wire R053-R056 detectors into auto-runaway-guard + R081 UokGate retrofit

- uok/auto-runaway-guard.js: invoke runDetectorSweep alongside the existing
  zero-progress check (fire-and-forget for sync-tick compatibility; results
  consumed on next tick via sweepState ring buffer). Passes unitId,
  unitMetrics, sessionFingerprint, lockPaths, and a 30-min DB-windowed
  recentFeedback slice.
- detectors/{same-unit-loop, zero-progress, repeated-feedback-kind,
  artifact-flap, stale-lock, periodic-runner}.js: each detector now also
  exports a UokGate wrapper (id/type/execute -> GateResult per ADR-0075).
  Plain detector functions kept for existing consumers.
- detectors/index.js: single import surface for the gate exports.
- detector-stale-lock.test.mjs (9), detector-periodic-runner.test.mjs (10),
  detector-gates-contract.test.mjs: fills the R055/R056 test gap filed
  earlier today + proves UokGate contract conformance.
- 41/41 detector tests green; copy-resources clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-05-17 14:18:54 +02:00
parent 527ebfcaa4
commit 625a830d2f
12 changed files with 822 additions and 3 deletions

View file

@ -67,3 +67,32 @@ export function detectArtifactFlap(artifactHistory, options = {}) {
return { stuck: false };
}
/**
* Run the artifact-flap detector as a UOK verification gate.
*
* Purpose: expose artifact oscillation detection to verification gates while
* preserving the periodic detector function contract.
*
* Consumer: UOK gate registry alignment for detector-based verification gates.
*/
export const artifactFlapGate = {
id: "artifact-flap",
type: "verification",
async execute(ctx = {}) {
const result = detectArtifactFlap(ctx.artifactHistory, ctx.options);
if (result.stuck) {
return {
outcome: "fail",
failureClass: "verification",
rationale: result.reason ?? "artifact-flap signal",
findings: result.signature,
};
}
return {
outcome: "pass",
failureClass: null,
rationale: "no artifact-flap signal",
};
},
};

View file

@ -0,0 +1,13 @@
/**
* detectors/index.js UOK detector gate exports.
*
* Purpose: provide one import surface for detector-backed UOK gates while each
* detector module keeps its existing function exports.
*/
export { artifactFlapGate } from "./artifact-flap.js";
export { periodicDetectorSweepGate } from "./periodic-runner.js";
export { repeatedFeedbackKindGate } from "./repeated-feedback-kind.js";
export { sameUnitLoopGate } from "./same-unit-loop.js";
export { staleLockGate } from "./stale-lock.js";
export { zeroProgressGate } from "./zero-progress.js";

View file

@ -27,7 +27,8 @@ function defaultDetectors(ctx, options) {
return [
{
name: "same-unit-loop",
run: () => detectSameUnitLoop(ctx?.unitId, ctx?.recentDispatches, options),
run: () =>
detectSameUnitLoop(ctx?.unitId, ctx?.recentDispatches, options),
},
{
name: "zero-progress",
@ -85,7 +86,8 @@ export async function runDetectorSweep(ctx = {}, options = {}) {
run: () => detector(ctx, options),
}));
const detectors =
options.detectors ?? defaultDetectors(ctx, options).concat(optionalDetectors);
options.detectors ??
defaultDetectors(ctx, options).concat(optionalDetectors);
const detectorsFired = [];
let totalChecked = 0;
@ -118,3 +120,32 @@ export async function runDetectorSweep(ctx = {}, options = {}) {
durationMs: Date.now() - startedAt,
};
}
/**
* Run the periodic detector sweep as a UOK verification gate.
*
* Purpose: expose the aggregate detector sweep to the gate runner as a single
* manual-attention signal when any detector fires.
*
* Consumer: UOK gate registry alignment for detector-based verification gates.
*/
export const periodicDetectorSweepGate = {
id: "periodic-detector-sweep",
type: "verification",
async execute(ctx = {}) {
const result = await runDetectorSweep(ctx);
if (result.detectorsFired.length === 0) {
return {
outcome: "pass",
failureClass: null,
rationale: "no detectors fired",
};
}
return {
outcome: "manual-attention",
failureClass: "verification",
rationale: "detectors fired",
findings: result.detectorsFired,
};
},
};

View file

@ -70,3 +70,35 @@ export function detectRepeatedFeedbackKind(recentFeedback, options = {}) {
return { stuck: false };
}
/**
* Run the repeated-feedback detector as a UOK policy gate.
*
* Purpose: expose repeated self-feedback detection to run-control policy gates
* without changing the detector function consumed by periodic sweeps.
*
* Consumer: UOK gate registry alignment for detector-based policy gates.
*/
export const repeatedFeedbackKindGate = {
id: "repeated-feedback-kind",
type: "policy",
async execute(ctx = {}) {
const result = detectRepeatedFeedbackKind(
ctx.feedbackHistory ?? ctx.recentFeedback,
ctx.options,
);
if (result.stuck) {
return {
outcome: "fail",
failureClass: "policy",
rationale: result.reason ?? "repeated-feedback-kind signal",
findings: result.signature,
};
}
return {
outcome: "pass",
failureClass: null,
rationale: "no repeated-feedback-kind signal",
};
},
};

View file

@ -64,3 +64,36 @@ export function detectSameUnitLoop(unitId, recentDispatches, options = {}) {
},
};
}
/**
* Run the same-unit loop detector as a UOK verification gate.
*
* Purpose: expose the dispatch-loop detector to the gate runner without
* changing the detector function contract used by autonomous dispatch.
*
* Consumer: UOK gate registry alignment for detector-based verification gates.
*/
export const sameUnitLoopGate = {
id: "same-unit-loop",
type: "verification",
async execute(ctx = {}) {
const result = detectSameUnitLoop(
ctx.unitId,
ctx.recentDispatches,
ctx.options,
);
if (result.stuck) {
return {
outcome: "fail",
failureClass: "verification",
rationale: result.reason ?? "same-unit-loop signal",
findings: result.signature,
};
}
return {
outcome: "pass",
failureClass: null,
rationale: "no same-unit-loop signal",
};
},
};

View file

@ -96,3 +96,35 @@ export async function detectStaleLock(lockPaths, options = {}) {
return { stuck: false };
}
/**
* Run the stale-lock detector as a UOK security gate.
*
* Purpose: expose abandoned lock detection to security gates so dead process
* ownership can block or recover autonomous operation.
*
* Consumer: UOK gate registry alignment for detector-based security gates.
*/
export const staleLockGate = {
id: "stale-lock",
type: "security",
async execute(ctx = {}) {
const result = await detectStaleLock(
ctx.lockInfo ?? ctx.lockPaths,
ctx.options,
);
if (result.stuck) {
return {
outcome: "fail",
failureClass: "verification",
rationale: result.reason ?? "stale-lock signal",
findings: result.signature,
};
}
return {
outcome: "pass",
failureClass: null,
rationale: "no stale-lock signal",
};
},
};

View file

@ -180,3 +180,36 @@ function parsePorcelainPath(line) {
}
return filePath || null;
}
/**
* Run the zero-progress detector as a UOK verification gate.
*
* Purpose: expose tool-churn detection to the gate runner while preserving the
* detector API used by the autonomous runaway guard.
*
* Consumer: UOK gate registry alignment for detector-based verification gates.
*/
export const zeroProgressGate = {
id: "zero-progress",
type: "verification",
async execute(ctx = {}) {
const result = await detectZeroProgress(
ctx.unitMetrics ?? ctx.recentEvents,
ctx.lastSnapshot ?? ctx.previousSnapshot,
ctx.options,
);
if (result.stuck) {
return {
outcome: "fail",
failureClass: "verification",
rationale: result.reason ?? "zero-progress signal",
findings: result.signature,
};
}
return {
outcome: "pass",
failureClass: null,
rationale: "no zero-progress signal",
};
},
};

View file

