From 3edc35a7ead1b7e1661aba513761f46dc5b0937d Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sat, 2 May 2026 10:52:52 +0200 Subject: [PATCH] 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) --- .../extensions/sf/bootstrap/register-hooks.ts | 8 + .../sf/tests/uok-parity-summary.test.ts | 226 ++++++++++++++++++ .../extensions/sf/uok-parity-summary.ts | 59 +++++ .../extensions/sf/uok/parity-report.ts | 9 +- .../extensions/sf/verification-gate.ts | 11 +- 5 files changed, 307 insertions(+), 6 deletions(-) create mode 100644 src/resources/extensions/sf/tests/uok-parity-summary.test.ts create mode 100644 src/resources/extensions/sf/uok-parity-summary.ts diff --git a/src/resources/extensions/sf/bootstrap/register-hooks.ts b/src/resources/extensions/sf/bootstrap/register-hooks.ts index e4b143bbd..f3141bf8f 100644 --- a/src/resources/extensions/sf/bootstrap/register-hooks.ts +++ b/src/resources/extensions/sf/bootstrap/register-hooks.ts @@ -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"); diff --git a/src/resources/extensions/sf/tests/uok-parity-summary.test.ts b/src/resources/extensions/sf/tests/uok-parity-summary.test.ts new file mode 100644 index 000000000..84bbed4d8 --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-parity-summary.test.ts @@ -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; + totalEvents: number; + paths: Record; +}> = {}) { + 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); + } +}); diff --git a/src/resources/extensions/sf/uok-parity-summary.ts b/src/resources/extensions/sf/uok-parity-summary.ts new file mode 100644 index 000000000..e136be05c --- /dev/null +++ b/src/resources/extensions/sf/uok-parity-summary.ts @@ -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; + statuses: Record; + 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 /.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, + pi?: PiLogInfo, +): Promise { + 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.`); + } +} diff --git a/src/resources/extensions/sf/uok/parity-report.ts b/src/resources/extensions/sf/uok/parity-report.ts index 1131c4ea6..63eb95ea9 100644 --- a/src/resources/extensions/sf/uok/parity-report.ts +++ b/src/resources/extensions/sf/uok/parity-report.ts @@ -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, 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; } diff --git a/src/resources/extensions/sf/verification-gate.ts b/src/resources/extensions/sf/verification-gate.ts index c026a8e7f..03762d9c2 100644 --- a/src/resources/extensions/sf/verification-gate.ts +++ b/src/resources/extensions/sf/verification-gate.ts @@ -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); }