@adhdev/daemon-core 0.9.82-rc.37 → 0.9.82-rc.371

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 (384) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +9 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/build-info.d.ts +37 -0
  4. package/dist/chat/source-machine.d.ts +166 -0
  5. package/dist/chat/source-resolver.d.ts +104 -0
  6. package/dist/chat/subscription-updates.d.ts +1 -0
  7. package/dist/cli-adapter-types.d.ts +55 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +116 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +63 -0
  14. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  15. package/dist/cli-adapters/resolve-executable.d.ts +14 -0
  16. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  17. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  18. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  19. package/dist/commands/chat-commands.d.ts +27 -0
  20. package/dist/commands/cli-manager.d.ts +15 -1
  21. package/dist/commands/handler.d.ts +125 -0
  22. package/dist/commands/high-family/index.d.ts +3 -0
  23. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  24. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  25. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  26. package/dist/commands/high-family/types.d.ts +60 -0
  27. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  28. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  29. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  30. package/dist/commands/low-family/index.d.ts +3 -0
  31. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  32. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  33. package/dist/commands/low-family/notification.d.ts +2 -0
  34. package/dist/commands/low-family/refine-config.d.ts +2 -0
  35. package/dist/commands/low-family/session-host.d.ts +2 -0
  36. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  37. package/dist/commands/low-family/status-meta.d.ts +2 -0
  38. package/dist/commands/low-family/types.d.ts +33 -0
  39. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  40. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  41. package/dist/commands/med-family/ide.d.ts +10 -0
  42. package/dist/commands/med-family/index.d.ts +3 -0
  43. package/dist/commands/med-family/mesh-crud.d.ts +2 -0
  44. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  45. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  46. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  47. package/dist/commands/med-family/types.d.ts +116 -0
  48. package/dist/commands/mesh-coordinator.d.ts +85 -1
  49. package/dist/commands/router.d.ts +666 -2
  50. package/dist/commands/upgrade-helper.d.ts +41 -1
  51. package/dist/config/chat-history.d.ts +9 -0
  52. package/dist/config/config.d.ts +5 -0
  53. package/dist/config/mesh-config.d.ts +77 -1
  54. package/dist/daemon/dev-server.d.ts +0 -2
  55. package/dist/detection/ide-detector.d.ts +13 -0
  56. package/dist/detection/win32-ide-version.d.ts +37 -0
  57. package/dist/git/change-impact-config.d.ts +159 -0
  58. package/dist/git/git-commands.d.ts +11 -1
  59. package/dist/git/git-diff.d.ts +6 -0
  60. package/dist/git/git-executor.d.ts +11 -0
  61. package/dist/git/git-status.d.ts +23 -0
  62. package/dist/git/git-types.d.ts +2 -50
  63. package/dist/git/index.d.ts +2 -0
  64. package/dist/index.d.ts +55 -13
  65. package/dist/index.js +46565 -19737
  66. package/dist/index.js.map +1 -1
  67. package/dist/index.mjs +43563 -16868
  68. package/dist/index.mjs.map +1 -1
  69. package/dist/installer.d.ts +1 -4
  70. package/dist/ipc/local-ipc-server.d.ts +91 -0
  71. package/dist/launch.d.ts +1 -1
  72. package/dist/logging/async-batch-writer.d.ts +10 -0
  73. package/dist/logging/log-redactor.d.ts +24 -0
  74. package/dist/logging/log-tail-reader.d.ts +46 -0
  75. package/dist/logging/logger.d.ts +1 -1
  76. package/dist/mesh/contracts.d.ts +164 -0
  77. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  78. package/dist/mesh/coordinator-registry.d.ts +59 -0
  79. package/dist/mesh/mesh-active-work.d.ts +174 -0
  80. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  81. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  82. package/dist/mesh/mesh-events-coordinator.d.ts +91 -0
  83. package/dist/mesh/mesh-events-pending.d.ts +55 -0
  84. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  85. package/dist/mesh/mesh-events-utils.d.ts +61 -0
  86. package/dist/mesh/mesh-events.d.ts +6 -49
  87. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  88. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  89. package/dist/mesh/mesh-init.d.ts +86 -0
  90. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  91. package/dist/mesh/mesh-ledger.d.ts +77 -1
  92. package/dist/mesh/mesh-missions.d.ts +164 -0
  93. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  94. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  95. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  96. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  97. package/dist/mesh/mesh-routing.d.ts +70 -0
  98. package/dist/mesh/mesh-runtime-store.d.ts +429 -0
  99. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  100. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  101. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  102. package/dist/mesh/mesh-work-queue.d.ts +246 -5
  103. package/dist/mesh/preview-freshness.d.ts +18 -0
  104. package/dist/mesh/refine-config.d.ts +216 -0
  105. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  106. package/dist/providers/acp-provider-instance.d.ts +5 -0
  107. package/dist/providers/approval-utils.d.ts +20 -0
  108. package/dist/providers/chat-message-normalization.d.ts +12 -0
  109. package/dist/providers/cli-provider-instance.d.ts +108 -3
  110. package/dist/providers/contracts.d.ts +139 -6
  111. package/dist/providers/external-sources.d.ts +71 -0
  112. package/dist/providers/manual-attendance.d.ts +63 -0
  113. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  114. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  115. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  116. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  117. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  118. package/dist/providers/native-history/index.d.ts +13 -0
  119. package/dist/providers/provider-instance-manager.d.ts +14 -0
  120. package/dist/providers/provider-instance.d.ts +23 -1
  121. package/dist/providers/provider-loader.d.ts +26 -4
  122. package/dist/providers/provider-trust.d.ts +31 -0
  123. package/dist/providers/read-chat-contract.d.ts +29 -0
  124. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  125. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  126. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  127. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  128. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  129. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  130. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  131. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  132. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  133. package/dist/providers/sdk/v1/index.d.ts +30 -0
  134. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  135. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  136. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  137. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  138. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  139. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  140. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  141. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  142. package/dist/providers/spec/adapter.d.ts +91 -0
  143. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  144. package/dist/providers/spec/evaluator.d.ts +45 -0
  145. package/dist/providers/spec/fsm-driver.d.ts +395 -0
  146. package/dist/providers/spec/fsm-evaluator.d.ts +76 -0
  147. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  148. package/dist/providers/spec/fsm-types.d.ts +174 -0
  149. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  150. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  151. package/dist/providers/spec/route.d.ts +4 -0
  152. package/dist/providers/spec/types.d.ts +222 -0
  153. package/dist/providers/status-monitor.d.ts +7 -7
  154. package/dist/providers/transcript-v2.d.ts +176 -0
  155. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  156. package/dist/providers/working-dir.d.ts +17 -0
  157. package/dist/repo-mesh-types.d.ts +295 -13
  158. package/dist/sessions/registry.d.ts +6 -0
  159. package/dist/shared-types-extra.d.ts +2 -4
  160. package/dist/shared-types.d.ts +59 -2
  161. package/dist/status/normalize.d.ts +1 -1
  162. package/dist/status/normalize.js +1 -0
  163. package/dist/status/normalize.js.map +1 -1
  164. package/dist/status/normalize.mjs +1 -0
  165. package/dist/status/normalize.mjs.map +1 -1
  166. package/dist/status/reporter.d.ts +2 -0
  167. package/dist/status/snapshot.d.ts +26 -0
  168. package/dist/system/hash.d.ts +8 -0
  169. package/dist/system/load-better-sqlite3.d.ts +21 -0
  170. package/dist/types.d.ts +5 -0
  171. package/package.json +7 -3
  172. package/src/agent-stream/poller.ts +2 -3
  173. package/src/agent-stream/provider-adapter.ts +1 -1
  174. package/src/boot/daemon-lifecycle.ts +63 -12
  175. package/src/boot/process-hardening.ts +89 -0
  176. package/src/build-info.ts +73 -0
  177. package/src/chat/source-machine.ts +534 -0
  178. package/src/chat/source-resolver.ts +0 -0
  179. package/src/chat/subscription-updates.ts +20 -1
  180. package/src/cli-adapter-types.d.ts +1 -0
  181. package/src/cli-adapter-types.ts +56 -2
  182. package/src/cli-adapters/cli-script-runner.ts +421 -0
  183. package/src/cli-adapters/cli-state-engine.ts +1221 -0
  184. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  185. package/src/cli-adapters/provider-cli-adapter.ts +775 -1141
  186. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  187. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  188. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  189. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  190. package/src/cli-adapters/provider-cli-shared.ts +110 -11
  191. package/src/cli-adapters/pty-transport.ts +2 -1
  192. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  193. package/src/cli-adapters/resolve-executable.ts +90 -0
  194. package/src/cli-adapters/session-host-transport.ts +2 -1
  195. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  196. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  197. package/src/cli-adapters/terminal-screen.ts +16 -81
  198. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +198 -0
  199. package/src/commands/chat-commands.ts +1917 -62
  200. package/src/commands/cli-manager.ts +502 -24
  201. package/src/commands/handler.ts +841 -2
  202. package/src/commands/high-family/index.ts +28 -0
  203. package/src/commands/high-family/mesh-coordinator-launch.ts +592 -0
  204. package/src/commands/high-family/mesh-events.ts +47 -0
  205. package/src/commands/high-family/mesh-status.ts +642 -0
  206. package/src/commands/high-family/types.ts +76 -0
  207. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  208. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  209. package/src/commands/low-family/diagnostics.ts +57 -0
  210. package/src/commands/low-family/index.ts +37 -0
  211. package/src/commands/low-family/mesh-ledger.ts +62 -0
  212. package/src/commands/low-family/mesh-node-logs.ts +81 -0
  213. package/src/commands/low-family/notification.ts +116 -0
  214. package/src/commands/low-family/refine-config.ts +106 -0
  215. package/src/commands/low-family/session-host.ts +274 -0
  216. package/src/commands/low-family/spec-providerdev.ts +217 -0
  217. package/src/commands/low-family/status-meta.ts +112 -0
  218. package/src/commands/low-family/types.ts +39 -0
  219. package/src/commands/med-family/cli-agent.ts +218 -0
  220. package/src/commands/med-family/fast-forward.ts +198 -0
  221. package/src/commands/med-family/ide.ts +163 -0
  222. package/src/commands/med-family/index.ts +37 -0
  223. package/src/commands/med-family/mesh-crud.ts +788 -0
  224. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  225. package/src/commands/med-family/mesh-queue.ts +131 -0
  226. package/src/commands/med-family/mesh-restart.ts +92 -0
  227. package/src/commands/med-family/types.ts +120 -0
  228. package/src/commands/mesh-coordinator.ts +334 -124
  229. package/src/commands/router.ts +5121 -2369
  230. package/src/commands/stream-commands.ts +8 -0
  231. package/src/commands/upgrade-helper.ts +310 -45
  232. package/src/config/chat-history.ts +483 -24
  233. package/src/config/config.ts +12 -0
  234. package/src/config/mesh-config.ts +389 -6
  235. package/src/config/recent-activity.ts +8 -2
  236. package/src/daemon/dev-auto-implement.ts +3 -2
  237. package/src/daemon/dev-cli-debug.ts +10 -1
  238. package/src/daemon/dev-server.ts +0 -541
  239. package/src/detection/cli-detector.ts +28 -9
  240. package/src/detection/ide-detector.ts +55 -16
  241. package/src/detection/win32-ide-version.ts +106 -0
  242. package/src/git/change-impact-config.ts +354 -0
  243. package/src/git/git-commands.ts +55 -14
  244. package/src/git/git-diff.ts +81 -11
  245. package/src/git/git-executor.ts +12 -0
  246. package/src/git/git-status.ts +574 -43
  247. package/src/git/git-types.ts +14 -62
  248. package/src/git/git-worktree.ts +8 -1
  249. package/src/git/index.ts +16 -0
  250. package/src/index.ts +158 -12
  251. package/src/installer.d.ts +1 -1
  252. package/src/installer.ts +8 -6
  253. package/src/ipc/local-ipc-server.ts +278 -0
  254. package/src/launch.d.ts +1 -1
  255. package/src/launch.ts +37 -28
  256. package/src/logging/async-batch-writer.ts +55 -0
  257. package/src/logging/command-log.ts +7 -5
  258. package/src/logging/log-redactor.ts +100 -0
  259. package/src/logging/log-tail-reader.ts +220 -0
  260. package/src/logging/logger.ts +14 -7
  261. package/src/mesh/contracts.ts +338 -0
  262. package/src/mesh/coordinator-prompt.ts +234 -31
  263. package/src/mesh/coordinator-registry.ts +121 -0
  264. package/src/mesh/mesh-active-work.ts +645 -0
  265. package/src/mesh/mesh-delivery-policy.ts +315 -0
  266. package/src/mesh/mesh-event-trace.ts +67 -0
  267. package/src/mesh/mesh-events-coordinator.ts +2866 -0
  268. package/src/mesh/mesh-events-pending.ts +599 -0
  269. package/src/mesh/mesh-events-stale.ts +309 -0
  270. package/src/mesh/mesh-events-utils.ts +387 -0
  271. package/src/mesh/mesh-events.ts +30 -1035
  272. package/src/mesh/mesh-fast-forward.ts +839 -0
  273. package/src/mesh/mesh-host-ownership.ts +73 -0
  274. package/src/mesh/mesh-init.ts +260 -0
  275. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  276. package/src/mesh/mesh-ledger.ts +591 -102
  277. package/src/mesh/mesh-missions.ts +322 -0
  278. package/src/mesh/mesh-reconcile-loop.ts +1134 -0
  279. package/src/mesh/mesh-refine-batch.ts +197 -0
  280. package/src/mesh/mesh-refine-status.ts +231 -0
  281. package/src/mesh/mesh-review-inbox.ts +307 -0
  282. package/src/mesh/mesh-routing.ts +291 -0
  283. package/src/mesh/mesh-runtime-store.ts +1697 -0
  284. package/src/mesh/mesh-task-stats.ts +161 -0
  285. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  286. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  287. package/src/mesh/mesh-work-queue.ts +997 -141
  288. package/src/mesh/preview-freshness.ts +118 -0
  289. package/src/mesh/refine-config.ts +423 -0
  290. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  291. package/src/providers/acp-provider-instance.ts +43 -10
  292. package/src/providers/approval-utils.d.ts +5 -0
  293. package/src/providers/approval-utils.ts +57 -5
  294. package/src/providers/chat-message-normalization.ts +45 -5
  295. package/src/providers/cli-provider-instance.ts +1310 -101
  296. package/src/providers/contracts.d.ts +55 -0
  297. package/src/providers/contracts.ts +150 -6
  298. package/src/providers/extension-provider-instance.ts +12 -7
  299. package/src/providers/external-sources.ts +218 -0
  300. package/src/providers/ide-provider-instance.ts +35 -12
  301. package/src/providers/manual-attendance.ts +85 -0
  302. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  303. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  304. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  305. package/src/providers/native-history/dispatcher.ts +340 -0
  306. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  307. package/src/providers/native-history/index.ts +30 -0
  308. package/src/providers/provider-instance-manager.ts +30 -0
  309. package/src/providers/provider-instance.ts +19 -1
  310. package/src/providers/provider-loader.ts +668 -50
  311. package/src/providers/provider-schema.ts +87 -14
  312. package/src/providers/provider-trust.ts +114 -0
  313. package/src/providers/read-chat-contract.ts +76 -16
  314. package/src/providers/sdk/README.md +49 -0
  315. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  316. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  317. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  318. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  319. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  320. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  321. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  322. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  323. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  324. package/src/providers/sdk/v1/index.ts +152 -0
  325. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  326. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  327. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  328. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  329. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  330. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  331. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  332. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  333. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  334. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  335. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  336. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  337. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  338. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  339. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  340. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  341. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  342. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  343. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  344. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  345. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  346. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  347. package/src/providers/sdk/v1/validators/index.ts +19 -0
  348. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  349. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  350. package/src/providers/spec/adapter.ts +235 -0
  351. package/src/providers/spec/cli-adapter.ts +1051 -0
  352. package/src/providers/spec/evaluator.ts +407 -0
  353. package/src/providers/spec/fsm-driver.ts +1305 -0
  354. package/src/providers/spec/fsm-evaluator.ts +272 -0
  355. package/src/providers/spec/fsm-loader.ts +120 -0
  356. package/src/providers/spec/fsm-types.ts +273 -0
  357. package/src/providers/spec/native-history-executor.ts +1136 -0
  358. package/src/providers/spec/pre-launch-trust.ts +104 -0
  359. package/src/providers/spec/route.ts +51 -0
  360. package/src/providers/spec/types.ts +262 -0
  361. package/src/providers/status-monitor.d.ts +7 -7
  362. package/src/providers/status-monitor.ts +37 -22
  363. package/src/providers/transcript-v2.ts +567 -0
  364. package/src/providers/types/interactive-prompt.ts +536 -0
  365. package/src/providers/version-archive.ts +64 -24
  366. package/src/providers/working-dir.ts +23 -0
  367. package/src/repo-mesh-types.ts +378 -14
  368. package/src/sessions/registry.ts +6 -0
  369. package/src/shared-types-extra.ts +2 -4
  370. package/src/shared-types.d.ts +8 -0
  371. package/src/shared-types.ts +64 -1
  372. package/src/status/builders.ts +26 -6
  373. package/src/status/normalize.ts +2 -0
  374. package/src/status/reporter.ts +19 -1
  375. package/src/status/snapshot.ts +95 -26
  376. package/src/system/hash.ts +23 -0
  377. package/src/system/host-memory.ts +29 -12
  378. package/src/system/load-better-sqlite3.ts +68 -0
  379. package/src/types.ts +5 -0
  380. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  381. package/dist/mesh/mesh-sync.d.ts +0 -53
  382. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  383. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  384. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,2866 @@
