From 625a830d2f980c583b72803fabc0f555caaad35e Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 17 May 2026 14:18:54 +0200 Subject: [PATCH] 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) --- .../extensions/sf/detectors/artifact-flap.js | 29 ++ .../extensions/sf/detectors/index.js | 13 + .../sf/detectors/periodic-runner.js | 35 ++- .../sf/detectors/repeated-feedback-kind.js | 32 +++ .../extensions/sf/detectors/same-unit-loop.js | 33 +++ .../extensions/sf/detectors/stale-lock.js | 32 +++ .../extensions/sf/detectors/zero-progress.js | 33 +++ .../sf/tests/detector-gates-contract.test.mjs | 82 ++++++ .../tests/detector-periodic-runner.test.mjs | 254 ++++++++++++++++++ .../sf/tests/detector-stale-lock.test.mjs | 173 ++++++++++++ .../extensions/sf/uok/auto-runaway-guard.js | 103 +++++++ .../extensions/sf/uok/auto-verification.js | 6 +- 12 files changed, 822 insertions(+), 3 deletions(-) create mode 100644 src/resources/extensions/sf/detectors/index.js create mode 100644 src/resources/extensions/sf/tests/detector-gates-contract.test.mjs create mode 100644 src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs create mode 100644 src/resources/extensions/sf/tests/detector-stale-lock.test.mjs diff --git a/src/resources/extensions/sf/detectors/artifact-flap.js b/src/resources/extensions/sf/detectors/artifact-flap.js index 73bbaa742..cb41b7542 100644 --- a/src/resources/extensions/sf/detectors/artifact-flap.js +++ b/src/resources/extensions/sf/detectors/artifact-flap.js @@ -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", + }; + }, +}; diff --git a/src/resources/extensions/sf/detectors/index.js b/src/resources/extensions/sf/detectors/index.js new file mode 100644 index 000000000..fa846278a --- /dev/null +++ b/src/resources/extensions/sf/detectors/index.js @@ -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"; diff --git a/src/resources/extensions/sf/detectors/periodic-runner.js b/src/resources/extensions/sf/detectors/periodic-runner.js index 8e05924ba..a945a1331 100644 --- a/src/resources/extensions/sf/detectors/periodic-runner.js +++ b/src/resources/extensions/sf/detectors/periodic-runner.js @@ -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, + }; + }, +}; diff --git a/src/resources/extensions/sf/detectors/repeated-feedback-kind.js b/src/resources/extensions/sf/detectors/repeated-feedback-kind.js index 7352c2323..7b18458c4 100644 --- a/src/resources/extensions/sf/detectors/repeated-feedback-kind.js +++ b/src/resources/extensions/sf/detectors/repeated-feedback-kind.js @@ -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", + }; + }, +}; diff --git a/src/resources/extensions/sf/detectors/same-unit-loop.js b/src/resources/extensions/sf/detectors/same-unit-loop.js index 9463272fd..357d5fe05 100644 --- a/src/resources/extensions/sf/detectors/same-unit-loop.js +++ b/src/resources/extensions/sf/detectors/same-unit-loop.js @@ -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", + }; + }, +}; diff --git a/src/resources/extensions/sf/detectors/stale-lock.js b/src/resources/extensions/sf/detectors/stale-lock.js index 7499b11b2..e8b6276bc 100644 --- a/src/resources/extensions/sf/detectors/stale-lock.js +++ b/src/resources/extensions/sf/detectors/stale-lock.js @@ -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", + }; + }, +}; diff --git a/src/resources/extensions/sf/detectors/zero-progress.js b/src/resources/extensions/sf/detectors/zero-progress.js index 0bcd498d9..dbe451832 100644 --- a/src/resources/extensions/sf/detectors/zero-progress.js +++ b/src/resources/extensions/sf/detectors/zero-progress.js @@ -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", + }; + }, +}; diff --git a/src/resources/extensions/sf/tests/detector-gates-contract.test.mjs b/src/resources/extensions/sf/tests/detector-gates-contract.test.mjs new file mode 100644 index 000000000..634bbf62f --- /dev/null +++ b/src/resources/extensions/sf/tests/detector-gates-contract.test.mjs @@ -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); + }); + } +}); diff --git a/src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs b/src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs new file mode 100644 index 000000000..ed8c92474 --- /dev/null +++ b/src/resources/extensions/sf/tests/detector-periodic-runner.test.mjs @@ -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); +}); diff --git a/src/resources/extensions/sf/tests/detector-stale-lock.test.mjs b/src/resources/extensions/sf/tests/detector-stale-lock.test.mjs new file mode 100644 index 000000000..2109a7a9a --- /dev/null +++ b/src/resources/extensions/sf/tests/detector-stale-lock.test.mjs @@ -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 }); +}); diff --git a/src/resources/extensions/sf/uok/auto-runaway-guard.js b/src/resources/extensions/sf/uok/auto-runaway-guard.js index e1a68973a..5436f86a1 100644 --- a/src/resources/extensions/sf/uok/auto-runaway-guard.js +++ b/src/resources/extensions/sf/uok/auto-runaway-guard.js @@ -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 ( diff --git a/src/resources/extensions/sf/uok/auto-verification.js b/src/resources/extensions/sf/uok/auto-verification.js index cde810e03..1d95d06ca 100644 --- a/src/resources/extensions/sf/uok/auto-verification.js +++ b/src/resources/extensions/sf/uok/auto-verification.js @@ -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: