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.
- package/README.md +24 -19
- package/dist/adapters.js +1 -1
- package/dist/cli-blocker.js +3 -5
- package/dist/cli-help.js +12 -10
- package/dist/cli-job-create.js +69 -0
- package/dist/cli-local-merge-config.js +9 -0
- package/dist/cli-support.js +25 -8
- package/dist/cli.js +66 -80
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/github-auth.js +4 -3
- package/dist/github-webhook.js +13 -5
- package/dist/local-authority-store.js +279 -0
- package/dist/local-git-merge.js +113 -0
- package/dist/local-job-path.js +7 -0
- package/dist/local-job-registry.js +23 -0
- package/dist/local-managed-store.js +21 -0
- package/dist/local-merge-delivery.js +52 -0
- package/dist/local-merge-lock.js +103 -0
- package/dist/no-github.js +11 -0
- package/dist/planned-review-head.js +7 -8
- package/dist/pool.js +72 -135
- package/dist/projected-store.js +20 -3
- package/dist/projection-outbox.js +3 -1
- package/dist/publication-store.js +37 -0
- package/dist/reconciler-review.js +25 -11
- package/dist/reconciler-runtime.js +15 -5
- package/dist/reconciler.js +33 -60
- package/dist/relay-store.js +108 -6
- package/dist/relayd.js +119 -124
- package/dist/runtime-status.js +8 -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-update.js +1 -1
- 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/package.json +5 -3
- package/workflow/job-lifecycle.mmd +19 -60
- package/workflow/review-lifecycle.mmd +2 -8
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
|
|
2
|
-
import { completionGateSatisfied, schedule, transitionTaskEvent,
|
|
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) {
|
|
13
|
-
|
|
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,
|
|
162
|
+
async ensureFinalReview(job, headSha) {
|
|
150
163
|
let reviewTaskId = null;
|
|
151
|
-
const decision = await dispatchReviewLifecycle(job.review?.outcome, 'reconcile', this.reviewFacts(job,
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 ?
|
|
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.
|
package/dist/reconciler.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
82
|
-
const headSha =
|
|
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
|
-
|
|
133
|
-
await this.ensureFinalReview(scheduled,
|
|
120
|
+
{
|
|
121
|
+
await this.ensureFinalReview(scheduled, headSha);
|
|
134
122
|
scheduled = schedule(await this.store.load(jobId));
|
|
135
123
|
}
|
|
136
|
-
if (
|
|
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
|
-
|
|
142
|
-
|
|
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,
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
await
|
|
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: {
|
|
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'
|
|
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
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
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,
|
|
279
|
-
for (const planner of
|
|
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 =
|
|
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;
|
|
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';
|
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;
|