agents-relay 1.0.177 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters.js +26 -2
  3. package/dist/blockers.js +60 -65
  4. package/dist/cli-support.js +33 -1
  5. package/dist/cli.js +72 -86
  6. package/dist/dashboard-actions.js +13 -35
  7. package/dist/dashboard-html.js +1 -1
  8. package/dist/dashboard.js +39 -0
  9. package/dist/execution-recovery.js +37 -0
  10. package/dist/graph-runtime.js +1 -0
  11. package/dist/job-actions.js +53 -0
  12. package/dist/manual-plan.js +15 -17
  13. package/dist/planner-runtime.js +135 -0
  14. package/dist/planner.js +76 -60
  15. package/dist/pool.js +54 -10
  16. package/dist/projected-store.js +314 -0
  17. package/dist/reconciler-review.js +185 -110
  18. package/dist/reconciler-runtime.js +67 -7
  19. package/dist/reconciler.js +134 -282
  20. package/dist/relay-store.js +380 -0
  21. package/dist/relayd-main.js +6 -0
  22. package/dist/relayd.js +122 -29
  23. package/dist/runtime-status.js +2 -1
  24. package/dist/scheduled-jobs.js +40 -31
  25. package/dist/scheduler.js +19 -18
  26. package/dist/store.js +1 -1
  27. package/dist/task-actions.js +139 -0
  28. package/dist/troubleshooter.js +14 -8
  29. package/dist/types.js +3 -3
  30. package/dist/unblock.js +31 -21
  31. package/dist/workflow/authoritative-unblock-v2.js +20 -0
  32. package/dist/workflow/blocker-resolution-v2.js +33 -0
  33. package/dist/workflow/contracts.js +21 -0
  34. package/dist/workflow/definitions-v2.js +75 -0
  35. package/dist/workflow/dispatcher.js +56 -0
  36. package/dist/workflow/entity-creation.js +32 -0
  37. package/dist/workflow/graph-v2.js +391 -0
  38. package/dist/workflow/job-reconcile-v2.js +19 -0
  39. package/dist/workflow/lifecycle.js +82 -4
  40. package/dist/workflow/planner-orchestration-v2.js +58 -0
  41. package/dist/workflow/planner-trigger-v2.js +24 -0
  42. package/dist/workflow/review-v2.js +42 -0
  43. package/dist/workflow/runtime.js +15 -1
  44. package/dist/workflow/schedule-cycle-v2.js +25 -0
  45. package/dist/workflow/scheduled-run-v2.js +30 -0
  46. package/dist/workflow/task-action-policy.js +19 -0
  47. package/dist/workflow/task-reconcile.js +6 -6
  48. package/dist/workflow/task-related-action-v2.js +42 -0
  49. package/dist/workflow/troubleshooter-v2.js +29 -0
  50. package/dist/workspace.js +6 -1
  51. package/package.json +3 -3
  52. package/skills/agents-relay/agents/planner.agent.md +5 -5
  53. package/skills/agents-relay/contracts/task-job-contract.md +2 -51
  54. package/workflow/authoritative-unblock-v2.mmd +15 -0
  55. package/workflow/blocker-resolution-v2.mmd +73 -0
  56. package/workflow/job-lifecycle-v2.mmd +70 -0
  57. package/workflow/job-lifecycle.mmd +2 -2
  58. package/workflow/planner-orchestration-v2.mmd +65 -0
  59. package/workflow/planner-trigger-v2.mmd +32 -0
  60. package/workflow/review-lifecycle-v2.mmd +124 -0
  61. package/workflow/schedule-cycle-v2.mmd +19 -0
  62. package/workflow/scheduled-run-v2.mmd +55 -0
  63. package/workflow/task-lifecycle-v2.mmd +201 -0
  64. package/workflow/task-lifecycle.mmd +3 -0
  65. package/workflow/task-related-action-v2.mmd +28 -0
  66. package/workflow/troubleshooter-v2.mmd +43 -0
@@ -4,47 +4,107 @@ import { implementationComplete, implementationTasks } from './runtime-status.js
4
4
  import { eventFor } from './events.js';
5
5
  import { workerCandidates } from './worker-routing.js';
6
6
  import { routeExecution } from './execution-routing.js';
7
- import { ReconcilerRuntimeBase } from './reconciler-runtime.js';
7
+ import { ReconcilerRuntimeBase, withExecutionBoundary } from './reconciler-runtime.js';
8
+ import { dispatchReviewLifecycle, reviewOutcome } from './workflow/review-v2.js';
9
+ import { createTaskEntity } from './workflow/entity-creation.js';
10
+ const taskRouteClaims = new Set();
8
11
  export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
9
12
  async currentHeadSha(job) { return await this.store.headSha?.() ?? job.headSha ?? job.review?.headSha ?? null; }
10
- async invalidateStaleReview(job, headSha) {
11
- if (!headSha || !job.review || job.review.headSha === headSha || job.review.outcome === 'stale')
12
- return;
13
- job.review = { ...job.review, outcome: 'stale', updatedAt: new Date().toISOString() };
14
- const staleTask = job.review.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
15
- if (staleTask && ['DRAFT', 'QUEUED', 'RUNNING'].includes(staleTask.state)) {
16
- this.live.get(staleTask.id)?.execution.cancel();
17
- await transitionTaskEvent(staleTask, 'cancel');
18
- staleTask.error = 'Review evidence became stale after the PR head changed';
19
- staleTask.leaseOwner = null;
20
- staleTask.leaseExpiresAt = null;
21
- await this.store.saveTask(staleTask);
22
- }
23
- await this.store.saveJob(job);
13
+ reviewFacts(job, prState, headSha) {
14
+ const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
15
+ const currentReviewTask = job.review?.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
16
+ return {
17
+ unscheduled: !job.schedule,
18
+ reviewEnabled: job.reviewEnabled !== false,
19
+ prOpen: prState === 'OPEN',
20
+ jobActive: job.state === 'ACTIVE',
21
+ implementationComplete: implementationComplete(job),
22
+ headAvailable: Boolean(headSha),
23
+ completionGateSatisfied: completionGateSatisfied(job),
24
+ noActiveReviewTask: !reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)),
25
+ currentReviewCancelled: currentReviewTask?.reviewHeadSha === headSha && currentReviewTask.state === 'CANCELLED',
26
+ noApprovedCurrentReview: !reviewTasks.some(task => task.state === 'SUCCEEDED' && job.review?.headSha === headSha && job.review.outcome === 'approved'),
27
+ };
24
28
  }
25
- async ensureFinalReview(job, prState, headSha) {
26
- if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'ACTIVE' || !implementationComplete(job) || !headSha)
27
- return;
28
- if (job.executionMode === 'autonomous' && !completionGateSatisfied(job))
29
+ async cancelStaleReviewTask(job) {
30
+ const staleTask = job.review?.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
31
+ if (!staleTask || !['DRAFT', 'QUEUED', 'RUNNING'].includes(staleTask.state))
29
32
  return;
30
- const review = job.review;
31
- if (review?.headSha === headSha && ['started', 'approved', 'changes_required', 'blocked'].includes(review.outcome))
32
- return;
33
- const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
34
- if (reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
33
+ this.cancelExecutionGeneration(job.id, staleTask.id, staleTask.executionId ?? staleTask.executionHistory?.at(-1)?.id);
34
+ await transitionTaskEvent(staleTask, 'cancel');
35
+ staleTask.error = 'Review evidence became stale after the PR head changed';
36
+ staleTask.leaseOwner = null;
37
+ staleTask.leaseExpiresAt = null;
38
+ await this.store.saveTask(staleTask);
39
+ }
40
+ async invalidateStaleReview(job, headSha) {
41
+ if (!headSha || !job.review || job.review.headSha === headSha)
35
42
  return;
36
- if (reviewTasks.some(task => task.state === 'SUCCEEDED' && review?.headSha === headSha && review.outcome === 'approved'))
43
+ const decision = await dispatchReviewLifecycle(job.review.outcome, 'head.changed', {}, async (actionId) => {
44
+ if (actionId === 'review.cancelStale') {
45
+ await this.cancelStaleReviewTask(job);
46
+ return {};
47
+ }
48
+ throw new Error('Unsupported stale review action ' + actionId);
49
+ });
50
+ if (!decision.accepted)
37
51
  return;
38
- const id = `final-review-${headSha.slice(0, 12)}`;
52
+ const outcome = reviewOutcome(decision.target);
53
+ if (!outcome)
54
+ throw new Error('Review graph produced invalid stale target ' + decision.target);
55
+ job.review = { ...job.review, outcome, updatedAt: new Date().toISOString() };
56
+ await this.store.saveJob(job);
57
+ }
58
+ nextFinalReviewTaskId(job, headSha) {
59
+ const base = 'final-review-' + headSha.slice(0, 12);
60
+ const ids = new Set(job.tasks.filter(task => task.reviewHeadSha === headSha && task.agentName === 'reviewer').map(task => task.id));
61
+ if (!ids.has(base))
62
+ return base;
63
+ for (let round = 2; round < 1000; round += 1) {
64
+ const candidate = base + '-r' + round;
65
+ if (!ids.has(candidate))
66
+ return candidate;
67
+ }
68
+ throw new Error('Too many final review rounds for ' + headSha);
69
+ }
70
+ async createFinalReviewTask(job, headSha) {
71
+ const id = this.nextFinalReviewTaskId(job, headSha);
39
72
  const now = new Date().toISOString();
40
73
  const reviewGateTasks = implementationTasks(job);
41
74
  const reviewerRouting = await this.selectReviewer(reviewGateTasks);
42
- const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: reviewGateTasks.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 };
75
+ const task = createTaskEntity(job, {
76
+ jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer',
77
+ parentTaskId: null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
78
+ workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter,
79
+ 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.',
80
+ output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null,
81
+ reviewHeadSha: headSha, attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2,
82
+ leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null,
83
+ error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now
84
+ });
43
85
  await this.store.appendTask(task);
44
- job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
86
+ 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 } }));
87
+ return id;
88
+ }
89
+ async ensureFinalReview(job, prState, headSha) {
90
+ let reviewTaskId = null;
91
+ const decision = await dispatchReviewLifecycle(job.review?.outcome, 'reconcile', this.reviewFacts(job, prState, headSha), async (actionId) => {
92
+ if (actionId !== 'review.create')
93
+ throw new Error('Unsupported review reconcile action ' + actionId);
94
+ if (!headSha)
95
+ throw new Error('Review graph requested creation without a durable head SHA');
96
+ reviewTaskId = await this.createFinalReviewTask(job, headSha);
97
+ return {};
98
+ });
99
+ if (!decision.accepted || !reviewTaskId || !headSha)
100
+ return;
101
+ const outcome = reviewOutcome(decision.target);
102
+ if (!outcome)
103
+ throw new Error('Review graph produced invalid create target ' + decision.target);
104
+ const now = new Date().toISOString();
105
+ job.review = { outcome, headSha, taskId: reviewTaskId, evidence: null, updatedAt: now };
45
106
  job.updatedAt = now;
46
107
  await this.store.saveJob(job);
47
- 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 } }));
48
108
  const ready = schedule(await this.store.load(job.id));
49
109
  await this.store.saveJob(ready);
50
110
  }
@@ -104,43 +164,58 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
104
164
  const registered = await this.store.listAgents();
105
165
  const candidates = workerCandidates(this.options.adapters, registered, usage);
106
166
  const runtimeSummary = this.options.runtimeSummaryProvider?.();
107
- for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
108
- const requirements = task.workerRequirements ?? { capabilities: [] };
109
- const taskFacts = {
110
- id: task.id,
111
- kind: task.kind ?? 'work',
112
- storySize: task.storySize ?? 'M',
113
- agentName: task.agentName ?? null,
114
- input: task.input,
115
- attachments: task.attachments ?? [],
116
- output: task.output ?? null,
117
- };
118
- const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
119
- const parent = task.agentName === 'troubleshooter' && task.parentTaskId ? job.tasks.find(item => item.id === task.parentTaskId) : undefined;
120
- const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
121
- const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
122
- ? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
123
- : [];
124
- const routed = await routeExecution(candidates, requirements, {
125
- decisionEngine: this.options.decisionEngine,
126
- decisionMode: this.options.decisionMode,
127
- taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null },
128
- usage,
129
- registered,
130
- chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
131
- routingConstraints,
132
- excludedRoutes,
133
- auditStore: this.options.decisionAuditStore,
134
- auditTask: task,
135
- });
136
- task.workerSelection = routed.selection;
137
- task.adapter = routed.candidate.adapter;
138
- task.routing = routed.candidate.routing;
139
- task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
140
- task.updatedAt = new Date().toISOString();
141
- await this.store.saveTask(task);
142
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routed.selection.workerId} selected for task ${task.id}`, 'user', { workerSelection: routed.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
143
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'model.selected', 'queued', `Selected ${task.routing.model} · thinking ${task.routing.reasoning ?? 'unknown'}`, 'user', { routing: task.routing }, { agent: 'relay', component: 'agents-relay' }));
167
+ for (const candidate of job.tasks.filter(item => item.kind !== 'planner' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
168
+ const claimKey = `${job.id}/${candidate.id}`;
169
+ if (taskRouteClaims.has(claimKey))
170
+ continue;
171
+ taskRouteClaims.add(claimKey);
172
+ try {
173
+ await withExecutionBoundary(job.id, candidate.id, async () => {
174
+ const freshJob = await this.store.load(job.id);
175
+ const task = freshJob.tasks.find(item => item.id === candidate.id);
176
+ if (!task || !['QUEUED', 'BLOCKED'].includes(task.state) || task.workerSelection || !task.output)
177
+ return;
178
+ const requirements = task.workerRequirements ?? { capabilities: [] };
179
+ const taskFacts = {
180
+ id: task.id,
181
+ kind: task.kind ?? 'work',
182
+ storySize: task.storySize ?? 'M',
183
+ agentName: task.agentName ?? null,
184
+ input: task.input,
185
+ attachments: task.attachments ?? [],
186
+ output: task.output ?? null,
187
+ };
188
+ const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
189
+ const parent = task.agentName === 'troubleshooter' && task.parentTaskId ? freshJob.tasks.find(item => item.id === task.parentTaskId) : undefined;
190
+ const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
191
+ const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
192
+ ? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
193
+ : [];
194
+ const routed = await routeExecution(candidates, requirements, {
195
+ decisionEngine: this.options.decisionEngine,
196
+ decisionMode: this.options.decisionMode,
197
+ taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null },
198
+ usage,
199
+ registered,
200
+ chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
201
+ routingConstraints,
202
+ excludedRoutes,
203
+ auditStore: this.options.decisionAuditStore,
204
+ auditTask: task,
205
+ });
206
+ task.workerSelection = routed.selection;
207
+ task.adapter = routed.candidate.adapter;
208
+ task.routing = routed.candidate.routing;
209
+ task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
210
+ task.updatedAt = new Date().toISOString();
211
+ await this.store.saveTask(task);
212
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routed.selection.workerId} selected for task ${task.id}`, 'user', { workerSelection: routed.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
213
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'model.selected', 'queued', `Selected ${task.routing.model} · thinking ${task.routing.reasoning ?? 'unknown'}`, 'user', { routing: task.routing }, { agent: 'relay', component: 'agents-relay' }));
214
+ });
215
+ }
216
+ finally {
217
+ taskRouteClaims.delete(claimKey);
218
+ }
144
219
  }
145
220
  }
146
221
  async applyReviewEvent(event) {
@@ -149,14 +224,13 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
149
224
  const reviewData = event.data?.review && typeof event.data.review === 'object' ? event.data.review : undefined;
150
225
  const headSha = typeof reviewData?.headSha === 'string' ? reviewData.headSha : typeof event.data?.headSha === 'string' ? event.data.headSha : task?.reviewHeadSha;
151
226
  if (!task || !headSha || task.reviewHeadSha !== headSha)
152
- return { applied: false, reconcile: false, reason: `${event.type}.ignored` };
153
- const outcome = event.type === 'review.started' ? 'started' : event.type === 'review.approved' ? 'approved' : event.type === 'review.changes_required' ? 'changes_required' : 'blocked';
227
+ return { applied: false, reconcile: false, reason: event.type + '.ignored' };
154
228
  const currentHeadSha = await this.store.headSha?.();
155
229
  if ((currentHeadSha && currentHeadSha !== headSha) || (job.headSha && job.headSha !== headSha))
156
- return { applied: false, reconcile: false, reason: `${event.type}.stale` };
230
+ return { applied: false, reconcile: false, reason: event.type + '.stale' };
157
231
  const taskDecision = tryTransitionTaskEvent(task, event.type);
158
232
  if (!taskDecision.accepted || !taskDecision.target)
159
- return { applied: false, reconcile: false, reason: `${event.type}.ignored` };
233
+ return { applied: false, reconcile: false, reason: event.type + '.ignored' };
160
234
  task.error = null;
161
235
  if (taskDecision.target !== 'RUNNING') {
162
236
  task.leaseOwner = null;
@@ -164,49 +238,50 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
164
238
  task.executionId = null;
165
239
  }
166
240
  await this.store.saveTask(task);
167
- const evidence = { summary: event.message || `Review ${outcome}`, data: { ...(event.data ?? {}), review: reviewData ?? { headSha }, headSha, outcome } };
168
- job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
169
- if (outcome === 'changes_required') {
170
- const rawIssues = Array.isArray(reviewData?.issues) ? reviewData.issues : [];
171
- const issues = rawIssues.map(item => {
172
- if (typeof item === 'string')
173
- return item.trim();
174
- if (item && typeof item === 'object') {
175
- const value = item;
176
- const summary = typeof value.summary === 'string' ? value.summary : typeof value.message === 'string' ? value.message : typeof value.title === 'string' ? value.title : '';
177
- return summary.trim();
241
+ let needsReconcile = false;
242
+ const reviewDecision = await dispatchReviewLifecycle(job.review?.outcome, event.type, { autoMerge: job.autoMerge !== false }, async (actionId) => {
243
+ if (actionId === 'review.createFix') {
244
+ needsReconcile = true;
245
+ const rawIssues = Array.isArray(reviewData?.issues) ? reviewData.issues : [];
246
+ const issues = rawIssues.map(item => {
247
+ if (typeof item === 'string')
248
+ return item.trim();
249
+ if (item && typeof item === 'object') {
250
+ const value = item;
251
+ return (typeof value.summary === 'string' ? value.summary : typeof value.message === 'string' ? value.message : typeof value.title === 'string' ? value.title : '').trim();
252
+ }
253
+ return '';
254
+ }).filter(Boolean);
255
+ if (issues.length === 0)
256
+ issues.push(event.message || 'Address the reviewer-requested changes');
257
+ const now = event.timestamp || new Date().toISOString();
258
+ const id = 'review-fix-' + headSha.slice(0, 8);
259
+ if (!job.tasks.some(item => item.id === id)) {
260
+ const input = 'Address all reviewer findings for PR #' + job.prNumber + ' at ' + headSha + ':\n\n' + issues.map((issue, index) => (index + 1) + '. ' + issue).join('\n');
261
+ 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 });
262
+ await this.store.appendTask(fixTask);
263
+ 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' }));
178
264
  }
179
- return '';
180
- }).filter(Boolean);
181
- if (issues.length === 0)
182
- issues.push(event.message || 'Address the reviewer-requested changes');
183
- const now = event.timestamp || new Date().toISOString();
184
- const id = `review-fix-${headSha.slice(0, 8)}`;
185
- if (!job.tasks.some(item => item.id === id)) {
186
- const input = `Address all reviewer findings for PR #${job.prNumber} at ${headSha}:\n\n${issues.map((issue, index) => `${index + 1}. ${issue}`).join('\n')}`;
187
- const fixTask = {
188
- jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title,
189
- agentName: 'implementation', parentTaskId: task.id, dependencies: [task.id], capabilities: ['implementation'],
190
- workerRequirements: { capabilities: [] }, adapter: 'codex', input, output: { kind: 'task_pr' },
191
- continuation: null, continuationDeliveredAt: null, state: 'DRAFT', attempt: 0, maxAttempts: 3,
192
- leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null,
193
- error: null, timeoutMs: 0, createdAt: now, updatedAt: now
194
- };
195
- await this.store.appendTask(fixTask);
196
- 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' }));
265
+ return {};
197
266
  }
198
- }
267
+ if (actionId === 'review.requestMerge') {
268
+ await transitionJobEvent(job, 'review.approved', { autoMerge: true }, async (effectId) => {
269
+ const outcomes = await this.workflowEffects.execute([effectId], { jobId: job.id, taskId: task.id, transitionKey: 'review.approved:' + headSha, data: { headSha } });
270
+ await this.applyJobEffectOutcomes(job, outcomes);
271
+ }, async () => { await this.store.saveJob(job); });
272
+ return {};
273
+ }
274
+ throw new Error('Unsupported review event action ' + actionId);
275
+ });
276
+ if (!reviewDecision.accepted)
277
+ return { applied: false, reconcile: false, reason: event.type + '.ignored' };
278
+ const outcome = reviewOutcome(reviewDecision.target);
279
+ if (!outcome)
280
+ return { applied: false, reconcile: false, reason: event.type + '.ignored' };
281
+ const evidence = { summary: event.message || 'Review ' + outcome, data: { ...(event.data ?? {}), review: reviewData ?? { headSha }, headSha, outcome } };
282
+ job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
199
283
  job.updatedAt = job.review.updatedAt;
200
284
  await this.store.saveJob(job);
201
- if (outcome === 'approved' && job.autoMerge !== false && this.store.mergePullRequest) {
202
- const decision = await transitionJobEvent(job, 'review.approved', { autoMerge: true });
203
- await this.store.saveJob(job);
204
- const outcomes = await this.workflowEffects.execute(decision.effects, { jobId: job.id, taskId: task.id, transitionKey: `review.approved:${headSha}`, data: { headSha } });
205
- await this.applyJobEffectOutcomes(job, outcomes);
206
- if (outcomes.some(item => item.effect === 'effect.mergePullRequest' && item.status !== 'failed')) {
207
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'review.merged', 'succeeded', 'Reviewer-approved PR #' + job.prNumber + ' merge requested', 'user', { headSha, autoMerge: true }, { agent: 'reviewer', component: 'agents-relay' }));
208
- }
209
- }
210
- return { applied: true, reconcile: outcome === 'changes_required', reason: `review.${outcome}`, reconcileMode: outcome === 'changes_required' ? 'detached' : undefined };
285
+ return { applied: true, reconcile: needsReconcile, reason: 'review.' + outcome, reconcileMode: needsReconcile ? 'detached' : undefined };
211
286
  }
212
287
  }
@@ -8,8 +8,35 @@ import { ReconcileCoordinator } from './reconcile-coordinator.js';
8
8
  import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
9
9
  export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
10
10
  function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
11
+ function executionIdFromSource(sourceId, jobId, taskId) {
12
+ const prefix = `job/${jobId}/task/${taskId}/execution/`;
13
+ return sourceId?.startsWith(prefix) ? sourceId.slice(prefix.length) || undefined : undefined;
14
+ }
11
15
  const terminalExecutionClaims = new Set();
12
16
  const activeExecutionOwners = new Map();
17
+ const executionBoundaryLocks = new Map();
18
+ /**
19
+ * Serialize the durable execution-generation boundary across reconciler
20
+ * instances in one relay process. Launch, routing, and terminal persistence
21
+ * must not observe and write the same queued generation concurrently.
22
+ */
23
+ export async function withExecutionBoundary(jobId, taskId, work) {
24
+ const key = `${jobId}/${taskId}`;
25
+ const previous = executionBoundaryLocks.get(key);
26
+ let release;
27
+ const current = new Promise(resolve => { release = resolve; });
28
+ executionBoundaryLocks.set(key, current);
29
+ if (previous)
30
+ await previous;
31
+ try {
32
+ return await work();
33
+ }
34
+ finally {
35
+ release();
36
+ if (executionBoundaryLocks.get(key) === current)
37
+ executionBoundaryLocks.delete(key);
38
+ }
39
+ }
13
40
  function claimTerminalExecution(sourceId) {
14
41
  terminalExecutionClaims.add(sourceId);
15
42
  const cleanup = setTimeout(() => terminalExecutionClaims.delete(sourceId), 60000);
@@ -185,6 +212,19 @@ export class ReconcilerRuntimeBase {
185
212
  registerExecutionOwner(jobId, taskId, executionId) {
186
213
  activeExecutionOwners.set(executionEventSource(jobId, taskId, executionId), this);
187
214
  }
215
+ cancelExecutionGeneration(jobId, taskId, executionId) {
216
+ if (!executionId)
217
+ return false;
218
+ const sourceId = executionEventSource(jobId, taskId, executionId);
219
+ const owner = activeExecutionOwners.get(sourceId);
220
+ const live = owner?.live.get(taskId);
221
+ if (!owner || !live || live.executionId !== executionId)
222
+ return false;
223
+ live.controller.abort();
224
+ live.execution.cancel();
225
+ owner.clearExecution(taskId, executionId);
226
+ return true;
227
+ }
188
228
  clearTerminalTimer(taskId, executionId) {
189
229
  const live = this.live.get(taskId);
190
230
  if (!live || live.executionId !== executionId)
@@ -281,7 +321,7 @@ export class ReconcilerRuntimeBase {
281
321
  live.processSettled = true;
282
322
  const job = await this.store.load(jobId);
283
323
  const task = job.tasks.find(item => item.id === taskId);
284
- if (!task || task.executionId !== executionId)
324
+ if (!task || (task.executionId !== executionId && !terminalExecutionClaimed(jobId, taskId, executionId)))
285
325
  return;
286
326
  if (task.adapter !== 'chatgpt')
287
327
  this.clearTerminalTimer(taskId, executionId);
@@ -311,13 +351,23 @@ export class ReconcilerRuntimeBase {
311
351
  this.detach(this.emit(event), jobId, taskId, 'timeout failure event');
312
352
  }
313
353
  async applyTerminalEvent(event, trustedInternal = false) {
314
- const transition = await this.taskTransitions.apply(event.job_id, event.task_id, event.type, {
354
+ const liveAtTerminal = this.live.get(event.task_id);
355
+ const terminalEventExecutionId = executionIdFromSource(eventSourceId(event), event.job_id, event.task_id);
356
+ const transition = await withExecutionBoundary(event.job_id, event.task_id, () => this.taskTransitions.apply(event.job_id, event.task_id, event.type, {
315
357
  eligible: (_job, task) => {
316
358
  if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
317
359
  return false;
318
360
  const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
319
- if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event))
320
- return false;
361
+ if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event)) {
362
+ const eventExecutionId = executionIdFromSource(eventSourceId(event), _job.id, task.id);
363
+ const prior = eventExecutionId ? task.executionHistory?.find(item => item.id === eventExecutionId) : undefined;
364
+ // A duplicate launch can leave one worker orphaned after another worker
365
+ // has already produced the terminal fact. Accept only a known execution
366
+ // from the same durable attempt and only when its fact is newer than its
367
+ // launch. A prior retry attempt cannot complete a newer generation.
368
+ if (!prior || prior.attempt !== task.attempt || !event.timestamp || new Date(event.timestamp).getTime() < new Date(prior.startedAt).getTime())
369
+ return false;
370
+ }
321
371
  const terminalWaiter = this.terminalWaiters.get(task.id);
322
372
  return !terminalWaiter || Boolean(event.timestamp && new Date(event.timestamp).getTime() >= terminalWaiter.launchBarrier);
323
373
  },
@@ -331,11 +381,17 @@ export class ReconcilerRuntimeBase {
331
381
  mutate: (job, task) => {
332
382
  const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
333
383
  const acceptedExecutionId = task.executionId;
334
- if (modelBacked && acceptedExecutionId)
335
- claimTerminalExecution(executionEventSource(job.id, task.id, acceptedExecutionId));
384
+ const eventExecutionId = executionIdFromSource(eventSourceId(event), job.id, task.id);
385
+ const terminalExecutionId = eventExecutionId ?? acceptedExecutionId;
386
+ if (modelBacked && terminalExecutionId)
387
+ claimTerminalExecution(executionEventSource(job.id, task.id, terminalExecutionId));
388
+ if (eventExecutionId && acceptedExecutionId && eventExecutionId !== acceptedExecutionId) {
389
+ this.cancelExecutionGeneration(job.id, task.id, eventExecutionId);
390
+ }
336
391
  this.clearExecution(task.id, acceptedExecutionId);
337
392
  task.leaseOwner = null;
338
393
  task.leaseExpiresAt = null;
394
+ task.executionId = null;
339
395
  task.updatedAt = event.timestamp || new Date().toISOString();
340
396
  this.terminalWaiters.get(task.id)?.resolve();
341
397
  this.terminalWaiters.delete(task.id);
@@ -368,9 +424,13 @@ export class ReconcilerRuntimeBase {
368
424
  task.error = event.message || `Task ${task.id} cancelled`;
369
425
  }
370
426
  },
371
- });
427
+ }));
372
428
  if (!transition?.decision.accepted)
373
429
  return { applied: false, reconcile: false, reason: `${event.type}.ignored` };
430
+ if (transition.task.adapter === 'codex' && liveAtTerminal && !liveAtTerminal.processSettled && liveAtTerminal.jobId === event.job_id && (!terminalEventExecutionId || liveAtTerminal.executionId === terminalEventExecutionId)) {
431
+ liveAtTerminal.controller.abort();
432
+ liveAtTerminal.execution.cancel();
433
+ }
374
434
  if (event.type === 'task.completed')
375
435
  await this.deliverContinuation(transition.job, transition.task);
376
436
  const reviewWorker = transition.task.agentName === 'reviewer';