fix: consolidate loop supervision gates
This commit is contained in:
parent
625a830d2f
commit
4d2266e57d
16 changed files with 921 additions and 8 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
106
src/resources/extensions/sf/supervision/loop-signals.js
Normal file
106
src/resources/extensions/sf/supervision/loop-signals.js
Normal 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",
|
||||
});
|
||||
}
|
||||
105
src/resources/extensions/sf/tests/gate-registry.test.mjs
Normal file
105
src/resources/extensions/sf/tests/gate-registry.test.mjs
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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: "" })));
|
||||
});
|
||||
65
src/resources/extensions/sf/tests/loop-signals.test.mjs
Normal file
65
src/resources/extensions/sf/tests/loop-signals.test.mjs
Normal 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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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["']/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
23
src/resources/extensions/sf/uok/gate-registry-bootstrap.js
Normal file
23
src/resources/extensions/sf/uok/gate-registry-bootstrap.js
Normal 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 };
|
||||
53
src/resources/extensions/sf/uok/gate-registry.js
Normal file
53
src/resources/extensions/sf/uok/gate-registry.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue