Skip to content

fix: recover fork compression blocks from parent state when historical compress inputs are stripped - #376

Open
ranxianglei wants to merge 4 commits into
masterfrom
2026-09-09_fork-parent-state-transfer
Open

fix: recover fork compression blocks from parent state when historical compress inputs are stripped#376
ranxianglei wants to merge 4 commits into
masterfrom
2026-09-09_fork-parent-state-transfer

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Related to #375defensive layer, not the confirmed root-cause fix.

Status note (2026-09-09): A maintainer audit of the opencode fork path found that the issue's premise (forks set parentID / strip state.input) is not verifiable in any inspectable opencode version — fork copies parts verbatim and never sets parentID (only v2 subagent creation does) — and that the quoted reason=no_executable_candidates log line is emitted only by the unmerged #341 adaptive-candidates patch, not by released upstream ACP. This PR is therefore a defensive layer for the case where a session carries parentID and has lost its historical compress inputs: it loads the parent's ACP state, maps IDs across the copied shared prefix, and transfers the blocks, falling back to the existing historical replay whenever the transfer isn't possible (so it is inert otherwise). It is not the confirmed fix for #375; resolving that issue still needs the exact build, the original log, and the session's plugin/acp/{sessionId}.json. See the audit in issue #375.

What it does

In the fork/subagent init path, when a session has a parentID and no fork-local state file, ACP now:

  1. Loads the parent's persisted ACP state.
  2. Builds a parent-raw-ID → fork-raw-ID mapping over the copied shared prefix, matching the two message lists by position + time.created + role (not by ref — a session classified as a sub-agent has its mNNNNN refs shifted by one relative to the parent, so a ref-based translation would point blocks at the wrong messages).
  3. Translates the parent's compression blocks onto the fork's IDs (directMessageIds/effectiveMessageIds, tool call ids preserved verbatim, anchorMessageId/compressMessageId, and best-effort startId/endId refs).
  4. Saves independent fork-local state.

It falls back to the existing historical replay whenever the transfer is not possible (no parent state, no shared prefix, no blocks, fetch failure, or no active block translatable).

Guarantees: the parent state is never mutated; parent nudge cadence / current-turn state is not inherited; blocks anchored outside the copied prefix are skipped (partial forks / parent continued after fork).

Changes

  • lib/state/fork-transfer.ts (new) — recoverFromParentState() + helpers (buildSharedPrefixMapping, translateBlock, remapBoundaryRef, translateByMessageId).
  • lib/state/state.ts — init path tries parent transfer first, then falls back to rebuildCompressionState.
  • lib/state/utils.ts — new getForkParentId(); isSubAgentSession reuses it (same boolean result).
  • tests/rebuild-parent.test.ts (new) — 7 tests: stripped-input recovery, ref-shift robustness, three fallbacks, independence (no nudge inheritance / no parent mutation), and the out-of-prefix skip path.
  • devlog/2026-09-09_fork-parent-state-transfer/ — REQ / WORKLOG / DESIGN.

Verification

  • npm run typecheck — clean
  • npm run build — success
  • npm run test — 1084 pass / 0 fail (was 1077; +7 new)

Dual-agent review (code + tests) completed; findings addressed (tool-call-id fidelity, active-only state assignment, best-effort boundary-ref documentation, added test coverage).

…al compress inputs are stripped

Forks copy the parent's message history but often strip the historical
compress tool inputs, so the replay-only reconstruction rebuilt zero blocks
and the copied raw parent history overflows the fork's context (issue #375).

Add a parent-state transfer path for fork init: when a session has a
parentID and no fork-local state, load the parent's ACP state, map parent
message IDs to the fork's IDs across the copied shared prefix (matched by
position + time.created + role, robust to the one-ref shift forks get from
being classified as sub-agents), translate the parent's blocks onto the
fork's IDs, and save independent fork-local state. Falls back to the
existing historical replay when the transfer is not possible.

- lib/state/fork-transfer.ts (new): recoverFromParentState + helpers
- lib/state/state.ts: fork init tries parent transfer, then replay fallback
- lib/state/utils.ts: getForkParentId; isSubAgentSession reuses it
- tests/rebuild-parent.test.ts (new): 7 tests incl. ref-shift + fallbacks
- devlog/2026-09-09_fork-parent-state-transfer/

Does not mutate the parent; does not inherit nudge cadence / current-turn state.
@github-actions

github-actions Bot commented Sep 9, 2026

Copy link
Copy Markdown

📦 Built Plugin Artifact

Branch: 2026-09-09_fork-parent-state-transfer (8649ce4)

Option A — Install from npm PR tag (recommended)

opencode plugin opencode-acp@pr-376 --global

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

Option B — Install from GitHub

opencode plugin "github:ranxianglei/opencode-acp#2026-09-09_fork-parent-state-transfer" --global

Option C — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf opencode-acp-pr376.tgz
cp -r package/dist ~/.cache/opencode/packages/opencode-acp@latest/node_modules/opencode-acp/dist
  1. Restart opencode to pick up changes.

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

解决冲突,并简述本issue解决了什么问题

ework-agent added 2 commits September 9, 2026 20:29
…t-state transfer)

Both changes coexist in ensureSessionInitialized(): master's storagePath
default-location warning runs first, then the PR's fork recovery path
(parent-state transfer → historical replay fallback).
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

✅ 冲突已解决,已推送到 2026-09-09_fork-parent-state-transfer 分支(PR 现为 mergeable,无冲突)。

冲突详情:将 master(含 #380 storagePath)合入 PR 分支,仅一处冲突 —— lib/state/state.tsensureSessionInitialized():本 PR 的 fork 恢复注释块与 #380 新增的 storagePath 默认位置警告重叠。解决方式:两者都保留 —— storagePath 警告先执行,随后进入 fork 恢复路径(parent-state transfer → 历史 replay 兜底)。两侧行为均无变化。

合并后验证npm run typecheck clean;npm run build 成功;npm run test1138 pass / 0 fail


本 PR 解决了什么问题(Fixes #375):

OpenCode fork 会话时会获得新 session ID 和复制的消息历史,但没有 fork 本地的 ACP 状态文件。ACP 原本只能靠重放历史 compress 工具调用(且其 state.input 幸存)来重建压缩状态 —— 而 fork 副本会剥离这些 input,导致重建出 0 个 block,复制来的父会话原始历史全部可见,token 用量暴涨(实测 20,581 条消息 / 925K tokens,远超 400K 上限)。

修复:当会话有 parentID 且无 fork 本地状态时,ACP 现在会:

  1. 加载父会话的持久化 ACP 状态;
  2. 在共享前缀上建立 parent→fork 消息 ID 映射(按 位置 + time.created + role 匹配,而不是 ref —— fork 被归类为 sub-agent,其 mNNNNN ref 相对父会话偏移一位,按 ref 翻译会把 block 指向错误消息);
  3. 把父会话的压缩 block 翻译到 fork 的 ID 上;
  4. 保存独立的 fork 本地状态。

若翻译不可行(无父状态 / 无共享前缀 / 无 block / 拉取失败 / 无 active block 可翻译),回退到原有的历史 replay。父状态永不被修改,也不继承父会话的 nudge 节奏;锚点在共享前缀之外的 block 会被跳过(部分 fork / fork 后父会话继续推进的场景)。

@ranxianglei

Copy link
Copy Markdown
Owner Author

Review: REQUEST_CHANGES — 3 majors (one empirically confirmed), plus test-coverage gaps

First: the premise of this PR is currently unverified — see the audit on #375 (comment). No inspectable opencode version (sst dev @ v1.18.30, 2.0 branch, opencode-stable full history) strips state.input on fork copy or sets parentID on fork sessions, and the no_executable_candidates log line quoted in the issue originates from a downstream fork's #341 patch, not from upstream ACP. Suggest we confirm the premise with the reporter before treating this as the fix for #375. That said, parent-state transfer is a reasonable defensive layer on its own merits — here's the review of the implementation as it stands.

Design verified good 👍

  • Parent immutability (fresh parse, never passed to saveSessionState; test asserts on-disk file unchanged)
  • Fallback safety — every failure path returns 0 before touching prune state; no half-translated state saved
  • Counter/ID handling — loadPruneMessagesState rebuilds nextBlockId/nextRunId as max+1, bN refs preserved, dangling references null-guarded at consumers
  • Raw-ID mapping is immune to the sub-agent ref shift; O(n) throughout

Major findings

M1 — lib/state/fork-transfer.ts:297-305: the exact #375 scenario still fails. When the parent compressed again after the fork point (nested), the in-prefix blocks are all active:false (consumed by a block anchored outside the prefix) and the newest active block is skipped → activeCount === 0 → fallback to replay → replay yields 0 on stripped inputs → runaway persists. Empirically confirmed with a probe (parent: b1 m1-m4 consumed by b2 m1-m8, fork copies m1-m5 stripped → recovered: 0). The comment at 297-302 acknowledges this case but bails instead of recovering. Suggested fix: re-activate a translated block when its only deactivation reason is consumption by a block that did not translate — i.e. active = block.active || (block.deactivatedByBlockId !== undefined && !translatedIds.has(block.deactivatedByBlockId)) while keeping user-initiated (deactivatedByUser*) and GC deactivations as-is — then rebuild byMessageId.activeBlockIds from the final active set. In the probe scenario this makes b1 active in the fork, which is semantically correct (b2's superset coverage doesn't exist in the fork's timeline).

M2 — lib/state/fork-transfer.ts:221: storagePath regression. loadSessionState(parentId, logger) omits the storageDir parameter — for users with storagePath configured (#380), the parent's state file lives in the custom dir, the transfer silently finds nothing, and every fork falls back to the broken replay path. Pass state.storageDir (already resolved in ensureSessionInitialized before the transfer call).

M3 — lib/state/fork-transfer.ts:87: prefix match compares only time.created + role. Adjacent same-role messages can share a millisecond timestamp (parallel tool results); a mid-prefix divergence (partial fork, deleted message) where neighbors share (created, role) lets the loop continue misaligned, mapping block coverage onto the wrong messages → prune() deletes messages with no summary covering them (silent corruption). Harden with a cheap content fingerprint (parts count/type, or first-text hash — note the reporter's own fork-restore uses a sha256 fingerprint of role+parts with compress tools normalized) and/or sanity-check the matched fraction.

Minor

  1. fork-transfer.ts:252 vs :260 — parent list goes through filterMessages but the fork list doesn't; filter both identically so one side can't shift the lists.
  2. fork-transfer.ts:251 — every true sub-agent (parentID set, no copied history) now fetches the parent's full message list just to discover mapping.size === 0. In the 20K-message scenario that's a large payload per spawned sub-agent; consider a cheap pre-guard or document the one-time cost.
  3. fork-transfer.ts:129-130 — remapping startId/endId into fork ref space breaks the rangeKey ↔ copied state.input.content[] match in hide-consumed.ts:26-27,67-69 (copied inputs still carry parent-space refs), so partially-consumed batch calls can no longer be rewritten — consumed siblings' summaries leak (conservative, not corruption). Either keep parent-space refs to match the copied inputs, or rewrite both sides.
  4. fork-transfer.ts:323 — after transfer the summary text exists only in persisted block.summary; the copied compress part's output is the generic "Compressed messages into …" line, so the fork's model has no in-context summary where the parent had one (search_context/decompress still work). Consider rewriting the copied part's state.output to embed block.summary at transfer time.
  5. lib/state/utils.ts:65getForkParentId returns "" unchanged when the API sends an empty-string parentID; old isSubAgentSession (!!parentID) treated that as false, the new one (!== null) treats it as true while the caller's if (parentId) gate treats it as absent — normalize with result.data?.parentID || null.

Test coverage gaps (per §5.6/§5.7)

  • tests/rebuild-parent.test.ts:29buildConfig() is missing 4 required CompressConfig fields (minNudgeGrowthRatio, minNudgeGrowthFloor, emergencyThresholdPercent, keepEmbedMaxChars; confirmed via tsc — masked because tests aren't in tsconfig include).
  • The two load-bearing fallback guards have zero mutation coverage: deleting the activeCount === 0 bail (fork-transfer.ts:304) or the catch → return 0 around the parent fetch (:247) keeps all 7 tests green — the latter would break session init for every fork if regressed.
  • No integration test exercises ensureSessionInitialized's fork path (transfer-before-replay ordering, independent fork-local state saved, first transform actually pruning). The M1 scenario above (nested parent compression → currently 0 recovered) should become the regression test for the fix.

Suggested path forward

  1. Confirm the fix: recover compression blocks when forks omit historical compress inputs #375 premise with the reporter (version + raw logs) — see issue comment.
  2. Fix M1 + M2 + M3 (M1 is what makes this PR actually solve its target scenario).
  3. Add the fallback-guard and integration tests.
  4. Re-run dual-agent review.

Happy to pair on the M1 re-activation semantics if useful.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 冲突已解决,PR 已与最新 master 同步。

冲突处理

  • 我上次同步后,master 又合入了 v1.16.0 发布(release: v1.16.0 — storagePath config for custom session-state storage location #382,仅涉及 CHANGELOG / devlog / 版本号),与 PR 的 lib/ 改动无重叠。
  • 已将最新 master(de6d325)merge 进 PR 分支并推送(cfa2f1f8649ce4),无冲突。
  • 验证:typecheck 通过、1138 个测试全部通过、build 成功。
  • PR 当前 mergeable: True,已与 base(de6d325)对齐、不再 behind(当前 blocked 只是分支保护在等 CI 检查跑完,非冲突)。

本 PR(#376)解决的问题
关联 #375,定位是防御层,而非已确认的根因修复#375 的前提——fork 会设置 parentID / 剥掉 state.input——在可查的 opencode 版本中无法证实,详见 #375 的审计)。

具体地:当某个 session 带有 parentID 且没有 fork 本地状态文件时(即历史 compress 输入丢失、仅靠 replay 重建不出压缩块、父会话原始历史会整段进入上下文导致溢出的场景),ACP 在 fork/subagent 初始化路径上:

  1. 加载父会话已持久化的 ACP 状态;
  2. 在复制出的共享前缀上,按位置 + time.created + role(而非 ref——被归为子代理的 session 其 mNNNNN ref 会相对父会话偏移一位,按 ref 翻译会把块指错消息)把父会话消息 ID 映射到 fork 消息 ID;
  3. 把父会话的压缩块翻译成 fork 本地块(tool call id 原样保留,anchor/compress 消息 ID 与边界 ref 一并翻译);
  4. 保存独立的 fork 本地状态

无法迁移时(无父状态 / 无共享前缀 / 无块 / 拉取失败 / 无可迁移的活跃块)回退到既有的历史 replay,因此对其它场景是无副作用的(inert)。保证:父状态绝不被修改、不继承父会话的 nudge 节奏、锚点在复制前缀之外的块会被跳过。

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant