fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(runtime): retreat a rejected fold to a span the provider has accepted - #4667

Merged
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary
Sep 3, 2026
Merged

fix(runtime): retreat a rejected fold to a span the provider has accepted#4667
likun666661 merged 1 commit into
apache:mainfrom
Joob1n:feat/context-compaction-boundary

Conversation

@Joob1n

@Joob1nJoob1n commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Summary

PR 2 of the series on #4559, following the merged #4653. It is the compaction module's failure path and nothing else: 4 files, no protocol change.

When the summarizer's own provider rejects a fold as too large, the planner halved the covered range and tried again. Halving is a guess in both directions. It can overshoot, discarding verbatim history the summarizer would have accepted, and it can undershoot, paying another provider round trip to find that out. The loop then exited through span selection, so the diagnostic reported a span problem for what was a provider verdict.

There is a boundary that needs no guessing. The last request this route had accepted covered everything before the newest reply this route produced. The run headers name that reply, so the span was accepted by this model on this connection and is provably within the provider's capacity; a span some other model accepted proves nothing about this summarizer's window. The fold retreats to it once; a rejection of that span too is the provider saying this fold cannot be made, and the fold fails open with the summarizer's own reason.

fold the largest safe prefix
-> rejected as too large
-> retreat to the span the last accepted input covered
-> rejected again -> fail open, the provider decides
-> accepted -> checkpoint
-> no reply from this route on the ledger -> no proven boundary -> fail open, no retry

The boundary is read from the ledger and its run headers rather than persisted, so there is no schema or epoch change. The newest model reply ends the proven span whether or not it sits at the tail: at a turn's first request the newest events are the user's message and its tool results, and the span still ends where the previous turn's reply began.

Refs #4559, #4634

What the retreat leaves behind, and for how long

The retreat keeps the newest reply out of the fold, so that reply stays in the request as raw text. It does not stay there: the next fold covers it, rolling the checkpoint forward, because by then a newer reply ends the proven span. The test "a later fold rolls over the reply the retreat left verbatim" pins that, and it bounds the leftover to one send.

I had planned a watermark here — fold that reply separately when it exceeds 24,000 tokens — and this measurement is why it is not in this PR. Its whole benefit is inside the one send where the leftover is large enough to keep the request over the line, and its cost is a second summarizer call and a second checkpoint write inside a transaction that writes one. If a session is found where that single send matters, it is worth revisiting with the evidence; on the current evidence it is complexity for a case the next fold already resolves.

