@swarmclawai/swarmclaw 0.2.0

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 (319) hide show
  1. package/README.md +577 -0
  2. package/bin/server-cmd.js +359 -0
  3. package/bin/swarmclaw.js +29 -0
  4. package/bin/swarmclaw.mjs +1504 -0
  5. package/next.config.ts +33 -0
  6. package/package.json +112 -0
  7. package/postcss.config.mjs +7 -0
  8. package/public/branding/swarmclaw-org-avatar.png +0 -0
  9. package/public/branding/swarmclaw-org-avatar.svg +58 -0
  10. package/public/file.svg +1 -0
  11. package/public/globe.svg +1 -0
  12. package/public/next.svg +1 -0
  13. package/public/screenshots/agents.png +0 -0
  14. package/public/screenshots/connectors.png +0 -0
  15. package/public/screenshots/dashboard.png +0 -0
  16. package/public/screenshots/new-session-openclaw.png +0 -0
  17. package/public/screenshots/providers.png +0 -0
  18. package/public/screenshots/schedules.png +0 -0
  19. package/public/screenshots/tasks.png +0 -0
  20. package/public/vercel.svg +1 -0
  21. package/public/window.svg +1 -0
  22. package/src/app/api/agents/[id]/route.ts +30 -0
  23. package/src/app/api/agents/[id]/thread/route.ts +66 -0
  24. package/src/app/api/agents/generate/route.ts +42 -0
  25. package/src/app/api/agents/route.ts +33 -0
  26. package/src/app/api/auth/route.ts +25 -0
  27. package/src/app/api/claude-skills/route.ts +42 -0
  28. package/src/app/api/clawhub/install/route.ts +39 -0
  29. package/src/app/api/clawhub/search/route.ts +11 -0
  30. package/src/app/api/connectors/[id]/route.ts +79 -0
  31. package/src/app/api/connectors/route.ts +60 -0
  32. package/src/app/api/credentials/[id]/route.ts +14 -0
  33. package/src/app/api/credentials/route.ts +31 -0
  34. package/src/app/api/daemon/health-check/route.ts +11 -0
  35. package/src/app/api/daemon/route.ts +22 -0
  36. package/src/app/api/dirs/pick/route.ts +60 -0
  37. package/src/app/api/dirs/route.ts +29 -0
  38. package/src/app/api/documents/[id]/route.ts +47 -0
  39. package/src/app/api/documents/route.ts +93 -0
  40. package/src/app/api/files/serve/route.ts +69 -0
  41. package/src/app/api/generate/info/route.ts +12 -0
  42. package/src/app/api/generate/route.ts +106 -0
  43. package/src/app/api/ip/route.ts +6 -0
  44. package/src/app/api/knowledge/[id]/route.ts +61 -0
  45. package/src/app/api/knowledge/route.ts +48 -0
  46. package/src/app/api/knowledge/upload/route.ts +86 -0
  47. package/src/app/api/logs/route.ts +65 -0
  48. package/src/app/api/mcp-servers/[id]/route.ts +32 -0
  49. package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
  50. package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
  51. package/src/app/api/mcp-servers/route.ts +27 -0
  52. package/src/app/api/memory/[id]/route.ts +126 -0
  53. package/src/app/api/memory/maintenance/route.ts +63 -0
  54. package/src/app/api/memory/route.ts +111 -0
  55. package/src/app/api/memory-images/[filename]/route.ts +36 -0
  56. package/src/app/api/orchestrator/run/route.ts +43 -0
  57. package/src/app/api/plugins/install/route.ts +58 -0
  58. package/src/app/api/plugins/marketplace/route.ts +33 -0
  59. package/src/app/api/plugins/route.ts +21 -0
  60. package/src/app/api/preview-server/route.ts +339 -0
  61. package/src/app/api/providers/[id]/models/route.ts +29 -0
  62. package/src/app/api/providers/[id]/route.ts +34 -0
  63. package/src/app/api/providers/configs/route.ts +7 -0
  64. package/src/app/api/providers/ollama/route.ts +30 -0
  65. package/src/app/api/providers/openclaw/health/route.ts +23 -0
  66. package/src/app/api/providers/route.ts +28 -0
  67. package/src/app/api/runs/[id]/route.ts +9 -0
  68. package/src/app/api/runs/route.ts +13 -0
  69. package/src/app/api/schedules/[id]/route.ts +28 -0
  70. package/src/app/api/schedules/[id]/run/route.ts +104 -0
  71. package/src/app/api/schedules/route.ts +78 -0
  72. package/src/app/api/secrets/[id]/route.ts +29 -0
  73. package/src/app/api/secrets/route.ts +42 -0
  74. package/src/app/api/sessions/[id]/browser/route.ts +13 -0
  75. package/src/app/api/sessions/[id]/chat/route.ts +96 -0
  76. package/src/app/api/sessions/[id]/clear/route.ts +19 -0
  77. package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
  78. package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
  79. package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
  80. package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
  81. package/src/app/api/sessions/[id]/messages/route.ts +9 -0
  82. package/src/app/api/sessions/[id]/retry/route.ts +28 -0
  83. package/src/app/api/sessions/[id]/route.ts +103 -0
  84. package/src/app/api/sessions/[id]/stop/route.ts +13 -0
  85. package/src/app/api/sessions/heartbeat/route.ts +26 -0
  86. package/src/app/api/sessions/route.ts +85 -0
  87. package/src/app/api/settings/route.ts +58 -0
  88. package/src/app/api/setup/check-provider/route.ts +326 -0
  89. package/src/app/api/setup/doctor/route.ts +250 -0
  90. package/src/app/api/skills/[id]/route.ts +40 -0
  91. package/src/app/api/skills/import/route.ts +69 -0
  92. package/src/app/api/skills/route.ts +28 -0
  93. package/src/app/api/tasks/[id]/route.ts +102 -0
  94. package/src/app/api/tasks/route.ts +115 -0
  95. package/src/app/api/tts/route.ts +40 -0
  96. package/src/app/api/upload/route.ts +18 -0
  97. package/src/app/api/uploads/[filename]/route.ts +59 -0
  98. package/src/app/api/usage/route.ts +35 -0
  99. package/src/app/api/version/route.ts +81 -0
  100. package/src/app/api/version/update/route.ts +95 -0
  101. package/src/app/api/webhooks/[id]/history/route.ts +13 -0
  102. package/src/app/api/webhooks/[id]/route.ts +204 -0
  103. package/src/app/api/webhooks/route.ts +37 -0
  104. package/src/app/favicon.ico +0 -0
  105. package/src/app/globals.css +370 -0
  106. package/src/app/layout.tsx +52 -0
  107. package/src/app/page.tsx +172 -0
  108. package/src/cli/index.js +1232 -0
  109. package/src/cli/index.test.js +281 -0
  110. package/src/cli/index.ts +1158 -0
  111. package/src/cli/spec.js +284 -0
  112. package/src/components/agents/agent-card.tsx +219 -0
  113. package/src/components/agents/agent-chat-list.tsx +165 -0
  114. package/src/components/agents/agent-list.tsx +110 -0
  115. package/src/components/agents/agent-sheet.tsx +1220 -0
  116. package/src/components/auth/access-key-gate.tsx +248 -0
  117. package/src/components/auth/setup-wizard.tsx +940 -0
  118. package/src/components/auth/user-picker.tsx +88 -0
  119. package/src/components/chat/chat-area.tsx +406 -0
  120. package/src/components/chat/chat-header.tsx +491 -0
  121. package/src/components/chat/chat-tool-toggles.tsx +161 -0
  122. package/src/components/chat/code-block.tsx +146 -0
  123. package/src/components/chat/dev-server-bar.tsx +39 -0
  124. package/src/components/chat/message-bubble.tsx +486 -0
  125. package/src/components/chat/message-list.tsx +299 -0
  126. package/src/components/chat/session-debug-panel.tsx +196 -0
  127. package/src/components/chat/streaming-bubble.tsx +85 -0
  128. package/src/components/chat/thinking-indicator.tsx +26 -0
  129. package/src/components/chat/tool-call-bubble.tsx +438 -0
  130. package/src/components/chat/tool-request-banner.tsx +103 -0
  131. package/src/components/connectors/connector-list.tsx +196 -0
  132. package/src/components/connectors/connector-sheet.tsx +804 -0
  133. package/src/components/input/chat-input.tsx +235 -0
  134. package/src/components/knowledge/knowledge-list.tsx +206 -0
  135. package/src/components/knowledge/knowledge-sheet.tsx +316 -0
  136. package/src/components/layout/app-layout.tsx +1016 -0
  137. package/src/components/layout/daemon-indicator.tsx +56 -0
  138. package/src/components/layout/mobile-header.tsx +31 -0
  139. package/src/components/layout/network-banner.tsx +17 -0
  140. package/src/components/layout/update-banner.tsx +130 -0
  141. package/src/components/logs/log-list.tsx +358 -0
  142. package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
  143. package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
  144. package/src/components/memory/memory-card.tsx +63 -0
  145. package/src/components/memory/memory-detail.tsx +339 -0
  146. package/src/components/memory/memory-list.tsx +198 -0
  147. package/src/components/memory/memory-sheet.tsx +70 -0
  148. package/src/components/plugins/plugin-list.tsx +60 -0
  149. package/src/components/plugins/plugin-sheet.tsx +311 -0
  150. package/src/components/providers/provider-list.tsx +96 -0
  151. package/src/components/providers/provider-sheet.tsx +542 -0
  152. package/src/components/runs/run-list.tsx +231 -0
  153. package/src/components/schedules/schedule-card.tsx +63 -0
  154. package/src/components/schedules/schedule-list.tsx +76 -0
  155. package/src/components/schedules/schedule-sheet.tsx +336 -0
  156. package/src/components/secrets/secret-sheet.tsx +180 -0
  157. package/src/components/secrets/secrets-list.tsx +91 -0
  158. package/src/components/sessions/new-session-sheet.tsx +478 -0
  159. package/src/components/sessions/session-card.tsx +144 -0
  160. package/src/components/sessions/session-list.tsx +202 -0
  161. package/src/components/shared/ai-gen-block.tsx +77 -0
  162. package/src/components/shared/avatar.tsx +48 -0
  163. package/src/components/shared/bottom-sheet.tsx +30 -0
  164. package/src/components/shared/confirm-dialog.tsx +47 -0
  165. package/src/components/shared/connector-platform-icon.tsx +113 -0
  166. package/src/components/shared/dir-browser.tsx +285 -0
  167. package/src/components/shared/dropdown.tsx +55 -0
  168. package/src/components/shared/icon-button.tsx +25 -0
  169. package/src/components/shared/settings/plugin-manager.tsx +207 -0
  170. package/src/components/shared/settings/section-capability-policy.tsx +93 -0
  171. package/src/components/shared/settings/section-embedding.tsx +99 -0
  172. package/src/components/shared/settings/section-heartbeat.tsx +168 -0
  173. package/src/components/shared/settings/section-memory.tsx +77 -0
  174. package/src/components/shared/settings/section-orchestrator.tsx +108 -0
  175. package/src/components/shared/settings/section-providers.tsx +181 -0
  176. package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
  177. package/src/components/shared/settings/section-secrets.tsx +132 -0
  178. package/src/components/shared/settings/section-user-preferences.tsx +24 -0
  179. package/src/components/shared/settings/section-voice.tsx +53 -0
  180. package/src/components/shared/settings/settings-sheet.tsx +88 -0
  181. package/src/components/shared/settings/types.ts +7 -0
  182. package/src/components/shared/settings/utils.ts +13 -0
  183. package/src/components/shared/settings-sheet.tsx +1 -0
  184. package/src/components/shared/skeleton.tsx +19 -0
  185. package/src/components/shared/usage-badge.tsx +28 -0
  186. package/src/components/skills/clawhub-browser.tsx +225 -0
  187. package/src/components/skills/skill-list.tsx +70 -0
  188. package/src/components/skills/skill-sheet.tsx +254 -0
  189. package/src/components/tasks/task-board.tsx +96 -0
  190. package/src/components/tasks/task-card.tsx +179 -0
  191. package/src/components/tasks/task-column.tsx +73 -0
  192. package/src/components/tasks/task-list.tsx +118 -0
  193. package/src/components/tasks/task-sheet.tsx +415 -0
  194. package/src/components/ui/avatar.tsx +109 -0
  195. package/src/components/ui/badge.tsx +48 -0
  196. package/src/components/ui/button.tsx +64 -0
  197. package/src/components/ui/card.tsx +92 -0
  198. package/src/components/ui/dialog.tsx +158 -0
  199. package/src/components/ui/dropdown-menu.tsx +257 -0
  200. package/src/components/ui/input.tsx +21 -0
  201. package/src/components/ui/scroll-area.tsx +58 -0
  202. package/src/components/ui/select.tsx +190 -0
  203. package/src/components/ui/separator.tsx +28 -0
  204. package/src/components/ui/sheet.tsx +143 -0
  205. package/src/components/ui/sonner.tsx +22 -0
  206. package/src/components/ui/textarea.tsx +18 -0
  207. package/src/components/ui/tooltip.tsx +56 -0
  208. package/src/components/usage/usage-list.tsx +105 -0
  209. package/src/components/webhooks/webhook-list.tsx +166 -0
  210. package/src/components/webhooks/webhook-sheet.tsx +402 -0
  211. package/src/hooks/use-auto-resize.ts +20 -0
  212. package/src/hooks/use-media-query.ts +21 -0
  213. package/src/hooks/use-speech-recognition.ts +83 -0
  214. package/src/instrumentation.ts +8 -0
  215. package/src/lib/agents.ts +13 -0
  216. package/src/lib/api-client.ts +100 -0
  217. package/src/lib/chat.ts +60 -0
  218. package/src/lib/memory.ts +42 -0
  219. package/src/lib/openclaw-endpoint.test.ts +48 -0
  220. package/src/lib/openclaw-endpoint.ts +67 -0
  221. package/src/lib/provider-config.ts +13 -0
  222. package/src/lib/providers/anthropic.ts +135 -0
  223. package/src/lib/providers/claude-cli.ts +202 -0
  224. package/src/lib/providers/codex-cli.ts +260 -0
  225. package/src/lib/providers/index.ts +351 -0
  226. package/src/lib/providers/ollama.ts +131 -0
  227. package/src/lib/providers/openai.ts +164 -0
  228. package/src/lib/providers/openclaw.ts +330 -0
  229. package/src/lib/providers/opencode-cli.ts +164 -0
  230. package/src/lib/runtime-loop.ts +15 -0
  231. package/src/lib/schedule-dedupe.test.ts +84 -0
  232. package/src/lib/schedule-dedupe.ts +174 -0
  233. package/src/lib/schedule-name.ts +62 -0
  234. package/src/lib/schedules.ts +16 -0
  235. package/src/lib/server/agent-registry.ts +70 -0
  236. package/src/lib/server/api-routes.test.ts +362 -0
  237. package/src/lib/server/autonomy-contract.ts +200 -0
  238. package/src/lib/server/build-llm.ts +155 -0
  239. package/src/lib/server/capability-router.test.ts +21 -0
  240. package/src/lib/server/capability-router.ts +172 -0
  241. package/src/lib/server/chat-execution.ts +894 -0
  242. package/src/lib/server/clawhub-client.test.ts +161 -0
  243. package/src/lib/server/clawhub-client.ts +26 -0
  244. package/src/lib/server/connectors/connector-routing.test.ts +243 -0
  245. package/src/lib/server/connectors/discord.ts +116 -0
  246. package/src/lib/server/connectors/googlechat.ts +66 -0
  247. package/src/lib/server/connectors/manager.ts +559 -0
  248. package/src/lib/server/connectors/matrix.ts +78 -0
  249. package/src/lib/server/connectors/media.ts +149 -0
  250. package/src/lib/server/connectors/openclaw.test.ts +375 -0
  251. package/src/lib/server/connectors/openclaw.ts +1132 -0
  252. package/src/lib/server/connectors/signal.ts +183 -0
  253. package/src/lib/server/connectors/slack.ts +258 -0
  254. package/src/lib/server/connectors/teams.ts +94 -0
  255. package/src/lib/server/connectors/telegram.ts +221 -0
  256. package/src/lib/server/connectors/types.ts +62 -0
  257. package/src/lib/server/connectors/whatsapp.ts +349 -0
  258. package/src/lib/server/context-manager.ts +232 -0
  259. package/src/lib/server/cost.ts +31 -0
  260. package/src/lib/server/daemon-state.ts +354 -0
  261. package/src/lib/server/data-dir.ts +3 -0
  262. package/src/lib/server/embeddings.ts +111 -0
  263. package/src/lib/server/execution-log.ts +257 -0
  264. package/src/lib/server/gateway/protocol.test.ts +54 -0
  265. package/src/lib/server/gateway/protocol.ts +114 -0
  266. package/src/lib/server/heartbeat-service.ts +366 -0
  267. package/src/lib/server/knowledge-db.test.ts +441 -0
  268. package/src/lib/server/logger.ts +47 -0
  269. package/src/lib/server/main-agent-loop.ts +1017 -0
  270. package/src/lib/server/mcp-client.test.ts +342 -0
  271. package/src/lib/server/mcp-client.ts +130 -0
  272. package/src/lib/server/memory-db.ts +1078 -0
  273. package/src/lib/server/memory-graph.test.ts +153 -0
  274. package/src/lib/server/memory-graph.ts +138 -0
  275. package/src/lib/server/openclaw-health.ts +245 -0
  276. package/src/lib/server/orchestrator-lg.ts +431 -0
  277. package/src/lib/server/orchestrator.ts +364 -0
  278. package/src/lib/server/playwright-proxy.mjs +70 -0
  279. package/src/lib/server/plugins.ts +229 -0
  280. package/src/lib/server/process-manager.ts +327 -0
  281. package/src/lib/server/provider-health.ts +113 -0
  282. package/src/lib/server/queue.ts +859 -0
  283. package/src/lib/server/runtime-settings.ts +119 -0
  284. package/src/lib/server/scheduler.ts +196 -0
  285. package/src/lib/server/session-mailbox.ts +129 -0
  286. package/src/lib/server/session-run-manager.ts +512 -0
  287. package/src/lib/server/session-tools/connector.ts +124 -0
  288. package/src/lib/server/session-tools/context-mgmt.ts +103 -0
  289. package/src/lib/server/session-tools/context.ts +114 -0
  290. package/src/lib/server/session-tools/crud.ts +673 -0
  291. package/src/lib/server/session-tools/delegate.ts +708 -0
  292. package/src/lib/server/session-tools/file.ts +264 -0
  293. package/src/lib/server/session-tools/index.ts +164 -0
  294. package/src/lib/server/session-tools/memory.ts +230 -0
  295. package/src/lib/server/session-tools/session-info.ts +422 -0
  296. package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
  297. package/src/lib/server/session-tools/shell.ts +171 -0
  298. package/src/lib/server/session-tools/web.ts +408 -0
  299. package/src/lib/server/session-tools.ts +9 -0
  300. package/src/lib/server/skills-normalize.ts +130 -0
  301. package/src/lib/server/storage-mcp.test.ts +161 -0
  302. package/src/lib/server/storage.ts +670 -0
  303. package/src/lib/server/stream-agent-chat.ts +571 -0
  304. package/src/lib/server/task-reports.ts +122 -0
  305. package/src/lib/server/task-result.ts +161 -0
  306. package/src/lib/server/task-validation.test.ts +27 -0
  307. package/src/lib/server/task-validation.ts +90 -0
  308. package/src/lib/server/tool-capability-policy.test.ts +58 -0
  309. package/src/lib/server/tool-capability-policy.ts +262 -0
  310. package/src/lib/sessions.ts +68 -0
  311. package/src/lib/tasks.ts +20 -0
  312. package/src/lib/tts.ts +42 -0
  313. package/src/lib/upload.ts +10 -0
  314. package/src/lib/utils.ts +6 -0
  315. package/src/proxy.ts +43 -0
  316. package/src/stores/use-app-store.ts +468 -0
  317. package/src/stores/use-chat-store.ts +323 -0
  318. package/src/types/index.ts +621 -0
  319. package/tsconfig.json +34 -0
@@ -0,0 +1,859 @@
1
+ import crypto from 'crypto'
2
+ import { loadTasks, saveTasks, loadQueue, saveQueue, loadAgents, loadSchedules, saveSchedules, loadSessions, saveSessions, loadSettings } from './storage'
3
+ import { createOrchestratorSession, executeOrchestrator } from './orchestrator'
4
+ import { formatValidationFailure, validateTaskCompletion } from './task-validation'
5
+ import { ensureTaskCompletionReport } from './task-reports'
6
+ import { pushMainLoopEventToMainSessions } from './main-agent-loop'
7
+ import { executeSessionChatTurn } from './chat-execution'
8
+ import { extractTaskResult, formatResultBody } from './task-result'
9
+ import type { Agent, BoardTask, Message } from '@/types'
10
+
11
+ let processing = false
12
+
13
+ interface SessionMessageLike {
14
+ role?: string
15
+ text?: string
16
+ time?: number
17
+ kind?: 'chat' | 'heartbeat' | 'system'
18
+ toolEvents?: Array<{ name?: string; output?: string }>
19
+ }
20
+
21
+ interface SessionLike {
22
+ name?: string
23
+ user?: string
24
+ cwd?: string
25
+ messages?: SessionMessageLike[]
26
+ lastActiveAt?: number
27
+ }
28
+
29
+ interface ScheduleTaskMeta extends BoardTask {
30
+ user?: string | null
31
+ createdInSessionId?: string | null
32
+ createdByAgentId?: string | null
33
+ }
34
+
35
+ function sameReasons(a?: string[] | null, b?: string[] | null): boolean {
36
+ const av = Array.isArray(a) ? a : []
37
+ const bv = Array.isArray(b) ? b : []
38
+ if (av.length !== bv.length) return false
39
+ for (let i = 0; i < av.length; i++) {
40
+ if (av[i] !== bv[i]) return false
41
+ }
42
+ return true
43
+ }
44
+
45
+ function normalizeInt(value: unknown, fallback: number, min: number, max: number): number {
46
+ const parsed = typeof value === 'number'
47
+ ? value
48
+ : typeof value === 'string'
49
+ ? Number.parseInt(value, 10)
50
+ : Number.NaN
51
+ if (!Number.isFinite(parsed)) return fallback
52
+ return Math.max(min, Math.min(max, Math.trunc(parsed)))
53
+ }
54
+
55
+ function resolveTaskPolicy(task: BoardTask): { maxAttempts: number; backoffSec: number } {
56
+ const settings = loadSettings()
57
+ const defaultMaxAttempts = normalizeInt(settings.defaultTaskMaxAttempts, 3, 1, 20)
58
+ const defaultBackoffSec = normalizeInt(settings.taskRetryBackoffSec, 30, 1, 3600)
59
+ const maxAttempts = normalizeInt(task.maxAttempts, defaultMaxAttempts, 1, 20)
60
+ const backoffSec = normalizeInt(task.retryBackoffSec, defaultBackoffSec, 1, 3600)
61
+ return { maxAttempts, backoffSec }
62
+ }
63
+
64
+ function applyTaskPolicyDefaults(task: BoardTask): void {
65
+ const policy = resolveTaskPolicy(task)
66
+ if (typeof task.attempts !== 'number' || task.attempts < 0) task.attempts = 0
67
+ task.maxAttempts = policy.maxAttempts
68
+ task.retryBackoffSec = policy.backoffSec
69
+ if (task.retryScheduledAt === undefined) task.retryScheduledAt = null
70
+ if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
71
+ }
72
+
73
+ function queueContains(queue: string[], id: string): boolean {
74
+ return queue.includes(id)
75
+ }
76
+
77
+ function pushQueueUnique(queue: string[], id: string): void {
78
+ if (!queueContains(queue, id)) queue.push(id)
79
+ }
80
+
81
+ function isMainSession(session: SessionLike | null | undefined): boolean {
82
+ return session?.name === '__main__'
83
+ }
84
+
85
+ function resolveTaskOwnerUser(task: ScheduleTaskMeta, sessions: Record<string, SessionLike>): string | null {
86
+ const direct = typeof task.user === 'string' ? task.user.trim() : ''
87
+ if (direct) return direct
88
+ const createdInSessionId = typeof task.createdInSessionId === 'string'
89
+ ? task.createdInSessionId
90
+ : ''
91
+ if (createdInSessionId) {
92
+ const sourceSession = sessions[createdInSessionId]
93
+ const sourceUser = typeof sourceSession?.user === 'string' ? sourceSession.user.trim() : ''
94
+ if (sourceUser) return sourceUser
95
+ }
96
+ return null
97
+ }
98
+
99
+ function latestAssistantText(session: SessionLike | null | undefined): string {
100
+ if (!Array.isArray(session?.messages)) return ''
101
+ for (let i = session.messages.length - 1; i >= 0; i--) {
102
+ const msg = session.messages[i]
103
+ if (msg?.role !== 'assistant') continue
104
+ const text = typeof msg?.text === 'string' ? msg.text.trim() : ''
105
+ if (!text) continue
106
+ if (/^HEARTBEAT_OK$/i.test(text)) continue
107
+ return text
108
+ }
109
+ return ''
110
+ }
111
+
112
+ // Task result extraction now uses Zod-validated structured data
113
+ // from ./task-result.ts (extractTaskResult, formatResultBody)
114
+
115
+ async function executeTaskRun(
116
+ task: BoardTask,
117
+ agent: Agent,
118
+ sessionId: string,
119
+ ): Promise<string> {
120
+ const prompt = task.description || task.title
121
+ if (agent?.isOrchestrator) {
122
+ return executeOrchestrator(agent, prompt, sessionId)
123
+ }
124
+
125
+ const run = await executeSessionChatTurn({
126
+ sessionId,
127
+ message: prompt,
128
+ internal: false,
129
+ source: 'task',
130
+ })
131
+ const text = typeof run.text === 'string' ? run.text.trim() : ''
132
+ if (text) return text
133
+ if (run.error) return `Error: ${run.error}`
134
+ return ''
135
+ }
136
+
137
+ function notifyMainChatScheduleResult(task: BoardTask): void {
138
+ const scheduleTask = task as ScheduleTaskMeta
139
+ const sourceType = typeof scheduleTask.sourceType === 'string' ? scheduleTask.sourceType : ''
140
+ if (sourceType !== 'schedule') return
141
+ if (task.status !== 'completed' && task.status !== 'failed') return
142
+
143
+ const sessions = loadSessions()
144
+ const ownerUser = resolveTaskOwnerUser(scheduleTask, sessions as Record<string, SessionLike>)
145
+ const scheduleNameRaw = typeof scheduleTask.sourceScheduleName === 'string'
146
+ ? scheduleTask.sourceScheduleName.trim()
147
+ : ''
148
+ const scheduleName = scheduleNameRaw || (task.title || 'Scheduled Task').replace(/^\[Sched\]\s*/i, '').trim()
149
+
150
+ const runSessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
151
+ const runSession = runSessionId ? sessions[runSessionId] : null
152
+ const fallbackText = runSession ? latestAssistantText(runSession) : ''
153
+
154
+ // Zod-validated structured extraction: one pass to get summary + all artifacts
155
+ const taskResult = extractTaskResult(runSession, task.result || fallbackText || null)
156
+ const resultBody = formatResultBody(taskResult)
157
+
158
+ const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
159
+ const srcScheduleId = typeof scheduleTask.sourceScheduleId === 'string' ? scheduleTask.sourceScheduleId : ''
160
+ const taskLink = `[${task.title}](#task:${task.id})`
161
+ const schedLink = srcScheduleId ? ` | [Schedule](#schedule:${srcScheduleId})` : ''
162
+ const body = [
163
+ `Scheduled run ${statusLabel}: **${scheduleName || 'Scheduled Task'}** ${taskLink}${schedLink}`,
164
+ resultBody || 'No summary was returned.',
165
+ ].join('\n\n').trim()
166
+ if (!body) return
167
+
168
+ // First image artifact goes on imageUrl for the inline preview above markdown
169
+ const firstImage = taskResult.artifacts.find((a) => a.type === 'image')
170
+ const now = Date.now()
171
+ let changed = false
172
+
173
+ const buildMsg = (): Message => {
174
+ const msg: Message = { role: 'assistant', text: body, time: now, kind: 'system' }
175
+ if (firstImage) msg.imageUrl = firstImage.url
176
+ return msg
177
+ }
178
+
179
+ for (const session of Object.values(sessions) as SessionLike[]) {
180
+ if (!isMainSession(session)) continue
181
+ if (ownerUser && session?.user && session.user !== ownerUser) continue
182
+ const last = Array.isArray(session.messages) ? session.messages.at(-1) : null
183
+ if (last?.role === 'assistant' && last?.text === body && typeof last?.time === 'number' && now - last.time < 30_000) continue
184
+ if (!Array.isArray(session.messages)) session.messages = []
185
+ session.messages.push(buildMsg())
186
+ session.lastActiveAt = now
187
+ changed = true
188
+ }
189
+
190
+ // Also push to the agent's persistent thread session
191
+ try {
192
+ const agents = loadAgents()
193
+ const agent = agents[task.agentId]
194
+ if (agent?.threadSessionId && sessions[agent.threadSessionId]) {
195
+ const thread = sessions[agent.threadSessionId] as SessionLike
196
+ const threadLast = Array.isArray(thread.messages) ? thread.messages.at(-1) : null
197
+ if (!(threadLast?.role === 'assistant' && threadLast?.text === body && typeof threadLast?.time === 'number' && now - threadLast.time < 30_000)) {
198
+ if (!Array.isArray(thread.messages)) thread.messages = []
199
+ thread.messages.push(buildMsg())
200
+ thread.lastActiveAt = now
201
+ changed = true
202
+ }
203
+ }
204
+ } catch { /* ignore thread push failure */ }
205
+
206
+ if (changed) saveSessions(sessions)
207
+ }
208
+
209
+ /**
210
+ * Notify agent thread sessions when a task completes or fails.
211
+ * - Always pushes to the executing agent's thread
212
+ * - If delegated, also pushes to the delegating agent's thread
213
+ */
214
+ function notifyAgentThreadTaskResult(task: BoardTask): void {
215
+ if (task.status !== 'completed' && task.status !== 'failed') return
216
+
217
+ const sessions = loadSessions()
218
+ const agents = loadAgents()
219
+ const agent = agents[task.agentId]
220
+
221
+ const runSessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
222
+ const runSession = runSessionId ? sessions[runSessionId] : null
223
+ const fallbackText = runSession ? latestAssistantText(runSession) : ''
224
+ const taskResult = extractTaskResult(runSession, task.result || fallbackText || null)
225
+ const resultBody = formatResultBody(taskResult)
226
+
227
+ const statusLabel = task.status === 'completed' ? 'completed' : 'failed'
228
+ const taskLink = `[${task.title}](#task:${task.id})`
229
+ const firstImage = taskResult.artifacts.find((a) => a.type === 'image')
230
+ const now = Date.now()
231
+ let changed = false
232
+
233
+ // Build CLI resume ID info lines
234
+ const resumeLines: string[] = []
235
+ if (task.claudeResumeId) resumeLines.push(`Claude session: \`${task.claudeResumeId}\``)
236
+ if (task.codexResumeId) resumeLines.push(`Codex thread: \`${task.codexResumeId}\``)
237
+ if (task.opencodeResumeId) resumeLines.push(`OpenCode session: \`${task.opencodeResumeId}\``)
238
+ // Fallback to legacy field
239
+ if (resumeLines.length === 0 && task.cliResumeId) {
240
+ resumeLines.push(`${task.cliProvider || 'CLI'} session: \`${task.cliResumeId}\``)
241
+ }
242
+
243
+ // Get working directory from execution session
244
+ const execCwd = runSession?.cwd || ''
245
+
246
+ const buildMsg = (text: string): Message => {
247
+ const msg: Message = { role: 'assistant', text, time: now, kind: 'system' }
248
+ if (firstImage) msg.imageUrl = firstImage.url
249
+ return msg
250
+ }
251
+
252
+ const buildResultBlock = (prefix: string): string => {
253
+ const parts = [prefix]
254
+ if (execCwd) parts.push(`Working directory: \`${execCwd}\``)
255
+ if (resumeLines.length > 0) parts.push(resumeLines.join(' | '))
256
+ parts.push(resultBody || 'No summary.')
257
+ return parts.join('\n\n')
258
+ }
259
+
260
+ // 1. Push to executing agent's thread
261
+ if (agent?.threadSessionId && sessions[agent.threadSessionId]) {
262
+ const thread = sessions[agent.threadSessionId]
263
+ if (!Array.isArray(thread.messages)) thread.messages = []
264
+ const body = buildResultBlock(`Task ${statusLabel}: **${taskLink}**`)
265
+ thread.messages.push(buildMsg(body))
266
+ thread.lastActiveAt = now
267
+ changed = true
268
+ }
269
+
270
+ // 2. If delegated, push to delegating agent's thread
271
+ const delegatedBy = (task as unknown as Record<string, unknown>).delegatedByAgentId
272
+ if (typeof delegatedBy === 'string' && delegatedBy !== task.agentId) {
273
+ const delegator = agents[delegatedBy]
274
+ if (delegator?.threadSessionId && sessions[delegator.threadSessionId]) {
275
+ const thread = sessions[delegator.threadSessionId]
276
+ if (!Array.isArray(thread.messages)) thread.messages = []
277
+ const agentName = agent?.name || task.agentId
278
+ const body = buildResultBlock(`Delegated task ${statusLabel}: **${taskLink}** (by ${agentName})`)
279
+ thread.messages.push(buildMsg(body))
280
+ thread.lastActiveAt = now
281
+ changed = true
282
+ }
283
+ }
284
+
285
+ if (changed) saveSessions(sessions)
286
+ }
287
+
288
+ /** Disable heartbeat on a task's session when the task finishes. */
289
+ export function disableSessionHeartbeat(sessionId: string | null | undefined) {
290
+ if (!sessionId) return
291
+ const sessions = loadSessions()
292
+ const session = sessions[sessionId]
293
+ if (!session || session.heartbeatEnabled === false) return
294
+ session.heartbeatEnabled = false
295
+ session.lastActiveAt = Date.now()
296
+ saveSessions(sessions)
297
+ console.log(`[queue] Disabled heartbeat on session ${sessionId} (task finished)`)
298
+ }
299
+
300
+ export function enqueueTask(taskId: string) {
301
+ const tasks = loadTasks()
302
+ const task = tasks[taskId] as BoardTask | undefined
303
+ if (!task) return
304
+
305
+ applyTaskPolicyDefaults(task)
306
+ task.status = 'queued'
307
+ task.queuedAt = Date.now()
308
+ task.retryScheduledAt = null
309
+ task.updatedAt = Date.now()
310
+ saveTasks(tasks)
311
+
312
+ const queue = loadQueue()
313
+ pushQueueUnique(queue, taskId)
314
+ saveQueue(queue)
315
+
316
+ pushMainLoopEventToMainSessions({
317
+ type: 'task_queued',
318
+ text: `Task queued: "${task.title}" (${task.id})`,
319
+ })
320
+
321
+ // Delay before kicking worker so UI shows the queued state
322
+ setTimeout(() => processNext(), 2000)
323
+ }
324
+
325
+ /**
326
+ * Re-validate all completed tasks so the completed queue only contains
327
+ * tasks with concrete completion evidence.
328
+ */
329
+ export function validateCompletedTasksQueue() {
330
+ const tasks = loadTasks()
331
+ const sessions = loadSessions()
332
+ const now = Date.now()
333
+ let checked = 0
334
+ let demoted = 0
335
+ let tasksDirty = false
336
+ let sessionsDirty = false
337
+
338
+ for (const task of Object.values(tasks) as BoardTask[]) {
339
+ if (task.status !== 'completed') continue
340
+ checked++
341
+
342
+ const report = ensureTaskCompletionReport(task)
343
+ if (report?.relativePath && task.completionReportPath !== report.relativePath) {
344
+ task.completionReportPath = report.relativePath
345
+ tasksDirty = true
346
+ }
347
+
348
+ const validation = validateTaskCompletion(task, { report })
349
+ const prevValidation = task.validation || null
350
+ const validationChanged = !prevValidation
351
+ || prevValidation.ok !== validation.ok
352
+ || !sameReasons(prevValidation.reasons, validation.reasons)
353
+
354
+ if (validationChanged) {
355
+ task.validation = validation
356
+ tasksDirty = true
357
+ }
358
+
359
+ if (validation.ok) {
360
+ if (!task.completedAt) {
361
+ task.completedAt = now
362
+ task.updatedAt = now
363
+ tasksDirty = true
364
+ }
365
+ continue
366
+ }
367
+
368
+ task.status = 'failed'
369
+ task.completedAt = null
370
+ task.error = formatValidationFailure(validation.reasons).slice(0, 500)
371
+ task.updatedAt = now
372
+ if (!task.comments) task.comments = []
373
+ task.comments.push({
374
+ id: crypto.randomBytes(4).toString('hex'),
375
+ author: 'System',
376
+ text: `Task auto-failed completed-queue validation.\n\n${validation.reasons.map((r) => `- ${r}`).join('\n')}`,
377
+ createdAt: now,
378
+ })
379
+ tasksDirty = true
380
+ demoted++
381
+
382
+ if (task.sessionId) {
383
+ const session = sessions[task.sessionId]
384
+ if (session && session.heartbeatEnabled !== false) {
385
+ session.heartbeatEnabled = false
386
+ session.lastActiveAt = now
387
+ sessionsDirty = true
388
+ }
389
+ }
390
+ }
391
+
392
+ if (tasksDirty) saveTasks(tasks)
393
+ if (sessionsDirty) saveSessions(sessions)
394
+ if (demoted > 0) {
395
+ console.warn(`[queue] Demoted ${demoted} invalid completed task(s) to failed after validation audit`)
396
+ }
397
+ return { checked, demoted }
398
+ }
399
+
400
+ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | 'dead_lettered' {
401
+ applyTaskPolicyDefaults(task)
402
+ const now = Date.now()
403
+ task.attempts = (task.attempts || 0) + 1
404
+
405
+ if ((task.attempts || 0) < (task.maxAttempts || 1)) {
406
+ const delaySec = Math.min(6 * 3600, (task.retryBackoffSec || 30) * (2 ** Math.max(0, (task.attempts || 1) - 1)))
407
+ task.status = 'queued'
408
+ task.retryScheduledAt = now + delaySec * 1000
409
+ task.updatedAt = now
410
+ task.error = `Retry scheduled after failure: ${reason}`.slice(0, 500)
411
+ if (!task.comments) task.comments = []
412
+ task.comments.push({
413
+ id: crypto.randomBytes(4).toString('hex'),
414
+ author: 'System',
415
+ text: `Attempt ${task.attempts}/${task.maxAttempts} failed. Retrying in ${delaySec}s.\n\nReason: ${reason}`,
416
+ createdAt: now,
417
+ })
418
+ return 'retry'
419
+ }
420
+
421
+ task.status = 'failed'
422
+ task.deadLetteredAt = now
423
+ task.retryScheduledAt = null
424
+ task.updatedAt = now
425
+ task.error = `Dead-lettered after ${task.attempts}/${task.maxAttempts} attempts: ${reason}`.slice(0, 500)
426
+ if (!task.comments) task.comments = []
427
+ task.comments.push({
428
+ id: crypto.randomBytes(4).toString('hex'),
429
+ author: 'System',
430
+ text: `Task moved to dead-letter after ${task.attempts}/${task.maxAttempts} attempts.\n\nReason: ${reason}`,
431
+ createdAt: now,
432
+ })
433
+ return 'dead_lettered'
434
+ }
435
+
436
+ function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
437
+ const now = Date.now()
438
+
439
+ // Remove stale entries first.
440
+ for (let i = queue.length - 1; i >= 0; i--) {
441
+ const id = queue[i]
442
+ const task = tasks[id]
443
+ if (!task || task.status !== 'queued') queue.splice(i, 1)
444
+ }
445
+
446
+ const idx = queue.findIndex((id) => {
447
+ const task = tasks[id]
448
+ if (!task) return false
449
+ const retryAt = typeof task.retryScheduledAt === 'number' ? task.retryScheduledAt : null
450
+ return !retryAt || retryAt <= now
451
+ })
452
+ if (idx === -1) return null
453
+ const [taskId] = queue.splice(idx, 1)
454
+ return taskId || null
455
+ }
456
+
457
+ export async function processNext() {
458
+ if (processing) return
459
+ processing = true
460
+
461
+ try {
462
+ // Recover orphaned tasks: status is 'queued' but missing from the queue array
463
+ {
464
+ const allTasks = loadTasks()
465
+ const currentQueue = loadQueue()
466
+ const queueSet = new Set(currentQueue)
467
+ let recovered = false
468
+ for (const [id, t] of Object.entries(allTasks) as [string, BoardTask][]) {
469
+ if (t.status === 'queued' && !queueSet.has(id)) {
470
+ console.log(`[queue] Recovering orphaned queued task: "${t.title}" (${id})`)
471
+ pushQueueUnique(currentQueue, id)
472
+ recovered = true
473
+ }
474
+ }
475
+ if (recovered) saveQueue(currentQueue)
476
+ }
477
+
478
+ while (true) {
479
+ const tasks = loadTasks()
480
+ const queue = loadQueue()
481
+ if (queue.length === 0) break
482
+
483
+ const taskId = dequeueNextRunnableTask(queue, tasks as Record<string, BoardTask>)
484
+ saveQueue(queue)
485
+ if (!taskId) break
486
+ const task = tasks[taskId] as BoardTask | undefined
487
+
488
+ if (!task || task.status !== 'queued') {
489
+ continue
490
+ }
491
+
492
+ const agents = loadAgents()
493
+ const agent = agents[task.agentId]
494
+ if (!agent) {
495
+ task.status = 'failed'
496
+ task.deadLetteredAt = Date.now()
497
+ task.error = `Agent ${task.agentId} not found`
498
+ task.updatedAt = Date.now()
499
+ saveTasks(tasks)
500
+ pushMainLoopEventToMainSessions({
501
+ type: 'task_failed',
502
+ text: `Task failed: "${task.title}" (${task.id}) — agent not found.`,
503
+ })
504
+ continue
505
+ }
506
+
507
+ // Mark as running
508
+ applyTaskPolicyDefaults(task)
509
+ task.status = 'running'
510
+ task.startedAt = Date.now()
511
+ task.retryScheduledAt = null
512
+ task.deadLetteredAt = null
513
+ task.updatedAt = Date.now()
514
+
515
+ const taskCwd = task.cwd || process.cwd()
516
+ let sessionId = ''
517
+ const scheduleTask = task as ScheduleTaskMeta
518
+ const isScheduleTask = scheduleTask.sourceType === 'schedule'
519
+ const sourceScheduleId = typeof scheduleTask.sourceScheduleId === 'string'
520
+ ? scheduleTask.sourceScheduleId
521
+ : ''
522
+
523
+ // Resolve the agent's persistent thread session to use as parentSessionId
524
+ const agentThreadSessionId = agent.threadSessionId || null
525
+
526
+ if (isScheduleTask && sourceScheduleId) {
527
+ const schedules = loadSchedules()
528
+ const linkedSchedule = schedules[sourceScheduleId]
529
+ const existingSessionId = typeof linkedSchedule?.lastSessionId === 'string'
530
+ ? linkedSchedule.lastSessionId
531
+ : ''
532
+ if (existingSessionId) {
533
+ const sessions = loadSessions()
534
+ if (sessions[existingSessionId]) {
535
+ sessionId = existingSessionId
536
+ }
537
+ }
538
+ if (!sessionId) {
539
+ sessionId = createOrchestratorSession(agent, task.title, agentThreadSessionId || undefined, taskCwd)
540
+ }
541
+ if (linkedSchedule && linkedSchedule.lastSessionId !== sessionId) {
542
+ linkedSchedule.lastSessionId = sessionId
543
+ linkedSchedule.updatedAt = Date.now()
544
+ schedules[sourceScheduleId] = linkedSchedule
545
+ saveSchedules(schedules)
546
+ }
547
+ } else {
548
+ sessionId = createOrchestratorSession(agent, task.title, agentThreadSessionId || undefined, taskCwd)
549
+ }
550
+
551
+ // Notify the agent's thread that a task has started
552
+ if (agentThreadSessionId) {
553
+ try {
554
+ const threadSessions = loadSessions()
555
+ const thread = threadSessions[agentThreadSessionId]
556
+ if (thread) {
557
+ if (!Array.isArray(thread.messages)) thread.messages = []
558
+ const scheduleTask2 = task as ScheduleTaskMeta
559
+ const schedId = typeof scheduleTask2.sourceScheduleId === 'string' ? scheduleTask2.sourceScheduleId : ''
560
+ const runLabel = task.runNumber ? ` (run #${task.runNumber})` : ''
561
+ const taskLink = `[${task.title}](#task:${task.id})`
562
+ const schedLink = schedId ? ` | [Schedule](#schedule:${schedId})` : ''
563
+ thread.messages.push({
564
+ role: 'assistant',
565
+ text: `Started task: **${taskLink}**${runLabel}${schedLink}`,
566
+ time: Date.now(),
567
+ kind: 'system',
568
+ })
569
+ thread.lastActiveAt = Date.now()
570
+ saveSessions(threadSessions)
571
+ }
572
+ } catch { /* ignore thread notification failure */ }
573
+ }
574
+
575
+ task.sessionId = sessionId
576
+ task.checkpoint = {
577
+ lastSessionId: sessionId,
578
+ note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started`,
579
+ updatedAt: Date.now(),
580
+ }
581
+ saveTasks(tasks)
582
+ pushMainLoopEventToMainSessions({
583
+ type: 'task_running',
584
+ text: `Task running: "${task.title}" (${task.id}) with ${agent.name}`,
585
+ })
586
+
587
+ // Save initial assistant message so user sees context when opening the session
588
+ const sessions = loadSessions()
589
+ if (sessions[sessionId]) {
590
+ sessions[sessionId].messages.push({
591
+ role: 'assistant',
592
+ text: `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`\n\nI'll begin working on this now.`,
593
+ time: Date.now(),
594
+ })
595
+ saveSessions(sessions)
596
+ }
597
+
598
+ console.log(`[queue] Running task "${task.title}" (${taskId}) with ${agent.name}`)
599
+
600
+ try {
601
+ const result = await executeTaskRun(task, agent, sessionId)
602
+ const t2 = loadTasks()
603
+ if (t2[taskId]) {
604
+ applyTaskPolicyDefaults(t2[taskId])
605
+ // Structured extraction: Zod-validated result with typed artifacts
606
+ const runSessions = loadSessions()
607
+ const taskResult = extractTaskResult(runSessions[sessionId], result || null)
608
+ const enrichedResult = formatResultBody(taskResult)
609
+ t2[taskId].result = enrichedResult.slice(0, 4000) || null
610
+ t2[taskId].updatedAt = Date.now()
611
+ const report = ensureTaskCompletionReport(t2[taskId])
612
+ if (report?.relativePath) t2[taskId].completionReportPath = report.relativePath
613
+ const validation = validateTaskCompletion(t2[taskId], { report })
614
+ t2[taskId].validation = validation
615
+
616
+ const now = Date.now()
617
+ // Add a completion/failure comment from the orchestrator.
618
+ if (!t2[taskId].comments) t2[taskId].comments = []
619
+
620
+ if (validation.ok) {
621
+ t2[taskId].status = 'completed'
622
+ t2[taskId].completedAt = now
623
+ t2[taskId].retryScheduledAt = null
624
+ t2[taskId].error = null
625
+ t2[taskId].checkpoint = {
626
+ ...(t2[taskId].checkpoint || {}),
627
+ lastRunId: sessionId,
628
+ lastSessionId: sessionId,
629
+ note: `Completed on attempt ${t2[taskId].attempts || 0}/${t2[taskId].maxAttempts || '?'}`,
630
+ updatedAt: now,
631
+ }
632
+ t2[taskId].comments!.push({
633
+ id: crypto.randomBytes(4).toString('hex'),
634
+ author: agent.name,
635
+ agentId: agent.id,
636
+ text: `Task completed.\n\n${result?.slice(0, 1000) || 'No summary provided.'}`,
637
+ createdAt: now,
638
+ })
639
+ } else {
640
+ const failureReason = formatValidationFailure(validation.reasons).slice(0, 500)
641
+ const retryState = scheduleRetryOrDeadLetter(t2[taskId], failureReason)
642
+ t2[taskId].completedAt = retryState === 'dead_lettered' ? null : t2[taskId].completedAt
643
+ t2[taskId].comments!.push({
644
+ id: crypto.randomBytes(4).toString('hex'),
645
+ author: agent.name,
646
+ agentId: agent.id,
647
+ text: `Task failed validation and was not marked completed.\n\n${validation.reasons.map((r) => `- ${r}`).join('\n')}`,
648
+ createdAt: now,
649
+ })
650
+ if (retryState === 'retry') {
651
+ const qRetry = loadQueue()
652
+ pushQueueUnique(qRetry, taskId)
653
+ saveQueue(qRetry)
654
+ pushMainLoopEventToMainSessions({
655
+ type: 'task_retry_scheduled',
656
+ text: `Task retry scheduled: "${task.title}" (${taskId}) attempt ${t2[taskId].attempts}/${t2[taskId].maxAttempts} in ${t2[taskId].retryBackoffSec}s.`,
657
+ })
658
+ }
659
+ }
660
+
661
+ // Copy ALL CLI resume IDs from the execution session to the task record
662
+ try {
663
+ const execSessions = loadSessions()
664
+ const execSession = execSessions[sessionId] as Record<string, unknown> | undefined
665
+ if (execSession) {
666
+ const delegateIds = execSession.delegateResumeIds as
667
+ | { claudeCode?: string | null; codex?: string | null; opencode?: string | null }
668
+ | undefined
669
+ // Store each CLI resume ID separately
670
+ const claudeId = (execSession.claudeSessionId as string) || delegateIds?.claudeCode || null
671
+ const codexId = (execSession.codexThreadId as string) || delegateIds?.codex || null
672
+ const opencodeId = (execSession.opencodeSessionId as string) || delegateIds?.opencode || null
673
+ if (claudeId) t2[taskId].claudeResumeId = claudeId
674
+ if (codexId) t2[taskId].codexResumeId = codexId
675
+ if (opencodeId) t2[taskId].opencodeResumeId = opencodeId
676
+ // Keep backward-compat single field (first available)
677
+ const primaryId = claudeId || codexId || opencodeId
678
+ if (primaryId) {
679
+ t2[taskId].cliResumeId = primaryId
680
+ if (claudeId) t2[taskId].cliProvider = 'claude-cli'
681
+ else if (codexId) t2[taskId].cliProvider = 'codex-cli'
682
+ else if (opencodeId) t2[taskId].cliProvider = 'opencode-cli'
683
+ }
684
+ console.log(`[queue] CLI resume IDs for task ${taskId}: claude=${claudeId}, codex=${codexId}, opencode=${opencodeId}`)
685
+ }
686
+ } catch (e) {
687
+ console.warn(`[queue] Failed to extract CLI resume IDs for task ${taskId}:`, e)
688
+ }
689
+
690
+ saveTasks(t2)
691
+ disableSessionHeartbeat(t2[taskId].sessionId)
692
+ }
693
+ const doneTask = t2[taskId]
694
+ if (doneTask?.status === 'completed') {
695
+ pushMainLoopEventToMainSessions({
696
+ type: 'task_completed',
697
+ text: `Task completed: "${task.title}" (${taskId})`,
698
+ })
699
+ notifyMainChatScheduleResult(doneTask)
700
+ notifyAgentThreadTaskResult(doneTask)
701
+ console.log(`[queue] Task "${task.title}" completed`)
702
+ } else {
703
+ if (doneTask?.status === 'queued') {
704
+ console.warn(`[queue] Task "${task.title}" scheduled for retry`)
705
+ } else {
706
+ pushMainLoopEventToMainSessions({
707
+ type: 'task_failed',
708
+ text: `Task failed validation: "${task.title}" (${taskId})`,
709
+ })
710
+ if (doneTask?.status === 'failed') {
711
+ notifyMainChatScheduleResult(doneTask)
712
+ notifyAgentThreadTaskResult(doneTask)
713
+ }
714
+ console.warn(`[queue] Task "${task.title}" failed completion validation`)
715
+ }
716
+ }
717
+ } catch (err: unknown) {
718
+ const errMsg = err instanceof Error ? err.message : String(err || 'Unknown error')
719
+ console.error(`[queue] Task "${task.title}" failed:`, errMsg)
720
+ const t2 = loadTasks()
721
+ if (t2[taskId]) {
722
+ applyTaskPolicyDefaults(t2[taskId])
723
+ const retryState = scheduleRetryOrDeadLetter(t2[taskId], errMsg.slice(0, 500) || 'Unknown error')
724
+ if (!t2[taskId].comments) t2[taskId].comments = []
725
+ // Only add a failure comment if the last comment isn't already an error comment
726
+ const lastComment = t2[taskId].comments!.at(-1)
727
+ const isRepeatError = lastComment?.agentId === agent.id && lastComment?.text.startsWith('Task failed')
728
+ if (!isRepeatError) {
729
+ t2[taskId].comments!.push({
730
+ id: crypto.randomBytes(4).toString('hex'),
731
+ author: agent.name,
732
+ agentId: agent.id,
733
+ text: 'Task failed — see error details above.',
734
+ createdAt: Date.now(),
735
+ })
736
+ }
737
+ saveTasks(t2)
738
+ disableSessionHeartbeat(t2[taskId].sessionId)
739
+ if (retryState === 'retry') {
740
+ const qRetry = loadQueue()
741
+ pushQueueUnique(qRetry, taskId)
742
+ saveQueue(qRetry)
743
+ pushMainLoopEventToMainSessions({
744
+ type: 'task_retry_scheduled',
745
+ text: `Task retry scheduled: "${task.title}" (${taskId}) attempt ${t2[taskId].attempts}/${t2[taskId].maxAttempts}.`,
746
+ })
747
+ }
748
+ }
749
+ const latest = loadTasks()[taskId] as BoardTask | undefined
750
+ if (latest?.status === 'queued') {
751
+ console.warn(`[queue] Task "${task.title}" queued for retry after error`)
752
+ } else {
753
+ pushMainLoopEventToMainSessions({
754
+ type: 'task_failed',
755
+ text: `Task failed: "${task.title}" (${taskId}) — ${errMsg.slice(0, 200)}`,
756
+ })
757
+ if (latest?.status === 'failed') {
758
+ notifyMainChatScheduleResult(latest)
759
+ notifyAgentThreadTaskResult(latest)
760
+ }
761
+ }
762
+ }
763
+ }
764
+ } finally {
765
+ processing = false
766
+ }
767
+ }
768
+
769
+ /** On boot, disable heartbeat on sessions whose tasks are already completed/failed. */
770
+ export function cleanupFinishedTaskSessions() {
771
+ const tasks = loadTasks()
772
+ const sessions = loadSessions()
773
+ let cleaned = 0
774
+ for (const task of Object.values(tasks) as BoardTask[]) {
775
+ if ((task.status === 'completed' || task.status === 'failed') && task.sessionId) {
776
+ const session = sessions[task.sessionId]
777
+ if (session && session.heartbeatEnabled !== false) {
778
+ session.heartbeatEnabled = false
779
+ session.lastActiveAt = Date.now()
780
+ cleaned++
781
+ }
782
+ }
783
+ }
784
+ if (cleaned > 0) {
785
+ saveSessions(sessions)
786
+ console.log(`[queue] Disabled heartbeat on ${cleaned} session(s) with finished tasks`)
787
+ }
788
+ }
789
+
790
+ /** Recover running tasks that appear stalled and requeue/dead-letter them per retry policy. */
791
+ export function recoverStalledRunningTasks(): { recovered: number; deadLettered: number } {
792
+ const settings = loadSettings()
793
+ const stallTimeoutMin = normalizeInt(settings.taskStallTimeoutMin, 45, 5, 24 * 60)
794
+ const staleMs = stallTimeoutMin * 60_000
795
+ const now = Date.now()
796
+ const tasks = loadTasks()
797
+ const queue = loadQueue()
798
+ let recovered = 0
799
+ let deadLettered = 0
800
+ let changed = false
801
+
802
+ for (const task of Object.values(tasks) as BoardTask[]) {
803
+ if (task.status !== 'running') continue
804
+ const since = Math.max(task.updatedAt || 0, task.startedAt || 0)
805
+ if (!since || (now - since) < staleMs) continue
806
+
807
+ const reason = `Detected stalled run after ${stallTimeoutMin}m without progress`
808
+ const state = scheduleRetryOrDeadLetter(task, reason)
809
+ disableSessionHeartbeat(task.sessionId)
810
+ changed = true
811
+ if (state === 'retry') {
812
+ pushQueueUnique(queue, task.id)
813
+ recovered++
814
+ pushMainLoopEventToMainSessions({
815
+ type: 'task_stall_recovered',
816
+ text: `Recovered stalled task "${task.title}" (${task.id}) and requeued attempt ${task.attempts}/${task.maxAttempts}.`,
817
+ })
818
+ } else {
819
+ deadLettered++
820
+ pushMainLoopEventToMainSessions({
821
+ type: 'task_dead_lettered',
822
+ text: `Task dead-lettered after stalling: "${task.title}" (${task.id}).`,
823
+ })
824
+ }
825
+ }
826
+
827
+ if (changed) {
828
+ saveTasks(tasks)
829
+ saveQueue(queue)
830
+ }
831
+
832
+ return { recovered, deadLettered }
833
+ }
834
+
835
+ /** Resume any queued tasks on server boot */
836
+ export function resumeQueue() {
837
+ // Check for tasks stuck in 'queued' status but not in the queue array
838
+ const tasks = loadTasks()
839
+ const queue = loadQueue()
840
+ let modified = false
841
+ for (const task of Object.values(tasks) as BoardTask[]) {
842
+ if (task.status === 'queued' && !queue.includes(task.id)) {
843
+ applyTaskPolicyDefaults(task)
844
+ console.log(`[queue] Recovering stuck queued task: "${task.title}" (${task.id})`)
845
+ queue.push(task.id)
846
+ task.queuedAt = task.queuedAt || Date.now()
847
+ modified = true
848
+ }
849
+ }
850
+ if (modified) {
851
+ saveQueue(queue)
852
+ saveTasks(tasks)
853
+ }
854
+
855
+ if (queue.length > 0) {
856
+ console.log(`[queue] Resuming ${queue.length} queued task(s) on boot`)
857
+ processNext()
858
+ }
859
+ }