Skip to content

fix: yield the first-user pin when dropping it cannot lose the user lead - #490

Closed
ranxianglei wants to merge 1 commit into
masterfrom
2026-10-01_first-user-pin-yields-coverage
Closed

ranxianglei wants to merge 1 commit into
masterfrom
2026-10-01_first-user-pin-yields-coverage

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

Fixes #489 (found while analyzing billion-context#1869)

What

rebuildMessages pinned the first user message unconditionally ahead of the covered check. Once a covered message became the view's first user (client trimmed earlier history before resending, or the fold targeted the opening message), its coverage was defeated forever: syncBlocks kept the block active because the base id was still present pre-prune, so the folded payload re-sent itself verbatim every turn while every fold reported success.

Behavior change (old → new)

  • Old: first user message always survives prune, even when covered by an active block — coverage of that message is silently void for the life of the session.
  • New: a covered first user drops when a plain user text core survives behind it (firstUserDropSafe: earliest non-covered, non-rendered-summary follower must be role user AND contentType text — the only shape the downstream orphan-stripping passes cannot remove). The rebuilt wire then opens with that surviving user (rendered summary anchored in front, re-voiced/stripped downstream per mode).
  • Unchanged: shapes where no user would lead after the drop ([U1-covered, A1, …], sole-user sessions) keep today's pin — shipping a non-user-leading frame would reject strict providers, which outweighs the compression gain. Documented as a residual limitation in DESIGN.md §8.1.

This is a wire-shape change on purpose: frames lose only a previously-pinned covered message whose content an active block has summarized. No ref/id churn (byRef untouched, ids never re-issued), no summary-format change, no node-ordering change.

Why kernel-side

The producer of the bad bytes is the rebuild step; adapter-side detection could only deactivate the stale block (mask) while the payload still occupies context.

Pre-flight

  • npm run typecheck clean
  • Targeted tests green: all 11 test files importing src/prune.js (121 tests) + issue-regression family (21), incl. the rewritten/added regression tests:
  • npm run build + npm run format:check green
  • Full suite left to CI per local-testing discipline
  • Second-agent review note: the oracle reviewer agent errored out twice in this environment (infra failure, 0s duration); the author ran an adversarial self-pass over anchor-placement, orphan-stripping, remint/ref-space interactions — human review carries the second-review requirement for this PR.

Sequencing

Kernel ships first (release v0.0.100 via the standard flow), then billion-context bumps its exact pin and lands the Responses WS lane regression test that reproduced this end-to-end.

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

📦 Built Package Artifact

Branch: 2026-10-01_first-user-pin-yields-coverage (e593fa8)

Option A — Install from npm PR tag (recommended)

In your adapter project:

npm install acp-kernel@pr-490

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

Option B — Download artifact

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

This comment is automatically updated on each push.

The pin in rebuildMessages fired before the covered check unconditionally,
so once a covered message became the view's first user (client trimmed
earlier history before resending, or the fold targeted the opening
message), its coverage was defeated forever: the block stayed active while
its payload re-sent itself verbatim every turn, reporting success
(billion-context#1869).

The pin now yields only when another user message survives behind it
(firstUserDropSafe: earliest non-covered, non-rendered-summary follower
must be a user). Degenerate shapes where no user would lead keep today's
behavior, documented in DESIGN.md \u00a78.1.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 PR #490 review 完成 — 审的是最终 head e593fa8(注意:head 在我初审后从 ac4571d amend 过一次——firstUserDropSafe 的判定从 role === "user" 收紧为 role === "user" && contentType === "text",并新增 stale-summary 回归测试。amend 是改进,方向正确,见下)。CI 在 e593fa8 上全绿(pr-validation / test(22) / test(24) / build-artifact)。本地定向验证:typecheck 干净、160/160(11 个 import src/prune 的测试文件 + issue-regression family + issue1476)、build + format:check 绿、工作树干净。

结构化七问:

  1. 是什么问题:fold 覆盖会话首条用户消息后,该消息被无条件钉在 wire 上逐轮原样重发,而 block 保持 active、每次 fold 都报成功——压缩静默失效(prune: unconditional first-user pin defeats fold coverage when a covered message becomes the view's first user #489,billion-context#1869 端到端复现)。

  2. 什么原因导致的:rebuildMessages 里 pin 检查排在 covered 检查之前且无条件触发。pin 的本意是属性保证("输入有 user ⇒ 输出至少一个 user",strict provider 拒绝 0-user 帧),实现却过度保证为"钉住那条具体的首消息"。一旦某条 covered 消息变成视图的首 user(client 裁剪历史后重发,或 fold 直接打开场消息),它的 coverage 被永久击败。病因定位准确:修的是机制(pin 语义过强),不是症状。

  3. 怎么修复的:pin 改为条件让位——covered 的 pin 仅当其后最早的非 covered、非 rendered-summary 跟随者是纯 user text core(firstUserDropSafe)时放行丢弃,重建 wire 由该存活 user 领衔(rendered summary 锚在其前)。退化形态(后面没有 user 能领衔)保持现状 pin,记入 DESIGN.md §8.1 residual limitation。e593fa8 的收紧是对的:若只判 role,一个 user-role 的 tool/reasoning core 理论上可被下游 orphan-stripping 再删掉,导致帧失去 user 领衔——只有 plain text user 是不可再删的形状。我核对了全部 codec:anthropic/openai/responses/google 的 tool-result core 一律 role: "tool",现有 wire 形状下首 user 不可能是 tool result;收紧属于对 host 构造 CoreMessage 的防御性加固,失败方向保守(宁可不压,不产出非法帧)。

  4. 是否有回归问题:对 prune 触碰路径的既有行为逐项清点——

    • 非首 user 的 covered 丢弃:不变(同一 covered-skip 路径)。
    • 未 covered 的首 user pin:不变(!isCovered 短路)。
    • covered 但无 user 可领衔:不变,pin 保持——state-prune 退化用例 + orphan-fixes fixture([u0,a0,u1,a1],block 覆盖 u0/a0/u1,后续仅 a1 assistant → pin 保持)双重钉住;orphan-fixes 本次仅改断言措辞,fixture 行为未变,已核实。
    • summary 锚定(collectSummaryAnchors/pairSafeAnchorIndex)、stale summary 去重:不变,新测试把"stale 副本夹在 pin 与存活 user 之间 → 单份新渲染 summary + m3"钉死。
    • 工具对原子性(§7.3 fix: hard-enforce protection of recent messages + last user message #5):保持——新丢弃路径与既有 covered 丢弃走完全相同的下游清理(stripOrphanedToolCalls/Results/Reasoning),无新失败类别;更严的门进一步缩小风险面。
    • 无其他代码/测试依赖旧无条件 pin:CI 全套绿。
      唯一行为变化即修复本身(PR 正文已披露 old→new),未发现静默漂移。无回归。
  5. 是否破坏缓存:否。byRef 不动、id 永不重发(issue1476 两遍 processTurn 断言 m00001 ref 稳定)、存活消息的 token 快照与 summary 格式不变。受影响帧变短(不再重发 pinned payload)是预期收益,不是失效churn。

  6. 是否改了设计方向:否。管线、节点序、block 生命周期均未动(syncBlocks 仍按 pre-prune 存在性保活 block——现在 prune 真的删掉 payload,这才是正确闭环)。kernel 侧修复层次判断成立:坏字节的产地就是 rebuild,adapter 侧去活 block 只是掩盖,payload 仍占 context。

  7. 是否引入了新的配置:不涉及。

测试有效性:新/改写测试断言有牙(deepEqual 精确 id 序列;两遍 processTurn e2e 走完整管线含 syncBlocks,断言 payload 文本消失 + ref 稳定,正是 #1869 的复现路径;退化变体保住 #462 的 id/ref 稳定性覆盖)。diff 整洁:相对当前 master 仅 5 个文件(DESIGN.md、src/prune.ts、3 个测试文件),无版本/lock 连带变更。

判定:可合(按规矩由人合并)。Sequencing 提醒:kernel 先发 v0.0.100,之后 billion-context 升精确 pin 并落 Responses WS lane 回归测试。

@ranxianglei

Copy link
Copy Markdown
Owner Author

设计内的
不需要修复
bug 本身:会话的第一条消息被设计成"永远保留在请求头位置"(这是故意的,为了前缀缓存稳定,本身没错)。但麻烦在于——当这条消息恰好被压缩折叠了之后,代码仍然把它的全文硬塞进每次发给模型的请求体
里。也就是说:这次压缩等于白干——摘要生成了、块也折叠了,可原文一个 token 都没省下来,而且全程不报错不提示,纯静默白干。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant