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.
- package/README.md +4 -2
- package/dist/adapters.js +26 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd-main.js +6 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- package/workflow/troubleshooter-v2.mmd +43 -0
package/dist/reconciler.js
CHANGED
|
@@ -1,24 +1,26 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { storySizeTimeoutMs,
|
|
2
|
+
import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
|
|
3
3
|
import { dependencyReady, schedule, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
4
|
-
import {
|
|
4
|
+
import { applyTaskCancelAction } from './task-actions.js';
|
|
5
5
|
import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
6
6
|
import { eventFor } from './events.js';
|
|
7
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
await this.
|
|
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
|
|
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.
|
|
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
|
-
&&
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
205
|
-
const
|
|
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
|
-
|
|
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
|
|
224
|
-
this.
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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(
|
|
253
|
-
}
|
|
254
|
-
await
|
|
255
|
-
|
|
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
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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,
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
340
|
-
const children = result.next_tasks.map(spec =>
|
|
341
|
-
for (const child of children)
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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,
|
|
360
|
-
|
|
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-${
|
|
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
}
|