agents-relay 2.0.68 → 2.0.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -1
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -28
- package/dist/cli-help.js +11 -8
- package/dist/cli-job-create.js +17 -9
- package/dist/cli-local-merge-config.js +4 -2
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +57 -1
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +177 -125
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +26 -1
- package/dist/e2e-injection.js +76 -0
- package/dist/github-binding-order.js +15 -0
- package/dist/github-client.js +7 -2
- package/dist/github-pr-archive.js +22 -0
- package/dist/github-webhook.js +31 -6
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +10 -1
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +17 -6
- package/dist/local-job-registry.js +10 -4
- package/dist/local-merge-delivery.js +3 -2
- package/dist/local-merge-projection.js +60 -0
- package/dist/managed-worker-input.js +13 -6
- package/dist/manual-plan.js +29 -7
- package/dist/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +22 -6
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +74 -18
- package/dist/reconciler-runtime.js +69 -34
- package/dist/reconciler.js +92 -26
- package/dist/relayd-control.js +184 -0
- package/dist/relayd-dashboard-actions.js +108 -0
- package/dist/relayd-utilities.js +107 -0
- package/dist/relayd.js +216 -206
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +13 -0
- package/dist/task-actions.js +59 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +4 -3
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- package/workflow/review-lifecycle.mmd +3 -0
package/dist/planner-runtime.js
CHANGED
|
@@ -1,10 +1,53 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
3
3
|
import { eventFor } from './events.js';
|
|
4
|
-
import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy } from './planner.js';
|
|
4
|
+
import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy, validatePlannerLifecycleOwnership, validatePlannerProgress } from './planner.js';
|
|
5
5
|
import { samePlannerChildDefinition } from './planner-child.js';
|
|
6
6
|
import { withExecutionBoundary } from './reconciler-runtime.js';
|
|
7
7
|
import { storySizeTimeoutMs } from './types.js';
|
|
8
|
+
export async function drainPlannerUnblocks(ports, jobId, planId) {
|
|
9
|
+
const job = await ports.store.load(jobId);
|
|
10
|
+
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
11
|
+
if (!ports.store.registerBlockerDependent)
|
|
12
|
+
throw new Error("Blocker registration unavailable");
|
|
13
|
+
const pending = [...(plan?.pendingUnblocks ?? [])];
|
|
14
|
+
// Persist only acknowledgments observed by this invocation. Subtract from
|
|
15
|
+
// the latest transactional snapshot, never replace another drain's intents.
|
|
16
|
+
const acknowledged = new Set();
|
|
17
|
+
const key = (item) => JSON.stringify(item);
|
|
18
|
+
for (const unblock of pending) {
|
|
19
|
+
try {
|
|
20
|
+
await ports.store.registerBlockerDependent(jobId, unblock);
|
|
21
|
+
acknowledged.add(key(unblock));
|
|
22
|
+
}
|
|
23
|
+
catch { /* Pending intent is retained for the next reconcile. */ }
|
|
24
|
+
}
|
|
25
|
+
if (acknowledged.size > 0) {
|
|
26
|
+
if (!ports.store.mutateJob)
|
|
27
|
+
throw new Error('Atomic SQLite mutation is required to acknowledge planner unblocks');
|
|
28
|
+
await ports.store.mutateJob(jobId, current => {
|
|
29
|
+
const fresh = (current.plans ?? []).find(item => item.id === planId);
|
|
30
|
+
if (fresh?.pendingUnblocks?.length) {
|
|
31
|
+
const outstanding = fresh.pendingUnblocks.filter(item => !acknowledged.has(key(item)));
|
|
32
|
+
if (outstanding.length)
|
|
33
|
+
fresh.pendingUnblocks = outstanding;
|
|
34
|
+
else
|
|
35
|
+
delete fresh.pendingUnblocks;
|
|
36
|
+
fresh.updatedAt = new Date().toISOString();
|
|
37
|
+
}
|
|
38
|
+
return current;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const refreshed = await ports.store.load(jobId);
|
|
42
|
+
const remaining = (refreshed.plans ?? []).find(item => item.id === planId)?.pendingUnblocks ?? [];
|
|
43
|
+
return remaining.map(item => `${item.repository}#${item.prNumber}:${item.jobId}:${item.taskId}`);
|
|
44
|
+
}
|
|
45
|
+
export async function retryPendingPlannerUnblocks(ports, jobId) {
|
|
46
|
+
const job = await ports.store.load(jobId);
|
|
47
|
+
for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && (item.pendingUnblocks?.length ?? 0) > 0)) {
|
|
48
|
+
await drainPlannerUnblocks(ports, jobId, plan.id);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
8
51
|
async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
9
52
|
const live = ports.live.get(planId);
|
|
10
53
|
if (live?.executionId === executionId)
|
|
@@ -13,40 +56,65 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
|
13
56
|
ports.live.delete(planId); };
|
|
14
57
|
const job = await ports.store.load(jobId);
|
|
15
58
|
const plan = (job.plans ?? []).find(item => item.id === planId);
|
|
16
|
-
if (!plan ||
|
|
59
|
+
if (!plan || plan.status !== "PLANNING" || plan.executionId !== executionId) {
|
|
17
60
|
releaseLive();
|
|
18
61
|
return;
|
|
19
62
|
}
|
|
20
63
|
const failOrRetry = async (message) => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
64
|
+
let retry = false;
|
|
65
|
+
let applied = false;
|
|
66
|
+
const update = (fresh) => {
|
|
67
|
+
if (fresh.status !== 'PLANNING' || fresh.executionId !== executionId)
|
|
68
|
+
return;
|
|
69
|
+
const attempt = fresh.attempt ?? 0;
|
|
70
|
+
const maxAttempts = fresh.maxAttempts ?? (fresh.manualPlanning ? 1 : 3);
|
|
71
|
+
retry = attempt < maxAttempts;
|
|
72
|
+
fresh.status = retry ? 'PLANNING' : 'FAILED';
|
|
73
|
+
fresh.retryFeedback = message;
|
|
74
|
+
fresh.error = message;
|
|
75
|
+
fresh.assessment = retry ? `Planning retry: ${message}` : `Planning failed: ${message}`;
|
|
76
|
+
fresh.leaseOwner = null;
|
|
77
|
+
fresh.leaseExpiresAt = null;
|
|
78
|
+
fresh.executionId = null;
|
|
79
|
+
fresh.updatedAt = new Date().toISOString();
|
|
80
|
+
applied = true;
|
|
81
|
+
};
|
|
82
|
+
if (ports.store.mutateJob) {
|
|
83
|
+
await ports.store.mutateJob(jobId, current => {
|
|
84
|
+
const fresh = (current.plans ?? []).find(item => item.id === planId);
|
|
85
|
+
if (fresh)
|
|
86
|
+
update(fresh);
|
|
87
|
+
return current;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
const fresh = (await ports.store.load(jobId)).plans?.find(item => item.id === planId);
|
|
92
|
+
if (fresh) {
|
|
93
|
+
update(fresh);
|
|
94
|
+
if (applied)
|
|
95
|
+
await ports.store.savePlan(fresh);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!applied)
|
|
99
|
+
return;
|
|
100
|
+
await ports.emit(eventFor(jobId, planId, null, retry ? 'planner.retry' : 'planner.failed', retry ? 'queued' : 'failed', `Planner ${planId}: ${message}`, 'user'));
|
|
34
101
|
if (retry && !plan.manualPlanning)
|
|
35
|
-
ports.detach(ports.reconcile(jobId), jobId,
|
|
102
|
+
ports.detach(ports.reconcile(jobId), jobId, planId, 'planner retry reconciliation');
|
|
36
103
|
};
|
|
37
104
|
if (error) {
|
|
38
105
|
await failOrRetry(error.message);
|
|
39
106
|
releaseLive();
|
|
40
107
|
return;
|
|
41
108
|
}
|
|
109
|
+
let planCommitted = false;
|
|
42
110
|
try {
|
|
43
111
|
const result = parsePlannerResult(raw);
|
|
44
112
|
const executionContext = await ports.plannerContext(job, plan);
|
|
45
113
|
validatePlannerHierarchy(job.tasks, result.next_tasks);
|
|
114
|
+
validatePlannerLifecycleOwnership(job, result.next_tasks);
|
|
115
|
+
validatePlannerProgress(job, result);
|
|
46
116
|
if (result.unblocks.length > 0 && !ports.store.registerBlockerDependent)
|
|
47
117
|
throw new Error('Durable store does not support Planner cross-Job unblock registration');
|
|
48
|
-
for (const unblock of result.unblocks)
|
|
49
|
-
await ports.store.registerBlockerDependent?.(job.id, unblock);
|
|
50
118
|
const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
|
|
51
119
|
for (const child of children) {
|
|
52
120
|
const existing = job.tasks.find(item => item.id === child.id);
|
|
@@ -54,29 +122,79 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
|
54
122
|
throw new Error(`Planner child ${child.id} already exists with a different definition`);
|
|
55
123
|
}
|
|
56
124
|
const now = new Date().toISOString();
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
125
|
+
if (ports.store.mutateJob) {
|
|
126
|
+
let committedPlan = false;
|
|
127
|
+
const committed = await ports.store.mutateJob(job.id, current => {
|
|
128
|
+
const durablePlan = (current.plans ?? []).find(item => item.id === plan.id);
|
|
129
|
+
if (!durablePlan || durablePlan.status !== 'PLANNING' || durablePlan.executionId !== executionId)
|
|
130
|
+
return current;
|
|
131
|
+
durablePlan.status = 'SUCCEEDED';
|
|
132
|
+
durablePlan.objectiveStatus = result.objective_status;
|
|
133
|
+
durablePlan.plannerResult = result;
|
|
134
|
+
durablePlan.assessment = result.assessment;
|
|
135
|
+
durablePlan.generatedTaskIds = result.next_tasks.map(item => item.id);
|
|
136
|
+
durablePlan.pendingUnblocks = result.unblocks.length > 0 ? structuredClone(result.unblocks) : undefined;
|
|
137
|
+
durablePlan.error = undefined;
|
|
138
|
+
durablePlan.retryFeedback = undefined;
|
|
139
|
+
durablePlan.leaseOwner = null;
|
|
140
|
+
durablePlan.leaseExpiresAt = null;
|
|
141
|
+
durablePlan.executionId = null;
|
|
142
|
+
durablePlan.updatedAt = now;
|
|
143
|
+
if (current.changeCycle && !current.changeCycle.consumedByPlannerTaskId && durablePlan.trigger.source === 'unblock') {
|
|
144
|
+
current.changeCycle.consumedByPlannerTaskId = durablePlan.id;
|
|
145
|
+
}
|
|
146
|
+
for (const child of children) {
|
|
147
|
+
const existing = current.tasks.find(item => item.id === child.id);
|
|
148
|
+
if (!existing)
|
|
149
|
+
current.tasks.push(structuredClone(child));
|
|
150
|
+
else if (!samePlannerChildDefinition(existing, child))
|
|
151
|
+
throw new Error(`Planner child ${child.id} already exists with a different definition`);
|
|
152
|
+
}
|
|
153
|
+
current.updatedAt = now;
|
|
154
|
+
committedPlan = true;
|
|
155
|
+
return current;
|
|
156
|
+
});
|
|
157
|
+
Object.assign(job, structuredClone(committed));
|
|
158
|
+
if (!committedPlan) {
|
|
159
|
+
releaseLive();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
plan.status = 'SUCCEEDED';
|
|
165
|
+
plan.objectiveStatus = result.objective_status;
|
|
166
|
+
plan.plannerResult = result;
|
|
167
|
+
plan.assessment = result.assessment;
|
|
168
|
+
plan.generatedTaskIds = result.next_tasks.map(item => item.id);
|
|
169
|
+
plan.pendingUnblocks = result.unblocks.length > 0 ? structuredClone(result.unblocks) : undefined;
|
|
170
|
+
plan.error = undefined;
|
|
171
|
+
plan.retryFeedback = undefined;
|
|
172
|
+
plan.leaseOwner = null;
|
|
173
|
+
plan.leaseExpiresAt = null;
|
|
174
|
+
plan.executionId = null;
|
|
175
|
+
plan.updatedAt = now;
|
|
176
|
+
await ports.store.savePlan(plan);
|
|
177
|
+
if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
|
|
178
|
+
job.changeCycle.consumedByPlannerTaskId = plan.id;
|
|
179
|
+
job.updatedAt = now;
|
|
180
|
+
await ports.store.saveJob(job);
|
|
181
|
+
}
|
|
182
|
+
for (const child of children)
|
|
183
|
+
if (!job.tasks.some(item => item.id === child.id))
|
|
184
|
+
await ports.store.appendTask(child);
|
|
73
185
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
186
|
+
planCommitted = true;
|
|
187
|
+
// Cross-Job registrations cannot join this Job's SQLite transaction. The
|
|
188
|
+
// committed Plan keeps replay intent until each external registration is
|
|
189
|
+
// confirmed, so a crash or partial failure is recoverable by reconcile.
|
|
190
|
+
const pendingUnblocks = await drainPlannerUnblocks(ports, job.id, plan.id);
|
|
191
|
+
await ports.emit(eventFor(job.id, plan.id, null, 'planner.completed', 'succeeded', `Plan ${plan.id} completed: ${result.objective_status}`, 'orchestrator', { objective_status: result.objective_status, pendingUnblocks }));
|
|
78
192
|
}
|
|
79
193
|
catch (caught) {
|
|
194
|
+
if (planCommitted) {
|
|
195
|
+
releaseLive();
|
|
196
|
+
throw caught;
|
|
197
|
+
}
|
|
80
198
|
await failOrRetry(caught instanceof Error ? caught.message : String(caught));
|
|
81
199
|
releaseLive();
|
|
82
200
|
return;
|
|
@@ -88,9 +206,32 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
|
|
|
88
206
|
export async function launchPlanExecution(ports, job, plan) {
|
|
89
207
|
const planner = ports.options.planner;
|
|
90
208
|
if (!planner) {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
209
|
+
let failed = false;
|
|
210
|
+
if (ports.store.mutateJob) {
|
|
211
|
+
await ports.store.mutateJob(job.id, current => {
|
|
212
|
+
const fresh = current.plans?.find(item => item.id === plan.id);
|
|
213
|
+
if (fresh?.status === 'PLANNING' && fresh.executionId === plan.executionId) {
|
|
214
|
+
fresh.status = 'FAILED';
|
|
215
|
+
fresh.error = 'Planner unavailable';
|
|
216
|
+
fresh.assessment = 'Planning failed: Planner unavailable';
|
|
217
|
+
fresh.leaseOwner = null;
|
|
218
|
+
fresh.leaseExpiresAt = null;
|
|
219
|
+
fresh.executionId = null;
|
|
220
|
+
fresh.updatedAt = new Date().toISOString();
|
|
221
|
+
failed = true;
|
|
222
|
+
}
|
|
223
|
+
return current;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
const fresh = (await ports.store.load(job.id)).plans?.find(item => item.id === plan.id);
|
|
228
|
+
if (fresh?.status === 'PLANNING' && fresh.executionId === plan.executionId) {
|
|
229
|
+
await ports.store.savePlan({ ...fresh, status: 'FAILED', error: 'Planner unavailable', assessment: 'Planning failed: Planner unavailable', leaseOwner: null, leaseExpiresAt: null, executionId: null, updatedAt: new Date().toISOString() });
|
|
230
|
+
failed = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (failed)
|
|
234
|
+
await ports.emit(eventFor(job.id, plan.id, null, 'planner.failed', 'failed', `Planner ${plan.id}: Planner unavailable`, 'user'));
|
|
94
235
|
return;
|
|
95
236
|
}
|
|
96
237
|
const claimed = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
|
package/dist/planner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parseTestInjection } from './e2e-injection.js';
|
|
2
3
|
import { fileURLToPath } from 'node:url';
|
|
3
4
|
import { dirname, join } from 'node:path';
|
|
4
5
|
import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
|
|
@@ -101,14 +102,14 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
|
101
102
|
const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
|
|
102
103
|
const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
|
|
103
104
|
const reviewHeadSha = spec.agentName === 'reviewer' && !(spec.dependencies?.length) ? plannerHeadSha ?? job.headSha : undefined;
|
|
104
|
-
return createTaskEntity(job, { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha });
|
|
105
|
+
return createTaskEntity(job, { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, testInjection: spec.testInjection, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha });
|
|
105
106
|
}
|
|
106
107
|
export function planningSpec(task) {
|
|
107
108
|
if (!task.output && task.adapter !== 'orchestrator')
|
|
108
109
|
throw new Error(`Initial task ${task.id} requires an output contract`);
|
|
109
110
|
if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
|
|
110
111
|
throw new Error(`Initial task ${task.id} requires a supported adapter`);
|
|
111
|
-
return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
|
|
112
|
+
return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, testInjection: task.testInjection, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
|
|
112
113
|
}
|
|
113
114
|
function record(value, field = 'result') {
|
|
114
115
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
@@ -286,12 +287,44 @@ export function parsePlannerResult(value) {
|
|
|
286
287
|
id, parentTaskId, input: task.input, priority, storySize,
|
|
287
288
|
projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
|
|
288
289
|
dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
|
|
290
|
+
testInjection: parseTestInjection(task.testInjection ?? task['--test-injection']) ?? optionalString(task.testInjection ?? task['--test-injection'], `next_tasks[${index}].--test-injection`),
|
|
289
291
|
adapter: adapter, output: output, routing: route,
|
|
290
292
|
continuation: task.continuation, maxAttempts, timeoutMs
|
|
291
293
|
};
|
|
292
294
|
});
|
|
293
295
|
return { objective_status: status, assessment: root.assessment, next_tasks, unblocks };
|
|
294
296
|
}
|
|
297
|
+
export function validatePlannerProgress(job, result) {
|
|
298
|
+
if (result.objective_status !== 'in_progress' || result.next_tasks.length > 0 || result.unblocks.length > 0)
|
|
299
|
+
return;
|
|
300
|
+
const previousReviewFinished = job.tasks.some(task => task.agentName === 'reviewer' && task.state === 'SUCCEEDED');
|
|
301
|
+
if (previousReviewFinished)
|
|
302
|
+
return;
|
|
303
|
+
const liveWork = job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(task.state));
|
|
304
|
+
if (liveWork)
|
|
305
|
+
return;
|
|
306
|
+
throw new Error('Planner returned in_progress with no executable tasks or unblocks while no live work remains; return satisfied when only Relay-owned review/merge/completion effects remain');
|
|
307
|
+
}
|
|
308
|
+
export function validatePlannerLifecycleOwnership(job, specs) {
|
|
309
|
+
if (job.prNumber !== 0 || !job.localMerge)
|
|
310
|
+
return;
|
|
311
|
+
for (const spec of specs) {
|
|
312
|
+
// Restrict ownership detection to an individual imperative clause. A long
|
|
313
|
+
// implementation brief may describe Relay-owned completion as context.
|
|
314
|
+
const clauses = spec.input.toLowerCase().split(/[.!?\n]+/);
|
|
315
|
+
for (const clause of clauses) {
|
|
316
|
+
if (/\b(?:relay|graph|orchestrator)\s+(?:must|will|should|owns?)\b/.test(clause))
|
|
317
|
+
continue;
|
|
318
|
+
const localMerge = /\blocal[ -]merge\b|\blocal merge\b/.test(clause);
|
|
319
|
+
const jobCompletion = /\bjob\b.{0,80}\bcompleted\b|\bcompleted\b.{0,80}\bjob\b/.test(clause);
|
|
320
|
+
const externalMerge = /\b(?:external|github|projected)\b.{0,80}\bpr\b.{0,80}\bmerged\b|\bpr\b.{0,80}\bmerged\b/.test(clause);
|
|
321
|
+
const ownsLifecycle = /^\s*(?:perform|execute|complete|trigger|drive|merge|transition|finish|finalize)\b/.test(clause);
|
|
322
|
+
if (ownsLifecycle && [localMerge, jobCompletion, externalMerge].filter(Boolean).length >= 2) {
|
|
323
|
+
throw new Error(`Planner next task ${spec.id} attempts to own Graph lifecycle delivery; local merge, Job COMPLETED, and projected PR merge are Relay-owned effects and must not be worker Tasks`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
295
328
|
export function validatePlannerHierarchy(existing, specs) {
|
|
296
329
|
const existingById = new Map(existing.map(task => [task.id, task]));
|
|
297
330
|
const specsById = new Map(specs.map(spec => [spec.id, spec]));
|
package/dist/pool.js
CHANGED
|
@@ -4,6 +4,7 @@ import { runScheduledBucketFromStore } from './scheduled-jobs.js';
|
|
|
4
4
|
import { scheduleBucket } from './scheduler.js';
|
|
5
5
|
import { ReconcileCoordinator } from './reconcile-coordinator.js';
|
|
6
6
|
import { errorMessage, observability, withObservabilityCorrelation } from './observability.js';
|
|
7
|
+
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
7
8
|
const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
8
9
|
export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
9
10
|
export function runnableManagedJobs(items) { return items.filter(item => item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
|
|
@@ -31,6 +32,7 @@ export class RepositoryWorkerPool {
|
|
|
31
32
|
reconcileLocks = new Map();
|
|
32
33
|
coordinator;
|
|
33
34
|
scheduleSignalCoordinator;
|
|
35
|
+
dispatchTail = Promise.resolve();
|
|
34
36
|
watchedBus;
|
|
35
37
|
constructor(concurrency, makeReconciler, localInventory, resolveLocalJob) {
|
|
36
38
|
this.concurrency = concurrency;
|
|
@@ -57,6 +59,18 @@ export class RepositoryWorkerPool {
|
|
|
57
59
|
throw new Error('Authoritative SQLite Job is not registered: ' + jobId);
|
|
58
60
|
return resolved.store;
|
|
59
61
|
}
|
|
62
|
+
async withDispatchGate(work) {
|
|
63
|
+
const previous = this.dispatchTail;
|
|
64
|
+
let release;
|
|
65
|
+
this.dispatchTail = new Promise(resolve => { release = resolve; });
|
|
66
|
+
await previous;
|
|
67
|
+
try {
|
|
68
|
+
await work();
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
release();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
60
74
|
async reconcileExclusive(jobId, work) {
|
|
61
75
|
while (this.reconcileLocks.has(jobId))
|
|
62
76
|
await this.reconcileLocks.get(jobId);
|
|
@@ -98,9 +112,11 @@ export class RepositoryWorkerPool {
|
|
|
98
112
|
const target = await this.localTarget(jobId);
|
|
99
113
|
if (!target)
|
|
100
114
|
throw new Error('Local job not found: ' + jobId);
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
115
|
+
await this.withDispatchGate(async () => {
|
|
116
|
+
await this.reconcileExclusive(jobId, async () => {
|
|
117
|
+
await withObservabilityCorrelation({ jobId, repository: target.job.repository }, () => this.makeReconciler(target.store, 0).reconcile(jobId));
|
|
118
|
+
});
|
|
119
|
+
await this.reconcileDiscovered(await this.localInventory());
|
|
104
120
|
});
|
|
105
121
|
void reason;
|
|
106
122
|
}
|
|
@@ -125,7 +141,7 @@ export class RepositoryWorkerPool {
|
|
|
125
141
|
}
|
|
126
142
|
async reconcileDiscovered(discovered) {
|
|
127
143
|
const jobs = runnableManagedJobs(discovered);
|
|
128
|
-
let capacity = Math.max(
|
|
144
|
+
let capacity = Math.max(0, this.concurrency - jobs.filter(job => jobHasActiveExecution(job)).length);
|
|
129
145
|
for (const job of jobs) {
|
|
130
146
|
if (capacity <= 0)
|
|
131
147
|
break;
|
|
@@ -170,7 +186,7 @@ export class RepositoryWorkerPool {
|
|
|
170
186
|
try {
|
|
171
187
|
do {
|
|
172
188
|
this.pending = false;
|
|
173
|
-
await this.reconcileDiscovered(await this.localInventory());
|
|
189
|
+
await this.withDispatchGate(async () => this.reconcileDiscovered(await this.localInventory()));
|
|
174
190
|
} while (this.pending);
|
|
175
191
|
}
|
|
176
192
|
finally {
|
|
@@ -200,7 +216,7 @@ export class RepositoryWorkerPool {
|
|
|
200
216
|
// GitHub transport is observational. It must never wake lifecycle execution.
|
|
201
217
|
if (event.type === 'github.webhook')
|
|
202
218
|
return;
|
|
203
|
-
const wake = event.type === 'job.wake' || event.type === 'review.created';
|
|
219
|
+
const wake = event.type === 'job.wake' || event.type === 'review.created' || event.type === 'planner.completed';
|
|
204
220
|
if (!lifecycle && !wake)
|
|
205
221
|
return;
|
|
206
222
|
await this.coordinator.acceptTransport({
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { applyTaskCancelAction } from './task-actions.js';
|
|
2
|
+
export async function cancelActiveTasks(store, jobId, reason, cancelPlanner, cancelExecutionGeneration) {
|
|
3
|
+
const job = await store.load(jobId);
|
|
4
|
+
for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
|
|
5
|
+
cancelPlanner(plan.id);
|
|
6
|
+
plan.status = 'FAILED';
|
|
7
|
+
plan.error = reason;
|
|
8
|
+
plan.assessment = `Planning failed: ${reason}`;
|
|
9
|
+
plan.leaseOwner = null;
|
|
10
|
+
plan.leaseExpiresAt = null;
|
|
11
|
+
plan.executionId = null;
|
|
12
|
+
plan.updatedAt = new Date().toISOString();
|
|
13
|
+
await store.savePlan(plan);
|
|
14
|
+
}
|
|
15
|
+
for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
|
|
16
|
+
cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
|
|
17
|
+
cancelPlanner(task.id);
|
|
18
|
+
await applyTaskCancelAction(task, reason);
|
|
19
|
+
await store.saveTask(task);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -6,9 +6,11 @@ import { workerCandidates } from './worker-routing.js';
|
|
|
6
6
|
import { routeExecution } from './execution-routing.js';
|
|
7
7
|
import { ReconcilerRuntimeBase } from './reconciler-runtime.js';
|
|
8
8
|
import { dispatchReviewLifecycle, reviewOutcome } from './workflow/review-v2.js';
|
|
9
|
+
import { dispatchTaskLifecycleSync } from './workflow/lifecycle.js';
|
|
9
10
|
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
10
11
|
import { localSourceHead, localWorkingHead } from './local-git-merge.js';
|
|
11
12
|
import { chatgptThreadRequirements, resolveReviewerThreadDestination } from './reviewer-thread.js';
|
|
13
|
+
import { reviewGenerationMatches } from './review-generation-fence.js';
|
|
12
14
|
export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
13
15
|
async currentHeadSha(job) {
|
|
14
16
|
// GitHub PR head facts are audit-only. Source identity is local Git.
|
|
@@ -118,8 +120,12 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
118
120
|
}
|
|
119
121
|
throw new Error('Too many final review rounds for ' + headSha);
|
|
120
122
|
}
|
|
123
|
+
externalReviewPrNumber(job) {
|
|
124
|
+
return job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : job.prNumber;
|
|
125
|
+
}
|
|
121
126
|
async createFinalReviewTask(job, headSha) {
|
|
122
127
|
const id = this.nextFinalReviewTaskId(job, headSha);
|
|
128
|
+
const reviewPrNumber = this.externalReviewPrNumber(job);
|
|
123
129
|
const now = new Date().toISOString();
|
|
124
130
|
const reviewGateTasks = implementationTasks(job);
|
|
125
131
|
const reviewThread = resolveReviewerThreadDestination(job, this.options.reviewer?.threadId);
|
|
@@ -130,13 +136,13 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
130
136
|
threadSource: reviewThread.source,
|
|
131
137
|
requestId: `${job.id}:${id}:${headSha}`,
|
|
132
138
|
jobId: job.id,
|
|
133
|
-
prNumber:
|
|
139
|
+
prNumber: reviewPrNumber,
|
|
134
140
|
reviewHeadSha: headSha,
|
|
135
141
|
projectId: reviewerRouting.candidate.routing.projectId ?? null,
|
|
136
142
|
} : undefined;
|
|
137
143
|
const input = [
|
|
138
144
|
`Job ID: ${job.id}`,
|
|
139
|
-
`Review PR #${
|
|
145
|
+
`Review PR #${reviewPrNumber} at exactly head SHA ${headSha}.`,
|
|
140
146
|
`Review head SHA: ${headSha}`,
|
|
141
147
|
'Job objective: ' + (job.objective ?? job.description ?? job.title),
|
|
142
148
|
'Use the selected reviewer agent contract as the authoritative review protocol.',
|
|
@@ -247,7 +253,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
247
253
|
id: job.id, title: job.title, objective: job.objective ?? null,
|
|
248
254
|
description: job.description ?? null, priority: job.priority ?? null,
|
|
249
255
|
executionMode: job.executionMode, repository: job.repository,
|
|
250
|
-
prNumber: job
|
|
256
|
+
prNumber: this.externalReviewPrNumber(job), state: job.state,
|
|
251
257
|
},
|
|
252
258
|
task: {
|
|
253
259
|
id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
|
|
@@ -264,9 +270,11 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
264
270
|
? job.tasks.find(item => item.id === task.parentTaskId)
|
|
265
271
|
: undefined;
|
|
266
272
|
const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
|
|
267
|
-
const
|
|
273
|
+
const parentExcludedRoutes = parentRouteFailedBeforeStart && parent?.routing
|
|
268
274
|
? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
|
|
269
275
|
: [];
|
|
276
|
+
const taskExcludedRoutes = (task.routingExclusions ?? []).map(item => ({ provider: item.provider, model: item.model, profile: item.profile }));
|
|
277
|
+
const excludedRoutes = [...taskExcludedRoutes, ...parentExcludedRoutes];
|
|
270
278
|
const routed = await routeExecution(candidates, requirements, {
|
|
271
279
|
decisionEngine: this.options.decisionEngine,
|
|
272
280
|
decisionMode: this.options.decisionMode,
|
|
@@ -307,19 +315,23 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
307
315
|
// their first trustworthy review event to PENDING before dispatching it.
|
|
308
316
|
if (!job.review && task.planningDecisionId) {
|
|
309
317
|
job.review = { outcome: 'pending', headSha, taskId: task.id, evidence: null, updatedAt: event.timestamp || new Date().toISOString() };
|
|
310
|
-
await this.store.saveJob(job);
|
|
311
318
|
}
|
|
312
|
-
const taskDecision =
|
|
319
|
+
const taskDecision = this.store.mutateJob
|
|
320
|
+
? dispatchTaskLifecycleSync(structuredClone(task), event.type)
|
|
321
|
+
: tryTransitionTaskEvent(task, event.type);
|
|
313
322
|
if (!taskDecision.accepted || !taskDecision.target)
|
|
314
323
|
return { applied: false, reconcile: false, reason: event.type + '.ignored' };
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
task.
|
|
324
|
+
if (!this.store.mutateJob && taskDecision.target !== 'RUNNING' && !this.finalizeReviewExecution(job.id, task, event)) {
|
|
325
|
+
return { applied: false, reconcile: false, reason: event.type + '.stale-execution' };
|
|
326
|
+
}
|
|
327
|
+
if (!this.store.mutateJob) {
|
|
328
|
+
task.error = null;
|
|
329
|
+
if (taskDecision.target !== 'RUNNING')
|
|
330
|
+
task.result = { summary: event.message || `Review ${event.type}`, data: event.data ?? {} };
|
|
331
|
+
await this.store.saveTask(task);
|
|
320
332
|
}
|
|
321
|
-
await this.store.saveTask(task);
|
|
322
333
|
let needsReconcile = false;
|
|
334
|
+
let pendingFixTask = null;
|
|
323
335
|
const reviewDecision = await dispatchReviewLifecycle(job.review?.outcome, event.type, { autoMerge: job.autoMerge !== false }, async (actionId) => {
|
|
324
336
|
if (actionId === 'review.createFix') {
|
|
325
337
|
needsReconcile = true;
|
|
@@ -329,10 +341,9 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
329
341
|
const now = event.timestamp || new Date().toISOString();
|
|
330
342
|
const id = 'review-fix-' + headSha.slice(0, 8);
|
|
331
343
|
if (!job.tasks.some(item => item.id === id)) {
|
|
332
|
-
const input = 'Address all reviewer findings for PR #' + job
|
|
344
|
+
const input = 'Address all reviewer findings for PR #' + this.externalReviewPrNumber(job) + ' at ' + headSha + ':\n\n' + issues.map((issue, index) => (index + 1) + '. ' + issue).join('\n');
|
|
333
345
|
const fixTask = createTaskEntity(job, { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'implementation', parentTaskId: task.id, dependencies: [task.id], capabilities: ['implementation'], workerRequirements: { capabilities: [] }, adapter: 'codex', input, output: { kind: 'task_pr' }, continuation: null, continuationDeliveredAt: null, attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: 0, createdAt: now, updatedAt: now });
|
|
334
|
-
|
|
335
|
-
await this.emit(eventFor(job.id, id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + id + ' for ' + issues.length + ' reviewer finding(s)', 'orchestrator', { headSha, issues }, { agent: 'relay', component: 'agents-relay' }));
|
|
346
|
+
pendingFixTask = fixTask;
|
|
336
347
|
}
|
|
337
348
|
return {};
|
|
338
349
|
}
|
|
@@ -352,9 +363,54 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
352
363
|
const evidence = { summary: event.message || 'Review ' + outcome, data: {
|
|
353
364
|
...(event.data ?? {}), review: { ...(reviewData ?? {}), headSha }, headSha, outcome, source: event.source,
|
|
354
365
|
} };
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
366
|
+
const reviewUpdatedAt = event.timestamp || new Date().toISOString();
|
|
367
|
+
if (this.store.mutateJob) {
|
|
368
|
+
let committed = false;
|
|
369
|
+
let terminalExecutionId = null;
|
|
370
|
+
const updated = await this.store.mutateJob(job.id, current => {
|
|
371
|
+
const freshTask = current.tasks.find(item => item.id === task.id);
|
|
372
|
+
if (!freshTask || !reviewGenerationMatches(current, job, task, headSha))
|
|
373
|
+
return current;
|
|
374
|
+
const freshDecision = tryTransitionTaskEvent(freshTask, event.type);
|
|
375
|
+
if (!freshDecision.accepted || !freshDecision.target)
|
|
376
|
+
return current;
|
|
377
|
+
if (freshDecision.target !== 'RUNNING') {
|
|
378
|
+
if (!this.finalizeReviewExecution(job.id, freshTask, event, false))
|
|
379
|
+
return current;
|
|
380
|
+
terminalExecutionId = freshTask.executionId;
|
|
381
|
+
}
|
|
382
|
+
freshTask.error = null;
|
|
383
|
+
if (freshDecision.target !== 'RUNNING')
|
|
384
|
+
freshTask.result = { summary: event.message || `Review ${event.type}`, data: event.data ?? {} };
|
|
385
|
+
if (pendingFixTask && !current.tasks.some(item => item.id === pendingFixTask.id))
|
|
386
|
+
current.tasks.push(structuredClone(pendingFixTask));
|
|
387
|
+
current.review = { outcome, headSha, taskId: freshTask.id, evidence, updatedAt: reviewUpdatedAt };
|
|
388
|
+
current.updatedAt = reviewUpdatedAt;
|
|
389
|
+
committed = true;
|
|
390
|
+
return current;
|
|
391
|
+
});
|
|
392
|
+
if (!committed)
|
|
393
|
+
return { applied: false, reconcile: false, reason: event.type + '.stale-execution' };
|
|
394
|
+
Object.assign(job, structuredClone(updated));
|
|
395
|
+
// Live waiter cleanup/terminal claims are irreversible; run after commit.
|
|
396
|
+
if (terminalExecutionId) {
|
|
397
|
+
this.finalizeReviewExecution(job.id, { ...task, executionId: terminalExecutionId }, event);
|
|
398
|
+
}
|
|
399
|
+
const committedFixTask = pendingFixTask;
|
|
400
|
+
if (committedFixTask)
|
|
401
|
+
await this.emit(eventFor(job.id, committedFixTask.id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + committedFixTask.id + ' for reviewer findings', 'orchestrator', { headSha }, { agent: 'relay', component: 'agents-relay' }));
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
const legacyFixTask = pendingFixTask;
|
|
405
|
+
if (legacyFixTask) {
|
|
406
|
+
await this.store.appendTask(legacyFixTask);
|
|
407
|
+
await this.emit(eventFor(job.id, legacyFixTask.id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + legacyFixTask.id + ' for reviewer findings', 'orchestrator', { headSha }, { agent: 'relay', component: 'agents-relay' }));
|
|
408
|
+
}
|
|
409
|
+
job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: reviewUpdatedAt };
|
|
410
|
+
job.updatedAt = reviewUpdatedAt;
|
|
411
|
+
await this.store.saveTask(task);
|
|
412
|
+
await this.store.saveJob(job);
|
|
413
|
+
}
|
|
358
414
|
// Review approval records evidence only. The subsequent Graph reconcile
|
|
359
415
|
// drives durable local Git merge delivery and terminalization.
|
|
360
416
|
if (outcome === 'approved')
|