feat: ingest RSI canary proof receipts

This commit is contained in:
artale 2026-06-25 00:17:18 +02:00
parent d5a865b230
commit 5f2a3bb1a1
6 changed files with 100 additions and 11 deletions

View File

@ -208,6 +208,7 @@ fable-agent plinius godmode "improve explanation quality"
- `--host <host>`
- `--port <n>`
- `--out <path>`
- `--canary-proof <path>`
- `--run <id>`
- `factory verify-cycle`
- `--host <host>`
@ -215,6 +216,8 @@ fable-agent plinius godmode "improve explanation quality"
- `--skill <name>`
- `--out <path>`
- `--allow-dirty`
- `--canary-proof <path>`
- `--allow-reconciled-stale-factory`
- `--run <id>`
- `factory verify <target>`: verify a target and emit an agentic attestation
- `--out <path>`

View File

@ -2,7 +2,7 @@ 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";
import { reconcileRsi, verifyCanaryProof } from "./rsi-reconcile.js";
const roots: string[] = [];
@ -46,6 +46,25 @@ describe("rsi reconcile", () => {
expect(receipt.decision).toBe("healthy");
});
it("marks healthy when a fresh canary proof receipt exists", () => {
const proof = tmpFile();
fs.writeFileSync(proof, [
"rsi_canary: degraded",
"STATUS_BEFORE:1",
"auto-patching 1 degraded skills",
"All patched successfully",
"rsi_canary: recovered",
"STATUS_PATCH:0",
"STATUS_AFTER:0",
].join("\n"));
expect(verifyCanaryProof(proof)).toBe(true);
const receipt = reconcileRsi({ host: "example", canaryProof: proof, runner: runner() });
expect(receipt.facts.auto_patch_proven).toBe(true);
expect(receipt.facts.canary_proof).toBe(proof);
expect(receipt.decision).toBe("healthy");
});
it("keeps healthy when only cron naming drifts", () => {
const receipt = reconcileRsi({ host: "example", runner: runner({ "skill_health.py|rsi-diagnosis": { status: 1, stdout: "" } }) });

View File

@ -6,6 +6,7 @@ export interface RsiReconcileOptions {
host: string;
port?: number;
out?: string;
canaryProof?: string;
now?: Date;
runner?: (command: string, args: string[]) => { status: number | null; stdout: string; stderr: string };
}
@ -22,6 +23,7 @@ export interface RsiReconcileReceipt {
factory_watcher_active: boolean;
deploy_route_present: boolean;
auto_patch_proven: boolean;
canary_proof?: string;
};
decision: "healthy" | "healthy_but_autopatch_unproven" | "degraded";
}
@ -37,16 +39,18 @@ export function reconcileRsi(opts: RsiReconcileOptions): RsiReconcileReceipt {
check("deploy_route", port, opts.host, "code=\"$(curl -sS -m 5 -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8099/deploy)\" && (test \"$code\" = \"401\" || test \"$code\" = \"403\") && echo present", runner),
];
const latest = checks.find((c) => c.name === "latest_rsi")?.stdout ?? "";
const canaryProofOk = opts.canaryProof ? verifyCanaryProof(opts.canaryProof) : false;
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: /auto-patching/.test(latest) && /All patched/.test(latest),
auto_patch_proven: (/auto-patching/.test(latest) && /All patched/.test(latest)) || canaryProofOk,
...(canaryProofOk ? { canary_proof: opts.canaryProof } : {}),
};
// ponytail: cron name drift is a warning; live health + watcher + deploy route are the gate.
const coreHealthy = facts.skill_health_present && /100%|65\/65/.test(facts.latest_score ?? "") && facts.factory_watcher_active && facts.deploy_route_present;
const coreHealthy = facts.skill_health_present && /100%|65\/65|66\/66/.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,
@ -59,6 +63,18 @@ export function reconcileRsi(opts: RsiReconcileOptions): RsiReconcileReceipt {
return receipt;
}
export function verifyCanaryProof(file: string): boolean {
if (!fs.existsSync(file)) return false;
const text = fs.readFileSync(file, "utf-8");
return /rsi_canary: degraded/.test(text)
&& /STATUS_BEFORE:1/.test(text)
&& /auto-patching 1 degraded skills/.test(text)
&& /All patched successfully/.test(text)
&& /rsi_canary: recovered/.test(text)
&& /STATUS_PATCH:0/.test(text)
&& /STATUS_AFTER:0/.test(text);
}
export function writeRsiReconcileReceipt(file: string, receipt: RsiReconcileReceipt): void {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);

View File

@ -18,7 +18,7 @@ function runner(command: string, args: string[]) {
if (full === "git status --short") return { status: 0, stdout: " M src/file.ts\n", stderr: "" };
if (full.includes("crontab")) return { status: 0, stdout: "0 */6 * * * skill_health.py\n", stderr: "" };
if (full.includes("test -f /tmp/skill_health.py")) return { status: 0, stdout: "present\n", stderr: "" };
if (full.includes("tail -160 /tmp/rsi-diagnosis.log")) return { status: 0, stdout: "RSI: 65/65 (100%)\nAll patched successfully\n", stderr: "" };
if (full.includes("tail -160 /tmp/rsi-diagnosis.log")) return { status: 0, stdout: "RSI: 65/65 (100%)\nauto-patching 1 degraded skills\nAll patched successfully\n", stderr: "" };
if (full.includes("systemctl is-active factory-watcher")) return { status: 0, stdout: "active\n", stderr: "" };
if (full.includes("127.0.0.1:8099/deploy")) return { status: 0, stdout: "present\n", stderr: "" };
if (full.includes("skill_health.py --auto-patch")) return { status: 0, stdout: "All healthy — ZTE idle\n", stderr: "" };
@ -49,6 +49,39 @@ describe("verifyCycle", () => {
expect(JSON.parse(fs.readFileSync(out, "utf-8")).result).toBe("passed");
});
it("passes reconciled stale factory only with explicit opt-in and canary proof", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "verify-cycle-proof-"));
const proof = path.join(dir, "canary.txt");
fs.writeFileSync(proof, [
"rsi_canary: degraded",
"STATUS_BEFORE:1",
"auto-patching 1 degraded skills",
"All patched successfully",
"rsi_canary: recovered",
"STATUS_PATCH:0",
"STATUS_AFTER:0",
].join("\n"));
const staleFactory = {
...factoryOk,
factory: {
status: "stale",
state: "STALE_FACTORY_STATE",
live_containers: 15,
catalog_containers: 25,
truth: "live docker ps wins over catalog/memory when counts disagree",
},
};
const blocked = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", allowDirty: true, canaryProof: proof, factoryCheck: async () => staleFactory, runner, rsiRunner: runner });
expect(blocked.result).toBe("failed");
expect(blocked.factory_ready).toBe(false);
const allowed = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", allowDirty: true, allowReconciledStaleFactory: true, canaryProof: proof, factoryCheck: async () => staleFactory, runner, rsiRunner: runner });
expect(allowed.result).toBe("passed");
expect(allowed.factory_ready).toBe(true);
expect(allowed.rsi.facts.canary_proof).toBe(proof);
});
it("fails dirty worktrees unless explicitly allowed", async () => {
const receipt = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", factoryCheck: async () => factoryOk, runner, rsiRunner: runner });
expect(receipt.result).toBe("failed");

View File

@ -10,6 +10,8 @@ export interface VerifyCycleOptions {
skill?: string;
out?: string;
allowDirty?: boolean;
canaryProof?: string;
allowReconciledStaleFactory?: boolean;
now?: Date;
factoryCheck?: () => Promise<FactoryCheckResult>;
rsiRunner?: RsiReconcileOptions["runner"];
@ -37,7 +39,7 @@ export async function verifyCycle(opts: VerifyCycleOptions): Promise<VerifyCycle
const port = opts.port ?? 2222;
const created = (opts.now ?? new Date()).toISOString();
const factory = await (opts.factoryCheck ?? checkFactory)();
const rsi = reconcileRsi({ host: opts.host, port, runner: opts.rsiRunner, now: opts.now });
const rsi = reconcileRsi({ host: opts.host, port, runner: opts.rsiRunner, canaryProof: opts.canaryProof, now: opts.now });
const commands = [
local("commit", "git", ["rev-parse", "HEAD"], runner),
local("changed_files", "git", ["status", "--short"], runner),
@ -47,13 +49,14 @@ export async function verifyCycle(opts: VerifyCycleOptions): Promise<VerifyCycle
const commit = commands.find((c) => c.name === "commit")?.stdout.trim() || "unknown";
const changed = (commands.find((c) => c.name === "changed_files")?.stdout ?? "").split(/\r?\n/).filter(Boolean);
const cleanEnough = opts.allowDirty || changed.length === 0;
const passed = cleanEnough && factoryGateReady(factory) && rsi.decision !== "degraded" && commands.every((c) => c.status === 0);
const factoryReady = factoryGateReady(factory) || (opts.allowReconciledStaleFactory === true && factoryStaleButReconciled(factory));
const passed = cleanEnough && factoryReady && rsi.decision !== "degraded" && commands.every((c) => c.status === 0);
const receipt: VerifyCycleReceipt = {
created_at: created,
commit_sha: commit,
skill,
deploy: "8099/deploy gated",
factory_ready: factoryGateReady(factory),
factory_ready: factoryReady,
factory,
rsi,
commands,
@ -69,6 +72,18 @@ export function writeVerifyCycleReceipt(file: string, receipt: VerifyCycleReceip
fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`);
}
function factoryStaleButReconciled(result: FactoryCheckResult): boolean {
const factory = result.factory as Record<string, unknown>;
return result.factoryAvailable
&& result.deployAvailable
&& result.deploy.status === "ok"
&& factory.status === "stale"
&& factory.state === "STALE_FACTORY_STATE"
&& typeof factory.live_containers === "number"
&& typeof factory.catalog_containers === "number"
&& factory.truth === "live docker ps wins over catalog/memory when counts disagree";
}
function local(name: string, command: string, args: string[], runner: NonNullable<VerifyCycleOptions["runner"]>) {
const r = runner(command, args);
return { name, command: [command, ...args].join(" "), status: r.status, stdout: r.stdout.slice(0, 4000), stderr: r.stderr.slice(0, 1000) };

View File

@ -1570,11 +1570,12 @@ factory
.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")
.option("--canary-proof <path>", "Fresh rsi_canary degrade→patch→recover proof file")
.option("--run <id>", "Emit RSI receipt event to .runs/<id>/channel.jsonl")
.action(async (opts: { host?: string; port?: number; out?: string; run?: string }) => {
.action(async (opts: { host?: string; port?: number; out?: string; canaryProof?: string; run?: 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 });
const receipt = reconcileRsi({ host: opts.host ?? "77.42.112.29", port: opts.port, canaryProof: opts.canaryProof, out });
console.log(JSON.stringify(receipt, null, 2));
console.log(`\n Receipt: ${out}\n`);
await emitRunEvent(opts.run, { source: "gate", type: receipt.decision === "degraded" ? "error" : "receipt", data: { kind: "factory-rsi", receipt, path: out } });
@ -1589,11 +1590,13 @@ factory
.option("--skill <name>", "Skill test to verify", "rsi_canary")
.option("--out <path>", "Receipt output path")
.option("--allow-dirty", "Allow a dirty git worktree in the receipt")
.option("--canary-proof <path>", "Fresh rsi_canary degrade→patch→recover proof file")
.option("--allow-reconciled-stale-factory", "Allow stale factory status only when live-vs-catalog reconciliation fields are present")
.option("--run <id>", "Emit verify-cycle receipt event to .runs/<id>/channel.jsonl")
.action(async (opts: { host?: string; port?: number; skill?: string; out?: string; allowDirty?: boolean; run?: string }) => {
.action(async (opts: { host?: string; port?: number; skill?: string; out?: string; allowDirty?: boolean; canaryProof?: string; allowReconciledStaleFactory?: boolean; run?: string }) => {
const { verifyCycle } = await import("./fable5/verify-cycle.js");
const out = opts.out ?? path.join(".fable", "verify-cycle", `${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
const receipt = await verifyCycle({ host: opts.host ?? "77.42.112.29", port: opts.port, skill: opts.skill, out, allowDirty: opts.allowDirty });
const receipt = await verifyCycle({ host: opts.host ?? "77.42.112.29", port: opts.port, skill: opts.skill, out, allowDirty: opts.allowDirty, canaryProof: opts.canaryProof, allowReconciledStaleFactory: opts.allowReconciledStaleFactory });
console.log(`${receipt.result.toUpperCase()} factory verify-cycle`);
console.log(`receipt: ${out}`);
console.log(`deploy: ${receipt.deploy}`);