agents-relay 2.0.65 → 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 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
- async function runBrowserWorker(cli, configFile, signal) {
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
- return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', 'temporary-submit', '--session-id', sessionId, '--config', configFile], undefined, signal);
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
- await writeFile(configFile, JSON.stringify({
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 output = await runBrowserWorker(cli, configFile, signal);
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
- if (response.operation !== 'submit' || response.status !== 'submitted') {
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, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
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 runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
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);
@@ -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 reviewerRouting = await this.selectReviewer(reviewGateTasks);
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: '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.',
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,
@@ -9,6 +9,7 @@ import { createPlanRound, materializePlannerChild, pendingPlan } from './planner
9
9
  import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
10
10
  import { resolveExecutionContext } from './execution-context.js';
11
11
  import { ReconcilerReviewBase } from './reconciler-review.js';
12
+ import { recordThreadDelivery } from './reviewer-thread.js';
12
13
  import { executeManualPlan } from './manual-plan.js';
13
14
  import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
14
15
  export { executionEventSource } from './reconciler-runtime.js';
@@ -405,9 +406,7 @@ export class Reconciler extends ReconcilerReviewBase {
405
406
  const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: executionContext.project.path } : task.routing };
406
407
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
407
408
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
408
- const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
409
- ? this.terminalWait(task.id, durableExecutionId, startedAt - 1)
410
- : null;
409
+ const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal' ? this.terminalWait(task.id, durableExecutionId, startedAt - 1) : null;
411
410
  const launchContext = {
412
411
  ...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
413
412
  ...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
@@ -422,7 +421,7 @@ export class Reconciler extends ReconcilerReviewBase {
422
421
  await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
423
422
  return;
424
423
  }
425
- 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'));
426
425
  if (startAckSetup)
427
426
  void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
428
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.65",
3
+ "version": "2.0.66",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {