agents-relay 1.0.90 → 1.0.91
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/dist/cli-help.js +192 -0
- package/dist/cli-support.js +103 -0
- package/dist/cli.js +6 -293
- package/dist/dashboard-html.js +292 -0
- package/dist/dashboard.js +2 -292
- package/dist/reconciler-review.js +158 -0
- package/dist/reconciler-runtime.js +404 -0
- package/dist/reconciler.js +65 -534
- package/dist/scheduler.js +11 -33
- package/dist/store-github.js +217 -0
- package/dist/store-task-definition.js +19 -0
- package/dist/store.js +3 -235
- package/dist/workflow/contracts.js +84 -0
- package/dist/workflow/definitions.js +19 -5
- package/dist/workflow/effects.js +74 -0
- package/dist/workflow/mermaid.js +6 -2
- package/dist/workflow/rollout.js +70 -56
- package/dist/workflow/runtime.js +39 -0
- package/dist/workflow/task-reconcile.js +48 -0
- package/package.json +1 -1
- package/workflow/job-lifecycle.mmd +10 -5
- package/workflow/task-lifecycle.mmd +12 -10
package/dist/reconciler.js
CHANGED
|
@@ -6,13 +6,13 @@ import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
|
6
6
|
import { eventFor } from './events.js';
|
|
7
7
|
import { parsePlannerResult, plannerInput } from './planner.js';
|
|
8
8
|
import { agentContractPrompt, executionContextPrompt, resolveExecutionContext } from './execution-context.js';
|
|
9
|
-
import { selectReviewer } from './reviewer-routing.js';
|
|
10
9
|
import { agentGraphPrompt } from './agent-graph.js';
|
|
11
|
-
import {
|
|
10
|
+
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
11
|
+
import { executionEventSource } from './reconciler-runtime.js';
|
|
12
|
+
export { executionEventSource } from './reconciler-runtime.js';
|
|
13
|
+
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
12
14
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
13
15
|
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); }
|
|
14
|
-
export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
|
|
15
|
-
function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
|
|
16
16
|
export async function managedWorkerInput(job, task, context) {
|
|
17
17
|
if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
|
|
18
18
|
return task.input;
|
|
@@ -72,60 +72,27 @@ Before your final return, verify that a terminal event for this exact Job/Task w
|
|
|
72
72
|
If no terminal event has been published, publish task.failed before exiting.`;
|
|
73
73
|
}
|
|
74
74
|
const taskLaunchClaims = new Set();
|
|
75
|
-
|
|
76
|
-
function claimTerminalExecution(sourceId) {
|
|
77
|
-
terminalExecutionClaims.add(sourceId);
|
|
78
|
-
const cleanup = setTimeout(() => terminalExecutionClaims.delete(sourceId), 60000);
|
|
79
|
-
cleanup.unref();
|
|
80
|
-
}
|
|
81
|
-
function terminalExecutionClaimed(jobId, taskId, executionId) {
|
|
82
|
-
return terminalExecutionClaims.has(executionEventSource(jobId, taskId, executionId));
|
|
83
|
-
}
|
|
84
|
-
export class Reconciler {
|
|
85
|
-
store;
|
|
86
|
-
options;
|
|
87
|
-
live = new Map();
|
|
88
|
-
plannerLive = new Map();
|
|
89
|
-
terminalTimers = new Map();
|
|
90
|
-
terminalExitGraceTimers = new Map();
|
|
91
|
-
continuationLive = new Set();
|
|
92
|
-
launchFailureClaims = new Set();
|
|
93
|
-
terminalWaiters = new Map();
|
|
94
|
-
reconciling = false;
|
|
75
|
+
export class Reconciler extends ReconcilerReviewBase {
|
|
95
76
|
constructor(store, options) {
|
|
96
|
-
|
|
97
|
-
this.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
async watch(jobId) {
|
|
116
|
-
if (!this.options.eventBus)
|
|
117
|
-
return async () => { };
|
|
118
|
-
try {
|
|
119
|
-
return await this.options.eventBus.subscribe(jobId, event => this.handleEvent(event));
|
|
120
|
-
}
|
|
121
|
-
catch (error) {
|
|
122
|
-
await this.reportTransportFailure(error);
|
|
123
|
-
return async () => { };
|
|
124
|
-
}
|
|
77
|
+
super(store, options);
|
|
78
|
+
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.launchTask', async (context) => {
|
|
79
|
+
const job = await this.store.load(context.jobId);
|
|
80
|
+
const task = context.taskId ? job.tasks.find(item => item.id === context.taskId) : undefined;
|
|
81
|
+
if (!task || task.state !== 'QUEUED' || task.executionId !== null)
|
|
82
|
+
return;
|
|
83
|
+
if (task.kind === 'planner')
|
|
84
|
+
await this.launchPlanner(job, task);
|
|
85
|
+
else if (task.adapter !== 'orchestrator')
|
|
86
|
+
await this.launch(job, task);
|
|
87
|
+
}, context => `effect.launchTask:${context.jobId}:${context.taskId ?? 'task'}:${String(context.data?.attempt ?? 0)}`));
|
|
88
|
+
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cancelActiveTasks', async (context) => this.cancelActiveTasks(context.jobId, String(context.data?.reason ?? 'Job terminated'))));
|
|
89
|
+
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.mergePullRequest', async (context) => {
|
|
90
|
+
const headSha = typeof context.data?.headSha === 'string' ? context.data.headSha : null;
|
|
91
|
+
if (!headSha || !this.store.mergePullRequest)
|
|
92
|
+
throw new Error('Pull request merge effect requires a head SHA and merge-capable store');
|
|
93
|
+
await this.store.mergePullRequest(headSha);
|
|
94
|
+
}, context => `effect.mergePullRequest:${context.jobId}:${String(context.data?.headSha ?? 'missing')}`));
|
|
125
95
|
}
|
|
126
|
-
async idle() { while (this.live.size > 0 || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling)
|
|
127
|
-
await new Promise(resolve => setTimeout(resolve, 25)); }
|
|
128
|
-
async wake(jobId) { return this.reconcile(jobId); }
|
|
129
96
|
async reconcile(jobId) {
|
|
130
97
|
if (this.reconciling)
|
|
131
98
|
return this.store.load(jobId);
|
|
@@ -141,27 +108,22 @@ export class Reconciler {
|
|
|
141
108
|
const now = (this.options.now ?? new Date()).getTime();
|
|
142
109
|
if (prState !== 'OPEN') {
|
|
143
110
|
const reason = prState === 'MERGED' ? 'Pull request merged' : 'Pull request closed';
|
|
144
|
-
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
|
|
145
|
-
this.live.get(task.id)?.execution.cancel();
|
|
146
|
-
this.plannerLive.get(task.id)?.controller.abort();
|
|
147
|
-
await transitionTaskEvent(task, 'cancel');
|
|
148
|
-
task.error = reason;
|
|
149
|
-
task.leaseOwner = null;
|
|
150
|
-
task.leaseExpiresAt = null;
|
|
151
|
-
task.executionId = null;
|
|
152
|
-
await this.store.saveTask(task);
|
|
153
|
-
}
|
|
154
111
|
const terminalState = prState === 'MERGED' ? 'COMPLETED' : 'CANCELLED';
|
|
155
112
|
if (job.state !== terminalState) {
|
|
156
113
|
if (!['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(job.state)) {
|
|
157
|
-
// Compatibility repair for
|
|
114
|
+
// Compatibility repair boundary for historical records that persisted runtime status as lifecycle.
|
|
158
115
|
job.state = terminalState;
|
|
159
116
|
job.updatedAt = new Date().toISOString();
|
|
117
|
+
await this.store.saveJob(job);
|
|
118
|
+
const outcomes = await this.workflowEffects.execute(['effect.cancelActiveTasks'], { jobId: job.id, transitionKey: `compatibility:${prState}`, data: { reason } });
|
|
119
|
+
await this.applyJobEffectOutcomes(job, outcomes);
|
|
160
120
|
}
|
|
161
121
|
else {
|
|
162
|
-
await transitionJobEvent(job, prState === 'MERGED' ? 'pr.merged' : 'pr.closed', prState === 'CLOSED' ? { notMerged: true } : {});
|
|
122
|
+
const decision = await transitionJobEvent(job, prState === 'MERGED' ? 'pr.merged' : 'pr.closed', prState === 'CLOSED' ? { notMerged: true } : {});
|
|
123
|
+
await this.store.saveJob(job);
|
|
124
|
+
const outcomes = await this.workflowEffects.execute(decision.effects, { jobId: job.id, transitionKey: `${decision.target}:${prState}`, data: { reason } });
|
|
125
|
+
await this.applyJobEffectOutcomes(job, outcomes);
|
|
163
126
|
}
|
|
164
|
-
await this.store.saveJob(job);
|
|
165
127
|
}
|
|
166
128
|
return this.store.load(jobId);
|
|
167
129
|
}
|
|
@@ -193,11 +155,23 @@ export class Reconciler {
|
|
|
193
155
|
for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt))
|
|
194
156
|
this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
|
|
195
157
|
const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
158
|
+
const dispatchable = scheduled.tasks
|
|
159
|
+
.filter(task => task.state === 'QUEUED'
|
|
160
|
+
&& task.executionId === null
|
|
161
|
+
&& (task.kind === 'planner' || task.adapter !== 'orchestrator')
|
|
162
|
+
&& dependencyReady(task, new Map(scheduled.tasks.map(item => [item.id, item]))))
|
|
163
|
+
.sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2'))
|
|
164
|
+
.slice(0, capacity);
|
|
165
|
+
for (const task of dispatchable) {
|
|
166
|
+
const decision = await transitionTaskEvent(task, 'dispatch', { dispatchable: true });
|
|
167
|
+
await this.store.saveTask(task);
|
|
168
|
+
const outcomes = await this.workflowEffects.execute(decision.effects, {
|
|
169
|
+
jobId: scheduled.id,
|
|
170
|
+
taskId: task.id,
|
|
171
|
+
transitionKey: `dispatch:${task.attempt}`,
|
|
172
|
+
data: { attempt: task.attempt },
|
|
173
|
+
});
|
|
174
|
+
await this.applyTaskEffectOutcomes(scheduled, task, outcomes);
|
|
201
175
|
}
|
|
202
176
|
}
|
|
203
177
|
const finalState = schedule(await this.store.load(jobId));
|
|
@@ -205,8 +179,13 @@ export class Reconciler {
|
|
|
205
179
|
await this.store.saveJob(finalState);
|
|
206
180
|
await this.ensureFinalReview(finalState, prState, headSha);
|
|
207
181
|
if (prState === 'OPEN' && finalState.reviewEnabled === false && implementationComplete(finalState) && finalState.autoMerge !== false && this.store.mergePullRequest && headSha) {
|
|
208
|
-
await
|
|
209
|
-
await this.
|
|
182
|
+
const decision = await transitionJobEvent(finalState, 'implementation.completed', { autoMerge: true });
|
|
183
|
+
await this.store.saveJob(finalState);
|
|
184
|
+
const outcomes = await this.workflowEffects.execute(decision.effects, { jobId: finalState.id, transitionKey: `implementation.completed:${headSha}`, data: { headSha } });
|
|
185
|
+
await this.applyJobEffectOutcomes(finalState, outcomes);
|
|
186
|
+
if (outcomes.some(item => item.effect === 'effect.mergePullRequest' && item.status !== 'failed')) {
|
|
187
|
+
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' }));
|
|
188
|
+
}
|
|
210
189
|
}
|
|
211
190
|
return this.store.load(jobId);
|
|
212
191
|
}
|
|
@@ -214,151 +193,17 @@ export class Reconciler {
|
|
|
214
193
|
this.reconciling = false;
|
|
215
194
|
}
|
|
216
195
|
}
|
|
217
|
-
async
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
staleTask.leaseOwner = null;
|
|
228
|
-
staleTask.leaseExpiresAt = null;
|
|
229
|
-
await this.store.saveTask(staleTask);
|
|
230
|
-
}
|
|
231
|
-
await this.store.saveJob(job);
|
|
232
|
-
}
|
|
233
|
-
async ensureFinalReview(job, prState, headSha) {
|
|
234
|
-
if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'ACTIVE' || !implementationComplete(job) || !headSha)
|
|
235
|
-
return;
|
|
236
|
-
if (!this.preReviewGatesPass(job))
|
|
237
|
-
return;
|
|
238
|
-
const review = job.review;
|
|
239
|
-
if (review?.headSha === headSha && ['started', 'approved', 'changes_required', 'blocked'].includes(review.outcome))
|
|
240
|
-
return;
|
|
241
|
-
const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
|
|
242
|
-
if (reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
|
|
243
|
-
return;
|
|
244
|
-
if (reviewTasks.some(task => task.state === 'SUCCEEDED' && review?.headSha === headSha && review.outcome === 'approved'))
|
|
245
|
-
return;
|
|
246
|
-
const id = `final-review-${headSha.slice(0, 12)}`;
|
|
247
|
-
const now = new Date().toISOString();
|
|
248
|
-
const implementationTasks = job.tasks.filter(item => item.agentName !== 'reviewer' && !this.isSupersededTask(item));
|
|
249
|
-
const reviewerRouting = await this.selectReviewer(implementationTasks);
|
|
250
|
-
const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: implementationTasks.map(item => item.id), capabilities: ['review'], workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter, input: `Review existing PR #${job.prNumber} at exactly head SHA ${headSha}. Job objective: ${job.objective ?? job.description ?? job.title}. Use the selected reviewer agent contract as the authoritative review protocol.`, output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null, reviewHeadSha: headSha, state: 'QUEUED', attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
|
|
251
|
-
await this.store.appendTask(task);
|
|
252
|
-
job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
|
|
253
|
-
job.updatedAt = now;
|
|
254
|
-
await this.store.saveJob(job);
|
|
255
|
-
await this.emit(eventFor(job.id, id, null, 'review.created', 'queued', `Final review task ${id} created for ${headSha} using ${reviewerRouting.reason}`, 'orchestrator', { headSha, agentName: 'reviewer', reviewerRouting: { workerId: reviewerRouting.selection.workerId, reason: reviewerRouting.reason, selectedBy: reviewerRouting.selection.selectedBy } }));
|
|
256
|
-
const ready = schedule(await this.store.load(job.id));
|
|
257
|
-
await this.store.saveJob(ready);
|
|
258
|
-
const reviewTask = ready.tasks.find(item => item.id === id && item.state === 'QUEUED');
|
|
259
|
-
if (reviewTask && (this.options.maxConcurrent ?? 4) > this.live.size + this.plannerLive.size)
|
|
260
|
-
await this.launch(ready, reviewTask);
|
|
261
|
-
}
|
|
262
|
-
async selectReviewer(implementationTasks) {
|
|
263
|
-
const configured = this.options.reviewer;
|
|
264
|
-
const registered = await this.store.listAgents();
|
|
265
|
-
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
266
|
-
const candidates = workerCandidates(this.options.adapters, registered, usage);
|
|
267
|
-
let constrained = candidates.filter(candidate => {
|
|
268
|
-
if (configured?.workerId && configured.workerId !== candidate.workerId)
|
|
269
|
-
return false;
|
|
270
|
-
if (configured?.adapter && configured.adapter !== candidate.adapter)
|
|
271
|
-
return false;
|
|
272
|
-
if (configured?.routing?.provider && configured.routing.provider !== candidate.routing.provider)
|
|
273
|
-
return false;
|
|
274
|
-
if (configured?.routing?.model && configured.routing.model !== candidate.routing.model)
|
|
275
|
-
return false;
|
|
276
|
-
return true;
|
|
277
|
-
}).map(candidate => ({ ...candidate, routing: configured?.routing ? { ...candidate.routing, ...configured.routing } : candidate.routing }));
|
|
278
|
-
// Explicit reviewer routing is a caller-owned hard constraint and remains
|
|
279
|
-
// readable in isolated/test runtimes that do not instantiate that adapter.
|
|
280
|
-
if (constrained.length === 0 && configured?.adapter && configured.routing) {
|
|
281
|
-
constrained = [{ workerId: configured.workerId ?? configured.adapter, adapter: configured.adapter, capabilities: ['model'], runtime: configured.adapter, routing: configured.routing }];
|
|
282
|
-
}
|
|
283
|
-
const result = selectReviewer(constrained, implementationTasks, configured?.requirements ?? { capabilities: ['model'] });
|
|
284
|
-
const resource = usageForProvider(usage, result.candidate.routing.provider);
|
|
285
|
-
result.selection.resourceHealthy = resource.healthy;
|
|
286
|
-
result.selection.resourceProvider = resource.healthy === undefined ? undefined : result.candidate.routing.provider;
|
|
287
|
-
result.selection.resourceUsedPercent = resource.usedPercent;
|
|
288
|
-
return result;
|
|
289
|
-
}
|
|
290
|
-
async routeExecutableTasks(job) {
|
|
291
|
-
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
292
|
-
const candidates = workerCandidates(this.options.adapters, await this.store.listAgents(), usage);
|
|
293
|
-
for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
|
|
294
|
-
const requirements = task.workerRequirements ?? { capabilities: task.capabilities.length > 0 ? task.capabilities : ['model'] };
|
|
295
|
-
const routed = selectWorker(candidates, requirements);
|
|
296
|
-
task.workerSelection = routed.selection;
|
|
297
|
-
task.adapter = routed.candidate.adapter;
|
|
298
|
-
task.routing = routed.candidate.routing;
|
|
299
|
-
task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
|
|
300
|
-
task.updatedAt = new Date().toISOString();
|
|
196
|
+
async cancelActiveTasks(jobId, reason) {
|
|
197
|
+
const job = await this.store.load(jobId);
|
|
198
|
+
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
|
|
199
|
+
this.live.get(task.id)?.execution.cancel();
|
|
200
|
+
this.plannerLive.get(task.id)?.controller.abort();
|
|
201
|
+
await transitionTaskEvent(task, 'cancel');
|
|
202
|
+
task.error = reason;
|
|
203
|
+
task.leaseOwner = null;
|
|
204
|
+
task.leaseExpiresAt = null;
|
|
205
|
+
task.executionId = null;
|
|
301
206
|
await this.store.saveTask(task);
|
|
302
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routed.selection.workerId} selected for task ${task.id}`, 'orchestrator', { workerSelection: routed.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
isSupersededTask(task) {
|
|
306
|
-
return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
|
|
307
|
-
}
|
|
308
|
-
preReviewGatesPass(job) {
|
|
309
|
-
return job.tasks.filter(task => task.agentName !== 'reviewer' && !this.isSupersededTask(task)).every(task => task.state === 'SUCCEEDED');
|
|
310
|
-
}
|
|
311
|
-
async applyReviewEvent(event) {
|
|
312
|
-
const job = await this.store.load(event.job_id);
|
|
313
|
-
const task = job.tasks.find(item => item.id === event.task_id && item.agentName === 'reviewer');
|
|
314
|
-
const reviewData = event.data?.review && typeof event.data.review === 'object' ? event.data.review : undefined;
|
|
315
|
-
const headSha = typeof reviewData?.headSha === 'string' ? reviewData.headSha : typeof event.data?.headSha === 'string' ? event.data.headSha : task?.reviewHeadSha;
|
|
316
|
-
if (!task || !headSha || task.reviewHeadSha !== headSha)
|
|
317
|
-
return;
|
|
318
|
-
const outcome = event.type === 'review.started' ? 'started' : event.type === 'review.approved' ? 'approved' : event.type === 'review.changes_required' ? 'changes_required' : 'blocked';
|
|
319
|
-
const currentHeadSha = await this.store.headSha?.();
|
|
320
|
-
if ((currentHeadSha && currentHeadSha !== headSha) || (job.headSha && job.headSha !== headSha))
|
|
321
|
-
return;
|
|
322
|
-
const evidence = { summary: event.message || `Review ${outcome}`, data: { ...(event.data ?? {}), review: reviewData ?? { headSha }, headSha, outcome } };
|
|
323
|
-
job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
|
|
324
|
-
if (outcome === 'changes_required') {
|
|
325
|
-
const rawIssues = Array.isArray(reviewData?.issues) ? reviewData.issues : [];
|
|
326
|
-
const issues = rawIssues.map(item => {
|
|
327
|
-
if (typeof item === 'string')
|
|
328
|
-
return item.trim();
|
|
329
|
-
if (item && typeof item === 'object') {
|
|
330
|
-
const value = item;
|
|
331
|
-
const summary = typeof value.summary === 'string' ? value.summary : typeof value.message === 'string' ? value.message : typeof value.title === 'string' ? value.title : '';
|
|
332
|
-
return summary.trim();
|
|
333
|
-
}
|
|
334
|
-
return '';
|
|
335
|
-
}).filter(Boolean);
|
|
336
|
-
if (issues.length === 0)
|
|
337
|
-
issues.push(event.message || 'Address the reviewer-requested changes');
|
|
338
|
-
const now = event.timestamp || new Date().toISOString();
|
|
339
|
-
const id = `review-fix-${headSha.slice(0, 8)}`;
|
|
340
|
-
if (!job.tasks.some(item => item.id === id)) {
|
|
341
|
-
const input = `Address all reviewer findings for PR #${job.prNumber} at ${headSha}:\n\n${issues.map((issue, index) => `${index + 1}. ${issue}`).join('\n')}`;
|
|
342
|
-
const fixTask = {
|
|
343
|
-
jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title,
|
|
344
|
-
agentName: 'review-fix', parentTaskId: task.id, dependencies: [task.id], capabilities: ['implementation'],
|
|
345
|
-
adapter: 'codex', input, output: { kind: 'task_pr' },
|
|
346
|
-
routing: { provider: 'openai', model: 'gpt-5.6-luna', reasoning: 'medium', decidedBy: 'review-fix', decidedAt: now },
|
|
347
|
-
continuation: null, continuationDeliveredAt: null, state: 'DRAFT', attempt: 0, maxAttempts: 3,
|
|
348
|
-
leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null,
|
|
349
|
-
error: null, timeoutMs: storySizeTimeoutMs('M'), createdAt: now, updatedAt: now
|
|
350
|
-
};
|
|
351
|
-
await this.store.appendTask(fixTask);
|
|
352
|
-
await this.emit(eventFor(job.id, id, task.id, 'review.fix_task_created', 'queued', `Created one fix task ${id} for ${issues.length} reviewer finding(s)`, 'orchestrator', { headSha, issues }, { agent: 'relay', component: 'agents-relay' }));
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
job.updatedAt = job.review.updatedAt;
|
|
356
|
-
await this.store.saveJob(job);
|
|
357
|
-
if (outcome === 'changes_required')
|
|
358
|
-
this.detach(this.reconcile(job.id), job.id, task.id, 'review-fix reconciliation');
|
|
359
|
-
if (outcome === 'approved' && job.autoMerge !== false && this.store.mergePullRequest) {
|
|
360
|
-
await this.store.mergePullRequest(headSha);
|
|
361
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'review.merged', 'succeeded', 'Reviewer-approved PR #' + job.prNumber + ' merged', 'user', { headSha, autoMerge: true }, { agent: 'reviewer', component: 'agents-relay' }));
|
|
362
207
|
}
|
|
363
208
|
}
|
|
364
209
|
async recoverLostExecutions(job, now) {
|
|
@@ -544,39 +389,6 @@ export class Reconciler {
|
|
|
544
389
|
? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'delivery')
|
|
545
390
|
: this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
|
|
546
391
|
}
|
|
547
|
-
async startAck(jobId, taskId, sourceId, launchBarrier, timeoutMs = this.options.startAckTimeoutMs ?? 60000) {
|
|
548
|
-
if (!this.options.eventBus)
|
|
549
|
-
throw new Error('Worker start acknowledgement requires an event bus');
|
|
550
|
-
let resolveAck;
|
|
551
|
-
let rejectAck;
|
|
552
|
-
let settled = false;
|
|
553
|
-
let timer;
|
|
554
|
-
const ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
|
|
555
|
-
const unsubscribe = await this.options.eventBus.subscribe(jobId, async (event) => {
|
|
556
|
-
if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
|
|
557
|
-
return;
|
|
558
|
-
if (eventSourceId(event) !== sourceId)
|
|
559
|
-
return;
|
|
560
|
-
if (!event.timestamp || new Date(event.timestamp).getTime() < launchBarrier)
|
|
561
|
-
return;
|
|
562
|
-
settled = true;
|
|
563
|
-
if (timer)
|
|
564
|
-
clearTimeout(timer);
|
|
565
|
-
resolveAck();
|
|
566
|
-
});
|
|
567
|
-
timer = setTimeout(() => {
|
|
568
|
-
if (settled)
|
|
569
|
-
return;
|
|
570
|
-
settled = true;
|
|
571
|
-
void unsubscribe();
|
|
572
|
-
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
|
|
573
|
-
}, timeoutMs);
|
|
574
|
-
let stopped = false;
|
|
575
|
-
const stop = async () => { if (stopped)
|
|
576
|
-
return; stopped = true; if (timer)
|
|
577
|
-
clearTimeout(timer); await unsubscribe(); };
|
|
578
|
-
return { ack, stop };
|
|
579
|
-
}
|
|
580
392
|
async launchPlanner(job, task) {
|
|
581
393
|
const planner = this.options.planner;
|
|
582
394
|
if (!planner) {
|
|
@@ -682,287 +494,6 @@ export class Reconciler {
|
|
|
682
494
|
const now = new Date().toISOString();
|
|
683
495
|
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, parentTaskId: planner.id, 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 };
|
|
684
496
|
}
|
|
685
|
-
clearExecution(taskId, executionId) {
|
|
686
|
-
const live = this.live.get(taskId);
|
|
687
|
-
if (!executionId || live?.executionId === executionId)
|
|
688
|
-
this.live.delete(taskId);
|
|
689
|
-
const timer = this.terminalTimers.get(taskId);
|
|
690
|
-
if (timer)
|
|
691
|
-
clearTimeout(timer);
|
|
692
|
-
this.terminalTimers.delete(taskId);
|
|
693
|
-
const graceTimer = this.terminalExitGraceTimers.get(taskId);
|
|
694
|
-
if (graceTimer)
|
|
695
|
-
clearTimeout(graceTimer);
|
|
696
|
-
this.terminalExitGraceTimers.delete(taskId);
|
|
697
|
-
}
|
|
698
|
-
async markWorkerStarted(event) {
|
|
699
|
-
const live = this.live.get(event.task_id);
|
|
700
|
-
if (live && event.job_id === live.jobId && event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
|
|
701
|
-
return;
|
|
702
|
-
const job = await this.store.load(event.job_id);
|
|
703
|
-
const task = job.tasks.find(item => item.id === event.task_id);
|
|
704
|
-
if (!task || !task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
|
|
705
|
-
return;
|
|
706
|
-
const claimedExecution = task.state === 'QUEUED' && task.executionId !== null && task.leaseOwner !== null;
|
|
707
|
-
const exactLiveExecution = live && event.job_id === live.jobId && task.executionId === live.executionId;
|
|
708
|
-
if ((exactLiveExecution || claimedExecution) && task.state === 'QUEUED') {
|
|
709
|
-
await transitionTaskEvent(task, 'task.started');
|
|
710
|
-
task.failureReason = undefined;
|
|
711
|
-
task.error = null;
|
|
712
|
-
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
713
|
-
await this.store.saveTask(task);
|
|
714
|
-
if (live)
|
|
715
|
-
live.workerStarted = true;
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
if (task.state !== 'FAILED' || !this.isRecoverableWatchdogFailure(task))
|
|
719
|
-
return;
|
|
720
|
-
if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
|
|
721
|
-
return;
|
|
722
|
-
await transitionTaskEvent(task, 'reconcile.started');
|
|
723
|
-
task.failureReason = undefined;
|
|
724
|
-
task.error = null;
|
|
725
|
-
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
726
|
-
await this.store.saveTask(task);
|
|
727
|
-
this.detach(this.reconcile(job.id), job.id, task.id, 'late worker-start reconciliation');
|
|
728
|
-
}
|
|
729
|
-
isRecoverableWatchdogFailure(task) {
|
|
730
|
-
if (['worker_start_timeout', 'terminal_event_timeout', 'worker_exit_before_start', 'worker_exit_without_terminal_event'].includes(task.failureReason ?? ''))
|
|
731
|
-
return true;
|
|
732
|
-
const error = task.error ?? '';
|
|
733
|
-
return error.includes('worker_start_timeout')
|
|
734
|
-
|| error.includes('timed out waiting for the mandatory terminal worker event')
|
|
735
|
-
|| error.includes('exited before publishing task.started')
|
|
736
|
-
|| error.includes('exited after task.started without publishing a terminal task event');
|
|
737
|
-
}
|
|
738
|
-
isCorrelatedWorkerEvent(task, event) {
|
|
739
|
-
return task.executionId !== null && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId);
|
|
740
|
-
}
|
|
741
|
-
async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
|
|
742
|
-
const claim = executionId ?? `${jobId}:${taskId}`;
|
|
743
|
-
if (this.launchFailureClaims.has(claim))
|
|
744
|
-
return;
|
|
745
|
-
this.launchFailureClaims.add(claim);
|
|
746
|
-
const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
|
|
747
|
-
cleanup.unref();
|
|
748
|
-
const job = await this.store.load(jobId);
|
|
749
|
-
const task = job.tasks.find(item => item.id === taskId);
|
|
750
|
-
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state))
|
|
751
|
-
return;
|
|
752
|
-
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker ${phase} failed: ${message}`, 'user', { phase, reason: phase === 'start-ack' ? 'worker_start_timeout' : undefined }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
753
|
-
if (task.state === 'QUEUED') {
|
|
754
|
-
this.clearExecution(task.id, executionId);
|
|
755
|
-
task.error = event.message;
|
|
756
|
-
task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
|
|
757
|
-
task.leaseOwner = null;
|
|
758
|
-
task.leaseExpiresAt = null;
|
|
759
|
-
await transitionTaskEvent(task, 'fail');
|
|
760
|
-
await this.store.saveTask(task);
|
|
761
|
-
}
|
|
762
|
-
else {
|
|
763
|
-
await this.applyTerminalEvent(event, true);
|
|
764
|
-
}
|
|
765
|
-
await this.emit(event);
|
|
766
|
-
}
|
|
767
|
-
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
768
|
-
const live = this.live.get(taskId);
|
|
769
|
-
const liveWorkerStarted = live?.executionId === executionId && live.workerStarted;
|
|
770
|
-
if (live?.executionId === executionId)
|
|
771
|
-
this.live.delete(taskId);
|
|
772
|
-
const job = await this.store.load(jobId);
|
|
773
|
-
const task = job.tasks.find(item => item.id === taskId);
|
|
774
|
-
const workerStarted = liveWorkerStarted || task?.state === 'RUNNING';
|
|
775
|
-
if (!task || task.executionId !== executionId)
|
|
776
|
-
return;
|
|
777
|
-
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
|
|
778
|
-
if (['QUEUED', 'RUNNING'].includes(task.state) && task.adapter === 'codex' && !terminalExecutionClaimed(jobId, taskId, executionId))
|
|
779
|
-
this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
|
|
780
|
-
}
|
|
781
|
-
scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
782
|
-
const graceMs = Math.max(1, this.options.terminalExitGraceMs ?? 1000);
|
|
783
|
-
const timer = setTimeout(() => {
|
|
784
|
-
this.terminalExitGraceTimers.delete(taskId);
|
|
785
|
-
this.detach(this.failUnexpectedExit(jobId, taskId, executionId, workerStarted), jobId, taskId, 'unexpected worker process exit');
|
|
786
|
-
}, graceMs);
|
|
787
|
-
this.terminalExitGraceTimers.set(taskId, timer);
|
|
788
|
-
}
|
|
789
|
-
async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
790
|
-
if (terminalExecutionClaimed(jobId, taskId, executionId))
|
|
791
|
-
return;
|
|
792
|
-
const job = await this.store.load(jobId);
|
|
793
|
-
const task = job.tasks.find(item => item.id === taskId);
|
|
794
|
-
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state) || terminalExecutionClaimed(jobId, taskId, executionId))
|
|
795
|
-
return;
|
|
796
|
-
const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
|
|
797
|
-
const message = workerStarted
|
|
798
|
-
? `Worker ${taskId} exited after task.started without publishing a terminal task event`
|
|
799
|
-
: `Worker ${taskId} exited before publishing task.started`;
|
|
800
|
-
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000 }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
801
|
-
if (task.state === 'QUEUED') {
|
|
802
|
-
this.clearExecution(task.id, executionId);
|
|
803
|
-
task.error = message;
|
|
804
|
-
task.failureReason = reason;
|
|
805
|
-
task.leaseOwner = null;
|
|
806
|
-
task.leaseExpiresAt = null;
|
|
807
|
-
await transitionTaskEvent(task, 'fail');
|
|
808
|
-
await this.store.saveTask(task);
|
|
809
|
-
}
|
|
810
|
-
else {
|
|
811
|
-
await this.applyTerminalEvent(event, true);
|
|
812
|
-
}
|
|
813
|
-
await this.emit(event);
|
|
814
|
-
}
|
|
815
|
-
async timeoutTask(jobId, taskId, executionId) {
|
|
816
|
-
const live = this.live.get(taskId);
|
|
817
|
-
if (live?.executionId === executionId) {
|
|
818
|
-
live.controller.abort();
|
|
819
|
-
live.execution.cancel();
|
|
820
|
-
}
|
|
821
|
-
const job = await this.store.load(jobId);
|
|
822
|
-
const task = job.tasks.find(item => item.id === taskId);
|
|
823
|
-
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state)) {
|
|
824
|
-
this.clearExecution(taskId, executionId);
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Task ${taskId} timed out waiting for the mandatory terminal worker event`, 'user', { reason: 'terminal_event_timeout', timeoutMs: task.timeoutMs });
|
|
828
|
-
if (task.state === 'QUEUED') {
|
|
829
|
-
task.error = event.message;
|
|
830
|
-
task.failureReason = 'terminal_event_timeout';
|
|
831
|
-
task.leaseOwner = null;
|
|
832
|
-
task.leaseExpiresAt = null;
|
|
833
|
-
await transitionTaskEvent(task, 'fail');
|
|
834
|
-
await this.store.saveTask(task);
|
|
835
|
-
this.clearExecution(task.id, executionId);
|
|
836
|
-
}
|
|
837
|
-
else {
|
|
838
|
-
await this.applyTerminalEvent(event, true);
|
|
839
|
-
}
|
|
840
|
-
await this.emit(event);
|
|
841
|
-
}
|
|
842
|
-
async applyTerminalEvent(event, trustedInternal = false) {
|
|
843
|
-
const job = await this.store.load(event.job_id);
|
|
844
|
-
const task = job.tasks.find(item => item.id === event.task_id);
|
|
845
|
-
if (!task || !['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
|
|
846
|
-
return;
|
|
847
|
-
const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
|
|
848
|
-
if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event))
|
|
849
|
-
return;
|
|
850
|
-
if (task.state === 'FAILED' && this.isRecoverableWatchdogFailure(task) && (trustedInternal || !modelBacked || this.isCorrelatedWorkerEvent(task, event))) {
|
|
851
|
-
if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
|
|
852
|
-
return;
|
|
853
|
-
await transitionTaskEvent(task, 'reconcile.started');
|
|
854
|
-
task.failureReason = undefined;
|
|
855
|
-
task.error = null;
|
|
856
|
-
}
|
|
857
|
-
if (task.state !== 'RUNNING')
|
|
858
|
-
return;
|
|
859
|
-
const terminalWaiter = this.terminalWaiters.get(task.id);
|
|
860
|
-
if (terminalWaiter && (!event.timestamp || new Date(event.timestamp).getTime() < terminalWaiter.launchBarrier))
|
|
861
|
-
return;
|
|
862
|
-
const acceptedExecutionId = task.executionId;
|
|
863
|
-
if (modelBacked && acceptedExecutionId)
|
|
864
|
-
claimTerminalExecution(executionEventSource(job.id, task.id, acceptedExecutionId));
|
|
865
|
-
this.clearExecution(task.id, acceptedExecutionId);
|
|
866
|
-
task.leaseOwner = null;
|
|
867
|
-
task.leaseExpiresAt = null;
|
|
868
|
-
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
869
|
-
this.terminalWaiters.get(task.id)?.resolve();
|
|
870
|
-
this.terminalWaiters.delete(task.id);
|
|
871
|
-
if (event.type === 'task.completed') {
|
|
872
|
-
task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
|
|
873
|
-
task.error = null;
|
|
874
|
-
task.failureReason = undefined;
|
|
875
|
-
await transitionTaskEvent(task, 'task.completed');
|
|
876
|
-
await this.store.saveTask(task);
|
|
877
|
-
await this.deliverContinuation(job, task);
|
|
878
|
-
}
|
|
879
|
-
else if (event.type === 'task.failed') {
|
|
880
|
-
task.error = event.message || `Task ${task.id} failed`;
|
|
881
|
-
task.failureReason = typeof event.data?.reason === 'string' ? event.data.reason : undefined;
|
|
882
|
-
await transitionTaskEvent(task, 'task.failed');
|
|
883
|
-
await this.store.saveTask(task);
|
|
884
|
-
}
|
|
885
|
-
else if (event.type === 'task.blocked') {
|
|
886
|
-
task.error = event.message || `Task ${task.id} blocked`;
|
|
887
|
-
await transitionTaskEvent(task, 'task.blocked');
|
|
888
|
-
await this.store.saveTask(task);
|
|
889
|
-
}
|
|
890
|
-
else if (event.type === 'task.cancelled') {
|
|
891
|
-
task.error = event.message || `Task ${task.id} cancelled`;
|
|
892
|
-
await transitionTaskEvent(task, 'task.cancelled');
|
|
893
|
-
await this.store.saveTask(task);
|
|
894
|
-
}
|
|
895
|
-
this.detach(this.reconcile(job.id), job.id, task.id, 'terminal-event reconciliation');
|
|
896
|
-
}
|
|
897
|
-
terminalWait(taskId, executionId, launchBarrier) {
|
|
898
|
-
let resolve;
|
|
899
|
-
const promise = new Promise(resolver => { resolve = resolver; });
|
|
900
|
-
this.terminalWaiters.set(taskId, { executionId, launchBarrier, resolve });
|
|
901
|
-
return {
|
|
902
|
-
promise,
|
|
903
|
-
cancel: () => {
|
|
904
|
-
const current = this.terminalWaiters.get(taskId);
|
|
905
|
-
if (current?.executionId === executionId)
|
|
906
|
-
this.terminalWaiters.delete(taskId);
|
|
907
|
-
},
|
|
908
|
-
};
|
|
909
|
-
}
|
|
910
|
-
async deliverContinuation(job, task) {
|
|
911
|
-
const continuation = task.continuation ?? job.continuation;
|
|
912
|
-
if (!continuation || task.continuationDeliveredAt || this.continuationLive.has(task.id))
|
|
913
|
-
return;
|
|
914
|
-
const adapter = this.options.continuations?.find(candidate => candidate.kind === continuation.kind);
|
|
915
|
-
if (!adapter) {
|
|
916
|
-
task.error = `No continuation adapter for ${continuation.kind}`;
|
|
917
|
-
await this.store.saveTask(task);
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
this.continuationLive.add(task.id);
|
|
921
|
-
try {
|
|
922
|
-
await adapter.continue(task.result?.summary ?? '', continuation);
|
|
923
|
-
const fresh = await this.store.load(job.id);
|
|
924
|
-
const durable = fresh.tasks.find(item => item.id === task.id);
|
|
925
|
-
if (durable?.state === 'SUCCEEDED' && !durable.continuationDeliveredAt) {
|
|
926
|
-
durable.continuationDeliveredAt = new Date().toISOString();
|
|
927
|
-
await this.store.saveTask(durable);
|
|
928
|
-
}
|
|
929
|
-
}
|
|
930
|
-
catch (error) {
|
|
931
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'continuation.failed', 'failed', `Continuation failed: ${error instanceof Error ? error.message : String(error)}`, 'user'));
|
|
932
|
-
}
|
|
933
|
-
finally {
|
|
934
|
-
this.continuationLive.delete(task.id);
|
|
935
|
-
}
|
|
936
|
-
}
|
|
937
|
-
async emit(event) { try {
|
|
938
|
-
if (this.options.emit)
|
|
939
|
-
await this.options.emit(event);
|
|
940
|
-
}
|
|
941
|
-
catch { /* observability cannot block durable execution */ } try {
|
|
942
|
-
if (this.options.eventBus)
|
|
943
|
-
await this.options.eventBus.publish(event);
|
|
944
|
-
}
|
|
945
|
-
catch (error) {
|
|
946
|
-
await this.reportTransportFailure(error);
|
|
947
|
-
} }
|
|
948
|
-
detach(promise, jobId, taskId, context) {
|
|
949
|
-
void promise.catch(error => this.reportDetachedFailure(jobId, taskId, context, error));
|
|
950
|
-
}
|
|
951
|
-
async reportDetachedFailure(jobId, taskId, context, error) {
|
|
952
|
-
if (!this.options.emit)
|
|
953
|
-
return;
|
|
954
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
955
|
-
try {
|
|
956
|
-
await this.options.emit(eventFor(jobId, taskId, null, 'runtime.persistence.failed', 'failed', `${context} failed: ${message}`, 'orchestrator'));
|
|
957
|
-
}
|
|
958
|
-
catch { /* detached failure reporting must never crash the daemon */ }
|
|
959
|
-
}
|
|
960
|
-
async reportTransportFailure(error) { if (this.options.emit) {
|
|
961
|
-
try {
|
|
962
|
-
await this.options.emit(eventFor('system', 'event-bus', null, 'event.transport.failed', 'failed', `Event transport unavailable: ${error instanceof Error ? error.message : String(error)}`, 'orchestrator'));
|
|
963
|
-
}
|
|
964
|
-
catch { /* reporting is best effort */ }
|
|
965
|
-
} }
|
|
966
497
|
}
|
|
967
498
|
function samePlannerChildDefinition(left, right) {
|
|
968
499
|
return JSON.stringify({ id: left.id, input: left.input, output: left.output, priority: left.priority, storySize: left.storySize ?? 'M', projectName: left.projectName, agentName: left.agentName, agentGraph: left.agentGraph, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, output: right.output, priority: right.priority, storySize: right.storySize ?? 'M', projectName: right.projectName, agentName: right.agentName, agentGraph: right.agentGraph, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
|