agents-relay 2.0.66 → 2.0.68

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 (43) hide show
  1. package/README.md +24 -19
  2. package/dist/adapters.js +1 -1
  3. package/dist/cli-blocker.js +3 -5
  4. package/dist/cli-help.js +12 -10
  5. package/dist/cli-job-create.js +69 -0
  6. package/dist/cli-local-merge-config.js +9 -0
  7. package/dist/cli-support.js +25 -8
  8. package/dist/cli.js +66 -80
  9. package/dist/dashboard-html.js +1 -1
  10. package/dist/dashboard.js +1 -1
  11. package/dist/github-auth.js +4 -3
  12. package/dist/github-webhook.js +13 -5
  13. package/dist/local-authority-store.js +279 -0
  14. package/dist/local-git-merge.js +113 -0
  15. package/dist/local-job-path.js +7 -0
  16. package/dist/local-job-registry.js +23 -0
  17. package/dist/local-managed-store.js +21 -0
  18. package/dist/local-merge-delivery.js +52 -0
  19. package/dist/local-merge-lock.js +103 -0
  20. package/dist/no-github.js +11 -0
  21. package/dist/planned-review-head.js +7 -8
  22. package/dist/pool.js +72 -135
  23. package/dist/projected-store.js +20 -3
  24. package/dist/projection-outbox.js +3 -1
  25. package/dist/publication-store.js +37 -0
  26. package/dist/reconciler-review.js +25 -11
  27. package/dist/reconciler-runtime.js +15 -5
  28. package/dist/reconciler.js +33 -60
  29. package/dist/relay-store.js +108 -6
  30. package/dist/relayd.js +119 -124
  31. package/dist/runtime-status.js +8 -7
  32. package/dist/scheduled-jobs.js +43 -36
  33. package/dist/service-external-actions.js +27 -0
  34. package/dist/service-publication.js +29 -0
  35. package/dist/store-github.js +17 -6
  36. package/dist/task-update.js +1 -1
  37. package/dist/workflow/contracts.js +10 -1
  38. package/dist/workflow/definitions-v2.js +2 -0
  39. package/dist/workflow/job-reconcile-v2.js +9 -60
  40. package/dist/workflow/runtime.js +18 -3
  41. package/package.json +5 -3
  42. package/workflow/job-lifecycle.mmd +19 -60
  43. package/workflow/review-lifecycle.mmd +2 -8
@@ -1,5 +1,5 @@
1
1
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
2
- import { completionGateSatisfied, schedule, transitionTaskEvent, transitionJobEvent, tryTransitionTaskEvent } from './scheduler.js';
2
+ import { completionGateSatisfied, schedule, transitionTaskEvent, tryTransitionTaskEvent } from './scheduler.js';
3
3
  import { implementationComplete, implementationTasks } from './runtime-status.js';
4
4
  import { eventFor } from './events.js';
5
5
  import { workerCandidates } from './worker-routing.js';
@@ -7,16 +7,29 @@ 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
9
  import { createTaskEntity } from './workflow/entity-creation.js';
10
+ import { localSourceHead, localWorkingHead } from './local-git-merge.js';
10
11
  import { chatgptThreadRequirements, resolveReviewerThreadDestination } from './reviewer-thread.js';
11
12
  export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
