agents-relay 2.0.63 → 2.0.65
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/dist/adapters.js +42 -10
- package/dist/dashboard-html.js +1 -2
- package/dist/dashboard.js +13 -21
- package/dist/execution-boundary.js +23 -0
- package/dist/github-client.js +26 -20
- package/dist/job-execution-slot.js +12 -0
- package/dist/job-worktree-cleanup.js +10 -0
- package/dist/local-commit-events.js +10 -0
- package/dist/managed-worker-input.js +62 -0
- package/dist/owned-process.js +5 -1
- package/dist/planned-review-head.js +17 -0
- package/dist/planner-runtime.js +5 -1
- package/dist/planner.js +1 -1
- package/dist/pool.js +1 -1
- package/dist/projected-store.js +6 -0
- package/dist/reconciler-review.js +11 -1
- package/dist/reconciler-runtime.js +8 -32
- package/dist/reconciler.js +17 -65
- package/dist/runtime-status.js +8 -5
- package/dist/scheduler.js +4 -1
- package/dist/workflow/contracts.js +8 -0
- package/dist/workspace.js +81 -6
- package/package.json +1 -1
- package/workflow/job-lifecycle.mmd +6 -0
package/dist/adapters.js
CHANGED
|
@@ -2,7 +2,7 @@ import { readFile, writeFile, unlink } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { spawnOwnedProcess, terminateOwnedProcess } from './owned-process.js';
|
|
5
|
+
import { spawnOwnedProcess, terminateOwnedProcess, ownedProcessTerminationReason } from './owned-process.js';
|
|
6
6
|
export function buildCodexArgs(route, input) {
|
|
7
7
|
const args = ['exec', '--json'];
|
|
8
8
|
if (route.profile)
|
|
@@ -82,7 +82,7 @@ export class CodexAdapter {
|
|
|
82
82
|
child.on('error', error => { launchedReject(error); reject(error); });
|
|
83
83
|
child.once('spawn', launchedResolve);
|
|
84
84
|
child.on('close', (code, signal) => {
|
|
85
|
-
execution.processExit = { exitCode: code, signal, stderr: stderrTail };
|
|
85
|
+
execution.processExit = { exitCode: code, signal, stderr: stderrTail, terminationReason: ownedProcessTerminationReason(child) };
|
|
86
86
|
if (stdoutBuffer)
|
|
87
87
|
consumeLine(stdoutBuffer);
|
|
88
88
|
if (code === 0) {
|
|
@@ -110,14 +110,34 @@ export class LaunchFailure extends Error {
|
|
|
110
110
|
}
|
|
111
111
|
export class StartAckTimeout extends LaunchFailure {
|
|
112
112
|
}
|
|
113
|
-
function
|
|
113
|
+
function findSubmittedReceipt(value) {
|
|
114
|
+
if (typeof value === 'string') {
|
|
115
|
+
try {
|
|
116
|
+
return findSubmittedReceipt(JSON.parse(value));
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!value || typeof value !== 'object')
|
|
123
|
+
return undefined;
|
|
124
|
+
const record = value;
|
|
125
|
+
if (record.operation === 'submit' && record.status === 'submitted')
|
|
126
|
+
return record;
|
|
127
|
+
for (const key of ['result', 'output', 'data', 'stdout']) {
|
|
128
|
+
const nested = findSubmittedReceipt(record[key]);
|
|
129
|
+
if (nested)
|
|
130
|
+
return nested;
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
export function parseDriverJson(output) {
|
|
114
135
|
const lines = output.trim().split(/\r?\n/).filter(Boolean);
|
|
115
136
|
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
116
137
|
try {
|
|
117
|
-
const
|
|
118
|
-
if (
|
|
119
|
-
|
|
120
|
-
return value;
|
|
138
|
+
const receipt = findSubmittedReceipt(JSON.parse(lines[index]));
|
|
139
|
+
if (receipt)
|
|
140
|
+
return receipt;
|
|
121
141
|
}
|
|
122
142
|
catch {
|
|
123
143
|
// Browser-harness diagnostics may precede the final JSON observation.
|
|
@@ -180,7 +200,19 @@ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS
|
|
|
180
200
|
}
|
|
181
201
|
await admin.ensureWorkspace(CHATGPT_BROWSER_WORKSPACE, CHATGPT_BROWSER_WORKSPACE_POOL_SIZE);
|
|
182
202
|
}
|
|
183
|
-
function
|
|
203
|
+
async function runBrowserWorker(cli, configFile, signal) {
|
|
204
|
+
const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start'], undefined, signal));
|
|
205
|
+
const sessionId = session.session_id;
|
|
206
|
+
if (typeof sessionId !== 'string' || !sessionId)
|
|
207
|
+
throw new Error('browser-workspace session start returned no session_id');
|
|
208
|
+
try {
|
|
209
|
+
return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', 'temporary-submit', '--session-id', sessionId, '--config', configFile], undefined, signal);
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
await browserWorkspaceCommand(cli, ['session', 'stop', sessionId]).catch(() => { });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function browserWorkerRunner() {
|
|
184
216
|
const cli = browserWorkspaceCli();
|
|
185
217
|
return (_definition, request, _route, signal) => {
|
|
186
218
|
const promise = (async () => {
|
|
@@ -192,7 +224,7 @@ function autoSessionBrowserWorkerRunner() {
|
|
|
192
224
|
}));
|
|
193
225
|
if (signal.aborted)
|
|
194
226
|
throw new Error('browser-worker execution aborted');
|
|
195
|
-
const output = await
|
|
227
|
+
const output = await runBrowserWorker(cli, configFile, signal);
|
|
196
228
|
return JSON.stringify(parseDriverJson(output));
|
|
197
229
|
}
|
|
198
230
|
finally {
|
|
@@ -255,7 +287,7 @@ export class ChatGptAdapter {
|
|
|
255
287
|
if (controller.signal.aborted)
|
|
256
288
|
throw new Error('browser-worker execution aborted');
|
|
257
289
|
const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
|
|
258
|
-
const run = (this.runner ??
|
|
290
|
+
const run = (this.runner ?? browserWorkerRunner())(definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
|
|
259
291
|
try {
|
|
260
292
|
const output = await (run.submitted ?? run.promise);
|
|
261
293
|
const response = parseBrowserWorkerResponse(output);
|
package/dist/dashboard-html.js
CHANGED
|
@@ -486,9 +486,8 @@ const stream=new EventSource('/events');
|
|
|
486
486
|
stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
|
|
487
487
|
stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
|
|
488
488
|
stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);renderAll();void drawRuntimeSummary();}};
|
|
489
|
-
stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
|
|
489
|
+
stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);}else if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
|
|
490
490
|
if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/graphs')showView('graphs');else if(location.hash==='#/execution-router-rules')showView('rules');else if(location.hash==='#/shortcuts')showView('shortcuts');else showView('jobs');
|
|
491
|
-
setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/graphs'&&location.hash!=='#/execution-router-rules'&&location.hash!=='#/shortcuts')void refresh();},300000);
|
|
492
491
|
setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
|
|
493
492
|
setInterval(()=>void refreshVersion(),30000);
|
|
494
493
|
async function refreshSelected(){const job=selected();if(!job){await refresh();return;}selectedJobRefresh.disabled=true;selectedJobRefresh.textContent='Refreshing…';try{const params=new URLSearchParams({id:job.id,repo:job.repository,pr:String(job.prNumber)});const updated=await loadJSON('/api/job?'+params.toString());const index=jobs.findIndex(candidate=>jobSelectionKey(candidate)===jobSelectionKey(job));if(index>=0)jobs[index]=updated;else jobs.push(updated);selectedJob=jobSelectionKey(updated);rememberSelection();renderAll();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';}finally{selectedJobRefresh.disabled=false;selectedJobRefresh.textContent='Refresh';}}
|
package/dist/dashboard.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { subscribeLocalCommits } from './local-commit-events.js';
|
|
2
3
|
import { readFile, readdir } from 'node:fs/promises';
|
|
3
4
|
import { join } from 'node:path';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -99,17 +100,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
99
100
|
subscribedJobs.add(job.id);
|
|
100
101
|
try {
|
|
101
102
|
await bus.subscribe(job.id, async (event) => {
|
|
102
|
-
const
|
|
103
|
-
const task = durable?.tasks.find(candidate => candidate.id === event.task_id) ?? null;
|
|
104
|
-
const statePatch = durable ? {
|
|
105
|
-
jobId: durable.id,
|
|
106
|
-
jobState: durable.state,
|
|
107
|
-
jobUpdatedAt: durable.updatedAt,
|
|
108
|
-
taskId: task?.id ?? null,
|
|
109
|
-
taskState: task?.state ?? null,
|
|
110
|
-
taskUpdatedAt: task?.updatedAt ?? null,
|
|
111
|
-
} : null;
|
|
112
|
-
const payload = JSON.stringify({ event, statePatch });
|
|
103
|
+
const payload = JSON.stringify({ event });
|
|
113
104
|
for (const client of clients)
|
|
114
105
|
client.write(`event: relay\ndata: ${payload}\n\n`);
|
|
115
106
|
});
|
|
@@ -119,6 +110,16 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
119
110
|
}
|
|
120
111
|
}
|
|
121
112
|
};
|
|
113
|
+
const unsubscribeLocalCommits = subscribeLocalCommits(durable => {
|
|
114
|
+
const known = lastKnownJobs?.find(job => job.id === durable.id && job.repository === durable.repository && job.prNumber === durable.prNumber);
|
|
115
|
+
const githubState = known?.githubState;
|
|
116
|
+
const current = { ...durable, githubState, runtimeStatus: deriveJobRuntimeStatus(durable, githubState) };
|
|
117
|
+
if (lastKnownJobs && known)
|
|
118
|
+
Object.assign(known, current);
|
|
119
|
+
const payload = JSON.stringify({ job: current });
|
|
120
|
+
for (const client of clients)
|
|
121
|
+
client.write(`data: ${payload}\n\n`);
|
|
122
|
+
});
|
|
122
123
|
const snapshot = async (requestedJobId = jobId, forceRefresh = false, target) => {
|
|
123
124
|
await subscribeDiscovered();
|
|
124
125
|
if (overviewProvider) {
|
|
@@ -563,15 +564,6 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
563
564
|
res.writeHead(404);
|
|
564
565
|
res.end('Not found');
|
|
565
566
|
});
|
|
566
|
-
const interval = setInterval(() => {
|
|
567
|
-
if (clients.size === 0)
|
|
568
|
-
return;
|
|
569
|
-
void snapshot().then(value => {
|
|
570
|
-
for (const client of clients)
|
|
571
|
-
client.write(`data: ${value.json}\n\n`);
|
|
572
|
-
}).catch(() => undefined);
|
|
573
|
-
}, Math.max(10000, refreshMs));
|
|
574
|
-
interval.unref();
|
|
575
567
|
const heartbeat = setInterval(() => {
|
|
576
568
|
if (clients.size === 0)
|
|
577
569
|
return;
|
|
@@ -579,7 +571,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
579
571
|
client.write(': heartbeat\n\n');
|
|
580
572
|
}, SSE_HEARTBEAT_MS);
|
|
581
573
|
heartbeat.unref();
|
|
582
|
-
server.on('close', () => {
|
|
574
|
+
server.on('close', () => { unsubscribeLocalCommits(); clearInterval(heartbeat); clients.clear(); });
|
|
583
575
|
server.listen(port, '127.0.0.1');
|
|
584
576
|
return server;
|
|
585
577
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const executionBoundaryLocks = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* Serialize the durable execution-generation boundary across reconciler
|
|
4
|
+
* instances in one relay process. Launch, routing, and terminal persistence
|
|
5
|
+
* must not observe and write the same queued generation concurrently.
|
|
6
|
+
*/
|
|
7
|
+
export async function withExecutionBoundary(jobId, taskId, work) {
|
|
8
|
+
const key = `${jobId}/${taskId}`;
|
|
9
|
+
const previous = executionBoundaryLocks.get(key);
|
|
10
|
+
let release;
|
|
11
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
12
|
+
executionBoundaryLocks.set(key, current);
|
|
13
|
+
if (previous)
|
|
14
|
+
await previous;
|
|
15
|
+
try {
|
|
16
|
+
return await work();
|
|
17
|
+
}
|
|
18
|
+
finally {
|
|
19
|
+
release();
|
|
20
|
+
if (executionBoundaryLocks.get(key) === current)
|
|
21
|
+
executionBoundaryLocks.delete(key);
|
|
22
|
+
}
|
|
23
|
+
}
|
package/dist/github-client.js
CHANGED
|
@@ -142,27 +142,33 @@ export class GhApiClient {
|
|
|
142
142
|
if (rateLimited)
|
|
143
143
|
this.assertAvailable();
|
|
144
144
|
const commandArgs = includeHeaders ? [args[0] ?? '', '--include', ...args.slice(1)] : args;
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.noteSuccess();
|
|
163
|
-
resolve({ ...response, stderr: failed ? message : undefined });
|
|
145
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
146
|
+
const result = await new Promise((resolve) => {
|
|
147
|
+
const child = spawnOwnedProcess('gh', commandArgs, { invocation: 'github.api', spawn: { env } });
|
|
148
|
+
let stdout = '';
|
|
149
|
+
let stderr = '';
|
|
150
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
151
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
152
|
+
child.on('error', error => resolve({ status: null, headers: {}, body: '', stderr: error.message }));
|
|
153
|
+
if (input !== undefined)
|
|
154
|
+
child.stdin?.write(input);
|
|
155
|
+
child.stdin?.end();
|
|
156
|
+
child.on('close', (code, signal) => {
|
|
157
|
+
const response = parseGitHubCliHeaders(stdout, includeHeaders);
|
|
158
|
+
const failed = code !== 0;
|
|
159
|
+
const message = failed ? stderr.trim() || response.body.trim() || `gh exited ${code}; signal=${signal ?? "none"}` : '';
|
|
160
|
+
resolve({ ...response, stderr: failed ? message : undefined, signal });
|
|
161
|
+
});
|
|
164
162
|
});
|
|
165
|
-
|
|
163
|
+
if (result.signal && attempt === 0)
|
|
164
|
+
continue;
|
|
165
|
+
if (result.stderr && rateLimited)
|
|
166
|
+
this.noteFailure(result.stderr);
|
|
167
|
+
if (!result.stderr && rateLimited)
|
|
168
|
+
this.noteSuccess();
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
throw new Error('GitHub CLI retry exhausted');
|
|
166
172
|
}
|
|
167
173
|
async spawnGh(args, input, env = process.env, rateLimited = true) {
|
|
168
174
|
return (await this.runGh(args, input, env, rateLimited, false)).body;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One active execution per Job, irrespective of adapter or current reconciler.
|
|
3
|
+
* A claimed QUEUED execution also occupies the slot; synchronous P0/self work
|
|
4
|
+
* occupies it by its RUNNING state even though it has no execution UUID.
|
|
5
|
+
*/
|
|
6
|
+
export function jobHasActiveExecution(job, except = {}) {
|
|
7
|
+
const worker = job.tasks.some(task => task.id !== except.taskId
|
|
8
|
+
&& (task.state === 'RUNNING' || (task.executionId !== null && !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(task.state))));
|
|
9
|
+
const planner = (job.plans ?? []).some(plan => plan.id !== except.planId
|
|
10
|
+
&& plan.executionId !== null && plan.status === 'PLANNING');
|
|
11
|
+
return worker || planner;
|
|
12
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { cleanupCompletedJobWorktree } from './workspace.js';
|
|
2
|
+
/** Mechanical effect selected by job-lifecycle Graph on a completed PR. */
|
|
3
|
+
export async function cleanupMergedJobWorktree(job, projectPathForJob) {
|
|
4
|
+
if (!projectPathForJob)
|
|
5
|
+
throw new Error('No canonical project resolver for Worktree cleanup');
|
|
6
|
+
const result = await cleanupCompletedJobWorktree(await projectPathForJob(job), job);
|
|
7
|
+
// A blocked cleanup must be retried on a subsequent completed-Job reconcile.
|
|
8
|
+
if (result.status === 'blocked')
|
|
9
|
+
throw new Error(result.reason ?? 'Unsafe Worktree cleanup');
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
const listeners = new Set();
|
|
2
|
+
/** Notify dashboard listeners only after authoritative SQLite state is committed. */
|
|
3
|
+
export function publishLocalCommit(job) {
|
|
4
|
+
for (const listener of listeners)
|
|
5
|
+
listener(structuredClone(job));
|
|
6
|
+
}
|
|
7
|
+
export function subscribeLocalCommits(listener) {
|
|
8
|
+
listeners.add(listener);
|
|
9
|
+
return () => { listeners.delete(listener); };
|
|
10
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { agentContractPrompt, executionContextPrompt } from './execution-context.js';
|
|
2
|
+
import { agentGraphPrompt } from './agent-graph.js';
|
|
3
|
+
import { createReviewChairExecutionPlan } from './review-chair.js';
|
|
4
|
+
import { taskTagWorkerPrompt } from './task-tag.js';
|
|
5
|
+
import { executionEventSource } from './reconciler-runtime.js';
|
|
6
|
+
export async function managedWorkerInput(job, task, context) {
|
|
7
|
+
if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
|
|
8
|
+
return task.input;
|
|
9
|
+
const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
|
|
10
|
+
const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
|
|
11
|
+
const chairPlan = task.review?.role === 'chair' && context
|
|
12
|
+
? createReviewChairExecutionPlan(context.agents, task.review.target)
|
|
13
|
+
: null;
|
|
14
|
+
if (!task.output)
|
|
15
|
+
throw new Error(`Task ${task.id} requires an explicit output contract`);
|
|
16
|
+
if (!task.executionId)
|
|
17
|
+
throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
|
|
18
|
+
const sourceId = executionEventSource(job.id, task.id, task.executionId);
|
|
19
|
+
const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
|
|
20
|
+
const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
|
|
21
|
+
const output = task.output.kind === 'file'
|
|
22
|
+
? `file\nPath: ${task.output.path}`
|
|
23
|
+
: `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
|
|
24
|
+
const taskTag = taskTagWorkerPrompt(job);
|
|
25
|
+
return `[Agents Relay managed task]
|
|
26
|
+
Job URL: ${prUrl}
|
|
27
|
+
Job ID: ${job.id}
|
|
28
|
+
Task URL: ${taskUrl}
|
|
29
|
+
Task ID: ${task.id}
|
|
30
|
+
Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
|
|
31
|
+
Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.
|
|
32
|
+
${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}${chairPlan ? `[Review Chair execution plan]\n${JSON.stringify(chairPlan, null, 2)}\n\nUse this runtime-discovered panel to create sibling durable specialist Tasks. Preserve each specialistTasks entry as review metadata, keep the target immutable, and synthesize structured results with synthesizeReview semantics before returning the unified revision plan.\n\n` : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
|
|
33
|
+
[Output contract]
|
|
34
|
+
${output}
|
|
35
|
+
${taskTag}[Execution event contract — mandatory]
|
|
36
|
+
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
37
|
+
Your exact event source identity is: ${sourceId}
|
|
38
|
+
Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
|
|
39
|
+
Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
|
|
40
|
+
publish through the federated MCP tool events__publish; do not open NATS directly.
|
|
41
|
+
For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
|
|
42
|
+
As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
|
|
43
|
+
you MUST publish exactly one canonical task.started event for this exact Job/Task.
|
|
44
|
+
|
|
45
|
+
${task.adapter === 'chatgpt' ? `Because ChatGPT execution continues asynchronously after the submit process exits, you MUST keep reporting liveness while the task is active. Publish meaningful progress/milestone events when there is real progress to report. If no meaningful milestone has been published for 30 seconds during active work, publish a non-terminal task.heartbeat event with status running, visibility orchestrator, this exact source.id, and a concise description of the current operation. Continue heartbeats after each further 30 seconds of active silence until a terminal task event is published. Do not invent percentage progress.` : `You MAY publish progress events while working. For long silent active work, follow the events-bus heartbeat policy.`}
|
|
46
|
+
Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
|
|
47
|
+
- task.completed only after the declared output is durable;
|
|
48
|
+
- task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
|
|
49
|
+
- task.blocked only when genuine human/external action is required;
|
|
50
|
+
- task.cancelled only when execution was cancelled.
|
|
51
|
+
|
|
52
|
+
If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
|
|
53
|
+
Never exit after task.started without publishing exactly one terminal task event.
|
|
54
|
+
Do not publish duplicate terminal task events.
|
|
55
|
+
The worker owns task.started and task terminal events. Relay-owned task.process.launched, task.process.exited,
|
|
56
|
+
and task.process.async_exited are process lifecycle only and NEVER replace the worker task lifecycle.
|
|
57
|
+
The ChatGPT/Codex conversation or process exit is never the task result.
|
|
58
|
+
|
|
59
|
+
[Final event gate]
|
|
60
|
+
Before your final return, verify that a terminal event for this exact Job/Task was published.
|
|
61
|
+
If no terminal event has been published, publish task.failed before exiting.`;
|
|
62
|
+
}
|
package/dist/owned-process.js
CHANGED
|
@@ -4,6 +4,9 @@ import { basename } from 'node:path';
|
|
|
4
4
|
import { observability } from './observability.js';
|
|
5
5
|
const owned = new Map();
|
|
6
6
|
const byChild = new WeakMap();
|
|
7
|
+
export function ownedProcessTerminationReason(child) {
|
|
8
|
+
return byChild.get(child)?.terminationReason ?? null;
|
|
9
|
+
}
|
|
7
10
|
let exitHookInstalled = false;
|
|
8
11
|
function audit(record, event, outcome, data) {
|
|
9
12
|
observability().record({
|
|
@@ -113,7 +116,7 @@ export function spawnOwnedProcess(command, args, options) {
|
|
|
113
116
|
record.timeoutTimer.unref();
|
|
114
117
|
}
|
|
115
118
|
child.once('exit', (code, signal) => {
|
|
116
|
-
audit(record, 'process.exited', code === 0 ? 'succeeded' : 'failed', { exitStatus: code, signal });
|
|
119
|
+
audit(record, 'process.exited', code === 0 ? 'succeeded' : 'failed', { exitStatus: code, signal, terminationReason: record.terminationReason ?? null });
|
|
117
120
|
// `close` waits for inherited stdio. End any descendants left behind by a
|
|
118
121
|
// wrapper before they can keep the caller alive indefinitely.
|
|
119
122
|
if (!record.terminating)
|
|
@@ -143,6 +146,7 @@ export async function terminateOwnedProcess(child, reason, graceMs) {
|
|
|
143
146
|
return;
|
|
144
147
|
if (!record.terminating) {
|
|
145
148
|
record.terminating = true;
|
|
149
|
+
record.terminationReason = reason;
|
|
146
150
|
const outcome = signalOwned(record, 'SIGTERM');
|
|
147
151
|
audit(record, 'process.signal', outcome === 'error' ? 'failed' : 'succeeded', { reason, signal: 'SIGTERM', delivered: outcome === 'sent', alreadyGone: outcome === 'gone' });
|
|
148
152
|
record.forceTimer = setTimeout(() => forceOwned(record, reason), graceMs ?? record.graceMs);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** A planner-created dependent review must freeze its head only after implementation completes. */
|
|
2
|
+
export async function bindPlannedReviewHead(store, task) {
|
|
3
|
+
const current = await store.pullRequestHead?.();
|
|
4
|
+
if (!current?.sha)
|
|
5
|
+
throw new Error(`Review ${task.id} requires current PR head SHA`);
|
|
6
|
+
task.reviewHeadSha = current.sha;
|
|
7
|
+
const rule = [
|
|
8
|
+
'Review the current immutable PR head as a PRE-MERGE approval gate.',
|
|
9
|
+
'Distinguish code added by this implementation from pre-existing PR baseline/seed files present before the Task started.',
|
|
10
|
+
'Preserved baseline files are not automatically extra changes merely because they differ from main; still review them for real risks.',
|
|
11
|
+
'Do not require PR MERGED, Job COMPLETED, or post-merge E2E evidence before review approval:',
|
|
12
|
+
'those events must follow approval and are verified by the independent E2E runner.',
|
|
13
|
+
].join(' ');
|
|
14
|
+
if (!task.input.includes(rule))
|
|
15
|
+
task.input += '\n\n' + rule;
|
|
16
|
+
await store.saveTask(task);
|
|
17
|
+
}
|
package/dist/planner-runtime.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
2
3
|
import { eventFor } from './events.js';
|
|
3
4
|
import { materializePlannerChild, parsePlannerResult, validatePlannerHierarchy } from './planner.js';
|
|
4
5
|
import { samePlannerChildDefinition } from './planner-child.js';
|
|
@@ -92,11 +93,14 @@ export async function launchPlanExecution(ports, job, plan) {
|
|
|
92
93
|
await ports.emit(eventFor(job.id, plan.id, null, 'planner.failed', 'failed', `Planner ${plan.id}: Planner unavailable`, 'user'));
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
|
-
const claimed = await withExecutionBoundary(job.id,
|
|
96
|
+
const claimed = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
|
|
96
97
|
const freshJob = await ports.store.load(job.id);
|
|
97
98
|
const fresh = (freshJob.plans ?? []).find(item => item.id === plan.id);
|
|
98
99
|
if (!fresh || fresh.status !== 'PLANNING')
|
|
99
100
|
return null;
|
|
101
|
+
// A Planner is an execution too: no overlapping worker or second Planner.
|
|
102
|
+
if (jobHasActiveExecution(freshJob, { planId: fresh.id }))
|
|
103
|
+
return null;
|
|
100
104
|
const live = ports.live.get(fresh.id);
|
|
101
105
|
if (fresh.executionId && fresh.leaseOwner === ports.options.owner && live?.executionId === fresh.executionId)
|
|
102
106
|
return null;
|
package/dist/planner.js
CHANGED
|
@@ -100,7 +100,7 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
|
|
|
100
100
|
const source = 'round' in planner ? planner.source : planner.planningSource ?? 'model-planner';
|
|
101
101
|
const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
|
|
102
102
|
const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
|
|
103
|
-
const reviewHeadSha = spec.agentName === 'reviewer' ? plannerHeadSha ?? job.headSha : undefined;
|
|
103
|
+
const reviewHeadSha = spec.agentName === 'reviewer' && !(spec.dependencies?.length) ? plannerHeadSha ?? job.headSha : undefined;
|
|
104
104
|
return createTaskEntity(job, { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha });
|
|
105
105
|
}
|
|
106
106
|
export function planningSpec(task) {
|
package/dist/pool.js
CHANGED
|
@@ -263,7 +263,7 @@ export class RepositoryWorkerPool {
|
|
|
263
263
|
if (routedWebhook)
|
|
264
264
|
return;
|
|
265
265
|
const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.unblocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
|
|
266
|
-
const wake = event.type === 'job.wake' || event.type === 'github.webhook';
|
|
266
|
+
const wake = event.type === 'job.wake' || event.type === 'github.webhook' || event.type === 'review.created';
|
|
267
267
|
if (!lifecycle && !wake)
|
|
268
268
|
return;
|
|
269
269
|
if (repository && prNumber)
|
package/dist/projected-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { publishLocalCommit } from './local-commit-events.js';
|
|
1
2
|
import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
|
|
2
3
|
import { DurableDefinitionConflictError, isDurableDefinitionConflict, sameTaskDefinition } from './store-task-definition.js';
|
|
3
4
|
import { decideProjectionFailure } from './workflow/projection-conflict-v2.js';
|
|
@@ -221,6 +222,7 @@ export class ProjectedDurableStore {
|
|
|
221
222
|
payload: { operation: 'save_job', value },
|
|
222
223
|
priority: ['COMPLETED', 'CANCELLED'].includes(job.state) ? 100 : 20,
|
|
223
224
|
});
|
|
225
|
+
publishLocalCommit(job);
|
|
224
226
|
observability().record({
|
|
225
227
|
stage: 'store', event: 'store.commit', outcome: 'succeeded',
|
|
226
228
|
message: `Job ${job.id} committed`,
|
|
@@ -243,6 +245,7 @@ export class ProjectedDurableStore {
|
|
|
243
245
|
payload: { operation: 'append_plan', value: plan },
|
|
244
246
|
priority: 30,
|
|
245
247
|
});
|
|
248
|
+
publishLocalCommit(job);
|
|
246
249
|
}
|
|
247
250
|
async savePlan(plan) {
|
|
248
251
|
const job = await this.load(plan.jobId);
|
|
@@ -260,6 +263,7 @@ export class ProjectedDurableStore {
|
|
|
260
263
|
payload: { operation: 'save_plan', value: plan },
|
|
261
264
|
priority: 30,
|
|
262
265
|
});
|
|
266
|
+
publishLocalCommit(job);
|
|
263
267
|
}
|
|
264
268
|
async appendTask(task) {
|
|
265
269
|
if (!task.jobId)
|
|
@@ -278,6 +282,7 @@ export class ProjectedDurableStore {
|
|
|
278
282
|
payload: { operation: 'append_task', value: task },
|
|
279
283
|
priority: 40,
|
|
280
284
|
});
|
|
285
|
+
publishLocalCommit(job);
|
|
281
286
|
observability().record({
|
|
282
287
|
stage: 'store', event: 'store.commit', outcome: 'succeeded',
|
|
283
288
|
message: `Task ${task.id} appended`,
|
|
@@ -305,6 +310,7 @@ export class ProjectedDurableStore {
|
|
|
305
310
|
payload: { operation: 'save_task', value },
|
|
306
311
|
priority: terminal ? 100 : 50,
|
|
307
312
|
});
|
|
313
|
+
publishLocalCommit(job);
|
|
308
314
|
observability().record({
|
|
309
315
|
stage: 'store', event: 'store.commit', outcome: 'succeeded',
|
|
310
316
|
message: `Task ${task.id} committed (${task.state})`,
|
|
@@ -113,7 +113,11 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
113
113
|
jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer',
|
|
114
114
|
parentTaskId: null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
|
|
115
115
|
workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter,
|
|
116
|
-
input: 'Review existing PR #' + job.prNumber + ' at exactly head SHA ' + headSha + '. Job objective: ' + (job.objective ?? job.description ?? job.title) + '. Use the selected reviewer agent contract as the authoritative review protocol.'
|
|
116
|
+
input: 'Review existing PR #' + job.prNumber + ' at exactly head SHA ' + headSha + '. Job objective: ' + (job.objective ?? job.description ?? job.title) + '. Use the selected reviewer agent contract as the authoritative review protocol. ' +
|
|
117
|
+
'This is a PRE-MERGE artifact approval gate: inspect the exact PR head, requested changes, applicable source/test requirements, and genuine blocking defects in the proposed changes. ' +
|
|
118
|
+
'Do not block approval because automatic merge, Job COMPLETED, or post-merge E2E evidence has not occurred yet; those are downstream effects of review approval and independently verified later. ' +
|
|
119
|
+
'Do not treat historical failed attempts or launch anomalies as current artifact defects if the latest implementation and tests succeeded; report historical lifecycle observations separately. ' +
|
|
120
|
+
'Compare implementation changes with the PR head that existed before the implementation, not only against main: preserved seed or baseline files are not automatically unrelated additions.',
|
|
117
121
|
output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null,
|
|
118
122
|
reviewHeadSha: headSha, attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2,
|
|
119
123
|
leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null,
|
|
@@ -267,6 +271,12 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
267
271
|
const currentHeadSha = await this.store.headSha?.();
|
|
268
272
|
if ((currentHeadSha && currentHeadSha !== headSha) || (job.headSha && job.headSha !== headSha))
|
|
269
273
|
return { applied: false, reconcile: false, reason: event.type + '.stale' };
|
|
274
|
+
// Planner-created reviewer tasks predate the final-review Job gate. Bind
|
|
275
|
+
// their first trustworthy review event to PENDING before dispatching it.
|
|
276
|
+
if (!job.review && task.planningDecisionId) {
|
|
277
|
+
job.review = { outcome: 'pending', headSha, taskId: task.id, evidence: null, updatedAt: event.timestamp || new Date().toISOString() };
|
|
278
|
+
await this.store.saveJob(job);
|
|
279
|
+
}
|
|
270
280
|
const taskDecision = tryTransitionTaskEvent(task, event.type);
|
|
271
281
|
if (!taskDecision.accepted || !taskDecision.target)
|
|
272
282
|
return { applied: false, reconcile: false, reason: event.type + '.ignored' };
|
|
@@ -17,29 +17,8 @@ function executionIdFromSource(sourceId, jobId, taskId) {
|
|
|
17
17
|
}
|
|
18
18
|
const terminalExecutionClaims = new Set();
|
|
19
19
|
const activeExecutionOwners = new Map();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* Serialize the durable execution-generation boundary across reconciler
|
|
23
|
-
* instances in one relay process. Launch, routing, and terminal persistence
|
|
24
|
-
* must not observe and write the same queued generation concurrently.
|
|
25
|
-
*/
|
|
26
|
-
export async function withExecutionBoundary(jobId, taskId, work) {
|
|
27
|
-
const key = `${jobId}/${taskId}`;
|
|
28
|
-
const previous = executionBoundaryLocks.get(key);
|
|
29
|
-
let release;
|
|
30
|
-
const current = new Promise(resolve => { release = resolve; });
|
|
31
|
-
executionBoundaryLocks.set(key, current);
|
|
32
|
-
if (previous)
|
|
33
|
-
await previous;
|
|
34
|
-
try {
|
|
35
|
-
return await work();
|
|
36
|
-
}
|
|
37
|
-
finally {
|
|
38
|
-
release();
|
|
39
|
-
if (executionBoundaryLocks.get(key) === current)
|
|
40
|
-
executionBoundaryLocks.delete(key);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
20
|
+
import { withExecutionBoundary } from './execution-boundary.js';
|
|
21
|
+
export { withExecutionBoundary } from './execution-boundary.js';
|
|
43
22
|
function claimTerminalExecution(sourceId) {
|
|
44
23
|
terminalExecutionClaims.add(sourceId);
|
|
45
24
|
const cleanup = setTimeout(() => terminalExecutionClaims.delete(sourceId), 60000);
|
|
@@ -302,12 +281,12 @@ export class ReconcilerRuntimeBase {
|
|
|
302
281
|
}
|
|
303
282
|
const job = await this.store.load(jobId);
|
|
304
283
|
const task = job.tasks.find(item => item.id === taskId);
|
|
305
|
-
if (!task ||
|
|
284
|
+
if (terminalExecutionClaimed(jobId, taskId, executionId) || !task || task.executionId !== executionId)
|
|
306
285
|
return;
|
|
307
286
|
if (task.adapter !== 'chatgpt' && task.executionId === executionId && live?.executionId === executionId)
|
|
308
287
|
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'));
|
|
309
|
-
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 ?? (error ? 'Worker runtime exited with error' : 'Worker runtime exited'), 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, exitCode: live?.execution.processExit?.exitCode ?? null,
|
|
310
|
-
signal: live?.execution.processExit?.signal ?? null, stderr: live?.execution.processExit?.stderr ?? '', processError: error, executionId, pid: live?.execution.pid ?? null }, { id: executionEventSource(jobId, taskId, executionId), agent: 'relay', component: 'agents-relay' }));
|
|
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,
|
|
289
|
+
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' }));
|
|
311
290
|
}
|
|
312
291
|
async timeoutTask(jobId, taskId, executionId) {
|
|
313
292
|
const live = this.live.get(taskId);
|
|
@@ -363,9 +342,8 @@ export class ReconcilerRuntimeBase {
|
|
|
363
342
|
const prior = eventExecutionId ? task.executionHistory?.find(item => item.id === eventExecutionId) : undefined;
|
|
364
343
|
// A duplicate launch can leave one worker orphaned after another worker
|
|
365
344
|
// has already produced the terminal fact. Accept only a known execution
|
|
366
|
-
// from the same durable attempt
|
|
367
|
-
|
|
368
|
-
if (!prior || prior.attempt !== task.attempt || !event.timestamp || new Date(event.timestamp).getTime() < new Date(prior.startedAt).getTime())
|
|
345
|
+
// from the same durable attempt. A prior retry attempt cannot complete a newer generation.
|
|
346
|
+
if (!prior || prior.attempt !== task.attempt)
|
|
369
347
|
return false;
|
|
370
348
|
}
|
|
371
349
|
const terminalWaiter = this.terminalWaiters.get(task.id);
|
|
@@ -373,9 +351,7 @@ export class ReconcilerRuntimeBase {
|
|
|
373
351
|
},
|
|
374
352
|
facts: (_job, task) => {
|
|
375
353
|
const processExitedAfterPublishedTerminal = task.failureReason === 'worker_exit_without_terminal_event';
|
|
376
|
-
const freshEnough = processExitedAfterPublishedTerminal
|
|
377
|
-
|| !event.timestamp
|
|
378
|
-
|| new Date(event.timestamp).getTime() >= new Date(task.updatedAt).getTime();
|
|
354
|
+
const freshEnough = processExitedAfterPublishedTerminal || this.isCorrelatedWorkerEvent(task, event);
|
|
379
355
|
return { recoverableExecution: this.isRecoverableWatchdogFailure(task) && freshEnough };
|
|
380
356
|
},
|
|
381
357
|
mutate: (job, task) => {
|
package/dist/reconciler.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { bindPlannedReviewHead } from './planned-review-head.js';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
|
|
3
4
|
import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
|
|
@@ -6,17 +7,18 @@ import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
|
6
7
|
import { eventFor } from './events.js';
|
|
7
8
|
import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
|
|
8
9
|
import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
|
|
9
|
-
import {
|
|
10
|
-
import { agentGraphPrompt } from './agent-graph.js';
|
|
10
|
+
import { resolveExecutionContext } from './execution-context.js';
|
|
11
11
|
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
12
12
|
import { executeManualPlan } from './manual-plan.js';
|
|
13
13
|
import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
|
|
14
14
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
15
15
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
16
16
|
import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
17
|
-
import { prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
17
|
+
import { assertCleanWorktreeAtHead, prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
|
|
18
|
+
import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
|
|
19
|
+
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
20
|
+
import { managedWorkerInput } from './managed-worker-input.js';
|
|
21
|
+
export { managedWorkerInput } from './managed-worker-input.js';
|
|
20
22
|
import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
|
|
21
23
|
import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
|
|
22
24
|
import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
|
|
@@ -24,63 +26,6 @@ import { launchPlanExecution } from './planner-runtime.js';
|
|
|
24
26
|
import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
|
|
25
27
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
26
28
|
export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
27
|
-
export async function managedWorkerInput(job, task, context) {
|
|
28
|
-
if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
|
|
29
|
-
return task.input;
|
|
30
|
-
const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
|
|
31
|
-
const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
|
|
32
|
-
const chairPlan = task.review?.role === 'chair' && context
|
|
33
|
-
? createReviewChairExecutionPlan(context.agents, task.review.target)
|
|
34
|
-
: null;
|
|
35
|
-
if (!task.output)
|
|
36
|
-
throw new Error(`Task ${task.id} requires an explicit output contract`);
|
|
37
|
-
if (!task.executionId)
|
|
38
|
-
throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
|
|
39
|
-
const sourceId = executionEventSource(job.id, task.id, task.executionId);
|
|
40
|
-
const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
|
|
41
|
-
const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
|
|
42
|
-
const output = task.output.kind === 'file'
|
|
43
|
-
? `file\nPath: ${task.output.path}`
|
|
44
|
-
: `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
|
|
45
|
-
const taskTag = taskTagWorkerPrompt(job);
|
|
46
|
-
return `[Agents Relay managed task]
|
|
47
|
-
Job URL: ${prUrl}
|
|
48
|
-
Job ID: ${job.id}
|
|
49
|
-
Task URL: ${taskUrl}
|
|
50
|
-
Task ID: ${task.id}
|
|
51
|
-
Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
|
|
52
|
-
Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.
|
|
53
|
-
${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}${chairPlan ? `[Review Chair execution plan]\n${JSON.stringify(chairPlan, null, 2)}\n\nUse this runtime-discovered panel to create sibling durable specialist Tasks. Preserve each specialistTasks entry as review metadata, keep the target immutable, and synthesize structured results with synthesizeReview semantics before returning the unified revision plan.\n\n` : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
|
|
54
|
-
[Output contract]
|
|
55
|
-
${output}
|
|
56
|
-
${taskTag}[Execution event contract — mandatory]
|
|
57
|
-
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
58
|
-
Your exact event source identity is: ${sourceId}
|
|
59
|
-
Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
|
|
60
|
-
Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
|
|
61
|
-
publish through the federated MCP tool events__publish; do not open NATS directly.
|
|
62
|
-
For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
|
|
63
|
-
As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
|
|
64
|
-
you MUST publish exactly one canonical task.started event for this exact Job/Task.
|
|
65
|
-
|
|
66
|
-
${task.adapter === 'chatgpt' ? `Because ChatGPT execution continues asynchronously after the submit process exits, you MUST keep reporting liveness while the task is active. Publish meaningful progress/milestone events when there is real progress to report. If no meaningful milestone has been published for 30 seconds during active work, publish a non-terminal task.heartbeat event with status running, visibility orchestrator, this exact source.id, and a concise description of the current operation. Continue heartbeats after each further 30 seconds of active silence until a terminal task event is published. Do not invent percentage progress.` : `You MAY publish progress events while working. For long silent active work, follow the events-bus heartbeat policy.`}
|
|
67
|
-
Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
|
|
68
|
-
- task.completed only after the declared output is durable;
|
|
69
|
-
- task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
|
|
70
|
-
- task.blocked only when genuine human/external action is required;
|
|
71
|
-
- task.cancelled only when execution was cancelled.
|
|
72
|
-
|
|
73
|
-
If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
|
|
74
|
-
Never exit after task.started without publishing exactly one terminal task event.
|
|
75
|
-
Do not publish duplicate terminal task events.
|
|
76
|
-
The worker owns task.started and task terminal events. Relay-owned task.process.launched, task.process.exited,
|
|
77
|
-
and task.process.async_exited are process lifecycle only and NEVER replace the worker task lifecycle.
|
|
78
|
-
The ChatGPT/Codex conversation or process exit is never the task result.
|
|
79
|
-
|
|
80
|
-
[Final event gate]
|
|
81
|
-
Before your final return, verify that a terminal event for this exact Job/Task was published.
|
|
82
|
-
If no terminal event has been published, publish task.failed before exiting.`;
|
|
83
|
-
}
|
|
84
29
|
export class Reconciler extends ReconcilerReviewBase {
|
|
85
30
|
constructor(store, options) {
|
|
86
31
|
super(store, options);
|
|
@@ -93,6 +38,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
93
38
|
await this.launch(job, task);
|
|
94
39
|
}, context => `effect.launchTask:${context.jobId}:${context.taskId ?? 'task'}:${String(context.data?.attempt ?? 0)}`));
|
|
95
40
|
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cancelActiveTasks', async (context) => this.cancelActiveTasks(context.jobId, String(context.data?.reason ?? 'Job terminated'))));
|
|
41
|
+
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cleanupJobWorktree', async (context) => cleanupMergedJobWorktree(await this.store.load(context.jobId), this.options.projectPathForJob), context => `effect.cleanupJobWorktree:${context.jobId}`));
|
|
96
42
|
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.mergePullRequest', async (context) => {
|
|
97
43
|
const headSha = typeof context.data?.headSha === 'string' ? context.data.headSha : null;
|
|
98
44
|
if (!headSha || !this.store.mergePullRequest)
|
|
@@ -106,14 +52,14 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
106
52
|
this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
|
|
107
53
|
}
|
|
108
54
|
const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
|
|
109
|
-
const dispatchable = scheduled.tasks
|
|
55
|
+
const dispatchable = (jobHasActiveExecution(scheduled) ? [] : scheduled.tasks)
|
|
110
56
|
.filter(task => task.state === 'QUEUED'
|
|
111
57
|
&& task.executionId === null
|
|
112
58
|
&& task.kind !== 'planner'
|
|
113
59
|
&& task.adapter !== 'orchestrator'
|
|
114
60
|
&& dependencyReady(task, new Map(scheduled.tasks.map(item => [item.id, item]))))
|
|
115
61
|
.sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2'))
|
|
116
|
-
.slice(0, capacity);
|
|
62
|
+
.slice(0, Math.min(capacity, 1));
|
|
117
63
|
for (const task of dispatchable) {
|
|
118
64
|
await transitionTaskEvent(task, 'dispatch', { dispatchable: true }, async (actionId) => {
|
|
119
65
|
const outcomes = await this.workflowEffects.execute([actionId], { jobId: scheduled.id, taskId: task.id, transitionKey: `dispatch:${task.attempt}`, data: { attempt: task.attempt } });
|
|
@@ -380,13 +326,17 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
380
326
|
await this.store.saveTask(task);
|
|
381
327
|
return;
|
|
382
328
|
}
|
|
383
|
-
const claimedTask = await withExecutionBoundary(job.id,
|
|
329
|
+
const claimedTask = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
|
|
384
330
|
const freshJob = await this.store.load(job.id);
|
|
385
331
|
let freshTask = freshJob.tasks.find(item => item.id === task.id);
|
|
386
332
|
if (!freshTask || freshTask.state !== 'QUEUED' || !dependencyReady(freshTask, new Map(freshJob.tasks.map(item => [item.id, item]))))
|
|
387
333
|
return;
|
|
388
334
|
if (freshTask.executionId !== null)
|
|
389
335
|
return;
|
|
336
|
+
if (jobHasActiveExecution(freshJob, { taskId: freshTask.id }))
|
|
337
|
+
return;
|
|
338
|
+
if (freshTask.agentName === 'reviewer' && freshTask.planningDecisionId && freshTask.dependencies.length)
|
|
339
|
+
await bindPlannedReviewHead(this.store, freshTask);
|
|
390
340
|
if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
|
|
391
341
|
freshTask = (await this.routeTaskForExecution(freshJob, freshTask)).task;
|
|
392
342
|
}
|
|
@@ -426,6 +376,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
426
376
|
if (!managedHead)
|
|
427
377
|
throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
|
|
428
378
|
projectPath = await prepareManagedExecutionWorktree(projectPath, job.repository, job.prNumber, managedHead, task.executionId);
|
|
379
|
+
if (role === 'reviewer')
|
|
380
|
+
await assertCleanWorktreeAtHead(projectPath, managedHead.sha);
|
|
429
381
|
}
|
|
430
382
|
executionContext = await this.resolvedContext(job, task, role, projectPath);
|
|
431
383
|
}
|
package/dist/runtime-status.js
CHANGED
|
@@ -32,13 +32,16 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
32
32
|
return 'WAITING';
|
|
33
33
|
return 'WAITING';
|
|
34
34
|
}
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
// Draft or queued reviewers must not mask implementation that is still active.
|
|
36
|
+
const implementation = implementationTasks(job);
|
|
37
|
+
if (implementation.some(task => task.state === 'RUNNING'))
|
|
38
|
+
return 'RUNNING';
|
|
39
|
+
if (implementation.some(task => task.state === 'QUEUED'))
|
|
40
|
+
return 'QUEUED';
|
|
41
|
+
const reviewRunning = job.tasks.some(task => task.agentName === 'reviewer' && task.state === 'RUNNING');
|
|
39
42
|
if (job.review?.outcome === 'approved' && prState === 'OPEN')
|
|
40
43
|
return 'MERGE_PENDING';
|
|
41
|
-
if (
|
|
44
|
+
if (reviewRunning)
|
|
42
45
|
return 'REVIEWING';
|
|
43
46
|
if (job.tasks.some(task => task.state === 'RUNNING'))
|
|
44
47
|
return 'RUNNING';
|
package/dist/scheduler.js
CHANGED
|
@@ -9,7 +9,10 @@ export function completionGateSatisfied(job) {
|
|
|
9
9
|
return false;
|
|
10
10
|
if (job.executionMode !== 'autonomous')
|
|
11
11
|
return true;
|
|
12
|
-
const
|
|
12
|
+
const latest = [...(job.plans ?? [])].sort((a, b) => a.round - b.round).at(-1);
|
|
13
|
+
const previousReviewFinished = job.tasks.some(task => task.agentName === 'reviewer' && task.state === 'SUCCEEDED');
|
|
14
|
+
const planSatisfied = latest?.status === 'SUCCEEDED' && (latest.objectiveStatus === 'satisfied'
|
|
15
|
+
|| (latest.objectiveStatus === 'in_progress' && latest.plannerResult?.next_tasks?.length === 0 && previousReviewFinished));
|
|
13
16
|
const legacySatisfied = (job.plans?.length ?? 0) === 0 && job.tasks.some(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied');
|
|
14
17
|
return (planSatisfied || legacySatisfied) && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
|
|
15
18
|
}
|
|
@@ -4,6 +4,7 @@ export const WORKFLOW_EVENT_NAMES = [
|
|
|
4
4
|
'cancel',
|
|
5
5
|
'dispatch',
|
|
6
6
|
'effect.cancelActiveTasks.failed',
|
|
7
|
+
'effect.cleanupJobWorktree.failed',
|
|
7
8
|
'effect.launchTask.failed',
|
|
8
9
|
'effect.mergePullRequest.failed',
|
|
9
10
|
'execution.orphaned',
|
|
@@ -66,6 +67,7 @@ export const WORKFLOW_EFFECT_NAMES = [
|
|
|
66
67
|
'effect.launchTask',
|
|
67
68
|
'effect.cancelActiveTasks',
|
|
68
69
|
'effect.mergePullRequest',
|
|
70
|
+
'effect.cleanupJobWorktree',
|
|
69
71
|
];
|
|
70
72
|
export const WORKFLOW_EFFECT_POLICIES = {
|
|
71
73
|
'effect.launchTask': {
|
|
@@ -80,6 +82,12 @@ export const WORKFLOW_EFFECT_POLICIES = {
|
|
|
80
82
|
retry: 'workflow',
|
|
81
83
|
observability: 'workflow.effect',
|
|
82
84
|
},
|
|
85
|
+
'effect.cleanupJobWorktree': {
|
|
86
|
+
idempotency: 'job-completed',
|
|
87
|
+
failureEvent: 'effect.cleanupJobWorktree.failed',
|
|
88
|
+
retry: 'workflow',
|
|
89
|
+
observability: 'workflow.effect',
|
|
90
|
+
},
|
|
83
91
|
'effect.mergePullRequest': {
|
|
84
92
|
idempotency: 'job-head-sha',
|
|
85
93
|
failureEvent: 'effect.mergePullRequest.failed',
|
package/dist/workspace.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
1
|
+
import { lstat, mkdir, readdir, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { spawnOwnedProcess } from './owned-process.js';
|
|
@@ -122,7 +122,7 @@ export function taskUsesCanonicalProjectRun(task, tasks) {
|
|
|
122
122
|
async function git(path, args) {
|
|
123
123
|
return (await gitOutput(['-C', path, ...args])).trim();
|
|
124
124
|
}
|
|
125
|
-
export async function prepareManagedExecutionWorktree(projectPath, repository, prNumber, head,
|
|
125
|
+
export async function prepareManagedExecutionWorktree(projectPath, repository, prNumber, head, _executionId, workspaceRoot = defaultWorkspaceRoot()) {
|
|
126
126
|
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|
|
127
127
|
const projectRelative = (await git(projectPath, ['rev-parse', '--show-prefix'])).replace(/\/$/, '');
|
|
128
128
|
const remote = await git(primary, ['config', '--get', 'remote.origin.url']);
|
|
@@ -133,14 +133,25 @@ export async function prepareManagedExecutionWorktree(projectPath, repository, p
|
|
|
133
133
|
if (fetched !== head.sha)
|
|
134
134
|
throw new Error(`PR #${prNumber} head changed while preparing execution checkout (${head.sha} -> ${fetched})`);
|
|
135
135
|
const safeRepo = repository.replace(/[^A-Za-z0-9_.-]+/g, '-');
|
|
136
|
-
|
|
137
|
-
const worktree =
|
|
136
|
+
// One stable checkout per Job; retries and reviewers share the same directory.
|
|
137
|
+
const worktree = managedJobWorktreePath(repository, prNumber, workspaceRoot);
|
|
138
138
|
await mkdir(dirname(worktree), { recursive: true });
|
|
139
139
|
try {
|
|
140
140
|
const existingRoot = await git(worktree, ['rev-parse', '--show-toplevel']);
|
|
141
141
|
const existingHead = await git(worktree, ['rev-parse', 'HEAD']);
|
|
142
|
-
if (
|
|
143
|
-
throw new Error(`
|
|
142
|
+
if (await realpath(existingRoot) !== await realpath(worktree))
|
|
143
|
+
throw new Error(`Worktree ownership mismatch: ${worktree}`);
|
|
144
|
+
if (existingHead !== head.sha) {
|
|
145
|
+
// Never wipe dirty files or commits unique to this Job checkout. A clean
|
|
146
|
+
// ancestor can safely advance to the remote PR head without reset --hard.
|
|
147
|
+
const dirty = await git(worktree, ['status', '--porcelain']);
|
|
148
|
+
if (dirty)
|
|
149
|
+
throw new Error(`Job worktree has changes while PR head advanced: ${worktree}`);
|
|
150
|
+
const ancestor = await gitOutput(['-C', worktree, 'merge-base', '--is-ancestor', existingHead, head.sha]).then(() => true, () => false);
|
|
151
|
+
if (!ancestor)
|
|
152
|
+
throw new Error(`Job worktree has non-fast-forward history; preserve commits: ${worktree}`);
|
|
153
|
+
await git(worktree, ['merge', '--ff-only', head.sha]);
|
|
154
|
+
}
|
|
144
155
|
return projectRelative ? join(worktree, projectRelative) : worktree;
|
|
145
156
|
}
|
|
146
157
|
catch (error) {
|
|
@@ -164,3 +175,67 @@ function repositoryPathRank(path, root) {
|
|
|
164
175
|
return 3;
|
|
165
176
|
return 1;
|
|
166
177
|
}
|
|
178
|
+
/** Stable checkout identity for the entire managed PR/Job lifecycle. */
|
|
179
|
+
export function managedJobWorktreePath(repository, prNumber, workspaceRoot = defaultWorkspaceRoot()) {
|
|
180
|
+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !Number.isSafeInteger(prNumber) || prNumber <= 0)
|
|
181
|
+
throw new Error('Invalid managed Job checkout identity');
|
|
182
|
+
return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-pr-${prNumber}`, 'job');
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Graph-owned eligibility: called only after authoritative MERGED -> COMPLETED.
|
|
186
|
+
* This function only performs the mechanical, conservative cleanup effect.
|
|
187
|
+
*/
|
|
188
|
+
export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
|
|
189
|
+
if (job.state !== 'COMPLETED')
|
|
190
|
+
return { status: 'blocked', reason: 'Job is not completed' };
|
|
191
|
+
if (!job.headSha)
|
|
192
|
+
return { status: 'blocked', reason: 'No durable PR head SHA' };
|
|
193
|
+
if (job.tasks.some(task => task.executionId !== null || task.leaseOwner !== null))
|
|
194
|
+
return { status: 'blocked', reason: 'Task still owns an execution or lease' };
|
|
195
|
+
if ((job.plans ?? []).some(plan => plan.executionId !== null || plan.leaseOwner !== null))
|
|
196
|
+
return { status: 'blocked', reason: 'Planner still owns an execution or lease' };
|
|
197
|
+
const worktree = managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
|
|
198
|
+
let metadata;
|
|
199
|
+
try {
|
|
200
|
+
metadata = await lstat(worktree);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
if (error.code === 'ENOENT')
|
|
204
|
+
return { status: 'absent' };
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink())
|
|
208
|
+
return { status: 'blocked', reason: 'Worktree path is not an owned directory' };
|
|
209
|
+
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|
|
210
|
+
const origin = await git(primary, ['config', '--get', 'remote.origin.url']);
|
|
211
|
+
if (githubRepositoryFromRemote(origin) !== job.repository)
|
|
212
|
+
return { status: 'blocked', reason: 'Canonical repository identity mismatch' };
|
|
213
|
+
const registered = await git(primary, ['worktree', 'list', '--porcelain']);
|
|
214
|
+
const ownedRoot = await realpath(worktree);
|
|
215
|
+
const registeredPaths = registered.split('\n').filter(line => line.startsWith('worktree '));
|
|
216
|
+
const matching = await Promise.all(registeredPaths.map(async (line) => {
|
|
217
|
+
try {
|
|
218
|
+
return await realpath(line.slice('worktree '.length)) === ownedRoot;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
}));
|
|
224
|
+
if (!matching.includes(true))
|
|
225
|
+
return { status: 'blocked', reason: 'Worktree is not registered with the canonical repository' };
|
|
226
|
+
const head = await git(worktree, ['rev-parse', 'HEAD']);
|
|
227
|
+
if (head !== job.headSha)
|
|
228
|
+
return { status: 'blocked', reason: 'Worktree contains unique or stale commits' };
|
|
229
|
+
if (await git(worktree, ['status', '--porcelain']))
|
|
230
|
+
return { status: 'blocked', reason: 'Worktree has uncommitted or untracked changes' };
|
|
231
|
+
await git(primary, ['worktree', 'remove', worktree]);
|
|
232
|
+
return { status: 'removed' };
|
|
233
|
+
}
|
|
234
|
+
/** Reviewer must never inspect local, uncommitted content as an immutable SHA. */
|
|
235
|
+
export async function assertCleanWorktreeAtHead(projectPath, headSha) {
|
|
236
|
+
const actual = await git(projectPath, ['rev-parse', 'HEAD']);
|
|
237
|
+
if (actual !== headSha)
|
|
238
|
+
throw new Error(`Review checkout has stale HEAD: ${actual}, expected ${headSha}`);
|
|
239
|
+
if (await git(projectPath, ['status', '--porcelain']))
|
|
240
|
+
throw new Error(`Review checkout has uncommitted changes: ${projectPath}`);
|
|
241
|
+
}
|
package/package.json
CHANGED
|
@@ -68,3 +68,9 @@ cancelled -->|on: pr.merged| cancelCancelledMerge
|
|
|
68
68
|
cancelCancelledMerge --> completed
|
|
69
69
|
cancelled -->|on: effect.cancelActiveTasks.failed| cancelled
|
|
70
70
|
completed -->|on: effect.cancelActiveTasks.failed| completed
|
|
71
|
+
|
|
72
|
+
%% Cleanup eligibility belongs to the lifecycle graph and is retryable after restart.
|
|
73
|
+
cleanupCompletedWorktree["action: effect.cleanupJobWorktree"]
|
|
74
|
+
completed -->|on: pr.merged| cleanupCompletedWorktree
|
|
75
|
+
cleanupCompletedWorktree --> completed
|
|
76
|
+
completed -->|on: effect.cleanupJobWorktree.failed| completed
|