agents-relay 2.0.61 → 2.0.63

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.
@@ -1,4 +1,25 @@
1
1
  import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ export const PACKAGE_FAILED_MARKER = 'agents-relay:package-publish-failed:v1';
3
+ export function parsePackagePublishFailureMarker(body) {
4
+ if (typeof body !== 'string')
5
+ return null;
6
+ const prefix = '<!-- ' + PACKAGE_FAILED_MARKER + ' ';
7
+ const start = body.indexOf(prefix);
8
+ if (start < 0)
9
+ return null;
10
+ const end = body.indexOf(' -->', start + prefix.length);
11
+ if (end < 0)
12
+ return null;
13
+ try {
14
+ const data = JSON.parse(body.slice(start + prefix.length, end));
15
+ if (typeof data.runId !== 'string' || !/^\d+$/.test(data.runId) || !Number.isInteger(data.attempt) || Number(data.attempt) < 1 || typeof data.sha !== 'string' || !/^[a-f0-9]{7,40}$/i.test(data.sha) || typeof data.stage !== 'string' || !data.stage)
16
+ return null;
17
+ return { runId: data.runId, attempt: Number(data.attempt), sha: data.sha, stage: data.stage };
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
2
23
  export const PACKAGE_PUBLISHED_MARKER = 'agents-relay:package-published:v1';
3
24
  export function parsePackagePublicationMarker(body) {
4
25
  if (typeof body !== 'string')
@@ -71,9 +92,11 @@ export function parseGitHubWebhook(headers, body) {
71
92
  if (event === 'issue_comment') {
72
93
  const comment = payload.comment;
73
94
  const publication = parsePackagePublicationMarker(comment?.body);
74
- if (!publication)
95
+ const publishFailure = comment?.user?.login === 'github-actions[bot]'
96
+ ? parsePackagePublishFailureMarker(comment?.body) : null;
97
+ if (!publication && !publishFailure)
75
98
  return null;
76
- return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication, pullRequestFacts: null };
99
+ return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication, ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
77
100
  }
78
101
  const facts = pullRequestFacts(payload, action);
79
102
  if (!facts)
package/dist/pool.js CHANGED
@@ -138,6 +138,13 @@ export class RepositoryWorkerPool {
138
138
  continue;
139
139
  }
140
140
  const result = await withGitHubRequestContext(this.client, `pool.coordinator-apply:${signal.reason}`, () => reconciler.applyEvent(signal.event));
141
+ if (!result.applied)
142
+ observability().record({
143
+ stage: 'transition', event: 'lifecycle.event_rejected', level: 'warn', outcome: 'skipped',
144
+ message: `Lifecycle event ${signal.event.type} not applied: ${result.reason}`,
145
+ correlation: { repository: target.repository, prNumber: target.prNumber, jobId, taskId: signal.event.task_id },
146
+ data: { eventId: signal.event.event_id, sourceId: signal.event.source?.id, reason: result.reason, eventTimestamp: signal.event.timestamp },
147
+ });
141
148
  shouldReconcile ||= result.reconcile;
142
149
  }
143
150
  if (!shouldReconcile) {
@@ -304,6 +311,9 @@ export class RepositoryWorkerPool {
304
311
  sha: wake.publication.sha,
305
312
  }, source));
306
313
  }
314
+ if (wake.publishFailure) {
315
+ await bus.publish(eventFor(job.id, 'release', null, 'package.publish_failed', 'failed', `Publish workflow ${wake.publishFailure.runId} failed: ${wake.publishFailure.stage}`, 'user', { ...wake.publishFailure, repository: job.repository, prNumber: job.prNumber, deliveryId: wake.deliveryId }, source));
316
+ }
307
317
  await bus.publish(eventFor(job.id, 'github', null, 'github.webhook', 'queued', `GitHub webhook ${wake.event}${wake.action ? ':' + wake.action : ''}`, 'orchestrator', { deliveryId: wake.deliveryId, event: wake.event, action: wake.action, repository: job.repository, prNumber: job.prNumber }, source));
308
318
  return;
309
319
  }
@@ -1,12 +1,13 @@
1
+ import { applyTaskRetryAction } from './task-actions.js';
1
2
  import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
2
3
  import { DurableTaskTransitionRuntime } from './workflow/durable-transition.js';
3
- import { StartAckTimeout } from './adapters.js';
4
4
  import { eventFor } from './events.js';
5
5
  import { WorkflowEffectExecutor } from './workflow/effects.js';
6
6
  import { workflowEffectFailureEvent } from './workflow/contracts.js';
7
7
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
8
8
  import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
9
9
  import { rearmTerminalEventDeadline } from './terminal-event-watchdog.js';
10
+ import { createWorkerStartAck } from './worker-start-ack.js';
10
11
  import { monitorExecutionProcess } from './execution-process-tracker.js';
11
12
  export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
12
13
  function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
@@ -183,46 +184,7 @@ export class ReconcilerRuntimeBase {
183
184
  async startAck(jobId, taskId, sourceId, launchBarrier, timeoutMs = this.options.startAckTimeoutMs ?? 60000) {
184
185
  if (!this.options.eventBus)
185
186
  throw new Error('Worker start acknowledgement requires an event bus');
186
- let resolveAck;
187
- let rejectAck;
188
- let settled = false;
189
- let timer;
190
- const ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
191
- const unsubscribe = await this.options.eventBus.subscribe(jobId, async (event) => {
192
- if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
193
- return;
194
- if (eventSourceId(event) !== sourceId)
195
- return;
196
- if (!event.timestamp || new Date(event.timestamp).getTime() < launchBarrier)
197
- return;
198
- settled = true;
199
- if (timer)
200
- clearTimeout(timer);
201
- resolveAck();
202
- });
203
- timer = setTimeout(() => {
204
- if (settled)
205
- return;
206
- settled = true;
207
- void unsubscribe();
208
- rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
209
- }, timeoutMs);
210
- let stopped = false;
211
- const stop = async () => {
212
- if (stopped)
213
- return;
214
- stopped = true;
215
- if (timer)
216
- clearTimeout(timer);
217
- if (!settled) {
218
- settled = true;
219
- rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_cancelled: start-ack owner closed`));
220
- }
221
- this.startAckCleanups.delete(stop);
222
- await unsubscribe();
223
- };
224
- this.startAckCleanups.add(stop);
225
- return { ack, stop };
187
+ return createWorkerStartAck(this.options.eventBus, jobId, taskId, sourceId, timeoutMs, this.startAckCleanups);
226
188
  }
227
189
  registerExecutionOwner(jobId, taskId, executionId) {
228
190
  activeExecutionOwners.set(executionEventSource(jobId, taskId, executionId), this);
@@ -269,8 +231,7 @@ export class ReconcilerRuntimeBase {
269
231
  }
270
232
  async markWorkerStarted(event) {
271
233
  const live = this.live.get(event.task_id);
272
- if (live && event.job_id === live.jobId && event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
273
- return { applied: false, reconcile: false, reason: 'task.started.stale' };
234
+ // Event timestamps are worker-authored and may be wrong; exact execution identity is authoritative.
274
235
  const transition = await this.taskTransitions.apply(event.job_id, event.task_id, 'task.started', {
275
236
  eligible: (job, task) => {
276
237
  if (!task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
@@ -281,10 +242,10 @@ export class ReconcilerRuntimeBase {
281
242
  },
282
243
  facts: (_job, task) => ({
283
244
  recoverableExecution: this.isRecoverableWatchdogFailure(task)
284
- && (!event.timestamp || new Date(event.timestamp).getTime() >= new Date(task.updatedAt).getTime()),
245
+ && Boolean(task.executionId && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId)),
285
246
  }),
286
247
  mutate: (_job, task) => {
287
- task.updatedAt = event.timestamp || new Date().toISOString();
248
+ task.updatedAt = new Date().toISOString();
288
249
  },
289
250
  });
290
251
  if (!transition?.decision.accepted)
@@ -335,8 +296,10 @@ export class ReconcilerRuntimeBase {
335
296
  }
336
297
  async runtimeSettled(jobId, taskId, executionId, result, error) {
337
298
  const live = this.live.get(taskId);
338
- if (live?.executionId === executionId)
299
+ if (live?.executionId === executionId) {
339
300
  live.processSettled = true;
301
+ live.processError = error;
302
+ }
340
303
  const job = await this.store.load(jobId);
341
304
  const task = job.tasks.find(item => item.id === taskId);
342
305
  if (!task || (task.executionId !== executionId && !terminalExecutionClaimed(jobId, taskId, executionId)))
@@ -352,12 +315,17 @@ export class ReconcilerRuntimeBase {
352
315
  live.controller.abort();
353
316
  live.execution.cancel();
354
317
  }
318
+ const exit = live?.executionId === executionId && live.processSettled ? live.execution.processExit : undefined;
319
+ const rawError = live?.executionId === executionId && live.processSettled ? (live.processError || exit?.stderr || '').trim() : '';
320
+ const failedExit = Boolean(exit && (exit.exitCode !== 0 || exit.signal));
321
+ const failureReason = failedExit ? (/rate.limit.exceeded|usage limit reached/i.test(rawError) ? 'rate_limit_exceeded' : 'worker_process_failed') : 'terminal_event_timeout';
322
+ const failureMessage = failedExit ? (rawError || `Worker exited with code ${exit?.exitCode ?? 'null'}${exit?.signal ? `, signal ${exit.signal}` : ''}`) : `Task ${taskId} timed out waiting for the mandatory terminal worker event`;
355
323
  const transition = await this.taskTransitions.apply(jobId, taskId, 'watchdog.failed', {
356
324
  eligible: (_job, task) => task.executionId === executionId,
357
325
  mutate: (_job, task) => {
358
326
  this.clearExecution(task.id, executionId);
359
- task.error = `Task ${taskId} timed out waiting for the mandatory terminal worker event`;
360
- task.failureReason = 'terminal_event_timeout';
327
+ task.error = failureMessage;
328
+ task.failureReason = failureReason;
361
329
  task.leaseOwner = null;
362
330
  task.leaseExpiresAt = null;
363
331
  },
@@ -366,8 +334,21 @@ export class ReconcilerRuntimeBase {
366
334
  this.clearExecution(taskId, executionId);
367
335
  return;
368
336
  }
369
- const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Task ${taskId} timed out waiting for the mandatory terminal worker event`, 'user', { reason: 'terminal_event_timeout', timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed' }, { agent: 'relay', component: 'agents-relay', watchdog: true });
370
- this.detach(this.emit(event), jobId, taskId, 'timeout failure event');
337
+ const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? failureMessage, 'user', { reason: failureReason, exitCode: exit?.exitCode ?? null, signal: exit?.signal ?? null, timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed' }, { agent: 'relay', component: 'agents-relay', watchdog: true });
338
+ await this.emit(event);
339
+ // Provider quota failures are recoverable. Drop the stale selection so the
340
+ // next launch runs the normal worker/model router with fresh usage data.
341
+ if (failureReason === 'rate_limit_exceeded' && transition.task.attempt < transition.task.maxAttempts) {
342
+ const current = (await this.store.load(jobId)).tasks.find(task => task.id === taskId);
343
+ if (current?.state === 'FAILED' && current.failureReason === failureReason && current.attempt === transition.task.attempt) {
344
+ await applyTaskRetryAction(current);
345
+ current.routing = undefined;
346
+ current.workerSelection = undefined;
347
+ await this.store.saveTask(current);
348
+ await this.emit(eventFor(jobId, taskId, current.parentTaskId, 'task.rerouting', 'queued', 'Provider rate limit reached; retrying with fresh routing and usage data', 'orchestrator', { previousFailure: failureReason, attempt: current.attempt, maxAttempts: current.maxAttempts }));
349
+ await this.eventCoordinator.accept({ jobId, reason: 'rate-limit-reroute' });
350
+ }
351
+ }
371
352
  }
372
353
  async applyTerminalEvent(event, trustedInternal = false) {
373
354
  const liveAtTerminal = this.live.get(event.task_id);
@@ -388,7 +369,7 @@ export class ReconcilerRuntimeBase {
388
369
  return false;
389
370
  }
390
371
  const terminalWaiter = this.terminalWaiters.get(task.id);
391
- return !terminalWaiter || Boolean(event.timestamp && new Date(event.timestamp).getTime() >= terminalWaiter.launchBarrier);
372
+ return !terminalWaiter || Boolean(task.executionId && terminalWaiter.executionId === task.executionId && this.isCorrelatedWorkerEvent(task, event));
392
373
  },
393
374
  facts: (_job, task) => {
394
375
  const processExitedAfterPublishedTerminal = task.failureReason === 'worker_exit_without_terminal_event';
@@ -411,7 +392,7 @@ export class ReconcilerRuntimeBase {
411
392
  task.leaseOwner = null;
412
393
  task.leaseExpiresAt = null;
413
394
  task.executionId = null;
414
- task.updatedAt = event.timestamp || new Date().toISOString();
395
+ task.updatedAt = new Date().toISOString();
415
396
  this.terminalWaiters.get(task.id)?.resolve();
416
397
  this.terminalWaiters.delete(task.id);
417
398
  const reportedTag = acceptedReportedTag(job, event.data);
@@ -0,0 +1,42 @@
1
+ import { StartAckTimeout } from './adapters.js';
2
+ /** A worker start ack is scoped to its server-issued execution source, never its client clock. */
3
+ export async function createWorkerStartAck(bus, jobId, taskId, sourceId, timeoutMs, cleanups) {
4
+ let resolveAck;
5
+ let rejectAck;
6
+ let settled = false;
7
+ let timer;
8
+ const ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
9
+ const unsubscribe = await bus.subscribe(jobId, async (event) => {
10
+ if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
11
+ return;
12
+ if (event.source?.id !== sourceId)
13
+ return;
14
+ settled = true;
15
+ if (timer)
16
+ clearTimeout(timer);
17
+ resolveAck();
18
+ });
19
+ timer = setTimeout(() => {
20
+ if (settled)
21
+ return;
22
+ settled = true;
23
+ void unsubscribe();
24
+ rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
25
+ }, timeoutMs);
26
+ let stopped = false;
27
+ const stop = async () => {
28
+ if (stopped)
29
+ return;
30
+ stopped = true;
31
+ if (timer)
32
+ clearTimeout(timer);
33
+ if (!settled) {
34
+ settled = true;
35
+ rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_cancelled: start-ack owner closed`));
36
+ }
37
+ cleanups.delete(stop);
38
+ await unsubscribe();
39
+ };
40
+ cleanups.add(stop);
41
+ return { ack, stop };
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.61",
3
+ "version": "2.0.63",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {