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