Skip to content

fix(compaction): ignore stale persisted totalTokens in preflight gate#93749

Merged
vincentkoc merged 1 commit into
openclaw:mainfrom
yetval:fix/preflight-stale-persisted-totaltokens
Jun 16, 2026
Merged

fix(compaction): ignore stale persisted totalTokens in preflight gate#93749
vincentkoc merged 1 commit into
openclaw:mainfrom
yetval:fix/preflight-stale-persisted-totaltokens

Conversation

@yetval

@yetval yetval commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Summary

runPreflightCompactionIfNeeded decides whether to compact by taking the Math.max of three token estimates: the transcript-usage projection, the fresh persisted total, and a raw persisted total (stalePersistedPromptTokens). The first two respect the session freshness flag, but the third reads entry.totalTokens directly with no totalTokensFresh check:

const stalePersistedPromptTokens = hasPersistedTotalTokens
  ? Math.floor(persistedTotalTokens)
  : undefined;

When a compaction completes without a tokensAfter figure, incrementCompactionCount (src/auto-reply/reply/session-updates.ts:310) sets totalTokensFresh = false but deliberately keeps the old large totalTokens. resolveFreshSessionTotalTokens (src/config/sessions/types.ts:629) returns undefined in that state by design, so freshProjectedTokenCount correctly drops out, but the raw stalePersistedPromptTokens still feeds the Math.max gate. The stale large value then overrides the small fresh transcript estimate and force-fires another preflight compaction on a session that is actually small, producing an unnecessary (lossy, user-visible) summarization and a "compacting" stall on the next turn.

This is the persisted-total leg of the same symptom tracked in #81178. PR #81916 bounds the transcript-usage leg (usageProjectedTokenCount / snapshot tail bytes); it does not touch stalePersistedPromptTokens, so even with #81916 merged the stale persisted total still trips the gate. It is also the opposite direction of the rejected #82919, which wanted resolveFreshSessionTotalTokens to return stale values; this change instead keeps the existing freshness contract and extends it to the one consumer that bypassed it.

Fix

const stalePersistedPromptTokens =
  hasPersistedTotalTokens && entry.totalTokensFresh !== false
    ? Math.floor(persistedTotalTokens)
    : undefined;

The fresh-large path is unaffected: when totalTokensFresh is true or absent, the persisted total still counts, so a genuinely large session still compacts.

Real behavior proof

Behavior addressed: after a compaction that reports no tokensAfter (totalTokensFresh = false, large totalTokens retained), the next preflight check must not force another compaction when the real active transcript is small. A genuinely large fresh session must still compact.

Real environment tested: local OpenClaw source runtime at origin/main head 617c9d4b7f, Linux, Node 22. A standalone node --import tsx script drove the real runPreflightCompactionIfNeeded path against a real temporary JSONL transcript and session store; the compaction dependency was instrumented only to count whether the runtime attempted compaction.

Exact steps or command run after this patch: ran the proof script twice, once with the source fix reverted (git stash) and once with it applied, each invoking runPreflightCompactionIfNeeded with totalTokens=200000, a ~2KB transcript, and a 100000-token context window, for both totalTokensFresh: false (stale) and totalTokensFresh: true (fresh).

Evidence after fix (copied terminal output):

===== BEFORE FIX (origin/main behavior) =====
STALE (post-compaction, tokensAfter omitted): totalTokens=200000 totalTokensFresh=false small-transcript=~2KB -> compactAttempts=1
FRESH (genuinely large session)             : totalTokens=200000 totalTokensFresh=true small-transcript=~2KB -> compactAttempts=1
stale-case compactAttempts = 1 (want 0: small session must NOT re-compact)
RESULT: FAIL

===== AFTER FIX =====
STALE (post-compaction, tokensAfter omitted): totalTokens=200000 totalTokensFresh=false small-transcript=~2KB -> compactAttempts=0
FRESH (genuinely large session)             : totalTokens=200000 totalTokensFresh=true small-transcript=~2KB -> compactAttempts=1
stale-case compactAttempts = 0 (want 0)
fresh-case compactAttempts = 1 (want 1)
RESULT: PASS

Observed result after fix: the stale small session no longer compacts; the fresh large session still compacts.

Regression test: src/auto-reply/reply/agent-runner-memory.preflight-stale-tokens.test.ts. Case A (large but stale total, small transcript) fails before the fix and passes after; Case B (large fresh total) confirms the gate still fires. Run with node scripts/run-vitest.mjs.

What was not tested: no full pnpm check / build run (memory-constrained box). Scope is one expression in a single decision path; existing colocated suites were run as regression coverage.

@openclaw-barnacle openclaw-barnacle Bot added size: S proof: supplied External PR includes structured after-fix real behavior proof. labels Jun 16, 2026
@clawsweeper

clawsweeper Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Thanks for the context here. I swept through the related work, and this is now duplicate or superseded.

Keep open: current main still trusts a raw persisted totalTokens value in the preflight compaction gate even when the session marks that total stale, and this PR is a focused fix with matching regression coverage and sufficient real behavior proof. I found no blocking correctness or security findings in the patch.

Canonical path: Close this PR as superseded by #81916.

So I’m closing this here and keeping the remaining discussion on #81916.

Review details

Best possible solution:

Close this PR as superseded by #81916.

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

Yes, source-reproducible: current main retains stale entry.totalTokens in the preflight Math.max gate even when totalTokensFresh is false, and compaction without tokensAfter creates that stale state. I did not execute the proof harness in this read-only review.

Is this the best way to solve the issue?

Yes, this is the best narrow fix: it applies the existing freshness contract at the one raw persisted-total consumer without changing resolveFreshSessionTotalTokens or duplicating the broader transcript-usage work.

Security review:

Security review cleared: The diff only changes a compaction gating expression and adds tests; it does not introduce dependency, workflow, secret, permission, or supply-chain changes.

AGENTS.md: found and applied where relevant.

What I checked:

  • linked superseding PR: fix(compaction): bound stale transcript usage #81916 (fix(compaction): bound stale transcript usage) is still open as the canonical replacement.
  • cluster evidence: the durable review links that PR in the work cluster or recommended risk path.
  • no human follow-up: live comments and timeline hydrated by apply contain no non-automation activity after the ClawSweeper review.

Likely related people:

  • yetval: Authored the recently merged same-function preflight compaction gate fix in fix(reply): project preflight compaction gate by next-input size on fresh tokens #91488, and this PR targets the adjacent stale persisted-total leg. (role: recent area contributor; confidence: high; commits: caab3434612c; files: src/auto-reply/reply/agent-runner-memory.ts, src/auto-reply/reply/agent-runner-memory.test.ts)
  • ArthurNie: Authored the current preflight hard-gate behavior for oversized agent turns, which is the runtime path this stale-total fix narrows. (role: introduced behavior; confidence: medium; commits: 9d54285b0d4a; files: src/auto-reply/reply/agent-runner-memory.ts)
  • jalehman: Recently refactored auto-reply session persistence through the session seam and touched the central memory/session update paths involved in this review. (role: recent adjacent owner; confidence: medium; commits: 127e174c9e4d; files: src/auto-reply/reply/agent-runner-memory.ts, src/auto-reply/reply/session-updates.ts, src/config/sessions/store.ts)
  • vincentkoc: Merged the adjacent same-gate fix in fix(reply): project preflight compaction gate by next-input size on fresh tokens #91488 and appears in local blame/provenance for the current preflight gate area. (role: recent merger; confidence: medium; commits: caab3434612c; files: src/auto-reply/reply/agent-runner-memory.ts)

Codex review notes: model internal, reasoning high; reviewed against 319e41d0c55e.

@clawsweeper clawsweeper Bot added proof: sufficient ClawSweeper judged the real behavior proof convincing. rating: 🦞 diamond lobster Very strong PR readiness with only minor 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. labels Jun 16, 2026
@vincentkoc vincentkoc self-assigned this Jun 16, 2026
@vincentkoc

Copy link
Copy Markdown
Member

Maintainer verification complete after rebasing onto current main.

  • focused regression: node scripts/run-vitest.mjs src/auto-reply/reply/agent-runner-memory.preflight-stale-tokens.test.ts (2 passed)
  • Testbox changed gate: tbx_01kv92dbskmv013qkn7dqe0avp (passed)
  • fresh autoreview: clean, no accepted/actionable findings
  • git diff --check: passed

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Normal backlog priority with limited blast radius. proof: supplied External PR includes structured after-fix real behavior proof. rating: 🦞 diamond lobster Very strong PR readiness with only minor maintainer review expected. size: S 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