@adhdev/daemon-core 0.9.82-rc.39 → 0.9.82-rc.391

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 (415) 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 +67 -2
  8. package/dist/cli-adapters/cli-script-runner.d.ts +117 -0
  9. package/dist/cli-adapters/cli-state-engine.d.ts +198 -0
  10. package/dist/cli-adapters/provider-cli-adapter.d.ts +121 -64
  11. package/dist/cli-adapters/provider-cli-parse.d.ts +4 -0
  12. package/dist/cli-adapters/provider-cli-runtime.d.ts +34 -0
  13. package/dist/cli-adapters/provider-cli-shared.d.ts +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 +14 -0
  17. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  18. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  19. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  20. package/dist/commands/chat-commands-debug-bundle.d.ts +14 -0
  21. package/dist/commands/chat-commands-read.d.ts +7 -0
  22. package/dist/commands/chat-commands-scope.d.ts +39 -0
  23. package/dist/commands/chat-commands-shared.d.ts +33 -0
  24. package/dist/commands/chat-commands-write.d.ts +14 -0
  25. package/dist/commands/chat-commands.d.ts +10 -23
  26. package/dist/commands/cli-manager.d.ts +15 -1
  27. package/dist/commands/handler.d.ts +125 -0
  28. package/dist/commands/high-family/index.d.ts +3 -0
  29. package/dist/commands/high-family/mesh-coordinator-launch.d.ts +2 -0
  30. package/dist/commands/high-family/mesh-events.d.ts +2 -0
  31. package/dist/commands/high-family/mesh-status.d.ts +2 -0
  32. package/dist/commands/high-family/types.d.ts +60 -0
  33. package/dist/commands/low-family/coordinator-prompt.d.ts +9 -0
  34. package/dist/commands/low-family/daemon-lifecycle.d.ts +2 -0
  35. package/dist/commands/low-family/diagnostics.d.ts +2 -0
  36. package/dist/commands/low-family/index.d.ts +3 -0
  37. package/dist/commands/low-family/mesh-ledger.d.ts +10 -0
  38. package/dist/commands/low-family/mesh-node-logs.d.ts +2 -0
  39. package/dist/commands/low-family/notification.d.ts +2 -0
  40. package/dist/commands/low-family/refine-config.d.ts +2 -0
  41. package/dist/commands/low-family/session-host.d.ts +2 -0
  42. package/dist/commands/low-family/spec-providerdev.d.ts +11 -0
  43. package/dist/commands/low-family/status-meta.d.ts +2 -0
  44. package/dist/commands/low-family/types.d.ts +33 -0
  45. package/dist/commands/med-family/cli-agent.d.ts +2 -0
  46. package/dist/commands/med-family/fast-forward.d.ts +2 -0
  47. package/dist/commands/med-family/ide.d.ts +10 -0
  48. package/dist/commands/med-family/index.d.ts +3 -0
  49. package/dist/commands/med-family/mesh-crud.d.ts +2 -0
  50. package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
  51. package/dist/commands/med-family/mesh-queue.d.ts +2 -0
  52. package/dist/commands/med-family/mesh-restart.d.ts +2 -0
  53. package/dist/commands/med-family/types.d.ts +116 -0
  54. package/dist/commands/mesh-coordinator.d.ts +85 -1
  55. package/dist/commands/router.d.ts +237 -2
  56. package/dist/commands/upgrade-helper.d.ts +41 -1
  57. package/dist/config/chat-history.d.ts +9 -0
  58. package/dist/config/config.d.ts +5 -0
  59. package/dist/config/mesh-config.d.ts +77 -1
  60. package/dist/daemon/dev-server.d.ts +0 -2
  61. package/dist/detection/ide-detector.d.ts +13 -0
  62. package/dist/detection/win32-ide-version.d.ts +37 -0
  63. package/dist/git/change-impact-config.d.ts +159 -0
  64. package/dist/git/git-commands.d.ts +11 -1
  65. package/dist/git/git-diff.d.ts +6 -0
  66. package/dist/git/git-executor.d.ts +11 -0
  67. package/dist/git/git-status.d.ts +23 -0
  68. package/dist/git/git-types.d.ts +2 -50
  69. package/dist/git/git-worktree.d.ts +22 -0
  70. package/dist/git/index.d.ts +2 -0
  71. package/dist/index.d.ts +57 -13
  72. package/dist/index.js +46318 -18260
  73. package/dist/index.js.map +1 -1
  74. package/dist/index.mjs +46450 -18533
  75. package/dist/index.mjs.map +1 -1
  76. package/dist/installer.d.ts +1 -4
  77. package/dist/ipc/local-ipc-server.d.ts +91 -0
  78. package/dist/launch.d.ts +1 -1
  79. package/dist/logging/async-batch-writer.d.ts +10 -0
  80. package/dist/logging/log-redactor.d.ts +24 -0
  81. package/dist/logging/log-tail-reader.d.ts +81 -0
  82. package/dist/logging/logger.d.ts +1 -1
  83. package/dist/mesh/contracts.d.ts +164 -0
  84. package/dist/mesh/coordinator-prompt.d.ts +89 -0
  85. package/dist/mesh/coordinator-registry.d.ts +59 -0
  86. package/dist/mesh/mesh-active-work.d.ts +174 -0
  87. package/dist/mesh/mesh-coordinator-config.d.ts +21 -0
  88. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  89. package/dist/mesh/mesh-event-classify.d.ts +5 -0
  90. package/dist/mesh/mesh-event-forwarding.d.ts +19 -0
  91. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  92. package/dist/mesh/mesh-events-coordinator.d.ts +4 -0
  93. package/dist/mesh/mesh-events-pending.d.ts +55 -0
  94. package/dist/mesh/mesh-events-stale.d.ts +52 -0
  95. package/dist/mesh/mesh-events-utils.d.ts +64 -0
  96. package/dist/mesh/mesh-events.d.ts +6 -49
  97. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  98. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  99. package/dist/mesh/mesh-init.d.ts +86 -0
  100. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -4
  101. package/dist/mesh/mesh-ledger.d.ts +77 -1
  102. package/dist/mesh/mesh-missions.d.ts +164 -0
  103. package/dist/mesh/mesh-node-identity.d.ts +289 -0
  104. package/dist/mesh/mesh-queue-assignment.d.ts +86 -0
  105. package/dist/mesh/mesh-reconcile-loop.d.ts +65 -0
  106. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  107. package/dist/mesh/mesh-refine-gates.d.ts +428 -0
  108. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  109. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  110. package/dist/mesh/mesh-routing.d.ts +70 -0
  111. package/dist/mesh/mesh-runtime-store.d.ts +447 -0
  112. package/dist/mesh/mesh-scheduling-runtime.d.ts +78 -0
  113. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  114. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  115. package/dist/mesh/mesh-warmup-deadline.d.ts +68 -0
  116. package/dist/mesh/mesh-work-queue.d.ts +247 -5
  117. package/dist/mesh/preview-freshness.d.ts +18 -0
  118. package/dist/mesh/refine-config.d.ts +216 -0
  119. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  120. package/dist/providers/acp-provider-instance.d.ts +5 -0
  121. package/dist/providers/approval-utils.d.ts +20 -0
  122. package/dist/providers/chat-message-normalization.d.ts +13 -1
  123. package/dist/providers/cli-provider-instance.d.ts +157 -3
  124. package/dist/providers/contracts.d.ts +139 -6
  125. package/dist/providers/external-sources.d.ts +71 -0
  126. package/dist/providers/manual-attendance.d.ts +63 -0
  127. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  128. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  129. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  130. package/dist/providers/native-history/constants.d.ts +12 -0
  131. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  132. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  133. package/dist/providers/native-history/index.d.ts +13 -0
  134. package/dist/providers/provider-instance-manager.d.ts +14 -0
  135. package/dist/providers/provider-instance.d.ts +23 -1
  136. package/dist/providers/provider-loader.d.ts +26 -4
  137. package/dist/providers/provider-trust.d.ts +31 -0
  138. package/dist/providers/read-chat-contract.d.ts +29 -0
  139. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  140. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +86 -0
  141. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  142. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  143. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  144. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  145. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  146. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  147. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  148. package/dist/providers/sdk/v1/index.d.ts +30 -0
  149. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  150. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  151. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  152. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  153. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  154. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  155. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  156. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  157. package/dist/providers/spec/adapter.d.ts +91 -0
  158. package/dist/providers/spec/cli-adapter.d.ts +223 -0
  159. package/dist/providers/spec/evaluator.d.ts +45 -0
  160. package/dist/providers/spec/fsm-driver.d.ts +407 -0
  161. package/dist/providers/spec/fsm-evaluator.d.ts +76 -0
  162. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  163. package/dist/providers/spec/fsm-types.d.ts +174 -0
  164. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  165. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  166. package/dist/providers/spec/route.d.ts +4 -0
  167. package/dist/providers/spec/types.d.ts +222 -0
  168. package/dist/providers/status-monitor.d.ts +7 -7
  169. package/dist/providers/transcript-v2.d.ts +176 -0
  170. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  171. package/dist/providers/working-dir.d.ts +17 -0
  172. package/dist/repo-mesh-types.d.ts +376 -13
  173. package/dist/runtime-defaults.d.ts +2 -0
  174. package/dist/sessions/registry.d.ts +6 -0
  175. package/dist/shared-types-extra.d.ts +2 -4
  176. package/dist/shared-types.d.ts +59 -2
  177. package/dist/status/normalize.d.ts +1 -1
  178. package/dist/status/normalize.js +1 -0
  179. package/dist/status/normalize.js.map +1 -1
  180. package/dist/status/normalize.mjs +1 -0
  181. package/dist/status/normalize.mjs.map +1 -1
  182. package/dist/status/reporter.d.ts +2 -0
  183. package/dist/status/snapshot.d.ts +26 -0
  184. package/dist/system/hash.d.ts +8 -0
  185. package/dist/system/load-better-sqlite3.d.ts +21 -0
  186. package/dist/types.d.ts +5 -0
  187. package/package.json +7 -3
  188. package/src/agent-stream/poller.ts +2 -3
  189. package/src/agent-stream/provider-adapter.ts +1 -1
  190. package/src/boot/daemon-lifecycle.ts +63 -12
  191. package/src/boot/process-hardening.ts +89 -0
  192. package/src/build-info.ts +73 -0
  193. package/src/chat/source-machine.ts +534 -0
  194. package/src/chat/source-resolver.ts +0 -0
  195. package/src/chat/subscription-updates.ts +20 -1
  196. package/src/cli-adapter-types.d.ts +1 -0
  197. package/src/cli-adapter-types.ts +68 -2
  198. package/src/cli-adapters/cli-script-runner.ts +421 -0
  199. package/src/cli-adapters/cli-state-engine.ts +1221 -0
  200. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  201. package/src/cli-adapters/provider-cli-adapter.ts +836 -1141
  202. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  203. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  204. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  205. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  206. package/src/cli-adapters/provider-cli-shared.ts +121 -11
  207. package/src/cli-adapters/pty-transport.ts +2 -1
  208. package/src/cli-adapters/pty-write-chunking.ts +106 -0
  209. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  210. package/src/cli-adapters/resolve-executable.ts +90 -0
  211. package/src/cli-adapters/session-host-transport.ts +2 -1
  212. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  213. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  214. package/src/cli-adapters/terminal-screen.ts +16 -81
  215. package/src/commands/WINDOWS-UPGRADE-LOCK-FAILURE.md +198 -0
  216. package/src/commands/chat-commands-debug-bundle.ts +398 -0
  217. package/src/commands/chat-commands-read.ts +2327 -0
  218. package/src/commands/chat-commands-scope.ts +54 -0
  219. package/src/commands/chat-commands-shared.ts +114 -0
  220. package/src/commands/chat-commands-write.ts +880 -0
  221. package/src/commands/chat-commands.ts +19 -1841
  222. package/src/commands/cli-manager.ts +513 -26
  223. package/src/commands/handler.ts +841 -2
  224. package/src/commands/high-family/index.ts +28 -0
  225. package/src/commands/high-family/mesh-coordinator-launch.ts +657 -0
  226. package/src/commands/high-family/mesh-events.ts +80 -0
  227. package/src/commands/high-family/mesh-status.ts +678 -0
  228. package/src/commands/high-family/types.ts +76 -0
  229. package/src/commands/low-family/coordinator-prompt.ts +72 -0
  230. package/src/commands/low-family/daemon-lifecycle.ts +107 -0
  231. package/src/commands/low-family/diagnostics.ts +57 -0
  232. package/src/commands/low-family/index.ts +37 -0
  233. package/src/commands/low-family/mesh-ledger.ts +62 -0
  234. package/src/commands/low-family/mesh-node-logs.ts +87 -0
  235. package/src/commands/low-family/notification.ts +116 -0
  236. package/src/commands/low-family/refine-config.ts +106 -0
  237. package/src/commands/low-family/session-host.ts +274 -0
  238. package/src/commands/low-family/spec-providerdev.ts +217 -0
  239. package/src/commands/low-family/status-meta.ts +112 -0
  240. package/src/commands/low-family/types.ts +39 -0
  241. package/src/commands/med-family/cli-agent.ts +230 -0
  242. package/src/commands/med-family/fast-forward.ts +230 -0
  243. package/src/commands/med-family/ide.ts +163 -0
  244. package/src/commands/med-family/index.ts +37 -0
  245. package/src/commands/med-family/mesh-crud.ts +836 -0
  246. package/src/commands/med-family/mesh-host-pairing.ts +234 -0
  247. package/src/commands/med-family/mesh-queue.ts +131 -0
  248. package/src/commands/med-family/mesh-restart.ts +92 -0
  249. package/src/commands/med-family/types.ts +120 -0
  250. package/src/commands/mesh-coordinator.ts +334 -124
  251. package/src/commands/router.ts +2476 -3207
  252. package/src/commands/stream-commands.ts +8 -0
  253. package/src/commands/upgrade-helper.ts +310 -45
  254. package/src/config/chat-history.ts +483 -24
  255. package/src/config/config.ts +12 -0
  256. package/src/config/mesh-config.ts +353 -22
  257. package/src/config/recent-activity.ts +8 -2
  258. package/src/daemon/dev-auto-implement.ts +3 -2
  259. package/src/daemon/dev-cli-debug.ts +10 -1
  260. package/src/daemon/dev-server.ts +0 -541
  261. package/src/detection/cli-detector.ts +28 -9
  262. package/src/detection/ide-detector.ts +55 -16
  263. package/src/detection/win32-ide-version.ts +106 -0
  264. package/src/git/change-impact-config.ts +354 -0
  265. package/src/git/git-commands.ts +55 -14
  266. package/src/git/git-diff.ts +81 -11
  267. package/src/git/git-executor.ts +12 -0
  268. package/src/git/git-status.ts +574 -43
  269. package/src/git/git-types.ts +14 -62
  270. package/src/git/git-worktree.ts +76 -1
  271. package/src/git/index.ts +16 -0
  272. package/src/index.ts +171 -12
  273. package/src/installer.d.ts +1 -1
  274. package/src/installer.ts +8 -6
  275. package/src/ipc/local-ipc-server.ts +278 -0
  276. package/src/launch.d.ts +1 -1
  277. package/src/launch.ts +37 -28
  278. package/src/logging/async-batch-writer.ts +55 -0
  279. package/src/logging/command-log.ts +7 -5
  280. package/src/logging/log-redactor.ts +100 -0
  281. package/src/logging/log-tail-reader.ts +341 -0
  282. package/src/logging/logger.ts +14 -7
  283. package/src/mesh/contracts.ts +338 -0
  284. package/src/mesh/coordinator-prompt.ts +381 -33
  285. package/src/mesh/coordinator-registry.ts +121 -0
  286. package/src/mesh/mesh-active-work.ts +645 -0
  287. package/src/mesh/mesh-coordinator-config.ts +97 -0
  288. package/src/mesh/mesh-delivery-policy.ts +315 -0
  289. package/src/mesh/mesh-event-classify.ts +51 -0
  290. package/src/mesh/mesh-event-forwarding.ts +1693 -0
  291. package/src/mesh/mesh-event-trace.ts +67 -0
  292. package/src/mesh/mesh-events-coordinator.ts +30 -0
  293. package/src/mesh/mesh-events-pending.ts +590 -0
  294. package/src/mesh/mesh-events-stale.ts +363 -0
  295. package/src/mesh/mesh-events-utils.ts +425 -0
  296. package/src/mesh/mesh-events.ts +32 -1035
  297. package/src/mesh/mesh-fast-forward.ts +839 -0
  298. package/src/mesh/mesh-host-ownership.ts +73 -0
  299. package/src/mesh/mesh-init.ts +260 -0
  300. package/src/mesh/mesh-ledger-reconciliation.ts +12 -7
  301. package/src/mesh/mesh-ledger.ts +596 -102
  302. package/src/mesh/mesh-missions.ts +322 -0
  303. package/src/mesh/mesh-node-identity.ts +1887 -0
  304. package/src/mesh/mesh-queue-assignment.ts +1491 -0
  305. package/src/mesh/mesh-reconcile-loop.ts +1527 -0
  306. package/src/mesh/mesh-refine-batch.ts +197 -0
  307. package/src/mesh/mesh-refine-gates.ts +1652 -0
  308. package/src/mesh/mesh-refine-status.ts +231 -0
  309. package/src/mesh/mesh-review-inbox.ts +307 -0
  310. package/src/mesh/mesh-routing.ts +291 -0
  311. package/src/mesh/mesh-runtime-store.ts +1795 -0
  312. package/src/mesh/mesh-scheduling-runtime.ts +199 -0
  313. package/src/mesh/mesh-task-stats.ts +161 -0
  314. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  315. package/src/mesh/mesh-warmup-deadline.ts +152 -0
  316. package/src/mesh/mesh-work-queue.ts +1141 -141
  317. package/src/mesh/preview-freshness.ts +118 -0
  318. package/src/mesh/refine-config.ts +423 -0
  319. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  320. package/src/providers/acp-provider-instance.ts +43 -10
  321. package/src/providers/approval-utils.d.ts +5 -0
  322. package/src/providers/approval-utils.ts +57 -5
  323. package/src/providers/chat-message-normalization.ts +46 -6
  324. package/src/providers/cli-provider-instance.ts +1552 -102
  325. package/src/providers/contracts.d.ts +55 -0
  326. package/src/providers/contracts.ts +150 -6
  327. package/src/providers/extension-provider-instance.ts +12 -7
  328. package/src/providers/external-sources.ts +218 -0
  329. package/src/providers/ide-provider-instance.ts +35 -12
  330. package/src/providers/manual-attendance.ts +85 -0
  331. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  332. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  333. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  334. package/src/providers/native-history/constants.ts +19 -0
  335. package/src/providers/native-history/dispatcher.ts +339 -0
  336. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  337. package/src/providers/native-history/index.ts +30 -0
  338. package/src/providers/provider-instance-manager.ts +30 -0
  339. package/src/providers/provider-instance.ts +19 -1
  340. package/src/providers/provider-loader.ts +668 -50
  341. package/src/providers/provider-schema.ts +87 -14
  342. package/src/providers/provider-trust.ts +114 -0
  343. package/src/providers/read-chat-contract.ts +76 -16
  344. package/src/providers/sdk/README.md +49 -0
  345. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  346. package/src/providers/sdk/v1/builders/cli/detect-status.ts +313 -0
  347. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  348. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  349. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  350. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  351. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  352. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  353. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  354. package/src/providers/sdk/v1/index.ts +152 -0
  355. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  356. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  357. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  358. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  359. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  360. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  361. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  362. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  363. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  364. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  365. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  366. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  367. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  368. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  369. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  370. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  371. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  372. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  373. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  374. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  375. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  376. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  377. package/src/providers/sdk/v1/validators/index.ts +19 -0
  378. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  379. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  380. package/src/providers/spec/adapter.ts +235 -0
  381. package/src/providers/spec/cli-adapter.ts +1056 -0
  382. package/src/providers/spec/evaluator.ts +407 -0
  383. package/src/providers/spec/fsm-driver.ts +1304 -0
  384. package/src/providers/spec/fsm-evaluator.ts +272 -0
  385. package/src/providers/spec/fsm-loader.ts +120 -0
  386. package/src/providers/spec/fsm-types.ts +273 -0
  387. package/src/providers/spec/native-history-executor.ts +1128 -0
  388. package/src/providers/spec/pre-launch-trust.ts +104 -0
  389. package/src/providers/spec/route.ts +51 -0
  390. package/src/providers/spec/types.ts +262 -0
  391. package/src/providers/status-monitor.d.ts +7 -7
  392. package/src/providers/status-monitor.ts +37 -22
  393. package/src/providers/transcript-v2.ts +567 -0
  394. package/src/providers/types/interactive-prompt.ts +536 -0
  395. package/src/providers/version-archive.ts +64 -24
  396. package/src/providers/working-dir.ts +23 -0
  397. package/src/repo-mesh-types.ts +542 -14
  398. package/src/runtime-defaults.ts +39 -0
  399. package/src/sessions/registry.ts +6 -0
  400. package/src/shared-types-extra.ts +2 -4
  401. package/src/shared-types.d.ts +8 -0
  402. package/src/shared-types.ts +64 -1
  403. package/src/status/builders.ts +26 -6
  404. package/src/status/normalize.ts +2 -0
  405. package/src/status/reporter.ts +19 -1
  406. package/src/status/snapshot.ts +95 -26
  407. package/src/system/hash.ts +23 -0
  408. package/src/system/host-memory.ts +29 -12
  409. package/src/system/load-better-sqlite3.ts +68 -0
  410. package/src/types.ts +5 -0
  411. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  412. package/dist/mesh/mesh-sync.d.ts +0 -53
  413. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  414. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  415. package/src/mesh/mesh-sync.ts +0 -111
@@ -0,0 +1,1795 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync } 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 { getLedgerDir } from './mesh-ledger.js';
6
+ import { nodeSatisfiesRequiredTags } from './mesh-work-queue.js';
7
+ import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
8
+ import type BetterSqlite3 from 'better-sqlite3';
9
+ import type { Database as DatabaseHandle } from 'better-sqlite3';
10
+
11
+ let DatabaseCtor: typeof BetterSqlite3 | undefined;
12
+
13
+ function loadDatabaseCtor(): typeof BetterSqlite3 {
14
+ if (DatabaseCtor) return DatabaseCtor;
15
+ DatabaseCtor = loadBetterSqlite3() as typeof BetterSqlite3;
16
+ return DatabaseCtor;
17
+ }
18
+
19
+ function safeMeshId(meshId: string): string {
20
+ return meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
21
+ }
22
+
23
+ function legacyQueuePath(meshId: string): string {
24
+ return join(getLedgerDir(), `${safeMeshId(meshId)}.queue.json`);
25
+ }
26
+
27
+ let loggedMigrationFailure = false;
28
+
29
+ function meshRuntimeStorePath(): string {
30
+ const dir = getLedgerDir();
31
+ const nextPath = join(dir, 'mesh-runtime.db');
32
+ if (existsSync(nextPath)) return nextPath;
33
+
34
+ const legacyPath = join(dir, 'beads.db');
35
+ if (!existsSync(legacyPath)) return nextPath;
36
+
37
+ try {
38
+ renameSync(legacyPath, nextPath);
39
+ for (const suffix of ['-wal', '-shm']) {
40
+ const legacyCompanion = `${legacyPath}${suffix}`;
41
+ if (existsSync(legacyCompanion)) {
42
+ renameSync(legacyCompanion, `${nextPath}${suffix}`);
43
+ }
44
+ }
45
+ } catch (err: any) {
46
+ // Migration failed — most commonly win32 EPERM when a handle to the
47
+ // legacy DB is still open. Do NOT fall through to opening `nextPath`:
48
+ // that would create a fresh EMPTY store while the existing data stays
49
+ // stranded in the legacy file (split-brain / silent data loss). Instead
50
+ // keep using whichever file actually holds the data in-place — the next
51
+ // boot retries the rename. If the main rename already landed (only a
52
+ // companion file failed), the data is at nextPath; otherwise it is still
53
+ // at legacyPath.
54
+ if (!loggedMigrationFailure) {
55
+ loggedMigrationFailure = true;
56
+ LOG.warn(
57
+ 'MeshRuntimeStore',
58
+ `Legacy beads.db→mesh-runtime.db migration failed; using existing DB in-place to avoid data loss: ${err?.message || err}`,
59
+ );
60
+ }
61
+ return existsSync(nextPath) ? nextPath : legacyPath;
62
+ }
63
+ return nextPath;
64
+ }
65
+
66
+ export class MeshRuntimeStore {
67
+ private static instance: MeshRuntimeStore | undefined;
68
+ private readonly db: DatabaseHandle;
69
+ private readonly dbPath: string;
70
+ private readonly migratedMeshIds = new Set<string>();
71
+ private fingerprintSweepCounter = 0;
72
+ private walWriteCounter = 0;
73
+ // Independent cadence for the tool-call-log sweep. Must NOT share walWriteCounter:
74
+ // sharing makes each store's threshold drift by the other's write volume (WAL
75
+ // checkpoint at 500 vs tool-log sweep at 200 would interfere arbitrarily).
76
+ private toolCallLogCounter = 0;
77
+ private static readonly WAL_CHECK_INTERVAL = 500;
78
+ private static readonly WAL_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
79
+
80
+ private constructor(dbPath: string) {
81
+ const dir = dirname(dbPath);
82
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
83
+
84
+ this.dbPath = dbPath;
85
+ this.db = new (loadDatabaseCtor())(dbPath);
86
+ this.db.pragma('journal_mode = WAL');
87
+ this.db.pragma('synchronous = NORMAL');
88
+ this.db.pragma('foreign_keys = ON');
89
+ this.db.pragma('busy_timeout = 5000');
90
+ this.migrate();
91
+ }
92
+
93
+ private static loggedGetInstanceFailure = false;
94
+
95
+ static getInstance(): MeshRuntimeStore {
96
+ if (!this.instance) {
97
+ try {
98
+ this.instance = new MeshRuntimeStore(meshRuntimeStorePath());
99
+ } catch (err: any) {
100
+ // SQLite store could not be opened (e.g. better-sqlite3 native
101
+ // load failure, locked/corrupt DB). Callers wrap getInstance in
102
+ // try/catch and silently degrade to JSONL-only — surface ONE warn
103
+ // so that degraded mode is diagnosable, then re-throw unchanged.
104
+ if (!MeshRuntimeStore.loggedGetInstanceFailure) {
105
+ MeshRuntimeStore.loggedGetInstanceFailure = true;
106
+ LOG.warn(
107
+ 'MeshRuntimeStore',
108
+ `getInstance failed; callers will degrade to JSONL-only: ${err?.message || err}`,
109
+ );
110
+ }
111
+ throw err;
112
+ }
113
+ }
114
+ return this.instance;
115
+ }
116
+
117
+ static resetForTests(): void {
118
+ this.instance?.close();
119
+ this.instance = undefined;
120
+ }
121
+
122
+ close(): void {
123
+ this.db.close();
124
+ }
125
+
126
+ transaction<T>(fn: () => T): T {
127
+ return this.db.transaction(fn).immediate();
128
+ }
129
+
130
+ private migrate(): void {
131
+ this.db.exec(`
132
+ CREATE TABLE IF NOT EXISTS mesh_queue (
133
+ id TEXT PRIMARY KEY,
134
+ mesh_id TEXT NOT NULL,
135
+ status TEXT NOT NULL,
136
+ target_node_id TEXT,
137
+ target_session_id TEXT,
138
+ assigned_node_id TEXT,
139
+ assigned_session_id TEXT,
140
+ created_at TEXT NOT NULL,
141
+ updated_at TEXT NOT NULL,
142
+ payload TEXT NOT NULL
143
+ );
144
+
145
+ CREATE INDEX IF NOT EXISTS idx_mesh_queue_mesh_status_created
146
+ ON mesh_queue(mesh_id, status, created_at);
147
+ CREATE INDEX IF NOT EXISTS idx_mesh_queue_assignment
148
+ ON mesh_queue(mesh_id, assigned_node_id, assigned_session_id, status);
149
+
150
+ -- mesh_id is DB-level isolation (defense-in-depth). The fingerprint STRING
151
+ -- also carries meshId as its first '::'-joined segment (see
152
+ -- buildMeshCompletionFingerprint) — that string-prefix defense is kept; this
153
+ -- column makes cross-mesh suppression impossible even if the string format
154
+ -- drifts or two meshes ever collide on a fingerprint body.
155
+ CREATE TABLE IF NOT EXISTS mesh_completion_fingerprints (
156
+ fingerprint TEXT PRIMARY KEY,
157
+ expires_at INTEGER NOT NULL,
158
+ mesh_id TEXT NOT NULL DEFAULT ''
159
+ );
160
+ -- NOTE: the (mesh_id, fingerprint) index is created in migrateMeshIsolationColumns,
161
+ -- NOT here. A pre-isolation DB still has the legacy table (CREATE IF NOT EXISTS is a
162
+ -- no-op), so referencing mesh_id in an index before the ALTER ADD COLUMN runs would
163
+ -- fail with "no such column". The migration adds the column then the index.
164
+
165
+ CREATE TABLE IF NOT EXISTS mesh_direct_dispatches (
166
+ task_id TEXT PRIMARY KEY,
167
+ mesh_id TEXT NOT NULL,
168
+ node_id TEXT,
169
+ session_id TEXT,
170
+ provider_type TEXT,
171
+ message TEXT NOT NULL,
172
+ task_mode TEXT,
173
+ via TEXT NOT NULL,
174
+ status TEXT NOT NULL DEFAULT 'dispatched',
175
+ dispatched_to_idle_session INTEGER NOT NULL DEFAULT 0,
176
+ dispatched_at TEXT NOT NULL,
177
+ updated_at TEXT NOT NULL
178
+ );
179
+
180
+ CREATE INDEX IF NOT EXISTS idx_direct_dispatches_mesh_session
181
+ ON mesh_direct_dispatches(mesh_id, session_id, status);
182
+
183
+ -- MESH-ISOLATION-LEAK: mesh_id is part of the PK so a nodeId shared across two
184
+ -- meshes (same machine in multiple repos) keeps a separate idle-session row per
185
+ -- mesh, and getRemoteIdleSessions(meshId) can never surface another mesh's
186
+ -- session for a queue claim.
187
+ CREATE TABLE IF NOT EXISTS remote_idle_sessions (
188
+ mesh_id TEXT NOT NULL,
189
+ node_id TEXT NOT NULL,
190
+ session_id TEXT NOT NULL,
191
+ provider_type TEXT NOT NULL,
192
+ expires_at INTEGER NOT NULL,
193
+ metadata TEXT,
194
+ PRIMARY KEY (mesh_id, node_id, session_id)
195
+ );
196
+
197
+ CREATE TABLE IF NOT EXISTS mesh_session_delivery (
198
+ id TEXT PRIMARY KEY,
199
+ mesh_id TEXT NOT NULL,
200
+ node_id TEXT,
201
+ session_id TEXT,
202
+ provider_type TEXT,
203
+ task_id TEXT,
204
+ kind TEXT NOT NULL,
205
+ priority INTEGER NOT NULL DEFAULT 0,
206
+ message TEXT NOT NULL,
207
+ status TEXT NOT NULL DEFAULT 'queued',
208
+ deliver_after TEXT,
209
+ expires_at TEXT,
210
+ attempt_count INTEGER NOT NULL DEFAULT 0,
211
+ source_coordinator_session_id TEXT,
212
+ source_coordinator_daemon_id TEXT,
213
+ last_error TEXT,
214
+ created_at TEXT NOT NULL,
215
+ updated_at TEXT NOT NULL
216
+ );
217
+
218
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_mesh_status
219
+ ON mesh_session_delivery(mesh_id, status, created_at);
220
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_session
221
+ ON mesh_session_delivery(mesh_id, session_id, status);
222
+ CREATE INDEX IF NOT EXISTS idx_mesh_session_delivery_task
223
+ ON mesh_session_delivery(mesh_id, task_id);
224
+
225
+ CREATE TABLE IF NOT EXISTS mesh_completion_conflicts (
226
+ id TEXT PRIMARY KEY,
227
+ mesh_id TEXT NOT NULL,
228
+ fingerprint TEXT NOT NULL,
229
+ conflicting_task_id TEXT,
230
+ conflicting_session_id TEXT,
231
+ original_task_id TEXT,
232
+ original_session_id TEXT,
233
+ event TEXT NOT NULL,
234
+ created_at TEXT NOT NULL
235
+ );
236
+
237
+ CREATE INDEX IF NOT EXISTS idx_mesh_completion_conflicts_mesh
238
+ ON mesh_completion_conflicts(mesh_id, created_at);
239
+
240
+ CREATE TABLE IF NOT EXISTS mesh_tool_call_log (
241
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
242
+ mesh_id TEXT NOT NULL,
243
+ tool TEXT NOT NULL,
244
+ session_id TEXT,
245
+ called_at INTEGER NOT NULL
246
+ );
247
+
248
+ CREATE INDEX IF NOT EXISTS idx_mesh_tool_call_log_mesh_tool_time
249
+ ON mesh_tool_call_log(mesh_id, tool, called_at);
250
+
251
+ -- G2: Event ledger — runtime source of truth for task/session lifecycle events.
252
+ -- JSONL files are retained as export/import/debug/legacy artifacts only.
253
+ CREATE TABLE IF NOT EXISTS mesh_event_ledger (
254
+ id TEXT PRIMARY KEY,
255
+ mesh_id TEXT NOT NULL,
256
+ timestamp TEXT NOT NULL,
257
+ kind TEXT NOT NULL,
258
+ node_id TEXT,
259
+ session_id TEXT,
260
+ provider_type TEXT,
261
+ payload TEXT NOT NULL DEFAULT '{}'
262
+ );
263
+
264
+ CREATE INDEX IF NOT EXISTS idx_mesh_event_ledger_mesh_time
265
+ ON mesh_event_ledger(mesh_id, timestamp);
266
+ CREATE INDEX IF NOT EXISTS idx_mesh_event_ledger_mesh_kind
267
+ ON mesh_event_ledger(mesh_id, kind, timestamp);
268
+ CREATE INDEX IF NOT EXISTS idx_mesh_event_ledger_session
269
+ ON mesh_event_ledger(mesh_id, session_id, timestamp);
270
+
271
+ -- G3: Pending coordinator event inbox — replaces <meshId>.pending-events.jsonl.
272
+ -- Coordinator drains this table on get_pending_mesh_events, then deletes drained rows.
273
+ CREATE TABLE IF NOT EXISTS mesh_pending_events (
274
+ id TEXT PRIMARY KEY,
275
+ mesh_id TEXT NOT NULL,
276
+ coordinator_daemon_id TEXT,
277
+ event TEXT NOT NULL,
278
+ payload TEXT NOT NULL DEFAULT '{}',
279
+ fingerprint TEXT,
280
+ queued_at INTEGER NOT NULL,
281
+ drained INTEGER NOT NULL DEFAULT 0,
282
+ drained_at INTEGER
283
+ );
284
+
285
+ CREATE INDEX IF NOT EXISTS idx_mesh_pending_events_mesh_drained
286
+ ON mesh_pending_events(mesh_id, drained, queued_at);
287
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_mesh_pending_events_fingerprint
288
+ ON mesh_pending_events(mesh_id, fingerprint)
289
+ WHERE fingerprint IS NOT NULL;
290
+
291
+ -- M3: persistent mission records. Plans live in the system, not in the
292
+ -- coordinator LLM's context. Progress is derived from task statuses at
293
+ -- query time (mission_id on queue tasks) — never stored here.
294
+ CREATE TABLE IF NOT EXISTS mesh_missions (
295
+ id TEXT PRIMARY KEY,
296
+ mesh_id TEXT NOT NULL,
297
+ title TEXT NOT NULL,
298
+ goal TEXT NOT NULL DEFAULT '',
299
+ status TEXT NOT NULL DEFAULT 'active',
300
+ created_at TEXT NOT NULL,
301
+ updated_at TEXT NOT NULL
302
+ );
303
+
304
+ CREATE INDEX IF NOT EXISTS idx_mesh_missions_mesh_status
305
+ ON mesh_missions(mesh_id, status, updated_at);
306
+
307
+ -- Load-balancing scheduler: per-mesh round-robin rotation cursor. When
308
+ -- the schedulingStrategy is 'round_robin', several eligible nodes tied at
309
+ -- the least load are rotated by this cursor so the tie-break winner cycles
310
+ -- across scheduling passes instead of always favouring the same array-order
311
+ -- node. Persisted (not a module Map) so rotation survives daemon restarts
312
+ -- and stays a single source of truth across scheduling entry points.
313
+ CREATE TABLE IF NOT EXISTS mesh_scheduler_cursor (
314
+ mesh_id TEXT PRIMARY KEY,
315
+ cursor INTEGER NOT NULL DEFAULT 0
316
+ );
317
+ `);
318
+ this.migrateMeshIsolationColumns();
319
+ }
320
+
321
+ private tableColumns(table: string): Set<string> {
322
+ const rows = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
323
+ return new Set(rows.map(r => r.name));
324
+ }
325
+
326
+ /**
327
+ * MESH-ISOLATION-LEAK migration. Two tables historically lacked a `mesh_id` column,
328
+ * letting one machine that belongs to multiple meshes (multiple repos) leak rows
329
+ * across meshes. Both migrations are idempotent and run on every boot — the column
330
+ * check short-circuits once the new schema is in place.
331
+ */
332
+ private migrateMeshIsolationColumns(): void {
333
+ try {
334
+ // 1. mesh_completion_fingerprints: ADD COLUMN + backfill mesh_id from the
335
+ // fingerprint string's first '::'-joined segment (buildMeshCompletionFingerprint
336
+ // prefixes meshId). A row whose fingerprint has no '::' (legacy/foreign format)
337
+ // backfills to '' — still strictly tighter than the prior global query.
338
+ const fpCols = this.tableColumns('mesh_completion_fingerprints');
339
+ if (!fpCols.has('mesh_id')) {
340
+ this.db.exec(`ALTER TABLE mesh_completion_fingerprints ADD COLUMN mesh_id TEXT NOT NULL DEFAULT ''`);
341
+ this.db.exec(`
342
+ UPDATE mesh_completion_fingerprints
343
+ SET mesh_id = substr(fingerprint, 1, instr(fingerprint, '::') - 1)
344
+ WHERE instr(fingerprint, '::') > 0 AND mesh_id = ''
345
+ `);
346
+ }
347
+ // The mesh_id column is now guaranteed to exist (fresh DB had it from CREATE TABLE,
348
+ // legacy DB just got it via ALTER). Create the index unconditionally — IF NOT EXISTS
349
+ // makes it a no-op once present.
350
+ this.db.exec(`
351
+ CREATE INDEX IF NOT EXISTS idx_mesh_completion_fingerprints_mesh
352
+ ON mesh_completion_fingerprints(mesh_id, fingerprint)
353
+ `);
354
+
355
+ // 2. remote_idle_sessions: the mesh_id is part of the PRIMARY KEY, which SQLite
356
+ // cannot add via ALTER. The rows are ephemeral — sessions re-register on the
357
+ // next agent:ready / agent:generating_completed — so a safe DROP+recreate is
358
+ // acceptable (per fix spec) rather than a full table rebuild + un-backfillable
359
+ // mesh_id. Only rebuild when the legacy (no mesh_id) schema is detected.
360
+ const idleCols = this.tableColumns('remote_idle_sessions');
361
+ if (!idleCols.has('mesh_id')) {
362
+ this.db.exec(`
363
+ DROP TABLE IF EXISTS remote_idle_sessions;
364
+ CREATE TABLE remote_idle_sessions (
365
+ mesh_id TEXT NOT NULL,
366
+ node_id TEXT NOT NULL,
367
+ session_id TEXT NOT NULL,
368
+ provider_type TEXT NOT NULL,
369
+ expires_at INTEGER NOT NULL,
370
+ metadata TEXT,
371
+ PRIMARY KEY (mesh_id, node_id, session_id)
372
+ );
373
+ `);
374
+ }
375
+ } catch (err: any) {
376
+ // Best-effort: a failed isolation migration must not brick the store. The
377
+ // CREATE-TABLE definitions above already carry the new schema for fresh DBs;
378
+ // an existing DB that fails here keeps the old (leaky-but-functional) schema
379
+ // until the next boot retries. Surface one warn for diagnosability.
380
+ if (!loggedMigrationFailure) {
381
+ loggedMigrationFailure = true;
382
+ LOG.warn('MeshRuntimeStore', `mesh-isolation column migration failed: ${err?.message || err}`);
383
+ }
384
+ }
385
+ }
386
+
387
+ hasCompletionFingerprint(meshId: string, fingerprint: string): boolean {
388
+ const now = Date.now();
389
+ // Scope by mesh_id (defense-in-depth) AS WELL AS the fingerprint string, whose
390
+ // first '::' segment already encodes meshId. A fingerprint can only suppress a
391
+ // duplicate within its own mesh.
392
+ const row = this.db
393
+ .prepare('SELECT 1 FROM mesh_completion_fingerprints WHERE mesh_id = ? AND fingerprint = ? AND expires_at > ?')
394
+ .get(meshId, fingerprint, now) as { 1: number } | undefined;
395
+ // Sweep expired fingerprints every 100 reads so stale rows don't accumulate
396
+ // even during read-heavy (non-write) periods when recordFingerprintSeen is idle.
397
+ if (++this.fingerprintSweepCounter >= 100) {
398
+ this.fingerprintSweepCounter = 0;
399
+ this.sweepExpiredFingerprints();
400
+ }
401
+ return row !== undefined;
402
+ }
403
+
404
+ recordCompletionFingerprint(meshId: string, fingerprint: string, ttlMs: number): void {
405
+ const expiresAt = Date.now() + ttlMs;
406
+ this.db.prepare('INSERT OR REPLACE INTO mesh_completion_fingerprints (fingerprint, expires_at, mesh_id) VALUES (?, ?, ?)')
407
+ .run(fingerprint, expiresAt, meshId);
408
+ this.maybeCheckpointWal();
409
+ }
410
+
411
+ sweepExpiredFingerprints(): void {
412
+ this.db.prepare('DELETE FROM mesh_completion_fingerprints WHERE expires_at <= ?').run(Date.now());
413
+ }
414
+
415
+ private maybeCheckpointWal(): void {
416
+ if (++this.walWriteCounter < MeshRuntimeStore.WAL_CHECK_INTERVAL) return;
417
+ this.walWriteCounter = 0;
418
+ try {
419
+ const walPath = `${this.dbPath}-wal`;
420
+ if (!existsSync(walPath)) return;
421
+ const size = statSync(walPath).size;
422
+ if (size < MeshRuntimeStore.WAL_MAX_BYTES) return;
423
+ process.stderr.write(
424
+ `[adhdev-mesh] WAL file ${Math.round(size / 1024 / 1024)}MB exceeds threshold; forcing checkpoint\n`,
425
+ );
426
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
427
+ } catch { /* best-effort */ }
428
+ }
429
+
430
+ private ensureLegacyQueueMigrated(meshId: string): void {
431
+ if (this.migratedMeshIds.has(meshId)) return;
432
+ this.migratedMeshIds.add(meshId);
433
+
434
+ const count = this.db
435
+ .prepare('SELECT COUNT(*) AS count FROM mesh_queue WHERE mesh_id = ?')
436
+ .get(meshId) as { count: number };
437
+ if (count.count > 0) return;
438
+
439
+ const path = legacyQueuePath(meshId);
440
+ if (!existsSync(path)) return;
441
+
442
+ try {
443
+ const entries = JSON.parse(readFileSync(path, 'utf-8')) as MeshWorkQueueEntry[];
444
+ if (!Array.isArray(entries)) return;
445
+ const insert = this.db.prepare(`
446
+ INSERT OR REPLACE INTO mesh_queue (
447
+ id, mesh_id, status, target_node_id, target_session_id,
448
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
449
+ ) VALUES (
450
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
451
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
452
+ )
453
+ `);
454
+ for (const entry of entries) {
455
+ insert.run(this.toRow(entry));
456
+ }
457
+ } catch {
458
+ return;
459
+ }
460
+ }
461
+
462
+ getQueueEntries(meshId: string, statuses?: MeshTaskStatus[]): MeshWorkQueueEntry[] {
463
+ this.ensureLegacyQueueMigrated(meshId);
464
+ if (statuses?.length) {
465
+ const placeholders = statuses.map(() => '?').join(', ');
466
+ const rows = this.db
467
+ .prepare(`SELECT payload FROM mesh_queue WHERE mesh_id = ? AND status IN (${placeholders}) ORDER BY created_at ASC`)
468
+ .all(meshId, ...statuses) as Array<{ payload: string }>;
469
+ return rows.map(row => JSON.parse(row.payload) as MeshWorkQueueEntry);
470
+ }
471
+ const rows = this.db
472
+ .prepare('SELECT payload FROM mesh_queue WHERE mesh_id = ? ORDER BY created_at ASC')
473
+ .all(meshId) as Array<{ payload: string }>;
474
+ return rows.map(row => JSON.parse(row.payload) as MeshWorkQueueEntry);
475
+ }
476
+
477
+ getQueueRevision(meshId: string): string {
478
+ this.ensureLegacyQueueMigrated(meshId);
479
+ const rows = this.db
480
+ .prepare('SELECT id, status, updated_at FROM mesh_queue WHERE mesh_id = ? ORDER BY id ASC')
481
+ .all(meshId) as Array<{ id: string; status: string; updated_at: string }>;
482
+ // Tab as field delimiter (UUIDs and ISO timestamps never contain tabs).
483
+ return rows.map(row => `${row.id}\t${row.status}\t${row.updated_at}`).join('\n');
484
+ }
485
+
486
+ replaceQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
487
+ const deleteStmt = this.db.prepare('DELETE FROM mesh_queue WHERE mesh_id = ?');
488
+ const insert = this.db.prepare(`
489
+ INSERT INTO mesh_queue (
490
+ id, mesh_id, status, target_node_id, target_session_id,
491
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
492
+ ) VALUES (
493
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
494
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
495
+ )
496
+ `);
497
+ deleteStmt.run(meshId);
498
+ for (const entry of queue) insert.run(this.toRow(entry));
499
+ this.maybeCheckpointWal();
500
+ }
501
+
502
+ deleteQueue(meshId: string): void {
503
+ this.db.prepare('DELETE FROM mesh_queue WHERE mesh_id = ?').run(meshId);
504
+ this.migratedMeshIds.delete(meshId);
505
+ }
506
+
507
+ insertQueueEntry(entry: MeshWorkQueueEntry): void {
508
+ this.db.prepare(`
509
+ INSERT INTO mesh_queue (
510
+ id, mesh_id, status, target_node_id, target_session_id,
511
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
512
+ ) VALUES (
513
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
514
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
515
+ )
516
+ `).run(this.toRow(entry));
517
+ this.maybeCheckpointWal();
518
+ }
519
+
520
+ updateQueueEntry(entry: MeshWorkQueueEntry): void {
521
+ const now = new Date().toISOString();
522
+ entry.updatedAt = now;
523
+ this.db.prepare(`
524
+ UPDATE mesh_queue SET
525
+ status = @status,
526
+ target_node_id = @targetNodeId,
527
+ target_session_id = @targetSessionId,
528
+ assigned_node_id = @assignedNodeId,
529
+ assigned_session_id = @assignedSessionId,
530
+ updated_at = @updatedAt,
531
+ payload = @payload
532
+ WHERE id = @id AND mesh_id = @meshId
533
+ `).run(this.toRow(entry));
534
+ this.maybeCheckpointWal();
535
+ }
536
+
537
+ findQueueEntryById(meshId: string, id: string): MeshWorkQueueEntry | null {
538
+ this.ensureLegacyQueueMigrated(meshId);
539
+ const row = this.db.prepare(
540
+ 'SELECT payload FROM mesh_queue WHERE id = ? AND mesh_id = ?'
541
+ ).get(id, meshId) as { payload: string } | undefined;
542
+ return row ? JSON.parse(row.payload) as MeshWorkQueueEntry : null;
543
+ }
544
+
545
+ hasActiveAssignment(meshId: string, sessionId: string, nodeId: string): boolean {
546
+ this.ensureLegacyQueueMigrated(meshId);
547
+ const row = this.db.prepare(`
548
+ SELECT 1 FROM mesh_queue
549
+ WHERE mesh_id = ? AND status = 'assigned'
550
+ AND (assigned_session_id = ? OR assigned_node_id = ?)
551
+ LIMIT 1
552
+ `).get(meshId, sessionId, nodeId);
553
+ return row !== undefined;
554
+ }
555
+
556
+ /** A session may only execute one task at a time, regardless of task mode. */
557
+ private hasActiveSessionAssignment(meshId: string, sessionId: string): boolean {
558
+ const row = this.db.prepare(`
559
+ SELECT 1 FROM mesh_queue
560
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_session_id = ?
561
+ LIMIT 1
562
+ `).get(meshId, sessionId);
563
+ return row !== undefined;
564
+ }
565
+
566
+ /** A node may only execute one write task at a time (worktree isolation). */
567
+ private hasActiveNodeAssignment(meshId: string, nodeId: string): boolean {
568
+ const row = this.db.prepare(`
569
+ SELECT 1 FROM mesh_queue
570
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id = ?
571
+ LIMIT 1
572
+ `).get(meshId, nodeId);
573
+ return row !== undefined;
574
+ }
575
+
576
+ /**
577
+ * Count active (status='assigned') tasks on a node, regardless of provider or
578
+ * task mode. This is the load metric for least-loaded / round-robin ranking:
579
+ * the scheduler prefers the node with the fewest active assignments so
580
+ * untargeted work spreads instead of piling onto whichever node asks first.
581
+ */
582
+ nodeActiveAssignmentCount(meshId: string, nodeId: string): number {
583
+ const row = this.db.prepare(`
584
+ SELECT COUNT(*) as count FROM mesh_queue
585
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id = ?
586
+ `).get(meshId, nodeId) as { count: number } | undefined;
587
+ return row?.count ?? 0;
588
+ }
589
+
590
+ /**
591
+ * O(1) count of queue tasks in 'pending' status for a mesh. A COUNT(*) over the
592
+ * indexed status column, so it avoids JSON.parse-ing every queue row — used as a
593
+ * cheap guard before the reconcile loop runs a full triggerMeshQueue scan.
594
+ */
595
+ pendingQueueTaskCount(meshId: string): number {
596
+ const row = this.db.prepare(`
597
+ SELECT COUNT(*) as count FROM mesh_queue
598
+ WHERE mesh_id = ? AND status = 'pending'
599
+ `).get(meshId) as { count: number } | undefined;
600
+ return row?.count ?? 0;
601
+ }
602
+
603
+ /**
604
+ * Read the current per-mesh round-robin cursor (0 when unset). Used to rotate
605
+ * the tie-break winner among nodes tied at the least load.
606
+ */
607
+ getSchedulerCursor(meshId: string): number {
608
+ const row = this.db.prepare(
609
+ 'SELECT cursor FROM mesh_scheduler_cursor WHERE mesh_id = ?'
610
+ ).get(meshId) as { cursor: number } | undefined;
611
+ return row?.cursor ?? 0;
612
+ }
613
+
614
+ /**
615
+ * Atomically advance the per-mesh round-robin cursor by one and return the
616
+ * value that was current BEFORE the bump (the value the caller should rotate
617
+ * by for this pass). UPSERT keeps it lock-free across concurrent passes.
618
+ */
619
+ bumpSchedulerCursor(meshId: string): number {
620
+ return this.transaction(() => {
621
+ const current = this.getSchedulerCursor(meshId);
622
+ this.db.prepare(`
623
+ INSERT INTO mesh_scheduler_cursor (mesh_id, cursor) VALUES (?, ?)
624
+ ON CONFLICT(mesh_id) DO UPDATE SET cursor = excluded.cursor
625
+ `).run(meshId, current + 1);
626
+ return current;
627
+ });
628
+ }
629
+
630
+ /**
631
+ * Count active (status='assigned') tasks on a (node, provider) combination,
632
+ * matched by the assignedProviderType stamped on the payload at claim time.
633
+ * Drives the per-(node, provider) maxParallel cap (RepoMeshNodePolicy
634
+ * providerRoles). The active-assignment set for a single node is tiny, so
635
+ * parsing payloads here is cheap and avoids a schema migration. Pre-cap legacy
636
+ * rows (no provider stamp) and other providers on the same node do not consume
637
+ * this provider's budget, so the cap is fully backward compatible.
638
+ */
639
+ private activeProviderAssignmentCount(meshId: string, nodeId: string, providerType: string): number {
640
+ const rows = this.db.prepare(`
641
+ SELECT payload FROM mesh_queue
642
+ WHERE mesh_id = ? AND status = 'assigned' AND assigned_node_id = ?
643
+ `).all(meshId, nodeId) as Array<{ payload: string }>;
644
+ let count = 0;
645
+ for (const row of rows) {
646
+ try {
647
+ const entry = JSON.parse(row.payload) as MeshWorkQueueEntry;
648
+ if (entry.assignedProviderType === providerType) count += 1;
649
+ } catch { /* skip unparsable row */ }
650
+ }
651
+ return count;
652
+ }
653
+
654
+ // O(1) claim: transaction ensures only one session claims a pending task
655
+ claimNextQueueTask(
656
+ meshId: string,
657
+ nodeId: string,
658
+ sessionId: string,
659
+ capabilityTags: string[] = [],
660
+ opts?: { providerType?: string; providerMaxParallel?: number; nodeIsWorktree?: boolean },
661
+ ): MeshWorkQueueEntry | null {
662
+ return this.transaction(() => {
663
+ this.ensureLegacyQueueMigrated(meshId);
664
+ // A session executes one task at a time regardless of mode — block early.
665
+ // The node-level conflict is evaluated per-candidate below so that
666
+ // read-only (live_debug_readonly) tasks can claim concurrently on a node
667
+ // that already has an active assignment, while write tasks keep the
668
+ // one-active-per-node invariant (worktree isolation).
669
+ if (this.hasActiveSessionAssignment(meshId, sessionId)) return null;
670
+ const nodeBusy = this.hasActiveNodeAssignment(meshId, nodeId);
671
+
672
+ // Per-(node, provider) maxParallel cap (RepoMeshNodePolicy providerRoles).
673
+ // Orthogonal to taskMode: this bounds the (node, provider) resource pool
674
+ // regardless of read-only vs write. When the cap is already met, this
675
+ // session cannot claim any candidate here — return null. This composes
676
+ // with the global/taskMode caps enforced in the coordinator (stricter
677
+ // wins); omitting providerMaxParallel preserves prior behavior exactly.
678
+ const providerType = typeof opts?.providerType === 'string' ? opts.providerType.trim() : '';
679
+ const providerMaxParallel = opts?.providerMaxParallel;
680
+ if (
681
+ providerType
682
+ && typeof providerMaxParallel === 'number'
683
+ && Number.isFinite(providerMaxParallel)
684
+ && providerMaxParallel >= 0
685
+ && this.activeProviderAssignmentCount(meshId, nodeId, providerType) >= providerMaxParallel
686
+ ) {
687
+ return null;
688
+ }
689
+
690
+ // Priority: session-targeted > node-targeted (no session) > unconstrained
691
+ const rows = [
692
+ ...(
693
+ this.db.prepare(`
694
+ SELECT payload FROM mesh_queue
695
+ WHERE mesh_id = ? AND status = 'pending' AND target_session_id = ?
696
+ ORDER BY created_at ASC
697
+ `).all(meshId, sessionId) as Array<{ payload: string }>
698
+ ),
699
+ ...(
700
+ this.db.prepare(`
701
+ SELECT payload FROM mesh_queue
702
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id = ? AND target_session_id IS NULL
703
+ ORDER BY created_at ASC
704
+ `).all(meshId, nodeId) as Array<{ payload: string }>
705
+ ),
706
+ ...(
707
+ this.db.prepare(`
708
+ SELECT payload FROM mesh_queue
709
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id IS NULL AND target_session_id IS NULL
710
+ ORDER BY created_at ASC
711
+ `).all(meshId) as Array<{ payload: string }>
712
+ ),
713
+ ];
714
+ const candidates = rows.map(row => JSON.parse(row.payload) as MeshWorkQueueEntry);
715
+
716
+ // M1: a task with unmet dependencies (or a system blockedReason) is not claimable.
717
+ // Resolve dependency statuses in one query over the union of referenced ids.
718
+ const depIds = [...new Set(candidates.flatMap(c => Array.isArray(c.dependsOn) ? c.dependsOn : []))];
719
+ const depStatus = new Map<string, string>();
720
+ if (depIds.length > 0) {
721
+ const placeholders = depIds.map(() => '?').join(', ');
722
+ const depRows = this.db.prepare(
723
+ `SELECT id, status FROM mesh_queue WHERE mesh_id = ? AND id IN (${placeholders})`
724
+ ).all(meshId, ...depIds) as Array<{ id: string; status: string }>;
725
+ for (const r of depRows) depStatus.set(r.id, r.status);
726
+ }
727
+ const dependenciesSatisfied = (candidate: MeshWorkQueueEntry): boolean => {
728
+ if (candidate.blockedReason) return false;
729
+ const deps = Array.isArray(candidate.dependsOn) ? candidate.dependsOn : [];
730
+ return deps.every(depId => depStatus.get(depId) === 'completed');
731
+ };
732
+
733
+ // Per-candidate node-conflict gate: write tasks (anything other than
734
+ // live_debug_readonly) require an idle node; read-only tasks bypass the
735
+ // node-busy check so N read-only diagnoses can run on one node at once.
736
+ const nodeConflictAllows = (candidate: MeshWorkQueueEntry): boolean => {
737
+ if (candidate.taskMode === 'live_debug_readonly') return true;
738
+ return !nodeBusy;
739
+ };
740
+
741
+ // WTDISPATCH-FANOUT: a `convergence` task lands its work onto base (merge →
742
+ // push → cleanup against the real checkout). It must NEVER be claimed by a
743
+ // co-located worktree-clone session — N sibling worktree sessions on one daemon
744
+ // each claiming the same convergence intent is the 4-way push/deploy fan-out the
745
+ // live repro hit. Base-only, fail-closed: when the claiming node is a worktree
746
+ // (nodeIsWorktree), exclude every convergence candidate so it stays pending for
747
+ // the base node to pull.
748
+ const nodeIsWorktree = opts?.nodeIsWorktree === true;
749
+ const convergenceAllows = (candidate: MeshWorkQueueEntry): boolean =>
750
+ candidate.taskMode !== 'convergence' || !nodeIsWorktree;
751
+
752
+ // WTDISPATCH-FANOUT: defensive exact-target gate. The prioritized SQL above
753
+ // already segregates session/node-pinned rows, but a future query change (or a
754
+ // candidate row whose stored target drifted from its column) must never let a
755
+ // sibling worktree session on the same daemon absorb another node's/session's
756
+ // pinned task. When a task carries an explicit target, require an exact match
757
+ // here too — fail-closed.
758
+ const targetMatches = (candidate: MeshWorkQueueEntry): boolean => {
759
+ if (candidate.targetSessionId && candidate.targetSessionId !== sessionId) return false;
760
+ if (candidate.targetNodeId && candidate.targetNodeId !== nodeId) return false;
761
+ return true;
762
+ };
763
+
764
+ const entry = candidates.find(candidate =>
765
+ nodeSatisfiesRequiredTags(candidate.requiredTags, capabilityTags)
766
+ && dependenciesSatisfied(candidate)
767
+ && convergenceAllows(candidate)
768
+ && targetMatches(candidate)
769
+ && nodeConflictAllows(candidate));
770
+ if (!entry) return null;
771
+
772
+ const now = new Date().toISOString();
773
+ entry.status = 'assigned';
774
+ entry.assignedNodeId = nodeId;
775
+ entry.assignedSessionId = sessionId;
776
+ if (providerType) entry.assignedProviderType = providerType;
777
+ entry.dispatchTimestamp = now;
778
+ entry.updatedAt = now;
779
+
780
+ this.db.prepare(`
781
+ UPDATE mesh_queue SET
782
+ status = 'assigned', assigned_node_id = ?, assigned_session_id = ?,
783
+ updated_at = ?, payload = ?
784
+ WHERE id = ? AND mesh_id = ?
785
+ `).run(nodeId, sessionId, now, JSON.stringify(entry), entry.id, meshId);
786
+
787
+ this.maybeCheckpointWal();
788
+ return entry;
789
+ });
790
+ }
791
+
792
+ getQueueStatsByStatus(meshId: string): { status: string; count: number }[] {
793
+ this.ensureLegacyQueueMigrated(meshId);
794
+ return this.db.prepare(
795
+ `SELECT status, COUNT(*) as count FROM mesh_queue WHERE mesh_id = ? GROUP BY status`
796
+ ).all(meshId) as { status: string; count: number }[];
797
+ }
798
+
799
+ getActiveAssignmentDetails(meshId: string): Array<{ id: string; nodeId?: string; sessionId?: string; message: string }> {
800
+ this.ensureLegacyQueueMigrated(meshId);
801
+ const rows = this.db.prepare(`
802
+ SELECT assigned_node_id, assigned_session_id, payload
803
+ FROM mesh_queue WHERE mesh_id = ? AND status = 'assigned'
804
+ `).all(meshId) as Array<{ assigned_node_id: string | null; assigned_session_id: string | null; payload: string }>;
805
+ return rows.map(r => {
806
+ let id = '', message = '';
807
+ try { const e = JSON.parse(r.payload) as MeshWorkQueueEntry; id = e.id; message = e.message; } catch { /* ignore */ }
808
+ return { id, nodeId: r.assigned_node_id ?? undefined, sessionId: r.assigned_session_id ?? undefined, message };
809
+ });
810
+ }
811
+
812
+ /**
813
+ * Resolve the `assigned` queue row a completion event belongs to.
814
+ *
815
+ * Clock-skew safety (C2): a completion event's `occurredAtIso` carries the
816
+ * REMOTE WORKER's clock, while `updated_at` carries the COORDINATOR's clock
817
+ * (set at assignment and re-bumped on every mutation). For a remote node,
818
+ * coordinator-clock > worker-clock skew used to make an `updated_at <= occurredAt`
819
+ * filter return nothing, stranding the finished task as `assigned` forever.
820
+ *
821
+ * We therefore NEVER filter completion-matching on the mutable `updated_at`:
822
+ * 1. If `taskId` is given, match the exact `assigned` row by id (no time filter).
823
+ * 2. Otherwise a session holds at most one `assigned` task — match it without a
824
+ * time filter. If several exist (shouldn't normally), disambiguate by the
825
+ * IMMUTABLE `dispatchTimestamp`: latest `dispatchTimestamp <= occurredAt`,
826
+ * and if skew makes ALL of them later than `occurredAt`, fall back to the
827
+ * most-recent `dispatchTimestamp` rather than returning null.
828
+ */
829
+ findAssignedBySession(
830
+ meshId: string,
831
+ sessionId: string,
832
+ occurredAtIso?: string,
833
+ taskId?: string,
834
+ ): MeshWorkQueueEntry | null {
835
+ this.ensureLegacyQueueMigrated(meshId);
836
+
837
+ // 1. Exact taskId match — robust against clock skew and stale rows.
838
+ if (taskId) {
839
+ const row = this.db.prepare(
840
+ `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' AND id = ? LIMIT 1`
841
+ ).get(meshId, sessionId, taskId) as { payload: string } | undefined;
842
+ if (row) return JSON.parse(row.payload) as MeshWorkQueueEntry;
843
+ // Fall through to session-based matching if the id didn't line up
844
+ // (e.g. event carried a stale/foreign taskId).
845
+ }
846
+
847
+ // 2. Session-based match WITHOUT the mutable updated_at filter.
848
+ const rows = this.db.prepare(
849
+ `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned'`
850
+ ).all(meshId, sessionId) as Array<{ payload: string }>;
851
+ if (rows.length === 0) return null;
852
+
853
+ const entries = rows
854
+ .map(r => { try { return JSON.parse(r.payload) as MeshWorkQueueEntry; } catch { return null; } })
855
+ .filter((e): e is MeshWorkQueueEntry => e !== null);
856
+ if (entries.length === 0) return null;
857
+ if (entries.length === 1) return entries[0];
858
+
859
+ // Multiple assigned rows for one session: disambiguate by the immutable
860
+ // dispatchTimestamp (falling back to updated_at only for legacy rows that
861
+ // predate dispatchTimestamp). We use these to ORDER, never to FILTER —
862
+ // so a skewed occurredAt can never drop the live row to null.
863
+ const orderKey = (e: MeshWorkQueueEntry) => e.dispatchTimestamp ?? e.updatedAt ?? '';
864
+ const byDispatchDesc = [...entries].sort((a, b) => orderKey(b).localeCompare(orderKey(a)));
865
+ if (occurredAtIso) {
866
+ const atOrBefore = byDispatchDesc.find(e => orderKey(e) <= occurredAtIso);
867
+ if (atOrBefore) return atOrBefore;
868
+ }
869
+ // Skew made every dispatch later than occurredAt — fall back to the
870
+ // most-recently dispatched row rather than stranding the completion.
871
+ return byDispatchDesc[0];
872
+ }
873
+
874
+ private toRow(entry: MeshWorkQueueEntry): Record<string, unknown> {
875
+ return {
876
+ id: entry.id,
877
+ meshId: entry.meshId,
878
+ status: entry.status,
879
+ targetNodeId: entry.targetNodeId ?? null,
880
+ targetSessionId: entry.targetSessionId ?? null,
881
+ assignedNodeId: entry.assignedNodeId ?? null,
882
+ assignedSessionId: entry.assignedSessionId ?? null,
883
+ createdAt: entry.createdAt,
884
+ updatedAt: entry.updatedAt,
885
+ payload: JSON.stringify(entry),
886
+ };
887
+ }
888
+
889
+ // ── Direct Dispatch Tracking ─────────────────────────────────────────────
890
+
891
+ insertDirectDispatch(entry: {
892
+ taskId: string;
893
+ meshId: string;
894
+ nodeId?: string;
895
+ sessionId?: string;
896
+ providerType?: string;
897
+ message: string;
898
+ taskMode?: string;
899
+ via: string;
900
+ dispatchedToIdleSession?: boolean;
901
+ dispatchedAt: string;
902
+ }): void {
903
+ const now = new Date().toISOString();
904
+ this.db.prepare(`
905
+ INSERT OR REPLACE INTO mesh_direct_dispatches
906
+ (task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
907
+ status, dispatched_to_idle_session, dispatched_at, updated_at)
908
+ VALUES
909
+ (@taskId, @meshId, @nodeId, @sessionId, @providerType, @message, @taskMode, @via,
910
+ 'dispatched', @dispatchedToIdle, @dispatchedAt, @updatedAt)
911
+ `).run({
912
+ taskId: entry.taskId,
913
+ meshId: entry.meshId,
914
+ nodeId: entry.nodeId ?? null,
915
+ sessionId: entry.sessionId ?? null,
916
+ providerType: entry.providerType ?? null,
917
+ message: entry.message,
918
+ taskMode: entry.taskMode ?? null,
919
+ via: entry.via,
920
+ dispatchedToIdle: entry.dispatchedToIdleSession ? 1 : 0,
921
+ dispatchedAt: entry.dispatchedAt,
922
+ updatedAt: now,
923
+ });
924
+ }
925
+
926
+ getActiveDirectDispatches(meshId: string): Array<{
927
+ taskId: string;
928
+ meshId: string;
929
+ nodeId: string | null;
930
+ sessionId: string | null;
931
+ providerType: string | null;
932
+ message: string;
933
+ taskMode: string | null;
934
+ via: string;
935
+ status: string;
936
+ dispatchedToIdleSession: boolean;
937
+ dispatchedAt: string;
938
+ updatedAt: string;
939
+ }> {
940
+ const rows = this.db.prepare(`
941
+ SELECT task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
942
+ status, dispatched_to_idle_session, dispatched_at, updated_at
943
+ FROM mesh_direct_dispatches
944
+ WHERE mesh_id = ? AND status NOT IN ('completed', 'failed', 'stale')
945
+ ORDER BY dispatched_at ASC
946
+ `).all(meshId) as Array<Record<string, unknown>>;
947
+ return rows.map(r => ({
948
+ taskId: r.task_id as string,
949
+ meshId: r.mesh_id as string,
950
+ nodeId: r.node_id as string | null,
951
+ sessionId: r.session_id as string | null,
952
+ providerType: r.provider_type as string | null,
953
+ message: r.message as string,
954
+ taskMode: r.task_mode as string | null,
955
+ via: r.via as string,
956
+ status: r.status as string,
957
+ dispatchedToIdleSession: (r.dispatched_to_idle_session as number) === 1,
958
+ dispatchedAt: r.dispatched_at as string,
959
+ updatedAt: r.updated_at as string,
960
+ }));
961
+ }
962
+
963
+ // CANON-B (dispatch identity): mesh_direct_dispatches is keyed by task_id (PK), but a
964
+ // single session can host several sequential direct dispatches (re-dispatch / nudge), so
965
+ // matching a status flip by session_id alone hits EVERY non-terminal row for that session
966
+ // — flipping a sibling task's row and stranding the one whose event actually fired (the
967
+ // assigned-stranded watchdog then requeues a task that is really still generating). When
968
+ // the firing event carries a taskId, target the single PK row; the session_id match is the
969
+ // legacy fallback only for events that arrive without a taskId.
970
+ updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void {
971
+ const now = new Date().toISOString();
972
+ if (taskId) {
973
+ this.db.prepare(`
974
+ UPDATE mesh_direct_dispatches
975
+ SET status = @status, updated_at = @updatedAt
976
+ WHERE mesh_id = @meshId AND task_id = @taskId
977
+ AND status NOT IN ('completed', 'failed')
978
+ `).run({ status, meshId, taskId, updatedAt: now });
979
+ return;
980
+ }
981
+ if (!sessionId) return; // never update rows without a session binding
982
+ this.db.prepare(`
983
+ UPDATE mesh_direct_dispatches
984
+ SET status = @status, updated_at = @updatedAt
985
+ WHERE mesh_id = @meshId AND session_id = @sessionId
986
+ AND session_id IS NOT NULL
987
+ AND status NOT IN ('completed', 'failed')
988
+ `).run({ status, meshId, sessionId, updatedAt: now });
989
+ }
990
+
991
+ /**
992
+ * MESH-DISPATCH-MISROUTE (fix 3, consumer residual): resolve the task_id of the SINGLE
993
+ * non-terminal direct dispatch a session owns. Returns the task_id only when the session
994
+ * holds exactly ONE active ('dispatched'/'acked') row — the case where a taskId-less
995
+ * lifecycle event (a legacy/relayed worker whose producer never stamped meshActiveTaskId)
996
+ * unambiguously belongs to that one dispatch. With zero rows there is nothing to ack; with
997
+ * two or more (a re-dispatch/nudge sibling) the firing event's owner is ambiguous, so we
998
+ * return null and the caller MUST NOT fall back to the session_id sweep that would flip a
999
+ * sibling row ("may flip a sibling dispatch row"). This narrows the legacy fallback to the
1000
+ * only safe case instead of removing the producer-side TASKIDLESS stamp's safety net.
1001
+ */
1002
+ getSoleActiveDirectDispatchTaskId(meshId: string, sessionId: string): string | null {
1003
+ if (!sessionId) return null;
1004
+ const rows = this.db.prepare(`
1005
+ SELECT task_id FROM mesh_direct_dispatches
1006
+ WHERE mesh_id = ? AND session_id = ?
1007
+ AND status NOT IN ('completed', 'failed', 'stale')
1008
+ `).all(meshId, sessionId) as Array<{ task_id: string }>;
1009
+ if (rows.length !== 1) return null;
1010
+ const taskId = typeof rows[0]?.task_id === 'string' ? rows[0].task_id.trim() : '';
1011
+ return taskId || null;
1012
+ }
1013
+
1014
+ cleanupTerminalDirectDispatches(olderThanMs: number): void {
1015
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
1016
+ this.db.prepare(`
1017
+ DELETE FROM mesh_direct_dispatches
1018
+ WHERE status IN ('completed', 'failed', 'stale') AND updated_at < ?
1019
+ `).run(cutoff);
1020
+ }
1021
+
1022
+ deleteDirectDispatches(meshId: string): void {
1023
+ this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ?`).run(meshId);
1024
+ }
1025
+
1026
+ /**
1027
+ * Delete specific direct dispatch rows by taskId for a mesh. Used by the staleDirect prune
1028
+ * path to remove orphaned/terminal dispatch records whose node/session is no longer in the
1029
+ * live mesh. Returns the number of rows actually deleted. No-op for an empty taskId list.
1030
+ */
1031
+ deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
1032
+ const ids = (taskIds || []).map(id => typeof id === 'string' ? id.trim() : '').filter(Boolean);
1033
+ if (!ids.length) return 0;
1034
+ const stmt = this.db.prepare(`DELETE FROM mesh_direct_dispatches WHERE mesh_id = ? AND task_id = ?`);
1035
+ let deleted = 0;
1036
+ const run = this.db.transaction((rows: string[]) => {
1037
+ for (const taskId of rows) {
1038
+ deleted += stmt.run(meshId, taskId).changes;
1039
+ }
1040
+ });
1041
+ run(ids);
1042
+ return deleted;
1043
+ }
1044
+
1045
+ markStaleDirectDispatches(meshId: string, olderThanMs: number): void {
1046
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
1047
+ const now = new Date().toISOString();
1048
+ this.db.prepare(`
1049
+ UPDATE mesh_direct_dispatches
1050
+ SET status = 'stale', updated_at = ?
1051
+ WHERE mesh_id = ? AND status = 'dispatched' AND dispatched_at < ?
1052
+ `).run(now, meshId, cutoff);
1053
+ }
1054
+
1055
+ // ── Remote Idle Sessions ─────────────────────────────────────────────────
1056
+
1057
+ setRemoteIdleSession(meshId: string, nodeId: string, sessionId: string, providerType: string, expiresAt: number, metadata?: any): void {
1058
+ this.db.prepare(`
1059
+ INSERT OR REPLACE INTO remote_idle_sessions (mesh_id, node_id, session_id, provider_type, expires_at, metadata)
1060
+ VALUES (?, ?, ?, ?, ?, ?)
1061
+ `).run(meshId, nodeId, sessionId, providerType, expiresAt, metadata ? JSON.stringify(metadata) : null);
1062
+ }
1063
+
1064
+ getRemoteIdleSessions(meshId: string): Array<{ nodeId: string; sessionId: string; providerType: string; expiresAt: number; metadata?: any }> {
1065
+ // MESH-ISOLATION-LEAK: always mesh-scoped. A bare (cross-mesh) read here is what
1066
+ // let mesh B claim mesh A's idle session when both share a nodeId.
1067
+ 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>;
1068
+ return rows.map(r => ({
1069
+ nodeId: r.node_id,
1070
+ sessionId: r.session_id,
1071
+ providerType: r.provider_type,
1072
+ expiresAt: r.expires_at,
1073
+ metadata: r.metadata ? JSON.parse(r.metadata) : undefined,
1074
+ }));
1075
+ }
1076
+
1077
+ deleteRemoteIdleSession(meshId: string, nodeId: string, sessionId: string): void {
1078
+ this.db.prepare('DELETE FROM remote_idle_sessions WHERE mesh_id = ? AND node_id = ? AND session_id = ?').run(meshId, nodeId, sessionId);
1079
+ }
1080
+
1081
+ pruneExpiredRemoteIdleSessions(): void {
1082
+ this.db.prepare('DELETE FROM remote_idle_sessions WHERE expires_at <= ?').run(Date.now());
1083
+ }
1084
+
1085
+ // ── Session Delivery Queue ───────────────────────────────────────────────
1086
+
1087
+ insertSessionDelivery(entry: {
1088
+ id: string;
1089
+ meshId: string;
1090
+ nodeId?: string;
1091
+ sessionId?: string;
1092
+ providerType?: string;
1093
+ taskId?: string;
1094
+ kind: string;
1095
+ priority?: number;
1096
+ message: string;
1097
+ status: string;
1098
+ deliverAfter?: string;
1099
+ expiresAt?: string;
1100
+ sourceCoordinatorSessionId?: string;
1101
+ sourceCoordinatorDaemonId?: string;
1102
+ createdAt: string;
1103
+ updatedAt: string;
1104
+ }): void {
1105
+ this.db.prepare(`
1106
+ INSERT OR REPLACE INTO mesh_session_delivery (
1107
+ id, mesh_id, node_id, session_id, provider_type, task_id, kind, priority,
1108
+ message, status, deliver_after, expires_at, attempt_count,
1109
+ source_coordinator_session_id, source_coordinator_daemon_id,
1110
+ last_error, created_at, updated_at
1111
+ ) VALUES (
1112
+ @id, @meshId, @nodeId, @sessionId, @providerType, @taskId, @kind, @priority,
1113
+ @message, @status, @deliverAfter, @expiresAt, 0,
1114
+ @sourceCoordinatorSessionId, @sourceCoordinatorDaemonId,
1115
+ NULL, @createdAt, @updatedAt
1116
+ )
1117
+ `).run({
1118
+ id: entry.id,
1119
+ meshId: entry.meshId,
1120
+ nodeId: entry.nodeId ?? null,
1121
+ sessionId: entry.sessionId ?? null,
1122
+ providerType: entry.providerType ?? null,
1123
+ taskId: entry.taskId ?? null,
1124
+ kind: entry.kind,
1125
+ priority: entry.priority ?? 0,
1126
+ message: entry.message,
1127
+ status: entry.status,
1128
+ deliverAfter: entry.deliverAfter ?? null,
1129
+ expiresAt: entry.expiresAt ?? null,
1130
+ sourceCoordinatorSessionId: entry.sourceCoordinatorSessionId ?? null,
1131
+ sourceCoordinatorDaemonId: entry.sourceCoordinatorDaemonId ?? null,
1132
+ createdAt: entry.createdAt,
1133
+ updatedAt: entry.updatedAt,
1134
+ });
1135
+ this.maybeCheckpointWal();
1136
+ }
1137
+
1138
+ updateSessionDeliveryStatus(id: string, status: string, opts?: { lastError?: string; incrementAttempt?: boolean }): void {
1139
+ const now = new Date().toISOString();
1140
+ if (opts?.incrementAttempt) {
1141
+ this.db.prepare(`
1142
+ UPDATE mesh_session_delivery
1143
+ SET status = @status, last_error = @lastError, attempt_count = attempt_count + 1, updated_at = @updatedAt
1144
+ WHERE id = @id
1145
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
1146
+ } else {
1147
+ this.db.prepare(`
1148
+ UPDATE mesh_session_delivery
1149
+ SET status = @status, last_error = @lastError, updated_at = @updatedAt
1150
+ WHERE id = @id
1151
+ `).run({ id, status, lastError: opts?.lastError ?? null, updatedAt: now });
1152
+ }
1153
+ }
1154
+
1155
+ getActiveSessionDeliveries(meshId: string, sessionId?: string): Array<{
1156
+ id: string; meshId: string; nodeId: string | null; sessionId: string | null;
1157
+ providerType: string | null; taskId: string | null; kind: string; priority: number;
1158
+ message: string; status: string; deliverAfter: string | null; expiresAt: string | null;
1159
+ attemptCount: number; sourceCoordinatorSessionId: string | null;
1160
+ sourceCoordinatorDaemonId: string | null; lastError: string | null;
1161
+ createdAt: string; updatedAt: string;
1162
+ }> {
1163
+ const now = new Date().toISOString();
1164
+ const sql = sessionId
1165
+ ? `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`
1166
+ : `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`;
1167
+ const rows = sessionId
1168
+ ? this.db.prepare(sql).all(meshId, sessionId, now) as Array<Record<string, unknown>>
1169
+ : this.db.prepare(sql).all(meshId, now) as Array<Record<string, unknown>>;
1170
+ return rows.map(r => ({
1171
+ id: r.id as string,
1172
+ meshId: r.mesh_id as string,
1173
+ nodeId: r.node_id as string | null,
1174
+ sessionId: r.session_id as string | null,
1175
+ providerType: r.provider_type as string | null,
1176
+ taskId: r.task_id as string | null,
1177
+ kind: r.kind as string,
1178
+ priority: r.priority as number,
1179
+ message: r.message as string,
1180
+ status: r.status as string,
1181
+ deliverAfter: r.deliver_after as string | null,
1182
+ expiresAt: r.expires_at as string | null,
1183
+ attemptCount: r.attempt_count as number,
1184
+ sourceCoordinatorSessionId: r.source_coordinator_session_id as string | null,
1185
+ sourceCoordinatorDaemonId: r.source_coordinator_daemon_id as string | null,
1186
+ lastError: r.last_error as string | null,
1187
+ createdAt: r.created_at as string,
1188
+ updatedAt: r.updated_at as string,
1189
+ }));
1190
+ }
1191
+
1192
+ /**
1193
+ * Bug B watchdog support: true when at least one delivery record for the task has
1194
+ * reached a confirmed-handed-off status (delivered / acked / completed). The
1195
+ * assigned-stranded watchdog uses this to distinguish a dispatch that was never
1196
+ * confirmed (reclaimable) from one that WAS handed to the worker (a genuinely
1197
+ * in-flight or completion-lost task, which is PHASE 4's responsibility, not this
1198
+ * watchdog's). Indexed by (mesh_id, task_id).
1199
+ */
1200
+ taskHasConfirmedDelivery(meshId: string, taskId: string): boolean {
1201
+ const row = this.db.prepare(`
1202
+ SELECT 1 FROM mesh_session_delivery
1203
+ WHERE mesh_id = ? AND task_id = ? AND status IN ('delivered','acked','completed')
1204
+ LIMIT 1
1205
+ `).get(meshId, taskId) as { 1: number } | undefined;
1206
+ return !!row;
1207
+ }
1208
+
1209
+ expireStaleSessionDeliveries(meshId: string): void {
1210
+ const now = new Date().toISOString();
1211
+ this.db.prepare(`
1212
+ UPDATE mesh_session_delivery
1213
+ SET status = 'expired', updated_at = ?
1214
+ WHERE mesh_id = ? AND expires_at IS NOT NULL AND expires_at <= ?
1215
+ AND status NOT IN ('delivered','completed','failed','expired','cancelled')
1216
+ `).run(now, meshId, now);
1217
+ }
1218
+
1219
+ deleteSessionDeliveries(meshId: string): void {
1220
+ this.db.prepare('DELETE FROM mesh_session_delivery WHERE mesh_id = ?').run(meshId);
1221
+ }
1222
+
1223
+ // ── Completion Conflict Diagnostics ──────────────────────────────────────
1224
+
1225
+ recordCompletionConflict(entry: {
1226
+ id: string;
1227
+ meshId: string;
1228
+ fingerprint: string;
1229
+ conflictingTaskId?: string;
1230
+ conflictingSessionId?: string;
1231
+ originalTaskId?: string;
1232
+ originalSessionId?: string;
1233
+ event: string;
1234
+ createdAt: string;
1235
+ }): void {
1236
+ this.db.prepare(`
1237
+ INSERT OR IGNORE INTO mesh_completion_conflicts
1238
+ (id, mesh_id, fingerprint, conflicting_task_id, conflicting_session_id,
1239
+ original_task_id, original_session_id, event, created_at)
1240
+ VALUES (@id, @meshId, @fingerprint, @conflictingTaskId, @conflictingSessionId,
1241
+ @originalTaskId, @originalSessionId, @event, @createdAt)
1242
+ `).run({
1243
+ id: entry.id,
1244
+ meshId: entry.meshId,
1245
+ fingerprint: entry.fingerprint,
1246
+ conflictingTaskId: entry.conflictingTaskId ?? null,
1247
+ conflictingSessionId: entry.conflictingSessionId ?? null,
1248
+ originalTaskId: entry.originalTaskId ?? null,
1249
+ originalSessionId: entry.originalSessionId ?? null,
1250
+ event: entry.event,
1251
+ createdAt: entry.createdAt,
1252
+ });
1253
+ this.maybeCheckpointWal();
1254
+ }
1255
+
1256
+ getRecentCompletionConflicts(meshId: string, limitMs: number = 60 * 60 * 1000): Array<{
1257
+ id: string; meshId: string; fingerprint: string; conflictingTaskId: string | null;
1258
+ conflictingSessionId: string | null; originalTaskId: string | null;
1259
+ originalSessionId: string | null; event: string; createdAt: string;
1260
+ }> {
1261
+ const cutoff = new Date(Date.now() - limitMs).toISOString();
1262
+ const rows = this.db.prepare(
1263
+ 'SELECT * FROM mesh_completion_conflicts WHERE mesh_id = ? AND created_at >= ? ORDER BY created_at DESC LIMIT 50'
1264
+ ).all(meshId, cutoff) as Array<Record<string, unknown>>;
1265
+ return rows.map(r => ({
1266
+ id: r.id as string,
1267
+ meshId: r.mesh_id as string,
1268
+ fingerprint: r.fingerprint as string,
1269
+ conflictingTaskId: r.conflicting_task_id as string | null,
1270
+ conflictingSessionId: r.conflicting_session_id as string | null,
1271
+ originalTaskId: r.original_task_id as string | null,
1272
+ originalSessionId: r.original_session_id as string | null,
1273
+ event: r.event as string,
1274
+ createdAt: r.created_at as string,
1275
+ }));
1276
+ }
1277
+
1278
+ /**
1279
+ * Record a mesh tool call and check whether this mesh+tool combination is
1280
+ * being called too rapidly (sliding window rate guard).
1281
+ *
1282
+ * Returns a rate-limit advisory string when the call rate is too high, null otherwise.
1283
+ * windowMs: sliding window size in ms (default 10s)
1284
+ * maxCalls: max allowed calls within the window (default 5)
1285
+ */
1286
+ recordMeshToolCall(opts: {
1287
+ meshId: string;
1288
+ tool: string;
1289
+ sessionId?: string | null;
1290
+ windowMs?: number;
1291
+ maxCalls?: number;
1292
+ }): { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null } {
1293
+ const { meshId, tool, sessionId = null } = opts;
1294
+ const windowMs = opts.windowMs ?? 10_000;
1295
+ const maxCalls = opts.maxCalls ?? 5;
1296
+ const now = Date.now();
1297
+ const windowStart = now - windowMs;
1298
+
1299
+ this.db.prepare(
1300
+ 'INSERT INTO mesh_tool_call_log (mesh_id, tool, session_id, called_at) VALUES (?, ?, ?, ?)'
1301
+ ).run(meshId, tool, sessionId, now);
1302
+
1303
+ const row = this.db.prepare(
1304
+ 'SELECT COUNT(*) as cnt FROM mesh_tool_call_log WHERE mesh_id = ? AND tool = ? AND called_at >= ?'
1305
+ ).get(meshId, tool, windowStart) as { cnt: number };
1306
+ const callsInWindow = row?.cnt ?? 0;
1307
+
1308
+ // Sweep old entries periodically to keep the table lean (every 200 calls across all tools).
1309
+ if (++this.toolCallLogCounter % 200 === 0) {
1310
+ this.db.prepare(
1311
+ 'DELETE FROM mesh_tool_call_log WHERE called_at < ?'
1312
+ ).run(now - Math.max(windowMs * 10, 60_000));
1313
+ }
1314
+
1315
+ if (callsInWindow > maxCalls) {
1316
+ const advisory = `Rate limit: ${tool} called ${callsInWindow} times in the last ${windowMs / 1000}s for mesh ${meshId}. `
1317
+ + `Wait for pendingCoordinatorEvents or an explicit user status request before calling again.`;
1318
+ return { rateLimitExceeded: true, callsInWindow, advisory };
1319
+ }
1320
+ return { rateLimitExceeded: false, callsInWindow, advisory: null };
1321
+ }
1322
+
1323
+ /**
1324
+ * Prune tool call log entries older than the given age in ms.
1325
+ * Exposed for testing.
1326
+ */
1327
+ pruneToolCallLog(olderThanMs: number): void {
1328
+ this.db.prepare('DELETE FROM mesh_tool_call_log WHERE called_at < ?').run(Date.now() - olderThanMs);
1329
+ }
1330
+
1331
+ // ── G2: Event Ledger ────────────────────────────────────────────────────
1332
+
1333
+ appendLedgerEntry(entry: {
1334
+ id: string;
1335
+ meshId: string;
1336
+ timestamp: string;
1337
+ kind: string;
1338
+ nodeId?: string | null;
1339
+ sessionId?: string | null;
1340
+ providerType?: string | null;
1341
+ payload?: unknown;
1342
+ }): void {
1343
+ this.db.prepare(
1344
+ `INSERT OR IGNORE INTO mesh_event_ledger
1345
+ (id, mesh_id, timestamp, kind, node_id, session_id, provider_type, payload)
1346
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1347
+ ).run(
1348
+ entry.id,
1349
+ entry.meshId,
1350
+ entry.timestamp,
1351
+ entry.kind,
1352
+ entry.nodeId ?? null,
1353
+ entry.sessionId ?? null,
1354
+ entry.providerType ?? null,
1355
+ JSON.stringify(entry.payload ?? {}),
1356
+ );
1357
+ this.maybeCheckpointWal();
1358
+ }
1359
+
1360
+ readLedgerEntries(meshId: string, opts?: {
1361
+ tail?: number;
1362
+ since?: string;
1363
+ kind?: string;
1364
+ limit?: number;
1365
+ }): Array<{ id: string; meshId: string; timestamp: string; kind: string; nodeId: string | null; sessionId: string | null; providerType: string | null; payload: unknown }> {
1366
+ const limit = opts?.tail ?? opts?.limit ?? 200;
1367
+ let query: string;
1368
+ const params: unknown[] = [meshId];
1369
+ if (opts?.kind && opts?.since) {
1370
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? AND kind = ? AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?`;
1371
+ params.push(opts.kind, opts.since, limit);
1372
+ } else if (opts?.kind) {
1373
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? AND kind = ? ORDER BY timestamp DESC LIMIT ?`;
1374
+ params.push(opts.kind, limit);
1375
+ } else if (opts?.since) {
1376
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? AND timestamp >= ? ORDER BY timestamp DESC LIMIT ?`;
1377
+ params.push(opts.since, limit);
1378
+ } else {
1379
+ query = `SELECT * FROM mesh_event_ledger WHERE mesh_id = ? ORDER BY timestamp DESC LIMIT ?`;
1380
+ params.push(limit);
1381
+ }
1382
+ const rows = this.db.prepare(query).all(...params) as Array<Record<string, unknown>>;
1383
+ return rows.map(r => ({
1384
+ id: r.id as string,
1385
+ meshId: r.mesh_id as string,
1386
+ timestamp: r.timestamp as string,
1387
+ kind: r.kind as string,
1388
+ nodeId: r.node_id as string | null,
1389
+ sessionId: r.session_id as string | null,
1390
+ providerType: r.provider_type as string | null,
1391
+ payload: (() => { try { return JSON.parse(r.payload as string); } catch { return {}; } })(),
1392
+ }));
1393
+ }
1394
+
1395
+ /**
1396
+ * G2 read cutover: read ledger entries in append order (oldest first),
1397
+ * matching legacy JSONL file-order semantics. Ties on the same timestamp
1398
+ * are broken by rowid (insertion order), preserving the positional
1399
+ * guarantee that mesh-events relies on for same-millisecond entries.
1400
+ */
1401
+ readLedgerEntriesOrdered(meshId: string, opts?: {
1402
+ since?: string;
1403
+ kinds?: string[];
1404
+ tail?: number;
1405
+ }): Array<{ id: string; meshId: string; timestamp: string; kind: string; nodeId: string | null; sessionId: string | null; providerType: string | null; payload: unknown }> {
1406
+ const params: unknown[] = [meshId];
1407
+ let whereClause = 'mesh_id = ?';
1408
+ if (opts?.since) {
1409
+ whereClause += ' AND timestamp >= ?';
1410
+ params.push(opts.since);
1411
+ }
1412
+ const kinds = Array.isArray(opts?.kinds) ? opts.kinds.filter(k => typeof k === 'string' && k.trim()) : [];
1413
+ if (kinds.length > 0) {
1414
+ whereClause += ` AND kind IN (${kinds.map(() => '?').join(', ')})`;
1415
+ params.push(...kinds);
1416
+ }
1417
+ let query: string;
1418
+ if (opts?.tail && opts.tail > 0) {
1419
+ // Tail: newest N in append order — inner DESC limit, outer re-sort ASC.
1420
+ query = `SELECT * FROM (
1421
+ SELECT rowid AS rid, * FROM mesh_event_ledger WHERE ${whereClause}
1422
+ ORDER BY timestamp DESC, rowid DESC LIMIT ?
1423
+ ) ORDER BY timestamp ASC, rid ASC`;
1424
+ params.push(Math.floor(opts.tail));
1425
+ } else {
1426
+ query = `SELECT rowid AS rid, * FROM mesh_event_ledger WHERE ${whereClause} ORDER BY timestamp ASC, rowid ASC`;
1427
+ }
1428
+ const rows = this.db.prepare(query).all(...params) as Array<Record<string, unknown>>;
1429
+ return rows.map(r => ({
1430
+ id: r.id as string,
1431
+ meshId: r.mesh_id as string,
1432
+ timestamp: r.timestamp as string,
1433
+ kind: r.kind as string,
1434
+ nodeId: r.node_id as string | null,
1435
+ sessionId: r.session_id as string | null,
1436
+ providerType: r.provider_type as string | null,
1437
+ payload: (() => { try { return JSON.parse(r.payload as string); } catch { return {}; } })(),
1438
+ }));
1439
+ }
1440
+
1441
+ /** Remove all ledger entries for a mesh (mesh deletion / test cleanup). */
1442
+ clearLedgerForMesh(meshId: string): number {
1443
+ return this.db.prepare('DELETE FROM mesh_event_ledger WHERE mesh_id = ?').run(meshId).changes;
1444
+ }
1445
+
1446
+ /** G2: remove entries moved to the JSONL archive so the SQLite runtime set mirrors the active ledger. */
1447
+ deleteLedgerEntries(meshId: string, ids: string[]): number {
1448
+ if (!ids.length) return 0;
1449
+ let deleted = 0;
1450
+ const stmt = this.db.prepare('DELETE FROM mesh_event_ledger WHERE mesh_id = ? AND id = ?');
1451
+ this.db.transaction(() => {
1452
+ for (const id of ids) {
1453
+ deleted += stmt.run(meshId, id).changes;
1454
+ }
1455
+ })();
1456
+ return deleted;
1457
+ }
1458
+
1459
+ hasLedgerEntry(meshId: string, id: string): boolean {
1460
+ const row = this.db.prepare(
1461
+ 'SELECT 1 FROM mesh_event_ledger WHERE mesh_id = ? AND id = ? LIMIT 1'
1462
+ ).get(meshId, id);
1463
+ return row !== undefined;
1464
+ }
1465
+
1466
+ ledgerEntryCount(meshId: string): number {
1467
+ const row = this.db.prepare(
1468
+ 'SELECT COUNT(*) as cnt FROM mesh_event_ledger WHERE mesh_id = ?'
1469
+ ).get(meshId) as { cnt: number } | undefined;
1470
+ return row?.cnt ?? 0;
1471
+ }
1472
+
1473
+ importLedgerEntries(entries: Array<{
1474
+ id: string; meshId: string; timestamp: string; kind: string;
1475
+ nodeId?: string | null; sessionId?: string | null; providerType?: string | null; payload?: unknown;
1476
+ }>): number {
1477
+ let imported = 0;
1478
+ const stmt = this.db.prepare(
1479
+ `INSERT OR IGNORE INTO mesh_event_ledger
1480
+ (id, mesh_id, timestamp, kind, node_id, session_id, provider_type, payload)
1481
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
1482
+ );
1483
+ this.db.transaction(() => {
1484
+ for (const e of entries) {
1485
+ const result = stmt.run(
1486
+ e.id, e.meshId, e.timestamp, e.kind,
1487
+ e.nodeId ?? null, e.sessionId ?? null, e.providerType ?? null,
1488
+ JSON.stringify(e.payload ?? {}),
1489
+ );
1490
+ if (result.changes > 0) imported++;
1491
+ }
1492
+ })();
1493
+ return imported;
1494
+ }
1495
+
1496
+ /**
1497
+ * G4: Read a bounded, cursor-addressable ledger slice directly from the SQLite
1498
+ * mesh_event_ledger table. This is the P2P reconcile read path; JSONL files are
1499
+ * retained as export/import/debug/legacy artifacts only.
1500
+ *
1501
+ * The return shape is structurally compatible with MeshLedgerSlice so callers
1502
+ * in mesh-tools.ts can pass it directly to buildMeshLedgerReplicaEvidence.
1503
+ */
1504
+ readLedgerSlice(meshId: string, opts?: {
1505
+ afterId?: string;
1506
+ since?: string;
1507
+ kind?: string;
1508
+ limit?: number;
1509
+ }): {
1510
+ protocol: 'adhdev.mesh.ledger.slice.v1';
1511
+ meshId: string;
1512
+ entries: Array<{ id: string; meshId: string; timestamp: string; kind: string; nodeId: string | null; sessionId: string | null; providerType: string | null; payload: unknown }>;
1513
+ cursor: { afterId: string | null; nextAfterId: string | null; limit: number; hasMore: boolean };
1514
+ sourceOfTruth: { kind: 'local_sqlite'; table: 'mesh_event_ledger'; bounded: true; maxLimit: number };
1515
+ } {
1516
+ // Protocol maximum of 500, default 100 — mirrors mesh-ledger.ts constants.
1517
+ const MAX_LIMIT = 500;
1518
+ const DEFAULT_LIMIT = 100;
1519
+ const limit = (typeof opts?.limit === 'number' && Number.isFinite(opts.limit))
1520
+ ? Math.max(1, Math.min(MAX_LIMIT, Math.floor(opts.limit)))
1521
+ : DEFAULT_LIMIT;
1522
+
1523
+ const afterId = typeof opts?.afterId === 'string' && opts.afterId.trim() ? opts.afterId.trim() : null;
1524
+
1525
+ // Build query: fetch limit+1 rows so we can detect hasMore without a COUNT(*).
1526
+ const params: unknown[] = [meshId];
1527
+ let whereClause = 'mesh_id = ?';
1528
+
1529
+ if (opts?.kind) {
1530
+ whereClause += ' AND kind = ?';
1531
+ params.push(opts.kind);
1532
+ }
1533
+ if (opts?.since) {
1534
+ whereClause += ' AND timestamp >= ?';
1535
+ params.push(opts.since);
1536
+ }
1537
+ if (afterId) {
1538
+ // afterId: return entries with timestamp strictly after the referenced entry's timestamp,
1539
+ // or with the same timestamp but id > afterId (stable pagination).
1540
+ 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 > ?))`;
1541
+ params.push(afterId, meshId, afterId, meshId, afterId);
1542
+ }
1543
+
1544
+ // Fetch limit+1 to detect hasMore
1545
+ const query = `SELECT * FROM mesh_event_ledger WHERE ${whereClause} ORDER BY timestamp ASC, id ASC LIMIT ?`;
1546
+ params.push(limit + 1);
1547
+
1548
+ const rows = this.db.prepare(query).all(...params) as Array<Record<string, unknown>>;
1549
+ const hasMore = rows.length > limit;
1550
+ const bounded = hasMore ? rows.slice(0, limit) : rows;
1551
+
1552
+ const entries = bounded.map(r => ({
1553
+ id: r.id as string,
1554
+ meshId: r.mesh_id as string,
1555
+ timestamp: r.timestamp as string,
1556
+ kind: r.kind as string,
1557
+ nodeId: r.node_id as string | null,
1558
+ sessionId: r.session_id as string | null,
1559
+ providerType: r.provider_type as string | null,
1560
+ payload: (() => { try { return JSON.parse(r.payload as string); } catch { return {}; } })(),
1561
+ }));
1562
+
1563
+ return {
1564
+ protocol: 'adhdev.mesh.ledger.slice.v1',
1565
+ meshId,
1566
+ entries,
1567
+ cursor: {
1568
+ afterId,
1569
+ nextAfterId: entries.length ? entries[entries.length - 1].id : afterId,
1570
+ limit,
1571
+ hasMore,
1572
+ },
1573
+ sourceOfTruth: {
1574
+ kind: 'local_sqlite',
1575
+ table: 'mesh_event_ledger',
1576
+ bounded: true,
1577
+ maxLimit: MAX_LIMIT,
1578
+ },
1579
+ };
1580
+ }
1581
+
1582
+ // ── G3: Pending Coordinator Events ──────────────────────────────────────
1583
+
1584
+ insertPendingEvent(event: {
1585
+ id: string;
1586
+ meshId: string;
1587
+ coordinatorDaemonId?: string | null;
1588
+ event: string;
1589
+ payload?: unknown;
1590
+ fingerprint?: string | null;
1591
+ queuedAt: number;
1592
+ }): boolean {
1593
+ const result = this.db.prepare(
1594
+ `INSERT OR IGNORE INTO mesh_pending_events
1595
+ (id, mesh_id, coordinator_daemon_id, event, payload, fingerprint, queued_at)
1596
+ VALUES (?, ?, ?, ?, ?, ?, ?)`
1597
+ ).run(
1598
+ event.id,
1599
+ event.meshId,
1600
+ event.coordinatorDaemonId ?? null,
1601
+ event.event,
1602
+ JSON.stringify(event.payload ?? {}),
1603
+ event.fingerprint ?? null,
1604
+ event.queuedAt,
1605
+ );
1606
+ this.maybeCheckpointWal();
1607
+ return result.changes > 0;
1608
+ }
1609
+
1610
+ /**
1611
+ * Drain undrained pending events for a mesh, atomically marking them drained.
1612
+ * When `opts.onlyEvents` is supplied, ONLY rows whose `event` is in that set are
1613
+ * drained — the rest stay queued (drained=0) for a later drain. This is how the
1614
+ * reconcile loop force-drains terminal/force-inject events into a *generating*
1615
+ * coordinator while leaving non-force progress events for the coordinator's next
1616
+ * idle transition. Filtering happens inside the same transaction as the
1617
+ * drained=1 marking, so force-drain + a concurrent full drain can never both
1618
+ * consume the same row.
1619
+ */
1620
+ drainPendingEvents(
1621
+ meshId: string,
1622
+ coordinatorDaemonId?: string | null | ReadonlyArray<string>,
1623
+ opts?: { onlyEvents?: ReadonlySet<string> },
1624
+ ): Array<{ id: string; event: string; payload: unknown }> {
1625
+ return this.transaction(() => {
1626
+ const onlyEvents = opts?.onlyEvents;
1627
+ // An explicit-but-empty filter means "drain nothing" (no event name can match).
1628
+ if (onlyEvents && onlyEvents.size === 0) return [];
1629
+ const eventList = onlyEvents ? [...onlyEvents] : [];
1630
+ // A coordinator daemon can answer to more than one id form: its canonical
1631
+ // status id (e.g. `standalone_<machineId>` / `daemon_<machineId>`, which the
1632
+ // MCP layer stamps via ctx.localDaemonId) AND the bare machineId (stamped by
1633
+ // the local queue-assignment path). Accept ANY of them so a unicast event
1634
+ // stamped with either id is drained here. Unscoped (NULL) rows always match.
1635
+ const daemonIds = (Array.isArray(coordinatorDaemonId)
1636
+ ? coordinatorDaemonId
1637
+ : coordinatorDaemonId ? [coordinatorDaemonId] : [])
1638
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
1639
+ // Filter by event name IN-SQL when onlyEvents is set so the LIMIT applies to
1640
+ // matching rows — a long run of non-force events ahead in the queue must not
1641
+ // crowd a force event out of the 100-row window.
1642
+ const clauses = ['mesh_id = ?', 'drained = 0'];
1643
+ const params: unknown[] = [meshId];
1644
+ if (daemonIds.length > 0) {
1645
+ clauses.push(`(coordinator_daemon_id IS NULL OR coordinator_daemon_id IN (${daemonIds.map(() => '?').join(',')}))`);
1646
+ params.push(...daemonIds);
1647
+ }
1648
+ if (eventList.length > 0) {
1649
+ clauses.push(`event IN (${eventList.map(() => '?').join(',')})`);
1650
+ params.push(...eventList);
1651
+ }
1652
+ const rows = this.db.prepare(
1653
+ `SELECT id, event, payload FROM mesh_pending_events WHERE ${clauses.join(' AND ')} ORDER BY queued_at ASC LIMIT 100`
1654
+ ).all(...params) as Array<{ id: string; event: string; payload: string }>;
1655
+ if (rows.length === 0) return [];
1656
+ const ids = rows.map(r => r.id);
1657
+ const now = Date.now();
1658
+ this.db.prepare(
1659
+ `UPDATE mesh_pending_events SET drained = 1, drained_at = ? WHERE id IN (${ids.map(() => '?').join(',')})`
1660
+ ).run(now, ...ids);
1661
+ return rows.map(r => ({
1662
+ id: r.id,
1663
+ event: r.event,
1664
+ payload: (() => { try { return JSON.parse(r.payload); } catch { return {}; } })(),
1665
+ }));
1666
+ });
1667
+ }
1668
+
1669
+ /** Non-destructive peek — returns undrained events without marking them drained. */
1670
+ peekPendingEvents(meshId: string, coordinatorDaemonId?: string | null | ReadonlyArray<string>): Array<{ id: string; event: string; payload: unknown }> {
1671
+ const daemonIds = (Array.isArray(coordinatorDaemonId)
1672
+ ? coordinatorDaemonId
1673
+ : coordinatorDaemonId ? [coordinatorDaemonId] : [])
1674
+ .filter((id): id is string => typeof id === 'string' && id.length > 0);
1675
+ const whereClause = daemonIds.length > 0
1676
+ ? `WHERE mesh_id = ? AND drained = 0 AND (coordinator_daemon_id IS NULL OR coordinator_daemon_id IN (${daemonIds.map(() => '?').join(',')}))`
1677
+ : `WHERE mesh_id = ? AND drained = 0`;
1678
+ const params: unknown[] = daemonIds.length > 0 ? [meshId, ...daemonIds] : [meshId];
1679
+ const rows = this.db.prepare(
1680
+ `SELECT id, event, payload FROM mesh_pending_events ${whereClause} ORDER BY queued_at ASC LIMIT 100`
1681
+ ).all(...params) as Array<{ id: string; event: string; payload: string }>;
1682
+ return rows.map(r => ({
1683
+ id: r.id,
1684
+ event: r.event,
1685
+ payload: (() => { try { return JSON.parse(r.payload); } catch { return {}; } })(),
1686
+ }));
1687
+ }
1688
+
1689
+ hasPendingEventFingerprint(meshId: string, fingerprint: string): boolean {
1690
+ const row = this.db.prepare(
1691
+ 'SELECT 1 FROM mesh_pending_events WHERE mesh_id = ? AND fingerprint = ? AND drained = 0 LIMIT 1'
1692
+ ).get(meshId, fingerprint);
1693
+ return row !== undefined;
1694
+ }
1695
+
1696
+ // ── M3: Mission Records ─────────────────────────────────────────────────
1697
+
1698
+ upsertMission(mission: {
1699
+ id: string;
1700
+ meshId: string;
1701
+ title: string;
1702
+ goal?: string;
1703
+ status?: string;
1704
+ }): void {
1705
+ const now = new Date().toISOString();
1706
+ this.db.prepare(
1707
+ `INSERT INTO mesh_missions (id, mesh_id, title, goal, status, created_at, updated_at)
1708
+ VALUES (?, ?, ?, ?, ?, ?, ?)
1709
+ ON CONFLICT(id) DO UPDATE SET
1710
+ title = excluded.title,
1711
+ goal = excluded.goal,
1712
+ status = excluded.status,
1713
+ updated_at = excluded.updated_at`
1714
+ ).run(
1715
+ mission.id,
1716
+ mission.meshId,
1717
+ mission.title,
1718
+ mission.goal ?? '',
1719
+ mission.status ?? 'active',
1720
+ now,
1721
+ now,
1722
+ );
1723
+ this.maybeCheckpointWal();
1724
+ }
1725
+
1726
+ getMission(meshId: string, missionId: string): { id: string; meshId: string; title: string; goal: string; status: string; createdAt: string; updatedAt: string } | null {
1727
+ const row = this.db.prepare(
1728
+ 'SELECT * FROM mesh_missions WHERE mesh_id = ? AND id = ?'
1729
+ ).get(meshId, missionId) as Record<string, string> | undefined;
1730
+ if (!row) return null;
1731
+ return { id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at };
1732
+ }
1733
+
1734
+ getMissions(meshId: string, statuses?: string[]): Array<{ id: string; meshId: string; title: string; goal: string; status: string; createdAt: string; updatedAt: string }> {
1735
+ let rows: Array<Record<string, string>>;
1736
+ if (statuses?.length) {
1737
+ const placeholders = statuses.map(() => '?').join(', ');
1738
+ rows = this.db.prepare(
1739
+ `SELECT * FROM mesh_missions WHERE mesh_id = ? AND status IN (${placeholders}) ORDER BY updated_at DESC`
1740
+ ).all(meshId, ...statuses) as Array<Record<string, string>>;
1741
+ } else {
1742
+ rows = this.db.prepare(
1743
+ 'SELECT * FROM mesh_missions WHERE mesh_id = ? ORDER BY updated_at DESC'
1744
+ ).all(meshId) as Array<Record<string, string>>;
1745
+ }
1746
+ return rows.map(row => ({ id: row.id, meshId: row.mesh_id, title: row.title, goal: row.goal, status: row.status, createdAt: row.created_at, updatedAt: row.updated_at }));
1747
+ }
1748
+
1749
+ /** Remove all missions for a mesh — mesh deletion / test cleanup. */
1750
+ clearMissionsForMesh(meshId: string): number {
1751
+ return this.db.prepare('DELETE FROM mesh_missions WHERE mesh_id = ?').run(meshId).changes;
1752
+ }
1753
+
1754
+ /** Remove all pending-event rows (drained included) for a mesh — mesh deletion / test cleanup. */
1755
+ clearPendingEventsForMesh(meshId: string): number {
1756
+ return this.db.prepare('DELETE FROM mesh_pending_events WHERE mesh_id = ?').run(meshId).changes;
1757
+ }
1758
+
1759
+ pendingEventCount(meshId: string): number {
1760
+ const row = this.db.prepare(
1761
+ 'SELECT COUNT(*) as cnt FROM mesh_pending_events WHERE mesh_id = ? AND drained = 0'
1762
+ ).get(meshId) as { cnt: number } | undefined;
1763
+ return row?.cnt ?? 0;
1764
+ }
1765
+
1766
+ /**
1767
+ * Mark specific pending-event rows drained by id (ack). Used by the
1768
+ * unresolved-delegate durable-forward outbox: an event is peeked (not drained)
1769
+ * while its push to the coordinator is unconfirmed, then marked drained ONLY
1770
+ * after the push is acked. A failed push leaves the row undrained so the next
1771
+ * reconcile tick retries it. Returns the number of rows newly marked drained.
1772
+ */
1773
+ markPendingEventsDrainedById(ids: ReadonlyArray<string>): number {
1774
+ const idList = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
1775
+ if (idList.length === 0) return 0;
1776
+ const now = Date.now();
1777
+ return this.db.prepare(
1778
+ `UPDATE mesh_pending_events SET drained = 1, drained_at = ? WHERE drained = 0 AND id IN (${idList.map(() => '?').join(',')})`
1779
+ ).run(now, ...idList).changes;
1780
+ }
1781
+
1782
+ /**
1783
+ * Hard-delete pending-event rows by id (including the dedup fingerprint history).
1784
+ * Used to expire an unresolved-delegate outbox entry that has exhausted its retry
1785
+ * budget — fully removing it frees the fingerprint so a genuinely new completion
1786
+ * for the same task could be re-queued later. Returns the number of rows deleted.
1787
+ */
1788
+ deletePendingEventsById(ids: ReadonlyArray<string>): number {
1789
+ const idList = ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
1790
+ if (idList.length === 0) return 0;
1791
+ return this.db.prepare(
1792
+ `DELETE FROM mesh_pending_events WHERE id IN (${idList.map(() => '?').join(',')})`
1793
+ ).run(...idList).changes;
1794
+ }
1795
+ }