Skip to content

fix(session): prevent double auto-compaction from filterCompacted reorder#27545

Merged
kitlangton merged 4 commits into
devfrom
fix-double-auto-compaction-repro
May 14, 2026
Merged

fix(session): prevent double auto-compaction from filterCompacted reorder#27545
kitlangton merged 4 commits into
devfrom
fix-double-auto-compaction-repro

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

@kitlangton kitlangton commented May 14, 2026

Summary

After #27145, MessageV2.filterCompacted returns messages reordered for model consumption:

[compaction-user, summary-assistant, ...retained tail..., continue-user]

SessionPrompt.runLoop walks msgs backward from the array end to pick lastUser, lastAssistant, lastFinished, and tasks (packages/opencode/src/session/prompt.ts:1660-1668). The walk treats array order as chronological. With the reorder it isn't — the backward walk hits a finished assistant inside the retained tail (a pre-compaction overflow turn) before it reaches the summary at the front. lastFinished then has summary === undefined and tokens.total ≈ 280K, the overflow guard at prompt.ts:1722 (lastFinished.summary !== true && isOverflow(...)) fails open on the stale tokens, and compaction.create fires a second time as soon as the first summary completes.

Observed in opencode.db as pairs of auto: true compaction parts ~1s–3m apart in multiple sessions post-2026-05-12, e.g. ses_1dcaf652cffeB0ZVK2zWAkxRSD at 08:01:35 → 08:02:41 today, where the second compaction summary started and then aborted.

Fix

Sort msgs by id (monotonic) inside the walk and iterate the chronological view. msgs itself stays in model-consumption order so toModelMessagesEffect and handleSubtask keep receiving the layout #27145 intended.

Smallest change that fixes the bug at the source. Alternatives considered:

  • Move the reorder out of filterCompacted into model-message construction. Architecturally cleaner, but msgs is consumed by both toModelMessagesEffect (prompt.ts:1827) and handleSubtasktaskTool.execute (prompt.ts:1705), so the reorder would have to be threaded through both paths. Bigger blast radius than a regression fix should carry; that cleanup is its own change.
  • Reshape the reorder so backward walk happens to work. Loses the point of fix(compaction): restore tail turns after summarization #27145, which deliberately puts [summary, tail] immediately before the continue prompt so the model attends to recent grounding.
  • Patch only the overflow guard. lastFinished and lastAssistant also feed the loop-exit check (prompt.ts:1682-1690) and the user-text wrap (prompt.ts:1803-1819). Both happen to coincidentally tolerate the wrong value today; better to fix the source.

Commits

  1. test(compaction): reproduce double auto-compaction trigger — failing regression test using the post-compaction message shape.
  2. fix(session): walk msgs chronologically when picking lastFinished — sort by id before the walk; updates the test to mirror the fixed walk so it stays a faithful regression contract.

Test plan

  • bun run test test/session/message-v2.test.ts -t filterCompacted fails on the test-only commit, passes after the fix
  • bun run test test/session/message-v2.test.ts test/session/compaction.test.ts test/session/prompt.test.ts — 138 pass, 0 fail
  • bun run typecheck clean

After PR #27145's tail-restoration reorder in filterCompacted, the
returned array is

  [compaction-user, summary-assistant, ...tail..., continue-user]

SessionPrompt.runLoop walks msgs backward to pick lastFinished and
gates the overflow check on lastFinished.summary !== true. With the
reordered layout the backward walk hits a finished assistant inside the
retained tail before it reaches the summary, the guard fails open, and
a second compaction.create fires immediately after the first summary
completes. Observed in the wild as two auto compaction parts created
within ~1s of each other.

Failing test mirrors the runLoop walk on the output of filterCompacted
and asserts that lastFinished is the summary, not the pre-compaction
overflow assistant.
filterCompacted returns messages in model-consumption order

  [compaction-user, summary, ...retained tail..., continue-user]

so the backward array walk in runLoop was picking a finished assistant
from the retained tail (a pre-compaction overflow turn) as lastFinished
instead of the freshly-completed summary. The overflow guard at
prompt.ts:1722 (lastFinished.summary !== true && isOverflow(tokens))
then failed open on the old assistant's stale ~280K-token usage and
fired compaction.create a second time immediately after the first
summary completed.

Sort msgs by id (MessageID is monotonic) before the backward walk so
lastUser, lastAssistant, lastFinished, and tasks are derived in
chronological order. msgs itself is left in model-consumption order so
toModelMessagesEffect and handleSubtask still see the reorder intended
by PR #27145.
@kitlangton kitlangton changed the title test(compaction): reproduce double auto-compaction trigger fix(session): prevent double auto-compaction from filterCompacted reorder May 14, 2026
@rekram1-node
Copy link
Copy Markdown
Collaborator

/review

// (packages/opencode/src/session/prompt.ts ~1660). The runLoop sorts msgs
// chronologically by id before walking so the model-consumption reorder
// performed by filterCompacted does not poison lastFinished.
const chronological = filtered
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion for the human to decide: this test now duplicates the SessionPrompt.runLoop sorting/walk logic instead of exercising the implementation that regressed. That is at odds with our testing guidance to test the actual implementation and not copy production logic into tests; it also means a future change that breaks the runLoop walk could leave this regression test passing. Could this cover the behavior through SessionPrompt.loop/runLoop, or alternatively extract the “latest chronological state” selection into a small tested helper used by runLoop?

Replaces slice+sort+reverse-walk with one forward pass tracking running
max-id for lastUser, lastAssistant, lastFinished, and a flatMap for
tasks. O(n), no allocation, no comparator, and the chronological intent
is explicit at the comparison site instead of buried in a sort callback.

Also fixes a latent dead-code check the rewrite obviated:
`if (task && !lastFinished)` where task was always a (possibly empty)
array, making the truthy half a no-op.

Test refactored to assert the same chronological-latest invariant via
reduce rather than mirroring the implementation's walk shape, so it
stays a faithful regression contract if the implementation changes.
The chronological-vs-model-consumption order distinction was the source
of the original double-compaction bug, so it deserves a named function
the runLoop can call instead of inlining. MessageV2.latest returns
{ user, assistant, finished, tasks } picked by max id (MessageID is
monotonic).

Two benefits over the previous inline form:
- runLoop's binding setup is one line instead of 24, and the comment
  explaining the model-consumption-order gotcha lives with the helper
  rather than at every consumer.
- Test now exercises MessageV2.latest directly. Previously the test
  re-implemented the selection logic, so a future change that broke
  the runLoop walk could pass the regression test silently.

Adds a second unit test covering the other half of latest's contract
(a fresh compaction-user newer than the latest summary must surface in
tasks so processCompaction runs).
@kitlangton kitlangton enabled auto-merge (squash) May 14, 2026 17:44
@kitlangton kitlangton disabled auto-merge May 14, 2026 17:55
@kitlangton kitlangton merged commit 94564f3 into dev May 14, 2026
10 checks passed
@kitlangton kitlangton deleted the fix-double-auto-compaction-repro branch May 14, 2026 17:56
opencode-agent Bot added a commit that referenced this pull request May 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants