From 5f2a3bb1a1eefe94342ebd4d211d2b29cae6789c Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 25 Jun 2026 00:17:18 +0200 Subject: [PATCH] feat: ingest RSI canary proof receipts --- COMMANDS.md | 3 +++ src/fable5/rsi-reconcile.test.ts | 21 ++++++++++++++++++- src/fable5/rsi-reconcile.ts | 20 ++++++++++++++++-- src/fable5/verify-cycle.test.ts | 35 +++++++++++++++++++++++++++++++- src/fable5/verify-cycle.ts | 21 ++++++++++++++++--- src/index.ts | 11 ++++++---- 6 files changed, 100 insertions(+), 11 deletions(-) diff --git a/COMMANDS.md b/COMMANDS.md index ad0ad91..aca7b06 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -208,6 +208,7 @@ fable-agent plinius godmode "improve explanation quality" - `--host ` - `--port ` - `--out ` + - `--canary-proof ` - `--run ` - `factory verify-cycle` - `--host ` @@ -215,6 +216,8 @@ fable-agent plinius godmode "improve explanation quality" - `--skill ` - `--out ` - `--allow-dirty` + - `--canary-proof ` + - `--allow-reconciled-stale-factory` - `--run ` - `factory verify `: verify a target and emit an agentic attestation - `--out ` diff --git a/src/fable5/rsi-reconcile.test.ts b/src/fable5/rsi-reconcile.test.ts index 1085cf6..a07e24b 100644 --- a/src/fable5/rsi-reconcile.test.ts +++ b/src/fable5/rsi-reconcile.test.ts @@ -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: "" } }) }); diff --git a/src/fable5/rsi-reconcile.ts b/src/fable5/rsi-reconcile.ts index a2fcf4e..2d8f543 100644 --- a/src/fable5/rsi-reconcile.ts +++ b/src/fable5/rsi-reconcile.ts @@ -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`); diff --git a/src/fable5/verify-cycle.test.ts b/src/fable5/verify-cycle.test.ts index 2f77c65..1df1750 100644 --- a/src/fable5/verify-cycle.test.ts +++ b/src/fable5/verify-cycle.test.ts @@ -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"); diff --git a/src/fable5/verify-cycle.ts b/src/fable5/verify-cycle.ts index e004c75..c1143eb 100644 --- a/src/fable5/verify-cycle.ts +++ b/src/fable5/verify-cycle.ts @@ -10,6 +10,8 @@ export interface VerifyCycleOptions { skill?: string; out?: string; allowDirty?: boolean; + canaryProof?: string; + allowReconciledStaleFactory?: boolean; now?: Date; factoryCheck?: () => Promise; rsiRunner?: RsiReconcileOptions["runner"]; @@ -37,7 +39,7 @@ export async function verifyCycle(opts: VerifyCycleOptions): Promise 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; + 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) { 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) }; diff --git a/src/index.ts b/src/index.ts index a76549b..8cc1d1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1570,11 +1570,12 @@ factory .option("--host ", "Factory SSH host", "77.42.112.29") .option("--port ", "Factory SSH port", (v) => Number(v), 2222) .option("--out ", "Receipt output path") + .option("--canary-proof ", "Fresh rsi_canary degrade→patch→recover proof file") .option("--run ", "Emit RSI receipt event to .runs//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 ", "Skill test to verify", "rsi_canary") .option("--out ", "Receipt output path") .option("--allow-dirty", "Allow a dirty git worktree in the receipt") + .option("--canary-proof ", "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 ", "Emit verify-cycle receipt event to .runs//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}`);