@ -0,0 +1,82 @@
/**
* detector-gates-contract.test.mjs detector-backed UOK gate contracts.
*
* Purpose: prove detector retrofits expose the canonical gate shape without
* breaking the plain detector functions used by existing consumers.
*/
import { describe, expect, it } from "vitest";
import {
artifactFlapGate,
periodicDetectorSweepGate,
repeatedFeedbackKindGate,
sameUnitLoopGate,
staleLockGate,
zeroProgressGate,
} from "../detectors/index.js";
const GATE_TYPES = new Set([
"security",
"policy",
"verification",
"learning",
"chaos",
]);
const OUTCOMES = new Set(["pass", "fail", "manual-attention", "skip"]);
const gateCases = [
{
gate: sameUnitLoopGate,
ctx: { unitId: "M001-S01-T01", recentDispatches: [] },
},
{
gate: zeroProgressGate,
ctx: {
unitMetrics: { tool_calls: 0, elapsedMs: 0, fingerprint: "clean" },
lastSnapshot: { tool_calls: 0, elapsedMs: 0, fingerprint: "clean" },
},
},
{
gate: repeatedFeedbackKindGate,
ctx: { feedbackHistory: [] },
},
{
gate: artifactFlapGate,
ctx: { artifactHistory: [] },
},
{
gate: staleLockGate,
ctx: { lockInfo: [] },
},
{
gate: periodicDetectorSweepGate,
ctx: {
unitId: "M001-S01-T01",
recentDispatches: [],
unitMetrics: { tool_calls: 0, elapsedMs: 0, fingerprint: "clean" },
sessionFingerprint: "clean",
recentFeedback: [],
artifactHistory: [],
lockPaths: [],
},
},
];
describe("detector UOK gate contracts", () => {
for (const { gate, ctx } of gateCases) {
it(`${gate.id}_when_called_with_minimal_ctx_returns_gate_result`, async () => {
expect(typeof gate.id).toBe("string");
expect(gate.id.length).toBeGreaterThan(0);
expect(GATE_TYPES.has(gate.type)).toBe(true);
expect(gate.execute.constructor.name).toBe("AsyncFunction");
const result = await gate.execute(ctx);
expect(result).toEqual(expect.any(Object));
expect(OUTCOMES.has(result.outcome)).toBe(true);
expect(
result.failureClass === null || typeof result.failureClass === "string",
).toBe(true);
});
}
});

View file

