feat: add RSI reconcile receipts

This commit is contained in:
artale 2026-06-18 03:10:23 +02:00
parent 933f3c8974
commit 72578ae7f7
5 changed files with 162 additions and 0 deletions

View File

@ -170,6 +170,10 @@ fable-agent plinius godmode "improve explanation quality"
- `factory check`
- `factory capabilities`
- `--file <path>`
- `factory rsi`
- `--host <host>`
- `--port <n>`
- `--out <path>`
- `factory deploy <command>`
- `--token <token>`
- `--gate-receipt <path>`

View File

@ -50,6 +50,9 @@ export type { CapabilityProbeResult, CapabilityReport, FactoryCapabilityService
export { deployToFactory, isAllowedFactoryCommand, writeFactoryDeployReceipt } from "./factory-deploy.js";
export type { FactoryDeployOptions, FactoryDeployReceipt } from "./factory-deploy.js";
export { reconcileRsi, writeRsiReconcileReceipt } from "./rsi-reconcile.js";
export type { RsiReconcileOptions, RsiReconcileReceipt } from "./rsi-reconcile.js";
export { FLUE_INTEROP_ROWS, formatFlueInteropReport, summarizeFlueInterop } from "./flue-interop.js";
export type { FlueInteropRow, FlueInteropStatus } from "./flue-interop.js";

View File

@ -0,0 +1,62 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { reconcileRsi } from "./rsi-reconcile.js";
const roots: string[] = [];
afterEach(() => {
for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
function tmpFile(): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "fable-rsi-reconcile-"));
roots.push(root);
return path.join(root, "receipt.json");
}
function runner(overrides: Record<string, { status: number; stdout: string; stderr?: string }> = {}) {
return (_command: string, args: string[]) => {
const script = args.at(-1) ?? "";
const hit = Object.entries(overrides).find(([needle]) => script.includes(needle))?.[1];
if (hit) return { status: hit.status, stdout: hit.stdout, stderr: hit.stderr ?? "" };
if (script.includes("skill_health.py|rsi-diagnosis")) return { status: 0, stdout: "0 */6 * * * docker cp /tmp/skill_health.py hermes:/tmp/skill_health.py\n", stderr: "" };
if (script.includes("test -f /tmp/skill_health.py")) return { status: 0, stdout: "present\n", stderr: "" };
if (script.includes("tail -160")) return { status: 0, stdout: "RSI: 65/65 (100%)\nAll healthy — ZTE idle\n", stderr: "" };
if (script.includes("systemctl is-active")) return { status: 0, stdout: "active\n", stderr: "" };
if (script.includes("/deploy")) return { status: 0, stdout: "present\n", stderr: "" };
return { status: 1, stdout: "", stderr: "unexpected" };
};
}
describe("rsi reconcile", () => {
it("marks healthy but autopatch unproven when all health checks pass", () => {
const receipt = reconcileRsi({ host: "example", runner: runner(), now: new Date("2026-06-18T00:00:00Z") });
expect(receipt.facts.cron_present).toBe(true);
expect(receipt.facts.latest_score).toBe("65/65 (100%)");
expect(receipt.decision).toBe("healthy_but_autopatch_unproven");
});
it("marks healthy when auto-patch evidence exists", () => {
const receipt = reconcileRsi({ host: "example", runner: runner({ "tail -160": { status: 0, stdout: "RSI: 65/65 (100%)\nauto-patching 1 degraded skills\nAll patched successfully\n" } }) });
expect(receipt.facts.auto_patch_proven).toBe(true);
expect(receipt.decision).toBe("healthy");
});
it("marks degraded when cron is missing", () => {
const receipt = reconcileRsi({ host: "example", runner: runner({ "skill_health.py|rsi-diagnosis": { status: 1, stdout: "" } }) });
expect(receipt.facts.cron_present).toBe(false);
expect(receipt.decision).toBe("degraded");
});
it("writes a receipt", () => {
const out = tmpFile();
reconcileRsi({ host: "example", out, runner: runner() });
expect(fs.readFileSync(out, "utf-8")).toContain("healthy_but_autopatch_unproven");
});
});

View File

