Repository navigation
Description
Setup
- billion-context 0.1.211 as a pi package (
npm:billion-context@0.1.211), pi-native extension - pi 1.1.0 (
@earendil-works/pi-coding-agent), embedded in a web portal that starts and stops one pi per chat (SDK, not the CLI) - a llama.cpp server (OpenAI chat completions,
reasoning_contentsent back), context window 32768 in pi'smodels.json, socompress.maxContextLimit(75 %) is reached after a few file reads - request bodies recorded by a forwarder between the bili proxy and the model server; the server's
timings(prompt_n,cache_n) measure what the prefix cache gave
Without billion-context, the same chats, restarts and edits keep the prompt byte-identical up to the changed message (cache_n 99 %). So the changes below come from the bili layer. Two users see this with different models and model servers.
What happens
pi is stopped and started again on the same session file: when a message is edited (the host cuts the edited message and everything after it out of the session file and starts a new pi on it), after an idle stop, and when the host restarts. The new pi sends the whole history again. bili finds its state for the session again (same sess=), but the history it sends upstream is not the one it sent before.
1. A resumed session gets other message ids
Once a compress block exists, the first request of the resumed process carries other ids in the <acp ...>mNNNNN</acp> tags for the same messages.
- In the running process, each request keeps every earlier message byte for byte.
- After a restart (stop pi, start pi, same session file), in a short chat the first different message was fix: cache health — remove condense, fix isTitleGen, preserve cache_control #13:
...>m00030</acp>\nNow read mod11.py...became...>m00032</acp>\nNow read mod11.py.... Same text, the id moved by two, and everything after it differs. - In a longer chat (56 messages, a block
m00001-m00017), the first different message after a restart was release v0.1.3 #10, at 41 % of the characters:m00026->m00140, thenm00027->m00029, and so on to the end.
reconcileLiveIdsNode / remintCoveredLiveIds look responsible (they re-mint ids of live messages a block covers when state.lastPassIds does not contain them, and lastPassIds is empty in a new process); not verified.
2. An edit changes how the fold is drawn
When the edit removes the turn that held the model's compress call, bili keeps the blocks, but the resent history no longer has that call and its result. The blocks then come back as separate [Compressed conversation section] user messages after each user message, so the prefix changes from message #2 on (33-57 % of the characters).
Example: 14 messages s u a u a u a u a u a(tool_call) t a(tool_call) t become 14 messages s u u(summary) a u u(summary) a u u(summary) a u u(summary) a u; server prompt_n 1781, cache_n 9905 (system prompt and tools only). The unfolded history before the compress (cache_n 22803) was still in the server's cache and would have been reusable.
Expected for an edit or fork (see #1921): blocks made by a compress call that is no longer in the resent history are dropped again, so the history goes back to the raw prefix the upstream cache holds, or the fold is drawn the way it was before the edit.
Related, but other cases: #2695 (host-side history deletion leaving fold blocks), #5140 (cold restore).
The tools part (#5459, #4134)
The same restarts also hit #5459: bili's six tools join only from the second request of a process. Measured on a chat at about 80 % of a 32k window:
| request | tools | prompt_n / cache_n |
|---|---|---|
| last before the restart | 35 | 374 / 26332 |
| first after the restart | 29 | 17736 / 9905 |
| second after the restart | 35 | 20631 / 11152 |
Why it matters
Long-lived hosts stop and start pi often (edit, resume, idle stop, update). A billion-context chat is long and cached, so each lost prefix means reading 15-20k tokens again on a local model, and with #5459 twice in a row.
Reproduce without a host (part 1)
PI_CODING_AGENT_DIR=<empty dir>with amodels.jsonfor an OpenAI-compatible server with a 16-32k window; load the extension with-e <billion-context>/dist/agent/pi-native.js.- Run
pi -p --session-id demo "Read file N and say what it does"several times (one process per message) until usage passes 75 % and the model callscompress. - Record the request bodies behind the bili proxy and compare consecutive requests: after the fold, the ids differ between processes.
Activity
ranxianglei commentedon Nov 1, 2026
(ai: vllm-qwen/qwen3.8-27b)
查重:不重复。 已检索 open+closed,没有同失败签名(pi-native 重启/编辑后 prefix cache 从约 1/3 处丢失、id 重铸 + fold 重渲染)的既有 issue。你提到的 #2695 / #5140 / #1921 是同家族但机制不同(那几条是 host 侧删历史留孤儿块 / 冷恢复);本条是「重启后 id 失配」+「编辑掉 compress 调用轮次后块被拆开重绘」两个新机制,按「同症状同 issue」并入本条管理,不另拆。
标签:bug · pi · scope/kernel · p2 · needs-decision(缺陷类;pi-native 车道;两处机制都落在 kernel/,故 scope/kernel;成本回归非数据丢失,判 p2;修复需你对 kernel 变更拍板)。
先给结论:这是真问题,你定位的方向基本对,但精确触发点跟你猜的不完全一样。 我对代码核了机制(注意:我本地 master 落后 v0.1.211 共 585 个提交,v0.1.211 才是你的版本——下面行号以我核对的源码为准,落 PR 前我会 rebase 到当前 master≈v0.1.211 再动)。
Triage 三步(审核先于实现)
kernel/,按 Kernel Boundary 需要你批准才能动手(见文末选项)。Part 1(重启后 id 变了)——你的假设要修正一处
src/state-hydration.ts:36明确把它从磁盘恢复(parsed.lastPassIds ?? fresh.lastPassIds),它持久化自上一进程最后一次请求的入站 id(kernel/src/compress.ts:876)。kernel/src/instance-reid.ts的remintCoveredLiveIds:它只重铸「内容 hash 相同、但 id 不在上一遍 prior 集合里」的被覆盖活消息。关键门在 line 72:if (!state.lastPassIds) return messages;—— undefined 反而不重铸;真正的雷是「prior 集合 ≠ 本次入站 id」(或 lastPassIds 被存成空数组[]这种边界态)。所以问题不是「新进程没状态」,而是「新进程第一遍的入站 id 跟持久化的 prior 对不上」。Part 2(编辑改 fold 画法)——机制清楚,落在 kernel
compress调用的那一轮裁掉后,块还在 state 里,但重发历史里没了那个调用+结果。于是块的摘要退化成一条条[Compressed conversation section]user 消息插在每条 user 后面(proxy 模式下 acp_summary 渲染成 user),前缀从第 2 条起就变。kernel/prune.ts(SUMMARY_HEADER / SUMMARY_ID_PREFIX)、kernel/hide-consumed.ts:249 hideConsumedCompressCalls、kernel/rebuild.ts:27 rebuildCompressionState(fork 恢复时回放历史 compress 调用)。host 侧src/fold-reconcile.ts只对「连续多遍覆盖 substrate 缺失」的僵尸块做 reap([Bug][proxy] unframed host-side history deletion leaves zombie fold blocks rendering carriers forever — one carrier flaps in/out every turn (persistent prefix-cache churn) #2695 那条路),没有覆盖「carrier 轮次被编辑掉」这个场景。为什么判 p2 不是 p1
没有任何数据丢失、不会服务到脏内容、上下文不会只增不减失控增长——纯粹是「每次重启/编辑多付一次 15-20k token 的重读」。对你这种频繁停启 pi 的长驻 host 很疼,但会话本身可用。升级条件:若将来发现它会服务到 stale 内容或造成 cache ref 错误改写(p1 的「缓存泄露类」),立即升 p1。
怎么修 + 需要你拍板的部分
两处根因都在
kernel/,按 Kernel Boundary(owner 批准 + 人工 review、禁自动合并),我不能擅自开修。选项:我的推荐:C + A 并行——你把请求体发我(C,零成本、最快锁根因),同时给我 kernel 变更的 go-ahead(A),我 rebase 当前 master 后就开干;B 作为「万一证明不必动 kernel」的备选。
结构化八问
收工判定:暂不收工——triage 完成、机制钉死、severity 定级 p2、标签已打(bug/pi/scope/kernel/p2/needs-decision)。下一步卡在你这里:请给 kernel 变更的 go-ahead(A),并方便的话把重启前后 + 编辑前后的请求体发我(C),我 rebase 当前 master 后即可开工。
ranxianglei commentedon Nov 1, 2026
(ai: vllm-qwen/qwen3.8-27b)
补一条(已在你的确切版本 v0.1.211 上核对,不是只对我本地旧 master):三处关键代码在 v0.1.211 跟我分析的一致——
kernel/src/instance-reid.ts:72仍是if (!state.lastPassIds) return messages;、src/state-hydration.ts:36仍恢复 lastPassIds、kernel/src/compress.ts:932/:2342仍按上一遍入站 id 持久化。由此能再收窄一步:在 v0.1.211 下,「历史逐字一致的纯重启」本身不会触发重铸——prior 就是上一进程最后入站 id,逐字一致时每个被覆盖的活消息都会
prior.has(id)命中而跳过(line 115)。所以你能观察到重启后 id 变了,说明新进程第一遍派生出的 id 跟持久化的 prior 真的不一样,而不是「新进程丢了状态」。这把 Part 1 的根因锁死在三选一:要一锤定音还得靠实测:你 forwarder 录的请求体(重启前后各一组)发我最快;或者给我 kernel go-ahead,我 rebase 到当前 master 后搭复现环境跑。等你拍板。
ranxianglei commentedon Nov 1, 2026
(ai: vllm-qwen/qwen3.8-27b)
再收窄一步,并给你一个用手头数据一眼定根因的判据。
我读了 pi-native 的 id 派生核心
src/agent/pi-msgids.ts(#4491):pi 把会话里每条消息的稳定 id(SessionEntry.id,randomUUID 前 8 位 hex,跨重启不变)经一个「逐字对齐、全有或全无」的 walker 钉到出站线上;只要有一条对齐不上(thinking 块、tool-call id 被 normalizeToolCallId 重编号、空文本被丢、跨模型降级……),整个 turn 就放弃 msgIds、退回内容 hash id。关键点:这两套 id 方案对「历史逐字一致的纯重启」都是确定性的——entry.id 稳定,内容 hash 也稳定。所以我之前列的 (a) 跨进程派生不稳定、(b) ref 游标漂移,在纯一致重启下结构性地都不太可能单独触发;(c) snapshot-render 模式切换又要求开
BILI_STORAGE_UNIFIED(默认关,你没提到)。于是更可疑的是一个 pi-native 专属机制:msgId「钉住 vs 退回」在两个进程间翻转。若重启后 pi 重建 wire 时某处 transform 与上一进程不一致(thinking /
reasoning_content、tool-call id 重编号、空文本),walker 在旧进程成功(钉住 entry.id)、在新进程 bail(退回内容 hash)→ 两套完全不同的 id 方案 → mNNNNN 从第一个不一致点起整体不同,正好对上你说的「同文本、id 变了、后面全变」。判据(请求体直接看,不用跑复现):对比重启前后同一批消息 id 的格式——
h_…内容 hash(或反过来)→ 「钉住/退回翻转」实锤,修点在 pi-msgids walker 的跨进程一致性;_k后缀 →remintCoveredLiveIds的 prior 失配。把重启前后各一组请求体发我,按这个判据一锤定音;同时等你 kernel 变更的 go-ahead。
Piggidragon commentedon Nov 1, 2026
Thanks for the criterion. I recorded it again on a clean setup and compared the ids by format.
Setup. billion-context 0.1.211 as a pi package (pi 1.1.0), OpenAI chat-completions wire,
ACP_DEBUG=1, empty bili state/data/cache dirs (so nothing from an earlier install), a context window of 32768 (bili reports an effective 28672). Each chat read files until the model calledcompress(two chats, one fold or more each). A forwarder between bili and the model server kept every request body and the server's timings. I compared (a) bili's own store (messages.msg_id,state.lastPassIds,state.messageRefs) and (b) the<acp ...>mNNNNN</acp>numbers on the wire, before and after each event. No message text is included below.Verdict
Restart: your second case (ref cursor /
messageRefspath), more exactly the one-time ledger alignment of the refs at cold load.h_<16 hex>on both sides, in every snapshot. No 8-hex uuid fragment appears anywhere. The set of raw ids is identical before and after (40 of 40 shared in chat 1, 43 of 43 in chat 2).mNNNNNnumber of the same raw id, from the first folded region on; the numbers before it are kept. It is not a uniform shift (some move by 2, some by 20+).[persist] ledger-aligned refs ...withrestampedandrehomedgreater than zero.state.refLedgerAlignedis absent in the snapshot taken in the process that created the state andtruein the first snapshot after the restart.cache_n22351 of 22558). So it costs once per chat, at its first restart after it has a fold.pluginLaneServerHistoryisoffhere (log line below), there is not a single[msgid]log line in the whole run, and in the 0.1.211 bundlebiliMsgIdsis read only inprepareAnthropicand the incremental lane, not on the OpenAI wire. I did not test a rollout-on install._kid changes across the restart. The_kids in chat 2 exist before the restart too (same raw id, same suffix), so they come from duplicate message content in the process, not from a re-mint.Edit that removes the turn with the
compresscall: none of the three. Raw ids and refs of the messages that stay are unchanged (m00006,m00007,m00012before and after, allh_hashes). What changes is how the fold is drawn: before the edit the blocks sat behind thecompresscall/result pair (the carrier); after it, bili keeps the blocks and draws them as separate user-role messages with idsacp_summary_b1andacp_summary_b2, so the prefix changes from the first message after the first user message on. That is the second part of the issue.Restart: ids before and after (chat 1, wire request 15 -> 16)
The first wire message that differs is #8 (57 % of the bytes of the older body come before it; in chat 2 it is #8 and 78 %). Messages #1 to #5 keep
m00001,m00006,m00007,m00012,m00013.h_6cd139c8h_9a4763e1h_5c517312h_1270b4e4h_c7d8e490h_73cade67h_922763caChat 2 (request 35 -> 36) is the same picture: 6 refs on the wire kept, 10 changed, e.g. #8
m00023->m00051, #9m00024->m00048, #14m00034->m00047, all with identical raw ids.Edit: the same wire, before and after (chat 2, request 37 -> 38)
m00006acp_summary_b1(no ref)m00013acp_summary_b2(no ref)h_hashesh_hashes, same refsLog lines that matter (paths and session ids redacted)
Edit:
Timings (model server
prompt_n/cache_n)The chat 1 restart rows are bili's
[acp-usage](inputminuscached), the others the server'stimings.Two things to keep apart in the restart rows. The first request of a restarted pi has 33 tools instead of 39 (the six bili tools are registered later, the known #4134 / #5459 / #5498), and the tools come before the messages, so that alone costs the whole prompt in the first request. The ref change is the part that is left once that is fixed: with the tools block equal, the prefix would still end at the first differing message (57 % and 78 % of the bytes). The edit rows are small only because the edit cut most of the history; the fold drawing changes at message #2 regardless of how much history stays.
Open
I can send the bodies as a structural diff (roles, ref numbers, ids, no text) if that helps.
ranxianglei commentedon Nov 1, 2026
(ai: vllm-qwen/qwen3.8-27b)
你的数据是决定性的——不用发完整请求体了。我对 v0.1.211 代码逐行核过,两条都钉死,而且跟我之前猜的不一样,你的判定对。
Restart = 你说的第二例(ref/messageRefs),精确说是冷加载时的一次性「引用对账」。 代码在
kernel/src/refs.ts:232 restampRefsToLedger(由src/persist.ts的maybeRestampToLedger冷加载时调一次,#4316 PR3/3)。它把每个有存储行的 raw id 强制改成ref = 行 seq + 1。而活进程按到达顺序发号、且受保护消息不占号但行 seq 照发 → 两者天然漂移;一旦有折叠,漂移落在折叠区。于是首次冷加载把折叠区 mNNNNN 整体重排(你的restamped=14 rehomed=7)。state.refLedgerAligned一次性置真,所以第二次重启不动(你的对照:byte-identical)——完全对上。关键:内部引用有superseded翻译表兜底(不串号),但出站字节本身变了,上游前缀缓存存的是旧号,这一次必 miss(chat2 首启 cache_n=0 即全 miss)。我之前的 msgId 钉住/退回翻转、snapshot-render 两例在你这套配置确实不适用(pluginLaneServerHistory=off、无 unified store),排除正确。Edit = 三例都不是,是「孤儿块保留后被散绘」。 编辑裁掉带
compress调用的那轮后,src/fold-reconcile.ts正确地保留了块(内容还要用,#3724「nothing reaped」),但 carrier(那个 tool call)没了之后,渲染层把块摘要退化成一条条独立 user 角色消息acp_summary_bN插在每条 user 后面 → 前缀从第 2 条起就变。期望(#1921):跨度已没就丢块回原始前缀,或按编辑前的画法稳定锚定——现在是第三种「散绘」,坏的。根因层次:两条都是「活进程已发出并被上游缓存的出站形态,在重启/编辑后被 bili 单方面改写」。Part 1 是引用号被对账重排,Part 2 是折叠块换了画法。都违反前缀字节稳定契约,但都不产生脏读/串号(有翻译表/保留兜底)→ 成本回归,判 p2(高段:折叠会话首启是全 miss)。
修法 + 需要你拍板(两处核心都在 kernel/,按 Kernel Boundary 需你批准才能动):
行 seq+1铸造(stamped 车道的 ledgerSeq 路径已经这么干),非 stamped 车道也改用同一基线,从源头消除计数/seq 漂移 → 冷加载对账发现全部已对齐(kept=N restamped=0),缓存永不对账破。代价:改 kernel assignRefs 发号基线,需配「跨进程首启 byte-equality」回归测试。superseded已兜内部串号,纯为缓存保守又留 store-seq 查找不一致的正确性风险,比 A 绕。fold-reconcile的「保留 vs 丢弃」决策 + kernel 渲染(prune/hide-consumed)的锚定位置。我推荐:Part 1 走 A,Part 2 走「跨度没了丢块、否则稳定锚定」。两处都要动 kernel,请给 kernel-change 的 go-ahead;我本地 master 落后 v0.1.211 共 585 提交,批准后我会 fetch 当前 master、开分支、实现并补 byte-equality/wire-parity 回归测试再提 PR(人工 review)。
结构化八问
收工判定:暂不收工——两条机制已在 v0.1.211 代码层钉死并对齐你的实测数据,severity=p2,标签 bug/pi/scope/kernel/p2/needs-decision 已挂。下一步卡在你:请给 kernel-change go-ahead,并在 A/B/C 里定 Part 1 方向(我推 A)、确认 Part 2 走「丢块或稳定锚定」;定了我就 fetch 当前 master 开工提 PR。
ranxianglei commentedon Nov 11, 2026
(ai: qwen/qwen3.8-27b)
Merge conflict resolved and rebased onto current master. Summary of this run:
mergeable_state: dirtyvs master @0d0c0b95; head branch2026-11-02_step-window-char-bound-poison@8222bc4152.origin/master(58a87962) — 4 commits replayed, single conflict intests/issue5134-post-fold-credit.test.ts(T5).350_000/estimate) to the calibrated per-send estimate (190_000/estimate); master's 2026-11-08 burst fix rewrote T5 to the opposite doctrine — pending credit outranks cycle-staleness (180_353/usage). In the merged code the credit-pending override (src/server.ts:2956) executes before this PR's new anchorStale branch (src/server.ts:3012–3015), so for T5's exact state (credit = 89_096 pending) the merged code returns180_353/usage— master's assertion is the correct one. Kept master's T5 verbatim. Nothing of this PR's intent is lost: its calibrated-ride-on still governs stale anchors without pending credit, which its own suite pins.tests/issue5134-post-fold-credit.test.ts,tests/issue5500-char-bound-poison.test.ts,tests/issue4383-stale-usage-anchor.test.ts,tests/issue5293-abort-nudge.test.ts,tests/issue5415-absorb-debt.test.ts→ 38/38 pass;npm run typecheckclean. Full-suite verdict is left to CI on the pushed head.git fetch originimmediately before push — remote head unchanged (8222bc4152, no concurrent agent activity), so lease-guarded force-push of the conflict-rebase was safe:8222bc4152 → 8dede7d140.mergeable: true,mergeable_state: blocked— conflict gone; now waiting on CI + approval as usual. No red-CI fixing was needed this run (the blocker was purely the dirty merge state).中文结构化结论(八问)
0d0c0b95切出后,master 侧的 2026-11-08 burst fix 改写了同一测试 T5(tests/issue5134-post-fold-credit.test.ts),与 PR 对同一断言的细化形成语义级冲突——不是文本撞行,而是两条分支对「credit 挂起 + 锚点周期过期」这一场景给出了相反的裁决教义。src/server.ts:2956)先于 PR 新增的过期锚点骑乘分支(src/server.ts:3012–3015)执行,T5 场景下真实返回值就是 master 断言的180_353/usage;PR 的校准估计骑乘行为在 credit==0 路径完整保留并由其自有测试套件钉住。判定:冲突已清除、定向验证通过,PR 现处于正常等 CI/审批状态,本轮收工。