agents-relay 2.0.94 → 2.0.95

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.
@@ -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.95",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {