Skip to content

fix: reconcile-live-ids keeps positional replays of folded originals - #466

Open
ranxianglei wants to merge 4 commits into
masterfrom
2026-09-27_reid-replay-originals
Open

ranxianglei wants to merge 4 commits into
masterfrom
2026-09-27_reid-replay-originals

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 27, 2026 •

Copy link
Copy Markdown
Owner

Root cause

v0.0.96's reconcile-live-ids node (src/instance-reid.ts, first pipeline node) re-minted every live message whose content-hash root appeared in coveredMessageIds. Its discriminator — "any live message carrying a covered id is a new instance, not the original" — conflates two distinct situations:

  1. Positional replay (remintCoveredLiveIds: full-history resend after fold escapes prune — covered originals re-minted instead of replaced by summary #461). Every proxy-mode host compresses server-side and resends its FULL raw history each request. After a fold, the next request carries the folded originals verbatim; their deterministic ids re-derive identically (the cluster counter restarts per pass). Those resends ARE the originals.
  2. Genuinely new duplicate (#1476). A fresh message matching folded content arriving in a pass where the original is not positionally resent (pruned view carrying the rendered summary, trimmed history, …).

For case 1, pre-0.0.96 behavior was load-bearing: resent covered originals were pruned and replaced by the rendered summary block at the anchor. v0.0.96 re-minted them instead — folded content returned to the wire every turn (payload bloat), the summary lost its carrier in both proxy and plugin mode, refs churned after each fold, blocks deactivated, and ranges computed from state refs no longer matched post-remint live ids (fail-fast 502 loop against the real codex CLI). Measured impact: 56/2919 unit failures + e2e lane red on billion-context with a pin-only bump.

Fix

Classify each covered-id occurrence by positional fingerprint — no new config surface, so all full-history-resending hosts are fixed pin-only:

  • A block is REPLAYING when its rendered summary is absent AND every one of its covered bases is present in the input — only a full resend of the folded population produces that shape.
  • An occurrence under exclusively-replaying coverage keeps its id → prune drops it, the rendered summary takes the anchor (pre-0.0.96 contract restored).
  • Any other shape (summary present, partial presence, ambiguous single-message fingerprint) keeps the 0.0.96 re-mint behavior — erring toward shipping a duplicate rather than deleting a user turn.

Two adjacent restorations in the same root cause:

  • Re-minting now touches only the conflicting members; non-conflicting siblings keep their ids (v0.0.96 renumbered the whole group, churning stable refs).
  • Coverage is judged per sync's own activation predicate — isBlockStillPresent (extracted from src/sync.ts, behavior-identical refactor), recomputed against the current input instead of the stored active flag. This closes a data-loss chain found during analysis: a block deactivated last turn (view lacked both raws and summary) reactivates this turn when the summary returns, but under the stored-flag check its coverage vanished before reconcile ran, so a colliding new instance kept its bare id, got ref'd to nothing new, and was silently pruned.

Behavior change disclosure (old → new)

Shape v0.0.96 this PR
Full-history resend of folded originals (summary absent, all bases present) re-minted _N each turn; escaped prune; summary lost carrier; block deactivated ids kept; pruned; rendered summary at anchor (= pre-0.0.96)
New dup under non-replaying coverage (summary present / partial presence) whole conflict group renumbered root_1..k only conflicting members renumbered; siblings untouched
Deactivated block whose summary returns same turn coverage invisible to reconcile → colliding dup silently dropped coverage counted (sync-predicate) → dup re-minted, survives

WHY: the v0.0.96 discriminator was built for #1476 without distinguishing replay from new instances; restoring the pre-0.0.96 contract for the replay shape is the fix, not a semantic drift — #1476 protection is preserved and now tested for both shapes.

No §7.3 contract touched: node ordering unchanged, wire format unchanged, ref immutability intact (no slot recycling), state schema unchanged, zero runtime deps unchanged.

Verification

Minimal repro (40 alternating messages → fold m00001..m00015 → resend full history):

0.0.95 / this PR 0.0.96
full resend, no tool call 40 → 26 raws + acp_summary_b1 at anchor 40 → 40, nothing pruned, no summary
full resend + compress pair pair survives as carrier tool pair gone
pruned view + new dup dup gets its own ref, survives dup silently dropped (deactivated-block chain)
  • New tests/issue461-replay.test.ts: 7 unit tests (replay identity, partial-presence conservatism, sibling no-churn, deactivated-block coverage, expanded never covers, …) + 4 end-to-end pipeline tests covering all shapes above.
  • tests/issue1476-reid.test.ts updated to the two-shape fixtures (its old "renumbers every live instance" expectation encoded the buggy group-wide discriminator).
  • Gates green on Node 22: npm run typecheck, npm test (1195 pass / 0 fail), npm run build, npm run format:check.

Update (2026-09-27): rebased onto current master — supersedes #463's mechanism

While this PR was under review, #463 (cross-pass lastPassIds snapshot) was merged and shipped in v0.0.97. Per the owner verdict in this thread ("recommend merging this over #463/#465"), the branch is now rebased onto current master and REPLACES that mechanism with the stateless positional fingerprint:

  • Classification is purely per-(state, body): rendered-summary presence × covered-base presence. No cross-pass id-stability assumption.
  • Behavior change vs v0.0.97 (disclosed): echo/new-instance discrimination moves from prior-pass membership (state.lastPassIds) to the structural replay fingerprint. Concrete gap closed: mergeCompressionState's field whitelist never carried lastPassIds across a state reload, so v0.0.97 degraded exactly ONE pass after every restore to 0.0.95 semantics (renumber nothing) — re-exposing both remintCoveredLiveIds: full-history resend after fold escapes prune — covered originals re-minted instead of replaced by summary #461 and #1476 for that pass. The stateless classification is correct on the first pass after any restore.
  • CompressionState.lastPassIds removed (src/types.ts, snapshot write in src/compress.ts). No persisted-state compat concern: the load path never carried the field (old blobs' stray key already ignored) and no adapter sets it directly.
  • Review nit addressed: docblock reworded (it described an "ambiguous single-message fingerprint" re-mint case that does not exist in the code) + unit test pinning the 1-base / summary-absent ⇒ replay classification.

Gates green on the rebased head (Node 22): typecheck, npm test (1196 pass / 0 fail), build, format:check.

Fixes #461

@github-actions

github-actions Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-09-27_reid-replay-originals (848eee9)

Option A — Install from npm PR tag (recommended)

In your adapter project:

npm install acp-kernel@pr-466

Each push to this PR publishes a new version under the pr-466 npm tag.

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf acp-kernel-pr466.tgz
npm install ./package

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 收到,这是我自己/agent 已完成的 PR(带 ework-agent-pr 标记),不重做。正在按 PR Review 规范核对:拉取分支 2026-09-27_reid-replay-originals (3abc6c3)、审代码、跑本地最小验证、确认 CI 状态。稍后给出完整结论。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 PR #466 reviewed — verified independently, mergeable (merge itself is human-only)

① Root-cause direction — confirmed in pre-PR code. The bug path is real: coveredMessageIds (src/state.ts:58) filters on the stored block.active flag, and old remintCoveredLiveIds re-minted every live message whose root was covered — conflating positional replay (proxy resends raw history; those ARE the originals) with genuinely new duplicates (#1476). Pipeline order verified in buildNodes (src/compress.ts): reconcile-live-ids runs FIRST, before sync-blocks, so judging coverage by recomputing isBlockStillPresent against the current input — instead of last turn's stored flag — is correct, and closes the deactivated-block data-loss chain (block deactivated last turn ⇒ coverage invisible to reconcile ⇒ colliding dup keeps bare id ⇒ silently pruned).

② Regression audit of touched paths:

  • src/sync.ts: pure extraction — isBlockStillPresent(block, presentBases, presentIds) is the exact boolean previously inline; sync behavior unchanged.
  • src/prune.ts still uses coveredMessageIds (stored-active semantics) — consistent, because sync-blocks refreshes flags before prune runs.
  • No state-schema change, no wire-format change, no node-ordering change, no new deps ⇒ touches no §7.3 kernel-owned contract.
  • Existing-behavior inventory & verdicts: (a) replay shape (summary absent + all bases present) → ids kept, prune drops originals, summary anchors, refs stable — e2e-asserted (m00021 stable, 27-message output, zero _\d+$ ids); (b) new-duplicate shape → re-minted _1, survives with its own ref; (c) partial presence → conservative re-mint (survival over deletion); (d) non-conflicting siblings untouched (no ref churn); (e) expanded blocks never cover; (f) tool-call/result pair atomicity preserved across fold+resend. All locked by tests.

③ Gates on head sha 3abc6c3 (Node 22.23.2): typecheck ✅ · npm test 1195 pass / 0 fail ✅ · build ✅ · format:check ✅. CI on head: 4/4 success (test 22, test 24, build-artifact, pr-validation). Branch is only behind master by release v0.0.96 version lines — no rebase needed.

④ Test quality: tests/issue461-replay.test.ts is real coverage, not tautology — unit tests pin every classification branch (replay kept / pruned-view re-mint / partial presence / sibling stability / deactivated block / expanded block), plus four full-pipeline e2e tests through processTurn+applyCompression. Updated tests/issue1476-reid.test.ts correctly re-encodes the two-shape expectation.

⚠️ Concurrent-PR flag (owner decision needed): #463 and #465 are still open and fix this same root cause on the same hotspot node. Both take the stateful route — optional CompressionState.lastPassIds snapshotted per pass (confirmed in #465's diff: src/types.ts, src/persist/state-merge.ts, src/compress.ts) — which is a state-schema change, cross-repo by construction (§7.3), and #465's own table shows it degrades to 0.0.95 semantics when lastPassIds is absent from persisted state. #466 is stateless: classifies replay purely from current-input structure, needs no adapter changes, works immediately on existing persisted state, and errs toward survival on ambiguity. Merging more than one rewrites the same function and will conflict. My recommendation: merge #466, close #463/#465 as superseded — but that call is yours.

中文摘要: 修复了 reconcile-live-ids 把「代理端整段重放的折叠原文」误判为新实例而重新编号(内容回流、ref 抖动、去活块下新副本被静默丢弃)的问题——按当前输入结构区分「重放」与「真新副本」,本地四项检查与 CI 全绿,无 §7.3 契约变更;注意 #463/#465 修同一根因(走 lastPassIds 状态字段路线),三者只能合一个,建议合本 PR,可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

Reviewed the design + verified empirically from the main consumer side (billion-context).

Design. The positional fingerprint is the right discriminator for the shape #459 got wrong. A live occurrence carrying a covered base is kept only when ALL blocks covering that base are replaying (every covered raw present by base id AND the rendered summary absent); anything else is re-minted as a genuinely new instance. That holds for both host classes:

  • plain proxy-mode clients re-send their own history, which NEVER contains the summary (it is an upstream-only carrier) → full resend classifies as replay → echoes keep their ids → prune drops them (the 0.0.96 regression is gone);
  • plugin-mode hosts carry the compress call/result in their re-sent history, so after a fold the summary IS present → identical re-typed text is a real new instance → re-minted (#1476 stays fixed).

No new persisted state, nothing to thread through merge/sync paths, deterministic per (state, body) — survives state restore trivially and stays prefix-cache friendly.

Empirical (billion-context, the repo whose full suite went red on 0.0.96). Injected this branch's build into bili's node_modules and ran the full suite (2884 tests):

  • acp-kernel on this branch: 1195/1195 pass;
  • bili on 0.0.96: 55 failures; bili with this branch: 0 failures (12 skips are normal).
    (One of the original 55 was actually a test-isolation defect in a NEW wire test I added on the bili side — file-global SessionStore pollution — fixed separately there, not kernel-related.)

One doc nit. The docblock says ambiguous single-message fingerprints "are treated as a new instance and re-minted", but the code has no such special case: for a 1-base block with the base present and the summary absent, replaying is true and the occurrence KEEPS its id. That behavior is correct for the only host class that produces that shape (proxy-mode echo — should be pruned), but the docblock sentence describes behavior that doesn't exist. Suggest rewording, or adding a unit test pinning the 1-base + summary-absent classification so the intent is explicit.

Verdict: recommend merging this over #463/#465 — both of which I also verified green under identical conditions (details in their threads). This one has the least surface: no schema growth, no cross-pass id-stability assumption.

ework-agent added 4 commits September 28, 2026 00:19
…461)

v0.0.96's reconcile-live-ids node re-minted every live message whose
content-hash root appeared in coveredMessageIds, conflating two distinct
situations: a host resending the very originals a block was folded from
(full-history resend after server-side compress) and a genuinely new
duplicate occurrence (#1476). Re-minting the resends broke the pre-0.0.96
contract "covered => pruned + replaced by rendered summary at anchor":
folded content returned to the wire every turn, the summary lost its
carrier, refs churned, and blocks deactivated.

Classify each covered-id occurrence by positional fingerprint: a block is
REPLAYING when its rendered summary is absent AND all of its covered bases
are present; occurrences under exclusively-replaying coverage keep their
ids (prune drops them, the summary takes the anchor). Every other shape
keeps the 0.0.96 re-mint behavior, now renumbering only the conflicting
members (non-conflicting siblings no longer churn) and skipping numbers
claimed by folded copies or retained by kept members.

Coverage is judged per sync's own activation predicate (isBlockStillPresent,
extracted from sync.ts and recomputed against the current input) instead of
the stored active flag: a block that sync would reactivate this turn counts
as covering even while currently inactive, closing a silent-drop chain in
which a deactivated block's coverage vanished before a colliding new
instance could get its instance id and then got pruned.

Fixes #461
The docblock listed an 'ambiguous single-message fingerprint' shape that is re-minted, but the code has no such special case: a 1-base block with its base present and the summary absent classifies as replaying and keeps the id. Reword to match the code.
Unit test for the one-message-fold echo shape: 1 covered base present, summary absent => positional replay => id kept. Makes the docblock intent explicit and guards against drift.
…ngerprint

Replaces #463's cross-pass snapshot (shipped in v0.0.97) with reconcile-live-ids' per-(state,body) positional classification: deterministic, no cross-pass id-stability assumption, and correct on the FIRST pass after any state restore — the snapshot was dropped by mergeCompressionState's field whitelist on every reload, which degraded exactly one pass to 0.0.95 semantics (renumber nothing), re-exposing both #461 and #1476 for that pass. No persisted-state compat concern: the load path never carried the field, so old blobs' stray key was already ignored; adapters never set it directly. Behavior change vs v0.0.97: echo-vs-new-instance discrimination is now structural (rendered-summary presence x covered-base presence) instead of prior-pass membership.
@ranxianglei
ranxianglei force-pushed the 2026-09-27_reid-replay-originals branch from 64171ab to 848eee9 Compare September 27, 2026 16:25
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Doc nit fixed + branch rebased onto current master (now supersedes #463)

① Your doc nit — confirmed and fixed. You're right: the old docblock (src/instance-reid.ts:50-53) listed "ambiguous single-message fingerprint" as a re-mint shape, but the code has no such special case — for a 1-base block with its base present and the summary absent, replaying = allPresent && !summaryPresent is true and the occurrence KEEPS its id. I did both options you offered (cheap together):

  • docs(instance-reid) commit: reworded the sentence to describe the actual classification (single-base shape IS replaying — the proxy-mode echo of a one-message fold; only summary-present / missing-base shapes re-mint);
  • test(reid) commit: unit test pinning folded([H]) + [msg(H)] ⇒ same array returned (ids kept), so the intent can't drift back.

② Master moved mid-review — rebase was required. Timeline (UTC): my review reply 12:22 → #463 merged 12:40 (then release v0.0.97, then #469) → your design review 15:47. #465 was closed unmerged. That left #466 dirty — both sides rewrite remintCoveredLiveIds.

Per your verdict ("recommend merging this over #463/#465"), I rebased onto current master (3519e08) so the PR now REPLACES #463's mechanism:

③ Why replacement is safe — plus a concrete gap found in #463. mergeCompressionState (src/persist/state-merge.ts) is a field-whitelist merge that never carried lastPassIds. So on v0.0.97, every state reload drops the snapshot and remintCoveredLiveIds hits its if (!state.lastPassIds) return messages fallback: exactly ONE pass after each restore runs 0.0.95 semantics (renumber nothing), re-exposing BOTH #461 (echo churn) and #1476 (dup starvation) for that pass. The stateless fingerprint has no such window — it is correct on the first pass after any restore. Persisted-state compat: no concern, because the load path never carried the field anyway (old blobs' stray key was already ignored).

④ Behavior change vs v0.0.97 (disclosed): echo/new-instance discrimination moves from prior-pass membership (state.lastPassIds) to the structural replay fingerprint (rendered-summary presence × covered-base presence); CompressionState.lastPassIds removed from the state schema (§7.3 cross-repo note: kernel-internal field, no adapter writes it, existing state blobs unaffected). Already-shipped v0.0.97 behavior is otherwise untouched until this merges.

⑤ Gates on rebased head 848eee9 (Node 22.23.2): typecheck ✅ · npm test 1196/1196 ✅ · build ✅ · format:check ✅. CI on 848eee9: 4/4 success (test 22, test 24, pr-validation, build-artifact). PR is mergeable: true; description updated with a supersession section documenting all of the above.

Ready for you to merge when you are.

中文摘要: 按你的 review 修了 docblock 的不实描述(改写 + 单测钉住 1-base 重放分类);因 master 在 review 期间合入了 #463 并发布 v0.0.97,已将本 PR rebase 到最新 master,用无状态指纹替换 lastPassIds 机制(顺带发现并披露了 #463 的缺口:mergeCompressionState 白名单从不携带 lastPassIds,每次状态重载后第一个 pass 会退化为 0.0.95 语义,#461 和 #1476 同时复现一个 pass),本地四项检查与 CI 全绿,可以合并。

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.

remintCoveredLiveIds: full-history resend after fold escapes prune — covered originals re-minted instead of replaced by summary

1 participant