Browse Source

Merge pull request #3669 from deepseek-harness/fix/spkv-root-latest-master

ci(session): bring protected V3 integration onto latest master
Tianyi Cui 1 month ago
parent
commit
930e4d67ea
100 changed files with 2347 additions and 149 deletions
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md
  4. 2 2
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml
  5. 1 1
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
  6. 1 1
      .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md
  7. 2 2
      .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.i18n.yaml
  8. 1 1
      .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.md
  9. 1 1
      .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.zh.md
  10. 6 0
      .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml
  11. 23 0
      .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md
  12. 23 0
      .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md
  13. 2 2
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml
  14. 5 5
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
  15. 5 5
      .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md
  16. 2 2
      .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml
  17. 14 14
      .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
  18. 14 14
      .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md
  19. 2 2
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml
  20. 2 2
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
  21. 2 2
      .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md
  22. 6 0
      .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.i18n.yaml
  23. 51 0
      .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md
  24. 51 0
      .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md
  25. 2 2
      .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml
  26. 1 1
      .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md
  27. 1 1
      .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md
  28. 6 0
      .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.i18n.yaml
  29. 43 0
      .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md
  30. 43 0
      .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.zh.md
  31. 6 0
      .agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.i18n.yaml
  32. 65 0
      .agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md
  33. 65 0
      .agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md
  34. 2 2
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml
  35. 1 1
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
  36. 1 1
      .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md
  37. 2 2
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml
  38. 4 4
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
  39. 5 5
      .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md
  40. 6 0
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml
  41. 68 0
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
  42. 68 0
      .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md
  43. 6 0
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml
  44. 85 0
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md
  45. 85 0
      .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md
  46. 6 0
      .agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml
  47. 28 0
      .agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md
  48. 28 0
      .agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md
  49. 6 0
      .github/workflows/ci-master.yml
  50. 21 0
      .github/workflows/ci.yml
  51. 1 0
      apps/web/tests/chat-scroll-contract.e2e.ts
  52. 4 4
      apps/web/tests/steering.e2e.ts
  53. 35 11
      apps/web/tests/subagent-interrupt-ui.e2e.ts
  54. 2 0
      apps/web/tests/subagent-interrupt.e2e.ts
  55. 1 0
      benchmarks/AGENTS.md
  56. 6 0
      benchmarks/active-stream-reconnect/README.i18n.yaml
  57. 7 0
      benchmarks/active-stream-reconnect/README.md
  58. 7 0
      benchmarks/active-stream-reconnect/README.zh.md
  59. 48 0
      benchmarks/active-stream-reconnect/reconnect.bench.client.ts
  60. 49 0
      benchmarks/active-stream-reconnect/reconnect.worker.client.ts
  61. 6 0
      benchmarks/agent-continuation/README.i18n.yaml
  62. 31 0
      benchmarks/agent-continuation/README.md
  63. 31 0
      benchmarks/agent-continuation/README.zh.md
  64. 175 0
      benchmarks/agent-continuation/agent-continuation.bench.ts
  65. 144 0
      benchmarks/agent-continuation/agent-continuation.worker.ts
  66. 95 0
      benchmarks/agent-continuation/child-catalog.worker.ts
  67. 42 0
      benchmarks/agent-continuation/profile-adapter.ts
  68. 85 0
      benchmarks/agent-continuation/profile-continuation.worker.ts
  69. 110 0
      benchmarks/agent-continuation/workload.ts
  70. 6 0
      benchmarks/long-session-browser/README.i18n.yaml
  71. 17 0
      benchmarks/long-session-browser/README.md
  72. 17 0
      benchmarks/long-session-browser/README.zh.md
  73. 209 0
      benchmarks/long-session-browser/long-session.bench.ts
  74. 92 0
      benchmarks/long-session-browser/synthetic-history.ts
  75. 5 0
      benchmarks/package.json
  76. 19 0
      benchmarks/tsdown.config.ts
  77. 2 2
      docs/architecture.i18n.yaml
  78. 2 0
      docs/architecture.md
  79. 2 0
      docs/architecture.zh.md
  80. 1 1
      docs/event-producer-consumer.i18n.yaml
  81. 4 4
      docs/event-producer-consumer.md
  82. 2 2
      docs/module-graph.i18n.yaml
  83. 2 1
      docs/module-graph.md
  84. 2 1
      docs/module-graph.zh.md
  85. 2 2
      docs/subsystems/subagent.i18n.yaml
  86. 11 8
      docs/subsystems/subagent.md
  87. 11 8
      docs/subsystems/subagent.zh.md
  88. 1 1
      package.json
  89. 2 2
      packages/api/session-controller/README.i18n.yaml
  90. 1 1
      packages/api/session-controller/README.md
  91. 1 1
      packages/api/session-controller/README.zh.md
  92. 2 0
      packages/api/session-controller/package.json
  93. 1 1
      packages/api/session-controller/src/client/contract/session.ts
  94. 1 0
      packages/api/session-controller/src/client/sessions/session.ts
  95. 27 12
      packages/api/session-controller/src/commands.ts
  96. 136 6
      packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts
  97. 1 0
      packages/api/session-controller/tests/manager.client.spec.ts
  98. 12 0
      packages/api/session-controller/tests/session.client.spec.ts
  99. 1 0
      packages/api/session-controller/tsconfig.host.json
  100. 2 2
      packages/client/ui-chat/README.i18n.yaml

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md
-2026-07-05-reconstructable-requests.md: bc8ba640c400b18598f18aa303f2bd1b5c5b9cdc
-2026-07-05-reconstructable-requests.zh.md: de1802aac83f1e0980172d2d541a093f2d729e4a
+2026-07-05-reconstructable-requests.md: bca93a60bf07484d73f1faf50359b72a0d00b9a3
+2026-07-05-reconstructable-requests.zh.md: c9d2a4a5d05456df8b0bd065bade8a41dd7e4e84

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md

@@ -24,7 +24,7 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro
 
 `EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded.
 
-Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
+Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and freezes it while leaving `AbortSignal` live. The [request-freeze provenance decision](../simplification/2026-09-06-agent-request-freeze-provenance.md) owns reuse of completed message freezes and per-request local header freezing. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header.
 
 **The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md

@@ -24,7 +24,7 @@ Status: implemented
 
 `EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。
 
-每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
+每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,冻结请求但保持 `AbortSignal` 活跃。[请求冻结来源证明决策](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)拥有消息完整冻结的复用规则和每次请求的本地 header 冻结规则。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。
 
 **已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。
 

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
-2026-09-05-read-only-session-migration-preparation.md: c343ca457184554f4b47a0795dcb33b8b07e9d39
-2026-09-05-read-only-session-migration-preparation.zh.md: 3a283259729eda6a01fa4208cac5399f45978fac
+2026-09-05-read-only-session-migration-preparation.md: ab23e7e61dcdf0762cae6185de5fd16c4070fcbf
+2026-09-05-read-only-session-migration-preparation.zh.md: 89377d4d84776bebbc6d2ca6acea92ac15f74df3

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md

@@ -59,7 +59,7 @@ interface MigrationPreparation {
 }
 ```
 
-A new read or write open joins the existing entry only when its source path and revision still match. `waitWithAbort()` races each caller's AbortSignal against the shared Promise without forwarding that signal to shared work. The backend-owned controller is aborted only when the last waiter leaves while preparation is still running.
+A new read or write open joins the existing entry only when its source path and revision still match. `waitWithAbort()` races each caller's AbortSignal against the shared Promise without forwarding that signal to shared work. The backend-owned controller is aborted only when the last waiter leaves while preparation is still running. The cancellation test pauses the physical read and observes two registered waiters before aborting one caller; an event-loop yield alone cannot establish admission after asynchronous path and revision lookup.
 
 Completed results enter the existing bounded `coldLogMemo`. The `StoredLog` discriminant separates published current state from `PreparedStoredLog`, whose `publication` field binds current logical events to their matching publication operation. A query followed by Agent resume therefore reuses the same Decode and migration result. The in-flight map owns only running work; it is not a second completed-result cache.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md

@@ -59,7 +59,7 @@ interface MigrationPreparation {
 }
 ```
 
-新的 read/write open 只有在 source path 与 revision 仍匹配时才加入已有 entry。`waitWithAbort()` 让每个 caller 的 AbortSignal 与 shared Promise 竞争,但不会把 caller signal 传给共享工作。只有最后一个 waiter 在 preparation 仍运行时离开,backend-owned controller 才会 abort。
+新的 read/write open 只有在 source path 与 revision 仍匹配时才加入已有 entry。`waitWithAbort()` 让每个 caller 的 AbortSignal 与 shared Promise 竞争,但不会把 caller signal 传给共享工作。只有最后一个 waiter 在 preparation 仍运行时离开,backend-owned controller 才会 abort。取消测试暂停物理读取,并在取消一个 caller 前观察到两个已注册的 waiter;仅让出一次事件循环不能证明异步路径与 revision 查找后的加入已经完成。
 
 完成结果进入既有 bounded `coldLogMemo`。`StoredLog` 判别字段把已发布 current state 与 `PreparedStoredLog` 分开,后者的 `publication` 字段把 current logical events 与匹配的 publication operation 绑定,使 query 后紧接的 Agent resume 复用同一次 Decode 与 migration。In-flight map 只拥有运行中的工作,不是第二个 completed-result cache。
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.md
-2026-08-17-subagent-message-settlement-ordering.md: cdc996643c84c5f50a3bd1836e82645660dc8c57
-2026-08-17-subagent-message-settlement-ordering.zh.md: 1143da1560e4969dcc4f6a0c6d5ca18060b56191
+2026-08-17-subagent-message-settlement-ordering.md: 7462a670766664745e46204dcb01e578b4f86219
+2026-08-17-subagent-message-settlement-ordering.zh.md: bba8fe0e7c543982176def4dbcc6f94dda204339

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.md

@@ -14,7 +14,7 @@ The child instruction says to send a finding whenever it changes what the parent
 
 Every model-authored adjacent-Agent message uses fixed Steer delivery through `SubagentRuntime.sendMessage()`. A running parent reads the child message at its nearest safe step boundary and an idle parent starts a turn. There is no quiet or next-turn model delivery option.
 
-The continuation manager retains `sendWaking()` and `admitWaking()` around messages delivered to resident continuable parents. Their purpose is waking-send admission accounting: the receiving Activation remains live between synchronous inbox insertion and the microtask that observes the wake.
+The continuation manager retains `sendWaking()` around messages delivered to resident continuable parents and routes the synchronous send through the parent's private `SubagentInbox`. The wrapper accepts the send before its closing promise is installed or rejects it afterwards, and an accepted attempt renews the Activation's wake generation before returning. The receiving Activation therefore cannot settle over an accepted waking send.
 
 ### Ordering across parent states
 

+ 1 - 1
.agents/notes/implemented/bug-fix/2026-08-17-subagent-message-settlement-ordering.zh.md

@@ -14,7 +14,7 @@ child 指令要求在发现会改变 parent 下一步动作时发送该发现。
 
 每条模型编写的相邻 Agent 消息都通过 `SubagentRuntime.sendMessage()` 使用固定 Steer 投递。运行中的 parent 在最近安全 step 边界读取 child 消息,空闲 parent 则启动一个轮次。模型没有静默或 next-turn 投递选项。
 
-继续执行管理器在投递到驻留可继续 parent 的消息周围保留 `sendWaking()` 与 `admitWaking()`。它们负责唤醒发送准入记账:接收方 Activation 会在同步 inbox 插入与观察到唤醒的微任务之间保持在线。
+继续执行管理器会在投递到驻留可继续 parent 的消息周围保留 `sendWaking()`,并通过 parent 的私有 `SubagentInbox` 执行同步发送。包装层会在安装 closing promise 前接受发送,并在安装后拒绝发送;被接受的尝试会在返回前更新 Activation 的 wake generation。因此,接收方 Activation 不会越过一条已接受的唤醒发送完成结算。
 
 ### 不同 parent 状态下的顺序
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md
+2026-09-07-pinned-scroll-delivery-before-layout.md: f3b428dbe0d2fdc7cbaedf0383d2ab80ea1bfd45
+2026-09-07-pinned-scroll-delivery-before-layout.zh.md: 9b200f45535c67cfbbb75feaf3025c5a3b5c1fa2

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.md

@@ -0,0 +1,23 @@
+# Agent Note: Settle pinned scroll deliveries before layout changes
+
+Status: implemented
+
+English | [中文](2026-09-07-pinned-scroll-delivery-before-layout.zh.md)
+
+## Problem
+
+A delayed scroll sample compares positions from different layouts. While Chat is pinned, a composer or transcript shrink can move the browser floor; subsequent growth can move the browser position again before `scrollend` or the sampling timer. Deferring follow during that interval leaves the observed-top ledger stale and can classify browser layout movement as reader input, disabling follow without a reader gesture.
+
+## Decision
+
+[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) uses the existing observed-top comparison to sample non-reader pinned scroll deliveries synchronously through the same sample operation that clears pending work. This releases layout follow before further growth. Genuine reader movement remains pending until the existing interval or `scrollend`, even within the follow threshold: growth must not erase small gestures before they accumulate into a scroll-away. Immediate pinned samples use scroll metrics, not semantic-row geometry.
+
+## Alternatives considered
+
+**Defer every delivery.** Coalescing reduces geometry work while reading history, but a pinned browser position and its floor must be attributed in the same layout. A longer timeout or retry cannot recover ownership once the stale comparison disarms it.
+
+**Sample every delivery synchronously.** This restores attribution but also repeats semantic-anchor and reading-line measurements throughout an away-reader scroll burst. Only pinned ownership needs the immediate path.
+
+## Consequences
+
+Pinned deliveries incur immediate scroll-metric reads. History reading retains its bounded sampling cadence, and explicit return-to-bottom deliveries clear any pending away sample. [Focused tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) cover shrink/regrowth before scrollend, observer growth without row measurements, repinning with a pending sample, timer and scrollend sampling, and unmount cancellation. The [keyless browser scenario](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) covers pinned Send, real scroll-away input, streaming, and tool disclosure across the long transcript.

+ 23 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pinned-scroll-delivery-before-layout.zh.md

@@ -0,0 +1,23 @@
+# Agent Note: 在布局变化前处理贴底滚动事件
+
+Status: implemented
+
+[English](2026-09-07-pinned-scroll-delivery-before-layout.md) | 中文
+
+## Problem
+
+延迟的滚动采样会比较来自不同布局的位置。Chat 贴底时,输入框或 transcript(文本记录)收缩可能改变浏览器底部位置;随后的增长又可能在 `scrollend` 或采样定时器触发前改变浏览器位置。在此期间推迟跟随会使已观察顶部位置记录过期,把浏览器布局移动误判为读者输入,在没有读者操作时关闭跟随。
+
+## Decision
+
+[ChatView](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) 使用现有的已观察顶部位置比较,通过同一个清除待处理工作的采样操作,同步采样非读者引起的贴底滚动事件。这会在后续增长前恢复布局跟随。真实读者移动即使位于跟随阈值内,也保持待处理直到现有周期或 `scrollend`:增长不能在小幅操作累积为离底滚动前将其抵消。立即执行的贴底采样只读取滚动指标,不读取语义行几何。
+
+## Alternatives considered
+
+**延迟所有事件。** 合并采样减少阅读历史时的几何计算,但贴底浏览器位置及其底部必须在同一布局中完成归因。过期比较关闭跟随后,延长超时或重试都无法恢复归属。
+
+**同步采样所有事件。** 这能恢复归因,却也会在离底读者连续滚动时重复测量语义锚点和阅读线。只有贴底归属需要立即处理。
+
+## Consequences
+
+贴底事件会立即读取滚动指标。历史阅读保留有界采样节奏,显式回到底部的滚动事件会清除任何待处理的离底采样。[聚焦测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) 覆盖 scrollend 前的收缩与增长、无需行测量的观察器增长、存在待处理采样时重新贴底、定时器与 scrollend 采样,以及卸载取消。[无密钥浏览器场景](../../../../apps/web/tests/chat-scroll-contract.e2e.ts) 覆盖长 transcript 中贴底发送、真实离底输入、流式输出与工具详情展开。

+ 2 - 2
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md
-2026-07-27-web-subagent-conversations.md: 28e06d1db9103de53e6e2fb266a03e168428d0d2
-2026-07-27-web-subagent-conversations.zh.md: e93abe89e855d688d822bfd20fd1644f24bdf3e9
+2026-07-27-web-subagent-conversations.md: d0713731de86df639a426ee56c39857c50af6bc7
+2026-07-27-web-subagent-conversations.zh.md: 0f4f6467484ee1caa3cdf28f44e016ff674ac1dc

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.md

