From 4d2266e57db75c626213928447d713ebd683f4f0 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 17 May 2026 14:35:40 +0200 Subject: [PATCH] fix: consolidate loop supervision gates --- .../extensions/sf/auto/phases-dispatch.js | 2 +- .../extensions/sf/auto/phases-pre-dispatch.js | 6 +- src/resources/extensions/sf/engine-types.js | 18 ++ .../extensions/sf/supervision/loop-signals.js | 106 ++++++ .../sf/tests/gate-registry.test.mjs | 105 ++++++ .../tests/gate-type-enum-validator.test.mjs | 127 ++++++++ .../extensions/sf/tests/loop-signals.test.mjs | 65 ++++ .../sf/tests/no-execution-gate-type.test.mjs | 20 ++ .../tests/uok-parity-exit-guarantee.test.mjs | 302 ++++++++++++++++++ .../extensions/sf/uok/auto-dispatch.js | 2 +- .../extensions/sf/uok/auto-runaway-guard.js | 16 +- .../sf/uok/gate-registry-bootstrap.js | 23 ++ .../extensions/sf/uok/gate-registry.js | 53 +++ .../extensions/sf/uok/gate-runner.js | 6 + src/resources/extensions/sf/uok/kernel.ts | 72 +++++ .../extensions/sf/uok/parity-report.js | 6 +- 16 files changed, 921 insertions(+), 8 deletions(-) create mode 100644 src/resources/extensions/sf/supervision/loop-signals.js create mode 100644 src/resources/extensions/sf/tests/gate-registry.test.mjs create mode 100644 src/resources/extensions/sf/tests/gate-type-enum-validator.test.mjs create mode 100644 src/resources/extensions/sf/tests/loop-signals.test.mjs create mode 100644 src/resources/extensions/sf/tests/no-execution-gate-type.test.mjs create mode 100644 src/resources/extensions/sf/tests/uok-parity-exit-guarantee.test.mjs create mode 100644 src/resources/extensions/sf/uok/gate-registry-bootstrap.js create mode 100644 src/resources/extensions/sf/uok/gate-registry.js diff --git a/src/resources/extensions/sf/auto/phases-dispatch.js b/src/resources/extensions/sf/auto/phases-dispatch.js index 29e959fd5..a78c0e374 100644 --- a/src/resources/extensions/sf/auto/phases-dispatch.js +++ b/src/resources/extensions/sf/auto/phases-dispatch.js @@ -243,7 +243,7 @@ export async function runDispatch(ic, preData, loopState) { if (!gate.proceed) { await runPreDispatchGate({ gateId: "uok-diagnostics-dispatch-gate", - gateType: "execution", + gateType: "verification", outcome: "manual-attention", failureClass: "manual-attention", rationale: "uok diagnostics blocked dispatch", diff --git a/src/resources/extensions/sf/auto/phases-pre-dispatch.js b/src/resources/extensions/sf/auto/phases-pre-dispatch.js index 511d5a610..51fd904f2 100644 --- a/src/resources/extensions/sf/auto/phases-pre-dispatch.js +++ b/src/resources/extensions/sf/auto/phases-pre-dispatch.js @@ -241,7 +241,7 @@ export async function runPreDispatch(ic, loopState) { if (!healthGate.proceed) { await runPreDispatchGate({ gateId: "pre-dispatch-health-gate", - gateType: "execution", + gateType: "policy", outcome: "manual-attention", failureClass: "manual-attention", rationale: "pre-dispatch health gate blocked dispatch", @@ -258,7 +258,7 @@ export async function runPreDispatch(ic, loopState) { } await runPreDispatchGate({ gateId: "pre-dispatch-health-gate", - gateType: "execution", + gateType: "policy", outcome: "pass", failureClass: "none", rationale: "pre-dispatch health gate passed", @@ -270,7 +270,7 @@ export async function runPreDispatch(ic, loopState) { } catch (e) { await runPreDispatchGate({ gateId: "pre-dispatch-health-gate", - gateType: "execution", + gateType: "policy", outcome: "manual-attention", failureClass: "manual-attention", rationale: "pre-dispatch health gate threw unexpectedly", diff --git a/src/resources/extensions/sf/engine-types.js b/src/resources/extensions/sf/engine-types.js index 3453ae7b7..4d7865617 100644 --- a/src/resources/extensions/sf/engine-types.js +++ b/src/resources/extensions/sf/engine-types.js @@ -24,6 +24,24 @@ const CUSTOMER_IMPACT_LEVELS = [ "customer-critical", ]; const GATE_OUTCOMES = ["pass", "fail", "retry", "manual-attention"]; + +/** + * UOK gate type enum per ADR-0075 line 20. + * + * Purpose: canonical list every gate.type field must match. Used by + * register() validation in uok/gate-runner.js. + */ +export const UOK_GATE_TYPES = Object.freeze([ + "security", + "policy", + "verification", + "learning", + "chaos", +]); + +export function isValidGateType(value) { + return typeof value === "string" && UOK_GATE_TYPES.includes(value); +} const FAILURE_CLASSES = [ "policy", "verification", diff --git a/src/resources/extensions/sf/supervision/loop-signals.js b/src/resources/extensions/sf/supervision/loop-signals.js new file mode 100644 index 000000000..a2906d424 --- /dev/null +++ b/src/resources/extensions/sf/supervision/loop-signals.js @@ -0,0 +1,106 @@ +/** + * loop-signals.js — normalized loop/stuck supervision signals. + * + * Purpose: give SF one contract for loop detectors, watchdogs, retry guards, + * and tool churn signals so the autonomous supervisor can decide from one + * shape instead of bespoke metadata branches. + * + * Consumer: autonomous unit supervision and detector adapters. + */ + +const VALID_SCOPES = new Set([ + "process", + "session", + "unit", + "tool", + "provider", + "artifact", +]); +const VALID_SEVERITIES = new Set(["info", "warning", "fail", "pause"]); +const VALID_ACTIONS = new Set([ + "continue", + "retry", + "switch-model", + "pause", + "block", + "recover", +]); + +function pick(value, allowed, fallback) { + return allowed.has(value) ? value : fallback; +} + +/** + * Build a normalized loop supervision signal. + * + * Purpose: preserve detector-specific evidence while making action, severity, + * scope, and unit identity machine-readable for the shared supervisor. + * + * Consumer: detector adapters and auto-runaway-guard. + */ +export function createLoopSignal(input = {}) { + const unit = + input.unit && typeof input.unit === "object" + ? { + type: String(input.unit.type ?? ""), + id: String(input.unit.id ?? ""), + } + : undefined; + return { + scope: pick(input.scope, VALID_SCOPES, "unit"), + kind: String(input.kind ?? "unknown-loop-signal"), + severity: pick(input.severity, VALID_SEVERITIES, "warning"), + ...(unit?.type && unit?.id ? { unit } : {}), + evidence: + input.evidence && typeof input.evidence === "object" + ? input.evidence + : { value: input.evidence ?? null }, + recommendedAction: pick(input.recommendedAction, VALID_ACTIONS, "pause"), + ...(input.message ? { message: String(input.message) } : {}), + ...(input.source ? { source: String(input.source) } : {}), + }; +} + +/** + * Convert a Wiggums detector sweep hit into a normalized loop signal. + * + * Purpose: let the periodic detector family feed the same supervisor contract + * as direct runaway and zero-progress checks. + * + * Consumer: auto-runaway-guard when consuming buffered detector sweep results. + */ +export function detectorSweepSignal(fired, unitType, unitId, now = Date.now()) { + return createLoopSignal({ + scope: "unit", + kind: `wiggums:${fired?.name ?? "unknown"}`, + severity: "fail", + unit: { type: unitType, id: unitId }, + evidence: { + detector: fired?.name ?? "unknown", + signature: fired?.signature ?? null, + failedAt: now, + }, + recommendedAction: "pause", + source: "periodic-detector-sweep", + }); +} + +/** + * Convert a zero-progress detector hit into a normalized loop signal. + * + * Purpose: make the strongest unit-churn signal available through the same + * contract as Wiggums detectors and future tool/provider loop producers. + * + * Consumer: auto-runaway-guard. + */ +export function zeroProgressSignal(input) { + return createLoopSignal({ + scope: "unit", + kind: "zero-progress", + severity: "fail", + unit: { type: input.unitType, id: input.unitId }, + evidence: input.evidence, + recommendedAction: "pause", + source: "zero-progress-detector", + }); +} diff --git a/src/resources/extensions/sf/tests/gate-registry.test.mjs b/src/resources/extensions/sf/tests/gate-registry.test.mjs new file mode 100644 index 000000000..c37001390 --- /dev/null +++ b/src/resources/extensions/sf/tests/gate-registry.test.mjs @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as GateRegistryModule from "../uok/gate-registry.js"; + +vi.mock("../uok/gate-runner.js", () => { + class UokGateRunner { + constructor() { + this.gates = new Map(); + } + + register(gate) { + if (!gate?.id || typeof gate.execute !== "function") { + throw new Error("UokGateRunner.register: invalid gate"); + } + this.gates.set(gate.id, gate); + } + + async run(gateId, ctx) { + const gate = this.gates.get(gateId); + if (!gate) throw new Error(`missing gate ${gateId}`); + return gate.execute(ctx); + } + } + + return { UokGateRunner }; +}); + +function stubGate(id, execute = async () => ({ passed: true, gateId: id })) { + return { + id, + type: "verification", + execute, + }; +} + +describe("UokGateRegistry", () => { + beforeEach(() => { + GateRegistryModule.resetGateRegistryForTests(); + }); + + it("register_has_list_when_two_gates_registered_returns_both_ids", () => { + const registry = new GateRegistryModule.UokGateRegistry(); + + registry.register(stubGate("alpha")); + registry.register(stubGate("beta")); + + expect(registry.has("alpha")).toBe(true); + expect(registry.has("beta")).toBe(true); + expect(registry.list()).toEqual(["alpha", "beta"]); + }); + + it("register_when_gate_missing_id_throws", () => { + const registry = new GateRegistryModule.UokGateRegistry(); + + expect(() => registry.register({ type: "verification" })).toThrow( + "gate.id required", + ); + }); + + it("run_when_gate_unknown_throws_with_gate_id", async () => { + const registry = new GateRegistryModule.UokGateRegistry(); + + await expect(registry.run("missing-gate")).rejects.toThrow("missing-gate"); + }); + + it("run_when_gate_registered_calls_execute_and_returns_gate_result_shape", async () => { + const registry = new GateRegistryModule.UokGateRegistry(); + const execute = vi.fn(async () => ({ passed: true, gateId: "alpha" })); + registry.register(stubGate("alpha", execute)); + + const result = await registry.run("alpha", { basePath: "/tmp/sf" }); + + expect(execute).toHaveBeenCalledWith({ basePath: "/tmp/sf" }); + expect(result).toEqual({ passed: true, gateId: "alpha" }); + }); + + it("singleton_when_reset_called_returns_new_instance", () => { + const first = GateRegistryModule.getGateRegistry(); + const second = GateRegistryModule.getGateRegistry(); + + GateRegistryModule.resetGateRegistryForTests(); + const third = GateRegistryModule.getGateRegistry(); + + expect(second).toBe(first); + expect(third).not.toBe(first); + }); + + it("run_when_ctx_factory_registered_merges_factory_and_override_context", async () => { + const registry = new GateRegistryModule.UokGateRegistry(); + const execute = vi.fn(async (ctx) => ({ + passed: true, + gateId: "alpha", + ctx, + })); + registry.register(stubGate("alpha", execute), async () => ({ foo: 1 })); + + const result = await registry.run("alpha", { bar: 2 }); + + expect(execute).toHaveBeenCalledWith({ foo: 1, bar: 2 }); + expect(result).toEqual({ + passed: true, + gateId: "alpha", + ctx: { foo: 1, bar: 2 }, + }); + }); +}); diff --git a/src/resources/extensions/sf/tests/gate-type-enum-validator.test.mjs b/src/resources/extensions/sf/tests/gate-type-enum-validator.test.mjs new file mode 100644 index 000000000..19ca83cdd --- /dev/null +++ b/src/resources/extensions/sf/tests/gate-type-enum-validator.test.mjs @@ -0,0 +1,127 @@ +/** + * Tests for UOK gate type enum validation (ADR-0075 §Gate Contract). + * + * Purpose: ensure that UOK_GATE_TYPES and isValidGateType are canonical and + * that UokGateRunner.register() rejects gates with types outside the 5-value + * enum at registration time, so slippage like type:"artifact" cannot recur. + * + * Consumer: CI gate and anyone registering UOK gates. + */ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { isValidGateType, UOK_GATE_TYPES } from "../engine-types.js"; +import { UokGateRunner } from "../uok/gate-runner.js"; + +// ─── UOK_GATE_TYPES constant ────────────────────────────────────────────── + +test("UOK_GATE_TYPES contains exactly the 5 ADR-0075 values", () => { + assert.deepEqual([...UOK_GATE_TYPES].sort(), [ + "chaos", + "learning", + "policy", + "security", + "verification", + ]); +}); + +test("UOK_GATE_TYPES is frozen — mutation throws in strict mode", () => { + assert.throws(() => { + UOK_GATE_TYPES.push("artifact"); + }, TypeError); +}); + +// ─── isValidGateType unit tests ─────────────────────────────────────────── + +test("isValidGateType: each of the 5 valid types returns true", () => { + for (const t of ["security", "policy", "verification", "learning", "chaos"]) { + assert.equal(isValidGateType(t), true, `expected true for "${t}"`); + } +}); + +test('isValidGateType: "artifact" returns false', () => { + assert.equal(isValidGateType("artifact"), false); +}); + +test("isValidGateType: undefined returns false", () => { + assert.equal(isValidGateType(undefined), false); +}); + +test("isValidGateType: empty string returns false", () => { + assert.equal(isValidGateType(""), false); +}); + +test("isValidGateType: non-string types return false", () => { + assert.equal(isValidGateType(null), false); + assert.equal(isValidGateType(42), false); + assert.equal(isValidGateType({}), false); +}); + +// ─── UokGateRunner.register() integration ──────────────────────────────── + +function makeGate(overrides = {}) { + return { + id: "test-gate", + type: "verification", + execute: async () => ({ outcome: "pass", rationale: "ok" }), + ...overrides, + }; +} + +test('register: gate with type "verification" succeeds', () => { + const runner = new UokGateRunner(); + assert.doesNotThrow(() => + runner.register(makeGate({ type: "verification" })), + ); + assert.equal(runner.list().length, 1); +}); + +test("register: each of the 5 valid gate types is accepted", () => { + for (const type of [ + "security", + "policy", + "verification", + "learning", + "chaos", + ]) { + const runner = new UokGateRunner(); + assert.doesNotThrow( + () => runner.register(makeGate({ id: `gate-${type}`, type })), + `type "${type}" should be accepted`, + ); + } +}); + +test('register: gate with type "artifact" throws TypeError naming gate id, bad type, and ADR-0075', () => { + const runner = new UokGateRunner(); + assert.throws( + () => runner.register(makeGate({ id: "my-gate", type: "artifact" })), + (err) => { + assert.ok(err instanceof TypeError, "must be TypeError"); + assert.ok( + err.message.includes("my-gate"), + `message must include gate id — got: ${err.message}`, + ); + assert.ok( + err.message.includes("artifact"), + `message must include bad type — got: ${err.message}`, + ); + assert.ok( + err.message.includes("ADR-0075"), + `message must reference ADR-0075 — got: ${err.message}`, + ); + return true; + }, + ); +}); + +test("register: gate with type undefined throws (validateGate catches it)", () => { + const runner = new UokGateRunner(); + // validateGate rejects undefined type before isValidGateType runs — any Error + assert.throws(() => runner.register(makeGate({ type: undefined }))); +}); + +test('register: gate with type "" (empty string) throws', () => { + const runner = new UokGateRunner(); + // validateGate catches empty string before isValidGateType, so any Error + assert.throws(() => runner.register(makeGate({ type: "" }))); +}); diff --git a/src/resources/extensions/sf/tests/loop-signals.test.mjs b/src/resources/extensions/sf/tests/loop-signals.test.mjs new file mode 100644 index 000000000..fdb3ff01e --- /dev/null +++ b/src/resources/extensions/sf/tests/loop-signals.test.mjs @@ -0,0 +1,65 @@ +import { describe, expect, test } from "vitest"; +import { + createLoopSignal, + detectorSweepSignal, + zeroProgressSignal, +} from "../supervision/loop-signals.js"; + +describe("loop supervision signals", () => { + test("createLoopSignal_when_invalid_fields_supplied_normalizes_to_safe_contract", () => { + const signal = createLoopSignal({ + scope: "bad", + kind: "duplicate-tool-call", + severity: "bad", + recommendedAction: "bad", + evidence: "same args", + }); + + expect(signal).toMatchObject({ + scope: "unit", + kind: "duplicate-tool-call", + severity: "warning", + recommendedAction: "pause", + evidence: { value: "same args" }, + }); + }); + + test("detectorSweepSignal_when_detector_fires_preserves_unit_and_signature", () => { + const signal = detectorSweepSignal( + { name: "artifact-flap", signature: { path: "SUMMARY.md" } }, + "execute-task", + "M001/S01/T01", + 123, + ); + + expect(signal).toMatchObject({ + scope: "unit", + kind: "wiggums:artifact-flap", + severity: "fail", + unit: { type: "execute-task", id: "M001/S01/T01" }, + recommendedAction: "pause", + evidence: { + detector: "artifact-flap", + signature: { path: "SUMMARY.md" }, + failedAt: 123, + }, + }); + }); + + test("zeroProgressSignal_when_detector_fires_uses_same_supervision_contract", () => { + const signal = zeroProgressSignal({ + unitType: "execute-task", + unitId: "M002/S03/T04", + evidence: { toolCallsTotal: 25 }, + }); + + expect(signal).toMatchObject({ + scope: "unit", + kind: "zero-progress", + severity: "fail", + unit: { type: "execute-task", id: "M002/S03/T04" }, + recommendedAction: "pause", + evidence: { toolCallsTotal: 25 }, + }); + }); +}); diff --git a/src/resources/extensions/sf/tests/no-execution-gate-type.test.mjs b/src/resources/extensions/sf/tests/no-execution-gate-type.test.mjs new file mode 100644 index 000000000..08771c0bc --- /dev/null +++ b/src/resources/extensions/sf/tests/no-execution-gate-type.test.mjs @@ -0,0 +1,20 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +const ROOT = join(import.meta.dirname, ".."); +const GATE_CALLSITE_FILES = [ + "auto/phases-pre-dispatch.js", + "auto/phases-dispatch.js", + "uok/auto-dispatch.js", +]; + +describe("uok gate type call sites", () => { + test("gate_call_sites_when_registering_or_emitting_do_not_use_execution_as_gate_type", () => { + for (const rel of GATE_CALLSITE_FILES) { + const source = readFileSync(join(ROOT, rel), "utf8"); + expect(source, rel).not.toMatch(/gateType:\s*["']execution["']/); + expect(source, rel).not.toMatch(/type:\s*["']execution["']/); + } + }); +}); diff --git a/src/resources/extensions/sf/tests/uok-parity-exit-guarantee.test.mjs b/src/resources/extensions/sf/tests/uok-parity-exit-guarantee.test.mjs new file mode 100644 index 000000000..41e66a77f --- /dev/null +++ b/src/resources/extensions/sf/tests/uok-parity-exit-guarantee.test.mjs @@ -0,0 +1,302 @@ +/** + * uok-parity-exit-guarantee.test.mjs + * + * Verifies that every UOK kernel enter event in the parity log is + * always matched by exactly one exit event, and that the status counter + * in buildParityReport only tallies exit events (not enter events). + */ +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "vitest"; +import { runAutoLoopWithUok } from "../uok/kernel.js"; +import { + buildParityReport, + parseParityEvents, + writeParityHeartbeat, +} from "../uok/parity-report.js"; + +const tmpRoots = []; + +afterEach(() => { + for (const dir of tmpRoots.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const root = mkdtempSync(join(tmpdir(), "sf-uok-exit-guarantee-")); + tmpRoots.push(root); + mkdirSync(join(root, ".sf", "runtime"), { recursive: true }); + return root; +} + +function testDeps() { + return { + loadEffectiveSFPreferences() { + return { + preferences: { + uok: { enabled: true, audit_envelope: { enabled: false } }, + }, + }; + }, + }; +} + +function testCtx(sessionId = "session-guarantee-test") { + return { + sessionManager: { getSessionId: () => sessionId }, + }; +} + +function readParityEvents(projectRoot) { + const path = join(projectRoot, ".sf", "runtime", "uok-parity.jsonl"); + assert.equal(existsSync(path), true, "parity.jsonl should exist"); + return parseParityEvents(readFileSync(path, "utf-8")); +} + +function countExitsByRunId(events) { + const counts = new Map(); + for (const ev of events) { + if (ev.phase === "exit" && ev.runId) { + counts.set(ev.runId, (counts.get(ev.runId) ?? 0) + 1); + } + } + return counts; +} + +// ── Case 1: Normal exit — exactly one exit per runId ───────────────────────── +test("guarantee_normal_exit_exactly_one_exit_per_runId", async () => { + const projectRoot = makeProject(); + const state = { basePath: projectRoot }; + + await runAutoLoopWithUok({ + ctx: testCtx("session-normal"), + pi: {}, + s: state, + deps: testDeps(), + async runKernelLoop() { + // Normal completion — no conditional exit site fires. + }, + }); + + const events = readParityEvents(projectRoot); + const enters = events.filter((e) => e.phase === "enter"); + const exits = events.filter((e) => e.phase === "exit"); + + assert.equal(enters.length, 1, "exactly one enter event"); + assert.equal(exits.length, 1, "exactly one exit event"); + assert.equal(enters[0].runId, exits[0].runId, "enter and exit share runId"); + + const exitsByRunId = countExitsByRunId(events); + assert.equal( + exitsByRunId.get(enters[0].runId), + 1, + "only one exit per runId", + ); +}); + +// ── Case 2: Abnormal exit via finally (no conditional site) ────────────────── +test("guarantee_finally_writes_exit_with_reason_normal_termination", async () => { + const projectRoot = makeProject(); + const state = { basePath: projectRoot }; + + // The kernel loop completes without throwing — guaranteed-exit finally fires. + await runAutoLoopWithUok({ + ctx: testCtx("session-finally"), + pi: {}, + s: state, + deps: testDeps(), + async runKernelLoop() { + // Simulate work; no explicit conditional exit site fires. + }, + }); + + const events = readParityEvents(projectRoot); + const exitEvent = events.find((e) => e.phase === "exit"); + assert.ok(exitEvent, "exit event must be present"); + // The finally block sets __uokExitWritten=true, then calls recordUokKernelTermination + // which writes the exit via writeParityHeartbeat with status "ok". + assert.equal(exitEvent.status, "ok"); + assert.equal(exitEvent.runId, events.find((e) => e.phase === "enter").runId); +}); + +// ── Case 3: Signal handler writes exit with correct reason ──────────────────── +// We test the once-flag + writeParityHeartbeat pattern that kernel.ts registers +// for signal handlers. We invoke the handler function directly (not via +// process.emit, which would disturb other signal listeners in the test process). +test("guarantee_signal_handler_writes_exit_with_signal_reason", async () => { + const projectRoot = makeProject(); + const parityPath = join(projectRoot, ".sf", "runtime", "uok-parity.jsonl"); + const runId = "uok-sigterm-simulate-run"; + + // Write an enter event as kernel.ts would. + writeParityHeartbeat(projectRoot, { + ts: new Date().toISOString(), + runId, + path: "uok-kernel", + phase: "enter", + }); + + // Mirror the kernel.ts once-flag + handler pattern. + let exitWritten = false; + function writeExitOnce(reason, exitStatus) { + if (exitWritten) return; + exitWritten = true; + writeParityHeartbeat(projectRoot, { + ts: new Date().toISOString(), + runId, + path: "uok-kernel", + phase: "exit", + status: exitStatus ?? "ok", + reason, + }); + } + + // Invoke the SIGTERM branch directly (without process.emit to avoid + // triggering other signal listeners in the test process). + writeExitOnce("signal:SIGTERM", "signal"); + + assert.equal(exitWritten, true, "exit must have been marked as written"); + + // Verify the parity log has the exit event. + assert.equal(existsSync(parityPath), true, "parity.jsonl should exist"); + const events = parseParityEvents(readFileSync(parityPath, "utf-8")); + + const enters = events.filter((e) => e.phase === "enter"); + const exits = events.filter((e) => e.phase === "exit"); + + assert.equal(enters.length, 1, "one enter event"); + assert.equal(exits.length, 1, "one exit event from signal handler"); + assert.equal(exits[0].reason, "signal:SIGTERM"); + assert.equal(exits[0].status, "signal"); + assert.equal(exits[0].runId, runId); + + // Verify once-guard: calling writeExitOnce a second time must NOT write another exit. + writeExitOnce("signal:SIGTERM", "signal"); + const eventsAfter = parseParityEvents(readFileSync(parityPath, "utf-8")); + assert.equal( + eventsAfter.filter((e) => e.phase === "exit").length, + 1, + "once-flag prevents second exit write", + ); +}); + +// ── Case 4: parity-report status counter only tallies exit events ──────────── +test("parity_report_status_counter_only_counts_exit_events", () => { + // Fixture: 3 enters + 2 exits (one ok, one error). Enter events have no + // status field. The status bucket should show {ok:1, error:1}, no "unknown". + const NOW = Date.parse("2026-05-17T00:00:00.000Z"); + const events = [ + // 3 enter events — deliberately no status field + { + schemaVersion: 1, + ts: new Date(NOW - 10_000).toISOString(), + runId: "uok-r1", + path: "uok-kernel", + phase: "enter", + }, + { + schemaVersion: 1, + ts: new Date(NOW - 9_000).toISOString(), + runId: "uok-r2", + path: "uok-kernel", + phase: "enter", + }, + { + schemaVersion: 1, + ts: new Date(NOW - 8_000).toISOString(), + runId: "uok-r3", + path: "uok-kernel", + phase: "enter", + }, + // 2 exit events + { + schemaVersion: 1, + ts: new Date(NOW - 7_000).toISOString(), + runId: "uok-r1", + path: "uok-kernel", + phase: "exit", + status: "ok", + }, + { + schemaVersion: 1, + ts: new Date(NOW - 6_000).toISOString(), + runId: "uok-r2", + path: "uok-kernel", + phase: "exit", + status: "error", + error: "boom", + }, + ]; + + const report = buildParityReport(events, "/tmp/uok-parity.jsonl", NOW); + + // Status bucket must only contain exit statuses + assert.deepEqual( + report.statuses, + { ok: 1, error: 1 }, + "statuses must sum to exit-event count only", + ); + assert.equal( + "unknown" in report.statuses, + false, + "unknown bucket must not appear from enter-only events", + ); + + // Sanity: counts + assert.equal(report.enterEvents, 3); + assert.equal(report.exitEvents, 2); + + // uok-r3 has enter but no exit — should be in unmatched + assert.equal(report.unmatchedRuns.length, 1); + assert.equal(report.unmatchedRuns[0].runId, "uok-r3"); +}); + +// ── Case 5: double exit (signal handler + finally both write) — deduplication ─ +test("parity_report_handles_duplicate_exits_per_runId_as_balanced", () => { + // If two exit events land for the same runId (e.g. signal handler fires and + // then the finally also fires), the reporter must treat it as balanced, not + // as mismatched in the other direction. + const NOW = Date.parse("2026-05-17T00:00:00.000Z"); + const events = [ + { + schemaVersion: 1, + ts: new Date(NOW - 10_000).toISOString(), + runId: "uok-double", + path: "uok-kernel", + phase: "enter", + }, + { + schemaVersion: 1, + ts: new Date(NOW - 5_000).toISOString(), + runId: "uok-double", + path: "uok-kernel", + phase: "exit", + status: "signal", + }, + { + schemaVersion: 1, + ts: new Date(NOW - 4_000).toISOString(), + runId: "uok-double", + path: "uok-kernel", + phase: "exit", + status: "ok", + }, + ]; + + const report = buildParityReport(events, "/tmp/uok-parity.jsonl", NOW); + + // enterEvents=1, exitEvents=2 → not in unmatchedRuns (exitEvents >= enterEvents) + assert.equal(report.unmatchedRuns.length, 0, "no unmatched runs with double-exit"); + assert.equal(report.missingExitEvents, 0); + // statuses bucket has both exits counted + assert.deepEqual(report.statuses, { signal: 1, ok: 1 }); +}); diff --git a/src/resources/extensions/sf/uok/auto-dispatch.js b/src/resources/extensions/sf/uok/auto-dispatch.js index 64534a306..36495d89d 100644 --- a/src/resources/extensions/sf/uok/auto-dispatch.js +++ b/src/resources/extensions/sf/uok/auto-dispatch.js @@ -1420,7 +1420,7 @@ export const DISPATCH_RULES = [ const egRunner = new UokGateRunner(); egRunner.register({ id: "execution-graph-gate", - type: "execution", + type: "verification", execute: async () => ({ outcome: "fail", failureClass: "execution", diff --git a/src/resources/extensions/sf/uok/auto-runaway-guard.js b/src/resources/extensions/sf/uok/auto-runaway-guard.js index 5436f86a1..ea30175c7 100644 --- a/src/resources/extensions/sf/uok/auto-runaway-guard.js +++ b/src/resources/extensions/sf/uok/auto-runaway-guard.js @@ -16,6 +16,10 @@ import { } from "../detectors/periodic-runner.js"; import { evaluateZeroProgress } from "../detectors/zero-progress.js"; import { _getAdapter } from "../sf-db/sf-db-core.js"; +import { + detectorSweepSignal, + zeroProgressSignal, +} from "../supervision/loop-signals.js"; export const DEFAULT_RUNAWAY_TOOL_CALL_WARNING = 60; export const DEFAULT_RUNAWAY_TOKEN_WARNING = 1_000_000; export const DEFAULT_RUNAWAY_ELAPSED_MINUTES = 20; @@ -264,6 +268,11 @@ export function evaluateRunawayGuard( elapsedMs: zeroProgress.signature.elapsedMs, fingerprint: zeroProgress.signature.fingerprint, }; + const signal = zeroProgressSignal({ + unitType, + unitId, + evidence, + }); return { action: "fail", reason: "zero-progress", @@ -275,6 +284,7 @@ export function evaluateRunawayGuard( metrics: unitMetrics, zeroProgress: true, evidence, + loopSignal: signal, selfFeedback: { kind: "zero-progress", severity: "high", @@ -293,6 +303,7 @@ export function evaluateRunawayGuard( sweepState.pendingFired = []; if (pendingFired.length > 0) { const fired = pendingFired[0]; + const signal = detectorSweepSignal(fired, unitType, unitId, now); return { action: "fail", reason: `detector-sweep:${fired.name}`, @@ -303,10 +314,11 @@ export function evaluateRunawayGuard( unitId, metrics: unitMetrics, detectorsFired: pendingFired, + loopSignal: signal, selfFeedback: { - kind: `wiggums:${fired.name}`, + kind: signal.kind, severity: "high", - evidence: JSON.stringify(fired.signature), + evidence: JSON.stringify(signal.evidence), }, }, }; diff --git a/src/resources/extensions/sf/uok/gate-registry-bootstrap.js b/src/resources/extensions/sf/uok/gate-registry-bootstrap.js new file mode 100644 index 000000000..1c77dd499 --- /dev/null +++ b/src/resources/extensions/sf/uok/gate-registry-bootstrap.js @@ -0,0 +1,23 @@ +import { driftDetectionGate } from "./drift-detection-gate.js"; +import { getGateRegistry } from "./gate-registry.js"; + +/** + * gate-registry-bootstrap.js — register ADR-0075 UOK gates that are liftable. + * + * Purpose: centralize process-wide gate registration without changing existing + * inline call sites while ADR-0075 migration proceeds. + * + * Consumer: future UOK call sites that import the bootstrap before running a + * gate through UokGateRegistry. + */ +const registry = getGateRegistry(); + +// SKIP unit-verification-gate: execute() closes over deps, s, ctx, pi, and pauseAuto from auto/phases-finalize.js. +// SKIP milestone-validation-post-check: execute() closes over persistGate arguments from runValidateMilestonePostCheck. +// SKIP verification-gate: execute() closes over the local verification result and helper formatting state from uok/auto-verification.js. +// SKIP post-execution-checks: execute() closes over postExecResult, strict-mode prefs, and derived blocking state from uok/auto-verification.js. +// SKIP milestone-validation-gates: execute() closes over validate_milestone params and derived verdict/remediation state. +// SKIP planning-flow-gate: execute() closes over persistGate arguments from guided-flow.js. +registry.register(driftDetectionGate); + +export { registry as gateRegistry }; diff --git a/src/resources/extensions/sf/uok/gate-registry.js b/src/resources/extensions/sf/uok/gate-registry.js new file mode 100644 index 000000000..2226ff95c --- /dev/null +++ b/src/resources/extensions/sf/uok/gate-registry.js @@ -0,0 +1,53 @@ +import { UokGateRunner } from "./gate-runner.js"; + +/** + * UokGateRegistry — process-wide registry of UokGate objects. + * + * Purpose: replace the 8+ ad-hoc `new UokGateRunner()` call sites with a + * single registered set per ADR-0075. Consumers register gates at module + * load time (static imports + register call); call sites then ask the + * registry to execute by gate id, with a ctxFactory that knows how to + * build the per-call ctx. + */ +export class UokGateRegistry { + constructor() { + this._runner = new UokGateRunner(); + this._registered = new Map(); // id -> {gate, ctxFactory?} + } + + register(gate, ctxFactory) { + if (!gate || typeof gate.id !== "string") { + throw new Error("UokGateRegistry.register: gate.id required"); + } + this._registered.set(gate.id, { gate, ctxFactory }); + this._runner.register(gate); + return this; + } + + has(gateId) { + return this._registered.has(gateId); + } + list() { + return Array.from(this._registered.keys()); + } + + async run(gateId, ctxOverride = {}) { + const entry = this._registered.get(gateId); + if (!entry) + throw new Error( + `UokGateRegistry: no gate registered with id="${gateId}"`, + ); + const baseCtx = entry.ctxFactory ? await entry.ctxFactory() : {}; + return this._runner.run(gateId, { ...baseCtx, ...ctxOverride }); + } +} + +// Process singleton +let _instance = null; +export function getGateRegistry() { + if (!_instance) _instance = new UokGateRegistry(); + return _instance; +} +export function resetGateRegistryForTests() { + _instance = null; +} diff --git a/src/resources/extensions/sf/uok/gate-runner.js b/src/resources/extensions/sf/uok/gate-runner.js index 6ccbda7c0..f8da1b1dd 100644 --- a/src/resources/extensions/sf/uok/gate-runner.js +++ b/src/resources/extensions/sf/uok/gate-runner.js @@ -1,5 +1,6 @@ import { debugLog } from "../debug-logger.js"; import { getErrorMessage } from "../error-utils.js"; +import { isValidGateType, UOK_GATE_TYPES } from "../engine-types.js"; import { getRelevantMemoriesRanked } from "../memory-store.js"; import { getDistinctGateIds, @@ -178,6 +179,11 @@ export class UokGateRunner { if (!validation.valid) { throw new Error(`UokGateRunner.register: ${validation.reason}`); } + if (!isValidGateType(gate.type)) { + throw new TypeError( + `UokGateRunner.register: gate "${gate.id}" has invalid type=${JSON.stringify(gate.type)}; must be one of: ${UOK_GATE_TYPES.join(", ")} (per ADR-0075)`, + ); + } this.registry.set(gate.id, gate); } list() { diff --git a/src/resources/extensions/sf/uok/kernel.ts b/src/resources/extensions/sf/uok/kernel.ts index 4d004ba99..60c9a173e 100644 --- a/src/resources/extensions/sf/uok/kernel.ts +++ b/src/resources/extensions/sf/uok/kernel.ts @@ -306,6 +306,65 @@ export async function runAutoLoopWithUok( uokRunControl: runControl, uokPermissionProfile: permissionProfile, }; + // ── Guaranteed-exit writer ──────────────────────────────────────────────── + // Ensures a parity heartbeat exit event is always written for every enter, + // even when the process exits via SIGTERM/SIGKILL, OOM, or uncaught exception + // before the async finally block can run. + // + // Design: + // - A once-flag prevents double-writes: the first caller (finally block, + // signal handler, or process 'exit' event) wins; subsequent calls are no-ops. + // - 'process.once' handlers are one-shot and do NOT prevent process exit. + // - The 'exit' event runs synchronously; writeParityHeartbeat uses appendFileSync + // so it is safe to call there. + // - All handlers are removed in the finally block to avoid accumulation across + // multiple runAutoLoopWithUok calls in the same process. + let __uokExitWritten = false; + const __writeUokExitOnce = (reason: string, exitStatus?: string): void => { + if (__uokExitWritten) return; + __uokExitWritten = true; + try { + writeParityHeartbeat(s.basePath, { + ts: new Date().toISOString(), + runId, + sessionId, + path: resolveKernelPathLabel(), + flags: lifecycleFlags, + runControl, + permissionProfile, + phase: "exit", + status: exitStatus ?? status, + reason, + }); + } catch { + // Best-effort: logging failure during shutdown must not propagate. + } + }; + // Build per-signal bound handlers so we can removeListener precisely without + // disturbing other listeners registered on the same signal (e.g. auto-supervisor). + const __uokSignals = ["SIGTERM", "SIGINT", "SIGHUP"] as const; + type CleanupSignal = (typeof __uokSignals)[number]; + const __uokBoundSignalHandlers = new Map void>(); + for (const sig of __uokSignals) { + const bound = (): void => __writeUokExitOnce(`signal:${sig}`, "signal"); + __uokBoundSignalHandlers.set(sig, bound); + process.once(sig, bound); + } + const __uokUncaughtHandler = (err: unknown): void => + __writeUokExitOnce( + `uncaught:${err instanceof Error ? err.message : String(err)}`, + "error", + ); + const __uokRejectionHandler = (reason: unknown): void => + __writeUokExitOnce( + `unhandledRejection:${typeof reason === "string" ? reason : reason instanceof Error ? reason.message : "unknown"}`, + "error", + ); + const __uokExitHandler = (): void => __writeUokExitOnce("process-exit"); + process.once("uncaughtException", __uokUncaughtHandler); + process.once("unhandledRejection", __uokRejectionHandler); + process.once("exit", __uokExitHandler); + let status = "ok"; let error: string | undefined; try { @@ -315,6 +374,19 @@ export async function runAutoLoopWithUok( error = err instanceof Error ? err.message : String(err); throw err; } finally { + // Remove our handlers precisely — do not disturb other listeners on the + // same signals (e.g. auto-supervisor's SIGTERM lock-cleanup handler). + for (const sig of __uokSignals) { + const bound = __uokBoundSignalHandlers.get(sig); + if (bound) process.removeListener(sig, bound); + } + process.removeListener("uncaughtException", __uokUncaughtHandler); + process.removeListener("unhandledRejection", __uokRejectionHandler); + process.removeListener("exit", __uokExitHandler); + // Mark exit as written BEFORE recordUokKernelTermination so that if an + // error inside recordUokKernelTermination causes a re-entry, the once-flag + // prevents a second write. + __uokExitWritten = true; recordUokKernelTermination({ basePath: s.basePath, runId, diff --git a/src/resources/extensions/sf/uok/parity-report.js b/src/resources/extensions/sf/uok/parity-report.js index a8ea38685..7f32b0050 100644 --- a/src/resources/extensions/sf/uok/parity-report.js +++ b/src/resources/extensions/sf/uok/parity-report.js @@ -170,7 +170,11 @@ export function buildParityReport( // Kernel heartbeat event const heartbeat = event; increment(paths, heartbeat.path); - increment(statuses, heartbeat.status); + // Only tally status from exit events — enter events intentionally omit + // the status field, so counting them inflates the "unknown" bucket. + if (heartbeat.phase === "exit") { + increment(statuses, heartbeat.status); + } const runId = typeof heartbeat.runId === "string" && heartbeat.runId.trim().length > 0 ? heartbeat.runId