Skip to content

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

Closed
ranxianglei wants to merge 3 commits into
masterfrom
wt-364-ework-daemon
Closed

feat(host): unified turn boundary + child-session state derivation (#364)#366
ranxianglei wants to merge 3 commits into
masterfrom
wt-364-ework-daemon

Conversation

@ranxianglei

@ranxianglei ranxianglei commented Sep 10, 2026

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).

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Review started on PR #366 (head wt-364-ework-daemon @ 5f435a7). Checking diff cleanliness first, then running typecheck / tests / build and reviewing the turn-boundary + deriveChildState changes. Will report findings (and fix anything small directly on the branch) in a follow-up comment.

@ranxianglei

Copy link
Copy Markdown
Owner Author

🤖 Powered by ework · qwen3.8-27b

[bot] 🏷 Review complete — approved, ready to merge.

I checked out the PR head (wt-364-ework-daemon @ 5f435a7, single squashed commit vs master), read the full diff, and re-ran all verification locally.

Diff cleanliness ✅

20 files, +847/−71. Every file is on-topic; no lockfile churn, no generated artifacts, no drive-by reformatting. package.json untouched (acp-kernel stays pinned at exact 0.0.60 — correct, since this PR adds no kernel dependency change). Stale-reference sweep: zero remaining hits for lastUserMessageId/turnStartIndex in src/ or tests/; all ACP_STATUS_CUSTOM_TYPE imports point at the new src/turn-boundary.ts.

Verification (reproduced locally)

Check Claim Result
npm run typecheck clean ✅ clean
npm test 671 pass / 0 fail / 3 skip 671 / 0 / 3 — exact match
npm run build dist/index.js 741.7 KB ✅ 741.70 KB, acp-kernel bundled inline as before

Problem 1 — turn boundary convergence

Read src/turn-boundary.ts in full plus all three call sites (src/index.ts turnKey + collectCompressOutcomes, src/compress-tool.ts retry-cap key, src/messages.ts projection). The convergence is real, not cosmetic: one predicate isTurnBoundary(entry, policy) + id/index dual-view helpers, with a single resolveHostSession(runtime.adapter) instance reused per event. The default-off regression oracle is the right call — it keeps the pre-#364 user-role-only scan verbatim in tests/turn-boundary.test.ts and asserts byte-for-byte equivalence of default-policy results, which is exactly the guarantee existing single-session users need. acp-status custom messages remain excluded under both policies (they're UI plumbing, never turn content) — correct.

One deliberate non-convergence I checked and accept: src/reasoning-drop.ts:80 keeps its own user-role-only "closed turn" gate. It's self-contained (never one of the three ledger sites), and under hostSession.on it stays conservative — thinking parts survive slightly longer rather than being dropped prematurely. Fail-safe by design, no correctness impact, out of scope for this PR. Noted here so it's on record, no issue warranted.

Problem 2 — deriveChildState

Read the pure fn in src/state.ts and the orchestration in src/runtime.ts. Inherit/reset split matches the contract (blocks deep-cloned via structuredClone; messageRefs/tokenSnapshot shallow-copied — safe since both are flat maps, tokenSnapshot: Record<string, number> in kernel types); id counters carried so inherited block ids can't collide. Guard ordering is sound: the ownSidecarHasBlocks disk check deliberately reads the sidecar rather than the cache slot, because load() may have filled the slot via implicit parentSession-header inheritance — which is precisely the replaceable case explicit derivation upgrades. Marker round-trip, every refusal path (child owns real blocks / parent empty / child fileless), and state-untouched-on-refusal are all covered in tests/derive-child-state.test.ts, including the Prime RLM inline scenario where implicit header inheritance happens first and explicit derivation upgrades exactly once.

Two things I analyzed and cleared:

  • No pipeline lock around deriveChild: the snapshot step is synchronous (structuredClone), so there's no torn-read window from event-loop concurrency; concurrent double-derivation is harmless (identical content, last-write-wins on derivedAt). Consistent with how other read paths (stateFor) behave outside the message_end lock. Fine as-is.
  • pi-native delegate path: separate-process children keep verbatim header inheritance — tests/state.test.ts is not in the diff and passes unmodified, confirming the claim.

Scope & duplication

Complements #317 (part 1) and #327 (ledger isolation) without overlapping them — the issue body's framing of what each part fixed checks out against the code. Closes #364 as described. Docs land where they should: new docs/host-adapter.md contract doc, hostSession sections in CONFIGURATION.md (+ zh-CN), CHANGELOG entry following house format. Config tests cover bool shorthand, object form, invalid-value fallback-to-off, and acp.json key gating.

No blocking or minor defects found, so no direct fix commits were needed. Merge when convenient — merging is yours to do.

中文摘要:修复了 #317 遗留的两个多会话结构性缺口——回合边界判定收敛为单一谓词(默认关闭时与 legacy 扫描逐字节等价,有回归测试证明),并新增内联子会话状态派生契约 deriveChildState(继承块/引用/原始消息索引、重置节奏账本、一次性迁移标记、全部护栏拒绝路径有测试覆盖,pi 原生 delegate 路径零变化);diff 干净,typecheck/test(671/0/3)/build 本地复跑全绿且与声明完全一致,可以合并。

ework-agent added 3 commits September 12, 2026 00:58
)

- 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
@github-actions

Copy link
Copy Markdown

📦 Built Extension Artifact

Branch: wt-364-ework-daemon (1b062bd)

Option A — Install from npm PR tag (recommended)

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

Each push to this PR publishes a new version under the pr-366 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-pr366.tgz
pi install ./package

This comment is automatically updated on each push.

@ranxianglei

Copy link
Copy Markdown
Owner Author

已由 #380 替代(同三提交 rebase 到 master v0.1.67,解了与 #327 per-sid 账本的冲突,分支改名 2026-09-10_host-turn-boundary 以满足 CI 分支规范;删除旧头分支导致本 PR 被自动关闭)。审查与讨论记录在此保留,后续跟踪请到 #380

ranxianglei added a commit that referenced this pull request Sep 12, 2026
feat(host): unified turn boundary + child-session state derivation (#364, replaces #366)
@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