@ -0,0 +1,78 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { spawnSync } from "node:child_process";
export interface RsiReconcileOptions {
host: string;
port?: number;
out?: string;
now?: Date;
runner?: (command: string, args: string[]) => { status: number | null; stdout: string; stderr: string };
}
export interface RsiReconcileReceipt {
created_at: string;
host: string;
port: number;
checks: Array<{ name: string; ok: boolean; status: number | null; stdout: string; stderr: string }>;
facts: {
cron_present: boolean;
skill_health_present: boolean;
latest_score?: string;
factory_watcher_active: boolean;
deploy_route_present: boolean;
auto_patch_proven: boolean;
};
decision: "healthy" | "healthy_but_autopatch_unproven" | "degraded";
}
export function reconcileRsi(opts: RsiReconcileOptions): RsiReconcileReceipt {
const port = opts.port ?? 2222;
const runner = opts.runner ?? runCommand;
const checks = [
check("cron", port, opts.host, "((crontab -l 2>/dev/null || true); (crontab -u goku -l 2>/dev/null || true)) | grep -E 'skill_health.py|rsi-diagnosis'", runner),
check("skill_health", port, opts.host, "test -f /tmp/skill_health.py && echo present", runner),
check("latest_rsi", port, opts.host, "tail -160 /tmp/rsi-diagnosis.log 2>/dev/null | grep -E 'RSI:|All healthy|auto-patching|Rolled back|All patched'", runner),
check("factory_watcher", port, opts.host, "systemctl is-active factory-watcher.service 2>/dev/null", runner),
check("deploy_route", port, opts.host, "test \"$(curl -sS -m 5 -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8099/deploy)\" = \"401\" && echo present", runner),
];
const latest = checks.find((c) => c.name === "latest_rsi")?.stdout ?? "";
const facts = {
cron_present: isOk("cron", checks),
skill_health_present: isOk("skill_health", checks),
latest_score: latest.match(/RSI:\s*([^\n]+)/)?.[1]?.trim(),
factory_watcher_active: /active/.test(checks.find((c) => c.name === "factory_watcher")?.stdout ?? ""),
deploy_route_present: isOk("deploy_route", checks),
auto_patch_proven: /All patched|auto-patching/.test(latest),
};
const coreHealthy = facts.cron_present && facts.skill_health_present && /100%|65\/65/.test(facts.latest_score ?? "") && facts.factory_watcher_active && facts.deploy_route_present;
const receipt: RsiReconcileReceipt = {
created_at: (opts.now ?? new Date()).toISOString(),
host: opts.host,
port,
checks,
facts,
decision: coreHealthy ? (facts.auto_patch_proven ? "healthy" : "healthy_but_autopatch_unproven") : "degraded",
};
if (opts.out) writeRsiReconcileReceipt(opts.out, receipt);
return receipt;
}
export function writeRsiReconcileReceipt(file: string, receipt: RsiReconcileReceipt): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
}
function check(name: string, port: number, host: string, script: string, runner: NonNullable<RsiReconcileOptions["runner"]>) {
const r = runner("ssh", ["-p", String(port), `root@${host}`, script]);
return { name, ok: r.status === 0, status: r.status, stdout: r.stdout.slice(0, 4000), stderr: r.stderr.slice(0, 1000) };
}
function isOk(name: string, checks: RsiReconcileReceipt["checks"]): boolean {
return checks.find((c) => c.name === name)?.ok ?? false;
}
function runCommand(command: string, args: string[]) {
const result = spawnSync(command, args, { encoding: "utf-8" });
return { status: result.status, stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}

View File

@ -1452,6 +1452,21 @@ factory
if (!report.requiredOk) process.exit(1);
});
factory
.command("rsi")
.description("Reconcile live RSI/self-improvement evidence into a receipt")
.option("--host <host>", "Factory SSH host", "77.42.112.29")
.option("--port <n>", "Factory SSH port", (v) => Number(v), 2222)
.option("--out <path>", "Receipt output path")
.action(async (opts: { host?: string; port?: number; out?: string }) => {
const { reconcileRsi } = await import("./fable5/rsi-reconcile.js");
const out = opts.out ?? path.join(".fable", "rsi", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
const receipt = reconcileRsi({ host: opts.host ?? "77.42.112.29", port: opts.port, out });
console.log(JSON.stringify(receipt, null, 2));
console.log(`\n Receipt: ${out}\n`);
if (receipt.decision === "degraded") process.exit(1);
});
factory
.command("deploy <command>")
.description("Post an approved command to the factory deploy webhook")