@polpo-ai/core 0.15.144 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/README.md +30 -0
  2. package/dist/__tests__/custom-tool-runner.test.d.ts +2 -0
  3. package/dist/__tests__/custom-tool-runner.test.d.ts.map +1 -0
  4. package/dist/__tests__/custom-tool-runner.test.js +121 -0
  5. package/dist/__tests__/custom-tool-runner.test.js.map +1 -0
  6. package/dist/__tests__/mcp-runner-client.test.d.ts +2 -0
  7. package/dist/__tests__/mcp-runner-client.test.d.ts.map +1 -0
  8. package/dist/__tests__/mcp-runner-client.test.js +144 -0
  9. package/dist/__tests__/mcp-runner-client.test.js.map +1 -0
  10. package/dist/__tests__/mission-execution.test.d.ts +2 -0
  11. package/dist/__tests__/mission-execution.test.d.ts.map +1 -0
  12. package/dist/__tests__/mission-execution.test.js +68 -0
  13. package/dist/__tests__/mission-execution.test.js.map +1 -0
  14. package/dist/__tests__/run-state-monitor.test.d.ts +2 -0
  15. package/dist/__tests__/run-state-monitor.test.d.ts.map +1 -0
  16. package/dist/__tests__/run-state-monitor.test.js +59 -0
  17. package/dist/__tests__/run-state-monitor.test.js.map +1 -0
  18. package/dist/__tests__/runtime-metadata.test.d.ts +2 -0
  19. package/dist/__tests__/runtime-metadata.test.d.ts.map +1 -0
  20. package/dist/__tests__/runtime-metadata.test.js +17 -0
  21. package/dist/__tests__/runtime-metadata.test.js.map +1 -0
  22. package/dist/__tests__/task-execution-router.test.js +1 -1
  23. package/dist/__tests__/task-execution-router.test.js.map +1 -1
  24. package/dist/__tests__/task-runner-agent-lifetime.test.d.ts +2 -0
  25. package/dist/__tests__/task-runner-agent-lifetime.test.d.ts.map +1 -0
  26. package/dist/__tests__/task-runner-agent-lifetime.test.js +136 -0
  27. package/dist/__tests__/task-runner-agent-lifetime.test.js.map +1 -0
  28. package/dist/adapter.d.ts +18 -0
  29. package/dist/adapter.d.ts.map +1 -1
  30. package/dist/agent-manager.d.ts +3 -0
  31. package/dist/agent-manager.d.ts.map +1 -1
  32. package/dist/agent-manager.js +28 -2
  33. package/dist/agent-manager.js.map +1 -1
  34. package/dist/agent-store.d.ts +70 -1
  35. package/dist/agent-store.d.ts.map +1 -1
  36. package/dist/agent-store.js +103 -1
  37. package/dist/agent-store.js.map +1 -1
  38. package/dist/approval-manager.d.ts +24 -4
  39. package/dist/approval-manager.d.ts.map +1 -1
  40. package/dist/approval-manager.js +160 -112
  41. package/dist/approval-manager.js.map +1 -1
  42. package/dist/approval-manager.test.d.ts +2 -0
  43. package/dist/approval-manager.test.d.ts.map +1 -0
  44. package/dist/approval-manager.test.js +116 -0
  45. package/dist/approval-manager.test.js.map +1 -0
  46. package/dist/assessment-orchestrator.d.ts +12 -1
  47. package/dist/assessment-orchestrator.d.ts.map +1 -1
  48. package/dist/assessment-orchestrator.js +181 -83
  49. package/dist/assessment-orchestrator.js.map +1 -1
  50. package/dist/custom-tool-runner.d.ts +56 -0
  51. package/dist/custom-tool-runner.d.ts.map +1 -0
  52. package/dist/custom-tool-runner.js +197 -0
  53. package/dist/custom-tool-runner.js.map +1 -0
  54. package/dist/escalation-manager.d.ts +2 -0
  55. package/dist/escalation-manager.d.ts.map +1 -1
  56. package/dist/escalation-manager.js +79 -25
  57. package/dist/escalation-manager.js.map +1 -1
  58. package/dist/events.d.ts +4 -0
  59. package/dist/events.d.ts.map +1 -1
  60. package/dist/hooks.d.ts.map +1 -1
  61. package/dist/hooks.js +6 -0
  62. package/dist/hooks.js.map +1 -1
  63. package/dist/index.d.ts +19 -3
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +13 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/mcp-capability.d.ts +40 -0
  68. package/dist/mcp-capability.d.ts.map +1 -0
  69. package/dist/mcp-capability.js +2 -0
  70. package/dist/mcp-capability.js.map +1 -0
  71. package/dist/mcp-runner.d.ts +45 -0
  72. package/dist/mcp-runner.d.ts.map +1 -0
  73. package/dist/mcp-runner.js +238 -0
  74. package/dist/mcp-runner.js.map +1 -0
  75. package/dist/mission-cancellation.d.ts +14 -0
  76. package/dist/mission-cancellation.d.ts.map +1 -0
  77. package/dist/mission-cancellation.js +43 -0
  78. package/dist/mission-cancellation.js.map +1 -0
  79. package/dist/mission-execution.d.ts +103 -0
  80. package/dist/mission-execution.d.ts.map +1 -0
  81. package/dist/mission-execution.js +196 -0
  82. package/dist/mission-execution.js.map +1 -0
  83. package/dist/mission-executor.d.ts +18 -16
  84. package/dist/mission-executor.d.ts.map +1 -1
  85. package/dist/mission-executor.js +339 -203
  86. package/dist/mission-executor.js.map +1 -1
  87. package/dist/mission-gating.d.ts +11 -15
  88. package/dist/mission-gating.d.ts.map +1 -1
  89. package/dist/mission-gating.js +25 -33
  90. package/dist/mission-gating.js.map +1 -1
  91. package/dist/mission-inspection.d.ts +29 -0
  92. package/dist/mission-inspection.d.ts.map +1 -0
  93. package/dist/mission-inspection.js +99 -0
  94. package/dist/mission-inspection.js.map +1 -0
  95. package/dist/mission-store.d.ts +7 -0
  96. package/dist/mission-store.d.ts.map +1 -1
  97. package/dist/mission-store.js +8 -0
  98. package/dist/mission-store.js.map +1 -1
  99. package/dist/orchestrator-context.d.ts +3 -0
  100. package/dist/orchestrator-context.d.ts.map +1 -1
  101. package/dist/orchestrator-engine.d.ts +39 -39
  102. package/dist/orchestrator-engine.d.ts.map +1 -1
  103. package/dist/orchestrator-engine.js +124 -24
  104. package/dist/orchestrator-engine.js.map +1 -1
  105. package/dist/run-state-monitor.d.ts +14 -0
  106. package/dist/run-state-monitor.d.ts.map +1 -0
  107. package/dist/run-state-monitor.js +63 -0
  108. package/dist/run-state-monitor.js.map +1 -0
  109. package/dist/run-store.d.ts +15 -0
  110. package/dist/run-store.d.ts.map +1 -1
  111. package/dist/runner-capability.d.ts +43 -0
  112. package/dist/runner-capability.d.ts.map +1 -0
  113. package/dist/runner-capability.js +55 -0
  114. package/dist/runner-capability.js.map +1 -0
  115. package/dist/runtime-metadata.d.ts +5 -0
  116. package/dist/runtime-metadata.d.ts.map +1 -0
  117. package/dist/runtime-metadata.js +21 -0
  118. package/dist/runtime-metadata.js.map +1 -0
  119. package/dist/scheduler.d.ts +2 -1
  120. package/dist/scheduler.d.ts.map +1 -1
  121. package/dist/scheduler.js +16 -6
  122. package/dist/scheduler.js.map +1 -1
  123. package/dist/schemas.d.ts +8 -6
  124. package/dist/schemas.d.ts.map +1 -1
  125. package/dist/schemas.js +10 -0
  126. package/dist/schemas.js.map +1 -1
  127. package/dist/spawner.d.ts +12 -0
  128. package/dist/spawner.d.ts.map +1 -1
  129. package/dist/steering.d.ts +4 -1
  130. package/dist/steering.d.ts.map +1 -1
  131. package/dist/steering.js.map +1 -1
  132. package/dist/task-approval-coordinator.d.ts +29 -0
  133. package/dist/task-approval-coordinator.d.ts.map +1 -0
  134. package/dist/task-approval-coordinator.js +216 -0
  135. package/dist/task-approval-coordinator.js.map +1 -0
  136. package/dist/task-dispatch.d.ts +40 -0
  137. package/dist/task-dispatch.d.ts.map +1 -0
  138. package/dist/task-dispatch.js +136 -0
  139. package/dist/task-dispatch.js.map +1 -0
  140. package/dist/task-execution.d.ts +146 -0
  141. package/dist/task-execution.d.ts.map +1 -0
  142. package/dist/task-execution.js +357 -0
  143. package/dist/task-execution.js.map +1 -0
  144. package/dist/task-manager.d.ts +31 -22
  145. package/dist/task-manager.d.ts.map +1 -1
  146. package/dist/task-manager.js +74 -7
  147. package/dist/task-manager.js.map +1 -1
  148. package/dist/task-runner.d.ts +30 -3
  149. package/dist/task-runner.d.ts.map +1 -1
  150. package/dist/task-runner.js +429 -90
  151. package/dist/task-runner.js.map +1 -1
  152. package/dist/task-store.d.ts +9 -3
  153. package/dist/task-store.d.ts.map +1 -1
  154. package/dist/tool-invocation.d.ts +4 -0
  155. package/dist/tool-invocation.d.ts.map +1 -1
  156. package/dist/tool-invocation.js +8 -1
  157. package/dist/tool-invocation.js.map +1 -1
  158. package/dist/tool-invocation.test.d.ts +2 -0
  159. package/dist/tool-invocation.test.d.ts.map +1 -0
  160. package/dist/tool-invocation.test.js +40 -0
  161. package/dist/tool-invocation.test.js.map +1 -0
  162. package/dist/types/agent.d.ts +4 -0
  163. package/dist/types/agent.d.ts.map +1 -1
  164. package/dist/types/agent.js.map +1 -1
  165. package/dist/types/config.d.ts +3 -0
  166. package/dist/types/config.d.ts.map +1 -1
  167. package/dist/types/config.js.map +1 -1
  168. package/dist/types/mission.d.ts +23 -1
  169. package/dist/types/mission.d.ts.map +1 -1
  170. package/dist/types/task.d.ts +10 -0
  171. package/dist/types/task.d.ts.map +1 -1
  172. package/package.json +17 -1
@@ -1,8 +1,13 @@
1
- import { sanitizeExpectations, parseMissionDocument } from "./schemas.js";
2
- import { resolveMissionStore, resolveMissionForGroup } from "./mission-store.js";
1
+ import { currentMissionExecutionTasks, resumableMissions } from "./mission-inspection.js";
2
+ import { nanoid } from "nanoid";
3
+ import { parseMissionDocument } from "./schemas.js";
4
+ import { resolveMissionStore, resolveMissionForGroup, resolveMissionExecutionStore } from "./mission-store.js";
5
+ import { assertMissionExecutionTask, MissionExecutionError, validateMissionExecutionKey } from "./mission-execution.js";
6
+ import { cancelMissionExecution } from "./mission-cancellation.js";
3
7
  import { InMemoryCheckpointStore, InMemoryDelayStore } from "./in-memory-stores.js";
4
8
  import { MissionGating, parseISO8601Duration } from "./mission-gating.js";
5
9
  import { resolveConfiguredModelSelection } from "./model-profiles.js";
10
+ import { normalizeRuntimeMetadata } from "./runtime-metadata.js";
6
11
  /**
7
12
  * Mission CRUD + execution + resume + group lifecycle.
8
13
  * Runtime checkpoint/delay gating is delegated to MissionGating.
@@ -22,6 +27,18 @@ export class MissionExecutor {
22
27
  /** Track the pre-execution status for scheduled/recurring missions (by group name).
23
28
  * When a mission completes/fails, this determines whether to return to scheduled/recurring. */
24
29
  scheduledOrigin = new Map();
30
+ preparedExecutions = new Map();
31
+ groupExecutions = new Map();
32
+ gatingKey(group) {
33
+ if (this.preparedExecutions.has(group))
34
+ return group;
35
+ const executions = this.groupExecutions.get(group);
36
+ if (!executions?.size)
37
+ return group;
38
+ if (executions.size > 1)
39
+ throw new MissionExecutionError("mission_execution_conflict", "Ambiguous Mission group; use its execution ID");
40
+ return [...executions][0];
41
+ }
25
42
  constructor(ctx, taskMgr, agentMgr) {
26
43
  this.ctx = ctx;
27
44
  this.taskMgr = taskMgr;
@@ -45,13 +62,34 @@ export class MissionExecutor {
45
62
  const allTasks = await this.ctx.taskStore.listTasks();
46
63
  const groups = new Set();
47
64
  for (const t of allTasks) {
48
- if (t.group)
49
- groups.add(t.group);
65
+ const group = t.missionExecutionId ?? t.group;
66
+ if (group)
67
+ groups.add(group);
50
68
  }
51
69
  for (const group of groups) {
52
- const groupTasks = allTasks.filter(t => t.group === group);
53
- if (groupTasks.every(t => t.status === "done" || t.status === "failed")) {
54
- this.cleanedGroups.add(group);
70
+ try {
71
+ const groupTasks = allTasks.filter(t => (t.missionExecutionId ?? t.group) === group);
72
+ const persisted = groupTasks[0];
73
+ // Presentation must retain its durable key even if receipt recovery is
74
+ // temporarily unavailable. Authorization still requires the receipt below.
75
+ if (persisted.missionExecutionId)
76
+ this.gating.setExecutionIdentity({
77
+ executionId: persisted.missionExecutionId, missionId: persisted.missionId, group: persisted.group ?? group,
78
+ });
79
+ if (groupTasks.every(t => t.status === "done" || t.status === "failed")) {
80
+ const mission = await resolveMissionForGroup(this.missions, groupTasks, group);
81
+ const receipt = groupTasks[0].missionExecutionId
82
+ ? (await resolveMissionExecutionStore(this.ctx)?.getMissionExecutionById(groupTasks[0].missionExecutionId))?.receipt : undefined;
83
+ if (mission?.status !== "active" || (receipt && receipt.sequence !== mission.executionCount))
84
+ this.cleanedGroups.add(group);
85
+ }
86
+ if (!this.cleanedGroups.has(group) && groupTasks[0].missionExecutionId)
87
+ await this.prepareTaskExecution(groupTasks[0], false);
88
+ }
89
+ catch {
90
+ // A receipt outage blocks only this execution. The dispatch barrier and
91
+ // cleanup retry recovery later; unrelated Missions remain available.
92
+ this.ctx.emitter.emit("log", { level: "warn", message: `Mission execution recovery deferred for ${group}` });
55
93
  }
56
94
  }
57
95
  }
@@ -61,26 +99,38 @@ export class MissionExecutor {
61
99
  }
62
100
  /** Get quality gates for a mission group. Returns empty array if none defined. */
63
101
  getQualityGates(group) {
64
- return this.gatesByGroup.get(group) ?? [];
102
+ return this.gatesByGroup.get(this.gatingKey(group)) ?? [];
65
103
  }
66
104
  // ─── Checkpoint/delay gating (delegated to MissionGating) ─────────────
67
105
  /** Get checkpoints for a mission group. Returns empty array if none defined. */
68
106
  getCheckpoints(group) {
69
- return this.gating.getCheckpoints(group);
107
+ return this.gating.getCheckpoints(this.gatingKey(group));
70
108
  }
71
109
  /**
72
110
  * Check if a task is blocked by an active (unresumed) checkpoint.
73
111
  * Returns the blocking checkpoint if found, undefined if the task can proceed.
74
112
  */
75
113
  async getBlockingCheckpoint(group, taskTitle, taskId, tasks) {
76
- return this.gating.getBlockingCheckpoint(group, taskTitle, taskId, tasks);
114
+ const key = this.gatingKey(group);
115
+ return this.gating.getBlockingCheckpoint(key, taskTitle, taskId, this.preparedExecutions.has(key) ? tasks.filter(task => task.missionExecutionId === key) : tasks);
77
116
  }
78
117
  /**
79
118
  * Resume a checkpoint, unblocking its blocksTasks.
80
119
  * Returns true if the checkpoint was active and is now resumed, false if not found.
81
120
  */
82
121
  async resumeCheckpoint(group, checkpointName) {
83
- return this.gating.resumeCheckpoint(group, checkpointName);
122
+ return this.gating.resumeCheckpoint(this.gatingKey(group), checkpointName);
123
+ }
124
+ async resumeCheckpointByMissionId(missionId, checkpointName) {
125
+ await this.ready;
126
+ const mission = await this.getMission(missionId);
127
+ if (!mission)
128
+ return false;
129
+ const task = (await this.currentExecutionTasks(mission, await this.ctx.taskStore.listTasks()))[0];
130
+ if (!task)
131
+ return false;
132
+ await this.prepareTaskExecution(task, false);
133
+ return this.resumeCheckpoint(task.missionExecutionId ?? task.group ?? mission.name, checkpointName);
84
134
  }
85
135
  /** Get all active (unresumed) checkpoints across all mission groups. */
86
136
  getActiveCheckpoints() {
@@ -88,7 +138,7 @@ export class MissionExecutor {
88
138
  }
89
139
  /** Get delays for a mission group. Returns empty array if none defined. */
90
140
  getDelays(group) {
91
- return this.gating.getDelays(group);
141
+ return this.gating.getDelays(this.gatingKey(group));
92
142
  }
93
143
  /**
94
144
  * Check if a task is blocked by an active (unexpired) delay.
@@ -97,13 +147,15 @@ export class MissionExecutor {
97
147
  * Returns the blocking delay if found, undefined if the task can proceed.
98
148
  */
99
149
  async getBlockingDelay(group, taskTitle, taskId, tasks) {
100
- return this.gating.getBlockingDelay(group, taskTitle, taskId, tasks);
150
+ const key = this.gatingKey(group);
151
+ return this.gating.getBlockingDelay(key, taskTitle, taskId, this.preparedExecutions.has(key) ? tasks.filter(task => task.missionExecutionId === key) : tasks);
101
152
  }
102
153
  /** Get all active (unexpired) delays across all mission groups. */
103
154
  getActiveDelays() {
104
155
  return this.gating.getActiveDelays();
105
156
  }
106
157
  async createMission(opts) {
158
+ const metadata = normalizeRuntimeMetadata(opts.metadata);
107
159
  const name = opts.name ?? await this.missions.nextMissionName();
108
160
  const mission = await this.missions.createMission({
109
161
  name,
@@ -115,6 +167,7 @@ export class MissionExecutor {
115
167
  endDate: opts.endDate,
116
168
  notifications: opts.notifications,
117
169
  user: opts.user,
170
+ metadata,
118
171
  });
119
172
  this.ctx.emitter.emit("mission:saved", { missionId: mission.id, name: mission.name, status: mission.status });
120
173
  return mission;
@@ -135,32 +188,20 @@ export class MissionExecutor {
135
188
  const mission = await this.getMission(missionId);
136
189
  if (!mission)
137
190
  return false;
138
- // ── Cascade cleanup ──────────────────────────────────
139
- // 1. Kill running processes and remove all tasks belonging to this mission
140
- const missionGroup = mission.name;
141
- const deletedTasks = await this.taskMgr.clearTasks(t => t.missionId === missionId || t.group === missionGroup);
142
- // 2. Clean up volatile agents registered for this mission group
143
- await this.agentMgr.cleanupVolatileAgents(missionGroup);
144
- // 3. Clean up in-memory quality gates
145
- this.gatesByGroup.delete(missionGroup);
146
- // Also clean numbered groups for recurring missions (e.g. "mission-1 #2", "mission-1 #3")
147
- for (const key of this.gatesByGroup.keys()) {
148
- if (key.startsWith(missionGroup + " #"))
149
- this.gatesByGroup.delete(key);
150
- }
151
- // 4. Clean up persisted checkpoints and delays (incl. numbered recurring-run groups)
152
- await this.gating.removeGroupAndRuns(missionGroup);
153
- // 5. Clean up scheduled origin cache
154
- this.scheduledOrigin.delete(missionGroup);
155
- for (const key of this.scheduledOrigin.keys()) {
156
- if (key.startsWith(missionGroup + " #"))
157
- this.scheduledOrigin.delete(key);
191
+ await this.ready;
192
+ // An explicit Mission ID always wins over the legacy display-label fallback.
193
+ const belongs = (task) => task.missionId === missionId || (!task.missionId && task.group === mission.name);
194
+ const tasks = (await this.ctx.taskStore.listTasks()).filter(belongs);
195
+ const groups = new Set(tasks.map(task => task.missionExecutionId ?? task.group).filter((group) => !!group));
196
+ for (const [key, pending] of this.preparedExecutions) {
197
+ if ((await pending).receipt.missionId === missionId)
198
+ groups.add(key);
158
199
  }
159
- // 6. Allow re-cleanup if the group is ever recreated
160
- this.cleanedGroups.delete(missionGroup);
161
- for (const key of this.cleanedGroups) {
162
- if (key.startsWith(missionGroup + " #"))
163
- this.cleanedGroups.delete(key);
200
+ const deletedTasks = await this.taskMgr.clearTasks(belongs);
201
+ for (const group of groups) {
202
+ await this.agentMgr.cleanupVolatileAgents(group);
203
+ await this.forgetExecution(group);
204
+ this.cleanedGroups.delete(group);
164
205
  }
165
206
  // ── Delete the mission record ────────────────────────
166
207
  const result = await this.missions.deleteMission(missionId);
@@ -169,193 +210,282 @@ export class MissionExecutor {
169
210
  }
170
211
  return result;
171
212
  }
213
+ async currentExecutionTasks(mission, tasks) {
214
+ return currentMissionExecutionTasks(mission, tasks, resolveMissionExecutionStore(this.ctx));
215
+ }
172
216
  async getResumableMissions() {
173
- const missions = await this.listMissions();
174
- const state = await this.ctx.taskStore.getState();
175
- return missions.filter(m => {
176
- // Non-resumable statuses: draft (never executed), scheduled/recurring (scheduler handles),
177
- // completed (done), cancelled (aborted)
178
- if (m.status === "draft" || m.status === "scheduled" || m.status === "recurring" ||
179
- m.status === "completed" || m.status === "cancelled")
180
- return false;
181
- const tasks = state.tasks.filter(t => t.group === m.name);
182
- if (tasks.length === 0)
183
- return false;
184
- return tasks.some(t => t.status === "pending" || t.status === "failed");
185
- });
217
+ await this.ready;
218
+ return resumableMissions(await this.listMissions(), await this.ctx.taskStore.listTasks(), resolveMissionExecutionStore(this.ctx));
186
219
  }
187
220
  async resumeMission(missionId, opts) {
221
+ await this.ready;
188
222
  const mission = await this.getMission(missionId);
189
223
  if (!mission)
190
224
  throw new Error("Mission not found");
191
- // Re-register volatile agents if they were cleaned up
192
- this.cleanedGroups.delete(mission.name);
193
- const enableVolatile = this.ctx.config.settings.enableVolatileTeams !== false;
194
- if (enableVolatile && mission.data) {
195
- try {
196
- const doc = JSON.parse(mission.data);
197
- if (doc?.team && Array.isArray(doc.team)) {
198
- for (const a of doc.team) {
199
- if (!a.name)
200
- continue;
201
- const { name, ...rest } = a;
202
- await this.agentMgr.addVolatileAgent({ name, ...rest }, mission.name);
203
- }
204
- }
205
- }
206
- catch (err) {
207
- this.ctx.emitter.emit("log", { level: "warn", message: `Failed to re-register volatile agents for ${mission.name}: ${err instanceof Error ? err.message : String(err)}` });
208
- }
225
+ const tasks = await this.currentExecutionTasks(mission, await this.ctx.taskStore.listTasks());
226
+ for (const task of tasks)
227
+ this.cleanedGroups.delete(task.missionExecutionId ?? task.group ?? mission.name);
228
+ // Old CRUD-only executions have no accepted snapshot. Preserve their legacy
229
+ // resume behavior without letting edited configuration replace durable plans.
230
+ if (tasks.some(task => !task.missionExecutionId) && this.ctx.config.settings.enableVolatileTeams !== false) {
231
+ const doc = parseMissionDocument(JSON.parse(mission.data));
232
+ for (const agent of doc.team ?? [])
233
+ await this.agentMgr.addVolatileAgent(agent, tasks[0]?.group ?? mission.name);
209
234
  }
210
- const state = await this.ctx.taskStore.getState();
211
- const tasks = state.tasks.filter(t => t.group === mission.name);
212
- const failedTasks = tasks.filter(t => t.status === "failed");
213
- const pendingTasks = tasks.filter(t => t.status === "pending");
235
+ const failedTasks = tasks.filter(task => task.status === "failed");
236
+ const pendingTasks = tasks.filter(task => task.status === "pending");
214
237
  let retried = 0;
215
238
  if (opts?.retryFailed) {
216
239
  for (const task of failedTasks) {
240
+ // Validate the receipt before making failed work runnable again.
241
+ await this.prepareTaskExecution(task);
217
242
  try {
218
243
  await this.taskMgr.retryTask(task.id);
219
244
  retried++;
220
245
  }
221
- catch { /* no retries left — skip */
246
+ catch {
247
+ continue; /* no retries left */
222
248
  }
249
+ await this.prepareTaskExecution((await this.ctx.taskStore.getTask(task.id)));
223
250
  }
224
251
  }
225
- if (mission.status === "failed") {
252
+ for (const task of pendingTasks)
253
+ await this.prepareTaskExecution(task);
254
+ if (mission.status === "failed")
226
255
  await this.updateMission(missionId, { status: "active" });
227
- }
228
256
  this.ctx.emitter.emit("mission:resumed", { missionId, name: mission.name, retried, pending: pendingTasks.length });
229
257
  return { retried, pending: pendingTasks.length };
230
258
  }
231
- async executeMission(missionId) {
232
- const mission = await this.missions.getMission(missionId);
233
- if (!mission)
234
- throw new Error("Mission not found");
235
- const executableStates = ["draft", "scheduled", "recurring", "failed", "cancelled"];
236
- if (!executableStates.includes(mission.status)) {
237
- throw new Error(`Cannot execute mission in "${mission.status}" state`);
238
- }
239
- // Remember whether this is a scheduled/recurring mission so we can restore status after completion
240
- const scheduledStatus = mission.status === "scheduled" || mission.status === "recurring" ? mission.status : undefined;
241
- // Increment execution count (tracks how many times this mission has run — useful for recurring)
242
- const runNumber = (mission.executionCount ?? 0) + 1;
243
- await this.missions.updateMission(missionId, { executionCount: runNumber });
244
- // Validate mission document through Zod schema — throws with clear error on invalid shape
245
- const raw = JSON.parse(mission.data);
246
- const doc = parseMissionDocument(raw);
247
- // For recurring/scheduled missions with multiple runs, disambiguate the group with a run number
248
- const group = runNumber > 1 ? `${mission.name} #${runNumber}` : mission.name;
249
- // Run before:mission:execute hook
250
- const hookResult = this.ctx.hooks.runBeforeSync("mission:execute", {
251
- missionId,
252
- mission,
253
- taskCount: doc.tasks.length,
254
- });
255
- if (hookResult.cancelled) {
256
- throw new Error(`Mission execution blocked by hook: ${hookResult.cancelReason ?? "no reason"}`);
257
- }
258
- // Register volatile agents from the mission's team section
259
- const enableVolatile = this.ctx.config.settings.enableVolatileTeams !== false;
260
- if (enableVolatile && doc.team && Array.isArray(doc.team)) {
261
- for (const a of doc.team) {
262
- if (!a.name)
263
- continue;
264
- const { name, ...rest } = a;
265
- await this.agentMgr.addVolatileAgent({ name, ...rest }, group);
266
- }
267
- }
268
- // Validate API keys for all agents referenced in the mission
269
- const allAgents = await this.agentMgr.getAgents();
270
- const referencedModels = [];
271
- for (const t of doc.tasks) {
272
- const agentName = t.assignTo || allAgents[0]?.name;
273
- const agent = allAgents.find(a => a.name === agentName);
274
- if (agent?.model) {
275
- referencedModels.push(...resolveConfiguredModelSelection(agent.model, this.ctx.config.settings, agent.allowedModelProfiles).policy.candidates);
259
+ /** Cancel one accepted execution, never a presentation group or a newer Task attempt. */
260
+ async abortMission(missionId, options = {}) {
261
+ const capturedOptions = { ...options };
262
+ await this.ready;
263
+ const result = await cancelMissionExecution({ missionStore: this.missions, taskStore: this.ctx.taskStore,
264
+ missionExecutionStore: resolveMissionExecutionStore(this.ctx) }, missionId, capturedOptions);
265
+ // Durable state wins even if an in-process owner or cache is unavailable.
266
+ for (const runId of result.killedRunIds) {
267
+ try {
268
+ await this.ctx.spawner?.cancelRun?.(runId);
276
269
  }
277
- else if (!agent) {
278
- throw new Error(`Mission references agent "${agentName}" (task "${t.title}") but no such agent exists. ` +
279
- `Available agents: ${allAgents.map(a => a.name).join(", ")}`);
270
+ catch {
271
+ this.ctx.emitter.emit("log", { level: "warn", message: `Mission cancellation committed; owner notification deferred for Run ${runId}` });
280
272
  }
281
273
  }
282
- if (referencedModels.length > 0 && this.ctx.validateProviderKeys) {
283
- const missing = this.ctx.validateProviderKeys(referencedModels);
284
- if (missing.length > 0) {
285
- const details = missing
286
- .map(m => `${m.provider} (model: ${m.modelSpec})`)
287
- .join(", ");
288
- throw new Error(`Missing API keys for providers: ${details}. ` +
289
- `Set the corresponding environment variables or add them to the project.json providers section.`);
290
- }
274
+ // Do not remove execution resources here: a retry may already be using the
275
+ // same receipt while owner notification is pending. Normal lifecycle cleanup
276
+ // owns those resources independently from this captured cancellation.
277
+ return { aborted: result.aborted, executionId: result.executionId, missionCancelled: result.missionCancelled };
278
+ }
279
+ async forgetExecution(group) {
280
+ // Settle recovery before removing its definitions, so an in-flight load
281
+ // cannot restore a cache that cleanup has just removed.
282
+ await this.preparedExecutions.get(group);
283
+ this.gatesByGroup.delete(group);
284
+ this.scheduledOrigin.delete(group);
285
+ await this.gating.removeGroup(group);
286
+ this.preparedExecutions.delete(group);
287
+ for (const [label, aliases] of this.groupExecutions) {
288
+ aliases.delete(group);
289
+ if (!aliases.size)
290
+ this.groupExecutions.delete(label);
291
291
  }
292
- // Create tasks with dependency resolution
293
- const titleToId = new Map();
294
- const tasks = [];
295
- for (const t of doc.tasks) {
296
- const deps = (t.dependsOn || [])
297
- .map((title) => titleToId.get(title))
298
- .filter((id) => !!id);
299
- // Validate expectations through Zod schemas
300
- let expectations = [];
301
- if (t.expectations && Array.isArray(t.expectations) && t.expectations.length > 0) {
302
- const { valid, warnings } = sanitizeExpectations(t.expectations);
303
- expectations = valid;
304
- for (const w of warnings) {
305
- this.ctx.emitter.emit("log", { level: "warn", message: `Mission task "${t.title}": ${w}` });
292
+ }
293
+ /** Restore accepted runtime definitions before any task can be dispatched.
294
+ * The durable receipt is authoritative even after Mission edits or a restart. */
295
+ async prepareTaskExecution(task, restoreAgents = true) {
296
+ if (!task.missionExecutionId)
297
+ return;
298
+ const store = resolveMissionExecutionStore(this.ctx);
299
+ if (!store)
300
+ throw new MissionExecutionError("mission_execution_unsupported", "Mission execution recovery is unavailable");
301
+ let pending = this.preparedExecutions.get(task.missionExecutionId);
302
+ if (!pending) {
303
+ pending = (async () => {
304
+ await this.gating.ready;
305
+ const admission = await store.getMissionExecutionById(task.missionExecutionId);
306
+ if (!admission)
307
+ throw new MissionExecutionError("mission_execution_invalid", "Mission execution receipt is missing");
308
+ const doc = parseMissionDocument(JSON.parse(admission.expected.data));
309
+ const titles = new Map(admission.runtime?.taskTitles?.map(entry => [entry.authored, entry.accepted]));
310
+ const translate = (items = []) => items.map(item => ({
311
+ ...item, afterTasks: item.afterTasks.map(title => titles.get(title) ?? title), blocksTasks: item.blocksTasks.map(title => titles.get(title) ?? title),
312
+ }));
313
+ const group = admission.receipt.executionId;
314
+ this.gating.setExecutionIdentity({ executionId: group, missionId: admission.receipt.missionId, group: admission.receipt.group });
315
+ const aliases = this.groupExecutions.get(admission.receipt.group) ?? new Set();
316
+ aliases.add(group);
317
+ this.groupExecutions.set(admission.receipt.group, aliases);
318
+ // Definitions already persist in the receipt. Do not rewrite shared
319
+ // checkpoint state from a second executor during admission recovery.
320
+ this.gatesByGroup.set(group, translate(doc.qualityGates));
321
+ await this.gating.setCheckpointDefinitions(group, translate(doc.checkpoints), false);
322
+ await this.gating.setDelayDefinitions(group, translate(doc.delays), false);
323
+ if (admission.expected.status === "scheduled" || admission.expected.status === "recurring") {
324
+ this.scheduledOrigin.set(group, admission.expected.status);
306
325
  }
307
- }
308
- const task = await this.taskMgr.createTask({
309
- title: t.title,
310
- description: t.description || t.title,
311
- assignTo: t.assignTo || (await this.agentMgr.getAgents())[0]?.name || "default",
312
- dependsOn: deps,
313
- expectations,
314
- expectedOutcomes: t.expectedOutcomes,
315
- group,
316
- missionId,
317
- maxDuration: t.maxDuration,
318
- retryPolicy: t.retryPolicy,
319
- notifications: t.notifications,
320
- sideEffects: t.sideEffects,
321
- });
322
- titleToId.set(t.title, task.id);
323
- tasks.push(task);
326
+ return admission;
327
+ })();
328
+ this.preparedExecutions.set(task.missionExecutionId, pending);
329
+ pending.catch(() => this.preparedExecutions.delete(task.missionExecutionId));
324
330
  }
325
- // Store quality gates (in-memory) and checkpoints (persisted to disk)
326
- if (doc.qualityGates && doc.qualityGates.length > 0) {
327
- this.gatesByGroup.set(group, doc.qualityGates);
328
- }
329
- if (doc.checkpoints && doc.checkpoints.length > 0) {
330
- await this.gating.setCheckpointDefinitions(group, doc.checkpoints);
331
+ const admission = await pending;
332
+ assertMissionExecutionTask(task, admission.receipt);
333
+ // Never recreate volatile agents for historical/terminal work. New active
334
+ // tasks recover them from the accepted plan, not the edited Mission data.
335
+ if (restoreAgents && task.status !== "done" && task.status !== "failed") {
336
+ for (const agent of admission.runtime?.volatileAgents ?? []) {
337
+ await this.agentMgr.addVolatileAgent(agent, admission.receipt.executionId);
338
+ }
331
339
  }
332
- if (doc.delays && doc.delays.length > 0) {
333
- await this.gating.setDelayDefinitions(group, doc.delays);
340
+ }
341
+ async executeMission(missionId, options = {}, expectedMission) {
342
+ // Trusted scheduler input, never exposed as an authored HTTP option. A
343
+ // delayed trigger must not obtain fresh authority by re-reading the Mission.
344
+ const expected = expectedMission === undefined ? undefined : structuredClone(expectedMission);
345
+ options = { ...options };
346
+ await this.ready;
347
+ const persistence = resolveMissionExecutionStore(this.ctx);
348
+ if (expected && (!persistence || expected.id !== missionId)) {
349
+ throw new MissionExecutionError("mission_execution_unsupported", "Guarded Mission execution requires matching atomic persistence");
334
350
  }
335
- // Track scheduled origin so we know where to return after completion
336
- if (scheduledStatus) {
337
- this.scheduledOrigin.set(group, scheduledStatus);
351
+ if (options.idempotencyKey !== undefined) {
352
+ validateMissionExecutionKey(missionId, options.idempotencyKey);
353
+ if (!persistence)
354
+ throw new MissionExecutionError("mission_execution_unsupported", "Idempotent Mission execution requires atomic persistence");
355
+ const accepted = await persistence.getMissionExecution(missionId, options.idempotencyKey);
356
+ if (accepted)
357
+ return { tasks: accepted.tasks, group: accepted.group, executionId: accepted.executionId };
338
358
  }
339
- // Persist mission-level notifications from document onto the Mission record
340
- if (doc.notifications) {
341
- await this.missions.updateMission(missionId, { status: "active", notifications: doc.notifications });
359
+ let admitted = false;
360
+ try {
361
+ const stored = expected ?? await this.missions.getMission(missionId);
362
+ if (!stored)
363
+ throw new Error("Mission not found");
364
+ // Some custom stores return mutable objects. Hooks and awaits must not alter
365
+ // the expected snapshot that will be compared during atomic admission.
366
+ const mission = structuredClone(stored);
367
+ if (!["draft", "scheduled", "recurring", "failed", "cancelled"].includes(mission.status)) {
368
+ throw new Error(`Cannot execute mission in "${mission.status}" state`);
369
+ }
370
+ const sequence = (mission.executionCount ?? 0) + 1;
371
+ const group = sequence > 1 ? `${mission.name} #${sequence}` : mission.name;
372
+ const doc = parseMissionDocument(JSON.parse(mission.data));
373
+ const hook = await this.ctx.hooks.runBefore("mission:execute", { missionId, mission: structuredClone(mission), taskCount: doc.tasks.length });
374
+ if (hook.cancelled)
375
+ throw new Error(`Mission execution blocked by hook: ${hook.cancelReason ?? "no reason"}`);
376
+ const existingAgents = await this.agentMgr.getAgents();
377
+ const volatileAgents = this.ctx.config.settings.enableVolatileTeams === false ? [] : (doc.team ?? []).filter(agent => {
378
+ if (!agent || typeof agent.name !== "string" || !agent.name.trim())
379
+ throw new Error("Mission team requires valid agent names");
380
+ return !existingAgents.some(existing => existing.name === agent.name);
381
+ });
382
+ const agents = [...existingAgents, ...volatileAgents];
383
+ // Resolve all dependencies, including forward references, before writing.
384
+ const byTitle = new Map(doc.tasks.map(task => [task.title, task]));
385
+ const ordered = [], visited = new Set(), visiting = new Set();
386
+ const visit = (title) => {
387
+ if (visited.has(title))
388
+ return;
389
+ if (visiting.has(title))
390
+ throw new Error(`Cyclic Mission dependency at "${title}"`);
391
+ const task = byTitle.get(title);
392
+ if (!task)
393
+ throw new Error(`Unknown Mission dependency "${title}"`);
394
+ visiting.add(title);
395
+ for (const dependency of task.dependsOn ?? [])
396
+ visit(dependency);
397
+ visiting.delete(title);
398
+ visited.add(title);
399
+ ordered.push(task);
400
+ };
401
+ for (const task of doc.tasks)
402
+ visit(task.title);
403
+ const createdAt = new Date().toISOString(), executionId = nanoid();
404
+ const titleToId = new Map(), tasks = [];
405
+ const referencedModels = [];
406
+ const taskTitles = [];
407
+ for (const authored of ordered) {
408
+ const prepared = await this.taskMgr.prepareTask({
409
+ title: authored.title, description: authored.description || authored.title,
410
+ assignTo: authored.assignTo || agents[0]?.name || "default",
411
+ dependsOn: (authored.dependsOn ?? []).map(title => titleToId.get(title)),
412
+ expectations: authored.expectations, expectedOutcomes: authored.expectedOutcomes,
413
+ group, missionId, user: mission.user, metadata: mission.metadata, maxDuration: authored.maxDuration,
414
+ retryPolicy: authored.retryPolicy, notifications: authored.notifications, sideEffects: authored.sideEffects,
415
+ });
416
+ const agent = agents.find(candidate => candidate.name === prepared.assignTo);
417
+ if (!agent)
418
+ throw new Error(`Mission references agent "${prepared.assignTo}" (task "${prepared.title}") but no such agent exists. Available agents: ${agents.map(agent => agent.name).join(", ")}`);
419
+ if (agent.model)
420
+ referencedModels.push(...resolveConfiguredModelSelection(agent.model, this.ctx.config.settings, agent.allowedModelProfiles).policy.candidates);
421
+ const task = { ...prepared, id: nanoid(), status: "pending", retries: 0, createdAt, updatedAt: createdAt,
422
+ missionId, group, user: mission.user, metadata: normalizeRuntimeMetadata(mission.metadata), ...(persistence ? { missionExecutionId: executionId } : {}) };
423
+ tasks.push(task);
424
+ titleToId.set(authored.title, task.id);
425
+ taskTitles.push({ authored: authored.title, accepted: task.title });
426
+ }
427
+ if (referencedModels.length && this.ctx.validateProviderKeys) {
428
+ const missing = this.ctx.validateProviderKeys(referencedModels);
429
+ if (missing.length)
430
+ throw new Error(`Missing API keys for providers: ${missing.map(model => `${model.provider} (model: ${model.modelSpec})`).join(", ")}. Set the corresponding environment variables or add them to the project.json providers section.`);
431
+ }
432
+ if (persistence) {
433
+ const result = await persistence.commitMissionExecution({ expected: mission, notifications: doc.notifications,
434
+ runtime: { volatileAgents, taskTitles }, receipt: { executionId, missionId, idempotencyKey: options.idempotencyKey ?? `execution:${executionId}`,
435
+ group, sequence, user: mission.user, metadata: normalizeRuntimeMetadata(mission.metadata), createdAt, tasks } });
436
+ admitted = true;
437
+ if (result.created) {
438
+ await this.prepareTaskExecution(result.receipt.tasks[0]);
439
+ for (const task of result.receipt.tasks)
440
+ this.taskMgr.notifyTaskCreated(task);
441
+ this.ctx.emitter.emit("mission:executed", { missionId, group, taskCount: result.receipt.tasks.length });
442
+ }
443
+ return { tasks: result.receipt.tasks, group: result.receipt.group, executionId: result.receipt.executionId };
444
+ }
445
+ // Compatibility for custom stores with only ordinary CRUD. Explicit
446
+ // idempotency is refused above; these writes do not claim atomic admission.
447
+ for (const agent of volatileAgents)
448
+ await this.agentMgr.addVolatileAgent(agent, group);
449
+ this.gatesByGroup.set(group, doc.qualityGates ?? []);
450
+ if (doc.checkpoints?.length)
451
+ await this.gating.setCheckpointDefinitions(group, doc.checkpoints);
452
+ if (doc.delays?.length)
453
+ await this.gating.setDelayDefinitions(group, doc.delays);
454
+ if (mission.status === "scheduled" || mission.status === "recurring")
455
+ this.scheduledOrigin.set(group, mission.status);
456
+ const actualIds = new Map(), created = [];
457
+ for (const task of tasks) {
458
+ const storedTask = await this.ctx.taskStore.createTask({ ...task, dependsOn: task.dependsOn.map(id => actualIds.get(id)) });
459
+ actualIds.set(task.id, storedTask.id);
460
+ created.push(storedTask);
461
+ this.taskMgr.notifyTaskCreated(storedTask);
462
+ }
463
+ await this.missions.updateMission(missionId, { status: "active", executionCount: sequence, ...(doc.notifications ? { notifications: doc.notifications } : {}) });
464
+ this.ctx.emitter.emit("mission:executed", { missionId, group, taskCount: created.length });
465
+ return { tasks: created, group };
342
466
  }
343
- else {
344
- // Mark mission as active
345
- await this.missions.updateMission(missionId, { status: "active" });
467
+ catch (error) {
468
+ // Another caller may win after our initial lookup but before preflight.
469
+ // Its accepted receipt also wins over active-state/title/hook conflicts.
470
+ if (!admitted && persistence && options.idempotencyKey !== undefined) {
471
+ const accepted = await persistence.getMissionExecution(missionId, options.idempotencyKey);
472
+ if (accepted)
473
+ return { tasks: accepted.tasks, group: accepted.group, executionId: accepted.executionId };
474
+ }
475
+ throw error;
346
476
  }
347
- this.ctx.emitter.emit("mission:executed", { missionId, group, taskCount: tasks.length });
348
- return { tasks, group };
349
477
  }
350
478
  /** Check if any mission groups have all tasks terminal, and clean up their volatile agents */
351
479
  async cleanupCompletedGroups(tasks) {
352
480
  const groups = new Set();
353
481
  for (const t of tasks) {
354
- if (t.group)
355
- groups.add(t.group);
482
+ const group = t.missionExecutionId ?? t.group;
483
+ if (group)
484
+ groups.add(group);
356
485
  }
357
486
  for (const group of groups) {
358
- const groupTasks = tasks.filter(t => t.group === group);
487
+ const groupTasks = tasks.filter(t => (t.missionExecutionId ?? t.group) === group);
488
+ const label = groupTasks[0]?.group ?? group;
359
489
  const allTerminal = groupTasks.every(t => t.status === "done" || t.status === "failed");
360
490
  // If tasks went back to non-terminal (e.g. individual retry via retryTask),
361
491
  // clear the cleaned flag so the group will be re-evaluated when done again.
@@ -367,18 +497,17 @@ export class MissionExecutor {
367
497
  continue;
368
498
  if (!allTerminal)
369
499
  continue;
370
- const cleanupPolicy = this.ctx.config.settings.volatileCleanup ?? "on_complete";
371
- if (cleanupPolicy === "on_complete") {
372
- await this.agentMgr.cleanupVolatileAgents(group);
373
- }
374
- this.cleanedGroups.add(group);
500
+ const capturedTask = groupTasks.find(task => task.missionExecutionId);
501
+ if (capturedTask)
502
+ await this.prepareTaskExecution(capturedTask);
375
503
  // Auto-update mission status
376
504
  const mission = await resolveMissionForGroup(this.missions, groupTasks, group);
377
- if (mission && mission.status === "active") {
505
+ const admission = capturedTask ? await resolveMissionExecutionStore(this.ctx)?.getMissionExecutionById(capturedTask.missionExecutionId) : undefined;
506
+ if (mission && mission.status === "active" && (!admission || admission.receipt.sequence === mission.executionCount)) {
378
507
  let allDone = groupTasks.every(t => t.status === "done");
379
508
  // Check mission quality threshold (only if all tasks passed structurally)
380
509
  if (allDone && this.qualityCtrl) {
381
- const thresholdResult = this.qualityCtrl.checkMissionThreshold(mission, groupTasks, this.ctx.config.settings.defaultQualityThreshold);
510
+ const thresholdResult = this.qualityCtrl.checkMissionThreshold(admission?.expected ?? mission, groupTasks, this.ctx.config.settings.defaultQualityThreshold);
382
511
  if (!thresholdResult.passed) {
383
512
  allDone = false; // Quality threshold not met — mark mission as failed
384
513
  this.ctx.emitter.emit("log", {
@@ -402,17 +531,24 @@ export class MissionExecutor {
402
531
  // Normal missions or successful one-shot scheduled missions
403
532
  finalStatus = allDone ? "completed" : "failed";
404
533
  }
405
- await this.missions.updateMission(mission.id, { status: finalStatus });
406
- const report = await this.buildMissionReport(mission.id, group, groupTasks, allDone);
407
- this.ctx.emitter.emit("mission:completed", { missionId: mission.id, group, allPassed: allDone, report });
408
- // Aggregate mission metrics
409
- this.qualityCtrl?.aggregateMissionMetrics(mission.id, groupTasks);
410
- // Clean up gate, checkpoint, delay, and scheduled-origin caches
411
- this.gatesByGroup.delete(group);
412
- this.scheduledOrigin.delete(group);
413
- // Clean up persisted checkpoint and delay entries for this group
414
- await this.gating.removeGroup(group);
534
+ const finalized = admission
535
+ ? await resolveMissionExecutionStore(this.ctx).finalizeMissionExecution(admission.receipt.executionId, finalStatus)
536
+ : !!await this.missions.updateMission(mission.id, { status: finalStatus });
537
+ // A retry may have made tasks runnable since this cleanup snapshot.
538
+ // Keep definitions/agents and retry from fresh state on the next tick.
539
+ if (!finalized)
540
+ continue;
541
+ if (finalized) {
542
+ const report = await this.buildMissionReport(mission.id, label, groupTasks, allDone);
543
+ this.ctx.emitter.emit("mission:completed", { missionId: mission.id, group: label, allPassed: allDone, report });
544
+ this.qualityCtrl?.aggregateMissionMetrics(mission.id, groupTasks);
545
+ }
415
546
  }
547
+ if ((this.ctx.config.settings.volatileCleanup ?? "on_complete") === "on_complete") {
548
+ await this.agentMgr.cleanupVolatileAgents(group);
549
+ }
550
+ await this.forgetExecution(group);
551
+ this.cleanedGroups.add(group);
416
552
  }
417
553
  }
418
554
  // ═══════════════════════════════════════════════════════