Skip to content

ref capacity exhausted: the ref map's 5-digit ceiling permanently bricks long sessions (restart does not help) — widening is implemented in PR #496 #483

Closed

Description

@LDyty

Summary

A long-lived host session eventually dies with:

ref capacity exhausted: cannot allocate beyond m99999

The throw escapes processTurn, so the failure is not a degraded turn — the host's whole
agent turn fails and, as established below, the session is permanently unusable.

Environment

  • Host: DeepSeek Harness (dsh) via the port billion-context-dsh, which reuses the kernel verbatim.
  • Kernel versions checked: 0.0.29 (the host's pinned build at the time), 0.0.63 (that port's pin),
    0.0.98 and 0.0.99 (npm latest) — every one still ships
    MAX_INDEX = 99999, REF_PATTERN = /^m0*(\d{1,5})$/ and the same ref capacity exhausted throw
    from allocateFreeRef, with the cursor still starting at highestUsedIndex(...) + 1.
    The kernel's own sync.ts comment documents the additive intent:
    "Refs are additive (assignRefs never removes them from messageRefs)" — only tokenSnapshot is pruned.

Why the wall, not a leak (corrected)

An earlier revision of this issue claimed a restart clears it temporarily. That was wrong, and the
correction was verified line-by-line against the kernel and the DSH port's source:

  • The port hands the kernel the whole log, not the visible view:
    allLogMessages(session) = eventsToCoreMessages(session.events) — its doc comment says
    "ALL message-type events in log order — the visible surface PLUS everything shadowed by
    compression … T2/T3 distillation requires the full log"
    . Five call sites feed it to
    kernel.processTurn (nudge, compress tool, status tool, /acp-prune, index).
  • assign-refs is pipeline node fix: release workflow grep lookbehind error #1 (base = [assignRefsNode, syncBlocksNode, pruneNode, …]), and its
    throw is uncaught, so it escapes processTurn before any compression decision can run.
  • Therefore a fresh process re-allocates refs for the entire log on its first turn (139,159
    message-level events in the failing session) and throws again immediately; /acp-prune and the
    compress tool traverse the same path and cannot dig out either.

Net effect for this host shape: a log past ~100k message-level events bricks that session under ACP,
with no host-side recovery path.

Data point that bounds the fix

quantity value
message-level events in the log (assistant/user/tool-call/tool-result) 139,159
of those, already shadowed by summary ranges 89,208
current visible surface ≈ 49,951

The live view needs ~50k refs; the cumulative map is what crosses 99,999. With MAX_INDEX = 9_999_999
the same session has ≈72× headroom over its entire lifetime.

Status (2026-10-02)

  • The hybrid widening (≤99999 byte-identical 5-digit padding; natural width above; MAX_INDEX = 9_999_999)
    is implemented in PR fix(refs): widen ref capacity to 9,999,999 — hybrid widths (#483) #496 — 6 files, +98/−14, including one surface the original two-file estimate
    missed (src/ccr.ts placeholder grammar), 1217/1217 tests plus typecheck/prettier and CI green.
    Reviews are complete; the remaining step is the human merge required by §7.3, then a release.
  • acp-kernel@0.0.99 (the currently published latest) does not contain the widening.
  • Adapters must widen their mirrors in the same cycle; for the DSH port that is
    src/tools.ts MN_RE = /^m0*(\d{1,5})(?:#.*)?$/i + index <= 99999 → {1,7} / <= 9999999.

Activity

  1. ranxianglei commented on Sep 30, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷

    Short answer: Yes — widening is the right fix, and it turns out to be much smaller than it looks. I recommend the hybrid variant you floated in point 2 (keep 5 digits ≤ 99999, widen only above it, MAX_INDEX = 9_999_999). I'd reject non-fatal overflow (point 3) as the primary fix — reasoning below. Needs owner sign-off on the release slot before implementation, because it touches a kernel-owned wire contract.

    What I verified on current master

    Why hybrid over uniform 7-digit padding

    • Uniform padding (m0000001) re-renders every visible tag +2 chars on upgrade. For a session with ~50k visible messages that's ≈100k chars ≈ ~25k extra tokens per request, paid by every session including ones far from the cap. Hybrid costs zero bytes until a session crosses 99999.
    • Backward compat falls out for free: refToIndex's leading-zero-tolerant pattern accepts both widths, so existing summary text citing 5-digit tags keeps resolving; and indexToRef's padStart(5) is a no-op for indices ≥ 100000, so the very same function already emits m100000-form once the bound is lifted.

    Why not non-fatal overflow (point 3)

    A ref-less message can't be cited in a compress range (boundary resolution is ref-keyed, src/boundaries.ts:20-28), can't enter a block's effectiveMessageIds, so prune never removes it → it accumulates in the visible view forever → guaranteed context overflow later, silently, with no diagnosable error. It also loses CCR retrieval and decompress range-mode for those messages. It converts a loud, diagnosable hard stop into a slow silent death. If a safety valve is wanted there, it's a separate design (id-based coverage), not a fallback for this fix.

    Implementation surface (hybrid, 7 digits)

    Kernel — surprisingly small, because the padding design was already forward-compatible:

    • src/refs.ts:5 — MAX_INDEX → 9_999_999; src/refs.ts:6 — pattern \d{1,5} → \d{1,7}. indexToRef itself needs no change.
    • src/boundaries.ts:20 — MESSAGE_REF_PATTERN → \d{1,7}; src/boundaries.ts:28 — bound 99999 → 9999999. (formatPaddedRef at :361 is already a no-op for wide indices.)
    • src/parse-compress-input.ts — checked: the line-form patterns already accept \d{1,7}, and its padStart(5) is a no-op for wide refs. No change needed.
    • Regression tests: allocation across the 99999→100000 boundary; mixed-width round-trip (m00001 ≡ m1 → 1; m99999/m100000 adjacency); boundary resolution of wide refs; byte-stability of all sub-cap fixtures.

    Host side (your audit list, confirmed):

    • billion-context-dsh src/tools.ts: MN_RE = /^m0*(\d{1,5})(?:#.*)?$/i + index <= 99999 → widen to {1,7} / <= 9999999. With hybrid, an un-updated host keeps working fully until a session crosses the cap (kernel-side parsing accepts both widths either way), but adapter mirror updates should still ship in the same cycle.

    Capacity math: the failing session consumed 139k ids over months; 10M gives ≈72× headroom versus that session's entire lifetime.

    Contract disclosure (why this is human-gated, not auto-merge)

    This changes the rendered ref-tag shape — a §7.3 kernel-owned wire contract — so it stays human-gated and follows cross-repo sequencing: kernel releases first, adapters bump pins after. Behavior deltas: nil below the old cap (byte-identical output everywhere under 99999); above it, new tags are 6–7 digits; the exhaustion error string changes from m99999 to m9999999. No state-schema change (the cursor is recomputed from highestUsedIndex each turn, no persisted high-water mark).

    Requesting

    Owner approval of the hybrid approach + a release slot (latest published is v0.0.98). Once approved I'll open the PR with regression tests and hand you a testable build/pin for the 139k-message session (decompress / acp_status / compress against old and new tags).


    中文摘要:bug 已在当前 master 逐条核实(refs.ts 五位上限 + 只增不删的 ref map,累计超 99999 即在 assign-refs 节点抛错、杀死整个 turn);推荐「混合加宽」——≤99999 保持 5 位字节不变、超出后自然变宽到 7 位(MAX_INDEX=9999999),旧标签全部继续可解析,低于上限的会话零变化;否决「溢出消息无 ref 化」备选(无 ref 消息永远无法进入压缩范围/被剪除,只会变成无声的慢性上下文死亡)。内核侧实际改动仅 refs.ts 与 boundaries.ts 各两行加回归测试。因触碰 wire 契约(§7.3)需 owner 拍板版本后开工,随后按「先发内核、再升适配器」顺序落地。

  2. LDyty commented on Sep 30, 2026

    @LDyty
    Author

    Correction: restarting does not clear it — the ceiling is a hard wall, not a leak you can outrun

    My opening comment said the failure is recoverable by restarting the hosting process ("kernel state is
    in-memory, rebuilds from the current view, ~50k refs"). That was wrong, and I want to correct it here
    because it changes how urgent this is.

    That estimate used the model-visible message count. But the failing host (the DeepSeek Harness port
    billion-context-dsh) does not hand the kernel a pruned view: every call site passes
    allLogMessages(session) — its own doc comment says "ALL message-type events in log order — the visible
    surface PLUS everything shadowed by compression … T2/T3 distillation requires the full log, not just the
    visible surface"
    :

    // billion-context-dsh/src/messages.ts
    export function allLogMessages(session) { return eventsToCoreMessages(session.events) }
    
    // every caller (nudge, compress tool, status tool, prune path):
    const coreMessages = allLogMessages(session)
    const turn = env.kernel.processTurn({ messages: coreMessages, state, config, tokenCount })

    assign-refs is the FIRST node of the pipeline, so refs are allocated for every message in the whole
    log
    , not for the view that survives prune. Consequences, now verified on the real session:

    • A fresh process re-allocates refs for all 139,159 message-level events on its first turn → it throws
      ref capacity exhausted immediately. Restart does not help, at all.
    • Compression cannot dig out either: /acp-prune and the compress tool go through the same
      allLogMessages → processTurn path, and assign-refs runs before prune.
    • So for this host the ceiling is a hard per-session wall at ~100k log messages: the session becomes
      permanently unusable, with no host-side recovery path, for both the user and the agent.

    That is a materially stronger failure mode than "the map grows monotonically and eventually stalls", and
    I would suggest it raises the priority of the REF_WIDTH widening. If widening is not imminent, the
    second-best host-side option now looks like: let the host pass the pruned view for ref assignment (or let
    assignRefs skip overflow messages instead of throwing), so at least the session survives with degraded
    drill-down. Data for calibrating that: 139,159 message-level events in the log, 89,208 of them already
    shadowed by summaries, i.e. only ≈49,951 are in the current visible view.

  3. ranxianglei commented on Sep 30, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷

    Correction accepted — and I've now verified the mechanism against current master. You're right that this is materially worse than "eventually stalls": for a host that passes the full log, the session is permanently bricked, and restart makes it fail immediately rather than later.

    Kernel-side confirmation of your corrected model

    • The kernel has no notion of visibility. assignRefsNode (src/compress.ts:821-849) allocates a ref for every message the host hands it, excluding only protected/media messages (which get BLOCKED_REF, consuming no number) and ephemeral retrieval injections. Nothing filters or prunes before allocation — what the host passes is what gets numbered.
    • With fresh state the cursor starts at 1: nextIndex: highestUsedIndex(io.state.messageRefs) + 1 (src/compress.ts:842, same in src/rebuild.ts:36). So a 139,159-event full-log input allocates m00001…m99999 and throws ref capacity exhausted on the next message — on the first turn after restart, exactly as you now describe.
    • assign-refs is pipeline node fix: release workflow grep lookbehind error #1, ahead of prune and ahead of every compression decision (src/compress.ts:788-799), and the throw is uncaught — it escapes processTurn. So both consequences follow directly: restart cannot help, and /acp-prune / compress cannot dig out because they traverse the same path.

    Severity update agreed: hard per-session wall at ~100k log messages, no host-side recovery path for this host shape. Priority raised accordingly. This does not change what the fix is — only how urgent it is.

    Your two stopgap options, assessed

    (a) Host passes the pruned/visible view instead of the full log. Feasible as degraded operation, with three costs: (i) it contradicts the stated reason for the full log (T2/T3 distillation needs the whole log); (ii) after a restart, shadowed messages get no refs, so mNNNNN tags already cited inside existing summaries dangle — decompress range-mode and search degrade across the historical span; (iii) it changes what the kernel's own prune/recommend/distill nodes see — a host-side semantic shift, not a fix. Acceptable as a temporary mitigation if giving up drill-down into shadowed history is tolerable.

    (b) Kernel skips overflow messages instead of throwing. I would not make this silent. Ref-less messages can't be cited in compress ranges (boundary resolution is ref-keyed), can't enter block coverage, and are never pruned — they accumulate forever. Concretely, on a fresh restart with full-log input the ref-less set is log positions ≥ 100,000 ≈ the last ~39k events; at your observed ~36% visible density that's ~14k visible messages — i.e., exactly the newest work loses compressibility when it is needed most, and the failure mode becomes a slow, silent context-window fill instead of a loud, diagnosable stop. If a valve like this is ever wanted, it must be loud (persistent status/nudge warning carrying the unref count) and tracked as its own issue — not bundled with the widening.

    Position

    Hybrid widening (≤99999 byte-identical 5-digit; natural width above; MAX_INDEX = 9_999_999; patterns \d{1,5}→\d{1,7}) remains the correct and only durable fix; surface unchanged from my previous comment — effectively 2 lines each in src/refs.ts and src/boundaries.ts plus regression tests. Given the corrected severity (permanent brick, immediate on restart), I'm asking the owner again for approval + release slot — happy to land the PR immediately on sign-off, since latest published is v0.0.98.


    中文摘要:已按内核代码逐条核实你的更正——内核没有「可见性」概念,assign-refs 是管线第一节点、对宿主传入的每条消息(全量日志)从 m00001 起分配且不捕获异常,因此重启后第一轮即抛错、压缩也无法自救;该会话是永久性硬墙而非渐进停滞,同意提升优先级。修复方案不变(混合加宽至 7 位,改动面仅 refs.ts/boundaries.ts 各两行+回归测试),两个临时方案中:宿主改传剪枝视图可行但牺牲历史钻取且与 T2/T3 蒸馏需求冲突;内核静默跳过溢出消息不可取(最新可见消息恰好失去可压缩性,故障变成无声的慢性上下文填满),若要做必须带持续告警并单独立项。仍请 owner 拍板批准并给出版本窗口。

  4. ranxianglei commented on Oct 2, 2026

    @ranxianglei
    Owner

    PR up: #496 — hybrid widening exactly as discussed, plus one surface the 2-file estimate missed: the CCR placeholder grammar (src/ccr.ts) hardcodes m\d{5} and must accept canonical widened shapes or stored-placeholder round-trips break past m100000. Canonical-only grammar (5-digit padded | natural ≥100000, never leading-zero) keeps the #1456 prose firewall intact — the existing negative test caught the naive {1,7} version during implementation. 1217/1217, typecheck/prettier clean. Needs review #2 (ework's issue analysis = review #1) + human merge per §7.3.

  5. ranxianglei commented on Oct 2, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷

    Review #2 — APPROVED (human merge required, §7.3 contract #1/#2)

    Verified on head 83b8609 vs current master (merge-base = master tip, so the diff is exactly the PR's 6 files). Method: full line-by-line read of every hunk; byte-level comparison of the old/new PLACEHOLDER_LINE2_RE capture semantics; exhaustive inventory of every digit-bounded regex in src/; local typecheck + targeted tests (tests/refs.test.ts, tests/ccr.test.ts, tests/compress.test.ts, tests/parse-compress-input.test.ts) = 185/185; CI on the head SHA is green (both workflows).

    Key subtlety checked: the old LINE2 regex captures the bare ref (quote sits outside the group — verified against git show FETCH_HEAD:src/ccr.ts:192), and the new construction '"(' + CANONICAL_REF + ')"' keeps the quote outside too. Consumer logic (ref !== hintRef) is untouched, so capture semantics are identical — no hidden behavior shift there.

    结构化七问

    1. 是什么问题:宿主传入全量日志时,ref map 单调累计触及 5 位上限(m99999),assignRefs 在管线第一节点抛错且不被捕获,整个会话被永久砖化(重启即死、压缩无法自救)。
    2. 什么原因导致的:根本原因是「只增不删的 ref map × 固定 5 位宽度上限」的组合——不是泄漏,是容量墙。混合加宽(≤99999 保持 5 位字节不变、超出自然变宽至 7 位)在不违反 id 不可复用不变量(docs: codify id-immutability invariant in AGENTS.md #192)的前提下移除该墙。
    3. 怎么修复的:把 REF_WIDTH=5 从"固定宽度"重定义为"填充下限"(padStart 对 ≥10^5 天然 no-op),上限提至 9,999,999;解析侧放宽为 \d{1,7}(前导零容忍保留);CCR 占位符语法采用仅规范形(m\d{5} | [1-9]\d{5,6},永不带前导零)——这是原 2 文件估算漏掉的第三处,选规范形而非朴素 {1,7} 是为了保住 #1456 散文防火墙("m004234" 类近例仍被拒绝,既有负向测试在实现期确实抓到了朴素版)。
    4. 是否有回归问题:逐项清点触碰路径的既有行为——① 低于 99,999 的渲染/解析:字节级不变(indexToRef 未动,旧测试全绿);② refToIndex/indexToRef/parseBoundary:严格超集,无调用方依赖"宽数字串返回 null";③ CCR:旧语法 ⊂ 新语法,非规范形照拒(#1456 负向测试未动且通过),捕获组语义经字节比对确认不变;④ 游标语义与 state schema:未变(highestUsedIndex+1,无持久高水位);⑤ 块引用 bN:从未与消息宽度耦合,未动且有测试钉住;⑥ 耗尽错误串 m99999→m9999999:已在 PR 体披露。未发现回归。
    5. 是否破坏缓存:不破坏。token 快照按消息首渲染冻结;低于上限的标签字节不变 → 存量会话前缀缓存零失效;宽标签只出现在越过上限后新分配的 ref 上。占位符构建器保持确定性(无时间戳/随机)。唯一注意项(非本 PR 引入):宽占位符在降级回旧内核后不可解析——按内核先发顺序发布即可消除该窗口。
    6. 是否改了设计方向:没有。正是 Revert "fix: reclaim dead message refs, reuse exhausted capacity (#176)" — ref slot reuse is unsafe #191 声明的正解路径(§7.3 fix: release workflow grep lookbehind error #1 "widen REF_WIDTH"),架构/边界/数据流均未转向。
    7. 是否引入新的配置:无。无新 env/config/默认值。

    Diff 整洁度

    6 文件(3 src + 3 tests)、单提交、无锁文件/生成物连带、注释均为带 issue 编号的承重不变量说明(与仓库既有风格一致)、分支命名合规、Fixes #483 在位。干净。

    两条非阻塞备注

    • PR 体措辞待修:"In-repo mirrors already width-agnostic (tag-echo-filter uses \d{4,})" —— 我在本仓 src/ 里穷举了所有定长数字正则(boundaries {1,7}/块引用 {1,9}、parse-compress-input 早已 {1,7}、ccr 规范形、refs {1,7}),不存在名为 tag-echo-filter 或用 \d{4,} 的内核代码。内核侧确实已全部宽度无关(结论成立),但那句话指的应是宿主侧镜像,建议改为明确指向 dsh 等宿主,避免后人按图索骥落空。
    • 适配器审计跟进(PR 体已列,同周期、非阻塞):dsh 端口 MN_RE = /^m0*(\d{1,5})(?:#.*)?$/i + index <= 99999 需同步放宽;未升级的宿主在会话越过上限前完全可用(内核侧两种宽度都认)。跨仓提交按惯例走 owner 批准流程。

    判定

    代码审查通过,测试有牙(边界跨越分配含占用扫描路径、新上限拒绝、双宽度往返、CCR 规范形正负双向均被钉住),CI 全绿。可合——但按 §7.3 由人工合并,我不执行 merge。 合并后按内核先发顺序通知各适配器升 pin。


    中文摘要:Review #2 通过。逐行审完 6 个文件的全部改动,重点核对了 CCR 第二行正则新旧捕获组语义(引号均在组外,捕获语义不变)、穷举了 src/ 内全部定长数字正则确认无遗漏面、本地定向测试 185/185 + typecheck/prettier 干净、CI 双工作流全绿。七问逐项作答:低于 99,999 字节级零变化、无回归、缓存不受影响、设计方向即 #191 正解、无新配置。两条非阻塞备注:PR 体中 "tag-echo-filter uses \d{4,}" 一句在本仓无对应代码(应指宿主侧,建议改措辞);dsh 等适配器镜像需同周期放宽。等待人工合并,合并后内核先发、适配器随后升 pin。

  6. changed the title [-]ref capacity exhausted: the monotonic ref map reaches m99999 and kills the host turn — is the REF_WIDTH widening (#191's stated proper fix) still planned?[/-] [+]ref capacity exhausted: the ref map's 5-digit ceiling permanently bricks long sessions (restart does not help) — widening is implemented in PR #496[/+] on Oct 2, 2026
  7. added a commit that references this issue on Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions