@adhdev/daemon-core 0.9.82-rc.38 → 0.9.82-rc.381

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 (414) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +9 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/build-info.d.ts +37 -0
  4. package/dist/chat/source-machine.d.ts +166 -0
  5. package/dist/chat/source-resolver.d.ts +104 -0
  6. package/dist/chat/subscription-updates.d.ts +1 -0
  7. package/dist/cli-adapter-types.d.ts +55 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +121 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +63 -0
  14. package/dist/cli-adapters/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 +14 -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 +7 -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 +15 -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 +60 -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 +2 -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 +116 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router.d.ts +227 -2
  56. package/dist/commands/upgrade-helper.d.ts +41 -1
  57. package/dist/config/chat-history.d.ts +9 -0
  58. package/dist/config/config.d.ts +5 -0
  59. package/dist/config/mesh-config.d.ts +77 -1
  60. package/dist/daemon/dev-server.d.ts +0 -2
  61. package/dist/detection/ide-detector.d.ts +13 -0
  62. package/dist/detection/win32-ide-version.d.ts +37 -0
  63. package/dist/git/change-impact-config.d.ts +159 -0
  64. package/dist/git/git-commands.d.ts +11 -1
  65. package/dist/git/git-diff.d.ts +6 -0
  66. package/dist/git/git-executor.d.ts +11 -0
  67. package/dist/git/git-status.d.ts +23 -0
  68. package/dist/git/git-types.d.ts +2 -50
  69. package/dist/git/index.d.ts +2 -0
  70. package/dist/index.d.ts +57 -13
  71. package/dist/index.js +47417 -19860
  72. package/dist/index.js.map +1 -1
  73. package/dist/index.mjs +45865 -18449
  74. package/dist/index.mjs.map +1 -1
  75. package/dist/installer.d.ts +1 -4
  76. package/dist/ipc/local-ipc-server.d.ts +91 -0
  77. package/dist/launch.d.ts +1 -1
  78. package/dist/logging/async-batch-writer.d.ts +10 -0
  79. package/dist/logging/log-redactor.d.ts +24 -0
  80. package/dist/logging/log-tail-reader.d.ts +81 -0
  81. package/dist/logging/logger.d.ts +1 -1
  82. package/dist/mesh/contracts.d.ts +164 -0
  83. package/dist/mesh/coordinator-prompt.d.ts +89 -0
  84. package/dist/mesh/coordinator-registry.d.ts +59 -0
  85. package/dist/mesh/mesh-active-work.d.ts +174 -0
  86. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  87. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  88. package/dist/mesh/mesh-event-classify.d.ts +5 -0
  89. package/dist/mesh/mesh-event-forwarding.d.ts +18 -0
  90. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  91. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  92. package/dist/mesh/mesh-events-pending.d.ts +55 -0
  93. package/dist/mesh/mesh-events-stale.d.ts +52 -0
  94. package/dist/mesh/mesh-events-utils.d.ts +64 -0
  95. package/dist/mesh/mesh-events.d.ts +6 -49
  96. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  97. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  98. package/dist/mesh/mesh-init.d.ts +86 -0
  99. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  100. package/dist/mesh/mesh-ledger.d.ts +77 -1
  101. package/dist/mesh/mesh-missions.d.ts +164 -0
  102. package/dist/mesh/mesh-node-identity.d.ts +289 -0
  103. package/dist/mesh/mesh-queue-assignment.d.ts +86 -0
  104. package/dist/mesh/mesh-reconcile-loop.d.ts +8 -0
  105. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  106. package/dist/mesh/mesh-refine-gates.d.ts +428 -0
  107. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  108. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  109. package/dist/mesh/mesh-routing.d.ts +70 -0
  110. package/dist/mesh/mesh-runtime-store.d.ts +439 -0
  111. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  112. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  113. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  114. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  115. package/dist/mesh/mesh-work-queue.d.ts +247 -5
  116. package/dist/mesh/preview-freshness.d.ts +18 -0
  117. package/dist/mesh/refine-config.d.ts +216 -0
  118. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  119. package/dist/providers/acp-provider-instance.d.ts +5 -0
  120. package/dist/providers/approval-utils.d.ts +20 -0
  121. package/dist/providers/chat-message-normalization.d.ts +13 -1
  122. package/dist/providers/cli-provider-instance.d.ts +111 -3
  123. package/dist/providers/contracts.d.ts +139 -6
  124. package/dist/providers/external-sources.d.ts +71 -0
  125. package/dist/providers/manual-attendance.d.ts +63 -0
  126. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  127. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  128. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  129. package/dist/providers/native-history/constants.d.ts +12 -0
  130. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  131. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  132. package/dist/providers/native-history/index.d.ts +13 -0
  133. package/dist/providers/provider-instance-manager.d.ts +14 -0
  134. package/dist/providers/provider-instance.d.ts +23 -1
  135. package/dist/providers/provider-loader.d.ts +26 -4
  136. package/dist/providers/provider-trust.d.ts +31 -0
  137. package/dist/providers/read-chat-contract.d.ts +29 -0
  138. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  139. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  140. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  141. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  142. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  143. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  144. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  145. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  146. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  147. package/dist/providers/sdk/v1/index.d.ts +30 -0
  148. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  149. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  150. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  151. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  152. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  153. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  154. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  155. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  156. package/dist/providers/spec/adapter.d.ts +91 -0
  157. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  158. package/dist/providers/spec/evaluator.d.ts +45 -0
  159. package/dist/providers/spec/fsm-driver.d.ts +397 -0
  160. package/dist/providers/spec/fsm-evaluator.d.ts +76 -0
  161. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  162. package/dist/providers/spec/fsm-types.d.ts +174 -0
  163. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  164. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  165. package/dist/providers/spec/route.d.ts +4 -0
  166. package/dist/providers/spec/types.d.ts +222 -0
  167. package/dist/providers/status-monitor.d.ts +7 -7
  168. package/dist/providers/transcript-v2.d.ts +176 -0
  169. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  170. package/dist/providers/working-dir.d.ts +17 -0
  171. package/dist/repo-mesh-types.d.ts +376 -13
  172. package/dist/runtime-defaults.d.ts +2 -0
  173. package/dist/sessions/registry.d.ts +6 -0
  174. package/dist/shared-types-extra.d.ts +2 -4
  175. package/dist/shared-types.d.ts +59 -2
  176. package/dist/status/normalize.d.ts +1 -1
  177. package/dist/status/normalize.js +1 -0
  178. package/dist/status/normalize.js.map +1 -1
  179. package/dist/status/normalize.mjs +1 -0
  180. package/dist/status/normalize.mjs.map +1 -1
  181. package/dist/status/reporter.d.ts +2 -0
  182. package/dist/status/snapshot.d.ts +26 -0
  183. package/dist/system/hash.d.ts +8 -0
  184. package/dist/system/load-better-sqlite3.d.ts +21 -0
  185. package/dist/types.d.ts +5 -0
  186. package/package.json +7 -3
  187. package/src/agent-stream/poller.ts +2 -3
  188. package/src/agent-stream/provider-adapter.ts +1 -1
  189. package/src/boot/daemon-lifecycle.ts +63 -12
  190. package/src/boot/process-hardening.ts +89 -0
  191. package/src/build-info.ts +73 -0
  192. package/src/chat/source-machine.ts +534 -0
  193. package/src/chat/source-resolver.ts +0 -0
  194. package/src/chat/subscription-updates.ts +20 -1
  195. package/src/cli-adapter-types.d.ts +1 -0
  196. package/src/cli-adapter-types.ts +56 -2
  197. package/src/cli-adapters/cli-script-runner.ts +421 -0
  198. package/src/cli-adapters/cli-state-engine.ts +1221 -0
  199. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  200. package/src/cli-adapters/provider-cli-adapter.ts +836 -1141
  201. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  202. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  203. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  204. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  205. package/src/cli-adapters/provider-cli-shared.ts +110 -11
  206. package/src/cli-adapters/pty-transport.ts +2 -1
  207. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  208. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  209. package/src/cli-adapters/resolve-executable.ts +90 -0
  210. package/src/cli-adapters/session-host-transport.ts +2 -1
  211. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  212. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  213. package/src/cli-adapters/terminal-screen.ts +16 -81
  214. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +198 -0
  215. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  216. package/src/commands/chat-commands-read.ts +2327 -0
  217. package/src/commands/chat-commands-scope.ts +54 -0
  218. package/src/commands/chat-commands-shared.ts +114 -0
  219. package/src/commands/chat-commands-write.ts +880 -0
  220. package/src/commands/chat-commands.ts +19 -1841
  221. package/src/commands/cli-manager.ts +513 -26
  222. package/src/commands/handler.ts +841 -2
  223. package/src/commands/high-family/index.ts +28 -0
  224. package/src/commands/high-family/mesh-coordinator-launch.ts +657 -0
  225. package/src/commands/high-family/mesh-events.ts +47 -0
  226. package/src/commands/high-family/mesh-status.ts +642 -0
  227. package/src/commands/high-family/types.ts +76 -0
  228. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  229. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  230. package/src/commands/low-family/diagnostics.ts +57 -0
  231. package/src/commands/low-family/index.ts +37 -0
  232. package/src/commands/low-family/mesh-ledger.ts +62 -0
  233. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  234. package/src/commands/low-family/notification.ts +116 -0
  235. package/src/commands/low-family/refine-config.ts +106 -0
  236. package/src/commands/low-family/session-host.ts +274 -0
  237. package/src/commands/low-family/spec-providerdev.ts +217 -0
  238. package/src/commands/low-family/status-meta.ts +112 -0
  239. package/src/commands/low-family/types.ts +39 -0
  240. package/src/commands/med-family/cli-agent.ts +230 -0
  241. package/src/commands/med-family/fast-forward.ts +230 -0
  242. package/src/commands/med-family/ide.ts +163 -0
  243. package/src/commands/med-family/index.ts +37 -0
  244. package/src/commands/med-family/mesh-crud.ts +825 -0
  245. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  246. package/src/commands/med-family/mesh-queue.ts +131 -0
  247. package/src/commands/med-family/mesh-restart.ts +92 -0
  248. package/src/commands/med-family/types.ts +120 -0
  249. package/src/commands/mesh-coordinator.ts +334 -124
  250. package/src/commands/router.ts +2395 -3206
  251. package/src/commands/stream-commands.ts +8 -0
  252. package/src/commands/upgrade-helper.ts +310 -45
  253. package/src/config/chat-history.ts +483 -24
  254. package/src/config/config.ts +12 -0
  255. package/src/config/mesh-config.ts +353 -22
  256. package/src/config/recent-activity.ts +8 -2
  257. package/src/daemon/dev-auto-implement.ts +3 -2
  258. package/src/daemon/dev-cli-debug.ts +10 -1
  259. package/src/daemon/dev-server.ts +0 -541
  260. package/src/detection/cli-detector.ts +28 -9
  261. package/src/detection/ide-detector.ts +55 -16
  262. package/src/detection/win32-ide-version.ts +106 -0
  263. package/src/git/change-impact-config.ts +354 -0
  264. package/src/git/git-commands.ts +55 -14
  265. package/src/git/git-diff.ts +81 -11
  266. package/src/git/git-executor.ts +12 -0
  267. package/src/git/git-status.ts +574 -43
  268. package/src/git/git-types.ts +14 -62
  269. package/src/git/git-worktree.ts +8 -1
  270. package/src/git/index.ts +16 -0
  271. package/src/index.ts +171 -12
  272. package/src/installer.d.ts +1 -1
  273. package/src/installer.ts +8 -6
  274. package/src/ipc/local-ipc-server.ts +278 -0
  275. package/src/launch.d.ts +1 -1
  276. package/src/launch.ts +37 -28
  277. package/src/logging/async-batch-writer.ts +55 -0
  278. package/src/logging/command-log.ts +7 -5
  279. package/src/logging/log-redactor.ts +100 -0
  280. package/src/logging/log-tail-reader.ts +341 -0
  281. package/src/logging/logger.ts +14 -7
  282. package/src/mesh/contracts.ts +338 -0
  283. package/src/mesh/coordinator-prompt.ts +381 -33
  284. package/src/mesh/coordinator-registry.ts +121 -0
  285. package/src/mesh/mesh-active-work.ts +645 -0
  286. package/src/mesh/mesh-coordinator-config.ts +97 -0
  287. package/src/mesh/mesh-delivery-policy.ts +315 -0
  288. package/src/mesh/mesh-event-classify.ts +51 -0
  289. package/src/mesh/mesh-event-forwarding.ts +1575 -0
  290. package/src/mesh/mesh-event-trace.ts +67 -0
  291. package/src/mesh/mesh-events-coordinator.ts +30 -0
  292. package/src/mesh/mesh-events-pending.ts +590 -0
  293. package/src/mesh/mesh-events-stale.ts +363 -0
  294. package/src/mesh/mesh-events-utils.ts +425 -0
  295. package/src/mesh/mesh-events.ts +30 -1035
  296. package/src/mesh/mesh-fast-forward.ts +839 -0
  297. package/src/mesh/mesh-host-ownership.ts +73 -0
  298. package/src/mesh/mesh-init.ts +260 -0
  299. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  300. package/src/mesh/mesh-ledger.ts +596 -102
  301. package/src/mesh/mesh-missions.ts +322 -0
  302. package/src/mesh/mesh-node-identity.ts +1887 -0
  303. package/src/mesh/mesh-queue-assignment.ts +1491 -0
  304. package/src/mesh/mesh-reconcile-loop.ts +1332 -0
  305. package/src/mesh/mesh-refine-batch.ts +197 -0
  306. package/src/mesh/mesh-refine-gates.ts +1652 -0
  307. package/src/mesh/mesh-refine-status.ts +231 -0
  308. package/src/mesh/mesh-review-inbox.ts +307 -0
  309. package/src/mesh/mesh-routing.ts +291 -0
  310. package/src/mesh/mesh-runtime-store.ts +1708 -0
  311. package/src/mesh/mesh-scheduling-runtime.ts +199 -0
  312. package/src/mesh/mesh-task-stats.ts +161 -0
  313. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  314. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  315. package/src/mesh/mesh-work-queue.ts +997 -141
  316. package/src/mesh/preview-freshness.ts +118 -0
  317. package/src/mesh/refine-config.ts +423 -0
  318. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  319. package/src/providers/acp-provider-instance.ts +43 -10
  320. package/src/providers/approval-utils.d.ts +5 -0
  321. package/src/providers/approval-utils.ts +57 -5
  322. package/src/providers/chat-message-normalization.ts +46 -6
  323. package/src/providers/cli-provider-instance.ts +1416 -101
  324. package/src/providers/contracts.d.ts +55 -0
  325. package/src/providers/contracts.ts +150 -6
  326. package/src/providers/extension-provider-instance.ts +12 -7
  327. package/src/providers/external-sources.ts +218 -0
  328. package/src/providers/ide-provider-instance.ts +35 -12
  329. package/src/providers/manual-attendance.ts +85 -0
  330. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  331. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  332. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  333. package/src/providers/native-history/constants.ts +19 -0
  334. package/src/providers/native-history/dispatcher.ts +339 -0
  335. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  336. package/src/providers/native-history/index.ts +30 -0
  337. package/src/providers/provider-instance-manager.ts +30 -0
  338. package/src/providers/provider-instance.ts +19 -1
  339. package/src/providers/provider-loader.ts +668 -50
  340. package/src/providers/provider-schema.ts +87 -14
  341. package/src/providers/provider-trust.ts +114 -0
  342. package/src/providers/read-chat-contract.ts +76 -16
  343. package/src/providers/sdk/README.md +49 -0
  344. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  345. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  346. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  347. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  348. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  349. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  350. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  351. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  352. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  353. package/src/providers/sdk/v1/index.ts +152 -0
  354. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  355. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  356. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  357. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  358. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  359. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  360. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  361. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  362. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  363. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  364. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  365. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  366. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  367. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  368. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  369. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  370. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  371. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  372. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  373. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  374. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  375. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  376. package/src/providers/sdk/v1/validators/index.ts +19 -0
  377. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  378. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  379. package/src/providers/spec/adapter.ts +235 -0
  380. package/src/providers/spec/cli-adapter.ts +1051 -0
  381. package/src/providers/spec/evaluator.ts +407 -0
  382. package/src/providers/spec/fsm-driver.ts +1292 -0
  383. package/src/providers/spec/fsm-evaluator.ts +272 -0
  384. package/src/providers/spec/fsm-loader.ts +120 -0
  385. package/src/providers/spec/fsm-types.ts +273 -0
  386. package/src/providers/spec/native-history-executor.ts +1128 -0
  387. package/src/providers/spec/pre-launch-trust.ts +104 -0
  388. package/src/providers/spec/route.ts +51 -0
  389. package/src/providers/spec/types.ts +262 -0
  390. package/src/providers/status-monitor.d.ts +7 -7
  391. package/src/providers/status-monitor.ts +37 -22
  392. package/src/providers/transcript-v2.ts +567 -0
  393. package/src/providers/types/interactive-prompt.ts +536 -0
  394. package/src/providers/version-archive.ts +64 -24
  395. package/src/providers/working-dir.ts +23 -0
  396. package/src/repo-mesh-types.ts +542 -14
  397. package/src/runtime-defaults.ts +39 -0
  398. package/src/sessions/registry.ts +6 -0
  399. package/src/shared-types-extra.ts +2 -4
  400. package/src/shared-types.d.ts +8 -0
  401. package/src/shared-types.ts +64 -1
  402. package/src/status/builders.ts +26 -6
  403. package/src/status/normalize.ts +2 -0
  404. package/src/status/reporter.ts +19 -1
  405. package/src/status/snapshot.ts +95 -26
  406. package/src/system/hash.ts +23 -0
  407. package/src/system/host-memory.ts +29 -12
  408. package/src/system/load-better-sqlite3.ts +68 -0
  409. package/src/types.ts +5 -0
  410. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  411. package/dist/mesh/mesh-sync.d.ts +0 -53
  412. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  413. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  414. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,1491 @@
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 } 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 } 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 } from '../repo-mesh-types.js';
17
+ import type { RepoMeshSchedulingStrategy } from '../repo-mesh-types.js';
18
+ import { normalizeMeshNodeId, meshNodeIdMatches, daemonIdsEquivalent, normalizeMeshWorkspaceForCompare, meshWorkspacesEquivalent, type MeshNodeIdentified } from '@adhdev/mesh-shared';
19
+ import { findTerminalLedgerEvidenceForTask, hasUnterminalDirectDispatchLedgerEntry } from './mesh-events-stale.js';
20
+ import { readNonEmptyString } from './mesh-events-utils.js';
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Idle auto fast-forward throttle state
24
+ // ---------------------------------------------------------------------------
25
+ const IDLE_AUTO_FAST_FORWARD_THROTTLE_MS = 30 * 60 * 1000;
26
+ const idleAutoFastForwardLastAttempt = new Map<string, number>();
27
+
28
+ export function __resetIdleAutoFastForwardForTests(): void {
29
+ idleAutoFastForwardLastAttempt.clear();
30
+ }
31
+
32
+ export function getMeshWithCache(components: DaemonComponents, meshId: string): any | undefined {
33
+ const localMesh = getMesh(meshId);
34
+ const cachedMesh = components.router?.getCachedInlineMesh(meshId);
35
+ if (!localMesh) return cachedMesh;
36
+ if (!cachedMesh) return localMesh;
37
+ return mergeInlineCacheOnlyNodes(localMesh, cachedMesh);
38
+ }
39
+
40
+ /**
41
+ * Claim-time membership view unification (CLAIMSTALL fix).
42
+ *
43
+ * The coordinator's claim path — triggerMeshQueue → autoLaunch candidate filter
44
+ * and the local/remote idle-session drain — reads mesh membership through
45
+ * getMeshWithCache, which historically returned the local-config mesh verbatim
46
+ * whenever one existed. A freshly cloned worktree node is registered ONLY into the
47
+ * router's inline mesh cache: clone_mesh_node's `meshRecord.inline` branch calls
48
+ * updateInlineMeshNode, NOT addNode, so the worktree node never reaches local
49
+ * config (meshes.json). The config-first view therefore omits the worktree node,
50
+ * while send_task — which resolves membership through getMeshForCommand(preferInline)
51
+ * over the same inline cache — sees it. That view asymmetry is the stall: a queue
52
+ * task pinned to the worktree node reports `target_node_id_unmatched` (autoLaunch
53
+ * candidate filter / targetPinUnmatched check) and the node's idle session is
54
+ * dropped from the drain pool (mesh.nodes.find miss), so claim never fires and the
55
+ * task is stranded pending — even though nodeId matching itself is correct.
56
+ *
57
+ * Fix: union the local-config nodes with any inline-cache-ONLY nodes, so the claim
58
+ * view matches the command (send_task) view. Base (non-worktree) nodes present in
59
+ * local config stay config-authoritative — their entry is taken verbatim from
60
+ * localMesh, so base node claim/matching is byte-for-byte unchanged. Only nodes
61
+ * that exist solely in the inline cache (the cloned worktree nodes) are appended.
62
+ * Identity comparison uses the shared 3-form normalizer (id / nodeId / node_id),
63
+ * identical to every other claim-path consumer — the matching logic is untouched,
64
+ * only which nodes are visible.
65
+ */
66
+ function mergeInlineCacheOnlyNodes(localMesh: any, cachedMesh: any): any {
67
+ const localNodes = Array.isArray(localMesh?.nodes) ? localMesh.nodes : [];
68
+ const cachedNodes = Array.isArray(cachedMesh?.nodes) ? cachedMesh.nodes : [];
69
+ if (!cachedNodes.length) return localMesh;
70
+ const cacheOnly = cachedNodes.filter((cachedNode: any) => {
71
+ const cachedId = readMeshNodeId(cachedNode);
72
+ // Unidentifiable cache entries can never be a claim/route target — skip them
73
+ // rather than appending junk that no consumer can address.
74
+ if (!cachedId) return false;
75
+ return !localNodes.some((localNode: any) => meshNodeIdMatches(localNode, cachedId));
76
+ });
77
+ if (!cacheOnly.length) return localMesh;
78
+ return { ...localMesh, nodes: [...localNodes, ...cacheOnly] };
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Queue assignment
83
+ // ---------------------------------------------------------------------------
84
+
85
+ // Per-dispatch confirmation timeout (Bug B). A dispatch promise that never settles —
86
+ // a saturated remote P2P relay that hangs, or a transport that resolves only after
87
+ // the worker acks — would otherwise leave the just-claimed queue row 'assigned' with
88
+ // its delivery stuck 'delivering' forever: the .catch that requeues never fires, and
89
+ // PHASE 3 reconcile skips the row (it counts 0 pending). Racing the dispatch against
90
+ // this timeout guarantees a hung dispatch deterministically returns the task to
91
+ // 'pending' for re-dispatch. Generous so a merely-slow-but-live dispatch (a cold
92
+ // remote relay) is never reclaimed early; the reconcile assigned-stranded watchdog is
93
+ // the durable cross-restart backstop for a timer lost to a daemon restart.
94
+ const DISPATCH_CONFIRM_TIMEOUT_MS = 120_000;
95
+
96
+ // Cold-open connect budget for the warmup-aware REMOTE task dispatch deadline. A
97
+ // remote `agent_command` to a peer whose mesh DataChannel is not open yet first has
98
+ // to drive the cross-machine (often TURN-relayed) handshake; charging that warmup
99
+ // against the response budget is the same cold-open false-timeout the git_status
100
+ // probe path already guards against. This budget bounds ONLY the "channel not open
101
+ // yet" phase; once the channel is warm the DISPATCH_CONFIRM_TIMEOUT_MS response
102
+ // budget governs (identical to the legacy flat guard for an already-open peer, so
103
+ // no latency is added to a normal dispatch). Matches the daemon-cloud
104
+ // DaemonMeshManager CONNECT_TIMEOUT_MS (45s) so the caller-side deadline tracks the
105
+ // transport's own cold-open window rather than guessing.
106
+ //
107
+ // Sourced from the unified, env-overridable MESH_CONNECT_TIMEOUT_MS (runtime-defaults)
108
+ // — the SAME budget the router's direct-peer git_status probe uses. Previously this
109
+ // was a hard-coded 45_000 while the probe path was env-overridable, so setting the
110
+ // env tuned the probe but silently left this dispatch path at 45s (a silent
111
+ // asymmetry). They now move together.
112
+ const DISPATCH_CONNECT_TIMEOUT_MS = MESH_CONNECT_TIMEOUT_MS;
113
+
114
+ // Fail-loud (throttled) trace for a remote dispatch that ran with NO live mesh
115
+ // connection getter wired — the same degraded-warmup misconfiguration the git probe
116
+ // path warns about. Warn once per peer; resolveWarmupDeadlineOpts then falls back to
117
+ // the conservative combined budget instead of silently assuming "always warm".
118
+ const dispatchWarmupGetterMissingWarned = new Set<string>();
119
+ function warnDispatchWarmupGetterMissingOnce(daemonId: string): void {
120
+ if (dispatchWarmupGetterMissingWarned.has(daemonId)) return;
121
+ dispatchWarmupGetterMissingWarned.add(daemonId);
122
+ 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.`);
123
+ }
124
+
125
+ interface DeliverTaskContext {
126
+ meshId: string;
127
+ nodeId: string;
128
+ sessionId: string;
129
+ providerType: string;
130
+ task: MeshWorkQueueEntry;
131
+ transport: 'remote' | 'local';
132
+ sourceCoordinatorSessionId?: string;
133
+ sourceCoordinatorDaemonId?: string;
134
+ }
135
+
136
+ // Readiness barrier for the LOCAL auto-launch path. A just-spawned CLI session is
137
+ // not interactive until its PTY prints the input prompt (the adapter flips
138
+ // isReady() / settles to idle ~2-6s later). Poll the local adapter until it reports
139
+ // ready (or idle), bounded by a generous timeout so a slow/contended boot still
140
+ // lands, and a hard cap so a session that never becomes interactive doesn't block the
141
+ // reconcile loop forever (the adapter's queue-until-ready path is the backstop then).
142
+ const LOCAL_LAUNCH_READY_TIMEOUT_MS = 15_000;
143
+ const LOCAL_LAUNCH_READY_POLL_MS = 100;
144
+
145
+ async function waitForLocalSessionReady(components: DaemonComponents, sessionId: string): Promise<void> {
146
+ const adapter = components.cliManager?.adapters?.get(sessionId) as
147
+ | { isReady?: () => boolean; currentStatus?: string }
148
+ | undefined;
149
+ // No locally-resolvable adapter (e.g. a remote/forwarded session that somehow
150
+ // reached this branch) → nothing to wait on; let dispatch proceed.
151
+ if (!adapter || typeof adapter.isReady !== 'function') return;
152
+ const deadline = Date.now() + LOCAL_LAUNCH_READY_TIMEOUT_MS;
153
+ while (Date.now() < deadline) {
154
+ if (adapter.isReady() || adapter.currentStatus === 'idle') return;
155
+ await new Promise<void>(resolve => setTimeout(resolve, LOCAL_LAUNCH_READY_POLL_MS));
156
+ }
157
+ LOG.warn('MeshQueue', `Auto-launched session ${sessionId} not interactive after ${LOCAL_LAUNCH_READY_TIMEOUT_MS}ms; dispatching anyway (adapter queue-until-ready will buffer)`);
158
+ }
159
+
160
+ // CONS scope 3: the SINGLE source of truth for dispatching a claimed task to its
161
+ // session. The remote (P2P dispatchMeshCommand) and local (cliManager.handleCliCommand)
162
+ // branches differ ONLY in the transport call — the delivery record, the delivered/failed
163
+ // transitions, the pending-requeue-on-failure, the dispatch_failed ledger entry, AND the
164
+ // Bug B hang timeout are identical and live here once so a future change to the dispatch
165
+ // lifecycle cannot drift between the two paths. The caller passes a `dispatchThunk` that
166
+ // performs only the transport-specific send and returns its promise.
167
+ //
168
+ // Cold-open warmup (remote only): the REMOTE transport speaks over a P2P
169
+ // DataChannel that may still be opening when the first task is dispatched to a peer.
170
+ // When `warmup` is supplied the dispatch is awaited under the warmup-aware deadline
171
+ // (mesh-warmup-deadline) — the cold-open handshake is charged to the connect budget
172
+ // and only the warm round trip to the DISPATCH_CONFIRM_TIMEOUT_MS response budget —
173
+ // so the very first dispatch to a not-yet-open peer is no longer false-timed at the
174
+ // combined window. An already-open peer behaves identically to the legacy flat guard
175
+ // (response budget governs from t0), so a normal dispatch sees no added latency. The
176
+ // LOCAL transport (in-process cliManager) has no channel to warm up and keeps the
177
+ // flat Bug B hang guard.
178
+ function deliverTaskToSession(
179
+ dispatchThunk: () => Promise<unknown>,
180
+ ctx: DeliverTaskContext,
181
+ warmup?: { daemonId: string; getConnection?: (daemonId: string) => Record<string, unknown> | null },
182
+ ): void {
183
+ const delivery = createSessionDelivery({
184
+ meshId: ctx.meshId,
185
+ nodeId: ctx.nodeId,
186
+ sessionId: ctx.sessionId,
187
+ providerType: ctx.providerType,
188
+ taskId: ctx.task.id,
189
+ kind: 'task',
190
+ message: ctx.task.message,
191
+ status: 'delivering',
192
+ ...(ctx.sourceCoordinatorSessionId ? { sourceCoordinatorSessionId: ctx.sourceCoordinatorSessionId } : {}),
193
+ ...(ctx.sourceCoordinatorDaemonId ? { sourceCoordinatorDaemonId: ctx.sourceCoordinatorDaemonId } : {}),
194
+ });
195
+
196
+ // Invoke the transport synchronously (preserves the prior fire-and-forget timing,
197
+ // and lets a synchronous throw fall into the same failure path as a rejection).
198
+ let dispatchPromise: Promise<unknown>;
199
+ try {
200
+ dispatchPromise = Promise.resolve(dispatchThunk());
201
+ } catch (e) {
202
+ dispatchPromise = Promise.reject(e);
203
+ }
204
+
205
+ let timer: ReturnType<typeof setTimeout> | undefined;
206
+ let guarded: Promise<unknown>;
207
+ if (warmup) {
208
+ // Remote P2P: cold-open-aware deadline. awaitWithWarmupDeadline owns its own
209
+ // timers (so `timer` stays undefined and the clearTimeout below is a no-op),
210
+ // and rejects with Error('timeout') when either budget lapses — the same
211
+ // retryable failure shape the catch below already handles (requeue + ledger).
212
+ guarded = awaitWithWarmupDeadline(dispatchPromise, resolveWarmupDeadlineOpts({
213
+ getConnection: warmup.getConnection,
214
+ daemonId: warmup.daemonId,
215
+ connectTimeoutMs: DISPATCH_CONNECT_TIMEOUT_MS,
216
+ responseTimeoutMs: DISPATCH_CONFIRM_TIMEOUT_MS,
217
+ onMissingGetter: warnDispatchWarmupGetterMissingOnce,
218
+ }));
219
+ } else {
220
+ guarded = Promise.race([
221
+ dispatchPromise,
222
+ new Promise<never>((_, reject) => {
223
+ timer = setTimeout(
224
+ () => reject(new Error(`dispatch_confirm_timeout after ${DISPATCH_CONFIRM_TIMEOUT_MS}ms`)),
225
+ DISPATCH_CONFIRM_TIMEOUT_MS,
226
+ );
227
+ // Never keep the process alive solely for this confirm-timeout timer.
228
+ if (typeof (timer as { unref?: () => void })?.unref === 'function') (timer as { unref: () => void }).unref();
229
+ }),
230
+ ]);
231
+ }
232
+
233
+ guarded.then(() => {
234
+ if (timer) clearTimeout(timer);
235
+ updateSessionDeliveryStatus(delivery.id, 'delivered');
236
+ }).catch((e: any) => {
237
+ if (timer) clearTimeout(timer);
238
+ // A dispatch failure (transport reject OR hang timeout) is most often transient —
239
+ // a busy/refusing adapter, or a relay that never acked — not a permanent task
240
+ // failure. Marking the task terminal here would permanently kill tasks a later
241
+ // tick delivers fine. Return it to 'pending' and record a retryable dispatch_failed
242
+ // ledger entry so the reconcile loop re-dispatches it. Identical for both transports.
243
+ LOG.error('MeshQueue', `Failed to dispatch task via ${ctx.transport} to node ${ctx.nodeId}: ${e?.message}`);
244
+ updateSessionDeliveryStatus(delivery.id, 'failed', { lastError: e?.message, incrementAttempt: true });
245
+ updateTaskStatus(ctx.meshId, ctx.task.id, 'pending');
246
+ try {
247
+ appendLedgerEntry(ctx.meshId, {
248
+ kind: 'dispatch_failed' as any,
249
+ nodeId: ctx.nodeId,
250
+ sessionId: ctx.sessionId,
251
+ payload: { taskId: ctx.task.id, deliveryId: delivery.id, error: e?.message, retryable: true, transport: ctx.transport },
252
+ });
253
+ } catch { /* ledger write is best-effort */ }
254
+ });
255
+ }
256
+
257
+ // WTCLAIM: workspace normalization for base-vs-worktree comparison now lives in
258
+ // @adhdev/mesh-shared (normalizeMeshWorkspaceForCompare) so the enqueue→claim path,
259
+ // the mesh_status per-node session filter, and the read_chat node scope guard all
260
+ // share one comparison rule instead of drifting module-private copies.
261
+
262
+ export function tryAssignQueueTask(
263
+ components: DaemonComponents,
264
+ meshId: string,
265
+ nodeId: string,
266
+ sessionId: string,
267
+ providerType: string
268
+ ): boolean {
269
+ const mesh = getMeshWithCache(components, meshId);
270
+ const node = mesh?.nodes.find((n: any) => readMeshNodeId(n) === nodeId);
271
+
272
+ // WTCLAIM (fix-B extended to the enqueue→claim path): a base-targeted task must never be
273
+ // claimed by — and dispatched into — a co-located worktree-clone session, nor vice versa.
274
+ // The drain candidate's nodeId is derived from settings.meshNodeId || settings.nodeId
275
+ // (triggerMeshQueue), so a worktree session whose meshNodeId is empty/stale falls back to
276
+ // settings.nodeId = the BASE node id and impersonates the base node here. fix-B's worker-side
277
+ // workspace scope only ran for sessionless dispatch (meshScopeNodeId && !targetSessionId); the
278
+ // claim path ALWAYS carries a targetSessionId, so it never engaged. Apply the same scope here:
279
+ // for a LOCAL claiming session (adapter resolvable on this daemon), require its actual
280
+ // workingDir to match the target node's declared workspace. On a confirmed mismatch, refuse the
281
+ // claim so the task returns to pending for the correctly-scoped session/node to pull. Scoped to
282
+ // local sessions where the workspace is verifiable — a remote session lives on another daemon
283
+ // whose paths we cannot compare here (and remote candidates are already nodeId-matched from
284
+ // getRemoteIdleSessions). Conservative by design: when either workspace is unknown we do NOT
285
+ // skip, so a node with no declared workspace keeps its prior behavior and no legitimate claim
286
+ // is starved.
287
+ // WTDISPATCH (residual of WTCLAIM): the cross-node claim guard must reach EVERY claiming
288
+ // session this daemon can observe — not only those whose adapter happens to be in
289
+ // cliManager.adapters. An auto-launched worker session can carry its node binding on the
290
+ // CLI-instance settings while its session-host record shows no_node_binding, and the
291
+ // event-driven / remote-idle drain (agent:ready → setRemoteIdleSession → tryAssignQueueTask)
292
+ // can pass a nodeId that does NOT belong to the claiming session — a sibling worktree node
293
+ // on the SAME daemon. The adapter-only WTCLAIM check (rc.361/4c5b30b1) never engaged for a
294
+ // session observed solely via instanceManager, so session A could pull node B's task and
295
+ // node A's task was left with no session to claim it (no task_dispatched — it never dispatches).
296
+ //
297
+ // Resolve the claiming session's REAL identity from the adapter workingDir, then fall back to
298
+ // the live CLI instance's workspace + its stamped meshNodeId, and refuse a claim that
299
+ // contradicts EITHER (fail-closed). Reuses the shared meshWorkspacesEquivalent / meshNodeIdMatches
300
+ // comparators — no new comparison logic. Conservative: when neither the workspace NOR the stamp
301
+ // is resolvable we do NOT refuse, so a node with no declared workspace keeps prior behavior and
302
+ // a genuinely remote (cross-daemon) candidate stays nodeId-matched from getRemoteIdleSessions.
303
+ const localClaimAdapter = components.cliManager?.adapters?.get(sessionId) as { workingDir?: string } | undefined;
304
+ let claimInstanceWorkspace = '';
305
+ let claimStampedNodeId = '';
306
+ try {
307
+ const claimState = components.instanceManager?.getInstance?.(sessionId)?.getState?.();
308
+ claimInstanceWorkspace = readNonEmptyString(claimState?.workspace);
309
+ const claimSettings = (claimState?.settings as Record<string, unknown>) || {};
310
+ claimStampedNodeId = readNonEmptyString(claimSettings.meshNodeId);
311
+ } catch { /* best-effort — fall through to the conservative (no refuse) path */ }
312
+
313
+ const nodeWorkspaceRaw = readNonEmptyString(node?.workspace);
314
+ const sessionWorkspaceRaw = readNonEmptyString(localClaimAdapter?.workingDir) || claimInstanceWorkspace;
315
+
316
+ if (claimStampedNodeId && nodeId) {
317
+ // The session carries its OWN meshNodeId stamp — its authoritative node identity, set when
318
+ // the coordinator launched/dispatched it (mesh-routing trusts this stamp FIRST). When it
319
+ // matches the claim target the session genuinely belongs to this node, so the stamp settles
320
+ // it and the workspace heuristic is skipped (a base/worktree pair can legitimately share a
321
+ // workspace). When it does NOT match, the claim is a cross-node leak — refuse, fail-closed.
322
+ if (!meshNodeIdMatches({ id: claimStampedNodeId } as MeshNodeIdentified, nodeId)) {
323
+ LOG.info('MeshQueue', `WTDISPATCH: refusing claim for node ${nodeId} (${sessionId}) — session is bound to node "${claimStampedNodeId}" (cross-node claim blocked)`);
324
+ return false;
325
+ }
326
+ } else if (sessionWorkspaceRaw && nodeWorkspaceRaw && !meshWorkspacesEquivalent(sessionWorkspaceRaw, nodeWorkspaceRaw)) {
327
+ // No stamp (the no_node_binding worker) — fall back to the workspace to tell two co-located
328
+ // sibling worktree sessions apart. WTCLAIM, now reaching instanceManager-observable sessions
329
+ // too. Conservative: unknown workspace on either side → do NOT refuse (no legitimate claim
330
+ // starved; a genuinely remote cross-daemon candidate stays nodeId-matched as before).
331
+ LOG.info('MeshQueue', `WTCLAIM: refusing claim for node ${nodeId} (${sessionId}) — session workspace "${normalizeMeshWorkspaceForCompare(sessionWorkspaceRaw)}" ≠ node workspace "${normalizeMeshWorkspaceForCompare(nodeWorkspaceRaw)}" (cross-workspace dispatch blocked)`);
332
+ return false;
333
+ }
334
+
335
+ const capabilityTags = buildMeshNodeCapabilityTags(node, providerType);
336
+ // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy.providerRoles) layers
337
+ // on top of the global/taskMode caps — stricter wins. Resolved here where the
338
+ // claiming session's providerType + node policy are both known, then enforced
339
+ // inside the atomic claim transaction so concurrent claims can't overshoot it.
340
+ const providerMaxParallel = resolveProviderMaxParallel(node?.policy, providerType);
341
+ // WTDISPATCH-FANOUT: tell the atomic claim whether the claiming node is a worktree
342
+ // clone so a `convergence` task (base-only: merge → push → cleanup) is refused for
343
+ // worktree sessions. Without it, every sibling worktree session on this daemon could
344
+ // claim the same convergence intent and race push/production-deploy (the 4-way fan-out).
345
+ const nodeIsWorktree = node?.isLocalWorktree === true;
346
+ const task = claimNextTask(meshId, nodeId, sessionId, capabilityTags, {
347
+ providerType,
348
+ ...(providerMaxParallel !== undefined ? { providerMaxParallel } : {}),
349
+ nodeIsWorktree,
350
+ });
351
+ if (!task) {
352
+ return false;
353
+ }
354
+
355
+ const terminal = findTerminalLedgerEvidenceForTask({
356
+ meshId,
357
+ taskId: task.id,
358
+ });
359
+ if (terminal) {
360
+ const status = terminal.kind === 'task_completed' ? 'completed' : 'failed';
361
+ updateTaskStatus(meshId, task.id, status);
362
+ LOG.info('MeshQueue', `Skipped dispatch for terminal task ${task.id} on mesh ${meshId}; ${terminal.kind} ledger evidence already exists`);
363
+ traceMeshEventDrop('dispatch_terminal_ledger', {
364
+ taskId: task.id,
365
+ sessionId,
366
+ nodeId,
367
+ meshId,
368
+ event: 'agent_command',
369
+ }, terminal.kind);
370
+ return false;
371
+ }
372
+
373
+ LOG.info('MeshQueue', `Node ${nodeId} (${sessionId}) pulled task ${task.id}`);
374
+
375
+ if (node?.daemonId && components.dispatchMeshCommand) {
376
+ const isLocalNode = components.cliManager.adapters.has(sessionId);
377
+ if (!isLocalNode) {
378
+ const localDaemonIdForDispatch = readNonEmptyString(loadConfig().machineId) || undefined;
379
+ // (3) Originating coordinator session that enqueued this task — route its
380
+ // completion back to that exact session (multi-coordinator). Carried over P2P
381
+ // to the remote worker, which echoes it on its completion event.
382
+ const sourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId) || undefined;
383
+ const dispatchMeshCommand = components.dispatchMeshCommand;
384
+ const remoteDaemonId = node.daemonId;
385
+ // CONS3: only the transport call differs — everything else (delivery record,
386
+ // status transitions, requeue-on-failure, ledger, Bug B hang timeout) is in
387
+ // the shared deliverTaskToSession helper.
388
+ deliverTaskToSession(
389
+ () => dispatchMeshCommand(remoteDaemonId, 'agent_command', {
390
+ targetSessionId: sessionId,
391
+ cliType: providerType,
392
+ action: 'send_chat',
393
+ message: task.message,
394
+ meshContext: {
395
+ meshId,
396
+ nodeId,
397
+ taskId: task.id,
398
+ ...(localDaemonIdForDispatch ? { coordinatorDaemonId: localDaemonIdForDispatch } : {}),
399
+ ...(sourceCoordinatorSessionId ? { coordinatorSessionId: sourceCoordinatorSessionId } : {}),
400
+ },
401
+ }),
402
+ {
403
+ meshId,
404
+ nodeId,
405
+ sessionId,
406
+ providerType,
407
+ task,
408
+ transport: 'remote',
409
+ ...(sourceCoordinatorSessionId ? { sourceCoordinatorSessionId } : {}),
410
+ ...(localDaemonIdForDispatch ? { sourceCoordinatorDaemonId: localDaemonIdForDispatch } : {}),
411
+ },
412
+ // Warmup-aware deadline: this dispatch can be the FIRST command to a
413
+ // peer whose mesh DataChannel is still opening — charge the cold-open
414
+ // handshake to the connect budget, not the response budget.
415
+ { daemonId: remoteDaemonId, getConnection: components.getMeshPeerConnectionStatus },
416
+ );
417
+ return true;
418
+ }
419
+ }
420
+
421
+ // Stamp mesh context onto the session so completion events route correctly
422
+ // via setupMeshEventForwarding. Without this, manually-opened idle sessions
423
+ // (mesh_launch_session without auto-launch) lack meshNodeFor/meshNodeId and
424
+ // agent:generating_completed is silently dropped as isMeshDelegate=false.
425
+ try {
426
+ const inst = components.instanceManager.getInstance(sessionId);
427
+ if (inst && typeof inst.updateSettings === 'function') {
428
+ // Adopting a (possibly manually-opened) local session as a worker: apply the
429
+ // delegated-worker auto-approve policy here too, so a session that was launched
430
+ // without autoApprove still auto-approves once the coordinator dispatches a task
431
+ // to it (the "approval notification fires only for certain delegated sessions"
432
+ // case). updateSettings preserves runtime mesh keys; passing autoApprove keeps it.
433
+ //
434
+ // This local-dispatch branch also runs on the coordinator daemon for a co-located
435
+ // session, so the coordinator daemon id IS this daemon's id. Stamp it alongside
436
+ // the node identity so the session is fully relay-safe (meshCoordinatorDaemonId is
437
+ // the anchor the forwarder keys on), matching what mesh_launch_session stamps.
438
+ const localDaemonId = readNonEmptyString(loadConfig().machineId);
439
+ const localSourceCoordinatorSessionId = readNonEmptyString(task.sourceCoordinatorSessionId);
440
+ inst.updateSettings({
441
+ meshNodeFor: meshId,
442
+ meshNodeId: nodeId,
443
+ launchedByCoordinator: true,
444
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
445
+ ...(localDaemonId ? { meshCoordinatorDaemonId: localDaemonId } : {}),
446
+ // (3) Stamp the originating coordinator session for session-anchored routing
447
+ // of this co-located worker's completion. Absent → daemon-level fallback.
448
+ ...(localSourceCoordinatorSessionId ? { meshCoordinatorSessionId: localSourceCoordinatorSessionId } : {}),
449
+ });
450
+ }
451
+ } catch { /* best-effort — dispatch still proceeds */ }
452
+
453
+ // CONS3: same shared dispatch lifecycle as the remote branch — only the transport
454
+ // (cliManager.handleCliCommand) differs.
455
+ deliverTaskToSession(
456
+ () => components.cliManager.handleCliCommand('agent_command', {
457
+ targetSessionId: sessionId,
458
+ cliType: providerType,
459
+ action: 'send_chat',
460
+ message: task.message,
461
+ }),
462
+ {
463
+ meshId,
464
+ nodeId,
465
+ sessionId,
466
+ providerType,
467
+ task,
468
+ transport: 'local',
469
+ ...(readNonEmptyString(task.sourceCoordinatorSessionId) ? { sourceCoordinatorSessionId: readNonEmptyString(task.sourceCoordinatorSessionId) } : {}),
470
+ ...(readNonEmptyString(loadConfig().machineId) ? { sourceCoordinatorDaemonId: readNonEmptyString(loadConfig().machineId) } : {}),
471
+ },
472
+ );
473
+
474
+ return true;
475
+ }
476
+
477
+ const autoLaunchInProgress = new Set<string>();
478
+ const autoLaunchCooldownUntil = new Map<string, number>();
479
+ const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
480
+ // A remote auto-launch (launch_cli forward) is fire-and-async: the worker session
481
+ // spawns, reaches idle, emits agent:ready, that ready is queued on the worker, pulled
482
+ // by this coordinator (reconcile PHASE 1), and only THEN claims the task. That round
483
+ // trip routinely exceeds the 5s per-(mesh,node) cooldown, so cooldown alone lets the
484
+ // reconcile loop fire a SECOND launch for the same still-pending task before the first
485
+ // session's claim lands — every tick spawns yet another orphan session (observed live:
486
+ // 26 sessions for one task). This is a per-TASK await-claim window: once a task has a
487
+ // successfully-launched session whose claim we are still waiting on, do not launch it
488
+ // again until the window lapses. It is generous (a slow remote spawn can take tens of
489
+ // seconds) but bounded so a launch that silently never reaches idle is eventually retried.
490
+ const AUTO_LAUNCH_AWAIT_CLAIM_MS = 90_000;
491
+
492
+ // De-dup for repeated `skipped` ledger noise: the reconcile loop re-runs the queue
493
+ // trigger every 4s, so a task that can't be claimed (e.g. a remote node with no
494
+ // transport, or a node under cooldown) would otherwise append an identical
495
+ // session_auto_launch{phase:'skipped'} entry on every tick — flooding the ledger.
496
+ // We suppress a `skipped` ledger append when the immediately-prior recorded event
497
+ // for that task was the SAME (phase, reason). Any non-skip phase (started/failed/
498
+ // completed) or a changed reason resets the de-dup so real transitions still record.
499
+ const lastAutoLaunchLedgerKey = new Map<string, string>();
500
+ const AUTO_LAUNCH_LEDGER_DEDUP_MAX = 2000;
501
+
502
+ function sweepExpiredCooldowns(): void {
503
+ const now = Date.now();
504
+ for (const [key, until] of autoLaunchCooldownUntil) {
505
+ if (now >= until) autoLaunchCooldownUntil.delete(key);
506
+ }
507
+ }
508
+
509
+ function normalizeProviderPriority(policy: unknown): string[] {
510
+ const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
511
+ ? (policy as Record<string, unknown>).providerPriority
512
+ : undefined;
513
+ if (!Array.isArray(raw)) return [];
514
+ const seen = new Set<string>();
515
+ return raw
516
+ .map(type => typeof type === 'string' ? type.trim() : '')
517
+ .filter(Boolean)
518
+ .filter(type => {
519
+ if (seen.has(type)) return false;
520
+ seen.add(type);
521
+ return true;
522
+ });
523
+ }
524
+
525
+ function isTerminalSessionStatus(status: string): boolean {
526
+ return ['stopped', 'failed', 'terminated', 'exited', 'closed'].includes(status);
527
+ }
528
+
529
+ function isIdleSessionState(state: any): boolean {
530
+ const status = readNonEmptyString(state?.status).toLowerCase();
531
+ if (isTerminalSessionStatus(status)) return false;
532
+ return status === 'idle' || state?.activeChat?.status === 'waiting_input';
533
+ }
534
+
535
+ function isDirtyNode(node: any): boolean {
536
+ return node?.health === 'dirty' || node?.git?.dirty === true;
537
+ }
538
+
539
+ function resolveAutoFastForwardPolicy(mesh: any): { enabled: boolean; maxBehind?: number; requireCleanSubmodules: boolean } {
540
+ const record = mesh?.policy?.autoFastForward && typeof mesh.policy.autoFastForward === 'object' && !Array.isArray(mesh.policy.autoFastForward)
541
+ ? mesh.policy.autoFastForward as Record<string, unknown>
542
+ : {};
543
+ const maxBehind = Number(record.maxBehind);
544
+ return {
545
+ enabled: record.enabled !== false,
546
+ ...(Number.isFinite(maxBehind) && maxBehind >= 0 ? { maxBehind: Math.floor(maxBehind) } : {}),
547
+ requireCleanSubmodules: record.requireCleanSubmodules !== false,
548
+ };
549
+ }
550
+
551
+ function sessionStateLooksActive(state: any): boolean {
552
+ const status = readNonEmptyString(state?.status).toLowerCase();
553
+ const chatStatus = readNonEmptyString(state?.activeChat?.status).toLowerCase();
554
+ // 'long_generating' is retained as a legacy alias for the renamed 'no_progress' busy status.
555
+ const active = new Set(['generating', 'streaming', 'no_progress', 'long_generating', 'working', 'starting', 'waiting_approval']);
556
+ return active.has(status) || active.has(chatStatus);
557
+ }
558
+
559
+ function nodeHasActiveMeshWork(components: DaemonComponents, meshId: string, nodeId: string, currentSessionId?: string): boolean {
560
+ if (nodeHasActiveAssignment(meshId, nodeId)) return true;
561
+ return components.instanceManager.getByCategory('cli').some((inst: any) => {
562
+ const state = inst.getState();
563
+ const settings = state.settings as Record<string, unknown> || {};
564
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
565
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
566
+ if (instNodeId !== nodeId) return false;
567
+ const sessionId = readNonEmptyString(state.instanceId);
568
+ if (currentSessionId && sessionId === currentSessionId && isIdleSessionState(state)) return false;
569
+ return sessionStateLooksActive(state);
570
+ });
571
+ }
572
+
573
+ function isLaunchableNode(node: any): boolean {
574
+ if (!node || node.status === 'disabled' || node.status === 'removed') return false;
575
+ const health = readNonEmptyString(node.health).toLowerCase();
576
+ if (!health) return true;
577
+ return health === 'online' || health === 'unknown';
578
+ }
579
+
580
+ /** Whether a mesh node's daemon/machine identity resolves to THIS coordinator daemon
581
+ * (i.e. the queue session can be spawned by a direct local `launch_cli`). */
582
+ function isLocalAutoLaunchNode(node: any): boolean {
583
+ const daemonId = readNonEmptyString(node?.daemonId);
584
+ const machineId = readNonEmptyString(node?.machineId);
585
+ const appConfig = loadConfig();
586
+ const localMachineId = readNonEmptyString(appConfig.machineId) || readNonEmptyString(appConfig.registeredMachineId);
587
+
588
+ // Route BOTH the daemonId and the machineId through the canonical machine-core
589
+ // equivalence helper so a node carrying any interchangeable id form (bare `mach_<hex>`
590
+ // or a `daemon_`/`standalone_` prefixed form) resolves to THIS coordinator instead of
591
+ // being misjudged as remote. machineId used to use a raw `===`, which — AND-combined
592
+ // with the daemonId match — would misjudge a local node as remote whenever a
593
+ // form-mismatched machineId arrived, dispatching a local task to a remote node (B-2).
594
+ const daemonMatchesLocal = !daemonId || daemonIdsEquivalent(daemonId, localMachineId);
595
+ const machineMatchesLocal = !machineId || daemonIdsEquivalent(machineId, localMachineId);
596
+
597
+ if (node?.isLocalWorktree === true) {
598
+ return daemonMatchesLocal && machineMatchesLocal;
599
+ }
600
+ if (daemonId || machineId) {
601
+ return daemonMatchesLocal && machineMatchesLocal;
602
+ }
603
+ return true;
604
+ }
605
+
606
+ /**
607
+ * Resolve how a pending queue task should be auto-launched onto a node.
608
+ *
609
+ * - `local`: spawn directly on this daemon via cliManager.handleCliCommand('launch_cli').
610
+ * - `remote`: forward `launch_cli` to the node's daemon via dispatchMeshCommand
611
+ * (mirrors what mesh_launch_session does). Requires dispatchMeshCommand AND a
612
+ * resolvable coordinator daemonId for relay-safe completion routing.
613
+ * - `skip`: not launchable from here — carries the reason (e.g. a remote node with
614
+ * no dispatch transport, or no coordinator daemonId to stamp).
615
+ */
616
+ function resolveAutoLaunchTarget(components: DaemonComponents, node: any): {
617
+ mode: 'local' | 'remote' | 'skip';
618
+ reason?: string;
619
+ daemonId?: string;
620
+ coordinatorDaemonId?: string;
621
+ } {
622
+ if (isLocalAutoLaunchNode(node)) return { mode: 'local' };
623
+
624
+ // Remote node. Forwarding the launch is possible only with a dispatch transport
625
+ // (cloud mode) plus a coordinator daemonId to stamp into the worker so completion
626
+ // events route back here. Without either, fall back to a graceful skip.
627
+ const daemonId = readNonEmptyString(node?.daemonId);
628
+ if (!daemonId) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
629
+ if (!components.dispatchMeshCommand) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
630
+ const coordinatorDaemonId = readNonEmptyString(loadConfig().machineId);
631
+ if (!coordinatorDaemonId) return { mode: 'skip', reason: 'remote_auto_launch_no_coordinator_daemon_id' };
632
+ return { mode: 'remote', daemonId, coordinatorDaemonId };
633
+ }
634
+
635
+ function activeAssignedCount(meshId: string): number {
636
+ return getQueue(meshId, { status: ['assigned'] as any }).length;
637
+ }
638
+
639
+ /** Active assignments that hold the one-active-per-node / global-parallel invariant
640
+ * (everything except read-only diagnoses, which run unbounded by the write cap). */
641
+ export function activeWriteAssignedCount(meshId: string): number {
642
+ return getQueue(meshId, { status: ['assigned'] as any })
643
+ .filter(task => task.taskMode !== 'live_debug_readonly').length;
644
+ }
645
+
646
+ /** Active read-only (live_debug_readonly) assignments, for the read-only safety cap. */
647
+ export function activeReadonlyAssignedCount(meshId: string): number {
648
+ return getQueue(meshId, { status: ['assigned'] as any })
649
+ .filter(task => task.taskMode === 'live_debug_readonly').length;
650
+ }
651
+
652
+ function nodeHasActiveAssignment(meshId: string, nodeId: string): boolean {
653
+ return getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedNodeId === nodeId);
654
+ }
655
+
656
+ /** Active (status='assigned') task count for a node — the load metric for
657
+ * least-loaded / round-robin ranking. Lower = preferred. */
658
+ function nodeActiveLoad(meshId: string, nodeId: string): number {
659
+ return MeshRuntimeStore.getInstance().nodeActiveAssignmentCount(meshId, nodeId);
660
+ }
661
+
662
+ /**
663
+ * The mesh-wide scheduling strategy. Defaults to 'first_eligible' (strict
664
+ * no-change) for any mesh that does not set it. Only governs the final tie-break;
665
+ * eligibility, capacity, and priority gates apply identically to every strategy.
666
+ */
667
+ function resolveSchedulingStrategy(mesh: any): RepoMeshSchedulingStrategy {
668
+ return normalizeMeshSchedulingStrategy(mesh?.policy?.schedulingStrategy);
669
+ }
670
+
671
+ /**
672
+ * Order eligible nodes for assignment per the mesh scheduling pipeline:
673
+ * PRIORITY (schedulingPriority desc) → TIE-BREAK (strategy).
674
+ *
675
+ * The caller has already applied the TAG hard-filter and is responsible for the
676
+ * MAX-ALLOC capacity gate (the per-node launch/claim checks). This function only
677
+ * decides the *preference order* among nodes that are otherwise eligible.
678
+ *
679
+ * - 'first_eligible' (default): returns the input order verbatim and does NOT touch
680
+ * the round-robin cursor — byte-for-byte the pre-feature behavior.
681
+ * - 'priority_only': schedulingPriority desc, then input order (load ignored).
682
+ * - 'least_loaded': schedulingPriority desc, then active load asc, then input order.
683
+ * - 'round_robin': same as least_loaded, but among nodes tied at (priority, load)
684
+ * the input order is rotated by a per-mesh cursor that advances once per pass.
685
+ *
686
+ * `nodes` carries the original config/array index so the tie-break can fall back to
687
+ * deterministic input order. `bumpCursor` advances the round-robin cursor exactly
688
+ * once per scheduling pass (only consulted for 'round_robin').
689
+ */
690
+ interface RankableNode { nodeId: string; node: any; index: number }
691
+
692
+ /** Test-only: the pure node-ordering stage (PRIORITY → TIE-BREAK). Exposed so the
693
+ * scheduling pipeline can be unit-tested without standing up live CLI sessions. */
694
+ export function __orderEligibleNodesForTests(
695
+ meshId: string,
696
+ strategy: RepoMeshSchedulingStrategy,
697
+ nodes: RankableNode[],
698
+ opts?: { bumpCursor?: boolean },
699
+ ): RankableNode[] {
700
+ return orderEligibleNodes(meshId, strategy, nodes, opts);
701
+ }
702
+
703
+ function orderEligibleNodes(
704
+ meshId: string,
705
+ strategy: RepoMeshSchedulingStrategy,
706
+ nodes: RankableNode[],
707
+ opts?: { bumpCursor?: boolean },
708
+ ): RankableNode[] {
709
+ if (strategy === 'first_eligible' || nodes.length <= 1) {
710
+ return nodes;
711
+ }
712
+
713
+ const priorityOf = (n: { node: any }) => resolveNodeSchedulingPriority(n.node?.policy);
714
+
715
+ // Round-robin rotation offset: rotate the deterministic input order by a
716
+ // per-mesh cursor so the tie-break winner among equal (priority, load) nodes
717
+ // cycles across passes. The cursor advances once per scheduling pass.
718
+ let rotation = 0;
719
+ if (strategy === 'round_robin') {
720
+ const cursor = opts?.bumpCursor
721
+ ? MeshRuntimeStore.getInstance().bumpSchedulerCursor(meshId)
722
+ : MeshRuntimeStore.getInstance().getSchedulerCursor(meshId);
723
+ rotation = ((cursor % nodes.length) + nodes.length) % nodes.length;
724
+ }
725
+
726
+ // Rotation rank: position of each node after rotating input order by `rotation`.
727
+ // For non-round-robin strategies rotation is 0, so this is just the input index.
728
+ const rotationRank = (index: number) => (index - rotation + nodes.length) % nodes.length;
729
+
730
+ return [...nodes].sort((a, b) => {
731
+ const prioDelta = priorityOf(b) - priorityOf(a); // higher priority first
732
+ if (prioDelta !== 0) return prioDelta;
733
+ if (strategy === 'least_loaded' || strategy === 'round_robin') {
734
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
735
+ if (loadDelta !== 0) return loadDelta;
736
+ }
737
+ return rotationRank(a.index) - rotationRank(b.index);
738
+ });
739
+ }
740
+
741
+ /** Active assignments on a (node, provider) — pre-launch guard for the per-(node,
742
+ * provider) maxParallel cap. The authoritative enforcement is in the claim
743
+ * transaction; this only avoids spawning a session that would fail the claim. */
744
+ function activeProviderAssignedCount(meshId: string, nodeId: string, providerType: string): number {
745
+ return getQueue(meshId, { status: ['assigned'] as any })
746
+ .filter(task => task.assignedNodeId === nodeId && task.assignedProviderType === providerType).length;
747
+ }
748
+
749
+ export function sessionHasActiveAssignment(meshId: string, sessionId: string): boolean {
750
+ if (getQueue(meshId, { status: ['assigned'] as any }).some(task => task.assignedSessionId === sessionId)) {
751
+ return true;
752
+ }
753
+ // Direct dispatches (mesh_send_task) are tracked in mesh_direct_dispatches, not the
754
+ // work queue. A session completing a still-active direct dispatch IS an active
755
+ // assignment — without this, findRecentTerminalLedgerEvidence dedup wrongly suppresses
756
+ // the canonical agent:generating_completed for direct-dispatch tasks (validation/general),
757
+ // so the coordinator polling get_pending_mesh_events never observes task_completed and the
758
+ // session goes silently idle. This check runs before markSessionTerminal marks the
759
+ // dispatch terminal, so the in-flight dispatch is still observable here.
760
+ try {
761
+ if (getActiveDirectDispatches(meshId).some(d => d.sessionId === sessionId)) return true;
762
+ if (hasUnterminalDirectDispatchLedgerEntry(meshId, sessionId)) return true;
763
+ } catch { /* best-effort — fall through to false */ }
764
+ return false;
765
+ }
766
+
767
+ function liveSessionCountForNode(components: DaemonComponents, meshId: string, nodeId: string): number {
768
+ return components.instanceManager.getByCategory('cli').filter((inst: any) => {
769
+ const state = inst.getState();
770
+ const settings = state.settings as Record<string, unknown> || {};
771
+ if (readNonEmptyString(settings.meshNodeFor) !== meshId) return false;
772
+ const instNodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
773
+ if (instNodeId !== nodeId) return false;
774
+ const status = readNonEmptyString(state.status).toLowerCase();
775
+ return !isTerminalSessionStatus(status);
776
+ }).length;
777
+ }
778
+
779
+ function recordAutoLaunchEvent(meshId: string, args: {
780
+ phase: 'skipped' | 'started' | 'failed' | 'completed';
781
+ taskId: string;
782
+ nodeId?: string;
783
+ providerType?: string;
784
+ sessionId?: string;
785
+ reason?: string;
786
+ error?: string;
787
+ }) {
788
+ // Suppress consecutive identical `skipped` entries for the same task (4s reconcile
789
+ // re-trigger noise). Non-skip phases and changed reasons always record and reset
790
+ // the de-dup so genuine state transitions remain visible in the ledger.
791
+ const dedupKey = `${meshId}:${args.taskId}`;
792
+ const currentSig = `${args.phase}|${args.reason || ''}`;
793
+ if (args.phase === 'skipped' && lastAutoLaunchLedgerKey.get(dedupKey) === currentSig) {
794
+ return;
795
+ }
796
+ lastAutoLaunchLedgerKey.set(dedupKey, currentSig);
797
+ if (lastAutoLaunchLedgerKey.size > AUTO_LAUNCH_LEDGER_DEDUP_MAX) {
798
+ // Bound memory: drop the oldest insertion (Map preserves insertion order).
799
+ const oldest = lastAutoLaunchLedgerKey.keys().next().value;
800
+ if (oldest !== undefined) lastAutoLaunchLedgerKey.delete(oldest);
801
+ }
802
+ try {
803
+ appendLedgerEntry(meshId, {
804
+ kind: 'session_auto_launch',
805
+ nodeId: args.nodeId,
806
+ sessionId: args.sessionId,
807
+ providerType: args.providerType,
808
+ payload: {
809
+ phase: args.phase,
810
+ taskId: args.taskId,
811
+ reason: args.reason,
812
+ error: args.error,
813
+ },
814
+ });
815
+ } catch (e: any) {
816
+ LOG.warn('MeshQueue', `Failed to record auto-launch ledger event: ${e?.message || e}`);
817
+ }
818
+ }
819
+
820
+ function markAutoLaunch(meshId: string, taskId: string, args: {
821
+ status: 'skipped' | 'started' | 'failed' | 'completed';
822
+ reason?: string;
823
+ nodeId?: string;
824
+ providerType?: string;
825
+ sessionId?: string;
826
+ error?: string;
827
+ }) {
828
+ recordTaskAutoLaunch(meshId, taskId, {
829
+ status: args.status,
830
+ reason: args.reason || args.error,
831
+ nodeId: args.nodeId,
832
+ providerType: args.providerType,
833
+ sessionId: args.sessionId,
834
+ });
835
+ recordAutoLaunchEvent(meshId, {
836
+ phase: args.status,
837
+ taskId,
838
+ nodeId: args.nodeId,
839
+ providerType: args.providerType,
840
+ sessionId: args.sessionId,
841
+ reason: args.reason,
842
+ error: args.error,
843
+ });
844
+ }
845
+
846
+ async function resolveUsableProvider(
847
+ components: DaemonComponents,
848
+ nodeId: string,
849
+ node: any,
850
+ requiredTags?: string[],
851
+ ): Promise<{ providerType?: string; reason?: string }> {
852
+ const providerPriority = normalizeProviderPriority(node?.policy);
853
+ if (!providerPriority.length) return { reason: 'missing_provider_priority' };
854
+ const providerLoader = components.providerLoader;
855
+ if (!providerLoader) return { reason: 'provider_loader_unavailable' };
856
+
857
+ const failed: string[] = [];
858
+ for (const requestedType of providerPriority) {
859
+ const normalizedType = typeof providerLoader.resolveAlias === 'function'
860
+ ? providerLoader.resolveAlias(requestedType)
861
+ : requestedType;
862
+ // Skip providers that can't satisfy the task's requiredTags (e.g. provider=hermes-cli
863
+ // means only hermes-cli qualifies, not any other type in providerPriority).
864
+ if (requiredTags?.length && !nodeSatisfiesRequiredTags(requiredTags, buildMeshNodeCapabilityTags(node, normalizedType))) {
865
+ failed.push(`${requestedType}: required_tags_mismatch`);
866
+ continue;
867
+ }
868
+ if (typeof providerLoader.isMachineProviderEnabled === 'function' && !providerLoader.isMachineProviderEnabled(normalizedType)) {
869
+ failed.push(`${requestedType}: disabled`);
870
+ continue;
871
+ }
872
+ let detected: any;
873
+ try {
874
+ detected = await detectCLI(normalizedType, providerLoader, { includeVersion: false });
875
+ } catch (e: any) {
876
+ failed.push(`${requestedType}: detect failed: ${e?.message || e}`);
877
+ continue;
878
+ }
879
+ if (typeof providerLoader.setCliDetectionResults === 'function') {
880
+ providerLoader.setCliDetectionResults([{
881
+ id: normalizedType,
882
+ installed: !!detected,
883
+ path: detected?.path,
884
+ }], false);
885
+ }
886
+ (components as any).onStatusChange?.();
887
+ if (detected) return { providerType: normalizedType };
888
+ failed.push(`${requestedType}: not detected`);
889
+ }
890
+ return { reason: `provider_priority_unusable: ${failed.join('; ') || nodeId}` };
891
+ }
892
+
893
+ // Canonical mesh node-id normalization. A node may arrive from the local config
894
+ // form (`id`) or the inline-cache form (`nodeId`/`node_id`) — see
895
+ // readInlineMeshNodeId in commands/router.ts. Comparing only `node.id` against a
896
+ // task.targetNodeId silently drops inline-cached worktree nodes, leaving a
897
+ // target-routed task permanently pending with a misleading
898
+ // `no_node_satisfies_required_tags` skip.
899
+ function readMeshNodeId(node: any): string {
900
+ // Delegate to the shared 3-way (id / nodeId / node_id) normalizer so this
901
+ // and every other mesh node-id read agree on identity. Coalesce to '' to
902
+ // preserve the existing string return contract for callers that do
903
+ // `=== task.targetNodeId` / `if (!nodeId)`.
904
+ return normalizeMeshNodeId(node) ?? '';
905
+ }
906
+
907
+ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, meshId: string, mesh: any): Promise<boolean> {
908
+ const queue = getQueue(meshId);
909
+ const pending = queue.filter(task => task.status === 'pending');
910
+ if (!pending.length) return false;
911
+
912
+ const maxParallelTasks = Math.max(1, Math.floor(Number(mesh?.policy?.maxParallelTasks) || 2));
913
+ // Read-only diagnoses carry no isolation/merge cost, so they are exempt from the
914
+ // write-task parallel cap. To prevent runaway auto-launch they get their own,
915
+ // higher safety cap (2x the write cap).
916
+ const maxReadonlyParallelTasks = Math.max(2, maxParallelTasks * 2);
917
+ for (const task of pending) {
918
+ const isReadonly = task.taskMode === 'live_debug_readonly';
919
+ if (isReadonly) {
920
+ if (activeReadonlyAssignedCount(meshId) >= maxReadonlyParallelTasks) {
921
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_readonly_parallel_tasks_reached' });
922
+ continue;
923
+ }
924
+ } else if (activeWriteAssignedCount(meshId) >= maxParallelTasks) {
925
+ // Write tasks are capped; skip this one but keep scanning so a later
926
+ // read-only task in the queue can still launch under its own cap.
927
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_parallel_tasks_reached' });
928
+ continue;
929
+ }
930
+ if (task.targetSessionId) {
931
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'target_session_constraint' });
932
+ continue;
933
+ }
934
+
935
+ // Per-task await-claim guard. A prior auto-launch already spawned a session for
936
+ // this task and we are waiting for that session's idle→claim to land (remote
937
+ // claims arrive via the worker→coordinator agent:ready pull, which can lag well
938
+ // past the per-node cooldown). Re-launching now would spawn a duplicate orphan
939
+ // session that never gets work. The task leaves `pending` the instant the claim
940
+ // succeeds, so this guard only suppresses the in-flight window; if the launched
941
+ // session never reaches idle within the window, a later tick retries.
942
+ if (task.autoLaunch?.status === 'completed' && task.autoLaunch.sessionId) {
943
+ const launchedAtMs = Date.parse(task.autoLaunch.updatedAt);
944
+ if (Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS) {
945
+ // Record the skip in the ledger ONLY (dedup'd). Do NOT call markAutoLaunch
946
+ // here: recordTaskAutoLaunch overwrites task.autoLaunch wholesale, which would
947
+ // erase the very `completed` record (status + sessionId + updatedAt) this guard
948
+ // reads on the next tick, reopening the duplicate-launch hole it closes.
949
+ recordAutoLaunchEvent(meshId, { phase: 'skipped', taskId: task.id, reason: 'awaiting_launched_session_claim', nodeId: task.autoLaunch.nodeId, sessionId: task.autoLaunch.sessionId });
950
+ continue;
951
+ }
952
+ }
953
+
954
+ const candidateNodes = Array.isArray(mesh?.nodes)
955
+ ? mesh.nodes.filter((node: any) => {
956
+ // Bug A: match the target pin with the shared 3-form (id / nodeId / node_id)
957
+ // normalizer, mirroring the remote-idle drain (meshNodeIdMatches at the
958
+ // getRemoteIdleSessions filter). A strict `readMeshNodeId(node) !== targetNodeId`
959
+ // dropped a target node whose identity arrived under a different form (a freshly
960
+ // mesh_clone_node'd worktree), emptying candidateNodes and mislabelling the skip.
961
+ if (task.targetNodeId && !meshNodeIdMatches(node, task.targetNodeId)) return false;
962
+ // WTDISPATCH-FANOUT: a convergence task is base-only (it merges/pushes onto
963
+ // base). Never auto-launch a worktree-clone session for it — that is the very
964
+ // fan-out the claim guard refuses, so spinning the session up would only waste
965
+ // a launch that can never claim. Mirrors claimNextQueueTask's convergence gate.
966
+ if (task.taskMode === 'convergence' && node?.isLocalWorktree === true) return false;
967
+ // Skip nodes that can never satisfy requiredTags regardless of which provider
968
+ // from providerPriority is selected. A node satisfies tags if at least one
969
+ // provider in its priority list would produce matching capability tags.
970
+ if (task.requiredTags?.length) {
971
+ const priorities = normalizeProviderPriority(node?.policy);
972
+ const providerCandidates = priorities.length ? priorities : [undefined as unknown as string];
973
+ return providerCandidates.some(p =>
974
+ nodeSatisfiesRequiredTags(task.requiredTags, buildMeshNodeCapabilityTags(node, p))
975
+ );
976
+ }
977
+ return true;
978
+ })
979
+ : [];
980
+ if (!candidateNodes.length) {
981
+ // Bug A: distinguish the two ways the candidate set empties. A task pinned to a
982
+ // targetNodeId whose node is absent from the mesh (or whose id arrived under a
983
+ // different form) is a ROUTING miss — report it as `target_node_id_unmatched`, not
984
+ // the hard-coded `no_node_satisfies_required_tags`, which mislabelled a 3-form
985
+ // node-id mismatch as a capability failure and sent diagnosis down the wrong path.
986
+ // Only fall back to the tag reason when no target pin is in play, or the pin DID
987
+ // match a node but its tags excluded it (a genuine capability miss).
988
+ const targetPinUnmatched = !!task.targetNodeId
989
+ && !(Array.isArray(mesh?.nodes) && mesh.nodes.some((n: any) => meshNodeIdMatches(n, task.targetNodeId)));
990
+ markAutoLaunch(meshId, task.id, {
991
+ status: 'skipped',
992
+ reason: targetPinUnmatched ? 'target_node_id_unmatched' : 'no_node_satisfies_required_tags',
993
+ nodeId: task.targetNodeId,
994
+ });
995
+ continue;
996
+ }
997
+
998
+ // PRIORITY → TIE-BREAK: order the eligible (TAG-filtered) candidate nodes by
999
+ // the mesh scheduling strategy. 'first_eligible' (default) returns them in
1000
+ // config/array order unchanged, so distribution is strictly opt-in. The
1001
+ // per-node MAX-ALLOC capacity gate (nodeHasActiveAssignment, provider cap,
1002
+ // maxConcurrentSessions) is still applied inside the loop below; this only
1003
+ // chooses which eligible node is *tried first*.
1004
+ const strategy = resolveSchedulingStrategy(mesh);
1005
+ const orderedCandidateNodes = strategy === 'first_eligible'
1006
+ ? candidateNodes
1007
+ : orderEligibleNodes(
1008
+ meshId,
1009
+ strategy,
1010
+ candidateNodes
1011
+ .map((node: any, index: number) => ({ nodeId: readMeshNodeId(node), node, index }))
1012
+ .filter((c: RankableNode) => c.nodeId),
1013
+ { bumpCursor: true },
1014
+ ).map((c: RankableNode) => c.node);
1015
+
1016
+ for (const node of orderedCandidateNodes) {
1017
+ const nodeId = readMeshNodeId(node);
1018
+ if (!nodeId) continue;
1019
+ const launchKey = `${meshId}:${nodeId}`;
1020
+ const now = Date.now();
1021
+ const cooldownUntil = autoLaunchCooldownUntil.get(launchKey) || 0;
1022
+ if (cooldownUntil > 0 && now >= cooldownUntil) autoLaunchCooldownUntil.delete(launchKey);
1023
+ if (autoLaunchInProgress.has(launchKey)) {
1024
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_in_progress', nodeId });
1025
+ continue;
1026
+ }
1027
+ if (now < cooldownUntil) {
1028
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'auto_launch_cooldown', nodeId });
1029
+ continue;
1030
+ }
1031
+ if (isDirtyNode(node)) {
1032
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'dirty_workspace', nodeId });
1033
+ continue;
1034
+ }
1035
+ if (!isLaunchableNode(node)) {
1036
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_not_launch_ready', nodeId });
1037
+ continue;
1038
+ }
1039
+ const launchTarget = resolveAutoLaunchTarget(components, node);
1040
+ if (launchTarget.mode === 'skip') {
1041
+ // Remote node we can't reach (no transport / no coordinator daemonId).
1042
+ // Set a cooldown so the 4s reconcile loop doesn't re-attempt this node
1043
+ // every tick; the de-dup'd skip ledger keeps it diagnosable without flood.
1044
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: launchTarget.reason || 'auto_launch_unavailable', nodeId });
1045
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1046
+ continue;
1047
+ }
1048
+ // Write tasks keep the one-active-per-node invariant (worktree isolation);
1049
+ // read-only (live_debug_readonly) diagnoses may auto-launch onto a node
1050
+ // that already has an active assignment.
1051
+ if (task.taskMode !== 'live_debug_readonly' && nodeHasActiveAssignment(meshId, nodeId)) {
1052
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_has_active_assignment', nodeId });
1053
+ continue;
1054
+ }
1055
+ const maxConcurrentSessions = Number(node?.policy?.maxConcurrentSessions);
1056
+ if (Number.isFinite(maxConcurrentSessions) && maxConcurrentSessions >= 0 && liveSessionCountForNode(components, meshId, nodeId) >= maxConcurrentSessions) {
1057
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_concurrent_sessions_reached', nodeId });
1058
+ continue;
1059
+ }
1060
+
1061
+ autoLaunchInProgress.add(launchKey);
1062
+ try {
1063
+ const resolved = await resolveUsableProvider(components, nodeId, node, task.requiredTags);
1064
+ if (!resolved.providerType) {
1065
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: resolved.reason || 'provider_unusable', nodeId });
1066
+ continue;
1067
+ }
1068
+
1069
+ // Don't spawn a session for a (node, provider) already at its declared
1070
+ // maxParallel cap — it would launch only to fail the claim. The claim
1071
+ // transaction enforces the cap regardless; this just avoids a doomed launch.
1072
+ const providerCap = resolveProviderMaxParallel(node?.policy, resolved.providerType);
1073
+ if (
1074
+ providerCap !== undefined
1075
+ && activeProviderAssignedCount(meshId, nodeId, resolved.providerType) >= providerCap
1076
+ ) {
1077
+ markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'max_provider_parallel_reached', nodeId, providerType: resolved.providerType });
1078
+ continue;
1079
+ }
1080
+
1081
+ // Shared worker-launch envelope. For a local node it spawns directly on this
1082
+ // daemon; for a remote node the identical command is forwarded to the node's
1083
+ // daemon (mirrors mesh_launch_session), with the coordinator daemonId stamped
1084
+ // so the worker's completion events route back to this coordinator.
1085
+ const launchSettings: Record<string, unknown> = {
1086
+ // Worker launch envelope: role + mesh context so worker can route completion events.
1087
+ role: 'worker',
1088
+ meshNodeFor: meshId,
1089
+ meshNodeId: nodeId,
1090
+ spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
1091
+ // Coordinator-dispatched worker: auto-approve unless mesh/node policy
1092
+ // opts out (default true). Lands in settingsOverride and beats the
1093
+ // global per-provider-type autoApprove config (see shouldAutoApprove).
1094
+ autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
1095
+ launchedByCoordinator: true,
1096
+ autoLaunchedForQueueTaskId: task.id,
1097
+ };
1098
+
1099
+ if (launchTarget.mode === 'remote') {
1100
+ // Relay-safe completion routing: stamp the coordinator anchor the same way
1101
+ // mesh_launch_session does so the worker forwards events back to this daemon.
1102
+ const remoteSettings: Record<string, unknown> = {
1103
+ ...launchSettings,
1104
+ meshCoordinatorDaemonId: launchTarget.coordinatorDaemonId,
1105
+ meshCoordinatorNodeId: nodeId,
1106
+ };
1107
+ markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
1108
+ let launchResult: any;
1109
+ try {
1110
+ launchResult = await components.dispatchMeshCommand!(launchTarget.daemonId!, 'launch_cli', {
1111
+ cliType: resolved.providerType,
1112
+ dir: node.workspace,
1113
+ settings: remoteSettings,
1114
+ });
1115
+ } catch (e: any) {
1116
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
1117
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1118
+ return false;
1119
+ }
1120
+ const payload = (launchResult && typeof launchResult === 'object' && 'payload' in launchResult && launchResult.payload && typeof launchResult.payload === 'object')
1121
+ ? launchResult.payload
1122
+ : launchResult;
1123
+ if (!payload?.success) {
1124
+ const reason = readNonEmptyString(payload?.error) || 'remote_launch_cli_failed';
1125
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
1126
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1127
+ return false;
1128
+ }
1129
+ // Remote launch is async: the worker session will register and emit agent:ready,
1130
+ // which (forwarded back here) drives the claim via the normal event path / PHASE 1
1131
+ // reconcile. Set a cooldown so the 4s loop doesn't re-launch before that lands.
1132
+ const remoteSessionId = readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.id) || readNonEmptyString(payload.runtimeSessionId);
1133
+ markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId: remoteSessionId || undefined });
1134
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1135
+ return true;
1136
+ }
1137
+
1138
+ markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
1139
+ const launchResult: any = await components.cliManager.handleCliCommand('launch_cli', {
1140
+ cliType: resolved.providerType,
1141
+ dir: node.workspace,
1142
+ settings: launchSettings,
1143
+ });
1144
+ if (!launchResult?.success) {
1145
+ const reason = launchResult?.error || 'launch_cli_failed';
1146
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
1147
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1148
+ return false;
1149
+ }
1150
+ const sessionId = readNonEmptyString(launchResult.sessionId) || readNonEmptyString(launchResult.id) || readNonEmptyString(launchResult.runtimeSessionId);
1151
+ if (!sessionId) {
1152
+ markAutoLaunch(meshId, task.id, { status: 'failed', reason: 'launch_missing_session_id', nodeId, providerType: resolved.providerType });
1153
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
1154
+ return false;
1155
+ }
1156
+ markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId });
1157
+ // Readiness barrier: a freshly-spawned local CLI session is NOT yet
1158
+ // interactive — its PTY prints the input prompt (and the adapter flips
1159
+ // isReady()) only ~2-6s after launch. Dispatching the task immediately
1160
+ // pushes the first (often large) message into a not-yet-ready PTY, which
1161
+ // could throw "not ready" and bounce the task through requeue (on win32
1162
+ // this raced the auto-launch cooldown and stranded the worker idle).
1163
+ // Await interactive readiness before claiming/dispatching so the very
1164
+ // first message lands cleanly. The adapter's queue-until-ready path is the
1165
+ // backstop if readiness is reported late; this just avoids the churn.
1166
+ await waitForLocalSessionReady(components, sessionId);
1167
+ tryAssignQueueTask(components, meshId, nodeId, sessionId, resolved.providerType);
1168
+ return true;
1169
+ } catch (e: any) {
1170
+ markAutoLaunch(meshId, task.id, { status: 'failed', error: e?.message || String(e), nodeId });
1171
+ autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS);
1172
+ return false;
1173
+ } finally {
1174
+ autoLaunchInProgress.delete(launchKey);
1175
+ }
1176
+ }
1177
+ }
1178
+ return false;
1179
+ }
1180
+
1181
+ export interface MeshQueueTriggerResult {
1182
+ success: true;
1183
+ meshId: string;
1184
+ pendingBefore: number;
1185
+ assignedBefore: number;
1186
+ pendingAfter: number;
1187
+ assignedAfter: number;
1188
+ claimed: boolean;
1189
+ newlyAssignedTasks: Array<{
1190
+ id: string;
1191
+ nodeId?: string;
1192
+ sessionId?: string;
1193
+ }>;
1194
+ localIdleSessionsChecked: number;
1195
+ remoteIdleSessionsChecked: number;
1196
+ skippedSessions: Array<{
1197
+ nodeId?: string;
1198
+ sessionId?: string;
1199
+ reason: string;
1200
+ status?: string;
1201
+ }>;
1202
+ autoLaunchStarted: boolean;
1203
+ /**
1204
+ * True when a worker session is already on its way to claim a still-pending task —
1205
+ * either launched this tick (autoLaunchStarted) or launched on a prior tick and still
1206
+ * booting/awaiting-claim. Callers MUST treat this as "wait, do not launch another
1207
+ * session": a second launch double-edits the worktree. Mutually informative with
1208
+ * `noIdleMeshSessionAvailable`, which is suppressed whenever this is true.
1209
+ */
1210
+ autoLaunchPending?: boolean;
1211
+ noIdleMeshSessionAvailable?: boolean;
1212
+ }
1213
+
1214
+ function countQueueStatus(meshId: string, status: 'pending' | 'assigned'): number {
1215
+ return getQueue(meshId, { status: [status] as any }).length;
1216
+ }
1217
+
1218
+ function getQueueStatusById(meshId: string): Map<string, string> {
1219
+ return new Map(getQueue(meshId).map(task => [task.id, task.status]));
1220
+ }
1221
+
1222
+ export async function triggerMeshQueue(components: DaemonComponents, meshId: string): Promise<MeshQueueTriggerResult> {
1223
+ const mesh = getMeshWithCache(components, meshId);
1224
+ const pendingBefore = countQueueStatus(meshId, 'pending');
1225
+ const assignedBefore = countQueueStatus(meshId, 'assigned');
1226
+ const beforeStatus = getQueueStatusById(meshId);
1227
+ const skippedSessions: MeshQueueTriggerResult['skippedSessions'] = [];
1228
+ let localIdleSessionsChecked = 0;
1229
+ let remoteIdleSessionsChecked = 0;
1230
+ let autoLaunchStarted = false;
1231
+ if (!mesh) {
1232
+ return {
1233
+ success: true,
1234
+ meshId,
1235
+ pendingBefore,
1236
+ assignedBefore,
1237
+ pendingAfter: pendingBefore,
1238
+ assignedAfter: assignedBefore,
1239
+ claimed: false,
1240
+ newlyAssignedTasks: [],
1241
+ localIdleSessionsChecked,
1242
+ remoteIdleSessionsChecked,
1243
+ skippedSessions: [{ reason: 'mesh_not_found' }],
1244
+ autoLaunchStarted,
1245
+ noIdleMeshSessionAvailable: true,
1246
+ };
1247
+ }
1248
+
1249
+ // Collect every idle mesh session (local CLI instances + remote idle records)
1250
+ // as drain candidates. The drain ORDER depends on the scheduling strategy:
1251
+ // - 'first_eligible' (default): local-first, then remote, exactly as before.
1252
+ // - otherwise: local + remote merged into one pool and drained in scheduling
1253
+ // order (priority → load → tie-break). This local-first debias is required
1254
+ // because without it the coordinator's own local node is always visited
1255
+ // first and greedily absorbs all untargeted work before any remote idle
1256
+ // session is even considered — the comparator alone can't spread work if
1257
+ // local is always tried first.
1258
+ type IdleCandidate = { nodeId: string; sessionId: string; providerType: string; origin: 'local' | 'remote'; node: any };
1259
+ const strategy = resolveSchedulingStrategy(mesh);
1260
+ const localCandidates: IdleCandidate[] = [];
1261
+
1262
+ const cliInstances = components.instanceManager.getByCategory('cli');
1263
+ for (const inst of cliInstances) {
1264
+ const state = inst.getState();
1265
+ const settings = state.settings as Record<string, unknown> || {};
1266
+
1267
+ const instMeshId = readNonEmptyString(settings.meshNodeFor);
1268
+ if (instMeshId !== meshId) continue;
1269
+
1270
+ const nodeId = readNonEmptyString(settings.meshNodeId) || readNonEmptyString(settings.nodeId);
1271
+ if (!nodeId) continue;
1272
+
1273
+ if (!isIdleSessionState(state)) {
1274
+ const status = readNonEmptyString(state.status).toLowerCase();
1275
+ skippedSessions.push({
1276
+ nodeId,
1277
+ sessionId: readNonEmptyString(state.instanceId),
1278
+ reason: isTerminalSessionStatus(status) ? 'terminal_session' : 'session_not_idle',
1279
+ status: status || undefined,
1280
+ });
1281
+ continue;
1282
+ }
1283
+
1284
+ const sessionId = state.instanceId;
1285
+ const providerType = state.type || readNonEmptyString(settings.providerType);
1286
+
1287
+ if (providerType) {
1288
+ localIdleSessionsChecked += 1;
1289
+ localCandidates.push({ nodeId, sessionId, providerType, origin: 'local', node: mesh.nodes.find((n: any) => readMeshNodeId(n) === nodeId) });
1290
+ } else {
1291
+ skippedSessions.push({
1292
+ nodeId,
1293
+ sessionId,
1294
+ reason: 'provider_type_missing',
1295
+ });
1296
+ }
1297
+ }
1298
+
1299
+ let remoteSessions: Array<{ nodeId: string; sessionId: string; providerType: string }> = [];
1300
+ try {
1301
+ remoteSessions = MeshRuntimeStore.getInstance().getRemoteIdleSessions();
1302
+ } catch { /* best-effort */ }
1303
+
1304
+ const remoteCandidates: IdleCandidate[] = [];
1305
+ for (const idle of remoteSessions) {
1306
+ // Match with the shared 3-form normalizer (id / nodeId / node_id), not raw
1307
+ // `n.id`, so an inline-cached worktree node whose identity arrived under a
1308
+ // different form is not silently dropped — leaving a remote idle session
1309
+ // unable to claim its pending queue task.
1310
+ const node = mesh.nodes.find((n: any) => meshNodeIdMatches(n, idle.nodeId));
1311
+ if (node) {
1312
+ remoteIdleSessionsChecked += 1;
1313
+ remoteCandidates.push({ nodeId: idle.nodeId, sessionId: idle.sessionId, providerType: idle.providerType, origin: 'remote', node });
1314
+ }
1315
+ }
1316
+
1317
+ const assignIdleCandidate = (candidate: IdleCandidate): void => {
1318
+ const assigned = tryAssignQueueTask(components, meshId, candidate.nodeId, candidate.sessionId, candidate.providerType);
1319
+ if (assigned && candidate.origin === 'remote') {
1320
+ try {
1321
+ MeshRuntimeStore.getInstance().deleteRemoteIdleSession(candidate.nodeId, candidate.sessionId);
1322
+ } catch { /* best-effort */ }
1323
+ }
1324
+ };
1325
+
1326
+ if (strategy === 'first_eligible') {
1327
+ // Strict no-change: drain local idle sessions first (original order), then
1328
+ // remote idle sessions. tryAssignQueueTask is a no-op when nothing matches.
1329
+ for (const candidate of localCandidates) assignIdleCandidate(candidate);
1330
+ for (const candidate of remoteCandidates) assignIdleCandidate(candidate);
1331
+ } else {
1332
+ // Merge local + remote into one pool and drain in scheduling order. Each
1333
+ // assignment mutates a node's active load, and the next pick re-reads it,
1334
+ // so re-ranking after every assignment keeps the spread fair as load shifts.
1335
+ const pool = [...localCandidates, ...remoteCandidates];
1336
+ const baseIndex = new Map<string, number>();
1337
+ pool.forEach((c, i) => { if (!baseIndex.has(c.nodeId)) baseIndex.set(c.nodeId, i); });
1338
+ // Bump the round-robin cursor once for this whole drain pass.
1339
+ const uniqueNodes = [...new Set(pool.map(c => c.nodeId))]
1340
+ .map((nodeId, index) => ({ nodeId, node: pool.find(c => c.nodeId === nodeId)?.node, index }));
1341
+ const ranked = orderEligibleNodes(meshId, strategy, uniqueNodes, { bumpCursor: true });
1342
+ const rankIndex = new Map<string, number>(ranked.map((r, i) => [r.nodeId, i]));
1343
+ const remaining = [...pool];
1344
+ while (remaining.length > 0) {
1345
+ // Re-rank each pass so a node that just took work defers its next session.
1346
+ remaining.sort((a, b) => {
1347
+ const aPrio = resolveNodeSchedulingPriority(a.node?.policy);
1348
+ const bPrio = resolveNodeSchedulingPriority(b.node?.policy);
1349
+ if (aPrio !== bPrio) return bPrio - aPrio;
1350
+ if (strategy === 'least_loaded' || strategy === 'round_robin') {
1351
+ const loadDelta = nodeActiveLoad(meshId, a.nodeId) - nodeActiveLoad(meshId, b.nodeId);
1352
+ if (loadDelta !== 0) return loadDelta;
1353
+ }
1354
+ return (rankIndex.get(a.nodeId) ?? 0) - (rankIndex.get(b.nodeId) ?? 0);
1355
+ });
1356
+ assignIdleCandidate(remaining.shift()!);
1357
+ }
1358
+ }
1359
+
1360
+ autoLaunchStarted = await maybeAutoLaunchOneQueueSession(components, meshId, mesh);
1361
+ const afterQueue = getQueue(meshId);
1362
+ const pendingAfter = afterQueue.filter(task => task.status === 'pending').length;
1363
+ const assignedAfter = afterQueue.filter(task => task.status === 'assigned').length;
1364
+ const newlyAssignedTasks = afterQueue
1365
+ .filter(task => task.status === 'assigned' && beforeStatus.get(task.id) !== 'assigned')
1366
+ .map(task => ({
1367
+ id: task.id,
1368
+ nodeId: task.assignedNodeId,
1369
+ sessionId: task.assignedSessionId,
1370
+ }));
1371
+
1372
+ // An auto-launch is "pending" when the coordinator has already spun a session up
1373
+ // for a still-pending task and is waiting on that session's idle→claim. This covers
1374
+ // two ticks:
1375
+ // - THIS tick fired the launch (autoLaunchStarted), or
1376
+ // - a PRIOR tick launched a session that is still booting/awaiting-claim — the
1377
+ // per-task await-claim guard (maybeAutoLaunchOneQueueSession) deliberately
1378
+ // declines to launch again, so autoLaunchStarted is false even though a session
1379
+ // is on its way to claim this task.
1380
+ // Without this signal, the second tick reports `noIdleMeshSessionAvailable` and the
1381
+ // MCP guidance tells the coordinator to launch ANOTHER worker — producing a duplicate
1382
+ // session that double-edits the worktree. The claim itself is fine; only the wording
1383
+ // was wrong, so we surface `autoLaunchPending` to suppress the bad "launch one more"
1384
+ // advice while the just-launched session converges.
1385
+ const autoLaunchPending = autoLaunchStarted || afterQueue.some(task => {
1386
+ if (task.status !== 'pending') return false;
1387
+ const al = task.autoLaunch;
1388
+ if (!al || (al.status !== 'started' && al.status !== 'completed')) return false;
1389
+ const launchedAtMs = Date.parse(al.updatedAt);
1390
+ return Number.isFinite(launchedAtMs) && Date.now() - launchedAtMs < AUTO_LAUNCH_AWAIT_CLAIM_MS;
1391
+ });
1392
+
1393
+ return {
1394
+ success: true,
1395
+ meshId,
1396
+ pendingBefore,
1397
+ assignedBefore,
1398
+ pendingAfter,
1399
+ assignedAfter,
1400
+ claimed: newlyAssignedTasks.length > 0,
1401
+ newlyAssignedTasks,
1402
+ localIdleSessionsChecked,
1403
+ remoteIdleSessionsChecked,
1404
+ skippedSessions,
1405
+ autoLaunchStarted,
1406
+ ...(autoLaunchPending ? { autoLaunchPending: true } : {}),
1407
+ // Only report "no idle session, go launch one" when nothing is already on its way.
1408
+ // A pending auto-launch (this tick or a prior still-converging one) means a session
1409
+ // WILL claim shortly, so it is not a no-session-available situation.
1410
+ ...(pendingAfter > 0 && newlyAssignedTasks.length === 0 && localIdleSessionsChecked === 0 && remoteIdleSessionsChecked === 0 && !autoLaunchPending
1411
+ ? { noIdleMeshSessionAvailable: true }
1412
+ : {}),
1413
+ };
1414
+ }
1415
+
1416
+ export async function maybeAutoFastForwardIdleNode(components: DaemonComponents, args: {
1417
+ meshId: string;
1418
+ nodeId: string;
1419
+ sessionId?: string;
1420
+ providerType?: string;
1421
+ }): Promise<void> {
1422
+ const mesh = getMeshWithCache(components, args.meshId);
1423
+ const node = mesh?.nodes?.find((candidate: any) => meshNodeIdMatches(candidate, args.nodeId));
1424
+ const workspace = readNonEmptyString(node?.workspace);
1425
+ if (!workspace) return;
1426
+ if (!existsSync(workspace)) return;
1427
+
1428
+ const policy = resolveAutoFastForwardPolicy(mesh);
1429
+ if (!policy.enabled) return;
1430
+ if (nodeHasActiveMeshWork(components, args.meshId, args.nodeId, args.sessionId)) return;
1431
+
1432
+ const throttleKey = `${args.meshId}:${args.nodeId}`;
1433
+ const now = Date.now();
1434
+ const lastAttempt = idleAutoFastForwardLastAttempt.get(throttleKey) || 0;
1435
+ if (now - lastAttempt < IDLE_AUTO_FAST_FORWARD_THROTTLE_MS) return;
1436
+ idleAutoFastForwardLastAttempt.set(throttleKey, now);
1437
+
1438
+ const submoduleIgnorePaths = Array.isArray(node?.policy?.submoduleIgnorePaths)
1439
+ ? node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
1440
+ : undefined;
1441
+ try {
1442
+ const dryRun = await fastForwardMeshNode({
1443
+ meshId: args.meshId,
1444
+ nodeId: args.nodeId,
1445
+ workspace,
1446
+ execute: false,
1447
+ dryRun: true,
1448
+ updateSubmodules: false,
1449
+ submoduleIgnorePaths,
1450
+ trigger: 'idle_auto',
1451
+ });
1452
+ if (!dryRun || dryRun.code !== 'fast_forward_available' || dryRun.allowed !== true) return;
1453
+ const behind = Number(dryRun.current?.behind);
1454
+ if (policy.maxBehind !== undefined && Number.isFinite(behind) && behind > policy.maxBehind) return;
1455
+ if (policy.requireCleanSubmodules) {
1456
+ const submodules = Array.isArray(dryRun.current?.submodules) ? dryRun.current.submodules : [];
1457
+ if (submodules.some((submodule: any) => submodule?.dirty || submodule?.outOfSync || submodule?.error)) return;
1458
+ }
1459
+ await fastForwardMeshNode({
1460
+ meshId: args.meshId,
1461
+ nodeId: args.nodeId,
1462
+ workspace,
1463
+ execute: true,
1464
+ dryRun: false,
1465
+ updateSubmodules: false,
1466
+ submoduleIgnorePaths,
1467
+ trigger: 'idle_auto',
1468
+ });
1469
+ } catch (e: any) {
1470
+ LOG.warn('MeshFastForward', `Idle auto fast-forward check failed for ${args.nodeId}: ${e?.message || e}`);
1471
+ }
1472
+ }
1473
+
1474
+ export function runIdleMaintenanceThenAssignQueue(components: DaemonComponents, args: {
1475
+ meshId: string;
1476
+ nodeId: string;
1477
+ sessionId: string;
1478
+ providerType: string;
1479
+ }): void {
1480
+ setImmediate(() => {
1481
+ maybeAutoFastForwardIdleNode(components, args)
1482
+ .finally(() => {
1483
+ try {
1484
+ tryAssignQueueTask(components, args.meshId, args.nodeId, args.sessionId, args.providerType);
1485
+ } catch (e: any) {
1486
+ LOG.warn('MeshQueue', `Failed to assign idle queue task after maintenance for ${args.nodeId}: ${e?.message || e}`);
1487
+ }
1488
+ });
1489
+ });
1490
+ }
1491
+