agents-relay 2.0.94 → 2.0.96

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
@@ -228,63 +228,12 @@ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS
228
228
  }
229
229
  await admin.ensureWorkspace(CHATGPT_BROWSER_WORKSPACE, CHATGPT_BROWSER_WORKSPACE_POOL_SIZE);
230
230
  }
231
- /**
232
- * The upstream new-turn action accepts idempotency_key but does not enforce it.
233
- * Inspect the current thread before resubmitting after an ambiguous exit.
234
- * If the transcript is not observable, fail closed rather than duplicate.
235
- */
236
- async function findExistingReviewTurn(cli, sessionId, delivery, signal) {
237
- const pathname = delivery.projectId
238
- ? `/g/${encodeURIComponent(delivery.projectId)}/c/${encodeURIComponent(delivery.threadId)}`
239
- : `/c/${encodeURIComponent(delivery.threadId)}`;
240
- const marker = `Relay delivery ID: ${delivery.requestId}`;
241
- const inspectJs = `(() => {
242
- const legacy = [...document.querySelectorAll('[data-message-author-role="user"]')]
243
- .map(e => ({text: (e.innerText || '').trim(), id: e.getAttribute('data-message-id') || ''})).filter(x => x.text);
244
- if (legacy.length) return legacy;
245
- return [...document.querySelectorAll('h4')].filter(e => (e.innerText || '').trim() === 'You said:')
246
- .map((e,i) => ({text: (e.parentElement?.innerText || '').trim(), id: 'dom-user-' + i})).filter(x => x.text);
247
- })()`;
248
- const script = [
249
- 'import json, time',
250
- 'from urllib.parse import urlsplit',
251
- `url = ${JSON.stringify(`https://chatgpt.com${pathname}`)}`,
252
- `marker = ${JSON.stringify(marker)}`,
253
- 'goto_url(url)',
254
- 'wait_for_load()',
255
- 'if urlsplit(page_info().get("url", "")).path.rstrip("/") != urlsplit(url).path.rstrip("/"): raise RuntimeError("review thread redirect mismatch")',
256
- 'turns = []',
257
- 'for attempt in range(30):',
258
- ` turns = js(${JSON.stringify(inspectJs)}) or []`,
259
- ' if turns: break',
260
- ' time.sleep(0.5)',
261
- 'if not turns: raise RuntimeError("review thread transcript unavailable; refusing ambiguous resubmission")',
262
- 'matches = [turn for turn in turns if marker in turn.get("text", "")]',
263
- 'if len(matches) > 1: raise RuntimeError("duplicate review delivery detected; manual reconciliation required")',
264
- 'print(json.dumps({"found": bool(matches), "user_message_id": matches[0].get("id") if matches else None}), flush=True)',
265
- ].join('\n');
266
- const envelope = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'exec', sessionId], script, signal));
267
- if (envelope.ok !== true || typeof envelope.stdout !== 'string')
268
- throw new Error('review thread inspection returned no executable result');
269
- const observation = parseWorkspaceJson(envelope.stdout);
270
- if (typeof observation.found !== 'boolean')
271
- throw new Error('review thread inspection returned no authoritative result');
272
- if (!observation.found)
273
- return null;
274
- return { status: 'submitted', mode: 'new-turn', thread_id: delivery.threadId, project_id: delivery.projectId,
275
- verified_by: 'existing-user-turn', user_message_id: typeof observation.user_message_id === 'string' ? observation.user_message_id : null };
276
- }
277
- async function runBrowserWorker(cli, configFile, signal, action = 'temporary-submit', delivery) {
231
+ async function runBrowserWorker(cli, configFile, signal, action = 'temporary-submit') {
278
232
  const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE], undefined, signal));
279
233
  const sessionId = session.session_id;
280
234
  if (typeof sessionId !== 'string' || !sessionId)
281
235
  throw new Error('browser-workspace session start returned no session_id');
282
236
  try {
283
- if (delivery) {
284
- const existing = await findExistingReviewTurn(cli, sessionId, delivery, signal);
285
- if (existing)
286
- return JSON.stringify(existing);
287
- }
288
237
  return await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', action, '--session-id', sessionId, '--config', configFile], undefined, signal);
289
238
  }
