@zhushanwen/subagent-core 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/dist/{chunk-4KN4TTG7.js → chunk-43ONBFZX.js} +1 -1
  2. package/dist/{chunk-X7SZ5HLQ.js → chunk-V3VHZ2VX.js} +20 -27
  3. package/dist/execution/engine/engines/zcode/constants.cjs +29 -41
  4. package/dist/execution/engine/engines/zcode/constants.d.cts +65 -53
  5. package/dist/execution/engine/engines/zcode/constants.d.ts +65 -53
  6. package/dist/execution/engine/engines/zcode/constants.js +17 -27
  7. package/dist/execution/engine/engines/zcode/reader.d.cts +2 -1
  8. package/dist/execution/engine/engines/zcode/reader.d.ts +2 -1
  9. package/dist/execution/engine/engines/zcode/reader.js +2 -2
  10. package/dist/index.cjs +16418 -14070
  11. package/dist/index.d.cts +5136 -4016
  12. package/dist/index.d.ts +5136 -4016
  13. package/dist/index.js +16343 -14048
  14. package/dist/{types-DpUO16pj.d.cts → types-Dv4QhSJ_.d.cts} +215 -112
  15. package/dist/{types-DpUO16pj.d.ts → types-Dv4QhSJ_.d.ts} +215 -112
  16. package/package.json +3 -3
  17. package/src/__tests__/agent-opts-resolver-schema-prompt.test.ts +0 -1
  18. package/src/__tests__/append-system-prompt-assembly.test.ts +8 -7
  19. package/src/__tests__/fr4-get-state-handshake.test.ts +2 -2
  20. package/src/__tests__/m2-append-content-probe.test.ts +0 -1
  21. package/src/__tests__/manifest-store.test.ts +10 -9
  22. package/src/__tests__/record-store-cache.test.ts +0 -2
  23. package/src/__tests__/record-store-index.test.ts +1 -2
  24. package/src/__tests__/review-fix-loop-script.test.ts +4 -2
  25. package/src/__tests__/review-fix-loop-utils.test.ts +10 -10
  26. package/src/__tests__/robustness-low-batch1.test.ts +6 -5
  27. package/src/__tests__/robustness-medium-batch1.test.ts +2 -2
  28. package/src/__tests__/robustness-medium-batch2.test.ts +17 -6
  29. package/src/__tests__/robustness-medium-batch4.test.ts +2 -2
  30. package/src/__tests__/session-runner.test.ts +1 -1
  31. package/src/core/__tests__/host-services.test.ts +2 -2
  32. package/src/core/__tests__/logger.test.ts +1 -1
  33. package/src/core/error-message.ts +9 -0
  34. package/src/core/host-services.ts +21 -5
  35. package/src/core/notify-ports.ts +18 -5
  36. package/src/execution/__tests__/agent-profile.test.ts +4 -4
  37. package/src/execution/__tests__/agent-registry.test.ts +370 -0
  38. package/src/execution/__tests__/agent-result-mapper.test.ts +18 -2
  39. package/src/execution/__tests__/agents-assembly.test.ts +2 -2
  40. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +6 -6
  41. package/src/execution/__tests__/chat-engine-routing.test.ts +68 -33
  42. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +4 -4
  43. package/src/execution/__tests__/cold-resurrect.test.ts +283 -0
  44. package/src/execution/__tests__/delivery-methods.test.ts +75 -62
  45. package/src/execution/__tests__/descendant-sweep-guards.test.ts +220 -0
  46. package/src/execution/__tests__/descendant-sweep.test.ts +6 -6
  47. package/src/execution/__tests__/dialog-queue.test.ts +2 -2
  48. package/src/execution/__tests__/engine-model-validation.test.ts +741 -0
  49. package/src/execution/__tests__/epipe-fallback.test.ts +29 -24
  50. package/src/execution/__tests__/execute-and-await-worktree.test.ts +21 -91
  51. package/src/execution/__tests__/execute-nesting.test.ts +34 -188
  52. package/src/execution/__tests__/execution-record.test.ts +217 -60
  53. package/src/execution/__tests__/execution-runtime-face.test.ts +5 -5
  54. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +3 -3
  55. package/src/execution/__tests__/finalize-record.test.ts +173 -17
  56. package/src/execution/__tests__/gc-timer.test.ts +55 -2
  57. package/src/execution/__tests__/get-record-for-action-restart.test.ts +12 -12
  58. package/src/execution/__tests__/get-state-handshake.test.ts +1 -1
  59. package/src/execution/__tests__/helpers/mock-extension-api.ts +1 -1
  60. package/src/execution/__tests__/helpers/session-runner-mocks.ts +113 -0
  61. package/src/execution/__tests__/helpers/spawn-mock.ts +150 -3
  62. package/src/execution/__tests__/helpers/subagent-service-mocks.ts +137 -0
  63. package/src/execution/__tests__/keep-alive-no-progress.test.ts +50 -148
  64. package/src/execution/__tests__/kill-all-escalation.test.ts +2 -2
  65. package/src/execution/__tests__/lifecycle-predicates.test.ts +1 -2
  66. package/src/execution/__tests__/manifest-parentid.test.ts +4 -4
  67. package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +1 -1
  68. package/src/execution/__tests__/model-resolver.test.ts +2 -2
  69. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +23 -121
  70. package/src/execution/__tests__/nested-visibility.test.ts +2 -0
  71. package/src/execution/__tests__/notify-ledger.test.ts +973 -18
  72. package/src/execution/__tests__/output-collector.test.ts +117 -6
  73. package/src/execution/__tests__/pi-invocation.test.ts +1 -1
  74. package/src/execution/__tests__/record-store.test.ts +10 -9
  75. package/src/execution/__tests__/records-cwd-isolation.test.ts +2 -2
  76. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +29 -132
  77. package/src/execution/__tests__/recursive-visibility-env.test.ts +55 -179
  78. package/src/execution/__tests__/rpc-mode.test.ts +2 -2
  79. package/src/execution/__tests__/run-and-finalize-anchoring.test.ts +387 -0
  80. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +15 -9
  81. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +4 -4
  82. package/src/execution/__tests__/run-spawn-edges.test.ts +17 -15
  83. package/src/execution/__tests__/run-spawn-integration.test.ts +2 -2
  84. package/src/execution/__tests__/run-spawn-resume.test.ts +2 -2
  85. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +2 -2
  86. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +4 -4
  87. package/src/execution/__tests__/service-kill-escalation.test.ts +7 -5
  88. package/src/execution/__tests__/session-context-resolver.test.ts +0 -2
  89. package/src/execution/__tests__/session-file-gc.test.ts +35 -0
  90. package/src/execution/__tests__/session-pending.test.ts +257 -14
  91. package/src/execution/__tests__/session-reconstructor.test.ts +366 -15
  92. package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +1 -1
  93. package/src/execution/__tests__/session-runner-close-prune.test.ts +2 -2
  94. package/src/execution/__tests__/session-runner-dispatch.test.ts +489 -0
  95. package/src/execution/__tests__/session-runner-epipe.test.ts +3 -3
  96. package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +5 -4
  97. package/src/execution/__tests__/session-runner-lifecycle-helpers.test.ts +290 -0
  98. package/src/execution/__tests__/session-runner-schema-env.test.ts +7 -5
  99. package/src/execution/__tests__/settled-watchdog.test.ts +282 -47
  100. package/src/execution/__tests__/spawn-args.test.ts +11 -10
  101. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +26 -23
  102. package/src/execution/__tests__/spawn-event-adapter.test.ts +1 -1
  103. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +5 -4
  104. package/src/execution/__tests__/spawned-children.test.ts +2 -1
  105. package/src/execution/__tests__/start-sync-model-guard.test.ts +4 -4
  106. package/src/execution/__tests__/stdin-writer.test.ts +6 -4
  107. package/src/execution/__tests__/stream-sink-retirement.test.ts +6 -6
  108. package/src/execution/__tests__/subagent-actions-core.test.ts +42 -22
  109. package/src/execution/__tests__/subagent-service-message-close.test.ts +29 -26
  110. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +8 -8
  111. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -22
  112. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +30 -29
  113. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +45 -20
  114. package/src/execution/__tests__/subagent-service.test.ts +37 -30
  115. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +88 -12
  116. package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +75 -0
  117. package/src/execution/__tests__/subprocess-agent-runner.test.ts +22 -20
  118. package/src/execution/__tests__/temp-prompt.test.ts +1 -1
  119. package/src/execution/__tests__/timeout-integration.test.ts +6 -5
  120. package/src/execution/__tests__/turn-limiter-semantics.test.ts +1 -1
  121. package/src/execution/__tests__/turn-limiter.test.ts +1 -1
  122. package/src/execution/__tests__/ui-request-handler-factory.test.ts +12 -6
  123. package/src/execution/__tests__/ui-request-handler.test.ts +4 -3
  124. package/src/execution/__tests__/ui-request-queue.test.ts +142 -2
  125. package/src/execution/__tests__/worktree-manager.test.ts +14 -5
  126. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -3
  127. package/src/execution/agent-registry.ts +74 -30
  128. package/src/execution/agent-result-mapper.ts +3 -0
  129. package/src/execution/cold-resurrect.ts +199 -0
  130. package/src/execution/engine/__tests__/common/capability-gate.test.ts +91 -0
  131. package/src/execution/engine/__tests__/common/data-dir.test.ts +1 -1
  132. package/src/execution/engine/__tests__/common/journal-wiring.test.ts +84 -0
  133. package/src/execution/engine/__tests__/common/kill-chain.test.ts +34 -7
  134. package/src/execution/engine/__tests__/common/persona-router.test.ts +4 -5
  135. package/src/execution/engine/__tests__/common/pool-manager.test.ts +211 -51
  136. package/src/execution/engine/__tests__/common/session-view-service-zcode-dbpath.test.ts +149 -0
  137. package/src/execution/engine/__tests__/common/session-view-service.test.ts +499 -0
  138. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +22 -112
  139. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +15 -28
  140. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +12 -12
  141. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +1 -1
  142. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +21 -82
  143. package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +4 -7
  144. package/src/execution/engine/__tests__/engine-discovery.test.ts +15 -3
  145. package/src/execution/engine/__tests__/model-prompt.test.ts +15 -3
  146. package/src/execution/engine/__tests__/registry.test.ts +4 -2
  147. package/src/execution/engine/__tests__/routing.test.ts +115 -6
  148. package/src/execution/engine/common/capability-gate.ts +92 -0
  149. package/src/execution/engine/common/journal-wiring.ts +97 -0
  150. package/src/execution/engine/common/kill-chain.ts +42 -10
  151. package/src/execution/engine/common/nesting-guard.ts +66 -0
  152. package/src/execution/engine/common/persona-router.ts +18 -8
  153. package/src/execution/engine/common/pool-manager.ts +332 -78
  154. package/src/execution/engine/common/session-view-service.ts +506 -0
  155. package/src/execution/engine/common/session-view-types.ts +125 -0
  156. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +174 -28
  157. package/src/execution/engine/engines/pi/__tests__/spawn-opts-direct.test.ts +257 -0
  158. package/src/execution/{argv-mirror.ts → engine/engines/pi/argv-mirror.ts} +43 -21
  159. package/src/execution/{get-state-handshake.ts → engine/engines/pi/get-state-handshake.ts} +1 -1
  160. package/src/execution/{output-collector.ts → engine/engines/pi/output-collector.ts} +108 -19
  161. package/src/execution/engine/engines/pi/pi-engine.ts +434 -38
  162. package/src/execution/{pi-invocation.ts → engine/engines/pi/pi-invocation.ts} +2 -2
  163. package/src/execution/engine/engines/pi/reader.ts +11 -0
  164. package/src/execution/engine/engines/pi/registration.ts +4 -2
  165. package/src/execution/{session-runner.ts → engine/engines/pi/session-runner.ts} +617 -412
  166. package/src/execution/{spawn-event-adapter.ts → engine/engines/pi/spawn-event-adapter.ts} +75 -34
  167. package/src/execution/{stdin-writer.ts → engine/engines/pi/stdin-writer.ts} +10 -9
  168. package/src/execution/{temp-prompt.ts → engine/engines/pi/temp-prompt.ts} +2 -2
  169. package/src/execution/{turn-limiter.ts → engine/engines/pi/turn-limiter.ts} +1 -1
  170. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +54 -27
  171. package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +321 -0
  172. package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +13 -12
  173. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +7 -210
  174. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +6 -110
  175. package/src/execution/engine/engines/zcode/__tests__/session-channel-dispose-harvest.test.ts +247 -0
  176. package/src/execution/engine/engines/zcode/__tests__/session-channel-turn-timers.test.ts +451 -0
  177. package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +20 -8
  178. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +77 -110
  179. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +25 -319
  180. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-dispose.test.ts +210 -0
  181. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-retry.test.ts +353 -0
  182. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-status.test.ts +282 -0
  183. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-timeout.test.ts +372 -0
  184. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +48 -148
  185. package/src/execution/engine/engines/zcode/appserver-launcher.ts +167 -0
  186. package/src/execution/engine/engines/zcode/connection.ts +24 -11
  187. package/src/execution/engine/engines/zcode/constants.ts +75 -71
  188. package/src/execution/engine/engines/zcode/golden-sample.ts +8 -46
  189. package/src/execution/engine/engines/zcode/parser.ts +12 -346
  190. package/src/execution/engine/engines/zcode/preparer.ts +12 -143
  191. package/src/execution/engine/engines/zcode/session-channel.ts +351 -39
  192. package/src/execution/engine/engines/zcode/zcode-engine.ts +665 -760
  193. package/src/execution/engine/host-task-spec.ts +32 -36
  194. package/src/execution/engine/model-validation.ts +177 -0
  195. package/src/execution/engine/port.ts +24 -6
  196. package/src/execution/engine/routing.ts +75 -1
  197. package/src/execution/engine/types.ts +30 -103
  198. package/src/execution/execution-record.ts +222 -144
  199. package/src/execution/finalize-record.ts +131 -93
  200. package/src/execution/idle-gc.ts +28 -0
  201. package/src/execution/lifecycle-predicates.ts +1 -1
  202. package/src/execution/notify-host.ts +212 -0
  203. package/src/execution/record-store.ts +119 -73
  204. package/src/execution/round-settlement.ts +93 -0
  205. package/src/execution/session-file-gc.ts +96 -62
  206. package/src/execution/session-reconstructor.ts +256 -143
  207. package/src/execution/sessions-index.ts +117 -47
  208. package/src/execution/settled-watchdog.ts +264 -52
  209. package/src/execution/subagent-actions-core.ts +22 -22
  210. package/src/execution/subagent-service.ts +815 -963
  211. package/src/execution/subprocess-agent-runner.ts +144 -97
  212. package/src/execution/types.ts +9 -0
  213. package/src/execution/ui-request-handler-factory.ts +102 -74
  214. package/src/execution/ui-request-queue.ts +50 -19
  215. package/src/index.ts +442 -316
  216. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +1 -1
  217. package/src/orchestration/__tests__/agent-call-stream.test.ts +2 -2
  218. package/src/orchestration/__tests__/args-validator.test.ts +14 -0
  219. package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +1 -1
  220. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +11 -9
  221. package/src/orchestration/__tests__/execute-agent-call.test.ts +151 -11
  222. package/src/orchestration/__tests__/file-run-store.test.ts +7 -6
  223. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +2 -2
  224. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +1 -1
  225. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +3 -3
  226. package/src/orchestration/__tests__/lifecycle.test.ts +7 -5
  227. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +1 -1
  228. package/src/orchestration/__tests__/run-snapshot.test.ts +8 -5
  229. package/src/orchestration/__tests__/script-lint.test.ts +17 -0
  230. package/src/orchestration/__tests__/test-mocks.ts +2 -0
  231. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +7 -6
  232. package/src/orchestration/__tests__/worker-message-pump-finalize-run.test.ts +229 -0
  233. package/src/orchestration/__tests__/{error-recovery-handlers.test.ts → worker-message-pump-handlers.test.ts} +27 -19
  234. package/src/orchestration/__tests__/{error-recovery-postmessage-defense.test.ts → worker-message-pump-postmessage-defense.test.ts} +8 -8
  235. package/src/orchestration/__tests__/{error-recovery-serialize-failed-result.test.ts → worker-message-pump-serialize-failed-result.test.ts} +2 -2
  236. package/src/orchestration/__tests__/{error-recovery-workflow-call.test.ts → worker-message-pump-workflow-call.test.ts} +1 -1
  237. package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +1 -1
  238. package/src/orchestration/__tests__/worker-script-builder.test.ts +1 -1
  239. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +1 -1
  240. package/src/orchestration/agent-opts-resolver.ts +1 -1
  241. package/src/orchestration/args-validator.ts +53 -31
  242. package/src/orchestration/execute-agent-call.ts +19 -73
  243. package/src/orchestration/launcher.ts +128 -81
  244. package/src/orchestration/lifecycle.ts +43 -45
  245. package/src/orchestration/models/ports.ts +4 -4
  246. package/src/orchestration/models/run-runtime.ts +2 -2
  247. package/src/orchestration/models/trace.ts +1 -1
  248. package/src/orchestration/models/types.ts +111 -22
  249. package/src/orchestration/models/workflow-run.ts +1 -1
  250. package/src/orchestration/script-lint.ts +91 -58
  251. package/src/orchestration/{error-recovery.ts → worker-message-pump.ts} +157 -150
  252. package/src/shared/__tests__/meta-parser.test.ts +151 -0
  253. package/src/shared/__tests__/resource-discovery.test.ts +258 -215
  254. package/src/shared/atomic-write.ts +12 -9
  255. package/src/shared/meta-parser.ts +158 -84
  256. package/src/shared/model-ref.ts +15 -2
  257. package/src/shared/resource-discovery.ts +9 -183
  258. package/src/shared/schema-jsonify.ts +1 -1
  259. package/workflows/review-fix-loop-utils.cjs +151 -89
  260. package/workflows/review-fix-loop-utils.d.cts +287 -0
  261. package/src/execution/__tests__/execute-options-mapper.test.ts +0 -200
  262. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +0 -164
  263. package/src/execution/engine/engines/pi/task-spec-mapper.ts +0 -100
  264. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +0 -39
  265. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +0 -150
  266. package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +0 -387
  267. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +0 -710
  268. package/src/execution/engine/engines/zcode/appserver-home.ts +0 -442
  269. package/src/execution/engine/engines/zcode/appserver-probe.ts +0 -141
  270. package/src/execution/engine/engines/zcode/launcher.ts +0 -161
  271. package/src/execution/execute-options-mapper.ts +0 -115
  272. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +0 -288
@@ -1,87 +1,79 @@
1
- // 执行编排 + 记录 + 通知领域 Service。
2
- // 上游:subagent-tool(execute/query/cancel)、TUI(onChange/listRunning/collectRecords)。
1
+ // 执行编排 + 记录领域 Service(D4 按变化轴拆分后的编排核:execute/executeAndAwait 入口、
2
+ // record 生命周期、cancel)。通知簇 → notify-host.ts;轮次结算闭包 → round-settlement.ts;
3
+ // 冷路径复活 → cold-resurrect.ts。
4
+ // 上游:subagent-tool(execute/query/cancel)、TUI(onChange/collectRecords)。
3
5
  // session_start 时经 initSession 注入 pi;modelRegistry/entries 归 ModelConfigService.initModel。
4
6
 
5
7
  import { AsyncLocalStorage } from "node:async_hooks";
6
- import * as fs from "node:fs";
7
8
 
8
9
  import { getLogger } from "../core/logger.ts";
9
10
 
10
11
  import type { ExtensionMode } from "./host-mode.ts";
11
12
 
12
13
  import type { AgentResult as WorkflowAgentResult } from "../orchestration/models/types.ts";
13
- import { displayAgentName } from "../shared/agent-ref.ts";
14
14
  import { MAX_TIMER_DELAY_MS } from "../shared/timer-delay.ts";
15
15
  // D-A10: workflow 侧 AgentResult 映射(executeAndAwait 出口)
16
16
  import { mapToWorkflowAgentResult } from "./agent-result-mapper.ts";
17
- import { removeAliveMarker, findForeignLiveInstance, writeAliveMarker } from "./alive-store.ts";
17
+ import { removeAliveMarker } from "./alive-store.ts";
18
18
  import { bestEffort } from "./best-effort.ts";
19
19
  // [V2 决策 3] lifecycle-manager idle timer:chatMode 统一投递新 turn disarm(防误杀活进程)。
20
- // [M3] hasIdleTimerpiAdapter.hasRunningBackground 排除等待续聊(timer armed)的 record。
21
- // [T4②] DEFAULT_IDLE_TIMEOUT_MS:deliverMessage EPIPE 失败 re-arm 的防御性兜底时长。
22
- import {
23
- acquireActivateLock,
24
- armIdleTimer,
25
- disarmIdleTimer,
26
- hasIdleTimer,
27
- DEFAULT_IDLE_TIMEOUT_MS,
28
- } from "./lifecycle-manager.ts";
20
+ // [T4②] DEFAULT_IDLE_TIMEOUT_MSassertIdleTimeoutMsSafe 错误文案的缺省时长基准。
21
+ // [M3] hasIdleTimer / hasLiveProcessHandle piAdapter.hasRunningBackground 过滤随通知簇
22
+ // 在 notify-host.ts 消费,不经本文件。)
23
+ import { disarmIdleTimer, DEFAULT_IDLE_TIMEOUT_MS } from "./lifecycle-manager.ts";
29
24
  import { type ConcurrencyPool,DefaultConcurrencyPool } from "./concurrency-pool.ts";
30
25
  import type { DialogGlobalQueue, UiRequestHandler } from "./dialog-queue.ts";
26
+ import { COLD_LOOKUP_SCAN_LIMIT, coldLookupForAction, type ColdResurrectDeps } from "./cold-resurrect.ts";
31
27
  import {
32
28
  completeRecord,
33
29
  createRecord,
34
- getFullTextFrom,
35
- nextRoundBaseTurnIndex,
36
30
  project,
37
- resurrectClosed,
38
31
  snapshot,
39
32
  tryTransition,
40
33
  } from "./execution-record.ts";
41
34
  import { doFinalizeRecord, doFinalizeRoundToIdle } from "./finalize-record.ts";
42
- import { getEngineDataDir } from "./engine/common/data-dir.ts";
43
- import { EngineError } from "./engine/common/errors.ts";
44
- import { JournalWriter } from "./engine/common/event-journal.ts";
45
- import { resolveJournalPath } from "./engine/paths.ts";
35
+ import { assertTaskShapeSupported } from "./engine/common/capability-gate.ts";
36
+ import { ExecutionNestingContext } from "./engine/common/nesting-guard.ts";
37
+ import { JOURNAL_INITIAL_POOL_KEY, wireEventJournal } from "./engine/common/journal-wiring.ts";
46
38
  import { executeOptionsToEngineTaskSpec } from "./engine/host-task-spec.ts";
39
+ import { PiEngine } from "./engine/engines/pi/pi-engine.ts";
40
+ import { PI_POOL_KEY } from "./engine/engines/pi/pi-engine.ts";
41
+ import type { ChatRoundTicket, PiEngineService } from "./engine/engines/pi/pi-engine.ts";
47
42
  import type { EnginePort, RunContext } from "./engine/port.ts";
48
- import { DEFAULT_ENGINE_ID, getEngine } from "./engine/registry.ts";
49
- import { type EngineRouteResult, resolveEngineRouting, routeEngine } from "./engine/routing.ts";
43
+ import { DEFAULT_ENGINE_ID, getEngine, listEngines } from "./engine/registry.ts";
44
+ import { validateModelForEngine, withCrossEngineHint } from "./engine/model-validation.ts";
45
+ import { type EngineRouteResult, routeEngineForHost } from "./engine/routing.ts";
50
46
  import type { AgentOutcome } from "./engine/types.ts";
51
47
  import { ManifestStore } from "./manifest-store.ts";
52
48
  import type { ModelConfigService } from "./model-config-service.ts";
53
49
  import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
54
- import type { BgNotifyRecord, BgNotifier, NotifierHost } from "./notifier.ts";
55
- import { createNotifier } from "./notifier.ts";
50
+ import { type NotifyHost, type PiLike, createNotifyHost } from "./notify-host.ts";
51
+ // [T4④ / PS-5] flush 被门拦时的未投递 pending 落盘账本(persistUndeliveredNotificationsForReplay 消费)
56
52
  import { getBoundNotifyLedger, NOTIFY_LEDGER_CUSTOM_TYPE } from "./notify-ledger.ts";
57
53
  import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts";
54
+ import { createRoundSettler } from "./round-settlement.ts";
58
55
  import type { StatusFilter } from "./record-store.ts";
59
56
  import { RecordStore } from "./record-store.ts";
60
57
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
61
58
  import {
62
- getChildByRecord,
63
59
  killAllSpawnedChildren,
64
60
  killRecordChildWithEscalation,
65
61
  registerSpawnedChildForRecord,
66
62
  runSpawn,
67
- spawnedChildren,
68
63
  type SessionRunnerContext,
69
64
  type SpawnResumeOpts,
70
- } from "./session-runner.ts";
65
+ } from "./engine/engines/pi/session-runner.ts";
66
+ // [u-t2a T2②/T2③] settled watchdog:chatMode 轮 settled 等待固定硬上限(10min),
67
+ // 双挂载点之一在编排层热路径(deliverChatMessage,prompt 发出后),disarm 站点散布
68
+ // cancel/close/终态化路径(原语幂等,见 settled-watchdog.ts 头注释)。
71
69
  import {
72
- armSettledWatchdog,
70
+ armMidRoundNoProgress,
73
71
  disarmSettledWatchdog,
74
- SETTLED_WATCHDOG_TIMEOUT_MS,
72
+ type SettledWatchdogFireInfo,
75
73
  } from "./settled-watchdog.ts";
76
- import { isIdle, isResumable, hasLiveProcessHandle } from "./lifecycle-predicates.ts";
74
+ import { isIdle, isResumable } from "./lifecycle-predicates.ts";
77
75
  import { startIdleGc } from "./idle-gc.ts";
78
- import {
79
- clearEpipeFailure,
80
- EPIPE_FAILURE_THRESHOLD,
81
- recordEpipeFailure,
82
- resetAllEpipeFailures,
83
- sendPromptCommand,
84
- } from "./stdin-writer.ts";
76
+ import { resetAllEpipeFailures } from "./engine/engines/pi/stdin-writer.ts";
85
77
  import type { StreamSink, SubagentStream } from "./stream-sink.ts";
86
78
  import { createBackgroundStream } from "./stream-sink.ts";
87
79
  import { writeCancelledTombstone } from "./tombstone-store.ts";
@@ -98,19 +90,45 @@ import type {
98
90
  SubagentRecord,
99
91
  } from "./types.ts";
100
92
  import { ForkDepthExceededError } from "./types.ts";
101
- import { isReconnectableFinalReason, ResurrectDeniedError } from "./types.ts";
102
93
  import { DEFAULT_AGENT_NAME } from "./types.ts";
103
94
  import { registerGlobalObservability, UiRequestObservability } from "./ui-request-observability.ts";
104
95
  import { WorktreeManager } from "./worktree-manager.ts";
96
+ import { toErrorMessage } from "../core/error-message.ts";
105
97
 
106
98
  const logger = getLogger("subagents");
107
99
 
108
- /** SP-2 冷路径按 id 查 record collectRecords 扫描上限(全扫兜底的容量 cap)。 */
109
- const COLD_LOOKUP_SCAN_LIMIT = 1000;
100
+ /** [D4 查询面聚合] 读模型轴(record 快照读取 + store 订阅)——Service 上的
101
+ * `service.queries` 消费面。变化轴:改查询投影 / 过滤 / 订阅语义,只动 queries 组;
102
+ * Service 本体保留编排核(execute/executeAndAwait/cancel)与生命周期面。 */
103
+ export interface SubagentQueries {
104
+ /** 按 id 查内存 running record 的只读快照(G3-002 修复)。不存在返回 undefined。 */
105
+ findRecord(id: string): RecordSnapshot | undefined;
106
+ /** [v8.5 A1/B] 全态查找:任意状态 × 任意归属的 record 快照(message 拒绝文案分流
107
+ * 与 fork-from 源解析共用)。id 在内存与磁盘均不存在返回 undefined。 */
108
+ lookupRecordAnyState(id: string): SubagentRecord | undefined;
109
+ /** 合并内存 + 磁盘 record(/subagents list + tool list 消费,按 rootSessionId 过滤)。 */
110
+ collectRecords(limit: number, statusFilter?: StatusFilter): SubagentRecord[];
111
+ /** [perf] 单 record 详情懒加载(全量:eventLog/displayItems/result/turns/tokens)。 */
112
+ getFullRecord(id: string): SubagentRecord | undefined;
113
+ /** 订阅 store 变更(widget/list requestRender)。返回取消订阅。 */
114
+ onChange(listener: () => void): () => void;
115
+ }
116
+
117
+ /** [D4 对话 action 面聚合] chat 域 message/close action 轴(M2-B3,原 Service 同节三方法)
118
+ * ——Service 上的 `service.chatActions` 消费面。变化轴:改对话域归属校验 / close 分流 /
119
+ 投递编排,只动 chatActions 组。 */
120
+ export interface SubagentChatActions {
121
+ /** 按 id 查 record 并做归属校验(message/close action 的统一入口)。 */
122
+ getRecordForAction(id: string, opts?: { allowReconnect?: boolean }): ExecutionRecord;
123
+ /** close action 的统一行为分流(running 子态 × force)。 */
124
+ closeSubagent(record: ExecutionRecord, force: boolean): Promise<void>;
125
+ /** chatMode 统一投递入口(message action,经引擎交互面执行)。 */
126
+ deliverChatMessage(record: ExecutionRecord, text: string, interrupt: boolean): Promise<void>;
127
+ }
110
128
 
111
- // [v4 A-1] EPIPE 连续失败计数器已迁移到 stdin-writer.ts(stdin 错误域,避免 session-runner
112
- // 反向 import 本文件 helper 产生循环依赖)。同步路径(deliverMessage)与异步路径
113
- //(session-runner child.stdin.on('error'))共用 stdin-writer 的同一计数器。
129
+ // [v4 A-1] EPIPE 连续失败计数器在 stdin-writer.ts(stdin 错误域,避免 session-runner
130
+ // 反向 import 本文件 helper 产生循环依赖)。同步路径(PiEngine 热路径投递,D2 协议知识
131
+ // 下沉后)与异步路径(session-runner child.stdin.on('error'))共用 stdin-writer 的同一计数器。
114
132
 
115
133
  /** dispose 后注入的 stub UI 请求 handler。
116
134
  *
@@ -129,48 +147,13 @@ const COLD_LOOKUP_SCAN_LIMIT = 1000;
129
147
  * 没真正解决。 */
130
148
  const disposedUiRequestStub: UiRequestHandler = () => Promise.resolve({ cancelled: true });
131
149
 
132
- /** Pi ExtensionAPI 的最小接口(duck-typed)。
133
- * subagent-service 直接调 pi.sendMessage 发 background 完成通知(BgNotifier 滑动窗口合并),
134
- * 不委托 pending-notifications EventBus 中继——后者只管 registry 不参与通知发送。 */
135
- interface PiLike {
136
- appendEntry(customType: string, data?: unknown): void;
137
- events: { emit(channel: string, data: unknown): void };
138
- sendMessage(
139
- message: { customType: string; content: string; display: boolean; details?: unknown },
140
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, // g4-allow: 类型注解——PiLike 接口形状(pi.sendMessage 签面子集),非投递调用
141
- ): void;
142
- /** 订阅 pi 事件(D8:notifier 的 settled 边沿订阅用 'agent_settled')。
143
- * pi 0.84.4 的 on 返回 void 且无 off——退订语义由调用侧 disposed 标志包装兑现。
144
- * 可选:旧测试 mock pi 可能未实现 on,缺省时 notifier 退化为内核退避路径。 */
145
- on?(event: "agent_settled", handler: () => void): void;
146
- }
147
-
148
150
  /** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
149
151
  * session_start 时从 ctx.ui 注入,background 执行期间用于把合并后的 text_delta
150
152
  * 通过 setWidget 通道转发到 RPC stdout(不经 sendMessage 的持久化路径)。 */
151
153
  export type { StreamSink } from "./stream-sink.ts";
152
154
 
153
- /** pending-notifications 注册/注销 helper(避免重复代码)。
154
- * name GUI pending 通知的显示名——取 basename 短名(displayAgentName),
155
- * 完整路径仍走 record.agent(env 注入 / 持久化)。 */
156
- function emitPendingRegister(pi: PiLike | null, id: string, name?: string): void {
157
- pi?.events.emit("pending:register", {
158
- id,
159
- type: "subagent",
160
- name: name ? displayAgentName(name) : id,
161
- });
162
- }
163
-
164
- function emitPendingUnregister(
165
- pi: PiLike | null,
166
- id: string,
167
- reason: string,
168
- ): void {
169
- pi?.events.emit("pending:unregister", {
170
- id,
171
- reason,
172
- });
173
- }
155
+ // pi 依赖端口类型 re-export:测试侧 mock PiLike 历来从本模块取(与 StreamSink 同构的门面模式)
156
+ export type { PiLike } from "./notify-host.ts";
174
157
 
175
158
  /**
176
159
  * Service 构造参数(进程级)。
@@ -204,8 +187,12 @@ export interface SubagentServiceSessionInit {
204
187
  * initSession 读取后存入 this.sessionMode,buildSessionRunnerContext 透传给 session-runner。 */
205
188
  mode?: ExtensionMode;
206
189
  /** UI 请求 handler(session 级覆盖进程级)。
207
- * initSession 读取后覆盖 this.uiRequestHandler(setUiRequestHandler session 级等价入口)。 */
208
- uiRequestHandler?: UiRequestHandler;
190
+ * [D4-④ UI 接线外提] 本字段是 handler 的唯一注入入口(原 setUiRequestHandler 方法已删)。
191
+ * 三态语义:undefined = 不动(保留进程级构造/上次值,供不注入 handler 的调用方);
192
+ * null = 显式清空(承载原 setUiRequestHandler(undefined) 语义——headless 的
193
+ * createUiRequestHandlerForMode 返回 undefined 时壳侧传 null);值 = 注入并重置
194
+ * 缺失告警去重。 */
195
+ uiRequestHandler?: UiRequestHandler | null;
209
196
  /** L2 跨子进程全局 dialog 串行队列(进程单例)。透传给 session-runner,
210
197
  * child close 时调 rejectChildDialogs 清理 pending(SR-4 防全局死锁)。 */
211
198
  dialogQueue?: DialogGlobalQueue;
@@ -223,14 +210,14 @@ const MS_PER_SECOND = 1000;
223
210
  const SECONDS_PER_MINUTE = 60;
224
211
 
225
212
  /** [T4① / PS-2] notify 门拦截集:disposeAllRecords 因这两类原因关闭的 record,其迟到的
226
- * kickOffBackground.then 完成回注不得注入新 session——/new、/fork 的决策([v4 A-6])
213
+ * 轮次完成回注不得注入新 session——/new、/fork 的决策([v4 A-6])
227
214
  * 是「被关 record 的告知改由 list 的 closedReason 表达」,不主动通知;旧门只排除
228
215
  * cancelled,parent-new/parent-fork 放行 → 新对话被「Subagent X failed: closed due to
229
216
  * parent-new」的僵尸回执 triggerTurn 唤醒,已废弃会话的通知注入新上下文。 */
230
217
  const NOTIFY_BLOCKED_CLOSED_REASONS: ReadonlySet<ClosedReason> = new Set(["parent-new", "parent-fork"]);
231
218
 
232
219
  /**
233
- * [T4① / PS-2] kickOffBackground.then 完成回注的 notify 门(按 closedReason 白名单放行)。
220
+ * [T4① / PS-2] 轮次完成回注的 notify 门(按 closedReason 白名单放行)。
234
221
  *
235
222
  * cancelled(cancelBackground 自己 notify)与 parent-new/parent-fork(编排性关闭,
236
223
  * 告知由 list 的 closedReason 表达)不放行;其余(undefined = 本路径抢到 CAS 尚未
@@ -303,16 +290,12 @@ export class SubagentService {
303
290
  * (collectRecords filter 用,与 createRecordForMode 的 rootSessionId 盖章同源——子进程
304
291
  * 因此看到整棵 ROOT 树)。设计见 recursive-subagent-visibility.md 决策 3。 */
305
292
  private sessionRootId: string | null = null;
306
- /** 进程级执行上下文基线(不依赖 ALS 贯穿——pi RPC mode 的 stdin JSONL 是事件回调式
307
- * (attachJsonlLineReader stream.on("data")),每个命令是独立异步链,initSession
308
- * execCtxAls.enterWith store 不会贯穿到后续 tool 调用事件(实测:递归第二层
309
- * parentRecordId/depth 丢失而 rootSessionId 正确——rootSessionId 是实例字段所以不受影响)。
310
- * 基线 = 本进程自己的身份(initSession 从 env 读取,与 sessionRootId 同机制):
311
- * ALS store 失败时兜底,保证「本进程派发的 subagent 都是本进程记录的孩子」
312
- * 这一跨进程树形关系成立。
313
- * initSession 设置:有 env PI_SUBAGENT_SELF_RECORD_ID → {recordId: env 值, depth: env DEPTH};
314
- * 无 env(根进程)→ null(顶层)。 */
315
- private execCtxBaseline: { recordId: string | undefined; depth: number } | null = null;
293
+ /**
294
+ * [D3-⑤ 嵌套防护合一] 进程内执行嵌套上下文(原 execCtxAls 私有字段下沉公共层
295
+ * common/nesting-guard.ts ExecutionNestingContext——机制注释含 ALS 断裂基线兜底)。
296
+ * 实例 per-Service:基线随宿主进程身份而异(initSession env 建立)。
297
+ */
298
+ private readonly execNesting = new ExecutionNestingContext();
316
299
  /** fork 深度基线(同 ALS 断裂问题:forkDepthAls.getStore() 兜底用)。根进程=0。 */
317
300
  private forkDepthBaseline = 0;
318
301
  /** [MF-3] 所属根进程 cwd(sessions/records 落盘目录编码键)。
@@ -328,30 +311,83 @@ export class SubagentService {
328
311
  getStreamSink(): StreamSink | null { return this.streamSink; }
329
312
  private _disposed = false;
330
313
  private _seq = 0;
331
- /** background 完成通知器(滑动窗口合并 + 去重)。session_start revive,shutdown dispose。 */
332
- private readonly notifier: BgNotifier;
314
+ /** [D4-①] 通知簇 host 面(notifyComplete/notifyClosed/pending 注册注销 + notifier
315
+ * 实例封装,原私有通知簇四方法与模块函数的搬移落点——notify-host.ts)。
316
+ * deps 惰性求值(pi/session 级状态运行时可变),行为与原 constructor 内
317
+ * createNotifier(this.piAdapter()) 逐字节等价。session_start revive,shutdown dispose。 */
318
+ private readonly notifyHost: NotifyHost = createNotifyHost({
319
+ getPi: () => this.pi,
320
+ listRunning: () => this.store.listRunning(),
321
+ getIsIdle: () => this.isIdleFn,
322
+ });
333
323
  /** [MF#4][MF#2] fork 深度按 async 调用链传递(AsyncLocalStorage),替代共享可变计数器。
334
324
  * 主 session=0;fork 进入子 session 期间推进为子深度,供嵌套 fork 经 ALS 读到自身深度作为
335
325
  * parentForkDepth。并发 background fork 各自独立调用链,不再互相压低深度值。
336
326
  * [MF#2] 旧实现用单实例字段跨执行链共享 → 并发下 A 还原深度后 B 读到被压低值 → 护栏失效。 */
337
327
  private readonly forkDepthAls = new AsyncLocalStorage<number>();
338
328
 
339
- /** subagent 执行上下文按 async 调用链传递(当前正在跑的 record 身份 + 递归深度)。
340
- * B run() 期间包此 ALS,B 内创建 C 时 createRecordForMode 读到 B 的 recordId/depth,
341
- * 据此设 C.parentRecordId=B.id、C.depth=B.depth+1。主 session 链上无 store → 顶层。
342
- * forkDepthAls 独立:后者只数 fork 链(fork=true 才递增),本 ALS 数所有 subagent 嵌套。 */
343
- private readonly execCtxAls = new AsyncLocalStorage<{ recordId: string | undefined; depth: number }>();
344
-
345
- /** [review MF1] record 级在途 resume 守卫。resumeRound 全部守卫通过后 add,
346
- * runAndFinalize 结束(finally,覆盖轮次完成 / MF-6 失败回退 / abort / 终态化所有分支)时
347
- * delete(幂等:execute() 新建 record 不在集合,no-op)。窗口 = resume 发起(含 pool.acquire
348
- * 排队)→ 本轮 runAndFinalize 收尾。窗口内同 record 再次到达 resumeRound(冷路径重入 /
349
- * EPIPE 兜底)直接 throw——防两个 pi 子进程以 --session 同一 JSONL 双写 + 前一个脱离
350
- * kill 记账成孤儿(deliverMessage 冷路径的 acquireActivateLock 只覆盖 resumeRound 同步段,
351
- * 锁释放在子进程注册(session-runner spawnedChildren.set)之前,锁空洞由此守卫兜住;
352
- * EPIPE 兜底不持锁,同样被覆盖)。child 注册完成后 deliverMessage 走热路径,不经此守卫。 */
329
+ // [D3-⑤] subagent 执行上下文(record 身份 + 递归深度)的 ALS 传递已下沉公共层
330
+ // (execNesting 字段,common/nesting-guard.ts)——「B run() 期间挂身份,B 内创建 C
331
+ // 时读到 B」的机制与 ALS 断裂基线兜底注释见该文件。与 forkDepthAls 独立:后者只数
332
+ // fork 链(fork=true 才递增),嵌套上下文数所有 subagent 嵌套。
333
+
334
+ /** [review MF1] record 级在途 resume 守卫。冷路径续轮(resumeChatRound)全部守卫通过后
335
+ * add,runAndFinalize 结束(finally,覆盖轮次完成 / MF-6 失败回退 / abort / 终态化所有
336
+ * 分支)时 delete(幂等:execute() 新建 record 不在集合,no-op)。窗口 = resume 发起
337
+ * (含 pool.acquire 排队)→ 本轮 runAndFinalize 收尾。窗口内同 record 再次到达续轮
338
+ * (冷路径重入 / EPIPE 兜底)直接 throw——防两个 pi 子进程以 --session 同一 JSONL 双写 +
339
+ * 前一个脱离 kill 记账成孤儿(冷路径的 acquireActivateLock 只覆盖续轮同步段,锁释放
340
+ * 在子进程注册(session-runner spawnedChildren.set)之前,锁空洞由此守卫兜住;EPIPE
341
+ * 兜底不持锁,同样被覆盖)。child 注册完成后投递走热路径,不经此守卫。 */
353
342
  private readonly resumesInFlight = new Set<string>();
354
343
 
344
+ /** chat 域 pi 引擎实例(D2 单轨:chat 域执行/投递统一经 EnginePort)。per-service DI——
345
+ * getService 经适配器绑本实例;registry 全局 'pi' 单例绑进程级 getSubagentService(),
346
+ * 直构 Service 的测试场景解析不到本实例。不能 import registration.ts(其 import 本文件
347
+ * → 循环依赖),直接构造 PiEngine(pi-engine 不反向依赖本文件)。 */
348
+ private readonly chatPiEngine: PiEngine = new PiEngine({ getService: () => this.piEngineServiceAdapter() });
349
+
350
+ /** chat 域轮次交接包(executeViaEngine / 冷路径续轮挂载 → PiEngine.run 经 taskId 消费)。 */
351
+ private readonly chatRoundTickets = new Map<string, ChatRoundTicket>();
352
+
353
+ /** [D4-②] 轮次结算回调(原 buildSessionRunnerContext 内的 onRoundSettled 业务闭包
354
+ * 搬移至 round-settlement.ts;deps 回调闭包惰性求值,session-runner agent_settled 时消费)。 */
355
+ private readonly settleRound = createRoundSettler({
356
+ notifyComplete: (record) => this.notifyHost.notifyComplete(record),
357
+ reportRecordTransition: (record) => this.store.reportRecordTransition(record),
358
+ closeAfterRoundSettled: (record) => this.closeAfterRoundSettled(record),
359
+ });
360
+
361
+ /** [D4-③] 冷路径复活依赖(原四件 private 方法的搬移落点——cold-resurrect.ts;
362
+ * deps 闭包惰性求值:sessionRootId / execNesting 基线运行时可变)。 */
363
+ private readonly coldResurrectDeps: ColdResurrectDeps = {
364
+ findLightById: (id) => this.store.findLightById(id),
365
+ collectRecords: (limit, statusFilter, rootFilter) =>
366
+ this.store.collectRecords(limit, statusFilter, rootFilter),
367
+ register: (record) => this.store.register(record),
368
+ reportRecordTransition: (record) => this.store.reportRecordTransition(record),
369
+ getSessionRootId: () => this.sessionRootId,
370
+ getBaselineRecordId: () => this.execNesting.baseline()?.recordId ?? undefined,
371
+ };
372
+
373
+ /** [D4 查询面聚合] 读模型消费面(壳 interface/ 视图与 tool 查询经此访问;
374
+ * 纯委托——方法本体保留 private 实现不重写,行为逐字节等价)。 */
375
+ readonly queries: SubagentQueries = {
376
+ findRecord: (id) => this.findRecord(id),
377
+ lookupRecordAnyState: (id) => this.lookupRecordAnyState(id),
378
+ collectRecords: (limit, statusFilter) => this.collectRecords(limit, statusFilter),
379
+ getFullRecord: (id) => this.getFullRecord(id),
380
+ onChange: (listener) => this.onChange(listener),
381
+ };
382
+
383
+ /** [D4 对话 action 面聚合] chat 域 message/close 消费面(壳 subagent-actions 经此访问;
384
+ * 纯委托同上。PiEngineService 适配器不经此——引擎边界走 piEngineServiceAdapter)。 */
385
+ readonly chatActions: SubagentChatActions = {
386
+ getRecordForAction: (id, opts) => this.getRecordForAction(id, opts),
387
+ closeSubagent: (record, force) => this.closeSubagent(record, force),
388
+ deliverChatMessage: (record, text, interrupt) => this.deliverChatMessage(record, text, interrupt),
389
+ };
390
+
355
391
  private readonly manifestStore: ManifestStore;
356
392
 
357
393
  /**
@@ -380,7 +416,6 @@ export class SubagentService {
380
416
  this.sessionsDir = sessionsDir;
381
417
  this.manifestStore = new ManifestStore(recordsDir);
382
418
  this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
383
- this.notifier = createNotifier(this.piAdapter());
384
419
  // #11:注册进程级 observability 单例——ui-request-queue.handleUiRequest 经
385
420
  // globalThis 桥接(notifyMissingHandlerGlobal)调到同一实例,共享
386
421
  // warnedMissingHandlerSessions 去重集合。未注册时 queue 走 fallback warn(不去重)。
@@ -389,20 +424,6 @@ export class SubagentService {
389
424
 
390
425
  // ── 生命周期(index.ts 调)──────────────────────────────
391
426
 
392
- /** 覆盖 UI 请求 handler(W3: index.ts session_start 时按 mode 注入 handler 后调)。
393
- * 委托 uiObservability 重置缺失告警去重——新 handler 就位后允许重新 warn。 */
394
- setUiRequestHandler(handler: UiRequestHandler | undefined): void {
395
- this.uiRequestHandler = handler;
396
- this.uiObservability.resetMissingHandlerWarnings();
397
- }
398
-
399
- /** session-runner handleUiRequest 在 handler 缺失时调用(FR-9 可观测性)。
400
- * 委托 uiObservability:按 session 去重,同一 session 的多次 UI 请求只 warn 一次。
401
- * W2: console.warn 兜底。W3 接入 pi.appendEntry("subagent:ui-request-missing-handler", ...)。 */
402
- notifyMissingHandler(sessionId: string): void {
403
- this.uiObservability.notifyMissingHandler(sessionId);
404
- }
405
-
406
427
  /** session_start 注入 pi + revive(modelRegistry/entries 归 ModelConfigService.initModel)。 */
407
428
  initSession(init: SubagentServiceSessionInit): void {
408
429
  this.pi = init.pi;
@@ -415,10 +436,12 @@ export class SubagentService {
415
436
  this.mainSessionFile = init.mainSessionFile;
416
437
  this.streamSink = init.streamSink ?? null;
417
438
  this.isIdleFn = init.isIdle;
418
- // 读取 mode(W4 守卫透传给 session-runner)+ session 级 handler 覆盖。
439
+ // 读取 mode(W4 守卫透传给 session-runner)+ session 级 handler 覆盖
440
+ //([D4-④] initSession.uiRequestHandler 是唯一注入入口;三态语义见接口注释——
441
+ //null = 显式清空,承载原 setUiRequestHandler(undefined) 语义)。
419
442
  this.uiObservability.setMode(init.mode);
420
443
  if (init.uiRequestHandler !== undefined) {
421
- this.uiRequestHandler = init.uiRequestHandler;
444
+ this.uiRequestHandler = init.uiRequestHandler ?? undefined;
422
445
  this.uiObservability.resetMissingHandlerWarnings();
423
446
  }
424
447
  // SR-4:注入 L2 dialog 队列(child close 清理路径)。undefined 时 buildSessionRunnerContext
@@ -437,7 +460,7 @@ export class SubagentService {
437
460
  // revive(dispose 的逆操作:/resume /fork /new 后复活)
438
461
  this._disposed = false;
439
462
  this.store.revive();
440
- this.notifier.revive();
463
+ this.notifyHost.revive();
441
464
  // 孤儿终态恢复(放 initSession 末尾:setPi 已注入(appendEntry 可用)、
442
465
  // sessionRootId 已建立(过滤当前根的 record);单扫描者判据见 recoverOrphansIfRootProcess)
443
466
  this.recoverOrphansIfRootProcess();
@@ -462,7 +485,7 @@ export class SubagentService {
462
485
 
463
486
  /**
464
487
  * [递归可见性] exec 上下文基线:子进程读 env PI_SUBAGENT_SELF_RECORD_ID / DEPTH
465
- * 建立身份基线后,createRecordForMode 读 execCtxAls 自动正确(孙挂到子名下)。
488
+ * 建立身份基线后,createRecordForMode 读嵌套上下文自动正确(孙挂到子名下)。
466
489
  * enterWith 贯穿整个 session 生命周期(与 forkDepthAls 同构,决策 4)。
467
490
  */
468
491
  private initExecContextBaseline(envRoot: string | undefined, sessionId: string): void {
@@ -470,13 +493,14 @@ export class SubagentService {
470
493
  if (envSelfRecord !== undefined && envSelfRecord !== "") {
471
494
  const envNestingDepth = Number.parseInt(process.env[ENV_DEPTH] ?? "0", 10);
472
495
  const nestingDepth = Number.isNaN(envNestingDepth) ? 0 : envNestingDepth;
473
- // [ALS 断裂修复] 基线兜底:enterWith 在 pi 事件回调模型下不可靠(见 execCtxBaseline 注释),
474
- // 基线是 createRecordForMode / 护栏读 ALS store 失败时的权威回退。
475
- this.execCtxBaseline = { recordId: envSelfRecord, depth: nestingDepth };
476
- this.execCtxAls.enterWith({ recordId: envSelfRecord, depth: nestingDepth });
496
+ // [ALS 断裂修复] 基线兜底:enterWith 在 pi 事件回调模型下不可靠(机制注释见
497
+ // common/nesting-guard.ts ExecutionNestingContext),基线是 createRecordForMode /
498
+ // 护栏读 ALS store 失败时的权威回退。
499
+ this.execNesting.setBaseline({ recordId: envSelfRecord, depth: nestingDepth });
500
+ this.execNesting.enterWith({ recordId: envSelfRecord, depth: nestingDepth });
477
501
  if (process.env.XYZ_AGENT_DEBUG) {
478
502
  logger.debug(
479
- `[subagents] execCtxAls initialized: recordId=${envSelfRecord} depth=${nestingDepth} rootSessionId=${envRoot ?? sessionId}`,
503
+ `[subagents] execNesting initialized: recordId=${envSelfRecord} depth=${nestingDepth} rootSessionId=${envRoot ?? sessionId}`,
480
504
  );
481
505
  }
482
506
  }
@@ -493,7 +517,7 @@ export class SubagentService {
493
517
  *(hours-long wave 必然命中)→ 活记录被无关进程盖 .finalized sidecar,closed entry
494
518
  * 写进别的进程的 session 文件(跨进程互写,无任何锁)。子进程身份判据 = env
495
519
  * PI_SUBAGENT_SELF_RECORD_ID(父 spawn 时注入的「子进程自己的 record id」,仅子进程
496
- * 非空)——与 execCtxBaseline 同源。根进程恢复语义不变。
520
+ * 非空)——与 execNesting 基线同源。根进程恢复语义不变。
497
521
  */
498
522
  private recoverOrphansIfRootProcess(): void {
499
523
  const isChildProcess = (process.env[ENV_SELF_RECORD_ID] ?? "") !== "";
@@ -504,23 +528,24 @@ export class SubagentService {
504
528
  }
505
529
  }
506
530
 
507
- /** 孤儿终态恢复委托(RecordStore.recoverOrphanRecords 的唯一公开入口,维持 store
508
- * private 封装——与 recoverManifestTmpFiles 同模式)。判定语义见 store 侧注释。
531
+ /** 孤儿终态恢复委托(RecordStore.recoverOrphanRecords 的唯一调用入口,维持 store
532
+ * private 封装——与 recoverManifestTmpFiles 同模式;[D4] public 面收窄:唯一调用方
533
+ * 是 initSession,转 private)。判定语义见 store 侧注释。
509
534
  * 随后跑 entry-born 孤儿恢复(无子文件锚的 register-only record,spawn 窗口期死亡,
510
535
  * E2E 实测缺口)——主 session 文件经 getMainSessionFile 注入(构造期可空)。 */
511
- recoverOrphanRecords(): void {
536
+ private recoverOrphanRecords(): void {
512
537
  try {
513
538
  this.store.recoverOrphanRecords(this.sessionRootId ?? undefined);
514
539
  } catch (err) {
515
540
  logger.warn("[subagents] orphan recovery failed", {
516
- reason: err instanceof Error ? err.message : String(err),
541
+ reason: toErrorMessage(err),
517
542
  });
518
543
  }
519
544
  try {
520
545
  this.store.recoverEntryOnlyOrphans(this.mainSessionFile, this.sessionRootId ?? undefined);
521
546
  } catch (err) {
522
547
  logger.warn("[subagents] entry-only orphan recovery failed", {
523
- reason: err instanceof Error ? err.message : String(err),
548
+ reason: toErrorMessage(err),
524
549
  });
525
550
  }
526
551
  }
@@ -594,7 +619,7 @@ export class SubagentService {
594
619
  });
595
620
  }
596
621
  // pending-notifications 注销
597
- emitPendingUnregister(this.pi, record.id, "closed");
622
+ this.notifyHost.emitPendingUnregister(record.id, "closed");
598
623
  count++;
599
624
  }
600
625
  return count;
@@ -641,7 +666,10 @@ export class SubagentService {
641
666
  // [dispose stub] 第一时间换 stub,防 trailing ui_request 调到 stale handler 闭包
642
667
  // (仍持有 disposed session 的 ctx)产生误导性 console.error。stub 干净降级为 cancelled。
643
668
  // 必须在 emit/abort 之前——这些步骤可能同步触发 trailing pump。
644
- this.setUiRequestHandler(disposedUiRequestStub);
669
+ // [D4-④] 原 setUiRequestHandler 方法已删(initSession 参数为唯一注入入口),
670
+ // 此处内联其方法体(赋值 + 缺失告警去重重置)。
671
+ this.uiRequestHandler = disposedUiRequestStub;
672
+ this.uiObservability.resetMissingHandlerWarnings();
645
673
  // [R0/C1 孤儿进程修复] 先 abort running controllers + kill spawned children,再 dispose 资源。
646
674
  // abortRunningControllers 需要在 disposeAllRecords archive 之前执行(archive 后 store 找不到 record)。
647
675
  this.store.abortRunningControllers();
@@ -651,18 +679,20 @@ export class SubagentService {
651
679
  this.disposeAllRecords("parent-shutdown");
652
680
  // [v4 A-1] EPIPE 连续失败计数器清零(计数器已迁移到 stdin-writer,防跨 session 泄漏)
653
681
  resetAllEpipeFailures();
654
- // [review MF1] 在途 resume 守卫清空(正常由 runAndFinalize finally 清除;此处兜底
682
+ // [review MF1] 在途 resume 守卫清空(正常由轮次收尾 finally 清除;此处兜底
655
683
  // abort/kill 后仍挂着的条目,防跨 session 复活时残留)
656
684
  this.resumesInFlight.clear();
685
+ // chat 轮次交接包清空(正常由 PiEngine.run 消费;此处兜底 kill 后仍挂着的条目)
686
+ this.chatRoundTickets.clear();
657
687
  // flush 待发通知(session_shutdown 尽力投递一次)。
658
- this.notifier.flushPendingNotifications();
688
+ this.notifyHost.flushPendingNotifications();
659
689
  // [T4④ / PS-5] flush 被 isIdle 门拦时的丢失面闭合:attemptDeliver 的 isIdle 二次
660
690
  // 复查失败时 pending 挂在 ledger 内存态,随后 dispose 销毁——旧注释「flush 后 dispose
661
- // (防丢失)」的承诺与行为不符。现把未投递 pending 经 ledger entry 通道复写落盘
691
+ //(防丢失)」的承诺与行为不符。现把未投递 pending 经 ledger entry 通道复写落盘
662
692
  // (notifyId 幂等,重复 entry 由恢复扫描后写覆盖吸收),重启 recoverFromSession 按
663
693
  // 账面差集重放补投——「不丢」由落盘账本承接而非本次 flush。
664
694
  this.persistUndeliveredNotificationsForReplay();
665
- this.notifier.dispose();
695
+ this.notifyHost.dispose();
666
696
  this.store.dispose();
667
697
  }
668
698
 
@@ -701,108 +731,9 @@ export class SubagentService {
701
731
 
702
732
  // ── 执行(subagent-tool 调)────────────────────────────
703
733
 
704
- /** background 完成回注(record BgNotifyRecord 映射 + notifier.notify)。
705
- * 正在执行(running + 活进程 + timer-armed)静默跳过——notify 只对 closed(终态)、
706
- * isIdle(chatMode 轮次完成)或 isResumable(SP-5 one-shot 成功完成 / MF-6 失败轮回退)有意义。
707
- * SP-1: closed 统一终态(done/failed/crashed 合并),closedReason 携带 L2 原因。 */
708
- private notifyComplete(record: ExecutionRecord): void {
709
- const notify = this.toNotifyRecord(record);
710
- if (notify) this.notifier.notify(notify);
711
- }
712
-
713
- /** [C-1] chatMode close 终态通知(设计 D2:正文空/本轮增量 + sessionFile 指针行)。
714
- *
715
- * 与 notifyComplete 的差异只在 dedup 身份与轮次统计:终态通知必须与最后一轮的轮次通知
716
- * 区分(轮次通知 key=`id:round`),否则同 key 被 60s dedup 吞——close 后父 agent 永远
717
- * 收不到带指针行的终态通知(审查 C-1)。故 round 置 undefined(key 回退为裸 id),
718
- * 轮数改经 totalRounds 进文案 "completed after N rounds."(C-2)。
719
- *
720
- * 仅 chatMode close 语义调用(closeChatIdle / closeAfterRoundSettled 终态化成功后)。
721
- * one-shot 显式拒绝(G4:one-shot close 路径现状无终态通知,字节不变);cancel 走
722
- * cancelBackground 自己的 notifyComplete,不经本方法。幂等性:两条 close 路径均由
723
- * closeSubagent 的 status 分流守卫(closed 后幂等 no-op)/ CAS 抢锁保证只执行一次,
724
- * 本方法自身不重复发送;迟到的 kickOffBackground.then 通知与轮次通知同 key=`id:round`,
725
- * 60s 窗内仍被吞,不构成第三条。 */
726
- /** @param emptyBody true = 终态通知正文置空串(D2 路径②)。W16 P-1 修复后
727
- * closeChatIdle 的 doneResult.text 改用 record.result 保真(close 终态
728
- * subagent-record entry 的 result 不抹空轮终真实值),「正文空」不再由合成空
729
- * text 的副作用承载,改为显式参数——持久化 result 与通知正文两个关注点解耦。 */
730
- private notifyClosed(record: ExecutionRecord, emptyBody = false): void {
731
- if (!record.chatMode) return;
732
- const notify = this.toNotifyRecord(record);
733
- if (!notify) return;
734
- notify.round = undefined;
735
- if (emptyBody) notify.result = "";
736
- if (record.round != null) notify.totalRounds = record.round;
737
- this.notifier.notify(notify);
738
- }
739
-
740
- /** notifier 的 NotifierHost 适配器(绑定到 pi.sendMessage + store 查询)。 */
741
- private piAdapter(): NotifierHost {
742
- return {
743
- sendMessage: (message, options) => {
744
- this.pi?.sendMessage(message, options);
745
- },
746
- hasRunningBackground: () => {
747
- // [M3] 「在跑的 background 工作」= 有活进程且非等待续聊(idle timer armed)。
748
- // v4 B-1 把旧 idle 折入 running 且 record 留 store:轮次完成的 chatMode record
749
- // (timer armed、无在跑轮)与 one-shot 完成后等待 message 升级的 record 都不再计入。
750
- // 旧判定 `mode === "background"` 对这两类恒 true → 轮次完成通知恒挂 60s 合并窗口
751
- // (notifier MERGE_WINDOW_MS),主 agent 的续聊回复固定延迟 60s 送达,持续对话(G1)失效。
752
- return this.store.listRunning().some(
753
- (r) => r.mode === "background" && hasLiveProcessHandle(r.id) && !hasIdleTimer(r.id),
754
- );
755
- },
756
- isIdle: () => this.isIdleFn?.() ?? true,
757
- // [must-fix #4 / D8] settled 边沿订阅,与 isIdle 同源(session_start 注入的 pi)。
758
- // 只注入原生订阅能力;disposed 标志包装(退订语义)在 notifier 的 port 装配完成。
759
- onAgentSettled: (handler) => { this.pi?.on?.("agent_settled", handler); },
760
- };
761
- }
762
-
763
- /** record → BgNotifyRecord(notifier.notify 入参映射,内部不外露)。
764
- * v4 B-1:守卫放行 closed(终态,含 cancelled)、isIdle(对话模式轮次完成,notify 主 agent G1)
765
- * 或 isResumable(running + 无活进程——SP-5 one-shot 成功完成 / MF-6 失败轮回退)。
766
- * 正在执行(running + 活进程 + 非 timer-armed)返回 undefined(调用方 notifyComplete 跳过)。
767
- * SP-1: closed 统一终态,closedReason 由 BgNotifyRecord 携带。 */
768
- private toNotifyRecord(record: ExecutionRecord): BgNotifyRecord | undefined {
769
- const snap = snapshot(record);
770
- const s = snap.status;
771
- // [N1] isResumable 放行:SP-5 one-shot 成功完成后 finalizeRoundToIdle 把 record 回退
772
- // running-resumable——进程已死且永不 arm idle timer(armIdleTimer 只在 agent_settled 的
773
- // chatMode 分支调用),旧守卫(closed / isIdle only)对其恒拒绝 → 完成通知静默丢失,
774
- // 而 one-shot 失败走 finalizeRecord 保持 closed 反而通知——与 tool 契约「runs once,
775
- // notifies on completion」完全倒置。isResumable = running + 无活进程,恰为该完成态;
776
- // 在跑轮的 record 有活进程,不会被误放行。
777
- if (s !== "closed" && !isIdle(record) && !isResumable(record)) return undefined;
778
- // closed → BgNotifyRecord.closed(cancelled 区分靠 closedReason);chatMode 的 isIdle/
779
- // isResumable(轮次完成或 MF-6 失败轮回退,对话可续)→ running(轮次完成)。
780
- // isResumable 且非 chatMode(SP-5 one-shot 成功完成)→ closed:对主 agent 的语义是
781
- // completed(非对话轮次),且只有 closed 分支文案携带 worktree patchFile 的 git apply
782
- // 提示——one-shot worktree 模式的改动回收依赖该提示(running 分支文案不含 patchFile)。
783
- const notifyStatus: BgNotifyRecord["status"] =
784
- s === "closed" || !record.chatMode ? "closed" : "running";
785
- return {
786
- id: snap.id,
787
- status: notifyStatus,
788
- agent: snap.agent,
789
- model: snap.model,
790
- result: snap.result,
791
- error: snap.error,
792
- startedAt: snap.startedAt,
793
- endedAt: snap.endedAt,
794
- patchFile: record.patchFile,
795
- // round 透传给 notifier 的 dedup key(对话模式按轮次去重,G1 决策 9)。
796
- round: record.round,
797
- // SP-1: closedReason 透传给 notifier(L2 原因,供通知文案按需展示)。
798
- closedReason: record.closedReason,
799
- // [wave2] chatMode 条件透传 sessionFile:通知末尾追加 Full transcript 指针行
800
- //(增量语义的全文恢复通道,见 notifier.buildLlmContent)。one-shot(chatMode
801
- // falsy)不透传——通知输出逐字节不变(G4),该条件由 message-close 测试的
802
- // 必选用例锁死(漏加条件时 notifier 单测不红——notifier 层只见最终字段)。
803
- sessionFile: record.chatMode ? record.sessionFile : undefined,
804
- };
805
- }
734
+ // [D4-①] 通知簇四方法(notifyComplete / notifyClosed / piAdapter / toNotifyRecord)
735
+ // emitPendingRegister / emitPendingUnregister 模块函数已整体搬移至 notify-host.ts
736
+ //(本类经 this.notifyHost 消费;行为逐字节等价,搬移 + 依赖注入)。
806
737
 
807
738
  /**
808
739
  * 预解析 model(renderCall 标题行用,同步)。代理 modelService.resolveModel。
@@ -828,13 +759,14 @@ export class SubagentService {
828
759
  // [T4② / PS-4] idleTimeoutMs 配置错误在首个副作用前同步 fail-fast(错误含合法范围)。
829
760
  this.assertIdleTimeoutMsSafe(opts);
830
761
 
831
- // 通用嵌套深度护栏(D-033):execCtxAls 记录所有 subagent 嵌套层级(fork + 非 fork),
832
- // 每层 +1。MAX_FORK_DEPTH 同时限 fork 链与通用嵌套——非 fork 递归虽不累积 session 体积,
833
- // 但耗资源且 LLM 易陷入「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
762
+ // 通用嵌套深度护栏(D-033):嵌套上下文([D3-⑤] 公共层 ExecutionNestingContext)
763
+ // 记录所有 subagent 嵌套层级(fork + fork),每层 +1。MAX_FORK_DEPTH 同时限
764
+ // fork 链与通用嵌套——非 fork 递归虽不累积 session 体积,但耗资源且 LLM 易陷入
765
+ // 「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
834
766
  // 计数基准:顶层 nestingDepth=0,nestingDepth>MAX 被拒。与 fork 体积护栏(parentForkDepth 检查)
835
767
  // 互补:本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
836
- // [ALS 断裂修复] getStore() pi 事件回调模型下可能读空(enterWith 不贯穿),基线兜底。
837
- const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
768
+ // [ALS 断裂修复] current() 内含基线兜底(pi 事件回调模型下 enterWith 不贯穿)。
769
+ const parentNesting = this.execNesting.current();
838
770
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
839
771
  if (nestingDepth > MAX_FORK_DEPTH) {
840
772
  throw new ForkDepthExceededError(
@@ -844,92 +776,41 @@ export class SubagentService {
844
776
 
845
777
  // mode 固定 background(sync 模式已删除)
846
778
  const mode: ExecutionMode = "background";
847
- const ctx = this.buildSessionRunnerContext(opts.cwd);
848
779
 
849
- // ── 1. IDENTITY 解析(确认 agentConfig → resolveModel)──
850
- const identity = await this.resolveIdentity(opts);
780
+ // ── 1. IDENTITY 前置解析:agentConfig(agent .md 加载)保持在最前 ──
781
+ // [u-h2 D2-1] 路由先行:model 解析从「路由之前」移到「路由之后、按目标引擎分支」
782
+ // (修 F2-A/B 时序根因——曾 :850 先解析 model 再 :867 路由)。agentConfig 是路由
783
+ // 第二层输入(frontmatter engine)必须先解析;显式 agent ref 校验语义不变。
784
+ const agent = opts.agent ?? DEFAULT_AGENT_NAME;
785
+ const agentConfig = opts.agent
786
+ ? this.modelService.getRequiredAgentConfig(opts.agent)
787
+ : undefined;
851
788
 
852
- // ── 1.5 引擎路由(D4 chat 入口分叉;U2 升级为 routeEngine 编排)──
853
- // 三层解析(调用参数 > agent frontmatter > config.json defaultEngine)仍是同步纯
854
- // 函数;解析为非 pi 时升级走 routeEngine(probe 编排 + fallback 三守卫)。时机
855
- // 选择:路由(含 probe)在 record 创建前完成——兜底时 record 直接按 pi 语义创建 +
856
- // engineFallback 留痕(D5 字节级守护只约束「无 fallback 的纯缺省路径」,兜底路径
857
- // entry 允许含 engine/engineFallback 字段);守卫命中/strict 时 routeEngine 在此
858
- // throw,不产生孤儿 record。
789
+ // ── 1.5 引擎路由(D2 单轨 + D3-② 路由单点:统一经 routeEngineForHost)──
790
+ // 唯一实现在 engine/routing.ts(pi 同步短路 + registry 注入 + 兜底回本地 pi 实例
791
+ // 收敛于此);本调用点只装配三层输入与注入件。时机:路由(含 probe)在 record
792
+ // 创建前完成——兜底时 record pi 语义创建 + engineFallback 留痕(D5 字节级守护
793
+ // 只约束「无 fallback 的纯缺省路径」);守卫命中/strict 时在此 throw,不产生孤儿
794
+ // record。pi 请求路径同步短路(routed Promise,零微任务——缺省路径时序不变)。
795
+ // [u-h2 D2-1] 路由先行于 pi 链 model 解析:agentConfig 是路由第二层输入(frontmatter
796
+ // engine)已前置解析;pi 的 resolveModel 移到路由之后、按目标引擎分支执行——非 pi
797
+ // 请求不被 pi registry 解析错误拦截(F2-A/B 时序根因),model 校验归目标引擎(D2-2)。
859
798
  const routingInput = {
860
799
  callEngine: opts.engine,
861
- agentEngine: identity.agentConfig?.engine,
800
+ agentEngine: agentConfig?.engine,
862
801
  globalDefaultEngine: this.modelService.getGlobalConfig().defaultEngine,
863
802
  };
864
- const routing = resolveEngineRouting(routingInput);
865
- let route: EngineRouteResult | undefined;
866
- if (routing.engineId !== DEFAULT_ENGINE_ID) {
867
- route = await routeEngine({
868
- routing: routingInput,
869
- // 守卫 c 判据只看调用方显式指定的 model(resolved model 含 ctxModel 兼底,
870
- // 恒非空会把一切兜底误判为 model 绑定命中)
871
- taskModel: opts.model,
872
- strict: this.modelService.getGlobalConfig().engineRouting?.strict === true,
873
- probe: (engineId) => getEngine(engineId).probe(),
874
- });
875
- if (route.engineId !== DEFAULT_ENGINE_ID) {
876
- return this.executeViaEngine(opts, identity, route);
877
- }
878
- // 兜底成功(典型:默认路由 + probe 失败 + 无守卫命中)→ 落回下方 pi 主路径,
879
- // record 创建时按 pi 语义 + engine/engineFallback 留痕(engine = 实际执行引擎)
880
- }
881
- // D5 字节级守护:无 fallback 的 pi 路由剥掉 opts.engine——createRecordForMode
882
- // 不盖章(pi record entry 序列化产物不得新增 engine 键,undefined 经 JSON 省略)。
883
- // 兜底路径显式盖 engine='pi' + engineFallback(见上方时机注释)。
884
- const piOpts =
885
- route?.engineFallback !== undefined
886
- ? { ...opts, engine: DEFAULT_ENGINE_ID, engineFallback: route.engineFallback }
887
- : opts.engine === undefined
888
- ? opts
889
- : { ...opts, engine: undefined };
890
-
891
- // ── 2. RECORD 创建 + 注册 ──
892
- const record = this.createRecordForMode(identity, piOpts, mode);
893
- emitPendingRegister(this.pi, record.id, record.agent);
894
-
895
- // ── 2.5 worktree 创建(仅 worktree===true 或已传入 handle 时)──
896
- // record 先创建,worktree 失败时可 finalizeFailed(record 已在 store 中)。
897
- // worktree 必须显式开启:worktree===true 创建新 worktree;worktree===undefined/false 不创建。
898
- // fork 不隐含 worktree(UC-1 fork 可独立使用,fork 仅继承上下文,在 parent cwd 跑)。
899
- let worktreeHandle: WorktreeHandle | undefined;
900
- if (typeof opts.worktree === "object") {
901
- // 传入的是已创建的 WorktreeHandle
902
- worktreeHandle = opts.worktree;
903
- } else if (opts.worktree === true) {
904
- // worktree===true(显式要求)——创建新 worktree。与 fork 正交(worktree 文件隔离不依赖 fork 上下文继承)。
905
- try {
906
- worktreeHandle = await this.worktreeManager.create(this.cwd, record.id);
907
- record.worktreeHandle = worktreeHandle;
908
- // [create-await 竞态守卫] create 的 await 窗口内 cancel/dispose 可 CAS 把 record
909
- // 转成 closed 终态——cancelBackground 当时读到的 worktreeHandle 可能仍是 undefined
910
- // (cleanup 被跳过)。赋值后同同步段检查终态:closed 则主动 cleanup(幂等,抢先的
911
- // fire-and-forget 清理无害)+ early-failed 返回,不进 kickOffBackground(避免子进程白跑)。
912
- // 实现约束:赋值 → 终态检查 → kickOffBackground 必须在同一同步段,中间禁止插入 await。
913
- if (record.status === "closed") {
914
- await this.worktreeManager.cleanup(worktreeHandle);
915
- return this.buildEarlyFailedHandle(record);
916
- }
917
- } catch (err) {
918
- // create 失败→不进入 run,finalizeFailed 统一收尾(含 emitPendingUnregister failed)
919
- const _result = await this.finalizeFailed(record, err);
920
- return this.buildEarlyFailedHandle(record);
921
- }
922
- }
923
-
924
- // ── 3. MODE 固定 background:signal/controller、priority 固定 ──
925
- const signal = record.controller!.signal;
926
- const priority = PRIORITY_BACKGROUND;
927
-
928
- // ── 4-7. background 包 detached 立即返回 id ──
929
- // background detached 运行对 tool 层不可见,完成由 notify 驱动新 turn。
930
- const bgDetails = project(record);
931
- this.kickOffBackground(record, { ...piOpts, worktree: worktreeHandle }, ctx, identity, signal, priority);
932
- return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details: bgDetails };
803
+ const routed = routeEngineForHost({
804
+ routing: routingInput,
805
+ // 守卫 c 判据只看调用方显式指定的 model(resolved model 含 ctxModel 兼底,
806
+ // 恒非空会把一切兜底误判为 model 绑定命中)
807
+ taskModel: opts.model,
808
+ strict: this.modelService.getGlobalConfig().engineRouting?.strict === true,
809
+ probe: (engineId) => getEngine(engineId).probe(),
810
+ piEngine: this.chatPiEngine,
811
+ });
812
+ const route: EngineRouteResult = routed instanceof Promise ? await routed : routed;
813
+ return this.executeViaEngine(opts, { agent, agentConfig }, route, mode);
933
814
  }
934
815
 
935
816
  /**
@@ -938,7 +819,7 @@ export class SubagentService {
938
819
  * 供 tool 层 cancelHandler 翻译 throw 用(id 不存在 / mode / 终态三种错误)。
939
820
  * 不存在返回 undefined。
940
821
  */
941
- findRecord(id: string): RecordSnapshot | undefined {
822
+ private findRecord(id: string): RecordSnapshot | undefined {
942
823
  this.assertReady();
943
824
  const record = this.store.getMutable(id);
944
825
  return record ? snapshot(record) : undefined;
@@ -963,7 +844,7 @@ export class SubagentService {
963
844
  *
964
845
  * 返回 undefined:id 在内存与磁盘均不存在。
965
846
  */
966
- lookupRecordAnyState(id: string): SubagentRecord | undefined {
847
+ private lookupRecordAnyState(id: string): SubagentRecord | undefined {
967
848
  try {
968
849
  this.assertReady();
969
850
  } catch {
@@ -977,33 +858,81 @@ export class SubagentService {
977
858
  // ── 对话模式投递(M2-B3 message action 调用)──────────────
978
859
 
979
860
  // [review 修复] 已删除 deliverToRunning(busy follow_up/steer 投递 + pendingMessages
980
- // 消费确认制):SP-5 upgrade 后所有 running record 走 chatMode 分支 → deliverMessage
981
- // 统一投递(热路径 prompt+streamingBehavior / 冷路径 resume),该方法无生产调用方,
861
+ // 消费确认制):SP-5 upgrade 后所有 running record 走 chatMode 分支 → 统一投递
862
+ //(热路径 prompt+streamingBehavior / 冷路径 resume),该方法无生产调用方,
982
863
  // 其配套三段消费链(push / message_start shift / redeliverPending 补投)全部不可达,
983
864
  // 一并移除(详见各文件同步删除)。
865
+ // [D2 单轨] 投递的 pi RPC stdin 协议知识(stdin prompt 命令直调 + streamingBehavior
866
+ // 映射 + EPIPE 兜底 + 冷路径分流)已下沉 PiEngine.deliverPrompt——本层经
867
+ // deliverChatMessage → PiEngine.interactRecord 调用,见下方两方法。
984
868
 
985
869
  /**
986
- * idle 投递:resume spawn 开启新一轮对话(设计决策 6 idle 分支)。
870
+ * [V2 决策 3] chatMode 统一投递入口(message action 的 Service 面)——经引擎交互面
871
+ * 执行(D2:PiEngine.interactRecord——port face interact 的 record 锚定形态,协议知识
872
+ * 在引擎边界,编排层不做 stdin 写入)。分流语义(按**进程死活**,
873
+ * 不按 record.status)与热/冷路径细节见 PiEngine.deliverPrompt:
987
874
  *
988
- * record 必须 idle(轮次完成、进程已回收、record 留内存)。手动把 status 设回 "running"
989
- * (M2-A 边界:idle→running 是恢复非终态,绕过 tryTransition——tryTransition 要求当前态
990
- * running 才 CAS,idle record 直接进 runAndFinalize 会被 tryTransition 拒绝转态)。
875
+ * 热路径(进程活):prompt + streamingBehavior——pi 权威裁决 busy/idle(F3/F4)。
876
+ * 冷路径(进程死):冷路径续轮(resumeChatRound)重开 session + prompt(仅崩溃/
877
+ * timeout kill/跨重启命中)。
878
+ *
879
+ * 失败语义与直调形态一致:业务拒绝(not ready / EPIPE 兜底耗尽等,文案自带行动语言)
880
+ * 经 interact 结构化结果回传后原样 throw(错误文本逐字节保持)。
881
+ *
882
+ * @param record 目标 record(chatMode,running 或 idle)
883
+ * @param text 消息正文
884
+ * @param interrupt true=steer(抢占)/ false=followUp(排队),仅热路径 streamingBehavior 用
885
+ */
886
+ private async deliverChatMessage(record: ExecutionRecord, text: string, interrupt: boolean): Promise<void> {
887
+ this.assertReady();
888
+ // interactRecord:interact 的 record 锚定形态(调用方已持归属校验过的同一 record
889
+ // 对象——port face interact 的 handle 解析在此冗余且会做二次 store 查找)
890
+ const result = await this.chatPiEngine.interactRecord(
891
+ record,
892
+ { kind: "message", payload: text, interrupt },
893
+ );
894
+ if (!result.ok) {
895
+ throw new Error(result.message);
896
+ }
897
+ // [T2③ / D9 两段式] 热路径轮 settled 等待守护(双挂载原语之热路径调用点,首轮
898
+ // 调用点在 session-runner runSpawn)。原挂载位在 deliverMessage prompt 写入成功后
899
+ // 的同步段——D2 协议知识下沉 PiEngine.deliverPrompt 后编排层在 interact 返回点
900
+ // arm(deliverPrompt 热路径段无 await,返回点距 prompt 发出仅差微任务链;窗口口径
901
+ // 「整轮含 turn 执行与收尾」不受影响)。冷路径(EPIPE 兜底/resume)下 runSpawn 的
902
+ // 首轮 arm 与此处重复挂载由原语幂等(先清旧 timer)吸收 = 窗口重置,无害。
903
+ // prompt 发出后挂**中段**无进展检测(有效协议事件行刷新,连续静默判 wedged)+
904
+ // agent_end 交棒收尾段固定上界;settled 到达(PiEngine.handleSdkEvent 的 disarm)/
905
+ // 进程 close / cancel/close 终态化处置后即清(disarm 站点散布上述路径,幂等)。
906
+ armMidRoundNoProgress(record.id, {
907
+ onMidTimeout: (fire) => this.onHotPathSettledWatchdogTimeout(record, fire),
908
+ onSettleTimeout: (fire) => this.onHotPathSettledWatchdogTimeout(record, fire),
909
+ });
910
+ }
911
+
912
+ /**
913
+ * 冷路径续轮(PiEngine.deliverPrompt 的编排回调,D2 下沉后的归属):resume spawn
914
+ * 开启新一轮对话(设计决策 6 idle 分支)。仅进程死(idle timer reap / 崩溃 / 跨重启
915
+ * / EPIPE 兜底)时经引擎到达。
916
+ *
917
+ * record 必须 idle-resumable(轮次完成、进程已回收、record 留内存)。手动把 status
918
+ * 设回 "running"(M2-A 边界:idle→running 是恢复非终态,绕过 tryTransition——
919
+ * tryTransition 要求当前态 running 才 CAS)。
991
920
  *
992
921
  * resume 参数从 record identity 读(防多轮对话模型漂移,探针 P-10):sessionFile、
993
922
  * model、thinkingLevel 均为 record 身份字段(创建时确定、不可变)。maxTurns/schema 等
994
923
  * 执行约束第一版不恢复(设计 §5 拆分 1 待验证检查点),agentConfig 用 undefined
995
- * (pi --session 续写保留上下文,agent 行为由 session 内 messages 决定;M2-B3 messageHandler 可完善)。
924
+ *(pi --session 续写保留上下文,agent 行为由 session 内 messages 决定;M2-B3 messageHandler 可完善)。
996
925
  *
997
- * detached 编排(参照 kickOffBackground):不 await,runAndFinalize background 跑。
998
- * chatMode + done runAndFinalize 的 M2-A 分流自动把 record 重新置 idle。并发槽在
999
- * runAndFinalize 内重新 acquire(轮次间 idle 已 release);pool.acquire 是排队模型,
926
+ * detached 编排(kickOffChatRound,经 EnginePort 交接):不 await,轮次在 background 跑。
927
+ * chatMode + done 时轮次收尾的 M2-A 分流自动把 record 回退 idle-resumable。并发槽在
928
+ * 轮次执行内重新 acquire(轮次间 idle 已 release);pool.acquire 是排队模型,
1000
929
  * 池满时排队等待槽位而非 throw(与 execute 一致)。
1001
930
  *
1002
- * @param record 目标 record(必须 idle)
931
+ * @param record 目标 record(必须 idle-resumable
1003
932
  * @param text 新一轮消息正文
1004
- * @throws Error record 非 idle / 无 sessionFile / 无 controller
933
+ * @throws Error record 非 running / 无 sessionFile / 无 controller / worktree 绑定丢失 / 续轮在途
1005
934
  */
1006
- resumeRound(record: ExecutionRecord, text: string): void {
935
+ private resumeColdRound(record: ExecutionRecord, text: string): void {
1007
936
  this.assertReady();
1008
937
  // [CL-b1-cas-coupling / v4 B-1] v4 把旧 idle 折入 running 后此守卫对 idle-resumable
1009
938
  // record 恒放行(idle 本来就是 running),`status = "running"`(下方)是幂等写——
@@ -1017,10 +946,10 @@ export class SubagentService {
1017
946
  );
1018
947
  }
1019
948
  // [review MF1] 在途 resume 守卫:上一条消息发起的 resume 仍在途(spawn 尚未注册 /
1020
- // 本轮 runAndFinalize 未收尾)时,再次到达(冷路径重入 / EPIPE 兜底)直接拒绝。
949
+ // 本轮轮次未收尾)时,再次到达(冷路径重入 / EPIPE 兜底)直接拒绝。
1021
950
  // 触发链:pi 对同一 assistant message 的 tool calls 顺序执行(sequential),tool1 的
1022
- // deliverMessage 在冷路径 resumeRound 返回即 resolve(早于 spawn 注册完成),tool2
1023
- // 立即执行 → getChildByRecord 仍 undefined → 再次冷路径。无此守卫 → 两次 kickOff →
951
+ // 投递在冷路径续轮返回即 resolve(早于 spawn 注册完成),tool2 立即执行 →
952
+ // getChildByRecord 仍 undefined → 再次冷路径。无此守卫 → 两次 kickOff →
1024
953
  // runSpawn 2 次 → 两 pi 子进程双写同一 session JSONL + 第一个脱离 kill 记账成孤儿。
1025
954
  if (this.resumesInFlight.has(record.id)) {
1026
955
  // MF-4:行动语言。
@@ -1033,7 +962,7 @@ export class SubagentService {
1033
962
  if (!record.sessionFile) {
1034
963
  // MF-4:session 损坏 → canonical 文案(spec §3.1 失败表)。
1035
964
  throw new Error(
1036
- `session unavailable for subagent ${record.id} (session file missing or unreadable). ` +
965
+ `subagent ${record.id} session unavailable (session file missing or unreadable). ` +
1037
966
  `Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
1038
967
  );
1039
968
  }
@@ -1097,144 +1026,21 @@ export class SubagentService {
1097
1026
  };
1098
1027
  const ctx = this.buildSessionRunnerContext();
1099
1028
 
1100
- // detached 编排:runAndFinalize background 跑,pool 重新 acquire(轮次间 idle 已 release)。
1029
+ // detached 编排:轮次在 background 跑,pool 重新 acquire(轮次间 idle 已 release)。
1101
1030
  // chatMode + done 时 M2-A 分流自动 finalizeRoundToIdle(record 回 idle、round+1)。
1102
- // [review MF1] 在途标记在 kickOff 前同步设置:resumeRound 返回即生效,后续重入
1103
- // (冷路径 / EPIPE 兜底)在守卫处被拒;runAndFinalize finally 统一清除。
1031
+ // [review MF1] 在途标记在 kickOff 前同步设置:本方法返回即生效,后续重入
1032
+ // (冷路径 / EPIPE 兜底)在守卫处被拒;轮次收尾 finally 统一清除。
1104
1033
  this.resumesInFlight.add(record.id);
1105
- this.kickOffBackground(record, opts, ctx, identity, record.controller.signal, PRIORITY_BACKGROUND, resume);
1106
- }
1107
-
1108
- /**
1109
- * [V2 决策 3] chatMode 统一投递:按**进程死活**分流,不按 record.status。
1110
- *
1111
- * V2 进程长驻——chatMode record 首轮 agent_settled 后进轻量 idle(Step 4a:进程保活、
1112
- * idle timer armed),续聊时进程仍在内存,不该重开 session。故续聊投递不按 status
1113
- *(running/idle 都可能是热路径),而是判进程死活:
1114
- *
1115
- * 热路径(进程活):prompt + streamingBehavior——pi 权威裁决 busy/idle(F3/F4)。
1116
- * busy(isStreaming)时 followUp 入队/steer 抢占;idle 时 streamingBehavior 被忽略、
1117
- * 直接开新 turn。不用 steer/followUp 命令、不依赖 clearQueue(F8),结构上消除残留。
1118
- * 冷路径(进程死):复用 resumeRound 重开 session + prompt(仅崩溃/timeout kill/跨重启命中)。
1119
- *
1120
- * disarm idle timer:新 turn 开始必须 disarm(V2 决策 4),防 turn 期间 idle timer 误杀活进程。
1121
- *
1122
- * status 处理:判活分流后**各自**设 running——热路径手动设 running(新 turn 开始);
1123
- * 冷路径由 resumeRound 校验 idle 并自行设 running + spawn(故不在此预设 running,否则
1124
- * resumeRound 的 idle 检查会 throw)。resume spawn 后 session-runner 回填 record.pid,
1125
- * 热路径拿到 child 时也顺便刷新 pid(resume 重开进程后 pid 已变)。
1126
- *
1127
- * [review 修复] 曾对比的 deliverToRunning(非 chatMode busy 投递 + pendingMessages
1128
- * 消费确认制)已删除——SP-5 upgrade 后无生产调用方(V2 决策 3 已删消费确认制)。
1129
- *
1130
- * @param record 目标 record(chatMode,running 或 idle)
1131
- * @param text 消息正文
1132
- * @param interrupt true=steer(抢占)/ false=followUp(排队),仅热路径 prompt streamingBehavior 用
1133
- */
1134
- async deliverMessage(record: ExecutionRecord, text: string, interrupt: boolean): Promise<void> {
1135
- this.assertReady();
1136
- // 新 turn,disarm idle timer(防 turn 期间误杀活进程,V2 决策 4)
1137
- disarmIdleTimer(record.id);
1138
- const child = getChildByRecord(record.id);
1139
- if (child && !child.killed) {
1140
- // 热路径:进程活,prompt + streamingBehavior(V2 决策 3,pi 权威裁决 busy/idle)
1141
- record.status = "running";
1142
- // 刷新 pid 内存记账(resume spawn 后 child.pid 已变,顺便更新)
1143
- if (child.pid !== undefined) record.pid = child.pid;
1144
- try {
1145
- sendPromptCommand(child, text, { streamingBehavior: interrupt ? "steer" : "followUp" });
1146
- // 热路径成功,清零 EPIPE 连续失败计数([v4 A-1] 计数器已迁移到 stdin-writer)
1147
- clearEpipeFailure(record.id);
1148
- // [race-F5] 写后死进程检测:write 同步成功只代表数据进了内核 pipe 缓冲,子进程
1149
- // 可能在读取前死亡(gate/idle kill 竞速)。exitCode/signalCode 已非 null = 进程已死
1150
- //(close 事件可能尚未到达),缓冲中的消息将被静默丢弃。只 warn 留证(含 runId 与
1151
- // 消息类型),不抛错不重试:终态回收已由 kill 路径保证,对死进程重试反而可能二次写。
1152
- if (child.exitCode !== null || child.signalCode !== null) {
1153
- logger.warn(
1154
- `[subagents] deliverMessage: child ${record.id} died around stdin write, message may be lost`,
1155
- {
1156
- msgType: interrupt ? "steer" : "followUp",
1157
- exitCode: child.exitCode,
1158
- signalCode: child.signalCode,
1159
- },
1160
- );
1161
- }
1162
- // 轮始执行态信号清除 + 迁移上报(residual-fixes U3 补全,与冷路径 resumeRound
1163
- // 对称):新一轮开跑 = 无轮终信号——清上一轮 result(§5.4 isStreaming 公式要求
1164
- // result undefined 才显示 streaming)与 resumable,appendEntry 让 runtime/W18
1165
- // 派生缓存失效、GUI 侧从 waiting 切回 spinner。仅在投递成功后清(失败保留
1166
- // 上一轮信号,EPIPE 兜底走 resumeRound 时由其再清)。
1167
- record.result = undefined;
1168
- record.resumable = undefined;
1169
- this.store.reportRecordTransition(record);
1170
- // [T2③ / LC-1] 热路径轮 settled 等待固定硬上限(双挂载原语之热路径调用点,
1171
- // 首轮调用点在 session-runner runSpawn)。prompt 发出即起算(整轮含 turn 执行与
1172
- // 收尾都在窗口内,impl-plan §5 窗口口径裁决),settled 到达(session-runner
1173
- // handleSdkEvent 的 disarm,幂等生效于热路径轮)/ 进程 close(waitForChildExit
1174
- // 的 disarm)/ 本方终态化处置后即清——settled 永不到达(事件行丢失 / 子进程
1175
- // wedged)时本 timer 是唯一独立回收通道:现状「settled 不 arm 则 idle timer 不挂、
1176
- // runSpawn 已在首轮返回、spawn watchdog 默认关」的三无窗口由此收敛。
1177
- armSettledWatchdog(record.id, () => this.onHotPathSettledWatchdogTimeout(record));
1178
- } catch (err) {
1179
- // EPIPE 兜底:stdin 管道已断,进程实际已死但 close 事件尚未到达。
1180
- // 检测 EPIPE 关键词 → 进程按 dead 处理 → 自动转冷路径 resume + 消息重放。
1181
- // [review MF1] 本兜底不持 activateLock,但与冷路径共用 resumeRound 的在途守卫
1182
- //(resumesInFlight):resume 已在途时兜底的 resumeRound 调用被拒(throw 行动语言),
1183
- // 不会二次 spawn。
1184
- if (err instanceof Error && err.message.includes("EPIPE")) {
1185
- logger.warn(`[subagents] EPIPE on hot path for ${record.id}, falling back to cold path resume`, {
1186
- detail: err.message,
1187
- });
1188
- // 清理 spawnedChildren 中的死进程条目(让 resumeRound 能重新 spawn)。
1189
- // [M4] 按值守卫:仅当 Map 当前值仍是本次写 EPIPE 的 child 才删——若已被 resume
1190
- // spawn 覆盖为新 child(close 事件先于本 catch 到达的极端时序),不误删新注册
1191
- //(与 session-runner removeChildRegistration 同语义)。
1192
- if (spawnedChildren.get(record.id) === child) {
1193
- spawnedChildren.delete(record.id);
1194
- }
1195
- // 递增连续 EPIPE 计数([v4 A-1] helper 合并同步/异步路径计数)
1196
- const count = recordEpipeFailure(record.id);
1197
- if (count >= EPIPE_FAILURE_THRESHOLD) {
1198
- // 连续达阈值 EPIPE → 不再尝试 resume,throw 含恢复指引
1199
- clearEpipeFailure(record.id);
1200
- throw new Error(
1201
- `[subagents] EPIPE fallback exhausted for ${record.id}: ${count} consecutive EPIPE failures. ` +
1202
- `Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
1203
- );
1204
- }
1205
- // 冷路径 resume + 原消息重放(v4 B-1: status 已 running,resumeRound CAS 直接放行)
1206
- this.resumeRound(record, text);
1207
- return;
1208
- }
1209
- // [T2⑧ / PS-3] 非 EPIPE 写失败(如 ERR_STREAM_DESTROYED——错误分类只认 "EPIPE"
1210
- // 子串,过窄):deliverMessage 入口已无条件 disarmIdleTimer,旧实现直接 rethrow
1211
- // 不 re-arm——「timer armed」这个防泄漏前提在失败路径永久丢失:进程无人回收
1212
- //(无 timer、无轮、record=running)泄漏至宿主退出,hasRunningBackground 恒真
1213
- // 还会顺延其他完成通知。修复:rethrow 前再武装 idle timer(含防御性降级),恢复
1214
- // 进程回收通道 + 通知放行门;调用方错误语义不变(仍 rethrow)。
1215
- this.rearmIdleTimerAfterHotPathFailure(record, err);
1216
- // 非 EPIPE 错误——不应发生,重新抛出让调用方处理
1217
- throw err;
1218
- }
1219
- } else {
1220
- // 冷路径:进程死(idle timer reap / 崩溃 / 跨重启),record 应为 idle → resume spawn。
1221
- // D3:acquireActivateLock 双保险——注意锁只覆盖 resumeRound 同步段,释放在子进程注册
1222
- //(session-runner spawnedChildren.set)之前(中间隔 pool.acquire await + tempFile 等异步点)。
1223
- // 真正的单写者守卫是 resumeRound 的 resumesInFlight([review MF1]):锁释放后、child
1224
- // 注册前到达的第二次冷路径 message 在 resumeRound 处被拒,不会二次 spawn。
1225
- const releaseLock = await acquireActivateLock(record.id);
1226
- try {
1227
- this.resumeRound(record, text);
1228
- } finally {
1229
- releaseLock();
1230
- }
1231
- }
1034
+ this.kickOffChatRound(record, opts, identity, ctx, record.controller.signal, PRIORITY_BACKGROUND, resume);
1232
1035
  }
1233
1036
 
1234
1037
  /**
1235
1038
  * [T2③] 热路径轮 settled watchdog 到期处置(对齐 u-t2a 首轮形态:kill + 该轮失败
1236
1039
  * 终态化 + 失败通知,error 含 'settled watchdog' 标记与恢复指引)。
1237
1040
  *
1041
+ * [D9 两段式] 两段(mid-round 无进展 / settled 收尾段上界)共用本处置,fire 信息
1042
+ *(段 + 窗长)由原语注入,失败文案按段分叉窗长语义。
1043
+ *
1238
1044
  * 与首轮的差异:runSpawn 已返回(无收尾链路承接 settledWatchdogFired 标记),失败
1239
1045
  * 终态化在本回调内完成。chatMode 按 MF-6 语义回退 running-resumable(与首轮 watchdog
1240
1046
  * 经 runAndFinalize 失败分支的最终形态一致——对话可冷路径复活);非 chatMode 终态
@@ -1243,10 +1049,14 @@ export class SubagentService {
1243
1049
  * 回调在 timer 触发的同步上下文执行:同步段只做 kill + CAS(不抛),异步收尾
1244
1050
  * fire-and-forget 且 catch 归 bestEffort——错误逃出回调 = uncaughtException 崩宿主。
1245
1051
  */
1246
- private onHotPathSettledWatchdogTimeout(record: ExecutionRecord): void {
1052
+ private onHotPathSettledWatchdogTimeout(record: ExecutionRecord, fire: SettledWatchdogFireInfo): void {
1053
+ const windowDesc =
1054
+ fire.phase === "mid-round"
1055
+ ? `no valid protocol event for ${fire.waitedMs / MS_PER_SECOND / SECONDS_PER_MINUTE} min after prompt (mid-round no-progress)`
1056
+ : `no agent_settled within ${fire.waitedMs / MS_PER_SECOND}s after agent_end (settled phase)`;
1247
1057
  logger.warn(
1248
- `[subagents] settled watchdog fired for ${record.id}: no agent_settled within ` +
1249
- `${SETTLED_WATCHDOG_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE} min of hot-path prompt, terminating (LC-1 wedge recovery)`,
1058
+ `[subagents] settled watchdog (${fire.phase}) fired for ${record.id}: ${windowDesc}, ` +
1059
+ `terminating (LC-1 wedge recovery)`,
1250
1060
  );
1251
1061
  killRecordChildWithEscalation(record.id, "settled watchdog (hot path)");
1252
1062
  const failedResult: AgentResult = {
@@ -1255,7 +1065,7 @@ export class SubagentService {
1255
1065
  durationMs: Date.now() - record.startedAt,
1256
1066
  success: false,
1257
1067
  error:
1258
- `subagent did not reach agent_settled within ${SETTLED_WATCHDOG_TIMEOUT_MS / MS_PER_SECOND / SECONDS_PER_MINUTE} min (settled watchdog); ` +
1068
+ `subagent did not reach agent_settled (${windowDesc}; settled watchdog); ` +
1259
1069
  `the process was terminated to bound the wait. ` +
1260
1070
  `Recovery: check state with subagents action:'list', then re-send your message to continue.`,
1261
1071
  sessionId: record.id,
@@ -1268,38 +1078,17 @@ export class SubagentService {
1268
1078
  ? this.finalizeRoundToIdle(record, failedResult)
1269
1079
  : this.finalizeRecord(record, failedResult, "closed", "gc");
1270
1080
  void finalize
1271
- .then(() => this.notifyComplete(record))
1081
+ // [D4-①] 通知簇搬移 notify-host 后的遗留调用点修正:this.notifyComplete 方法已
1082
+ // 不存在(其余三处调用点均 this.notifyHost.notifyComplete),旧引用 throw
1083
+ // TypeError 被本 .catch 吞掉 → settled watchdog 失败通知静默丢失
1084
+ .then(() => this.notifyHost.notifyComplete(record))
1272
1085
  .catch((err: unknown) => bestEffort(err, "settled watchdog hot-path finalize", "error"));
1273
1086
  }
1274
1087
 
1275
- /**
1276
- * [T2⑧ / PS-3] EPIPE 热路径失败后的 idle timer 再武装(防泄漏底线)。
1277
- *
1278
- * record.idleTimeoutMs 已在 spawn 入口经 assertIdleTimeoutMsSafe 校验(T4②),此处
1279
- * armIdleTimer 理论不 throw;降级链仍保底:非法 → 挂 DEFAULT_IDLE_TIMEOUT_MS + warn
1280
- * (兜底可见,对齐 session-runner agent_settled 侧的 T4② 降级形态),双重失败退回
1281
- * 「不挂」但留 error 痕。
1282
- */
1283
- private rearmIdleTimerAfterHotPathFailure(record: ExecutionRecord, cause: unknown): void {
1284
- const detail = cause instanceof Error ? cause.message : String(cause);
1285
- const onTimeout = (): void => {
1286
- killRecordChildWithEscalation(record.id, "idle timer (hot-path failure fallback)");
1287
- };
1288
- try {
1289
- armIdleTimer(record.id, onTimeout, record.idleTimeoutMs);
1290
- } catch {
1291
- try {
1292
- armIdleTimer(record.id, onTimeout, DEFAULT_IDLE_TIMEOUT_MS);
1293
- } catch (fallbackErr) {
1294
- bestEffort(fallbackErr, "rearmIdleTimer fallback (deliverMessage non-EPIPE failure)", "error");
1295
- return;
1296
- }
1297
- }
1298
- logger.warn(
1299
- `[subagents] deliverMessage hot path failed for ${record.id}; idle timer re-armed to keep process recovery bounded`,
1300
- { detail },
1301
- );
1302
- }
1088
+ // [T2⑧ / PS-3] 非 EPIPE 热路径写失败后的 idle timer 再武装已随 D2 投递下沉迁移落位:
1089
+ // 挂载点在 PiEngine.deliverPrompt 的非 EPIPE catch(engine/engines/pi/pi-engine.ts
1090
+ // rearmIdleTimerAfterHotPathFailure,该文件归 engine 域)——编排层 interactRecord 的
1091
+ // 结构化结果无法区分「stdin 写失败」与业务拒绝,盲目 re-arm 会误武装。
1303
1092
 
1304
1093
  // ── 对话模式 message/close action 支持(M2-B3)──────────────
1305
1094
 
@@ -1323,14 +1112,15 @@ export class SubagentService {
1323
1112
  * @throws Error record 不存在 / 非本 session 所有(含恢复指引)
1324
1113
  * @throws ResurrectDeniedError 命中可重连集但被 worktree/异进程活实例守卫拦截(自带完整行动语言)
1325
1114
  */
1326
- getRecordForAction(id: string, opts?: { allowReconnect?: boolean }): ExecutionRecord {
1115
+ private getRecordForAction(id: string, opts?: { allowReconnect?: boolean }): ExecutionRecord {
1327
1116
  this.assertReady();
1328
1117
  let record = this.store.getMutable(id);
1329
1118
  // SP-2 跨重启恢复:内存未命中时,从磁盘 collectRecords 重建 idle record。
1330
1119
  // reconstructAll 已将跨重启 record(无 sidecar + pid 死)标记为 running(v4 B-1 可续聊语义,非 crashed),
1331
1120
  // 直接转为可变 ExecutionRecord register 进内存,供 message/close action 续操作。
1332
1121
  if (!record) {
1333
- record = this.coldLookupForAction(id, opts?.allowReconnect === true);
1122
+ // [D4-③] 冷查/复活链整体搬移至 cold-resurrect.ts(行为逐字节等价)。
1123
+ record = coldLookupForAction(this.coldResurrectDeps, id, opts?.allowReconnect === true);
1334
1124
  }
1335
1125
  if (!record || record.rootSessionId !== this.sessionRootId) {
1336
1126
  throw new Error(
@@ -1340,12 +1130,12 @@ export class SubagentService {
1340
1130
  }
1341
1131
  // [v4 A-5 / P7] 直接父校验:rootSessionId 已确认 record 属于本 session 树,但递归场景下
1342
1132
  // 孙级 record(parentRecordId = 某子进程的 self recordId)的子进程句柄只存在于其直接父
1343
- // 进程内存。主进程(execCtxBaseline=null)若仅凭 rootSessionId 通过就 message 孙级,会走
1133
+ // 进程内存。主进程(基线 null)若仅凭 rootSessionId 通过就 message 孙级,会走
1344
1134
  // 冷路径重新 spawn → 双写同一 session 文件(P7 双写者窗口)。统一用 baseline recordId 校验:
1345
1135
  // - 主进程 baseline=undefined → 只能操作 parentRecordId=undefined 的根层 record
1346
1136
  // - 子进程 baseline="sa-X" → 只能操作 parentRecordId="sa-X" 的直接孩子
1347
1137
  // record.parentRecordId===undefined 视作根层,仅主进程可操作(身份缺省的旧/异常 record 归此)。
1348
- const baselineRecordId = this.execCtxBaseline?.recordId ?? undefined;
1138
+ const baselineRecordId = this.execNesting.baseline()?.recordId ?? undefined;
1349
1139
  if (record.parentRecordId !== baselineRecordId) {
1350
1140
  throw new Error(
1351
1141
  `subagent ${id} is owned by its direct parent; message it through that parent ` +
@@ -1357,156 +1147,13 @@ export class SubagentService {
1357
1147
  return record;
1358
1148
  }
1359
1149
 
1360
- /** SP-2 冷路径(getRecordForAction 内存未命中分支的提取):从磁盘重建可变 ExecutionRecord
1361
- * register 进内存。[perf] 先走 idToFile 索引直查(单文件 stat 校验),未命中(进程重启后
1362
- * 尚未扫描、索引未热)才全目录 collectRecords 兜底建索引——跨重启后每条 message
1363
- * 「readdir + N×4 stat 全扫」降为单文件校验。
1364
- * @returns 重建的 record;磁盘也无则 undefined
1365
- * @throws ResurrectDeniedError 可重连候选被 worktree/异进程活实例守卫拦截 */
1366
- /** 冷查候选定位(coldLookupForAction 步骤 1):idToFile 索引直查 running 命中,
1367
- * 未命中再全目录 collectRecords 兜底(running,或 allowReconnect 且可重连 closed)。
1368
- *
1369
- * [T5③ / PS-7b] running 候选异进程活实例守卫:冷查 running 候选(跨重启 / 内存重建)
1370
- * 此前不经任何探针直接 resurrect + resume spawn——若其 .alive marker 仍指向活着的
1371
- * 异进程实例(父进程重启后旧子进程尚存的窗口),resume 会 spawn 第二个 pi 子进程
1372
- * 写同一 session JSONL(本代码最忌惮的双写者形态,v4 A-5/P7 事故模式)。closed 候选
1373
- * 的同款守卫已在 assertReconnectAllowed(v8.5 D);本守卫闭合 running 候选的防御
1374
- * 不对称。marker 的 pid 是子进程 pi 的 pid(非父进程),本进程持有的 running record
1375
- * 恒在内存(archive 才移出),可达本冷查分支的 running 候选必然来自磁盘重建——
1376
- * 探针命中即拒绝(ResurrectDeniedError,与 closed 候选守卫同异常类型,错误含 pid
1377
- * 与恢复指引)。 */
1378
- private findColdLookupCandidate(id: string, allowReconnect: boolean): SubagentRecord | undefined {
1379
- const direct = this.store.findLightById(id);
1380
- const found =
1381
- (direct?.status === "running" ? direct : undefined) ??
1382
- this.store
1383
- .collectRecords(COLD_LOOKUP_SCAN_LIMIT, "all", undefined)
1384
- .find((r) => r.id === id && (r.status === "running" || (allowReconnect && this.isReconnectableClosed(r))));
1385
- if (found?.status === "running" && found.sessionFile) {
1386
- const foreign = findForeignLiveInstance(found.sessionFile);
1387
- if (foreign) {
1388
- throw new ResurrectDeniedError(
1389
- `subagent ${id} is currently running in another process instance (pid ${foreign.pid}, ` +
1390
- `startedAt=${new Date(foreign.startedAt).toISOString()}); resuming here would double-write ${found.sessionFile}. ` +
1391
- `Recovery: retry once that process exits; if it never exits, action:'close' this subagent, then action:'start' a fresh one.`,
1392
- );
1393
- }
1394
- }
1395
- return found;
1396
- }
1397
-
1398
- /** 可重连守卫(coldLookupForAction 步骤 2,[v8.5 D]):先于任何状态突变与注册。
1399
- * worktree 绑定丢失 / 异进程活实例以 ResurrectDeniedError 抛出(endedMessageGuard
1400
- * 必须原样透传,不得改写为 fork-from 指引误导 agent 走已被判死的通道);拒绝时
1401
- * 内存不得残留该记录(findRecord 契约)。 */
1402
- private assertReconnectAllowed(found: SubagentRecord, id: string): void {
1403
- if (found.status !== "closed") return;
1404
- if (found.worktree === true) {
1405
- throw new ResurrectDeniedError(
1406
- `subagent ${id} cannot be transparently resumed: it was created with worktree isolation, ` +
1407
- `and its worktree checkout no longer exists after restart (resuming in place would make spawn cwd fall back to the main repo). ` +
1408
- `Recovery: action:'start' a fresh subagent (with a new worktree if isolation is still needed); ` +
1409
- `its conversation history remains intact at ${found.sessionFile}.`,
1410
- );
1411
- }
1412
- const foreign = found.sessionFile ? findForeignLiveInstance(found.sessionFile) : undefined;
1413
- if (foreign) {
1414
- throw new ResurrectDeniedError(
1415
- `subagent ${id} is not transparently resumable: its previous instance still finishing in another process ` +
1416
- `(pid ${foreign.pid}, startedAt=${new Date(foreign.startedAt).toISOString()}). ` +
1417
- `Resuming in place would double-write ${found.sessionFile}. ` +
1418
- `Recovery: retry once that process exits; if it never exits, action:'start' a fresh subagent and treat the history at ${found.sessionFile} as read-only reference.`,
1419
- );
1420
- }
1421
- }
1422
-
1423
- /** 磁盘候选重建为可变 record 并 register + 上报(coldLookupForAction 步骤 4)。 */
1424
- private resurrectColdRecord(found: SubagentRecord, id: string): ExecutionRecord {
1425
- const record = createRecord(id, {
1426
- agent: found.agent,
1427
- model: found.model,
1428
- thinkingLevel: found.thinkingLevel,
1429
- mode: found.mode,
1430
- task: found.task,
1431
- slug: found.slug,
1432
- startedAt: found.startedAt,
1433
- rootSessionId: found.rootSessionId,
1434
- parentRecordId: found.parentRecordId,
1435
- depth: found.depth,
1436
- // [v4 A-3] 跨重启恢复入口——message 路径磁盘重建无条件置 chatMode=true(现状机制,
1437
- // V3 方案 A 方向兑现)。改动此处必须带 S3 回归场景(跨重启 message 续聊验证)。
1438
- // V3 SP-5 探针定案:机制已存在,本注释即定案,不再悬置。
1439
- chatMode: true,
1440
- controller: new AbortController(),
1441
- });
1442
- record.sessionFile = found.sessionFile;
1443
- record.round = found.round;
1444
- // [review round2] 跨重启 worktree 绑定丢失防护:原 record 创建时启用了 worktree 隔离
1445
- //(session entry 的 worktree 标志),但 WorktreeHandle 不可序列化、重建后恒缺失。
1446
- // 标记 hadWorktree,resumeRound 守卫据此拒绝续聊(防 spawn cwd 静默回落主 repo 破坏
1447
- // 隔离——正是 worktree 要防的并发写冲突场景)。close 不受影响(closeChatIdle 走
1448
- // doFinalizeRecord,泄漏的 worktree 由 reaper 兜底回收)。
1449
- record.hadWorktree = found.worktree === true;
1450
- // [v8.5 D] 透明重生回边:独立函数不走 tryTransition 单向语义(closed 单向性对正常
1451
- // 执行流完整保留);准入唯一依据 = A 档 sidecar 真实死因 ∈ 可重连集。死亡语义位由
1452
- // resurrectClosed 清除;register 后立刻上报 transition entry,live/reload 视图同步
1453
- // 翻回 running(等价性由 applyEntry reducer 保证,对齐 SP-2 重建即报告先例)。
1454
- if (found.status !== "running") {
1455
- // [review MF-8] 磁盘终态位同步翻转:record-store buildRecord 分支 2(.finalized
1456
- // 存在 → closed)优先级高于 .alive 活态分支 3,重生若不删 sidecar,任何磁盘扫描
1457
- // (异进程 / reload / session-reader)都会把本进程内存里 running 的 record 报成
1458
- // closed/disconnected——破坏 live ≡ reload,且为跨进程二次 resurrect 开门。
1459
- // best-effort 对齐 BC-4 语义;.alive 刷新为当前进程(后续 resume spawn 会覆盖写)。
1460
- if (record.sessionFile) {
1461
- try {
1462
- fs.rmSync(`${record.sessionFile}.finalized`, { force: true });
1463
- writeAliveMarker(record.sessionFile, { pid: process.pid, id, startedAt: Date.now() });
1464
- } catch (_e) {
1465
- void _e; // best-effort:sidecar 翻转失败不阻断重生主流程
1466
- }
1467
- }
1468
- resurrectClosed(record);
1469
- }
1470
- this.store.register(record);
1471
- if (found.status !== "running") {
1472
- this.store.reportRecordTransition(record);
1473
- }
1474
- return record;
1475
- }
1476
-
1477
- private coldLookupForAction(id: string, allowReconnect: boolean): ExecutionRecord | undefined {
1478
- const found = this.findColdLookupCandidate(id, allowReconnect);
1479
- if (!found) return undefined;
1480
- // [v8.5 D] 可重连候选的守卫先于任何状态突变与注册(细节见 assertReconnectAllowed)
1481
- this.assertReconnectAllowed(found, id);
1482
- // [review MF-9] 归属校验先于任何持久化副作用:coldLookup 是 getRecordForAction 的
1483
- // 内存未命中分支,若先 resurrect/register/report 再由调用方抛归属错误,会在磁盘/
1484
- // 内存留下幽灵 running record + running transition entry(跨进程双 resurrect 窗口)。
1485
- // rootSessionId 不匹配 → 返回 undefined,由 getRecordForAction 抛统一「not found or
1486
- // not owned」(不区分失败形态,防跨 session 探测);parentRecordId 跨层不匹配 →
1487
- // 原样抛 direct parent 错误(与外层校验同文案,保留跨层导航指引)。
1488
- if (found.rootSessionId !== this.sessionRootId) {
1489
- return undefined;
1490
- }
1491
- const baselineRecordId = this.execCtxBaseline?.recordId ?? undefined;
1492
- if (found.parentRecordId !== baselineRecordId) {
1493
- throw new Error(
1494
- `subagent ${id} is owned by its direct parent; message it through that parent ` +
1495
- `(see /subagents list, parent=${found.parentRecordId ?? "(root layer)"}). [v4 A-5] cross-layer ` +
1496
- `ownership guard: this process's baseline=${baselineRecordId ?? "(root)"} is not the direct parent of ${id}; ` +
1497
- `operating here would race the owning child process's handle and double-write the session file.`,
1498
- );
1499
- }
1500
- return this.resurrectColdRecord(found, id);
1501
- }
1502
-
1503
- /** [v8.5 D] 冷查候选过滤:closed 且死因落在可重连集。判定源 = closedReason(buildRecord
1504
- * 归一化后的对外字段:A 档真实死因直通、旧空 sidecar 兑底 disconnected——SubagentRecord
1505
- * 不暴露 raw finalizedReason);cancelled/user-close/gc 等主动关闭与自然完成死因天然不在集合内。
1506
- * 防线在集合本身而非调用点。 */
1507
- private isReconnectableClosed(r: SubagentRecord): boolean {
1508
- return isReconnectableFinalReason(r.closedReason);
1509
- }
1150
+ // [D4-③] 冷路径复活链(findColdLookupCandidate / assertReconnectAllowed /
1151
+ // resurrectColdRecord / coldLookupForAction + isReconnectableClosed 判定)已整体
1152
+ // 搬移至 cold-resurrect.ts(本类经 coldResurrectDeps 注入,行为逐字节等价)。
1153
+ // SP-2 冷路径 [perf] 语义不变:idToFile 索引直查 → collectRecords 全扫兜底。
1154
+ // [T5③ / PS-7b] running 候选异进程活实例守卫(findForeignLiveInstance 探针,
1155
+ // ResurrectDeniedError)已随迁 cold-resurrect.ts findColdLookupCandidate
1156
+ //(与 closed 候选守卫 assertReconnectAllowed 对称)。
1510
1157
 
1511
1158
  /**
1512
1159
  * close action 的统一行为分流(running 子态 × force)。
@@ -1523,7 +1170,7 @@ export class SubagentService {
1523
1170
  * @param record 目标 record(getRecordForAction 已校验归属)
1524
1171
  * @param force true=立即终止(running 时 SIGTERM)/ false=优雅关闭(running 时等轮完)
1525
1172
  */
1526
- async closeSubagent(record: ExecutionRecord, force: boolean): Promise<void> {
1173
+ private async closeSubagent(record: ExecutionRecord, force: boolean): Promise<void> {
1527
1174
  this.assertReady();
1528
1175
  if (record.status === "running") {
1529
1176
  if (force) {
@@ -1590,7 +1237,7 @@ export class SubagentService {
1590
1237
  store: this.store,
1591
1238
  modelService: this.modelService,
1592
1239
  pi: this.pi,
1593
- emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
1240
+ emitUnregister: (id, st) => this.notifyHost.emitPendingUnregister(id, st),
1594
1241
  sessionDir: this.sessionsDir,
1595
1242
  },
1596
1243
  record,
@@ -1603,7 +1250,7 @@ export class SubagentService {
1603
1250
  // (P-1 修复),正文空由 notifyClosed 的 emptyBody 参数显式表达。
1604
1251
  // dedup 身份独立于轮次通知(notifyClosed 置 round=undefined),60s 窗内不被吞。
1605
1252
  // 防重入:closeSubagent 对 closed record 幂等 no-op,本路径不会被二次进入。
1606
- this.notifyClosed(record, true);
1253
+ this.notifyHost.notifyClosed(record, true);
1607
1254
  }
1608
1255
 
1609
1256
  /**
@@ -1641,7 +1288,7 @@ export class SubagentService {
1641
1288
  await this.finalizeRecord(record, doneResult, "closed", "user-close");
1642
1289
  // [C-1] 终态通知(设计 D2 路径①):与前置轮次通知(key=`id:round`)dedup 身份区分,
1643
1290
  // 「最后一轮轮次通知 + 终态通知」两条都送达父 agent。
1644
- this.notifyClosed(record);
1291
+ this.notifyHost.notifyClosed(record);
1645
1292
  }
1646
1293
 
1647
1294
  // ── 编排层专用接口(workflow 消费)──────────────────────
@@ -1651,7 +1298,7 @@ export class SubagentService {
1651
1298
  *
1652
1299
  * 与 execute() 的区别(D-A1):
1653
1300
  * 1. 返回 workflow AgentResult(content 字段),非 ExecutionHandle
1654
- * 2. 不调 kickOffBackground 不注入 followUp 完成通知(BC-11,结果直接返回 workflow)
1301
+ * 2. 不经 chat 轮次 kick-off(detached 回注)→ 不注入 followUp 完成通知(BC-11,结果直接返回 workflow)
1655
1302
  * 3. T2 删 sync 时 executeAndAwait 不受牵连(独立方法)
1656
1303
  *
1657
1304
  * 共享:runSpawn + ConcurrencyPool + record + pending emit(D-A4)。
@@ -1666,9 +1313,9 @@ export class SubagentService {
1666
1313
  // [T4② / PS-4] 与 execute() 同款入口校验(两入口共享 runAndFinalize → armIdleTimer 链)。
1667
1314
  this.assertIdleTimeoutMsSafe(opts);
1668
1315
 
1669
- // ── BC-12 嵌套护栏:复用 execute() execCtxAls 深度检查 ──
1670
- // [ALS 断裂修复] getStore() 可能读空,基线兜底(与 execute 同)。
1671
- const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
1316
+ // ── BC-12 嵌套护栏:复用 execute() 的嵌套上下文深度检查 ──
1317
+ // [ALS 断裂修复] current() 内含基线兜底(与 execute 同)。
1318
+ const parentNesting = this.execNesting.current();
1672
1319
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
1673
1320
  if (nestingDepth > MAX_FORK_DEPTH) {
1674
1321
  throw new ForkDepthExceededError(
@@ -1681,7 +1328,7 @@ export class SubagentService {
1681
1328
 
1682
1329
  // ── 步骤 2: RECORD 创建(mode="background" 进池)──
1683
1330
  const record = this.createRecordForMode(identity, opts, "background");
1684
- emitPendingRegister(this.pi, record.id, record.agent);
1331
+ this.notifyHost.emitPendingRegister(record.id, record.agent);
1685
1332
 
1686
1333
  // ── 步骤 2.5: worktree creation (only worktree===true; handle injection is execute()'s path) ──
1687
1334
  // Workflow path receives boolean only (AgentCallOpts.worktree: boolean) — WorktreeHandle is a
@@ -1764,59 +1411,121 @@ export class SubagentService {
1764
1411
  // ── 状态查询(TUI 调)──────────────────────────────────
1765
1412
 
1766
1413
  /** 订阅 store 变更(widget/list requestRender)。返回取消订阅。 */
1767
- onChange(listener: () => void): () => void {
1414
+ private onChange(listener: () => void): () => void {
1768
1415
  return this.store.onChange(listener);
1769
1416
  }
1770
1417
 
1771
- /** 列出 running record 快照(widget 计数用)。 */
1772
- listRunning(): RecordSnapshot[] {
1773
- return this.store.listRunning();
1774
- }
1418
+ // [D4] listRunning 已删除:零生产调用方(TUI 计数经 collectRecords / notify-host 的
1419
+ // piAdapter 直调 store.listRunning 覆盖),唯一消费是初始空态单测——保留 store 层方法。
1775
1420
 
1776
1421
  /** 合并内存(running) + 磁盘(session.jsonl 重建) record(/subagents list + tool list 消费)。
1777
1422
  * 按 rootSessionId 过滤:根进程=本 session(sessionRootId===sessionId);
1778
1423
  * 子进程=env 贯穿的真 ROOT(sessionRootId≠sessionId)→ 看到整棵 ROOT 树(决策 3)。
1779
1424
  * [perf] 磁盘源为 light(头部 identity + 状态,无 eventLog/result/turns 等重数据)
1780
1425
  * ——列表/补全/hasRunning 够用;详情场景调 getFullRecord(id) 懒加载补齐。 */
1781
- collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
1426
+ private collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
1782
1427
  return this.store.collectRecords(limit, statusFilter, this.sessionRootId ?? this.sessionId ?? undefined);
1783
1428
  }
1784
1429
 
1785
1430
  /** [perf] 单 record 详情懒加载(全量:eventLog/displayItems/result/turns/tokens)。
1786
1431
  * 内存 running record 直接投影;磁盘 record 全量重建(per-file 缓存,stat 戳校验)。
1787
1432
  * 返回 undefined:id 不存在于内存与磁盘。 */
1788
- getFullRecord(id: string): SubagentRecord | undefined {
1433
+ private getFullRecord(id: string): SubagentRecord | undefined {
1789
1434
  return this.store.getFullRecord(id);
1790
1435
  }
1791
1436
 
1792
1437
  // ── 执行内部:身份解析 + record 创建 ──────────
1793
1438
 
1794
- /** 步骤 1:身份解析。agentConfig → resolveModel(三层:override → agentConfig → 主 agent model)。 */
1795
- private async resolveIdentity(opts: ExecuteOptions): Promise<ResolvedIdentity> {
1439
+ /** 步骤 1:身份解析。agentConfig → resolveModel(三层:override → agentConfig → 主 agent model)。
1440
+ *
1441
+ * [u-h2] pi 未命中跨引擎候选(D2-4):resolveModel 抛 notFoundError(pi registry
1442
+ * 全等裁决未命中)时反查其他已注册引擎清单,唯一命中则追加「该 id 属于引擎 X」
1443
+ * 候选段(场景 3);其余裁决失败(孪生歧义/auth)与命中路径原样返回(零回归)。
1444
+ * execute() 与 executeAndAwait() 两个派发路径共享本方法,故 chat 与 workflow 域的
1445
+ * pi 校验同享场景 3 文案。
1446
+ */
1447
+ private async resolveIdentity(
1448
+ opts: ExecuteOptions,
1449
+ pre?: { agent: string; agentConfig: AgentConfig | undefined },
1450
+ ): Promise<ResolvedIdentity> {
1796
1451
  // agentRef 语义(S2):agent 参数 = .md 绝对路径;不传 = 不加载 agentConfig,
1797
1452
  // 直接用 override → 主 agent model。DEFAULT_AGENT_NAME 仅作 record 显示名
1798
1453
  // (TUI 层 extractAgentName 共用,保证显示一致)。
1799
- const agent = opts.agent ?? DEFAULT_AGENT_NAME;
1454
+ const agent = pre?.agent ?? opts.agent ?? DEFAULT_AGENT_NAME;
1800
1455
  // 显式 agent ref(用户点名)失败必须报错,不静默降级:无 require 的 loadByPath
1801
1456
  // 对相对路径/裸名/文件缺失都返回 undefined → agentConfig undefined → resolveModel
1802
1457
  // 静默回落 override→主 agent model,用户拿到的 subagent 无 systemPrompt/工具白名单
1803
1458
  // 且零反馈。require:true 让失败抛出带 <available_subagents> 指引的错误(对齐
1804
1459
  // workflow name not found 反馈风格);不传 agent = 默认 general-purpose 语义,
1805
1460
  // agentConfig 保持 undefined(合法缺省,走 override → ctxModel 兑底)。
1806
- const agentConfig = opts.agent
1807
- ? this.modelService.getRequiredAgentConfig(opts.agent)
1808
- : undefined;
1809
-
1810
- const resolved = this.modelService.resolveModel(
1811
- opts.agent ?? "",
1812
- { model: opts.model, thinkingLevel: opts.thinkingLevel },
1813
- opts.ctxModel,
1814
- agentConfig,
1815
- );
1461
+ // [u-h2 D2-1] execute() 已在路由前解析 agentConfig(pre 通道),此处复用不二次加载。
1462
+ const agentConfig = pre
1463
+ ? pre.agentConfig
1464
+ : opts.agent
1465
+ ? this.modelService.getRequiredAgentConfig(opts.agent)
1466
+ : undefined;
1467
+
1468
+ let resolved: ResolvedModel;
1469
+ try {
1470
+ resolved = this.modelService.resolveModel(
1471
+ opts.agent ?? "",
1472
+ { model: opts.model, thinkingLevel: opts.thinkingLevel },
1473
+ opts.ctxModel,
1474
+ agentConfig,
1475
+ );
1476
+ } catch (err) {
1477
+ throw withCrossEngineHint(err, listEngines(), (id) => {
1478
+ try {
1479
+ return getEngine(id);
1480
+ } catch {
1481
+ return undefined; // 清单快照与注册表并发变化的防御:取不到引擎按未注册处理
1482
+ }
1483
+ });
1484
+ }
1816
1485
 
1817
1486
  return { agent, agentConfig, resolved };
1818
1487
  }
1819
1488
 
1489
+ /**
1490
+ * [u-h2 D2-1③] 非 pi 引擎的 identity 解析:跳过 pi registry 三层解析(ctxModel 主
1491
+ * agent model 不透传——主 agent 的 pi id 对目标引擎大概率无效,缺省语义归引擎)。
1492
+ *
1493
+ * 逐层语义(设计 D2-1 归趋表):
1494
+ * - model 源 = engineModel(调用参数 opts.model > agentConfig.model frontmatter,
1495
+ * agent 作者声明不忽略——配错在 validateModel 同步报错,不落引擎缺省静默续跑);
1496
+ * - 无显式 model → 校验/留痕走引擎缺省(validateModel(undefined) 的 canonicalRef);
1497
+ * - thinkingLevel 直接透传(引擎中立参数,不涉 registry)。
1498
+ *
1499
+ * 引擎未实现 validateModel 时 modelRef 原样透传(其 prepare 期校验兜底,现状语义)。
1500
+ */
1501
+ private resolveIdentityForEngine(
1502
+ engine: EnginePort,
1503
+ engineModel: string | undefined,
1504
+ agent: string,
1505
+ agentConfig: AgentConfig | undefined,
1506
+ opts: ExecuteOptions,
1507
+ ): ResolvedIdentity {
1508
+ const canonical = validateModelForEngine(engine, engineModel);
1509
+ // record.model 留痕:canonical(引擎裁决全名,含短名→缺省 provider 归一化);
1510
+ // 引擎未实现校验面且无显式 model 时为空串(记录形态退化,生产不可达——注册表
1511
+ // 内非 pi 引擎均实现 validateModel;防御性拼接避免 throw 打断兜底语义)。
1512
+ const modelStr = canonical ?? engineModel ?? "";
1513
+ const slashIdx = modelStr.indexOf("/");
1514
+ return {
1515
+ agent,
1516
+ agentConfig,
1517
+ resolved: {
1518
+ model: {
1519
+ id: slashIdx > 0 ? modelStr.slice(slashIdx + 1) : "",
1520
+ name: modelStr,
1521
+ provider: slashIdx > 0 ? modelStr.slice(0, slashIdx) : modelStr,
1522
+ reasoning: false,
1523
+ },
1524
+ thinkingLevel: opts.thinkingLevel ?? agentConfig?.thinkingLevel,
1525
+ },
1526
+ };
1527
+ }
1528
+
1820
1529
  /** 步骤 2:按 mode 生成 id + controller,创建 record 并注册。
1821
1530
  * [L-1] ExecutionMode 类型固定 "background"(sync 已删除),id/controller 分支简化。 */
1822
1531
  private createRecordForMode(
@@ -1829,11 +1538,11 @@ export class SubagentService {
1829
1538
  const controller = new AbortController();
1830
1539
 
1831
1540
  // 从 async 调用链读父执行上下文:主 session 链上无 store → 顶层 record;
1832
- // B run() 期间包了 execCtxAls,B 内创建 C 时读到 B → C.parentRecordId=B.id, C.depth=B.depth+1。
1541
+ // B run() 期间包了嵌套上下文,B 内创建 C 时读到 B → C.parentRecordId=B.id, C.depth=B.depth+1。
1833
1542
  // depth 语义:顶层(无父)=0;有父=父 depth+1。靠 recordId 是否存在区分,不用负数魔数。
1834
- // [ALS 断裂修复] getStore() pi 事件回调模型下可能读空(enterWith 不贯穿),
1835
- // 基线兜底——本进程的身份在 initSession 已确定(env 注入),任何上下文下都能正确挂父链。
1836
- const parentCtx = this.execCtxAls.getStore() ?? this.execCtxBaseline;
1543
+ // [ALS 断裂修复] current() 内含基线兜底——本进程的身份在 initSession 已确定(env 注入),
1544
+ // 任何上下文下都能正确挂父链。
1545
+ const parentCtx = this.execNesting.current();
1837
1546
  const parentRecordId = parentCtx?.recordId;
1838
1547
  const depth = parentCtx ? parentCtx.depth + 1 : 0;
1839
1548
 
@@ -1871,70 +1580,113 @@ export class SubagentService {
1871
1580
  // ── 引擎分支(D4/D10:非 pi 引擎的 chat 域执行骨架,U0)──────────
1872
1581
 
1873
1582
  /**
1874
- * 路由到非 pi 引擎的执行入口:routeEngine(注册表校验 + probe/守卫)已由 execute
1875
- * 完成——这里只剩 unsupported 预检 record 创建+盖章 → detached 引擎 run。
1583
+ * chat 域统一执行入口(D2 单轨:全引擎——含 pi——经此进入 EnginePort)。路由
1584
+ *(routeEngineForHost:三层 + pi 同步短路 + probe/守卫)已由 execute 完成——这里
1585
+ * 只剩 unsupported 预检 → identity(pi 链解析 / 非 pi 按目标引擎校验,
1586
+ * [u-h2 D2-1/D2-2])→ record 创建+盖章 → worktree → detached 引擎 run。
1876
1587
  * 全部同步拒绝发生在 record 创建前(不产生孤儿 record)。
1877
1588
  */
1878
- private executeViaEngine(
1589
+ private async executeViaEngine(
1879
1590
  opts: ExecuteOptions,
1880
- identity: ResolvedIdentity,
1591
+ preIdentity: { agent: string; agentConfig: AgentConfig | undefined },
1881
1592
  route: EngineRouteResult,
1882
- ): ExecutionHandle {
1593
+ mode: ExecutionMode,
1594
+ ): Promise<ExecutionHandle> {
1883
1595
  const engine = route.engine;
1884
- this.assertEngineParamSupport(engine, opts);
1885
- // record 盖章路由结果(D5 pi 缺省不盖章;非 pi 显式留痕,createRecordForMode
1886
- // 经 opts.engine/engineFallback 读入 record identity——engine 为实际执行引擎,
1887
- // fallback 路径 from=请求引擎留痕,probe 通过的常态路径恒缺省)
1888
- const record = this.createRecordForMode(
1889
- identity,
1890
- {
1596
+ // [D3-④ 预检 capabilities 化] 唯一实现 = common/capability-gate(capabilities
1597
+ // 驱动,含 maxTurns 扩位)。检查点钉死:execute/executeViaEngine 同步段、record
1598
+ // 创建前(engine.capabilities() 同步可得)——承接「全部同步拒绝发生在 record
1599
+ // 创建前、不产生孤儿 record」不变量(其后的 kickOffEngineRun 是 fire-and-forget,
1600
+ // 检查若只落在 engine.run 内则拒绝异步化为「派发成功 + 静默失败 record」)。
1601
+ assertTaskShapeSupported(engine.id, engine.capabilities(), opts);
1602
+
1603
+ // identity 按路由结果分支构造([u-h2 D2-1] 路由先行):
1604
+ // - pi:pi 链三层解析在路由后执行(现状三层解析链行为零变化;含 resolveModel
1605
+ // 失败的跨引擎候选提示,D2-4);
1606
+ // - 非 pi:跳过 pi registry,model 按目标引擎校验(同步期 throw,record 创建前
1607
+ // ——场景 2 错误;ctxModel 不透传,缺省语义归引擎,D2-1③)。
1608
+ const isPiRoute = route.engineId === DEFAULT_ENGINE_ID;
1609
+ // [u-h2 D2-1③] 非 pi 的 model 源 = 调用参数 > agent .md frontmatter(作者声明不
1610
+ // 忽略)——frontmatter 声明须真正透传给引擎(taskSpec.model 消费 opts.model),
1611
+ // 不能只进 record 留痕;无显式 model 时引擎落自身缺省(validateModel(undefined) 裁决)。
1612
+ const engineModel = isPiRoute ? undefined : (opts.model ?? preIdentity.agentConfig?.model);
1613
+ const identity = isPiRoute
1614
+ ? await this.resolveIdentity(opts, preIdentity)
1615
+ : this.resolveIdentityForEngine(engine, engineModel, preIdentity.agent, preIdentity.agentConfig, opts);
1616
+
1617
+ // record 盖章路由结果(D5 字节级守护的执行侧落点):
1618
+ // - pi 纯缺省/显式 pi:不盖 engine 键(pi record entry 序列化产物不得新增 engine
1619
+ // 键,undefined 经 JSON 省略)——与旧 pi 主路径 piOpts 剥离语义逐字节一致;
1620
+ // - pi 兜底:engine='pi' + engineFallback 留痕(engine = 实际执行引擎,from=请求
1621
+ // 引擎留痕);
1622
+ // - 非 pi:engine=route.engineId 显式留痕(+engineFallback 如有)+ model 覆写
1623
+ // (frontmatter 声明透传,u-h2 D2-1③)。
1624
+ const recordOpts: ExecuteOptions = isPiRoute
1625
+ ? route.engineFallback !== undefined
1626
+ ? { ...opts, engine: DEFAULT_ENGINE_ID, engineFallback: route.engineFallback }
1627
+ : opts.engine === undefined
1628
+ ? opts
1629
+ : { ...opts, engine: undefined }
1630
+ : {
1891
1631
  ...opts,
1632
+ ...(engineModel !== undefined ? { model: engineModel } : {}),
1892
1633
  engine: route.engineId,
1893
1634
  ...(route.engineFallback !== undefined ? { engineFallback: route.engineFallback } : {}),
1894
- },
1895
- "background",
1896
- );
1897
- emitPendingRegister(this.pi, record.id, record.agent);
1898
- this.kickOffEngineRun(record, opts, engine);
1899
- return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details: project(record) };
1900
- }
1635
+ };
1636
+ const record = this.createRecordForMode(identity, recordOpts, mode);
1637
+ this.notifyHost.emitPendingRegister(record.id, record.agent);
1901
1638
 
1902
- /**
1903
- * pi 引擎的 unsupported 参数预检(D11 处置「调用前拒绝」的判据 = capabilities)。
1904
- * conversation / fork / worktree 三参数对首期接入的引擎(zcode)均不可用:
1905
- * conversation 依赖同进程 idle 复用、fork 依赖父 pi session 上下文继承、worktree 依赖
1906
- * 文件隔离(capabilities.sandbox='none')。同步 throw,文案含 capabilities 依据与恢复指引。
1907
- */
1908
- private assertEngineParamSupport(engine: EnginePort, opts: ExecuteOptions): void {
1909
- const caps = engine.capabilities();
1910
- if (opts.conversation === true && caps.conversation === "unsupported") {
1911
- throw new EngineError(
1912
- "engine_capability_unsupported",
1913
- `engine '${engine.id}' 不支持 conversation(capabilities.conversation = 'unsupported',` +
1914
- `spawn 单轮模式无同进程 idle 复用,message/close 交互控制面不可用)`,
1915
- `改用 engine: pi(支持 conversation 续聊),或不传该参数(一次性任务默认形态)`,
1916
- );
1917
- }
1918
- if (opts.fork === true || opts.forkFromSessionFile !== undefined) {
1919
- throw new EngineError(
1920
- "engine_capability_unsupported",
1921
- `engine '${engine.id}' 不支持 fork${opts.forkFromSessionFile !== undefined ? "(fork-from 同为父 pi session 上下文继承)" : ""}(fork 依赖父 pi session 上下文继承,` +
1922
- `capabilities.steer = '${caps.steer}'——非 pi 引擎无父 session 分叉通道)`,
1923
- `把所需父上下文写进 task 正文后不传 fork,或改用 engine: pi`,
1924
- );
1639
+ // ── worktree 创建(仅 worktree===true 或已传入 handle 时)──
1640
+ // record 先创建,worktree 失败时可 finalizeFailed(record 已在 store 中)。
1641
+ // worktree 必须显式开启:worktree===true 创建新 worktree;worktree===undefined/false 不创建。
1642
+ // fork 不隐含 worktree(UC-1 fork 可独立使用,fork 仅继承上下文,在 parent cwd 跑)。
1643
+ // 非 pi 引擎带 worktree 已被上方预检同步拒绝(caps.sandbox='none'),此段实际仅
1644
+ // sandbox 能力引擎(pi:caps.sandbox='emulated')可达。
1645
+ let worktreeHandle: WorktreeHandle | undefined;
1646
+ if (typeof opts.worktree === "object") {
1647
+ // 传入的是已创建的 WorktreeHandle
1648
+ worktreeHandle = opts.worktree;
1649
+ } else if (opts.worktree === true) {
1650
+ // worktree===true(显式要求)——创建新 worktree。与 fork 正交(worktree 文件隔离不依赖 fork 上下文继承)。
1651
+ try {
1652
+ worktreeHandle = await this.worktreeManager.create(this.cwd, record.id);
1653
+ record.worktreeHandle = worktreeHandle;
1654
+ // [create-await 竞态守卫] create 的 await 窗口内 cancel/dispose 可 CAS 把 record
1655
+ // 转成 closed 终态——cancelBackground 当时读到的 worktreeHandle 可能仍是 undefined
1656
+ // (cleanup 被跳过)。赋值后同同步段检查终态:closed 则主动 cleanup(幂等,抢先的
1657
+ // fire-and-forget 清理无害)+ early-failed 返回,不进轮次 kick-off(避免子进程白跑)。
1658
+ // 实现约束:赋值 终态检查 kick-off 必须在同一同步段,中间禁止插入 await。
1659
+ if (record.status === "closed") {
1660
+ await this.worktreeManager.cleanup(worktreeHandle);
1661
+ return this.buildEarlyFailedHandle(record);
1662
+ }
1663
+ } catch (err) {
1664
+ // create 失败→不进入 run,finalizeFailed 统一收尾(含 emitPendingUnregister failed)
1665
+ const _result = await this.finalizeFailed(record, err);
1666
+ return this.buildEarlyFailedHandle(record);
1667
+ }
1925
1668
  }
1926
- if ((opts.worktree === true || typeof opts.worktree === "object") && caps.sandbox === "none") {
1927
- throw new EngineError(
1928
- "engine_capability_unsupported",
1929
- `engine '${engine.id}' 不支持 worktree 隔离(capabilities.sandbox = 'none',` +
1930
- `引擎未接文件系统隔离层)`,
1931
- `改用 engine: pi(worktree 隔离可用),或不传该参数(在 parent cwd 执行)`,
1669
+
1670
+ if (isPiRoute) {
1671
+ // pi:record 耦合执行(runSpawn 直驱 record 记账)——预备轮次经 EnginePort 交接
1672
+ //(kickOffChatRound),编排收尾(notify/终态迁移)与旧 pi 主路径语义一致。
1673
+ this.kickOffChatRound(
1674
+ record,
1675
+ { ...recordOpts, worktree: worktreeHandle },
1676
+ identity,
1677
+ this.buildSessionRunnerContext(opts.cwd),
1678
+ record.controller!.signal,
1679
+ PRIORITY_BACKGROUND,
1932
1680
  );
1681
+ } else {
1682
+ // 非 pi 引擎:engine.run 自足执行(handle+outcome),编排侧 journal 接线 + 终态迁移
1683
+ this.kickOffEngineRun(record, recordOpts, engine);
1933
1684
  }
1685
+ return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details: project(record) };
1934
1686
  }
1935
1687
 
1936
1688
  /**
1937
- * 非 pi 引擎的 detached 执行编排(与 kickOffBackground 同构的 background 语义):
1689
+ * 非 pi 引擎的 detached 执行编排(与 pi 轮次 kick-off 同构的 background 语义):
1938
1690
  * pool 并发槽(maxConcurrent 对非 pi 引擎同样生效)→ journal 接线(D6 第②级:
1939
1691
  * taskId=record.id,初始池 key 占位 'shared',onPoolResolved retarget 到引擎实际
1940
1692
  * 池 key——路径与 paths.ts 同源推导)→ engine.run(signal 接 record controller,
@@ -1962,7 +1714,7 @@ export class SubagentService {
1962
1714
  await this.runEngineTask(record, opts, engine, signal);
1963
1715
  // cancel 抢先(closedReason='cancelled')时 cancelBackground 自己 notify,跳过
1964
1716
  if (record.closedReason !== "cancelled") {
1965
- this.notifyComplete(record);
1717
+ this.notifyHost.notifyComplete(record);
1966
1718
  }
1967
1719
  } finally {
1968
1720
  this.pool.release();
@@ -1974,7 +1726,7 @@ export class SubagentService {
1974
1726
  * kickOffEngineRun 的 acquire 后主体:journal 接线(D6 第②级:taskId=record.id,
1975
1727
  * 初始池 key 占位 'shared',onPoolResolved retarget 到引擎实际池 key)→ engine.run
1976
1728
  * (signal 接 record controller,kill-chain 两级生效)→ engineHandle 回填(终态迁移
1977
- * 落 entry 前)→ 终态迁移。bg notify 归编排侧(与 kickOffBackground 收尾通知归编排对称)。
1729
+ * 落 entry 前)→ 终态迁移。bg notify 归编排侧(与 pi 轮次收尾通知归编排对称)。
1978
1730
  */
1979
1731
  private async runEngineTask(
1980
1732
  record: ExecutionRecord,
@@ -1982,20 +1734,16 @@ export class SubagentService {
1982
1734
  engine: EnginePort,
1983
1735
  signal: AbortSignal | undefined,
1984
1736
  ): Promise<void> {
1985
- const journal = new JournalWriter({
1986
- path: resolveJournalPath(getEngineDataDir(), engine.id, "shared", record.id),
1987
- taskId: record.id,
1988
- engineId: engine.id,
1989
- });
1990
- const retargetJournal = (poolKey: string): void => {
1991
- journal.retarget(resolveJournalPath(getEngineDataDir(), engine.id, poolKey, record.id));
1992
- };
1737
+ // [D3-③ journal 接线合一] writer + retarget + 路径权威收敛 common/journal-wiring
1738
+ //(与 SAR 同一实现)。chat 域无下游 onEvent 消费者——journal 是事件唯一出口,
1739
+ // 不传 forwardEvents。
1740
+ const journal = wireEventJournal({ engineId: engine.id, taskId: record.id });
1993
1741
  // [R4 §3.4 不变量 3] 运行中句柄回填:create 应答后(远早于 run resolve)回填
1994
1742
  // record.engineHandle 并经 reportRecordTransition 落 entry——运行中的 GUI 经 entry
1995
1743
  // 重建 record 即拿到 ①②级读取钥匙(sessionRef/dbPath/poolKey/journalPath),
1996
1744
  // 不再等终态回填(详情页中途打开可见当时进度快照)。journalPath 此时已是
1997
- // retarget 后的最终路径(onPoolResolved 在 prepare 期先行触发)。仅 chat 域接线
1998
- // ——workflow 域 SAR 无运行中 record 读取方,刻意不做同类回填(防误扩展)。
1745
+ // retarget 后的最终路径(onPoolResolved 在 prepare 期先行触发,writer 是路径权威)。
1746
+ // 仅 chat 域接线——workflow 域 SAR 无运行中 record 读取方,刻意不做同类回填(防误扩展)。
1999
1747
  const backfillEngineHandle = (partial: { sessionRef: Record<string, string>; poolKey: string }): void => {
2000
1748
  if (record.engineHandle !== undefined && record.engineHandle.sessionRef["sessionId"] !== undefined) {
2001
1749
  return; // 幂等守卫:终态回填已落(迟到重复回调不覆盖)
@@ -2008,14 +1756,15 @@ export class SubagentService {
2008
1756
  this.store.reportRecordTransition(record);
2009
1757
  };
2010
1758
  // 对齐点③:journal 路径权威 = 引擎声明的池 key(writer 初始用占位,retarget 后
2011
- // 与 handle.poolKey 同源)。模式对齐 SAR 的 journalingOnEvent:先落盘再转发。
1759
+ // 与 handle.poolKey 同源)。
2012
1760
  const runCtx: RunContext = {
2013
1761
  taskId: record.id,
2014
- poolKey: "shared",
1762
+ poolKey: JOURNAL_INITIAL_POOL_KEY,
2015
1763
  signal,
2016
1764
  ctxModel: opts.ctxModel,
2017
- onEvent: (event) => journal.append(event),
2018
- onPoolResolved: retargetJournal,
1765
+ onEvent: journal.onEvent,
1766
+ onPoolResolved: journal.onPoolResolved,
1767
+ // [R4 §3.4 不变量 3] 运行中句柄回填通道(engine/port.ts RunContext.onHandleReady)
2019
1768
  onHandleReady: backfillEngineHandle,
2020
1769
  // D9①:路由层 fallback 留痕投影进 outcome(zcode 无独立 record 通路)
2021
1770
  ...(record.engineFallback !== undefined ? { engineFallback: record.engineFallback } : {}),
@@ -2090,17 +1839,14 @@ export class SubagentService {
2090
1839
  /** resume 选项(M2-B1):透传 runSpawn,重开已 idle 的 session 续聊。undefined = 新 session。 */
2091
1840
  resume?: SpawnResumeOpts,
2092
1841
  ): Promise<AgentResult> {
1842
+ // [U04 阶段化提取] 主函数只留编排,按「装配(池槽/worktree/深度)→ 执行(ALS 包装
1843
+ // spawn)→ 回收(finally)→ 错误收口 → 终态收口」分段;行为逐字节不变。
2093
1844
  const pooled = record.mode === "background";
2094
1845
  let acquired = false;
2095
1846
  if (pooled) {
2096
- try {
2097
- await this.pool.acquire(priority, this.effectiveMaxConcurrentFor(record), signal);
2098
- acquired = true;
2099
- } catch {
2100
- // S1: 排队中被 abort(signal.aborted)走 cancelled,与已运行被 abort 一致。
2101
- if (signal?.aborted) return this.finalizeAborted(record);
2102
- return this.finalizeFailed(record, new Error("aborted"));
2103
- }
1847
+ const acquireFailure = await this.acquirePoolOrFinalize(record, signal, priority);
1848
+ if (acquireFailure !== undefined) return acquireFailure;
1849
+ acquired = true;
2104
1850
  }
2105
1851
  // onEvent 直通(原此处曾有 onUpdate(project(record)) 节流回流包装——生产死路径,
2106
1852
  // 三调用点恒 onUpdate: undefined、仅测试触达,已按 swf-perf-impl ledger #22 删除
@@ -2108,78 +1854,24 @@ export class SubagentService {
2108
1854
  // ExecuteOptions.onUpdate 字段一并删除,未来误用将编译期失败而非静默无效。
2109
1855
  const onEvent = rawOnEvent;
2110
1856
 
2111
- // 解析 worktree 参数:boolean → WorktreeHandle | undefined(true/undefined 由 run 内部处理)
2112
- let worktreeHandle: WorktreeHandle | undefined;
2113
- if (typeof opts.worktree === "object") {
2114
- worktreeHandle = opts.worktree;
2115
- }
2116
- // [MF#4][MF#2] fork 深度护栏:ALS 传递深度(主 session 链无 store→0,fork 推进 +1)。
2117
- const parentDepth = this.forkDepthAls.getStore() ?? this.forkDepthBaseline;
2118
- const effectiveDepth = opts.fork ? parentDepth + 1 : parentDepth;
1857
+ const worktreeHandle = this.resolveWorktreeHandle(opts);
1858
+ const { parentDepth, effectiveDepth } = this.resolveForkDepths(opts);
2119
1859
 
2120
1860
  let result: AgentResult;
2121
1861
  try {
2122
- // execCtxAls 包在 forkDepthAls 内层:B run() 期间它的 store={recordId:B.id,depth:B.depth},
2123
- // B 内创建 C createRecordForMode 读到 B → C 挂到 B 名下。两层 ALS 独立但同生命周期。
2124
- result = await this.forkDepthAls.run(effectiveDepth, () =>
2125
- this.execCtxAls.run(
2126
- { recordId: record.id, depth: record.depth },
2127
- () => runSpawn(record, opts.task, {
2128
- resolved: identity.resolved,
2129
- agentConfig: identity.agentConfig,
2130
- appendSystemPrompt: opts.appendSystemPrompt,
2131
- skillPath: opts.skillPath,
2132
- schema: opts.schema,
2133
- schemaEnv: opts.schemaEnv, // D-A6 bridge: workflow 编排层透传 schema 到 childEnv
2134
- maxTurns: opts.maxTurns,
2135
- graceTurns: opts.graceTurns,
2136
- signal,
2137
- onEvent,
2138
- stream, // text_delta streaming(background 路径有值,workflow 路径 undefined)
2139
- fork: opts.fork,
2140
- // [v8.5 B] fork-from 显式源(ExecuteOptions.forkFromSessionFile)优先于
2141
- // opts.fork 推导的 mainSessionFile;undefined = 旧语义不变。
2142
- forkSource: opts.forkFromSessionFile,
2143
- worktree: worktreeHandle,
2144
- parentForkDepth: parentDepth, // [MF#4] 父链深度,不从 opts 读
2145
- }, ctx, resume),
2146
- ),
1862
+ result = await this.runSpawnNested(
1863
+ record, opts, ctx, identity, signal, onEvent, stream, resume,
1864
+ worktreeHandle, parentDepth, effectiveDepth,
2147
1865
  );
2148
1866
  } catch (err) {
2149
1867
  // run() 正常路径不抛错,但创建期异常(createAndConfigureSession 失败)
2150
1868
  // 会逃逸出 run() —— 合成 failed result + 收尾。
2151
1869
  // swallow(不 re-throw):sync 调用方拿到合成 failed result,background 的
2152
1870
  // .then 正常跑 notify。避免异常逃逸到 tool 层 + record 卡 running。
2153
- //
2154
- // MF-6(决策 6 spec §3.1):chatMode(含 resume)spawn/创建失败不销毁对话——回退 idle
2155
- //(可恢复),让 agent 可重试 message 或 close。与一次性模式(finalizeFailed 终态销毁)区分。
2156
- if (record.chatMode) {
2157
- const errMsg = err instanceof Error ? err.message : String(err);
2158
- const failedResult: AgentResult = {
2159
- text: "",
2160
- turns: record.turnCount,
2161
- durationMs: Date.now() - record.startedAt,
2162
- success: false,
2163
- error: errMsg,
2164
- sessionId: record.id,
2165
- toolCalls: [],
2166
- };
2167
- if (tryTransition(record, "closed", "gc")) {
2168
- // 回退 idle(record.result 由 finalizeRoundToIdle 设为 error 兜底文本,notify 可读)。
2169
- await this.finalizeRoundToIdle(record, failedResult);
2170
- }
2171
- return failedResult;
2172
- }
2173
- result = await this.finalizeFailed(record, err);
2174
- return result;
1871
+ if (record.chatMode) return this.finalizeChatSpawnFailure(record, err);
1872
+ return this.finalizeFailed(record, err);
2175
1873
  } finally {
2176
- if (pooled && acquired) this.pool.release();
2177
- // 清除 streaming widget(subagent 终态,幂等)
2178
- stream?.dispose();
2179
- // [review MF1] 清除在途 resume 守卫(幂等):本轮收尾——无论轮次完成(early return)、
2180
- // MF-6 失败回退 resumable、abort 还是终态化,record 都可再次接受冷路径 message。
2181
- // execute() 新建 record 不在集合,delete 是 no-op。
2182
- this.resumesInFlight.delete(record.id);
1874
+ this.releaseRoundResources(record, pooled && acquired, stream);
2183
1875
  }
2184
1876
 
2185
1877
  // [V2 决策 2/3] chatMode 首轮闭环:runSpawn 因 agent_settled 提前 resolve(onRoundSettled
@@ -2187,97 +1879,320 @@ export class SubagentService {
2187
1879
  // 不进下方 chatMode 分流(那是 close 后 done/failed/cancelled 终态化的,走 finalizeRoundToIdle
2188
1880
  // / finalizeRecord)。tryTransition(idle→done) 天然失败(要求 status==="running"),此处显式
2189
1881
  // early return 让语义清晰 + 防状态机未来改动。防 double-notify 由 notifier dedup 兜底
2190
- //(同 id:round 60s 内吞,kickOffBackground.then 的 notify 是 no-op,见 notifier.ts L122)。
1882
+ //(同 id:round 60s 内吞,chat 轮次收尾 .then 的 notify 是 no-op,见 notifier.ts L122)。
2191
1883
  if (record.chatMode && isIdle(record)) {
2192
1884
  return result;
2193
1885
  }
2194
1886
 
2195
1887
  // v4 B-1: status 恒为 closed。cancelled 折入 closed(closedReason='cancelled')。
2196
1888
  const aborted = signal?.aborted === true;
2197
- // closedReason 派生:aborted cancelled;否则 success → user-close,!success → gc。
2198
- const closedReason: ClosedReason = aborted ? "cancelled" : result.success ? "user-close" : "gc";
2199
-
2200
- // CAS 抢锁:抢到则完整收尾;没抢到(cancel 已先设 closed+cancelled)则跳过
2201
- if (tryTransition(record, "closed", closedReason)) {
2202
- if (record.chatMode && !aborted && result.success) {
2203
- if (record.closeAfterRound) {
2204
- // close 优雅关闭(force:false):当前轮完成后终态化为 closed。
2205
- record.closeAfterRound = undefined;
2206
- await this.finalizeRecord(record, result, "closed", "user-close");
2207
- } else {
2208
- // 对话模式轮次成功完成 保持 running(旧 idle 折入 running,finalizeRoundToIdle 设回 running)。
2209
- await this.finalizeRoundToIdle(record, result);
2210
- }
2211
- } else if (record.chatMode && (!result.success || aborted)) {
2212
- // MF-6:chatMode 轮次失败/取消不销毁对话——回退 running-resumable(旧 idle,可恢复)。
2213
- if (record.closeAfterRound) {
2214
- // [M5] 优雅关闭挂起的失败/取消轮:轮已完成即兑现 close 意图终态化(含本轮 result),
2215
- // 不再回退 resumable——否则标志残留到下一轮,record 已被 tool 谎报 closed。
2216
- record.closeAfterRound = undefined;
2217
- await this.finalizeRecord(record, result, "closed", closedReason);
2218
- } else {
2219
- await this.finalizeRoundToIdle(record, result);
2220
- }
2221
- } else if (!record.chatMode && !aborted && result.success) {
2222
- if (record.closeAfterRound) {
2223
- // [M5] 非 chatMode(one-shot)busy 时 close(force:false) 置的标志在本轮完成时消费
2224
- // 终态化(对齐 close schema 文案 "release its resources")。旧代码走
2225
- // finalizeRoundToIdle 不消费——tool 返回 {closed:true} 谎报,record 永久
2226
- // running-resumable、5min idle timer 杀进程、期间还能继续收 message。
2227
- record.closeAfterRound = undefined;
2228
- await this.finalizeRecord(record, result, "closed", "user-close");
2229
- } else {
2230
- // [SP-5] one-shot 成功完成 → 保持 running(旧 idle),等待 message 触发 upgrade。
2231
- await this.finalizeRoundToIdle(record, result);
2232
- }
1889
+ await this.settleFinalOutcome(record, result, aborted, this.deriveClosedReason(aborted, result.success));
1890
+ return result;
1891
+ }
1892
+
1893
+ /** [U04 提取·装配] 池槽获取:pooled(background)record 排队 acquire。成功返回 undefined
1894
+ * 继续执行;失败返回终态 result 供调用方 early-return(该路径在 try/finally 之前,
1895
+ * 不触发轮次资源回收——与原控制流逐字节一致)。 */
1896
+ private async acquirePoolOrFinalize(
1897
+ record: ExecutionRecord,
1898
+ signal: AbortSignal | undefined,
1899
+ priority: number,
1900
+ ): Promise<AgentResult | undefined> {
1901
+ try {
1902
+ await this.pool.acquire(priority, this.effectiveMaxConcurrentFor(record), signal);
1903
+ } catch {
1904
+ // S1: 排队中被 abort(signal.aborted)走 cancelled,与已运行被 abort 一致。
1905
+ if (signal?.aborted) return this.finalizeAborted(record);
1906
+ return this.finalizeFailed(record, new Error("aborted"));
1907
+ }
1908
+ return undefined;
1909
+ }
1910
+
1911
+ /** [U04 提取·装配] 解析 worktree 参数:boolean → WorktreeHandle | undefined(true/undefined 由 run 内部处理)。 */
1912
+ private resolveWorktreeHandle(opts: ExecuteOptions): WorktreeHandle | undefined {
1913
+ return typeof opts.worktree === "object" ? opts.worktree : undefined;
1914
+ }
1915
+
1916
+ /** [U04 提取·装配] [MF#4][MF#2] fork 深度护栏:ALS 传递深度(主 session 链无 store→0,fork 推进 +1)。 */
1917
+ private resolveForkDepths(opts: ExecuteOptions): { parentDepth: number; effectiveDepth: number } {
1918
+ const parentDepth = this.forkDepthAls.getStore() ?? this.forkDepthBaseline;
1919
+ const effectiveDepth = opts.fork ? parentDepth + 1 : parentDepth;
1920
+ return { parentDepth, effectiveDepth };
1921
+ }
1922
+
1923
+ /** [U04 提取·执行] runSpawn 的两层 ALS 包装(forkDepthAls 外层 + execNesting 内层)。 */
1924
+ private runSpawnNested(
1925
+ record: ExecutionRecord,
1926
+ opts: ExecuteOptions,
1927
+ ctx: SessionRunnerContext,
1928
+ identity: ResolvedIdentity,
1929
+ signal: AbortSignal | undefined,
1930
+ onEvent: ((event: AgentEvent) => void) | undefined,
1931
+ stream: SubagentStream | undefined,
1932
+ resume: SpawnResumeOpts | undefined,
1933
+ worktreeHandle: WorktreeHandle | undefined,
1934
+ parentDepth: number,
1935
+ effectiveDepth: number,
1936
+ ): Promise<AgentResult> {
1937
+ // 嵌套上下文包在 forkDepthAls 内层:B run() 期间挂 {recordId:B.id,depth:B.depth},
1938
+ // B 内创建 C 时 createRecordForMode 读到 B → C 挂到 B 名下。两层 ALS 独立但同生命周期。
1939
+ return this.forkDepthAls.run(effectiveDepth, () =>
1940
+ this.execNesting.run(
1941
+ { recordId: record.id, depth: record.depth },
1942
+ () => runSpawn(record, opts.task, {
1943
+ resolved: identity.resolved,
1944
+ agentConfig: identity.agentConfig,
1945
+ appendSystemPrompt: opts.appendSystemPrompt,
1946
+ skillPath: opts.skillPath,
1947
+ schema: opts.schema,
1948
+ schemaEnv: opts.schemaEnv, // D-A6 bridge: workflow 编排层透传 schema 到 childEnv
1949
+ maxTurns: opts.maxTurns,
1950
+ graceTurns: opts.graceTurns,
1951
+ signal,
1952
+ onEvent,
1953
+ stream, // text_delta streaming(background 路径有值,workflow 路径 undefined)
1954
+ fork: opts.fork,
1955
+ // [v8.5 B] fork-from 显式源(ExecuteOptions.forkFromSessionFile)优先于
1956
+ // opts.fork 推导的 mainSessionFile;undefined = 旧语义不变。
1957
+ forkSource: opts.forkFromSessionFile,
1958
+ worktree: worktreeHandle,
1959
+ parentForkDepth: parentDepth, // [MF#4] 父链深度,不从 opts 读
1960
+ }, ctx, resume),
1961
+ ),
1962
+ );
1963
+ }
1964
+
1965
+ /** [U04 提取·回收] 轮次资源回收(finally 语义,幂等):池槽归还(仅 pooled 且 acquire
1966
+ * 成功)、streaming widget 清除、在途 resume 守卫清除。 */
1967
+ private releaseRoundResources(
1968
+ record: ExecutionRecord,
1969
+ holdSlot: boolean,
1970
+ stream: SubagentStream | undefined,
1971
+ ): void {
1972
+ if (holdSlot) this.pool.release();
1973
+ // 清除 streaming widget(subagent 终态,幂等)
1974
+ stream?.dispose();
1975
+ // [review MF1] 清除在途 resume 守卫(幂等):本轮收尾——无论轮次完成(early return)、
1976
+ // MF-6 失败回退 resumable、abort 还是终态化,record 都可再次接受冷路径 message。
1977
+ // execute() 新建 record 不在集合,delete 是 no-op。
1978
+ this.resumesInFlight.delete(record.id);
1979
+ }
1980
+
1981
+ /** [U04 提取·错误收口] MF-6(决策 6 spec §3.1):chatMode(含 resume)spawn/创建失败
1982
+ * 不销毁对话——回退 idle(可恢复),让 agent 可重试 message 或 close。与一次性模式
1983
+ * (finalizeFailed 终态销毁)区分。返回合成 failed result(swallow,不 re-throw)。 */
1984
+ private async finalizeChatSpawnFailure(record: ExecutionRecord, err: unknown): Promise<AgentResult> {
1985
+ const errMsg = toErrorMessage(err);
1986
+ const failedResult: AgentResult = {
1987
+ text: "",
1988
+ turns: record.turnCount,
1989
+ durationMs: Date.now() - record.startedAt,
1990
+ success: false,
1991
+ error: errMsg,
1992
+ sessionId: record.id,
1993
+ toolCalls: [],
1994
+ };
1995
+ if (tryTransition(record, "closed", "gc")) {
1996
+ // 回退 idle(record.result 由 finalizeRoundToIdle 设为 error 兜底文本,notify 可读)。
1997
+ await this.finalizeRoundToIdle(record, failedResult);
1998
+ }
1999
+ return failedResult;
2000
+ }
2001
+
2002
+ /** [U04 提取·收口派生] closedReason 派生:aborted → cancelled;否则 success → user-close,!success → gc。 */
2003
+ private deriveClosedReason(aborted: boolean, success: boolean): ClosedReason {
2004
+ return aborted ? "cancelled" : success ? "user-close" : "gc";
2005
+ }
2006
+
2007
+ /** [U04 提取·终态收口] CAS 抢锁:抢到则按 chatMode 分流完整收尾;没抢到(cancel 已先设
2008
+ * closed+cancelled)则跳过。 */
2009
+ private async settleFinalOutcome(
2010
+ record: ExecutionRecord,
2011
+ result: AgentResult,
2012
+ aborted: boolean,
2013
+ closedReason: ClosedReason,
2014
+ ): Promise<void> {
2015
+ if (!tryTransition(record, "closed", closedReason)) return;
2016
+ if (record.chatMode) {
2017
+ await this.settleChatRoundOutcome(record, result, aborted, closedReason);
2018
+ } else {
2019
+ await this.settleOneShotOutcome(record, result, aborted, closedReason);
2020
+ }
2021
+ }
2022
+
2023
+ /** [U04 提取·终态收口] chatMode 轮次分流(原 A/B 分支,De Morgan 等价拆分):
2024
+ * 成功轮(!aborted && success)与失败/取消轮(其补集 !success || aborted)各自处理
2025
+ * closeAfterRound 挂起语义。 */
2026
+ private async settleChatRoundOutcome(
2027
+ record: ExecutionRecord,
2028
+ result: AgentResult,
2029
+ aborted: boolean,
2030
+ closedReason: ClosedReason,
2031
+ ): Promise<void> {
2032
+ if (!aborted && result.success) {
2033
+ if (record.closeAfterRound) {
2034
+ // close 优雅关闭(force:false):当前轮完成后终态化为 closed。
2035
+ await this.consumeCloseAfterRound(record, result, "user-close");
2036
+ } else {
2037
+ // 对话模式轮次成功完成 → 保持 running(旧 idle 折入 running,finalizeRoundToIdle 设回 running)。
2038
+ await this.finalizeRoundToIdle(record, result);
2039
+ }
2040
+ } else {
2041
+ // MF-6:chatMode 轮次失败/取消不销毁对话——回退 running-resumable(旧 idle,可恢复)。
2042
+ if (record.closeAfterRound) {
2043
+ // [M5] 优雅关闭挂起的失败/取消轮:轮已完成即兑现 close 意图终态化(含本轮 result),
2044
+ // 不再回退 resumable——否则标志残留到下一轮,record 已被 tool 谎报 closed。
2045
+ await this.consumeCloseAfterRound(record, result, closedReason);
2233
2046
  } else {
2234
- // chatMode 失败/取消 或其他终态:一次性销毁(archive + worktree cleanup)。
2235
- await this.finalizeRecord(record, result, "closed", closedReason);
2047
+ await this.finalizeRoundToIdle(record, result);
2236
2048
  }
2237
2049
  }
2238
- return result;
2239
2050
  }
2240
2051
 
2241
- /** background 的步骤 4-6:包进 detached promise(不 await),execute 立即返回。 */
2242
- private kickOffBackground(
2052
+ /** [U04 提取·终态收口] one-shot(非 chatMode)分流(原 C/D 分支):成功轮消费
2053
+ * closeAfterRound 挂起标志;失败/取消一次性销毁。 */
2054
+ private async settleOneShotOutcome(
2055
+ record: ExecutionRecord,
2056
+ result: AgentResult,
2057
+ aborted: boolean,
2058
+ closedReason: ClosedReason,
2059
+ ): Promise<void> {
2060
+ if (!aborted && result.success) {
2061
+ if (record.closeAfterRound) {
2062
+ // [M5] 非 chatMode(one-shot)busy 时 close(force:false) 置的标志在本轮完成时消费
2063
+ // 终态化(对齐 close schema 文案 "release its resources")。旧代码走
2064
+ // finalizeRoundToIdle 不消费——tool 返回 {closed:true} 谎报,record 永久
2065
+ // running-resumable、5min idle timer 杀进程、期间还能继续收 message。
2066
+ await this.consumeCloseAfterRound(record, result, "user-close");
2067
+ } else {
2068
+ // [SP-5] one-shot 成功完成 → 保持 running(旧 idle),等待 message 触发 upgrade。
2069
+ await this.finalizeRoundToIdle(record, result);
2070
+ }
2071
+ } else {
2072
+ // 非 chatMode 失败/取消 或其他终态:一次性销毁(archive + worktree cleanup)。
2073
+ await this.finalizeRecord(record, result, "closed", closedReason);
2074
+ }
2075
+ }
2076
+
2077
+ /** [U04 提取·终态收口] closeAfterRound 挂起标志消费(原三处分支的公共收尾序列):
2078
+ * 清标志 + 终态化 closed。reason:成功轮恒 user-close;失败/取消轮用派生值。 */
2079
+ private async consumeCloseAfterRound(
2080
+ record: ExecutionRecord,
2081
+ result: AgentResult,
2082
+ reason: ClosedReason,
2083
+ ): Promise<void> {
2084
+ record.closeAfterRound = undefined;
2085
+ await this.finalizeRecord(record, result, "closed", reason);
2086
+ }
2087
+
2088
+ /**
2089
+ * pi chat 域轮次的 detached 编排(D2 单轨——旧 pi 主路径绕过 EnginePort 的 detached
2090
+ * 编排删除后的 EnginePort 化形态):
2091
+ * 预备轮次(record/opts/identity/host ctx/stream/resume)挂载交接 Map → engine.run 经
2092
+ * ctx.taskId 消费(PiEngine chat 分支回调 runChatRound——即 runAndFinalize 链,含 pool
2093
+ * 并发槽 acquire/release 与杀链,行为零变化)→ 完成回注 notify(cancel 抢先时跳过,
2094
+ * 与旧 .then 语义一致)。
2095
+ * stream 在挂载前同步创建(spawn 前置不变量,stream-sink 退役步骤 2 守护)。
2096
+ * chat 域不接 event journal(pi 子代理 session JSONL 即原生数据源;与迁移前产物
2097
+ * 形态一致——journal 接线仅 workflow 域 SAR 与非 pi 引擎 chat 路径)。
2098
+ */
2099
+ private kickOffChatRound(
2243
2100
  record: ExecutionRecord,
2244
2101
  opts: ExecuteOptions,
2245
- ctx: SessionRunnerContext,
2246
2102
  identity: ResolvedIdentity,
2103
+ ctx: SessionRunnerContext,
2247
2104
  signal: AbortSignal | undefined,
2248
2105
  priority: number,
2249
- /** resume 选项(M2-B1):透传 runAndFinalize→runSpawn。undefined = 新 session。 */
2106
+ /** resume 选项(M2-B1):透传轮次执行→runSpawn。undefined = 新 session。 */
2250
2107
  resume?: SpawnResumeOpts,
2251
2108
  ): void {
2252
2109
  // 创建 streaming 生命周期对象。策略(含 widget 退役步骤 2:GUI + relay 激活时停发
2253
2110
  // 私货、TUI/未激活原样创建、sink 未注入降级 undefined)集中在 createBackgroundStream。
2254
2111
  const stream = createBackgroundStream(record.id, this.streamSink, ctx.mode, process.env);
2255
2112
 
2256
- void this.runAndFinalize(
2257
- record, opts, ctx, identity, signal, priority,
2258
- undefined, stream, resume,
2259
- )
2113
+ this.chatRoundTickets.set(record.id, { record, opts, identity, ctx, signal, priority, stream, resume });
2114
+ void this.chatPiEngine
2115
+ .run(
2116
+ // task 形参仅满足 port 签名——chat 轮次由 ticket lossless 携带
2117
+ //(identity/forkFromSessionFile/host ctx 是 host 编排件,不入任务声明
2118
+ // AgentCallOpts——[u-3b D6] 双形态保留的结构性裁决见 ChatRoundTicket 注释)
2119
+ executeOptionsToEngineTaskSpec(opts),
2120
+ { taskId: record.id, poolKey: PI_POOL_KEY, signal, stream },
2121
+ )
2260
2122
  .then(() => {
2261
2123
  // background 回注:仅当本路径抢到 CAS 才 notify。cancel 抢先时 closedReason=
2262
2124
  // 'cancelled'(cancelBackground 自己 notify);[T4①/PS-2] parent-new/parent-fork
2263
2125
  // 是 disposeAllRecords 的编排性关闭(record 已关、告知由 list 的 closedReason
2264
2126
  // 表达)——迟到的完成回注不注入(可能已切换的)新 session。
2265
2127
  if (notifyGateAllowsDelivery(record.closedReason)) {
2266
- this.notifyComplete(record);
2128
+ this.notifyHost.notifyComplete(record);
2267
2129
  }
2268
2130
  })
2269
2131
  .catch((err: unknown) => {
2270
- // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),
2132
+ // detached 吞错:轮次执行内部已 finalize record(含 emitPendingUnregister),
2271
2133
  // 且 finalizeRecord 的 manifest 写入已降级为 best-effort(失败仅 logger.error + appendEntry,
2272
2134
  // 不外抛)。因此此处不应走到——但作为最后一道兼底,记录调试日志后吞下,不外抛。
2273
2135
  // 完成通知由 finalizeRecord 内的 emitPendingUnregister 承担(pending-notifications 消费)。
2274
2136
  // cancel 抢先时 status=cancelled,cancelBackground 自己 emit,此处无需重复。
2137
+ // 交接包未被消费(engine.run 前置 throw 的极端形态)时的泄漏兜底。
2138
+ this.chatRoundTickets.delete(record.id);
2275
2139
  if (err instanceof Error) {
2276
2140
  logger.debug(`[subagent] background finalize error (record=${record.id}): ${err.message}`);
2277
2141
  }
2278
2142
  });
2279
2143
  }
2280
2144
 
2145
+ /** PiEngine 的编排服务适配器(chat 绑定):闭包持有本实例的编排面——chat 轮次交接
2146
+ * 由此成为 PiEngineService 的可选面(SAR 直绑 Service 的 workflow 实例不提供,
2147
+ * Service 公共接口不为引擎内部交接扩面)。 */
2148
+ private piEngineServiceAdapter(): PiEngineService {
2149
+ return {
2150
+ executeAndAwait: (opts, signal, onEvent, stream) => this.executeAndAwait(opts, signal, onEvent, stream),
2151
+ getRecordForAction: (id) => this.getRecordForAction(id),
2152
+ closeSubagent: (record, force) => this.closeSubagent(record, force),
2153
+ cancel: (id) => this.cancel(id),
2154
+ collectRecords: (limit, statusFilter) => this.collectRecords(limit, statusFilter),
2155
+ takeChatRound: (taskId) => this.takeChatTicket(taskId),
2156
+ runChatRound: (ticket) => this.runTicketRound(ticket),
2157
+ resumeChatRound: (record, text) => this.resumeColdRound(record, text),
2158
+ reportRecordTransition: (record) => this.store.reportRecordTransition(record),
2159
+ };
2160
+ }
2161
+
2162
+ /** [D4 聚合连带] PiEngineService 的显式结构视图(registration / SAR 直绑消费)。
2163
+ * 原两绑定点把 SubagentService 整体结构化兼容为 PiEngineService——依赖查询/交互
2164
+ * 方法 public;聚合收窄后改经本视图显式适配(成员集合与原直绑等价,行为零差异)。
2165
+ * getter 形态:face 视图(惰性构造的适配对象)而非动作方法。 */
2166
+ get asEngineService(): PiEngineService {
2167
+ return this.piEngineServiceAdapter();
2168
+ }
2169
+
2170
+ /** chat 域轮次交接的消费侧(PiEngine.run 回调):一次性取走,防重复消费。私有名与
2171
+ * PiEngineService 可选面成员不同名(历史约束:D4 聚合前的结构化直绑时代,私有同名
2172
+ * 成员会阻断 SubagentService 直绑;聚合后经 asEngineService 显式视图,约束已消失,
2173
+ * 不同名保留为与 adapter 显式映射一致的命名纪律)。 */
2174
+ private takeChatTicket(taskId: string): ChatRoundTicket | undefined {
2175
+ const ticket = this.chatRoundTickets.get(taskId);
2176
+ if (ticket !== undefined) this.chatRoundTickets.delete(taskId);
2177
+ return ticket;
2178
+ }
2179
+
2180
+ /** PiEngine chat 分支的执行回调:原 runAndFinalize 直调链的 EnginePort 化落点
2181
+ * (编排归 Service——pool 槽 + runSpawn + 终态迁移,行为零变化)。 */
2182
+ private async runTicketRound(ticket: ChatRoundTicket): Promise<AgentResult> {
2183
+ return this.runAndFinalize(
2184
+ ticket.record,
2185
+ ticket.opts,
2186
+ ticket.ctx,
2187
+ ticket.identity,
2188
+ ticket.signal,
2189
+ ticket.priority,
2190
+ undefined,
2191
+ ticket.stream,
2192
+ ticket.resume,
2193
+ );
2194
+ }
2195
+
2281
2196
  /**
2282
2197
  * 取消 background record。CAS 抢锁(tryTransition)——抢到则 notify + 写 tombstone;
2283
2198
  * 没抢到(detached 已 finalize,record 已终态)返回 false,不触碰任何收尾副作用。
@@ -2291,7 +2206,7 @@ export class SubagentService {
2291
2206
  record.controller?.abort();
2292
2207
  // [M6] 显式 kill + disarm:chatMode 首轮 agent_settled 后 runSpawn 提前 resolveRun(0)
2293
2208
  // 返回,`opts.signal.removeEventListener("abort", onAbort)`(session-runner runSpawn 尾部)
2294
- // 已移除 abort→kill listener;热路径续聊轮(deliverMessage 直接 sendPromptCommand)不再
2209
+ // 已移除 abort→kill listener;热路径续聊轮(PiEngine 直接 stdin 写入)不再
2295
2210
  // 进 runSpawn。此后 cancel 只有 controller.abort() 无人响应——record 已终态化 cancelled
2296
2211
  // 但子进程继续跑完当前 turn(工具副作用继续发生),之后 agent_settled 还对已 archived
2297
2212
  // record 触发脏通知(round+1 → 新 dedup key → "finished a round"),最终靠 5min idle
@@ -2341,9 +2256,9 @@ export class SubagentService {
2341
2256
  }
2342
2257
  }
2343
2258
  // pending-notifications:cancel 注销(只记 registry 状态)
2344
- emitPendingUnregister(this.pi, record.id, "closed");
2345
- // cancel 完成通知(与 kickOffBackground.then 对称——cancel 抢先时 .then 跳过 notify)
2346
- this.notifyComplete(record);
2259
+ this.notifyHost.emitPendingUnregister(record.id, "closed");
2260
+ // cancel 完成通知(与轮次收尾 .then 对称——cancel 抢先时 .then 跳过 notify)
2261
+ this.notifyHost.notifyComplete(record);
2347
2262
  return true;
2348
2263
  }
2349
2264
 
@@ -2363,7 +2278,7 @@ export class SubagentService {
2363
2278
  store: this.store,
2364
2279
  modelService: this.modelService,
2365
2280
  pi: this.pi,
2366
- emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
2281
+ emitUnregister: (id, st) => this.notifyHost.emitPendingUnregister(id, st),
2367
2282
  sessionDir: this.sessionsDir,
2368
2283
  },
2369
2284
  record,
@@ -2388,7 +2303,7 @@ export class SubagentService {
2388
2303
  store: this.store,
2389
2304
  modelService: this.modelService,
2390
2305
  pi: this.pi,
2391
- emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
2306
+ emitUnregister: (id, st) => this.notifyHost.emitPendingUnregister(id, st),
2392
2307
  },
2393
2308
  record,
2394
2309
  result,
@@ -2399,7 +2314,7 @@ export class SubagentService {
2399
2314
  * AgentResult → CAS 抢锁 → finalizeRecord(与正常路径同形)。返回合成 result 供 runAndFinalize
2400
2315
  * 继续返回(不 re-throw,swallow 策略)。 */
2401
2316
  private async finalizeFailed(record: ExecutionRecord, err: unknown): Promise<AgentResult> {
2402
- const errMsg = err instanceof Error ? err.message : String(err);
2317
+ const errMsg = toErrorMessage(err);
2403
2318
  // durationMs 用真实耗时(startedAt → now),避免失败统计恒为 0 失真。
2404
2319
  const failedResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: errMsg, sessionId: record.id, toolCalls: [] };
2405
2320
  // CAS 抢锁:抢到(status 仍 running)则完整收尾;没抢到(cancel 已先设 cancelled)跳过。
@@ -2502,74 +2417,11 @@ export class SubagentService {
2502
2417
  // 落盘统一用 rootCwd 编码,ROOT 磁盘重建才扫得到深层 record(与 sessionRootId 同构)。
2503
2418
  rootCwd: this.rootCwd,
2504
2419
  // [V2 决策 2] chatMode 首轮闭环:agent_settled 时 session-runner 调本回调。
2505
- // 轻量 idle 化(选项 1):设 record.status=idle + round+=1 让 notify 守卫放行 + notify
2506
- // agent,但**不调 doFinalizeRoundToIdle**(不 emitUnregister /
2507
- // redeliver——V2 要删的副作用都不做)。runAndFinalize 检测到 status=idle early return,
2508
- // 不进现有 chatMode 分流。Step 5 删 idle 状态机时统一清理这个过渡 idle。
2509
- // 防箭头函数 this 丢失:用箭头函数捕获 SubagentService 实例 this。
2510
- onRoundSettled: (record) => {
2511
- // v4 B-1:status 保持 running(旧 idle 折入 running);session-runner 已 arm idle timer,
2512
- // isIdle=true 让 notify 守卫放行(时序:armIdleTimer → onRoundSettled,见 session-runner.ts:670)。
2513
- // round 可能初始 undefined(与 notifier.ts `record.round ?? 0` 兜底一致),
2514
- // 首轮 0+1=1。round 是 notifier dedup key 的组成部分,递增后同 id 下一轮不被 60s dedup 吞。
2515
- record.round = (record.round ?? 0) + 1;
2516
- // [N2][增量] 轮次回复写点(增量语义):roundText 自 roundBaseTurnIndex 起派生本轮增量
2517
- //(undefined 视为 0——首轮增量 = 全量,与改造前首轮逐字节一致)。成功轮次的 MF-2 原写点
2518
- //(doFinalizeRoundToIdle)不可达——agent_settled 恒 arm idle timer → runAndFinalize 恒
2519
- // early return。写入 record.result 后再 notify;本轮无非空增量且无 lastError(纯工具轮 /
2520
- // interrupt 抢占轮 / 模型空回复)时固定占位 "(no output this round)"(D5:增量语义下沿用
2521
- // 旧 record.result = 上一轮增量 → 本轮通知正文 = 上一轮内容,父 agent 误读为原样重复回复;
2522
- // lastError 兜底保留让失败轮通知可读)。后续 closeAfterRoundSettled 的合成 result 读
2523
- // record.result,同样携带本轮增量。
2524
- const roundText = getFullTextFrom(record, record.roundBaseTurnIndex ?? 0);
2525
- record.result = roundText ||
2526
- (record.lastError ? `round did not complete: ${record.lastError}` : "(no output this round)");
2527
- // 先送达本轮增量(notify),再推进 base / 消费 closeAfterRound——终态通知由
2528
- // closeAfterRoundSettled / closeChatIdle 的 notifyClosed 显式发出(dedup 身份为裸 id,
2529
- // 与本次 round notify 的 id:round key 区分),保证「本轮增量 + 终态通知」都送达;
2530
- // kickOffBackground.then 的冷路径 notifyComplete 仍与本次 round notify 同 key 被 60s
2531
- // dedup 吞(不构成第三条)。
2532
- //
2533
- // 幂等性(覆盖面如实限定):同步路径 at-least-once——notifyComplete(同步 void)抛错时
2534
- // 推进/消费被跳过 → base 不推进 → 增量未消费,下轮 roundText 必含本轮文本(重发载体为
2535
- // 后续轮次增量拼接)。kickOffBackground.then 的冷路径 notifyComplete 不构成重发通道
2536
- // (notifier dedup.set 与 pending.splice 均先于 sendMessage,同 key `${id}:${round}`——
2537
- // round 已递增——60s 窗内重入被吞)。异步 flush 窗口不保证:合并 timer armed(其他 busy
2538
- // background 在场)或 isIdle 退避期间 notify 的『成功』只是入队,实际 sendMessage 发生在
2539
- // base 推进之后的异步时机;该窗口进程崩溃或 sendMessage 失败 → 丢失不可重发(现状全量
2540
- // 重发的次轮自愈在增量语义下消失),wave1 期恢复通道仅父 agent 经 /subagents 详情读取
2541
- // record.result,wave2 指针行落地后补全。反序(先推进后 notify)在同步路径 notify 失败时
2542
- // 静默丢增量且无任何重算机会,故 notify 后推进是定案。重复发送由 notifier dedup key
2543
- // 60s 窗界定。
2544
- this.notifyComplete(record);
2545
- // R1 观测哨(不变式违反形态):推进前检查末 turn 未闭合且 text 非空——pi 现序下不可达
2546
- //(带 usage 的 message_end 恒先于 turn_end,settle 时 turn 全闭合,见 types.ts
2547
- // roundBaseTurnIndex 注释的行号锚定),ES1 单测自造事件序列锁不住 pi 层变化;pi 升级若
2548
- // 改变 turn_end/agent_end 时序,此哨兵留痕(该形态下公式仍把文本计入本轮,不丢数据)。
2549
- const lastTurn = record.turns[record.turns.length - 1];
2550
- if (lastTurn !== undefined && !lastTurn.closed && lastTurn.text.length > 0) {
2551
- logger.warn(
2552
- `[subagents] round settle with unclosed non-empty turn (record=${record.id}, turnIndex=${record.turns.length - 1}) — pi turn_end/agent_end ordering may have changed`,
2553
- );
2554
- }
2555
- // [增量] base 推进(notify 之后):下一轮增量从本轮边界起。滞后空 turn 不计入边界
2556
- //(防御分支,留在下一轮增量内防丢文本——nextRoundBaseTurnIndex 注释)。
2557
- record.roundBaseTurnIndex = nextRoundBaseTurnIndex(record);
2558
- // 轮终迁移持久化(residual-fixes U3 补全):热路径轮终不经 doFinalizeRoundToIdle
2559
- //(agent_settled 恒 arm idle timer → runAndFinalize 恒 early return,MF-2 原写点
2560
- // 不可达)——不 appendEntry 则 runtime/W18 派生缓存不失效,renderer 停留在
2561
- // register 快照(无 result),chat 等续聊的 waiting 形态显示不出来、spinner
2562
- // 卡死。显式上报:entry 携带本轮 result/round/chatMode(§5.4:result 有值 +
2563
- // chatMode=true → waiting)。closeAfterRound 的终态 entry 在此后追加,序不变。
2564
- this.store.reportRecordTransition(record);
2565
- // [M5] closeAfterRound 消费点:chatMode 每轮完成的统一汇聚点(热路径轮不经
2566
- // runAndFinalize CAS 分支——agent_settled 恒 arm idle timer → runAndFinalize 恒
2567
- // early return,旧消费点对 chatMode 不可达,标志置了无人消费、tool 谎报 closed:true)。
2568
- if (record.closeAfterRound) {
2569
- record.closeAfterRound = undefined;
2570
- void this.closeAfterRoundSettled(record);
2571
- }
2572
- },
2420
+ // [D4-②] 业务闭包已拆至 round-settlement.ts(this.settleRound 字段工厂构造,
2421
+ // deps 回调注入 notifyComplete / reportRecordTransition / closeAfterRoundSettled,
2422
+ // 行为逐字节等价)——轻量 idle 化语义(round+=1 + notify agent,不调
2423
+ // doFinalizeRoundToIdle)与增量派生/base 推进/哨兵的机制注释见该文件。
2424
+ onRoundSettled: this.settleRound,
2573
2425
  };
2574
2426
  }
2575
2427
  }