12
- async currentHeadSha(job) { return await this.store.headSha?.() ?? job.headSha ?? job.review?.headSha ?? null; }
13
- reviewFacts(job, prState, headSha) {
13
+ async currentHeadSha(job) {
14
+ // GitHub PR head facts are audit-only. Source identity is local Git.
15
+ if (!this.options.projectPathForJob)
16
+ return job.headSha ?? job.review?.headSha ?? null;
17
+ try {
18
+ const project = await this.options.projectPathForJob(job);
19
+ if (job.localMerge?.sourceRef)
20
+ return localSourceHead(project, job.localMerge.sourceRef);
21
+ return localWorkingHead(project);
22
+ }
23
+ catch {
24
+ return job.headSha ?? job.review?.headSha ?? null;
25
+ }
26
+ }
27
+ reviewFacts(job, headSha) {
14
28
  const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
15
29
  const currentReviewTask = job.review?.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
16
30
  return {
17
31
  unscheduled: !job.schedule,
18
32
  reviewEnabled: job.reviewEnabled !== false,
19
- prOpen: prState === 'OPEN',
20
33
  jobActive: job.state === 'ACTIVE',
21
34
  implementationComplete: implementationComplete(job),
22
35
  headAvailable: Boolean(headSha),
@@ -146,9 +159,9 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
146
159
  await this.emit(eventFor(job.id, id, null, 'review.created', 'queued', 'Final review task ' + id + ' created for ' + headSha + ' using ' + reviewerRouting.reason, 'orchestrator', { headSha, agentName: 'reviewer', reviewerRouting: { workerId: reviewerRouting.selection.workerId, reason: reviewerRouting.reason, selectedBy: reviewerRouting.selection.selectedBy } }));
147
160
  return id;
148
161
  }
149
- async ensureFinalReview(job, prState, headSha) {
162
+ async ensureFinalReview(job, headSha) {
150
163
  let reviewTaskId = null;
151
- const decision = await dispatchReviewLifecycle(job.review?.outcome, 'reconcile', this.reviewFacts(job, prState, headSha), async (actionId) => {
164
+ const decision = await dispatchReviewLifecycle(job.review?.outcome, 'reconcile', this.reviewFacts(job, headSha), async (actionId) => {
152
165
  if (actionId !== 'review.create')
153
166
  throw new Error('Unsupported review reconcile action ' + actionId);
154
167
  if (!headSha)
@@ -287,7 +300,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
287
300
  const headSha = normalizedEvidence.headSha;
288
301
  if (task.reviewHeadSha !== headSha)
289
302
  return { applied: false, reconcile: false, reason: event.type + '.stale' };
290
- const currentHeadSha = await this.store.headSha?.();
303
+ const currentHeadSha = await this.currentHeadSha(job);
291
304
  if ((currentHeadSha && currentHeadSha !== headSha) || (job.headSha && job.headSha !== headSha))
292
305
  return { applied: false, reconcile: false, reason: event.type + '.stale' };
293
306
  // Planner-created reviewer tasks predate the final-review Job gate. Bind
@@ -324,10 +337,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
324
337
  return {};
325
338
  }
326
339
  if (actionId === 'review.requestMerge') {
327
- await transitionJobEvent(job, 'review.approved', { autoMerge: true }, async (effectId) => {
328
- const outcomes = await this.workflowEffects.execute([effectId], { jobId: job.id, taskId: task.id, transitionKey: 'review.approved:' + headSha, data: { headSha } });
329
- await this.applyJobEffectOutcomes(job, outcomes);
330
- }, async () => { await this.store.saveJob(job); });
340
+ // Approval is a local fact; only subsequent local Git delivery may complete the Job.
331
341
  return {};
332
342
  }
333
343
  throw new Error('Unsupported review event action ' + actionId);
@@ -345,6 +355,10 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
345
355
  job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
346
356
  job.updatedAt = job.review.updatedAt;
347
357
  await this.store.saveJob(job);
358
+ // Review approval records evidence only. The subsequent Graph reconcile
359
+ // drives durable local Git merge delivery and terminalization.
360
+ if (outcome === 'approved')
361
+ needsReconcile = true;
348
362
  return { applied: true, reconcile: needsReconcile, reason: 'review.' + outcome, reconcileMode: needsReconcile ? 'detached' : undefined };
349
363
  }
350
364
  }
@@ -110,11 +110,21 @@ export class ReconcilerRuntimeBase {
110
110
  return activeOwner.applyEvent(event);
111
111
  if (event.type === 'task.started')
112
112
  return this.markWorkerStarted(event);
113
- if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type))
114
- return this.applyTerminalEvent(event);
113
+ if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
114
+ const result = await this.applyTerminalEvent(event);
115
+ if (event.type !== 'task.failed' || event.source?.watchdog !== true || event.data?.workflowEvent !== 'watchdog.failed' || result.reconcile)
116
+ return result;
117
+ // The watchdog has already committed FAILED. The graph owns the recovery
118
+ // action; this correlated event wakes the graph evaluation, not a retry.
119
+ const durable = (await this.store.load(event.job_id)).tasks.find(task => task.id === event.task_id);
120
+ return durable?.state === 'FAILED' && durable.executionHistory?.some(history => history.id === event.data?.executionId)
121
+ ? { applied: true, reconcile: true, reason: 'watchdog.failure-fact' } : result;
122
+ }
115
123
  if (['review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type))
116
124
  return this.applyReviewEvent(event);
117
- if (event.type === 'job.wake' || event.type === 'github.webhook')
125
+ if (event.type === 'github.webhook')
126
+ return { applied: true, reconcile: false, reason: 'github.audit_only' };
127
+ if (event.type === 'job.wake')
118
128
  return { applied: true, reconcile: true, reason: event.type };
119
129
  return { applied: false, reconcile: false, reason: event.type };
120
130
  }
@@ -285,7 +295,7 @@ export class ReconcilerRuntimeBase {
285
295
  return;
286
296
  if (task.adapter !== 'chatgpt' && task.executionId === executionId && live?.executionId === executionId)
287
297
  rearmTerminalEventDeadline(this.terminalTimers, taskId, task.timeoutMs - (Date.now() - live.launchBarrier), this.options.postExitTerminalGraceMs ?? 15_000, () => this.detach(this.timeoutTask(jobId, taskId, executionId), jobId, taskId, 'post-exit terminal event timeout'));
288
- await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', live?.execution.processExit ? (live.execution.processExit.exitCode === 0 ? 'succeeded' : 'failed') : (error ? 'failed' : 'succeeded'), result?.summary ?? (live?.execution.processExit?.terminationReason === 'parent-shutdown' ? 'Worker interrupted by daemon shutdown' : error ? 'Worker runtime exited with error' : 'Worker runtime exited'), 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, exitCode: live?.execution.processExit?.exitCode ?? null,
298
+ await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', live?.execution.processExit ? (live.execution.processExit.exitCode === 0 ? 'succeeded' : 'failed') : (error ? 'failed' : 'succeeded'), result?.summary ?? (live?.execution.processExit?.terminationReason === 'parent-shutdown' ? 'Worker interrupted by daemon shutdown' : error ? `Worker exited: ${error}${live?.execution.processExit ? ` (exit=${live.execution.processExit.exitCode ?? 'null'}${live.execution.processExit.signal ? ` signal=${live.execution.processExit.signal}` : ''})` : ''}` : 'Worker runtime exited'), 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, exitCode: live?.execution.processExit?.exitCode ?? null,
289
299
  signal: live?.execution.processExit?.signal ?? null, terminationReason: live?.execution.processExit?.terminationReason ?? null, stderr: live?.execution.processExit?.stderr ?? '', processError: error, executionId, pid: live?.execution.pid ?? null }, { id: executionEventSource(jobId, taskId, executionId), agent: 'relay', component: 'agents-relay' }));
290
300
  }
291
301
  async timeoutTask(jobId, taskId, executionId) {
@@ -313,7 +323,7 @@ export class ReconcilerRuntimeBase {
313
323
  this.clearExecution(taskId, executionId);
314
324
  return;
315
325
  }
316
- const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? failureMessage, 'user', { reason: failureReason, exitCode: exit?.exitCode ?? null, signal: exit?.signal ?? null, timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed' }, { agent: 'relay', component: 'agents-relay', watchdog: true });
326
+ const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? failureMessage, 'user', { reason: failureReason, exitCode: exit?.exitCode ?? null, signal: exit?.signal ?? null, timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed', executionId }, { agent: 'relay', component: 'agents-relay', watchdog: true });
317
327
  await this.emit(event);
318
328
  // Provider quota failures are recoverable. Drop the stale selection so the
319
329
  // next launch runs the normal worker/model router with fresh usage data.
@@ -1,3 +1,4 @@
1
+ import { implementationComplete } from './runtime-status.js';
1
2
  import { bindPlannedReviewHead } from './planned-review-head.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
@@ -15,13 +16,14 @@ import { executionEventSource, withExecutionBoundary } from './reconciler-runtim
15
16
  export { executionEventSource } from './reconciler-runtime.js';
16
17
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
17
18
  import { pendingTroubleshooterTasks } from './troubleshooter.js';
18
- import { assertCleanWorktreeAtHead, prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
19
+ import { taskUsesCanonicalProjectRun } from './workspace.js';
19
20
  import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
20
21
  import { jobHasActiveExecution } from './job-execution-slot.js';
21
22
  import { managedWorkerInput } from './managed-worker-input.js';
22
23
  export { managedWorkerInput } from './managed-worker-input.js';
23
24
  import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
24
25
  import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
26
+ import { reconcileLocalGitCompletion } from './local-merge-delivery.js';
25
27
  import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
26
28
  import { launchPlanExecution } from './planner-runtime.js';
27
29
  import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
@@ -40,12 +42,6 @@ export class Reconciler extends ReconcilerReviewBase {
40
42
  }, context => `effect.launchTask:${context.jobId}:${context.taskId ?? 'task'}:${String(context.data?.attempt ?? 0)}`));
41
43
  this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cancelActiveTasks', async (context) => this.cancelActiveTasks(context.jobId, String(context.data?.reason ?? 'Job terminated'))));
42
44
  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
45
  }
50
46
  async dispatchExecutableTasks(jobId) {
51
47
  const scheduled = schedule(await this.store.load(jobId));
@@ -78,27 +74,19 @@ export class Reconciler extends ReconcilerReviewBase {
78
74
  const previousState = job.state;
79
75
  const lifecycleFingerprint = (value) => `${value.state}|${value.tasks.map(task => `${task.id}:${task.state}`).join(',')}`;
80
76
  const beforeFingerprint = lifecycleFingerprint(job);
81
- const prState = await this.store.pullRequestState?.() ?? 'OPEN';
82
- const headSha = prState === 'OPEN' ? await this.currentHeadSha(job) : null;
77
+ // GitHub state is audit-only and never participates in workflow decisions.
78
+ const headSha = await this.currentHeadSha(job);
83
79
  if (headSha && job.headSha !== headSha) {
80
+ if (job.localMerge && job.localMerge.status !== 'succeeded') {
81
+ job.localMerge = { ...job.localMerge, sourceSha: undefined, status: 'pending',
82
+ error: undefined, nextAttemptAt: undefined, attempts: 0 };
83
+ }
84
84
  job.headSha = headSha;
85
85
  job.updatedAt = new Date().toISOString();
86
86
  await this.store.saveJob(job);
87
87
  }
88
88
  await this.invalidateStaleReview(job, headSha);
89
89
  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
90
  if (job.state !== 'ACTIVE')
103
91
  return job;
104
92
  await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
@@ -129,24 +117,25 @@ export class Reconciler extends ReconcilerReviewBase {
129
117
  }
130
118
  if (scheduled.state !== previousState || JSON.stringify(scheduled.schedule ?? null) !== scheduleBefore)
131
119
  await this.store.saveJob(scheduled);
132
- if (prState === 'OPEN') {
133
- await this.ensureFinalReview(scheduled, prState, headSha);
120
+ {
121
+ await this.ensureFinalReview(scheduled, headSha);
134
122
  scheduled = schedule(await this.store.load(jobId));
135
123
  }
136
- if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
124
+ if (scheduled.executionMode === 'autonomous') {
137
125
  scheduled = schedule(await this.maybeCreatePlanner(scheduled));
138
126
  }
139
127
  if (scheduled.state !== previousState || scheduled.updatedAt !== job.updatedAt)
140
128
  await this.store.saveJob(scheduled);
141
- if (prState === 'OPEN')
142
- scheduled = await this.dispatchExecutableTasks(jobId);
143
- const finalState = schedule(await this.store.load(jobId));
129
+ scheduled = await this.dispatchExecutableTasks(jobId);
130
+ let finalState = schedule(await this.store.load(jobId));
144
131
  if (finalState.updatedAt !== scheduled.updatedAt)
145
132
  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) => {
133
+ await this.ensureFinalReview(finalState, headSha);
134
+ await this.dispatchExecutableTasks(jobId);
135
+ finalState = await this.store.load(jobId);
136
+ if (await reconcileLocalGitCompletion(this.store, finalState, headSha, true, this.options.projectPathForJob))
137
+ return this.store.load(jobId);
138
+ await dispatchJobReconcile(finalState, { jobActive: finalState.state === 'ACTIVE', implementationComplete: implementationComplete(finalState), headAvailable: Boolean(headSha), deliveryReady: finalState.localMerge?.status === 'succeeded' }, async (actionId) => {
150
139
  const outcomes = await this.workflowEffects.execute([actionId], { jobId: finalState.id, transitionKey: `reconcile:${headSha ?? 'no-head'}`, data: { headSha } });
151
140
  await this.applyJobEffectOutcomes(finalState, outcomes);
152
141
  }, async () => { await this.store.saveJob(finalState); });
@@ -162,7 +151,7 @@ export class Reconciler extends ReconcilerReviewBase {
162
151
  : `Reconcile ${settled.repository}#${settled.prNumber} made no lifecycle change`,
163
152
  correlation: { repository: settled.repository, prNumber: settled.prNumber, jobId: settled.id },
164
153
  transition: { graph: 'job-lifecycle', from: previousState, to: settled.state },
165
- data: { prState, tasks: settled.tasks.length },
154
+ data: { tasks: settled.tasks.length },
166
155
  });
167
156
  return settled;
168
157
  }
@@ -223,7 +212,7 @@ export class Reconciler extends ReconcilerReviewBase {
223
212
  async recoverLostExecutions(job, now) { await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job)); }
224
213
  async recoverStartupOrphans(jobId) {
225
214
  const job = await this.store.load(jobId);
226
- if (job.state === 'ACTIVE' && (await this.store.pullRequestState?.() ?? 'OPEN') === 'OPEN') {
215
+ if (job.state === 'ACTIVE') {
227
216
  await recoverStartupExecutionOrphans(job, this.executionRecoveryPorts(job));
228
217
  }
229
218
  return this.store.load(jobId);
@@ -271,12 +260,14 @@ export class Reconciler extends ReconcilerReviewBase {
271
260
  job.tasks.push(child);
272
261
  }
273
262
  }
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)
263
+ // Only legacy successful Planner Tasks need execution context. Ordinary
264
+ // failed work must not fetch a GitHub PR head merely to check for legacy
265
+ // planner records; offline Troubleshooter recovery must remain runnable.
266
+ const historicalPlanners = job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult);
267
+ if (historicalPlanners.length === 0)
277
268
  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)) {
269
+ const executionContext = await this.plannerContext(job, historicalPlanners[0]);
270
+ for (const planner of historicalPlanners) {
280
271
  const result = planner.plannerResult;
281
272
  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
273
  if (!(job.plans ?? []).some(item => item.id === plan.id)) {
@@ -304,13 +295,6 @@ export class Reconciler extends ReconcilerReviewBase {
304
295
  async plannerContext(job, planning) {
305
296
  const routingCwd = 'routing' in planning ? planning.routing?.cwd : undefined;
306
297
  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
298
  return resolveExecutionContext(projectPath, 'planner', {
315
299
  ...(this.options.executionContextRoots ?? {}),
316
300
  registeredAgents: await this.store.listAgents(),
@@ -337,7 +321,7 @@ export class Reconciler extends ReconcilerReviewBase {
337
321
  if (jobHasActiveExecution(freshJob, { taskId: freshTask.id }))
338
322
  return;
339
323
  if (freshTask.agentName === 'reviewer' && freshTask.planningDecisionId && freshTask.dependencies.length)
340
- await bindPlannedReviewHead(this.store, freshTask);
324
+ await bindPlannedReviewHead(this.store, freshTask, await this.currentHeadSha(freshJob));
341
325
  if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
342
326
  freshTask = (await this.routeTaskForExecution(freshJob, freshTask)).task;
343
327
  }
@@ -367,19 +351,10 @@ export class Reconciler extends ReconcilerReviewBase {
367
351
  return;
368
352
  }
369
353
  let executionContext;
370
- let managedHead = null;
371
354
  try {
372
355
  const role = task.agentName === 'reviewer' ? 'reviewer' : task.agentName === 'troubleshooter' ? 'troubleshooter' : task.parentTaskId ? 'nested-worker' : 'worker';
373
356
  let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
374
357
  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
358
  executionContext = await this.resolvedContext(job, task, role, projectPath);
384
359
  }
385
360
  catch (error) {
@@ -398,11 +373,9 @@ export class Reconciler extends ReconcilerReviewBase {
398
373
  const sourceId = executionEventSource(job.id, task.id, durableExecutionId);
399
374
  const baseWorkerInput = await managedWorkerInput(job, task, executionContext);
400
375
  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;
376
+ const workerInput = canonicalProjectRun
377
+ ? `${baseWorkerInput}\n\n[Canonical Neo project run]\nExecution cwd: ${executionContext.project.path}\nKeep generated artifacts under runs/.`
378
+ : baseWorkerInput;
406
379
  const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: executionContext.project.path } : task.routing };
407
380
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
408
381
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
@@ -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;