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
- if (task.priority !== undefined && !['P0', 'P1', 'P2', 'P3'].includes(String(task.priority)))
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: task.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`),
@@ -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 stopping, you MUST publish exactly one terminal event:
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 when execution cannot complete;
39
- - task.blocked when human/external action is required.
40
- The ChatGPT/Codex conversation or process exit is never the task result.`;
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 || task.state !== 'RUNNING')
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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