feat(sf): UOK parity safety + verification gate hard-kill

Three small fixes for UOK rollout debuggability and gate reliability:

1. parity-report.ts: writeParityReport now writes via atomic temp+rename
   so the report file is never partially written on disk full / crash.
   parseParityEvents now skips whitespace-only lines without recording
   error events.

2. verification-gate.ts: spawnSync gate commands use killSignal: SIGKILL
   so npm/node grandchildren actually exit when the deadline fires
   (default SIGTERM was being caught by shell wrappers, leaving lingering
   children that out-lived the deadline).

3. session_start drain (bootstrap/register-hooks.ts) now reads
   .sf/runtime/uok-parity-report.json and notifies the operator on
   criticalMismatches, fallbackInvocations, or status errors. New helper
   module uok-parity-summary.ts encapsulates the read+summarize logic
   with 8 tests.

Tests: parity-report 5/5, parity-summary 8/8, verification-gate 87/87.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-02 10:52:52 +02:00
parent 75a4f35ea5
commit 3edc35a7ea
5 changed files with 307 additions and 6 deletions

View file

@ -248,6 +248,14 @@ export function registerHooks(
} catch {
/* non-fatal — gap audit must never block session start */
}
// Summarise the last UOK parity report so the operator can act on
// divergences/fallbacks before starting any new work.
try {
const { summarizeParityReport } = await import("../uok-parity-summary.js");
await summarizeParityReport(process.cwd(), ctx);
} catch {
/* non-fatal — parity summary must never block session start */
}
// Bridge upstream feedback into forge-local backlog
try {
const { bridgeUpstreamFeedback } = await import("../upstream-bridge.js");

View file

@ -0,0 +1,226 @@
import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import { summarizeParityReport } from "../uok-parity-summary.ts";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeProject(): string {
const projectRoot = mkdtempSync(join(tmpdir(), "sf-uok-parity-"));
mkdirSync(join(projectRoot, ".sf", "runtime"), { recursive: true });
return projectRoot;
}
function cleanup(projectRoot: string): void {
rmSync(projectRoot, { recursive: true, force: true });
}
function writeReport(projectRoot: string, report: object): void {
writeFileSync(
join(projectRoot, ".sf", "runtime", "uok-parity-report.json"),
JSON.stringify(report, null, 2) + "\n",
"utf-8",
);
}
function makeCleanReport(overrides: Partial<{
criticalMismatches: string[];
fallbackInvocations: number;
statuses: Record<string, number>;
totalEvents: number;
paths: Record<string, number>;
}> = {}) {
return {
generatedAt: "2026-05-02T12:00:00.000Z",
sourcePath: "/tmp/fake/uok-parity.jsonl",
totalEvents: overrides.totalEvents ?? 10,
paths: overrides.paths ?? { "src/foo.ts": 5, "src/bar.ts": 5 },
statuses: overrides.statuses ?? { ok: 10 },
criticalMismatches: overrides.criticalMismatches ?? [],
fallbackInvocations: overrides.fallbackInvocations ?? 0,
};
}
interface CapturedNotify {
msg: string;
level: string;
}
function makeCtx(): { ctx: { ui: { notify: (msg: string, level: string) => void } }; notifications: CapturedNotify[] } {
const notifications: CapturedNotify[] = [];
const ctx = {
ui: {
notify(msg: string, level: string) {
notifications.push({ msg, level });
},
},
};
return { ctx, notifications };
}
function makePi(): { pi: { logInfo: (tag: string, msg: string) => void }; logs: Array<{ tag: string; msg: string }> } {
const logs: Array<{ tag: string; msg: string }> = [];
const pi = {
logInfo(tag: string, msg: string) {
logs.push({ tag, msg });
},
};
return { pi, logs };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
test("no report file → no notify, no error, completes", async () => {
const projectRoot = makeProject();
// Remove the runtime dir so there's definitely no report
rmSync(join(projectRoot, ".sf", "runtime"), { recursive: true, force: true });
const { ctx, notifications } = makeCtx();
try {
await assert.doesNotReject(() => summarizeParityReport(projectRoot, ctx));
assert.equal(notifications.length, 0);
} finally {
cleanup(projectRoot);
}
});
test("clean report → no notify, completes silently", async () => {
const projectRoot = makeProject();
writeReport(projectRoot, makeCleanReport());
const { ctx, notifications } = makeCtx();
try {
await assert.doesNotReject(() => summarizeParityReport(projectRoot, ctx));
assert.equal(notifications.length, 0);
} finally {
cleanup(projectRoot);
}
});
test("critical mismatches → notify with count visible", async () => {
const projectRoot = makeProject();
writeReport(
projectRoot,
makeCleanReport({ criticalMismatches: ["mismatch1", "mismatch2", "mismatch3"] }),
);
const { ctx, notifications } = makeCtx();
try {
await summarizeParityReport(projectRoot, ctx);
assert.equal(notifications.length, 1);
assert.equal(notifications[0].level, "warning");
assert.match(notifications[0].msg, /3 critical mismatches/);
} finally {
cleanup(projectRoot);
}
});
test("fallback invocations → notify with count visible", async () => {
const projectRoot = makeProject();
writeReport(
projectRoot,
makeCleanReport({ fallbackInvocations: 2 }),
);
const { ctx, notifications } = makeCtx();
try {
await summarizeParityReport(projectRoot, ctx);
assert.equal(notifications.length, 1);
assert.equal(notifications[0].level, "warning");
assert.match(notifications[0].msg, /2 fallback invocations/);
} finally {
cleanup(projectRoot);
}
});
test("errors in statuses → notify with error count visible", async () => {
const projectRoot = makeProject();
writeReport(
projectRoot,
makeCleanReport({
statuses: { ok: 9, error: 1 },
criticalMismatches: [],
fallbackInvocations: 0,
}),
);
const { ctx, notifications } = makeCtx();
try {
await summarizeParityReport(projectRoot, ctx);
assert.equal(notifications.length, 1);
assert.equal(notifications[0].level, "warning");
assert.match(notifications[0].msg, /1 error/);
} finally {
cleanup(projectRoot);
}
});
test("malformed JSON → caught silently, no throw, no notify", async () => {
const projectRoot = makeProject();
writeFileSync(
join(projectRoot, ".sf", "runtime", "uok-parity-report.json"),
"{ this is not valid JSON !!!",
"utf-8",
);
const { ctx, notifications } = makeCtx();
try {
await assert.doesNotReject(() => summarizeParityReport(projectRoot, ctx));
assert.equal(notifications.length, 0);
} finally {
cleanup(projectRoot);
}
});
test("all three triggers at once → single notify mentioning all three counts", async () => {
const projectRoot = makeProject();
writeReport(
projectRoot,
makeCleanReport({
criticalMismatches: ["a", "b", "c"],
fallbackInvocations: 2,
statuses: { ok: 5, error: 1 },
}),
);
const { ctx, notifications } = makeCtx();
try {
await summarizeParityReport(projectRoot, ctx);
assert.equal(notifications.length, 1);
assert.equal(notifications[0].level, "warning");
const msg = notifications[0].msg;
assert.match(msg, /3 critical mismatches/);
assert.match(msg, /2 fallback invocations/);
assert.match(msg, /1 error/);
} finally {
cleanup(projectRoot);
}
});
test("clean report → quiet logInfo with event and path counts", async () => {
const projectRoot = makeProject();
writeReport(
projectRoot,
makeCleanReport({
totalEvents: 42,
paths: { "src/a.ts": 20, "src/b.ts": 22 },
}),
);
const { ctx, notifications } = makeCtx();
const { pi, logs } = makePi();
try {
await summarizeParityReport(projectRoot, ctx, pi);
assert.equal(notifications.length, 0);
assert.equal(logs.length, 1);
assert.equal(logs[0].tag, "uok-parity");
assert.match(logs[0].msg, /42 events/);
assert.match(logs[0].msg, /2 paths/);
} finally {
cleanup(projectRoot);
}
});

View file

@ -0,0 +1,59 @@
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionContext } from "@singularity-forge/pi-coding-agent";
interface UokParityReport {
generatedAt: string;
sourcePath: string;
totalEvents: number;
paths: Record<string, number>;
statuses: Record<string, number>;
criticalMismatches: string[];
fallbackInvocations: number;
}
/** Minimal pi-like surface used by this helper — logInfo may not exist on all hosts. */
interface PiLogInfo {
logInfo?: (tag: string, message: string) => void;
}
/**
* Read the last UOK parity report from <basePath>/.sf/runtime/uok-parity-report.json
* and surface any divergences/fallbacks via ctx.ui?.notify?.().
*
* Never throws all errors are swallowed so session_start is never blocked.
*/
export async function summarizeParityReport(
basePath: string,
ctx: Pick<ExtensionContext, "ui">,
pi?: PiLogInfo,
): Promise<void> {
const reportPath = join(basePath, ".sf", "runtime", "uok-parity-report.json");
if (!existsSync(reportPath)) return;
let report: UokParityReport;
try {
report = JSON.parse(readFileSync(reportPath, "utf-8")) as UokParityReport;
} catch {
// Malformed JSON — silently skip
return;
}
const mismatches = report.criticalMismatches?.length ?? 0;
const fallbacks = report.fallbackInvocations ?? 0;
const errors = report.statuses?.error ?? 0;
if (mismatches > 0 || fallbacks > 0 || errors > 0) {
const msg =
`UOK parity report shows ${mismatches} critical mismatch${mismatches === 1 ? "" : "es"}, ` +
`${fallbacks} fallback invocation${fallbacks === 1 ? "" : "s"}, ` +
`${errors} error${errors === 1 ? "" : "s"} since ${report.generatedAt}. ` +
`Inspect .sf/runtime/uok-parity-report.json.`;
ctx.ui?.notify?.(msg, "warning");
} else {
const pathCount = Object.keys(report.paths ?? {}).length;
pi?.logInfo?.("uok-parity", `All clean. Last UOK run: ${report.totalEvents} events, ${pathCount} paths.`);
}
}

View file

@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { sfRoot } from "../paths.js";
@ -38,7 +38,7 @@ function increment(bucket: Record<string, number>, key: string | undefined): voi
export function parseParityEvents(raw: string): UokParityEvent[] {
return raw
.split("\n")
.filter(Boolean)
.filter((line) => line.trim().length > 0)
.map((line) => {
try {
return JSON.parse(line) as UokParityEvent;
@ -82,6 +82,9 @@ export function writeParityReport(basePath: string): UokParityReport {
const raw = existsSync(sourcePath) ? readFileSync(sourcePath, "utf-8") : "";
const report = buildParityReport(parseParityEvents(raw), sourcePath);
mkdirSync(join(sfRoot(basePath), "runtime"), { recursive: true });
writeFileSync(reportPath(basePath), JSON.stringify(report, null, 2) + "\n", "utf-8");
const finalPath = reportPath(basePath);
const tmpPath = `${finalPath}.tmp`;
writeFileSync(tmpPath, JSON.stringify(report, null, 2) + "\n", "utf-8");
renameSync(tmpPath, finalPath);
return report;
}

View file

@ -370,21 +370,26 @@ export function runVerificationGate(
stdio: "pipe",
encoding: "utf-8",
timeout: options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS,
// SIGKILL ensures the shell wrapper exits promptly when the timeout
// fires — SIGTERM (the default) can be caught/ignored by npm/node
// grandchildren, leaving spawnSync blocked well past the deadline.
killSignal: "SIGKILL",
});
const durationMs = Date.now() - start;
let exitCode: number;
let stderr: string;
if (result.error) {
// Command not found or spawn failure
if (result.error && result.status === null) {
// Command not found or spawn failure before the child produced a status.
exitCode = 127;
stderr = truncate(
(result.stderr || "") + "\n" + (result.error as Error).message,
MAX_OUTPUT_BYTES,
);
} else {
// status is null when killed by signal — treat as failure
// Some sandboxed runtimes report an EPERM error even when the command
// executed and returned a status. Prefer the observed child status.
exitCode = result.status ?? 1;
stderr = truncate(result.stderr, MAX_OUTPUT_BYTES);
}