Skip to content

fix(agent-runtime): keep context recoverable after a failed compaction - #249

Merged
vastsa merged 10 commits into
vastsa:mainfrom
muzimu217:fix/compaction-fallback-restore
Sep 12, 2026
Merged

vastsa merged 10 commits into
vastsa:mainfrom
muzimu217:fix/compaction-fallback-restore

Conversation

@muzimu217

Copy link
Copy Markdown
Contributor

Fixes #224

Problem

After an automatic context compaction fails and the session writes a retained_tail fallback checkpoint (CONTEXT_COMPACTION_FAILED / retainedTail: []), switching provider/model or restarting restores a runtime whose model context contains nothing before the compaction boundary — no history and no usable summary — even though the full transcript (500+ messages in the report) is intact in SQLite and the session JSONL. The new model reports it cannot read prior task content, and the condition never self-heals.

Root cause

  1. createFallbackCheckpoint's tail comes from codexShapedPreparation, which retains no user messages on a completed_turn — correct for a real summary, fatal for a fallback whose "summary" is only the recovery notice. Empty tail + notice ⇒ the rebuilt context has nothing before the boundary.
  2. pi 0.85 prepareCompaction copies the previous checkpoint's summary as the next run's previousSummary. With the notice as previousSummary, the next compaction runs UPDATE_SUMMARIZATION_PROMPT against text that was never a real summary — the failure is cemented.
  3. fallbackPreparation carries terminal.summary (possibly the notice) into the rebuild path, same pollution.

Fix

Three minimal changes in packages/agent-runtime/src/runtime.ts:

  • createFallbackCheckpoint: when the shaped tail is empty, retain the newest user messages from the summarized range under the same budget (reusing selectRetainedUserMessages) — the failure path now always restores a bounded, non-empty context instead of an empty one.
  • prepareCompactionInput: strip previousSummary when it contains COMPACTION_FALLBACK_MARKER, so the next summarization regenerates a real summary from the transcript (self-healing retry) instead of updating the recovery notice.
  • fallbackPreparation: apply the same strip on the rebuild path.

Against the issue's expectations: no checkpoint that restores an empty context is persisted anymore; the next compaction automatically rebuilds a real summary; a runtime rebuild restores from the canonical transcript plus a non-empty tail. A dedicated user-facing recovery affordance (the issue's last expectation) is left out of this PR — the existing contextCompaction.recovered toast already covers the fallback notification.

Testing

  • packages/agent-runtime: vitest 356/356 pass.
  • apps/desktop/test/context-compaction.test.mjs: +2 source-assertion tests (non-empty tail on fallback; notice never used as previousSummary), 12/12 pass.
  • tsc --noEmit clean.

A retained-tail fallback checkpoint persisted an empty retainedTail on
completed turns (the Codex-shaped tail only retains messages on an active
turn), so after a runtime rebuild — model switch, restart — the session
restored with nothing before the boundary: the new model saw no history
and no usable summary, while the transcript kept all 500+ messages (vastsa#224).

Two further paths cemented the failure instead of recovering it: pi's
prepareCompaction copies the previous checkpoint's summary as the next
run's previousSummary, and the fallback rebuild carried the terminal
checkpoint's summary forward — both fed the recovery notice text into the
next summarization as if it were a real summary, so the model kept
"updating" a summary that never existed.

- createFallbackCheckpoint: when the shaped tail is empty, retain the
  newest user messages from the summarized range under the same budget,
  so the failure path still restores a bounded, non-empty context.
- prepareCompactionInput / fallbackPreparation: strip a previousSummary
  that contains the fallback notice, so the next compaction regenerates
  a real summary from the transcript instead of updating the notice.

Fixes vastsa#224
The drop-before/after hairline shipped with a raw `border-radius: 1px`,
which trips the style-token gate (`check-style-tokens.mjs`) that guards
lint on every PR. The established pattern for a 2px hairline is
`--radius-full` (see `.subagent-topology-connector`).

Pre-existing on main (introduced with the project drag-reorder feature);
fixing it here so this PR's CI can be green against a red main.
`c944d808` shipped `.empty-hero .project-underline:focus-visible` with a
raw `border-radius: 4px`, which trips the style-token gate — main's CI is
red on it. 4px maps to `--radius-3xs`.
…ject-move

The project drag-reorder refactor replaced the HTML5 drag handlers the
source-assertion tests were anchored on (`handleProjectDragOver`,
`handleProjectDrop`, `source.meta.archived` inline buckets), so both drag
tests fail on current main. Point the assertions at the code that exists
now: the drop-target handlers' payload-authoritative guard, the
long-press arm threshold, and the bucket guard in
sidebar-project-reorder.ts.

@vastsa vastsa left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Found one merge-blocking context regression in the self-healing path.

prepareCompactionInput clears the entire previousSummary whenever the latest checkpoint contains COMPACTION_FALLBACK_MARKER (runtime.ts:4859-4869). A fallback checkpoint is built by prefixing that marker to the carried-forward summary (runtime.ts:5185-5198), so this also removes any valid summary that existed before the failed compaction.

This is not rebuilt from the canonical transcript: pi's prepareCompaction starts after the latest compaction and only exposes its retained-tail virtual entries plus later messages. I reproduced this on the PR head with a fallback containing REAL SUMMARY, retained tail recent, and a later prompt; the next preparation returned previousSummary: undefined and summary input [recent, new prompt], so REAL SUMMARY was absent. After the next compaction, older task context is therefore permanently missing from model context.

Please preserve/extract the carried-forward real summary while removing only the recovery notice (and add a behavioral regression test for a fallback that already contains a valid summary). The same issue should be handled in the terminal-checkpoint branch at runtime.ts:5361-5366.

…s stripped

pi reads `previousSummary` straight from the previous compaction entry. A
retained-tail fallback stores its carried-forward summary ahead of the
recovery notice, so dropping the whole `previousSummary` on seeing the marker
also discarded the valid summary the failed compaction was carrying forward.
After the next compaction that older context was gone for good.

Strip only the notice — and the "no previous summary" placeholder — leaving
any real summary ahead of the marker intact. Applies to both
`prepareCompactionInput` and the terminal-checkpoint branch of
`fallbackPreparation`. Adds behavioral regressions for a fallback that
already carries a valid summary, and keeps the source-wiring assertion in the
desktop compaction test in sync.
…ck-restore

# Conflicts:
#	apps/desktop/src/styles/sidebar-threads.css
#	apps/desktop/test/session-project-move.test.mjs
@muzimu217

Copy link
Copy Markdown
Contributor Author

Fixed in f9003ff5 — thanks for the precise repro, the diagnosis was exactly right.

A fallback checkpoint is carriedForwardSummary + notice, so clearing the whole previousSummary whenever the marker was present also dropped the valid summary the failed compaction was carrying forward. Since prepareCompaction cannot rebuild it from the transcript, the older context was gone after the next compaction. Both call sites now share one extraction helper, stripCompactionFallbackNotice():

  • prepareCompactionInput: replaces previousSummary with the text before the marker instead of undefined.
  • terminal-checkpoint branch of fallbackPreparation: stripCompactionFallbackNotice(terminal.summary) ?? sourceInput.value.previousSummary, so a chained fallback keeps the carried summary rather than falling back to the placeholder.

The helper returns undefined only when nothing real precedes the notice — an empty prefix, or the "No previous context checkpoint is available." placeholder — so a fallback that carried no real summary still regenerates from the transcript.

Behavioral regressions added in packages/agent-runtime/src/runtime.test.ts:

  • keeps the summary a fallback checkpoint carries forward — seeds a fallback whose summary is REAL SUMMARY + marker + notice, runs the next prepareCompactionInput, and asserts previousSummary is REAL SUMMARY.
  • keeps the carried summary when a fallback checkpoint is the terminal entry — the same shape on the rebuild path, asserting the appended checkpoint still contains the carried summary.

Both fail against the previous logic (the terminal case degenerated to the placeholder) and pass now.

Verification: agent-runtime 361/361 with tsc --noEmit clean; desktop node --test 1424/1424; check-style-tokens OK. Branch merged current main (ee9c4e59).

@muzimu217

Copy link
Copy Markdown
Contributor Author

Branch refreshed onto current main (ae149e7 — the two incoming commits are README-only). Both CI gates are green on the new head.

E2E disposition (fork PR): the E2E-164 provider/UI journey is still Draft — no runnable E2E exists for this runtime path yet. The relevant local coverage is the two behavioral regressions added in f9003ff5 (a fallback carrying a real summary, and the terminal-checkpoint variant), the agent-runtime suite (361/361), and the desktop context-compaction contract test (12/12) — all green locally on the refreshed head, matching the CI run above.

@vastsa could you re-review the post-fix head? Both call sites you flagged (prepareCompactionInput and the terminal-checkpoint branch) now strip only the notice via stripCompactionFallbackNotice() and keep the summary the failed compaction was carrying forward, with the requested behavioral regression test.

@vastsa
vastsa merged commit e014ae1 into vastsa:main Sep 12, 2026
3 checks passed
@muzimu217

Copy link
Copy Markdown
Contributor Author

All three merged — thank you, @vastsa! 🎉

Thanks especially for the sharp review earlier on this one: the carried-summary catch made the fix strictly better, and your runtime-era context around #169/#134 kept the scope honest. #202 and #235 also landed clean with the new architecture/format gates. We'll keep following main as it moves and stay responsive on the plugin side (session-import 0.4.8 is up next for review whenever you get to it).

This branch was previously deployed

1 inactive deployment
Preview — d056478e Deployed Sep 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] 上下文压缩失败后切换模型无法恢复会话历史 / Model switch loses context after failed compaction

2 participants