@adhdev/daemon-core 0.9.82-rc.50 → 0.9.82-rc.501

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 (478) 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 +85 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +246 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +139 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +5 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +74 -0
  14. package/dist/cli-adapters/pty-write-chunking.d.ts +34 -0
  15. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  16. package/dist/cli-adapters/resolve-executable.d.ts +69 -0
  17. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  18. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  19. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  20. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  21. package/dist/commands/chat-commands-read.d.ts +23 -0
  22. package/dist/commands/chat-commands-scope.d.ts +39 -0
  23. package/dist/commands/chat-commands-shared.d.ts +33 -0
  24. package/dist/commands/chat-commands-write.d.ts +14 -0
  25. package/dist/commands/chat-commands.d.ts +10 -23
  26. package/dist/commands/cli-manager.d.ts +71 -1
  27. package/dist/commands/handler.d.ts +125 -0
  28. package/dist/commands/high-family/index.d.ts +3 -0
  29. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  30. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  31. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  32. package/dist/commands/high-family/types.d.ts +64 -0
  33. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  34. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  35. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  36. package/dist/commands/low-family/index.d.ts +3 -0
  37. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  38. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  39. package/dist/commands/low-family/notification.d.ts +2 -0
  40. package/dist/commands/low-family/refine-config.d.ts +2 -0
  41. package/dist/commands/low-family/session-host.d.ts +2 -0
  42. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  43. package/dist/commands/low-family/status-meta.d.ts +2 -0
  44. package/dist/commands/low-family/types.d.ts +33 -0
  45. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  46. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  47. package/dist/commands/med-family/ide.d.ts +10 -0
  48. package/dist/commands/med-family/index.d.ts +3 -0
  49. package/dist/commands/med-family/mesh-crud.d.ts +48 -0
  50. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  51. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  52. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  53. package/dist/commands/med-family/types.d.ts +152 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router-aggregate-status.d.ts +24 -0
  56. package/dist/commands/router-mesh-session-owner.d.ts +59 -0
  57. package/dist/commands/router-refine.d.ts +151 -0
  58. package/dist/commands/router-worktree-cleanup.d.ts +133 -0
  59. package/dist/commands/router.d.ts +203 -15
  60. package/dist/commands/upgrade-helper.d.ts +41 -1
  61. package/dist/config/chat-history.d.ts +11 -0
  62. package/dist/config/config.d.ts +19 -0
  63. package/dist/config/mesh-config.d.ts +66 -0
  64. package/dist/config/mesh-json-config.d.ts +199 -0
  65. package/dist/config/registry-resolver.d.ts +54 -0
  66. package/dist/config/repo-settings.d.ts +77 -0
  67. package/dist/config/state-store.d.ts +30 -0
  68. package/dist/daemon/dev-server.d.ts +0 -2
  69. package/dist/detection/cli-detector.d.ts +17 -0
  70. package/dist/detection/ide-detector.d.ts +13 -0
  71. package/dist/detection/win32-ide-version.d.ts +37 -0
  72. package/dist/git/change-impact-config.d.ts +159 -0
  73. package/dist/git/git-commands.d.ts +26 -2
  74. package/dist/git/git-diff.d.ts +6 -0
  75. package/dist/git/git-executor.d.ts +11 -0
  76. package/dist/git/git-status.d.ts +80 -0
  77. package/dist/git/git-types.d.ts +2 -50
  78. package/dist/git/git-worktree.d.ts +71 -1
  79. package/dist/git/index.d.ts +3 -1
  80. package/dist/index.d.ts +71 -17
  81. package/dist/index.js +56455 -21586
  82. package/dist/index.js.map +1 -1
  83. package/dist/index.mjs +53763 -19064
  84. package/dist/index.mjs.map +1 -1
  85. package/dist/installer.d.ts +1 -4
  86. package/dist/ipc/local-ipc-server.d.ts +91 -0
  87. package/dist/launch.d.ts +1 -1
  88. package/dist/logging/async-batch-writer.d.ts +10 -0
  89. package/dist/logging/debug-config.d.ts +16 -0
  90. package/dist/logging/log-redactor.d.ts +24 -0
  91. package/dist/logging/log-tail-reader.d.ts +81 -0
  92. package/dist/logging/logger.d.ts +1 -1
  93. package/dist/mesh/contracts.d.ts +256 -0
  94. package/dist/mesh/coordinator-prompt.d.ts +76 -0
  95. package/dist/mesh/coordinator-registry.d.ts +59 -0
  96. package/dist/mesh/mesh-active-work.d.ts +203 -0
  97. package/dist/mesh/mesh-clone-grace.d.ts +8 -0
  98. package/dist/mesh/mesh-completion-synthesis.d.ts +4 -0
  99. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  100. package/dist/mesh/mesh-delivery-policy.d.ts +104 -0
  101. package/dist/mesh/mesh-event-classify.d.ts +7 -0
  102. package/dist/mesh/mesh-event-forwarding.d.ts +58 -0
  103. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  104. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  105. package/dist/mesh/mesh-events-pending.d.ts +258 -0
  106. package/dist/mesh/mesh-events-stale.d.ts +61 -0
  107. package/dist/mesh/mesh-events-utils.d.ts +75 -0
  108. package/dist/mesh/mesh-events.d.ts +6 -49
  109. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  110. package/dist/mesh/mesh-host-ownership.d.ts +21 -1
  111. package/dist/mesh/mesh-idle-reminder.d.ts +68 -0
  112. package/dist/mesh/mesh-init.d.ts +128 -0
  113. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  114. package/dist/mesh/mesh-ledger.d.ts +139 -1
  115. package/dist/mesh/mesh-magi-status.d.ts +63 -0
  116. package/dist/mesh/mesh-missions.d.ts +281 -0
  117. package/dist/mesh/mesh-node-identity.d.ts +310 -0
  118. package/dist/mesh/mesh-queue-assignment.d.ts +162 -0
  119. package/dist/mesh/mesh-reconcile-acked-hold.d.ts +17 -0
  120. package/dist/mesh/mesh-reconcile-config.d.ts +6 -0
  121. package/dist/mesh/mesh-reconcile-identity.d.ts +6 -0
  122. package/dist/mesh/mesh-reconcile-loop.d.ts +84 -0
  123. package/dist/mesh/mesh-reconcile-v2-backstop.d.ts +16 -0
  124. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  125. package/dist/mesh/mesh-refine-gates.d.ts +457 -0
  126. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  127. package/dist/mesh/mesh-remote-event-pull.d.ts +15 -0
  128. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  129. package/dist/mesh/mesh-routing.d.ts +70 -0
  130. package/dist/mesh/mesh-runtime-store.d.ts +584 -0
  131. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  132. package/dist/mesh/mesh-task-inflight.d.ts +46 -0
  133. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  134. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +34 -0
  135. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  136. package/dist/mesh/mesh-work-queue.d.ts +392 -1
  137. package/dist/mesh/preview-freshness.d.ts +28 -0
  138. package/dist/mesh/refine-config.d.ts +97 -0
  139. package/dist/mesh/worktree-bootstrap-config.d.ts +219 -0
  140. package/dist/providers/acp-provider-instance.d.ts +5 -0
  141. package/dist/providers/approval-utils.d.ts +46 -0
  142. package/dist/providers/chat-message-normalization.d.ts +58 -1
  143. package/dist/providers/cli-provider-effect-format.d.ts +30 -0
  144. package/dist/providers/cli-provider-history-dedup.d.ts +17 -0
  145. package/dist/providers/cli-provider-input-prompt.d.ts +12 -0
  146. package/dist/providers/cli-provider-instance-types.d.ts +47 -0
  147. package/dist/providers/cli-provider-instance.d.ts +461 -30
  148. package/dist/providers/cli-provider-status-helpers.d.ts +46 -0
  149. package/dist/providers/cli-provider-transcript-merge.d.ts +7 -0
  150. package/dist/providers/contracts.d.ts +196 -6
  151. package/dist/providers/external-sources.d.ts +71 -0
  152. package/dist/providers/manual-attendance.d.ts +79 -0
  153. package/dist/providers/native-history/antigravity-claim-registry.d.ts +39 -0
  154. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +137 -0
  155. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  156. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  157. package/dist/providers/native-history/constants.d.ts +12 -0
  158. package/dist/providers/native-history/dispatcher.d.ts +44 -0
  159. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  160. package/dist/providers/native-history/index.d.ts +13 -0
  161. package/dist/providers/provider-instance-manager.d.ts +30 -0
  162. package/dist/providers/provider-instance.d.ts +31 -1
  163. package/dist/providers/provider-loader.d.ts +42 -5
  164. package/dist/providers/provider-trust.d.ts +31 -0
  165. package/dist/providers/read-chat-contract.d.ts +29 -0
  166. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  167. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  168. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  169. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  170. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  171. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  172. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  173. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  174. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  175. package/dist/providers/sdk/v1/index.d.ts +30 -0
  176. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  177. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  178. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  179. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  180. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  181. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  182. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  183. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  184. package/dist/providers/spec/adapter.d.ts +98 -0
  185. package/dist/providers/spec/background-task-detector.d.ts +29 -0
  186. package/dist/providers/spec/cli-adapter.d.ts +224 -0
  187. package/dist/providers/spec/evaluator.d.ts +45 -0
  188. package/dist/providers/spec/fsm-driver.d.ts +449 -0
  189. package/dist/providers/spec/fsm-evaluator.d.ts +93 -0
  190. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  191. package/dist/providers/spec/fsm-types.d.ts +202 -0
  192. package/dist/providers/spec/native-history-executor.d.ts +50 -0
  193. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  194. package/dist/providers/spec/route.d.ts +4 -0
  195. package/dist/providers/spec/types.d.ts +244 -0
  196. package/dist/providers/status-monitor.d.ts +7 -7
  197. package/dist/providers/transcript-v2.d.ts +176 -0
  198. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  199. package/dist/providers/working-dir.d.ts +17 -0
  200. package/dist/repo-mesh-types.d.ts +629 -14
  201. package/dist/runtime-defaults.d.ts +2 -0
  202. package/dist/session-host/managed-host.d.ts +64 -0
  203. package/dist/session-host/runtime-surface.d.ts +10 -16
  204. package/dist/sessions/registry.d.ts +26 -0
  205. package/dist/shared-types-extra.d.ts +2 -4
  206. package/dist/shared-types.d.ts +110 -55
  207. package/dist/status/chat-tail-hot-sessions.d.ts +40 -0
  208. package/dist/status/normalize.d.ts +1 -1
  209. package/dist/status/normalize.js +1 -0
  210. package/dist/status/normalize.js.map +1 -1
  211. package/dist/status/normalize.mjs +1 -0
  212. package/dist/status/normalize.mjs.map +1 -1
  213. package/dist/status/reporter.d.ts +2 -0
  214. package/dist/status/snapshot.d.ts +27 -0
  215. package/dist/system/hash.d.ts +8 -0
  216. package/dist/system/load-better-sqlite3.d.ts +21 -0
  217. package/dist/types.d.ts +20 -6
  218. package/package.json +11 -4
  219. package/src/agent-stream/poller.ts +2 -3
  220. package/src/agent-stream/provider-adapter.ts +1 -1
  221. package/src/boot/daemon-lifecycle.ts +79 -14
  222. package/src/boot/process-hardening.ts +89 -0
  223. package/src/build-info.ts +73 -0
  224. package/src/chat/source-machine.ts +534 -0
  225. package/src/chat/source-resolver.ts +0 -0
  226. package/src/chat/subscription-updates.ts +20 -1
  227. package/src/cli-adapter-types.d.ts +3 -1
  228. package/src/cli-adapter-types.ts +85 -2
  229. package/src/cli-adapters/cli-script-runner.ts +421 -0
  230. package/src/cli-adapters/cli-state-engine.ts +1379 -0
  231. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  232. package/src/cli-adapters/provider-cli-adapter.ts +1059 -1143
  233. package/src/cli-adapters/provider-cli-parse.d.ts +2 -0
  234. package/src/cli-adapters/provider-cli-parse.ts +19 -0
  235. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  236. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  237. package/src/cli-adapters/provider-cli-shared.ts +121 -11
  238. package/src/cli-adapters/pty-transport.ts +2 -1
  239. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  240. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  241. package/src/cli-adapters/resolve-executable.ts +204 -0
  242. package/src/cli-adapters/session-host-transport.ts +2 -1
  243. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +63 -29
  244. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  245. package/src/cli-adapters/terminal-screen.ts +16 -81
  246. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  247. package/src/commands/chat-commands-read.ts +2788 -0
  248. package/src/commands/chat-commands-scope.ts +54 -0
  249. package/src/commands/chat-commands-shared.ts +114 -0
  250. package/src/commands/chat-commands-write.ts +891 -0
  251. package/src/commands/chat-commands.ts +19 -1841
  252. package/src/commands/cli-manager.d.ts +2 -0
  253. package/src/commands/cli-manager.ts +724 -27
  254. package/src/commands/handler.ts +865 -2
  255. package/src/commands/high-family/index.ts +28 -0
  256. package/src/commands/high-family/mesh-coordinator-launch.ts +696 -0
  257. package/src/commands/high-family/mesh-events.ts +102 -0
  258. package/src/commands/high-family/mesh-status.ts +857 -0
  259. package/src/commands/high-family/types.ts +80 -0
  260. package/src/commands/low-family/coordinator-prompt.ts +125 -0
  261. package/src/commands/low-family/daemon-lifecycle.ts +120 -0
  262. package/src/commands/low-family/diagnostics.ts +57 -0
  263. package/src/commands/low-family/index.ts +37 -0
  264. package/src/commands/low-family/mesh-ledger.ts +62 -0
  265. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  266. package/src/commands/low-family/notification.ts +116 -0
  267. package/src/commands/low-family/refine-config.ts +106 -0
  268. package/src/commands/low-family/session-host.ts +282 -0
  269. package/src/commands/low-family/spec-providerdev.ts +217 -0
  270. package/src/commands/low-family/status-meta.ts +112 -0
  271. package/src/commands/low-family/types.ts +39 -0
  272. package/src/commands/med-family/cli-agent.ts +270 -0
  273. package/src/commands/med-family/fast-forward.ts +230 -0
  274. package/src/commands/med-family/ide.ts +163 -0
  275. package/src/commands/med-family/index.ts +37 -0
  276. package/src/commands/med-family/mesh-crud.ts +1309 -0
  277. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  278. package/src/commands/med-family/mesh-queue.ts +167 -0
  279. package/src/commands/med-family/mesh-restart.ts +92 -0
  280. package/src/commands/med-family/types.ts +153 -0
  281. package/src/commands/mesh-coordinator.ts +334 -124
  282. package/src/commands/router-aggregate-status.ts +209 -0
  283. package/src/commands/router-mesh-session-owner.ts +114 -0
  284. package/src/commands/router-refine.ts +1794 -0
  285. package/src/commands/router-worktree-cleanup.ts +870 -0
  286. package/src/commands/router.ts +567 -4149
  287. package/src/commands/stream-commands.ts +8 -0
  288. package/src/commands/upgrade-helper.ts +310 -45
  289. package/src/config/chat-history.ts +517 -24
  290. package/src/config/config.ts +30 -0
  291. package/src/config/mesh-config.ts +300 -24
  292. package/src/config/mesh-json-config.ts +376 -0
  293. package/src/config/recent-activity.ts +8 -2
  294. package/src/config/registry-resolver.ts +100 -0
  295. package/src/config/repo-settings.ts +111 -0
  296. package/src/config/state-store.ts +55 -0
  297. package/src/daemon/dev-auto-implement.ts +3 -2
  298. package/src/daemon/dev-cli-debug.ts +10 -1
  299. package/src/daemon/dev-server.ts +0 -541
  300. package/src/detection/cli-detector.ts +94 -9
  301. package/src/detection/ide-detector.ts +55 -16
  302. package/src/detection/win32-ide-version.ts +106 -0
  303. package/src/git/change-impact-config.ts +354 -0
  304. package/src/git/git-commands.ts +112 -16
  305. package/src/git/git-diff.ts +81 -11
  306. package/src/git/git-executor.ts +12 -0
  307. package/src/git/git-status.ts +822 -48
  308. package/src/git/git-types.ts +14 -62
  309. package/src/git/git-worktree.ts +261 -4
  310. package/src/git/index.ts +17 -0
  311. package/src/index.ts +191 -13
  312. package/src/installer.d.ts +1 -1
  313. package/src/installer.ts +8 -6
  314. package/src/ipc/local-ipc-server.ts +278 -0
  315. package/src/launch.d.ts +1 -1
  316. package/src/launch.ts +37 -28
  317. package/src/logging/async-batch-writer.ts +55 -0
  318. package/src/logging/command-log.ts +7 -5
  319. package/src/logging/debug-config.ts +25 -0
  320. package/src/logging/debug-trace.ts +7 -2
  321. package/src/logging/log-redactor.ts +100 -0
  322. package/src/logging/log-tail-reader.ts +341 -0
  323. package/src/logging/logger.ts +14 -7
  324. package/src/mesh/contracts.ts +510 -0
  325. package/src/mesh/coordinator-prompt.ts +626 -34
  326. package/src/mesh/coordinator-registry.ts +121 -0
  327. package/src/mesh/mesh-active-work.ts +706 -0
  328. package/src/mesh/mesh-clone-grace.ts +68 -0
  329. package/src/mesh/mesh-completion-synthesis.ts +416 -0
  330. package/src/mesh/mesh-coordinator-config.ts +97 -0
  331. package/src/mesh/mesh-delivery-policy.ts +284 -0
  332. package/src/mesh/mesh-event-classify.ts +78 -0
  333. package/src/mesh/mesh-event-forwarding.ts +2042 -0
  334. package/src/mesh/mesh-event-trace.ts +67 -0
  335. package/src/mesh/mesh-events-coordinator.ts +32 -0
  336. package/src/mesh/mesh-events-pending.ts +1548 -0
  337. package/src/mesh/mesh-events-stale.ts +452 -0
  338. package/src/mesh/mesh-events-utils.ts +443 -0
  339. package/src/mesh/mesh-events.ts +43 -1035
  340. package/src/mesh/mesh-fast-forward.ts +856 -0
  341. package/src/mesh/mesh-host-ownership.ts +41 -3
  342. package/src/mesh/mesh-idle-reminder.ts +153 -0
  343. package/src/mesh/mesh-init.ts +350 -0
  344. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  345. package/src/mesh/mesh-ledger.ts +897 -102
  346. package/src/mesh/mesh-magi-status.ts +223 -0
  347. package/src/mesh/mesh-missions.ts +695 -0
  348. package/src/mesh/mesh-node-identity.ts +2065 -0
  349. package/src/mesh/mesh-queue-assignment.ts +2498 -0
  350. package/src/mesh/mesh-reconcile-acked-hold.ts +230 -0
  351. package/src/mesh/mesh-reconcile-config.ts +66 -0
  352. package/src/mesh/mesh-reconcile-identity.ts +103 -0
  353. package/src/mesh/mesh-reconcile-loop.ts +1709 -0
  354. package/src/mesh/mesh-reconcile-v2-backstop.ts +62 -0
  355. package/src/mesh/mesh-refine-batch.ts +205 -0
  356. package/src/mesh/mesh-refine-gates.ts +1792 -0
  357. package/src/mesh/mesh-refine-status.ts +231 -0
  358. package/src/mesh/mesh-remote-event-pull.ts +286 -0
  359. package/src/mesh/mesh-review-inbox.ts +308 -0
  360. package/src/mesh/mesh-routing.ts +291 -0
  361. package/src/mesh/mesh-runtime-store.ts +2318 -0
  362. package/src/mesh/mesh-scheduling-runtime.ts +198 -0
  363. package/src/mesh/mesh-task-inflight.ts +70 -0
  364. package/src/mesh/mesh-task-stats.ts +161 -0
  365. package/src/mesh/mesh-unresolved-forward-outbox.ts +215 -0
  366. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  367. package/src/mesh/mesh-work-queue.ts +1489 -145
  368. package/src/mesh/p2p-relay-failure.ts +2 -2
  369. package/src/mesh/preview-freshness.ts +163 -0
  370. package/src/mesh/refine-config.ts +129 -12
  371. package/src/mesh/worktree-bootstrap-config.ts +599 -0
  372. package/src/providers/acp-provider-instance.ts +43 -10
  373. package/src/providers/approval-utils.d.ts +5 -0
  374. package/src/providers/approval-utils.ts +100 -6
  375. package/src/providers/chat-message-normalization.ts +126 -4
  376. package/src/providers/cli-provider-effect-format.ts +53 -0
  377. package/src/providers/cli-provider-history-dedup.ts +75 -0
  378. package/src/providers/cli-provider-input-prompt.ts +133 -0
  379. package/src/providers/cli-provider-instance-types.ts +150 -0
  380. package/src/providers/cli-provider-instance.ts +2914 -498
  381. package/src/providers/cli-provider-status-helpers.ts +123 -0
  382. package/src/providers/cli-provider-transcript-merge.ts +114 -0
  383. package/src/providers/contracts.d.ts +55 -0
  384. package/src/providers/contracts.ts +207 -6
  385. package/src/providers/extension-provider-instance.ts +12 -7
  386. package/src/providers/external-sources.ts +218 -0
  387. package/src/providers/ide-provider-instance.ts +35 -12
  388. package/src/providers/manual-attendance.ts +105 -0
  389. package/src/providers/native-history/antigravity-claim-registry.ts +138 -0
  390. package/src/providers/native-history/antigravity-cli-transcript.ts +1275 -0
  391. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  392. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  393. package/src/providers/native-history/constants.ts +19 -0
  394. package/src/providers/native-history/dispatcher.ts +617 -0
  395. package/src/providers/native-history/hermes-cli-transcript.ts +312 -0
  396. package/src/providers/native-history/index.ts +30 -0
  397. package/src/providers/provider-instance-manager.ts +71 -0
  398. package/src/providers/provider-instance.ts +24 -1
  399. package/src/providers/provider-loader.ts +700 -55
  400. package/src/providers/provider-schema.ts +93 -14
  401. package/src/providers/provider-trust.ts +114 -0
  402. package/src/providers/read-chat-contract.ts +76 -16
  403. package/src/providers/sdk/README.md +49 -0
  404. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  405. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  406. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  407. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +261 -0
  408. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  409. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  410. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  411. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  412. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  413. package/src/providers/sdk/v1/index.ts +152 -0
  414. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  415. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  416. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  417. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +538 -0
  418. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  419. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  420. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  421. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  422. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  423. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  424. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  425. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  426. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  427. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  428. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  429. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  430. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  431. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  432. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  433. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  434. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  435. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  436. package/src/providers/sdk/v1/validators/index.ts +19 -0
  437. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  438. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  439. package/src/providers/spec/adapter.ts +246 -0
  440. package/src/providers/spec/background-task-detector.ts +178 -0
  441. package/src/providers/spec/cli-adapter.ts +1090 -0
  442. package/src/providers/spec/evaluator.ts +407 -0
  443. package/src/providers/spec/fsm-driver.ts +1528 -0
  444. package/src/providers/spec/fsm-evaluator.ts +290 -0
  445. package/src/providers/spec/fsm-loader.ts +128 -0
  446. package/src/providers/spec/fsm-types.ts +301 -0
  447. package/src/providers/spec/native-history-executor.ts +1256 -0
  448. package/src/providers/spec/pre-launch-trust.ts +104 -0
  449. package/src/providers/spec/route.ts +51 -0
  450. package/src/providers/spec/types.ts +284 -0
  451. package/src/providers/status-monitor.d.ts +7 -7
  452. package/src/providers/status-monitor.ts +37 -22
  453. package/src/providers/transcript-v2.ts +567 -0
  454. package/src/providers/types/interactive-prompt.ts +536 -0
  455. package/src/providers/version-archive.ts +64 -24
  456. package/src/providers/working-dir.ts +23 -0
  457. package/src/repo-mesh-types.ts +877 -16
  458. package/src/runtime-defaults.ts +39 -0
  459. package/src/session-host/managed-host.ts +218 -0
  460. package/src/session-host/runtime-surface.ts +20 -80
  461. package/src/sessions/registry.ts +44 -0
  462. package/src/shared-types-extra.ts +2 -4
  463. package/src/shared-types.d.ts +8 -0
  464. package/src/shared-types.ts +148 -55
  465. package/src/status/builders.ts +69 -6
  466. package/src/status/chat-tail-hot-sessions.ts +117 -2
  467. package/src/status/normalize.ts +2 -0
  468. package/src/status/reporter.ts +19 -1
  469. package/src/status/snapshot.ts +100 -27
  470. package/src/system/hash.ts +23 -0
  471. package/src/system/host-memory.ts +29 -12
  472. package/src/system/load-better-sqlite3.ts +68 -0
  473. package/src/types.ts +26 -6
  474. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  475. package/dist/mesh/mesh-sync.d.ts +0 -53
  476. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  477. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  478. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,2318 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import { LOG } from '../logging/logger.js';
4
+ import { loadBetterSqlite3 } from '../system/load-better-sqlite3.js';
5
+ import { getConfigDir } from '../config/config.js';
6
+ import { getLedgerDir } from './mesh-ledger.js';
7
+ import { nodeSatisfiesRequiredTags, isTaskReadonly, taskDependenciesSatisfied, meshTaskNotBeforeReady, meshTaskPriorityRank } from './mesh-work-queue.js';
8
+ import { meshNodeIdMatches, daemonIdsEquivalent, expandDaemonIdForms, sessionIdsEquivalent } from '@adhdev/mesh-shared';
9
+ import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
10
+ import type BetterSqlite3 from 'better-sqlite3';
11
+ import type { Database as DatabaseHandle } from 'better-sqlite3';
12
+
13
+ let DatabaseCtor: typeof BetterSqlite3 | undefined;
14
+
15
+ function loadDatabaseCtor(): typeof BetterSqlite3 {
16
+ if (DatabaseCtor) return DatabaseCtor;
17
+ DatabaseCtor = loadBetterSqlite3() as typeof BetterSqlite3;
18
+ return DatabaseCtor;
19
+ }
20
+
21
+ function safeMeshId(meshId: string): string {
22
+ return meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
23
+ }
24
+
25
+ // T2 (B2b): a persisted acked-hold record for one in-flight direct dispatch. The
26
+ // reconcile loop keeps a Map cache of these but this row is the SSOT so the hold
27
+ // survives a daemon restart. See the mesh_inflight_hold table comment.
28
+ export interface MeshInflightHoldRow {
29
+ taskId: string;
30
+ meshId: string | null;
31
+ holdReason: string | null;
32
+ heldAt: number | null;
33
+ firstIdleSinceAck: number | null;
34
+ readFailureCount: number | null;
35
+ updatedAt: number | null;
36
+ }
37
+
38
+ function legacyQueuePath(meshId: string): string {
39
+ return join(getLedgerDir(), `${safeMeshId(meshId)}.queue.json`);
40
+ }
41
+
42
+ let loggedMigrationFailure = false;
43
+ let loggedStrayCleanup = false;
44
+
45
+ /**
46
+ * MESH-COMPLEXITY-AUDIT Part 8-1: one-shot hygiene for a stray root mesh-runtime.db.
47
+ *
48
+ * The store lives at `~/.adhdev/mesh-ledger/mesh-runtime.db` (getLedgerDir()). An older
49
+ * build path could create a 0-byte `mesh-runtime.db` directly under `~/.adhdev/` — a
50
+ * dead file that is never opened or read (the canonical path is the only one used) but
51
+ * lingers. Remove it if and only if it is provably that stray: (a) exists, (b) is NOT the
52
+ * canonical store path, and (c) is empty (0 bytes). The size gate is the safety belt — we
53
+ * never unlink a non-empty file, so a real DB that somehow landed here is left untouched
54
+ * and surfaces as data rather than being silently deleted. Best-effort: any error is
55
+ * swallowed (with one diagnostic warn), never blocking store init.
56
+ */
57
+ function cleanupStrayRootRuntimeDb(canonicalPath: string): void {
58
+ try {
59
+ const strayPath = join(getConfigDir(), 'mesh-runtime.db');
60
+ if (strayPath === canonicalPath) return; // canonical dir IS the config dir — never touch
61
+ if (!existsSync(strayPath)) return;
62
+ if (statSync(strayPath).size !== 0) return; // non-empty → not the known 0-byte stray; leave it
63
+ unlinkSync(strayPath);
64
+ if (!loggedStrayCleanup) {
65
+ loggedStrayCleanup = true;
66
+ LOG.info('MeshRuntimeStore', `Removed stray 0-byte root mesh-runtime.db at ${strayPath}`);
67
+ }
68
+ } catch (err: any) {
69
+ if (!loggedStrayCleanup) {
70
+ loggedStrayCleanup = true;
71
+ LOG.warn('MeshRuntimeStore', `Stray root mesh-runtime.db cleanup failed (ignored): ${err?.message || err}`);
72
+ }
73
+ }
74
+ }
75
+
76
+ function meshRuntimeStorePath(): string {
77
+ const dir = getLedgerDir();
78
+ const nextPath = join(dir, 'mesh-runtime.db');
79
+ cleanupStrayRootRuntimeDb(nextPath);
80
+ if (existsSync(nextPath)) return nextPath;
81
+
82
+ const legacyPath = join(dir, 'beads.db');
83
+ if (!existsSync(legacyPath)) return nextPath;
84
+
85
+ try {
86
+ renameSync(legacyPath, nextPath);
87
+ for (const suffix of ['-wal', '-shm']) {
88
+ const legacyCompanion = `${legacyPath}${suffix}`;
89
+ if (existsSync(legacyCompanion)) {
90
+ renameSync(legacyCompanion, `${nextPath}${suffix}`);
91
+ }
92
+ }
93
+ } catch (err: any) {
94
+ // Migration failed — most commonly win32 EPERM when a handle to the
95
+ // legacy DB is still open. Do NOT fall through to opening `nextPath`:
96
+ // that would create a fresh EMPTY store while the existing data stays
97
+ // stranded in the legacy file (split-brain / silent data loss). Instead
98
+ // keep using whichever file actually holds the data in-place — the next
99
+ // boot retries the rename. If the main rename already landed (only a
100
+ // companion file failed), the data is at nextPath; otherwise it is still
101
+ // at legacyPath.
102
+ if (!loggedMigrationFailure) {
103
+ loggedMigrationFailure = true;
104
+ LOG.warn(
105
+ 'MeshRuntimeStore',
106
+ `Legacy beads.db→mesh-runtime.db migration failed; using existing DB in-place to avoid data loss: ${err?.message || err}`,
107
+ );
108
+ }
109
+ return existsSync(nextPath) ? nextPath : legacyPath;
110
+ }
111
+ return nextPath;
112
+ }
113
+
114
+ export class MeshRuntimeStore {
115
+ private static instance: MeshRuntimeStore | undefined;
116
+ private readonly db: DatabaseHandle;
117
+ private readonly dbPath: string;
118
+ private readonly migratedMeshIds = new Set<string>();
119
+ // Idle-active-mission-reminder debounce (mesh-idle-reminder.ts). In-memory only:
120
+ // this is a spam guard for a best-effort coordinator nudge, so a daemon restart
121
+ // resetting it (at most one extra reminder) is harmless — no SQLite persistence
122
+ // is warranted. Keyed by meshId; the value records when the last reminder fired
123
+ // and the hash of the active-mission id set it named, so a changed mission set
124
+ // re-fires before the time window elapses.
125
+ private readonly idleReminderState = new Map<string, { emittedAt: number; missionSetHash: string }>();
126
+ private fingerprintSweepCounter = 0;
127
+ private walWriteCounter = 0;
128
+ // Independent cadence for the tool-call-log sweep. Must NOT share walWriteCounter:
129
+ // sharing makes each store's threshold drift by the other's write volume (WAL
130
+ // checkpoint at 500 vs tool-log sweep at 200 would interfere arbitrarily).
131
+ private toolCallLogCounter = 0;
132
+ private static readonly WAL_CHECK_INTERVAL = 500;
133
+ private static readonly WAL_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
134
+
135
+ private constructor(dbPath: string) {
136
+ const dir = dirname(dbPath);
137
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
138
+
139
+ this.dbPath = dbPath;
140
+ this.db = new (loadDatabaseCtor())(dbPath);
141
+ this.db.pragma('journal_mode = WAL');
142
+ this.db.pragma('synchronous = NORMAL');
143
+ this.db.pragma('foreign_keys = ON');
144
+ this.db.pragma('busy_timeout = 5000');
145
+ this.migrate();
146
+ }
147
+
148
+ private static loggedGetInstanceFailure = false;
149
+
150
+ static getInstance(): MeshRuntimeStore {
151
+ if (!this.instance) {
152
+ try {
153
+ this.instance = new MeshRuntimeStore(meshRuntimeStorePath());
154
+ } catch (err: any) {
155
+ // SQLite store could not be opened (e.g. better-sqlite3 native
156
+ // load failure, locked/corrupt DB). Callers wrap getInstance in
157
+ // try/catch and silently degrade to JSONL-only — surface ONE warn
158
+ // so that degraded mode is diagnosable, then re-throw unchanged.
159
+ if (!MeshRuntimeStore.loggedGetInstanceFailure) {
160
+ MeshRuntimeStore.loggedGetInstanceFailure = true;
161
+ LOG.warn(
162
+ 'MeshRuntimeStore',
163
+ `getInstance failed; callers will degrade to JSONL-only: ${err?.message || err}`,
164
+ );
165
+ }
166
+ throw err;
167
+ }
168
+ }
169
+ return this.instance;
170
+ }
171
+
172
+ static resetForTests(): void {
173
+ this.instance?.close();
174
+ this.instance = undefined;
175
+ }
176
+
177
+ close(): void {
178
+ this.db.close();
179
+ }
180
+
181
+ transaction<T>(fn: () => T): T {
182
+ return this.db.transaction(fn).immediate();
183
+ }
184
+
185
+ private migrate(): void {
186
+ this.db.exec(`
187
+ CREATE TABLE IF NOT EXISTS mesh_queue (
188
+ id TEXT PRIMARY KEY,
189
+ mesh_id TEXT NOT NULL,
190
+ status TEXT NOT NULL,
191
+ target_node_id TEXT,
192
+ target_session_id TEXT,
193
+ assigned_node_id TEXT,
194
+ assigned_session_id TEXT,
195
+ created_at TEXT NOT NULL,
196
+ updated_at TEXT NOT NULL,
197
+ payload TEXT NOT NULL
198
+ );
199
+
200
+ CREATE INDEX IF NOT EXISTS idx_mesh_queue_mesh_status_created
201
+ ON mesh_queue(mesh_id, status, created_at);
202
+ CREATE INDEX IF NOT EXISTS idx_mesh_queue_assignment
203
+ ON mesh_queue(mesh_id, assigned_node_id, assigned_session_id, status);
204
+
205
+ -- mesh_id is DB-level isolation (defense-in-depth). The fingerprint STRING
206
+ -- also carries meshId as its first '::'-joined segment (see
207
+ -- buildMeshCompletionFingerprint) — that string-prefix defense is kept; this
208
+ -- column makes cross-mesh suppression impossible even if the string format
209
+ -- drifts or two meshes ever collide on a fingerprint body.
210
+ CREATE TABLE IF NOT EXISTS mesh_completion_fingerprints (
211
+ fingerprint TEXT PRIMARY KEY,
212
+ expires_at INTEGER NOT NULL,
213
+ mesh_id TEXT NOT NULL DEFAULT ''
214
+ );
215
+ -- NOTE: the (mesh_id, fingerprint) index is created in migrateMeshIsolationColumns,
216
+ -- NOT here. A pre-isolation DB still has the legacy table (CREATE IF NOT EXISTS is a
217
+ -- no-op), so referencing mesh_id in an index before the ALTER ADD COLUMN runs would
218
+ -- fail with "no such column". The migration adds the column then the index.
219
+
220
+ CREATE TABLE IF NOT EXISTS mesh_direct_dispatches (
221
+ task_id TEXT PRIMARY KEY,
222
+ mesh_id TEXT NOT NULL,
223
+ node_id TEXT,
224
+ session_id TEXT,
225
+ provider_type TEXT,
226
+ message TEXT NOT NULL,
227
+ task_mode TEXT,
228
+ via TEXT NOT NULL,
229
+ status TEXT NOT NULL DEFAULT 'dispatched',
230
+ dispatched_to_idle_session INTEGER NOT NULL DEFAULT 0,
231
+ dispatched_at TEXT NOT NULL,
232
+ updated_at TEXT NOT NULL
233
+ );
234
+
235
+ CREATE INDEX IF NOT EXISTS idx_direct_dispatches_mesh_session
236
+ ON mesh_direct_dispatches(mesh_id, session_id, status);
237
+
238
+ -- MESH-ISOLATION-LEAK: mesh_id is part of the PK so a nodeId shared across two
239
+ -- meshes (same machine in multiple repos) keeps a separate idle-session row per
240
+ -- mesh, and getRemoteIdleSessions(meshId) can never surface another mesh's
241
+ -- session for a queue claim.
242
+ CREATE TABLE IF NOT EXISTS remote_idle_sessions (
243
+ mesh_id TEXT NOT NULL,
244
+ node_id TEXT NOT NULL,
245
+ session_id TEXT NOT NULL,
246
+ provider_type TEXT NOT NULL,
247
+ expires_at INTEGER NOT NULL,
248
+ metadata TEXT,
249
+ PRIMARY KEY (mesh_id, node_id, session_id)
250
+ );
251
+
252
+ CREATE TABLE IF NOT EXISTS mesh_session_delivery (
253
+ id TEXT PRIMARY KEY,
254
+ mesh_id TEXT NOT NULL,
255
+ node_id TEXT,
256
+ session_id TEXT,
257
+ provider_type TEXT,
258
+ task_id TEXT,
259
+ kind TEXT NOT NULL,
260
+ priority INTEGER NOT NULL DEFAULT 0,
261
+ message TEXT NOT NULL,
262
+ status TEXT NOT NULL DEFAULT 'queued',
263
+ deliver_after TEXT,
264
+ expires_at TEXT,
265
+ attempt_count INTEGER NOT NULL DEFAULT 0,
266
+ source_coordinator_session_id TEXT,
267
+ source_coordinator_daemon_id TEXT,
268
+ last_error TEXT,
269
+ created_at TEXT NOT NULL,
270
+ updated_at TEXT NOT NULL
271
+ );
272
+
273
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_mesh_status
274
+ ON mesh_session_delivery(mesh_id, status, created_at);
275
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_session
276
+ ON mesh_session_delivery(mesh_id, session_id, status);
277
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_task
278
+ ON mesh_session_delivery(mesh_id, task_id);
279
+
280
+ -- MESH-COMPLEXITY-AUDIT Part 8-2: mesh_completion_conflicts removed
281
+ -- (write-only fingerprint-collision diagnostic, no production reader,
282
+ -- no no-loss role). Dropped in migrateMeshIsolationColumns step 6.
283
+
284
+ CREATE TABLE IF NOT EXISTS mesh_tool_call_log (
285
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
286
+ mesh_id TEXT NOT NULL,
287
+ tool TEXT NOT NULL,
288
+ session_id TEXT,
289
+ called_at INTEGER NOT NULL
290
+ );
291
+
292
+ CREATE INDEX IF NOT EXISTS idx_mesh_tool_call_log_mesh_tool_time
293
+ ON mesh_tool_call_log(mesh_id, tool, called_at);
294
+
295
+ -- G2: Event ledger — runtime source of truth for task/session lifecycle events.
296
+ -- JSONL files are retained as export/import/debug/legacy artifacts only.
297
+ CREATE TABLE IF NOT EXISTS mesh_event_ledger (
298
+ id TEXT PRIMARY KEY,
299
+ mesh_id TEXT NOT NULL,
300
+ timestamp TEXT NOT NULL,
301
+ kind TEXT NOT NULL,
302
+ node_id TEXT,
303
+ session_id TEXT,
304
+ provider_type TEXT,
305
+ payload TEXT NOT NULL DEFAULT '{}'
306
+ );
307
+
308
+ CREATE INDEX IF NOT EXISTS idx_mesh_event_ledger_mesh_time
309
+ ON mesh_event_ledger(mesh_id, timestamp);
310
+ CREATE INDEX IF NOT EXISTS idx_mesh_event_ledger_mesh_kind
311
+ ON mesh_event_ledger(mesh_id, kind, timestamp);
312
+ CREATE INDEX IF NOT EXISTS idx_mesh_event_ledger_session
313
+ ON mesh_event_ledger(mesh_id, session_id, timestamp);
314
+
315
+ -- G3: Pending coordinator event inbox — replaces <meshId>.pending-events.jsonl.
316
+ -- Coordinator drains this table on get_pending_mesh_events, then deletes drained rows.
317
+ CREATE TABLE IF NOT EXISTS mesh_pending_events (
318
+ id TEXT PRIMARY KEY,
319
+ mesh_id TEXT NOT NULL,
320
+ coordinator_daemon_id TEXT,
321
+ event TEXT NOT NULL,
322
+ payload TEXT NOT NULL DEFAULT '{}',
323
+ fingerprint TEXT,
324
+ queued_at INTEGER NOT NULL,
325
+ drained INTEGER NOT NULL DEFAULT 0,
326
+ drained_at INTEGER,
327
+ -- v2 protocol envelope (B2a). All nullable so pre-v2 rows and events
328
+ -- emitted before a coordinator identity is known coexist as v1. The
329
+ -- authoritative copy of each also rides inside the payload column; these
330
+ -- columns exist for queryable idempotency (event_id) and scope-based drain
331
+ -- filtering without JSON-parsing every row. dispatched_by / intended_for
332
+ -- hold the JSON-serialized CoordinatorIdentity.
333
+ protocol_version TEXT,
334
+ event_id TEXT,
335
+ scope TEXT,
336
+ dispatched_by TEXT,
337
+ intended_for TEXT
338
+ );
339
+
340
+ CREATE INDEX IF NOT EXISTS idx_mesh_pending_events_mesh_drained
341
+ ON mesh_pending_events(mesh_id, drained, queued_at);
342
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_mesh_pending_events_fingerprint
343
+ ON mesh_pending_events(mesh_id, fingerprint)
344
+ WHERE fingerprint IS NOT NULL;
345
+
346
+ -- M3: persistent mission records. Plans live in the system, not in the
347
+ -- coordinator LLM's context. Progress is derived from task statuses at
348
+ -- query time (mission_id on queue tasks) — never stored here.
349
+ CREATE TABLE IF NOT EXISTS mesh_missions (
350
+ id TEXT PRIMARY KEY,
351
+ mesh_id TEXT NOT NULL,
352
+ title TEXT NOT NULL,
353
+ goal TEXT NOT NULL DEFAULT '',
354
+ status TEXT NOT NULL DEFAULT 'active',
355
+ source TEXT,
356
+ -- G3: idempotency marker for the mission_close_candidate coordinator
357
+ -- event. Set to the emit timestamp when all of a mission's tasks first
358
+ -- become terminal (so the "consider closing this" nudge fires exactly
359
+ -- once per all-terminal edge), and cleared back to NULL when the mission
360
+ -- returns to a non-terminal state (new/re-opened task) so a later
361
+ -- re-completion can nudge again. Never drives a status transition — the
362
+ -- coordinator/human still decides via mesh_mission_upsert.
363
+ close_candidate_emitted_at TEXT,
364
+ created_at TEXT NOT NULL,
365
+ updated_at TEXT NOT NULL
366
+ );
367
+
368
+ CREATE INDEX IF NOT EXISTS idx_mesh_missions_mesh_status
369
+ ON mesh_missions(mesh_id, status, updated_at);
370
+
371
+ -- Load-balancing scheduler: per-mesh round-robin rotation cursor. When
372
+ -- the schedulingStrategy is 'round_robin', several eligible nodes tied at
373
+ -- the least load are rotated by this cursor so the tie-break winner cycles
374
+ -- across scheduling passes instead of always favouring the same array-order
375
+ -- node. Persisted (not a module Map) so rotation survives daemon restarts
376
+ -- and stays a single source of truth across scheduling entry points.
377
+ CREATE TABLE IF NOT EXISTS mesh_scheduler_cursor (
378
+ mesh_id TEXT PRIMARY KEY,
379
+ cursor INTEGER NOT NULL DEFAULT 0
380
+ );
381
+
382
+ -- T2 (B2b): persistent acked-hold state for in-flight direct dispatches.
383
+ -- The reconcile loop's PHASE-4 acked-hold (death-consequence counter,
384
+ -- fast-track idle streak, live-confirmed flag) used to live only in a
385
+ -- process-local Map (mesh-reconcile-loop.ts inFlightAckedHoldState), so a
386
+ -- daemon restart lost it — re-opening the door to the duplicate-emit / drop
387
+ -- window that the PHASE-4 transcript synth backstop then had to correct after
388
+ -- the fact. Persisting it lets the state survive a restart: the loop
389
+ -- rehydrates the Map from this table on first touch and stays read-through /
390
+ -- write-through against it thereafter. Keyed by task_id (one hold per
391
+ -- in-flight dispatch); mesh_id is carried for per-mesh listing / prune.
392
+ -- hold_reason — 'live' once a conclusive read confirmed the session
393
+ -- reachable since the ack, else 'unconfirmed' (drives
394
+ -- the death-backstop's liveConfirmedSinceAck gate).
395
+ -- held_at — ms epoch the hold row was first created.
396
+ -- first_idle_since_ack — ms epoch of the FIRST tick in the current continuous
397
+ -- idle-with-final-assistant run (fast-track streak); NULL
398
+ -- when the streak is broken / not yet started.
399
+ -- read_failure_count — consecutive read_chat failures since the last
400
+ -- conclusive read (death backstop (a)).
401
+ CREATE TABLE IF NOT EXISTS mesh_inflight_hold (
402
+ task_id TEXT PRIMARY KEY,
403
+ mesh_id TEXT,
404
+ hold_reason TEXT,
405
+ held_at INTEGER,
406
+ first_idle_since_ack INTEGER,
407
+ read_failure_count INTEGER,
408
+ updated_at INTEGER
409
+ );
410
+
411
+ CREATE INDEX IF NOT EXISTS idx_mesh_inflight_hold_mesh
412
+ ON mesh_inflight_hold(mesh_id);
413
+ `);
414
+ this.migrateMeshIsolationColumns();
415
+ }
416
+
417
+ private tableColumns(table: string): Set<string> {
418
+ const rows = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
419
+ return new Set(rows.map(r => r.name));
420
+ }
421
+
422
+ /**
423
+ * MESH-ISOLATION-LEAK migration. Two tables historically lacked a `mesh_id` column,
424
+ * letting one machine that belongs to multiple meshes (multiple repos) leak rows
425
+ * across meshes. Both migrations are idempotent and run on every boot — the column
426
+ * check short-circuits once the new schema is in place.
427
+ */
428
+ private migrateMeshIsolationColumns(): void {
429
+ try {
430
+ // 1. mesh_completion_fingerprints: ADD COLUMN + backfill mesh_id from the
431
+ // fingerprint string's first '::'-joined segment (buildMeshCompletionFingerprint
432
+ // prefixes meshId). A row whose fingerprint has no '::' (legacy/foreign format)
433
+ // backfills to '' — still strictly tighter than the prior global query.
434
+ const fpCols = this.tableColumns('mesh_completion_fingerprints');
435
+ if (!fpCols.has('mesh_id')) {
436
+ this.db.exec(`ALTER TABLE mesh_completion_fingerprints ADD COLUMN mesh_id TEXT NOT NULL DEFAULT ''`);
437
+ this.db.exec(`
438
+ UPDATE mesh_completion_fingerprints
439
+ SET mesh_id = substr(fingerprint, 1, instr(fingerprint, '::') - 1)
440
+ WHERE instr(fingerprint, '::') > 0 AND mesh_id = ''
441
+ `);
442
+ }
443
+ // The mesh_id column is now guaranteed to exist (fresh DB had it from CREATE TABLE,
444
+ // legacy DB just got it via ALTER). Create the index unconditionally — IF NOT EXISTS
445
+ // makes it a no-op once present.
446
+ this.db.exec(`
447
+ CREATE INDEX IF NOT EXISTS idx_mesh_completion_fingerprints_mesh
448
+ ON mesh_completion_fingerprints(mesh_id, fingerprint)
449
+ `);
450
+
451
+ // 2. remote_idle_sessions: the mesh_id is part of the PRIMARY KEY, which SQLite
452
+ // cannot add via ALTER. The rows are ephemeral — sessions re-register on the
453
+ // next agent:ready / agent:generating_completed — so a safe DROP+recreate is
454
+ // acceptable (per fix spec) rather than a full table rebuild + un-backfillable
455
+ // mesh_id. Only rebuild when the legacy (no mesh_id) schema is detected.
456
+ const idleCols = this.tableColumns('remote_idle_sessions');
457
+ if (!idleCols.has('mesh_id')) {
458
+ this.db.exec(`
459
+ DROP TABLE IF EXISTS remote_idle_sessions;
460
+ CREATE TABLE remote_idle_sessions (
461
+ mesh_id TEXT NOT NULL,
462
+ node_id TEXT NOT NULL,
463
+ session_id TEXT NOT NULL,
464
+ provider_type TEXT NOT NULL,
465
+ expires_at INTEGER NOT NULL,
466
+ metadata TEXT,
467
+ PRIMARY KEY (mesh_id, node_id, session_id)
468
+ );
469
+ `);
470
+ }
471
+
472
+ // 3. mesh_missions.source: nullable provenance tag ('magi' | 'coordinator').
473
+ // Pre-existing rows keep source NULL — listMeshMissionSummaries treats a
474
+ // NULL/absent source as a coordinator mission (never auto-hidden), so the
475
+ // completed-MAGI bounding only ever affects rows explicitly stamped 'magi'.
476
+ const missionCols = this.tableColumns('mesh_missions');
477
+ if (!missionCols.has('source')) {
478
+ this.db.exec(`ALTER TABLE mesh_missions ADD COLUMN source TEXT`);
479
+ }
480
+ // 3b. mesh_missions.close_candidate_emitted_at (G3): nullable idempotency
481
+ // marker for the mission_close_candidate coordinator nudge. Pre-existing
482
+ // rows keep it NULL — treated as "not yet emitted", so the first
483
+ // all-terminal detection after this migration emits once, then marks it.
484
+ if (!missionCols.has('close_candidate_emitted_at')) {
485
+ this.db.exec(`ALTER TABLE mesh_missions ADD COLUMN close_candidate_emitted_at TEXT`);
486
+ }
487
+
488
+ // 4. mesh_pending_events v2 envelope columns (B2a). A pre-v2 DB has the
489
+ // table (CREATE IF NOT EXISTS is a no-op) without these columns, so add
490
+ // each missing one. All nullable — legacy rows read back as v1 events
491
+ // (protocol_version NULL) with no reader change. Idempotent: the column
492
+ // check short-circuits once present, and every ADD COLUMN is guarded.
493
+ const pendingCols = this.tableColumns('mesh_pending_events');
494
+ for (const col of ['protocol_version', 'event_id', 'scope', 'dispatched_by', 'intended_for'] as const) {
495
+ if (!pendingCols.has(col)) {
496
+ this.db.exec(`ALTER TABLE mesh_pending_events ADD COLUMN ${col} TEXT`);
497
+ }
498
+ }
499
+ // Idempotency index on event_id (partial: only stamped v2 rows). Created
500
+ // unconditionally — IF NOT EXISTS makes it a no-op once present, and the
501
+ // event_id column is guaranteed to exist by the loop above.
502
+ this.db.exec(`
503
+ CREATE INDEX IF NOT EXISTS idx_mesh_pending_events_event_id
504
+ ON mesh_pending_events(mesh_id, event_id)
505
+ WHERE event_id IS NOT NULL
506
+ `);
507
+
508
+ // 5. MESH-COMPLEXITY-AUDIT Part 8-1: drop the legacy mesh_direct_delivered_events
509
+ // table. It backed the retired R3 "direct-delivered" dedup marker
510
+ // (markMeshCoordinatorEventDirectDelivered / wasDirectDeliveredToCoordinator,
511
+ // removed when spontaneous PTY direct-inject was retired — see the NOTE in
512
+ // mesh-events-pending.ts). No live code CREATEs, reads, or writes it anymore,
513
+ // so this is a pure runtime-residue cleanup with no behavior change: a store
514
+ // that never had the table just no-ops (IF EXISTS), an old install carrying
515
+ // the dormant table has it removed once. Idempotent — DROP TABLE IF EXISTS is
516
+ // a no-op on every subsequent boot.
517
+ this.db.exec(`DROP TABLE IF EXISTS mesh_direct_delivered_events`);
518
+
519
+ // 6. MESH-COMPLEXITY-AUDIT Part 8-2: drop the mesh_completion_conflicts
520
+ // diagnostic table. It recorded which task lost a completion-fingerprint
521
+ // dedup collision but had NO production reader (getRecentCompletionConflicts
522
+ // was test-only) and played NO part in the no-loss delivery contract — the
523
+ // dedup DECISION is the fingerprint match in mesh-event-forwarding.ts and is
524
+ // unchanged. Pure runtime-residue cleanup with no behavior change: a fresh
525
+ // store never creates it; an old install drops the dormant table once.
526
+ // Idempotent — DROP TABLE IF EXISTS is a no-op on every subsequent boot.
527
+ this.db.exec(`DROP TABLE IF EXISTS mesh_completion_conflicts`);
528
+ } catch (err: any) {
529
+ // Best-effort: a failed isolation migration must not brick the store. The
530
+ // CREATE-TABLE definitions above already carry the new schema for fresh DBs;
531
+ // an existing DB that fails here keeps the old (leaky-but-functional) schema
532
+ // until the next boot retries. Surface one warn for diagnosability.
533
+ if (!loggedMigrationFailure) {
534
+ loggedMigrationFailure = true;
535
+ LOG.warn('MeshRuntimeStore', `mesh-isolation column migration failed: ${err?.message || err}`);
536
+ }
537
+ }
538
+ }
539
+
540
+ hasCompletionFingerprint(meshId: string, fingerprint: string): boolean {
541
+ const now = Date.now();
542
+ // Scope by mesh_id (defense-in-depth) AS WELL AS the fingerprint string, whose
543
+ // first '::' segment already encodes meshId. A fingerprint can only suppress a
544
+ // duplicate within its own mesh.
545
+ const row = this.db
546
+ .prepare('SELECT 1 FROM mesh_completion_fingerprints WHERE mesh_id = ? AND fingerprint = ? AND expires_at > ?')
547
+ .get(meshId, fingerprint, now) as { 1: number } | undefined;
548
+ // Sweep expired fingerprints every 100 reads so stale rows don't accumulate
549
+ // even during read-heavy (non-write) periods when recordFingerprintSeen is idle.
550
+ if (++this.fingerprintSweepCounter >= 100) {
551
+ this.fingerprintSweepCounter = 0;
552
+ this.sweepExpiredFingerprints();
553
+ }
554
+ return row !== undefined;
555
+ }
556
+
557
+ recordCompletionFingerprint(meshId: string, fingerprint: string, ttlMs: number): void {
558
+ const expiresAt = Date.now() + ttlMs;
559
+ this.db.prepare('INSERT OR REPLACE INTO mesh_completion_fingerprints (fingerprint, expires_at, mesh_id) VALUES (?, ?, ?)')
560
+ .run(fingerprint, expiresAt, meshId);
561
+ this.maybeCheckpointWal();
562
+ }
563
+
564
+ sweepExpiredFingerprints(): void {
565
+ this.db.prepare('DELETE FROM mesh_completion_fingerprints WHERE expires_at <= ?').run(Date.now());
566
+ }
567
+
568
+ private maybeCheckpointWal(): void {
569
+ if (++this.walWriteCounter < MeshRuntimeStore.WAL_CHECK_INTERVAL) return;
570
+ this.walWriteCounter = 0;
571
+ try {
572
+ const walPath = `${this.dbPath}-wal`;
573
+ if (!existsSync(walPath)) return;
574
+ const size = statSync(walPath).size;
575
+ if (size < MeshRuntimeStore.WAL_MAX_BYTES) return;
576
+ process.stderr.write(
577
+ `[adhdev-mesh] WAL file ${Math.round(size / 1024 / 1024)}MB exceeds threshold; forcing checkpoint\n`,
578
+ );
579
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
580
+ } catch { /* best-effort */ }
581
+ }
582
+
583
+ private ensureLegacyQueueMigrated(meshId: string): void {
584
+ if (this.migratedMeshIds.has(meshId)) return;
585
+ this.migratedMeshIds.add(meshId);
586
+
587
+ const count = this.db
588
+ .prepare('SELECT COUNT(*) AS count FROM mesh_queue WHERE mesh_id = ?')
589
+ .get(meshId) as { count: number };
590
+ if (count.count > 0) return;
591
+
592
+ const path = legacyQueuePath(meshId);
593
+ if (!existsSync(path)) return;
594
+
595
+ try {
596
+ const entries = JSON.parse(readFileSync(path, 'utf-8')) as MeshWorkQueueEntry[];
597
+ if (!Array.isArray(entries)) return;
598
+ const insert = this.db.prepare(`
599
+ INSERT OR REPLACE INTO mesh_queue (
600
+ id, mesh_id, status, target_node_id, target_session_id,
601
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
602
+ ) VALUES (
603
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
604
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
605
+ )
606
+ `);
607
+ for (const entry of entries) {
608
+ insert.run(this.toRow(entry));
609
+ }
610
+ } catch {
611
+ return;
612
+ }
613
+ }
614
+
615
+ getQueueEntries(meshId: string, statuses?: MeshTaskStatus[]): MeshWorkQueueEntry[] {
616
+ this.ensureLegacyQueueMigrated(meshId);
617
+ if (statuses?.length) {
618
+ const placeholders = statuses.map(() => '?').join(', ');
619
+ const rows = this.db
620
+ .prepare(`SELECT payload FROM mesh_queue WHERE mesh_id = ? AND status IN (${placeholders}) ORDER BY created_at ASC`)
621
+ .all(meshId, ...statuses) as Array<{ payload: string }>;
622
+ return rows.map(row => JSON.parse(row.payload) as MeshWorkQueueEntry);
623
+ }
624
+ const rows = this.db
625
+ .prepare('SELECT payload FROM mesh_queue WHERE mesh_id = ? ORDER BY created_at ASC')
626
+ .all(meshId) as Array<{ payload: string }>;
627
+ return rows.map(row => JSON.parse(row.payload) as MeshWorkQueueEntry);
628
+ }
629
+
630
+ getQueueRevision(meshId: string): string {
631
+ this.ensureLegacyQueueMigrated(meshId);
632
+ const rows = this.db
633
+ .prepare('SELECT id, status, updated_at FROM mesh_queue WHERE mesh_id = ? ORDER BY id ASC')
634
+ .all(meshId) as Array<{ id: string; status: string; updated_at: string }>;
635
+ // Tab as field delimiter (UUIDs and ISO timestamps never contain tabs).
636
+ return rows.map(row => `${row.id}\t${row.status}\t${row.updated_at}`).join('\n');
637
+ }
638
+
639
+ replaceQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
640
+ const deleteStmt = this.db.prepare('DELETE FROM mesh_queue WHERE mesh_id = ?');
641
+ const insert = this.db.prepare(`
642
+ INSERT INTO mesh_queue (
643
+ id, mesh_id, status, target_node_id, target_session_id,
644
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
645
+ ) VALUES (
646
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
647
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
648
+ )
649
+ `);
650
+ deleteStmt.run(meshId);
651
+ for (const entry of queue) insert.run(this.toRow(entry));
652
+ this.maybeCheckpointWal();
653
+ }
654
+
655
+ deleteQueue(meshId: string): void {
656
+ this.db.prepare('DELETE FROM mesh_queue WHERE mesh_id = ?').run(meshId);
657
+ this.migratedMeshIds.delete(meshId);
658
+ }
659
+
660
+ insertQueueEntry(entry: MeshWorkQueueEntry): void {
661
+ this.db.prepare(`
662
+ INSERT INTO mesh_queue (
663
+ id, mesh_id, status, target_node_id, target_session_id,
664
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
665
+ ) VALUES (
666
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
667
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
668
+ )
669
+ `).run(this.toRow(entry));
670
+ this.maybeCheckpointWal();
671
+ }
672
+
673
+ updateQueueEntry(entry: MeshWorkQueueEntry): void {
674
+ const now = new Date().toISOString();
675
+ entry.updatedAt = now;
676
+ this.db.prepare(`
677
+ UPDATE mesh_queue SET
678
+ status = @status,
679
+ target_node_id = @targetNodeId,
680
+ target_session_id = @targetSessionId,
681
+ assigned_node_id = @assignedNodeId,
682
+ assigned_session_id = @assignedSessionId,
683
+ updated_at = @updatedAt,
684
+ payload = @payload
685
+ WHERE id = @id AND mesh_id = @meshId
686
+ `).run(this.toRow(entry));
687
+ this.maybeCheckpointWal();
688
+ }
689
+
690
+ findQueueEntryById(meshId: string, id: string): MeshWorkQueueEntry | null {
691
+ this.ensureLegacyQueueMigrated(meshId);
692
+ const row = this.db.prepare(
693
+ 'SELECT payload FROM mesh_queue WHERE id = ? AND mesh_id = ?'
694
+ ).get(id, meshId) as { payload: string } | undefined;
695
+ return row ? JSON.parse(row.payload) as MeshWorkQueueEntry : null;
696
+ }
697
+
698
+ hasActiveAssignment(meshId: string, sessionId: string, nodeId: string): boolean {
699
+ this.ensureLegacyQueueMigrated(meshId);
700
+ const row = this.db.prepare(`
701
+ SELECT 1 FROM mesh_queue
702
+ WHERE mesh_id = ? AND status = 'assigned'
703
+ AND (assigned_session_id = ? OR assigned_node_id = ?)
704
+ LIMIT 1
705
+ `).get(meshId, sessionId, nodeId);
706
+ return row !== undefined;
707
+ }
708
+
709
+ /** A session may only execute one task at a time, regardless of task mode. */
710
+ private hasActiveSessionAssignment(meshId: string, sessionId: string): boolean {
711
+ const row = this.db.prepare(`
712
+ SELECT 1 FROM mesh_queue
713
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_session_id = ?
714
+ LIMIT 1
715
+ `).get(meshId, sessionId);
716
+ return row !== undefined;
717
+ }
718
+
719
+ /** A node may only execute one write task at a time (worktree isolation). */
720
+ private hasActiveNodeAssignment(meshId: string, nodeId: string): boolean {
721
+ // The serialization gate (claimNextQueueTask's `!nodeBusy`) must see a node as
722
+ // busy when ANY active row's assigned_node_id matches in ANY equivalent
723
+ // daemon-id form (config-form `daemon_mach_X` vs stamp-form `mach_X`, or the
724
+ // standalone form). A raw `assigned_node_id = ?` on a single form silently
725
+ // misses a form-variant assigned row, making an already-assigned node look idle
726
+ // and letting a second write task claim it — duplicate claim / base leak. Mirror
727
+ // the node-pinned SELECT below (the `target_node_id IN (...)` query): expand to
728
+ // every equivalent form and bind an IN (...) set so the busy gate and the
729
+ // candidate SELECT use the SAME matching rule.
730
+ const nodeIdForms = expandDaemonIdForms(nodeId);
731
+ if (nodeIdForms.length === 0) return false;
732
+ const placeholders = nodeIdForms.map(() => '?').join(', ');
733
+ const row = this.db.prepare(`
734
+ SELECT 1 FROM mesh_queue
735
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id IN (${placeholders})
736
+ LIMIT 1
737
+ `).get(meshId, ...nodeIdForms);
738
+ return row !== undefined;
739
+ }
740
+
741
+ /**
742
+ * Count active (status='assigned') tasks on a node, regardless of provider or
743
+ * task mode. This is the load metric for least-loaded / round-robin ranking:
744
+ * the scheduler prefers the node with the fewest active assignments so
745
+ * untargeted work spreads instead of piling onto whichever node asks first.
746
+ */
747
+ nodeActiveAssignmentCount(meshId: string, nodeId: string): number {
748
+ const row = this.db.prepare(`
749
+ SELECT COUNT(*) as count FROM mesh_queue
750
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id = ?
751
+ `).get(meshId, nodeId) as { count: number } | undefined;
752
+ return row?.count ?? 0;
753
+ }
754
+
755
+ /**
756
+ * O(1) count of queue tasks in 'pending' status for a mesh. A COUNT(*) over the
757
+ * indexed status column, so it avoids JSON.parse-ing every queue row — used as a
758
+ * cheap guard before the reconcile loop runs a full triggerMeshQueue scan.
759
+ */
760
+ pendingQueueTaskCount(meshId: string): number {
761
+ const row = this.db.prepare(`
762
+ SELECT COUNT(*) as count FROM mesh_queue
763
+ WHERE mesh_id = ? AND status = 'pending'
764
+ `).get(meshId) as { count: number } | undefined;
765
+ return row?.count ?? 0;
766
+ }
767
+
768
+ /**
769
+ * Read the current per-mesh round-robin cursor (0 when unset). Used to rotate
770
+ * the tie-break winner among nodes tied at the least load.
771
+ */
772
+ getSchedulerCursor(meshId: string): number {
773
+ const row = this.db.prepare(
774
+ 'SELECT cursor FROM mesh_scheduler_cursor WHERE mesh_id = ?'
775
+ ).get(meshId) as { cursor: number } | undefined;
776
+ return row?.cursor ?? 0;
777
+ }
778
+
779
+ /**
780
+ * Atomically advance the per-mesh round-robin cursor by one and return the
781
+ * value that was current BEFORE the bump (the value the caller should rotate
782
+ * by for this pass). UPSERT keeps it lock-free across concurrent passes.
783
+ */
784
+ bumpSchedulerCursor(meshId: string): number {
785
+ return this.transaction(() => {
786
+ const current = this.getSchedulerCursor(meshId);
787
+ this.db.prepare(`
788
+ INSERT INTO mesh_scheduler_cursor (mesh_id, cursor) VALUES (?, ?)
789
+ ON CONFLICT(mesh_id) DO UPDATE SET cursor = excluded.cursor
790
+ `).run(meshId, current + 1);
791
+ return current;
792
+ });
793
+ }
794
+
795
+ // ── Acked-Hold State (T2 / B2b) ──────────────────────────────────────────
796
+ //
797
+ // Persistent mirror of the reconcile loop's inFlightAckedHoldState Map. Keyed
798
+ // by task_id (one in-flight dispatch = one hold). These are plain read/write/
799
+ // delete/list accessors; the read-through/write-through cache and the restart
800
+ // rehydrate live in mesh-reconcile-loop.ts.
801
+
802
+ private mapInflightHoldRow(r: Record<string, unknown> | undefined): MeshInflightHoldRow | null {
803
+ if (!r) return null;
804
+ return {
805
+ taskId: r.task_id as string,
806
+ meshId: (r.mesh_id as string | null) ?? null,
807
+ holdReason: (r.hold_reason as string | null) ?? null,
808
+ heldAt: (r.held_at as number | null) ?? null,
809
+ firstIdleSinceAck: (r.first_idle_since_ack as number | null) ?? null,
810
+ readFailureCount: (r.read_failure_count as number | null) ?? null,
811
+ updatedAt: (r.updated_at as number | null) ?? null,
812
+ };
813
+ }
814
+
815
+ upsertInflightHold(entry: {
816
+ taskId: string;
817
+ meshId?: string | null;
818
+ holdReason?: string | null;
819
+ heldAt?: number | null;
820
+ firstIdleSinceAck?: number | null;
821
+ readFailureCount?: number | null;
822
+ }): void {
823
+ const now = Date.now();
824
+ // Preserve held_at across an upsert (it marks when the hold was first created);
825
+ // only set it from the incoming value when the row is new. All other fields are
826
+ // overwritten with the latest state — the caller passes the full current state.
827
+ this.db.prepare(`
828
+ INSERT INTO mesh_inflight_hold
829
+ (task_id, mesh_id, hold_reason, held_at, first_idle_since_ack, read_failure_count, updated_at)
830
+ VALUES (@taskId, @meshId, @holdReason, @heldAt, @firstIdleSinceAck, @readFailureCount, @updatedAt)
831
+ ON CONFLICT(task_id) DO UPDATE SET
832
+ mesh_id = excluded.mesh_id,
833
+ hold_reason = excluded.hold_reason,
834
+ first_idle_since_ack = excluded.first_idle_since_ack,
835
+ read_failure_count = excluded.read_failure_count,
836
+ updated_at = excluded.updated_at
837
+ `).run({
838
+ taskId: entry.taskId,
839
+ meshId: entry.meshId ?? null,
840
+ holdReason: entry.holdReason ?? null,
841
+ heldAt: entry.heldAt ?? now,
842
+ firstIdleSinceAck: entry.firstIdleSinceAck ?? null,
843
+ readFailureCount: entry.readFailureCount ?? null,
844
+ updatedAt: now,
845
+ });
846
+ this.maybeCheckpointWal();
847
+ }
848
+
849
+ getInflightHold(taskId: string): MeshInflightHoldRow | null {
850
+ const row = this.db.prepare(
851
+ 'SELECT * FROM mesh_inflight_hold WHERE task_id = ?'
852
+ ).get(taskId) as Record<string, unknown> | undefined;
853
+ return this.mapInflightHoldRow(row);
854
+ }
855
+
856
+ listInflightHoldsByMesh(meshId: string): MeshInflightHoldRow[] {
857
+ const rows = this.db.prepare(
858
+ 'SELECT * FROM mesh_inflight_hold WHERE mesh_id = ?'
859
+ ).all(meshId) as Array<Record<string, unknown>>;
860
+ return rows.map(r => this.mapInflightHoldRow(r)).filter((r): r is MeshInflightHoldRow => r !== null);
861
+ }
862
+
863
+ deleteInflightHold(taskId: string): void {
864
+ this.db.prepare('DELETE FROM mesh_inflight_hold WHERE task_id = ?').run(taskId);
865
+ }
866
+
867
+ /**
868
+ * Count active (status='assigned') tasks on a (node, provider) combination,
869
+ * matched by the assignedProviderType stamped on the payload at claim time.
870
+ * Drives the per-(node, provider) maxParallel cap (RepoMeshNodePolicy
871
+ * providerRoles). The active-assignment set for a single node is tiny, so
872
+ * parsing payloads here is cheap and avoids a schema migration. Pre-cap legacy
873
+ * rows (no provider stamp) and other providers on the same node do not consume
874
+ * this provider's budget, so the cap is fully backward compatible.
875
+ */
876
+ private activeProviderAssignmentCount(meshId: string, nodeId: string, providerType: string): number {
877
+ const rows = this.db.prepare(`
878
+ SELECT payload FROM mesh_queue
879
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id = ?
880
+ `).all(meshId, nodeId) as Array<{ payload: string }>;
881
+ let count = 0;
882
+ for (const row of rows) {
883
+ try {
884
+ const entry = JSON.parse(row.payload) as MeshWorkQueueEntry;
885
+ if (entry.assignedProviderType === providerType) count += 1;
886
+ } catch { /* skip unparsable row */ }
887
+ }
888
+ return count;
889
+ }
890
+
891
+ // O(1) claim: transaction ensures only one session claims a pending task
892
+ claimNextQueueTask(
893
+ meshId: string,
894
+ nodeId: string,
895
+ sessionId: string,
896
+ capabilityTags: string[] = [],
897
+ opts?: { providerType?: string; providerMaxParallel?: number; nodeIsWorktree?: boolean },
898
+ ): MeshWorkQueueEntry | null {
899
+ return this.transaction(() => {
900
+ this.ensureLegacyQueueMigrated(meshId);
901
+ // A session executes one task at a time regardless of mode — block early.
902
+ // The node-level conflict is evaluated per-candidate below so that
903
+ // read-only (live_debug_readonly) tasks can claim concurrently on a node
904
+ // that already has an active assignment, while write tasks keep the
905
+ // one-active-per-node invariant (worktree isolation).
906
+ if (this.hasActiveSessionAssignment(meshId, sessionId)) return null;
907
+ const nodeBusy = this.hasActiveNodeAssignment(meshId, nodeId);
908
+
909
+ // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy providerRoles).
910
+ // Orthogonal to taskMode: this bounds the (node, provider) resource pool
911
+ // regardless of read-only vs write. When the cap is already met, this
912
+ // session cannot claim any candidate here — return null. This composes
913
+ // with the global/taskMode caps enforced in the coordinator (stricter
914
+ // wins); omitting providerMaxParallel preserves prior behavior exactly.
915
+ const providerType = typeof opts?.providerType === 'string' ? opts.providerType.trim() : '';
916
+ const providerMaxParallel = opts?.providerMaxParallel;
917
+ if (
918
+ providerType
919
+ && typeof providerMaxParallel === 'number'
920
+ && Number.isFinite(providerMaxParallel)
921
+ && providerMaxParallel >= 0
922
+ && this.activeProviderAssignmentCount(meshId, nodeId, providerType) >= providerMaxParallel
923
+ ) {
924
+ return null;
925
+ }
926
+
927
+ // The node-pinned SELECT must match a row whose target_node_id was stamped
928
+ // in ANY equivalent daemon-id form (config-form `daemon_mach_X` vs the
929
+ // claiming session's stamp-form `mach_X`). A single `= ?` bind on the
930
+ // stamp-form silently fails to fetch a config-form row, leaving the task
931
+ // pending forever (the empty-session WORKTREE-CLAIM-GATE repro). Expand to
932
+ // every equivalent form and bind an IN (...) set; the per-candidate
933
+ // targetMatches() JS gate above re-validates each fetched row.
934
+ const nodeIdForms = expandDaemonIdForms(nodeId);
935
+ const nodePinnedPlaceholders = nodeIdForms.map(() => '?').join(', ');
936
+ // Priority: session-targeted > node-targeted (no session) > unconstrained.
937
+ // G6: WITHIN each targeting tier, a higher task-level priority is pulled first;
938
+ // created_at ASC (from the SQL ORDER BY) is the intra-priority tie-break. The
939
+ // tier ordering is preserved (a high-priority unconstrained task never jumps
940
+ // ahead of a session/node-pinned task) so targeting stays the outer key and
941
+ // priority is the inner key. Sort is stable, so equal-priority rows keep FIFO.
942
+ const parseTier = (query: string, ...params: unknown[]): MeshWorkQueueEntry[] => {
943
+ const tierRows = this.db.prepare(query).all(...params) as Array<{ payload: string }>;
944
+ return tierRows
945
+ .map(row => JSON.parse(row.payload) as MeshWorkQueueEntry)
946
+ .sort((a, b) => meshTaskPriorityRank(b.priority) - meshTaskPriorityRank(a.priority));
947
+ };
948
+ const candidates = [
949
+ ...parseTier(`
950
+ SELECT payload FROM mesh_queue
951
+ WHERE mesh_id = ? AND status = 'pending' AND target_session_id = ?
952
+ ORDER BY created_at ASC
953
+ `, meshId, sessionId),
954
+ ...parseTier(`
955
+ SELECT payload FROM mesh_queue
956
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id IN (${nodePinnedPlaceholders}) AND target_session_id IS NULL
957
+ ORDER BY created_at ASC
958
+ `, meshId, ...nodeIdForms),
959
+ ...parseTier(`
960
+ SELECT payload FROM mesh_queue
961
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id IS NULL AND target_session_id IS NULL
962
+ ORDER BY created_at ASC
963
+ `, meshId),
964
+ ];
965
+
966
+ // M1: a task with unmet dependencies (or a system blockedReason) is not claimable.
967
+ // Resolve dependency statuses in one query over the union of referenced ids.
968
+ const depIds = [...new Set(candidates.flatMap(c => Array.isArray(c.dependsOn) ? c.dependsOn : []))];
969
+ const depStatus = new Map<string, string>();
970
+ if (depIds.length > 0) {
971
+ const placeholders = depIds.map(() => '?').join(', ');
972
+ const depRows = this.db.prepare(
973
+ `SELECT id, status FROM mesh_queue WHERE mesh_id = ? AND id IN (${placeholders})`
974
+ ).all(meshId, ...depIds) as Array<{ id: string; status: string }>;
975
+ for (const r of depRows) depStatus.set(r.id, r.status);
976
+ }
977
+ // DEPENDSON-GATE-SYMMETRY: the claim gate shares the single
978
+ // taskDependenciesSatisfied predicate with the auto-launch filter and
979
+ // the cloud eager P2P push, so a task blocked here is blocked there too.
980
+ const dependenciesSatisfied = (candidate: MeshWorkQueueEntry): boolean =>
981
+ taskDependenciesSatisfied(candidate, depStatus);
982
+
983
+ // Per-candidate node-conflict gate: write tasks require an idle node; read-only
984
+ // tasks bypass the node-busy check so N read-only diagnoses can run on one node
985
+ // at once. Read-only classification is decided solely by isTaskReadonly (the
986
+ // single predicate shared with the cap counters / auto-launch / guardrail).
987
+ const nodeConflictAllows = (candidate: MeshWorkQueueEntry): boolean => {
988
+ if (isTaskReadonly(candidate)) return true;
989
+ return !nodeBusy;
990
+ };
991
+
992
+ // G7: delayed execution. A task with a notBefore in the future is held pending
993
+ // (skipped as a claim candidate) until the wall clock passes it. Fail-open on an
994
+ // unparseable timestamp (meshTaskNotBeforeReady) so a bad value never strands work.
995
+ const claimNowMs = Date.now();
996
+ const notBeforeReady = (candidate: MeshWorkQueueEntry): boolean =>
997
+ meshTaskNotBeforeReady(candidate, claimNowMs);
998
+
999
+ // WTDISPATCH-FANOUT: a `convergence` task lands its work onto base (merge →
1000
+ // push → cleanup against the real checkout). It must NEVER be claimed by a
1001
+ // co-located worktree-clone session — N sibling worktree sessions on one daemon
1002
+ // each claiming the same convergence intent is the 4-way push/deploy fan-out the
1003
+ // live repro hit. Base-only, fail-closed: when the claiming node is a worktree
1004
+ // (nodeIsWorktree), exclude every convergence candidate so it stays pending for
1005
+ // the base node to pull.
1006
+ const nodeIsWorktree = opts?.nodeIsWorktree === true;
1007
+ const convergenceAllows = (candidate: MeshWorkQueueEntry): boolean =>
1008
+ candidate.taskMode !== 'convergence' || !nodeIsWorktree;
1009
+
1010
+ // WTDISPATCH-FANOUT: defensive exact-target gate. The prioritized SQL above
1011
+ // already segregates session/node-pinned rows, but a future query change (or a
1012
+ // candidate row whose stored target drifted from its column) must never let a
1013
+ // sibling worktree session on the same daemon absorb another node's/session's
1014
+ // pinned task. When a task carries an explicit target, require an exact match
1015
+ // here too — fail-closed.
1016
+ // The target id may have been stamped in a different serialization /
1017
+ // daemon-id form than the claiming session's nodeId (config-form
1018
+ // `daemon_mach_X` vs stamp-form `mach_X`, or the 3-way id/nodeId/node_id
1019
+ // node forms). A raw `!==` here permanently strands a node-pinned task as
1020
+ // an empty session. Accept the candidate when the target resolves to the
1021
+ // same node under ANY equivalent form; keep targetSessionId an exact match.
1022
+ const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
1023
+ // Session ids are single-form (unlike node/daemon ids with their 3
1024
+ // serialization forms requiring expandDaemonIdForms) — see the
1025
+ // sessionIdsEquivalent doc; it is the one canonical exact-match
1026
+ // predicate for them.
1027
+ if (candidate.targetSessionId && !sessionIdsEquivalent(candidate.targetSessionId, sessionId)) return false;
1028
+ if (
1029
+ candidate.targetNodeId
1030
+ && !daemonIdsEquivalent(candidate.targetNodeId, nodeId)
1031
+ && !meshNodeIdMatches({ id: candidate.targetNodeId }, nodeId)
1032
+ ) {
1033
+ return false;
1034
+ }
1035
+ return true;
1036
+ };
1037
+
1038
+ const entry = candidates.find(candidate =>
1039
+ nodeSatisfiesRequiredTags(candidate.requiredTags, capabilityTags)
1040
+ && dependenciesSatisfied(candidate)
1041
+ && notBeforeReady(candidate)
1042
+ && convergenceAllows(candidate)
1043
+ && targetMatches(candidate)
1044
+ && nodeConflictAllows(candidate));
1045
+ if (!entry) return null;
1046
+
1047
+ const now = new Date().toISOString();
1048
+ entry.status = 'assigned';
1049
+ entry.assignedNodeId = nodeId;
1050
+ entry.assignedSessionId = sessionId;
1051
+ if (providerType) entry.assignedProviderType = providerType;
1052
+ entry.dispatchTimestamp = now;
1053
+ // REDRIVE-DUP: bump the per-task dispatch nonce on every claim so this dispatch
1054
+ // carries a nonce strictly greater than any prior (reclaimed) dispatch of the same
1055
+ // task. The worker echoes it on agent:generating_started; the coordinator rejects a
1056
+ // stale-nonce ack so a reclaimed+re-dispatched task's original inject cannot execute.
1057
+ entry.dispatchNonce = (entry.dispatchNonce || 0) + 1;
1058
+ entry.updatedAt = now;
1059
+
1060
+ this.db.prepare(`
1061
+ UPDATE mesh_queue SET
1062
+ status = 'assigned', assigned_node_id = ?, assigned_session_id = ?,
1063
+ updated_at = ?, payload = ?
1064
+ WHERE id = ? AND mesh_id = ?
1065
+ `).run(nodeId, sessionId, now, JSON.stringify(entry), entry.id, meshId);
1066
+
1067
+ this.maybeCheckpointWal();
1068
+ return entry;
1069
+ });
1070
+ }
1071
+
1072
+ getQueueStatsByStatus(meshId: string): { status: string; count: number }[] {
1073
+ this.ensureLegacyQueueMigrated(meshId);
1074
+ return this.db.prepare(
1075
+ `SELECT status, COUNT(*) as count FROM mesh_queue WHERE mesh_id = ? GROUP BY status`
1076
+ ).all(meshId) as { status: string; count: number }[];
1077
+ }
1078
+
1079
+ getActiveAssignmentDetails(meshId: string): Array<{ id: string; nodeId?: string; sessionId?: string; message: string }> {
1080
+ this.ensureLegacyQueueMigrated(meshId);
1081
+ const rows = this.db.prepare(`
1082
+ SELECT assigned_node_id, assigned_session_id, payload
1083
+ FROM mesh_queue WHERE mesh_id = ? AND status = 'assigned'
1084
+ `).all(meshId) as Array<{ assigned_node_id: string | null; assigned_session_id: string | null; payload: string }>;
1085
+ return rows.map(r => {
1086
+ let id = '', message = '';
1087
+ try { const e = JSON.parse(r.payload) as MeshWorkQueueEntry; id = e.id; message = e.message; } catch { /* ignore */ }
1088
+ return { id, nodeId: r.assigned_node_id ?? undefined, sessionId: r.assigned_session_id ?? undefined, message };
1089
+ });
1090
+ }
1091
+
1092
+ /**
1093
+ * Resolve the `assigned` queue row a completion event belongs to.
1094
+ *
1095
+ * Clock-skew safety (C2): a completion event's `occurredAtIso` carries the
1096
+ * REMOTE WORKER's clock, while `updated_at` carries the COORDINATOR's clock
1097
+ * (set at assignment and re-bumped on every mutation). For a remote node,
1098
+ * coordinator-clock > worker-clock skew used to make an `updated_at <= occurredAt`
1099
+ * filter return nothing, stranding the finished task as `assigned` forever.
1100
+ *
1101
+ * We therefore NEVER filter completion-matching on the mutable `updated_at`:
1102
+ * 1. If `taskId` is given, match the exact `assigned` row by id (no time filter).
1103
+ * 2. Otherwise a session holds at most one `assigned` task — match it without a
1104
+ * time filter. If several exist (shouldn't normally), disambiguate by the
1105
+ * IMMUTABLE `dispatchTimestamp`: latest `dispatchTimestamp <= occurredAt`,
1106
+ * and if skew makes ALL of them later than `occurredAt`, fall back to the
1107
+ * most-recent `dispatchTimestamp` rather than returning null.
1108
+ */
1109
+ findAssignedBySession(
1110
+ meshId: string,
1111
+ sessionId: string,
1112
+ occurredAtIso?: string,
1113
+ taskId?: string,
1114
+ ): MeshWorkQueueEntry | null {
1115
+ this.ensureLegacyQueueMigrated(meshId);
1116
+
1117
+ // WRITE/READ PREDICATE SYMMETRY (COMPLETION-PROPAGATION F1): the claim path writes
1118
+ // assigned_session_id RAW (claimNextTask), and the sibling gates that decide whether a
1119
+ // session already holds work (sessionHasActiveAssignment) and which pending row a
1120
+ // session may claim (targetMatches) compare it through sessionIdsEquivalent — the
1121
+ // canonical single-form predicate that TRIMS both sides. A raw SQL `assigned_session_id
1122
+ // = ?` here is asymmetric with that write/sibling predicate: a completion whose
1123
+ // resolveEventSessionId-reinterpreted sessionId is equivalent-but-not-byte-identical to
1124
+ // the stored column (e.g. a whitespace/serialization skew from a manually-launched
1125
+ // session) silently fetched zero rows and stranded the finished task as `assigned`
1126
+ // forever (the mesh-work-queue :1251 "N assigned row(s) exist" warning is that exact
1127
+ // signature). Fetch every `assigned` row for the mesh and filter session membership in
1128
+ // JS with sessionIdsEquivalent, mirroring the node-id IN(...)+JS-revalidate pattern the
1129
+ // claim SELECT uses (claimNextTask :720-736 / targetMatches :797-811).
1130
+ const allRows = this.db.prepare(
1131
+ `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND status = 'assigned'`
1132
+ ).all(meshId) as Array<{ payload: string }>;
1133
+ const sessionEntries = allRows
1134
+ .map(r => { try { return JSON.parse(r.payload) as MeshWorkQueueEntry; } catch { return null; } })
1135
+ .filter((e): e is MeshWorkQueueEntry => e !== null)
1136
+ .filter(e => sessionIdsEquivalent(e.assignedSessionId, sessionId));
1137
+
1138
+ // 1. Exact taskId match — robust against clock skew and stale rows. Scoped to the
1139
+ // session-equivalent set (as the raw `AND assigned_session_id = ? AND id = ?` was),
1140
+ // now via the trimming equivalence predicate.
1141
+ if (taskId) {
1142
+ const byId = sessionEntries.find(e => e.id === taskId);
1143
+ if (byId) return byId;
1144
+ // Fall through to session-based matching if the id didn't line up
1145
+ // (e.g. event carried a stale/foreign taskId).
1146
+ }
1147
+
1148
+ // 2. Session-based match WITHOUT the mutable updated_at filter.
1149
+ const entries = sessionEntries;
1150
+ if (entries.length === 0) return null;
1151
+ if (entries.length === 1) return entries[0];
1152
+
1153
+ // Multiple assigned rows for one session: disambiguate by the immutable
1154
+ // dispatchTimestamp (falling back to updated_at only for legacy rows that
1155
+ // predate dispatchTimestamp). We use these to ORDER, never to FILTER —
1156
+ // so a skewed occurredAt can never drop the live row to null.
1157
+ const orderKey = (e: MeshWorkQueueEntry) => e.dispatchTimestamp ?? e.updatedAt ?? '';
1158
+ const byDispatchDesc = [...entries].sort((a, b) => orderKey(b).localeCompare(orderKey(a)));
1159
+ if (occurredAtIso) {
1160
+ const atOrBefore = byDispatchDesc.find(e => orderKey(e) <= occurredAtIso);
1161
+ if (atOrBefore) return atOrBefore;
1162
+ }
1163
+ // Skew made every dispatch later than occurredAt — fall back to the
1164
+ // most-recently dispatched row rather than stranding the completion.
1165
+ return byDispatchDesc[0];
1166
+ }
1167
+
1168
+ private toRow(entry: MeshWorkQueueEntry): Record<string, unknown> {
1169
+ return {
1170
+ id: entry.id,
1171
+ meshId: entry.meshId,
1172
+ status: entry.status,
1173
+ targetNodeId: entry.targetNodeId ?? null,
1174
+ targetSessionId: entry.targetSessionId ?? null,
1175
+ assignedNodeId: entry.assignedNodeId ?? null,
1176
+ assignedSessionId: entry.assignedSessionId ?? null,
1177
+ createdAt: entry.createdAt,
1178
+ updatedAt: entry.updatedAt,
1179
+ payload: JSON.stringify(entry),
1180
+ };
1181
+ }
1182
+
1183
+ // ── Direct Dispatch Tracking ─────────────────────────────────────────────
1184
+
1185
+ insertDirectDispatch(entry: {
1186
+ taskId: string;
1187
+ meshId: string;
1188
+ nodeId?: string;
1189
+ sessionId?: string;
1190
+ providerType?: string;
1191
+ message: string;
1192
+ taskMode?: string;
1193
+ via: string;
1194
+ dispatchedToIdleSession?: boolean;
1195
+ dispatchedAt: string;
1196
+ }): void {
1197
+ const now = new Date().toISOString();
1198
+ this.db.prepare(`
1199
+ INSERT OR REPLACE INTO mesh_direct_dispatches
1200
+ (task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
1201
+ status, dispatched_to_idle_session, dispatched_at, updated_at)
1202
+ VALUES
1203
+ (@taskId, @meshId, @nodeId, @sessionId, @providerType, @message, @taskMode, @via,
1204
+ 'dispatched', @dispatchedToIdle, @dispatchedAt, @updatedAt)
1205
+ `).run({
1206
+ taskId: entry.taskId,
1207
+ meshId: entry.meshId,
1208
+ nodeId: entry.nodeId ?? null,
1209
+ sessionId: entry.sessionId ?? null,
1210
+ providerType: entry.providerType ?? null,
1211
+ message: entry.message,
1212
+ taskMode: entry.taskMode ?? null,
1213
+ via: entry.via,
1214
+ dispatchedToIdle: entry.dispatchedToIdleSession ? 1 : 0,
1215
+ dispatchedAt: entry.dispatchedAt,
1216
+ updatedAt: now,
1217
+ });
1218
+ }
1219
+
1220
+ getActiveDirectDispatches(meshId: string): Array<{
1221
+ taskId: string;
1222
+ meshId: string;
1223
+ nodeId: string | null;
1224
+ sessionId: string | null;
1225
+ providerType: string | null;
1226
+ message: string;
1227
+ taskMode: string | null;
1228
+ via: string;
1229
+ status: string;
1230
+ dispatchedToIdleSession: boolean;
1231
+ dispatchedAt: string;
1232
+ updatedAt: string;
1233
+ }> {
1234
+ const rows = this.db.prepare(`
1235
+ SELECT task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
1236
+ status, dispatched_to_idle_session, dispatched_at, updated_at
1237
+ FROM mesh_direct_dispatches
1238
+ WHERE mesh_id = ? AND status NOT IN ('completed', 'failed', 'stale')
1239
+ ORDER BY dispatched_at ASC
1240
+ `).all(meshId) as Array<Record<string, unknown>>;
1241
+ return rows.map(r => ({
1242
+ taskId: r.task_id as string,
1243
+ meshId: r.mesh_id as string,
1244
+ nodeId: r.node_id as string | null,
1245
+ sessionId: r.session_id as string | null,
1246
+ providerType: r.provider_type as string | null,
1247
+ message: r.message as string,
1248
+ taskMode: r.task_mode as string | null,
1249
+ via: r.via as string,
1250
+ status: r.status as string,
1251
+ dispatchedToIdleSession: (r.dispatched_to_idle_session as number) === 1,
1252
+ dispatchedAt: r.dispatched_at as string,
1253
+ updatedAt: r.updated_at as string,
1254
+ }));
1255
+ }
1256
+
1257
+ // CANON-B (dispatch identity): mesh_direct_dispatches is keyed by task_id (PK), but a
1258
+ // single session can host several sequential direct dispatches (re-dispatch / nudge), so
1259
+ // matching a status flip by session_id alone hits EVERY non-terminal row for that session
1260
+ // — flipping a sibling task's row and stranding the one whose event actually fired (the
1261
+ // assigned-stranded watchdog then requeues a task that is really still generating). When
1262
+ // the firing event carries a taskId, target the single PK row; the session_id match is the
1263
+ // legacy fallback only for events that arrive without a taskId.
1264
+ updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void {
1265
+ const now = new Date().toISOString();
1266
+ if (taskId) {
1267
+ this.db.prepare(`
1268
+ UPDATE mesh_direct_dispatches
1269
+ SET status = @status, updated_at = @updatedAt
1270
+ WHERE mesh_id = @meshId AND task_id = @taskId
1271
+ AND status NOT IN ('completed', 'failed')
1272
+ `).run({ status, meshId, taskId, updatedAt: now });
1273
+ return;
1274
+ }
1275
+ if (!sessionId) return; // never update rows without a session binding
1276
+ this.db.prepare(`
1277
+ UPDATE mesh_direct_dispatches
1278
+ SET status = @status, updated_at = @updatedAt
1279
+ WHERE mesh_id = @meshId AND session_id = @sessionId
1280
+ AND session_id IS NOT NULL
1281
+ AND status NOT IN ('completed', 'failed')
1282
+ `).run({ status, meshId, sessionId, updatedAt: now });
1283
+ }
1284
+
1285
+ /**
1286
+ * MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
1287
+ * non-terminal direct dispatch a session owns. Returns the task_id only when the session
1288
+ * holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
1289
+ * lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
1290
+ * unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
1291
+ * two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
1292
+ * return null and the caller MUST NOT fall back to the session_id sweep that would flip a
1293
+ * sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
1294
+ * only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
1295
+ */
1296
+ getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null {
1297
+ if (!sessionId) return null;
1298
+ const rows = this.db.prepare(`
1299
+ SELECT task_id FROM mesh_direct_dispatches
1300
+ WHERE mesh_id = ? AND session_id = ?
1301
+ AND status NOT IN ('completed', 'failed', 'stale')
1302
+ `).all(meshId, sessionId) as Array<{ task_id: string }>;
1303
+ if (rows.length !== 1) return null;
1304
+ const taskId = typeof rows[0]?.task_id === 'string' ? rows[0].task_id.trim() : '';
1305
+ return taskId || null;
1306
+ }
1307
+
1308
+ cleanupTerminalDirectDispatches(olderThanMs: number): void {
1309
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
1310
+ this.db.prepare(`
1311
+ DELETE FROM mesh_direct_dispatches
1312
+ WHERE status IN ('completed', 'failed', 'stale') AND updated_at < ?
1313
+ `).run(cutoff);
1314
+ }
1315
+
1316
+ deleteDirectDispatches(meshId: string): void {
1317
+ this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ?`).run(meshId);
1318
+ }
1319
+
1320
+ /**
1321
+ * Delete specific direct dispatch rows by taskId for a mesh. Used by the staleDirect prune
1322
+ * path to remove orphaned/terminal dispatch records whose node/session is no longer in the
1323
+ * live mesh. Returns the number of rows actually deleted. No-op for an empty taskId list.
1324
+ */
1325
+ deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
1326
+ const ids = (taskIds || []).map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean);
1327
+ if (!ids.length) return 0;
1328
+ const stmt = this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ? AND task_id = ?`);
1329
+ let deleted = 0;
1330
+ const run = this.db.transaction((rows: string[]) => {
1331
+ for (const taskId of rows) {
1332
+ deleted += stmt.run(meshId, taskId).changes;
1333
+ }
1334
+ });
1335
+ run(ids);
1336
+ return deleted;
1337
+ }
1338
+
1339
+ markStaleDirectDispatches(meshId: string, olderThanMs: number): void {
1340
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
1341
+ const now = new Date().toISOString();
1342
+ this.db.prepare(`
1343
+ UPDATE mesh_direct_dispatches
1344
+ SET status = 'stale', updated_at = ?
1345
+ WHERE mesh_id = ? AND status = 'dispatched' AND dispatched_at < ?
1346
+ `).run(now, meshId, cutoff);
1347
+ }
1348
+
1349
+ // ── Remote Idle Sessions ─────────────────────────────────────────────────
1350
+
1351
+ setRemoteIdleSession(meshId: string, nodeId: string, sessionId: string, providerType: string, expiresAt: number, metadata?: any): void {
1352
+ this.db.prepare(`
1353
+ INSERT OR REPLACE INTO remote_idle_sessions (mesh_id, node_id, session_id, provider_type, expires_at, metadata)
1354
+ VALUES (?, ?, ?, ?, ?, ?)
1355
+ `).run(meshId, nodeId, sessionId, providerType, expiresAt, metadata ? JSON.stringify(metadata) : null);
1356
+ }
1357
+
1358
+ getRemoteIdleSessions(meshId: string): Array<{ nodeId: string; sessionId: string; providerType: string; expiresAt: number; metadata?: any }> {
1359
+ // MESH-ISOLATION-LEAK: always mesh-scoped. A bare (cross-mesh) read here is what
1360
+ // let mesh B claim mesh A's idle session when both share a nodeId.
1361
+ const rows = this.db.prepare('SELECT node_id, session_id, provider_type, expires_at, metadata FROM remote_idle_sessions WHERE mesh_id = ?').all(meshId) as Array<any>;
1362
+ return rows.map(r => ({
1363
+ nodeId: r.node_id,
1364
+ sessionId: r.session_id,
1365
+ providerType: r.provider_type,
1366
+ expiresAt: r.expires_at,
1367
+ metadata: r.metadata ? JSON.parse(r.metadata) : undefined,
1368
+ }));
1369
+ }
1370
+
1371
+ deleteRemoteIdleSession(meshId: string, nodeId: string, sessionId: string): void {
1372
+ this.db.prepare('DELETE FROM remote_idle_sessions WHERE mesh_id = ? AND node_id = ? AND session_id = ?').run(meshId, nodeId, sessionId);
1373
+ }
1374
+
1375
+ pruneExpiredRemoteIdleSessions(): void {
1376
+ this.db.prepare('DELETE FROM remote_idle_sessions WHERE expires_at <= ?').run(Date.now());
1377
+ }
1378
+
1379
+ // ── Session Delivery Queue ───────────────────────────────────────────────
1380
+
1381
+ insertSessionDelivery(entry: {
1382
+ id: string;
1383
+ meshId: string;
1384
+ nodeId?: string;
1385
+ sessionId?: string;
1386
+ providerType?: string;
1387
+ taskId?: string;
1388
+ kind: string;
1389
+ priority?: number;
1390
+ message: string;
1391
+ status: string;
1392
+ deliverAfter?: string;
1393
+ expiresAt?: string;
1394
+ sourceCoordinatorSessionId?: string;
1395
+ sourceCoordinatorDaemonId?: string;
1396
+ createdAt: string;
1397
+ updatedAt: string;
1398
+ }): void {
1399
+ this.db.prepare(`
1400
+ INSERT OR REPLACE INTO mesh_session_delivery (
1401
+ id, mesh_id, node_id, session_id, provider_type, task_id, kind, priority,
1402
+ message, status, deliver_after, expires_at, attempt_count,
1403
+ source_coordinator_session_id, source_coordinator_daemon_id,
1404
+ last_error, created_at, updated_at
1405
+ ) VALUES (
1406
+ @id, @meshId, @nodeId, @sessionId, @providerType, @taskId, @kind, @priority,
1407
+ @message, @status, @deliverAfter, @expiresAt, 0,
1408
+ @sourceCoordinatorSessionId, @sourceCoordinatorDaemonId,
1409
+ NULL, @createdAt, @updatedAt
1410
+ )
1411
+ `).run({
1412
+ id: entry.id,
1413
+ meshId: entry.meshId,
1414
+ nodeId: entry.nodeId ?? null,
1415
+ sessionId: entry.sessionId ?? null,
1416
+ providerType: entry.providerType ?? null,
1417
+ taskId: entry.taskId ?? null,
1418
+ kind: entry.kind,
1419
+ priority: entry.priority ?? 0,
1420
+ message: entry.message,
1421
+ status: entry.status,
1422
+ deliverAfter: entry.deliverAfter ?? null,
1423
+ expiresAt: entry.expiresAt ?? null,
1424
+ sourceCoordinatorSessionId: entry.sourceCoordinatorSessionId ?? null,
1425
+ sourceCoordinatorDaemonId: entry.sourceCoordinatorDaemonId ?? null,
1426
+ createdAt: entry.createdAt,
1427
+ updatedAt: entry.updatedAt,
1428
+ });
1429
+ this.maybeCheckpointWal();
1430
+ }
1431
+
1432
+ updateSessionDeliveryStatus(id: string, status: string, opts?: { lastError?: string; incrementAttempt?: boolean }): void {
1433
+ const now = new Date().toISOString();
1434
+ if (opts?.incrementAttempt) {
1435
+ this.db.prepare(`
1436
+ UPDATE mesh_session_delivery
1437
+ SET status = @status, last_error = @lastError, attempt_count = attempt_count + 1, updated_at = @updatedAt
1438
+ WHERE id = @id
1439
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
1440
+ } else {
1441
+ this.db.prepare(`
1442
+ UPDATE mesh_session_delivery
1443
+ SET status = @status, last_error = @lastError, updated_at = @updatedAt
1444
+ WHERE id = @id
1445
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
1446
+ }
1447
+ }
1448
+
1449
+ getActiveSessionDeliveries(meshId: string, sessionId?: string): Array<{
1450
+ id: string; meshId: string; nodeId: string | null; sessionId: string | null;
1451
+ providerType: string | null; taskId: string | null; kind: string; priority: number;
1452
+ message: string; status: string; deliverAfter: string | null; expiresAt: string | null;
1453
+ attemptCount: number; sourceCoordinatorSessionId: string | null;
1454
+ sourceCoordinatorDaemonId: string | null; lastError: string | null;
1455
+ createdAt: string; updatedAt: string;
1456
+ }> {
1457
+ const now = new Date().toISOString();
1458
+ const sql = sessionId
1459
+ ? `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND session_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`
1460
+ : `SELECT * FROM mesh_session_delivery WHERE mesh_id = ? AND status NOT IN ('delivered','completed','failed','expired','cancelled') AND (expires_at IS NULL OR expires_at > ?) ORDER BY priority DESC, created_at ASC`;
1461
+ const rows = sessionId
1462
+ ? this.db.prepare(sql).all(meshId, sessionId, now) as Array<Record<string, unknown>>
1463
+ : this.db.prepare(sql).all(meshId, now) as Array<Record<string, unknown>>;
1464
+ return rows.map(r => ({
1465
+ id: r.id as string,
1466
+ meshId: r.mesh_id as string,
1467
+ nodeId: r.node_id as string | null,
1468
+ sessionId: r.session_id as string | null,
1469
+ providerType: r.provider_type as string | null,
1470
+ taskId: r.task_id as string | null,
1471
+ kind: r.kind as string,
1472
+ priority: r.priority as number,
1473
+ message: r.message as string,
1474
+ status: r.status as string,
1475
+ deliverAfter: r.deliver_after as string | null,
1476
+ expiresAt: r.expires_at as string | null,
1477
+ attemptCount: r.attempt_count as number,
1478
+ sourceCoordinatorSessionId: r.source_coordinator_session_id as string | null,
1479
+ sourceCoordinatorDaemonId: r.source_coordinator_daemon_id as string | null,
1480
+ lastError: r.last_error as string | null,
1481
+ createdAt: r.created_at as string,
1482
+ updatedAt: r.updated_at as string,
1483
+ }));
1484
+ }
1485
+
1486
+ /**
1487
+ * Bug B watchdog support: true when at least one delivery record for the task has
1488
+ * reached a confirmed-handed-off status (delivered / acked / completed). The
1489
+ * assigned-stranded watchdog uses this to distinguish a dispatch that was never
1490
+ * confirmed (reclaimable) from one that WAS handed to the worker (a genuinely
1491
+ * in-flight or completion-lost task, which is PHASE 4's responsibility, not this
1492
+ * watchdog's). Indexed by (mesh_id, task_id).
1493
+ */
1494
+ taskHasConfirmedDelivery(meshId: string, taskId: string): boolean {
1495
+ const row = this.db.prepare(`
1496
+ SELECT 1 FROM mesh_session_delivery
1497
+ WHERE mesh_id = ? AND task_id = ? AND status IN ('delivered','acked','completed')
1498
+ LIMIT 1
1499
+ `).get(meshId, taskId) as { 1: number } | undefined;
1500
+ return !!row;
1501
+ }
1502
+
1503
+ /**
1504
+ * DELIVERED-NOT-CONSUMED re-drive support: true when at least one delivery record for
1505
+ * the task has reached a CONSUMED status ('acked' / 'completed'). Distinct from
1506
+ * {@link taskHasConfirmedDelivery} ('delivered' | 'acked' | 'completed'): a delivery is
1507
+ * flipped to 'delivered' the instant the transport hands the dispatch off, but only
1508
+ * flipped to 'acked' when the worker's agent:generating_started event arrives (see the
1509
+ * generating_started handler in mesh-event-forwarding) — i.e. when the session has
1510
+ * actually begun the turn. That distinction is the cross-daemon consumption signal the
1511
+ * short-grace re-drive uses: a row whose delivery is 'delivered' but never 'acked' was
1512
+ * handed to a REMOTE worker that never started generating — the remote autoLaunch
1513
+ * delivered≠consumed gap — even when the session's busy verdict is UNKNOWN (not locally
1514
+ * observable). Indexed by (mesh_id, task_id).
1515
+ */
1516
+ taskDeliveryConsumed(meshId: string, taskId: string): boolean {
1517
+ const row = this.db.prepare(`
1518
+ SELECT 1 FROM mesh_session_delivery
1519
+ WHERE mesh_id = ? AND task_id = ? AND status IN ('acked','completed')
1520
+ LIMIT 1
1521
+ `).get(meshId, taskId) as { 1: number } | undefined;
1522
+ return !!row;
1523
+ }
1524
+
1525
+ expireStaleSessionDeliveries(meshId: string): void {
1526
+ const now = new Date().toISOString();
1527
+ this.db.prepare(`
1528
+ UPDATE mesh_session_delivery
1529
+ SET status = 'expired', updated_at = ?
1530
+ WHERE mesh_id = ? AND expires_at IS NOT NULL AND expires_at <= ?
1531
+ AND status NOT IN ('delivered','completed','failed','expired','cancelled')
1532
+ `).run(now, meshId, now);
1533
+ }
1534
+
1535
+ deleteSessionDeliveries(meshId: string): void {
1536
+ this.db.prepare('DELETE FROM mesh_session_delivery WHERE mesh_id = ?').run(meshId);
1537
+ }
1538
+
1539
+ // ── Completion Conflict Diagnostics ──────────────────────────────────────
1540
+
1541
+ // MESH-COMPLEXITY-AUDIT Part 8-2: recordCompletionConflict /
1542
+ // getRecentCompletionConflicts (and their mesh_completion_conflicts table)
1543
+ // were removed. They were a write-only diagnostic of fingerprint-dedup
1544
+ // collisions with no production reader and no part in the no-loss delivery
1545
+ // contract; the table is dropped in migrateMeshIsolationColumns (step 6).
1546
+
1547
+ /**
1548
+ * Record a mesh tool call and check whether this mesh+tool combination is
1549
+ * being called too rapidly (sliding window rate guard).
1550
+ *
1551
+ * Returns a rate-limit advisory string when the call rate is too high, null otherwise.
1552
+ * windowMs: sliding window size in ms (default 10s)
1553
+ * maxCalls: max allowed calls within the window (default 5)
1554
+ */
1555
+ recordMeshToolCall(opts: {
1556
+ meshId: string;
1557
+ tool: string;
1558
+ sessionId?: string | null;
1559
+ windowMs?: number;
1560
+ maxCalls?: number;
1561
+ }): { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null } {
1562
+ const { meshId, tool, sessionId = null } = opts;
1563
+ const windowMs = opts.windowMs ?? 10_000;
1564
+ const maxCalls = opts.maxCalls ?? 5;
1565
+ const now = Date.now();
1566
+ const windowStart = now - windowMs;
1567
+
1568
+ this.db.prepare(
1569
+ 'INSERT INTO mesh_tool_call_log (mesh_id, tool, session_id, called_at) VALUES (?, ?, ?, ?)'
1570
+ ).run(meshId, tool, sessionId, now);
1571
+
1572
+ const row = this.db.prepare(
1573
+ 'SELECT COUNT(*) as cnt FROM mesh_tool_call_log WHERE mesh_id = ? AND tool = ? AND called_at >= ?'
1574
+ ).get(meshId, tool, windowStart) as { cnt: number };
1575
+ const callsInWindow = row?.cnt ?? 0;
1576
+
1577
+ // Sweep old entries periodically to keep the table lean (every 200 calls across all tools).
1578
+ if (++this.toolCallLogCounter % 200 === 0) {
1579
+ this.db.prepare(
1580
+ 'DELETE FROM mesh_tool_call_log WHERE called_at < ?'
1581
+ ).run(now - Math.max(windowMs * 10, 60_000));
1582
+ }
1583
+
1584
+ if (callsInWindow > maxCalls) {
1585
+ const advisory = `Rate limit: ${tool} called ${callsInWindow} times in the last ${windowMs / 1000}s for mesh ${meshId}. `
1586
+ + `Wait for pendingCoordinatorEvents or an explicit user status request before calling again.`;
1587
+ return { rateLimitExceeded: true, callsInWindow, advisory };
1588
+ }
1589
+ return { rateLimitExceeded: false, callsInWindow, advisory: null };
1590
+ }
1591
+
1592
+ /**
1593
+ * Prune tool call log entries older than the given age in ms.
1594
+ * Returns the number of rows deleted. Also used by the periodic retention
1595
+ * sweep (pruneMeshRuntimeRetention) — the in-write sweep in recordMeshToolCall
1596
+ * only fires every 200 calls and only covers the rate-limit window, so a
1597
+ * quiet mesh otherwise accumulates rows indefinitely.
1598
+ */
1599
+ pruneToolCallLog(olderThanMs: number): number {
1600
+ return this.db.prepare('DELETE FROM mesh_tool_call_log WHERE called_at < ?').run(Date.now() - olderThanMs).changes;
1601
+ }
1602
+
1603
+ /**
1604
+ * Retention prune for mesh_event_ledger (SoT 1-11 (b)). The ledger is append-only
1605
+ * with NO lifecycle GC of its own, so lifecycle events accumulate without bound
1606
+ * (the dominant mesh-runtime.db growth). Every production reader is bounded to a
1607
+ * recent window (readLedgerEntries tail/limit ≤ a few hundred; task-stats /
1608
+ * terminal-evidence scans look at recent tasks), so rows past a generous age only
1609
+ * cost space. Excluded from deletion — retained forever:
1610
+ * - coordinator_operating_note / _tombstone: runtime-accumulated lessons whose
1611
+ * whole point is surviving restarts; a tombstone must also outlive the notes
1612
+ * it retracts.
1613
+ * Timestamps are ISO-8601 TEXT, so the lexicographic `<` cutoff is a correct time
1614
+ * comparison; a malformed timestamp compares greater than any ISO date and is
1615
+ * conservatively retained. Returns rows deleted.
1616
+ */
1617
+ pruneEventLedger(olderThanMs: number): number {
1618
+ const cutoffIso = new Date(Date.now() - Math.max(0, olderThanMs)).toISOString();
1619
+ return this.db.prepare(
1620
+ `DELETE FROM mesh_event_ledger
1621
+ WHERE timestamp < ?
1622
+ AND kind NOT IN ('coordinator_operating_note', 'coordinator_operating_note_tombstone')`
1623
+ ).run(cutoffIso).changes;
1624
+ }
1625
+
1626
+ /**
1627
+ * Retention prune for TERMINAL (completed/cancelled/failed) mesh_queue rows
1628
+ * (SoT 1-11 (b)). Terminal rows are kept as recent history (mesh_task_history,
1629
+ * completion-dedup taskId lookups) but nothing ever deletes them, so the queue
1630
+ * table grows monotonically. Rows past the retention window serve no reader —
1631
+ * every dedup/attribution path operates on recent tasks — EXCEPT as a dependency
1632
+ * anchor: taskDependenciesSatisfied resolves dependsOn by id and treats a MISSING
1633
+ * row as not-completed, so deleting a completed row that a still-live
1634
+ * (pending/assigned) row depends on would permanently strand the dependent.
1635
+ * Those ids are collected first and excluded. Returns rows deleted.
1636
+ */
1637
+ pruneTerminalQueueEntries(olderThanMs: number): number {
1638
+ const cutoffIso = new Date(Date.now() - Math.max(0, olderThanMs)).toISOString();
1639
+ return this.transaction(() => {
1640
+ // Dependency guard: protect every id a live row still depends on.
1641
+ const liveRows = this.db.prepare(
1642
+ `SELECT payload FROM mesh_queue WHERE status IN ('pending', 'assigned')`
1643
+ ).all() as Array<{ payload: string }>;
1644
+ const protectedIds = new Set<string>();
1645
+ for (const row of liveRows) {
1646
+ try {
1647
+ const entry = JSON.parse(row.payload) as MeshWorkQueueEntry;
1648
+ if (Array.isArray(entry.dependsOn)) {
1649
+ for (const dep of entry.dependsOn) {
1650
+ if (typeof dep === 'string' && dep) protectedIds.add(dep);
1651
+ }
1652
+ }
1653
+ } catch { /* unparsable payload → nothing to protect */ }
1654
+ }
1655
+ const candidates = this.db.prepare(
1656
+ `SELECT id FROM mesh_queue
1657
+ WHERE status IN ('completed', 'cancelled', 'failed') AND updated_at < ?`
1658
+ ).all(cutoffIso) as Array<{ id: string }>;
1659
+ const deletable = candidates.map(r => r.id).filter(id => !protectedIds.has(id));
1660
+ let removed = 0;
1661
+ // Chunk the DELETE to stay well under SQLite's bind-parameter limit.
1662
+ for (let i = 0; i < deletable.length; i += 500) {
1663
+ const chunk = deletable.slice(i, i + 500);
1664
+ removed += this.db.prepare(
1665
+ `DELETE FROM mesh_queue WHERE id IN (${chunk.map(() => '?').join(',')})`
1666
+ ).run(...chunk).changes;
1667
+ }
1668
+ return removed;
1669
+ });
1670
+ }
1671
+
1672
+ // ── G2: Event Ledger ────────────────────────────────────────────────────
1673
+
1674
+ appendLedgerEntry(entry: {
1675
+ id: string;
1676
+ meshId: string;
1677
+ timestamp: string;
1678
+ kind: string;
1679
+ nodeId?: string | null;
1680
+ sessionId?: string | null;
1681
+ providerType?: string | null;
1682
+ payload?: unknown;
1683
+ }): void {
1684
+ // Ledger `kind` is a mandatory schema invariant (mesh_event_ledger.kind is
1685
+ // NOT NULL; every MeshLedgerKind is a non-empty tag). A blank kind would be a
1686
+ // structurally-broken entry — reject it here rather than write an unqueryable
1687
+ // row. NOTE: pending-event JSONL files (`*.pending-events.jsonl`) are a
1688
+ // SEPARATE shape that intentionally has NO `kind` field (they key off `.event`);
1689
+ // a generic audit that scans the whole ledger DIRECTORY and reads `.kind` off
1690
+ // those rows sees "kind=None", which is an artifact of mixing the two files, not
1691
+ // a ledger defect. This guard makes the ledger-side invariant explicit.
1692
+ if (!entry.kind || !String(entry.kind).trim()) {
1693
+ LOG.warn('MeshRuntimeStore', `Refusing to append ledger entry with empty kind for mesh ${entry.meshId} (id ${entry.id})`);
1694
+ return;
1695
+ }
1696
+ this.db.prepare(
1697
+ `INSERT OR IGNORE INTO mesh_event_ledger
1698
+ (id, mesh_id, timestamp, kind, node_id, session_id, provider_type, payload)
1699
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1700
+ ).run(
1701
+ entry.id,
1702
+ entry.meshId,
1703
+ entry.timestamp,
1704
+ entry.kind,
1705
+ entry.nodeId ?? null,
1706
+ entry.sessionId ?? null,
1707
+ entry.providerType ?? null,
1708
+ JSON.stringify(entry.payload ?? {}),
1709
+ );
1710
+ this.maybeCheckpointWal();
1711
+ }
1712
+
1713
+ readLedgerEntries(meshId: string, opts?: {
1714
+ tail?: number;
1715
+ since?: string;
1716
+ kind?: string;
1717
+ limit?: number;
1718
+ }): Array<{ id: string; meshId: string; timestamp: string; kind: string; nodeId: string | null; sessionId: string | null; providerType: string | null; payload: unknown }> {
1719
+ const limit = opts?.tail ?? opts?.limit ?? 200;
1720
+ let query: string;
1721
+ const params: unknown[] = [meshId];
1722
+ if (opts?.kind && opts?.since) {
1723
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? AND kind = ? AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?`;
1724
+ params.push(opts.kind, opts.since, limit);
1725
+ } else if (opts?.kind) {
1726
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? AND kind = ? ORDER BY timestamp DESC LIMIT ?`;
1727
+ params.push(opts.kind, limit);
1728
+ } else if (opts?.since) {
1729
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?`;
1730
+ params.push(opts.since, limit);
1731
+ } else {
1732
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? ORDER BY timestamp DESC LIMIT ?`;
1733
+ params.push(limit);
1734
+ }
1735
+ const rows = this.db.prepare(query).all(...params) as Array<Record<string, unknown>>;
1736
+ return rows.map(r => ({
1737
+ id: r.id as string,
1738
+ meshId: r.mesh_id as string,
1739
+ timestamp: r.timestamp as string,
1740
+ kind: r.kind as string,
1741
+ nodeId: r.node_id as string | null,
1742
+ sessionId: r.session_id as string | null,
1743
+ providerType: r.provider_type as string | null,
1744
+ payload: (() => { try { return JSON.parse(r.payload as string); } catch { return {}; } })(),
1745
+ }));
1746
+ }
1747
+
1748
+ /**
1749
+ * G2 read cutover: read ledger entries in append order (oldest first),
1750
+ * matching legacy JSONL file-order semantics. Ties on the same timestamp
1751
+ * are broken by rowid (insertion order), preserving the positional
1752
+ * guarantee that mesh-events relies on for same-millisecond entries.
1753
+ */
1754
+ readLedgerEntriesOrdered(meshId: string, opts?: {
1755
+ since?: string;
1756
+ kinds?: string[];
1757
+ tail?: number;
1758
+ }): Array<{ id: string; meshId: string; timestamp: string; kind: string; nodeId: string | null; sessionId: string | null; providerType: string | null; payload: unknown }> {
1759
+ const params: unknown[] = [meshId];
1760
+ let whereClause = 'mesh_id = ?';
1761
+ if (opts?.since) {
1762
+ whereClause += ' AND timestamp >= ?';
1763
+ params.push(opts.since);
1764
+ }
1765
+ const kinds = Array.isArray(opts?.kinds) ? opts.kinds.filter(k => typeof k === 'string' && k.trim()) : [];
1766
+ if (kinds.length > 0) {
1767
+ whereClause += ` AND kind IN (${kinds.map(() => '?').join(', ')})`;
1768
+ params.push(...kinds);
1769
+ }
1770
+ let query: string;
1771
+ if (opts?.tail && opts.tail > 0) {
1772
+ // Tail: newest N in append order — inner DESC limit, outer re-sort ASC.
1773
+ query = `SELECT * FROM (
1774
+ SELECT rowid AS rid, * FROM mesh_event_ledger WHERE ${whereClause}
1775
+ ORDER BY timestamp DESC, rowid DESC LIMIT ?
1776
+ ) ORDER BY timestamp ASC, rid ASC`;
1777
+ params.push(Math.floor(opts.tail));
1778
+ } else {
1779
+ query = `SELECT rowid AS rid, * FROM mesh_event_ledger WHERE ${whereClause} ORDER BY timestamp ASC, rowid ASC`;
1780
+ }
1781
+ const rows = this.db.prepare(query).all(...params) as Array<Record<string, unknown>>;
1782
+ return rows.map(r => ({
1783
+ id: r.id as string,
1784
+ meshId: r.mesh_id as string,
1785
+ timestamp: r.timestamp as string,
1786
+ kind: r.kind as string,
1787
+ nodeId: r.node_id as string | null,
1788
+ sessionId: r.session_id as string | null,
1789
+ providerType: r.provider_type as string | null,
1790
+ payload: (() => { try { return JSON.parse(r.payload as string); } catch { return {}; } })(),
1791
+ }));
1792
+ }
1793
+
1794
+ /** Remove all ledger entries for a mesh (mesh deletion / test cleanup). */
1795
+ clearLedgerForMesh(meshId: string): number {
1796
+ return this.db.prepare('DELETE FROM mesh_event_ledger WHERE mesh_id = ?').run(meshId).changes;
1797
+ }
1798
+
1799
+ /** G2: remove entries moved to the JSONL archive so the SQLite runtime set mirrors the active ledger. */
1800
+ deleteLedgerEntries(meshId: string, ids: string[]): number {
1801
+ if (!ids.length) return 0;
1802
+ let deleted = 0;
1803
+ const stmt = this.db.prepare('DELETE FROM mesh_event_ledger WHERE mesh_id = ? AND id = ?');
1804
+ this.db.transaction(() => {
1805
+ for (const id of ids) {
1806
+ deleted += stmt.run(meshId, id).changes;
1807
+ }
1808
+ })();
1809
+ return deleted;
1810
+ }
1811
+
1812
+ hasLedgerEntry(meshId: string, id: string): boolean {
1813
+ const row = this.db.prepare(
1814
+ 'SELECT 1 FROM mesh_event_ledger WHERE mesh_id = ? AND id = ? LIMIT 1'
1815
+ ).get(meshId, id);
1816
+ return row !== undefined;
1817
+ }
1818
+
1819
+ ledgerEntryCount(meshId: string): number {
1820
+ const row = this.db.prepare(
1821
+ 'SELECT COUNT(*) as cnt FROM mesh_event_ledger WHERE mesh_id = ?'
1822
+ ).get(meshId) as { cnt: number } | undefined;
1823
+ return row?.cnt ?? 0;
1824
+ }
1825
+
1826
+ importLedgerEntries(entries: Array<{
1827
+ id: string; meshId: string; timestamp: string; kind: string;
1828
+ nodeId?: string | null; sessionId?: string | null; providerType?: string | null; payload?: unknown;
1829
+ }>): number {
1830
+ let imported = 0;
1831
+ const stmt = this.db.prepare(
1832
+ `INSERT OR IGNORE INTO mesh_event_ledger
1833
+ (id, mesh_id, timestamp, kind, node_id, session_id, provider_type, payload)
1834
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1835
+ );
1836
+ this.db.transaction(() => {
1837
+ for (const e of entries) {
1838
+ // Skip structurally-broken entries with a blank kind (see appendLedgerEntry):
1839
+ // mesh_event_ledger.kind is NOT NULL and every kind is a non-empty tag, so an
1840
+ // empty-kind row is unqueryable noise. Mirrors readLedgerFile's `entry.id && entry.kind`
1841
+ // JSONL guard, keeping the import path from re-introducing what the read path filters.
1842
+ if (!e.kind || !String(e.kind).trim()) continue;
1843
+ const result = stmt.run(
1844
+ e.id, e.meshId, e.timestamp, e.kind,
1845
+ e.nodeId ?? null, e.sessionId ?? null, e.providerType ?? null,
1846
+ JSON.stringify(e.payload ?? {}),
1847
+ );
1848
+ if (result.changes > 0) imported++;
1849
+ }
1850
+ })();
1851
+ return imported;
1852
+ }
1853
+
1854
+ /**
1855
+ * G4: Read a bounded, cursor-addressable ledger slice directly from the SQLite
1856
+ * mesh_event_ledger table. This is the P2P reconcile read path; JSONL files are
1857
+ * retained as export/import/debug/legacy artifacts only.
1858
+ *
1859
+ * The return shape is structurally compatible with MeshLedgerSlice so callers
1860
+ * in mesh-tools.ts can pass it directly to buildMeshLedgerReplicaEvidence.
1861
+ */
1862
+ readLedgerSlice(meshId: string, opts?: {
1863
+ afterId?: string;
1864
+ since?: string;
1865
+ kind?: string;
1866
+ limit?: number;
1867
+ }): {
1868
+ protocol: 'adhdev.mesh.ledger.slice.v1';
1869
+ meshId: string;
1870
+ entries: Array<{ id: string; meshId: string; timestamp: string; kind: string; nodeId: string | null; sessionId: string | null; providerType: string | null; payload: unknown }>;
1871
+ cursor: { afterId: string | null; nextAfterId: string | null; limit: number; hasMore: boolean };
1872
+ sourceOfTruth: { kind: 'local_sqlite'; table: 'mesh_event_ledger'; bounded: true; maxLimit: number };
1873
+ } {
1874
+ // Protocol maximum of 500, default 100 — mirrors mesh-ledger.ts constants.
1875
+ const MAX_LIMIT = 500;
1876
+ const DEFAULT_LIMIT = 100;
1877
+ const limit = (typeof opts?.limit === 'number' && Number.isFinite(opts.limit))
1878
+ ? Math.max(1, Math.min(MAX_LIMIT, Math.floor(opts.limit)))
1879
+ : DEFAULT_LIMIT;
1880
+
1881
+ const afterId = typeof opts?.afterId === 'string' && opts.afterId.trim() ? opts.afterId.trim() : null;
1882
+
1883
+ // Build query: fetch limit+1 rows so we can detect hasMore without a COUNT(*).
1884
+ const params: unknown[] = [meshId];
1885
+ let whereClause = 'mesh_id = ?';
1886
+
1887
+ if (opts?.kind) {
1888
+ whereClause += ' AND kind = ?';
1889
+ params.push(opts.kind);
1890
+ }
1891
+ if (opts?.since) {
1892
+ whereClause += ' AND timestamp >= ?';
1893
+ params.push(opts.since);
1894
+ }
1895
+ if (afterId) {
1896
+ // afterId: return entries with timestamp strictly after the referenced entry's timestamp,
1897
+ // or with the same timestamp but id > afterId (stable pagination).
1898
+ whereClause += ` AND (timestamp > (SELECT timestamp FROM mesh_event_ledger WHERE id = ? AND mesh_id = ?) OR (timestamp = (SELECT timestamp FROM mesh_event_ledger WHERE id = ? AND mesh_id = ?) AND id > ?))`;
1899
+ params.push(afterId, meshId, afterId, meshId, afterId);
1900
+ }
1901
+
1902
+ // Fetch limit+1 to detect hasMore
1903
+ const query = `SELECT * FROM mesh_event_ledger WHERE ${whereClause} ORDER BY timestamp ASC, id ASC LIMIT ?`;
1904
+ params.push(limit + 1);
1905
+
1906
+ const rows = this.db.prepare(query).all(...params) as Array<Record<string, unknown>>;
1907
+ const hasMore = rows.length > limit;
1908
+ const bounded = hasMore ? rows.slice(0, limit) : rows;
1909
+
1910
+ const entries = bounded.map(r => ({
1911
+ id: r.id as string,
1912
+ meshId: r.mesh_id as string,
1913
+ timestamp: r.timestamp as string,
1914
+ kind: r.kind as string,
1915
+ nodeId: r.node_id as string | null,
1916
+ sessionId: r.session_id as string | null,
1917
+ providerType: r.provider_type as string | null,
1918
+ payload: (() => { try { return JSON.parse(r.payload as string); } catch { return {}; } })(),
1919
+ }));
1920
+
1921
+ return {
1922
+ protocol: 'adhdev.mesh.ledger.slice.v1',
1923
+ meshId,
1924
+ entries,
1925
+ cursor: {
1926
+ afterId,
1927
+ nextAfterId: entries.length ? entries[entries.length - 1].id : afterId,
1928
+ limit,
1929
+ hasMore,
1930
+ },
1931
+ sourceOfTruth: {
1932
+ kind: 'local_sqlite',
1933
+ table: 'mesh_event_ledger',
1934
+ bounded: true,
1935
+ maxLimit: MAX_LIMIT,
1936
+ },
1937
+ };
1938
+ }
1939
+
1940
+ // ── G3: Pending Coordinator Events ──────────────────────────────────────
1941
+
1942
+ insertPendingEvent(event: {
1943
+ id: string;
1944
+ meshId: string;
1945
+ coordinatorDaemonId?: string | null;
1946
+ event: string;
1947
+ payload?: unknown;
1948
+ fingerprint?: string | null;
1949
+ queuedAt: number;
1950
+ // v2 envelope columns (B2a) — all optional so v1 callers/rows are unaffected.
1951
+ // dispatchedBy / intendedFor are pre-serialized CoordinatorIdentity JSON.
1952
+ protocolVersion?: string | null;
1953
+ eventId?: string | null;
1954
+ scope?: string | null;
1955
+ dispatchedBy?: string | null;
1956
+ intendedFor?: string | null;
1957
+ }): boolean {
1958
+ const result = this.db.prepare(
1959
+ `INSERT OR IGNORE INTO mesh_pending_events
1960
+ (id, mesh_id, coordinator_daemon_id, event, payload, fingerprint, queued_at,
1961
+ protocol_version, event_id, scope, dispatched_by, intended_for)
1962
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
1963
+ ).run(
1964
+ event.id,
1965
+ event.meshId,
1966
+ event.coordinatorDaemonId ?? null,
1967
+ event.event,
1968
+ JSON.stringify(event.payload ?? {}),
1969
+ event.fingerprint ?? null,
1970
+ event.queuedAt,
1971
+ event.protocolVersion ?? null,
1972
+ event.eventId ?? null,
1973
+ event.scope ?? null,
1974
+ event.dispatchedBy ?? null,
1975
+ event.intendedFor ?? null,
1976
+ );
1977
+ this.maybeCheckpointWal();
1978
+ return result.changes > 0;
1979
+ }
1980
+
1981
+ /**
1982
+ * Drain undrained pending events for a mesh, atomically marking them drained.
1983
+ * When `opts.onlyEvents` is supplied, ONLY rows whose `event` is in that set are
1984
+ * drained — the rest stay queued (drained=0) for a later drain. This is how the
1985
+ * reconcile loop force-drains terminal/force-inject events into a *generating*
1986
+ * coordinator while leaving non-force progress events for the coordinator's next
1987
+ * idle transition. Filtering happens inside the same transaction as the
1988
+ * drained=1 marking, so force-drain + a concurrent full drain can never both
1989
+ * consume the same row.
1990
+ */
1991
+ drainPendingEvents(
1992
+ meshId: string,
1993
+ coordinatorDaemonId?: string | null | ReadonlyArray<string>,
1994
+ opts?: { onlyEvents?: ReadonlySet<string> },
1995
+ ): Array<{ id: string; event: string; payload: unknown }> {
1996
+ return this.transaction(() => {
1997
+ const onlyEvents = opts?.onlyEvents;
1998
+ // An explicit-but-empty filter means "drain nothing" (no event name can match).
1999
+ if (onlyEvents && onlyEvents.size === 0) return [];
2000
+ const eventList = onlyEvents ? [...onlyEvents] : [];
2001
+ // A coordinator daemon can answer to more than one id form: its canonical
2002
+ // status id (e.g. `standalone_<machineId>` / `daemon_<machineId>`, which the
2003
+ // MCP layer stamps via ctx.localDaemonId) AND the bare machineId (stamped by
2004
+ // the local queue-assignment path). Accept ANY of them so a unicast event
2005
+ // stamped with either id is drained here. Unscoped (NULL) rows always match.
2006
+ const daemonIds = (Array.isArray(coordinatorDaemonId)
2007
+ ? coordinatorDaemonId
2008
+ : coordinatorDaemonId ? [coordinatorDaemonId] : [])
2009
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
2010
+ // Filter by event name IN-SQL when onlyEvents is set so the LIMIT applies to
2011
+ // matching rows — a long run of non-force events ahead in the queue must not
2012
+ // crowd a force event out of the 100-row window.
2013
+ const clauses = ['mesh_id = ?', 'drained = 0'];
2014
+ const params: unknown[] = [meshId];
2015
+ if (daemonIds.length > 0) {
2016
+ clauses.push(`(coordinator_daemon_id IS NULL OR coordinator_daemon_id IN (${daemonIds.map(() => '?').join(',')}))`);
2017
+ params.push(...daemonIds);
2018
+ }
2019
+ if (eventList.length > 0) {
2020
+ clauses.push(`event IN (${eventList.map(() => '?').join(',')})`);
2021
+ params.push(...eventList);
2022
+ }
2023
+ const rows = this.db.prepare(
2024
+ `SELECT id, event, payload FROM mesh_pending_events WHERE ${clauses.join(' AND ')} ORDER BY queued_at ASC LIMIT 100`
2025
+ ).all(...params) as Array<{ id: string; event: string; payload: string }>;
2026
+ if (rows.length === 0) return [];
2027
+ const ids = rows.map(r => r.id);
2028
+ const now = Date.now();
2029
+ this.db.prepare(
2030
+ `UPDATE mesh_pending_events SET drained = 1, drained_at = ? WHERE id IN (${ids.map(() => '?').join(',')})`
2031
+ ).run(now, ...ids);
2032
+ return rows.map(r => ({
2033
+ id: r.id,
2034
+ event: r.event,
2035
+ payload: (() => { try { return JSON.parse(r.payload); } catch { return {}; } })(),
2036
+ }));
2037
+ });
2038
+ }
2039
+
2040
+ /** Non-destructive peek — returns undrained events without marking them drained. */
2041
+ peekPendingEvents(meshId: string, coordinatorDaemonId?: string | null | ReadonlyArray<string>): Array<{ id: string; event: string; payload: unknown }> {
2042
+ const daemonIds = (Array.isArray(coordinatorDaemonId)
2043
+ ? coordinatorDaemonId
2044
+ : coordinatorDaemonId ? [coordinatorDaemonId] : [])
2045
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
2046
+ const whereClause = daemonIds.length > 0
2047
+ ? `WHERE mesh_id = ? AND drained = 0 AND (coordinator_daemon_id IS NULL OR coordinator_daemon_id IN (${daemonIds.map(() => '?').join(',')}))`
2048
+ : `WHERE mesh_id = ? AND drained = 0`;
2049
+ const params: unknown[] = daemonIds.length > 0 ? [meshId, ...daemonIds] : [meshId];
2050
+ const rows = this.db.prepare(
2051
+ `SELECT id, event, payload FROM mesh_pending_events ${whereClause} ORDER BY queued_at ASC LIMIT 100`
2052
+ ).all(...params) as Array<{ id: string; event: string; payload: string }>;
2053
+ return rows.map(r => ({
2054
+ id: r.id,
2055
+ event: r.event,
2056
+ payload: (() => { try { return JSON.parse(r.payload); } catch { return {}; } })(),
2057
+ }));
2058
+ }
2059
+
2060
+ hasPendingEventFingerprint(meshId: string, fingerprint: string): boolean {
2061
+ const row = this.db.prepare(
2062
+ 'SELECT 1 FROM mesh_pending_events WHERE mesh_id = ? AND fingerprint = ? AND drained = 0 LIMIT 1'
2063
+ ).get(meshId, fingerprint);
2064
+ return row !== undefined;
2065
+ }
2066
+
2067
+ /**
2068
+ * B3a — v2 eventId idempotency. Returns true when a row with this event_id has
2069
+ * ALREADY been drained (drained = 1) for the mesh. Drained rows are retained
2070
+ * (soft-marked, not deleted until mesh deletion), so this is a durable, restart-
2071
+ * surviving dedup: a v2 event whose eventId was already consumed is skipped on
2072
+ * re-delivery even when its content fingerprint differs. Scoped by mesh_id +
2073
+ * the partial event_id index (idx_mesh_pending_events_event_id).
2074
+ */
2075
+ hasDrainedEventId(meshId: string, eventId: string): boolean {
2076
+ if (!eventId) return false;
2077
+ const row = this.db.prepare(
2078
+ 'SELECT 1 FROM mesh_pending_events WHERE mesh_id = ? AND event_id = ? AND drained = 1 LIMIT 1'
2079
+ ).get(meshId, eventId);
2080
+ return row !== undefined;
2081
+ }
2082
+
2083
+ /**
2084
+ * B3a — snapshot of the v2 event_ids ALREADY drained (drained = 1) for the mesh.
2085
+ * Taken BEFORE a drain call marks the current batch drained=1, so the resulting
2086
+ * set names only PRIOR drains — the re-delivery dedup baseline. (Reading it after
2087
+ * the drain would self-match the batch's own freshly-drained rows.) Non-v2 rows
2088
+ * have a NULL event_id and are excluded by the index/WHERE.
2089
+ */
2090
+ drainedEventIdsForMesh(meshId: string): Set<string> {
2091
+ const rows = this.db.prepare(
2092
+ 'SELECT DISTINCT event_id FROM mesh_pending_events WHERE mesh_id = ? AND drained = 1 AND event_id IS NOT NULL'
2093
+ ).all(meshId) as Array<{ event_id: string }>;
2094
+ return new Set(rows.map(r => r.event_id));
2095
+ }
2096
+
2097
+ // ── M3: Mission Records ─────────────────────────────────────────────────
2098
+
2099
+ upsertMission(mission: {
2100
+ id: string;
2101
+ meshId: string;
2102
+ title: string;
2103
+ goal?: string;
2104
+ status?: string;
2105
+ source?: string;
2106
+ }): void {
2107
+ const now = new Date().toISOString();
2108
+ // `source` is a write-once provenance tag: on conflict we only overwrite it
2109
+ // with a non-null incoming value (COALESCE(excluded, existing)), so a later
2110
+ // status/goal upsert that omits source never clears a previously-stamped
2111
+ // 'magi'/'coordinator' tag.
2112
+ // close_candidate_emitted_at is deliberately NOT in the UPDATE set: the G3
2113
+ // idempotency marker is owned solely by setMissionCloseCandidateEmittedAt, so a
2114
+ // title/goal/status upsert here never clears or overwrites it.
2115
+ this.db.prepare(
2116
+ `INSERT INTO mesh_missions (id, mesh_id, title, goal, status, source, created_at, updated_at)
2117
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2118
+ ON CONFLICT(id) DO UPDATE SET
2119
+ title = excluded.title,
2120
+ goal = excluded.goal,
2121
+ status = excluded.status,
2122
+ source = COALESCE(excluded.source, mesh_missions.source),
2123
+ updated_at = excluded.updated_at`
2124
+ ).run(
2125
+ mission.id,
2126
+ mission.meshId,
2127
+ mission.title,
2128
+ mission.goal ?? '',
2129
+ mission.status ?? 'active',
2130
+ mission.source ?? null,
2131
+ now,
2132
+ now,
2133
+ );
2134
+ this.maybeCheckpointWal();
2135
+ }
2136
+
2137
+ getMission(meshId: string, missionId: string): { id: string; meshId: string; title: string; goal: string; status: string; source?: string; closeCandidateEmittedAt?: string; createdAt: string; updatedAt: string } | null {
2138
+ const row = this.db.prepare(
2139
+ 'SELECT * FROM mesh_missions WHERE mesh_id = ? AND id = ?'
2140
+ ).get(meshId, missionId) as Record<string, string> | undefined;
2141
+ if (!row) return null;
2142
+ return { id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, source: row.source ?? undefined, closeCandidateEmittedAt: row.close_candidate_emitted_at ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at };
2143
+ }
2144
+
2145
+ getMissions(meshId: string, statuses?: string[]): Array<{ id: string; meshId: string; title: string; goal: string; status: string; source?: string; closeCandidateEmittedAt?: string; createdAt: string; updatedAt: string }> {
2146
+ let rows: Array<Record<string, string>>;
2147
+ if (statuses?.length) {
2148
+ const placeholders = statuses.map(() => '?').join(', ');
2149
+ rows = this.db.prepare(
2150
+ `SELECT * FROM mesh_missions WHERE mesh_id = ? AND status IN (${placeholders}) ORDER BY updated_at DESC`
2151
+ ).all(meshId, ...statuses) as Array<Record<string, string>>;
2152
+ } else {
2153
+ rows = this.db.prepare(
2154
+ 'SELECT * FROM mesh_missions WHERE mesh_id = ? ORDER BY updated_at DESC'
2155
+ ).all(meshId) as Array<Record<string, string>>;
2156
+ }
2157
+ return rows.map(row => ({ id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, source: row.source ?? undefined, closeCandidateEmittedAt: row.close_candidate_emitted_at ?? undefined, createdAt: row.created_at, updatedAt: row.updated_at }));
2158
+ }
2159
+
2160
+ /**
2161
+ * G3: set/clear the mission_close_candidate idempotency marker. Passing an ISO
2162
+ * timestamp records that the all-terminal nudge has been emitted for this mission;
2163
+ * passing null clears it (mission returned to a non-terminal state, so a future
2164
+ * re-completion may nudge again). Touches ONLY this column — never the mission's
2165
+ * updated_at — so the marker write is invisible to updatedAt-ordered surfaces and
2166
+ * does not masquerade as mission activity. Returns rows changed (0 if no such mission).
2167
+ */
2168
+ setMissionCloseCandidateEmittedAt(meshId: string, missionId: string, emittedAt: string | null): number {
2169
+ return this.db.prepare(
2170
+ 'UPDATE mesh_missions SET close_candidate_emitted_at = ? WHERE mesh_id = ? AND id = ?'
2171
+ ).run(emittedAt, meshId, missionId).changes;
2172
+ }
2173
+
2174
+ /**
2175
+ * Read the last idle-active-mission-reminder debounce marker for a mesh, or null if
2176
+ * none has fired this process. In-memory only (see idleReminderState) — best-effort
2177
+ * spam guard for a coordinator nudge, intentionally not SQLite-backed.
2178
+ */
2179
+ getIdleReminderState(meshId: string): { emittedAt: number; missionSetHash: string } | null {
2180
+ return this.idleReminderState.get(meshId) ?? null;
2181
+ }
2182
+
2183
+ /** Record that an idle-active-mission reminder just fired for a mesh (debounce marker). */
2184
+ setIdleReminderState(meshId: string, state: { emittedAt: number; missionSetHash: string }): void {
2185
+ this.idleReminderState.set(meshId, state);
2186
+ }
2187
+
2188
+ /** Clear the idle-reminder debounce marker for a mesh — mesh deletion / test cleanup. */
2189
+ clearIdleReminderState(meshId: string): void {
2190
+ this.idleReminderState.delete(meshId);
2191
+ }
2192
+
2193
+ /** Remove all missions for a mesh — mesh deletion / test cleanup. */
2194
+ clearMissionsForMesh(meshId: string): number {
2195
+ return this.db.prepare('DELETE FROM mesh_missions WHERE mesh_id = ?').run(meshId).changes;
2196
+ }
2197
+
2198
+ /** Remove all pending-event rows (drained included) for a mesh — mesh deletion / test cleanup. */
2199
+ clearPendingEventsForMesh(meshId: string): number {
2200
+ return this.db.prepare('DELETE FROM mesh_pending_events WHERE mesh_id = ?').run(meshId).changes;
2201
+ }
2202
+
2203
+ pendingEventCount(meshId: string): number {
2204
+ const row = this.db.prepare(
2205
+ 'SELECT COUNT(*) as cnt FROM mesh_pending_events WHERE mesh_id = ? AND drained = 0'
2206
+ ).get(meshId) as { cnt: number } | undefined;
2207
+ return row?.cnt ?? 0;
2208
+ }
2209
+
2210
+ /**
2211
+ * Mark specific pending-event rows drained by id (ack). Used by the
2212
+ * unresolved-delegate durable-forward outbox: an event is peeked (not drained)
2213
+ * while its push to the coordinator is unconfirmed, then marked drained ONLY
2214
+ * after the push is acked. A failed push leaves the row undrained so the next
2215
+ * reconcile tick retries it. Returns the number of rows newly marked drained.
2216
+ */
2217
+ markPendingEventsDrainedById(ids: ReadonlyArray<string>): number {
2218
+ const idList = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
2219
+ if (idList.length === 0) return 0;
2220
+ const now = Date.now();
2221
+ return this.db.prepare(
2222
+ `UPDATE mesh_pending_events SET drained = 1, drained_at = ? WHERE drained = 0 AND id IN (${idList.map(() => '?').join(',')})`
2223
+ ).run(now, ...idList).changes;
2224
+ }
2225
+
2226
+ /**
2227
+ * Hard-delete pending-event rows by id (including the dedup fingerprint history).
2228
+ * Used to expire an unresolved-delegate outbox entry that has exhausted its retry
2229
+ * budget — fully removing it frees the fingerprint so a genuinely new completion
2230
+ * for the same task could be re-queued later. Returns the number of rows deleted.
2231
+ */
2232
+ deletePendingEventsById(ids: ReadonlyArray<string>): number {
2233
+ const idList = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
2234
+ if (idList.length === 0) return 0;
2235
+ return this.db.prepare(
2236
+ `DELETE FROM mesh_pending_events WHERE id IN (${idList.map(() => '?').join(',')})`
2237
+ ).run(...idList).changes;
2238
+ }
2239
+
2240
+ /**
2241
+ * Retention prune for mesh_pending_events. This table has no lifecycle GC of its
2242
+ * own: a drained row is soft-marked (drained=1) and RETAINED — deliberately, so
2243
+ * drainedEventIdsForMesh() has a durable v2-eventId dedup baseline — and an
2244
+ * undrained row queued for a coordinator that never returned (a dead/evicted
2245
+ * coordinator identity) stays drained=0 forever. Both accumulate without bound
2246
+ * (observed: tens of thousands of rows, mostly stale). This is the missing
2247
+ * retention step. Two independent windows:
2248
+ *
2249
+ * - drained rows older than `drainedOlderThanMs`: the coordinator consumed them
2250
+ * long ago; the only thing they still back is the eventId re-delivery guard,
2251
+ * which is only meaningful for the recent past (a re-delivery of a week-old
2252
+ * event cannot occur — its producer session is long gone). Safe to delete.
2253
+ * - UNDRAINED rows older than `undrainedOlderThanMs` (a much wider window):
2254
+ * these are orphaned events for a coordinator identity that never drained
2255
+ * them. Kept wide so a genuinely-offline-but-returning coordinator still
2256
+ * receives its backlog; only genuinely unrecoverable orphans are swept.
2257
+ *
2258
+ * Both windows key off `queued_at` (always present) — `drained_at` can be NULL on
2259
+ * legacy rows. Returns the number of rows deleted. Best-effort / idempotent:
2260
+ * running it repeatedly with nothing to prune is a cheap no-op.
2261
+ */
2262
+ prunePendingEvents(opts: { drainedOlderThanMs: number; undrainedOlderThanMs: number }): number {
2263
+ const now = Date.now();
2264
+ const drainedCutoff = now - Math.max(0, opts.drainedOlderThanMs);
2265
+ const undrainedCutoff = now - Math.max(0, opts.undrainedOlderThanMs);
2266
+ let removed = 0;
2267
+ removed += this.db.prepare(
2268
+ 'DELETE FROM mesh_pending_events WHERE drained = 1 AND queued_at < ?'
2269
+ ).run(drainedCutoff).changes;
2270
+ removed += this.db.prepare(
2271
+ 'DELETE FROM mesh_pending_events WHERE drained = 0 AND queued_at < ?'
2272
+ ).run(undrainedCutoff).changes;
2273
+ return removed;
2274
+ }
2275
+ }
2276
+
2277
+ // ─── Mesh runtime retention windows (SoT 1-11 (b) / gap I-10) ────────────────
2278
+ // mesh-runtime.db had lifecycle GC only for mesh_pending_events (prunePendingEvents,
2279
+ // hourly via the mesh-event maintenance sweep) and fingerprints/tool-call windows;
2280
+ // mesh_event_ledger and terminal mesh_queue rows grew without bound. These windows
2281
+ // are deliberately CONSERVATIVE — every production reader operates on a recent
2282
+ // window far narrower than these, so the deletes trade only dead space:
2283
+ // - Event ledger 30 days: readers are tail/limit-bounded (≤ a few hundred rows) or
2284
+ // recent-task scoped; 30d comfortably exceeds any reconcile/stat/audit horizon.
2285
+ // Operating notes are exempted inside pruneEventLedger (retained forever).
2286
+ // - Tool-call log 14 days: it backs a seconds-scale rate-limit window; 14d keeps a
2287
+ // generous debugging horizon at trivial cost.
2288
+ // - Terminal queue rows 30 days: mesh_task_history / completion-dedup lookups are
2289
+ // recent-task scoped; live dependsOn anchors are exempted inside
2290
+ // pruneTerminalQueueEntries.
2291
+ // No VACUUM here by design: reclaiming file pages is not worth stalling the daemon's
2292
+ // single writer; freed pages are reused by future inserts.
2293
+ export const MESH_EVENT_LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
2294
+ export const MESH_TOOL_CALL_LOG_RETENTION_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
2295
+ export const MESH_TERMINAL_QUEUE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
2296
+
2297
+ /**
2298
+ * Periodic retention sweep for the mesh-runtime.db tables that previously had no
2299
+ * lifecycle GC (event ledger, tool-call log, terminal queue rows). Runs on the SAME
2300
+ * cadence as the pending-events retention prune (the hourly mesh-event maintenance
2301
+ * sweep in mesh-event-forwarding.ts). Best-effort and idempotent: a store failure
2302
+ * degrades to a no-op with one warn; an empty table costs three cheap DELETEs.
2303
+ */
2304
+ export function pruneMeshRuntimeRetention(): { ledger: number; toolCalls: number; terminalQueue: number } {
2305
+ try {
2306
+ const store = MeshRuntimeStore.getInstance();
2307
+ const ledger = store.pruneEventLedger(MESH_EVENT_LEDGER_RETENTION_MS);
2308
+ const toolCalls = store.pruneToolCallLog(MESH_TOOL_CALL_LOG_RETENTION_MS);
2309
+ const terminalQueue = store.pruneTerminalQueueEntries(MESH_TERMINAL_QUEUE_RETENTION_MS);
2310
+ if (ledger + toolCalls + terminalQueue > 0) {
2311
+ LOG.info('MeshRuntimeStore', `Retention prune removed ${ledger} ledger / ${toolCalls} tool-call / ${terminalQueue} terminal-queue row(s)`);
2312
+ }
2313
+ return { ledger, toolCalls, terminalQueue };
2314
+ } catch (e: any) {
2315
+ LOG.warn('MeshRuntimeStore', `Runtime retention prune failed: ${e?.message || e}`);
2316
+ return { ledger: 0, toolCalls: 0, terminalQueue: 0 };
2317
+ }
2318
+ }