290
239
  finally {
@@ -310,7 +259,7 @@ function browserWorkerRunner() {
310
259
  if (signal.aborted)
311
260
  throw new Error('browser-worker execution aborted');
312
261
  const action = delivery ? 'new-turn' : 'temporary-submit';
313
- const output = await runBrowserWorker(cli, configFile, signal, action, delivery);
262
+ const output = await runBrowserWorker(cli, configFile, signal, action);
314
263
  return JSON.stringify(parseDriverJson(output));
315
264
  }
316
265
  finally {
@@ -111,8 +111,7 @@ export class GitHubProjectionWriter {
111
111
  observedAt: new Date().toISOString(),
112
112
  });
113
113
  }
114
- await this.relay.completeProjection(item.id);
115
- await this.relay.recordProjectionReceipt?.(item.operationKey, 'succeeded', item.attempts, { operation: item.operation });
114
+ await this.relay.completeProjectionWithReceipt(item.id, item.operationKey, 'succeeded', item.attempts, { operation: item.operation });
116
115
  projected += 1;
117
116
  observability().record({
118
117
  stage: 'projection', event: 'projection.completed', outcome: 'succeeded',
@@ -137,8 +136,7 @@ export class GitHubProjectionWriter {
137
136
  }
138
137
  else {
139
138
  const retryAt = githubProjectionRetryAt(error, item.attempts, now);
140
- await this.relay.retryProjection(item.id, message, retryAt);
141
- await this.relay.recordProjectionReceipt?.(item.operationKey, 'failed', item.attempts + 1, { operation: item.operation, error: message });
139
+ await this.relay.retryProjectionWithReceipt(item.id, item.operationKey, message, retryAt, item.attempts + 1, { operation: item.operation, error: message });
142
140
  deferred += 1;
143
141
  observability().record({
144
142
  stage: 'projection', event: 'projection.retry_scheduled', level: 'warn', outcome: 'failed',
@@ -215,6 +215,32 @@ export class ProjectionOutbox {
215
215
  complete(id) {
216
216
  this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
217
217
  }
218
+ completeWithReceipt(id, operationKey, status, attempts, detail, now) {
219
+ this.db.exec('BEGIN IMMEDIATE');
220
+ try {
221
+ this.recordReceipt(operationKey, status, attempts, detail, now);
222
+ this.db.prepare('DELETE FROM projection_outbox WHERE id=?').run(id);
223
+ this.db.exec('COMMIT');
224
+ }
225
+ catch (error) {
226
+ this.db.exec('ROLLBACK');
227
+ throw error;
228
+ }
229
+ }
230
+ retryWithReceipt(id, operationKey, error, nextAttemptAt, attempts, detail, now) {
231
+ this.db.exec('BEGIN IMMEDIATE');
232
+ try {
233
+ this.db.prepare(`UPDATE projection_outbox
234
+ SET status='pending', attempts=?, next_attempt_at=?, last_error=?, updated_at=? WHERE id=?`)
235
+ .run(attempts, nextAttemptAt.toISOString(), error, now, id);
236
+ this.recordReceipt(operationKey, 'failed', attempts, detail, now);
237
+ this.db.exec('COMMIT');
238
+ }
239
+ catch (caught) {
240
+ this.db.exec('ROLLBACK');
241
+ throw caught;
242
+ }
243
+ }
218
244
  retry(id, error, nextAttemptAt) {
219
245
  this.db.prepare(`
220
246
  UPDATE projection_outbox
@@ -372,7 +372,13 @@ export class SQLiteRelayStore {
372
372
  return this.outbox.claimDue(limit, now);
373
373
  }
374
374
  async completeProjection(id) { this.outbox.complete(id); }
375
+ async completeProjectionWithReceipt(id, operationKey, status, attempts, detail) {
376
+ this.outbox.completeWithReceipt(id, operationKey, status, attempts, detail, new Date().toISOString());
377
+ }
375
378
  async retryProjection(id, error, nextAttemptAt) { this.outbox.retry(id, error, nextAttemptAt); }
379
+ async retryProjectionWithReceipt(id, operationKey, error, nextAttemptAt, attempts, detail) {
380
+ this.outbox.retryWithReceipt(id, operationKey, error, nextAttemptAt, attempts, detail, new Date().toISOString());
381
+ }
376
382
  async deadLetterProjection(id, error) { this.outbox.deadLetter(id, error); }
377
383
  async recordProjectionConflict(id, diagnostic) { this.outbox.recordConflict(id, diagnostic); }
378
384
  async listProjectionConflicts() { return this.outbox.listConflicts(); }
package/dist/relayd.js CHANGED
@@ -222,12 +222,9 @@ export async function runDaemon(argv) {
222
222
  const snapshot = { path: candidate.snapshotPath };
223
223
  const discovered = candidate.job;
224
224
  try {
225
- if (discovered.state === 'CANCELLED')
226
- continue;
227
225
  if (!repositoryPaths.has(discovered.repository) || !discovered.localMerge?.sourceRef || !discovered.localMerge?.targetRef)
228
226
  continue;
229
- const sourceRef = discovered.localMerge.sourceRef;
230
- const targetRef = discovered.localMerge.targetRef;
227
+ const sourceRef = discovered.localMerge.sourceRef, targetRef = discovered.localMerge.targetRef;
231
228
  if (!sourceRef.startsWith('refs/heads/') || !targetRef.startsWith('refs/heads/'))
232
229
  continue;
233
230
  if (targetRef !== 'refs/heads/main')
@@ -250,13 +247,33 @@ export async function runDaemon(argv) {
250
247
  await bus?.publish(eventFor(discovered.id, 'projection', null, 'projection.delivered', 'succeeded', `GitHub main synchronization ${published.status}`, 'orchestrator', { operation: 'github.syncMain', deliveryStatus: published.status, mergedCommitSha: published.localMergedCommitSha }, { agent: 'relay', component: 'github-projection' }));
251
248
  continue;
252
249
  }
253
- const existingPrNumber = discovered.prNumber > 0 ? discovered.prNumber : 0;
254
250
  const terminal = ['COMPLETED', 'CANCELLED'].includes(discovered.state);
255
251
  const sourceSha = await gitText(canonical, ['rev-parse', '--verify', sourceRef]).catch(() => '');
256
252
  if (!sourceSha)
257
253
  continue;
258
254
  let job = (await relay.loadJob(discovered.id)) ?? discovered;
259
255
  let prNumber = job.prNumber > 0 ? job.prNumber : 0;
256
+ // The callback is part of the durable create intent, not daemon memory.
257
+ // Rebuild it on every scan so a reclaimed row remains executable after a
258
+ // crash, including when the binding mutation committed first.
259
+ const effect = async (item) => {
260
+ const payload = item.payload;
261
+ await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/${payload.base}:refs/remotes/origin/${payload.base}`]);
262
+ const remoteRef = `refs/remotes/origin/${payload.head}`;
263
+ const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
264
+ const remoteTreeMatchesBase = remoteHeadSha ? await gitText(canonical, ['diff', '--quiet', `refs/remotes/origin/${payload.base}`, remoteRef]).then(() => true, () => false) : false;
265
+ await gitText(canonical, authoritativeBranchPushArgs({ sourceRef: payload.sourceRef, branch: payload.head, remoteHeadSha: remoteHeadSha || null, remoteTreeMatchesBase }));
266
+ // Lookup by head/base first: a crash after GitHub created the PR must
267
+ // bind the existing collaboration projection rather than create one.
268
+ const pr = await resolvePullRequest(client, payload.repository, { head: payload.head, base: payload.base, title: payload.title, body: payload.body });
269
+ if (!relay.mutateJob)
270
+ throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
271
+ await relay.mutateJob(job.id, current => ({ ...current, prNumber: pr.number, githubProjection: { status: 'pr_bound', sourceSha, observedAt: new Date().toISOString() }, updatedAt: new Date().toISOString() }));
272
+ observability().record({ stage: 'projection', event: 'github.pr_bound', outcome: 'succeeded', message: `Bound local Job ${job.id} to GitHub PR #${pr.number}`, correlation: { repository: job.repository, prNumber: pr.number, jobId: job.id }, data: { head: branch, base } });
273
+ };
274
+ projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, prNumber > 0 ? new GitHubStore(client, job.repository, prNumber, trusted) : new InMemoryStore(job), undefined, true, effect));
275
+ if (job.state === 'CANCELLED')
276
+ continue; // Drain committed effects only; never push.
260
277
  if (prNumber === 0) {
261
278
  // Do not bootstrap an empty remote-only PR commit: it would diverge from
262
279
  // the real local Job branch before its first Worker commit. Publish the
@@ -277,21 +294,9 @@ export async function runDaemon(argv) {
277
294
  await relay.mutateJob(job.id, current => ({ ...current, githubProjection: { status: 'merged_without_pr', sourceSha, observedAt: new Date().toISOString() } }));
278
295
  continue;
279
296
  }
280
- const effect = async (item) => {
281
- const payload = item.payload;
282
- await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/${payload.base}:refs/remotes/origin/${payload.base}`]);
283
- const remoteRef = `refs/remotes/origin/${payload.head}`;
284
- const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
285
- const remoteTreeMatchesBase = remoteHeadSha ? await gitText(canonical, ['diff', '--quiet', `refs/remotes/origin/${payload.base}`, remoteRef]).then(() => true, () => false) : false;
286
- await gitText(canonical, authoritativeBranchPushArgs({ sourceRef: payload.sourceRef, branch: payload.head, remoteHeadSha: remoteHeadSha || null, remoteTreeMatchesBase }));
287
- const pr = await resolvePullRequest(client, payload.repository, { head: payload.head, base: payload.base, title: payload.title, body: payload.body });
288
- if (!relay.mutateJob)
289
- throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
290
- await relay.mutateJob(job.id, current => ({ ...current, prNumber: pr.number, githubProjection: { status: 'pr_bound', sourceSha, observedAt: new Date().toISOString() }, updatedAt: new Date().toISOString() }));
291
- observability().record({ stage: 'projection', event: 'github.pr_bound', outcome: 'succeeded', message: `Bound local Job ${job.id} to GitHub PR #${pr.number}`, correlation: { repository: job.repository, prNumber: pr.number, jobId: job.id }, data: { head: branch, base } });
292
- };
293
- projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, new InMemoryStore(job), undefined, true, effect));
294
- if (relay.enqueueExternalActionOnce)
297
+ // A terminal Job may drain an already-committed intent, but terminal
298
+ // state is never permission to create a new external PR intent.
299
+ if (!terminal && relay.enqueueExternalActionOnce)
295
300
  await relay.enqueueExternalActionOnce({ idempotencyKey: `create_pull_request:${job.id}:${sourceSha}`, operation: 'create_pull_request', payload: { operation: 'create_pull_request', repository: job.repository, head: branch, base, title: job.title, body: job.objective ?? job.description ?? '', sourceRef, targetRef }, priority: 100 });
296
301
  if (terminal)
297
302
  continue;
@@ -312,7 +317,7 @@ export async function runDaemon(argv) {
312
317
  await relay.savePullRequestFacts({ state: latestFacts?.state === 'CLOSED' ? 'CLOSED' : 'OPEN', head: { ref: branch, sha: sourceSha }, observedAt: new Date().toISOString() });
313
318
  }
314
319
  const github = new GitHubStore(client, job.repository, prNumber, trusted);
315
- projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true));
320
+ projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true, effect));
316
321
  if (job.state === 'COMPLETED' && job.localMerge?.status === 'succeeded' && job.localMerge.projectionStrategy === 'target_ref_push') {
317
322
  const injection = await consumeMethodInjection(relay, job.repository, job.id, 'github.projectLocalMerge');
318
323
  const projectedMerge = await withTestInjection('github.projectLocalMerge', injection, () => projectAuthoritativeLocalMerge(canonical, job));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.94",
3
+ "version": "2.0.96",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {