agents-relay 1.0.22 → 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`),
@@ -59,6 +59,7 @@ export class Reconciler {
59
59
  live = new Map();
60
60
  plannerLive = new Map();
61
61
  terminalTimers = new Map();
62
+ terminalExitGraceTimers = new Map();
62
63
  continuationLive = new Set();
63
64
  terminalWaiters = new Map();
64
65
  reconciling = false;
@@ -67,6 +68,10 @@ export class Reconciler {
67
68
  this.options = options;
68
69
  }
69
70
  async handleEvent(event) {
71
+ if (event.type === 'task.started') {
72
+ this.markWorkerStarted(event);
73
+ return;
74
+ }
70
75
  if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
71
76
  await this.applyTerminalEvent(event);
72
77
  return;
@@ -341,7 +346,7 @@ export class Reconciler {
341
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'));
342
347
  if (startAckSetup)
343
348
  void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
344
- 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 });
345
350
  const terminalTimer = setTimeout(() => {
346
351
  this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
347
352
  }, Math.max(1, task.timeoutMs));
@@ -484,6 +489,22 @@ export class Reconciler {
484
489
  if (timer)
485
490
  clearTimeout(timer);
486
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;
487
508
  }
488
509
  async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
489
510
  const job = await this.store.load(jobId);
@@ -496,13 +517,37 @@ export class Reconciler {
496
517
  }
497
518
  async runtimeSettled(jobId, taskId, executionId, result, error) {
498
519
  const live = this.live.get(taskId);
520
+ const workerStarted = live?.executionId === executionId && live.workerStarted;
499
521
  if (live?.executionId === executionId)
500
522
  this.live.delete(taskId);
501
523
  const job = await this.store.load(jobId);
502
524
  const task = job.tasks.find(item => item.id === taskId);
503
- if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
525
+ if (!task || task.executionId !== executionId)
504
526
  return;
505
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);
506
551
  }
507
552
  async timeoutTask(jobId, taskId, executionId) {
508
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.22",
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