@adhdev/daemon-core 0.9.82-rc.46 → 0.9.82-rc.461

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 (440) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +9 -0
  2. package/dist/boot/process-hardening.d.ts +50 -0
  3. package/dist/build-info.d.ts +37 -0
  4. package/dist/chat/source-machine.d.ts +166 -0
  5. package/dist/chat/source-resolver.d.ts +104 -0
  6. package/dist/chat/subscription-updates.d.ts +1 -0
  7. package/dist/cli-adapter-types.d.ts +68 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +200 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +126 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
  14. package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
  15. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  16. package/dist/cli-adapters/resolve-executable.d.ts +69 -0
  17. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  18. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  19. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  20. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  21. package/dist/commands/chat-commands-read.d.ts +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 +25 -1
  27. package/dist/commands/handler.d.ts +125 -0
  28. package/dist/commands/high-family/index.d.ts +3 -0
  29. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  30. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  31. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  32. package/dist/commands/high-family/types.d.ts +64 -0
  33. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  34. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  35. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  36. package/dist/commands/low-family/index.d.ts +3 -0
  37. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  38. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  39. package/dist/commands/low-family/notification.d.ts +2 -0
  40. package/dist/commands/low-family/refine-config.d.ts +2 -0
  41. package/dist/commands/low-family/session-host.d.ts +2 -0
  42. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  43. package/dist/commands/low-family/status-meta.d.ts +2 -0
  44. package/dist/commands/low-family/types.d.ts +33 -0
  45. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  46. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  47. package/dist/commands/med-family/ide.d.ts +10 -0
  48. package/dist/commands/med-family/index.d.ts +3 -0
  49. package/dist/commands/med-family/mesh-crud.d.ts +48 -0
  50. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  51. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  52. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  53. package/dist/commands/med-family/types.d.ts +152 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router-refine.d.ts +137 -0
  56. package/dist/commands/router-worktree-cleanup.d.ts +133 -0
  57. package/dist/commands/router.d.ts +241 -12
  58. package/dist/commands/upgrade-helper.d.ts +41 -1
  59. package/dist/config/chat-history.d.ts +9 -0
  60. package/dist/config/config.d.ts +19 -0
  61. package/dist/config/mesh-config.d.ts +136 -1
  62. package/dist/config/mesh-json-config.d.ts +199 -0
  63. package/dist/config/registry-resolver.d.ts +54 -0
  64. package/dist/config/repo-settings.d.ts +77 -0
  65. package/dist/daemon/dev-server.d.ts +0 -2
  66. package/dist/detection/cli-detector.d.ts +17 -0
  67. package/dist/detection/ide-detector.d.ts +13 -0
  68. package/dist/detection/win32-ide-version.d.ts +37 -0
  69. package/dist/git/change-impact-config.d.ts +159 -0
  70. package/dist/git/git-commands.d.ts +26 -2
  71. package/dist/git/git-diff.d.ts +6 -0
  72. package/dist/git/git-executor.d.ts +11 -0
  73. package/dist/git/git-status.d.ts +57 -0
  74. package/dist/git/git-types.d.ts +2 -50
  75. package/dist/git/git-worktree.d.ts +71 -1
  76. package/dist/git/index.d.ts +3 -1
  77. package/dist/index.d.ts +67 -15
  78. package/dist/index.js +58583 -25541
  79. package/dist/index.js.map +1 -1
  80. package/dist/index.mjs +58207 -25332
  81. package/dist/index.mjs.map +1 -1
  82. package/dist/installer.d.ts +1 -4
  83. package/dist/ipc/local-ipc-server.d.ts +91 -0
  84. package/dist/launch.d.ts +1 -1
  85. package/dist/logging/async-batch-writer.d.ts +10 -0
  86. package/dist/logging/debug-config.d.ts +16 -0
  87. package/dist/logging/log-redactor.d.ts +24 -0
  88. package/dist/logging/log-tail-reader.d.ts +81 -0
  89. package/dist/logging/logger.d.ts +1 -1
  90. package/dist/mesh/contracts.d.ts +230 -0
  91. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  92. package/dist/mesh/coordinator-registry.d.ts +59 -0
  93. package/dist/mesh/mesh-active-work.d.ts +174 -0
  94. package/dist/mesh/mesh-clone-grace.d.ts +8 -0
  95. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  96. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  97. package/dist/mesh/mesh-event-classify.d.ts +7 -0
  98. package/dist/mesh/mesh-event-forwarding.d.ts +41 -0
  99. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  100. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  101. package/dist/mesh/mesh-events-pending.d.ts +155 -0
  102. package/dist/mesh/mesh-events-stale.d.ts +61 -0
  103. package/dist/mesh/mesh-events-utils.d.ts +75 -0
  104. package/dist/mesh/mesh-events.d.ts +6 -49
  105. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  106. package/dist/mesh/mesh-host-ownership.d.ts +29 -0
  107. package/dist/mesh/mesh-init.d.ts +128 -0
  108. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  109. package/dist/mesh/mesh-ledger.d.ts +133 -1
  110. package/dist/mesh/mesh-magi-status.d.ts +63 -0
  111. package/dist/mesh/mesh-missions.d.ts +189 -0
  112. package/dist/mesh/mesh-node-identity.d.ts +310 -0
  113. package/dist/mesh/mesh-queue-assignment.d.ts +158 -0
  114. package/dist/mesh/mesh-reconcile-loop.d.ts +80 -0
  115. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  116. package/dist/mesh/mesh-refine-gates.d.ts +428 -0
  117. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  118. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  119. package/dist/mesh/mesh-routing.d.ts +70 -0
  120. package/dist/mesh/mesh-runtime-store.d.ts +493 -0
  121. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  122. package/dist/mesh/mesh-task-inflight.d.ts +46 -0
  123. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  124. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  125. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  126. package/dist/mesh/mesh-work-queue.d.ts +318 -5
  127. package/dist/mesh/preview-freshness.d.ts +28 -0
  128. package/dist/mesh/refine-config.d.ts +216 -0
  129. package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
  130. package/dist/providers/acp-provider-instance.d.ts +5 -0
  131. package/dist/providers/approval-utils.d.ts +45 -0
  132. package/dist/providers/chat-message-normalization.d.ts +58 -1
  133. package/dist/providers/cli-provider-instance.d.ts +290 -3
  134. package/dist/providers/contracts.d.ts +149 -6
  135. package/dist/providers/external-sources.d.ts +71 -0
  136. package/dist/providers/manual-attendance.d.ts +79 -0
  137. package/dist/providers/native-history/antigravity-claim-registry.d.ts +28 -0
  138. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
  139. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  140. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  141. package/dist/providers/native-history/constants.d.ts +12 -0
  142. package/dist/providers/native-history/dispatcher.d.ts +31 -0
  143. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  144. package/dist/providers/native-history/index.d.ts +13 -0
  145. package/dist/providers/provider-instance-manager.d.ts +29 -0
  146. package/dist/providers/provider-instance.d.ts +30 -1
  147. package/dist/providers/provider-loader.d.ts +42 -5
  148. package/dist/providers/provider-trust.d.ts +31 -0
  149. package/dist/providers/read-chat-contract.d.ts +29 -0
  150. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  151. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  152. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  153. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  154. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  155. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  156. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  157. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  158. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  159. package/dist/providers/sdk/v1/index.d.ts +30 -0
  160. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  161. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  162. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  163. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  164. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  165. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  166. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  167. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  168. package/dist/providers/spec/adapter.d.ts +98 -0
  169. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  170. package/dist/providers/spec/evaluator.d.ts +45 -0
  171. package/dist/providers/spec/fsm-driver.d.ts +449 -0
  172. package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
  173. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  174. package/dist/providers/spec/fsm-types.d.ts +202 -0
  175. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  176. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  177. package/dist/providers/spec/route.d.ts +4 -0
  178. package/dist/providers/spec/types.d.ts +222 -0
  179. package/dist/providers/status-monitor.d.ts +7 -7
  180. package/dist/providers/transcript-v2.d.ts +176 -0
  181. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  182. package/dist/providers/working-dir.d.ts +17 -0
  183. package/dist/repo-mesh-types.d.ts +598 -12
  184. package/dist/runtime-defaults.d.ts +2 -0
  185. package/dist/session-host/managed-host.d.ts +64 -0
  186. package/dist/session-host/runtime-surface.d.ts +10 -16
  187. package/dist/sessions/registry.d.ts +6 -0
  188. package/dist/shared-types-extra.d.ts +2 -4
  189. package/dist/shared-types.d.ts +86 -55
  190. package/dist/status/normalize.d.ts +1 -1
  191. package/dist/status/normalize.js +1 -0
  192. package/dist/status/normalize.js.map +1 -1
  193. package/dist/status/normalize.mjs +1 -0
  194. package/dist/status/normalize.mjs.map +1 -1
  195. package/dist/status/reporter.d.ts +2 -0
  196. package/dist/status/snapshot.d.ts +26 -0
  197. package/dist/system/hash.d.ts +8 -0
  198. package/dist/system/load-better-sqlite3.d.ts +21 -0
  199. package/dist/types.d.ts +5 -0
  200. package/package.json +11 -4
  201. package/src/agent-stream/poller.ts +2 -3
  202. package/src/agent-stream/provider-adapter.ts +1 -1
  203. package/src/boot/daemon-lifecycle.ts +80 -14
  204. package/src/boot/process-hardening.ts +89 -0
  205. package/src/build-info.ts +73 -0
  206. package/src/chat/source-machine.ts +534 -0
  207. package/src/chat/source-resolver.ts +0 -0
  208. package/src/chat/subscription-updates.ts +20 -1
  209. package/src/cli-adapter-types.d.ts +3 -1
  210. package/src/cli-adapter-types.ts +68 -2
  211. package/src/cli-adapters/cli-script-runner.ts +421 -0
  212. package/src/cli-adapters/cli-state-engine.ts +1246 -0
  213. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  214. package/src/cli-adapters/provider-cli-adapter.ts +914 -1144
  215. package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
  216. package/src/cli-adapters/provider-cli-parse.ts +19 -0
  217. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  218. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  219. package/src/cli-adapters/provider-cli-shared.ts +121 -11
  220. package/src/cli-adapters/pty-transport.ts +2 -1
  221. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  222. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  223. package/src/cli-adapters/resolve-executable.ts +204 -0
  224. package/src/cli-adapters/session-host-transport.ts +2 -1
  225. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
  226. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  227. package/src/cli-adapters/terminal-screen.ts +16 -81
  228. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  229. package/src/commands/chat-commands-read.ts +2488 -0
  230. package/src/commands/chat-commands-scope.ts +54 -0
  231. package/src/commands/chat-commands-shared.ts +114 -0
  232. package/src/commands/chat-commands-write.ts +891 -0
  233. package/src/commands/chat-commands.ts +19 -1841
  234. package/src/commands/cli-manager.ts +596 -27
  235. package/src/commands/handler.ts +845 -2
  236. package/src/commands/high-family/index.ts +28 -0
  237. package/src/commands/high-family/mesh-coordinator-launch.ts +680 -0
  238. package/src/commands/high-family/mesh-events.ts +89 -0
  239. package/src/commands/high-family/mesh-status.ts +832 -0
  240. package/src/commands/high-family/types.ts +80 -0
  241. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  242. package/src/commands/low-family/daemon-lifecycle.ts +120 -0
  243. package/src/commands/low-family/diagnostics.ts +57 -0
  244. package/src/commands/low-family/index.ts +37 -0
  245. package/src/commands/low-family/mesh-ledger.ts +62 -0
  246. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  247. package/src/commands/low-family/notification.ts +116 -0
  248. package/src/commands/low-family/refine-config.ts +106 -0
  249. package/src/commands/low-family/session-host.ts +274 -0
  250. package/src/commands/low-family/spec-providerdev.ts +217 -0
  251. package/src/commands/low-family/status-meta.ts +112 -0
  252. package/src/commands/low-family/types.ts +39 -0
  253. package/src/commands/med-family/cli-agent.ts +241 -0
  254. package/src/commands/med-family/fast-forward.ts +230 -0
  255. package/src/commands/med-family/ide.ts +163 -0
  256. package/src/commands/med-family/index.ts +37 -0
  257. package/src/commands/med-family/mesh-crud.ts +1287 -0
  258. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  259. package/src/commands/med-family/mesh-queue.ts +167 -0
  260. package/src/commands/med-family/mesh-restart.ts +92 -0
  261. package/src/commands/med-family/types.ts +153 -0
  262. package/src/commands/mesh-coordinator.ts +334 -124
  263. package/src/commands/router-refine.ts +1711 -0
  264. package/src/commands/router-worktree-cleanup.ts +870 -0
  265. package/src/commands/router.ts +1025 -4070
  266. package/src/commands/stream-commands.ts +8 -0
  267. package/src/commands/upgrade-helper.ts +310 -45
  268. package/src/config/chat-history.ts +493 -24
  269. package/src/config/config.ts +30 -0
  270. package/src/config/mesh-config.ts +632 -23
  271. package/src/config/mesh-json-config.ts +376 -0
  272. package/src/config/recent-activity.ts +8 -2
  273. package/src/config/registry-resolver.ts +100 -0
  274. package/src/config/repo-settings.ts +111 -0
  275. package/src/daemon/dev-auto-implement.ts +3 -2
  276. package/src/daemon/dev-cli-debug.ts +10 -1
  277. package/src/daemon/dev-server.ts +0 -541
  278. package/src/detection/cli-detector.ts +94 -9
  279. package/src/detection/ide-detector.ts +55 -16
  280. package/src/detection/win32-ide-version.ts +106 -0
  281. package/src/git/change-impact-config.ts +354 -0
  282. package/src/git/git-commands.ts +112 -16
  283. package/src/git/git-diff.ts +81 -11
  284. package/src/git/git-executor.ts +12 -0
  285. package/src/git/git-status.ts +767 -48
  286. package/src/git/git-types.ts +14 -62
  287. package/src/git/git-worktree.ts +261 -4
  288. package/src/git/index.ts +17 -0
  289. package/src/index.ts +200 -12
  290. package/src/installer.d.ts +1 -1
  291. package/src/installer.ts +8 -6
  292. package/src/ipc/local-ipc-server.ts +278 -0
  293. package/src/launch.d.ts +1 -1
  294. package/src/launch.ts +37 -28
  295. package/src/logging/async-batch-writer.ts +55 -0
  296. package/src/logging/command-log.ts +7 -5
  297. package/src/logging/debug-config.ts +25 -0
  298. package/src/logging/debug-trace.ts +7 -2
  299. package/src/logging/log-redactor.ts +100 -0
  300. package/src/logging/log-tail-reader.ts +341 -0
  301. package/src/logging/logger.ts +14 -7
  302. package/src/mesh/contracts.ts +469 -0
  303. package/src/mesh/coordinator-prompt.ts +554 -34
  304. package/src/mesh/coordinator-registry.ts +121 -0
  305. package/src/mesh/mesh-active-work.ts +656 -0
  306. package/src/mesh/mesh-clone-grace.ts +68 -0
  307. package/src/mesh/mesh-coordinator-config.ts +97 -0
  308. package/src/mesh/mesh-delivery-policy.ts +315 -0
  309. package/src/mesh/mesh-event-classify.ts +73 -0
  310. package/src/mesh/mesh-event-forwarding.ts +1950 -0
  311. package/src/mesh/mesh-event-trace.ts +67 -0
  312. package/src/mesh/mesh-events-coordinator.ts +32 -0
  313. package/src/mesh/mesh-events-pending.ts +1132 -0
  314. package/src/mesh/mesh-events-stale.ts +440 -0
  315. package/src/mesh/mesh-events-utils.ts +443 -0
  316. package/src/mesh/mesh-events.ts +35 -1035
  317. package/src/mesh/mesh-fast-forward.ts +856 -0
  318. package/src/mesh/mesh-host-ownership.ts +111 -0
  319. package/src/mesh/mesh-init.ts +350 -0
  320. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  321. package/src/mesh/mesh-ledger.ts +873 -102
  322. package/src/mesh/mesh-magi-status.ts +223 -0
  323. package/src/mesh/mesh-missions.ts +449 -0
  324. package/src/mesh/mesh-node-identity.ts +2065 -0
  325. package/src/mesh/mesh-queue-assignment.ts +2233 -0
  326. package/src/mesh/mesh-reconcile-loop.ts +2397 -0
  327. package/src/mesh/mesh-refine-batch.ts +205 -0
  328. package/src/mesh/mesh-refine-gates.ts +1686 -0
  329. package/src/mesh/mesh-refine-status.ts +231 -0
  330. package/src/mesh/mesh-review-inbox.ts +308 -0
  331. package/src/mesh/mesh-routing.ts +291 -0
  332. package/src/mesh/mesh-runtime-store.ts +2056 -0
  333. package/src/mesh/mesh-scheduling-runtime.ts +198 -0
  334. package/src/mesh/mesh-task-inflight.ts +70 -0
  335. package/src/mesh/mesh-task-stats.ts +161 -0
  336. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  337. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  338. package/src/mesh/mesh-work-queue.ts +1281 -141
  339. package/src/mesh/preview-freshness.ts +163 -0
  340. package/src/mesh/refine-config.ts +423 -0
  341. package/src/mesh/worktree-bootstrap-config.ts +599 -0
  342. package/src/providers/acp-provider-instance.ts +43 -10
  343. package/src/providers/approval-utils.d.ts +5 -0
  344. package/src/providers/approval-utils.ts +99 -5
  345. package/src/providers/chat-message-normalization.ts +126 -4
  346. package/src/providers/cli-provider-instance.ts +2456 -107
  347. package/src/providers/contracts.d.ts +55 -0
  348. package/src/providers/contracts.ts +160 -6
  349. package/src/providers/extension-provider-instance.ts +12 -7
  350. package/src/providers/external-sources.ts +218 -0
  351. package/src/providers/ide-provider-instance.ts +35 -12
  352. package/src/providers/manual-attendance.ts +105 -0
  353. package/src/providers/native-history/antigravity-claim-registry.ts +131 -0
  354. package/src/providers/native-history/antigravity-cli-transcript.ts +1193 -0
  355. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  356. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  357. package/src/providers/native-history/constants.ts +19 -0
  358. package/src/providers/native-history/dispatcher.ts +503 -0
  359. package/src/providers/native-history/hermes-cli-transcript.ts +260 -0
  360. package/src/providers/native-history/index.ts +30 -0
  361. package/src/providers/provider-instance-manager.ts +71 -0
  362. package/src/providers/provider-instance.ts +24 -1
  363. package/src/providers/provider-loader.ts +700 -55
  364. package/src/providers/provider-schema.ts +87 -14
  365. package/src/providers/provider-trust.ts +114 -0
  366. package/src/providers/read-chat-contract.ts +76 -16
  367. package/src/providers/sdk/README.md +49 -0
  368. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  369. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  370. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  371. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
  372. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  373. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  374. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  375. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  376. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  377. package/src/providers/sdk/v1/index.ts +152 -0
  378. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  379. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  380. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  381. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +509 -0
  382. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  383. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  384. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  385. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  386. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  387. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  388. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  389. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  390. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  391. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  392. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  393. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  394. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  395. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  396. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  397. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  398. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  399. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  400. package/src/providers/sdk/v1/validators/index.ts +19 -0
  401. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  402. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  403. package/src/providers/spec/adapter.ts +246 -0
  404. package/src/providers/spec/cli-adapter.ts +1064 -0
  405. package/src/providers/spec/evaluator.ts +407 -0
  406. package/src/providers/spec/fsm-driver.ts +1528 -0
  407. package/src/providers/spec/fsm-evaluator.ts +290 -0
  408. package/src/providers/spec/fsm-loader.ts +128 -0
  409. package/src/providers/spec/fsm-types.ts +301 -0
  410. package/src/providers/spec/native-history-executor.ts +1174 -0
  411. package/src/providers/spec/pre-launch-trust.ts +104 -0
  412. package/src/providers/spec/route.ts +51 -0
  413. package/src/providers/spec/types.ts +262 -0
  414. package/src/providers/status-monitor.d.ts +7 -7
  415. package/src/providers/status-monitor.ts +37 -22
  416. package/src/providers/transcript-v2.ts +567 -0
  417. package/src/providers/types/interactive-prompt.ts +536 -0
  418. package/src/providers/version-archive.ts +64 -24
  419. package/src/providers/working-dir.ts +23 -0
  420. package/src/repo-mesh-types.ts +836 -13
  421. package/src/runtime-defaults.ts +39 -0
  422. package/src/session-host/managed-host.ts +218 -0
  423. package/src/session-host/runtime-surface.ts +20 -80
  424. package/src/sessions/registry.ts +6 -0
  425. package/src/shared-types-extra.ts +2 -4
  426. package/src/shared-types.d.ts +8 -0
  427. package/src/shared-types.ts +124 -55
  428. package/src/status/builders.ts +26 -6
  429. package/src/status/normalize.ts +2 -0
  430. package/src/status/reporter.ts +19 -1
  431. package/src/status/snapshot.ts +95 -26
  432. package/src/system/hash.ts +23 -0
  433. package/src/system/host-memory.ts +29 -12
  434. package/src/system/load-better-sqlite3.ts +68 -0
  435. package/src/types.ts +5 -0
  436. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  437. package/dist/mesh/mesh-sync.d.ts +0 -53
  438. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  439. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  440. package/src/mesh/mesh-sync.ts +0 -111
