From 0fa1aea357841b8da7eb78ec83922ddab3a37e18 Mon Sep 17 00:00:00 2001 From: Joey Krug Date: Sat, 14 Mar 2026 16:13:10 -0400 Subject: [PATCH] fix: cap timeout-compaction retries to prevent rotation bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When timeout-triggered compaction succeeds, the run loop retries the same model via `continue`. Without a cap, repeated timeouts with successful compaction (e.g. force: true on long sessions) create an infinite compact→retry→timeout→compact loop that never falls through to the failover rotation path (shouldRotate). Add a timeoutCompactionAttempts counter (max 1) so after one successful timeout compaction, subsequent timeouts skip compaction and fall through to profile/model rotation. Co-Authored-By: Claude Opus 4.6 --- .../run.timeout-triggered-compaction.test.ts | 39 +++++++++++++++++++ src/agents/pi-embedded-runner/run.ts | 9 ++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts index 24b7720ed81..b92c31ba602 100644 --- a/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts +++ b/src/agents/pi-embedded-runner/run.timeout-triggered-compaction.test.ts @@ -231,6 +231,45 @@ describe("timeout-triggered compaction", () => { expect(mockedCompactDirect).not.toHaveBeenCalled(); }); + it("falls through to failover rotation after max timeout compaction attempts", async () => { + // First attempt: timeout with high usage (150k / 200k = 75%) + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { total: 150000 }, + } as never, + }), + ); + // Compaction succeeds on first timeout + mockedCompactDirect.mockResolvedValueOnce( + makeCompactionSuccess({ + summary: "timeout recovery compaction", + tokensBefore: 150000, + tokensAfter: 80000, + }), + ); + // Second attempt after compaction: also times out with high usage + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + timedOut: true, + lastAssistant: { + usage: { total: 140000 }, + } as never, + }), + ); + + const result = await runEmbeddedPiAgent(overflowBaseRunParams); + + // Compaction was only attempted once (first timeout); second timeout + // should NOT trigger compaction because the counter is exhausted. + expect(mockedCompactDirect).toHaveBeenCalledTimes(1); + expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); + // Falls through to timeout error payload (failover rotation path) + expect(result.payloads?.[0]?.isError).toBe(true); + expect(result.payloads?.[0]?.text).toContain("timed out"); + }); + it("catches thrown errors from contextEngine.compact during timeout recovery", async () => { // Timeout with high usage mockedRunEmbeddedAttempt.mockResolvedValueOnce( diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 3309a7af1fb..f3b47d7dbb5 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -815,6 +815,7 @@ export async function runEmbeddedPiAgent( } }; + const MAX_TIMEOUT_COMPACTION_ATTEMPTS = 1; const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3; const MAX_RUN_LOOP_ITERATIONS = resolveMaxRunRetryIterations(profileCandidates.length); let overflowCompactionAttempts = 0; @@ -827,6 +828,7 @@ export async function runEmbeddedPiAgent( let autoCompactionCount = 0; let runLoopIterations = 0; let overloadFailoverAttempts = 0; + let timeoutCompactionAttempts = 0; const maybeMarkAuthProfileFailure = async (failure: { profileId?: string; reason?: AuthProfileFailureReason | null; @@ -1098,7 +1100,11 @@ export async function runEmbeddedPiAgent( if (timedOut && !aborted && !timedOutDuringCompaction) { const tokenUsedRatio = lastTurnTotal != null && ctxInfo.tokens > 0 ? lastTurnTotal / ctxInfo.tokens : 0; - if (tokenUsedRatio > 0.65) { + if (timeoutCompactionAttempts >= MAX_TIMEOUT_COMPACTION_ATTEMPTS) { + log.warn( + `[timeout-compaction] already compacted ${timeoutCompactionAttempts} time(s) for timeouts; falling through to failover rotation`, + ); + } else if (tokenUsedRatio > 0.65) { const timeoutDiagId = createCompactionDiagId(); log.warn( `[timeout-compaction] LLM timed out with high context usage (${Math.round(tokenUsedRatio * 100)}%); ` + @@ -1148,6 +1154,7 @@ export async function runEmbeddedPiAgent( await runOwnsCompactionAfterHook("timeout recovery", timeoutCompactResult); if (timeoutCompactResult.compacted) { autoCompactionCount += 1; + timeoutCompactionAttempts += 1; log.info( `[timeout-compaction] compaction succeeded for ${provider}/${modelId}; retrying prompt`, );