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:
parent
75a4f35ea5
commit
3edc35a7ea
5 changed files with 307 additions and 6 deletions
|
|
@ -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");
|
||||
|
|
|
|||
226
src/resources/extensions/sf/tests/uok-parity-summary.test.ts
Normal file
226
src/resources/extensions/sf/tests/uok-parity-summary.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
59
src/resources/extensions/sf/uok-parity-summary.ts
Normal file
59
src/resources/extensions/sf/uok-parity-summary.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue