From 729a07900a528fd2a8a7676db3dd980e00ac253a Mon Sep 17 00:00:00 2001 From: fjia Date: Tue, 16 Jun 2026 10:13:40 +0800 Subject: [PATCH] @ fix: clear writerFailures counter in resetThresholds resetThresholds(sessionID) clears crossed and maxCrossed but leaves writerFailures untouched. A session entering a fresh checkpoint threshold cycle after rebuild could inherit stale failure counts, hitting the retry cap prematurely. Add writerFailures.delete(sessionID) so all per-session checkpoint threshold state resets together. Add test that records failures, calls resetThresholds, then verifies the next cycle starts with a fresh failure count. Closes: #734 @ --- packages/opencode/src/session/prune.ts | 1 + packages/opencode/test/session/prune.test.ts | 42 ++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/packages/opencode/src/session/prune.ts b/packages/opencode/src/session/prune.ts index 71b21d31..00887ae0 100644 --- a/packages/opencode/src/session/prune.ts +++ b/packages/opencode/src/session/prune.ts @@ -463,6 +463,7 @@ export const layer: Layer.Layer< const resetThresholds = Effect.fn("SessionPrune.resetThresholds")(function* (sessionID: SessionID) { crossed.delete(sessionID) maxCrossed.delete(sessionID) + writerFailures.delete(sessionID) }) return Service.of({ prune, fireCheckpoints, maxThresholdCrossed, resetThresholds }) diff --git a/packages/opencode/test/session/prune.test.ts b/packages/opencode/test/session/prune.test.ts index 47b2b750..0dd12f28 100644 --- a/packages/opencode/test/session/prune.test.ts +++ b/packages/opencode/test/session/prune.test.ts @@ -367,6 +367,48 @@ describe("SessionPrune.fireCheckpoints writer-failure retry", () => { { checkpoint: { thresholds: ["50%"] } }, ) }) + + test("resetThresholds clears writer failure counter without prior success", async () => { + const harness = makeRetryHarness() + const promptOps = {} as any + + await runWithHarness( + harness, + Effect.gen(function* () { + const svc = yield* SessionPrune.Service + const ssn = yield* SessionNs.Service + const info = yield* ssn.create({}) + const model = createModel({ context: 100_000, output: 32_000 }) + + // Phase 1: record 2 writer failures (counter = 2, one short of cap). + harness.outcomes.push("failure", "failure") + for (let i = 0; i < 2; i++) { + yield* svc.fireCheckpoints({ sessionID: info.id, model, tokens: makeTokens(), promptOps }) + yield* Effect.sleep(100) + } + expect(harness.state.enqueueCount).toBe(2) + + // Simulate a discard+rebuild cycle: resetThresholds should clear + // crossed, maxCrossed, AND writerFailures together. + yield* svc.resetThresholds(info.id) + + // Phase 2: with a fresh counter, all 3 failure slots are available. + harness.outcomes.push("failure", "failure", "failure") + for (let i = 0; i < 3; i++) { + yield* svc.fireCheckpoints({ sessionID: info.id, model, tokens: makeTokens(), promptOps }) + yield* Effect.sleep(100) + } + // Counter started fresh → all 3 fire. Total enqueues: 2 + 3 = 5. + expect(harness.state.enqueueCount).toBe(5) + + // Fourth fire: counter === 3 again (cap hit), crossed stays → no enqueue. + yield* svc.fireCheckpoints({ sessionID: info.id, model, tokens: makeTokens(), promptOps }) + yield* Effect.sleep(100) + expect(harness.state.enqueueCount).toBe(5) + }), + { checkpoint: { thresholds: ["50%"] } }, + ) + }) }) describe("defaultThresholdsFor (Part 2 density)", () => {