From 3327c6335357c7a7bc88da2fe8d92d1594d41e5e Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:01:36 -0700 Subject: [PATCH 1/4] feat(miner-governor): propagate the kill-switch into shouldSubmit (submission-gate.ts) #2339's own deliverable: the manage-phase submission-gate function must check the kill-switch/dry-run state as its FIRST guard, before any other logic -- flipping the kill-switch must halt any pending submission-gate check immediately, the same way it already halts the Governor chokepoint (#2340). killSwitchScope is a new REQUIRED field on SubmissionGateCandidate (not optional-with-a-permissive-default) so a caller cannot forget to resolve and pass it. Reuses isMinerKillSwitchActive (kill-switch.ts, #2341) directly -- the single shared helper #2339 asks for, not a bespoke wrapper -- rather than duplicating the check; the sibling loop-reentry-policy.ts change (next commit) consults the identical function. --- .../src/miner/submission-gate.ts | 19 ++++++++++++++++++- .../test/submission-gate.test.ts | 16 ++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/gittensory-engine/src/miner/submission-gate.ts b/packages/gittensory-engine/src/miner/submission-gate.ts index ac1849a20a..2e7d140600 100644 --- a/packages/gittensory-engine/src/miner/submission-gate.ts +++ b/packages/gittensory-engine/src/miner/submission-gate.ts @@ -23,7 +23,16 @@ // the Governor chokepoint's own dry-run/live action-mode dial (#2342), which gates autonomous WRITING at all // for a repo. `"observe"` here is specifically for safely calibrating the predicted-gate/slop thresholds // against live traffic before ever trusting them to gate a real submission. +// +// KILL-SWITCH (#2339): checked FIRST, before any other logic -- flipping the kill-switch must halt this +// chokepoint immediately, the same way it halts the Governor chokepoint (#2340). Reuses +// `isMinerKillSwitchActive` (kill-switch.ts, #2341) directly rather than a bespoke wrapper -- the exact "single +// shared helper, not duplicated per call site" #2339's own deliverable calls for; `loop-reentry-policy.ts`'s +// `shouldReenter` consults the identical function. `killSwitchScope` is REQUIRED (not optional-with-a- +// permissive-default) so a caller cannot forget to resolve and pass it -- the same fail-closed-by-construction +// discipline as every other required field here. +import { isMinerKillSwitchActive, type MinerKillSwitchScope } from "../governor/kill-switch.js"; import type { PredictedGateVerdict } from "../predicted-gate.js"; import type { SelfReviewSlopAssessment, SelfReviewSlopBand } from "./self-review-adapter.js"; @@ -51,6 +60,8 @@ export function isSlopBandWithinThreshold(band: SelfReviewSlopBand, threshold: S export type SubmissionGateMode = "observe" | "enforce"; export type SubmissionGateCandidate = { + /** Checked FIRST, before any other field below -- see the module doc comment's KILL-SWITCH section. */ + killSwitchScope: MinerKillSwitchScope; /** `null` means the predictor was unreachable or errored -- fails closed, exactly like a genuine non-passing * verdict, never treated as "no opinion, so allow". */ predictedGateVerdict: PredictedGateVerdict | null; @@ -92,9 +103,15 @@ function evaluateSubmissionSignals(candidate: SubmissionGateCandidate): string[] /** * THE gate: build (or invoke) `gittensory_open_pr`'s action spec ONLY when this returns `allow: true`. Requires * BOTH a clean predicted-gate pass AND a slop band at or under the configured threshold; any missing signal, or - * `mode: "observe"`, forces `allow: false`. Pure; identical inputs always yield the identical decision. + * `mode: "observe"`, forces `allow: false`. The kill-switch is checked FIRST, before any other logic -- an + * active kill-switch blocks unconditionally, regardless of otherwise-passing signals. Pure; identical inputs + * always yield the identical decision. */ export function shouldSubmit(candidate: SubmissionGateCandidate): SubmissionGateDecision { + if (isMinerKillSwitchActive(candidate.killSwitchScope)) { + return { allow: false, reasons: [`${candidate.killSwitchScope}_kill_switch_active`] }; + } + const reasons = evaluateSubmissionSignals(candidate); const signalsPass = reasons.length === 0; diff --git a/packages/gittensory-engine/test/submission-gate.test.ts b/packages/gittensory-engine/test/submission-gate.test.ts index a070684bc3..c51fe30f3e 100644 --- a/packages/gittensory-engine/test/submission-gate.test.ts +++ b/packages/gittensory-engine/test/submission-gate.test.ts @@ -51,6 +51,7 @@ function slop(band: SelfReviewSlopBand, slopRisk = 0): SelfReviewSlopAssessment function baseCandidate(overrides: Partial = {}): SubmissionGateCandidate { return { + killSwitchScope: "none", predictedGateVerdict: passingVerdict(), slopAssessment: slop("clean"), slopThreshold: "low", @@ -70,6 +71,21 @@ test("pass/pass: a clean predicted-gate pass with slop under threshold allows, w assert.deepEqual(decision, { allow: true, reasons: [] }); }); +test("kill-switch (#2339): a global kill-switch blocks unconditionally, even with every other signal otherwise passing", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "global" })); + assert.deepEqual(decision, { allow: false, reasons: ["global_kill_switch_active"] }); +}); + +test("kill-switch (#2339): a per-repo kill-switch blocks unconditionally, checked before any signal or mode logic", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "repo", mode: "observe" })); + assert.deepEqual(decision, { allow: false, reasons: ["repo_kill_switch_active"] }); +}); + +test("kill-switch (#2339): an inactive kill-switch (scope 'none') never itself blocks -- signals are still evaluated normally", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "none" })); + assert.equal(decision.allow, true); +}); + test("fail/pass: a non-passing predicted-gate verdict blocks even with slop cleanly under threshold", () => { const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict() })); assert.equal(decision.allow, false); From 6d76dae768ea550b59bd07190d9f4ad499e987db Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:08:20 -0700 Subject: [PATCH 2/4] feat(miner-governor): propagate the kill-switch into shouldReenter (loop-reentry-policy.ts) The sibling half of #2339's own deliverable: the manage-phase loop- reentry function must ALSO check the kill-switch/dry-run state as its FIRST guard, before any other logic. killSwitchScope is a new REQUIRED field on LoopReentryCandidate, checked via the identical isMinerKillSwitchActive helper submission-gate.ts's shouldSubmit now consults (the "single shared helper, not duplicated per call site" this issue asks for) -- not a bespoke wrapper. Threads killSwitchScope through packages/gittensory-miner/lib/loop- reentry.js's attemptLoopReentry (validated fail-closed alongside its existing repoFullName/outcome checks) and into the audit event payload for traceability. --- .../src/miner/loop-reentry-policy.ts | 19 +++++++- .../test/loop-reentry-policy.test.ts | 18 ++++++++ .../gittensory-miner/lib/loop-reentry.d.ts | 3 ++ packages/gittensory-miner/lib/loop-reentry.js | 5 +- test/unit/miner-loop-reentry.test.ts | 46 +++++++++++++------ 5 files changed, 76 insertions(+), 15 deletions(-) diff --git a/packages/gittensory-engine/src/miner/loop-reentry-policy.ts b/packages/gittensory-engine/src/miner/loop-reentry-policy.ts index 381ac416d0..804ac52f33 100644 --- a/packages/gittensory-engine/src/miner/loop-reentry-policy.ts +++ b/packages/gittensory-engine/src/miner/loop-reentry-policy.ts @@ -14,6 +14,16 @@ // re-entries may fire in a rolling hour or across the whole session. // Both reasons are collected (not short-circuited) so a caller logging the decision sees every ceiling that // was hit, not just the first one checked. +// +// KILL-SWITCH (#2339): checked FIRST, before any other logic -- flipping the kill-switch must halt any pending +// re-entry immediately, the same way it halts the Governor chokepoint (#2340). Reuses +// `isMinerKillSwitchActive` (kill-switch.ts, #2341) directly -- the identical shared helper +// `submission-gate.ts`'s `shouldSubmit` consults, per #2339's own "single shared helper, not duplicated per +// call site" deliverable. Unlike the reasons above, the kill-switch check DOES short-circuit (an active kill- +// switch is the only reason reported) -- "as their FIRST guard, before any other logic" reads as "don't even +// evaluate the rest," not "collect this alongside the rest." + +import { isMinerKillSwitchActive, type MinerKillSwitchScope } from "../governor/kill-switch.js"; /** The terminal outcome that just resolved for the repo the caller is considering re-entering on. */ export type LoopReentryOutcome = "merged" | "disengaged" | "other"; @@ -23,6 +33,8 @@ export const DEFAULT_MAX_REENTRIES_PER_HOUR = 4; export const DEFAULT_MAX_REENTRIES_PER_SESSION = 20; export type LoopReentryCandidate = { + /** Checked FIRST, before any other field below -- see the module doc comment's KILL-SWITCH section. */ + killSwitchScope: MinerKillSwitchScope; repoFullName: string; outcome: LoopReentryOutcome; /** Caller-computed count of CONSECUTIVE `"disengaged"` outcomes for this repo, ending with (and including, @@ -48,9 +60,14 @@ export type LoopReentryDecision = { /** * Decide whether the loop may re-enter discovery for this repo. Pure; identical inputs always yield the * identical decision. `outcome === "merged"` alone never bypasses the rate/session cap -- a healthy repo can - * still be rate-limited if the operator-wide ceiling is already spent. + * still be rate-limited if the operator-wide ceiling is already spent. The kill-switch is checked FIRST and + * short-circuits everything else -- an active kill-switch blocks unconditionally. */ export function shouldReenter(candidate: LoopReentryCandidate): LoopReentryDecision { + if (isMinerKillSwitchActive(candidate.killSwitchScope)) { + return { reenter: false, reasons: [`${candidate.killSwitchScope}_kill_switch_active`] }; + } + const reasons: string[] = []; const maxConsecutiveDisengagements = candidate.maxConsecutiveDisengagements ?? DEFAULT_MAX_CONSECUTIVE_DISENGAGEMENTS; const maxReentriesPerHour = candidate.maxReentriesPerHour ?? DEFAULT_MAX_REENTRIES_PER_HOUR; diff --git a/packages/gittensory-engine/test/loop-reentry-policy.test.ts b/packages/gittensory-engine/test/loop-reentry-policy.test.ts index af86561c18..14b6eeff09 100644 --- a/packages/gittensory-engine/test/loop-reentry-policy.test.ts +++ b/packages/gittensory-engine/test/loop-reentry-policy.test.ts @@ -11,6 +11,7 @@ import { function baseCandidate(overrides: Partial = {}): LoopReentryCandidate { return { + killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged", consecutiveDisengagements: 0, @@ -30,6 +31,23 @@ test("a merged outcome with every counter well within limits re-enters cleanly", assert.deepEqual(decision, { reenter: true, reasons: [] }); }); +test("kill-switch (#2339): a global kill-switch blocks unconditionally, even with every counter otherwise clear", () => { + const decision = shouldReenter(baseCandidate({ killSwitchScope: "global" })); + assert.deepEqual(decision, { reenter: false, reasons: ["global_kill_switch_active"] }); +}); + +test("kill-switch (#2339): a per-repo kill-switch blocks unconditionally, checked before the circuit breaker or rate caps", () => { + const decision = shouldReenter( + baseCandidate({ killSwitchScope: "repo", outcome: "disengaged", consecutiveDisengagements: 99 }), + ); + assert.deepEqual(decision, { reenter: false, reasons: ["repo_kill_switch_active"] }); +}); + +test("kill-switch (#2339): an inactive kill-switch (scope 'none') never itself blocks -- other checks are still evaluated normally", () => { + const decision = shouldReenter(baseCandidate({ killSwitchScope: "none" })); + assert.equal(decision.reenter, true); +}); + test("an 'other' outcome (neither merged nor disengaged) is never subject to the per-repo circuit breaker", () => { const decision = shouldReenter(baseCandidate({ outcome: "other" })); assert.deepEqual(decision, { reenter: true, reasons: [] }); diff --git a/packages/gittensory-miner/lib/loop-reentry.d.ts b/packages/gittensory-miner/lib/loop-reentry.d.ts index 75fcda5a34..ab4b3721bf 100644 --- a/packages/gittensory-miner/lib/loop-reentry.d.ts +++ b/packages/gittensory-miner/lib/loop-reentry.d.ts @@ -1,8 +1,11 @@ export const LOOP_REENTRY_DECISION_EVENT: "loop_reentry_decision"; export type LoopReentryOutcome = "merged" | "disengaged" | "other"; +export type LoopReentryKillSwitchScope = "global" | "repo" | "none"; export type LoopReentryCandidateInput = { + /** Checked FIRST by the pure `shouldReenter` policy, before any other logic. */ + killSwitchScope: LoopReentryKillSwitchScope; repoFullName: string; outcome: LoopReentryOutcome; maxConsecutiveDisengagements?: number; diff --git a/packages/gittensory-miner/lib/loop-reentry.js b/packages/gittensory-miner/lib/loop-reentry.js index 4fe248a315..c89821fffd 100644 --- a/packages/gittensory-miner/lib/loop-reentry.js +++ b/packages/gittensory-miner/lib/loop-reentry.js @@ -56,11 +56,12 @@ export function countReentriesSince(eventLedger, sinceMs) { * event. Fails closed (throws) on a malformed candidate or missing required dependency, mirroring * `recordManagePollSnapshot`'s own validation style. * - * @param {{ repoFullName: string, outcome: "merged"|"disengaged"|"other", maxConsecutiveDisengagements?: number, maxReentriesPerHour?: number, maxReentriesPerSession?: number }} candidate + * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, outcome: "merged"|"disengaged"|"other", maxConsecutiveDisengagements?: number, maxReentriesPerHour?: number, maxReentriesPerSession?: number }} candidate * @param {{ eventLedger: object, portfolioQueue: object, runState?: object, nowMs?: number, sessionStartMs?: number }} deps */ export function attemptLoopReentry(candidate, deps) { if (!candidate || typeof candidate !== "object") throw new Error("invalid_loop_reentry_candidate"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; if (!repoFullName) throw new Error("invalid_repo_full_name"); if (!["merged", "disengaged", "other"].includes(candidate.outcome)) throw new Error("invalid_outcome"); @@ -79,6 +80,7 @@ export function attemptLoopReentry(candidate, deps) { const reentriesThisSession = countReentriesSince(eventLedger, sessionStartMs); const decision = shouldReenter({ + killSwitchScope: candidate.killSwitchScope, repoFullName, outcome: candidate.outcome, consecutiveDisengagements, @@ -101,6 +103,7 @@ export function attemptLoopReentry(candidate, deps) { type: LOOP_REENTRY_DECISION_EVENT, repoFullName, payload: { + killSwitchScope: candidate.killSwitchScope, outcome: candidate.outcome, reentered: decision.reenter, reasons: decision.reasons, diff --git a/test/unit/miner-loop-reentry.test.ts b/test/unit/miner-loop-reentry.test.ts index 7f3a63c0b6..23888effd1 100644 --- a/test/unit/miner-loop-reentry.test.ts +++ b/test/unit/miner-loop-reentry.test.ts @@ -53,7 +53,7 @@ describe("attemptLoopReentry (#2338)", () => { portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-42" }); const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "merged" }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue, runState }, ); @@ -84,7 +84,7 @@ describe("attemptLoopReentry (#2338)", () => { expect(countConsecutiveDisengagements(eventLedger, "acme/widgets")).toBe(3); const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "disengaged" }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "disengaged" }, { eventLedger, portfolioQueue, runState }, ); @@ -129,7 +129,7 @@ describe("attemptLoopReentry (#2338)", () => { expect(countReentriesSince(eventLedger, now - 60 * 60 * 1000)).toBe(4); const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 4 }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 4 }, { eventLedger, portfolioQueue, runState, nowMs: now }, ); @@ -150,7 +150,7 @@ describe("attemptLoopReentry (#2338)", () => { } const result = attemptLoopReentry( - { repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 1_000, maxReentriesPerSession: 20 }, + { killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged", maxReentriesPerHour: 1_000, maxReentriesPerSession: 20 }, { eventLedger, portfolioQueue, runState, sessionStartMs: 0 }, ); @@ -164,12 +164,32 @@ describe("attemptLoopReentry (#2338)", () => { const portfolioQueue = tempPortfolioQueue(); expect(() => attemptLoopReentry(null as never, { eventLedger, portfolioQueue })).toThrow("invalid_loop_reentry_candidate"); - expect(() => attemptLoopReentry({ outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); - expect(() => attemptLoopReentry({ repoFullName: "", outcome: "merged" }, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "bogus" as never }, { eventLedger, portfolioQueue })).toThrow("invalid_outcome"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, null as never)).toThrow("invalid_loop_reentry_deps"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger } as never)).toThrow("invalid_portfolio_queue"); - expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { portfolioQueue } as never)).toThrow("invalid_event_ledger"); + expect(() => attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_kill_switch_scope"); + expect(() => attemptLoopReentry({ killSwitchScope: "bogus", repoFullName: "acme/widgets", outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_kill_switch_scope"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", outcome: "merged" } as never, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "", outcome: "merged" }, { eventLedger, portfolioQueue })).toThrow("invalid_repo_full_name"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "bogus" as never }, { eventLedger, portfolioQueue })).toThrow("invalid_outcome"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, null as never)).toThrow("invalid_loop_reentry_deps"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger } as never)).toThrow("invalid_portfolio_queue"); + expect(() => attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { portfolioQueue } as never)).toThrow("invalid_event_ledger"); + }); + + it("kill-switch (#2339): an active kill-switch blocks re-entry unconditionally, without dequeuing or moving run-state", () => { + const eventLedger = tempEventLedger(); + const portfolioQueue = tempPortfolioQueue(); + const runState = tempRunState(); + portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); + + const result = attemptLoopReentry( + { killSwitchScope: "global", repoFullName: "acme/widgets", outcome: "merged" }, + { eventLedger, portfolioQueue, runState }, + ); + + expect(result.decision).toEqual({ reenter: false, reasons: ["global_kill_switch_active"] }); + expect(result.dequeued).toBeNull(); + expect(runState.getRunState("acme/widgets")).toBeNull(); + expect(portfolioQueue.listQueue("acme/widgets")).toHaveLength(1); + expect(result.event.payload).toMatchObject({ killSwitchScope: "global" }); }); it("threads a caller-supplied loopSummary verbatim into the audit event payload for traceability", () => { @@ -178,7 +198,7 @@ describe("attemptLoopReentry (#2338)", () => { portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); const loopSummary = { sinceSeq: 10, lastSeq: 42, events: { total: 3, byType: { pr_outcome: 1 } }, queue: { total: 1, byStatus: { queued: 1 } }, runState: "idle" }; - const result = attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue, loopSummary }); + const result = attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue, loopSummary }); expect(result.event.payload.loopSummary).toEqual(loopSummary); }); @@ -188,7 +208,7 @@ describe("attemptLoopReentry (#2338)", () => { const portfolioQueue = tempPortfolioQueue(); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); - const result = attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); + const result = attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); expect(result.event.payload.loopSummary).toBeNull(); }); @@ -198,7 +218,7 @@ describe("attemptLoopReentry (#2338)", () => { const portfolioQueue = tempPortfolioQueue(); portfolioQueue.enqueue({ repoFullName: "acme/widgets", identifier: "issue-1" }); - const result = attemptLoopReentry({ repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); + const result = attemptLoopReentry({ killSwitchScope: "none", repoFullName: "acme/widgets", outcome: "merged" }, { eventLedger, portfolioQueue }); expect(result.decision.reenter).toBe(true); expect(result.dequeued?.identifier).toBe("issue-1"); }); From 6b0a998a7e98ced8ce4429d6dda42644ec7b5ce8 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 05:53:00 -0700 Subject: [PATCH 3/4] test(miner-governor): add root-vitest coverage for submission-gate.ts (#2339, #2336) submission-gate.ts had zero root-vitest (Codecov-visible) coverage -- only an engine-level node:test file, which Codecov cannot see. #2339's new kill-switch check (2 lines) was the first change to this file Codecov could measure, and it correctly flagged 0% patch coverage as a result. Ports the existing, already-comprehensive engine-level test suite (18 scenarios: kill-switch x3, pass/pass, fail/pass, pass/fail, both-fail, null-verdict, null-slop, both-null, observe-mode x2, isSlopBandWithinThreshold x4, barrel check) into a new root vitest file importing directly from engine source, giving 100% statement/branch/function/line coverage on the whole file, not just the newly-changed lines. Also resolves the rebase onto current main: an add/add conflict on test/unit/miner-loop-reentry.test.ts (this branch's own commit history still referenced an orphaned, pre-fix copy of the now-merged #2338 commit) and the shared build-script line conflict (package.json), both cleanly reconciled with no functional change to either. --- test/unit/miner-submission-gate.test.ts | 168 ++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 test/unit/miner-submission-gate.test.ts diff --git a/test/unit/miner-submission-gate.test.ts b/test/unit/miner-submission-gate.test.ts new file mode 100644 index 0000000000..7e14016f0b --- /dev/null +++ b/test/unit/miner-submission-gate.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import { + isSlopBandWithinThreshold, + shouldSubmit, + SUBMISSION_GATE_PASSING_CONCLUSION, + type PredictedGateVerdict, + type SelfReviewSlopAssessment, + type SelfReviewSlopBand, + type SubmissionGateCandidate, +} from "../../packages/gittensory-engine/src/index"; + +function passingVerdict(): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "success", + title: "Predicted gate: pass", + summary: "Every check is expected to pass.", + readinessScore: 92, + confirmedContributor: undefined, + blockers: [], + warnings: [], + funnel: null, + note: "", + }; +} + +function failingVerdict(blockers: PredictedGateVerdict["blockers"] = [{ code: "duplicate_pr_risk", title: "Likely duplicate", detail: "Matches an existing open PR." }]): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "failure", + title: "Predicted gate: fail", + summary: "At least one check is expected to fail.", + readinessScore: 15, + confirmedContributor: undefined, + blockers, + warnings: [], + funnel: null, + note: "", + }; +} + +function slop(band: SelfReviewSlopBand, slopRisk = 0): SelfReviewSlopAssessment { + return { slopRisk, band, findings: [] }; +} + +function baseCandidate(overrides: Partial = {}): SubmissionGateCandidate { + return { + killSwitchScope: "none", + predictedGateVerdict: passingVerdict(), + slopAssessment: slop("clean"), + slopThreshold: "low", + mode: "enforce", + ...overrides, + }; +} + +describe("shouldSubmit (#2336)", () => { + it("barrel: the public entrypoint re-exports the submission gate", () => { + expect(typeof shouldSubmit).toBe("function"); + expect(typeof isSlopBandWithinThreshold).toBe("function"); + expect(SUBMISSION_GATE_PASSING_CONCLUSION).toBe("success"); + }); + + it("pass/pass: a clean predicted-gate pass with slop under threshold allows, with no reasons", () => { + const decision = shouldSubmit(baseCandidate()); + expect(decision).toEqual({ allow: true, reasons: [] }); + }); + + it("kill-switch (#2339): a global kill-switch blocks unconditionally, even with every other signal otherwise passing", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "global" })); + expect(decision).toEqual({ allow: false, reasons: ["global_kill_switch_active"] }); + }); + + it("kill-switch (#2339): a per-repo kill-switch blocks unconditionally, checked before any signal or mode logic", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "repo", mode: "observe" })); + expect(decision).toEqual({ allow: false, reasons: ["repo_kill_switch_active"] }); + }); + + it("kill-switch (#2339): an inactive kill-switch (scope 'none') never itself blocks -- signals are still evaluated normally", () => { + const decision = shouldSubmit(baseCandidate({ killSwitchScope: "none" })); + expect(decision.allow).toBe(true); + }); + + it("fail/pass: a non-passing predicted-gate verdict blocks even with slop cleanly under threshold", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict() })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toHaveLength(1); + expect(decision.reasons[0]).toMatch(/^predicted_gate_not_passing:failure:duplicate_pr_risk$/); + }); + + it("fail/pass: a non-passing verdict with NO blockers listed still formats a reason, without a dangling separator", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict([]) })); + expect(decision.reasons[0]).toBe("predicted_gate_not_passing:failure"); + }); + + it("pass/fail: a clean predicted-gate pass blocks when slop exceeds the configured threshold", () => { + const decision = shouldSubmit(baseCandidate({ slopAssessment: slop("high"), slopThreshold: "low" })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["slop_band_exceeds_threshold:high>low"]); + }); + + it("both-fail: a non-passing verdict AND over-threshold slop blocks with both reasons listed", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: failingVerdict(), slopAssessment: slop("high"), slopThreshold: "low" })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toHaveLength(2); + expect(decision.reasons.some((r) => r.startsWith("predicted_gate_not_passing"))).toBe(true); + expect(decision.reasons.some((r) => r.startsWith("slop_band_exceeds_threshold"))).toBe(true); + }); + + it("fail-closed: a null predictedGateVerdict (predictor unreachable) blocks, never treated as no-opinion-so-allow", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["predicted_gate_unavailable"]); + }); + + it("fail-closed: a null slopAssessment (slop check errored) blocks, never treated as no-opinion-so-allow", () => { + const decision = shouldSubmit(baseCandidate({ slopAssessment: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["slop_assessment_unavailable"]); + }); + + it("fail-closed: both signals missing blocks with both unavailable reasons listed", () => { + const decision = shouldSubmit(baseCandidate({ predictedGateVerdict: null, slopAssessment: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["predicted_gate_unavailable", "slop_assessment_unavailable"]); + }); + + it("observe mode: forces allow: false even for signals that would otherwise cleanly pass", () => { + const decision = shouldSubmit(baseCandidate({ mode: "observe" })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["observe_mode_active:would_have_allowed"]); + }); + + it("observe mode: a would-have-blocked decision is distinguishable from a would-have-allowed one, with the real reasons preserved", () => { + const decision = shouldSubmit(baseCandidate({ mode: "observe", predictedGateVerdict: null })); + expect(decision.allow).toBe(false); + expect(decision.reasons).toEqual(["observe_mode_active:would_have_blocked", "predicted_gate_unavailable"]); + }); +}); + +describe("isSlopBandWithinThreshold (#2336)", () => { + it("a band exactly equal to the threshold passes (inclusive boundary)", () => { + expect(isSlopBandWithinThreshold("elevated", "elevated")).toBe(true); + }); + + it("a band one severity level under the threshold passes", () => { + expect(isSlopBandWithinThreshold("low", "elevated")).toBe(true); + }); + + it("a band one severity level over the threshold fails", () => { + expect(isSlopBandWithinThreshold("high", "elevated")).toBe(false); + }); + + it("the full clean..high ordering is respected end to end", () => { + const order: SelfReviewSlopBand[] = ["clean", "low", "elevated", "high"]; + for (let i = 0; i < order.length; i += 1) { + for (let j = 0; j < order.length; j += 1) { + const band = order[i] as SelfReviewSlopBand; + const threshold = order[j] as SelfReviewSlopBand; + expect(isSlopBandWithinThreshold(band, threshold)).toBe(i <= j); + } + } + }); +}); From e172927e7334ce0daf87aca839bfb64e4f2614a0 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sat, 11 Jul 2026 06:03:08 -0700 Subject: [PATCH 4/4] feat(miner-governor): wire the gated-submission trigger into the driving loop's handoff signal (#2337) (#5054) * chore(orb): bump manifest target version to 0.4.1 (#5075) orb-v0.4.0 was cut stable earlier today (2026-07-11), but the manifest was never bumped afterward, so orb-beta-release's due-check correctly refused to cut another 0.4.0-suffixed beta -- blocking the automated beta channel for every feat/fix merged since, including #5071/#5072. * feat(miner-governor): propagate the kill-switch into shouldSubmit (submission-gate.ts) #2339's own deliverable: the manage-phase submission-gate function must check the kill-switch/dry-run state as its FIRST guard, before any other logic -- flipping the kill-switch must halt any pending submission-gate check immediately, the same way it already halts the Governor chokepoint (#2340). killSwitchScope is a new REQUIRED field on SubmissionGateCandidate (not optional-with-a-permissive-default) so a caller cannot forget to resolve and pass it. Reuses isMinerKillSwitchActive (kill-switch.ts, #2341) directly -- the single shared helper #2339 asks for, not a bespoke wrapper -- rather than duplicating the check; the sibling loop-reentry-policy.ts change (next commit) consults the identical function. * feat(miner-governor): propagate the kill-switch into shouldReenter (loop-reentry-policy.ts) The sibling half of #2339's own deliverable: the manage-phase loop- reentry function must ALSO check the kill-switch/dry-run state as its FIRST guard, before any other logic. killSwitchScope is a new REQUIRED field on LoopReentryCandidate, checked via the identical isMinerKillSwitchActive helper submission-gate.ts's shouldSubmit now consults (the "single shared helper, not duplicated per call site" this issue asks for) -- not a bespoke wrapper. Threads killSwitchScope through packages/gittensory-miner/lib/loop- reentry.js's attemptLoopReentry (validated fail-closed alongside its existing repoFullName/outcome checks) and into the audit event payload for traceability. * feat(miner-governor): wire the gated-submission trigger into the driving loop's handoff signal Adds evaluateHarnessSubmissionTrigger (packages/gittensory-engine/src/ miner/harness-submission-trigger.ts) + evaluateAndRecordHarnessSubmis sionTrigger (packages/gittensory-miner/lib/harness-submission- trigger.js): connects shouldSubmit (#2336) to the actual driving loop's own handoff signal -- iterate-loop.ts's (#2333) HandoffPacket, produced the moment a real run's self-review reaches a clean predicted-gate pass. Not wired into any scheduler as part of this change, per the issue's own "manual owner sign-off on the wiring before this ships to any default-on profile" deliverable. Adds the session-level circuit breaker this issue's own deliverable calls for, distinct from shouldSubmit's per-candidate signal checks: N consecutive allow:false decisions in one session pauses the run entirely pending human review (checked FIRST, before ever consulting shouldSubmit -- once tripped, only a human clearing the session's own tally can un-trip it, unlike a per-candidate block a later, different candidate can clear on its own merits). Session-scoped rather than per-repo, matching "pauses the run entirely" -- distinct from #2338's loop-reentry circuit breaker, which is deliberately per-repo. Every decision (allow or block) is appended to the local event ledger with full reasons, whether or not the circuit breaker was involved. This does NOT build or invoke buildOpenPrSpec (src/mcp/local-write- tools.ts) -- that lives in the private root src/ tree, unreachable from this portable package for the same cross-package-boundary reason self-review-adapter.ts's slop injection exists. A real call site (root-side integration, wired in a later issue) consults this function's allow:true before it builds that spec itself. On the referenced "harness driving loop": the issue cites both the old M4-era gittensory-mcp harness adapter (#781, a different package) and this session's own submission-gate.ts. Wired to iterate-loop.ts's HandoffPacket instead, since that's the actual, current, same-wave driving loop #2336 was built to compose with. Test-covered per the issue's own explicit deliverable: a full candidate -> gate-check -> submit (or correctly-blocked) cycle, plus the circuit breaker tripping and resetting, and fail-closed validation. * feat(miner-governor): thread killSwitchScope through the harness submission trigger #2339 makes SubmissionGateCandidate.killSwitchScope required on shouldSubmit -- this is the coupling fixup: evaluateHarnessSubmission Trigger (#2337) constructs a shouldSubmit candidate internally, so it now requires and forwards killSwitchScope from its own candidate rather than duplicating the kill-switch check itself (shouldSubmit's own guard covers it whenever the session circuit breaker hasn't already short-circuited first). Also closes a real, pre-existing codecov gap discovered while verifying this: submission-gate.ts had ZERO root-vitest scenarios for its null-predictedGateVerdict, null-slopAssessment, slop-exceeds- threshold, and observe-mode-would-have-blocked/would-have-allowed paths -- it's only reachable from the root vitest suite through this file's own integration test, and that test never exercised them. Added all five; submission-gate.ts now measures 100% branch coverage via the actual codecov-visible path, not just the engine's own node:test suite. * fix(miner-governor): add missing as-never casts for deliberately-invalid deps assertions Two "fails closed on malformed candidate" assertions in miner-harness-submission-trigger.test.ts passed a HarnessSubmissionCandidateInput missing slopThreshold/mode without the as-never cast the four other deliberately- invalid-shape assertions in the same test already use, so tsc rejected them once the type was fully threaded through. No behavior change -- the runtime fail-closed assertions themselves were always correct. Also carries forward the same stacked-branch rebase reconciliation as the two branches this one is built on (rerere replayed the loop-reentry.test.ts resolution automatically). --- orb-manifest.json | 2 +- packages/gittensory-engine/src/index.ts | 6 + .../src/miner/harness-submission-trigger.ts | 81 +++++ .../test/harness-submission-trigger.test.ts | 130 ++++++++ .../lib/harness-submission-trigger.d.ts | 46 +++ .../lib/harness-submission-trigger.js | 84 +++++ packages/gittensory-miner/package.json | 2 +- .../miner-harness-submission-trigger.test.ts | 287 ++++++++++++++++++ 8 files changed, 636 insertions(+), 2 deletions(-) create mode 100644 packages/gittensory-engine/src/miner/harness-submission-trigger.ts create mode 100644 packages/gittensory-engine/test/harness-submission-trigger.test.ts create mode 100644 packages/gittensory-miner/lib/harness-submission-trigger.d.ts create mode 100644 packages/gittensory-miner/lib/harness-submission-trigger.js create mode 100644 test/unit/miner-harness-submission-trigger.test.ts diff --git a/orb-manifest.json b/orb-manifest.json index f398f109cd..4ca270038f 100644 --- a/orb-manifest.json +++ b/orb-manifest.json @@ -1,4 +1,4 @@ { - "version": "0.4.0", + "version": "0.4.1", "description": "Source of truth for the self-hostable gittensory-orb container image's target release version (ghcr.io/jsonbored/gittensory-selfhost). Bumped by a maintainer when a feat/fix/breaking change since the last stable orb-v tag warrants moving to a new target version -- scripts/orb-release-core.mjs and .github/workflows/orb-beta-release.yml read this file to decide what version the next automated beta snapshot targets. Promoting a beta to a stable orb-vX.Y.Z release is still a manual `git tag` -- this manifest only drives the automated beta channel." } diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 62d88e5356..a10c01a74f 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -253,6 +253,12 @@ export { type LoopReentryDecision, type LoopReentryOutcome, } from "./miner/loop-reentry-policy.js"; +export { + DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS, + evaluateHarnessSubmissionTrigger, + type HarnessSubmissionTriggerCandidate, + type HarnessSubmissionTriggerDecision, +} from "./miner/harness-submission-trigger.js"; export { codingAgentModeExecutes, isGlobalMinerCodingAgentPause, diff --git a/packages/gittensory-engine/src/miner/harness-submission-trigger.ts b/packages/gittensory-engine/src/miner/harness-submission-trigger.ts new file mode 100644 index 0000000000..e26c53a4ec --- /dev/null +++ b/packages/gittensory-engine/src/miner/harness-submission-trigger.ts @@ -0,0 +1,81 @@ +// Harness submission-gate wiring (#2337): connects the gated-submission decision function (`shouldSubmit`, +// submission-gate.ts, #2336) to the ACTUAL driving loop's own handoff signal -- iterate-loop.ts's (#2333) +// `HandoffPacket`, the exact object produced the moment a real run's self-review reaches a clean predicted-gate +// pass. This is the live actuation wiring itself: the trigger surface the safety-tier system reserves for +// maintainer review, since a bug here means an autonomous write happens when it should not have. +// +// WHAT THIS DOES NOT DO: build or invoke the actual `open_pr` local-write spec (`buildOpenPrSpec`, +// `src/mcp/local-write-tools.ts`) -- that lives in the private root `src/` tree, unreachable from this +// portable package for the same cross-package-boundary reason self-review-adapter.ts's slop injection exists +// (#2334's own module doc comment). This function's OUTPUT (`allow: true`) is the gate a real call site +// (root-side server/CLI integration, wired in a later issue) consults before it builds that spec itself -- +// mirrors #2336's own "gated exclusively through this function" scoping. +// +// THE SESSION-LEVEL CIRCUIT BREAKER: distinct from `shouldSubmit`'s own per-candidate signal checks +// (predicted-gate pass, slop-under-threshold), this issue's own deliverable calls for "N consecutive +// submission-gate allow:false decisions in one session pauses the run entirely pending human review, never +// silently loops trying to force a pass." Checked FIRST, before ever consulting `shouldSubmit` -- once tripped, +// no candidate can un-trip it (that requires a human clearing the session's own consecutive-block tally), +// unlike a per-candidate block which a later, different candidate can clear on its own merits. + +import type { MinerKillSwitchScope } from "../governor/kill-switch.js"; +import type { HandoffPacket } from "./iterate-policy.js"; +import type { SelfReviewSlopBand } from "./self-review-adapter.js"; +import { shouldSubmit, type SubmissionGateMode } from "./submission-gate.js"; + +export const DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS = 3; + +export type HarnessSubmissionTriggerCandidate = { + /** Forwarded to `shouldSubmit`'s own kill-switch check (#2339) -- this function does not ALSO short-circuit + * on it separately (that would be a second, duplicated check, exactly what #2339's "single shared helper, + * not duplicated per call site" deliverable warns against); `shouldSubmit` is always still called (the + * circuit breaker above is the only thing that skips it), and its own kill-switch guard covers this. */ + killSwitchScope: MinerKillSwitchScope; + handoffPacket: HandoffPacket; + slopThreshold: SelfReviewSlopBand; + mode: SubmissionGateMode; + /** Caller-computed count of CONSECUTIVE `allow: false` submission-gate decisions so far this session, + * ending with (and NOT including) this candidate's own about-to-be-computed decision. The caller owns this + * tally (mirrors #2338's caller-supplied `consecutiveDisengagements`); a `true` decision anywhere resets it + * to 0 for the caller's NEXT candidate. */ + consecutiveGateBlocks: number; + maxConsecutiveGateBlocks?: number | undefined; +}; + +export type HarnessSubmissionTriggerDecision = { + allow: boolean; + reasons: string[]; + /** True only when the SESSION-LEVEL circuit breaker (not a normal per-candidate block) is what stopped this + * decision -- the caller's own driving loop should treat this as "pause the run entirely pending human + * review," distinct from an ordinary `allow: false` a later, different candidate might still clear. */ + circuitBreakerTripped: boolean; +}; + +/** + * THE final gate before a real call site may build the `open_pr` local-write spec from a passing + * `HandoffPacket`. Pure; identical inputs always yield the identical decision. Checks the session-level + * circuit breaker FIRST (never consults `shouldSubmit` once tripped), then re-checks `shouldSubmit`'s own + * predicted-gate-pass + slop-under-threshold signals against the handoff's own verdict -- defense in depth, + * not a blind trust of the fact that a handoff happened at all. + */ +export function evaluateHarnessSubmissionTrigger(candidate: HarnessSubmissionTriggerCandidate): HarnessSubmissionTriggerDecision { + const maxConsecutiveGateBlocks = candidate.maxConsecutiveGateBlocks ?? DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS; + + if (candidate.consecutiveGateBlocks >= maxConsecutiveGateBlocks) { + return { + allow: false, + circuitBreakerTripped: true, + reasons: [`circuit_breaker_tripped_after_consecutive_blocks:${candidate.consecutiveGateBlocks}>=${maxConsecutiveGateBlocks}`], + }; + } + + const gateDecision = shouldSubmit({ + killSwitchScope: candidate.killSwitchScope, + predictedGateVerdict: candidate.handoffPacket.selfReviewVerdict.predictedGateVerdict, + slopAssessment: candidate.handoffPacket.selfReviewVerdict.slopAssessment, + slopThreshold: candidate.slopThreshold, + mode: candidate.mode, + }); + + return { allow: gateDecision.allow, reasons: gateDecision.reasons, circuitBreakerTripped: false }; +} diff --git a/packages/gittensory-engine/test/harness-submission-trigger.test.ts b/packages/gittensory-engine/test/harness-submission-trigger.test.ts new file mode 100644 index 0000000000..2f5e957ae2 --- /dev/null +++ b/packages/gittensory-engine/test/harness-submission-trigger.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS, + evaluateHarnessSubmissionTrigger, + type HandoffPacket, + type HarnessSubmissionTriggerCandidate, + type PredictedGateVerdict, + type SelfReviewSlopAssessment, + type SelfReviewVerdict, +} from "../dist/index.js"; + +function passingVerdictFields(): PredictedGateVerdict { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "success", + title: "t", + summary: "s", + readinessScore: 92, + confirmedContributor: undefined, + blockers: [], + warnings: [], + funnel: null, + note: "", + }; +} + +function failingVerdictFields(): PredictedGateVerdict { + return { ...passingVerdictFields(), conclusion: "failure", blockers: [{ code: "duplicate_pr_risk", title: "t", detail: "d" }] }; +} + +function slop(band: SelfReviewSlopAssessment["band"]): SelfReviewSlopAssessment { + return { slopRisk: 0, band, findings: [] }; +} + +function selfReviewVerdict(overrides: Partial = {}): SelfReviewVerdict { + return { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: slop("clean"), + changedPaths: ["src/upload.ts"], + passesPredictedGate: true, + ...overrides, + }; +} + +function handoffPacket(verdictOverrides: Partial = {}): HandoffPacket { + return { + worktreePath: "/tmp/attempt-1", + diffSummary: "added retry logic", + selfReviewVerdict: selfReviewVerdict(verdictOverrides), + attemptLogReference: "attempt-1", + }; +} + +function baseCandidate(overrides: Partial = {}): HarnessSubmissionTriggerCandidate { + return { + killSwitchScope: "none", + handoffPacket: handoffPacket(), + slopThreshold: "low", + mode: "enforce", + consecutiveGateBlocks: 0, + ...overrides, + }; +} + +test("barrel: the public entrypoint re-exports the harness submission trigger (#2337)", () => { + assert.equal(typeof evaluateHarnessSubmissionTrigger, "function"); + assert.equal(typeof DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS, "number"); +}); + +test("a passing handoff with the circuit breaker well clear allows, forwarding shouldSubmit's own empty reasons", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate()); + assert.deepEqual(decision, { allow: true, reasons: [], circuitBreakerTripped: false }); +}); + +test("kill-switch (#2339): forwarded to shouldSubmit's own check, blocking an otherwise-clean handoff below the circuit breaker", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ killSwitchScope: "global" })); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, false); + assert.deepEqual(decision.reasons, ["global_kill_switch_active"]); +}); + +test("circuit breaker: N consecutive blocks trips it, refusing even an otherwise-clean handoff -- never consulting shouldSubmit", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: 3, maxConsecutiveGateBlocks: 3 })); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, true); + assert.deepEqual(decision.reasons, ["circuit_breaker_tripped_after_consecutive_blocks:3>=3"]); +}); + +test("circuit breaker: below the ceiling still proceeds to consult shouldSubmit", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: 2, maxConsecutiveGateBlocks: 3 })); + assert.equal(decision.allow, true); + assert.equal(decision.circuitBreakerTripped, false); +}); + +test("default circuit-breaker ceiling applies when the candidate omits its own override", () => { + const justUnder = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS - 1 })); + assert.equal(justUnder.allow, true); + + const atDefault = evaluateHarnessSubmissionTrigger(baseCandidate({ consecutiveGateBlocks: DEFAULT_MAX_CONSECUTIVE_GATE_BLOCKS })); + assert.equal(atDefault.allow, false); + assert.equal(atDefault.circuitBreakerTripped, true); +}); + +test("a handoff whose verdict fails predicted-gate is blocked by shouldSubmit, not the circuit breaker -- defense in depth against a malformed handoff", () => { + const decision = evaluateHarnessSubmissionTrigger( + baseCandidate({ handoffPacket: handoffPacket({ predictedGateVerdict: failingVerdictFields(), passesPredictedGate: false }) }), + ); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, false); + assert.ok(decision.reasons.some((r) => r.startsWith("predicted_gate_not_passing"))); +}); + +test("a handoff whose slop assessment exceeds the configured threshold is blocked by shouldSubmit", () => { + const decision = evaluateHarnessSubmissionTrigger( + baseCandidate({ handoffPacket: handoffPacket({ slopAssessment: slop("high") }), slopThreshold: "low" }), + ); + assert.equal(decision.allow, false); + assert.deepEqual(decision.reasons, ["slop_band_exceeds_threshold:high>low"]); +}); + +test("observe mode forces allow: false even for an otherwise-clean handoff, below the circuit breaker", () => { + const decision = evaluateHarnessSubmissionTrigger(baseCandidate({ mode: "observe" })); + assert.equal(decision.allow, false); + assert.equal(decision.circuitBreakerTripped, false); + assert.deepEqual(decision.reasons, ["observe_mode_active:would_have_allowed"]); +}); diff --git a/packages/gittensory-miner/lib/harness-submission-trigger.d.ts b/packages/gittensory-miner/lib/harness-submission-trigger.d.ts new file mode 100644 index 0000000000..1a223030a0 --- /dev/null +++ b/packages/gittensory-miner/lib/harness-submission-trigger.d.ts @@ -0,0 +1,46 @@ +export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT: "harness_submission_trigger_decision"; + +export type HarnessSubmissionSlopBand = "clean" | "low" | "elevated" | "high"; +export type HarnessSubmissionMode = "observe" | "enforce"; +export type HarnessSubmissionKillSwitchScope = "global" | "repo" | "none"; + +export type HarnessSubmissionCandidateInput = { + /** Forwarded to shouldSubmit's own kill-switch check (#2339). */ + killSwitchScope: HarnessSubmissionKillSwitchScope; + repoFullName: string; + handoffPacket: { + worktreePath: string; + branchRef?: string; + diffSummary: string; + selfReviewVerdict: unknown; + attemptLogReference: string; + }; + slopThreshold: HarnessSubmissionSlopBand; + mode: HarnessSubmissionMode; + maxConsecutiveGateBlocks?: number; +}; + +export interface HarnessSubmissionEventLedger { + appendEvent(event: { type: string; repoFullName?: string; payload: Record }): { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; + readEvents(filter?: { since?: number; repoFullName?: string }): Array<{ type: string; repoFullName?: string | null; payload?: Record; createdAt: string }>; +} + +export type HarnessSubmissionDeps = { + eventLedger: HarnessSubmissionEventLedger; + sessionStartMs?: number; +}; + +export type HarnessSubmissionDecision = { + allow: boolean; + reasons: string[]; + circuitBreakerTripped: boolean; +}; + +export type HarnessSubmissionResult = { + decision: HarnessSubmissionDecision; + event: { id: number; seq: number; type: string; repoFullName: string | null; payload: Record; createdAt: string }; +}; + +export function countConsecutiveGateBlocks(eventLedger: HarnessSubmissionEventLedger, sinceMs: number): number; + +export function evaluateAndRecordHarnessSubmissionTrigger(candidate: HarnessSubmissionCandidateInput, deps: HarnessSubmissionDeps): HarnessSubmissionResult; diff --git a/packages/gittensory-miner/lib/harness-submission-trigger.js b/packages/gittensory-miner/lib/harness-submission-trigger.js new file mode 100644 index 0000000000..9eb580aa6f --- /dev/null +++ b/packages/gittensory-miner/lib/harness-submission-trigger.js @@ -0,0 +1,84 @@ +import { evaluateHarnessSubmissionTrigger } from "@jsonbored/gittensory-engine"; + +// Harness submission-gate wiring orchestrator (#2337): the real-IO half of connecting the gated-submission +// decision (`shouldSubmit`, wrapped by `evaluateHarnessSubmissionTrigger`, @jsonbored/gittensory-engine) to a +// real driving loop's own handoff signal. Reads the session's recent decision history to compute the +// consecutive-block circuit-breaker tally, consults the pure decision, and always records exactly one audit +// event -- regardless of outcome, so a paused-pending-human-review session leaves a full trail of why. +// +// NOT WIRED INTO ANY AUTOMATIC SCHEDULE: per this issue's own "manual owner sign-off on the wiring before this +// ships to any default-on profile" deliverable. A real call site (root-side server/CLI integration) invokes +// this function with a real `HandoffPacket`; on `allow: true` it may then build the `open_pr` local-write spec +// itself -- this module does not, and cannot, do that (the spec builder lives in the private root `src/` tree, +// unreachable from this package -- same cross-package-boundary reason self-review-adapter.ts's slop injection +// exists). +// +// SESSION-SCOPED, NOT PER-REPO: the circuit breaker's own "pauses the run entirely" wording means the tally is +// counted across EVERY repo's decisions this session, not scoped to one repo -- distinct from #2338's loop- +// reentry circuit breaker, which is deliberately per-repo (a rejection streak on one repo must not pause +// unrelated repos). + +export const HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT = "harness_submission_trigger_decision"; + +/** Count consecutive `allow: false` decisions recorded at or after `sinceMs`, walking backward from the most + * recent decision until an `allow: true` breaks the streak (or history runs out). Session-scoped (not + * filtered by repo) to match the circuit breaker's own "pauses the run entirely" semantics. */ +export function countConsecutiveGateBlocks(eventLedger, sinceMs) { + const decisions = eventLedger + .readEvents({}) + .filter((event) => event.type === HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT && Date.parse(event.createdAt) >= sinceMs); + let count = 0; + for (let i = decisions.length - 1; i >= 0; i -= 1) { + if (decisions[i].payload?.allow === true) break; + count += 1; + } + return count; +} + +/** + * Evaluate the harness submission trigger for one candidate handoff, reading real session history to compute + * the circuit-breaker tally, and always appending exactly one audit event. Fails closed (throws) on a + * malformed candidate or missing required dependency. + * + * @param {{ killSwitchScope: "global"|"repo"|"none", repoFullName: string, handoffPacket: object, slopThreshold: "clean"|"low"|"elevated"|"high", mode: "observe"|"enforce", maxConsecutiveGateBlocks?: number }} candidate + * @param {{ eventLedger: object, sessionStartMs?: number }} deps + */ +export function evaluateAndRecordHarnessSubmissionTrigger(candidate, deps) { + if (!candidate || typeof candidate !== "object") throw new Error("invalid_harness_submission_candidate"); + if (!["global", "repo", "none"].includes(candidate.killSwitchScope)) throw new Error("invalid_kill_switch_scope"); + const repoFullName = typeof candidate.repoFullName === "string" ? candidate.repoFullName.trim() : ""; + if (!repoFullName) throw new Error("invalid_repo_full_name"); + if (!candidate.handoffPacket || typeof candidate.handoffPacket !== "object") throw new Error("invalid_handoff_packet"); + + if (!deps || typeof deps !== "object") throw new Error("invalid_harness_submission_deps"); + const { eventLedger, sessionStartMs = 0 } = deps; + if (!eventLedger || typeof eventLedger.appendEvent !== "function" || typeof eventLedger.readEvents !== "function") { + throw new Error("invalid_event_ledger"); + } + + const consecutiveGateBlocks = countConsecutiveGateBlocks(eventLedger, sessionStartMs); + + const decision = evaluateHarnessSubmissionTrigger({ + killSwitchScope: candidate.killSwitchScope, + handoffPacket: candidate.handoffPacket, + slopThreshold: candidate.slopThreshold, + mode: candidate.mode, + consecutiveGateBlocks, + maxConsecutiveGateBlocks: candidate.maxConsecutiveGateBlocks, + }); + + const event = eventLedger.appendEvent({ + type: HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT, + repoFullName, + payload: { + killSwitchScope: candidate.killSwitchScope, + allow: decision.allow, + reasons: decision.reasons, + circuitBreakerTripped: decision.circuitBreakerTripped, + consecutiveGateBlocks, + attemptLogReference: candidate.handoffPacket.attemptLogReference ?? null, + }, + }); + + return { decision, event }; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 7b36ec1f59..a441ae18a1 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -32,7 +32,7 @@ "expected-engine.version" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/deny-hook-synthesis.js && node --check lib/pretooluse-hook.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/governor-run-halt.js && node --check lib/governor-kill-switch.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/discover-cli.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/orb-export.js && node --check lib/portfolio-dashboard.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/rejection-state-machine.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/version.js && node --check lib/local-store.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/run-state-cli.js && node --check lib/update-check.js && node --check lib/worktree-allocator.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/deny-hook-synthesis.js && node --check lib/pretooluse-hook.js && node --check lib/event-ledger.js && node --check lib/event-ledger-cli.js && node --check lib/claim-ledger.js && node --check lib/claim-ledger-expiry.js && node --check lib/claim-ledger-cli.js && node --check lib/claim-adjudication.js && node --check lib/portfolio-queue.js && node --check lib/portfolio-queue-cli.js && node --check lib/portfolio-queue-manager.js && node --check lib/portfolio-discovery.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/plan-store-cli.js && node --check lib/rejection-templates.js && node --check lib/governor-ledger.js && node --check lib/governor-ledger-cli.js && node --check lib/governor-open-pr.js && node --check lib/governor-write-rate-limit.js && node --check lib/governor-run-halt.js && node --check lib/governor-kill-switch.js && node --check lib/attempt-log.js && node --check lib/manage-status.js && node --check lib/manage-poll.js && node --check lib/status.js && node --check lib/laptop-init.js && node --check lib/replay-objective-anchor.js && node --check lib/replay-task-generation.js && node --check lib/calibration-types.js && node --check lib/calibration.js && node --check lib/discover-cli.js && node --check lib/feasibility-cli.js && node --check lib/gate-verdict-poller.js && node --check lib/loop-closure.js && node --check lib/loop-reentry.js && node --check lib/orb-export.js && node --check lib/portfolio-dashboard.js && node --check lib/pr-outcome.js && node --check lib/prediction-ledger.js && node --check lib/rejection-state-machine.js && node --check lib/harness-submission-trigger.js" }, "dependencies": { "@jsonbored/gittensory-engine": "*" diff --git a/test/unit/miner-harness-submission-trigger.test.ts b/test/unit/miner-harness-submission-trigger.test.ts new file mode 100644 index 0000000000..77df563f26 --- /dev/null +++ b/test/unit/miner-harness-submission-trigger.test.ts @@ -0,0 +1,287 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@jsonbored/gittensory-engine", async () => { + return import("../../packages/gittensory-engine/src/index"); +}); + +import { evaluateAndRecordHarnessSubmissionTrigger, countConsecutiveGateBlocks, HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT } from "../../packages/gittensory-miner/lib/harness-submission-trigger.js"; +import { initEventLedger } from "../../packages/gittensory-miner/lib/event-ledger.js"; + +const roots: string[] = []; +const closers: Array<{ close(): void }> = []; + +function tempEventLedger() { + const root = mkdtempSync(join(tmpdir(), "gittensory-miner-harness-trigger-")); + roots.push(root); + const ledger = initEventLedger(join(root, "db.sqlite3")); + closers.push(ledger); + return ledger; +} + +function passingVerdictFields() { + return { + predicted: true, + basis: "public_config", + pack: "oss-anti-slop", + conclusion: "success", + title: "t", + summary: "s", + readinessScore: 92, + confirmedContributor: undefined, + blockers: [], + warnings: [], + funnel: null, + note: "", + }; +} + +function handoffPacket(overrides: Record = {}) { + return { + worktreePath: "/tmp/attempt-1", + diffSummary: "added retry logic", + selfReviewVerdict: { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: ["src/upload.ts"], + passesPredictedGate: true, + }, + attemptLogReference: "attempt-1", + ...overrides, + }; +} + +afterEach(() => { + for (const closer of closers.splice(0)) closer.close(); + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("evaluateAndRecordHarnessSubmissionTrigger (#2337)", () => { + it("full candidate -> gate-check -> submit cycle: a clean handoff allows and records one audit event", () => { + const eventLedger = tempEventLedger(); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision).toEqual({ allow: true, reasons: [], circuitBreakerTripped: false }); + const events = eventLedger.readEvents({ repoFullName: "acme/widgets" }); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe(HARNESS_SUBMISSION_TRIGGER_DECISION_EVENT); + expect(events[0]?.payload).toMatchObject({ allow: true, circuitBreakerTripped: false, attemptLogReference: "attempt-1" }); + }); + + it("kill-switch (#2339): blocks an otherwise-clean handoff unconditionally, and the block is recorded with the scope that caused it", () => { + const eventLedger = tempEventLedger(); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "global", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision).toEqual({ allow: false, reasons: ["global_kill_switch_active"], circuitBreakerTripped: false }); + const events = eventLedger.readEvents({ repoFullName: "acme/widgets" }); + expect(events[0]?.payload).toMatchObject({ killSwitchScope: "global", allow: false }); + }); + + it("full candidate -> gate-check -> correctly-blocked cycle: a non-passing handoff is blocked, and the block itself is recorded", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: { ...passingVerdictFields(), conclusion: "failure", blockers: [{ code: "duplicate_pr_risk", title: "t", detail: "d" }] }, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: ["src/upload.ts"], + passesPredictedGate: false, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.circuitBreakerTripped).toBe(false); + const events = eventLedger.readEvents({ repoFullName: "acme/widgets" }); + expect(events[0]?.payload).toMatchObject({ allow: false }); + }); + + it("circuit breaker: after enough consecutive blocked decisions this session, the run pauses even for an otherwise-clean handoff", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: { ...passingVerdictFields(), conclusion: "failure", blockers: [] }, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + // Three consecutive blocked decisions, each recorded to the real session history. + for (let i = 0; i < 3; i += 1) { + const blocked = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "enforce", maxConsecutiveGateBlocks: 3 }, + { eventLedger }, + ); + expect(blocked.decision.allow).toBe(false); + } + expect(countConsecutiveGateBlocks(eventLedger, 0)).toBe(3); + + // A fourth candidate, this time a genuinely clean handoff -- the circuit breaker still pauses it. + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce", maxConsecutiveGateBlocks: 3 }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.circuitBreakerTripped).toBe(true); + expect(result.decision.reasons).toEqual(["circuit_breaker_tripped_after_consecutive_blocks:3>=3"]); + }); + + it("a single allowed decision resets the consecutive-block streak, un-pausing the next candidate", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: { ...passingVerdictFields(), conclusion: "failure", blockers: [] }, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "enforce" }, { eventLedger }); + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "enforce" }, { eventLedger }); + + expect(countConsecutiveGateBlocks(eventLedger, 0)).toBe(0); + }); + + it("fail-closed: a null predictedGateVerdict (predictor unreachable) blocks, never treated as no-opinion-so-allow", () => { + const eventLedger = tempEventLedger(); + const unreachableHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: null, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: unreachableHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toContain("predicted_gate_unavailable"); + }); + + it("fail-closed: a null slopAssessment (slop check errored) blocks, never treated as no-opinion-so-allow", () => { + const eventLedger = tempEventLedger(); + const erroredSlopHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: null, + changedPaths: [], + passesPredictedGate: true, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: erroredSlopHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toContain("slop_assessment_unavailable"); + }); + + it("a handoff whose slop assessment exceeds the configured threshold is blocked, with the band/threshold pair in the reason", () => { + const eventLedger = tempEventLedger(); + const highSlopHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: passingVerdictFields(), + slopAssessment: { slopRisk: 0, band: "high", findings: [] }, + changedPaths: [], + passesPredictedGate: true, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: highSlopHandoff, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toEqual(["slop_band_exceeds_threshold:high>low"]); + }); + + it("observe mode: a would-have-allowed decision still forces allow: false, with a distinct reason from a would-have-blocked one", () => { + const eventLedger = tempEventLedger(); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket(), slopThreshold: "low", mode: "observe" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toEqual(["observe_mode_active:would_have_allowed"]); + }); + + it("observe mode: a would-have-blocked decision is distinguishable from a would-have-allowed one, with the real reasons preserved", () => { + const eventLedger = tempEventLedger(); + const failingHandoff = handoffPacket({ + selfReviewVerdict: { + predictedGateVerdict: null, + slopAssessment: { slopRisk: 0, band: "clean", findings: [] }, + changedPaths: [], + passesPredictedGate: false, + }, + }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: failingHandoff, slopThreshold: "low", mode: "observe" }, + { eventLedger }, + ); + + expect(result.decision.allow).toBe(false); + expect(result.decision.reasons).toEqual(["observe_mode_active:would_have_blocked", "predicted_gate_unavailable"]); + }); + + it("records a null attemptLogReference in the audit payload when the handoff packet omits one", () => { + const eventLedger = tempEventLedger(); + const withoutReference = handoffPacket({ attemptLogReference: undefined }); + + const result = evaluateAndRecordHarnessSubmissionTrigger( + { killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: withoutReference, slopThreshold: "low", mode: "enforce" }, + { eventLedger }, + ); + + expect(result.event.payload.attemptLogReference).toBeNull(); + }); + + it("fails closed on a malformed candidate or missing dependency rather than silently allowing", () => { + const eventLedger = tempEventLedger(); + expect(() => evaluateAndRecordHarnessSubmissionTrigger(null as never, { eventLedger })).toThrow("invalid_harness_submission_candidate"); + expect(() => evaluateAndRecordHarnessSubmissionTrigger({ repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, { eventLedger })).toThrow( + "invalid_kill_switch_scope", + ); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "bogus", repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, { eventLedger }), + ).toThrow("invalid_kill_switch_scope"); + expect(() => evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", handoffPacket: handoffPacket() } as never, { eventLedger })).toThrow( + "invalid_repo_full_name", + ); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets" } as never, { eventLedger }), + ).toThrow("invalid_handoff_packet"); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, null as never), + ).toThrow("invalid_harness_submission_deps"); + expect(() => + evaluateAndRecordHarnessSubmissionTrigger({ killSwitchScope: "none", repoFullName: "acme/widgets", handoffPacket: handoffPacket() } as never, {} as never), + ).toThrow("invalid_event_ledger"); + }); +});