agents-relay 1.0.21 → 1.0.23
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/planner.js
CHANGED
|
@@ -21,6 +21,30 @@ function stringArray(value, field, fallback = []) {
|
|
|
21
21
|
throw new Error(`Planner ${field} must be an array of non-empty strings`);
|
|
22
22
|
return [...value];
|
|
23
23
|
}
|
|
24
|
+
function rawValue(value) {
|
|
25
|
+
if (typeof value === 'string')
|
|
26
|
+
return JSON.stringify(value);
|
|
27
|
+
if (value === undefined)
|
|
28
|
+
return 'undefined';
|
|
29
|
+
try {
|
|
30
|
+
return JSON.stringify(value) ?? String(value);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return String(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function normalizePlannerPriority(value, field) {
|
|
37
|
+
if (typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 3)
|
|
38
|
+
return `P${value}`;
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
const normalized = value.trim();
|
|
41
|
+
if (/^P[0-3]$/i.test(normalized))
|
|
42
|
+
return normalized.toUpperCase();
|
|
43
|
+
if (/^[0-3]$/.test(normalized))
|
|
44
|
+
return `P${normalized}`;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Planner ${field} is invalid: ${rawValue(value)}`);
|
|
47
|
+
}
|
|
24
48
|
function routing(value, field) {
|
|
25
49
|
if (value === undefined || value === null)
|
|
26
50
|
return undefined;
|
|
@@ -58,8 +82,7 @@ export function parsePlannerResult(value) {
|
|
|
58
82
|
throw new Error(`Planner next_tasks[${index}].output is required`);
|
|
59
83
|
if (output.kind === 'file' && (typeof output.path !== 'string' || output.path.length === 0))
|
|
60
84
|
throw new Error(`Planner next_tasks[${index}].output.path is required for file output`);
|
|
61
|
-
|
|
62
|
-
throw new Error(`Planner next_tasks[${index}].priority is invalid`);
|
|
85
|
+
const priority = task.priority === undefined ? undefined : normalizePlannerPriority(task.priority, `next_tasks[${index}].priority`);
|
|
63
86
|
const maxAttempts = task.maxAttempts === undefined ? 3 : task.maxAttempts;
|
|
64
87
|
const timeoutMs = task.timeoutMs === undefined ? 300000 : task.timeoutMs;
|
|
65
88
|
if (typeof maxAttempts !== 'number' || !Number.isInteger(maxAttempts) || maxAttempts < 1)
|
|
@@ -67,7 +90,7 @@ export function parsePlannerResult(value) {
|
|
|
67
90
|
if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0)
|
|
68
91
|
throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
|
|
69
92
|
return {
|
|
70
|
-
id, input: task.input, priority
|
|
93
|
+
id, input: task.input, priority,
|
|
71
94
|
projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'),
|
|
72
95
|
dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'),
|
|
73
96
|
adapter: adapter, output: output, routing: routing(task.routing, `next_tasks[${index}].routing`),
|
package/dist/reconciler.js
CHANGED
|
@@ -26,18 +26,32 @@ ${task.input}
|
|
|
26
26
|
[Output contract]
|
|
27
27
|
${output}
|
|
28
28
|
|
|
29
|
-
[Execution event contract]
|
|
29
|
+
[Execution event contract — mandatory]
|
|
30
30
|
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
31
31
|
Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
|
|
32
32
|
publish through the federated MCP tool events__publish; do not open NATS directly.
|
|
33
33
|
For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
|
|
34
34
|
|
|
35
|
+
As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
|
|
36
|
+
you MUST publish exactly one canonical task.started event for this exact Job/Task.
|
|
37
|
+
|
|
35
38
|
You MAY publish progress events while working.
|
|
36
|
-
Before
|
|
39
|
+
Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
|
|
37
40
|
- task.completed only after the declared output is durable;
|
|
38
|
-
- task.failed
|
|
39
|
-
- task.blocked when human/external action is required
|
|
40
|
-
|
|
41
|
+
- task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
|
|
42
|
+
- task.blocked only when genuine human/external action is required;
|
|
43
|
+
- task.cancelled only when execution was cancelled.
|
|
44
|
+
|
|
45
|
+
If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
|
|
46
|
+
Never exit after task.started without publishing exactly one terminal task event.
|
|
47
|
+
Do not publish duplicate terminal task events.
|
|
48
|
+
The worker owns task.started and task terminal events. Relay-owned task.process.launched, task.process.exited,
|
|
49
|
+
and task.process.async_exited are process lifecycle only and NEVER replace the worker task lifecycle.
|
|
50
|
+
The ChatGPT/Codex conversation or process exit is never the task result.
|
|
51
|
+
|
|
52
|
+
[Final event gate]
|
|
53
|
+
Before your final return, verify that a terminal event for this exact Job/Task was published.
|
|
54
|
+
If no terminal event has been published, publish task.failed before exiting.`;
|
|
41
55
|
}
|
|
42
56
|
export class Reconciler {
|
|
43
57
|
store;
|
|
@@ -45,6 +59,7 @@ export class Reconciler {
|
|
|
45
59
|
live = new Map();
|
|
46
60
|
plannerLive = new Map();
|
|
47
61
|
terminalTimers = new Map();
|
|
62
|
+
terminalExitGraceTimers = new Map();
|
|
48
63
|
continuationLive = new Set();
|
|
49
64
|
terminalWaiters = new Map();
|
|
50
65
|
reconciling = false;
|
|
@@ -53,6 +68,10 @@ export class Reconciler {
|
|
|
53
68
|
this.options = options;
|
|
54
69
|
}
|
|
55
70
|
async handleEvent(event) {
|
|
71
|
+
if (event.type === 'task.started') {
|
|
72
|
+
this.markWorkerStarted(event);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
56
75
|
if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
|
|
57
76
|
await this.applyTerminalEvent(event);
|
|
58
77
|
return;
|
|
@@ -327,7 +346,7 @@ export class Reconciler {
|
|
|
327
346
|
void (execution.launched ?? Promise.resolve()).then(() => this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model }, { agent: 'relay', component: 'agents-relay' }))).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
|
|
328
347
|
if (startAckSetup)
|
|
329
348
|
void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
|
|
330
|
-
this.live.set(task.id, { taskId: task.id, executionId: durableExecutionId, execution, controller });
|
|
349
|
+
this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: Date.now(), workerStarted: false });
|
|
331
350
|
const terminalTimer = setTimeout(() => {
|
|
332
351
|
this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
|
|
333
352
|
}, Math.max(1, task.timeoutMs));
|
|
@@ -470,6 +489,22 @@ export class Reconciler {
|
|
|
470
489
|
if (timer)
|
|
471
490
|
clearTimeout(timer);
|
|
472
491
|
this.terminalTimers.delete(taskId);
|
|
492
|
+
const graceTimer = this.terminalExitGraceTimers.get(taskId);
|
|
493
|
+
if (graceTimer)
|
|
494
|
+
clearTimeout(graceTimer);
|
|
495
|
+
this.terminalExitGraceTimers.delete(taskId);
|
|
496
|
+
}
|
|
497
|
+
markWorkerStarted(event) {
|
|
498
|
+
const live = this.live.get(event.task_id);
|
|
499
|
+
if (!live || event.job_id !== live.jobId)
|
|
500
|
+
return;
|
|
501
|
+
const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
|
|
502
|
+
const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
|
|
503
|
+
if (sourceAgent === 'relay' || sourceComponent === 'agents-relay')
|
|
504
|
+
return;
|
|
505
|
+
if (event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
|
|
506
|
+
return;
|
|
507
|
+
live.workerStarted = true;
|
|
473
508
|
}
|
|
474
509
|
async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
|
|
475
510
|
const job = await this.store.load(jobId);
|
|
@@ -482,13 +517,37 @@ export class Reconciler {
|
|
|
482
517
|
}
|
|
483
518
|
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
484
519
|
const live = this.live.get(taskId);
|
|
520
|
+
const workerStarted = live?.executionId === executionId && live.workerStarted;
|
|
485
521
|
if (live?.executionId === executionId)
|
|
486
522
|
this.live.delete(taskId);
|
|
487
523
|
const job = await this.store.load(jobId);
|
|
488
524
|
const task = job.tasks.find(item => item.id === taskId);
|
|
489
|
-
if (!task || task.executionId !== executionId
|
|
525
|
+
if (!task || task.executionId !== executionId)
|
|
490
526
|
return;
|
|
491
527
|
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
|
|
528
|
+
if (task.state === 'RUNNING' && task.adapter === 'codex')
|
|
529
|
+
this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
|
|
530
|
+
}
|
|
531
|
+
scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
532
|
+
const graceMs = Math.max(1, this.options.terminalExitGraceMs ?? 1000);
|
|
533
|
+
const timer = setTimeout(() => {
|
|
534
|
+
this.terminalExitGraceTimers.delete(taskId);
|
|
535
|
+
this.detach(this.failUnexpectedExit(jobId, taskId, executionId, workerStarted), jobId, taskId, 'unexpected worker process exit');
|
|
536
|
+
}, graceMs);
|
|
537
|
+
this.terminalExitGraceTimers.set(taskId, timer);
|
|
538
|
+
}
|
|
539
|
+
async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
540
|
+
const job = await this.store.load(jobId);
|
|
541
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
542
|
+
if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
|
|
543
|
+
return;
|
|
544
|
+
const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
|
|
545
|
+
const message = workerStarted
|
|
546
|
+
? `Worker ${taskId} exited after task.started without publishing a terminal task event`
|
|
547
|
+
: `Worker ${taskId} exited before publishing task.started`;
|
|
548
|
+
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000 }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
549
|
+
await this.applyTerminalEvent(event);
|
|
550
|
+
await this.emit(event);
|
|
492
551
|
}
|
|
493
552
|
async timeoutTask(jobId, taskId, executionId) {
|
|
494
553
|
const live = this.live.get(taskId);
|
package/package.json
CHANGED
|
@@ -47,6 +47,8 @@ Agents Relay maps those terminal events to durable state:
|
|
|
47
47
|
|
|
48
48
|
A model-backed task must not launch without an event bus. If no terminal event arrives before the task timeout, Relay records an explicit timeout failure. A Codex process exit or ChatGPT submission receipt alone never changes the task to a terminal state.
|
|
49
49
|
|
|
50
|
+
For Codex, an unexpected process exit after the worker's `task.started` is given only a short bounded terminal grace window. If no exact correlated worker terminal event arrives in that window, Relay records `task.failed` with `worker_exit_without_terminal_event` as an explicit lifecycle-contract failure. This watchdog failure is still distinct from `task.process.exited`; it never treats process exit as task success and never duplicates an already-applied terminal event. An exit before `task.started` is likewise recorded as `worker_exit_before_start`.
|
|
51
|
+
|
|
50
52
|
## Managed GitHub jobs
|
|
51
53
|
|
|
52
54
|
For repository work, create/adopt/repair the PR-backed job through the Agents Relay CLI. Do not create the managed PR through a parallel raw GitHub path.
|
|
@@ -17,6 +17,7 @@ Inspect the durable objective and task tree, then decide whether the objective i
|
|
|
17
17
|
- `{"kind":"file","path":"..."}`.
|
|
18
18
|
- Include routing metadata for every model-backed child task.
|
|
19
19
|
- Do not call an execution agent "codex"; agent identity describes responsibility, while the adapter describes runtime.
|
|
20
|
+
- If a child task includes `priority`, emit the canonical string `P0`, `P1`, `P2`, or `P3` exactly. Do not emit numeric priorities, lowercase aliases, or labels such as `high`/`medium`/`low`.
|
|
20
21
|
- Return no prose outside the JSON result.
|
|
21
22
|
|
|
22
23
|
## Output
|