@adhdev/daemon-core 0.9.82-rc.34 → 0.9.82-rc.341

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 (318) hide show
  1. package/dist/boot/daemon-lifecycle.d.ts +8 -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 +46 -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 +116 -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 +53 -0
  14. package/dist/cli-adapters/raw-terminal-io.d.ts +37 -0
  15. package/dist/cli-adapters/resolve-executable.d.ts +14 -0
  16. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +6 -3
  17. package/dist/cli-adapters/terminal-backends/types.d.ts +10 -2
  18. package/dist/cli-adapters/terminal-screen.d.ts +4 -11
  19. package/dist/commands/cli-manager.d.ts +2 -1
  20. package/dist/commands/handler.d.ts +110 -0
  21. package/dist/commands/mesh-coordinator.d.ts +85 -1
  22. package/dist/commands/router.d.ts +308 -1
  23. package/dist/commands/upgrade-helper.d.ts +12 -0
  24. package/dist/config/chat-history.d.ts +9 -0
  25. package/dist/config/config.d.ts +5 -0
  26. package/dist/config/mesh-config.d.ts +77 -1
  27. package/dist/detection/ide-detector.d.ts +13 -0
  28. package/dist/detection/win32-ide-version.d.ts +37 -0
  29. package/dist/git/change-impact-config.d.ts +159 -0
  30. package/dist/git/git-commands.d.ts +11 -1
  31. package/dist/git/git-diff.d.ts +6 -0
  32. package/dist/git/git-executor.d.ts +11 -0
  33. package/dist/git/git-status.d.ts +23 -0
  34. package/dist/git/git-types.d.ts +2 -50
  35. package/dist/git/index.d.ts +2 -0
  36. package/dist/index.d.ts +52 -13
  37. package/dist/index.js +42012 -17098
  38. package/dist/index.js.map +1 -1
  39. package/dist/index.mjs +41683 -16890
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/installer.d.ts +1 -4
  42. package/dist/ipc/local-ipc-server.d.ts +91 -0
  43. package/dist/launch.d.ts +1 -1
  44. package/dist/logging/async-batch-writer.d.ts +10 -0
  45. package/dist/logging/log-redactor.d.ts +24 -0
  46. package/dist/logging/log-tail-reader.d.ts +46 -0
  47. package/dist/mesh/contracts.d.ts +164 -0
  48. package/dist/mesh/coordinator-prompt.d.ts +36 -0
  49. package/dist/mesh/coordinator-registry.d.ts +59 -0
  50. package/dist/mesh/mesh-active-work.d.ts +174 -0
  51. package/dist/mesh/mesh-delivery-policy.d.ts +131 -0
  52. package/dist/mesh/mesh-events-coordinator.d.ts +169 -0
  53. package/dist/mesh/mesh-events-pending.d.ts +45 -0
  54. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  55. package/dist/mesh/mesh-events-utils.d.ts +66 -0
  56. package/dist/mesh/mesh-events.d.ts +6 -49
  57. package/dist/mesh/mesh-fast-forward.d.ts +81 -0
  58. package/dist/mesh/mesh-host-ownership.d.ts +9 -0
  59. package/dist/mesh/mesh-init.d.ts +86 -0
  60. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  61. package/dist/mesh/mesh-ledger.d.ts +77 -1
  62. package/dist/mesh/mesh-missions.d.ts +116 -0
  63. package/dist/mesh/mesh-reconcile-loop.d.ts +7 -0
  64. package/dist/mesh/mesh-refine-batch.d.ts +68 -0
  65. package/dist/mesh/mesh-refine-status.d.ts +62 -0
  66. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  67. package/dist/mesh/mesh-routing.d.ts +70 -0
  68. package/dist/mesh/mesh-runtime-store.d.ts +418 -0
  69. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  70. package/dist/mesh/mesh-unresolved-forward-outbox.d.ts +30 -0
  71. package/dist/mesh/mesh-work-queue.d.ts +208 -5
  72. package/dist/mesh/preview-freshness.d.ts +18 -0
  73. package/dist/mesh/refine-config.d.ts +216 -0
  74. package/dist/mesh/worktree-bootstrap-config.d.ts +135 -0
  75. package/dist/providers/acp-provider-instance.d.ts +2 -0
  76. package/dist/providers/approval-utils.d.ts +13 -0
  77. package/dist/providers/chat-message-normalization.d.ts +12 -0
  78. package/dist/providers/cli-provider-instance.d.ts +76 -3
  79. package/dist/providers/contracts.d.ts +139 -6
  80. package/dist/providers/external-sources.d.ts +71 -0
  81. package/dist/providers/native-history/antigravity-cli-transcript.d.ts +100 -0
  82. package/dist/providers/native-history/claude-cli-transcript.d.ts +70 -0
  83. package/dist/providers/native-history/codex-cli-transcript.d.ts +73 -0
  84. package/dist/providers/native-history/dispatcher.d.ts +27 -0
  85. package/dist/providers/native-history/hermes-cli-transcript.d.ts +30 -0
  86. package/dist/providers/native-history/index.d.ts +13 -0
  87. package/dist/providers/provider-instance-manager.d.ts +13 -0
  88. package/dist/providers/provider-instance.d.ts +13 -1
  89. package/dist/providers/provider-loader.d.ts +26 -4
  90. package/dist/providers/provider-trust.d.ts +31 -0
  91. package/dist/providers/read-chat-contract.d.ts +29 -0
  92. package/dist/providers/sdk/v1/builders/acp/detect-status.d.ts +68 -0
  93. package/dist/providers/sdk/v1/builders/cli/detect-status.d.ts +85 -0
  94. package/dist/providers/sdk/v1/builders/cli/parse-approval-squash.d.ts +59 -0
  95. package/dist/providers/sdk/v1/builders/cli/parse-approval.d.ts +65 -0
  96. package/dist/providers/sdk/v1/builders/cli/parse-session.d.ts +91 -0
  97. package/dist/providers/sdk/v1/builders/cli/visible-region.d.ts +42 -0
  98. package/dist/providers/sdk/v1/fixture-tooling/format.d.ts +126 -0
  99. package/dist/providers/sdk/v1/fixture-tooling/index.d.ts +8 -0
  100. package/dist/providers/sdk/v1/fixture-tooling/replay.d.ts +38 -0
  101. package/dist/providers/sdk/v1/index.d.ts +30 -0
  102. package/dist/providers/sdk/v1/sandbox/README-design.d.ts +193 -0
  103. package/dist/providers/sdk/v1/sandbox/require-whitelist.d.ts +74 -0
  104. package/dist/providers/sdk/v1/sandbox/script-runner.d.ts +98 -0
  105. package/dist/providers/sdk/v1/types/cli/index.d.ts +277 -0
  106. package/dist/providers/sdk/v1/types/common/index.d.ts +203 -0
  107. package/dist/providers/sdk/v1/validators/index.d.ts +5 -0
  108. package/dist/providers/sdk/v1/validators/manifest.d.ts +40 -0
  109. package/dist/providers/sdk/v1/validators/taint.d.ts +52 -0
  110. package/dist/providers/spec/adapter.d.ts +69 -0
  111. package/dist/providers/spec/cli-adapter.d.ts +217 -0
  112. package/dist/providers/spec/evaluator.d.ts +23 -0
  113. package/dist/providers/spec/fsm-driver.d.ts +306 -0
  114. package/dist/providers/spec/fsm-evaluator.d.ts +72 -0
  115. package/dist/providers/spec/fsm-loader.d.ts +14 -0
  116. package/dist/providers/spec/fsm-types.d.ts +144 -0
  117. package/dist/providers/spec/native-history-executor.d.ts +41 -0
  118. package/dist/providers/spec/pre-launch-trust.d.ts +16 -0
  119. package/dist/providers/spec/route.d.ts +4 -0
  120. package/dist/providers/spec/types.d.ts +180 -0
  121. package/dist/providers/status-monitor.d.ts +7 -7
  122. package/dist/providers/transcript-v2.d.ts +176 -0
  123. package/dist/providers/types/interactive-prompt.d.ts +84 -0
  124. package/dist/providers/working-dir.d.ts +17 -0
  125. package/dist/repo-mesh-types.d.ts +295 -13
  126. package/dist/sessions/registry.d.ts +6 -0
  127. package/dist/shared-types-extra.d.ts +2 -4
  128. package/dist/shared-types.d.ts +59 -2
  129. package/dist/status/normalize.d.ts +1 -1
  130. package/dist/status/normalize.js +1 -0
  131. package/dist/status/normalize.js.map +1 -1
  132. package/dist/status/normalize.mjs +1 -0
  133. package/dist/status/normalize.mjs.map +1 -1
  134. package/dist/status/reporter.d.ts +2 -0
  135. package/dist/status/snapshot.d.ts +26 -0
  136. package/dist/system/load-better-sqlite3.d.ts +21 -0
  137. package/dist/types.d.ts +5 -0
  138. package/package.json +7 -3
  139. package/src/agent-stream/poller.ts +2 -3
  140. package/src/agent-stream/provider-adapter.ts +1 -1
  141. package/src/boot/daemon-lifecycle.ts +55 -12
  142. package/src/boot/process-hardening.ts +89 -0
  143. package/src/build-info.ts +73 -0
  144. package/src/chat/source-machine.ts +534 -0
  145. package/src/chat/source-resolver.ts +0 -0
  146. package/src/chat/subscription-updates.ts +20 -1
  147. package/src/cli-adapter-types.d.ts +1 -0
  148. package/src/cli-adapter-types.ts +47 -2
  149. package/src/cli-adapters/cli-script-runner.ts +421 -0
  150. package/src/cli-adapters/cli-state-engine.ts +1215 -0
  151. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  152. package/src/cli-adapters/provider-cli-adapter.ts +774 -1141
  153. package/src/cli-adapters/provider-cli-parse.d.ts +1 -0
  154. package/src/cli-adapters/provider-cli-parse.ts +13 -0
  155. package/src/cli-adapters/provider-cli-runtime.ts +56 -7
  156. package/src/cli-adapters/provider-cli-shared.d.ts +4 -0
  157. package/src/cli-adapters/provider-cli-shared.ts +100 -11
  158. package/src/cli-adapters/pty-transport.ts +2 -1
  159. package/src/cli-adapters/raw-terminal-io.ts +252 -0
  160. package/src/cli-adapters/resolve-executable.ts +90 -0
  161. package/src/cli-adapters/session-host-transport.ts +2 -1
  162. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +53 -29
  163. package/src/cli-adapters/terminal-backends/types.ts +10 -3
  164. package/src/cli-adapters/terminal-screen.ts +16 -81
  165. package/src/commands/chat-commands.ts +1823 -62
  166. package/src/commands/cli-manager.ts +286 -14
  167. package/src/commands/handler.ts +809 -2
  168. package/src/commands/mesh-coordinator.ts +332 -122
  169. package/src/commands/router.ts +7433 -1317
  170. package/src/commands/stream-commands.ts +8 -0
  171. package/src/commands/upgrade-helper.ts +81 -30
  172. package/src/config/chat-history.ts +483 -24
  173. package/src/config/config.ts +12 -0
  174. package/src/config/mesh-config.ts +388 -6
  175. package/src/config/recent-activity.ts +8 -2
  176. package/src/daemon/dev-auto-implement.ts +3 -2
  177. package/src/daemon/dev-cli-debug.ts +10 -1
  178. package/src/detection/cli-detector.ts +28 -9
  179. package/src/detection/ide-detector.ts +55 -16
  180. package/src/detection/win32-ide-version.ts +106 -0
  181. package/src/git/change-impact-config.ts +354 -0
  182. package/src/git/git-commands.ts +55 -14
  183. package/src/git/git-diff.ts +53 -0
  184. package/src/git/git-executor.ts +12 -0
  185. package/src/git/git-status.ts +574 -43
  186. package/src/git/git-types.ts +14 -62
  187. package/src/git/git-worktree.ts +8 -1
  188. package/src/git/index.ts +16 -0
  189. package/src/index.ts +146 -12
  190. package/src/installer.d.ts +1 -1
  191. package/src/installer.ts +8 -6
  192. package/src/ipc/local-ipc-server.ts +278 -0
  193. package/src/launch.d.ts +1 -1
  194. package/src/launch.ts +37 -28
  195. package/src/logging/async-batch-writer.ts +55 -0
  196. package/src/logging/log-redactor.ts +100 -0
  197. package/src/logging/log-tail-reader.ts +220 -0
  198. package/src/logging/logger.ts +2 -1
  199. package/src/mesh/contracts.ts +329 -0
  200. package/src/mesh/coordinator-prompt.ts +234 -31
  201. package/src/mesh/coordinator-registry.ts +121 -0
  202. package/src/mesh/mesh-active-work.ts +623 -0
  203. package/src/mesh/mesh-delivery-policy.ts +315 -0
  204. package/src/mesh/mesh-events-coordinator.ts +2144 -0
  205. package/src/mesh/mesh-events-pending.ts +508 -0
  206. package/src/mesh/mesh-events-stale.ts +290 -0
  207. package/src/mesh/mesh-events-utils.ts +300 -0
  208. package/src/mesh/mesh-events.ts +30 -1035
  209. package/src/mesh/mesh-fast-forward.ts +839 -0
  210. package/src/mesh/mesh-host-ownership.ts +73 -0
  211. package/src/mesh/mesh-init.ts +260 -0
  212. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  213. package/src/mesh/mesh-ledger.ts +589 -102
  214. package/src/mesh/mesh-missions.ts +248 -0
  215. package/src/mesh/mesh-reconcile-loop.ts +822 -0
  216. package/src/mesh/mesh-refine-batch.ts +197 -0
  217. package/src/mesh/mesh-refine-status.ts +231 -0
  218. package/src/mesh/mesh-review-inbox.ts +307 -0
  219. package/src/mesh/mesh-routing.ts +291 -0
  220. package/src/mesh/mesh-runtime-store.ts +1626 -0
  221. package/src/mesh/mesh-task-stats.ts +154 -0
  222. package/src/mesh/mesh-unresolved-forward-outbox.ts +185 -0
  223. package/src/mesh/mesh-work-queue.ts +888 -141
  224. package/src/mesh/preview-freshness.ts +118 -0
  225. package/src/mesh/refine-config.ts +423 -0
  226. package/src/mesh/worktree-bootstrap-config.ts +331 -0
  227. package/src/providers/acp-provider-instance.ts +25 -9
  228. package/src/providers/approval-utils.d.ts +4 -0
  229. package/src/providers/approval-utils.ts +47 -5
  230. package/src/providers/chat-message-normalization.ts +45 -5
  231. package/src/providers/cli-provider-instance.ts +1038 -99
  232. package/src/providers/contracts.d.ts +55 -0
  233. package/src/providers/contracts.ts +150 -6
  234. package/src/providers/extension-provider-instance.ts +12 -7
  235. package/src/providers/external-sources.ts +218 -0
  236. package/src/providers/ide-provider-instance.ts +35 -12
  237. package/src/providers/native-history/antigravity-cli-transcript.ts +643 -0
  238. package/src/providers/native-history/claude-cli-transcript.ts +396 -0
  239. package/src/providers/native-history/codex-cli-transcript.ts +479 -0
  240. package/src/providers/native-history/dispatcher.ts +340 -0
  241. package/src/providers/native-history/hermes-cli-transcript.ts +230 -0
  242. package/src/providers/native-history/index.ts +30 -0
  243. package/src/providers/provider-instance-manager.ts +30 -0
  244. package/src/providers/provider-instance.ts +10 -1
  245. package/src/providers/provider-loader.ts +670 -50
  246. package/src/providers/provider-schema.ts +87 -14
  247. package/src/providers/provider-trust.ts +114 -0
  248. package/src/providers/read-chat-contract.ts +76 -16
  249. package/src/providers/sdk/README.md +49 -0
  250. package/src/providers/sdk/v1/builders/acp/detect-status.ts +144 -0
  251. package/src/providers/sdk/v1/builders/cli/detect-status.ts +262 -0
  252. package/src/providers/sdk/v1/builders/cli/parse-approval-squash.ts +158 -0
  253. package/src/providers/sdk/v1/builders/cli/parse-approval.ts +250 -0
  254. package/src/providers/sdk/v1/builders/cli/parse-session.ts +276 -0
  255. package/src/providers/sdk/v1/builders/cli/visible-region.ts +143 -0
  256. package/src/providers/sdk/v1/fixture-tooling/format.ts +130 -0
  257. package/src/providers/sdk/v1/fixture-tooling/index.ts +22 -0
  258. package/src/providers/sdk/v1/fixture-tooling/replay.ts +352 -0
  259. package/src/providers/sdk/v1/index.ts +152 -0
  260. package/src/providers/sdk/v1/sandbox/README-design.ts +195 -0
  261. package/src/providers/sdk/v1/sandbox/require-whitelist.ts +472 -0
  262. package/src/providers/sdk/v1/sandbox/script-runner.ts +150 -0
  263. package/src/providers/sdk/v1/schemas/cli/provider.schema.json +504 -0
  264. package/src/providers/sdk/v1/schemas/primitives/acp-session-protocol-v1.json +131 -0
  265. package/src/providers/sdk/v1/schemas/primitives/native-history-codex-rollout-v1.json +66 -0
  266. package/src/providers/sdk/v1/schemas/primitives/tui-approval-squash-v1.json +91 -0
  267. package/src/providers/sdk/v1/schemas/primitives/tui-assistant-block-v1.json +91 -0
  268. package/src/providers/sdk/v1/schemas/primitives/tui-cue-ordering-v1.json +47 -0
  269. package/src/providers/sdk/v1/schemas/primitives/tui-dispatch-order-v1.json +32 -0
  270. package/src/providers/sdk/v1/schemas/primitives/tui-footer-chrome-v1.json +42 -0
  271. package/src/providers/sdk/v1/schemas/primitives/tui-index-finder-v1.json +27 -0
  272. package/src/providers/sdk/v1/schemas/primitives/tui-modal-v1.json +125 -0
  273. package/src/providers/sdk/v1/schemas/primitives/tui-prompt-marker-v1.json +45 -0
  274. package/src/providers/sdk/v1/schemas/primitives/tui-session-id-extraction-v1.json +46 -0
  275. package/src/providers/sdk/v1/schemas/primitives/tui-settled-prompt-v1.json +71 -0
  276. package/src/providers/sdk/v1/schemas/primitives/tui-spinner-v1.json +83 -0
  277. package/src/providers/sdk/v1/schemas/primitives/tui-transcript-pty-v1.json +83 -0
  278. package/src/providers/sdk/v1/schemas/primitives/tui-visible-region-v1.json +57 -0
  279. package/src/providers/sdk/v1/schemas/primitives/tui-welcome-screen-v1.json +35 -0
  280. package/src/providers/sdk/v1/types/cli/index.ts +365 -0
  281. package/src/providers/sdk/v1/types/common/index.ts +229 -0
  282. package/src/providers/sdk/v1/validators/index.ts +19 -0
  283. package/src/providers/sdk/v1/validators/manifest.ts +110 -0
  284. package/src/providers/sdk/v1/validators/taint.ts +309 -0
  285. package/src/providers/spec/adapter.ts +168 -0
  286. package/src/providers/spec/cli-adapter.ts +986 -0
  287. package/src/providers/spec/evaluator.ts +345 -0
  288. package/src/providers/spec/fsm-driver.ts +1015 -0
  289. package/src/providers/spec/fsm-evaluator.ts +255 -0
  290. package/src/providers/spec/fsm-loader.ts +120 -0
  291. package/src/providers/spec/fsm-types.ts +238 -0
  292. package/src/providers/spec/native-history-executor.ts +1136 -0
  293. package/src/providers/spec/pre-launch-trust.ts +104 -0
  294. package/src/providers/spec/route.ts +51 -0
  295. package/src/providers/spec/types.ts +226 -0
  296. package/src/providers/status-monitor.d.ts +7 -7
  297. package/src/providers/status-monitor.ts +37 -22
  298. package/src/providers/transcript-v2.ts +567 -0
  299. package/src/providers/types/interactive-prompt.ts +536 -0
  300. package/src/providers/version-archive.ts +64 -24
  301. package/src/providers/working-dir.ts +23 -0
  302. package/src/repo-mesh-types.ts +378 -14
  303. package/src/sessions/registry.ts +6 -0
  304. package/src/shared-types-extra.ts +2 -4
  305. package/src/shared-types.d.ts +8 -0
  306. package/src/shared-types.ts +64 -1
  307. package/src/status/builders.ts +26 -6
  308. package/src/status/normalize.ts +2 -0
  309. package/src/status/reporter.ts +19 -1
  310. package/src/status/snapshot.ts +95 -26
  311. package/src/system/host-memory.ts +29 -12
  312. package/src/system/load-better-sqlite3.ts +68 -0
  313. package/src/types.ts +5 -0
  314. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  315. package/dist/mesh/mesh-sync.d.ts +0 -53
  316. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  317. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -97
  318. package/src/mesh/mesh-sync.ts +0 -111
@@ -1,28 +1,335 @@
1
- import { existsSync, writeFileSync, readFileSync, openSync, closeSync, unlinkSync } from 'fs';
2
- import { join } from 'path';
3
1
  import { randomUUID } from 'crypto';
4
- import { getLedgerDir } from './mesh-ledger.js';
2
+ import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
3
+ import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
4
+ import { MESH_CONVERGE_REFINE_TAG, resolveAutoConvergeCodeChange } from '../repo-mesh-types.js';
5
+ import { MeshRuntimeStore } from './mesh-runtime-store.js';
6
+ import { getMesh } from '../config/mesh-config.js';
7
+ import { LOG } from '../logging/logger.js';
5
8
 
6
9
  export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
7
10
  export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
8
11
  export type MeshHistoricalTaskStatus = Extract<MeshTaskStatus, 'completed' | 'failed' | 'cancelled'>;
12
+ export type MeshTaskMode = 'code_change' | 'validation' | 'live_debug_readonly' | 'launch_app' | 'convergence';
9
13
 
10
14
  export const ACTIVE_MESH_QUEUE_STATUSES: MeshActiveTaskStatus[] = ['pending', 'assigned'];
11
15
  export const HISTORICAL_MESH_QUEUE_STATUSES: MeshHistoricalTaskStatus[] = ['completed', 'failed', 'cancelled'];
