Skip to content

fix(compaction): let the core route claim the idle warm summary - #862

Merged
code-yeongyu merged 4 commits into
mainfrom
fix/core-route-warm-handoff
Aug 13, 2026
Merged

fix(compaction): let the core route claim the idle warm summary#862
code-yeongyu merged 4 commits into
mainfrom
fix/core-route-warm-handoff

Conversation

@code-yeongyu

@code-yeongyu code-yeongyu commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Problem

senpi pre-generates a summary while your session sits idle so you never wait for compaction. PR #853 taught the extension route to reuse it. The core route still threw it away.

The ordering is deterministic, not a race — on a new prompt _enforceCompactionBeforeProvider runs before emitBeforeAgentStart, so core always reaches compaction first, and its handler called invalidateSpeculativeCompaction() as its first statement. Measured with a file probe on the real CLI:

IDLE_TRIGGER
CORE_ROUTE_ENTER reason=threshold hasJob=true   # core arrives first, job alive
BLOCKING_ROUTE_ENTER hasJob=false               # already destroyed

So the warm-up was aborted and re-billed in exactly the case it exists for.

Fix

Claim the job for the core route instead of invalidating it:

  • Atomic — detached synchronously before any await, so exactly one route can own it.
  • Not aborted — the controller is deliberately left alive; aborting is what destroyed the finished work.
  • Narrow — the claim holds only for an automatic compaction with no custom instructions, a speculative origin, the same model identity, a valid WarmAnchorSnapshot, and a boundary equal to core's own preparation.firstKeptEntryId. Every other case falls through to the existing fresh generation, unchanged.

Manual compaction keeps its own instructions, a landed boundary still forces regeneration, and a mismatched cut is refused — each covered by a test.

Verification

Real-CLI A/B (core-route-warm-handoff-qa.mjs, RPC + mock provider, sandboxed HOME) — a genuine defect reproduction:

baseline 3358d48ff this branch
result 7/9 9/9
warm_consumed 0 1
  • Unit: RED expected 'fresh summary…' to contain 'warm summary…' → GREEN; 4 cases incl. three safety paths.
  • test/compaction/ 346 passed (344 before).
  • Invariant files green: speculative-budget-handoff, required-compaction-deterministic-fallback, warm-summary-anchor, stale-warm-blocking-repro, metadata-side-effects, lifecycle, idle-compaction, speculative-abort-cancellation, suite/compaction-race, suite/agent-session-compaction — 88 tests.
  • Root npm run check exit 0.

Scope note

An AgentSession-level test cannot drive this: shouldRunIdleCompaction requires a persistent mode (tui/rpc/app-server) and the vitest harness runs in print, so the warm-up never starts there. The end-to-end proof is the RPC QA scenario above, which runs in a real persistent mode.


Summary by cubic

Reuses the idle warm summary in the core route so users don’t wait for a second compaction. Previously core always invalidated the speculative job and regenerated; now it claims the job when safe, returns its result, and reliably aborts any unused claim, including on thrown exits.

  • session_before_compact calls claimWarmSummaryForCoreRoute() before invalidation. The claim detaches the job synchronously, logs warm_consumed with route: "core-route" on success, and uses a try/finally to abort the controller unless the result is consumed (covers early returns and exceptions).
  • Claim conditions: automatic compaction (no custom instructions), speculative origin, same model identity, valid warm anchor on the current branch, and a boundary equal to preparation.firstKeptEntryId. Otherwise it regenerates.
  • Manual compaction, landed boundaries, and mismatched cuts still regenerate. Covered by core-route-warm-handoff.test.ts. Adds a real-CLI QA scenario to verify warm_consumed and that core does not regenerate.

Written for commit 4c91dfd. Summary will update on new commits.

Review in cubic

On a new prompt the ordering is deterministic rather than a race:
`_enforceCompactionBeforeProvider` runs before `emitBeforeAgentStart`, so the
core route always reaches compaction first. Its `session_before_compact` handler
called `invalidateSpeculativeCompaction()` as its very first statement, aborting
and discarding the summary the idle warm-up had already paid for, then
summarizing again while the user waited. PR #853 fixed the extension route; the
warm-up stayed wasted whenever core won.

Claim the job for the core route instead. The claim detaches it synchronously
before any await so exactly one route owns it, and deliberately leaves the
controller unaborted, which is what keeps the finished work alive. It holds only
for an automatic compaction with no custom instructions, the same model, a valid
warm anchor, and a boundary equal to core's own preparation; every other case
falls through to the existing fresh generation untouched.

Real-CLI QA reproduces the defect: on origin/main the scenario scores 7/9 with
`warm_consumed=0`, and 9/9 with `warm_consumed=1` here.
@code-yeongyu

Copy link
Copy Markdown
Owner Author

Test (workspaces + scripts) is red on main, not from this PR

The failing assertion is in scripts/prepare-senpi-bundled-workspaces.test.mjs:191 — an unexpected which: '1.0.0' in the bundled workspace dependency set:

+ actual - expected
  {
    '@earendil-works/pi-ai': 'npm:@code-yeongyu/[email protected]',
    'cross-spawn': '7.0.6',
+   which: '1.0.0'
  }

Reproduced on pristine origin/main (3358d48), same machine, no changes from this branch:

npm run test:scripts
ℹ pass 138
ℹ fail 1
✖ which: '1.0.0'

This branch produces the byte-identical failure. Nothing in the diff touches package.json, the lockfile, or the bundling scripts — it changes one compaction handler, adds a test file, and adds a QA scenario.

The bundled-dependency drift deserves its own fix rather than being bundled into a compaction change.

The claim deliberately detaches the warm job without aborting it, because
aborting is what used to throw the finished summarization away. That left every
non-consuming exit from the handler holding a live request nobody would read:
the SDK-native lane rejection, the per-turn cap, the circuit breaker, a missing
model, and the OpenAI remote path all returned while the warm generation kept
streaming. Before the claim existed, `invalidateSpeculativeCompaction` aborted
it on those paths.

Release the claim on each of those exits, restoring the previous abort
semantics for every path that does not consume the result.
Releasing the claim at each early return covered the paths that return, but not
the ones that throw. `runOpenAiRemoteCompaction` can raise while preparing auth
or transforming the request, and the handler then unwound with the warm request
still streaming and no reference left to it, because the claim had already
detached it from `speculativeJob`.

Hold the claim in a try/finally instead, aborting unless the result was marked
consumed. That covers returns and throws with one rule, so the five explicit
release calls are gone.
@code-yeongyu
code-yeongyu merged commit f9f39e0 into main Aug 13, 2026
15 checks passed
@code-yeongyu
code-yeongyu deleted the fix/core-route-warm-handoff branch August 13, 2026 10:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant