@swarmclawai/swarmclaw 1.2.0 → 1.2.2

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 (241) hide show
  1. package/README.md +19 -0
  2. package/package.json +5 -2
  3. package/skills/coding-agent/SKILL.md +111 -0
  4. package/skills/github/SKILL.md +140 -0
  5. package/skills/nano-banana-pro/SKILL.md +62 -0
  6. package/skills/nano-banana-pro/scripts/generate_image.py +235 -0
  7. package/skills/nano-pdf/SKILL.md +53 -0
  8. package/skills/openai-image-gen/SKILL.md +78 -0
  9. package/skills/openai-image-gen/scripts/gen.py +328 -0
  10. package/skills/resourceful-problem-solving/SKILL.md +49 -0
  11. package/skills/skill-creator/SKILL.md +147 -0
  12. package/skills/skill-creator/scripts/init_skill.py +378 -0
  13. package/skills/skill-creator/scripts/quick_validate.py +159 -0
  14. package/skills/summarize/SKILL.md +77 -0
  15. package/src/app/api/auth/route.ts +20 -5
  16. package/src/app/api/chats/[id]/deploy/route.ts +11 -6
  17. package/src/app/api/chats/[id]/devserver/route.ts +17 -20
  18. package/src/app/api/chats/[id]/messages/route.ts +15 -11
  19. package/src/app/api/chats/[id]/route.ts +9 -10
  20. package/src/app/api/chats/[id]/stop/route.ts +5 -7
  21. package/src/app/api/chats/messages-route.test.ts +8 -6
  22. package/src/app/api/chats/route.ts +9 -10
  23. package/src/app/api/credentials/[id]/route.ts +4 -1
  24. package/src/app/api/extensions/marketplace/route.ts +5 -2
  25. package/src/app/api/ip/route.ts +2 -2
  26. package/src/app/api/memory/maintenance/route.ts +5 -2
  27. package/src/app/api/preview-server/route.ts +15 -12
  28. package/src/app/api/projects/[id]/route.ts +7 -46
  29. package/src/app/api/system/status/route.ts +11 -0
  30. package/src/app/api/upload/route.ts +4 -1
  31. package/src/cli/index.js +7 -0
  32. package/src/cli/spec.js +1 -0
  33. package/src/components/agents/agent-files-editor.tsx +44 -32
  34. package/src/components/agents/personality-builder.tsx +13 -7
  35. package/src/components/agents/trash-list.tsx +1 -1
  36. package/src/components/chat/chat-area.tsx +45 -23
  37. package/src/components/chat/message-bubble.test.ts +35 -0
  38. package/src/components/chat/message-bubble.tsx +20 -9
  39. package/src/components/chat/message-list.tsx +62 -42
  40. package/src/components/chat/swarm-status-card.tsx +10 -3
  41. package/src/components/input/chat-input.tsx +34 -14
  42. package/src/components/layout/daemon-indicator.tsx +7 -8
  43. package/src/components/layout/update-banner.tsx +8 -13
  44. package/src/components/logs/log-list.tsx +1 -1
  45. package/src/components/memory/memory-card.tsx +3 -1
  46. package/src/components/org-chart/org-chart-view.tsx +4 -0
  47. package/src/components/projects/project-list.tsx +4 -2
  48. package/src/components/projects/tabs/overview-tab.tsx +3 -2
  49. package/src/components/secrets/secret-sheet.tsx +1 -1
  50. package/src/components/secrets/secrets-list.tsx +1 -1
  51. package/src/components/shared/agent-switch-dialog.tsx +12 -6
  52. package/src/components/shared/dir-browser.tsx +22 -18
  53. package/src/components/skills/skill-sheet.tsx +2 -3
  54. package/src/components/tasks/task-list.tsx +1 -1
  55. package/src/components/tasks/task-sheet.tsx +1 -1
  56. package/src/hooks/use-openclaw-gateway.ts +46 -27
  57. package/src/instrumentation.ts +10 -7
  58. package/src/lib/chat/assistant-render-id.ts +3 -0
  59. package/src/lib/chat/chat-streaming-state.test.ts +42 -3
  60. package/src/lib/chat/chat-streaming-state.ts +20 -8
  61. package/src/lib/chat/chat.ts +18 -2
  62. package/src/lib/chat/queued-message-queue.test.ts +23 -1
  63. package/src/lib/chat/queued-message-queue.ts +11 -2
  64. package/src/lib/providers/anthropic.ts +6 -3
  65. package/src/lib/providers/claude-cli.ts +9 -3
  66. package/src/lib/providers/cli-utils.test.ts +124 -0
  67. package/src/lib/providers/cli-utils.ts +15 -0
  68. package/src/lib/providers/codex-cli.ts +9 -3
  69. package/src/lib/providers/gemini-cli.ts +6 -2
  70. package/src/lib/providers/index.ts +4 -1
  71. package/src/lib/providers/ollama.ts +5 -2
  72. package/src/lib/providers/openai.ts +8 -5
  73. package/src/lib/providers/opencode-cli.ts +6 -2
  74. package/src/lib/server/activity/activity-log.ts +21 -0
  75. package/src/lib/server/agents/agent-availability.test.ts +10 -5
  76. package/src/lib/server/agents/agent-cascade.ts +79 -59
  77. package/src/lib/server/agents/agent-registry.ts +23 -4
  78. package/src/lib/server/agents/agent-repository.ts +90 -0
  79. package/src/lib/server/agents/delegation-job-repository.ts +53 -0
  80. package/src/lib/server/agents/delegation-jobs.ts +11 -4
  81. package/src/lib/server/agents/guardian-checkpoint-repository.ts +35 -0
  82. package/src/lib/server/agents/guardian.ts +2 -2
  83. package/src/lib/server/agents/main-agent-loop.ts +14 -6
  84. package/src/lib/server/agents/main-loop-state-repository.ts +38 -0
  85. package/src/lib/server/agents/subagent-runtime.ts +9 -6
  86. package/src/lib/server/agents/subagent-swarm.ts +3 -2
  87. package/src/lib/server/agents/task-session.ts +3 -4
  88. package/src/lib/server/approvals/approval-repository.ts +30 -0
  89. package/src/lib/server/autonomy/supervisor-incident-repository.ts +42 -0
  90. package/src/lib/server/autonomy/supervisor-reflection.ts +14 -1
  91. package/src/lib/server/chat-execution/chat-execution-types.ts +38 -0
  92. package/src/lib/server/chat-execution/chat-execution-utils.ts +1 -1
  93. package/src/lib/server/chat-execution/chat-execution.ts +84 -1914
  94. package/src/lib/server/chat-execution/chat-turn-finalization.ts +620 -0
  95. package/src/lib/server/chat-execution/chat-turn-partial-persistence.ts +221 -0
  96. package/src/lib/server/chat-execution/chat-turn-preflight.ts +133 -0
  97. package/src/lib/server/chat-execution/chat-turn-preparation.ts +817 -0
  98. package/src/lib/server/chat-execution/chat-turn-stream-execution.ts +296 -0
  99. package/src/lib/server/chat-execution/chat-turn-tool-routing.ts +5 -5
  100. package/src/lib/server/chat-execution/continuation-evaluator.ts +4 -3
  101. package/src/lib/server/chat-execution/continuation-limits.ts +6 -3
  102. package/src/lib/server/chat-execution/message-classifier.test.ts +329 -0
  103. package/src/lib/server/chat-execution/message-classifier.ts +5 -2
  104. package/src/lib/server/chat-execution/post-stream-finalization.ts +5 -2
  105. package/src/lib/server/chat-execution/prompt-builder.ts +22 -1
  106. package/src/lib/server/chat-execution/prompt-sections.ts +55 -13
  107. package/src/lib/server/chat-execution/response-completeness.ts +5 -2
  108. package/src/lib/server/chat-execution/situational-awareness.ts +12 -7
  109. package/src/lib/server/chat-execution/stream-agent-chat.ts +58 -25
  110. package/src/lib/server/chatrooms/chatroom-memory-bridge.ts +6 -3
  111. package/src/lib/server/chatrooms/chatroom-repository.ts +32 -0
  112. package/src/lib/server/connectors/bluebubbles.ts +7 -4
  113. package/src/lib/server/connectors/connector-inbound.ts +16 -13
  114. package/src/lib/server/connectors/connector-lifecycle.ts +11 -8
  115. package/src/lib/server/connectors/connector-outbound.ts +6 -3
  116. package/src/lib/server/connectors/connector-repository.ts +58 -0
  117. package/src/lib/server/connectors/discord.ts +10 -7
  118. package/src/lib/server/connectors/email.ts +17 -14
  119. package/src/lib/server/connectors/googlechat.ts +7 -4
  120. package/src/lib/server/connectors/inbound-audio-transcription.ts +5 -2
  121. package/src/lib/server/connectors/matrix.ts +6 -3
  122. package/src/lib/server/connectors/openclaw.ts +20 -17
  123. package/src/lib/server/connectors/outbox.ts +4 -1
  124. package/src/lib/server/connectors/runtime-state.test.ts +117 -0
  125. package/src/lib/server/connectors/runtime-state.ts +19 -0
  126. package/src/lib/server/connectors/session-consolidation.ts +5 -2
  127. package/src/lib/server/connectors/signal.ts +9 -6
  128. package/src/lib/server/connectors/slack.ts +13 -10
  129. package/src/lib/server/connectors/teams.ts +8 -5
  130. package/src/lib/server/connectors/telegram.ts +15 -12
  131. package/src/lib/server/connectors/whatsapp.ts +32 -29
  132. package/src/lib/server/credentials/credential-repository.ts +7 -0
  133. package/src/lib/server/embeddings.ts +4 -1
  134. package/src/lib/server/gateways/gateway-profile-repository.ts +4 -0
  135. package/src/lib/server/link-understanding.ts +4 -1
  136. package/src/lib/server/memory/memory-abstract.test.ts +59 -0
  137. package/src/lib/server/memory/memory-abstract.ts +59 -0
  138. package/src/lib/server/memory/memory-db.ts +40 -14
  139. package/src/lib/server/missions/mission-repository.ts +74 -0
  140. package/src/lib/server/missions/mission-service/actions.ts +6 -0
  141. package/src/lib/server/missions/mission-service/bindings.ts +9 -0
  142. package/src/lib/server/missions/mission-service/context.ts +4 -0
  143. package/src/lib/server/missions/mission-service/core.ts +2269 -0
  144. package/src/lib/server/missions/mission-service/queries.ts +12 -0
  145. package/src/lib/server/missions/mission-service/recovery.ts +5 -0
  146. package/src/lib/server/missions/mission-service/ticks.ts +9 -0
  147. package/src/lib/server/missions/mission-service.test.ts +9 -2
  148. package/src/lib/server/missions/mission-service.ts +6 -2263
  149. package/src/lib/server/openclaw/gateway.ts +8 -5
  150. package/src/lib/server/persistence/repository-utils.ts +154 -0
  151. package/src/lib/server/persistence/storage-context.ts +51 -0
  152. package/src/lib/server/persistence/transaction.ts +1 -0
  153. package/src/lib/server/project-utils.ts +13 -0
  154. package/src/lib/server/projects/project-repository.ts +36 -0
  155. package/src/lib/server/projects/project-service.ts +79 -0
  156. package/src/lib/server/protocols/protocol-agent-turn.ts +5 -2
  157. package/src/lib/server/protocols/protocol-normalization.test.ts +6 -4
  158. package/src/lib/server/protocols/protocol-run-lifecycle.ts +5 -2
  159. package/src/lib/server/protocols/protocol-step-helpers.ts +4 -1
  160. package/src/lib/server/provider-health.ts +18 -0
  161. package/src/lib/server/query-expansion.ts +4 -1
  162. package/src/lib/server/runtime/alert-dispatch.ts +8 -7
  163. package/src/lib/server/runtime/daemon-policy.ts +1 -1
  164. package/src/lib/server/runtime/daemon-state/core.ts +1570 -0
  165. package/src/lib/server/runtime/daemon-state/health.ts +6 -0
  166. package/src/lib/server/runtime/daemon-state/policy.ts +7 -0
  167. package/src/lib/server/runtime/daemon-state/supervisor.ts +6 -0
  168. package/src/lib/server/runtime/daemon-state.test.ts +48 -0
  169. package/src/lib/server/runtime/daemon-state.ts +3 -1331
  170. package/src/lib/server/runtime/estop-repository.ts +4 -0
  171. package/src/lib/server/runtime/estop.ts +3 -1
  172. package/src/lib/server/runtime/heartbeat-service.test.ts +2 -2
  173. package/src/lib/server/runtime/heartbeat-service.ts +78 -34
  174. package/src/lib/server/runtime/heartbeat-wake.ts +6 -4
  175. package/src/lib/server/runtime/idle-window.ts +6 -3
  176. package/src/lib/server/runtime/network.ts +11 -0
  177. package/src/lib/server/runtime/orchestrator-events.ts +2 -2
  178. package/src/lib/server/runtime/perf.ts +4 -1
  179. package/src/lib/server/runtime/process-manager.ts +7 -4
  180. package/src/lib/server/runtime/queue/claims.ts +4 -0
  181. package/src/lib/server/runtime/queue/core.ts +2079 -0
  182. package/src/lib/server/runtime/queue/execution.ts +7 -0
  183. package/src/lib/server/runtime/queue/followups.ts +4 -0
  184. package/src/lib/server/runtime/queue/queries.ts +12 -0
  185. package/src/lib/server/runtime/queue/recovery.ts +7 -0
  186. package/src/lib/server/runtime/queue-recovery.test.ts +48 -13
  187. package/src/lib/server/runtime/queue-repository.ts +17 -0
  188. package/src/lib/server/runtime/queue.ts +5 -2058
  189. package/src/lib/server/runtime/run-ledger.ts +6 -5
  190. package/src/lib/server/runtime/run-repository.ts +73 -0
  191. package/src/lib/server/runtime/runtime-lock-repository.ts +8 -0
  192. package/src/lib/server/runtime/runtime-settings.ts +1 -1
  193. package/src/lib/server/runtime/runtime-state.ts +99 -0
  194. package/src/lib/server/runtime/scheduler.ts +13 -8
  195. package/src/lib/server/runtime/session-run-manager/cancellation.ts +157 -0
  196. package/src/lib/server/runtime/session-run-manager/drain.ts +246 -0
  197. package/src/lib/server/runtime/session-run-manager/enqueue.ts +287 -0
  198. package/src/lib/server/runtime/session-run-manager/queries.ts +117 -0
  199. package/src/lib/server/runtime/session-run-manager/recovery.ts +238 -0
  200. package/src/lib/server/runtime/session-run-manager/state.ts +441 -0
  201. package/src/lib/server/runtime/session-run-manager/types.ts +74 -0
  202. package/src/lib/server/runtime/session-run-manager.ts +72 -1374
  203. package/src/lib/server/runtime/watch-job-repository.ts +35 -0
  204. package/src/lib/server/runtime/watch-jobs.ts +3 -1
  205. package/src/lib/server/sandbox/bridge-auth-registry.ts +6 -0
  206. package/src/lib/server/sandbox/novnc-auth.ts +10 -0
  207. package/src/lib/server/schedules/schedule-repository.ts +42 -0
  208. package/src/lib/server/session-tools/context.ts +14 -0
  209. package/src/lib/server/session-tools/discovery.ts +9 -6
  210. package/src/lib/server/session-tools/index.ts +3 -1
  211. package/src/lib/server/session-tools/platform.ts +1 -1
  212. package/src/lib/server/session-tools/subagent.ts +23 -2
  213. package/src/lib/server/session-tools/wallet.ts +4 -1
  214. package/src/lib/server/sessions/session-repository.ts +85 -0
  215. package/src/lib/server/settings/settings-repository.ts +25 -0
  216. package/src/lib/server/skills/clawhub-client.ts +4 -1
  217. package/src/lib/server/skills/runtime-skill-resolver.ts +8 -2
  218. package/src/lib/server/skills/skill-discovery.test.ts +2 -2
  219. package/src/lib/server/skills/skill-discovery.ts +2 -2
  220. package/src/lib/server/skills/skill-eligibility.ts +6 -0
  221. package/src/lib/server/skills/skill-repository.ts +14 -0
  222. package/src/lib/server/solana.ts +6 -0
  223. package/src/lib/server/storage-auth.ts +5 -5
  224. package/src/lib/server/storage-normalization.ts +4 -0
  225. package/src/lib/server/storage.ts +32 -32
  226. package/src/lib/server/tasks/task-followups.ts +4 -1
  227. package/src/lib/server/tasks/task-repository.ts +54 -0
  228. package/src/lib/server/tool-loop-detection.ts +8 -3
  229. package/src/lib/server/tool-planning.ts +226 -0
  230. package/src/lib/server/tool-retry.ts +4 -3
  231. package/src/lib/server/usage/usage-repository.ts +30 -0
  232. package/src/lib/server/wallet/wallet-portfolio.ts +29 -0
  233. package/src/lib/server/webhooks/webhook-repository.ts +10 -0
  234. package/src/lib/server/ws-hub.ts +5 -2
  235. package/src/lib/strip-internal-metadata.test.ts +78 -37
  236. package/src/lib/strip-internal-metadata.ts +20 -6
  237. package/src/stores/use-approval-store.ts +7 -1
  238. package/src/stores/use-chat-store.test.ts +54 -0
  239. package/src/stores/use-chat-store.ts +26 -6
  240. package/src/types/index.ts +6 -0
  241. /package/{bundled-skills → skills}/google-workspace/SKILL.md +0 -0
@@ -0,0 +1,2269 @@
1
+ import { log } from '@/lib/server/logger'
2
+ import { genId } from '@/lib/id'
3
+ import type {
4
+ ApprovalRequest,
5
+ BoardTask,
6
+ DelegationJobRecord,
7
+ MessageToolEvent,
8
+ Mission,
9
+ MissionEvent,
10
+ MissionPhase,
11
+ MissionSource,
12
+ MissionSourceRef,
13
+ MissionStatus,
14
+ MissionSummary,
15
+ MissionVerificationVerdict,
16
+ Schedule,
17
+ Session,
18
+ SessionQueuedTurn,
19
+ SessionRunRecord,
20
+ } from '@/types'
21
+ import { loadApprovals } from '@/lib/server/approvals/approval-repository'
22
+ import { loadDelegationJob } from '@/lib/server/agents/delegation-job-repository'
23
+ import { logActivity } from '@/lib/server/activity/activity-log'
24
+ import {
25
+ classifyMissionTurn,
26
+ planMissionTick,
27
+ verifyMissionOutcome,
28
+ type MissionOutcomeDecision,
29
+ type MissionPlannerDecisionResult,
30
+ type MissionTurnDecision,
31
+ } from '@/lib/server/missions/mission-intent'
32
+ import {
33
+ loadMission,
34
+ loadMissionEvents,
35
+ loadMissions,
36
+ patchMission,
37
+ upsertMission,
38
+ upsertMissionEvent,
39
+ } from '@/lib/server/missions/mission-repository'
40
+ import {
41
+ releaseRuntimeLock,
42
+ renewRuntimeLock,
43
+ tryAcquireRuntimeLock,
44
+ } from '@/lib/server/runtime/runtime-lock-repository'
45
+ import { upsertSchedule } from '@/lib/server/schedules/schedule-repository'
46
+ import { loadSettings } from '@/lib/server/settings/settings-repository'
47
+ import { loadSession, patchSession } from '@/lib/server/sessions/session-repository'
48
+ import { errorMessage, hmrSingleton } from '@/lib/shared-utils'
49
+ import { getSessionQueueSnapshot, listRuns } from '@/lib/server/runtime/session-run-manager'
50
+ import { loadTask, loadTasks, patchTask } from '@/lib/server/tasks/task-repository'
51
+ import { notify } from '@/lib/server/ws-hub'
52
+
53
+ const TAG = 'mission-service'
54
+
55
+ function now(): number {
56
+ return Date.now()
57
+ }
58
+
59
+ function cleanText(value: unknown, max = 320): string {
60
+ return typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : ''
61
+ }
62
+
63
+ function uniqueStrings(values: unknown, maxItems: number, maxChars = 180): string[] {
64
+ const source = Array.isArray(values) ? values : []
65
+ const out: string[] = []
66
+ const seen = new Set<string>()
67
+ for (const entry of source) {
68
+ const normalized = cleanText(entry, maxChars)
69
+ if (!normalized) continue
70
+ const key = normalized.toLowerCase()
71
+ if (seen.has(key)) continue
72
+ seen.add(key)
73
+ out.push(normalized)
74
+ if (out.length >= maxItems) break
75
+ }
76
+ return out
77
+ }
78
+
79
+ const MISSION_LEASE_TTL_MS = 15_000
80
+ const MISSION_LEASE_OWNER = `mission:${process.pid}:${genId(6)}`
81
+ const recoveryState = hmrSingleton('__swarmclaw_mission_controller_recovery__', () => ({ running: false }))
82
+
83
+ function areMissionHumanLoopWaitsEnabled(): boolean {
84
+ const settings = loadSettings() as { missionHumanLoopEnabled?: unknown }
85
+ return settings.missionHumanLoopEnabled === true
86
+ }
87
+
88
+ function shouldSuppressMissionHumanLoopWait(waitKind: unknown): boolean {
89
+ return waitKind === 'human_reply' && !areMissionHumanLoopWaitsEnabled()
90
+ }
91
+
92
+ function isMissionTerminal(status: MissionStatus): boolean {
93
+ return status === 'completed' || status === 'failed' || status === 'cancelled'
94
+ }
95
+
96
+ function missionLeaseName(missionId: string): string {
97
+ return `mission:${missionId}`
98
+ }
99
+
100
+ function listMissionIds(value: unknown, maxItems = 128): string[] {
101
+ return uniqueStrings(value, maxItems, 48)
102
+ }
103
+
104
+ function pickMissionPhase(value: unknown, fallback: MissionPhase = 'planning'): MissionPhase {
105
+ const phase = typeof value === 'string' ? value.trim().toLowerCase() : ''
106
+ if (phase === 'intake' || phase === 'planning' || phase === 'dispatching' || phase === 'executing' || phase === 'verifying' || phase === 'waiting' || phase === 'completed' || phase === 'failed') {
107
+ return phase
108
+ }
109
+ return fallback
110
+ }
111
+
112
+ function pickMissionWaitKind(value: unknown): NonNullable<Mission['waitState']>['kind'] {
113
+ const kind = typeof value === 'string' ? value.trim().toLowerCase() : ''
114
+ if (kind === 'human_reply' || kind === 'approval' || kind === 'external_dependency' || kind === 'provider' || kind === 'blocked_task' || kind === 'blocked_mission' || kind === 'scheduled') {
115
+ return kind
116
+ }
117
+ return 'other'
118
+ }
119
+
120
+ function normalizeMissionSourceRef(source: MissionSource, mission: Partial<Mission>): MissionSourceRef {
121
+ const sourceRef = mission.sourceRef
122
+ if (sourceRef && typeof sourceRef === 'object' && 'kind' in sourceRef) return sourceRef
123
+ if (source === 'schedule' && typeof (mission as { sourceScheduleId?: string | null }).sourceScheduleId === 'string') {
124
+ return {
125
+ kind: 'schedule',
126
+ scheduleId: (mission as { sourceScheduleId?: string | null }).sourceScheduleId || '',
127
+ recurring: true,
128
+ }
129
+ }
130
+ if ((source === 'chat' || source === 'connector' || source === 'heartbeat' || source === 'main-loop-followup') && typeof mission.sessionId === 'string' && mission.sessionId.trim()) {
131
+ return source === 'connector'
132
+ ? { kind: 'connector', sessionId: mission.sessionId.trim(), connectorId: '', channelId: '' }
133
+ : source === 'heartbeat'
134
+ ? { kind: 'heartbeat', sessionId: mission.sessionId.trim() }
135
+ : { kind: 'chat', sessionId: mission.sessionId.trim() }
136
+ }
137
+ if (source === 'task' && typeof mission.rootTaskId === 'string' && mission.rootTaskId.trim()) {
138
+ return { kind: 'task', taskId: mission.rootTaskId.trim() }
139
+ }
140
+ return { kind: 'manual' }
141
+ }
142
+
143
+ function normalizeMissionRecord(mission: Mission): Mission {
144
+ const rootMissionId = typeof mission.rootMissionId === 'string' && mission.rootMissionId.trim()
145
+ ? mission.rootMissionId.trim()
146
+ : mission.id
147
+ const parentMissionId = typeof mission.parentMissionId === 'string' && mission.parentMissionId.trim()
148
+ ? mission.parentMissionId.trim()
149
+ : null
150
+ const controllerState = mission.controllerState && typeof mission.controllerState === 'object'
151
+ ? { ...mission.controllerState }
152
+ : {}
153
+ const plannerState = mission.plannerState && typeof mission.plannerState === 'object'
154
+ ? { ...mission.plannerState }
155
+ : {}
156
+ const verificationState = mission.verificationState && typeof mission.verificationState === 'object'
157
+ ? { ...mission.verificationState }
158
+ : { candidate: false }
159
+ return {
160
+ ...mission,
161
+ phase: pickMissionPhase(mission.phase),
162
+ sourceRef: normalizeMissionSourceRef(mission.source, mission),
163
+ rootMissionId,
164
+ ...(parentMissionId ? { parentMissionId } : {}),
165
+ childMissionIds: listMissionIds(mission.childMissionIds, 256),
166
+ dependencyMissionIds: listMissionIds(mission.dependencyMissionIds, 256),
167
+ dependencyTaskIds: listMissionIds(mission.dependencyTaskIds, 256),
168
+ taskIds: listMissionIds(mission.taskIds, 256),
169
+ controllerState,
170
+ plannerState,
171
+ verificationState: {
172
+ candidate: verificationState.candidate === true,
173
+ requiredTaskIds: listMissionIds(verificationState.requiredTaskIds, 128),
174
+ requiredChildMissionIds: listMissionIds(verificationState.requiredChildMissionIds, 128),
175
+ requiredArtifacts: uniqueStrings(verificationState.requiredArtifacts, 128, 240),
176
+ evidenceSummary: cleanText(verificationState.evidenceSummary, 320) || null,
177
+ lastVerdict: ((): MissionVerificationVerdict | null => {
178
+ const verdict = typeof verificationState.lastVerdict === 'string' ? verificationState.lastVerdict.trim().toLowerCase() : ''
179
+ return verdict === 'continue' || verdict === 'waiting' || verdict === 'completed' || verdict === 'failed' || verdict === 'replan'
180
+ ? verdict
181
+ : null
182
+ })(),
183
+ lastVerifiedAt: typeof verificationState.lastVerifiedAt === 'number' ? verificationState.lastVerifiedAt : null,
184
+ },
185
+ waitState: mission.waitState
186
+ ? {
187
+ kind: pickMissionWaitKind(mission.waitState.kind),
188
+ reason: cleanText(mission.waitState.reason, 220) || 'Mission is waiting.',
189
+ approvalId: typeof mission.waitState.approvalId === 'string' ? mission.waitState.approvalId : null,
190
+ untilAt: typeof mission.waitState.untilAt === 'number' ? mission.waitState.untilAt : null,
191
+ dependencyTaskId: typeof mission.waitState.dependencyTaskId === 'string' ? mission.waitState.dependencyTaskId : null,
192
+ dependencyMissionId: typeof mission.waitState.dependencyMissionId === 'string' ? mission.waitState.dependencyMissionId : null,
193
+ providerKey: typeof mission.waitState.providerKey === 'string' ? mission.waitState.providerKey : null,
194
+ }
195
+ : null,
196
+ }
197
+ }
198
+
199
+ function missionSourceFromTask(task: BoardTask, fallback: MissionSource = 'manual'): MissionSource {
200
+ if (task.sourceType === 'schedule') return 'schedule'
201
+ if (task.sourceType === 'delegation') return 'delegation'
202
+ return fallback
203
+ }
204
+
205
+ export function loadMissionById(id: string | null | undefined): Mission | null {
206
+ const missionId = typeof id === 'string' ? id.trim() : ''
207
+ if (!missionId) return null
208
+ const mission = loadMission(missionId)
209
+ return mission ? normalizeMissionRecord(mission) : null
210
+ }
211
+
212
+ export function findLatestMissionForSession(sessionId: string): Mission | null {
213
+ const missions = Object.values(loadMissions())
214
+ .map((mission) => normalizeMissionRecord(mission))
215
+ .filter((mission) => mission.sessionId === sessionId)
216
+ .sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0))
217
+ const active = missions.find((mission) => !isMissionTerminal(mission.status))
218
+ return active || missions[0] || null
219
+ }
220
+
221
+ export function getMissionForSession(session: Session | null | undefined): Mission | null {
222
+ if (!session) return null
223
+ const byId = loadMissionById(session.missionId)
224
+ if (byId) return byId
225
+ return findLatestMissionForSession(session.id)
226
+ }
227
+
228
+ function listTaskSummaries(taskIds: string[] | undefined): Array<{
229
+ id: string
230
+ title: string
231
+ status: string
232
+ result?: string | null
233
+ error?: string | null
234
+ }> {
235
+ const tasks = loadTasks()
236
+ const source = Array.isArray(taskIds) ? taskIds : []
237
+ return source
238
+ .map((taskId) => tasks[taskId])
239
+ .filter((task): task is BoardTask => Boolean(task))
240
+ .map((task) => ({
241
+ id: task.id,
242
+ title: task.title,
243
+ status: task.status,
244
+ result: task.result || null,
245
+ error: task.error || null,
246
+ }))
247
+ }
248
+
249
+ export function buildMissionSummary(mission: Mission): MissionSummary {
250
+ const taskSummaries = listTaskSummaries(mission.taskIds)
251
+ const completedTaskCount = taskSummaries.filter((task) => task.status === 'completed').length
252
+ const openTaskCount = taskSummaries.filter((task) => !['completed', 'failed', 'cancelled', 'archived'].includes(task.status)).length
253
+ return {
254
+ id: mission.id,
255
+ objective: mission.objective,
256
+ status: mission.status,
257
+ phase: mission.phase,
258
+ source: mission.source,
259
+ currentStep: mission.currentStep || null,
260
+ waitingReason: mission.waitState?.reason || null,
261
+ sessionId: mission.sessionId || null,
262
+ agentId: mission.agentId || null,
263
+ projectId: mission.projectId || null,
264
+ parentMissionId: mission.parentMissionId || null,
265
+ rootMissionId: mission.rootMissionId || mission.id,
266
+ taskIds: Array.isArray(mission.taskIds) ? mission.taskIds : [],
267
+ openTaskCount,
268
+ completedTaskCount,
269
+ childCount: Array.isArray(mission.childMissionIds) ? mission.childMissionIds.length : 0,
270
+ sourceRef: mission.sourceRef,
271
+ updatedAt: mission.updatedAt,
272
+ }
273
+ }
274
+
275
+ export function enrichSessionWithMissionSummary<T extends Session>(session: T): T {
276
+ const mission = getMissionForSession(session)
277
+ if (!mission) return { ...session, missionSummary: null } as T
278
+ return {
279
+ ...session,
280
+ missionId: mission.id,
281
+ missionSummary: buildMissionSummary(mission),
282
+ } as T
283
+ }
284
+
285
+ export function enrichTaskWithMissionSummary<T extends BoardTask>(task: T): T {
286
+ const mission = loadMissionById(task.missionId)
287
+ if (!mission) return { ...task, missionSummary: null } as T
288
+ return {
289
+ ...task,
290
+ missionSummary: buildMissionSummary(mission),
291
+ } as T
292
+ }
293
+
294
+ export function listMissionEventsForMission(missionId: string, limit = 200): MissionEvent[] {
295
+ const safeLimit = Math.max(1, Math.min(2000, Math.trunc(limit)))
296
+ return Object.values(loadMissionEvents())
297
+ .filter((event) => event.missionId === missionId)
298
+ .sort((left, right) => left.createdAt - right.createdAt)
299
+ .slice(-safeLimit)
300
+ }
301
+
302
+ export function listMissions(options?: {
303
+ sessionId?: string | null
304
+ status?: MissionStatus | 'non_terminal'
305
+ phase?: MissionPhase | null
306
+ source?: MissionSource | null
307
+ agentId?: string | null
308
+ projectId?: string | null
309
+ parentMissionId?: string | null
310
+ limit?: number
311
+ }): Mission[] {
312
+ const missions = Object.values(loadMissions())
313
+ .map((mission) => normalizeMissionRecord(mission))
314
+ .filter((mission) => {
315
+ if (options?.sessionId && mission.sessionId !== options.sessionId) return false
316
+ if (!options?.status) return true
317
+ if (options.status === 'non_terminal') return !isMissionTerminal(mission.status)
318
+ return mission.status === options.status
319
+ })
320
+ .filter((mission) => !options?.phase || mission.phase === options.phase)
321
+ .filter((mission) => !options?.source || mission.source === options.source)
322
+ .filter((mission) => !options?.agentId || mission.agentId === options.agentId)
323
+ .filter((mission) => !options?.projectId || mission.projectId === options.projectId)
324
+ .filter((mission) => !options?.parentMissionId || mission.parentMissionId === options.parentMissionId)
325
+ .sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0))
326
+
327
+ const limit = typeof options?.limit === 'number'
328
+ ? Math.max(1, Math.min(500, Math.trunc(options.limit)))
329
+ : null
330
+ return limit ? missions.slice(0, limit) : missions
331
+ }
332
+
333
+ export function listChildMissions(parentMissionId: string, limit?: number): Mission[] {
334
+ const missions = listMissions({ parentMissionId })
335
+ if (typeof limit !== 'number') return missions
336
+ return missions.slice(0, Math.max(1, Math.trunc(limit)))
337
+ }
338
+
339
+ function listMissionApprovals(mission: Mission): ApprovalRequest[] {
340
+ const approvals = Object.values(loadApprovals()) as ApprovalRequest[]
341
+ return approvals
342
+ .filter((approval) =>
343
+ approval.id === mission.waitState?.approvalId
344
+ || (typeof approval.sessionId === 'string' && approval.sessionId === mission.sessionId)
345
+ )
346
+ .sort((left, right) => (right.createdAt || 0) - (left.createdAt || 0))
347
+ }
348
+
349
+ function listMissionQueuedTurns(mission: Mission): SessionQueuedTurn[] {
350
+ const queue = mission.sessionId ? getSessionQueueSnapshot(mission.sessionId) : null
351
+ if (!queue) return []
352
+ return queue.items.filter((item) => item.missionId === mission.id)
353
+ }
354
+
355
+ function listMissionRuns(mission: Mission, limit = 20): SessionRunRecord[] {
356
+ return listRuns({ limit: Math.max(20, limit * 4) })
357
+ .filter((run) => run.missionId === mission.id)
358
+ .slice(0, limit)
359
+ }
360
+
361
+ function listRecentMissionEvents(missionId: string, limit = 12): MissionEvent[] {
362
+ return listMissionEventsForMission(missionId, limit)
363
+ }
364
+
365
+ function hasTerminalMissionEvidence(mission: Mission): boolean {
366
+ const requiredTaskIds = mission.verificationState?.requiredTaskIds || mission.taskIds || []
367
+ const requiredChildMissionIds = mission.verificationState?.requiredChildMissionIds || mission.childMissionIds || []
368
+ const tasks = loadTasks()
369
+ const requiredTasksSatisfied = requiredTaskIds.every((taskId) => {
370
+ const task = tasks[taskId]
371
+ return Boolean(task && task.status === 'completed')
372
+ })
373
+ const requiredChildrenSatisfied = requiredChildMissionIds.every((childId) => {
374
+ const child = loadMissionById(childId)
375
+ return Boolean(child && child.status === 'completed')
376
+ })
377
+ return requiredTasksSatisfied && requiredChildrenSatisfied
378
+ }
379
+
380
+ function missionNeedsStartupRecovery(mission: Mission): boolean {
381
+ if (isMissionTerminal(mission.status)) return false
382
+ if (mission.status === 'waiting') return false
383
+ return mission.phase === 'dispatching' || mission.phase === 'executing' || mission.phase === 'verifying'
384
+ }
385
+
386
+ function recoverMissionOnStartup(mission: Mission): { mission: Mission | null; rerunVerification: boolean } {
387
+ const reconciled = reconcileMissionState(mission)
388
+ if (!missionNeedsStartupRecovery(reconciled)) return { mission: loadMissionById(reconciled.id) || reconciled, rerunVerification: false }
389
+ const hasLiveExecution = missionHasActiveTask(reconciled) || missionHasActiveRun(reconciled) || missionHasActiveChild(reconciled)
390
+ if (hasLiveExecution) return { mission: loadMissionById(reconciled.id) || reconciled, rerunVerification: false }
391
+ if (reconciled.phase === 'verifying' && hasTerminalMissionEvidence(reconciled)) {
392
+ const updated = patchMissionStatus(reconciled.id, (current) => ({
393
+ ...current,
394
+ status: 'active',
395
+ phase: 'verifying',
396
+ controllerState: {
397
+ ...(current.controllerState || {}),
398
+ activeRunId: null,
399
+ currentTaskId: null,
400
+ currentChildMissionId: null,
401
+ tickRequestedAt: now(),
402
+ tickReason: 'restart_recovery',
403
+ },
404
+ }))
405
+ if (updated) {
406
+ appendMissionEvent({
407
+ missionId: updated.id,
408
+ type: 'interrupted',
409
+ source: 'system',
410
+ summary: 'Mission verification recovered after restart.',
411
+ sessionId: updated.sessionId || null,
412
+ runId: updated.lastRunId || null,
413
+ data: { phase: mission.phase, recoveredPhase: 'verifying' },
414
+ })
415
+ }
416
+ return { mission: updated, rerunVerification: Boolean(updated) }
417
+ }
418
+
419
+ const updated = patchMissionStatus(reconciled.id, (current) => ({
420
+ ...clearMissionExecutionPointers(current),
421
+ status: 'active',
422
+ phase: 'planning',
423
+ waitState: null,
424
+ controllerState: {
425
+ ...(current.controllerState || {}),
426
+ tickRequestedAt: now(),
427
+ tickReason: 'restart_recovery',
428
+ },
429
+ }))
430
+ if (updated) {
431
+ appendMissionEvent({
432
+ missionId: updated.id,
433
+ type: 'interrupted',
434
+ source: 'system',
435
+ summary: 'Mission execution was interrupted and returned to planning.',
436
+ sessionId: updated.sessionId || null,
437
+ runId: updated.lastRunId || null,
438
+ data: { phase: mission.phase, recoveredPhase: 'planning' },
439
+ })
440
+ }
441
+ return { mission: updated, rerunVerification: Boolean(updated) }
442
+ }
443
+
444
+ export function runMissionControllerStartupRecovery(): { recovered: number; rerunVerification: number } {
445
+ if (recoveryState.running) return { recovered: 0, rerunVerification: 0 }
446
+ recoveryState.running = true
447
+ const rerunTickIds = new Set<string>()
448
+ let recoveredCount = 0
449
+ try {
450
+ for (const mission of Object.values(loadMissions()).map((entry) => normalizeMissionRecord(entry))) {
451
+ if (isMissionTerminal(mission.status)) continue
452
+ const recovered = recoverMissionOnStartup(mission)
453
+ if (recovered.mission && (
454
+ recovered.mission.status !== mission.status
455
+ || recovered.mission.phase !== mission.phase
456
+ || recovered.rerunVerification
457
+ )) {
458
+ recoveredCount++
459
+ }
460
+ if (recovered.rerunVerification && recovered.mission?.id) rerunTickIds.add(recovered.mission.id)
461
+ }
462
+ } finally {
463
+ recoveryState.running = false
464
+ }
465
+ for (const missionId of rerunTickIds) {
466
+ queueMicrotask(() => {
467
+ requestMissionTick(missionId, 'restart_recovery', { recovered: true })
468
+ })
469
+ }
470
+ return { recovered: recoveredCount, rerunVerification: rerunTickIds.size }
471
+ }
472
+
473
+ export function getMissionDetail(missionId: string): {
474
+ mission: Mission
475
+ summary: MissionSummary
476
+ parent: MissionSummary | null
477
+ children: MissionSummary[]
478
+ linkedTasks: BoardTask[]
479
+ recentRuns: SessionRunRecord[]
480
+ queuedTurns: SessionQueuedTurn[]
481
+ approvals: ApprovalRequest[]
482
+ events: MissionEvent[]
483
+ } | null {
484
+ const mission = loadMissionById(missionId)
485
+ if (!mission) return null
486
+ const tasks = loadTasks()
487
+ const parentMission = mission.parentMissionId ? loadMissionById(mission.parentMissionId) : null
488
+ return {
489
+ mission,
490
+ summary: buildMissionSummary(mission),
491
+ parent: parentMission ? buildMissionSummary(parentMission) : null,
492
+ children: listChildMissions(mission.id).map((child) => buildMissionSummary(child)),
493
+ linkedTasks: (mission.taskIds || [])
494
+ .map((taskId) => tasks[taskId])
495
+ .filter((task): task is BoardTask => Boolean(task))
496
+ .map((task) => enrichTaskWithMissionSummary(task)),
497
+ recentRuns: listMissionRuns(mission),
498
+ queuedTurns: listMissionQueuedTurns(mission),
499
+ approvals: listMissionApprovals(mission),
500
+ events: listMissionEventsForMission(mission.id, 80),
501
+ }
502
+ }
503
+
504
+ export function appendMissionEvent(input: Omit<MissionEvent, 'id' | 'createdAt'> & { createdAt?: number }): MissionEvent {
505
+ const event: MissionEvent = {
506
+ id: genId(12),
507
+ createdAt: typeof input.createdAt === 'number' ? input.createdAt : now(),
508
+ ...input,
509
+ }
510
+ upsertMissionEvent(event.id, event)
511
+ notify('missions')
512
+ return event
513
+ }
514
+
515
+ function ensureMissionTaskLink(mission: Mission, taskId: string): Mission {
516
+ const taskIds = uniqueStrings([...(mission.taskIds || []), taskId], 128, 48)
517
+ return {
518
+ ...mission,
519
+ taskIds,
520
+ rootTaskId: mission.rootTaskId || taskId,
521
+ verificationState: {
522
+ candidate: mission.verificationState?.candidate === true,
523
+ requiredTaskIds: uniqueStrings([...(mission.verificationState?.requiredTaskIds || []), taskId], 128, 48),
524
+ requiredChildMissionIds: listMissionIds(mission.verificationState?.requiredChildMissionIds, 128),
525
+ requiredArtifacts: uniqueStrings(mission.verificationState?.requiredArtifacts, 128, 240),
526
+ evidenceSummary: mission.verificationState?.evidenceSummary || null,
527
+ lastVerdict: mission.verificationState?.lastVerdict || null,
528
+ lastVerifiedAt: mission.verificationState?.lastVerifiedAt || null,
529
+ },
530
+ }
531
+ }
532
+
533
+ function patchMissionStatus(
534
+ missionId: string,
535
+ updater: (mission: Mission) => Mission,
536
+ ): Mission | null {
537
+ const updated = patchMission(missionId, (current) => {
538
+ if (!current) return current
539
+ return normalizeMissionRecord({
540
+ ...updater(current),
541
+ updatedAt: now(),
542
+ lastActiveAt: now(),
543
+ })
544
+ })
545
+ if (updated) notify('missions')
546
+ return updated ? normalizeMissionRecord(updated) : null
547
+ }
548
+
549
+ export function acquireMissionLease(missionId: string, ttlMs = MISSION_LEASE_TTL_MS): (() => void) | null {
550
+ if (!tryAcquireRuntimeLock(missionLeaseName(missionId), MISSION_LEASE_OWNER, ttlMs)) return null
551
+ let released = false
552
+ return () => {
553
+ if (released) return
554
+ released = true
555
+ releaseRuntimeLock(missionLeaseName(missionId), MISSION_LEASE_OWNER)
556
+ }
557
+ }
558
+
559
+ export function renewMissionLease(missionId: string, ttlMs = MISSION_LEASE_TTL_MS): boolean {
560
+ return renewRuntimeLock(missionLeaseName(missionId), MISSION_LEASE_OWNER, ttlMs)
561
+ }
562
+
563
+ function missionHasActiveTask(mission: Mission): boolean {
564
+ const taskId = mission.controllerState?.currentTaskId
565
+ if (!taskId) return false
566
+ const task = loadTask(taskId)
567
+ return Boolean(task && (task.status === 'queued' || task.status === 'running'))
568
+ }
569
+
570
+ function missionHasActiveRun(mission: Mission): boolean {
571
+ const runId = mission.controllerState?.activeRunId || mission.lastRunId
572
+ if (!runId) return false
573
+ const runs = listMissionRuns(mission, 50)
574
+ return runs.some((run) => run.id === runId && (run.status === 'queued' || run.status === 'running'))
575
+ }
576
+
577
+ function missionHasActiveChild(mission: Mission): boolean {
578
+ const currentChildMissionId = mission.controllerState?.currentChildMissionId
579
+ if (currentChildMissionId) {
580
+ const child = loadMissionById(currentChildMissionId)
581
+ if (child && !isMissionTerminal(child.status)) return true
582
+ }
583
+ return (mission.childMissionIds || []).some((childId) => {
584
+ const child = loadMissionById(childId)
585
+ return Boolean(child && !isMissionTerminal(child.status))
586
+ })
587
+ }
588
+
589
+ function isWaitSatisfied(mission: Mission): boolean {
590
+ const waitState = mission.waitState
591
+ if (!waitState) return true
592
+ if (waitState.approvalId) {
593
+ const approval = listMissionApprovals(mission).find((entry) => entry.id === waitState.approvalId)
594
+ if (!approval || approval.status === 'pending') return false
595
+ }
596
+ if (waitState.untilAt && waitState.untilAt > now()) return false
597
+ if (waitState.dependencyTaskId) {
598
+ const task = loadTask(waitState.dependencyTaskId)
599
+ if (!task || !['completed', 'failed', 'cancelled', 'archived'].includes(task.status)) return false
600
+ }
601
+ if (waitState.dependencyMissionId) {
602
+ const child = loadMissionById(waitState.dependencyMissionId)
603
+ if (!child || !isMissionTerminal(child.status)) return false
604
+ }
605
+ return true
606
+ }
607
+
608
+ function clearMissionExecutionPointers(mission: Mission): Mission {
609
+ return {
610
+ ...mission,
611
+ controllerState: {
612
+ ...(mission.controllerState || {}),
613
+ activeRunId: null,
614
+ currentTaskId: null,
615
+ currentChildMissionId: null,
616
+ },
617
+ }
618
+ }
619
+
620
+ function maybePromoteChildOutcome(mission: Mission): Mission {
621
+ const childIds = mission.childMissionIds || []
622
+ if (!childIds.length) return mission
623
+ const children = childIds.map((childId) => loadMissionById(childId)).filter((child): child is Mission => Boolean(child))
624
+ const activeChild = children.find((child) => !isMissionTerminal(child.status))
625
+ if (activeChild) {
626
+ return {
627
+ ...mission,
628
+ status: 'waiting',
629
+ phase: 'waiting',
630
+ waitState: {
631
+ kind: 'blocked_mission',
632
+ reason: activeChild.waitState?.reason || `Waiting on child mission: ${activeChild.objective}`,
633
+ dependencyMissionId: activeChild.id,
634
+ },
635
+ controllerState: {
636
+ ...(mission.controllerState || {}),
637
+ currentChildMissionId: activeChild.id,
638
+ },
639
+ }
640
+ }
641
+ const failedChild = children.find((child) => child.status === 'failed')
642
+ if (failedChild) {
643
+ return {
644
+ ...mission,
645
+ status: 'waiting',
646
+ phase: 'waiting',
647
+ blockerSummary: failedChild.blockerSummary || failedChild.verifierSummary || `Child mission failed: ${failedChild.objective}`,
648
+ waitState: {
649
+ kind: 'blocked_mission',
650
+ reason: failedChild.blockerSummary || failedChild.verifierSummary || `Child mission failed: ${failedChild.objective}`,
651
+ dependencyMissionId: failedChild.id,
652
+ },
653
+ }
654
+ }
655
+ return mission
656
+ }
657
+
658
+ function reconcileMissionState(mission: Mission): Mission {
659
+ let next = normalizeMissionRecord(mission)
660
+ next = maybePromoteChildOutcome(next)
661
+ if (!missionHasActiveTask(next) && !missionHasActiveRun(next) && !missionHasActiveChild(next)) {
662
+ next = clearMissionExecutionPointers(next)
663
+ }
664
+ if (next.status === 'waiting' && isWaitSatisfied(next)) {
665
+ next = {
666
+ ...next,
667
+ status: 'active',
668
+ phase: 'planning',
669
+ waitState: null,
670
+ blockerSummary: null,
671
+ }
672
+ }
673
+ return next
674
+ }
675
+
676
+ function isAutoMissionSource(source: MissionSource): boolean {
677
+ return source === 'schedule' || source === 'heartbeat' || source === 'main-loop-followup' || source === 'delegation'
678
+ }
679
+
680
+ function buildMissionFollowupMessage(mission: Mission): string {
681
+ return [
682
+ 'MISSION_CONTROLLER_TICK',
683
+ buildMissionContextBlock(mission),
684
+ 'Take the single highest-value next step for this mission.',
685
+ 'If the mission is blocked on a real dependency, say so plainly.',
686
+ 'If the mission is complete, explain the actual completed outcome instead of promising future work.',
687
+ ].filter(Boolean).join('\n\n')
688
+ }
689
+
690
+ function plannerDecisionSummary(
691
+ decision: MissionPlannerDecisionResult,
692
+ mission: Mission,
693
+ ): string {
694
+ const explicit = cleanText((decision as { summary?: string | null }).summary, 360)
695
+ if (explicit) return explicit
696
+ if (decision.decision === 'dispatch_task') return `Queue linked task ${decision.taskId}.`
697
+ if (decision.decision === 'dispatch_session_turn') return 'Queue a mission follow-up turn.'
698
+ if (decision.decision === 'spawn_child_mission') return `Create child mission: ${decision.childObjective}`
699
+ if (decision.decision === 'wait') return cleanText(decision.waitReason, 220) || 'Mission is waiting.'
700
+ if (decision.decision === 'verify_now') return 'Verify mission completion from current durable evidence.'
701
+ if (decision.decision === 'complete_candidate') return `Mission looks complete and should enter verification: ${mission.objective}`
702
+ if (decision.decision === 'fail_terminal') return `Mission failed: ${mission.objective}`
703
+ return 'Mission replanned.'
704
+ }
705
+
706
+ function areMissionDependenciesSatisfied(mission: Mission): { satisfied: boolean; blockerSummary: string | null } {
707
+ const depMissionIds = Array.isArray(mission.dependencyMissionIds) ? mission.dependencyMissionIds : []
708
+ for (const depId of depMissionIds) {
709
+ const dep = loadMissionById(depId)
710
+ if (!dep || !isMissionTerminal(dep.status) || dep.status !== 'completed') {
711
+ return { satisfied: false, blockerSummary: `Blocked by mission: ${dep?.objective || depId} (${dep?.status || 'not found'})` }
712
+ }
713
+ }
714
+ const depTaskIds = Array.isArray(mission.dependencyTaskIds) ? mission.dependencyTaskIds : []
715
+ for (const depId of depTaskIds) {
716
+ const dep = loadTask(depId)
717
+ if (!dep || dep.status !== 'completed') {
718
+ return { satisfied: false, blockerSummary: `Blocked by task: ${dep?.title || depId} (${dep?.status || 'not found'})` }
719
+ }
720
+ }
721
+ return { satisfied: true, blockerSummary: null }
722
+ }
723
+
724
+ function deterministicPlannerDecision(mission: Mission): MissionPlannerDecisionResult | null {
725
+ // Check external dependencies (dependencyMissionIds / dependencyTaskIds)
726
+ const depCheck = areMissionDependenciesSatisfied(mission)
727
+ if (!depCheck.satisfied) {
728
+ return {
729
+ decision: 'wait',
730
+ confidence: 1,
731
+ summary: depCheck.blockerSummary || 'Blocked by unsatisfied dependency.',
732
+ waitKind: 'blocked_mission',
733
+ waitReason: depCheck.blockerSummary || 'Blocked by unsatisfied dependency.',
734
+ }
735
+ }
736
+
737
+ const tasks = listTaskSummaries(mission.taskIds)
738
+ const failedTask = tasks.find((task) => task.status === 'failed')
739
+ if (failedTask) {
740
+ return {
741
+ decision: 'wait',
742
+ confidence: 1,
743
+ summary: failedTask.error || `Waiting on failed task: ${failedTask.title}`,
744
+ waitKind: 'blocked_task',
745
+ waitReason: failedTask.error || `Waiting on failed task: ${failedTask.title}`,
746
+ }
747
+ }
748
+
749
+ const nonTerminalChild = (mission.childMissionIds || [])
750
+ .map((childId) => loadMissionById(childId))
751
+ .find((child): child is Mission => Boolean(child && !isMissionTerminal(child.status)))
752
+ if (nonTerminalChild) {
753
+ return {
754
+ decision: 'wait',
755
+ confidence: 1,
756
+ summary: nonTerminalChild.waitState?.reason || `Waiting on child mission: ${nonTerminalChild.objective}`,
757
+ waitKind: 'blocked_mission',
758
+ waitReason: nonTerminalChild.waitState?.reason || `Waiting on child mission: ${nonTerminalChild.objective}`,
759
+ }
760
+ }
761
+
762
+ const completedTasks = tasks.filter((task) => task.status === 'completed')
763
+ const hasTerminalTaskSet = tasks.length > 0 && completedTasks.length === tasks.length
764
+ const requiredArtifacts = mission.verificationState?.requiredArtifacts || []
765
+ if (hasTerminalTaskSet && requiredArtifacts.length === 0) {
766
+ return {
767
+ decision: 'verify_now',
768
+ confidence: 1,
769
+ summary: 'All required linked tasks are complete.',
770
+ }
771
+ }
772
+
773
+ return null
774
+ }
775
+
776
+ async function planMissionAction(
777
+ mission: Mission,
778
+ options?: { generateText?: (prompt: string) => Promise<string> },
779
+ ): Promise<MissionPlannerDecisionResult> {
780
+ const deterministic = deterministicPlannerDecision(mission)
781
+ if (deterministic) return deterministic
782
+
783
+ const taskSummaries = listTaskSummaries(mission.taskIds)
784
+ const childMissionSummaries = listChildMissions(mission.id, 8).map((child) => buildMissionSummary(child))
785
+ const queuedTurns = listMissionQueuedTurns(mission)
786
+ const recentRuns = listMissionRuns(mission, 8).map((run) => ({
787
+ id: run.id,
788
+ status: run.status,
789
+ source: run.source,
790
+ queuedAt: run.queuedAt,
791
+ messagePreview: run.messagePreview,
792
+ resultPreview: run.resultPreview,
793
+ error: run.error,
794
+ }))
795
+ const recentEvents = listRecentMissionEvents(mission.id, 10).map((event) => ({
796
+ type: event.type,
797
+ summary: event.summary,
798
+ createdAt: event.createdAt,
799
+ }))
800
+
801
+ const planned = await planMissionTick({
802
+ sessionId: mission.sessionId || mission.id,
803
+ agentId: mission.agentId || null,
804
+ mission,
805
+ linkedTaskSummaries: taskSummaries,
806
+ childMissionSummaries,
807
+ recentRuns,
808
+ queuedTurns,
809
+ recentEvents,
810
+ }, options)
811
+
812
+ if (planned) return planned
813
+
814
+ if (isAutoMissionSource(mission.source) && mission.sessionId) {
815
+ return {
816
+ decision: 'dispatch_session_turn',
817
+ confidence: 0,
818
+ summary: 'Queue a mission follow-up turn using the durable mission context.',
819
+ sessionMessage: buildMissionFollowupMessage(mission),
820
+ ...(mission.currentStep ? { currentStep: mission.currentStep } : {}),
821
+ }
822
+ }
823
+
824
+ return {
825
+ decision: 'replan',
826
+ confidence: 0,
827
+ summary: 'Mission remains active and is waiting for the next concrete planner decision.',
828
+ ...(mission.currentStep ? { currentStep: mission.currentStep } : {}),
829
+ }
830
+ }
831
+
832
+ function applyMissionPlannerPolicies(
833
+ mission: Mission,
834
+ decision: MissionPlannerDecisionResult,
835
+ ): MissionPlannerDecisionResult {
836
+ if (decision.decision !== 'wait' || !shouldSuppressMissionHumanLoopWait(decision.waitKind)) return decision
837
+ const currentStep = decision.currentStep || mission.currentStep || undefined
838
+ if (hasTerminalMissionEvidence(mission) || ((mission.taskIds?.length || 0) === 0 && (mission.childMissionIds?.length || 0) === 0)) {
839
+ return {
840
+ decision: 'verify_now',
841
+ confidence: decision.confidence,
842
+ summary: 'Mission human-loop waits are disabled, so the mission will close instead of waiting for another reply.',
843
+ ...(currentStep ? { currentStep } : {}),
844
+ }
845
+ }
846
+ return {
847
+ decision: 'replan',
848
+ confidence: decision.confidence,
849
+ summary: 'Mission human-loop waits are disabled, so the mission stays active instead of pausing for another reply.',
850
+ ...(currentStep ? { currentStep } : {}),
851
+ }
852
+ }
853
+
854
+ async function executeMissionPlannerDecision(
855
+ mission: Mission,
856
+ decision: MissionPlannerDecisionResult,
857
+ trigger: string,
858
+ ): Promise<Mission | null> {
859
+ const summary = plannerDecisionSummary(decision, mission)
860
+ const basePatch = (updater: (current: Mission) => Mission) => patchMissionStatus(mission.id, (current) => ({
861
+ ...updater(current),
862
+ plannerState: {
863
+ ...(current.plannerState || {}),
864
+ lastDecision: decision.decision,
865
+ lastPlannedAt: now(),
866
+ planSummary: summary,
867
+ },
868
+ controllerState: {
869
+ ...(current.controllerState || {}),
870
+ tickRequestedAt: now(),
871
+ tickReason: trigger,
872
+ },
873
+ }))
874
+
875
+ appendMissionEvent({
876
+ missionId: mission.id,
877
+ type: 'planner_decision',
878
+ source: 'system',
879
+ summary,
880
+ sessionId: mission.sessionId || null,
881
+ runId: mission.lastRunId || null,
882
+ data: {
883
+ decision: decision.decision,
884
+ trigger,
885
+ },
886
+ })
887
+
888
+ if (decision.decision === 'wait') {
889
+ const waitReason = cleanText(decision.waitReason, 220) || summary
890
+ const updated = basePatch((current) => ({
891
+ ...current,
892
+ status: 'waiting',
893
+ phase: 'waiting',
894
+ waitState: {
895
+ kind: decision.waitKind || 'other',
896
+ reason: waitReason,
897
+ },
898
+ blockerSummary: waitReason,
899
+ currentStep: decision.currentStep || current.currentStep || null,
900
+ }))
901
+ if (updated) {
902
+ appendMissionEvent({
903
+ missionId: updated.id,
904
+ type: 'waiting',
905
+ source: 'system',
906
+ summary: waitReason,
907
+ sessionId: updated.sessionId || null,
908
+ runId: updated.lastRunId || null,
909
+ data: updated.waitState ? updated.waitState as unknown as Record<string, unknown> : null,
910
+ })
911
+ }
912
+ return updated
913
+ }
914
+
915
+ if (decision.decision === 'complete_candidate') {
916
+ return basePatch((current) => ({
917
+ ...current,
918
+ status: 'active',
919
+ phase: 'verifying',
920
+ currentStep: decision.currentStep || current.currentStep || null,
921
+ verificationState: {
922
+ ...(current.verificationState || { candidate: false }),
923
+ candidate: true,
924
+ evidenceSummary: summary,
925
+ },
926
+ }))
927
+ }
928
+
929
+ if (decision.decision === 'verify_now') {
930
+ const updated = basePatch((current) => ({
931
+ ...current,
932
+ status: 'completed',
933
+ phase: 'completed',
934
+ waitState: null,
935
+ blockerSummary: null,
936
+ verifierSummary: current.verifierSummary || summary,
937
+ currentStep: decision.currentStep || current.currentStep || null,
938
+ verificationState: {
939
+ ...(current.verificationState || { candidate: false }),
940
+ candidate: true,
941
+ evidenceSummary: summary,
942
+ lastVerdict: 'completed',
943
+ lastVerifiedAt: now(),
944
+ },
945
+ completedAt: current.completedAt || now(),
946
+ }))
947
+ if (updated) {
948
+ appendMissionEvent({
949
+ missionId: updated.id,
950
+ type: 'verifier_decision',
951
+ source: 'system',
952
+ summary,
953
+ sessionId: updated.sessionId || null,
954
+ runId: updated.lastRunId || null,
955
+ data: { verdict: 'completed' },
956
+ })
957
+ appendMissionEvent({
958
+ missionId: updated.id,
959
+ type: 'completed',
960
+ source: 'system',
961
+ summary: updated.verifierSummary || summary,
962
+ sessionId: updated.sessionId || null,
963
+ runId: updated.lastRunId || null,
964
+ data: { status: updated.status },
965
+ })
966
+ if (updated.parentMissionId) noteParentMissionChildOutcome(updated)
967
+ }
968
+ return updated
969
+ }
970
+
971
+ if (decision.decision === 'dispatch_task') {
972
+ const { enqueueTask } = await import('@/lib/server/runtime/queue')
973
+ const task = loadTask(decision.taskId)
974
+ if (!task) {
975
+ return basePatch((current) => ({
976
+ ...current,
977
+ status: 'waiting',
978
+ phase: 'waiting',
979
+ waitState: {
980
+ kind: 'blocked_task',
981
+ reason: `Linked task ${decision.taskId} was not found.`,
982
+ },
983
+ blockerSummary: `Linked task ${decision.taskId} was not found.`,
984
+ }))
985
+ }
986
+ enqueueTask(decision.taskId)
987
+ const updated = basePatch((current) => ({
988
+ ...current,
989
+ status: 'active',
990
+ phase: 'dispatching',
991
+ currentStep: decision.currentStep || current.currentStep || task.title || null,
992
+ controllerState: {
993
+ ...(current.controllerState || {}),
994
+ currentTaskId: decision.taskId,
995
+ tickRequestedAt: now(),
996
+ tickReason: trigger,
997
+ },
998
+ }))
999
+ if (updated) {
1000
+ appendMissionEvent({
1001
+ missionId: updated.id,
1002
+ type: 'dispatch_started',
1003
+ source: 'system',
1004
+ summary,
1005
+ sessionId: updated.sessionId || null,
1006
+ taskId: decision.taskId,
1007
+ runId: updated.lastRunId || null,
1008
+ data: { taskId: decision.taskId },
1009
+ })
1010
+ }
1011
+ return updated
1012
+ }
1013
+
1014
+ if (decision.decision === 'dispatch_session_turn') {
1015
+ if (!mission.sessionId) {
1016
+ return basePatch((current) => ({
1017
+ ...current,
1018
+ status: 'waiting',
1019
+ phase: 'waiting',
1020
+ waitState: {
1021
+ kind: 'external_dependency',
1022
+ reason: 'Mission follow-up needs a linked session before it can continue.',
1023
+ },
1024
+ blockerSummary: 'Mission follow-up needs a linked session before it can continue.',
1025
+ }))
1026
+ }
1027
+ const { enqueueSessionRun } = await import('@/lib/server/runtime/session-run-manager')
1028
+ const queued = enqueueSessionRun({
1029
+ sessionId: mission.sessionId || '',
1030
+ missionId: mission.id,
1031
+ message: decision.sessionMessage,
1032
+ internal: true,
1033
+ source: 'main-loop-followup',
1034
+ mode: 'followup',
1035
+ dedupeKey: `mission-tick:${mission.id}`,
1036
+ })
1037
+ const updated = basePatch((current) => ({
1038
+ ...current,
1039
+ status: 'active',
1040
+ phase: 'dispatching',
1041
+ currentStep: decision.currentStep || current.currentStep || null,
1042
+ controllerState: {
1043
+ ...(current.controllerState || {}),
1044
+ activeRunId: queued.runId,
1045
+ tickRequestedAt: now(),
1046
+ tickReason: trigger,
1047
+ },
1048
+ }))
1049
+ if (updated) {
1050
+ appendMissionEvent({
1051
+ missionId: updated.id,
1052
+ type: 'dispatch_started',
1053
+ source: 'system',
1054
+ summary,
1055
+ sessionId: updated.sessionId || null,
1056
+ runId: queued.runId,
1057
+ data: { queuedRunId: queued.runId },
1058
+ })
1059
+ }
1060
+ return updated
1061
+ }
1062
+
1063
+ if (decision.decision === 'spawn_child_mission') {
1064
+ const childMission = createMission({
1065
+ source: mission.source === 'delegation' ? 'delegation' : 'manual',
1066
+ sourceRef: mission.source === 'delegation'
1067
+ ? { kind: 'delegation', parentMissionId: mission.id, backend: 'agent' }
1068
+ : { kind: 'manual' },
1069
+ objective: decision.childObjective,
1070
+ successCriteria: decision.childSuccessCriteria,
1071
+ currentStep: decision.childCurrentStep || decision.currentStep || null,
1072
+ plannerSummary: decision.childPlannerSummary || summary,
1073
+ sessionId: mission.sessionId || null,
1074
+ agentId: mission.agentId || null,
1075
+ projectId: mission.projectId || null,
1076
+ parentMissionId: mission.id,
1077
+ sourceMessage: decision.childPlannerSummary || decision.childObjective,
1078
+ })
1079
+ const updated = basePatch((current) => ({
1080
+ ...current,
1081
+ status: 'waiting',
1082
+ phase: 'waiting',
1083
+ currentStep: decision.currentStep || current.currentStep || null,
1084
+ waitState: {
1085
+ kind: 'blocked_mission',
1086
+ reason: `Waiting on child mission: ${childMission.objective}`,
1087
+ dependencyMissionId: childMission.id,
1088
+ },
1089
+ controllerState: {
1090
+ ...(current.controllerState || {}),
1091
+ currentChildMissionId: childMission.id,
1092
+ tickRequestedAt: now(),
1093
+ tickReason: trigger,
1094
+ },
1095
+ }))
1096
+ if (updated) {
1097
+ requestMissionTick(childMission.id, 'child_created', { parentMissionId: mission.id })
1098
+ }
1099
+ return updated
1100
+ }
1101
+
1102
+ if (decision.decision === 'fail_terminal') {
1103
+ const updated = basePatch((current) => ({
1104
+ ...current,
1105
+ status: 'failed',
1106
+ phase: 'failed',
1107
+ blockerSummary: summary,
1108
+ verifierSummary: summary,
1109
+ failedAt: current.failedAt || now(),
1110
+ }))
1111
+ if (updated) {
1112
+ appendMissionEvent({
1113
+ missionId: updated.id,
1114
+ type: 'failed',
1115
+ source: 'system',
1116
+ summary,
1117
+ sessionId: updated.sessionId || null,
1118
+ runId: updated.lastRunId || null,
1119
+ data: { status: updated.status },
1120
+ })
1121
+ if (updated.parentMissionId) noteParentMissionChildOutcome(updated)
1122
+ }
1123
+ return updated
1124
+ }
1125
+
1126
+ return basePatch((current) => ({
1127
+ ...current,
1128
+ status: 'active',
1129
+ phase: 'planning',
1130
+ currentStep: decision.currentStep || current.currentStep || null,
1131
+ }))
1132
+ }
1133
+
1134
+ export function requestMissionTick(
1135
+ missionId: string,
1136
+ trigger: string,
1137
+ data?: Record<string, unknown> | null,
1138
+ ): Mission | null {
1139
+ const mission = patchMissionStatus(missionId, (current) => ({
1140
+ ...reconcileMissionState(current),
1141
+ controllerState: {
1142
+ ...(current.controllerState || {}),
1143
+ tickRequestedAt: now(),
1144
+ tickReason: trigger,
1145
+ },
1146
+ }))
1147
+ if (!mission) return null
1148
+ appendMissionEvent({
1149
+ missionId,
1150
+ type: 'source_triggered',
1151
+ source: 'system',
1152
+ summary: `Mission tick requested: ${trigger}`,
1153
+ sessionId: mission.sessionId || null,
1154
+ runId: mission.lastRunId || null,
1155
+ data: data || null,
1156
+ })
1157
+ queueMicrotask(() => {
1158
+ void runMissionTick(missionId, trigger).catch((err: unknown) => {
1159
+ log.warn(TAG, `mission tick failed for ${missionId}: ${errorMessage(err)}`)
1160
+ })
1161
+ })
1162
+ return mission
1163
+ }
1164
+
1165
+ export function requestMissionTicksForApprovalDecision(params: {
1166
+ approvalId: string
1167
+ status: 'approved' | 'rejected'
1168
+ sessionId?: string | null
1169
+ }): Mission[] {
1170
+ const candidates = listMissions({ status: 'non_terminal' }).filter((mission) => (
1171
+ mission.waitState?.kind === 'approval'
1172
+ && (
1173
+ mission.waitState?.approvalId === params.approvalId
1174
+ || (params.sessionId && mission.sessionId === params.sessionId)
1175
+ )
1176
+ ))
1177
+ return candidates
1178
+ .map((mission) => requestMissionTick(mission.id, 'approval_resolved', {
1179
+ approvalId: params.approvalId,
1180
+ status: params.status,
1181
+ }))
1182
+ .filter((mission): mission is Mission => Boolean(mission))
1183
+ }
1184
+
1185
+ export function requestMissionTicksForHumanReply(params: {
1186
+ sessionId: string
1187
+ correlationId?: string | null
1188
+ envelopeId?: string | null
1189
+ payload?: string | null
1190
+ fromSessionId?: string | null
1191
+ }): Mission[] {
1192
+ const candidates = listMissions({ sessionId: params.sessionId, status: 'non_terminal' }).filter((mission) => (
1193
+ mission.status === 'waiting'
1194
+ && mission.waitState?.kind === 'human_reply'
1195
+ ))
1196
+ return candidates
1197
+ .map((mission) => requestMissionTick(mission.id, 'human_reply', {
1198
+ correlationId: params.correlationId || null,
1199
+ envelopeId: params.envelopeId || null,
1200
+ payload: cleanText(params.payload, 320) || null,
1201
+ fromSessionId: params.fromSessionId || null,
1202
+ }))
1203
+ .filter((mission): mission is Mission => Boolean(mission))
1204
+ }
1205
+
1206
+ export function requestMissionTicksForProviderRecovery(providerKey: string): Mission[] {
1207
+ const normalizedProviderKey = cleanText(providerKey, 80)
1208
+ if (!normalizedProviderKey) return []
1209
+ const candidates = listMissions({ status: 'non_terminal' }).filter((mission) => (
1210
+ mission.waitState?.kind === 'provider'
1211
+ && cleanText(mission.waitState?.providerKey, 80) === normalizedProviderKey
1212
+ ))
1213
+ return candidates
1214
+ .map((mission) => requestMissionTick(mission.id, 'provider_recovered', {
1215
+ providerKey: normalizedProviderKey,
1216
+ }))
1217
+ .filter((mission): mission is Mission => Boolean(mission))
1218
+ }
1219
+
1220
+ export async function runMissionTick(
1221
+ missionId: string,
1222
+ trigger = 'manual',
1223
+ options?: { generateText?: (prompt: string) => Promise<string> },
1224
+ ): Promise<Mission | null> {
1225
+ const release = acquireMissionLease(missionId)
1226
+ if (!release) return loadMissionById(missionId)
1227
+ try {
1228
+ let mission = loadMissionById(missionId)
1229
+ if (!mission) return null
1230
+ if (isMissionTerminal(mission.status)) return mission
1231
+ const reconciled = patchMissionStatus(missionId, (current) => reconcileMissionState(current))
1232
+ mission = reconciled || mission
1233
+ if (mission.status === 'waiting' && !isWaitSatisfied(mission)) return mission
1234
+ if (missionHasActiveTask(mission) || missionHasActiveRun(mission) || missionHasActiveChild(mission)) {
1235
+ return patchMissionStatus(missionId, (current) => ({
1236
+ ...current,
1237
+ status: current.status === 'waiting' ? current.status : 'active',
1238
+ phase: current.status === 'waiting' ? 'waiting' : 'executing',
1239
+ controllerState: {
1240
+ ...(current.controllerState || {}),
1241
+ tickRequestedAt: now(),
1242
+ tickReason: trigger,
1243
+ },
1244
+ })) || mission
1245
+ }
1246
+ const planned = applyMissionPlannerPolicies(mission, await planMissionAction(mission, options))
1247
+ return await executeMissionPlannerDecision(mission, planned, trigger)
1248
+ } finally {
1249
+ release()
1250
+ }
1251
+ }
1252
+
1253
+ export function bindMissionToSession(sessionId: string, missionId: string): void {
1254
+ patchSession(sessionId, (current) => {
1255
+ if (!current) return current
1256
+ if (current.missionId === missionId) return current
1257
+ return {
1258
+ ...current,
1259
+ missionId,
1260
+ updatedAt: now(),
1261
+ }
1262
+ })
1263
+ }
1264
+
1265
+ export function bindMissionToTask(taskId: string, missionId: string): void {
1266
+ patchTask(taskId, (current) => {
1267
+ if (!current) return current
1268
+ if (current.missionId === missionId) return current
1269
+ return {
1270
+ ...current,
1271
+ missionId,
1272
+ updatedAt: now(),
1273
+ }
1274
+ })
1275
+ }
1276
+
1277
+ function createMission(input: {
1278
+ source: MissionSource
1279
+ sourceRef?: MissionSourceRef
1280
+ objective: string
1281
+ successCriteria?: string[]
1282
+ currentStep?: string | null
1283
+ plannerSummary?: string | null
1284
+ sessionId?: string | null
1285
+ agentId?: string | null
1286
+ projectId?: string | null
1287
+ taskId?: string | null
1288
+ runId?: string | null
1289
+ sourceMessage?: string | null
1290
+ parentMissionId?: string | null
1291
+ dependencyMissionIds?: string[]
1292
+ dependencyTaskIds?: string[]
1293
+ }): Mission {
1294
+ const timestamp = now()
1295
+ const parentMission = input.parentMissionId ? loadMissionById(input.parentMissionId) : null
1296
+ const mission = normalizeMissionRecord({
1297
+ id: genId(),
1298
+ source: input.source,
1299
+ sourceRef: input.sourceRef,
1300
+ objective: cleanText(input.objective, 300),
1301
+ successCriteria: uniqueStrings(input.successCriteria, 6, 180),
1302
+ status: 'active',
1303
+ phase: 'intake',
1304
+ sessionId: input.sessionId || null,
1305
+ agentId: input.agentId || null,
1306
+ projectId: input.projectId || null,
1307
+ rootMissionId: parentMission?.rootMissionId || parentMission?.id || null,
1308
+ parentMissionId: input.parentMissionId || null,
1309
+ childMissionIds: [],
1310
+ dependencyMissionIds: listMissionIds(input.dependencyMissionIds, 128),
1311
+ dependencyTaskIds: listMissionIds(input.dependencyTaskIds, 128),
1312
+ taskIds: input.taskId ? [input.taskId] : [],
1313
+ rootTaskId: input.taskId || null,
1314
+ currentStep: cleanText(input.currentStep, 200) || null,
1315
+ plannerSummary: cleanText(input.plannerSummary, 320) || null,
1316
+ verifierSummary: null,
1317
+ blockerSummary: null,
1318
+ waitState: null,
1319
+ controllerState: {
1320
+ tickRequestedAt: timestamp,
1321
+ tickReason: 'mission_created',
1322
+ attemptCount: 0,
1323
+ },
1324
+ plannerState: {
1325
+ lastDecision: null,
1326
+ lastPlannedAt: null,
1327
+ planSummary: cleanText(input.plannerSummary, 320) || null,
1328
+ },
1329
+ verificationState: {
1330
+ candidate: false,
1331
+ requiredTaskIds: input.taskId ? [input.taskId] : [],
1332
+ requiredChildMissionIds: [],
1333
+ requiredArtifacts: [],
1334
+ evidenceSummary: null,
1335
+ lastVerdict: null,
1336
+ lastVerifiedAt: null,
1337
+ },
1338
+ lastRunId: input.runId || null,
1339
+ sourceRunId: input.runId || null,
1340
+ sourceMessage: cleanText(input.sourceMessage, 600) || null,
1341
+ createdAt: timestamp,
1342
+ updatedAt: timestamp,
1343
+ lastActiveAt: timestamp,
1344
+ completedAt: null,
1345
+ failedAt: null,
1346
+ cancelledAt: null,
1347
+ })
1348
+ if (!mission.rootMissionId) mission.rootMissionId = mission.parentMissionId || mission.id
1349
+ upsertMission(mission.id, mission)
1350
+ notify('missions')
1351
+ appendMissionEvent({
1352
+ missionId: mission.id,
1353
+ type: 'created',
1354
+ source: input.source,
1355
+ summary: `Mission created: ${mission.objective}`,
1356
+ sessionId: mission.sessionId || null,
1357
+ taskId: input.taskId || null,
1358
+ runId: input.runId || null,
1359
+ data: {
1360
+ successCriteria: mission.successCriteria || [],
1361
+ currentStep: mission.currentStep || null,
1362
+ plannerSummary: mission.plannerSummary || null,
1363
+ sourceRef: mission.sourceRef || null,
1364
+ },
1365
+ })
1366
+ if (mission.parentMissionId) {
1367
+ patchMissionStatus(mission.parentMissionId, (parent) => ({
1368
+ ...parent,
1369
+ childMissionIds: listMissionIds([...(parent.childMissionIds || []), mission.id], 256),
1370
+ phase: parent.phase === 'completed' ? 'planning' : parent.phase,
1371
+ status: parent.status === 'completed' ? 'active' : parent.status,
1372
+ waitState: {
1373
+ kind: 'blocked_mission',
1374
+ reason: `Waiting on child mission: ${mission.objective}`,
1375
+ dependencyMissionId: mission.id,
1376
+ },
1377
+ dependencyMissionIds: listMissionIds([...(parent.dependencyMissionIds || []), mission.id], 256),
1378
+ }))
1379
+ appendMissionEvent({
1380
+ missionId: mission.parentMissionId,
1381
+ type: 'child_created',
1382
+ source: input.source,
1383
+ summary: `Child mission created: ${mission.objective}`,
1384
+ sessionId: mission.sessionId || null,
1385
+ runId: input.runId || null,
1386
+ data: {
1387
+ childMissionId: mission.id,
1388
+ objective: mission.objective,
1389
+ },
1390
+ })
1391
+ }
1392
+ return mission
1393
+ }
1394
+
1395
+ export function ensureMissionForTask(
1396
+ task: BoardTask,
1397
+ options?: {
1398
+ source?: MissionSource
1399
+ sessionId?: string | null
1400
+ runId?: string | null
1401
+ },
1402
+ ): Mission | null {
1403
+ if (!task || !task.id) return null
1404
+ const existingMission = loadMissionById(task.missionId)
1405
+ if (existingMission) {
1406
+ const linked = patchMissionStatus(existingMission.id, (mission) => ensureMissionTaskLink(mission, task.id))
1407
+ if (linked) bindMissionToTask(task.id, linked.id)
1408
+ if (task.sessionId && linked) bindMissionToSession(task.sessionId, linked.id)
1409
+ return linked
1410
+ }
1411
+
1412
+ const sourceTaskMission = (() => {
1413
+ const tasks = loadTasks()
1414
+ const sourceTaskId = typeof task.delegatedFromTaskId === 'string' && task.delegatedFromTaskId.trim()
1415
+ ? task.delegatedFromTaskId.trim()
1416
+ : Array.isArray(task.blockedBy) && task.blockedBy.length > 0
1417
+ ? task.blockedBy[0]
1418
+ : ''
1419
+ if (!sourceTaskId) return null
1420
+ return loadMissionById(tasks[sourceTaskId]?.missionId)
1421
+ })()
1422
+
1423
+ if (sourceTaskMission) {
1424
+ const linked = patchMissionStatus(sourceTaskMission.id, (mission) => ensureMissionTaskLink(mission, task.id))
1425
+ if (linked) {
1426
+ bindMissionToTask(task.id, linked.id)
1427
+ if (task.sessionId) bindMissionToSession(task.sessionId, linked.id)
1428
+ appendMissionEvent({
1429
+ missionId: linked.id,
1430
+ type: 'task_linked',
1431
+ source: options?.source || missionSourceFromTask(task),
1432
+ summary: `Linked task: ${task.title}`,
1433
+ sessionId: task.sessionId || null,
1434
+ taskId: task.id,
1435
+ runId: options?.runId || null,
1436
+ data: { taskStatus: task.status },
1437
+ })
1438
+ }
1439
+ return linked
1440
+ }
1441
+
1442
+ const session = task.sessionId ? loadSession(task.sessionId) : null
1443
+ const sessionMission = getMissionForSession(session)
1444
+ if (sessionMission && !isMissionTerminal(sessionMission.status)) {
1445
+ const linked = patchMissionStatus(sessionMission.id, (mission) => ensureMissionTaskLink(mission, task.id))
1446
+ if (linked) {
1447
+ bindMissionToTask(task.id, linked.id)
1448
+ if (task.sessionId) bindMissionToSession(task.sessionId, linked.id)
1449
+ appendMissionEvent({
1450
+ missionId: linked.id,
1451
+ type: 'task_linked',
1452
+ source: options?.source || missionSourceFromTask(task),
1453
+ summary: `Linked task: ${task.title}`,
1454
+ sessionId: task.sessionId || null,
1455
+ taskId: task.id,
1456
+ runId: options?.runId || null,
1457
+ data: { taskStatus: task.status },
1458
+ })
1459
+ }
1460
+ return linked
1461
+ }
1462
+
1463
+ const objective = cleanText(task.goalContract?.objective, 300) || cleanText(task.title, 300)
1464
+ if (!objective) return null
1465
+ const mission = createMission({
1466
+ source: options?.source || missionSourceFromTask(task),
1467
+ objective,
1468
+ successCriteria: task.goalContract?.constraints || [],
1469
+ currentStep: cleanText(task.description, 200) || null,
1470
+ plannerSummary: task.description || task.title,
1471
+ sessionId: options?.sessionId || task.sessionId || null,
1472
+ agentId: task.agentId,
1473
+ projectId: task.projectId || null,
1474
+ taskId: task.id,
1475
+ runId: options?.runId || null,
1476
+ sourceMessage: task.description || task.title,
1477
+ })
1478
+ bindMissionToTask(task.id, mission.id)
1479
+ if (task.sessionId) bindMissionToSession(task.sessionId, mission.id)
1480
+ appendMissionEvent({
1481
+ missionId: mission.id,
1482
+ type: 'task_linked',
1483
+ source: options?.source || missionSourceFromTask(task),
1484
+ summary: `Linked task: ${task.title}`,
1485
+ sessionId: task.sessionId || null,
1486
+ taskId: task.id,
1487
+ runId: options?.runId || null,
1488
+ data: { taskStatus: task.status },
1489
+ })
1490
+ return loadMissionById(mission.id)
1491
+ }
1492
+
1493
+ function applyTurnDecisionToMission(
1494
+ decision: MissionTurnDecision,
1495
+ params: {
1496
+ session: Session
1497
+ source: MissionSource
1498
+ runId?: string | null
1499
+ message: string
1500
+ currentMission: Mission | null
1501
+ },
1502
+ ): Mission | null {
1503
+ if (decision.action === 'none') return null
1504
+ if (decision.action === 'attach_current' && params.currentMission) {
1505
+ const updated = patchMissionStatus(params.currentMission.id, (mission) => ({
1506
+ ...mission,
1507
+ phase: mission.status === 'waiting' ? 'waiting' : mission.phase,
1508
+ currentStep: decision.currentStep || mission.currentStep || null,
1509
+ plannerSummary: decision.plannerSummary || mission.plannerSummary || null,
1510
+ lastRunId: params.runId || mission.lastRunId || null,
1511
+ }))
1512
+ if (updated) {
1513
+ bindMissionToSession(params.session.id, updated.id)
1514
+ appendMissionEvent({
1515
+ missionId: updated.id,
1516
+ type: 'attached',
1517
+ source: params.source,
1518
+ summary: `Attached turn to mission: ${updated.objective}`,
1519
+ sessionId: params.session.id,
1520
+ runId: params.runId || null,
1521
+ data: { message: cleanText(params.message, 320) },
1522
+ })
1523
+ }
1524
+ return updated
1525
+ }
1526
+ if (decision.action !== 'create_new') return null
1527
+ const mission = createMission({
1528
+ source: params.source,
1529
+ objective: decision.objective,
1530
+ successCriteria: decision.successCriteria,
1531
+ currentStep: decision.currentStep || null,
1532
+ plannerSummary: decision.plannerSummary || null,
1533
+ sessionId: params.session.id,
1534
+ agentId: params.session.agentId || null,
1535
+ projectId: params.session.projectId || null,
1536
+ runId: params.runId || null,
1537
+ sourceMessage: params.message,
1538
+ })
1539
+ bindMissionToSession(params.session.id, mission.id)
1540
+ return loadMissionById(mission.id)
1541
+ }
1542
+
1543
+ export async function resolveMissionForTurn(params: {
1544
+ session: Session
1545
+ message: string
1546
+ source: string
1547
+ internal: boolean
1548
+ runId?: string | null
1549
+ explicitMissionId?: string | null
1550
+ generateText?: (prompt: string) => Promise<string>
1551
+ }): Promise<Mission | null> {
1552
+ const explicitMission = loadMissionById(params.explicitMissionId)
1553
+ if (explicitMission) {
1554
+ bindMissionToSession(params.session.id, explicitMission.id)
1555
+ return explicitMission
1556
+ }
1557
+
1558
+ const currentMission = getMissionForSession(params.session)
1559
+ if (params.source === 'task' && currentMission) {
1560
+ bindMissionToSession(params.session.id, currentMission.id)
1561
+ return currentMission
1562
+ }
1563
+ if (params.internal) {
1564
+ if (currentMission) bindMissionToSession(params.session.id, currentMission.id)
1565
+ return currentMission
1566
+ }
1567
+
1568
+ let decision: MissionTurnDecision | null = null
1569
+ try {
1570
+ decision = await classifyMissionTurn({
1571
+ sessionId: params.session.id,
1572
+ agentId: params.session.agentId || null,
1573
+ message: params.message,
1574
+ recentMessages: Array.isArray(params.session.messages) ? params.session.messages : [],
1575
+ currentMission: currentMission ? buildMissionSummary(currentMission) : null,
1576
+ session: params.session,
1577
+ }, params.generateText ? { generateText: params.generateText } : undefined)
1578
+ } catch (err: unknown) {
1579
+ log.warn(TAG, `resolveMissionForTurn failed for ${params.session.id}: ${errorMessage(err)}`)
1580
+ return null
1581
+ }
1582
+
1583
+ if (!decision) return null
1584
+ return applyTurnDecisionToMission(decision, {
1585
+ session: params.session,
1586
+ source: params.source === 'chat' ? 'chat' : 'connector',
1587
+ runId: params.runId || null,
1588
+ message: params.message,
1589
+ currentMission,
1590
+ })
1591
+ }
1592
+
1593
+ function missionPhaseForVerdict(decision: MissionOutcomeDecision, mission: Mission): MissionPhase {
1594
+ if (decision.phase) return decision.phase
1595
+ if (decision.verdict === 'completed') return 'completed'
1596
+ if (decision.verdict === 'failed') return 'failed'
1597
+ if (decision.verdict === 'waiting') return 'waiting'
1598
+ if (decision.verdict === 'replan') return 'planning'
1599
+ if (mission.phase === 'planning') return 'executing'
1600
+ return 'verifying'
1601
+ }
1602
+
1603
+ function applyMissionOutcomePolicies(
1604
+ mission: Mission,
1605
+ decision: MissionOutcomeDecision,
1606
+ ): MissionOutcomeDecision {
1607
+ if (decision.verdict !== 'waiting' || !shouldSuppressMissionHumanLoopWait(decision.waitKind)) return decision
1608
+ const currentStep = decision.currentStep || mission.currentStep
1609
+ if (hasTerminalMissionEvidence(mission) || ((mission.taskIds?.length || 0) === 0 && (mission.childMissionIds?.length || 0) === 0)) {
1610
+ return {
1611
+ verdict: 'completed',
1612
+ confidence: decision.confidence,
1613
+ phase: 'completed',
1614
+ ...(currentStep ? { currentStep } : {}),
1615
+ verifierSummary: 'Mission human-loop waits are disabled, so the completed work was closed instead of waiting for another reply.',
1616
+ }
1617
+ }
1618
+ return {
1619
+ verdict: 'replan',
1620
+ confidence: decision.confidence,
1621
+ phase: 'planning',
1622
+ ...(currentStep ? { currentStep } : {}),
1623
+ verifierSummary: 'Mission human-loop waits are disabled, so the controller kept the mission active instead of waiting for another reply.',
1624
+ }
1625
+ }
1626
+
1627
+ function summaryForOutcome(decision: MissionOutcomeDecision, fallback: string): string {
1628
+ return cleanText(decision.verifierSummary, 360) || cleanText(fallback, 360) || 'Mission updated.'
1629
+ }
1630
+
1631
+ export async function applyMissionOutcomeForTurn(params: {
1632
+ session: Session
1633
+ missionId: string
1634
+ source: string
1635
+ runId?: string | null
1636
+ message: string
1637
+ assistantText?: string | null
1638
+ error?: string | null
1639
+ toolEvents?: MessageToolEvent[]
1640
+ generateText?: (prompt: string) => Promise<string>
1641
+ }): Promise<Mission | null> {
1642
+ const mission = loadMissionById(params.missionId)
1643
+ if (!mission) return null
1644
+ const taskSummaries = listTaskSummaries(mission.taskIds)
1645
+ let decision: MissionOutcomeDecision | null = null
1646
+ try {
1647
+ decision = await verifyMissionOutcome({
1648
+ sessionId: params.session.id,
1649
+ agentId: params.session.agentId || null,
1650
+ userMessage: params.message,
1651
+ assistantText: params.assistantText || null,
1652
+ error: params.error || null,
1653
+ toolEvents: params.toolEvents,
1654
+ currentMission: buildMissionSummary(mission),
1655
+ linkedTaskSummaries: taskSummaries,
1656
+ }, params.generateText ? { generateText: params.generateText } : undefined)
1657
+ } catch (err: unknown) {
1658
+ log.warn(TAG, `applyMissionOutcomeForTurn failed for ${params.session.id}: ${errorMessage(err)}`)
1659
+ return mission
1660
+ }
1661
+ if (!decision) return mission
1662
+ decision = applyMissionOutcomePolicies(mission, decision)
1663
+
1664
+ const fallbackSummary = params.error
1665
+ ? `Run ended with error: ${params.error}`
1666
+ : cleanText(params.assistantText, 360) || 'Mission run completed.'
1667
+ const outcomeSummary = summaryForOutcome(decision, fallbackSummary)
1668
+ const updated = patchMissionStatus(mission.id, (current) => {
1669
+ const next: Mission = {
1670
+ ...current,
1671
+ phase: missionPhaseForVerdict(decision, current),
1672
+ currentStep: decision.currentStep || current.currentStep || null,
1673
+ verifierSummary: outcomeSummary,
1674
+ lastRunId: params.runId || current.lastRunId || null,
1675
+ waitState: null,
1676
+ blockerSummary: null,
1677
+ completedAt: current.completedAt || null,
1678
+ failedAt: current.failedAt || null,
1679
+ cancelledAt: current.cancelledAt || null,
1680
+ }
1681
+ if (decision.verdict === 'completed') {
1682
+ next.status = 'completed'
1683
+ next.phase = 'completed'
1684
+ next.waitState = null
1685
+ next.completedAt = now()
1686
+ } else if (decision.verdict === 'failed') {
1687
+ next.status = 'failed'
1688
+ next.phase = 'failed'
1689
+ next.failedAt = now()
1690
+ next.blockerSummary = outcomeSummary
1691
+ } else if (decision.verdict === 'waiting') {
1692
+ next.status = 'waiting'
1693
+ next.phase = 'waiting'
1694
+ next.waitState = {
1695
+ kind: decision.waitKind || 'other',
1696
+ reason: cleanText(decision.waitReason, 220) || outcomeSummary,
1697
+ }
1698
+ } else if (decision.verdict === 'replan') {
1699
+ next.status = 'active'
1700
+ next.phase = 'planning'
1701
+ next.waitState = null
1702
+ next.blockerSummary = null
1703
+ } else {
1704
+ next.status = 'active'
1705
+ if (next.phase === 'completed' || next.phase === 'failed' || next.phase === 'waiting') {
1706
+ next.phase = 'executing'
1707
+ }
1708
+ }
1709
+ return next
1710
+ })
1711
+ if (!updated) return mission
1712
+
1713
+ logActivity({
1714
+ entityType: 'mission',
1715
+ entityId: updated.id,
1716
+ action: `phase_${updated.phase}`,
1717
+ actor: 'system',
1718
+ summary: `Mission "${updated.objective?.slice(0, 60) || updated.id}" → ${updated.phase} (${decision.verdict})`,
1719
+ })
1720
+
1721
+ appendMissionEvent({
1722
+ missionId: updated.id,
1723
+ type: 'run_result',
1724
+ source: params.source === 'heartbeat' || params.source === 'main-loop-followup'
1725
+ ? (params.source as MissionSource)
1726
+ : 'chat',
1727
+ summary: outcomeSummary,
1728
+ sessionId: params.session.id,
1729
+ runId: params.runId || null,
1730
+ data: {
1731
+ verdict: decision.verdict,
1732
+ phase: updated.phase,
1733
+ status: updated.status,
1734
+ currentStep: updated.currentStep || null,
1735
+ waitState: updated.waitState || null,
1736
+ },
1737
+ })
1738
+
1739
+ if (decision.verdict === 'waiting') {
1740
+ appendMissionEvent({
1741
+ missionId: updated.id,
1742
+ type: 'waiting',
1743
+ source: params.source === 'heartbeat' || params.source === 'main-loop-followup'
1744
+ ? (params.source as MissionSource)
1745
+ : 'chat',
1746
+ summary: updated.waitState?.reason || outcomeSummary,
1747
+ sessionId: params.session.id,
1748
+ runId: params.runId || null,
1749
+ data: updated.waitState ? updated.waitState as unknown as Record<string, unknown> : null,
1750
+ })
1751
+ } else if (decision.verdict === 'completed') {
1752
+ appendMissionEvent({
1753
+ missionId: updated.id,
1754
+ type: 'completed',
1755
+ source: params.source === 'heartbeat' || params.source === 'main-loop-followup'
1756
+ ? (params.source as MissionSource)
1757
+ : 'chat',
1758
+ summary: outcomeSummary,
1759
+ sessionId: params.session.id,
1760
+ runId: params.runId || null,
1761
+ data: { status: updated.status },
1762
+ })
1763
+ } else if (decision.verdict === 'failed') {
1764
+ appendMissionEvent({
1765
+ missionId: updated.id,
1766
+ type: 'failed',
1767
+ source: params.source === 'heartbeat' || params.source === 'main-loop-followup'
1768
+ ? (params.source as MissionSource)
1769
+ : 'chat',
1770
+ summary: outcomeSummary,
1771
+ sessionId: params.session.id,
1772
+ runId: params.runId || null,
1773
+ data: { status: updated.status },
1774
+ })
1775
+ }
1776
+
1777
+ bindMissionToSession(params.session.id, updated.id)
1778
+ if (
1779
+ params.source !== 'chat'
1780
+ && updated.status === 'active'
1781
+ && updated.phase !== 'executing'
1782
+ && updated.phase !== 'dispatching'
1783
+ && !missionHasActiveTask(updated)
1784
+ && !missionHasActiveRun(updated)
1785
+ && !missionHasActiveChild(updated)
1786
+ ) {
1787
+ requestMissionTick(updated.id, 'run_outcome', {
1788
+ source: params.source,
1789
+ verdict: decision.verdict,
1790
+ runId: params.runId || null,
1791
+ })
1792
+ }
1793
+ if (updated.parentMissionId && isMissionTerminal(updated.status)) {
1794
+ noteParentMissionChildOutcome(updated)
1795
+ }
1796
+ return updated
1797
+ }
1798
+
1799
+ function noteParentMissionChildOutcome(childMission: Mission): void {
1800
+ if (!childMission.parentMissionId) return
1801
+ const parent = loadMissionById(childMission.parentMissionId)
1802
+ if (!parent) return
1803
+ const summary = childMission.status === 'completed'
1804
+ ? `Child mission completed: ${childMission.objective}`
1805
+ : childMission.status === 'failed'
1806
+ ? `Child mission failed: ${childMission.objective}`
1807
+ : `Child mission updated: ${childMission.objective}`
1808
+ appendMissionEvent({
1809
+ missionId: parent.id,
1810
+ type: childMission.status === 'completed' ? 'child_completed' : childMission.status === 'failed' ? 'child_failed' : 'status_change',
1811
+ source: childMission.source,
1812
+ summary,
1813
+ sessionId: parent.sessionId || null,
1814
+ runId: childMission.lastRunId || null,
1815
+ data: {
1816
+ childMissionId: childMission.id,
1817
+ childStatus: childMission.status,
1818
+ childPhase: childMission.phase,
1819
+ },
1820
+ })
1821
+ requestMissionTick(parent.id, 'child_mission_changed', {
1822
+ childMissionId: childMission.id,
1823
+ childStatus: childMission.status,
1824
+ })
1825
+ wakeDependentMissions(childMission.id, 'mission')
1826
+ }
1827
+
1828
+ function wakeDependentMissions(completedId: string, kind: 'mission' | 'task'): void {
1829
+ const allMissions = Object.values(loadMissions()).map(normalizeMissionRecord)
1830
+ for (const candidate of allMissions) {
1831
+ if (isMissionTerminal(candidate.status)) continue
1832
+ const deps = kind === 'mission'
1833
+ ? Array.isArray(candidate.dependencyMissionIds) ? candidate.dependencyMissionIds : []
1834
+ : Array.isArray(candidate.dependencyTaskIds) ? candidate.dependencyTaskIds : []
1835
+ if (deps.includes(completedId)) {
1836
+ requestMissionTick(candidate.id, `dependency_${kind}_completed`, { [`completed${kind === 'mission' ? 'Mission' : 'Task'}Id`]: completedId })
1837
+ }
1838
+ }
1839
+ }
1840
+
1841
+ export function performMissionAction(params: {
1842
+ missionId: string
1843
+ action: 'resume' | 'replan' | 'cancel' | 'retry_verification' | 'wait'
1844
+ reason?: string | null
1845
+ waitKind?: NonNullable<Mission['waitState']>['kind']
1846
+ untilAt?: number | null
1847
+ }): { mission: Mission; event: MissionEvent } | null {
1848
+ const mission = loadMissionById(params.missionId)
1849
+ if (!mission) return null
1850
+ const summaryReason = cleanText(params.reason, 220) || null
1851
+ const updated = patchMissionStatus(mission.id, (current) => {
1852
+ if (params.action === 'cancel') {
1853
+ return {
1854
+ ...current,
1855
+ status: 'cancelled',
1856
+ phase: 'failed',
1857
+ blockerSummary: summaryReason || 'Mission cancelled by operator.',
1858
+ waitState: null,
1859
+ cancelledAt: now(),
1860
+ }
1861
+ }
1862
+ if (params.action === 'wait') {
1863
+ return {
1864
+ ...current,
1865
+ status: 'waiting',
1866
+ phase: 'waiting',
1867
+ waitState: {
1868
+ kind: params.waitKind || 'other',
1869
+ reason: summaryReason || 'Mission paused by operator.',
1870
+ untilAt: typeof params.untilAt === 'number' ? params.untilAt : null,
1871
+ },
1872
+ }
1873
+ }
1874
+ if (params.action === 'retry_verification') {
1875
+ return {
1876
+ ...current,
1877
+ status: 'active',
1878
+ phase: 'verifying',
1879
+ waitState: null,
1880
+ blockerSummary: null,
1881
+ verificationState: {
1882
+ ...(current.verificationState || { candidate: false }),
1883
+ candidate: true,
1884
+ },
1885
+ }
1886
+ }
1887
+ return {
1888
+ ...current,
1889
+ status: 'active',
1890
+ phase: 'planning',
1891
+ waitState: null,
1892
+ blockerSummary: null,
1893
+ controllerState: {
1894
+ ...(current.controllerState || {}),
1895
+ tickRequestedAt: now(),
1896
+ tickReason: params.action,
1897
+ },
1898
+ }
1899
+ })
1900
+ if (!updated) return null
1901
+ const event = appendMissionEvent({
1902
+ missionId: updated.id,
1903
+ type: 'operator_action',
1904
+ source: 'system',
1905
+ summary: `${params.action.replace(/_/g, ' ')} mission`,
1906
+ sessionId: updated.sessionId || null,
1907
+ runId: updated.lastRunId || null,
1908
+ data: {
1909
+ action: params.action,
1910
+ reason: summaryReason,
1911
+ waitKind: params.waitKind || null,
1912
+ untilAt: typeof params.untilAt === 'number' ? params.untilAt : null,
1913
+ },
1914
+ })
1915
+ if (params.action !== 'wait' && params.action !== 'cancel') {
1916
+ requestMissionTick(updated.id, `operator:${params.action}`, {
1917
+ reason: summaryReason,
1918
+ })
1919
+ }
1920
+ return { mission: updated, event }
1921
+ }
1922
+
1923
+ export function ensureMissionForSchedule(
1924
+ schedule: Schedule,
1925
+ options?: {
1926
+ sessionId?: string | null
1927
+ runId?: string | null
1928
+ },
1929
+ ): Mission | null {
1930
+ if (!schedule?.id) return null
1931
+ const linked = loadMissionById(schedule.linkedMissionId)
1932
+ if (linked) return linked
1933
+ const objective = cleanText(schedule.taskPrompt, 300)
1934
+ || cleanText(schedule.message, 300)
1935
+ || cleanText(schedule.name, 300)
1936
+ if (!objective) return null
1937
+ const mission = createMission({
1938
+ source: 'schedule',
1939
+ sourceRef: {
1940
+ kind: 'schedule',
1941
+ scheduleId: schedule.id,
1942
+ recurring: schedule.scheduleType !== 'once',
1943
+ },
1944
+ objective,
1945
+ currentStep: cleanText(schedule.taskPrompt || schedule.message || schedule.name, 200) || null,
1946
+ plannerSummary: schedule.taskPrompt || schedule.message || schedule.name,
1947
+ sessionId: options?.sessionId || schedule.createdInSessionId || null,
1948
+ agentId: schedule.agentId,
1949
+ projectId: schedule.projectId || null,
1950
+ runId: options?.runId || null,
1951
+ sourceMessage: schedule.taskPrompt || schedule.message || schedule.name,
1952
+ })
1953
+ schedule.linkedMissionId = mission.id
1954
+ upsertSchedule(schedule.id, {
1955
+ ...schedule,
1956
+ linkedMissionId: mission.id,
1957
+ })
1958
+ return mission
1959
+ }
1960
+
1961
+ export function noteScheduleMissionTriggered(
1962
+ schedule: Schedule,
1963
+ options?: {
1964
+ runId?: string | null
1965
+ taskId?: string | null
1966
+ wakeOnly?: boolean
1967
+ sessionId?: string | null
1968
+ },
1969
+ ): Mission | null {
1970
+ const mission = ensureMissionForSchedule(schedule, {
1971
+ sessionId: options?.sessionId || schedule.createdInSessionId || null,
1972
+ runId: options?.runId || null,
1973
+ })
1974
+ if (!mission) return null
1975
+ const updated = patchMissionStatus(mission.id, (current) => ({
1976
+ ...current,
1977
+ status: 'active',
1978
+ phase: options?.wakeOnly ? 'planning' : 'dispatching',
1979
+ currentStep: cleanText(schedule.taskPrompt || schedule.message || schedule.name, 200) || current.currentStep || null,
1980
+ controllerState: {
1981
+ ...(current.controllerState || {}),
1982
+ tickRequestedAt: now(),
1983
+ tickReason: options?.wakeOnly ? 'schedule_wake' : 'schedule_task',
1984
+ currentTaskId: options?.taskId || current.controllerState?.currentTaskId || null,
1985
+ },
1986
+ }))
1987
+ const sessionId = options?.sessionId || schedule.createdInSessionId || null
1988
+ if (updated && sessionId) bindMissionToSession(sessionId, updated.id)
1989
+ if (updated) {
1990
+ appendMissionEvent({
1991
+ missionId: updated.id,
1992
+ type: 'source_triggered',
1993
+ source: 'schedule',
1994
+ summary: options?.wakeOnly
1995
+ ? `Schedule wake fired: ${schedule.name}`
1996
+ : `Schedule task fired: ${schedule.name}`,
1997
+ sessionId,
1998
+ runId: options?.runId || null,
1999
+ taskId: options?.taskId || null,
2000
+ data: {
2001
+ scheduleId: schedule.id,
2002
+ wakeOnly: options?.wakeOnly === true,
2003
+ },
2004
+ })
2005
+ }
2006
+ return updated
2007
+ }
2008
+
2009
+ export function ensureDelegationMission(input: {
2010
+ task: string
2011
+ backend?: DelegationJobRecord['backend']
2012
+ parentSessionId?: string | null
2013
+ childSessionId?: string | null
2014
+ agentId?: string | null
2015
+ parentMissionId?: string | null
2016
+ jobId?: string | null
2017
+ }): Mission | null {
2018
+ const explicitParent = loadMissionById(input.parentMissionId)
2019
+ const sessionParent = input.parentSessionId ? getMissionForSession(loadSession(input.parentSessionId)) : null
2020
+ const parentMission = explicitParent || sessionParent
2021
+ if (!parentMission) return null
2022
+ const childSession = input.childSessionId ? loadSession(input.childSessionId) : null
2023
+ const existing = childSession?.missionId ? loadMissionById(childSession.missionId) : null
2024
+ if (existing && existing.parentMissionId === parentMission.id) return existing
2025
+ const childMission = createMission({
2026
+ source: 'delegation',
2027
+ sourceRef: {
2028
+ kind: 'delegation',
2029
+ parentMissionId: parentMission.id,
2030
+ backend: input.backend === 'codex' || input.backend === 'claude' || input.backend === 'opencode' || input.backend === 'gemini'
2031
+ ? input.backend
2032
+ : 'agent',
2033
+ },
2034
+ objective: cleanText(input.task, 300) || 'Delegated work',
2035
+ currentStep: cleanText(input.task, 200) || 'Execute delegated task',
2036
+ plannerSummary: cleanText(input.task, 320) || 'Execute delegated task',
2037
+ sessionId: input.childSessionId || input.parentSessionId || null,
2038
+ agentId: input.agentId || null,
2039
+ projectId: parentMission.projectId || null,
2040
+ sourceMessage: cleanText(input.task, 600) || null,
2041
+ parentMissionId: parentMission.id,
2042
+ })
2043
+ if (input.childSessionId) bindMissionToSession(input.childSessionId, childMission.id)
2044
+ return childMission
2045
+ }
2046
+
2047
+ export function syncDelegationMissionFromJob(jobId: string): Mission | null {
2048
+ const job = loadDelegationJob(jobId)
2049
+ if (!job) return null
2050
+ const mission = loadMissionById(job.missionId) || ensureDelegationMission({
2051
+ task: job.task,
2052
+ backend: job.backend,
2053
+ parentSessionId: job.parentSessionId || null,
2054
+ childSessionId: job.childSessionId || null,
2055
+ agentId: job.agentId || null,
2056
+ parentMissionId: job.parentMissionId || null,
2057
+ jobId,
2058
+ })
2059
+ if (!mission) return null
2060
+ const status = job.status
2061
+ const updated = patchMissionStatus(mission.id, (current) => {
2062
+ if (status === 'queued' || status === 'running') {
2063
+ return {
2064
+ ...current,
2065
+ status: 'active',
2066
+ phase: 'executing',
2067
+ currentStep: cleanText(job.task, 200) || current.currentStep || null,
2068
+ }
2069
+ }
2070
+ if (status === 'completed') {
2071
+ return {
2072
+ ...current,
2073
+ status: 'completed',
2074
+ phase: 'completed',
2075
+ verifierSummary: cleanText(job.result || job.resultPreview, 320) || current.verifierSummary || null,
2076
+ completedAt: now(),
2077
+ }
2078
+ }
2079
+ if (status === 'failed') {
2080
+ return {
2081
+ ...current,
2082
+ status: 'failed',
2083
+ phase: 'failed',
2084
+ blockerSummary: cleanText(job.error, 240) || 'Delegation failed.',
2085
+ failedAt: now(),
2086
+ }
2087
+ }
2088
+ return {
2089
+ ...current,
2090
+ status: 'cancelled',
2091
+ phase: 'failed',
2092
+ cancelledAt: now(),
2093
+ }
2094
+ })
2095
+ if (updated && updated.parentMissionId && isMissionTerminal(updated.status)) noteParentMissionChildOutcome(updated)
2096
+ return updated
2097
+ }
2098
+
2099
+ export function noteMissionTaskStarted(task: BoardTask, runId?: string | null): Mission | null {
2100
+ const mission = ensureMissionForTask(task, {
2101
+ source: missionSourceFromTask(task),
2102
+ runId: runId || null,
2103
+ })
2104
+ if (!mission) return null
2105
+ const updated = patchMissionStatus(mission.id, (current) => ({
2106
+ ...ensureMissionTaskLink(current, task.id),
2107
+ status: 'active',
2108
+ phase: 'executing',
2109
+ currentStep: cleanText(task.title, 200) || current.currentStep || null,
2110
+ controllerState: {
2111
+ ...(current.controllerState || {}),
2112
+ activeRunId: runId || current.controllerState?.activeRunId || null,
2113
+ currentTaskId: task.id,
2114
+ tickRequestedAt: now(),
2115
+ tickReason: 'task_started',
2116
+ },
2117
+ }))
2118
+ if (updated) {
2119
+ appendMissionEvent({
2120
+ missionId: updated.id,
2121
+ type: 'task_started',
2122
+ source: missionSourceFromTask(task),
2123
+ summary: `Task started: ${task.title}`,
2124
+ sessionId: task.sessionId || null,
2125
+ taskId: task.id,
2126
+ runId: runId || null,
2127
+ data: { taskStatus: task.status },
2128
+ })
2129
+ }
2130
+ return updated
2131
+ }
2132
+
2133
+ export function noteMissionTaskFinished(task: BoardTask, status: 'completed' | 'failed' | 'cancelled', runId?: string | null): Mission | null {
2134
+ const mission = loadMissionById(task.missionId) || ensureMissionForTask(task, {
2135
+ source: missionSourceFromTask(task),
2136
+ runId: runId || null,
2137
+ })
2138
+ if (!mission) return null
2139
+ const summary = status === 'completed'
2140
+ ? `Task completed: ${task.title}`
2141
+ : status === 'cancelled'
2142
+ ? `Task cancelled: ${task.title}`
2143
+ : `Task failed: ${task.title}`
2144
+ const updated = patchMissionStatus(mission.id, (current) => {
2145
+ const linked = ensureMissionTaskLink(current, task.id)
2146
+ const taskSummaries = listTaskSummaries(linked.taskIds)
2147
+ const hasOpenTask = taskSummaries.some((row) => !['completed', 'failed', 'cancelled', 'archived'].includes(row.status))
2148
+ const hasFailedTask = taskSummaries.some((row) => row.status === 'failed')
2149
+ const allCancelled = taskSummaries.length > 0 && taskSummaries.every((row) => row.status === 'cancelled')
2150
+ const completedAt = !hasOpenTask && !hasFailedTask && status === 'completed'
2151
+ ? now()
2152
+ : current.completedAt || null
2153
+ const cancelledAt = allCancelled ? now() : current.cancelledAt || null
2154
+ return {
2155
+ ...linked,
2156
+ status: hasFailedTask
2157
+ ? 'waiting'
2158
+ : allCancelled
2159
+ ? 'cancelled'
2160
+ : hasOpenTask
2161
+ ? 'active'
2162
+ : 'completed',
2163
+ phase: hasFailedTask
2164
+ ? 'waiting'
2165
+ : allCancelled
2166
+ ? 'failed'
2167
+ : hasOpenTask
2168
+ ? 'planning'
2169
+ : 'completed',
2170
+ blockerSummary: status === 'failed' ? cleanText(task.error, 240) || summary : current.blockerSummary || null,
2171
+ waitState: status === 'failed'
2172
+ ? {
2173
+ kind: 'blocked_task',
2174
+ reason: cleanText(task.error, 220) || summary,
2175
+ dependencyTaskId: task.id,
2176
+ }
2177
+ : null,
2178
+ controllerState: {
2179
+ ...(current.controllerState || {}),
2180
+ activeRunId: null,
2181
+ currentTaskId: hasOpenTask ? current.controllerState?.currentTaskId || null : null,
2182
+ tickRequestedAt: now(),
2183
+ tickReason: status === 'completed' ? 'task_completed' : status === 'failed' ? 'task_failed' : 'task_cancelled',
2184
+ },
2185
+ completedAt,
2186
+ cancelledAt,
2187
+ failedAt: status === 'failed' ? now() : current.failedAt || null,
2188
+ }
2189
+ })
2190
+ if (updated) {
2191
+ appendMissionEvent({
2192
+ missionId: updated.id,
2193
+ type: status === 'completed' ? 'task_completed' : 'task_failed',
2194
+ source: missionSourceFromTask(task),
2195
+ summary,
2196
+ sessionId: task.sessionId || null,
2197
+ taskId: task.id,
2198
+ runId: runId || null,
2199
+ data: {
2200
+ taskStatus: status,
2201
+ result: cleanText(task.result, 280) || null,
2202
+ error: cleanText(task.error, 220) || null,
2203
+ },
2204
+ })
2205
+ }
2206
+ if (updated && !isMissionTerminal(updated.status)) {
2207
+ requestMissionTick(updated.id, status === 'completed' ? 'task_state_changed' : 'task_blocked', {
2208
+ taskId: task.id,
2209
+ taskStatus: status,
2210
+ })
2211
+ }
2212
+ wakeDependentMissions(task.id, 'task')
2213
+ return updated
2214
+ }
2215
+
2216
+ export function buildMissionContextBlock(mission: Mission | null | undefined): string {
2217
+ if (!mission) return ''
2218
+ const summary = buildMissionSummary(mission)
2219
+ const linkedTasks = listTaskSummaries(summary.taskIds)
2220
+ const childMissions = listChildMissions(mission.id, 4)
2221
+ const taskBlock = linkedTasks.length > 0
2222
+ ? linkedTasks
2223
+ .slice(0, 6)
2224
+ .map((task) => {
2225
+ const base = `- [${task.status}] ${task.title}`
2226
+ if (task.status === 'completed' && task.result) {
2227
+ return `${base}: ${task.result.slice(0, 120)}`
2228
+ }
2229
+ return base
2230
+ })
2231
+ .join('\n')
2232
+ : ''
2233
+ const childBlock = childMissions.length > 0
2234
+ ? childMissions.map((child) => `- [${child.status}/${child.phase}] ${child.objective}`).join('\n')
2235
+ : ''
2236
+ return [
2237
+ '## Active Mission',
2238
+ `Objective: ${summary.objective}`,
2239
+ mission.successCriteria?.length ? `Success criteria: ${mission.successCriteria.join(' | ')}` : '',
2240
+ `Status: ${summary.status}`,
2241
+ `Phase: ${summary.phase}`,
2242
+ mission.sourceRef ? `Source: ${mission.sourceRef.kind}` : '',
2243
+ summary.currentStep ? `Current step: ${summary.currentStep}` : '',
2244
+ summary.waitingReason ? `Waiting reason: ${summary.waitingReason}` : '',
2245
+ mission.plannerSummary ? `Planner summary: ${mission.plannerSummary}` : '',
2246
+ mission.verifierSummary ? `Verifier summary: ${mission.verifierSummary}` : '',
2247
+ mission.verificationState?.candidate ? 'Verification candidate: true' : '',
2248
+ taskBlock ? `Linked tasks:\n${taskBlock}` : '',
2249
+ childBlock ? `Child missions:\n${childBlock}` : '',
2250
+ 'Advance the mission. Do not confuse planning, promises, or partial progress with completion.',
2251
+ ].filter(Boolean).join('\n')
2252
+ }
2253
+
2254
+ export function buildMissionHeartbeatPrompt(session: Session, fallbackPrompt: string): string | null {
2255
+ const mission = getMissionForSession(session)
2256
+ if (!mission || isMissionTerminal(mission.status)) return null
2257
+ const contextBlock = buildMissionContextBlock(mission)
2258
+ return [
2259
+ 'MAIN_AGENT_HEARTBEAT_TICK',
2260
+ `Time: ${new Date().toISOString()}`,
2261
+ contextBlock,
2262
+ fallbackPrompt ? `Base heartbeat instructions:\n${fallbackPrompt}` : '',
2263
+ '',
2264
+ 'You are checking the durable mission state for this agent.',
2265
+ 'Take the single highest-value next step for the mission.',
2266
+ 'If the mission is genuinely waiting on an external dependency, say so plainly.',
2267
+ 'Reply HEARTBEAT_OK only when the mission is completed or waiting and no immediate action should be taken.',
2268
+ ].filter(Boolean).join('\n')
2269
+ }