agents-relay 1.0.177 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/dist/adapters.js +26 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd-main.js +6 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- package/workflow/troubleshooter-v2.mmd +43 -0
|
@@ -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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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';
|