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:
Mikael Hugo 2026-05-17 21:15:21 +02:00
parent acd907fec2
commit ab2c996866
4 changed files with 398 additions and 0 deletions

View file

@ -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",
};
},
};

View file

@ -7,6 +7,7 @@
export { artifactFlapGate } from "./artifact-flap.js"; export { artifactFlapGate } from "./artifact-flap.js";
export { crashLoopGate } from "./crash-loop-classifier.js"; export { crashLoopGate } from "./crash-loop-classifier.js";
export { gateDeadlockClassifierGate } from "./gate-deadlock-classifier.js";
export { periodicDetectorSweepGate } from "./periodic-runner.js"; export { periodicDetectorSweepGate } from "./periodic-runner.js";
export { productionPlateauGate } from "./production-plateau.js"; export { productionPlateauGate } from "./production-plateau.js";
export { repeatedFeedbackKindGate } from "./repeated-feedback-kind.js"; export { repeatedFeedbackKindGate } from "./repeated-feedback-kind.js";

View file

@ -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);
});
});

View file

@ -12,6 +12,7 @@ import { artifactFlapGate } from "../detectors/artifact-flap.js";
import { staleLockGate } from "../detectors/stale-lock.js"; import { staleLockGate } from "../detectors/stale-lock.js";
import { periodicDetectorSweepGate } from "../detectors/periodic-runner.js"; import { periodicDetectorSweepGate } from "../detectors/periodic-runner.js";
import { serverDirectionDriftGate } from "../detectors/server-direction-drift.js"; import { serverDirectionDriftGate } from "../detectors/server-direction-drift.js";
import { gateDeadlockClassifierGate } from "../detectors/gate-deadlock-classifier.js";
import { inlineRuntimeGate } from "./inline-runtime-gate.js"; import { inlineRuntimeGate } from "./inline-runtime-gate.js";
/** /**
@ -45,5 +46,9 @@ registry.register(staleLockGate);
registry.register(serverDirectionDriftGate); registry.register(serverDirectionDriftGate);
registry.register(periodicDetectorSweepGate); registry.register(periodicDetectorSweepGate);
registry.register(inlineRuntimeGate); 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 }; export { registry as gateRegistry };