@@ -1,28 +1,532 @@
1
- import { existsSync, writeFileSync, readFileSync, openSync, closeSync, unlinkSync } from 'fs';
2
- import { join } from 'path';
3
1
  import { randomUUID } from 'crypto';
4
- import { getLedgerDir } from './mesh-ledger.js';
2
+ import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
3
+ import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
+ import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
5
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
6
+ import { getMesh } from '../config/mesh-config.js';
7
+ import { LOG } from '../logging/logger.js';
8
+ import { appendLedgerEntry } from './mesh-ledger.js';
9
+ import type { MeshLedgerKind } from './mesh-ledger.js';
10
+ import { createSessionDelivery } from './mesh-delivery-policy.js';
11
+ import { isTaskDispatchInFlight, endTaskDispatchInFlight } from './mesh-task-inflight.js';
12
+ import { sessionIdsEquivalent } from '@adhdev/mesh-shared';
5
13
 
6
14
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
7
15
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
8
16
  export type MeshHistoricalTaskStatus = Extract<MeshTaskStatus, 'completed' | 'failed' | 'cancelled'>;
17
+ export type MeshTaskMode = 'code_change' | 'validation' | 'live_debug_readonly' | 'launch_app' | 'convergence';
9
18
 
10
19
  export const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[] = ['pending', 'assigned'];
11
20
  export const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[] = ['completed', 'failed', 'cancelled'];
21
+ export const MESH_TASK_MODES: MeshTaskMode[] = ['code_change', 'validation', 'live_debug_readonly', 'launch_app', 'convergence'];
22
+
23
+ /**
24
+ * QUEUE-NODE-SERIALIZATION: single source of truth for "is this task read-only?".
25
+ *
26
+ * Read-only classification used to be inlined as `task.taskMode === 'live_debug_readonly'`
27
+ * at every enforcement site (node-conflict claim gate, auto-launch isolation, the
28
+ * write/readonly cap counters, the write guardrail). That spread-out comparison is the
29
+ * exact recurring-defect class — one site drifting from the others silently makes the same
30
+ * task read-only at some gates and write at others, i.e. partial serialization. All sites
31
+ * MUST call this predicate so the classification is decided in exactly one place.
32
+ *
33
+ * Two orthogonal inputs feed the same boolean axis (kept backward-compatible):
34
+ * • `readonly === true` — the explicit boolean axis (new API surface).
35
+ * • `taskMode === 'live_debug_readonly'` — the original enum value, preserved as an
36
+ * OR-fallback so existing live_debug_readonly tasks keep behaving identically.
37
+ *
38
+ * Accepts any task-like shape (full {@link MeshWorkQueueEntry} or a bare
39
+ * `{ readonly?, taskMode? }`) so the daemon-core and mcp-server boundaries can share it.
40
+ */
41
+ export function isTaskReadonly(task: { readonly?: boolean; taskMode?: MeshTaskMode | string } | null | undefined): boolean {
42
+ if (!task) return false;
43
+ return task.readonly === true || task.taskMode === 'live_debug_readonly';
44
+ }
45
+
46
+ export interface MeshTaskModeValidationResult {
47
+ valid: boolean;
48
+ taskMode?: MeshTaskMode;
49
+ violations: string[];
50
+ allowedOperations?: string[];
51
+ }
52
+
53
+ const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> = [
54
+ { label: 'source_edit', pattern: /\b(edit|modify|patch|apply\s+patch|write\s+(?:to\s+)?(?:file|source)|overwrite|delete\s+file|remove\s+file|create\s+file|touch\s+file)\b/i },
55
+ { label: 'checkpoint', pattern: /\b(checkpoint|mesh_checkpoint)\b/i },
56
+ { label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i },
57
+ { label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i|shred\b)\b/i },
58
+ { label: 'package_install', pattern: /\b(npm\s+(?:install|i|add|link|uninstall|remove)|yarn\s+(?:add|remove|link)|pnpm\s+(?:add|remove|link)|pip\s+install|brew\s+install|apt\s+install|cargo\s+install)\b/i },
59
+ { label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
60
+ ];
61
+
62
+ /**
63
+ * Negation cues that, when they appear shortly before a mutation keyword inside
64
+ * the same clause, mean the keyword is being *forbidden* or described rather
65
+ * than invoked (e.g. "do not git reset", "절대 push 하지 마세요"). Matched
66
+ * case-insensitively. The Korean cues intentionally include sub-string forms
67
+ * ("않", "금지", "말 것") so conjugated variants are caught.
68
+ */
69
+ const NEGATION_CUES: string[] = [
70
+ "don't", 'do not', 'never', 'avoid', 'without', 'no longer', 'not', 'forbidden',
71
+ '하지 마', '하지 마세요', '말 것', '금지', '없음', '않',
72
+ ];
73
+
74
+ /** How many whitespace-delimited tokens before a keyword we scan for negation. */
75
+ const NEGATION_WINDOW_TOKENS = 6;
76
+
77
+ /**
78
+ * Returns true if a negation cue appears within {@link NEGATION_WINDOW_TOKENS}
79
+ * tokens before `matchIndex`, staying inside the same clause — we stop at
80
+ * sentence/line/clause boundaries (newline, `.`/`!`/`?`, `;`) so a negation in a
81
+ * previous sentence does not suppress a real command in the next one.
82
+ */
83
+ function hasNegationBefore(text: string, matchIndex: number): boolean {
84
+ const before = text.slice(0, matchIndex);
85
+ // Restrict to the current clause: cut at the last clause/sentence/line break.
86
+ const clauseStart = Math.max(
87
+ before.lastIndexOf('\n'),
88
+ before.lastIndexOf('. '),
89
+ before.lastIndexOf('! '),
90
+ before.lastIndexOf('? '),
91
+ before.lastIndexOf(';'),
92
+ );
93
+ const clause = before.slice(clauseStart + 1);
94
+ const lower = clause.toLowerCase();
95
+ // Whitespace tokens immediately preceding the keyword, within the window.
96
+ const tokens = clause.split(/\s+/).filter(Boolean);
97
+ const windowTokens = tokens.slice(Math.max(0, tokens.length - NEGATION_WINDOW_TOKENS));
98
+ const windowText = windowTokens.join(' ').toLowerCase();
99
+ for (const cue of NEGATION_CUES) {
100
+ const c = cue.toLowerCase();
101
+ // ASCII cues are word-ish phrases — match in the bounded window only.
102
+ // CJK cues have no spaces, so the window-join can miss them; for those
103
+ // fall back to scanning the whole clause (still clause-bounded).
104
+ if (/[^\x00-\x7f]/.test(c)) {
105
+ if (lower.includes(c)) return true;
106
+ } else if (windowText.includes(c)) {
107
+ return true;
108
+ }
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /**
114
+ * Korean (and other) negation often trails the verb it negates ("git reset 하지
115
+ * 마세요" = "do not git reset"). Returns true if a CJK negation cue appears
116
+ * shortly after `matchEnd`, within the same clause. Scoped to CJK cues only —
117
+ * trailing ASCII words rarely negate a preceding command and would over-match.
118
+ */
119
+ function hasTrailingNegation(text: string, matchEnd: number): boolean {
120
+ const after = text.slice(matchEnd);
121
+ // Clause-bound the lookahead: stop at the next clause/line break.
122
+ const clauseEnd = (() => {
123
+ const stops = [after.indexOf('\n'), after.indexOf('. '), after.indexOf('; ')]
124
+ .filter(i => i >= 0);
125
+ return stops.length ? Math.min(...stops) : after.length;
126
+ })();
127
+ const clause = after.slice(0, clauseEnd).toLowerCase();
128
+ for (const cue of NEGATION_CUES) {
129
+ const c = cue.toLowerCase();
130
+ if (/[^\x00-\x7f]/.test(c) && clause.includes(c)) return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * Returns true when the keyword at [matchStart, matchEnd) looks like an actual
137
+ * command invocation rather than a plain-prose mention. Command context is:
138
+ * - inside a fenced code block (``` ... ```) or inline backticks (`...`)
139
+ * - on a shell-prompt line (starts with `$ ` or `> `)
140
+ * - at a command-call position: line start (leading whitespace allowed) or
141
+ * right after a shell connective (`&&`, `||`, `|`, `;`) or an imperative
142
+ * connective ("then"/"run"/"," ) that introduces a command.
143
+ * Plain mid-sentence prose mentions (no backticks, no command position) are not
144
+ * command context and are excluded from violations.
145
+ */
146
+ function isMutationKeywordInCommandContext(text: string, matchStart: number, matchEnd: number): boolean {
147
+ if (isInsideBackticksOrFence(text, matchStart, matchEnd)) return true;
148
+
149
+ // The line containing the match, and the portion of it before the keyword.
150
+ const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
151
+ const linePrefix = text.slice(lineStart, matchStart);
152
+
153
+ // Shell-prompt line: "$ ..." or "> ..." (leading whitespace allowed).
154
+ if (/^\s*[$>]\s/.test(text.slice(lineStart))) return true;
155
+
156
+ // Line start (only whitespace before the keyword on this line).
157
+ if (/^\s*$/.test(linePrefix)) return true;
158
+
159
+ // Executed script at command position: the keyword sits inside a run-prefix
160
+ // path token (`./scripts/version-bump.sh`, `~/bin/deploy.sh`) that is the
161
+ // leading token of the line or follows a shell connective — the script is
162
+ // being invoked, so the keyword is a real command. (Path *arguments* like
163
+ // `list build/Release` are excluded earlier by isInsidePathSegment.)
164
+ let tokStart = matchStart;
165
+ while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
166
+ const beforeToken = text.slice(lineStart, tokStart);
167
+ const tokenLead = text.slice(tokStart, matchStart);
168
+ const atCmdPos = /^\s*$/.test(beforeToken) || /(?:&&|\|\||\||;)\s*$/.test(beforeToken);
169
+ if (atCmdPos && /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenLead)) return true;
170
+
171
+ // After a shell connective or imperative connective introducing a command.
172
+ // We look at what immediately precedes the keyword on the same line.
173
+ if (/(?:&&|\|\||\||;)\s*$/.test(linePrefix)) return true;
174
+ if (/(?:^|[\s,])(?:then|run|first)\s+$/i.test(linePrefix)) return true;
175
+ if (/,\s*$/.test(linePrefix)) return true;
176
+
177
+ return false;
178
+ }
179
+
180
+ /**
181
+ * True if [matchStart, matchEnd) lies inside an inline-backtick span or a fenced
182
+ * code block. Fenced blocks (```...```) take precedence; otherwise we count
183
+ * inline backticks before the match — an odd count means we are inside a span.
184
+ */
185
+ function isInsideBackticksOrFence(text: string, matchStart: number, matchEnd: number): boolean {
186
+ // Fenced code blocks: count ``` fences before the match.
187
+ const fenceRe = /```/g;
188
+ let fenceCount = 0;
189
+ let m: RegExpExecArray | null;
190
+ while ((m = fenceRe.exec(text)) !== null) {
191
+ if (m.index >= matchStart) break;
192
+ fenceCount++;
193
+ }
194
+ if (fenceCount % 2 === 1) return true;
195
+
196
+ // Inline backticks: count single backticks before the match start, ignoring
197
+ // those that are part of a ``` fence (handled above). An odd count → inside.
198
+ let inlineCount = 0;
199
+ for (let i = 0; i < matchStart; i++) {
200
+ if (text[i] === '`') {
201
+ // Skip triple-fence backticks.
202
+ if (text[i + 1] === '`' && text[i + 2] === '`') {
203
+ i += 2;
204
+ continue;
205
+ }
206
+ inlineCount++;
207
+ }
208
+ }
209
+ return inlineCount % 2 === 1;
210
+ }
211
+
212
+ /**
213
+ * True if the matched keyword is part of a filesystem-path-like token rather than
214
+ * a standalone word/command. A "release" inside `build/Release`, `dist/release/`,
215
+ * or `packages\release` is a directory/file name, not a deploy instruction. We
216
+ * look at the characters immediately adjacent to the match: if either side is a
217
+ * path separator (`/` or `\`) joining it to another path segment, the keyword is
218
+ * a path component.
219
+ *
220
+ * IMPORTANT: a path token that is itself being *executed* — i.e. the leading
221
+ * token of a command line such as `./scripts/version-bump.sh patch` — is NOT a
222
+ * suppressible path; that is a real command. We therefore exclude the case where
223
+ * the path token sits at command-invocation position (line start, optionally with
224
+ * a leading `./` / `/` / `~/`, or right after a shell connective), which means it
225
+ * is the program being run rather than an argument being inspected.
226
+ */
227
+ function isInsidePathSegment(text: string, matchStart: number, matchEnd: number): boolean {
228
+ const prev = matchStart > 0 ? text[matchStart - 1] : '';
229
+ const next = matchEnd < text.length ? text[matchEnd] : '';
230
+ const isSep = (c: string) => c === '/' || c === '\\';
231
+ const segChar = (c: string) => /[A-Za-z0-9._~-]/.test(c);
232
+ const inPath =
233
+ (isSep(prev) && (next === '' || isSep(next) || segChar(next) || /\s/.test(next))) ||
234
+ (isSep(next) && (prev === '' || isSep(prev) || segChar(prev) || /\s/.test(prev)));
235
+ if (!inPath) return false;
236
+
237
+ // Find the whole whitespace-delimited path token containing the match and the
238
+ // text on its line before it. If the token is the first thing on the line
239
+ // (after an optional `./`, `/`, `~/`, or `../` prefix) or directly follows a
240
+ // shell connective, it is being executed → not a suppressible path.
241
+ const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
242
+ let tokStart = matchStart;
243
+ while (tokStart > lineStart && !/\s/.test(text[tokStart - 1])) tokStart--;
244
+ const linePrefixBeforeToken = text.slice(lineStart, tokStart);
245
+ const tokenPrefix = text.slice(tokStart, matchStart);
246
+ // The path token is in command position when nothing but whitespace (or a
247
+ // shell connective) precedes it on the line, AND the token itself is a
248
+ // run-prefix path (`./`, `/`, `~/`, `../`). A bare `build/Release` argument
249
+ // after a verb ("list build/Release") is NOT command position.
250
+ const atLineStart = /^\s*$/.test(linePrefixBeforeToken);
251
+ const afterConnective = /(?:&&|\|\||\||;)\s*$/.test(linePrefixBeforeToken);
252
+ const isRunPrefixPath = /^(?:\.\/|\.\.\/|~\/|\/)/.test(tokenPrefix);
253
+ if ((atLineStart || afterConnective) && isRunPrefixPath) return false;
254
+ return true;
255
+ }
256
+
257
+ /**
258
+ * True if [matchStart, matchEnd) lies inside a quoted span — straight quotes
259
+ * (`"..."`, `'...'`), typographic quotes (`“...”`, `‘...’`), or CJK brackets
260
+ * (`「...」`, `『...』`, `《...》`). Commit-message citations and other quoted prose
261
+ * (e.g. quoting a `chore: ... version-bump ...` log line, or the Korean
262
+ * "포인터 bump" phrase) are descriptive, not command invocations, so a forbidden
263
+ * keyword inside such a quote must not trip the guardrail. Backticks are excluded
264
+ * here on purpose — they denote shell/code snippets and are handled as command
265
+ * context in {@link isInsideBackticksOrFence}.
266
+ */
267
+ function isInsideQuotedSpan(text: string, matchStart: number, matchEnd: number): boolean {
268
+ // Paired quotes: an opening char before the match without its closer in
269
+ // between, and the matching closer after the match.
270
+ const pairs: Array<[string, string]> = [
271
+ ['“', '”'], ['‘', '’'], ['「', '」'], ['『', '』'], ['《', '》'],
272
+ ];
273
+ for (const [open, close] of pairs) {
274
+ const openIdx = text.lastIndexOf(open, matchStart - 1);
275
+ if (openIdx < 0) continue;
276
+ // No closer between the opener and the match → still open at the match.
277
+ if (text.indexOf(close, openIdx + open.length) >= matchEnd) return true;
278
+ }
279
+ // Symmetric quotes (" and '): odd count of the quote char before the match,
280
+ // within the same line (a quote does not span newlines), and a closing quote
281
+ // later on the line.
282
+ const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
283
+ const lineEnd = (() => { const i = text.indexOf('\n', matchEnd); return i < 0 ? text.length : i; })();
284
+ for (const q of ['"', "'"]) {
285
+ let count = 0;
286
+ for (let i = lineStart; i < matchStart; i++) if (text[i] === q) count++;
287
+ if (count % 2 === 1 && text.indexOf(q, matchEnd) >= 0 && text.indexOf(q, matchEnd) < lineEnd) {
288
+ // For "'" guard against the apostrophe-in-prose case (e.g. "don't"):
289
+ // require the opening quote to be preceded by a boundary (start/space/
290
+ // open-paren) so a contraction apostrophe is not read as a quote opener.
291
+ if (q === '"') return true;
292
+ // find the opening quote (the one making the count odd)
293
+ let openPos = -1, c = 0;
294
+ for (let i = lineStart; i < matchStart; i++) { if (text[i] === q) { c++; if (c % 2 === 1) openPos = i; } }
295
+ const beforeOpen = openPos > lineStart ? text[openPos - 1] : ' ';
296
+ if (/[\s(\[{>]/.test(beforeOpen) || openPos === lineStart) return true;
297
+ }
298
+ }
299
+ return false;
300
+ }
301
+
302
+ /**
303
+ * True if the keyword at [matchStart, matchEnd) is glued directly to a Unicode
304
+ * *letter* on either side with no separator — i.e. it is word-internal, part of a
305
+ * larger word rather than a standalone command token.
306
+ *
307
+ * The keyword regexes use `\b` boundaries, which already exclude an *ASCII-letter*
308
+ * suffix/prefix: `\bdeploy\b` does NOT match the "deploy" inside "deployed"
309
+ * (`y`→`e` is not a boundary). But `\b` fires a boundary between a Latin letter
310
+ * and a non-Latin letter, so `deploy된` ("deployed", Korean passive), `release한`,
311
+ * or `version-bump됨` DO match even though they are single conjugated/compound
312
+ * words in prose, not shell commands. That is the exact i18n asymmetry behind
313
+ * GUARDRAIL-I18N-FP: the English "deployed worker" passes (mid-prose, no command
314
+ * context) while the Korean "deploy된 워커" was flagged because the glued CJK
315
+ * suffix left the bare keyword sitting at line-start, which the command-context
316
+ * heuristic reads as a command invocation.
317
+ *
318
+ * We restore the symmetry `\b` provides for ASCII: a forbidden keyword fused to
319
+ * ANY Unicode letter (Hangul, Han, Kana, accented Latin, …) is a word-internal
320
+ * occurrence → descriptive prose, never a command. A genuine command keyword is
321
+ * always followed by a non-letter — whitespace, end-of-input, a path separator,
322
+ * a flag/redirect, or a shell metachar (`npm run deploy`, `wrangler deploy\n`,
323
+ * `version-bump.sh`, `npm publish && ...`).
324
+ */
325
+ function isGluedToLetterSuffix(text: string, matchStart: number, matchEnd: number): boolean {
326
+ // \p{L} = any Unicode letter; \p{M} = combining mark (e.g. Jamo/diacritics
327
+ // that compose with the adjacent letter). Either side counts as "glued".
328
+ const letterOrMark = /[\p{L}\p{M}]/u;
329
+ const next = matchEnd < text.length ? text[matchEnd] : '';
330
+ const prev = matchStart > 0 ? text[matchStart - 1] : '';
331
+ return letterOrMark.test(next) || letterOrMark.test(prev);
332
+ }
333
+
334
+ /**
335
+ * Decides whether a forbidden keyword match at [matchStart, matchEnd) should
336
+ * count as a real violation. A match counts only when it is in command context
337
+ * and not negated, and is NOT merely a path segment or quoted (descriptive)
338
+ * citation. Negation always wins (even inside a code block), per the conservative
339
+ * rule: code-block + negation → exclude; other code-block → keep.
340
+ */
341
+ function isRealMutationMatch(text: string, matchStart: number, matchEnd: number): boolean {
342
+ if (hasNegationBefore(text, matchStart)) return false;
343
+ if (hasTrailingNegation(text, matchEnd)) return false;
344
+ // A keyword fused to a non-ASCII letter ("deploy된", "release한") is a single
345
+ // conjugated/compound word in prose, not a command — `\b` only guards the
346
+ // ASCII-letter case, so restore the same symmetry for all Unicode letters.
347
+ if (isGluedToLetterSuffix(text, matchStart, matchEnd)) return false;
348
+ // A keyword that is part of a file path (build/Release, dist/release/) or
349
+ // sits inside quoted prose (a commit-message citation, "포인터 bump") is a
350
+ // description, not a command — suppress it even if it lands at line-start.
351
+ if (isInsidePathSegment(text, matchStart, matchEnd)) return false;
352
+ if (isInsideQuotedSpan(text, matchStart, matchEnd)) return false;
353
+ return isMutationKeywordInCommandContext(text, matchStart, matchEnd);
354
+ }
355
+
356
+ /**
357
+ * Runs a global regex over `text` and returns true if any match is a real
358
+ * mutation (command context, not negated).
359
+ */
360
+ function patternHasRealMutation(pattern: RegExp, text: string): boolean {
361
+ const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
362
+ let match: RegExpExecArray | null;
363
+ while ((match = re.exec(text)) !== null) {
364
+ if (isRealMutationMatch(text, match.index, match.index + match[0].length)) return true;
365
+ if (match.index === re.lastIndex) re.lastIndex++; // avoid zero-width loop
366
+ }
367
+ return false;
368
+ }
369
+
370
+ /**
371
+ * Git subcommands that mutate the working tree, index, refs, or remote.
372
+ * `stash` and `checkout` are intentionally absent here: they have read-only
373
+ * variants (`git stash list`/`show`, `git checkout-index`) and are classified
374
+ * token-by-token in {@link detectGitMutation} rather than by bare keyword.
375
+ */
376
+ const GIT_MUTATION_SUBCOMMANDS = new Set([
377
+ 'add', 'commit', 'push', 'reset', 'rebase', 'clean', 'switch', 'merge',
378
+ 'tag', 'restore', 'rm', 'mv', 'cherry-pick', 'revert', 'pull', 'fetch',
379
+ 'am', 'apply', 'gc', 'prune',
380
+ ]);
381
+
382
+ /**
383
+ * Read-only `git stash` variants. Any other `git stash <x>` (pop/apply/drop/
384
+ * push/save/clear, or bare `git stash` which defaults to push) is a mutation.
385
+ */
386
+ const GIT_STASH_READONLY_SUBCOMMANDS = new Set(['list', 'show']);
387
+
388
+ /**
389
+ * Detects a true git mutation in free-text task message, token-aware so that
390
+ * read-only diagnostics (`git stash list`, `git stash show --stat`,
391
+ * `git checkout-index`, `git status`, `git diff`, `git log`, ...) are allowed.
392
+ * Returns true only when a genuine mutating git invocation is present.
393
+ */
394
+ function detectGitMutation(message: string): boolean {
395
+ const re = /\bgit\s+([a-z][a-z0-9-]*)/gi;
396
+ let match: RegExpExecArray | null;
397
+ while ((match = re.exec(message)) !== null) {
398
+ const sub = match[1].toLowerCase();
399
+ // Only treat a mutating `git <sub>` as a real violation when it appears
400
+ // as an actual command (code/command context) and is not negated. Plain
401
+ // prose mentions ("don't git reset", "we won't push") are ignored.
402
+ const isReal = () => isRealMutationMatch(message, match!.index, match!.index + match![0].length);
403
+ if (GIT_MUTATION_SUBCOMMANDS.has(sub)) {
404
+ if (isReal()) return true;
405
+ continue;
406
+ }
407
+ if (sub === 'stash') {
408
+ // Token following `git stash`; read-only only for list/show.
409
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
410
+ const next = after ? after[1].toLowerCase() : '';
411
+ if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next) && isReal()) return true; // bare stash = push, or pop/apply/drop/...
412
+ } else if (sub === 'checkout') {
413
+ // `git checkout <ref/path>` mutates; `git checkout-index` is matched
414
+ // as its own token by the regex (sub === 'checkout-index') and is read-only.
415
+ if (isReal()) return true;
416
+ } else if (sub === 'submodule') {
417
+ // `git submodule update` mutates; `git submodule status` is read-only.
418
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
419
+ const next = after ? after[1].toLowerCase() : '';
420
+ if ((next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') && isReal()) return true;
421
+ } else if (sub === 'worktree') {
422
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
423
+ const next = after ? after[1].toLowerCase() : '';
424
+ if ((next === 'add' || next === 'remove' || next === 'move' || next === 'prune') && isReal()) return true;
425
+ }
426
+ // checkout-index, stash-with-no-next-already-handled, status/diff/log/show/
427
+ // rev-parse/branch/submodule status fall through as read-only.
428
+ }
429
+ return false;
430
+ }
431
+
432
+ export function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined {
433
+ if (typeof value !== 'string') return undefined;
434
+ const normalized = value.trim() as MeshTaskMode;
435
+ return (MESH_TASK_MODES as string[]).includes(normalized) ? normalized : undefined;
436
+ }
437
+
438
+ export function validateMeshTaskModeRequest(mode: unknown, message: string, readonly?: boolean): MeshTaskModeValidationResult {
439
+ const taskMode = normalizeMeshTaskMode(mode);
440
+ // QUEUE-NODE-SERIALIZATION: the write guardrail (reject deploy/push/edit commands on a
441
+ // read-only task) is driven by the unified read-only axis, not by the enum alone — so a
442
+ // task flagged read-only via the explicit `readonly:true` boolean is guarded identically
443
+ // to a legacy live_debug_readonly task. isTaskReadonly is the single classifier.
444
+ const isReadonly = isTaskReadonly({ readonly, taskMode });
445
+ if (!isReadonly) {
446
+ return taskMode ? { valid: true, taskMode, violations: [] } : { valid: true, violations: [] };
447
+ }
448
+ const text = message || '';
449
+ // Only flag keywords that look like real commands (code/command context) and
450
+ // are not negated — descriptive/prohibitive prose ("don't run `npm publish`",
451
+ // "read-only, no deploy") must not trip the guardrail.
452
+ const violations = LIVE_DEBUG_READONLY_FORBIDDEN
453
+ .filter(rule => patternHasRealMutation(rule.pattern, text))
454
+ .map(rule => rule.label);
455
+ if (detectGitMutation(text)) {
456
+ violations.push('git_mutation');
457
+ }
458
+ return {
459
+ valid: violations.length === 0,
460
+ taskMode,
461
+ violations,
462
+ allowedOperations: [
463
+ 'process/log/window/port/session inspection',
464
+ 'read-only filesystem listing/reading',
465
+ 'status probes and keep-running handle reporting',
466
+ 'diagnostic summaries without source edits, commits, checkpoints, pushes, deploys, resets, rebases, or destructive cleanups',
467
+ ],
468
+ };
469
+ }
12
470
 
13
471
  export interface MeshWorkQueueEntry {
14
472
  id: string;
15
473
  meshId: string;
16
474
  message: string;
17
475
  status: MeshTaskStatus;
476
+ taskMode?: MeshTaskMode;
477
+ /**
478
+ * QUEUE-NODE-SERIALIZATION: explicit read-only axis, orthogonal to taskMode. When
479
+ * true the task is treated as read-only by every scheduling gate (no node-busy
480
+ * isolation, counted under the read-only cap, write commands rejected) regardless of
481
+ * its taskMode. Decided exclusively through {@link isTaskReadonly}; `taskMode ===
482
+ * 'live_debug_readonly'` remains an OR-fallback so legacy rows behave unchanged.
483
+ */
484
+ readonly?: boolean;
18
485
  /** If specified, only this node can claim the task (used by legacy mesh_send_task) */
19
486
  targetNodeId?: string;
20
487
  /** If specified, only this runtime session can claim the task */
21
488
  targetSessionId?: string;
489
+ /** If specified, a node must expose all tags before it can claim the task. */
490
+ requiredTags?: string[];
491
+ /**
492
+ * M1: ids of tasks that must reach 'completed' before this task is claimable.
493
+ * Forward references (ids not yet enqueued) are allowed for batch flows and
494
+ * simply keep the task waiting until the referenced task exists and completes.
495
+ */
496
+ dependsOn?: string[];
497
+ /** M1/M3: mission this task belongs to (joins mesh_missions). */
498
+ missionId?: string;
499
+ /**
500
+ * MAGI: consensus group id shared by every replica of one mesh_magi_review
501
+ * fan-out. Marks the task as part of an INTENTIONAL same-prompt quorum so the
502
+ * completion-event dedup (mesh-events-pending) never collapses grouped
503
+ * replicas. Absent on ordinary tasks. Rides in the payload JSON (no column).
504
+ */
505
+ consensusGroupId?: string;
506
+ /**
507
+ * MAGI-KIND-PANEL (model axis): model override for the session that executes this
508
+ * task. When the task auto-launches a session, this is passed to launch_cli as
509
+ * `initialModel` (ACP → setConfigOption; CLI → modelLaunchArgs template). Absent on
510
+ * ordinary tasks. Rides in the payload JSON (no column). Best-effort — a provider
511
+ * that cannot honor the model still runs the task (never a fatal launch error).
512
+ */
513
+ model?: string;
514
+ /**
515
+ * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
516
+ * Only set by the system on dependency failure under the 'block' policy;
517
+ * waiting-on-dependency state is computed at view time, not stored.
518
+ */
519
+ blockedReason?: string;
22
520
  /** The node that actually claimed and is executing the task */
23
521
  assignedNodeId?: string;
24
522
  /** The session currently executing the task */
25
523
  assignedSessionId?: string;
524
+ /**
525
+ * Provider type of the session that claimed the task. Recorded so the queue
526
+ * can enforce per-(node, provider) maxParallel caps (RepoMeshNodePolicy
527
+ * providerRoles) by counting active assignments grouped by node + provider.
528
+ */
529
+ assignedProviderType?: string;
26
530
  /** Human/operator reason for terminal cancellation. */
27
531
  cancelReason?: string;
28
532
  cancelledAt?: string;
@@ -30,6 +534,16 @@ export interface MeshWorkQueueEntry {
30
534
  requeueReason?: string;
31
535
  requeuedAt?: string;
32
536
  requeueCount?: number;
537
+ /** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
538
+ maxRetries?: number;
539
+ /**
540
+ * Bug B: number of times the reconcile assigned-stranded watchdog has reclaimed this
541
+ * row from 'assigned' back to 'pending' because its dispatch was never confirmed
542
+ * delivered. Separate from requeueCount (operator/execution retries) and bounded by
543
+ * MAX_STRANDED_RECLAIMS so a permanently-undeliverable target auto-fails rather than
544
+ * cycling reclaim→re-dispatch→strand forever.
545
+ */
546
+ strandedReclaimCount?: number;
33
547
  /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
34
548
  autoLaunch?: {
35
549
  status: 'skipped' | 'started' | 'failed' | 'completed';
@@ -41,54 +555,211 @@ export interface MeshWorkQueueEntry {
41
555
  };
42
556
  /** ISO timestamp when the task was dispatched (assigned) to a node/session. Used for precise matching on completion. */
43
557
  dispatchTimestamp?: string;
558
+ /**
559
+ * (3) The ORIGINATING coordinator session that enqueued this task. Stamped onto the
560
+ * worker at dispatch (meshCoordinatorSessionId) so the task's completion routes back to
561
+ * the exact coordinator session — even when several coordinator sessions share one
562
+ * daemon. Rides in the queue payload JSON (no column migration); absent on legacy rows
563
+ * → daemon-level routing fallback (backward + version-skew safe).
564
+ */
565
+ sourceCoordinatorSessionId?: string;
44
566
  createdAt: string;
45
567
  updatedAt: string;
46
568
  }
47
569
 
48
- function getQueuePath(meshId: string): string {
49
- const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
50
- return join(getLedgerDir(), `${safe}.queue.json`);
570
+ export interface MeshQueueMutationOptions {
571
+ ownerRole?: RepoMeshDaemonRole;
572
+ }
573
+
574
+ export function normalizeMeshCapabilityTags(value: unknown): string[] {
575
+ if (!Array.isArray(value)) return [];
576
+ const seen = new Set<string>();
577
+ return value
578
+ .map(tag => typeof tag === 'string' ? tag.trim() : '')
579
+ .filter(Boolean)
580
+ .filter(tag => {
581
+ if (seen.has(tag)) return false;
582
+ seen.add(tag);
583
+ return true;
584
+ });
585
+ }
586
+
587
+ function firstProviderPriority(policy: unknown): string | undefined {
588
+ const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
589
+ ? (policy as Record<string, unknown>).providerPriority
590
+ : undefined;
591
+ if (!Array.isArray(raw)) return undefined;
592
+ return raw.find(type => typeof type === 'string' && type.trim())?.trim();
51
593
  }
52
594
 
53
- function getLockPath(meshId: string): string {
54
- const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
55
- return join(getLedgerDir(), `${safe}.queue.lock`);
595
+ function readNodeOverride(node: { userOverrides?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
596
+ const overrides = node?.userOverrides;
597
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return null;
598
+ const value = (overrides as Record<string, unknown>)[key];
599
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
56
600
  }
57
601
 
58
602
  /**
59
- * Simple advisory file lock using O_EXCL (atomic create) for queue mutations.
60
- * Retries up to 10 times at 30 ms intervals; proceeds without lock on timeout
61
- * to prevent deadlock (best-effort far better than no locking at all).
603
+ * Live, self-reported platform/arch the owning daemon stamped onto the node from
604
+ * its own process.platform/process.arch via the git_status envelope. Kept on a
605
+ * field DISTINCT from userOverrides so capability-tag derivation can prefer an
606
+ * explicit operator override while still self-healing auto-detected nodes — and
607
+ * so the value reflects the node's real OS rather than the coordinator's.
62
608
  */
63
- function withQueueLock<T>(meshId: string, fn: () => T): T {
64
- const lockPath = getLockPath(meshId);
65
- let fd = -1;
66
- for (let i = 0; i < 10; i++) {
67
- try { fd = openSync(lockPath, 'wx'); break; } catch {
68
- const deadline = Date.now() + 30;
69
- while (Date.now() < deadline) { /* spin */ }
70
- }
71
- }
72
- try { return fn(); } finally {
73
- if (fd !== -1) try { closeSync(fd); } catch { /* noop */ }
74
- try { unlinkSync(lockPath); } catch { /* already removed */ }
75
- }
609
+ function readNodeReporter(node: { reportedPlatform?: unknown; reportedArch?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
610
+ const value = key === 'platform' ? node?.reportedPlatform : node?.reportedArch;
611
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
76
612
  }
77
613
 
78
- function readQueue(meshId: string): MeshWorkQueueEntry[] {
79
- const path = getQueuePath(meshId);
80
- if (!existsSync(path)) return [];
614
+ export function buildMeshNodeCapabilityTags(
615
+ node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown; reportedPlatform?: unknown; reportedArch?: unknown } | undefined,
616
+ providerType?: string,
617
+ ): string[] {
618
+ const provider = typeof providerType === 'string' && providerType.trim()
619
+ ? providerType.trim()
620
+ : firstProviderPriority(node?.policy);
621
+ const worktreeBranch = typeof node?.worktreeBranch === 'string' && node.worktreeBranch.trim()
622
+ ? node.worktreeBranch.trim()
623
+ : null;
624
+ // Per-node platform/arch precedence (highest → lowest):
625
+ // 1. userOverrides.platform/arch — an EXPLICIT operator override always wins.
626
+ // 2. reportedPlatform/reportedArch — the live OS the owning daemon
627
+ // self-reported (its own process.platform/process.arch via the git_status
628
+ // envelope), persisted to the node record on each direct probe. This is
629
+ // why a Windows member advertises os=win32 even though the COORDINATOR
630
+ // computing these tags runs on darwin — without it the consumer reads the
631
+ // persistent node (operator userOverrides empty) and would fall straight
632
+ // through to the coordinator's own process.platform, mislabeling every
633
+ // node os=darwin. We prefer this LIVE value over any stale auto-stamp.
634
+ // 3. process.platform/process.arch — last-resort fallback, correct only for
635
+ // the local coordinator node / local worktree nodes that have not yet
636
+ // been probed (their workspace lives on THIS machine anyway).
637
+ // Vocabulary is raw process.platform/process.arch ("darwin"/"win32"/"linux",
638
+ // "arm64"/"x64") on both the advertiser and the required_tags matcher, which
639
+ // compares with plain string equality (nodeSatisfiesRequiredTags) — so this
640
+ // keeps the win32/darwin/linux vocabulary the matcher already expects.
641
+ const os = readNodeOverride(node, 'platform') ?? readNodeReporter(node, 'platform') ?? process.platform;
642
+ const arch = readNodeOverride(node, 'arch') ?? readNodeReporter(node, 'arch') ?? process.arch;
643
+ return normalizeMeshCapabilityTags([
644
+ ...(Array.isArray(node?.capabilities) ? node.capabilities : []),
645
+ `os=${os}`,
646
+ `arch=${arch}`,
647
+ ...(provider ? [`provider=${provider}`] : []),
648
+ // Worktree nodes automatically expose a "worktree=<branch>" tag so that
649
+ // mesh_enqueue_task with required_tags: ["worktree=<branch>"] routes
650
+ // only to the matching worktree node.
651
+ ...(node?.isLocalWorktree === true && worktreeBranch ? [`worktree=${worktreeBranch}`] : []),
652
+ // Convergence routing: advertise how this node can land its work onto base.
653
+ // - converge=refine: local worktree nodes (on ANY machine — refine_mesh_node
654
+ // now forwards to the owning daemon) can run the Refinery merge → push →
655
+ // cleanup against their own checkout, so they accept code_change tasks.
656
+ // - converge=fast_forward: non-worktree nodes (the machine itself) can only
657
+ // ff/push an already-converged branch; they are NOT a destination for
658
+ // code_change work (a worktree is created first, and that worktree node
659
+ // receives the task instead). Reuses the ordinary required-tags filter —
660
+ // the load-balancing scheduler auto-injects converge=refine for code_change
661
+ // so such work is hard-filtered onto refine-capable nodes.
662
+ ...(node?.isLocalWorktree === true ? ['converge=refine'] : ['converge=fast_forward']),
663
+ ]);
664
+ }
665
+
666
+ export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean {
667
+ const required = normalizeMeshCapabilityTags(requiredTags);
668
+ if (required.length === 0) return true;
669
+ const available = new Set(normalizeMeshCapabilityTags(capabilityTags));
670
+ return required.every(tag => available.has(tag));
671
+ }
672
+
673
+ /**
674
+ * Convergence-aware required-tags resolution (load-balancing scheduler, opt-in).
675
+ *
676
+ * When the mesh enables policy.autoConvergeCodeChange, a `converge=refine` required
677
+ * tag is merged into a code_change task's required tags at enqueue time, so the
678
+ * scheduler hard-filters the task onto refine-capable worktree nodes only (on any
679
+ * machine — refine_mesh_node forwards to the owning daemon). Because the tag is
680
+ * persisted on the queue entry, BOTH the eligibility scan (maybeAutoLaunchOneQueueSession)
681
+ * and the claim transaction (claimNextQueueTask → nodeSatisfiesRequiredTags) enforce
682
+ * it consistently.
683
+ *
684
+ * Strict backward compatibility — the injection is skipped (returns the explicit tags
685
+ * unchanged) when ANY of:
686
+ * - the mesh does not opt in (autoConvergeCodeChange !== true), or
687
+ * - the task is not code_change (validation / live_debug_readonly / launch_app /
688
+ * convergence carry no merge cost and may run anywhere), or
689
+ * - the task is explicitly targeted (targetNodeId): the operator chose the node, so
690
+ * we do not second-guess it by filtering on convergence capability.
691
+ * Idempotent: normalizeMeshCapabilityTags dedupes, so re-injection is a no-op.
692
+ */
693
+ export function resolveConvergeRequiredTags(
694
+ meshId: string,
695
+ taskMode: MeshTaskMode | undefined,
696
+ explicitRequiredTags: string[],
697
+ opts?: { targetNodeId?: string },
698
+ ): string[] {
699
+ if (taskMode !== 'code_change') return explicitRequiredTags;
700
+ if (typeof opts?.targetNodeId === 'string' && opts.targetNodeId.trim()) return explicitRequiredTags;
701
+ let optedIn = false;
81
702
  try {
82
- const content = readFileSync(path, 'utf-8');
83
- return JSON.parse(content) as MeshWorkQueueEntry[];
703
+ optedIn = resolveAutoConvergeCodeChange(getMesh(meshId)?.policy as any);
84
704
  } catch {
85
- return [];
705
+ optedIn = false;
86
706
  }
707
+ if (!optedIn) return explicitRequiredTags;
708
+ return normalizeMeshCapabilityTags([...explicitRequiredTags, MESH_CONVERGE_REFINE_TAG]);
709
+ }
710
+
711
+ function withQueueLock<T>(_meshId: string, fn: () => T): T {
712
+ return MeshRuntimeStore.getInstance().transaction(fn);
713
+ }
714
+
715
+ function readQueue(meshId: string): MeshWorkQueueEntry[] {
716
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId);
87
717
  }
88
718
 
89
719
  function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
90
- const path = getQueuePath(meshId);
91
- writeFileSync(path, JSON.stringify(queue, null, 2), 'utf-8');
720
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
721
+ }
722
+
723
+ function normalizeDependsOn(value: unknown): string[] {
724
+ if (!Array.isArray(value)) return [];
725
+ const seen = new Set<string>();
726
+ return value
727
+ .map(id => typeof id === 'string' ? id.trim() : '')
728
+ .filter(Boolean)
729
+ .filter(id => {
730
+ if (seen.has(id)) return false;
731
+ seen.add(id);
732
+ return true;
733
+ });
734
+ }
735
+
736
+ /**
737
+ * M1: detect dependency cycles before enqueue. Walks the dependency graph of
738
+ * existing queue entries plus the new task's edges. Fail-closed: a cycle
739
+ * rejects the enqueue entirely. Synchronous and bounded by queue size.
740
+ */
741
+ export function assertNoDependencyCycle(meshId: string, newTaskId: string, dependsOn: string[]): void {
742
+ if (dependsOn.length === 0) return;
743
+ if (dependsOn.includes(newTaskId)) {
744
+ throw new Error(`dependency_cycle_detected: task '${newTaskId}' cannot depend on itself`);
745
+ }
746
+ const adjacency = new Map<string, string[]>();
747
+ for (const entry of readQueue(meshId)) {
748
+ adjacency.set(entry.id, normalizeDependsOn(entry.dependsOn));
749
+ }
750
+ adjacency.set(newTaskId, dependsOn);
751
+ // DFS from the new task: if we can reach newTaskId again, the edges form a cycle.
752
+ const stack = [...dependsOn];
753
+ const visited = new Set<string>();
754
+ while (stack.length > 0) {
755
+ const current = stack.pop()!;
756
+ if (current === newTaskId) {
757
+ throw new Error(`dependency_cycle_detected: task '${newTaskId}' is part of a dependency cycle via '${dependsOn.join(', ')}'`);
758
+ }
759
+ if (visited.has(current)) continue;
760
+ visited.add(current);
761
+ stack.push(...(adjacency.get(current) ?? []));
762
+ }
92
763
  }
93
764
 
94
765
  /**
@@ -97,22 +768,161 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
97
768
  export function enqueueTask(
98
769
  meshId: string,
99
770
  message: string,
100
- opts?: { targetNodeId?: string; targetSessionId?: string }
771
+ opts?: {
772
+ targetNodeId?: string;
773
+ targetSessionId?: string;
774
+ taskMode?: MeshTaskMode | string;
775
+ /** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
776
+ readonly?: boolean;
777
+ requiredTags?: string[];
778
+ /** M1: tasks that must complete before this one is claimable. */
779
+ dependsOn?: string[];
780
+ /** M1/M3: mission this task belongs to. */
781
+ missionId?: string;
782
+ /** MAGI: consensus group id shared by every replica of a mesh_magi_review fan-out. */
783
+ consensusGroupId?: string;
784
+ /** MAGI-KIND-PANEL: model override forwarded to the executing session's launch (initialModel). */
785
+ model?: string;
786
+ /** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
787
+ id?: string;
788
+ /** (3) Originating coordinator session id (for session-anchored completion routing). */
789
+ sourceCoordinatorSessionId?: string;
790
+ } & MeshQueueMutationOptions,
101
791
  ): MeshWorkQueueEntry {
792
+ requireMeshHostQueueOwner(opts);
793
+ const readonly = opts?.readonly === true;
794
+ const modeValidation = validateMeshTaskModeRequest(opts?.taskMode, message, readonly);
795
+ if (!modeValidation.valid) {
796
+ throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
797
+ }
798
+ const id = typeof opts?.id === 'string' && opts.id.trim() ? opts.id.trim() : randomUUID();
799
+ const dependsOn = normalizeDependsOn(opts?.dependsOn);
102
800
  return withQueueLock(meshId, () => {
103
- const queue = readQueue(meshId);
801
+ if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, id)) {
802
+ throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
803
+ }
804
+ assertNoDependencyCycle(meshId, id, dependsOn);
805
+ const callerTags = normalizeMeshCapabilityTags(opts?.requiredTags);
806
+ // Convergence routing (opt-in): auto-inject converge=refine for code_change
807
+ // tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
808
+ // the mesh opts in; explicit target_node_id / required_tags are preserved.
809
+ // Routing is otherwise governed solely by the caller's required_tags (hard
810
+ // filter through nodeSatisfiesRequiredTags) — no role/taskMode auto-routing.
811
+ const resolvedRequiredTags = resolveConvergeRequiredTags(
812
+ meshId,
813
+ modeValidation.taskMode,
814
+ callerTags,
815
+ { targetNodeId: opts?.targetNodeId },
816
+ );
104
817
  const entry: MeshWorkQueueEntry = {
105
- id: randomUUID(),
818
+ id,
106
819
  meshId,
107
820
  message,
108
821
  status: 'pending',
822
+ taskMode: modeValidation.taskMode,
823
+ ...(readonly ? { readonly: true } : {}),
109
824
  targetNodeId: opts?.targetNodeId,
110
825
  targetSessionId: opts?.targetSessionId,
826
+ requiredTags: resolvedRequiredTags,
827
+ ...(dependsOn.length > 0 ? { dependsOn } : {}),
828
+ ...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
829
+ ...(typeof opts?.consensusGroupId === 'string' && opts.consensusGroupId.trim() ? { consensusGroupId: opts.consensusGroupId.trim() } : {}),
830
+ ...(typeof opts?.model === 'string' && opts.model.trim() ? { model: opts.model.trim() } : {}),
831
+ ...(typeof opts?.sourceCoordinatorSessionId === 'string' && opts.sourceCoordinatorSessionId.trim()
832
+ ? { sourceCoordinatorSessionId: opts.sourceCoordinatorSessionId.trim() }
833
+ : {}),
111
834
  createdAt: new Date().toISOString(),
112
835
  updatedAt: new Date().toISOString(),
113
836
  };
114
- queue.push(entry);
115
- writeQueue(meshId, queue);
837
+ MeshRuntimeStore.getInstance().insertQueueEntry(entry);
838
+ return entry;
839
+ });
840
+ }
841
+
842
+ /**
843
+ * Record a direct-dispatch task (mesh_send_task) as an already-assigned queue
844
+ * entry so it is attributable to a mission.
845
+ *
846
+ * Direct dispatch normally bypasses the queue entirely — the task lives only in
847
+ * the ledger + mesh_direct_dispatches table, neither of which carries a
848
+ * missionId, so {@link summarizeMissionTasks}/{@link computeMeshTaskStats}
849
+ * (which both scan the queue for `task.missionId`) count it as 0. When a
850
+ * mission is attached, we materialise the same queue entry shape an enqueued
851
+ * task would have, but pre-assigned to the dispatched node/session and stamped
852
+ * with the dispatch timestamp. The terminal event path (updateSessionTaskStatus
853
+ * → findAssignedBySession) then flips it to completed/failed exactly like a
854
+ * pulled task, so mission total + completed aggregates work with no extra wiring.
855
+ *
856
+ * Intentionally separate from {@link enqueueTask}: enqueue creates `pending`
857
+ * work for the queue to assign, whereas this records work already dispatched
858
+ * out-of-band. They share the missionId stamping rule and mode validation.
859
+ */
860
+ export function recordDirectDispatchTask(
861
+ meshId: string,
862
+ message: string,
863
+ opts: {
864
+ id: string;
865
+ missionId: string;
866
+ assignedNodeId?: string;
867
+ assignedSessionId?: string;
868
+ taskMode?: MeshTaskMode | string;
869
+ /** QUEUE-NODE-SERIALIZATION: explicit read-only axis (orthogonal to taskMode). */
870
+ readonly?: boolean;
871
+ dispatchedAt?: string;
872
+ },
873
+ ): MeshWorkQueueEntry | null {
874
+ const missionId = typeof opts.missionId === 'string' ? opts.missionId.trim() : '';
875
+ if (!missionId) return null;
876
+ const taskId = typeof opts.id === 'string' ? opts.id.trim() : '';
877
+ if (!taskId) return null;
878
+ const readonly = opts.readonly === true;
879
+ const modeValidation = validateMeshTaskModeRequest(opts.taskMode, message, readonly);
880
+ if (!modeValidation.valid) {
881
+ throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
882
+ }
883
+ const now = opts.dispatchedAt && opts.dispatchedAt.trim() ? opts.dispatchedAt : new Date().toISOString();
884
+ return withQueueLock(meshId, () => {
885
+ if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId)) {
886
+ // Already materialised (e.g. retry of the same dispatch) — leave it untouched.
887
+ return null;
888
+ }
889
+ const entry: MeshWorkQueueEntry = {
890
+ id: taskId,
891
+ meshId,
892
+ message,
893
+ status: 'assigned',
894
+ ...(modeValidation.taskMode ? { taskMode: modeValidation.taskMode } : {}),
895
+ ...(readonly ? { readonly: true } : {}),
896
+ missionId,
897
+ ...(opts.assignedNodeId ? { targetNodeId: opts.assignedNodeId, assignedNodeId: opts.assignedNodeId } : {}),
898
+ ...(opts.assignedSessionId ? { targetSessionId: opts.assignedSessionId, assignedSessionId: opts.assignedSessionId } : {}),
899
+ dispatchTimestamp: now,
900
+ createdAt: now,
901
+ updatedAt: now,
902
+ };
903
+ MeshRuntimeStore.getInstance().insertQueueEntry(entry);
904
+ // R2 / NOTIF-DROP: a mission-attributed DIRECT dispatch (mesh_send_task) has
905
+ // already been handed to the transport by the time we materialise this assigned
906
+ // row — unlike a queue claim, there is no later delivery-confirmation write for
907
+ // it. Without a confirmed delivery record keyed by this taskId, the assigned-
908
+ // stranded watchdog (recoverStrandedAssignedDispatches → taskHasConfirmedDelivery)
909
+ // sees the row as never-confirmed after ASSIGNED_STRANDED_DEADLINE_MS and reclaims
910
+ // a task the worker already COMPLETED, dropping its agent:generating_completed
911
+ // (live PROBE-B repro: "never confirmed delivered → pending"). Record a confirmed
912
+ // delivery here so taskHasConfirmedDelivery() is true and the watchdog leaves the
913
+ // row to PHASE 4 completion reconcile. This point is only reached after the direct
914
+ // dispatch's result.success, so 'delivered' is the accurate state.
915
+ try {
916
+ createSessionDelivery({
917
+ meshId,
918
+ ...(opts.assignedNodeId ? { nodeId: opts.assignedNodeId } : {}),
919
+ ...(opts.assignedSessionId ? { sessionId: opts.assignedSessionId } : {}),
920
+ taskId,
921
+ kind: 'task',
922
+ message,
923
+ status: 'delivered',
924
+ });
925
+ } catch { /* best-effort — the assigned row is already recorded */ }
116
926
  return entry;
117
927
  });
118
928
  }
@@ -121,43 +931,83 @@ export function enqueueTask(
121
931
  * Get all tasks in the queue, optionally filtered by status.
122
932
  */
123
933
  export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
124
- let queue = readQueue(meshId);
125
- if (opts?.status?.length) {
126
- const statuses = new Set(opts.status);
127
- queue = queue.filter(q => statuses.has(q.status));
128
- }
129
- return queue;
934
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
935
+ }
936
+
937
+ export function getMeshQueueRevision(meshId: string): string {
938
+ return MeshRuntimeStore.getInstance().getQueueRevision(meshId);
130
939
  }
131
940
 
132
941
  /**
133
942
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
943
+ *
944
+ * `opts.providerType` is stamped onto the claimed entry (assignedProviderType) so
945
+ * per-(node, provider) caps can be counted. `opts.providerMaxParallel`, when set,
946
+ * is the enforced per-(node, provider) cap from RepoMeshNodePolicy.providerRoles:
947
+ * a task is not assigned to this (node, provider) once it already has that many
948
+ * active assignments. This composes with the global/taskMode caps (stricter wins).
134
949
  */
135
- export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
136
- return withQueueLock(meshId, () => {
137
- const queue = readQueue(meshId);
138
- const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
139
- q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
140
- ));
141
- if (hasActiveAssignment) return null;
142
- let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
143
- if (targetIdx === -1) {
144
- targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
145
- }
146
- if (targetIdx === -1) {
147
- targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
950
+ export function claimNextTask(
951
+ meshId: string,
952
+ nodeId: string,
953
+ sessionId: string,
954
+ capabilityTags?: string[],
955
+ opts?: { providerType?: string; providerMaxParallel?: number; nodeIsWorktree?: boolean },
956
+ ): MeshWorkQueueEntry | null {
957
+ return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags, opts);
958
+ }
959
+
960
+ // ─── M1: Dependency Failure Propagation ─────────
961
+
962
+ export type DependencyFailurePolicy = 'block' | 'cancel';
963
+
964
+ function resolveDependencyFailurePolicy(meshId: string): DependencyFailurePolicy {
965
+ try {
966
+ const policy = (getMesh(meshId)?.policy ?? {}) as Record<string, unknown>;
967
+ return policy.onDependencyFailure === 'cancel' ? 'cancel' : 'block';
968
+ } catch {
969
+ return 'block';
970
+ }
971
+ }
972
+
973
+ /**
974
+ * Apply the mesh's onDependencyFailure policy to pending dependents of a task
975
+ * that just reached a failed/cancelled terminal state.
976
+ *
977
+ * - 'block' (default): dependents stay pending with blockedReason
978
+ * "dependency_failed:<taskId>" so an operator can requeue/cancel them.
979
+ * - 'cancel': dependents are cancelled (cascading to their own dependents).
980
+ *
981
+ * Must be called inside the queue lock of the triggering transition.
982
+ */
983
+ function propagateDependencyFailure(meshId: string, failedTaskId: string): void {
984
+ const policy = resolveDependencyFailurePolicy(meshId);
985
+ const store = MeshRuntimeStore.getInstance();
986
+ const frontier = [failedTaskId];
987
+ const seen = new Set<string>(frontier);
988
+ while (frontier.length > 0) {
989
+ const currentId = frontier.pop()!;
990
+ const dependents = store.getQueueEntries(meshId, ['pending'])
991
+ .filter(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(currentId));
992
+ for (const dependent of dependents) {
993
+ if (seen.has(dependent.id)) continue;
994
+ seen.add(dependent.id);
995
+ if (policy === 'cancel') {
996
+ dependent.status = 'cancelled';
997
+ dependent.cancelledAt = new Date().toISOString();
998
+ dependent.cancelReason = `dependency_failed:${currentId}`;
999
+ store.updateQueueEntry(dependent);
1000
+ frontier.push(dependent.id); // cascade to transitive dependents
1001
+ } else {
1002
+ dependent.blockedReason = `dependency_failed:${currentId}`;
1003
+ store.updateQueueEntry(dependent);
1004
+ }
148
1005
  }
149
- if (targetIdx === -1) return null;
150
- const entry = queue[targetIdx];
151
- entry.status = 'assigned';
152
- entry.assignedNodeId = nodeId;
153
- entry.assignedSessionId = sessionId;
154
- entry.dispatchTimestamp = new Date().toISOString();
155
- entry.updatedAt = new Date().toISOString();
156
- writeQueue(meshId, queue);
157
- return entry;
158
- });
1006
+ }
159
1007
  }
160
1008
 
1009
+ const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
1010
+
161
1011
  /**
162
1012
  * Update the status of a specific task.
163
1013
  * Used when a session completes, fails, or stalls.
@@ -166,15 +1016,19 @@ export function updateTaskStatus(
166
1016
  meshId: string,
167
1017
  taskId: string,
168
1018
  status: MeshTaskStatus,
1019
+ opts?: MeshQueueMutationOptions,
169
1020
  ): MeshWorkQueueEntry | null {
1021
+ requireMeshHostQueueOwner(opts);
170
1022
  return withQueueLock(meshId, () => {
171
- const queue = readQueue(meshId);
172
- const idx = queue.findIndex(q => q.id === taskId);
173
- if (idx === -1) return null;
174
- queue[idx].status = status;
175
- queue[idx].updatedAt = new Date().toISOString();
176
- writeQueue(meshId, queue);
177
- return queue[idx];
1023
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1024
+ if (!entry) return null;
1025
+ entry.status = status;
1026
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1027
+ // Any transition OFF `assigned` ends the single-flight dispatch window (terminal
1028
+ // completion/failure, or the dispatch-failure requeue to `pending`).
1029
+ if (status !== 'assigned') endTaskDispatchInFlight(meshId, taskId);
1030
+ if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, taskId);
1031
+ return entry;
178
1032
  });
179
1033
  }
180
1034
 
@@ -184,14 +1038,12 @@ export function recordTaskAutoLaunch(
184
1038
  autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
185
1039
  ): MeshWorkQueueEntry | null {
186
1040
  return withQueueLock(meshId, () => {
187
- const queue = readQueue(meshId);
188
- const idx = queue.findIndex(q => q.id === taskId);
189
- if (idx === -1) return null;
1041
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1042
+ if (!entry) return null;
190
1043
  const now = new Date().toISOString();
191
- queue[idx].autoLaunch = { ...autoLaunch, updatedAt: now };
192
- queue[idx].updatedAt = now;
193
- writeQueue(meshId, queue);
194
- return queue[idx];
1044
+ entry.autoLaunch = { ...autoLaunch, updatedAt: now };
1045
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1046
+ return entry;
195
1047
  });
196
1048
  }
197
1049
 
@@ -201,19 +1053,20 @@ export function recordTaskAutoLaunch(
201
1053
  export function cancelTask(
202
1054
  meshId: string,
203
1055
  taskId: string,
204
- opts?: { reason?: string },
1056
+ opts?: { reason?: string } & MeshQueueMutationOptions,
205
1057
  ): MeshWorkQueueEntry | null {
1058
+ requireMeshHostQueueOwner(opts);
206
1059
  return withQueueLock(meshId, () => {
207
- const queue = readQueue(meshId);
208
- const idx = queue.findIndex(q => q.id === taskId);
209
- if (idx === -1) return null;
1060
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1061
+ if (!entry) return null;
210
1062
  const now = new Date().toISOString();
211
- queue[idx].status = 'cancelled';
212
- queue[idx].updatedAt = now;
213
- queue[idx].cancelledAt = now;
214
- if (opts?.reason) queue[idx].cancelReason = opts.reason;
215
- writeQueue(meshId, queue);
216
- return queue[idx];
1063
+ entry.status = 'cancelled';
1064
+ entry.cancelledAt = now;
1065
+ if (opts?.reason) entry.cancelReason = opts.reason;
1066
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1067
+ endTaskDispatchInFlight(meshId, taskId);
1068
+ propagateDependencyFailure(meshId, taskId);
1069
+ return entry;
217
1070
  });
218
1071
  }
219
1072
 
@@ -221,6 +1074,11 @@ export function cancelTask(
221
1074
  * Return a queue task to pending for retry. By default, dead session targeting
222
1075
  * and assigned ownership are cleared so stale assignments do not strand again.
223
1076
  */
1077
+ export type RequeueResult =
1078
+ | { status: 'requeued'; entry: MeshWorkQueueEntry }
1079
+ | { status: 'failed_max_retries'; entry: MeshWorkQueueEntry; maxRetries: number; requeueCount: number }
1080
+ | { status: 'not_found' };
1081
+
224
1082
  export function requeueTask(
225
1083
  meshId: string,
226
1084
  taskId: string,
@@ -230,15 +1088,51 @@ export function requeueTask(
230
1088
  targetSessionId?: string;
231
1089
  clearTargetNode?: boolean;
232
1090
  clearTargetSession?: boolean;
233
- },
1091
+ /**
1092
+ * Override the retry cap for this call. Use only for explicit operator actions.
1093
+ * If true, the task is requeued even when requeueCount >= maxRetries.
1094
+ */
1095
+ force?: boolean;
1096
+ /** Per-task retry cap override. Falls back to mesh policy maxTaskRetries (default 1). */
1097
+ maxRetries?: number;
1098
+ } & MeshQueueMutationOptions,
234
1099
  ): MeshWorkQueueEntry | null {
1100
+ requireMeshHostQueueOwner(opts);
235
1101
  return withQueueLock(meshId, () => {
236
- const queue = readQueue(meshId);
237
- const idx = queue.findIndex(q => q.id === taskId);
238
- if (idx === -1) return null;
239
- const entry = queue[idx];
240
- const now = new Date().toISOString();
1102
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1103
+ if (!entry) return null;
1104
+ // CANON-IDENTITY single-flight: refuse (no-op) to reopen a task whose dispatch
1105
+ // is still in-flight — the worker is actively generating on it. Requeueing it
1106
+ // here would flip the row back to `pending` and let a SECOND session claim the
1107
+ // SAME task (the live `ade8586d` requeue-while-generating double-dispatch). A
1108
+ // STALE assigned row (dead session, dispatch never confirmed) is NOT in-flight
1109
+ // — its mark was cleared on the dispatch failure — so it still requeues as
1110
+ // before. An explicit operator override (`force`) bypasses this guard.
1111
+ // MAGI-NOTE: the future consensus group fan-out (separate mission) intentionally
1112
+ // re-dispatches a group-tagged task into multiple sessions and must be exempted
1113
+ // from this single-flight guard; the exemption hook (group-id check) belongs here.
1114
+ if (!opts?.force && isTaskDispatchInFlight(meshId, taskId)) {
1115
+ LOG.warn('MeshQueue', `Refusing to requeue task ${taskId} on mesh ${meshId}: it is actively dispatched/generating (single-flight in-flight). Requeueing now would open a duplicate second dispatch into another session. Pass force to override.`);
1116
+ return entry;
1117
+ }
1118
+ // Proceeding to requeue (or force-override): the prior dispatch is being abandoned,
1119
+ // so end the single-flight window for this task id.
1120
+ endTaskDispatchInFlight(meshId, taskId);
1121
+ const currentCount = entry.requeueCount || 0;
1122
+ const maxRetries = opts?.maxRetries ?? entry.maxRetries ?? 1;
1123
+ if (!opts?.force && currentCount >= maxRetries) {
1124
+ // Auto-fail: cap exceeded without explicit force override.
1125
+ entry.status = 'failed';
1126
+ entry.cancelReason = `max_retries_exceeded: requeued ${currentCount} time(s), limit is ${maxRetries}`;
1127
+ entry.updatedAt = new Date().toISOString();
1128
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1129
+ propagateDependencyFailure(meshId, taskId);
1130
+ return entry;
1131
+ }
241
1132
  entry.status = 'pending';
1133
+ // Operator requeue clears a dependency-failure block — the operator is
1134
+ // explicitly overriding the held-back state.
1135
+ delete entry.blockedReason;
242
1136
  delete entry.assignedNodeId;
243
1137
  delete entry.assignedSessionId;
244
1138
  delete entry.cancelledAt;
@@ -247,11 +1141,92 @@ export function requeueTask(
247
1141
  if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
248
1142
  if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
249
1143
  if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
250
- entry.updatedAt = now;
251
- entry.requeuedAt = now;
252
- entry.requeueCount = (entry.requeueCount || 0) + 1;
1144
+ entry.requeuedAt = new Date().toISOString();
1145
+ entry.requeueCount = currentCount + 1;
253
1146
  if (opts?.reason) entry.requeueReason = opts.reason;
254
- writeQueue(meshId, queue);
1147
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1148
+ return entry;
1149
+ });
1150
+ }
1151
+
1152
+ /**
1153
+ * Max times the assigned-stranded watchdog will reclaim a single task before giving
1154
+ * up and failing it. Bounds the reclaim→re-dispatch→strand cycle so a permanently
1155
+ * undeliverable target (e.g. a node whose transport is wedged) eventually fails and
1156
+ * unblocks its dependents instead of looping every reconcile tick.
1157
+ */
1158
+ const MAX_STRANDED_RECLAIMS = 3;
1159
+
1160
+ /**
1161
+ * Bug B: reclaim a task stuck in 'assigned' because its dispatch was never confirmed.
1162
+ *
1163
+ * claimNextTask atomically marks a row 'assigned' BEFORE the fire-and-forget dispatch
1164
+ * runs. If that dispatch neither rejects (→ no .catch requeue) nor is confirmed
1165
+ * delivered — a relay that hangs without acking, or a confirm timer lost across a
1166
+ * daemon restart — the row stays 'assigned' forever, contributing 0 pending so PHASE 3
1167
+ * reconcile never re-examines it. This returns such a row to 'pending' and clears its
1168
+ * dead assignment ownership (node / session / provider / dispatchTimestamp) — the same
1169
+ * ownership-clear requeueTask applies — so PHASE 3 can re-dispatch it onto a fresh idle
1170
+ * session.
1171
+ *
1172
+ * Guarded to 'assigned' rows only (a completion/cancel that already moved the row off
1173
+ * 'assigned' must never be resurrected) and bounded by MAX_STRANDED_RECLAIMS (beyond
1174
+ * which the task is failed so dependents unblock).
1175
+ */
1176
+ export function reclaimStrandedAssignedTask(
1177
+ meshId: string,
1178
+ taskId: string,
1179
+ opts?: { reason?: string; ageMs?: number } & MeshQueueMutationOptions,
1180
+ ): MeshWorkQueueEntry | null {
1181
+ requireMeshHostQueueOwner(opts);
1182
+ return withQueueLock(meshId, () => {
1183
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
1184
+ if (!entry) return null;
1185
+ // Only a still-assigned row is stranded. If a completion/cancel already moved it
1186
+ // off 'assigned', there is nothing to reclaim — never resurrect a terminal row.
1187
+ if (entry.status !== 'assigned') return null;
1188
+ const now = new Date().toISOString();
1189
+ const reason = opts?.reason || 'assigned_stranded_dispatch_unconfirmed';
1190
+ const reclaims = (entry.strandedReclaimCount || 0) + 1;
1191
+ const prevNode = entry.assignedNodeId;
1192
+ const prevSession = entry.assignedSessionId;
1193
+ // Always clear the dead assignment ownership so a re-claim starts clean and the
1194
+ // assigned-counters (which filter status==='assigned') stop counting this row.
1195
+ delete entry.assignedNodeId;
1196
+ delete entry.assignedSessionId;
1197
+ delete entry.assignedProviderType;
1198
+ delete entry.dispatchTimestamp;
1199
+ entry.strandedReclaimCount = reclaims;
1200
+ entry.updatedAt = now;
1201
+ // The stranded assignment is being torn down (→ pending or failed); end its
1202
+ // single-flight window so a re-claim/requeue is not blocked.
1203
+ endTaskDispatchInFlight(meshId, taskId);
1204
+ if (reclaims > MAX_STRANDED_RECLAIMS) {
1205
+ // Repeatedly undeliverable — stop cycling and fail it so dependents unblock.
1206
+ entry.status = 'failed';
1207
+ entry.cancelReason = `stranded_dispatch_unrecovered: reclaimed ${reclaims - 1} time(s) without a confirmed dispatch`;
1208
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1209
+ propagateDependencyFailure(meshId, taskId);
1210
+ } else {
1211
+ entry.status = 'pending';
1212
+ entry.requeuedAt = now;
1213
+ entry.requeueReason = reason;
1214
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
1215
+ }
1216
+ try {
1217
+ appendLedgerEntry(meshId, {
1218
+ kind: 'task_reclaimed' as MeshLedgerKind,
1219
+ nodeId: prevNode,
1220
+ sessionId: prevSession,
1221
+ payload: {
1222
+ taskId,
1223
+ reason,
1224
+ ...(typeof opts?.ageMs === 'number' ? { ageMs: opts.ageMs } : {}),
1225
+ reclaimCount: reclaims,
1226
+ outcome: entry.status,
1227
+ },
1228
+ });
1229
+ } catch { /* ledger write is best-effort */ }
255
1230
  return entry;
256
1231
  });
257
1232
  }
@@ -263,28 +1238,87 @@ export function updateSessionTaskStatus(
263
1238
  meshId: string,
264
1239
  sessionId: string,
265
1240
  status: MeshTaskStatus,
266
- opts?: { occurredAt?: string },
1241
+ opts?: { occurredAt?: string; taskId?: string },
267
1242
  ): MeshWorkQueueEntry | null {
268
1243
  return withQueueLock(meshId, () => {
269
- const queue = readQueue(meshId);
270
- const occurredAtTime = opts?.occurredAt ? new Date(opts.occurredAt).getTime() : Number.NaN;
271
- const hasOccurredAt = Number.isFinite(occurredAtTime);
272
- let bestIdx = -1;
273
- let bestTime = 0;
274
- for (let i = queue.length - 1; i >= 0; i--) {
275
- if (queue[i].assignedSessionId !== sessionId || queue[i].status !== 'assigned') continue;
276
- const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
277
- if (hasOccurredAt && Number.isFinite(time) && time > occurredAtTime) continue;
278
- if (time > bestTime) { bestTime = time; bestIdx = i; }
1244
+ const store = MeshRuntimeStore.getInstance();
1245
+ const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
1246
+ const entry = store.findAssignedBySession(meshId, sessionId, occurredAtIso, opts?.taskId);
1247
+ if (!entry) {
1248
+ // C2: the silent null here is exactly what stranded a finished task as
1249
+ // `assigned` for 19 minutes. If the session still has an assigned row we
1250
+ // failed to resolve, surface it loudly instead of dropping the completion.
1251
+ const assignedRows = store.getActiveAssignmentDetails(meshId)
1252
+ .filter(r => sessionIdsEquivalent(r.sessionId, sessionId));
1253
+ if (assignedRows.length > 0) {
1254
+ LOG.warn('MeshQueue', `No assigned queue row matched completion for mesh ${meshId} session ${sessionId} `
1255
+ + `(taskId=${opts?.taskId ?? 'none'}, occurredAt=${occurredAtIso ?? 'none'}); `
1256
+ + `${assignedRows.length} assigned row(s) exist: ${assignedRows.map(r => r.id).join(',')}`);
1257
+ }
1258
+ return null;
279
1259
  }
280
- if (bestIdx === -1) return null;
281
- queue[bestIdx].status = status;
282
- queue[bestIdx].updatedAt = new Date().toISOString();
283
- writeQueue(meshId, queue);
284
- return queue[bestIdx];
1260
+ entry.status = status;
1261
+ store.updateQueueEntry(entry);
1262
+ // The worker reported a terminal/non-assigned outcome — the dispatch is over;
1263
+ // release the single-flight mark so the task id can be re-dispatched later.
1264
+ if (status !== 'assigned') endTaskDispatchInFlight(meshId, entry.id);
1265
+ if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, entry.id);
1266
+ return entry;
285
1267
  });
286
1268
  }
287
1269
 
1270
+ /**
1271
+ * M1-3: true when at least one pending task is waiting on the given task.
1272
+ * Used by the completion event path to decide whether to wake the queue.
1273
+ */
1274
+ export function hasPendingDependents(meshId: string, taskId: string): boolean {
1275
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId, ['pending'])
1276
+ .some(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(taskId));
1277
+ }
1278
+
1279
+ /**
1280
+ * M1: THE single dependency-gate predicate. A task is claimable from a
1281
+ * dependency standpoint iff it carries no system block (`blockedReason`) AND
1282
+ * every id in `dependsOn` has reached 'completed'.
1283
+ *
1284
+ * DEPENDSON-GATE-SYMMETRY: every scheduler surface that decides whether a
1285
+ * pending task may run MUST route through this one predicate — the queue claim
1286
+ * (claimNextQueueTask), the auto-launch candidate filter
1287
+ * (maybeAutoLaunchOneQueueSession), and the cloud eager P2P push
1288
+ * (enqueue-and-push). If any surface computes dependency readiness on its own,
1289
+ * the gate goes asymmetric and a task blocked from the pull path can still be
1290
+ * eager-pushed straight to an idle session, silently bypassing its
1291
+ * prerequisites. The semantics here (all deps completed && !blocked) are the
1292
+ * invariant — do not fork them.
1293
+ */
1294
+ export function taskDependenciesSatisfied(
1295
+ entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
1296
+ statusById: Map<string, MeshTaskStatus | string>,
1297
+ ): boolean {
1298
+ if (entry.blockedReason) return false;
1299
+ const deps = Array.isArray(entry.dependsOn) ? entry.dependsOn : [];
1300
+ return deps.every(depId => statusById.get(depId) === 'completed');
1301
+ }
1302
+
1303
+ /**
1304
+ * M1-4: view-time dependency state for a task — unmet dependency ids and
1305
+ * whether the task is currently claimable from a dependency standpoint.
1306
+ * Not stored (truth stays in task statuses). The `dependenciesSatisfied` field
1307
+ * is derived from {@link taskDependenciesSatisfied} so the view and the
1308
+ * scheduler gates can never disagree.
1309
+ */
1310
+ export function describeTaskDependencyState(
1311
+ entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
1312
+ statusById: Map<string, MeshTaskStatus | string>,
1313
+ ): { waitingOn: string[]; dependenciesSatisfied: boolean } {
1314
+ const deps = Array.isArray(entry.dependsOn) ? entry.dependsOn : [];
1315
+ const waitingOn = deps.filter(depId => statusById.get(depId) !== 'completed');
1316
+ return {
1317
+ waitingOn,
1318
+ dependenciesSatisfied: taskDependenciesSatisfied(entry, statusById),
1319
+ };
1320
+ }
1321
+
288
1322
  export interface MeshWorkQueueStats {
289
1323
  total: number;
290
1324
  active: number;
@@ -310,14 +1344,16 @@ export interface MeshWorkQueueStats {
310
1344
  * Return aggregate queue statistics for the given mesh.
311
1345
  */
312
1346
  export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
313
- const queue = readQueue(meshId);
314
- const pending = queue.filter(q => q.status === 'pending').length;
315
- const assigned = queue.filter(q => q.status === 'assigned').length;
316
- const completed = queue.filter(q => q.status === 'completed').length;
317
- const failed = queue.filter(q => q.status === 'failed').length;
318
- const cancelled = queue.filter(q => q.status === 'cancelled').length;
1347
+ const rows = MeshRuntimeStore.getInstance().getQueueStatsByStatus(meshId);
1348
+ const counts: Record<string, number> = {};
1349
+ for (const r of rows) counts[r.status] = r.count;
1350
+ const pending = counts['pending'] ?? 0;
1351
+ const assigned = counts['assigned'] ?? 0;
1352
+ const completed = counts['completed'] ?? 0;
1353
+ const failed = counts['failed'] ?? 0;
1354
+ const cancelled = counts['cancelled'] ?? 0;
319
1355
  return {
320
- total: queue.length,
1356
+ total: pending + assigned + completed + failed + cancelled,
321
1357
  active: pending + assigned,
322
1358
  historical: completed + failed + cancelled,
323
1359
  pending,
@@ -325,22 +1361,126 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
325
1361
  completed,
326
1362
  failed,
327
1363
  cancelled,
328
- activeCounts: {
329
- pending,
330
- assigned,
331
- },
332
- historicalCounts: {
333
- completed,
334
- failed,
335
- cancelled,
336
- },
337
- activeAssignments: queue
338
- .filter(q => q.status === 'assigned')
339
- .map(q => ({
340
- id: q.id,
341
- nodeId: q.assignedNodeId,
342
- sessionId: q.assignedSessionId,
343
- message: q.message,
344
- })),
1364
+ activeCounts: { pending, assigned },
1365
+ historicalCounts: { completed, failed, cancelled },
1366
+ activeAssignments: MeshRuntimeStore.getInstance().getActiveAssignmentDetails(meshId),
345
1367
  };
346
1368
  }
1369
+
1370
+ export function __replaceMeshQueueForTests(meshId: string, queue: MeshWorkQueueEntry[]): void {
1371
+ MeshRuntimeStore.getInstance().transaction(() => {
1372
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
1373
+ });
1374
+ }
1375
+
1376
+ export function __clearMeshQueueForTests(meshId: string): void {
1377
+ MeshRuntimeStore.getInstance().deleteQueue(meshId);
1378
+ }
1379
+
1380
+ export function __clearDirectDispatchesForTests(meshId: string): void {
1381
+ MeshRuntimeStore.getInstance().deleteDirectDispatches(meshId);
1382
+ }
1383
+
1384
+ export function __resetMeshRuntimeStoreForTests(): void {
1385
+ MeshRuntimeStore.resetForTests();
1386
+ }
1387
+
1388
+ // ── Direct Dispatch Tracking ─────────────────────────────────────────────────
1389
+ // Persists direct (non-queue) task dispatches so buildMeshActiveWork can read
1390
+ // active work from MeshRuntimeStore instead of scanning ledger JSONL entries.
1391
+
1392
+ export type DirectDispatchRecord = ReturnType<MeshRuntimeStore['getActiveDirectDispatches']>[number];
1393
+
1394
+ export function insertDirectDispatch(
1395
+ meshId: string,
1396
+ data: {
1397
+ taskId: string;
1398
+ nodeId?: string;
1399
+ sessionId?: string;
1400
+ providerType?: string;
1401
+ message: string;
1402
+ taskMode?: string;
1403
+ via: string;
1404
+ dispatchedToIdleSession?: boolean;
1405
+ dispatchedAt: string;
1406
+ },
1407
+ ): void {
1408
+ try {
1409
+ MeshRuntimeStore.getInstance().insertDirectDispatch({ ...data, meshId });
1410
+ } catch (e: any) {
1411
+ process.stderr.write(`[adhdev-mesh] insertDirectDispatch failed for task ${data.taskId}: ${e?.message || e}\n`);
1412
+ }
1413
+ }
1414
+
1415
+ export function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[] {
1416
+ try {
1417
+ return MeshRuntimeStore.getInstance().getActiveDirectDispatches(meshId);
1418
+ } catch {
1419
+ return [];
1420
+ }
1421
+ }
1422
+
1423
+ export function updateDirectDispatchStatus(
1424
+ meshId: string,
1425
+ sessionId: string,
1426
+ status: 'acked' | 'completed' | 'failed' | 'stale',
1427
+ taskId?: string,
1428
+ ): void {
1429
+ try {
1430
+ // CANON-B: prefer the exact task_id row; fall back to the session_id match only when
1431
+ // the firing event carried no taskId (a legacy/relayed event). Warn on the fallback so
1432
+ // the residual PK-substitute path is observable when it strands a sibling dispatch.
1433
+ if (!taskId) {
1434
+ LOG.warn('MeshQueue', `updateDirectDispatchStatus(${status}) for mesh ${meshId} session ${sessionId} has no taskId — falling back to session_id match (may flip a sibling dispatch row)`);
1435
+ }
1436
+ MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status, taskId);
1437
+ } catch { /* best-effort */ }
1438
+ }
1439
+
1440
+ export function cleanupTerminalDirectDispatches(olderThanMs = 7 * 24 * 60 * 60_000): void {
1441
+ try {
1442
+ MeshRuntimeStore.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
1443
+ } catch { /* best-effort */ }
1444
+ }
1445
+
1446
+ export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_000): void {
1447
+ try {
1448
+ MeshRuntimeStore.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
1449
+ } catch { /* best-effort */ }
1450
+ }
1451
+
1452
+ /**
1453
+ * Delete specific direct dispatch rows by taskId. Returns the number of rows deleted.
1454
+ * Used by the staleDirect prune path to evict orphaned/terminal dispatch records from the
1455
+ * active staleDirect surface while leaving the append-only mesh ledger (audit history) intact.
1456
+ */
1457
+ export function deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
1458
+ try {
1459
+ return MeshRuntimeStore.getInstance().deleteDirectDispatchesByTaskId(meshId, taskIds);
1460
+ } catch {
1461
+ return 0;
1462
+ }
1463
+ }
1464
+
1465
+ export type MeshToolCallRateResult = { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null };
1466
+
1467
+ /**
1468
+ * Record a coordinator tool call and return a rate-limit advisory when the
1469
+ * call rate for that tool exceeds the allowed threshold.
1470
+ *
1471
+ * Defaults: 10-second sliding window, max 5 calls before advisory is raised.
1472
+ * Returns { rateLimitExceeded: false } on any store error so callers are not blocked.
1473
+ */
1474
+ export function recordMeshToolCall(opts: {
1475
+ meshId: string;
1476
+ tool: string;
1477
+ sessionId?: string | null;
1478
+ windowMs?: number;
1479
+ maxCalls?: number;
1480
+ }): MeshToolCallRateResult {
1481
+ try {
1482
+ return MeshRuntimeStore.getInstance().recordMeshToolCall(opts);
1483
+ } catch {
1484
+ return { rateLimitExceeded: false, callsInWindow: 0, advisory: null };
1485
+ }
1486
+ }