agents-relay 2.0.67 → 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 (72) hide show
  1. package/README.md +56 -20
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -30
  5. package/dist/cli-help.js +22 -17
  6. package/dist/cli-job-create.js +77 -0
  7. package/dist/cli-local-merge-config.js +11 -0
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +81 -8
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +223 -185
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +27 -2
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-auth.js +4 -3
  17. package/dist/github-binding-order.js +15 -0
  18. package/dist/github-client.js +7 -2
  19. package/dist/github-pr-archive.js +22 -0
  20. package/dist/github-webhook.js +43 -10
  21. package/dist/job-actions.js +29 -0
  22. package/dist/local-authority-store.js +288 -0
  23. package/dist/local-commit-events.js +2 -0
  24. package/dist/local-git-merge.js +124 -0
  25. package/dist/local-job-path.js +7 -0
  26. package/dist/local-job-registry.js +29 -0
  27. package/dist/local-managed-store.js +21 -0
  28. package/dist/local-merge-delivery.js +53 -0
  29. package/dist/local-merge-lock.js +103 -0
  30. package/dist/local-merge-projection.js +60 -0
  31. package/dist/managed-worker-input.js +13 -6
  32. package/dist/manual-plan.js +29 -7
  33. package/dist/no-github.js +11 -0
  34. package/dist/planned-review-head.js +7 -8
  35. package/dist/planner-runtime.js +182 -41
  36. package/dist/planner.js +35 -2
  37. package/dist/pool.js +89 -136
  38. package/dist/projected-store.js +20 -3
  39. package/dist/projection-outbox.js +3 -1
  40. package/dist/publication-store.js +37 -0
  41. package/dist/reconciler-cancellation.js +21 -0
  42. package/dist/reconciler-review.js +99 -29
  43. package/dist/reconciler-runtime.js +72 -35
  44. package/dist/reconciler.js +124 -85
  45. package/dist/relay-store.js +108 -6
  46. package/dist/relayd-control.js +184 -0
  47. package/dist/relayd-dashboard-actions.js +108 -0
  48. package/dist/relayd-utilities.js +107 -0
  49. package/dist/relayd.js +304 -299
  50. package/dist/review-generation-fence.js +12 -0
  51. package/dist/runtime-status-events.js +21 -0
  52. package/dist/runtime-status.js +21 -7
  53. package/dist/scheduled-jobs.js +43 -36
  54. package/dist/service-external-actions.js +27 -0
  55. package/dist/service-publication.js +29 -0
  56. package/dist/store-github.js +17 -6
  57. package/dist/task-actions.js +59 -1
  58. package/dist/task-update.js +1 -1
  59. package/dist/troubleshooter.js +21 -2
  60. package/dist/unblock.js +47 -15
  61. package/dist/worker-rejection.js +11 -0
  62. package/dist/workflow/contracts.js +10 -1
  63. package/dist/workflow/definitions-v2.js +2 -0
  64. package/dist/workflow/job-reconcile-v2.js +9 -60
  65. package/dist/workflow/runtime.js +18 -3
  66. package/dist/workflow-effect-failures.js +30 -0
  67. package/dist/workspace.js +95 -6
  68. package/package.json +6 -3
  69. package/skills/agents-relay/agents/planner.agent.md +3 -0
  70. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  71. package/workflow/job-lifecycle.mmd +19 -60
  72. package/workflow/review-lifecycle.mmd +5 -8
@@ -1,11 +1,15 @@
1
+ import { cancelActiveTasks } from './reconciler-cancellation.js';
2
+ import { authorizedInjection } from './e2e-injection.js';
3
+ import { implementationComplete } from './runtime-status.js';
1
4
  import { bindPlannedReviewHead } from './planned-review-head.js';
2
5
  import { randomUUID } from 'node:crypto';
3
6
  import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
4
7
  import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
5
- import { applyTaskCancelAction } from './task-actions.js';
8
+ import { applyTaskRetryAction } from './task-actions.js';
6
9
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
7
10
  import { eventFor } from './events.js';
8
11
  import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
12
+ import { retryPendingPlannerUnblocks } from './planner-runtime.js';
9
13
  import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
10
14
  import { resolveExecutionContext } from './execution-context.js';
11
15
  import { ReconcilerReviewBase } from './reconciler-review.js';
@@ -14,14 +18,15 @@ import { executeManualPlan } from './manual-plan.js';
14
18
  import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
15
19
  export { executionEventSource } from './reconciler-runtime.js';
16
20
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
17
- import { pendingTroubleshooterTasks } from './troubleshooter.js';
18
- import { assertCleanWorktreeAtHead, prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
21
+ import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks } from './troubleshooter.js';
22
+ import { taskUsesCanonicalProjectRun } from './workspace.js';
19
23
  import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
20
24
  import { jobHasActiveExecution } from './job-execution-slot.js';
21
25
  import { managedWorkerInput } from './managed-worker-input.js';
22
26
  export { managedWorkerInput } from './managed-worker-input.js';
23
27
  import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
24
28
  import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
29
+ import { reconcileLocalGitCompletion } from './local-merge-delivery.js';
25
30
  import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
26
31
  import { launchPlanExecution } from './planner-runtime.js';
27
32
  import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
@@ -40,12 +45,6 @@ export class Reconciler extends ReconcilerReviewBase {
40
45
  }, context => `effect.launchTask:${context.jobId}:${context.taskId ?? 'task'}:${String(context.data?.attempt ?? 0)}`));
41
46
  this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cancelActiveTasks', async (context) => this.cancelActiveTasks(context.jobId, String(context.data?.reason ?? 'Job terminated'))));
42
47
  this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cleanupJobWorktree', async (context) => cleanupMergedJobWorktree(await this.store.load(context.jobId), this.options.projectPathForJob), context => `effect.cleanupJobWorktree:${context.jobId}`));
43
- this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.mergePullRequest', async (context) => {
44
- const headSha = typeof context.data?.headSha === 'string' ? context.data.headSha : null;
45
- if (!headSha || !this.store.mergePullRequest)
46
- throw new Error('Pull request merge effect requires a head SHA and merge-capable store');
47
- await this.store.mergePullRequest(headSha);
48
- }, context => `effect.mergePullRequest:${context.jobId}:${String(context.data?.headSha ?? 'missing')}`));
49
48
  }
50
49
  async dispatchExecutableTasks(jobId) {
51
50
  const scheduled = schedule(await this.store.load(jobId));
@@ -78,27 +77,19 @@ export class Reconciler extends ReconcilerReviewBase {
78
77
  const previousState = job.state;
79
78
  const lifecycleFingerprint = (value) => `${value.state}|${value.tasks.map(task => `${task.id}:${task.state}`).join(',')}`;
80
79
  const beforeFingerprint = lifecycleFingerprint(job);
81
- const prState = await this.store.pullRequestState?.() ?? 'OPEN';
82
- const headSha = prState === 'OPEN' ? await this.currentHeadSha(job) : null;
80
+ // GitHub state is audit-only and never participates in workflow decisions.
81
+ const headSha = await this.currentHeadSha(job);
83
82
  if (headSha && job.headSha !== headSha) {
83
+ if (job.localMerge && job.localMerge.status !== 'succeeded') {
84
+ job.localMerge = { ...job.localMerge, sourceSha: undefined, status: 'pending',
85
+ error: undefined, nextAttemptAt: undefined, attempts: 0 };
86
+ }
84
87
  job.headSha = headSha;
85
88
  job.updatedAt = new Date().toISOString();
86
89
  await this.store.saveJob(job);
87
90
  }
88
91
  await this.invalidateStaleReview(job, headSha);
89
92
  const now = (this.options.now ?? new Date()).getTime();
90
- if (prState !== 'OPEN') {
91
- await dispatchJobReconcile(job, { prState, headSha, mergeCapable: Boolean(this.store.mergePullRequest) }, async (actionId) => {
92
- const outcomes = await this.workflowEffects.execute([actionId], { jobId: job.id, transitionKey: `terminal:${prState}`, data: { reason: `Pull request ${prState.toLowerCase()}` } });
93
- await this.applyJobEffectOutcomes(job, outcomes);
94
- }, async () => { await this.store.saveJob(job); });
95
- if (prState === 'MERGED' && this.store.resolveBlockerDependents) {
96
- const resolved = await this.store.resolveBlockerDependents(job.id, 'merge');
97
- for (const target of resolved.unblocked)
98
- await this.emit(eventFor(target.jobId, target.taskId, null, 'task.unblocked', 'unblocked', `Blocker Job ${job.id} resolved (merge)`, 'orchestrator', { repository: target.repository, prNumber: target.prNumber, blockerJobId: job.id }));
99
- }
100
- return this.store.load(jobId);
101
- }
102
93
  if (job.state !== 'ACTIVE')
103
94
  return job;
104
95
  await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
@@ -110,10 +101,49 @@ export class Reconciler extends ReconcilerReviewBase {
110
101
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.orchestrator_rescued', 'queued', `Stale orchestrator task ${task.id} converted to ChatGPT execution`, 'orchestrator', { ageMs: rescue.ageMs, adapter: 'chatgpt' }, { agent: 'relay', component: 'agents-relay' }));
111
102
  }
112
103
  await this.recoverPlannerChildren(job);
104
+ await retryPendingPlannerUnblocks({
105
+ store: this.store,
106
+ options: this.options,
107
+ live: this.plannerLive,
108
+ plannerContext: (currentJob, currentPlan) => this.plannerContext(currentJob, currentPlan),
109
+ emit: event => this.emit(event),
110
+ detach: (promise, currentJobId, planId, context) => this.detach(promise, currentJobId, planId, context),
111
+ reconcile: currentJobId => this.reconcile(currentJobId),
112
+ }, jobId);
113
113
  for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
114
114
  await this.store.appendTask(recovery);
115
115
  await this.emit(eventFor(job.id, recovery.id, null, "troubleshooter.created", "queued", "Troubleshooter task " + recovery.id + " created for failed/blocked work", "orchestrator", { agentName: "troubleshooter" }));
116
116
  }
117
+ {
118
+ const recoveryJob = await this.store.load(jobId);
119
+ // A failed recovery worker is itself recoverable: retry the SAME Task
120
+ // within its budget. No recursive Troubleshooter is generated.
121
+ for (const recovery of recoveryJob.tasks) {
122
+ if (recovery.agentName !== 'troubleshooter' || recovery.state !== 'FAILED' || recovery.attempt >= recovery.maxAttempts)
123
+ continue;
124
+ const original = recoveryJob.tasks.find(task => task.id === recovery.parentTaskId);
125
+ if (!original || !['FAILED', 'BLOCKED'].includes(original.state))
126
+ continue;
127
+ await applyTaskRetryAction(recovery);
128
+ recovery.routing = undefined;
129
+ recovery.workerSelection = undefined;
130
+ await this.store.saveTask(recovery);
131
+ await this.emit(eventFor(job.id, recovery.id, original.id, 'troubleshooter.retry_queued', 'queued', `Retrying failed Troubleshooter ${recovery.id} (attempt ${recovery.attempt + 1}/${recovery.maxAttempts})`, 'orchestrator', { originalTaskId: original.id, attempt: recovery.attempt + 1, maxAttempts: recovery.maxAttempts }));
132
+ }
133
+ const exhaustedJob = await this.store.load(jobId);
134
+ for (const exhausted of exhaustedTroubleshooterRecoveries(exhaustedJob)) {
135
+ const at = new Date().toISOString();
136
+ exhaustedJob.recoveryFailures = [...(exhaustedJob.recoveryFailures ?? []), {
137
+ taskId: exhausted.original.id,
138
+ recoveryTaskId: exhausted.recovery.id,
139
+ reason: exhausted.reason,
140
+ at,
141
+ }];
142
+ exhaustedJob.updatedAt = at;
143
+ await this.store.saveJob(exhaustedJob);
144
+ await this.emit(eventFor(job.id, exhausted.recovery.id, exhausted.original.id, 'recovery.exhausted', 'failed', `Recovery exhausted for ${exhausted.original.id}: ${exhausted.reason}`, 'user', { originalTaskId: exhausted.original.id, recoveryTaskId: exhausted.recovery.id, reason: exhausted.reason }, { agent: 'relay', component: 'agents-relay' }));
145
+ }
146
+ }
117
147
  for (const task of job.tasks)
118
148
  if (task.state === 'CANCELLED') {
119
149
  this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
@@ -129,24 +159,25 @@ export class Reconciler extends ReconcilerReviewBase {
129
159
  }
130
160
  if (scheduled.state !== previousState || JSON.stringify(scheduled.schedule ?? null) !== scheduleBefore)
131
161
  await this.store.saveJob(scheduled);
132
- if (prState === 'OPEN') {
133
- await this.ensureFinalReview(scheduled, prState, headSha);
162
+ {
163
+ await this.ensureFinalReview(scheduled, headSha);
134
164
  scheduled = schedule(await this.store.load(jobId));
135
165
  }
136
- if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
166
+ if (scheduled.executionMode === 'autonomous') {
137
167
  scheduled = schedule(await this.maybeCreatePlanner(scheduled));
138
168
  }
139
169
  if (scheduled.state !== previousState || scheduled.updatedAt !== job.updatedAt)
140
170
  await this.store.saveJob(scheduled);
141
- if (prState === 'OPEN')
142
- scheduled = await this.dispatchExecutableTasks(jobId);
143
- const finalState = schedule(await this.store.load(jobId));
171
+ scheduled = await this.dispatchExecutableTasks(jobId);
172
+ let finalState = schedule(await this.store.load(jobId));
144
173
  if (finalState.updatedAt !== scheduled.updatedAt)
145
174
  await this.store.saveJob(finalState);
146
- await this.ensureFinalReview(finalState, prState, headSha);
147
- if (prState === 'OPEN')
148
- await this.dispatchExecutableTasks(jobId);
149
- await dispatchJobReconcile(finalState, { prState, headSha, mergeCapable: Boolean(this.store.mergePullRequest) }, async (actionId) => {
175
+ await this.ensureFinalReview(finalState, headSha);
176
+ await this.dispatchExecutableTasks(jobId);
177
+ finalState = await this.store.load(jobId);
178
+ if (await reconcileLocalGitCompletion(this.store, finalState, headSha, this.options.noGitHub === true, this.options.projectPathForJob))
179
+ return this.store.load(jobId);
180
+ await dispatchJobReconcile(finalState, { jobActive: finalState.state === 'ACTIVE', implementationComplete: implementationComplete(finalState), headAvailable: Boolean(headSha), deliveryReady: finalState.localMerge?.status === 'succeeded' }, async (actionId) => {
150
181
  const outcomes = await this.workflowEffects.execute([actionId], { jobId: finalState.id, transitionKey: `reconcile:${headSha ?? 'no-head'}`, data: { headSha } });
151
182
  await this.applyJobEffectOutcomes(finalState, outcomes);
152
183
  }, async () => { await this.store.saveJob(finalState); });
@@ -162,7 +193,7 @@ export class Reconciler extends ReconcilerReviewBase {
162
193
  : `Reconcile ${settled.repository}#${settled.prNumber} made no lifecycle change`,
163
194
  correlation: { repository: settled.repository, prNumber: settled.prNumber, jobId: settled.id },
164
195
  transition: { graph: 'job-lifecycle', from: previousState, to: settled.state },
165
- data: { prState, tasks: settled.tasks.length },
196
+ data: { tasks: settled.tasks.length },
166
197
  });
167
198
  return settled;
168
199
  }
@@ -183,29 +214,11 @@ export class Reconciler extends ReconcilerReviewBase {
183
214
  }
184
215
  }
185
216
  async cancelActiveTasks(jobId, reason) {
186
- const job = await this.store.load(jobId);
187
- for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
188
- const live = this.plannerLive.get(plan.id);
189
- if (live) {
190
- clearTimeout(live.timer);
191
- live.controller.abort();
192
- this.plannerLive.delete(plan.id);
193
- }
194
- plan.status = 'FAILED';
195
- plan.error = reason;
196
- plan.assessment = `Planning failed: ${reason}`;
197
- plan.leaseOwner = null;
198
- plan.leaseExpiresAt = null;
199
- plan.executionId = null;
200
- plan.updatedAt = new Date().toISOString();
201
- await this.store.savePlan(plan);
202
- }
203
- for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
204
- this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
205
- this.plannerLive.get(task.id)?.controller.abort();
206
- await applyTaskCancelAction(task, reason);
207
- await this.store.saveTask(task);
208
- }
217
+ return cancelActiveTasks(this.store, jobId, reason, taskId => { const live = this.plannerLive.get(taskId); if (live) {
218
+ clearTimeout(live.timer);
219
+ live.controller.abort();
220
+ this.plannerLive.delete(taskId);
221
+ } }, (job, taskId, executionId) => { this.cancelExecutionGeneration(job, taskId, executionId); });
209
222
  }
