agents-relay 2.0.64 → 2.0.66
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 +16 -0
- package/dist/adapters.js +77 -9
- package/dist/cli-help.js +15 -5
- package/dist/cli-review.js +35 -0
- package/dist/cli.js +18 -3
- package/dist/job-execution-slot.js +12 -0
- package/dist/job-worktree-cleanup.js +10 -0
- package/dist/managed-worker-input.js +62 -0
- package/dist/planner-runtime.js +5 -1
- package/dist/reconciler-review.js +27 -8
- package/dist/reconciler.js +17 -69
- package/dist/relayd.js +1 -1
- package/dist/reviewer-thread.js +45 -0
- 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/README.md
CHANGED
|
@@ -151,6 +151,22 @@ Use `job update` for partial mutable metadata changes on an already healthy Job
|
|
|
151
151
|
npx agents-relay job update --repo OWNER/REPO --pr 6 --id job-6 --priority P0 --no-auto-merge
|
|
152
152
|
~~~
|
|
153
153
|
|
|
154
|
+
Request an eligible new review with `npx agents-relay review request --repo OWNER/REPO --pr NUMBER --id JOB_ID`.
|
|
155
|
+
Pass optional `--thread-id THREAD_ID` to persist a reviewer-thread override for subsequent
|
|
156
|
+
review routing. Without it, Relay uses the stored reviewer thread, then creator thread,
|
|
157
|
+
or the legacy review path when neither exists. This request wakes graph-owned
|
|
158
|
+
review orchestration; it is not a delivery receipt.
|
|
159
|
+
|
|
160
|
+
Review destinations are durable Job facts. `job create` and `job update` accept
|
|
161
|
+
`--reviewer-thread-id` and `--creator-thread-id`; a reviewer ID takes precedence
|
|
162
|
+
over the creator/origin ID. A local runtime invocation may pass
|
|
163
|
+
`--thread-id` as an explicit one-run review override. When a destination exists,
|
|
164
|
+
final-review routing requires a ChatGPT/browser-workspace worker and submits the
|
|
165
|
+
review request as a verified `new-turn` to that existing thread with the Job ID,
|
|
166
|
+
PR number, and immutable review head SHA. Failure still follows the ordinary
|
|
167
|
+
review task retry/recovery path; a transport failure is never treated as
|
|
168
|
+
review delivery.
|
|
169
|
+
|
|
154
170
|
`job update` validates the trusted Job marker first. Missing, malformed, duplicated, mismatched, or conflicting bindings fail with a precise diagnostic and are not repaired implicitly. The orchestrator handles exceptional repair explicitly according to the Job contract, then retries the update. `job show` reads one Job and `job list` lists managed Jobs in a repository.
|
|
155
171
|
|
|
156
172
|
Managed GitHub task create/update/show/list/retry/cancel and reconcile/serve commands also require a durable Job marker. Orchestrators do not need raw GitHub commands for normal Job creation or metadata updates: Agents Relay uses its authenticated GitHub client internally.
|
package/dist/adapters.js
CHANGED
|
@@ -122,7 +122,7 @@ function findSubmittedReceipt(value) {
|
|
|
122
122
|
if (!value || typeof value !== 'object')
|
|
123
123
|
return undefined;
|
|
124
124
|
const record = value;
|
|
125
|
-
if (record.operation === 'submit' && record.status === 'submitted')
|
|
125
|
+
if ((record.operation === 'submit' || record.operation === 'new-turn' || record.mode === 'new-turn') && record.status === 'submitted')
|
|
126
126
|
return record;
|
|
127
127
|
for (const key of ['result', 'output', 'data', 'stdout']) {
|
|
128
128
|
const nested = findSubmittedReceipt(record[key]);
|
|
@@ -200,13 +200,64 @@ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS
|
|
|
200
200
|
}
|
|
201
201
|
await admin.ensureWorkspace(CHATGPT_BROWSER_WORKSPACE, CHATGPT_BROWSER_WORKSPACE_POOL_SIZE);
|
|
202
202
|
}
|
|
203
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The upstream new-turn action accepts idempotency_key but does not enforce it.
|
|
205
|
+
* Inspect the current thread before resubmitting after an ambiguous exit.
|
|
206
|
+
* If the transcript is not observable, fail closed rather than duplicate.
|
|
207
|
+
*/
|
|
208
|
+
async function findExistingReviewTurn(cli, sessionId, delivery, signal) {
|
|
209
|
+
const pathname = delivery.projectId
|
|
210
|
+
? `/g/${encodeURIComponent(delivery.projectId)}/c/${encodeURIComponent(delivery.threadId)}`
|
|
211
|
+
: `/c/${encodeURIComponent(delivery.threadId)}`;
|
|
212
|
+
const marker = `Relay delivery ID: ${delivery.requestId}`;
|
|
213
|
+
const inspectJs = `(() => {
|
|
214
|
+
const legacy = [...document.querySelectorAll('[data-message-author-role="user"]')]
|
|
215
|
+
.map(e => ({text: (e.innerText || '').trim(), id: e.getAttribute('data-message-id') || ''})).filter(x => x.text);
|
|
216
|
+
if (legacy.length) return legacy;
|
|
217
|
+
return [...document.querySelectorAll('h4')].filter(e => (e.innerText || '').trim() === 'You said:')
|
|
218
|
+
.map((e,i) => ({text: (e.parentElement?.innerText || '').trim(), id: 'dom-user-' + i})).filter(x => x.text);
|
|
219
|
+
})()`;
|
|
220
|
+
const script = [
|
|
221
|
+
'import json, time',
|
|
222
|
+
'from urllib.parse import urlsplit',
|
|
223
|
+
`url = ${JSON.stringify(`https://chatgpt.com${pathname}`)}`,
|
|
224
|
+
`marker = ${JSON.stringify(marker)}`,
|
|
225
|
+
'goto_url(url)',
|
|
226
|
+
'wait_for_load()',
|
|
227
|
+
'if urlsplit(page_info().get("url", "")).path.rstrip("/") != urlsplit(url).path.rstrip("/"): raise RuntimeError("review thread redirect mismatch")',
|
|
228
|
+
'turns = []',
|
|
229
|
+
'for attempt in range(30):',
|
|
230
|
+
` turns = js(${JSON.stringify(inspectJs)}) or []`,
|
|
231
|
+
' if turns: break',
|
|
232
|
+
' time.sleep(0.5)',
|
|
233
|
+
'if not turns: raise RuntimeError("review thread transcript unavailable; refusing ambiguous resubmission")',
|
|
234
|
+
'matches = [turn for turn in turns if marker in turn.get("text", "")]',
|
|
235
|
+
'if len(matches) > 1: raise RuntimeError("duplicate review delivery detected; manual reconciliation required")',
|
|
236
|
+
'print(json.dumps({"found": bool(matches), "user_message_id": matches[0].get("id") if matches else None}), flush=True)',
|
|
237
|
+
].join('\n');
|
|
238
|
+
const envelope = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'exec', sessionId], script, signal));
|
|
239
|
+
if (envelope.ok !== true || typeof envelope.stdout !== 'string')
|
|
240
|
+
throw new Error('review thread inspection returned no executable result');
|
|
241
|
+
const observation = parseWorkspaceJson(envelope.stdout);
|
|
242
|
+
if (typeof observation.found !== 'boolean')
|
|
243
|
+
throw new Error('review thread inspection returned no authoritative result');
|
|
244
|
+
if (!observation.found)
|
|
245
|
+
return null;
|
|
246
|
+
return { status: 'submitted', mode: 'new-turn', thread_id: delivery.threadId, project_id: delivery.projectId,
|
|
247
|
+
verified_by: 'existing-user-turn', user_message_id: typeof observation.user_message_id === 'string' ? observation.user_message_id : null };
|
|
248
|
+
}
|
|
249
|
+
async function runBrowserWorker(cli, configFile, signal, action = 'temporary-submit', delivery) {
|
|
204
250
|
const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start'], undefined, signal));
|
|
205
251
|
const sessionId = session.session_id;
|
|
206
252
|
if (typeof sessionId !== 'string' || !sessionId)
|
|
207
253
|
throw new Error('browser-workspace session start returned no session_id');
|
|
208
254
|
try {
|
|
209
|
-
|
|
255
|
+
if (delivery) {
|
|
256
|
+
const existing = await findExistingReviewTurn(cli, sessionId, delivery, signal);
|
|
257
|
+
if (existing)
|
|
258
|
+
return JSON.stringify(existing);
|
|
259
|
+
}
|
|
260
|
+
return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', action, '--session-id', sessionId, '--config', configFile], undefined, signal);
|
|
210
261
|
}
|
|
211
262
|
finally {
|
|
212
263
|
await browserWorkspaceCommand(cli, ['session', 'stop', sessionId]).catch(() => { });
|
|
@@ -218,13 +269,20 @@ function browserWorkerRunner() {
|
|
|
218
269
|
const promise = (async () => {
|
|
219
270
|
const configFile = join(process.env.TMPDIR ?? '/tmp', `agents-relay-chatgpt-${randomUUID()}.json`);
|
|
220
271
|
try {
|
|
221
|
-
|
|
272
|
+
const delivery = request.thread;
|
|
273
|
+
await writeFile(configFile, JSON.stringify(delivery ? {
|
|
274
|
+
project_id: delivery.projectId ?? _route.projectId ?? null,
|
|
275
|
+
thread_id: delivery.threadId,
|
|
276
|
+
message: `${request.prompt}\n\nRelay delivery ID: ${delivery.requestId}`,
|
|
277
|
+
idempotency_key: delivery.requestId,
|
|
278
|
+
} : {
|
|
222
279
|
prompt: request.prompt,
|
|
223
280
|
file: request.files ?? [],
|
|
224
281
|
}));
|
|
225
282
|
if (signal.aborted)
|
|
226
283
|
throw new Error('browser-worker execution aborted');
|
|
227
|
-
const
|
|
284
|
+
const action = delivery ? 'new-turn' : 'temporary-submit';
|
|
285
|
+
const output = await runBrowserWorker(cli, configFile, signal, action, delivery);
|
|
228
286
|
return JSON.stringify(parseDriverJson(output));
|
|
229
287
|
}
|
|
230
288
|
finally {
|
|
@@ -246,11 +304,10 @@ function parseBrowserWorkerResponse(output) {
|
|
|
246
304
|
if (!value || typeof value !== 'object')
|
|
247
305
|
throw new Error('browser-worker returned an invalid JSON response');
|
|
248
306
|
const response = value;
|
|
249
|
-
|
|
307
|
+
const verified = response.verified === true || response.verified_by !== undefined;
|
|
308
|
+
if (response.status !== 'submitted' || !verified) {
|
|
250
309
|
throw new Error('browser-worker did not return a submitted receipt');
|
|
251
310
|
}
|
|
252
|
-
if (response.verified !== true)
|
|
253
|
-
throw new Error('browser-worker submission was not verified');
|
|
254
311
|
return response;
|
|
255
312
|
}
|
|
256
313
|
export class ChatGptAdapter {
|
|
@@ -287,10 +344,19 @@ export class ChatGptAdapter {
|
|
|
287
344
|
if (controller.signal.aborted)
|
|
288
345
|
throw new Error('browser-worker execution aborted');
|
|
289
346
|
const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
|
|
290
|
-
const run = (this.runner ?? browserWorkerRunner())(definition, {
|
|
347
|
+
const run = (this.runner ?? browserWorkerRunner())(definition, {
|
|
348
|
+
prompt: task.input,
|
|
349
|
+
...(task.attachments?.length ? { files: task.attachments } : {}),
|
|
350
|
+
...(task.chatgptThreadDelivery ? { thread: task.chatgptThreadDelivery } : {}),
|
|
351
|
+
tabClosePolicy: policy,
|
|
352
|
+
}, route, controller.signal);
|
|
291
353
|
try {
|
|
292
354
|
const output = await (run.submitted ?? run.promise);
|
|
293
355
|
const response = parseBrowserWorkerResponse(output);
|
|
356
|
+
if (task.chatgptThreadDelivery && (typeof response.thread_id !== 'string' || response.thread_id !== task.chatgptThreadDelivery.threadId)) {
|
|
357
|
+
throw new Error('browser-worker submitted to an unexpected ChatGPT thread');
|
|
358
|
+
}
|
|
359
|
+
execution.deliveryReceipt = { ...response };
|
|
294
360
|
launchResolve();
|
|
295
361
|
if (policy === 'after-start' && context.waitForWorkerStart)
|
|
296
362
|
await context.waitForWorkerStart;
|
|
@@ -307,6 +373,8 @@ export class ChatGptAdapter {
|
|
|
307
373
|
summary: 'ChatGPT task submitted; terminal task state is event-owned',
|
|
308
374
|
data: {
|
|
309
375
|
submitted: true,
|
|
376
|
+
mode: response.mode ?? 'temporary-submit',
|
|
377
|
+
threadId: response.thread_id ?? response.diagnostic_thread_id ?? null,
|
|
310
378
|
diagnosticThreadId: response.diagnostic_thread_id ?? null,
|
|
311
379
|
userMessageId: response.user_message_id ?? null,
|
|
312
380
|
verifiedBy: response.verified_by ?? null,
|
package/dist/cli-help.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const usage = 'agents-relay <job|task|plan|blocker|projection|escalation|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
|
|
1
|
+
export const usage = 'agents-relay <job|task|review|plan|blocker|projection|escalation|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
|
|
2
2
|
const help = {
|
|
3
3
|
root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
|
|
4
4
|
|
|
@@ -8,6 +8,7 @@ Usage:
|
|
|
8
8
|
Commands:
|
|
9
9
|
job create|update|show|list|merge|unblock|cancel Manage durable GitHub-backed jobs
|
|
10
10
|
task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
|
|
11
|
+
review request Request graph-owned review (optional thread override)
|
|
11
12
|
plan Invoke the Planner for an existing managed Job
|
|
12
13
|
blocker add|resolve|show Manage cross-Job blocker dependencies
|
|
13
14
|
projection conflicts|resolve Inspect and repair deterministic GitHub projection conflicts
|
|
@@ -44,11 +45,20 @@ Common options:
|
|
|
44
45
|
|
|
45
46
|
Example:
|
|
46
47
|
npx agents-relay job show --repo OWNER/REPO --pr 12 --id job-1`,
|
|
48
|
+
review: `Request a new SHA-bound reviewer turn through the existing Relay lifecycle.
|
|
49
|
+
|
|
50
|
+
Usage: npx agents-relay review request --repo OWNER/REPO --pr NUMBER --id JOB_ID [--thread-id THREAD_ID]
|
|
51
|
+
|
|
52
|
+
When provided, --thread-id persists a reviewer destination; otherwise the Job reviewer thread, then creator thread, then legacy review route is used. The command wakes Relay. Review Task creation, idempotency, routing and delivery remain graph/daemon-owned. Rejects in-flight/queued reviews; does not itself mark review delivered.`,
|
|
53
|
+
'review request': `Request a review in an existing ChatGPT thread.
|
|
54
|
+
|
|
55
|
+
Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
56
|
+
Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job reviewer/creator thread or legacy route. Rejects pending reviews and wakes managed reconciliation.`,
|
|
47
57
|
'job create': `Create a durable Job and its trusted marker.
|
|
48
58
|
|
|
49
59
|
Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
|
|
50
60
|
With --head, reuse/create the PR for that head. With --pr, bind an existing unmanaged PR.
|
|
51
|
-
Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --no-review, --tag JSON. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
|
|
61
|
+
Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
|
|
52
62
|
Job creation always enables auto-merge and never creates executable tasks. The orchestrator decides the initial task batch separately. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
|
|
53
63
|
|
|
54
64
|
Example:
|
|
@@ -56,7 +66,7 @@ Example:
|
|
|
56
66
|
'job update': `Partially update mutable metadata on a healthy existing Job.
|
|
57
67
|
|
|
58
68
|
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
|
|
59
|
-
Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, and continuation options. Updating the objective also updates the PR body.
|
|
69
|
+
Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, and continuation options. Updating the objective also updates the PR body.
|
|
60
70
|
Unspecified fields are preserved. Marker/binding corruption is never repaired implicitly; update fails with a precise diagnostic instead.
|
|
61
71
|
|
|
62
72
|
Example:
|
|
@@ -185,7 +195,7 @@ Example:
|
|
|
185
195
|
|
|
186
196
|
Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
187
197
|
|
|
188
|
-
Managed GitHub jobs are reconciled by agents-relayd. The CLI publishes a job.wake request and does not launch workers; NATS is auto-detected when reachable; use --events nats to require it explicitly.
|
|
198
|
+
Managed GitHub jobs are reconciled by agents-relayd. The CLI publishes a job.wake request and does not launch workers; NATS is auto-detected when reachable; use --events nats to require it explicitly. Local/runtime invocations may use --thread-id ID to override the durable reviewer/creator thread for ChatGPT review delivery.
|
|
189
199
|
|
|
190
200
|
Example:
|
|
191
201
|
npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1`,
|
|
@@ -198,7 +208,7 @@ Example:
|
|
|
198
208
|
serve: `Run the dashboard and reconciliation service for a managed job.
|
|
199
209
|
|
|
200
210
|
Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
201
|
-
Common: --port PORT, --interval MS, --events nats (optional; reachable NATS is auto-detected).
|
|
211
|
+
Common: --port PORT, --interval MS, --events nats (optional; reachable NATS is auto-detected), --thread-id ID (reviewer-thread override).
|
|
202
212
|
|
|
203
213
|
Example:
|
|
204
214
|
npx agents-relay serve --repo OWNER/REPO --pr 12 --id job-1`,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { storeFor, arg, hasArg, withEventBus, requestManagedReconciliation } from './cli-support.js';
|
|
2
|
+
import { normalizeThreadId, resolveReviewerThreadDestination } from './reviewer-thread.js';
|
|
3
|
+
export function reviewRequestThreadOverride(args) {
|
|
4
|
+
const values = [...args];
|
|
5
|
+
return hasArg(values, '--thread-id')
|
|
6
|
+
? normalizeThreadId(arg(values, '--thread-id'), '--thread-id')
|
|
7
|
+
: undefined;
|
|
8
|
+
}
|
|
9
|
+
export async function runReviewCommand(action, reviewArgs) {
|
|
10
|
+
if (action !== 'request')
|
|
11
|
+
throw new Error('review supports: request');
|
|
12
|
+
if (!arg(reviewArgs, '--repo') || !arg(reviewArgs, '--pr') || !arg(reviewArgs, '--id'))
|
|
13
|
+
throw new Error('review request requires --repo, --pr and --id');
|
|
14
|
+
const threadId = reviewRequestThreadOverride(reviewArgs);
|
|
15
|
+
const { store, job, localFile } = await storeFor(reviewArgs);
|
|
16
|
+
if (localFile)
|
|
17
|
+
throw new Error('review request requires a managed GitHub Job');
|
|
18
|
+
if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
|
|
19
|
+
throw new Error('Cannot request review for terminal Job');
|
|
20
|
+
if (!job.reviewEnabled)
|
|
21
|
+
throw new Error('Review disabled for this Job');
|
|
22
|
+
if (job.tasks.some(task => task.agentName === 'reviewer' && ['RUNNING', 'QUEUED', 'DRAFT'].includes(task.state)))
|
|
23
|
+
throw new Error('Review already queued or running; refusing duplicate request');
|
|
24
|
+
if (job.review?.outcome === 'approved')
|
|
25
|
+
throw new Error('Current head already approved; review request not needed');
|
|
26
|
+
if (threadId !== undefined) {
|
|
27
|
+
job.reviewerThreadId = threadId;
|
|
28
|
+
job.updatedAt = new Date().toISOString();
|
|
29
|
+
await store.saveJob(job);
|
|
30
|
+
}
|
|
31
|
+
const destination = resolveReviewerThreadDestination(job);
|
|
32
|
+
await withEventBus(reviewArgs, async (bus) => requestManagedReconciliation(bus, job));
|
|
33
|
+
console.log(JSON.stringify({ jobId: job.id, pr: job.prNumber, reviewerThreadId: destination?.threadId ?? null, status: 'requested', note: 'Relay graph will create the review Task once eligible; this is not a delivery receipt.' }, null, 2));
|
|
34
|
+
return;
|
|
35
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -32,6 +32,9 @@ import { createJobEntity, createTaskEntity } from './workflow/entity-creation.js
|
|
|
32
32
|
import { runCliEntrypoint } from './cli-shutdown.js';
|
|
33
33
|
import { shutdownOwnedProcesses } from './owned-process.js';
|
|
34
34
|
export { helpText } from './cli-help.js';
|
|
35
|
+
import { runReviewCommand } from './cli-review.js';
|
|
36
|
+
import { normalizeThreadId, runtimeReviewer } from './reviewer-thread.js';
|
|
37
|
+
export { runtimeReviewer } from './reviewer-thread.js';
|
|
35
38
|
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus } from './cli-support.js';
|
|
36
39
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
37
40
|
export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
|
|
@@ -59,6 +62,8 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
59
62
|
tag: options.tag,
|
|
60
63
|
autoMerge: true,
|
|
61
64
|
reviewEnabled: options.reviewEnabled ?? true,
|
|
65
|
+
...(options.reviewerThreadId ? { reviewerThreadId: normalizeThreadId(options.reviewerThreadId, 'reviewer thread ID') } : {}),
|
|
66
|
+
...(options.creatorThreadId ? { creatorThreadId: normalizeThreadId(options.creatorThreadId, 'creator thread ID') } : {}),
|
|
62
67
|
prNumber: pr.number,
|
|
63
68
|
repository: repositoryName,
|
|
64
69
|
continuation: options.continuation ?? null,
|
|
@@ -390,6 +395,8 @@ async function runJobCommand(action, args) {
|
|
|
390
395
|
tag: jobTagFromArgs(args),
|
|
391
396
|
reviewEnabled: !hasArg(args, '--no-review'),
|
|
392
397
|
continuation: continuation(args),
|
|
398
|
+
...(hasArg(args, '--reviewer-thread-id') ? { reviewerThreadId: arg(args, '--reviewer-thread-id') } : {}),
|
|
399
|
+
...(hasArg(args, '--creator-thread-id') ? { creatorThreadId: arg(args, '--creator-thread-id') } : {}),
|
|
393
400
|
prNumber,
|
|
394
401
|
head: arg(args, '--head') || undefined,
|
|
395
402
|
base: arg(args, '--base', 'main'),
|
|
@@ -459,6 +466,10 @@ async function runJobCommand(action, args) {
|
|
|
459
466
|
job.reviewEnabled = true;
|
|
460
467
|
if (hasArg(args, '--no-review'))
|
|
461
468
|
job.reviewEnabled = false;
|
|
469
|
+
if (hasArg(args, '--reviewer-thread-id'))
|
|
470
|
+
job.reviewerThreadId = normalizeThreadId(arg(args, '--reviewer-thread-id'), '--reviewer-thread-id');
|
|
471
|
+
if (hasArg(args, '--creator-thread-id'))
|
|
472
|
+
job.creatorThreadId = normalizeThreadId(arg(args, '--creator-thread-id'), '--creator-thread-id');
|
|
462
473
|
if (hasArg(args, '--continue-kind') || hasArg(args, '--continue-target')) {
|
|
463
474
|
const next = continuation(args);
|
|
464
475
|
if (!next)
|
|
@@ -470,8 +481,7 @@ async function runJobCommand(action, args) {
|
|
|
470
481
|
console.log(JSON.stringify(await store.load(id), null, 2));
|
|
471
482
|
}
|
|
472
483
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
473
|
-
export function
|
|
474
|
-
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
|
|
484
|
+
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
|
|
475
485
|
export function dashboardJobAction(store, reconciler, action) {
|
|
476
486
|
return async (jobId) => {
|
|
477
487
|
const job = await store.load(jobId);
|
|
@@ -572,7 +582,7 @@ async function main() {
|
|
|
572
582
|
return;
|
|
573
583
|
}
|
|
574
584
|
if (args.includes('--help') || args.includes('-h')) {
|
|
575
|
-
console.log(helpText(command, command === 'job' || command === 'task' || command === 'context' ? args[0] : undefined));
|
|
585
|
+
console.log(helpText(command, command === 'job' || command === 'task' || command === 'context' || command === 'review' ? args[0] : undefined));
|
|
576
586
|
return;
|
|
577
587
|
}
|
|
578
588
|
if (command === 'job') {
|
|
@@ -585,6 +595,11 @@ async function main() {
|
|
|
585
595
|
await runTaskCommand(action ?? '', taskArgs);
|
|
586
596
|
return;
|
|
587
597
|
}
|
|
598
|
+
if (command === 'review') {
|
|
599
|
+
const [action, ...reviewArgs] = args;
|
|
600
|
+
await runReviewCommand(action ?? '', reviewArgs);
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
588
603
|
if (command === 'context') {
|
|
589
604
|
const [action, ...contextArgs] = args;
|
|
590
605
|
await runContextCommand(action ?? '', contextArgs);
|
|
@@ -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,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/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;
|
|
@@ -7,6 +7,7 @@ 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 { chatgptThreadRequirements, resolveReviewerThreadDestination } from './reviewer-thread.js';
|
|
10
11
|
export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
11
12
|
async currentHeadSha(job) { return await this.store.headSha?.() ?? job.headSha ?? job.review?.headSha ?? null; }
|
|
12
13
|
reviewFacts(job, prState, headSha) {
|
|
@@ -108,17 +109,35 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
108
109
|
const id = this.nextFinalReviewTaskId(job, headSha);
|
|
109
110
|
const now = new Date().toISOString();
|
|
110
111
|
const reviewGateTasks = implementationTasks(job);
|
|
111
|
-
const
|
|
112
|
+
const reviewThread = resolveReviewerThreadDestination(job, this.options.reviewer?.threadId);
|
|
113
|
+
const reviewerRouting = await this.selectReviewer(reviewGateTasks, reviewThread ? chatgptThreadRequirements(this.options.reviewer?.requirements) : undefined);
|
|
114
|
+
const chatgptThreadDelivery = reviewThread ? {
|
|
115
|
+
kind: 'new-turn',
|
|
116
|
+
threadId: reviewThread.threadId,
|
|
117
|
+
threadSource: reviewThread.source,
|
|
118
|
+
requestId: `${job.id}:${id}:${headSha}`,
|
|
119
|
+
jobId: job.id,
|
|
120
|
+
prNumber: job.prNumber,
|
|
121
|
+
reviewHeadSha: headSha,
|
|
122
|
+
projectId: reviewerRouting.candidate.routing.projectId ?? null,
|
|
123
|
+
} : undefined;
|
|
124
|
+
const input = [
|
|
125
|
+
`Job ID: ${job.id}`,
|
|
126
|
+
`Review PR #${job.prNumber} at exactly head SHA ${headSha}.`,
|
|
127
|
+
`Review head SHA: ${headSha}`,
|
|
128
|
+
'Job objective: ' + (job.objective ?? job.description ?? job.title),
|
|
129
|
+
'Use the selected reviewer agent contract as the authoritative review protocol.',
|
|
130
|
+
'This is a PRE-MERGE artifact approval gate: inspect exact PR head, requirements, tests and genuine blocking defects.',
|
|
131
|
+
'Do not block approval because automatic merge, Job COMPLETED, or post-merge E2E evidence has not occurred yet.',
|
|
132
|
+
'Do not treat historical failed attempts as current artifact defects; distinguish preserved baseline files from implementation.',
|
|
133
|
+
].join('\n\n');
|
|
112
134
|
const task = createTaskEntity(job, {
|
|
113
135
|
jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer',
|
|
114
136
|
parentTaskId: null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
|
|
115
137
|
workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter,
|
|
116
|
-
input
|
|
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.',
|
|
138
|
+
input,
|
|
121
139
|
output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null,
|
|
140
|
+
chatgptThreadDelivery,
|
|
122
141
|
reviewHeadSha: headSha, attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2,
|
|
123
142
|
leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null,
|
|
124
143
|
error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now
|
|
@@ -149,7 +168,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
149
168
|
const ready = schedule(await this.store.load(job.id));
|
|
150
169
|
await this.store.saveJob(ready);
|
|
151
170
|
}
|
|
152
|
-
async selectReviewer(implementationTasks) {
|
|
171
|
+
async selectReviewer(implementationTasks, requirementsOverride) {
|
|
153
172
|
const configured = this.options.reviewer;
|
|
154
173
|
const registered = await this.store.listAgents();
|
|
155
174
|
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
@@ -185,7 +204,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
185
204
|
},
|
|
186
205
|
};
|
|
187
206
|
const runtimeSummary = this.options.runtimeSummaryProvider?.();
|
|
188
|
-
const routed = await routeExecution(workers, configured?.requirements ?? { capabilities: ['model'] }, {
|
|
207
|
+
const routed = await routeExecution(workers, requirementsOverride ?? configured?.requirements ?? { capabilities: ['model'] }, {
|
|
189
208
|
decisionEngine: this.options.decisionEngine,
|
|
190
209
|
decisionMode: this.options.decisionMode,
|
|
191
210
|
taskFacts,
|
package/dist/reconciler.js
CHANGED
|
@@ -7,17 +7,19 @@ import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
|
7
7
|
import { eventFor } from './events.js';
|
|
8
8
|
import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
|
|
9
9
|
import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
|
|
10
|
-
import {
|
|
11
|
-
import { agentGraphPrompt } from './agent-graph.js';
|
|
10
|
+
import { resolveExecutionContext } from './execution-context.js';
|
|
12
11
|
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
12
|
+
import { recordThreadDelivery } from './reviewer-thread.js';
|
|
13
13
|
import { executeManualPlan } from './manual-plan.js';
|
|
14
14
|
import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
|
|
15
15
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
16
16
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
17
17
|
import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
18
|
-
import { prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
18
|
+
import { assertCleanWorktreeAtHead, prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
|
|
19
|
+
import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
|
|
20
|
+
import { jobHasActiveExecution } from './job-execution-slot.js';
|
|
21
|
+
import { managedWorkerInput } from './managed-worker-input.js';
|
|
22
|
+
export { managedWorkerInput } from './managed-worker-input.js';
|
|
21
23
|
import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
|
|
22
24
|
import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
|
|
23
25
|
import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
|
|
@@ -25,63 +27,6 @@ import { launchPlanExecution } from './planner-runtime.js';
|
|
|
25
27
|
import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
|
|
26
28
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
27
29
|
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); }
|
|
28
|
-
export async function managedWorkerInput(job, task, context) {
|
|
29
|
-
if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
|
|
30
|
-
return task.input;
|
|
31
|
-
const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
|
|
32
|
-
const selectedGraph = task.agentGraph && context ? await agentGraphPrompt(context, task.agentGraph) : '';
|
|
33
|
-
const chairPlan = task.review?.role === 'chair' && context
|
|
34
|
-
? createReviewChairExecutionPlan(context.agents, task.review.target)
|
|
35
|
-
: null;
|
|
36
|
-
if (!task.output)
|
|
37
|
-
throw new Error(`Task ${task.id} requires an explicit output contract`);
|
|
38
|
-
if (!task.executionId)
|
|
39
|
-
throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
|
|
40
|
-
const sourceId = executionEventSource(job.id, task.id, task.executionId);
|
|
41
|
-
const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
|
|
42
|
-
const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
|
|
43
|
-
const output = task.output.kind === 'file'
|
|
44
|
-
? `file\nPath: ${task.output.path}`
|
|
45
|
-
: `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
|
|
46
|
-
const taskTag = taskTagWorkerPrompt(job);
|
|
47
|
-
return `[Agents Relay managed task]
|
|
48
|
-
Job URL: ${prUrl}
|
|
49
|
-
Job ID: ${job.id}
|
|
50
|
-
Task URL: ${taskUrl}
|
|
51
|
-
Task ID: ${task.id}
|
|
52
|
-
Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
|
|
53
|
-
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.
|
|
54
|
-
${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.
|
|
55
|
-
[Output contract]
|
|
56
|
-
${output}
|
|
57
|
-
${taskTag}[Execution event contract — mandatory]
|
|
58
|
-
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
59
|
-
Your exact event source identity is: ${sourceId}
|
|
60
|
-
Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
|
|
61
|
-
Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
|
|
62
|
-
publish through the federated MCP tool events__publish; do not open NATS directly.
|
|
63
|
-
For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
|
|
64
|
-
As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
|
|
65
|
-
you MUST publish exactly one canonical task.started event for this exact Job/Task.
|
|
66
|
-
|
|
67
|
-
${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.`}
|
|
68
|
-
Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
|
|
69
|
-
- task.completed only after the declared output is durable;
|
|
70
|
-
- task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
|
|
71
|
-
- task.blocked only when genuine human/external action is required;
|
|
72
|
-
- task.cancelled only when execution was cancelled.
|
|
73
|
-
|
|
74
|
-
If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
|
|
75
|
-
Never exit after task.started without publishing exactly one terminal task event.
|
|
76
|
-
Do not publish duplicate terminal task events.
|
|
77
|
-
The worker owns task.started and task terminal events. Relay-owned task.process.launched, task.process.exited,
|
|
78
|
-
and task.process.async_exited are process lifecycle only and NEVER replace the worker task lifecycle.
|
|
79
|
-
The ChatGPT/Codex conversation or process exit is never the task result.
|
|
80
|
-
|
|
81
|
-
[Final event gate]
|
|
82
|
-
Before your final return, verify that a terminal event for this exact Job/Task was published.
|
|
83
|
-
If no terminal event has been published, publish task.failed before exiting.`;
|
|
84
|
-
}
|
|
85
30
|
export class Reconciler extends ReconcilerReviewBase {
|
|
86
31
|
constructor(store, options) {
|
|
87
32
|
super(store, options);
|
|
@@ -94,6 +39,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
94
39
|
await this.launch(job, task);
|
|
95
40
|
}, context => `effect.launchTask:${context.jobId}:${context.taskId ?? 'task'}:${String(context.data?.attempt ?? 0)}`));
|
|
96
41
|
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cancelActiveTasks', async (context) => this.cancelActiveTasks(context.jobId, String(context.data?.reason ?? 'Job terminated'))));
|
|
42
|
+
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cleanupJobWorktree', async (context) => cleanupMergedJobWorktree(await this.store.load(context.jobId), this.options.projectPathForJob), context => `effect.cleanupJobWorktree:${context.jobId}`));
|
|
97
43
|
this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.mergePullRequest', async (context) => {
|
|
98
44
|
const headSha = typeof context.data?.headSha === 'string' ? context.data.headSha : null;
|
|
99
45
|
if (!headSha || !this.store.mergePullRequest)
|
|
@@ -107,14 +53,14 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
107
53
|
this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
|
|
108
54
|
}
|
|
109
55
|
const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
|
|
110
|
-
const dispatchable = scheduled.tasks
|
|
56
|
+
const dispatchable = (jobHasActiveExecution(scheduled) ? [] : scheduled.tasks)
|
|
111
57
|
.filter(task => task.state === 'QUEUED'
|
|
112
58
|
&& task.executionId === null
|
|
113
59
|
&& task.kind !== 'planner'
|
|
114
60
|
&& task.adapter !== 'orchestrator'
|
|
115
61
|
&& dependencyReady(task, new Map(scheduled.tasks.map(item => [item.id, item]))))
|
|
116
62
|
.sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2'))
|
|
117
|
-
.slice(0, capacity);
|
|
63
|
+
.slice(0, Math.min(capacity, 1));
|
|
118
64
|
for (const task of dispatchable) {
|
|
119
65
|
await transitionTaskEvent(task, 'dispatch', { dispatchable: true }, async (actionId) => {
|
|
120
66
|
const outcomes = await this.workflowEffects.execute([actionId], { jobId: scheduled.id, taskId: task.id, transitionKey: `dispatch:${task.attempt}`, data: { attempt: task.attempt } });
|
|
@@ -381,13 +327,15 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
381
327
|
await this.store.saveTask(task);
|
|
382
328
|
return;
|
|
383
329
|
}
|
|
384
|
-
const claimedTask = await withExecutionBoundary(job.id,
|
|
330
|
+
const claimedTask = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
|
|
385
331
|
const freshJob = await this.store.load(job.id);
|
|
386
332
|
let freshTask = freshJob.tasks.find(item => item.id === task.id);
|
|
387
333
|
if (!freshTask || freshTask.state !== 'QUEUED' || !dependencyReady(freshTask, new Map(freshJob.tasks.map(item => [item.id, item]))))
|
|
388
334
|
return;
|
|
389
335
|
if (freshTask.executionId !== null)
|
|
390
336
|
return;
|
|
337
|
+
if (jobHasActiveExecution(freshJob, { taskId: freshTask.id }))
|
|
338
|
+
return;
|
|
391
339
|
if (freshTask.agentName === 'reviewer' && freshTask.planningDecisionId && freshTask.dependencies.length)
|
|
392
340
|
await bindPlannedReviewHead(this.store, freshTask);
|
|
393
341
|
if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
|
|
@@ -429,6 +377,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
429
377
|
if (!managedHead)
|
|
430
378
|
throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
|
|
431
379
|
projectPath = await prepareManagedExecutionWorktree(projectPath, job.repository, job.prNumber, managedHead, task.executionId);
|
|
380
|
+
if (role === 'reviewer')
|
|
381
|
+
await assertCleanWorktreeAtHead(projectPath, managedHead.sha);
|
|
432
382
|
}
|
|
433
383
|
executionContext = await this.resolvedContext(job, task, role, projectPath);
|
|
434
384
|
}
|
|
@@ -456,9 +406,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
456
406
|
const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: executionContext.project.path } : task.routing };
|
|
457
407
|
const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
|
|
458
408
|
const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
|
|
459
|
-
const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
|
|
460
|
-
? this.terminalWait(task.id, durableExecutionId, startedAt - 1)
|
|
461
|
-
: null;
|
|
409
|
+
const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal' ? this.terminalWait(task.id, durableExecutionId, startedAt - 1) : null;
|
|
462
410
|
const launchContext = {
|
|
463
411
|
...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
|
|
464
412
|
...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
|
|
@@ -473,7 +421,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
473
421
|
await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
|
|
474
422
|
return;
|
|
475
423
|
}
|
|
476
|
-
void (execution.launched ?? Promise.resolve()).then(() => this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model, pid: execution.pid ?? null, executionId: durableExecutionId }, { agent: 'relay', component: 'agents-relay' }))).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
|
|
424
|
+
void (execution.launched ?? Promise.resolve()).then(() => Promise.all([recordThreadDelivery(this.store, job.id, task.id, durableExecutionId, workerTask), this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model, pid: execution.pid ?? null, executionId: durableExecutionId }, { agent: 'relay', component: 'agents-relay' }))])).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
|
|
477
425
|
if (startAckSetup)
|
|
478
426
|
void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
|
|
479
427
|
this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: startedAt, workerStarted: false, processSettled: false });
|
package/dist/relayd.js
CHANGED
|
@@ -198,7 +198,7 @@ export async function runDaemon(argv) {
|
|
|
198
198
|
job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
|
|
199
199
|
}));
|
|
200
200
|
}));
|
|
201
|
-
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
201
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
202
202
|
const refreshAll = async (full = false) => {
|
|
203
203
|
if (client.rateLimitStatus().limited)
|
|
204
204
|
return cachedOverviews;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { arg, hasArg } from './cli-support.js';
|
|
2
|
+
export function normalizeThreadId(value, label = 'thread ID') {
|
|
3
|
+
if (value === undefined || value === null)
|
|
4
|
+
return undefined;
|
|
5
|
+
if (typeof value !== 'string')
|
|
6
|
+
throw new Error(`${label} must be a string`);
|
|
7
|
+
const threadId = value.trim();
|
|
8
|
+
if (!threadId)
|
|
9
|
+
throw new Error(`${label} cannot be empty`);
|
|
10
|
+
if (threadId.length > 256)
|
|
11
|
+
throw new Error(`${label} cannot exceed 256 characters`);
|
|
12
|
+
return threadId;
|
|
13
|
+
}
|
|
14
|
+
export function resolveReviewerThreadDestination(job, override) {
|
|
15
|
+
const overridden = override === undefined ? undefined : normalizeThreadId(override, '--thread-id');
|
|
16
|
+
if (overridden)
|
|
17
|
+
return { threadId: overridden, source: 'override' };
|
|
18
|
+
const reviewerThreadId = job.reviewerThreadId ? normalizeThreadId(job.reviewerThreadId, 'Job reviewer thread ID') : undefined;
|
|
19
|
+
if (reviewerThreadId)
|
|
20
|
+
return { threadId: reviewerThreadId, source: 'reviewer' };
|
|
21
|
+
const creatorThreadId = job.creatorThreadId ? normalizeThreadId(job.creatorThreadId, 'Job creator thread ID') : undefined;
|
|
22
|
+
return creatorThreadId ? { threadId: creatorThreadId, source: 'creator' } : null;
|
|
23
|
+
}
|
|
24
|
+
export function chatgptThreadRequirements(configured) {
|
|
25
|
+
const base = configured ?? { capabilities: ['model'] };
|
|
26
|
+
const capabilities = new Set([...base.capabilities, 'model', 'chatgpt', 'browser-workspace']);
|
|
27
|
+
return { ...base, capabilities: [...capabilities], runtime: 'chatgpt' };
|
|
28
|
+
}
|
|
29
|
+
export function runtimeReviewer(args = []) {
|
|
30
|
+
const values = [...args];
|
|
31
|
+
const threadId = arg(values, '--thread-id', '');
|
|
32
|
+
if (hasArg(values, '--thread-id'))
|
|
33
|
+
normalizeThreadId(threadId, '--thread-id');
|
|
34
|
+
return { requirements: { capabilities: ['model'] }, ...(threadId.trim() ? { threadId: threadId.trim() } : {}) };
|
|
35
|
+
}
|
|
36
|
+
export async function recordThreadDelivery(store, jobId, taskId, executionId, workerTask) {
|
|
37
|
+
const delivery = workerTask.chatgptThreadDelivery;
|
|
38
|
+
if (!delivery || delivery.deliveredAt)
|
|
39
|
+
return;
|
|
40
|
+
const current = (await store.load(jobId)).tasks.find(item => item.id === taskId);
|
|
41
|
+
if (current?.executionId !== executionId || current.state !== 'RUNNING' || !current.chatgptThreadDelivery)
|
|
42
|
+
return;
|
|
43
|
+
current.chatgptThreadDelivery = { ...current.chatgptThreadDelivery, deliveredAt: new Date().toISOString() };
|
|
44
|
+
await store.saveTask(current);
|
|
45
|
+
}
|
|
@@ -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
|