diff --git a/packages/loopover-engine/src/miner/iterate-policy.ts b/packages/loopover-engine/src/miner/iterate-policy.ts index 8c7aa3f665..282a04280a 100644 --- a/packages/loopover-engine/src/miner/iterate-policy.ts +++ b/packages/loopover-engine/src/miner/iterate-policy.ts @@ -71,14 +71,20 @@ export type IterationState = { /** 1-indexed count of iterations attempted so far, INCLUDING this one. */ iterationNumber: number; /** Hard ceiling enforced INSIDE this policy (#2333's own deliverable: not left to an external caller to - * remember to enforce). `iterationNumber >= maxIterations` abandons regardless of self-review outcome. */ + * remember to enforce). `iterationNumber >= maxIterations` abandons when the self-review has NOT reached a + * clean pass; a clean pass at or past the ceiling still hands off (subject to `autonomyLevel`), because the + * `pass` branch precedes both ceilings in `decideNextActionWithReason`'s numbered precedence list -- see + * that list for the one canonical statement of the ladder. */ maxIterations: number; /** True when the loop's own cumulative cost ceiling (e.g. total driver turns spent across every iteration of * this attempt so far, not just this one) has been reached or exceeded -- the loop mechanics' (#2333) OWN * "max-cost ceiling enforced inside the loop" deliverable, alongside the iteration ceiling above. This * policy has no notion of what "cost" means; the caller computes the boolean from whatever cost signal it - * tracks. Optional and defaults to not-reached, so `IterationState` fixtures that predate this field remain - * valid. */ + * tracks. Like the iteration ceiling, it abandons only when the self-review has NOT reached a clean pass; a + * clean pass at or past the ceiling still hands off (subject to `autonomyLevel`), because the `pass` branch + * precedes both ceilings in `decideNextActionWithReason`'s numbered precedence list -- see that list for the + * one canonical statement of the ladder. Optional and defaults to not-reached, so `IterationState` fixtures + * that predate this field remain valid. */ costCeilingReached?: boolean | undefined; selfReview: SelfReviewOutcome; /** The prior iteration's `fail` blocker codes, for the no-progress detector -- `null` when there is no prior diff --git a/packages/loopover-engine/test/iterate-policy.test.ts b/packages/loopover-engine/test/iterate-policy.test.ts index 401135f8d9..62a3295b09 100644 --- a/packages/loopover-engine/test/iterate-policy.test.ts +++ b/packages/loopover-engine/test/iterate-policy.test.ts @@ -249,3 +249,35 @@ test("autonomy #6560: rejectionSignaled and an ambiguous self-review still win o ); assert.equal(ambiguous.abandonReason, "self_review_ambiguous"); }); + +// #9997: the `maxIterations`/`costCeilingReached` field docs claimed the ceilings abandon "regardless of +// self-review outcome", but the precedence list puts the `pass` branch (step 3) AHEAD of both ceilings +// (steps 4-5), so a clean pass at or past a ceiling still hands off. These pin that precedence end to end so +// a future reordering fails the suite instead of silently discarding a passing attempt. +test("#9997: a clean pass AT the iteration ceiling still hands off (pass precedes the ceiling)", () => { + const decision = decideNextActionWithReason(passingState({ iterationNumber: 5, maxIterations: 5 })); + assert.equal(decision.action, "handoff"); + assert.equal(decision.abandonReason, undefined); +}); + +test("#9997: a clean pass with the cost ceiling reached still hands off", () => { + const decision = decideNextActionWithReason(passingState({ costCeilingReached: true })); + assert.equal(decision.action, "handoff"); + assert.equal(decision.abandonReason, undefined); +}); + +test("#9997: a clean pass with BOTH ceilings reached still hands off", () => { + const decision = decideNextActionWithReason(passingState({ iterationNumber: 5, maxIterations: 5, costCeilingReached: true })); + assert.equal(decision.action, "handoff"); + assert.equal(decision.abandonReason, undefined); +}); + +test("#9997: the two branches that DO win over a pass still do, pinning the ladder end to end", () => { + // rejectionSignaled (step 1) beats a pass... + const rejected = decideNextActionWithReason(passingState({ rejectionSignaled: true })); + assert.equal(rejected.abandonReason, "rejection_signaled"); + // ...and autonomy "observe" narrows the pass->handoff transition to an abandon, NOT max_iterations_reached, + // even at a reached ceiling (the pass branch is entered first, and observe abandons inside it). + const observed = decideNextActionWithReason(passingState({ autonomyLevel: "observe", iterationNumber: 5, maxIterations: 5 })); + assert.equal(observed.abandonReason, "autonomy_observe_only"); +}); diff --git a/test/unit/engine-iterate-policy-autonomy.test.ts b/test/unit/engine-iterate-policy-autonomy.test.ts index a24f24f472..5a2719de23 100644 --- a/test/unit/engine-iterate-policy-autonomy.test.ts +++ b/test/unit/engine-iterate-policy-autonomy.test.ts @@ -114,4 +114,18 @@ describe("autonomy never overrides the higher-precedence ladder steps (#6560)", expect(noProgress.abandonReason).toBe("no_progress"); }, ); + + it("#9997: a clean pass at or past a ceiling still hands off — the pass branch precedes both ceilings", () => { + // The field docs used to claim the ceilings abandon "regardless of self-review outcome"; the precedence + // list (step 3 before steps 4-5) is the real contract, so a clean pass wins over a reached ceiling. + for (const state of [ + passingState({ iterationNumber: 5, maxIterations: 5 }), + passingState({ costCeilingReached: true }), + passingState({ iterationNumber: 5, maxIterations: 5, costCeilingReached: true }), + ]) { + const decision = decideNextActionWithReason(state); + expect(decision.action).toBe("handoff"); + expect(decision.abandonReason).toBeUndefined(); + } + }); });