Skip to content

feat(host): unified turn boundary + child-session state derivation (#364, replaces #366) - #380

Merged
ranxianglei merged 7 commits into
masterfrom
2026-09-10_host-turn-boundary
Sep 12, 2026
Merged

feat(host): unified turn boundary + child-session state derivation (#364, replaces #366)#380
ranxianglei merged 7 commits into
masterfrom
2026-09-10_host-turn-boundary

Conversation

@ranxianglei

Copy link
Copy Markdown
Owner

Fixes #364

Host multi-session support (part 2) — the two structural gaps left over from #317 (ledger isolation was already fixed by #327).

Problem 1 — turn boundary determination was decided independently in three inconsistent places

lastUserMessageId (src/tokens.ts), turnKey/turnStartIndex (src/index.ts), and the context-entry projection (src/messages.ts) each had their own "what starts a turn" logic. Host-injected custom_message agent turns enter the LLM context but never started a turn, so multiple real turns collapsed into one turnKey — nudge ledger cells misaligned, per-turn compress retry cap and throttle/overflow cycle stats distorted.

Now: single predicate isTurnBoundary(entry, policy) + lastTurnBoundaryId/lastTurnBoundaryIndex in new src/turn-boundary.ts; all three sites converge on it. Host policy entry hostSession (boolean | { countCustomMessages }, default off = pi-native behavior): when off, the predicate is exactly equivalent to the legacy user-role-only scans — existing single-session users see byte-for-byte unchanged cadence (proven by unit tests that diff default-policy results against the legacy scan as oracle). When on, injected non-UI custom_messages start turns for all per-turn ledgers without touching LLM-context projection.

Problem 2 — inline child sessions had no state inheritance contract

runtime.stateFor(childSid) always returned fresh state: correct for pi-native delegates (separate process), wrong for inline same-process children (Prime RLM), which need the parent's blocks to make decompress/search_context work.

Now: deriveChildState(parentState) (pure fn in src/state.ts) + runtime.deriveChildState(childRef, parentRef) orchestration:

  • Inherit: blocks (deep-copied), messageRefs, tokenSnapshot (original-message index), nextBlockId/nextRunId (carried so new block ids can't collide with inherited ones)
  • Reset: nudge rhythm baselines, stats, absorbed — the child starts its cadence from scratch
  • One-time migration marker derivedFrom: {parentSessionId, derivedAt} persisted into the child's own sidecar <child>.jsonl.acp.json (independent of the parent file); re-derivation refused
  • Guards: refuses when the child already has its own non-derived blocks, when the parent has no blocks, or when the child has no session file — state is never mutated on refusal
  • Explicit derivation wins over implicit parentSession header inheritance (upgrades it exactly once)
  • pi-native delegate path is untouched — separate-process children keep verbatim header inheritance; the existing parent-inheritance tests pass unmodified

Verification

  • npm run typecheck clean
  • npm test: 671 pass / 0 fail / 3 skip (baseline 648/0/3; net +23 new tests)
  • npm run build: clean bundle (dist/index.js 741.7 KB)

New tests: tests/turn-boundary.test.ts (predicate matrix, id/index dual-view consistency across a mixed-entry battery, default-policy ≡ legacy-scan regression), tests/derive-child-state.test.ts (inherit/reset matrix, deep-copy isolation, kernel activeBlocks/blockById viability on derived state, marker round-trip, every guard-refusal case, inline sub-agent header scenario). Config/user-config tests cover hostSession parsing and acp.json key gating.

Docs: new docs/host-adapter.md (turn-boundary contract + child-session derivation contract, incl. host usage snippet and who-should-enable guidance); hostSession sections added to CONFIGURATION.md (+ zh-CN); CHANGELOG entry.


中文摘要:修复了 #317 遗留的两个多会话结构性缺口——回合边界判定收敛为单一谓词(默认关闭时存量行为逐字节不变,有回归测试证明),并新增内联子会话状态派生契约 deriveChildState(继承压缩块/消息引用/原始消息索引,重置节奏账本,一次性迁移标记,pi 原生 delegate 路径零变化),补齐 docs/host-adapter.md;typecheck/test/build 全绿,可以合并。


Addendum — also fixes #367 (4a4e88a): docs/host-adapter.md's host contract requires runtime.deriveChildState(childRef, parentRef), but "the extension runtime" was private to the createAcpExtension factory closure and exports exposes only . → dist/index.js, so no external host importing billion-context-pi could ever reach the documented derivation call (forced onto the implicit parentSession-header inheritance instead). Fix: re-export createRuntime + types AcpRuntime/SessionRef + pure deriveChildState from the package entrypoint; docs/host-adapter.md "API surfaces" rewritten to import from the package entrypoint; CI test job reordered so build precedes test (new test resolves the package name via Node self-reference to dist/index.js); new tests/host-api.test.ts imports only from "billion-context-pi" and exercises the documented call end-to-end (entrypoint typeof checks, deep-copy/rhythm-reset, temp-dir sidecar derivation with one-time marker + repeat-refusal; skips when dist/ is unbuilt). Local verification: typecheck + build + full suite green (677 tests, 0 failures, 3 pre-existing skips).


Replaces #366 — same three commits (edf6a9d/95db150/1b062bd) rebased onto master v0.1.67 + conflict resolution against #327's per-sid ledger (noteCompressOutcomes(sid, turnKey, ...) signature + lastTurnBoundaryIndex in index.ts; runtime return line keeps master's named per-sid closures and re-adds deriveChildState: deriveChild). Branch renamed wt-364-ework-daemon → 2026-09-10_host-turn-boundary to satisfy the pr-validation branch convention; deleting the old branch closed #366, hence this replacement PR.

@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

📦 Built Extension Artifact

Branch: 2026-09-10_host-turn-boundary (14b83c5)

Option A — Install from npm PR tag (recommended)

pi install npm:billion-context-pi@pr-380

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

Option B — Download artifact

  1. Download the artifact from the Actions run
  2. Extract the tarball and install:
tar xzf billion-context-pi-pr380.tgz
pi install ./package

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Reviewing PR #380 now. Confirmed branch state: head 2026-09-10_host-turn-boundary @ 1b062bd, 3 commits (edf6a9d turn-boundary+deriveChildState, 95db150 Pi-fork host detection, 1b062bd expose entrypoint API for #367), base fb7f55fa, 35 files (+1279/-139), mergeable=true.

Note: master has advanced past the PR base (fb7f55faa8dfb49b) — I'm checking that the PR still applies cleanly and that its tests pass against current master, not just the old base. Full review follows after I run typecheck/test/build locally.

ranxianglei pushed a commit that referenced this pull request Sep 11, 2026
…ion (#380)

The degenerationGuard doc block was re-indented by one extra space when the hostSession field was added, so the region sat at 3-space field / 4-space comment-continuation while every sibling uses 2-space / 3-space. Restore the convention so the diff carries only the genuine hostSession addition instead of unrelated whitespace churn on degenerationGuard.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Reviewed PR #380 (head 740e102 after my fix; base fb7f55fa) and ran the full gate locally.

Local verification

  • npm run typecheck — clean
  • npm run build — clean, dist/index.js 756 KB
  • npm test738 pass / 0 fail / 3 skipped (build-first order, matching the new CI sequence; the 3 skips are pre-existing: OMP toolCallId/image bind ×2 + empty-live-context)

Design — sound.

  • A single turn-boundary predicate (src/turn-boundary.ts) now backs all three legacy sites (src/index.ts turnKey + outcome window, src/compress-tool.ts turnKey), defaulting off so existing single-session cadence is unchanged.
  • deriveChildState is pure (clones blocks/messageRefs/tokenSnapshot/id-counters, resets nudge/stats/absorbed), with orchestration guards in AcpRuntime.deriveChild() — notably checking the child's on-disk sidecar before store.load, so a self-compressed child isn't mistaken for implicit parent-header inheritance.
  • Host detection keeps OMP blocked by default with a PI_ACP_FORK_HOST opt-in; CONFIG_DIR_NAME / isBashToolCall are vendored so aliased host bundles can't fail module load. Entrypoint re-exports (createRuntime, SessionRef, deriveChildState) cover host adapter: deriveChildState runtime is not reachable from the published package API #367.

Diff-cleanliness fix (pushed).
src/config.ts: adding hostSession re-indented the pre-existing degenerationGuard doc block by one extra space, leaving lines 245–261 at 3-space field / 4-space comment while every sibling uses 2/3 — unrelated whitespace churn on a pre-existing line plus an inconsistency. I stripped one leading space from those lines (whitespace-only, zero behavior change) and pushed 740e102 on top of your ef95839; the config.ts diff now carries only the genuine hostSession addition. Re-ran typecheck+build+test after the edit — still green. Remote ref verified updated.

FYI (non-blocking): I measure 738 tests vs 677 in the addendum — master has grown past your stated 648 baseline since the rebase; the suite itself is healthy.

Looks good to merge once CI is green (the merge itself is yours per repo rules).

中文摘要:修复了 src/config.ts 中新增 hostSession 时把既有的 degenerationGuard 文档块连带多缩进一格所造成的无关空白改动与缩进不一致(已直接提交到 PR 分支 740e102);本地 typecheck/build/test 全绿(738 通过、0 失败、3 跳过),设计合理,可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

为何这个diff这么多 这个目标是为了单进程多会话吗

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 两个问题分开答。

1) 为什么 diff 这么大(+1270 / −130,35 文件)

按类别拆一下行数就清楚了——新增行里约 71% 是测试和文档,不是功能逻辑

类别 增/删 说明
tests +608 −38 最大头,占新增约 48%。4 个新测试文件:derive-child-state(+243)、turn-boundary(+125)、host-api(+89)、host-detection(+79)。本仓库要求每个新模块带测试。
src 功能代码 +362 −87 真正的逻辑,分散在 19 个文件里。
docs + 根目录 md +297 docs/host-adapter.md 单文件就 +217;CONFIGURATION 中/英各 +34;CHANGELOG。
CI +3 −1 build-before-test 顺序调整。

而 src 那 362 行其实捆了三件事,其中一件是跨文件重构,所以文件数看着多、单文件改动却很小:

  • turn boundary 特性:新增 src/turn-boundary.ts(+62),把原先散在 index/compress-tool/tokens/messages 的三处判断收敛到同一个谓词。
  • 子会话状态派生src/state.ts 的纯函数 deriveChildState + runtime.deriveChild() 编排。
  • fork 宿主可加载/识别(commit 95db150),这块最"铺得开":OMP_UNSUPPORTED_MESSAGE→UNSUPPORTED_HOST_MESSAGE 改名波及 5 个工具文件;CONFIG_DIR_NAME 改从本地 config-dir.ts 引入、波及 log/update/user-config/setup-subagent-tools/index;isBashToolCall 本地化。每处只动一两行,加起来文件数就上去了。

一句话:测试+文档占了大头,源码部分是"一个新特性 + 一次跨文件的宿主兼容重构"叠在一起,所以总行数和文件数都偏高,但没有灌水。

2) 这个目标是单进程多会话吗?

是的,核心两条都是为同进程内联多会话宿主(如 Prime RLM 在主会话里跑子 RLM)准备的:

  • hostSession.countCustomMessages:这类宿主会在轮次之间注入非 user 角色的消息(agent_message/custom_message),需把它们当作真实 turn 边界,nudge 节奏才准。默认关,保持现有单会话 pi 用户行为完全不变(只有真 user 消息才开一轮)。
  • deriveChildState:让内联子会话继承父会话的压缩状态(blocks / refs / token 快照 / id 计数器),同时重置自己的节奏账本。跨进程的 pi 原生 delegate 不走这条——它们靠 session 头的 parentSession 直接继承。

第三条(fork 可加载/识别)算前置使能:让 Pi-fork 宿主能加载并识别 ACP(OMP 默认仍拒绝,fork 用 PI_ACP_FORK_HOST 显式开启)。它服务"这些 fork 宿主能跑起来",和多会话相关但不完全是同一件事。

结论:默认对存量用户零影响,功能只在显式开启的内联多会话 / fork 场景生效。

@ranxianglei

Copy link
Copy Markdown
Owner Author

解决冲突

ranxianglei pushed a commit that referenced this pull request Sep 12, 2026
…ion (#380)

The degenerationGuard doc block was re-indented by one extra space when the hostSession field was added, so the region sat at 3-space field / 4-space comment-continuation while every sibling uses 2-space / 3-space. Restore the convention so the diff carries only the genuine hostSession addition instead of unrelated whitespace churn on degenerationGuard.
@ranxianglei
ranxianglei force-pushed the 2026-09-10_host-turn-boundary branch from 740e102 to db017be Compare September 12, 2026 06:48
ework-agent and others added 6 commits September 12, 2026 14:51
)

- src/turn-boundary.ts: single isTurnBoundary(entry, policy) predicate +
  lastTurnBoundaryId/Index; tokens.ts lastUserMessageId, index.ts
  turnStartIndex, and messages.ts projection all converge on it
- hostSession config (boolean | {countCustomMessages}), default off =
  pi-native behavior, existing single-session cadence byte-for-byte unchanged
- deriveChildState(parentState): inherit blocks/messageRefs/tokenSnapshot/
  counters, reset nudge/stats/absorbed; one-time derivedFrom marker persisted
  to the independent child sidecar; runtime.deriveChildState(childRef, parentRef)
  with guards (own blocks / empty parent / no file / already derived)
- pi-native delegate (separate-process) path untouched
- docs/host-adapter.md + CONFIGURATION.md(+zh-CN) + CHANGELOG entries
- tests: tests/turn-boundary.test.ts, tests/derive-child-state.test.ts,
  config/user-config additions; 671 pass / 0 fail / 3 skip
- src/config-dir.ts: namespace import of the pi package + feature-detect
  CONFIG_DIR_NAME with ".pi" fallback — safe under both link-time and
  runtime missing-export failure modes; sole value import from pi pkg
  (tool-guardrails vendors its one guard locally, same pattern as the
  existing isBashToolResult vendoring)
- src/host.ts: entrySourceOf / isDeclaredForkHost (PI_ACP_FORK_HOST=1|true) /
  isUnsupportedHost — OMP stand-down protection stays default; declared
  Pi-compatible forks are accepted and get the existing live-message merge
- omp.ts: UNSUPPORTED_HOST_MESSAGE guidance (fork opt-in + billion-context
  proxy); session_start gate uses isUnsupportedHost
- turn boundary: empty custom_message no longer starts a turn (same
  extractText gate as projection; #364 acceptance c) — isCustomMessageEntry
  moves back to messages.ts so projection and predicate share one definition
- docs: host-adapter.md §3 detection/entry-source contract + §4 config-dir
  responsibility boundary; omp.md(+zh); CONFIGURATION(+zh) env table
…ion (#380)

The degenerationGuard doc block was re-indented by one extra space when the hostSession field was added, so the region sat at 3-space field / 4-space comment-continuation while every sibling uses 2-space / 3-space. Restore the convention so the diff carries only the genuine hostSession addition instead of unrelated whitespace churn on degenerationGuard.
…ntry (#364)

Rebase-integration follow-up onto master (#272 added ACP_EXPORT_CUSTOM_TYPE + CONTEXT_EXCLUDED_CUSTOM_TYPES). isCustomMessageEntry now excludes via that shared set, but TurnBoundaryEntry.customType is optional (string|undefined), which Set<string>.has() rejects under tsc. Guard with an explicit undefined check so narrowing makes the argument a definite string.
@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

冲突已全部解决并推送。 分支 2026-09-10_host-turn-boundary 已 rebase 到最新 master(2bdc831),现为线性历史、无冲突、可合并。新 tip:ed8594e(仅对特性分支做了 force-with-lease,master 未动)。

为什么有冲突
master 在我上一轮 rebase(c99e1ae)之后又合入了三个 PR:#376/#377(compress 结果面板报告 block id + ref span)、#363(strict-echo reasoning gate)、#379(测试修复)。它们与我的改动落在同一片区:

验证(rebase 后全量重跑)

  • typecheck ✅ · build ✅(dist/index.js 766 KB)
  • test:757 pass / 7 fail / 3 skip(skip 为既有的条件性跳过)

关于那 7 个失败(重点:非本 PR 引入)
7 个全部集中在 tests/export-cmd.test.ts(master 上 #272 的 acp-export 测试),都是同一断言 assert.match(text, /1 block/, ...) 拿到了新面板串 blocks: b1=m00002。我在干净的 origin/master(2bdc831)单独跑该文件复现:同样 3 pass / 7 fail、报错逐字一致 —— 即这是 master 本身已存在的问题(#376/#377 改了 compress 结果文案却没同步更新 #272 的断言),与本 PR 无关,本 PR 也未触碰任何 export 相关文件。按 diff 收敛原则,该修复没有混进 #380,已单独建 issue 跟踪:#389(含复现步骤、根因推断、建议修复)。

一句话中文摘要:解决了本 PR 与最新 master(#376/#377#272)在 CHANGELOG 及 custom_message 常量上的冲突,rebase 后 typecheck/build 通过、并确认仅剩的 7 个 export 测试失败属 master 既有问题(已另开 issue #389),本 PR 可以合并。

@ranxianglei

Copy link
Copy Markdown
Owner Author

CI 失败原因已查明:与本 PR 无关,是 master 红(#272×#377 交叉回归)

四个 test 矩阵失败于 export-cmd.test.tsAssertionError: compress created a block#272 的测试断言旧面板格式 /1 block/,#377 已把面板改为 blocks: b1=m00002,两 PR 分支互不包含对方,各自 CI 全绿,合并后 master 挂了,本 PR 只是继承。

修复已提交:#392(Fixes #391,一行断言更新,master 本地全套 722 pass 已验证)。#392 合并后本分支 rebase/update 即恢复全绿 — 本 PR 自身改动没有问题(昨晚 9/9 检查绿时挂的就是这个根因之后的 master 变化)。

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

收到,根因判断一致 ✅ —— 我们独立得出同一结论:master 红是 #272 × #377 交叉回归(tests/export-cmd.test.ts 断言旧面板 /1 block/,而 #377 已改为 blocks: bN=<ref>),本 PR 只是继承,自身改动无问题。

去重:我先前已就同一问题开了 #389;既然你这边已有 #391 + 修复 PR #392,我已把 #389 关闭并打 重复 标签(留了说明评论),以 #391 / #392 为准,避免同一问题双开。

当前状态

计划:#392 合入、master 前进后,我再把 #380 rebase 到新 master 并重跑全量门禁(typecheck/build/test)确认全绿。在此之前不 churn 分支。#392 合入后在此 ping 我,或等下一次 sync 触发即可。

一句话中文摘要:确认与你根因一致(同一 #272×#377 交叉回归),已把我早开的重复 issue #389 关闭并归并到 #391/#392;#392 未合入前 master 无新内容可 rebase,待其合入后再 rebase #380 复绿。

@ranxianglei
ranxianglei merged commit 10f98e8 into master Sep 12, 2026
9 checks passed
@ranxianglei ranxianglei mentioned this pull request Sep 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant