Repository navigation
fix: reclaim dead message refs, reuse exhausted capacity, classify boundary failures (billion-context#386) - #176
Conversation
📦 Built Package ArtifactBranch: Option A — Install from npm PR tag (recommended)In your adapter project: npm install acp-kernel@pr-176Each push to this PR publishes a new version under the Option B — Download artifact
tar xzf acp-kernel-pr176.tgz
npm install ./packageThis comment is automatically updated on each push. |
[bot] 🏷 Progress update — review in progress, one infrastructure hiccup: Done so far:
Problem hit: the second independent agent review (AGENTS.md requires ≥2 agent reviews before merge) failed — the subagent launch errored immediately (model not found) and a retry hung the session for 35 min until it was force-restarted. I'm doing the second pass myself now with direct edge-case probes (near-full ref map + multi-turn reuse flow, tokenSnapshot-vs-ref-reuse interaction, mutation checks) instead of a delegated agent. Final verdict coming shortly. |
|
请处理这个 issue 并回复处理结果 |
[bot] 🏷 Review complete — verdict: APPROVE-WITH-NITS (no blockers; one minor finding + test-gap suggestions) Pre-flight (verified locally, real Node v22.23.2):
What I verified beyond the diff (adversarial second pass with runtime probes):
MINOR finding (not a blocker, pre-existing but activated by this PR): Realistic exposure is long-tail (needs a count-equal view swap earlier in the session and ~100k+ live messages for low-slot reuse to actually hit the stale slot), and impact is limited to the rendered tag's token count / nudge math for that message. Suggested fix (one-liner, could land in this PR or a follow-up): compare key sets, not counts — const keys = Object.keys(result.tokenSnapshot);
if (keys.length !== liveRefs.size || keys.some((k) => !liveRefs.has(k))) { /* prune */ }Test-gap suggestions (nice-to-have, not required for merge):
Process note: AGENTS.md asks for ≥2 agent reviews before merge. My subagent launches for an independent second reviewer failed (model-not-found / hung — infra issue, not the code), so I performed the second pass myself with the runtime probes above. A human pass is still welcome. And per the rules I can't merge the PR — that's yours: #176 |
…undary failures Rebase onto master (incl. #175): resolve refs.js import conflict (indexToRef + pruneDeadRefs). Because applyCompression now syncs (prunes dead refs) before boundary resolution, a drifted ref is released and reported as unknown rather than the pre-#175 dangling branch; name edit-drift as an unknown-ref cause in the all-unknown gate message and update the #387 drift test accordingly.
cb9ec13 to
54d45b7
Compare
…ation Revert "fix: reclaim dead message refs, reuse exhausted capacity (#176)" — ref slot reuse is unsafe
Root cause
assignRefs(refs.ts) allocateshighestUsedIndex(map)+1forward andallocateFreeRefskips occupied slots, but no path ever removed dead entries: after a message is edited/truncated/deduplicated (content-hash id drift,wire/message-id.ts), its raw id stays inbyRaw/byRefforever (rebuildRefIndexonly rebuilds the reverse table). Consequences:allocateFreeRefthrowsref capacity exhausted, which the host (biliprepareAnthropic) catches and forwards unchanged — compression silently stalls for that request, and the stall gets closer with every ghost entry.orphan-gc/deactivateBlockdeactivates blocks but theireffectiveMessageIdsmappings are kept →boundaries.ts"is an active block but none of its content is visible" — an unsolvable error for the model.Fix
refs.tspruneDeadRefs(map, liveIds)— pure; dropsbyRawentries whose raw id is not live (incl. deadBLOCKED_REFentries, re-protected on reappear) and rebuildsbyRef.allocateFreeRef: when the forward scan from the cursor exhausts MAX_INDEX, fall back to scanning from MIN_INDEX (reclaim freed low slots). Throws only when all 99999 slots are occupied by live messages (impossible for a bounded view), with a message that says so.sync.tssyncBlocks: after the block-activity pass, prune refs toliveIds = current view ∪ effectiveMessageIds of ACTIVE blocks. Active-block ids are tombstones (their summary text may still reference them); inactive-block ids are released — so the map stays proportional to view + active-block refs, matching the issue's acceptance criterion, instead of total session history.compress.tsapplyCompressionnow starts fromsyncBlocks(input.messages, input.state).stateinstead of a bare clone (clone deleted). Drifted/orphaned blocks are deactivated and dead refs pruned before boundary resolution, so the "active block but none of its content is visible" error is unreachable in the normal flow — such blocks become inactive and the range is reported as consumed (skip + warning) instead of a hard error.assignRefsNodeprunes before allocating: a legacy near-full state (pre-reclamation persisted state) releases its dead slots first, so allocation reuses them instead of throwing capacity-exhausted.rebuild.tsrebuildCompressionState: same pre-allocation prune so pre-fork stale entries can't pin the cursor near MAX_INDEX.boundaries.ts:BoundaryNotFoundErrorgainsreason: "consumed" | "generation-replaced" | "edit-drift" | null(additive;kindunchanged for API compat). The message-branch failures now classify into the three real causes: covered by an active block ("retry with the block ref"), covered only by inactive blocks (distilled/consumed), or no block (message edited/removed, ref released). The unknown-ref message mentions ref release. The active-block-invisible branch is rewritten as id-drift wording (next sync deactivates the block).index.ts: exportspruneDeadRefsandBoundaryFailureReason.Scope note
The stale-ref gate wording at
compress.ts:274/276("renumbers the remaining refs") is deliberately untouched here — it is owned by the sibling issue #387 PR (branch2026-08-31_stale-ref-gate-truth). Both PRs land independently; the billion-context dep bump should happen after both merge.Tests (14 new, tests/ref-reclamation.test.ts)
pruneDeadRefs: dead byRaw/byRef both directions, BLOCKED entries, input not mutatedassignRefs: reuse of freed low slots when cursor > MAX_INDEX; throw only with all-99999 occupiedsyncBlocks: edit-drift release; active-block tombstones kept when absent from view; orphaned active block deactivated + refs released; inactive-block-only refs released; input state not mutatedapplyCompression: orphaned active block deactivated before resolution (no "active but invisible"); message ref into it → "ref released" wording; block ref into it → consumed/generation-replacedresolveBoundaries: 3-way classification (consumed / generation-replaced / edit-drift) + unknownprocessTurnturns mixing appends + edits (id drift) + truncation + periodicapplyCompression+ periodic orphan-gc-style deactivation — per-turn invariantbyRaw size ≤ view + Σ active-block refs,maxByRaw ≤ 150with >1500 distinct ids churnedprocessTurnsucceeds with no throw, refs reused at low indices, and a subsequentapplyCompressionon the range succeeds (no stall)Pre-flight
npm run typecheck— cleannpm test— 563/563 pass (549 existing + 14 new)npm run build— successPer AGENTS.md, source changes require review by at least 2 separate agents before merge.