agents-relay 2.0.68 → 2.0.70
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 +32 -1
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -28
- package/dist/cli-help.js +11 -8
- package/dist/cli-job-create.js +19 -13
- package/dist/cli-local-merge-config.js +4 -2
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +59 -4
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +179 -129
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +26 -1
- package/dist/e2e-injection.js +76 -0
- package/dist/github-binding-order.js +15 -0
- package/dist/github-client.js +7 -2
- package/dist/github-pr-archive.js +22 -0
- package/dist/github-webhook.js +31 -6
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +10 -1
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +17 -6
- package/dist/local-job-path.js +5 -0
- package/dist/local-job-registry.js +10 -4
- package/dist/local-managed-store.js +2 -4
- package/dist/local-merge-delivery.js +3 -2
- package/dist/local-merge-projection.js +60 -0
- package/dist/managed-worker-input.js +13 -6
- package/dist/manual-plan.js +29 -7
- package/dist/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +22 -6
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +74 -18
- package/dist/reconciler-runtime.js +69 -34
- package/dist/reconciler.js +92 -26
- package/dist/relay-store.js +1 -1
- package/dist/relayd-control.js +184 -0
- package/dist/relayd-dashboard-actions.js +108 -0
- package/dist/relayd-utilities.js +107 -0
- package/dist/relayd.js +220 -210
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +13 -0
- package/dist/service-publication.js +2 -4
- package/dist/task-actions.js +59 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +5 -4
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- package/workflow/review-lifecycle.mmd +3 -0
|
@@ -6,9 +6,11 @@ import { workerCandidates } from './worker-routing.js';
|
|
|
6
6
|
import { routeExecution } from './execution-routing.js';
|
|
7
7
|
import { ReconcilerRuntimeBase } from './reconciler-runtime.js';
|
|
8
8
|
import { dispatchReviewLifecycle, reviewOutcome } from './workflow/review-v2.js';
|
|
9
|
+
import { dispatchTaskLifecycleSync } from './workflow/lifecycle.js';
|
|
9
10
|
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
10
11
|
import { localSourceHead, localWorkingHead } from './local-git-merge.js';
|
|
11
12
|
import { chatgptThreadRequirements, resolveReviewerThreadDestination } from './reviewer-thread.js';
|
|
13
|
+
import { reviewGenerationMatches } from './review-generation-fence.js';
|
|
12
14
|
export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
13
15
|
async currentHeadSha(job) {
|
|
14
16
|
// GitHub PR head facts are audit-only. Source identity is local Git.
|
|
@@ -118,8 +120,12 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
118
120
|
}
|
|
119
121
|
throw new Error('Too many final review rounds for ' + headSha);
|
|
120
122
|
}
|
|
123
|
+
externalReviewPrNumber(job) {
|
|
124
|
+
return job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : job.prNumber;
|
|
125
|
+
}
|
|
121
126
|
async createFinalReviewTask(job, headSha) {
|
|
122
127
|
const id = this.nextFinalReviewTaskId(job, headSha);
|
|
128
|
+
const reviewPrNumber = this.externalReviewPrNumber(job);
|
|
123
129
|
const now = new Date().toISOString();
|
|
124
130
|
const reviewGateTasks = implementationTasks(job);
|
|
125
131
|
const reviewThread = resolveReviewerThreadDestination(job, this.options.reviewer?.threadId);
|
|
@@ -130,13 +136,13 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
130
136
|
threadSource: reviewThread.source,
|
|
131
137
|
requestId: `${job.id}:${id}:${headSha}`,
|
|
132
138
|
jobId: job.id,
|
|
133
|
-
prNumber:
|
|
139
|
+
prNumber: reviewPrNumber,
|
|
134
140
|
reviewHeadSha: headSha,
|
|
135
141
|
projectId: reviewerRouting.candidate.routing.projectId ?? null,
|
|
136
142
|
} : undefined;
|
|
137
143
|
const input = [
|
|
138
144
|
`Job ID: ${job.id}`,
|
|
139
|
-
`Review PR #${
|
|
145
|
+
`Review PR #${reviewPrNumber} at exactly head SHA ${headSha}.`,
|
|
140
146
|
`Review head SHA: ${headSha}`,
|
|
141
147
|
'Job objective: ' + (job.objective ?? job.description ?? job.title),
|
|
142
148
|
'Use the selected reviewer agent contract as the authoritative review protocol.',
|
|
@@ -247,7 +253,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
247
253
|
id: job.id, title: job.title, objective: job.objective ?? null,
|
|
248
254
|
description: job.description ?? null, priority: job.priority ?? null,
|
|
249
255
|
executionMode: job.executionMode, repository: job.repository,
|
|
250
|
-
prNumber: job
|
|
256
|
+
prNumber: this.externalReviewPrNumber(job), state: job.state,
|
|
251
257
|
},
|
|
252
258
|
task: {
|
|
253
259
|
id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
|
|
@@ -264,9 +270,11 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
264
270
|
? job.tasks.find(item => item.id === task.parentTaskId)
|
|
265
271
|
: undefined;
|
|
266
272
|
const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
|
|
267
|
-
const
|
|
273
|
+
const parentExcludedRoutes = parentRouteFailedBeforeStart && parent?.routing
|
|
268
274
|
? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
|
|
269
275
|
: [];
|
|
276
|
+
const taskExcludedRoutes = (task.routingExclusions ?? []).map(item => ({ provider: item.provider, model: item.model, profile: item.profile }));
|
|
277
|
+
const excludedRoutes = [...taskExcludedRoutes, ...parentExcludedRoutes];
|
|
270
278
|
const routed = await routeExecution(candidates, requirements, {
|
|
271
279
|
decisionEngine: this.options.decisionEngine,
|
|
272
280
|
decisionMode: this.options.decisionMode,
|
|
@@ -307,19 +315,23 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
307
315
|
// their first trustworthy review event to PENDING before dispatching it.
|
|
308
316
|
if (!job.review && task.planningDecisionId) {
|
|
309
317
|
job.review = { outcome: 'pending', headSha, taskId: task.id, evidence: null, updatedAt: event.timestamp || new Date().toISOString() };
|
|
310
|
-
await this.store.saveJob(job);
|
|
311
318
|
}
|
|
312
|
-
const taskDecision =
|
|
319
|
+
const taskDecision = this.store.mutateJob
|
|
320
|
+
? dispatchTaskLifecycleSync(structuredClone(task), event.type)
|
|
321
|
+
: tryTransitionTaskEvent(task, event.type);
|
|
313
322
|
if (!taskDecision.accepted || !taskDecision.target)
|
|
314
323
|
return { applied: false, reconcile: false, reason: event.type + '.ignored' };
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
task.
|
|
324
|
+
if (!this.store.mutateJob && taskDecision.target !== 'RUNNING' && !this.finalizeReviewExecution(job.id, task, event)) {
|
|
325
|
+
return { applied: false, reconcile: false, reason: event.type + '.stale-execution' };
|
|
326
|
+
}
|
|
327
|
+
if (!this.store.mutateJob) {
|
|
328
|
+
task.error = null;
|
|
329
|
+
if (taskDecision.target !== 'RUNNING')
|
|
330
|
+
task.result = { summary: event.message || `Review ${event.type}`, data: event.data ?? {} };
|
|
331
|
+
await this.store.saveTask(task);
|
|
320
332
|
}
|
|
321
|
-
await this.store.saveTask(task);
|
|
322
333
|
let needsReconcile = false;
|
|
334
|
+
let pendingFixTask = null;
|
|
323
335
|
const reviewDecision = await dispatchReviewLifecycle(job.review?.outcome, event.type, { autoMerge: job.autoMerge !== false }, async (actionId) => {
|
|
324
336
|
if (actionId === 'review.createFix') {
|
|
325
337
|
needsReconcile = true;
|
|
@@ -329,10 +341,9 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
329
341
|
const now = event.timestamp || new Date().toISOString();
|
|
330
342
|
const id = 'review-fix-' + headSha.slice(0, 8);
|
|
331
343
|
if (!job.tasks.some(item => item.id === id)) {
|
|
332
|
-
const input = 'Address all reviewer findings for PR #' + job
|
|
344
|
+
const input = 'Address all reviewer findings for PR #' + this.externalReviewPrNumber(job) + ' at ' + headSha + ':\n\n' + issues.map((issue, index) => (index + 1) + '. ' + issue).join('\n');
|
|
333
345
|
const fixTask = createTaskEntity(job, { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'implementation', parentTaskId: task.id, dependencies: [task.id], capabilities: ['implementation'], workerRequirements: { capabilities: [] }, adapter: 'codex', input, output: { kind: 'task_pr' }, continuation: null, continuationDeliveredAt: null, attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: 0, createdAt: now, updatedAt: now });
|
|
334
|
-
|
|
335
|
-
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' }));
|
|
346
|
+
pendingFixTask = fixTask;
|
|
336
347
|
}
|
|
337
348
|
return {};
|
|
338
349
|
}
|
|
@@ -352,9 +363,54 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
352
363
|
const evidence = { summary: event.message || 'Review ' + outcome, data: {
|
|
353
364
|
...(event.data ?? {}), review: { ...(reviewData ?? {}), headSha }, headSha, outcome, source: event.source,
|
|
354
365
|
} };
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
366
|
+
const reviewUpdatedAt = event.timestamp || new Date().toISOString();
|
|
367
|
+
if (this.store.mutateJob) {
|
|
368
|
+
let committed = false;
|
|
369
|
+
let terminalExecutionId = null;
|
|
370
|
+
const updated = await this.store.mutateJob(job.id, current => {
|
|
371
|
+
const freshTask = current.tasks.find(item => item.id === task.id);
|
|
372
|
+
if (!freshTask || !reviewGenerationMatches(current, job, task, headSha))
|
|
373
|
+
return current;
|
|
374
|
+
const freshDecision = tryTransitionTaskEvent(freshTask, event.type);
|
|
375
|
+
if (!freshDecision.accepted || !freshDecision.target)
|
|
376
|
+
return current;
|
|
377
|
+
if (freshDecision.target !== 'RUNNING') {
|
|
378
|
+
if (!this.finalizeReviewExecution(job.id, freshTask, event, false))
|
|
379
|
+
return current;
|
|
380
|
+
terminalExecutionId = freshTask.executionId;
|
|
381
|
+
}
|
|
382
|
+
freshTask.error = null;
|
|
383
|
+
if (freshDecision.target !== 'RUNNING')
|
|
384
|
+
freshTask.result = { summary: event.message || `Review ${event.type}`, data: event.data ?? {} };
|
|
385
|
+
if (pendingFixTask && !current.tasks.some(item => item.id === pendingFixTask.id))
|
|
386
|
+
current.tasks.push(structuredClone(pendingFixTask));
|
|
387
|
+
current.review = { outcome, headSha, taskId: freshTask.id, evidence, updatedAt: reviewUpdatedAt };
|
|
388
|
+
current.updatedAt = reviewUpdatedAt;
|
|
389
|
+
committed = true;
|
|
390
|
+
return current;
|
|
391
|
+
});
|
|
392
|
+
if (!committed)
|
|
393
|
+
return { applied: false, reconcile: false, reason: event.type + '.stale-execution' };
|
|
394
|
+
Object.assign(job, structuredClone(updated));
|
|
395
|
+
// Live waiter cleanup/terminal claims are irreversible; run after commit.
|
|
396
|
+
if (terminalExecutionId) {
|
|
397
|
+
this.finalizeReviewExecution(job.id, { ...task, executionId: terminalExecutionId }, event);
|
|
398
|
+
}
|
|
399
|
+
const committedFixTask = pendingFixTask;
|
|
400
|
+
if (committedFixTask)
|
|
401
|
+
await this.emit(eventFor(job.id, committedFixTask.id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + committedFixTask.id + ' for reviewer findings', 'orchestrator', { headSha }, { agent: 'relay', component: 'agents-relay' }));
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
const legacyFixTask = pendingFixTask;
|
|
405
|
+
if (legacyFixTask) {
|
|
406
|
+
await this.store.appendTask(legacyFixTask);
|
|
407
|
+
await this.emit(eventFor(job.id, legacyFixTask.id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + legacyFixTask.id + ' for reviewer findings', 'orchestrator', { headSha }, { agent: 'relay', component: 'agents-relay' }));
|
|
408
|
+
}
|
|
409
|
+
job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: reviewUpdatedAt };
|
|
410
|
+
job.updatedAt = reviewUpdatedAt;
|
|
411
|
+
await this.store.saveTask(task);
|
|
412
|
+
await this.store.saveJob(job);
|
|
413
|
+
}
|
|
358
414
|
// Review approval records evidence only. The subsequent Graph reconcile
|
|
359
415
|
// drives durable local Git merge delivery and terminalization.
|
|
360
416
|
if (outcome === 'approved')
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { applyJobEffectOutcomes, applyTaskEffectOutcomes } from './workflow-effect-failures.js';
|
|
2
|
+
import { classifyWorkerRejection, reroutableWorkerRejections } from './worker-rejection.js';
|
|
1
3
|
import { applyTaskRetryAction } from './task-actions.js';
|
|
2
|
-
import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
3
4
|
import { DurableTaskTransitionRuntime } from './workflow/durable-transition.js';
|
|
4
5
|
import { eventFor } from './events.js';
|
|
5
6
|
import { WorkflowEffectExecutor } from './workflow/effects.js';
|
|
6
|
-
import { workflowEffectFailureEvent } from './workflow/contracts.js';
|
|
7
7
|
import { ReconcileCoordinator } from './reconcile-coordinator.js';
|
|
8
8
|
import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
|
|
9
9
|
import { rearmTerminalEventDeadline } from './terminal-event-watchdog.js';
|
|
@@ -77,31 +77,10 @@ export class ReconcilerRuntimeBase {
|
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
79
|
async applyJobEffectOutcomes(job, outcomes) {
|
|
80
|
-
|
|
81
|
-
const eventName = workflowEffectFailureEvent(outcome.effect);
|
|
82
|
-
try {
|
|
83
|
-
await transitionJobEvent(job, eventName);
|
|
84
|
-
await this.store.saveJob(job);
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// Some effects are observational for a given state; failure is still emitted below.
|
|
88
|
-
}
|
|
89
|
-
await this.emit(eventFor(job.id, 'job', null, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
|
|
90
|
-
}
|
|
80
|
+
await applyJobEffectOutcomes(this.store, event => this.emit(event), job, outcomes);
|
|
91
81
|
}
|
|
92
82
|
async applyTaskEffectOutcomes(job, task, outcomes) {
|
|
93
|
-
|
|
94
|
-
const eventName = workflowEffectFailureEvent(outcome.effect);
|
|
95
|
-
try {
|
|
96
|
-
await transitionTaskEvent(task, eventName);
|
|
97
|
-
task.error = outcome.error ?? `${outcome.effect} failed`;
|
|
98
|
-
await this.store.saveTask(task);
|
|
99
|
-
}
|
|
100
|
-
catch {
|
|
101
|
-
// The effect failure remains observable even if this state has no feedback transition.
|
|
102
|
-
}
|
|
103
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
|
|
104
|
-
}
|
|
83
|
+
await applyTaskEffectOutcomes(this.store, event => this.emit(event), job, task, outcomes);
|
|
105
84
|
}
|
|
106
85
|
async applyEvent(event) {
|
|
107
86
|
const sourceId = eventSourceId(event);
|
|
@@ -256,6 +235,28 @@ export class ReconcilerRuntimeBase {
|
|
|
256
235
|
isCorrelatedWorkerEvent(task, event) {
|
|
257
236
|
return task.executionId !== null && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId);
|
|
258
237
|
}
|
|
238
|
+
finalizeReviewExecution(jobId, task, event, afterCommit = true) {
|
|
239
|
+
if (!task.executionId)
|
|
240
|
+
return true;
|
|
241
|
+
const sourceId = eventSourceId(event);
|
|
242
|
+
const expected = executionEventSource(jobId, task.id, task.executionId);
|
|
243
|
+
if ((task.adapter === 'codex' || task.adapter === 'chatgpt') && sourceId !== expected)
|
|
244
|
+
return false;
|
|
245
|
+
if (afterCommit)
|
|
246
|
+
claimTerminalExecution(expected);
|
|
247
|
+
const executionId = task.executionId;
|
|
248
|
+
if (afterCommit)
|
|
249
|
+
this.clearExecution(task.id, executionId);
|
|
250
|
+
task.leaseOwner = null;
|
|
251
|
+
task.leaseExpiresAt = null;
|
|
252
|
+
task.executionId = null;
|
|
253
|
+
task.updatedAt = new Date().toISOString();
|
|
254
|
+
if (afterCommit) {
|
|
255
|
+
this.terminalWaiters.get(task.id)?.resolve();
|
|
256
|
+
this.terminalWaiters.delete(task.id);
|
|
257
|
+
}
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
259
260
|
async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
|
|
260
261
|
const claim = executionId ?? `${jobId}:${taskId}`;
|
|
261
262
|
if (this.launchFailureClaims.has(claim))
|
|
@@ -282,6 +283,11 @@ export class ReconcilerRuntimeBase {
|
|
|
282
283
|
}
|
|
283
284
|
const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: transition.task.failureReason, workflowEvent }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
284
285
|
this.detach(this.emit(event), jobId, taskId, 'worker failure event');
|
|
286
|
+
// failLaunch already committed the authoritative FAILED state. The emitted
|
|
287
|
+
// task.failed observation may be ignored as a duplicate, so explicitly wake
|
|
288
|
+
// graph reconciliation from the durable failure fact. This is required for
|
|
289
|
+
// automatic Troubleshooter recovery, including reviewer start-ack failures.
|
|
290
|
+
this.detach(this.eventCoordinator.accept({ jobId, reason: `worker-${phase}-failure` }), jobId, taskId, 'worker failure reconcile wake');
|
|
285
291
|
}
|
|
286
292
|
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
287
293
|
const live = this.live.get(taskId);
|
|
@@ -291,7 +297,15 @@ export class ReconcilerRuntimeBase {
|
|
|
291
297
|
}
|
|
292
298
|
const job = await this.store.load(jobId);
|
|
293
299
|
const task = job.tasks.find(item => item.id === taskId);
|
|
294
|
-
|
|
300
|
+
// Async transport settlement is an independent, non-authoritative observation.
|
|
301
|
+
// A worker terminal event may already have claimed the Task; do not suppress
|
|
302
|
+
// transport-exit evidence merely because business completion won the race.
|
|
303
|
+
if (!task)
|
|
304
|
+
return;
|
|
305
|
+
const correlated = task.executionId === executionId || task.executionHistory?.some(item => item.id === executionId);
|
|
306
|
+
if (!correlated)
|
|
307
|
+
return;
|
|
308
|
+
if (task.adapter !== 'chatgpt' && (task.executionId !== executionId || terminalExecutionClaimed(jobId, taskId, executionId)))
|
|
295
309
|
return;
|
|
296
310
|
if (task.adapter !== 'chatgpt' && task.executionId === executionId && live?.executionId === executionId)
|
|
297
311
|
rearmTerminalEventDeadline(this.terminalTimers, taskId, task.timeoutMs - (Date.now() - live.launchBarrier), this.options.postExitTerminalGraceMs ?? 15_000, () => this.detach(this.timeoutTask(jobId, taskId, executionId), jobId, taskId, 'post-exit terminal event timeout'));
|
|
@@ -307,7 +321,7 @@ export class ReconcilerRuntimeBase {
|
|
|
307
321
|
const exit = live?.executionId === executionId && live.processSettled ? live.execution.processExit : undefined;
|
|
308
322
|
const rawError = live?.executionId === executionId && live.processSettled ? (live.processError || exit?.stderr || '').trim() : '';
|
|
309
323
|
const failedExit = Boolean(exit && (exit.exitCode !== 0 || exit.signal));
|
|
310
|
-
const failureReason = failedExit ? (
|
|
324
|
+
const failureReason = failedExit ? classifyWorkerRejection(rawError) : 'terminal_event_timeout';
|
|
311
325
|
const failureMessage = failedExit ? (rawError || `Worker exited with code ${exit?.exitCode ?? 'null'}${exit?.signal ? `, signal ${exit.signal}` : ''}`) : `Task ${taskId} timed out waiting for the mandatory terminal worker event`;
|
|
312
326
|
const transition = await this.taskTransitions.apply(jobId, taskId, 'watchdog.failed', {
|
|
313
327
|
eligible: (_job, task) => task.executionId === executionId,
|
|
@@ -323,21 +337,38 @@ export class ReconcilerRuntimeBase {
|
|
|
323
337
|
this.clearExecution(taskId, executionId);
|
|
324
338
|
return;
|
|
325
339
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
if (failureReason
|
|
340
|
+
let reroutedTask = null;
|
|
341
|
+
// Provider quota failures are recoverable. Commit the retry + route exclusion
|
|
342
|
+
// before publishing task.failed so event-driven reconciliation can never see
|
|
343
|
+
// an intermediate FAILED state and spawn a Troubleshooter for quota exhaustion.
|
|
344
|
+
if (reroutableWorkerRejections.has(failureReason) && transition.task.attempt < transition.task.maxAttempts) {
|
|
331
345
|
const current = (await this.store.load(jobId)).tasks.find(task => task.id === taskId);
|
|
332
346
|
if (current?.state === 'FAILED' && current.failureReason === failureReason && current.attempt === transition.task.attempt) {
|
|
347
|
+
const failedRoute = current.routing;
|
|
333
348
|
await applyTaskRetryAction(current);
|
|
349
|
+
if (failedRoute) {
|
|
350
|
+
const duplicate = current.routingExclusions?.some(item => item.provider === failedRoute.provider && item.model === failedRoute.model && item.profile === failedRoute.profile);
|
|
351
|
+
if (!duplicate)
|
|
352
|
+
current.routingExclusions = [...(current.routingExclusions ?? []), {
|
|
353
|
+
provider: failedRoute.provider,
|
|
354
|
+
model: failedRoute.model,
|
|
355
|
+
...(failedRoute.profile ? { profile: failedRoute.profile } : {}),
|
|
356
|
+
reason: 'rate_limit_exceeded',
|
|
357
|
+
at: new Date().toISOString(),
|
|
358
|
+
}];
|
|
359
|
+
}
|
|
334
360
|
current.routing = undefined;
|
|
335
361
|
current.workerSelection = undefined;
|
|
336
362
|
await this.store.saveTask(current);
|
|
337
|
-
|
|
338
|
-
await this.eventCoordinator.accept({ jobId, reason: 'rate-limit-reroute' });
|
|
363
|
+
reroutedTask = current;
|
|
339
364
|
}
|
|
340
365
|
}
|
|
366
|
+
const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? failureMessage, 'user', { reason: failureReason, exitCode: exit?.exitCode ?? null, signal: exit?.signal ?? null, timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed', executionId }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
367
|
+
await this.emit(event);
|
|
368
|
+
if (reroutedTask) {
|
|
369
|
+
await this.emit(eventFor(jobId, taskId, reroutedTask.parentTaskId, 'task.rerouting', 'queued', `Worker rejection ${failureReason}; retrying with fresh routing and usage data`, 'orchestrator', { previousFailure: failureReason, attempt: reroutedTask.attempt, maxAttempts: reroutedTask.maxAttempts }));
|
|
370
|
+
await this.eventCoordinator.accept({ jobId, reason: 'worker-rejection-reroute' });
|
|
371
|
+
}
|
|
341
372
|
}
|
|
342
373
|
async applyTerminalEvent(event, trustedInternal = false) {
|
|
343
374
|
const liveAtTerminal = this.live.get(event.task_id);
|
|
@@ -346,6 +377,10 @@ export class ReconcilerRuntimeBase {
|
|
|
346
377
|
eligible: (_job, task) => {
|
|
347
378
|
if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
|
|
348
379
|
return false;
|
|
380
|
+
// Reviewer business terminal state is owned by review.approved / review.changes_required / review.blocked.
|
|
381
|
+
// A generic task.completed may arrive before the review decision and must not consume the execution generation.
|
|
382
|
+
if (task.agentName === 'reviewer' && event.type === 'task.completed')
|
|
383
|
+
return false;
|
|
349
384
|
const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
|
|
350
385
|
if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event)) {
|
|
351
386
|
const eventExecutionId = executionIdFromSource(eventSourceId(event), _job.id, task.id);
|
package/dist/reconciler.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
import { cancelActiveTasks } from './reconciler-cancellation.js';
|
|
2
|
+
import { authorizedInjection } from './e2e-injection.js';
|
|
1
3
|
import { implementationComplete } from './runtime-status.js';
|
|
2
4
|
import { bindPlannedReviewHead } from './planned-review-head.js';
|
|
3
5
|
import { randomUUID } from 'node:crypto';
|
|
4
6
|
import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
|
|
5
7
|
import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
|
|
6
|
-
import {
|
|
8
|
+
import { applyTaskRetryAction } from './task-actions.js';
|
|
7
9
|
import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
8
10
|
import { eventFor } from './events.js';
|
|
9
11
|
import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
|
|
12
|
+
import { retryPendingPlannerUnblocks } from './planner-runtime.js';
|
|
10
13
|
import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
|
|
11
14
|
import { resolveExecutionContext } from './execution-context.js';
|
|
12
15
|
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
@@ -15,7 +18,7 @@ import { executeManualPlan } from './manual-plan.js';
|
|
|
15
18
|
import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
|
|
16
19
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
17
20
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
18
|
-
import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
21
|
+
import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
19
22
|
import { taskUsesCanonicalProjectRun } from './workspace.js';
|
|
20
23
|
import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
|
|
21
24
|
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
@@ -98,10 +101,49 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
98
101
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.orchestrator_rescued', 'queued', `Stale orchestrator task ${task.id} converted to ChatGPT execution`, 'orchestrator', { ageMs: rescue.ageMs, adapter: 'chatgpt' }, { agent: 'relay', component: 'agents-relay' }));
|
|
99
102
|
}
|
|
100
103
|
await this.recoverPlannerChildren(job);
|
|
104
|
+
await retryPendingPlannerUnblocks({
|
|
105
|
+
store: this.store,
|
|
106
|
+
options: this.options,
|
|
107
|
+
live: this.plannerLive,
|
|
108
|
+
plannerContext: (currentJob, currentPlan) => this.plannerContext(currentJob, currentPlan),
|
|
109
|
+
emit: event => this.emit(event),
|
|
110
|
+
detach: (promise, currentJobId, planId, context) => this.detach(promise, currentJobId, planId, context),
|
|
111
|
+
reconcile: currentJobId => this.reconcile(currentJobId),
|
|
112
|
+
}, jobId);
|
|
101
113
|
for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
|
|
102
114
|
await this.store.appendTask(recovery);
|
|
103
115
|
await this.emit(eventFor(job.id, recovery.id, null, "troubleshooter.created", "queued", "Troubleshooter task " + recovery.id + " created for failed/blocked work", "orchestrator", { agentName: "troubleshooter" }));
|
|
104
116
|
}
|
|
117
|
+
{
|
|
118
|
+
const recoveryJob = await this.store.load(jobId);
|
|
119
|
+
// A failed recovery worker is itself recoverable: retry the SAME Task
|
|
120
|
+
// within its budget. No recursive Troubleshooter is generated.
|
|
121
|
+
for (const recovery of recoveryJob.tasks) {
|
|
122
|
+
if (recovery.agentName !== 'troubleshooter' || recovery.state !== 'FAILED' || recovery.attempt >= recovery.maxAttempts)
|
|
123
|
+
continue;
|
|
124
|
+
const original = recoveryJob.tasks.find(task => task.id === recovery.parentTaskId);
|
|
125
|
+
if (!original || !['FAILED', 'BLOCKED'].includes(original.state))
|
|
126
|
+
continue;
|
|
127
|
+
await applyTaskRetryAction(recovery);
|
|
128
|
+
recovery.routing = undefined;
|
|
129
|
+
recovery.workerSelection = undefined;
|
|
130
|
+
await this.store.saveTask(recovery);
|
|
131
|
+
await this.emit(eventFor(job.id, recovery.id, original.id, 'troubleshooter.retry_queued', 'queued', `Retrying failed Troubleshooter ${recovery.id} (attempt ${recovery.attempt + 1}/${recovery.maxAttempts})`, 'orchestrator', { originalTaskId: original.id, attempt: recovery.attempt + 1, maxAttempts: recovery.maxAttempts }));
|
|
132
|
+
}
|
|
133
|
+
const exhaustedJob = await this.store.load(jobId);
|
|
134
|
+
for (const exhausted of exhaustedTroubleshooterRecoveries(exhaustedJob)) {
|
|
135
|
+
const at = new Date().toISOString();
|
|
136
|
+
exhaustedJob.recoveryFailures = [...(exhaustedJob.recoveryFailures ?? []), {
|
|
137
|
+
taskId: exhausted.original.id,
|
|
138
|
+
recoveryTaskId: exhausted.recovery.id,
|
|
139
|
+
reason: exhausted.reason,
|
|
140
|
+
at,
|
|
141
|
+
}];
|
|
142
|
+
exhaustedJob.updatedAt = at;
|
|
143
|
+
await this.store.saveJob(exhaustedJob);
|
|
144
|
+
await this.emit(eventFor(job.id, exhausted.recovery.id, exhausted.original.id, 'recovery.exhausted', 'failed', `Recovery exhausted for ${exhausted.original.id}: ${exhausted.reason}`, 'user', { originalTaskId: exhausted.original.id, recoveryTaskId: exhausted.recovery.id, reason: exhausted.reason }, { agent: 'relay', component: 'agents-relay' }));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
105
147
|
for (const task of job.tasks)
|
|
106
148
|
if (task.state === 'CANCELLED') {
|
|
107
149
|
this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
|
|
@@ -133,7 +175,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
133
175
|
await this.ensureFinalReview(finalState, headSha);
|
|
134
176
|
await this.dispatchExecutableTasks(jobId);
|
|
135
177
|
finalState = await this.store.load(jobId);
|
|
136
|
-
if (await reconcileLocalGitCompletion(this.store, finalState, headSha, true, this.options.projectPathForJob))
|
|
178
|
+
if (await reconcileLocalGitCompletion(this.store, finalState, headSha, this.options.noGitHub === true, this.options.projectPathForJob))
|
|
137
179
|
return this.store.load(jobId);
|
|
138
180
|
await dispatchJobReconcile(finalState, { jobActive: finalState.state === 'ACTIVE', implementationComplete: implementationComplete(finalState), headAvailable: Boolean(headSha), deliveryReady: finalState.localMerge?.status === 'succeeded' }, async (actionId) => {
|
|
139
181
|
const outcomes = await this.workflowEffects.execute([actionId], { jobId: finalState.id, transitionKey: `reconcile:${headSha ?? 'no-head'}`, data: { headSha } });
|
|
@@ -172,29 +214,11 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
172
214
|
}
|
|
173
215
|
}
|
|
174
216
|
async cancelActiveTasks(jobId, reason) {
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
live.controller.abort();
|
|
181
|
-
this.plannerLive.delete(plan.id);
|
|
182
|
-
}
|
|
183
|
-
plan.status = 'FAILED';
|
|
184
|
-
plan.error = reason;
|
|
185
|
-
plan.assessment = `Planning failed: ${reason}`;
|
|
186
|
-
plan.leaseOwner = null;
|
|
187
|
-
plan.leaseExpiresAt = null;
|
|
188
|
-
plan.executionId = null;
|
|
189
|
-
plan.updatedAt = new Date().toISOString();
|
|
190
|
-
await this.store.savePlan(plan);
|
|
191
|
-
}
|
|
192
|
-
for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
|
|
193
|
-
this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
|
|
194
|
-
this.plannerLive.get(task.id)?.controller.abort();
|
|
195
|
-
await applyTaskCancelAction(task, reason);
|
|
196
|
-
await this.store.saveTask(task);
|
|
197
|
-
}
|
|
217
|
+
return cancelActiveTasks(this.store, jobId, reason, taskId => { const live = this.plannerLive.get(taskId); if (live) {
|
|
218
|
+
clearTimeout(live.timer);
|
|
219
|
+
live.controller.abort();
|
|
220
|
+
this.plannerLive.delete(taskId);
|
|
221
|
+
} }, (job, taskId, executionId) => { this.cancelExecutionGeneration(job, taskId, executionId); });
|
|
198
222
|
}
|
|
199
223
|
executionRecoveryPorts(job) {
|
|
200
224
|
return {
|
|
@@ -339,6 +363,31 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
339
363
|
attempt: freshTask.attempt,
|
|
340
364
|
startedAt: new Date(startedAt).toISOString(),
|
|
341
365
|
}].slice(-8);
|
|
366
|
+
if (this.store.mutateJob) {
|
|
367
|
+
let claimed;
|
|
368
|
+
await this.store.mutateJob(job.id, current => {
|
|
369
|
+
const durable = current.tasks.find(item => item.id === freshTask.id);
|
|
370
|
+
if (!durable || durable.state !== 'QUEUED' || durable.executionId !== null
|
|
371
|
+
|| !dependencyReady(durable, new Map(current.tasks.map(item => [item.id, item]))))
|
|
372
|
+
return current;
|
|
373
|
+
// Routing is a pre-claim fact; copy only that fact into the fresh
|
|
374
|
+
// authoritative snapshot, then claim the generation atomically.
|
|
375
|
+
if (freshTask.routing)
|
|
376
|
+
durable.routing = structuredClone(freshTask.routing);
|
|
377
|
+
if (freshTask.workerSelection)
|
|
378
|
+
durable.workerSelection = structuredClone(freshTask.workerSelection);
|
|
379
|
+
durable.attempt += 1;
|
|
380
|
+
durable.leaseOwner = this.options.owner;
|
|
381
|
+
durable.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
|
|
382
|
+
durable.executionId = randomUUID();
|
|
383
|
+
durable.executionHistory = [...(durable.executionHistory ?? []), {
|
|
384
|
+
id: durable.executionId, attempt: durable.attempt, startedAt: new Date(startedAt).toISOString(),
|
|
385
|
+
}].slice(-8);
|
|
386
|
+
claimed = structuredClone(durable);
|
|
387
|
+
return current;
|
|
388
|
+
});
|
|
389
|
+
return claimed;
|
|
390
|
+
}
|
|
342
391
|
await this.store.saveTask(freshTask);
|
|
343
392
|
return freshTask;
|
|
344
393
|
}) ?? null;
|
|
@@ -380,7 +429,24 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
380
429
|
const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
|
|
381
430
|
const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
|
|
382
431
|
const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal' ? this.terminalWait(task.id, durableExecutionId, startedAt - 1) : null;
|
|
432
|
+
// Consume E2E injection before execution: a retry will not repeat it.
|
|
433
|
+
// Production repositories ignore the option entirely.
|
|
434
|
+
const declaration = authorizedInjection(job.repository, task.testInjection);
|
|
435
|
+
const testInjection = declaration && ((task.adapter === 'codex' && declaration.method === 'worker.codex.launch') || (task.adapter === 'chatgpt' && declaration.method === 'browserWorkspace.command')) ? declaration : undefined;
|
|
436
|
+
if (testInjection) {
|
|
437
|
+
const durable = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
|
|
438
|
+
if (!durable || durable.executionId !== durableExecutionId)
|
|
439
|
+
return;
|
|
440
|
+
delete durable.testInjection;
|
|
441
|
+
durable.updatedAt = new Date(Math.max(Date.now(), Date.parse(durable.updatedAt) + 1)).toISOString();
|
|
442
|
+
await this.store.saveTask(durable);
|
|
443
|
+
const consumed = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
|
|
444
|
+
if (!consumed || consumed.executionId !== durableExecutionId || consumed.testInjection)
|
|
445
|
+
throw new Error(`E2E injection consumption was not durably committed for ${task.id}`);
|
|
446
|
+
delete task.testInjection;
|
|
447
|
+
}
|
|
383
448
|
const launchContext = {
|
|
449
|
+
...(testInjection ? { testInjection } : {}),
|
|
384
450
|
...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
|
|
385
451
|
...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
|
|
386
452
|
executionContext,
|
package/dist/relay-store.js
CHANGED
|
@@ -367,7 +367,7 @@ export class SQLiteRelayStore {
|
|
|
367
367
|
export function relayStorePath(directory, repository, prNumber) {
|
|
368
368
|
return directory + '/' + `${repository.replace('/', '_')}-${prNumber}.db`;
|
|
369
369
|
}
|
|
370
|
-
export { relayJobStorePath } from './local-job-path.js';
|
|
370
|
+
export { relayJobStorePath, relayStoreDirectory } from './local-job-path.js';
|
|
371
371
|
/**
|
|
372
372
|
* SQLite is the only job registry. Inventory queries must be strictly read-only:
|
|
373
373
|
* opening a writable RelayStore here would reset in-flight projection claims
|