@swarmclawai/swarmclaw 0.8.4 → 0.8.7

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 (394) hide show
  1. package/README.md +9 -9
  2. package/bin/swarmclaw.js +5 -1
  3. package/bin/worker-cmd.js +73 -0
  4. package/package.json +2 -1
  5. package/src/app/api/agents/[id]/route.ts +17 -7
  6. package/src/app/api/agents/route.ts +21 -8
  7. package/src/app/api/approvals/route.test.ts +6 -6
  8. package/src/app/api/approvals/route.ts +2 -1
  9. package/src/app/api/auth/route.ts +2 -3
  10. package/src/app/api/chatrooms/[id]/chat/route.test.ts +299 -0
  11. package/src/app/api/chatrooms/[id]/chat/route.ts +3 -2
  12. package/src/app/api/chatrooms/[id]/route.ts +7 -6
  13. package/src/app/api/chats/[id]/chat/route.test.ts +496 -0
  14. package/src/app/api/chats/[id]/chat/route.ts +7 -3
  15. package/src/app/api/chats/[id]/clear/route.ts +9 -9
  16. package/src/app/api/chats/[id]/devserver/route.ts +2 -1
  17. package/src/app/api/chats/[id]/edit-resend/route.ts +3 -4
  18. package/src/app/api/chats/[id]/fork/route.ts +3 -5
  19. package/src/app/api/chats/[id]/restore/route.ts +6 -7
  20. package/src/app/api/chats/[id]/retry/route.ts +3 -4
  21. package/src/app/api/chats/[id]/route.ts +61 -62
  22. package/src/app/api/chats/route.ts +7 -1
  23. package/src/app/api/connectors/[id]/route.ts +7 -8
  24. package/src/app/api/connectors/route.ts +5 -4
  25. package/src/app/api/eval/run/route.ts +2 -1
  26. package/src/app/api/eval/suite/route.ts +2 -1
  27. package/src/app/api/external-agents/route.test.ts +1 -1
  28. package/src/app/api/external-agents/route.ts +2 -2
  29. package/src/app/api/files/serve/route.ts +1 -1
  30. package/src/app/api/gateways/[id]/route.ts +7 -5
  31. package/src/app/api/gateways/route.ts +1 -1
  32. package/src/app/api/knowledge/upload/route.ts +1 -1
  33. package/src/app/api/logs/route.ts +5 -7
  34. package/src/app/api/memory-images/[filename]/route.ts +2 -3
  35. package/src/app/api/openclaw/agent-files/route.ts +4 -3
  36. package/src/app/api/openclaw/approvals/route.ts +3 -4
  37. package/src/app/api/openclaw/config-sync/route.ts +3 -2
  38. package/src/app/api/openclaw/cron/route.ts +3 -2
  39. package/src/app/api/openclaw/dotenv-keys/route.ts +2 -1
  40. package/src/app/api/openclaw/exec-config/route.ts +3 -2
  41. package/src/app/api/openclaw/gateway/route.ts +5 -4
  42. package/src/app/api/openclaw/history/route.ts +3 -2
  43. package/src/app/api/openclaw/media/route.ts +2 -1
  44. package/src/app/api/openclaw/permissions/route.ts +3 -2
  45. package/src/app/api/openclaw/sandbox-env/route.ts +3 -2
  46. package/src/app/api/openclaw/skills/install/route.ts +2 -1
  47. package/src/app/api/openclaw/skills/remove/route.ts +2 -1
  48. package/src/app/api/openclaw/skills/route.ts +3 -2
  49. package/src/app/api/orchestrator/run/route.ts +5 -14
  50. package/src/app/api/perf/route.ts +43 -0
  51. package/src/app/api/plugins/dependencies/route.ts +2 -1
  52. package/src/app/api/plugins/install/route.ts +2 -1
  53. package/src/app/api/plugins/marketplace/route.ts +3 -2
  54. package/src/app/api/plugins/settings/route.ts +2 -1
  55. package/src/app/api/preview-server/route.ts +11 -10
  56. package/src/app/api/projects/[id]/route.ts +1 -1
  57. package/src/app/api/schedules/[id]/route.test.ts +128 -0
  58. package/src/app/api/schedules/[id]/route.ts +43 -43
  59. package/src/app/api/schedules/[id]/run/route.ts +11 -62
  60. package/src/app/api/schedules/route.ts +21 -87
  61. package/src/app/api/settings/route.ts +2 -0
  62. package/src/app/api/setup/doctor/route.ts +9 -8
  63. package/src/app/api/tasks/[id]/approve/route.ts +33 -30
  64. package/src/app/api/tasks/[id]/route.ts +12 -35
  65. package/src/app/api/tasks/import/github/route.ts +2 -1
  66. package/src/app/api/tasks/route.ts +79 -91
  67. package/src/app/api/wallets/[id]/approve/route.ts +2 -1
  68. package/src/app/api/wallets/[id]/route.ts +13 -19
  69. package/src/app/api/wallets/[id]/send/route.ts +2 -1
  70. package/src/app/api/wallets/route.ts +2 -1
  71. package/src/app/api/webhooks/[id]/route.ts +2 -1
  72. package/src/app/api/webhooks/route.test.ts +3 -1
  73. package/src/app/page.tsx +23 -331
  74. package/src/cli/index.js +19 -0
  75. package/src/cli/index.ts +38 -7
  76. package/src/cli/spec.js +9 -0
  77. package/src/components/activity/activity-feed.tsx +7 -4
  78. package/src/components/agents/agent-card.tsx +32 -6
  79. package/src/components/agents/agent-chat-list.tsx +55 -22
  80. package/src/components/agents/agent-files-editor.tsx +3 -2
  81. package/src/components/agents/agent-sheet.tsx +123 -22
  82. package/src/components/agents/inspector-panel.tsx +1 -1
  83. package/src/components/agents/openclaw-skills-panel.tsx +2 -1
  84. package/src/components/agents/trash-list.tsx +1 -1
  85. package/src/components/auth/access-key-gate.tsx +8 -2
  86. package/src/components/auth/setup-wizard.tsx +10 -9
  87. package/src/components/auth/user-picker.tsx +3 -2
  88. package/src/components/chat/chat-area.tsx +20 -1
  89. package/src/components/chat/chat-card.tsx +18 -3
  90. package/src/components/chat/chat-header.tsx +24 -4
  91. package/src/components/chat/chat-list.tsx +2 -11
  92. package/src/components/chat/heartbeat-history-panel.tsx +2 -1
  93. package/src/components/chat/message-bubble.tsx +45 -6
  94. package/src/components/chat/message-list.tsx +280 -145
  95. package/src/components/chat/streaming-bubble.tsx +217 -60
  96. package/src/components/chat/swarm-panel.test.ts +274 -0
  97. package/src/components/chat/swarm-panel.tsx +410 -0
  98. package/src/components/chat/swarm-status-card.tsx +346 -0
  99. package/src/components/chat/tool-call-bubble.tsx +48 -23
  100. package/src/components/chatrooms/chatroom-list.tsx +8 -5
  101. package/src/components/chatrooms/chatroom-message.tsx +10 -7
  102. package/src/components/chatrooms/chatroom-view.tsx +12 -9
  103. package/src/components/connectors/connector-health.tsx +6 -4
  104. package/src/components/connectors/connector-list.tsx +16 -11
  105. package/src/components/connectors/connector-sheet.tsx +12 -6
  106. package/src/components/home/home-view.tsx +38 -24
  107. package/src/components/input/chat-input.tsx +10 -1
  108. package/src/components/layout/app-layout.tsx +2 -38
  109. package/src/components/layout/sheet-layer.tsx +50 -0
  110. package/src/components/mcp-servers/mcp-server-list.tsx +37 -5
  111. package/src/components/mcp-servers/mcp-server-sheet.tsx +12 -2
  112. package/src/components/plugins/plugin-list.tsx +8 -4
  113. package/src/components/plugins/plugin-sheet.tsx +2 -1
  114. package/src/components/providers/provider-list.tsx +3 -2
  115. package/src/components/providers/provider-sheet.tsx +2 -1
  116. package/src/components/runs/run-list.tsx +11 -7
  117. package/src/components/schedules/schedule-card.tsx +5 -3
  118. package/src/components/shared/agent-switch-dialog.tsx +1 -1
  119. package/src/components/shared/attachment-chip.tsx +19 -3
  120. package/src/components/shared/notification-center.tsx +6 -3
  121. package/src/components/shared/settings/plugin-manager.tsx +3 -2
  122. package/src/components/shared/settings/section-embedding.tsx +2 -1
  123. package/src/components/shared/settings/section-orchestrator.tsx +2 -1
  124. package/src/components/shared/settings/section-user-preferences.tsx +107 -0
  125. package/src/components/shared/settings/settings-page.tsx +13 -9
  126. package/src/components/skills/clawhub-browser.tsx +15 -4
  127. package/src/components/skills/skill-list.tsx +15 -4
  128. package/src/components/tasks/approvals-panel.tsx +2 -1
  129. package/src/components/tasks/task-board.tsx +35 -37
  130. package/src/components/tasks/task-sheet.tsx +4 -3
  131. package/src/components/ui/full-screen-loader.tsx +164 -0
  132. package/src/components/wallets/wallet-approval-dialog.tsx +2 -1
  133. package/src/components/wallets/wallet-panel.tsx +6 -5
  134. package/src/components/wallets/wallet-section.tsx +3 -2
  135. package/src/components/webhooks/webhook-list.tsx +4 -5
  136. package/src/components/webhooks/webhook-sheet.tsx +6 -6
  137. package/src/hooks/use-app-bootstrap.ts +202 -0
  138. package/src/hooks/use-mounted-ref.ts +14 -0
  139. package/src/hooks/use-now.ts +31 -0
  140. package/src/hooks/use-openclaw-gateway.ts +2 -1
  141. package/src/instrumentation.ts +20 -8
  142. package/src/lib/agent-default-tools.test.ts +52 -0
  143. package/src/lib/agent-default-tools.ts +40 -0
  144. package/src/lib/api-client.test.ts +21 -0
  145. package/src/lib/api-client.ts +6 -11
  146. package/src/lib/canvas-content.test.ts +360 -0
  147. package/src/lib/chat-streaming-state.test.ts +49 -2
  148. package/src/lib/chat-streaming-state.ts +26 -10
  149. package/src/lib/fetch-timeout.test.ts +54 -0
  150. package/src/lib/fetch-timeout.ts +60 -3
  151. package/src/lib/live-tool-events.test.ts +77 -0
  152. package/src/lib/live-tool-events.ts +73 -0
  153. package/src/lib/local-observability.test.ts +2 -2
  154. package/src/lib/openclaw-endpoint.test.ts +1 -1
  155. package/src/lib/providers/anthropic.ts +12 -16
  156. package/src/lib/providers/index.ts +4 -2
  157. package/src/lib/providers/ollama.ts +9 -6
  158. package/src/lib/providers/openai.ts +11 -14
  159. package/src/lib/runtime-env.test.ts +8 -8
  160. package/src/lib/schedule-dedupe-advanced.test.ts +2 -2
  161. package/src/lib/schedule-dedupe.test.ts +1 -1
  162. package/src/lib/schedule-dedupe.ts +3 -2
  163. package/src/lib/server/agent-thread-session.test.ts +6 -6
  164. package/src/lib/server/agent-thread-session.ts +6 -9
  165. package/src/lib/server/alert-dispatch.ts +2 -1
  166. package/src/lib/server/api-routes.test.ts +6 -6
  167. package/src/lib/server/approval-connector-notify.test.ts +4 -4
  168. package/src/lib/server/approvals-auto-approve.test.ts +29 -29
  169. package/src/lib/server/approvals.test.ts +317 -0
  170. package/src/lib/server/approvals.ts +5 -4
  171. package/src/lib/server/autonomy-runtime.test.ts +11 -11
  172. package/src/lib/server/browser-state.ts +2 -2
  173. package/src/lib/server/capability-router.test.ts +1 -1
  174. package/src/lib/server/capability-router.ts +3 -2
  175. package/src/lib/server/chat-execution-advanced.test.ts +15 -2
  176. package/src/lib/server/chat-execution-connector-delivery.ts +67 -0
  177. package/src/lib/server/chat-execution-disabled.test.ts +3 -3
  178. package/src/lib/server/chat-execution-eval-history.test.ts +3 -3
  179. package/src/lib/server/chat-execution-heartbeat.test.ts +42 -1
  180. package/src/lib/server/chat-execution-session-sync.test.ts +119 -0
  181. package/src/lib/server/chat-execution-tool-events.ts +116 -0
  182. package/src/lib/server/chat-execution-utils.test.ts +479 -0
  183. package/src/lib/server/chat-execution-utils.ts +533 -0
  184. package/src/lib/server/chat-execution.ts +153 -748
  185. package/src/lib/server/chat-streaming-utils.ts +174 -0
  186. package/src/lib/server/chat-turn-tool-routing.ts +310 -0
  187. package/src/lib/server/chatroom-session-persistence.test.ts +2 -2
  188. package/src/lib/server/clawhub-client.ts +2 -1
  189. package/src/lib/server/collection-helpers.test.ts +92 -0
  190. package/src/lib/server/collection-helpers.ts +25 -3
  191. package/src/lib/server/connectors/access.ts +146 -0
  192. package/src/lib/server/connectors/bluebubbles.test.ts +1 -1
  193. package/src/lib/server/connectors/bluebubbles.ts +4 -4
  194. package/src/lib/server/connectors/commands.ts +367 -0
  195. package/src/lib/server/connectors/connector-routing.test.ts +4 -4
  196. package/src/lib/server/connectors/delivery.ts +142 -0
  197. package/src/lib/server/connectors/discord.ts +37 -40
  198. package/src/lib/server/connectors/email.ts +11 -10
  199. package/src/lib/server/connectors/googlechat.ts +4 -4
  200. package/src/lib/server/connectors/inbound-audio-transcription.ts +2 -1
  201. package/src/lib/server/connectors/ingress-delivery.ts +23 -0
  202. package/src/lib/server/connectors/manager-roundtrip.test.ts +300 -0
  203. package/src/lib/server/connectors/manager.test.ts +352 -77
  204. package/src/lib/server/connectors/manager.ts +134 -673
  205. package/src/lib/server/connectors/matrix.ts +4 -4
  206. package/src/lib/server/connectors/message-sentinel.ts +7 -0
  207. package/src/lib/server/connectors/openclaw.test.ts +1 -1
  208. package/src/lib/server/connectors/openclaw.ts +8 -10
  209. package/src/lib/server/connectors/outbox.test.ts +192 -0
  210. package/src/lib/server/connectors/outbox.ts +369 -0
  211. package/src/lib/server/connectors/pairing.test.ts +18 -1
  212. package/src/lib/server/connectors/pairing.ts +49 -4
  213. package/src/lib/server/connectors/policy.ts +9 -3
  214. package/src/lib/server/connectors/reconnect-state.ts +71 -0
  215. package/src/lib/server/connectors/response-media.ts +256 -0
  216. package/src/lib/server/connectors/runtime-state.ts +67 -0
  217. package/src/lib/server/connectors/session.test.ts +357 -0
  218. package/src/lib/server/connectors/session.ts +422 -0
  219. package/src/lib/server/connectors/signal.ts +7 -7
  220. package/src/lib/server/connectors/slack.ts +43 -43
  221. package/src/lib/server/connectors/teams.ts +4 -4
  222. package/src/lib/server/connectors/telegram.ts +37 -43
  223. package/src/lib/server/connectors/types.ts +31 -1
  224. package/src/lib/server/connectors/whatsapp.test.ts +108 -0
  225. package/src/lib/server/connectors/whatsapp.ts +106 -34
  226. package/src/lib/server/context-manager.test.ts +409 -0
  227. package/src/lib/server/cost.test.ts +1 -1
  228. package/src/lib/server/daemon-policy.ts +78 -0
  229. package/src/lib/server/daemon-state-connectors.test.ts +167 -0
  230. package/src/lib/server/daemon-state.test.ts +283 -55
  231. package/src/lib/server/daemon-state.ts +106 -109
  232. package/src/lib/server/data-dir.test.ts +5 -5
  233. package/src/lib/server/data-dir.ts +4 -0
  234. package/src/lib/server/delegation-jobs-advanced.test.ts +1 -1
  235. package/src/lib/server/delegation-jobs.test.ts +87 -0
  236. package/src/lib/server/delegation-jobs.ts +42 -48
  237. package/src/lib/server/devserver-launch.ts +1 -1
  238. package/src/lib/server/document-utils.ts +7 -9
  239. package/src/lib/server/elevenlabs.ts +2 -1
  240. package/src/lib/server/embeddings.test.ts +105 -0
  241. package/src/lib/server/ethereum.ts +3 -2
  242. package/src/lib/server/eval/agent-regression.ts +3 -2
  243. package/src/lib/server/eval/runner.ts +2 -1
  244. package/src/lib/server/eval/scorer.ts +2 -1
  245. package/src/lib/server/evm-swap.ts +2 -1
  246. package/src/lib/server/gateway/protocol.test.ts +1 -1
  247. package/src/lib/server/guardian.ts +2 -1
  248. package/src/lib/server/heartbeat-blocked-suppression.test.ts +151 -0
  249. package/src/lib/server/heartbeat-service-timer.test.ts +6 -6
  250. package/src/lib/server/heartbeat-service.test.ts +406 -0
  251. package/src/lib/server/heartbeat-service.ts +54 -7
  252. package/src/lib/server/heartbeat-wake.test.ts +19 -0
  253. package/src/lib/server/heartbeat-wake.ts +17 -16
  254. package/src/lib/server/integrity-monitor.test.ts +149 -0
  255. package/src/lib/server/json-utils.ts +22 -0
  256. package/src/lib/server/knowledge-db.test.ts +13 -13
  257. package/src/lib/server/link-understanding.ts +2 -1
  258. package/src/lib/server/llm-response-cache.test.ts +1 -1
  259. package/src/lib/server/main-agent-loop-advanced.test.ts +65 -3
  260. package/src/lib/server/main-agent-loop.test.ts +6 -6
  261. package/src/lib/server/main-agent-loop.ts +21 -7
  262. package/src/lib/server/mcp-client.test.ts +1 -1
  263. package/src/lib/server/mcp-conformance.test.ts +1 -1
  264. package/src/lib/server/mcp-conformance.ts +3 -2
  265. package/src/lib/server/memory-consolidation.ts +2 -1
  266. package/src/lib/server/memory-db.test.ts +485 -0
  267. package/src/lib/server/memory-db.ts +39 -26
  268. package/src/lib/server/memory-graph.test.ts +2 -2
  269. package/src/lib/server/memory-policy.test.ts +7 -7
  270. package/src/lib/server/memory-retrieval.test.ts +1 -1
  271. package/src/lib/server/openclaw-config-sync.ts +2 -1
  272. package/src/lib/server/openclaw-deploy.test.ts +1 -1
  273. package/src/lib/server/openclaw-deploy.ts +8 -12
  274. package/src/lib/server/openclaw-exec-config.ts +2 -1
  275. package/src/lib/server/openclaw-gateway.ts +6 -7
  276. package/src/lib/server/openclaw-skills-normalize.ts +2 -1
  277. package/src/lib/server/openclaw-sync.ts +7 -5
  278. package/src/lib/server/orchestrator-lg-structure.test.ts +17 -0
  279. package/src/lib/server/orchestrator-lg.ts +199 -327
  280. package/src/lib/server/path-utils.ts +31 -0
  281. package/src/lib/server/perf.ts +161 -0
  282. package/src/lib/server/plugins-approval-guidance.ts +115 -0
  283. package/src/lib/server/plugins.test.ts +1 -1
  284. package/src/lib/server/plugins.ts +22 -132
  285. package/src/lib/server/process-manager.ts +5 -8
  286. package/src/lib/server/provider-health.test.ts +137 -0
  287. package/src/lib/server/provider-health.ts +3 -3
  288. package/src/lib/server/provider-model-discovery.ts +3 -12
  289. package/src/lib/server/queue-followups.test.ts +9 -9
  290. package/src/lib/server/queue-reconcile.test.ts +2 -2
  291. package/src/lib/server/queue-recovery.test.ts +269 -0
  292. package/src/lib/server/queue.test.ts +570 -0
  293. package/src/lib/server/queue.ts +62 -455
  294. package/src/lib/server/resolve-image.ts +30 -0
  295. package/src/lib/server/runtime-settings.test.ts +4 -4
  296. package/src/lib/server/runtime-storage-write-paths.test.ts +60 -0
  297. package/src/lib/server/schedule-normalization.test.ts +279 -0
  298. package/src/lib/server/schedule-service.ts +263 -0
  299. package/src/lib/server/scheduler.ts +17 -74
  300. package/src/lib/server/session-mailbox.test.ts +191 -0
  301. package/src/lib/server/session-run-manager.test.ts +640 -0
  302. package/src/lib/server/session-run-manager.ts +59 -15
  303. package/src/lib/server/session-tools/autonomy-tools.test.ts +20 -20
  304. package/src/lib/server/session-tools/calendar.ts +2 -1
  305. package/src/lib/server/session-tools/canvas.ts +2 -1
  306. package/src/lib/server/session-tools/chatroom.ts +2 -1
  307. package/src/lib/server/session-tools/connector.ts +26 -28
  308. package/src/lib/server/session-tools/context-mgmt.ts +3 -2
  309. package/src/lib/server/session-tools/crawl.ts +4 -3
  310. package/src/lib/server/session-tools/crud.ts +105 -324
  311. package/src/lib/server/session-tools/delegate-fallback.test.ts +9 -9
  312. package/src/lib/server/session-tools/delegate.ts +6 -8
  313. package/src/lib/server/session-tools/discovery-approvals.test.ts +15 -15
  314. package/src/lib/server/session-tools/discovery.ts +4 -3
  315. package/src/lib/server/session-tools/document.ts +2 -1
  316. package/src/lib/server/session-tools/email.ts +2 -1
  317. package/src/lib/server/session-tools/extract.ts +2 -1
  318. package/src/lib/server/session-tools/file.ts +4 -3
  319. package/src/lib/server/session-tools/http.ts +2 -1
  320. package/src/lib/server/session-tools/human-loop.ts +2 -1
  321. package/src/lib/server/session-tools/image-gen.ts +4 -3
  322. package/src/lib/server/session-tools/index.ts +26 -30
  323. package/src/lib/server/session-tools/mailbox.ts +2 -1
  324. package/src/lib/server/session-tools/manage-connectors.test.ts +4 -4
  325. package/src/lib/server/session-tools/manage-schedules.test.ts +12 -12
  326. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +5 -5
  327. package/src/lib/server/session-tools/manage-tasks.test.ts +2 -2
  328. package/src/lib/server/session-tools/monitor.ts +2 -1
  329. package/src/lib/server/session-tools/platform.ts +2 -1
  330. package/src/lib/server/session-tools/plugin-creator.ts +2 -1
  331. package/src/lib/server/session-tools/replicate.ts +3 -2
  332. package/src/lib/server/session-tools/session-tools-wiring.test.ts +6 -6
  333. package/src/lib/server/session-tools/shell.ts +4 -9
  334. package/src/lib/server/session-tools/subagent.ts +322 -170
  335. package/src/lib/server/session-tools/table.ts +6 -5
  336. package/src/lib/server/session-tools/wallet-tool.test.ts +3 -3
  337. package/src/lib/server/session-tools/wallet.ts +7 -6
  338. package/src/lib/server/session-tools/web-browser-config.test.ts +1 -0
  339. package/src/lib/server/session-tools/web-utils.ts +317 -0
  340. package/src/lib/server/session-tools/web.ts +62 -328
  341. package/src/lib/server/skill-prompt-budget.test.ts +1 -1
  342. package/src/lib/server/skills-normalize.ts +2 -1
  343. package/src/lib/server/storage-item-access.test.ts +302 -0
  344. package/src/lib/server/storage.ts +366 -314
  345. package/src/lib/server/stream-agent-chat.test.ts +82 -3
  346. package/src/lib/server/stream-agent-chat.ts +146 -510
  347. package/src/lib/server/stream-continuation.ts +412 -0
  348. package/src/lib/server/subagent-lineage.test.ts +647 -0
  349. package/src/lib/server/subagent-lineage.ts +435 -0
  350. package/src/lib/server/subagent-runtime.test.ts +484 -0
  351. package/src/lib/server/subagent-runtime.ts +419 -0
  352. package/src/lib/server/subagent-swarm.test.ts +391 -0
  353. package/src/lib/server/subagent-swarm.ts +564 -0
  354. package/src/lib/server/system-events.ts +3 -3
  355. package/src/lib/server/task-followups.test.ts +491 -0
  356. package/src/lib/server/task-followups.ts +391 -0
  357. package/src/lib/server/task-lifecycle.test.ts +205 -0
  358. package/src/lib/server/task-lifecycle.ts +200 -0
  359. package/src/lib/server/task-quality-gate.test.ts +1 -1
  360. package/src/lib/server/task-resume.ts +208 -0
  361. package/src/lib/server/task-service.test.ts +108 -0
  362. package/src/lib/server/task-service.ts +264 -0
  363. package/src/lib/server/task-validation.test.ts +1 -1
  364. package/src/lib/server/test-utils/run-with-temp-data-dir.ts +42 -0
  365. package/src/lib/server/tool-capability-policy.test.ts +2 -2
  366. package/src/lib/server/tool-capability-policy.ts +3 -2
  367. package/src/lib/server/tool-planning.ts +2 -1
  368. package/src/lib/server/tool-retry.ts +2 -3
  369. package/src/lib/server/wake-dispatcher.test.ts +303 -0
  370. package/src/lib/server/wake-dispatcher.ts +318 -0
  371. package/src/lib/server/wake-mode.test.ts +161 -0
  372. package/src/lib/server/wake-mode.ts +174 -0
  373. package/src/lib/server/wallet-service.ts +8 -9
  374. package/src/lib/server/watch-jobs.ts +2 -1
  375. package/src/lib/server/workspace-context.ts +2 -2
  376. package/src/lib/shared-utils.test.ts +142 -0
  377. package/src/lib/shared-utils.ts +62 -0
  378. package/src/lib/tool-event-summary.ts +2 -1
  379. package/src/lib/view-routes.test.ts +100 -0
  380. package/src/lib/wallet.test.ts +322 -6
  381. package/src/proxy.test.ts +4 -4
  382. package/src/proxy.ts +2 -3
  383. package/src/stores/set-if-changed.ts +40 -0
  384. package/src/stores/slices/agent-slice.ts +111 -0
  385. package/src/stores/slices/auth-slice.ts +25 -0
  386. package/src/stores/slices/data-slice.ts +301 -0
  387. package/src/stores/slices/index.ts +7 -0
  388. package/src/stores/slices/session-slice.ts +112 -0
  389. package/src/stores/slices/task-slice.ts +63 -0
  390. package/src/stores/slices/ui-slice.ts +192 -0
  391. package/src/stores/use-app-store.ts +17 -822
  392. package/src/stores/use-approval-store.ts +2 -1
  393. package/src/stores/use-chat-store.ts +8 -1
  394. package/src/types/index.ts +10 -0
@@ -0,0 +1,264 @@
1
+ import { computeTaskFingerprint, findDuplicateTask } from '@/lib/task-dedupe'
2
+ import type { BoardTask, BoardTaskStatus } from '@/types'
3
+
4
+ import { hasManagedAgentAssignmentInput } from './agent-assignment'
5
+ import {
6
+ buildBoardTask,
7
+ markInvalidCompletedTaskFailed,
8
+ markValidatedTaskCompleted,
9
+ refreshTaskCompletionValidation,
10
+ } from './task-lifecycle'
11
+ import { normalizeTaskQualityGate } from './task-quality-gate'
12
+
13
+ const TASK_STATUS_VALUES = new Set([
14
+ 'backlog',
15
+ 'queued',
16
+ 'running',
17
+ 'completed',
18
+ 'failed',
19
+ 'archived',
20
+ ])
21
+
22
+ export function deriveTaskTitle(input: { title?: unknown; description?: unknown }): string {
23
+ const explicit = typeof input.title === 'string' ? input.title.replace(/\s+/g, ' ').trim() : ''
24
+ if (explicit && !/^untitled task$/i.test(explicit)) return explicit.slice(0, 120)
25
+
26
+ const description = typeof input.description === 'string'
27
+ ? input.description.replace(/\s+/g, ' ').trim()
28
+ : ''
29
+ if (!description) return ''
30
+
31
+ const firstSentence = description.split(/[.!?]\s+/)[0] || description
32
+ const compact = firstSentence
33
+ .replace(/^please\s+/i, '')
34
+ .replace(/^(create|make|build|implement|write)\s+/i, '')
35
+ .trim()
36
+ if (!compact) return ''
37
+ return compact.slice(0, 120)
38
+ }
39
+
40
+ export function normalizeTaskStatusInput(status: unknown, prevStatus?: string): BoardTaskStatus | null {
41
+ if (typeof status !== 'string') return null
42
+ const normalized = status.trim().toLowerCase()
43
+ if (!TASK_STATUS_VALUES.has(normalized)) return null
44
+ if (normalized === 'running' && prevStatus !== 'running') return 'queued'
45
+ return normalized as BoardTaskStatus
46
+ }
47
+
48
+ export function normalizeTaskIdList(value: unknown): string[] {
49
+ const rawValues = Array.isArray(value)
50
+ ? value
51
+ : typeof value === 'string'
52
+ ? value.split(',')
53
+ : []
54
+ const seen = new Set<string>()
55
+ const out: string[] = []
56
+ for (const entry of rawValues) {
57
+ const normalized = typeof entry === 'string' ? entry.trim() : ''
58
+ if (!normalized || seen.has(normalized)) continue
59
+ seen.add(normalized)
60
+ out.push(normalized)
61
+ }
62
+ return out
63
+ }
64
+
65
+ export function pickFirstTaskId(value: unknown): string | null {
66
+ const ids = normalizeTaskIdList(value)
67
+ return ids[0] || null
68
+ }
69
+
70
+ export function applyTaskContinuationDefaults(
71
+ parsed: Record<string, unknown>,
72
+ tasks: Record<string, BoardTask>,
73
+ explicitInput?: Record<string, unknown>,
74
+ ): string | null {
75
+ const explicit = explicitInput || parsed
76
+ const continuationTaskId = pickFirstTaskId(parsed.continueFromTaskId)
77
+ || pickFirstTaskId(parsed.followUpToTaskId)
78
+ || pickFirstTaskId(parsed.resumeFromTaskId)
79
+ const blockedBy = [
80
+ ...normalizeTaskIdList(parsed.blockedBy),
81
+ ...normalizeTaskIdList(parsed.dependsOn),
82
+ ...normalizeTaskIdList(parsed.dependsOnTaskIds),
83
+ ...normalizeTaskIdList(parsed.prerequisiteTaskIds),
84
+ ]
85
+ if (continuationTaskId && !blockedBy.includes(continuationTaskId)) {
86
+ blockedBy.unshift(continuationTaskId)
87
+ }
88
+ if (blockedBy.length > 0) parsed.blockedBy = blockedBy
89
+
90
+ if (continuationTaskId) {
91
+ const sourceTask = tasks[continuationTaskId]
92
+ if (!sourceTask) return `Error: source task "${continuationTaskId}" not found.`
93
+
94
+ if (!Object.prototype.hasOwnProperty.call(explicit, 'projectId') && typeof sourceTask.projectId === 'string' && sourceTask.projectId.trim()) {
95
+ parsed.projectId = sourceTask.projectId.trim()
96
+ }
97
+ if (
98
+ !Object.prototype.hasOwnProperty.call(explicit, 'agentId')
99
+ && !hasManagedAgentAssignmentInput(explicit)
100
+ && typeof sourceTask.agentId === 'string'
101
+ && sourceTask.agentId.trim()
102
+ ) {
103
+ parsed.agentId = sourceTask.agentId.trim()
104
+ }
105
+ if (!Object.prototype.hasOwnProperty.call(explicit, 'cwd') && typeof sourceTask.cwd === 'string' && sourceTask.cwd.trim()) {
106
+ parsed.cwd = sourceTask.cwd.trim()
107
+ }
108
+ const sourceSessionId = typeof sourceTask.checkpoint?.lastSessionId === 'string' && sourceTask.checkpoint.lastSessionId.trim()
109
+ ? sourceTask.checkpoint.lastSessionId.trim()
110
+ : typeof sourceTask.sessionId === 'string' && sourceTask.sessionId.trim()
111
+ ? sourceTask.sessionId.trim()
112
+ : ''
113
+ if (!Object.prototype.hasOwnProperty.call(explicit, 'sessionId') && sourceSessionId) {
114
+ parsed.sessionId = sourceSessionId
115
+ }
116
+
117
+ const resumeFieldMap: Array<[keyof BoardTask, string]> = [
118
+ ['cliResumeId', 'cliResumeId'],
119
+ ['cliProvider', 'cliProvider'],
120
+ ['claudeResumeId', 'claudeResumeId'],
121
+ ['codexResumeId', 'codexResumeId'],
122
+ ['opencodeResumeId', 'opencodeResumeId'],
123
+ ['geminiResumeId', 'geminiResumeId'],
124
+ ]
125
+ for (const [sourceKey, targetKey] of resumeFieldMap) {
126
+ const value = sourceTask[sourceKey]
127
+ if (Object.prototype.hasOwnProperty.call(explicit, targetKey)) continue
128
+ if (typeof value === 'string' && value.trim()) {
129
+ parsed[targetKey] = value.trim()
130
+ }
131
+ }
132
+ }
133
+
134
+ for (const aliasKey of ['continueFromTaskId', 'followUpToTaskId', 'resumeFromTaskId', 'dependsOn', 'dependsOnTaskIds', 'prerequisiteTaskIds']) {
135
+ delete parsed[aliasKey]
136
+ }
137
+ return null
138
+ }
139
+
140
+ export interface PrepareTaskCreationOptions {
141
+ id?: string
142
+ input: Record<string, unknown>
143
+ tasks: Record<string, BoardTask>
144
+ now: number
145
+ settings?: Record<string, unknown> | null
146
+ fallbackAgentId?: string | null
147
+ defaultCwd?: string | null
148
+ deriveTitleFromDescription?: boolean
149
+ requireMeaningfulTitle?: boolean
150
+ seed?: Record<string, unknown>
151
+ }
152
+
153
+ export type PrepareTaskCreationResult =
154
+ | { ok: false; error: string }
155
+ | { ok: true; task: BoardTask; duplicate: BoardTask | null }
156
+
157
+ export function prepareTaskCreation(options: PrepareTaskCreationOptions): PrepareTaskCreationResult {
158
+ const seed = options.seed ? { ...options.seed } : {}
159
+ const explicitTitle = typeof options.input.title === 'string' ? options.input.title.trim() : ''
160
+ const derivedTitle = deriveTaskTitle(options.input)
161
+ const nextTitle = options.deriveTitleFromDescription
162
+ ? (derivedTitle || explicitTitle || 'Untitled Task')
163
+ : (explicitTitle || derivedTitle || 'Untitled Task')
164
+
165
+ if (options.requireMeaningfulTitle && (!nextTitle || /^untitled task$/i.test(nextTitle))) {
166
+ return { ok: false, error: 'Error: manage_tasks create requires a specific title or a meaningful description.' }
167
+ }
168
+
169
+ const normalizedStatus = normalizeTaskStatusInput(options.input.status) || 'backlog'
170
+ const description = typeof options.input.description === 'string' ? options.input.description : ''
171
+ const agentId = typeof options.input.agentId === 'string'
172
+ ? options.input.agentId
173
+ : (typeof options.fallbackAgentId === 'string' ? options.fallbackAgentId : '')
174
+ const qualityGate = Object.prototype.hasOwnProperty.call(options.input, 'qualityGate')
175
+ ? (options.input.qualityGate
176
+ ? normalizeTaskQualityGate(options.input.qualityGate, options.settings || null)
177
+ : null)
178
+ : seed.qualityGate
179
+ const cwd = Object.prototype.hasOwnProperty.call(options.input, 'cwd')
180
+ ? (typeof options.input.cwd === 'string' ? options.input.cwd : null)
181
+ : (typeof options.defaultCwd === 'string' ? options.defaultCwd : seed.cwd ?? null)
182
+
183
+ const task = buildBoardTask({
184
+ id: options.id,
185
+ title: nextTitle,
186
+ description,
187
+ agentId,
188
+ now: options.now,
189
+ status: normalizedStatus,
190
+ seed: {
191
+ ...seed,
192
+ cwd,
193
+ qualityGate,
194
+ },
195
+ })
196
+ task.fingerprint = computeTaskFingerprint(task.title || 'Untitled Task', task.agentId || '')
197
+
198
+ const duplicate = task.fingerprint
199
+ ? findDuplicateTask(options.tasks, { fingerprint: task.fingerprint })
200
+ : null
201
+ if (duplicate) {
202
+ return { ok: true, task, duplicate }
203
+ }
204
+
205
+ if (task.status === 'completed') {
206
+ const { validation } = refreshTaskCompletionValidation(task, options.settings)
207
+ if (validation.ok) {
208
+ markValidatedTaskCompleted(task, { now: options.now })
209
+ } else {
210
+ markInvalidCompletedTaskFailed(task, validation, { now: options.now })
211
+ }
212
+ }
213
+
214
+ return { ok: true, task, duplicate: null }
215
+ }
216
+
217
+ export interface ApplyTaskPatchOptions {
218
+ task: BoardTask
219
+ patch: Record<string, unknown>
220
+ now: number
221
+ settings?: Record<string, unknown> | null
222
+ preserveCompletedAt?: boolean
223
+ clearProjectIdWhenNull?: boolean
224
+ invalidCompletionCommentAuthor?: string | null
225
+ }
226
+
227
+ export function applyTaskPatch(options: ApplyTaskPatchOptions): BoardTask {
228
+ const nextPatch = { ...options.patch }
229
+ if (Object.prototype.hasOwnProperty.call(nextPatch, 'status')) {
230
+ const normalized = normalizeTaskStatusInput(nextPatch.status, options.task.status)
231
+ if (normalized) nextPatch.status = normalized
232
+ else delete nextPatch.status
233
+ }
234
+ if (Object.prototype.hasOwnProperty.call(nextPatch, 'qualityGate')) {
235
+ nextPatch.qualityGate = nextPatch.qualityGate
236
+ ? normalizeTaskQualityGate(nextPatch.qualityGate, options.settings || null)
237
+ : null
238
+ }
239
+
240
+ Object.assign(options.task, nextPatch, { updatedAt: options.now })
241
+ if (options.clearProjectIdWhenNull && nextPatch.projectId === null) delete options.task.projectId
242
+
243
+ if (options.task.status === 'completed') {
244
+ const { validation } = refreshTaskCompletionValidation(options.task, options.settings)
245
+ if (validation.ok) {
246
+ markValidatedTaskCompleted(options.task, {
247
+ now: options.now,
248
+ preserveCompletedAt: options.preserveCompletedAt,
249
+ })
250
+ } else {
251
+ markInvalidCompletedTaskFailed(options.task, validation, {
252
+ now: options.now,
253
+ comment: options.invalidCompletionCommentAuthor
254
+ ? {
255
+ author: options.invalidCompletionCommentAuthor,
256
+ text: `Completion validation failed.\n\n${validation.reasons.map((reason) => `- ${reason}`).join('\n')}`,
257
+ }
258
+ : undefined,
259
+ })
260
+ }
261
+ }
262
+
263
+ return options.task
264
+ }
@@ -1,6 +1,6 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { test } from 'node:test'
3
- import { validateTaskCompletion } from './task-validation.ts'
3
+ import { validateTaskCompletion } from './task-validation'
4
4
  import type { BoardTask } from '@/types'
5
5
 
6
6
  test('validateTaskCompletion fails screenshot delivery tasks without artifact evidence', () => {
@@ -0,0 +1,42 @@
1
+ import assert from 'node:assert/strict'
2
+ import fs from 'node:fs'
3
+ import os from 'node:os'
4
+ import path from 'node:path'
5
+ import { spawnSync } from 'node:child_process'
6
+
7
+ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '../../../..')
8
+
9
+ export function runWithTempDataDir<T = unknown>(
10
+ script: string,
11
+ options: {
12
+ prefix?: string
13
+ } = {},
14
+ ): T {
15
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), options.prefix || 'swarmclaw-test-'))
16
+ const workspaceDir = path.join(tempDir, 'workspace')
17
+ fs.mkdirSync(workspaceDir, { recursive: true })
18
+
19
+ try {
20
+ const result = spawnSync(process.execPath, ['--import', 'tsx', '--input-type=module', '--eval', script], {
21
+ cwd: repoRoot,
22
+ env: {
23
+ ...process.env,
24
+ DATA_DIR: tempDir,
25
+ WORKSPACE_DIR: workspaceDir,
26
+ },
27
+ encoding: 'utf-8',
28
+ })
29
+
30
+ assert.equal(result.status, 0, result.stderr || result.stdout || 'subprocess failed')
31
+
32
+ const lines = (result.stdout || '')
33
+ .trim()
34
+ .split('\n')
35
+ .map((line) => line.trim())
36
+ .filter(Boolean)
37
+ const jsonLine = [...lines].reverse().find((line) => line.startsWith('{'))
38
+ return JSON.parse(jsonLine || '{}') as T
39
+ } finally {
40
+ fs.rmSync(tempDir, { recursive: true, force: true })
41
+ }
42
+ }
@@ -3,7 +3,7 @@ import { test } from 'node:test'
3
3
  import {
4
4
  resolveConcreteToolPolicyBlock,
5
5
  resolveSessionToolPolicy,
6
- } from './tool-capability-policy.ts'
6
+ } from './tool-capability-policy'
7
7
 
8
8
  test('capability policy permissive mode allows non-blocked tools', () => {
9
9
  const decision = resolveSessionToolPolicy(['shell', 'web_search'], { capabilityPolicyMode: 'permissive' })
@@ -76,7 +76,7 @@ test('task and project management can be disabled from app settings', () => {
76
76
  true,
77
77
  )
78
78
  assert.match(
79
- resolveConcreteToolPolicyBlock('manage_tasks', decision, { taskManagementEnabled: false }),
79
+ resolveConcreteToolPolicyBlock('manage_tasks', decision as "allow" | "deny" | "review", { taskManagementEnabled: false }),
80
80
  /task management is disabled/i,
81
81
  )
82
82
  })
@@ -1,4 +1,5 @@
1
1
  import type { AppSettings } from '@/types'
2
+ import { dedup } from '@/lib/shared-utils'
2
3
 
3
4
  export type CapabilityPolicyMode = 'permissive' | 'balanced' | 'strict'
4
5
 
@@ -117,7 +118,7 @@ function normalizeList(value: unknown): string[] {
117
118
  if (!normalized) continue
118
119
  names.push(normalized)
119
120
  }
120
- return Array.from(new Set(names))
121
+ return dedup(names)
121
122
  }
122
123
 
123
124
  function getSettingsList(settings: Record<string, unknown>, key: string): string[] {
@@ -227,7 +228,7 @@ export function resolveSessionToolPolicy(
227
228
  } = parsePolicyConfig(normalizedSettings)
228
229
 
229
230
  const requestedPlugins = Array.isArray(sessionTools)
230
- ? Array.from(new Set(sessionTools.map((id) => normalizeName(id)).filter(Boolean)))
231
+ ? dedup(sessionTools.map((id) => normalizeName(id)).filter(Boolean))
231
232
  : []
232
233
 
233
234
  const enabledPlugins: string[] = []
@@ -1,4 +1,5 @@
1
1
  import type { PluginToolPlanning } from '@/types'
2
+ import { dedup } from '@/lib/shared-utils'
2
3
  import { getPluginManager } from './plugins'
3
4
  import { canonicalizePluginId, expandPluginIds } from './tool-aliases'
4
5
 
@@ -165,7 +166,7 @@ const CORE_TOOL_PLANNING: Record<string, ToolPlanningEntry[]> = {
165
166
  }
166
167
 
167
168
  function dedupeStrings(values: string[]): string[] {
168
- return Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim())))
169
+ return dedup(values.filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim()))
169
170
  }
