From 0df432920dc236d97db2c32bf93812cb0bff9e39 Mon Sep 17 00:00:00 2001 From: galuis116 Date: Sun, 26 Jul 2026 07:27:31 -0700 Subject: [PATCH] fix(engine): key chokepoint ledger paused off kill_switch eventType Budget-cap termination fires stage budget_cap with eventType kill_switch; denyResult previously keyed decision off stage, so the ledger recorded deny instead of paused. Key the ternary off eventType instead. Closes #8864 --- .../src/governor/chokepoint.ts | 4 +- .../loopover-engine/test/chokepoint.test.ts | 2 + ...or-chokepoint-kill-switch-decision.test.ts | 58 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 test/unit/engine-governor-chokepoint-kill-switch-decision.test.ts diff --git a/packages/loopover-engine/src/governor/chokepoint.ts b/packages/loopover-engine/src/governor/chokepoint.ts index a92af0ba92..f59e16db95 100644 --- a/packages/loopover-engine/src/governor/chokepoint.ts +++ b/packages/loopover-engine/src/governor/chokepoint.ts @@ -130,7 +130,9 @@ function denyResult(input: { eventType: input.eventType, repoFullName: input.repoFullName, actionClass: input.actionClass, - decision: input.stage === "kill_switch" ? "paused" : input.eventType === "throttled" ? "throttle" : "deny", + // Key off eventType, not stage: budget-cap termination fires stage "budget_cap" with + // eventType "kill_switch" and must still ledger as "paused" (#8864). + decision: input.eventType === "kill_switch" ? "paused" : input.eventType === "throttled" ? "throttle" : "deny", reason: input.reason, payload: { stage: input.stage, ...input.extraPayload }, }, diff --git a/packages/loopover-engine/test/chokepoint.test.ts b/packages/loopover-engine/test/chokepoint.test.ts index b327ffa657..01a3c97be9 100644 --- a/packages/loopover-engine/test/chokepoint.test.ts +++ b/packages/loopover-engine/test/chokepoint.test.ts @@ -94,6 +94,8 @@ test("budget cap: the termination ceiling denies with a kill_switch eventType (h ); assert.equal(decision.stage, "budget_cap"); assert.equal(decision.ledgerEvent.eventType, "kill_switch"); + // eventType (not stage) drives ledger decision — termination is a kill_switch, so "paused" (#8864) + assert.equal(decision.ledgerEvent.decision, "paused"); }); test("non-convergence: a stuck item denies before reputation/self-plagiarism run", () => { diff --git a/test/unit/engine-governor-chokepoint-kill-switch-decision.test.ts b/test/unit/engine-governor-chokepoint-kill-switch-decision.test.ts new file mode 100644 index 0000000000..5fb4780151 --- /dev/null +++ b/test/unit/engine-governor-chokepoint-kill-switch-decision.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + evaluateGovernorChokepoint, + type GovernorChokepointInput, +} from "../../packages/loopover-engine/src/governor/chokepoint"; + +function baseInput(overrides: Partial = {}): GovernorChokepointInput { + return { + actionClass: "open_pr", + repoFullName: "acme/widgets", + nowMs: 10_000, + wouldBeAction: { action: "open_pr", title: "Fix bug" }, + killSwitchGlobal: false, + killSwitchRepoPaused: false, + liveModeGlobalOptIn: true, + liveModeRepoOptIn: "live", + rateLimitBuckets: { global: {}, perRepo: {} }, + rateLimitBackoffAttempts: {}, + capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 0 }, + capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 }, + convergenceInput: { attempts: 0, consecutiveFailures: 0, reenqueues: 0, reachedDone: false }, + ...overrides, + }; +} + +describe("denyResult ledger decision keys off eventType (#8864)", () => { + it("budget-cap termination ceiling ledgers decision paused (kill_switch eventType, budget_cap stage)", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ + capUsage: { budgetSpent: 0, turnsTaken: 0, elapsedMs: 2_000_000 }, + capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 }, + }), + ); + expect(decision.stage).toBe("budget_cap"); + expect(decision.ledgerEvent.eventType).toBe("kill_switch"); + expect(decision.ledgerEvent.decision).toBe("paused"); + }); + + it("top-level kill_switch stage still ledgers paused", () => { + const decision = evaluateGovernorChokepoint(baseInput({ killSwitchGlobal: true })); + expect(decision.stage).toBe("kill_switch"); + expect(decision.ledgerEvent.eventType).toBe("kill_switch"); + expect(decision.ledgerEvent.decision).toBe("paused"); + }); + + it("budget-cap soft deny (non-termination) still ledgers deny", () => { + const decision = evaluateGovernorChokepoint( + baseInput({ + capUsage: { budgetSpent: 100, turnsTaken: 0, elapsedMs: 0 }, + capLimits: { budget: 100, turns: 100, elapsedMs: 1_000_000 }, + }), + ); + expect(decision.stage).toBe("budget_cap"); + expect(decision.ledgerEvent.eventType).toBe("denied"); + expect(decision.ledgerEvent.decision).toBe("deny"); + }); +});