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.
- package/README.md +56 -20
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -30
- package/dist/cli-help.js +22 -17
- package/dist/cli-job-create.js +77 -0
- package/dist/cli-local-merge-config.js +11 -0
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +81 -8
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +223 -185
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +27 -2
- package/dist/e2e-injection.js +76 -0
- package/dist/github-auth.js +4 -3
- package/dist/github-binding-order.js +15 -0
- package/dist/github-client.js +7 -2
- package/dist/github-pr-archive.js +22 -0
- package/dist/github-webhook.js +43 -10
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +288 -0
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +124 -0
- package/dist/local-job-path.js +7 -0
- package/dist/local-job-registry.js +29 -0
- package/dist/local-managed-store.js +21 -0
- package/dist/local-merge-delivery.js +53 -0
- package/dist/local-merge-lock.js +103 -0
- package/dist/local-merge-projection.js +60 -0
- package/dist/managed-worker-input.js +13 -6
- package/dist/manual-plan.js +29 -7
- package/dist/no-github.js +11 -0
- package/dist/planned-review-head.js +7 -8
- package/dist/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +89 -136
- package/dist/projected-store.js +20 -3
- package/dist/projection-outbox.js +3 -1
- package/dist/publication-store.js +37 -0
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +99 -29
- package/dist/reconciler-runtime.js +72 -35
- package/dist/reconciler.js +124 -85
- package/dist/relay-store.js +108 -6
- package/dist/relayd-control.js +184 -0
- package/dist/relayd-dashboard-actions.js +108 -0
- package/dist/relayd-utilities.js +107 -0
- package/dist/relayd.js +304 -299
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +21 -7
- package/dist/scheduled-jobs.js +43 -36
- package/dist/service-external-actions.js +27 -0
- package/dist/service-publication.js +29 -0
- package/dist/store-github.js +17 -6
- package/dist/task-actions.js +59 -1
- package/dist/task-update.js +1 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow/contracts.js +10 -1
- package/dist/workflow/definitions-v2.js +2 -0
- package/dist/workflow/job-reconcile-v2.js +9 -60
- package/dist/workflow/runtime.js +18 -3
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +6 -3
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- package/workflow/job-lifecycle.mmd +19 -60
- package/workflow/review-lifecycle.mmd +5 -8
package/dist/reconciler.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
82
|
-
const headSha =
|
|
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
|
-
|
|
133
|
-
await this.ensureFinalReview(scheduled,
|
|
162
|
+
{
|
|
163
|
+
await this.ensureFinalReview(scheduled, headSha);
|
|
134
164
|
scheduled = schedule(await this.store.load(jobId));
|
|
135
165
|
}
|
|
136
|
-
if (
|
|
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
|
-
|
|
142
|
-
|
|
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,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await
|
|
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: {
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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'
|
|
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
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
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,
|
|
279
|
-
for (const planner of
|
|
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 =
|
|
402
|
-
? `${baseWorkerInput}\n\n[
|
|
403
|
-
:
|
|
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,
|
package/dist/relay-store.js
CHANGED
|
@@ -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
|
|
387
|
+
let db;
|
|
295
388
|
try {
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
402
|
+
// Ignore unrelated/corrupt files without allowing one damaged store to block recovery.
|
|
301
403
|
}
|
|
302
404
|
finally {
|
|
303
|
-
|
|
405
|
+
db?.close();
|
|
304
406
|
}
|
|
305
407
|
}
|
|
306
408
|
return snapshots;
|