agents-relay 2.0.63 → 2.0.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapters.js CHANGED
@@ -2,7 +2,7 @@ import { readFile, writeFile, unlink } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { randomUUID } from 'node:crypto';
5
- import { spawnOwnedProcess, terminateOwnedProcess } from './owned-process.js';
5
+ import { spawnOwnedProcess, terminateOwnedProcess, ownedProcessTerminationReason } from './owned-process.js';
6
6
  export function buildCodexArgs(route, input) {
7
7
  const args = ['exec', '--json'];
8
8
  if (route.profile)
@@ -82,7 +82,7 @@ export class CodexAdapter {
82
82
  child.on('error', error => { launchedReject(error); reject(error); });
83
83
  child.once('spawn', launchedResolve);
84
84
  child.on('close', (code, signal) => {
85
- execution.processExit = { exitCode: code, signal, stderr: stderrTail };
85
+ execution.processExit = { exitCode: code, signal, stderr: stderrTail, terminationReason: ownedProcessTerminationReason(child) };
86
86
  if (stdoutBuffer)
87
87
  consumeLine(stdoutBuffer);
88
88
  if (code === 0) {
@@ -110,14 +110,34 @@ export class LaunchFailure extends Error {
110
110
  }
111
111
  export class StartAckTimeout extends LaunchFailure {
112
112
  }
113
- function parseDriverJson(output) {
113
+ function findSubmittedReceipt(value) {
114
+ if (typeof value === 'string') {
115
+ try {
116
+ return findSubmittedReceipt(JSON.parse(value));
117
+ }
118
+ catch {
119
+ return undefined;
120
+ }
121
+ }
122
+ if (!value || typeof value !== 'object')
123
+ return undefined;
124
+ const record = value;
125
+ if (record.operation === 'submit' && record.status === 'submitted')
126
+ return record;
127
+ for (const key of ['result', 'output', 'data', 'stdout']) {
128
+ const nested = findSubmittedReceipt(record[key]);
129
+ if (nested)
130
+ return nested;
131
+ }
132
+ return undefined;
133
+ }
134
+ export function parseDriverJson(output) {
114
135
  const lines = output.trim().split(/\r?\n/).filter(Boolean);
115
136
  for (let index = lines.length - 1; index >= 0; index -= 1) {
116
137
  try {
117
- const value = JSON.parse(lines[index]);
118
- if (value.operation !== 'submit' || value.status !== 'submitted')
119
- continue;
120
- return value;
138
+ const receipt = findSubmittedReceipt(JSON.parse(lines[index]));
139
+ if (receipt)
140
+ return receipt;
121
141
  }
122
142
  catch {
123
143
  // Browser-harness diagnostics may precede the final JSON observation.
@@ -180,7 +200,19 @@ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS
180
200
  }
181
201
  await admin.ensureWorkspace(CHATGPT_BROWSER_WORKSPACE, CHATGPT_BROWSER_WORKSPACE_POOL_SIZE);
182
202
  }
183
- function autoSessionBrowserWorkerRunner() {
203
+ async function runBrowserWorker(cli, configFile, signal) {
204
+ const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start'], undefined, signal));
205
+ const sessionId = session.session_id;
206
+ if (typeof sessionId !== 'string' || !sessionId)
207
+ throw new Error('browser-workspace session start returned no session_id');
208
+ try {
209
+ return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', 'temporary-submit', '--session-id', sessionId, '--config', configFile], undefined, signal);
210
+ }
211
+ finally {
212
+ await browserWorkspaceCommand(cli, ['session', 'stop', sessionId]).catch(() => { });
213
+ }
214
+ }
215
+ function browserWorkerRunner() {
184
216
  const cli = browserWorkspaceCli();
185
217
  return (_definition, request, _route, signal) => {
186
218
  const promise = (async () => {
@@ -192,7 +224,7 @@ function autoSessionBrowserWorkerRunner() {
192
224
  }));
193
225
  if (signal.aborted)
194
226
  throw new Error('browser-worker execution aborted');
195
- const output = await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', 'temporary-submit', '--auto-session', '--config', configFile], undefined, signal);
227
+ const output = await runBrowserWorker(cli, configFile, signal);
196
228
  return JSON.stringify(parseDriverJson(output));
197
229
  }
198
230
  finally {
@@ -255,7 +287,7 @@ export class ChatGptAdapter {
255
287
  if (controller.signal.aborted)
256
288
  throw new Error('browser-worker execution aborted');
257
289
  const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
258
- const run = (this.runner ?? autoSessionBrowserWorkerRunner())(definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
290
+ const run = (this.runner ?? browserWorkerRunner())(definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
259
291
  try {
260
292
  const output = await (run.submitted ?? run.promise);
261
293
  const response = parseBrowserWorkerResponse(output);
@@ -486,9 +486,8 @@ const stream=new EventSource('/events');
486
486
  stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
487
487
  stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
488
488
  stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);renderAll();void drawRuntimeSummary();}};
489
- stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
489
+ stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);}else if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
490
490
  if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/graphs')showView('graphs');else if(location.hash==='#/execution-router-rules')showView('rules');else if(location.hash==='#/shortcuts')showView('shortcuts');else showView('jobs');
491
- setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/graphs'&&location.hash!=='#/execution-router-rules'&&location.hash!=='#/shortcuts')void refresh();},300000);
492
491
  setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
493
492
  setInterval(()=>void refreshVersion(),30000);
494
493
  async function refreshSelected(){const job=selected();if(!job){await refresh();return;}selectedJobRefresh.disabled=true;selectedJobRefresh.textContent='Refreshing…';try{const params=new URLSearchParams({id:job.id,repo:job.repository,pr:String(job.prNumber)});const updated=await loadJSON('/api/job?'+params.toString());const index=jobs.findIndex(candidate=>jobSelectionKey(candidate)===jobSelectionKey(job));if(index>=0)jobs[index]=updated;else jobs.push(updated);selectedJob=jobSelectionKey(updated);rememberSelection();renderAll();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';}finally{selectedJobRefresh.disabled=false;selectedJobRefresh.textContent='Refresh';}}
package/dist/dashboard.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createServer } from 'node:http';
2
+ import { subscribeLocalCommits } from './local-commit-events.js';
2
3
  import { readFile, readdir } from 'node:fs/promises';
3
4
  import { join } from 'node:path';
4
5
  import { fileURLToPath } from 'node:url';
@@ -99,17 +100,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
99
100
  subscribedJobs.add(job.id);
100
101
  try {
101
102
  await bus.subscribe(job.id, async (event) => {
102
- const durable = await store.load(job.id).catch(() => null);
103
- const task = durable?.tasks.find(candidate => candidate.id === event.task_id) ?? null;
104
- const statePatch = durable ? {
105
- jobId: durable.id,
106
- jobState: durable.state,
107
- jobUpdatedAt: durable.updatedAt,
108
- taskId: task?.id ?? null,
109
- taskState: task?.state ?? null,
110
- taskUpdatedAt: task?.updatedAt ?? null,
111
- } : null;
112
- const payload = JSON.stringify({ event, statePatch });
103
+ const payload = JSON.stringify({ event });
113
104
  for (const client of clients)
114
105
  client.write(`event: relay\ndata: ${payload}\n\n`);
115
106
  });
@@ -119,6 +110,16 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
119
110
  }
120
111
  }
121
112
  };
113
+ const unsubscribeLocalCommits = subscribeLocalCommits(durable => {
114
+ const known = lastKnownJobs?.find(job => job.id === durable.id && job.repository === durable.repository && job.prNumber === durable.prNumber);
115
+ const githubState = known?.githubState;
116
+ const current = { ...durable, githubState, runtimeStatus: deriveJobRuntimeStatus(durable, githubState) };
117
+ if (lastKnownJobs && known)
118
+ Object.assign(known, current);
119
+ const payload = JSON.stringify({ job: current });
120
+ for (const client of clients)
121
+ client.write(`data: ${payload}\n\n`);
122
+ });
122
123
  const snapshot = async (requestedJobId = jobId, forceRefresh = false, target) => {
123
124
  await subscribeDiscovered();
124
125
  if (overviewProvider) {
@@ -563,15 +564,6 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
563
564
  res.writeHead(404);
564
565
  res.end('Not found');
565
566
  });
566
- const interval = setInterval(() => {
567
- if (clients.size === 0)
568
- return;
569
- void snapshot().then(value => {
570
- for (const client of clients)
571
- client.write(`data: ${value.json}\n\n`);
572
- }).catch(() => undefined);
573
- }, Math.max(10000, refreshMs));
574
- interval.unref();
575
567
  const heartbeat = setInterval(() => {
576
568
  if (clients.size === 0)
577
569
  return;
@@ -579,7 +571,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
579
571
  client.write(': heartbeat\n\n');
580
572
  }, SSE_HEARTBEAT_MS);
581
573
  heartbeat.unref();
582
- server.on('close', () => { clearInterval(interval); clearInterval(heartbeat); clients.clear(); });
574
+ server.on('close', () => { unsubscribeLocalCommits(); clearInterval(heartbeat); clients.clear(); });
583
575
  server.listen(port, '127.0.0.1');
584
576
  return server;
585
577
  }
@@ -0,0 +1,23 @@
1
+ const executionBoundaryLocks = new Map();
2
+ /**
3
+ * Serialize the durable execution-generation boundary across reconciler
4
+ * instances in one relay process. Launch, routing, and terminal persistence
5
+ * must not observe and write the same queued generation concurrently.
6
+ */
7
+ export async function withExecutionBoundary(jobId, taskId, work) {
8
+ const key = `${jobId}/${taskId}`;
9
+ const previous = executionBoundaryLocks.get(key);
10
+ let release;
11
+ const current = new Promise(resolve => { release = resolve; });
12
+ executionBoundaryLocks.set(key, current);
13
+ if (previous)
14
+ await previous;
15
+ try {
16
+ return await work();
17
+ }
18
+ finally {
19
+ release();
20
+ if (executionBoundaryLocks.get(key) === current)
21
+ executionBoundaryLocks.delete(key);
22
+ }
23
+ }
@@ -142,27 +142,33 @@ export class GhApiClient {
142
142
  if (rateLimited)
143
143
  this.assertAvailable();
144
144
  const commandArgs = includeHeaders ? [args[0] ?? '', '--include', ...args.slice(1)] : args;
145
- return await new Promise((resolve) => {
146
- const child = spawnOwnedProcess('gh', commandArgs, { invocation: 'github.api', spawn: { env } });
147
- let stdout = '';
148
- let stderr = '';
149
- child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
150
- child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
151
- child.on('error', error => resolve({ status: null, headers: {}, body: '', stderr: error.message }));
152
- if (input !== undefined)
153
- child.stdin?.write(input);
154
- child.stdin?.end();
155
- child.on('close', code => {
156
- const response = parseGitHubCliHeaders(stdout, includeHeaders);
157
- const failed = code !== 0;
158
- const message = failed ? stderr.trim() || response.body.trim() || `gh exited ${code}` : '';
159
- if (failed && rateLimited)
160
- this.noteFailure(message);
161
- if (!failed && rateLimited)
162
- this.noteSuccess();
163
- resolve({ ...response, stderr: failed ? message : undefined });
145
+ for (let attempt = 0; attempt < 2; attempt += 1) {
146
+ const result = await new Promise((resolve) => {
147
+ const child = spawnOwnedProcess('gh', commandArgs, { invocation: 'github.api', spawn: { env } });
148
+ let stdout = '';
149
+ let stderr = '';
150
+ child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
151
+ child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
152
+ child.on('error', error => resolve({ status: null, headers: {}, body: '', stderr: error.message }));
153
+ if (input !== undefined)
154
+ child.stdin?.write(input);
155
+ child.stdin?.end();
156
+ child.on('close', (code, signal) => {
157
+ const response = parseGitHubCliHeaders(stdout, includeHeaders);
158
+ const failed = code !== 0;
159
+ const message = failed ? stderr.trim() || response.body.trim() || `gh exited ${code}; signal=${signal ?? "none"}` : '';
160
+ resolve({ ...response, stderr: failed ? message : undefined, signal });
161
+ });
164
162
  });
165
- });
163
+ if (result.signal && attempt === 0)
164
+ continue;
165
+ if (result.stderr && rateLimited)
166
+ this.noteFailure(result.stderr);
167
+ if (!result.stderr && rateLimited)
168
+ this.noteSuccess();
169
+ return result;
170
+ }
171
+ throw new Error('GitHub CLI retry exhausted');
166
172
  }
167
173
  async spawnGh(args, input, env = process.env, rateLimited = true) {
168
174
  return (await this.runGh(args, input, env, rateLimited, false)).body;
@@ -0,0 +1,10 @@
1
+ const listeners = new Set();
2
+ /** Notify dashboard listeners only after authoritative SQLite state is committed. */
3
+ export function publishLocalCommit(job) {
4
+ for (const listener of listeners)
5
+ listener(structuredClone(job));
6
+ }
7
+ export function subscribeLocalCommits(listener) {
8
+ listeners.add(listener);
9
+ return () => { listeners.delete(listener); };
10
+ }
@@ -4,6 +4,9 @@ import { basename } from 'node:path';
4
4
  import { observability } from './observability.js';
5
5
  const owned = new Map();
6
6
  const byChild = new WeakMap();
7
+ export function ownedProcessTerminationReason(child) {
8
+ return byChild.get(child)?.terminationReason ?? null;
9
+ }
7
10
  let exitHookInstalled = false;
8
11
  function audit(record, event, outcome, data) {
9
12
  observability().record({
@@ -113,7 +116,7 @@ export function spawnOwnedProcess(command, args, options) {
113
116
  record.timeoutTimer.unref();
114
117
  }
115
118
  child.once('exit', (code, signal) => {
116
- audit(record, 'process.exited', code === 0 ? 'succeeded' : 'failed', { exitStatus: code, signal });
119
+ audit(record, 'process.exited', code === 0 ? 'succeeded' : 'failed', { exitStatus: code, signal, terminationReason: record.terminationReason ?? null });
117
120
  // `close` waits for inherited stdio. End any descendants left behind by a
118
121
  // wrapper before they can keep the caller alive indefinitely.
119
122
  if (!record.terminating)
@@ -143,6 +146,7 @@ export async function terminateOwnedProcess(child, reason, graceMs) {
143
146
  return;
144
147
  if (!record.terminating) {
145
148
  record.terminating = true;
149
+ record.terminationReason = reason;
146
150
  const outcome = signalOwned(record, 'SIGTERM');
147
151
  audit(record, 'process.signal', outcome === 'error' ? 'failed' : 'succeeded', { reason, signal: 'SIGTERM', delivered: outcome === 'sent', alreadyGone: outcome === 'gone' });
148
152
  record.forceTimer = setTimeout(() => forceOwned(record, reason), graceMs ?? record.graceMs);
@@ -0,0 +1,17 @@
1
+ /** A planner-created dependent review must freeze its head only after implementation completes. */
2
+ export async function bindPlannedReviewHead(store, task) {
3
+ const current = await store.pullRequestHead?.();
4
+ if (!current?.sha)
5
+ throw new Error(`Review ${task.id} requires current PR head SHA`);
6
+ task.reviewHeadSha = current.sha;
7
+ const rule = [
8
+ 'Review the current immutable PR head as a PRE-MERGE approval gate.',
9
+ 'Distinguish code added by this implementation from pre-existing PR baseline/seed files present before the Task started.',
10
+ 'Preserved baseline files are not automatically extra changes merely because they differ from main; still review them for real risks.',
11
+ 'Do not require PR MERGED, Job COMPLETED, or post-merge E2E evidence before review approval:',
12
+ 'those events must follow approval and are verified by the independent E2E runner.',
13
+ ].join(' ');
14
+ if (!task.input.includes(rule))
15
+ task.input += '\n\n' + rule;
16
+ await store.saveTask(task);
17
+ }
package/dist/planner.js CHANGED
@@ -100,7 +100,7 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
100
100
  const source = 'round' in planner ? planner.source : planner.planningSource ?? 'model-planner';
101
101
  const round = 'round' in planner ? planner.round : planner.planningRound ?? 1;
102
102
  const plannerHeadSha = 'round' in planner ? planner.headSha : undefined;
103
- const reviewHeadSha = spec.agentName === 'reviewer' ? plannerHeadSha ?? job.headSha : undefined;
103
+ const reviewHeadSha = spec.agentName === 'reviewer' && !(spec.dependencies?.length) ? plannerHeadSha ?? job.headSha : undefined;
104
104
  return createTaskEntity(job, { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha });
105
105
  }
106
106
  export function planningSpec(task) {
package/dist/pool.js CHANGED
@@ -263,7 +263,7 @@ export class RepositoryWorkerPool {
263
263
  if (routedWebhook)
264
264
  return;
265
265
  const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.unblocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
266
- const wake = event.type === 'job.wake' || event.type === 'github.webhook';
266
+ const wake = event.type === 'job.wake' || event.type === 'github.webhook' || event.type === 'review.created';
267
267
  if (!lifecycle && !wake)
268
268
  return;
269
269
  if (repository && prNumber)
@@ -1,3 +1,4 @@
1
+ import { publishLocalCommit } from './local-commit-events.js';
1
2
  import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
2
3
  import { DurableDefinitionConflictError, isDurableDefinitionConflict, sameTaskDefinition } from './store-task-definition.js';
3
4
  import { decideProjectionFailure } from './workflow/projection-conflict-v2.js';
@@ -221,6 +222,7 @@ export class ProjectedDurableStore {
221
222
  payload: { operation: 'save_job', value },
222
223
  priority: ['COMPLETED', 'CANCELLED'].includes(job.state) ? 100 : 20,
223
224
  });
225
+ publishLocalCommit(job);
224
226
  observability().record({
225
227
  stage: 'store', event: 'store.commit', outcome: 'succeeded',
226
228
  message: `Job ${job.id} committed`,
@@ -243,6 +245,7 @@ export class ProjectedDurableStore {
243
245
  payload: { operation: 'append_plan', value: plan },
244
246
  priority: 30,
245
247
  });
248
+ publishLocalCommit(job);
246
249
  }
247
250
  async savePlan(plan) {
248
251
  const job = await this.load(plan.jobId);
@@ -260,6 +263,7 @@ export class ProjectedDurableStore {
260
263
  payload: { operation: 'save_plan', value: plan },
261
264
  priority: 30,
262
265
  });
266
+ publishLocalCommit(job);
263
267
  }
264
268
  async appendTask(task) {
265
269
  if (!task.jobId)
@@ -278,6 +282,7 @@ export class ProjectedDurableStore {
278
282
  payload: { operation: 'append_task', value: task },
279
283
  priority: 40,
280
284
  });
285
+ publishLocalCommit(job);
281
286
  observability().record({
282
287
  stage: 'store', event: 'store.commit', outcome: 'succeeded',
283
288
  message: `Task ${task.id} appended`,
@@ -305,6 +310,7 @@ export class ProjectedDurableStore {
305
310
  payload: { operation: 'save_task', value },
306
311
  priority: terminal ? 100 : 50,
307
312
  });
313
+ publishLocalCommit(job);
308
314
  observability().record({
309
315
  stage: 'store', event: 'store.commit', outcome: 'succeeded',
310
316
  message: `Task ${task.id} committed (${task.state})`,
@@ -113,7 +113,11 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
113
113
  jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer',
114
114
  parentTaskId: null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'],
115
115
  workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter,
116
- input: 'Review existing PR #' + job.prNumber + ' at exactly head SHA ' + headSha + '. Job objective: ' + (job.objective ?? job.description ?? job.title) + '. Use the selected reviewer agent contract as the authoritative review protocol.',
116
+ input: 'Review existing PR #' + job.prNumber + ' at exactly head SHA ' + headSha + '. Job objective: ' + (job.objective ?? job.description ?? job.title) + '. Use the selected reviewer agent contract as the authoritative review protocol. ' +
117
+ 'This is a PRE-MERGE artifact approval gate: inspect the exact PR head, requested changes, applicable source/test requirements, and genuine blocking defects in the proposed changes. ' +
118
+ 'Do not block approval because automatic merge, Job COMPLETED, or post-merge E2E evidence has not occurred yet; those are downstream effects of review approval and independently verified later. ' +
119
+ 'Do not treat historical failed attempts or launch anomalies as current artifact defects if the latest implementation and tests succeeded; report historical lifecycle observations separately. ' +
120
+ 'Compare implementation changes with the PR head that existed before the implementation, not only against main: preserved seed or baseline files are not automatically unrelated additions.',
117
121
  output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null,
118
122
  reviewHeadSha: headSha, attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2,
119
123
  leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null,
@@ -267,6 +271,12 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
267
271
  const currentHeadSha = await this.store.headSha?.();
268
272
  if ((currentHeadSha && currentHeadSha !== headSha) || (job.headSha && job.headSha !== headSha))
269
273
  return { applied: false, reconcile: false, reason: event.type + '.stale' };
274
+ // Planner-created reviewer tasks predate the final-review Job gate. Bind
275
+ // their first trustworthy review event to PENDING before dispatching it.
276
+ if (!job.review && task.planningDecisionId) {
277
+ job.review = { outcome: 'pending', headSha, taskId: task.id, evidence: null, updatedAt: event.timestamp || new Date().toISOString() };
278
+ await this.store.saveJob(job);
279
+ }
270
280
  const taskDecision = tryTransitionTaskEvent(task, event.type);
271
281
  if (!taskDecision.accepted || !taskDecision.target)
272
282
  return { applied: false, reconcile: false, reason: event.type + '.ignored' };
@@ -17,29 +17,8 @@ function executionIdFromSource(sourceId, jobId, taskId) {
17
17
  }
18
18
  const terminalExecutionClaims = new Set();
19
19
  const activeExecutionOwners = new Map();
20
- const executionBoundaryLocks = new Map();
21
- /**
22
- * Serialize the durable execution-generation boundary across reconciler
23
- * instances in one relay process. Launch, routing, and terminal persistence
24
- * must not observe and write the same queued generation concurrently.
25
- */
26
- export async function withExecutionBoundary(jobId, taskId, work) {
27
- const key = `${jobId}/${taskId}`;
28
- const previous = executionBoundaryLocks.get(key);
29
- let release;
30
- const current = new Promise(resolve => { release = resolve; });
31
- executionBoundaryLocks.set(key, current);
32
- if (previous)
33
- await previous;
34
- try {
35
- return await work();
36
- }
37
- finally {
38
- release();
39
- if (executionBoundaryLocks.get(key) === current)
40
- executionBoundaryLocks.delete(key);
41
- }
42
- }
20
+ import { withExecutionBoundary } from './execution-boundary.js';
21
+ export { withExecutionBoundary } from './execution-boundary.js';
43
22
  function claimTerminalExecution(sourceId) {
44
23
  terminalExecutionClaims.add(sourceId);
45
24
  const cleanup = setTimeout(() => terminalExecutionClaims.delete(sourceId), 60000);
@@ -302,12 +281,12 @@ export class ReconcilerRuntimeBase {
302
281
  }
303
282
  const job = await this.store.load(jobId);
304
283
  const task = job.tasks.find(item => item.id === taskId);
305
- if (!task || (task.executionId !== executionId && !terminalExecutionClaimed(jobId, taskId, executionId)))
284
+ if (terminalExecutionClaimed(jobId, taskId, executionId) || !task || task.executionId !== executionId)
306
285
  return;
307
286
  if (task.adapter !== 'chatgpt' && task.executionId === executionId && live?.executionId === executionId)
308
287
  rearmTerminalEventDeadline(this.terminalTimers, taskId, task.timeoutMs - (Date.now() - live.launchBarrier), this.options.postExitTerminalGraceMs ?? 15_000, () => this.detach(this.timeoutTask(jobId, taskId, executionId), jobId, taskId, 'post-exit terminal event timeout'));
309
- await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', live?.execution.processExit ? (live.execution.processExit.exitCode === 0 ? 'succeeded' : 'failed') : (error ? 'failed' : 'succeeded'), result?.summary ?? (error ? 'Worker runtime exited with error' : 'Worker runtime exited'), 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, exitCode: live?.execution.processExit?.exitCode ?? null,
310
- signal: live?.execution.processExit?.signal ?? null, stderr: live?.execution.processExit?.stderr ?? '', processError: error, executionId, pid: live?.execution.pid ?? null }, { id: executionEventSource(jobId, taskId, executionId), agent: 'relay', component: 'agents-relay' }));
288
+ await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', live?.execution.processExit ? (live.execution.processExit.exitCode === 0 ? 'succeeded' : 'failed') : (error ? 'failed' : 'succeeded'), result?.summary ?? (live?.execution.processExit?.terminationReason === 'parent-shutdown' ? 'Worker interrupted by daemon shutdown' : error ? 'Worker runtime exited with error' : 'Worker runtime exited'), 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, exitCode: live?.execution.processExit?.exitCode ?? null,
289
+ signal: live?.execution.processExit?.signal ?? null, terminationReason: live?.execution.processExit?.terminationReason ?? null, stderr: live?.execution.processExit?.stderr ?? '', processError: error, executionId, pid: live?.execution.pid ?? null }, { id: executionEventSource(jobId, taskId, executionId), agent: 'relay', component: 'agents-relay' }));
311
290
  }
312
291
  async timeoutTask(jobId, taskId, executionId) {
313
292
  const live = this.live.get(taskId);
@@ -363,9 +342,8 @@ export class ReconcilerRuntimeBase {
363
342
  const prior = eventExecutionId ? task.executionHistory?.find(item => item.id === eventExecutionId) : undefined;
364
343
  // A duplicate launch can leave one worker orphaned after another worker
365
344
  // has already produced the terminal fact. Accept only a known execution
366
- // from the same durable attempt and only when its fact is newer than its
367
- // launch. A prior retry attempt cannot complete a newer generation.
368
- if (!prior || prior.attempt !== task.attempt || !event.timestamp || new Date(event.timestamp).getTime() < new Date(prior.startedAt).getTime())
345
+ // from the same durable attempt. A prior retry attempt cannot complete a newer generation.
346
+ if (!prior || prior.attempt !== task.attempt)
369
347
  return false;
370
348
  }
371
349
  const terminalWaiter = this.terminalWaiters.get(task.id);
@@ -373,9 +351,7 @@ export class ReconcilerRuntimeBase {
373
351
  },
374
352
  facts: (_job, task) => {
375
353
  const processExitedAfterPublishedTerminal = task.failureReason === 'worker_exit_without_terminal_event';
376
- const freshEnough = processExitedAfterPublishedTerminal
377
- || !event.timestamp
378
- || new Date(event.timestamp).getTime() >= new Date(task.updatedAt).getTime();
354
+ const freshEnough = processExitedAfterPublishedTerminal || this.isCorrelatedWorkerEvent(task, event);
379
355
  return { recoverableExecution: this.isRecoverableWatchdogFailure(task) && freshEnough };
380
356
  },
381
357
  mutate: (job, task) => {
@@ -1,3 +1,4 @@
1
+ import { bindPlannedReviewHead } from './planned-review-head.js';
1
2
  import { randomUUID } from 'node:crypto';
2
3
  import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
3
4
  import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
@@ -387,6 +388,8 @@ export class Reconciler extends ReconcilerReviewBase {
387
388
  return;
388
389
  if (freshTask.executionId !== null)
389
390
  return;
391
+ if (freshTask.agentName === 'reviewer' && freshTask.planningDecisionId && freshTask.dependencies.length)
392
+ await bindPlannedReviewHead(this.store, freshTask);
390
393
  if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
391
394
  freshTask = (await this.routeTaskForExecution(freshJob, freshTask)).task;
392
395
  }
@@ -32,13 +32,16 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
32
32
  return 'WAITING';
33
33
  return 'WAITING';
34
34
  }
35
- const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer');
36
- const reviewActive = reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state))
37
- || job.review?.outcome === 'pending'
38
- || job.review?.outcome === 'started';
35
+ // Draft or queued reviewers must not mask implementation that is still active.
36
+ const implementation = implementationTasks(job);
37
+ if (implementation.some(task => task.state === 'RUNNING'))
38
+ return 'RUNNING';
39
+ if (implementation.some(task => task.state === 'QUEUED'))
40
+ return 'QUEUED';
41
+ const reviewRunning = job.tasks.some(task => task.agentName === 'reviewer' && task.state === 'RUNNING');
39
42
  if (job.review?.outcome === 'approved' && prState === 'OPEN')
40
43
  return 'MERGE_PENDING';
41
- if (reviewActive || (job.reviewEnabled !== false && implementationComplete(job) && job.review?.outcome !== 'approved'))
44
+ if (reviewRunning)
42
45
  return 'REVIEWING';
43
46
  if (job.tasks.some(task => task.state === 'RUNNING'))
44
47
  return 'RUNNING';
package/dist/scheduler.js CHANGED
@@ -9,7 +9,10 @@ export function completionGateSatisfied(job) {
9
9
  return false;
10
10
  if (job.executionMode !== 'autonomous')
11
11
  return true;
12
- const planSatisfied = (job.plans ?? []).some(plan => plan.status === 'SUCCEEDED' && plan.objectiveStatus === 'satisfied');
12
+ const latest = [...(job.plans ?? [])].sort((a, b) => a.round - b.round).at(-1);
13
+ const previousReviewFinished = job.tasks.some(task => task.agentName === 'reviewer' && task.state === 'SUCCEEDED');
14
+ const planSatisfied = latest?.status === 'SUCCEEDED' && (latest.objectiveStatus === 'satisfied'
15
+ || (latest.objectiveStatus === 'in_progress' && latest.plannerResult?.next_tasks?.length === 0 && previousReviewFinished));
13
16
  const legacySatisfied = (job.plans?.length ?? 0) === 0 && job.tasks.some(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied');
14
17
  return (planSatisfied || legacySatisfied) && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
15
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.63",
3
+ "version": "2.0.64",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {