Skip to content

fix(compaction): previous summary is duplicated when a later split degrades#109828

Merged
steipete merged 2 commits into
openclaw:mainfrom
Yigtwxx:fix/compaction-duplicate-summary
Jul 17, 2026
Merged

fix(compaction): previous summary is duplicated when a later split degrades#109828
steipete merged 2 commits into
openclaw:mainfrom
Yigtwxx:fix/compaction-duplicate-summary

Conversation

@Yigtwxx

@Yigtwxx Yigtwxx commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

What Problem This Solves

When staged compaction summarizes a long session and any split degrades, the previous summary is added
to the new summary twice. It then compounds: every later compaction cycle that has a degraded split
re-adds it again, so the summary grows a copy of itself each round and burns tokens on every subsequent
turn.

Since #86900 landed the staged circuit breaker to stop token burn when the summarizer is unavailable,
this is the same failure mode arriving through the merge path.

Why This Change Was Made

summarizeInStages (src/agents/compaction.ts:492) stamped the merged result as a fallback if any
split degraded:

usedGenericFallback ||= result.kind === "generic-fallback";   // coarse OR over every chunk
...
return usedGenericFallback && mergedResult.kind === "summary"
  ? { kind: "generic-fallback", text: mergedResult.text }
  : mergedResult;

The only consumer of that kind is summarizeViaLLM (src/agents/agent-hooks/compaction-safeguard.ts:413-420),
which reads it to decide whether to restore the previous summary:

if (result.kind === "summary") {
  return result.text;
}
// A generic fallback means redistillation never happened. Preserve the
// known summary verbatim so a temporary model outage cannot erase it.
const previousSummary = params.previousSummary?.trim();
return previousSummary ? `${previousSummary}\n\n${result.text}` : result.text;

That comment states an invariant the code does not hold. The same function prepends the previous
summary as the first message (:396-399prependPreviousSummaryForRedistill, :107), so it always
lands in chunk 0. If chunk 0 summarizes fine and a later chunk degrades, redistillation demonstrably
did happen — the merge already carries the summary — but the coarse flag stamps the result as a fallback
anyway, and the previous summary gets prepended on top of a merge that already contains it.

oldest split previous summary correct before
degraded lost restore it restore ✔
survived, later split degraded already in the merge do not restore restore again ✘

Reachable on the normal path: MAX_CONSECUTIVE_GENERIC_FALLBACKS (:48) only opens the circuit on
consecutive degradations, so a single failed split completes the merge, and compaction-safeguard.ts:1375
passes previousSummary on every repeat compaction.

The fix reports what the consumer actually asks — did the oldest split degrade — instead of "did anything
degrade". The coarse flag is removed rather than kept beside the precise one, so there is one source of
truth and the two cannot drift apart. With this, the existing comment becomes true.

Nothing outside these two modules reads the kind, so no other surface is affected. compaction-safeguard.ts
needs no change: its logic was already correct given a correct kind.

Evidence

End-to-end, composing the real summarizeInStages with the verbatim decision from
summarizeViaLLM:413-420, with chunk 0 succeeding and a later chunk degrading:

Before (origin/main):

summarizeInStages kind : generic-fallback
final summary          : "## Goal\nShip the parser rewrite.\n\nmerged: ## Goal\nShip the parser rewrite. + newest"
previous summary occurs: 2 time(s)

After (this branch):

summarizeInStages kind : summary
final summary          : "merged: ## Goal\nShip the parser rewrite. + newest"
previous summary occurs: 1 time(s)

Tests

The added case (oldest summary → degraded → newest summary → merge) fails on origin/main with
expected { kind: 'generic-fallback' } to deeply equal { kind: 'summary' }, and passes here.

The existing remains degraded case is unmodified and still green — its oldest split is the one that
rejects, so the previous summary really is lost there and the fallback stamp is still correct. That is the
behavior split this change is built around.

compaction.circuit-breaker + compaction-safeguard + compaction-instructions   261 passed | 4 skipped, 0 failed
all src/agents/compaction*.test.ts                                            103 passed | 14 skipped, 0 failed
max-lines ratchet                                                             OK
dead-export ratchet (knip)                                                    0 entries
oxlint / oxfmt --check                                                        clean
tsgo -p tsconfig.core.json                                                    no errors in touched files

compaction-planning-worker.test.ts fails identically on unmodified origin/main in this environment
(Cannot find module compaction-planning.js — the worker wants built output), so it is unrelated to this
change.

Merge Risk

Low. One flag in one function, +14/-4 in source, no signature or type change, no config surface.

The only behavior that moves is the case this fixes: a merged summary whose oldest split survived is now
reported as summary instead of generic-fallback. The degraded-oldest-split path — the one the existing
test pins — is untouched, so the outage protection that #86900 added still holds exactly where it matters.

AI-assisted.

@openclaw-barnacle openclaw-barnacle Bot added agents Agent runtime and tooling size: XS labels Jul 17, 2026
@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR. P2 Normal backlog priority with limited blast radius. merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. labels Jul 17, 2026
@clawsweeper

clawsweeper Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge. Reviewed July 17, 2026, 12:46 PM ET / 16:46 UTC.

Summary
The branch marks a staged compaction result as a generic fallback only when the oldest split degraded, with regression coverage preventing prior-summary duplication when only a later split degrades.

PR surface: Source +4, Tests +22. Total +26 across 3 files.

Reproducibility: yes. The supplied composed current-main run reproduces two copies of the prior summary when the oldest split succeeds and a later split degrades, and the added regression case models the same reachable sequence.

Review metrics: none identified.

Merge readiness
Overall: 🦞 diamond lobster
Proof: 🦞 diamond lobster
Patch quality: 🦞 diamond lobster
Result: ready for maintainer review.

Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch.

Risk before merge

  • [P1] The result kind controls whether prior session context is restored; an incorrect oldest-split invariant could duplicate summary state or omit needed restoration, so both boundary cases must remain green on the exact merge head.

Maintainer options:

  1. Land with boundary checks (recommended)
    Merge after exact-head CI confirms the oldest-degraded restoration case and later-degraded deduplication case together.

Next step before merge

  • No automated repair remains; normal maintainer review and exact-head checks can gate the existing focused patch.

Security
Cleared: The patch is limited to internal compaction classification and tests, with no dependency, workflow, permission, secret, network, artifact-download, or supply-chain changes.

Review details

Best possible solution:

Land the focused classification change after exact-head checks confirm both invariants: restore the prior summary when the oldest split degrades, and do not restore it again when the oldest split survives but a later split degrades.

Do we have a high-confidence way to reproduce the issue?

Yes. The supplied composed current-main run reproduces two copies of the prior summary when the oldest split succeeds and a later split degrades, and the added regression case models the same reachable sequence.

Is this the best way to solve the issue?

Yes. Narrowing the result kind to whether the oldest split degraded answers the safeguard consumer's actual restoration question, removes the competing coarse flag, and preserves the existing oldest-split outage behavior.

AGENTS.md: found and applied where relevant.

Codex review notes: model internal, reasoning high; reviewed against 5366b6de8171.

Label changes

Label changes:

  • add rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • remove rating: 🐚 platinum hermit: Current PR rating is rating: 🦞 diamond lobster, so this older rating label is no longer current.

Label justifications:

  • P2: The PR fixes recurring session-summary duplication and token growth with a bounded compaction-only blast radius.
  • merge-risk: 🚨 session-state: The changed fallback classification directly determines whether prior session-summary state is restored, duplicated, or potentially omitted.
  • rating: 🦞 diamond lobster: Overall readiness is 🦞 diamond lobster; proof is 🦞 diamond lobster and patch quality is 🦞 diamond lobster.
  • status: 👀 ready for maintainer look: ClawSweeper has no concrete contributor-facing blocker left for this PR. Sufficient (live_output): The PR includes copied before/after live output from the composed real compaction path showing the prior-summary count fall from two to one, plus focused regression results; no additional contributor proof is needed.
  • proof: sufficient: Contributor real behavior proof is sufficient. The PR includes copied before/after live output from the composed real compaction path showing the prior-summary count fall from two to one, plus focused regression results; no additional contributor proof is needed.
Evidence reviewed

PR surface:

Source +4, Tests +22. Total +26 across 3 files.

View PR surface stats
Area Files Added Removed Net
Source 1 8 4 +4
Tests 2 22 0 +22
Docs 0 0 0 0
Config 0 0 0 0
Generated 0 0 0 0
Other 0 0 0 0
Total 3 30 4 +26

What I checked:

  • Current-main failure path: Current main tracks whether any staged split used a generic fallback and propagates that state through the merged result, causing the safeguard consumer to restore the previous summary even when the oldest split already carried it into a successful merge. (src/agents/compaction.ts:413, 5366b6de8171)
  • Consumer invariant: The safeguard prepends the previous summary to the oldest input context, then restores it when summarizeInStages reports generic-fallback; survival of the oldest split is therefore the relevant restoration boundary. (src/agents/agent-hooks/compaction-safeguard.ts:396, 5366b6de8171)
  • Focused implementation: The PR replaces the coarse any-split flag with oldestChunkDegraded in both return paths without changing signatures, config, storage, or public APIs. (src/agents/compaction.ts:413, 8ad8cfdd1bb3)
  • Two-sided regression coverage: The head adds a later-split degradation case that must return summary and a safeguard assertion that prior-summary text is absent from the committed result, while retaining oldest-split degradation restoration coverage. (src/agents/compaction.circuit-breaker.test.ts:83, 8ad8cfdd1bb3)
  • Related implementation provenance: Merged pull request fix(compaction): add circuit breaker to stop token burn when summarizer unavailable #86900 introduced the typed generic-fallback circuit breaker and prior-summary restoration behavior that this focused follow-up corrects. (src/agents/compaction.ts:413, 473dfc2e51b8)
  • Real behavior proof: The PR body shows the composed current-main path producing two occurrences of the prior summary and the branch producing one, followed by focused compaction test results; the repository marks this proof sufficient. (8ad8cfdd1bb3)

Likely related people:

  • steipete: The PR history shows steipete assigned the PR, refreshed its head, and authored the safeguard-level assertion that pins the intended session result. (role: recent area contributor and current-head refiner; confidence: high; commits: 8ad8cfdd1bb3; files: src/agents/agent-hooks/compaction-safeguard.test.ts)
  • tanshanshan: The merged circuit-breaker work introduced the typed degraded-result and prior-summary preservation behavior refined by this PR. (role: introduced adjacent fallback behavior; confidence: high; commits: 473dfc2e51b8; files: src/agents/compaction.ts, src/agents/agent-hooks/compaction-safeguard.ts, src/agents/compaction.circuit-breaker.test.ts)
What the crustacean ranks mean
  • 🦀 challenger crab: rare, exceptional readiness with strong proof, clean implementation, and convincing validation.
  • 🦞 diamond lobster: very strong readiness with only minor maintainer review expected.
  • 🐚 platinum hermit: good normal PR, likely mergeable with ordinary maintainer review.
  • 🦐 gold shrimp: useful signal, but proof or patch confidence is still limited.
  • 🦪 silver shellfish: thin signal; proof, validation, or implementation needs work.
  • 🧂 unranked krab: not merge-ready because proof is missing/unusable or there are serious correctness or safety concerns.
  • 🌊 off-meta tidepool: rating does not apply to this item.

Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics.

How this review workflow works
  • ClawSweeper keeps one durable marker-backed review comment per issue or PR.
  • Re-runs edit this comment so the latest verdict, findings, and automation markers stay together instead of adding duplicate bot comments.
  • A fresh review can be triggered by eligible @clawsweeper re-review comments, exact-item GitHub events, scheduled/background review runs, or manual workflow dispatch.
  • PR/issue authors and users with repository write access can comment @clawsweeper re-review or @clawsweeper re-run on an open PR or issue to request a fresh review only.
  • Maintainers can also comment @clawsweeper review to request a fresh review only.
  • Fresh-review commands do not start repair, autofix, rebase, CI repair, or automerge.
  • Maintainer-only repair and merge flows require explicit commands such as @clawsweeper autofix, @clawsweeper automerge, @clawsweeper fix ci, or @clawsweeper address review.
  • Maintainers can comment @clawsweeper explain to ask for more context, or @clawsweeper stop to stop active automation.
Review history (4 earlier review cycles)
  • reviewed 2026-07-17T08:57:45.447Z sha d332714 :: needs maintainer review before merge. :: none
  • reviewed 2026-07-17T09:12:05.824Z sha c995b8b :: needs maintainer review before merge. :: none
  • reviewed 2026-07-17T09:24:52.435Z sha 66185cd :: needs maintainer review before merge. :: none
  • reviewed 2026-07-17T09:35:51.982Z sha b761716 :: needs maintainer review before merge. :: none

@steipete steipete self-assigned this Jul 17, 2026
@steipete
steipete force-pushed the fix/compaction-duplicate-summary branch from b761716 to 3c3cec0 Compare July 17, 2026 16:40
Yigtwxx and others added 2 commits July 17, 2026 17:41
…plit degrades

summarizeInStages stamped a merged result as generic-fallback whenever ANY
split degraded. compaction-safeguard reads that kind to decide whether to
restore the previous summary, on the documented assumption that "a generic
fallback means redistillation never happened".

That assumption only holds for chunk 0. summarizeViaLLM prepends the previous
summary as the first message, so it always lands in the oldest chunk. When
that chunk summarizes fine but a later one degrades, the merge already carries
the redistilled summary — and the safeguard prepends it a second time. Every
subsequent compaction that has any degraded split re-adds it again, so a PR
that exists to stop token burn compounds it instead.

The kind now reports whether the oldest split degraded, which is exactly the
question the only consumer asks. The coarse "any chunk" flag is gone rather
than kept alongside the precise one, so there is a single source of truth.
Behavior for a degraded oldest split is unchanged: the previous summary is
genuinely lost there and restoring it is still correct.
@steipete
steipete force-pushed the fix/compaction-duplicate-summary branch from 3c3cec0 to 8ad8cfd Compare July 17, 2026 16:41
@steipete

Copy link
Copy Markdown
Contributor

Maintainer review complete. This is the best fix for the compaction boundary: only loss of the oldest split requires prior-summary restoration; later fallback placeholders remain represented in the successful merge.

Proof:

Ready for maintainer prepare/merge.

@clawsweeper clawsweeper Bot added rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. and removed rating: 🐚 platinum hermit Good normal PR readiness with ordinary maintainer review expected. labels Jul 17, 2026
@steipete
steipete merged commit 12e7f91 into openclaw:main Jul 17, 2026
124 of 126 checks passed
@steipete

Copy link
Copy Markdown
Contributor

Merged via squash.

@Yigtwxx
Yigtwxx deleted the fix/compaction-duplicate-summary branch July 17, 2026 18:24
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request Jul 18, 2026
…grades (openclaw#109828)

* fix(compaction): stop duplicating the previous summary when a later split degrades

summarizeInStages stamped a merged result as generic-fallback whenever ANY
split degraded. compaction-safeguard reads that kind to decide whether to
restore the previous summary, on the documented assumption that "a generic
fallback means redistillation never happened".

That assumption only holds for chunk 0. summarizeViaLLM prepends the previous
summary as the first message, so it always lands in the oldest chunk. When
that chunk summarizes fine but a later one degrades, the merge already carries
the redistilled summary — and the safeguard prepends it a second time. Every
subsequent compaction that has any degraded split re-adds it again, so a PR
that exists to stop token burn compounds it instead.

The kind now reports whether the oldest split degraded, which is exactly the
question the only consumer asks. The coarse "any chunk" flag is gone rather
than kept alongside the precise one, so there is a single source of truth.
Behavior for a degraded oldest split is unchanged: the previous summary is
genuinely lost there and restoring it is still correct.

* test(compaction): pin previous-summary restoration boundary

---------

Co-authored-by: Peter Steinberger <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling merge-risk: 🚨 session-state 🚨 May lose, corrupt, stale, or mis-associate session, agent, or context state. P2 Normal backlog priority with limited blast radius. proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. size: XS status: 👀 ready for maintainer look ClawSweeper has no concrete contributor-facing blocker left for this PR.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants