agents-relay 1.0.177 → 2.0.1

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 (66) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters.js +26 -2
  3. package/dist/blockers.js +60 -65
  4. package/dist/cli-support.js +33 -1
  5. package/dist/cli.js +72 -86
  6. package/dist/dashboard-actions.js +13 -35
  7. package/dist/dashboard-html.js +1 -1
  8. package/dist/dashboard.js +39 -0
  9. package/dist/execution-recovery.js +37 -0
  10. package/dist/graph-runtime.js +1 -0
  11. package/dist/job-actions.js +53 -0
  12. package/dist/manual-plan.js +15 -17
  13. package/dist/planner-runtime.js +135 -0
  14. package/dist/planner.js +76 -60
  15. package/dist/pool.js +54 -10
  16. package/dist/projected-store.js +314 -0
  17. package/dist/reconciler-review.js +185 -110
  18. package/dist/reconciler-runtime.js +67 -7
  19. package/dist/reconciler.js +134 -282
  20. package/dist/relay-store.js +380 -0
  21. package/dist/relayd-main.js +6 -0
  22. package/dist/relayd.js +122 -29
  23. package/dist/runtime-status.js +2 -1
  24. package/dist/scheduled-jobs.js +40 -31
  25. package/dist/scheduler.js +19 -18
  26. package/dist/store.js +1 -1
  27. package/dist/task-actions.js +139 -0
  28. package/dist/troubleshooter.js +14 -8
  29. package/dist/types.js +3 -3
  30. package/dist/unblock.js +31 -21
  31. package/dist/workflow/authoritative-unblock-v2.js +20 -0
  32. package/dist/workflow/blocker-resolution-v2.js +33 -0
  33. package/dist/workflow/contracts.js +21 -0
  34. package/dist/workflow/definitions-v2.js +75 -0
  35. package/dist/workflow/dispatcher.js +56 -0
  36. package/dist/workflow/entity-creation.js +32 -0
  37. package/dist/workflow/graph-v2.js +391 -0
  38. package/dist/workflow/job-reconcile-v2.js +19 -0
  39. package/dist/workflow/lifecycle.js +82 -4
  40. package/dist/workflow/planner-orchestration-v2.js +58 -0
  41. package/dist/workflow/planner-trigger-v2.js +24 -0
  42. package/dist/workflow/review-v2.js +42 -0
  43. package/dist/workflow/runtime.js +15 -1
  44. package/dist/workflow/schedule-cycle-v2.js +25 -0
  45. package/dist/workflow/scheduled-run-v2.js +30 -0
  46. package/dist/workflow/task-action-policy.js +19 -0
  47. package/dist/workflow/task-reconcile.js +6 -6
  48. package/dist/workflow/task-related-action-v2.js +42 -0
  49. package/dist/workflow/troubleshooter-v2.js +29 -0
  50. package/dist/workspace.js +6 -1
  51. package/package.json +3 -3
  52. package/skills/agents-relay/agents/planner.agent.md +5 -5
  53. package/skills/agents-relay/contracts/task-job-contract.md +2 -51
  54. package/workflow/authoritative-unblock-v2.mmd +15 -0
  55. package/workflow/blocker-resolution-v2.mmd +73 -0
  56. package/workflow/job-lifecycle-v2.mmd +70 -0
  57. package/workflow/job-lifecycle.mmd +2 -2
  58. package/workflow/planner-orchestration-v2.mmd +65 -0
  59. package/workflow/planner-trigger-v2.mmd +32 -0
  60. package/workflow/review-lifecycle-v2.mmd +124 -0
  61. package/workflow/schedule-cycle-v2.mmd +19 -0
  62. package/workflow/scheduled-run-v2.mmd +55 -0
  63. package/workflow/task-lifecycle-v2.mmd +201 -0
  64. package/workflow/task-lifecycle.mmd +3 -0
  65. package/workflow/task-related-action-v2.mmd +28 -0
  66. package/workflow/troubleshooter-v2.mmd +43 -0
@@ -1,24 +1,26 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { storySizeTimeoutMs, taskOverallTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
2
+ import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
3
3
  import { dependencyReady, schedule, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
4
- import { implementationComplete } from './runtime-status.js';
4
+ import { applyTaskCancelAction } from './task-actions.js';
5
5
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
6
6
  import { eventFor } from './events.js';
7
- import { createPlannerRoundTask, pendingPlanForPlannerTask, recoverPendingPlannerTask, parsePlannerResult, plannerInput, validatePlannerHierarchy } from './planner.js';
7
+ import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
8
+ import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
8
9
  import { agentContractPrompt, executionContextPrompt, resolveExecutionContext } from './execution-context.js';
9
- import { agentGraphPrompt, validateAgentSelection } from './agent-graph.js';
10
+ import { agentGraphPrompt } from './agent-graph.js';
10
11
  import { ReconcilerReviewBase } from './reconciler-review.js';
11
12
  import { executeManualPlan } from './manual-plan.js';
12
- import { executionEventSource } from './reconciler-runtime.js';
13
+ import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
13
14
  export { executionEventSource } from './reconciler-runtime.js';
14
15
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
15
16
  import { applyHistoricalJobCompatibilityRepair } from './workflow/runtime.js';
16
17
  import { pendingTroubleshooterTasks } from './troubleshooter.js';
17
18
  import { prepareManagedExecutionWorktree } from './workspace.js';
18
19
  import { createReviewChairExecutionPlan } from './review-chair.js';
19
- import { samePlannerChildDefinition } from './planner-child.js';
20
- import { isEffectivelyBlocked } from './unblock.js';
21
20
  import { taskTagWorkerPrompt } from './task-tag.js';
21
+ import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
22
+ import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
23
+ import { launchPlanExecution } from './planner-runtime.js';
22
24
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
23
25
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
24
26
  export async function managedWorkerInput(job, task, context) {
@@ -78,7 +80,6 @@ The ChatGPT/Codex conversation or process exit is never the task result.
78
80
  Before your final return, verify that a terminal event for this exact Job/Task was published.
79
81
  If no terminal event has been published, publish task.failed before exiting.`;
80
82
  }
81
- const taskLaunchClaims = new Set();
82
83
  export class Reconciler extends ReconcilerReviewBase {
83
84
  constructor(store, options) {
84
85
  super(store, options);
@@ -87,9 +88,7 @@ export class Reconciler extends ReconcilerReviewBase {
87
88
  const task = context.taskId ? job.tasks.find(item => item.id === context.taskId) : undefined;
88
89
  if (!task || task.state !== 'QUEUED' || task.executionId !== null)
89
90
  return;
90
- if (task.kind === 'planner')
91
- await this.launchPlanner(job, task);
92
- else if (task.adapter !== 'orchestrator')
91
+ if (task.kind !== 'planner' && task.adapter !== 'orchestrator')
93
92
  await this.launch(job, task);
94
93
  }, context => `effect.launchTask:${context.jobId}:${context.taskId ?? 'task'}:${String(context.data?.attempt ?? 0)}`));
95
94
  this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cancelActiveTasks', async (context) => this.cancelActiveTasks(context.jobId, String(context.data?.reason ?? 'Job terminated'))));
@@ -111,6 +110,7 @@ export class Reconciler extends ReconcilerReviewBase {
111
110
  const headSha = prState === 'OPEN' ? await this.currentHeadSha(job) : null;
112
111
  if (headSha && job.headSha !== headSha) {
113
112
  job.headSha = headSha;
113
+ job.updatedAt = new Date().toISOString();
114
114
  await this.store.saveJob(job);
115
115
  }
116
116
  await this.invalidateStaleReview(job, headSha);
@@ -127,10 +127,10 @@ export class Reconciler extends ReconcilerReviewBase {
127
127
  await this.applyJobEffectOutcomes(job, outcomes);
128
128
  }
129
129
  else {
130
- const decision = await transitionJobEvent(job, prState === 'MERGED' ? 'pr.merged' : 'pr.closed', prState === 'CLOSED' ? { notMerged: true } : {});
131
- await this.store.saveJob(job);
132
- const outcomes = await this.workflowEffects.execute(decision.effects, { jobId: job.id, transitionKey: `${decision.target}:${prState}`, data: { reason } });
133
- await this.applyJobEffectOutcomes(job, outcomes);
130
+ await transitionJobEvent(job, prState === 'MERGED' ? 'pr.merged' : 'pr.closed', prState === 'CLOSED' ? { notMerged: true } : {}, async (actionId) => {
131
+ const outcomes = await this.workflowEffects.execute([actionId], { jobId: job.id, transitionKey: `terminal:${prState}`, data: { reason } });
132
+ await this.applyJobEffectOutcomes(job, outcomes);
133
+ }, async () => { await this.store.saveJob(job); });
134
134
  }
135
135
  }
136
136
  if (prState === 'MERGED' && this.store.resolveBlockerDependents) {
@@ -142,7 +142,7 @@ export class Reconciler extends ReconcilerReviewBase {
142
142
  }
143
143
  if (job.state !== 'ACTIVE')
144
144
  return job;
145
- await this.recoverLostExecutions(job, now);
145
+ await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
146
146
  await this.recoverPlannerChildren(job);
147
147
  for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
148
148
  await this.store.appendTask(recovery);
@@ -150,7 +150,7 @@ export class Reconciler extends ReconcilerReviewBase {
150
150
  }
151
151
  for (const task of job.tasks)
152
152
  if (task.state === 'CANCELLED') {
153
- this.live.get(task.id)?.execution.cancel();
153
+ this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
154
154
  this.plannerLive.get(task.id)?.controller.abort();
155
155
  }
156
156
  const beforeSchedule = await this.store.load(jobId);
@@ -181,171 +181,141 @@ export class Reconciler extends ReconcilerReviewBase {
181
181
  const dispatchable = scheduled.tasks
182
182
  .filter(task => task.state === 'QUEUED'
183
183
  && task.executionId === null
184
- && (task.kind === 'planner' || task.adapter !== 'orchestrator')
184
+ && task.kind !== 'planner'
185
+ && task.adapter !== 'orchestrator'
185
186
  && dependencyReady(task, new Map(scheduled.tasks.map(item => [item.id, item]))))
186
187
  .sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2'))
187
188
  .slice(0, capacity);
188
189
  for (const task of dispatchable) {
189
- const decision = await transitionTaskEvent(task, 'dispatch', { dispatchable: true });
190
- await this.store.saveTask(task);
191
- const outcomes = await this.workflowEffects.execute(decision.effects, {
192
- jobId: scheduled.id,
193
- taskId: task.id,
194
- transitionKey: `dispatch:${task.attempt}`,
195
- data: { attempt: task.attempt },
196
- });
197
- await this.applyTaskEffectOutcomes(scheduled, task, outcomes);
190
+ await transitionTaskEvent(task, 'dispatch', { dispatchable: true }, async (actionId) => {
191
+ const outcomes = await this.workflowEffects.execute([actionId], { jobId: scheduled.id, taskId: task.id, transitionKey: `dispatch:${task.attempt}`, data: { attempt: task.attempt } });
192
+ await this.applyTaskEffectOutcomes(scheduled, task, outcomes);
193
+ }, async () => { await this.store.saveTask(task); });
198
194
  }
199
195
  }
200
196
  const finalState = schedule(await this.store.load(jobId));
201
197
  if (finalState.updatedAt !== scheduled.updatedAt)
202
198
  await this.store.saveJob(finalState);
203
199
  await this.ensureFinalReview(finalState, prState, headSha);
204
- if (prState === 'OPEN' && finalState.reviewEnabled === false && implementationComplete(finalState) && finalState.autoMerge !== false && this.store.mergePullRequest && headSha) {
205
- const decision = await transitionJobEvent(finalState, 'implementation.completed', { autoMerge: true });
206
- await this.store.saveJob(finalState);
207
- const outcomes = await this.workflowEffects.execute(decision.effects, { jobId: finalState.id, transitionKey: `implementation.completed:${headSha}`, data: { headSha } });
200
+ await dispatchJobReconcile(finalState, { prState, headSha, mergeCapable: Boolean(this.store.mergePullRequest) }, async (actionId) => {
201
+ const outcomes = await this.workflowEffects.execute([actionId], { jobId: finalState.id, transitionKey: `reconcile:${headSha ?? 'no-head'}`, data: { headSha } });
208
202
  await this.applyJobEffectOutcomes(finalState, outcomes);
209
- if (outcomes.some(item => item.effect === 'effect.mergePullRequest' && item.status !== 'failed')) {
210
- await this.emit(eventFor(finalState.id, 'job', null, 'job.merged', 'succeeded', 'Completed work requested merge for PR #' + finalState.prNumber, 'user', { headSha, autoMerge: true }, { agent: 'relay', component: 'agents-relay' }));
211
- }
212
- }
203
+ }, async () => { await this.store.saveJob(finalState); });
213
204
  return this.store.load(jobId);
214
205
  }
215
206
  finally {
216
207
  this.reconciling = false;
217
208
  }
218
209
  }
219
- async recoverStartupOrphans(jobId) { const job = await this.store.load(jobId); if (job.state === 'ACTIVE' && (await this.store.pullRequestState?.() ?? 'OPEN') === 'OPEN')
220
- await this.recoverOrphanedLocalExecutions(job); return this.store.load(jobId); }
221
210
  async cancelActiveTasks(jobId, reason) {
222
211
  const job = await this.store.load(jobId);
223
- for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
224
- this.live.get(task.id)?.execution.cancel();
212
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
213
+ const live = this.plannerLive.get(plan.id);
214
+ if (live) {
215
+ clearTimeout(live.timer);
216
+ live.controller.abort();
217
+ this.plannerLive.delete(plan.id);
218
+ }
219
+ plan.status = 'FAILED';
220
+ plan.error = reason;
221
+ plan.assessment = `Planning failed: ${reason}`;
222
+ plan.leaseOwner = null;
223
+ plan.leaseExpiresAt = null;
224
+ plan.executionId = null;
225
+ plan.updatedAt = new Date().toISOString();
226
+ await this.store.savePlan(plan);
227
+ }
228
+ for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
229
+ this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
225
230
  this.plannerLive.get(task.id)?.controller.abort();
226
- await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
227
- task.error = reason;
228
- task.leaseOwner = null;
229
- task.leaseExpiresAt = null;
230
- task.executionId = null;
231
+ await applyTaskCancelAction(task, reason);
231
232
  await this.store.saveTask(task);
232
233
  }
233
234
  }
234
- async recoverLostExecutions(job, now) {
235
- const candidates = job.tasks.filter(item => item.state === 'RUNNING' || (item.state === 'QUEUED' && item.executionId !== null));
236
- for (const staleTask of candidates) {
237
- if (staleTask.leaseExpiresAt === null || new Date(staleTask.leaseExpiresAt).getTime() > now)
238
- continue;
239
- // Re-read durable state before applying lease recovery: another reconciler may have accepted a terminal event or launched a newer execution.
240
- const task = (await this.store.load(job.id)).tasks.find(item => item.id === staleTask.id);
241
- if (!task)
242
- continue;
243
- const sameGeneration = task.executionId === staleTask.executionId && task.leaseOwner === staleTask.leaseOwner && task.leaseExpiresAt === staleTask.leaseExpiresAt;
244
- const recoverable = task.state === 'RUNNING' || (task.state === 'QUEUED' && task.executionId !== null);
245
- if (!sameGeneration || !recoverable || task.leaseExpiresAt === null || new Date(task.leaseExpiresAt).getTime() > now)
246
- continue;
247
- this.live.get(task.id)?.execution.cancel();
248
- const planner = this.plannerLive.get(task.id);
249
- if (planner) {
235
+ executionRecoveryPorts(job) {
236
+ return {
237
+ store: this.store,
238
+ owner: this.options.owner,
239
+ cancelGeneration: task => { this.cancelExecutionGeneration(job.id, task.id, task.executionId); },
240
+ cancelPlanner: taskId => { const planner = this.plannerLive.get(taskId); if (planner) {
250
241
  clearTimeout(planner.timer);
251
242
  planner.controller.abort();
252
- this.plannerLive.delete(task.id);
253
- }
254
- await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.failed' : 'fail');
255
- task.error = 'Lease expired';
256
- task.leaseOwner = null;
257
- task.leaseExpiresAt = null;
258
- task.executionId = null;
259
- if (task.attempt < task.maxAttempts) {
260
- await transitionTaskEvent(task, 'retry');
261
- await this.store.saveTask(task);
262
- continue;
263
- }
264
- task.error += ' after maximum attempts';
265
- await this.store.saveTask(task);
266
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: 'lease_expired' }));
267
- }
243
+ this.plannerLive.delete(taskId);
244
+ } },
245
+ emitFailed: async (task, reason) => { await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error ?? reason, 'user', { reason })); },
246
+ };
268
247
  }
269
- async recoverOrphanedLocalExecutions(job) {
270
- for (const stale of job.tasks.filter(t => t.state === 'RUNNING' && t.executionId && t.leaseOwner && t.leaseOwner !== this.options.owner && t.adapter !== 'chatgpt')) {
271
- const task = (await this.store.load(job.id)).tasks.find(t => t.id === stale.id);
272
- if (!task || task.state !== 'RUNNING' || task.executionId !== stale.executionId || task.leaseOwner !== stale.leaseOwner || task.leaseExpiresAt !== stale.leaseExpiresAt || task.adapter === 'chatgpt' || task.leaseOwner === this.options.owner)
273
- continue;
274
- await transitionTaskEvent(task, 'task.failed');
275
- task.error = 'Orphaned after relayd restart';
276
- task.leaseOwner = null;
277
- task.leaseExpiresAt = null;
278
- task.executionId = null;
279
- if (task.attempt < task.maxAttempts) {
280
- await transitionTaskEvent(task, 'retry');
281
- await this.store.saveTask(task);
282
- continue;
283
- }
284
- task.error += ' after maximum attempts';
285
- await this.store.saveTask(task);
286
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: 'startup_orphaned' }));
248
+ async recoverLostExecutions(job, now) { await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job)); }
249
+ async recoverStartupOrphans(jobId) {
250
+ const job = await this.store.load(jobId);
251
+ if (job.state === 'ACTIVE' && (await this.store.pullRequestState?.() ?? 'OPEN') === 'OPEN') {
252
+ await recoverStartupExecutionOrphans(job, this.executionRecoveryPorts(job));
287
253
  }
254
+ return this.store.load(jobId);
288
255
  }
289
256
  async planJob(jobId, guidance) {
290
- return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, task) => this.launchPlanner(job, task));
257
+ return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan));
291
258
  }
292
259
  async maybeCreatePlanner(job) {
293
- if (job.executionMode !== 'autonomous')
294
- return job;
295
- if (job.initialPlanning?.status === 'pending')
296
- return job;
297
- const recovered = recoverPendingPlannerTask(job, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'));
298
- if (recovered) {
299
- await this.store.appendTask(recovered);
300
- await this.emit(eventFor(job.id, recovered.id, null, 'planner.created', 'queued', `Recovered pending Plan ${recovered.id}`, 'orchestrator'));
301
- return this.store.load(job.id);
302
- }
303
- const allPlanners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
304
- if (allPlanners.length === 0 && job.tasks.length === 0)
305
- return job;
306
- const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
307
- const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
308
- const planners = scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
309
- if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING', 'FAILED'].includes(task.state)))
310
- return job;
311
- if (scopedTasks.some(isEffectivelyBlocked))
312
- return job;
313
- if (scopedTasks.some(task => task.kind !== 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
314
- return job;
315
- if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
316
- return job;
317
- const latest = planners.at(-1);
318
- if (latest?.plannerResult?.objective_status === 'satisfied')
260
+ const pending = pendingPlan(job);
261
+ const recoverable = pending && !this.plannerLive.has(pending.id)
262
+ && (!pending.executionId || pending.leaseOwner !== this.options.owner || !pending.leaseExpiresAt || Date.parse(pending.leaseExpiresAt) <= (this.options.now ?? new Date()).getTime())
263
+ ? pending : null;
264
+ let changed = false;
265
+ const decision = await dispatchPlannerOrchestration(plannerOrchestrationFacts(job, Boolean(recoverable)), async (actionId) => {
266
+ if (actionId === 'planner.recoverPending') {
267
+ if (!recoverable)
268
+ throw new Error('Planner graph requested pending recovery without a recoverable Plan');
269
+ await this.launchPlanner(job, recoverable);
270
+ changed = true;
271
+ return {};
272
+ }
273
+ if (actionId === 'planner.createRound') {
274
+ const plan = createPlanRound(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
275
+ await this.store.appendPlan(plan);
276
+ await this.emit(eventFor(job.id, plan.id, null, 'planner.created', 'queued', 'Plan ' + plan.id + ' created', 'orchestrator'));
277
+ await this.launchPlanner(await this.store.load(job.id), plan);
278
+ changed = true;
279
+ return {};
280
+ }
281
+ throw new Error('Unsupported planner orchestration action ' + actionId);
282
+ });
283
+ if (!decision.accepted || !changed)
319
284
  return job;
320
- const task = createPlannerRoundTask(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
321
- await this.store.appendPlan(pendingPlanForPlannerTask(job, task));
322
- await this.store.appendTask(task);
323
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
324
285
  return this.store.load(job.id);
325
286
  }
326
287
  async recoverPlannerChildren(job) {
288
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && item.plannerResult)) {
289
+ const executionContext = await this.plannerContext(job, plan);
290
+ const children = plan.plannerResult.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
291
+ for (const child of children) {
292
+ const existing = job.tasks.find(item => item.id === child.id);
293
+ if (existing)
294
+ continue;
295
+ await this.store.appendTask(child);
296
+ job.tasks.push(child);
297
+ }
298
+ }
299
+ // Legacy read compatibility: migrate historical successful planner Tasks into first-class Plans.
327
300
  const contextTask = job.tasks.find(task => task.kind === 'planner') ?? job.tasks[0];
328
301
  if (!contextTask)
329
302
  return;
330
303
  const executionContext = await this.plannerContext(job, contextTask);
331
304
  for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
332
305
  const result = planner.plannerResult;
333
- const plan = { jobId: job.id, id: planner.id, round: planner.planningRound ?? 1, source: planner.planningSource ?? 'model-planner', previousPlanId: planner.previousPlannerTaskId ?? null, trigger: planner.planTrigger ?? { source: 'continuation' }, status: 'SUCCEEDED', guidance: planner.plannerGuidance, objectiveStatus: result.objective_status, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: planner.planTrigger?.currentHeadSha ?? job.headSha, createdAt: planner.createdAt, updatedAt: planner.updatedAt };
306
+ const plan = { jobId: job.id, id: planner.id, round: planner.planningRound ?? 1, source: planner.planningSource ?? 'model-planner', previousPlanId: planner.previousPlannerTaskId ?? null, trigger: planner.planTrigger ?? { source: 'continuation' }, status: 'SUCCEEDED', guidance: planner.plannerGuidance, objectiveStatus: result.objective_status, plannerResult: result, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: planner.planTrigger?.currentHeadSha ?? job.headSha, attempt: planner.attempt, maxAttempts: planner.maxAttempts, leaseOwner: null, leaseExpiresAt: null, executionId: null, timeoutMs: planner.timeoutMs, manualPlanning: planner.manualPlanning, createdAt: planner.createdAt, updatedAt: planner.updatedAt };
334
307
  if (!(job.plans ?? []).some(item => item.id === plan.id)) {
335
308
  await this.store.appendPlan(plan);
336
309
  (job.plans ??= []).push(plan);
337
310
  }
338
311
  else
339
- await this.store.savePlan(plan);
340
- const children = result.next_tasks.map(spec => this.plannerChild(job, planner, spec, executionContext));
341
- for (const child of children) {
342
- const existing = job.tasks.find(item => item.id === child.id);
343
- if (existing) {
344
- continue;
312
+ continue;
313
+ const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
314
+ for (const child of children)
315
+ if (!job.tasks.some(item => item.id === child.id)) {
316
+ await this.store.appendTask(child);
317
+ job.tasks.push(child);
345
318
  }
346
- await this.store.appendTask(child);
347
- job.tasks.push(child);
348
- }
349
319
  }
350
320
  }
351
321
  async resolvedContext(job, task, role, projectPathOverride) {
@@ -356,16 +326,21 @@ export class Reconciler extends ReconcilerReviewBase {
356
326
  runtimes: this.options.adapters.map(adapter => ({ name: adapter.name, id: adapter.id, capabilities: [...adapter.capabilities] })),
357
327
  });
358
328
  }
359
- async plannerContext(job, task) {
360
- let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
329
+ async plannerContext(job, planning) {
330
+ const routingCwd = 'routing' in planning ? planning.routing?.cwd : undefined;
331
+ let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : routingCwd ?? process.cwd();
361
332
  if (this.store.pullRequestHead) {
362
333
  const head = await this.store.pullRequestHead();
363
334
  if (!head)
364
335
  throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
365
- const key = `planner-${task.id}-${head.sha.slice(0, 12)}`;
336
+ const key = `planner-${planning.id}-${head.sha.slice(0, 12)}`;
366
337
  projectPath = await prepareManagedExecutionWorktree(projectPath, job.repository, job.prNumber, head, key);
367
338
  }
368
- return this.resolvedContext(job, task, 'planner', projectPath);
339
+ return resolveExecutionContext(projectPath, 'planner', {
340
+ ...(this.options.executionContextRoots ?? {}),
341
+ registeredAgents: await this.store.listAgents(),
342
+ runtimes: this.options.adapters.map(adapter => ({ name: adapter.name, id: adapter.id, capabilities: [...adapter.capabilities] })),
343
+ });
369
344
  }
370
345
  async launch(job, task) {
371
346
  const adapter = this.options.adapters.find(x => x.name === task.adapter);
@@ -381,30 +356,27 @@ export class Reconciler extends ReconcilerReviewBase {
381
356
  await this.store.saveTask(task);
382
357
  return;
383
358
  }
384
- const claimKey = `${job.repository}#${job.prNumber}:${task.id}`;
385
- if (taskLaunchClaims.has(claimKey))
386
- return;
387
- taskLaunchClaims.add(claimKey);
388
- let claimedTask = null;
389
- try {
359
+ const claimedTask = await withExecutionBoundary(job.id, task.id, async () => {
390
360
  const freshJob = await this.store.load(job.id);
391
361
  const freshTask = freshJob.tasks.find(item => item.id === task.id);
392
362
  if (!freshTask || freshTask.state !== 'QUEUED' || !dependencyReady(freshTask, new Map(freshJob.tasks.map(item => [item.id, item]))))
393
363
  return;
394
- const leaseMs = Math.max(this.options.leaseMs ?? 300000, freshTask.timeoutMs);
395
- const startedAt = (this.options.now ?? new Date()).getTime();
396
364
  if (freshTask.executionId !== null)
397
365
  return;
366
+ const leaseMs = Math.max(this.options.leaseMs ?? 300000, freshTask.timeoutMs);
367
+ const startedAt = (this.options.now ?? new Date()).getTime();
398
368
  freshTask.attempt += 1;
399
369
  freshTask.leaseOwner = this.options.owner;
400
370
  freshTask.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
401
371
  freshTask.executionId = randomUUID();
372
+ freshTask.executionHistory = [...(freshTask.executionHistory ?? []), {
373
+ id: freshTask.executionId,
374
+ attempt: freshTask.attempt,
375
+ startedAt: new Date(startedAt).toISOString(),
376
+ }].slice(-8);
402
377
  await this.store.saveTask(freshTask);
403
- claimedTask = freshTask;
404
- }
405
- finally {
406
- taskLaunchClaims.delete(claimKey);
407
- }
378
+ return freshTask;
379
+ }) ?? null;
408
380
  if (!claimedTask)
409
381
  return;
410
382
  task = claimedTask;
@@ -474,135 +446,15 @@ export class Reconciler extends ReconcilerReviewBase {
474
446
  ? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'delivery')
475
447
  : this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
476
448
  }
477
- async launchPlanner(job, task) {
478
- const planner = this.options.planner;
479
- if (!planner) {
480
- task.attempt = task.maxAttempts;
481
- task.error = 'No objective planner configured';
482
- await transitionTaskEvent(task, 'fail');
483
- await this.store.saveTask(task);
484
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${task.error}`, 'user'));
485
- return;
486
- }
487
- const startedAt = (this.options.now ?? new Date()).getTime();
488
- await transitionTaskEvent(task, 'task.started');
489
- task.attempt += 1;
490
- task.leaseOwner = this.options.owner;
491
- task.leaseExpiresAt = new Date(startedAt + Math.max(this.options.leaseMs ?? 300000, task.timeoutMs)).toISOString();
492
- task.executionId = randomUUID();
493
- await this.store.saveTask(task);
494
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.started', 'running', `Planner task ${task.id} started`, 'orchestrator'));
495
- const controller = new AbortController();
496
- const executionId = task.executionId;
497
- const timer = setTimeout(() => controller.abort(), task.timeoutMs);
498
- this.plannerLive.set(task.id, { executionId: executionId, controller, timer });
499
- let promise;
500
- try {
501
- const executionContext = await this.plannerContext(job, task);
502
- const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
503
- task.input = plannerInput(context);
504
- await this.store.saveTask(task);
505
- promise = planner.plan(context, controller.signal);
506
- }
507
- catch (error) {
508
- await this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error)));
509
- return;
510
- }
511
- this.detach(promise.then(result => this.finishPlanner(job.id, task.id, executionId, null, result), error => this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error)))), job.id, task.id, 'planner completion');
512
- }
513
- async finishPlanner(jobId, taskId, executionId, error, raw) {
514
- const live = this.plannerLive.get(taskId);
515
- if (live?.executionId === executionId) {
516
- clearTimeout(live.timer);
517
- this.plannerLive.delete(taskId);
518
- }
519
- const job = await this.store.load(jobId);
520
- const task = job.tasks.find(item => item.id === taskId);
521
- if (!task || (executionId !== null && task.executionId !== executionId))
522
- return;
523
- if (task.state === 'CANCELLED')
524
- return;
525
- if (error) {
526
- task.plannerRetryFeedback = error.message;
527
- task.error = error.message;
528
- task.leaseOwner = null;
529
- task.leaseExpiresAt = null;
530
- task.executionId = null;
531
- await transitionTaskEvent(task, 'task.failed');
532
- if (task.attempt < task.maxAttempts)
533
- await transitionTaskEvent(task, 'retry');
534
- await this.store.saveTask(task);
535
- const pending = (job.plans ?? []).find(plan => plan.id === task.id) ?? pendingPlanForPlannerTask(job, task);
536
- await this.store.savePlan({ ...pending, status: task.state === 'FAILED' ? 'FAILED' : 'PLANNING', error: error.message, assessment: task.state === 'FAILED' ? `Planning failed: ${error.message}` : `Planning retry: ${error.message}`, updatedAt: new Date().toISOString() });
537
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${error.message}`, 'user'));
538
- this.detach(this.reconcile(jobId), jobId, task.id, 'planner retry reconciliation');
539
- return;
540
- }
541
- try {
542
- const result = parsePlannerResult(raw);
543
- const executionContext = await this.plannerContext(job, task);
544
- validatePlannerHierarchy(job.tasks, result.next_tasks);
545
- if (result.unblocks.length > 0 && !this.store.registerBlockerDependent)
546
- throw new Error('Durable store does not support Planner cross-Job unblock registration');
547
- for (const unblock of result.unblocks)
548
- await this.store.registerBlockerDependent?.(job.id, unblock);
549
- const children = result.next_tasks.map(spec => this.plannerChild(job, task, spec, executionContext));
550
- for (const child of children) {
551
- const existing = job.tasks.find(item => item.id === child.id);
552
- if (existing && !samePlannerChildDefinition(existing, child))
553
- throw new Error(`Planner child ${child.id} already exists with a different definition`);
554
- }
555
- // Persist the first-class Plan decision before exposing executable child Task comments.
556
- const now = new Date().toISOString();
557
- const plan = { jobId: job.id, id: task.id, round: task.planningRound ?? 1, source: task.planningSource ?? 'model-planner', previousPlanId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger ?? { source: task.manualPlanning ? 'manual' : 'continuation' }, status: 'SUCCEEDED', guidance: task.plannerGuidance, objectiveStatus: result.objective_status, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: task.planTrigger?.currentHeadSha ?? await this.store.headSha?.() ?? job.headSha, createdAt: task.createdAt, updatedAt: now };
558
- await this.store.savePlan(plan);
559
- if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
560
- job.changeCycle.consumedByPlannerTaskId = task.id;
561
- job.updatedAt = now;
562
- await this.store.saveJob(job);
563
- }
564
- task.plannerResult = result;
565
- task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks, unblocks: result.unblocks } };
566
- task.error = null;
567
- task.plannerRetryFeedback = undefined;
568
- task.leaseOwner = null;
569
- task.leaseExpiresAt = null;
570
- task.executionId = null;
571
- await transitionTaskEvent(task, 'task.completed');
572
- await this.store.saveTask(task);
573
- for (const child of children)
574
- if (!job.tasks.some(item => item.id === child.id))
575
- await this.store.appendTask(child);
576
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.completed', 'succeeded', `Planner ${task.id} completed: ${result.objective_status}`, 'orchestrator', { objective_status: result.objective_status }));
577
- }
578
- catch (caught) {
579
- const message = caught instanceof Error ? caught.message : String(caught);
580
- if (task.state === 'SUCCEEDED') {
581
- task.error = `Child materialization failed: ${message}`;
582
- await this.store.saveTask(task);
583
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.children_failed', 'failed', `Planner ${task.id} child materialization failed: ${message}`, 'user'));
584
- }
585
- else {
586
- task.plannerRetryFeedback = message;
587
- task.error = message;
588
- task.leaseOwner = null;
589
- task.leaseExpiresAt = null;
590
- task.executionId = null;
591
- await transitionTaskEvent(task, 'task.failed');
592
- if (task.attempt < task.maxAttempts)
593
- await transitionTaskEvent(task, 'retry');
594
- await this.store.saveTask(task);
595
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${message}`, 'user'));
596
- }
597
- const pending = (await this.store.load(job.id)).plans?.find(plan => plan.id === task.id) ?? pendingPlanForPlannerTask(job, task);
598
- await this.store.savePlan({ ...pending, status: task.state === 'QUEUED' ? 'PLANNING' : 'FAILED', error: message, assessment: task.state === 'QUEUED' ? `Planning retry: ${message}` : `Planning failed: ${message}`, updatedAt: new Date().toISOString() });
599
- }
600
- if (!task.manualPlanning)
601
- this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
602
- }
603
- plannerChild(job, planner, spec, executionContext) {
604
- validateAgentSelection(executionContext, spec.agentName, spec.agentGraph);
605
- const now = new Date().toISOString();
606
- return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
449
+ async launchPlanner(job, plan) {
450
+ return launchPlanExecution({
451
+ store: this.store,
452
+ options: this.options,
453
+ live: this.plannerLive,
454
+ plannerContext: (currentJob, currentPlan) => this.plannerContext(currentJob, currentPlan),
455
+ emit: event => this.emit(event),
456
+ detach: (promise, jobId, planId, context) => this.detach(promise, jobId, planId, context),
457
+ reconcile: jobId => this.reconcile(jobId),
458
+ }, job, plan);
607
459
  }
608
460
  }