fix(compaction): recover a session from a provider-killed summary stream - #1745
Merged
Merged
Conversation
A summary stream terminated by a provider or credential failure classifies as undefined today, so required compaction rethrows the marker-bearing error, the session cannot retry or fall back, and the next prompt repeats it forever. Final result() settlement also sits outside the watchdog, and nothing bounds one compaction's total wall clock independently of its input size. Refs #1741
A summary stream terminated by a credential-rotation or provider error classified as undefined, so the deterministic fallback that exists exactly for "summarization did not complete" never ran: the required route rethrew, the `senpi:no-turn-retry:` marker disabled both session retry and model fallback, the context stayed above the threshold, and the next prompt repeated the same failure forever. Authorize the fallback by outcome instead of by a four-class enumeration: a credential failover error, a marker-bearing error, or a non-transient, non-refused summary-request error now maps to `summarization-provider-failure` on every required route, and the same classes are mirrored in the summarization retry predicate so the terminal class is never re-billed. Refusals, aborts and missing credentials stay loud. Bound one compaction at 15 minutes across every attempt and retry regardless of input size, keep the stream's final settlement inside the watchdog so a provider that ends its iterator without a terminal event cannot park compaction with no timer armed, and state the recovery to the user in plain words without the internal marker. Fixes #1741
Three characterizations expressed a "policy rejection" only as error-message prose, so they pinned the exact contract #1741 changes: a terminal provider error that is not a refusal used to propagate and wedge the session. Bind them to the real `stopDetails.type` refusal signal, which still fails closed, and add the recovery case for a terminal provider error. Refs #1741
…allback-authorization # Conflicts: # packages/coding-agent/CHANGELOG.md
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A session could become permanently uncontinuable when the compaction summarizer was killed by a
provider or credential failure. This makes that impossible: any summary stream that dies without a
usable summary now recovers through the deterministic checkpoint on every required-compaction route,
the turn continues, the user is told plainly what happened, and one compaction is bounded in wall
clock regardless of how large its input is.
Fixes #1741
Root cause
Two independent facts combined into the wedge.
The 690s the reporter saw was a legal budget, not a watchdog that failed to fire.
packages/coding-agent/src/core/compaction/stream-watchdog.ts:57scales the per-attempt budget at2 ms per estimated input token,
:65caps it at 1,800,000 ms and:78-84clamps it against a120,000 ms floor, so a 345k-token input licenses exactly 690,000 ms and a 900k-token input licenses
the full 30 minutes. Every retry re-arms that budget from scratch
(
.../builtin/compaction/summarization-retry.ts:19-27,47), and nothing bounded the operation as awhole.
When that stream finally failed, the error class escaped every recovery mechanism.
packages/coding-agent/src/core/extensions/builtin/compaction/deterministic-fallback.ts:143-159(
classifyRequiredCompactionFallbackFailure) was a four-class enumeration. Credential rotationrethrows every terminal outcome as
CredentialFailoverError(
packages/coding-agent/src/core/credential-pool/failover.ts:145-167) and, once any event paststartreached the caller, prepends thesenpi:no-turn-retry:marker. That class matched none ofthe four, so it classified as
undefined: the deterministic fallback that exists precisely for"summarization did not complete" never ran, the error was rethrown
(
.../builtin/compaction/index.ts:555,:590), the marker disabled both session retry and modelfallback (
packages/coding-agent/src/core/agent-session.ts:7876-7878,:7949-7951), the contextstayed above the threshold, and the next prompt re-entered required compaction and failed
identically. The circuit breaker never debited because the failure is classified non-transient
(
.../builtin/compaction/index.ts:585-589). Single-key providers have no rotation wrapper and reachthe same
undefinedthrough a non-transientSummaryRequestError.Two further holes on the same path: the marker prefix
(
packages/coding-agent/src/core/agent-session.ts:252) reached user-visible compaction text, andfinal
result()settlement sat outside the watchdog -packages/coding-agent/src/core/compaction/compaction.ts:767awaitedresult()after the timer'sfinallyhad cleared it (:746-750armed it only around iteration), same shape at.../builtin/compaction/speculative-summary.ts:162/173. A provider whose iterator ends without aterminal event parked compaction forever with no timer armed at all.
What changed
classifyRequiredCompactionFallbackFailuregains
summarization-provider-failurefor a summary stream terminated by a provider or credentialfault: any
CredentialFailoverError, any error whose message carries the retry-suppressionmarker, and a non-transient, non-refused
SummaryRequestErrorwith no structured failure kind.This reaches every required route, because all four call sites (blocking route, inherited warm
job, core route, warm-inherit gate) go through that one classifier. User aborts, policy refusals
(
SummaryRequestErrornow carries an explicitrefusedflag set fromrefusal/sensitivestopdetails), missing credentials and ordinary bugs stay unauthorized - destructive context reduction
must never be a bug's recovery path.
isRetryableSummaryAttemptrefuses the same classes, so amarker-bearing failure is never re-billed as another summarization request (its wrapped provider
detail can read as transient), while a genuinely transient failure still retries unchanged.
consumeStreamWithIdleTimeouttakes an optionalsettle()and awaits the stream's finalresult()under the same idle and wall-clock timers asiteration; both summarization call sites return that value instead of awaiting
result()afterthe watchdog cleared its timers.
SUMMARIZATION_TOTAL_BUDGET_MS(900,000 ms) andcreateSummarizationDeadline()bound a whole compaction across every attempt, retry and overflowshrink. The bound never scales with the input; each attempt budget is re-clamped to what is left,
and no retry starts past the deadline. An explicit
compaction.summarizationMaxDurationMslargerthan the total still wins, clamped to the existing 30-minute ceiling. The trip classifies as
summarization-timeout, so it recovers through the same checkpoint and surfaces in the samecompaction end message.
that a provider summary could not be completed, that a deterministic checkpoint was applied and
older detail was dropped, that it is safe to continue, and what the provider reported. Every
compaction message built from an error message is stripped of the
senpi:no-turn-retry:prefix;the prefix stays on the error object the session-level retry-suppression predicates read.
One deliberate contract change in existing tests
Three characterizations expressed "a policy rejection surfaces loudly" only as error-message prose
(
errorMessage: "request blocked by provider policy"with nostopDetails), which is exactly theclass this issue says must stop wedging the session. They are now bound to the real refusal signal
(
stopDetails.type), which still fails closed and still surfaces loudly, and each gained a companioncase pinning the new recovery. No assertion was weakened or removed.
Relationship to #1735
PR #1735 (
fix/astra-preprompt-compaction-recovery) touchesdeterministic-fallback.tstoo, but adifferent function: it adds a later-safe-boundary scan to the retained-suffix search and adds
pre_prompttoisRequiredCompactionFallbackReasoninextension-wiring.ts. This PR does not toucheither, is branched from
mainrather than rebased onto #1735, and changes only the classification,watchdog and message paths. The two are complementary: #1735 widens which routes may recover,
this PR widens which failures authorize a recovery at all - #1735's trigger is a classified idle
timeout, and a marker-bearing provider error still escaped it. Whichever lands first, the other
applies cleanly.
Verification
Run on a second machine in a disposable checkout, which was removed afterwards and verified gone.
RED (tests only, before the production change)
GREEN (after the production change, on the branch head)
The whole
test/compactiondirectory is green on this branch:biome check --error-on-warningsover the changed compaction trees reports no errors andtsc --noEmitis clean on the same checkout.test/suite/regressionsplus the credential-failoversuites fail identically on this branch and on
mainin that environment (5 files / 3 tests: RPCsession grants, internal-socket mkdir, fswatch crash, multi-session theme init, cross-project
resume) - pre-existing and unrelated to compaction.
mainmoved while this was in progress, soorigin/mainis merged into the branch and theverification above was re-run on the merged head. This PR is branched from
main, not rebased onto#1735.