@zhushanwen/subagent-core 0.4.0 → 0.6.0

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