fix(opencode): stop overflow compaction from replaying a message that cannot fit

YU
2mo ago
+142-0

A single message larger than the model's context window used to make opencode loop forever: overflow, compact, replay the same message, overflow again. In production this ran 2,145 cycles over 12.8 hours on one customer session ($296) before it was killed by hand. After this change the second overflow compaction in a row fails the turn with a clear error instead.

Context: Replo postmortem REPL-31509, companion tickets REPL-31502 (attachment size cap) and REPL-31503 (Retry on overflow). The coordinator side gets a circuit breaker in a separate andytown PR; this is the fix at the source.

Why the loop never ended. compaction.process({ overflow: true }) replays the last real user message after the summary so the request is not lost. The only existing guard fires when the summary call overflows. Here the summary always succeeded (~580k tokens) and the replayed message is what overflowed, one step later, which fed straight back into compaction.create(auto: true).

Changes

  • In processCompaction, before calling the model: if this is an overflow compaction and no finished, non-errored assistant step has happened since the previous summary, the conversation is stalled. Write the summary row as finish: "error" with a ContextOverflowError explaining the message is too large, and return "stop". Writing the error on the summary row consumes the pending compaction task, so the next prompt does not re-fire it.
  • Also refuse the replay when Token.estimate of the replayed parts alone exceeds the usable window. This catches the obvious cases without paying for a summary; the stall check is the one that catches token-dense content the estimator undercounts (base64, markup).
  • The stall check reads input.messages, not the trimmed history: with a replay, history has already dropped the replayed turn, which is exactly where progress would show.

Cost bound after this change: one oversized message costs at most two compaction summaries before the turn fails, instead of unbounded.

Testing Done

  • New tests in test/session/compaction.test.ts: a summary followed by a replayed message that errored, then another overflow compaction, returns "stop" with the errored summary row; a summary followed by a finished step and then an overflow compaction still returns "continue".
  • bun test test/session/compaction.test.ts -t overflow passes (4/4). The full file has pre-existing timing-sensitive tests that time out on this laptop under the default 5s budget; with --timeout 30000 the only remaining failure is stops quickly when aborted during retry backoff, a wall-clock assertion unrelated to this change.
  • bun run typecheck and prettier --check pass on the changed files.

šŸ¤– Generated with Claude Code


Devin Review

Summary by cubic

Stops overflow compaction from replaying a message that cannot fit the context window, which previously looped forever (2,145 cycles over 12.8 hours in one production session). The second overflow compaction in a row now fails the turn with a clear error.

  • Fails the turn when an overflow compaction follows a summary that produced no finished assistant step, writing the error on the summary row so the pending compaction task is consumed.
  • Refuses the replay when the estimated token size of the replayed parts exceeds the usable window; the stall check catches token-dense content the estimator undercounts.