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.
Files changed (56) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +19 -13
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +59 -4
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +179 -129
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-path.js +5 -0
  25. package/dist/local-job-registry.js +10 -4
  26. package/dist/local-managed-store.js +2 -4
  27. package/dist/local-merge-delivery.js +3 -2
  28. package/dist/local-merge-projection.js +60 -0
  29. package/dist/managed-worker-input.js +13 -6
  30. package/dist/manual-plan.js +29 -7
  31. package/dist/planner-runtime.js +182 -41
  32. package/dist/planner.js +35 -2
  33. package/dist/pool.js +22 -6
  34. package/dist/reconciler-cancellation.js +21 -0
  35. package/dist/reconciler-review.js +74 -18
  36. package/dist/reconciler-runtime.js +69 -34
  37. package/dist/reconciler.js +92 -26
  38. package/dist/relay-store.js +1 -1
  39. package/dist/relayd-control.js +184 -0
  40. package/dist/relayd-dashboard-actions.js +108 -0
  41. package/dist/relayd-utilities.js +107 -0
  42. package/dist/relayd.js +220 -210
  43. package/dist/review-generation-fence.js +12 -0
  44. package/dist/runtime-status-events.js +21 -0
  45. package/dist/runtime-status.js +13 -0
  46. package/dist/service-publication.js +2 -4
  47. package/dist/task-actions.js +59 -1
  48. package/dist/troubleshooter.js +21 -2
  49. package/dist/unblock.js +47 -15
  50. package/dist/worker-rejection.js +11 -0
  51. package/dist/workflow-effect-failures.js +30 -0
  52. package/dist/workspace.js +95 -6
  53. package/package.json +5 -4
  54. package/skills/agents-relay/agents/planner.agent.md +3 -0
  55. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  56. 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: job.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 #${job.prNumber} at exactly head SHA ${headSha}.`,
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.prNumber, state: job.state,
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 excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
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 = tryTransitionTaskEvent(task, event.type);
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
- task.error = null;
316
- if (taskDecision.target !== 'RUNNING') {
317
- task.leaseOwner = null;
318
- task.leaseExpiresAt = null;
319
- task.executionId = null;
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.prNumber + ' at ' + headSha + ':\n\n' + issues.map((issue, index) => (index + 1) + '. ' + issue).join('\n');
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
- await this.store.appendTask(fixTask);
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
- job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
356
- job.updatedAt = job.review.updatedAt;
357
- await this.store.saveJob(job);
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
- for (const outcome of outcomes.filter(item => item.status === 'failed')) {
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
- for (const outcome of outcomes.filter(item => item.status === 'failed')) {
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
- if (terminalExecutionClaimed(jobId, taskId, executionId) || !task || task.executionId !== executionId)
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 ? (/rate.limit.exceeded|usage limit reached/i.test(rawError) ? 'rate_limit_exceeded' : 'worker_process_failed') : 'terminal_event_timeout';
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
- 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 });
327
- await this.emit(event);
328
- // Provider quota failures are recoverable. Drop the stale selection so the
329
- // next launch runs the normal worker/model router with fresh usage data.
330
- if (failureReason === 'rate_limit_exceeded' && transition.task.attempt < transition.task.maxAttempts) {
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
- await this.emit(eventFor(jobId, taskId, current.parentTaskId, 'task.rerouting', 'queued', 'Provider rate limit reached; retrying with fresh routing and usage data', 'orchestrator', { previousFailure: failureReason, attempt: current.attempt, maxAttempts: current.maxAttempts }));
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);
@@ -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 { applyTaskCancelAction } from './task-actions.js';
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 job = await this.store.load(jobId);
176
- for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
177
- const live = this.plannerLive.get(plan.id);
178
- if (live) {
179
- clearTimeout(live.timer);
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,
@@ -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