Skip to content

[bug] Fork ("new session from here") replays the parent's compress events but loses bili's block state — context re-inflates and search_context goes blind #3925

Closed

Description

@Leeszzz

Description *

What happened

I forked a long bili-compressed session from the DSH Web UI ("在新会话中分支" / fork into a new session). The child session came up with no compressed blocks at all, while the parent's entire history — including its compress tool calls — was replayed verbatim into the child's context.
Net effect: every token bili had folded away came back as raw messages. Context jumped from <10% to ~35% of the window on the first turn of the child session, and search_context / acp_status report "no compressed blocks" so there is no way to recover the old folds from inside the child.

Environment

  • billion-context 0.1.202 (6deb454), DSH host (desktop Web GUI), Windows 11 / pwsh 7
  • storage.unified = shadow (default); no index.db on disk, JSON envelope is the source of truth
  • forkAdoption was not set (so it took the default false); resumeInheritance also not set (default true)
  • Neither BILI_FORK_ADOPTION nor BILI_RESUME_INHERITANCE present in the environment

Steps to reproduce

  1. In a long session, run compress a few times until acp_status shows N active blocks (mine had ~300K original tokens folded).
  2. Use the host's "fork into a new session" action on that session (DSH writes parentSession + isSeeded: true into the child's journal header).
  3. In the child, call acp_status.

Expected

The child inherits the blocks whose source messages are fully present in the replayed context (that is exactly what forkAdoption's doc string promises), or at minimum search_context still resolves the parent's blocks.

Actual

  • acp_status: COMPRESSED BLOCKS — none, with the full m00001–m00465 range (465 messages, 381.4K) listed as compressible again.
  • search_context("<anything>"): [No compressed blocks exist yet — nothing to search.]
  • acp_cache: 0 folds, compress re-pay 0 tok — bili does not even register that it had folded this content before.
  • The child's own journal literally contains the parent's tool/call events for compress at the same seq numbers and timestamps as the parent (seq 249/293/298/367/519/636/975/982), then diverges. So the history replay is confirmed; only the block index is missing.

Root cause (from reading dist/index.js of 0.1.202)

Two separate adoption paths exist, and only the one that cannot fire mid-session is enabled by default:

  1. Startup sweep — reconcileDerivedLineage(listSessions(), {log, adoptionEnabled: opts.resumeInheritance !== false}), called once in the boot sequence right after initSessions(), logging [lineage-reconcile] restored fold state for N forked session(s). resumeInheritance defaults to true, so this path is on — but it only runs at proxy startup, so a fork created while the proxy is already running is never seen by it.
  2. Runtime adoption — maybeAdoptForkBlocks({session, parentId, protocol, parsed, upstreamOrigin, enabled: opts.forkAdoption === true, log}), gated on anonAffinity?.via === "new" && anonAffinity.lineage?.reason === "forked" && session.stats.requests === 0. This is the path that actually fires for a live fork, and it is disabled because forkAdoption defaults to **false`:
if(!enabled){
  log2("info",`[fork-adoption] fork of ${parentId}: parent has ${plan.adoptedActive} adoptable block(s) covering ~${plan.adoptedTokens} tokens; forkAdoption disabled, starting fresh (set forkAdoption: true or BILI_FORK_ADOPTION=1)`);
  return
}

So by design the runtime path bails out and "starts fresh". Combined with the replay of the parent's compress events, "fresh" means un-compressed: the events say blocks were created, the state says there are none.
Note this is not the same shape as #629 (anonymous fork with no adoption support) — here the adoptable blocks exist and planForkAdoption computes them; the feature is simply off by default.

Suggested fix

  • Default forkAdoption to true, or at least piggyback: when resumeInheritance is true, let the runtime maybeAdoptForkBlocks path inherit blocks too (it already has planForkAdoption's output in hand).
  • If the feature stays opt-in, make the degradation visible rather than silent: when the incoming request clearly replays prior compress events but state.blocks is empty, surface a one-line warning in acp_status (e.g. "child session of <parent> — N blocks not inherited; enable forkAdoption") so users are not left guessing why their context doubled.
  • Consider search_context falling back to the parent session's store (the derivedAncestorSessions walk already exists in executeSearchContextTarget) instead of short-circuiting on an empty local blocks array.

Workaround

Re-run compress in the child immediately after forking. It works (I re-folded 7 blocks / 431.5K original tokens in one call), but you pay the full re-read of the unfolded history for one turn, which is the exact cost bili exists to avoid.
Happy to test a patch — the reproduction is 2 clicks.

bili version *

0.1.202

Usage mode *

Proxy mode (client manually points its base URL at bili)

Client and version *

dsh v0.2.1-rc.2

Operating system *

windows 11

Upstream and model *

workbuddy/deepseek-v4.1-flash 1M

Compression-related config (optional)

Log excerpt (strongly recommended)


/acp or acp_status output (optional)


Activity

  1. ranxianglei commented on Oct 20, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    查重: 同症状家族 #3011 — 并入该 umbrella。 Per the owner's 2026-10-13 policy (same user-visible symptom = ONE issue, even when the underlying mechanisms differ), this is the same "fork inheritance lost → zero-fold child" problem #3011 tracks. Your mechanism (proxy-lane anonymous fork with forkAdoption off by default) differs from #3011's (plugin-lane 409 retryability classification) — so it goes in as a new checklist item (C9) in #3025 rather than a separate issue. First-come-first-served: #3011 (Oct 13) predates this one (Oct 20), so I'll migrate your full finding there and close this issue once the fix PR is up — your repro details are preserved in the migration comment. If you disagree with the merge, say so and I'll reopen.

    标签: bug, dsh, scope/kernel, p1 — "fork 继承被永久放弃" is a named p1 category per the owner's 2026-10-19 revision (session-recovery/data-loss family); scope/kernel because this is session-core fold state, not storage mechanics.

    Your root-cause reading is correct — verified against source at v0.1.202 (your exact build):

    • forkAdoption defaults to false (src/config.ts:1459: true only when the file sets forkAdoption: true or BILI_FORK_ADOPTION=1).
    • The runtime anonymous-fork path fires exactly where you say (src/server/handle.ts:1425, gate via === "new" && lineage.reason === "forked" && requests === 0) and bails because enabled: opts.forkAdoption === true is false.
    • Your two-path analysis holds: the boot sweep reconcileDerivedLineage runs startup-only and only sees sessions carrying a persistent derivedFromSessionId link.

    One addition beyond your analysis — it's worse than "until next restart": when the runtime path bails (adoption disabled), it records no persistent lineage at all. Consequences:

    1. The boot sweep can never see this child (it iterates only sessions with derivedFromSessionId), so a restart does NOT heal it either;
    2. The read-time parent fallback that search_context/decompress use (the derivedAncestorSessions walk in src/decompress-shared.ts) is keyed off that same link, so it never engages for this child.

    So under default config the defect is permanent for the child's lifetime, not merely until restart. (After a restart the child's replay matches its own stored chain, so the fork-detection gate doesn't re-fire either.)

    One nuance, explicit: in proxy mode DSH sends no recognized session header, so the child becomes an anonymous pfa-* session — which is why path 1 (anonymous fork) is the one involved, not the identified resume-fork path. Your dist read plus the m00001-starting refs corroborate this.

    Plan:

    1. Now (needs no decision): mitigation PR — record the persistent lineage link at fork detection (mirrors what the identified-fork paths already do, [pi plugin] Prime RLM inline 派生子会话在代理模式下零继承:搜不到也解压不了父会话压缩块(billion-context-pi#531 同场景) #1333/[Bug] 血缘元数据落盘窗口:derivedFromSessionId 可能未持久化(非优雅退出/多实例覆写),kind 子会话永久孤儿 #2986) + an acp_status warning line naming the parent and the N blocks not inherited, with how to enable adoption. That makes the failure diagnosable from the GUI, lets the existing boot sweep heal the child across a restart, and restores the search_context/decompress parent fallback for free (your suggestion 3 — the ancestor walk already exists, it just needs the link).
    2. Pending owner decision (a default-value change needs sign-off): whether forkAdoption should default to true / follow resumeInheritance. Options + recommendation go to @ranxianglei in the [Bug] Fork of DSH branch clone: inheritance permanently abandoned by 409 SNAPSHOT_UNAVAILABLE — zero-fold clone triggers context_length_exceeded 400 on first turn | [Bug] DSH 分支克隆的 fork 继承被 409 SNAPSHOT_UNAVAILABLE 永久放弃:克隆零折叠,首轮即 context_length_exceeded … #3011 thread.
    3. Your suggestion 2 lands in step 1; your workaround (manual compress in the child) remains valid in the meantime.

    @Leeszzz — glad you're up for testing: I'll post the installable artifact in the #3011 thread once CI is green. A 2-click repro is exactly what the new regression tests will encode.

  2. ranxianglei commented on Oct 20, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    查重:重复 #3011(同症状族:fork/克隆失去继承的压缩状态 → 零折叠子会话;按 owner policy 2026-10-13,同症状 = 一个 issue,内部按机制 checklist 管理)。本 issue 的机制与 #3011 主体不同(那边:插件车道 409 可重试性误分类,#3024/#3039 已修/在途;这边:代理车道匿名 fork 继承 off-by-default + 缺 lineage 链接)→ 实质已迁移为 #3025 checklist 新项 C9,本 issue 按先来后到关闭(#3011 开于 10-13,早于本 issue 的 10-20,先到保留、后到关闭并补信息)。@Leeszzz 后续请在 #3011 线程跟踪;你提过愿意测补丁——PR #3951 的 CI 构建出来后可以直接用你的两次点击复现验证。

    标签:bug, dsh, scope/kernel, p1, duplicate(fork 继承被永久放弃属 owner 2026-10-19 修订的 p1 类别①——压缩状态不手动干预无法恢复;范围是会话核心/上下文管理逻辑而非存储机制)。

    八问:

    1. 是什么问题:代理模式(客户端直接把 base URL 指向 bili)下点「new session from here」建出的子会话丢失父会话全部压缩状态——父会话完整历史(含它自己的 compress 工具调用)被逐字重放,上下文重新膨胀(10%→35%),search_context 找不到任何块。
    2. 什么原因导致的(两层,已在你构建的 v0.1.202 上逐行核实,master 一致):
    3. 影响范围与严重程度:所有代理模式下 fork 已折叠会话的用户(你的 DSH 桌面 Web GUI 配置即触发面);p1——子会话整个生命周期丢折叠状态、每轮上下文膨胀、search/decompress 失明;有绕行(在子会话手动重跑 compress——你做的正是这个)。
    4. 怎么修复的:PR [PR] fix: link anonymous forks to their parent so skipped fold-inheritance heals across a restart #3951(无需决策的部分):每个检测到父会话可加载的匿名 fork 都记录持久 lineage 链接并立即落盘(writeNow)——于是现有启动扫在下次重启时继承父会话 fully-present 的块,读时 search_context/decompress 免费回退父链(你的建议 3 免费实现,未新增搜索机制);外加 acp_status 一行 FORK ADOPTION SKIPPED 警告(点名父会话/块数/开启方式,你的建议 2)、bail 日志 info→warn、成功路径置 derivedBlocksAdopted(Forked child permanently loses block inheritance when its registered parent had no blocks at first contact (weak-link gate closes unconditionally) | 注册父会话在子会话首触时还没有折叠块,子会话将永久失去块继承(弱链接门无条件关闭) #3351 纪律)。默认值翻转(你的建议 1)属产品决策——选项 A/B/C 已列在 [Bug] Fork of DSH branch clone: inheritance permanently abandoned by 409 SNAPSHOT_UNAVAILABLE — zero-fold clone triggers context_length_exceeded 400 on first turn | [Bug] DSH 分支克隆的 fork 继承被 409 SNAPSHOT_UNAVAILABLE 永久放弃:克隆零折叠,首轮即 context_length_exceeded … #3011 线程请 owner 拍板(推荐 B:未设置时 forkAdoption 回落 resumeInheritance)。
    5. 是否有回归问题:derivedFromSessionId/metadata.derivedFrom 的全部读取方逐项审过——启动扫会跳过已继承子会话(blocks>0 + 标记)、Claude 上 resume 导致消息错乱 #1486 路径门不受影响(匿名车道被排除)、读时遍历只是新增一条此前诚实 miss 的回退。继承本身语义不变:raw-id 集合交集、内容寻址、copy-on-fork 深拷贝——猜错的父最多被只读搜索,不会被继承。定向套件本地 23/23 绿(fork-adoption / acp-status / lineage-reconcile-boot / issue1333-derived-inheritance),全量以 CI 为准。
    6. 是否破坏缓存:否——adoption 只 seed 折叠状态,wire 字节与 prefix-cache 稳定性不动。
    7. 是否改了设计方向:否——把既有继承/lineage 契约(Claude 上 resume 导致消息错乱 #1486/[pi plugin] Prime RLM inline 派生子会话在代理模式下零继承:搜不到也解压不了父会话压缩块(billion-context-pi#531 同场景) #1333/fix(sessions): wire-neutral affinity hashing for cross-protocol continuity (#2791) #2807/[Bug] 血缘元数据落盘窗口:derivedFromSessionId 可能未持久化(非优雅退出/多实例覆写),kind 子会话永久孤儿 #2986)补到匿名车道,无新机制,只是让既有恢复路径可达。
    8. 是否引入新配置:[PR] fix: link anonymous forks to their parent so skipped fold-inheritance heals across a restart #3951 无。若 owner 对默认值拍板选 B,那是既有 forkAdoption key 的默认解析变化(无新 key/env),CONFIGURATION.md en/zh 随该 PR 更新。

    收工判定:本 issue 作为 #3011 的重复关闭(实质现为 #3025 的 C9);缓解 PR #3951 open(CI 待跑);默认值决策已在 #3011 提出。

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

    bugSomething isn't workingdshduplicateThis issue or pull request already existsp1scope/kernel

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions