@swarmclawai/swarmclaw 0.7.7 → 0.8.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 (281) hide show
  1. package/README.md +12 -14
  2. package/next.config.ts +13 -2
  3. package/package.json +4 -2
  4. package/src/app/api/agents/[id]/thread/route.ts +9 -0
  5. package/src/app/api/agents/route.ts +4 -0
  6. package/src/app/api/agents/thread-route.test.ts +133 -0
  7. package/src/app/api/approvals/route.test.ts +148 -0
  8. package/src/app/api/canvas/[sessionId]/route.ts +3 -1
  9. package/src/app/api/chatrooms/[id]/chat/route.ts +4 -2
  10. package/src/app/api/chats/[id]/devserver/route.ts +48 -7
  11. package/src/app/api/chats/[id]/messages/route.ts +42 -18
  12. package/src/app/api/chats/[id]/route.ts +1 -1
  13. package/src/app/api/chats/[id]/stop/route.ts +5 -4
  14. package/src/app/api/chats/route.ts +23 -2
  15. package/src/app/api/clawhub/install/route.ts +28 -8
  16. package/src/app/api/connectors/[id]/route.ts +46 -3
  17. package/src/app/api/connectors/route.ts +12 -8
  18. package/src/app/api/external-agents/route.test.ts +165 -0
  19. package/src/app/api/gateways/[id]/health/route.ts +27 -12
  20. package/src/app/api/gateways/[id]/route.ts +2 -0
  21. package/src/app/api/gateways/health-route.test.ts +135 -0
  22. package/src/app/api/gateways/route.ts +2 -0
  23. package/src/app/api/mcp-servers/route.test.ts +130 -0
  24. package/src/app/api/openclaw/deploy/route.ts +38 -5
  25. package/src/app/api/plugins/install/route.ts +46 -6
  26. package/src/app/api/plugins/marketplace/route.ts +48 -15
  27. package/src/app/api/preview-server/route.ts +26 -11
  28. package/src/app/api/projects/[id]/route.ts +6 -2
  29. package/src/app/api/projects/route.ts +4 -3
  30. package/src/app/api/schedules/[id]/run/route.ts +4 -0
  31. package/src/app/api/schedules/route.test.ts +86 -0
  32. package/src/app/api/schedules/route.ts +6 -1
  33. package/src/app/api/secrets/[id]/route.ts +1 -0
  34. package/src/app/api/secrets/route.ts +2 -1
  35. package/src/app/api/settings/route.ts +2 -0
  36. package/src/app/api/setup/check-provider/route.test.ts +19 -0
  37. package/src/app/api/setup/check-provider/route.ts +40 -10
  38. package/src/app/api/skills/[id]/route.ts +12 -0
  39. package/src/app/api/skills/import/route.ts +14 -12
  40. package/src/app/api/skills/route.ts +13 -1
  41. package/src/app/api/tasks/[id]/route.ts +10 -1
  42. package/src/app/api/tasks/import/github/route.test.ts +65 -0
  43. package/src/app/api/tasks/import/github/route.ts +337 -0
  44. package/src/app/api/wallets/[id]/approve/route.ts +17 -3
  45. package/src/app/api/wallets/[id]/route.ts +79 -33
  46. package/src/app/api/wallets/[id]/send/route.ts +19 -33
  47. package/src/app/api/wallets/route.ts +78 -61
  48. package/src/app/api/webhooks/[id]/route.ts +33 -6
  49. package/src/app/api/webhooks/route.test.ts +272 -0
  50. package/src/cli/index.js +1 -0
  51. package/src/cli/spec.js +1 -0
  52. package/src/components/agents/agent-card.tsx +9 -2
  53. package/src/components/agents/agent-chat-list.tsx +18 -2
  54. package/src/components/agents/agent-list.tsx +1 -0
  55. package/src/components/agents/agent-sheet.tsx +257 -38
  56. package/src/components/agents/inspector-panel.tsx +41 -0
  57. package/src/components/canvas/canvas-panel.tsx +236 -65
  58. package/src/components/chat/chat-area.tsx +36 -19
  59. package/src/components/chat/chat-card.tsx +36 -13
  60. package/src/components/chat/chat-header.tsx +48 -16
  61. package/src/components/chat/chat-list.tsx +28 -4
  62. package/src/components/chat/checkpoint-timeline.tsx +50 -34
  63. package/src/components/chat/delegation-banner.test.ts +14 -1
  64. package/src/components/chat/delegation-banner.tsx +1 -1
  65. package/src/components/chat/message-bubble.tsx +208 -145
  66. package/src/components/chat/message-list.tsx +48 -19
  67. package/src/components/chatrooms/chatroom-message.tsx +2 -2
  68. package/src/components/chatrooms/chatroom-sheet.tsx +16 -2
  69. package/src/components/connectors/connector-health.tsx +1 -1
  70. package/src/components/connectors/connector-list.tsx +7 -2
  71. package/src/components/connectors/connector-sheet.tsx +337 -148
  72. package/src/components/gateways/gateway-sheet.tsx +2 -2
  73. package/src/components/layout/app-layout.tsx +40 -23
  74. package/src/components/mcp-servers/mcp-server-list.tsx +26 -5
  75. package/src/components/mcp-servers/mcp-server-sheet.tsx +19 -2
  76. package/src/components/openclaw/openclaw-deploy-panel.tsx +269 -21
  77. package/src/components/plugins/plugin-list.tsx +45 -9
  78. package/src/components/plugins/plugin-sheet.tsx +55 -7
  79. package/src/components/projects/project-detail.tsx +217 -0
  80. package/src/components/projects/project-sheet.tsx +176 -4
  81. package/src/components/providers/provider-list.tsx +2 -1
  82. package/src/components/providers/provider-sheet.tsx +21 -2
  83. package/src/components/schedules/schedule-card.tsx +25 -1
  84. package/src/components/schedules/schedule-sheet.tsx +44 -2
  85. package/src/components/secrets/secret-sheet.tsx +21 -2
  86. package/src/components/shared/agent-switch-dialog.tsx +12 -1
  87. package/src/components/shared/bottom-sheet.tsx +13 -3
  88. package/src/components/shared/command-palette.tsx +8 -1
  89. package/src/components/shared/confirm-dialog.tsx +19 -4
  90. package/src/components/shared/connector-platform-icon.test.ts +28 -0
  91. package/src/components/shared/connector-platform-icon.tsx +39 -6
  92. package/src/components/shared/settings/plugin-manager.tsx +29 -6
  93. package/src/components/shared/settings/section-capability-policy.tsx +45 -3
  94. package/src/components/shared/settings/section-voice.tsx +11 -3
  95. package/src/components/skills/skill-list.tsx +25 -0
  96. package/src/components/skills/skill-sheet.tsx +84 -12
  97. package/src/components/tasks/approvals-panel.tsx +289 -34
  98. package/src/components/tasks/task-board.tsx +410 -25
  99. package/src/components/tasks/task-card.tsx +66 -8
  100. package/src/components/tasks/task-sheet.tsx +16 -4
  101. package/src/components/ui/dialog.tsx +2 -2
  102. package/src/components/wallets/wallet-approval-dialog.tsx +4 -2
  103. package/src/components/wallets/wallet-panel.tsx +435 -90
  104. package/src/components/wallets/wallet-section.tsx +198 -48
  105. package/src/components/webhooks/webhook-sheet.tsx +22 -2
  106. package/src/lib/approval-display.ts +20 -0
  107. package/src/lib/canvas-content.ts +198 -0
  108. package/src/lib/chat-artifact-summary.ts +165 -0
  109. package/src/lib/chat-display.test.ts +91 -0
  110. package/src/lib/chat-display.ts +58 -0
  111. package/src/lib/chat-streaming-state.test.ts +47 -1
  112. package/src/lib/chat-streaming-state.ts +42 -0
  113. package/src/lib/ollama-model.ts +10 -0
  114. package/src/lib/openclaw-endpoint.test.ts +8 -0
  115. package/src/lib/openclaw-endpoint.ts +6 -1
  116. package/src/lib/plugin-install-cors.ts +46 -0
  117. package/src/lib/plugin-sources.test.ts +43 -0
  118. package/src/lib/plugin-sources.ts +77 -0
  119. package/src/lib/providers/ollama.ts +16 -6
  120. package/src/lib/providers/openclaw.test.ts +54 -0
  121. package/src/lib/providers/openclaw.ts +127 -11
  122. package/src/lib/schedule-dedupe-advanced.test.ts +1335 -0
  123. package/src/lib/schedule-dedupe.test.ts +66 -1
  124. package/src/lib/schedule-dedupe.ts +169 -12
  125. package/src/lib/schedule-origin.test.ts +20 -0
  126. package/src/lib/schedule-origin.ts +15 -0
  127. package/src/lib/server/__fixtures__/fake-mcp-stdio-server.mjs +27 -0
  128. package/src/lib/server/agent-availability.ts +16 -0
  129. package/src/lib/server/agent-runtime-config.ts +12 -4
  130. package/src/lib/server/agent-thread-session.test.ts +51 -0
  131. package/src/lib/server/agent-thread-session.ts +7 -0
  132. package/src/lib/server/approval-match.ts +205 -0
  133. package/src/lib/server/approvals-auto-approve.test.ts +538 -1
  134. package/src/lib/server/approvals.ts +214 -1
  135. package/src/lib/server/assistant-control.test.ts +29 -0
  136. package/src/lib/server/assistant-control.ts +23 -0
  137. package/src/lib/server/build-llm.test.ts +79 -0
  138. package/src/lib/server/build-llm.ts +14 -4
  139. package/src/lib/server/canvas-content.test.ts +32 -0
  140. package/src/lib/server/canvas-content.ts +6 -0
  141. package/src/lib/server/capability-router.test.ts +33 -0
  142. package/src/lib/server/capability-router.ts +80 -19
  143. package/src/lib/server/chat-execution-advanced.test.ts +651 -0
  144. package/src/lib/server/chat-execution-disabled.test.ts +94 -0
  145. package/src/lib/server/chat-execution-tool-events.test.ts +157 -0
  146. package/src/lib/server/chat-execution.ts +378 -73
  147. package/src/lib/server/clawhub-client.test.ts +14 -8
  148. package/src/lib/server/connectors/manager-reconnect.test.ts +47 -0
  149. package/src/lib/server/connectors/manager.test.ts +1147 -0
  150. package/src/lib/server/connectors/manager.ts +461 -137
  151. package/src/lib/server/connectors/pairing.ts +26 -5
  152. package/src/lib/server/connectors/types.ts +2 -0
  153. package/src/lib/server/connectors/whatsapp.test.ts +134 -0
  154. package/src/lib/server/connectors/whatsapp.ts +271 -47
  155. package/src/lib/server/context-manager.ts +6 -1
  156. package/src/lib/server/daemon-state.ts +84 -47
  157. package/src/lib/server/data-dir.test.ts +37 -0
  158. package/src/lib/server/data-dir.ts +20 -1
  159. package/src/lib/server/delegation-jobs-advanced.test.ts +513 -0
  160. package/src/lib/server/devserver-launch.test.ts +60 -0
  161. package/src/lib/server/devserver-launch.ts +85 -0
  162. package/src/lib/server/elevenlabs.test.ts +247 -1
  163. package/src/lib/server/elevenlabs.ts +147 -43
  164. package/src/lib/server/ethereum.ts +590 -0
  165. package/src/lib/server/eval/agent-regression-advanced.test.ts +302 -0
  166. package/src/lib/server/eval/agent-regression.test.ts +18 -1
  167. package/src/lib/server/eval/agent-regression.ts +383 -11
  168. package/src/lib/server/evm-swap.ts +475 -0
  169. package/src/lib/server/execution-log.ts +1 -0
  170. package/src/lib/server/heartbeat-service-timer.test.ts +173 -0
  171. package/src/lib/server/heartbeat-service.ts +20 -11
  172. package/src/lib/server/heartbeat-wake.test.ts +112 -0
  173. package/src/lib/server/heartbeat-wake.ts +338 -57
  174. package/src/lib/server/main-agent-loop-advanced.test.ts +538 -0
  175. package/src/lib/server/main-agent-loop.test.ts +260 -0
  176. package/src/lib/server/main-agent-loop.ts +559 -14
  177. package/src/lib/server/mcp-client.test.ts +16 -0
  178. package/src/lib/server/mcp-client.ts +25 -0
  179. package/src/lib/server/memory-integration.test.ts +719 -0
  180. package/src/lib/server/memory-policy.test.ts +43 -0
  181. package/src/lib/server/memory-policy.ts +132 -0
  182. package/src/lib/server/memory-tiers.test.ts +60 -0
  183. package/src/lib/server/memory-tiers.ts +16 -0
  184. package/src/lib/server/ollama-runtime.ts +58 -0
  185. package/src/lib/server/openclaw-deploy.test.ts +109 -1
  186. package/src/lib/server/openclaw-deploy.ts +557 -81
  187. package/src/lib/server/openclaw-gateway.test.ts +131 -0
  188. package/src/lib/server/openclaw-gateway.ts +10 -4
  189. package/src/lib/server/openclaw-health.test.ts +35 -0
  190. package/src/lib/server/openclaw-health.ts +215 -47
  191. package/src/lib/server/orchestrator-lg.ts +3 -2
  192. package/src/lib/server/orchestrator.ts +2 -0
  193. package/src/lib/server/plugins-advanced.test.ts +351 -0
  194. package/src/lib/server/plugins.ts +211 -6
  195. package/src/lib/server/project-context.ts +162 -0
  196. package/src/lib/server/project-utils.ts +150 -0
  197. package/src/lib/server/queue-advanced.test.ts +528 -0
  198. package/src/lib/server/queue-followups.test.ts +409 -2
  199. package/src/lib/server/queue-reconcile.test.ts +128 -0
  200. package/src/lib/server/queue.ts +527 -68
  201. package/src/lib/server/scheduler.ts +29 -1
  202. package/src/lib/server/session-note.test.ts +36 -0
  203. package/src/lib/server/session-note.ts +42 -0
  204. package/src/lib/server/session-run-manager.ts +83 -4
  205. package/src/lib/server/session-tools/canvas.ts +14 -12
  206. package/src/lib/server/session-tools/connector-inputs.test.ts +37 -0
  207. package/src/lib/server/session-tools/connector.test.ts +138 -0
  208. package/src/lib/server/session-tools/connector.ts +366 -54
  209. package/src/lib/server/session-tools/context.ts +17 -3
  210. package/src/lib/server/session-tools/crud.ts +484 -84
  211. package/src/lib/server/session-tools/delegate-fallback.test.ts +103 -0
  212. package/src/lib/server/session-tools/delegate-resume.test.ts +50 -0
  213. package/src/lib/server/session-tools/delegate.ts +102 -10
  214. package/src/lib/server/session-tools/discovery-approvals.test.ts +142 -0
  215. package/src/lib/server/session-tools/discovery.ts +80 -12
  216. package/src/lib/server/session-tools/file-normalize.test.ts +36 -0
  217. package/src/lib/server/session-tools/file.ts +43 -4
  218. package/src/lib/server/session-tools/human-loop.ts +35 -5
  219. package/src/lib/server/session-tools/index.ts +44 -9
  220. package/src/lib/server/session-tools/manage-connectors.test.ts +139 -0
  221. package/src/lib/server/session-tools/manage-schedules-advanced.test.ts +564 -0
  222. package/src/lib/server/session-tools/manage-schedules.test.ts +283 -0
  223. package/src/lib/server/session-tools/manage-tasks-advanced.test.ts +852 -0
  224. package/src/lib/server/session-tools/manage-tasks.test.ts +114 -0
  225. package/src/lib/server/session-tools/memory.test.ts +93 -0
  226. package/src/lib/server/session-tools/memory.ts +554 -75
  227. package/src/lib/server/session-tools/normalize-tool-args.ts +1 -1
  228. package/src/lib/server/session-tools/platform-access.test.ts +58 -0
  229. package/src/lib/server/session-tools/platform.ts +60 -19
  230. package/src/lib/server/session-tools/plugin-creator.ts +57 -1
  231. package/src/lib/server/session-tools/primitive-tools.test.ts +6 -0
  232. package/src/lib/server/session-tools/schedule.ts +6 -1
  233. package/src/lib/server/session-tools/shell-normalize.test.ts +25 -1
  234. package/src/lib/server/session-tools/shell.ts +22 -3
  235. package/src/lib/server/session-tools/wallet-tool.test.ts +254 -0
  236. package/src/lib/server/session-tools/wallet.ts +1374 -139
  237. package/src/lib/server/session-tools/web-inputs.test.ts +178 -0
  238. package/src/lib/server/session-tools/web.ts +621 -70
  239. package/src/lib/server/skill-discovery.ts +128 -0
  240. package/src/lib/server/skill-eligibility.test.ts +84 -0
  241. package/src/lib/server/skill-eligibility.ts +95 -0
  242. package/src/lib/server/skill-prompt-budget.test.ts +102 -0
  243. package/src/lib/server/skill-prompt-budget.ts +125 -0
  244. package/src/lib/server/skills-normalize.test.ts +54 -0
  245. package/src/lib/server/skills-normalize.ts +372 -26
  246. package/src/lib/server/solana.ts +214 -29
  247. package/src/lib/server/storage.ts +65 -36
  248. package/src/lib/server/stream-agent-chat.test.ts +437 -2
  249. package/src/lib/server/stream-agent-chat.ts +957 -79
  250. package/src/lib/server/system-events.ts +1 -1
  251. package/src/lib/server/tool-aliases.ts +2 -0
  252. package/src/lib/server/tool-capability-policy-advanced.test.ts +502 -0
  253. package/src/lib/server/tool-capability-policy.test.ts +24 -0
  254. package/src/lib/server/tool-capability-policy.ts +29 -1
  255. package/src/lib/server/tool-loop-detection.test.ts +105 -0
  256. package/src/lib/server/tool-loop-detection.ts +260 -0
  257. package/src/lib/server/tool-planning.test.ts +44 -0
  258. package/src/lib/server/tool-planning.ts +271 -0
  259. package/src/lib/server/wallet-execution.test.ts +198 -0
  260. package/src/lib/server/wallet-portfolio.test.ts +98 -0
  261. package/src/lib/server/wallet-portfolio.ts +724 -0
  262. package/src/lib/server/wallet-service.test.ts +57 -0
  263. package/src/lib/server/wallet-service.ts +213 -0
  264. package/src/lib/server/watch-jobs-advanced.test.ts +594 -0
  265. package/src/lib/server/watch-jobs.ts +17 -2
  266. package/src/lib/server/workspace-context.ts +111 -0
  267. package/src/lib/skill-save-payload.test.ts +39 -0
  268. package/src/lib/skill-save-payload.ts +37 -0
  269. package/src/lib/tasks.ts +28 -0
  270. package/src/lib/tool-definitions.ts +2 -1
  271. package/src/lib/tool-event-summary.test.ts +30 -0
  272. package/src/lib/tool-event-summary.ts +37 -0
  273. package/src/lib/validation/schemas.ts +1 -0
  274. package/src/lib/wallet-transactions.test.ts +75 -0
  275. package/src/lib/wallet-transactions.ts +43 -0
  276. package/src/lib/wallet.test.ts +17 -0
  277. package/src/lib/wallet.ts +183 -0
  278. package/src/proxy.test.ts +31 -0
  279. package/src/proxy.ts +34 -2
  280. package/src/stores/use-chat-store.ts +15 -1
  281. package/src/types/index.ts +249 -14
@@ -13,7 +13,9 @@ import { extractTaskResult, formatResultBody } from './task-result'
13
13
  import { getCheckpointSaver } from './langgraph-checkpoint'
14
14
  import { cascadeUnblock } from './dag-validation'
15
15
  import { performGuardianRollback } from './guardian'
16
- import type { Agent, BoardTask, Connector, Message } from '@/types'
16
+ import { shouldAutoDeleteScheduleAfterTerminalRun } from '@/lib/schedule-origin'
17
+ import type { Agent, BoardTask, Connector, Message, Session } from '@/types'
18
+ import { buildAgentDisabledMessage, isAgentDisabled } from './agent-availability'
17
19
 
18
20
  // HMR-safe: pin processing flag to globalThis so hot reloads don't reset it
19
21
  const _queueState = ((globalThis as Record<string, unknown>).__swarmclaw_queue__ ??= { processing: false, pendingKick: false }) as { processing: boolean; pendingKick: boolean }
@@ -23,9 +25,11 @@ interface SessionMessageLike {
23
25
  text?: string
24
26
  time?: number
25
27
  kind?: string
28
+ historyExcluded?: boolean
26
29
  source?: {
27
30
  connectorId?: string
28
31
  channelId?: string
32
+ threadId?: string
29
33
  }
30
34
  toolEvents?: Array<{ name?: string; output?: string }>
31
35
  streaming?: boolean
@@ -37,7 +41,17 @@ interface SessionLike {
37
41
  user?: string
38
42
  cwd?: string
39
43
  messages?: SessionMessageLike[]
44
+ connectorContext?: {
45
+ connectorId?: string | null
46
+ channelId?: string | null
47
+ threadId?: string | null
48
+ senderId?: string | null
49
+ senderName?: string | null
50
+ }
40
51
  lastActiveAt?: number
52
+ heartbeatEnabled?: boolean | null
53
+ active?: boolean
54
+ currentRunId?: string | null
41
55
  }
42
56
 
43
57
  interface ScheduleTaskMeta extends BoardTask {
@@ -58,8 +72,11 @@ interface RunningConnectorLike {
58
72
  interface ConnectorTaskFollowupTarget {
59
73
  connectorId: string
60
74
  channelId: string
75
+ threadId?: string | null
61
76
  }
62
77
 
78
+ const DISABLED_AGENT_RETRY_MS = 60_000
79
+
63
80
  function sameReasons(a?: string[] | null, b?: string[] | null): boolean {
64
81
  const av = Array.isArray(a) ? a : []
65
82
  const bv = Array.isArray(b) ? b : []
@@ -128,6 +145,212 @@ function applyTaskPolicyDefaults(task: BoardTask): void {
128
145
  if (task.deadLetteredAt === undefined) task.deadLetteredAt = null
129
146
  }
130
147
 
148
+ export interface TaskResumeState {
149
+ claudeSessionId: string | null
150
+ codexThreadId: string | null
151
+ opencodeSessionId: string | null
152
+ delegateResumeIds: NonNullable<Session['delegateResumeIds']>
153
+ }
154
+
155
+ export interface TaskResumeContext {
156
+ source: 'self' | 'delegated_from_task' | 'blocked_by'
157
+ sourceTaskId: string
158
+ sourceTaskTitle: string
159
+ sourceSessionId: string | null
160
+ resume: TaskResumeState
161
+ }
162
+
163
+ function normalizeResumeHandle(value: unknown): string | null {
164
+ return typeof value === 'string' && value.trim() ? value.trim() : null
165
+ }
166
+
167
+ function buildEmptyDelegateResumeIds(): NonNullable<Session['delegateResumeIds']> {
168
+ return {
169
+ claudeCode: null,
170
+ codex: null,
171
+ opencode: null,
172
+ gemini: null,
173
+ }
174
+ }
175
+
176
+ function normalizeCliProvider(value: unknown): string | null {
177
+ return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : null
178
+ }
179
+
180
+ function hasResumeState(state: TaskResumeState | null | undefined): state is TaskResumeState {
181
+ if (!state) return false
182
+ return Boolean(
183
+ state.claudeSessionId
184
+ || state.codexThreadId
185
+ || state.opencodeSessionId
186
+ || state.delegateResumeIds.claudeCode
187
+ || state.delegateResumeIds.codex
188
+ || state.delegateResumeIds.opencode
189
+ || state.delegateResumeIds.gemini
190
+ )
191
+ }
192
+
193
+ export function extractTaskResumeState(task: Partial<BoardTask> | null | undefined): TaskResumeState | null {
194
+ if (!task) return null
195
+
196
+ const legacyResumeId = normalizeResumeHandle(task.cliResumeId)
197
+ const legacyProvider = normalizeCliProvider(task.cliProvider)
198
+ const claudeSessionId = normalizeResumeHandle(task.claudeResumeId)
199
+ || (legacyProvider === 'claude-cli' ? legacyResumeId : null)
200
+ const codexThreadId = normalizeResumeHandle(task.codexResumeId)
201
+ || (legacyProvider === 'codex-cli' ? legacyResumeId : null)
202
+ const opencodeSessionId = normalizeResumeHandle(task.opencodeResumeId)
203
+ || (legacyProvider === 'opencode-cli' ? legacyResumeId : null)
204
+ const geminiSessionId = normalizeResumeHandle(task.geminiResumeId)
205
+ || (legacyProvider === 'gemini-cli' ? legacyResumeId : null)
206
+
207
+ const resume = {
208
+ claudeSessionId,
209
+ codexThreadId,
210
+ opencodeSessionId,
211
+ delegateResumeIds: {
212
+ claudeCode: claudeSessionId,
213
+ codex: codexThreadId,
214
+ opencode: opencodeSessionId,
215
+ gemini: geminiSessionId,
216
+ },
217
+ } satisfies TaskResumeState
218
+
219
+ return hasResumeState(resume) ? resume : null
220
+ }
221
+
222
+ export function extractSessionResumeState(session: Partial<Session> | null | undefined): TaskResumeState | null {
223
+ if (!session) return null
224
+
225
+ const claudeSessionId = normalizeResumeHandle(session.claudeSessionId)
226
+ const codexThreadId = normalizeResumeHandle(session.codexThreadId)
227
+ const opencodeSessionId = normalizeResumeHandle(session.opencodeSessionId)
228
+ const delegateResumeIds = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
229
+ ? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
230
+ : buildEmptyDelegateResumeIds()
231
+
232
+ const resume = {
233
+ claudeSessionId,
234
+ codexThreadId,
235
+ opencodeSessionId,
236
+ delegateResumeIds: {
237
+ claudeCode: normalizeResumeHandle(delegateResumeIds.claudeCode) || claudeSessionId,
238
+ codex: normalizeResumeHandle(delegateResumeIds.codex) || codexThreadId,
239
+ opencode: normalizeResumeHandle(delegateResumeIds.opencode) || opencodeSessionId,
240
+ gemini: normalizeResumeHandle(delegateResumeIds.gemini),
241
+ },
242
+ } satisfies TaskResumeState
243
+
244
+ return hasResumeState(resume) ? resume : null
245
+ }
246
+
247
+ export function resolveTaskResumeContext(
248
+ task: BoardTask,
249
+ tasksById: Record<string, BoardTask>,
250
+ sessionsById?: Record<string, SessionLike | Session>,
251
+ ): TaskResumeContext | null {
252
+ const candidates: Array<{ source: TaskResumeContext['source']; taskId: string | null | undefined }> = [
253
+ { source: 'self', taskId: task.id },
254
+ { source: 'delegated_from_task', taskId: task.delegatedFromTaskId },
255
+ ...((Array.isArray(task.blockedBy) ? task.blockedBy : []).map((taskId) => ({ source: 'blocked_by' as const, taskId }))),
256
+ ]
257
+ const seen = new Set<string>()
258
+
259
+ for (const candidate of candidates) {
260
+ const taskId = typeof candidate.taskId === 'string' ? candidate.taskId.trim() : ''
261
+ if (!taskId || seen.has(taskId)) continue
262
+ seen.add(taskId)
263
+ const sourceTask = taskId === task.id ? task : tasksById[taskId]
264
+ if (!sourceTask) continue
265
+ const sourceSessionId = normalizeResumeHandle(sourceTask.checkpoint?.lastSessionId) || normalizeResumeHandle(sourceTask.sessionId)
266
+ const resume = extractTaskResumeState(sourceTask)
267
+ || (sourceSessionId && sessionsById?.[sourceSessionId]
268
+ ? extractSessionResumeState(sessionsById[sourceSessionId] as Session)
269
+ : null)
270
+ if (!resume) continue
271
+ return {
272
+ source: candidate.source,
273
+ sourceTaskId: sourceTask.id,
274
+ sourceTaskTitle: sourceTask.title,
275
+ sourceSessionId,
276
+ resume,
277
+ }
278
+ }
279
+
280
+ return null
281
+ }
282
+
283
+ export function applyTaskResumeStateToSession(session: Session, resume: TaskResumeState | null | undefined): boolean {
284
+ if (!hasResumeState(resume)) return false
285
+
286
+ let changed = false
287
+ const directFields: Array<['claudeSessionId' | 'codexThreadId' | 'opencodeSessionId', string | null]> = [
288
+ ['claudeSessionId', resume.claudeSessionId],
289
+ ['codexThreadId', resume.codexThreadId],
290
+ ['opencodeSessionId', resume.opencodeSessionId],
291
+ ]
292
+ for (const [key, value] of directFields) {
293
+ if (!value || session[key] === value) continue
294
+ session[key] = value
295
+ changed = true
296
+ }
297
+
298
+ const currentDelegateResume = session.delegateResumeIds && typeof session.delegateResumeIds === 'object'
299
+ ? { ...buildEmptyDelegateResumeIds(), ...session.delegateResumeIds }
300
+ : buildEmptyDelegateResumeIds()
301
+ for (const [key, value] of Object.entries(resume.delegateResumeIds) as Array<[keyof NonNullable<Session['delegateResumeIds']>, string | null]>) {
302
+ if (!value || currentDelegateResume[key] === value) continue
303
+ currentDelegateResume[key] = value
304
+ changed = true
305
+ }
306
+ if (changed) session.delegateResumeIds = currentDelegateResume
307
+ return changed
308
+ }
309
+
310
+ export function resolveReusableTaskSessionId(
311
+ task: BoardTask,
312
+ tasks: Record<string, BoardTask>,
313
+ sessions: Record<string, SessionLike>,
314
+ ): string {
315
+ const candidateTaskIds = [
316
+ task.id,
317
+ typeof task.delegatedFromTaskId === 'string' ? task.delegatedFromTaskId : '',
318
+ ...(Array.isArray(task.blockedBy) ? task.blockedBy : []),
319
+ ]
320
+ const seen = new Set<string>()
321
+ for (const candidateTaskId of candidateTaskIds) {
322
+ const taskId = typeof candidateTaskId === 'string' ? candidateTaskId.trim() : ''
323
+ if (!taskId || seen.has(taskId)) continue
324
+ seen.add(taskId)
325
+ const sourceTask = taskId === task.id ? task : tasks[taskId]
326
+ if (!sourceTask) continue
327
+ const candidates = [
328
+ normalizeResumeHandle(sourceTask.checkpoint?.lastSessionId),
329
+ normalizeResumeHandle(sourceTask.sessionId),
330
+ ]
331
+ for (const candidate of candidates) {
332
+ if (candidate && sessions[candidate]) return candidate
333
+ }
334
+ }
335
+ return ''
336
+ }
337
+
338
+ function buildTaskContinuationNote(
339
+ reusedExistingSession: boolean,
340
+ resumeContext: TaskResumeContext | null,
341
+ ): string {
342
+ const notes: string[] = []
343
+ if (reusedExistingSession) {
344
+ notes.push('Reusing the previous execution session for this task.')
345
+ }
346
+ if (resumeContext?.source === 'delegated_from_task' || resumeContext?.source === 'blocked_by') {
347
+ notes.push(`Stored CLI context is available from related task "${resumeContext.sourceTaskTitle}".`)
348
+ } else if (resumeContext?.source === 'self' && !reusedExistingSession) {
349
+ notes.push('Stored CLI resume handles are available for continuation.')
350
+ }
351
+ return notes.length ? `\n\n${notes.join(' ')}` : ''
352
+ }
353
+
131
354
  const DEV_TASK_HINT = /\b(dev(?:\s+server)?|start(?:ing)?\s+(?:the\s+)?server|run(?:ning)?\s+(?:the\s+)?(?:app|project|site)|serve|localhost|http\s+server|web\s+server|npm\b|pnpm\b|yarn\b|bun\b|vite|next(?:\.js)?|react|build|compile)\b/i
132
355
  const TASK_CWD_NOISE_DIRS = new Set([
133
356
  'uploads',
@@ -460,12 +683,10 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
460
683
  const delegatedByAgentId = typeof metaTask.delegatedByAgentId === 'string'
461
684
  ? metaTask.delegatedByAgentId.trim()
462
685
  : ''
686
+ const allowedOwners = new Set([task.agentId, delegatedByAgentId].filter(Boolean))
463
687
  const sourceSessionId = typeof metaTask.createdInSessionId === 'string'
464
688
  ? metaTask.createdInSessionId.trim()
465
689
  : ''
466
- if (!sourceSessionId) return null
467
- const sourceSession = sessions[sourceSessionId]
468
- if (!sourceSession || !Array.isArray(sourceSession.messages)) return null
469
690
 
470
691
  const runningById = new Map<string, RunningConnectorLike>()
471
692
  for (const entry of running) {
@@ -473,9 +694,64 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
473
694
  runningById.set(entry.id, entry)
474
695
  }
475
696
 
697
+ const normalizeTarget = (raw: {
698
+ connectorId?: string | null
699
+ channelId?: string | null
700
+ threadId?: string | null
701
+ }): ConnectorTaskFollowupTarget | null => {
702
+ const connectorId = typeof raw.connectorId === 'string' ? raw.connectorId.trim() : ''
703
+ if (!connectorId) return null
704
+ const connector = connectors[connectorId]
705
+ if (!connector) return null
706
+ const ownerId = typeof connector.agentId === 'string' ? connector.agentId.trim() : ''
707
+ if (ownerId && !allowedOwners.has(ownerId)) return null
708
+
709
+ const runtime = runningById.get(connectorId)
710
+ if (runtime && !runtime.supportsSend) return null
711
+
712
+ const channelId = typeof raw.channelId === 'string' ? raw.channelId.trim() : ''
713
+ if (!channelId) return null
714
+ const normalizedChannelId = connector.platform === 'whatsapp'
715
+ ? normalizeWhatsappTarget(channelId)
716
+ : channelId
717
+ const threadId = typeof raw.threadId === 'string' ? raw.threadId.trim() : ''
718
+ return {
719
+ connectorId,
720
+ channelId: normalizedChannelId,
721
+ ...(threadId ? { threadId } : {}),
722
+ }
723
+ }
724
+
725
+ const explicitTarget = normalizeTarget({
726
+ connectorId: typeof metaTask.followupConnectorId === 'string' ? metaTask.followupConnectorId : null,
727
+ channelId: typeof metaTask.followupChannelId === 'string' ? metaTask.followupChannelId : null,
728
+ threadId: typeof metaTask.followupThreadId === 'string' ? metaTask.followupThreadId : null,
729
+ })
730
+ if (explicitTarget) return explicitTarget
731
+
732
+ if (!sourceSessionId) return null
733
+ const sourceSession = sessions[sourceSessionId]
734
+ if (!sourceSession) return null
735
+
736
+ const sessionContextTarget = normalizeTarget({
737
+ connectorId: typeof sourceSession.connectorContext?.connectorId === 'string'
738
+ ? sourceSession.connectorContext.connectorId
739
+ : null,
740
+ channelId: typeof sourceSession.connectorContext?.channelId === 'string'
741
+ ? sourceSession.connectorContext.channelId
742
+ : null,
743
+ threadId: typeof sourceSession.connectorContext?.threadId === 'string'
744
+ ? sourceSession.connectorContext.threadId
745
+ : null,
746
+ })
747
+ if (sessionContextTarget) return sessionContextTarget
748
+
749
+ if (!Array.isArray(sourceSession.messages)) return null
750
+
476
751
  for (let i = sourceSession.messages.length - 1; i >= 0; i--) {
477
752
  const message = sourceSession.messages[i]
478
753
  if (!message || message.role !== 'user') continue
754
+ if (message.historyExcluded === true) continue
479
755
 
480
756
  const connectorId = typeof message.source?.connectorId === 'string'
481
757
  ? message.source.connectorId.trim()
@@ -484,15 +760,7 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
484
760
 
485
761
  const connector = connectors[connectorId]
486
762
  if (!connector) continue
487
- const ownerId = typeof connector.agentId === 'string' ? connector.agentId.trim() : ''
488
- if (ownerId) {
489
- const allowedOwners = new Set([task.agentId, delegatedByAgentId].filter(Boolean))
490
- if (!allowedOwners.has(ownerId)) continue
491
- }
492
-
493
763
  const runtime = runningById.get(connectorId)
494
- if (runtime && !runtime.supportsSend) continue
495
-
496
764
  const sourceChannel = typeof message.source?.channelId === 'string'
497
765
  ? message.source.channelId.trim()
498
766
  : ''
@@ -501,20 +769,59 @@ export function resolveTaskOriginConnectorFollowupTarget(params: {
501
769
  || connector.config?.outboundJid
502
770
  || connector.config?.outboundTarget
503
771
  || ''
504
- const rawChannel = sourceChannel || fallbackChannel
505
- if (!rawChannel) continue
506
-
507
- return {
772
+ const target = normalizeTarget({
508
773
  connectorId,
509
- channelId: connector.platform === 'whatsapp'
510
- ? normalizeWhatsappTarget(rawChannel)
511
- : rawChannel,
512
- }
774
+ channelId: sourceChannel || fallbackChannel,
775
+ threadId: typeof message.source?.threadId === 'string' ? message.source.threadId : null,
776
+ })
777
+ if (target) return target
513
778
  }
514
779
 
515
780
  return null
516
781
  }
517
782
 
783
+ export function collectTaskConnectorFollowupTargets(params: {
784
+ task: BoardTask
785
+ sessions: Record<string, SessionLike>
786
+ connectors: Record<string, Connector>
787
+ running: RunningConnectorLike[]
788
+ }): ConnectorTaskFollowupTarget[] {
789
+ const { task, sessions, connectors, running } = params
790
+ const originTarget = resolveTaskOriginConnectorFollowupTarget({ task, sessions, connectors, running })
791
+ if (originTarget) return [originTarget]
792
+
793
+ const targets: ConnectorTaskFollowupTarget[] = []
794
+ const seen = new Set<string>()
795
+ const pushTarget = (target: ConnectorTaskFollowupTarget | null | undefined) => {
796
+ if (!target?.connectorId || !target?.channelId) return
797
+ const key = `${target.connectorId}|${target.channelId}|${target.threadId || ''}`
798
+ if (seen.has(key)) return
799
+ seen.add(key)
800
+ targets.push(target)
801
+ }
802
+
803
+ for (const entry of running) {
804
+ if (!entry.supportsSend || !entry.id) continue
805
+ const connector = connectors[entry.id]
806
+ if (!connector) continue
807
+ if (connector.agentId !== task.agentId) continue
808
+ if (!isEnabledFlag(connector.config?.taskFollowups)) continue
809
+ const channelTargetRaw = entry.configuredTargets[0]
810
+ || connector.config?.outboundJid
811
+ || connector.config?.outboundTarget
812
+ || ''
813
+ if (!channelTargetRaw) continue
814
+ pushTarget({
815
+ connectorId: entry.id,
816
+ channelId: connector.platform === 'whatsapp'
817
+ ? normalizeWhatsappTarget(channelTargetRaw)
818
+ : channelTargetRaw,
819
+ })
820
+ }
821
+
822
+ return targets
823
+ }
824
+
518
825
  // Task result extraction now uses Zod-validated structured data
519
826
  // from ./task-result.ts (extractTaskResult, formatResultBody)
520
827
 
@@ -573,6 +880,132 @@ async function executeTaskRun(
573
880
  return text
574
881
  }
575
882
 
883
+ function hasFinishedExecutionSession(session: SessionLike | Session | null | undefined): boolean {
884
+ if (!session) return false
885
+ return session.active === false && !session.currentRunId
886
+ }
887
+
888
+ export function reconcileFinishedRunningTasks(): { reconciled: number; deadLettered: number } {
889
+ const tasks = loadTasks()
890
+ const sessions = loadSessions() as Record<string, SessionLike>
891
+ const settings = loadSettings()
892
+ const queue = loadQueue()
893
+ const now = Date.now()
894
+ let reconciled = 0
895
+ let deadLettered = 0
896
+ let tasksDirty = false
897
+ let sessionsDirty = false
898
+ let queueDirty = false
899
+ const terminalTasks: BoardTask[] = []
900
+
901
+ for (const task of Object.values(tasks) as BoardTask[]) {
902
+ if (task.status !== 'running') continue
903
+ const sessionId = typeof task.sessionId === 'string' ? task.sessionId : ''
904
+ if (!sessionId) continue
905
+ const session = sessions[sessionId]
906
+ if (!hasFinishedExecutionSession(session)) continue
907
+
908
+ const fallbackText = latestAssistantText(session)
909
+ if (!fallbackText && !task.result) continue
910
+
911
+ applyTaskPolicyDefaults(task)
912
+ const taskResult = extractTaskResult(
913
+ session,
914
+ task.result || fallbackText || null,
915
+ { sinceTime: typeof task.startedAt === 'number' ? task.startedAt : null },
916
+ )
917
+ const enrichedResult = formatResultBody(taskResult)
918
+ task.result = enrichedResult.slice(0, 4000) || null
919
+ task.artifacts = taskResult.artifacts.slice(0, 24)
920
+ task.outputFiles = extractLikelyOutputFiles(enrichedResult).slice(0, 24)
921
+ task.updatedAt = now
922
+ const report = ensureTaskCompletionReport(task)
923
+ if (report?.relativePath) task.completionReportPath = report.relativePath
924
+ const validation = validateTaskCompletion(task, { report, settings })
925
+ task.validation = validation
926
+ if (!task.comments) task.comments = []
927
+
928
+ if (validation.ok) {
929
+ task.status = 'completed'
930
+ task.completedAt = now
931
+ task.retryScheduledAt = null
932
+ task.deadLetteredAt = null
933
+ task.error = null
934
+ task.checkpoint = {
935
+ ...(task.checkpoint || {}),
936
+ lastRunId: sessionId,
937
+ lastSessionId: sessionId,
938
+ note: 'Recovered completed task state from finished session.',
939
+ updatedAt: now,
940
+ }
941
+ task.comments.push({
942
+ id: genId(),
943
+ author: 'System',
944
+ text: 'Recovered completed task state from a finished execution session.',
945
+ createdAt: now,
946
+ })
947
+ reconciled++
948
+ terminalTasks.push(task)
949
+ } else {
950
+ const failureReason = formatValidationFailure(validation.reasons).slice(0, 500)
951
+ const retryState = scheduleRetryOrDeadLetter(task, failureReason)
952
+ task.completedAt = retryState === 'dead_lettered' ? null : task.completedAt
953
+ task.comments.push({
954
+ id: genId(),
955
+ author: 'System',
956
+ text: `Recovered finished session but the task result failed validation.\n\n${validation.reasons.map((reason) => `- ${reason}`).join('\n')}`,
957
+ createdAt: now,
958
+ })
959
+ if (retryState === 'retry') {
960
+ pushQueueUnique(queue, task.id)
961
+ queueDirty = true
962
+ reconciled++
963
+ pushMainLoopEventToMainSessions({
964
+ type: 'task_retry_scheduled',
965
+ text: `Task retry scheduled: "${task.title}" (${task.id}) attempt ${task.attempts}/${task.maxAttempts} in ${task.retryBackoffSec}s.`,
966
+ })
967
+ } else {
968
+ deadLettered++
969
+ terminalTasks.push(task)
970
+ }
971
+ }
972
+
973
+ if (session.heartbeatEnabled !== false) {
974
+ session.heartbeatEnabled = false
975
+ session.lastActiveAt = now
976
+ sessionsDirty = true
977
+ }
978
+ tasksDirty = true
979
+ }
980
+
981
+ if (tasksDirty) {
982
+ saveTasks(tasks)
983
+ notify('tasks')
984
+ notify('runs')
985
+ }
986
+ if (sessionsDirty) saveSessions(sessions as Record<string, Session>)
987
+ if (queueDirty) saveQueue(queue)
988
+
989
+ for (const task of terminalTasks) {
990
+ if (task.status === 'completed') {
991
+ pushMainLoopEventToMainSessions({
992
+ type: 'task_completed',
993
+ text: `Task completed: "${task.title}" (${task.id})`,
994
+ })
995
+ } else if (task.status === 'failed') {
996
+ pushMainLoopEventToMainSessions({
997
+ type: 'task_failed',
998
+ text: `Task failed validation: "${task.title}" (${task.id})`,
999
+ })
1000
+ }
1001
+ notifyMainChatScheduleResult(task)
1002
+ notifyAgentThreadTaskResult(task)
1003
+ cleanupTerminalOneOffSchedule(task)
1004
+ }
1005
+
1006
+ return { reconciled, deadLettered }
1007
+ }
1008
+
576
1009
  function notifyMainChatScheduleResult(task: BoardTask): void {
577
1010
  const scheduleTask = task as ScheduleTaskMeta
578
1011
  const sourceType = typeof scheduleTask.sourceType === 'string' ? scheduleTask.sourceType : ''
@@ -638,6 +1071,22 @@ function notifyMainChatScheduleResult(task: BoardTask): void {
638
1071
  if (changed) saveSessions(sessions)
639
1072
  }
640
1073
 
1074
+ function cleanupTerminalOneOffSchedule(task: BoardTask): void {
1075
+ const scheduleTask = task as ScheduleTaskMeta
1076
+ const sourceType = typeof scheduleTask.sourceType === 'string' ? scheduleTask.sourceType : ''
1077
+ if (sourceType !== 'schedule') return
1078
+ const scheduleId = typeof scheduleTask.sourceScheduleId === 'string' ? scheduleTask.sourceScheduleId : ''
1079
+ if (!scheduleId) return
1080
+
1081
+ const schedules = loadSchedules()
1082
+ const schedule = schedules[scheduleId]
1083
+ if (!shouldAutoDeleteScheduleAfterTerminalRun(schedule)) return
1084
+
1085
+ delete schedules[scheduleId]
1086
+ saveSchedules(schedules)
1087
+ notify('schedules')
1088
+ }
1089
+
641
1090
  async function notifyConnectorTaskFollowups(params: {
642
1091
  task: BoardTask
643
1092
  statusLabel: string
@@ -652,54 +1101,23 @@ async function notifyConnectorTaskFollowups(params: {
652
1101
  const running = (await import('./connectors/manager')).listRunningConnectors()
653
1102
  const manager = await import('./connectors/manager')
654
1103
  const sessions = loadSessions()
655
-
656
- const candidateByKey = new Map<string, ConnectorTaskFollowupTarget>()
657
- const addCandidate = (candidate: ConnectorTaskFollowupTarget | null | undefined) => {
658
- if (!candidate?.connectorId || !candidate?.channelId) return
659
- const key = `${candidate.connectorId}|${candidate.channelId}`
660
- if (!candidateByKey.has(key)) candidateByKey.set(key, candidate)
661
- }
662
-
1104
+ const targets = collectTaskConnectorFollowupTargets({
1105
+ task,
1106
+ sessions: sessions as Record<string, SessionLike>,
1107
+ connectors,
1108
+ running: running as RunningConnectorLike[],
1109
+ })
1110
+ if (!targets.length) return
663
1111
  const originTarget = resolveTaskOriginConnectorFollowupTarget({
664
1112
  task,
665
1113
  sessions: sessions as Record<string, SessionLike>,
666
1114
  connectors,
667
1115
  running: running as RunningConnectorLike[],
668
1116
  })
669
- addCandidate(originTarget)
670
1117
  const preferredTargetKey = originTarget
671
- ? `${originTarget.connectorId}|${originTarget.channelId}`
1118
+ ? `${originTarget.connectorId}|${originTarget.channelId}|${originTarget.threadId || ''}`
672
1119
  : ''
673
1120
 
674
- for (const entry of running) {
675
- if (!entry.supportsSend || !entry.id) continue
676
- const connector = connectors[entry.id]
677
- if (!connector) continue
678
- if (connector.agentId !== task.agentId) continue
679
- if (!isEnabledFlag(connector.config?.taskFollowups)) continue
680
- const channelTargetRaw = entry.recentChannelId
681
- || entry.configuredTargets[0]
682
- || connector.config?.outboundJid
683
- || connector.config?.outboundTarget
684
- || ''
685
- if (!channelTargetRaw) continue
686
- addCandidate({
687
- connectorId: entry.id,
688
- channelId: connector.platform === 'whatsapp'
689
- ? normalizeWhatsappTarget(channelTargetRaw)
690
- : channelTargetRaw,
691
- })
692
- }
693
- const targets = [...candidateByKey.values()].sort((a, b) => {
694
- if (!preferredTargetKey) return 0
695
- const aKey = `${a.connectorId}|${a.channelId}`
696
- const bKey = `${b.connectorId}|${b.channelId}`
697
- if (aKey === preferredTargetKey && bKey !== preferredTargetKey) return -1
698
- if (bKey === preferredTargetKey && aKey !== preferredTargetKey) return 1
699
- return 0
700
- })
701
- if (!targets.length) return
702
-
703
1121
  const summary = summaryText.trim().slice(0, 1400)
704
1122
  for (const target of targets) {
705
1123
  const connector = connectors[target.connectorId]
@@ -719,7 +1137,7 @@ async function notifyConnectorTaskFollowups(params: {
719
1137
  `Task ${statusLabel}: ${task.title}`,
720
1138
  summary || 'No summary provided.',
721
1139
  ].join('\n\n')
722
- const targetKey = `${target.connectorId}|${target.channelId}`
1140
+ const targetKey = `${target.connectorId}|${target.channelId}|${target.threadId || ''}`
723
1141
  const preferredChannelNote = !template && preferredTargetKey && targetKey === preferredTargetKey
724
1142
  ? '\n\n(Update sent in the same channel that requested this task.)'
725
1143
  : ''
@@ -730,6 +1148,7 @@ async function notifyConnectorTaskFollowups(params: {
730
1148
  await manager.sendConnectorMessage({
731
1149
  connectorId: target.connectorId,
732
1150
  channelId: target.channelId,
1151
+ threadId: target.threadId || undefined,
733
1152
  text: outboundMessage,
734
1153
  ...(resolvedMediaPath
735
1154
  ? {
@@ -1057,7 +1476,7 @@ function scheduleRetryOrDeadLetter(task: BoardTask, reason: string): 'retry' | '
1057
1476
  return 'dead_lettered'
1058
1477
  }
1059
1478
 
1060
- function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
1479
+ export function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTask>): string | null {
1061
1480
  const now = Date.now()
1062
1481
 
1063
1482
  // Remove stale entries first.
@@ -1071,7 +1490,9 @@ function dequeueNextRunnableTask(queue: string[], tasks: Record<string, BoardTas
1071
1490
  const task = tasks[id]
1072
1491
  if (!task) return false
1073
1492
  const retryAt = typeof task.retryScheduledAt === 'number' ? task.retryScheduledAt : null
1074
- return !retryAt || retryAt <= now
1493
+ if (retryAt && retryAt > now) return false
1494
+ const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
1495
+ return blockers.every((blockerId) => tasks[blockerId]?.status === 'completed')
1075
1496
  })
1076
1497
  if (idx === -1) return null
1077
1498
  const [taskId] = queue.splice(idx, 1)
@@ -1143,6 +1564,21 @@ export async function processNext() {
1143
1564
  })
1144
1565
  continue
1145
1566
  }
1567
+ if (isAgentDisabled(agent)) {
1568
+ const now = Date.now()
1569
+ task.retryScheduledAt = now + DISABLED_AGENT_RETRY_MS
1570
+ task.updatedAt = now
1571
+ task.error = buildAgentDisabledMessage(agent, 'process queued tasks')
1572
+ saveTasks(tasks)
1573
+ notify('tasks')
1574
+ pushQueueUnique(queue, taskId)
1575
+ saveQueue(queue)
1576
+ pushMainLoopEventToMainSessions({
1577
+ type: 'task_deferred',
1578
+ text: `Task deferred: "${task.title}" (${task.id}) — agent ${task.agentId} is disabled.`,
1579
+ })
1580
+ continue
1581
+ }
1146
1582
 
1147
1583
  // Mark as running
1148
1584
  applyTaskPolicyDefaults(task)
@@ -1164,6 +1600,8 @@ export async function processNext() {
1164
1600
  const sourceScheduleId = typeof scheduleTask.sourceScheduleId === 'string'
1165
1601
  ? scheduleTask.sourceScheduleId
1166
1602
  : ''
1603
+ const reusableTaskSessionId = resolveReusableTaskSessionId(task, tasks as Record<string, BoardTask>, sessionsForCwd)
1604
+ const resumeContext = resolveTaskResumeContext(task, tasks as Record<string, BoardTask>, sessionsForCwd as Record<string, SessionLike | Session>)
1167
1605
 
1168
1606
  // Resolve the agent's persistent thread session to use as parentSessionId
1169
1607
  const agentThreadSessionId = agent.threadSessionId || null
@@ -1197,7 +1635,7 @@ export async function processNext() {
1197
1635
  saveSchedules(schedules)
1198
1636
  }
1199
1637
  } else {
1200
- sessionId = createOrchestratorSession(
1638
+ sessionId = reusableTaskSessionId || createOrchestratorSession(
1201
1639
  agent,
1202
1640
  task.title,
1203
1641
  agentThreadSessionId || undefined,
@@ -1206,6 +1644,13 @@ export async function processNext() {
1206
1644
  )
1207
1645
  }
1208
1646
 
1647
+ const executionSessions = loadSessions() as Record<string, Session>
1648
+ const executionSession = executionSessions[sessionId]
1649
+ const seededResumeState = executionSession
1650
+ ? applyTaskResumeStateToSession(executionSession, resumeContext?.resume)
1651
+ : false
1652
+ if (seededResumeState) saveSessions(executionSessions)
1653
+
1209
1654
  // Notify the agent's thread that a task has started
1210
1655
  if (agentThreadSessionId) {
1211
1656
  try {
@@ -1231,9 +1676,19 @@ export async function processNext() {
1231
1676
  }
1232
1677
 
1233
1678
  task.sessionId = sessionId
1679
+ const reusedExistingSession = !isScheduleTask && Boolean(reusableTaskSessionId) && reusableTaskSessionId === sessionId
1680
+ const continuationBits: string[] = []
1681
+ if (reusedExistingSession) {
1682
+ continuationBits.push('reusing prior session')
1683
+ }
1684
+ if (resumeContext?.source === 'delegated_from_task' || resumeContext?.source === 'blocked_by') {
1685
+ continuationBits.push(`seeded from task ${resumeContext.sourceTaskId}`)
1686
+ } else if (seededResumeState) {
1687
+ continuationBits.push('restored CLI resume handles')
1688
+ }
1234
1689
  task.checkpoint = {
1235
1690
  lastSessionId: sessionId,
1236
- note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started`,
1691
+ note: `Attempt ${(task.attempts || 0) + 1}/${task.maxAttempts || '?'} started${continuationBits.length ? ` (${continuationBits.join('; ')})` : ''}`,
1237
1692
  updatedAt: Date.now(),
1238
1693
  }
1239
1694
  saveTasks(tasks)
@@ -1251,9 +1706,9 @@ export async function processNext() {
1251
1706
  const delegatorId = (task as unknown as Record<string, unknown>).delegatedByAgentId as string | undefined
1252
1707
  const delegator = delegatorId ? agents[delegatorId] : null
1253
1708
  const prefix = `[delegation-source:${delegatorId || ''}:${delegator?.name || 'Agent'}:${delegator?.avatarSeed || ''}]`
1254
- initialText = `${prefix}\nDelegated by **${delegator?.name || 'another agent'}** | [${task.title}](#task:${task.id})\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`\n\nI'll begin working on this now.`
1709
+ initialText = `${prefix}\nDelegated by **${delegator?.name || 'another agent'}** | [${task.title}](#task:${task.id})\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`${buildTaskContinuationNote(Boolean(reusedExistingSession), resumeContext)}\n\nI'll begin working on this now.`
1255
1710
  } else {
1256
- initialText = `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`\n\nI'll begin working on this now.`
1711
+ initialText = `Starting task: **${task.title}**\n\n${task.description || ''}\n\nWorking directory: \`${taskCwd}\`${buildTaskContinuationNote(Boolean(reusedExistingSession), resumeContext)}\n\nI'll begin working on this now.`
1257
1712
  }
1258
1713
  sessions[sessionId].messages.push({
1259
1714
  role: 'assistant',
@@ -1379,6 +1834,7 @@ export async function processNext() {
1379
1834
  })
1380
1835
  notifyMainChatScheduleResult(doneTask)
1381
1836
  notifyAgentThreadTaskResult(doneTask)
1837
+ cleanupTerminalOneOffSchedule(doneTask)
1382
1838
  // Clean up LangGraph checkpoints for completed tasks
1383
1839
  getCheckpointSaver().deleteThread(taskId).catch((e) =>
1384
1840
  console.warn(`[queue] Failed to clean up checkpoints for task ${taskId}:`, e)
@@ -1406,6 +1862,7 @@ export async function processNext() {
1406
1862
  if (doneTask?.status === 'failed') {
1407
1863
  notifyMainChatScheduleResult(doneTask)
1408
1864
  notifyAgentThreadTaskResult(doneTask)
1865
+ cleanupTerminalOneOffSchedule(doneTask)
1409
1866
  }
1410
1867
  console.warn(`[queue] Task "${task.title}" failed completion validation`)
1411
1868
  }
@@ -1455,6 +1912,7 @@ export async function processNext() {
1455
1912
  if (latest?.status === 'failed') {
1456
1913
  notifyMainChatScheduleResult(latest)
1457
1914
  notifyAgentThreadTaskResult(latest)
1915
+ cleanupTerminalOneOffSchedule(latest)
1458
1916
  }
1459
1917
  }
1460
1918
  }
@@ -1492,14 +1950,15 @@ export function cleanupFinishedTaskSessions() {
1492
1950
 
1493
1951
  /** Recover running tasks that appear stalled and requeue/dead-letter them per retry policy. */
1494
1952
  export function recoverStalledRunningTasks(): { recovered: number; deadLettered: number } {
1953
+ const finished = reconcileFinishedRunningTasks()
1495
1954
  const settings = loadSettings()
1496
1955
  const stallTimeoutMin = normalizeInt(settings.taskStallTimeoutMin, 45, 5, 24 * 60)
1497
1956
  const staleMs = stallTimeoutMin * 60_000
1498
1957
  const now = Date.now()
1499
1958
  const tasks = loadTasks()
1500
1959
  const queue = loadQueue()
1501
- let recovered = 0
1502
- let deadLettered = 0
1960
+ let recovered = finished.reconciled
1961
+ let deadLettered = finished.deadLettered
1503
1962
  let changed = false
1504
1963
 
1505
1964
  for (const task of Object.values(tasks) as BoardTask[]) {