16
+ export const MESH_TASK_MODES: MeshTaskMode[] = ['code_change', 'validation', 'live_debug_readonly', 'launch_app', 'convergence'];
17
+
18
+ export interface MeshTaskModeValidationResult {
19
+ valid: boolean;
20
+ taskMode?: MeshTaskMode;
21
+ violations: string[];
22
+ allowedOperations?: string[];
23
+ }
24
+
25
+ const LIVE_DEBUG_READONLY_FORBIDDEN: Array<{ label: string; pattern: RegExp }> = [
26
+ { label: 'source_edit', pattern: /\b(edit|modify|patch|apply\s+patch|write\s+(?:to\s+)?(?:file|source)|overwrite|delete\s+file|remove\s+file|create\s+file|touch\s+file)\b/i },
27
+ { label: 'checkpoint', pattern: /\b(checkpoint|mesh_checkpoint)\b/i },
28
+ { label: 'deploy_or_version_bump', pattern: /\b(deploy|wrangler\s+deploy|version[-\s]?bump|npm\s+version|release|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i },
29
+ { label: 'destructive_shell', pattern: /\b(rm\s+-rf|mv\s+\S+\s+\S+|truncate\s|tee\s+\S+|sed\s+-i|shred\b)\b/i },
30
+ { label: 'package_install', pattern: /\b(npm\s+(?:install|i|add|link|uninstall|remove)|yarn\s+(?:add|remove|link)|pnpm\s+(?:add|remove|link)|pip\s+install|brew\s+install|apt\s+install|cargo\s+install)\b/i },
31
+ { label: 'container_mutation', pattern: /\b(docker\s+(?:build|run|exec|push|tag|rmi|rm|create|start|stop|kill)|kubectl\s+(?:apply|delete|patch|replace|create|scale))\b/i },
32
+ ];
33
+
34
+ /**
35
+ * Negation cues that, when they appear shortly before a mutation keyword inside
36
+ * the same clause, mean the keyword is being *forbidden* or described rather
37
+ * than invoked (e.g. "do not git reset", "절대 push 하지 마세요"). Matched
38
+ * case-insensitively. The Korean cues intentionally include sub-string forms
39
+ * ("않", "금지", "말 것") so conjugated variants are caught.
40
+ */
41
+ const NEGATION_CUES: string[] = [
42
+ "don't", 'do not', 'never', 'avoid', 'without', 'no longer', 'not', 'forbidden',
43
+ '하지 마', '하지 마세요', '말 것', '금지', '없음', '않',
44
+ ];
45
+
46
+ /** How many whitespace-delimited tokens before a keyword we scan for negation. */
47
+ const NEGATION_WINDOW_TOKENS = 6;
48
+
49
+ /**
50
+ * Returns true if a negation cue appears within {@link NEGATION_WINDOW_TOKENS}
51
+ * tokens before `matchIndex`, staying inside the same clause — we stop at
52
+ * sentence/line/clause boundaries (newline, `.`/`!`/`?`, `;`) so a negation in a
53
+ * previous sentence does not suppress a real command in the next one.
54
+ */
55
+ function hasNegationBefore(text: string, matchIndex: number): boolean {
56
+ const before = text.slice(0, matchIndex);
57
+ // Restrict to the current clause: cut at the last clause/sentence/line break.
58
+ const clauseStart = Math.max(
59
+ before.lastIndexOf('\n'),
60
+ before.lastIndexOf('. '),
61
+ before.lastIndexOf('! '),
62
+ before.lastIndexOf('? '),
63
+ before.lastIndexOf(';'),
64
+ );
65
+ const clause = before.slice(clauseStart + 1);
66
+ const lower = clause.toLowerCase();
67
+ // Whitespace tokens immediately preceding the keyword, within the window.
68
+ const tokens = clause.split(/\s+/).filter(Boolean);
69
+ const windowTokens = tokens.slice(Math.max(0, tokens.length - NEGATION_WINDOW_TOKENS));
70
+ const windowText = windowTokens.join(' ').toLowerCase();
71
+ for (const cue of NEGATION_CUES) {
72
+ const c = cue.toLowerCase();
73
+ // ASCII cues are word-ish phrases — match in the bounded window only.
74
+ // CJK cues have no spaces, so the window-join can miss them; for those
75
+ // fall back to scanning the whole clause (still clause-bounded).
76
+ if (/[^\x00-\x7f]/.test(c)) {
77
+ if (lower.includes(c)) return true;
78
+ } else if (windowText.includes(c)) {
79
+ return true;
80
+ }
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Korean (and other) negation often trails the verb it negates ("git reset 하지
87
+ * 마세요" = "do not git reset"). Returns true if a CJK negation cue appears
88
+ * shortly after `matchEnd`, within the same clause. Scoped to CJK cues only —
89
+ * trailing ASCII words rarely negate a preceding command and would over-match.
90
+ */
91
+ function hasTrailingNegation(text: string, matchEnd: number): boolean {
92
+ const after = text.slice(matchEnd);
93
+ // Clause-bound the lookahead: stop at the next clause/line break.
94
+ const clauseEnd = (() => {
95
+ const stops = [after.indexOf('\n'), after.indexOf('. '), after.indexOf('; ')]
96
+ .filter(i => i >= 0);
97
+ return stops.length ? Math.min(...stops) : after.length;
98
+ })();
99
+ const clause = after.slice(0, clauseEnd).toLowerCase();
100
+ for (const cue of NEGATION_CUES) {
101
+ const c = cue.toLowerCase();
102
+ if (/[^\x00-\x7f]/.test(c) && clause.includes(c)) return true;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ /**
108
+ * Returns true when the keyword at [matchStart, matchEnd) looks like an actual
109
+ * command invocation rather than a plain-prose mention. Command context is:
110
+ * - inside a fenced code block (``` ... ```) or inline backticks (`...`)
111
+ * - on a shell-prompt line (starts with `$ ` or `> `)
112
+ * - at a command-call position: line start (leading whitespace allowed) or
113
+ * right after a shell connective (`&&`, `||`, `|`, `;`) or an imperative
114
+ * connective ("then"/"run"/"," ) that introduces a command.
115
+ * Plain mid-sentence prose mentions (no backticks, no command position) are not
116
+ * command context and are excluded from violations.
117
+ */
118
+ function isMutationKeywordInCommandContext(text: string, matchStart: number, matchEnd: number): boolean {
119
+ if (isInsideBackticksOrFence(text, matchStart, matchEnd)) return true;
120
+
121
+ // The line containing the match, and the portion of it before the keyword.
122
+ const lineStart = text.lastIndexOf('\n', matchStart - 1) + 1;
123
+ const linePrefix = text.slice(lineStart, matchStart);
124
+
125
+ // Shell-prompt line: "$ ..." or "> ..." (leading whitespace allowed).
126
+ if (/^\s*[$>]\s/.test(text.slice(lineStart))) return true;
127
+
128
+ // Line start (only whitespace before the keyword on this line).
129
+ if (/^\s*$/.test(linePrefix)) return true;
130
+
131
+ // After a shell connective or imperative connective introducing a command.
132
+ // We look at what immediately precedes the keyword on the same line.
133
+ if (/(?:&&|\|\||\||;)\s*$/.test(linePrefix)) return true;
134
+ if (/(?:^|[\s,])(?:then|run|first)\s+$/i.test(linePrefix)) return true;
135
+ if (/,\s*$/.test(linePrefix)) return true;
136
+
137
+ return false;
138
+ }
139
+
140
+ /**
141
+ * True if [matchStart, matchEnd) lies inside an inline-backtick span or a fenced
142
+ * code block. Fenced blocks (```...```) take precedence; otherwise we count
143
+ * inline backticks before the match — an odd count means we are inside a span.
144
+ */
145
+ function isInsideBackticksOrFence(text: string, matchStart: number, matchEnd: number): boolean {
146
+ // Fenced code blocks: count ``` fences before the match.
147
+ const fenceRe = /```/g;
148
+ let fenceCount = 0;
149
+ let m: RegExpExecArray | null;
150
+ while ((m = fenceRe.exec(text)) !== null) {
151
+ if (m.index >= matchStart) break;
152
+ fenceCount++;
153
+ }
154
+ if (fenceCount % 2 === 1) return true;
155
+
156
+ // Inline backticks: count single backticks before the match start, ignoring
157
+ // those that are part of a ``` fence (handled above). An odd count → inside.
158
+ let inlineCount = 0;
159
+ for (let i = 0; i < matchStart; i++) {
160
+ if (text[i] === '`') {
161
+ // Skip triple-fence backticks.
162
+ if (text[i + 1] === '`' && text[i + 2] === '`') {
163
+ i += 2;
164
+ continue;
165
+ }
166
+ inlineCount++;
167
+ }
168
+ }
169
+ return inlineCount % 2 === 1;
170
+ }
171
+
172
+ /**
173
+ * Decides whether a forbidden keyword match at [matchStart, matchEnd) should
174
+ * count as a real violation. A match counts only when it is in command context
175
+ * and not negated. Negation always wins (even inside a code block), per the
176
+ * conservative rule: code-block + negation → exclude; other code-block → keep.
177
+ */
178
+ function isRealMutationMatch(text: string, matchStart: number, matchEnd: number): boolean {
179
+ if (hasNegationBefore(text, matchStart)) return false;
180
+ if (hasTrailingNegation(text, matchEnd)) return false;
181
+ return isMutationKeywordInCommandContext(text, matchStart, matchEnd);
182
+ }
183
+
184
+ /**
185
+ * Runs a global regex over `text` and returns true if any match is a real
186
+ * mutation (command context, not negated).
187
+ */
188
+ function patternHasRealMutation(pattern: RegExp, text: string): boolean {
189
+ const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
190
+ let match: RegExpExecArray | null;
191
+ while ((match = re.exec(text)) !== null) {
192
+ if (isRealMutationMatch(text, match.index, match.index + match[0].length)) return true;
193
+ if (match.index === re.lastIndex) re.lastIndex++; // avoid zero-width loop
194
+ }
195
+ return false;
196
+ }
197
+
198
+ /**
199
+ * Git subcommands that mutate the working tree, index, refs, or remote.
200
+ * `stash` and `checkout` are intentionally absent here: they have read-only
201
+ * variants (`git stash list`/`show`, `git checkout-index`) and are classified
202
+ * token-by-token in {@link detectGitMutation} rather than by bare keyword.
203
+ */
204
+ const GIT_MUTATION_SUBCOMMANDS = new Set([
205
+ 'add', 'commit', 'push', 'reset', 'rebase', 'clean', 'switch', 'merge',
206
+ 'tag', 'restore', 'rm', 'mv', 'cherry-pick', 'revert', 'pull', 'fetch',
207
+ 'am', 'apply', 'gc', 'prune',
208
+ ]);
209
+
210
+ /**
211
+ * Read-only `git stash` variants. Any other `git stash <x>` (pop/apply/drop/
212
+ * push/save/clear, or bare `git stash` which defaults to push) is a mutation.
213
+ */
214
+ const GIT_STASH_READONLY_SUBCOMMANDS = new Set(['list', 'show']);
215
+
216
+ /**
217
+ * Detects a true git mutation in free-text task message, token-aware so that
218
+ * read-only diagnostics (`git stash list`, `git stash show --stat`,
219
+ * `git checkout-index`, `git status`, `git diff`, `git log`, ...) are allowed.
220
+ * Returns true only when a genuine mutating git invocation is present.
221
+ */
222
+ function detectGitMutation(message: string): boolean {
223
+ const re = /\bgit\s+([a-z][a-z0-9-]*)/gi;
224
+ let match: RegExpExecArray | null;
225
+ while ((match = re.exec(message)) !== null) {
226
+ const sub = match[1].toLowerCase();
227
+ // Only treat a mutating `git <sub>` as a real violation when it appears
228
+ // as an actual command (code/command context) and is not negated. Plain
229
+ // prose mentions ("don't git reset", "we won't push") are ignored.
230
+ const isReal = () => isRealMutationMatch(message, match!.index, match!.index + match![0].length);
231
+ if (GIT_MUTATION_SUBCOMMANDS.has(sub)) {
232
+ if (isReal()) return true;
233
+ continue;
234
+ }
235
+ if (sub === 'stash') {
236
+ // Token following `git stash`; read-only only for list/show.
237
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
238
+ const next = after ? after[1].toLowerCase() : '';
239
+ if (!GIT_STASH_READONLY_SUBCOMMANDS.has(next) && isReal()) return true; // bare stash = push, or pop/apply/drop/...
240
+ } else if (sub === 'checkout') {
241
+ // `git checkout <ref/path>` mutates; `git checkout-index` is matched
242
+ // as its own token by the regex (sub === 'checkout-index') and is read-only.
243
+ if (isReal()) return true;
244
+ } else if (sub === 'submodule') {
245
+ // `git submodule update` mutates; `git submodule status` is read-only.
246
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
247
+ const next = after ? after[1].toLowerCase() : '';
248
+ if ((next === 'update' || next === 'add' || next === 'sync' || next === 'deinit') && isReal()) return true;
249
+ } else if (sub === 'worktree') {
250
+ const after = message.slice(re.lastIndex).match(/^\s+([a-z][a-z0-9-]*)/i);
251
+ const next = after ? after[1].toLowerCase() : '';
252
+ if ((next === 'add' || next === 'remove' || next === 'move' || next === 'prune') && isReal()) return true;
253
+ }
254
+ // checkout-index, stash-with-no-next-already-handled, status/diff/log/show/
255
+ // rev-parse/branch/submodule status fall through as read-only.
256
+ }
257
+ return false;
258
+ }
259
+
260
+ export function normalizeMeshTaskMode(value: unknown): MeshTaskMode | undefined {
261
+ if (typeof value !== 'string') return undefined;
262
+ const normalized = value.trim() as MeshTaskMode;
263
+ return (MESH_TASK_MODES as string[]).includes(normalized) ? normalized : undefined;
264
+ }
265
+
266
+ export function validateMeshTaskModeRequest(mode: unknown, message: string): MeshTaskModeValidationResult {
267
+ const taskMode = normalizeMeshTaskMode(mode);
268
+ if (!taskMode) {
269
+ return { valid: true, violations: [] };
270
+ }
271
+ if (taskMode !== 'live_debug_readonly') {
272
+ return { valid: true, taskMode, violations: [] };
273
+ }
274
+ const text = message || '';
275
+ // Only flag keywords that look like real commands (code/command context) and
276
+ // are not negated — descriptive/prohibitive prose ("don't run `npm publish`",
277
+ // "read-only, no deploy") must not trip the guardrail.
278
+ const violations = LIVE_DEBUG_READONLY_FORBIDDEN
279
+ .filter(rule => patternHasRealMutation(rule.pattern, text))
280
+ .map(rule => rule.label);
281
+ if (detectGitMutation(text)) {
282
+ violations.push('git_mutation');
283
+ }
284
+ return {
285
+ valid: violations.length === 0,
286
+ taskMode,
287
+ violations,
288
+ allowedOperations: [
289
+ 'process/log/window/port/session inspection',
290
+ 'read-only filesystem listing/reading',
291
+ 'status probes and keep-running handle reporting',
292
+ 'diagnostic summaries without source edits, commits, checkpoints, pushes, deploys, resets, rebases, or destructive cleanups',
293
+ ],
294
+ };
295
+ }
12
296
 
13
297
  export interface MeshWorkQueueEntry {
14
298
  id: string;
15
299
  meshId: string;
16
300
  message: string;
17
301
  status: MeshTaskStatus;
302
+ taskMode?: MeshTaskMode;
18
303
  /** If specified, only this node can claim the task (used by legacy mesh_send_task) */
19
304
  targetNodeId?: string;
20
305
  /** If specified, only this runtime session can claim the task */
21
306
  targetSessionId?: string;
307
+ /** If specified, a node must expose all tags before it can claim the task. */
308
+ requiredTags?: string[];
309
+ /**
310
+ * M1: ids of tasks that must reach 'completed' before this task is claimable.
311
+ * Forward references (ids not yet enqueued) are allowed for batch flows and
312
+ * simply keep the task waiting until the referenced task exists and completes.
313
+ */
314
+ dependsOn?: string[];
315
+ /** M1/M3: mission this task belongs to (joins mesh_missions). */
316
+ missionId?: string;
317
+ /**
318
+ * M1: why this task is held back (e.g. "dependency_failed:<taskId>").
319
+ * Only set by the system on dependency failure under the 'block' policy;
320
+ * waiting-on-dependency state is computed at view time, not stored.
321
+ */
322
+ blockedReason?: string;
22
323
  /** The node that actually claimed and is executing the task */
23
324
  assignedNodeId?: string;
24
325
  /** The session currently executing the task */
25
326
  assignedSessionId?: string;
327
+ /**
328
+ * Provider type of the session that claimed the task. Recorded so the queue
329
+ * can enforce per-(node, provider) maxParallel caps (RepoMeshNodePolicy
330
+ * providerRoles) by counting active assignments grouped by node + provider.
331
+ */
332
+ assignedProviderType?: string;
26
333
  /** Human/operator reason for terminal cancellation. */
27
334
  cancelReason?: string;
28
335
  cancelledAt?: string;
@@ -30,6 +337,8 @@ export interface MeshWorkQueueEntry {
30
337
  requeueReason?: string;
31
338
  requeuedAt?: string;
32
339
  requeueCount?: number;
340
+ /** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
341
+ maxRetries?: number;
33
342
  /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
34
343
  autoLaunch?: {
35
344
  status: 'skipped' | 'started' | 'failed' | 'completed';
@@ -45,50 +354,199 @@ export interface MeshWorkQueueEntry {
45
354
  updatedAt: string;
46
355
  }
47
356
 
48
- function getQueuePath(meshId: string): string {
49
- const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
50
- return join(getLedgerDir(), `${safe}.queue.json`);
357
+ export interface MeshQueueMutationOptions {
358
+ ownerRole?: RepoMeshDaemonRole;
359
+ }
360
+
361
+ export function normalizeMeshCapabilityTags(value: unknown): string[] {
362
+ if (!Array.isArray(value)) return [];
363
+ const seen = new Set<string>();
364
+ return value
365
+ .map(tag => typeof tag === 'string' ? tag.trim() : '')
366
+ .filter(Boolean)
367
+ .filter(tag => {
368
+ if (seen.has(tag)) return false;
369
+ seen.add(tag);
370
+ return true;
371
+ });
51
372
  }
52
373
 
53
- function getLockPath(meshId: string): string {
54
- const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
55
- return join(getLedgerDir(), `${safe}.queue.lock`);
374
+ function firstProviderPriority(policy: unknown): string | undefined {
375
+ const raw = policy && typeof policy === 'object' && !Array.isArray(policy)
376
+ ? (policy as Record<string, unknown>).providerPriority
377
+ : undefined;
378
+ if (!Array.isArray(raw)) return undefined;
379
+ return raw.find(type => typeof type === 'string' && type.trim())?.trim();
380
+ }
381
+
382
+ function readNodeOverride(node: { userOverrides?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
383
+ const overrides = node?.userOverrides;
384
+ if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return null;
385
+ const value = (overrides as Record<string, unknown>)[key];
386
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
56
387
  }
57
388
 
58
389
  /**
59
- * Simple advisory file lock using O_EXCL (atomic create) for queue mutations.
60
- * Retries up to 10 times at 30 ms intervals; proceeds without lock on timeout
61
- * to prevent deadlock (best-effort far better than no locking at all).
390
+ * Live, self-reported platform/arch the owning daemon stamped onto the node from
391
+ * its own process.platform/process.arch via the git_status envelope. Kept on a
392
+ * field DISTINCT from userOverrides so capability-tag derivation can prefer an
393
+ * explicit operator override while still self-healing auto-detected nodes — and
394
+ * so the value reflects the node's real OS rather than the coordinator's.
62
395
  */
63
- function withQueueLock<T>(meshId: string, fn: () => T): T {
64
- const lockPath = getLockPath(meshId);
65
- let fd = -1;
66
- for (let i = 0; i < 10; i++) {
67
- try { fd = openSync(lockPath, 'wx'); break; } catch {
68
- const deadline = Date.now() + 30;
69
- while (Date.now() < deadline) { /* spin */ }
70
- }
71
- }
72
- try { return fn(); } finally {
73
- if (fd !== -1) try { closeSync(fd); } catch { /* noop */ }
74
- try { unlinkSync(lockPath); } catch { /* already removed */ }
75
- }
396
+ function readNodeReporter(node: { reportedPlatform?: unknown; reportedArch?: unknown } | undefined, key: 'platform' | 'arch'): string | null {
397
+ const value = key === 'platform' ? node?.reportedPlatform : node?.reportedArch;
398
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
76
399
  }
77
400
 
78
- function readQueue(meshId: string): MeshWorkQueueEntry[] {
79
- const path = getQueuePath(meshId);
80
- if (!existsSync(path)) return [];
401
+ export function buildMeshNodeCapabilityTags(
402
+ node: { capabilities?: unknown; policy?: unknown; isLocalWorktree?: unknown; worktreeBranch?: unknown; userOverrides?: unknown; reportedPlatform?: unknown; reportedArch?: unknown } | undefined,
403
+ providerType?: string,
404
+ ): string[] {
405
+ const provider = typeof providerType === 'string' && providerType.trim()
406
+ ? providerType.trim()
407
+ : firstProviderPriority(node?.policy);
408
+ const worktreeBranch = typeof node?.worktreeBranch === 'string' && node.worktreeBranch.trim()
409
+ ? node.worktreeBranch.trim()
410
+ : null;
411
+ // Per-node platform/arch precedence (highest → lowest):
412
+ // 1. userOverrides.platform/arch — an EXPLICIT operator override always wins.
413
+ // 2. reportedPlatform/reportedArch — the live OS the owning daemon
414
+ // self-reported (its own process.platform/process.arch via the git_status
415
+ // envelope), persisted to the node record on each direct probe. This is
416
+ // why a Windows member advertises os=win32 even though the COORDINATOR
417
+ // computing these tags runs on darwin — without it the consumer reads the
418
+ // persistent node (operator userOverrides empty) and would fall straight
419
+ // through to the coordinator's own process.platform, mislabeling every
420
+ // node os=darwin. We prefer this LIVE value over any stale auto-stamp.
421
+ // 3. process.platform/process.arch — last-resort fallback, correct only for
422
+ // the local coordinator node / local worktree nodes that have not yet
423
+ // been probed (their workspace lives on THIS machine anyway).
424
+ // Vocabulary is raw process.platform/process.arch ("darwin"/"win32"/"linux",
425
+ // "arm64"/"x64") on both the advertiser and the required_tags matcher, which
426
+ // compares with plain string equality (nodeSatisfiesRequiredTags) — so this
427
+ // keeps the win32/darwin/linux vocabulary the matcher already expects.
428
+ const os = readNodeOverride(node, 'platform') ?? readNodeReporter(node, 'platform') ?? process.platform;
429
+ const arch = readNodeOverride(node, 'arch') ?? readNodeReporter(node, 'arch') ?? process.arch;
430
+ return normalizeMeshCapabilityTags([
431
+ ...(Array.isArray(node?.capabilities) ? node.capabilities : []),
432
+ `os=${os}`,
433
+ `arch=${arch}`,
434
+ ...(provider ? [`provider=${provider}`] : []),
435
+ // Worktree nodes automatically expose a "worktree=<branch>" tag so that
436
+ // mesh_enqueue_task with required_tags: ["worktree=<branch>"] routes
437
+ // only to the matching worktree node.
438
+ ...(node?.isLocalWorktree === true && worktreeBranch ? [`worktree=${worktreeBranch}`] : []),
439
+ // Convergence routing: advertise how this node can land its work onto base.
440
+ // - converge=refine: local worktree nodes (on ANY machine — refine_mesh_node
441
+ // now forwards to the owning daemon) can run the Refinery merge → push →
442
+ // cleanup against their own checkout, so they accept code_change tasks.
443
+ // - converge=fast_forward: non-worktree nodes (the machine itself) can only
444
+ // ff/push an already-converged branch; they are NOT a destination for
445
+ // code_change work (a worktree is created first, and that worktree node
446
+ // receives the task instead). Reuses the ordinary required-tags filter —
447
+ // the load-balancing scheduler auto-injects converge=refine for code_change
448
+ // so such work is hard-filtered onto refine-capable nodes.
449
+ ...(node?.isLocalWorktree === true ? ['converge=refine'] : ['converge=fast_forward']),
450
+ ]);
451
+ }
452
+
453
+ export function nodeSatisfiesRequiredTags(requiredTags: unknown, capabilityTags: unknown): boolean {
454
+ const required = normalizeMeshCapabilityTags(requiredTags);
455
+ if (required.length === 0) return true;
456
+ const available = new Set(normalizeMeshCapabilityTags(capabilityTags));
457
+ return required.every(tag => available.has(tag));
458
+ }
459
+
460
+ /**
461
+ * Convergence-aware required-tags resolution (load-balancing scheduler, opt-in).
462
+ *
463
+ * When the mesh enables policy.autoConvergeCodeChange, a `converge=refine` required
464
+ * tag is merged into a code_change task's required tags at enqueue time, so the
465
+ * scheduler hard-filters the task onto refine-capable worktree nodes only (on any
466
+ * machine — refine_mesh_node forwards to the owning daemon). Because the tag is
467
+ * persisted on the queue entry, BOTH the eligibility scan (maybeAutoLaunchOneQueueSession)
468
+ * and the claim transaction (claimNextQueueTask → nodeSatisfiesRequiredTags) enforce
469
+ * it consistently.
470
+ *
471
+ * Strict backward compatibility — the injection is skipped (returns the explicit tags
472
+ * unchanged) when ANY of:
473
+ * - the mesh does not opt in (autoConvergeCodeChange !== true), or
474
+ * - the task is not code_change (validation / live_debug_readonly / launch_app /
475
+ * convergence carry no merge cost and may run anywhere), or
476
+ * - the task is explicitly targeted (targetNodeId): the operator chose the node, so
477
+ * we do not second-guess it by filtering on convergence capability.
478
+ * Idempotent: normalizeMeshCapabilityTags dedupes, so re-injection is a no-op.
479
+ */
480
+ export function resolveConvergeRequiredTags(
481
+ meshId: string,
482
+ taskMode: MeshTaskMode | undefined,
483
+ explicitRequiredTags: string[],
484
+ opts?: { targetNodeId?: string },
485
+ ): string[] {
486
+ if (taskMode !== 'code_change') return explicitRequiredTags;
487
+ if (typeof opts?.targetNodeId === 'string' && opts.targetNodeId.trim()) return explicitRequiredTags;
488
+ let optedIn = false;
81
489
  try {
82
- const content = readFileSync(path, 'utf-8');
83
- return JSON.parse(content) as MeshWorkQueueEntry[];
490
+ optedIn = resolveAutoConvergeCodeChange(getMesh(meshId)?.policy as any);
84
491
  } catch {
85
- return [];
492
+ optedIn = false;
86
493
  }
494
+ if (!optedIn) return explicitRequiredTags;
495
+ return normalizeMeshCapabilityTags([...explicitRequiredTags, MESH_CONVERGE_REFINE_TAG]);
496
+ }
497
+
498
+ function withQueueLock<T>(_meshId: string, fn: () => T): T {
499
+ return MeshRuntimeStore.getInstance().transaction(fn);
500
+ }
501
+
502
+ function readQueue(meshId: string): MeshWorkQueueEntry[] {
503
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId);
87
504
  }
88
505
 
89
506
  function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
90
- const path = getQueuePath(meshId);
91
- writeFileSync(path, JSON.stringify(queue, null, 2), 'utf-8');
507
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
508
+ }
509
+
510
+ function normalizeDependsOn(value: unknown): string[] {
511
+ if (!Array.isArray(value)) return [];
512
+ const seen = new Set<string>();
513
+ return value
514
+ .map(id => typeof id === 'string' ? id.trim() : '')
515
+ .filter(Boolean)
516
+ .filter(id => {
517
+ if (seen.has(id)) return false;
518
+ seen.add(id);
519
+ return true;
520
+ });
521
+ }
522
+
523
+ /**
524
+ * M1: detect dependency cycles before enqueue. Walks the dependency graph of
525
+ * existing queue entries plus the new task's edges. Fail-closed: a cycle
526
+ * rejects the enqueue entirely. Synchronous and bounded by queue size.
527
+ */
528
+ export function assertNoDependencyCycle(meshId: string, newTaskId: string, dependsOn: string[]): void {
529
+ if (dependsOn.length === 0) return;
530
+ if (dependsOn.includes(newTaskId)) {
531
+ throw new Error(`dependency_cycle_detected: task '${newTaskId}' cannot depend on itself`);
532
+ }
533
+ const adjacency = new Map<string, string[]>();
534
+ for (const entry of readQueue(meshId)) {
535
+ adjacency.set(entry.id, normalizeDependsOn(entry.dependsOn));
536
+ }
537
+ adjacency.set(newTaskId, dependsOn);
538
+ // DFS from the new task: if we can reach newTaskId again, the edges form a cycle.
539
+ const stack = [...dependsOn];
540
+ const visited = new Set<string>();
541
+ while (stack.length > 0) {
542
+ const current = stack.pop()!;
543
+ if (current === newTaskId) {
544
+ throw new Error(`dependency_cycle_detected: task '${newTaskId}' is part of a dependency cycle via '${dependsOn.join(', ')}'`);
545
+ }
546
+ if (visited.has(current)) continue;
547
+ visited.add(current);
548
+ stack.push(...(adjacency.get(current) ?? []));
549
+ }
92
550
  }
93
551
 
94
552
  /**
@@ -97,22 +555,120 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
97
555
  export function enqueueTask(
98
556
  meshId: string,
99
557
  message: string,
100
- opts?: { targetNodeId?: string; targetSessionId?: string }
558
+ opts?: {
559
+ targetNodeId?: string;
560
+ targetSessionId?: string;
561
+ taskMode?: MeshTaskMode | string;
562
+ requiredTags?: string[];
563
+ /** M1: tasks that must complete before this one is claimable. */
564
+ dependsOn?: string[];
565
+ /** M1/M3: mission this task belongs to. */
566
+ missionId?: string;
567
+ /** Explicit task id for batch/template flows (M5). Random UUID when omitted. */
568
+ id?: string;
569
+ } & MeshQueueMutationOptions,
101
570
  ): MeshWorkQueueEntry {
571
+ requireMeshHostQueueOwner(opts);
572
+ const modeValidation = validateMeshTaskModeRequest(opts?.taskMode, message);
573
+ if (!modeValidation.valid) {
574
+ throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
575
+ }
576
+ const id = typeof opts?.id === 'string' && opts.id.trim() ? opts.id.trim() : randomUUID();
577
+ const dependsOn = normalizeDependsOn(opts?.dependsOn);
102
578
  return withQueueLock(meshId, () => {
103
- const queue = readQueue(meshId);
579
+ if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, id)) {
580
+ throw new Error(`duplicate_task_id: task '${id}' already exists in mesh '${meshId}'`);
581
+ }
582
+ assertNoDependencyCycle(meshId, id, dependsOn);
583
+ const callerTags = normalizeMeshCapabilityTags(opts?.requiredTags);
584
+ // Convergence routing (opt-in): auto-inject converge=refine for code_change
585
+ // tasks so they hard-filter onto refine-capable worktree nodes. No-op unless
586
+ // the mesh opts in; explicit target_node_id / required_tags are preserved.
587
+ // Routing is otherwise governed solely by the caller's required_tags (hard
588
+ // filter through nodeSatisfiesRequiredTags) — no role/taskMode auto-routing.
589
+ const resolvedRequiredTags = resolveConvergeRequiredTags(
590
+ meshId,
591
+ modeValidation.taskMode,
592
+ callerTags,
593
+ { targetNodeId: opts?.targetNodeId },
594
+ );
104
595
  const entry: MeshWorkQueueEntry = {
105
- id: randomUUID(),
596
+ id,
106
597
  meshId,
107
598
  message,
108
599
  status: 'pending',
600
+ taskMode: modeValidation.taskMode,
109
601
  targetNodeId: opts?.targetNodeId,
110
602
  targetSessionId: opts?.targetSessionId,
603
+ requiredTags: resolvedRequiredTags,
604
+ ...(dependsOn.length > 0 ? { dependsOn } : {}),
605
+ ...(typeof opts?.missionId === 'string' && opts.missionId.trim() ? { missionId: opts.missionId.trim() } : {}),
111
606
  createdAt: new Date().toISOString(),
112
607
  updatedAt: new Date().toISOString(),
113
608
  };
114
- queue.push(entry);
115
- writeQueue(meshId, queue);
609
+ MeshRuntimeStore.getInstance().insertQueueEntry(entry);
610
+ return entry;
611
+ });
612
+ }
613
+
614
+ /**
615
+ * Record a direct-dispatch task (mesh_send_task) as an already-assigned queue
616
+ * entry so it is attributable to a mission.
617
+ *
618
+ * Direct dispatch normally bypasses the queue entirely — the task lives only in
619
+ * the ledger + mesh_direct_dispatches table, neither of which carries a
620
+ * missionId, so {@link summarizeMissionTasks}/{@link computeMeshTaskStats}
621
+ * (which both scan the queue for `task.missionId`) count it as 0. When a
622
+ * mission is attached, we materialise the same queue entry shape an enqueued
623
+ * task would have, but pre-assigned to the dispatched node/session and stamped
624
+ * with the dispatch timestamp. The terminal event path (updateSessionTaskStatus
625
+ * → findAssignedBySession) then flips it to completed/failed exactly like a
626
+ * pulled task, so mission total + completed aggregates work with no extra wiring.
627
+ *
628
+ * Intentionally separate from {@link enqueueTask}: enqueue creates `pending`
629
+ * work for the queue to assign, whereas this records work already dispatched
630
+ * out-of-band. They share the missionId stamping rule and mode validation.
631
+ */
632
+ export function recordDirectDispatchTask(
633
+ meshId: string,
634
+ message: string,
635
+ opts: {
636
+ id: string;
637
+ missionId: string;
638
+ assignedNodeId?: string;
639
+ assignedSessionId?: string;
640
+ taskMode?: MeshTaskMode | string;
641
+ dispatchedAt?: string;
642
+ },
643
+ ): MeshWorkQueueEntry | null {
644
+ const missionId = typeof opts.missionId === 'string' ? opts.missionId.trim() : '';
645
+ if (!missionId) return null;
646
+ const taskId = typeof opts.id === 'string' ? opts.id.trim() : '';
647
+ if (!taskId) return null;
648
+ const modeValidation = validateMeshTaskModeRequest(opts.taskMode, message);
649
+ if (!modeValidation.valid) {
650
+ throw new Error(`live_debug_readonly_guardrail_violation: forbidden operations (${modeValidation.violations.join(', ')})`);
651
+ }
652
+ const now = opts.dispatchedAt && opts.dispatchedAt.trim() ? opts.dispatchedAt : new Date().toISOString();
653
+ return withQueueLock(meshId, () => {
654
+ if (MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId)) {
655
+ // Already materialised (e.g. retry of the same dispatch) — leave it untouched.
656
+ return null;
657
+ }
658
+ const entry: MeshWorkQueueEntry = {
659
+ id: taskId,
660
+ meshId,
661
+ message,
662
+ status: 'assigned',
663
+ ...(modeValidation.taskMode ? { taskMode: modeValidation.taskMode } : {}),
664
+ missionId,
665
+ ...(opts.assignedNodeId ? { targetNodeId: opts.assignedNodeId, assignedNodeId: opts.assignedNodeId } : {}),
666
+ ...(opts.assignedSessionId ? { targetSessionId: opts.assignedSessionId, assignedSessionId: opts.assignedSessionId } : {}),
667
+ dispatchTimestamp: now,
668
+ createdAt: now,
669
+ updatedAt: now,
670
+ };
671
+ MeshRuntimeStore.getInstance().insertQueueEntry(entry);
116
672
  return entry;
117
673
  });
118
674
  }
@@ -121,43 +677,83 @@ export function enqueueTask(
121
677
  * Get all tasks in the queue, optionally filtered by status.
122
678
  */
123
679
  export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }): MeshWorkQueueEntry[] {
124
- let queue = readQueue(meshId);
125
- if (opts?.status?.length) {
126
- const statuses = new Set(opts.status);
127
- queue = queue.filter(q => statuses.has(q.status));
128
- }
129
- return queue;
680
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId, opts?.status?.length ? opts.status : undefined);
681
+ }
682
+
683
+ export function getMeshQueueRevision(meshId: string): string {
684
+ return MeshRuntimeStore.getInstance().getQueueRevision(meshId);
130
685
  }
131
686
 
132
687
  /**
133
688
  * Find the next pending task that this node is allowed to claim, and mark it as assigned.
689
+ *
690
+ * `opts.providerType` is stamped onto the claimed entry (assignedProviderType) so
691
+ * per-(node, provider) caps can be counted. `opts.providerMaxParallel`, when set,
692
+ * is the enforced per-(node, provider) cap from RepoMeshNodePolicy.providerRoles:
693
+ * a task is not assigned to this (node, provider) once it already has that many
694
+ * active assignments. This composes with the global/taskMode caps (stricter wins).
134
695
  */
135
- export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
136
- return withQueueLock(meshId, () => {
137
- const queue = readQueue(meshId);
138
- const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
139
- q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
140
- ));
141
- if (hasActiveAssignment) return null;
142
- let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
143
- if (targetIdx === -1) {
144
- targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
145
- }
146
- if (targetIdx === -1) {
147
- targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
696
+ export function claimNextTask(
697
+ meshId: string,
698
+ nodeId: string,
699
+ sessionId: string,
700
+ capabilityTags?: string[],
701
+ opts?: { providerType?: string; providerMaxParallel?: number },
702
+ ): MeshWorkQueueEntry | null {
703
+ return MeshRuntimeStore.getInstance().claimNextQueueTask(meshId, nodeId, sessionId, capabilityTags, opts);
704
+ }
705
+
706
+ // ─── M1: Dependency Failure Propagation ─────────
707
+
708
+ export type DependencyFailurePolicy = 'block' | 'cancel';
709
+
710
+ function resolveDependencyFailurePolicy(meshId: string): DependencyFailurePolicy {
711
+ try {
712
+ const policy = (getMesh(meshId)?.policy ?? {}) as Record<string, unknown>;
713
+ return policy.onDependencyFailure === 'cancel' ? 'cancel' : 'block';
714
+ } catch {
715
+ return 'block';
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Apply the mesh's onDependencyFailure policy to pending dependents of a task
721
+ * that just reached a failed/cancelled terminal state.
722
+ *
723
+ * - 'block' (default): dependents stay pending with blockedReason
724
+ * "dependency_failed:<taskId>" so an operator can requeue/cancel them.
725
+ * - 'cancel': dependents are cancelled (cascading to their own dependents).
726
+ *
727
+ * Must be called inside the queue lock of the triggering transition.
728
+ */
729
+ function propagateDependencyFailure(meshId: string, failedTaskId: string): void {
730
+ const policy = resolveDependencyFailurePolicy(meshId);
731
+ const store = MeshRuntimeStore.getInstance();
732
+ const frontier = [failedTaskId];
733
+ const seen = new Set<string>(frontier);
734
+ while (frontier.length > 0) {
735
+ const currentId = frontier.pop()!;
736
+ const dependents = store.getQueueEntries(meshId, ['pending'])
737
+ .filter(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(currentId));
738
+ for (const dependent of dependents) {
739
+ if (seen.has(dependent.id)) continue;
740
+ seen.add(dependent.id);
741
+ if (policy === 'cancel') {
742
+ dependent.status = 'cancelled';
743
+ dependent.cancelledAt = new Date().toISOString();
744
+ dependent.cancelReason = `dependency_failed:${currentId}`;
745
+ store.updateQueueEntry(dependent);
746
+ frontier.push(dependent.id); // cascade to transitive dependents
747
+ } else {
748
+ dependent.blockedReason = `dependency_failed:${currentId}`;
749
+ store.updateQueueEntry(dependent);
750
+ }
148
751
  }
149
- if (targetIdx === -1) return null;
150
- const entry = queue[targetIdx];
151
- entry.status = 'assigned';
152
- entry.assignedNodeId = nodeId;
153
- entry.assignedSessionId = sessionId;
154
- entry.dispatchTimestamp = new Date().toISOString();
155
- entry.updatedAt = new Date().toISOString();
156
- writeQueue(meshId, queue);
157
- return entry;
158
- });
752
+ }
159
753
  }
160
754
 
755
+ const DEPENDENCY_FAILURE_TERMINALS = new Set<MeshTaskStatus>(['failed', 'cancelled']);
756
+
161
757
  /**
162
758
  * Update the status of a specific task.
163
759
  * Used when a session completes, fails, or stalls.
@@ -166,15 +762,16 @@ export function updateTaskStatus(
166
762
  meshId: string,
167
763
  taskId: string,
168
764
  status: MeshTaskStatus,
765
+ opts?: MeshQueueMutationOptions,
169
766
  ): MeshWorkQueueEntry | null {
767
+ requireMeshHostQueueOwner(opts);
170
768
  return withQueueLock(meshId, () => {
171
- const queue = readQueue(meshId);
172
- const idx = queue.findIndex(q => q.id === taskId);
173
- if (idx === -1) return null;
174
- queue[idx].status = status;
175
- queue[idx].updatedAt = new Date().toISOString();
176
- writeQueue(meshId, queue);
177
- return queue[idx];
769
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
770
+ if (!entry) return null;
771
+ entry.status = status;
772
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
773
+ if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, taskId);
774
+ return entry;
178
775
  });
179
776
  }
180
777
 
@@ -184,14 +781,12 @@ export function recordTaskAutoLaunch(
184
781
  autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
185
782
  ): MeshWorkQueueEntry | null {
186
783
  return withQueueLock(meshId, () => {
187
- const queue = readQueue(meshId);
188
- const idx = queue.findIndex(q => q.id === taskId);
189
- if (idx === -1) return null;
784
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
785
+ if (!entry) return null;
190
786
  const now = new Date().toISOString();
191
- queue[idx].autoLaunch = { ...autoLaunch, updatedAt: now };
192
- queue[idx].updatedAt = now;
193
- writeQueue(meshId, queue);
194
- return queue[idx];
787
+ entry.autoLaunch = { ...autoLaunch, updatedAt: now };
788
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
789
+ return entry;
195
790
  });
196
791
  }
197
792
 
@@ -201,19 +796,19 @@ export function recordTaskAutoLaunch(
201
796
  export function cancelTask(
202
797
  meshId: string,
203
798
  taskId: string,
204
- opts?: { reason?: string },
799
+ opts?: { reason?: string } & MeshQueueMutationOptions,
205
800
  ): MeshWorkQueueEntry | null {
801
+ requireMeshHostQueueOwner(opts);
206
802
  return withQueueLock(meshId, () => {
207
- const queue = readQueue(meshId);
208
- const idx = queue.findIndex(q => q.id === taskId);
209
- if (idx === -1) return null;
803
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
804
+ if (!entry) return null;
210
805
  const now = new Date().toISOString();
211
- queue[idx].status = 'cancelled';
212
- queue[idx].updatedAt = now;
213
- queue[idx].cancelledAt = now;
214
- if (opts?.reason) queue[idx].cancelReason = opts.reason;
215
- writeQueue(meshId, queue);
216
- return queue[idx];
806
+ entry.status = 'cancelled';
807
+ entry.cancelledAt = now;
808
+ if (opts?.reason) entry.cancelReason = opts.reason;
809
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
810
+ propagateDependencyFailure(meshId, taskId);
811
+ return entry;
217
812
  });
218
813
  }
219
814
 
@@ -221,6 +816,11 @@ export function cancelTask(
221
816
  * Return a queue task to pending for retry. By default, dead session targeting
222
817
  * and assigned ownership are cleared so stale assignments do not strand again.
223
818
  */
819
+ export type RequeueResult =
820
+ | { status: 'requeued'; entry: MeshWorkQueueEntry }
821
+ | { status: 'failed_max_retries'; entry: MeshWorkQueueEntry; maxRetries: number; requeueCount: number }
822
+ | { status: 'not_found' };
823
+
224
824
  export function requeueTask(
225
825
  meshId: string,
226
826
  taskId: string,
@@ -230,15 +830,34 @@ export function requeueTask(
230
830
  targetSessionId?: string;
231
831
  clearTargetNode?: boolean;
232
832
  clearTargetSession?: boolean;
233
- },
833
+ /**
834
+ * Override the retry cap for this call. Use only for explicit operator actions.
835
+ * If true, the task is requeued even when requeueCount >= maxRetries.
836
+ */
837
+ force?: boolean;
838
+ /** Per-task retry cap override. Falls back to mesh policy maxTaskRetries (default 1). */
839
+ maxRetries?: number;
840
+ } & MeshQueueMutationOptions,
234
841
  ): MeshWorkQueueEntry | null {
842
+ requireMeshHostQueueOwner(opts);
235
843
  return withQueueLock(meshId, () => {
236
- const queue = readQueue(meshId);
237
- const idx = queue.findIndex(q => q.id === taskId);
238
- if (idx === -1) return null;
239
- const entry = queue[idx];
240
- const now = new Date().toISOString();
844
+ const entry = MeshRuntimeStore.getInstance().findQueueEntryById(meshId, taskId);
845
+ if (!entry) return null;
846
+ const currentCount = entry.requeueCount || 0;
847
+ const maxRetries = opts?.maxRetries ?? entry.maxRetries ?? 1;
848
+ if (!opts?.force && currentCount >= maxRetries) {
849
+ // Auto-fail: cap exceeded without explicit force override.
850
+ entry.status = 'failed';
851
+ entry.cancelReason = `max_retries_exceeded: requeued ${currentCount} time(s), limit is ${maxRetries}`;
852
+ entry.updatedAt = new Date().toISOString();
853
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
854
+ propagateDependencyFailure(meshId, taskId);
855
+ return entry;
856
+ }
241
857
  entry.status = 'pending';
858
+ // Operator requeue clears a dependency-failure block — the operator is
859
+ // explicitly overriding the held-back state.
860
+ delete entry.blockedReason;
242
861
  delete entry.assignedNodeId;
243
862
  delete entry.assignedSessionId;
244
863
  delete entry.cancelledAt;
@@ -247,11 +866,10 @@ export function requeueTask(
247
866
  if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
248
867
  if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
249
868
  if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
250
- entry.updatedAt = now;
251
- entry.requeuedAt = now;
252
- entry.requeueCount = (entry.requeueCount || 0) + 1;
869
+ entry.requeuedAt = new Date().toISOString();
870
+ entry.requeueCount = currentCount + 1;
253
871
  if (opts?.reason) entry.requeueReason = opts.reason;
254
- writeQueue(meshId, queue);
872
+ MeshRuntimeStore.getInstance().updateQueueEntry(entry);
255
873
  return entry;
256
874
  });
257
875
  }
@@ -263,28 +881,58 @@ export function updateSessionTaskStatus(
263
881
  meshId: string,
264
882
  sessionId: string,
265
883
  status: MeshTaskStatus,
266
- opts?: { occurredAt?: string },
884
+ opts?: { occurredAt?: string; taskId?: string },
267
885
  ): MeshWorkQueueEntry | null {
268
886
  return withQueueLock(meshId, () => {
269
- const queue = readQueue(meshId);
270
- const occurredAtTime = opts?.occurredAt ? new Date(opts.occurredAt).getTime() : Number.NaN;
271
- const hasOccurredAt = Number.isFinite(occurredAtTime);
272
- let bestIdx = -1;
273
- let bestTime = 0;
274
- for (let i = queue.length - 1; i >= 0; i--) {
275
- if (queue[i].assignedSessionId !== sessionId || queue[i].status !== 'assigned') continue;
276
- const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
277
- if (hasOccurredAt && Number.isFinite(time) && time > occurredAtTime) continue;
278
- if (time > bestTime) { bestTime = time; bestIdx = i; }
887
+ const store = MeshRuntimeStore.getInstance();
888
+ const occurredAtIso = opts?.occurredAt ? new Date(opts.occurredAt).toISOString() : undefined;
889
+ const entry = store.findAssignedBySession(meshId, sessionId, occurredAtIso, opts?.taskId);
890
+ if (!entry) {
891
+ // C2: the silent null here is exactly what stranded a finished task as
892
+ // `assigned` for 19 minutes. If the session still has an assigned row we
893
+ // failed to resolve, surface it loudly instead of dropping the completion.
894
+ const assignedRows = store.getActiveAssignmentDetails(meshId)
895
+ .filter(r => r.sessionId === sessionId);
896
+ if (assignedRows.length > 0) {
897
+ LOG.warn('MeshQueue', `No assigned queue row matched completion for mesh ${meshId} session ${sessionId} `
898
+ + `(taskId=${opts?.taskId ?? 'none'}, occurredAt=${occurredAtIso ?? 'none'}); `
899
+ + `${assignedRows.length} assigned row(s) exist: ${assignedRows.map(r => r.id).join(',')}`);
900
+ }
901
+ return null;
279
902
  }
280
- if (bestIdx === -1) return null;
281
- queue[bestIdx].status = status;
282
- queue[bestIdx].updatedAt = new Date().toISOString();
283
- writeQueue(meshId, queue);
284
- return queue[bestIdx];
903
+ entry.status = status;
904
+ store.updateQueueEntry(entry);
905
+ if (DEPENDENCY_FAILURE_TERMINALS.has(status)) propagateDependencyFailure(meshId, entry.id);
906
+ return entry;
285
907
  });
286
908
  }
287
909
 
910
+ /**
911
+ * M1-3: true when at least one pending task is waiting on the given task.
912
+ * Used by the completion event path to decide whether to wake the queue.
913
+ */
914
+ export function hasPendingDependents(meshId: string, taskId: string): boolean {
915
+ return MeshRuntimeStore.getInstance().getQueueEntries(meshId, ['pending'])
916
+ .some(entry => Array.isArray(entry.dependsOn) && entry.dependsOn.includes(taskId));
917
+ }
918
+
919
+ /**
920
+ * M1-4: view-time dependency state for a task — unmet dependency ids and
921
+ * whether the task is currently claimable from a dependency standpoint.
922
+ * Not stored (truth stays in task statuses).
923
+ */
924
+ export function describeTaskDependencyState(
925
+ entry: Pick<MeshWorkQueueEntry, 'dependsOn' | 'blockedReason'>,
926
+ statusById: Map<string, MeshTaskStatus | string>,
927
+ ): { waitingOn: string[]; dependenciesSatisfied: boolean } {
928
+ const deps = Array.isArray(entry.dependsOn) ? entry.dependsOn : [];
929
+ const waitingOn = deps.filter(depId => statusById.get(depId) !== 'completed');
930
+ return {
931
+ waitingOn,
932
+ dependenciesSatisfied: waitingOn.length === 0 && !entry.blockedReason,
933
+ };
934
+ }
935
+
288
936
  export interface MeshWorkQueueStats {
289
937
  total: number;
290
938
  active: number;
@@ -310,14 +958,16 @@ export interface MeshWorkQueueStats {
310
958
  * Return aggregate queue statistics for the given mesh.
311
959
  */
312
960
  export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
313
- const queue = readQueue(meshId);
314
- const pending = queue.filter(q => q.status === 'pending').length;
315
- const assigned = queue.filter(q => q.status === 'assigned').length;
316
- const completed = queue.filter(q => q.status === 'completed').length;
317
- const failed = queue.filter(q => q.status === 'failed').length;
318
- const cancelled = queue.filter(q => q.status === 'cancelled').length;
961
+ const rows = MeshRuntimeStore.getInstance().getQueueStatsByStatus(meshId);
962
+ const counts: Record<string, number> = {};
963
+ for (const r of rows) counts[r.status] = r.count;
964
+ const pending = counts['pending'] ?? 0;
965
+ const assigned = counts['assigned'] ?? 0;
966
+ const completed = counts['completed'] ?? 0;
967
+ const failed = counts['failed'] ?? 0;
968
+ const cancelled = counts['cancelled'] ?? 0;
319
969
  return {
320
- total: queue.length,
970
+ total: pending + assigned + completed + failed + cancelled,
321
971
  active: pending + assigned,
322
972
  historical: completed + failed + cancelled,
323
973
  pending,
@@ -325,22 +975,119 @@ export function getMeshQueueStats(meshId: string): MeshWorkQueueStats {
325
975
  completed,
326
976
  failed,
327
977
  cancelled,
328
- activeCounts: {
329
- pending,
330
- assigned,
331
- },
332
- historicalCounts: {
333
- completed,
334
- failed,
335
- cancelled,
336
- },
337
- activeAssignments: queue
338
- .filter(q => q.status === 'assigned')
339
- .map(q => ({
340
- id: q.id,
341
- nodeId: q.assignedNodeId,
342
- sessionId: q.assignedSessionId,
343
- message: q.message,
344
- })),
978
+ activeCounts: { pending, assigned },
979
+ historicalCounts: { completed, failed, cancelled },
980
+ activeAssignments: MeshRuntimeStore.getInstance().getActiveAssignmentDetails(meshId),
345
981
  };
346
982
  }
983
+
984
+ export function __replaceMeshQueueForTests(meshId: string, queue: MeshWorkQueueEntry[]): void {
985
+ MeshRuntimeStore.getInstance().transaction(() => {
986
+ MeshRuntimeStore.getInstance().replaceQueue(meshId, queue);
987
+ });
988
+ }
989
+
990
+ export function __clearMeshQueueForTests(meshId: string): void {
991
+ MeshRuntimeStore.getInstance().deleteQueue(meshId);
992
+ }
993
+
994
+ export function __clearDirectDispatchesForTests(meshId: string): void {
995
+ MeshRuntimeStore.getInstance().deleteDirectDispatches(meshId);
996
+ }
997
+
998
+ export function __resetMeshRuntimeStoreForTests(): void {
999
+ MeshRuntimeStore.resetForTests();
1000
+ }
1001
+
1002
+ // ── Direct Dispatch Tracking ─────────────────────────────────────────────────
1003
+ // Persists direct (non-queue) task dispatches so buildMeshActiveWork can read
1004
+ // active work from MeshRuntimeStore instead of scanning ledger JSONL entries.
1005
+
1006
+ export type DirectDispatchRecord = ReturnType<MeshRuntimeStore['getActiveDirectDispatches']>[number];
1007
+
1008
+ export function insertDirectDispatch(
1009
+ meshId: string,
1010
+ data: {
1011
+ taskId: string;
1012
+ nodeId?: string;
1013
+ sessionId?: string;
1014
+ providerType?: string;
1015
+ message: string;
1016
+ taskMode?: string;
1017
+ via: string;
1018
+ dispatchedToIdleSession?: boolean;
1019
+ dispatchedAt: string;
1020
+ },
1021
+ ): void {
1022
+ try {
1023
+ MeshRuntimeStore.getInstance().insertDirectDispatch({ ...data, meshId });
1024
+ } catch (e: any) {
1025
+ process.stderr.write(`[adhdev-mesh] insertDirectDispatch failed for task ${data.taskId}: ${e?.message || e}\n`);
1026
+ }
1027
+ }
1028
+
1029
+ export function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[] {
1030
+ try {
1031
+ return MeshRuntimeStore.getInstance().getActiveDirectDispatches(meshId);
1032
+ } catch {
1033
+ return [];
1034
+ }
1035
+ }
1036
+
1037
+ export function updateDirectDispatchStatus(
1038
+ meshId: string,
1039
+ sessionId: string,
1040
+ status: 'acked' | 'completed' | 'failed' | 'stale',
1041
+ ): void {
1042
+ try {
1043
+ MeshRuntimeStore.getInstance().updateDirectDispatchStatus(meshId, sessionId, status);
1044
+ } catch { /* best-effort */ }
1045
+ }
1046
+
1047
+ export function cleanupTerminalDirectDispatches(olderThanMs = 7 * 24 * 60 * 60_000): void {
1048
+ try {
1049
+ MeshRuntimeStore.getInstance().cleanupTerminalDirectDispatches(olderThanMs);
1050
+ } catch { /* best-effort */ }
1051
+ }
1052
+
1053
+ export function markStaleDirectDispatches(meshId: string, olderThanMs = 60 * 60_000): void {
1054
+ try {
1055
+ MeshRuntimeStore.getInstance().markStaleDirectDispatches(meshId, olderThanMs);
1056
+ } catch { /* best-effort */ }
1057
+ }
1058
+
1059
+ /**
1060
+ * Delete specific direct dispatch rows by taskId. Returns the number of rows deleted.
1061
+ * Used by the staleDirect prune path to evict orphaned/terminal dispatch records from the
1062
+ * active staleDirect surface while leaving the append-only mesh ledger (audit history) intact.
1063
+ */
1064
+ export function deleteDirectDispatchesByTaskId(meshId: string, taskIds: string[]): number {
1065
+ try {
1066
+ return MeshRuntimeStore.getInstance().deleteDirectDispatchesByTaskId(meshId, taskIds);
1067
+ } catch {
1068
+ return 0;
1069
+ }
1070
+ }
1071
+
1072
+ export type MeshToolCallRateResult = { rateLimitExceeded: boolean; callsInWindow: number; advisory: string | null };
1073
+
1074
+ /**
1075
+ * Record a coordinator tool call and return a rate-limit advisory when the
1076
+ * call rate for that tool exceeds the allowed threshold.
1077
+ *
1078
+ * Defaults: 10-second sliding window, max 5 calls before advisory is raised.
1079
+ * Returns { rateLimitExceeded: false } on any store error so callers are not blocked.
1080
+ */
1081
+ export function recordMeshToolCall(opts: {
1082
+ meshId: string;
1083
+ tool: string;
1084
+ sessionId?: string | null;
1085
+ windowMs?: number;
1086
+ maxCalls?: number;
1087
+ }): MeshToolCallRateResult {
1088
+ try {
1089
+ return MeshRuntimeStore.getInstance().recordMeshToolCall(opts);
1090
+ } catch {
1091
+ return { rateLimitExceeded: false, callsInWindow: 0, advisory: null };
1092
+ }
1093
+ }