Also not here: "compact and retry" for an unrecognised rejection (#4623).

Verification

Every local gate clean. Runtime suites: history compaction 21/21, overflow recovery 50/50, mid-turn capacity 73/73, checkpoint and summarizer suites unchanged and green. runtime-host protocol and composition 28/28 (that suite times out under parallel load on my machine and passes on its own; CI runs it serially). The epoch guard confirms no protocol change against the base.

Self-review

  • The first implementation read refs.stepId to find the newest reply. Tests showed that field is only set on function-call events, so a plain text reply left no boundary and the retreat would have silently never fired — worse than halving. The role-based rule replaced it.
  • The second implementation looked only at the ledger tail, which is correct mid-turn but wrong at a turn's first request, where the tail is the new user message. Scanning for the newest reply anywhere fixes that case, and the step-0 recovery test covers it.
  • step-0 overflow recovery gates reasoning on retry and durable reload previously relied on two halving retreats to keep the reasoning tail out of the fold. It now rejects once and the proven boundary leaves that tail verbatim, so the reasoning-gating assertions it exists for are unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: Claude Code — implementation; reviewed and verified by the author.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary above
  • No

https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J

@sylvesterkaczmareksylvesterkaczmarek left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

acceptedInputBoundary() proves only that some prior model request accepted this prefix, not that the current summarizer model/connection did. Session history can span runs on different routes (the compaction path already carries runtimeContextRunHeaders for that distinction), but this scan uses only role === 'model'; after a model/connection switch the single retreat can therefore target a span never accepted by the current summarizer and fail open unnecessarily. Derive the boundary from the latest response on the same effective route and add a mixed-route session test.

@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from bcd8a5c to 378f987CompareSeptember 3, 2026 15:23
@Joob1n

Copy link
Copy Markdown
ContributorAuthor

You are right, and the claim in the code comment was stronger than what the code proved. Fixed on 378f987f1.

acceptedInputBoundary now finds the newest reply produced on the route this fold is dispatched on, resolving each candidate reply's runId through the run headers and matching modelId and llmConnectionId — the same pairing persistedRequestAnchor enforces. The caller passes runHeaders and acceptedRoute; a role-only scan was the bug, because a session's history can span runs on several routes and a span another model accepted says nothing about this summarizer's window.

Two regressions:

  • "a mixed-route session retreats to this route's own newest reply" — four events where the newest reply belongs to model-b/conn-b and an older one to the active model-a/conn-a. The retreat targets the older boundary, not the nearer foreign one; the assertion spells out the wrong answer it would otherwise give.
  • "fails open when only another route has ever been accepted" — one attempt, no retreat, fail open. Nothing proven means no retreat, which is the same rule as before, now correctly scoped.

Still no schema or epoch change: the boundary is read from the ledger and its run headers.

…pted
When the summarizer's own provider rejects a fold as too large, the planner
halved the covered range and tried again. Halving is a guess in both
directions: it can discard verbatim history the summarizer would have taken,
and it can still be too large, paying another round trip to find out.
There is a boundary that needs no guessing. The last accepted request's input
covered everything before the newest model reply began; that span was accepted
by this model on this connection, so it is provably within the provider's
capacity. The fold retreats to it once. A rejection of that span too is the
provider saying this fold cannot be made, and the fold fails open with the
summarizer's own reason rather than a span-selection one.
The boundary is read from the ledger rather than persisted: the newest model
reply is the end of the proven span whether or not it sits at the tail, so a
turn's first request finds the previous turn's reply. A ledger with no model
reply has nothing proven and gets no retreat, because inventing a boundary is
the guess this change removes.
Refs apache#4559, apache#4634
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
@Joob1n
Joob1nforce-pushed the feat/context-compaction-boundary branch from 378f987 to a45346cCompareSeptember 3, 2026 15:32
@likun666661
likun666661 merged commit ea51bc4 into apache:mainSep 3, 2026
1 check passed
@Astro-Han

Copy link
Copy Markdown
Contributor

Nice change, and the proven boundary is a much better idea than halving. One thing after the merge, only because a release is close: the retreat is wired into one of the two production call sites, and the one it misses is the one /compact uses.

The five added lines land at ai-sdk-compaction.ts:1072 (mid-turn and pre-turn). The standalone site at ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open right away.

That path did retreat before. Its coverage gate is just coveredCount > 0, so every halving step passed it and the loop walked down until a span was accepted. The test you rewrote, retreats the safe prefix by half for each input-too-large rejection, was itself phase: 'standalone'.

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end as failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. And the standalone first attempt covers the whole prior session (reserveTailEvents: 0), which is the span most likely to be rejected, so it is the ordinary long-session case.

The fix is the two lines already at :1072, and both values are in hand there (input.runtimeContextRunHeaders is used ten lines below):

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

Worth one test driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly. That is the gap that let it through: the new planner tests use phase: 'standalone' with a hand-supplied acceptedRoute, which neither call site produces, so they would stay green if both call sites were deleted.

Two things I checked and they are fine. The overflow-reactive-recovery.test.ts edit is clean: only the two call counts moved, every assertion carrying the obligation is unchanged, and it still exercises one real retreat. And mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. But that gate is unchanged and halving usually undershot it too, so it is a pre-existing limit, not something you broke. A follow-up, not a fix under time pressure.

Static read of a45346ca against b9748a77, no tests run, so the standalone claim is a trace rather than an observation. One test call against compactHistory settles it in a minute.

AI-assisted review: drafted with Maka. I verified the call sites, the gates and the entry points myself.

简体中文

改得挺好,用被证明过的边界替代折半是更对的思路。合并之后才提一句,只因为发版临近:退避接到了两个生产调用点里的一个,而漏掉的那个正是 /compact 走的。

加的五行落在 ai-sdk-compaction.ts:1072(mid-turn 和 pre-turn)。ai-sdk-compaction.ts:365 的 standalone 调用点 runHeadersacceptedRoute 都没传,于是 acceptedInputBoundary 第一行就返回 undefined,第一次 input_too_large 直接 fail open。

这条路改动前是有退避的。它的覆盖闸只是 coveredCount > 0,折半到哪一步都能过,循环会一路走到某个跨度被接受。你改写掉的那条 retreats the safe prefix by half for each input-too-large rejection 本身就是 phase: 'standalone'

四个入口会走到:CLI /compact、Desktop sessions:compactagent-graph-supervisor-wake.ts:111 的 sub-agent 自动压缩,以及 ai-sdk-backend.ts:3426 的 pre-turn 兜底。前三个以失败收场并写一条 context_compaction_failed_open;第四个会把超窗历史发出去,turn 死在 context_overflow。而 standalone 的第一次尝试覆盖整个既往会话(reserveTailEvents: 0),正是最容易被拒的那个跨度,所以这是长会话的常规情况。

修法就是 :1072 已有的那两行,两个值在这个点都是现成的(input.runtimeContextRunHeaders 在下面十行就在用)。

建议补一条驱动 AiSdkCompaction.compactHistory 而不是直接调 planHistoryCompaction 的测试。这正是它溜过去的缺口:新加的 planner 测试是 phase: 'standalone' 配手工塞的 acceptedRoute,两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。

两件我核过、没问题的事。overflow-reactive-recovery.test.ts 的改动是干净的:只动了两个调用计数,承载义务的断言一条没变,而且仍然跑了一次真实退避。另外 mid-turn 其实也到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上;但这个闸和改动前一样,折半通常也过不去,所以是既有限制,不是你弄坏的。跟进即可,不必在赶时间的时候动。

@Joob1n

Copy link
Copy Markdown
ContributorAuthor

Confirmed, and it is a regression this PR introduced rather than a gap it left: the standalone site retreated before, because its coverage gate admitted every halving step. Fixed in #4671, with the two values that were already in hand there.

Your point about the test is the one that matters most: the planner tests hand acceptedRoute in directly, so they would have stayed green with both call sites deleted. #4671's test drives compactHistory and asserts both attempts, and it fails without the wiring.

I also confirmed the mid-turn observation. priorRunHeaders excludes the current turn, so the proven index lands at or below headAnchorIndex while the gate wants it above, and mid-turn cannot reach a retreat today. As you say, halving usually undershot the same gate, so it is a pre-existing limit rather than something this PR changed; it needs its own change to the gate and is not in #4671.

Thank you for tracing the four entry points and for saying which parts you had checked and which were a static read.

@Astro-Han

Copy link
Copy Markdown
Contributor

Severities for the comment above, which I should have included with it.

P1 — the standalone retreat is unwired. Normal supported operation: any /compact, supervisor-wake sub-agent compaction, or pre-turn fallback whose summarizer request the provider rejects as too large, which is the ordinary long-session case since the first attempt covers the whole prior session. ai-sdk-compaction.ts:365 passes neither runHeaders nor acceptedRoute, so acceptedInputBoundary returns undefined and the first rejection fails open, where halving previously walked down until a span was accepted. The first three entries report failed with a context_compaction_failed_open note; ai-sdk-backend.ts:3426 sends the oversized history and the turn dies with context_overflow. No flag or fallback. The fix is the two lines already at :1072.

P2 — the new planner tests prove no production obligation. They pair phase: 'standalone' with a hand-supplied acceptedRoute, a combination neither call site produces, so they would stay green with both call sites deleted. One case driving AiSdkCompaction.compactHistory closes it.

P2 — CHANGELOG.md:51 describes behavior the shipped code does not have on /compact, supervisor-wake compaction and the pre-turn fallback. Land the wiring rather than reword the line.

P3 — mid-turn cannot reach a retreat either, since priorRunHeaders excludes the current turn so the proven index lands at or below headAnchorIndex while the gate wants above it. Pre-existing: the gate is unchanged and halving usually undershot it too. A follow-up, not a fix under time pressure.

No finding on the overflow-reactive-recovery.test.ts edit, on route collision, on reply-role coverage, on idempotence, or on the interaction with #4653 and #4669.

The P1 is the one worth a decision before the cut: land the two lines, or revert this for the release.

简体中文

上面那条评论的分级,应该跟着一起给的。

P1 —— standalone 的退避没接线。 正常支持路径:任何 /compact、supervisor-wake 的 sub-agent 压缩、或 pre-turn 兜底,只要 summarizer 请求被 provider 判为过大就会撞上,而第一次尝试覆盖整个既往会话,所以这就是长会话的常规情况。ai-sdk-compaction.ts:365 既没传 runHeaders 也没传 acceptedRouteacceptedInputBoundary 返回 undefined,第一次被拒就 fail open,而折半原本会一路走到某个跨度被接受。前三个入口报失败并写一条 context_compaction_failed_openai-sdk-backend.ts:3426 会把超窗历史发出去,turn 死在 context_overflow。没有开关也没有兜底。修法是 :1072 已有的那两行。

P2 —— 新加的 planner 测试没有证明任何生产义务。 它们把 phase: 'standalone' 和手工塞的 acceptedRoute 配在一起,而两个调用点都不会产生这个组合,所以把两个调用点都删掉它们照样绿。补一条驱动 AiSdkCompaction.compactHistory 的用例即可。

P2 —— CHANGELOG.md:51 描述的行为在 /compact、supervisor-wake 压缩和 pre-turn 兜底上并不存在。 应该补接线,而不是改措辞。

P3 —— mid-turn 同样到不了退避,因为 priorRunHeaders 排除了当前 turn,proven 下标必然落在 headAnchorIndex 或之前,而闸要求在它之上。既有问题:闸没变,折半通常也过不去。跟进即可,不必在赶时间的时候动。

无发现overflow-reactive-recovery.test.ts 的改动、route 碰撞、reply 角色覆盖、幂等性,以及与 #4653#4669 的交互。

发版前需要拍板的只有那条 P1:补上两行,或者这次先 revert。

@Astro-HanAstro-Han left a comment

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.

Anchoring the graded points to the lines they belong to, since a follow-up PR is the likely shape here.

orderedEvents,
headAnchor: { runtimeEventId: state.headAnchor.id, turnId },
runHeaders: state.priorRunHeaders,
acceptedRoute: {

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.

P1 These two lines are the whole wiring, and they only reach the mid-turn and pre-turn call site. The sibling call at :365 (phase: 'standalone') passes neither, so acceptedInputBoundary returns undefined on its first line and the first input_too_large fails open there, where halving previously walked down until a span was accepted (that path's coverage gate is just coveredCount > 0, so every halving step passed it).

Four entries reach it: CLI /compact, Desktop sessions:compact, supervisor-wake sub-agent compaction (agent-graph-supervisor-wake.ts:111), and the pre-turn fallback at ai-sdk-backend.ts:3426. The first three end failed with a context_compaction_failed_open note; the fourth sends the oversized history and the turn dies with context_overflow. No flag or fallback. And the standalone first attempt covers the entire prior session (reserveTailEvents: 0), the span most likely to be rejected, so it is the ordinary long-session case.

Both values are in hand at :365, where input.runtimeContextRunHeaders is already used ten lines below:

...(input.runtimeContextRunHeaders ? {runHeaders: input.runtimeContextRunHeaders} : {}),acceptedRoute: {modelId: this.input.modelId,
...(this.targetConnectionId!==undefined ? {connectionId: this.targetConnectionId} : {}),},

P3, separately: state.priorRunHeaders excludes the current turn by construction (prior-run-context.ts:66-71 filters run.turnId !== currentTurnId), so no current-turn reply can be on route and the proven index always lands at or below headAnchorIndex, while the mid_turn gate wants strictly above it. So mid-turn cannot reach a retreat either. That gate is unchanged from before and halving usually undershot it too, so this is a pre-existing limit rather than something this PR broke. A follow-up, not a fix under time pressure. If you do pursue it, the current run is the route by construction, so a synthetic header for input.origin.runId or a currentRunId field treated as on-route would make the comment at history-compaction.ts:239-246 true.

input.runHeaders ?? [],
input.acceptedRoute,
);
if (proven === undefined || proven >= boundary.coveredCount) {

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.

Context for the P1 above. This early exit is correct and it does report the summarizer's own reason, which was the point. It is just reached unconditionally on the standalone path, because acceptedInputBoundary returns undefined whenever acceptedRoute is absent and :365 never passes one.

let attempts = 0;
const retreated = await planHistoryCompaction(
planInput({
phase: 'standalone',

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.

P2 These cases pair phase: 'standalone' with a hand-supplied acceptedRoute, and no production caller produces that combination: the standalone site passes neither field, and the site that passes them is mid-turn or pre-turn. So they prove the boundary arithmetic but not one production obligation, and they would stay green if both call sites were deleted. That is what let the wiring gap through.

One case driving AiSdkCompaction.compactHistory rather than planHistoryCompaction directly, asserting a second summarizer call after one input_too_large, closes it. The overflow-reactive-recovery.test.ts assertion is the only production-wired one today, and it covers the one phase where the wiring happens to work.

Comment threadCHANGELOG.md
owner, immutable request snapshots remain enforced at AgentRun acceptance and backend dispatch,
and SessionEvent-to-RuntimeEvent conversion remains a pure mapper.
- Retired the Task Ledger domain: SessionTodo is now the sole authority for in-session work items, and the operational-state schema drops the `workflow_task_ledger_events` table on first open. **Unfinished Tasks are not migrated and are permanently deleted.** This affects workspaces last opened by `v0.1.0` through `v0.1.11`, `cli-v0.1.0-beta.1`, `v0.2.0-incubating-rc1`, or a `v0.2.0-dev` build; those releases wrote Tasks to a table that no shipped build ever bridged into SessionTodo. Before opening such a workspace with this build, finish or export the Tasks you still need, or copy the workspace's `runtime.sqlite` aside — the migration removes the only live copy, so afterwards recovery requires a backup made in advance.
- A compaction rejected as too large for the summarizer's own window now retreats to the span the last accepted request's input covered, instead of halving the covered range. That span is the newest reply this route produced, found through the run headers, so it was accepted by this model on this connection and is provably within capacity; halving can overshoot (discarding verbatim history for nothing) or undershoot (paying another round trip), and a span another route accepted proves nothing at all. One retreat, then the fold fails open and the provider decides.

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.

P2 This is true for step-0 recovery and not for /compact, supervisor-wake compaction, or the pre-turn fallback, where there are zero retreats. Land the wiring rather than rewording the line, since rewording would document the gap.

Astro-Han pushed a commit that referenced this pull request Sep 3, 2026
…eat (#4671)
#4667 wired the proven-boundary retreat into the mid-turn and pre-turn call
site and missed the standalone one, which is the site manual compaction uses.
Without `runHeaders` and `acceptedRoute`, `acceptedInputBoundary` returns
nothing on its first line, so the first `input_too_large` fails open. That path
retreated before: its coverage gate admitted every halving step, so the loop
walked down until a span was accepted.
Four entries reach it: CLI `/compact`, Desktop `sessions:compact`, sub-agent
compaction from supervisor wake, and the pre-turn fallback. Its first attempt
covers the whole prior session with no reserved tail, which is the span most
likely to be rejected, so the regression landed on the ordinary long session
rather than an edge. The first three entries reported failure with a
`context_compaction_failed_open` note; the fourth sent the oversized history
and the turn died with `context_overflow`.
The fix passes the same two values the other call site already passes, so there
is one rule and two call sites rather than two rules. The test drives
`compactHistory` rather than the planner: the planner tests hand the route in
directly, so they would have stayed green with both call sites deleted, which
is exactly how this got through.
Mid-turn still cannot reach a retreat, because `priorRunHeaders` excludes the
current turn so the proven index lands at or below `headAnchorIndex` while the
gate wants it above. That is a pre-existing limit rather than something #4667
changed, and it needs its own change to the gate, so it is not in this PR.
No protocol or schema change.
Refs #4559, #4667
Generated-by: Claude Code
Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@Joob1n@Astro-Han@sylvesterkaczmarek@likun666661