@polpo-ai/core 0.15.143 → 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 (180) 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 +20 -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/data/data.test.d.ts +2 -0
  55. package/dist/data/data.test.d.ts.map +1 -0
  56. package/dist/data/data.test.js +233 -0
  57. package/dist/data/data.test.js.map +1 -0
  58. package/dist/data/index.d.ts +384 -0
  59. package/dist/data/index.d.ts.map +1 -0
  60. package/dist/data/index.js +598 -0
  61. package/dist/data/index.js.map +1 -0
  62. package/dist/escalation-manager.d.ts +2 -0
  63. package/dist/escalation-manager.d.ts.map +1 -1
  64. package/dist/escalation-manager.js +79 -25
  65. package/dist/escalation-manager.js.map +1 -1
  66. package/dist/events.d.ts +4 -0
  67. package/dist/events.d.ts.map +1 -1
  68. package/dist/hooks.d.ts.map +1 -1
  69. package/dist/hooks.js +6 -0
  70. package/dist/hooks.js.map +1 -1
  71. package/dist/index.d.ts +19 -3
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +13 -1
  74. package/dist/index.js.map +1 -1
  75. package/dist/mcp-capability.d.ts +40 -0
  76. package/dist/mcp-capability.d.ts.map +1 -0
  77. package/dist/mcp-capability.js +2 -0
  78. package/dist/mcp-capability.js.map +1 -0
  79. package/dist/mcp-runner.d.ts +45 -0
  80. package/dist/mcp-runner.d.ts.map +1 -0
  81. package/dist/mcp-runner.js +238 -0
  82. package/dist/mcp-runner.js.map +1 -0
  83. package/dist/mission-cancellation.d.ts +14 -0
  84. package/dist/mission-cancellation.d.ts.map +1 -0
  85. package/dist/mission-cancellation.js +43 -0
  86. package/dist/mission-cancellation.js.map +1 -0
  87. package/dist/mission-execution.d.ts +103 -0
  88. package/dist/mission-execution.d.ts.map +1 -0
  89. package/dist/mission-execution.js +196 -0
  90. package/dist/mission-execution.js.map +1 -0
  91. package/dist/mission-executor.d.ts +18 -16
  92. package/dist/mission-executor.d.ts.map +1 -1
  93. package/dist/mission-executor.js +339 -203
  94. package/dist/mission-executor.js.map +1 -1
  95. package/dist/mission-gating.d.ts +11 -15
  96. package/dist/mission-gating.d.ts.map +1 -1
  97. package/dist/mission-gating.js +25 -33
  98. package/dist/mission-gating.js.map +1 -1
  99. package/dist/mission-inspection.d.ts +29 -0
  100. package/dist/mission-inspection.d.ts.map +1 -0
  101. package/dist/mission-inspection.js +99 -0
  102. package/dist/mission-inspection.js.map +1 -0
  103. package/dist/mission-store.d.ts +7 -0
  104. package/dist/mission-store.d.ts.map +1 -1
  105. package/dist/mission-store.js +8 -0
  106. package/dist/mission-store.js.map +1 -1
  107. package/dist/orchestrator-context.d.ts +3 -0
  108. package/dist/orchestrator-context.d.ts.map +1 -1
  109. package/dist/orchestrator-engine.d.ts +39 -39
  110. package/dist/orchestrator-engine.d.ts.map +1 -1
  111. package/dist/orchestrator-engine.js +124 -24
  112. package/dist/orchestrator-engine.js.map +1 -1
  113. package/dist/run-state-monitor.d.ts +14 -0
  114. package/dist/run-state-monitor.d.ts.map +1 -0
  115. package/dist/run-state-monitor.js +63 -0
  116. package/dist/run-state-monitor.js.map +1 -0
  117. package/dist/run-store.d.ts +15 -0
  118. package/dist/run-store.d.ts.map +1 -1
  119. package/dist/runner-capability.d.ts +43 -0
  120. package/dist/runner-capability.d.ts.map +1 -0
  121. package/dist/runner-capability.js +55 -0
  122. package/dist/runner-capability.js.map +1 -0
  123. package/dist/runtime-metadata.d.ts +5 -0
  124. package/dist/runtime-metadata.d.ts.map +1 -0
  125. package/dist/runtime-metadata.js +21 -0
  126. package/dist/runtime-metadata.js.map +1 -0
  127. package/dist/scheduler.d.ts +2 -1
  128. package/dist/scheduler.d.ts.map +1 -1
  129. package/dist/scheduler.js +16 -6
  130. package/dist/scheduler.js.map +1 -1
  131. package/dist/schemas.d.ts +8 -6
  132. package/dist/schemas.d.ts.map +1 -1
  133. package/dist/schemas.js +10 -0
  134. package/dist/schemas.js.map +1 -1
  135. package/dist/spawner.d.ts +12 -0
  136. package/dist/spawner.d.ts.map +1 -1
  137. package/dist/steering.d.ts +4 -1
  138. package/dist/steering.d.ts.map +1 -1
  139. package/dist/steering.js.map +1 -1
  140. package/dist/task-approval-coordinator.d.ts +29 -0
  141. package/dist/task-approval-coordinator.d.ts.map +1 -0
  142. package/dist/task-approval-coordinator.js +216 -0
  143. package/dist/task-approval-coordinator.js.map +1 -0
  144. package/dist/task-dispatch.d.ts +40 -0
  145. package/dist/task-dispatch.d.ts.map +1 -0
  146. package/dist/task-dispatch.js +136 -0
  147. package/dist/task-dispatch.js.map +1 -0
  148. package/dist/task-execution.d.ts +146 -0
  149. package/dist/task-execution.d.ts.map +1 -0
  150. package/dist/task-execution.js +357 -0
  151. package/dist/task-execution.js.map +1 -0
  152. package/dist/task-manager.d.ts +31 -22
  153. package/dist/task-manager.d.ts.map +1 -1
  154. package/dist/task-manager.js +74 -7
  155. package/dist/task-manager.js.map +1 -1
  156. package/dist/task-runner.d.ts +30 -3
  157. package/dist/task-runner.d.ts.map +1 -1
  158. package/dist/task-runner.js +429 -90
  159. package/dist/task-runner.js.map +1 -1
  160. package/dist/task-store.d.ts +9 -3
  161. package/dist/task-store.d.ts.map +1 -1
  162. package/dist/tool-invocation.d.ts +4 -0
  163. package/dist/tool-invocation.d.ts.map +1 -1
  164. package/dist/tool-invocation.js +8 -1
  165. package/dist/tool-invocation.js.map +1 -1
  166. package/dist/tool-invocation.test.d.ts +2 -0
  167. package/dist/tool-invocation.test.d.ts.map +1 -0
  168. package/dist/tool-invocation.test.js +40 -0
  169. package/dist/tool-invocation.test.js.map +1 -0
  170. package/dist/types/agent.d.ts +4 -0
  171. package/dist/types/agent.d.ts.map +1 -1
  172. package/dist/types/agent.js.map +1 -1
  173. package/dist/types/config.d.ts +3 -0
  174. package/dist/types/config.d.ts.map +1 -1
  175. package/dist/types/config.js.map +1 -1
  176. package/dist/types/mission.d.ts +23 -1
  177. package/dist/types/mission.d.ts.map +1 -1
  178. package/dist/types/task.d.ts +10 -0
  179. package/dist/types/task.d.ts.map +1 -1
  180. package/package.json +21 -1
@@ -1,11 +1,16 @@
1
1
  import { nanoid } from "nanoid";
2
- import { resolveMissionStore, resolveMissionForTask } from "./mission-store.js";
2
+ import { isVersionedAgentStore, agentIdentityFromSnapshot, assertAgentIdentity, AgentIdentityError } from "./agent-store.js";
3
+ import { resolveMissionStore, resolveMissionForTask, resolveMissionExecutionStore } from "./mission-store.js";
4
+ import { MissionExecutionError } from "./mission-execution.js";
3
5
  import { agentMemoryScope } from "./memory-store.js";
4
6
  import { resolveExecutionMode } from "./execution-mode.js";
5
7
  import { resolveRuntimeSandboxOptions } from "./runtime-sandbox.js";
8
+ import { normalizeRuntimeMetadata } from "./runtime-metadata.js";
6
9
  import { resolveConfiguredModelSelection } from "./model-profiles.js";
7
10
  import { createRuntimePromptContextSegment, resolveRuntimeContext, replacesLegacyAgentMemory, replacesLegacySharedMemory, } from "./runtime-context/index.js";
8
11
  import { createExecutionRouteResolvedEvent, resolveTaskExecutionRoute, } from "./execution-router.js";
12
+ import { isConditionalTaskExecutionStore, isTaskExecutionStore, taskExecutionReference, TaskExecutionError } from "./task-execution.js";
13
+ import { claimTaskDispatch, authorizeTaskDispatch, isTaskDispatchActive } from "./task-dispatch.js";
9
14
  /**
10
15
  * Durable turns: max age of a resume checkpoint before orphan recovery
11
16
  * ignores it and falls back to retry-from-zero. Crash/deploy restarts are
@@ -53,6 +58,131 @@ function pathDirname(p) {
53
58
  */
54
59
  export class TaskRunner {
55
60
  ctx;
61
+ closing = false;
62
+ closePromise;
63
+ preparations = new Set();
64
+ owners = new Map();
65
+ pendingSpawns = new Map();
66
+ /** Interrupted preparations retain their receipt until failure persistence
67
+ * and acknowledgement finish, or ownership transfers to a SpawnResult. */
68
+ interruptedPreparations = new Set();
69
+ /** Stop admission synchronously; in-flight preparation remains owned. */
70
+ stop() { this.closing = true; }
71
+ /** Drain only locally spawned owners. A terminal store row is not an exit
72
+ * receipt. Hosts may bound this wait, but must keep stores open on timeout. */
73
+ close() {
74
+ this.stop();
75
+ if (this.closePromise)
76
+ return this.closePromise;
77
+ const drain = this.drainOwners();
78
+ this.closePromise = drain;
79
+ void drain.catch(() => { if (this.closePromise === drain)
80
+ this.closePromise = undefined; });
81
+ return drain;
82
+ }
83
+ async drainOwners() {
84
+ const initial = new Set(this.owners.keys());
85
+ const stopping = Promise.allSettled([...initial].map(id => this.stopOwner(id)));
86
+ // A pending spawn may need cancellation to finish acquisition/cleanup. Do
87
+ // not wait for its preparation before notifying the exact local owner.
88
+ // Report a failed notification immediately so close() can be retried even
89
+ // while a sibling cancellation is pending. Each sibling retains its own
90
+ // single-flight promise; rejecting this drain does not abandon that work.
91
+ await Promise.all([...this.pendingSpawns.keys()].map(id => this.stopPendingSpawn(id)));
92
+ const stopped = await stopping;
93
+ const stopFailure = stopped.find(result => result.status === "rejected");
94
+ if (stopFailure?.status === "rejected")
95
+ throw stopFailure.reason;
96
+ await Promise.allSettled([...this.preparations]);
97
+ const late = await Promise.allSettled([...this.owners.keys()].filter(id => !initial.has(id)).map(id => this.stopOwner(id)));
98
+ const failed = late.find(result => result.status === "rejected");
99
+ if (failed?.status === "rejected")
100
+ throw failed.reason;
101
+ // spawn() rejection is a drained preparation, not a fabricated process
102
+ // exit. The host must settle its owned effects before rejecting spawn().
103
+ for (const runId of this.interruptedPreparations) {
104
+ const run = await this.ctx.runStore.getRun(runId);
105
+ if (run?.status === "killed" && run.result?.stderr === "Killed during shutdown")
106
+ await this.acknowledgeRun(run);
107
+ this.interruptedPreparations.delete(runId);
108
+ }
109
+ const owners = [...this.owners.values()];
110
+ if (owners.some(owner => !owner.exited))
111
+ throw new Error("Runner shutdown requires an onExit receipt from the spawner");
112
+ await Promise.all(owners.map(owner => owner.exited));
113
+ }
114
+ stopPendingSpawn(runId) {
115
+ const pending = this.pendingSpawns.get(runId);
116
+ if (!pending)
117
+ return Promise.resolve();
118
+ if (pending.stopping)
119
+ return pending.stopping;
120
+ const stopping = (async () => {
121
+ if (!this.ctx.spawner.cancelRun)
122
+ throw new Error("Pending runner shutdown requires owner-scoped cancelRun from the spawner");
123
+ this.interruptedPreparations.add(runId);
124
+ await this.ctx.runStore.completeRun(runId, "killed", {
125
+ exitCode: 1, stdout: "", stderr: "Killed during shutdown", duration: 0,
126
+ });
127
+ await this.ctx.spawner.cancelRun(runId);
128
+ })();
129
+ pending.stopping = stopping;
130
+ void stopping.catch(() => { if (pending.stopping === stopping)
131
+ pending.stopping = undefined; });
132
+ return stopping;
133
+ }
134
+ stopOwner(runId) {
135
+ const owner = this.owners.get(runId);
136
+ if (!owner)
137
+ return Promise.resolve();
138
+ if (owner.stopping)
139
+ return owner.stopping;
140
+ const stopping = this.drainOwner(runId, owner);
141
+ owner.stopping = stopping;
142
+ void stopping.catch(() => { if (owner.stopping === stopping)
143
+ owner.stopping = undefined; });
144
+ return stopping;
145
+ }
146
+ async drainOwner(runId, owner) {
147
+ if (!owner.exited || !this.ctx.spawner.cancelRun) {
148
+ throw new Error("Runner shutdown requires owner-scoped cancelRun and an onExit receipt from the spawner");
149
+ }
150
+ // Keep the receipt after exit until post-exit persistence is confirmed.
151
+ // A failed acknowledgement must be retried before storage can close.
152
+ owner.shutdownPending = true;
153
+ await this.ctx.runStore.completeRun(runId, "killed", {
154
+ exitCode: 1, stdout: "", stderr: "Killed during shutdown", duration: 0,
155
+ });
156
+ if (!owner.exitConfirmed)
157
+ await this.ctx.spawner.cancelRun(runId);
158
+ await owner.exited;
159
+ const run = await this.ctx.runStore.getRun(runId);
160
+ // A competing successful/failed completion wins and remains collectable.
161
+ if (run?.status === "killed" && run.result?.stderr === "Killed during shutdown")
162
+ await this.acknowledgeRun(run);
163
+ if (this.owners.get(runId) === owner)
164
+ this.owners.delete(runId);
165
+ }
166
+ ownSpawn(runId, taskId, handle) {
167
+ const owner = { exitConfirmed: false, shutdownPending: this.closing };
168
+ this.owners.set(runId, owner);
169
+ if (handle.onExit)
170
+ owner.exited = new Promise(resolve => {
171
+ handle.onExit(() => {
172
+ owner.exitConfirmed = true;
173
+ if (!owner.shutdownPending && this.owners.get(runId) === owner)
174
+ this.owners.delete(runId);
175
+ try {
176
+ this.ctx.emitter.emit("run:exited", { taskId, runId, pid: handle.pid });
177
+ }
178
+ finally {
179
+ resolve();
180
+ }
181
+ });
182
+ });
183
+ }
184
+ approvals;
185
+ setApprovalManager(manager) { this.approvals = manager; }
56
186
  staleWarned = new Set();
57
187
  /** Last known activity snapshot per taskId, used to diff and emit SSE events */
58
188
  lastActivity = new Map();
@@ -69,6 +199,8 @@ export class TaskRunner {
69
199
  constructor(ctx) {
70
200
  this.ctx = ctx;
71
201
  }
202
+ prepareMissionExecution;
203
+ setMissionExecutionPreparer(prepare) { this.prepareMissionExecution = prepare; }
72
204
  async resolveTaskExecutionRoute(task, agent) {
73
205
  const route = await resolveTaskExecutionRoute({ task, agent }, {
74
206
  getProjectLoop: this.ctx.getProjectLoop,
@@ -84,12 +216,56 @@ export class TaskRunner {
84
216
  * The callback is typically the assessment pipeline (handleResult).
85
217
  */
86
218
  async collectResults(onResult) {
219
+ if (this.closing)
220
+ return;
87
221
  const terminalRuns = await this.ctx.runStore.getTerminalRuns();
88
222
  for (const run of terminalRuns) {
223
+ // Shutdown interruptions belong to recovery, not ordinary assessment.
224
+ // An assessment already admitted before stop() is still drained by the host.
225
+ if (this.closing)
226
+ return;
227
+ let execution;
228
+ const sid = run.sessionId ?? run.activity.sessionId;
229
+ if (run.taskExecutionRevision !== undefined) {
230
+ if (!isTaskExecutionStore(this.ctx.taskStore)) {
231
+ throw new TaskExecutionError("task_execution_unsupported", "Result collection requires execution persistence");
232
+ }
233
+ execution = { taskId: run.taskId, revision: run.taskExecutionRevision, runId: run.id };
234
+ try {
235
+ await this.ctx.taskStore.mutateTaskExecution(execution, { type: "patch", updates: {
236
+ ...(sid ? { sessionId: sid } : {}), ...(run.outcomes ? { outcomes: run.outcomes } : {}),
237
+ } });
238
+ }
239
+ catch (error) {
240
+ if (!(error instanceof TaskExecutionError) || error.code !== "task_execution_conflict")
241
+ throw error;
242
+ await this.acknowledgeRun(run);
243
+ continue;
244
+ }
245
+ }
246
+ else if (isTaskExecutionStore(this.ctx.taskStore)) {
247
+ const task = await this.ctx.taskStore.getTask(run.taskId);
248
+ if (!task || task.executionRevision !== undefined || task.activeRunId !== undefined) {
249
+ // An old, unversioned writer cannot attach its result to a new attempt.
250
+ await this.acknowledgeRun(run);
251
+ continue;
252
+ }
253
+ execution = taskExecutionReference(task);
254
+ try {
255
+ await this.ctx.taskStore.mutateTaskExecution(execution, { type: "patch", updates: {
256
+ ...(sid ? { sessionId: sid } : {}), ...(run.outcomes ? { outcomes: run.outcomes } : {}),
257
+ } });
258
+ }
259
+ catch (error) {
260
+ if (!(error instanceof TaskExecutionError) || error.code !== "task_execution_conflict")
261
+ throw error;
262
+ await this.acknowledgeRun(run);
263
+ continue;
264
+ }
265
+ }
89
266
  await this.emitGuardrailEvents(run);
90
267
  // Persist sessionId on the task before acknowledging the run.
91
- const sid = run.sessionId ?? run.activity.sessionId;
92
- if (sid) {
268
+ if (!execution && sid) {
93
269
  try {
94
270
  await this.ctx.taskStore.updateTask(run.taskId, { sessionId: sid });
95
271
  }
@@ -98,13 +274,13 @@ export class TaskRunner {
98
274
  // Persist auto-collected outcomes on the task.
99
275
  // REPLACE (not append) — each execution produces its own definitive outcomes.
100
276
  // Appending caused "exponential outcome" accumulation across retries/fix cycles.
101
- if (run.outcomes && run.outcomes.length > 0) {
277
+ if (!execution && run.outcomes && run.outcomes.length > 0) {
102
278
  try {
103
279
  await this.ctx.taskStore.updateTask(run.taskId, { outcomes: run.outcomes });
104
280
  }
105
281
  catch { /* task may already be gone */ }
106
282
  }
107
- const result = run.result ?? {
283
+ const result = run.result ? { ...run.result } : {
108
284
  exitCode: 1,
109
285
  stdout: "",
110
286
  stderr: `Run ${run.id} ended with status ${run.status} without a result`,
@@ -126,19 +302,21 @@ export class TaskRunner {
126
302
  result.stderr = (result.stderr ? result.stderr + "\n" : "") + diagnosis;
127
303
  }
128
304
  }
129
- await onResult(run.taskId, result);
130
- }
131
- // Keep terminal Runs as durable history. Older/custom stores can keep
132
- // their previous consume-and-delete behavior until they implement ack.
133
- if (this.ctx.runStore.markRunCollected) {
134
- await this.ctx.runStore.markRunCollected(run.id);
135
- }
136
- else {
137
- await this.ctx.runStore.deleteRun(run.id);
305
+ if (execution)
306
+ await onResult(run.taskId, result, execution);
307
+ else
308
+ await onResult(run.taskId, result);
138
309
  }
139
- this.staleWarned.delete(run.taskId);
310
+ await this.acknowledgeRun(run);
140
311
  }
141
312
  }
313
+ async acknowledgeRun(run) {
314
+ if (this.ctx.runStore.markRunCollected)
315
+ await this.ctx.runStore.markRunCollected(run.id);
316
+ else
317
+ await this.ctx.runStore.deleteRun(run.id);
318
+ this.staleWarned.delete(run.taskId);
319
+ }
142
320
  async emitGuardrailEvents(run) {
143
321
  const byId = new Map();
144
322
  const sid = run.sessionId ?? run.activity.sessionId;
@@ -383,9 +561,38 @@ export class TaskRunner {
383
561
  * Resets orphaned tasks to pending WITHOUT burning retry count.
384
562
  */
385
563
  async recoverOrphanedTasks() {
564
+ let recovered = 0;
565
+ const orphanStates = new Set(["assigned", "in_progress", "review"]);
386
566
  // Check RunStore active runs first
387
567
  const activeRuns = await this.ctx.runStore.getActiveRuns();
388
568
  for (const run of activeRuns) {
569
+ if (run.taskExecutionRevision !== undefined) {
570
+ const store = this.ctx.taskStore;
571
+ if (!isTaskExecutionStore(store))
572
+ throw new TaskExecutionError("task_execution_unsupported", "Recovery requires execution persistence");
573
+ const task = await store.getTask(run.taskId);
574
+ if (!task || task.activeRunId !== run.id || task.executionRevision !== run.taskExecutionRevision
575
+ || !orphanStates.has(task.status) || this.isProcessAlive(run.pid))
576
+ continue;
577
+ try {
578
+ const mutation = { type: "requeue", countRetry: false, reason: "Orphan recovery", onlyIfRunRunning: true };
579
+ const changed = isConditionalTaskExecutionStore(store)
580
+ ? await store.mutateTaskExecutionConditionally(taskExecutionReference(task), mutation, [{ taskId: task.id, snapshot: task }])
581
+ : await store.mutateTaskExecution(taskExecutionReference(task), mutation);
582
+ if (changed.run)
583
+ await this.acknowledgeRun(changed.run);
584
+ const checkpoint = usableCheckpoint(changed.run?.resumeState);
585
+ if (checkpoint)
586
+ this.pendingResume.set(task.id, { state: checkpoint, agentIdentity: changed.run?.config?.agentIdentity });
587
+ this.ctx.emitter.emit("task:recovered", { taskId: task.id, title: task.title, previousStatus: task.status });
588
+ recovered++;
589
+ }
590
+ catch (error) {
591
+ if (!(error instanceof TaskExecutionError) || error.code !== "task_execution_conflict")
592
+ throw error;
593
+ }
594
+ continue;
595
+ }
389
596
  if (this.isProcessAlive(run.pid)) {
390
597
  // Runner still alive — leave it running, work is NOT lost!
391
598
  this.ctx.emitter.emit("log", { level: "info", message: `Runner PID ${run.pid} still alive for task ${run.taskId} — reconnecting` });
@@ -395,7 +602,7 @@ export class TaskRunner {
395
602
  // so the respawn resumes at turn + 1 instead of starting over.
396
603
  const checkpoint = usableCheckpoint(run.resumeState);
397
604
  if (checkpoint) {
398
- this.pendingResume.set(run.taskId, checkpoint);
605
+ this.pendingResume.set(run.taskId, { state: checkpoint, agentIdentity: run.config?.agentIdentity });
399
606
  this.ctx.emitter.emit("log", {
400
607
  level: "info",
401
608
  message: `[${run.taskId}] Runner died mid-run — checkpoint at turn ${checkpoint.turn + 1} saved for resume`,
@@ -415,30 +622,53 @@ export class TaskRunner {
415
622
  }
416
623
  }
417
624
  }
418
- // Backward compat: kill orphan OS processes from old processes table
419
- const state = await this.ctx.taskStore.getState();
420
- for (const proc of state.processes) {
421
- if (proc.pid > 0 && proc.alive) {
422
- this.killOrphanProcess(proc.pid, proc.agentName);
423
- }
424
- }
625
+ // Persisted process-list PIDs are not local ownership receipts. Do not
626
+ // signal them: a PID may have been reused or belong to another supervisor.
425
627
  const tasks = await this.ctx.taskStore.listTasks();
426
- const orphanStates = new Set(["assigned", "in_progress", "review"]);
427
- let recovered = 0;
428
628
  for (const task of tasks) {
429
629
  if (!orphanStates.has(task.status))
430
630
  continue;
431
631
  // Check if there's a live runner for this task
432
- const run = await this.ctx.runStore.getRunByTaskId(task.id);
433
- if (run && run.status === "running" && this.isProcessAlive(run.pid)) {
632
+ const run = task.activeRunId ? await this.ctx.runStore.getRun(task.activeRunId)
633
+ : await this.ctx.runStore.getRunByTaskId(task.id);
634
+ const matches = run && (run.taskExecutionRevision === undefined
635
+ ? task.executionRevision === undefined && task.activeRunId === undefined
636
+ : run.taskExecutionRevision === task.executionRevision && run.id === task.activeRunId);
637
+ // Completed work must still traverse assessment and completion approvals.
638
+ // Preserve failed results too: their retry policy is not orphan recovery.
639
+ if (matches && run.status !== "running" && !run.collectedAt)
640
+ continue;
641
+ if (matches && run.status === "running" && this.isProcessAlive(run.pid)) {
434
642
  // Runner still working — skip recovery for this task
435
643
  continue;
436
644
  }
437
645
  // Recover: reset to pending WITHOUT incrementing retries.
438
646
  // Shutdown interrupts are not real failures — unsafeSetStatus bypasses
439
647
  // transition(failed → pending) which would burn a retry.
648
+ const store = this.ctx.taskStore;
649
+ try {
650
+ if (isTaskExecutionStore(store)) {
651
+ const mutation = { type: "requeue", countRetry: false, reason: "Orphan recovery", ...(run?.status === "running" ? { onlyIfRunRunning: true } : {}) };
652
+ if (isConditionalTaskExecutionStore(store))
653
+ await store.mutateTaskExecutionConditionally(taskExecutionReference(task), mutation, [{ taskId: task.id, snapshot: task }]);
654
+ else
655
+ await store.mutateTaskExecution(taskExecutionReference(task), mutation);
656
+ }
657
+ else {
658
+ await store.unsafeSetStatus(task.id, "pending", "orphan recovery — shutdown interrupt");
659
+ }
660
+ }
661
+ catch (error) {
662
+ if (error instanceof TaskExecutionError && error.code === "task_execution_conflict")
663
+ continue;
664
+ throw error;
665
+ }
666
+ if (matches && run.collectedAt) {
667
+ const checkpoint = usableCheckpoint(run.resumeState);
668
+ if (checkpoint)
669
+ this.pendingResume.set(task.id, { state: checkpoint, agentIdentity: run.config?.agentIdentity });
670
+ }
440
671
  this.ctx.emitter.emit("task:recovered", { taskId: task.id, title: task.title, previousStatus: task.status });
441
- await this.ctx.taskStore.unsafeSetStatus(task.id, "pending", "orphan recovery — shutdown interrupt");
442
672
  recovered++;
443
673
  }
444
674
  // Clear stale process list
@@ -459,7 +689,11 @@ export class TaskRunner {
459
689
  return this.ctx.spawner.isAlive(pid);
460
690
  }
461
691
  /** Persist failures that happen before a host process/sandbox can start. */
462
- async failBeforeSpawn(task, message) {
692
+ async failBeforeSpawn(task, message, claimedRun) {
693
+ if (claimedRun) {
694
+ await this.ctx.runStore.completeRun(claimedRun.id, "failed", { exitCode: 1, stdout: "", stderr: message, duration: 0 });
695
+ return;
696
+ }
463
697
  await this.ctx.taskStore.transition(task.id, "assigned");
464
698
  await this.ctx.taskStore.transition(task.id, "in_progress");
465
699
  await this.ctx.taskStore.updateTask(task.id, { phase: "execution" });
@@ -488,50 +722,124 @@ export class TaskRunner {
488
722
  duration: 0,
489
723
  });
490
724
  }
491
- async spawnForTask(task) {
492
- const agent = await this.ctx.agentStore.getAgent(task.assignTo);
725
+ spawnForTask(task, overrides) {
726
+ if (this.closing)
727
+ return Promise.reject(new Error("Task runner is closing"));
728
+ const work = this.dispatchTask(task, overrides);
729
+ this.preparations.add(work);
730
+ void work.then(() => this.preparations.delete(work), () => this.preparations.delete(work));
731
+ return work;
732
+ }
733
+ async dispatchTask(task, overrides) {
734
+ if (task.missionExecutionId && !this.prepareMissionExecution) {
735
+ throw new MissionExecutionError("mission_execution_unsupported", "Mission execution must be prepared before dispatch");
736
+ }
737
+ const executions = this.ctx.taskStore;
738
+ if (!this.approvals && (task.approvalState || this.ctx.config.settings.approvalGates?.some(gate => gate.hook === "task:spawn"))) {
739
+ throw new TaskExecutionError("task_execution_unsupported", "Task approval coordinator is required");
740
+ }
741
+ if (!isTaskExecutionStore(executions)) {
742
+ if (task.activeRunId !== undefined || task.executionRevision !== undefined || task.approvalState !== undefined) {
743
+ throw new TaskExecutionError("task_execution_unsupported", "Task execution persistence is required for this Task");
744
+ }
745
+ try {
746
+ await this.prepareAndSpawn(task, undefined, overrides);
747
+ }
748
+ catch (error) {
749
+ await this.failBeforeSpawn(task, error instanceof Error ? error.message : "Task preparation failed");
750
+ }
751
+ return;
752
+ }
753
+ // Claim before any asynchronous preparation. The stored Task is the source
754
+ // of the principal, assignment and input; the scheduler's snapshot may lag.
755
+ const claimed = await claimTaskDispatch(this.ctx, task, nanoid(), this.approvals);
756
+ try {
757
+ await this.prepareAndSpawn(claimed.task, claimed.run, overrides ?? claimed.overrides);
758
+ }
759
+ catch (error) {
760
+ await this.ctx.runStore.completeRun(claimed.run.id, "failed", {
761
+ exitCode: 1, stdout: "", stderr: error instanceof Error ? error.message : "Task preparation failed", duration: 0,
762
+ });
763
+ }
764
+ }
765
+ async claimIsActive(task, run) {
766
+ return isTaskDispatchActive(this.ctx, task, run);
767
+ }
768
+ async prepareAndSpawn(task, initialRun, overrides) {
769
+ const claimed = initialRun !== undefined;
770
+ const callerContext = { user: task.user, metadata: normalizeRuntimeMetadata(task.metadata) };
771
+ const executionIdentity = claimed ? { id: task.id, executionRevision: task.executionRevision, activeRunId: task.activeRunId } : {};
772
+ task = { ...task, ...callerContext, ...executionIdentity };
773
+ if (task.missionExecutionId) {
774
+ if (!this.prepareMissionExecution)
775
+ throw new MissionExecutionError("mission_execution_unsupported", "Mission execution must be prepared before dispatch");
776
+ await this.prepareMissionExecution(task);
777
+ }
778
+ const store = this.ctx.agentStore;
779
+ const versioned = isVersionedAgentStore(store);
780
+ const snapshot = versioned ? await store.getAgentSnapshot(task.assignTo) : undefined;
781
+ let agent = versioned ? snapshot?.agent : await store.getAgent(task.assignTo);
493
782
  if (!agent) {
494
783
  const message = `No agent "${task.assignTo}" for task "${task.title}"`;
495
784
  this.ctx.emitter.emit("log", { level: "error", message });
496
- await this.failBeforeSpawn(task, message);
785
+ await this.failBeforeSpawn(task, message, initialRun);
497
786
  return;
498
787
  }
499
- // Fail fast if the agent's model provider has no API key
500
- if (agent.model && this.ctx.validateProviderKeys) {
501
- const resolved = resolveConfiguredModelSelection(agent.model, this.ctx.config.settings, agent.allowedModelProfiles);
502
- const missing = this.ctx.validateProviderKeys([...resolved.policy.candidates]);
503
- if (missing.length > 0) {
504
- const detail = missing.map(m => `${m.provider} (${m.modelSpec})`).join(", ");
505
- this.ctx.emitter.emit("log", {
506
- level: "error",
507
- message: `[${task.id}] Missing API key for ${detail} — cannot spawn agent "${agent.name}"`,
508
- });
509
- await this.failBeforeSpawn(task, `Missing API key for ${detail}`);
510
- return;
511
- }
788
+ const agentIdentity = snapshot ? agentIdentityFromSnapshot(snapshot) : undefined;
789
+ const pendingResume = this.pendingResume.get(task.id);
790
+ try {
791
+ if (agent.name !== task.assignTo)
792
+ throw new AgentIdentityError();
793
+ if (pendingResume && (versioned || pendingResume.agentIdentity))
794
+ assertAgentIdentity(pendingResume.agentIdentity, snapshot);
795
+ }
796
+ catch (error) {
797
+ this.pendingResume.delete(task.id);
798
+ await this.failBeforeSpawn(task, error instanceof Error ? error.message : "Agent identity check failed", initialRun);
799
+ return;
512
800
  }
513
- // Run before:task:spawn hook (sync — tick loop is synchronous)
514
- const hookResult = this.ctx.hooks.runBeforeSync("task:spawn", { task, agent });
515
- if (hookResult.cancelled) {
801
+ let executionRoute;
802
+ const invocation = await authorizeTaskDispatch(this.ctx, {
803
+ source: task, run: initialRun, invocation: { task, agent, agentIdentity, overrides },
804
+ prepare: async (effective) => {
805
+ // Resolve the effective model, loop and sandbox BEFORE consent. The
806
+ // returned values are used unchanged after approval, including resume.
807
+ if (effective.agent.model && this.ctx.validateProviderKeys) {
808
+ const resolved = resolveConfiguredModelSelection(effective.agent.model, this.ctx.config.settings, effective.agent.allowedModelProfiles);
809
+ const missing = this.ctx.validateProviderKeys([...resolved.policy.candidates]);
810
+ if (missing.length)
811
+ throw new Error(`Missing API key for ${missing.map(m => `${m.provider} (${m.modelSpec})`).join(", ")}`);
812
+ }
813
+ executionRoute = await this.resolveTaskExecutionRoute(effective.task, effective.agent);
814
+ return { ...effective, task: { ...effective.task,
815
+ ...(executionRoute?.mode === "loop" ? { loop: executionRoute.loop } : {}),
816
+ executionMode: resolveExecutionMode(effective.task, effective.agent, this.ctx.config.settings),
817
+ sandbox: resolveRuntimeSandboxOptions(this.ctx.config.settings, effective.agent, effective.task),
818
+ } };
819
+ },
820
+ }, this.approvals);
821
+ task = invocation.task;
822
+ agent = invocation.agent;
823
+ if (!invocation.allowed) {
516
824
  this.ctx.emitter.emit("log", {
517
825
  level: "info",
518
- message: `[${task.id}] Spawn blocked by hook: ${hookResult.cancelReason ?? "no reason"}`,
826
+ message: `[${task.id}] Spawn blocked by hook: ${invocation.reason ?? "no reason"}`,
519
827
  });
520
- return; // task stays pending — will be re-evaluated next tick
828
+ return; // Approval, veto, or cancellation prevents provider execution.
521
829
  }
522
- await this.ctx.taskStore.transition(task.id, "assigned");
523
- await this.ctx.taskStore.transition(task.id, "in_progress");
524
- // Set phase if not already set (new tasks start in execution phase)
525
- if (!task.phase) {
526
- await this.ctx.taskStore.updateTask(task.id, { phase: "execution" });
830
+ if (!claimed) {
831
+ await this.ctx.taskStore.transition(task.id, "assigned");
832
+ await this.ctx.taskStore.transition(task.id, "in_progress");
833
+ if (!task.phase)
834
+ await this.ctx.taskStore.updateTask(task.id, { phase: "execution" });
527
835
  }
528
- const runId = nanoid();
836
+ const runId = initialRun?.id ?? nanoid();
529
837
  // Create per-task output directory for deliverables
530
838
  const outputDir = pathJoin(this.ctx.polpoDir, "output", task.id);
531
839
  // Establish the durable Run before memory, mission context, storage or
532
840
  // sandbox preparation. Any subsequent failure is therefore inspectable.
533
- const startedAt = new Date().toISOString();
534
- const initialRun = {
841
+ const startedAt = initialRun?.startedAt ?? new Date().toISOString();
842
+ initialRun ??= {
535
843
  id: runId,
536
844
  taskId: task.id,
537
845
  pid: 0,
@@ -547,8 +855,11 @@ export class TaskRunner {
547
855
  engine: "agent",
548
856
  delivery: "background",
549
857
  };
550
- await this.ctx.runStore.upsertRun(initialRun);
858
+ if (!claimed)
859
+ await this.ctx.runStore.upsertRun(initialRun);
551
860
  try {
861
+ if (claimed && !await this.claimIsActive(task, initialRun))
862
+ return;
552
863
  let runtimeContext;
553
864
  try {
554
865
  runtimeContext = await resolveRuntimeContext(this.ctx.runtimeContext, {
@@ -563,7 +874,8 @@ export class TaskRunner {
563
874
  catch {
564
875
  throw new Error("Runtime context retrieval failed");
565
876
  }
566
- const executionRoute = await this.resolveTaskExecutionRoute(task, agent);
877
+ if (claimed && !await this.claimIsActive(task, initialRun))
878
+ return;
567
879
  const contextTrust = this.ctx.config.settings.contextTrust === "enforce"
568
880
  ? "enforce"
569
881
  : "off";
@@ -571,12 +883,7 @@ export class TaskRunner {
571
883
  // metadata structural; disabled runs preserve the historical task prompt.
572
884
  const promptContextSegments = [];
573
885
  const legacyContextParts = [];
574
- const taskWithContext = {
575
- ...task,
576
- ...(executionRoute?.mode === "loop"
577
- ? { loop: executionRoute.loop }
578
- : {}),
579
- };
886
+ const taskWithContext = task;
580
887
  // 1. Shared memory (persistent cross-session knowledge, visible to all agents)
581
888
  const sharedMemory = replacesLegacySharedMemory(runtimeContext)
582
889
  ? ""
@@ -611,7 +918,9 @@ export class TaskRunner {
611
918
  if (task.group) {
612
919
  try {
613
920
  // Resolve mission via direct ID (preferred) or group name (legacy fallback)
614
- const mission = await resolveMissionForTask(resolveMissionStore(this.ctx), task);
921
+ const mission = task.missionExecutionId
922
+ ? (await resolveMissionExecutionStore(this.ctx)?.getMissionExecutionById(task.missionExecutionId))?.expected
923
+ : await resolveMissionForTask(resolveMissionStore(this.ctx), task);
615
924
  const legacyMissionParts = [];
616
925
  // Original user prompt that generated this mission (the "why")
617
926
  if (mission?.prompt) {
@@ -627,7 +936,7 @@ export class TaskRunner {
627
936
  }
628
937
  // Sibling tasks — just titles and statuses for awareness, not full descriptions
629
938
  const allTasks = await this.ctx.taskStore.listTasks();
630
- const siblings = allTasks.filter(t => t.group === task.group && t.id !== task.id);
939
+ const siblings = allTasks.filter(t => (task.missionExecutionId ? t.missionExecutionId === task.missionExecutionId : t.group === task.group) && t.id !== task.id);
631
940
  if (siblings.length > 0) {
632
941
  const missionParts = [`Other tasks in this mission:`];
633
942
  for (const s of siblings) {
@@ -662,7 +971,7 @@ export class TaskRunner {
662
971
  // Durable turns: consume (one-shot) a checkpoint harvested by orphan
663
972
  // recovery — the runner resumes the conversation at turn + 1 instead
664
973
  // of redoing completed work. Absent checkpoint = spawn from zero.
665
- const resumeState = this.pendingResume.get(task.id);
974
+ const resumeState = pendingResume?.state;
666
975
  if (resumeState) {
667
976
  this.pendingResume.delete(task.id);
668
977
  const from = resumeState.pipelineName
@@ -674,8 +983,8 @@ export class TaskRunner {
674
983
  });
675
984
  }
676
985
  // Adaptive isolation: resolve WHERE this run executes (task > agent > settings)
677
- const executionMode = resolveExecutionMode(task, agent, this.ctx.config.settings);
678
- const sandbox = resolveRuntimeSandboxOptions(this.ctx.config.settings, agent, task);
986
+ const executionMode = task.executionMode;
987
+ const sandbox = task.sandbox;
679
988
  const runnerConfig = {
680
989
  runId,
681
990
  taskId: task.id,
@@ -688,6 +997,7 @@ export class TaskRunner {
688
997
  runtimeContext,
689
998
  executionRoute,
690
999
  agent,
1000
+ ...(agentIdentity ? { agentIdentity } : {}),
691
1001
  task: taskForRun,
692
1002
  polpoDir: this.ctx.polpoDir,
693
1003
  cwd: this.ctx.agentWorkDir,
@@ -704,13 +1014,54 @@ export class TaskRunner {
704
1014
  };
705
1015
  // Persist the complete input before handing control to the host. This is
706
1016
  // also the recovery/debug snapshot when host acquisition fails.
707
- await this.ctx.runStore.upsertRun({
708
- ...initialRun,
709
- executionMode,
710
- config: runnerConfig,
711
- updatedAt: new Date().toISOString(),
712
- });
713
- const spawnResult = await this.ctx.spawner.spawn(runnerConfig);
1017
+ if (claimed && !await this.claimIsActive(task, initialRun))
1018
+ return;
1019
+ if (this.ctx.runStore.updateRunConfig) {
1020
+ if (!await this.ctx.runStore.updateRunConfig(runId, runnerConfig, initialRun.configPath))
1021
+ return;
1022
+ }
1023
+ else {
1024
+ // Compatibility for third-party stores predating updateRunConfig. This
1025
+ // legacy registration path cannot provide the update-only fence; hosts
1026
+ // requiring that guarantee must require the optional store capability.
1027
+ await this.ctx.runStore.upsertRun({
1028
+ ...initialRun,
1029
+ executionMode,
1030
+ config: runnerConfig,
1031
+ updatedAt: new Date().toISOString(),
1032
+ });
1033
+ }
1034
+ if (claimed && !await this.claimIsActive(task, initialRun))
1035
+ return;
1036
+ if (this.closing) {
1037
+ await this.ctx.runStore.completeRun(runId, "killed", { exitCode: 1, stdout: "", stderr: "Killed during shutdown", duration: 0 });
1038
+ const interrupted = await this.ctx.runStore.getRun(runId);
1039
+ if (interrupted?.status === "killed" && interrupted.result?.stderr === "Killed during shutdown")
1040
+ await this.acknowledgeRun(interrupted);
1041
+ return;
1042
+ }
1043
+ const pending = {};
1044
+ this.pendingSpawns.set(runId, pending);
1045
+ let spawnResult;
1046
+ try {
1047
+ spawnResult = await this.ctx.spawner.spawn(runnerConfig);
1048
+ this.ownSpawn(runId, task.id, spawnResult);
1049
+ this.interruptedPreparations.delete(runId);
1050
+ }
1051
+ finally {
1052
+ if (this.pendingSpawns.get(runId) === pending)
1053
+ this.pendingSpawns.delete(runId);
1054
+ }
1055
+ if (this.closing)
1056
+ await this.stopOwner(runId);
1057
+ // A host may finish acquisition after cancellation. Only this spawn's
1058
+ // returned handle is local authority to kill; a persisted foreign PID is not.
1059
+ if (claimed && (await this.ctx.runStore.getRun(runId))?.status === "killed") {
1060
+ if (this.ctx.spawner.cancelRun)
1061
+ await this.ctx.spawner.cancelRun(runId);
1062
+ else
1063
+ this.ctx.spawner.kill(spawnResult.pid);
1064
+ }
714
1065
  // Never upsert the whole record here: an in-process runner can complete
715
1066
  // before spawn() returns. Updating only spawn metadata cannot resurrect
716
1067
  // that terminal Run back to "running".
@@ -728,12 +1079,6 @@ export class TaskRunner {
728
1079
  });
729
1080
  }
730
1081
  }
731
- // Wake the supervisor as soon as the runner process exits so results
732
- // are collected immediately (poll interval remains the safety net for
733
- // spawners that don't track process lifecycle).
734
- spawnResult.onExit?.(() => {
735
- this.ctx.emitter.emit("run:exited", { taskId: task.id, runId, pid: spawnResult.pid });
736
- });
737
1082
  this.ctx.emitter.emit("agent:spawned", {
738
1083
  taskId: task.id,
739
1084
  agentName: agent.name,
@@ -751,11 +1096,5 @@ export class TaskRunner {
751
1096
  });
752
1097
  }
753
1098
  }
754
- killOrphanProcess(pid, agentName) {
755
- if (!this.ctx.spawner.isAlive(pid))
756
- return;
757
- this.ctx.emitter.emit("log", { level: "warn", message: `Killing orphan process PID ${pid} (${agentName})` });
758
- this.ctx.spawner.kill(pid);
759
- }
760
1099
  }
761
1100
  //# sourceMappingURL=task-runner.js.map