@zhushanwen/subagent-core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/dist/{chunk-4KN4TTG7.js → chunk-43ONBFZX.js} +1 -1
  2. package/dist/{chunk-X7SZ5HLQ.js → chunk-V3VHZ2VX.js} +20 -27
  3. package/dist/execution/engine/engines/zcode/constants.cjs +29 -41
  4. package/dist/execution/engine/engines/zcode/constants.d.cts +65 -53
  5. package/dist/execution/engine/engines/zcode/constants.d.ts +65 -53
  6. package/dist/execution/engine/engines/zcode/constants.js +17 -27
  7. package/dist/execution/engine/engines/zcode/reader.d.cts +2 -1
  8. package/dist/execution/engine/engines/zcode/reader.d.ts +2 -1
  9. package/dist/execution/engine/engines/zcode/reader.js +2 -2
  10. package/dist/index.cjs +17100 -14082
  11. package/dist/index.d.cts +5336 -4033
  12. package/dist/index.d.ts +5336 -4033
  13. package/dist/index.js +17020 -14055
  14. package/dist/{types-DpUO16pj.d.cts → types-C3fE3R9x.d.cts} +280 -112
  15. package/dist/{types-DpUO16pj.d.ts → types-C3fE3R9x.d.ts} +280 -112
  16. package/dist.bundle/index.cjs +34933 -0
  17. package/package.json +3 -3
  18. package/src/__tests__/agent-opts-resolver-schema-prompt.test.ts +0 -1
  19. package/src/__tests__/append-system-prompt-assembly.test.ts +8 -7
  20. package/src/__tests__/fr4-get-state-handshake.test.ts +2 -2
  21. package/src/__tests__/m2-append-content-probe.test.ts +0 -1
  22. package/src/__tests__/manifest-store.test.ts +10 -9
  23. package/src/__tests__/record-store-cache.test.ts +0 -2
  24. package/src/__tests__/record-store-index.test.ts +1 -2
  25. package/src/__tests__/review-fix-loop-script.test.ts +4 -2
  26. package/src/__tests__/review-fix-loop-utils.test.ts +10 -10
  27. package/src/__tests__/robustness-low-batch1.test.ts +6 -5
  28. package/src/__tests__/robustness-medium-batch1.test.ts +2 -2
  29. package/src/__tests__/robustness-medium-batch2.test.ts +17 -6
  30. package/src/__tests__/robustness-medium-batch4.test.ts +2 -2
  31. package/src/__tests__/session-runner.test.ts +1 -1
  32. package/src/core/__tests__/host-services.test.ts +2 -2
  33. package/src/core/__tests__/logger.test.ts +1 -1
  34. package/src/core/error-message.ts +9 -0
  35. package/src/core/host-services.ts +21 -5
  36. package/src/core/notify-ports.ts +18 -5
  37. package/src/execution/__tests__/agent-profile.test.ts +4 -4
  38. package/src/execution/__tests__/agent-registry.test.ts +370 -0
  39. package/src/execution/__tests__/agent-result-mapper.test.ts +18 -2
  40. package/src/execution/__tests__/agents-assembly.test.ts +2 -2
  41. package/src/execution/__tests__/alive-store.test.ts +1 -1
  42. package/src/execution/__tests__/ask-user-transit-e2e.test.ts +6 -6
  43. package/src/execution/__tests__/chat-engine-routing.test.ts +68 -33
  44. package/src/execution/__tests__/chatmode-first-round-closure-spawn.test.ts +4 -4
  45. package/src/execution/__tests__/cold-resurrect.test.ts +283 -0
  46. package/src/execution/__tests__/collect-budget.test.ts +291 -0
  47. package/src/execution/__tests__/collect-coordinator-service.test.ts +327 -0
  48. package/src/execution/__tests__/collect-coordinator.test.ts +371 -0
  49. package/src/execution/__tests__/collect-mixed-dispatch.test.ts +262 -0
  50. package/src/execution/__tests__/config-collect-sync.test.ts +120 -0
  51. package/src/execution/__tests__/config.test.ts +1 -1
  52. package/src/execution/__tests__/delivery-methods.test.ts +75 -62
  53. package/src/execution/__tests__/descendant-sweep-guards.test.ts +220 -0
  54. package/src/execution/__tests__/descendant-sweep.test.ts +6 -6
  55. package/src/execution/__tests__/dialog-queue.test.ts +2 -2
  56. package/src/execution/__tests__/engine-model-validation.test.ts +741 -0
  57. package/src/execution/__tests__/epipe-fallback.test.ts +29 -24
  58. package/src/execution/__tests__/execute-and-await-worktree.test.ts +21 -91
  59. package/src/execution/__tests__/execute-nesting.test.ts +34 -188
  60. package/src/execution/__tests__/execution-record.test.ts +217 -60
  61. package/src/execution/__tests__/execution-runtime-face.test.ts +5 -5
  62. package/src/execution/__tests__/explicit-agent-ref-guard.test.ts +4 -4
  63. package/src/execution/__tests__/finalize-record.test.ts +173 -17
  64. package/src/execution/__tests__/finalized-marker.test.ts +1 -1
  65. package/src/execution/__tests__/gc-timer.test.ts +56 -3
  66. package/src/execution/__tests__/get-record-for-action-restart.test.ts +12 -12
  67. package/src/execution/__tests__/get-state-handshake.test.ts +1 -1
  68. package/src/execution/__tests__/helpers/mock-extension-api.ts +1 -1
  69. package/src/execution/__tests__/helpers/session-runner-mocks.ts +113 -0
  70. package/src/execution/__tests__/helpers/spawn-mock.ts +150 -3
  71. package/src/execution/__tests__/helpers/subagent-service-mocks.ts +137 -0
  72. package/src/execution/__tests__/keep-alive-no-progress.test.ts +50 -148
  73. package/src/execution/__tests__/kill-all-escalation.test.ts +2 -2
  74. package/src/execution/__tests__/lifecycle-predicates.test.ts +1 -2
  75. package/src/execution/__tests__/manifest-parentid.test.ts +4 -4
  76. package/src/execution/__tests__/manifest-store-tmp-recovery.test.ts +1 -1
  77. package/src/execution/__tests__/max-turns-to-watchdog-ms.test.ts +1 -1
  78. package/src/execution/__tests__/model-resolver.test.ts +2 -2
  79. package/src/execution/__tests__/nested-visibility-env-propagation.test.ts +23 -121
  80. package/src/execution/__tests__/nested-visibility.test.ts +2 -0
  81. package/src/execution/__tests__/notify-batch.test.ts +500 -0
  82. package/src/execution/__tests__/notify-ledger.test.ts +1000 -18
  83. package/src/execution/__tests__/output-collector.test.ts +117 -6
  84. package/src/execution/__tests__/pi-invocation.test.ts +36 -4
  85. package/src/execution/__tests__/record-entry-collect.test.ts +102 -0
  86. package/src/execution/__tests__/record-store.test.ts +10 -9
  87. package/src/execution/__tests__/records-cwd-isolation.test.ts +2 -2
  88. package/src/execution/__tests__/recursive-visibility-baseline.test.ts +29 -132
  89. package/src/execution/__tests__/recursive-visibility-env.test.ts +55 -179
  90. package/src/execution/__tests__/relay-env.test.ts +12 -1
  91. package/src/execution/__tests__/rpc-mode.test.ts +2 -2
  92. package/src/execution/__tests__/run-and-finalize-anchoring.test.ts +387 -0
  93. package/src/execution/__tests__/run-and-finalize-chatmode.test.ts +15 -9
  94. package/src/execution/__tests__/run-spawn-chatmode-settled.test.ts +4 -4
  95. package/src/execution/__tests__/run-spawn-edges.test.ts +17 -15
  96. package/src/execution/__tests__/run-spawn-integration.test.ts +2 -2
  97. package/src/execution/__tests__/run-spawn-resume.test.ts +2 -2
  98. package/src/execution/__tests__/run-spawn-rpc-mode.test.ts +2 -2
  99. package/src/execution/__tests__/run-spawn-stdout-callback-throw.test.ts +4 -4
  100. package/src/execution/__tests__/service-kill-escalation.test.ts +7 -5
  101. package/src/execution/__tests__/session-context-resolver.test.ts +0 -2
  102. package/src/execution/__tests__/session-file-gc.test.ts +36 -1
  103. package/src/execution/__tests__/session-pending.test.ts +257 -14
  104. package/src/execution/__tests__/session-reconstructor.test.ts +366 -15
  105. package/src/execution/__tests__/session-runner-branch-cache-lru.test.ts +1 -1
  106. package/src/execution/__tests__/session-runner-close-prune.test.ts +2 -2
  107. package/src/execution/__tests__/session-runner-dispatch.test.ts +489 -0
  108. package/src/execution/__tests__/session-runner-epipe.test.ts +3 -3
  109. package/src/execution/__tests__/session-runner-heartbeat-idle-fallback.test.ts +5 -4
  110. package/src/execution/__tests__/session-runner-lifecycle-helpers.test.ts +290 -0
  111. package/src/execution/__tests__/session-runner-schema-env.test.ts +7 -5
  112. package/src/execution/__tests__/settled-watchdog.test.ts +282 -47
  113. package/src/execution/__tests__/spawn-args.test.ts +11 -10
  114. package/src/execution/__tests__/spawn-event-adapter-rpc.test.ts +26 -23
  115. package/src/execution/__tests__/spawn-event-adapter.test.ts +3 -3
  116. package/src/execution/__tests__/spawn-worktree-guidance.test.ts +5 -4
  117. package/src/execution/__tests__/spawned-children.test.ts +2 -1
  118. package/src/execution/__tests__/start-collect-guard.test.ts +255 -0
  119. package/src/execution/__tests__/start-sync-model-guard.test.ts +5 -5
  120. package/src/execution/__tests__/stdin-writer.test.ts +6 -4
  121. package/src/execution/__tests__/stream-sink-retirement.test.ts +6 -6
  122. package/src/execution/__tests__/subagent-actions-core.test.ts +48 -22
  123. package/src/execution/__tests__/subagent-service-message-close.test.ts +29 -26
  124. package/src/execution/__tests__/subagent-service-multiproc-guard.test.ts +21 -8
  125. package/src/execution/__tests__/subagent-service-notify-gate.test.ts +27 -22
  126. package/src/execution/__tests__/subagent-service-parent-guard.test.ts +30 -29
  127. package/src/execution/__tests__/subagent-service-recovery-bounds.test.ts +45 -20
  128. package/src/execution/__tests__/subagent-service.test.ts +37 -30
  129. package/src/execution/__tests__/subprocess-agent-runner-routing.test.ts +88 -12
  130. package/src/execution/__tests__/subprocess-agent-runner-timeout.test.ts +75 -0
  131. package/src/execution/__tests__/subprocess-agent-runner.test.ts +22 -20
  132. package/src/execution/__tests__/sync-collect-recovery.test.ts +1117 -0
  133. package/src/execution/__tests__/temp-prompt.test.ts +1 -1
  134. package/src/execution/__tests__/timeout-integration.test.ts +6 -5
  135. package/src/execution/__tests__/tombstone-store.test.ts +1 -1
  136. package/src/execution/__tests__/turn-limiter-semantics.test.ts +1 -1
  137. package/src/execution/__tests__/turn-limiter.test.ts +1 -1
  138. package/src/execution/__tests__/ui-request-handler-factory.test.ts +12 -6
  139. package/src/execution/__tests__/ui-request-handler.test.ts +4 -3
  140. package/src/execution/__tests__/ui-request-queue.test.ts +142 -2
  141. package/src/execution/__tests__/worktree-git-ops.test.ts +1 -1
  142. package/src/execution/__tests__/worktree-manager.test.ts +14 -5
  143. package/src/execution/__tests__/worktree-pid-registration.integration.test.ts +4 -3
  144. package/src/execution/__tests__/worktree-reconcile-aging.test.ts +2 -2
  145. package/src/execution/__tests__/worktree-reconcile.integration.test.ts +1 -1
  146. package/src/execution/__tests__/worktree-registry.test.ts +1 -1
  147. package/src/execution/agent-registry.ts +74 -30
  148. package/src/execution/agent-result-mapper.ts +3 -0
  149. package/src/execution/cold-resurrect.ts +199 -0
  150. package/src/execution/collect-coordinator.ts +200 -0
  151. package/src/execution/config.ts +77 -5
  152. package/src/execution/engine/__tests__/common/capability-gate.test.ts +91 -0
  153. package/src/execution/engine/__tests__/common/data-dir.test.ts +1 -1
  154. package/src/execution/engine/__tests__/common/event-journal.test.ts +1 -1
  155. package/src/execution/engine/__tests__/common/journal-wiring.test.ts +84 -0
  156. package/src/execution/engine/__tests__/common/kill-chain.test.ts +34 -7
  157. package/src/execution/engine/__tests__/common/persona-router.test.ts +4 -5
  158. package/src/execution/engine/__tests__/common/pool-manager.test.ts +213 -53
  159. package/src/execution/engine/__tests__/common/session-view-service-zcode-dbpath.test.ts +149 -0
  160. package/src/execution/engine/__tests__/common/session-view-service.test.ts +499 -0
  161. package/src/execution/engine/__tests__/conformance/contract.abort.test.ts +22 -112
  162. package/src/execution/engine/__tests__/conformance/contract.agent-events.test.ts +15 -28
  163. package/src/execution/engine/__tests__/conformance/contract.read-degradation.test.ts +1 -1
  164. package/src/execution/engine/__tests__/conformance/engine-conformance.live.test.ts +12 -12
  165. package/src/execution/engine/__tests__/conformance/golden-replay.pi.test.ts +2 -2
  166. package/src/execution/engine/__tests__/conformance/golden-replay.zcode.test.ts +21 -82
  167. package/src/execution/engine/__tests__/conformance/zcode-appserver-harness.ts +4 -7
  168. package/src/execution/engine/__tests__/engine-discovery.test.ts +15 -3
  169. package/src/execution/engine/__tests__/model-prompt.test.ts +15 -3
  170. package/src/execution/engine/__tests__/registry.test.ts +4 -2
  171. package/src/execution/engine/__tests__/routing.test.ts +115 -6
  172. package/src/execution/engine/common/capability-gate.ts +92 -0
  173. package/src/execution/engine/common/journal-wiring.ts +97 -0
  174. package/src/execution/engine/common/kill-chain.ts +42 -10
  175. package/src/execution/engine/common/nesting-guard.ts +66 -0
  176. package/src/execution/engine/common/persona-router.ts +18 -8
  177. package/src/execution/engine/common/pool-manager.ts +332 -78
  178. package/src/execution/engine/common/session-view-service.ts +506 -0
  179. package/src/execution/engine/common/session-view-types.ts +125 -0
  180. package/src/execution/engine/engines/pi/__tests__/pi-engine.test.ts +174 -28
  181. package/src/execution/engine/engines/pi/__tests__/spawn-opts-direct.test.ts +257 -0
  182. package/src/execution/{argv-mirror.ts → engine/engines/pi/argv-mirror.ts} +43 -21
  183. package/src/execution/{get-state-handshake.ts → engine/engines/pi/get-state-handshake.ts} +1 -1
  184. package/src/execution/{output-collector.ts → engine/engines/pi/output-collector.ts} +108 -19
  185. package/src/execution/engine/engines/pi/pi-engine.ts +434 -38
  186. package/src/execution/{pi-invocation.ts → engine/engines/pi/pi-invocation.ts} +2 -2
  187. package/src/execution/engine/engines/pi/reader.ts +11 -0
  188. package/src/execution/engine/engines/pi/registration.ts +4 -2
  189. package/src/execution/{session-runner.ts → engine/engines/pi/session-runner.ts} +617 -412
  190. package/src/execution/{spawn-event-adapter.ts → engine/engines/pi/spawn-event-adapter.ts} +75 -34
  191. package/src/execution/{stdin-writer.ts → engine/engines/pi/stdin-writer.ts} +10 -9
  192. package/src/execution/{temp-prompt.ts → engine/engines/pi/temp-prompt.ts} +2 -2
  193. package/src/execution/{turn-limiter.ts → engine/engines/pi/turn-limiter.ts} +1 -1
  194. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/fake-appserver.mjs +54 -27
  195. package/src/execution/engine/engines/zcode/__tests__/appserver-launcher.test.ts +321 -0
  196. package/src/execution/engine/engines/zcode/__tests__/connection.test.ts +13 -12
  197. package/src/execution/engine/engines/zcode/__tests__/parser.test.ts +7 -210
  198. package/src/execution/engine/engines/zcode/__tests__/preparer.test.ts +6 -110
  199. package/src/execution/engine/engines/zcode/__tests__/session-channel-dispose-harvest.test.ts +247 -0
  200. package/src/execution/engine/engines/zcode/__tests__/session-channel-turn-timers.test.ts +451 -0
  201. package/src/execution/engine/engines/zcode/__tests__/session-channel.test.ts +20 -8
  202. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-appserver.test.ts +77 -110
  203. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-degrade.test.ts +26 -320
  204. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-dispose.test.ts +210 -0
  205. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-retry.test.ts +353 -0
  206. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-status.test.ts +282 -0
  207. package/src/execution/engine/engines/zcode/__tests__/zcode-engine-timeout.test.ts +372 -0
  208. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.live.test.ts +48 -148
  209. package/src/execution/engine/engines/zcode/appserver-launcher.ts +167 -0
  210. package/src/execution/engine/engines/zcode/connection.ts +24 -11
  211. package/src/execution/engine/engines/zcode/constants.ts +75 -71
  212. package/src/execution/engine/engines/zcode/golden-sample.ts +8 -46
  213. package/src/execution/engine/engines/zcode/parser.ts +12 -346
  214. package/src/execution/engine/engines/zcode/preparer.ts +12 -143
  215. package/src/execution/engine/engines/zcode/session-channel.ts +351 -39
  216. package/src/execution/engine/engines/zcode/zcode-engine.ts +665 -760
  217. package/src/execution/engine/host-task-spec.ts +32 -36
  218. package/src/execution/engine/model-validation.ts +177 -0
  219. package/src/execution/engine/port.ts +24 -6
  220. package/src/execution/engine/routing.ts +75 -1
  221. package/src/execution/engine/types.ts +30 -103
  222. package/src/execution/execution-record.ts +229 -144
  223. package/src/execution/finalize-record.ts +141 -99
  224. package/src/execution/idle-gc.ts +28 -0
  225. package/src/execution/lifecycle-predicates.ts +1 -1
  226. package/src/execution/notifier.ts +234 -25
  227. package/src/execution/notify-host.ts +232 -0
  228. package/src/execution/record-entry.ts +16 -0
  229. package/src/execution/record-store.ts +280 -103
  230. package/src/execution/round-settlement.ts +93 -0
  231. package/src/execution/session-file-gc.ts +96 -62
  232. package/src/execution/session-reconstructor.ts +256 -143
  233. package/src/execution/sessions-index.ts +117 -47
  234. package/src/execution/settled-watchdog.ts +264 -52
  235. package/src/execution/subagent-actions-core.ts +84 -28
  236. package/src/execution/subagent-service.ts +1225 -964
  237. package/src/execution/subprocess-agent-runner.ts +144 -97
  238. package/src/execution/sync-rebuild.ts +87 -0
  239. package/src/execution/types.ts +72 -0
  240. package/src/execution/ui-request-handler-factory.ts +102 -74
  241. package/src/execution/ui-request-queue.ts +50 -19
  242. package/src/index.ts +442 -316
  243. package/src/orchestration/__tests__/agent-call-catch-fallback.test.ts +1 -1
  244. package/src/orchestration/__tests__/agent-call-stream.test.ts +2 -2
  245. package/src/orchestration/__tests__/args-validator.test.ts +14 -0
  246. package/src/orchestration/__tests__/config-loader.test.ts +1 -1
  247. package/src/orchestration/__tests__/error-recovery-rebuild-failure.test.ts +1 -1
  248. package/src/orchestration/__tests__/error-recovery-terminal-hardening.test.ts +11 -9
  249. package/src/orchestration/__tests__/execute-agent-call.test.ts +151 -11
  250. package/src/orchestration/__tests__/file-run-store-prune.test.ts +1 -1
  251. package/src/orchestration/__tests__/file-run-store-throttle.test.ts +1 -1
  252. package/src/orchestration/__tests__/file-run-store.test.ts +7 -6
  253. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +2 -2
  254. package/src/orchestration/__tests__/lifecycle-abort-broadcast-signal.test.ts +1 -1
  255. package/src/orchestration/__tests__/lifecycle-runid-injection.test.ts +3 -3
  256. package/src/orchestration/__tests__/lifecycle.test.ts +7 -5
  257. package/src/orchestration/__tests__/non-cloneable-return-e2e.test.ts +1 -1
  258. package/src/orchestration/__tests__/review-fix-loop-scriptpath-failfast.test.ts +1 -1
  259. package/src/orchestration/__tests__/run-snapshot.test.ts +8 -5
  260. package/src/orchestration/__tests__/script-generate.test.ts +5 -5
  261. package/src/orchestration/__tests__/script-lint.test.ts +17 -0
  262. package/src/orchestration/__tests__/skill-discovery.test.ts +2 -2
  263. package/src/orchestration/__tests__/test-mocks.ts +2 -0
  264. package/src/orchestration/__tests__/worker-exit-without-result.test.ts +7 -6
  265. package/src/orchestration/__tests__/worker-message-pump-finalize-run.test.ts +229 -0
  266. package/src/orchestration/__tests__/{error-recovery-handlers.test.ts → worker-message-pump-handlers.test.ts} +27 -19
  267. package/src/orchestration/__tests__/{error-recovery-postmessage-defense.test.ts → worker-message-pump-postmessage-defense.test.ts} +8 -8
  268. package/src/orchestration/__tests__/{error-recovery-serialize-failed-result.test.ts → worker-message-pump-serialize-failed-result.test.ts} +2 -2
  269. package/src/orchestration/__tests__/{error-recovery-workflow-call.test.ts → worker-message-pump-workflow-call.test.ts} +1 -1
  270. package/src/orchestration/__tests__/worker-pending-timeout-abort.test.ts +1 -1
  271. package/src/orchestration/__tests__/worker-script-builder.test.ts +1 -1
  272. package/src/orchestration/__tests__/workflow-files.test.ts +10 -10
  273. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +1 -1
  274. package/src/orchestration/__tests__/workflow-script-registry-impl.test.ts +1 -1
  275. package/src/orchestration/agent-opts-resolver.ts +1 -1
  276. package/src/orchestration/args-validator.ts +53 -31
  277. package/src/orchestration/execute-agent-call.ts +19 -73
  278. package/src/orchestration/launcher.ts +128 -81
  279. package/src/orchestration/lifecycle.ts +43 -45
  280. package/src/orchestration/models/ports.ts +4 -4
  281. package/src/orchestration/models/run-runtime.ts +2 -2
  282. package/src/orchestration/models/trace.ts +1 -1
  283. package/src/orchestration/models/types.ts +111 -22
  284. package/src/orchestration/models/workflow-run.ts +1 -1
  285. package/src/orchestration/script-lint.ts +91 -58
  286. package/src/orchestration/{error-recovery.ts → worker-message-pump.ts} +157 -150
  287. package/src/shared/__tests__/atomic-write.test.ts +1 -1
  288. package/src/shared/__tests__/meta-parser.test.ts +151 -0
  289. package/src/shared/__tests__/resource-discovery-host-roots.test.ts +5 -5
  290. package/src/shared/__tests__/resource-discovery.test.ts +258 -215
  291. package/src/shared/atomic-write.ts +12 -9
  292. package/src/shared/meta-parser.ts +158 -84
  293. package/src/shared/model-ref.ts +15 -2
  294. package/src/shared/resource-discovery.ts +9 -183
  295. package/src/shared/schema-jsonify.ts +1 -1
  296. package/workflows/review-fix-loop-utils.cjs +151 -89
  297. package/workflows/review-fix-loop-utils.d.cts +287 -0
  298. package/src/execution/__tests__/execute-options-mapper.test.ts +0 -200
  299. package/src/execution/engine/engines/pi/__tests__/task-spec-mapper.test.ts +0 -164
  300. package/src/execution/engine/engines/pi/task-spec-mapper.ts +0 -100
  301. package/src/execution/engine/engines/zcode/__tests__/__fixtures__/zcode-golden-spawn.json +0 -39
  302. package/src/execution/engine/engines/zcode/__tests__/launcher.test.ts +0 -150
  303. package/src/execution/engine/engines/zcode/__tests__/preparer-appserver.test.ts +0 -387
  304. package/src/execution/engine/engines/zcode/__tests__/zcode-engine.test.ts +0 -710
  305. package/src/execution/engine/engines/zcode/appserver-home.ts +0 -442
  306. package/src/execution/engine/engines/zcode/appserver-probe.ts +0 -141
  307. package/src/execution/engine/engines/zcode/launcher.ts +0 -161
  308. package/src/execution/execute-options-mapper.ts +0 -115
  309. package/src/shared/__tests__/resource-discovery-manifest-cache.test.ts +0 -288
@@ -0,0 +1,1117 @@
1
+ // src/execution/__tests__/sync-collect-recovery.test.ts
2
+ //
3
+ // U5 崩溃恢复钩子(E1)+ dispose 转换(E9)的真实文件通路测试(subagent-sync-collect
4
+ // 设计 §3.1.5)。
5
+ //
6
+ // 通路保真(U8 红线:禁 mock record 断言):
7
+ // 种子 entry 经真实 RecordStore.reportSubagentRecord → toSubagentRecordEntry 序列化
8
+ // → 真实 appendFileSync 落 tmpdir 主 session JSONL → 恢复侧真实 readFileSync 扫描
9
+ // (scanLastRecordEntries → collectLastRecordEntries + rebuildEntryRecord 投影)。
10
+ // 断言全部打在「磁盘文件内容 + 真实投影产物」上,scan/投影/序列化三层零 mock。
11
+ //
12
+ // 场景:
13
+ // 1. 标记与终态五字段可见性:collectMode/batchFinalized/status/endedAt/closedReason/
14
+ // result/error 经末条 entry 重建后全部可见(rebuildEntryRecord 投影白名单扩展);
15
+ // 2. E1 补发:崩溃残留(全员终态 + 无标记)→ manifest 屏障(await 落盘,先于
16
+ // 写账——「通知可达 ⇒ 索引就位」构造性保证)→ notifyBatch 单批补发(内容 = 末条终态
17
+ // 快照)+ 统一落标;async 成员 / 已标记成员(E9 排除)/ 异根成员均不入批;
18
+ // 3. E1 幂等窗口:账本同 hash 拒绝(accepted=false)也统一补标 → 标记落盘后二次
19
+ // 恢复零补发(收敛);
20
+ // 4. E1 仍有 running:不补发,等自然终态;
21
+ // 5. E9 dispose:缓冲中已终态成员逐条转 async notify(放弃攒批)+ 落标;在跑成员
22
+ // 走现有退出路径(不通知);
23
+ // 6. U4 deviation #8 接线:notifyBatch 收到 config 热读的 budget 参数。
24
+ //
25
+ // v2 断链 2+3(设计 subagent-sync-collect-v2.md §3.3 D3)真实序列种子(形态取自
26
+ // kill -9 真实链路,v1 教训:种子绕过 orphan 恢复直接写终态 entry = 测不到断链):
27
+ // 7. 主用例(写文件 pi):主文件种 register(running+sync)→ 轮终(running+
28
+ // resumable+result 全文)两笔 entry,sessionsDir 构造子 session 文件(identity +
29
+ // 末行完整 JSON)且不写三 sidecar(分支 4 命中)→ initSession 后 orphan 覆写
30
+ // (finalizeOrphanRecord 真实跑,merge 保留批标记与 result/model)→ E1 补发
31
+ // 单批 + 落标 → 二次重启零补发;
32
+ // 8. async 对照(同构造无 collectMode):覆写 entry 批域字段不出现、result 按
33
+ // merge 补齐(拉齐修复口径);
34
+ // 9. 豁免路径(断言 pi,覆写不落盘):E1 直读轮终 running+resumable entry 走
35
+ // resumable 豁免判定 → 补发可达(判定侧另一分支,与协调器 hasRunningSync 同构);
36
+ // 10. P-rebuild:rebuildEntryRecord 新投影字段(resumable/sessionFile)不改变
37
+ // recoverEntryOnlyOrphans 的「只认 running 末条」候选判定;
38
+ // 11. v2 断链 4(设计 §3.3 D4)延迟闭合:E1 waiting → 注册 settled 有界重扫 →
39
+ // 成员补种终态 entry → settled 边沿重扫补发单批 + 落标 → 后续 settled 零处理;
40
+ // 12. D4 上限:成员恒 running → settled 驱动 8 次重扫达限 → disposed(warn 留痕含滞留 id,
41
+ // D6 #7b/SC-2),第 9 次(成员此刻已终态)不再扫描;
42
+ // 13. v2 断链 1(设计 §3.3 D1/D2)E1 路径 manifest:写账前屏障 await 补写
43
+ // records/<sa-id>.json(时序竞态修订:原落标出口 fire-and-forget)——成功成员
44
+ // status 如实投影 "running"(豁免形态末条)/"closed"(覆写后末条),sessionFile
45
+ // 投影(W1 前置依赖)随批补发就位;
46
+ // 14. P-manifest 不变量:子文件锚 + manifest 并存 → 重启重建 collectRecords 投影
47
+ // 与删 manifest 后一致(byId.has 跳过语义:有子文件锚的成员永不被 manifest
48
+ // 补充投影覆盖,running manifest 不改变 list 形态);
49
+ // 15. D4 dispose 惰化:E1 waiting → dispose() → trailing settled 边沿不扫描不落标
50
+ // (notifier 已 dispose,跑了 notifyBatch 必 false 不写账而落标照发 → 通知永久
51
+ // 丢失;惰化后通知留给下次重启 E1 兑现);
52
+ // 16. D3 merge 保留方向反向锁定:子文件头部 model_change 重建 model=A 非空 + 主
53
+ // 文件轮终 entry model=B(≠A)→ 覆写 entry 取 rec 侧 A(仅补不覆盖——防未来
54
+ // 被改成恒取 src 的覆盖语义而既有只测「补齐方向」的用例仍绿)。
55
+ //
56
+ // mock 手法对齐 collect-coordinator-service.test.ts:mock session-runner(不 spawn 真子
57
+ // 进程)+ logger;record-store / config 走真实实现(tmpdir 自建自删,红线)。
58
+
59
+ import * as fs from "node:fs";
60
+ import * as os from "node:os";
61
+ import * as path from "node:path";
62
+
63
+ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
64
+
65
+ const { loggerMock, runSpawnMock } = vi.hoisted(() => ({
66
+ loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() },
67
+ runSpawnMock: vi.fn(async () => ({
68
+ text: "ok",
69
+ turns: 1,
70
+ durationMs: 10,
71
+ success: true,
72
+ sessionId: "spawned",
73
+ toolCalls: [],
74
+ })),
75
+ }));
76
+ // mock logger:路径从 __tests__ 出发是 ../../core/(src/core/logger.ts——subagent-service
77
+ // 经 ../core/logger.ts 引用的同一模块)。曾写 ../core/logger.ts 指向不存在的
78
+ // src/execution/core/,vi.mock 静默失效(D4 上限用例首次断言日志时暴露)。
79
+ vi.mock("../../core/logger.ts", () => ({ getLogger: () => loggerMock }));
80
+
81
+ // mock session-runner:execute 链经 kickOffBackground → runAndFinalize → runSpawn。
82
+ // 路径必须命中生产 import 的真实模块——merge 后 session-runner 落位
83
+ // src/execution/engine/engines/pi/session-runner.ts(subagent-service.ts 的 import 源),
84
+ // 旧路径 "../session-runner.ts" 模块已不存在,拦截静默失效(U2 教训的变体:
85
+ // 相对路径「层级」与「落位」都可能漂移,见 collect-coordinator-service.test.ts)。
86
+ vi.mock("../engine/engines/pi/session-runner.ts", () => ({
87
+ runSpawn: runSpawnMock,
88
+ killAllSpawnedChildren: vi.fn(),
89
+ killRecordChildWithEscalation: vi.fn(),
90
+ getChildByRecord: vi.fn(() => undefined),
91
+ registerSpawnedChildForRecord: vi.fn(),
92
+ spawnedChildren: new Map(),
93
+ }));
94
+
95
+ import { SUBAGENT_RECORD_CUSTOM_TYPE } from "../record-entry.ts";
96
+ import { ManifestStore } from "../manifest-store.ts";
97
+ import { ModelConfigService } from "../model-config-service.ts";
98
+ import type { ModelRegistryLike } from "../model-resolver.ts";
99
+ import { getSubagentRecordsDir, getSubagentSessionDir } from "../path-encoding.ts";
100
+ import { RecordStore } from "../record-store.ts";
101
+ import type { SubagentRecord } from "../types.ts";
102
+ import { SubagentService } from "../subagent-service.ts";
103
+
104
+ /** 剥身份/relay env:身份 env 会让 service 误判自己是子进程(跳过恢复扫描),
105
+ * relay env 属 pi-invocation/relay-env 存量测试的敏感面(测试纪律:env 剥离)。 */
106
+ const ENV_KEYS_TO_STRIP = [
107
+ "PI_SUBAGENT_ROOT_SESSION_ID",
108
+ "PI_SUBAGENT_SELF_RECORD_ID",
109
+ "PI_SUBAGENT_DEPTH",
110
+ "PI_SUBAGENT_ROOT_CWD",
111
+ "PI_SUBAGENT_FORK_DEPTH",
112
+ "XYZ_SUBAGENT_RELAY_SOCKET",
113
+ "XYZ_SUBAGENT_RELAY_NODE",
114
+ "XYZ_SUBAGENT_RELAY_SCRIPT",
115
+ ] as const;
116
+
117
+ const ROOT_SESSION = "root-session-crash";
118
+
119
+ /** settled handler 捕获 + 驱动(D4 重扫用例用):pi 实装为列表分发(P-settled 定谳,
120
+ * 0.84.4 dist loader.js on() push 进数组 + runner.js emit() 遍历全部 handler 并逐一
121
+ * await),捕获数组同构——emitAgentSettled 逐一 await 已注册 handler(D4 重扫
122
+ * handler 已 async 化:E1 补发前有 manifest 屏障 await),模拟一次 settled 边沿。 */
123
+ interface SettledDriver {
124
+ /** 驱动一次 agent_settled 边沿(快照遍历,防 handler 内注册/变异干扰本轮)。 */
125
+ emitAgentSettled(): Promise<void>;
126
+ }
127
+
128
+ // on/sendMessage 给显式 Mock 签名:vi.fn() 默认 Mock<Procedure | Constructable>
129
+ // 不可赋给 PiLike 的具名方法签名(PiLike 结构兼容要求参数可逆变)
130
+ function settledCapture(): {
131
+ driver: SettledDriver;
132
+ on: Mock<(event: string, handler: () => void | Promise<void>) => void>;
133
+ } {
134
+ const handlers: Array<() => void | Promise<void>> = [];
135
+ return {
136
+ driver: {
137
+ emitAgentSettled: async () => {
138
+ for (const h of [...handlers]) await h();
139
+ },
140
+ },
141
+ on: vi.fn((event: string, handler: () => void | Promise<void>) => {
142
+ if (event === "agent_settled") handlers.push(handler);
143
+ }),
144
+ };
145
+ }
146
+
147
+ /** 种子 pi:appendEntry 真写主 session JSONL(每 entry 一行,pi 落盘形态)。 */
148
+ function makeWritingPi(mainFile: string) {
149
+ const { driver, on } = settledCapture();
150
+ return {
151
+ appendEntry: vi.fn((customType: string, data: unknown) => {
152
+ fs.appendFileSync(
153
+ mainFile,
154
+ `${JSON.stringify({
155
+ type: "custom",
156
+ id: `seed-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
157
+ parentId: null,
158
+ timestamp: new Date().toISOString(),
159
+ customType,
160
+ data,
161
+ })}\n`,
162
+ "utf-8",
163
+ );
164
+ }),
165
+ events: { emit: vi.fn() },
166
+ sendMessage: vi.fn((_m: unknown, _o?: unknown) => {}),
167
+ on,
168
+ emitAgentSettled: driver.emitAgentSettled,
169
+ };
170
+ }
171
+
172
+ /** 断言 pi:appendEntry 只记录不落盘(断言恢复侧写点用)。 */
173
+ function makeAssertPi() {
174
+ const { driver, on } = settledCapture();
175
+ return {
176
+ appendEntry: vi.fn(),
177
+ events: { emit: vi.fn() },
178
+ sendMessage: vi.fn((_m: unknown, _o?: unknown) => {}),
179
+ on,
180
+ emitAgentSettled: driver.emitAgentSettled,
181
+ };
182
+ }
183
+
184
+ type AssertPi = ReturnType<typeof makeAssertPi>;
185
+ type WritingPi = ReturnType<typeof makeWritingPi>;
186
+
187
+ interface NotifierSpy {
188
+ // Mock<T> 而非纯函数签名:测试断言消费 .mock.calls,需保留 mock 元数据
189
+ notify: Mock<(record: unknown) => void>;
190
+ notifyBatch: Mock<(records: unknown, budget?: unknown) => boolean>;
191
+ }
192
+
193
+ function spyNotifier(service: SubagentService): NotifierSpy {
194
+ // [D4-① 适配] notifier 实例已封装进 notifyHost(createNotifyHost),spy 改为包装
195
+ // host 的 notify/notifyBatch 出口(其余方法经 ...host 保留真实现);协调器 deps
196
+ // 闭包经 this 运行时读取 service.notifyHost,字段替换即生效。
197
+ const host = (service as unknown as { notifyHost: object }).notifyHost;
198
+ const spy: NotifierSpy = { notify: vi.fn(), notifyBatch: vi.fn(() => true) };
199
+ (service as unknown as { notifyHost: unknown }).notifyHost = {
200
+ ...host,
201
+ notify: (record: unknown) => spy.notify(record),
202
+ notifyBatch: (records: unknown, budget?: unknown) => spy.notifyBatch(records, budget),
203
+ };
204
+ return spy;
205
+ }
206
+
207
+ /** 手写短轮询(真实 timers)——vi.waitFor 在 vitest 4.1.8 本包环境对 falsy callback
208
+ * 立即 resolve 不轮询(见 collect-coordinator-service.test.ts 同款说明)。 */
209
+ async function until(cond: () => boolean, timeoutMs = 3000): Promise<void> {
210
+ const start = Date.now();
211
+ while (!cond()) {
212
+ if (Date.now() - start > timeoutMs) {
213
+ throw new Error(`sync-collect-recovery: condition not met within ${timeoutMs}ms`);
214
+ }
215
+ await new Promise((resolve) => setTimeout(resolve, 10));
216
+ }
217
+ }
218
+
219
+ /** 种子成员 record(真实 reportSubagentRecord 入参——序列化/落盘/扫描三层真实)。 */
220
+ function memberRecord(overrides: Partial<SubagentRecord> & { id: string }): SubagentRecord {
221
+ return {
222
+ agent: "/agents/worker.md",
223
+ task: "seed task",
224
+ slug: "seed",
225
+ status: "running",
226
+ mode: "background",
227
+ startedAt: 1000,
228
+ rootSessionId: ROOT_SESSION,
229
+ parentRecordId: undefined,
230
+ depth: 0,
231
+ endedAt: undefined,
232
+ turns: 0,
233
+ totalTokens: 0,
234
+ model: "prov/m1",
235
+ thinkingLevel: undefined,
236
+ eventLog: [],
237
+ displayItems: [],
238
+ result: undefined,
239
+ error: undefined,
240
+ sessionFile: undefined,
241
+ chatMode: false,
242
+ collectMode: "sync",
243
+ ...overrides,
244
+ };
245
+ }
246
+
247
+ describe("sync collect recovery (U5 E1/E9) — 真实文件通路", () => {
248
+ let agentDir: string;
249
+ let mainFile: string;
250
+
251
+ beforeEach(() => {
252
+ for (const k of ENV_KEYS_TO_STRIP) delete process.env[k];
253
+ agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "core-sync-recovery-"));
254
+ fs.mkdirSync(getSubagentSessionDir(agentDir, agentDir), { recursive: true });
255
+ mainFile = path.join(agentDir, "main-session.jsonl");
256
+ });
257
+
258
+ afterEach(() => {
259
+ // maxRetries:E9 落标的 manifest fire-and-forget 原子写(tmp→fsync→rename,
260
+ // 链尾 fsyncDir 仍在飞;批通知路径的屏障 await 已含 fsyncDir,无此竞态)+
261
+ // sessions-index fire 写都可能与删除并发(ENOTEMPTY 竞态,根级全量并行时机器
262
+ // 负载高会放大窗口)——同款修法见 get-record-for-action-restart.test.ts /
263
+ // record-store-index.test.ts。
264
+ fs.rmSync(agentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
265
+ });
266
+
267
+ /** 种子 store:真实 RecordStore + 写文件 pi(种子经真实 toSubagentRecordEntry 序列化)。 */
268
+ function makeSeedStore(): RecordStore {
269
+ return new RecordStore(
270
+ getSubagentSessionDir(agentDir, agentDir),
271
+ new ManifestStore(getSubagentRecordsDir(agentDir, agentDir)),
272
+ makeWritingPi(mainFile),
273
+ );
274
+ }
275
+
276
+ /** 恢复侧 service:断言 pi(覆写不落盘,断言内存面)/ 写文件 pi(覆写真实落盘,
277
+ * E1 读覆写后末条——kill -9 真实链路同构)两用。 */
278
+ function makeRecoveryService(pi: AssertPi | WritingPi): SubagentService {
279
+ const modelService = new ModelConfigService({ agentDir, cwd: agentDir });
280
+ const modelRegistry: ModelRegistryLike = {
281
+ getAvailable: () => [],
282
+ find: () => undefined,
283
+ hasConfiguredAuth: () => false,
284
+ };
285
+ modelService.initModel({
286
+ sessionId: ROOT_SESSION,
287
+ ctxModel: { id: "m", name: "M", provider: "p", reasoning: false },
288
+ modelRegistry,
289
+ });
290
+ const service = new SubagentService({ cwd: agentDir, modelService });
291
+ service.initSession({ pi, sessionId: ROOT_SESSION, mainSessionFile: mainFile });
292
+ return service;
293
+ }
294
+
295
+ /** 读回主文件每 id 末条 entry data(断言文件真实内容用)。 */
296
+ function readMainFileLastEntries(): Map<string, Record<string, unknown>> {
297
+ const lastById = new Map<string, Record<string, unknown>>();
298
+ for (const line of fs.readFileSync(mainFile, "utf-8").split("\n")) {
299
+ if (!line.includes(SUBAGENT_RECORD_CUSTOM_TYPE) || line.trim() === "") continue;
300
+ const obj = JSON.parse(line) as { customType?: string; data?: { id?: string } };
301
+ if (obj.customType === SUBAGENT_RECORD_CUSTOM_TYPE && typeof obj.data?.id === "string") {
302
+ lastById.set(obj.data.id, obj.data as Record<string, unknown>);
303
+ }
304
+ }
305
+ return lastById;
306
+ }
307
+
308
+ /** 在 sessionsDir 手工构造子 session 文件(session 头 + identity entry + 末行完整
309
+ * JSON record entry,E9 用例同款形态)且**不写** `.finalized`/`.cancelled`/`.alive`
310
+ * 三 sidecar——重建矩阵分支 4 命中条件,保证 orphan 恢复走 finalizeOrphanRecord
311
+ * 真实路径(entry-born 兜底测不到 D3 merge 所在路径 = 假绿)。
312
+ * identity 含 reconstructAll 过滤必需字段:id/agent/task string + mode 枚举 +
313
+ * startedAt number + rootSessionId。
314
+ * headModel(可选,"provider/modelId" 形态):在 session 头与 identity 之间插一笔
315
+ * model_change entry——pi sdk 新 session 真实形态(dist sdk.js 对新 session 先
316
+ * appendModelChange 初值,session_start hook 的 identity 随后),readIdentityHeader
317
+ * 解析 identity 前途经的 model_change 产出 light 重建的 rec.model。 */
318
+ function writeChildSessionFile(recordId: string, task: string, headModel?: string): string {
319
+ const childFile = path.join(getSubagentSessionDir(agentDir, agentDir), `${recordId}.jsonl`);
320
+ const ts = new Date(1000).toISOString();
321
+ const modelChangeLine =
322
+ headModel === undefined
323
+ ? ""
324
+ : JSON.stringify({
325
+ type: "model_change", id: `mc-${recordId}-0`, parentId: null, timestamp: ts,
326
+ provider: headModel.slice(0, headModel.indexOf("/")),
327
+ modelId: headModel.slice(headModel.indexOf("/") + 1),
328
+ }) + "\n";
329
+ fs.writeFileSync(
330
+ childFile,
331
+ JSON.stringify({ type: "session", version: 3, id: `sess-${recordId}`, timestamp: ts, cwd: agentDir }) + "\n" +
332
+ modelChangeLine +
333
+ JSON.stringify({
334
+ type: "custom", id: `cid-${recordId}-1`, parentId: null, timestamp: ts,
335
+ customType: "subagent-identity",
336
+ data: { id: recordId, agent: "/agents/worker.md", mode: "background", task, startedAt: 1000, rootSessionId: ROOT_SESSION, depth: 0 },
337
+ }) + "\n" +
338
+ JSON.stringify({
339
+ type: "custom", id: `cid-${recordId}-2`, parentId: `cid-${recordId}-1`, timestamp: ts,
340
+ customType: "subagent-record",
341
+ data: {
342
+ v: 1, id: recordId, agent: "/agents/worker.md", task, slug: "child",
343
+ status: "running", mode: "background", startedAt: 1000, rootSessionId: ROOT_SESSION,
344
+ depth: 0, turns: 1, totalTokens: 10, model: "prov/child-m", eventLog: [], displayItems: [],
345
+ },
346
+ }) + "\n",
347
+ "utf-8",
348
+ );
349
+ return childFile;
350
+ }
351
+
352
+ /** 种下「崩溃前主文件末条序列」:register(running+sync)→ 轮终(running+
353
+ * resumable + result 全文 + sessionFile)——成功成员崩溃时的真实末条形态
354
+ * (SP-5 one-shot 轮终写点,finalize-record.ts doFinalizeRoundToIdle)。
355
+ * collectMode 必须显式传("sync" 主用例 / undefined async 对照)——不可给默认值:
356
+ * JS 默认参数对显式 undefined 也触发,async 对照会被默认 "sync" 污染。 */
357
+ function seedRoundTerminalEntries(id: string, childFile: string, result: string, model: string, collectMode: "sync" | undefined): void {
358
+ const store = makeSeedStore();
359
+ store.reportSubagentRecord(memberRecord({ id, sessionFile: childFile, model, collectMode }));
360
+ store.reportSubagentRecord(memberRecord({ id, sessionFile: childFile, model, collectMode, resumable: true, result }));
361
+ }
362
+
363
+ it("标记与终态五字段经真实落盘→扫描投影后可见(rebuildEntryRecord 白名单扩展)", () => {
364
+ const store = makeSeedStore();
365
+ // 成员 A:register(running+sync)→ 终态(closed + gc + result)两笔真实 entry
366
+ store.reportSubagentRecord(memberRecord({ id: "sa-a" }));
367
+ store.reportSubagentRecord(
368
+ memberRecord({
369
+ id: "sa-a",
370
+ status: "closed",
371
+ closedReason: "gc",
372
+ endedAt: 2000,
373
+ result: "done-A",
374
+ }),
375
+ );
376
+ // 成员 B:failed 终态(error 字段)
377
+ store.reportSubagentRecord(memberRecord({ id: "sa-b" }));
378
+ store.reportSubagentRecord(
379
+ memberRecord({ id: "sa-b", status: "closed", closedReason: "gc", endedAt: 3000, error: "boom" }),
380
+ );
381
+ // 成员 C:已落标(batchFinalized,E9/flush 后形态)
382
+ store.reportSubagentRecord(
383
+ memberRecord({ id: "sa-c", status: "closed", closedReason: "gc", endedAt: 4000, batchFinalized: true }),
384
+ );
385
+ // 成员 D:async(无 collectMode)终态
386
+ store.reportSubagentRecord(
387
+ memberRecord({ id: "sa-d", status: "closed", closedReason: "gc", endedAt: 5000, collectMode: undefined }),
388
+ );
389
+
390
+ // 恢复侧:真实 readFileSync 扫描 + 投影(零 mock;store 经测试后门访问,与
391
+ // collect-coordinator-service.test.ts 访问 notifier 同款手法)
392
+ const recovery = makeRecoveryService(makeAssertPi());
393
+ const scanned = (recovery as unknown as { store: RecordStore }).store.scanLastRecordEntries(mainFile);
394
+ const byId = new Map(scanned.map((r) => [r.id, r]));
395
+
396
+ // 终态五字段 + 两标记字段全可见(末条 last-writer-wins)
397
+ const a = byId.get("sa-a");
398
+ expect(a).toBeDefined();
399
+ expect(a!.status).toBe("closed");
400
+ expect(a!.endedAt).toBe(2000);
401
+ expect(a!.closedReason).toBe("gc");
402
+ expect(a!.result).toBe("done-A");
403
+ expect(a!.error).toBeUndefined();
404
+ expect(a!.collectMode).toBe("sync");
405
+ expect(a!.batchFinalized).toBeUndefined();
406
+
407
+ const b = byId.get("sa-b");
408
+ expect(b!.error).toBe("boom");
409
+ expect(b!.result).toBeUndefined();
410
+
411
+ expect(byId.get("sa-c")!.batchFinalized).toBe(true);
412
+ expect(byId.get("sa-d")!.collectMode).toBeUndefined();
413
+ });
414
+
415
+ it("E1 补发:全员终态无标记 → notifyBatch 单批(末条终态快照)+ 统一落标;async/已标记/异根排除", async () => {
416
+ const store = makeSeedStore();
417
+ store.reportSubagentRecord(memberRecord({ id: "sa-a" }));
418
+ store.reportSubagentRecord(
419
+ memberRecord({ id: "sa-a", status: "closed", closedReason: "gc", endedAt: 2000, result: "done-A" }),
420
+ );
421
+ store.reportSubagentRecord(memberRecord({ id: "sa-b" }));
422
+ store.reportSubagentRecord(
423
+ memberRecord({ id: "sa-b", status: "closed", closedReason: "gc", endedAt: 3000, error: "boom" }),
424
+ );
425
+ // 排除面 1:async 终态成员不入批
426
+ store.reportSubagentRecord(
427
+ memberRecord({ id: "sa-async", status: "closed", closedReason: "gc", endedAt: 4000, collectMode: undefined }),
428
+ );
429
+ // 排除面 2:已标记成员(E9 转换后重启形态)不入批
430
+ store.reportSubagentRecord(
431
+ memberRecord({ id: "sa-marked", status: "closed", closedReason: "gc", endedAt: 5000, batchFinalized: true }),
432
+ );
433
+ // 排除面 3:异根成员不入批
434
+ store.reportSubagentRecord(
435
+ memberRecord({ id: "sa-foreign", status: "closed", closedReason: "gc", endedAt: 6000, rootSessionId: "other-root" }),
436
+ );
437
+
438
+ const pi = makeAssertPi();
439
+ const recovery = makeRecoveryService(pi);
440
+ const spy = spyNotifier(recovery);
441
+ // 屏障断言钩子(严格时序门):notifyBatch(写账入口)被调用的时刻,成员 manifest
442
+ // 必已在磁盘——「通知可达 ⇒ 索引就位」的构造性保证(修复前 manifest 在写账后
443
+ // fire-and-forget 写,该时刻只大概率成立)。
444
+ let manifestExistsAtLedgerWrite = false;
445
+ spy.notifyBatch.mockImplementation(() => {
446
+ manifestExistsAtLedgerWrite = fs.existsSync(
447
+ path.join(getSubagentRecordsDir(agentDir, agentDir), "sa-a.json"),
448
+ );
449
+ return true;
450
+ });
451
+ await recovery.recoverSyncCollectBatch();
452
+
453
+ // 单批补发:只有 sa-a / sa-b 入批,内容 = 末条终态快照
454
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
455
+ const batch = spy.notifyBatch.mock.calls[0]![0] as Array<Record<string, unknown>>;
456
+ expect(batch.map((m) => m.id).sort()).toEqual(["sa-a", "sa-b"]);
457
+ const a = batch.find((m) => m.id === "sa-a")!;
458
+ expect(a.status).toBe("closed");
459
+ expect(a.result).toBe("done-A");
460
+ expect(a.closedReason).toBe("gc");
461
+ const b = batch.find((m) => m.id === "sa-b")!;
462
+ expect(b.error).toBe("boom");
463
+ // 单条 notify 零调用(补发形态是批,不是逐条)
464
+ expect(spy.notify).not.toHaveBeenCalled();
465
+ // E1 屏障:写账时刻成员 manifest 已落盘(严格时序门)
466
+ expect(manifestExistsAtLedgerWrite).toBe(true);
467
+
468
+ // 统一落标:两成员各一笔 batchFinalized entry(appendEntry 直投影,含终态快照)
469
+ const marks = pi.appendEntry.mock.calls
470
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
471
+ .map((c) => c[1] as Record<string, unknown>)
472
+ .filter((d) => d.batchFinalized === true);
473
+ expect(marks.map((m) => m.id).sort()).toEqual(["sa-a", "sa-b"]);
474
+ });
475
+
476
+ it("屏障失败 warn 留痕(D6 #7a/SC-1):manifest 写失败不阻断补发,warn 含成员 id 与 manifest 路径", async () => {
477
+ const childFile = writeChildSessionFile("sa-bar-fail", "barrier fail task");
478
+ seedRoundTerminalEntries("sa-bar-fail", childFile, "barrier result", "prov/bar-m", "sync");
479
+
480
+ const recovery = makeRecoveryService(makeAssertPi());
481
+ const spy = spyNotifier(recovery);
482
+ loggerMock.warn.mockClear(); // logger 模块级共享,计数从本用例起算
483
+ // 私有 manifestStore 打桩(TS private 仅编译期可见性):单成员写失败(EACCES 类),
484
+ // 屏障 allSettled 语义 = 不阻断写账投递
485
+ const internal = recovery as unknown as {
486
+ manifestStore: { writeManifest: (r: { id: string }) => Promise<void> };
487
+ };
488
+ vi.spyOn(internal.manifestStore, "writeManifest").mockRejectedValueOnce(
489
+ Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }),
490
+ );
491
+
492
+ await recovery.recoverSyncCollectBatch();
493
+
494
+ // best-effort 语义:写账照常推进(反查索引缺失只影响指针行反查,不构成写账失败)
495
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
496
+ // D6 #7a:原 debug 级排障不可见 → warn,且含成员 id + manifest 路径两个定位线索
497
+ const manifestFile = path.join(getSubagentRecordsDir(agentDir, agentDir), "sa-bar-fail.json");
498
+ const barrierWarns = loggerMock.warn.mock.calls.filter((c) =>
499
+ String(c[0]).startsWith("[subagents] batch-finalized manifest write failed"),
500
+ );
501
+ expect(barrierWarns).toHaveLength(1);
502
+ expect(String(barrierWarns[0]![0])).toContain("record=sa-bar-fail");
503
+ expect(String(barrierWarns[0]![0])).toContain(manifestFile);
504
+ });
505
+
506
+ it("E1 幂等窗口:账本同 hash 拒绝(accepted=false)也统一补标 → 标记落盘后二次恢复零补发", async () => {
507
+ const store = makeSeedStore();
508
+ store.reportSubagentRecord(memberRecord({ id: "sa-a" }));
509
+ store.reportSubagentRecord(
510
+ memberRecord({ id: "sa-a", status: "closed", closedReason: "gc", endedAt: 2000, result: "done-A" }),
511
+ );
512
+
513
+ // 第一次恢复:notifyBatch 返回 false(模拟「批闭合写账成功、落标前崩溃」后账本已在该批)
514
+ const pi1 = makeAssertPi();
515
+ const first = makeRecoveryService(pi1);
516
+ const spy1 = spyNotifier(first);
517
+ spy1.notifyBatch.mockReturnValue(false);
518
+ await first.recoverSyncCollectBatch();
519
+ expect(spy1.notifyBatch).toHaveBeenCalledTimes(1);
520
+ // 账本拒绝也算已投递 → 仍统一补标
521
+ const marks1 = pi1.appendEntry.mock.calls
522
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
523
+ .map((c) => c[1] as Record<string, unknown>)
524
+ .filter((d) => d.batchFinalized === true);
525
+ expect(marks1.map((m) => m.id)).toEqual(["sa-a"]);
526
+
527
+ // 标记真实落盘(写文件 pi 把补标 entry 追加进主文件——flush 后形态)
528
+ const seedForMark = makeSeedStore();
529
+ seedForMark.reportSubagentRecord(
530
+ memberRecord({ id: "sa-a", status: "closed", closedReason: "gc", endedAt: 2000, result: "done-A", batchFinalized: true }),
531
+ );
532
+
533
+ // 第二次恢复:全员已标记 → 零补发零 notify(收敛,无振荡)
534
+ const second = makeRecoveryService(makeAssertPi());
535
+ const spy2 = spyNotifier(second);
536
+ await second.recoverSyncCollectBatch();
537
+ expect(spy2.notifyBatch).not.toHaveBeenCalled();
538
+ expect(spy2.notify).not.toHaveBeenCalled();
539
+ });
540
+
541
+ it("E1 仍有 running 成员:不补发不落标(等自然终态走正常流)", async () => {
542
+ const store = makeSeedStore();
543
+ store.reportSubagentRecord(memberRecord({ id: "sa-a" }));
544
+ store.reportSubagentRecord(
545
+ memberRecord({ id: "sa-a", status: "closed", closedReason: "gc", endedAt: 2000, result: "done-A" }),
546
+ );
547
+ // 在跑成员:末条 running(子进程活到重启后的形态)
548
+ store.reportSubagentRecord(memberRecord({ id: "sa-running" }));
549
+
550
+ const pi = makeAssertPi();
551
+ const recovery = makeRecoveryService(pi);
552
+ const spy = spyNotifier(recovery);
553
+ await recovery.recoverSyncCollectBatch();
554
+
555
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
556
+ expect(spy.notify).not.toHaveBeenCalled();
557
+ expect(
558
+ pi.appendEntry.mock.calls
559
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
560
+ .map((c) => c[1] as Record<string, unknown>)
561
+ .some((d) => d.batchFinalized === true),
562
+ ).toBe(false);
563
+ });
564
+
565
+ it("E9 dispose:缓冲终态成员逐条转 async notify + 落标;在跑成员走现有退出路径(不通知)", async () => {
566
+ // 复用 execute 真链:成员 1 受控终态(入缓冲后批因成员 2 在跑不闭合),成员 2 悬置 running
567
+ let resolve1!: (v: { text: string; turns: number; durationMs: number; success: boolean; sessionId: string; toolCalls: [] }) => void;
568
+ runSpawnMock.mockImplementationOnce(() => new Promise((res) => { resolve1 = res; }));
569
+ runSpawnMock.mockImplementationOnce(() => new Promise(() => { /* 悬置到 dispose */ }));
570
+ const pi = makeAssertPi();
571
+ const service = makeRecoveryService(pi);
572
+ const spy = spyNotifier(service);
573
+ const h1 = await service.execute({ task: "terminal one", slug: "one", collect: "sync" });
574
+ await service.execute({ task: "running two", slug: "two", collect: "sync" });
575
+ await until(() => runSpawnMock.mock.calls.length >= 2);
576
+
577
+ // 成员 1 子文件就位(E9 落标 getFullRecord 冷路径数据源)
578
+ const sessionsDir = getSubagentSessionDir(agentDir, agentDir);
579
+ const ts = new Date(1000).toISOString();
580
+ fs.writeFileSync(
581
+ path.join(sessionsDir, `${h1.subagentId}.jsonl`),
582
+ `${JSON.stringify({ type: "session", version: 3, id: `sess-${h1.subagentId}`, timestamp: ts, cwd: agentDir })}\n` +
583
+ JSON.stringify({
584
+ type: "custom", id: "id-1", parentId: null, timestamp: ts,
585
+ customType: "subagent-identity",
586
+ data: { id: h1.subagentId, agent: "/agents/worker.md", mode: "background", task: "terminal one", startedAt: 1000, rootSessionId: ROOT_SESSION, depth: 0 },
587
+ }) + "\n" +
588
+ JSON.stringify({
589
+ type: "custom", id: "id-2", parentId: "id-1", timestamp: ts,
590
+ customType: "subagent-record",
591
+ data: {
592
+ v: 1, id: h1.subagentId, agent: "/agents/worker.md", task: "terminal one", slug: "one",
593
+ status: "closed", mode: "background", startedAt: 1000, rootSessionId: ROOT_SESSION,
594
+ depth: 0, endedAt: 2000, turns: 1, totalTokens: 10, model: "prov/m1",
595
+ eventLog: [], displayItems: [], result: "ok-terminal",
596
+ },
597
+ }) + "\n",
598
+ "utf-8",
599
+ );
600
+
601
+ // 成员 1 终态:入缓冲,成员 2 在跑 → 批不闭合(零批投递)
602
+ resolve1({ text: "ok-terminal", turns: 1, durationMs: 10, success: true, sessionId: "spawned", toolCalls: [] });
603
+ await until(() => spy.notify.mock.calls.length + spy.notifyBatch.mock.calls.length > 0 || runSpawnMock.mock.calls.length >= 2);
604
+ await new Promise((resolve) => setTimeout(resolve, 50)); // microtask 链排空
605
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
606
+
607
+ // dispose(E9):成员 1 逐条转 async 写账 + 落标;成员 2 不通知(现有退出路径)
608
+ service.dispose();
609
+ expect(spy.notify).toHaveBeenCalledTimes(1);
610
+ expect(spy.notify.mock.calls[0]![0]).toMatchObject({ id: h1.subagentId, status: "closed" });
611
+ expect(spy.notifyBatch).not.toHaveBeenCalled(); // 放弃攒批
612
+ const marks = pi.appendEntry.mock.calls
613
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
614
+ .map((c) => c[1] as Record<string, unknown>)
615
+ .filter((d) => d.batchFinalized === true);
616
+ expect(marks.map((m) => m.id)).toEqual([h1.subagentId]);
617
+ expect(marks[0]!.collectMode).toBe("sync");
618
+ expect(marks[0]!.result).toBe("ok-terminal");
619
+ });
620
+
621
+ it("U4 deviation #8 接线:config collectSync 预算热读传入 notifyBatch budget 参数", async () => {
622
+ const store = makeSeedStore();
623
+ store.reportSubagentRecord(memberRecord({ id: "sa-a" }));
624
+ store.reportSubagentRecord(
625
+ memberRecord({ id: "sa-a", status: "closed", closedReason: "gc", endedAt: 2000, result: "done-A" }),
626
+ );
627
+ // 写 config(collectSync 预算字段)+ reload —— 与 getCollectSyncDefault 同款访问链
628
+ const configPath = path.join(agentDir, "subagents", "config.json");
629
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
630
+ fs.writeFileSync(
631
+ configPath,
632
+ JSON.stringify({ version: 1, maxConcurrent: 6, collectSync: { default: "sync", perItemChars: 1234, totalChars: 5678 } }),
633
+ "utf-8",
634
+ );
635
+
636
+ const pi = makeAssertPi();
637
+ const recovery = makeRecoveryService(pi);
638
+ const modelService = (recovery as unknown as { modelService: ModelConfigService }).modelService;
639
+ modelService.reloadGlobalConfig();
640
+ const spy = spyNotifier(recovery);
641
+ await recovery.recoverSyncCollectBatch();
642
+
643
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
644
+ expect(spy.notifyBatch.mock.calls[0]![1]).toEqual({ perItemChars: 1234, totalChars: 5678 });
645
+
646
+ // 文件末条确为无标记终态(本用例的数据前提自检)
647
+ const last = readMainFileLastEntries().get("sa-a");
648
+ expect(last!.batchFinalized).toBeUndefined();
649
+ });
650
+
651
+ // ============================================================
652
+ // v2 断链 2+3(设计 §3.3 D3):orphan 覆写 merge + E1 resumable 豁免。
653
+ // 种子形态取自 kill -9 真实链路(v1 教训:绕过 orphan 恢复直接写终态 entry
654
+ // 测不到断链——主文件两笔 entry + 子文件 + 无三 sidecar,让 finalizeOrphanRecord
655
+ // 与 E1 在同一测试里真实跑)。
656
+ // ============================================================
657
+
658
+ it("kill -9 同构主用例:orphan 覆写 merge 保留批标记与 result/model → E1 补发单批 + 落标 → 二次重启零补发", async () => {
659
+ // ── 崩溃前形态:register(running+sync)→ 轮终(running+resumable+result 全文)──
660
+ const childFile = writeChildSessionFile("sa-kill9", "kill -9 crash task");
661
+ seedRoundTerminalEntries("sa-kill9", childFile, "kill-9 full result body", "prov/round-m", "sync");
662
+
663
+ // ── 重启恢复(写文件 pi:orphan 覆写 entry 真实落盘,E1 读覆写后末条)──
664
+ // initSession 内已真实跑 orphan 恢复(ENV 已剥 → 根进程判定 → finalizeOrphanRecord)。
665
+ const recoveryPi = makeWritingPi(mainFile);
666
+ const recovery = makeRecoveryService(recoveryPi);
667
+
668
+ // 断链 2 核心断言:覆写 entry(主文件末条)保留批域标记与轮终正文/模型——
669
+ // 重建矩阵不含这些字段,不 merge 就会被覆写抹掉(E1 候选集恒空的真根因)。
670
+ const overwritten = readMainFileLastEntries().get("sa-kill9")!;
671
+ expect(overwritten.status).toBe("closed");
672
+ expect(overwritten.closedReason).toBe("gc");
673
+ expect(overwritten.collectMode).toBe("sync");
674
+ expect(overwritten.result).toBe("kill-9 full result body");
675
+ expect(overwritten.model).toBe("prov/round-m");
676
+ // 防重锚落盘(覆写已判终态,二次重启不再进判定)
677
+ expect(fs.existsSync(`${childFile}.finalized`)).toBe(true);
678
+
679
+ // ── E1 真实跑:末条(覆写后 closed entry)候选命中 → 补发 + 落标 ──
680
+ const spy = spyNotifier(recovery);
681
+ await recovery.recoverSyncCollectBatch();
682
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
683
+ expect(spy.notify).not.toHaveBeenCalled();
684
+ const batch = spy.notifyBatch.mock.calls[0]![0] as Array<Record<string, unknown>>;
685
+ expect(batch).toHaveLength(1);
686
+ expect(batch[0]!.id).toBe("sa-kill9");
687
+ // 成功成员正文含 result 全文(来自覆写 entry 的 merge 保留,非 "(empty)")
688
+ expect(batch[0]!.result).toBe("kill-9 full result body");
689
+ expect(batch[0]!.status).toBe("closed");
690
+
691
+ // 补发后落标 batchFinalized(主文件末条)
692
+ const marked = readMainFileLastEntries().get("sa-kill9")!;
693
+ expect(marked.batchFinalized).toBe(true);
694
+ expect(marked.collectMode).toBe("sync");
695
+
696
+ // [v2 D1] E1 路径 manifest:写账前屏障 await 补写 records/<sa-id>.json(时序
697
+ // 竞态修订:原为落标出口 fire-and-forget),反查索引随补发就位(指针行消费前
698
+ // 可得——await 返回时屏障已完成,无需轮询)。覆写后 closed 重建快照 → status
699
+ // 如实投影 "closed";sessionFile 来自 W1 的 rebuildEntryRecord 投影扩展。
700
+ const manifestFile = path.join(getSubagentRecordsDir(agentDir, agentDir), "sa-kill9.json");
701
+ expect(fs.existsSync(manifestFile)).toBe(true);
702
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf-8")) as Record<string, unknown>;
703
+ expect(manifest).toMatchObject({
704
+ id: "sa-kill9",
705
+ rootSessionId: ROOT_SESSION,
706
+ agentName: "/agents/worker.md",
707
+ status: "closed",
708
+ sessionFile: childFile,
709
+ });
710
+
711
+ // ── 二次重启(V2 验收「零重发」):已标记 → 候选空 → 零补发 ──
712
+ const second = makeRecoveryService(makeAssertPi());
713
+ const spy2 = spyNotifier(second);
714
+ await second.recoverSyncCollectBatch();
715
+ expect(spy2.notifyBatch).not.toHaveBeenCalled();
716
+ expect(spy2.notify).not.toHaveBeenCalled();
717
+ });
718
+
719
+ it("async 对照:覆写 entry 批域字段不出现,result/model 按 merge 补齐(拉齐修复口径)", () => {
720
+ const childFile = writeChildSessionFile("sa-async-ctl", "async crash task");
721
+ // 同构造但无 collectMode(undefined 覆盖 memberRecord 默认 "sync")
722
+ seedRoundTerminalEntries("sa-async-ctl", childFile, "async full result body", "prov/async-m", undefined);
723
+
724
+ const recoveryPi = makeWritingPi(mainFile);
725
+ makeRecoveryService(recoveryPi); // initSession 内 orphan 覆写真实跑
726
+
727
+ const overwritten = readMainFileLastEntries().get("sa-async-ctl")!;
728
+ expect(overwritten.status).toBe("closed");
729
+ // 批域字段对非 sync 成员恒 no-op:序列化产物不含这两键(merge 无值可补)
730
+ expect(Object.hasOwn(overwritten, "collectMode")).toBe(false);
731
+ expect(Object.hasOwn(overwritten, "batchFinalized")).toBe(false);
732
+ // result/model 修补对 async 成员同样生效(light 重建丢 result/model 的拉齐修复)
733
+ expect(overwritten.result).toBe("async full result body");
734
+ expect(overwritten.model).toBe("prov/async-m");
735
+ });
736
+
737
+ it("merge 保留方向反向锁定:子文件 identity 重建 model=A 非空 + 主文件轮终 model=B → 覆写 entry 取 rec 侧 A(仅补不覆盖)", () => {
738
+ // 反向构造(既有用例只测「rec 侧空 → src 补齐」方向,恒取 src 的覆盖语义回归下
739
+ // 仍绿):rec 侧 model 来自子文件头部 model_change 的 light 重建(A),last 侧
740
+ // model 来自主文件轮终 entry(B≠A)→ 覆写 entry 必须保留 A。
741
+ const childFile = writeChildSessionFile("sa-merge-dir", "merge direction task", "prov-child/child-m-a");
742
+ seedRoundTerminalEntries("sa-merge-dir", childFile, "merge direction result", "prov/round-m-b", "sync");
743
+
744
+ const recoveryPi = makeWritingPi(mainFile);
745
+ makeRecoveryService(recoveryPi); // initSession 内 orphan 覆写(merge 生效点)真实跑
746
+
747
+ const overwritten = readMainFileLastEntries().get("sa-merge-dir")!;
748
+ expect(overwritten.status).toBe("closed");
749
+ // rec 侧 A(identity 重建值)胜出:merge 是「仅补 undefined/空值、不覆盖已有值」,
750
+ // 恒取 src 的覆盖语义会把这里改写成 B —— pickStr 的 cur 半边由此锁定。
751
+ expect(overwritten.model).toBe("prov-child/child-m-a");
752
+ // 前提自检(非 vacuous):src 侧轮终 entry(覆写前已落盘,仍在文件中)确携带
753
+ // 异值 B——证明 cur 侧非空时 src 侧有可覆盖的异值被让位,而非「无源可取」。
754
+ const allModels = fs.readFileSync(mainFile, "utf-8").split("\n")
755
+ .filter((l) => l.includes(SUBAGENT_RECORD_CUSTOM_TYPE) && l.includes("sa-merge-dir"))
756
+ .map((l) => (JSON.parse(l) as { data?: { model?: string } }).data?.model);
757
+ expect(allModels).toContain("prov/round-m-b");
758
+ });
759
+
760
+ it("豁免路径:轮终 running+resumable 末条(覆写不落盘)→ E1 resumable 豁免 → 补发可达 + manifest 如实投影 running", async () => {
761
+ const childFile = writeChildSessionFile("sa-exempt", "exempt path task");
762
+ seedRoundTerminalEntries("sa-exempt", childFile, "exempt full result body", "prov/round-m", "sync");
763
+
764
+ // 断言 pi:orphan 覆写 entry 不落盘——主文件末条保持轮终 running+resumable 形态,
765
+ // E1 读的是该 entry(覆写不可达的防御分支残余,正是豁免口径要覆盖的形态)。
766
+ const pi = makeAssertPi();
767
+ const recovery = makeRecoveryService(pi);
768
+
769
+ // 前提自检:主文件末条仍是轮终 running+resumable(旧口径 status !== "closed"
770
+ // 会把它顶死在「等自然终态」,本用例锁定豁免判定让补发可达)。
771
+ const lastBefore = readMainFileLastEntries().get("sa-exempt")!;
772
+ expect(lastBefore.status).toBe("running");
773
+ expect(lastBefore.resumable).toBe(true);
774
+
775
+ const spy = spyNotifier(recovery);
776
+ await recovery.recoverSyncCollectBatch();
777
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
778
+ const batch = spy.notifyBatch.mock.calls[0]![0] as Array<Record<string, unknown>>;
779
+ expect(batch[0]!.id).toBe("sa-exempt");
780
+ // 补发内容 = 轮终快照(result 全文;resumable 投影使豁免判定可见)
781
+ expect(batch[0]!.result).toBe("exempt full result body");
782
+
783
+ // 落标 entry(断言 pi 的内存面):覆写被豁免放行的成员同样统一补标
784
+ const marks = pi.appendEntry.mock.calls
785
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
786
+ .map((c) => c[1] as Record<string, unknown>)
787
+ .filter((d) => d.batchFinalized === true);
788
+ expect(marks.map((m) => m.id)).toEqual(["sa-exempt"]);
789
+
790
+ // [v2 D1/D2 断链 1] E1 补发路径(rebuildEntryRecord 重建快照来源)的 manifest:
791
+ // 写账前屏障 await 补写(await 返回时必在场),成功成员此刻实态 running+
792
+ // resumable → status 如实投影 "running"(D2:不撒谎写 closed);sessionFile
793
+ // 来自 W1 的投影扩展(前置依赖)。
794
+ const manifestFile = path.join(getSubagentRecordsDir(agentDir, agentDir), "sa-exempt.json");
795
+ expect(fs.existsSync(manifestFile)).toBe(true);
796
+ const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf-8")) as Record<string, unknown>;
797
+ expect(manifest).toMatchObject({
798
+ id: "sa-exempt",
799
+ rootSessionId: ROOT_SESSION,
800
+ agentName: "/agents/worker.md",
801
+ status: "running",
802
+ createdAt: 1000,
803
+ sessionFile: childFile,
804
+ });
805
+ });
806
+
807
+ // ============================================================
808
+ // v2 断链 4(设计 §3.3 D4):E1 等待分支的 settled 有界重扫。等待不再死等——
809
+ // 成员延迟终态(冷路径 resume → 正常流落 entry)由 settled 边沿驱动重扫收敛。
810
+ // ============================================================
811
+
812
+ it("D4 延迟闭合:E1 仍有 running → 注册 settled 重扫(单注册)→ 成员补种终态 entry → 边沿重扫补发单批+落标 → disposed", async () => {
813
+ const store = makeSeedStore();
814
+ store.reportSubagentRecord(memberRecord({ id: "sa-a" }));
815
+ store.reportSubagentRecord(
816
+ memberRecord({ id: "sa-a", status: "closed", closedReason: "gc", endedAt: 2000, result: "done-A" }),
817
+ );
818
+ // 在跑成员:末条 running(子进程活到重启后的形态,与既有 waiting 用例同构造)
819
+ store.reportSubagentRecord(memberRecord({ id: "sa-late" }));
820
+
821
+ // 断言 pi:orphan 覆写不落盘 → 主文件末条保持 running,E1 走等待分支(前提自检)
822
+ const pi = makeAssertPi();
823
+ const recovery = makeRecoveryService(pi);
824
+ const spy = spyNotifier(recovery);
825
+ await recovery.recoverSyncCollectBatch();
826
+
827
+ // 等待分支:零补发 + 注册 settled 重扫恰一次(agent_settled 单注册断言)
828
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
829
+ const settledRegistrations = pi.on.mock.calls.filter((c) => c[0] === "agent_settled");
830
+ expect(settledRegistrations).toHaveLength(1);
831
+
832
+ // 成员延迟终态:冷路径 resume → 正常流终态落 entry(reportSubagentRecord 真实写点同构)
833
+ const lateStore = makeSeedStore();
834
+ lateStore.reportSubagentRecord(
835
+ memberRecord({ id: "sa-late", status: "closed", endedAt: 9000, result: "late full result" }),
836
+ );
837
+
838
+ // settled 边沿 → 重扫一次(await 完整补发链:manifest 屏障 → 写账 → 落标):
839
+ // 全员终态 → 补发单批(两成员,含延迟成员 result 全文)
840
+ await pi.emitAgentSettled();
841
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
842
+ const batch = spy.notifyBatch.mock.calls[0]![0] as Array<Record<string, unknown>>;
843
+ expect(batch.map((m) => m.id).sort()).toEqual(["sa-a", "sa-late"]);
844
+ expect(batch.find((m) => m.id === "sa-late")!.result).toBe("late full result");
845
+
846
+ // 重扫落标:两成员统一补 batchFinalized(断言 pi 内存面)
847
+ const marks = pi.appendEntry.mock.calls
848
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
849
+ .map((c) => c[1] as Record<string, unknown>)
850
+ .filter((d) => d.batchFinalized === true);
851
+ expect(marks.map((m) => m.id).sort()).toEqual(["sa-a", "sa-late"]);
852
+
853
+ // disposed 验证:补发完成后再驱动 settled 边沿 → 零处理(无第二次补发)
854
+ await pi.emitAgentSettled();
855
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
856
+ });
857
+
858
+ it("D4 上限:成员恒 running → settled 驱动 8 次重扫达限 disposed(warn 留痕含滞留 id,D6 #7b)→ 第 9 次(成员已终态)不再扫描", async () => {
859
+ const store = makeSeedStore();
860
+ store.reportSubagentRecord(memberRecord({ id: "sa-stuck" }));
861
+
862
+ const pi = makeAssertPi();
863
+ const recovery = makeRecoveryService(pi);
864
+ const spy = spyNotifier(recovery);
865
+ loggerMock.debug.mockClear(); // logger 是模块级共享,计数从本用例起算
866
+ await recovery.recoverSyncCollectBatch();
867
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
868
+ expect(pi.on.mock.calls.filter((c) => c[0] === "agent_settled")).toHaveLength(1);
869
+
870
+ // 连续 8 次 settled:每次重扫(成员末条恒 running → 每次都判「仍在等」)。
871
+ // waiting 判定过滤用消息前缀精确匹配(达限日志文案同样含 "still running" 词)。
872
+ const e1WaitingLogs = () => loggerMock.debug.mock.calls.map((c) => String(c[0])).filter((m) => m.startsWith("[subagents] E1 sync batch recovery:"));
873
+ for (let i = 0; i < 8; i++) await pi.emitAgentSettled();
874
+ // 首扫 1 + 重扫 8 = 9 次 waiting 判定留痕;达限 warn 恰一次(D6 #7b/SC-2:debug→warn,含滞留成员 id)
875
+ expect(e1WaitingLogs()).toHaveLength(9);
876
+ const limitCalls = loggerMock.warn.mock.calls.filter((c) =>
877
+ String(c[0]).startsWith("[subagents] E1 settled rescan: reached limit"),
878
+ );
879
+ expect(limitCalls).toHaveLength(1);
880
+ expect(limitCalls[0]![1]).toEqual({ ids: ["sa-stuck"] });
881
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
882
+
883
+ // 第 9 次驱动时成员已补种终态:disposed 不再扫描(零补发 + waiting 日志不增)
884
+ const lateStore = makeSeedStore();
885
+ lateStore.reportSubagentRecord(
886
+ memberRecord({ id: "sa-stuck", status: "closed", endedAt: 9500, result: "too late" }),
887
+ );
888
+ await pi.emitAgentSettled();
889
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
890
+ expect(e1WaitingLogs()).toHaveLength(9);
891
+ });
892
+
893
+ it("D4 dispose 惰化:E1 waiting → dispose() → trailing settled 边沿不扫描不落标(通知不丢失)", async () => {
894
+ // 与「D4 延迟闭合」同构造(成员补种终态 + settled 边沿 → 补发落标)作对照:
895
+ // 本用例在边沿前插入 dispose()——handler 已惰化,扫描/补发/落标全不发生。
896
+ // 失败模式(修复前):dispose 后 notifier 已 dispose → notifyBatch 短路 false
897
+ // 且不写账,而 E1 dispatched 段不判 accepted 仍统一落标 → 批被标 batchFinalized
898
+ // 而通知从未写账(永久丢失);惰化后通知由下次重启 E1 首扫兑现(无标记可达)。
899
+ const store = makeSeedStore();
900
+ store.reportSubagentRecord(memberRecord({ id: "sa-a" }));
901
+ store.reportSubagentRecord(
902
+ memberRecord({ id: "sa-a", status: "closed", closedReason: "gc", endedAt: 2000, result: "done-A" }),
903
+ );
904
+ // 在跑成员:末条 running → E1 走等待分支并注册 settled 重扫
905
+ store.reportSubagentRecord(memberRecord({ id: "sa-late" }));
906
+
907
+ const pi = makeAssertPi();
908
+ const recovery = makeRecoveryService(pi);
909
+ const spy = spyNotifier(recovery);
910
+ await recovery.recoverSyncCollectBatch();
911
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
912
+ expect(pi.on.mock.calls.filter((c) => c[0] === "agent_settled")).toHaveLength(1);
913
+
914
+ // session_shutdown:dispose 惰化重扫 handler
915
+ recovery.dispose();
916
+
917
+ // 成员补种终态 + trailing settled 边沿:handler 已惰化 → 零扫描(无 E1 日志)
918
+ const lateStore = makeSeedStore();
919
+ lateStore.reportSubagentRecord(
920
+ memberRecord({ id: "sa-late", status: "closed", endedAt: 9000, result: "late full result" }),
921
+ );
922
+ loggerMock.debug.mockClear();
923
+ loggerMock.warn.mockClear();
924
+ await pi.emitAgentSettled();
925
+ expect(spy.notifyBatch).not.toHaveBeenCalled();
926
+ const e1Logs = () =>
927
+ [...loggerMock.debug.mock.calls, ...loggerMock.warn.mock.calls]
928
+ .map((c) => String(c[0]))
929
+ .filter((m) => m.startsWith("[subagents] E1"));
930
+ expect(e1Logs()).toHaveLength(0);
931
+
932
+ // 不落标:无 batchFinalized entry —— 通知不写账也不封门,留给下次重启兑现
933
+ expect(
934
+ pi.appendEntry.mock.calls
935
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
936
+ .map((c) => c[1] as Record<string, unknown>)
937
+ .some((d) => d.batchFinalized === true),
938
+ ).toBe(false);
939
+ });
940
+
941
+ it("P-rebuild:新投影字段不改变 entry-born 孤儿判定(轮终 running 末条无子文件锚仍入判定覆写)", () => {
942
+ // 轮终形态末条(running + resumable + sessionFile 字段在 entry 里),子文件不
943
+ // 存在——recoverEntryOnlyOrphans 的「只认 running 末条」判定不应因新投影字段
944
+ // (resumable/sessionFile)而跳过该 id(resumable 豁免只在 E1 口径,不在
945
+ // entry-born 域)。
946
+ const seedStore = makeSeedStore();
947
+ seedStore.reportSubagentRecord(
948
+ memberRecord({ id: "sa-p-rebuild", resumable: true, result: "round done", sessionFile: path.join(agentDir, "no-such-child.jsonl") }),
949
+ );
950
+
951
+ const pi = makeAssertPi();
952
+ makeRecoveryService(pi); // initSession 内 recoverEntryOnlyOrphans 真实跑
953
+
954
+ const entry = pi.appendEntry.mock.calls
955
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
956
+ .map((c) => c[1] as Record<string, unknown>)
957
+ .find((d) => d.id === "sa-p-rebuild");
958
+ expect(entry).toBeDefined();
959
+ expect(entry!.status).toBe("closed");
960
+ expect(entry!.closedReason).toBe("gc");
961
+ expect(String(entry!.error)).toContain("no child session file");
962
+ });
963
+
964
+ // ============================================================
965
+ // v2 断链 1(设计 §3.3 D1/D2 + 探针 P-manifest):落标出口 manifest 落盘后,
966
+ // running manifest 不得触发 collectRecords 孤儿补充投影——有子文件锚的成员
967
+ // 永不被 manifest 补充投影覆盖(record-store byId.has 跳过语义)。
968
+ // ============================================================
969
+
970
+ it("P-manifest 不变量:子文件锚 + manifest 并存 → 重启重建 list 投影与删 manifest 后逐字段一致", async () => {
971
+ // 豁免形态构造(区分度最强):落标写出的 manifest.status="running"(D2 如实
972
+ // 投影)与子文件 sidecar 重建投影 closed+gc 可辨——若 manifest 被错误投影,
973
+ // status/closedReason 形态差异立即暴露。
974
+ const childFile = writeChildSessionFile("sa-pm", "p-manifest task");
975
+ seedRoundTerminalEntries("sa-pm", childFile, "p-manifest full result", "prov/pm-m", "sync");
976
+ const recovery = makeRecoveryService(makeAssertPi()); // initSession:orphan 判定 + sidecar 真实落盘
977
+ const spy = spyNotifier(recovery);
978
+ await recovery.recoverSyncCollectBatch(); // E1 豁免补发:manifest 屏障(先于写账)→ 落标
979
+ expect(spy.notifyBatch).toHaveBeenCalledTimes(1);
980
+ const manifestFile = path.join(getSubagentRecordsDir(agentDir, agentDir), "sa-pm.json");
981
+ expect(fs.existsSync(manifestFile)).toBe(true); // 屏障:await 返回时必已落盘
982
+
983
+ // 模拟重启重建:全新 RecordStore + ManifestStore(内存缓存零残留),manifest 存在时
984
+ const freshStore = () =>
985
+ new RecordStore(
986
+ getSubagentSessionDir(agentDir, agentDir),
987
+ new ManifestStore(getSubagentRecordsDir(agentDir, agentDir)),
988
+ );
989
+ const withManifest = freshStore().collectRecords(100, "all", ROOT_SESSION);
990
+ const pm = withManifest.find((r) => r.id === "sa-pm");
991
+ expect(pm).toBeDefined();
992
+ // 投影来自子文件 sidecar 重建(closed+gc),非 manifest(status="running")
993
+ expect(pm!.status).toBe("closed");
994
+ expect(pm!.closedReason).toBe("gc");
995
+
996
+ // 删 manifest 文件 → 同款重建 → 投影逐字段一致(不变量:manifest 的存在不改变
997
+ // list 形态——补充投影只服务「entry/子文件源完全缺失」的孤儿)
998
+ fs.rmSync(manifestFile);
999
+ const withoutManifest = freshStore().collectRecords(100, "all", ROOT_SESSION);
1000
+ expect(withoutManifest).toEqual(withManifest);
1001
+ });
1002
+
1003
+ // ============================================================
1004
+ // E1 恢复批语义修复(review round1 簇 B):one-shot 成功成员崩溃时末条 entry 恒为
1005
+ // running+resumable(SP-5 轮终形态)——syncRebuildToNotifyMember 直通 status 的旧
1006
+ // 形态使补发记录仍是 running:批头只统计 closed 成员 → 全员成功的恢复批显示
1007
+ // 「0 finished」,条目文案落「finished a round」(对话轮次语义),且 rebuildEntryRecord
1008
+ // 不投影 patchFile → worktree 改动的 git-apply 回收指针丢失。修复 = 对齐
1009
+ // notify-host toNotifyRecord 映射(closed + outcome 物化 + patchFile 透传)+
1010
+ // rebuildEntryRecord 补 patchFile 投影。
1011
+ // ============================================================
1012
+
1013
+ it("E1 恢复批语义:one-shot 成功成员(running+resumable 末条)补发记录为 closed 形态——批头计数正确 + patchFile 提示在场", async () => {
1014
+ const patchPath = path.join(agentDir, "patches", "sa-ntf.patch");
1015
+ // 崩溃前主文件末条序列:register → 轮终 running+resumable + result 全文 + patchFile
1016
+ //(worktree one-shot 成功成员 kill -9 后的真实末条形态,SP-5 轮终写点)。
1017
+ const store = makeSeedStore();
1018
+ store.reportSubagentRecord(memberRecord({ id: "sa-ntf", collectMode: "sync", patchFile: patchPath }));
1019
+ store.reportSubagentRecord(
1020
+ memberRecord({
1021
+ id: "sa-ntf",
1022
+ collectMode: "sync",
1023
+ resumable: true,
1024
+ result: "resumable full result body",
1025
+ endedAt: 2000,
1026
+ patchFile: patchPath,
1027
+ }),
1028
+ );
1029
+
1030
+ const pi = makeAssertPi();
1031
+ const recovery = makeRecoveryService(pi);
1032
+
1033
+ // 投影层断言:rebuildEntryRecord 补 patchFile 投影后,E1 扫描快照可见
1034
+ //(修复前投影丢弃 → 补发记录无从携带 git-apply 指针)。
1035
+ const scanned = (recovery as unknown as { store: RecordStore }).store.scanLastRecordEntries(mainFile);
1036
+ expect(scanned.find((r) => r.id === "sa-ntf")!.patchFile).toBe(patchPath);
1037
+
1038
+ // 真实 notifier 通路(不 spy——测试进程无 ledger/投递域装配 → 内核直发降级,
1039
+ // pi.sendMessage 同步可捕获):断言主 agent 实际消费的批通知内容。
1040
+ await recovery.recoverSyncCollectBatch();
1041
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
1042
+ const sent = pi.sendMessage.mock.calls[0]![0] as {
1043
+ content: string;
1044
+ details?: { items?: Array<Record<string, unknown>> };
1045
+ };
1046
+ // 批头计数:closed+completed 成员计入 finished(修复前 running 直通 → 0 finished)
1047
+ expect(sent.content).toContain("Subagent batch completed: 1 finished, 0 failed, 0 cancelled.");
1048
+ // 条目文案:completed 终态而非「finished a round」(对话轮次语义)
1049
+ expect(sent.content).toContain("completed. Result:");
1050
+ expect(sent.content).toContain("resumable full result body");
1051
+ expect(sent.content).not.toContain("finished a round");
1052
+ // patchFile 的 git-apply 回收指针在场(修复前投影缺失 → 提示整行丢失)
1053
+ expect(sent.content).toContain(`git apply ${patchPath}`);
1054
+ // 补发记录一等断言(details items = ledger/GUI 同源消费形态):closed + outcome 物化
1055
+ expect(sent.details?.items?.[0]).toMatchObject({
1056
+ id: "sa-ntf",
1057
+ status: "closed",
1058
+ outcome: "completed",
1059
+ patchFile: patchPath,
1060
+ });
1061
+
1062
+ // 落标不受影响:批补发后统一补 batchFinalized
1063
+ const marks = pi.appendEntry.mock.calls
1064
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
1065
+ .map((c) => c[1] as Record<string, unknown>)
1066
+ .filter((d) => d.batchFinalized === true);
1067
+ expect(marks.map((m) => m.id)).toEqual(["sa-ntf"]);
1068
+ });
1069
+
1070
+ it("S11:flushBatch 中 getFullRecord miss 成员用缓冲快照兜底落标(防重启后 E1 异 hash 双投递)", async () => {
1071
+ // 成员 1:失败终态(archive 出内存)且不写子文件 → getFullRecord 双 miss
1072
+ //(内存已出 + 子文件缺失——子文件被 GC/删除的窗口形态);
1073
+ // 成员 2:SP-5 成功回退(留内存)→ getFullRecord 内存命中(正常落标对照)。
1074
+ let resolve1!: (v: { text: string; turns: number; durationMs: number; success: boolean; sessionId: string; toolCalls: [] }) => void;
1075
+ let resolve2!: (v: { text: string; turns: number; durationMs: number; success: boolean; sessionId: string; toolCalls: [] }) => void;
1076
+ runSpawnMock.mockImplementationOnce(() => new Promise((res) => { resolve1 = res; }));
1077
+ runSpawnMock.mockImplementationOnce(() => new Promise((res) => { resolve2 = res; }));
1078
+ // 写文件 pi:标记 entry 真实落盘主文件,E1 扫描通路(rebuildEntryRecord)可重解析
1079
+ const pi = makeWritingPi(mainFile);
1080
+ const service = makeRecoveryService(pi);
1081
+ const spy = spyNotifier(service);
1082
+ const h1 = await service.execute({ task: "miss one", slug: "one", collect: "sync" });
1083
+ const h2 = await service.execute({ task: "full two", slug: "two", collect: "sync" });
1084
+ await until(() => runSpawnMock.mock.calls.length >= 2);
1085
+
1086
+ // 成员 1 子文件故意缺失(getFullRecord miss 前提);成员 2 内存命中不依赖子文件。
1087
+ // 注:两成员的 settle 链竞态可能拆两批([U8] 合批窗口形态)——S11 断言与批切分
1088
+ // 无关,只锁「批成员并集覆盖」与「miss 成员兜底落标」。
1089
+ resolve1({ text: "boom-miss", turns: 1, durationMs: 10, success: false, sessionId: "spawned", toolCalls: [] });
1090
+ resolve2({ text: "ok-full", turns: 1, durationMs: 10, success: true, sessionId: "spawned", toolCalls: [] });
1091
+ await until(() => spy.notifyBatch.mock.calls.length > 0);
1092
+ await new Promise((resolve) => setTimeout(resolve, 50)); // flushBatch 落标同步链排空
1093
+
1094
+ // 批成员并集覆盖两成员(miss 成员随批写账,成员集 hash 口径不变)
1095
+ const batchIds = spy.notifyBatch.mock.calls
1096
+ .flatMap((c) => (c[0] as Array<{ id: string }>).map((m) => m.id))
1097
+ .sort();
1098
+ expect(batchIds).toEqual([h1.subagentId, h2.subagentId].sort());
1099
+
1100
+ // 落标全员覆盖:miss 成员 h1 缓冲快照兜底 + full 成员 h2 正常落标
1101
+ //(修复前 h1 被跳过 → 无标记 → 重启后 E1 重新收集该成员,以异成员集 hash
1102
+ // 重新补发 → 同成员双投递)
1103
+ const marks = pi.appendEntry.mock.calls
1104
+ .filter((c) => c[0] === SUBAGENT_RECORD_CUSTOM_TYPE)
1105
+ .map((c) => c[1] as Record<string, unknown>)
1106
+ .filter((d) => d.batchFinalized === true);
1107
+ expect(marks.map((m) => m.id).sort()).toEqual([h1.subagentId, h2.subagentId].sort());
1108
+
1109
+ // 兜底标记 entry 经 E1 扫描通路可重解析且带标记(重启后候选排除判据就位)
1110
+ const scanned = (service as unknown as { store: RecordStore }).store.scanLastRecordEntries(mainFile);
1111
+ const h1Last = scanned.find((r) => r.id === h1.subagentId);
1112
+ expect(h1Last).toBeDefined();
1113
+ expect(h1Last!.batchFinalized).toBe(true);
1114
+ expect(h1Last!.collectMode).toBe("sync");
1115
+ expect(h1Last!.status).toBe("closed");
1116
+ });
1117
+ });