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:
parent
527ebfcaa4
commit
625a830d2f
12 changed files with 822 additions and 3 deletions
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
13
src/resources/extensions/sf/detectors/index.js
Normal file
13
src/resources/extensions/sf/detectors/index.js
Normal 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";
|
||||
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
173
src/resources/extensions/sf/tests/detector-stale-lock.test.mjs
Normal file
173
src/resources/extensions/sf/tests/detector-stale-lock.test.mjs
Normal 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 });
|
||||
});
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue