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.
Files changed (52) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +17 -9
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +57 -1
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +177 -125
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-registry.js +10 -4
  25. package/dist/local-merge-delivery.js +3 -2
  26. package/dist/local-merge-projection.js +60 -0
  27. package/dist/managed-worker-input.js +13 -6
  28. package/dist/manual-plan.js +29 -7
  29. package/dist/planner-runtime.js +182 -41
  30. package/dist/planner.js +35 -2
  31. package/dist/pool.js +22 -6
  32. package/dist/reconciler-cancellation.js +21 -0
  33. package/dist/reconciler-review.js +74 -18
  34. package/dist/reconciler-runtime.js +69 -34
  35. package/dist/reconciler.js +92 -26
  36. package/dist/relayd-control.js +184 -0
  37. package/dist/relayd-dashboard-actions.js +108 -0
  38. package/dist/relayd-utilities.js +107 -0
  39. package/dist/relayd.js +216 -206
  40. package/dist/review-generation-fence.js +12 -0
  41. package/dist/runtime-status-events.js +21 -0
  42. package/dist/runtime-status.js +13 -0
  43. package/dist/task-actions.js +59 -1
  44. package/dist/troubleshooter.js +21 -2
  45. package/dist/unblock.js +47 -15
  46. package/dist/worker-rejection.js +11 -0
  47. package/dist/workflow-effect-failures.js +30 -0
  48. package/dist/workspace.js +95 -6
  49. package/package.json +4 -3
  50. package/skills/agents-relay/agents/planner.agent.md +3 -0
  51. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  52. package/workflow/review-lifecycle.mmd +3 -0
@@ -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 || (executionId !== null && plan.executionId !== executionId)) {
59
+ if (!plan || plan.status !== "PLANNING" || plan.executionId !== executionId) {
17
60
  releaseLive();
18
61
  return;
19
62
  }
20
63
  const failOrRetry = async (message) => {
21
- const attempt = plan.attempt ?? 0;
22
- const maxAttempts = plan.maxAttempts ?? (plan.manualPlanning ? 1 : 3);
23
- const retry = attempt < maxAttempts;
24
- plan.status = retry ? 'PLANNING' : 'FAILED';
25
- plan.retryFeedback = message;
26
- plan.error = message;
27
- plan.assessment = retry ? `Planning retry: ${message}` : `Planning failed: ${message}`;
28
- plan.leaseOwner = null;
29
- plan.leaseExpiresAt = null;
30
- plan.executionId = null;
31
- plan.updatedAt = new Date().toISOString();
32
- await ports.store.savePlan(plan);
33
- await ports.emit(eventFor(job.id, plan.id, null, retry ? 'planner.retry' : 'planner.failed', retry ? 'queued' : 'failed', `Planner ${plan.id}: ${message}`, 'user'));
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, plan.id, 'planner retry reconciliation');
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
- plan.status = 'SUCCEEDED';
58
- plan.objectiveStatus = result.objective_status;
59
- plan.plannerResult = result;
60
- plan.assessment = result.assessment;
61
- plan.generatedTaskIds = result.next_tasks.map(item => item.id);
62
- plan.error = undefined;
63
- plan.retryFeedback = undefined;
64
- plan.leaseOwner = null;
65
- plan.leaseExpiresAt = null;
66
- plan.executionId = null;
67
- plan.updatedAt = now;
68
- await ports.store.savePlan(plan);
69
- if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
70
- job.changeCycle.consumedByPlannerTaskId = plan.id;
71
- job.updatedAt = now;
72
- await ports.store.saveJob(job);
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
- for (const child of children)
75
- if (!job.tasks.some(item => item.id === child.id))
76
- await ports.store.appendTask(child);
77
- 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 }));
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
- const failed = { ...plan, status: 'FAILED', error: 'Planner unavailable', assessment: 'Planning failed: Planner unavailable', leaseOwner: null, leaseExpiresAt: null, executionId: null, updatedAt: new Date().toISOString() };
92
- await ports.store.savePlan(failed);
93
- await ports.emit(eventFor(job.id, plan.id, null, 'planner.failed', 'failed', `Planner ${plan.id}: Planner unavailable`, 'user'));
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
- const reconciler = this.makeReconciler(target.store, Math.max(1, this.concurrency));
102
- await this.reconcileExclusive(jobId, async () => {
103
- await withObservabilityCorrelation({ jobId, repository: target.job.repository }, () => reconciler.reconcile(jobId));
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(1, this.concurrency);
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: job.prNumber,
139
+ prNumber: reviewPrNumber,
134
140
  reviewHeadSha: headSha,
135
141
  projectId: reviewerRouting.candidate.routing.projectId ?? null,
136
142
  } : undefined;
137
143
  const input = [
138
144
  `Job ID: ${job.id}`,
139
- `Review PR #${job.prNumber} at exactly head SHA ${headSha}.`,
145
+ `Review PR #${reviewPrNumber} at exactly head SHA ${headSha}.`,
140
146
  `Review head SHA: ${headSha}`,
141
147
  'Job objective: ' + (job.objective ?? job.description ?? job.title),
142
148
  'Use the selected reviewer agent contract as the authoritative review protocol.',
@@ -247,7 +253,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
247
253
  id: job.id, title: job.title, objective: job.objective ?? null,
248
254
  description: job.description ?? null, priority: job.priority ?? null,
249
255
  executionMode: job.executionMode, repository: job.repository,
250
- prNumber: job.prNumber, state: job.state,
256
+ prNumber: this.externalReviewPrNumber(job), state: job.state,
251
257
  },
252
258
  task: {
253
259
  id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
@@ -264,9 +270,11 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
264
270
  ? job.tasks.find(item => item.id === task.parentTaskId)
265
271
  : undefined;
266
272
  const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
267
- const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
273
+ const parentExcludedRoutes = parentRouteFailedBeforeStart && parent?.routing
268
274
  ? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
269
275
  : [];
276
+ const taskExcludedRoutes = (task.routingExclusions ?? []).map(item => ({ provider: item.provider, model: item.model, profile: item.profile }));
277
+ const excludedRoutes = [...taskExcludedRoutes, ...parentExcludedRoutes];
270
278
  const routed = await routeExecution(candidates, requirements, {
271
279
  decisionEngine: this.options.decisionEngine,
272
280
  decisionMode: this.options.decisionMode,
@@ -307,19 +315,23 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
307
315
  // their first trustworthy review event to PENDING before dispatching it.
308
316
  if (!job.review && task.planningDecisionId) {
309
317
  job.review = { outcome: 'pending', headSha, taskId: task.id, evidence: null, updatedAt: event.timestamp || new Date().toISOString() };
310
- await this.store.saveJob(job);
311
318
  }
312
- const taskDecision = tryTransitionTaskEvent(task, event.type);
319
+ const taskDecision = this.store.mutateJob
320
+ ? dispatchTaskLifecycleSync(structuredClone(task), event.type)
321
+ : tryTransitionTaskEvent(task, event.type);
313
322
  if (!taskDecision.accepted || !taskDecision.target)
314
323
  return { applied: false, reconcile: false, reason: event.type + '.ignored' };
315
- task.error = null;
316
- if (taskDecision.target !== 'RUNNING') {
317
- task.leaseOwner = null;
318
- task.leaseExpiresAt = null;
319
- task.executionId = null;
324
+ if (!this.store.mutateJob && taskDecision.target !== 'RUNNING' && !this.finalizeReviewExecution(job.id, task, event)) {
325
+ return { applied: false, reconcile: false, reason: event.type + '.stale-execution' };
326
+ }
327
+ if (!this.store.mutateJob) {
328
+ task.error = null;
329
+ if (taskDecision.target !== 'RUNNING')
330
+ task.result = { summary: event.message || `Review ${event.type}`, data: event.data ?? {} };
331
+ await this.store.saveTask(task);
320
332
  }
321
- await this.store.saveTask(task);
322
333
  let needsReconcile = false;
334
+ let pendingFixTask = null;
323
335
  const reviewDecision = await dispatchReviewLifecycle(job.review?.outcome, event.type, { autoMerge: job.autoMerge !== false }, async (actionId) => {
324
336
  if (actionId === 'review.createFix') {
325
337
  needsReconcile = true;
@@ -329,10 +341,9 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
329
341
  const now = event.timestamp || new Date().toISOString();
330
342
  const id = 'review-fix-' + headSha.slice(0, 8);
331
343
  if (!job.tasks.some(item => item.id === id)) {
332
- const input = 'Address all reviewer findings for PR #' + job.prNumber + ' at ' + headSha + ':\n\n' + issues.map((issue, index) => (index + 1) + '. ' + issue).join('\n');
344
+ const input = 'Address all reviewer findings for PR #' + this.externalReviewPrNumber(job) + ' at ' + headSha + ':\n\n' + issues.map((issue, index) => (index + 1) + '. ' + issue).join('\n');
333
345
  const fixTask = createTaskEntity(job, { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'implementation', parentTaskId: task.id, dependencies: [task.id], capabilities: ['implementation'], workerRequirements: { capabilities: [] }, adapter: 'codex', input, output: { kind: 'task_pr' }, continuation: null, continuationDeliveredAt: null, attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: 0, createdAt: now, updatedAt: now });
334
- await this.store.appendTask(fixTask);
335
- await this.emit(eventFor(job.id, id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + id + ' for ' + issues.length + ' reviewer finding(s)', 'orchestrator', { headSha, issues }, { agent: 'relay', component: 'agents-relay' }));
346
+ pendingFixTask = fixTask;
336
347
  }
337
348
  return {};
338
349
  }
@@ -352,9 +363,54 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
352
363
  const evidence = { summary: event.message || 'Review ' + outcome, data: {
353
364
  ...(event.data ?? {}), review: { ...(reviewData ?? {}), headSha }, headSha, outcome, source: event.source,
354
365
  } };
355
- job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
356
- job.updatedAt = job.review.updatedAt;
357
- await this.store.saveJob(job);
366
+ const reviewUpdatedAt = event.timestamp || new Date().toISOString();
367
+ if (this.store.mutateJob) {
368
+ let committed = false;
369
+ let terminalExecutionId = null;
370
+ const updated = await this.store.mutateJob(job.id, current => {
371
+ const freshTask = current.tasks.find(item => item.id === task.id);
372
+ if (!freshTask || !reviewGenerationMatches(current, job, task, headSha))
373
+ return current;
374
+ const freshDecision = tryTransitionTaskEvent(freshTask, event.type);
375
+ if (!freshDecision.accepted || !freshDecision.target)
376
+ return current;
377
+ if (freshDecision.target !== 'RUNNING') {
378
+ if (!this.finalizeReviewExecution(job.id, freshTask, event, false))
379
+ return current;
380
+ terminalExecutionId = freshTask.executionId;
381
+ }
382
+ freshTask.error = null;
383
+ if (freshDecision.target !== 'RUNNING')
384
+ freshTask.result = { summary: event.message || `Review ${event.type}`, data: event.data ?? {} };
385
+ if (pendingFixTask && !current.tasks.some(item => item.id === pendingFixTask.id))
386
+ current.tasks.push(structuredClone(pendingFixTask));
387
+ current.review = { outcome, headSha, taskId: freshTask.id, evidence, updatedAt: reviewUpdatedAt };
388
+ current.updatedAt = reviewUpdatedAt;
389
+ committed = true;
390
+ return current;
391
+ });
392
+ if (!committed)
393
+ return { applied: false, reconcile: false, reason: event.type + '.stale-execution' };
394
+ Object.assign(job, structuredClone(updated));
395
+ // Live waiter cleanup/terminal claims are irreversible; run after commit.
396
+ if (terminalExecutionId) {
397
+ this.finalizeReviewExecution(job.id, { ...task, executionId: terminalExecutionId }, event);
398
+ }
399
+ const committedFixTask = pendingFixTask;
400
+ if (committedFixTask)
401
+ await this.emit(eventFor(job.id, committedFixTask.id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + committedFixTask.id + ' for reviewer findings', 'orchestrator', { headSha }, { agent: 'relay', component: 'agents-relay' }));
402
+ }
403
+ else {
404
+ const legacyFixTask = pendingFixTask;
405
+ if (legacyFixTask) {
406
+ await this.store.appendTask(legacyFixTask);
407
+ await this.emit(eventFor(job.id, legacyFixTask.id, task.id, 'review.fix_task_created', 'queued', 'Created one fix task ' + legacyFixTask.id + ' for reviewer findings', 'orchestrator', { headSha }, { agent: 'relay', component: 'agents-relay' }));
408
+ }
409
+ job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: reviewUpdatedAt };
410
+ job.updatedAt = reviewUpdatedAt;
411
+ await this.store.saveTask(task);
412
+ await this.store.saveJob(job);
413
+ }
358
414
  // Review approval records evidence only. The subsequent Graph reconcile
359
415
  // drives durable local Git merge delivery and terminalization.
360
416
  if (outcome === 'approved')