210
223
  executionRecoveryPorts(job) {
211
224
  return {
@@ -223,7 +236,7 @@ export class Reconciler extends ReconcilerReviewBase {
223
236
  async recoverLostExecutions(job, now) { await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job)); }
224
237
  async recoverStartupOrphans(jobId) {
225
238
  const job = await this.store.load(jobId);
226
- if (job.state === 'ACTIVE' && (await this.store.pullRequestState?.() ?? 'OPEN') === 'OPEN') {
239
+ if (job.state === 'ACTIVE') {
227
240
  await recoverStartupExecutionOrphans(job, this.executionRecoveryPorts(job));
228
241
  }
229
242
  return this.store.load(jobId);
@@ -271,12 +284,14 @@ export class Reconciler extends ReconcilerReviewBase {
271
284
  job.tasks.push(child);
272
285
  }
273
286
  }
274
- // Legacy read compatibility: migrate historical successful planner Tasks into first-class Plans.
275
- const contextTask = job.tasks.find(task => task.kind === 'planner') ?? job.tasks[0];
276
- if (!contextTask)
287
+ // Only legacy successful Planner Tasks need execution context. Ordinary
288
+ // failed work must not fetch a GitHub PR head merely to check for legacy
289
+ // planner records; offline Troubleshooter recovery must remain runnable.
290
+ const historicalPlanners = job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult);
291
+ if (historicalPlanners.length === 0)
277
292
  return;
278
- const executionContext = await this.plannerContext(job, contextTask);
279
- for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
293
+ const executionContext = await this.plannerContext(job, historicalPlanners[0]);
294
+ for (const planner of historicalPlanners) {
280
295
  const result = planner.plannerResult;
281
296
  const plan = { jobId: job.id, id: planner.id, round: planner.planningRound ?? 1, source: planner.planningSource ?? 'model-planner', previousPlanId: planner.previousPlannerTaskId ?? null, trigger: planner.planTrigger ?? { source: 'continuation' }, status: 'SUCCEEDED', guidance: planner.plannerGuidance, objectiveStatus: result.objective_status, plannerResult: result, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: planner.planTrigger?.currentHeadSha ?? job.headSha, attempt: planner.attempt, maxAttempts: planner.maxAttempts, leaseOwner: null, leaseExpiresAt: null, executionId: null, timeoutMs: planner.timeoutMs, manualPlanning: planner.manualPlanning, createdAt: planner.createdAt, updatedAt: planner.updatedAt };
282
297
  if (!(job.plans ?? []).some(item => item.id === plan.id)) {
@@ -304,13 +319,6 @@ export class Reconciler extends ReconcilerReviewBase {
304
319
  async plannerContext(job, planning) {
305
320
  const routingCwd = 'routing' in planning ? planning.routing?.cwd : undefined;
306
321
  let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : routingCwd ?? process.cwd();
307
- if (this.store.pullRequestHead) {
308
- const head = await this.store.pullRequestHead();
309
- if (!head)
310
- throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
311
- const key = `planner-${planning.id}-${head.sha.slice(0, 12)}`;
312
- projectPath = await prepareManagedExecutionWorktree(projectPath, job.repository, job.prNumber, head, key);
313
- }
314
322
  return resolveExecutionContext(projectPath, 'planner', {
315
323
  ...(this.options.executionContextRoots ?? {}),
316
324
  registeredAgents: await this.store.listAgents(),
@@ -337,7 +345,7 @@ export class Reconciler extends ReconcilerReviewBase {
337
345
  if (jobHasActiveExecution(freshJob, { taskId: freshTask.id }))
338
346
  return;
339
347
  if (freshTask.agentName === 'reviewer' && freshTask.planningDecisionId && freshTask.dependencies.length)
340
- await bindPlannedReviewHead(this.store, freshTask);
348
+ await bindPlannedReviewHead(this.store, freshTask, await this.currentHeadSha(freshJob));
341
349
  if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
342
350
  freshTask = (await this.routeTaskForExecution(freshJob, freshTask)).task;
343
351
  }
@@ -355,6 +363,31 @@ export class Reconciler extends ReconcilerReviewBase {
355
363
  attempt: freshTask.attempt,
356
364
  startedAt: new Date(startedAt).toISOString(),
357
365
  }].slice(-8);
366
+ if (this.store.mutateJob) {
367
+ let claimed;
368
+ await this.store.mutateJob(job.id, current => {
369
+ const durable = current.tasks.find(item => item.id === freshTask.id);
370
+ if (!durable || durable.state !== 'QUEUED' || durable.executionId !== null
371
+ || !dependencyReady(durable, new Map(current.tasks.map(item => [item.id, item]))))
372
+ return current;
373
+ // Routing is a pre-claim fact; copy only that fact into the fresh
374
+ // authoritative snapshot, then claim the generation atomically.
375
+ if (freshTask.routing)
376
+ durable.routing = structuredClone(freshTask.routing);
377
+ if (freshTask.workerSelection)
378
+ durable.workerSelection = structuredClone(freshTask.workerSelection);
379
+ durable.attempt += 1;
380
+ durable.leaseOwner = this.options.owner;
381
+ durable.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
382
+ durable.executionId = randomUUID();
383
+ durable.executionHistory = [...(durable.executionHistory ?? []), {
384
+ id: durable.executionId, attempt: durable.attempt, startedAt: new Date(startedAt).toISOString(),
385
+ }].slice(-8);
386
+ claimed = structuredClone(durable);
387
+ return current;
388
+ });
389
+ return claimed;
390
+ }
358
391
  await this.store.saveTask(freshTask);
359
392
  return freshTask;
360
393
  }) ?? null;
@@ -367,19 +400,10 @@ export class Reconciler extends ReconcilerReviewBase {
367
400
  return;
368
401
  }
369
402
  let executionContext;
370
- let managedHead = null;
371
403
  try {
372
404
  const role = task.agentName === 'reviewer' ? 'reviewer' : task.agentName === 'troubleshooter' ? 'troubleshooter' : task.parentTaskId ? 'nested-worker' : 'worker';
373
405
  let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
374
406
  const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
375
- if (['codex', 'chatgpt'].includes(task.adapter) && this.store.pullRequestHead && task.executionId && !canonicalProjectRun) {
376
- managedHead = await this.store.pullRequestHead();
377
- if (!managedHead)
378
- throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
379
- projectPath = await prepareManagedExecutionWorktree(projectPath, job.repository, job.prNumber, managedHead, task.executionId);
380
- if (role === 'reviewer')
381
- await assertCleanWorktreeAtHead(projectPath, managedHead.sha);
382
- }
383
407
  executionContext = await this.resolvedContext(job, task, role, projectPath);
384
408
  }
385
409
  catch (error) {
@@ -398,16 +422,31 @@ export class Reconciler extends ReconcilerReviewBase {
398
422
  const sourceId = executionEventSource(job.id, task.id, durableExecutionId);
399
423
  const baseWorkerInput = await managedWorkerInput(job, task, executionContext);
400
424
  const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
401
- const workerInput = managedHead
402
- ? `${baseWorkerInput}\n\n[Managed PR checkout]\nExecution cwd: ${executionContext.project.path}\nExpected PR head: ${managedHead.ref} @ ${managedHead.sha}\nThis checkout is isolated from the user's primary checkout. Commit only here. Push durable changes with: git push origin HEAD:${managedHead.ref}\nDo not switch, reset, merge into, or commit on the primary checkout.`
403
- : canonicalProjectRun
404
- ? `${baseWorkerInput}\n\n[Canonical Neo project run]\nExecution cwd: ${executionContext.project.path}\nThis Task owns or descends from a durable runs/ artifact. Work directly in this canonical project directory so all continuations, reviews, QA, and publication steps share the same runs/ evidence. Do not create or switch to an Agents Relay task worktree for this runtime work. Keep generated/private execution artifacts under runs/ and do not commit them.`
405
- : baseWorkerInput;
425
+ const workerInput = canonicalProjectRun
426
+ ? `${baseWorkerInput}\n\n[Canonical Neo project run]\nExecution cwd: ${executionContext.project.path}\nKeep generated artifacts under runs/.`
427
+ : baseWorkerInput;
406
428
  const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: executionContext.project.path } : task.routing };
407
429
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
408
430
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
409
431
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal' ? this.terminalWait(task.id, durableExecutionId, startedAt - 1) : null;
432
+ // Consume E2E injection before execution: a retry will not repeat it.
433
+ // Production repositories ignore the option entirely.
434
+ const declaration = authorizedInjection(job.repository, task.testInjection);
435
+ const testInjection = declaration && ((task.adapter === 'codex' && declaration.method === 'worker.codex.launch') || (task.adapter === 'chatgpt' && declaration.method === 'browserWorkspace.command')) ? declaration : undefined;
436
+ if (testInjection) {
437
+ const durable = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
438
+ if (!durable || durable.executionId !== durableExecutionId)
439
+ return;
440
+ delete durable.testInjection;
441
+ durable.updatedAt = new Date(Math.max(Date.now(), Date.parse(durable.updatedAt) + 1)).toISOString();
442
+ await this.store.saveTask(durable);
443
+ const consumed = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
444
+ if (!consumed || consumed.executionId !== durableExecutionId || consumed.testInjection)
445
+ throw new Error(`E2E injection consumption was not durably committed for ${task.id}`);
446
+ delete task.testInjection;
447
+ }
410
448
  const launchContext = {
449
+ ...(testInjection ? { testInjection } : {}),
411
450
  ...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
412
451
  ...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
413
452
  executionContext,
@@ -2,8 +2,9 @@ import { DatabaseSync } from 'node:sqlite';
2
2
  import { dirname } from 'node:path';
3
3
  import { mkdirSync } from 'node:fs';
4
4
  import { readdir } from 'node:fs/promises';
5
+ import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
5
6
  import { observability } from './observability.js';
6
- import { ProjectionOutbox, SCHEMA_VERSION, } from './projection-outbox.js';
7
+ import { ProjectionOutbox, SCHEMA_VERSION, logicalOperationKey, } from './projection-outbox.js';
7
8
  function newerRecord(local, remote) {
8
9
  if (!local)
9
10
  return structuredClone(remote);
@@ -31,6 +32,12 @@ export function mergeInboundJobSnapshot(local, remote) {
31
32
  }
32
33
  return base;
33
34
  }
35
+ function monotonicJobTimestamp(previous, candidate = new Date().toISOString()) {
36
+ const prior = Date.parse(previous), next = Date.parse(candidate);
37
+ if (!Number.isFinite(prior))
38
+ return candidate;
39
+ return new Date(Math.max(Number.isFinite(next) ? next : Date.now(), prior + 1)).toISOString();
40
+ }
34
41
  export class SQLiteRelayStore {
35
42
  path;
36
43
  db;
@@ -63,6 +70,8 @@ export class SQLiteRelayStore {
63
70
  head_sha TEXT,
64
71
  observed_at TEXT NOT NULL
65
72
  );
73
+ CREATE TABLE IF NOT EXISTS publication_state (job_id TEXT PRIMARY KEY, json TEXT NOT NULL);
74
+ CREATE TABLE IF NOT EXISTS publication_delivery (job_id TEXT NOT NULL, delivery_id TEXT NOT NULL, type TEXT NOT NULL, json TEXT NOT NULL, observed_at TEXT NOT NULL, PRIMARY KEY(job_id,delivery_id));
66
75
  CREATE TABLE IF NOT EXISTS agent_snapshot (
67
76
  agent_id TEXT PRIMARY KEY,
68
77
  json TEXT NOT NULL,
@@ -99,6 +108,15 @@ export class SQLiteRelayStore {
99
108
  this.db.prepare("INSERT INTO relay_meta(key,value) VALUES('schema_version',?) ON CONFLICT(key) DO UPDATE SET value=excluded.value")
100
109
  .run(String(SCHEMA_VERSION));
101
110
  }
111
+ async recordPublication(jobId, deliveryId, publication, failure, observedAt) {
112
+ return recordPublicationInDatabase(this.db, jobId, deliveryId, publication, failure, observedAt);
113
+ }
114
+ async loadPublication(jobId) {
115
+ return loadPublicationFromDatabase(this.db, jobId);
116
+ }
117
+ async listPublicationEvents(jobId) {
118
+ return listPublicationEventsFromDatabase(this.db, jobId);
119
+ }
102
120
  async loadJob(jobId) {
103
121
  const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
104
122
  return row?.json ? JSON.parse(row.json) : null;
@@ -110,6 +128,30 @@ export class SQLiteRelayStore {
110
128
  async saveJob(job) {
111
129
  this.writeJobSnapshot(job, new Date().toISOString());
112
130
  }
131
+ /** Atomic read/modify/write for local authoritative Task and Plan updates. */
132
+ async mutateJob(jobId, mutation) {
133
+ this.db.exec('BEGIN IMMEDIATE');
134
+ try {
135
+ const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
136
+ if (!row?.json)
137
+ throw new Error(`Local authoritative Job ${jobId} not found`);
138
+ const current = JSON.parse(row.json);
139
+ const before = JSON.stringify(current);
140
+ const next = mutation(current);
141
+ if (next.id !== jobId)
142
+ throw new Error('Cannot change authoritative Job identity');
143
+ if (JSON.stringify(next) !== before && next.updatedAt <= JSON.parse(before).updatedAt) {
144
+ next.updatedAt = monotonicJobTimestamp(JSON.parse(before).updatedAt);
145
+ }
146
+ this.writeJobSnapshot(next, new Date().toISOString());
147
+ this.db.exec('COMMIT');
148
+ return structuredClone(next);
149
+ }
150
+ catch (error) {
151
+ this.db.exec('ROLLBACK');
152
+ throw error;
153
+ }
154
+ }
113
155
  async ingestJobSnapshot(job) {
114
156
  const now = new Date().toISOString();
115
157
  this.db.exec('BEGIN IMMEDIATE');
@@ -176,6 +218,19 @@ export class SQLiteRelayStore {
176
218
  throw error;
177
219
  }
178
220
  }
221
+ async upsertAgent(agent) {
222
+ const now = new Date().toISOString();
223
+ this.db.exec('BEGIN IMMEDIATE');
224
+ try {
225
+ this.writeAgentSnapshot(agent, now);
226
+ this.markAgentRegistryObserved(now);
227
+ this.db.exec('COMMIT');
228
+ }
229
+ catch (error) {
230
+ this.db.exec('ROLLBACK');
231
+ throw error;
232
+ }
233
+ }
179
234
  async saveAgentWithProjection(agent, request) {
180
235
  const now = new Date().toISOString();
181
236
  this.db.exec('BEGIN IMMEDIATE');
@@ -209,9 +264,16 @@ export class SQLiteRelayStore {
209
264
  return { state: row.state, head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null, observedAt: row.observed_at };
210
265
  }
211
266
  async savePullRequestFacts(facts) {
267
+ // UNKNOWN means absence of confirmed external evidence; never persist it as a fact.
268
+ if (facts.state === 'UNKNOWN')
269
+ return;
212
270
  this.db.prepare(`
213
271
  INSERT INTO pull_request_facts(id,state,head_ref,head_sha,observed_at) VALUES(1,?,?,?,?)
214
272
  ON CONFLICT(id) DO UPDATE SET state=excluded.state,head_ref=excluded.head_ref,head_sha=excluded.head_sha,observed_at=excluded.observed_at
273
+ WHERE excluded.observed_at >= pull_request_facts.observed_at
274
+ AND (pull_request_facts.state='OPEN'
275
+ OR (pull_request_facts.state='CLOSED' AND excluded.state IN ('CLOSED','MERGED'))
276
+ OR (pull_request_facts.state='MERGED' AND excluded.state='MERGED'))
215
277
  `).run(facts.state, facts.head?.ref ?? null, facts.head?.sha ?? null, facts.observedAt);
216
278
  }
217
279
  async recordExecutionProcess(record) {
@@ -262,6 +324,31 @@ export class SQLiteRelayStore {
262
324
  throw error;
263
325
  }
264
326
  }
327
+ /**
328
+ * Idempotent external-effect reservation. Repeated Graph reconciliation
329
+ * must never reset backoff or queue a second merge while a first one runs.
330
+ */
331
+ async enqueueExternalActionOnce(request) {
332
+ if (!['merge_pull_request', 'close_pull_request', 'update_pr_body'].includes(request.operation)) {
333
+ throw new Error('Lifecycle projection is forbidden in external-only outbox');
334
+ }
335
+ const key = logicalOperationKey(request.idempotencyKey);
336
+ this.db.exec('BEGIN IMMEDIATE');
337
+ try {
338
+ const existing = this.db.prepare('SELECT id FROM projection_outbox WHERE operation_key=? LIMIT 1').get(key);
339
+ if (existing) {
340
+ this.db.exec('COMMIT');
341
+ return false;
342
+ }
343
+ this.outbox.enqueue(request, new Date().toISOString());
344
+ this.db.exec('COMMIT');
345
+ return true;
346
+ }
347
+ catch (error) {
348
+ this.db.exec('ROLLBACK');
349
+ throw error;
350
+ }
351
+ }
265
352
  async claimDueProjections(limit = 50, now = new Date()) {
266
353
  return this.outbox.claimDue(limit, now);
267
354
  }
@@ -280,6 +367,12 @@ export class SQLiteRelayStore {
280
367
  export function relayStorePath(directory, repository, prNumber) {
281
368
  return directory + '/' + `${repository.replace('/', '_')}-${prNumber}.db`;
282
369
  }
370
+ export { relayJobStorePath } from './local-job-path.js';
371
+ /**
372
+ * SQLite is the only job registry. Inventory queries must be strictly read-only:
373
+ * opening a writable RelayStore here would reset in-flight projection claims
374
+ * on every dashboard refresh and could replay external merge side effects.
375
+ */
283
376
  export async function discoverLocalRelayStoreSnapshots(directory) {
284
377
  let names;
285
378
  try {
@@ -291,16 +384,25 @@ export async function discoverLocalRelayStoreSnapshots(directory) {
291
384
  const snapshots = [];
292
385
  for (const name of names.filter(item => item.endsWith('.db')).sort()) {
293
386
  const path = directory + '/' + name;
294
- let store;
387
+ let db;
295
388
  try {
296
- store = new SQLiteRelayStore(path);
297
- snapshots.push({ path, jobs: await store.listJobs(), facts: await store.loadPullRequestFacts() });
389
+ db = new DatabaseSync(path, { readOnly: true });
390
+ const rows = db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
391
+ const jobs = rows.map(row => JSON.parse(row.json));
392
+ const row = db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
393
+ const facts = row ? {
394
+ state: row.state,
395
+ head: row.head_ref && row.head_sha ? { ref: row.head_ref, sha: row.head_sha } : null,
396
+ observedAt: row.observed_at,
397
+ } : null;
398
+ const publications = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='publication_state'").get() ? Object.fromEntries(db.prepare('SELECT job_id,json FROM publication_state').all().map(item => [item.job_id, JSON.parse(item.json)])) : {};
399
+ snapshots.push({ path, jobs, facts, publications });
298
400
  }
299
401
  catch {
300
- // Ignore unrelated/corrupt files so one bad file cannot block daemon startup.
402
+ // Ignore unrelated/corrupt files without allowing one damaged store to block recovery.
301
403
  }
302
404
  finally {
303
- store?.close();
405
+ db?.close();
304
406
  }
305
407
  }
306
408
  return snapshots;