fix: cap timeout-compaction retries to prevent rotation bypass

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 <noreply@anthropic.com>
This commit is contained in:
Joey Krug 2026-03-14 16:13:10 -04:00
parent 2ada811e9d
commit 0fa1aea357
2 changed files with 47 additions and 1 deletions

View File

@ -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(

View File

@ -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`,
);