1
+ import { existsSync } from 'fs';
2
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
3
+ import { loadConfig } from '../config/config.js';
4
+ import { getMesh, getMeshByRepo, listMeshes } from '../config/mesh-config.js';
5
+ import { detectCLI } from '../detection/cli-detector.js';
6
+ import { LOG } from '../logging/logger.js';
7
+ import { appendLedgerEntry, buildTaskCompletionEvidence, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
8
+ import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
9
+ import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, getActiveDirectDispatches, hasPendingDependents } from './mesh-work-queue.js';
10
+ import type { MeshWorkQueueEntry } from './mesh-work-queue.js';
11
+ import { fastForwardMeshNode } from './mesh-fast-forward.js';
12
+ import { createSessionDelivery, markSessionDeliveriesTerminal, updateSessionDeliveryStatus, recordCompletionConflict } from './mesh-delivery-policy.js';
13
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
14
+ import { queuePendingMeshCoordinatorEvent, drainPendingMeshCoordinatorEvents } from './mesh-events-pending.js';
15
+ import type { PendingMeshCoordinatorEvent } from './mesh-events-pending.js';
16
+ import { resolveWorkerDelegateRouting, recordUnroutableDelegateEvent, isUnroutableDelegateRejection } from './mesh-routing.js';
17
+ import { enqueueUnresolvedDelegateForward, peekUnresolvedDelegateForwards, ackUnresolvedDelegateForward } from './mesh-unresolved-forward-outbox.js';
18
+ import { traceMeshEventStage, traceMeshEventDrop } from './mesh-event-trace.js';
19
+ import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
20
+ import { getLastDisplayMessage } from '../status/snapshot.js';
21
+ import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy } from '../repo-mesh-types.js';
22
+ import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
23
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
24
+ import {
25
+ findRecentTerminalLedgerEvidence,
26
+ hasDispatchAfterTerminal,
27
+ hasUnterminalDirectDispatchLedgerEntry,
28
+ buildNoProgressCompletionReconciliation,
29
+ } from './mesh-events-stale.js';
30
+ import {
31
+ buildMeshSystemMessage,
32
+ readNonEmptyString,
33
+ readRecord,
34
+ resolveEventSessionId,
35
+ readRefineJobId,
36
+ readWorkerResultMetadata,
37
+ resolveMeshSurfacedSessionPreview,
38
+ } from './mesh-events-utils.js';
39
+
40
+ // The set of coordinator-daemon ids this daemon answers to when draining the
41
+ // pending-events queue. Mirrors resolveCoordinatorDaemonIds in mesh-reconcile-loop:
42
+ // a unicast event may be stamped with the status id, the bare machineId, OR the
43
+ // config-form node daemonId (`daemon_<machineId>`) depending on which dispatch path
44
+ // created the worker. We expand to EVERY equivalent form so a `daemon_<machineId>`
45
+ // completion matches a coordinator that knows itself as bare `<machineId>` (the
46
+ // base-node completion-surface bug) and vice versa.
47
+ function resolveCoordinatorDrainDaemonIds(components: DaemonComponents): string[] {
48
+ const statusInstanceId = readNonEmptyString((components as { statusInstanceId?: string }).statusInstanceId);
49
+ const machineId = readNonEmptyString(loadConfig().machineId);
50
+ return expandDaemonIdForms([statusInstanceId, machineId]);
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Remote Node Idle Session Tracking
55
+ // ---------------------------------------------------------------------------
56
+ const REMOTE_IDLE_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Workspace-to-mesh lookup cache
60
+ // ---------------------------------------------------------------------------
61
+ const meshByWorkspaceCache = new Map<string, { mesh: any; cachedAt: number }>();
62
+ const MESH_WORKSPACE_CACHE_TTL_MS = 5_000;
63
+ const IDLE_AUTO_FAST_FORWARD_THROTTLE_MS = 30 * 60 * 1000;
64
+ const idleAutoFastForwardLastAttempt = new Map<string, number>();
65
+
66
+ function getCachedMeshByWorkspace(workspace: string): any {
67
+ const now = Date.now();
68
+ const cached = meshByWorkspaceCache.get(workspace);
69
+ if (cached && now - cached.cachedAt < MESH_WORKSPACE_CACHE_TTL_MS) return cached.mesh;
70
+ const mesh = getMeshByRepo(workspace);
71
+ meshByWorkspaceCache.set(workspace, { mesh, cachedAt: now });
72
+ return mesh;
73
+ }
74
+
75
+ // Deterministic meshId recovery for a forwarded worker event that carries no meshId.
76
+ // An unresolved-mesh worker (forwardUnresolvedDelegateEvent) cannot resolve its own
77
+ // meshId locally, so it pushes the event with nodeId + workspace only and relies on
78
+ // the coordinator — which hosts the mesh — to recover the id. Workspace recovery
79
+ // (getCachedMeshByWorkspace → getMeshByRepo) is the fast path but can miss (a worktree
80
+ // clone whose repoIdentity differs, or a transient cache state), which left the retry
81
+ // permanently rejected with "meshId required". The node-id IS a stable, coordinator-side
82
+ // fact: scan the hosted meshes for the one whose node matches the forwarded nodeId
83
+ // (3-form normalizer). This is timing-independent and never depends on repo lookup.
84
+ function recoverMeshIdByNodeId(nodeId: string): string {
85
+ if (!nodeId) return '';
86
+ for (const mesh of listMeshes()) {
87
+ if (Array.isArray(mesh.nodes) && mesh.nodes.some((n: any) => meshNodeIdMatches(n, nodeId))) {
88
+ return readNonEmptyString(mesh.id);
89
+ }
90
+ }
91
+ return '';
92
+ }
93
+
94
+ export function __resetIdleAutoFastForwardForTests(): void {
95
+ idleAutoFastForwardLastAttempt.clear();
96
+ }
97
+
98
+ export function __resetMeshWorkspaceCacheForTests(): void {
99
+ meshByWorkspaceCache.clear();
100
+ }
101
+
102
+ function sweepExpiredRemoteIdleSessions(): void {
103
+ try {
104
+ MeshRuntimeStore.getInstance().pruneExpiredRemoteIdleSessions();
105
+ } catch { /* best-effort */ }
106
+ }
107
+
108
+ function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
109
+ const localMesh = getMesh(meshId);
110
+ const cachedMesh = components.router?.getCachedInlineMesh(meshId);
111
+ if (!localMesh) return cachedMesh;
112
+ if (!cachedMesh) return localMesh;
113
+ return mergeInlineCacheOnlyNodes(localMesh, cachedMesh);
114
+ }
115
+
116
+ /**
117
+ * Claim-time membership view unification (CLAIMSTALL fix).
118
+ *
119
+ * The coordinator's claim path — triggerMeshQueue → autoLaunch candidate filter
120
+ * and the local/remote idle-session drain — reads mesh membership through
121
+ * getMeshWithCache, which historically returned the local-config mesh verbatim
122
+ * whenever one existed. A freshly cloned worktree node is registered ONLY into the
123
+ * router's inline mesh cache: clone_mesh_node's `meshRecord.inline` branch calls
124
+ * updateInlineMeshNode, NOT addNode, so the worktree node never reaches local
125
+ * config (meshes.json). The config-first view therefore omits the worktree node,
126
+ * while send_task — which resolves membership through getMeshForCommand(preferInline)
127
+ * over the same inline cache — sees it. That view asymmetry is the stall: a queue
128
+ * task pinned to the worktree node reports `target_node_id_unmatched` (autoLaunch
129
+ * candidate filter / targetPinUnmatched check) and the node's idle session is
130
+ * dropped from the drain pool (mesh.nodes.find miss), so claim never fires and the
131
+ * task is stranded pending — even though nodeId matching itself is correct.
132
+ *
133
+ * Fix: union the local-config nodes with any inline-cache-ONLY nodes, so the claim
134
+ * view matches the command (send_task) view. Base (non-worktree) nodes present in
135
+ * local config stay config-authoritative — their entry is taken verbatim from
136
+ * localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
137
+ * that exist solely in the inline cache (the cloned worktree nodes) are appended.
138
+ * Identity comparison uses the shared 3-form normalizer (id / nodeId / node_id),
139
+ * identical to every other claim-path consumer — the matching logic is untouched,
140
+ * only which nodes are visible.
141
+ */
142
+ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
143
+ const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
144
+ const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
145
+ if (!cachedNodes.length) return localMesh;
146
+ const cacheOnly = cachedNodes.filter((cachedNode: any) => {
147
+ const cachedId = readMeshNodeId(cachedNode);
148
+ // Unidentifiable cache entries can never be a claim/route target — skip them
149
+ // rather than appending junk that no consumer can address.
150
+ if (!cachedId) return false;
151
+ return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
152
+ });
153
+ if (!cacheOnly.length) return localMesh;
154
+ return { ...localMesh, nodes: [...localNodes, ...cacheOnly] };
155
+ }
156
+
157
+ const INTENTIONAL_CLEANUP_STOP_SUPPRESSION_MS = 30 * 60 * 1000;
158
+
159
+ function isIntentionalCleanupStopMetadata(event: Record<string, unknown>): boolean {
160
+ return event.intentional === true
161
+ || event.intentionalStop === true
162
+ || event.operatorCleanup === true
163
+ || event.reason === 'operator_cleanup'
164
+ || event.stopReason === 'operator_cleanup'
165
+ || event.cleanupReason === 'operator_cleanup'
166
+ || event.source === 'mesh_cleanup_sessions'
167
+ || event.source === 'mesh_remove_node';
168
+ }
169
+
170
+ function hasRecentIntentionalCleanupStop(meshId: string, sessionId?: string, nodeId?: string): boolean {
171
+ if (!sessionId && !nodeId) return false;
172
+ const cutoff = Date.now() - INTENTIONAL_CLEANUP_STOP_SUPPRESSION_MS;
173
+ const entries = readLedgerEntries(meshId, { tail: 200 });
174
+ for (let i = entries.length - 1; i >= 0; i--) {
175
+ const entry = entries[i];
176
+ const timestamp = new Date(entry.timestamp).getTime();
177
+ if (!Number.isNaN(timestamp) && timestamp < cutoff) break;
178
+ if (!isIntentionalCleanupStopEntry(entry)) continue;
179
+ if (sessionId && entry.sessionId === sessionId) return true;
180
+ // Normalized node-id match (P4): the cleanup-stop entry's node id may be stored as
181
+ // `nodeId` or `node_id` and the `nodeId` arg can be in either form — a raw `===`
182
+ // would miss a genuine intentional-cleanup entry and fail to suppress the stop event.
183
+ if (!sessionId && nodeId && meshNodeIdMatches(entry as unknown as MeshNodeIdentified, nodeId)) return true;
184
+ }
185
+ return false;
186
+ }
187
+
188
+ function shouldSuppressIntentionalCleanupStop(args: {
189
+ event: string;
190
+ meshId: string;
191
+ metadataEvent: Record<string, unknown>;
192
+ sessionId?: string;
193
+ nodeId?: string;
194
+ }): boolean {
195
+ if (args.event !== 'agent:stopped' && args.event !== 'monitor:no_progress') return false;
196
+ if (isIntentionalCleanupStopMetadata(args.metadataEvent)) return true;
197
+ return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
198
+ }
199
+
200
+ const RECENT_COMPLETION_FINGERPRINT_TTL_MS = 10 * 60 * 1000;
201
+
202
+ function hasFingerprintSeen(fingerprint: string): boolean {
203
+ try {
204
+ return MeshRuntimeStore.getInstance().hasCompletionFingerprint(fingerprint);
205
+ } catch {
206
+ return false;
207
+ }
208
+ }
209
+
210
+ function recordFingerprintSeen(fingerprint: string): void {
211
+ try {
212
+ const db = MeshRuntimeStore.getInstance();
213
+ db.recordCompletionFingerprint(fingerprint, RECENT_COMPLETION_FINGERPRINT_TTL_MS);
214
+ db.sweepExpiredFingerprints();
215
+ } catch { /* best-effort; duplicate events are preferable to a crash */ }
216
+ }
217
+
218
+ function readEventTimestamp(value: unknown): number | null {
219
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
220
+ if (typeof value === 'string' && value.trim()) {
221
+ const numeric = Number(value);
222
+ if (Number.isFinite(numeric)) return numeric;
223
+ const parsed = Date.parse(value);
224
+ if (Number.isFinite(parsed)) return parsed;
225
+ }
226
+ return null;
227
+ }
228
+
229
+ function buildMeshCompletionFingerprint(args: {
230
+ meshId: string;
231
+ event: string;
232
+ sessionId: string;
233
+ providerType?: string;
234
+ providerSessionId?: string;
235
+ timestamp?: number | null;
236
+ finalSummary?: string;
237
+ coordinatorDaemonId?: string;
238
+ }): string {
239
+ const timestampPart = Number.isFinite(args.timestamp)
240
+ ? String(args.timestamp)
241
+ : readNonEmptyString(args.finalSummary).slice(0, 200);
242
+ return [
243
+ args.meshId,
244
+ args.event,
245
+ args.sessionId,
246
+ args.providerType || '',
247
+ args.providerSessionId || '',
248
+ timestampPart,
249
+ args.coordinatorDaemonId || '',
250
+ ].join('::');
251
+ }
252
+
253
+ function isDuplicateMeshCompletionEvent(args: {
254
+ meshId: string;
255
+ event: string;
256
+ sessionId: string;
257
+ providerType?: string;
258
+ providerSessionId?: string;
259
+ timestamp?: number | null;
260
+ finalSummary?: string;
261
+ coordinatorDaemonId?: string;
262
+ taskId?: string;
263
+ nodeId?: string;
264
+ }): boolean {
265
+ const fingerprint = buildMeshCompletionFingerprint(args);
266
+ if (!fingerprint) return false;
267
+ if (hasFingerprintSeen(fingerprint)) {
268
+ if (args.taskId) {
269
+ recordCompletionConflict({
270
+ meshId: args.meshId,
271
+ fingerprint,
272
+ conflictingTaskId: args.taskId,
273
+ conflictingSessionId: args.sessionId,
274
+ event: args.event,
275
+ });
276
+ }
277
+ return true;
278
+ }
279
+ recordFingerprintSeen(fingerprint);
280
+ return false;
281
+ }
282
+
283
+ function isDuplicateMeshApprovalEvent(args: {
284
+ meshId: string;
285
+ sessionId: string;
286
+ providerType?: string;
287
+ timestamp?: number | null;
288
+ modalMessage?: string;
289
+ modalButtons?: unknown;
290
+ }): boolean {
291
+ const modalButtons = Array.isArray(args.modalButtons)
292
+ ? args.modalButtons.map(button => String(button).trim()).filter(Boolean)
293
+ : [];
294
+ const approvalIdentity = Number.isFinite(args.timestamp)
295
+ ? String(args.timestamp)
296
+ : JSON.stringify({ message: args.modalMessage || '', buttons: modalButtons });
297
+ if (!approvalIdentity || approvalIdentity === '{"message":"","buttons":[]}') return false;
298
+ const fingerprint = [
299
+ args.meshId,
300
+ 'agent:waiting_approval',
301
+ args.sessionId,
302
+ args.providerType || '',
303
+ approvalIdentity,
304
+ ].join('::');
305
+ if (hasFingerprintSeen(fingerprint)) return true;
306
+ recordFingerprintSeen(fingerprint);
307
+ return false;
308
+ }
309
+
310
+ function isDuplicateRefineTerminalEvent(meshId: string, eventName: string, metadataEvent: Record<string, unknown>): boolean {
311
+ const jobId = readRefineJobId({ metadataEvent });
312
+ const fingerprint = jobId && new Set(['refine:completed', 'refine:failed']).has(eventName) ? `${meshId}::${eventName}::${jobId}` : '';
313
+ if (!fingerprint) return false;
314
+ if (hasFingerprintSeen(fingerprint)) return true;
315
+ recordFingerprintSeen(fingerprint);
316
+ return false;
317
+ }
318
+
319
+ // A worker/coordinator "false idle": the provider dropped to idle WITHOUT a confirmed
320
+ // final assistant message for the turn (a finalization timeout, or a "scheduled fallback"
321
+ // idle). This is the signal cli-provider-instance emits as
322
+ // completionDiagnostic.blockReason='missing_final_assistant' / finalAssistantPresent=false.
323
+ // Such a completion is NOT trustworthy terminal evidence: it must neither permanently
324
+ // terminate a direct-dispatch task nor suppress the genuine completion a later turn
325
+ // (commonly driven by a coordinator nudge / re-dispatch) produces.
326
+ function isFalseIdleCompletion(metadataEvent: Record<string, unknown>): boolean {
327
+ const diag = readRecord(metadataEvent.completionDiagnostic);
328
+ if (!diag) return false;
329
+ return diag.finalAssistantPresent === false || diag.blockReason === 'missing_final_assistant';
330
+ }
331
+
332
+ // The genuine-completion counterpart: a real final summary / worker result is present and
333
+ // the completion is not flagged as a missing-final-assistant false idle. Used to decide
334
+ // whether a new completion may supersede a prior WEAK (false-idle) terminal.
335
+ function isGenuineCompletionEvidence(metadataEvent: Record<string, unknown>): boolean {
336
+ if (isFalseIdleCompletion(metadataEvent)) return false;
337
+ return !!readWorkerResultMetadata(metadataEvent) || !!readNonEmptyString(metadataEvent.finalSummary);
338
+ }
339
+
340
+ // True when a terminal ledger payload was recorded from WEAK completion evidence (a false
341
+ // idle): insufficient evidence level, review-recommended, or a missing-final-assistant
342
+ // completion diagnostic. A weak terminal is non-authoritative — a later genuine completion
343
+ // (live path) or a transcript reconcile (fallback path) may supersede it.
344
+ function isWeakTerminalLedgerPayload(payload: Record<string, unknown> | undefined): boolean {
345
+ if (!payload) return false;
346
+ if (payload.evidenceLevel === 'insufficient' || payload.reviewRecommended === true) return true;
347
+ const diag = readRecord(payload.completionDiagnostic);
348
+ return diag?.finalAssistantPresent === false || diag?.blockReason === 'missing_final_assistant';
349
+ }
350
+
351
+ // The latest still-active direct-dispatch taskId for a session, resolved BEFORE the
352
+ // completion flips the dispatch row terminal. Direct dispatches (mesh_send_task) have no
353
+ // work-queue row, so this is the only taskId available to attribute the terminal ledger
354
+ // entry (and thus mesh task-stats) to — without it the terminal carries no taskId and the
355
+ // task surfaces as status='unknown' / terminalKind=null in computeMeshTaskStats.
356
+ function resolveActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | undefined {
357
+ try {
358
+ const matches = getActiveDirectDispatches(meshId).filter(d => d.sessionId === sessionId);
359
+ if (!matches.length) return undefined;
360
+ // getActiveDirectDispatches returns rows ordered by dispatched_at ASC; the last is
361
+ // the most recent dispatch (the re-dispatch / nudge whose completion this is).
362
+ return readNonEmptyString(matches[matches.length - 1].taskId) || undefined;
363
+ } catch {
364
+ return undefined;
365
+ }
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Queue assignment
370
+ // ---------------------------------------------------------------------------
371
+
372
+ // Per-dispatch confirmation timeout (Bug B). A dispatch promise that never settles —
373
+ // a saturated remote P2P relay that hangs, or a transport that resolves only after
374
+ // the worker acks — would otherwise leave the just-claimed queue row 'assigned' with
375
+ // its delivery stuck 'delivering' forever: the .catch that requeues never fires, and
376
+ // PHASE 3 reconcile skips the row (it counts 0 pending). Racing the dispatch against
377
+ // this timeout guarantees a hung dispatch deterministically returns the task to
378
+ // 'pending' for re-dispatch. Generous so a merely-slow-but-live dispatch (a cold
379
+ // remote relay) is never reclaimed early; the reconcile assigned-stranded watchdog is
380
+ // the durable cross-restart backstop for a timer lost to a daemon restart.
381
+ const DISPATCH_CONFIRM_TIMEOUT_MS = 120_000;
382
+
383
+ // Cold-open connect budget for the warmup-aware REMOTE task dispatch deadline. A
384
+ // remote `agent_command` to a peer whose mesh DataChannel is not open yet first has
385
+ // to drive the cross-machine (often TURN-relayed) handshake; charging that warmup
386
+ // against the response budget is the same cold-open false-timeout the git_status
387
+ // probe path already guards against. This budget bounds ONLY the "channel not open
388
+ // yet" phase; once the channel is warm the DISPATCH_CONFIRM_TIMEOUT_MS response
389
+ // budget governs (identical to the legacy flat guard for an already-open peer, so
390
+ // no latency is added to a normal dispatch). Matches the daemon-cloud
391
+ // DaemonMeshManager CONNECT_TIMEOUT_MS (45s) so the caller-side deadline tracks the
392
+ // transport's own cold-open window rather than guessing.
393
+ const DISPATCH_CONNECT_TIMEOUT_MS = 45_000;
394
+
395
+ // Fail-loud (throttled) trace for a remote dispatch that ran with NO live mesh
396
+ // connection getter wired — the same degraded-warmup misconfiguration the git probe
397
+ // path warns about. Warn once per peer; resolveWarmupDeadlineOpts then falls back to
398
+ // the conservative combined budget instead of silently assuming "always warm".
399
+ const dispatchWarmupGetterMissingWarned = new Set<string>();
400
+ function warnDispatchWarmupGetterMissingOnce(daemonId: string): void {
401
+ if (dispatchWarmupGetterMissingWarned.has(daemonId)) return;
402
+ dispatchWarmupGetterMissingWarned.add(daemonId);
403
+ LOG.warn('MeshQueue', `Mesh peer connection getter unavailable for ${String(daemonId).slice(0, 12)}; remote task-dispatch warmup deadline degraded to the combined connect+response window. Avoids a cold-open false-timeout but loses warm/cold precision — wire getMeshPeerConnectionStatus on this daemon.`);
404
+ }
405
+
406
+ interface DeliverTaskContext {
407
+ meshId: string;
408
+ nodeId: string;
409
+ sessionId: string;
410
+ providerType: string;
411
+ task: MeshWorkQueueEntry;
412
+ transport: 'remote' | 'local';
413
+ sourceCoordinatorSessionId?: string;
414
+ sourceCoordinatorDaemonId?: string;
415
+ }
416
+
417
+ // CONS scope 3: the SINGLE source of truth for dispatching a claimed task to its
418
+ // session. The remote (P2P dispatchMeshCommand) and local (cliManager.handleCliCommand)
419
+ // branches differ ONLY in the transport call — the delivery record, the delivered/failed
420
+ // transitions, the pending-requeue-on-failure, the dispatch_failed ledger entry, AND the
421
+ // Bug B hang timeout are identical and live here once so a future change to the dispatch
422
+ // lifecycle cannot drift between the two paths. The caller passes a `dispatchThunk` that
423
+ // performs only the transport-specific send and returns its promise.
424
+ //
425
+ // Cold-open warmup (remote only): the REMOTE transport speaks over a P2P
426
+ // DataChannel that may still be opening when the first task is dispatched to a peer.
427
+ // When `warmup` is supplied the dispatch is awaited under the warmup-aware deadline
428
+ // (mesh-warmup-deadline) — the cold-open handshake is charged to the connect budget
429
+ // and only the warm round trip to the DISPATCH_CONFIRM_TIMEOUT_MS response budget —
430
+ // so the very first dispatch to a not-yet-open peer is no longer false-timed at the
431
+ // combined window. An already-open peer behaves identically to the legacy flat guard
432
+ // (response budget governs from t0), so a normal dispatch sees no added latency. The
433
+ // LOCAL transport (in-process cliManager) has no channel to warm up and keeps the
434
+ // flat Bug B hang guard.
435
+ function deliverTaskToSession(
436
+ dispatchThunk: () => Promise<unknown>,
437
+ ctx: DeliverTaskContext,
438
+ warmup?: { daemonId: string; getConnection?: (daemonId: string) => Record<string, unknown> | null },
439
+ ): void {
440
+ const delivery = createSessionDelivery({
441
+ meshId: ctx.meshId,
442
+ nodeId: ctx.nodeId,
443
+ sessionId: ctx.sessionId,
444
+ providerType: ctx.providerType,
445
+ taskId: ctx.task.id,
446
+ kind: 'task',
447
+ message: ctx.task.message,
448
+ status: 'delivering',
449
+ ...(ctx.sourceCoordinatorSessionId ? { sourceCoordinatorSessionId: ctx.sourceCoordinatorSessionId } : {}),
450
+ ...(ctx.sourceCoordinatorDaemonId ? { sourceCoordinatorDaemonId: ctx.sourceCoordinatorDaemonId } : {}),
451
+ });
452
+
453
+ // Invoke the transport synchronously (preserves the prior fire-and-forget timing,
454
+ // and lets a synchronous throw fall into the same failure path as a rejection).
455
+ let dispatchPromise: Promise<unknown>;
456
+ try {
457
+ dispatchPromise = Promise.resolve(dispatchThunk());
458
+ } catch (e) {
459
+ dispatchPromise = Promise.reject(e);
460
+ }
461
+
462
+ let timer: ReturnType<typeof setTimeout> | undefined;
463
+ let guarded: Promise<unknown>;
464
+ if (warmup) {
465
+ // Remote P2P: cold-open-aware deadline. awaitWithWarmupDeadline owns its own
466
+ // timers (so `timer` stays undefined and the clearTimeout below is a no-op),
467
+ // and rejects with Error('timeout') when either budget lapses — the same
468
+ // retryable failure shape the catch below already handles (requeue + ledger).
469
+ guarded = awaitWithWarmupDeadline(dispatchPromise, resolveWarmupDeadlineOpts({
470
+ getConnection: warmup.getConnection,
471
+ daemonId: warmup.daemonId,
472
+ connectTimeoutMs: DISPATCH_CONNECT_TIMEOUT_MS,
473
+ responseTimeoutMs: DISPATCH_CONFIRM_TIMEOUT_MS,
474
+ onMissingGetter: warnDispatchWarmupGetterMissingOnce,
475
+ }));
476
+ } else {
477
+ guarded = Promise.race([
478
+ dispatchPromise,
479
+ new Promise<never>((_, reject) => {
480
+ timer = setTimeout(
481
+ () => reject(new Error(`dispatch_confirm_timeout after ${DISPATCH_CONFIRM_TIMEOUT_MS}ms`)),
482
+ DISPATCH_CONFIRM_TIMEOUT_MS,
483
+ );
484
+ // Never keep the process alive solely for this confirm-timeout timer.
485
+ if (typeof (timer as { unref?: () => void })?.unref === 'function') (timer as { unref: () => void }).unref();
486
+ }),
487
+ ]);
488
+ }
489
+
490
+ guarded.then(() => {
491
+ if (timer) clearTimeout(timer);
492
+ updateSessionDeliveryStatus(delivery.id, 'delivered');
493
+ }).catch((e: any) => {
494
+ if (timer) clearTimeout(timer);
495
+ // A dispatch failure (transport reject OR hang timeout) is most often transient —
496
+ // a busy/refusing adapter, or a relay that never acked — not a permanent task
497
+ // failure. Marking the task terminal here would permanently kill tasks a later
498
+ // tick delivers fine. Return it to 'pending' and record a retryable dispatch_failed
499
+ // ledger entry so the reconcile loop re-dispatches it. Identical for both transports.
500
+ LOG.error('MeshQueue', `Failed to dispatch task via ${ctx.transport} to node ${ctx.nodeId}: ${e?.message}`);
501
+ updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
502
+ updateTaskStatus(ctx.meshId, ctx.task.id, 'pending');
503
+ try {
504
+ appendLedgerEntry(ctx.meshId, {
505
+ kind: 'dispatch_failed' as any,
506
+ nodeId: ctx.nodeId,
507
+ sessionId: ctx.sessionId,
508
+ payload: { taskId: ctx.task.id, deliveryId: delivery.id, error: e?.message, retryable: true, transport: ctx.transport },
509
+ });
510
+ } catch { /* ledger write is best-effort */ }
511
+ });
512
+ }
513
+
514
+ // WTCLAIM: workspace normalization for base-vs-worktree comparison now lives in
515
+ // @adhdev/mesh-shared (normalizeMeshWorkspaceForCompare) so the enqueue→claim path,
516
+ // the mesh_status per-node session filter, and the read_chat node scope guard all
517
+ // share one comparison rule instead of drifting module-private copies.
518
+
519
+ export function tryAssignQueueTask(
520
+ components: DaemonComponents,
521
+ meshId: string,
522
+ nodeId: string,
523
+ sessionId: string,
524
+ providerType: string
525
+ ): boolean {
526
+ const mesh = getMeshWithCache(components, meshId);
527
+ const node = mesh?.nodes.find((n: any) => readMeshNodeId(n) === nodeId);
528
+
529
+ // WTCLAIM (fix-B extended to the enqueue→claim path): a base-targeted task must never be
530
+ // claimed by — and dispatched into — a co-located worktree-clone session, nor vice versa.
531
+ // The drain candidate's nodeId is derived from settings.meshNodeId || settings.nodeId
532
+ // (triggerMeshQueue), so a worktree session whose meshNodeId is empty/stale falls back to
533
+ // settings.nodeId = the BASE node id and impersonates the base node here. fix-B's worker-side
534
+ // workspace scope only ran for sessionless dispatch (meshScopeNodeId && !targetSessionId); the
535
+ // claim path ALWAYS carries a targetSessionId, so it never engaged. Apply the same scope here:
536
+ // for a LOCAL claiming session (adapter resolvable on this daemon), require its actual
537
+ // workingDir to match the target node's declared workspace. On a confirmed mismatch, refuse the
538
+ // claim so the task returns to pending for the correctly-scoped session/node to pull. Scoped to
539
+ // local sessions where the workspace is verifiable — a remote session lives on another daemon
540
+ // whose paths we cannot compare here (and remote candidates are already nodeId-matched from
541
+ // getRemoteIdleSessions). Conservative by design: when either workspace is unknown we do NOT
542
+ // skip, so a node with no declared workspace keeps its prior behavior and no legitimate claim
543
+ // is starved.
544
+ // WTDISPATCH (residual of WTCLAIM): the cross-node claim guard must reach EVERY claiming
545
+ // session this daemon can observe — not only those whose adapter happens to be in
546
+ // cliManager.adapters. An auto-launched worker session can carry its node binding on the
547
+ // CLI-instance settings while its session-host record shows no_node_binding, and the
548
+ // event-driven / remote-idle drain (agent:ready → setRemoteIdleSession → tryAssignQueueTask)
549
+ // can pass a nodeId that does NOT belong to the claiming session — a sibling worktree node
550
+ // on the SAME daemon. The adapter-only WTCLAIM check (rc.361/4c5b30b1) never engaged for a
551
+ // session observed solely via instanceManager, so session A could pull node B's task and
552
+ // node A's task was left with no session to claim it (no task_dispatched — it never dispatches).
553
+ //
554
+ // Resolve the claiming session's REAL identity from the adapter workingDir, then fall back to
555
+ // the live CLI instance's workspace + its stamped meshNodeId, and refuse a claim that
556
+ // contradicts EITHER (fail-closed). Reuses the shared meshWorkspacesEquivalent / meshNodeIdMatches
557
+ // comparators — no new comparison logic. Conservative: when neither the workspace NOR the stamp
558
+ // is resolvable we do NOT refuse, so a node with no declared workspace keeps prior behavior and
559
+ // a genuinely remote (cross-daemon) candidate stays nodeId-matched from getRemoteIdleSessions.
560
+ const localClaimAdapter = components.cliManager?.adapters?.get(sessionId) as { workingDir?: string } | undefined;
561
+ let claimInstanceWorkspace = '';
562
+ let claimStampedNodeId = '';
563
+ try {
564
+ const claimState = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
565
+ claimInstanceWorkspace = readNonEmptyString(claimState?.workspace);
566
+ const claimSettings = (claimState?.settings as Record<string, unknown>) || {};
567
+ claimStampedNodeId = readNonEmptyString(claimSettings.meshNodeId);
568
+ } catch { /* best-effort — fall through to the conservative (no refuse) path */ }
569
+
570
+ const nodeWorkspaceRaw = readNonEmptyString(node?.workspace);
571
+ const sessionWorkspaceRaw = readNonEmptyString(localClaimAdapter?.workingDir) || claimInstanceWorkspace;
572
+
573
+ if (claimStampedNodeId && nodeId) {
574
+ // The session carries its OWN meshNodeId stamp — its authoritative node identity, set when
575
+ // the coordinator launched/dispatched it (mesh-routing trusts this stamp FIRST). When it
576
+ // matches the claim target the session genuinely belongs to this node, so the stamp settles
577
+ // it and the workspace heuristic is skipped (a base/worktree pair can legitimately share a
578
+ // workspace). When it does NOT match, the claim is a cross-node leak — refuse, fail-closed.
579
+ if (!meshNodeIdMatches({ id: claimStampedNodeId } as MeshNodeIdentified, nodeId)) {
580
+ LOG.info('MeshQueue', `WTDISPATCH: refusing claim for node ${nodeId} (${sessionId}) — session is bound to node "${claimStampedNodeId}" (cross-node claim blocked)`);
581
+ return false;
582
+ }
583
+ } else if (sessionWorkspaceRaw && nodeWorkspaceRaw && !meshWorkspacesEquivalent(sessionWorkspaceRaw, nodeWorkspaceRaw)) {
584
+ // No stamp (the no_node_binding worker) — fall back to the workspace to tell two co-located
585
+ // sibling worktree sessions apart. WTCLAIM, now reaching instanceManager-observable sessions
586
+ // too. Conservative: unknown workspace on either side → do NOT refuse (no legitimate claim
587
+ // starved; a genuinely remote cross-daemon candidate stays nodeId-matched as before).
588
+ LOG.info('MeshQueue', `WTCLAIM: refusing claim for node ${nodeId} (${sessionId}) — session workspace "${normalizeMeshWorkspaceForCompare(sessionWorkspaceRaw)}" ≠ node workspace "${normalizeMeshWorkspaceForCompare(nodeWorkspaceRaw)}" (cross-workspace dispatch blocked)`);
589
+ return false;
590
+ }
591
+
592
+ const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
593
+ // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy.providerRoles) layers
594
+ // on top of the global/taskMode caps — stricter wins. Resolved here where the
595
+ // claiming session's providerType + node policy are both known, then enforced
596
+ // inside the atomic claim transaction so concurrent claims can't overshoot it.
597
+ const providerMaxParallel = resolveProviderMaxParallel(node?.policy, providerType);
598
+ const task = claimNextTask(meshId, nodeId, sessionId, capabilityTags, {
599
+ providerType,
600
+ ...(providerMaxParallel !== undefined ? { providerMaxParallel } : {}),
601
+ });
602
+ if (!task) {
603
+ return false;
604
+ }
605
+
606
+ LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
607
+
608
+ if (node?.daemonId && components.dispatchMeshCommand) {
609
+ const isLocalNode = components.cliManager.adapters.has(sessionId);
610
+ if (!isLocalNode) {
611
+ const localDaemonIdForDispatch = readNonEmptyString(loadConfig().machineId) || undefined;
612
+ // (3) Originating coordinator session that enqueued this task — route its
613
+ // completion back to that exact session (multi-coordinator). Carried over P2P
614
+ // to the remote worker, which echoes it on its completion event.
615
+ const sourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId) || undefined;
616
+ const dispatchMeshCommand = components.dispatchMeshCommand;
617
+ const remoteDaemonId = node.daemonId;
618
+ // CONS3: only the transport call differs — everything else (delivery record,
619
+ // status transitions, requeue-on-failure, ledger, Bug B hang timeout) is in
620
+ // the shared deliverTaskToSession helper.
621
+ deliverTaskToSession(
622
+ () => dispatchMeshCommand(remoteDaemonId, 'agent_command', {
623
+ targetSessionId: sessionId,
624
+ cliType: providerType,
625
+ action: 'send_chat',
626
+ message: task.message,
627
+ meshContext: {
628
+ meshId,
629
+ nodeId,
630
+ taskId: task.id,
631
+ ...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
632
+ ...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
633
+ },
634
+ }),
635
+ {
636
+ meshId,
637
+ nodeId,
638
+ sessionId,
639
+ providerType,
640
+ task,
641
+ transport: 'remote',
642
+ ...(sourceCoordinatorSessionId ? { sourceCoordinatorSessionId } : {}),
643
+ ...(localDaemonIdForDispatch ? { sourceCoordinatorDaemonId: localDaemonIdForDispatch } : {}),
644
+ },
645
+ // Warmup-aware deadline: this dispatch can be the FIRST command to a
646
+ // peer whose mesh DataChannel is still opening — charge the cold-open
647
+ // handshake to the connect budget, not the response budget.
648
+ { daemonId: remoteDaemonId, getConnection: components.getMeshPeerConnectionStatus },
649
+ );
650
+ return true;
651
+ }
652
+ }
653
+
654
+ // Stamp mesh context onto the session so completion events route correctly
655
+ // via setupMeshEventForwarding. Without this, manually-opened idle sessions
656
+ // (mesh_launch_session without auto-launch) lack meshNodeFor/meshNodeId and
657
+ // agent:generating_completed is silently dropped as isMeshDelegate=false.
658
+ try {
659
+ const inst = components.instanceManager.getInstance(sessionId);
660
+ if (inst && typeof inst.updateSettings === 'function') {
661
+ // Adopting a (possibly manually-opened) local session as a worker: apply the
662
+ // delegated-worker auto-approve policy here too, so a session that was launched
663
+ // without autoApprove still auto-approves once the coordinator dispatches a task
664
+ // to it (the "approval notification fires only for certain delegated sessions"
665
+ // case). updateSettings preserves runtime mesh keys; passing autoApprove keeps it.
666
+ //
667
+ // This local-dispatch branch also runs on the coordinator daemon for a co-located
668
+ // session, so the coordinator daemon id IS this daemon's id. Stamp it alongside
669
+ // the node identity so the session is fully relay-safe (meshCoordinatorDaemonId is
670
+ // the anchor the forwarder keys on), matching what mesh_launch_session stamps.
671
+ const localDaemonId = readNonEmptyString(loadConfig().machineId);
672
+ const localSourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId);
673
+ inst.updateSettings({
674
+ meshNodeFor: meshId,
675
+ meshNodeId: nodeId,
676
+ launchedByCoordinator: true,
677
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
678
+ ...(localDaemonId ? { meshCoordinatorDaemonId: localDaemonId } : {}),
679
+ // (3) Stamp the originating coordinator session for session-anchored routing
680
+ // of this co-located worker's completion. Absent → daemon-level fallback.
681
+ ...(localSourceCoordinatorSessionId ? { meshCoordinatorSessionId: localSourceCoordinatorSessionId } : {}),
682
+ });
683
+ }
684
+ } catch { /* best-effort — dispatch still proceeds */ }
685
+
686
+ // CONS3: same shared dispatch lifecycle as the remote branch — only the transport
687
+ // (cliManager.handleCliCommand) differs.
688
+ deliverTaskToSession(
689
+ () => components.cliManager.handleCliCommand('agent_command', {
690
+ targetSessionId: sessionId,
691
+ cliType: providerType,
692
+ action: 'send_chat',
693
+ message: task.message,
694
+ }),
695
+ {
696
+ meshId,
697
+ nodeId,
698
+ sessionId,
699
+ providerType,
700
+ task,
701
+ transport: 'local',
702
+ ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { sourceCoordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
703
+ ...(readNonEmptyString(loadConfig().machineId) ? { sourceCoordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
704
+ },
705
+ );
706
+
707
+ return true;
708
+ }
709
+
710
+ const autoLaunchInProgress = new Set<string>();
711
+ const autoLaunchCooldownUntil = new Map<string, number>();
712
+ const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
713
+ // A remote auto-launch (launch_cli forward) is fire-and-async: the worker session
714
+ // spawns, reaches idle, emits agent:ready, that ready is queued on the worker, pulled
715
+ // by this coordinator (reconcile PHASE 1), and only THEN claims the task. That round
716
+ // trip routinely exceeds the 5s per-(mesh,node) cooldown, so cooldown alone lets the
717
+ // reconcile loop fire a SECOND launch for the same still-pending task before the first
718
+ // session's claim lands — every tick spawns yet another orphan session (observed live:
719
+ // 26 sessions for one task). This is a per-TASK await-claim window: once a task has a
720
+ // successfully-launched session whose claim we are still waiting on, do not launch it
721
+ // again until the window lapses. It is generous (a slow remote spawn can take tens of
722
+ // seconds) but bounded so a launch that silently never reaches idle is eventually retried.
723
+ const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
724
+
725
+ // De-dup for repeated `skipped` ledger noise: the reconcile loop re-runs the queue
726
+ // trigger every 4s, so a task that can't be claimed (e.g. a remote node with no
727
+ // transport, or a node under cooldown) would otherwise append an identical
728
+ // session_auto_launch{phase:'skipped'} entry on every tick — flooding the ledger.
729
+ // We suppress a `skipped` ledger append when the immediately-prior recorded event
730
+ // for that task was the SAME (phase, reason). Any non-skip phase (started/failed/
731
+ // completed) or a changed reason resets the de-dup so real transitions still record.
732
+ const lastAutoLaunchLedgerKey = new Map<string, string>();
733
+ const AUTO_LAUNCH_LEDGER_DEDUP_MAX = 2000;
734
+
735
+ function sweepExpiredCooldowns(): void {
736
+ const now = Date.now();
737
+ for (const [key, until] of autoLaunchCooldownUntil) {
738
+ if (now >= until) autoLaunchCooldownUntil.delete(key);
739
+ }
740
+ }
741
+
742
+ function normalizeProviderPriority(policy: unknown): string[] {
743
+ const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
744
+ ? (policy as Record<string, unknown>).providerPriority
745
+ : undefined;
746
+ if (!Array.isArray(raw)) return [];
747
+ const seen = new Set<string>();
748
+ return raw
749
+ .map(type => typeof type === 'string' ? type.trim() : '')
750
+ .filter(Boolean)
751
+ .filter(type => {
752
+ if (seen.has(type)) return false;
753
+ seen.add(type);
754
+ return true;
755
+ });
756
+ }
757
+
758
+ function isTerminalSessionStatus(status: string): boolean {
759
+ return ['stopped', 'failed', 'terminated', 'exited', 'closed'].includes(status);
760
+ }
761
+
762
+ function isIdleSessionState(state: any): boolean {
763
+ const status = readNonEmptyString(state?.status).toLowerCase();
764
+ if (isTerminalSessionStatus(status)) return false;
765
+ return status === 'idle' || state?.activeChat?.status === 'waiting_input';
766
+ }
767
+
768
+ function isDirtyNode(node: any): boolean {
769
+ return node?.health === 'dirty' || node?.git?.dirty === true;
770
+ }
771
+
772
+ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?: number; requireCleanSubmodules: boolean } {
773
+ const record = mesh?.policy?.autoFastForward && typeof mesh.policy.autoFastForward === 'object' && !Array.isArray(mesh.policy.autoFastForward)
774
+ ? mesh.policy.autoFastForward as Record<string, unknown>
775
+ : {};
776
+ const maxBehind = Number(record.maxBehind);
777
+ return {
778
+ enabled: record.enabled !== false,
779
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
780
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
781
+ };
782
+ }
783
+
784
+ function sessionStateLooksActive(state: any): boolean {
785
+ const status = readNonEmptyString(state?.status).toLowerCase();
786
+ const chatStatus = readNonEmptyString(state?.activeChat?.status).toLowerCase();
787
+ // 'long_generating' is retained as a legacy alias for the renamed 'no_progress' busy status.
788
+ const active = new Set(['generating', 'streaming', 'no_progress', 'long_generating', 'working', 'starting', 'waiting_approval']);
789
+ return active.has(status) || active.has(chatStatus);
790
+ }
791
+
792
+ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nodeId: string, currentSessionId?: string): boolean {
793
+ if (nodeHasActiveAssignment(meshId, nodeId)) return true;
794
+ return components.instanceManager.getByCategory('cli').some((inst: any) => {
795
+ const state = inst.getState();
796
+ const settings = state.settings as Record<string, unknown> || {};
797
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
798
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
799
+ if (instNodeId !== nodeId) return false;
800
+ const sessionId = readNonEmptyString(state.instanceId);
801
+ if (currentSessionId && sessionId === currentSessionId && isIdleSessionState(state)) return false;
802
+ return sessionStateLooksActive(state);
803
+ });
804
+ }
805
+
806
+ function isLaunchableNode(node: any): boolean {
807
+ if (!node || node.status === 'disabled' || node.status === 'removed') return false;
808
+ const health = readNonEmptyString(node.health).toLowerCase();
809
+ if (!health) return true;
810
+ return health === 'online' || health === 'unknown';
811
+ }
812
+
813
+ /** Whether a mesh node's daemon/machine identity resolves to THIS coordinator daemon
814
+ * (i.e. the queue session can be spawned by a direct local `launch_cli`). */
815
+ function isLocalAutoLaunchNode(node: any): boolean {
816
+ const daemonId = readNonEmptyString(node?.daemonId);
817
+ const machineId = readNonEmptyString(node?.machineId);
818
+ const appConfig = loadConfig();
819
+ const localMachineId = readNonEmptyString(appConfig.machineId) || readNonEmptyString(appConfig.registeredMachineId);
820
+
821
+ // Route through the canonical daemon-id equivalence helper so a node carrying the
822
+ // bare `mach_<hex>` form (not just the reassembled `daemon_`/`standalone_` prefixed
823
+ // forms) resolves to THIS coordinator instead of being misjudged as remote.
824
+ const daemonMatchesLocal = !daemonId || daemonIdsEquivalent(daemonId, localMachineId);
825
+ const machineMatchesLocal = !machineId || (!!localMachineId && machineId === localMachineId);
826
+
827
+ if (node?.isLocalWorktree === true) {
828
+ return daemonMatchesLocal && machineMatchesLocal;
829
+ }
830
+ if (daemonId || machineId) {
831
+ return daemonMatchesLocal && machineMatchesLocal;
832
+ }
833
+ return true;
834
+ }
835
+
836
+ /**
837
+ * Resolve how a pending queue task should be auto-launched onto a node.
838
+ *
839
+ * - `local`: spawn directly on this daemon via cliManager.handleCliCommand('launch_cli').
840
+ * - `remote`: forward `launch_cli` to the node's daemon via dispatchMeshCommand
841
+ * (mirrors what mesh_launch_session does). Requires dispatchMeshCommand AND a
842
+ * resolvable coordinator daemonId for relay-safe completion routing.
843
+ * - `skip`: not launchable from here — carries the reason (e.g. a remote node with
844
+ * no dispatch transport, or no coordinator daemonId to stamp).
845
+ */
846
+ function resolveAutoLaunchTarget(components: DaemonComponents, node: any): {
847
+ mode: 'local' | 'remote' | 'skip';
848
+ reason?: string;
849
+ daemonId?: string;
850
+ coordinatorDaemonId?: string;
851
+ } {
852
+ if (isLocalAutoLaunchNode(node)) return { mode: 'local' };
853
+
854
+ // Remote node. Forwarding the launch is possible only with a dispatch transport
855
+ // (cloud mode) plus a coordinator daemonId to stamp into the worker so completion
856
+ // events route back here. Without either, fall back to a graceful skip.
857
+ const daemonId = readNonEmptyString(node?.daemonId);
858
+ if (!daemonId) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
859
+ if (!components.dispatchMeshCommand) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
860
+ const coordinatorDaemonId = readNonEmptyString(loadConfig().machineId);
861
+ if (!coordinatorDaemonId) return { mode: 'skip', reason: 'remote_auto_launch_no_coordinator_daemon_id' };
862
+ return { mode: 'remote', daemonId, coordinatorDaemonId };
863
+ }
864
+
865
+ function activeAssignedCount(meshId: string): number {
866
+ return getQueue(meshId, { status: ['assigned'] as any }).length;
867
+ }
868
+
869
+ /** Active assignments that hold the one-active-per-node / global-parallel invariant
870
+ * (everything except read-only diagnoses, which run unbounded by the write cap). */
871
+ export function activeWriteAssignedCount(meshId: string): number {
872
+ return getQueue(meshId, { status: ['assigned'] as any })
873
+ .filter(task => task.taskMode !== 'live_debug_readonly').length;
874
+ }
875
+
876
+ /** Active read-only (live_debug_readonly) assignments, for the read-only safety cap. */
877
+ export function activeReadonlyAssignedCount(meshId: string): number {
878
+ return getQueue(meshId, { status: ['assigned'] as any })
879
+ .filter(task => task.taskMode === 'live_debug_readonly').length;
880
+ }
881
+
882
+ function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
883
+ return getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedNodeId === nodeId);
884
+ }
885
+
886
+ /** Active (status='assigned') task count for a node — the load metric for
887
+ * least-loaded / round-robin ranking. Lower = preferred. */
888
+ function nodeActiveLoad(meshId: string, nodeId: string): number {
889
+ return MeshRuntimeStore.getInstance().nodeActiveAssignmentCount(meshId, nodeId);
890
+ }
891
+
892
+ /**
893
+ * The mesh-wide scheduling strategy. Defaults to 'first_eligible' (strict
894
+ * no-change) for any mesh that does not set it. Only governs the final tie-break;
895
+ * eligibility, capacity, and priority gates apply identically to every strategy.
896
+ */
897
+ function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
898
+ return normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
899
+ }
900
+
901
+ /**
902
+ * Order eligible nodes for assignment per the mesh scheduling pipeline:
903
+ * PRIORITY (schedulingPriority desc) → TIE-BREAK (strategy).
904
+ *
905
+ * The caller has already applied the TAG hard-filter and is responsible for the
906
+ * MAX-ALLOC capacity gate (the per-node launch/claim checks). This function only
907
+ * decides the *preference order* among nodes that are otherwise eligible.
908
+ *
909
+ * - 'first_eligible' (default): returns the input order verbatim and does NOT touch
910
+ * the round-robin cursor — byte-for-byte the pre-feature behavior.
911
+ * - 'priority_only': schedulingPriority desc, then input order (load ignored).
912
+ * - 'least_loaded': schedulingPriority desc, then active load asc, then input order.
913
+ * - 'round_robin': same as least_loaded, but among nodes tied at (priority, load)
914
+ * the input order is rotated by a per-mesh cursor that advances once per pass.
915
+ *
916
+ * `nodes` carries the original config/array index so the tie-break can fall back to
917
+ * deterministic input order. `bumpCursor` advances the round-robin cursor exactly
918
+ * once per scheduling pass (only consulted for 'round_robin').
919
+ */
920
+ interface RankableNode { nodeId: string; node: any; index: number }
921
+
922
+ /** Test-only: the pure node-ordering stage (PRIORITY → TIE-BREAK). Exposed so the
923
+ * scheduling pipeline can be unit-tested without standing up live CLI sessions. */
924
+ export function __orderEligibleNodesForTests(
925
+ meshId: string,
926
+ strategy: RepoMeshSchedulingStrategy,
927
+ nodes: RankableNode[],
928
+ opts?: { bumpCursor?: boolean },
929
+ ): RankableNode[] {
930
+ return orderEligibleNodes(meshId, strategy, nodes, opts);
931
+ }
932
+
933
+ function orderEligibleNodes(
934
+ meshId: string,
935
+ strategy: RepoMeshSchedulingStrategy,
936
+ nodes: RankableNode[],
937
+ opts?: { bumpCursor?: boolean },
938
+ ): RankableNode[] {
939
+ if (strategy === 'first_eligible' || nodes.length <= 1) {
940
+ return nodes;
941
+ }
942
+
943
+ const priorityOf = (n: { node: any }) => resolveNodeSchedulingPriority(n.node?.policy);
944
+
945
+ // Round-robin rotation offset: rotate the deterministic input order by a
946
+ // per-mesh cursor so the tie-break winner among equal (priority, load) nodes
947
+ // cycles across passes. The cursor advances once per scheduling pass.
948
+ let rotation = 0;
949
+ if (strategy === 'round_robin') {
950
+ const cursor = opts?.bumpCursor
951
+ ? MeshRuntimeStore.getInstance().bumpSchedulerCursor(meshId)
952
+ : MeshRuntimeStore.getInstance().getSchedulerCursor(meshId);
953
+ rotation = ((cursor % nodes.length) + nodes.length) % nodes.length;
954
+ }
955
+
956
+ // Rotation rank: position of each node after rotating input order by `rotation`.
957
+ // For non-round-robin strategies rotation is 0, so this is just the input index.
958
+ const rotationRank = (index: number) => (index - rotation + nodes.length) % nodes.length;
959
+
960
+ return [...nodes].sort((a, b) => {
961
+ const prioDelta = priorityOf(b) - priorityOf(a); // higher priority first
962
+ if (prioDelta !== 0) return prioDelta;
963
+ if (strategy === 'least_loaded' || strategy === 'round_robin') {
964
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
965
+ if (loadDelta !== 0) return loadDelta;
966
+ }
967
+ return rotationRank(a.index) - rotationRank(b.index);
968
+ });
969
+ }
970
+
971
+ /** Active assignments on a (node, provider) — pre-launch guard for the per-(node,
972
+ * provider) maxParallel cap. The authoritative enforcement is in the claim
973
+ * transaction; this only avoids spawning a session that would fail the claim. */
974
+ function activeProviderAssignedCount(meshId: string, nodeId: string, providerType: string): number {
975
+ return getQueue(meshId, { status: ['assigned'] as any })
976
+ .filter(task => task.assignedNodeId === nodeId && task.assignedProviderType === providerType).length;
977
+ }
978
+
979
+ function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
980
+ if (getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId)) {
981
+ return true;
982
+ }
983
+ // Direct dispatches (mesh_send_task) are tracked in mesh_direct_dispatches, not the
984
+ // work queue. A session completing a still-active direct dispatch IS an active
985
+ // assignment — without this, findRecentTerminalLedgerEvidence dedup wrongly suppresses
986
+ // the canonical agent:generating_completed for direct-dispatch tasks (validation/general),
987
+ // so the coordinator polling get_pending_mesh_events never observes task_completed and the
988
+ // session goes silently idle. This check runs before markSessionTerminal marks the
989
+ // dispatch terminal, so the in-flight dispatch is still observable here.
990
+ try {
991
+ if (getActiveDirectDispatches(meshId).some(d => d.sessionId === sessionId)) return true;
992
+ if (hasUnterminalDirectDispatchLedgerEntry(meshId, sessionId)) return true;
993
+ } catch { /* best-effort — fall through to false */ }
994
+ return false;
995
+ }
996
+
997
+ function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
998
+ return components.instanceManager.getByCategory('cli').filter((inst: any) => {
999
+ const state = inst.getState();
1000
+ const settings = state.settings as Record<string, unknown> || {};
1001
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
1002
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1003
+ if (instNodeId !== nodeId) return false;
1004
+ const status = readNonEmptyString(state.status).toLowerCase();
1005
+ return !isTerminalSessionStatus(status);
1006
+ }).length;
1007
+ }
1008
+
1009
+ function recordAutoLaunchEvent(meshId: string, args: {
1010
+ phase: 'skipped' | 'started' | 'failed' | 'completed';
1011
+ taskId: string;
1012
+ nodeId?: string;
1013
+ providerType?: string;
1014
+ sessionId?: string;
1015
+ reason?: string;
1016
+ error?: string;
1017
+ }) {
1018
+ // Suppress consecutive identical `skipped` entries for the same task (4s reconcile
1019
+ // re-trigger noise). Non-skip phases and changed reasons always record and reset
1020
+ // the de-dup so genuine state transitions remain visible in the ledger.
1021
+ const dedupKey = `${meshId}:${args.taskId}`;
1022
+ const currentSig = `${args.phase}|${args.reason || ''}`;
1023
+ if (args.phase === 'skipped' && lastAutoLaunchLedgerKey.get(dedupKey) === currentSig) {
1024
+ return;
1025
+ }
1026
+ lastAutoLaunchLedgerKey.set(dedupKey, currentSig);
1027
+ if (lastAutoLaunchLedgerKey.size > AUTO_LAUNCH_LEDGER_DEDUP_MAX) {
1028
+ // Bound memory: drop the oldest insertion (Map preserves insertion order).
1029
+ const oldest = lastAutoLaunchLedgerKey.keys().next().value;
1030
+ if (oldest !== undefined) lastAutoLaunchLedgerKey.delete(oldest);
1031
+ }
1032
+ try {
1033
+ appendLedgerEntry(meshId, {
1034
+ kind: 'session_auto_launch',
1035
+ nodeId: args.nodeId,
1036
+ sessionId: args.sessionId,
1037
+ providerType: args.providerType,
1038
+ payload: {
1039
+ phase: args.phase,
1040
+ taskId: args.taskId,
1041
+ reason: args.reason,
1042
+ error: args.error,
1043
+ },
1044
+ });
1045
+ } catch (e: any) {
1046
+ LOG.warn('MeshQueue', `Failed to record auto-launch ledger event: ${e?.message || e}`);
1047
+ }
1048
+ }
1049
+
1050
+ function markAutoLaunch(meshId: string, taskId: string, args: {
1051
+ status: 'skipped' | 'started' | 'failed' | 'completed';
1052
+ reason?: string;
1053
+ nodeId?: string;
1054
+ providerType?: string;
1055
+ sessionId?: string;
1056
+ error?: string;
1057
+ }) {
1058
+ recordTaskAutoLaunch(meshId, taskId, {
1059
+ status: args.status,
1060
+ reason: args.reason || args.error,
1061
+ nodeId: args.nodeId,
1062
+ providerType: args.providerType,
1063
+ sessionId: args.sessionId,
1064
+ });
1065
+ recordAutoLaunchEvent(meshId, {
1066
+ phase: args.status,
1067
+ taskId,
1068
+ nodeId: args.nodeId,
1069
+ providerType: args.providerType,
1070
+ sessionId: args.sessionId,
1071
+ reason: args.reason,
1072
+ error: args.error,
1073
+ });
1074
+ }
1075
+
1076
+ async function resolveUsableProvider(
1077
+ components: DaemonComponents,
1078
+ nodeId: string,
1079
+ node: any,
1080
+ requiredTags?: string[],
1081
+ ): Promise<{ providerType?: string; reason?: string }> {
1082
+ const providerPriority = normalizeProviderPriority(node?.policy);
1083
+ if (!providerPriority.length) return { reason: 'missing_provider_priority' };
1084
+ const providerLoader = components.providerLoader;
1085
+ if (!providerLoader) return { reason: 'provider_loader_unavailable' };
1086
+
1087
+ const failed: string[] = [];
1088
+ for (const requestedType of providerPriority) {
1089
+ const normalizedType = typeof providerLoader.resolveAlias === 'function'
1090
+ ? providerLoader.resolveAlias(requestedType)
1091
+ : requestedType;
1092
+ // Skip providers that can't satisfy the task's requiredTags (e.g. provider=hermes-cli
1093
+ // means only hermes-cli qualifies, not any other type in providerPriority).
1094
+ if (requiredTags?.length && !nodeSatisfiesRequiredTags(requiredTags, buildMeshNodeCapabilityTags(node, normalizedType))) {
1095
+ failed.push(`${requestedType}: required_tags_mismatch`);
1096
+ continue;
1097
+ }
1098
+ if (typeof providerLoader.isMachineProviderEnabled === 'function' && !providerLoader.isMachineProviderEnabled(normalizedType)) {
1099
+ failed.push(`${requestedType}: disabled`);
1100
+ continue;
1101
+ }
1102
+ let detected: any;
1103
+ try {
1104
+ detected = await detectCLI(normalizedType, providerLoader, { includeVersion: false });
1105
+ } catch (e: any) {
1106
+ failed.push(`${requestedType}: detect failed: ${e?.message || e}`);
1107
+ continue;
1108
+ }
1109
+ if (typeof providerLoader.setCliDetectionResults === 'function') {
1110
+ providerLoader.setCliDetectionResults([{
1111
+ id: normalizedType,
1112
+ installed: !!detected,
1113
+ path: detected?.path,
1114
+ }], false);
1115
+ }
1116
+ (components as any).onStatusChange?.();
1117
+ if (detected) return { providerType: normalizedType };
1118
+ failed.push(`${requestedType}: not detected`);
1119
+ }
1120
+ return { reason: `provider_priority_unusable: ${failed.join('; ') || nodeId}` };
1121
+ }
1122
+
1123
+ // Canonical mesh node-id normalization. A node may arrive from the local config
1124
+ // form (`id`) or the inline-cache form (`nodeId`/`node_id`) — see
1125
+ // readInlineMeshNodeId in commands/router.ts. Comparing only `node.id` against a
1126
+ // task.targetNodeId silently drops inline-cached worktree nodes, leaving a
1127
+ // target-routed task permanently pending with a misleading
1128
+ // `no_node_satisfies_required_tags` skip.
1129
+ function readMeshNodeId(node: any): string {
1130
+ // Delegate to the shared 3-way (id / nodeId / node_id) normalizer so this
1131
+ // and every other mesh node-id read agree on identity. Coalesce to '' to
1132
+ // preserve the existing string return contract for callers that do
1133
+ // `=== task.targetNodeId` / `if (!nodeId)`.
1134
+ return normalizeMeshNodeId(node) ?? '';
1135
+ }
1136
+
1137
+ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, meshId: string, mesh: any): Promise<boolean> {
1138
+ const queue = getQueue(meshId);
1139
+ const pending = queue.filter(task => task.status === 'pending');
1140
+ if (!pending.length) return false;
1141
+
1142
+ const maxParallelTasks = Math.max(1, Math.floor(Number(mesh?.policy?.maxParallelTasks) || 2));
1143
+ // Read-only diagnoses carry no isolation/merge cost, so they are exempt from the
1144
+ // write-task parallel cap. To prevent runaway auto-launch they get their own,
1145
+ // higher safety cap (2x the write cap).
1146
+ const maxReadonlyParallelTasks = Math.max(2, maxParallelTasks * 2);
1147
+ for (const task of pending) {
1148
+ const isReadonly = task.taskMode === 'live_debug_readonly';
1149
+ if (isReadonly) {
1150
+ if (activeReadonlyAssignedCount(meshId) >= maxReadonlyParallelTasks) {
1151
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_readonly_parallel_tasks_reached' });
1152
+ continue;
1153
+ }
1154
+ } else if (activeWriteAssignedCount(meshId) >= maxParallelTasks) {
1155
+ // Write tasks are capped; skip this one but keep scanning so a later
1156
+ // read-only task in the queue can still launch under its own cap.
1157
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_parallel_tasks_reached' });
1158
+ continue;
1159
+ }
1160
+ if (task.targetSessionId) {
1161
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_constraint' });
1162
+ continue;
1163
+ }
1164
+
1165
+ // Per-task await-claim guard. A prior auto-launch already spawned a session for
1166
+ // this task and we are waiting for that session's idle→claim to land (remote
1167
+ // claims arrive via the worker→coordinator agent:ready pull, which can lag well
1168
+ // past the per-node cooldown). Re-launching now would spawn a duplicate orphan
1169
+ // session that never gets work. The task leaves `pending` the instant the claim
1170
+ // succeeds, so this guard only suppresses the in-flight window; if the launched
1171
+ // session never reaches idle within the window, a later tick retries.
1172
+ if (task.autoLaunch?.status === 'completed' && task.autoLaunch.sessionId) {
1173
+ const launchedAtMs = Date.parse(task.autoLaunch.updatedAt);
1174
+ if (Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS) {
1175
+ // Record the skip in the ledger ONLY (dedup'd). Do NOT call markAutoLaunch
1176
+ // here: recordTaskAutoLaunch overwrites task.autoLaunch wholesale, which would
1177
+ // erase the very `completed` record (status + sessionId + updatedAt) this guard
1178
+ // reads on the next tick, reopening the duplicate-launch hole it closes.
1179
+ recordAutoLaunchEvent(meshId, { phase: 'skipped', taskId: task.id, reason: 'awaiting_launched_session_claim', nodeId: task.autoLaunch.nodeId, sessionId: task.autoLaunch.sessionId });
1180
+ continue;
1181
+ }
1182
+ }
1183
+
1184
+ const candidateNodes = Array.isArray(mesh?.nodes)
1185
+ ? mesh.nodes.filter((node: any) => {
1186
+ // Bug A: match the target pin with the shared 3-form (id / nodeId / node_id)
1187
+ // normalizer, mirroring the remote-idle drain (meshNodeIdMatches at the
1188
+ // getRemoteIdleSessions filter). A strict `readMeshNodeId(node) !== targetNodeId`
1189
+ // dropped a target node whose identity arrived under a different form (a freshly
1190
+ // mesh_clone_node'd worktree), emptying candidateNodes and mislabelling the skip.
1191
+ if (task.targetNodeId && !meshNodeIdMatches(node, task.targetNodeId)) return false;
1192
+ // Skip nodes that can never satisfy requiredTags regardless of which provider
1193
+ // from providerPriority is selected. A node satisfies tags if at least one
1194
+ // provider in its priority list would produce matching capability tags.
1195
+ if (task.requiredTags?.length) {
1196
+ const priorities = normalizeProviderPriority(node?.policy);
1197
+ const providerCandidates = priorities.length ? priorities : [undefined as unknown as string];
1198
+ return providerCandidates.some(p =>
1199
+ nodeSatisfiesRequiredTags(task.requiredTags, buildMeshNodeCapabilityTags(node, p))
1200
+ );
1201
+ }
1202
+ return true;
1203
+ })
1204
+ : [];
1205
+ if (!candidateNodes.length) {
1206
+ // Bug A: distinguish the two ways the candidate set empties. A task pinned to a
1207
+ // targetNodeId whose node is absent from the mesh (or whose id arrived under a
1208
+ // different form) is a ROUTING miss — report it as `target_node_id_unmatched`, not
1209
+ // the hard-coded `no_node_satisfies_required_tags`, which mislabelled a 3-form
1210
+ // node-id mismatch as a capability failure and sent diagnosis down the wrong path.
1211
+ // Only fall back to the tag reason when no target pin is in play, or the pin DID
1212
+ // match a node but its tags excluded it (a genuine capability miss).
1213
+ const targetPinUnmatched = !!task.targetNodeId
1214
+ && !(Array.isArray(mesh?.nodes) && mesh.nodes.some((n: any) => meshNodeIdMatches(n, task.targetNodeId)));
1215
+ markAutoLaunch(meshId, task.id, {
1216
+ status: 'skipped',
1217
+ reason: targetPinUnmatched ? 'target_node_id_unmatched' : 'no_node_satisfies_required_tags',
1218
+ nodeId: task.targetNodeId,
1219
+ });
1220
+ continue;
1221
+ }
1222
+
1223
+ // PRIORITY → TIE-BREAK: order the eligible (TAG-filtered) candidate nodes by
1224
+ // the mesh scheduling strategy. 'first_eligible' (default) returns them in
1225
+ // config/array order unchanged, so distribution is strictly opt-in. The
1226
+ // per-node MAX-ALLOC capacity gate (nodeHasActiveAssignment, provider cap,
1227
+ // maxConcurrentSessions) is still applied inside the loop below; this only
1228
+ // chooses which eligible node is *tried first*.
1229
+ const strategy = resolveSchedulingStrategy(mesh);
1230
+ const orderedCandidateNodes = strategy === 'first_eligible'
1231
+ ? candidateNodes
1232
+ : orderEligibleNodes(
1233
+ meshId,
1234
+ strategy,
1235
+ candidateNodes
1236
+ .map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
1237
+ .filter((c: RankableNode) => c.nodeId),
1238
+ { bumpCursor: true },
1239
+ ).map((c: RankableNode) => c.node);
1240
+
1241
+ for (const node of orderedCandidateNodes) {
1242
+ const nodeId = readMeshNodeId(node);
1243
+ if (!nodeId) continue;
1244
+ const launchKey = `${meshId}:${nodeId}`;
1245
+ const now = Date.now();
1246
+ const cooldownUntil = autoLaunchCooldownUntil.get(launchKey) || 0;
1247
+ if (cooldownUntil > 0 && now >= cooldownUntil) autoLaunchCooldownUntil.delete(launchKey);
1248
+ if (autoLaunchInProgress.has(launchKey)) {
1249
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_in_progress', nodeId });
1250
+ continue;
1251
+ }
1252
+ if (now < cooldownUntil) {
1253
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_cooldown', nodeId });
1254
+ continue;
1255
+ }
1256
+ if (isDirtyNode(node)) {
1257
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'dirty_workspace', nodeId });
1258
+ continue;
1259
+ }
1260
+ if (!isLaunchableNode(node)) {
1261
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_not_launch_ready', nodeId });
1262
+ continue;
1263
+ }
1264
+ const launchTarget = resolveAutoLaunchTarget(components, node);
1265
+ if (launchTarget.mode === 'skip') {
1266
+ // Remote node we can't reach (no transport / no coordinator daemonId).
1267
+ // Set a cooldown so the 4s reconcile loop doesn't re-attempt this node
1268
+ // every tick; the de-dup'd skip ledger keeps it diagnosable without flood.
1269
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: launchTarget.reason || 'auto_launch_unavailable', nodeId });
1270
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1271
+ continue;
1272
+ }
1273
+ // Write tasks keep the one-active-per-node invariant (worktree isolation);
1274
+ // read-only (live_debug_readonly) diagnoses may auto-launch onto a node
1275
+ // that already has an active assignment.
1276
+ if (task.taskMode !== 'live_debug_readonly' && nodeHasActiveAssignment(meshId, nodeId)) {
1277
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_active_assignment', nodeId });
1278
+ continue;
1279
+ }
1280
+ const maxConcurrentSessions = Number(node?.policy?.maxConcurrentSessions);
1281
+ if (Number.isFinite(maxConcurrentSessions) && maxConcurrentSessions >= 0 && liveSessionCountForNode(components, meshId, nodeId) >= maxConcurrentSessions) {
1282
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_concurrent_sessions_reached', nodeId });
1283
+ continue;
1284
+ }
1285
+
1286
+ autoLaunchInProgress.add(launchKey);
1287
+ try {
1288
+ const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags);
1289
+ if (!resolved.providerType) {
1290
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: resolved.reason || 'provider_unusable', nodeId });
1291
+ continue;
1292
+ }
1293
+
1294
+ // Don't spawn a session for a (node, provider) already at its declared
1295
+ // maxParallel cap — it would launch only to fail the claim. The claim
1296
+ // transaction enforces the cap regardless; this just avoids a doomed launch.
1297
+ const providerCap = resolveProviderMaxParallel(node?.policy, resolved.providerType);
1298
+ if (
1299
+ providerCap !== undefined
1300
+ && activeProviderAssignedCount(meshId, nodeId, resolved.providerType) >= providerCap
1301
+ ) {
1302
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_provider_parallel_reached', nodeId, providerType: resolved.providerType });
1303
+ continue;
1304
+ }
1305
+
1306
+ // Shared worker-launch envelope. For a local node it spawns directly on this
1307
+ // daemon; for a remote node the identical command is forwarded to the node's
1308
+ // daemon (mirrors mesh_launch_session), with the coordinator daemonId stamped
1309
+ // so the worker's completion events route back to this coordinator.
1310
+ const launchSettings: Record<string, unknown> = {
1311
+ // Worker launch envelope: role + mesh context so worker can route completion events.
1312
+ role: 'worker',
1313
+ meshNodeFor: meshId,
1314
+ meshNodeId: nodeId,
1315
+ spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
1316
+ // Coordinator-dispatched worker: auto-approve unless mesh/node policy
1317
+ // opts out (default true). Lands in settingsOverride and beats the
1318
+ // global per-provider-type autoApprove config (see shouldAutoApprove).
1319
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
1320
+ launchedByCoordinator: true,
1321
+ autoLaunchedForQueueTaskId: task.id,
1322
+ };
1323
+
1324
+ if (launchTarget.mode === 'remote') {
1325
+ // Relay-safe completion routing: stamp the coordinator anchor the same way
1326
+ // mesh_launch_session does so the worker forwards events back to this daemon.
1327
+ const remoteSettings: Record<string, unknown> = {
1328
+ ...launchSettings,
1329
+ meshCoordinatorDaemonId: launchTarget.coordinatorDaemonId,
1330
+ meshCoordinatorNodeId: nodeId,
1331
+ };
1332
+ markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
1333
+ let launchResult: any;
1334
+ try {
1335
+ launchResult = await components.dispatchMeshCommand!(launchTarget.daemonId!, 'launch_cli', {
1336
+ cliType: resolved.providerType,
1337
+ dir: node.workspace,
1338
+ settings: remoteSettings,
1339
+ });
1340
+ } catch (e: any) {
1341
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
1342
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1343
+ return false;
1344
+ }
1345
+ const payload = (launchResult && typeof launchResult === 'object' && 'payload' in launchResult && launchResult.payload && typeof launchResult.payload === 'object')
1346
+ ? launchResult.payload
1347
+ : launchResult;
1348
+ if (!payload?.success) {
1349
+ const reason = readNonEmptyString(payload?.error) || 'remote_launch_cli_failed';
1350
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
1351
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1352
+ return false;
1353
+ }
1354
+ // Remote launch is async: the worker session will register and emit agent:ready,
1355
+ // which (forwarded back here) drives the claim via the normal event path / PHASE 1
1356
+ // reconcile. Set a cooldown so the 4s loop doesn't re-launch before that lands.
1357
+ const remoteSessionId = readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.id) || readNonEmptyString(payload.runtimeSessionId);
1358
+ markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId: remoteSessionId || undefined });
1359
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1360
+ return true;
1361
+ }
1362
+
1363
+ markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
1364
+ const launchResult: any = await components.cliManager.handleCliCommand('launch_cli', {
1365
+ cliType: resolved.providerType,
1366
+ dir: node.workspace,
1367
+ settings: launchSettings,
1368
+ });
1369
+ if (!launchResult?.success) {
1370
+ const reason = launchResult?.error || 'launch_cli_failed';
1371
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
1372
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1373
+ return false;
1374
+ }
1375
+ const sessionId = readNonEmptyString(launchResult.sessionId) || readNonEmptyString(launchResult.id) || readNonEmptyString(launchResult.runtimeSessionId);
1376
+ if (!sessionId) {
1377
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason: 'launch_missing_session_id', nodeId, providerType: resolved.providerType });
1378
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1379
+ return false;
1380
+ }
1381
+ markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId });
1382
+ tryAssignQueueTask(components, meshId, nodeId, sessionId, resolved.providerType);
1383
+ return true;
1384
+ } catch (e: any) {
1385
+ markAutoLaunch(meshId, task.id, { status: 'failed', error: e?.message || String(e), nodeId });
1386
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS);
1387
+ return false;
1388
+ } finally {
1389
+ autoLaunchInProgress.delete(launchKey);
1390
+ }
1391
+ }
1392
+ }
1393
+ return false;
1394
+ }
1395
+
1396
+ export interface MeshQueueTriggerResult {
1397
+ success: true;
1398
+ meshId: string;
1399
+ pendingBefore: number;
1400
+ assignedBefore: number;
1401
+ pendingAfter: number;
1402
+ assignedAfter: number;
1403
+ claimed: boolean;
1404
+ newlyAssignedTasks: Array<{
1405
+ id: string;
1406
+ nodeId?: string;
1407
+ sessionId?: string;
1408
+ }>;
1409
+ localIdleSessionsChecked: number;
1410
+ remoteIdleSessionsChecked: number;
1411
+ skippedSessions: Array<{
1412
+ nodeId?: string;
1413
+ sessionId?: string;
1414
+ reason: string;
1415
+ status?: string;
1416
+ }>;
1417
+ autoLaunchStarted: boolean;
1418
+ /**
1419
+ * True when a worker session is already on its way to claim a still-pending task —
1420
+ * either launched this tick (autoLaunchStarted) or launched on a prior tick and still
1421
+ * booting/awaiting-claim. Callers MUST treat this as "wait, do not launch another
1422
+ * session": a second launch double-edits the worktree. Mutually informative with
1423
+ * `noIdleMeshSessionAvailable`, which is suppressed whenever this is true.
1424
+ */
1425
+ autoLaunchPending?: boolean;
1426
+ noIdleMeshSessionAvailable?: boolean;
1427
+ }
1428
+
1429
+ function countQueueStatus(meshId: string, status: 'pending' | 'assigned'): number {
1430
+ return getQueue(meshId, { status: [status] as any }).length;
1431
+ }
1432
+
1433
+ function getQueueStatusById(meshId: string): Map<string, string> {
1434
+ return new Map(getQueue(meshId).map(task => [task.id, task.status]));
1435
+ }
1436
+
1437
+ export async function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<MeshQueueTriggerResult> {
1438
+ const mesh = getMeshWithCache(components, meshId);
1439
+ const pendingBefore = countQueueStatus(meshId, 'pending');
1440
+ const assignedBefore = countQueueStatus(meshId, 'assigned');
1441
+ const beforeStatus = getQueueStatusById(meshId);
1442
+ const skippedSessions: MeshQueueTriggerResult['skippedSessions'] = [];
1443
+ let localIdleSessionsChecked = 0;
1444
+ let remoteIdleSessionsChecked = 0;
1445
+ let autoLaunchStarted = false;
1446
+ if (!mesh) {
1447
+ return {
1448
+ success: true,
1449
+ meshId,
1450
+ pendingBefore,
1451
+ assignedBefore,
1452
+ pendingAfter: pendingBefore,
1453
+ assignedAfter: assignedBefore,
1454
+ claimed: false,
1455
+ newlyAssignedTasks: [],
1456
+ localIdleSessionsChecked,
1457
+ remoteIdleSessionsChecked,
1458
+ skippedSessions: [{ reason: 'mesh_not_found' }],
1459
+ autoLaunchStarted,
1460
+ noIdleMeshSessionAvailable: true,
1461
+ };
1462
+ }
1463
+
1464
+ // Collect every idle mesh session (local CLI instances + remote idle records)
1465
+ // as drain candidates. The drain ORDER depends on the scheduling strategy:
1466
+ // - 'first_eligible' (default): local-first, then remote, exactly as before.
1467
+ // - otherwise: local + remote merged into one pool and drained in scheduling
1468
+ // order (priority → load → tie-break). This local-first debias is required
1469
+ // because without it the coordinator's own local node is always visited
1470
+ // first and greedily absorbs all untargeted work before any remote idle
1471
+ // session is even considered — the comparator alone can't spread work if
1472
+ // local is always tried first.
1473
+ type IdleCandidate = { nodeId: string; sessionId: string; providerType: string; origin: 'local' | 'remote'; node: any };
1474
+ const strategy = resolveSchedulingStrategy(mesh);
1475
+ const localCandidates: IdleCandidate[] = [];
1476
+
1477
+ const cliInstances = components.instanceManager.getByCategory('cli');
1478
+ for (const inst of cliInstances) {
1479
+ const state = inst.getState();
1480
+ const settings = state.settings as Record<string, unknown> || {};
1481
+
1482
+ const instMeshId = readNonEmptyString(settings.meshNodeFor);
1483
+ if (instMeshId !== meshId) continue;
1484
+
1485
+ const nodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1486
+ if (!nodeId) continue;
1487
+
1488
+ if (!isIdleSessionState(state)) {
1489
+ const status = readNonEmptyString(state.status).toLowerCase();
1490
+ skippedSessions.push({
1491
+ nodeId,
1492
+ sessionId: readNonEmptyString(state.instanceId),
1493
+ reason: isTerminalSessionStatus(status) ? 'terminal_session' : 'session_not_idle',
1494
+ status: status || undefined,
1495
+ });
1496
+ continue;
1497
+ }
1498
+
1499
+ const sessionId = state.instanceId;
1500
+ const providerType = state.type || readNonEmptyString(settings.providerType);
1501
+
1502
+ if (providerType) {
1503
+ localIdleSessionsChecked += 1;
1504
+ localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => readMeshNodeId(n) === nodeId) });
1505
+ } else {
1506
+ skippedSessions.push({
1507
+ nodeId,
1508
+ sessionId,
1509
+ reason: 'provider_type_missing',
1510
+ });
1511
+ }
1512
+ }
1513
+
1514
+ let remoteSessions: Array<{ nodeId: string; sessionId: string; providerType: string }> = [];
1515
+ try {
1516
+ remoteSessions = MeshRuntimeStore.getInstance().getRemoteIdleSessions();
1517
+ } catch { /* best-effort */ }
1518
+
1519
+ const remoteCandidates: IdleCandidate[] = [];
1520
+ for (const idle of remoteSessions) {
1521
+ // Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
1522
+ // `n.id`, so an inline-cached worktree node whose identity arrived under a
1523
+ // different form is not silently dropped — leaving a remote idle session
1524
+ // unable to claim its pending queue task.
1525
+ const node = mesh.nodes.find((n: any) => meshNodeIdMatches(n, idle.nodeId));
1526
+ if (node) {
1527
+ remoteIdleSessionsChecked += 1;
1528
+ remoteCandidates.push({ nodeId: idle.nodeId, sessionId: idle.sessionId, providerType: idle.providerType, origin: 'remote', node });
1529
+ }
1530
+ }
1531
+
1532
+ const assignIdleCandidate = (candidate: IdleCandidate): void => {
1533
+ const assigned = tryAssignQueueTask(components, meshId, candidate.nodeId, candidate.sessionId, candidate.providerType);
1534
+ if (assigned && candidate.origin === 'remote') {
1535
+ try {
1536
+ MeshRuntimeStore.getInstance().deleteRemoteIdleSession(candidate.nodeId, candidate.sessionId);
1537
+ } catch { /* best-effort */ }
1538
+ }
1539
+ };
1540
+
1541
+ if (strategy === 'first_eligible') {
1542
+ // Strict no-change: drain local idle sessions first (original order), then
1543
+ // remote idle sessions. tryAssignQueueTask is a no-op when nothing matches.
1544
+ for (const candidate of localCandidates) assignIdleCandidate(candidate);
1545
+ for (const candidate of remoteCandidates) assignIdleCandidate(candidate);
1546
+ } else {
1547
+ // Merge local + remote into one pool and drain in scheduling order. Each
1548
+ // assignment mutates a node's active load, and the next pick re-reads it,
1549
+ // so re-ranking after every assignment keeps the spread fair as load shifts.
1550
+ const pool = [...localCandidates, ...remoteCandidates];
1551
+ const baseIndex = new Map<string, number>();
1552
+ pool.forEach((c, i) => { if (!baseIndex.has(c.nodeId)) baseIndex.set(c.nodeId, i); });
1553
+ // Bump the round-robin cursor once for this whole drain pass.
1554
+ const uniqueNodes = [...new Set(pool.map(c => c.nodeId))]
1555
+ .map((nodeId, index) => ({ nodeId, node: pool.find(c => c.nodeId === nodeId)?.node, index }));
1556
+ const ranked = orderEligibleNodes(meshId, strategy, uniqueNodes, { bumpCursor: true });
1557
+ const rankIndex = new Map<string, number>(ranked.map((r, i) => [r.nodeId, i]));
1558
+ const remaining = [...pool];
1559
+ while (remaining.length > 0) {
1560
+ // Re-rank each pass so a node that just took work defers its next session.
1561
+ remaining.sort((a, b) => {
1562
+ const aPrio = resolveNodeSchedulingPriority(a.node?.policy);
1563
+ const bPrio = resolveNodeSchedulingPriority(b.node?.policy);
1564
+ if (aPrio !== bPrio) return bPrio - aPrio;
1565
+ if (strategy === 'least_loaded' || strategy === 'round_robin') {
1566
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
1567
+ if (loadDelta !== 0) return loadDelta;
1568
+ }
1569
+ return (rankIndex.get(a.nodeId) ?? 0) - (rankIndex.get(b.nodeId) ?? 0);
1570
+ });
1571
+ assignIdleCandidate(remaining.shift()!);
1572
+ }
1573
+ }
1574
+
1575
+ autoLaunchStarted = await maybeAutoLaunchOneQueueSession(components, meshId, mesh);
1576
+ const afterQueue = getQueue(meshId);
1577
+ const pendingAfter = afterQueue.filter(task => task.status === 'pending').length;
1578
+ const assignedAfter = afterQueue.filter(task => task.status === 'assigned').length;
1579
+ const newlyAssignedTasks = afterQueue
1580
+ .filter(task => task.status === 'assigned' && beforeStatus.get(task.id) !== 'assigned')
1581
+ .map(task => ({
1582
+ id: task.id,
1583
+ nodeId: task.assignedNodeId,
1584
+ sessionId: task.assignedSessionId,
1585
+ }));
1586
+
1587
+ // An auto-launch is "pending" when the coordinator has already spun a session up
1588
+ // for a still-pending task and is waiting on that session's idle→claim. This covers
1589
+ // two ticks:
1590
+ // - THIS tick fired the launch (autoLaunchStarted), or
1591
+ // - a PRIOR tick launched a session that is still booting/awaiting-claim — the
1592
+ // per-task await-claim guard (maybeAutoLaunchOneQueueSession) deliberately
1593
+ // declines to launch again, so autoLaunchStarted is false even though a session
1594
+ // is on its way to claim this task.
1595
+ // Without this signal, the second tick reports `noIdleMeshSessionAvailable` and the
1596
+ // MCP guidance tells the coordinator to launch ANOTHER worker — producing a duplicate
1597
+ // session that double-edits the worktree. The claim itself is fine; only the wording
1598
+ // was wrong, so we surface `autoLaunchPending` to suppress the bad "launch one more"
1599
+ // advice while the just-launched session converges.
1600
+ const autoLaunchPending = autoLaunchStarted || afterQueue.some(task => {
1601
+ if (task.status !== 'pending') return false;
1602
+ const al = task.autoLaunch;
1603
+ if (!al || (al.status !== 'started' && al.status !== 'completed')) return false;
1604
+ const launchedAtMs = Date.parse(al.updatedAt);
1605
+ return Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
1606
+ });
1607
+
1608
+ return {
1609
+ success: true,
1610
+ meshId,
1611
+ pendingBefore,
1612
+ assignedBefore,
1613
+ pendingAfter,
1614
+ assignedAfter,
1615
+ claimed: newlyAssignedTasks.length > 0,
1616
+ newlyAssignedTasks,
1617
+ localIdleSessionsChecked,
1618
+ remoteIdleSessionsChecked,
1619
+ skippedSessions,
1620
+ autoLaunchStarted,
1621
+ ...(autoLaunchPending ? { autoLaunchPending: true } : {}),
1622
+ // Only report "no idle session, go launch one" when nothing is already on its way.
1623
+ // A pending auto-launch (this tick or a prior still-converging one) means a session
1624
+ // WILL claim shortly, so it is not a no-session-available situation.
1625
+ ...(pendingAfter > 0 && newlyAssignedTasks.length === 0 && localIdleSessionsChecked === 0 && remoteIdleSessionsChecked === 0 && !autoLaunchPending
1626
+ ? { noIdleMeshSessionAvailable: true }
1627
+ : {}),
1628
+ };
1629
+ }
1630
+
1631
+ async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args: {
1632
+ meshId: string;
1633
+ nodeId: string;
1634
+ sessionId?: string;
1635
+ providerType?: string;
1636
+ }): Promise<void> {
1637
+ const mesh = getMeshWithCache(components, args.meshId);
1638
+ const node = mesh?.nodes?.find((candidate: any) => meshNodeIdMatches(candidate, args.nodeId));
1639
+ const workspace = readNonEmptyString(node?.workspace);
1640
+ if (!workspace) return;
1641
+ if (!existsSync(workspace)) return;
1642
+
1643
+ const policy = resolveAutoFastForwardPolicy(mesh);
1644
+ if (!policy.enabled) return;
1645
+ if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId, args.sessionId)) return;
1646
+
1647
+ const throttleKey = `${args.meshId}:${args.nodeId}`;
1648
+ const now = Date.now();
1649
+ const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
1650
+ if (now - lastAttempt < IDLE_AUTO_FAST_FORWARD_THROTTLE_MS) return;
1651
+ idleAutoFastForwardLastAttempt.set(throttleKey, now);
1652
+
1653
+ const submoduleIgnorePaths = Array.isArray(node?.policy?.submoduleIgnorePaths)
1654
+ ? node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
1655
+ : undefined;
1656
+ try {
1657
+ const dryRun = await fastForwardMeshNode({
1658
+ meshId: args.meshId,
1659
+ nodeId: args.nodeId,
1660
+ workspace,
1661
+ execute: false,
1662
+ dryRun: true,
1663
+ updateSubmodules: false,
1664
+ submoduleIgnorePaths,
1665
+ trigger: 'idle_auto',
1666
+ });
1667
+ if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
1668
+ const behind = Number(dryRun.current?.behind);
1669
+ if (policy.maxBehind !== undefined && Number.isFinite(behind) && behind > policy.maxBehind) return;
1670
+ if (policy.requireCleanSubmodules) {
1671
+ const submodules = Array.isArray(dryRun.current?.submodules) ? dryRun.current.submodules : [];
1672
+ if (submodules.some((submodule: any) => submodule?.dirty || submodule?.outOfSync || submodule?.error)) return;
1673
+ }
1674
+ await fastForwardMeshNode({
1675
+ meshId: args.meshId,
1676
+ nodeId: args.nodeId,
1677
+ workspace,
1678
+ execute: true,
1679
+ dryRun: false,
1680
+ updateSubmodules: false,
1681
+ submoduleIgnorePaths,
1682
+ trigger: 'idle_auto',
1683
+ });
1684
+ } catch (e: any) {
1685
+ LOG.warn('MeshFastForward', `Idle auto fast-forward check failed for ${args.nodeId}: ${e?.message || e}`);
1686
+ }
1687
+ }
1688
+
1689
+ function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
1690
+ meshId: string;
1691
+ nodeId: string;
1692
+ sessionId: string;
1693
+ providerType: string;
1694
+ }): void {
1695
+ setImmediate(() => {
1696
+ maybeAutoFastForwardIdleNode(components, args)
1697
+ .finally(() => {
1698
+ try {
1699
+ tryAssignQueueTask(components, args.meshId, args.nodeId, args.sessionId, args.providerType);
1700
+ } catch (e: any) {
1701
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${args.nodeId}: ${e?.message || e}`);
1702
+ }
1703
+ });
1704
+ });
1705
+ }
1706
+
1707
+ // ---------------------------------------------------------------------------
1708
+ // Core event injection
1709
+ // ---------------------------------------------------------------------------
1710
+
1711
+ const MESH_COORDINATOR_EVENTS = new Set([
1712
+ 'agent:generating_started',
1713
+ 'agent:generating_completed',
1714
+ 'agent:waiting_approval',
1715
+ 'agent:stopped',
1716
+ 'agent:ready',
1717
+ 'monitor:no_progress',
1718
+ 'refine:accepted',
1719
+ 'refine:completed',
1720
+ 'refine:failed',
1721
+ 'worktree_bootstrap_complete',
1722
+ 'worktree_bootstrap_failed',
1723
+ ]);
1724
+
1725
+ const EVENT_TO_LEDGER_KIND: Record<string, MeshLedgerKind> = {
1726
+ 'agent:generating_completed': 'task_completed',
1727
+ 'agent:waiting_approval': 'task_approval_needed',
1728
+ 'agent:stopped': 'task_failed',
1729
+ 'monitor:no_progress': 'task_stalled',
1730
+ };
1731
+
1732
+ export function isMeshCoordinatorEvent(eventName: unknown): eventName is string {
1733
+ return typeof eventName === 'string' && MESH_COORDINATOR_EVENTS.has(eventName);
1734
+ }
1735
+
1736
+ // Terminal events that the coordinator is actively blocked waiting on. When the
1737
+ // coordinator CLI session dispatches a task (e.g. mesh_send_task) it stays in
1738
+ // `generating` until the result arrives — but a generating coordinator queues
1739
+ // incoming send_message calls into its adapter's pendingOutboundQueue, which is
1740
+ // only flushed on the coordinator's OWN idle transition. That transition can't
1741
+ // happen until it receives this very event → deadlock. We force-inject these so
1742
+ // they bypass the busy send-guard and land in the PTY while generating.
1743
+ export const MESH_FORCE_INJECT_EVENTS: ReadonlySet<string> = new Set([
1744
+ 'agent:generating_completed',
1745
+ 'agent:stopped',
1746
+ 'agent:waiting_approval',
1747
+ 'refine:completed',
1748
+ 'refine:failed',
1749
+ 'worktree_bootstrap_complete',
1750
+ 'worktree_bootstrap_failed',
1751
+ ]);
1752
+
1753
+ export function shouldForceInjectMeshEvent(eventName: unknown): boolean {
1754
+ return typeof eventName === 'string' && MESH_FORCE_INJECT_EVENTS.has(eventName);
1755
+ }
1756
+
1757
+ // Coordinator-side suppression/reconcile gate for an incoming mesh event. Each clause is a
1758
+ // closed dedup/suppression concern that only inspects the event + already-resolved context and
1759
+ // either (a) returns a `suppress` result the caller forwards verbatim, (b) returns a `reconcile`
1760
+ // signal carrying the rewritten metadataEvent for the caller to re-inject as
1761
+ // agent:generating_completed, or (c) returns null to let the event fall through to the
1762
+ // terminal/ledger machinery. Extracted verbatim from injectMeshSystemMessage — no behavior
1763
+ // change; the only side effects (best-effort remote-idle cleanup, LOG, trace) fire on the same
1764
+ // paths as before.
1765
+ function evaluateMeshEventSuppression(
1766
+ args: {
1767
+ meshId: string;
1768
+ sourceInstanceId?: string;
1769
+ nodeId?: string;
1770
+ nodeLabel: string;
1771
+ event: string;
1772
+ metadataEvent: Record<string, unknown>;
1773
+ },
1774
+ ctx: {
1775
+ traceCtx: Parameters<typeof traceMeshEventDrop>[1];
1776
+ eventSessionId: string;
1777
+ eventNodeId: string;
1778
+ eventTimestamp: number | null;
1779
+ workerCoordinatorDaemonId: string | undefined;
1780
+ },
1781
+ ):
1782
+ | { kind: 'suppress'; result: { success: true; forwarded: 0; suppressed: true; [extra: string]: unknown } }
1783
+ | { kind: 'reconcile'; metadataEvent: Record<string, unknown> }
1784
+ | null {
1785
+ const { traceCtx, eventSessionId, eventNodeId, eventTimestamp, workerCoordinatorDaemonId } = ctx;
1786
+
1787
+ const intentionalCleanupStop = shouldSuppressIntentionalCleanupStop({
1788
+ event: args.event,
1789
+ meshId: args.meshId,
1790
+ metadataEvent: args.metadataEvent,
1791
+ sessionId: eventSessionId || undefined,
1792
+ nodeId: eventNodeId || undefined,
1793
+ });
1794
+ if (intentionalCleanupStop) {
1795
+ if (eventSessionId && eventNodeId) {
1796
+ try {
1797
+ MeshRuntimeStore.getInstance().deleteRemoteIdleSession(eventNodeId, eventSessionId);
1798
+ } catch { /* best-effort */ }
1799
+ }
1800
+ LOG.info('MeshEvents', `Suppressed ${args.event} for intentionally cleanup-stopped session ${eventSessionId || '(unknown session)'}`);
1801
+ traceMeshEventDrop('intentional_cleanup_stop', traceCtx);
1802
+ return { kind: 'suppress', result: { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true } };
1803
+ }
1804
+
1805
+ if (args.event === 'monitor:no_progress') {
1806
+ const reconciledCompletion = buildNoProgressCompletionReconciliation({
1807
+ meshId: args.meshId,
1808
+ nodeId: args.nodeId,
1809
+ nodeLabel: args.nodeLabel,
1810
+ metadataEvent: args.metadataEvent,
1811
+ sourceInstanceId: args.sourceInstanceId,
1812
+ });
1813
+ if (reconciledCompletion?.source === 'no_progress_reconciliation') {
1814
+ LOG.info('MeshEvents', `Reconciled no-progress monitor to completion for session ${eventSessionId || '(unknown session)'}`);
1815
+ return { kind: 'reconcile', metadataEvent: reconciledCompletion };
1816
+ }
1817
+ if (reconciledCompletion?.source === 'no_progress_terminal_ledger_suppression') {
1818
+ LOG.info('MeshEvents', `Suppressed no-progress monitor because terminal ledger evidence already exists for session ${eventSessionId || '(unknown session)'}`);
1819
+ traceMeshEventDrop('no_progress_terminal_ledger_suppression', traceCtx, `terminalKind=${reconciledCompletion.terminalLedgerKind}`);
1820
+ return {
1821
+ kind: 'suppress',
1822
+ result: {
1823
+ success: true,
1824
+ forwarded: 0,
1825
+ suppressed: true,
1826
+ terminalLedgerEvidence: true,
1827
+ terminalLedgerKind: reconciledCompletion.terminalLedgerKind,
1828
+ },
1829
+ };
1830
+ }
1831
+ }
1832
+
1833
+ if (isDuplicateRefineTerminalEvent(args.meshId, args.event, args.metadataEvent)) {
1834
+ LOG.info('MeshEvents', `Suppressed duplicate ${args.event} for refine job ${readRefineJobId({ metadataEvent: args.metadataEvent })}`);
1835
+ traceMeshEventDrop('duplicate_refine_terminal', traceCtx);
1836
+ return { kind: 'suppress', result: { success: true, forwarded: 0, suppressed: true, duplicateRefineTerminalEvent: true } };
1837
+ }
1838
+
1839
+ if (args.event === 'agent:waiting_approval' && eventSessionId) {
1840
+ const duplicateApproval = isDuplicateMeshApprovalEvent({
1841
+ meshId: args.meshId,
1842
+ sessionId: eventSessionId,
1843
+ providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
1844
+ timestamp: eventTimestamp,
1845
+ modalMessage: readNonEmptyString(args.metadataEvent.modalMessage) || undefined,
1846
+ modalButtons: args.metadataEvent.modalButtons,
1847
+ });
1848
+ if (duplicateApproval) {
1849
+ LOG.info('MeshEvents', `Suppressed duplicate approval event for mesh ${args.meshId} session ${eventSessionId}`);
1850
+ traceMeshEventDrop('duplicate_approval', traceCtx);
1851
+ return { kind: 'suppress', result: { success: true, forwarded: 0, suppressed: true, duplicateApproval: true } };
1852
+ }
1853
+ }
1854
+ if (args.event === 'agent:generating_completed' && eventSessionId) {
1855
+ const terminal = findRecentTerminalLedgerEvidence({
1856
+ meshId: args.meshId,
1857
+ sessionId: eventSessionId,
1858
+ nodeId: eventNodeId || undefined,
1859
+ });
1860
+ if (terminal?.kind === 'task_completed' && !sessionHasActiveAssignment(args.meshId, eventSessionId)) {
1861
+ const newDispatchAfterTerminal = hasDispatchAfterTerminal(args.meshId, eventSessionId, terminal.id);
1862
+ // Fix B (re-dispatch 2nd-completion routing): a prior terminal recorded from a FALSE
1863
+ // idle (weak evidence / no confirmed final assistant) must NOT permanently suppress a
1864
+ // later GENUINE completion of the same session. providerSessionId is stable across a
1865
+ // session's turns, so the providerSessionId/finalSummary dedup below would otherwise
1866
+ // swallow the real 2nd-turn completion that a coordinator nudge (direct re-dispatch)
1867
+ // drove — exactly the missed-event bug. When the prior terminal was weak and the new
1868
+ // event carries genuine completion evidence, let it through so it is recorded and
1869
+ // re-attributed to the latest task (the normal task_completed path below).
1870
+ const supersedesWeakTerminal = isWeakTerminalLedgerPayload(terminal.payload)
1871
+ && isGenuineCompletionEvidence(args.metadataEvent);
1872
+ // CANON-B (direct-dispatch completion race): a FAST direct dispatch (mesh_send_task)
1873
+ // to an already-idle, previously-used session can have its genuine completion reach
1874
+ // this coordinator handler BEFORE the dispatching side records the new task's dispatch
1875
+ // row / task_dispatched ledger entry — insertDirectDispatch + appendLedgerEntry both run
1876
+ // AFTER the agent_command await resolves, while the worker may already be done. In that
1877
+ // window sessionHasActiveAssignment is false (no active dispatch row, no unterminal
1878
+ // ledger entry yet), so this prior-terminal dedup engages; and because providerSessionId
1879
+ // is STABLE across a reused session's turns, the providerSessionId/finalSummary match
1880
+ // below would suppress the NEW task's completion as a duplicate of the PRIOR task —
1881
+ // silently losing it (the observed intermittent miss; fresh enqueue/autoLaunch is immune
1882
+ // because a fresh session has no prior same-providerSessionId terminal and the queue row
1883
+ // is claimed atomically before dispatch). The echoed taskId is the authoritative
1884
+ // discriminator: when the completion names a DIFFERENT task than the recorded terminal,
1885
+ // it is a genuinely new task's completion, never a duplicate — let it through so it is
1886
+ // attributed to its own taskId. A same-task re-arrival (taskId equal) or a taskId-less
1887
+ // legacy event still falls through to the providerSessionId/finalSummary dedup.
1888
+ const terminalTaskId = readNonEmptyString(terminal.payload.taskId);
1889
+ const eventTaskId = readNonEmptyString(args.metadataEvent.taskId);
1890
+ const distinctTaskCompletion = !!eventTaskId && !!terminalTaskId && eventTaskId !== terminalTaskId;
1891
+ if (!newDispatchAfterTerminal && !supersedesWeakTerminal && !distinctTaskCompletion) {
1892
+ const terminalProviderSessionId = readNonEmptyString(terminal.payload.providerSessionId);
1893
+ const terminalFinalSummary = readNonEmptyString(terminal.payload.finalSummary);
1894
+ const eventProviderSessionId = readNonEmptyString(args.metadataEvent.providerSessionId);
1895
+ const eventFinalSummary = readNonEmptyString(args.metadataEvent.finalSummary);
1896
+ if (
1897
+ (terminalProviderSessionId && terminalProviderSessionId === eventProviderSessionId)
1898
+ || (terminalFinalSummary && terminalFinalSummary === eventFinalSummary)
1899
+ || args.metadataEvent.source === 'no_progress_reconciliation'
1900
+ ) {
1901
+ LOG.info('MeshEvents', `Suppressed duplicate completion with existing terminal ledger evidence for mesh ${args.meshId} session ${eventSessionId}`);
1902
+ traceMeshEventDrop('duplicate_completion_terminal_ledger', traceCtx);
1903
+ return { kind: 'suppress', result: { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true, terminalLedgerEvidence: true } };
1904
+ }
1905
+ }
1906
+ }
1907
+ const duplicateCompletion = isDuplicateMeshCompletionEvent({
1908
+ meshId: args.meshId,
1909
+ event: args.event,
1910
+ sessionId: eventSessionId,
1911
+ providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
1912
+ providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
1913
+ timestamp: eventTimestamp,
1914
+ finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
1915
+ coordinatorDaemonId: workerCoordinatorDaemonId || undefined,
1916
+ taskId: readNonEmptyString(args.metadataEvent.taskId) || undefined,
1917
+ nodeId: eventNodeId || undefined,
1918
+ });
1919
+ if (duplicateCompletion) {
1920
+ LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
1921
+ traceMeshEventDrop('duplicate_completion', traceCtx);
1922
+ return { kind: 'suppress', result: { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true } };
1923
+ }
1924
+ }
1925
+ if (args.event === 'agent:stopped' && eventSessionId) {
1926
+ const duplicateStopped = isDuplicateMeshCompletionEvent({
1927
+ meshId: args.meshId,
1928
+ event: args.event,
1929
+ sessionId: eventSessionId,
1930
+ providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
1931
+ providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
1932
+ timestamp: eventTimestamp,
1933
+ finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
1934
+ coordinatorDaemonId: workerCoordinatorDaemonId || undefined,
1935
+ taskId: readNonEmptyString(args.metadataEvent.taskId) || undefined,
1936
+ nodeId: eventNodeId || undefined,
1937
+ });
1938
+ if (duplicateStopped) {
1939
+ LOG.info('MeshEvents', `Suppressed duplicate stopped event for mesh ${args.meshId} session ${eventSessionId}`);
1940
+ traceMeshEventDrop('duplicate_stopped', traceCtx);
1941
+ return { kind: 'suppress', result: { success: true, forwarded: 0, suppressed: true, duplicateStopped: true } };
1942
+ }
1943
+ }
1944
+
1945
+ return null;
1946
+ }
1947
+
1948
+ function injectMeshSystemMessage(components: DaemonComponents, args: {
1949
+ meshId: string;
1950
+ sourceInstanceId?: string;
1951
+ nodeId?: string;
1952
+ nodeLabel: string;
1953
+ event: string;
1954
+ metadataEvent: Record<string, unknown>;
1955
+ }) {
1956
+ const eventSessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
1957
+ const eventNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
1958
+
1959
+ // EVTTRACE correlation context for this event's coordinator-side lifecycle (queue /
1960
+ // dedup / suppress). Observation only — never read by any decision below.
1961
+ const traceCtx = {
1962
+ taskId: args.metadataEvent.taskId,
1963
+ sessionId: eventSessionId,
1964
+ nodeId: eventNodeId,
1965
+ meshId: args.meshId,
1966
+ event: args.event,
1967
+ };
1968
+
1969
+ const sourceSession = args.sourceInstanceId
1970
+ ? components.instanceManager.getInstance(args.sourceInstanceId)
1971
+ : undefined;
1972
+ const workerCoordinatorDaemonId = readNonEmptyString(
1973
+ (sourceSession?.getState()?.settings as Record<string, unknown>)?.meshCoordinatorDaemonId,
1974
+ );
1975
+ // Session-level routing anchor (multi-coordinator). Prefer the LIVE worker session's
1976
+ // stamp; fall back to a relayed value carried in metadataEvent.meshCoordinatorSessionId
1977
+ // (a remote worker's completion arrives via handleMeshForwardEvent with no local
1978
+ // sourceSession, so the stamp can only ride in the relayed metadata). Empty on legacy /
1979
+ // version-skewed dispatches → the event stays daemon-broadcast (no regression).
1980
+ const workerCoordinatorSessionId = readNonEmptyString(
1981
+ (sourceSession?.getState()?.settings as Record<string, unknown>)?.meshCoordinatorSessionId,
1982
+ ) || readNonEmptyString(args.metadataEvent.meshCoordinatorSessionId);
1983
+
1984
+ // T2: a summary-less completion (and any non-completion status-sync event) carries no
1985
+ // assistant text on the event, so resolveMeshSurfacedSessionPreview had nothing to surface
1986
+ // and the coordinator's inbox mirror stayed stuck on the first dispatched user task. When
1987
+ // THIS daemon hosts the live worker instance (sourceSession present), derive the worker's
1988
+ // latest display message straight from its transcript and attach it to the event as
1989
+ // lastMessagePreview/lastMessageRole/lastMessageAt. resolveMeshSurfacedSessionPreview reads
1990
+ // these as an assistant-only fallback; they also ride the pending-queue + P2P relay
1991
+ // (handleMeshForwardEvent whitelist) so a remote coordinator can surface them. A remote
1992
+ // coordinator has no local instance and keeps relying on the relayed fields — unchanged.
1993
+ const enrichedMetadataEvent = ((): Record<string, unknown> => {
1994
+ const last = sourceSession ? getLastDisplayMessage(sourceSession.getState()) : null;
1995
+ if (!last || !last.preview) return args.metadataEvent;
1996
+ return {
1997
+ ...args.metadataEvent,
1998
+ lastMessagePreview: last.preview,
1999
+ lastMessageRole: last.role,
2000
+ ...(last.receivedAt > 0 ? { lastMessageAt: last.receivedAt } : {}),
2001
+ };
2002
+ })();
2003
+
2004
+ // R2: cloud P2P dashboard metadata sync. The cloud daemon used to do this from its own
2005
+ // relay listener; now the single core forwarder invokes the injected hook (no-op on
2006
+ // standalone) so the event path stays single-listener and the local code path is identical
2007
+ // across standalone and cloud.
2008
+ if (components.onMeshCoordinatorEventForwarded) {
2009
+ try {
2010
+ // T: the coordinator surfaces a remote worker's session but holds no local
2011
+ // instance for it, so the status snapshot can't derive a preview and the
2012
+ // mirror would stay stuck on the first dispatched user task. Resolve the
2013
+ // worker's latest assistant reply (carried on the completion event's
2014
+ // finalSummary / workerResult) into a preview the mirror can stamp, so the
2015
+ // mobile inbox reflects the assistant response. Completion events carry assistant
2016
+ // text as finalSummary; a summary-less completion / status sync falls back to the
2017
+ // worker's latest assistant display message (enrichedMetadataEvent.lastMessage*).
2018
+ // For a mid-turn user-only event this is undefined and the prior surfaced preview
2019
+ // is preserved downstream (no clobber).
2020
+ const surfacedPreview = resolveMeshSurfacedSessionPreview(enrichedMetadataEvent);
2021
+ components.onMeshCoordinatorEventForwarded({
2022
+ event: args.event,
2023
+ meshId: args.meshId,
2024
+ nodeId: eventNodeId || undefined,
2025
+ ...enrichedMetadataEvent,
2026
+ // Ensure a `workspace` field reaches updateMeshOwnedSession even when the
2027
+ // worker provider event only carried `workspaceName`. The merge spread of
2028
+ // metadataEvent above wins when it already has a non-empty `workspace`.
2029
+ workspace: readNonEmptyString(args.metadataEvent.workspace)
2030
+ || readNonEmptyString(args.metadataEvent.workspaceName)
2031
+ || undefined,
2032
+ ...(surfacedPreview ? {
2033
+ meshSessionLastMessagePreview: surfacedPreview.preview,
2034
+ meshSessionLastMessageRole: surfacedPreview.role,
2035
+ meshSessionLastMessageAt: surfacedPreview.receivedAt || undefined,
2036
+ } : {}),
2037
+ });
2038
+ } catch { /* dashboard metadata sync is best-effort */ }
2039
+ }
2040
+
2041
+ const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
2042
+ // Coordinator-side dedup/suppression gate (extracted, behavior-preserving). A non-null
2043
+ // outcome either short-circuits with a forwarded result or signals a no-progress→completion
2044
+ // reconciliation that we re-inject; null lets the event fall through to the ledger machinery.
2045
+ const suppression = evaluateMeshEventSuppression(args, {
2046
+ traceCtx,
2047
+ eventSessionId,
2048
+ eventNodeId,
2049
+ eventTimestamp,
2050
+ workerCoordinatorDaemonId,
2051
+ });
2052
+ if (suppression) {
2053
+ if (suppression.kind === 'reconcile') {
2054
+ return injectMeshSystemMessage(components, {
2055
+ ...args,
2056
+ event: 'agent:generating_completed',
2057
+ metadataEvent: suppression.metadataEvent,
2058
+ });
2059
+ }
2060
+ return suppression.result;
2061
+ }
2062
+
2063
+ function markSessionTerminal(sessionId: string, outcome: 'completed' | 'failed', occurredAtMs?: number | null, opts?: { tentativeIfDirect?: boolean }): { id?: string } | null {
2064
+ // C2: prefer an exact taskId match when the completion event carries one —
2065
+ // it's immune to coordinator↔worker clock skew that can hide the assigned row.
2066
+ const eventTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
2067
+ const task = updateSessionTaskStatus(args.meshId, sessionId, outcome, {
2068
+ occurredAt: occurredAtMs != null ? new Date(occurredAtMs).toISOString() : undefined,
2069
+ taskId: eventTaskId,
2070
+ });
2071
+ // Fix A (early-terminal prevention): a false-idle completion (no confirmed final
2072
+ // assistant) for a DIRECT dispatch — i.e. no work-queue row matched — must not flip the
2073
+ // dispatch row terminal. Leaving it active lets the reconcile loop (PHASE 4) re-read the
2074
+ // transcript and record the genuine completion once the worker truly finishes (commonly
2075
+ // after a coordinator nudge / re-dispatch). A matched queue task, or a completion with
2076
+ // genuine evidence, is marked terminal as before.
2077
+ // WARMUPGAP: a no-taskId completion from a session that holds no active assignment is a
2078
+ // pre-assignment warmup / ghost event (a worker spawns, idles, and emits idle→generating→
2079
+ // completed before any task is dispatched, with meshActiveTaskId unset so the event carries
2080
+ // no taskId). Letting it through would hit the session_id fallback in updateDirectDispatchStatus
2081
+ // and flip a sibling/stale dispatch row this event does not own — the real task later lands on
2082
+ // a corrupted row and never reaches completed. Skip the dispatch update for that case. A
2083
+ // taskId-carrying completion (real task), or any completion whose session currently holds an
2084
+ // active assignment (legacy/relayed worker), still flips as before.
2085
+ const leaveDirectDispatchActive = (!task && opts?.tentativeIfDirect === true)
2086
+ || (!eventTaskId && !sessionHasActiveAssignment(args.meshId, sessionId));
2087
+ if (!leaveDirectDispatchActive) {
2088
+ // CANON-B: flip the exact dispatch row the completion echoed its taskId for; the
2089
+ // session_id fallback (no echoed taskId) still covers legacy/relayed workers.
2090
+ updateDirectDispatchStatus(args.meshId, sessionId, outcome, eventTaskId);
2091
+ }
2092
+ markSessionDeliveriesTerminal(args.meshId, sessionId, outcome);
2093
+ setImmediate(() => cleanupTerminalDirectDispatches());
2094
+ return task ? { id: task.id } : null;
2095
+ }
2096
+
2097
+ let completedTaskForLedger: { id?: string } | null = null;
2098
+ // Fix B: direct-dispatch taskId used to attribute the terminal ledger entry when no
2099
+ // work-queue row matches (resolved BEFORE markSessionTerminal flips the dispatch terminal).
2100
+ let directDispatchTaskIdForLedger: string | undefined;
2101
+ if (args.event === 'agent:generating_completed') {
2102
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
2103
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
2104
+ const providerType = readNonEmptyString(args.metadataEvent.providerType);
2105
+
2106
+ if (sessionId) {
2107
+ // CANON-B: trust the taskId the completion echoed; only fall back to the
2108
+ // most-recent-by-session heuristic when the worker carried none.
2109
+ directDispatchTaskIdForLedger = readNonEmptyString(args.metadataEvent.taskId)
2110
+ || resolveActiveDirectDispatchTaskId(args.meshId, sessionId);
2111
+ // A false-idle completion of a direct dispatch is recorded but kept tentative (the
2112
+ // dispatch row stays active for the reconcile fallback); a genuine completion is terminal.
2113
+ const isFalseIdle = isFalseIdleCompletion(args.metadataEvent);
2114
+ completedTaskForLedger = markSessionTerminal(sessionId, 'completed', eventTimestamp, { tentativeIfDirect: isFalseIdle });
2115
+ if (nodeId && providerType) {
2116
+ runIdleMaintenanceThenAssignQueue(components, { meshId: args.meshId, nodeId, sessionId, providerType });
2117
+ }
2118
+ // M1-3: wake dependents of the completed task. The maintenance path above
2119
+ // only assigns to the completing session; dependents may be claimable by
2120
+ // other idle sessions, so run a full queue trigger when any are waiting.
2121
+ const completedTaskId = completedTaskForLedger?.id;
2122
+ if (completedTaskId && hasPendingDependents(args.meshId, completedTaskId)) {
2123
+ setImmediate(() => {
2124
+ triggerMeshQueue(components, args.meshId).catch((e: any) => {
2125
+ LOG.warn('MeshQueue', `Dependent wake after task ${completedTaskId} failed: ${e?.message || e}`);
2126
+ });
2127
+ });
2128
+ }
2129
+ }
2130
+ } else if (args.event === 'agent:ready') {
2131
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
2132
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
2133
+ const providerType = readNonEmptyString(args.metadataEvent.providerType);
2134
+ const providerSessionId = readNonEmptyString(args.metadataEvent.providerSessionId) || undefined;
2135
+ const finalSummary = readNonEmptyString(args.metadataEvent.finalSummary) || undefined;
2136
+ const workerResult = readWorkerResultMetadata(args.metadataEvent);
2137
+ const hasCompletionEvidence = !!finalSummary || !!workerResult;
2138
+ if (sessionId && hasCompletionEvidence) {
2139
+ completedTaskForLedger = markSessionTerminal(sessionId, 'completed');
2140
+ if (completedTaskForLedger) {
2141
+ try {
2142
+ appendLedgerEntry(args.meshId, {
2143
+ kind: 'task_completed',
2144
+ nodeId: nodeId || undefined,
2145
+ sessionId,
2146
+ providerType: providerType || undefined,
2147
+ payload: {
2148
+ event: args.event,
2149
+ nodeLabel: args.nodeLabel,
2150
+ taskId: completedTaskForLedger.id,
2151
+ completedViaReady: true,
2152
+ providerSessionId,
2153
+ finalSummary,
2154
+ workerResult,
2155
+ evidence: buildTaskCompletionEvidence({
2156
+ event: 'agent:ready',
2157
+ nodeId,
2158
+ sessionId,
2159
+ providerType: providerType || undefined,
2160
+ providerSessionId,
2161
+ finalSummary,
2162
+ workerResult,
2163
+ }),
2164
+ },
2165
+ });
2166
+ } catch (e: any) {
2167
+ LOG.warn('MeshLedger', `Failed to record task_completed from ready: ${e?.message || e}`);
2168
+ }
2169
+ }
2170
+ }
2171
+
2172
+ if (sessionId && nodeId && providerType) {
2173
+ sweepExpiredRemoteIdleSessions();
2174
+ try {
2175
+ MeshRuntimeStore.getInstance().setRemoteIdleSession(nodeId, sessionId, providerType, Date.now() + REMOTE_IDLE_SESSION_TTL_MS);
2176
+ } catch { /* best-effort */ }
2177
+ setImmediate(() => {
2178
+ maybeAutoFastForwardIdleNode(components, { meshId: args.meshId, nodeId, sessionId, providerType })
2179
+ .finally(() => {
2180
+ try {
2181
+ const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
2182
+ if (assigned) MeshRuntimeStore.getInstance().deleteRemoteIdleSession(nodeId, sessionId);
2183
+ } catch (e: any) {
2184
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${nodeId}: ${e?.message || e}`);
2185
+ }
2186
+ });
2187
+ });
2188
+ }
2189
+ } else if (args.event === 'agent:generating_started') {
2190
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
2191
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
2192
+ if (sessionId && nodeId) {
2193
+ try {
2194
+ MeshRuntimeStore.getInstance().deleteRemoteIdleSession(nodeId, sessionId);
2195
+ } catch { /* best-effort */ }
2196
+ }
2197
+ if (sessionId) {
2198
+ // CANON-B: a generating_started that echoes its taskId acks exactly the dispatch
2199
+ // and the delivery for THAT task — not every in-flight dispatch/delivery on the
2200
+ // session. A session that already holds a freshly-dispatched (still 'dispatched')
2201
+ // sibling must keep that row 'dispatched' so its own confirm can match it; acking
2202
+ // by session would mark it 'acked' prematurely and hide a genuine non-delivery.
2203
+ const startedTaskId = readNonEmptyString(args.metadataEvent.taskId) || undefined;
2204
+ // WARMUPGAP: only ack a dispatch row when the event names its task, or the session
2205
+ // currently holds an active assignment. A no-taskId generating_started from an
2206
+ // unassigned session is a pre-assignment warmup — the session_id fallback would ack a
2207
+ // sibling/stale dispatch row this event does not own, marking it 'acked' prematurely and
2208
+ // hiding a genuine non-delivery. Skip the dispatch ack for that ghost case (the delivery
2209
+ // acks below are bound to actual deliveries and stay a no-op for a warmup session).
2210
+ if (startedTaskId || sessionHasActiveAssignment(args.meshId, sessionId)) {
2211
+ updateDirectDispatchStatus(args.meshId, sessionId, 'acked', startedTaskId);
2212
+ }
2213
+ const activeDeliveries = ((): { id: string; taskId: string | null }[] => {
2214
+ try { return MeshRuntimeStore.getInstance().getActiveSessionDeliveries(args.meshId, sessionId); }
2215
+ catch { return []; }
2216
+ })();
2217
+ const deliveriesToAck = startedTaskId
2218
+ ? activeDeliveries.filter(d => d.taskId === startedTaskId)
2219
+ : activeDeliveries;
2220
+ for (const d of deliveriesToAck) {
2221
+ updateSessionDeliveryStatus(d.id, 'acked');
2222
+ }
2223
+ }
2224
+ } else if (args.event === 'agent:stopped') {
2225
+ const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
2226
+ const nodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId);
2227
+ if (sessionId && nodeId) {
2228
+ try {
2229
+ MeshRuntimeStore.getInstance().deleteRemoteIdleSession(nodeId, sessionId);
2230
+ } catch { /* best-effort */ }
2231
+ }
2232
+ if (sessionId) {
2233
+ // CANON-B: prefer the echoed taskId; session heuristic is the fallback.
2234
+ directDispatchTaskIdForLedger = readNonEmptyString(args.metadataEvent.taskId)
2235
+ || resolveActiveDirectDispatchTaskId(args.meshId, sessionId);
2236
+ completedTaskForLedger = markSessionTerminal(sessionId, 'failed');
2237
+ }
2238
+ }
2239
+
2240
+ const ledgerKind = EVENT_TO_LEDGER_KIND[args.event];
2241
+ if (ledgerKind) {
2242
+ try {
2243
+ const ledgerNodeId = readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId) || undefined;
2244
+ const ledgerSessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId) || undefined;
2245
+ const ledgerProviderType = readNonEmptyString(args.metadataEvent.providerType) || undefined;
2246
+ const providerSessionId = readNonEmptyString(args.metadataEvent.providerSessionId) || undefined;
2247
+ const finalSummary = readNonEmptyString(args.metadataEvent.finalSummary) || undefined;
2248
+ const workerResult = readWorkerResultMetadata(args.metadataEvent);
2249
+ const completionEvidence = ledgerKind === 'task_completed' && ledgerNodeId && ledgerSessionId
2250
+ ? buildTaskCompletionEvidence({
2251
+ event: 'agent:generating_completed',
2252
+ nodeId: ledgerNodeId,
2253
+ sessionId: ledgerSessionId,
2254
+ providerType: ledgerProviderType,
2255
+ providerSessionId,
2256
+ finalSummary,
2257
+ workerResult,
2258
+ })
2259
+ : undefined;
2260
+ appendLedgerEntry(args.meshId, {
2261
+ kind: ledgerKind,
2262
+ nodeId: ledgerNodeId,
2263
+ sessionId: ledgerSessionId,
2264
+ providerType: ledgerProviderType,
2265
+ payload: {
2266
+ event: args.event,
2267
+ nodeLabel: args.nodeLabel,
2268
+ // Fix B: fall back to the direct-dispatch taskId when no work-queue row
2269
+ // matched, so the terminal entry is attributable in mesh task-stats
2270
+ // (otherwise the direct task shows status='unknown' / terminalKind=null).
2271
+ taskId: completedTaskForLedger?.id || directDispatchTaskIdForLedger || undefined,
2272
+ providerSessionId,
2273
+ finalSummary,
2274
+ workerResult,
2275
+ completionDiagnostic: args.metadataEvent.completionDiagnostic && typeof args.metadataEvent.completionDiagnostic === 'object'
2276
+ ? args.metadataEvent.completionDiagnostic
2277
+ : undefined,
2278
+ evidence: completionEvidence,
2279
+ // B2: evidenceLevel lets coordinator know when completion evidence is insufficient.
2280
+ ...(completionEvidence
2281
+ ? completionEvidence.workerResult.source === 'default'
2282
+ ? { evidenceLevel: 'insufficient', reviewRecommended: true }
2283
+ : { evidenceLevel: 'sufficient' }
2284
+ : {}),
2285
+ },
2286
+ });
2287
+ } catch (e: any) {
2288
+ LOG.warn('MeshLedger', `Failed to record ${ledgerKind}: ${e?.message || e}`);
2289
+ }
2290
+ }
2291
+
2292
+ let recoveryContext: SessionRecoveryContext | null = null;
2293
+ if (args.event === 'agent:stopped') {
2294
+ try {
2295
+ const mesh = getMesh(args.meshId);
2296
+ const maxRetries = mesh?.policy?.maxTaskRetries ?? 1;
2297
+
2298
+ recoveryContext = getSessionRecoveryContext(args.meshId, {
2299
+ sessionId: resolveEventSessionId(args.metadataEvent, args.sourceInstanceId) || undefined,
2300
+ nodeId: readNonEmptyString(args.nodeId) || readNonEmptyString(args.metadataEvent.meshNodeId) || undefined,
2301
+ maxRetries,
2302
+ });
2303
+ recoveryContext.failedProviderType = readNonEmptyString(args.metadataEvent.providerType) || null;
2304
+
2305
+ if (recoveryContext.retryRecommended && recoveryContext.consecutiveNodeFailures > 0) {
2306
+ appendLedgerEntry(args.meshId, {
2307
+ kind: 'recovery_attempted',
2308
+ nodeId: recoveryContext.failedNodeId || undefined,
2309
+ sessionId: recoveryContext.failedSessionId || undefined,
2310
+ providerType: recoveryContext.failedProviderType || undefined,
2311
+ payload: {
2312
+ consecutiveFailures: recoveryContext.consecutiveNodeFailures,
2313
+ taskAttemptCount: recoveryContext.taskAttemptCount,
2314
+ retryRecommended: recoveryContext.retryRecommended,
2315
+ advice: recoveryContext.advice,
2316
+ },
2317
+ });
2318
+
2319
+ if (recoveryContext.lastTaskMessage && recoveryContext.failedNodeId && recoveryContext.failedProviderType) {
2320
+ const autoNodeId = recoveryContext.failedNodeId;
2321
+ try {
2322
+ const task = enqueueTask(args.meshId, recoveryContext.lastTaskMessage, {
2323
+ targetNodeId: autoNodeId
2324
+ });
2325
+ LOG.info('MeshRecovery', `Auto-requeued failed task: ${task.id} for node ${autoNodeId}`);
2326
+
2327
+ const node = mesh?.nodes.find((n: any) => meshNodeIdMatches(n, autoNodeId));
2328
+ if (node) {
2329
+ components.cliManager.handleCliCommand('launch_cli', {
2330
+ cliType: recoveryContext.failedProviderType,
2331
+ dir: node.workspace,
2332
+ settings: {
2333
+ role: 'worker',
2334
+ meshNodeFor: args.meshId,
2335
+ meshNodeId: node.id,
2336
+ spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
2337
+ // Coordinator-dispatched recovery relaunch: same auto-approve
2338
+ // policy as the primary worker launch path.
2339
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
2340
+ launchedByCoordinator: true,
2341
+ }
2342
+ }).catch((e: any) => LOG.error('MeshRecovery', `Failed to auto-relaunch session for ${node.id}: ${e?.message}`));
2343
+ }
2344
+ } catch (e: any) {
2345
+ LOG.warn('MeshRecovery', `Failed to execute auto-recovery: ${e?.message}`);
2346
+ }
2347
+ }
2348
+ }
2349
+
2350
+ LOG.info('MeshRecovery', `Recovery context for ${args.nodeLabel}: ${recoveryContext.advice}`);
2351
+ } catch (e: any) {
2352
+ LOG.warn('MeshRecovery', `Failed to build recovery context: ${e?.message || e}`);
2353
+ }
2354
+ }
2355
+
2356
+ const messageText = buildMeshSystemMessage({
2357
+ event: args.event,
2358
+ nodeLabel: args.nodeLabel,
2359
+ metadataEvent: args.metadataEvent,
2360
+ recoveryContext,
2361
+ });
2362
+ if (!messageText) {
2363
+ // Lifecycle events that carry no coordinator-facing message (agent:ready /
2364
+ // agent:generating_started) still drive the remote-claim state machine: the
2365
+ // coordinator's agent:ready branch above runs setRemoteIdleSession +
2366
+ // tryAssignQueueTask, and agent:generating_started clears the remote-idle entry.
2367
+ // For a LOCAL worker whose coordinator is a REMOTE daemon those side effects ran
2368
+ // on the wrong daemon (this worker's empty queue / store), so the coordinator never
2369
+ // learns the auto-launched session went idle and re-auto-launches it forever
2370
+ // (queue task stuck pending). Queue the silent event so the coordinator pulls it
2371
+ // (PHASE 1 pullRemoteNodeQueues → handleMeshForwardEvent) and re-runs the claim on
2372
+ // the daemon that actually owns the queue. Gate strictly on a present, REMOTE
2373
+ // coordinator daemon id: a co-located worker already ran the claim on the right
2374
+ // daemon, and a coordinator processing a *pulled* event has no sourceSession so
2375
+ // workerCoordinatorDaemonId is empty — neither re-queues, so there is no loop.
2376
+ const isSilentClaimRelevantEvent = args.event === 'agent:ready' || args.event === 'agent:generating_started';
2377
+ const coordinatorIsRemote = !!workerCoordinatorDaemonId
2378
+ && !resolveCoordinatorDrainDaemonIds(components).includes(workerCoordinatorDaemonId);
2379
+ if (!(isSilentClaimRelevantEvent && coordinatorIsRemote)) {
2380
+ return { success: false, error: 'unsupported mesh event' };
2381
+ }
2382
+ }
2383
+
2384
+ // ── Queue-only delivery (single-model: queue + periodic poll) ──────────────
2385
+ // Every mesh coordinator event — terminal or not, local-coordinator or
2386
+ // remote — is persisted to the pending-events queue (SQLite + JSONL) and
2387
+ // NOTHING is pushed here. The old spontaneous-forward paths were removed:
2388
+ // - F1 remote P2P `mesh_forward_event` dispatch (network/stamp-dependent,
2389
+ // silently dropped on P2P failure or missing meshCoordinatorDaemonId)
2390
+ // - F3 live-CLI PTY `send_message` fire-and-forget inject (silently
2391
+ // dropped when the coordinator was generating)
2392
+ // Delivery to a live CLI coordinator now happens via setupMeshReconcileLoop,
2393
+ // which drains this queue on a fixed interval and injects into the coordinator
2394
+ // only when it is idle. A pure stdio MCP (LLM) coordinator — which has no live
2395
+ // CLI session to inject into — drains the queue itself when it calls a mesh
2396
+ // tool (mesh_status / mesh_read_chat). Either way the queue is the single
2397
+ // source of truth and the only thing this function writes to.
2398
+ //
2399
+ // targetCoordinatorDaemonId scopes the event to a specific coordinator daemon
2400
+ // (unicast) when the worker carries one, so the reconcile loop on the right
2401
+ // daemon drains it and other daemons skip it. Absent → broadcast/backfill.
2402
+ const pendingEvent = {
2403
+ event: args.event,
2404
+ meshId: args.meshId,
2405
+ nodeLabel: args.nodeLabel,
2406
+ nodeId: args.nodeId || undefined,
2407
+ workspace: readNonEmptyString(args.metadataEvent.workspace)
2408
+ || readNonEmptyString(args.metadataEvent.workspaceName),
2409
+ metadataEvent: {
2410
+ ...enrichedMetadataEvent,
2411
+ ...(recoveryContext ? { recoveryContext } : {}),
2412
+ // Stash the coordinator session id INSIDE metadataEvent too, so it survives the
2413
+ // P2P relay serialization (buildForwardPayloadFromPending spreads metadata; the
2414
+ // handleMeshForwardEvent whitelist reads it back) — a top-level field alone would
2415
+ // be dropped when the event crosses a machine boundary.
2416
+ ...(workerCoordinatorSessionId ? { meshCoordinatorSessionId: workerCoordinatorSessionId } : {}),
2417
+ },
2418
+ // Silent lifecycle events (agent:ready / agent:generating_started) carry no
2419
+ // coordinator message; they are queued only so the coordinator re-runs the
2420
+ // remote-claim state machine on pull. injectPendingIntoCoordinator skips
2421
+ // entries without a coordinatorMessage, so a live CLI coordinator is not spammed.
2422
+ ...(messageText ? { coordinatorMessage: messageText } : {}),
2423
+ queuedAt: Date.now(),
2424
+ ...(workerCoordinatorDaemonId ? { targetCoordinatorDaemonId: workerCoordinatorDaemonId } : {}),
2425
+ // Top-level session anchor for the local PHASE 2 strict-match on the coordinator
2426
+ // daemon. Absent → daemon-level broadcast (legacy / single-coordinator path).
2427
+ ...(workerCoordinatorSessionId ? { targetCoordinatorSessionId: workerCoordinatorSessionId } : {}),
2428
+ };
2429
+ if (queuePendingMeshCoordinatorEvent(pendingEvent)) {
2430
+ LOG.info('MeshEvents', `Queued ${args.event} for coordinator (mesh ${args.meshId}${workerCoordinatorDaemonId ? `, coordinator daemon ${workerCoordinatorDaemonId}` : ''}${workerCoordinatorSessionId ? `, coordinator session ${workerCoordinatorSessionId}` : ''})`);
2431
+ // EVTTRACE: event persisted to the coordinator pending queue (awaiting reconcile drain).
2432
+ traceMeshEventStage('queued', traceCtx, workerCoordinatorDaemonId ? `coordinatorDaemon=${workerCoordinatorDaemonId}` : 'broadcast');
2433
+ } else {
2434
+ // EVTTRACE: queue rejected the event (dedup at queue time / persistence guard).
2435
+ traceMeshEventDrop('queue_dedup', traceCtx);
2436
+ }
2437
+ return { success: true, forwarded: 0 };
2438
+ }
2439
+
2440
+ // Reconstruct the metadataEvent that injectMeshSystemMessage consumes from a forwarded
2441
+ // (cross-machine) mesh event. The remote relay hop arrives as a flat payload, NOT the
2442
+ // original provider event object, so this whitelists the fields the coordinator-side
2443
+ // pipeline reads and re-projects them. Kept pure + exported so the relay-path field
2444
+ // preservation (esp. taskId) is unit-testable without driving injectMeshSystemMessage.
2445
+ //
2446
+ // IMPORTANT asymmetry: the LOCAL in-process forward path (onMeshCoordinatorEventForwarded)
2447
+ // passes the whole event through as metadataEvent, so every field on the event survives
2448
+ // there for free. This remote-only path must explicitly mirror each field it needs.
2449
+ export function buildRelayMetadataEvent(payload: Record<string, unknown>): Record<string, unknown> {
2450
+ const relayModalMessage = readNonEmptyString(payload.modalMessage);
2451
+ const relayModalButtons = Array.isArray(payload.modalButtons)
2452
+ ? (payload.modalButtons as unknown[]).filter((b): b is string => typeof b === 'string' && b.trim().length > 0)
2453
+ : null;
2454
+ return {
2455
+ // Preserve the dispatch task id across the machine boundary. The `received` trace
2456
+ // stage reads payload.taskId; without mirroring it here the rebuilt metadataEvent
2457
+ // loses it, so injectMeshSystemMessage's traceCtx.taskId and the
2458
+ // updateDirectDispatchStatus(eventTaskId) call go undefined — the EvtTrace
2459
+ // queued/surfaced stages show task=- and the direct-dispatch ledger falls back to a
2460
+ // session_id match (which can flip a sibling row). The local in-process forward path
2461
+ // keeps event.taskId/meshActiveTaskId for free; this mirrors it for the remote relay.
2462
+ // Same taskId/meshActiveTaskId ordering the local unroutable trace uses.
2463
+ taskId: readNonEmptyString(payload.taskId) || readNonEmptyString(payload.meshActiveTaskId),
2464
+ targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
2465
+ providerType: readNonEmptyString(payload.providerType),
2466
+ providerSessionId: readNonEmptyString(payload.providerSessionId),
2467
+ // Preserve the originating coordinator SESSION id across the machine boundary so
2468
+ // the completion routes back to the exact coordinator session (multi-coordinator).
2469
+ // buildForwardPayloadFromPending spreads the worker event's metadata, so the id
2470
+ // arrives as payload.meshCoordinatorSessionId; the top-level targetCoordinatorSessionId
2471
+ // is also accepted as a fallback. injectMeshSystemMessage re-derives the routing
2472
+ // anchors from this. Absent → daemon-level fallback (version-skew safe).
2473
+ meshCoordinatorSessionId: readNonEmptyString(payload.meshCoordinatorSessionId) || readNonEmptyString(payload.targetCoordinatorSessionId),
2474
+ // Carry the session identity fields the worker provider event emits so the
2475
+ // coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
2476
+ // settings. Without these the remote-relay hop reconstructs metadataEvent with
2477
+ // an empty workspace, and the dashboard flaps to the generic
2478
+ // "Terminal (Mesh Node)" title (and degrades the provider label) between live
2479
+ // events and the periodic get_status_metadata snapshot. The local in-process
2480
+ // forward path (onMeshCoordinatorEventForwarded) already preserves these; this
2481
+ // mirrors them for the remote-only relay path.
2482
+ workspace: readNonEmptyString(payload.workspace) || readNonEmptyString(payload.workspaceName),
2483
+ workspaceName: readNonEmptyString(payload.workspaceName) || readNonEmptyString(payload.workspace),
2484
+ sessionTitle: readNonEmptyString(payload.sessionTitle),
2485
+ sessionStatus: readNonEmptyString(payload.sessionStatus),
2486
+ sessionChatStatus: readNonEmptyString(payload.sessionChatStatus),
2487
+ providerName: readNonEmptyString(payload.providerName),
2488
+ ...(payload.sessionSettings && typeof payload.sessionSettings === 'object' && !Array.isArray(payload.sessionSettings) ? { sessionSettings: payload.sessionSettings } : {}),
2489
+ finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
2490
+ // T2: carry the worker's status-snapshot last-message preview across the machine
2491
+ // boundary so a summary-less completion still surfaces the assistant reply in the
2492
+ // coordinator's inbox mirror. resolveMeshSurfacedSessionPreview reads these
2493
+ // (assistant-role only) when finalSummary is absent.
2494
+ lastMessagePreview: readNonEmptyString(payload.lastMessagePreview),
2495
+ lastMessageRole: readNonEmptyString(payload.lastMessageRole),
2496
+ ...(payload.lastMessageAt !== undefined ? { lastMessageAt: payload.lastMessageAt } : {}),
2497
+ jobId: readNonEmptyString(payload.jobId),
2498
+ interactionId: readNonEmptyString(payload.interactionId),
2499
+ status: readNonEmptyString(payload.status),
2500
+ targetDaemonId: readNonEmptyString(payload.targetDaemonId),
2501
+ startedAt: readNonEmptyString(payload.startedAt),
2502
+ completedAt: readNonEmptyString(payload.completedAt),
2503
+ retryOfJobId: readNonEmptyString(payload.retryOfJobId),
2504
+ ...(relayModalMessage ? { modalMessage: relayModalMessage } : {}),
2505
+ ...(relayModalButtons && relayModalButtons.length > 0 ? { modalButtons: relayModalButtons } : {}),
2506
+ ...(payload.result && typeof payload.result === 'object' && !Array.isArray(payload.result) ? { result: payload.result } : {}),
2507
+ ...(payload.completionDiagnostic && typeof payload.completionDiagnostic === 'object' && !Array.isArray(payload.completionDiagnostic) ? { completionDiagnostic: payload.completionDiagnostic } : {}),
2508
+ ...(payload.workerResult && typeof payload.workerResult === 'object' && !Array.isArray(payload.workerResult) ? { workerResult: payload.workerResult } : {}),
2509
+ ...(payload.meshWorkerResult && typeof payload.meshWorkerResult === 'object' && !Array.isArray(payload.meshWorkerResult) ? { meshWorkerResult: payload.meshWorkerResult } : {}),
2510
+ ...(payload.structuredResult && typeof payload.structuredResult === 'object' && !Array.isArray(payload.structuredResult) ? { structuredResult: payload.structuredResult } : {}),
2511
+ ...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
2512
+ intentional: payload.intentional === true,
2513
+ intentionalStop: payload.intentionalStop === true,
2514
+ operatorCleanup: payload.operatorCleanup === true,
2515
+ reason: readNonEmptyString(payload.reason),
2516
+ stopReason: readNonEmptyString(payload.stopReason),
2517
+ cleanupReason: readNonEmptyString(payload.cleanupReason),
2518
+ source: readNonEmptyString(payload.source),
2519
+ };
2520
+ }
2521
+
2522
+ export function handleMeshForwardEvent(components: DaemonComponents, payload: Record<string, unknown>) {
2523
+ const eventName = readNonEmptyString(payload.event);
2524
+ if (!isMeshCoordinatorEvent(eventName)) {
2525
+ return { success: false, error: 'unsupported mesh event' };
2526
+ }
2527
+ const nodeId = readNonEmptyString(payload.nodeId);
2528
+ const workspace = readNonEmptyString(payload.workspace);
2529
+
2530
+ // The fallback worker-forward path (forwardUnresolvedDelegateEvent) cannot resolve a
2531
+ // mesh id locally on the remote worker, so it forwards the event with nodeId +
2532
+ // workspace only. The coordinator hosting the mesh CAN resolve it. Two recovery
2533
+ // paths, in order:
2534
+ // 1) workspace → mesh (fast path; cached repoIdentity lookup), then
2535
+ // 2) nodeId → mesh (deterministic backstop; scans hosted meshes for the node).
2536
+ // Workspace recovery alone was unreliable — a worktree clone whose repoIdentity
2537
+ // differs, or a transient cache miss, left the reconcile retry permanently rejected
2538
+ // ("meshId required") so the worker's completion never surfaced to the coordinator.
2539
+ // The nodeId is a stable coordinator-side fact and resolves timing-independently.
2540
+ const meshId = readNonEmptyString(payload.meshId)
2541
+ || (workspace ? readNonEmptyString(getCachedMeshByWorkspace(workspace)?.id) : '')
2542
+ || recoverMeshIdByNodeId(nodeId);
2543
+ if (!meshId) {
2544
+ // EVTTRACE: forwarded event rejected at receive — no meshId could be resolved
2545
+ // (no payload.meshId, no workspace→mesh, no nodeId→mesh). Observation only.
2546
+ traceMeshEventDrop('meshId_required', {
2547
+ taskId: payload.taskId,
2548
+ sessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId),
2549
+ nodeId,
2550
+ event: eventName,
2551
+ }, workspace ? `workspace=${workspace} unresolved` : 'no workspace/nodeId');
2552
+ return { success: false, error: 'meshId required' };
2553
+ }
2554
+ // EVTTRACE: forwarded event accepted at receive (meshId resolved).
2555
+ traceMeshEventStage('received', {
2556
+ taskId: payload.taskId,
2557
+ sessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId),
2558
+ nodeId,
2559
+ meshId,
2560
+ event: eventName,
2561
+ });
2562
+ const nodeLabel = nodeId ? `Node '${nodeId}'` : workspace ? `Agent at ${workspace}` : 'Remote agent';
2563
+
2564
+ return injectMeshSystemMessage(components, {
2565
+ meshId,
2566
+ nodeId,
2567
+ nodeLabel,
2568
+ event: eventName,
2569
+ metadataEvent: buildRelayMetadataEvent(payload),
2570
+ });
2571
+ }
2572
+
2573
+ // ---------------------------------------------------------------------------
2574
+ // Per-coordinator forward serialization (P2P send-backpressure relief).
2575
+ //
2576
+ // When several workers finish at once, each completion runs forwardUnresolvedDelegate
2577
+ // Event and fires its own `mesh_forward_event` push. Firing the whole burst
2578
+ // concurrently dumps it into the single per-peer P2P DataChannel buffer in one tick,
2579
+ // which starves the rpc_ack/rpc_res replies the same channel must carry — a
2580
+ // coordinator's inbound `git_status` then times out even though the worker's own
2581
+ // forward acks return in ~1s. To cap the concurrent burst we serialize the immediate
2582
+ // pushes per coordinator: at most one push is in flight to a given coordinator at a
2583
+ // time, the rest run in arrival order behind it. A lone event (idle lane) still
2584
+ // dispatches immediately — only a genuine burst is paced. Durability is unchanged:
2585
+ // every event is already persisted to the outbox before the push runs, so serializing
2586
+ // only delays the best-effort fast path; PHASE 0 retry still covers any gap. This pairs
2587
+ // with the DataChannel send-buffer gate in daemon-cloud's mesh manager (writeRequest),
2588
+ // which is the hard guarantee; this throttle keeps the burst from piling up there.
2589
+ interface CoordinatorForwardLane { tail: Promise<unknown>; depth: number; }
2590
+ const coordinatorForwardLanes = new Map<string, CoordinatorForwardLane>();
2591
+ function enqueueCoordinatorForwardPush(coordinatorDaemonId: string, run: () => Promise<unknown>): void {
2592
+ let lane = coordinatorForwardLanes.get(coordinatorDaemonId);
2593
+ if (!lane) { lane = { tail: Promise.resolve(), depth: 0 }; coordinatorForwardLanes.set(coordinatorDaemonId, lane); }
2594
+ const wasIdle = lane.depth === 0;
2595
+ lane.depth += 1;
2596
+ const dec = (): void => { lane!.depth -= 1; };
2597
+ if (wasIdle) {
2598
+ // Idle lane → dispatch synchronously, so a lone completion (the common case) has
2599
+ // ZERO added latency and the push call happens in-line. Only a genuine burst —
2600
+ // events arriving while a push is still in flight — is paced (else branch).
2601
+ lane.tail = Promise.resolve(run()).catch(() => {}).then(dec, dec);
2602
+ } else {
2603
+ // Burst: queue behind the in-flight push(es) in arrival order so the whole burst
2604
+ // is not dumped into the shared DataChannel buffer at once. The tail is guarded
2605
+ // so one rejecting push never wedges the lane for the next.
2606
+ lane.tail = lane.tail.then(() => run()).catch(() => {}).then(dec, dec);
2607
+ }
2608
+ }
2609
+
2610
+ // ---------------------------------------------------------------------------
2611
+ // Worker-side fallback forward for unresolved-mesh delegates.
2612
+ //
2613
+ // A REMOTE worker daemon that is being P2P-remote-controlled by a coordinator is
2614
+ // NOT a member of the coordinator's mesh — it has no local mesh record. So when its
2615
+ // completion event reaches the forwarder, resolveWorkerDelegateRouting() resolves the
2616
+ // coordinator anchor (meshCoordinatorDaemonId) from the worker envelope but cannot
2617
+ // resolve the mesh id (neither meshNodeFor nor a workspace→mesh lookup yields one) and
2618
+ // returns isDelegate=false / mesh_unresolved. Before this fallback the event was dropped
2619
+ // (delivery_unroutable) and only recovered later when the coordinator happened to pull
2620
+ // the worker's queue — which it can't, because the worker never queued an unroutable
2621
+ // event. Live symptom: `WARN [MeshEvents] delivery_unroutable: ... mesh unresolved`.
2622
+ //
2623
+ // The fix: the routing object still carries coordinatorDaemonId. Forward the raw event
2624
+ // straight to that coordinator daemon over P2P (mesh_forward_event). The coordinator
2625
+ // hosts the mesh, so it recovers the mesh id by workspace in handleMeshForwardEvent and
2626
+ // injects/queues it normally. meshId is intentionally omitted from the payload (the
2627
+ // worker has none); workspace is the routing anchor the coordinator resolves from.
2628
+ //
2629
+ // No loop / no double-delivery:
2630
+ // - This only fires on the WORKER (the coordinator-own session is rejected by the
2631
+ // resolver before reaching here), and the coordinator merely injects — it does not
2632
+ // re-enter this forwarder for the relayed event.
2633
+ // - It fires only when the normal queue path did NOT run (isDelegate=false), so the
2634
+ // event is never both queued locally and forwarded.
2635
+ //
2636
+ // Returns true when the event was durably accepted for delivery to the coordinator
2637
+ // daemon (so the caller skips the delivery_unroutable diagnostic); false when no
2638
+ // fallback was possible (no coordinator anchor / no dispatch transport).
2639
+ //
2640
+ // Durability: the directed push to the coordinator is the ONLY delivery route for an
2641
+ // unresolved-mesh worker (it is in no mesh.node the coordinator can pull). So instead
2642
+ // of a fire-and-forget push that drops on one transient P2P failure, the event is
2643
+ // persisted to the worker-side outbox FIRST and only acked after a successful push.
2644
+ // A best-effort immediate push keeps latency low on the happy path; a failed or
2645
+ // un-acked push leaves the durable row for setupMeshReconcileLoop's PHASE 0 to retry.
2646
+ function forwardUnresolvedDelegateEvent(
2647
+ components: DaemonComponents,
2648
+ routing: ReturnType<typeof resolveWorkerDelegateRouting>,
2649
+ event: Record<string, unknown>,
2650
+ ): boolean {
2651
+ const coordinatorDaemonId = readNonEmptyString(routing.coordinatorDaemonId);
2652
+ if (!coordinatorDaemonId) return false;
2653
+ if (!components.dispatchMeshCommand) return false;
2654
+
2655
+ const eventName = readNonEmptyString(event.event);
2656
+ if (!eventName) return false;
2657
+
2658
+ // Flat payload mirroring buildForwardPayloadFromPending / what handleMeshForwardEvent
2659
+ // reads. meshId is omitted on purpose — the worker can't resolve it; the coordinator
2660
+ // recovers it from workspace. nodeId/workspace come from the worker envelope so the
2661
+ // coordinator can name and locate the node.
2662
+ const payload: Record<string, unknown> = {
2663
+ ...event,
2664
+ event: eventName,
2665
+ nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId) || undefined,
2666
+ workspace: readNonEmptyString(routing.workspace) || readNonEmptyString(event.workspace) || undefined,
2667
+ };
2668
+
2669
+ // Self-addressed fallback: the resolved coordinator IS this daemon (a self-
2670
+ // coordinating / single-node mesh, or a delegate whose coordinator anchor resolved
2671
+ // to our own id). A cross-daemon mesh_forward_event to our own id is REFUSED by the
2672
+ // dispatch self-dial guard ("route via the local router instead"), so persisting it
2673
+ // to the outbox would only loop forever in PHASE 0's retry, never acked. Honour the
2674
+ // guard's advice: route the event straight through the local receiver — the exact
2675
+ // path the coordinator runs on receiving a remote push — and skip the outbox entirely.
2676
+ const selfDaemonIds = resolveCoordinatorDrainDaemonIds(components);
2677
+ if (selfDaemonIds.some(self => daemonIdsEquivalent(self, coordinatorDaemonId))) {
2678
+ try {
2679
+ handleMeshForwardEvent(components, payload);
2680
+ LOG.info('MeshEvents', `Self-addressed unresolved-delegate ${eventName} routed via local router (coordinator ${coordinatorDaemonId} is self) — outbox skipped`);
2681
+ } catch (e: any) {
2682
+ LOG.warn('MeshEvents', `Local route of self-addressed unresolved-delegate ${eventName} failed: ${e?.message || e}`);
2683
+ }
2684
+ return true;
2685
+ }
2686
+
2687
+ // 1) Persist durably FIRST. Idempotent on fingerprint, so a re-fired completion
2688
+ // does not duplicate the outbox row. If persistence fails we still attempt the
2689
+ // push below (degrades to the old at-most-once behaviour rather than dropping
2690
+ // the chance entirely).
2691
+ const persisted = enqueueUnresolvedDelegateForward(coordinatorDaemonId, eventName, payload);
2692
+ // EVTTRACE: unresolved-mesh worker persisted its completion to the outbox (no meshId
2693
+ // available locally; coordinator will recover it on receive).
2694
+ const fwdTraceCtx = {
2695
+ taskId: (payload as Record<string, unknown>).taskId,
2696
+ sessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId),
2697
+ nodeId: readNonEmptyString(routing.nodeId) || readNonEmptyString(event.meshNodeId),
2698
+ event: eventName,
2699
+ };
2700
+ traceMeshEventStage('outbox_enqueue', fwdTraceCtx, `coordinatorDaemon=${coordinatorDaemonId} meshId=absent`);
2701
+
2702
+ // 2) Best-effort immediate push for low latency. On success, ack the outbox row so
2703
+ // the retry loop won't re-send it. On failure, leave it queued — PHASE 0 retries.
2704
+ traceMeshEventStage('forward_send', fwdTraceCtx, 'immediate push');
2705
+ // Serialize per coordinator so a multi-worker completion burst is paced rather than
2706
+ // dumped concurrently into the shared P2P DataChannel buffer (see coordinator
2707
+ // ForwardLanes). dispatchMeshCommand was null-checked above; capture it for the
2708
+ // deferred closure.
2709
+ const dispatchMeshCommand = components.dispatchMeshCommand;
2710
+ enqueueCoordinatorForwardPush(coordinatorDaemonId, () =>
2711
+ Promise.resolve(dispatchMeshCommand(coordinatorDaemonId, 'mesh_forward_event', payload))
2712
+ .then((result: any) => {
2713
+ if (result && result.success === false) {
2714
+ LOG.warn('MeshEvents', `Immediate forward of ${eventName} to coordinator ${coordinatorDaemonId} rejected (${readNonEmptyString(result.error) || 'no reason'}) — left queued for retry`);
2715
+ traceMeshEventDrop('immediate_forward_rejected', fwdTraceCtx, readNonEmptyString(result.error) || 'no reason');
2716
+ return;
2717
+ }
2718
+ // Acked. Mark the durable copy delivered so the retry loop skips it.
2719
+ if (persisted) ackUnresolvedDelegateForwardByFingerprint(coordinatorDaemonId, eventName, payload);
2720
+ })
2721
+ .catch((e: any) => {
2722
+ // Coordinator momentarily unreachable; the durable row stays queued and the
2723
+ // reconcile loop retries it. Trace so the relay attempt is visible.
2724
+ LOG.warn('MeshEvents', `Immediate forward of ${eventName} to coordinator ${coordinatorDaemonId} failed: ${e?.message || e} — left queued for retry`);
2725
+ }));
2726
+ LOG.info('MeshEvents', `Durably forwarded ${eventName} for unresolved-mesh worker at ${routing.workspace || '(no workspace)'} to coordinator daemon ${coordinatorDaemonId}`);
2727
+ return true;
2728
+ }
2729
+
2730
+ // Ack a just-pushed outbox entry by re-deriving its row from the same coordinator +
2731
+ // event + payload. We don't thread the row id back from enqueue (the immediate push is
2732
+ // fire-then-ack), so locate it among the undrained entries by matching coordinator and
2733
+ // the flat payload's forward identity. A miss is harmless — the retry loop's own
2734
+ // receiver-side dedup suppresses a duplicate delivery.
2735
+ function ackUnresolvedDelegateForwardByFingerprint(
2736
+ coordinatorDaemonId: string,
2737
+ eventName: string,
2738
+ payload: Record<string, unknown>,
2739
+ ): void {
2740
+ const match = peekUnresolvedDelegateForwards().find(entry =>
2741
+ daemonIdsEquivalent(entry.coordinatorDaemonId, coordinatorDaemonId)
2742
+ && readNonEmptyString(entry.payload.event) === eventName
2743
+ && readNonEmptyString(entry.payload.targetSessionId || entry.payload.sessionId || entry.payload.instanceId)
2744
+ === readNonEmptyString(payload.targetSessionId || payload.sessionId || payload.instanceId)
2745
+ && readNonEmptyString(entry.payload.workspace) === readNonEmptyString(payload.workspace),
2746
+ );
2747
+ if (match) ackUnresolvedDelegateForward(match.id);
2748
+ }
2749
+
2750
+ export function setupMeshEventForwarding(components: DaemonComponents) {
2751
+ components.instanceManager.onEvent((event) => {
2752
+ // --- Coordinator idle auto-flush (fast path) ---
2753
+ // When a coordinator session becomes idle, immediately flush any pending
2754
+ // coordinator events that accumulated while it was generating, rather than
2755
+ // waiting up to one reconcile interval for setupMeshReconcileLoop to do it.
2756
+ // Both paths drain the SAME queue via drainPendingMeshCoordinatorEvents,
2757
+ // whose SQLite drained=1 marking is atomic — whichever fires first consumes
2758
+ // the events and the other gets nothing, so there is no double-delivery.
2759
+ // This runs before the delegate routing below so that coordinator-own idle
2760
+ // transitions are handled first.
2761
+ // Exception: a coordinator that is itself a direct-dispatch target still needs
2762
+ // to go through delegate routing so that the dispatching coordinator receives a
2763
+ // pendingCoordinatorEvents entry for the completion.
2764
+ if (event.event === 'agent:ready' || event.event === 'agent:generating_completed') {
2765
+ const flushInstanceId = readNonEmptyString(event.instanceId);
2766
+ if (flushInstanceId) {
2767
+ const flushSource = components.instanceManager.getInstance(flushInstanceId);
2768
+ if (flushSource && flushSource.category === 'cli') {
2769
+ const flushState = flushSource.getState();
2770
+ const flushSettings = flushState.settings && typeof flushState.settings === 'object' ? flushState.settings as Record<string, unknown> : {};
2771
+ const coordinatorMeshId = readNonEmptyString(flushSettings.meshCoordinatorFor);
2772
+ if (coordinatorMeshId) {
2773
+ const status = readNonEmptyString(flushState.status).toLowerCase();
2774
+ if (status === 'idle') {
2775
+ try {
2776
+ // Drain with the daemon's full coordinator-id set (status id + machineId).
2777
+ // The MCP layer stamps the prefixed status id (`standalone_<machineId>` /
2778
+ // `daemon_<machineId>`) as the worker's meshCoordinatorDaemonId; draining
2779
+ // with bare machineId alone would miss those unicast events. Mirrors
2780
+ // resolveCoordinatorDaemonIds in mesh-reconcile-loop.
2781
+ const drainDaemonIds = resolveCoordinatorDrainDaemonIds(components);
2782
+ const pendingEvents = drainPendingMeshCoordinatorEvents(coordinatorMeshId, drainDaemonIds.length > 0 ? drainDaemonIds : undefined);
2783
+ if (pendingEvents.length > 0) {
2784
+ LOG.info('MeshEvents', `Auto-flushing ${pendingEvents.length} pending coordinator event(s) for mesh ${coordinatorMeshId} on coordinator idle`);
2785
+ for (const pending of pendingEvents) {
2786
+ if (!pending.coordinatorMessage) continue;
2787
+ const forcePending = shouldForceInjectMeshEvent(pending.event);
2788
+ flushSource.onEvent('send_message', {
2789
+ input: { text: pending.coordinatorMessage, textFallback: pending.coordinatorMessage },
2790
+ ...(forcePending ? { force: true } : {}),
2791
+ });
2792
+ }
2793
+ }
2794
+ } catch (e: any) {
2795
+ LOG.warn('MeshEvents', `Failed to auto-flush pending coordinator events: ${e?.message || e}`);
2796
+ }
2797
+ }
2798
+ // Skip delegate routing unless this coordinator session is itself
2799
+ // a direct-dispatch target — in that case fall through so the
2800
+ // dispatching coordinator gets a pendingCoordinatorEvents entry.
2801
+ let hasDirectDispatch = false;
2802
+ try {
2803
+ hasDirectDispatch =
2804
+ getActiveDirectDispatches(coordinatorMeshId).some(d => d.sessionId === flushInstanceId)
2805
+ || hasUnterminalDirectDispatchLedgerEntry(coordinatorMeshId, flushInstanceId);
2806
+ } catch { /* best-effort */ }
2807
+ if (!hasDirectDispatch) return;
2808
+ }
2809
+ }
2810
+ }
2811
+ }
2812
+
2813
+ // --- Delegate event routing ---
2814
+ if (!isMeshCoordinatorEvent(event.event)) return;
2815
+
2816
+ const instanceId = readNonEmptyString(event.instanceId);
2817
+ if (!instanceId) return;
2818
+
2819
+ // R1: all session→node→mesh→coordinator interpretation is folded into the single
2820
+ // resolveWorkerDelegateRouting() resolver. No stamp (meshNodeFor / meshNodeId /
2821
+ // meshCoordinatorDaemonId / meshCoordinatorNodeId / launchedByCoordinator) is read
2822
+ // here to make a routing decision — the resolver is the one authority, and the
2823
+ // forwarder consumes its typed result only.
2824
+ const routing = resolveWorkerDelegateRouting(components, instanceId, {
2825
+ getMeshById: (meshId) => getMeshWithCache(components, meshId),
2826
+ getMeshByWorkspace: (workspace) => getCachedMeshByWorkspace(workspace),
2827
+ });
2828
+ if (!routing.isDelegate) {
2829
+ // Fallback: a REMOTE worker that isn't a member of the coordinator's mesh can't
2830
+ // resolve a mesh id locally (mesh_unresolved), but it still carries the coordinator
2831
+ // daemon anchor. Forward the event straight to that coordinator over P2P instead of
2832
+ // dropping it — the coordinator hosts the mesh and recovers the id by workspace.
2833
+ if (isUnroutableDelegateRejection(routing)
2834
+ && forwardUnresolvedDelegateEvent(components, routing, event)) {
2835
+ return;
2836
+ }
2837
+ // R4: a worker that presented a valid envelope but resolved to no mesh (and could
2838
+ // not be fallback-forwarded — e.g. no coordinator anchor) used to be dropped
2839
+ // silently. Leave a fail-loud diagnostic so the missing completion is traceable.
2840
+ // Benign non-delegate rejections (not_cli / no_workspace / etc.) are no-ops inside
2841
+ // recordUnroutableDelegateEvent.
2842
+ // EVTTRACE: a delegate event that could not be routed AND could not be
2843
+ // fallback-forwarded (no coordinator anchor). Only mesh_unresolved is a real
2844
+ // drop; the benign non-delegate rejections are ordinary non-mesh traffic.
2845
+ if (isUnroutableDelegateRejection(routing)) {
2846
+ traceMeshEventDrop('unroutable', {
2847
+ taskId: (event as Record<string, unknown>).meshActiveTaskId ?? (event as Record<string, unknown>).taskId,
2848
+ sessionId: routing.sessionId,
2849
+ nodeId: routing.nodeId,
2850
+ event: event.event,
2851
+ }, 'no coordinator anchor / mesh_unresolved');
2852
+ }
2853
+ recordUnroutableDelegateEvent(routing, event.event);
2854
+ return;
2855
+ }
2856
+
2857
+ injectMeshSystemMessage(components, {
2858
+ meshId: routing.meshId,
2859
+ sourceInstanceId: instanceId,
2860
+ nodeId: routing.nodeId,
2861
+ nodeLabel: routing.nodeLabel,
2862
+ event: event.event,
2863
+ metadataEvent: event,
2864
+ });
2865
+ });
2866
+ }