170
171
 
171
172
  function normalizePlanningEntry(toolName: string, planning: PluginToolPlanning | null | undefined): ToolPlanningEntry | null {
@@ -2,6 +2,8 @@
2
2
  * Structured retry with exponential backoff for transient tool failures.
3
3
  */
4
4
 
5
+ import { sleep } from '@/lib/shared-utils'
6
+
5
7
  export interface RetryOptions {
6
8
  maxAttempts?: number
7
9
  backoffMs?: number
@@ -25,9 +27,6 @@ function isRetryableError(error: string, patterns: RegExp[]): boolean {
25
27
  return patterns.some((p) => p.test(error))
26
28
  }
27
29
 
28
- function sleep(ms: number): Promise<void> {
29
- return new Promise((resolve) => setTimeout(resolve, ms))
30
- }
31
30
 
32
31
  /**
33
32
  * Wraps a tool handler function with retry logic for transient failures.
@@ -0,0 +1,303 @@
1
+ import assert from 'node:assert/strict'
2
+ import { afterEach, describe, it } from 'node:test'
3
+
4
+ import {
5
+ cancelScheduledWake,
6
+ dispatchWake,
7
+ drainDeferredWakes,
8
+ endJobExecution,
9
+ getActiveJob,
10
+ getActiveJobsForSession,
11
+ getWakeDispatcherStatus,
12
+ hasDeferredWakes,
13
+ resetWakeDispatcherForTests,
14
+ startJobExecution,
15
+ } from './wake-dispatcher'
16
+ import { resetHeartbeatWakeStateForTests, snapshotPendingHeartbeatWakesForTests } from './heartbeat-wake'
17
+
18
+ describe('wake-dispatcher', () => {
19
+ afterEach(() => {
20
+ resetWakeDispatcherForTests()
21
+ resetHeartbeatWakeStateForTests()
22
+ })
23
+
24
+ describe('dispatchWake — immediate mode', () => {
25
+ it('dispatches to heartbeat-wake pending queue', () => {
26
+ const result = dispatchWake({
27
+ mode: 'immediate',
28
+ sessionId: 'sess-1',
29
+ reason: 'connector-message',
30
+ source: 'connector:slack',
31
+ resumeMessage: 'New message from Slack.',
32
+ })
33
+
34
+ assert.equal(result.mode, 'immediate')
35
+ assert.equal(result.priority, 80)
36
+ assert.ok(result.runAt !== null)
37
+ assert.ok(result.jobId.length > 0)
38
+
39
+ // Should appear in heartbeat-wake pending state
40
+ const pending = snapshotPendingHeartbeatWakesForTests()
41
+ assert.equal(pending.length, 1)
42
+ assert.equal(pending[0].sessionId, 'sess-1')
43
+ assert.equal(pending[0].events[0].reason, 'connector-message')
44
+ })
45
+
46
+ it('respects explicit priority override', () => {
47
+ const result = dispatchWake({
48
+ mode: 'immediate',
49
+ sessionId: 'sess-1',
50
+ reason: 'approval',
51
+ priority: 95,
52
+ })
53
+
54
+ assert.equal(result.priority, 95)
55
+ })
56
+ })
57
+
58
+ describe('dispatchWake — next_heartbeat mode', () => {
59
+ it('queues in the deferred queue instead of firing immediately', () => {
60
+ const result = dispatchWake({
61
+ mode: 'next_heartbeat',
62
+ agentId: 'agent-1',
63
+ sessionId: 'sess-1',
64
+ reason: 'low-priority-poll',
65
+ })
66
+
67
+ assert.equal(result.mode, 'next_heartbeat')
68
+ assert.equal(result.priority, 40)
69
+ assert.equal(result.runAt, null)
70
+
71
+ // Should NOT appear in heartbeat-wake pending state
72
+ const pending = snapshotPendingHeartbeatWakesForTests()
73
+ assert.equal(pending.length, 0)
74
+
75
+ // Should be in the deferred queue
76
+ assert.equal(hasDeferredWakes('agent-1', 'sess-1'), true)
77
+ })
78
+
79
+ it('deduplicates by reason+source', () => {
80
+ dispatchWake({
81
+ mode: 'next_heartbeat',
82
+ agentId: 'a1',
83
+ sessionId: 's1',
84
+ reason: 'poll',
85
+ source: 'system',
86
+ })
87
+ dispatchWake({
88
+ mode: 'next_heartbeat',
89
+ agentId: 'a1',
90
+ sessionId: 's1',
91
+ reason: 'poll',
92
+ source: 'system',
93
+ })
94
+
95
+ const drained = drainDeferredWakes('a1', 's1')
96
+ assert.equal(drained.length, 1)
97
+ })
98
+
99
+ it('preserves distinct reasons', () => {
100
+ dispatchWake({
101
+ mode: 'next_heartbeat',
102
+ agentId: 'a1',
103
+ sessionId: 's1',
104
+ reason: 'poll',
105
+ source: 'system',
106
+ })
107
+ dispatchWake({
108
+ mode: 'next_heartbeat',
109
+ agentId: 'a1',
110
+ sessionId: 's1',
111
+ reason: 'check-email',
112
+ source: 'system',
113
+ })
114
+
115
+ const drained = drainDeferredWakes('a1', 's1')
116
+ assert.equal(drained.length, 2)
117
+ assert.deepEqual(drained.map((d) => d.reason).sort(), ['check-email', 'poll'])
118
+ })
119
+
120
+ it('drainDeferredWakes clears the queue', () => {
121
+ dispatchWake({
122
+ mode: 'next_heartbeat',
123
+ agentId: 'a1',
124
+ sessionId: 's1',
125
+ reason: 'poll',
126
+ })
127
+
128
+ assert.equal(hasDeferredWakes('a1', 's1'), true)
129
+ const first = drainDeferredWakes('a1', 's1')
130
+ assert.equal(first.length, 1)
131
+
132
+ // Second drain returns empty
133
+ assert.equal(hasDeferredWakes('a1', 's1'), false)
134
+ const second = drainDeferredWakes('a1', 's1')
135
+ assert.equal(second.length, 0)
136
+ })
137
+ })
138
+
139
+ describe('dispatchWake — scheduled mode', () => {
140
+ it('fires immediately when target time is in the past', () => {
141
+ const result = dispatchWake({
142
+ mode: 'scheduled',
143
+ sessionId: 'sess-1',
144
+ reason: 'overdue-schedule',
145
+ runAt: Date.now() - 1000,
146
+ })
147
+
148
+ assert.equal(result.mode, 'scheduled')
149
+
150
+ // Should have dispatched as immediate (appears in heartbeat-wake queue)
151
+ const pending = snapshotPendingHeartbeatWakesForTests()
152
+ assert.equal(pending.length, 1)
153
+ })
154
+
155
+ it('creates a scheduled timer for future execution', () => {
156
+ const result = dispatchWake({
157
+ mode: 'scheduled',
158
+ sessionId: 'sess-1',
159
+ reason: 'future-task',
160
+ delayMs: 60_000,
161
+ })
162
+
163
+ assert.equal(result.mode, 'scheduled')
164
+ assert.ok(result.runAt !== null && result.runAt > Date.now())
165
+
166
+ const status = getWakeDispatcherStatus()
167
+ assert.equal(status.scheduledTimerCount, 1)
168
+
169
+ // Not yet in heartbeat-wake queue
170
+ const pending = snapshotPendingHeartbeatWakesForTests()
171
+ assert.equal(pending.length, 0)
172
+ })
173
+
174
+ it('cancelScheduledWake removes the timer', () => {
175
+ const result = dispatchWake({
176
+ mode: 'scheduled',
177
+ sessionId: 'sess-1',
178
+ reason: 'cancellable',
179
+ delayMs: 300_000,
180
+ })
181
+
182
+ assert.equal(getWakeDispatcherStatus().scheduledTimerCount, 1)
183
+ const cancelled = cancelScheduledWake(result.jobId)
184
+ assert.equal(cancelled, true)
185
+ assert.equal(getWakeDispatcherStatus().scheduledTimerCount, 0)
186
+ })
187
+ })
188
+
189
+ describe('job context management', () => {
190
+ it('startJobExecution creates an isolated context', () => {
191
+ const controller = new AbortController()
192
+ const ctx = startJobExecution({
193
+ sessionId: 'sess-1',
194
+ agentId: 'agent-1',
195
+ mode: 'immediate',
196
+ signal: controller.signal,
197
+ source: 'connector:slack',
198
+ reason: 'New message',
199
+ heartbeatSnapshot: '# Tasks\n- [ ] Check weather',
200
+ })
201
+
202
+ assert.ok(ctx.jobId.length > 0)
203
+ assert.equal(ctx.sessionId, 'sess-1')
204
+ assert.equal(ctx.mode, 'immediate')
205
+ assert.ok(ctx.startedAt! > 0)
206
+ assert.equal(ctx.heartbeatSnapshot, '# Tasks\n- [ ] Check weather')
207
+
208
+ // Verify it's registered
209
+ const retrieved = getActiveJob(ctx.jobId)
210
+ assert.ok(retrieved)
211
+ assert.equal(retrieved!.jobId, ctx.jobId)
212
+ })
213
+
214
+ it('endJobExecution removes from active tracking', () => {
215
+ const controller = new AbortController()
216
+ const ctx = startJobExecution({
217
+ sessionId: 'sess-1',
218
+ mode: 'immediate',
219
+ signal: controller.signal,
220
+ })
221
+
222
+ const ended = endJobExecution(ctx.jobId)
223
+ assert.ok(ended)
224
+ assert.ok(ended!.endedAt! > 0)
225
+
226
+ // No longer active
227
+ assert.equal(getActiveJob(ctx.jobId), null)
228
+ })
229
+
230
+ it('getActiveJobsForSession lists jobs for a session', () => {
231
+ const controller = new AbortController()
232
+ startJobExecution({
233
+ sessionId: 'sess-1',
234
+ mode: 'immediate',
235
+ signal: controller.signal,
236
+ })
237
+ startJobExecution({
238
+ sessionId: 'sess-1',
239
+ mode: 'next_heartbeat',
240
+ signal: controller.signal,
241
+ })
242
+ startJobExecution({
243
+ sessionId: 'sess-2',
244
+ mode: 'immediate',
245
+ signal: controller.signal,
246
+ })
247
+
248
+ const jobs = getActiveJobsForSession('sess-1')
249
+ assert.equal(jobs.length, 2)
250
+ assert.ok(jobs.every((j) => j.sessionId === 'sess-1'))
251
+ })
252
+
253
+ it('scratchpad provides per-job isolation', () => {
254
+ const controller = new AbortController()
255
+ const job1 = startJobExecution({
256
+ sessionId: 'sess-1',
257
+ mode: 'immediate',
258
+ signal: controller.signal,
259
+ })
260
+ const job2 = startJobExecution({
261
+ sessionId: 'sess-1',
262
+ mode: 'immediate',
263
+ signal: controller.signal,
264
+ })
265
+
266
+ job1.scratchpad.set('result', 'job1-data')
267
+ job2.scratchpad.set('result', 'job2-data')
268
+
269
+ assert.equal(getActiveJob(job1.jobId)!.scratchpad.get('result'), 'job1-data')
270
+ assert.equal(getActiveJob(job2.jobId)!.scratchpad.get('result'), 'job2-data')
271
+ })
272
+ })
273
+
274
+ describe('diagnostics', () => {
275
+ it('getWakeDispatcherStatus reports queue depths', () => {
276
+ const controller = new AbortController()
277
+
278
+ dispatchWake({ mode: 'next_heartbeat', agentId: 'a1', sessionId: 's1', reason: 'poll' })
279
+ dispatchWake({ mode: 'next_heartbeat', agentId: 'a1', sessionId: 's1', reason: 'check' })
280
+ dispatchWake({ mode: 'scheduled', sessionId: 's2', reason: 'future', delayMs: 60_000 })
281
+ startJobExecution({ sessionId: 's1', mode: 'immediate', signal: controller.signal })
282
+
283
+ const status = getWakeDispatcherStatus()
284
+ assert.equal(status.deferredQueueCount, 2)
285
+ assert.equal(status.scheduledTimerCount, 1)
286
+ assert.equal(status.activeJobCount, 1)
287
+ })
288
+
289
+ it('resetWakeDispatcherForTests clears all state', () => {
290
+ const controller = new AbortController()
291
+ dispatchWake({ mode: 'next_heartbeat', agentId: 'a1', sessionId: 's1', reason: 'poll' })
292
+ dispatchWake({ mode: 'scheduled', sessionId: 's2', reason: 'future', delayMs: 60_000 })
293
+ startJobExecution({ sessionId: 's1', mode: 'immediate', signal: controller.signal })
294
+
295
+ resetWakeDispatcherForTests()
296
+
297
+ const status = getWakeDispatcherStatus()
298
+ assert.equal(status.deferredQueueCount, 0)
299
+ assert.equal(status.scheduledTimerCount, 0)
300
+ assert.equal(status.activeJobCount, 0)
301
+ })
302
+ })
303
+ })