diff --git a/src/resources/extensions/sf/detectors/gate-deadlock-classifier.js b/src/resources/extensions/sf/detectors/gate-deadlock-classifier.js new file mode 100644 index 000000000..fe3e7e438 --- /dev/null +++ b/src/resources/extensions/sf/detectors/gate-deadlock-classifier.js @@ -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: "[] ... 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 + * (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 — 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", + }; + }, +}; diff --git a/src/resources/extensions/sf/detectors/index.js b/src/resources/extensions/sf/detectors/index.js index b67851686..7a9b28e6f 100644 --- a/src/resources/extensions/sf/detectors/index.js +++ b/src/resources/extensions/sf/detectors/index.js @@ -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"; diff --git a/src/resources/extensions/sf/tests/detector-gate-deadlock-classifier.test.mjs b/src/resources/extensions/sf/tests/detector-gate-deadlock-classifier.test.mjs new file mode 100644 index 000000000..3a20e0b06 --- /dev/null +++ b/src/resources/extensions/sf/tests/detector-gate-deadlock-classifier.test.mjs @@ -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); + }); +}); diff --git a/src/resources/extensions/sf/uok/gate-registry-bootstrap.js b/src/resources/extensions/sf/uok/gate-registry-bootstrap.js index 5bc136ef4..04afc062c 100644 --- a/src/resources/extensions/sf/uok/gate-registry-bootstrap.js +++ b/src/resources/extensions/sf/uok/gate-registry-bootstrap.js @@ -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 };