feat(detectors): gate-deadlock-classifier — Wiggums detector for R074 self-deadlock
The R074 inlineRuntimeGate refused inline dispatch for M048/S05 reassess-roadmap
because R020 and R066 are still 'active' — but those slices ARE the work that
validates R066. Autonomous mode stopped with no way to escape. Filed earlier as
sf-mpa4f9k1-jm01rc.
This detector classifies the pattern at runtime:
parseGateRefusal(rationale)
extracts gateId + refused requirement ids from gate-refusal text
matching shape "[gate-id] ... R020=active R066=active ..."
detectGateDeadlock(ctx, options)
ctx.gateRefusals: recent gate refusal events ({rationale, unitType, unitId})
ctx.requirementCoverageByMilestone: milestone -> R-ids in its DoD/coverage
ctx.resolveMilestoneId: optional unit -> milestone resolver
(default: strip after '/', require M-prefix)
Returns { stuck, reason: "gate-deadlock", signature: {
gateId, deadlockedRequirements, refusedUnits, examples, suggestedAction
}} when any refused unit's milestone coverage overlaps the gate's refused
requirements. Per-gateId throttle prevents repeat firings within 60s.
gateDeadlockClassifierGate
UokGate (type=verification per ADR-0075) wrapping the detector for
integration into periodicDetectorSweepGate + post-finalize sweeps.
Registered in uok/gate-registry-bootstrap.js between inlineRuntimeGate and the
existing detector chain. Also re-exported from detectors/index.js for the
common detector import surface.
Test coverage:
- parseGateRefusal: 5 cases (inline shape, dedup, missing reqs, missing gate, empty)
- detectGateDeadlock: 7 cases (empty input, fire-on-overlap, no-overlap,
empty coverage, throttle, custom resolver,
examples cap)
- UokGate wrapper: 3 cases (contract shape, pass, fail-with-findings)
- Threshold export sanity: 1 case
16/16 tests pass.
The wiring from autonomous-loop output (where gate refusals are emitted) into
the detector's gateRefusals input is a follow-up — this commit lands the
detector with a stable contract and tests it can be wired against.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
acd907fec2
commit
ab2c996866
4 changed files with 398 additions and 0 deletions
|
|
@ -0,0 +1,198 @@
|
|||
/**
|
||||
* gate-deadlock-classifier.js — detect closed-loop UokGate refusals.
|
||||
*
|
||||
* Purpose: catch the pattern where a UokGate refuses dispatch on a unit
|
||||
* whose purpose would advance the very requirement validation the gate
|
||||
* is waiting for. SF cannot lift a gate that's blocking the work that
|
||||
* would lift the gate.
|
||||
*
|
||||
* Concrete trigger (2026-05-17 incident): R074 inlineRuntimeGate refused
|
||||
* inline dispatch for M048/S05 reassess-roadmap because R020 and R066
|
||||
* were still 'active' — but reassess-roadmap + M048 slices ARE the work
|
||||
* that validates R066. Autonomous mode stopped permanently until manual
|
||||
* operator intervention.
|
||||
*
|
||||
* Consumer: periodic-runner.js default detector list and the autonomous
|
||||
* loop's post-dispatch sweep. Filed as sf-mpa4f9k1-jm01rc.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Minimum number of distinct refused units sharing the same gate before
|
||||
* firing — guards against a single transient refusal triggering a
|
||||
* false-positive deadlock report.
|
||||
*/
|
||||
export const DEADLOCK_REFUSAL_THRESHOLD = 1;
|
||||
|
||||
/**
|
||||
* Parse a gate refusal rationale into { gateId, refusedRequirements }.
|
||||
*
|
||||
* Purpose: extract the requirement identifiers a gate is waiting on so
|
||||
* the detector can cross-reference against the refused unit's milestone
|
||||
* coverage. The rationale format mirrors the existing inlineRuntimeGate
|
||||
* shape: "[<gate-id>] ... R020=active R066=active ...".
|
||||
*
|
||||
* Returns null if the input doesn't match the expected refusal shape.
|
||||
*
|
||||
* @param {string} rationale
|
||||
* @returns {{ gateId: string, refusedRequirements: string[] } | null}
|
||||
*/
|
||||
export function parseGateRefusal(rationale) {
|
||||
if (typeof rationale !== "string" || rationale.length === 0) return null;
|
||||
const gateMatch = rationale.match(/\[([a-z][a-z0-9-]+(?:-gate)?)\]/);
|
||||
if (!gateMatch) return null;
|
||||
const reqMatches = rationale.match(/\bR\d{2,}\b(?==active|=pending|=deferred)/g);
|
||||
if (!reqMatches || reqMatches.length === 0) return null;
|
||||
const seen = new Set();
|
||||
const refusedRequirements = [];
|
||||
for (const id of reqMatches) {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
refusedRequirements.push(id);
|
||||
}
|
||||
}
|
||||
return { gateId: gateMatch[1], refusedRequirements };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect closed-loop gate refusals.
|
||||
*
|
||||
* Purpose: classify the autonomous-loop halt pattern where the refusing
|
||||
* gate is waiting on requirements that the refused unit would advance.
|
||||
*
|
||||
* Inputs (ctx):
|
||||
* - gateRefusals?: Array<{ rationale, unitType?, unitId?, ts? }>
|
||||
* - requirementCoverageByMilestone?: Record<string, string[]>
|
||||
* (milestone id → list of R-ids in its definition_of_done / coverage)
|
||||
* - resolveMilestoneId?: (unitId) => string | null
|
||||
* (resolver for unitId → owning milestone, defaults to "M\d+" prefix)
|
||||
* - now?: number — current time for throttling
|
||||
* - throttleState?: Map<string, number> — per-gateId last-fired ms
|
||||
* - options?: { throttleMs?: number, threshold?: number }
|
||||
*
|
||||
* Returns standard detector shape: { stuck, reason, signature }.
|
||||
*
|
||||
* @returns {{ stuck: boolean, reason: string, signature: object }}
|
||||
*/
|
||||
export function detectGateDeadlock(ctx = {}, options = {}) {
|
||||
const refusals = Array.isArray(ctx.gateRefusals) ? ctx.gateRefusals : [];
|
||||
if (refusals.length === 0) {
|
||||
return { stuck: false, reason: "", signature: {} };
|
||||
}
|
||||
const coverageMap =
|
||||
ctx.requirementCoverageByMilestone &&
|
||||
typeof ctx.requirementCoverageByMilestone === "object"
|
||||
? ctx.requirementCoverageByMilestone
|
||||
: {};
|
||||
const resolveMilestone =
|
||||
typeof ctx.resolveMilestoneId === "function"
|
||||
? ctx.resolveMilestoneId
|
||||
: defaultResolveMilestone;
|
||||
const now = options.now ?? ctx.now ?? Date.now();
|
||||
const throttleMs = options.throttleMs ?? 60_000;
|
||||
const threshold = options.threshold ?? DEADLOCK_REFUSAL_THRESHOLD;
|
||||
const throttleState =
|
||||
ctx.throttleState instanceof Map ? ctx.throttleState : null;
|
||||
|
||||
// Group refusals by gateId and accumulate the set of refused unit/milestone
|
||||
// pairs whose milestone coverage overlaps with the gate's refused
|
||||
// requirements. A deadlock fires when threshold ≥ DEADLOCK_REFUSAL_THRESHOLD
|
||||
// distinct overlapping unit/milestone pairs are observed.
|
||||
const byGate = new Map();
|
||||
for (const refusal of refusals) {
|
||||
const parsed = parseGateRefusal(refusal?.rationale);
|
||||
if (!parsed) continue;
|
||||
const milestoneId = resolveMilestone(refusal?.unitId ?? "");
|
||||
if (!milestoneId) continue;
|
||||
const milestoneCoverage = coverageMap[milestoneId];
|
||||
if (!Array.isArray(milestoneCoverage) || milestoneCoverage.length === 0)
|
||||
continue;
|
||||
const overlap = parsed.refusedRequirements.filter((id) =>
|
||||
milestoneCoverage.includes(id),
|
||||
);
|
||||
if (overlap.length === 0) continue;
|
||||
let bucket = byGate.get(parsed.gateId);
|
||||
if (!bucket) {
|
||||
bucket = {
|
||||
gateId: parsed.gateId,
|
||||
deadlockedRequirements: new Set(),
|
||||
refusedUnits: new Set(),
|
||||
examples: [],
|
||||
};
|
||||
byGate.set(parsed.gateId, bucket);
|
||||
}
|
||||
for (const id of overlap) bucket.deadlockedRequirements.add(id);
|
||||
bucket.refusedUnits.add(`${milestoneId}/${refusal?.unitId ?? "?"}`);
|
||||
if (bucket.examples.length < 3) {
|
||||
bucket.examples.push({
|
||||
unitId: refusal?.unitId ?? null,
|
||||
unitType: refusal?.unitType ?? null,
|
||||
milestoneId,
|
||||
overlap,
|
||||
ts: refusal?.ts ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const bucket of byGate.values()) {
|
||||
if (bucket.refusedUnits.size < threshold) continue;
|
||||
if (throttleState) {
|
||||
const last = throttleState.get(`gate-deadlock:${bucket.gateId}`);
|
||||
if (typeof last === "number" && now - last < throttleMs) continue;
|
||||
throttleState.set(`gate-deadlock:${bucket.gateId}`, now);
|
||||
}
|
||||
return {
|
||||
stuck: true,
|
||||
reason: "gate-deadlock",
|
||||
signature: {
|
||||
gateId: bucket.gateId,
|
||||
deadlockedRequirements: [...bucket.deadlockedRequirements],
|
||||
refusedUnits: [...bucket.refusedUnits],
|
||||
examples: bucket.examples,
|
||||
suggestedAction:
|
||||
"file self-feedback (kind=gate-deadlock-detected, severity=high); " +
|
||||
"consider audited bypass via gate-specific env or spawn-mode retry until " +
|
||||
"deadlocked requirements validate",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { stuck: false, reason: "", signature: {} };
|
||||
}
|
||||
|
||||
/** Default unitId → milestoneId resolver: strip everything from first '/'. */
|
||||
function defaultResolveMilestone(unitId) {
|
||||
if (typeof unitId !== "string" || unitId.length === 0) return null;
|
||||
const idx = unitId.indexOf("/");
|
||||
if (idx <= 0) return unitId.startsWith("M") ? unitId : null;
|
||||
const head = unitId.slice(0, idx);
|
||||
return head.startsWith("M") ? head : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the gate-deadlock classifier as a UOK verification gate.
|
||||
*
|
||||
* Purpose: surface the deadlock via the common gate contract so it can
|
||||
* be wired into periodicDetectorSweepGate and post-finalize sweeps.
|
||||
*
|
||||
* Consumer: gate-registry-bootstrap.js and tests.
|
||||
*/
|
||||
export const gateDeadlockClassifierGate = {
|
||||
id: "gate-deadlock-classifier",
|
||||
type: "verification",
|
||||
async execute(ctx = {}) {
|
||||
const result = detectGateDeadlock(ctx, ctx.options ?? {});
|
||||
if (result.stuck) {
|
||||
return {
|
||||
outcome: "fail",
|
||||
failureClass: "verification",
|
||||
rationale: `gate-deadlock on ${result.signature.gateId}: refused units ${result.signature.refusedUnits.join(", ")} need ${result.signature.deadlockedRequirements.join(", ")} which the gate is waiting for`,
|
||||
findings: result.signature,
|
||||
};
|
||||
}
|
||||
return {
|
||||
outcome: "pass",
|
||||
failureClass: null,
|
||||
rationale: "no gate-deadlock pattern",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
export { artifactFlapGate } from "./artifact-flap.js";
|
||||
export { crashLoopGate } from "./crash-loop-classifier.js";
|
||||
export { gateDeadlockClassifierGate } from "./gate-deadlock-classifier.js";
|
||||
export { periodicDetectorSweepGate } from "./periodic-runner.js";
|
||||
export { productionPlateauGate } from "./production-plateau.js";
|
||||
export { repeatedFeedbackKindGate } from "./repeated-feedback-kind.js";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,194 @@
|
|||
/**
|
||||
* detector-gate-deadlock-classifier.test.mjs — tests for the gate-deadlock
|
||||
* Wiggums detector that fires when a UokGate refuses dispatch on a unit
|
||||
* whose milestone coverage would advance the requirements the gate is
|
||||
* waiting for.
|
||||
*
|
||||
* Covers parseGateRefusal helper, the detector function across the
|
||||
* deadlock-pattern × purpose-match × throttle × empty-input matrix, and
|
||||
* the UokGate wrapper contract per ADR-0075.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEADLOCK_REFUSAL_THRESHOLD,
|
||||
detectGateDeadlock,
|
||||
gateDeadlockClassifierGate,
|
||||
parseGateRefusal,
|
||||
} from "../detectors/gate-deadlock-classifier.js";
|
||||
|
||||
const INLINE_REFUSAL =
|
||||
"[inline-runtime-gate] inline dispatch refused: R020=active R066=active both must be 'validated' or set SF_INLINE_DISPATCH=1 for audited bypass";
|
||||
|
||||
// ─── parseGateRefusal ──────────────────────────────────────────────────────
|
||||
|
||||
describe("parseGateRefusal", () => {
|
||||
it("extracts gate id and refused requirements from inline refusal", () => {
|
||||
const parsed = parseGateRefusal(INLINE_REFUSAL);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed.gateId).toBe("inline-runtime-gate");
|
||||
expect(parsed.refusedRequirements).toEqual(["R020", "R066"]);
|
||||
});
|
||||
|
||||
it("dedupes repeated requirement ids", () => {
|
||||
const parsed = parseGateRefusal(
|
||||
"[some-gate] refused: R020=active R020=active R066=pending",
|
||||
);
|
||||
expect(parsed.refusedRequirements).toEqual(["R020", "R066"]);
|
||||
});
|
||||
|
||||
it("returns null when no requirement ids are present", () => {
|
||||
expect(parseGateRefusal("[some-gate] generic failure")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when no gate id bracket is present", () => {
|
||||
expect(parseGateRefusal("R020=active R066=active no gate")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on empty / non-string input", () => {
|
||||
expect(parseGateRefusal("")).toBeNull();
|
||||
expect(parseGateRefusal(null)).toBeNull();
|
||||
expect(parseGateRefusal(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── detectGateDeadlock ────────────────────────────────────────────────────
|
||||
|
||||
describe("detectGateDeadlock", () => {
|
||||
it("returns not-stuck for empty refusal list", () => {
|
||||
expect(detectGateDeadlock({ gateRefusals: [] })).toEqual({
|
||||
stuck: false,
|
||||
reason: "",
|
||||
signature: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("fires when refused unit's milestone covers the deadlocked requirement", () => {
|
||||
const result = detectGateDeadlock({
|
||||
gateRefusals: [
|
||||
{
|
||||
rationale: INLINE_REFUSAL,
|
||||
unitType: "reassess-roadmap",
|
||||
unitId: "M048/S05",
|
||||
ts: "2026-05-17T18:24:00Z",
|
||||
},
|
||||
],
|
||||
requirementCoverageByMilestone: { M048: ["R066", "R078"] },
|
||||
});
|
||||
expect(result.stuck).toBe(true);
|
||||
expect(result.reason).toBe("gate-deadlock");
|
||||
expect(result.signature.gateId).toBe("inline-runtime-gate");
|
||||
expect(result.signature.deadlockedRequirements).toEqual(["R066"]);
|
||||
expect(result.signature.refusedUnits).toEqual(["M048/M048/S05"]);
|
||||
expect(result.signature.suggestedAction).toMatch(/audited bypass/);
|
||||
});
|
||||
|
||||
it("does NOT fire when refused unit's milestone covers none of the gate requirements", () => {
|
||||
const result = detectGateDeadlock({
|
||||
gateRefusals: [
|
||||
{
|
||||
rationale: INLINE_REFUSAL,
|
||||
unitType: "execute-task",
|
||||
unitId: "M010/S04/T01",
|
||||
},
|
||||
],
|
||||
requirementCoverageByMilestone: { M010: ["R015"] },
|
||||
});
|
||||
expect(result.stuck).toBe(false);
|
||||
});
|
||||
|
||||
it("does NOT fire when milestone coverage map is empty for the unit's milestone", () => {
|
||||
const result = detectGateDeadlock({
|
||||
gateRefusals: [
|
||||
{ rationale: INLINE_REFUSAL, unitId: "M048/S05" },
|
||||
],
|
||||
requirementCoverageByMilestone: {},
|
||||
});
|
||||
expect(result.stuck).toBe(false);
|
||||
});
|
||||
|
||||
it("throttles repeat firings for the same gate within throttleMs", () => {
|
||||
const throttleState = new Map();
|
||||
const ctx = {
|
||||
gateRefusals: [
|
||||
{ rationale: INLINE_REFUSAL, unitId: "M048/S05" },
|
||||
],
|
||||
requirementCoverageByMilestone: { M048: ["R066"] },
|
||||
throttleState,
|
||||
};
|
||||
const first = detectGateDeadlock(ctx, { now: 1000 });
|
||||
expect(first.stuck).toBe(true);
|
||||
const second = detectGateDeadlock(ctx, { now: 5000 });
|
||||
expect(second.stuck).toBe(false);
|
||||
const third = detectGateDeadlock(ctx, { now: 1000 + 70_000 });
|
||||
expect(third.stuck).toBe(true);
|
||||
});
|
||||
|
||||
it("custom resolveMilestoneId overrides the default M-prefix resolver", () => {
|
||||
const result = detectGateDeadlock({
|
||||
gateRefusals: [
|
||||
{ rationale: INLINE_REFUSAL, unitId: "slice-xyz" },
|
||||
],
|
||||
requirementCoverageByMilestone: { CUSTOM: ["R020"] },
|
||||
resolveMilestoneId: () => "CUSTOM",
|
||||
});
|
||||
expect(result.stuck).toBe(true);
|
||||
expect(result.signature.deadlockedRequirements).toEqual(["R020"]);
|
||||
});
|
||||
|
||||
it("captures at most 3 example refusals in signature", () => {
|
||||
const refusals = ["M048/S01", "M048/S02", "M048/S03", "M048/S04"].map(
|
||||
(unitId) => ({ rationale: INLINE_REFUSAL, unitId }),
|
||||
);
|
||||
const result = detectGateDeadlock({
|
||||
gateRefusals: refusals,
|
||||
requirementCoverageByMilestone: { M048: ["R066"] },
|
||||
});
|
||||
expect(result.stuck).toBe(true);
|
||||
expect(result.signature.examples.length).toBeLessThanOrEqual(3);
|
||||
expect(result.signature.refusedUnits.length).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── gateDeadlockClassifierGate (UokGate wrapper, ADR-0075) ────────────────
|
||||
|
||||
describe("gateDeadlockClassifierGate", () => {
|
||||
it("exports the ADR-0075 gate contract shape", () => {
|
||||
expect(gateDeadlockClassifierGate.id).toBe("gate-deadlock-classifier");
|
||||
expect(gateDeadlockClassifierGate.type).toBe("verification");
|
||||
expect(typeof gateDeadlockClassifierGate.execute).toBe("function");
|
||||
});
|
||||
|
||||
it("execute returns pass when no deadlock pattern", async () => {
|
||||
const result = await gateDeadlockClassifierGate.execute({
|
||||
gateRefusals: [],
|
||||
});
|
||||
expect(result.outcome).toBe("pass");
|
||||
expect(result.failureClass).toBeNull();
|
||||
expect(result.rationale).toContain("no gate-deadlock");
|
||||
});
|
||||
|
||||
it("execute returns fail with structured findings when deadlock detected", async () => {
|
||||
const result = await gateDeadlockClassifierGate.execute({
|
||||
gateRefusals: [
|
||||
{ rationale: INLINE_REFUSAL, unitId: "M048/S05" },
|
||||
],
|
||||
requirementCoverageByMilestone: { M048: ["R066"] },
|
||||
});
|
||||
expect(result.outcome).toBe("fail");
|
||||
expect(result.failureClass).toBe("verification");
|
||||
expect(result.rationale).toContain("inline-runtime-gate");
|
||||
expect(result.rationale).toContain("R066");
|
||||
expect(result.findings.gateId).toBe("inline-runtime-gate");
|
||||
expect(result.findings.deadlockedRequirements).toEqual(["R066"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── threshold sanity ──────────────────────────────────────────────────────
|
||||
|
||||
describe("threshold export", () => {
|
||||
it("DEADLOCK_REFUSAL_THRESHOLD is a positive integer", () => {
|
||||
expect(DEADLOCK_REFUSAL_THRESHOLD).toBeGreaterThanOrEqual(1);
|
||||
expect(Number.isInteger(DEADLOCK_REFUSAL_THRESHOLD)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,7 @@ import { artifactFlapGate } from "../detectors/artifact-flap.js";
|
|||
import { staleLockGate } from "../detectors/stale-lock.js";
|
||||
import { periodicDetectorSweepGate } from "../detectors/periodic-runner.js";
|
||||
import { serverDirectionDriftGate } from "../detectors/server-direction-drift.js";
|
||||
import { gateDeadlockClassifierGate } from "../detectors/gate-deadlock-classifier.js";
|
||||
import { inlineRuntimeGate } from "./inline-runtime-gate.js";
|
||||
|
||||
/**
|
||||
|
|
@ -45,5 +46,9 @@ registry.register(staleLockGate);
|
|||
registry.register(serverDirectionDriftGate);
|
||||
registry.register(periodicDetectorSweepGate);
|
||||
registry.register(inlineRuntimeGate);
|
||||
// gate-deadlock-classifier — fires when a UokGate refuses dispatch on a
|
||||
// unit whose milestone coverage would advance the gate's deadlocked
|
||||
// requirements. Closes the R074 self-deadlock pattern (sf-mpa4f9k1).
|
||||
registry.register(gateDeadlockClassifierGate);
|
||||
|
||||
export { registry as gateRegistry };
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue