@zhushanwen/subagent-core 0.2.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 (347) hide show
  1. package/README.md +45 -2
  2. package/agents/analyst.md +60 -0
  3. package/agents/coder.md +69 -0
  4. package/agents/debugger.md +66 -0
  5. package/agents/doc-reviewer.md +49 -0
  6. package/agents/explorer.md +63 -0
  7. package/agents/general-purpose.md +32 -0
  8. package/agents/orchestrator.md +61 -0
  9. package/agents/planner.md +53 -0
  10. package/agents/researcher.md +67 -0
  11. package/agents/reviewer.md +73 -0
  12. package/dist/chunk-43ONBFZX.js +240 -0
  13. package/dist/chunk-APZY4IME.js +27 -0
  14. package/dist/chunk-V3VHZ2VX.js +59 -0
  15. package/dist/execution/engine/engines/zcode/constants.cjs +62 -9
  16. package/dist/execution/engine/engines/zcode/constants.d.cts +116 -12
  17. package/dist/execution/engine/engines/zcode/constants.d.ts +116 -12
  18. package/dist/execution/engine/engines/zcode/constants.js +39 -7
  19. package/dist/execution/engine/engines/zcode/reader.d.cts +2 -1
  20. package/dist/execution/engine/engines/zcode/reader.d.ts +2 -1
  21. package/dist/execution/engine/engines/zcode/reader.js +4 -234
  22. package/dist/execution/engine/paths.js +7 -19
  23. package/dist/index.cjs +18234 -1277
  24. package/dist/index.d.cts +5646 -673
  25. package/dist/index.d.ts +5646 -673
  26. package/dist/index.js +17828 -1277
  27. package/dist/{types-BxyAidGf.d.cts → types-Dv4QhSJ_.d.cts} +678 -112
  28. package/dist/{types-BxyAidGf.d.ts → types-Dv4QhSJ_.d.ts} +678 -112
  29. package/package.json +7 -4
  30. package/src/__tests__/agent-opts-resolver-schema-prompt.test.ts +0 -1
  31. package/src/__tests__/append-system-prompt-assembly.test.ts +8 -7
  32. package/src/__tests__/fr4-get-state-handshake.test.ts +2 -2
  33. package/src/__tests__/m2-append-content-probe.test.ts +0 -1
  34. package/src/__tests__/manifest-store.test.ts +10 -9
  35. package/src/__tests__/record-store-cache.test.ts +0 -2
  36. package/src/__tests__/record-store-index.test.ts +1 -2
  37. package/src/__tests__/review-fix-loop-script.test.ts +93 -3
  38. package/src/__tests__/review-fix-loop-utils.test.ts +31 -10
  39. package/src/__tests__/robustness-low-batch1.test.ts +6 -5
  40. package/src/__tests__/robustness-medium-batch1.test.ts +2 -2
  41. package/src/__tests__/robustness-medium-batch2.test.ts +17 -6
  42. package/src/__tests__/robustness-medium-batch4.test.ts +2 -2
  43. package/src/__tests__/session-runner.test.ts +1 -1
  44. package/src/__tests__/smoke.test.ts +9 -2
  45. package/src/core/__tests__/host-services.test.ts +2 -2
  46. package/src/core/__tests__/logger.test.ts +1 -1
  47. package/src/core/error-message.ts +9 -0
  48. package/src/core/host-services.ts +21 -5
  49. package/src/core/notify-ports.ts +18 -5
  50. package/src/execution/__tests__/agent-profile.test.ts +232 -0
  51. package/src/execution/__tests__/agent-registry.test.ts +370 -0
  52. package/src/execution/__tests__/agent-result-mapper.test.ts +18 -2
  53. package/src/execution/__tests__/agents-assembly.test.ts +219 -0
  54. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +6 -6
  55. package/src/execution/__tests__/chat-engine-routing.test.ts +136 -30
  56. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +5 -4
  57. package/src/execution/__tests__/cold-resurrect.test.ts +283 -0
  58. package/src/execution/__tests__/create-concurrency-pool.test.ts +194 -0
  59. package/src/execution/__tests__/delivery-methods.test.ts +77 -62
  60. package/src/execution/__tests__/descendant-sweep-guards.test.ts +220 -0
  61. package/src/execution/__tests__/descendant-sweep.test.ts +269 -0
  62. package/src/execution/__tests__/dialog-queue.test.ts +199 -1
  63. package/src/execution/__tests__/engine-model-validation.test.ts +741 -0
  64. package/src/execution/__tests__/epipe-fallback.test.ts +31 -24
  65. package/src/execution/__tests__/execute-and-await-worktree.test.ts +21 -91
  66. package/src/execution/__tests__/execute-nesting.test.ts +34 -188
  67. package/src/execution/__tests__/execution-record.test.ts +217 -60
  68. package/src/execution/__tests__/execution-runtime-face.test.ts +272 -0
  69. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +3 -3
  70. package/src/execution/__tests__/finalize-record.test.ts +278 -16
  71. package/src/execution/__tests__/gc-timer.test.ts +57 -2
  72. package/src/execution/__tests__/get-record-for-action-restart.test.ts +14 -12
  73. package/src/execution/__tests__/get-state-handshake.test.ts +127 -0
  74. package/src/execution/__tests__/helpers/mock-extension-api.ts +1 -1
  75. package/src/execution/__tests__/helpers/session-runner-mocks.ts +113 -0
  76. package/src/execution/__tests__/helpers/spawn-mock.ts +150 -3
  77. package/src/execution/__tests__/helpers/subagent-service-mocks.ts +137 -0
  78. package/src/execution/__tests__/keep-alive-no-progress.test.ts +280 -0
  79. package/src/execution/__tests__/kill-all-escalation.test.ts +196 -0
  80. package/src/execution/__tests__/lifecycle-manager-idle-timer-identity.test.ts +117 -0
  81. package/src/execution/__tests__/lifecycle-manager.test.ts +39 -0
  82. package/src/execution/__tests__/lifecycle-predicates.test.ts +1 -2
  83. package/src/execution/__tests__/manifest-parentid.test.ts +4 -4
  84. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +99 -0
  85. package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +113 -0
  86. package/src/execution/__tests__/model-resolver.test.ts +2 -2
  87. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +23 -121
  88. package/src/execution/__tests__/nested-visibility.test.ts +2 -0
  89. package/src/execution/__tests__/notify-ledger.test.ts +1249 -0
  90. package/src/execution/__tests__/output-collector.test.ts +117 -6
  91. package/src/execution/__tests__/pi-invocation.test.ts +1 -1
  92. package/src/execution/__tests__/record-store-orphan-revive.test.ts +159 -0
  93. package/src/execution/__tests__/record-store.test.ts +89 -5
  94. package/src/execution/__tests__/records-cwd-isolation.test.ts +2 -2
  95. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +29 -132
  96. package/src/execution/__tests__/recursive-visibility-env.test.ts +55 -179
  97. package/src/execution/__tests__/rpc-mode.test.ts +2 -2
  98. package/src/execution/__tests__/run-and-finalize-anchoring.test.ts +387 -0
  99. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +19 -9
  100. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +5 -4
  101. package/src/execution/__tests__/run-spawn-edges.test.ts +418 -26
  102. package/src/execution/__tests__/run-spawn-integration.test.ts +3 -3
  103. package/src/execution/__tests__/run-spawn-resume.test.ts +2 -2
  104. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +2 -2
  105. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +16 -8
  106. package/src/execution/__tests__/service-kill-escalation.test.ts +91 -0
  107. package/src/execution/__tests__/session-context-resolver.test.ts +0 -2
  108. package/src/execution/__tests__/session-file-gc.test.ts +35 -0
  109. package/src/execution/__tests__/session-pending.test.ts +471 -0
  110. package/src/execution/__tests__/session-reconstructor.test.ts +366 -15
  111. package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +110 -0
  112. package/src/execution/__tests__/session-runner-close-prune.test.ts +181 -0
  113. package/src/execution/__tests__/session-runner-dispatch.test.ts +489 -0
  114. package/src/execution/__tests__/session-runner-epipe.test.ts +4 -3
  115. package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +227 -0
  116. package/src/execution/__tests__/session-runner-lifecycle-helpers.test.ts +290 -0
  117. package/src/execution/__tests__/session-runner-schema-env.test.ts +7 -5
  118. package/src/execution/__tests__/settled-watchdog.test.ts +524 -0
  119. package/src/execution/__tests__/spawn-args.test.ts +11 -10
  120. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +26 -23
  121. package/src/execution/__tests__/spawn-event-adapter.test.ts +1 -1
  122. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +5 -4
  123. package/src/execution/__tests__/spawned-children.test.ts +155 -11
  124. package/src/execution/__tests__/start-sync-model-guard.test.ts +4 -4
  125. package/src/execution/__tests__/stdin-writer.test.ts +6 -4
  126. package/src/execution/__tests__/stream-sink-retirement.test.ts +6 -6
  127. package/src/execution/__tests__/subagent-actions-core.test.ts +1017 -0
  128. package/src/execution/__tests__/subagent-service-message-close.test.ts +99 -35
  129. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +174 -0
  130. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +271 -0
  131. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +33 -29
  132. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +341 -0
  133. package/src/execution/__tests__/subagent-service.test.ts +37 -30
  134. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +88 -12
  135. package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +75 -0
  136. package/src/execution/__tests__/subprocess-agent-runner.test.ts +22 -20
  137. package/src/execution/__tests__/temp-prompt.test.ts +1 -1
  138. package/src/execution/__tests__/timeout-integration.test.ts +13 -12
  139. package/src/execution/__tests__/turn-limiter-semantics.test.ts +1 -1
  140. package/src/execution/__tests__/turn-limiter.test.ts +1 -1
  141. package/src/execution/__tests__/ui-request-handler-factory.test.ts +107 -7
  142. package/src/execution/__tests__/ui-request-handler.test.ts +4 -3
  143. package/src/execution/__tests__/ui-request-queue.test.ts +167 -2
  144. package/src/execution/__tests__/worktree-git-ops.test.ts +571 -0
  145. package/src/execution/__tests__/worktree-manager.test.ts +14 -5
  146. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -3
  147. package/src/execution/__tests__/worktree-reconcile-aging.test.ts +204 -0
  148. package/src/execution/agent-registry.ts +298 -30
  149. package/src/execution/agent-result-mapper.ts +3 -0
  150. package/src/execution/agents-assembly.ts +88 -0
  151. package/src/execution/cold-resurrect.ts +199 -0
  152. package/src/execution/concurrency-pool.ts +71 -9
  153. package/src/execution/dialog-queue.ts +101 -3
  154. package/src/execution/engine/__tests__/common/capability-gate.test.ts +91 -0
  155. package/src/execution/engine/__tests__/common/data-dir.test.ts +1 -1
  156. package/src/execution/engine/__tests__/common/journal-wiring.test.ts +84 -0
  157. package/src/execution/engine/__tests__/common/kill-chain.test.ts +34 -7
  158. package/src/execution/engine/__tests__/common/persona-router.test.ts +4 -5
  159. package/src/execution/engine/__tests__/common/pool-manager.test.ts +211 -51
  160. package/src/execution/engine/__tests__/common/session-view-service-zcode-dbpath.test.ts +149 -0
  161. package/src/execution/engine/__tests__/common/session-view-service.test.ts +499 -0
  162. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +56 -99
  163. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +88 -15
  164. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +33 -7
  165. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +1 -1
  166. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +75 -54
  167. package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +128 -0
  168. package/src/execution/engine/__tests__/engine-discovery.test.ts +15 -3
  169. package/src/execution/engine/__tests__/model-prompt.test.ts +15 -3
  170. package/src/execution/engine/__tests__/registry.test.ts +94 -3
  171. package/src/execution/engine/__tests__/routing.test.ts +115 -6
  172. package/src/execution/engine/common/capability-gate.ts +92 -0
  173. package/src/execution/engine/common/journal-wiring.ts +97 -0
  174. package/src/execution/engine/common/kill-chain.ts +42 -10
  175. package/src/execution/engine/common/nesting-guard.ts +66 -0
  176. package/src/execution/engine/common/persona-router.ts +18 -8
  177. package/src/execution/engine/common/pool-manager.ts +332 -78
  178. package/src/execution/engine/common/session-view-service.ts +506 -0
  179. package/src/execution/engine/common/session-view-types.ts +125 -0
  180. package/src/execution/engine/engine-discovery.ts +12 -16
  181. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +174 -28
  182. package/src/execution/engine/engines/pi/__tests__/spawn-opts-direct.test.ts +257 -0
  183. package/src/execution/{argv-mirror.ts → engine/engines/pi/argv-mirror.ts} +43 -21
  184. package/src/execution/{get-state-handshake.ts → engine/engines/pi/get-state-handshake.ts} +86 -11
  185. package/src/execution/{output-collector.ts → engine/engines/pi/output-collector.ts} +108 -19
  186. package/src/execution/engine/engines/pi/pi-engine.ts +434 -38
  187. package/src/execution/{pi-invocation.ts → engine/engines/pi/pi-invocation.ts} +2 -2
  188. package/src/execution/engine/engines/pi/reader.ts +11 -0
  189. package/src/execution/engine/engines/pi/registration.ts +4 -2
  190. package/src/execution/engine/engines/pi/session-runner.ts +2840 -0
  191. package/src/execution/{spawn-event-adapter.ts → engine/engines/pi/spawn-event-adapter.ts} +75 -34
  192. package/src/execution/{stdin-writer.ts → engine/engines/pi/stdin-writer.ts} +10 -9
  193. package/src/execution/{temp-prompt.ts → engine/engines/pi/temp-prompt.ts} +2 -2
  194. package/src/execution/{turn-limiter.ts → engine/engines/pi/turn-limiter.ts} +1 -1
  195. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +301 -0
  196. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-appserver.json +36 -0
  197. package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +321 -0
  198. package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +473 -0
  199. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +7 -210
  200. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +6 -110
  201. package/src/execution/engine/engines/zcode/__tests__/session-channel-dispose-harvest.test.ts +247 -0
  202. package/src/execution/engine/engines/zcode/__tests__/session-channel-turn-timers.test.ts +451 -0
  203. package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +792 -0
  204. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +782 -0
  205. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +168 -0
  206. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-dispose.test.ts +210 -0
  207. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-retry.test.ts +353 -0
  208. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-status.test.ts +282 -0
  209. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-timeout.test.ts +372 -0
  210. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +65 -47
  211. package/src/execution/engine/engines/zcode/appserver-launcher.ts +167 -0
  212. package/src/execution/engine/engines/zcode/connection.ts +598 -0
  213. package/src/execution/engine/engines/zcode/constants.ts +147 -14
  214. package/src/execution/engine/engines/zcode/golden-sample.ts +42 -34
  215. package/src/execution/engine/engines/zcode/parser.ts +12 -346
  216. package/src/execution/engine/engines/zcode/preparer.ts +22 -150
  217. package/src/execution/engine/engines/zcode/session-channel.ts +968 -0
  218. package/src/execution/engine/engines/zcode/zcode-engine.ts +1023 -250
  219. package/src/execution/engine/host-task-spec.ts +32 -36
  220. package/src/execution/engine/model-validation.ts +177 -0
  221. package/src/execution/engine/port.ts +65 -7
  222. package/src/execution/engine/registry.ts +53 -0
  223. package/src/execution/engine/routing.ts +75 -1
  224. package/src/execution/engine/types.ts +30 -103
  225. package/src/execution/execution-record.ts +222 -144
  226. package/src/execution/finalize-record.ts +181 -85
  227. package/src/execution/idle-gc.ts +28 -0
  228. package/src/execution/lifecycle-manager.ts +27 -3
  229. package/src/execution/lifecycle-predicates.ts +1 -1
  230. package/src/execution/manifest-store.ts +53 -62
  231. package/src/execution/notifier.ts +10 -10
  232. package/src/execution/notify-host.ts +212 -0
  233. package/src/execution/notify-ledger.ts +117 -16
  234. package/src/execution/record-entry.ts +8 -2
  235. package/src/execution/record-store.ts +148 -88
  236. package/src/execution/round-settlement.ts +93 -0
  237. package/src/execution/session-file-gc.ts +96 -62
  238. package/src/execution/session-pending.ts +213 -62
  239. package/src/execution/session-reconstructor.ts +256 -143
  240. package/src/execution/sessions-index.ts +127 -102
  241. package/src/execution/settled-watchdog.ts +311 -0
  242. package/src/execution/subagent-actions-core.ts +686 -0
  243. package/src/execution/subagent-service.ts +1106 -907
  244. package/src/execution/subprocess-agent-runner.ts +144 -97
  245. package/src/execution/types.ts +9 -0
  246. package/src/execution/ui-request-handler-factory.ts +126 -75
  247. package/src/execution/ui-request-queue.ts +50 -19
  248. package/src/execution/worktree-git-ops.ts +397 -0
  249. package/src/execution/worktree-manager.ts +72 -10
  250. package/src/execution/worktree-registry.ts +6 -12
  251. package/src/index.ts +542 -9
  252. package/src/orchestration/__tests__/__fixtures__/worker-template.snapshot.txt +42 -3
  253. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +3 -5
  254. package/src/orchestration/__tests__/agent-call-stream.test.ts +2 -2
  255. package/src/orchestration/__tests__/args-meta.test.ts +358 -0
  256. package/src/orchestration/__tests__/args-validator.test.ts +14 -0
  257. package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +312 -0
  258. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +384 -0
  259. package/src/orchestration/__tests__/execute-agent-call.test.ts +151 -11
  260. package/src/orchestration/__tests__/file-run-store-prune.test.ts +168 -0
  261. package/src/orchestration/__tests__/file-run-store-throttle.test.ts +168 -0
  262. package/src/orchestration/__tests__/file-run-store.test.ts +390 -0
  263. package/src/orchestration/__tests__/helpers/flush-microtasks.ts +13 -0
  264. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +34 -0
  265. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +357 -0
  266. package/src/orchestration/__tests__/lifecycle-recover-crashed.test.ts +289 -0
  267. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +3 -3
  268. package/src/orchestration/__tests__/lifecycle.test.ts +84 -5
  269. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +1 -1
  270. package/src/orchestration/__tests__/run-snapshot.test.ts +356 -0
  271. package/src/orchestration/__tests__/script-generate.test.ts +314 -0
  272. package/src/orchestration/__tests__/script-lint.test.ts +17 -0
  273. package/src/orchestration/__tests__/test-mocks.ts +2 -0
  274. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +7 -6
  275. package/src/orchestration/__tests__/worker-message-pump-finalize-run.test.ts +229 -0
  276. package/src/orchestration/__tests__/{error-recovery-handlers.test.ts → worker-message-pump-handlers.test.ts} +28 -27
  277. package/src/orchestration/__tests__/{error-recovery-postmessage-defense.test.ts → worker-message-pump-postmessage-defense.test.ts} +8 -8
  278. package/src/orchestration/__tests__/{error-recovery-serialize-failed-result.test.ts → worker-message-pump-serialize-failed-result.test.ts} +2 -2
  279. package/src/orchestration/__tests__/{error-recovery-workflow-call.test.ts → worker-message-pump-workflow-call.test.ts} +1 -1
  280. package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +275 -0
  281. package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +78 -1
  282. package/src/orchestration/__tests__/worker-script-builder.test.ts +1 -1
  283. package/src/orchestration/__tests__/workflow-files.test.ts +186 -0
  284. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +1 -1
  285. package/src/orchestration/__tests__/workflow-run-summary.test.ts +119 -0
  286. package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +124 -0
  287. package/src/orchestration/agent-opts-resolver.ts +8 -8
  288. package/src/orchestration/args-meta.ts +198 -0
  289. package/src/orchestration/args-validator.ts +53 -31
  290. package/src/orchestration/execute-agent-call.ts +25 -79
  291. package/src/orchestration/file-run-store.ts +327 -0
  292. package/src/orchestration/launcher.ts +160 -104
  293. package/src/orchestration/lifecycle.ts +371 -97
  294. package/src/orchestration/models/agent-call.ts +7 -7
  295. package/src/orchestration/models/budget.ts +5 -5
  296. package/src/orchestration/models/ports.ts +4 -4
  297. package/src/orchestration/models/run-runtime.ts +15 -15
  298. package/src/orchestration/models/trace.ts +9 -9
  299. package/src/orchestration/models/types.ts +111 -22
  300. package/src/orchestration/models/workflow-run.ts +28 -28
  301. package/src/orchestration/models/workflow-script.ts +4 -4
  302. package/src/orchestration/run-snapshot.ts +266 -0
  303. package/src/orchestration/script-generate.ts +154 -0
  304. package/src/orchestration/script-lint.ts +123 -90
  305. package/src/orchestration/worker-handle.ts +10 -10
  306. package/src/orchestration/worker-host.ts +10 -10
  307. package/src/orchestration/{error-recovery.ts → worker-message-pump.ts} +498 -221
  308. package/src/orchestration/worker-script-builder.ts +401 -346
  309. package/src/orchestration/workflow-files.ts +37 -11
  310. package/src/orchestration/workflow-run-summary.ts +69 -0
  311. package/src/orchestration/workflow-script-registry-impl.ts +31 -9
  312. package/src/shared/__tests__/agent-ref.test.ts +209 -2
  313. package/src/shared/__tests__/atomic-write.test.ts +267 -0
  314. package/src/shared/__tests__/bounded-serialize.test.ts +236 -0
  315. package/src/shared/__tests__/injection-render.test.ts +518 -0
  316. package/src/shared/__tests__/meta-parser.test.ts +151 -0
  317. package/src/shared/__tests__/resource-discovery-host-roots.test.ts +474 -0
  318. package/src/shared/__tests__/resource-discovery.test.ts +261 -217
  319. package/src/shared/agent-ref.ts +143 -2
  320. package/src/shared/atomic-write.ts +323 -0
  321. package/src/shared/bounded-serialize.ts +154 -0
  322. package/src/shared/injection-render.ts +279 -0
  323. package/src/shared/meta-parser.ts +182 -68
  324. package/src/shared/model-ref.ts +15 -2
  325. package/src/shared/resource-discovery.ts +97 -204
  326. package/src/shared/resource-meta.ts +14 -0
  327. package/src/shared/schema-jsonify.ts +1 -1
  328. package/src/shared/xml-injection.ts +9 -9
  329. package/workflows/README.md +9 -9
  330. package/workflows/chain.js +4 -2
  331. package/workflows/map-reduce.js +5 -3
  332. package/workflows/parallel.js +5 -3
  333. package/workflows/review-fix-loop-utils.cjs +165 -92
  334. package/workflows/review-fix-loop-utils.d.cts +287 -0
  335. package/workflows/review-fix-loop.js +18 -7
  336. package/workflows/scatter-gather.js +4 -2
  337. package/dist/chunk-3VOERJPJ.js +0 -22
  338. package/src/execution/__tests__/execute-options-mapper.test.ts +0 -200
  339. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +0 -164
  340. package/src/execution/engine/engines/pi/task-spec-mapper.ts +0 -100
  341. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +0 -39
  342. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +0 -150
  343. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +0 -580
  344. package/src/execution/engine/engines/zcode/launcher.ts +0 -161
  345. package/src/execution/execute-options-mapper.ts +0 -115
  346. package/src/execution/session-runner.ts +0 -1789
  347. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +0 -288
@@ -1,64 +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
+ import { MAX_TIMER_DELAY_MS } from "../shared/timer-delay.ts";
14
15
  // D-A10: workflow 侧 AgentResult 映射(executeAndAwait 出口)
15
16
  import { mapToWorkflowAgentResult } from "./agent-result-mapper.ts";
16
- import { removeAliveMarker, findForeignLiveInstance, writeAliveMarker } from "./alive-store.ts";
17
+ import { removeAliveMarker } from "./alive-store.ts";
17
18
  import { bestEffort } from "./best-effort.ts";
18
19
  // [V2 决策 3] lifecycle-manager idle timer:chatMode 统一投递新 turn disarm(防误杀活进程)。
19
- // [M3] hasIdleTimerpiAdapter.hasRunningBackground 排除等待续聊(timer armed)的 record。
20
- import { acquireActivateLock, disarmIdleTimer, hasIdleTimer } 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";
21
24
  import { type ConcurrencyPool,DefaultConcurrencyPool } from "./concurrency-pool.ts";
22
25
  import type { DialogGlobalQueue, UiRequestHandler } from "./dialog-queue.ts";
26
+ import { COLD_LOOKUP_SCAN_LIMIT, coldLookupForAction, type ColdResurrectDeps } from "./cold-resurrect.ts";
23
27
  import {
24
28
  completeRecord,
25
29
  createRecord,
26
- getFullTextFrom,
27
- nextRoundBaseTurnIndex,
28
30
  project,
29
- resurrectClosed,
30
31
  snapshot,
31
32
  tryTransition,
32
33
  } from "./execution-record.ts";
33
34
  import { doFinalizeRecord, doFinalizeRoundToIdle } from "./finalize-record.ts";
34
- import { getEngineDataDir } from "./engine/common/data-dir.ts";
35
- import { EngineError } from "./engine/common/errors.ts";
36
- import { JournalWriter } from "./engine/common/event-journal.ts";
37
- 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";
38
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";
39
42
  import type { EnginePort, RunContext } from "./engine/port.ts";
40
- import { DEFAULT_ENGINE_ID, getEngine } from "./engine/registry.ts";
41
- 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";
42
46
  import type { AgentOutcome } from "./engine/types.ts";
43
47
  import { ManifestStore } from "./manifest-store.ts";
44
48
  import type { ModelConfigService } from "./model-config-service.ts";
45
49
  import type { AgentConfig, ModelInfo, ResolvedModel } from "./model-resolver.ts";
46
- import type { BgNotifyRecord, BgNotifier, NotifierHost } from "./notifier.ts";
47
- import { createNotifier } from "./notifier.ts";
50
+ import { type NotifyHost, type PiLike, createNotifyHost } from "./notify-host.ts";
51
+ // [T4④ / PS-5] flush 被门拦时的未投递 pending 落盘账本(persistUndeliveredNotificationsForReplay 消费)
52
+ import { getBoundNotifyLedger, NOTIFY_LEDGER_CUSTOM_TYPE } from "./notify-ledger.ts";
48
53
  import { getSubagentRecordsDir, getSubagentSessionDir } from "./path-encoding.ts";
54
+ import { createRoundSettler } from "./round-settlement.ts";
49
55
  import type { StatusFilter } from "./record-store.ts";
50
56
  import { RecordStore } from "./record-store.ts";
51
57
  import { MAX_FORK_DEPTH } from "./session-context-resolver.ts";
52
- import { getChildByRecord, killAllSpawnedChildren, registerSpawnedChildForRecord, runSpawn, spawnedChildren, type SessionRunnerContext, type SpawnResumeOpts } from "./session-runner.ts";
53
- import { isIdle, isResumable, hasLiveProcessHandle } from "./lifecycle-predicates.ts";
54
- import { startIdleGc } from "./idle-gc.ts";
55
58
  import {
56
- clearEpipeFailure,
57
- EPIPE_FAILURE_THRESHOLD,
58
- recordEpipeFailure,
59
- resetAllEpipeFailures,
60
- sendPromptCommand,
61
- } from "./stdin-writer.ts";
59
+ killAllSpawnedChildren,
60
+ killRecordChildWithEscalation,
61
+ registerSpawnedChildForRecord,
62
+ runSpawn,
63
+ type SessionRunnerContext,
64
+ type SpawnResumeOpts,
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 头注释)。
69
+ import {
70
+ armMidRoundNoProgress,
71
+ disarmSettledWatchdog,
72
+ type SettledWatchdogFireInfo,
73
+ } from "./settled-watchdog.ts";
74
+ import { isIdle, isResumable } from "./lifecycle-predicates.ts";
75
+ import { startIdleGc } from "./idle-gc.ts";
76
+ import { resetAllEpipeFailures } from "./engine/engines/pi/stdin-writer.ts";
62
77
  import type { StreamSink, SubagentStream } from "./stream-sink.ts";
63
78
  import { createBackgroundStream } from "./stream-sink.ts";
64
79
  import { writeCancelledTombstone } from "./tombstone-store.ts";
@@ -75,19 +90,45 @@ import type {
75
90
  SubagentRecord,
76
91
  } from "./types.ts";
77
92
  import { ForkDepthExceededError } from "./types.ts";
78
- import { isReconnectableFinalReason, ResurrectDeniedError } from "./types.ts";
79
93
  import { DEFAULT_AGENT_NAME } from "./types.ts";
80
94
  import { registerGlobalObservability, UiRequestObservability } from "./ui-request-observability.ts";
81
95
  import { WorktreeManager } from "./worktree-manager.ts";
96
+ import { toErrorMessage } from "../core/error-message.ts";
82
97
 
83
98
  const logger = getLogger("subagents");
84
99
 
85
- /** SP-2 冷路径按 id 查 record collectRecords 扫描上限(全扫兜底的容量 cap)。 */
86
- 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
+ }
87
128
 
88
- // [v4 A-1] EPIPE 连续失败计数器已迁移到 stdin-writer.ts(stdin 错误域,避免 session-runner
89
- // 反向 import 本文件 helper 产生循环依赖)。同步路径(deliverMessage)与异步路径
90
- //(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 的同一计数器。
91
132
 
92
133
  /** dispose 后注入的 stub UI 请求 handler。
93
134
  *
@@ -106,50 +147,20 @@ const COLD_LOOKUP_SCAN_LIMIT = 1000;
106
147
  * 没真正解决。 */
107
148
  const disposedUiRequestStub: UiRequestHandler = () => Promise.resolve({ cancelled: true });
108
149
 
109
- /** Pi ExtensionAPI 的最小接口(duck-typed)。
110
- * subagent-service 直接调 pi.sendMessage 发 background 完成通知(BgNotifier 滑动窗口合并),
111
- * 不委托 pending-notifications EventBus 中继——后者只管 registry 不参与通知发送。 */
112
- interface PiLike {
113
- appendEntry(customType: string, data?: unknown): void;
114
- events: { emit(channel: string, data: unknown): void };
115
- sendMessage(
116
- message: { customType: string; content: string; display: boolean; details?: unknown },
117
- options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }, // g4-allow: 类型注解——PiLike 接口形状(pi.sendMessage 签面子集),非投递调用
118
- ): void;
119
- /** 订阅 pi 事件(D8:notifier 的 settled 边沿订阅用 'agent_settled')。
120
- * pi 0.84.1 的 on 返回 void 且无 off——退订语义由调用侧 disposed 标志包装兑现。
121
- * 可选:旧测试 mock pi 可能未实现 on,缺省时 notifier 退化为内核退避路径。 */
122
- on?(event: "agent_settled", handler: () => void): void;
123
- }
124
-
125
150
  /** UI streaming sink 的最小接口(ctx.ui.setWidget 的 duck-typed 子集)。
126
151
  * session_start 时从 ctx.ui 注入,background 执行期间用于把合并后的 text_delta
127
152
  * 通过 setWidget 通道转发到 RPC stdout(不经 sendMessage 的持久化路径)。 */
128
153
  export type { StreamSink } from "./stream-sink.ts";
129
154
 
130
- /** pending-notifications 注册/注销 helper(避免重复代码)。
131
- * name GUI pending 通知的显示名——取 basename 短名(displayAgentName),
132
- * 完整路径仍走 record.agent(env 注入 / 持久化)。 */
133
- function emitPendingRegister(pi: PiLike | null, id: string, name?: string): void {
134
- pi?.events.emit("pending:register", {
135
- id,
136
- type: "subagent",
137
- name: name ? displayAgentName(name) : id,
138
- });
139
- }
140
-
141
- function emitPendingUnregister(
142
- pi: PiLike | null,
143
- id: string,
144
- reason: string,
145
- ): void {
146
- pi?.events.emit("pending:unregister", {
147
- id,
148
- reason,
149
- });
150
- }
155
+ // pi 依赖端口类型 re-export:测试侧 mock PiLike 历来从本模块取(与 StreamSink 同构的门面模式)
156
+ export type { PiLike } from "./notify-host.ts";
151
157
 
152
- /** Service 构造参数(进程级)。 */
158
+ /**
159
+ * Service 构造参数(进程级)。
160
+ *
161
+ * @experimental execution 运行时面(设计 docs/design/subagent-core-sink-design.md §3.3 D6):
162
+ * 一个 minor 周期内允许签名微调,稳定后转常规 semver 承诺。
163
+ */
153
164
  export interface SubagentServiceInit {
154
165
  cwd: string;
155
166
  /** 配置/模型域 Service(execute 内部调其 resolveModel)。 */
@@ -176,8 +187,12 @@ export interface SubagentServiceSessionInit {
176
187
  * initSession 读取后存入 this.sessionMode,buildSessionRunnerContext 透传给 session-runner。 */
177
188
  mode?: ExtensionMode;
178
189
  /** UI 请求 handler(session 级覆盖进程级)。
179
- * initSession 读取后覆盖 this.uiRequestHandler(setUiRequestHandler session 级等价入口)。 */
180
- uiRequestHandler?: UiRequestHandler;
190
+ * [D4-④ UI 接线外提] 本字段是 handler 的唯一注入入口(原 setUiRequestHandler 方法已删)。
191
+ * 三态语义:undefined = 不动(保留进程级构造/上次值,供不注入 handler 的调用方);
192
+ * null = 显式清空(承载原 setUiRequestHandler(undefined) 语义——headless 的
193
+ * createUiRequestHandlerForMode 返回 undefined 时壳侧传 null);值 = 注入并重置
194
+ * 缺失告警去重。 */
195
+ uiRequestHandler?: UiRequestHandler | null;
181
196
  /** L2 跨子进程全局 dialog 串行队列(进程单例)。透传给 session-runner,
182
197
  * child close 时调 rejectChildDialogs 清理 pending(SR-4 防全局死锁)。 */
183
198
  dialogQueue?: DialogGlobalQueue;
@@ -190,6 +205,30 @@ export interface SubagentServiceSessionInit {
190
205
  /** background 优先级(保留 priority 排序机制,单一值)。 */
191
206
  const PRIORITY_BACKGROUND = 1000;
192
207
 
208
+ /** 时间换算常数(settled watchdog 分钟数展示用;与 session-runner 同名常量同语义)。 */
209
+ const MS_PER_SECOND = 1000;
210
+ const SECONDS_PER_MINUTE = 60;
211
+
212
+ /** [T4① / PS-2] notify 门拦截集:disposeAllRecords 因这两类原因关闭的 record,其迟到的
213
+ * 轮次完成回注不得注入新 session——/new、/fork 的决策([v4 A-6])
214
+ * 是「被关 record 的告知改由 list 的 closedReason 表达」,不主动通知;旧门只排除
215
+ * cancelled,parent-new/parent-fork 放行 → 新对话被「Subagent X failed: closed due to
216
+ * parent-new」的僵尸回执 triggerTurn 唤醒,已废弃会话的通知注入新上下文。 */
217
+ const NOTIFY_BLOCKED_CLOSED_REASONS: ReadonlySet<ClosedReason> = new Set(["parent-new", "parent-fork"]);
218
+
219
+ /**
220
+ * [T4① / PS-2] 轮次完成回注的 notify 门(按 closedReason 白名单放行)。
221
+ *
222
+ * cancelled(cancelBackground 自己 notify)与 parent-new/parent-fork(编排性关闭,
223
+ * 告知由 list 的 closedReason 表达)不放行;其余(undefined = 本路径抢到 CAS 尚未
224
+ * 终态化的迟到回调、user-close/gc 等真实终态)照旧回注。导出供测试与未来调用点复用。
225
+ */
226
+ export function notifyGateAllowsDelivery(closedReason: ClosedReason | undefined): boolean {
227
+ if (closedReason === undefined) return true;
228
+ if (closedReason === "cancelled") return false;
229
+ return !NOTIFY_BLOCKED_CLOSED_REASONS.has(closedReason);
230
+ }
231
+
193
232
  /** 跨进程身份贯穿的 env 名(父进程 spawn 子进程时注入,子进程 initSession 读取)。
194
233
  * 仿照 PI_SUBAGENT_FORK_DEPTH 机制,让递归 subagent 的身份(rootSessionId / parentRecordId / depth)
195
234
  * 跨进程传递,使主进程 /subagents 能看到完整递归树(设计见 docs/design/recursive-subagent-visibility.md)。
@@ -214,13 +253,18 @@ interface ResolvedIdentity {
214
253
  * 执行编排 Service。进程级单例。
215
254
  *
216
255
  * session_start:
217
- * 1. modelService = getModelConfigService() ?? new ModelConfigService({homeDir, agentDir})
256
+ * 1. modelService = getModelConfigService() ?? new ModelConfigService({cwd, agentDir})
218
257
  * 2. service = getSubagentService() ?? new SubagentService({cwd, modelService})
219
258
  * 3. modelService.initModel({modelRegistry, sessionId, entries})
220
259
  * 4. service.initSession({pi, sessionId})
221
260
  *
222
261
  * session_shutdown:
223
262
  * service.dispose()
263
+ *
264
+ * 第三宿主不经 session_start 流程时改用 createSubagentService(init) 参数注入构造。
265
+ *
266
+ * @experimental execution 运行时面(设计 docs/design/subagent-core-sink-design.md §3.3 D6):
267
+ * 一个 minor 周期内允许签名微调,稳定后转常规 semver 承诺。
224
268
  */
225
269
  export class SubagentService {
226
270
  private readonly pool: ConcurrencyPool;
@@ -246,16 +290,12 @@ export class SubagentService {
246
290
  * (collectRecords filter 用,与 createRecordForMode 的 rootSessionId 盖章同源——子进程
247
291
  * 因此看到整棵 ROOT 树)。设计见 recursive-subagent-visibility.md 决策 3。 */
248
292
  private sessionRootId: string | null = null;
249
- /** 进程级执行上下文基线(不依赖 ALS 贯穿——pi RPC mode 的 stdin JSONL 是事件回调式
250
- * (attachJsonlLineReader stream.on("data")),每个命令是独立异步链,initSession
251
- * execCtxAls.enterWith store 不会贯穿到后续 tool 调用事件(实测:递归第二层
252
- * parentRecordId/depth 丢失而 rootSessionId 正确——rootSessionId 是实例字段所以不受影响)。
253
- * 基线 = 本进程自己的身份(initSession 从 env 读取,与 sessionRootId 同机制):
254
- * ALS store 失败时兜底,保证「本进程派发的 subagent 都是本进程记录的孩子」
255
- * 这一跨进程树形关系成立。
256
- * initSession 设置:有 env PI_SUBAGENT_SELF_RECORD_ID → {recordId: env 值, depth: env DEPTH};
257
- * 无 env(根进程)→ null(顶层)。 */
258
- 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();
259
299
  /** fork 深度基线(同 ALS 断裂问题:forkDepthAls.getStore() 兜底用)。根进程=0。 */
260
300
  private forkDepthBaseline = 0;
261
301
  /** [MF-3] 所属根进程 cwd(sessions/records 落盘目录编码键)。
@@ -271,32 +311,92 @@ export class SubagentService {
271
311
  getStreamSink(): StreamSink | null { return this.streamSink; }
272
312
  private _disposed = false;
273
313
  private _seq = 0;
274
- /** background 完成通知器(滑动窗口合并 + 去重)。session_start revive,shutdown dispose。 */
275
- 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
+ });
276
323
  /** [MF#4][MF#2] fork 深度按 async 调用链传递(AsyncLocalStorage),替代共享可变计数器。
277
324
  * 主 session=0;fork 进入子 session 期间推进为子深度,供嵌套 fork 经 ALS 读到自身深度作为
278
325
  * parentForkDepth。并发 background fork 各自独立调用链,不再互相压低深度值。
279
326
  * [MF#2] 旧实现用单实例字段跨执行链共享 → 并发下 A 还原深度后 B 读到被压低值 → 护栏失效。 */
280
327
  private readonly forkDepthAls = new AsyncLocalStorage<number>();
281
328
 
282
- /** subagent 执行上下文按 async 调用链传递(当前正在跑的 record 身份 + 递归深度)。
283
- * B run() 期间包此 ALS,B 内创建 C 时 createRecordForMode 读到 B 的 recordId/depth,
284
- * 据此设 C.parentRecordId=B.id、C.depth=B.depth+1。主 session 链上无 store → 顶层。
285
- * forkDepthAls 独立:后者只数 fork 链(fork=true 才递增),本 ALS 数所有 subagent 嵌套。 */
286
- private readonly execCtxAls = new AsyncLocalStorage<{ recordId: string | undefined; depth: number }>();
287
-
288
- /** [review MF1] record 级在途 resume 守卫。resumeRound 全部守卫通过后 add,
289
- * runAndFinalize 结束(finally,覆盖轮次完成 / MF-6 失败回退 / abort / 终态化所有分支)时
290
- * delete(幂等:execute() 新建 record 不在集合,no-op)。窗口 = resume 发起(含 pool.acquire
291
- * 排队)→ 本轮 runAndFinalize 收尾。窗口内同 record 再次到达 resumeRound(冷路径重入 /
292
- * EPIPE 兜底)直接 throw——防两个 pi 子进程以 --session 同一 JSONL 双写 + 前一个脱离
293
- * kill 记账成孤儿(deliverMessage 冷路径的 acquireActivateLock 只覆盖 resumeRound 同步段,
294
- * 锁释放在子进程注册(session-runner spawnedChildren.set)之前,锁空洞由此守卫兜住;
295
- * 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 注册完成后投递走热路径,不经此守卫。 */
296
342
  private readonly resumesInFlight = new Set<string>();
297
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
+
298
391
  private readonly manifestStore: ManifestStore;
299
392
 
393
+ /**
394
+ * [T1/PS-9] subagent sessionDir(getSubagentSessionDir 推导,与 store 同源同一 rootCwd)。
395
+ * 传给 doFinalizeRecord 的 FinalizeDeps.sessionDir——record.sessionFile 缺失时 finalize
396
+ * 用它做磁盘 identity 反查(marker/alive 清理的依据)。
397
+ */
398
+ private readonly sessionsDir: string;
399
+
300
400
  constructor(init: SubagentServiceInit) {
301
401
  this.cwd = init.cwd;
302
402
  this.modelService = init.modelService;
@@ -313,9 +413,9 @@ export class SubagentService {
313
413
  this.rootCwd = envRootCwd && envRootCwd !== "" ? envRootCwd : init.cwd;
314
414
  const sessionsDir = getSubagentSessionDir(this.modelService.getAgentDir(), this.rootCwd);
315
415
  const recordsDir = getSubagentRecordsDir(this.modelService.getAgentDir(), this.rootCwd);
416
+ this.sessionsDir = sessionsDir;
316
417
  this.manifestStore = new ManifestStore(recordsDir);
317
418
  this.store = new RecordStore(sessionsDir, this.manifestStore, this.pi ?? undefined);
318
- this.notifier = createNotifier(this.piAdapter());
319
419
  // #11:注册进程级 observability 单例——ui-request-queue.handleUiRequest 经
320
420
  // globalThis 桥接(notifyMissingHandlerGlobal)调到同一实例,共享
321
421
  // warnedMissingHandlerSessions 去重集合。未注册时 queue 走 fallback warn(不去重)。
@@ -324,20 +424,6 @@ export class SubagentService {
324
424
 
325
425
  // ── 生命周期(index.ts 调)──────────────────────────────
326
426
 
327
- /** 覆盖 UI 请求 handler(W3: index.ts session_start 时按 mode 注入 handler 后调)。
328
- * 委托 uiObservability 重置缺失告警去重——新 handler 就位后允许重新 warn。 */
329
- setUiRequestHandler(handler: UiRequestHandler | undefined): void {
330
- this.uiRequestHandler = handler;
331
- this.uiObservability.resetMissingHandlerWarnings();
332
- }
333
-
334
- /** session-runner handleUiRequest 在 handler 缺失时调用(FR-9 可观测性)。
335
- * 委托 uiObservability:按 session 去重,同一 session 的多次 UI 请求只 warn 一次。
336
- * W2: console.warn 兜底。W3 接入 pi.appendEntry("subagent:ui-request-missing-handler", ...)。 */
337
- notifyMissingHandler(sessionId: string): void {
338
- this.uiObservability.notifyMissingHandler(sessionId);
339
- }
340
-
341
427
  /** session_start 注入 pi + revive(modelRegistry/entries 归 ModelConfigService.initModel)。 */
342
428
  initSession(init: SubagentServiceSessionInit): void {
343
429
  this.pi = init.pi;
@@ -350,10 +436,12 @@ export class SubagentService {
350
436
  this.mainSessionFile = init.mainSessionFile;
351
437
  this.streamSink = init.streamSink ?? null;
352
438
  this.isIdleFn = init.isIdle;
353
- // 读取 mode(W4 守卫透传给 session-runner)+ session 级 handler 覆盖。
439
+ // 读取 mode(W4 守卫透传给 session-runner)+ session 级 handler 覆盖
440
+ //([D4-④] initSession.uiRequestHandler 是唯一注入入口;三态语义见接口注释——
441
+ //null = 显式清空,承载原 setUiRequestHandler(undefined) 语义)。
354
442
  this.uiObservability.setMode(init.mode);
355
443
  if (init.uiRequestHandler !== undefined) {
356
- this.uiRequestHandler = init.uiRequestHandler;
444
+ this.uiRequestHandler = init.uiRequestHandler ?? undefined;
357
445
  this.uiObservability.resetMissingHandlerWarnings();
358
446
  }
359
447
  // SR-4:注入 L2 dialog 队列(child close 清理路径)。undefined 时 buildSessionRunnerContext
@@ -361,10 +449,30 @@ export class SubagentService {
361
449
  if (init.dialogQueue !== undefined) {
362
450
  this.dialogQueue = init.dialogQueue;
363
451
  }
364
- // [SPAWN fork depth 跨进程传递] 子进程被父 spawn 时,父通过 env
365
- // PI_SUBAGENT_FORK_DEPTH 传入当前 fork 链深度。子进程 session_start 时
366
- // 读取作为 forkDepthAls 基线,使后续嵌套 spawn fork 能从正确深度递增。
367
- // 未设置(顶层主 session)→ 基线 0。enterWith 贯穿整个 session 生命周期。
452
+ this.initForkDepthBaseline();
453
+ // [递归可见性] 跨进程身份贯穿(设计 recursive-subagent-visibility.md)。
454
+ // 父进程 spawn 时注入 env 描述「子进程自己的身份」(rootSessionId / selfRecordId /
455
+ // depth / rootCwd),语义与基线建立见 initExecContextBaseline。根进程无 env
456
+ // sessionRootId = init.sessionId(自己是 root),execCtxAls 不 enterWith(顶层)。
457
+ const envRoot = process.env[ENV_ROOT_SESSION_ID];
458
+ this.sessionRootId = envRoot ?? init.sessionId;
459
+ this.initExecContextBaseline(envRoot, init.sessionId);
460
+ // revive(dispose 的逆操作:/resume /fork /new 后复活)
461
+ this._disposed = false;
462
+ this.store.revive();
463
+ this.notifyHost.revive();
464
+ // 孤儿终态恢复(放 initSession 末尾:setPi 已注入(appendEntry 可用)、
465
+ // sessionRootId 已建立(过滤当前根的 record);单扫描者判据见 recoverOrphansIfRootProcess)
466
+ this.recoverOrphansIfRootProcess();
467
+ }
468
+
469
+ /**
470
+ * [SPAWN fork depth 跨进程传递] fork 链深度基线:子进程被父 spawn 时,父通过 env
471
+ * PI_SUBAGENT_FORK_DEPTH 传入当前 fork 链深度。子进程 session_start 时读取作为
472
+ * forkDepthAls 基线,使后续嵌套 spawn fork 能从正确深度递增。未设置(顶层主
473
+ * session)→ 基线 0。enterWith 贯穿整个 session 生命周期。
474
+ */
475
+ private initForkDepthBaseline(): void {
368
476
  const envDepth = process.env.PI_SUBAGENT_FORK_DEPTH;
369
477
  if (envDepth !== undefined && envDepth !== "") {
370
478
  const base = Number.parseInt(envDepth, 10);
@@ -373,59 +481,71 @@ export class SubagentService {
373
481
  this.forkDepthBaseline = base;
374
482
  }
375
483
  }
376
- // [递归可见性] 跨进程身份贯穿(设计 recursive-subagent-visibility.md)。
377
- // 父进程 spawn 时注入这 4 个 env 描述「子进程自己的身份」:
378
- // - rootSessionId:所属根 session(贯穿真 ROOT,子进程不覆盖)
379
- // - selfRecordId:子进程自己的 record id(孙 subagent 的直接父)
380
- // - depth:子进程的嵌套深度
381
- // - rootCwd:真 ROOT cwd([MF-3] 落盘目录编码键,worktree 下与自身 cwd 不同)
382
- // 子进程读 env 建立基线后,createRecordForMode 读 execCtxAls 自动正确(孙挂到子名下)。
383
- // 根进程无 env sessionRootId = init.sessionId(自己是 root),execCtxAls enterWith(顶层)。
384
- // enterWith 贯穿整个 session 生命周期(与 forkDepthAls 同构,决策 4)。
385
- const envRoot = process.env[ENV_ROOT_SESSION_ID];
386
- this.sessionRootId = envRoot ?? init.sessionId;
484
+ }
485
+
486
+ /**
487
+ * [递归可见性] exec 上下文基线:子进程读 env PI_SUBAGENT_SELF_RECORD_ID / DEPTH
488
+ * 建立身份基线后,createRecordForMode 读嵌套上下文自动正确(孙挂到子名下)。
489
+ * enterWith 贯穿整个 session 生命周期(与 forkDepthAls 同构,决策 4)。
490
+ */
491
+ private initExecContextBaseline(envRoot: string | undefined, sessionId: string): void {
387
492
  const envSelfRecord = process.env[ENV_SELF_RECORD_ID];
388
493
  if (envSelfRecord !== undefined && envSelfRecord !== "") {
389
494
  const envNestingDepth = Number.parseInt(process.env[ENV_DEPTH] ?? "0", 10);
390
495
  const nestingDepth = Number.isNaN(envNestingDepth) ? 0 : envNestingDepth;
391
- // [ALS 断裂修复] 基线兜底:enterWith 在 pi 事件回调模型下不可靠(见 execCtxBaseline 注释),
392
- // 基线是 createRecordForMode / 护栏读 ALS store 失败时的权威回退。
393
- this.execCtxBaseline = { recordId: envSelfRecord, depth: nestingDepth };
394
- 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 });
395
501
  if (process.env.XYZ_AGENT_DEBUG) {
396
502
  logger.debug(
397
- `[subagents] execCtxAls initialized: recordId=${envSelfRecord} depth=${nestingDepth} rootSessionId=${envRoot ?? init.sessionId}`,
503
+ `[subagents] execNesting initialized: recordId=${envSelfRecord} depth=${nestingDepth} rootSessionId=${envRoot ?? sessionId}`,
398
504
  );
399
505
  }
400
506
  }
401
- // revive(dispose 的逆操作:/resume /fork /new 后复活)
402
- this._disposed = false;
403
- this.store.revive();
404
- this.notifier.revive();
405
- // 孤儿终态恢复(residual-fixes):session_start 主动触发一次——父扩展死后再无人写
406
- // 终态 entry 的 record 在此判定落盘(否则侧栏永久 running)。放 initSession 末尾:
407
- // setPi 已注入(appendEntry 可用),sessionRootId 已建立(过滤当前根的 record)。
408
- // 幂等不 throw,失败不阻断 session_start。
409
- this.recoverOrphanRecords();
410
507
  }
411
508
 
412
- /** 孤儿终态恢复委托(RecordStore.recoverOrphanRecords 的唯一公开入口,维持 store
413
- * private 封装——与 recoverManifestTmpFiles 同模式)。判定语义见 store 侧注释。
509
+ /**
510
+ * 孤儿终态恢复(residual-fixes):session_start 主动触发一次——父扩展死后再无人写
511
+ * 终态 entry 的 record 在此判定落盘(否则侧栏永久 running)。幂等不 throw,失败不
512
+ * 阻断 session_start。
513
+ *
514
+ * [T5① / PS-8] 只有根进程做扫描者:恢复机制假设「单扫描者」,但子进程 sessionRootId
515
+ * 经 env 与父同值(过滤域 = 整树共享的 sessions/records 目录),env 贯穿让每个子进程
516
+ * 都成了扫描者——递归编排中任一子进程启动时,恰有兄弟记录 marker 缺失或超软超时
517
+ *(hours-long wave 必然命中)→ 活记录被无关进程盖 .finalized sidecar,closed entry
518
+ * 写进别的进程的 session 文件(跨进程互写,无任何锁)。子进程身份判据 = env
519
+ * PI_SUBAGENT_SELF_RECORD_ID(父 spawn 时注入的「子进程自己的 record id」,仅子进程
520
+ * 非空)——与 execNesting 基线同源。根进程恢复语义不变。
521
+ */
522
+ private recoverOrphansIfRootProcess(): void {
523
+ const isChildProcess = (process.env[ENV_SELF_RECORD_ID] ?? "") !== "";
524
+ if (!isChildProcess) {
525
+ this.recoverOrphanRecords();
526
+ } else if (process.env.XYZ_AGENT_DEBUG) {
527
+ logger.debug("[subagents] child process detected (PI_SUBAGENT_SELF_RECORD_ID set), skipping orphan recovery scan");
528
+ }
529
+ }
530
+
531
+ /** 孤儿终态恢复委托(RecordStore.recoverOrphanRecords 的唯一调用入口,维持 store
532
+ * private 封装——与 recoverManifestTmpFiles 同模式;[D4] public 面收窄:唯一调用方
533
+ * 是 initSession,转 private)。判定语义见 store 侧注释。
414
534
  * 随后跑 entry-born 孤儿恢复(无子文件锚的 register-only record,spawn 窗口期死亡,
415
535
  * E2E 实测缺口)——主 session 文件经 getMainSessionFile 注入(构造期可空)。 */
416
- recoverOrphanRecords(): void {
536
+ private recoverOrphanRecords(): void {
417
537
  try {
418
538
  this.store.recoverOrphanRecords(this.sessionRootId ?? undefined);
419
539
  } catch (err) {
420
540
  logger.warn("[subagents] orphan recovery failed", {
421
- reason: err instanceof Error ? err.message : String(err),
541
+ reason: toErrorMessage(err),
422
542
  });
423
543
  }
424
544
  try {
425
545
  this.store.recoverEntryOnlyOrphans(this.mainSessionFile, this.sessionRootId ?? undefined);
426
546
  } catch (err) {
427
547
  logger.warn("[subagents] entry-only orphan recovery failed", {
428
- reason: err instanceof Error ? err.message : String(err),
548
+ reason: toErrorMessage(err),
429
549
  });
430
550
  }
431
551
  }
@@ -448,6 +568,15 @@ export class SubagentService {
448
568
  * 遍历 store 中所有 running record,逐个 CAS 转终态 + completeRecord + archive。
449
569
  * 对有 worktreeHandle 的 record 触发 worktreeManager.cleanup(T3: worktree 绑定清理)。
450
570
  *
571
+ * [T2⑥ / PS-1] 补齐三回收面(对照 dispose() 的既有形态,消除同文件双标):
572
+ * controller.abort + kill(收敛到 killChildWithEscalation)+ disarmIdleTimer +
573
+ * disarmSettledWatchdog。旧实现只关 record 不中止执行——「record 已关」≠「执行已
574
+ * 处置」:在途子进程继续跑且无任何用户可及的取消通道(cancel 只查内存 running,
575
+ * archive 后恒 false),若挂死唯一上界是默认关闭的 spawn watchdog → 泄漏至宿主退出。
576
+ * abort/kill/timer 三面对已终态/已死 record 均幂等 no-op,dispose() 先行的
577
+ * abortRunningControllers + killAllSpawnedChildren 不受影响(parent-shutdown 路径
578
+ * 双保险)。
579
+ *
451
580
  * [v4 A-6] 旧实现的 recentlyCascaded 收集(供已删除的 before_agent_start 注入告知)
452
581
  * 与 drainCascaded 已一并移除——被关 record 的告知改由 list 的 closedReason 表达。
453
582
  *
@@ -458,6 +587,14 @@ export class SubagentService {
458
587
  const activeRecords = this.store.listAllActive();
459
588
  let count = 0;
460
589
  for (const record of activeRecords) {
590
+ // [T2⑥/PS-1] 回收面 i:abort 在途 controller(排队的 acquire / 在途 signal listener
591
+ // 立即感知取消)。幂等:已 aborted 的 controller.abort() 是 no-op。
592
+ record.controller?.abort();
593
+ // 回收面 ii:回收子进程(SIGTERM + 30s SIGKILL 升级,收敛 T2④同款)。
594
+ // 回收面 iii:disarm idle timer + settled watchdog(进程回收后 timer 只会误触发)。
595
+ killRecordChildWithEscalation(record.id, `disposeAllRecords (${reason})`);
596
+ disarmIdleTimer(record.id);
597
+ disarmSettledWatchdog(record.id);
461
598
  // tryTransition 只对 running 生效;idle 需要直接 completeRecord(无 CAS 保护)。
462
599
  // 与 closeChatIdle 对称:idle 无在途 AgentResult,构造合成 result。
463
600
  if (record.status === "running") {
@@ -482,7 +619,7 @@ export class SubagentService {
482
619
  });
483
620
  }
484
621
  // pending-notifications 注销
485
- emitPendingUnregister(this.pi, record.id, "closed");
622
+ this.notifyHost.emitPendingUnregister(record.id, "closed");
486
623
  count++;
487
624
  }
488
625
  return count;
@@ -529,7 +666,10 @@ export class SubagentService {
529
666
  // [dispose stub] 第一时间换 stub,防 trailing ui_request 调到 stale handler 闭包
530
667
  // (仍持有 disposed session 的 ctx)产生误导性 console.error。stub 干净降级为 cancelled。
531
668
  // 必须在 emit/abort 之前——这些步骤可能同步触发 trailing pump。
532
- this.setUiRequestHandler(disposedUiRequestStub);
669
+ // [D4-④] 原 setUiRequestHandler 方法已删(initSession 参数为唯一注入入口),
670
+ // 此处内联其方法体(赋值 + 缺失告警去重重置)。
671
+ this.uiRequestHandler = disposedUiRequestStub;
672
+ this.uiObservability.resetMissingHandlerWarnings();
533
673
  // [R0/C1 孤儿进程修复] 先 abort running controllers + kill spawned children,再 dispose 资源。
534
674
  // abortRunningControllers 需要在 disposeAllRecords archive 之前执行(archive 后 store 找不到 record)。
535
675
  this.store.abortRunningControllers();
@@ -539,119 +679,61 @@ export class SubagentService {
539
679
  this.disposeAllRecords("parent-shutdown");
540
680
  // [v4 A-1] EPIPE 连续失败计数器清零(计数器已迁移到 stdin-writer,防跨 session 泄漏)
541
681
  resetAllEpipeFailures();
542
- // [review MF1] 在途 resume 守卫清空(正常由 runAndFinalize finally 清除;此处兜底
682
+ // [review MF1] 在途 resume 守卫清空(正常由轮次收尾 finally 清除;此处兜底
543
683
  // abort/kill 后仍挂着的条目,防跨 session 复活时残留)
544
684
  this.resumesInFlight.clear();
545
- // flush 待发通知后 dispose(防丢失)
546
- this.notifier.flushPendingNotifications();
547
- this.notifier.dispose();
685
+ // chat 轮次交接包清空(正常由 PiEngine.run 消费;此处兜底 kill 后仍挂着的条目)
686
+ this.chatRoundTickets.clear();
687
+ // flush 待发通知(session_shutdown 尽力投递一次)。
688
+ this.notifyHost.flushPendingNotifications();
689
+ // [T4④ / PS-5] flush 被 isIdle 门拦时的丢失面闭合:attemptDeliver 的 isIdle 二次
690
+ // 复查失败时 pending 挂在 ledger 内存态,随后 dispose 销毁——旧注释「flush 后 dispose
691
+ //(防丢失)」的承诺与行为不符。现把未投递 pending 经 ledger entry 通道复写落盘
692
+ // (notifyId 幂等,重复 entry 由恢复扫描后写覆盖吸收),重启 recoverFromSession 按
693
+ // 账面差集重放补投——「不丢」由落盘账本承接而非本次 flush。
694
+ this.persistUndeliveredNotificationsForReplay();
695
+ this.notifyHost.dispose();
548
696
  this.store.dispose();
549
697
  }
550
698
 
551
- // ── 执行(subagent-tool 调)────────────────────────────
552
-
553
- /** background 完成回注(record → BgNotifyRecord 映射 + notifier.notify)。
554
- * 正在执行(running + 活进程 + 非 timer-armed)静默跳过——notify 只对 closed(终态)、
555
- * isIdle(chatMode 轮次完成)或 isResumable(SP-5 one-shot 成功完成 / MF-6 失败轮回退)有意义。
556
- * SP-1: closed 统一终态(done/failed/crashed 合并),closedReason 携带 L2 原因。 */
557
- private notifyComplete(record: ExecutionRecord): void {
558
- const notify = this.toNotifyRecord(record);
559
- if (notify) this.notifier.notify(notify);
560
- }
561
-
562
- /** [C-1] chatMode close 终态通知(设计 D2:正文空/本轮增量 + sessionFile 指针行)。
563
- *
564
- * 与 notifyComplete 的差异只在 dedup 身份与轮次统计:终态通知必须与最后一轮的轮次通知
565
- * 区分(轮次通知 key=`id:round`),否则同 key 被 60s dedup 吞——close 后父 agent 永远
566
- * 收不到带指针行的终态通知(审查 C-1)。故 round 置 undefined(key 回退为裸 id),
567
- * 轮数改经 totalRounds 进文案 "completed after N rounds."(C-2)。
699
+ /**
700
+ * [T4④ / PS-5] shutdown flush 被门拦时把未投递 pending 复写落盘(供重启 replay)。
568
701
  *
569
- * chatMode close 语义调用(closeChatIdle / closeAfterRoundSettled 终态化成功后)。
570
- * one-shot 显式拒绝(G4:one-shot close 路径现状无终态通知,字节不变);cancel
571
- * cancelBackground 自己的 notifyComplete,不经本方法。幂等性:两条 close 路径均由
572
- * closeSubagent status 分流守卫(closed 后幂等 no-op)/ CAS 抢锁保证只执行一次,
573
- * 本方法自身不重复发送;迟到的 kickOffBackground.then 通知与轮次通知同 key=`id:round`,
574
- * 60s 窗内仍被吞,不构成第三条。 */
575
- /** @param emptyBody true = 终态通知正文置空串(D2 路径②)。W16 P-1 修复后
576
- * closeChatIdle 的 doneResult.text 改用 record.result 保真(close 终态
577
- * subagent-record entry result 不抹空轮终真实值),「正文空」不再由合成空
578
- * text 的副作用承载,改为显式参数——持久化 result 与通知正文两个关注点解耦。 */
579
- private notifyClosed(record: ExecutionRecord, emptyBody = false): void {
580
- if (!record.chatMode) return;
581
- const notify = this.toNotifyRecord(record);
582
- if (!notify) return;
583
- notify.round = undefined;
584
- if (emptyBody) notify.result = "";
585
- if (record.round != null) notify.totalRounds = record.round;
586
- this.notifier.notify(notify);
587
- }
588
-
589
- /** notifier 的 NotifierHost 适配器(绑定到 pi.sendMessage + store 查询)。 */
590
- private piAdapter(): NotifierHost {
591
- return {
592
- sendMessage: (message, options) => {
593
- this.pi?.sendMessage(message, options);
594
- },
595
- hasRunningBackground: () => {
596
- // [M3] 「在跑的 background 工作」= 有活进程且非等待续聊(idle timer armed)。
597
- // v4 B-1 把旧 idle 折入 running 且 record 留 store:轮次完成的 chatMode record
598
- // (timer armed、无在跑轮)与 one-shot 完成后等待 message 升级的 record 都不再计入。
599
- // 旧判定 `mode === "background"` 对这两类恒 true → 轮次完成通知恒挂 60s 合并窗口
600
- // (notifier MERGE_WINDOW_MS),主 agent 的续聊回复固定延迟 60s 送达,持续对话(G1)失效。
601
- return this.store.listRunning().some(
602
- (r) => r.mode === "background" && hasLiveProcessHandle(r.id) && !hasIdleTimer(r.id),
603
- );
604
- },
605
- isIdle: () => this.isIdleFn?.() ?? true,
606
- // [must-fix #4 / D8] settled 边沿订阅,与 isIdle 同源(session_start 注入的 pi)。
607
- // 只注入原生订阅能力;disposed 标志包装(退订语义)在 notifier 的 port 装配完成。
608
- onAgentSettled: (handler) => { this.pi?.on?.("agent_settled", handler); },
609
- };
702
+ * 触发条件:flushPendingNotifications ledger 仍有 pending(isIdle 门拦 / sendDelivery
703
+ * 受理失败的残留)且主 agent idle——即本次 shutdown 注定投不出去。落盘动作 =
704
+ * pi.appendEntry 重写 NOTIFY_LEDGER_CUSTOM_TYPE entry(与 ledger.record 同通道同 schema,
705
+ * notifyId 幂等:恢复扫描按后写覆盖 + ack/abandoned 差集去重,重复账面不产生重复投递)。
706
+ * agent idle 时 flush 已投出,无需复写(零开销)。
707
+ */
708
+ private persistUndeliveredNotificationsForReplay(): void {
709
+ try {
710
+ const ledger = getBoundNotifyLedger();
711
+ const pending = ledger?.pendingEntries() ?? [];
712
+ if (pending.length === 0) return;
713
+ if (this.isIdleFn?.() !== false) return; // idle:flush 已投出(或无 gate 场景照旧)
714
+ for (const item of pending) {
715
+ this.pi?.appendEntry(NOTIFY_LEDGER_CUSTOM_TYPE, {
716
+ v: 1,
717
+ notifyId: item.notifyId,
718
+ content: item.content,
719
+ record: item.record,
720
+ });
721
+ }
722
+ logger.warn(
723
+ `[subagents] shutdown flush blocked by busy main agent: ${pending.length} pending notification(s) persisted to ledger for replay on next session_start`,
724
+ { count: pending.length },
725
+ );
726
+ } catch (err) {
727
+ // 落盘复写是防丢失增强,失败不阻断 dispose(账本原 entry 仍随 pi flush 落盘)。
728
+ bestEffort(err, "persistUndeliveredNotificationsForReplay", "error");
729
+ }
610
730
  }
611
731
 
612
- /** record BgNotifyRecord(notifier.notify 入参映射,内部不外露)。
613
- * v4 B-1:守卫放行 closed(终态,含 cancelled)、isIdle(对话模式轮次完成,notify 主 agent G1)
614
- * isResumable(running + 无活进程——SP-5 one-shot 成功完成 / MF-6 失败轮回退)。
615
- * 正在执行(running + 活进程 + timer-armed)返回 undefined(调用方 notifyComplete 跳过)。
616
- * SP-1: closed 统一终态,closedReason BgNotifyRecord 携带。 */
617
- private toNotifyRecord(record: ExecutionRecord): BgNotifyRecord | undefined {
618
- const snap = snapshot(record);
619
- const s = snap.status;
620
- // [N1] isResumable 放行:SP-5 one-shot 成功完成后 finalizeRoundToIdle 把 record 回退
621
- // running-resumable——进程已死且永不 arm idle timer(armIdleTimer 只在 agent_settled 的
622
- // chatMode 分支调用),旧守卫(closed / isIdle only)对其恒拒绝 → 完成通知静默丢失,
623
- // 而 one-shot 失败走 finalizeRecord 保持 closed 反而通知——与 tool 契约「runs once,
624
- // notifies on completion」完全倒置。isResumable = running + 无活进程,恰为该完成态;
625
- // 在跑轮的 record 有活进程,不会被误放行。
626
- if (s !== "closed" && !isIdle(record) && !isResumable(record)) return undefined;
627
- // closed → BgNotifyRecord.closed(cancelled 区分靠 closedReason);chatMode 的 isIdle/
628
- // isResumable(轮次完成或 MF-6 失败轮回退,对话可续)→ running(轮次完成)。
629
- // isResumable 且非 chatMode(SP-5 one-shot 成功完成)→ closed:对主 agent 的语义是
630
- // completed(非对话轮次),且只有 closed 分支文案携带 worktree patchFile 的 git apply
631
- // 提示——one-shot worktree 模式的改动回收依赖该提示(running 分支文案不含 patchFile)。
632
- const notifyStatus: BgNotifyRecord["status"] =
633
- s === "closed" || !record.chatMode ? "closed" : "running";
634
- return {
635
- id: snap.id,
636
- status: notifyStatus,
637
- agent: snap.agent,
638
- model: snap.model,
639
- result: snap.result,
640
- error: snap.error,
641
- startedAt: snap.startedAt,
642
- endedAt: snap.endedAt,
643
- patchFile: record.patchFile,
644
- // round 透传给 notifier 的 dedup key(对话模式按轮次去重,G1 决策 9)。
645
- round: record.round,
646
- // SP-1: closedReason 透传给 notifier(L2 原因,供通知文案按需展示)。
647
- closedReason: record.closedReason,
648
- // [wave2] chatMode 条件透传 sessionFile:通知末尾追加 Full transcript 指针行
649
- //(增量语义的全文恢复通道,见 notifier.buildLlmContent)。one-shot(chatMode
650
- // falsy)不透传——通知输出逐字节不变(G4),该条件由 message-close 测试的
651
- // 必选用例锁死(漏加条件时 notifier 单测不红——notifier 层只见最终字段)。
652
- sessionFile: record.chatMode ? record.sessionFile : undefined,
653
- };
654
- }
732
+ // ── 执行(subagent-tool 调)────────────────────────────
733
+
734
+ // [D4-①] 通知簇四方法(notifyComplete / notifyClosed / piAdapter / toNotifyRecord)
735
+ // emitPendingRegister / emitPendingUnregister 模块函数已整体搬移至 notify-host.ts
736
+ //(本类经 this.notifyHost 消费;行为逐字节等价,搬移 + 依赖注入)。
655
737
 
656
738
  /**
657
739
  * 预解析 model(renderCall 标题行用,同步)。代理 modelService.resolveModel。
@@ -674,14 +756,17 @@ export class SubagentService {
674
756
  */
675
757
  async execute(opts: ExecuteOptions): Promise<ExecutionHandle> {
676
758
  this.assertReady();
759
+ // [T4② / PS-4] idleTimeoutMs 配置错误在首个副作用前同步 fail-fast(错误含合法范围)。
760
+ this.assertIdleTimeoutMsSafe(opts);
677
761
 
678
- // 通用嵌套深度护栏(D-033):execCtxAls 记录所有 subagent 嵌套层级(fork + 非 fork),
679
- // 每层 +1。MAX_FORK_DEPTH 同时限 fork 链与通用嵌套——非 fork 递归虽不累积 session 体积,
680
- // 但耗资源且 LLM 易陷入「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
762
+ // 通用嵌套深度护栏(D-033):嵌套上下文([D3-⑤] 公共层 ExecutionNestingContext)
763
+ // 记录所有 subagent 嵌套层级(fork + fork),每层 +1。MAX_FORK_DEPTH 同时限
764
+ // fork 链与通用嵌套——非 fork 递归虽不累积 session 体积,但耗资源且 LLM 易陷入
765
+ // 「委派→再委派」死循环。在所有副作用之前拦截,错误直达调用方。
681
766
  // 计数基准:顶层 nestingDepth=0,nestingDepth>MAX 被拒。与 fork 体积护栏(parentForkDepth 检查)
682
767
  // 互补:本护栏更严(计所有嵌套),混合链下先生效;两者共享 MAX_FORK_DEPTH 上限不漂移。
683
- // [ALS 断裂修复] getStore() pi 事件回调模型下可能读空(enterWith 不贯穿),基线兜底。
684
- const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
768
+ // [ALS 断裂修复] current() 内含基线兜底(pi 事件回调模型下 enterWith 不贯穿)。
769
+ const parentNesting = this.execNesting.current();
685
770
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
686
771
  if (nestingDepth > MAX_FORK_DEPTH) {
687
772
  throw new ForkDepthExceededError(
@@ -691,92 +776,41 @@ export class SubagentService {
691
776
 
692
777
  // mode 固定 background(sync 模式已删除)
693
778
  const mode: ExecutionMode = "background";
694
- const ctx = this.buildSessionRunnerContext(opts.cwd);
695
779
 
696
- // ── 1. IDENTITY 解析(确认 agentConfig → resolveModel)──
697
- 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;
698
788
 
699
- // ── 1.5 引擎路由(D4 chat 入口分叉;U2 升级为 routeEngine 编排)──
700
- // 三层解析(调用参数 > agent frontmatter > config.json defaultEngine)仍是同步纯
701
- // 函数;解析为非 pi 时升级走 routeEngine(probe 编排 + fallback 三守卫)。时机
702
- // 选择:路由(含 probe)在 record 创建前完成——兜底时 record 直接按 pi 语义创建 +
703
- // engineFallback 留痕(D5 字节级守护只约束「无 fallback 的纯缺省路径」,兜底路径
704
- // entry 允许含 engine/engineFallback 字段);守卫命中/strict 时 routeEngine 在此
705
- // 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)。
706
798
  const routingInput = {
707
799
  callEngine: opts.engine,
708
- agentEngine: identity.agentConfig?.engine,
800
+ agentEngine: agentConfig?.engine,
709
801
  globalDefaultEngine: this.modelService.getGlobalConfig().defaultEngine,
710
802
  };
711
- const routing = resolveEngineRouting(routingInput);
712
- let route: EngineRouteResult | undefined;
713
- if (routing.engineId !== DEFAULT_ENGINE_ID) {
714
- route = await routeEngine({
715
- routing: routingInput,
716
- // 守卫 c 判据只看调用方显式指定的 model(resolved model 含 ctxModel 兼底,
717
- // 恒非空会把一切兜底误判为 model 绑定命中)
718
- taskModel: opts.model,
719
- strict: this.modelService.getGlobalConfig().engineRouting?.strict === true,
720
- probe: (engineId) => getEngine(engineId).probe(),
721
- });
722
- if (route.engineId !== DEFAULT_ENGINE_ID) {
723
- return this.executeViaEngine(opts, identity, route);
724
- }
725
- // 兜底成功(典型:默认路由 + probe 失败 + 无守卫命中)→ 落回下方 pi 主路径,
726
- // record 创建时按 pi 语义 + engine/engineFallback 留痕(engine = 实际执行引擎)
727
- }
728
- // D5 字节级守护:无 fallback 的 pi 路由剥掉 opts.engine——createRecordForMode
729
- // 不盖章(pi record entry 序列化产物不得新增 engine 键,undefined 经 JSON 省略)。
730
- // 兜底路径显式盖 engine='pi' + engineFallback(见上方时机注释)。
731
- const piOpts =
732
- route?.engineFallback !== undefined
733
- ? { ...opts, engine: DEFAULT_ENGINE_ID, engineFallback: route.engineFallback }
734
- : opts.engine === undefined
735
- ? opts
736
- : { ...opts, engine: undefined };
737
-
738
- // ── 2. RECORD 创建 + 注册 ──
739
- const record = this.createRecordForMode(identity, piOpts, mode);
740
- emitPendingRegister(this.pi, record.id, record.agent);
741
-
742
- // ── 2.5 worktree 创建(仅 worktree===true 或已传入 handle 时)──
743
- // record 先创建,worktree 失败时可 finalizeFailed(record 已在 store 中)。
744
- // worktree 必须显式开启:worktree===true 创建新 worktree;worktree===undefined/false 不创建。
745
- // fork 不隐含 worktree(UC-1 fork 可独立使用,fork 仅继承上下文,在 parent cwd 跑)。
746
- let worktreeHandle: WorktreeHandle | undefined;
747
- if (typeof opts.worktree === "object") {
748
- // 传入的是已创建的 WorktreeHandle
749
- worktreeHandle = opts.worktree;
750
- } else if (opts.worktree === true) {
751
- // worktree===true(显式要求)——创建新 worktree。与 fork 正交(worktree 文件隔离不依赖 fork 上下文继承)。
752
- try {
753
- worktreeHandle = await this.worktreeManager.create(this.cwd, record.id);
754
- record.worktreeHandle = worktreeHandle;
755
- // [create-await 竞态守卫] create 的 await 窗口内 cancel/dispose 可 CAS 把 record
756
- // 转成 closed 终态——cancelBackground 当时读到的 worktreeHandle 可能仍是 undefined
757
- // (cleanup 被跳过)。赋值后同同步段检查终态:closed 则主动 cleanup(幂等,抢先的
758
- // fire-and-forget 清理无害)+ early-failed 返回,不进 kickOffBackground(避免子进程白跑)。
759
- // 实现约束:赋值 → 终态检查 → kickOffBackground 必须在同一同步段,中间禁止插入 await。
760
- if (record.status === "closed") {
761
- await this.worktreeManager.cleanup(worktreeHandle);
762
- return this.buildEarlyFailedHandle(record);
763
- }
764
- } catch (err) {
765
- // create 失败→不进入 run,finalizeFailed 统一收尾(含 emitPendingUnregister failed)
766
- const _result = await this.finalizeFailed(record, err);
767
- return this.buildEarlyFailedHandle(record);
768
- }
769
- }
770
-
771
- // ── 3. MODE 固定 background:signal/controller、priority 固定 ──
772
- const signal = record.controller!.signal;
773
- const priority = PRIORITY_BACKGROUND;
774
-
775
- // ── 4-7. background 包 detached 立即返回 id ──
776
- // background detached 运行对 tool 层不可见,完成由 notify 驱动新 turn。
777
- const bgDetails = project(record);
778
- this.kickOffBackground(record, { ...piOpts, worktree: worktreeHandle }, ctx, identity, signal, priority);
779
- 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);
780
814
  }
781
815
 
782
816
  /**
@@ -785,7 +819,7 @@ export class SubagentService {
785
819
  * 供 tool 层 cancelHandler 翻译 throw 用(id 不存在 / mode / 终态三种错误)。
786
820
  * 不存在返回 undefined。
787
821
  */
788
- findRecord(id: string): RecordSnapshot | undefined {
822
+ private findRecord(id: string): RecordSnapshot | undefined {
789
823
  this.assertReady();
790
824
  const record = this.store.getMutable(id);
791
825
  return record ? snapshot(record) : undefined;
@@ -810,7 +844,7 @@ export class SubagentService {
810
844
  *
811
845
  * 返回 undefined:id 在内存与磁盘均不存在。
812
846
  */
813
- lookupRecordAnyState(id: string): SubagentRecord | undefined {
847
+ private lookupRecordAnyState(id: string): SubagentRecord | undefined {
814
848
  try {
815
849
  this.assertReady();
816
850
  } catch {
@@ -824,33 +858,81 @@ export class SubagentService {
824
858
  // ── 对话模式投递(M2-B3 message action 调用)──────────────
825
859
 
826
860
  // [review 修复] 已删除 deliverToRunning(busy follow_up/steer 投递 + pendingMessages
827
- // 消费确认制):SP-5 upgrade 后所有 running record 走 chatMode 分支 → deliverMessage
828
- // 统一投递(热路径 prompt+streamingBehavior / 冷路径 resume),该方法无生产调用方,
861
+ // 消费确认制):SP-5 upgrade 后所有 running record 走 chatMode 分支 → 统一投递
862
+ //(热路径 prompt+streamingBehavior / 冷路径 resume),该方法无生产调用方,
829
863
  // 其配套三段消费链(push / message_start shift / redeliverPending 补投)全部不可达,
830
864
  // 一并移除(详见各文件同步删除)。
865
+ // [D2 单轨] 投递的 pi RPC stdin 协议知识(stdin prompt 命令直调 + streamingBehavior
866
+ // 映射 + EPIPE 兜底 + 冷路径分流)已下沉 PiEngine.deliverPrompt——本层经
867
+ // deliverChatMessage → PiEngine.interactRecord 调用,见下方两方法。
868
+
869
+ /**
870
+ * [V2 决策 3] chatMode 统一投递入口(message action 的 Service 面)——经引擎交互面
871
+ * 执行(D2:PiEngine.interactRecord——port face interact 的 record 锚定形态,协议知识
872
+ * 在引擎边界,编排层不做 stdin 写入)。分流语义(按**进程死活**,
873
+ * 不按 record.status)与热/冷路径细节见 PiEngine.deliverPrompt:
874
+ *
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
+ }
831
911
 
832
912
  /**
833
- * idle 投递:resume spawn 开启新一轮对话(设计决策 6 idle 分支)。
913
+ * 冷路径续轮(PiEngine.deliverPrompt 的编排回调,D2 下沉后的归属):resume spawn
914
+ * 开启新一轮对话(设计决策 6 idle 分支)。仅进程死(idle timer reap / 崩溃 / 跨重启
915
+ * / EPIPE 兜底)时经引擎到达。
834
916
  *
835
- * record 必须 idle(轮次完成、进程已回收、record 留内存)。手动把 status 设回 "running"
836
- * (M2-A 边界:idle→running 是恢复非终态,绕过 tryTransition——tryTransition 要求当前态
837
- * running 才 CAS,idle record 直接进 runAndFinalize 会被 tryTransition 拒绝转态)。
917
+ * record 必须 idle-resumable(轮次完成、进程已回收、record 留内存)。手动把 status
918
+ * 设回 "running"(M2-A 边界:idle→running 是恢复非终态,绕过 tryTransition——
919
+ * tryTransition 要求当前态 running 才 CAS)。
838
920
  *
839
921
  * resume 参数从 record identity 读(防多轮对话模型漂移,探针 P-10):sessionFile、
840
922
  * model、thinkingLevel 均为 record 身份字段(创建时确定、不可变)。maxTurns/schema 等
841
923
  * 执行约束第一版不恢复(设计 §5 拆分 1 待验证检查点),agentConfig 用 undefined
842
- * (pi --session 续写保留上下文,agent 行为由 session 内 messages 决定;M2-B3 messageHandler 可完善)。
924
+ *(pi --session 续写保留上下文,agent 行为由 session 内 messages 决定;M2-B3 messageHandler 可完善)。
843
925
  *
844
- * detached 编排(参照 kickOffBackground):不 await,runAndFinalize background 跑。
845
- * chatMode + done runAndFinalize 的 M2-A 分流自动把 record 重新置 idle。并发槽在
846
- * 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 是排队模型,
847
929
  * 池满时排队等待槽位而非 throw(与 execute 一致)。
848
930
  *
849
- * @param record 目标 record(必须 idle)
931
+ * @param record 目标 record(必须 idle-resumable
850
932
  * @param text 新一轮消息正文
851
- * @throws Error record 非 idle / 无 sessionFile / 无 controller
933
+ * @throws Error record 非 running / 无 sessionFile / 无 controller / worktree 绑定丢失 / 续轮在途
852
934
  */
853
- resumeRound(record: ExecutionRecord, text: string): void {
935
+ private resumeColdRound(record: ExecutionRecord, text: string): void {
854
936
  this.assertReady();
855
937
  // [CL-b1-cas-coupling / v4 B-1] v4 把旧 idle 折入 running 后此守卫对 idle-resumable
856
938
  // record 恒放行(idle 本来就是 running),`status = "running"`(下方)是幂等写——
@@ -864,10 +946,10 @@ export class SubagentService {
864
946
  );
865
947
  }
866
948
  // [review MF1] 在途 resume 守卫:上一条消息发起的 resume 仍在途(spawn 尚未注册 /
867
- // 本轮 runAndFinalize 未收尾)时,再次到达(冷路径重入 / EPIPE 兜底)直接拒绝。
949
+ // 本轮轮次未收尾)时,再次到达(冷路径重入 / EPIPE 兜底)直接拒绝。
868
950
  // 触发链:pi 对同一 assistant message 的 tool calls 顺序执行(sequential),tool1 的
869
- // deliverMessage 在冷路径 resumeRound 返回即 resolve(早于 spawn 注册完成),tool2
870
- // 立即执行 → getChildByRecord 仍 undefined → 再次冷路径。无此守卫 → 两次 kickOff →
951
+ // 投递在冷路径续轮返回即 resolve(早于 spawn 注册完成),tool2 立即执行 →
952
+ // getChildByRecord 仍 undefined → 再次冷路径。无此守卫 → 两次 kickOff →
871
953
  // runSpawn 2 次 → 两 pi 子进程双写同一 session JSONL + 第一个脱离 kill 记账成孤儿。
872
954
  if (this.resumesInFlight.has(record.id)) {
873
955
  // MF-4:行动语言。
@@ -880,7 +962,7 @@ export class SubagentService {
880
962
  if (!record.sessionFile) {
881
963
  // MF-4:session 损坏 → canonical 文案(spec §3.1 失败表)。
882
964
  throw new Error(
883
- `session unavailable for subagent ${record.id} (session file missing or unreadable). ` +
965
+ `subagent ${record.id} session unavailable (session file missing or unreadable). ` +
884
966
  `Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
885
967
  );
886
968
  }
@@ -944,125 +1026,70 @@ export class SubagentService {
944
1026
  };
945
1027
  const ctx = this.buildSessionRunnerContext();
946
1028
 
947
- // detached 编排:runAndFinalize background 跑,pool 重新 acquire(轮次间 idle 已 release)。
1029
+ // detached 编排:轮次在 background 跑,pool 重新 acquire(轮次间 idle 已 release)。
948
1030
  // chatMode + done 时 M2-A 分流自动 finalizeRoundToIdle(record 回 idle、round+1)。
949
- // [review MF1] 在途标记在 kickOff 前同步设置:resumeRound 返回即生效,后续重入
950
- // (冷路径 / EPIPE 兜底)在守卫处被拒;runAndFinalize finally 统一清除。
1031
+ // [review MF1] 在途标记在 kickOff 前同步设置:本方法返回即生效,后续重入
1032
+ // (冷路径 / EPIPE 兜底)在守卫处被拒;轮次收尾 finally 统一清除。
951
1033
  this.resumesInFlight.add(record.id);
952
- this.kickOffBackground(record, opts, ctx, identity, record.controller.signal, PRIORITY_BACKGROUND, resume);
1034
+ this.kickOffChatRound(record, opts, identity, ctx, record.controller.signal, PRIORITY_BACKGROUND, resume);
953
1035
  }
954
1036
 
955
1037
  /**
956
- * [V2 决策 3] chatMode 统一投递:按**进程死活**分流,不按 record.status。
1038
+ * [T2③] 热路径轮 settled watchdog 到期处置(对齐 u-t2a 首轮形态:kill + 该轮失败
1039
+ * 终态化 + 失败通知,error 含 'settled watchdog' 标记与恢复指引)。
957
1040
  *
958
- * V2 进程长驻——chatMode record 首轮 agent_settled 后进轻量 idle(Step 4a:进程保活、
959
- * idle timer armed),续聊时进程仍在内存,不该重开 session。故续聊投递不按 status
960
- *(running/idle 都可能是热路径),而是判进程死活:
1041
+ * [D9 两段式] 两段(mid-round 无进展 / settled 收尾段上界)共用本处置,fire 信息
1042
+ *(段 + 窗长)由原语注入,失败文案按段分叉窗长语义。
961
1043
  *
962
- * 热路径(进程活):prompt + streamingBehavior——pi 权威裁决 busy/idle(F3/F4)。
963
- * busy(isStreaming)时 followUp 入队/steer 抢占;idle streamingBehavior 被忽略、
964
- * 直接开新 turn。不用 steer/followUp 命令、不依赖 clearQueue(F8),结构上消除残留。
965
- * 冷路径(进程死):复用 resumeRound 重开 session + prompt(仅崩溃/timeout kill/跨重启命中)。
1044
+ * 与首轮的差异:runSpawn 已返回(无收尾链路承接 settledWatchdogFired 标记),失败
1045
+ * 终态化在本回调内完成。chatMode MF-6 语义回退 running-resumable(与首轮 watchdog
1046
+ * runAndFinalize 失败分支的最终形态一致——对话可冷路径复活);非 chatMode 终态
1047
+ * 销毁。CAS(tryTransition closed+gc)防与 cancel/dispose 双收尾,抢锁失败即跳过。
966
1048
  *
967
- * disarm idle timer:新 turn 开始必须 disarm(V2 决策 4),防 turn 期间 idle timer 误杀活进程。
968
- *
969
- * status 处理:判活分流后**各自**设 running——热路径手动设 running(新 turn 开始);
970
- * 冷路径由 resumeRound 校验 idle 并自行设 running + spawn(故不在此预设 running,否则
971
- * resumeRound 的 idle 检查会 throw)。resume spawn 后 session-runner 回填 record.pid,
972
- * 热路径拿到 child 时也顺便刷新 pid(resume 重开进程后 pid 已变)。
973
- *
974
- * [review 修复] 曾对比的 deliverToRunning(非 chatMode busy 投递 + pendingMessages
975
- * 消费确认制)已删除——SP-5 upgrade 后无生产调用方(V2 决策 3 已删消费确认制)。
976
- *
977
- * @param record 目标 record(chatMode,running 或 idle)
978
- * @param text 消息正文
979
- * @param interrupt true=steer(抢占)/ false=followUp(排队),仅热路径 prompt streamingBehavior 用
1049
+ * 回调在 timer 触发的同步上下文执行:同步段只做 kill + CAS(不抛),异步收尾
1050
+ * fire-and-forget 且 catch 归 bestEffort——错误逃出回调 = uncaughtException 崩宿主。
980
1051
  */
981
- async deliverMessage(record: ExecutionRecord, text: string, interrupt: boolean): Promise<void> {
982
- this.assertReady();
983
- // turn,disarm idle timer(防 turn 期间误杀活进程,V2 决策 4)
984
- disarmIdleTimer(record.id);
985
- const child = getChildByRecord(record.id);
986
- if (child && !child.killed) {
987
- // 热路径:进程活,prompt + streamingBehavior(V2 决策 3,pi 权威裁决 busy/idle)
988
- record.status = "running";
989
- // 刷新 pid 内存记账(resume spawn 后 child.pid 已变,顺便更新)
990
- if (child.pid !== undefined) record.pid = child.pid;
991
- try {
992
- sendPromptCommand(child, text, { streamingBehavior: interrupt ? "steer" : "followUp" });
993
- // 热路径成功,清零 EPIPE 连续失败计数([v4 A-1] 计数器已迁移到 stdin-writer)
994
- clearEpipeFailure(record.id);
995
- // [race-F5] 写后死进程检测:write 同步成功只代表数据进了内核 pipe 缓冲,子进程
996
- // 可能在读取前死亡(gate/idle kill 竞速)。exitCode/signalCode 已非 null = 进程已死
997
- //(close 事件可能尚未到达),缓冲中的消息将被静默丢弃。只 warn 留证(含 runId
998
- // 消息类型),不抛错不重试:终态回收已由 kill 路径保证,对死进程重试反而可能二次写。
999
- if (child.exitCode !== null || child.signalCode !== null) {
1000
- logger.warn(
1001
- `[subagents] deliverMessage: child ${record.id} died around stdin write, message may be lost`,
1002
- {
1003
- msgType: interrupt ? "steer" : "followUp",
1004
- exitCode: child.exitCode,
1005
- signalCode: child.signalCode,
1006
- },
1007
- );
1008
- }
1009
- // 轮始执行态信号清除 + 迁移上报(residual-fixes U3 补全,与冷路径 resumeRound
1010
- // 对称):新一轮开跑 = 无轮终信号——清上一轮 result(§5.4 isStreaming 公式要求
1011
- // result undefined 才显示 streaming)与 resumable,appendEntry 让 runtime/W18
1012
- // 派生缓存失效、GUI 侧从 waiting 切回 spinner。仅在投递成功后清(失败保留
1013
- // 上一轮信号,EPIPE 兜底走 resumeRound 时由其再清)。
1014
- record.result = undefined;
1015
- record.resumable = undefined;
1016
- this.store.reportRecordTransition(record);
1017
- } catch (err) {
1018
- // EPIPE 兜底:stdin 管道已断,进程实际已死但 close 事件尚未到达。
1019
- // 检测 EPIPE 关键词 → 进程按 dead 处理 → 自动转冷路径 resume + 消息重放。
1020
- // [review MF1] 本兜底不持 activateLock,但与冷路径共用 resumeRound 的在途守卫
1021
- //(resumesInFlight):resume 已在途时兜底的 resumeRound 调用被拒(throw 行动语言),
1022
- // 不会二次 spawn。
1023
- if (err instanceof Error && err.message.includes("EPIPE")) {
1024
- logger.warn(`[subagents] EPIPE on hot path for ${record.id}, falling back to cold path resume`, {
1025
- detail: err.message,
1026
- });
1027
- // 清理 spawnedChildren 中的死进程条目(让 resumeRound 能重新 spawn)。
1028
- // [M4] 按值守卫:仅当 Map 当前值仍是本次写 EPIPE 的 child 才删——若已被 resume
1029
- // spawn 覆盖为新 child(close 事件先于本 catch 到达的极端时序),不误删新注册
1030
- //(与 session-runner removeChildRegistration 同语义)。
1031
- if (spawnedChildren.get(record.id) === child) {
1032
- spawnedChildren.delete(record.id);
1033
- }
1034
- // 递增连续 EPIPE 计数([v4 A-1] helper 合并同步/异步路径计数)
1035
- const count = recordEpipeFailure(record.id);
1036
- if (count >= EPIPE_FAILURE_THRESHOLD) {
1037
- // 连续达阈值 EPIPE → 不再尝试 resume,throw 含恢复指引
1038
- clearEpipeFailure(record.id);
1039
- throw new Error(
1040
- `[subagents] EPIPE fallback exhausted for ${record.id}: ${count} consecutive EPIPE failures. ` +
1041
- `Recovery: use action:'close' to clean up, then action:'start' a new subagent.`,
1042
- );
1043
- }
1044
- // 冷路径 resume + 原消息重放(v4 B-1: status 已 running,resumeRound CAS 直接放行)
1045
- this.resumeRound(record, text);
1046
- return;
1047
- }
1048
- // 非 EPIPE 错误——不应发生,重新抛出让调用方处理
1049
- throw err;
1050
- }
1051
- } else {
1052
- // 冷路径:进程死(idle timer reap / 崩溃 / 跨重启),record 应为 idle → resume spawn。
1053
- // D3:acquireActivateLock 双保险——注意锁只覆盖 resumeRound 同步段,释放在子进程注册
1054
- //(session-runner spawnedChildren.set)之前(中间隔 pool.acquire await + tempFile 等异步点)。
1055
- // 真正的单写者守卫是 resumeRound 的 resumesInFlight([review MF1]):锁释放后、child
1056
- // 注册前到达的第二次冷路径 message 在 resumeRound 处被拒,不会二次 spawn。
1057
- const releaseLock = await acquireActivateLock(record.id);
1058
- try {
1059
- this.resumeRound(record, text);
1060
- } finally {
1061
- releaseLock();
1062
- }
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)`;
1057
+ logger.warn(
1058
+ `[subagents] settled watchdog (${fire.phase}) fired for ${record.id}: ${windowDesc}, ` +
1059
+ `terminating (LC-1 wedge recovery)`,
1060
+ );
1061
+ killRecordChildWithEscalation(record.id, "settled watchdog (hot path)");
1062
+ const failedResult: AgentResult = {
1063
+ text: "",
1064
+ turns: record.turnCount,
1065
+ durationMs: Date.now() - record.startedAt,
1066
+ success: false,
1067
+ error:
1068
+ `subagent did not reach agent_settled (${windowDesc}; settled watchdog); ` +
1069
+ `the process was terminated to bound the wait. ` +
1070
+ `Recovery: check state with subagents action:'list', then re-send your message to continue.`,
1071
+ sessionId: record.id,
1072
+ toolCalls: [],
1073
+ };
1074
+ if (!tryTransition(record, "closed", "gc")) {
1075
+ return; // 已被 cancel/dispose 抢先终态化——不重复收尾(watchdog disarm 由对方承接)
1063
1076
  }
1077
+ const finalize = record.chatMode
1078
+ ? this.finalizeRoundToIdle(record, failedResult)
1079
+ : this.finalizeRecord(record, failedResult, "closed", "gc");
1080
+ void finalize
1081
+ // [D4-①] 通知簇搬移 notify-host 后的遗留调用点修正:this.notifyComplete 方法已
1082
+ // 不存在(其余三处调用点均 this.notifyHost.notifyComplete),旧引用 throw
1083
+ // TypeError 被本 .catch 吞掉 → settled watchdog 失败通知静默丢失
1084
+ .then(() => this.notifyHost.notifyComplete(record))
1085
+ .catch((err: unknown) => bestEffort(err, "settled watchdog hot-path finalize", "error"));
1064
1086
  }
1065
1087
 
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 会误武装。
1092
+
1066
1093
  // ── 对话模式 message/close action 支持(M2-B3)──────────────
1067
1094
 
1068
1095
  /**
@@ -1085,14 +1112,15 @@ export class SubagentService {
1085
1112
  * @throws Error record 不存在 / 非本 session 所有(含恢复指引)
1086
1113
  * @throws ResurrectDeniedError 命中可重连集但被 worktree/异进程活实例守卫拦截(自带完整行动语言)
1087
1114
  */
1088
- getRecordForAction(id: string, opts?: { allowReconnect?: boolean }): ExecutionRecord {
1115
+ private getRecordForAction(id: string, opts?: { allowReconnect?: boolean }): ExecutionRecord {
1089
1116
  this.assertReady();
1090
1117
  let record = this.store.getMutable(id);
1091
1118
  // SP-2 跨重启恢复:内存未命中时,从磁盘 collectRecords 重建 idle record。
1092
1119
  // reconstructAll 已将跨重启 record(无 sidecar + pid 死)标记为 running(v4 B-1 可续聊语义,非 crashed),
1093
1120
  // 直接转为可变 ExecutionRecord register 进内存,供 message/close action 续操作。
1094
1121
  if (!record) {
1095
- record = this.coldLookupForAction(id, opts?.allowReconnect === true);
1122
+ // [D4-③] 冷查/复活链整体搬移至 cold-resurrect.ts(行为逐字节等价)。
1123
+ record = coldLookupForAction(this.coldResurrectDeps, id, opts?.allowReconnect === true);
1096
1124
  }
1097
1125
  if (!record || record.rootSessionId !== this.sessionRootId) {
1098
1126
  throw new Error(
@@ -1102,12 +1130,12 @@ export class SubagentService {
1102
1130
  }
1103
1131
  // [v4 A-5 / P7] 直接父校验:rootSessionId 已确认 record 属于本 session 树,但递归场景下
1104
1132
  // 孙级 record(parentRecordId = 某子进程的 self recordId)的子进程句柄只存在于其直接父
1105
- // 进程内存。主进程(execCtxBaseline=null)若仅凭 rootSessionId 通过就 message 孙级,会走
1133
+ // 进程内存。主进程(基线 null)若仅凭 rootSessionId 通过就 message 孙级,会走
1106
1134
  // 冷路径重新 spawn → 双写同一 session 文件(P7 双写者窗口)。统一用 baseline recordId 校验:
1107
1135
  // - 主进程 baseline=undefined → 只能操作 parentRecordId=undefined 的根层 record
1108
1136
  // - 子进程 baseline="sa-X" → 只能操作 parentRecordId="sa-X" 的直接孩子
1109
1137
  // record.parentRecordId===undefined 视作根层,仅主进程可操作(身份缺省的旧/异常 record 归此)。
1110
- const baselineRecordId = this.execCtxBaseline?.recordId ?? undefined;
1138
+ const baselineRecordId = this.execNesting.baseline()?.recordId ?? undefined;
1111
1139
  if (record.parentRecordId !== baselineRecordId) {
1112
1140
  throw new Error(
1113
1141
  `subagent ${id} is owned by its direct parent; message it through that parent ` +
@@ -1119,136 +1147,13 @@ export class SubagentService {
1119
1147
  return record;
1120
1148
  }
1121
1149
 
1122
- /** SP-2 冷路径(getRecordForAction 内存未命中分支的提取):从磁盘重建可变 ExecutionRecord
1123
- * register 进内存。[perf] 先走 idToFile 索引直查(单文件 stat 校验),未命中(进程重启后
1124
- * 尚未扫描、索引未热)才全目录 collectRecords 兜底建索引——跨重启后每条 message
1125
- * 「readdir + N×4 stat 全扫」降为单文件校验。
1126
- * @returns 重建的 record;磁盘也无则 undefined
1127
- * @throws ResurrectDeniedError 可重连候选被 worktree/异进程活实例守卫拦截 */
1128
- /** 冷查候选定位(coldLookupForAction 步骤 1):idToFile 索引直查 running 命中,
1129
- * 未命中再全目录 collectRecords 兜底(running,或 allowReconnect 且可重连 closed)。 */
1130
- private findColdLookupCandidate(id: string, allowReconnect: boolean): SubagentRecord | undefined {
1131
- const direct = this.store.findLightById(id);
1132
- return (
1133
- (direct?.status === "running" ? direct : undefined) ??
1134
- this.store
1135
- .collectRecords(COLD_LOOKUP_SCAN_LIMIT, "all", undefined)
1136
- .find((r) => r.id === id && (r.status === "running" || (allowReconnect && this.isReconnectableClosed(r))))
1137
- );
1138
- }
1139
-
1140
- /** 可重连守卫(coldLookupForAction 步骤 2,[v8.5 D]):先于任何状态突变与注册。
1141
- * worktree 绑定丢失 / 异进程活实例以 ResurrectDeniedError 抛出(endedMessageGuard
1142
- * 必须原样透传,不得改写为 fork-from 指引误导 agent 走已被判死的通道);拒绝时
1143
- * 内存不得残留该记录(findRecord 契约)。 */
1144
- private assertReconnectAllowed(found: SubagentRecord, id: string): void {
1145
- if (found.status !== "closed") return;
1146
- if (found.worktree === true) {
1147
- throw new ResurrectDeniedError(
1148
- `subagent ${id} cannot be transparently resumed: it was created with worktree isolation, ` +
1149
- `and its worktree checkout no longer exists after restart (resuming in place would make spawn cwd fall back to the main repo). ` +
1150
- `Recovery: action:'start' a fresh subagent (with a new worktree if isolation is still needed); ` +
1151
- `its conversation history remains intact at ${found.sessionFile}.`,
1152
- );
1153
- }
1154
- const foreign = found.sessionFile ? findForeignLiveInstance(found.sessionFile) : undefined;
1155
- if (foreign) {
1156
- throw new ResurrectDeniedError(
1157
- `subagent ${id} is not transparently resumable: its previous instance still finishing in another process ` +
1158
- `(pid ${foreign.pid}, startedAt=${new Date(foreign.startedAt).toISOString()}). ` +
1159
- `Resuming in place would double-write ${found.sessionFile}. ` +
1160
- `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.`,
1161
- );
1162
- }
1163
- }
1164
-
1165
- /** 磁盘候选重建为可变 record 并 register + 上报(coldLookupForAction 步骤 4)。 */
1166
- private resurrectColdRecord(found: SubagentRecord, id: string): ExecutionRecord {
1167
- const record = createRecord(id, {
1168
- agent: found.agent,
1169
- model: found.model,
1170
- thinkingLevel: found.thinkingLevel,
1171
- mode: found.mode,
1172
- task: found.task,
1173
- slug: found.slug,
1174
- startedAt: found.startedAt,
1175
- rootSessionId: found.rootSessionId,
1176
- parentRecordId: found.parentRecordId,
1177
- depth: found.depth,
1178
- // [v4 A-3] 跨重启恢复入口——message 路径磁盘重建无条件置 chatMode=true(现状机制,
1179
- // V3 方案 A 方向兑现)。改动此处必须带 S3 回归场景(跨重启 message 续聊验证)。
1180
- // V3 SP-5 探针定案:机制已存在,本注释即定案,不再悬置。
1181
- chatMode: true,
1182
- controller: new AbortController(),
1183
- });
1184
- record.sessionFile = found.sessionFile;
1185
- record.round = found.round;
1186
- // [review round2] 跨重启 worktree 绑定丢失防护:原 record 创建时启用了 worktree 隔离
1187
- //(session entry 的 worktree 标志),但 WorktreeHandle 不可序列化、重建后恒缺失。
1188
- // 标记 hadWorktree,resumeRound 守卫据此拒绝续聊(防 spawn cwd 静默回落主 repo 破坏
1189
- // 隔离——正是 worktree 要防的并发写冲突场景)。close 不受影响(closeChatIdle 走
1190
- // doFinalizeRecord,泄漏的 worktree 由 reaper 兜底回收)。
1191
- record.hadWorktree = found.worktree === true;
1192
- // [v8.5 D] 透明重生回边:独立函数不走 tryTransition 单向语义(closed 单向性对正常
1193
- // 执行流完整保留);准入唯一依据 = A 档 sidecar 真实死因 ∈ 可重连集。死亡语义位由
1194
- // resurrectClosed 清除;register 后立刻上报 transition entry,live/reload 视图同步
1195
- // 翻回 running(等价性由 applyEntry reducer 保证,对齐 SP-2 重建即报告先例)。
1196
- if (found.status !== "running") {
1197
- // [review MF-8] 磁盘终态位同步翻转:record-store buildRecord 分支 2(.finalized
1198
- // 存在 → closed)优先级高于 .alive 活态分支 3,重生若不删 sidecar,任何磁盘扫描
1199
- // (异进程 / reload / session-reader)都会把本进程内存里 running 的 record 报成
1200
- // closed/disconnected——破坏 live ≡ reload,且为跨进程二次 resurrect 开门。
1201
- // best-effort 对齐 BC-4 语义;.alive 刷新为当前进程(后续 resume spawn 会覆盖写)。
1202
- if (record.sessionFile) {
1203
- try {
1204
- fs.rmSync(`${record.sessionFile}.finalized`, { force: true });
1205
- writeAliveMarker(record.sessionFile, { pid: process.pid, id, startedAt: Date.now() });
1206
- } catch (_e) {
1207
- void _e; // best-effort:sidecar 翻转失败不阻断重生主流程
1208
- }
1209
- }
1210
- resurrectClosed(record);
1211
- }
1212
- this.store.register(record);
1213
- if (found.status !== "running") {
1214
- this.store.reportRecordTransition(record);
1215
- }
1216
- return record;
1217
- }
1218
-
1219
- private coldLookupForAction(id: string, allowReconnect: boolean): ExecutionRecord | undefined {
1220
- const found = this.findColdLookupCandidate(id, allowReconnect);
1221
- if (!found) return undefined;
1222
- // [v8.5 D] 可重连候选的守卫先于任何状态突变与注册(细节见 assertReconnectAllowed)
1223
- this.assertReconnectAllowed(found, id);
1224
- // [review MF-9] 归属校验先于任何持久化副作用:coldLookup 是 getRecordForAction 的
1225
- // 内存未命中分支,若先 resurrect/register/report 再由调用方抛归属错误,会在磁盘/
1226
- // 内存留下幽灵 running record + running transition entry(跨进程双 resurrect 窗口)。
1227
- // rootSessionId 不匹配 → 返回 undefined,由 getRecordForAction 抛统一「not found or
1228
- // not owned」(不区分失败形态,防跨 session 探测);parentRecordId 跨层不匹配 →
1229
- // 原样抛 direct parent 错误(与外层校验同文案,保留跨层导航指引)。
1230
- if (found.rootSessionId !== this.sessionRootId) {
1231
- return undefined;
1232
- }
1233
- const baselineRecordId = this.execCtxBaseline?.recordId ?? undefined;
1234
- if (found.parentRecordId !== baselineRecordId) {
1235
- throw new Error(
1236
- `subagent ${id} is owned by its direct parent; message it through that parent ` +
1237
- `(see /subagents list, parent=${found.parentRecordId ?? "(root layer)"}). [v4 A-5] cross-layer ` +
1238
- `ownership guard: this process's baseline=${baselineRecordId ?? "(root)"} is not the direct parent of ${id}; ` +
1239
- `operating here would race the owning child process's handle and double-write the session file.`,
1240
- );
1241
- }
1242
- return this.resurrectColdRecord(found, id);
1243
- }
1244
-
1245
- /** [v8.5 D] 冷查候选过滤:closed 且死因落在可重连集。判定源 = closedReason(buildRecord
1246
- * 归一化后的对外字段:A 档真实死因直通、旧空 sidecar 兑底 disconnected——SubagentRecord
1247
- * 不暴露 raw finalizedReason);cancelled/user-close/gc 等主动关闭与自然完成死因天然不在集合内。
1248
- * 防线在集合本身而非调用点。 */
1249
- private isReconnectableClosed(r: SubagentRecord): boolean {
1250
- return isReconnectableFinalReason(r.closedReason);
1251
- }
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 对称)。
1252
1157
 
1253
1158
  /**
1254
1159
  * close action 的统一行为分流(running 子态 × force)。
@@ -1265,7 +1170,7 @@ export class SubagentService {
1265
1170
  * @param record 目标 record(getRecordForAction 已校验归属)
1266
1171
  * @param force true=立即终止(running 时 SIGTERM)/ false=优雅关闭(running 时等轮完)
1267
1172
  */
1268
- async closeSubagent(record: ExecutionRecord, force: boolean): Promise<void> {
1173
+ private async closeSubagent(record: ExecutionRecord, force: boolean): Promise<void> {
1269
1174
  this.assertReady();
1270
1175
  if (record.status === "running") {
1271
1176
  if (force) {
@@ -1304,10 +1209,14 @@ export class SubagentService {
1304
1209
  * completeRecord 直接覆盖 status,与 cancelBackground 对 record 的处理同构)。
1305
1210
  */
1306
1211
  private async closeChatIdle(record: ExecutionRecord): Promise<void> {
1307
- // [M5] Path A:回收保活进程 + disarm idle timer(终态化后无其他 kill 路径)
1212
+ // [M5] Path A:回收保活进程 + disarm idle timer(终态化后无其他 kill 路径)。
1213
+ // [T2④ / LC-2] 裸 SIGTERM 收敛到 killRecordChildWithEscalation:SIGTERM 被无视时
1214
+ // 30s 升级 SIGKILL——终态化后 record 已 archive,此前裸 SIGTERM 挂住 = 幽灵进程
1215
+ // 无任何后续回收通道。同时 disarm settled watchdog(本路径终态化 = settled 等待
1216
+ // 窗口终结,防 watchdog 误杀后续同 id 资源)。
1308
1217
  disarmIdleTimer(record.id);
1309
- const child = getChildByRecord(record.id);
1310
- if (child && !child.killed) child.kill("SIGTERM");
1218
+ disarmSettledWatchdog(record.id);
1219
+ killRecordChildWithEscalation(record.id, "closeChatIdle");
1311
1220
  // 合成 closed result(无在途 AgentResult,对齐 closeAfterRoundSettled 的
1312
1221
  // `record.result ?? ""` 模式)。[W16 P-1 修复] text 必须沿用轮终真实 result:
1313
1222
  // completeRecord 会执行 record.result = result.text,合成空串会把轮终真实值抹空,
@@ -1328,7 +1237,8 @@ export class SubagentService {
1328
1237
  store: this.store,
1329
1238
  modelService: this.modelService,
1330
1239
  pi: this.pi,
1331
- emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
1240
+ emitUnregister: (id, st) => this.notifyHost.emitPendingUnregister(id, st),
1241
+ sessionDir: this.sessionsDir,
1332
1242
  },
1333
1243
  record,
1334
1244
  doneResult,
@@ -1340,7 +1250,7 @@ export class SubagentService {
1340
1250
  // (P-1 修复),正文空由 notifyClosed 的 emptyBody 参数显式表达。
1341
1251
  // dedup 身份独立于轮次通知(notifyClosed 置 round=undefined),60s 窗内不被吞。
1342
1252
  // 防重入:closeSubagent 对 closed record 幂等 no-op,本路径不会被二次进入。
1343
- this.notifyClosed(record, true);
1253
+ this.notifyHost.notifyClosed(record, true);
1344
1254
  }
1345
1255
 
1346
1256
  /**
@@ -1358,10 +1268,12 @@ export class SubagentService {
1358
1268
  * 60s dedup 吞(不与下方终态通知叠加成第三条——后者 key 是裸 id)。
1359
1269
  */
1360
1270
  private async closeAfterRoundSettled(record: ExecutionRecord): Promise<void> {
1361
- // 回收保活进程(Path A:轮次完成后进程仍活)+ disarm idle timer(终态化后无其他 kill 路径)
1271
+ // 回收保活进程(Path A:轮次完成后进程仍活)+ disarm idle timer(终态化后无其他 kill 路径)。
1272
+ // [T2④ / LC-2] 裸 SIGTERM 收敛到 killRecordChildWithEscalation(30s 升级 SIGKILL,
1273
+ // 终态化后无后续回收通道);settled 等待窗口同步终结(disarm 幂等)。
1362
1274
  disarmIdleTimer(record.id);
1363
- const child = getChildByRecord(record.id);
1364
- if (child && !child.killed) child.kill("SIGTERM");
1275
+ disarmSettledWatchdog(record.id);
1276
+ killRecordChildWithEscalation(record.id, "closeAfterRoundSettled");
1365
1277
  if (!tryTransition(record, "closed", "user-close")) {
1366
1278
  return; // 已被 cancel/finalize 抢先(CAS 失败),标志已消费即可——不发终态通知(幂等)
1367
1279
  }
@@ -1376,7 +1288,7 @@ export class SubagentService {
1376
1288
  await this.finalizeRecord(record, doneResult, "closed", "user-close");
1377
1289
  // [C-1] 终态通知(设计 D2 路径①):与前置轮次通知(key=`id:round`)dedup 身份区分,
1378
1290
  // 「最后一轮轮次通知 + 终态通知」两条都送达父 agent。
1379
- this.notifyClosed(record);
1291
+ this.notifyHost.notifyClosed(record);
1380
1292
  }
1381
1293
 
1382
1294
  // ── 编排层专用接口(workflow 消费)──────────────────────
@@ -1386,7 +1298,7 @@ export class SubagentService {
1386
1298
  *
1387
1299
  * 与 execute() 的区别(D-A1):
1388
1300
  * 1. 返回 workflow AgentResult(content 字段),非 ExecutionHandle
1389
- * 2. 不调 kickOffBackground 不注入 followUp 完成通知(BC-11,结果直接返回 workflow)
1301
+ * 2. 不经 chat 轮次 kick-off(detached 回注)→ 不注入 followUp 完成通知(BC-11,结果直接返回 workflow)
1390
1302
  * 3. T2 删 sync 时 executeAndAwait 不受牵连(独立方法)
1391
1303
  *
1392
1304
  * 共享:runSpawn + ConcurrencyPool + record + pending emit(D-A4)。
@@ -1398,10 +1310,12 @@ export class SubagentService {
1398
1310
  stream?: SubagentStream,
1399
1311
  ): Promise<WorkflowAgentResult> {
1400
1312
  this.assertReady();
1313
+ // [T4② / PS-4] 与 execute() 同款入口校验(两入口共享 runAndFinalize → armIdleTimer 链)。
1314
+ this.assertIdleTimeoutMsSafe(opts);
1401
1315
 
1402
- // ── BC-12 嵌套护栏:复用 execute() execCtxAls 深度检查 ──
1403
- // [ALS 断裂修复] getStore() 可能读空,基线兜底(与 execute 同)。
1404
- const parentNesting = this.execCtxAls.getStore() ?? this.execCtxBaseline;
1316
+ // ── BC-12 嵌套护栏:复用 execute() 的嵌套上下文深度检查 ──
1317
+ // [ALS 断裂修复] current() 内含基线兜底(与 execute 同)。
1318
+ const parentNesting = this.execNesting.current();
1405
1319
  const nestingDepth = parentNesting ? parentNesting.depth + 1 : 0;
1406
1320
  if (nestingDepth > MAX_FORK_DEPTH) {
1407
1321
  throw new ForkDepthExceededError(
@@ -1414,7 +1328,7 @@ export class SubagentService {
1414
1328
 
1415
1329
  // ── 步骤 2: RECORD 创建(mode="background" 进池)──
1416
1330
  const record = this.createRecordForMode(identity, opts, "background");
1417
- emitPendingRegister(this.pi, record.id, record.agent);
1331
+ this.notifyHost.emitPendingRegister(record.id, record.agent);
1418
1332
 
1419
1333
  // ── 步骤 2.5: worktree creation (only worktree===true; handle injection is execute()'s path) ──
1420
1334
  // Workflow path receives boolean only (AgentCallOpts.worktree: boolean) — WorktreeHandle is a
@@ -1497,59 +1411,121 @@ export class SubagentService {
1497
1411
  // ── 状态查询(TUI 调)──────────────────────────────────
1498
1412
 
1499
1413
  /** 订阅 store 变更(widget/list requestRender)。返回取消订阅。 */
1500
- onChange(listener: () => void): () => void {
1414
+ private onChange(listener: () => void): () => void {
1501
1415
  return this.store.onChange(listener);
1502
1416
  }
1503
1417
 
1504
- /** 列出 running record 快照(widget 计数用)。 */
1505
- listRunning(): RecordSnapshot[] {
1506
- return this.store.listRunning();
1507
- }
1418
+ // [D4] listRunning 已删除:零生产调用方(TUI 计数经 collectRecords / notify-host 的
1419
+ // piAdapter 直调 store.listRunning 覆盖),唯一消费是初始空态单测——保留 store 层方法。
1508
1420
 
1509
1421
  /** 合并内存(running) + 磁盘(session.jsonl 重建) record(/subagents list + tool list 消费)。
1510
1422
  * 按 rootSessionId 过滤:根进程=本 session(sessionRootId===sessionId);
1511
1423
  * 子进程=env 贯穿的真 ROOT(sessionRootId≠sessionId)→ 看到整棵 ROOT 树(决策 3)。
1512
1424
  * [perf] 磁盘源为 light(头部 identity + 状态,无 eventLog/result/turns 等重数据)
1513
1425
  * ——列表/补全/hasRunning 够用;详情场景调 getFullRecord(id) 懒加载补齐。 */
1514
- collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
1426
+ private collectRecords(limit: number, statusFilter: StatusFilter = "all"): SubagentRecord[] {
1515
1427
  return this.store.collectRecords(limit, statusFilter, this.sessionRootId ?? this.sessionId ?? undefined);
1516
1428
  }
1517
1429
 
1518
1430
  /** [perf] 单 record 详情懒加载(全量:eventLog/displayItems/result/turns/tokens)。
1519
1431
  * 内存 running record 直接投影;磁盘 record 全量重建(per-file 缓存,stat 戳校验)。
1520
1432
  * 返回 undefined:id 不存在于内存与磁盘。 */
1521
- getFullRecord(id: string): SubagentRecord | undefined {
1433
+ private getFullRecord(id: string): SubagentRecord | undefined {
1522
1434
  return this.store.getFullRecord(id);
1523
1435
  }
1524
1436
 
1525
1437
  // ── 执行内部:身份解析 + record 创建 ──────────
1526
1438
 
1527
- /** 步骤 1:身份解析。agentConfig → resolveModel(三层:override → agentConfig → 主 agent model)。 */
1528
- 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> {
1529
1451
  // agentRef 语义(S2):agent 参数 = .md 绝对路径;不传 = 不加载 agentConfig,
1530
1452
  // 直接用 override → 主 agent model。DEFAULT_AGENT_NAME 仅作 record 显示名
1531
1453
  // (TUI 层 extractAgentName 共用,保证显示一致)。
1532
- const agent = opts.agent ?? DEFAULT_AGENT_NAME;
1454
+ const agent = pre?.agent ?? opts.agent ?? DEFAULT_AGENT_NAME;
1533
1455
  // 显式 agent ref(用户点名)失败必须报错,不静默降级:无 require 的 loadByPath
1534
1456
  // 对相对路径/裸名/文件缺失都返回 undefined → agentConfig undefined → resolveModel
1535
1457
  // 静默回落 override→主 agent model,用户拿到的 subagent 无 systemPrompt/工具白名单
1536
1458
  // 且零反馈。require:true 让失败抛出带 <available_subagents> 指引的错误(对齐
1537
1459
  // workflow name not found 反馈风格);不传 agent = 默认 general-purpose 语义,
1538
1460
  // agentConfig 保持 undefined(合法缺省,走 override → ctxModel 兑底)。
1539
- const agentConfig = opts.agent
1540
- ? this.modelService.getRequiredAgentConfig(opts.agent)
1541
- : undefined;
1542
-
1543
- const resolved = this.modelService.resolveModel(
1544
- opts.agent ?? "",
1545
- { model: opts.model, thinkingLevel: opts.thinkingLevel },
1546
- opts.ctxModel,
1547
- agentConfig,
1548
- );
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
+ }
1549
1485
 
1550
1486
  return { agent, agentConfig, resolved };
1551
1487
  }
1552
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
+
1553
1529
  /** 步骤 2:按 mode 生成 id + controller,创建 record 并注册。
1554
1530
  * [L-1] ExecutionMode 类型固定 "background"(sync 已删除),id/controller 分支简化。 */
1555
1531
  private createRecordForMode(
@@ -1562,11 +1538,11 @@ export class SubagentService {
1562
1538
  const controller = new AbortController();
1563
1539
 
1564
1540
  // 从 async 调用链读父执行上下文:主 session 链上无 store → 顶层 record;
1565
- // 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。
1566
1542
  // depth 语义:顶层(无父)=0;有父=父 depth+1。靠 recordId 是否存在区分,不用负数魔数。
1567
- // [ALS 断裂修复] getStore() pi 事件回调模型下可能读空(enterWith 不贯穿),
1568
- // 基线兜底——本进程的身份在 initSession 已确定(env 注入),任何上下文下都能正确挂父链。
1569
- const parentCtx = this.execCtxAls.getStore() ?? this.execCtxBaseline;
1543
+ // [ALS 断裂修复] current() 内含基线兜底——本进程的身份在 initSession 已确定(env 注入),
1544
+ // 任何上下文下都能正确挂父链。
1545
+ const parentCtx = this.execNesting.current();
1570
1546
  const parentRecordId = parentCtx?.recordId;
1571
1547
  const depth = parentCtx ? parentCtx.depth + 1 : 0;
1572
1548
 
@@ -1604,70 +1580,113 @@ export class SubagentService {
1604
1580
  // ── 引擎分支(D4/D10:非 pi 引擎的 chat 域执行骨架,U0)──────────
1605
1581
 
1606
1582
  /**
1607
- * 路由到非 pi 引擎的执行入口:routeEngine(注册表校验 + probe/守卫)已由 execute
1608
- * 完成——这里只剩 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。
1609
1587
  * 全部同步拒绝发生在 record 创建前(不产生孤儿 record)。
1610
1588
  */
1611
- private executeViaEngine(
1589
+ private async executeViaEngine(
1612
1590
  opts: ExecuteOptions,
1613
- identity: ResolvedIdentity,
1591
+ preIdentity: { agent: string; agentConfig: AgentConfig | undefined },
1614
1592
  route: EngineRouteResult,
1615
- ): ExecutionHandle {
1593
+ mode: ExecutionMode,
1594
+ ): Promise<ExecutionHandle> {
1616
1595
  const engine = route.engine;
1617
- this.assertEngineParamSupport(engine, opts);
1618
- // record 盖章路由结果(D5 pi 缺省不盖章;非 pi 显式留痕,createRecordForMode
1619
- // 经 opts.engine/engineFallback 读入 record identity——engine 为实际执行引擎,
1620
- // fallback 路径 from=请求引擎留痕,probe 通过的常态路径恒缺省)
1621
- const record = this.createRecordForMode(
1622
- identity,
1623
- {
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
+ : {
1624
1631
  ...opts,
1632
+ ...(engineModel !== undefined ? { model: engineModel } : {}),
1625
1633
  engine: route.engineId,
1626
1634
  ...(route.engineFallback !== undefined ? { engineFallback: route.engineFallback } : {}),
1627
- },
1628
- "background",
1629
- );
1630
- emitPendingRegister(this.pi, record.id, record.agent);
1631
- this.kickOffEngineRun(record, opts, engine);
1632
- return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details: project(record) };
1633
- }
1635
+ };
1636
+ const record = this.createRecordForMode(identity, recordOpts, mode);
1637
+ this.notifyHost.emitPendingRegister(record.id, record.agent);
1634
1638
 
1635
- /**
1636
- * pi 引擎的 unsupported 参数预检(D11 处置「调用前拒绝」的判据 = capabilities)。
1637
- * conversation / fork / worktree 三参数对首期接入的引擎(zcode)均不可用:
1638
- * conversation 依赖同进程 idle 复用、fork 依赖父 pi session 上下文继承、worktree 依赖
1639
- * 文件隔离(capabilities.sandbox='none')。同步 throw,文案含 capabilities 依据与恢复指引。
1640
- */
1641
- private assertEngineParamSupport(engine: EnginePort, opts: ExecuteOptions): void {
1642
- const caps = engine.capabilities();
1643
- if (opts.conversation === true && caps.conversation === "unsupported") {
1644
- throw new EngineError(
1645
- "engine_capability_unsupported",
1646
- `engine '${engine.id}' 不支持 conversation(capabilities.conversation = 'unsupported',` +
1647
- `spawn 单轮模式无同进程 idle 复用,message/close 交互控制面不可用)`,
1648
- `改用 engine: pi(支持 conversation 续聊),或不传该参数(一次性任务默认形态)`,
1649
- );
1650
- }
1651
- if (opts.fork === true || opts.forkFromSessionFile !== undefined) {
1652
- throw new EngineError(
1653
- "engine_capability_unsupported",
1654
- `engine '${engine.id}' 不支持 fork${opts.forkFromSessionFile !== undefined ? "(fork-from 同为父 pi session 上下文继承)" : ""}(fork 依赖父 pi session 上下文继承,` +
1655
- `capabilities.steer = '${caps.steer}'——非 pi 引擎无父 session 分叉通道)`,
1656
- `把所需父上下文写进 task 正文后不传 fork,或改用 engine: pi`,
1657
- );
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
+ }
1658
1668
  }
1659
- if ((opts.worktree === true || typeof opts.worktree === "object") && caps.sandbox === "none") {
1660
- throw new EngineError(
1661
- "engine_capability_unsupported",
1662
- `engine '${engine.id}' 不支持 worktree 隔离(capabilities.sandbox = 'none',` +
1663
- `引擎未接文件系统隔离层)`,
1664
- `改用 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,
1665
1680
  );
1681
+ } else {
1682
+ // 非 pi 引擎:engine.run 自足执行(handle+outcome),编排侧 journal 接线 + 终态迁移
1683
+ this.kickOffEngineRun(record, recordOpts, engine);
1666
1684
  }
1685
+ return { mode: "background", subagentId: record.id, sessionFile: record.sessionFile, details: project(record) };
1667
1686
  }
1668
1687
 
1669
1688
  /**
1670
- * 非 pi 引擎的 detached 执行编排(与 kickOffBackground 同构的 background 语义):
1689
+ * 非 pi 引擎的 detached 执行编排(与 pi 轮次 kick-off 同构的 background 语义):
1671
1690
  * pool 并发槽(maxConcurrent 对非 pi 引擎同样生效)→ journal 接线(D6 第②级:
1672
1691
  * taskId=record.id,初始池 key 占位 'shared',onPoolResolved retarget 到引擎实际
1673
1692
  * 池 key——路径与 paths.ts 同源推导)→ engine.run(signal 接 record controller,
@@ -1695,7 +1714,7 @@ export class SubagentService {
1695
1714
  await this.runEngineTask(record, opts, engine, signal);
1696
1715
  // cancel 抢先(closedReason='cancelled')时 cancelBackground 自己 notify,跳过
1697
1716
  if (record.closedReason !== "cancelled") {
1698
- this.notifyComplete(record);
1717
+ this.notifyHost.notifyComplete(record);
1699
1718
  }
1700
1719
  } finally {
1701
1720
  this.pool.release();
@@ -1707,7 +1726,7 @@ export class SubagentService {
1707
1726
  * kickOffEngineRun 的 acquire 后主体:journal 接线(D6 第②级:taskId=record.id,
1708
1727
  * 初始池 key 占位 'shared',onPoolResolved retarget 到引擎实际池 key)→ engine.run
1709
1728
  * (signal 接 record controller,kill-chain 两级生效)→ engineHandle 回填(终态迁移
1710
- * 落 entry 前)→ 终态迁移。bg notify 归编排侧(与 kickOffBackground 收尾通知归编排对称)。
1729
+ * 落 entry 前)→ 终态迁移。bg notify 归编排侧(与 pi 轮次收尾通知归编排对称)。
1711
1730
  */
1712
1731
  private async runEngineTask(
1713
1732
  record: ExecutionRecord,
@@ -1715,23 +1734,38 @@ export class SubagentService {
1715
1734
  engine: EnginePort,
1716
1735
  signal: AbortSignal | undefined,
1717
1736
  ): Promise<void> {
1718
- const journal = new JournalWriter({
1719
- path: resolveJournalPath(getEngineDataDir(), engine.id, "shared", record.id),
1720
- taskId: record.id,
1721
- engineId: engine.id,
1722
- });
1723
- const retargetJournal = (poolKey: string): void => {
1724
- journal.retarget(resolveJournalPath(getEngineDataDir(), engine.id, poolKey, record.id));
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 });
1741
+ // [R4 §3.4 不变量 3] 运行中句柄回填:create 应答后(远早于 run resolve)回填
1742
+ // record.engineHandle 并经 reportRecordTransition entry——运行中的 GUI 经 entry
1743
+ // 重建 record 即拿到 ①②级读取钥匙(sessionRef/dbPath/poolKey/journalPath),
1744
+ // 不再等终态回填(详情页中途打开可见当时进度快照)。journalPath 此时已是
1745
+ // retarget 后的最终路径(onPoolResolved 在 prepare 期先行触发,writer 是路径权威)。
1746
+ // 仅 chat 域接线——workflow 域 SAR 无运行中 record 读取方,刻意不做同类回填(防误扩展)。
1747
+ const backfillEngineHandle = (partial: { sessionRef: Record<string, string>; poolKey: string }): void => {
1748
+ if (record.engineHandle !== undefined && record.engineHandle.sessionRef["sessionId"] !== undefined) {
1749
+ return; // 幂等守卫:终态回填已落(迟到重复回调不覆盖)
1750
+ }
1751
+ record.engineHandle = {
1752
+ sessionRef: partial.sessionRef,
1753
+ poolKey: partial.poolKey,
1754
+ journalPath: journal.path,
1755
+ };
1756
+ this.store.reportRecordTransition(record);
1725
1757
  };
1726
1758
  // 对齐点③:journal 路径权威 = 引擎声明的池 key(writer 初始用占位,retarget 后
1727
- // 与 handle.poolKey 同源)。模式对齐 SAR 的 journalingOnEvent:先落盘再转发。
1759
+ // 与 handle.poolKey 同源)。
1728
1760
  const runCtx: RunContext = {
1729
1761
  taskId: record.id,
1730
- poolKey: "shared",
1762
+ poolKey: JOURNAL_INITIAL_POOL_KEY,
1731
1763
  signal,
1732
1764
  ctxModel: opts.ctxModel,
1733
- onEvent: (event) => journal.append(event),
1734
- onPoolResolved: retargetJournal,
1765
+ onEvent: journal.onEvent,
1766
+ onPoolResolved: journal.onPoolResolved,
1767
+ // [R4 §3.4 不变量 3] 运行中句柄回填通道(engine/port.ts RunContext.onHandleReady)
1768
+ onHandleReady: backfillEngineHandle,
1735
1769
  // D9①:路由层 fallback 留痕投影进 outcome(zcode 无独立 record 通路)
1736
1770
  ...(record.engineFallback !== undefined ? { engineFallback: record.engineFallback } : {}),
1737
1771
  // D10 终止链:engine spawn 的子进程注册进 spawnedChildren 记账
@@ -1740,7 +1774,8 @@ export class SubagentService {
1740
1774
  };
1741
1775
  try {
1742
1776
  const { handle, outcome } = await engine.run(executeOptionsToEngineTaskSpec(opts), runCtx);
1743
- // engineHandle 完整回填(U2:终态迁移落 entry 前)。sessionRef 整体透传——
1777
+ // engineHandle 终态回填(U2:终态迁移落 entry 前;R4 起为兜底面——运行中回填
1778
+ // 已由 onHandleReady 提前落 entry,此处覆写终态权威值)。sessionRef 整体透传——
1744
1779
  // 失败终态 sessionId 缺失时也回填已有部分(dbPath/poolKey),读侧①级降②级
1745
1780
  // 的防御形态;journalPath 取 retarget 后的实际落盘路径(writer 是路径权威)。
1746
1781
  record.engineHandle = {
@@ -1804,17 +1839,14 @@ export class SubagentService {
1804
1839
  /** resume 选项(M2-B1):透传 runSpawn,重开已 idle 的 session 续聊。undefined = 新 session。 */
1805
1840
  resume?: SpawnResumeOpts,
1806
1841
  ): Promise<AgentResult> {
1842
+ // [U04 阶段化提取] 主函数只留编排,按「装配(池槽/worktree/深度)→ 执行(ALS 包装
1843
+ // spawn)→ 回收(finally)→ 错误收口 → 终态收口」分段;行为逐字节不变。
1807
1844
  const pooled = record.mode === "background";
1808
1845
  let acquired = false;
1809
1846
  if (pooled) {
1810
- try {
1811
- await this.pool.acquire(priority, this.effectiveMaxConcurrentFor(record), signal);
1812
- acquired = true;
1813
- } catch {
1814
- // S1: 排队中被 abort(signal.aborted)走 cancelled,与已运行被 abort 一致。
1815
- if (signal?.aborted) return this.finalizeAborted(record);
1816
- return this.finalizeFailed(record, new Error("aborted"));
1817
- }
1847
+ const acquireFailure = await this.acquirePoolOrFinalize(record, signal, priority);
1848
+ if (acquireFailure !== undefined) return acquireFailure;
1849
+ acquired = true;
1818
1850
  }
1819
1851
  // onEvent 直通(原此处曾有 onUpdate(project(record)) 节流回流包装——生产死路径,
1820
1852
  // 三调用点恒 onUpdate: undefined、仅测试触达,已按 swf-perf-impl ledger #22 删除
@@ -1822,78 +1854,24 @@ export class SubagentService {
1822
1854
  // ExecuteOptions.onUpdate 字段一并删除,未来误用将编译期失败而非静默无效。
1823
1855
  const onEvent = rawOnEvent;
1824
1856
 
1825
- // 解析 worktree 参数:boolean → WorktreeHandle | undefined(true/undefined 由 run 内部处理)
1826
- let worktreeHandle: WorktreeHandle | undefined;
1827
- if (typeof opts.worktree === "object") {
1828
- worktreeHandle = opts.worktree;
1829
- }
1830
- // [MF#4][MF#2] fork 深度护栏:ALS 传递深度(主 session 链无 store→0,fork 推进 +1)。
1831
- const parentDepth = this.forkDepthAls.getStore() ?? this.forkDepthBaseline;
1832
- const effectiveDepth = opts.fork ? parentDepth + 1 : parentDepth;
1857
+ const worktreeHandle = this.resolveWorktreeHandle(opts);
1858
+ const { parentDepth, effectiveDepth } = this.resolveForkDepths(opts);
1833
1859
 
1834
1860
  let result: AgentResult;
1835
1861
  try {
1836
- // execCtxAls 包在 forkDepthAls 内层:B run() 期间它的 store={recordId:B.id,depth:B.depth},
1837
- // B 内创建 C createRecordForMode 读到 B → C 挂到 B 名下。两层 ALS 独立但同生命周期。
1838
- result = await this.forkDepthAls.run(effectiveDepth, () =>
1839
- this.execCtxAls.run(
1840
- { recordId: record.id, depth: record.depth },
1841
- () => runSpawn(record, opts.task, {
1842
- resolved: identity.resolved,
1843
- agentConfig: identity.agentConfig,
1844
- appendSystemPrompt: opts.appendSystemPrompt,
1845
- skillPath: opts.skillPath,
1846
- schema: opts.schema,
1847
- schemaEnv: opts.schemaEnv, // D-A6 bridge: workflow 编排层透传 schema 到 childEnv
1848
- maxTurns: opts.maxTurns,
1849
- graceTurns: opts.graceTurns,
1850
- signal,
1851
- onEvent,
1852
- stream, // text_delta streaming(background 路径有值,workflow 路径 undefined)
1853
- fork: opts.fork,
1854
- // [v8.5 B] fork-from 显式源(ExecuteOptions.forkFromSessionFile)优先于
1855
- // opts.fork 推导的 mainSessionFile;undefined = 旧语义不变。
1856
- forkSource: opts.forkFromSessionFile,
1857
- worktree: worktreeHandle,
1858
- parentForkDepth: parentDepth, // [MF#4] 父链深度,不从 opts 读
1859
- }, ctx, resume),
1860
- ),
1862
+ result = await this.runSpawnNested(
1863
+ record, opts, ctx, identity, signal, onEvent, stream, resume,
1864
+ worktreeHandle, parentDepth, effectiveDepth,
1861
1865
  );
1862
1866
  } catch (err) {
1863
1867
  // run() 正常路径不抛错,但创建期异常(createAndConfigureSession 失败)
1864
1868
  // 会逃逸出 run() —— 合成 failed result + 收尾。
1865
1869
  // swallow(不 re-throw):sync 调用方拿到合成 failed result,background 的
1866
1870
  // .then 正常跑 notify。避免异常逃逸到 tool 层 + record 卡 running。
1867
- //
1868
- // MF-6(决策 6 spec §3.1):chatMode(含 resume)spawn/创建失败不销毁对话——回退 idle
1869
- //(可恢复),让 agent 可重试 message 或 close。与一次性模式(finalizeFailed 终态销毁)区分。
1870
- if (record.chatMode) {
1871
- const errMsg = err instanceof Error ? err.message : String(err);
1872
- const failedResult: AgentResult = {
1873
- text: "",
1874
- turns: record.turnCount,
1875
- durationMs: Date.now() - record.startedAt,
1876
- success: false,
1877
- error: errMsg,
1878
- sessionId: record.id,
1879
- toolCalls: [],
1880
- };
1881
- if (tryTransition(record, "closed", "gc")) {
1882
- // 回退 idle(record.result 由 finalizeRoundToIdle 设为 error 兜底文本,notify 可读)。
1883
- await this.finalizeRoundToIdle(record, failedResult);
1884
- }
1885
- return failedResult;
1886
- }
1887
- result = await this.finalizeFailed(record, err);
1888
- return result;
1871
+ if (record.chatMode) return this.finalizeChatSpawnFailure(record, err);
1872
+ return this.finalizeFailed(record, err);
1889
1873
  } finally {
1890
- if (pooled && acquired) this.pool.release();
1891
- // 清除 streaming widget(subagent 终态,幂等)
1892
- stream?.dispose();
1893
- // [review MF1] 清除在途 resume 守卫(幂等):本轮收尾——无论轮次完成(early return)、
1894
- // MF-6 失败回退 resumable、abort 还是终态化,record 都可再次接受冷路径 message。
1895
- // execute() 新建 record 不在集合,delete 是 no-op。
1896
- this.resumesInFlight.delete(record.id);
1874
+ this.releaseRoundResources(record, pooled && acquired, stream);
1897
1875
  }
1898
1876
 
1899
1877
  // [V2 决策 2/3] chatMode 首轮闭环:runSpawn 因 agent_settled 提前 resolve(onRoundSettled
@@ -1901,109 +1879,349 @@ export class SubagentService {
1901
1879
  // 不进下方 chatMode 分流(那是 close 后 done/failed/cancelled 终态化的,走 finalizeRoundToIdle
1902
1880
  // / finalizeRecord)。tryTransition(idle→done) 天然失败(要求 status==="running"),此处显式
1903
1881
  // early return 让语义清晰 + 防状态机未来改动。防 double-notify 由 notifier dedup 兜底
1904
- //(同 id:round 60s 内吞,kickOffBackground.then 的 notify 是 no-op,见 notifier.ts L122)。
1882
+ //(同 id:round 60s 内吞,chat 轮次收尾 .then 的 notify 是 no-op,见 notifier.ts L122)。
1905
1883
  if (record.chatMode && isIdle(record)) {
1906
1884
  return result;
1907
1885
  }
1908
1886
 
1909
1887
  // v4 B-1: status 恒为 closed。cancelled 折入 closed(closedReason='cancelled')。
1910
1888
  const aborted = signal?.aborted === true;
1911
- // closedReason 派生:aborted cancelled;否则 success → user-close,!success → gc。
1912
- const closedReason: ClosedReason = aborted ? "cancelled" : result.success ? "user-close" : "gc";
1913
-
1914
- // CAS 抢锁:抢到则完整收尾;没抢到(cancel 已先设 closed+cancelled)则跳过
1915
- if (tryTransition(record, "closed", closedReason)) {
1916
- if (record.chatMode && !aborted && result.success) {
1917
- if (record.closeAfterRound) {
1918
- // close 优雅关闭(force:false):当前轮完成后终态化为 closed。
1919
- record.closeAfterRound = undefined;
1920
- await this.finalizeRecord(record, result, "closed", "user-close");
1921
- } else {
1922
- // 对话模式轮次成功完成 保持 running(旧 idle 折入 running,finalizeRoundToIdle 设回 running)。
1923
- await this.finalizeRoundToIdle(record, result);
1924
- }
1925
- } else if (record.chatMode && (!result.success || aborted)) {
1926
- // MF-6:chatMode 轮次失败/取消不销毁对话——回退 running-resumable(旧 idle,可恢复)。
1927
- if (record.closeAfterRound) {
1928
- // [M5] 优雅关闭挂起的失败/取消轮:轮已完成即兑现 close 意图终态化(含本轮 result),
1929
- // 不再回退 resumable——否则标志残留到下一轮,record 已被 tool 谎报 closed。
1930
- record.closeAfterRound = undefined;
1931
- await this.finalizeRecord(record, result, "closed", closedReason);
1932
- } else {
1933
- await this.finalizeRoundToIdle(record, result);
1934
- }
1935
- } else if (!record.chatMode && !aborted && result.success) {
1936
- if (record.closeAfterRound) {
1937
- // [M5] 非 chatMode(one-shot)busy 时 close(force:false) 置的标志在本轮完成时消费
1938
- // 终态化(对齐 close schema 文案 "release its resources")。旧代码走
1939
- // finalizeRoundToIdle 不消费——tool 返回 {closed:true} 谎报,record 永久
1940
- // running-resumable、5min idle timer 杀进程、期间还能继续收 message。
1941
- record.closeAfterRound = undefined;
1942
- await this.finalizeRecord(record, result, "closed", "user-close");
1943
- } else {
1944
- // [SP-5] one-shot 成功完成 → 保持 running(旧 idle),等待 message 触发 upgrade。
1945
- await this.finalizeRoundToIdle(record, result);
1946
- }
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);
1947
2046
  } else {
1948
- // chatMode 失败/取消 或其他终态:一次性销毁(archive + worktree cleanup)。
1949
- await this.finalizeRecord(record, result, "closed", closedReason);
2047
+ await this.finalizeRoundToIdle(record, result);
1950
2048
  }
1951
2049
  }
1952
- return result;
1953
2050
  }
1954
2051
 
1955
- /** background 的步骤 4-6:包进 detached promise(不 await),execute 立即返回。 */
1956
- 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(
1957
2100
  record: ExecutionRecord,
1958
2101
  opts: ExecuteOptions,
1959
- ctx: SessionRunnerContext,
1960
2102
  identity: ResolvedIdentity,
2103
+ ctx: SessionRunnerContext,
1961
2104
  signal: AbortSignal | undefined,
1962
2105
  priority: number,
1963
- /** resume 选项(M2-B1):透传 runAndFinalize→runSpawn。undefined = 新 session。 */
2106
+ /** resume 选项(M2-B1):透传轮次执行→runSpawn。undefined = 新 session。 */
1964
2107
  resume?: SpawnResumeOpts,
1965
2108
  ): void {
1966
2109
  // 创建 streaming 生命周期对象。策略(含 widget 退役步骤 2:GUI + relay 激活时停发
1967
2110
  // 私货、TUI/未激活原样创建、sink 未注入降级 undefined)集中在 createBackgroundStream。
1968
2111
  const stream = createBackgroundStream(record.id, this.streamSink, ctx.mode, process.env);
1969
2112
 
1970
- void this.runAndFinalize(
1971
- record, opts, ctx, identity, signal, priority,
1972
- undefined, stream, resume,
1973
- )
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
+ )
1974
2122
  .then(() => {
1975
- // background 回注:仅当本路径抢到 CAS(closedReason cancelled)才 notify。
1976
- // cancel 抢先时 closedReason='cancelled'cancelBackground 自己 notify,此处跳过。
1977
- if (record.closedReason !== "cancelled") {
1978
- this.notifyComplete(record);
2123
+ // background 回注:仅当本路径抢到 CAS notify。cancel 抢先时 closedReason=
2124
+ // 'cancelled'cancelBackground 自己 notify);[T4①/PS-2] parent-new/parent-fork
2125
+ // 是 disposeAllRecords 的编排性关闭(record 已关、告知由 list 的 closedReason
2126
+ // 表达)——迟到的完成回注不注入(可能已切换的)新 session。
2127
+ if (notifyGateAllowsDelivery(record.closedReason)) {
2128
+ this.notifyHost.notifyComplete(record);
1979
2129
  }
1980
2130
  })
1981
2131
  .catch((err: unknown) => {
1982
- // detached 吞错:runAndFinalize 内部已 finalize record(含 emitPendingUnregister),
2132
+ // detached 吞错:轮次执行内部已 finalize record(含 emitPendingUnregister),
1983
2133
  // 且 finalizeRecord 的 manifest 写入已降级为 best-effort(失败仅 logger.error + appendEntry,
1984
2134
  // 不外抛)。因此此处不应走到——但作为最后一道兼底,记录调试日志后吞下,不外抛。
1985
2135
  // 完成通知由 finalizeRecord 内的 emitPendingUnregister 承担(pending-notifications 消费)。
1986
2136
  // cancel 抢先时 status=cancelled,cancelBackground 自己 emit,此处无需重复。
2137
+ // 交接包未被消费(engine.run 前置 throw 的极端形态)时的泄漏兜底。
2138
+ this.chatRoundTickets.delete(record.id);
1987
2139
  if (err instanceof Error) {
1988
2140
  logger.debug(`[subagent] background finalize error (record=${record.id}): ${err.message}`);
1989
2141
  }
1990
2142
  });
1991
2143
  }
1992
2144
 
1993
- /** 取消 background record。CAS 抢锁——抢到则 notify + 写 tombstone。 */
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
+
2196
+ /**
2197
+ * 取消 background record。CAS 抢锁(tryTransition)——抢到则 notify + 写 tombstone;
2198
+ * 没抢到(detached 已 finalize,record 已终态)返回 false,不触碰任何收尾副作用。
2199
+ *
2200
+ * stop 手段(abort/kill/disarm)无条件先执行:对已终态 record 幂等无害,且保证
2201
+ * cancel 语义 = 进程必死;收尾副作用(completeRecord/tombstone/archive/notify)只归
2202
+ * CAS 赢家。[A2-1] 此前 CAS 被误删,cancel 可在 doFinalizeRecord Step 0 await 窗口
2203
+ * 命中已终态 record——覆写终态 + tombstone/finalized 双标 + notify 双发 + 谎报 true。
2204
+ */
1994
2205
  private cancelBackground(record: ExecutionRecord): boolean {
1995
2206
  record.controller?.abort();
1996
2207
  // [M6] 显式 kill + disarm:chatMode 首轮 agent_settled 后 runSpawn 提前 resolveRun(0)
1997
2208
  // 返回,`opts.signal.removeEventListener("abort", onAbort)`(session-runner runSpawn 尾部)
1998
- // 已移除 abort→kill listener;热路径续聊轮(deliverMessage 直接 sendPromptCommand)不再
2209
+ // 已移除 abort→kill listener;热路径续聊轮(PiEngine 直接 stdin 写入)不再
1999
2210
  // 进 runSpawn。此后 cancel 只有 controller.abort() 无人响应——record 已终态化 cancelled
2000
2211
  // 但子进程继续跑完当前 turn(工具副作用继续发生),之后 agent_settled 还对已 archived
2001
2212
  // record 触发脏通知(round+1 → 新 dedup key → "finished a round"),最终靠 5min idle
2002
- // timer 兜底 kill。故 cancel 必须显式 SIGTERM + disarm idle timer。非 chatMode 路径
2213
+ // timer 兜底 kill。故 cancel 必须显式 kill + disarm idle timer。非 chatMode 路径
2003
2214
  // listener 仍在(abort 已 kill 一次),此处对已 killed child 是 no-op,无副作用。
2004
- const child = getChildByRecord(record.id);
2005
- if (child && !child.killed) child.kill("SIGTERM");
2215
+ // [T2④ / LC-2] 裸 SIGTERM 收敛到 killRecordChildWithEscalation:SIGTERM 被无视时
2216
+ // 30s 升级 SIGKILL——cancel 语义 = 进程必死,不能赌 SIGTERM 生效(record 已终态化,
2217
+ // 此后无其他回收通道)。settled watchdog 同步撤下(等待窗口随取消终结,幂等)。
2218
+ killRecordChildWithEscalation(record.id, "cancelBackground");
2006
2219
  disarmIdleTimer(record.id);
2220
+ disarmSettledWatchdog(record.id);
2221
+ // [A2-1] CAS 抢锁防重复收尾:running 才放行。doFinalizeRecord Step 0 await 窗口内
2222
+ // record 可能已被终态化(closed)但尚未 archive——没抢到说明 detached 已 finalize,
2223
+ // cancel 来晚了,闭嘴返回 false(completeRecord 会覆写终态 + tombstone/finalized
2224
+ // 双标 + notify 双发,绝不可执行)。
2007
2225
  if (!tryTransition(record, "closed", "cancelled")) {
2008
2226
  return false; // detached 已 finalize,cancel 来晚了
2009
2227
  }
@@ -2038,9 +2256,9 @@ export class SubagentService {
2038
2256
  }
2039
2257
  }
2040
2258
  // pending-notifications:cancel 注销(只记 registry 状态)
2041
- emitPendingUnregister(this.pi, record.id, "closed");
2042
- // cancel 完成通知(与 kickOffBackground.then 对称——cancel 抢先时 .then 跳过 notify)
2043
- this.notifyComplete(record);
2259
+ this.notifyHost.emitPendingUnregister(record.id, "closed");
2260
+ // cancel 完成通知(与轮次收尾 .then 对称——cancel 抢先时 .then 跳过 notify)
2261
+ this.notifyHost.notifyComplete(record);
2044
2262
  return true;
2045
2263
  }
2046
2264
 
@@ -2060,7 +2278,8 @@ export class SubagentService {
2060
2278
  store: this.store,
2061
2279
  modelService: this.modelService,
2062
2280
  pi: this.pi,
2063
- emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
2281
+ emitUnregister: (id, st) => this.notifyHost.emitPendingUnregister(id, st),
2282
+ sessionDir: this.sessionsDir,
2064
2283
  },
2065
2284
  record,
2066
2285
  result,
@@ -2084,7 +2303,7 @@ export class SubagentService {
2084
2303
  store: this.store,
2085
2304
  modelService: this.modelService,
2086
2305
  pi: this.pi,
2087
- emitUnregister: (id, st) => emitPendingUnregister(this.pi, id, st),
2306
+ emitUnregister: (id, st) => this.notifyHost.emitPendingUnregister(id, st),
2088
2307
  },
2089
2308
  record,
2090
2309
  result,
@@ -2095,7 +2314,7 @@ export class SubagentService {
2095
2314
  * AgentResult → CAS 抢锁 → finalizeRecord(与正常路径同形)。返回合成 result 供 runAndFinalize
2096
2315
  * 继续返回(不 re-throw,swallow 策略)。 */
2097
2316
  private async finalizeFailed(record: ExecutionRecord, err: unknown): Promise<AgentResult> {
2098
- const errMsg = err instanceof Error ? err.message : String(err);
2317
+ const errMsg = toErrorMessage(err);
2099
2318
  // durationMs 用真实耗时(startedAt → now),避免失败统计恒为 0 失真。
2100
2319
  const failedResult: AgentResult = { text: "", turns: record.turnCount, durationMs: Date.now() - record.startedAt, success: false, error: errMsg, sessionId: record.id, toolCalls: [] };
2101
2320
  // CAS 抢锁:抢到(status 仍 running)则完整收尾;没抢到(cancel 已先设 cancelled)跳过。
@@ -2141,6 +2360,34 @@ export class SubagentService {
2141
2360
  }
2142
2361
  }
2143
2362
 
2363
+ /**
2364
+ * [T4② / PS-4] idleTimeoutMs 合法域入口校验(>2^31-1 / 非有限值 fail-fast)。
2365
+ *
2366
+ * 旧链路:非法值穿透到 agent_settled 回调里的 armIdleTimer → assertSafeTimerDelay
2367
+ * throw 被异步 catch 降级——配置错误被吞成静默语义变更(每轮完成通知被 isIdle 放行门
2368
+ * 吞 + 进程无回收 timer)。对齐 shared/timer-delay「不静默 clamp」既有裁决:配置错误
2369
+ * 显式暴露,错误消息含合法范围(0/负数 = 显式禁用是合法语义,不在本校验域;env 非法值
2370
+ * 由 lifecycle-manager 的 warn 回落承接)。execute/executeAndAwait 两入口共用。
2371
+ */
2372
+ private assertIdleTimeoutMsSafe(opts: ExecuteOptions): void {
2373
+ const v = opts.idleTimeoutMs;
2374
+ if (v === undefined) return;
2375
+ if (!Number.isFinite(v)) {
2376
+ throw new Error(
2377
+ `subagent idleTimeoutMs = ${v} is not a finite number (NaN/±Infinity). ` +
2378
+ `Valid range: a positive millisecond number up to ${MAX_TIMER_DELAY_MS} (2^31-1, Node setTimeout limit), or 0/negative to disable idle GC. ` +
2379
+ "Recovery: fix the idleTimeoutMs value and retry.",
2380
+ );
2381
+ }
2382
+ if (v > MAX_TIMER_DELAY_MS) {
2383
+ throw new Error(
2384
+ `subagent idleTimeoutMs = ${v} exceeds the Node setTimeout limit ` +
2385
+ `(${MAX_TIMER_DELAY_MS} ms = 2^31-1); larger delays silently collapse to 1ms and would kill the subagent immediately. ` +
2386
+ `Recovery: pass idleTimeoutMs <= ${MAX_TIMER_DELAY_MS}, or omit it for the default (${DEFAULT_IDLE_TIMEOUT_MS}ms), or use 0/negative to disable idle GC.`,
2387
+ );
2388
+ }
2389
+ }
2390
+
2144
2391
  /** 构造 SessionRunnerContext(spawn 模式:无需 SDK 实例)。 */
2145
2392
  private buildSessionRunnerContext(overrideCwd?: string): SessionRunnerContext {
2146
2393
  return {
@@ -2170,74 +2417,11 @@ export class SubagentService {
2170
2417
  // 落盘统一用 rootCwd 编码,ROOT 磁盘重建才扫得到深层 record(与 sessionRootId 同构)。
2171
2418
  rootCwd: this.rootCwd,
2172
2419
  // [V2 决策 2] chatMode 首轮闭环:agent_settled 时 session-runner 调本回调。
2173
- // 轻量 idle 化(选项 1):设 record.status=idle + round+=1 让 notify 守卫放行 + notify
2174
- // agent,但**不调 doFinalizeRoundToIdle**(不 emitUnregister /
2175
- // redeliver——V2 要删的副作用都不做)。runAndFinalize 检测到 status=idle early return,
2176
- // 不进现有 chatMode 分流。Step 5 删 idle 状态机时统一清理这个过渡 idle。
2177
- // 防箭头函数 this 丢失:用箭头函数捕获 SubagentService 实例 this。
2178
- onRoundSettled: (record) => {
2179
- // v4 B-1:status 保持 running(旧 idle 折入 running);session-runner 已 arm idle timer,
2180
- // isIdle=true 让 notify 守卫放行(时序:armIdleTimer → onRoundSettled,见 session-runner.ts:670)。
2181
- // round 可能初始 undefined(与 notifier.ts `record.round ?? 0` 兜底一致),
2182
- // 首轮 0+1=1。round 是 notifier dedup key 的组成部分,递增后同 id 下一轮不被 60s dedup 吞。
2183
- record.round = (record.round ?? 0) + 1;
2184
- // [N2][增量] 轮次回复写点(增量语义):roundText 自 roundBaseTurnIndex 起派生本轮增量
2185
- //(undefined 视为 0——首轮增量 = 全量,与改造前首轮逐字节一致)。成功轮次的 MF-2 原写点
2186
- //(doFinalizeRoundToIdle)不可达——agent_settled 恒 arm idle timer → runAndFinalize 恒
2187
- // early return。写入 record.result 后再 notify;本轮无非空增量且无 lastError(纯工具轮 /
2188
- // interrupt 抢占轮 / 模型空回复)时固定占位 "(no output this round)"(D5:增量语义下沿用
2189
- // 旧 record.result = 上一轮增量 → 本轮通知正文 = 上一轮内容,父 agent 误读为原样重复回复;
2190
- // lastError 兜底保留让失败轮通知可读)。后续 closeAfterRoundSettled 的合成 result 读
2191
- // record.result,同样携带本轮增量。
2192
- const roundText = getFullTextFrom(record, record.roundBaseTurnIndex ?? 0);
2193
- record.result = roundText ||
2194
- (record.lastError ? `round did not complete: ${record.lastError}` : "(no output this round)");
2195
- // 先送达本轮增量(notify),再推进 base / 消费 closeAfterRound——终态通知由
2196
- // closeAfterRoundSettled / closeChatIdle 的 notifyClosed 显式发出(dedup 身份为裸 id,
2197
- // 与本次 round notify 的 id:round key 区分),保证「本轮增量 + 终态通知」都送达;
2198
- // kickOffBackground.then 的冷路径 notifyComplete 仍与本次 round notify 同 key 被 60s
2199
- // dedup 吞(不构成第三条)。
2200
- //
2201
- // 幂等性(覆盖面如实限定):同步路径 at-least-once——notifyComplete(同步 void)抛错时
2202
- // 推进/消费被跳过 → base 不推进 → 增量未消费,下轮 roundText 必含本轮文本(重发载体为
2203
- // 后续轮次增量拼接)。kickOffBackground.then 的冷路径 notifyComplete 不构成重发通道
2204
- // (notifier dedup.set 与 pending.splice 均先于 sendMessage,同 key `${id}:${round}`——
2205
- // round 已递增——60s 窗内重入被吞)。异步 flush 窗口不保证:合并 timer armed(其他 busy
2206
- // background 在场)或 isIdle 退避期间 notify 的『成功』只是入队,实际 sendMessage 发生在
2207
- // base 推进之后的异步时机;该窗口进程崩溃或 sendMessage 失败 → 丢失不可重发(现状全量
2208
- // 重发的次轮自愈在增量语义下消失),wave1 期恢复通道仅父 agent 经 /subagents 详情读取
2209
- // record.result,wave2 指针行落地后补全。反序(先推进后 notify)在同步路径 notify 失败时
2210
- // 静默丢增量且无任何重算机会,故 notify 后推进是定案。重复发送由 notifier dedup key
2211
- // 60s 窗界定。
2212
- this.notifyComplete(record);
2213
- // R1 观测哨(不变式违反形态):推进前检查末 turn 未闭合且 text 非空——pi 现序下不可达
2214
- //(带 usage 的 message_end 恒先于 turn_end,settle 时 turn 全闭合,见 types.ts
2215
- // roundBaseTurnIndex 注释的行号锚定),ES1 单测自造事件序列锁不住 pi 层变化;pi 升级若
2216
- // 改变 turn_end/agent_end 时序,此哨兵留痕(该形态下公式仍把文本计入本轮,不丢数据)。
2217
- const lastTurn = record.turns[record.turns.length - 1];
2218
- if (lastTurn !== undefined && !lastTurn.closed && lastTurn.text.length > 0) {
2219
- logger.warn(
2220
- `[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`,
2221
- );
2222
- }
2223
- // [增量] base 推进(notify 之后):下一轮增量从本轮边界起。滞后空 turn 不计入边界
2224
- //(防御分支,留在下一轮增量内防丢文本——nextRoundBaseTurnIndex 注释)。
2225
- record.roundBaseTurnIndex = nextRoundBaseTurnIndex(record);
2226
- // 轮终迁移持久化(residual-fixes U3 补全):热路径轮终不经 doFinalizeRoundToIdle
2227
- //(agent_settled 恒 arm idle timer → runAndFinalize 恒 early return,MF-2 原写点
2228
- // 不可达)——不 appendEntry 则 runtime/W18 派生缓存不失效,renderer 停留在
2229
- // register 快照(无 result),chat 等续聊的 waiting 形态显示不出来、spinner
2230
- // 卡死。显式上报:entry 携带本轮 result/round/chatMode(§5.4:result 有值 +
2231
- // chatMode=true → waiting)。closeAfterRound 的终态 entry 在此后追加,序不变。
2232
- this.store.reportRecordTransition(record);
2233
- // [M5] closeAfterRound 消费点:chatMode 每轮完成的统一汇聚点(热路径轮不经
2234
- // runAndFinalize CAS 分支——agent_settled 恒 arm idle timer → runAndFinalize 恒
2235
- // early return,旧消费点对 chatMode 不可达,标志置了无人消费、tool 谎报 closed:true)。
2236
- if (record.closeAfterRound) {
2237
- record.closeAfterRound = undefined;
2238
- void this.closeAfterRoundSettled(record);
2239
- }
2240
- },
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,
2241
2425
  };
2242
2426
  }
2243
2427
  }
@@ -2266,3 +2450,18 @@ export function getSubagentService(): SubagentService | null {
2266
2450
  export function setSubagentService(service: SubagentService): void {
2267
2451
  getServiceSlot().current = service;
2268
2452
  }
2453
+
2454
+ /**
2455
+ * [U10① D6] 第三宿主最小构造入口:仅凭参数注入构造 SubagentService(无全局查找)。
2456
+ *
2457
+ * 构造依赖(modelService / getMainSessionFile / uiRequestHandler)全部经 init
2458
+ * 参数注入;本工厂是 `new SubagentService(init)` 的薄包装,不读也不写
2459
+ * getSubagentService/setSubagentService 的全局槽位——session_start 单例流程
2460
+ * 行为零改动,宿主自持实例时用本工厂。构造内部行为与直接 new 逐字等价。
2461
+ *
2462
+ * @experimental execution 运行时面(设计 docs/design/subagent-core-sink-design.md §3.3 D6):
2463
+ * 一个 minor 周期内允许签名微调,稳定后转常规 semver 承诺。
2464
+ */
2465
+ export function createSubagentService(init: SubagentServiceInit): SubagentService {
2466
+ return new SubagentService(init);
2467
+ }