@ -0,0 +1,254 @@
/**
* detector-periodic-runner.test.mjs periodic detector sweep contracts.
*
* Purpose: prove R056 aggregates all Wiggums detectors, throttles per-name,
* respects a custom detector list, and isolates individual detector failures
* from the rest of the sweep.
*/
import assert from "node:assert/strict";
import { test } from "vitest";
import {
SWEEP_CADENCE_MS,
runDetectorSweep,
} from "../detectors/periodic-runner.js";
const NOW = Date.parse("2026-05-17T12:00:00.000Z");
/**
* Build a minimal clean ctx that produces no detector fires.
* All optional fields are either absent or set to values that do not trigger.
*/
function cleanCtx() {
return {
unitId: "M001/S01/T01",
recentDispatches: [], // no same-unit-loop
unitMetrics: {
// no zero-progress (no tool calls yet)
toolCalls: 0,
elapsedMs: 0,
iterationsSinceProgress: 0,
},
sessionFingerprint: null,
recentFeedback: [], // no repeated-feedback-kind
artifactHistory: [], // no artifact-flap
lockPaths: [], // no stale locks (empty list)
};
}
// ── All-clean ctx → zero detectorsFired, all 5 core detectors checked ─────
test("runDetectorSweep_when_all_clean_returns_empty_detectorsFired_and_five_checked", async () => {
const result = await runDetectorSweep(cleanCtx());
assert.equal(result.detectorsFired.length, 0);
// 5 core detectors; optional model-route-flap / prompt-drift add up to 2 more
assert.ok(
result.totalChecked >= 5,
`Expected at least 5 detectors checked, got ${result.totalChecked}`,
);
assert.equal(typeof result.durationMs, "number");
assert.ok(result.durationMs >= 0);
});
// ── One detector fires → appears in detectorsFired with name + signature ──
test("runDetectorSweep_when_one_custom_detector_fires_returns_it_in_detectorsFired", async () => {
const customSig = { testKey: "testValue" };
const customDetector = {
name: "test-always-fires",
run: async () => ({ stuck: true, reason: "test", signature: customSig }),
};
const result = await runDetectorSweep({}, { detectors: [customDetector] });
assert.equal(result.detectorsFired.length, 1);
assert.equal(result.detectorsFired[0].name, "test-always-fires");
assert.deepEqual(result.detectorsFired[0].signature, customSig);
assert.equal(result.totalChecked, 1);
});
// ── Throttle: same detector fires twice within throttleMs → second run skips it ─
test("runDetectorSweep_throttle_prevents_second_fire_within_throttleMs", async () => {
const throttleState = new Map();
const throttleMs = 5_000; // 5 seconds
const callCount = { n: 0 };
const flakyDetector = {
name: "test-flaky",
run: async () => {
callCount.n += 1;
return { stuck: true, reason: "test", signature: { call: callCount.n } };
},
};
const opts = { detectors: [flakyDetector], throttleState, throttleMs };
// First call — detector fires and is throttled.
const result1 = await runDetectorSweep({}, opts);
assert.equal(result1.detectorsFired.length, 1);
assert.equal(result1.totalChecked, 1);
// Second call within throttleMs — detector must NOT run (skipped by throttle).
const result2 = await runDetectorSweep({}, opts);
assert.equal(result2.detectorsFired.length, 0);
assert.equal(
result2.totalChecked,
0,
"Throttled detector should not be checked",
);
// The detector's run() was only called once (the throttle short-circuits).
assert.equal(callCount.n, 1);
});
// ── Throttle: after throttleMs expires the detector runs again ────────────
test("runDetectorSweep_throttle_allows_refire_after_throttleMs", async () => {
const throttleState = new Map();
const throttleMs = 100; // 100ms
const fireTime = NOW - 200; // 200ms ago — before window
// Seed the throttle state with a past fire time that is older than throttleMs.
throttleState.set("test-old", fireTime);
const refiringDetector = {
name: "test-old",
run: async () => ({
stuck: true,
reason: "test",
signature: { refired: true },
}),
};
const result = await runDetectorSweep(
{},
{
detectors: [refiringDetector],
throttleState,
throttleMs,
},
);
assert.equal(result.detectorsFired.length, 1);
assert.equal(result.detectorsFired[0].name, "test-old");
});
// ── options.detectors override: only those detectors run ──────────────────
test("runDetectorSweep_when_custom_detectors_provided_only_those_run", async () => {
const runLog = [];
const detectorA = {
name: "custom-a",
run: async () => {
runLog.push("a");
return { stuck: false };
},
};
const detectorB = {
name: "custom-b",
run: async () => {
runLog.push("b");
return { stuck: false };
},
};
const result = await runDetectorSweep(
{},
{ detectors: [detectorA, detectorB] },
);
assert.deepEqual(runLog, ["a", "b"]);
assert.equal(result.totalChecked, 2);
assert.equal(result.detectorsFired.length, 0);
});
// ── onError callback is invoked when a detector throws ───────────────────
test("runDetectorSweep_when_detector_throws_onError_is_called_and_sweep_continues", async () => {
const errors = [];
const names = [];
const throwingDetector = {
name: "test-throws",
run: async () => {
throw new Error("boom");
},
};
const goodDetector = {
name: "test-good",
run: async () => ({ stuck: false }),
};
const result = await runDetectorSweep(
{},
{
detectors: [throwingDetector, goodDetector],
onError: (err, name) => {
errors.push(err.message);
names.push(name);
},
},
);
// Good detector still ran.
assert.equal(result.totalChecked, 2);
assert.equal(result.detectorsFired.length, 0);
// Error callback was invoked for the throwing detector.
assert.equal(errors.length, 1);
assert.equal(errors[0], "boom");
assert.equal(names[0], "test-throws");
});
// ── Not-stuck detectors don't appear in detectorsFired ───────────────────
test("runDetectorSweep_when_detector_returns_not_stuck_it_is_excluded_from_fired", async () => {
const notStuckDetector = {
name: "test-not-stuck",
run: async () => ({ stuck: false }),
};
const result = await runDetectorSweep({}, { detectors: [notStuckDetector] });
assert.equal(result.detectorsFired.length, 0);
assert.equal(result.totalChecked, 1);
});
// ── Multiple detectors fire → all appear in detectorsFired ───────────────
test("runDetectorSweep_when_multiple_detectors_fire_all_appear_in_fired", async () => {
const detectors = [
{
name: "fire-alpha",
run: async () => ({ stuck: true, reason: "alpha", signature: { a: 1 } }),
},
{
name: "fire-beta",
run: async () => ({ stuck: true, reason: "beta", signature: { b: 2 } }),
},
];
const result = await runDetectorSweep({}, { detectors });
assert.equal(result.detectorsFired.length, 2);
const names = result.detectorsFired.map((d) => d.name);
assert.ok(names.includes("fire-alpha"));
assert.ok(names.includes("fire-beta"));
});
// ── durationMs is always a non-negative number ───────────────────────────
test("runDetectorSweep_durationMs_is_always_non_negative", async () => {
const result = await runDetectorSweep(cleanCtx(), {
detectors: [],
});
assert.equal(result.totalChecked, 0);
assert.ok(result.durationMs >= 0);
});
// ── SWEEP_CADENCE_MS is exported and is 60s ───────────────────────────────
test("SWEEP_CADENCE_MS_is_60_seconds", () => {
assert.equal(SWEEP_CADENCE_MS, 60_000);
});

View file

@ -0,0 +1,173 @@
/**
* detector-stale-lock.test.mjs stale-lock detector contracts.
*
* Purpose: prove R055 catches lock files whose owning process is gone while
* leaving live locks and absent locks alone.
*/
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, test } from "vitest";
import { detectStaleLock } from "../detectors/stale-lock.js";
let tmpDir;
beforeEach(() => {
tmpDir = mkdtempSync(join(tmpdir(), "sf-stale-lock-test-"));
});
afterEach(() => {
try {
rmSync(tmpDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup.
}
});
function lockPath(name = "sf.lock") {
return join(tmpDir, name);
}
/**
* Write a JSON envelope lock file with an embedded pid field.
* The detector's parsePid reads the ".pid" key from a JSON object; a plain
* integer string is valid JSON but has no .pid property, so JSON envelope is
* the only format that reliably round-trips through parsePid.
*/
function writeLock(path, pid, extra = {}) {
writeFileSync(
path,
JSON.stringify({ pid, lockedAt: Date.now(), ...extra }),
"utf8",
);
}
/**
* A PID that is virtually guaranteed to be dead: pid 1 is always alive;
* pick a very high PID unlikely to exist and that process.kill(pid, 0) will
* reject with ESRCH. We use MAX_PID - 1 so it never aliases a live process
* in the test runner's PID namespace.
*/
const DEAD_PID = 2_147_483_647; // INT_MAX; ESRCH guaranteed on Linux/macOS
/**
* The current process PID guaranteed alive.
*/
const LIVE_PID = process.pid;
// ── Dead PID: plain lock file ─────────────────────────────────────────────
test("detectStaleLock_when_dead_pid_plain_lock_returns_stuck", async () => {
const path = lockPath("sf.lock");
writeLock(path, DEAD_PID);
const result = await detectStaleLock([path]);
assert.equal(result.stuck, true);
assert.equal(result.reason, "stale-lock");
assert.equal(result.signature.lockPath, path);
assert.equal(result.signature.deadPid, DEAD_PID);
assert.equal(typeof result.signature.ageMs, "number");
assert.ok(result.signature.ageMs >= 0);
});
// ── Dead PID: JSON envelope lock file ────────────────────────────────────
test("detectStaleLock_when_dead_pid_json_lock_returns_stuck", async () => {
const path = lockPath("interactive.lock");
writeLock(path, DEAD_PID);
const result = await detectStaleLock([path]);
assert.equal(result.stuck, true);
assert.equal(result.reason, "stale-lock");
assert.equal(result.signature.deadPid, DEAD_PID);
});
// ── Live PID: lock file exists but owner is alive ─────────────────────────
test("detectStaleLock_when_live_pid_lock_returns_not_stuck", async () => {
const path = lockPath("sf.lock");
writeLock(path, LIVE_PID);
const result = await detectStaleLock([path]);
assert.deepEqual(result, { stuck: false });
});
// ── Lock file absent ──────────────────────────────────────────────────────
test("detectStaleLock_when_lock_file_absent_returns_not_stuck", async () => {
const path = lockPath("missing.lock");
// Deliberately do not create the file.
const result = await detectStaleLock([path]);
assert.deepEqual(result, { stuck: false });
});
// ── autoRecover: true, dead PID → deletes the lock and returns recovered ──
test("detectStaleLock_when_autoRecover_true_and_dead_pid_deletes_lock_and_returns_recovered", async () => {
const path = lockPath("sf.lock");
writeLock(path, DEAD_PID);
const result = await detectStaleLock([path], { autoRecover: true });
assert.equal(result.stuck, true);
assert.equal(result.recovered, true);
// The lock file must be gone after autoRecover.
assert.equal(
existsSync(path),
false,
"Lock file must be deleted by autoRecover",
);
});
// ── autoRecover: false (default) → reports stuck but does NOT delete ──────
test("detectStaleLock_when_autoRecover_false_does_not_delete_lock", async () => {
const path = lockPath("sf.lock");
writeLock(path, DEAD_PID);
const result = await detectStaleLock([path], { autoRecover: false });
assert.equal(result.stuck, true);
assert.equal(result.recovered, false);
assert.equal(
existsSync(path),
true,
"Lock file must remain when autoRecover is false",
);
});
// ── Multiple paths: first stale lock wins ────────────────────────────────
test("detectStaleLock_when_multiple_paths_first_stale_lock_is_returned", async () => {
const livePath = lockPath("live.lock");
const deadPath = lockPath("dead.lock");
writeLock(livePath, LIVE_PID);
writeLock(deadPath, DEAD_PID);
const result = await detectStaleLock([livePath, deadPath]);
assert.equal(result.stuck, true);
assert.equal(result.signature.lockPath, deadPath);
});
// ── Empty paths array → not stuck ────────────────────────────────────────
test("detectStaleLock_when_empty_paths_returns_not_stuck", async () => {
const result = await detectStaleLock([]);
assert.deepEqual(result, { stuck: false });
});
// ── Null/undefined paths → not stuck (graceful) ──────────────────────────
test("detectStaleLock_when_null_paths_returns_not_stuck", async () => {
const result = await detectStaleLock(null);
assert.deepEqual(result, { stuck: false });
});

View file

@ -8,8 +8,14 @@
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { formatTokenCount } from "@singularity-forge/coding-agent";
import {
runDetectorSweep,
SWEEP_CADENCE_MS,
} from "../detectors/periodic-runner.js";
import { evaluateZeroProgress } from "../detectors/zero-progress.js";
import { _getAdapter } from "../sf-db/sf-db-core.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;
@ -20,6 +26,46 @@ const EXECUTE_NO_PROGRESS_TOOL_WARNING = 25;
const EXECUTE_NO_PROGRESS_TOKEN_WARNING = 500_000;
const DURABLE_SF_ARTIFACT_PATHS = [".sf/milestones", ".sf/approvals"];
let state = null;
// ── Wiggums detector sweep state (R053-R056) ─────────────────────────────
// runDetectorSweep is async; evaluateRunawayGuard is sync. We fire the sweep
// fire-and-forget on each tick and consume the previous tick's result on the
// next call — one-tick delayed effect, documented choice.
let sweepState = {
lastFiredAt: 0,
throttleState: new Map(),
pendingFired: /** @type {Array<{name: string, signature: unknown}>} */ ([]),
};
/**
* Read the last N self-feedback rows from the DB within a time window.
* Returns [] when the DB is unavailable the detector handles missing data.
* @param {number} windowMs
* @returns {Array<{kind: string, timestamp: number, occurredIn?: unknown}>}
*/
function readRecentFeedbackFromDb(windowMs = 30 * 60 * 1000) {
try {
const db = _getAdapter();
if (!db) return [];
const cutoff = Date.now() - windowMs;
const rows = db
.prepare(
"SELECT kind, ts, occurred_in_milestone, occurred_in_slice, occurred_in_task FROM self_feedback WHERE ts >= :cutoff ORDER BY ts DESC LIMIT 100",
)
.all({ ":cutoff": cutoff });
return rows.map((row) => ({
kind: row.kind,
timestamp: row.ts,
occurredIn: row.occurred_in_milestone
? {
milestone: row.occurred_in_milestone,
slice: row.occurred_in_slice ?? undefined,
task: row.occurred_in_task ?? undefined,
}
: undefined,
}));
} catch {
return [];
}
}
export function resetRunawayGuardState(unitType, unitId, baseline) {
state = {
unitKey: `${unitType}/${unitId}`,
@ -40,6 +86,7 @@ export function resetRunawayGuardState(unitType, unitId, baseline) {
}
export function clearRunawayGuardState() {
state = null;
sweepState = { lastFiredAt: 0, throttleState: new Map(), pendingFired: [] };
}
export function resolveRunawayGuardConfig(supervisor) {
return {
@ -236,6 +283,62 @@ export function evaluateRunawayGuard(
},
};
}
// ── Wiggums detector sweep (R053-R056) ──────────────────────────────────
// Consume the result buffered from the previous tick's async sweep. If any
// detector fired, treat it as a hard fail (same severity as zero-progress).
// runDetectorSweep is async; we fire fire-and-forget here so the sync
// evaluateRunawayGuard contract is preserved, with one-tick delay before
// a result can act.
const pendingFired = sweepState.pendingFired;
sweepState.pendingFired = [];
if (pendingFired.length > 0) {
const fired = pendingFired[0];
return {
action: "fail",
reason: `detector-sweep:${fired.name}`,
metadata: {
reason: `detector-sweep:${fired.name}`,
failedAt: now,
unitType,
unitId,
metrics: unitMetrics,
detectorsFired: pendingFired,
selfFeedback: {
kind: `wiggums:${fired.name}`,
severity: "high",
evidence: JSON.stringify(fired.signature),
},
},
};
}
// Fire a new sweep for the next tick — cadence-throttled, fire-and-forget.
if (now - sweepState.lastFiredAt >= SWEEP_CADENCE_MS) {
sweepState.lastFiredAt = now;
const basePath = metrics.basePath;
const sweepCtx = {
unitId,
recentDispatches: undefined,
unitMetrics,
sessionFingerprint: unitMetrics.worktreeFingerprint ?? null,
recentFeedback: readRecentFeedbackFromDb(),
artifactHistory: undefined,
lockPaths: basePath
? [
join(basePath, ".sf/sf.lock"),
join(basePath, ".sf/interactive.lock"),
]
: [".sf/sf.lock", ".sf/interactive.lock"],
};
runDetectorSweep(sweepCtx, { throttleState: sweepState.throttleState })
.then((result) => {
if (result.detectorsFired.length > 0) {
sweepState.pendingFired = result.detectorsFired;
}
})
.catch(() => {
// Sweep errors must not interrupt the guard tick.
});
}
const reasons = thresholdReasons(unitType, unitMetrics, config);
if (reasons.length === 0) return { action: "none" };
if (

View file

@ -658,7 +658,11 @@ export async function runPostUnitVerification(vctx, pauseAuto) {
const gateRunner = new UokGateRunner();
gateRunner.register({
id: "post-execution-checks",
type: "artifact",
// type must be one of ADR-0075's 5 (security|policy|verification|
// learning|chaos). This gate verifies post-execution artifacts
// exist + pass checks → verification. The "artifact" failureClass
// below is still correct as the failure CATEGORY (a different field).
type: "verification",
execute: async () => ({
outcome: blockingFailure ? "fail" : "pass",
failureClass: