Skip to content

prune: unconditional first-user pin defeats fold coverage when a covered message becomes the view's first user #489

Open

Description

@ranxianglei

来源: billion-context#1869 (ranxianglei/billion-context#1869) 分析 ACP fold 静默失效时发现

Symptom

When a folded message occupies (or becomes) the session's first user message in the resent view, the fold reports success ([Compressed mX–mY → 1 block(s)]) and the block stays active, yet the rebuilt wire keeps carrying the covered payload verbatim on every subsequent turn. No compression benefit ever materializes while the client believes compression happened.

Two trigger shapes, same mechanism:

  1. Divergent resend: the client trims earlier history before resending without previous_response_id; an earlier-folded message shifts into the first-user slot.
  2. Direct fold of the opening message: protected-zone rules are tail-based, so folding the current first user is allowed, and its coverage is void from creation.

Root cause

In rebuildMessages (src/prune.ts) the first-user pin check runs before the covered check unconditionally:

if (index === firstUserIndex && firstUserIndex >= 0) { result.push(messages[index]); continue; }
if (isCovered(messages[index].id, coveredBases)) continue;

The pin's stated intent (DESIGN.md §8.1) is a property — "the rebuilt wire retains at least one user message whenever the input had one" (strict providers reject 0-user conversations). The implementation over-guarantees by pinning the specific first message. When that message is covered, syncBlocks keeps its block active (the base id is present in the pre-prune view), so the payload re-sends itself forever.

Evidence (deterministic, A/B)

Reproduced through the billion-context Responses WS test harness (fake upstream + real proxy, OpenCode V2 plugin lane, defect-free full terminal mode):

  • Shape: seed user → bulky sentinel user (~12 KB) → 5 plain users; fold the sentinel (success, block active); next turn the client resends full history without the seed (sentinel now first user).
  • Seed dropped: rebuilt frame retains the covered sentinel verbatim, no summary benefit — exact #1869 signature.
  • Seed kept (control): sentinel correctly pruned from the same fold state. Single variable isolated.
  • Tool pairs in the conversation are a red herring: they only correlated via a separate checkpoint defect (#1866) that also shifts the first user.

Proposed fix

Make the pin conditional: a covered first user drops when another user message survives behind it (earliest non-covered, non-rendered-summary follower must itself be a user — firstUserDropSafe). Degenerate shapes where no user would lead keep today's behavior (documented residual limitation in DESIGN.md §8.1). No ref/id churn, no wire-artifact format change, no node-ordering change.

Consumer note

billion-context consumes this via its bundled pin; the adapter-side regression test (Responses WS lane) lands after this ships. Wire-shape change is intentionally narrow: frames lose only a previously-pinned covered message that another surviving user makes redundant.

Activity

  1. ranxianglei commented on Oct 1, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    已完成修复并开 PR:**https://github.com/ranxianglei/acp-kernel/pull/491**(分支 2026-10-01_first-user-pin-drop,commit 328de51,基于当前 master e7e1614,未合并——合并留给人)。查重:全仓 open/closed 无同类条目,非重复。

    结构化七问

    1. 是什么问题:当某条被 fold 覆盖的消息成为(重发视图中的)第一条 user 消息时,fold 报成功、block 保持 active,但重建后的 wire 每轮仍逐字携带被覆盖的 payload——压缩收益永远不兑现。两种触发形态(divergent resend 把早先折叠的消息挤进 first-user 槽位;直接折叠开场消息)同一机制。

    2. 什么原因导致的:根因是「属性」与「特定消息」的混淆。pin 的设计意图(DESIGN.md §8.1)是个属性——"只要输入有 user,重建 wire 就至少保留一条 user"(严格 provider 拒绝 0-user 会话);但 rebuildMessages 的实现把它焊死在特定的第一条消息上:pin 检查无条件先于 covered 检查执行。当该消息恰好被覆盖时,syncBlocks 因 base id 存在于 pre-prune 视图(定义上必然存在——它就是视图的第一条 user)而保持 block active,payload 遂永久重发。因果分层:表面现象 = fold 静默失效;机制层 = pin 过度保证;契约层 = DESIGN.md 写的是属性,代码实现的是实例。

    3. 怎么修复的:把 pin 变成条件式(src/prune.ts):仅当「covered 的第一条 user 若丢弃将导致没有 user 能领头」时才钉住。判据 firstUserDropSafe:扫描其后最早会存活的非 covered、非渲染摘要消息,必须是 user 才可丢;渲染摘要跳过是因为四个 wire codec 全部把 system 消息提升到系统通道(openai-chat/anthropic/google/responses 已逐一核实),leading summary 不会破坏 user-led 约束。退化形态(最早存活者是 assistant/tool,或无后续存活者)保持旧行为,作为 DESIGN.md §8.1 记录的残余限制。无 ref/id churn、无 wire-artifact 格式变更、无节点顺序变更。

    4. 是否回归问题:审过既有行为并逐项确认——① uncovered 首条 user:照旧存活(covered 检查本来就放行);② 退化形态(assistant-led 后继 / 首条 user 后无任何存活 user):行为与修复前逐字节一致,orphan-fixes.test.ts 既有 pin 测试原样通过;③ 孤儿 tool-result 不会领头(其 call id 缺失时结果本身被 strip);④ sub-id 投影(prune: isCovered misses base-id coverage when block effective ids use sub-id forms (base#suffix) #231)按 base 归一化,覆盖判定不变;⑤ blockVisibleInRange 逻辑未动,对 pinned 退化形态仍正确。本地定向验证:typecheck ✓、build ✓、format:check ✓、触及路径全部相关测试 174/174 ✓(prune/pipeline/boundaries/sync/protected-zone/refold/range-coverage 等);全量回归交 CI。新增 tests/regression-first-user-pin.test.ts:7 个单元形态 + 2 个 pipeline A/B(processTurn+applyCompression,覆盖 issue 描述的两种触发形态,含 seed 保留对照组)。

    5. 是否破坏缓存:不涉及。ref/id 不重发不回收,summary 锚点位置(pair-safe anchor)机制未动,block 生命周期(sync/syncBlocks)未动;唯一变化是帧里少了一条本来就该被剪掉的 covered 消息。

    6. 是否改了设计方向:不涉及架构转向。设计方向本来就是属性式 pin——本次是把实现拉回文档声明的属性,并在文档中显式记录退化形态的残余限制。

    7. 是否引入新配置:不涉及。无新 env/config/默认值。

    ⚠️ 行为变更披露(旧→新):旧 = covered 的首条 user 无条件保留,payload 在 block active 期间永久重发(#489/#1869 签名);新 = 只要其后还有存活的 user 消息,该 covered 首条 user 即被正常剪掉,重建帧只丢失这条此前被错误钉住的 covered 消息,其余内容不变。consumer 侧:billion-context 经 bundled pin 消费此变更;wire 断言从「必须期望 summary + pinned 原文」放宽为「另一 user 存活时期望仅 summary」。adapter 侧回归测试(Responses WS lane)按计划在本 PR 落地后再上。

    收工判定:代码/测试/文档齐备,本地定向验证全绿,PR 已开且引用本 issue(Fixes #489),等待 CI 与人工 review/merge。

  2. added a commit that references this issue on Oct 1, 2026
  3. ranxianglei commented on Oct 1, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    CI 跟进(透明记录):首推 commit 328de51 的 CI 全量回归(Node 22,1217 测试)抓到 1 个失败——tests/issue1476-reid.test.ts 的 #462 e2e("first-user pinned echo keeps its id and ref across post-fold passes")。

    定性:不是修复引入的行为回归,而是该既有测试把旧契约(无条件 pin)钉死在了一个 drop-safe 场景里(被覆盖的首条 user 后面还有存活的 user)。按新契约这条消息本就该被剪掉——修复行为是对的,测试期望过期了。这正是本地定向集没覆盖到该文件、全量 CI 兜住的价值所在。

    处置(commit b36d622,已推):把该 e2e 移到退化形态(pin 仍生效的形状:首条 user 被覆盖、后继是 assistant),它原本要守护的 #462 关切——pinned echo 跨 pass 的 id/ref 稳定性——继续被追踪;DESIGN.md §8.1 的测试清单同步补上引用。

    当前状态:PR #491 head b36d622,CI 四项全绿(test 22 / test 24 / pr-validation / build-artifact)。等待人工 review + merge。

  4. ranxianglei commented on Oct 2, 2026

    @ranxianglei
    OwnerAuthor

    三层核查结论:首条消息在选区层没有任何保护(记录备查,暂不实施)

    讨论中存在一个关键分歧:「第一次压缩时首条消息是被保护的,所以第一条压不掉」。逐层查证后确认该保护在任何一层都不存在,证据如下。

    1. billion-context-pi(master 1cb6340,v0.1.83)

    • 全仓 firstUser 仅一处:src/export.ts:48 firstUserText() —— 会话列表标题派生,与压缩无关;
    • 压缩逻辑全部委托 acp-kernel(package.json:70 钉 0.0.98),pi 侧自身没有首条消息逻辑。

    2. acp-kernel 选区层(src/recommend.ts)

    • 保护面 = ALWAYS_PROTECTED_TOOLS(compress / acp_rule 载体)+ 尾部窗口(preserveRecentMessages / preserveRecentTokens + 最后一条 user);
    • buildCompressibleRanges 无头部排除:开场消息只要够大且不在尾部窗口,会照常进入推荐 range;
    • PR fix: make first-user pin conditional so covered first users prune (#489) #491 的 pipeline 测试「direct fold of the opening message」直接对开场消息 applyCompression 且内核接受(block 创建、active),证明选区层不会拒绝。

    3. acp-kernel wire 层(src/prune.ts)

    • 唯一存在的「保护」= 无条件 first-user pin:覆盖之后字节仍留在 wire —— 这正是本 issue 告的静默失效本身;
    • 也就是说,观察到的「首条永远压不掉」是 pin 的影子(fold 报成功、payload 每轮回流),不是设计出来的选区保护;DESIGN.md §8.1 描述的也是这个 wire 层 pin,容易被读成「首条受保护压不掉」。

    建议(影响面大,先评论记录,不动代码,待拍板)

    1. fix: make first-user pin conditional so covered first users prune (#489) #491 照合:它修的是形态①(截头重发让已折叠消息上位为视图首条 user)的「报成功不生效」,与下条正交;
    2. 若设计意图是「首条不该被压」,自洽做法是选区层硬保护:range 校验拒绝覆盖首条 user(或将其剔除出 effectiveMessageIds),让「首条永远逐字在 wire」机械成立,T2/T3 蒸馏也无从转述首条内容;代价 = 特别大的开场消息永久保留,需要作为有意识取舍接受;
    3. 备选:尺寸门控 pin(小于阈值钉住、超大的照常剪,把「质量锚 vs 体积」权衡直接编码)或纯提示词软锚(引导选区避开首条,无硬保证);
    4. 时间线:bc-pi 捆 0.0.98,无论哪案,pi 腿都要等 billion-context-pi 升 kernel pin 后才生效。
  5. ranxianglei commented on Oct 2, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    三层结论我逐条对内核代码复核过(kernel 侧我有责任自己验证,不只采信转述):

    复核结果

    1. 选区层 ✓:computeProtectedRefs(src/recommend.ts:66)三条规则 = 尾部 N 消息(preserveRecentMessages)/ 尾部 N tokens 回扩 / 最后一条可见 user(与 preserveRecentMessages > 0 耦合,置 0 全关)。无任何头部排除;buildCompressibleRanges 同理。开场消息够大且不在尾窗就进推荐 range。
    2. wire 层 ✓(针对合并前 master 的描述准确)。补一个时态更新:fix: make first-user pin conditional so covered first users prune (#489) #491 合并后 pin 是条件式的,「首条压不掉」的影子收缩到退化形态(最早存活者非 user / 无存活后继,DESIGN.md §8.1 记录的残余限制)——正常形态下该现象将完全消失。
    3. bc-pi 侧(v0.1.83 / 钉 0.0.98)未替你复跑,与你陈述及已知事实一致。

    另有一个对你选项 2 有利的结构事实:protected zone 是选区推荐与手动 fold 的共享校验点——applyCompression(src/compress.ts:~1280)对同一 zone 做软剔除(从 range/effectiveMessageIds 剔除 + warn,全被保护则 throw)。所以「首条硬保护」不需要新子系统:把首条塞进这个 zone,自动推荐和模型手动 compress 同时命中,且语义天然自洽——剔除出 effectiveMessageIds 后 block 不覆盖它,prune 按普通未覆盖内容保留,#489 式的「报成功不生效」影子在机制上不存在。

    我的建议(明确立场,供拍板)

    1. fix: make first-user pin conditional so covered first users prune (#489) #491 照合(同意你第 1 点):正确性修复与策略问题正交,互不等待。
    2. 反对把「首条硬保护」做成静默默认。理由:历史上它不是设计意图——§8.1 记载的 pin 目的只有 provider 格式约束(拒绝 0-user 会话),「保护」是 bug 的影子而非功能;把影子升格为默认可选语义 = 改变所有带大开场消息的会话的行为(永久驻留、最坏成本无界),这种取舍必须显式拍板,不该由实现细节隐式继承。
    3. 若你要 task-anchor 语义(首条永远逐字在 wire)→ 选方案 2(选区层硬保护),否决 3/4:尺寸门控引入阈值配置项 + 不连续行为,属产品参数,我没有依据替你选数;纯提示词软锚无机械保证、污染 nudge 文本,只配当过渡。方案 2 落地形态:接入现有 protected zone(唯一共享校验点),沿用软剔除语义;身份定义为 session-origin(state 中最早签发的 user ref,跨 divergent resend 稳定;host 真把它裁掉则视图里已无此条,保护自然落空)。两个边界问题需一并定:① 与 emergency-truncate 的交互(极端压力下安全阀是否允许切锚——我倾向允许,truncate 是保命阀);② 超大开场消息永久驻留的成本作为有意识取舍接受。
    4. 流程:此事独立立项(来源标本 issue),不混入 fix: make first-user pin conditional so covered first users prune (#489) #491(loop guard);你拍板后我开新 issue 实现。时间线第 4 点同意:无论哪案,pi 腿等 billion-context-pi 升 kernel pin 后生效。

    一句话:#491 先合(修正确性);「首条要不要永远压不掉」是独立产品决策,要就做选区层硬保护(session-origin + 软剔除 + 两个边界定案),不要就维持现状(#491 后的条件 pin)。

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