@@ -16,11 +16,11 @@ The UI must also preserve the membership, modes, and diagnostics of the [durable
 
 The Web product exposes the selected session's direct session-backed subagents from the current-title lineage region in the header. Users can lazily expand descendant catalogs and open either mode in the existing conversation region. A one-shot child is permanently read-only. A continuable child accepts human follow-ups only while its exact direct-parent Agent is live; otherwise its persisted transcript remains readable with a recovery explanation.
 
-Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt calls `ctx.subagents.followup()` and succeeds at inbox acceptance with `{ messageId }`; it does not steer an open turn, expose an Activation, wait for completion, or return an outcome.
+Every opened child carries a catalog-derived address `{ parentSessionId, childSessionId, mode }`. The mode-bearing address, not lineage or the coarse origin marker, selects dedicated history and prompt transports. History reads the persisted session without activation. A continuable prompt carries Queue or Steer delivery through `subagent.prompt` and succeeds at inbox acceptance with `{ messageId }`; it does not expose an Activation, wait for completion, or return an outcome. Adjacent-Agent model messages use the separately owned fixed-Steer operation.
 
 The generic Host domain preserves the same ownership boundary. `session.history` and the source side of `session.fork` read an attached Session or inspect persistence without acquiring an Agent; history folds cold projection values from that exact inspected prefix, while a fork publishes an ordinary independent session. Generic Agent-bound session, command, and goal routes return `agent-busy` for session-backed subagents, as do explicit-id `session.create` adoption and attached-only queue controls. The denial classifier accepts the coarse `origin` marker, a `subagent/descriptor` in the session's own suffix, or exact live runtime ownership by the parent; these signals only prevent generic ownership and never replace catalog mode or direct-parent authorization.
 
-Stopping an addressed child never falls through to `session.cancel`. `SubagentRuntime.followup()` owns admission only until inbox acceptance and grants no cancellation handle; a running continuable child is stopped through the dedicated `subagent.interrupt` route under the [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md), which parks pending work instead of discarding it. One-shot children remain uncancellable from the Web.
+Stopping an addressed child never falls through to `session.cancel`. Browser prompt delivery owns admission only until inbox acceptance and grants no cancellation handle; a running continuable child is stopped through the dedicated `subagent.interrupt` route under the [current-turn interrupt contract](2026-08-06-continuable-subagent-interrupt.md), which parks pending work instead of discarding it. One-shot children remain uncancellable from the Web.
 
 This decision covers Web discovery, transcript viewing, and parent-authorized human continuation. It does not make a subagent independently user-owned; that product remains [interactive side sessions](../../proposed/feature/2026-07-08-interactive-side-sessions.md).
 
@@ -45,7 +45,7 @@ Healthy rows reuse the standard session projections retained in the list mirror.
 
 Selecting a row records its exact address before opening the resident client `Session`. History pagination, event folding, tool render intents, titles, and live mux reconciliation reuse the ordinary conversation machinery. Breadcrumbs follow parent links only through `origin: 'subagent'` rows, include the first ordinary owner, and keep ordinary forks single-level. Each subagent breadcrumb gets its direct-parent sibling catalog and uses that catalog's label when available. Forking an addressed subagent creates an ordinary fork with direct source lineage and attaches it to the nearest workspace-owning ancestor. The catalog is an ARIA tree with lazy ArrowRight/ArrowLeft disclosure, linear ArrowUp/ArrowDown navigation, Home/End, Escape, and focus restoration.
 
-A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false and the child is not running; a running parent-offline child keeps the ordinary composer with its input and Send action disabled so independent Stop stays reachable, and the read-only takeover returns once it stops. With a live parent, Enter and Send admit another FIFO turn even while the child runs, while independent Stop routes through `subagent.interrupt` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Prompt failures retain the draft through the ordinary error behavior.
+A one-shot row always replaces the composer with copy explaining that the execution record is read-only. A continuable row does so only while `parentAvailable` is false and the child is not running; a running parent-offline child keeps the ordinary composer with its input and Send action disabled so independent Stop and live QueueDock controls stay reachable, and the read-only takeover returns once it stops. With a live parent, the ordinary Enter/Cmd+Enter preference selects Queue or best-effort Steer even while the child runs. QueueDock Edit, Remove, and Steer remain available for a live continuable child even when its parent is offline, while independent Stop routes through `subagent.interrupt` ([interrupt contract](2026-08-06-continuable-subagent-interrupt.md)). Prompt failures retain the draft through the ordinary error behavior.
 
 Agent-bound auxiliary controls are unavailable in addressed child views. In particular, the model selector and `/model` contribution do not call ordinary `session.models` or `session.selectModel`; the Host also rejects any accidental call instead of activating persisted child history outside the direct-parent continuation path.
 
@@ -55,13 +55,13 @@ Agent-bound auxiliary controls are unavailable in addressed child views. In part
 
 - `subagent.list` takes `parentSessionId`, calls `ctx.subagents.listChildren(parentSessionId, signal)`, returns the complete ordered entries with each healthy row's boolean `hasChildren` snapshot, replaces each healthy row's corpus activity with whether its exact Agent driver is running, and includes whether the exact parent currently resolves from `ctx.agents`.
 - `subagent.history` takes the full mode-bearing address plus ordinary page arguments. It verifies the child and mode against the direct catalog, reads through `ctx.sessionQuery.readSession()`, rechecks direct lineage, and returns the ordinary raw-event, render-intent, pagination, and host-computed session-projection baseline without publishing an Agent.
-- `subagent.prompt` accepts only a `mode: 'continuable'` address and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, calls `ctx.subagents.followup(parent, childId, content, { source, signal })`, and returns the accepted `MessageId`.
+- `subagent.prompt` accepts only a `mode: 'continuable'` address, `delivery: 'queue' | 'steer'`, and upload-shaped `PromptContentPart[]`; the Host admits and persists image parts into durable references before delivery ([image delivery](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md)). It requires the exact live parent, revalidates the catalog address, uses the continuation manager's shared human-delivery admission, and returns the accepted `MessageId`.
 
 The gateway maps missing parent, missing or diagnostic catalog entries, not-resumable and unauthorized children, request cancellation, image admission and image-capability refusals (`subagent/attachment-invalid`), and temporarily unavailable continuation admission to typed RPC errors. It does not expose descriptor or provider details. A list/prompt race is normal: the prompt result, not the earlier availability or activity snapshot, is authoritative.
 
 Viewing persisted history creates no mux subscription by itself. When a follow-up materializes a cold child Activation, the existing Host and mux streams publish its lifecycle and events. Reconnect rebuilds the addressed window through `subagent.history`.
 
-The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` and `session.updateQueue` apply the same check directly because they intentionally query only attached Agents.
+The ordinary `session.history` route is likewise observation-only for both ordinary and subagent sessions, but it does not carry the catalog address or grant continuation authority. Every ordinary route that needs an Agent resolves through the shared ownership fence before cold resume; `session.cancel` retains that fence. `session.updateQueue` has one target-local exception for a live child whose current projected identity is continuable and comes from its own non-seed suffix; one-shot, missing, unknown, corrupt, seed-only, or cold children remain fenced.
 
 The adapter stays behind the generated Remote namespace; `dsh-host-webserver` remains a carrier. Browser code imports the contract through the existing connection package and never reaches host `ctx`, preserving the [archived GUI RPC layering decision](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md).
 

+ 5 - 5
.agents/notes/implemented/feature/2026-07-27-web-subagent-conversations.zh.md

@@ -16,11 +16,11 @@ UI 还必须保留[持久化目录](../../archived/feature/2026-07-22-durable-su
 
 Web 产品通过页头的当前 title 谱系区域公开选中会话中由会话支撑的直接 subagent。用户可以懒加载展开后代目录,并在现有对话区域中打开任一 mode。one-shot child 永久只读。可继续 child 只有在其确切直接 parent agent 存活时才接受用户后续消息;否则,其持久化 transcript 仍然可读,并附带恢复说明。
 
-每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词操作会调用 `ctx.subagents.followup()`,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会对进行中的轮次执行 steering(中途引导)、公开 Activation、等待完成或返回结果。
+每个打开的 child 都携带目录派生地址 `{ parentSessionId, childSessionId, mode }`。选择专用历史与提示词传输的是包含 mode 的地址,而不是谱系或粗粒度 origin 标记。历史操作会从持久化存储读取会话,而不触发激活。可继续提示词通过 `subagent.prompt` 携带 Queue 或 Steer 投递,并在 inbox 接受消息时以 `{ messageId }` 成功返回;它不会公开 Activation、等待完成或返回结果。相邻 Agent 的模型消息使用单独拥有的固定 Steer 操作。
 
 通用 Host 领域遵守同一所有权边界。`session.history` 与 `session.fork` 的源端会读取已附加 Session 或检查持久化存储,而不获取 Agent;history 从所检查的确切前缀归并冷态投影值,fork 则发布一个普通的独立会话。绑定到 Agent 的通用会话、命令与目标路由会对由会话支撑的 subagent 返回 `agent-busy`;显式 id 的 `session.create` 接纳与仅针对已附加会话的队列控件亦然。拒绝分类器接受粗粒度 `origin` 标记、会话自身后缀中的 `subagent/descriptor`,或 parent 对其确切的存活运行时所有权;这些信号只会阻止通用路径取得所有权,绝不取代目录 mode 或直接 parent 授权。
 
-停止一个已寻址 child 绝不回退到 `session.cancel`。`SubagentRuntime.followup()` 只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断约定](2026-08-06-continuable-subagent-interrupt.zh.md),该约定会停放并保留待处理工作,而不是将其丢弃。one-shot child 在 Web 端仍不可取消。
+停止一个已寻址 child 绝不回退到 `session.cancel`。浏览器 prompt 投递只负责消息被 inbox 接受前的准入,不授予取消句柄;正在运行的可继续 child 通过专用的 `subagent.interrupt` 路由停止,遵循[当前轮次中断约定](2026-08-06-continuable-subagent-interrupt.zh.md),该约定会停放并保留待处理工作,而不是将其丢弃。one-shot child 在 Web 端仍不可取消。
 
 本决策涵盖 Web 端发现、transcript 查看与经 parent 授权的用户继续交互。它不会让 subagent 成为用户独立所有的对象;这类产品仍然属于[交互式 side session](../../proposed/feature/2026-07-08-interactive-side-sessions.zh.md)。
 
@@ -45,7 +45,7 @@ Figma 中的 [subagent 列表](https://www.figma.com/design/jRBBK7zBgcszdVWQ0Fh5
 
 选择一行后,系统会先记录其确切地址,再打开常驻客户端 `Session`。历史分页、事件 fold、工具渲染意图、title 与实时 mux 归并都会复用普通对话机制。面包屑导航只会沿 `origin: 'subagent'` 行的父链接逐级回溯,包含第一个普通 owner,并让普通 fork 保持单层。每一级 subagent 面包屑都会获得其直接 parent 的 sibling 目录,并在目录可用时采用其中的 label。从已寻址 subagent 创建 fork 时,会生成具有直接源谱系的普通 fork,并将其附加到最近拥有 Workspace 的祖先。目录是一棵 ARIA 树,支持懒加载式 ArrowRight/ArrowLeft 展开与折叠、线性 ArrowUp/ArrowDown 导航、Home/End、Escape 以及焦点恢复。
 
-one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,并禁用其输入区和 Send 操作,让独立的 Stop 保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,Enter 和 Send 也会准入另一个 FIFO 轮次,而独立的 Stop 经由 `subagent.interrupt` 路由([中断约定](2026-08-06-continuable-subagent-interrupt.zh.md))。提示词失败会通过普通错误行为保留草稿。
+one-shot 行始终会用文案替代输入框,说明执行记录为只读。可继续行仅在 `parentAvailable` 为 false 且 child 未在运行时如此;parent 离线但仍在运行的 child 保留普通输入框,并禁用其输入区和 Send 操作,让独立的 Stop 与在线 QueueDock 控制保持可达,停止后只读替代恢复。parent 在线时,即使 child 正在运行,普通 Enter/Cmd+Enter 偏好也会选择 Queue 或 best-effort Steer。对在线可继续 child,QueueDock Edit、Remove 与 Steer 在 parent 离线时仍可用;独立 Stop 经由 `subagent.interrupt` 路由([中断约定](2026-08-06-continuable-subagent-interrupt.zh.md))。提示词失败会通过普通错误行为保留草稿。
 
 已寻址 child 视图不提供绑定到 agent 的辅助控件。具体而言,模型选择器与 `/model` contribution 不会调用普通 `session.models` 或 `session.selectModel`;Host 也会拒绝任何意外调用,而不是在直接 parent 继续执行路径之外激活持久化 child 历史。
 
@@ -55,13 +55,13 @@ one-shot 行始终会用文案替代输入框,说明执行记录为只读。
 
 - `subagent.list` 接受 `parentSessionId`,调用 `ctx.subagents.listChildren(parentSessionId, signal)`,返回完整有序的条目以及每个健康行的布尔 `hasChildren` 快照,把每个健康行的语料活动状态替换为其确切 Agent driver 是否正在运行,并说明当前能否从 `ctx.agents` 解析出确切 parent。
 - `subagent.history` 接受包含 mode 的完整地址与普通页参数。它对照直接目录校验 child 与 mode,通过 `ctx.sessionQuery.readSession()` 读取,再次检查直接谱系,并在不发布 agent 的情况下返回普通原始事件、渲染意图、分页与由 Host 计算的会话投影基线。
-- `subagent.prompt` 只接受 `mode: 'continuable'` 地址与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md))。它要求确切的存活 parent,重新校验目录地址,调用 `ctx.subagents.followup(parent, childId, content, { source, signal })`,并返回已接受的 `MessageId`。
+- `subagent.prompt` 只接受 `mode: 'continuable'` 地址、`delivery: 'queue' | 'steer'` 与上传形态的 `PromptContentPart[]`;Host 在投递前把图片部分准入并持久化为持久引用([图片投递](../../archived/bug-fix/2026-08-27-steer-followup-image-delivery.md))。它要求确切的存活 parent,重新校验目录地址,使用 continuation manager 共享的人类投递准入,并返回已接受的 `MessageId`。
 
 网关会将 parent 缺失、目录条目缺失或为 diagnostic、child 不可恢复或未授权、请求取消、图片准入或图片能力拒绝(`subagent/attachment-invalid`)以及继续执行准入暂时不可用等失败映射为类型化 RPC 错误。它不会公开描述符或提供方细节。list/prompt 竞态属于正常情况:权威依据是提示词操作的结果,而不是更早的可用性或活动快照。
 
 查看持久化历史本身不会创建 mux 订阅。当后续消息物化冷态 child Activation 时,现有 Host 与 mux 流会发布其生命周期与事件。重新连接时,系统通过 `subagent.history` 重建已寻址窗口。
 
-普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 与 `session.updateQueue` 会直接执行同一检查,因为它们有意只查询已附加的 Agent。
+普通 `session.history` 路由对于普通会话和 subagent 会话同样只执行观察,但它既不携带目录地址,也不授予继续执行权限。每条需要 Agent 的普通路由都会在恢复冷会话前经过共享所有权栅栏;`session.cancel` 保留该栅栏。`session.updateQueue` 只有一个目标本地例外:目标是在线 child,且其当前 projection identity 为 continuable 并来自自身的非 seed suffix;one-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 仍受栅栏阻挡。
 
 适配器仍位于生成的 Remote 命名空间之后;`dsh-host-webserver` 仍作为载体。浏览器代码通过现有连接包导入约定,绝不直接访问宿主 `ctx`,从而保持[已归档的 GUI RPC 分层决策](../../archived/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md
-2026-07-28-continuable-subagent-conversations.md: fef7aba2080521253d7a67dd5169880fa5146dc0
-2026-07-28-continuable-subagent-conversations.zh.md: 875d6ab7ea5b8dd78276c3a7a9e789646340bc15
+2026-07-28-continuable-subagent-conversations.md: 8c3f2e1da593157f17528f13fc8842012aad0284
+2026-07-28-continuable-subagent-conversations.zh.md: 886e31fa3d88b78f875d51058bb2ec8c525837fe

+ 14 - 14
.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md

@@ -62,14 +62,14 @@ The internal residency lifecycle has three conditions and no separate `queued` s
 
 ```text
 running
-  | Agent quiescent with live children
+  | Agent quiescent with pending inbox or live children
   v
 waiting
-  | next-turn
+  | waking delivery
   +--------------------------> running
 
 running or waiting
-  | Agent quiescent and no live children
+  | Agent quiescent, empty inbox, and no live children
   v
 settled
   | AgentHandle.dispose completes
@@ -77,15 +77,15 @@ settled
 no Activation
 ```
 
-`running` means the Agent has an active admission or turn, or its inbox contains waking work. `waiting` means the Agent is quiescent but the Activation still owns at least one child Activation that has not completed disposal. `settled` means the Agent is quiescent and every owned child is disposed; the manager then disposes the `AgentHandle` and removes the Activation.
+`running` means the Agent has an active admission or turn. `waiting` means the Agent is quiescent but its Inbox is nonempty or the Activation still owns at least one child Activation that has not completed disposal. `settled` means the Agent is quiescent, its Inbox is empty, and every owned child is disposed; the manager then disposes the `AgentHandle` and removes the Activation.
 
-The manager derives these states from Agent quiescence and the owned-child set rather than maintaining a second execution state machine. A `next-turn` delivered while `running` joins the Agent inbox. A `next-turn` delivered while `waiting` wakes the same Agent and returns the Activation to `running`. Delivery after disposal cold-resumes a new Activation.
+The manager derives these states from Agent quiescence, the Inbox's pending state, and the owned-child set rather than maintaining a second execution state machine. A `next-turn` delivered while `running` joins the Agent inbox. A waking delivery while `waiting` wakes the same Agent and returns the Activation to `running`. Delivery after disposal cold-resumes a new Activation.
 
-The manager linearizes delivery, child release, and disposal for each durable child. If a delivery races with final disposal, exactly one side wins the admission cutoff: delivery either enters the still-live Agent inbox, or waits for disposal and cold-resumes a new Activation. No caller can send to a handle after its disposal transaction begins.
+The manager linearizes manager-owned delivery, child release, and disposal for each durable child. A private `SubagentInbox` delegates Queue and Steer to the Agent inbox and owns the Activation's existing close transaction. If manager delivery races with final disposal, exactly one side wins this admission cutoff: delivery either enters the still-live Agent inbox, or observes closing and follows its operation-specific rejection or cold-resume path. Direct Agent work does not use this wrapper, so natural settlement uses short maintenance claims to validate the idle phase before the final flush and final disposal decision, then revalidates the Session sequence, Inbox pending state, wake generation, and owned-child set under the child lock. Accepted work that remains active or changes Session, Inbox, or ownership state invalidates that settlement attempt instead of being cancelled by it; maintenance that starts and finishes entirely during the flush has completed before the cutoff.
 
 ### One inbox and follow-up delivery
 
-The Agent inbox is the only queue. Every continuation message uses `Agent.followup()` and becomes one FIFO turn; neither the continuation manager nor the host maintains another message queue. Every accepted waking item keeps the current Activation live until `Agent.whenIdle()` observes the complete waking suffix.
+The Agent inbox is the only queue. Every continuation message uses `Agent.followup()` and becomes one FIFO turn; neither the continuation manager nor the host maintains another message queue. Every pending Inbox occurrence keeps the current Activation live until it is claimed or discarded. This conservative rule also retains injected context: a quiet injection that remains after quiescence can keep the Activation and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown disposes the tree.
 
 Routing depends only on Activation residency:
 
@@ -103,21 +103,21 @@ Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`.
 
 When the authenticated parent is itself a continuation-managed Activation, starting a child or submitting parent-originated work adds the child Session id to that parent's `ownedChildren` before the child can run or the message can enter its inbox. That parent cannot settle or dispose while this set is non-empty. A top-level or other non-continuation Agent has no Activation and does not join this waiting graph.
 
-Child release occurs only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal. The manager awaits `ctx.sessions.flush(child.session)` but does not interpret its participation boolean: an arbitrary listener cannot prove that the selected persistence backend stored the state. A rejection is logged without preventing handle disposal or ownership release, because retaining a child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order.
+Child release occurs only after the child Agent is quiescent, its Inbox is empty, every child of that child is disposed, the best-effort final session flush settles, the same settlement facts survive a child-lock revalidation, and the child's `AgentHandle` completes disposal. The manager awaits `ctx.sessions.flush(child.session)` before closing admission but does not interpret its participation boolean: an arbitrary listener cannot prove that the selected persistence backend stored the state. A rejection is logged without preventing revalidation, handle disposal, or ownership release, because retaining a child would permanently pin its ancestors in `waiting`. If the child is owned, the manager then resolves the live parent through `SessionHeader.parentSession` and removes the child Session id from its `ownedChildren`. Manager teardown uses the same child-first order but closes admission and stops work immediately rather than performing natural-settlement revalidation.
 
 Ownership is retained until the child Activation is disposed. A later refinement may release a request-scoped lease earlier, but it would require an exact turn-completion correlation that this Task-free design deliberately does not add.
 
 Top-level teardown is host-owned rather than represented as another Activation. Manager unload invokes its internal manager-wide drain to close admission synchronously, await every admitted materialization through publication or rollback, stop the stable live forest, and release it child-first. A host that owns selected top-level Agents uses `drainContinuableDescendants(parents)`: exact Agent identities close admission only below those roots until each leaves the registry, while unrelated forests and manager-wide admission remain live; the manager stops their visible descendants before its first await, waits only materializations admitted below those roots, and releases only the selected branches. Every materialized start and live delivery rechecks caller cancellation, the applicable draining scope, Activation disposal, and exact parent authority in the same synchronous span as inbox submission, so teardown or parent replacement that wins before acceptance prevents delivery to the closing handle. Only after the applicable drain settles may the host dispose its top-level Agents; only manager-wide drain precedes manager-scope disposal.
 
-The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Each Activation installs one memoized disposal promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown.
+The activation-owner scope exists because ordinary Cordis owner effects unwind in reverse registration order, which cannot express the dynamic child graph. Manager initialization registers the private scope's structural disposer first and its drain disposer afterward, so reverse unwind invokes the drain before releasing that scope; merely registering a cleanup effect on the same scope as later Agent handles would allow structural handle disposal to bypass child-first ordering. Each materialization registers its barrier participant and snapshots its exact live ancestry before starting the inner transaction, then remains tracked until it installs an Activation or fully rolls back. The Activation retains weak membership of that ancestry, so an intermediate Agent may leave the registry without hiding a still-live descendant from its host root. Its private `SubagentInbox` installs one memoized closing promise before cancellation or recursive callbacks, allowing scoped host shutdown, global manager unload, child release, and normal settlement to converge without double release. Cancellation propagates top-down before slow descendant cleanup; handle release remains child-first. Sibling branches drain independently; one disposal failure is recorded but does not prevent the manager from attempting the remaining selected handles, and the aggregate drain reports failure after all selected branches settle. Durable child Sessions survive this process-local teardown.
 
 ### Adjacent-Agent messaging
 
 The shared `sendMessage(sender, targetId, content, options)` service operation adds no second queue. It accepts an exact live sender, permits only its direct parent or direct continuable child, and uses fixed Steer scheduling through the Agent inbox. The global `send_message({ agent_id, message })` tool exposes that same operation in both directions; the child's initial task identifies its direct parent when the tool is visible. The [adjacent-Agent messaging Agent Note](../architecture/2026-08-27-adjacent-agent-steer-messaging.md) owns its schema, authority, attribution, and prompt placement.
 
-### Fixed Steer scheduling
+### Agent and human scheduling
 
-Every accepted Agent message uses `Agent.steer()`. A running target claims it at the nearest step boundary; an idle or cold-resumed target starts a turn. The continuation layer does not expose a caller-selectable quiet, next-turn, or follow-up mode.
+Every accepted Agent message uses `Agent.steer()`. A running target claims it at the nearest step boundary; an idle or cold-resumed target starts a turn. Browser-authored human input separately carries `delivery: 'queue' | 'steer'` through `subagent.prompt`: Queue opens a later FIFO turn, while Steer uses the same best-effort nearest-step scheduling without changing the message's human provenance. The public service exposes no caller-selectable scheduling mode for Agent messages.
 
 ### Authority and recorded sender identity
 
@@ -133,7 +133,7 @@ Without Jobs there is no `job_output`, `job_kill`, Task status, or per-message r
 
 Host and manager teardown remains the lifecycle stop path. Manager unload applies it globally; a host applies it only below the exact top-level Agents it owns. Each form closes the applicable admission scope, stops the selected visible Activations, awaits admitted materializations in that scope, releases child-first, and preserves the durable Sessions.
 
-Each turn requests the Session durability checkpoint, while final Activation settlement additionally awaits `ctx.sessions.flush()` as a best-effort barrier. The manager deliberately ignores the boolean result because listener participation cannot identify a persistence backend. A rejection is logged without changing the lifecycle result or host-drain outcome; the manager still disposes the handle and releases ownership, and the persisted child state may be missing or stale on a later resume.
+Each turn requests the Session durability checkpoint, while final Activation settlement additionally awaits `ctx.sessions.flush()` as a best-effort barrier before closing admission. The manager then revalidates that no Agent, Inbox, Session, or owned-child state changed during the await; a changed observation retries settlement and flushes the newer state. The manager deliberately ignores the flush boolean because listener participation cannot identify a persistence backend. A rejection is logged without changing the lifecycle result or host-drain outcome; the manager still performs the final revalidation, disposes the handle when it succeeds, and releases ownership, while the persisted child state may be missing or stale on a later resume.
 
 Only messages written to the child Session log are reconstructable with the source that supplied them; inbox acceptance alone provides no restart guarantee.
 
@@ -189,13 +189,13 @@ The implementation pins these behaviors:
 - An idle Agent with live owned children yields a `waiting` Activation whose `AgentHandle` remains retained.
 - A `next-turn` delivered to `waiting` wakes the same Activation; delivery after completed disposal cold-resumes a new Activation.
 - Every continuation-managed parent Activation disposes only after all directly owned child Activations complete `AgentHandle` disposal; top-level Agents do not join the waiting graph.
-- Final Activation settlement awaits `ctx.sessions.flush(child.session)` as a best-effort barrier, logs rejection without interpreting listener participation as durability proof, then disposes the child handle and releases parent ownership so a flush failure cannot leak a `waiting` Activation.
+- Final Activation settlement awaits `ctx.sessions.flush(child.session)` with admission open, logs rejection without interpreting listener participation as durability proof, revalidates the final state under the child lock, then closes admission, disposes the child handle, and releases parent ownership so a flush failure cannot leak a `waiting` Activation.
 - Manager teardown closes admission globally; a host owning selected top-level Agents instead closes admission only below their exact identities until those roots leave the registry. Both track admitted materializations by exact ancestry, install one memoized disposal cutoff per selected visible Activation, propagate cancellation top-down, release handles child-first, await every selected branch despite individual failures, and only then dispose the corresponding top-level Agents or manager scope.
 - The base lifecycle has no implicit report behavior; the optional report package contributes an explicit child-scoped tool through the setup hook.
 - Session logs reconstruct only messages that were actually written, with the source that supplied each message; inbox-accepted but unlogged messages have no restart guarantee.
 - No continuable-subagent path creates or depends on a Task, `JobId`, Task completion notice, Task cancellation, or intermediate result-bearing execution wrapper.
 - Unit coverage pins the `startContinuable()` inbox-acceptance return boundary, complete rollback for each pre-acceptance and lifecycle-publication failure, global and parent-scoped drain quiescence for materialization caught between Agent publication and Activation registration, sibling-forest isolation, exact ancestry after an intermediate Agent leaves the registry, provider-independent cold resume, final exact-parent reauthorization after cold-resume materialization, caller-signal and teardown ownership on both sides of acceptance, and the absence of automatic replay for accepted-but-unlogged messages.
-- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, best-effort final flush with absent and failing listeners, and the absence of public subagent cancellation and steering.
+- Unit coverage pins the residency-only routing table, single-inbox ordering, `MessageId` correlation through inbox events, follow-up during an open turn, waiting wakeup, cold resume, ownership registration and release, child-first disposal, send-versus-dispose races, direct Agent turns, Session-only work, and maintenance accepted during the final-flush await, best-effort final flush with absent and failing listeners, and the absence of public subagent cancellation and steering.
 - Report-package unit coverage separately pins child-only visibility, setup revocation, authority, delivery modes, stable message identity, and lifecycle races.
 - A keyless assembled-app snapshot covers parent delegation and follow-up queueing, the absence of subagent steering and implicit report delivery, retained waiting `AgentHandle`, and child-first disposal. A separate report snapshot covers the optional explicit return channel.
 

+ 14 - 14
.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.zh.md

@@ -62,14 +62,14 @@ inbox 接受消息前发生任何失败,操作都会在不返回任何 id 的
 
 ```text
 running
-  | Agent quiescent with live children
+  | Agent quiescent with pending inbox or live children
   v
 waiting
-  | next-turn
+  | waking delivery
   +--------------------------> running
 
 running or waiting
-  | Agent quiescent and no live children
+  | Agent quiescent, empty inbox, and no live children
   v
 settled
   | AgentHandle.dispose completes
@@ -77,15 +77,15 @@ settled
 no Activation
 ```
 
-`running` 表示 Agent 正在执行准入或轮次,或者 inbox 中存在会唤醒 Agent 的工作。`waiting` 表示 Agent 已经完全停稳,但激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。
+`running` 表示 Agent 正在执行准入或轮次。`waiting` 表示 Agent 已经完全停稳,但其 Inbox 非空,或激活仍持有至少一个尚未完成 dispose 的 child 激活。`settled` 表示 Agent 已经完全停稳、其 Inbox 为空且所有持有的 child 都已 dispose;随后管理器会 dispose `AgentHandle` 并移除激活。
 
-管理器根据 Agent 是否完全停稳以及所持 child 集合派生这些状态,而不是维护第二套执行状态机。在 `running` 时投递的 `next-turn` 会进入 Agent inbox。在 `waiting` 时投递的 `next-turn` 会唤醒同一个 Agent,并使激活回到 `running`。在 dispose 完成后投递消息则会冷恢复新激活。
+管理器根据 Agent 是否完全停稳、Inbox 的待处理状态以及所持 child 集合派生这些状态,而不是维护第二套执行状态机。在 `running` 时投递的 `next-turn` 会进入 Agent inbox。在 `waiting` 时到达的唤醒投递会唤醒同一个 Agent,并使激活回到 `running`。在 dispose 完成后投递消息则会冷恢复新激活。
 
-管理器会针对每个持久化 child,将投递、child 释放和 dispose 线性化。如果投递与最终 dispose 发生竞争,只有一方能越过准入截止点:投递要么进入仍在线的 Agent inbox,要么等待 dispose 完成后冷恢复新激活。任何调用方都不能向已经开始 dispose 事务的 handle 发送消息。
+管理器会针对每个持久化 child,将 manager 所有的投递、child 释放和 dispose 线性化。私有 `SubagentInbox` 会把 Queue 与 Steer 委托给 Agent inbox,并持有 Activation 既有的关闭事务。如果 manager 投递与最终 dispose 发生竞争,只有一方能越过这条准入截止点:投递要么进入仍在线的 Agent inbox,要么观察到正在关闭,并遵循该操作特有的拒绝或冷恢复路径。直接操作 Agent 的工作不经过这层包装,因此自然结算会通过短暂的 maintenance 占用,在最终 flush 与最终 dispose 决策之前验证 idle 阶段,并在 child lock 内重新验证 Session 序号、Inbox 待处理状态、wake generation 与 owned-child set。仍然活跃或改变 Session、Inbox 或所有权状态的已接受工作会让本次结算尝试失效,而不会被它取消;完全在 flush 期间开始并结束的 maintenance 已在截止点前完成。
 
 ### 一个 inbox 与 follow-up 投递
 
-Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup()`,并成为一个 FIFO 轮次;继续执行管理器和宿主都不维护另一条消息队列。每个已接受且会唤醒 Agent 的条目都会让当前激活保持在线,直至 `Agent.whenIdle()` 观察到完整的唤醒工作后缀已经结束。
+Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup()`,并成为一个 FIFO 轮次;继续执行管理器和宿主都不维护另一条消息队列。每个待处理 Inbox occurrence 都会让当前激活保持在线,直到它被 claim 或 discard。这条保守规则也会保留注入 context:完全停稳后仍存在的静默注入可以让 Activation 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown dispose 整棵树。
 
 路由只取决于激活的驻留状态:
 
@@ -103,21 +103,21 @@ Agent inbox 是唯一队列。每条继续执行消息都使用 `Agent.followup(
 
 当经过身份认证的 parent 自身是由继续执行管理器管理的激活时,启动 child 或提交由 parent 发起的工作,会在 child 可以运行或消息可以进入其 inbox 前,将 child 会话 id 加入该 parent 的 `ownedChildren`。该集合非空时,这个 parent 不能结算或 dispose。顶层 Agent 或其他非继续执行 Agent 没有激活,也不会加入该等待图。
 
-只有在 child Agent 完全停稳、该 child 持有的每个 child 都已 dispose、best-effort 的最终会话 flush 结算且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会等待 `ctx.sessions.flush(child.session)`,但不解释其参与布尔值:任意 listener 都无法证明所选持久化后端已存储该状态。rejection 会被记录,但不会阻止 handle dispose 或释放所有权,因为保留 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。管理器拆卸使用相同的 child-first 顺序。
+只有在 child Agent 完全停稳、其 Inbox 为空、该 child 持有的每个 child 都已 dispose、best-effort 的最终会话 flush 结算、相同结算事实通过 child-lock 重验且 child 的 `AgentHandle` 完成 dispose 后,系统才释放 child。管理器会在关闭准入前等待 `ctx.sessions.flush(child.session)`,但不解释其参与布尔值:任意 listener 都无法证明所选持久化后端已存储该状态。系统会记录 rejection,但不会让它阻止重验、handle dispose 或释放所有权,因为保留 child 会让其祖先永久固定在 `waiting`。如果 child 归 parent 所有,管理器随后会通过 `SessionHeader.parentSession` 解析在线 parent,并从其 `ownedChildren` 中移除 child 会话 id。Manager teardown 使用相同的 child-first 顺序,但会立即关闭准入并停止工作,而不执行自然结算重验。
 
 系统会一直保留所有权,直至 child 激活完成 dispose。后续改进可以更早释放限定到请求的 lease,但这需要精确关联轮次完成,而本 Task-free 设计特意不增加该机制。
 
 顶层拆卸由宿主负责,而不表示为另一次激活。管理器卸载会调用其内部的管理器全局 drain,同步关闭准入,等待每个已获准的物化过程完成发布或回滚,停止稳定的在线森林,并按 child-first 顺序释放。拥有选定顶层 Agent 的宿主使用 `drainContinuableDescendants(parents)`:确切的 Agent 身份只关闭这些根之下的准入,直到每个身份离开注册表,而无关森林和管理器全局准入保持在线;管理器会在第一次 await 之前停止其可见后代,只等待这些根之下已获准的物化过程,并且只释放选定分支。每个已物化的 start 和在线投递都会在与 inbox 提交相同的同步区间内重新检查调用方取消、适用的 draining 作用域、Activation dispose 和确切的 parent 权限,因此只要拆卸或 parent 替换先于接受发生,就会阻止向正在关闭的 handle 投递。只有适用的 drain 结算后,宿主才能 dispose 自己的顶层 Agent;只有管理器全局 drain 会先于管理器作用域 dispose。
 
-activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会保留其在这组祖先中的弱成员关系,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。每个 Activation 都会在取消或递归回调前安装一个记忆化的 dispose promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
+activation-owner 作用域之所以存在,是因为普通 Cordis owner effect 按注册逆序撤销,无法表达动态 child 图。管理器初始化时先注册私有作用域的结构化 disposer,再注册自身的 drain disposer,使逆序撤销先执行 drain、再释放该作用域;如果只在与后续 Agent handle 相同的作用域上注册 cleanup effect,结构化 handle dispose 就可能绕过 child-first 顺序。每个物化过程都会在启动内部事务前注册其屏障参与项,并对其确切的在线祖先建立快照,然后保持跟踪,直到安装 Activation 或完全回滚。Activation 会保留其在这组祖先中的弱成员关系,因此中间 Agent 即使离开注册表,也不会让仍在线的后代脱离宿主根节点的可见范围。其私有 `SubagentInbox` 会在取消或递归回调前安装一个记忆化的 closing promise,使限定作用域的宿主关闭、全局管理器卸载、child 释放和正常结算能够汇合,而不会重复释放。取消会在等待缓慢的后代清理之前自顶向下传播;handle 释放仍是 child-first。同级分支独立 drain;系统会记录单次 dispose 失败,但仍会尝试其余选中 handle,聚合 drain 则在所有选中分支结算后报告失败。这次进程内拆卸不会销毁持久化 child 会话。
 
 ### 相邻 Agent 消息
 
 共享的 `sendMessage(sender, targetId, content, options)` 服务操作不会增加第二条队列。它接收确切在线 sender,只允许其直接 parent 或直接可继续 child,并通过 Agent inbox 使用固定 Steer 调度。全局 `send_message({ agent_id, message })` 工具在两个方向暴露同一个操作;当 child 可以看到该工具时,其初始任务会标明直接 parent。[相邻 Agent 消息 Agent Note](../architecture/2026-08-27-adjacent-agent-steer-messaging.zh.md)规定其 schema、权限、来源信息与提示词位置。
 
-### 固定 Steer 调度
+### Agent 与人类调度
 
-每条已接受的 Agent 消息都使用 `Agent.steer()`。运行中的目标会在最近的 step 边界领取消息;空闲或冷恢复的目标会启动一个轮次。继续执行层不暴露由调用方选择的 quiet、next-turn 或 follow-up 模式。
+每条已接受的 Agent 消息都使用 `Agent.steer()`。运行中的目标会在最近的 step 边界领取消息;空闲或冷恢复的目标会启动一个轮次。浏览器编写的人类输入会另行通过 `subagent.prompt` 携带 `delivery: 'queue' | 'steer'`:Queue 开启后续 FIFO 轮次,Steer 使用相同的 best-effort 最近 step 调度,并保留消息的人类来源。公开服务不为 Agent 消息提供调用方可选的调度模式。
 
 ### 权限与已记录的发送方身份
 
@@ -133,7 +133,7 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
 
 宿主和管理器拆卸仍是生命周期停止路径。管理器卸载会全局应用它;宿主只会在自己确切拥有的顶层 Agent 之下应用它。两种形式都会关闭适用的准入作用域,停止选中的可见 Activation,等待该作用域中已获准的物化过程,按 child-first 顺序释放,并保留持久化 Session。
 
-每个轮次都会请求执行会话持久性检查点,而 Activation 最终结算还会等待 `ctx.sessions.flush()`,将其作为 best-effort 屏障。管理器特意忽略布尔结果,因为 listener 是否参与无法标识持久化后端。rejection 会被记录,但不会改变生命周期结果或宿主 drain 的结果;管理器仍会 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。
+每个轮次都会请求执行会话持久性检查点,而 Activation 最终结算还会在关闭准入前等待 `ctx.sessions.flush()`,将其作为 best-effort 屏障。管理器随后会重新验证 await 期间没有 Agent、Inbox、Session 或 owned-child 状态发生变化;观察发生变化时,系统会重试结算并 flush 更新后的状态。管理器特意忽略 flush 布尔结果,因为 listener 是否参与无法标识持久化后端。系统会记录 rejection,但不会改变生命周期结果或宿主 drain 的结果;管理器仍会执行最终重验,在重验成功时 dispose handle 并释放所有权,后续恢复时持久化 child 状态可能缺失或陈旧。
 
 只有实际写入 child 会话日志的消息,才能在重建时保留提供它的来源;仅被 inbox 接受并不提供重启保证。
 
@@ -189,13 +189,13 @@ activation-owner 作用域之所以存在,是因为普通 Cordis owner effect
 - 带有在线所持 child 的空闲 Agent 会产生 `waiting` 激活,其 `AgentHandle` 继续保留。
 - 向 `waiting` 投递 `next-turn` 会唤醒同一个激活;完成 dispose 后投递消息会冷恢复新激活。
 - 每个由继续执行管理器管理的 parent 激活只会在直接持有的所有 child 激活完成 `AgentHandle` dispose 后进行 dispose;顶层 Agent 不加入等待图。
-- Activation 最终结算会等待 `ctx.sessions.flush(child.session)`,将其作为 best-effort 屏障;它会记录 rejection,但不会把 listener 参与解释为持久性证明,然后 dispose child handle 并释放 parent 所有权,使 flush 失败不会泄漏 `waiting` Activation。
+- Activation 最终结算会在准入开放时等待 `ctx.sessions.flush(child.session)`,将其作为 best-effort 屏障;它会记录 rejection,但不会把 listener 参与解释为持久性证明,随后在 child lock 内重新验证最终状态,再关闭准入、dispose child handle 并释放 parent 所有权,使 flush 失败不会泄漏 `waiting` Activation。
 - 管理器拆卸会全局关闭准入;拥有选定顶层 Agent 的宿主则只关闭这些确切身份之下的准入,直到这些根离开注册表。两者都会按确切祖先关系跟踪已获准的物化过程,为每个选中的可见 Activation 安装一个记忆化 dispose 截止点,自顶向下传播取消,按 child-first 顺序释放 handle,即使个别分支失败也会等待所有选中分支,之后才 dispose 对应的顶层 Agent 或管理器作用域。
 - 基础生命周期不暴露隐式报告行为;可选的 report 包通过 setup 钩子贡献一个显式的 child 作用域工具。
 - 会话日志只会重建实际写入的消息,并保留每条消息的提供来源;已被 inbox 接受但未写入日志的消息没有重启保证。
 - 可继续 subagent 路径不创建或依赖 Task、`JobId`、Task 完成通知、Task 取消或中间的带结果执行包装层。
 - 单元覆盖固定 `startContinuable()` 在 inbox 接受消息时的返回边界、每条接受前和生命周期发布失败路径的完整回滚、全局和限定到 parent 作用域的 drain 都会等待夹在 Agent 发布与 Activation 注册之间的物化过程完全停稳、同级森林隔离、中间 Agent 离开注册表后的确切祖先关系、不依赖提供方的冷恢复、冷恢复物化后的最终确切 parent 再授权、接受前后两个阶段的调用方 signal 与拆卸所有权,以及已接受但未写入日志的消息不会自动回放。
-- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、没有 listener 和 listener 失败时的 best-effort 最终 flush,以及不存在公开 subagent 取消和 steering。
+- 单元覆盖固定仅由驻留状态决定的路由表、单 inbox 顺序、通过 inbox 事件关联 `MessageId`、在开放轮次期间 follow-up、等待唤醒、冷恢复、所有权注册与释放、child-first dispose、发送与 dispose 的竞争、在最终 flush await 期间接受的直接 Agent 轮次、仅修改 Session 的工作与 maintenance、没有 listener 和 listener 失败时的 best-effort 最终 flush,以及不存在公开 subagent 取消和 steering。
 - report 包的单元覆盖会分别固定仅 child 可见性、setup 撤销、权限、投递模式、稳定消息身份和生命周期竞争。
 - 一项无密钥整套应用快照覆盖 parent 委派和 follow-up 排队、不存在 subagent steering 和隐式 report 投递、保留 waiting 中的 `AgentHandle` 以及 child-first dispose。另一项 report 快照覆盖可选的显式返回通道。
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md
-2026-08-06-manager-owned-subagent-settlement-delivery.md: d06245eacd3b7453a031716b1921015a5e38a25c
-2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: e5259e97f185203ed77ae9427e5523ac29d4162f
+2026-08-06-manager-owned-subagent-settlement-delivery.md: f223571dc91300d085b7fcf0a9e3196daa48b760
+2026-08-06-manager-owned-subagent-settlement-delivery.zh.md: 4bbee373aa2b50902af5319f9398a89da9cc3143

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.md

@@ -26,9 +26,9 @@ The notice carries `{ kind: 'subagent-settled', form: 'notice', summary, senderS
 
 An external `ctx.on('subagent/end')` listener looks more decoupled and is wrong. `SubagentRunEndInfo` names no parent, the child handle is already disposed when the edge fires so the parent cannot be recovered from it, and the ownership release that wakes the parent's own settlement watcher has already run. The manager holds the parent reference throughout disposal, so none of those obstacles exist for it.
 
-**The send happens before `releaseOwnership`.** At that point the parent still counts this child, so `stateOf(parent)` is `waiting` and the parent is structurally unable to be judged settled. Delivering after the release instead races a watcher that resumes one microtask later, finds itself childless and quiet, and disposes an Agent whose `cancel()` clears the very inbox the notice is sitting in. The failure mode is a silently missing message with no error anywhere.
+**The send happens before `releaseOwnership`.** At that point the parent's owned-child set still contains this child, so the settlement predicate cannot succeed. Delivering after the release instead races a watcher that resumes one microtask later, finds itself childless and quiet, and disposes an Agent whose `cancel()` clears the very inbox the notice is sitting in. The failure mode is a silently missing message with no error anywhere.
 
-**A resident parent receives it through `admitWaking`.** Registering the message id before the synchronous send is what keeps the window between `followup()` and the microtask that admits it from being read as quiescence. This is not belt-and-braces over the first rule: `Agent.status` folds context maintenance into `idle`, and a waking send behind maintenance only arms a deferred wake, so a parent compacting its context is judged quiet by both `status` and the owned-child set the moment the release lands.
+**A resident parent receives it through its private `SubagentInbox`.** The wrapper checks the Activation's closing promise immediately before the synchronous waking send, and the manager renews the wake generation before returning. The final settlement decision rechecks that generation, the Session sequence, the pending Inbox, and the owned-child set under the child lock, then claims the Agent's idle phase through `runMaintenance()` before closing admission. This is not redundant with the first rule: `Agent.status` folds context maintenance into `idle`, and a waking send behind maintenance only arms a deferred wake.
 
 Both rules are pinned by tests that fail when the ordering is reversed or the accounting removed.
 

+ 2 - 2
.agents/notes/implemented/feature/2026-08-06-manager-owned-subagent-settlement-delivery.zh.md

@@ -26,9 +26,9 @@ Status: implemented
 
 外部 `ctx.on('subagent/end')` listener 看起来更解耦,但它是错的。`SubagentRunEndInfo` 不指名父级;该边触发时 child handle 已被 dispose,因此无法从中恢复父级;而唤醒父级自身结算 watcher 的所有权释放也已经执行过了。管理器在整个 dispose 过程中都持有父级引用,因此这些障碍对它都不存在。
 
-**发送发生在 `releaseOwnership` 之前。** 此刻父级仍然计入这个 child,因此 `stateOf(parent)` 为 `waiting`,父级在结构上不可能被判定为已结算。改在释放之后投递,则会与一个在下一个 microtask 恢复的 watcher 竞争:它会发现自己没有 child 且处于静止,于是 dispose 一个 Agent,而该 Agent 的 `cancel()` 会清空正装着这条通知的那个 inbox。失效表现是一条静默丢失的消息,任何地方都不会报错。
+**发送发生在 `releaseOwnership` 之前。** 此刻 parent 的 owned-child set 仍然包含这个 child,因此结算判据不可能成立。改在释放之后投递,则会与一个在下一个 microtask 恢复的 watcher 竞争:它会发现自己没有 child 且处于静止,于是 dispose 一个 Agent,而该 Agent 的 `cancel()` 会清空正装着这条通知的那个 inbox。失效表现是一条静默丢失的消息,任何地方都不会报错。
 
-**驻留父级通过 `admitWaking` 接收它。** 在同步发送之前登记消息 id,正是让 `followup()` 与承认它的那个 microtask 之间的窗口不被读作静止的原因。这不是对第一条规则的多余保险:`Agent.status` 会把上下文维护折叠成 `idle`,而维护期间的唤醒发送只会预置一次延后唤醒,因此正在压缩上下文的父级,在所有权释放落地的那一刻会同时被 `status` 与已拥有 child 集合判定为静止。
+**驻留 parent 通过私有 `SubagentInbox` 接收它。** 包装层会在同步唤醒发送前立即检查 Activation 的 closing promise,manager 则会在返回前更新 wake generation。最终结算决策会在 child lock 内重新检查该 generation、Session 序号、待处理 Inbox 与 owned-child set,再通过 `runMaintenance()` 占用 Agent 的 idle 阶段,然后关闭准入。这并非对第一条规则的重复保护:`Agent.status` 会把 context maintenance 折叠成 `idle`,而 maintenance 期间的唤醒发送只会预置一次延后唤醒。
 
 两条规则都有测试固定:把顺序反转或去掉记账,测试就会失败。
 

+ 6 - 0
.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md
+2026-08-27-continuable-subagent-human-inbox-control.md: cf5dfd070dfd600fb64bc529b4a1476a258c1181
+2026-08-27-continuable-subagent-human-inbox-control.zh.md: 081fb84f75afcc94339ae3bf627480081bc56d89

+ 51 - 0
.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md

@@ -0,0 +1,51 @@
+# Agent Note: Human inbox controls for continuable subagents
+
+Status: implemented
+
+English | [中文](2026-08-27-continuable-subagent-human-inbox-control.zh.md)
+
+## Problem
+
+Continuable children use the same Agent loop and inbox as ordinary Agents, but the human delivery path exposed only FIFO follow-up. The Client discarded its existing Queue/Steer choice when it selected the dedicated subagent prompt Remote, and the generic Session ownership fence rejected every queue mutation for a subagent-owned identity. The browser therefore hid controls that the live child inbox already supported.
+
+Opening generic Session control indiscriminately would weaken the subagent ownership rule. Prompt delivery still needs exact live direct-parent authorization and cold-resume accounting, while queue mutation must reject one-shot, unknown, corrupt, and cold children. A valid continuable descriptor in the child's own log suffix identifies which live subagent-owned Sessions may use occurrence mutation. Settlement must also retain an idle Agent while a delivery a driver will claim is still pending, and must not tear down an Agent that claimed the idle phase for a maintenance task after `whenIdle()` resolved.
+
+## Decision
+
+A live continuable child exposes the ordinary human inbox controls without adding another queue, Remote endpoint, queue action, or Host-facing subagent operation. One-shot children remain read-only.
+
+The existing `SubagentPromptRequest` carries `delivery: 'queue' | 'steer'`. The Client forwards the mode already selected by `Session.prompt(content, mode)` through `subagent.prompt`. The Remote still requires the exact live direct parent and then uses one package-internal continuation-manager delivery operation. Queue calls `Agent.followup(message)`; steer calls `Agent.steer(message)`. Both paths share child locking, cold resume, final parent reauthorization, caller-signal cutoff, `MessageId` creation, rollback, and disposal-race handling. This human choice adds no public scheduling method or model tool; the separately owned `sendMessage()` and model-facing `send_message` operation keep their fixed adjacent-Agent Steer semantics.
+
+The browser gives a continuable child the ordinary busy Enter/Cmd+Enter Queue/Steer preference, QueueDock Edit/Remove/Steer actions, and empty-draft steer-all gesture. Send and Stop remain independent controls. Composer prompts still require the live parent because their Remote mints new admitted work. QueueDock mutations address already-live inbox work directly, so they remain available when the parent is offline; the parent-offline composer stays locked.
+
+The existing `session.updateQueue(itemId, action)` resolves the exact live Agent and admits a subagent-owned Session only when its current projected identity is continuable and the descriptor sequence belongs to the child's own non-seed suffix. A live one-shot Agent and a missing, inherited-only, or invalid identity retain the ownership failure. An absent Agent returns `queue-item-not-found` and does not cold-resume the child. The target Session id is sufficient human authority for a live inbox occurrence mutation; a parent address is not required. Edit and Remove retain their complete existing `nextTurn` and `nextStep` semantics, including plugin-injected context, while Steer requires a queued occurrence and an Agent that reports running when the command begins.
+
+The continuation manager keeps no second message-reservation state. One private `SubagentInbox` delegates Queue and Steer to the Agent inbox and owns the Activation's existing closing promise. Natural settlement waits for `Agent.whenIdle()`, an empty child Inbox, and disposal of every owned child. The manager confirms the Inbox, owned-child set, and wake generation under the child lock, then flushes final Session state while admission remains open. The final child-lock decision revalidates the Session sequence and the same residency facts, then synchronously starts an `Agent.runMaintenance()` task whose entry claims the idle phase and closes the wrapper in the same JavaScript turn. Every pending Inbox occurrence retains the Activation regardless of its delivery mode or provenance. Manager-owned deliveries, Inbox claims or discards, and owned-child release renew the wake generation. Direct Agent work accepted during the flush either changes the final Session or residency observation, remains active and prevents the final maintenance task from starting, or completes before revalidation.
+
+QueueDock Steer uses the Agent's best-effort delivery after the command admits a running queued occurrence. If the queued occurrence was claimed first, `queue-item-not-found` leaves its ordinary Queue delivery underway. If active cancellation wins during the synchronous transfer, Agent steering appends the message to `nextTurn`, latches a wake, and the Session command still succeeds. The selected message moves behind the remaining Queue in that fallback case. Newly composed Steer uses the same fallback and remains deliverable when it misses the nearest step.
+
+This decision partially supersedes the human-control exclusions in [Web subagent catalog and human continuation](2026-07-27-web-subagent-conversations.md), [Continuable subagents](2026-07-28-continuable-subagent-conversations.md), [Steer a queued Web message](../../archived/feature/2026-07-30-web-queue-steer-action.md), and [Steer the whole Web queue with an empty-draft Cmd/Ctrl+Enter](../../archived/feature/2026-08-06-web-queue-steer-all-gesture.md). The active records own catalog authorization and Activation lifecycle; the archived records preserve the original QueueDock Steer and gesture decisions.
+
+## Alternatives considered
+
+**Add `SubagentRuntime.steer()` and a new Remote.** Rejected because human prompt delivery already has a mode-bearing Client method and one authenticated Remote. A new public operation would expand both the service and model-adjacent surface without adding an execution primitive.
+
+**Add `subagents.updateQueue`.** Rejected because `session.updateQueue` already owns exact inbox occurrence mutation and its race failures. The projected continuable identity provides the narrow ownership-fence exception without adding another operation.
+
+**Route every subagent control through generic Session APIs.** Rejected because prompt and cancellation require subagent lineage authorization, cold-resume accounting, and dedicated failure mapping. Only live inbox occurrence mutation has enough target-local state to use the narrow ownership-fence exception.
+
+**Restrict continuable queue mutation to `nextTurn`.** Rejected because human inbox parity intentionally includes editing or removing pending steering and injected context. If a plugin needs a stronger transaction around its `nextStep` input, that protection belongs to the shared Agent inbox semantics rather than a subagent-only restriction.
+
+**Track waking work by `MessageId` and transfer that record across mutation.** Rejected because it duplicates the Inbox's pending set with a second activity ledger and couples residency to occurrence identity. `whenIdle()` waits for existing Agent activity, `Inbox.hasPending` conservatively retains every occurrence, the Activation generation invalidates stale observations, and the final maintenance task atomically joins idle ownership to admission closure. This choice can retain quiet injected context, but it avoids both an additional mutation protocol and silent loss of accepted steering.
+
+**Derive residency from `MessageSource.kind`, treating `plugin` as parked context.** Rejected because `kind` records who produced a message, not how it was delivered, and `MessageSourceMap` is merge-extensible. Plugins steer with a plugin source (`cordis-host-runner` failure reports, blocking Stop hooks) and hosts inject with non-plugin sources (`dsh-experimental-agent-team` quiet mail), so the correspondence fails in both directions. Treating all pending occurrences alike avoids that unsupported inference.
+
+## Consequences
+
+Continuable child conversations and ordinary Sessions share one human inbox interaction model and one Agent-loop queue. Human steering can affect a resident or cold-resumed child without changing public model controls. QueueDock remains useful for a live child after its parent goes offline, while new messages continue to respect direct-parent authorization.
+
+The generic Session command has one narrow ownership-fence exception for a live subagent-owned Agent with a valid own-suffix continuable identity. Because the operation addresses either inbox destination, a caller that knows a pending `MessageId` can edit or remove plugin-supplied next-step input, exactly as on an ordinary Session. QueueDock renders only `queued`-placement rows, so no browser gesture reaches that input; an edit there also keeps the original producer's `MessageSource`, which would attribute human text to that producer.
+
+Inbox notifications retain their occurrence semantics and do not carry continuation residency. Claim and discard notifications only wake settlement after pending work changes; `whenIdle()`, the final idle-phase maintenance task, `Inbox.hasPending`, the owned-child set, the Activation generation, and the Session sequence decide whether disposal is safe without depending on scheduler ordering, message identity, or provenance. The final flush precedes the closing cutoff, so a detached hook, job completion, or direct Agent delivery accepted during that await invalidates the observation instead of being stopped by the resulting disposal. Maintenance that remains active prevents the final task from claiming the idle phase; maintenance that starts and finishes during the flush has completed before disposal. A child left holding only injected context remains resident even though no driver is obliged to claim it; without a later waking delivery, queue removal, or manager teardown, that child and its live ancestors can remain resident for the process lifetime. A replayed Inbox follows the same conservative rule without reconstructing how each pending message was delivered.
+
+Model-side scheduling remains fixed rather than caller-selectable. The adjacent-Agent `send_message` tool always uses Steer, while only the browser human path chooses Queue or Steer.

+ 51 - 0
.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md

@@ -0,0 +1,51 @@
+# Agent Note: 可继续 subagent 的人类 inbox 控制
+
+Status: implemented
+
+[English](2026-08-27-continuable-subagent-human-inbox-control.md) | 中文
+
+## 问题
+
+可继续子级与普通 Agent 使用相同的 agent loop(智能体循环)和 inbox,但人类投递路径只公开 FIFO 后续轮次。Client 选择专用 subagent prompt Remote 时会丢弃既有的 Queue/Steer 选择,通用 Session ownership fence 又拒绝 subagent 所有身份的全部 queue 变更。因此,浏览器隐藏了在线子级 inbox 已经支持的控制。
+
+无差别开放通用 Session 控制会削弱 subagent 所有权规则。Prompt 投递仍需要确切在线直接父级鉴权与冷恢复记账,而 queue 变更必须拒绝一次性、未知、损坏和冷子级。child 自身 log suffix 中的有效 continuable descriptor 可标识哪些在线 subagent-owned Session 能使用 occurrence mutation。Settlement 还必须在 idle Agent 仍有会被 driver 认领的待投递工作时保留该 Agent,也不得拆除在 `whenIdle()` 兑现后才占用 idle 阶段执行 maintenance 任务的 Agent。
+
+## 决策
+
+在线可继续子级公开普通的人类 inbox 控制,不增加另一套 queue、Remote endpoint、queue action 或面向 Host 的 subagent 操作。一次性子级继续只读。
+
+现有 `SubagentPromptRequest` 携带 `delivery: 'queue' | 'steer'`。Client 把 `Session.prompt(content, mode)` 已选出的 mode 经 `subagent.prompt` 原样转发。Remote 仍要求确切在线直接父级,随后使用一个包内 continuation manager 投递操作。Queue 调用 `Agent.followup(message)`;steer 调用 `Agent.steer(message)`。两条路径共享 child lock、冷恢复、最终父级重新鉴权、调用方 signal 截止、`MessageId` 创建、回滚与 dispose 竞态处理。该人类选择不新增公开调度方法或模型工具;由其他决策拥有的 `sendMessage()` 与面向模型的 `send_message` 操作保留固定的相邻 Agent Steer 语义。
+
+浏览器为可继续子级提供普通的繁忙态 Enter/Cmd+Enter Queue/Steer 偏好、QueueDock Edit/Remove/Steer 操作,以及空草稿 steer-all 手势。Send 与 Stop 继续是独立控制。Composer prompt 会创建新的已准入工作,因此仍要求在线父级。QueueDock 变更直接寻址已经在线的 inbox 工作,所以父级离线时仍可使用;父级离线的 composer 继续锁定。
+
+现有 `session.updateQueue(itemId, action)` 会解析确切在线 Agent,并且只有 subagent-owned Session 的当前 projected identity 为 continuable、descriptor 序号属于 child 自身的非 seed suffix 时才会准入。在线 one-shot Agent 以及缺失、仅继承或无效的 identity 都会继续触发所有权失败。Agent 不存在时返回 `queue-item-not-found`,且不会冷恢复子级。对在线 inbox occurrence 变更而言,目标 Session id 已是充分的人类权限;无需 parent 地址。Edit 与 Remove 保留既有完整 `nextTurn` 和 `nextStep` 语义,包括插件注入的 context;Steer 要求排队 occurrence,且 command 开始时 Agent 必须报告 running。
+
+Continuation manager 不保留第二套消息 reservation 状态。一个私有 `SubagentInbox` 会把 Queue 与 Steer 委托给 Agent inbox,并持有 Activation 既有的 closing promise。自然结算会等待 `Agent.whenIdle()`、child Inbox 为空以及所拥有的每个子级完成 dispose。管理器会在 child lock 内确认 Inbox、owned-child set 与 wake generation,再在准入保持开放时 flush 最终 Session 状态。最终 child-lock 决策会重新验证 Session 序号与相同的驻留事实,然后同步启动一个 `Agent.runMaintenance()` 任务;该任务的入口会占用 idle 阶段,并在同一个 JavaScript turn 内关闭包装层。每个待处理 Inbox occurrence 都会保留 Activation,无论其投递模式或来源如何。由 manager 所有的投递、Inbox claim 或 discard,以及所拥有子级的释放都会更新 wake generation。flush 期间直接接受的 Agent 工作要么改变最终 Session 或驻留观察,要么保持活跃并阻止最终 maintenance 任务启动,要么在重验前完成。
+
+QueueDock Steer 在 command 准入一个正在运行的排队 occurrence 后,采用 Agent 的 best-effort 投递。如果排队 occurrence 先被 claim,`queue-item-not-found` 表示其普通 Queue 投递已经开始。如果活跃取消在同步转移期间先发生,Agent steering 会把消息追加到 `nextTurn`、锁存唤醒,Session command 仍然成功。在该 fallback 情况下,选中消息会移到 Queue 剩余项之后。新组合的 Steer 使用同样的 fallback,错过最近步骤时仍保证可投递。
+
+本决策部分取代 [Web subagent 目录与人类 continuation](2026-07-27-web-subagent-conversations.zh.md)、[可继续 subagent](2026-07-28-continuable-subagent-conversations.zh.md)、[Steer Web 已排队消息](../../archived/feature/2026-07-30-web-queue-steer-action.md)和[用空草稿 Cmd/Ctrl+Enter steer 整个 Web queue](../../archived/feature/2026-08-06-web-queue-steer-all-gesture.md)中的人类控制排除项。活跃记录拥有目录鉴权与 Activation 生命周期;归档记录保留最初的 QueueDock Steer 与手势决策。
+
+## 考虑过的替代方案
+
+**新增 `SubagentRuntime.steer()` 与 Remote。** 拒绝,因为人类 prompt 投递已经拥有带 mode 的 Client 方法和一个已鉴权 Remote。新的公开操作会扩大 service 与模型相邻接口,却不增加执行原语。
+
+**新增 `subagents.updateQueue`。** 拒绝,因为 `session.updateQueue` 已经拥有准确 inbox occurrence 变更及其竞态失败。Projected continuable identity 提供狭窄的 ownership-fence 例外,无需新增操作。
+
+**把所有 subagent 控制都路由到通用 Session API。** 拒绝,因为 prompt 与取消需要 subagent 血缘鉴权、冷恢复记账与专用失败映射。只有在线 inbox occurrence 变更拥有足够的目标本地状态,可使用狭窄的 ownership-fence 例外。
+
+**把可继续 queue 变更限制在 `nextTurn`。** 拒绝,因为人类 inbox 对齐有意包括编辑或删除待处理 steering 与注入 context。如果插件需要围绕其 `nextStep` 输入建立更强事务,该保护应属于共享 Agent inbox 语义,而非 subagent 专属限制。
+
+**按 `MessageId` 跟踪唤醒工作,并在 mutation 中转移该记录。** 拒绝,因为这会用第二套活动账本重复 Inbox 的待处理集合,并让驻留依赖 occurrence 身份。`whenIdle()` 会等待既有 Agent 活动,`Inbox.hasPending` 保守地保留每个 occurrence,Activation generation 会让过期观察失效,而最终 maintenance 任务则以原子方式衔接 idle ownership 与准入关闭。这项选择可能保留静默注入的 context,但既避免额外的 mutation 协议,也避免静默丢失已接受的 steering。
+
+**用 `MessageSource.kind` 推导驻留,把 `plugin` 视为停放 context。** 拒绝,因为 `kind` 记录的是消息由谁产生,而非如何投递,且 `MessageSourceMap` 可合并扩展。插件会以 plugin 来源 steer(`cordis-host-runner` 的失败报告、阻断式 Stop hook),host 也会以非 plugin 来源 inject(`dsh-experimental-agent-team` 的静默邮件),因此该对应关系在两个方向上都不成立。统一对待所有待处理 occurrence 可以避免这种没有依据的推断。
+
+## 结果
+
+可继续子级会话与普通 Session 共享一套人类 inbox 交互模型和一套 Agent-loop queue。人类 steering 可以影响驻留或冷恢复的子级,而不改变公开模型控制。父级离线后,QueueDock 对在线子级仍有用;新消息则继续遵守直接父级鉴权。
+
+通用 Session command 为拥有有效自身 suffix continuable identity 的在线 subagent-owned Agent 提供一个狭窄的 ownership-fence 例外。因为该操作可寻址两个 inbox 目标,知道待处理 `MessageId` 的调用方可以像操作普通 Session 一样,编辑或删除插件提供的 next-step 输入。QueueDock 只渲染 `queued` placement 的行,因此没有浏览器手势能到达该输入;在那里编辑还会保留原产出方的 `MessageSource`,从而把人类文本归属给该产出方。
+
+Inbox notification 保留 occurrence 语义,不携带 continuation 驻留状态。Claim 与 discard notification 只负责在待处理工作变化后唤醒 settlement;`whenIdle()`、最终 idle 阶段 maintenance 任务、`Inbox.hasPending`、owned-child set、Activation generation 与 Session 序号无需依赖调度顺序、消息身份或来源即可决定何时安全 dispose。最终 flush 位于 closing cutoff 之前,因此 detached hook、job completion 或直接 Agent 投递只要在该 await 期间被接受,就会让观察失效,而不会被随后发生的 dispose 停止。仍然活跃的 maintenance 会阻止最终任务占用 idle 阶段;在 flush 期间开始并结束的 maintenance 已在 dispose 前完成。仅持有被注入 context 的 child 即使没有 driver 必须认领它,也会保持驻留;如果之后没有唤醒投递、queue removal 或 manager teardown,该 child 及其在线祖先可以在进程生命周期内一直驻留。重放出的 Inbox 遵循同一条保守规则,无需重建每条待处理消息的投递方式。
+
+模型侧调度保持固定,不由调用方选择。相邻 Agent 的 `send_message` 工具始终使用 Steer,只有浏览器人类路径选择 Queue 或 Steer。

+ 2 - 2
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md
-2026-08-18-in-job-partitioned-coverage.md: ef22245354bbeb26d551e227a6b5ca26ca22306c
-2026-08-18-in-job-partitioned-coverage.zh.md: 67b434a8559f32da5bd2f863f526ce2b10e36b4c
+2026-08-18-in-job-partitioned-coverage.md: 8a3eb202255ecbf680dfa0af656fd5b89e958001
+2026-08-18-in-job-partitioned-coverage.zh.md: e7ca2692485fa4f9f0135821963e64347c3cdfdd

+ 1 - 1
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md

@@ -30,7 +30,7 @@ A normal failed test still emits a blob through `--coverage.reportOnFailure`, al
 
 `scripts/coverage-partitions.spec.ts` pins argument construction, package-script separator removal, one-worker partitions, weighted longest-processing-time assignment (including a case that fails when assignment ignores recorded weights), the single merged threshold command, failed-test merging, failure diagnostics before complete-blob validation, waiting for sibling partitions after a spawn failure, and link-safe cleanup. `scripts/run-gates.spec.ts` pins opt-in selection, invalid-count rejection, both native Windows coverage gates' complete-build dependency, the complete Windows inventory with its blocking split, and unbuffered streamed output. React fake-timer cases that can move between partitions advance timers inside `act()`; geometry-dependent portal tests stub their element rectangles so a different shard schedule cannot turn deferred updates or jsdom coordinates into coverage-only failures.
 
-Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current coverage phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 36 across the failover VM's six runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency.
+Completed native Windows comparisons measured two partitions near 405 seconds and sixteen partitions at 112.66–122.01 seconds under the earlier gate ordering; those values compare partition latency, not the current peak. The current coverage phase runs four instrumented partition processes beside two exempt workers, for six coverage execution units. Sixteen partitions would raise that phase to eighteen before any still-running production-site work or system overhead. Four partitions keep separate-process isolation and match Linux, at the cost of a longer single-job coverage wall time; the trade-off is accepted to reduce vitest worker startup failures under high self-hosted concurrency. Two Linux samples measured the conservative two-partition configuration at 276.68 and 282.27 seconds; that configuration was stable but halved the ordinary path's four instrumented workers. Four partitions restore that fan-out, for six total coverage execution units on the 16-core hosted runner and at most 192 across the failover VM's 32 runner instances. These values come from completed runs or fixed capacity bounds; an unfinished run crossing an arbitrary elapsed-time mark is not evidence for increasing concurrency.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md

@@ -30,7 +30,7 @@ Status: implemented
 
 `scripts/coverage-partitions.spec.ts` 固定了参数构造、包脚本分隔符移除、单 worker 分区、加权最长处理时间分配(含一个在分配忽略记录权重时必然失败的用例)、唯一一次合并阈值命令、失败测试合并、完整 blob 校验前的失败诊断、spawn 失败后等待兄弟分区,以及链接安全清理。`scripts/run-gates.spec.ts` 固定了显式启用、非法数量拒绝、两道原生 Windows 覆盖率门禁对完整构建的依赖、完整 Windows 清单及其阻断性划分,以及不缓冲的流式输出。可能在分区间移动的 React fake-timer 用例会在 `act()` 内推进计时器;依赖几何位置的 portal 测试会固定元素矩形,使不同分片调度不会把延迟更新或 jsdom 坐标变成只在覆盖率运行中出现的失败。
 
-已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的覆盖率阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 6 个 runner 实例最多合计 36 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。
+已完成的原生 Windows 对比中,双分区耗时约 405 秒,16 分区耗时 112.66–122.01 秒;这些数据来自先前的门禁顺序,只用于比较分区延迟,不代表当前峰值。当前的覆盖率阶段会让 4 个插桩分区进程与 2 个豁免 worker 并行,共形成 6 个覆盖率执行单元。若改为 16 个分区,则在尚未结束的生产网站工作或系统开销计入之前,该阶段就会达到 18 个执行单元。4 个分区保留独立进程隔离并与 Linux 对齐,代价是单 job 覆盖率墙钟更长;这是为了降低自托管高并发下 vitest worker 启动失败而接受的取舍。两个 Linux 样本中,保守的双分区配置耗时 276.68 秒和 282.27 秒;该配置运行稳定,却把普通路径原有的 4 个插桩 worker 减半。4 个分区恢复这份并发,使 16 核托管 runner 上的覆盖率执行单元总数为 6,故障切换虚拟机的 32 个 runner 实例最多合计 192 个执行单元。这些数值来自完整运行或固定容量上限;运行尚未结束时跨过任意耗时刻度,不构成增加并发的证据。
 
 ## 曾考虑的替代方案
 

+ 6 - 0
.agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md
+2026-08-28-ci-node-compile-cache-data-disk.md: 3f5f7eb2f00a37b635b63093f60c66688b82316b
+2026-08-28-ci-node-compile-cache-data-disk.zh.md: c55a6c73cf98f0dd0b31b50d64e7937ee9e14305

+ 43 - 0
.agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md

@@ -0,0 +1,43 @@
+# Agent Note: Redirect the Node compile cache to the data-volume runner temp
+
+Status: implemented
+
+English | [中文](2026-08-28-ci-node-compile-cache-data-disk.zh.md)
+
+## Problem
+
+The self-hosted Linux CI VM (`vm-backup` pool, 32 runner instances on one host) exhausts the root partition's inode capacity. Issue #3134's residue (`/tmp/dsh-*`) is one source; a second, larger source is the Node.js module compile cache. Tools in the CI toolchain call `module.enableCompileCache()` explicitly: pnpm 11.7.0 enables the cache in its entry (`module.enableCompileCache?.()` in `bin/pnpm.mjs`) on every invocation, and TypeScript does so in `tsc`/`tsserver`; vitest forwards the API but does not enable it itself. Every such call writes the serialized V8 bytecode cache under `os.tmpdir()/node-compile-cache`. On the shared VM that is the root partition's `/tmp`: measured 2026-08-28 at **697,389 inodes and 9.2 GB**, with 34,110 files younger than 1 hour — the cache grows on every CI run and is never cleaned, so the root partition's 3,276,800 inodes trend toward exhaustion even after the `dsh-*` residue is controlled.
+
+## Decision
+
+Each Linux lane that can run on the `vm-backup` pool under failover (`ci.yml` static/coverage/snapshots — hosted by default, self-hosted only when `DSH_CI_FAILOVER_LINUX=selfhosted` — and `ci-master.yml` serial standby, always self-hosted) redirects `NODE_COMPILE_CACHE` to the per-runner data-volume temp dir `${{ runner.temp }}/node-compile-cache`. `runner.temp` lives on `/data_local` (1 TB, ~1% inode used) and is per-runner (`_workNN/_temp`), so the cache stops consuming root-partition inodes.
+
+The redirect is a step right after `actions/checkout` that writes `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache` into `$GITHUB_ENV`, so every later step in the lane — `pnpm/action-setup`, the store-path probe, install, Playwright install, and the test gate — inherits it. Injection is required because the `runner` context is unavailable in job-level `env` (the same constraint as the earlier TMPDIR work), and a step-level env on the gate step alone would leave the earlier pnpm calls writing to the root partition's `/tmp`. A confined child (bwrap/Landlock) whose sandbox does not grant the `runner.temp` path inherits the variable but **silently skips caching** — verified on the VM: with `NODE_COMPILE_CACHE` pointing at an ungranted path inside bwrap, `node` runs normally (exit 0), unlike `mkdtemp` which fails hard with a read-only filesystem error. The compile cache is best-effort by design; a failed write is a cache miss, not a crash.
+
+## Verification
+
+- VM probe: `NODE_COMPILE_CACHE=/data_local/ci/compile-cache-probe node -e 'require("node:fs")'` wrote a `v22.23.2-x64-*` cache subdirectory on the data disk (location switch effective).
+- VM probe (bwrap): with `NODE_COMPILE_CACHE` set to a path the bwrap profile does not grant, `node` ran normally (exit 0) — cache write failure is tolerated.
+- `scripts/ci-workflow.spec.ts` asserts every Linux lane injects `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache` (a `$GITHUB_ENV` `KEY=VALUE` line) into `$GITHUB_ENV` before `pnpm/action-setup`; the position assertion fails if the injection moves after the first pnpm call.
+- CI lanes: the three required Linux jobs (hosted by default, self-hosted `vm-backup` under `DSH_CI_FAILOVER_LINUX`) run the full suite under the new env; a regression in cache handling would surface as lane failure.
+
+## Alternatives considered
+
+### Why not disable the compile cache entirely?
+
+`NODE_DISABLE_COMPILE_CACHE=1` would stop root-partition growth immediately but forfeit the startup speedup on every run, and the cache is a legitimately useful Node feature (enabled explicitly by pnpm and TypeScript). Redirecting preserves the benefit while moving the cost off the constrained partition.
+
+### Why not add `node-compile-cache` to the `dsh-*` residue sweep?
+
+The CI sweep (added in the residue-cleanup change) targets test residue; the compile cache is a cache, not residue. Deleting it every run would discard the speedup the cache exists to provide. Redirecting is the structural fix: the cache's growth moves to the volume sized for it.
+
+### Why not job-level env or gate-step env only?
+
+The `runner` context is only available in step-level `env`; job-level `env` evaluates it to an empty string (GitHub contexts-availability), which would silently leave the cache on the root partition. A step-level env on the gate step alone would cover only that step: every earlier pnpm invocation in the lane (setup, store-path probe, install) would still write to the root partition's `/tmp`. Injecting into `$GITHUB_ENV` in a step between checkout and `pnpm/action-setup` sets the variable before the lane's first pnpm call, so one step covers the whole lane.
+
+## Consequences
+
+- **Bought**: the Node compile cache stops consuming root-partition inodes; inode pressure from this source is removed without losing the cache's startup benefit. The cache now lives in per-runner `_workNN/_temp` on the data volume.
+- **Cost**: the cache accumulates in `runner.temp`, which the runner does not empty between jobs (measured earlier) — but on the data volume (~1% inode used) that is harmless.
+- **Cost**: confined children without the `runner.temp` grant skip caching for their own `node` invocations; this is a cache miss, not a failure, and matches Node's best-effort contract.
+- **Cost**: the change touches CI configuration only; local development keeps the default `os.tmpdir()` location.

+ 43 - 0
.agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 将 Node 编译缓存重定向到数据卷 runner 临时目录
+
+Status: implemented
+
+[English](2026-08-28-ci-node-compile-cache-data-disk.md) | 中文
+
+## 问题
+
+自托管 Linux CI 虚拟机(`vm-backup` 池,32 个 runner 实例共宿一机)的根分区 inode 正在耗尽。issue #3134 的残留(`/tmp/dsh-*`)是来源之一;第二个、更大的来源是 Node.js 模块编译缓存。CI 工具链中的工具显式调用 `module.enableCompileCache()`:pnpm 11.7.0 在入口(`bin/pnpm.mjs` 中的 `module.enableCompileCache?.()`)每次调用都启用缓存,TypeScript 在 `tsc`/`tsserver` 中启用;vitest 转发该 API 但自身不启用。每次这样的调用都把序列化 V8 字节码缓存写到 `os.tmpdir()/node-compile-cache`。在共享虚拟机上即根分区的 `/tmp`:2026-08-28 实测为 **697,389 个 inode、9.2 GB**,其中 34,110 个文件不足 1 小时——缓存每次 CI 运行都在增长且从不清理,即使 `dsh-*` 残留被控制,根分区 3,276,800 个 inode 仍趋向耗尽。
+
+## 决策
+
+每个可能运行在 `vm-backup` 池的 Linux lane(`ci.yml` static/coverage/snapshots——默认 hosted,仅 `DSH_CI_FAILOVER_LINUX=selfhosted` 时自托管;`ci-master.yml` serial standby——始终自托管)都把 `NODE_COMPILE_CACHE` 重定向到 per-runner 数据卷临时目录 `${{ runner.temp }}/node-compile-cache`。`runner.temp` 在 `/data_local`(1 TB,inode 用量约 1%)上,per-runner(`_workNN/_temp`),因此缓存不再消耗根分区 inode。
+
+重定向是在 `actions/checkout` 之后的一个 step,把 `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache` 写入 `$GITHUB_ENV`,因此 lane 中后续每个 step——`pnpm/action-setup`、store 路径探测、安装、Playwright 安装和测试门禁——都会继承该变量。必须用注入而非 job 级 env:`runner` 上下文在 job 级 `env` 不可用(与早前 TMPDIR 工作相同的约束);而仅给门禁 step 设 step 级 env 会让更早的 pnpm 调用继续写根分区 `/tmp`。sandbox(bwrap/Landlock)未授权 `runner.temp` 路径的受限子进程会继承该变量但**静默跳过缓存**——已在虚拟机上验证:`NODE_COMPILE_CACHE` 指向 bwrap 内未授权路径时,`node` 正常运行(exit 0),与 `mkdtemp` 的只读文件系统硬失败不同。编译缓存按设计是尽力而为;写失败只是缓存未命中,不是崩溃。
+
+## 验证
+
+- VM 探针:`NODE_COMPILE_CACHE=/data_local/ci/compile-cache-probe node -e 'require("node:fs")'` 在数据盘写出了 `v22.23.2-x64-*` 缓存子目录(位置切换生效)。
+- VM 探针(bwrap):`NODE_COMPILE_CACHE` 指向 bwrap profile 未授权的路径时,`node` 正常运行(exit 0)——缓存写失败被容忍。
+- `scripts/ci-workflow.spec.ts` 断言每个 Linux lane 都在 `pnpm/action-setup` 之前把 `NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache`(`$GITHUB_ENV` 的 `KEY=VALUE` 行)注入 `$GITHUB_ENV`;位置断言在注入移出首次 pnpm 调用之后时会失败。
+- CI lane:三个必需的 Linux job(默认 hosted,`DSH_CI_FAILOVER_LINUX` 时自托管 `vm-backup`)会在新 env 下跑完整套件;缓存处理回归会表现为 lane 失败。
+
+## 备选方案
+
+### 为什么不彻底禁用编译缓存?
+
+`NODE_DISABLE_COMPILE_CACHE=1` 会立即停止根分区增长,但会放弃每次运行的启动加速,而缓存是 Node 正当有用的特性(由 pnpm 和 TypeScript 显式启用)。重定向在保留收益的同时把成本移出受限分区。
+
+### 为什么不把 `node-compile-cache` 纳入 `dsh-*` 清理?
+
+CI 清理(残留清理改动中新增)针对测试残留;编译缓存是缓存而非残留。每次运行删掉它会丢弃缓存本要提供的加速。重定向是结构性修复:缓存的增长移到为它准备的卷上。
+
+### 为什么不用 job 级 env 或只给门禁 step 设 env?
+
+`runner` 上下文只在 step 级 `env` 可用;job 级 `env` 会求值为空字符串(GitHub contexts-availability),静默让缓存留在根分区。只给门禁 step 设 step 级 env 也只覆盖那一个 step:lane 中更早的每次 pnpm 调用(setup、store 路径探测、安装)仍会写根分区 `/tmp`。在 checkout 与 `pnpm/action-setup` 之间的 step 注入 `$GITHUB_ENV`,使变量在 lane 首次 pnpm 调用之前生效,一个 step 即可覆盖整条 lane。
+
+## 后果
+
+- **买到**:Node 编译缓存不再消耗根分区 inode;该来源的 inode 压力被移除且不损失缓存的启动收益。缓存现在位于数据卷上的 per-runner `_workNN/_temp`。
+- **代价**:缓存在 `runner.temp` 累积,而 runner 不会在 job 之间清空它(早前实测)——但在数据卷(inode 用量约 1%)上无碍。
+- **代价**:没有 `runner.temp` 授权的受限子进程会为其自身的 `node` 调用跳过缓存;这是缓存未命中而非失败,符合 Node 的尽力而为契约。
+- **代价**:改动只涉及 CI 配置;本地开发保持默认 `os.tmpdir()` 位置。

+ 6 - 0
.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md
+2026-09-06-agent-request-freeze-provenance.md: 7a4816df61f6490647aba6f0603719e1b4662a20
+2026-09-06-agent-request-freeze-provenance.zh.md: 239d7e69df1596010ef0f3c8789250f654a75cb1

+ 65 - 0
.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.md

@@ -0,0 +1,65 @@
+# Agent Note: Reuse only loop-proven message freezes
+
+Status: implemented
+
+English | [中文](2026-09-06-agent-request-freeze-provenance.zh.md)
+
+## Problem
+
+Long tool conversations repeatedly traverse immutable history while constructing requests. The [backend continuation baseline](../testing/2026-09-06-backend-continuation-performance.md) attributes 132.876 ms of sampled CPU self time to `buildRequest`'s `deepFreeze` during a 211.300 ms request-history operation. Skipping all frozen roots is unsafe: restore adopts independently owned graphs without freezing them, and a shallow-frozen message can still contain mutable content.
+
+## Decision
+
+Each `ReactLoopAgent` owns a private WeakSet<Message> containing only identities whose complete `deepFreeze` call succeeded in that instance. Every unseen message is deep-frozen in place, then added. Later requests reuse that proof. A fresh loop proves each identity again; equal message ids do not establish object identity. Weak references add no ownership of compacted history.
+
+The loop deep-freezes the small local canonical header on every request. `canonicalHeader` shares nested values, and `Session.append` freezes a separate snapshot: neither operation proves the local tools or a `NO_ADAPTER` fallback's stop array immutable. The loop separately freezes its fresh messages array and request envelope, retains `markAgentLoopRequest`, and leaves the live `AbortSignal` mutable. Restored message identity and containing event-wrapper mutability remain unchanged.
+
+This specializes request construction, not Session ownership or general `deepFreeze` behavior. `Session.deriveMessages` and `fromRestore` remain unchanged. LLM file, image, and replay projections retain their own freezes because their newly produced values have no loop-local proof. The [reconstructable-request decision](../architecture/2026-07-05-reconstructable-requests.md) continues to own observable immutability and logged request reconstruction.
+
+## Measurement evidence
+
+Apple M4 Pro, macOS arm64, Node 24.19.0; independent worktree dependencies and built artifacts. The exact parent Agent source at 1dc3296eba is rebuilt for the negative control, then the optimized source is restored and rebuilt. Each row retains all five fresh-process totals in sampling order; all timings are milliseconds. Exclusive slots do not overlap repository builds or sibling benchmarks.
+
+| Implementation and UTC interval (2026-09-06) | Request-history raw totals | Median | 175 ms verdict |
+|---|---|---:|---|
+| Optimized, 07:15:40–07:15:51 | 65.737375, 67.292833, 68.035208, 65.380417, 67.919167 | 67.292833 | Pass |
+| Original, 07:17:06–07:17:10 | 249.050708, 238.275291, 242.172084, 250.093166, 246.130875 | 246.130875 | Fail |
+| Optimized repeat, 07:18:17–07:18:20 | 66.693500, 67.402083, 68.665000, 66.642083, 66.609125 | 66.693500 | Pass |
+
+The same 800-turn, four-tools-per-historical-turn history and 40 live requests complete in every sample: 13,923 events, no live tool calls. The repeat median is 72.9% below the isolated original. The historical 70 ms M4 expectation rounds above both optimized medians; applying the shared 2× CI scale and 1.25× headroom produced the 175 ms budget used in the table. These remain local reference measurements, not hosted-runner expectations. The explicit hosted calibration below owns the enforced request-history budget; no other case or memory budget changes here.
+
+The first optimized slot also measures cold tool continuation: totals 185.839958, 185.235583, 185.865917, 189.213459, 185.279417; median 185.839958 ms. Every sample completes 40 requests and 160 tool calls with 14,143 events. Retained heap samples are 22.591591, 22.590355, 22.594795, 22.591743, 22.594681 MiB, below the unchanged 28.75 MiB budget. The earlier baseline's approximately 22.295 MiB highlights the small provenance-table cost; weak keys prevent the table itself retaining replaced messages.
+
+The same slot's shipped SDK profile completes 100 turns, 200 requests, and 800 real reads per sample. Totals are 1428.555292, 1160.396333, 1139.843500, 1135.834750, 1155.890334 ms; median 1155.890334 ms. The first sample includes 461.829250 ms boot time versus 164–169 ms for the others and is retained, not discarded. Provider serialization, network time, and browser rendering remain excluded as specified by the baseline owner.
+
+An earlier original-code run at 06:58:28 UTC overlaps a sibling build because of scheduling-message latency: totals 264.269792, 282.442000, 365.836334, 293.172791, 288.719500 ms; median 288.719500 ms. It also fails 175 ms but is not calibration evidence. The isolated original row replaces that comparison, without removing or averaging away the contaminated samples.
+
+### Standard hosted CI calibration
+
+The standard two-CPU `ubuntu-24.04` lane runs Node 24.20.0. [Run 34033336380, job 101487280801](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336380/job/101487280801) measures the optimized request path at merge commit `8fba64d9ae06d1a9a778a95487bb915d24cb0644` in Azure eastus: 183.355397, 184.468253, 185.042397, 182.160790, 182.924728 ms; median 183.355397 ms. Every sample completes the same 40 requests and 13,923 events. All five exceed the historical 175 ms budget without changing the WeakSet implementation or workload.
+
+A second hosted run of the same request implementation, [run 34033336246, job 101487216170](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336246/job/101487216170), records 145.644577, 144.204300, 143.072572, 145.985903, 146.834474 ms; median 145.644577 ms. It uses the same Ubuntu image and Node version but a different worker in Azure westus3 at merge commit `c366e49`. This faster run does not replace the eastus evidence or establish why the workers differ. The older self-hosted `VM-7-113-ubuntu-ci-9` run with Node 24.18.1 ([run 34021903421, job 101456015028](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34021903421/job/101456015028)) records 110.025154, 119.958978, 108.266860, 107.557950, 108.538902 ms; median 108.538902 ms. Its runner and Node version do not calibrate the standard hosted lane.
+
+The request-history CI expectation is 190 ms, rounded above this observed range. The enforced median budget is `ceil(190 × 1.25) = 238 ms`; the shared 2× reference-machine scale does not apply again to a CI measurement. This matches the direct-CI calibration method of the [63 ms Session-reopen budget](../../../../benchmarks/session-open/session-open.bench.ts), rather than relabeling the M4 reference as hosted evidence. The 238 ms budget remains below the isolated original implementation’s 246.130875 ms M4 median.
+
+Deterministic controls call the same `assertRequestHistoryBudget` assertion as the timed case. They accept the recorded hosted median and maximum (185.042397 ms), reject the recorded original M4 median, and reject a synthetic 250 ms median from 248, 250, 252, 251, 249 ms inputs. The synthetic inputs model a material regression; they are not runtime measurements. Replaying recorded values verifies the assertion, not a new hosted run. The acceptance control fails at 175 ms before calibration; all three controls and the five request-freeze behavior tests pass at 238 ms.
+
+## Alternatives considered
+
+**Return immediately for `Object.isFrozen`.** A frozen root does not prove its descendants frozen. Applying this shortcut to the shared helper would weaken every caller, including restore and projection paths.
+
+**Trust every Session message or cache message ids.** Restore explicitly permits owned unfrozen data; replacements can preserve an id while changing identity and content. Only completed traversal of that exact object proves the request's requirement.
+
+**Retain a strong Set or share a global proof cache.** Strong references extend old history lifetime. Global caching expands ownership beyond the Agent and is unnecessary for repeated requests from one loop.
+
+**Remove downstream projection freezes.** Projected file/image/replay messages are distinct values with separate ownership. Optimizing them requires their own evidence and is not implied by freezing canonical history.
+
+## Consequences
+
+Request construction still scans message identities and allocates a fresh array; it avoids recursively traversing already-proven history. Each loop pays one complete traversal for restored history. Local headers remain a per-request cost. Message values, request markers, previous request snapshots, cancellation, and serialized SDK outputs keep their existing behavior.
+
+The [focused tests](../../../../packages/core/agent-loop/tests/request-freeze.spec.ts) exercise shallow-frozen restored roots with mutable descendants, wrapper identity and mutability, successful-only provenance, repeated requests, same-id compaction replacements, a fresh loop, nested tool schemas, adapter and `NO_ADAPTER` stop arrays, held requests, and live cancellation. Reconstruction and cancellation suites cover adjacent loop semantics. Performance measurements use the unchanged [continuation workload](../../../../benchmarks/agent-continuation/workload.ts), not a smaller synthetic microbenchmark.
+
+Validation runs 646 Agent-loop and LLM tests with 100% statement, branch, function, and line coverage of agent.ts. Keyless TypeScript SDK bash-tool and multi-turn snapshots pass against rebuilt libraries. Python sdk-minimal and sdk-snapshot checks pass against an independently packaged node24-macos-arm64 executable. Neither SDK requires an expected-output change. The packaging deploy temporarily removes workspace dependency links; a frozen-lockfile install restores them before source checks, without a tracked dependency change.
+
+The active immutability, message-identity, observable-state-machine, and backend-baseline notes remain independently useful; none is fully superseded or archived. This note specializes the request-freezing mechanism and cross-links its reconstructability owner.

+ 65 - 0
.agents/notes/implemented/simplification/2026-09-06-agent-request-freeze-provenance.zh.md

@@ -0,0 +1,65 @@
+# Agent Note: 仅复用循环已证明的消息冻结
+
+Status: implemented
+
+[English](2026-09-06-agent-request-freeze-provenance.md) | 中文
+
+## 问题
+
+长工具对话在构造请求时反复遍历不可变历史。[后端续跑基线](../testing/2026-09-06-backend-continuation-performance.zh.md)在一次 211.300 ms 的请求历史操作中,将 132.876 ms 的采样 CPU 自耗时归因于 `buildRequest` 的 `deepFreeze`。跳过所有已冻结根对象并不安全:恢复操作会接管独立拥有的对象图而不冻结它们,浅冻结消息仍可能包含可变内容。
+
+## 决策
+
+每个 `ReactLoopAgent` 拥有私有 WeakSet<Message>,其中只记录该实例中完整 `deepFreeze` 调用成功的对象身份。每个未见消息先原地深冻结,再加入集合。后续请求复用该证明。新循环会重新证明每个对象;消息 id 相等不能证明对象身份相同。弱引用不会增加对已压缩历史的所有权。
+
+循环每次请求都深冻结较小的本地规范化 header。`canonicalHeader` 共享嵌套值,`Session.append` 冻结的是独立快照:两者都不能证明本地 tools 或 `NO_ADAPTER` 回退中的 stop 数组不可变。循环分别冻结新消息数组与请求封装,保留 `markAgentLoopRequest`,并保持实时 `AbortSignal` 可变。恢复消息的对象身份及其外围事件包装对象的可变性保持不变。
+
+该决策专门优化请求构造,不改变 Session 所有权或通用 `deepFreeze` 行为。`Session.deriveMessages` 与 `fromRestore` 保持不变。LLM(大语言模型)的文件、图像和回放投影保留各自的冻结,因为它们新生成的值没有循环本地证明。[可重建请求决策](../architecture/2026-07-05-reconstructable-requests.zh.md)继续拥有可观察的不可变性与基于日志的请求重建规则。
+
+## 测量证据
+
+Apple M4 Pro、macOS arm64、Node 24.19.0;worktree 使用独立依赖和构建产物。负对照重新构建 1dc3296eba 中精确的父版本 Agent 源码,随后恢复并重新构建优化源码。每行按采样顺序保留全部五个新进程总耗时;时间单位均为毫秒。独占时段不与仓库构建或其他基准重叠。
+
+| 实现与 UTC 时段(2026-09-06) | 请求历史原始总耗时 | 中位数 | 175 ms 判定 |
+|---|---|---:|---|
+| 优化版,07:15:40–07:15:51 | 65.737375, 67.292833, 68.035208, 65.380417, 67.919167 | 67.292833 | 通过 |
+| 原版,07:17:06–07:17:10 | 249.050708, 238.275291, 242.172084, 250.093166, 246.130875 | 246.130875 | 失败 |
+| 优化版复测,07:18:17–07:18:20 | 66.693500, 67.402083, 68.665000, 66.642083, 66.609125 | 66.693500 | 通过 |
+
+每个样本都完成相同的 800 轮历史(每个历史轮次四个工具)和 40 个实时请求:13,923 个事件,无实时工具调用。复测中位数比独占原版低 72.9%。历史 M4 期望值 70 ms 向上取整并高于两次优化版中位数;应用共享 2× CI 系数和 1.25× 余量,得到表中使用的 175 ms 预算。这些仍是本地参考测量,而非托管运行器期望值。下文的显式托管校准拥有实际执行的请求历史预算;本文不改变其他场景或内存预算。
+
+首个优化版时段还测量冷启动工具续跑:总耗时 185.839958, 185.235583, 185.865917, 189.213459, 185.279417;中位数 185.839958 ms。每个样本都完成 40 个请求、160 个工具调用和 14,143 个事件。保留堆样本为 22.591591, 22.590355, 22.594795, 22.591743, 22.594681 MiB,低于不变的 28.75 MiB 预算。先前基线约 22.295 MiB,显示了证明表的小额成本;弱键防止表本身保留已替换消息。
+
+同一时段的随产品发布 SDK profile 每个样本都完成 100 轮、200 个请求和 800 次真实读取。总耗时为 1428.555292, 1160.396333, 1139.843500, 1135.834750, 1155.890334 ms;中位数 1155.890334 ms。首个样本包含 461.829250 ms 启动时间,其他样本为 164–169 ms;首个样本被保留而非丢弃。供应商序列化、网络时间和浏览器渲染仍按基线所属说明排除。
+
+较早的原版运行始于 06:58:28 UTC,因调度消息延迟而与其他构建重叠:总耗时 264.269792, 282.442000, 365.836334, 293.172791, 288.719500 ms;中位数 288.719500 ms。它也超过 175 ms,但不属于校准证据。独占原版行替代该比较,没有删除受污染样本或通过取平均掩盖它们。
+
+### 标准托管 CI 校准
+
+标准双 CPU `ubuntu-24.04` 测试通道运行 Node 24.20.0。[运行 34033336380、任务 101487280801](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336380/job/101487280801)在 Azure eastus 上测量合并提交 `8fba64d9ae06d1a9a778a95487bb915d24cb0644` 的优化请求路径:183.355397, 184.468253, 185.042397, 182.160790, 182.924728 ms;中位数 183.355397 ms。每个样本都完成相同的 40 个请求和 13,923 个事件。在 WeakSet 实现与工作负载未变的情况下,全部五个样本均超过历史 175 ms 预算。
+
+相同请求实现的另一次托管运行,[运行 34033336246、任务 101487216170](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336246/job/101487216170),记录了 145.644577, 144.204300, 143.072572, 145.985903, 146.834474 ms;中位数 145.644577 ms。它在合并提交 `c366e49` 上使用相同的 Ubuntu 镜像和 Node 版本,但运行于 Azure westus3 的另一台工作机。较快的运行不能替代 eastus 证据,也不能证明工作机差异的原因。较早的自托管 `VM-7-113-ubuntu-ci-9` 运行使用 Node 24.18.1([运行 34021903421、任务 101456015028](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34021903421/job/101456015028)),记录了 110.025154, 119.958978, 108.266860, 107.557950, 108.538902 ms;中位数 108.538902 ms。其运行器和 Node 版本不能校准标准托管通道。
+
+请求历史的 CI 期望值为 190 ms,向上取整并高于该观测范围。实际执行的中位数预算为 `ceil(190 × 1.25) = 238 ms`;CI 测量不再应用共享的参考机器 2× 系数。这与 [Session 重开 63 ms 预算](../../../../benchmarks/session-open/session-open.bench.ts)的直接 CI 校准方法一致,而非将 M4 参考值重新标注为托管证据。238 ms 预算仍低于独占原版实现的 M4 中位数 246.130875 ms。
+
+确定性对照调用与计时场景相同的 `assertRequestHistoryBudget` 断言。它们接受已记录的托管中位数和最大值(185.042397 ms),拒绝已记录的原版 M4 中位数,并拒绝由 248, 250, 252, 251, 249 ms 输入得到的合成 250 ms 中位数。合成输入模拟显著回归,并非运行时测量。回放已记录数值验证的是断言,而非新的托管运行。接受对照在校准前以 175 ms 预算失败;三个对照和五个请求冻结行为测试在 238 ms 预算下均通过。
+
+## 考虑过的替代方案
+
+**`Object.isFrozen` 为真时立即返回。** 已冻结根对象不能证明其后代已冻结。在共享辅助函数中使用此捷径会削弱所有调用方,包括恢复与投影路径。
+
+**信任所有 Session 消息或缓存消息 id。** 恢复明确允许拥有独立所有权的未冻结数据;替换操作可保留 id,同时改变对象身份与内容。只有对该精确对象完成遍历才能证明请求要求。
+
+**保留强引用 Set 或共享全局证明缓存。** 强引用会延长旧历史的生命周期。全局缓存将所有权扩大到 agent(智能体)之外,对同一循环的重复请求并无必要。
+
+**移除下游投影冻结。** 投影后的文件/图像/回放消息是拥有独立所有权的不同值。优化它们需要独立证据,不能由规范历史已冻结推导出来。
+
+## 影响
+
+请求构造仍扫描消息身份并分配新数组,但避免递归遍历已证明的历史。每个循环都为恢复历史支付一次完整遍历成本。本地 header 仍是每次请求的成本。消息值、请求标记、先前请求快照、取消及 SDK 序列化输出保持现有行为。
+
+[聚焦测试](../../../../packages/core/agent-loop/tests/request-freeze.spec.ts)覆盖具有可变后代的浅冻结恢复根对象、包装对象身份与可变性、仅成功遍历的证明、重复请求、同 id 压缩替换、新循环、嵌套工具 schema、适配器与 `NO_ADAPTER` stop 数组、持有的旧请求以及实时取消。重建与取消测试集覆盖相邻循环语义。性能测量采用不变的[续跑工作负载](../../../../benchmarks/agent-continuation/workload.ts),而非缩小的合成微基准。
+
+验证运行了 646 个 agent loop 与 LLM 测试,agent.ts 的语句、分支、函数和行覆盖率均为 100%。无密钥 TypeScript SDK bash-tool 与 multi-turn 快照通过重新构建的库执行并通过。Python sdk-minimal 与 sdk-snapshot 检查使用独立打包的 node24-macos-arm64 可执行文件并通过。两个 SDK 均无需修改期望输出。打包部署暂时移除了工作区依赖链接;执行冻结 lockfile 安装可在源码检查前恢复它们,无需修改受版本管理的依赖文件。
+
+现行不可变性、消息身份、可观察状态机和后端基线说明仍各自具有价值;没有说明被完全取代或归档。本文专门规定请求冻结机制,并与可重建性所属说明交叉链接。

+ 2 - 2
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md
-2026-07-24-web-gui-browser-e2e-lane.md: a996b380a55fc33f44cfdc2e31e179bc11f40be3
-2026-07-24-web-gui-browser-e2e-lane.zh.md: 1a413b6b8277ac696c6b728de740494bbf056695
+2026-07-24-web-gui-browser-e2e-lane.md: 07a37ada9c2a43f04612048f9bff6b22d022ec40
+2026-07-24-web-gui-browser-e2e-lane.zh.md: 668e612712175821d6ad123ce364a7cb96e01272

+ 1 - 1
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md

@@ -93,4 +93,4 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot
 
 ## Consequences
 
-The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable diagnostic workload without adding host-sensitive duration or memory expectations to CI; performance regressions remain a manually interpreted signal until the repository owns a calibrated benchmark environment.
+The web surface gains its record-once/replay-forever tier: the real chromium → SSE → apiproxy → loop → tools → persistence chain runs keylessly in ~10-30s, deterministic across repeat runs, with fixtures owned and re-recordable by the lane itself. Costs accepted: every intentional conversation-UI change ends with a keyless `DSH_SNAPSHOT=refresh` (golden churn is reviewed diff, anchors keep semantic green); the aria format is Playwright-owned — the one committed snapshot format the repo does not control — so playwright version bumps must be deliberate bump-and-refresh commits (the dependency floats `^1.49.0` in `apps/web/package.json`; pin exactly if churn bites); replay's first-call-order binding constrains scenarios to one prompting session each, with the consumption assertion as the tripwire; `compaction-basic` shares the session's replay cursor and stays inert only under the published 128k catalog window; and the required consumer job pays for Chromium provisioning and one browser run so the PR that changes the assembled UI owns its expected-output diff. The opt-in performance lane preserves a repeatable, threshold-free diagnostic workload whose measurements require manual interpretation. The separate required [frontend performance benchmarks](2026-09-06-frontend-performance-budgets.md) enforce calibrated budgets in the isolated benchmark CI job; they do not add thresholds to the manual inventory.

+ 1 - 1
.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md

@@ -93,4 +93,4 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu
 
 ## 后果
 
-Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留了可重复的诊断工作负载,又不会向 CI 添加受 host 差异影响的时长或内存预期;在仓库拥有经校准的基准测试环境之前,性能回归仍是需要人工解读的信号。
+Web 表面获得了录制一次/永久回放的层级:真实 chromium → SSE → apiproxy → 循环 → 工具 → 持久化的链路以约 10-30 秒无密钥运行,重复运行结果确定,fixture 由车道自身持有并可重录。接受的成本:每次有意的会话 UI 变更都以一次无密钥 `DSH_SNAPSHOT=refresh` 收尾(预期输出变动是受评审的 diff,锚断言保住语义绿色);aria 格式归 Playwright 所有——仓库唯一不受自己控制的提交快照格式——因此 playwright 版本升级必须是刻意的升级加刷新提交(依赖在 `apps/web/package.json` 中浮动为 `^1.49.0`;若变动伤人则改为精确锁定);回放的首次调用顺序绑定把每个场景限制为至多一个发起提示的会话,消费断言是绊线;`compaction-basic` 与会话共享回放游标,仅在目录中发布的 128k 上下文窗口下保持闲置;必需的消费方任务承担 Chromium 供给与一次浏览器运行的成本,使改动组装后 UI 的 PR(Pull Request)持有相应的预期输出 diff。按需启用的性能车道保留可重复、无阈值的诊断工作负载,其测量需要人工解读。独立的必需[前端性能基准](2026-09-06-frontend-performance-budgets.zh.md)在隔离的基准 CI job 中执行经校准的预算;它们不向手动清单添加阈值。

+ 2 - 2
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md
-2026-09-04-session-open-performance-gate.md: 2820c9d7d0e5b7d9382c7f8d6540154440175f26
-2026-09-04-session-open-performance-gate.zh.md: 965b9035074504870bcb2f1ca8166962c264d75a
+2026-09-04-session-open-performance-gate.md: 2937a2aec1dbddb31fde82d2617d69852a611d90
+2026-09-04-session-open-performance-gate.zh.md: b6608ca79d07c3e9fb00d62801038ecefbdf944c

+ 4 - 4
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.md

@@ -12,13 +12,13 @@ Measuring only `SessionPersistence.open()` does not stably describe the result f
 
 ## Decision
 
-Linux pull requests run a required `node 24 / benchmarks` job that executes `pnpm run check:ci:bench` → `pnpm run test:bench`. The private `@deepseek-ai/dsh-benchmarks` workspace owns benchmark-only dependencies. The command first builds workspace libraries and dedicated workers under `benchmarks/.dsh-build/`, then invokes `vitest.bench.config.ts`. The [standard hosted runner decision](2026-09-06-standard-hosted-benchmark-runner.md) owns runner selection and the outer job timeout. The job runs the benchmark lane alone; Vitest runs one file at a time and only prepares input, starts measurement children, aggregates results, and enforces budgets. Every timed CPU path executes compiled JavaScript under plain Node with `NODE_OPTIONS` removed and no TypeScript loader; bare workspace imports therefore resolve from `benchmarks/node_modules` through package exports to built `lib/` entries.
+Linux pull requests run a required `node 24 / benchmarks` job that executes `pnpm run check:ci:bench` → `pnpm run test:bench`. The private `@deepseek-ai/dsh-benchmarks` workspace owns benchmark-only dependencies. The command first builds workspace libraries and dedicated workers under `benchmarks/.dsh-build/`, then invokes `vitest.bench.config.ts`. The [standard hosted runner decision](2026-09-06-standard-hosted-benchmark-runner.md) owns runner selection and the outer job timeout. The job runs the benchmark lane alone; Vitest runs one file at a time and only prepares input, starts measurement children, aggregates results, and enforces budgets. Every timed Node CPU path executes compiled JavaScript under plain Node with `NODE_OPTIONS` removed and no TypeScript loader; bare workspace imports therefore resolve from `benchmarks/node_modules` through package exports to built `lib/` entries.
 
 Required performance gates live under top-level `benchmarks/`, grouped by measured user path rather than package ownership. Host files use `*.bench.ts`, Client-face files use `*.bench.client.ts`, and scenario-specific workers and fixtures stay beside their benchmark without a benchmark suffix. Package-local `.perf.ts` files remain non-gating diagnostics; `scripts/` owns orchestration rather than benchmark cases.
 
 The Session benchmarks synthesize a released-v0 input from fixed parameters: 200 turns with 500 text deltas and 125 reasoning deltas per turn, for 127,400 logical events. The input uses Zstandard with fixed logical-row grouping and frame partitioning, so every run processes the same events, bytes, and frame distribution. The fixture constructs the immutable released-v0 physical rows directly instead of depending on a current-runtime historical encoder; compression and every measured read or migration entry point still use production code. Setup writes the input into a private temporary directory for each sample before timing starts; benchmarks never use recorded Sessions.
 
-Every Session endpoint runs at two user-lifecycle points. `first-open` starts with only the released V0 generation and therefore includes migration and successor publication. Setup produces `post-upgrade-reopen` once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published V2 successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches.
+Every Session endpoint runs at two user-lifecycle points. `first-open` starts with only the released V0 generation and includes migration; read-only consumers do not publish a successor, while writable Agent resume does. Setup produces `post-upgrade-reopen` once through that same production migration outside measurement, then copies both the unchanged V0 predecessor and published V2 successor into each sample root. Reopen samples use a fresh process, so they measure an upgraded user's later disk open without migration or process-local caches.
 
 Each access-kind and endpoint sample runs in a fresh compiled Node child process. Module imports, Host service initialization, and fixture preparation finish before measurement; the measured process performs no extra parse warm-up. Normal-heap mode runs five independent samples, reports every sample plus minimum, median, and maximum, and enforces access-specific fixed budgets against the median. Another child runs the same path under a fixed 128 MB old-space limit and checks only that it completes; extra GC caused by the constrained heap does not enter the normal timing baseline.
 
@@ -26,7 +26,7 @@ The lane contains three independent Session-opening benchmarks and retains the C
 
 | Benchmark | Measured path | Timing metrics |
 |---|---|---|
-| Phase profile | Executes the real persistence open, handle read, Session restore, and projection for both first open and post-upgrade reopen | `openMs`, `readMs`, `sessionRestoreMs`, and `projectionMs` each have a fixed budget; encoding, writes, verification, and publication awaited by migration all belong to first-open `openMs` |
+| Phase profile | Executes the real persistence open, handle read, Session restore, and projection for both first open and post-upgrade reopen | `openMs`, `readMs`, `sessionRestoreMs`, and `projectionMs` each have a fixed budget; read-only migration belongs to first-open `openMs`; successor encoding, verification, and publication belong to writable Agent resume |
 | First history | Reads each access kind through the Host Session history controller until it produces the first paginated snapshot | Separate first-open and reopen end-to-end budgets; each includes source stat, reading, restoration, projection, pagination, and snapshot construction, while first open additionally includes migration; both exclude Gateway network transport, Client fold, and browser paint |
 | Agent resume | Calls `ctx.agents.resume()` for each access kind until Agent creation, setup, publication, and loop startup finish | Separate first-open and reopen end-to-end budgets; neither path runs after first-history or reuses that benchmark's cache |
 | Client fold | Folds small and large v2 history windows through the real `ConversationNodeAssembler` and every Chat Definition | The large window's absolute time and scaling relative to the small window each have a fixed budget |
@@ -102,4 +102,4 @@ The calibrated source budgets are:
 
 Every pull request pays for one required Linux job; its Session portion runs several short-lived child processes in exchange for cold caches, isolated V8 heaps, explicit GC state, and attributable failures. The repository-level benchmark tree accepts deliberate cross-package test dependencies without changing product package manifests. The fixed Zstandard workload covers both event volume and frame topology; first-open measurements protect the one-time upgrade experience, reopen measurements prevent regressions in later opens, phase budgets locate cost, first-history budgets protect user-visible waiting, Agent-resume budgets and post-GC deltas protect complete cold activation and resident memory, and the 128 MB mode protects the transient allocation ceiling.
 
-The gate does not measure network transfer, browser rendering, or recorded Sessions, and it is not a continuous performance-trend system. A Node or runner change requires resampling the same workload and reviewing the budgets; a business-implementation change must not relax a budget without new positive and negative control data.
+The Session and Node-fold scenarios do not measure network transfer, browser rendering, or recorded Sessions, and they are not a continuous performance-trend system. [Frontend performance budgets](2026-09-06-frontend-performance-budgets.md) own browser workflow measurements. A Node or runner change requires resampling the same workload and reviewing the budgets; a business-implementation change must not relax a budget without new positive and negative control data.

+ 5 - 5
.agents/notes/implemented/testing/2026-09-04-session-open-performance-gate.zh.md

@@ -12,13 +12,13 @@ Session format v2 的推出改变了两条成本随模型输出增长的路径
 
 ## 决定
 
-Linux pull request 运行必需的 `node 24 / benchmarks` job,执行 `pnpm run check:ci:bench` → `pnpm run test:bench`。私有 `@deepseek-ai/dsh-benchmarks` workspace 拥有 benchmark 专属依赖。该命令先构建 workspace library 和 `benchmarks/.dsh-build/` 下的专用 worker,再调用 `vitest.bench.config.ts`。[标准托管运行器决策](2026-09-06-standard-hosted-benchmark-runner.zh.md)拥有运行器选择及外层 job 超时。该 job 单独运行 benchmark lane;Vitest 逐文件运行,只负责准备输入、启动测量子进程、汇总结果和执行预算断言。每条被计时的 CPU 路径都以纯 Node 执行编译后的 JavaScript,并移除 `NODE_OPTIONS` 且不加载 TypeScript runtime;workspace 裸导入因此从 `benchmarks/node_modules` 通过 package exports 解析到构建后的 `lib/` 入口。
+Linux pull request 运行必需的 `node 24 / benchmarks` job,执行 `pnpm run check:ci:bench` → `pnpm run test:bench`。私有 `@deepseek-ai/dsh-benchmarks` workspace 拥有 benchmark 专属依赖。该命令先构建 workspace library 和 `benchmarks/.dsh-build/` 下的专用 worker,再调用 `vitest.bench.config.ts`。[标准托管运行器决策](2026-09-06-standard-hosted-benchmark-runner.zh.md)拥有运行器选择及外层 job 超时。该 job 单独运行 benchmark lane;Vitest 逐文件运行,只负责准备输入、启动测量子进程、汇总结果和执行预算断言。每条被计时的 Node CPU 路径都以纯 Node 执行编译后的 JavaScript,并移除 `NODE_OPTIONS` 且不加载 TypeScript runtime;workspace 裸导入因此从 `benchmarks/node_modules` 通过 package exports 解析到构建后的 `lib/` 入口。
 
 必需性能 gate 位于顶层 `benchmarks/`,按被测用户路径而非 package 归属组织。Host 文件使用 `*.bench.ts`,Client 面文件使用 `*.bench.client.ts`,场景专属 worker 与 fixture 留在对应 benchmark 旁且不带 benchmark 后缀。包内 `.perf.ts` 文件仍是非门禁诊断;`scripts/` 负责编排而不承载 benchmark case。
 
 Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮 500 个 text delta 与 125 个 reasoning delta,共 127,400 个逻辑事件。输入使用 Zstandard,并固定 logical rows 的分组与 frame 拆分,使每次运行处理相同的事件、字节与 frame 分布。fixture 直接构造不可变的 released-v0 physical rows,不依赖当前 runtime 的历史 encoder;压缩以及所有被测读取和 migration 入口仍使用生产代码。输入在计时前写入每个样本独占的临时目录;benchmark 不使用录制的 Session。
 
-每个 Session endpoint 都针对用户生命周期中的两个时点运行。`first-open` 最初只有 released V0 generation,因此包含 migration 与后继 generation 发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 `post-upgrade-reopen`,再把未改动的 V0 前代和已发布的 V2 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache。
+每个 Session endpoint 都针对用户生命周期中的两个时点运行。`first-open` 最初只有 released V0 generation,包含 migration;只读消费者不发布后继文件,可写 Agent resume 才会发布。测试准备阶段在计时外通过同一套生产 migration 生成一次 `post-upgrade-reopen`,再把未改动的 V0 前代和已发布的 V2 后继一起复制到每个样本目录。Reopen 样本使用全新进程,因此测量用户升级完成后的磁盘再次打开,不包含 migration 或进程内 cache。
 
 每个 access kind 与 endpoint 的样本都在全新、已编译的 Node 子进程中运行。模块加载、Host 服务初始化和 fixture 准备在测量开始前完成;测量进程不执行额外的预热解析。正常堆模式运行五个独立样本,报告全部样本及最小值、中位数和最大值,并以中位数执行各访问状态独立的固定预算。另一个子进程使用固定 128 MB old-space 上限运行同一路径,只判断能否完成;低堆限制引起的额外 GC 不进入正常时间基线。
 
@@ -26,7 +26,7 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮
 
 | Benchmark | 被测路径 | 时间指标 |
 |---|---|---|
-| 阶段剖面 | 分别为 first open 与 post-upgrade reopen 执行真实 persistence open、handle read、Session restore 与 projection | `openMs`、`readMs`、`sessionRestoreMs`、`projectionMs` 各自使用固定预算;migration 所等待的编码、写入、verify 与 publish 全部归入 first-open `openMs` |
+| 阶段剖面 | 分别为 first open 与 post-upgrade reopen 执行真实 persistence open、handle read、Session restore 与 projection | `openMs`、`readMs`、`sessionRestoreMs`、`projectionMs` 各自使用固定预算;只读 migration 归入 first-open `openMs`;后继编码、verify 与 publish 属于可写 Agent resume |
 | 首屏历史 | 两种 access kind 分别经 Host Session history controller 读取到首个分页 snapshot | First open 与 reopen 各有一个端到端预算;均包含 source stat、读取、Session restore、projection、分页与 snapshot 构造,first open 还包含 migration;两者都不包含 Gateway 网络传输、Client fold 或浏览器 paint |
 | Agent resume | 对两种 access kind 分别调用 `ctx.agents.resume()`,直到 Agent 创建、setup、发布与 loop 启动完成 | First open 与 reopen 各有一个端到端预算;两条路径都不与首屏历史串行,也不依赖它留下的 cache |
 | Client fold | 大小两个 v2 history window 经真实 `ConversationNodeAssembler` 与全部 Chat Definition fold | 大窗口的绝对时间与相对小窗口的缩放比各自使用固定预算 |
@@ -50,7 +50,7 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮
 | First open | 栈前参考版本 | 249.0 ms | 253.8 ms | 100.7 ms | 26.1 MB | 完成 |
 | First open | 重复 snapshot 退化实现 | 4,197.5 ms | 4,284.8 ms | 4,197.9 ms | 4.4 MB | 堆耗尽 |
 | Post-upgrade reopen | 栈前参考版本 | 251.1 ms | 253.8 ms | 100.7 ms | 26.1 MB | 完成 |
-| Post-upgrade reopen | 重复 snapshot 退化实现 | 49.2 ms | 50.4 ms | 43.8 ms | 完成 |
+| Post-upgrade reopen | 重复 snapshot 退化实现 | 49.2 ms | 50.4 ms | 43.8 ms | 4.5 MB | 完成 |
 
 栈前实现以 V0 作为当前格式,因此 first open 不改变磁盘表示;它的原生 V0 首屏历史与 Agent resume 测量同时适用于两个生命周期行。
 
@@ -102,4 +102,4 @@ Session benchmark 使用固定参数合成 released-v0 输入:200 轮,每轮
 
 每个 pull request 多付出一个必需 Linux job;该 job 的 Session 部分运行多个短生命周期子进程,以换取冷 cache、独立 V8 heap、明确 GC 状态和可归因的失败。仓库级 benchmark 目录接受有意的跨包测试依赖,而不修改产品 package manifest。固定 Zstandard workload 同时覆盖事件规模与 frame 拓扑;first-open 测量保护一次性升级体验,reopen 测量防止后续打开退化,四阶段预算定位成本归属,首屏预算保护用户可见等待,Agent resume 预算与 GC 后增量保护完整冷恢复及常驻内存,128 MB 模式保护瞬时分配上限。
 
-该 gate 不测量网络传输、浏览器渲染或真实录制 Session,也不是持续性能趋势系统。Node 或 runner 变化需要用同一 workload 重新采样并评审预算;修改业务实现时不得顺带放宽预算而不提供新的正反例数据。
+Session 与 Node-fold 场景不测量网络传输、浏览器渲染或真实录制 Session,也不是持续性能趋势系统。[前端性能预算](2026-09-06-frontend-performance-budgets.zh.md)拥有浏览器工作流测量。Node 或 runner 变化需要用同一 workload 重新采样并评审预算;修改业务实现时不得顺带放宽预算而不提供新的正反例数据。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md
+2026-09-06-backend-continuation-performance.md: f4316e790cf62f5027a0f7bfb2d3148cc79e7536
+2026-09-06-backend-continuation-performance.zh.md: 0fb36ba5375c61e907791b31d96f09062b7f62a3

+ 68 - 0
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md

@@ -0,0 +1,68 @@
+# Agent Note: Performance baselines for tool-heavy backend continuation
+
+Status: implemented
+
+English | [中文](2026-09-06-backend-continuation-performance.zh.md)
+
+## Problem
+
+Opening one Session does not measure the repeated cost of preparing model requests after a long tool conversation, executing another tool-heavy turn, or discovering multiple inactive fork children. The [Session-opening gate](2026-09-04-session-open-performance-gate.md) covers first history and activation but deliberately stops before new model work. Its text/reasoning workload also lacks historical tool-call arguments and large tool results.
+
+## Decision
+
+The [agent-continuation benchmark](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts) adds three scenario groups, including a shipped-profile variant, without changing product implementations. They use current-generation Zstandard Sessions authored through production append, stream accumulation, and persistence APIs. A separate seed process creates the deterministic source before measurement; each sample copies that source into its private root and starts a fresh compiled plain-Node worker. No recorded Session, ambient repository, network, private Harness home, or deployed GUI supplies input.
+
+The shared history has 800 completed two-step turns, four tool calls per turn, and 2,048-character tool results: 13,600 events and 5,600 conversation messages. Each assistant reply carries reasoning, text, and compact streamed records; tool replies additionally carry fragmented arguments. Fixed timestamps and ids describe the seed. Live synthetic replies use the real loop's clocks and ids without overriding process globals.
+
+| Case | Timed operation | Endpoint |
+|---|---|---|
+| Request history | After unmeasured cold resume, deliver 40 sequential text-only turns over the tool-heavy history, then flush | Idle Agent with all 40 model requests completed; reports turn and final-flush time separately |
+| Tool continuation | Cold resume, 20 sequential turns with eight parallel-safe synthetic tool calls and a final reply per turn, then flush | Idle Agent with 40 model requests and 160 completed tool executions; reports resume, turns, and final flush separately |
+| Shipped SDK workflow | Launch built dsh with the sdk-minimal profile, deliver 100 sequential turns with eight real file-view calls per turn, then close the SDK | SDK receives 200 assistant messages and 800 successful file results; includes Loader boot, stdio JSON-RPC, persistence, and shutdown |
+| Child catalog | List 16 inactive seeded fork children twice through the real subagent and Session query services | Two complete healthy catalogs with observations released; each child inherits 80 tool-heavy turns and owns its descriptor after the exact fork cut |
+
+The tool execution pipeline, request preparation, Session projections required by those services, persistence, and catalog observations remain production code. Only the model adapter and bounded tool body are synthetic. The adapter retains a request counter, not request objects, so the fixture cannot manufacture a growing retention cost. Sequential input means each idle interval belongs to the one request delivered by this worker; it does not generalize idle to a per-message completion API under concurrent input.
+
+Five samples report raw wall time, CPU user/system time, peak RSS, endpoint counts, and the minimum, median, and maximum total wall time. Budgets enforce the unrounded median. Continuation additionally measures retained heap against an initialized Host: two explicit GCs separated by an event-loop yield precede and follow the timed operation, while the idle Agent remains reachable. The measured delta therefore includes the resident historical Session and live additions, not just newly appended turns. GC and teardown are outside timing; flush is inside. Request-history retention starts after resume and is diagnostic only. Catalog peak RSS is diagnostic; no retained-heap budget claims to measure already-released child observations.
+
+The parent bounds every child to 60 seconds, checks timeout, signal, exit, and report independently, awaits process close, and removes private roots after failures. Context and Agent teardown run in finally blocks. Seed processes cannot warm the measured process's caches. Filesystem caches are not forcibly evicted: cold means a fresh process, not cold physical storage.
+
+## Calibration evidence
+
+The implementation reference is `925e012340f033f0521e802ba8569ce6dd7ef1ac` on Apple M4 Pro, macOS arm64, Node 24.19.0. Two exclusive five-sample runs use the same seed and no product optimization. Durations below are milliseconds; source expectations round above the observed run medians rather than imposing an unimplemented optimization target.
+
+| Case | Run 1 raw totals | Run 2 raw totals | Medians | Historical M4 expectation | Historical scaled budget |
+|---|---|---|---|---:|---:|
+| Request history | 209.134, 210.333, 208.959, 236.355, 238.685 | 222.833, 213.911, 208.089, 211.494, 209.137 | 210.333 / 211.494 | 220 | 550 |
+| Tool continuation | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 |
+| Child catalog | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 |
+
+Continuation retains approximately 22.295 MiB; its source expectation is 23 MiB and its budget is 28.75 MiB. SDK time expectations use the existing [calibration helper](../../../../benchmarks/support/calibration.ts): 2× shared CI time scale and 1.25× variance headroom. Request history uses the direct hosted expectation in the [request-freeze calibration](../simplification/2026-09-06-agent-request-freeze-provenance.md), without the 2× scale. Memory uses only 1.25× headroom. The scale is inherited from the existing lane's calibration, not a new Linux measurement of these cases; CI evidence remains necessary when runner characteristics change. Baseline budgets protect the measured implementation; tighter budgets belong with a measured behavior-preserving fix.
+
+A separate plain-Node request-history CPU profile attributes 132.876 ms of sampled self time to deepFreeze called by buildRequest during a 211.300 ms operation. This identifies repeated traversal of already-frozen history as a focused investigation target, not a proven optimization result. Catalog first/repeat timings remain separate because a second listing still reads body-bearing seeded children after observations are released.
+
+The shipped SDK variant completes 100 turns, 200 requests, and 800 real file reads. Its five-sample smoke totals are 1,521.773, 1,463.465, 1,689.701, 1,365.485, and 1,417.106 ms (median 1,463.465 ms); a full-suite repeat reports 1,596.183, 1,784.536, 2,120.082, 1,405.365, and 1,355.894 ms (median 1,596.183 ms). Its 1,700 ms reference expectation yields a 4,250 ms CI budget. The repeat also slows the unchanged service cases, so it is validation under variable host load rather than evidence to relax their exclusive calibration. The SDK process receives an allowlisted environment and private home/workspace. A 40-second deadline starts SDK shutdown; every path awaits the same memoized close promise before the outer worker’s 60-second deadline. Profile timing includes boot, all turns, and shutdown, reported separately; no parent-process CPU or heap metric is presented as server memory. The adapter does not serialize requests for an external model provider.
+
+The first Linux x64 CI measurement at commit `1dc3296eba631d51fbb3bb50e249bf3cc0fce9f6` ran on `VM-7-113-ubuntu-ci-10` with Node 24.18.1 ([run 34017868081, attempt 1, job 101444810498](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34017868081/job/101444810498)). The SDK median was 2,753.441 ms against its 4,250 ms budget, and tool-continuation retained-heap median was 22.274 MiB against 28.75 MiB. Request-history and tool-continuation time budgets failed: 785.498 ms against 550 ms and 1,077.285 ms against 850 ms, respectively. The unchanged Session-reopen open phase also failed at 31.6 ms against 30 ms. [Attempt 2, job 101447076381](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34017868081/job/101447076381) passed every benchmark on the same commit and unchanged budgets, but used `VM-7-113-ubuntu-ci-29` with Node 24.19.0. The gate runner suppressed successful child output, so that attempt supplies a passing verdict rather than raw medians. The changed runner and Node version prevent attributing the difference solely to contention or claiming stable repeated CI calibration; neither the budgets nor the shared scale are changed on this evidence.
+
+Catalog uses an explicit 900 ms expected CI duration and only the existing 1.25× headroom, yielding 1,125 ms without applying the reference-machine scale again. The standard two-CPU hosted `ubuntu-24.04` [run 34033336380, job 101487280801](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336380/job/101487280801) reports five unchanged-catalog totals of 797.374, 883.157, 858.364, 790.569, and 904.579 ms: median 858.364 ms exceeds the historical 800 ms budget. The 320 ms M4 expectation above remains historical evidence, not a CI measurement. This follows the explicit-CI calibration used by Session reopening (50 ms expected CI); shared factors, workloads, timing endpoints, and product implementations remain unchanged. Deterministic controls use the same assertion as the measured verdict: the unrounded recorded median passes 1,125 ms and fails 800 ms, while a synthetic 1,400 ms median fails 1,125 ms. A passing run on a faster host does not calibrate the standard hosted runner.
+
+Tool continuation also uses a 900 ms expected CI duration with 1.25× headroom (1,125 ms). At unchanged implementation `79c052ab29`, standard two-CPU hosted [run 34034524265, job 101490056074](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34034524265/job/101490056074) reports totals of 917.007, 892.091, 887.839, 905.659, and 898.252 ms: median 898.252 ms exceeds the historical 850 ms budget. The 340 ms M4 expectation remains historical evidence. The same measured-verdict assertion accepts the recorded unrounded median under 1,125 ms, rejects it under 850 ms, and rejects a synthetic 1,400 ms regression. Workload, timing, product code, and the 28.75 MiB retained-heap budget remain unchanged.
+
+Baseline request history uses a 600 ms expected CI duration with 1.25× headroom (750 ms). At unchanged implementation `54d1190a75`, standard two-CPU hosted [run 34035306987, job 101492163630](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34035306987/job/101492163630) reports totals of 618.598, 618.606, 582.035, 582.304, and 581.832 ms: median 582.304 ms exceeds the historical 550 ms budget. The 220 ms M4 expectation remains historical evidence. The same measured-verdict assertion accepts the recorded unrounded median under 750 ms, rejects it under 550 ms, and rejects a synthetic 900 ms regression. This calibrates the unoptimized baseline only; workload, timing, product code, and memory budgets remain unchanged.
+
+## Alternatives considered
+
+**Repeat existing migration and first-open variants.** Rejected: those twelve cases already distinguish read-only preparation from writable publication. These cases use the current generation and begin or continue actual model work, or enumerate a corpus rather than open one Session.
+
+**Measure only deriveMessages.** Rejected: its incremental cache does not include complete request freezing, adapter dispatch, live append, or persistence. Actual sequential requests protect the cost the Agent pays per step.
+
+**Use only unseeded children with warm projection-cache rows.** Rejected: that path bypasses body observations and misses the exact inherited-cut requirement of fork children. The catalog intentionally omits the optional projection cache and reports the seeded fallback path; it does not characterize cache-hit discovery.
+
+**Apply an optimization and its desired budget together with the first measurements.** Rejected: a baseline-only layer remains independently mergeable and records the current workload before attribution or implementation changes. Source constants cannot be overridden by environment variables.
+
+## Consequences
+
+The lane adds four cases in three scenario groups and twenty measured workers, plus two seed processes. The integrated continuation case spans resume through completed model/tool work and durable flush. The shipped SDK workflow additionally includes profile boot, SDK transport, real file tools, and shutdown; only its model adapter is synthetic. It starts a fresh Session because the public SDK prompt API creates rather than resumes stored identities. Neither path includes network model latency, provider-specific request serialization, optional user plugins, compaction, failed tool results, images, cancellation, or browser rendering. Functional tests retain responsibility for event contents, immutable messages, tool semantics, fork lineage, and read-only versus writable side effects; endpoint counts prevent timing a skipped workload without duplicating those assertions.
+
+This note supplements, rather than supersedes, the Session-opening gate's isolation and calibration rationale. No existing active decision is retired.

+ 68 - 0
.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md

@@ -0,0 +1,68 @@
+# Agent Note: 工具密集后端续聊的性能基线
+
+Status: implemented
+
+[English](2026-09-06-backend-continuation-performance.md) | 中文
+
+## 问题
+
+打开一个 Session 不能衡量长工具对话后重复准备模型请求、执行更多工具密集轮次或发现多个非活动 fork 子会话的成本。[Session 打开门禁](2026-09-04-session-open-performance-gate.zh.md)覆盖首屏历史和激活,但有意停在新的模型工作开始前。它的文本与推理负载也不包含历史工具调用参数和大型工具结果。
+
+## 决定
+
+[agent-continuation 基准](../../../../benchmarks/agent-continuation/agent-continuation.bench.ts)增加三个场景组,包含一个已发布 profile 变体,不修改产品实现。它们通过生产追加、流累积和持久化 API 构造当前代际的 Zstandard Session。独立播种进程在测量前生成确定性源数据;每个样本将其复制到私有根目录,并启动新的已编译纯 Node worker。输入不来自录制 Session、环境仓库、网络、私有 Harness 主目录或已部署 GUI。
+
+共享历史包含 800 个已完成的双步骤轮次,每轮四次工具调用,工具结果为 2,048 字符:共 13,600 个事件和 5,600 条对话消息。每条助手回复携带推理、文本和紧凑流记录;请求工具的回复还携带分片参数。播种数据使用固定时间戳和 id。实时合成回复使用真实循环的时钟和 id,不覆盖进程全局状态。
+
+| 用例 | 计时操作 | 终点 |
+|---|---|---|
+| 请求历史 | 在不计时的冷恢复后,向工具密集历史顺序提交 40 个纯文本轮次,然后 flush | 空闲 Agent,已完成全部 40 次模型请求;分别报告轮次和最终 flush 时间 |
+| 工具续聊 | 冷恢复,顺序执行 20 个轮次,每轮八次可安全并行的合成工具调用和一条最终回复,然后 flush | 空闲 Agent,已完成 40 次模型请求和 160 次工具执行;分别报告恢复、轮次和最终 flush 时间 |
+| 已发布 SDK 工作流 | 使用 sdk-minimal profile 启动已构建 dsh,顺序提交 100 个轮次,每轮八次真实文件查看调用,然后关闭 SDK | SDK 收到 200 条助手消息和 800 个成功文件结果;包含 Loader 启动、stdio JSON-RPC、持久化和关闭 |
+| 子会话目录 | 通过真实 subagent 和 Session 查询服务,两次列出 16 个非活动、带种子的 fork 子会话 | 两份完整健康目录,观察已释放;每个子会话继承 80 个工具密集轮次,并在精确 fork 切点后拥有自己的描述符 |
+
+工具执行管线、请求准备、这些服务所需的 Session 投影、持久化和目录观察均保留生产代码。只有模型适配器和有界工具体是合成的。适配器只保留请求计数,不保留请求对象,因此 fixture(测试前置数据)不会制造不断增长的保留成本。顺序输入使每个空闲区间对应此 worker 提交的唯一请求;这不代表并发输入时可以把空闲状态推广为逐消息完成 API。
+
+五个样本报告原始壁钟时间、CPU 用户态/内核态时间、峰值 RSS、终点计数及总壁钟时间的最小值、中位数和最大值。预算约束未经舍入的中位数。续聊还相对已初始化 Host 测量保留堆内存:计时操作前后各执行两次显式 GC,中间让出一次事件循环,空闲 Agent 始终可达。因此该增量包含常驻历史 Session 和实时追加,而不只是新轮次。GC 与资源释放不计时;flush 计时。请求历史的内存基线从恢复后开始,只作诊断。目录峰值 RSS 仅作诊断;没有保留堆预算声称衡量已经释放的子会话观察。
+
+父进程为每个子进程设置 60 秒上限,独立检查超时、信号、退出状态和报告,等待进程关闭,并在失败后删除私有根目录。Context 和 Agent 在 finally 中释放。播种进程无法预热被测进程的缓存。不强制清除文件系统缓存:冷指新进程,不指冷物理存储。
+
+## 校准证据
+
+实现参考为 Apple M4 Pro、macOS arm64、Node 24.19.0 上的 `925e012340f033f0521e802ba8569ce6dd7ef1ac`。两轮独占的五样本运行使用相同播种数据,没有产品优化。下表时间单位为毫秒;源码期望值向上取整至实测各轮中位数以上,而不是施加尚未实现的优化目标。
+
+| 用例 | 第一轮原始总时间 | 第二轮原始总时间 | 中位数 | 历史 M4 期望 | 历史缩放预算 |
+|---|---|---|---|---:|---:|
+| 请求历史 | 209.134, 210.333, 208.959, 236.355, 238.685 | 222.833, 213.911, 208.089, 211.494, 209.137 | 210.333 / 211.494 | 220 | 550 |
+| 工具续聊 | 358.953, 324.790, 318.861, 320.119, 322.896 | 324.280, 321.952, 340.409, 325.470, 324.312 | 322.896 / 324.312 | 340 | 850 |
+| 子会话目录 | 318.730, 309.006, 311.404, 308.565, 310.105 | 308.670, 310.030, 280.086, 303.084, 284.829 | 310.105 / 303.084 | 320 | 800 |
+
+续聊保留约 22.295 MiB;源码期望值为 23 MiB,预算为 28.75 MiB。SDK 时间期望值使用现有[校准辅助函数](../../../../benchmarks/support/calibration.ts):2× 共享 CI 时间比例和 1.25× 波动余量。请求历史使用[请求冻结校准](../simplification/2026-09-06-agent-request-freeze-provenance.zh.md)中的直接托管期望值,不乘以 2× 比例。内存只使用 1.25× 余量。比例继承现有通道的校准,并非这些用例的新 Linux 实测值;runner 特征变化时仍需 CI 证据。基线预算保护实测实现;更紧预算属于有测量依据且保持行为的修复。
+
+独立的纯 Node 请求历史 CPU profile 在一次 211.300 ms 操作中,将 132.876 ms 采样自身时间归因于 buildRequest 调用的 deepFreeze。这把重复遍历已冻结历史定位为聚焦调查目标,不是已证实的优化结果。目录首次/重复时间分别保留,因为观察释放后第二次列举仍读取带种子子会话的正文。
+
+已发布 SDK 变体完成 100 个轮次、200 次请求和 800 次真实文件读取。五样本 smoke 总时间为 1,521.773、1,463.465、1,689.701、1,365.485 和 1,417.106 ms(中位数 1,463.465 ms);完整套件重复运行报告 1,596.183、1,784.536、2,120.082、1,405.365 和 1,355.894 ms(中位数 1,596.183 ms)。1,700 ms 参考期望对应 4,250 ms CI 预算。重复运行中未改变的服务用例也变慢,因此这是可变主机负载下的验证,不是放宽其独占校准预算的依据。SDK 进程使用白名单环境和私有主目录/工作区。40 秒截止时间启动 SDK 关闭;所有路径等待同一个记忆化 close Promise,并早于外层 worker 的 60 秒截止时间。Profile 时间包含启动、全部轮次和关闭,分别报告;不把父进程 CPU 或堆指标当作服务端内存。适配器不为外部模型服务商序列化请求。
+
+提交 `1dc3296eba631d51fbb3bb50e249bf3cc0fce9f6` 的首次 Linux x64 CI 测量使用 `VM-7-113-ubuntu-ci-10` 和 Node 24.18.1([run 34017868081,attempt 1,job 101444810498](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34017868081/job/101444810498))。SDK 中位数为 2,753.441 ms,预算为 4,250 ms;工具续聊保留堆中位数为 22.274 MiB,预算为 28.75 MiB。请求历史与工具续聊时间预算失败:分别为 785.498 ms 对 550 ms、1,077.285 ms 对 850 ms。未修改的 Session 重开 open 阶段也以 31.6 ms 对 30 ms 失败。[Attempt 2,job 101447076381](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34017868081/job/101447076381) 在同一提交和未修改预算下通过全部基准,但使用 `VM-7-113-ubuntu-ci-29` 和 Node 24.19.0。门禁运行器隐藏成功子进程的输出,因此该次运行只提供通过结论,不提供原始中位数。Runner 与 Node 版本同时变化,不能把差异仅归因于资源争用,也不能宣称已获得稳定的重复 CI 校准;这些证据不改变预算或共享比例。
+
+目录用例使用显式的 900 ms CI 期望时间,仅乘现有 1.25× 余量,得到 1,125 ms,不再应用参考机器比例。标准双 CPU 托管 `ubuntu-24.04` 的 [run 34033336380,job 101487280801](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336380/job/101487280801) 报告未修改目录实现的五个总时间为 797.374、883.157、858.364、790.569 和 904.579 ms:中位数 858.364 ms 超出历史 800 ms 预算。上表 320 ms M4 期望保留为历史证据,不是 CI 测量。此方法与 Session 重开使用的显式 CI 校准一致(CI 期望为 50 ms);共享系数、负载、计时终点和产品实现均不改变。确定性对照与实测判定使用同一断言:未经舍入的录制中位数通过 1,125 ms 并被 800 ms 拒绝,合成的 1,400 ms 中位数则被 1,125 ms 拒绝。更快主机上的通过结果不能校准标准托管 runner。
+
+工具续聊同样使用 900 ms CI 期望时间与 1.25× 余量(1,125 ms)。未修改实现的 `79c052ab29` 在标准双 CPU 托管 [run 34034524265,job 101490056074](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34034524265/job/101490056074) 中报告总时间为 917.007、892.091、887.839、905.659 和 898.252 ms:中位数 898.252 ms 超出历史 850 ms 预算。340 ms M4 期望保留为历史证据。与实测判定相同的断言在 1,125 ms 下接受未经舍入的录制中位数,在 850 ms 下拒绝它,并拒绝合成的 1,400 ms 回退。负载、计时、产品代码和 28.75 MiB 保留堆预算均不改变。
+
+基线请求历史使用 600 ms CI 期望时间与 1.25× 余量(750 ms)。未修改实现的 `54d1190a75` 在标准双 CPU 托管 [run 34035306987,job 101492163630](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34035306987/job/101492163630) 中报告总时间为 618.598、618.606、582.035、582.304 和 581.832 ms:中位数 582.304 ms 超出历史 550 ms 预算。220 ms M4 期望保留为历史证据。与实测判定相同的断言在 750 ms 下接受未经舍入的录制中位数,在 550 ms 下拒绝它,并拒绝合成的 900 ms 回退。此校准仅针对未优化基线;负载、计时、产品代码和内存预算均不改变。
+
+## 考虑过的替代方案
+
+**重复现有迁移和首次打开变体。** 拒绝:现有十二个用例已经区分只读准备与可写发布。这些用例使用当前代际并开始或继续实际模型工作,或者列举语料集合而不是打开单个 Session。
+
+**只测 deriveMessages。** 拒绝:它的增量缓存不包含完整请求冻结、适配器分发、实时追加或持久化。实际顺序请求保护 Agent 每一步支付的成本。
+
+**只使用投影缓存行已预热的无种子子会话。** 拒绝:该路径绕过正文观察,遗漏 fork 子会话的精确继承切点要求。目录用例有意不挂载可选投影缓存,报告带种子的回退路径;它不代表缓存命中的发现过程。
+
+**将优化及其目标预算与首次测量一起应用。** 拒绝:纯基线层可以独立合并,并在归因或实现改变前记录当前负载。环境变量不能覆盖源码常量。
+
+## 后果
+
+通道增加三个场景组中的四个用例、二十个测量 worker 和两个播种进程。集成续聊用例覆盖恢复、完成模型/工具工作及持久化 flush。已发布 SDK 工作流额外包含 profile 启动、SDK 传输、真实文件工具和关闭;只有模型适配器是合成的。它创建新 Session,因为公共 SDK prompt API 创建而非恢复已存储身份。两条路径均不包含网络模型延迟、服务商专属请求序列化、可选用户插件、压缩、失败工具结果、图像、取消或浏览器渲染。功能测试仍负责事件内容、不可变消息、工具语义、fork 谱系以及只读/可写副作用;终点计数防止把跳过的工作当作测量结果,不重复这些断言。
+
+本记录补充而非取代 Session 打开门禁的隔离和校准依据。不退役任何现有活跃决策。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md
+2026-09-06-frontend-performance-budgets.md: 7dc7af97d8bb65c17109ab675c250085c9c5831c
+2026-09-06-frontend-performance-budgets.zh.md: 9935e382ec4c3c3ede762b23339f14c014b67c4e

+ 85 - 0
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md

@@ -0,0 +1,85 @@
+# Agent Note: Frontend large-session performance budgets
+
+Status: implemented
+
+English | [中文](2026-09-06-frontend-performance-budgets.zh.md)
+
+## Problem
+
+A fast Node conversation fold does not prove that a browser paints a long conversation or remains responsive while a response streams. Active reconnect also reconstructs a different representation from settled history: a compact prefix becomes public per-chunk Client entries. The [Session performance policy](2026-09-04-session-open-performance-gate.md) supplies an isolated CI job but does not measure these user paths.
+
+## Decision
+
+The existing serial benchmark inventory includes two frontend owners: [active reconnect](../../../../benchmarks/active-stream-reconnect/README.md) and a [browser workflow](../../../../benchmarks/long-session-browser/README.md). The browser workflow combines cold open, older-page navigation, first Trajectory activation, return to Chat, and a paced response with trusted keyboard input into one sequential scenario. These are endpoints of one workflow, not independent cold scenarios. The settled conversation-fold benchmark remains unchanged.
+
+`build:bench` keeps the Node-only library and worker build. `test:bench` additionally builds the Web shell before running all cases; the required benchmark CI job follows the [standard hosted runner decision](2026-09-06-standard-hosted-benchmark-runner.md), unconditionally provisions Chromium and its Linux dependencies on that hosted runner, and enables the existing verbose gate output so successful raw samples remain available for calibration. Browser cases reuse the shipped-composition Web scaffold with private temporary roots and an atomically assigned loopback port. Only the nondeterministic model is replaced by synthetic replay. The scaffold Host runs under the existing Vitest source resolver; measured Client rendering runs built bundles in fresh Chromium processes. Browser wall times therefore include this test Host, transport, Playwright actionability, and rendering, and are not claims about a published Host process.
+
+The browser input contains 240 closed turns, 40 tool results, and 20 code fences, plus mixed-language prose and reasoning. Historical Assistant records carry matching compact streams built through the production accumulator with 12-character reasoning/text deltas and 8-character tool-argument deltas; empty streams would omit stored and transferred payload costs. Nine older-page actions exhaust this input from its observed 25-turn initial window; the readiness probe follows mounted turn growth rather than duplicating the pagination algorithm. Each sample uses a fresh scaffold and browser. Setup, seeding, browser launch, initial shell load, and sidebar expansion are excluded from open timing. Open ends at transcript availability and an editable composer; page and navigation timings end at their target DOM state. Two animation frames include a rendering opportunity, not hardware presentation or a guarantee that every offscreen node painted.
+
+The continuation sends 120 text deltas at 16 ms replay pacing. The input witness is installed before Send; typing starts immediately after the first visible marker, without a separate pre-input animation-frame wait. Send lookup stays inside the composer seat; first/final marker lookups stay inside the latest Assistant step and retain visible-state waits. The synchronous input witness reads that same bounded reply. Whole-history text and accessibility queries add observer CPU and garbage collection to the measured interval, so reducing that observer work is benchmark repair, not product optimization. It records click-to-first-visible-reply, trusted draft typing whose first actual input event observes the first reply but no completion marker, complete reply wall time through settled persistence and the new rendered turn-tail, and Chromium main-thread task duration. The complete wall budget adds the fixed 1984 ms scripted pacing to a scaled overhead allowance; input and completion have their own enforced budgets. Post-GC browser heap and DOM counts remain diagnostics because one endpoint does not prove a leak.
+
+Reconnect uses three fresh compiled plain-Node children. Each creates a 100,000-delta reasoning prefix with distinct timestamps and two compact records before timing `ClientAssistantStream.replace()`. GC precedes the baseline and follows replacement while the result remains reachable; replacement time excludes both collections. The report consumes the result after collection and checks that the next dense live frame remains accepted. This measures reconstruction, not transport, rendering, or an entire reconnect workflow.
+
+## Calibration
+
+Three-sample medians on the arm64 reference machine, Node 24.19 and Chromium 149.0.7827.55, at product revision `925e012340`, establish the baseline below. An isolated repeat follows a complete workflow smoke. Each browser sample reports raw endpoint values and every page; the paging verdict uses the median of the sample maxima. Reconnect reports all child measurements. The following historical reference table uses 8 ms replay pacing and includes a two-frame wait in first-reply timing. Standard-hosted open, paging, Trajectory, and reconnect expectations are recorded separately below; other source reference constants retain these allowances. The bounded-observer 261.60 ms paging median exceeds its 260 ms reference allowance but remains below its 650 ms CI limit; the shared 2× time scale and 1.25× variance allowance produce CI limits. Memory uses only variance allowance. The shared scale originates in Node CI calibration. Both actual x64 browser runs below pass the fixed budgets on unchanged benchmark code; this supplies repeated-run evidence for these runners, not a universal browser speed ratio.
+
+| Endpoint | Measured median | Reference allowance | Historical CI limit |
+|---|---:|---:|---:|
+| Browser open | 184.62 ms | 200 ms | 500 ms |
+| Slowest older page | 261.60 ms | 260 ms | 650 ms |
+| First Trajectory | 136.46 ms | 160 ms | 400 ms |
+| First reply | 373.72 ms | 1100 ms | 2750 ms |
+| Stream main-thread task | 1053.87 ms | 1800 ms | 4500 ms |
+| Draft typing | 487.35 ms | 500 ms | 1250 ms |
+| Complete response | 1366.36 ms | 1000 ms overhead + 992 ms pacing | 3492 ms |
+| Reconnect replacement | 13.83 ms | 16 ms | 40 ms |
+| Reconnect retained heap | 23.03 MiB | 24 MiB | 30 MiB |
+
+Draft typing spans 124.97–504.96 ms across the three isolated samples; the reference remains 500 ms and the scaled CI limit covers that observed spread; the median is not a per-keystroke bound. No budget is an environment override. Temporary zero allowances exercise every rejection path; these negative controls prove enforcement, not an optimization or a historical regression. A separate control waits for the final reply marker before typing and fails the actual-input overlap assertion. The compact synthetic JSONL is 3,262,577 bytes; all three corrected samples report an overlapping trusted input event and end after the 241st rendered turn-tail.
+
+### Actual CI runs
+
+[Run 34020120425, benchmark job 101451135853](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34020120425/job/101451135853) passes the complete benchmark inventory at `6d1ba089e5052680961825c08aa4de19b4fe137a`. The runner is `VM-7-113-ubuntu-ci-19` in `dsh-selfhosted-ci`, using x64 Node 24.19.0 and Chromium 149.0.7827.55. [Attempt 2, benchmark job 101453296071](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34020120425/job/101453296071) also passes the complete inventory at the same commit, on `VM-7-113-ubuntu-ci-25` with the same Node and Chromium versions. The following medians use three fresh samples per scenario in each run and leave the local reference table and source budgets unchanged.
+
+| Endpoint | First CI median | Second CI median |
+|---|---:|---:|
+| Browser open | 303.066 ms | 284.726 ms |
+| Slowest older page | 432.979 ms | 413.798 ms |
+| First Trajectory | 298.608 ms | 267.372 ms |
+| First reply | 740.265 ms | 658.906 ms |
+| Stream main-thread task | 1614.594 ms | 1035.385 ms |
+| Draft typing | 932.746 ms | 142.148 ms |
+| Complete response | 1677.882 ms | 1641.702 ms |
+| Reconnect replacement | 29.232 ms | 31.674 ms |
+| Reconnect retained heap | 23.028 MiB | 23.028 MiB |
+
+All six browser samples report `inputOverlapped: true` and finish after the 241st rendered turn-tail. Post-GC browser heap is approximately 52.94 MiB in the first run and 53.00 MiB in the second, with 17,064 DOM elements in both; these remain diagnostic endpoints. Both runs support the existing budgets on these runners, not a universal 2× browser speed ratio. Draft-typing medians vary from 932.746 ms to 142.148 ms because the endpoint measures the entire typed draft, including scheduling and Playwright actionability, rather than a per-key latency guarantee. These are self-hosted measurements, not standard-hosted calibration; no product optimization is claimed.
+
+### Standard hosted expectations and input scheduling
+
+[Run 34033336246, job 101487216170](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336246/job/101487216170) on standard hosted Ubuntu with two CPUs records reconnect replacements of 46.574411, 46.067910, and 44.193704 ms, with 23.028 MiB retained heap. The endpoint-specific expectation is 50 ms; the existing 1.25× headroom gives a 63 ms integer ceiling. The 30 MiB memory budget and shared machine factor remain unchanged. Browser open records 681.276514 and 541.051233 ms before the third sample fails input overlap; both exceed the historical 500 ms limit. Repeated hosted open measurements below set its expectation and ceiling. Deterministic controls pass these recorded values and reject values above the new ceilings through the same assertions as the measured verdicts. Complete repeated hosted verdicts remain required; the two open values are not a three-sample median.
+
+A local diagnostic with temporary 3× Chromium CPU throttling reproduces the overlap failure: the first marker becomes visible at 1321 ms, two animation frames finish at 1370 ms, and the composer click finishes at 1660 ms; the actual input is trusted but already sees DONE. Removing the frame wait and installing the witness before Send still leaves a run with first visibility at 1415 ms and click completion at 1726 ms, after the original 992 ms scripted stream. The fixed 16 ms cadence keeps the same 120 deltas and payload, providing 1984 ms of scripted pacing for this workload. Only that pacing term changes in the complete-wall allowance (4484 ms); input, first-reply, and main-thread overhead allowances remain unchanged. With the same diagnostic slowdown, three 16 ms samples reach first visibility at 1307/1479/1599 ms and accept trusted input before DONE; their post-DONE controls reject it. The diagnostic is not a CPU-ratio calibration. Each measured sample still requires trusted input while FIRST is present and DONE absent; a post-measurement trusted key after DONE must fail that same assertion. Host settlement and the 241st rendered turn-tail remain completion witnesses.
+
+[Run 34034524861, job 101490135303](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34034524861/job/101490135303) records three complete browser samples with trusted input overlap and passing post-DONE rejection controls. Slowest-page samples are 843.941625/672.834329/684.461818 ms (median 684.461818); first-Trajectory samples are 605.788061/367.754027/485.931656 ms (median 485.931656). Their endpoint-specific hosted expectations are 700 and 500 ms, with the same 1.25× headroom producing 875 and 625 ms limits. Recorded-median controls reject the historical 650/400 ms limits, accept these hosted limits, and reject one millisecond above each limit through the measured verdict's assertion. Open, first reply, main-thread task, input, and complete-wall medians are 713.910/1486.206/2806.415/947.398/2986.983 ms; only the open limit is recalibrated by the repeated measurements below. This run supplies calibration data, not a passing benchmark verdict; a complete hosted repeat remains required.
+
+[Run 34036109842, job 101494445658](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34036109842/job/101494445658) records open samples of 875.306861/1083.683529/814.700998 ms, with a median of 875.306861 ms versus 713.909727 ms in the preceding hosted run. The endpoint-specific expectation is 900 ms, rounding up the larger repeated median rather than adding an epsilon to the 875 ms limit; unchanged 1.25× headroom gives 1125 ms. The same enforced assertion accepts the recorded median, rejects it at both historical 500 and 875 ms limits, and rejects a synthetic 1126 ms value at the current limit. All three samples retain trusted input overlap and post-DONE rejection; every other frontend median remains within its unchanged limit. This calibration does not claim a green CI run.
+
+## Alternatives considered
+
+**Use the Node fold as paint evidence.** Rejected because it never performs DOM mutation, layout, or browser scheduling. The focused reconnect case likewise makes no GUI speed claim.
+
+**Promote the entire manual browser diagnostic into CI.** Rejected because its 1,000-session sidebar and 100-turn soak cover a much broader workload. The bounded required case reuses its shipped scaffold and measurement approach without importing a test module or changing the manual inventory.
+
+**Coalesce active reconnect chunks.** Rejected as a benchmark shortcut: Client entries expose per-member ordering and timestamps to conversation definitions. The benchmark retains that production behavior; reducing retained entries requires a separate semantic design, not copied product algorithms or a synthetic approximation.
+
+**Search the entire loaded history for every stream marker.** Rejected because Playwright injects text and accessibility scans into the same renderer whose CPU the benchmark measures. Scoping queries to the composer and latest Assistant preserves visible completion checks without making observer cost proportional to loaded history.
+
+**Measure stream CPU alone.** Rejected because transport stalls and final-settlement delays can leave main-thread CPU low. The independent input, first-reply, and complete-wall budgets cover those waits.
+
+## Consequences
+
+The benchmark layer changes no product implementation or user-visible behavior. It adds approximately fifteen seconds of local browser/reconnect execution plus Web build and browser provisioning to the existing isolated CI lane. A fresh browser discards previous caches, but each workflow deliberately retains its own loaded history and previously activated Trajectory during continuation.
+
+The baseline is independently mergeable and protects current performance; optimization layers tighten budgets only with repeated measurements and focused semantic tests. It does not cover sidebar cardinality, an hours-long soak, GPU presentation, real model latency, a published Host launch, or reconnect rendering. The [Web browser lane](2026-07-24-web-gui-browser-e2e-lane.md) retains its separate threshold-free manual diagnostics and functional browser tests; calibrated required measurements belong to this benchmark lane. The existing Session performance note remains active because it owns Node calibration and persistence rationale; this note extends rather than supersedes it.

+ 85 - 0
.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md

@@ -0,0 +1,85 @@
+# Agent Note: 前端长 Session 性能预算
+
+Status: implemented
+
+[English](2026-09-06-frontend-performance-budgets.md) | 中文
+
+## 问题
+
+Node 对话折叠很快,并不能证明浏览器能绘制长对话或在流式回复期间保持响应。活跃重连还会重建与已结算历史不同的表示:紧凑前缀变成公开的逐 chunk Client 条目。[Session 性能策略](2026-09-04-session-open-performance-gate.zh.md)提供隔离的 CI job,但不测量这些用户路径。
+
+## 决策
+
+现有串行基准清单包含两个前端所有者:[活跃重连](../../../../benchmarks/active-stream-reconnect/README.zh.md)和[浏览器工作流](../../../../benchmarks/long-session-browser/README.zh.md)。浏览器工作流在一个顺序场景中组合冷打开、更早分页导航、首次激活 Trajectory、返回 Chat,以及伴随真实键盘输入的有节奏回复。这些是同一工作流的测量终点,而不是相互独立的冷场景。已结算对话折叠基准保持不变。
+
+`build:bench` 保留仅 Node 的 library 与 worker 构建。`test:bench` 额外构建 Web shell 后再运行所有用例;必需的基准 CI job 遵循[标准托管运行器决策](2026-09-06-standard-hosted-benchmark-runner.zh.md),在该托管运行器上无条件安装 Chromium 及其 Linux 依赖,并启用现有门禁详细输出,使成功用例的原始样本可用于校准。浏览器用例复用产品组合的 Web scaffold,使用私有临时目录和原子分配的回环端口。只有不确定的模型被合成重放替代。scaffold Host 通过现有 Vitest 源码解析器运行;被测 Client 渲染在全新 Chromium 进程中执行构建后的 bundle。因此浏览器壁钟时间包含测试 Host、传输、Playwright 可交互性等待及渲染,不代表发布版 Host 进程。
+
+浏览器输入包含 240 个已关闭轮次、40 个工具结果和 20 个代码块,以及混合语言正文和推理。历史 Assistant 记录携带匹配的紧凑 stream,通过生产 accumulator 按 12 字符推理/文本 delta 和 8 字符工具参数 delta 构建;空 stream 会遗漏存储与传输负载成本。从观察到的初始 25 轮窗口开始,九次更早分页操作读完该输入;就绪探针跟踪已挂载轮次增长,不复制分页算法。每个样本使用全新 scaffold 和浏览器。环境准备、数据播种、浏览器启动、初始 shell 加载及侧栏展开不计入打开时间。打开测量在对话可用且输入框可编辑时结束;分页与导航测量在目标 DOM 状态出现时结束。两次动画帧包含一次渲染机会,不代表硬件显示或保证每个屏幕外节点都已绘制。
+
+续接以 16 ms 重放间隔发送 120 个文本 delta。输入观察器在发送前安装;首个标记可见后立即开始键入,不单独等待输入前动画帧。发送控件查找限制在 composer seat;首段/最终标记查找限制在最新 Assistant step,并保留可见状态等待。同步输入证据读取同一个受限回复。全历史文本与无障碍查询会向测量区间加入观察器 CPU 和垃圾回收成本,因此减少此类观察工作属于基准修正,而非产品优化。它记录点击到首段可见回复的时间、首个实际输入事件观察到首段回复且完成标记尚未出现时的真实草稿键入、直到持久化结算并渲染新 turn-tail 的完整回复壁钟时间,以及 Chromium 主线程任务时间。完整壁钟预算在缩放后的额外开销额度上加固定的 1984 ms 脚本节奏;输入和完成均有独立执行的预算。强制 GC 后的浏览器 heap 和 DOM 数量仍仅供诊断,因为单个终点不能证明泄漏。
+
+重连使用三个全新编译后的纯 Node 子进程。各进程在计时 `ClientAssistantStream.replace()` 前创建包含不同时间戳、两条紧凑记录和 100,000 个 delta 的推理前缀。在基线前执行 GC,并在结果仍可达时于替换后再次 GC;替换时间不含两次回收。报告在回收后消费结果,并检查下一个稠密序号的实时 frame 仍被接受。这测量重建,不测量传输、渲染或完整重连工作流。
+
+## 校准
+
+在 arm64 参考机器、Node 24.19、Chromium 149.0.7827.55 和产品版本 `925e012340` 上,三个样本的中位数建立下表基线。完整工作流 smoke 后执行一次隔离重复测量。每个浏览器样本报告原始终点数据和每一页;分页判定使用各样本最大值的中位数。重连报告全部子进程测量。下列历史参考表使用 8 ms 重放节奏,首段回复计时包含两帧等待。标准托管打开、分页、Trajectory 和重连预期在下文单独记录;其他源码参考常量保留这些额度。受限观察器的分页中位数 261.60 ms 超过 260 ms 参考额度,但仍低于 650 ms CI 限制;共享的 2× 时间倍率和 1.25× 方差余量产生 CI 限制。内存仅使用方差余量。共享倍率源自 Node CI 校准。下述两次实际 x64 浏览器运行在基准代码不变的情况下均通过固定预算;这提供这些 runner 的重复运行证据,而非普遍适用的浏览器速度比。
+
+| 终点 | 实测中位数 | 参考额度 | 历史 CI 限制 |
+|---|---:|---:|---:|
+| 浏览器打开 | 184.62 ms | 200 ms | 500 ms |
+| 最慢更早分页 | 261.60 ms | 260 ms | 650 ms |
+| 首次 Trajectory | 136.46 ms | 160 ms | 400 ms |
+| 首段回复 | 373.72 ms | 1100 ms | 2750 ms |
+| 流式主线程任务 | 1053.87 ms | 1800 ms | 4500 ms |
+| 草稿键入 | 487.35 ms | 500 ms | 1250 ms |
+| 完整回复 | 1366.36 ms | 1000 ms 额外开销 + 992 ms 节奏 | 3492 ms |
+| 重连替换 | 13.83 ms | 16 ms | 40 ms |
+| 重连保留 heap | 23.03 MiB | 24 MiB | 30 MiB |
+
+三个隔离样本中的草稿键入时间为 124.97–504.96 ms;参考额度保持 500 ms,缩放后的 CI 限制覆盖观察到的波动;中位数不是单次按键上限。预算不能通过环境变量覆盖。临时零额度覆盖每条拒绝路径;这些负向对照证明预算执行,而非优化或历史回归。另一项对照在键入前等待最终回复标记,实际输入重叠断言因此失败。紧凑合成 JSONL 为 3,262,577 字节;三个修正样本均报告重叠的真实输入事件,并在第 241 个 turn-tail 渲染后结束。
+
+### 实际 CI 运行
+
+[运行 34020120425,基准 job 101451135853](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34020120425/job/101451135853) 在 `6d1ba089e5052680961825c08aa4de19b4fe137a` 上通过完整基准清单。runner 为 `dsh-selfhosted-ci` 中的 `VM-7-113-ubuntu-ci-19`,使用 x64 Node 24.19.0 和 Chromium 149.0.7827.55。[第 2 次执行,基准 job 101453296071](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34020120425/job/101453296071) 在相同 commit 上也通过完整清单,runner 为 `VM-7-113-ubuntu-ci-25`,Node 和 Chromium 版本相同。下列中位数来自每次运行中每个场景的三个全新样本,本地参考表和源码预算保持不变。
+
+| 终点 | 首次 CI 中位数 | 第二次 CI 中位数 |
+|---|---:|---:|
+| 浏览器打开 | 303.066 ms | 284.726 ms |
+| 最慢更早分页 | 432.979 ms | 413.798 ms |
+| 首次 Trajectory | 298.608 ms | 267.372 ms |
+| 首段回复 | 740.265 ms | 658.906 ms |
+| 流式主线程任务 | 1614.594 ms | 1035.385 ms |
+| 草稿键入 | 932.746 ms | 142.148 ms |
+| 完整回复 | 1677.882 ms | 1641.702 ms |
+| 重连替换 | 29.232 ms | 31.674 ms |
+| 重连保留 heap | 23.028 MiB | 23.028 MiB |
+
+六个浏览器样本均报告 `inputOverlapped: true`,并在第 241 个 turn-tail 渲染后结束。强制 GC 后浏览器 heap 首次运行约为 52.94 MiB,第二次约为 53.00 MiB,两次 DOM 元素均为 17,064 个;这些仍为诊断终点。两次运行支持这些 runner 上的现有预算,而不证明普遍适用的 2× 浏览器速度比。草稿键入中位数从 932.746 ms 变化到 142.148 ms,因为该终点测量整个草稿键入,包含调度和 Playwright 可交互性等待,而非单次按键延迟保证。这些是自托管测量,而非标准托管校准;不声称产品优化。
+
+### 标准托管预期与输入调度
+
+[运行 34033336246,job 101487216170](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34033336246/job/101487216170) 在双 CPU 标准托管 Ubuntu 上记录重连替换时间 46.574411、46.067910 和 44.193704 ms,保留 heap 为 23.028 MiB。该终点的预期为 50 ms;现有 1.25× 余量产生向上取整后的 63 ms 上限。30 MiB 内存预算及共享机器倍率不变。浏览器打开记录 681.276514 和 541.051233 ms,第三个样本因输入重叠失败而中止;两个值均超过历史 500 ms 上限。下文的托管打开重复测量决定其预期与上限。确定性对照通过这些记录值,并使用与测量判定相同的断言拒绝超过新上限的值。仍需完整的托管重复运行判定;这两个打开值不是三样本中位数。
+
+临时使用 3× Chromium CPU 降速的本地诊断复现重叠失败:首个标记在 1321 ms 可见,两次动画帧在 1370 ms 结束,输入框点击在 1660 ms 完成;实际输入是真实事件,但已看到 DONE。移除帧等待并在发送前安装观察器后,一次运行仍在 1415 ms 才看到首个标记,点击在 1726 ms 完成,晚于原先 992 ms 的脚本流。固定 16 ms 节奏保留相同的 120 个 delta 和负载,为该工作负载提供 1984 ms 脚本节奏。完整壁钟额度仅改变该节奏项(4484 ms);输入、首段回复及主线程额外开销额度不变。在相同诊断降速下,三个 16 ms 样本在 1307/1479/1599 ms 达到首段可见状态,并接受 DONE 之前的真实输入;其 DONE 之后的对照拒绝该输入。该诊断不是 CPU 比率校准。每个测量样本仍要求真实输入发生时 FIRST 存在且 DONE 不存在;测量后在 DONE 之后发送的真实按键必须无法通过同一个断言。Host 结算和第 241 个已渲染 turn-tail 仍是完成证据。
+
+[运行 34034524861,job 101490135303](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34034524861/job/101490135303) 记录三个完整浏览器样本,均具有真实输入重叠,并通过 DONE 之后的拒绝对照。最慢分页样本为 843.941625/672.834329/684.461818 ms(中位数 684.461818);首次 Trajectory 样本为 605.788061/367.754027/485.931656 ms(中位数 485.931656)。两者的终点专属托管预期分别为 700 和 500 ms,相同的 1.25× 余量产生 875 和 625 ms 上限。记录中位数对照拒绝历史 650/400 ms 上限,接受这些托管上限,并通过测量判定所用断言拒绝超过各上限一毫秒的值。打开、首段回复、主线程任务、输入及完整壁钟的中位数为 713.910/1486.206/2806.415/947.398/2986.983 ms;仅打开上限根据下文的重复测量重新校准。该运行提供校准数据,不代表基准判定通过;仍需完整的托管重复运行。
+
+[运行 34036109842,job 101494445658](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34036109842/job/101494445658) 记录打开样本 875.306861/1083.683529/814.700998 ms,中位数为 875.306861 ms,前一次托管运行的中位数为 713.909727 ms。该终点的预期为 900 ms,向上取整较大的重复测量中位数,而非向 875 ms 上限增加微量余量;不变的 1.25× 余量产生 1125 ms 上限。同一个强制断言接受记录中位数,在历史 500 和 875 ms 上限下均拒绝它,并在当前上限下拒绝合成的 1126 ms 值。三个样本均保留真实输入重叠与 DONE 之后的拒绝;其他所有前端中位数均在不变的上限内。此校准不代表 CI 运行通过。
+
+## 考虑过的替代方案
+
+**用 Node 折叠作为绘制证据。** 拒绝,因为它不执行 DOM 修改、布局或浏览器调度。聚焦重连用例同样不声称 GUI 提速。
+
+**把整个手动浏览器诊断提升到 CI。** 拒绝,因为其 1,000 Session 侧栏和 100 轮 soak 覆盖更广的工作负载。受限的必需用例复用其产品 scaffold 和测量方式,不导入测试模块,也不改变手动清单。
+
+**合并活跃重连 chunk。** 不能作为基准捷径:Client 条目向对话定义公开每个成员的顺序和时间戳。基准保留该生产行为;减少保留条目需要独立的语义设计,而非复制产品算法或使用合成近似。
+
+**每次流式标记查找都搜索全部已加载历史。** 拒绝,因为 Playwright 把文本与无障碍扫描注入同一个被测 CPU 的渲染进程。将查询限定在输入框和最新 Assistant 中,可保留可见完成检查,同时避免观察器成本随已加载历史增长。
+
+**只测量流式 CPU。** 拒绝,因为传输停顿和最终结算延迟可能不增加主线程 CPU。独立的输入、首段回复和完整壁钟预算覆盖这些等待。
+
+## 影响
+
+基准层不改变产品实现或用户可见行为。它在现有隔离 CI lane 中增加约十五秒的本地浏览器与重连执行,以及 Web 构建和浏览器安装成本。全新浏览器丢弃此前的缓存,但每个工作流刻意在续接期间保留自身已加载历史和曾激活的 Trajectory。
+
+基线可独立合并并保护现有性能;优化层只有在重复测量与聚焦语义测试支持下才收紧预算。它不覆盖侧栏数量级、数小时 soak、GPU 显示、真实模型延迟、发布版 Host 启动或重连渲染。[Web 浏览器车道](2026-07-24-web-gui-browser-e2e-lane.zh.md)保留独立的无阈值手动诊断与功能浏览器测试;经校准的必需测量由本基准车道负责。现有 Session 性能记录保持活跃,因为它拥有 Node 校准和持久化理由;本记录扩展而不替代它。

+ 6 - 0
.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md
+2026-09-07-subagent-teardown-test-budgets.md: 4fe83c421383aa768ffa0d33520407ed8d099d14
+2026-09-07-subagent-teardown-test-budgets.zh.md: 1487351d498c9d0ef9eb6c2e83e47425ac82b9c9

+ 28 - 0
.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md

@@ -0,0 +1,28 @@
+# Agent Note: Subagent teardown tests inherit their execution lane budgets
+
+Status: implemented
+
+English | [中文](2026-09-07-subagent-teardown-test-budgets.zh.md)
+
+## Problem
+
+The [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34085536250/job/101628739668) reports two teardown failures despite granting tests and hooks 90 seconds. The ACP ignored-EOF test races disposal against its own five-second timer. The real Codex test overrides the hook budget with 30 seconds. Neither deadline tests a product latency guarantee. The Codex body has already observed process-tree exit before its hook fails; the log does not identify whether context disposal, HTTP closure, or temporary-directory removal exceeded the hook budget.
+
+## Decision
+
+The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged.
+
+The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
+
+The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect.
+
+## Alternatives considered
+
+- Increase production grace periods or filesystem retries: the failures do not demonstrate incorrect product timing or exhausted removal retries.
+- Replace local deadlines with larger constants: that would still override future lane budgets.
+- Return from cleanup immediately after requesting termination: that would permit children or sockets to outlive the fixture.
+- Serialize coverage: unrelated tests need not lose concurrency to accommodate two local deadline overrides.
+
+## Consequences
+
+The lane timeout remains a bound on hangs. Focused tests verify observed child completion and cleanup ownership instead of host termination speed. Native Windows runs remain necessary for taskkill, process-exit delivery, and NTFS removal evidence; passing macOS tests cannot prove those mechanisms. No model-visible output, Session fixture, production timeout, or CI routing changes.

+ 28 - 0
.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md

@@ -0,0 +1,28 @@
+# Agent Note: 子代理清理测试继承执行通道的时间预算
+
+Status: implemented
+
+[English](2026-09-07-subagent-teardown-test-budgets.md) | 中文
+
+## 问题
+
+[Windows 覆盖率运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34085536250/job/101628739668) 为测试和钩子提供 90 秒预算,却报告了两个清理失败。ACP 忽略 EOF 测试让清理与自设的五秒定时器竞争。真实 Codex 测试将钩子预算覆盖为 30 秒。这两个期限都不用于验证产品延迟保证。Codex 测试正文在钩子失败前已经观察到进程树退出;日志未指出究竟是上下文释放、HTTP 关闭还是临时目录删除超出了钩子预算。
+
+## 决策
+
+[ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。
+
+[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
+
+[原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。
+
+## 曾考虑的替代方案
+
+- 增加生产环境宽限期或文件系统重试次数:这些失败不能证明产品时序错误或删除重试耗尽。
+- 用更大的常量替换局部期限:这样仍会覆盖未来的通道预算。
+- 请求终止后立即结束清理:这样会允许子进程或套接字存活超过夹具的生命周期。
+- 将覆盖率测试串行化:无关测试不应为两个局部期限覆盖而失去并发能力。
+
+## 后果
+
+通道超时仍为挂起提供时间上限。定向测试验证观察到的子进程完成和清理所有权,而不是宿主机终止速度。taskkill、进程退出通知和 NTFS 删除仍需原生 Windows 运行提供证据;macOS 测试通过不能证明这些机制。模型可见输出、Session 夹具、生产环境超时与 CI 路由均不变。

+ 6 - 0
.github/workflows/ci-master.yml

@@ -146,6 +146,12 @@ jobs:
         with:
           fetch-depth: 0
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}

+ 21 - 0
.github/workflows/ci.yml

@@ -56,6 +56,12 @@ jobs:
           fetch-depth: 0
           persist-credentials: false
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
@@ -113,6 +119,12 @@ jobs:
         with:
           persist-credentials: false
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
@@ -194,6 +206,9 @@ jobs:
       - name: Install (immutable)
         run: pnpm install --frozen-lockfile
 
+      - name: Install benchmark browser and hosted dependencies
+        run: pnpm --filter @deepseek-ai/dsh-benchmarks exec playwright install --with-deps chromium
+
       - name: Run performance benchmarks
         env:
           DSH_GATE_VERBOSE: '1'
@@ -224,6 +239,12 @@ jobs:
         with:
           persist-credentials: false
 
+      # Redirect the Node compile cache (enabled by pnpm and TypeScript) off
+      # the root partition's /tmp before the first pnpm call in this lane —
+      # see .agents/notes/implemented/process/2026-08-28-ci-node-compile-cache-data-disk.md.
+      - name: Redirect Node compile cache to runner temp
+        run: echo "NODE_COMPILE_CACHE=${{ runner.temp }}/node-compile-cache" >> "$GITHUB_ENV"
+
       - uses: pnpm/action-setup@v4
         with:
           dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}

+ 1 - 0
apps/web/tests/chat-scroll-contract.e2e.ts

@@ -660,6 +660,7 @@ describe('web e2e: long Chat scroll contract', () => {
         await liveRow.waitFor({ timeout: 15_000 })
         expect(await liveRow.getAttribute('data-state')).toBe('running')
         await expectBottom(world.page)
+        expect(await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).count()).toBe(0)
 
         await wheelTranscript(world.page, -1_200)
         await world.page.getByRole('button', { name: 'Back to bottom', exact: true }).waitFor({ timeout: 10_000 })

+ 4 - 4
apps/web/tests/steering.e2e.ts

@@ -110,8 +110,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
       { timeout: 10_000 },
     ).toBe(true)
 
-    // Enter remains the Queue gesture. The row action then atomically moves
-    // this exact occurrence into the current turn's steering outbox.
+    // Enter remains the Queue gesture. In this live window the row action
+    // atomically moves this exact occurrence into the current turn's steering outbox.
     await page.locator('[data-composer-input][contenteditable="true"]').first().waitFor({ timeout: 10_000 })
     await input.fill(STEER)
     await input.press('Enter')
@@ -121,8 +121,8 @@ describe('web e2e: mid-turn steering lands durably and visibly', () => {
     await expect.poll(() => steerButton.isEnabled(), { timeout: 10_000 }).toBe(true)
     await steerButton.click({ timeout: 10_000 })
     const pendingSteering = page.locator('[data-pending-steering]').filter({ hasText: STEER })
-    // A timeout while the Queue row remains means strict steer lost to a
-    // closing window (`steer-unavailable`); inspect replay pacing first.
+    // A timeout while the Queue row remains means the command observed a
+    // stopped Agent (`steer-unavailable`); inspect replay pacing first.
     await pendingSteering.waitFor({ timeout: 10_000 })
 
     // The blocked composer keeps steering pending long enough to observe the

+ 35 - 11
apps/web/tests/subagent-interrupt-ui.e2e.ts

@@ -37,6 +37,7 @@ const INITIAL = 'Explain event sourcing in one sentence.'
 const REARM = 'Keep working until I stop you again.'
 const REARM_WAKE = 'Start that queued work now.'
 const FOLLOWUP = 'Now give the same explanation to a human reader.'
+const EDITED_FOLLOWUP = 'Explain the same idea for a human reader.'
 const WAKING = 'And add one concrete example.'
 const REARMED_ANSWER = 're-armed setup answer'
 const PARKED_ANSWER = 'parked follow-up answer'
@@ -213,22 +214,26 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       const send = page.getByRole('button', { name: 'Send message' })
       expect(await send.count()).toBe(1)
       expect(await send.isDisabled()).toBe(true)
-      await compareOrRefreshGolden(
-        OFFLINE_COMPOSER_EXPECTED,
-        await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
-        MODE,
-      )
-
       // Keep the continuable Activation resident after this first abort. The
-      // direct setup queue does not change the parent-offline UI contract: its
-      // input and Send remain disabled throughout the exercised browser path.
+      // direct setup queue also proves the ordinary row controls remain
+      // available while this parent-offline composer cannot submit new input.
       await scaffold.ctx.subagents.prompt({
         requestId: 'interrupt-ui-rearm' as SubagentPromptRequestId,
         parentSessionId: parent.id,
         childSessionId: childId,
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: REARM }],
       }, new AbortController().signal)
+      await page.getByRole('button', { name: 'Edit queued message' }).waitFor({ timeout: 15_000 })
+      expect(await page.getByRole('button', { name: 'Remove queued message' }).count()).toBe(1)
+      expect(await page.getByRole('button', { name: 'Steer queued message' }).count()).toBe(1)
+      await compareOrRefreshGolden(
+        OFFLINE_COMPOSER_EXPECTED,
+        await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd),
+        MODE,
+      )
+
       const aborted = waitForAbortedTurn(scaffold, childId)
       const interruptResponse = page.waitForResponse(response =>
         new URL(response.url()).pathname === '/api/subagents/interruptByParent')
@@ -247,6 +252,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
         parentSessionId: parent.id,
         childSessionId: childId,
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: REARM_WAKE }],
       }, new AbortController().signal)
       await waitFor(() => existsSync(rearmedReadyFile), 'the re-armed child turn to open')
@@ -263,8 +269,13 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       .getByRole('button').first().click()
     await page.getByRole('button', { name: /1 subagent/ }).click()
     await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
-    const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
-    await input.waitFor({ timeout: 15_000 })
+    // A live continuable child advertises the ordinary steer-all gesture, so
+    // that placeholder is the composer's accessible name in this window. It
+    // changes back as the queue drains, so later interactions address the
+    // stable composer node instead.
+    await page.getByRole('textbox', { name: 'Cmd/Ctrl+Enter steers all queued messages' })
+      .waitFor({ timeout: 15_000 })
+    const input = page.locator('[data-composer-input]').first()
     expect(await input.isDisabled()).toBe(false)
 
     // Queue a follow-up through Send while independent Stop remains available.
@@ -275,6 +286,19 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
     expect(((await (await promptResponse).json()) as { result: { ok: boolean } }).result)
       .toMatchObject({ ok: true })
 
+    await page.getByRole('button', { name: '2 queued messages' }).click()
+    const followupRow = page.locator('[data-queue-dock] li', { hasText: FOLLOWUP })
+    await followupRow.getByRole('button', { name: 'Edit queued message' }).click()
+    const editor = page.getByRole('textbox', { name: 'Edit queued message' })
+    await editor.fill(EDITED_FOLLOWUP)
+    const updateResponse = page.waitForResponse(response =>
+      new URL(response.url()).pathname === '/api/session/updateQueue')
+    await page.getByRole('button', { name: 'Save queued message' }).click()
+    expect(((await (await updateResponse).json()) as { result: { ok: boolean } }).result)
+      .toMatchObject({ ok: true })
+    await page.getByText(EDITED_FOLLOWUP, { exact: true }).waitFor()
+    expect(apiCalls.filter(path => path === '/api/subagents/updateQueue')).toEqual([])
+
     const aborted = waitForAbortedTurn(scaffold, childId)
     const stop = page.getByRole('button', { name: 'Stop generating' })
     expect(await stop.count()).toBe(1)
@@ -314,7 +338,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
       : [])
     expect(userTexts[0]).toBe(INITIAL)
     expect(userTexts[1]).toMatch(/^Your parent agent id is .+send_message\(\{ agent_id: /)
-    expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, FOLLOWUP, WAKING])
+    expect(userTexts.slice(2)).toEqual([REARM, REARM_WAKE, EDITED_FOLLOWUP, WAKING])
     const turnEndKinds = events
       .filter(event => event.type === 'turn/end')
       .map(event => event.data.reason.kind)

+ 2 - 0
apps/web/tests/subagent-interrupt.e2e.ts

@@ -136,6 +136,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th
         parentSessionId: parentId,
         childSessionId: childId,
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: FOLLOWUP }],
       },
     })
@@ -170,6 +171,7 @@ describe.skipIf(MODE === 'record')('web e2e: subagents/interruptByParent over th
         parentSessionId: parentId,
         childSessionId: childId,
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: WAKING }],
       },
     })

+ 1 - 0
benchmarks/AGENTS.md

@@ -5,6 +5,7 @@ This tree owns required, repository-level performance gates whose measured user
 - Organize benchmarks by measured user path, one directory per path. Do not mirror the package tree.
 - Host cases use `*.bench.ts`; Client-face cases use `*.bench.client.ts`. Worker, fixture, and support modules do not carry a benchmark suffix.
 - The private `@deepseek-ai/dsh-benchmarks` workspace owns benchmark-only dependencies. `test:bench` builds workspace libraries and `benchmarks/.dsh-build/` workers before Vitest orchestration. Timed CPU work runs in those workers under plain Node, without a TypeScript loader; runtime package imports must resolve to built `lib/` entries.
+- Browser workflow cases drive built Client bundles through the shared shipped-composition Web scaffold. Report its source-resolved test Host separately from published-Host evidence; two animation frames prove a rendering opportunity, not hardware presentation. Use fresh browsers and private scaffold worlds per sample.
 - Synthesize fixed inputs from reviewed constants. Never use recorded Sessions, user material, ambient repositories, or network services.
 - Run process-level wall-clock and retained-memory samples in fresh children with private `mkdtemp` roots. Pure synchronous folds create a fresh object graph per sample and must not mutate process-global state. Bound every child, await exit, and remove owned roots after failure as well as success.
 - Record reference-machine expectations separately from the shared CI time scale and variance headroom. Do not apply the time scale to memory or dimensionless ratios.

+ 6 - 0
benchmarks/active-stream-reconnect/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write benchmarks/active-stream-reconnect/README.md
+README.md: e75a41eba3952bb4db343c2018e217e6f5e88f96
+README.zh.md: acf0260f53855f9f4e643b2e72c67aa7a8afd6bb

+ 7 - 0
benchmarks/active-stream-reconnect/README.md

@@ -0,0 +1,7 @@
+# Active Assistant reconnect benchmark
+
+English | [中文](README.zh.md)
+
+[reconnect.bench.client.ts](reconnect.bench.client.ts) measures the production Client fold when a reconnect carries an unfinished 100,000-delta reasoning prefix. A compiled private adapter reaches `ClientAssistantStream.replace()` without adding product exports. Three fresh plain-Node workers synthesize the compact baseline before timing; replacement time and retained heap after forced GC have separate median budgets. The next dense live frame must still be accepted. Standard hosted CI uses a 50 ms replacement expectation with the shared 1.25× headroom (63 ms ceiling); the retained-heap budget remains 30 MiB. Recorded-sample and synthetic-regression controls exercise the same time assertion as the worker verdict.
+
+Build with `pnpm run build:bench`, then select `benchmarks/active-stream-reconnect` in `vitest.bench.config.ts`. This focused Node workload neither builds nor measures browser rendering. [Frontend performance budgets](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md) records calibration and exclusions.

+ 7 - 0
benchmarks/active-stream-reconnect/README.zh.md

@@ -0,0 +1,7 @@
+# 活跃 Assistant 重连基准
+
+[English](README.md) | 中文
+
+[reconnect.bench.client.ts](reconnect.bench.client.ts) 测量重连携带未完成的 100,000 个 reasoning delta 前缀时,生产 Client 的折叠成本。编译后的私有适配器调用 `ClientAssistantStream.replace()`,不增加产品导出。三个全新纯 Node worker 在计时前合成紧凑 baseline;替换时间与强制 GC 后的保留 heap 分别执行中位数预算检查。下一个稠密序号的实时 frame 仍须被接受。标准托管 CI 使用 50 ms 替换预期及共享的 1.25× 余量(向上取整为 63 ms);保留 heap 预算仍为 30 MiB。记录样本和合成回归对照使用与 worker 判定相同的时间断言。
+
+通过 `pnpm run build:bench` 构建,再在 `vitest.bench.config.ts` 中选择 `benchmarks/active-stream-reconnect`。该聚焦 Node workload 既不构建也不测量浏览器渲染。[前端性能预算](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md)记录校准与排除项。

+ 48 - 0
benchmarks/active-stream-reconnect/reconnect.bench.client.ts

@@ -0,0 +1,48 @@
+/** Required baseline budgets for reconnecting during a large active Assistant stream. */
+import { join } from 'node:path'
+import { expect, it } from 'vitest'
+import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
+import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import type { ReconnectReport } from './reconnect.worker.client.ts'
+
+const EXPECTED_REPLACE_CI_MS = 50
+const REPLACE_BUDGET_MS = Math.ceil(EXPECTED_REPLACE_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+const REFERENCE_RETAINED_MB = 24
+const SAMPLES = 3
+
+function expectReplacementWithinBudget(value: number, budget: number): void {
+  expect(value).toBeLessThanOrEqual(budget)
+}
+
+it('accepts recorded hosted reconnect samples and rejects replacement regressions', () => {
+  const recordedMedian = [46.574411, 46.067910, 44.193704].toSorted((a, b) => a - b)[1]!
+  expect(() => expectReplacementWithinBudget(recordedMedian, ciTimeBudget(16))).toThrow()
+  expectReplacementWithinBudget(recordedMedian, REPLACE_BUDGET_MS)
+  expect(REPLACE_BUDGET_MS).toBe(63)
+  expect(() => expectReplacementWithinBudget(75, REPLACE_BUDGET_MS)).toThrow()
+  expect(() => expectReplacementWithinBudget(REPLACE_BUDGET_MS + 1, REPLACE_BUDGET_MS)).toThrow()
+})
+
+it('reconstructs a 100000-delta live prefix within baseline time and retained-memory budgets', async () => {
+  const samples: ReconnectReport[] = []
+  for (let sample = 0; sample < SAMPLES; sample++) {
+    const run = await runBuiltBenchmarkWorker<ReconnectReport>({
+      worker: join(import.meta.dirname, '../.dsh-build/active-stream-reconnect/reconnect.worker.js'),
+      exposeGc: true, timeoutMs: 30000,
+    })
+    expect(run.timedOut, run.stderr).toBe(false)
+    expect(run.signal, run.stderr).toBeNull()
+    expect(run.exitCode, run.stderr).toBe(0)
+    if (run.report === undefined) throw new Error('reconnect worker omitted report')
+    expect(run.report.nextFrame).toBe('transient')
+    expect(run.report.entries).toBeGreaterThan(0)
+    samples.push(run.report)
+  }
+  const replaceMs = samples.map(sample => sample.replaceMs).toSorted((a, b) => a - b)[1]!
+  const retainedMb = samples.map(sample => sample.retainedMb).toSorted((a, b) => a - b)[1]!
+  const budgetMs = REPLACE_BUDGET_MS
+  const budgetMb = REFERENCE_RETAINED_MB * PERFORMANCE_BUDGET_HEADROOM
+  console.log(JSON.stringify({ benchmark: 'active-stream-reconnect', samples, median: { replaceMs, retainedMb }, expectedCiMs: EXPECTED_REPLACE_CI_MS, referenceMb: REFERENCE_RETAINED_MB, budgetMs, budgetMb }))
+  expectReplacementWithinBudget(replaceMs, budgetMs)
+  expect.soft(retainedMb).toBeLessThanOrEqual(budgetMb)
+})

+ 49 - 0
benchmarks/active-stream-reconnect/reconnect.worker.client.ts

@@ -0,0 +1,49 @@
+/** Compiled production Client fold for a reconnect during a long Assistant attempt. */
+import { performance } from 'node:perf_hooks'
+import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
+import { LlmAttemptId } from '@deepseek-ai/dsh-llm/brand'
+import type { SessionAssistantStreamBaseline } from '@deepseek-ai/dsh-api-session-controller/types'
+// The Client implementation has no plain-Node export; only this adapter is bundled.
+import { ClientAssistantStream } from '../../packages/api/session-controller/src/client/sessions/assistant-stream.ts'
+import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
+
+/** Measurements of replace() only; fixture construction and forced GC are excluded. */
+export interface ReconnectReport {
+  readonly deltas: number
+  readonly records: number
+  readonly entries: number
+  readonly replaceMs: number
+  readonly retainedMb: number
+  readonly nextFrame: string | undefined
+}
+
+assertBuiltBenchmarkRuntime(import.meta.url, {
+  '@deepseek-ai/dsh-llm/assistant-stream': import.meta.resolve('@deepseek-ai/dsh-llm/assistant-stream'),
+})
+const deltas = 100000
+const accumulator = new AssistantStreamAccumulator()
+accumulator.push({ time: 1700000000000, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } })
+for (let index = 0; index < deltas; index++) {
+  accumulator.push({ time: 1700000000001 + index, chunk: { type: 'reasoning-delta', index: 0, text: 'token ' } })
+}
+const attemptId = LlmAttemptId('synthetic-reconnect')
+const nextIndex = deltas + 1
+const baseline: SessionAssistantStreamBaseline = {
+  revision: nextIndex + 1,
+  activeAttempt: {
+    attemptId, startedAfterSeq: -1, turn: 1, step: 1, nextIndex,
+    stream: JSON.parse(JSON.stringify(accumulator.snapshot())) as NonNullable<SessionAssistantStreamBaseline['activeAttempt']>['stream'],
+  },
+}
+if (globalThis.gc === undefined) throw new Error('reconnect benchmark requires --expose-gc')
+globalThis.gc()
+const before = process.memoryUsage().heapUsed
+const client = new ClientAssistantStream()
+const start = performance.now()
+const visible = client.replace([], baseline)
+const replaceMs = performance.now() - start
+globalThis.gc()
+const retainedMb = (process.memoryUsage().heapUsed - before) / 1048576
+const next = client.acceptFrame({ type: 'chunk', attemptId, revision: nextIndex + 2, index: nextIndex, time: 1700000000001 + deltas, chunk: { type: 'reasoning-delta', index: 0, text: 'suffix' } })
+const report: ReconnectReport = { deltas, records: baseline.activeAttempt!.stream.length, entries: visible.length, replaceMs, retainedMb, nextFrame: next?.type }
+process.stdout.write(JSON.stringify(report) + '\n')

+ 6 - 0
benchmarks/agent-continuation/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write benchmarks/agent-continuation/README.md
+README.md: 5f76b67805a906ea13321ab22eb671d41bfc3190
+README.zh.md: 54d9e1a0e950d14ce5a01568bc9e1cdb61de597a

+ 31 - 0
benchmarks/agent-continuation/README.md

@@ -0,0 +1,31 @@
+# Backend continuation benchmarks
+
+English | [中文](README.zh.md)
+
+## Summary
+
+Measure long-history request processing, cold tool-heavy continuation, and repeated discovery of inactive fork children without network services or recorded user data. The SDK variant drives 100 turns and 800 real file reads through the shipped sdk-minimal profile; other cases isolate backend service costs. No case renders a browser.
+
+## Table of Contents
+
+- [Run](#run)
+- [Measurements](#measurements)
+- [Dev Note](#dev-note)
+
+<a id="run"></a>
+
+## Run
+
+From the repository root, build the libraries and workers with `pnpm run build:bench`, then run `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`. Do not overlap timing runs with builds or other benchmarks.
+
+The test reports all five fresh-process samples and enforces reviewed median budgets. Catalog and tool continuation each use a 900 ms standard hosted CI expectation with 1.25× headroom (1,125 ms); request history uses a 190 ms hosted expectation with the same headroom (238 ms), and SDK continuation uses reference-machine scaling. A failed worker reports its exit, signal, timeout, and stderr; temporary roots are removed even on failure. The required benchmark lane discovers this file automatically.
+
+<a id="measurements"></a>
+
+## Measurements
+
+[workload.ts](workload.ts) owns synthetic dimensions. [The Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.md) owns timing endpoints, calibration evidence, memory interpretation, and exclusions. The model adapter does not perform provider serialization or network calls; integrated cases run synthetic tool bodies through the real tool-execution pipeline, while the SDK profile variant performs real file reads.
+
+## Dev Note
+
+None.

+ 31 - 0
benchmarks/agent-continuation/README.zh.md

@@ -0,0 +1,31 @@
+# 后端续聊基准
+
+[English](README.md) | 中文
+
+## Summary
+
+在不使用网络服务或录制用户数据的情况下,测量长历史请求处理、冷工具密集续聊和重复发现非活动 fork 子会话。SDK 变体通过已发布 sdk-minimal profile 执行 100 个轮次和 800 次真实文件读取;其他用例隔离后端服务成本。所有用例均不渲染浏览器。
+
+## Table of Contents
+
+- [运行](#run)
+- [测量](#measurements)
+- [Dev Note](#dev-note)
+
+<a id="run"></a>
+
+## 运行
+
+在仓库根目录使用 `pnpm run build:bench` 构建库和 worker,然后运行 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/agent-continuation/agent-continuation.bench.ts`。不要让计时运行与构建或其他基准重叠。
+
+测试报告全部五个新进程样本,并约束经审查的中位数预算。目录和工具续聊用例均使用标准托管 CI 的 900 ms 期望值与 1.25× 余量(1,125 ms);请求历史使用 190 ms 托管期望值与相同余量(238 ms),SDK 续聊使用参考机器缩放。worker 失败时报告退出状态、信号、超时和 stderr;失败时也会删除临时根目录。必需基准通道自动发现此文件。
+
+<a id="measurements"></a>
+
+## 测量
+
+[workload.ts](workload.ts)拥有合成维度。[Agent Note](../../.agents/notes/implemented/testing/2026-09-06-backend-continuation-performance.zh.md)拥有计时终点、校准证据、内存解释和排除项。模型适配器不执行服务商序列化或网络调用;集成用例通过真实工具执行管线运行合成工具体,SDK profile 变体则执行真实文件读取。
+
+## Dev Note
+
+无。

+ 175 - 0
benchmarks/agent-continuation/agent-continuation.bench.ts

@@ -0,0 +1,175 @@
+/** Baseline budgets for long-history requests, tool continuation, and fork-child discovery. */
+
+import { cp, mkdir, mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterAll, beforeAll, describe, expect, it } from 'vitest'
+import { runBuiltBenchmarkWorker } from '../support/built-worker.ts'
+import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import type { ContinuationReport } from './agent-continuation.worker.ts'
+import type { CatalogReport } from './child-catalog.worker.ts'
+import type { ProfileReport } from './profile-continuation.worker.ts'
+import { WORKLOAD } from './workload.ts'
+
+const ATTEMPTS = 5
+const WORKER_TIMEOUT_MS = 60_000
+/** M4 Pro / Node 24.19 baseline expectations, before shared CI scaling and variance headroom. */
+const EXPECTED_MS = { 'profile-continuation': 1_700 } as const
+/** Standard two-CPU hosted CI baseline request-history median is 582.304 ms. */
+const EXPECTED_BASELINE_REQUEST_CI_MS = 600
+const BASELINE_REQUEST_BUDGET_MS = Math.ceil(EXPECTED_BASELINE_REQUEST_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+/** Standard two-CPU hosted CI tool-continuation median is 898.252 ms. */
+const EXPECTED_TOOL_CONTINUATION_CI_MS = 900
+const TOOL_CONTINUATION_BUDGET_MS = Math.ceil(EXPECTED_TOOL_CONTINUATION_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+/** Standard two-CPU hosted CI catalog median is 858.364 ms; 900 ms is the rounded expectation. */
+const EXPECTED_CATALOG_CI_MS = 900
+const CATALOG_BUDGET_MS = Math.ceil(EXPECTED_CATALOG_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+/** Two-CPU ubuntu-24.04 / Node 24.20 samples span 182.161–185.042 ms; rounded CI expectation. */
+const EXPECTED_REQUEST_HISTORY_CI_MS = 190
+const REQUEST_HISTORY_BUDGET_MS = Math.ceil(EXPECTED_REQUEST_HISTORY_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+const EXPECTED_RETAINED_HEAP_MB = 23
+const WORKERS = join(import.meta.dirname, '..', '.dsh-build', 'agent-continuation')
+
+type Scenario = 'request-history' | 'catalog' | 'tool-continuation' | keyof typeof EXPECTED_MS
+type Report = ContinuationReport | CatalogReport | ProfileReport
+
+function workerName(scenario: Scenario): string {
+  if (scenario === 'profile-continuation') return 'profile-continuation.worker.js'
+  return scenario === 'catalog' ? 'child-catalog.worker.js' : 'agent-continuation.worker.js'
+}
+
+async function run<Output>(root: string, scenario: Scenario, mode: string): Promise<Output> {
+  const outcome = await runBuiltBenchmarkWorker<Output>({
+    worker: join(WORKERS, workerName(scenario)), args: [root, mode],
+    timeoutMs: WORKER_TIMEOUT_MS, exposeGc: true,
+  })
+  if (outcome.timedOut || outcome.signal !== null || outcome.exitCode !== 0 || outcome.report === undefined) {
+    throw new Error('backend worker failed: ' + JSON.stringify(outcome))
+  }
+  return outcome.report
+}
+
+function median(values: readonly number[]): number {
+  return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)] as number
+}
+
+function expectTotalWithinBudget(value: number, budget: number): void {
+  expect(value).toBeLessThanOrEqual(budget)
+}
+
+describe('standard hosted catalog calibration', () => {
+  it('accepts the recorded two-CPU samples that exceed the historical budget', () => {
+    const recordedMedian = median([797.373945, 883.157358, 858.363927, 790.568538, 904.5785669999999])
+
+    expect(recordedMedian).toBe(858.363927)
+    expect(() => expectTotalWithinBudget(recordedMedian, 800)).toThrow()
+    expectTotalWithinBudget(recordedMedian, CATALOG_BUDGET_MS)
+    expect(CATALOG_BUDGET_MS).toBe(1_125)
+  })
+
+  it('rejects a synthetic material catalog regression', () => {
+    const regressionMedian = median([1_380, 1_400, 1_420, 1_410, 1_390])
+
+    expect(regressionMedian).toBe(1_400)
+    expect(() => expectTotalWithinBudget(regressionMedian, CATALOG_BUDGET_MS)).toThrow()
+  })
+})
+
+describe('standard hosted tool-continuation calibration', () => {
+  it('accepts recorded two-CPU samples but rejects a material regression', () => {
+    const recordedMedian = median([917.006744, 892.091482, 887.838867, 905.6594390000001, 898.2517579999999])
+
+    expect(recordedMedian).toBe(898.2517579999999)
+    expect(() => expectTotalWithinBudget(recordedMedian, 850)).toThrow()
+    expectTotalWithinBudget(recordedMedian, TOOL_CONTINUATION_BUDGET_MS)
+    expect(TOOL_CONTINUATION_BUDGET_MS).toBe(1_125)
+    expect(() => expectTotalWithinBudget(1_400, TOOL_CONTINUATION_BUDGET_MS)).toThrow()
+  })
+})
+
+describe('standard hosted baseline request-history calibration', () => {
+  it('accepts recorded two-CPU samples but rejects a material regression', () => {
+    const recordedMedian = median([618.598065, 618.606407, 582.0351149999999, 582.303506, 581.8318300000001])
+
+    expect(recordedMedian).toBe(582.303506)
+    expect(() => expectTotalWithinBudget(recordedMedian, 550)).toThrow()
+    expectTotalWithinBudget(recordedMedian, BASELINE_REQUEST_BUDGET_MS)
+    expect(BASELINE_REQUEST_BUDGET_MS).toBe(750)
+    expect(() => expectTotalWithinBudget(900, BASELINE_REQUEST_BUDGET_MS)).toThrow()
+  })
+})
+
+function assertRequestHistoryBudget(value: number): void {
+  expect(value).toBeLessThanOrEqual(REQUEST_HISTORY_BUDGET_MS)
+}
+
+describe('standard hosted request-history calibration', () => {
+  it('accepts the recorded two-CPU samples above the historical budget', () => {
+    const recorded = [183.355397, 184.468253, 185.042397, 182.160790, 182.924728]
+    const recordedMedian = median(recorded)
+
+    expect(recordedMedian).toBe(183.355397)
+    expect(recordedMedian).toBeGreaterThan(ciTimeBudget(70))
+    assertRequestHistoryBudget(recordedMedian)
+    assertRequestHistoryBudget(Math.max(...recorded))
+    expect(REQUEST_HISTORY_BUDGET_MS).toBe(238)
+  })
+
+  it('rejects a synthetic material request-history regression', () => {
+    const regressionMedian = median([248, 250, 252, 251, 249])
+    expect(() => assertRequestHistoryBudget(regressionMedian)).toThrow()
+  })
+
+  it('rejects the recorded original implementation on the M4 reference', () => {
+    const originalMedian = median([249.050708, 238.275291, 242.172084, 250.093166, 246.130875])
+    expect(originalMedian).toBe(246.130875)
+    expect(() => assertRequestHistoryBudget(originalMedian)).toThrow()
+  })
+})
+
+describe('continuing tool-heavy Sessions with large histories', () => {
+  let scratch: string | undefined
+  const sources = new Map<Scenario, string>()
+
+  beforeAll(async () => {
+    scratch = await mkdtemp(join(tmpdir(), 'dsh-agent-continuation-bench-'))
+    for (const scenario of ['request-history', 'catalog'] as const) {
+      const root = join(scratch, 'source-' + scenario)
+      await run(root, scenario, 'seed')
+      sources.set(scenario, root)
+    }
+    sources.set('tool-continuation', sources.get('request-history') as string)
+  })
+  afterAll(async () => {
+    if (scratch !== undefined) await rm(scratch, { recursive: true, force: true })
+  })
+
+  for (const scenario of ['request-history', 'tool-continuation', 'catalog', 'profile-continuation'] as const) {
+    it(scenario, async () => {
+      const samples: Report[] = []
+      for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
+        const root = join(scratch as string, scenario + '-' + String(attempt))
+        if (scenario === 'profile-continuation') await mkdir(root)
+        else await cp(sources.get(scenario) as string, root, { recursive: true })
+        try { samples.push(await run<Report>(root, scenario, scenario)) }
+        finally { await rm(root, { recursive: true, force: true }) }
+      }
+      const totalMs = samples.map(sample => sample.totalMs)
+      const budgetMs = scenario === 'request-history' ? REQUEST_HISTORY_BUDGET_MS
+        : scenario === 'catalog' ? CATALOG_BUDGET_MS
+          : scenario === 'tool-continuation' ? TOOL_CONTINUATION_BUDGET_MS : ciTimeBudget(EXPECTED_MS[scenario])
+      const retainedHeapBudgetMb = EXPECTED_RETAINED_HEAP_MB * PERFORMANCE_BUDGET_HEADROOM
+      console.log(JSON.stringify({
+        benchmark: 'agent-continuation/' + scenario, workload: WORKLOAD,
+        samples, totalMs: { min: Math.min(...totalMs), median: median(totalMs), max: Math.max(...totalMs) },
+        budgetMs, ...(scenario === 'tool-continuation' ? { retainedHeapBudgetMb } : {}),
+      }))
+      if (scenario === 'request-history') assertRequestHistoryBudget(median(totalMs))
+      else expectTotalWithinBudget(median(totalMs), budgetMs)
+      if (scenario === 'tool-continuation') {
+        expect(median((samples as ContinuationReport[]).map(sample => sample.retainedHeapMb)))
+          .toBeLessThanOrEqual(retainedHeapBudgetMb)
+      }
+    })
+  }
+})

+ 144 - 0
benchmarks/agent-continuation/agent-continuation.worker.ts

@@ -0,0 +1,144 @@
+/** Plain-Node measurements of active request history and cold tool-heavy continuation. */
+
+import { performance } from 'node:perf_hooks'
+import { scheduler } from 'node:timers/promises'
+import { Context } from '@deepseek-ai/cordis'
+import AgentLoop from '@deepseek-ai/dsh-agent-loop'
+import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
+import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
+import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
+import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
+import { PARENT_ID, response, resultText, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts'
+
+/** Raw timing and retained-memory report from one isolated backend process. */
+export interface ContinuationReport {
+  readonly totalMs: number
+  readonly resumeMs: number
+  readonly turnsMs: number
+  readonly flushMs: number
+  readonly cpuUserMs: number
+  readonly cpuSystemMs: number
+  readonly retainedHeapMb: number
+  readonly peakRssMb: number
+  readonly requests: number
+  readonly toolCalls: number
+  readonly events: number
+}
+
+class SyntheticAdapter extends LlmAdapter {
+  requests = 0
+  constructor(private readonly toolsPerTurn: number) { super() }
+
+  override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
+    return Promise.resolve({ provider, id: model, name: model })
+  }
+
+  async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
+    const tools = this.toolsPerTurn > 0 && this.requests % 2 === 0 ? this.toolsPerTurn : 0
+    const reply = response(100_000 + this.requests++, tools)
+    yield* reply.chunks
+  }
+}
+
+async function collectHeap(): Promise<number> {
+  if (globalThis.gc === undefined) throw new Error('backend benchmark requires --expose-gc')
+  globalThis.gc()
+  await scheduler.yield()
+  globalThis.gc()
+  return process.memoryUsage().heapUsed / 1_048_576
+}
+
+async function seed(root: string): Promise<void> {
+  const ctx = new Context()
+  try {
+    await mountAgentLoopTestDependencies(ctx)
+    await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
+    const handle = await ctx.sessionPersistence.create({
+      version: SESSION_FORMAT_VERSION, id: PARENT_ID, createdAt: TIME_ZERO, cwd: '/bench', isSeeded: false,
+    }, {})
+    try {
+      await handle.append(syntheticHistory(WORKLOAD.historyTurns))
+      await handle.flush()
+    } finally { await handle.close() }
+  } finally { await ctx.fiber.dispose() }
+}
+
+async function runTurns(agent: Agent, turns: number): Promise<void> {
+  for (let turn = 0; turn < turns; turn++) {
+    agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Continue synthetic task ' + String(turn) }], source: { kind: 'user' } }))
+    await agent.whenIdle()
+  }
+}
+
+async function measure(root: string, scenario: string): Promise<ContinuationReport> {
+  const ctx = new Context()
+  let handle: AgentHandle | undefined
+  const toolHeavy = scenario === 'tool-continuation'
+  const adapter = new SyntheticAdapter(toolHeavy ? WORKLOAD.toolsPerLiveTurn : 0)
+  let toolCalls = 0
+  try {
+    await ctx.plugin(SessionProjectionRegistry)
+    await mountAgentLoopTestDependencies(ctx)
+    await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
+    await ctx.plugin(AgentLoop, { agents: [] })
+    ctx.effect(() => ctx.llm.registerAdapter(['bench'], adapter))
+    ctx.effect(() => ctx.tools.register(defineContentToolFixture({
+      name: 'bench_tool', description: 'Read a bounded synthetic module.',
+      parameters: { ordinal: { type: 'number', required: true } },
+      isConcurrencySafe: () => true,
+      execute(args) {
+        toolCalls++
+        return Promise.resolve([{ type: 'text', text: resultText(args.ordinal) }])
+      },
+    })))
+    if (!toolHeavy) {
+      handle = await ctx.agents.resume({ resumeSessionId: PARENT_ID, agentOptions: { provider: 'bench', model: 'bench' } })
+    }
+    const beforeHeap = await collectHeap()
+    const cpuStart = process.cpuUsage()
+    const start = performance.now()
+    if (handle === undefined) {
+      handle = await ctx.agents.resume({ resumeSessionId: PARENT_ID, agentOptions: { provider: 'bench', model: 'bench' } })
+    }
+    const resumed = performance.now()
+    await runTurns(handle.agent, toolHeavy ? WORKLOAD.continuationTurns : WORKLOAD.requestTurns)
+    const turnsDone = performance.now()
+    await ctx.sessions.flush(handle.agent.session)
+    const end = performance.now()
+    const cpu = process.cpuUsage(cpuStart)
+    const retainedHeapMb = (await collectHeap()) - beforeHeap
+    if (adapter.requests !== (toolHeavy ? WORKLOAD.continuationTurns * 2 : WORKLOAD.requestTurns)
+      || toolCalls !== (toolHeavy ? WORKLOAD.continuationTurns * WORKLOAD.toolsPerLiveTurn : 0)) {
+      throw new Error('backend benchmark did not complete every requested model/tool step')
+    }
+    return {
+      totalMs: end - start, resumeMs: resumed - start, turnsMs: turnsDone - resumed, flushMs: end - turnsDone,
+      cpuUserMs: cpu.user / 1_000, cpuSystemMs: cpu.system / 1_000,
+      retainedHeapMb, peakRssMb: process.resourceUsage().maxRSS / 1_024,
+      requests: adapter.requests, toolCalls, events: handle.agent.session.seq,
+    }
+  } finally {
+    await handle?.dispose()
+    await ctx.fiber.dispose()
+  }
+}
+
+assertBuiltBenchmarkRuntime(import.meta.url, Object.fromEntries([
+  '@deepseek-ai/dsh-agent-loop', '@deepseek-ai/dsh-session', '@deepseek-ai/dsh-llm',
+  '@deepseek-ai/dsh-tools', '@deepseek-ai/dsh-session-persistence-jsonl',
+].map(name => [name, import.meta.resolve(name)])))
+const [root, scenario] = process.argv.slice(2)
+if (root === undefined || scenario === undefined || !['seed', 'request-history', 'tool-continuation'].includes(scenario)) {
+  throw new Error('usage: agent-continuation.worker.js <root> <seed|request-history|tool-continuation>')
+}
+if (scenario === 'seed') {
+  await seed(root)
+  process.stdout.write(JSON.stringify({ seeded: true }) + '\n')
+} else {
+  process.stdout.write(JSON.stringify(await measure(root, scenario)) + '\n')
+}

+ 95 - 0
benchmarks/agent-continuation/child-catalog.worker.ts

@@ -0,0 +1,95 @@
+/** Cold catalog observations of persisted fork children with tool-heavy inherited histories. */
+
+import { performance } from 'node:perf_hooks'
+import { Context } from '@deepseek-ai/cordis'
+import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl'
+import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
+import SessionQueryEngine from '@deepseek-ai/dsh-session-query'
+import SubagentRuntime, { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
+import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
+import { PARENT_ID, syntheticHistory, TIME_ZERO, WORKLOAD } from './workload.ts'
+
+/** Two complete catalog reads in one fresh Host, with every child observation released. */
+export interface CatalogReport {
+  readonly totalMs: number
+  readonly firstMs: number
+  readonly repeatMs: number
+  readonly cpuUserMs: number
+  readonly cpuSystemMs: number
+  readonly children: number
+  readonly peakRssMb: number
+}
+
+class CatalogQuery extends SessionQueryEngine {
+  override searchSessions(): Promise<never> {
+    return Promise.reject(new Error('search is outside the child-catalog benchmark'))
+  }
+  override searchEvents(): Promise<never> {
+    return Promise.reject(new Error('search is outside the child-catalog benchmark'))
+  }
+}
+
+async function seed(ctx: Context): Promise<void> {
+  const inherited = syntheticHistory(WORKLOAD.childHistoryTurns)
+  for (let child = 0; child < WORKLOAD.children; child++) {
+    const id = SessionId('bench-child-' + String(child))
+    const events: SessionEvent[] = [
+      ...inherited,
+      { type: 'session/end-seed', seq: SessionSeq(inherited.length), time: TIME_ZERO + inherited.length, data: { inherited: true } },
+      { type: 'subagent/descriptor', seq: SessionSeq(inherited.length + 1), time: TIME_ZERO + inherited.length + 1, data: {
+        version: SUBAGENT_DESCRIPTOR_VERSION, mode: 'continuable', provider: 'fork', label: 'Synthetic child ' + String(child),
+      } },
+    ]
+    const handle = await ctx.sessionPersistence.create({
+      version: SESSION_FORMAT_VERSION, id, createdAt: TIME_ZERO + child, cwd: '/bench',
+      parentSession: PARENT_ID, isSeeded: true, origin: 'subagent', delegationDepth: 1,
+    }, { inheritedEventCount: SessionLogOffset(inherited.length) })
+    try {
+      await handle.append(events)
+      await handle.flush()
+    } finally { await handle.close() }
+  }
+}
+
+async function run(root: string, mode: string): Promise<CatalogReport | { seeded: true }> {
+  const ctx = new Context()
+  try {
+    await ctx.plugin(SessionStore)
+    await ctx.plugin(SessionProjectionRegistry)
+    await ctx.plugin(JsonlSessionPersistence, { root, compression: 'zstd' })
+    await ctx.plugin(CatalogQuery)
+    await ctx.plugin(SubagentRuntime)
+    if (mode === 'seed') {
+      await seed(ctx)
+      return { seeded: true }
+    }
+    const cpuStart = process.cpuUsage()
+    const start = performance.now()
+    const first = await ctx.subagents.listChildren(PARENT_ID)
+    const firstDone = performance.now()
+    const repeated = await ctx.subagents.listChildren(PARENT_ID)
+    const end = performance.now()
+    const cpu = process.cpuUsage(cpuStart)
+    if (first.length !== WORKLOAD.children || repeated.length !== WORKLOAD.children
+      || [...first, ...repeated].some(row => row.kind !== 'child')) {
+      throw new Error('child-catalog benchmark did not reach the complete healthy catalog')
+    }
+    return {
+      totalMs: end - start, firstMs: firstDone - start, repeatMs: end - firstDone,
+      cpuUserMs: cpu.user / 1_000, cpuSystemMs: cpu.system / 1_000,
+      children: first.length, peakRssMb: process.resourceUsage().maxRSS / 1_024,
+    }
+  } finally { await ctx.fiber.dispose() }
+}
+
+assertBuiltBenchmarkRuntime(import.meta.url, Object.fromEntries([
+  '@deepseek-ai/dsh-subagent', '@deepseek-ai/dsh-session-query',
+  '@deepseek-ai/dsh-session-persistence-jsonl',
+].map(name => [name, import.meta.resolve(name)])))
+const [root, mode] = process.argv.slice(2)
+if (root === undefined || (mode !== 'seed' && mode !== 'catalog')) {
+  throw new Error('usage: child-catalog.worker.js <root> <seed|catalog>')
+}
+process.stdout.write(JSON.stringify(await run(root, mode)) + '\n')

+ 42 - 0
benchmarks/agent-continuation/profile-adapter.ts

@@ -0,0 +1,42 @@
+/** Compiled synthetic model for the shipped sdk-minimal profile; tools remain production plugins. */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { LlmAdapter, ToolCallId } from '@deepseek-ai/dsh-llm'
+import type { GenerateOptions, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { response, WORKLOAD } from './workload.ts'
+
+class ProfileAdapter extends LlmAdapter {
+  private requests = 0
+  override resolveModel(provider: string, model: string): Promise<LlmResolvedModelInfo> {
+    return Promise.resolve({ provider, id: model, name: model, contextWindow: 1_000_000 })
+  }
+
+  async * stream(_options: GenerateOptions): AsyncIterable<StreamChunk> {
+    const serial = this.requests++
+    if (serial % 2 === 1) {
+      yield* response(200_000 + serial, 0).chunks
+      return
+    }
+    for (let index = 0; index < WORKLOAD.toolsPerLiveTurn; index++) {
+      const id = ToolCallId('profile-call-' + String(serial) + '-' + String(index))
+      const args = JSON.stringify({ command: 'view', path: process.cwd() + '/synthetic.txt' })
+      yield { type: 'block-start', index, blockType: 'tool-call' }
+      yield { type: 'tool-call-delta', index, id, name: 'str_replace_editor', argumentsDelta: args }
+      yield { type: 'block-end', index, block: { type: 'tool-call', id, name: 'str_replace_editor', arguments: args } }
+    }
+    yield { type: 'finish', reason: { kind: 'tool-calls' } }
+  }
+}
+
+/** Loader plugin identity. */
+export const name = 'backend-profile-benchmark-model'
+/** The scripted provider requires the production LLM registry. */
+export const inject = ['llm']
+
+/**
+ * Register the synthetic provider without changing any runtime services or tools.
+ * @param ctx - profile-owned plugin context.
+ */
+export function apply(ctx: Context): void {
+  ctx.effect(() => ctx.llm.registerAdapter(['bench'], new ProfileAdapter()))
+}

+ 85 - 0
benchmarks/agent-continuation/profile-continuation.worker.ts

@@ -0,0 +1,85 @@
+/** End-to-end SDK continuation through the built dsh sdk-minimal profile and real file tools. */
+
+import { mkdir, writeFile } from 'node:fs/promises'
+import { join } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import { DeepSeekHarness } from '@deepseek-ai/dsh-sdk-client'
+import { assertBuiltBenchmarkRuntime } from '../support/built-worker.ts'
+import { PARENT_ID, resultText, WORKLOAD } from './workload.ts'
+
+/** Parent-observed wall time, including profile launch and SDK shutdown. */
+export interface ProfileReport {
+  readonly totalMs: number
+  readonly bootMs: number
+  readonly turnsMs: number
+  readonly closeMs: number
+  readonly requests: number
+  readonly toolCalls: number
+}
+
+async function run(root: string): Promise<ProfileReport> {
+  const home = join(root, 'home')
+  const cwd = join(root, 'workspace')
+  await mkdir(cwd, { recursive: true })
+  await mkdir(home, { recursive: true })
+  await writeFile(join(cwd, 'synthetic.txt'), resultText(0))
+  const patch = join(root, 'profile.patch.yml')
+  await writeFile(patch, [
+    '- id: llm-deepseek', '  disabled: true',
+    '- id: sessions', '  config:', '    root: ' + JSON.stringify(join(root, 'profile-sessions')), '    compression: zstd',
+    '- insert:', '    - id: benchmark-model', '      name: ' + JSON.stringify(join(import.meta.dirname, 'profile-adapter.js')),
+    '',
+  ].join('\n'))
+  const env: NodeJS.ProcessEnv = {
+    PATH: process.env.PATH, HOME: home, USERPROFILE: home,
+    DSH_AGENTS_HOME: join(home, 'agents'),
+  }
+  const harness = new DeepSeekHarness({
+    dshBin: join(import.meta.dirname, '..', '..', '..', 'apps', 'cli', 'lib', 'bin.js'),
+    profile: 'sdk-minimal', dshHome: home, processCwd: cwd, cwd,
+    provider: 'bench', model: 'bench', patches: [patch], env,
+    initializeTimeoutMs: 15_000, requestTimeoutMs: 15_000,
+  })
+  let closing: Promise<void> | undefined
+  const close = (): Promise<void> => closing ??= harness.close()
+  let expired = false
+  const deadline = setTimeout(() => {
+    expired = true
+    // The awaited finally close below reports shutdown failures; this only requests cancellation.
+    void close().catch(() => undefined)
+  }, 40_000)
+  let requests = 0
+  let toolCalls = 0
+  const start = performance.now()
+  try {
+    await harness.start()
+    const booted = performance.now()
+    for (let turn = 0; turn < WORKLOAD.profileTurns; turn++) {
+      const result = await harness.run('Read the synthetic file ' + String(turn), { sessionId: PARENT_ID })
+      requests += result.events.filter(event => event.type === 'assistant/message').length
+      for (const event of result.events) {
+        if (event.type !== 'tool/result') continue
+        const result = event.data.message.content[0]
+        if (result.isError || !result.content.some(block => block.type === 'text' && block.text.includes('export const synthetic = 42;'))) {
+          throw new Error('profile benchmark did not read the synthetic file')
+        }
+        toolCalls++
+      }
+    }
+    const turnsDone = performance.now()
+    await close()
+    const end = performance.now()
+    if (expired || requests !== WORKLOAD.profileTurns * 2 || toolCalls !== WORKLOAD.profileTurns * WORKLOAD.toolsPerLiveTurn) {
+      throw new Error('profile benchmark did not finish every model request and real tool call')
+    }
+    return { totalMs: end - start, bootMs: booted - start, turnsMs: turnsDone - booted, closeMs: end - turnsDone, requests, toolCalls }
+  } finally {
+    clearTimeout(deadline)
+    await close()
+  }
+}
+
+assertBuiltBenchmarkRuntime(import.meta.url, { '@deepseek-ai/dsh-sdk-client': import.meta.resolve('@deepseek-ai/dsh-sdk-client') })
+const [root] = process.argv.slice(2)
+if (root === undefined) throw new Error('usage: profile-continuation.worker.js <root>')
+process.stdout.write(JSON.stringify(await run(root)) + '\n')

+ 110 - 0
benchmarks/agent-continuation/workload.ts

@@ -0,0 +1,110 @@
+/** Reviewed synthetic tool history shared by continuation and child-catalog measurements. */
+
+import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
+import { MessageId, ToolCallId } from '@deepseek-ai/dsh-llm'
+import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
+import { Session, SessionId } from '@deepseek-ai/dsh-session'
+import type { SessionEvent } from '@deepseek-ai/dsh-session'
+
+/** Workload dimensions, independent of environment and recorded user material. */
+export const WORKLOAD = {
+  historyTurns: 800,
+  toolsPerHistoricalTurn: 4,
+  toolResultChars: 2_048,
+  requestTurns: 40,
+  continuationTurns: 20,
+  profileTurns: 100,
+  toolsPerLiveTurn: 8,
+  children: 16,
+  childHistoryTurns: 80,
+} as const
+
+/** Fixed clock used only to author persisted synthetic input. */
+export const TIME_ZERO = 1_700_000_000_000
+/** Durable parent identity of the measured continuation. */
+export const PARENT_ID = SessionId('bench-parent')
+
+/**
+ * Construct a deterministic model reply without retaining past requests.
+ * @param serial - unique response ordinal.
+ * @param tools - number of synthetic tool calls, or zero for a final text reply.
+ * @returns streamed chunks and their known final blocks.
+ */
+export function response(serial: number, tools: number): { chunks: StreamChunk[]; content: ContentBlock[] } {
+  const content: ContentBlock[] = [
+    { type: 'reasoning', text: 'Inspect the synthetic result. '.repeat(8) },
+    { type: 'text', text: 'Synthetic response. '.repeat(8) },
+    ...Array.from({ length: tools }, (_, index): ContentBlock => ({
+      type: 'tool-call', id: ToolCallId('call-' + String(serial) + '-' + String(index)), name: 'bench_tool',
+      arguments: JSON.stringify({ ordinal: serial * 100 + index }),
+    })),
+  ]
+  const chunks: StreamChunk[] = []
+  content.forEach((block, index) => {
+    chunks.push({ type: 'block-start', index, blockType: block.type })
+    if (block.type === 'text' || block.type === 'reasoning') {
+      for (let offset = 0; offset < block.text.length; offset += 16) {
+        chunks.push({ type: block.type === 'text' ? 'text-delta' : 'reasoning-delta', index, text: block.text.slice(offset, offset + 16) })
+      }
+    } else if (block.type === 'tool-call') {
+      for (let offset = 0; offset < block.arguments.length; offset += 8) {
+        chunks.push({ type: 'tool-call-delta', index, id: block.id, name: block.name, argumentsDelta: block.arguments.slice(offset, offset + 8) })
+      }
+    }
+    chunks.push({ type: 'block-end', index, block })
+  })
+  chunks.push({ type: 'usage', usage: { inputTokens: 10_000, outputTokens: 100 } })
+  chunks.push({ type: 'finish', reason: { kind: tools === 0 ? 'stop' : 'tool-calls' } })
+  return { chunks, content }
+}
+
+/**
+ * Author completed two-step turns through production Session append and stream compaction.
+ * @param turns - completed historical turns.
+ * @returns detached current-generation events with fixed ids, timestamps and payloads.
+ */
+export function syntheticHistory(turns: number): SessionEvent[] {
+  const session = Session.create(PARENT_ID)
+  for (let turn = 1; turn <= turns; turn++) {
+    session.append('turn/start', { turn })
+    session.append('step/start', { turn, step: 1 })
+    session.append('user/message', {
+      id: MessageId('prompt-' + String(turn)), role: 'user',
+      content: [{ type: 'text', text: 'Inspect synthetic module ' + String(turn) }], source: { kind: 'user' },
+    }, { surfaceOp: 'append' })
+    for (const step of [1, 2]) {
+      if (step === 2) session.append('step/start', { turn, step })
+      const reply = response(turn * 2 + step, step === 1 ? WORKLOAD.toolsPerHistoricalTurn : 0)
+      const stream = new AssistantStreamAccumulator()
+      reply.chunks.forEach((chunk, index) => { stream.push({ time: TIME_ZERO + turn * 1_000 + step * 100 + index, chunk }) })
+      session.append('assistant/message', {
+        turn, step,
+        message: { id: MessageId('reply-' + String(turn) + '-' + String(step)), role: 'assistant', content: reply.content, source: { kind: 'model', provider: 'bench', model: 'bench' } },
+        stream: [...stream.snapshot()],
+      }, { surfaceOp: 'append' })
+      for (const block of reply.content) {
+        if (block.type !== 'tool-call') continue
+        const call = session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments })
+        session.append('tool/result', {
+          turn, step,
+          message: {
+            id: MessageId('result-' + block.id), role: 'user', source: { kind: 'tool', callId: block.id },
+            content: [{ type: 'tool-result', toolCallId: block.id, content: [{ type: 'text', text: resultText(turn) }], isError: false }],
+          },
+        }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
+      }
+      session.append('step/end', { turn, step })
+    }
+    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  }
+  return session.snapshotEvents().map(event => ({ ...event, time: TIME_ZERO + event.seq }))
+}
+
+/**
+ * Build a bounded synthetic file-read result with a varying prefix.
+ * @param ordinal - deterministic result identifier.
+ * @returns exactly the reviewed number of UTF-16 characters.
+ */
+export function resultText(ordinal: number): string {
+  return ('module ' + String(ordinal) + '\n' + 'export const synthetic = 42;\n'.repeat(100)).slice(0, WORKLOAD.toolResultChars)
+}

+ 6 - 0
benchmarks/long-session-browser/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write benchmarks/long-session-browser/README.md
+README.md: 6b6777687947bee42337568ad3748e37a563be87
+README.zh.md: 139bd237d45275ea165a3e90f055f8ce507c5c53

+ 17 - 0
benchmarks/long-session-browser/README.md

@@ -0,0 +1,17 @@
+# Long-session browser benchmark
+
+English | [中文](README.zh.md)
+
+This reference describes the required Chromium workflow in [long-session.bench.ts](long-session.bench.ts). It opens a synthetic 240-turn Session, loads every older page, visits Trajectory, returns to Chat, and submits a paced reply while typing another draft. The shipped Web scaffold owns the isolated home, persistence, replay adapter, and loopback listener; Chromium loads the built Web artifacts, not a replacement development server.
+
+## Run
+
+`pnpm run test:bench` builds libraries, workers, and Web artifacts before running the serial benchmark inventory. With artifacts already built, select this directory through `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/long-session-browser`. Install Chromium through the benchmark workspace before the first run.
+
+## Measurements
+
+Three fresh browser processes and scaffold worlds produce raw samples and median verdicts. Open and paging end after the expected transcript state and two animation frames; this includes a rendering opportunity, not a hardware presentation timestamp. Paging reports every page and gates the median of each sample’s slowest page. Stream reports first visible reply, trusted draft typing, complete reply wall time, and Chromium main-thread task duration. Send lookup is scoped to the composer seat; reply-marker lookups and the input-event text witness read only the latest Assistant step, avoiding repeated whole-history text and accessibility scans. The input witness is installed before Send, and draft typing starts as soon as the first marker is visible, without an extra pre-input animation-frame wait. The actual first input event must observe an unfinished reply; completion waits for the new rendered turn-tail after Host settlement. After measurement, a trusted keystroke after DONE must fail the same overlap assertion. Open, the slowest older page, and first Trajectory use standard-hosted expectations of 900/700/500 ms. Shared 1.25× headroom gives limits of 1125/875/625 ms respectively; stream endpoint overhead budgets are unchanged. Heap after forced GC and DOM counts are diagnostics, not leak budgets.
+
+The fixture contains mixed-language prompts, prose, reasoning, 20 code fences, and 40 synthetic tool results. Every historical Assistant includes a compact stream built by the production accumulator from matching reasoning, text, tool arguments, usage, and finish chunks. No model, tool, external network, recorded Session, or private Harness home supplies its content. Streaming uses 120 text deltas at 16 ms replay pacing through the real composer, agent loop, transport, and persistence.
+
+The [decision record](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.md) owns calibration, exclusions, and alternatives. The larger [manual diagnostic](../../apps/web/tests/complex-history.perf.ts) remains separate.

+ 17 - 0
benchmarks/long-session-browser/README.zh.md

@@ -0,0 +1,17 @@
+# 长 Session 浏览器基准
+
+[English](README.md) | 中文
+
+本文说明 [long-session.bench.ts](long-session.bench.ts) 中必需的 Chromium 工作流。它打开一个合成的 240 轮 Session,加载所有更早的分页,访问 Trajectory,返回 Chat,并在流式回复期间输入下一条草稿。随产品维护的 Web scaffold 拥有隔离的主目录、持久化、重放适配器和回环监听器;Chromium 加载构建后的 Web 产物,而非替代开发服务器。
+
+## 运行
+
+`pnpm run test:bench` 先构建 library、worker 和 Web 产物,再串行运行基准清单。产物已构建时,通过 `pnpm exec vitest run --config vitest.bench.config.ts benchmarks/long-session-browser` 选择此目录。首次运行前,通过 benchmark workspace 安装 Chromium。
+
+## 测量
+
+三个全新浏览器进程与 scaffold 环境产生原始样本及中位数判定。打开和分页在预期对话状态出现且经过两次动画帧后结束;这包含一次渲染机会,而非硬件显示时间戳。分页报告每一页,并对各样本最慢分页时间的中位数执行预算检查。流式报告首段可见回复、真实草稿键入、完整回复壁钟时间和 Chromium 主线程任务时间。发送控件查找限定在 composer seat;回复标记查找与输入事件文本证据仅读取最新 Assistant step,避免重复扫描全部历史文本与无障碍属性。输入观察器在发送前安装,首个标记可见后立即开始草稿键入,不额外等待输入前动画帧。实际首个输入事件必须观察到未完成的回复;完成测量在 Host 结算后等待新 turn-tail 渲染。测量后,在 DONE 之后发送的真实按键必须无法通过同一个重叠断言。打开、最慢更早分页和首次 Trajectory 使用标准托管预期 900/700/500 ms。共享的 1.25× 余量分别产生 1125/875/625 ms 上限;流式终点的额外开销预算不变。强制 GC 后的 heap 与 DOM 数量仅供诊断,不作为泄漏预算。
+
+fixture(测试前置数据)包含混合语言提示、正文、推理、20 个代码块和 40 个合成工具结果。每条历史 Assistant 都含紧凑 stream,由生产 accumulator 从匹配的推理、文本、工具参数、usage 和 finish chunk 构建。其内容不来自模型、工具、外部网络、录制 Session 或私有 Harness 主目录。流式回复以 16 ms 重放间隔发送 120 个文本 delta,经过真实输入框、agent loop(智能体循环)、传输与持久化。
+
+[决策记录](../../.agents/notes/implemented/testing/2026-09-06-frontend-performance-budgets.zh.md)拥有校准、排除项与替代方案。更大规模的[手动诊断](../../apps/web/tests/complex-history.perf.ts)保持独立。

+ 209 - 0
benchmarks/long-session-browser/long-session.bench.ts

@@ -0,0 +1,209 @@
+/** Required browser budgets for opening, paging and continuing synthetic long history. */
+import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { performance } from 'node:perf_hooks'
+import { chromium, type Page, type CDPSession, type Locator } from 'playwright'
+import { expect, it } from 'vitest'
+import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from '../../apps/web/tests/scaffold.ts'
+import { newEnglishPage } from '../../apps/web/tests/support.ts'
+import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
+import { HISTORY_TURNS, SESSION_ID, FIRST, DONE, DELTAS, PACE_MS, syntheticHistory, syntheticReply } from './synthetic-history.ts'
+
+const SAMPLES = 3
+const TAIL = '[data-chat-flow-key^="9:turn-tail"]'
+const REFERENCE = { open: 200, page: 260, trajectory: 160, first: 1100, streamTask: 1800, input: 500, streamWall: 1000 }
+const EXPECTED_OPEN_CI_MS = 900
+const EXPECTED_PAGE_CI_MS = 700
+const EXPECTED_TRAJECTORY_CI_MS = 500
+const OPEN_BUDGET_MS = Math.ceil(EXPECTED_OPEN_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+const PAGE_BUDGET_MS = Math.ceil(EXPECTED_PAGE_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+const TRAJECTORY_BUDGET_MS = Math.ceil(EXPECTED_TRAJECTORY_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
+const REPLAY_DURATION_MS = (DELTAS + 4) * PACE_MS
+
+async function painted(page: Page): Promise<void> {
+  // Two rAF callbacks include a rendering opportunity, not a GPU presentation timestamp.
+  await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
+}
+
+async function measure(page: Page, action: () => Promise<void>): Promise<number> {
+  const start = performance.now()
+  await action()
+  await painted(page)
+  return performance.now() - start
+}
+
+async function taskMs(cdp: CDPSession): Promise<number> {
+  const result = await cdp.send('Performance.getMetrics')
+  const metric = result.metrics.find(metric => metric.name === 'TaskDuration')
+  if (metric === undefined) throw new Error('Chromium TaskDuration missing')
+  return metric.value * 1000
+}
+
+function median(values: number[]): number {
+  return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]!
+}
+
+function expectEndpointWithinBudget(value: number, budget: number): void {
+  expect(value).toBeLessThanOrEqual(budget)
+}
+
+function expectInputOverlap(value: boolean): void {
+  expect(value).toBe(true)
+}
+
+async function watchInputOverlap(composer: Locator): Promise<void> {
+  await composer.evaluate((element, markers) => {
+    element.removeAttribute('data-benchmark-input-witness')
+    element.removeAttribute('data-benchmark-input-overlap')
+    element.addEventListener('input', (event) => {
+      const transcript = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)?.textContent ?? ''
+      element.setAttribute('data-benchmark-input-overlap', String(event.isTrusted && transcript.includes(markers.first) && !transcript.includes(markers.done)))
+      element.setAttribute('data-benchmark-input-witness', JSON.stringify({ trusted: event.isTrusted, first: transcript.includes(markers.first), done: transcript.includes(markers.done) }))
+    }, { once: true })
+  }, { first: FIRST, done: DONE })
+}
+
+it('accepts recorded hosted open samples and rejects slower endpoints', () => {
+  for (const value of [681.276514, 541.051233]) {
+    expect(() => expectEndpointWithinBudget(value, ciTimeBudget(REFERENCE.open))).toThrow()
+    expectEndpointWithinBudget(value, OPEN_BUDGET_MS)
+  }
+  const repeatedMedian = median([875.306861, 1083.683529, 814.700998])
+  expect(repeatedMedian).toBe(875.306861)
+  expect(() => expectEndpointWithinBudget(repeatedMedian, ciTimeBudget(REFERENCE.open))).toThrow()
+  expect(() => expectEndpointWithinBudget(repeatedMedian, 875)).toThrow()
+  expectEndpointWithinBudget(repeatedMedian, OPEN_BUDGET_MS)
+  expect(OPEN_BUDGET_MS).toBe(1125)
+  expect(() => expectEndpointWithinBudget(OPEN_BUDGET_MS + 1, OPEN_BUDGET_MS)).toThrow()
+  expect(() => expectEndpointWithinBudget(2000, OPEN_BUDGET_MS)).toThrow()
+})
+
+it('accepts recorded hosted paging and Trajectory medians and rejects slower endpoints', () => {
+  const endpoints = [
+    { samples: [843.941625, 672.834329, 684.461818], reference: REFERENCE.page, budget: PAGE_BUDGET_MS, expectedBudget: 875 },
+    { samples: [605.788061, 367.754027, 485.931656], reference: REFERENCE.trajectory, budget: TRAJECTORY_BUDGET_MS, expectedBudget: 625 },
+  ]
+  for (const { samples, reference, budget, expectedBudget } of endpoints) {
+    const value = median(samples)
+    expect(() => expectEndpointWithinBudget(value, ciTimeBudget(reference))).toThrow()
+    expectEndpointWithinBudget(value, budget)
+    expect(budget).toBe(expectedBudget)
+    expect(() => expectEndpointWithinBudget(budget + 1, budget)).toThrow()
+  }
+})
+
+it('opens, pages, navigates and streams into a 240-turn browser history', async () => {
+  if (webSnapshotMode() !== 'replay') throw new Error('browser benchmarks require keyless replay mode')
+  const samples: { open: number; page: number; trajectory: number; first: number; streamTask: number; streamWall: number; input: number; inputOverlapped: boolean; heapMb: number; nodes: number }[] = []
+  for (let sample = 0; sample < SAMPLES; sample++) {
+    const failures: unknown[] = []
+    const root = await mkdtemp(join(tmpdir(), 'dsh-browser-benchmark-'))
+    try {
+      const replayOverride = join(root, 'reply.json')
+      await writeFile(replayOverride, JSON.stringify([{ kind: 'chunks', chunks: syntheticReply() }]))
+      const scaffold = await launchWebScaffold({ replayFixture: join(root, 'override-only.jsonl'), replayOverride, paceMs: PACE_MS, replayContextWindow: 10000000 })
+      try {
+        const history = syntheticHistory()
+        await seedSession(scaffold, history, SESSION_ID)
+        console.log(JSON.stringify({ benchmark: 'long-session-browser/fixture', bytes: Buffer.byteLength(history) }))
+        const browser = await chromium.launch({ headless: true })
+        try {
+          const page = await newEnglishPage(browser)
+          const consoleWatch = watchConsole(page)
+          page.setDefaultTimeout(30000)
+          await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+          expect(new URL(page.url()).origin).toBe(scaffold.baseUrl)
+          console.log(JSON.stringify({ benchmark: 'long-session-browser/server', url: scaffold.baseUrl, browser: browser.version(), sample }))
+          await page.waitForSelector('[class*="frame"]')
+          await page.getByRole('treeitem').first().click()
+          const result = page.getByRole('treeitem').nth(1)
+          await result.waitFor()
+          const open = await measure(page, async () => {
+            await result.click()
+            await page.locator(TAIL).last().waitFor()
+            await page.locator('[data-composer-input][contenteditable="true"]').last().waitFor()
+          })
+          const pages: number[] = []
+          const initialTurns = await page.locator(TAIL).count()
+          expect(initialTurns).toBeGreaterThan(0)
+          expect(initialTurns).toBeLessThan(HISTORY_TURNS)
+          let count = initialTurns
+          while (count < HISTORY_TURNS) {
+            pages.push(await measure(page, async () => {
+              await page.getByRole('button', { name: 'Load earlier', exact: true }).click()
+              await page.waitForFunction(({ selector, previous }) => document.querySelectorAll(selector).length > previous, { selector: TAIL, previous: count })
+            }))
+            count = await page.locator(TAIL).count()
+          }
+          const trajectory = await measure(page, async () => {
+            await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
+            await page.getByRole('searchbox', { name: 'Search trajectory', exact: true }).waitFor()
+            await page.getByRole('row').last().waitFor()
+          })
+          await page.getByRole('tab', { name: 'Chat', exact: true }).click()
+          await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS })
+          const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
+          await composer.fill('Continue the synthetic review and summarize the validation. '.repeat(30))
+          const cdp = await page.context().newCDPSession(page)
+          await cdp.send('Performance.enable')
+          const beforeTask = await taskMs(cdp)
+          const settled = scaffold.whenTurnSettled(60000).then(
+            () => ({ ok: true as const }),
+            (error: unknown) => ({ ok: false as const, error }),
+          )
+          await watchInputOverlap(composer)
+          const started = performance.now()
+          await page.locator('[data-composer-seat]').getByRole('button', { name: 'Send message', exact: true }).click()
+          const reply = page.locator('[data-chat-flow-kind="assistant-step"]').last()
+          await reply.getByText(FIRST, { exact: false }).last().waitFor()
+          const first = performance.now() - started
+          // Observe the actual trusted input event, not state before asynchronous click/typing.
+          const input = await measure(page, async () => {
+            await composer.click()
+            await page.keyboard.type('next synthetic question')
+            await expect.poll(() => composer.textContent()).toBe('next synthetic question')
+          })
+          const inputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true'
+          console.log(JSON.stringify({ benchmark: 'long-session-browser/input', sample, first, input, witness: await composer.getAttribute('data-benchmark-input-witness') }))
+          expectInputOverlap(inputOverlapped)
+          await reply.getByText(DONE, { exact: false }).last().waitFor()
+          const settlement = await settled
+          if (!settlement.ok) throw settlement.error
+          await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS + 1 })
+          await painted(page)
+          const streamWall = performance.now() - started
+          const streamTask = await taskMs(cdp) - beforeTask
+          await cdp.send('HeapProfiler.collectGarbage')
+          const metrics = (await cdp.send('Performance.getMetrics')).metrics
+          const heap = metrics.find(metric => metric.name === 'JSHeapUsedSize')
+          if (heap === undefined) throw new Error('Chromium heap metric missing')
+          samples.push({ open, page: Math.max(...pages), trajectory, first, streamTask, streamWall, input, inputOverlapped, heapMb: heap.value / 1048576, nodes: await page.locator('*').count() })
+          console.log(JSON.stringify({ benchmark: 'long-session-browser/sample', sample, initialTurns, pages, ...samples.at(-1) }))
+          await watchInputOverlap(composer)
+          await composer.click()
+          await page.keyboard.type('!')
+          const lateInputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true'
+          expect(await composer.getAttribute('data-benchmark-input-witness')).toBe(JSON.stringify({ trusted: true, first: true, done: true }))
+          expect(() => expectInputOverlap(lateInputOverlapped)).toThrow()
+          expect(consoleWatch.pageErrors).toEqual([])
+          expect(consoleWatch.warnings).toEqual([])
+        } catch (error) { failures.push(error) } finally {
+          await browser.close().catch((error: unknown) => failures.push(error))
+        }
+      } catch (error) { failures.push(error) } finally {
+        await scaffold.close().catch((error: unknown) => failures.push(error))
+      }
+    } catch (error) { failures.push(error) } finally {
+      await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
+    }
+    if (failures.length > 0) throw new AggregateError(failures, 'browser benchmark failed')
+  }
+  const aggregate = Object.fromEntries(Object.keys(REFERENCE).map(key => [key, median(samples.map(sample => sample[key as keyof typeof REFERENCE]))]))
+  const budgets: Record<string, number> = {
+    ...Object.fromEntries(Object.entries(REFERENCE).map(([key, value]) => [key, ciTimeBudget(value) + (key === 'streamWall' ? REPLAY_DURATION_MS : 0)])),
+    open: OPEN_BUDGET_MS, page: PAGE_BUDGET_MS, trajectory: TRAJECTORY_BUDGET_MS,
+  }
+  console.log(JSON.stringify({ benchmark: 'long-session-browser/median', turns: HISTORY_TURNS, deltas: DELTAS, paceMs: PACE_MS, samples, aggregate, referenceMs: REFERENCE, expectedOpenCiMs: EXPECTED_OPEN_CI_MS, expectedPageCiMs: EXPECTED_PAGE_CI_MS, expectedTrajectoryCiMs: EXPECTED_TRAJECTORY_CI_MS, budgets }))
+  for (const [key, value] of Object.entries(aggregate)) expectEndpointWithinBudget(value, budgets[key]!)
+})

+ 92 - 0
benchmarks/long-session-browser/synthetic-history.ts

@@ -0,0 +1,92 @@
+/** Synthetic current-generation history and paced reply for browser measurements. */
+import { createAssistantMessage, createUserMessage, createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
+import type { StreamChunk } from '@deepseek-ai/dsh-llm'
+import { AssistantStreamAccumulator } from '@deepseek-ai/dsh-llm/assistant-stream'
+import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
+import type {} from '@deepseek-ai/dsh-session-title'
+
+/** Closed turns in the browser history workload. */
+export const HISTORY_TURNS = 240
+/** Identity private to each isolated scaffold. */
+export const SESSION_ID = 'benchmark-browser-history'
+const TITLE = 'SYNTHETIC_BROWSER_HISTORY'
+/** First streamed text marker. */
+export const FIRST = 'SYNTHETIC_REPLY_FIRST'
+/** Last streamed text marker. */
+export const DONE = 'SYNTHETIC_REPLY_DONE'
+/** Paced text chunks per continuation. */
+export const DELTAS = 120
+/** Replay delay per stream chunk, in milliseconds. */
+export const PACE_MS = 16
+
+/** Create mixed prose, code, reasoning and tool history without reading user data.
+ * @returns Current Session JSONL accepted by the shared Web seeder.
+ */
+export function syntheticHistory(): string {
+  const session = Session.create(SessionId(SESSION_ID))
+  for (let turn = 1; turn <= HISTORY_TURNS; turn++) {
+    session.append('turn/start', { turn })
+    const user = session.append('user/message', createUserMessage({
+      content: [{ type: 'text', text: 'Review synthetic change ' + String(turn) + ': 检查增量渲染。 '.repeat(30) }],
+      source: { kind: 'user' },
+    }), { surfaceOp: 'append' })
+    if (turn === 1) session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } })
+    session.append('step/start', { turn, step: 1 })
+    const callId = ToolCallId('synthetic-tool-' + String(turn))
+    const tool = turn % 6 === 0
+    const code = turn % 12 === 0
+      ? '\n\n```ts\n' + Array.from({ length: 60 }, (_, i) => 'const value' + String(i) + ' = ' + String(i)).join('\n') + '\n```'
+      : ''
+    const reasoning = 'Compare the synthetic module and test. '.repeat(40)
+    const text = 'Synthetic answer ' + String(turn) + '. ' + 'Preserve ordering and validate the output. '.repeat(30) + code
+    const args = '{"path":"src/example.ts"}'
+    const stream = new AssistantStreamAccumulator()
+    let time = 1700000000000 + turn * 10000
+    const push = (chunk: StreamChunk): void => { stream.push({ time: time++, chunk }) }
+    for (const [index, block] of [{ type: 'reasoning' as const, text: reasoning }, { type: 'text' as const, text }].entries()) {
+      push({ type: 'block-start', index, blockType: block.type })
+      for (let offset = 0; offset < block.text.length; offset += 12) {
+        push({ type: block.type === 'reasoning' ? 'reasoning-delta' : 'text-delta', index, text: block.text.slice(offset, offset + 12) })
+      }
+      push({ type: 'block-end', index, block })
+    }
+    if (tool) {
+      push({ type: 'block-start', index: 2, blockType: 'tool-call' })
+      for (let offset = 0; offset < args.length; offset += 8) {
+        push({ type: 'tool-call-delta', index: 2, id: callId, ...offset === 0 ? { name: 'synthetic_tool' } : {}, argumentsDelta: args.slice(offset, offset + 8) })
+      }
+      push({ type: 'block-end', index: 2, block: { type: 'tool-call', id: callId, name: 'synthetic_tool', arguments: args } })
+    }
+    push({ type: 'usage', usage: { inputTokens: 4000, outputTokens: 800 } })
+    push({ type: 'finish', reason: { kind: tool ? 'tool-calls' : 'stop' } })
+    session.append('assistant/message', {
+      turn, step: 1, stream: [...stream.snapshot()],
+      message: createAssistantMessage({
+        source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
+        content: [
+          { type: 'reasoning', text: reasoning },
+          { type: 'text', text },
+          ...tool ? [{ type: 'tool-call' as const, id: callId, name: 'synthetic_tool', arguments: args }] : [],
+        ],
+      }),
+      usage: { inputTokens: 4000, outputTokens: 800 },
+    }, { surfaceOp: 'append' })
+    if (tool) {
+      const call = session.append('tool/call', { turn, step: 1, callId, name: 'synthetic_tool', arguments: args })
+      session.append('tool/result', { turn, step: 1, message: createToolResultMessage({
+        callId, isError: false, content: [{ type: 'text', text: 'Synthetic tool output line.\n'.repeat(160) }],
+      }) }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] })
+    }
+    session.append('step/end', { turn, step: 1 })
+    session.append('turn/end', { turn, reason: { kind: 'completed' } })
+  }
+  return [JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 1700000000000, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0 }), ...session.snapshotEvents().map(event => JSON.stringify(event)), ''].join('\n')
+}
+
+/** Create one paced response; replay owns delays outside the browser.
+ * @returns Stream chunks ending in a visible completion marker.
+ */
+export function syntheticReply(): StreamChunk[] {
+  const deltas = Array.from({ length: DELTAS }, (_, i) => i === 0 ? FIRST + ' ' : i === DELTAS - 1 ? DONE : 'Synthetic response ' + String(i) + '. ')
+  return [{ type: 'block-start', index: 0, blockType: 'text' }, ...deltas.map(text => ({ type: 'text-delta' as const, index: 0, text })), { type: 'block-end', index: 0, block: { type: 'text', text: deltas.join('') } }, { type: 'usage', usage: { inputTokens: 4000, outputTokens: 800 } }, { type: 'finish', reason: { kind: 'stop' } }]
+}

+ 5 - 0
benchmarks/package.json

@@ -5,6 +5,8 @@
   "private": true,
   "type": "module",
   "devDependencies": {
+    "playwright": "^1.49.0",
+    "@deepseek-ai/dsh-llm-replay": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-agent": "workspace:^",
     "@deepseek-ai/dsh-agent-loop": "workspace:^",
@@ -15,6 +17,7 @@
     "@deepseek-ai/dsh-client-ui-chat": "workspace:^",
     "@deepseek-ai/dsh-deque": "workspace:^",
     "@deepseek-ai/dsh-llm": "workspace:^",
+    "@deepseek-ai/dsh-sdk-client": "workspace:^",
     "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/dsh-session-persistence": "workspace:^",
     "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
@@ -23,6 +26,8 @@
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-session-title": "workspace:^",
     "@deepseek-ai/dsh-session-turn-outline": "workspace:^",
+    "@deepseek-ai/dsh-subagent": "workspace:^",
+    "@deepseek-ai/dsh-tools": "workspace:^",
     "@deepseek-ai/dsh-token-meter": "workspace:^",
     "@deepseek-ai/dsh-typert-protocol": "workspace:^"
   },

+ 19 - 0
benchmarks/tsdown.config.ts

@@ -14,6 +14,25 @@ const shared = {
 
 /** Compile measured benchmark workers while keeping workspace packages on their built `lib` entries. */
 export default defineConfig([
+  {
+    ...shared,
+    entry: { 'reconnect.worker': 'active-stream-reconnect/reconnect.worker.client.ts' },
+    outDir: '.dsh-build/active-stream-reconnect',
+    clean: true,
+    tsconfig: 'tsconfig.client.json',
+  },
+  {
+    ...shared,
+    entry: {
+      'agent-continuation.worker': 'agent-continuation/agent-continuation.worker.ts',
+      'child-catalog.worker': 'agent-continuation/child-catalog.worker.ts',
+      'profile-continuation.worker': 'agent-continuation/profile-continuation.worker.ts',
+      'profile-adapter': 'agent-continuation/profile-adapter.ts',
+    },
+    outDir: '.dsh-build/agent-continuation',
+    clean: true,
+    tsconfig: 'tsconfig.host.json',
+  },
   {
     ...shared,
     entry: { 'session-open.worker': 'session-open/session-open.worker.ts' },

+ 2 - 2
docs/architecture.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/architecture.md
-architecture.md: bbd6a7e09b6af2fe5e90acab33ad220d3f1b62d1
-architecture.zh.md: b05670f8f715c5c3dd5c8cdd76ec81e9d426ace6
+architecture.md: fcb9c1e59b60dc059ab66b64ac26acd3c9157c96
+architecture.zh.md: d7a0a3833ebf9397837967065249d7fe1650d707

+ 2 - 0
docs/architecture.md

@@ -100,6 +100,8 @@ Input reaches the driver through one inbox. Some messages wake it immediately; i
 
 `agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered.
 
+The loop sends immutable requests while keeping cancellation live. It reuses message-freeze provenance only for identities it has fully frozen; [agent-loop](../packages/core/agent-loop/README.md) owns the request construction rules.
+
 Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle).
 
 ## Session log

+ 2 - 0
docs/architecture.zh.md

@@ -104,6 +104,8 @@ turn/end
 
 `agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。
 
+循环发送不可变请求,同时保留实时取消能力。只有已由该循环完整冻结的消息对象身份才能复用冻结证明;[agent-loop](../packages/core/agent-loop/README.zh.md)拥有请求构造规则。
+
 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。
 
 ## 会话日志

+ 1 - 1
docs/event-producer-consumer.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
-event-producer-consumer.md: ae4119a08d4150a9d3284e6fbcfb33ba7207923a
+event-producer-consumer.md: 7cecb1f362c311cf9b7b617466c1eaa06721a05e
 event-producer-consumer.zh.md: 57af4848a79065cc4ca65d6f56f4d543ec979441

+ 4 - 4
docs/event-producer-consumer.md

@@ -54,10 +54,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` |
 | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) |
 | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
-| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:172`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
-| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:146`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:152`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
-| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
+| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:168`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), `server`, [`subagent`](../packages/subagent/subagent) |
+| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:148`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) |
+| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:159`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`subagent`](../packages/subagent/subagent) |
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`session-reference`](../packages/context/session-reference), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:199`](../packages/core/tools/src/index.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`), [`tools`](../packages/core/tools) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |

+ 2 - 2
docs/module-graph.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/module-graph.md
-module-graph.md: 5307a62360f2c01d03df4693081c02c5d6e56a39
-module-graph.zh.md: fa6e5b3cd98e3dc80aad906d01d1d1a07ef91842
+module-graph.md: 83b68cf3398e8f24d0332dd572679efe8fa75ceb
+module-graph.zh.md: b680094f342034e21a25f22f35404b2865d4318e

+ 2 - 1
docs/module-graph.md

@@ -1075,6 +1075,7 @@ flowchart TD
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_util_time
+  pkg_api_session_controller --> pkg_util_values
   pkg_api_session_controller --> pkg_util_workspace_path
   pkg_api_session_controller --> pkg_workspace
   pkg_experimental_agent_team --> pkg_agent
@@ -1401,7 +1402,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 2 - 1
docs/module-graph.zh.md

@@ -1077,6 +1077,7 @@ flowchart TD
   pkg_api_session_controller --> pkg_typert_protocol
   pkg_api_session_controller --> pkg_typert_registry
   pkg_api_session_controller --> pkg_util_time
+  pkg_api_session_controller --> pkg_util_values
   pkg_api_session_controller --> pkg_util_workspace_path
   pkg_api_session_controller --> pkg_workspace
   pkg_experimental_agent_team --> pkg_agent
@@ -1403,7 +1404,7 @@ flowchart TD
 | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
 | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
 | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
-| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
+| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
 | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
 | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
 | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |

+ 2 - 2
docs/subsystems/subagent.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write docs/subsystems/subagent.md
-subagent.md: 616300f92ffa827f14c4780a7648f60f52df56ad
-subagent.zh.md: 059dc4af988ad5ce66bed64827b72449f5a781ca
+subagent.md: cf711185d02808f70a71a46c6d6c1af4da4a7334
+subagent.zh.md: e97b4ab965d279f04ddda5e8ae66621b0198a5cd

+ 11 - 8
docs/subsystems/subagent.md

@@ -141,13 +141,15 @@ persisted Session
 | `waiting` | wake and steer the same Activation |
 | no Activation | cold-resume a new Activation, then steer it |
 
-`running` means the Agent has an active admission or turn, or waking inbox work; `waiting` means it is quiescent but still owns at least one child Activation that has not completed disposal; `settled` means quiescent with every owned child disposed, at which point the manager disposes the [`AgentHandle`](core.md#creation-and-ownership) and removes the Activation. The manager derives these internal conditions from Agent quiescence and the owned-child set rather than maintaining a second execution state machine.
+`running` means the Agent has an active driver or maintenance task; `waiting` means no Agent activity is active but its Inbox is nonempty or it owns at least one child Activation that has not completed disposal; `settled` means no Agent activity is active, the Inbox is empty, and every owned child is disposed, at which point the manager disposes the [`AgentHandle`](core.md#creation-and-ownership) and removes the Activation. The manager derives these internal conditions from `Agent.whenIdle()`, `Agent.inbox.hasPending`, the owned-child set, and an Activation generation that invalidates stale observations, rather than maintaining a second execution state machine. After the final Session flush, the child-lock decision uses the synchronous task entry of `Agent.runMaintenance()` to claim the idle phase and close admission in the same JavaScript turn. This conservative rule does not distinguish delivery modes: context parked by `Agent.inject()` can keep an idle Activation and its live ancestors resident until a waking delivery claims it, a queue mutation removes it, or manager teardown discards it.
 
-The Agent inbox is the only queue. Every Agent message uses `Agent.steer()`: an idle target starts a turn, while a running target claims it at the nearest step boundary. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/inserted`, `agent/inbox/claimed`, and `agent/inbox/discarded` events remain the message-lifecycle observations, and the continuation layer defines no subagent-specific delivery route.
+The Agent inbox is the only queue. Every Agent message uses `Agent.steer()`: an idle target starts a turn, while a running target claims it at the nearest step boundary. The browser `subagent.prompt` Remote separately carries `delivery: 'queue' | 'steer'` through the same internal admission path; Queue opens a later FIFO turn, while Steer retains the Agent loop's best-effort nearest-step behavior and the message's human source. Successful delivery returns the accepted `MessageId`; the existing `agent/inbox/inserted`, `agent/inbox/claimed`, and `agent/inbox/discarded` events remain the message-lifecycle observations, and the continuation layer defines no second queue.
 
 Authority comes from the exact live sender. Parent-to-child delivery requires the target's `SessionHeader.parentSession` to name the sender; child-to-parent delivery requires the sender's resident Activation to name the target. Siblings, ancestors beyond one edge, self-targets, stale Agent objects, and one-shot children are rejected. Each accepted message is framed as `Agent <sender-id> sent a message:` and records `AgentMessageSource`; provenance records the sender but grants no authority.
 
-For `startContinuable()` and `sendMessage()`, the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child. Human browser prompts remain a separate private Queue adapter and therefore still produce distinct FIFO turns.
+For `startContinuable()`, `sendMessage()`, and browser prompt delivery, the caller signal owns lookup, materialization, and admission only until inbox acceptance. Afterwards the manager owns the Activation independently: later caller cancellation neither cancels the accepted turn nor disposes the child. The public subagent service exposes no caller-selected Agent-message scheduling; browser human Queue and Steer remain internal adapter choices.
+
+Live queue occurrence mutation remains in the Session domain. `session.updateQueue` admits ordinary Edit, Remove, and QueueDock Steer for a live subagent-owned Agent only when its current projected identity is continuable and its descriptor sequence is in that child's own non-seed suffix. The identity projection folds descriptors last-wins so a child descriptor supersedes descriptors retained from fork lineage; the own-suffix sequence check prevents a seed-only ancestor identity from authorizing mutation. One-shot, missing, unknown, corrupt, or cold children remain rejected, and queue mutation never cold-resumes a child. The target Session id is the human authority for these mutations, including pending `nextStep` steering or injected context. Steer requires a queued `MessageId` and an Agent that reports running when the command begins; cancellation after admission uses the Agent's accepted waking `nextTurn` fallback. Edit rewrites content under the same `MessageId`, and both Edit and Steer complete their Inbox work synchronously, so settlement observes only the final state. `agent/inbox/claimed` and `agent/inbox/discarded` wake the watcher to re-read whether any pending occurrence remains; this lets direct Agent delivery resume parked work and lets removing the last parked occurrence settle an idle child. The [human inbox-control Agent Note](../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md) owns these semantics.
 
 `SubagentRuntime.interrupt(targetSessionId, authority)` is the one public stop: it authorizes synchronously, issues `Agent.cancel(cause, { keepInbox: true })` on the live target, and returns without awaiting quiescence. The Activation, its unclaimed pending inbox work, and published descendants are untouched; work already claimed into the interrupted turn is not requeued. Once the interrupted driver is idle, a waking send resumes the parked FIFO queue. An absent target — unknown, one-shot, or already settled — and a manager-less composition are accepted no-ops. For a live target, a mismatched parent address or caller outside its live ancestry rejects with `UNAUTHORIZED`; stale ancestor objects and self-targeting ancestor requests reject before target lookup.
 
@@ -162,7 +164,7 @@ type SubagentInterruptAuthority =
   | { readonly kind: 'ancestor'; readonly agent: Agent }
 ```
 
-Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child Agent is quiescent, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal.
+Every Activation owns its `AgentHandle` and an `ownedChildren: Set<SessionId>`; because one Session has at most one live Activation, the child Session id identifies the live child without another runtime-incarnation reference. Starting a child or submitting parent-originated work registers the child in a continuation-managed parent's set before the child can run, and that parent cannot settle while the set is non-empty. A top-level or other non-continuation Agent has no Activation and stays outside the waiting graph. Child release happens only after the child has no active Agent work, its Inbox is empty, every child of that child is disposed, the best-effort final session flush settles, and the child's `AgentHandle` completes disposal.
 
 Final settlement awaits `ctx.sessions.flush(session)` but ignores its participation boolean because an arbitrary listener cannot prove that a persistence backend stored the state. Rejection is logged without failing the Activation, and the manager still disposes the handle and releases ownership; the persisted child state may then be missing or stale on a later resume. Manager unload invokes an internal manager-wide drain that closes admission and disposes every live forest; `drainContinuableDescendants(parents)` closes admission only below exact live host-owned Agents and disposes their continuable descendants while unrelated forests remain live. Both await already-admitted materializations in their scope, propagate cancellation top-down, release handles child-first, and await every selected branch despite individual failures. Durable child Sessions survive that process-local teardown.
 
@@ -195,7 +197,7 @@ interface ContinuableStart {
 }
 ```
 
-When a resident Activation settles, the manager delivers one notice to the child's durable direct parent describing how that epoch ended and carrying its final assistant content. That delivery is unconditional for every child whose id a caller received, happens before the ownership release that would let the parent be judged settled, and reaches a resident parent through the same waking-admission accounting as an Agent message. A parent whose own lineage is already tearing down receives it without a wake, because waking a quiescent Agent starts a turn rather than queueing work. Its provenance is a distinct kind so a transcript never presents a runtime account as something the child wrote.
+When a resident Activation settles, the manager delivers one notice to the child's durable direct parent describing how that epoch ended and carrying its final assistant content. That delivery is unconditional for every child whose id a caller received, happens before the ownership release that would let the parent be judged settled, and reaches a resident parent through the same waking Agent delivery as an Agent message. A parent whose own lineage is already tearing down receives it without a wake, because waking an idle Agent starts a turn rather than queueing work. Its provenance is a distinct kind so a transcript never presents a runtime account as something the child wrote.
 
 ```ts type-equiv
 /**
@@ -611,11 +613,12 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<Subagen
  * Deliver one browser-authored message to a continuable child through the
  * exact live direct parent, retaining the caller-minted request identity and
  * validated browser zone on the accepted message. Success identifies the
- * message the child's FIFO inbox accepted; later execution is independent of
- * this call.
+ * message the child's inbox accepted; later execution is independent of this
+ * call. Queue delivery targets a later turn; steer delivery targets the
+ * nearest step and retains the Agent loop's best-effort fallback semantics.
  * Image parts are admitted and persisted through the attachment store
  * before delivery, and the child's model must accept image input.
- * @param request - durable address, minted identity, content, and optional browser zone.
+ * @param request - durable address, delivery, minted identity, content, and optional browser zone.
  * @param signal - carrier cancellation, owning the call until inbox acceptance.
  * @returns the accepted message's inbox identity.
  * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,

+ 11 - 8
docs/subsystems/subagent.zh.md

@@ -141,13 +141,15 @@ persisted Session
 | `waiting` | 唤醒并 steer 同一 Activation |
 | 无 Activation | 冷恢复新的 Activation,然后 steer |
 
-`running` 表示 Agent 拥有活跃的准入或轮次,或正在唤醒收件箱工作;`waiting` 表示它已完全停稳,但仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示已完全停稳且其拥有的每个子级都已 dispose,此时管理器会 dispose [`AgentHandle`](core.zh.md#creation-and-ownership) 并移除该 Activation。管理器根据 Agent 的完全停稳状态与其拥有的子级集合推导这些内部条件,而非维护第二套执行状态机。
+`running` 表示 Agent 拥有活跃的 driver 或 maintenance 任务;`waiting` 表示没有活跃的 Agent 工作,但其 Inbox 非空或仍拥有至少一个尚未完成 dispose 的子 Activation;`settled` 表示没有活跃的 Agent 工作、Inbox 为空且其拥有的每个子级都已 dispose,此时管理器会 dispose [`AgentHandle`](core.zh.md#creation-and-ownership) 并移除该 Activation。管理器根据 `Agent.whenIdle()`、`Agent.inbox.hasPending`、其拥有的子级集合,以及让过期观察失效的 Activation generation 推导这些内部条件,而非维护第二套执行状态机。最终 Session flush 之后,child-lock 决策会通过 `Agent.runMaintenance()` 的同步 task 入口占用 idle 阶段,并在同一个 JavaScript turn 内关闭准入。这条保守规则不区分投递模式:`Agent.inject()` 停放的 context 可以让空闲 Activation 及其在线祖先继续驻留,直到唤醒投递将其 claim、queue 变更将其移除,或 manager teardown 将其丢弃。
 
-Agent 收件箱是唯一队列。每条 Agent 消息都使用 `Agent.steer()`:空闲目标会启动一个轮次,运行中目标则在最近的 step 边界领取消息。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/inserted`、`agent/inbox/claimed` 与 `agent/inbox/discarded` 事件仍是消息生命周期的观测点,继续执行层不定义任何 subagent 专属的投递路由。
+Agent 收件箱是唯一队列。每条 Agent 消息都使用 `Agent.steer()`:空闲目标会启动一个轮次,运行中目标则在最近的 step 边界领取消息。浏览器 `subagent.prompt` Remote 会另行通过同一条内部准入路径携带 `delivery: 'queue' | 'steer'`;Queue 开启后续 FIFO 轮次,Steer 保留 Agent loop 的 best-effort 最近 step 行为以及消息的人类来源。投递成功会返回被接受的 `MessageId`;既有的 `agent/inbox/inserted`、`agent/inbox/claimed` 与 `agent/inbox/discarded` 事件仍是消息生命周期的观测点,继续执行层不定义第二条队列。
 
 权限来自确切在线 sender。parent 到 child 的投递要求目标的 `SessionHeader.parentSession` 指向 sender;child 到 parent 的投递要求 sender 的驻留 Activation 指向目标。sibling、相隔多于一条边的 ancestor、self-target、陈旧 Agent 对象与一次性 child 都会被拒绝。每条已接受消息都以 `Agent <sender-id> sent a message:` 作为前缀,并记录 `AgentMessageSource`;来源信息记录 sender,但不授予权限。
 
-对于 `startContinuable()` 与 `sendMessage()`,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent。浏览器中的人类提示仍由私有 Queue 适配器处理,因此继续产生独立 FIFO 轮次。
+对于 `startContinuable()`、`sendMessage()` 与浏览器 prompt 投递,调用方 signal 仅在收件箱接受之前掌管查找、物化与准入。此后管理器独立掌管该 Activation:之后的调用方取消既不会取消已接受的轮次,也不会 dispose 子 agent。公开 subagent 服务不暴露由调用方选择的 Agent 消息调度;浏览器人类 Queue 与 Steer 仍是内部适配器选择。
+
+在线 queue occurrence 变更属于 Session 域。只有在线 subagent-owned Agent 的当前 projection identity 为 continuable,且其 descriptor 序号位于该 child 自身的非 seed suffix 时,`session.updateQueue` 才会接纳普通 Edit、Remove 与 QueueDock Steer。Identity projection 以 last-wins 方式折叠 descriptor,因此 child descriptor 会覆盖 fork lineage 保留的 descriptor;own-suffix 序号检查会阻止仅来自 seed 的祖先 identity 授权变更。One-shot、缺失、未知、损坏或冷 child 会被拒绝,queue 变更绝不会冷恢复 child。这些变更以目标 Session id 作为人类权限,包括待处理 `nextStep` steering 或注入 context。Steer 要求 queued `MessageId`,且 command 开始时 Agent 必须报告 running;准入后发生取消时,会使用 Agent 已接受的唤醒 `nextTurn` fallback。Edit 会在同一个 `MessageId` 下改写内容,且 Edit 与 Steer 都会同步完成 Inbox 变更,因此 settlement 只会观察最终状态。`agent/inbox/claimed` 与 `agent/inbox/discarded` 都会唤醒 watcher 重新读取是否仍有待处理 occurrence;这样,直接 Agent 投递可以恢复停放工作,而移除最后一个停放 occurrence 可使 idle child 结算。[人类 inbox 控制 Agent Note](../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md)拥有这些语义。
 
 `SubagentRuntime.interrupt(targetSessionId, authority)` 是唯一的公开停止操作:它同步完成鉴权,对在线目标发出 `Agent.cancel(cause, { keepInbox: true })`,然后不等待完全停稳即返回。Activation、其尚未领取的待处理 inbox 工作与已发布的后代均不受影响;已被领取进入中断轮次的工作不会重新入队。被中断的 driver 进入 idle 后,一次唤醒发送会恢复被暂停的 FIFO 队列。不存在的目标——未知、一次性或已结算——以及未绑定管理器的组合是被接受的 no-op。对在线目标,错误的 parent 地址或不在其在线祖先链中的调用方会以 `UNAUTHORIZED` 拒绝;陈旧的 ancestor 对象和指向自身的 ancestor 请求会在查找目标前拒绝。
 
@@ -162,7 +164,7 @@ type SubagentInterruptAuthority =
   | { readonly kind: 'ancestor'; readonly agent: Agent }
 ```
 
-每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 已完全停稳、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。
+每个 Activation 都拥有自己的 `AgentHandle` 和一个 `ownedChildren: Set<SessionId>`;由于一份会话至多有一个存活 Activation,子会话 id 无需另一个运行时化身引用即可标识存活的子 agent。启动子 agent 或提交源自 parent 的工作,会在子 agent 能够运行之前将其注册到受继续执行管理的父级集合中;只要该集合非空,该父级就无法 settle。顶层或其他非继续执行的 Agent 没有 Activation,处于 waiting 图之外。只有当子 Agent 没有活跃工作、其 Inbox 为空、该子 agent 的每个子级都已 dispose、best-effort 的最终会话 flush 结算完毕,且子 agent 的 `AgentHandle` 完成 dispose 之后,才会释放子 agent。
 
 最终结算会等待 `ctx.sessions.flush(session)`,但会忽略其参与布尔值,因为任意 listener 都无法证明某个持久化后端已存储该状态。rejection 会被记录,但不会使 Activation 失败;管理器仍会 dispose 该 handle 并释放所有权,此后持久化的子 agent 状态在后续恢复时可能缺失或陈旧。管理器卸载会调用内部的管理器全局 drain,关闭准入并 dispose 每片在线森林;`drainContinuableDescendants(parents)` 只关闭由 host 确切拥有的在线 Agent 之下的准入,并 dispose 其可继续后代,而无关森林保持在线。两者都会等待各自作用域内已获准的物化过程,自顶向下传播取消,按 child-first 顺序释放 handle,并且即使个别分支失败也会等待所有选中分支。持久化子会话不受该进程内拆卸的影响。
 
@@ -195,7 +197,7 @@ interface ContinuableStart {
 }
 ```
 
-当驻留 Activation 结算时,管理器会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 内容。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与 Agent 消息相同的唤醒准入记账到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个静息 Agent 是开启一个轮次,而不是排队等待工作。其来源信息使用一个独立的 kind,因此 transcript(文本记录)绝不会把运行时的记账呈现为 child 自己写下的内容。
+当驻留 Activation 结算时,管理器会向该 child 持久化的直接 parent 投递一条通知,说明该 epoch 如何结束,并携带其最终 assistant 内容。对每个调用方拿到过 id 的 child,这条投递都是无条件的;它发生在会让 parent 被判定为已结算的所有权释放之前,并通过与 Agent 消息相同的唤醒 Agent 投递到达驻留 parent。若 parent 自身所在的谱系已在拆卸中,这条通知会以不唤醒的方式送达,因为唤醒一个 idle Agent 是开启一个轮次,而不是排队等待工作。其来源信息使用一个独立的 kind,因此 transcript(文本记录)绝不会把运行时的记账呈现为 child 自己写下的内容。
 
 ```ts type-equiv
 /**
@@ -615,11 +617,12 @@ listDescendants(rootSessionId: SessionId, signal?: AbortSignal): Promise<Subagen
  * Deliver one browser-authored message to a continuable child through the
  * exact live direct parent, retaining the caller-minted request identity and
  * validated browser zone on the accepted message. Success identifies the
- * message the child's FIFO inbox accepted; later execution is independent of
- * this call.
+ * message the child's inbox accepted; later execution is independent of this
+ * call. Queue delivery targets a later turn; steer delivery targets the
+ * nearest step and retains the Agent loop's best-effort fallback semantics.
  * Image parts are admitted and persisted through the attachment store
  * before delivery, and the child's model must accept image input.
- * @param request - durable address, minted identity, content, and optional browser zone.
+ * @param request - durable address, delivery, minted identity, content, and optional browser zone.
  * @param signal - carrier cancellation, owning the call until inbox acceptance.
  * @returns the accepted message's inbox identity.
  * @throws {RemoteError} `gateway/bad-request`, `subagent/attachment-invalid`,

+ 1 - 1
package.json

@@ -37,7 +37,7 @@
     "test:coverage": "vitest run --coverage",
     "test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts",
     "test:e2e": "vitest run --config vitest.e2e.config.ts",
-    "test:bench": "npm run build:bench && npm run test:bench:built",
+    "test:bench": "npm run build:bench && npm run build:web && npm run test:bench:built",
     "test:bench:built": "vitest run --config vitest.bench.config.ts",
     "test:expected": "vitest run --config vitest.expected.config.ts",
     "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",

+ 2 - 2
packages/api/session-controller/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: ec8b70b1724afc462d2d0b5d267603bcab4aceb6
-README.zh.md: 6fc0c92300262bfdf0fc52deaec1d66313d91ebe
+README.md: a2639ebd5fb648ed193d7c3575a649891ebf76b9
+README.zh.md: 7bed20d3e47855b045bb838b354328f1769273e2

File diff suppressed because it is too large
+ 1 - 1
packages/api/session-controller/README.md


File diff suppressed because it is too large
+ 1 - 1
packages/api/session-controller/README.zh.md


+ 2 - 0
packages/api/session-controller/package.json

@@ -99,6 +99,7 @@
     "@deepseek-ai/dsh-typert-protocol": "workspace:^",
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-util-time": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^"
   },
@@ -145,6 +146,7 @@
     "@deepseek-ai/dsh-typert-registry": "workspace:^",
     "@deepseek-ai/dsh-util-crypto": "workspace:^",
     "@deepseek-ai/dsh-util-time": "workspace:^",
+    "@deepseek-ai/dsh-util-values": "workspace:^",
     "@deepseek-ai/dsh-util-workspace-path": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^"
   }

+ 1 - 1
packages/api/session-controller/src/client/contract/session.ts

@@ -98,7 +98,7 @@ export interface ISession {
     attachmentId: AttachmentIdType,
   ): Promise<RemoteResult<{ attachment: ImageAttachmentRef; data: Uint8Array }>>
   /**
-   * Apply one edit, remove, or strict steer action to a still-pending queue occurrence.
+   * Apply one edit, remove, or Steer action to a still-pending queue occurrence.
    * @param itemId - agent-owned inbox occurrence identity.
    * @param action - requested queue operation.
    * @returns acceptance, or a business/transport error.

+ 1 - 0
packages/api/session-controller/src/client/sessions/session.ts

@@ -271,6 +271,7 @@ export class Session implements SessionFace {
         parentSessionId: this.address.parentSessionId,
         childSessionId: this.address.childSessionId,
         mode: 'continuable',
+        delivery: mode,
         content: routedContent,
         clientTimeZone: resolvedClientTimeZone(),
       }, signal)

+ 27 - 12
packages/api/session-controller/src/commands.ts

@@ -19,6 +19,7 @@ import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deeps
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
 import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
+import { assertNever } from '@deepseek-ai/dsh-util-values'
 import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
 import type { Workspace } from '@deepseek-ai/dsh-workspace'
 import {
@@ -415,12 +416,18 @@ export class SessionCommandController {
       )
     }
     const agent = this.ctx.agents.get(request.sessionId)
-    if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
-      throw apiSessionSubagentOwnershipError(request.sessionId)
-    }
     if (agent === undefined) {
       throw new RemoteError('session/queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId })
     }
+    if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) {
+      const identity = this.ctx.sessionProjections
+        .snapshot(agent.session, ['subagent'])
+        .values.subagent
+      if (identity?.mode !== 'continuable'
+        || !agent.session.isOwnSeq(identity.seq)) {
+        throw apiSessionSubagentOwnershipError(request.sessionId)
+      }
+    }
     const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId)
     const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId)
     const located = nextTurn === undefined
@@ -433,20 +440,28 @@ export class SessionCommandController {
     if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
       throw new RemoteError('session/steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId })
     }
-    if (request.action.kind === 'edit') {
-      agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
-        ...message,
-        content: [...request.action.content],
-      }))
-    } else {
-      agent.inbox.remove(request.itemId)
-      if (request.action.kind === 'remove') {
+    switch (request.action.kind) {
+      case 'edit':
+        agent.inbox.replace(request.itemId, freezeMessage<UserMessage>({
+          ...message,
+          content: [...request.action.content],
+        }))
+        break
+      case 'remove': {
+        agent.inbox.remove(request.itemId)
         const source = message.source
         if (source.kind === 'user' && 'rpcId' in source) {
           this.ctx.fileUploads.retirePrompt(agent, source.rpcId)
         }
+        break
       }
-      if (request.action.kind === 'steer') agent.steer(message)
+      case 'steer':
+        agent.inbox.remove(request.itemId)
+        agent.steer(message)
+        break
+      /* v8 ignore next 2 -- closed-union exhaustiveness guard */
+      default:
+        assertNever(request.action, 'queue action')
     }
     return { accepted: true }
   }

+ 136 - 6
packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts

@@ -4,14 +4,20 @@ import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent'
 import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
 import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm'
-import SessionStore, { SESSION_FORMAT_VERSION, SessionId, SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session'
-import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
+import SessionStore, {
+  SESSION_FORMAT_VERSION, Session, SessionId, SessionLogOffset, SessionSeq,
+} from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
+import { snapshotSubagentDescriptor, SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
+import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts'
 import { describe, expect, it, vi } from 'vitest'
 import { ApiSessionAgentController } from '../src/agent.ts'
 import { SessionCommandController } from '../src/commands.ts'
 import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts'
 
-async function commandHarness(): Promise<{
+async function commandHarness(
+  childMode?: 'continuable' | 'seeded-continuable' | 'seed-only' | 'one-shot' | 'unknown' | 'corrupt',
+): Promise<{
   ctx: Context
   controller: SessionCommandController
   agent: Agent
@@ -22,9 +28,48 @@ async function commandHarness(): Promise<{
   const ctx = new Context()
   await ctx.plugin(SessionStore)
   await ctx.plugin(AgentRegistry)
-  const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } })
+  installSessionReadTestServices(ctx)
+  ctx.sessionProjections.register(subagentIdentityProjectionDefinition)
+  const sessionId = SessionId('commands-session')
+  const ancestor = Session.create(SessionId('ancestor'))
+  ancestor.append('subagent/descriptor', snapshotSubagentDescriptor({
+    mode: 'continuable', provider: 'test', label: 'ancestor',
+  }))
+  // A seeded child inherits exactly the ancestor prefix; its own descriptor
+  // is appended after creation, as the continuation manager does. `seed-only`
+  // never appends one: the identity folds as continuable, but from the
+  // inherited prefix rather than this Session's own suffix.
+  const lineage = childMode === 'seeded-continuable' || childMode === 'seed-only'
+    ? ancestor.snapshotEvents()
+    : undefined
+  const session = ctx.sessions.create(sessionId, {
+    ...lineage === undefined ? {} : { seed: lineage, inheritedEventCount: SessionLogOffset(lineage.length) },
+    meta: {
+      cwd: '/workspace',
+      ...(childMode === undefined ? {} : {
+        origin: 'subagent' as const,
+        parentSession: SessionId('offline-parent'),
+      }),
+      ...lineage === undefined ? {} : { isSeeded: true },
+    },
+  })
+  if (childMode === 'continuable' || childMode === 'seeded-continuable') {
+    session.append('subagent/descriptor', snapshotSubagentDescriptor({
+      mode: 'continuable', provider: 'test', label: 'child',
+    }))
+  } else if (childMode === 'one-shot') {
+    session.append('subagent/descriptor', snapshotSubagentDescriptor({
+      mode: 'one-shot', provider: 'test', label: 'child',
+    }))
+  } else if (childMode === 'corrupt') {
+    session.append('subagent/descriptor', {
+      version: SUBAGENT_DESCRIPTOR_VERSION,
+      mode: 'continuable',
+      provider: 1,
+    } as never)
+  }
   const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} })
-  const steer = vi.fn()
+  const steer = vi.fn((message: UserMessage) => { inbox.append('next-step', message) })
   const cancel = vi.fn()
   const agent = {
     id: session.id,
@@ -52,7 +97,14 @@ async function commandHarness(): Promise<{
     serializeImageAdmission: <Value>(_agent: Agent, operation: () => Promise<Value>) => operation(),
     composeAgent: () => Promise.resolve({ setup: () => {} }),
   } as unknown as ApiSessionAgentController
-  return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), agent, inbox, steer, cancel }
+  return {
+    ctx,
+    controller: new SessionCommandController(ctx, agents, '/workspace'),
+    agent,
+    inbox,
+    steer,
+    cancel,
+  }
 }
 
 async function expectFailure(operation: Promise<unknown>, code: string): Promise<void> {
@@ -100,6 +152,9 @@ describe('Session queue commands', () => {
       action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] },
     })).toEqual({ accepted: true })
     expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }])
+    // An edit rewrites content in place, so the occurrence a client addressed
+    // by id stays addressable.
+    expect(inbox.nextTurn[0]?.id).toBe(queued.id)
     expect(controller.updateQueue({
       sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' },
     })).toEqual({ accepted: true })
@@ -136,6 +191,81 @@ describe('Session queue commands', () => {
     expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true })
     await ctx.fiber.dispose()
   })
+
+  it.each(['continuable', 'seeded-continuable'] as const)(
+    'mutates both inbox destinations of a live %s child while its parent is offline',
+    async (childMode) => {
+      const { ctx, controller, agent, inbox, steer } = await commandHarness(childMode)
+      const queued = createUserMessage({
+        content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' },
+      })
+      const context = createUserMessage({
+        content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' },
+      })
+      inbox.append('next-turn', queued)
+      inbox.append('next-step', context)
+
+      expect(controller.updateQueue({
+        sessionId: agent.id,
+        itemId: context.id,
+        action: { kind: 'edit', content: [{ type: 'text', text: 'edited context' }] },
+      })).toEqual({ accepted: true })
+      const editedContext = inbox.nextStep[0]
+      expect(editedContext).toMatchObject({
+        content: [{ type: 'text', text: 'edited context' }],
+        source: context.source,
+      })
+      expect(editedContext?.id).toBe(context.id)
+      if (editedContext === undefined) throw new Error('missing edited context')
+      expect(controller.updateQueue({
+        sessionId: agent.id, itemId: editedContext.id, action: { kind: 'remove' },
+      })).toEqual({ accepted: true })
+      expect(controller.updateQueue({
+        sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' },
+      })).toEqual({ accepted: true })
+      expect(steer).toHaveBeenCalledWith(queued)
+      await ctx.fiber.dispose()
+    },
+  )
+
+  it('removes the selected message before handing it to Agent steering', async () => {
+    const { ctx, controller, agent, inbox, steer } = await commandHarness('continuable')
+    const first = createUserMessage({
+      content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
+    })
+    const second = createUserMessage({
+      content: [{ type: 'text', text: 'second' }], source: { kind: 'user' },
+    })
+    inbox.append('next-turn', first)
+    inbox.append('next-turn', second)
+    // Stand in for the Agent's cancellation-convergence destination; the
+    // command must accept whichever boundary `Agent.steer()` selects.
+    steer.mockImplementation((message: UserMessage) => { inbox.append('next-turn', message) })
+
+    expect(controller.updateQueue({
+      sessionId: agent.id, itemId: first.id, action: { kind: 'steer' },
+    })).toEqual({ accepted: true })
+    expect(steer).toHaveBeenCalledWith(first)
+    // Ordering proves the removal happened before delivery rather than after.
+    expect(inbox.nextTurn).toEqual([second, first])
+    expect(inbox.nextStep).toEqual([])
+    await ctx.fiber.dispose()
+  })
+
+  it('keeps one-shot, seed-only, missing, and malformed child descriptors behind the ownership fence', async () => {
+    for (const mode of ['one-shot', 'seed-only', 'unknown', 'corrupt'] as const) {
+      const { ctx, controller, agent, inbox } = await commandHarness(mode)
+      const queued = createUserMessage({
+        content: [{ type: 'text', text: mode }], source: { kind: 'user' },
+      })
+      inbox.append('next-turn', queued)
+      await expectFailure(Promise.resolve().then(() => controller.updateQueue({
+        sessionId: agent.id, itemId: queued.id, action: { kind: 'remove' },
+      })), 'session/agent-busy')
+      expect(inbox.nextTurn).toEqual([queued])
+      await ctx.fiber.dispose()
+    }
+  })
 })
 
 function imageRef(id: string): ImageAttachmentRef {

+ 1 - 0
packages/api/session-controller/tests/manager.client.spec.ts

@@ -320,6 +320,7 @@ describe('subagent catalogs', () => {
         requestId: expect.any(String) as unknown as string,
         parentSessionId: S1, childSessionId: S2,
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: 'continue' }],
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },

+ 12 - 0
packages/api/session-controller/tests/session.client.spec.ts

@@ -400,9 +400,11 @@ describe('prompt and cancel errors', () => {
     })
     await session.open()
     const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue')
+    const steered = await session.prompt([{ type: 'text', text: '现在处理' }], 'steer')
     const cancelled = await session.cancel()
 
     expect(prompted).toEqual({ ok: true, value: { accepted: true } })
+    expect(steered).toEqual({ ok: true, value: { accepted: true } })
     expect(cancelled).toEqual({ ok: true, value: { accepted: true } })
     expect(api.callsOf('session.follow')).toEqual([
       {
@@ -419,9 +421,18 @@ describe('prompt and cancel errors', () => {
         requestId: expect.any(String) as unknown as string,
         parentSessionId: PARENT, childSessionId: SID,
         mode: 'continuable',
+        delivery: 'queue',
         content: [{ type: 'text', text: '继续' }],
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },
+      {
+        requestId: expect.any(String) as unknown as string,
+        parentSessionId: PARENT, childSessionId: SID,
+        mode: 'continuable',
+        delivery: 'steer',
+        content: [{ type: 'text', text: '现在处理' }],
+        clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
+      },
     ])
     expect(api.callsOf('subagents.interruptByParent')).toEqual([
       { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' },
@@ -456,6 +467,7 @@ describe('prompt and cancel errors', () => {
         requestId: expect.any(String) as unknown as string,
         parentSessionId: PARENT, childSessionId: SID,
         mode: 'continuable',
+        delivery: 'queue',
         content,
         clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone,
       },

+ 1 - 0
packages/api/session-controller/tsconfig.host.json

@@ -44,6 +44,7 @@
     { "path": "../../skill/skill" },
     { "path": "../../subagent/subagent" },
     { "path": "../../util/time" },
+    { "path": "../../util/values" },
     { "path": "../../typert/protocol" },
     { "path": "../../typert/registry" },
     { "path": "../../workspace/workspace" }

+ 2 - 2
packages/client/ui-chat/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
-README.md: 405e5095d75c3e8b752c028bbc187307a9647bb6
-README.zh.md: 7f57bc3480ea0186e89d12e8e443926906677742
+README.md: fa9653f7acab47f352e59971b333c6e691f44191
+README.zh.md: f8dc1968fffc57336d6aece8fe07113d5b99893b

Some files were not shown because too many files changed in this diff