agents-relay 1.0.69 → 1.0.71

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/pool.js CHANGED
@@ -81,7 +81,7 @@ export class RepositoryWorkerPool {
81
81
  const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
82
82
  await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
83
83
  }
84
- async handleTerminalEvent(event) {
84
+ async handleLifecycleEvent(event) {
85
85
  const target = this.jobTargets.get(event.job_id);
86
86
  if (!target || !this.repositories.includes(target.repository)) {
87
87
  process.stderr.write(`warning: terminal event target unknown for job ${event.job_id}\n`);
@@ -92,8 +92,8 @@ export class RepositoryWorkerPool {
92
92
  }
93
93
  async watch(bus) {
94
94
  return bus.subscribeAll(async (event) => {
95
- if (['task.completed', 'task.failed', 'task.blocked', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type)) {
96
- await this.handleTerminalEvent(event);
95
+ if (['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type)) {
96
+ await this.handleLifecycleEvent(event);
97
97
  return;
98
98
  }
99
99
  if (event.type === 'job.wake' || event.type === 'github.webhook') {
@@ -10,11 +10,16 @@ import { selectReviewer } from './reviewer-routing.js';
10
10
  import { selectWorker, usageForProvider, workerCandidates } from './worker-routing.js';
11
11
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
12
12
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
13
+ export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
14
+ function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
13
15
  export function managedWorkerInput(job, task, context) {
14
16
  if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
15
17
  return task.input;
16
18
  if (!task.output)
17
19
  throw new Error(`Task ${task.id} requires an explicit output contract`);
20
+ if (!task.executionId)
21
+ throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
22
+ const sourceId = executionEventSource(job.id, task.id, task.executionId);
18
23
  const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
19
24
  const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
20
25
  const output = task.output.kind === 'file'
@@ -36,6 +41,8 @@ ${output}
36
41
 
37
42
  [Execution event contract — mandatory]
38
43
  Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
44
+ Your exact event source identity is: ${sourceId}
45
+ Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
39
46
  Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
40
47
  publish through the federated MCP tool events__publish; do not open NATS directly.
41
48
  For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
@@ -236,7 +243,7 @@ export class Reconciler {
236
243
  const now = new Date().toISOString();
237
244
  const implementationTasks = job.tasks.filter(item => item.agentName !== 'reviewer' && !this.isSupersededTask(item));
238
245
  const reviewerRouting = await this.selectReviewer(implementationTasks);
239
- const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: implementationTasks.map(item => item.id), capabilities: ['review'], workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter, input: `Review PR #${job.prNumber} at head SHA ${headSha}. Objective: ${job.objective ?? job.description ?? job.title}. First publish task.started as the worker start acknowledgement with data.worker_owned=true, then verify the deterministic pre-review gates have passed and inspect the completed work. Emit review.started, then exactly one of review.approved, review.changes_required, or review.blocked with data.review.headSha set to ${headSha}. For review.changes_required, include every actionable finding in data.review.issues as an array of concise issue strings; Relay will create one durable fix task containing all findings. Do not modify code. When auto-merge is enabled, Relay will merge this exact reviewed head after review.approved.`, output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null, reviewHeadSha: headSha, state: 'QUEUED', attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
246
+ const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: implementationTasks.map(item => item.id), capabilities: ['review'], workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter, input: `Review PR #${job.prNumber} at head SHA ${headSha}. Objective: ${job.objective ?? job.description ?? job.title}. First publish task.started using the caller-provided execution source identity, then verify the deterministic pre-review gates have passed and inspect the completed work. Emit review.started, then exactly one of review.approved, review.changes_required, or review.blocked with data.review.headSha set to ${headSha}. For review.changes_required, include every actionable finding in data.review.issues as an array of concise issue strings; Relay will create one durable fix task containing all findings. Do not modify code. When auto-merge is enabled, Relay will merge this exact reviewed head after review.approved.`, output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null, reviewHeadSha: headSha, state: 'QUEUED', attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
240
247
  await this.store.appendTask(task);
241
248
  job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
242
249
  job.updatedAt = now;
@@ -455,12 +462,10 @@ export class Reconciler {
455
462
  await this.store.saveTask(task);
456
463
  return;
457
464
  }
458
- let workerInput;
459
465
  let executionContext;
460
466
  try {
461
467
  const role = task.agentName === 'reviewer' ? 'reviewer' : task.parentTaskId ? 'nested-worker' : 'worker';
462
468
  executionContext = await this.resolvedContext(job, task, role);
463
- workerInput = managedWorkerInput(job, task, executionContext);
464
469
  }
465
470
  catch (error) {
466
471
  await transitionTaskEvent(task, 'block');
@@ -499,8 +504,10 @@ export class Reconciler {
499
504
  const durableExecutionId = task.executionId;
500
505
  const controller = new AbortController();
501
506
  let execution;
507
+ const sourceId = executionEventSource(job.id, task.id, durableExecutionId);
508
+ const workerInput = managedWorkerInput(job, task, executionContext);
502
509
  const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: task.routing.cwd ?? executionContext.project.path } : task.routing };
503
- const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
510
+ const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
504
511
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
505
512
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
506
513
  ? this.terminalWait(task.id, durableExecutionId, startedAt - 1)
@@ -533,7 +540,7 @@ export class Reconciler {
533
540
  ? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'delivery')
534
541
  : this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
535
542
  }
536
- async startAck(jobId, taskId, launchBarrier, timeoutMs = this.options.startAckTimeoutMs ?? 60000) {
543
+ async startAck(jobId, taskId, sourceId, launchBarrier, timeoutMs = this.options.startAckTimeoutMs ?? 60000) {
537
544
  if (!this.options.eventBus)
538
545
  throw new Error('Worker start acknowledgement requires an event bus');
539
546
  let resolveAck;
@@ -544,10 +551,7 @@ export class Reconciler {
544
551
  const unsubscribe = await this.options.eventBus.subscribe(jobId, async (event) => {
545
552
  if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
546
553
  return;
547
- const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
548
- const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
549
- const workerOwned = event.data?.worker_owned === true || ['codex', 'chatgpt'].includes(sourceAgent);
550
- if (!workerOwned || sourceAgent === 'relay' || sourceComponent === 'agents-relay')
554
+ if (eventSourceId(event) !== sourceId)
551
555
  return;
552
556
  if (!event.timestamp || new Date(event.timestamp).getTime() < launchBarrier)
553
557
  return;
@@ -688,16 +692,12 @@ export class Reconciler {
688
692
  this.terminalExitGraceTimers.delete(taskId);
689
693
  }
690
694
  async markWorkerStarted(event) {
691
- const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
692
- const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
693
- if (sourceAgent === 'relay' || sourceComponent === 'agents-relay')
694
- return;
695
695
  const live = this.live.get(event.task_id);
696
696
  if (live && event.job_id === live.jobId && event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
697
697
  return;
698
698
  const job = await this.store.load(event.job_id);
699
699
  const task = job.tasks.find(item => item.id === event.task_id);
700
- if (!task)
700
+ if (!task || !task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
701
701
  return;
702
702
  const claimedExecution = task.state === 'QUEUED' && task.executionId !== null && task.leaseOwner !== null;
703
703
  const exactLiveExecution = live && event.job_id === live.jobId && task.executionId === live.executionId;
@@ -731,10 +731,8 @@ export class Reconciler {
731
731
  || error.includes('exited before publishing task.started')
732
732
  || error.includes('exited after task.started without publishing a terminal task event');
733
733
  }
734
- isWorkerOwnedEvent(event) {
735
- const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
736
- const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
737
- return sourceAgent !== 'relay' && sourceComponent !== 'agents-relay';
734
+ isCorrelatedWorkerEvent(task, event) {
735
+ return task.executionId !== null && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId);
738
736
  }
739
737
  async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
740
738
  const claim = executionId ?? `${jobId}:${taskId}`;
@@ -758,7 +756,7 @@ export class Reconciler {
758
756
  await this.store.saveTask(task);
759
757
  }
760
758
  else {
761
- await this.applyTerminalEvent(event);
759
+ await this.applyTerminalEvent(event, true);
762
760
  }
763
761
  await this.emit(event);
764
762
  }
@@ -804,7 +802,7 @@ export class Reconciler {
804
802
  await this.store.saveTask(task);
805
803
  }
806
804
  else {
807
- await this.applyTerminalEvent(event);
805
+ await this.applyTerminalEvent(event, true);
808
806
  }
809
807
  await this.emit(event);
810
808
  }
@@ -831,16 +829,19 @@ export class Reconciler {
831
829
  this.clearExecution(task.id, executionId);
832
830
  }
833
831
  else {
834
- await this.applyTerminalEvent(event);
832
+ await this.applyTerminalEvent(event, true);
835
833
  }
836
834
  await this.emit(event);
837
835
  }
838
- async applyTerminalEvent(event) {
836
+ async applyTerminalEvent(event, trustedInternal = false) {
839
837
  const job = await this.store.load(event.job_id);
840
838
  const task = job.tasks.find(item => item.id === event.task_id);
841
839
  if (!task || !['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
842
840
  return;
843
- if (task.state === 'FAILED' && this.isRecoverableWatchdogFailure(task) && this.isWorkerOwnedEvent(event)) {
841
+ const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
842
+ if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event))
843
+ return;
844
+ if (task.state === 'FAILED' && this.isRecoverableWatchdogFailure(task) && (trustedInternal || !modelBacked || this.isCorrelatedWorkerEvent(task, event))) {
844
845
  if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
845
846
  return;
846
847
  await transitionTaskEvent(task, 'reconcile.started');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.69",
3
+ "version": "1.0.71",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -63,6 +63,8 @@ Do not use `WAITING` as a Task state or durable Job lifecycle state. `WAITING` i
63
63
 
64
64
  Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must publish canonical `task.started` as its first execution action, then exactly one of `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. Process launch/exit events do not substitute for worker semantic events.
65
65
 
66
+ Relay allocates the durable `executionId` before launching model-backed work and gives that worker one exact event source identity: `job/<job-id>/task/<task-id>/execution/<execution-id>`. Every semantic lifecycle event from that execution must copy the identity verbatim in `source.id`. Relay correlates model-worker lifecycle only by this execution identity; `source.agent`, `source.component`, provider/adapter names, and `data.worker_owned` are diagnostic/legacy metadata and MUST NOT authorize or correlate lifecycle state. A retry receives a new execution identity, so events from an older execution are ignored.
67
+
66
68
  ## Job lifecycle and runtime status
67
69
 
68
70
  The canonical durable lifecycle transition graphs are `workflow/job-lifecycle.mmd` and `workflow/task-lifecycle.mmd`. They use Relay's minimal executable Mermaid `stateDiagram-v2` profile and are the single source of truth for transition legality; do not maintain a parallel JSON/YAML transition table. Runtime status is not part of those lifecycle graphs.