fix(compaction): apply warm summaries anchored to the summarized prefix - #853
Conversation
The idle warm-up moves summarization off the user's critical path, but its result was pinned to `expectedRevision`, a counter that increments on every appended message. A session parked in a cache-warm wait appends wait notices, monitor state, and finally the user's own prompt, so the warm summary was guaranteed stale exactly when the blocking route needed it: field logs show 415 warm-ups started, 36 consumed and only 10 applied, the rest paying a second full summarization for work already done. A summary describes the entries before its cut point, so growth after that cut cannot invalidate it. Validate `preparation.firstKeptEntryId` against the current branch instead, and admit the warm result while the summarized prefix is unchanged; a landed compaction boundary or a missing anchor still rejects. `speculative-compaction.test.ts` asserted the defect directly - its fixture bumps the revision without mutating the session at all - so that case now asserts the intact-prefix contract and a new case covers the rewritten prefix.
Adds an RPC scenario that parks a session over the compaction threshold but under the hard limit, so `before_agent_start` takes the same `threshold_trigger` -> `applyBlockingCompaction` path as the field logs, then asserts the compaction debug log records `speculative_applied` with no `speculative_stale` and that the blocking path bills no second summarization. Requests are classified by the exact compaction instruction text rather than loose word matching, and the sandbox compaction log is captured as evidence before teardown.
CI status: the
|
| Shard | main run 31676716029 |
this PR |
|---|---|---|
| 1/3 | claude-sdk-oauth-diagnostic-render, regressions/6784-claude-sdk-oauth-default-lane |
same cluster |
| 2/3 | claude-sdk-oauth-observability, claude-sdk-oauth-stream |
same cluster |
| 3/3 | claude-sdk-oauth-auth-lane, regressions/494-claude-sdk-oauth-installed-sdk-hook-stop |
claude-sdk-oauth-auth-lane |
Every failure is in the claude-sdk-oauth cluster. No compaction test fails. Nothing in this diff touches that subsystem, and no commit between this branch's base (1b7d72e19) and current main touches claude-sdk-oauth source or tests.
Local A/B (same machine, CI=1, --shard=3/3)
| files | tests failed | |
|---|---|---|
| this branch | 303 | 14 |
pristine origin/main |
302 | 14 |
Identical failure count and identical failing file; the totals differ by exactly this PR's +1 test file / +3 passing tests. The targeted files also pass 14/14 on both branch and main locally, and 56/56 under CI=1 with the new test files co-resident.
What this PR's own gates say
- Compaction suite 342/342 (341 before; +2 new cases, 0 deleted)
- Root
npm run check— green Static checks,Terminal tools(mac/ubuntu/windows),Inspector handoff(mac/ubuntu/windows),Test (workspaces + scripts),Changelog gate, GitGuardian — all pass- Real-CLI QA
warm-summary-anchor-qa.mjs— 8/8, compaction log recordsspeculative_appliedwith nospeculative_stale, one summarization, real auth sha256 unchanged cubicskipped: monthly quota exhausted (80,038/80,000 lines, resumes 1 Sep) — the only permitted skip reason
The claude-sdk-oauth breakage on main is a separate defect and deserves its own fix rather than being bundled here.
The core admission gate rejected a warm summary whenever a compaction record sat later in the branch than the anchor. Compaction records are appended after the entries they summarize, so a valid next-generation anchor routinely precedes the boundary it updates: every warm summary in an already-compacted session was discarded and re-billed, which is the steady state for the long sessions this feature exists to serve. Compare the latest compaction entry by ID against the snapshot instead. Simply dropping the positional rule would have admitted a summary built on one branch's boundary onto a sibling branch, so the snapshot now carries the anchor, its prefix entry ids, and the boundary id, and both gates run the one validator in core/compaction/warm-anchor.ts rather than two rules that drifted apart. The negative test now also asserts a fresh summary is generated and applied, so it cannot pass by the route giving up; its fixture keeps a message past the new boundary because a compaction record at the branch tip makes prepareCompaction bail.
# Conflicts: # packages/coding-agent/CHANGELOG.md
Problem
The idle warm-up exists so summarization happens while the user is not waiting. Its result was
pinned to
expectedRevision, a counteragent-sessionbumps on every appended message. Asession parked in a cache-warm wait keeps appending — wait notices, monitor state, and finally the
user's own next prompt — so the warm summary was guaranteed stale exactly when the blocking route
needed it.
Field logs from a real session:
Aggregate over that log: 415 warm-ups started, 36 consumed, 26 stale, 10 applied. Commit
3689633 previously made
stalenon-terminal (regenerate instead of "Compaction did not apply"),which made the path correct but left the warm result unusable.
Fix
A summary describes the entries before its cut point, so growth after that cut cannot
invalidate it — only a rewrite of the summarized prefix can.
CompactionPreparation.firstKeptEntryIdis that cut.warm-anchor.tspredicate: the anchor still exists, the prefix ids are unchanged, and nonewer compaction boundary landed behind it.
applyGeneratedCompactionconsults it when the revision moved, instead of rejecting outright.ApplyCompactionOptionsgainsexpectedFirstKeptEntryId; the core compare-and-apply gatere-validates the same anchor before mutating the transcript.
expectedRevisionis untouched forback-compat.
Verification
warm-summary-anchor.test.tsfailedexpected 2 to be 1(warm summarydiscarded, a second summarization billed), then passed at one summarization.
still rejected and regenerated.
npm run checkgreen.warm-summary-anchor-qa.mjs, RPC + mock provider): 8/8, compaction log recordsspeculative_appliedwith nospeculative_stale, one summarization, second reply delivered,real auth untouched.
One pre-existing test asserted the defect
speculative-compaction.test.tshad "skips applyCompaction when message revision changes", whosefixture bumps the revision without mutating the session at all — provably an append-only case.
That assertion now expresses the intact-prefix contract, and a new case covers the rewritten-prefix
rejection it was standing in for.
Scope limit (measured, not assumed)
A file probe on the real CLI shows a second path that this PR does not change:
When the core route (
session_before_compact) wins, it invalidates the warm job and regenerates.Handing the warm summary to that route needs core admission changes (a first attempt produced a
retry storm), so it is deliberately left to a follow-up rather than bolted on here.
Plan:
.omo/plans/warm-summary-anchor.mdSummary by cubic
Applies idle warm-up summaries when the summarized prefix is unchanged, instead of discarding them because the message revision advanced. Previously tied to
expectedRevision(stale on any append); now validated by a content anchor and still rejected when the anchor is missing or a compaction boundary rewrote the prefix.AgentSession.applyCompactionacceptsexpectedWarmAnchorand revalidates via a sharedwarm-anchorvalidator;expectedRevisionremains supported.speculative.tsbuilds aWarmAnchorSnapshotfrompreparation.firstKeptEntryId; usesexpectedWarmAnchorwhen the revision changed and the anchor is valid, otherwiseexpectedRevision.ApplyCompactionOptionsadds optionalexpectedWarmAnchor; backward compatible.speculative-compaction.test.ts, newwarm-summary-anchor.test.ts, and a real CLI scenariowarm-summary-anchor-qa.mjsthat recordsspeculative_appliedand nospeculative_stale.Migration and rollout
expectedRevision.expectedWarmAnchorto reuse warm summaries after idle appends.Written for commit 3265c8e. Summary will update on new commits.