@adhdev/daemon-core 0.9.82-rc.49 → 0.9.82-rc.490

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 (473) 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 +68 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +220 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +137 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
  14. package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
  15. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  16. package/dist/cli-adapters/resolve-executable.d.ts +69 -0
  17. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  18. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  19. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  20. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  21. package/dist/commands/chat-commands-read.d.ts +23 -0
  22. package/dist/commands/chat-commands-scope.d.ts +39 -0
  23. package/dist/commands/chat-commands-shared.d.ts +33 -0
  24. package/dist/commands/chat-commands-write.d.ts +14 -0
  25. package/dist/commands/chat-commands.d.ts +10 -23
  26. package/dist/commands/cli-manager.d.ts +71 -1
  27. package/dist/commands/handler.d.ts +125 -0
  28. package/dist/commands/high-family/index.d.ts +3 -0
  29. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  30. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  31. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  32. package/dist/commands/high-family/types.d.ts +64 -0
  33. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  34. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  35. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  36. package/dist/commands/low-family/index.d.ts +3 -0
  37. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  38. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  39. package/dist/commands/low-family/notification.d.ts +2 -0
  40. package/dist/commands/low-family/refine-config.d.ts +2 -0
  41. package/dist/commands/low-family/session-host.d.ts +2 -0
  42. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  43. package/dist/commands/low-family/status-meta.d.ts +2 -0
  44. package/dist/commands/low-family/types.d.ts +33 -0
  45. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  46. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  47. package/dist/commands/med-family/ide.d.ts +10 -0
  48. package/dist/commands/med-family/index.d.ts +3 -0
  49. package/dist/commands/med-family/mesh-crud.d.ts +48 -0
  50. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  51. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  52. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  53. package/dist/commands/med-family/types.d.ts +152 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router-aggregate-status.d.ts +24 -0
  56. package/dist/commands/router-mesh-session-owner.d.ts +59 -0
  57. package/dist/commands/router-refine.d.ts +151 -0
  58. package/dist/commands/router-worktree-cleanup.d.ts +133 -0
  59. package/dist/commands/router.d.ts +203 -15
  60. package/dist/commands/upgrade-helper.d.ts +41 -1
  61. package/dist/config/chat-history.d.ts +11 -0
  62. package/dist/config/config.d.ts +19 -0
  63. package/dist/config/mesh-config.d.ts +65 -0
  64. package/dist/config/mesh-json-config.d.ts +199 -0
  65. package/dist/config/registry-resolver.d.ts +54 -0
  66. package/dist/config/repo-settings.d.ts +77 -0
  67. package/dist/config/state-store.d.ts +30 -0
  68. package/dist/daemon/dev-server.d.ts +0 -2
  69. package/dist/detection/cli-detector.d.ts +17 -0
  70. package/dist/detection/ide-detector.d.ts +13 -0
  71. package/dist/detection/win32-ide-version.d.ts +37 -0
  72. package/dist/git/change-impact-config.d.ts +159 -0
  73. package/dist/git/git-commands.d.ts +26 -2
  74. package/dist/git/git-diff.d.ts +6 -0
  75. package/dist/git/git-executor.d.ts +11 -0
  76. package/dist/git/git-status.d.ts +80 -0
  77. package/dist/git/git-types.d.ts +2 -50
  78. package/dist/git/git-worktree.d.ts +71 -1
  79. package/dist/git/index.d.ts +3 -1
  80. package/dist/index.d.ts +70 -17
  81. package/dist/index.js +53532 -19073
  82. package/dist/index.js.map +1 -1
  83. package/dist/index.mjs +53283 -18989
  84. package/dist/index.mjs.map +1 -1
  85. package/dist/installer.d.ts +1 -4
  86. package/dist/ipc/local-ipc-server.d.ts +91 -0
  87. package/dist/launch.d.ts +1 -1
  88. package/dist/logging/async-batch-writer.d.ts +10 -0
  89. package/dist/logging/debug-config.d.ts +16 -0
  90. package/dist/logging/log-redactor.d.ts +24 -0
  91. package/dist/logging/log-tail-reader.d.ts +81 -0
  92. package/dist/logging/logger.d.ts +1 -1
  93. package/dist/mesh/contracts.d.ts +256 -0
  94. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  95. package/dist/mesh/coordinator-registry.d.ts +59 -0
  96. package/dist/mesh/mesh-active-work.d.ts +203 -0
  97. package/dist/mesh/mesh-clone-grace.d.ts +8 -0
  98. package/dist/mesh/mesh-completion-synthesis.d.ts +4 -0
  99. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  100. package/dist/mesh/mesh-delivery-policy.d.ts +104 -0
  101. package/dist/mesh/mesh-event-classify.d.ts +7 -0
  102. package/dist/mesh/mesh-event-forwarding.d.ts +58 -0
  103. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  104. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  105. package/dist/mesh/mesh-events-pending.d.ts +258 -0
  106. package/dist/mesh/mesh-events-stale.d.ts +61 -0
  107. package/dist/mesh/mesh-events-utils.d.ts +75 -0
  108. package/dist/mesh/mesh-events.d.ts +6 -49
  109. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  110. package/dist/mesh/mesh-host-ownership.d.ts +21 -1
  111. package/dist/mesh/mesh-init.d.ts +128 -0
  112. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  113. package/dist/mesh/mesh-ledger.d.ts +139 -1
  114. package/dist/mesh/mesh-magi-status.d.ts +63 -0
  115. package/dist/mesh/mesh-missions.d.ts +281 -0
  116. package/dist/mesh/mesh-node-identity.d.ts +310 -0
  117. package/dist/mesh/mesh-queue-assignment.d.ts +162 -0
  118. package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
  119. package/dist/mesh/mesh-reconcile-config.d.ts +6 -0
  120. package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
  121. package/dist/mesh/mesh-reconcile-loop.d.ts +84 -0
  122. package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
  123. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  124. package/dist/mesh/mesh-refine-gates.d.ts +457 -0
  125. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  126. package/dist/mesh/mesh-remote-event-pull.d.ts +15 -0
  127. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  128. package/dist/mesh/mesh-routing.d.ts +70 -0
  129. package/dist/mesh/mesh-runtime-store.d.ts +567 -0
  130. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  131. package/dist/mesh/mesh-task-inflight.d.ts +46 -0
  132. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  133. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +34 -0
  134. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  135. package/dist/mesh/mesh-work-queue.d.ts +392 -1
  136. package/dist/mesh/preview-freshness.d.ts +28 -0
  137. package/dist/mesh/refine-config.d.ts +97 -0
  138. package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
  139. package/dist/providers/acp-provider-instance.d.ts +5 -0
  140. package/dist/providers/approval-utils.d.ts +46 -0
  141. package/dist/providers/chat-message-normalization.d.ts +58 -1
  142. package/dist/providers/cli-provider-effect-format.d.ts +30 -0
  143. package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
  144. package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
  145. package/dist/providers/cli-provider-instance-types.d.ts +45 -0
  146. package/dist/providers/cli-provider-instance.d.ts +426 -30
  147. package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
  148. package/dist/providers/cli-provider-transcript-merge.d.ts +7 -0
  149. package/dist/providers/contracts.d.ts +196 -6
  150. package/dist/providers/external-sources.d.ts +71 -0
  151. package/dist/providers/manual-attendance.d.ts +79 -0
  152. package/dist/providers/native-history/antigravity-claim-registry.d.ts +39 -0
  153. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
  154. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  155. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  156. package/dist/providers/native-history/constants.d.ts +12 -0
  157. package/dist/providers/native-history/dispatcher.d.ts +44 -0
  158. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  159. package/dist/providers/native-history/index.d.ts +13 -0
  160. package/dist/providers/provider-instance-manager.d.ts +30 -0
  161. package/dist/providers/provider-instance.d.ts +31 -1
  162. package/dist/providers/provider-loader.d.ts +42 -5
  163. package/dist/providers/provider-trust.d.ts +31 -0
  164. package/dist/providers/read-chat-contract.d.ts +29 -0
  165. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  166. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  167. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  168. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  169. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  170. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  171. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  172. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  173. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  174. package/dist/providers/sdk/v1/index.d.ts +30 -0
  175. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  176. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  177. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  178. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  179. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  180. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  181. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  182. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  183. package/dist/providers/spec/adapter.d.ts +98 -0
  184. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  185. package/dist/providers/spec/evaluator.d.ts +45 -0
  186. package/dist/providers/spec/fsm-driver.d.ts +449 -0
  187. package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
  188. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  189. package/dist/providers/spec/fsm-types.d.ts +202 -0
  190. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  191. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  192. package/dist/providers/spec/route.d.ts +4 -0
  193. package/dist/providers/spec/types.d.ts +244 -0
  194. package/dist/providers/status-monitor.d.ts +7 -7
  195. package/dist/providers/transcript-v2.d.ts +176 -0
  196. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  197. package/dist/providers/working-dir.d.ts +17 -0
  198. package/dist/repo-mesh-types.d.ts +616 -12
  199. package/dist/runtime-defaults.d.ts +2 -0
  200. package/dist/session-host/managed-host.d.ts +64 -0
  201. package/dist/session-host/runtime-surface.d.ts +10 -16
  202. package/dist/sessions/registry.d.ts +26 -0
  203. package/dist/shared-types-extra.d.ts +2 -4
  204. package/dist/shared-types.d.ts +110 -55
  205. package/dist/status/chat-tail-hot-sessions.d.ts +40 -0
  206. package/dist/status/normalize.d.ts +1 -1
  207. package/dist/status/normalize.js +1 -0
  208. package/dist/status/normalize.js.map +1 -1
  209. package/dist/status/normalize.mjs +1 -0
  210. package/dist/status/normalize.mjs.map +1 -1
  211. package/dist/status/reporter.d.ts +2 -0
  212. package/dist/status/snapshot.d.ts +27 -0
  213. package/dist/system/hash.d.ts +8 -0
  214. package/dist/system/load-better-sqlite3.d.ts +21 -0
  215. package/dist/types.d.ts +20 -6
  216. package/package.json +11 -4
  217. package/src/agent-stream/poller.ts +2 -3
  218. package/src/agent-stream/provider-adapter.ts +1 -1
  219. package/src/boot/daemon-lifecycle.ts +80 -14
  220. package/src/boot/process-hardening.ts +89 -0
  221. package/src/build-info.ts +73 -0
  222. package/src/chat/source-machine.ts +534 -0
  223. package/src/chat/source-resolver.ts +0 -0
  224. package/src/chat/subscription-updates.ts +20 -1
  225. package/src/cli-adapter-types.d.ts +3 -1
  226. package/src/cli-adapter-types.ts +68 -2
  227. package/src/cli-adapters/cli-script-runner.ts +421 -0
  228. package/src/cli-adapters/cli-state-engine.ts +1282 -0
  229. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  230. package/src/cli-adapters/provider-cli-adapter.ts +1048 -1143
  231. package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
  232. package/src/cli-adapters/provider-cli-parse.ts +19 -0
  233. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  234. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  235. package/src/cli-adapters/provider-cli-shared.ts +121 -11
  236. package/src/cli-adapters/pty-transport.ts +2 -1
  237. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  238. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  239. package/src/cli-adapters/resolve-executable.ts +204 -0
  240. package/src/cli-adapters/session-host-transport.ts +2 -1
  241. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
  242. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  243. package/src/cli-adapters/terminal-screen.ts +16 -81
  244. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  245. package/src/commands/chat-commands-read.ts +2788 -0
  246. package/src/commands/chat-commands-scope.ts +54 -0
  247. package/src/commands/chat-commands-shared.ts +114 -0
  248. package/src/commands/chat-commands-write.ts +891 -0
  249. package/src/commands/chat-commands.ts +19 -1841
  250. package/src/commands/cli-manager.d.ts +2 -0
  251. package/src/commands/cli-manager.ts +724 -27
  252. package/src/commands/handler.ts +865 -2
  253. package/src/commands/high-family/index.ts +28 -0
  254. package/src/commands/high-family/mesh-coordinator-launch.ts +696 -0
  255. package/src/commands/high-family/mesh-events.ts +102 -0
  256. package/src/commands/high-family/mesh-status.ts +857 -0
  257. package/src/commands/high-family/types.ts +80 -0
  258. package/src/commands/low-family/coordinator-prompt.ts +125 -0
  259. package/src/commands/low-family/daemon-lifecycle.ts +120 -0
  260. package/src/commands/low-family/diagnostics.ts +57 -0
  261. package/src/commands/low-family/index.ts +37 -0
  262. package/src/commands/low-family/mesh-ledger.ts +62 -0
  263. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  264. package/src/commands/low-family/notification.ts +116 -0
  265. package/src/commands/low-family/refine-config.ts +106 -0
  266. package/src/commands/low-family/session-host.ts +282 -0
  267. package/src/commands/low-family/spec-providerdev.ts +217 -0
  268. package/src/commands/low-family/status-meta.ts +112 -0
  269. package/src/commands/low-family/types.ts +39 -0
  270. package/src/commands/med-family/cli-agent.ts +270 -0
  271. package/src/commands/med-family/fast-forward.ts +230 -0
  272. package/src/commands/med-family/ide.ts +163 -0
  273. package/src/commands/med-family/index.ts +37 -0
  274. package/src/commands/med-family/mesh-crud.ts +1268 -0
  275. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  276. package/src/commands/med-family/mesh-queue.ts +167 -0
  277. package/src/commands/med-family/mesh-restart.ts +92 -0
  278. package/src/commands/med-family/types.ts +153 -0
  279. package/src/commands/mesh-coordinator.ts +334 -124
  280. package/src/commands/router-aggregate-status.ts +209 -0
  281. package/src/commands/router-mesh-session-owner.ts +114 -0
  282. package/src/commands/router-refine.ts +1794 -0
  283. package/src/commands/router-worktree-cleanup.ts +870 -0
  284. package/src/commands/router.ts +567 -4149
  285. package/src/commands/stream-commands.ts +8 -0
  286. package/src/commands/upgrade-helper.ts +310 -45
  287. package/src/config/chat-history.ts +517 -24
  288. package/src/config/config.ts +30 -0
  289. package/src/config/mesh-config.ts +300 -24
  290. package/src/config/mesh-json-config.ts +376 -0
  291. package/src/config/recent-activity.ts +8 -2
  292. package/src/config/registry-resolver.ts +100 -0
  293. package/src/config/repo-settings.ts +111 -0
  294. package/src/config/state-store.ts +55 -0
  295. package/src/daemon/dev-auto-implement.ts +3 -2
  296. package/src/daemon/dev-cli-debug.ts +10 -1
  297. package/src/daemon/dev-server.ts +0 -541
  298. package/src/detection/cli-detector.ts +94 -9
  299. package/src/detection/ide-detector.ts +55 -16
  300. package/src/detection/win32-ide-version.ts +106 -0
  301. package/src/git/change-impact-config.ts +354 -0
  302. package/src/git/git-commands.ts +112 -16
  303. package/src/git/git-diff.ts +81 -11
  304. package/src/git/git-executor.ts +12 -0
  305. package/src/git/git-status.ts +822 -48
  306. package/src/git/git-types.ts +14 -62
  307. package/src/git/git-worktree.ts +261 -4
  308. package/src/git/index.ts +17 -0
  309. package/src/index.ts +190 -13
  310. package/src/installer.d.ts +1 -1
  311. package/src/installer.ts +8 -6
  312. package/src/ipc/local-ipc-server.ts +278 -0
  313. package/src/launch.d.ts +1 -1
  314. package/src/launch.ts +37 -28
  315. package/src/logging/async-batch-writer.ts +55 -0
  316. package/src/logging/command-log.ts +7 -5
  317. package/src/logging/debug-config.ts +25 -0
  318. package/src/logging/debug-trace.ts +7 -2
  319. package/src/logging/log-redactor.ts +100 -0
  320. package/src/logging/log-tail-reader.ts +341 -0
  321. package/src/logging/logger.ts +14 -7
  322. package/src/mesh/contracts.ts +510 -0
  323. package/src/mesh/coordinator-prompt.ts +627 -34
  324. package/src/mesh/coordinator-registry.ts +121 -0
  325. package/src/mesh/mesh-active-work.ts +706 -0
  326. package/src/mesh/mesh-clone-grace.ts +68 -0
  327. package/src/mesh/mesh-completion-synthesis.ts +416 -0
  328. package/src/mesh/mesh-coordinator-config.ts +97 -0
  329. package/src/mesh/mesh-delivery-policy.ts +284 -0
  330. package/src/mesh/mesh-event-classify.ts +78 -0
  331. package/src/mesh/mesh-event-forwarding.ts +2030 -0
  332. package/src/mesh/mesh-event-trace.ts +67 -0
  333. package/src/mesh/mesh-events-coordinator.ts +32 -0
  334. package/src/mesh/mesh-events-pending.ts +1548 -0
  335. package/src/mesh/mesh-events-stale.ts +452 -0
  336. package/src/mesh/mesh-events-utils.ts +443 -0
  337. package/src/mesh/mesh-events.ts +43 -1035
  338. package/src/mesh/mesh-fast-forward.ts +856 -0
  339. package/src/mesh/mesh-host-ownership.ts +41 -3
  340. package/src/mesh/mesh-init.ts +350 -0
  341. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  342. package/src/mesh/mesh-ledger.ts +897 -102
  343. package/src/mesh/mesh-magi-status.ts +223 -0
  344. package/src/mesh/mesh-missions.ts +695 -0
  345. package/src/mesh/mesh-node-identity.ts +2065 -0
  346. package/src/mesh/mesh-queue-assignment.ts +2498 -0
  347. package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
  348. package/src/mesh/mesh-reconcile-config.ts +66 -0
  349. package/src/mesh/mesh-reconcile-identity.ts +103 -0
  350. package/src/mesh/mesh-reconcile-loop.ts +1696 -0
  351. package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
  352. package/src/mesh/mesh-refine-batch.ts +205 -0
  353. package/src/mesh/mesh-refine-gates.ts +1792 -0
  354. package/src/mesh/mesh-refine-status.ts +231 -0
  355. package/src/mesh/mesh-remote-event-pull.ts +279 -0
  356. package/src/mesh/mesh-review-inbox.ts +308 -0
  357. package/src/mesh/mesh-routing.ts +291 -0
  358. package/src/mesh/mesh-runtime-store.ts +2292 -0
  359. package/src/mesh/mesh-scheduling-runtime.ts +198 -0
  360. package/src/mesh/mesh-task-inflight.ts +70 -0
  361. package/src/mesh/mesh-task-stats.ts +161 -0
  362. package/src/mesh/mesh-unresolved-forward-outbox.ts +215 -0
  363. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  364. package/src/mesh/mesh-work-queue.ts +1489 -145
  365. package/src/mesh/preview-freshness.ts +163 -0
  366. package/src/mesh/refine-config.ts +129 -12
  367. package/src/mesh/worktree-bootstrap-config.ts +599 -0
  368. package/src/providers/acp-provider-instance.ts +43 -10
  369. package/src/providers/approval-utils.d.ts +5 -0
  370. package/src/providers/approval-utils.ts +100 -6
  371. package/src/providers/chat-message-normalization.ts +126 -4
  372. package/src/providers/cli-provider-effect-format.ts +53 -0
  373. package/src/providers/cli-provider-history-dedup.ts +75 -0
  374. package/src/providers/cli-provider-input-prompt.ts +133 -0
  375. package/src/providers/cli-provider-instance-types.ts +131 -0
  376. package/src/providers/cli-provider-instance.ts +2771 -499
  377. package/src/providers/cli-provider-status-helpers.ts +123 -0
  378. package/src/providers/cli-provider-transcript-merge.ts +114 -0
  379. package/src/providers/contracts.d.ts +55 -0
  380. package/src/providers/contracts.ts +207 -6
  381. package/src/providers/extension-provider-instance.ts +12 -7
  382. package/src/providers/external-sources.ts +218 -0
  383. package/src/providers/ide-provider-instance.ts +35 -12
  384. package/src/providers/manual-attendance.ts +105 -0
  385. package/src/providers/native-history/antigravity-claim-registry.ts +138 -0
  386. package/src/providers/native-history/antigravity-cli-transcript.ts +1275 -0
  387. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  388. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  389. package/src/providers/native-history/constants.ts +19 -0
  390. package/src/providers/native-history/dispatcher.ts +617 -0
  391. package/src/providers/native-history/hermes-cli-transcript.ts +312 -0
  392. package/src/providers/native-history/index.ts +30 -0
  393. package/src/providers/provider-instance-manager.ts +71 -0
  394. package/src/providers/provider-instance.ts +24 -1
  395. package/src/providers/provider-loader.ts +700 -55
  396. package/src/providers/provider-schema.ts +93 -14
  397. package/src/providers/provider-trust.ts +114 -0
  398. package/src/providers/read-chat-contract.ts +76 -16
  399. package/src/providers/sdk/README.md +49 -0
  400. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  401. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  402. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  403. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
  404. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  405. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  406. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  407. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  408. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  409. package/src/providers/sdk/v1/index.ts +152 -0
  410. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  411. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  412. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  413. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +538 -0
  414. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  415. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  416. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  417. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  418. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  419. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  420. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  421. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  422. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  423. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  424. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  425. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  426. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  427. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  428. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  429. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  430. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  431. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  432. package/src/providers/sdk/v1/validators/index.ts +19 -0
  433. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  434. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  435. package/src/providers/spec/adapter.ts +246 -0
  436. package/src/providers/spec/cli-adapter.ts +1064 -0
  437. package/src/providers/spec/evaluator.ts +407 -0
  438. package/src/providers/spec/fsm-driver.ts +1528 -0
  439. package/src/providers/spec/fsm-evaluator.ts +290 -0
  440. package/src/providers/spec/fsm-loader.ts +128 -0
  441. package/src/providers/spec/fsm-types.ts +301 -0
  442. package/src/providers/spec/native-history-executor.ts +1241 -0
  443. package/src/providers/spec/pre-launch-trust.ts +104 -0
  444. package/src/providers/spec/route.ts +51 -0
  445. package/src/providers/spec/types.ts +284 -0
  446. package/src/providers/status-monitor.d.ts +7 -7
  447. package/src/providers/status-monitor.ts +37 -22
  448. package/src/providers/transcript-v2.ts +567 -0
  449. package/src/providers/types/interactive-prompt.ts +536 -0
  450. package/src/providers/version-archive.ts +64 -24
  451. package/src/providers/working-dir.ts +23 -0
  452. package/src/repo-mesh-types.ts +861 -14
  453. package/src/runtime-defaults.ts +39 -0
  454. package/src/session-host/managed-host.ts +218 -0
  455. package/src/session-host/runtime-surface.ts +20 -80
  456. package/src/sessions/registry.ts +44 -0
  457. package/src/shared-types-extra.ts +2 -4
  458. package/src/shared-types.d.ts +8 -0
  459. package/src/shared-types.ts +148 -55
  460. package/src/status/builders.ts +69 -6
  461. package/src/status/chat-tail-hot-sessions.ts +117 -2
  462. package/src/status/normalize.ts +2 -0
  463. package/src/status/reporter.ts +19 -1
  464. package/src/status/snapshot.ts +100 -27
  465. package/src/system/hash.ts +23 -0
  466. package/src/system/host-memory.ts +29 -12
  467. package/src/system/load-better-sqlite3.ts +68 -0
  468. package/src/types.ts +26 -6
  469. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  470. package/dist/mesh/mesh-sync.d.ts +0 -53
  471. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  472. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  473. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,2498 @@
1
+ import { existsSync } from 'fs';
2
+ import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
3
+ import { MESH_CONNECT_TIMEOUT_MS } from '../runtime-defaults.js';
4
+ import { loadConfig } from '../config/config.js';
5
+ import { getMesh, getDifficultyBrains } from '../config/mesh-config.js';
6
+ import { detectCLI } from '../detection/cli-detector.js';
7
+ import { LOG } from '../logging/logger.js';
8
+ import { appendLedgerEntry } from './mesh-ledger.js';
9
+ import { buildMeshNodeCapabilityTags, nodeSatisfiesRequiredTags, claimNextTask, updateTaskStatus, getQueue, recordTaskAutoLaunch, getActiveDirectDispatches, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank } 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, updateSessionDeliveryStatus } from './mesh-delivery-policy.js';
13
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
14
+ import { traceMeshEventDrop } from './mesh-event-trace.js';
15
+ import { awaitWithWarmupDeadline, resolveWarmupDeadlineOpts } from './mesh-warmup-deadline.js';
16
+ import { resolveDelegatedWorkerAutoApprove, resolveProviderMaxParallel, resolveNodeSchedulingPriority, normalizeMeshSchedulingStrategy, resolveMaxParallelTasks, resolveMaxReadonlyParallelTasks } from '../repo-mesh-types.js';
17
+ import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, canonicalDaemonId, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, sessionIdsEquivalent, deriveSlotsFromLegacy, normalizeNodeCapabilitySlots, isMeshTaskDifficulty, type MeshNodeIdentified, type NodeCapabilitySlot, type MeshTaskDifficulty } from '@adhdev/mesh-shared';
19
+ import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
20
+ import { readNonEmptyString } from './mesh-events-utils.js';
21
+ import { readMeshNodeDaemonId } from './mesh-node-identity.js';
22
+ import { queuePendingMeshCoordinatorEvent, retractPendingDispatchBlockedEvent } from './mesh-events-pending.js';
23
+ import { isWorktreeBootstrapStaleRunning, shouldDeferDispatchForBootstrap } from './worktree-bootstrap-config.js';
24
+ import { isWithinCloneBootstrapGrace } from './mesh-clone-grace.js';
25
+ import { beginTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
26
+
27
+ /**
28
+ * CANON: the single canonical coordinator-daemon id this daemon stamps onto every
29
+ * worker dispatch (meshContext.coordinatorDaemonId / sourceCoordinatorDaemonId / the
30
+ * co-located meshCoordinatorDaemonId anchor). loadConfig().machineId is the bare
31
+ * `mach_X` form; canonicalizing to `daemon_mach_X` unifies it with the MCP-side
32
+ * resolveCoordinatorDaemonId producer so the two dispatch paths can never stamp a
33
+ * worker's coordinator anchor in two different forms — the CANON-IDENTITY
34
+ * double-dispatch root cause. Consumers of the anchor already compare under
35
+ * daemonIdsEquivalent / expandDaemonIdForms, so the exact form is form-agnostic on
36
+ * the read side; this only removes the producer-side skew.
37
+ */
38
+ function localCoordinatorDaemonId(): string | undefined {
39
+ return canonicalDaemonId(readNonEmptyString(loadConfig().machineId));
40
+ }
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Idle auto fast-forward throttle state
44
+ // ---------------------------------------------------------------------------
45
+ const IDLE_AUTO_FAST_FORWARD_THROTTLE_MS = 30 * 60 * 1000;
46
+ const idleAutoFastForwardLastAttempt = new Map<string, number>();
47
+
48
+ export function __resetIdleAutoFastForwardForTests(): void {
49
+ idleAutoFastForwardLastAttempt.clear();
50
+ }
51
+
52
+ export function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
53
+ const localMesh = getMesh(meshId);
54
+ const cachedMesh = components.router?.getCachedInlineMesh(meshId);
55
+ if (!localMesh) return cachedMesh;
56
+ if (!cachedMesh) return localMesh;
57
+ return mergeInlineCacheOnlyNodes(localMesh, cachedMesh);
58
+ }
59
+
60
+ /**
61
+ * Claim-time membership view unification (CLAIMSTALL fix).
62
+ *
63
+ * The coordinator's claim path — triggerMeshQueue → autoLaunch candidate filter
64
+ * and the local/remote idle-session drain — reads mesh membership through
65
+ * getMeshWithCache, which historically returned the local-config mesh verbatim
66
+ * whenever one existed. A freshly cloned worktree node is registered ONLY into the
67
+ * router's inline mesh cache: clone_mesh_node's `meshRecord.inline` branch calls
68
+ * updateInlineMeshNode, NOT addNode, so the worktree node never reaches local
69
+ * config (meshes.json). The config-first view therefore omits the worktree node,
70
+ * while send_task — which resolves membership through getMeshForCommand(preferInline)
71
+ * over the same inline cache — sees it. That view asymmetry is the stall: a queue
72
+ * task pinned to the worktree node reports `target_node_id_unmatched` (autoLaunch
73
+ * candidate filter / targetPinUnmatched check) and the node's idle session is
74
+ * dropped from the drain pool (mesh.nodes.find miss), so claim never fires and the
75
+ * task is stranded pending — even though nodeId matching itself is correct.
76
+ *
77
+ * Fix: union the local-config nodes with any inline-cache-ONLY nodes, so the claim
78
+ * view matches the command (send_task) view. Base (non-worktree) nodes present in
79
+ * local config stay config-authoritative — their STATIC fields are taken verbatim
80
+ * from localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
81
+ * that exist solely in the inline cache (the cloned worktree nodes) are appended.
82
+ * Identity comparison uses the shared 3-form normalizer (id / nodeId / node_id),
83
+ * identical to every other claim-path consumer — the matching logic is untouched,
84
+ * only which nodes are visible.
85
+ *
86
+ * BOOTSTRAP-DEFER VIEW-CONSISTENCY (this fix): for a worktree node that IS registered
87
+ * in local config, the union previously took the config node verbatim and discarded the
88
+ * inline-cache entry entirely. But the inline cache holds the FRESHER runtime bootstrap
89
+ * state — markWorktreeBootstrapTerminalState stamps worktreeBootstrap.status='complete'
90
+ * synchronously into the inline cache, while local config lags behind the detached async
91
+ * persist chain (and on the coordinator may never receive it at all). A config-registered
92
+ * worktree node therefore read a permanently stale 'running' here, so
93
+ * shouldDeferDispatchForBootstrap deferred its claim forever. We now MERGE the inline
94
+ * cache's dynamic runtime bootstrap state onto the config node (config keeps its static
95
+ * fields; worktreeBootstrap is preferred from the inline cache) so EVERY consumer of the
96
+ * merged view — not just tryAssignQueueTask's gate — observes the terminal stamp.
97
+ *
98
+ * RESIDUAL-getMeshWithCache-bootstrap-overlay (precedence guard): the overlay is DIRECTIONAL —
99
+ * it prefers the inline entry ONLY when the inline runtime state is actually fresher, never
100
+ * merely because the inline entry carries a status. inlineBootstrapIsFresher() (below) permits
101
+ * the overlay in exactly two cases, mirroring the mission's "terminal OR strictly newer" rule:
102
+ * (1) the inline state is TERMINAL ('complete'/'failed') while the config state is NOT — the
103
+ * markWorktreeBootstrapTerminalState synchronous stamp the async config persist has not
104
+ * yet caught up to; this is the whole point of the overlay (opens the gate).
105
+ * (2) both states are non-terminal but the inline startedAt is STRICTLY newer — a re-driven
106
+ * bootstrap whose fresher 'running' epoch the config has not observed.
107
+ * It REFUSES the overlay when the config state is already terminal and the inline state is a
108
+ * stale/non-terminal 'running' — otherwise a stale inline 'running' would MASK a genuinely
109
+ * complete config node and re-defer its claim forever (the exact anti-case this guard closes).
110
+ * And when both are 'running' with no newer epoch, the config value is kept and the gate still
111
+ * defers — the half-built-worktree → empty-session defense is preserved: only a terminal-confirmed
112
+ * inline state, never an ambiguous read, ever opens the gate.
113
+ */
114
+ const BOOTSTRAP_TERMINAL_STATUSES = new Set(['complete', 'failed']);
115
+
116
+ function bootstrapEpochMs(bootstrap: any): number {
117
+ const raw = readNonEmptyString(bootstrap?.startedAt) || readNonEmptyString(bootstrap?.completedAt);
118
+ if (!raw) return 0;
119
+ const parsed = Date.parse(raw);
120
+ return Number.isFinite(parsed) ? parsed : 0;
121
+ }
122
+
123
+ /**
124
+ * Directional freshness test for the bootstrap overlay: may the inline runtime state
125
+ * REPLACE the config runtime state? True only when the inline state is terminal and the
126
+ * config state is not (the synchronous terminal stamp the async persist lags), or when
127
+ * both are non-terminal but the inline epoch is strictly newer. A terminal config state is
128
+ * never overwritten by a non-terminal inline read (the stale-'running'-masks-complete
129
+ * anti-case), and equal states never trigger a rewrite.
130
+ */
131
+ function inlineBootstrapIsFresher(inlineBootstrap: any, configBootstrap: any): boolean {
132
+ const inlineStatus = readNonEmptyString(inlineBootstrap?.status);
133
+ if (!inlineStatus) return false;
134
+ const configStatus = readNonEmptyString(configBootstrap?.status);
135
+ const inlineTerminal = BOOTSTRAP_TERMINAL_STATUSES.has(inlineStatus);
136
+ const configTerminal = !!configStatus && BOOTSTRAP_TERMINAL_STATUSES.has(configStatus);
137
+ // Config already terminal: only a DIFFERENT terminal inline state (e.g. config 'complete'
138
+ // vs a later 'failed' re-drive) may supersede it; a non-terminal inline read must never
139
+ // mask a terminal config state.
140
+ if (configTerminal) {
141
+ return inlineTerminal && inlineStatus !== configStatus
142
+ && bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
143
+ }
144
+ // Config not terminal: an inline terminal state is always fresher (opens the gate).
145
+ if (inlineTerminal) return true;
146
+ // Both non-terminal: prefer inline only when its epoch is strictly newer (a re-driven
147
+ // bootstrap the config has not observed). Equal/older ⇒ keep config, gate still defers.
148
+ return bootstrapEpochMs(inlineBootstrap) > bootstrapEpochMs(configBootstrap);
149
+ }
150
+
151
+ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
152
+ const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
153
+ const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
154
+ if (!cachedNodes.length) return localMesh;
155
+ // Index inline-cache nodes by identity so we can (a) append cache-only nodes and
156
+ // (b) prefer the inline runtime bootstrap state on config-registered nodes.
157
+ const cacheOnly = cachedNodes.filter((cachedNode: any) => {
158
+ const cachedId = readMeshNodeId(cachedNode);
159
+ // Unidentifiable cache entries can never be a claim/route target — skip them
160
+ // rather than appending junk that no consumer can address.
161
+ if (!cachedId) return false;
162
+ return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
163
+ });
164
+ // Overlay the inline cache's fresher worktreeBootstrap state onto any config node that
165
+ // also exists in the inline cache. inlineBootstrapIsFresher() gates the overlay to the
166
+ // "terminal OR strictly newer" cases, so a stale inline 'running' can never mask a
167
+ // terminal config state and the gate's deferral is preserved for a genuine 'running'.
168
+ let overlaidLocalNodes: any[] = localNodes;
169
+ let overlaid = false;
170
+ for (let i = 0; i < localNodes.length; i++) {
171
+ const localNode = localNodes[i];
172
+ const localId = readMeshNodeId(localNode);
173
+ if (!localId) continue;
174
+ const inlineMatch = cachedNodes.find((cachedNode: any) => meshNodeIdMatches(cachedNode, localId));
175
+ if (!inlineMatch) continue;
176
+ if (!inlineBootstrapIsFresher(inlineMatch.worktreeBootstrap, localNode.worktreeBootstrap)) continue;
177
+ if (!overlaid) {
178
+ overlaidLocalNodes = [...localNodes];
179
+ overlaid = true;
180
+ }
181
+ // Keep the config node's static fields; overlay only the dynamic worktreeBootstrap
182
+ // runtime substate (fresher terminal stamp / epoch) — config identity is unchanged.
183
+ overlaidLocalNodes[i] = { ...localNode, worktreeBootstrap: inlineMatch.worktreeBootstrap };
184
+ }
185
+ if (!cacheOnly.length && !overlaid) return localMesh;
186
+ return { ...localMesh, nodes: [...overlaidLocalNodes, ...cacheOnly] };
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // Queue assignment
191
+ // ---------------------------------------------------------------------------
192
+
193
+ // Per-dispatch confirmation timeout (Bug B). A dispatch promise that never settles —
194
+ // a saturated remote P2P relay that hangs, or a transport that resolves only after
195
+ // the worker acks — would otherwise leave the just-claimed queue row 'assigned' with
196
+ // its delivery stuck 'delivering' forever: the .catch that requeues never fires, and
197
+ // PHASE 3 reconcile skips the row (it counts 0 pending). Racing the dispatch against
198
+ // this timeout guarantees a hung dispatch deterministically returns the task to
199
+ // 'pending' for re-dispatch. Generous so a merely-slow-but-live dispatch (a cold
200
+ // remote relay) is never reclaimed early; the reconcile assigned-stranded watchdog is
201
+ // the durable cross-restart backstop for a timer lost to a daemon restart.
202
+ const DISPATCH_CONFIRM_TIMEOUT_MS = 120_000;
203
+
204
+ // Cold-open connect budget for the warmup-aware REMOTE task dispatch deadline. A
205
+ // remote `agent_command` to a peer whose mesh DataChannel is not open yet first has
206
+ // to drive the cross-machine (often TURN-relayed) handshake; charging that warmup
207
+ // against the response budget is the same cold-open false-timeout the git_status
208
+ // probe path already guards against. This budget bounds ONLY the "channel not open
209
+ // yet" phase; once the channel is warm the DISPATCH_CONFIRM_TIMEOUT_MS response
210
+ // budget governs (identical to the legacy flat guard for an already-open peer, so
211
+ // no latency is added to a normal dispatch). Matches the daemon-cloud
212
+ // DaemonMeshManager CONNECT_TIMEOUT_MS (45s) so the caller-side deadline tracks the
213
+ // transport's own cold-open window rather than guessing.
214
+ //
215
+ // Sourced from the unified, env-overridable MESH_CONNECT_TIMEOUT_MS (runtime-defaults)
216
+ // — the SAME budget the router's direct-peer git_status probe uses. Previously this
217
+ // was a hard-coded 45_000 while the probe path was env-overridable, so setting the
218
+ // env tuned the probe but silently left this dispatch path at 45s (a silent
219
+ // asymmetry). They now move together.
220
+ const DISPATCH_CONNECT_TIMEOUT_MS = MESH_CONNECT_TIMEOUT_MS;
221
+
222
+ // Fail-loud (throttled) trace for a remote dispatch that ran with NO live mesh
223
+ // connection getter wired — the same degraded-warmup misconfiguration the git probe
224
+ // path warns about. Warn once per peer; resolveWarmupDeadlineOpts then falls back to
225
+ // the conservative combined budget instead of silently assuming "always warm".
226
+ const dispatchWarmupGetterMissingWarned = new Set<string>();
227
+ function warnDispatchWarmupGetterMissingOnce(daemonId: string): void {
228
+ if (dispatchWarmupGetterMissingWarned.has(daemonId)) return;
229
+ dispatchWarmupGetterMissingWarned.add(daemonId);
230
+ 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.`);
231
+ }
232
+
233
+ interface DeliverTaskContext {
234
+ meshId: string;
235
+ nodeId: string;
236
+ sessionId: string;
237
+ providerType: string;
238
+ task: MeshWorkQueueEntry;
239
+ transport: 'remote' | 'local';
240
+ sourceCoordinatorSessionId?: string;
241
+ sourceCoordinatorDaemonId?: string;
242
+ }
243
+
244
+ // Readiness barrier for the LOCAL auto-launch path. A just-spawned CLI session is
245
+ // not interactive until its PTY prints the input prompt (the adapter flips
246
+ // isReady() / settles to idle ~2-6s later). Poll the local adapter until it reports
247
+ // ready (or idle), bounded by a generous timeout so a slow/contended boot still
248
+ // lands, and a hard cap so a session that never becomes interactive doesn't block the
249
+ // reconcile loop forever (the adapter's queue-until-ready path is the backstop then).
250
+ const LOCAL_LAUNCH_READY_TIMEOUT_MS = 15_000;
251
+ const LOCAL_LAUNCH_READY_POLL_MS = 100;
252
+
253
+ async function waitForLocalSessionReady(components: DaemonComponents, sessionId: string): Promise<void> {
254
+ const adapter = components.cliManager?.adapters?.get(sessionId) as
255
+ | { isReady?: () => boolean; currentStatus?: string }
256
+ | undefined;
257
+ // No locally-resolvable adapter (e.g. a remote/forwarded session that somehow
258
+ // reached this branch) → nothing to wait on; let dispatch proceed.
259
+ if (!adapter || typeof adapter.isReady !== 'function') return;
260
+ const deadline = Date.now() + LOCAL_LAUNCH_READY_TIMEOUT_MS;
261
+ while (Date.now() < deadline) {
262
+ if (adapter.isReady() || adapter.currentStatus === 'idle') return;
263
+ await new Promise<void>(resolve => setTimeout(resolve, LOCAL_LAUNCH_READY_POLL_MS));
264
+ }
265
+ LOG.warn('MeshQueue', `Auto-launched session ${sessionId} not interactive after ${LOCAL_LAUNCH_READY_TIMEOUT_MS}ms; dispatching anyway (adapter queue-until-ready will buffer)`);
266
+ }
267
+
268
+ // CONS scope 3: the SINGLE source of truth for dispatching a claimed task to its
269
+ // session. The remote (P2P dispatchMeshCommand) and local (cliManager.handleCliCommand)
270
+ // branches differ ONLY in the transport call — the delivery record, the delivered/failed
271
+ // transitions, the pending-requeue-on-failure, the dispatch_failed ledger entry, AND the
272
+ // Bug B hang timeout are identical and live here once so a future change to the dispatch
273
+ // lifecycle cannot drift between the two paths. The caller passes a `dispatchThunk` that
274
+ // performs only the transport-specific send and returns its promise.
275
+ //
276
+ // Cold-open warmup (remote only): the REMOTE transport speaks over a P2P
277
+ // DataChannel that may still be opening when the first task is dispatched to a peer.
278
+ // When `warmup` is supplied the dispatch is awaited under the warmup-aware deadline
279
+ // (mesh-warmup-deadline) — the cold-open handshake is charged to the connect budget
280
+ // and only the warm round trip to the DISPATCH_CONFIRM_TIMEOUT_MS response budget —
281
+ // so the very first dispatch to a not-yet-open peer is no longer false-timed at the
282
+ // combined window. An already-open peer behaves identically to the legacy flat guard
283
+ // (response budget governs from t0), so a normal dispatch sees no added latency. The
284
+ // LOCAL transport (in-process cliManager) has no channel to warm up and keeps the
285
+ // flat Bug B hang guard.
286
+ function deliverTaskToSession(
287
+ dispatchThunk: () => Promise<unknown>,
288
+ ctx: DeliverTaskContext,
289
+ warmup?: { daemonId: string; getConnection?: (daemonId: string) => Record<string, unknown> | null },
290
+ ): void {
291
+ const delivery = createSessionDelivery({
292
+ meshId: ctx.meshId,
293
+ nodeId: ctx.nodeId,
294
+ sessionId: ctx.sessionId,
295
+ providerType: ctx.providerType,
296
+ taskId: ctx.task.id,
297
+ kind: 'task',
298
+ message: ctx.task.message,
299
+ status: 'delivering',
300
+ ...(ctx.sourceCoordinatorSessionId ? { sourceCoordinatorSessionId: ctx.sourceCoordinatorSessionId } : {}),
301
+ ...(ctx.sourceCoordinatorDaemonId ? { sourceCoordinatorDaemonId: ctx.sourceCoordinatorDaemonId } : {}),
302
+ });
303
+
304
+ // Invoke the transport synchronously (preserves the prior fire-and-forget timing,
305
+ // and lets a synchronous throw fall into the same failure path as a rejection).
306
+ let dispatchPromise: Promise<unknown>;
307
+ try {
308
+ dispatchPromise = Promise.resolve(dispatchThunk());
309
+ } catch (e) {
310
+ dispatchPromise = Promise.reject(e);
311
+ }
312
+
313
+ let timer: ReturnType<typeof setTimeout> | undefined;
314
+ let guarded: Promise<unknown>;
315
+ if (warmup) {
316
+ // Remote P2P: cold-open-aware deadline. awaitWithWarmupDeadline owns its own
317
+ // timers (so `timer` stays undefined and the clearTimeout below is a no-op),
318
+ // and rejects with Error('timeout') when either budget lapses — the same
319
+ // retryable failure shape the catch below already handles (requeue + ledger).
320
+ guarded = awaitWithWarmupDeadline(dispatchPromise, resolveWarmupDeadlineOpts({
321
+ getConnection: warmup.getConnection,
322
+ daemonId: warmup.daemonId,
323
+ connectTimeoutMs: DISPATCH_CONNECT_TIMEOUT_MS,
324
+ responseTimeoutMs: DISPATCH_CONFIRM_TIMEOUT_MS,
325
+ onMissingGetter: warnDispatchWarmupGetterMissingOnce,
326
+ }));
327
+ } else {
328
+ guarded = Promise.race([
329
+ dispatchPromise,
330
+ new Promise<never>((_, reject) => {
331
+ timer = setTimeout(
332
+ () => reject(new Error(`dispatch_confirm_timeout after ${DISPATCH_CONFIRM_TIMEOUT_MS}ms`)),
333
+ DISPATCH_CONFIRM_TIMEOUT_MS,
334
+ );
335
+ // Never keep the process alive solely for this confirm-timeout timer.
336
+ if (typeof (timer as { unref?: () => void })?.unref === 'function') (timer as { unref: () => void }).unref();
337
+ }),
338
+ ]);
339
+ }
340
+
341
+ guarded.then(() => {
342
+ if (timer) clearTimeout(timer);
343
+ updateSessionDeliveryStatus(delivery.id, 'delivered');
344
+ }).catch((e: any) => {
345
+ if (timer) clearTimeout(timer);
346
+ // A dispatch failure (transport reject OR hang timeout) is most often transient —
347
+ // a busy/refusing adapter, or a relay that never acked — not a permanent task
348
+ // failure. Marking the task terminal here would permanently kill tasks a later
349
+ // tick delivers fine. Return it to 'pending' and record a retryable dispatch_failed
350
+ // ledger entry so the reconcile loop re-dispatches it. Identical for both transports.
351
+ LOG.error('MeshQueue', `Failed to dispatch task via ${ctx.transport} to node ${ctx.nodeId}: ${e?.message}`);
352
+ updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
353
+ // The dispatch failed — the task is no longer in-flight (it returns to pending
354
+ // for a clean re-dispatch). Clear the single-flight mark so a legitimate
355
+ // requeue/re-claim is not blocked as if a worker were still generating.
356
+ endTaskDispatchInFlight(ctx.meshId, ctx.task.id);
357
+ updateTaskStatus(ctx.meshId, ctx.task.id, 'pending');
358
+ try {
359
+ appendLedgerEntry(ctx.meshId, {
360
+ kind: 'dispatch_failed' as any,
361
+ nodeId: ctx.nodeId,
362
+ sessionId: ctx.sessionId,
363
+ payload: { taskId: ctx.task.id, deliveryId: delivery.id, error: e?.message, retryable: true, transport: ctx.transport },
364
+ });
365
+ } catch { /* ledger write is best-effort */ }
366
+ });
367
+ }
368
+
369
+ // WTCLAIM: workspace normalization for base-vs-worktree comparison now lives in
370
+ // @adhdev/mesh-shared (normalizeMeshWorkspaceForCompare) so the enqueue→claim path,
371
+ // the mesh_status per-node session filter, and the read_chat node scope guard all
372
+ // share one comparison rule instead of drifting module-private copies.
373
+
374
+ export function tryAssignQueueTask(
375
+ components: DaemonComponents,
376
+ meshId: string,
377
+ nodeId: string,
378
+ sessionId: string,
379
+ providerType: string
380
+ ): boolean {
381
+ const mesh = getMeshWithCache(components, meshId);
382
+ // Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
383
+ // `n.id` — a stamp-form nodeId vs the mesh node's config-form id must still
384
+ // resolve, mirroring the remote idle-session path below (:1341).
385
+ const node = mesh?.nodes.find((n: any) => meshNodeIdMatches(n, nodeId));
386
+
387
+ // WORKTREE-CLAIM-GATE-BYPASS: the SINGLE claim-time gate for the worktree-bootstrap defer.
388
+ // tryAssignQueueTask is the one funnel every claim path flows through — the event-driven
389
+ // agent:ready drain, the triggerMeshQueue idle-session drain (local + remote), the
390
+ // auto-launch claim, and the PHASE 3 reconcile re-drain all call it. The agent:ready handler
391
+ // (mesh-event-forwarding) deferred its OWN claim while a worktree node's bootstrap was still
392
+ // 'running', but it ALSO called setRemoteIdleSession first — registering the session as a
393
+ // claim candidate. A concurrent triggerMeshQueue drain then pulled that candidate and claimed
394
+ // through tryAssignQueueTask within ~0.16s, BYPASSING the event-handler-local defer: the task
395
+ // dispatched into a half-built worktree (native addons not yet installed → child daemon dies
396
+ // → empty session, totalMessages=0). The transport ack returns ok:true, so neither the
397
+ // assigned-stranded watchdog nor the pending-only PHASE 3 reconcile ever re-fires it → the
398
+ // session is stranded empty forever.
399
+ //
400
+ // Lowering the gate HERE makes the defer a property of the claim itself, not of one caller:
401
+ // a worktree node whose bootstrap is still 'running' can never be claimed from any path. The
402
+ // task stays pending (we return false WITHOUT touching its status — no fail/cancel), so the
403
+ // bootstrap_complete refire (triggerMeshQueue re-fired on worktree_bootstrap_complete) re-runs
404
+ // this claim and passes once status is no longer 'running'. The registered remote-idle session
405
+ // persists for REMOTE_IDLE_SESSION_TTL_MS (5min > observed ~2m8s bootstrap), so the refire
406
+ // still finds a live candidate to re-claim. Identity uses meshNodeIdMatches (the same shared
407
+ // 3-form normalizer the defer guard uses), never a raw === — canon-identity regression guard.
408
+ // Conservative: any non-'running' status (idle/complete/failed/absent/unknown) does NOT gate,
409
+ // so a base node and a fully-bootstrapped worktree keep prior behavior exactly.
410
+ // COMPLETION-PROPAGATION F7 (C2 SSOT): resolve the node's bootstrap status from the router's
411
+ // synchronous inline cache FIRST — the authoritative source markWorktreeBootstrapTerminalState
412
+ // stamps synchronously — falling back to the merged claim view only when the inline node carries
413
+ // no bootstrap status. getMeshWithCache takes a config-REGISTERED node verbatim from local
414
+ // config, whose bootstrap status lags the inline stamp (the detached async persist chain), so a
415
+ // config-registered worktree node could read a stale 'running' here and defer a claim whose
416
+ // bootstrap is already complete. Reading the inline node removes that stale-'running' defer,
417
+ // symmetric with the remote dispatch guard (cli-agent.ts F6). Conservative: only override with
418
+ // the inline node when it actually carries a status (an incomplete inline entry never masks a
419
+ // genuine config 'running').
420
+ const inlineBootstrapNode = (() => {
421
+ try {
422
+ const inlineMesh = components.router?.getCachedInlineMesh?.(meshId);
423
+ const inlineNode = Array.isArray(inlineMesh?.nodes)
424
+ ? inlineMesh.nodes.find((n: any) => meshNodeIdMatches(n, nodeId))
425
+ : undefined;
426
+ return readNonEmptyString(inlineNode?.worktreeBootstrap?.status) ? inlineNode : undefined;
427
+ } catch { return undefined; }
428
+ })();
429
+ const bootstrapGateNode = inlineBootstrapNode ?? node;
430
+ if ((bootstrapGateNode as { worktreeBootstrap?: { status?: string } } | undefined)?.worktreeBootstrap?.status === 'running') {
431
+ // Fix (3) safety net + F7: shouldDeferDispatchForBootstrap returns false when the 'running'
432
+ // state is stale (older than the backstop AND git-clean) — treat that as silently complete
433
+ // and allow the claim; otherwise defer (leave the task pending) so the claim re-fires once
434
+ // bootstrap reaches a terminal state and never dispatches into a half-built worktree.
435
+ if (!shouldDeferDispatchForBootstrap(bootstrapGateNode as any)) {
436
+ LOG.warn('MeshQueue', `Worktree node ${nodeId} (${sessionId}) bootstrap stuck 'running' beyond the stale backstop and its worktree is git-clean — treating bootstrap as silently complete and allowing the claim (the terminal-state stamp likely never reached this daemon's mesh view)`);
437
+ } else {
438
+ LOG.info('MeshQueue', `Gating queue claim for worktree node ${nodeId} (${sessionId}): worktree bootstrap still running — task left pending; claim re-fires once bootstrap reaches a terminal state (guards against dispatching into a half-built worktree → empty session)`);
439
+ return false;
440
+ }
441
+ }
442
+
443
+ // WTCLAIM (fix-B extended to the enqueue→claim path): a base-targeted task must never be
444
+ // claimed by — and dispatched into — a co-located worktree-clone session, nor vice versa.
445
+ // The drain candidate's nodeId is derived from settings.meshNodeId || settings.nodeId
446
+ // (triggerMeshQueue), so a worktree session whose meshNodeId is empty/stale falls back to
447
+ // settings.nodeId = the BASE node id and impersonates the base node here. fix-B's worker-side
448
+ // workspace scope only ran for sessionless dispatch (meshScopeNodeId && !targetSessionId); the
449
+ // claim path ALWAYS carries a targetSessionId, so it never engaged. Apply the same scope here:
450
+ // for a LOCAL claiming session (adapter resolvable on this daemon), require its actual
451
+ // workingDir to match the target node's declared workspace. On a confirmed mismatch, refuse the
452
+ // claim so the task returns to pending for the correctly-scoped session/node to pull. Scoped to
453
+ // local sessions where the workspace is verifiable — a remote session lives on another daemon
454
+ // whose paths we cannot compare here (and remote candidates are already nodeId-matched from
455
+ // getRemoteIdleSessions). Conservative by design: when either workspace is unknown we do NOT
456
+ // skip, so a node with no declared workspace keeps its prior behavior and no legitimate claim
457
+ // is starved.
458
+ // WTDISPATCH (residual of WTCLAIM): the cross-node claim guard must reach EVERY claiming
459
+ // session this daemon can observe — not only those whose adapter happens to be in
460
+ // cliManager.adapters. An auto-launched worker session can carry its node binding on the
461
+ // CLI-instance settings while its session-host record shows no_node_binding, and the
462
+ // event-driven / remote-idle drain (agent:ready → setRemoteIdleSession → tryAssignQueueTask)
463
+ // can pass a nodeId that does NOT belong to the claiming session — a sibling worktree node
464
+ // on the SAME daemon. The adapter-only WTCLAIM check (rc.361/4c5b30b1) never engaged for a
465
+ // session observed solely via instanceManager, so session A could pull node B's task and
466
+ // node A's task was left with no session to claim it (no task_dispatched — it never dispatches).
467
+ //
468
+ // Resolve the claiming session's REAL identity from the adapter workingDir, then fall back to
469
+ // the live CLI instance's workspace + its stamped meshNodeId, and refuse a claim that
470
+ // contradicts EITHER (fail-closed). Reuses the shared meshWorkspacesEquivalent / meshNodeIdMatches
471
+ // comparators — no new comparison logic. Conservative: when neither the workspace NOR the stamp
472
+ // is resolvable we do NOT refuse, so a node with no declared workspace keeps prior behavior and
473
+ // a genuinely remote (cross-daemon) candidate stays nodeId-matched from getRemoteIdleSessions.
474
+ const localClaimAdapter = components.cliManager?.adapters?.get(sessionId) as { workingDir?: string } | undefined;
475
+ let claimInstanceWorkspace = '';
476
+ let claimStampedNodeId = '';
477
+ try {
478
+ const claimState = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
479
+ claimInstanceWorkspace = readNonEmptyString(claimState?.workspace);
480
+ const claimSettings = (claimState?.settings as Record<string, unknown>) || {};
481
+ claimStampedNodeId = readNonEmptyString(claimSettings.meshNodeId);
482
+ } catch { /* best-effort — fall through to the conservative (no refuse) path */ }
483
+
484
+ const nodeWorkspaceRaw = readNonEmptyString(node?.workspace);
485
+ const sessionWorkspaceRaw = readNonEmptyString(localClaimAdapter?.workingDir) || claimInstanceWorkspace;
486
+
487
+ if (claimStampedNodeId && nodeId) {
488
+ // The session carries its OWN meshNodeId stamp — its authoritative node identity, set when
489
+ // the coordinator launched/dispatched it (mesh-routing trusts this stamp FIRST). When it
490
+ // matches the claim target the session genuinely belongs to this node, so the stamp settles
491
+ // it and the workspace heuristic is skipped (a base/worktree pair can legitimately share a
492
+ // workspace). When it does NOT match, the claim is a cross-node leak — refuse, fail-closed.
493
+ if (!meshNodeIdMatches({ id: claimStampedNodeId } as MeshNodeIdentified, nodeId)) {
494
+ LOG.info('MeshQueue', `WTDISPATCH: refusing claim for node ${nodeId} (${sessionId}) — session is bound to node "${claimStampedNodeId}" (cross-node claim blocked)`);
495
+ return false;
496
+ }
497
+ } else if (sessionWorkspaceRaw && nodeWorkspaceRaw && !meshWorkspacesEquivalent(sessionWorkspaceRaw, nodeWorkspaceRaw)) {
498
+ // No stamp (the no_node_binding worker) — fall back to the workspace to tell two co-located
499
+ // sibling worktree sessions apart. WTCLAIM, now reaching instanceManager-observable sessions
500
+ // too. Conservative: unknown workspace on either side → do NOT refuse (no legitimate claim
501
+ // starved; a genuinely remote cross-daemon candidate stays nodeId-matched as before).
502
+ LOG.info('MeshQueue', `WTCLAIM: refusing claim for node ${nodeId} (${sessionId}) — session workspace "${normalizeMeshWorkspaceForCompare(sessionWorkspaceRaw)}" ≠ node workspace "${normalizeMeshWorkspaceForCompare(nodeWorkspaceRaw)}" (cross-workspace dispatch blocked)`);
503
+ return false;
504
+ }
505
+
506
+ const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
507
+ // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy.providerRoles) layers
508
+ // on top of the global/taskMode caps — stricter wins. Resolved here where the
509
+ // claiming session's providerType + node policy are both known, then enforced
510
+ // inside the atomic claim transaction so concurrent claims can't overshoot it.
511
+ const providerMaxParallel = resolveProviderMaxParallel(node?.policy, providerType);
512
+ // WTDISPATCH-FANOUT: tell the atomic claim whether the claiming node is a worktree
513
+ // clone so a `convergence` task (base-only: merge → push → cleanup) is refused for
514
+ // worktree sessions. Without it, every sibling worktree session on this daemon could
515
+ // claim the same convergence intent and race push/production-deploy (the 4-way fan-out).
516
+ const nodeIsWorktree = node?.isLocalWorktree === true;
517
+ const task = claimNextTask(meshId, nodeId, sessionId, capabilityTags, {
518
+ providerType,
519
+ ...(providerMaxParallel !== undefined ? { providerMaxParallel } : {}),
520
+ nodeIsWorktree,
521
+ });
522
+ if (!task) {
523
+ return false;
524
+ }
525
+
526
+ const terminal = findTerminalLedgerEvidenceForTask({
527
+ meshId,
528
+ taskId: task.id,
529
+ });
530
+ if (terminal) {
531
+ const status = terminal.kind === 'task_completed' ? 'completed' : 'failed';
532
+ updateTaskStatus(meshId, task.id, status);
533
+ LOG.info('MeshQueue', `Skipped dispatch for terminal task ${task.id} on mesh ${meshId}; ${terminal.kind} ledger evidence already exists`);
534
+ traceMeshEventDrop('dispatch_terminal_ledger', {
535
+ taskId: task.id,
536
+ sessionId,
537
+ nodeId,
538
+ meshId,
539
+ event: 'agent_command',
540
+ }, terminal.kind);
541
+ return false;
542
+ }
543
+
544
+ LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
545
+
546
+ // FALSE-BLOCKER-CLONE-QUEUE (stale-event clear): the task just claimed and will dispatch,
547
+ // so any actionable blocker previously paged for it (e.g. a 'target_node_id_unmatched'
548
+ // emitted during the clone/bootstrap propagation window before the node became
549
+ // claimable) is now stale — re-arm the de-dup ledger and retract any undelivered
550
+ // dispatch_blocked event so the coordinator does not keep seeing a resolved blocker.
551
+ retractActionableSkipIfPreviouslyNotified(meshId, task.id);
552
+
553
+ // CANON-IDENTITY single-flight: mark the just-claimed task in-flight the moment it
554
+ // is handed to a transport. The atomic claim already prevents a concurrent claim,
555
+ // but this lets requeueTask distinguish a genuinely-generating task (refuse the
556
+ // operator requeue — it would open a second session) from a stale assigned row
557
+ // (still requeueable). Cleared when the task leaves `assigned` (terminal / dispatch
558
+ // failure / cancel / reclaim).
559
+ beginTaskDispatchInFlight(meshId, task.id);
560
+
561
+ // CANON-IDENTITY: read the remote daemon id through the normalizing helper so a node
562
+ // whose daemonId arrives in a non-top-level-camelCase serialization form (daemon_id /
563
+ // machine.daemonId / lastProbe.machine.daemon_id / …) is still recognized as remote.
564
+ // Reading raw `node.daemonId` here made the guard false for those forms, so the remote
565
+ // block was skipped and execution fell through to the LOCAL cliManager.handleCliCommand
566
+ // path — which has no adapter for the remote sessionId and threw
567
+ // 'Cannot read properties of undefined (reading handleCliCommand)'.
568
+ const remoteDaemonId = readMeshNodeDaemonId(node ?? {});
569
+ if (remoteDaemonId && components.dispatchMeshCommand) {
570
+ const isLocalNode = components.cliManager.adapters.has(sessionId);
571
+ if (!isLocalNode) {
572
+ const localDaemonIdForDispatch = localCoordinatorDaemonId();
573
+ // (3) Originating coordinator session that enqueued this task — route its
574
+ // completion back to that exact session (multi-coordinator). Carried over P2P
575
+ // to the remote worker, which echoes it on its completion event.
576
+ const sourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId) || undefined;
577
+ const dispatchMeshCommand = components.dispatchMeshCommand;
578
+ // CONS3: only the transport call differs — everything else (delivery record,
579
+ // status transitions, requeue-on-failure, ledger, Bug B hang timeout) is in
580
+ // the shared deliverTaskToSession helper.
581
+ deliverTaskToSession(
582
+ () => dispatchMeshCommand(remoteDaemonId, 'agent_command', {
583
+ targetSessionId: sessionId,
584
+ cliType: providerType,
585
+ action: 'send_chat',
586
+ message: task.message,
587
+ meshContext: {
588
+ meshId,
589
+ nodeId,
590
+ taskId: task.id,
591
+ // REDRIVE-DUP: carry the current dispatch nonce so the worker can echo it
592
+ // back on generating_started; a reclaim bumps this row's nonce, making an
593
+ // already-in-flight stale inject rejectable on arrival.
594
+ ...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
595
+ ...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
596
+ ...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
597
+ },
598
+ }),
599
+ {
600
+ meshId,
601
+ nodeId,
602
+ sessionId,
603
+ providerType,
604
+ task,
605
+ transport: 'remote',
606
+ ...(sourceCoordinatorSessionId ? { sourceCoordinatorSessionId } : {}),
607
+ ...(localDaemonIdForDispatch ? { sourceCoordinatorDaemonId: localDaemonIdForDispatch } : {}),
608
+ },
609
+ // Warmup-aware deadline: this dispatch can be the FIRST command to a
610
+ // peer whose mesh DataChannel is still opening — charge the cold-open
611
+ // handshake to the connect budget, not the response budget.
612
+ { daemonId: remoteDaemonId, getConnection: components.getMeshPeerConnectionStatus },
613
+ );
614
+ return true;
615
+ }
616
+ }
617
+
618
+ // Stamp mesh context onto the session so completion events route correctly
619
+ // via setupMeshEventForwarding. Without this, manually-opened idle sessions
620
+ // (mesh_launch_session without auto-launch) lack meshNodeFor/meshNodeId and
621
+ // agent:generating_completed is silently dropped as isMeshDelegate=false.
622
+ try {
623
+ const inst = components.instanceManager.getInstance(sessionId);
624
+ if (inst && typeof inst.updateSettings === 'function') {
625
+ // Adopting a (possibly manually-opened) local session as a worker: apply the
626
+ // delegated-worker auto-approve policy here too, so a session that was launched
627
+ // without autoApprove still auto-approves once the coordinator dispatches a task
628
+ // to it (the "approval notification fires only for certain delegated sessions"
629
+ // case). updateSettings preserves runtime mesh keys; passing autoApprove keeps it.
630
+ //
631
+ // This local-dispatch branch also runs on the coordinator daemon for a co-located
632
+ // session, so the coordinator daemon id IS this daemon's id. Stamp it alongside
633
+ // the node identity so the session is fully relay-safe (meshCoordinatorDaemonId is
634
+ // the anchor the forwarder keys on), matching what mesh_launch_session stamps.
635
+ const localDaemonId = localCoordinatorDaemonId();
636
+ const localSourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId);
637
+ inst.updateSettings({
638
+ meshNodeFor: meshId,
639
+ meshNodeId: nodeId,
640
+ launchedByCoordinator: true,
641
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
642
+ ...(localDaemonId ? { meshCoordinatorDaemonId: localDaemonId } : {}),
643
+ // COMPLETION-PROPAGATION F5: (re)stamp the coordinator SESSION anchor from THIS
644
+ // task's sourceCoordinatorSessionId with PRIORITY — a manually-launched (or reused)
645
+ // session may already carry a stale anchor from mesh_launch_session or a prior task,
646
+ // and a stale session anchor makes the completion unicast to the wrong/absent
647
+ // coordinator session (targetCoordinatorSessionId), stranding it. When this task
648
+ // carries a source, overwrite; when it carries NONE, CLEAR the anchor to undefined
649
+ // (updateSettings merges, so an explicit undefined overrides) so the completion
650
+ // cannot be misrouted by a stale unicast anchor and instead BROADCASTS — the real
651
+ // coordinator (which drains its own pending queue) then picks it up. Daemon-level
652
+ // routing (meshCoordinatorDaemonId above) is unaffected.
653
+ meshCoordinatorSessionId: localSourceCoordinatorSessionId || undefined,
654
+ });
655
+ }
656
+ } catch { /* best-effort — dispatch still proceeds */ }
657
+
658
+ // CONS3: same shared dispatch lifecycle as the remote branch — only the transport
659
+ // (cliManager.handleCliCommand) differs.
660
+ // ARCH-REFACTOR R1: carry meshContext (incl. taskId) on the LOCAL dispatch too, so
661
+ // handleCliCommand's send_chat path binds this task to its turn (per-turn identity).
662
+ // Previously only the remote branch shipped meshContext.taskId; the local path relied
663
+ // on the last-write-wins session scalar, which races a follow-up task and made the
664
+ // completion echo the wrong taskId (the standalone NOTIF-MISDELIVER repro).
665
+ deliverTaskToSession(
666
+ () => components.cliManager.handleCliCommand('agent_command', {
667
+ targetSessionId: sessionId,
668
+ cliType: providerType,
669
+ action: 'send_chat',
670
+ message: task.message,
671
+ meshContext: {
672
+ meshId,
673
+ nodeId,
674
+ taskId: task.id,
675
+ // REDRIVE-DUP: carry the current dispatch nonce (see remote branch above).
676
+ ...(typeof task.dispatchNonce === 'number' ? { dispatchNonce: task.dispatchNonce } : {}),
677
+ ...(localCoordinatorDaemonId() ? { coordinatorDaemonId: localCoordinatorDaemonId() } : {}),
678
+ ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { coordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
679
+ },
680
+ }),
681
+ {
682
+ meshId,
683
+ nodeId,
684
+ sessionId,
685
+ providerType,
686
+ task,
687
+ transport: 'local',
688
+ ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { sourceCoordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
689
+ ...(localCoordinatorDaemonId() ? { sourceCoordinatorDaemonId: localCoordinatorDaemonId() } : {}),
690
+ },
691
+ );
692
+
693
+ return true;
694
+ }
695
+
696
+ const autoLaunchInProgress = new Set<string>();
697
+ const autoLaunchCooldownUntil = new Map<string, number>();
698
+ const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
699
+ // A remote auto-launch (launch_cli forward) is fire-and-async: the worker session
700
+ // spawns, reaches idle, emits agent:ready, that ready is queued on the worker, pulled
701
+ // by this coordinator (reconcile PHASE 1), and only THEN claims the task. That round
702
+ // trip routinely exceeds the 5s per-(mesh,node) cooldown, so cooldown alone lets the
703
+ // reconcile loop fire a SECOND launch for the same still-pending task before the first
704
+ // session's claim lands — every tick spawns yet another orphan session (observed live:
705
+ // 26 sessions for one task). This is a per-TASK await-claim window: once a task has a
706
+ // successfully-launched session whose claim we are still waiting on, do not launch it
707
+ // again until the window lapses. It is generous (a slow remote spawn can take tens of
708
+ // seconds) but bounded so a launch that silently never reaches idle is eventually retried.
709
+ const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
710
+
711
+ // AUTOLAUNCH-CLAIM-CHURN. For a REMOTE node the launch→claim handshake is purely
712
+ // event-sourced: the worker's agent:ready must be pulled (reconcile PHASE 1) to run
713
+ // setRemoteIdleSession before the drain can claim. If that pull is lost, nothing recovers,
714
+ // and after AUTO_LAUNCH_AWAIT_CLAIM_MS the loop used to blindly RESPAWN a new session — whose
715
+ // respawn guards (nodeHasLiveSessionPendingClaim / liveSessionCountForNode) scan only the LOCAL
716
+ // instanceManager, so the remote pending-claim session is invisible and a fresh ghost accumulates
717
+ // every ~90s (observed live 2026-07-04: task 8b188c64, and 7 ghost sessions on this worktree's
718
+ // own task at 11:23-11:34). Instead of respawning on window expiry, we re-drive the claim for the
719
+ // EXISTING session; when its liveness cannot be positively determined we EXTEND the window with
720
+ // exponential backoff (90 → 180 → 360s) and, only after the cap, deliver the task directly into
721
+ // the launched session (the mesh_send_task-equivalent) rather than spawning another worker.
722
+ const AUTO_LAUNCH_AWAIT_CLAIM_BACKOFF_CAP_CYCLES = 2;
723
+ // Local mirror of REMOTE_IDLE_SESSION_TTL_MS (mesh-event-forwarding) — kept here to avoid a
724
+ // cross-module import cycle. Used when (re)registering a launched remote session as an idle
725
+ // claim candidate during the await-claim re-drive.
726
+ const AUTO_LAUNCH_REMOTE_IDLE_TTL_MS = 5 * 60 * 1000;
727
+
728
+ // Per-task await-claim backoff state, keyed `${meshId}::${taskId}`. `cycles` counts how many
729
+ // times the window has been extended; `nextAttemptAtMs` rate-limits the re-drive to the backoff
730
+ // cadence so the 4s reconcile tick does not hammer it. Cleared once the task claims, the direct
731
+ // dispatch fires, or a respawn is authorized. In-memory (per process); a stale entry is harmless
732
+ // (it only defers a respawn) and self-clears on the next resolution.
733
+ interface AwaitClaimBackoffState { cycles: number; nextAttemptAtMs: number; }
734
+ const autoLaunchAwaitClaimBackoff = new Map<string, AwaitClaimBackoffState>();
735
+
736
+ // Test hooks: reset / seed the await-claim backoff state between cases.
737
+ export function __resetAutoLaunchAwaitClaimBackoffForTests(): void {
738
+ autoLaunchAwaitClaimBackoff.clear();
739
+ }
740
+ export function __seedAutoLaunchAwaitClaimBackoffForTests(meshId: string, taskId: string, state: AwaitClaimBackoffState): void {
741
+ autoLaunchAwaitClaimBackoff.set(`${meshId}::${taskId}`, { ...state });
742
+ }
743
+
744
+ // Backoff window for a given cycle count: 90 → 180 → 360s (capped at the cap-cycle multiplier).
745
+ function awaitClaimWindowMs(cycles: number): number {
746
+ return AUTO_LAUNCH_AWAIT_CLAIM_MS * Math.pow(2, Math.min(cycles, AUTO_LAUNCH_AWAIT_CLAIM_BACKOFF_CAP_CYCLES));
747
+ }
748
+
749
+ // Does the coordinator's remote-session view (MeshRuntimeStore remote idle sessions, populated by
750
+ // mesh event forwarding) currently show this session as a live idle claim candidate? Positive
751
+ // evidence the launched remote session is reachable — used to re-drive its claim directly instead
752
+ // of respawning. Absence is NOT proof the session is gone (the agent:ready pull may simply have
753
+ // been lost), so callers treat a false here as UNKNOWN liveness, never a definitive terminal.
754
+ function remoteSessionAppearsLive(meshId: string, sessionId: string): boolean {
755
+ if (!sessionId) return false;
756
+ try {
757
+ return MeshRuntimeStore.getInstance().getRemoteIdleSessions(meshId)
758
+ .some(s => sessionIdsEquivalent(s.sessionId, sessionId));
759
+ } catch {
760
+ return false;
761
+ }
762
+ }
763
+
764
+ // (A) Respawn-guard remote-awareness. The session ids of pending tasks whose auto-launch record
765
+ // targets `nodeId` (status started/completed with a sessionId) and is still inside its await-claim
766
+ // window — the base 90s window OR an active backoff extension. Such a session is ALREADY on its way
767
+ // to claim even when it is REMOTE (invisible to this daemon's instanceManager), so counting it
768
+ // suppresses a duplicate launch that would otherwise spawn a ghost.
769
+ function inWindowAutoLaunchSessionIdsForNode(meshId: string, nodeId: string): string[] {
770
+ const nowMs = Date.now();
771
+ const out: string[] = [];
772
+ for (const task of getQueue(meshId, { status: ['pending'] as any })) {
773
+ const al = task.autoLaunch;
774
+ const sid = al ? readNonEmptyString(al.sessionId) : '';
775
+ if (!al || (al.status !== 'started' && al.status !== 'completed') || !sid) continue;
776
+ if (!daemonIdsEquivalent(al.nodeId, nodeId)) continue;
777
+ const launchedAtMs = Date.parse(al.updatedAt);
778
+ const inBaseWindow = Number.isFinite(launchedAtMs) && nowMs - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
779
+ const inBackoff = autoLaunchAwaitClaimBackoff.has(`${meshId}::${task.id}`);
780
+ if (inBaseWindow || inBackoff) out.push(sid);
781
+ }
782
+ return out;
783
+ }
784
+
785
+ // De-dup for repeated `skipped` ledger noise: the reconcile loop re-runs the queue
786
+ // trigger every 4s, so a task that can't be claimed (e.g. a remote node with no
787
+ // transport, or a node under cooldown) would otherwise append an identical
788
+ // session_auto_launch{phase:'skipped'} entry on every tick — flooding the ledger.
789
+ // We suppress a `skipped` ledger append when the immediately-prior recorded event
790
+ // for that task was the SAME (phase, reason). Any non-skip phase (started/failed/
791
+ // completed) or a changed reason resets the de-dup so real transitions still record.
792
+ const lastAutoLaunchLedgerKey = new Map<string, string>();
793
+ const AUTO_LAUNCH_LEDGER_DEDUP_MAX = 2000;
794
+
795
+ // Fix (1): actionable dispatch-skip notification.
796
+ //
797
+ // User-core gap: when a queued task cannot be dispatched the coordinator (Claude Code via
798
+ // MCP) had no proactive signal — the skip was only recorded to task.autoLaunch + the ledger,
799
+ // both of which the coordinator must poll to discover. For a skip that will NOT self-resolve
800
+ // (a routing miss, no capable node, a convergence task pinned to a worktree, an unusable
801
+ // provider, an unreachable remote, or a dirty workspace) the coordinator could sit waiting
802
+ // for a dispatch that can never happen. We now actively surface those — and ONLY those — as a
803
+ // pending coordinator event carrying a "why + how to act" message, routed to the originating
804
+ // coordinator (sourceCoordinator*). Transient/back-pressure skips (cooldown, in-progress,
805
+ // awaiting-claim, parallel/session caps, node not yet launch-ready, an active assignment) are
806
+ // deliberately excluded: they clear on their own and would only spam the coordinator every 4s.
807
+ const ACTIONABLE_SKIP_REASON_PREFIXES = [
808
+ 'target_node_id_unmatched',
809
+ 'no_node_satisfies_required_tags',
810
+ 'mesh_convergence_target_is_worktree',
811
+ 'remote_auto_launch_unsupported',
812
+ 'remote_auto_launch_no_coordinator_daemon_id',
813
+ 'missing_provider_priority',
814
+ 'provider_loader_unavailable',
815
+ 'provider_priority_unusable',
816
+ 'provider_unusable',
817
+ 'dirty_workspace',
818
+ ];
819
+
820
+ // FALSE-BLOCKER-CLONE-QUEUE: the TRANSIENT counterpart of 'target_node_id_unmatched'. A
821
+ // queue task pinned to a freshly cloned worktree node can transiently find no matching node
822
+ // (its inline-cache entry has not propagated to this coordinator daemon yet, and/or its
823
+ // worktree bootstrap is still running). That unmatch SELF-RESOLVES — it is not the permanent
824
+ // routing miss the actionable blocker exists for — so it is deliberately NOT listed in
825
+ // ACTIONABLE_SKIP_REASON_PREFIXES: isActionableSkipReason() returns false for it, so no
826
+ // "actionable blocker — will NOT clear on its own" coordinator page is emitted. The skip is
827
+ // still recorded to task.autoLaunch + the ledger for diagnosability.
828
+ const TRANSIENT_TARGET_NODE_BOOTSTRAP_PENDING_REASON = 'target_node_bootstrap_pending';
829
+
830
+ // De-dup actionable-skip coordinator notifications: emit once per (mesh, task) until the
831
+ // reason CHANGES, so the 4s reconcile loop re-marking the same skip does not re-notify. A
832
+ // non-skip transition (or a genuine reason change) re-arms it. In-memory only — a daemon
833
+ // restart re-notifies once, which is the correct behaviour after a restart.
834
+ const lastActionableSkipNotified = new Map<string, string>();
835
+
836
+ function isActionableSkipReason(reason?: string): boolean {
837
+ if (!reason) return false;
838
+ return ACTIONABLE_SKIP_REASON_PREFIXES.some(prefix => reason === prefix || reason.startsWith(prefix));
839
+ }
840
+
841
+ /**
842
+ * FALSE-BLOCKER-CLONE-QUEUE: a target pin is TRANSIENTLY (not permanently) unresolved when
843
+ * the pinned node is a freshly cloned worktree that will auto-claim once its bootstrap
844
+ * completes / its inline-cache entry propagates — as opposed to a removed/dead node whose
845
+ * unmatch is a permanent, actionable routing miss. Two signals, either suffices:
846
+ * (a) the node IS visible in the (cache-merged) mesh view but its worktree bootstrap is
847
+ * still 'running' (and not stuck past the stale backstop), or
848
+ * (b) the node is NOT visible here yet, but a clone for its id was issued within the grace
849
+ * window (propagation/bootstrap latency) — see mesh-clone-grace.
850
+ * Conservative: a node neither bootstrap-running nor recently cloned → returns false, so a
851
+ * genuinely dead node keeps its permanent, actionable 'target_node_id_unmatched'.
852
+ */
853
+ function isTargetNodeTransientlyUnresolved(mesh: any, task: MeshWorkQueueEntry): boolean {
854
+ const targetNodeId = readNonEmptyString(task.targetNodeId);
855
+ if (!targetNodeId) return false;
856
+ const node = Array.isArray(mesh?.nodes)
857
+ ? mesh.nodes.find((n: any) => meshNodeIdMatches(n, targetNodeId))
858
+ : undefined;
859
+ if (node
860
+ && (node as { worktreeBootstrap?: { status?: string } }).worktreeBootstrap?.status === 'running'
861
+ && !isWorktreeBootstrapStaleRunning(node)) {
862
+ return true;
863
+ }
864
+ return isWithinCloneBootstrapGrace(targetNodeId);
865
+ }
866
+
867
+ /**
868
+ * FALSE-BLOCKER-CLONE-QUEUE (stale-event clear): once a task whose actionable blocker we
869
+ * previously paged either gets claimed or transitions to a self-resolving state, re-arm the
870
+ * de-dup ledger (so a later genuine blocker re-notifies) AND retract any still-undelivered
871
+ * dispatch_blocked pending event, so the coordinator's pending queue no longer carries the
872
+ * stale "will NOT clear on its own" warning. Cheap: only touches the pending store when this
873
+ * (mesh, task) actually had a prior actionable notification recorded.
874
+ */
875
+ function retractActionableSkipIfPreviouslyNotified(meshId: string, taskId: string): void {
876
+ const dedupKey = `${meshId}:${taskId}`;
877
+ if (!lastActionableSkipNotified.delete(dedupKey)) return; // nothing was paged → nothing to retract
878
+ try {
879
+ const coordinatorDaemonId = readNonEmptyString(loadConfig().machineId) || undefined;
880
+ const removed = retractPendingDispatchBlockedEvent(meshId, taskId, coordinatorDaemonId);
881
+ if (removed > 0) {
882
+ LOG.info('MeshQueue', `Retracted ${removed} stale dispatch-blocked event(s) for task ${taskId} (mesh ${meshId}) — its blocker resolved`);
883
+ }
884
+ } catch (e: any) {
885
+ LOG.warn('MeshQueue', `Failed to retract stale dispatch-blocked event for task ${taskId} (mesh ${meshId}): ${e?.message || e}`);
886
+ }
887
+ }
888
+
889
+ function actionableSkipGuidance(reason: string): { summary: string; nextAction: string } {
890
+ if (reason === 'target_node_id_unmatched') return {
891
+ summary: 'it is pinned to a target node id that matches no node in the mesh (the node may have been removed, or its id form does not resolve)',
892
+ nextAction: 'Verify the target node still exists with mesh_status, then re-enqueue without the node pin or with a valid node id (or re-clone the node).',
893
+ };
894
+ if (reason === 'no_node_satisfies_required_tags') return {
895
+ summary: "no node in the mesh can satisfy the task's required capability tags",
896
+ nextAction: "Relax the task's requiredTags, or add/launch a node whose provider produces the required capabilities.",
897
+ };
898
+ if (reason === 'mesh_convergence_target_is_worktree') return {
899
+ summary: 'it is a convergence task (base-only: merge → push → cleanup) but every candidate node is a worktree clone',
900
+ nextAction: 'Dispatch the convergence task to the base node, or run the deterministic fast-forward path (mesh_fast_forward_node / mesh_refine_node) instead.',
901
+ };
902
+ if (reason.startsWith('remote_auto_launch')) return {
903
+ summary: 'the target node is on a remote daemon this coordinator cannot auto-launch a session on (no dispatch transport, or no coordinator daemon id to stamp)',
904
+ nextAction: 'Launch a session on that node yourself with mesh_launch_session, or ensure the remote daemon is connected over P2P.',
905
+ };
906
+ if (reason.startsWith('provider') || reason === 'missing_provider_priority') return {
907
+ summary: 'the node has no usable provider for this task (provider priority missing/unusable, or the provider loader is unavailable)',
908
+ nextAction: "Check the node's providerPriority policy and that the required CLI/ACP provider is installed and enabled on that machine.",
909
+ };
910
+ if (reason === 'dirty_workspace') return {
911
+ summary: "the node's workspace is dirty, so auto-launch is blocked to avoid clobbering uncommitted changes",
912
+ nextAction: "Clean or commit the node's working tree (or fast-forward it); the task will then auto-assign.",
913
+ };
914
+ return {
915
+ summary: `it cannot be dispatched (${reason})`,
916
+ nextAction: 'Inspect the node/mesh state with mesh_status and resolve the blocker, or re-enqueue the task.',
917
+ };
918
+ }
919
+
920
+ /** Surface a non-self-resolving dispatch skip to the originating coordinator as a pending
921
+ * event (so it is delivered actively, not only on poll). De-duped per (mesh, task, reason). */
922
+ function notifyCoordinatorOfActionableSkip(meshId: string, taskId: string, reason: string | undefined, nodeId?: string): void {
923
+ if (!isActionableSkipReason(reason)) return;
924
+ // FALSE-BLOCKER-CLONE-QUEUE chokepoint defense: a 'target_node_id_unmatched' skip whose
925
+ // node was cloned within the grace window is a TRANSIENT propagation/bootstrap gap that
926
+ // auto-clears, not a permanent routing miss — never page the coordinator for it (the
927
+ // reason classifier upstream already routes the common case to the transient reason; this
928
+ // is the single-funnel backstop for any path that still labels it as the permanent reason).
929
+ if (reason === 'target_node_id_unmatched' && isWithinCloneBootstrapGrace(readNonEmptyString(nodeId))) return;
930
+ const dedupKey = `${meshId}:${taskId}`;
931
+ if (lastActionableSkipNotified.get(dedupKey) === reason) return;
932
+ lastActionableSkipNotified.set(dedupKey, reason!);
933
+ if (lastActionableSkipNotified.size > AUTO_LAUNCH_LEDGER_DEDUP_MAX) {
934
+ const oldest = lastActionableSkipNotified.keys().next().value;
935
+ if (oldest !== undefined) lastActionableSkipNotified.delete(oldest);
936
+ }
937
+ let task: MeshWorkQueueEntry | undefined;
938
+ try { task = getQueue(meshId).find(t => t.id === taskId); } catch { /* best-effort */ }
939
+ // The queue is owned by this coordinator daemon, so scope the event to this daemon's id;
940
+ // the originating coordinator SESSION (if known) further narrows delivery on this daemon.
941
+ const targetCoordinatorDaemonId = readNonEmptyString(loadConfig().machineId);
942
+ const targetCoordinatorSessionId = readNonEmptyString(task?.sourceCoordinatorSessionId);
943
+ const nodeLabel = readNonEmptyString(nodeId) || readNonEmptyString(task?.targetNodeId);
944
+ const { summary, nextAction } = actionableSkipGuidance(reason!);
945
+ const coordinatorMessage = `[System] A queued mesh task${nodeLabel ? ` for node ${nodeLabel}` : ''} is not being dispatched because ${summary}. ${nextAction} This is an actionable blocker — it will NOT clear on its own; the task stays pending until you resolve it.`;
946
+ try {
947
+ queuePendingMeshCoordinatorEvent({
948
+ event: 'mesh:dispatch_blocked',
949
+ meshId,
950
+ nodeLabel: nodeLabel || meshId,
951
+ ...(nodeLabel ? { nodeId: nodeLabel } : {}),
952
+ metadataEvent: {
953
+ source: 'mesh_queue_dispatch_skip',
954
+ taskId,
955
+ reason,
956
+ ...(nodeLabel ? { nodeId: nodeLabel } : {}),
957
+ coordinatorMessage,
958
+ },
959
+ coordinatorMessage,
960
+ queuedAt: Date.now(),
961
+ ...(targetCoordinatorDaemonId ? { targetCoordinatorDaemonId } : {}),
962
+ ...(targetCoordinatorSessionId ? { targetCoordinatorSessionId } : {}),
963
+ });
964
+ } catch (e: any) {
965
+ LOG.warn('MeshQueue', `Failed to surface actionable dispatch-skip (${reason}) for task ${taskId}: ${e?.message || e}`);
966
+ }
967
+ }
968
+
969
+ function sweepExpiredCooldowns(): void {
970
+ const now = Date.now();
971
+ for (const [key, until] of autoLaunchCooldownUntil) {
972
+ if (now >= until) autoLaunchCooldownUntil.delete(key);
973
+ }
974
+ }
975
+
976
+ function normalizeProviderPriority(policy: unknown): string[] {
977
+ const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
978
+ ? (policy as Record<string, unknown>).providerPriority
979
+ : undefined;
980
+ if (!Array.isArray(raw)) return [];
981
+ const seen = new Set<string>();
982
+ return raw
983
+ .map(type => typeof type === 'string' ? type.trim() : '')
984
+ .filter(Boolean)
985
+ .filter(type => {
986
+ if (seen.has(type)) return false;
987
+ seen.add(type);
988
+ return true;
989
+ });
990
+ }
991
+
992
+ function isTerminalSessionStatus(status: string): boolean {
993
+ return ['stopped', 'failed', 'terminated', 'exited', 'closed'].includes(status);
994
+ }
995
+
996
+ function isIdleSessionState(state: any): boolean {
997
+ const status = readNonEmptyString(state?.status).toLowerCase();
998
+ if (isTerminalSessionStatus(status)) return false;
999
+ return status === 'idle' || state?.activeChat?.status === 'waiting_input';
1000
+ }
1001
+
1002
+ function isDirtyNode(node: any): boolean {
1003
+ return node?.health === 'dirty' || node?.git?.dirty === true;
1004
+ }
1005
+
1006
+ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?: number; requireCleanSubmodules: boolean } {
1007
+ const record = mesh?.policy?.autoFastForward && typeof mesh.policy.autoFastForward === 'object' && !Array.isArray(mesh.policy.autoFastForward)
1008
+ ? mesh.policy.autoFastForward as Record<string, unknown>
1009
+ : {};
1010
+ const maxBehind = Number(record.maxBehind);
1011
+ return {
1012
+ enabled: record.enabled !== false,
1013
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
1014
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
1015
+ };
1016
+ }
1017
+
1018
+ function sessionStateLooksActive(state: any): boolean {
1019
+ const status = readNonEmptyString(state?.status).toLowerCase();
1020
+ const chatStatus = readNonEmptyString(state?.activeChat?.status).toLowerCase();
1021
+ // 'long_generating' is retained as a legacy alias for the renamed 'no_progress' busy status.
1022
+ const active = new Set(['generating', 'streaming', 'no_progress', 'long_generating', 'working', 'starting', 'waiting_approval']);
1023
+ return active.has(status) || active.has(chatStatus);
1024
+ }
1025
+
1026
+ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nodeId: string, currentSessionId?: string): boolean {
1027
+ if (nodeHasActiveAssignment(meshId, nodeId)) return true;
1028
+ return components.instanceManager.getByCategory('cli').some((inst: any) => {
1029
+ const state = inst.getState();
1030
+ const settings = state.settings as Record<string, unknown> || {};
1031
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
1032
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1033
+ // Match under canonical machine-core form, NOT a raw `!==`: a session's stamped
1034
+ // meshNodeId and the candidate nodeId can carry interchangeable daemon-id forms
1035
+ // (bare `mach_X` vs `daemon_mach_X`). A raw mismatch makes a BUSY node look idle,
1036
+ // so the active-work gate passes and a SECOND session is launched/claimed for a
1037
+ // task already running here — the CANON-IDENTITY duplicate dispatch.
1038
+ if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
1039
+ const sessionId = readNonEmptyString(state.instanceId);
1040
+ if (currentSessionId && sessionIdsEquivalent(sessionId, currentSessionId) && isIdleSessionState(state)) return false;
1041
+ return sessionStateLooksActive(state);
1042
+ });
1043
+ }
1044
+
1045
+ function isLaunchableNode(node: any): boolean {
1046
+ if (!node || node.status === 'disabled' || node.status === 'removed') return false;
1047
+ const health = readNonEmptyString(node.health).toLowerCase();
1048
+ if (!health) return true;
1049
+ return health === 'online' || health === 'unknown';
1050
+ }
1051
+
1052
+ /** Whether a mesh node's daemon/machine identity resolves to THIS coordinator daemon
1053
+ * (i.e. the queue session can be spawned by a direct local `launch_cli`). */
1054
+ function isLocalAutoLaunchNode(node: any): boolean {
1055
+ const daemonId = readNonEmptyString(node?.daemonId);
1056
+ const machineId = readNonEmptyString(node?.machineId);
1057
+ const appConfig = loadConfig();
1058
+ const localMachineId = readNonEmptyString(appConfig.machineId) || readNonEmptyString(appConfig.registeredMachineId);
1059
+
1060
+ // Route BOTH the daemonId and the machineId through the canonical machine-core
1061
+ // equivalence helper so a node carrying any interchangeable id form (bare `mach_<hex>`
1062
+ // or a `daemon_`/`standalone_` prefixed form) resolves to THIS coordinator instead of
1063
+ // being misjudged as remote. machineId used to use a raw `===`, which — AND-combined
1064
+ // with the daemonId match — would misjudge a local node as remote whenever a
1065
+ // form-mismatched machineId arrived, dispatching a local task to a remote node (B-2).
1066
+ const daemonMatchesLocal = !daemonId || daemonIdsEquivalent(daemonId, localMachineId);
1067
+ const machineMatchesLocal = !machineId || daemonIdsEquivalent(machineId, localMachineId);
1068
+
1069
+ if (node?.isLocalWorktree === true) {
1070
+ return daemonMatchesLocal && machineMatchesLocal;
1071
+ }
1072
+ if (daemonId || machineId) {
1073
+ return daemonMatchesLocal && machineMatchesLocal;
1074
+ }
1075
+ return true;
1076
+ }
1077
+
1078
+ /**
1079
+ * Resolve how a pending queue task should be auto-launched onto a node.
1080
+ *
1081
+ * - `local`: spawn directly on this daemon via cliManager.handleCliCommand('launch_cli').
1082
+ * - `remote`: forward `launch_cli` to the node's daemon via dispatchMeshCommand
1083
+ * (mirrors what mesh_launch_session does). Requires dispatchMeshCommand AND a
1084
+ * resolvable coordinator daemonId for relay-safe completion routing.
1085
+ * - `skip`: not launchable from here — carries the reason (e.g. a remote node with
1086
+ * no dispatch transport, or no coordinator daemonId to stamp).
1087
+ */
1088
+ function resolveAutoLaunchTarget(components: DaemonComponents, node: any): {
1089
+ mode: 'local' | 'remote' | 'skip';
1090
+ reason?: string;
1091
+ daemonId?: string;
1092
+ coordinatorDaemonId?: string;
1093
+ } {
1094
+ if (isLocalAutoLaunchNode(node)) return { mode: 'local' };
1095
+
1096
+ // Remote node. Forwarding the launch is possible only with a dispatch transport
1097
+ // (cloud mode) plus a coordinator daemonId to stamp into the worker so completion
1098
+ // events route back here. Without either, fall back to a graceful skip.
1099
+ //
1100
+ // CANON-IDENTITY: read the daemonId through the normalizing helper (same defect class
1101
+ // as the dispatch guard above). A raw `node.daemonId` read misses non-top-level-camelCase
1102
+ // serialization forms (daemon_id / machine.daemonId / lastProbe.machine.daemon_id / …),
1103
+ // so a genuinely-remote node arriving in one of those forms read empty here and was
1104
+ // wrongly skipped as `remote_auto_launch_unsupported`. readMeshNodeDaemonId returns
1105
+ // undefined (falsy) when absent — equivalent to the old readNonEmptyString + !daemonId
1106
+ // guard, so local/self auto-launch is unchanged.
1107
+ const daemonId = readMeshNodeDaemonId(node ?? {});
1108
+ if (!daemonId) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
1109
+ if (!components.dispatchMeshCommand) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
1110
+ // CANON: stamp the canonical `daemon_mach_` coordinator anchor onto the remote
1111
+ // worker (meshCoordinatorDaemonId) so its completion forwards back under the same
1112
+ // form every other dispatch path uses — no producer-side coordinator-id skew.
1113
+ const coordinatorDaemonId = localCoordinatorDaemonId();
1114
+ if (!coordinatorDaemonId) return { mode: 'skip', reason: 'remote_auto_launch_no_coordinator_daemon_id' };
1115
+ return { mode: 'remote', daemonId, coordinatorDaemonId };
1116
+ }
1117
+
1118
+ function activeAssignedCount(meshId: string): number {
1119
+ return getQueue(meshId, { status: ['assigned'] as any }).length;
1120
+ }
1121
+
1122
+ /** Active assignments that hold the one-active-per-node / global-parallel invariant
1123
+ * (everything except read-only diagnoses, which run unbounded by the write cap). */
1124
+ export function activeWriteAssignedCount(meshId: string): number {
1125
+ return getQueue(meshId, { status: ['assigned'] as any })
1126
+ .filter(task => !isTaskReadonly(task)).length;
1127
+ }
1128
+
1129
+ /** Active read-only assignments, for the read-only safety cap. */
1130
+ export function activeReadonlyAssignedCount(meshId: string): number {
1131
+ return getQueue(meshId, { status: ['assigned'] as any })
1132
+ .filter(isTaskReadonly).length;
1133
+ }
1134
+
1135
+ function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
1136
+ // Canonical-form match, NOT a raw `===`: an assigned row stamped in one daemon-id
1137
+ // form (e.g. `daemon_mach_X`) must still register as this node's active work when
1138
+ // the candidate nodeId arrives bare (`mach_X`). A raw mismatch makes the node look
1139
+ // free, letting a second write task auto-launch onto an already-busy node and
1140
+ // breaking the one-write-per-node (worktree isolation) invariant.
1141
+ return getQueue(meshId, { status: ['assigned'] as any }).some(task => daemonIdsEquivalent(task.assignedNodeId, nodeId));
1142
+ }
1143
+
1144
+ /** Active (status='assigned') task count for a node — the load metric for
1145
+ * least-loaded / round-robin ranking. Lower = preferred. */
1146
+ function nodeActiveLoad(meshId: string, nodeId: string): number {
1147
+ return MeshRuntimeStore.getInstance().nodeActiveAssignmentCount(meshId, nodeId);
1148
+ }
1149
+
1150
+ /** True when any node in the mesh has an EXPLICIT capability-slot list configured
1151
+ * (policy.slots). Legacy-derived slots don't count — only an operator (or the
1152
+ * orchestrator, with approval) authoring slots signals intent to route by fitness. */
1153
+ function meshHasExplicitSlots(mesh: any): boolean {
1154
+ const nodes = Array.isArray(mesh?.nodes) ? mesh.nodes : [];
1155
+ return nodes.some((n: any) => normalizeNodeCapabilitySlots(n?.policy?.slots).length > 0);
1156
+ }
1157
+
1158
+ /**
1159
+ * The mesh-wide scheduling strategy, read from the MACHINE-LOCAL stored mesh
1160
+ * policy. Resolution order:
1161
+ * 1. an EXPLICITLY stored schedulingStrategy (operator picked a mode) wins, else
1162
+ * 2. 'fitness' AUTO when the mesh has any node with explicit capability slots —
1163
+ * configuring slots is itself the signal to route tasks by task→slot fitness
1164
+ * (difficulty/capability), no separate strategy toggle required, else
1165
+ * 3. 'first_eligible' (strict no-change default for slot-less meshes).
1166
+ *
1167
+ * Persistence economy drops schedulingStrategy from policy when it equals the
1168
+ * first_eligible default (repo-mesh-types normalizeMeshPolicy), so an ABSENT value
1169
+ * means "unset" — safe to auto-upgrade — while a PRESENT value means the operator
1170
+ * chose it and we never override. Policy is machine-local only; this only governs
1171
+ * the final tie-break — eligibility, capacity, and priority gates are unchanged.
1172
+ */
1173
+ function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
1174
+ const raw = mesh?.policy?.schedulingStrategy;
1175
+ if (typeof raw === 'string' && raw.trim()) {
1176
+ return normalizeMeshSchedulingStrategy(raw);
1177
+ }
1178
+ // Unset → auto-fitness when slots exist, else the historical default.
1179
+ return meshHasExplicitSlots(mesh) ? 'fitness' : normalizeMeshSchedulingStrategy(undefined);
1180
+ }
1181
+
1182
+ /**
1183
+ * Order eligible nodes for assignment per the mesh scheduling pipeline:
1184
+ * PRIORITY (schedulingPriority desc) → TIE-BREAK (strategy).
1185
+ *
1186
+ * The caller has already applied the TAG hard-filter and is responsible for the
1187
+ * MAX-ALLOC capacity gate (the per-node launch/claim checks). This function only
1188
+ * decides the *preference order* among nodes that are otherwise eligible.
1189
+ *
1190
+ * - 'first_eligible' (default): returns the input order verbatim and does NOT touch
1191
+ * the round-robin cursor — byte-for-byte the pre-feature behavior.
1192
+ * - 'priority_only': schedulingPriority desc, then input order (load ignored).
1193
+ * - 'least_loaded' (the user-facing 'spread' mode): schedulingPriority desc, then
1194
+ * active load asc, then — among nodes still tied at (priority, load) — the input
1195
+ * order is rotated by a per-mesh cursor that advances once per pass. The rotation
1196
+ * tie-break is ALWAYS on for least_loaded so a single 'spread' mode subsumes the
1197
+ * former separate 'round_robin' strategy (two equal-load nodes still alternate
1198
+ * fairly across passes instead of the lower index always winning).
1199
+ * - 'round_robin': retained as an escape-hatch alias; behaves identically to
1200
+ * 'least_loaded' now that least_loaded carries the rotation.
1201
+ *
1202
+ * `nodes` carries the original config/array index so the tie-break can fall back to
1203
+ * deterministic input order. `bumpCursor` advances the round-robin cursor exactly
1204
+ * once per scheduling pass (consulted for both 'least_loaded' and 'round_robin').
1205
+ */
1206
+ interface RankableNode { nodeId: string; node: any; index: number }
1207
+
1208
+ /** Test-only: resolve a mesh's effective scheduling strategy through the full
1209
+ * LOCAL-WINS path (`.adhdev/mesh.json` distribution → strategy, else stored policy).
1210
+ * Exposed so the repo-file overlay wiring can be exercised by a direct call. */
1211
+ export function __resolveSchedulingStrategyForTests(mesh: any): RepoMeshSchedulingStrategy {
1212
+ return resolveSchedulingStrategy(mesh);
1213
+ }
1214
+
1215
+ /** Test-only: the pure node-ordering stage (PRIORITY → TIE-BREAK). Exposed so the
1216
+ * scheduling pipeline can be unit-tested without standing up live CLI sessions. */
1217
+ export function __orderEligibleNodesForTests(
1218
+ meshId: string,
1219
+ strategy: RepoMeshSchedulingStrategy,
1220
+ nodes: RankableNode[],
1221
+ opts?: { bumpCursor?: boolean; task?: { difficulty?: string; requiredTags?: string[] } },
1222
+ ): RankableNode[] {
1223
+ return orderEligibleNodes(meshId, strategy, nodes, opts);
1224
+ }
1225
+
1226
+ /** One idle session eligible to claim a queued task, together with the resolved
1227
+ * mesh node record it belongs to. Local candidates come from live CLI instances,
1228
+ * remote candidates from the registered remote-idle-session store; the two arrive
1229
+ * with their `nodeId` under different serialization forms. */
1230
+ type IdleCandidate = { nodeId: string; sessionId: string; providerType: string; origin: 'local' | 'remote'; node: any };
1231
+
1232
+ /**
1233
+ * Merge local + remote idle candidates into the scheduling pool with a single,
1234
+ * form-canonical node identity.
1235
+ *
1236
+ * INVARIANT: every candidate in the returned pool carries its `nodeId` in
1237
+ * CANONICAL (normalized) form, and `uniqueNodes` has exactly one entry per
1238
+ * physical node. Local and remote candidates arrive with their `nodeId` under
1239
+ * mixed forms (config `id`, wire `nodeId`, DB `node_id`). Downstream scheduling
1240
+ * keys the pool by raw-string equality (Set dedup, baseIndex, rankIndex,
1241
+ * nodeActiveLoad), so two candidates for the SAME physical node under two
1242
+ * different forms would otherwise be treated as two distinct nodes — form-drift
1243
+ * that splits a node's load and double-ranks it. Canonicalizing here (via the
1244
+ * resolved node record, which meshNodeIdMatches already matched form-agnostically)
1245
+ * makes every later `=== nodeId` comparison operate on one agreed form. Falls back
1246
+ * to the raw candidate id when the node is unresolved (an unresolved candidate
1247
+ * cannot be normalized, but also has no sibling to collide with).
1248
+ */
1249
+ function buildSchedulingPool(
1250
+ localCandidates: IdleCandidate[],
1251
+ remoteCandidates: IdleCandidate[],
1252
+ ): { pool: IdleCandidate[]; uniqueNodes: RankableNode[] } {
1253
+ const pool = [...localCandidates, ...remoteCandidates].map(c => ({
1254
+ ...c,
1255
+ nodeId: normalizeMeshNodeId(c.node) ?? c.nodeId,
1256
+ }));
1257
+ // Both sides are canonical now, so raw `===` in the Set dedup and the node
1258
+ // re-lookup is form-safe.
1259
+ const uniqueNodes: RankableNode[] = [...new Set(pool.map(c => c.nodeId))]
1260
+ .map((nodeId, index) => ({
1261
+ nodeId,
1262
+ node: pool.find(c => meshNodeIdMatches({ id: c.nodeId } as MeshNodeIdentified, nodeId))?.node,
1263
+ index,
1264
+ }));
1265
+ return { pool, uniqueNodes };
1266
+ }
1267
+
1268
+ /** Test-only: the pool-canonicalization + unique-node collapse stage. Exposed so
1269
+ * the mixed-form dedup invariant can be unit-tested without a live daemon. */
1270
+ export function __buildSchedulingPoolForTests(
1271
+ localCandidates: IdleCandidate[],
1272
+ remoteCandidates: IdleCandidate[],
1273
+ ): { pool: IdleCandidate[]; uniqueNodes: RankableNode[] } {
1274
+ return buildSchedulingPool(localCandidates, remoteCandidates);
1275
+ }
1276
+
1277
+ // ─────────────────────────────────────────────────────────────────────────────
1278
+ // Node capability slots — task→node/slot fitness (ORCHESTRATION_NODE_SLOTS.md)
1279
+ //
1280
+ // A node's capability slots are the single source of truth for routing. When a
1281
+ // node has explicit `policy.slots` we use them; otherwise we derive slots from the
1282
+ // legacy providerPriority/providerRoles + the machine-global difficultyBrains so
1283
+ // existing nodes keep working (back-compat). The fitness scorer ranks a node for a
1284
+ // specific task by how well its best slot matches the task's difficulty and
1285
+ // required tags — with graceful fallback so a task is never blocked by a missing
1286
+ // exact match.
1287
+ // ─────────────────────────────────────────────────────────────────────────────
1288
+
1289
+ /** The task shape the fitness scorer reads (a subset of MeshWorkQueueEntry). */
1290
+ interface FitnessTask {
1291
+ difficulty?: string;
1292
+ requiredTags?: string[];
1293
+ }
1294
+
1295
+ /** Resolve a node's capability slots: explicit policy.slots, else derived from legacy. */
1296
+ function resolveNodeCapabilitySlots(node: any): NodeCapabilitySlot[] {
1297
+ const explicit = normalizeNodeCapabilitySlots(node?.policy?.slots);
1298
+ if (explicit.length) return explicit;
1299
+ let difficultyBrains: any;
1300
+ try { difficultyBrains = getDifficultyBrains(); } catch { difficultyBrains = undefined; }
1301
+ return deriveSlotsFromLegacy({
1302
+ providerPriority: normalizeProviderPriority(node?.policy),
1303
+ providerRoles: Array.isArray(node?.policy?.providerRoles) ? node.policy.providerRoles : undefined,
1304
+ difficultyBrains,
1305
+ });
1306
+ }
1307
+
1308
+ /**
1309
+ * Score how well one slot fits a task. Higher = better. A slot whose difficulty
1310
+ * range contains the task's difficulty scores highest; a general-purpose slot
1311
+ * (no declared difficulty) is a valid fallback; a slot whose capability tags cover
1312
+ * the task's requiredTags gets a capability bonus. Never negative — the worst a
1313
+ * slot does is score 0 (still selectable as a last-resort fallback).
1314
+ */
1315
+ function scoreSlotForTask(slot: NodeCapabilitySlot, task: FitnessTask): number {
1316
+ let score = 1; // base: any slot can run the task (fallback floor)
1317
+ const diff = isMeshTaskDifficulty(task.difficulty) ? task.difficulty as MeshTaskDifficulty : undefined;
1318
+ if (diff) {
1319
+ if (slot.difficulty?.length) {
1320
+ score += slot.difficulty.includes(diff) ? 100 : 0; // exact difficulty match dominates
1321
+ } else {
1322
+ score += 20; // general-purpose slot: decent fallback for any difficulty
1323
+ }
1324
+ }
1325
+ const req = task.requiredTags?.filter(t => !!t) ?? [];
1326
+ if (req.length) {
1327
+ const cap = new Set(slot.capability ?? []);
1328
+ const covered = req.every(t => cap.has(t));
1329
+ score += covered ? 30 : 0; // capability coverage bonus (hard filter is applied elsewhere)
1330
+ }
1331
+ return score;
1332
+ }
1333
+
1334
+ /** Best (slot, score) for a task on a node, or null when the node has no slots. */
1335
+ function bestSlotForTask(node: any, task: FitnessTask): { slot: NodeCapabilitySlot; score: number } | null {
1336
+ const slots = resolveNodeCapabilitySlots(node);
1337
+ if (!slots.length) return null;
1338
+ let best: { slot: NodeCapabilitySlot; score: number } | null = null;
1339
+ for (const slot of slots) {
1340
+ const score = scoreSlotForTask(slot, task);
1341
+ if (!best || score > best.score) best = { slot, score };
1342
+ }
1343
+ return best;
1344
+ }
1345
+
1346
+ /** Node-level fitness for a task = its best slot's score (0 when the node has no slots). */
1347
+ function nodeFitnessForTask(node: any, task: FitnessTask): number {
1348
+ return bestSlotForTask(node, task)?.score ?? 0;
1349
+ }
1350
+
1351
+ function orderEligibleNodes(
1352
+ meshId: string,
1353
+ strategy: RepoMeshSchedulingStrategy,
1354
+ nodes: RankableNode[],
1355
+ opts?: { bumpCursor?: boolean; task?: FitnessTask },
1356
+ ): RankableNode[] {
1357
+ if (strategy === 'first_eligible' || nodes.length <= 1) {
1358
+ return nodes;
1359
+ }
1360
+
1361
+ // Fitness strategy: rank by task→slot fit first (when a task is in scope —
1362
+ // auto-launch drains per-task), then fall through to priority/load/rotation for
1363
+ // ties. Without a task (idle-session drain ranks task-independently) fitness is
1364
+ // inert and this behaves like least_loaded.
1365
+ if (strategy === 'fitness' && opts?.task) {
1366
+ const task = opts.task;
1367
+ return [...nodes].sort((a, b) => {
1368
+ const fitDelta = nodeFitnessForTask(b.node, task) - nodeFitnessForTask(a.node, task);
1369
+ if (fitDelta !== 0) return fitDelta; // higher fitness first
1370
+ const prioDelta = resolveNodeSchedulingPriority(b.node?.policy) - resolveNodeSchedulingPriority(a.node?.policy);
1371
+ if (prioDelta !== 0) return prioDelta;
1372
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
1373
+ if (loadDelta !== 0) return loadDelta;
1374
+ return a.index - b.index;
1375
+ });
1376
+ }
1377
+
1378
+ const priorityOf = (n: { node: any }) => resolveNodeSchedulingPriority(n.node?.policy);
1379
+
1380
+ // Round-robin rotation offset: rotate the deterministic input order by a
1381
+ // per-mesh cursor so the tie-break winner among equal (priority, load) nodes
1382
+ // cycles across passes. The cursor advances once per scheduling pass. Applied
1383
+ // to both 'least_loaded' (the 'spread' mode — rotation absorbed as its
1384
+ // tie-break) and the legacy 'round_robin' alias.
1385
+ let rotation = 0;
1386
+ if (strategy === 'least_loaded' || strategy === 'round_robin') {
1387
+ const cursor = opts?.bumpCursor
1388
+ ? MeshRuntimeStore.getInstance().bumpSchedulerCursor(meshId)
1389
+ : MeshRuntimeStore.getInstance().getSchedulerCursor(meshId);
1390
+ rotation = ((cursor % nodes.length) + nodes.length) % nodes.length;
1391
+ }
1392
+
1393
+ // Rotation rank: position of each node after rotating input order by `rotation`.
1394
+ // For non-round-robin strategies rotation is 0, so this is just the input index.
1395
+ const rotationRank = (index: number) => (index - rotation + nodes.length) % nodes.length;
1396
+
1397
+ return [...nodes].sort((a, b) => {
1398
+ const prioDelta = priorityOf(b) - priorityOf(a); // higher priority first
1399
+ if (prioDelta !== 0) return prioDelta;
1400
+ if (strategy === 'least_loaded' || strategy === 'round_robin') {
1401
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
1402
+ if (loadDelta !== 0) return loadDelta;
1403
+ }
1404
+ return rotationRank(a.index) - rotationRank(b.index);
1405
+ });
1406
+ }
1407
+
1408
+ /** Active assignments on a (node, provider) — pre-launch guard for the per-(node,
1409
+ * provider) maxParallel cap. The authoritative enforcement is in the claim
1410
+ * transaction; this only avoids spawning a session that would fail the claim. */
1411
+ function activeProviderAssignedCount(meshId: string, nodeId: string, providerType: string): number {
1412
+ return getQueue(meshId, { status: ['assigned'] as any })
1413
+ .filter(task => daemonIdsEquivalent(task.assignedNodeId, nodeId) && task.assignedProviderType === providerType).length;
1414
+ }
1415
+
1416
+ export function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
1417
+ if (getQueue(meshId, { status: ['assigned'] as any }).some(task => sessionIdsEquivalent(task.assignedSessionId, sessionId))) {
1418
+ return true;
1419
+ }
1420
+ // Direct dispatches (mesh_send_task) are tracked in mesh_direct_dispatches, not the
1421
+ // work queue. A session completing a still-active direct dispatch IS an active
1422
+ // assignment — without this, findRecentTerminalLedgerEvidence dedup wrongly suppresses
1423
+ // the canonical agent:generating_completed for direct-dispatch tasks (validation/general),
1424
+ // so the coordinator polling get_pending_mesh_events never observes task_completed and the
1425
+ // session goes silently idle. This check runs before markSessionTerminal marks the
1426
+ // dispatch terminal, so the in-flight dispatch is still observable here.
1427
+ try {
1428
+ if (getActiveDirectDispatches(meshId).some(d => sessionIdsEquivalent(d.sessionId, sessionId))) return true;
1429
+ if (hasUnterminalDirectDispatchLedgerEntry(meshId, sessionId)) return true;
1430
+ } catch { /* best-effort — fall through to false */ }
1431
+ return false;
1432
+ }
1433
+
1434
+ /**
1435
+ * CANON-IDENTITY single-flight hardening (restart-safe, observation-based).
1436
+ *
1437
+ * The in-memory single-flight Set (mesh-task-inflight) is process-local and is LOST on a
1438
+ * daemon restart — after a restart, a task still being generated by a live local worker is
1439
+ * no longer marked in-flight, so requeueTask's Set check passes and would re-open the task
1440
+ * for a duplicate second dispatch. This recovers the "still generating" signal from
1441
+ * observable runtime state instead of the in-memory mark: a session is actively generating
1442
+ * when its live local CLI instance reports an active (generating/streaming/…) status — the
1443
+ * same predicate the dispatch active-work gate uses (sessionStateLooksActive).
1444
+ *
1445
+ * Local-only by design: it inspects THIS daemon's instanceManager. The primary cross-process
1446
+ * fix (IpcTransport requeue delegating to the mesh-host daemon) keeps begin (dispatch) and
1447
+ * check (requeue guard) co-located so the in-memory mark stays authoritative in the common
1448
+ * path; this is the restart-safety net for sessions hosted on this daemon. A genuinely
1449
+ * dead/stale session is not generating → returns false → the requeue proceeds as before.
1450
+ */
1451
+ export function isSessionActivelyGenerating(components: DaemonComponents, sessionId: string): boolean {
1452
+ if (!sessionId) return false;
1453
+ const state = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
1454
+ if (!state) return false;
1455
+ return sessionStateLooksActive(state);
1456
+ }
1457
+
1458
+ /**
1459
+ * RECLAIM-FALSEPOS tri-state busy verdict for a session id.
1460
+ *
1461
+ * The binary isSessionActivelyGenerating() folds "absence of a positive generating
1462
+ * signal" into a definitive NEGATIVE (returns false when the instance is absent). But a
1463
+ * REMOTE session (never in THIS daemon's instanceManager) — or a locally-present session
1464
+ * looked up under a skewed id form — then looks "not generating" and can be reclaimed out
1465
+ * from under a worker that is genuinely mid-turn. This resolves an explicit three-way
1466
+ * verdict instead:
1467
+ * - GENERATING — a locally-present instance reports an active/streaming state.
1468
+ * - IDLE_CONFIRMED — a locally-present instance reports a non-active (idle/terminal)
1469
+ * state. Positive local evidence the worker is not working.
1470
+ * - UNKNOWN — no locally-present instance matches (remote / gone / id-skew) or
1471
+ * the observation failed. NEVER treated as IDLE_CONFIRMED.
1472
+ *
1473
+ * The lookup scans getByCategory('cli') with sessionIdsEquivalent (the same equivalence
1474
+ * matching nodeHasActiveMeshWork / liveSessionCountForNode use) rather than a raw
1475
+ * instanceManager.getInstance(id) Map.get, so an id-form-skewed but present session is
1476
+ * found (closing the same id-form-skew hole class e245c2f9's F1 fixed elsewhere).
1477
+ */
1478
+ export type SessionBusyVerdict = 'GENERATING' | 'IDLE_CONFIRMED' | 'UNKNOWN';
1479
+ export function resolveSessionBusyVerdict(components: DaemonComponents, sessionId: string): SessionBusyVerdict {
1480
+ if (!sessionId) return 'UNKNOWN';
1481
+ try {
1482
+ const instances = components.instanceManager?.getByCategory?.('cli') || [];
1483
+ const inst = instances.find((i: any) => {
1484
+ const sid = readNonEmptyString(i?.getState?.().instanceId);
1485
+ return sid && sessionIdsEquivalent(sid, sessionId);
1486
+ });
1487
+ if (!inst) return 'UNKNOWN'; // remote / gone / id-form skew not present locally
1488
+ const state = inst.getState?.();
1489
+ if (!state) return 'UNKNOWN';
1490
+ return sessionStateLooksActive(state) ? 'GENERATING' : 'IDLE_CONFIRMED';
1491
+ } catch {
1492
+ return 'UNKNOWN'; // failed observation ⇒ unknown, never a definitive idle
1493
+ }
1494
+ }
1495
+
1496
+ function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
1497
+ const localInstances = components.instanceManager.getByCategory('cli').filter((inst: any) => {
1498
+ const state = inst.getState();
1499
+ const settings = state.settings as Record<string, unknown> || {};
1500
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
1501
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1502
+ // Canonical-form match (see nodeHasActiveMeshWork): a daemon-id form skew between
1503
+ // the session's stamped nodeId and the candidate nodeId must not undercount this
1504
+ // node's live sessions, which would defeat the maxConcurrentSessions cap.
1505
+ if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
1506
+ const status = readNonEmptyString(state.status).toLowerCase();
1507
+ return !isTerminalSessionStatus(status);
1508
+ });
1509
+ let count = localInstances.length;
1510
+ // (A) AUTOLAUNCH-CLAIM-CHURN: also count launched-but-not-yet-claimed sessions targeting this
1511
+ // node whose await-claim window is still open. A REMOTE such session is invisible to the local
1512
+ // instanceManager above, so without this the maxConcurrentSessions cap undercounts it and a
1513
+ // duplicate ghost launch slips through. Exclude any id already represented by a local instance
1514
+ // so a co-located launch is not double-counted.
1515
+ const localSessionIds = localInstances
1516
+ .map((inst: any) => readNonEmptyString(inst.getState().instanceId))
1517
+ .filter(Boolean);
1518
+ for (const sid of inWindowAutoLaunchSessionIdsForNode(meshId, nodeId)) {
1519
+ if (!localSessionIds.some(local => sessionIdsEquivalent(local, sid))) count += 1;
1520
+ }
1521
+ return count;
1522
+ }
1523
+
1524
+ /**
1525
+ * DOUBLE-DISPATCH auto-launch gate: does this node already have a LIVE mesh session that
1526
+ * is NOT holding an assigned queue task — i.e. one that is idle, booting toward its first
1527
+ * claim, or in a momentary non-idle flip? Such a session WILL claim a still-pending task
1528
+ * on its own via the idle→claim / agent:ready drain, so spawning a NEW session here only
1529
+ * races it and yields a duplicate worker that double-stamps the same taskId (the
1530
+ * enqueue → drain-miss → auto-launch RCA: the drain skipped a momentarily-non-idle idle
1531
+ * session as a candidate, and the write-only nodeHasActiveAssignment gate — which only
1532
+ * inspects status='assigned' rows — could not see the about-to-claim session either).
1533
+ *
1534
+ * A session that already HOLDS an assigned queue task is genuine concurrent work, not a
1535
+ * free claimer, and is excluded — so a read-only auto-launch onto a busy-but-no-idle node
1536
+ * is still allowed. A node with NO live mesh session at all (dead, or never launched) does
1537
+ * not match, preserving the legitimate first-session spawn.
1538
+ */
1539
+ function nodeHasLiveSessionPendingClaim(components: DaemonComponents, meshId: string, nodeId: string): boolean {
1540
+ // (A) AUTOLAUNCH-CLAIM-CHURN remote-awareness: a task whose auto-launch record targets this
1541
+ // node and is still inside its await-claim window (base or backoff) already has a session on
1542
+ // its way to claim — even when that session is REMOTE and thus invisible to the local
1543
+ // instanceManager scan below. Treat it as a live pending-claim session so a duplicate launch
1544
+ // is suppressed and no ghost accumulates every ~90s.
1545
+ if (inWindowAutoLaunchSessionIdsForNode(meshId, nodeId).length > 0) return true;
1546
+ // Session ids currently holding an assigned queue task on this node — those are busy,
1547
+ // not pending claimers, so they must NOT suppress a (read-only) launch.
1548
+ const busySessionIds = new Set(
1549
+ getQueue(meshId, { status: ['assigned'] as any })
1550
+ .filter(task => daemonIdsEquivalent(task.assignedNodeId, nodeId))
1551
+ .map(task => readNonEmptyString(task.assignedSessionId))
1552
+ .filter(Boolean),
1553
+ );
1554
+ return components.instanceManager.getByCategory('cli').some((inst: any) => {
1555
+ const state = inst.getState();
1556
+ const settings = state.settings as Record<string, unknown> || {};
1557
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
1558
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1559
+ // Canonical-form match (see nodeHasActiveMeshWork / liveSessionCountForNode): a
1560
+ // daemon-id form skew must not make a present session look absent and reopen the
1561
+ // duplicate-launch hole.
1562
+ if (!daemonIdsEquivalent(instNodeId, nodeId)) return false;
1563
+ const status = readNonEmptyString(state.status).toLowerCase();
1564
+ if (isTerminalSessionStatus(status)) return false; // dead → no claimer here, allow launch
1565
+ const sessionId = readNonEmptyString(state.instanceId);
1566
+ if (sessionId && busySessionIds.has(sessionId)) return false; // busy with its own assigned task
1567
+ return true; // live + unassigned → will claim the pending task itself
1568
+ });
1569
+ }
1570
+
1571
+ function recordAutoLaunchEvent(meshId: string, args: {
1572
+ phase: 'skipped' | 'started' | 'failed' | 'completed';
1573
+ taskId: string;
1574
+ nodeId?: string;
1575
+ providerType?: string;
1576
+ sessionId?: string;
1577
+ reason?: string;
1578
+ error?: string;
1579
+ }) {
1580
+ // Suppress consecutive identical `skipped` entries for the same task (4s reconcile
1581
+ // re-trigger noise). Non-skip phases and changed reasons always record and reset
1582
+ // the de-dup so genuine state transitions remain visible in the ledger.
1583
+ const dedupKey = `${meshId}:${args.taskId}`;
1584
+ const currentSig = `${args.phase}|${args.reason || ''}`;
1585
+ if (args.phase === 'skipped' && lastAutoLaunchLedgerKey.get(dedupKey) === currentSig) {
1586
+ return;
1587
+ }
1588
+ lastAutoLaunchLedgerKey.set(dedupKey, currentSig);
1589
+ if (lastAutoLaunchLedgerKey.size > AUTO_LAUNCH_LEDGER_DEDUP_MAX) {
1590
+ // Bound memory: drop the oldest insertion (Map preserves insertion order).
1591
+ const oldest = lastAutoLaunchLedgerKey.keys().next().value;
1592
+ if (oldest !== undefined) lastAutoLaunchLedgerKey.delete(oldest);
1593
+ }
1594
+ try {
1595
+ appendLedgerEntry(meshId, {
1596
+ kind: 'session_auto_launch',
1597
+ nodeId: args.nodeId,
1598
+ sessionId: args.sessionId,
1599
+ providerType: args.providerType,
1600
+ payload: {
1601
+ phase: args.phase,
1602
+ taskId: args.taskId,
1603
+ reason: args.reason,
1604
+ error: args.error,
1605
+ },
1606
+ });
1607
+ } catch (e: any) {
1608
+ LOG.warn('MeshQueue', `Failed to record auto-launch ledger event: ${e?.message || e}`);
1609
+ }
1610
+ }
1611
+
1612
+ function markAutoLaunch(meshId: string, taskId: string, args: {
1613
+ status: 'skipped' | 'started' | 'failed' | 'completed';
1614
+ reason?: string;
1615
+ nodeId?: string;
1616
+ providerType?: string;
1617
+ sessionId?: string;
1618
+ error?: string;
1619
+ }) {
1620
+ recordTaskAutoLaunch(meshId, taskId, {
1621
+ status: args.status,
1622
+ reason: args.reason || args.error,
1623
+ nodeId: args.nodeId,
1624
+ providerType: args.providerType,
1625
+ sessionId: args.sessionId,
1626
+ });
1627
+ recordAutoLaunchEvent(meshId, {
1628
+ phase: args.status,
1629
+ taskId,
1630
+ nodeId: args.nodeId,
1631
+ providerType: args.providerType,
1632
+ sessionId: args.sessionId,
1633
+ reason: args.reason,
1634
+ error: args.error,
1635
+ });
1636
+ // Fix (1): actively notify the coordinator of a non-self-resolving skip; re-arm the
1637
+ // notification on any non-skip transition (started/completed) so a later genuine skip
1638
+ // re-notifies.
1639
+ if (args.status === 'skipped') {
1640
+ if (isActionableSkipReason(args.reason)) {
1641
+ notifyCoordinatorOfActionableSkip(meshId, taskId, args.reason, args.nodeId);
1642
+ } else if (args.reason === TRANSIENT_TARGET_NODE_BOOTSTRAP_PENDING_REASON) {
1643
+ // FALSE-BLOCKER-CLONE-QUEUE (stale-event clear): the unmatch is now known to be a
1644
+ // self-resolving clone/bootstrap window — retract any earlier actionable blocker we
1645
+ // paged for this same task. Other transient/back-pressure reasons (cooldown, caps)
1646
+ // intentionally do NOT retract: they can mask a still-standing real blocker.
1647
+ retractActionableSkipIfPreviouslyNotified(meshId, taskId);
1648
+ }
1649
+ } else {
1650
+ // started/completed: the task is progressing — re-arm the de-dup ledger and retract
1651
+ // any still-undelivered stale blocker for it.
1652
+ retractActionableSkipIfPreviouslyNotified(meshId, taskId);
1653
+ }
1654
+ }
1655
+
1656
+ async function resolveUsableProvider(
1657
+ components: DaemonComponents,
1658
+ nodeId: string,
1659
+ node: any,
1660
+ requiredTags?: string[],
1661
+ task?: FitnessTask,
1662
+ ): Promise<{ providerType?: string; model?: string; thinkingLevel?: string; reason?: string }> {
1663
+ const providerLoader = components.providerLoader;
1664
+ if (!providerLoader) return { reason: 'provider_loader_unavailable' };
1665
+
1666
+ // Slot-based order (ORCHESTRATION_NODE_SLOTS.md): rank the node's capability
1667
+ // slots by task→slot fitness (difficulty/requiredTags) so the best-fit slot's
1668
+ // provider is tried first, and its model/thinkingLevel ride along. Falls back
1669
+ // to the legacy providerPriority-derived slots when no explicit slots exist.
1670
+ const slots = resolveNodeCapabilitySlots(node);
1671
+ if (!slots.length) return { reason: 'missing_provider_priority' };
1672
+ const orderedSlots = task
1673
+ ? [...slots].sort((a, b) => scoreSlotForTask(b, task) - scoreSlotForTask(a, task))
1674
+ : slots;
1675
+
1676
+ const failed: string[] = [];
1677
+ for (const slot of orderedSlots) {
1678
+ const requestedType = slot.provider;
1679
+ const normalizedType = typeof providerLoader.resolveAlias === 'function'
1680
+ ? providerLoader.resolveAlias(requestedType)
1681
+ : requestedType;
1682
+ // Skip providers that can't satisfy the task's requiredTags (e.g. provider=hermes-cli
1683
+ // means only hermes-cli qualifies, not any other slot's provider).
1684
+ if (requiredTags?.length && !nodeSatisfiesRequiredTags(requiredTags, buildMeshNodeCapabilityTags(node, normalizedType))) {
1685
+ failed.push(`${requestedType}: required_tags_mismatch`);
1686
+ continue;
1687
+ }
1688
+ if (typeof providerLoader.isMachineProviderEnabled === 'function' && !providerLoader.isMachineProviderEnabled(normalizedType)) {
1689
+ failed.push(`${requestedType}: disabled`);
1690
+ continue;
1691
+ }
1692
+ let detected: any;
1693
+ try {
1694
+ detected = await detectCLI(normalizedType, providerLoader, { includeVersion: false });
1695
+ } catch (e: any) {
1696
+ failed.push(`${requestedType}: detect failed: ${e?.message || e}`);
1697
+ continue;
1698
+ }
1699
+ if (typeof providerLoader.setCliDetectionResults === 'function') {
1700
+ providerLoader.setCliDetectionResults([{
1701
+ id: normalizedType,
1702
+ installed: !!detected,
1703
+ path: detected?.path,
1704
+ }], false);
1705
+ }
1706
+ (components as any).onStatusChange?.();
1707
+ if (detected) {
1708
+ return {
1709
+ providerType: normalizedType,
1710
+ ...(slot.model ? { model: slot.model } : {}),
1711
+ ...(slot.thinkingLevel ? { thinkingLevel: slot.thinkingLevel } : {}),
1712
+ };
1713
+ }
1714
+ failed.push(`${requestedType}: not detected`);
1715
+ }
1716
+ return { reason: `provider_priority_unusable: ${failed.join('; ') || nodeId}` };
1717
+ }
1718
+
1719
+ // Canonical mesh node-id normalization. A node may arrive from the local config
1720
+ // form (`id`) or the inline-cache form (`nodeId`/`node_id`) — see
1721
+ // readInlineMeshNodeId in commands/router.ts. Comparing only `node.id` against a
1722
+ // task.targetNodeId silently drops inline-cached worktree nodes, leaving a
1723
+ // target-routed task permanently pending with a misleading
1724
+ // `no_node_satisfies_required_tags` skip.
1725
+ function readMeshNodeId(node: any): string {
1726
+ // Delegate to the shared 3-way (id / nodeId / node_id) normalizer so this
1727
+ // and every other mesh node-id read agree on identity. Coalesce to '' to
1728
+ // preserve the existing string return contract for callers that do
1729
+ // `=== task.targetNodeId` / `if (!nodeId)`.
1730
+ return normalizeMeshNodeId(node) ?? '';
1731
+ }
1732
+
1733
+ // AUTOLAUNCH-CLAIM-CHURN. The await-claim window for a launched (remote) session has expired
1734
+ // without a claim. Instead of a blind respawn, re-drive the claim for the EXISTING session,
1735
+ // backing off when its liveness is unknown, and only respawning when it is provably unclaimable.
1736
+ // Returns a directive for the caller:
1737
+ // - 'claimed' — the re-drive claimed/dispatched the task into the existing session (progress).
1738
+ // - 'fallback' — the post-cap direct dispatch delivered the task into the existing session.
1739
+ // - 'backoff' — liveness unknown; the window was extended (or is still cooling down). No launch.
1740
+ // - 'respawn' — the session is provably gone/unclaimable; the caller may launch a fresh one.
1741
+ function driveExpiredAwaitClaim(
1742
+ components: DaemonComponents,
1743
+ meshId: string,
1744
+ task: MeshWorkQueueEntry,
1745
+ ctx: { sessionId: string; nodeId: string; providerType: string },
1746
+ ): 'claimed' | 'fallback' | 'backoff' | 'respawn' {
1747
+ const { sessionId, nodeId, providerType } = ctx;
1748
+ const backoffKey = `${meshId}::${task.id}`;
1749
+ const nowMs = Date.now();
1750
+ const state = autoLaunchAwaitClaimBackoff.get(backoffKey) || { cycles: 0, nextAttemptAtMs: 0 };
1751
+ // Rate-limit re-drive attempts to the backoff cadence so the 4s reconcile tick does not hammer
1752
+ // a still-cooling-down window. The initial (no-state) expiry proceeds immediately.
1753
+ if (state.nextAttemptAtMs && nowMs < state.nextAttemptAtMs) return 'backoff';
1754
+
1755
+ const atCap = state.cycles >= AUTO_LAUNCH_AWAIT_CLAIM_BACKOFF_CAP_CYCLES;
1756
+ const live = remoteSessionAppearsLive(meshId, sessionId);
1757
+
1758
+ // (B) Re-drive when the remote view shows the session live; (C) after the backoff cap, force the
1759
+ // same direct dispatch unconditionally. Both funnel through tryAssignQueueTask, which
1760
+ // idempotently (re)registers the session, delivers the task message (send_chat), and marks the
1761
+ // row assigned — the exact operation a coordinator performs manually via mesh_send_task. (D)
1762
+ // The setRemoteIdleSession re-register makes this robust to a dropped agent:ready.
1763
+ if ((live || atCap) && nodeId && providerType) {
1764
+ try {
1765
+ MeshRuntimeStore.getInstance().setRemoteIdleSession(meshId, nodeId, sessionId, providerType, nowMs + AUTO_LAUNCH_REMOTE_IDLE_TTL_MS);
1766
+ } catch { /* best-effort re-register */ }
1767
+ const assigned = tryAssignQueueTask(components, meshId, nodeId, sessionId, providerType);
1768
+ if (assigned) {
1769
+ autoLaunchAwaitClaimBackoff.delete(backoffKey);
1770
+ const isFallback = atCap && !live;
1771
+ recordAutoLaunchEvent(meshId, {
1772
+ phase: 'completed',
1773
+ taskId: task.id,
1774
+ reason: isFallback ? 'await_claim_direct_dispatch_fallback' : 'await_claim_redriven',
1775
+ nodeId,
1776
+ sessionId,
1777
+ });
1778
+ // Content-free progress line (ids only).
1779
+ LOG.info('MeshQueue', `Auto-launch await-claim ${isFallback ? 'direct-dispatch fallback' : 're-drive'} claimed task ${task.id} into existing session ${sessionId} on node ${nodeId} (mesh ${meshId})`);
1780
+ return isFallback ? 'fallback' : 'claimed';
1781
+ }
1782
+ if (atCap) {
1783
+ // The forced dispatch could not claim — the session is genuinely gone/unclaimable.
1784
+ // Authorize a fresh respawn (ghosts were already prevented through the backoff window).
1785
+ autoLaunchAwaitClaimBackoff.delete(backoffKey);
1786
+ return 'respawn';
1787
+ }
1788
+ }
1789
+ // Liveness unknown (or live-but-not-claimable) and not at cap → extend the window with backoff.
1790
+ const cycles = Math.min(state.cycles + 1, AUTO_LAUNCH_AWAIT_CLAIM_BACKOFF_CAP_CYCLES);
1791
+ autoLaunchAwaitClaimBackoff.set(backoffKey, { cycles, nextAttemptAtMs: nowMs + awaitClaimWindowMs(cycles) });
1792
+ recordAutoLaunchEvent(meshId, { phase: 'skipped', taskId: task.id, reason: 'awaiting_launched_session_claim_backoff', nodeId, sessionId });
1793
+ return 'backoff';
1794
+ }
1795
+
1796
+ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, meshId: string, mesh: any): Promise<boolean> {
1797
+ const queue = getQueue(meshId);
1798
+ // DEPENDSON-GATE-SYMMETRY: status index over the FULL queue (incl. completed)
1799
+ // so the dependency gate below sees the terminal state of every referenced
1800
+ // dependency, not just the still-active rows.
1801
+ const statusById = new Map(queue.map(task => [task.id, task.status] as const));
1802
+ // G6: scan higher task-level priority first so a high-priority task auto-launches its
1803
+ // session ahead of an older normal/low task (getQueue is FIFO; a stable sort by priority
1804
+ // rank descending keeps created_at order within a priority band). The claim path applies
1805
+ // the same ordering, so the launched session pulls the same task the scan chose.
1806
+ const pending = queue
1807
+ .filter(task => task.status === 'pending')
1808
+ .sort((a, b) => meshTaskPriorityRank(b.priority) - meshTaskPriorityRank(a.priority));
1809
+ // AUTOLAUNCH-CLAIM-CHURN: prune await-claim backoff state for tasks of this mesh that are no
1810
+ // longer pending (claimed/completed/cancelled) so the map cannot grow without bound.
1811
+ {
1812
+ const pendingIds = new Set(pending.map(t => t.id));
1813
+ const prefix = `${meshId}::`;
1814
+ for (const key of [...autoLaunchAwaitClaimBackoff.keys()]) {
1815
+ if (key.startsWith(prefix) && !pendingIds.has(key.slice(prefix.length))) autoLaunchAwaitClaimBackoff.delete(key);
1816
+ }
1817
+ }
1818
+ if (!pending.length) return false;
1819
+
1820
+ // Write cap + read-only cap resolved through the shared helpers from the
1821
+ // MACHINE-LOCAL stored mesh policy (no repo-file overlay). These are the same
1822
+ // resolvers the observability projection uses, so the enforced and exposed
1823
+ // caps can never drift.
1824
+ const maxParallelTasks = resolveMaxParallelTasks(mesh?.policy?.maxParallelTasks);
1825
+ // Read-only diagnoses carry no isolation/merge cost, so they are exempt from the
1826
+ // write-task parallel cap. To prevent runaway auto-launch they get their own,
1827
+ // higher safety cap (default 2× the write cap).
1828
+ const maxReadonlyParallelTasks = resolveMaxReadonlyParallelTasks(maxParallelTasks);
1829
+ for (const task of pending) {
1830
+ // DEPENDSON-GATE-SYMMETRY: never spawn a session for a task whose
1831
+ // dependsOn set is not all-completed (or that carries a system block). The
1832
+ // launched session would idle→claim and be refused by the SAME predicate in
1833
+ // claimNextQueueTask, producing orphan-session / re-launch churn. Skip it so
1834
+ // a later tick — after the dependency completes — launches it. Tasks with no
1835
+ // dependsOn pass through unchanged (predicate is true).
1836
+ if (!taskDependenciesSatisfied(task, statusById)) {
1837
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'dependencies_unsatisfied' });
1838
+ continue;
1839
+ }
1840
+ // G7: never spawn a session for a task still held by its notBefore gate — the launched
1841
+ // session would idle→claim and be refused by the SAME gate in claimNextQueueTask,
1842
+ // producing orphan-session churn. Skip it so a later tick (after not_before passes)
1843
+ // launches it. Tasks with no notBefore pass through unchanged.
1844
+ if (!meshTaskNotBeforeReady(task)) {
1845
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'not_before_delayed' });
1846
+ continue;
1847
+ }
1848
+ const isReadonly = isTaskReadonly(task);
1849
+ if (isReadonly) {
1850
+ if (activeReadonlyAssignedCount(meshId) >= maxReadonlyParallelTasks) {
1851
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_readonly_parallel_tasks_reached' });
1852
+ continue;
1853
+ }
1854
+ } else if (activeWriteAssignedCount(meshId) >= maxParallelTasks) {
1855
+ // Write tasks are capped; skip this one but keep scanning so a later
1856
+ // read-only task in the queue can still launch under its own cap.
1857
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_parallel_tasks_reached' });
1858
+ continue;
1859
+ }
1860
+ if (task.targetSessionId) {
1861
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_constraint' });
1862
+ continue;
1863
+ }
1864
+
1865
+ // Per-task await-claim guard. A prior auto-launch already spawned a session for
1866
+ // this task and we are waiting for that session's idle→claim to land (remote
1867
+ // claims arrive via the worker→coordinator agent:ready pull, which can lag well
1868
+ // past the per-node cooldown). Re-launching now would spawn a duplicate orphan
1869
+ // session that never gets work. The task leaves `pending` the instant the claim
1870
+ // succeeds, so this guard only suppresses the in-flight window; if the launched
1871
+ // session never reaches idle within the window, a later tick retries.
1872
+ if (task.autoLaunch?.status === 'completed' && task.autoLaunch.sessionId) {
1873
+ const launchedAtMs = Date.parse(task.autoLaunch.updatedAt);
1874
+ const alSessionId = readNonEmptyString(task.autoLaunch.sessionId);
1875
+ const alNodeId = readNonEmptyString(task.autoLaunch.nodeId);
1876
+ const alProvider = readNonEmptyString(task.autoLaunch.providerType);
1877
+ if (Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS) {
1878
+ // Record the skip in the ledger ONLY (dedup'd). Do NOT call markAutoLaunch
1879
+ // here: recordTaskAutoLaunch overwrites task.autoLaunch wholesale, which would
1880
+ // erase the very `completed` record (status + sessionId + updatedAt) this guard
1881
+ // reads on the next tick, reopening the duplicate-launch hole it closes.
1882
+ recordAutoLaunchEvent(meshId, { phase: 'skipped', taskId: task.id, reason: 'awaiting_launched_session_claim', nodeId: alNodeId, sessionId: alSessionId });
1883
+ continue;
1884
+ }
1885
+ // AUTOLAUNCH-CLAIM-CHURN: the initial await-claim window expired. Rather than a blind
1886
+ // respawn (which the local-only respawn guards can't dedup for a remote pending-claim
1887
+ // session → ghost accumulation), re-drive the claim for the EXISTING launched session,
1888
+ // backing off on unknown liveness and direct-dispatching after the cap. Only a
1889
+ // 'respawn' directive falls through to a fresh launch below.
1890
+ if (Number.isFinite(launchedAtMs) && alSessionId && alNodeId) {
1891
+ const outcome = driveExpiredAwaitClaim(components, meshId, task, { sessionId: alSessionId, nodeId: alNodeId, providerType: alProvider });
1892
+ if (outcome === 'claimed' || outcome === 'fallback') return true; // progress; suppress a duplicate launch
1893
+ if (outcome === 'backoff') continue; // window extended; no respawn
1894
+ // outcome === 'respawn' → session provably gone; proceed to a fresh launch below.
1895
+ }
1896
+ }
1897
+
1898
+ const candidateNodes = Array.isArray(mesh?.nodes)
1899
+ ? mesh.nodes.filter((node: any) => {
1900
+ // Bug A: match the target pin with the shared 3-form (id / nodeId / node_id)
1901
+ // normalizer, mirroring the remote-idle drain (meshNodeIdMatches at the
1902
+ // getRemoteIdleSessions filter). A strict `readMeshNodeId(node) !== targetNodeId`
1903
+ // dropped a target node whose identity arrived under a different form (a freshly
1904
+ // mesh_clone_node'd worktree), emptying candidateNodes and mislabelling the skip.
1905
+ if (task.targetNodeId && !meshNodeIdMatches(node, task.targetNodeId)) return false;
1906
+ // WTDISPATCH-FANOUT: a convergence task is base-only (it merges/pushes onto
1907
+ // base). Never auto-launch a worktree-clone session for it — that is the very
1908
+ // fan-out the claim guard refuses, so spinning the session up would only waste
1909
+ // a launch that can never claim. Mirrors claimNextQueueTask's convergence gate.
1910
+ if (task.taskMode === 'convergence' && node?.isLocalWorktree === true) return false;
1911
+ // Skip nodes that can never satisfy requiredTags regardless of which provider
1912
+ // from providerPriority is selected. A node satisfies tags if at least one
1913
+ // provider in its priority list would produce matching capability tags.
1914
+ if (task.requiredTags?.length) {
1915
+ const priorities = normalizeProviderPriority(node?.policy);
1916
+ const providerCandidates = priorities.length ? priorities : [undefined as unknown as string];
1917
+ return providerCandidates.some(p =>
1918
+ nodeSatisfiesRequiredTags(task.requiredTags, buildMeshNodeCapabilityTags(node, p))
1919
+ );
1920
+ }
1921
+ return true;
1922
+ })
1923
+ : [];
1924
+ if (!candidateNodes.length) {
1925
+ // Bug A: distinguish the two ways the candidate set empties. A task pinned to a
1926
+ // targetNodeId whose node is absent from the mesh (or whose id arrived under a
1927
+ // different form) is a ROUTING miss — report it as `target_node_id_unmatched`, not
1928
+ // the hard-coded `no_node_satisfies_required_tags`, which mislabelled a 3-form
1929
+ // node-id mismatch as a capability failure and sent diagnosis down the wrong path.
1930
+ // Only fall back to the tag reason when no target pin is in play, or the pin DID
1931
+ // match a node but its tags excluded it (a genuine capability miss).
1932
+ const targetPinUnmatched = !!task.targetNodeId
1933
+ && !(Array.isArray(mesh?.nodes) && mesh.nodes.some((n: any) => meshNodeIdMatches(n, task.targetNodeId)));
1934
+ // Fix (2): a `convergence` task is base-only — the candidate filter above
1935
+ // (`taskMode === 'convergence' && node.isLocalWorktree`) deliberately drops every
1936
+ // worktree-clone node, so candidateNodes can empty out NOT because the target is
1937
+ // missing or tag-incapable, but because every node the task could land on is a
1938
+ // worktree. Reporting that as `target_node_id_unmatched` / `no_node_satisfies_
1939
+ // required_tags` mislabels the cause and sends diagnosis down the wrong path.
1940
+ // Detect it explicitly and report the same reason mesh_send_task uses for a direct
1941
+ // convergence dispatch onto a worktree, so both surfaces agree.
1942
+ const convergenceOntoWorktree = task.taskMode === 'convergence'
1943
+ && Array.isArray(mesh?.nodes)
1944
+ && (() => {
1945
+ const matched = (mesh.nodes as any[]).filter((n: any) =>
1946
+ !task.targetNodeId || meshNodeIdMatches(n, task.targetNodeId));
1947
+ return matched.length > 0 && matched.every((n: any) => n?.isLocalWorktree === true);
1948
+ })();
1949
+ // FALSE-BLOCKER-CLONE-QUEUE: an unmatched target pin is only a PERMANENT routing
1950
+ // miss when the node is genuinely absent — a freshly cloned worktree whose
1951
+ // inline-cache entry has not propagated here yet (or whose bootstrap is still
1952
+ // running) is TRANSIENTLY unresolved and auto-claims shortly. Report that as the
1953
+ // transient (non-actionable) reason so the coordinator is not paged with a false
1954
+ // "actionable blocker — will NOT clear on its own". A genuinely dead node is neither
1955
+ // bootstrap-running nor inside the clone grace window → stays 'target_node_id_unmatched'.
1956
+ const targetTransientlyUnresolved = targetPinUnmatched
1957
+ && isTargetNodeTransientlyUnresolved(mesh, task);
1958
+ markAutoLaunch(meshId, task.id, {
1959
+ status: 'skipped',
1960
+ reason: convergenceOntoWorktree
1961
+ ? 'mesh_convergence_target_is_worktree'
1962
+ : targetTransientlyUnresolved
1963
+ ? TRANSIENT_TARGET_NODE_BOOTSTRAP_PENDING_REASON
1964
+ : (targetPinUnmatched ? 'target_node_id_unmatched' : 'no_node_satisfies_required_tags'),
1965
+ nodeId: task.targetNodeId,
1966
+ });
1967
+ continue;
1968
+ }
1969
+
1970
+ // PRIORITY → TIE-BREAK: order the eligible (TAG-filtered) candidate nodes by
1971
+ // the mesh scheduling strategy. 'first_eligible' (default) returns them in
1972
+ // config/array order unchanged, so distribution is strictly opt-in. The
1973
+ // per-node MAX-ALLOC capacity gate (nodeHasActiveAssignment, provider cap,
1974
+ // maxConcurrentSessions) is still applied inside the loop below; this only
1975
+ // chooses which eligible node is *tried first*.
1976
+ const strategy = resolveSchedulingStrategy(mesh);
1977
+ const orderedCandidateNodes = strategy === 'first_eligible'
1978
+ ? candidateNodes
1979
+ : orderEligibleNodes(
1980
+ meshId,
1981
+ strategy,
1982
+ candidateNodes
1983
+ .map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
1984
+ .filter((c: RankableNode) => c.nodeId),
1985
+ // Auto-launch drains one task at a time, so the task IS in scope here —
1986
+ // pass it through for the 'fitness' strategy's task→slot ranking.
1987
+ { bumpCursor: true, task: { difficulty: (task as any).difficulty, requiredTags: task.requiredTags } },
1988
+ ).map((c: RankableNode) => c.node);
1989
+
1990
+ for (const node of orderedCandidateNodes) {
1991
+ const nodeId = readMeshNodeId(node);
1992
+ if (!nodeId) continue;
1993
+ const launchKey = `${meshId}:${nodeId}`;
1994
+ const now = Date.now();
1995
+ const cooldownUntil = autoLaunchCooldownUntil.get(launchKey) || 0;
1996
+ if (cooldownUntil > 0 && now >= cooldownUntil) autoLaunchCooldownUntil.delete(launchKey);
1997
+ if (autoLaunchInProgress.has(launchKey)) {
1998
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_in_progress', nodeId });
1999
+ continue;
2000
+ }
2001
+ if (now < cooldownUntil) {
2002
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_cooldown', nodeId });
2003
+ continue;
2004
+ }
2005
+ if (isDirtyNode(node)) {
2006
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'dirty_workspace', nodeId });
2007
+ continue;
2008
+ }
2009
+ if (!isLaunchableNode(node)) {
2010
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_not_launch_ready', nodeId });
2011
+ continue;
2012
+ }
2013
+ const launchTarget = resolveAutoLaunchTarget(components, node);
2014
+ if (launchTarget.mode === 'skip') {
2015
+ // Remote node we can't reach (no transport / no coordinator daemonId).
2016
+ // Set a cooldown so the 4s reconcile loop doesn't re-attempt this node
2017
+ // every tick; the de-dup'd skip ledger keeps it diagnosable without flood.
2018
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: launchTarget.reason || 'auto_launch_unavailable', nodeId });
2019
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
2020
+ continue;
2021
+ }
2022
+ // DOUBLE-DISPATCH auto-launch gate (see nodeHasLiveSessionPendingClaim): when this
2023
+ // node already has a live session on its way to claim (idle / booting / momentary
2024
+ // non-idle flip), do NOT spawn a second one — that session pulls the pending task
2025
+ // via the normal idle→claim / agent:ready drain. Launching here races it and yields
2026
+ // a duplicate worker that double-stamps the same taskId. Applies to read-only tasks
2027
+ // too: an idle session can claim either kind, while a genuinely BUSY session (holding
2028
+ // its own assigned task) is excluded by the helper, so a read-only launch onto a
2029
+ // busy-but-no-idle node is still allowed. Skip with a transient (non-actionable)
2030
+ // reason so the coordinator is not paged; the 4s reconcile retries, and once the
2031
+ // existing session goes terminal this gate clears and a legitimate launch proceeds.
2032
+ if (nodeHasLiveSessionPendingClaim(components, meshId, nodeId)) {
2033
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_live_session_pending_claim', nodeId });
2034
+ continue;
2035
+ }
2036
+ // Write tasks keep the one-active-per-node invariant (worktree isolation);
2037
+ // read-only diagnoses may auto-launch onto a node that already has an active
2038
+ // assignment. Classified by the shared isTaskReadonly predicate.
2039
+ if (!isTaskReadonly(task) && nodeHasActiveAssignment(meshId, nodeId)) {
2040
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_active_assignment', nodeId });
2041
+ continue;
2042
+ }
2043
+ const maxConcurrentSessions = Number(node?.policy?.maxConcurrentSessions);
2044
+ if (Number.isFinite(maxConcurrentSessions) && maxConcurrentSessions >= 0 && liveSessionCountForNode(components, meshId, nodeId) >= maxConcurrentSessions) {
2045
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_concurrent_sessions_reached', nodeId });
2046
+ continue;
2047
+ }
2048
+
2049
+ autoLaunchInProgress.add(launchKey);
2050
+ try {
2051
+ const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags, { difficulty: (task as any).difficulty, requiredTags: task.requiredTags });
2052
+ if (!resolved.providerType) {
2053
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: resolved.reason || 'provider_unusable', nodeId });
2054
+ continue;
2055
+ }
2056
+ // Slot-derived model/thinking: an explicit task.model/thinkingLevel
2057
+ // (resolved from the enqueue-time brain) still wins; the matched
2058
+ // slot fills only what the task left blank (ORCHESTRATION_NODE_SLOTS.md).
2059
+ const effectiveModel = (typeof task.model === 'string' && task.model.trim()) ? task.model.trim() : resolved.model;
2060
+ const effectiveThinkingLevel = (typeof task.thinkingLevel === 'string' && task.thinkingLevel.trim()) ? task.thinkingLevel.trim() : resolved.thinkingLevel;
2061
+
2062
+ // Don't spawn a session for a (node, provider) already at its declared
2063
+ // maxParallel cap — it would launch only to fail the claim. The claim
2064
+ // transaction enforces the cap regardless; this just avoids a doomed launch.
2065
+ const providerCap = resolveProviderMaxParallel(node?.policy, resolved.providerType);
2066
+ if (
2067
+ providerCap !== undefined
2068
+ && activeProviderAssignedCount(meshId, nodeId, resolved.providerType) >= providerCap
2069
+ ) {
2070
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_provider_parallel_reached', nodeId, providerType: resolved.providerType });
2071
+ continue;
2072
+ }
2073
+
2074
+ // Shared worker-launch envelope. For a local node it spawns directly on this
2075
+ // daemon; for a remote node the identical command is forwarded to the node's
2076
+ // daemon (mirrors mesh_launch_session), with the coordinator daemonId stamped
2077
+ // so the worker's completion events route back to this coordinator.
2078
+ const launchSettings: Record<string, unknown> = {
2079
+ // Worker launch envelope: role + mesh context so worker can route completion events.
2080
+ role: 'worker',
2081
+ meshNodeFor: meshId,
2082
+ meshNodeId: nodeId,
2083
+ spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
2084
+ // Coordinator-dispatched worker: auto-approve unless mesh/node policy
2085
+ // opts out (default true). Lands in settingsOverride and beats the
2086
+ // global per-provider-type autoApprove config (see shouldAutoApprove).
2087
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
2088
+ launchedByCoordinator: true,
2089
+ autoLaunchedForQueueTaskId: task.id,
2090
+ };
2091
+
2092
+ if (launchTarget.mode === 'remote') {
2093
+ // Relay-safe completion routing: stamp the coordinator anchor the same way
2094
+ // mesh_launch_session does so the worker forwards events back to this daemon.
2095
+ const remoteSettings: Record<string, unknown> = {
2096
+ ...launchSettings,
2097
+ meshCoordinatorDaemonId: launchTarget.coordinatorDaemonId,
2098
+ meshCoordinatorNodeId: nodeId,
2099
+ };
2100
+ markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
2101
+ let launchResult: any;
2102
+ try {
2103
+ launchResult = await components.dispatchMeshCommand!(launchTarget.daemonId!, 'launch_cli', {
2104
+ cliType: resolved.providerType,
2105
+ dir: node.workspace,
2106
+ settings: remoteSettings,
2107
+ // MAGI-KIND-PANEL model axis: forward the task's model override so the
2108
+ // remote worker session launches with it (initialModel). Best-effort.
2109
+ // Slot-aware: task override wins, else the matched slot's model.
2110
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2111
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2112
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
2113
+ });
2114
+ } catch (e: any) {
2115
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
2116
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
2117
+ return false;
2118
+ }
2119
+ const payload = (launchResult && typeof launchResult === 'object' && 'payload' in launchResult && launchResult.payload && typeof launchResult.payload === 'object')
2120
+ ? launchResult.payload
2121
+ : launchResult;
2122
+ if (!payload?.success) {
2123
+ const reason = readNonEmptyString(payload?.error) || 'remote_launch_cli_failed';
2124
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
2125
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
2126
+ return false;
2127
+ }
2128
+ // Remote launch is async: the worker session will register and emit agent:ready,
2129
+ // which (forwarded back here) drives the claim via the normal event path / PHASE 1
2130
+ // reconcile. Set a cooldown so the 4s loop doesn't re-launch before that lands.
2131
+ const remoteSessionId = readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.id) || readNonEmptyString(payload.runtimeSessionId);
2132
+ markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId: remoteSessionId || undefined });
2133
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
2134
+ return true;
2135
+ }
2136
+
2137
+ markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
2138
+ const launchResult: any = await components.cliManager.handleCliCommand('launch_cli', {
2139
+ cliType: resolved.providerType,
2140
+ dir: node.workspace,
2141
+ settings: launchSettings,
2142
+ // MAGI-KIND-PANEL model axis: local launch forwards the effective model
2143
+ // (task override, else matched slot) as initialModel (CLI → modelLaunchArgs; ACP → setConfigOption).
2144
+ ...(effectiveModel ? { initialModel: effectiveModel } : {}),
2145
+ // BRAIN-ROUTING thinking axis: forward the effective thinking level (initialThinkingLevel).
2146
+ ...(effectiveThinkingLevel ? { initialThinkingLevel: effectiveThinkingLevel } : {}),
2147
+ });
2148
+ if (!launchResult?.success) {
2149
+ const reason = launchResult?.error || 'launch_cli_failed';
2150
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
2151
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
2152
+ return false;
2153
+ }
2154
+ const sessionId = readNonEmptyString(launchResult.sessionId) || readNonEmptyString(launchResult.id) || readNonEmptyString(launchResult.runtimeSessionId);
2155
+ if (!sessionId) {
2156
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason: 'launch_missing_session_id', nodeId, providerType: resolved.providerType });
2157
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
2158
+ return false;
2159
+ }
2160
+ markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId });
2161
+ // Readiness barrier: a freshly-spawned local CLI session is NOT yet
2162
+ // interactive — its PTY prints the input prompt (and the adapter flips
2163
+ // isReady()) only ~2-6s after launch. Dispatching the task immediately
2164
+ // pushes the first (often large) message into a not-yet-ready PTY, which
2165
+ // could throw "not ready" and bounce the task through requeue (on win32
2166
+ // this raced the auto-launch cooldown and stranded the worker idle).
2167
+ // Await interactive readiness before claiming/dispatching so the very
2168
+ // first message lands cleanly. The adapter's queue-until-ready path is the
2169
+ // backstop if readiness is reported late; this just avoids the churn.
2170
+ await waitForLocalSessionReady(components, sessionId);
2171
+ tryAssignQueueTask(components, meshId, nodeId, sessionId, resolved.providerType);
2172
+ return true;
2173
+ } catch (e: any) {
2174
+ markAutoLaunch(meshId, task.id, { status: 'failed', error: e?.message || String(e), nodeId });
2175
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS);
2176
+ return false;
2177
+ } finally {
2178
+ autoLaunchInProgress.delete(launchKey);
2179
+ }
2180
+ }
2181
+ }
2182
+ return false;
2183
+ }
2184
+
2185
+ export interface MeshQueueTriggerResult {
2186
+ success: true;
2187
+ meshId: string;
2188
+ pendingBefore: number;
2189
+ assignedBefore: number;
2190
+ pendingAfter: number;
2191
+ assignedAfter: number;
2192
+ claimed: boolean;
2193
+ newlyAssignedTasks: Array<{
2194
+ id: string;
2195
+ nodeId?: string;
2196
+ sessionId?: string;
2197
+ }>;
2198
+ localIdleSessionsChecked: number;
2199
+ remoteIdleSessionsChecked: number;
2200
+ skippedSessions: Array<{
2201
+ nodeId?: string;
2202
+ sessionId?: string;
2203
+ reason: string;
2204
+ status?: string;
2205
+ }>;
2206
+ autoLaunchStarted: boolean;
2207
+ /**
2208
+ * True when a worker session is already on its way to claim a still-pending task —
2209
+ * either launched this tick (autoLaunchStarted) or launched on a prior tick and still
2210
+ * booting/awaiting-claim. Callers MUST treat this as "wait, do not launch another
2211
+ * session": a second launch double-edits the worktree. Mutually informative with
2212
+ * `noIdleMeshSessionAvailable`, which is suppressed whenever this is true.
2213
+ */
2214
+ autoLaunchPending?: boolean;
2215
+ noIdleMeshSessionAvailable?: boolean;
2216
+ }
2217
+
2218
+ function countQueueStatus(meshId: string, status: 'pending' | 'assigned'): number {
2219
+ return getQueue(meshId, { status: [status] as any }).length;
2220
+ }
2221
+
2222
+ function getQueueStatusById(meshId: string): Map<string, string> {
2223
+ return new Map(getQueue(meshId).map(task => [task.id, task.status]));
2224
+ }
2225
+
2226
+ export async function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<MeshQueueTriggerResult> {
2227
+ const mesh = getMeshWithCache(components, meshId);
2228
+ const pendingBefore = countQueueStatus(meshId, 'pending');
2229
+ const assignedBefore = countQueueStatus(meshId, 'assigned');
2230
+ const beforeStatus = getQueueStatusById(meshId);
2231
+ const skippedSessions: MeshQueueTriggerResult['skippedSessions'] = [];
2232
+ let localIdleSessionsChecked = 0;
2233
+ let remoteIdleSessionsChecked = 0;
2234
+ let autoLaunchStarted = false;
2235
+ if (!mesh) {
2236
+ return {
2237
+ success: true,
2238
+ meshId,
2239
+ pendingBefore,
2240
+ assignedBefore,
2241
+ pendingAfter: pendingBefore,
2242
+ assignedAfter: assignedBefore,
2243
+ claimed: false,
2244
+ newlyAssignedTasks: [],
2245
+ localIdleSessionsChecked,
2246
+ remoteIdleSessionsChecked,
2247
+ skippedSessions: [{ reason: 'mesh_not_found' }],
2248
+ autoLaunchStarted,
2249
+ noIdleMeshSessionAvailable: true,
2250
+ };
2251
+ }
2252
+
2253
+ // Collect every idle mesh session (local CLI instances + remote idle records)
2254
+ // as drain candidates. The drain ORDER depends on the scheduling strategy:
2255
+ // - 'first_eligible' (default): local-first, then remote, exactly as before.
2256
+ // - otherwise: local + remote merged into one pool and drained in scheduling
2257
+ // order (priority → load → tie-break). This local-first debias is required
2258
+ // because without it the coordinator's own local node is always visited
2259
+ // first and greedily absorbs all untargeted work before any remote idle
2260
+ // session is even considered — the comparator alone can't spread work if
2261
+ // local is always tried first.
2262
+ const strategy = resolveSchedulingStrategy(mesh);
2263
+ const localCandidates: IdleCandidate[] = [];
2264
+
2265
+ const cliInstances = components.instanceManager.getByCategory('cli');
2266
+ for (const inst of cliInstances) {
2267
+ const state = inst.getState();
2268
+ const settings = state.settings as Record<string, unknown> || {};
2269
+
2270
+ const instMeshId = readNonEmptyString(settings.meshNodeFor);
2271
+ if (instMeshId !== meshId) continue;
2272
+
2273
+ const nodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
2274
+ if (!nodeId) continue;
2275
+
2276
+ if (!isIdleSessionState(state)) {
2277
+ const status = readNonEmptyString(state.status).toLowerCase();
2278
+ skippedSessions.push({
2279
+ nodeId,
2280
+ sessionId: readNonEmptyString(state.instanceId),
2281
+ reason: isTerminalSessionStatus(status) ? 'terminal_session' : 'session_not_idle',
2282
+ status: status || undefined,
2283
+ });
2284
+ continue;
2285
+ }
2286
+
2287
+ const sessionId = state.instanceId;
2288
+ const providerType = state.type || readNonEmptyString(settings.providerType);
2289
+
2290
+ if (providerType) {
2291
+ localIdleSessionsChecked += 1;
2292
+ localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => meshNodeIdMatches(n, nodeId)) });
2293
+ } else {
2294
+ skippedSessions.push({
2295
+ nodeId,
2296
+ sessionId,
2297
+ reason: 'provider_type_missing',
2298
+ });
2299
+ }
2300
+ }
2301
+
2302
+ let remoteSessions: Array<{ nodeId: string; sessionId: string; providerType: string }> = [];
2303
+ try {
2304
+ remoteSessions = MeshRuntimeStore.getInstance().getRemoteIdleSessions(meshId);
2305
+ } catch { /* best-effort */ }
2306
+
2307
+ const remoteCandidates: IdleCandidate[] = [];
2308
+ for (const idle of remoteSessions) {
2309
+ // Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
2310
+ // `n.id`, so an inline-cached worktree node whose identity arrived under a
2311
+ // different form is not silently dropped — leaving a remote idle session
2312
+ // unable to claim its pending queue task.
2313
+ const node = mesh.nodes.find((n: any) => meshNodeIdMatches(n, idle.nodeId));
2314
+ if (node) {
2315
+ remoteIdleSessionsChecked += 1;
2316
+ remoteCandidates.push({ nodeId: idle.nodeId, sessionId: idle.sessionId, providerType: idle.providerType, origin: 'remote', node });
2317
+ }
2318
+ }
2319
+
2320
+ const assignIdleCandidate = (candidate: IdleCandidate): void => {
2321
+ const assigned = tryAssignQueueTask(components, meshId, candidate.nodeId, candidate.sessionId, candidate.providerType);
2322
+ if (assigned && candidate.origin === 'remote') {
2323
+ try {
2324
+ MeshRuntimeStore.getInstance().deleteRemoteIdleSession(meshId, candidate.nodeId, candidate.sessionId);
2325
+ } catch { /* best-effort */ }
2326
+ }
2327
+ };
2328
+
2329
+ if (strategy === 'first_eligible') {
2330
+ // Strict no-change: drain local idle sessions first (original order), then
2331
+ // remote idle sessions. tryAssignQueueTask is a no-op when nothing matches.
2332
+ for (const candidate of localCandidates) assignIdleCandidate(candidate);
2333
+ for (const candidate of remoteCandidates) assignIdleCandidate(candidate);
2334
+ } else {
2335
+ // Merge local + remote into one pool and drain in scheduling order. Each
2336
+ // assignment mutates a node's active load, and the next pick re-reads it,
2337
+ // so re-ranking after every assignment keeps the spread fair as load shifts.
2338
+ // buildSchedulingPool canonicalizes every candidate's nodeId so the Set
2339
+ // dedup, baseIndex, rankIndex, and nodeActiveLoad keying below all agree on
2340
+ // one form (see the invariant on that helper).
2341
+ const { pool, uniqueNodes } = buildSchedulingPool(localCandidates, remoteCandidates);
2342
+ const baseIndex = new Map<string, number>();
2343
+ pool.forEach((c, i) => { if (!baseIndex.has(c.nodeId)) baseIndex.set(c.nodeId, i); });
2344
+ // Bump the round-robin cursor once for this whole drain pass.
2345
+ const ranked = orderEligibleNodes(meshId, strategy, uniqueNodes, { bumpCursor: true });
2346
+ const rankIndex = new Map<string, number>(ranked.map((r, i) => [r.nodeId, i]));
2347
+ const remaining = [...pool];
2348
+ while (remaining.length > 0) {
2349
+ // Re-rank each pass so a node that just took work defers its next session.
2350
+ remaining.sort((a, b) => {
2351
+ const aPrio = resolveNodeSchedulingPriority(a.node?.policy);
2352
+ const bPrio = resolveNodeSchedulingPriority(b.node?.policy);
2353
+ if (aPrio !== bPrio) return bPrio - aPrio;
2354
+ // The idle-session drain ranks task-independently (a session pulls
2355
+ // whatever task matches), so 'fitness' here reduces to load-aware
2356
+ // ordering — the same tiebreak as least_loaded/round_robin.
2357
+ if (strategy === 'least_loaded' || strategy === 'round_robin' || strategy === 'fitness') {
2358
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
2359
+ if (loadDelta !== 0) return loadDelta;
2360
+ }
2361
+ return (rankIndex.get(a.nodeId) ?? 0) - (rankIndex.get(b.nodeId) ?? 0);
2362
+ });
2363
+ assignIdleCandidate(remaining.shift()!);
2364
+ }
2365
+ }
2366
+
2367
+ autoLaunchStarted = await maybeAutoLaunchOneQueueSession(components, meshId, mesh);
2368
+ const afterQueue = getQueue(meshId);
2369
+ const pendingAfter = afterQueue.filter(task => task.status === 'pending').length;
2370
+ const assignedAfter = afterQueue.filter(task => task.status === 'assigned').length;
2371
+ const newlyAssignedTasks = afterQueue
2372
+ .filter(task => task.status === 'assigned' && beforeStatus.get(task.id) !== 'assigned')
2373
+ .map(task => ({
2374
+ id: task.id,
2375
+ nodeId: task.assignedNodeId,
2376
+ sessionId: task.assignedSessionId,
2377
+ }));
2378
+
2379
+ // An auto-launch is "pending" when the coordinator has already spun a session up
2380
+ // for a still-pending task and is waiting on that session's idle→claim. This covers
2381
+ // two ticks:
2382
+ // - THIS tick fired the launch (autoLaunchStarted), or
2383
+ // - a PRIOR tick launched a session that is still booting/awaiting-claim — the
2384
+ // per-task await-claim guard (maybeAutoLaunchOneQueueSession) deliberately
2385
+ // declines to launch again, so autoLaunchStarted is false even though a session
2386
+ // is on its way to claim this task.
2387
+ // Without this signal, the second tick reports `noIdleMeshSessionAvailable` and the
2388
+ // MCP guidance tells the coordinator to launch ANOTHER worker — producing a duplicate
2389
+ // session that double-edits the worktree. The claim itself is fine; only the wording
2390
+ // was wrong, so we surface `autoLaunchPending` to suppress the bad "launch one more"
2391
+ // advice while the just-launched session converges.
2392
+ const autoLaunchPending = autoLaunchStarted || afterQueue.some(task => {
2393
+ if (task.status !== 'pending') return false;
2394
+ const al = task.autoLaunch;
2395
+ if (!al || (al.status !== 'started' && al.status !== 'completed')) return false;
2396
+ const launchedAtMs = Date.parse(al.updatedAt);
2397
+ return Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
2398
+ });
2399
+
2400
+ return {
2401
+ success: true,
2402
+ meshId,
2403
+ pendingBefore,
2404
+ assignedBefore,
2405
+ pendingAfter,
2406
+ assignedAfter,
2407
+ claimed: newlyAssignedTasks.length > 0,
2408
+ newlyAssignedTasks,
2409
+ localIdleSessionsChecked,
2410
+ remoteIdleSessionsChecked,
2411
+ skippedSessions,
2412
+ autoLaunchStarted,
2413
+ ...(autoLaunchPending ? { autoLaunchPending: true } : {}),
2414
+ // Only report "no idle session, go launch one" when nothing is already on its way.
2415
+ // A pending auto-launch (this tick or a prior still-converging one) means a session
2416
+ // WILL claim shortly, so it is not a no-session-available situation.
2417
+ ...(pendingAfter > 0 && newlyAssignedTasks.length === 0 && localIdleSessionsChecked === 0 && remoteIdleSessionsChecked === 0 && !autoLaunchPending
2418
+ ? { noIdleMeshSessionAvailable: true }
2419
+ : {}),
2420
+ };
2421
+ }
2422
+
2423
+ export async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args: {
2424
+ meshId: string;
2425
+ nodeId: string;
2426
+ sessionId?: string;
2427
+ providerType?: string;
2428
+ }): Promise<void> {
2429
+ const mesh = getMeshWithCache(components, args.meshId);
2430
+ const node = mesh?.nodes?.find((candidate: any) => meshNodeIdMatches(candidate, args.nodeId));
2431
+ const workspace = readNonEmptyString(node?.workspace);
2432
+ if (!workspace) return;
2433
+ if (!existsSync(workspace)) return;
2434
+
2435
+ const policy = resolveAutoFastForwardPolicy(mesh);
2436
+ if (!policy.enabled) return;
2437
+ if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId, args.sessionId)) return;
2438
+
2439
+ const throttleKey = `${args.meshId}:${args.nodeId}`;
2440
+ const now = Date.now();
2441
+ const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
2442
+ if (now - lastAttempt < IDLE_AUTO_FAST_FORWARD_THROTTLE_MS) return;
2443
+ idleAutoFastForwardLastAttempt.set(throttleKey, now);
2444
+
2445
+ const submoduleIgnorePaths = Array.isArray(node?.policy?.submoduleIgnorePaths)
2446
+ ? node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
2447
+ : undefined;
2448
+ try {
2449
+ const dryRun = await fastForwardMeshNode({
2450
+ meshId: args.meshId,
2451
+ nodeId: args.nodeId,
2452
+ workspace,
2453
+ execute: false,
2454
+ dryRun: true,
2455
+ updateSubmodules: false,
2456
+ submoduleIgnorePaths,
2457
+ trigger: 'idle_auto',
2458
+ });
2459
+ if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
2460
+ const behind = Number(dryRun.current?.behind);
2461
+ if (policy.maxBehind !== undefined && Number.isFinite(behind) && behind > policy.maxBehind) return;
2462
+ if (policy.requireCleanSubmodules) {
2463
+ const submodules = Array.isArray(dryRun.current?.submodules) ? dryRun.current.submodules : [];
2464
+ if (submodules.some((submodule: any) => submodule?.dirty || submodule?.outOfSync || submodule?.error)) return;
2465
+ }
2466
+ await fastForwardMeshNode({
2467
+ meshId: args.meshId,
2468
+ nodeId: args.nodeId,
2469
+ workspace,
2470
+ execute: true,
2471
+ dryRun: false,
2472
+ updateSubmodules: false,
2473
+ submoduleIgnorePaths,
2474
+ trigger: 'idle_auto',
2475
+ });
2476
+ } catch (e: any) {
2477
+ LOG.warn('MeshFastForward', `Idle auto fast-forward check failed for ${args.nodeId}: ${e?.message || e}`);
2478
+ }
2479
+ }
2480
+
2481
+ export function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
2482
+ meshId: string;
2483
+ nodeId: string;
2484
+ sessionId: string;
2485
+ providerType: string;
2486
+ }): void {
2487
+ setImmediate(() => {
2488
+ maybeAutoFastForwardIdleNode(components, args)
2489
+ .finally(() => {
2490
+ try {
2491
+ tryAssignQueueTask(components, args.meshId, args.nodeId, args.sessionId, args.providerType);
2492
+ } catch (e: any) {
2493
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${args.nodeId}: ${e?.message || e}`);
2494
+ }
2495
+ });
2496
+ });
2497
+ }
2498
+