agents-relay 1.0.69 → 1.0.70
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/reconciler.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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
|
@@ -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.
|