fix: consolidate loop supervision gates

This commit is contained in:
Mikael Hugo 2026-05-17 14:35:40 +02:00
parent 625a830d2f
commit 4d2266e57d
16 changed files with 921 additions and 8 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",
});
}

View file

@ -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 },
});
});
});

View file

@ -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: "" })));
});

View file

@ -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 },
});
});
});

View file

@ -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["']/);
}
});
});

View file

@ -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 });
});

View file

@ -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",

View file

@ -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),
},
},
};

View file

@ -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 };

View file

@ -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;
}

View file

@ -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() {

View file

@ -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<CleanupSignal, () => 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,

View file

@ -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