@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.
- package/README.md +577 -0
- package/bin/server-cmd.js +359 -0
- package/bin/swarmclaw.js +29 -0
- package/bin/swarmclaw.mjs +1504 -0
- package/next.config.ts +33 -0
- package/package.json +112 -0
- package/postcss.config.mjs +7 -0
- package/public/branding/swarmclaw-org-avatar.png +0 -0
- package/public/branding/swarmclaw-org-avatar.svg +58 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/screenshots/agents.png +0 -0
- package/public/screenshots/connectors.png +0 -0
- package/public/screenshots/dashboard.png +0 -0
- package/public/screenshots/new-session-openclaw.png +0 -0
- package/public/screenshots/providers.png +0 -0
- package/public/screenshots/schedules.png +0 -0
- package/public/screenshots/tasks.png +0 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/agents/[id]/route.ts +30 -0
- package/src/app/api/agents/[id]/thread/route.ts +66 -0
- package/src/app/api/agents/generate/route.ts +42 -0
- package/src/app/api/agents/route.ts +33 -0
- package/src/app/api/auth/route.ts +25 -0
- package/src/app/api/claude-skills/route.ts +42 -0
- package/src/app/api/clawhub/install/route.ts +39 -0
- package/src/app/api/clawhub/search/route.ts +11 -0
- package/src/app/api/connectors/[id]/route.ts +79 -0
- package/src/app/api/connectors/route.ts +60 -0
- package/src/app/api/credentials/[id]/route.ts +14 -0
- package/src/app/api/credentials/route.ts +31 -0
- package/src/app/api/daemon/health-check/route.ts +11 -0
- package/src/app/api/daemon/route.ts +22 -0
- package/src/app/api/dirs/pick/route.ts +60 -0
- package/src/app/api/dirs/route.ts +29 -0
- package/src/app/api/documents/[id]/route.ts +47 -0
- package/src/app/api/documents/route.ts +93 -0
- package/src/app/api/files/serve/route.ts +69 -0
- package/src/app/api/generate/info/route.ts +12 -0
- package/src/app/api/generate/route.ts +106 -0
- package/src/app/api/ip/route.ts +6 -0
- package/src/app/api/knowledge/[id]/route.ts +61 -0
- package/src/app/api/knowledge/route.ts +48 -0
- package/src/app/api/knowledge/upload/route.ts +86 -0
- package/src/app/api/logs/route.ts +65 -0
- package/src/app/api/mcp-servers/[id]/route.ts +32 -0
- package/src/app/api/mcp-servers/[id]/test/route.ts +23 -0
- package/src/app/api/mcp-servers/[id]/tools/route.ts +32 -0
- package/src/app/api/mcp-servers/route.ts +27 -0
- package/src/app/api/memory/[id]/route.ts +126 -0
- package/src/app/api/memory/maintenance/route.ts +63 -0
- package/src/app/api/memory/route.ts +111 -0
- package/src/app/api/memory-images/[filename]/route.ts +36 -0
- package/src/app/api/orchestrator/run/route.ts +43 -0
- package/src/app/api/plugins/install/route.ts +58 -0
- package/src/app/api/plugins/marketplace/route.ts +33 -0
- package/src/app/api/plugins/route.ts +21 -0
- package/src/app/api/preview-server/route.ts +339 -0
- package/src/app/api/providers/[id]/models/route.ts +29 -0
- package/src/app/api/providers/[id]/route.ts +34 -0
- package/src/app/api/providers/configs/route.ts +7 -0
- package/src/app/api/providers/ollama/route.ts +30 -0
- package/src/app/api/providers/openclaw/health/route.ts +23 -0
- package/src/app/api/providers/route.ts +28 -0
- package/src/app/api/runs/[id]/route.ts +9 -0
- package/src/app/api/runs/route.ts +13 -0
- package/src/app/api/schedules/[id]/route.ts +28 -0
- package/src/app/api/schedules/[id]/run/route.ts +104 -0
- package/src/app/api/schedules/route.ts +78 -0
- package/src/app/api/secrets/[id]/route.ts +29 -0
- package/src/app/api/secrets/route.ts +42 -0
- package/src/app/api/sessions/[id]/browser/route.ts +13 -0
- package/src/app/api/sessions/[id]/chat/route.ts +96 -0
- package/src/app/api/sessions/[id]/clear/route.ts +19 -0
- package/src/app/api/sessions/[id]/deploy/route.ts +34 -0
- package/src/app/api/sessions/[id]/devserver/route.ts +69 -0
- package/src/app/api/sessions/[id]/mailbox/route.ts +70 -0
- package/src/app/api/sessions/[id]/main-loop/route.ts +94 -0
- package/src/app/api/sessions/[id]/messages/route.ts +9 -0
- package/src/app/api/sessions/[id]/retry/route.ts +28 -0
- package/src/app/api/sessions/[id]/route.ts +103 -0
- package/src/app/api/sessions/[id]/stop/route.ts +13 -0
- package/src/app/api/sessions/heartbeat/route.ts +26 -0
- package/src/app/api/sessions/route.ts +85 -0
- package/src/app/api/settings/route.ts +58 -0
- package/src/app/api/setup/check-provider/route.ts +326 -0
- package/src/app/api/setup/doctor/route.ts +250 -0
- package/src/app/api/skills/[id]/route.ts +40 -0
- package/src/app/api/skills/import/route.ts +69 -0
- package/src/app/api/skills/route.ts +28 -0
- package/src/app/api/tasks/[id]/route.ts +102 -0
- package/src/app/api/tasks/route.ts +115 -0
- package/src/app/api/tts/route.ts +40 -0
- package/src/app/api/upload/route.ts +18 -0
- package/src/app/api/uploads/[filename]/route.ts +59 -0
- package/src/app/api/usage/route.ts +35 -0
- package/src/app/api/version/route.ts +81 -0
- package/src/app/api/version/update/route.ts +95 -0
- package/src/app/api/webhooks/[id]/history/route.ts +13 -0
- package/src/app/api/webhooks/[id]/route.ts +204 -0
- package/src/app/api/webhooks/route.ts +37 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +370 -0
- package/src/app/layout.tsx +52 -0
- package/src/app/page.tsx +172 -0
- package/src/cli/index.js +1232 -0
- package/src/cli/index.test.js +281 -0
- package/src/cli/index.ts +1158 -0
- package/src/cli/spec.js +284 -0
- package/src/components/agents/agent-card.tsx +219 -0
- package/src/components/agents/agent-chat-list.tsx +165 -0
- package/src/components/agents/agent-list.tsx +110 -0
- package/src/components/agents/agent-sheet.tsx +1220 -0
- package/src/components/auth/access-key-gate.tsx +248 -0
- package/src/components/auth/setup-wizard.tsx +940 -0
- package/src/components/auth/user-picker.tsx +88 -0
- package/src/components/chat/chat-area.tsx +406 -0
- package/src/components/chat/chat-header.tsx +491 -0
- package/src/components/chat/chat-tool-toggles.tsx +161 -0
- package/src/components/chat/code-block.tsx +146 -0
- package/src/components/chat/dev-server-bar.tsx +39 -0
- package/src/components/chat/message-bubble.tsx +486 -0
- package/src/components/chat/message-list.tsx +299 -0
- package/src/components/chat/session-debug-panel.tsx +196 -0
- package/src/components/chat/streaming-bubble.tsx +85 -0
- package/src/components/chat/thinking-indicator.tsx +26 -0
- package/src/components/chat/tool-call-bubble.tsx +438 -0
- package/src/components/chat/tool-request-banner.tsx +103 -0
- package/src/components/connectors/connector-list.tsx +196 -0
- package/src/components/connectors/connector-sheet.tsx +804 -0
- package/src/components/input/chat-input.tsx +235 -0
- package/src/components/knowledge/knowledge-list.tsx +206 -0
- package/src/components/knowledge/knowledge-sheet.tsx +316 -0
- package/src/components/layout/app-layout.tsx +1016 -0
- package/src/components/layout/daemon-indicator.tsx +56 -0
- package/src/components/layout/mobile-header.tsx +31 -0
- package/src/components/layout/network-banner.tsx +17 -0
- package/src/components/layout/update-banner.tsx +130 -0
- package/src/components/logs/log-list.tsx +358 -0
- package/src/components/mcp-servers/mcp-server-list.tsx +122 -0
- package/src/components/mcp-servers/mcp-server-sheet.tsx +243 -0
- package/src/components/memory/memory-card.tsx +63 -0
- package/src/components/memory/memory-detail.tsx +339 -0
- package/src/components/memory/memory-list.tsx +198 -0
- package/src/components/memory/memory-sheet.tsx +70 -0
- package/src/components/plugins/plugin-list.tsx +60 -0
- package/src/components/plugins/plugin-sheet.tsx +311 -0
- package/src/components/providers/provider-list.tsx +96 -0
- package/src/components/providers/provider-sheet.tsx +542 -0
- package/src/components/runs/run-list.tsx +231 -0
- package/src/components/schedules/schedule-card.tsx +63 -0
- package/src/components/schedules/schedule-list.tsx +76 -0
- package/src/components/schedules/schedule-sheet.tsx +336 -0
- package/src/components/secrets/secret-sheet.tsx +180 -0
- package/src/components/secrets/secrets-list.tsx +91 -0
- package/src/components/sessions/new-session-sheet.tsx +478 -0
- package/src/components/sessions/session-card.tsx +144 -0
- package/src/components/sessions/session-list.tsx +202 -0
- package/src/components/shared/ai-gen-block.tsx +77 -0
- package/src/components/shared/avatar.tsx +48 -0
- package/src/components/shared/bottom-sheet.tsx +30 -0
- package/src/components/shared/confirm-dialog.tsx +47 -0
- package/src/components/shared/connector-platform-icon.tsx +113 -0
- package/src/components/shared/dir-browser.tsx +285 -0
- package/src/components/shared/dropdown.tsx +55 -0
- package/src/components/shared/icon-button.tsx +25 -0
- package/src/components/shared/settings/plugin-manager.tsx +207 -0
- package/src/components/shared/settings/section-capability-policy.tsx +93 -0
- package/src/components/shared/settings/section-embedding.tsx +99 -0
- package/src/components/shared/settings/section-heartbeat.tsx +168 -0
- package/src/components/shared/settings/section-memory.tsx +77 -0
- package/src/components/shared/settings/section-orchestrator.tsx +108 -0
- package/src/components/shared/settings/section-providers.tsx +181 -0
- package/src/components/shared/settings/section-runtime-loop.tsx +183 -0
- package/src/components/shared/settings/section-secrets.tsx +132 -0
- package/src/components/shared/settings/section-user-preferences.tsx +24 -0
- package/src/components/shared/settings/section-voice.tsx +53 -0
- package/src/components/shared/settings/settings-sheet.tsx +88 -0
- package/src/components/shared/settings/types.ts +7 -0
- package/src/components/shared/settings/utils.ts +13 -0
- package/src/components/shared/settings-sheet.tsx +1 -0
- package/src/components/shared/skeleton.tsx +19 -0
- package/src/components/shared/usage-badge.tsx +28 -0
- package/src/components/skills/clawhub-browser.tsx +225 -0
- package/src/components/skills/skill-list.tsx +70 -0
- package/src/components/skills/skill-sheet.tsx +254 -0
- package/src/components/tasks/task-board.tsx +96 -0
- package/src/components/tasks/task-card.tsx +179 -0
- package/src/components/tasks/task-column.tsx +73 -0
- package/src/components/tasks/task-list.tsx +118 -0
- package/src/components/tasks/task-sheet.tsx +415 -0
- package/src/components/ui/avatar.tsx +109 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/dialog.tsx +158 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/scroll-area.tsx +58 -0
- package/src/components/ui/select.tsx +190 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +143 -0
- package/src/components/ui/sonner.tsx +22 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +56 -0
- package/src/components/usage/usage-list.tsx +105 -0
- package/src/components/webhooks/webhook-list.tsx +166 -0
- package/src/components/webhooks/webhook-sheet.tsx +402 -0
- package/src/hooks/use-auto-resize.ts +20 -0
- package/src/hooks/use-media-query.ts +21 -0
- package/src/hooks/use-speech-recognition.ts +83 -0
- package/src/instrumentation.ts +8 -0
- package/src/lib/agents.ts +13 -0
- package/src/lib/api-client.ts +100 -0
- package/src/lib/chat.ts +60 -0
- package/src/lib/memory.ts +42 -0
- package/src/lib/openclaw-endpoint.test.ts +48 -0
- package/src/lib/openclaw-endpoint.ts +67 -0
- package/src/lib/provider-config.ts +13 -0
- package/src/lib/providers/anthropic.ts +135 -0
- package/src/lib/providers/claude-cli.ts +202 -0
- package/src/lib/providers/codex-cli.ts +260 -0
- package/src/lib/providers/index.ts +351 -0
- package/src/lib/providers/ollama.ts +131 -0
- package/src/lib/providers/openai.ts +164 -0
- package/src/lib/providers/openclaw.ts +330 -0
- package/src/lib/providers/opencode-cli.ts +164 -0
- package/src/lib/runtime-loop.ts +15 -0
- package/src/lib/schedule-dedupe.test.ts +84 -0
- package/src/lib/schedule-dedupe.ts +174 -0
- package/src/lib/schedule-name.ts +62 -0
- package/src/lib/schedules.ts +16 -0
- package/src/lib/server/agent-registry.ts +70 -0
- package/src/lib/server/api-routes.test.ts +362 -0
- package/src/lib/server/autonomy-contract.ts +200 -0
- package/src/lib/server/build-llm.ts +155 -0
- package/src/lib/server/capability-router.test.ts +21 -0
- package/src/lib/server/capability-router.ts +172 -0
- package/src/lib/server/chat-execution.ts +894 -0
- package/src/lib/server/clawhub-client.test.ts +161 -0
- package/src/lib/server/clawhub-client.ts +26 -0
- package/src/lib/server/connectors/connector-routing.test.ts +243 -0
- package/src/lib/server/connectors/discord.ts +116 -0
- package/src/lib/server/connectors/googlechat.ts +66 -0
- package/src/lib/server/connectors/manager.ts +559 -0
- package/src/lib/server/connectors/matrix.ts +78 -0
- package/src/lib/server/connectors/media.ts +149 -0
- package/src/lib/server/connectors/openclaw.test.ts +375 -0
- package/src/lib/server/connectors/openclaw.ts +1132 -0
- package/src/lib/server/connectors/signal.ts +183 -0
- package/src/lib/server/connectors/slack.ts +258 -0
- package/src/lib/server/connectors/teams.ts +94 -0
- package/src/lib/server/connectors/telegram.ts +221 -0
- package/src/lib/server/connectors/types.ts +62 -0
- package/src/lib/server/connectors/whatsapp.ts +349 -0
- package/src/lib/server/context-manager.ts +232 -0
- package/src/lib/server/cost.ts +31 -0
- package/src/lib/server/daemon-state.ts +354 -0
- package/src/lib/server/data-dir.ts +3 -0
- package/src/lib/server/embeddings.ts +111 -0
- package/src/lib/server/execution-log.ts +257 -0
- package/src/lib/server/gateway/protocol.test.ts +54 -0
- package/src/lib/server/gateway/protocol.ts +114 -0
- package/src/lib/server/heartbeat-service.ts +366 -0
- package/src/lib/server/knowledge-db.test.ts +441 -0
- package/src/lib/server/logger.ts +47 -0
- package/src/lib/server/main-agent-loop.ts +1017 -0
- package/src/lib/server/mcp-client.test.ts +342 -0
- package/src/lib/server/mcp-client.ts +130 -0
- package/src/lib/server/memory-db.ts +1078 -0
- package/src/lib/server/memory-graph.test.ts +153 -0
- package/src/lib/server/memory-graph.ts +138 -0
- package/src/lib/server/openclaw-health.ts +245 -0
- package/src/lib/server/orchestrator-lg.ts +431 -0
- package/src/lib/server/orchestrator.ts +364 -0
- package/src/lib/server/playwright-proxy.mjs +70 -0
- package/src/lib/server/plugins.ts +229 -0
- package/src/lib/server/process-manager.ts +327 -0
- package/src/lib/server/provider-health.ts +113 -0
- package/src/lib/server/queue.ts +859 -0
- package/src/lib/server/runtime-settings.ts +119 -0
- package/src/lib/server/scheduler.ts +196 -0
- package/src/lib/server/session-mailbox.ts +129 -0
- package/src/lib/server/session-run-manager.ts +512 -0
- package/src/lib/server/session-tools/connector.ts +124 -0
- package/src/lib/server/session-tools/context-mgmt.ts +103 -0
- package/src/lib/server/session-tools/context.ts +114 -0
- package/src/lib/server/session-tools/crud.ts +673 -0
- package/src/lib/server/session-tools/delegate.ts +708 -0
- package/src/lib/server/session-tools/file.ts +264 -0
- package/src/lib/server/session-tools/index.ts +164 -0
- package/src/lib/server/session-tools/memory.ts +230 -0
- package/src/lib/server/session-tools/session-info.ts +422 -0
- package/src/lib/server/session-tools/session-tools-wiring.test.ts +166 -0
- package/src/lib/server/session-tools/shell.ts +171 -0
- package/src/lib/server/session-tools/web.ts +408 -0
- package/src/lib/server/session-tools.ts +9 -0
- package/src/lib/server/skills-normalize.ts +130 -0
- package/src/lib/server/storage-mcp.test.ts +161 -0
- package/src/lib/server/storage.ts +670 -0
- package/src/lib/server/stream-agent-chat.ts +571 -0
- package/src/lib/server/task-reports.ts +122 -0
- package/src/lib/server/task-result.ts +161 -0
- package/src/lib/server/task-validation.test.ts +27 -0
- package/src/lib/server/task-validation.ts +90 -0
- package/src/lib/server/tool-capability-policy.test.ts +58 -0
- package/src/lib/server/tool-capability-policy.ts +262 -0
- package/src/lib/sessions.ts +68 -0
- package/src/lib/tasks.ts +20 -0
- package/src/lib/tts.ts +42 -0
- package/src/lib/upload.ts +10 -0
- package/src/lib/utils.ts +6 -0
- package/src/proxy.ts +43 -0
- package/src/stores/use-app-store.ts +468 -0
- package/src/stores/use-chat-store.ts +323 -0
- package/src/types/index.ts +621 -0
- 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
|
+
}
|