feat: ingest RSI canary proof receipts
This commit is contained in:
parent
d5a865b230
commit
5f2a3bb1a1
|
|
@ -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>`
|
||||
|
|
|
|||
|
|
@ -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: "" } }) });
|
||||
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
11
src/index.ts
11
src/index.ts
|
|
@ -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}`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue