agents-relay 1.0.22 → 1.0.24

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/cli.js CHANGED
@@ -572,8 +572,8 @@ async function main() {
572
572
  task.id = arg(args, '--task-id', randomUUID());
573
573
  await store.appendTask(task);
574
574
  job.tasks.push(task);
575
- if (job.state === 'COMPLETED') {
576
- job.state = 'OPEN';
575
+ if (job.state !== 'RUNNING') {
576
+ job.state = 'RUNNING';
577
577
  job.updatedAt = now;
578
578
  await store.saveJob(job);
579
579
  }
@@ -597,6 +597,11 @@ async function main() {
597
597
  task.error = command === 'retry' ? null : 'Cancelled by user';
598
598
  task.updatedAt = new Date().toISOString();
599
599
  await store.saveTask(task);
600
+ if (command === 'retry' && job.state !== 'RUNNING') {
601
+ job.state = 'RUNNING';
602
+ job.updatedAt = task.updatedAt;
603
+ await store.saveJob(job);
604
+ }
600
605
  await saveLocal(localFile, job);
601
606
  await publishWake(bus, job, task.id, `${task.id} ${command} requested`);
602
607
  console.log(`${id}: ${task.state}`);
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,7 +59,9 @@ 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();
64
+ launchFailureClaims = new Set();
63
65
  terminalWaiters = new Map();
64
66
  reconciling = false;
65
67
  constructor(store, options) {
@@ -67,6 +69,10 @@ export class Reconciler {
67
69
  this.options = options;
68
70
  }
69
71
  async handleEvent(event) {
72
+ if (event.type === 'task.started') {
73
+ this.markWorkerStarted(event);
74
+ return;
75
+ }
70
76
  if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
71
77
  await this.applyTerminalEvent(event);
72
78
  return;
@@ -341,7 +347,7 @@ export class Reconciler {
341
347
  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
348
  if (startAckSetup)
343
349
  void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
344
- this.live.set(task.id, { taskId: task.id, executionId: durableExecutionId, execution, controller });
350
+ this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: Date.now(), workerStarted: false });
345
351
  const terminalTimer = setTimeout(() => {
346
352
  this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
347
353
  }, Math.max(1, task.timeoutMs));
@@ -484,8 +490,30 @@ export class Reconciler {
484
490
  if (timer)
485
491
  clearTimeout(timer);
486
492
  this.terminalTimers.delete(taskId);
493
+ const graceTimer = this.terminalExitGraceTimers.get(taskId);
494
+ if (graceTimer)
495
+ clearTimeout(graceTimer);
496
+ this.terminalExitGraceTimers.delete(taskId);
497
+ }
498
+ markWorkerStarted(event) {
499
+ const live = this.live.get(event.task_id);
500
+ if (!live || event.job_id !== live.jobId)
501
+ return;
502
+ const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
503
+ const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
504
+ if (sourceAgent === 'relay' || sourceComponent === 'agents-relay')
505
+ return;
506
+ if (event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
507
+ return;
508
+ live.workerStarted = true;
487
509
  }
488
510
  async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
511
+ const claim = executionId ?? `${jobId}:${taskId}`;
512
+ if (this.launchFailureClaims.has(claim))
513
+ return;
514
+ this.launchFailureClaims.add(claim);
515
+ const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
516
+ cleanup.unref();
489
517
  const job = await this.store.load(jobId);
490
518
  const task = job.tasks.find(item => item.id === taskId);
491
519
  if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
@@ -496,13 +524,37 @@ export class Reconciler {
496
524
  }
497
525
  async runtimeSettled(jobId, taskId, executionId, result, error) {
498
526
  const live = this.live.get(taskId);
527
+ const workerStarted = live?.executionId === executionId && live.workerStarted;
499
528
  if (live?.executionId === executionId)
500
529
  this.live.delete(taskId);
501
530
  const job = await this.store.load(jobId);
502
531
  const task = job.tasks.find(item => item.id === taskId);
503
- if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
532
+ if (!task || task.executionId !== executionId)
504
533
  return;
505
534
  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' }));
535
+ if (task.state === 'RUNNING' && task.adapter === 'codex')
536
+ this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
537
+ }
538
+ scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
539
+ const graceMs = Math.max(1, this.options.terminalExitGraceMs ?? 1000);
540
+ const timer = setTimeout(() => {
541
+ this.terminalExitGraceTimers.delete(taskId);
542
+ this.detach(this.failUnexpectedExit(jobId, taskId, executionId, workerStarted), jobId, taskId, 'unexpected worker process exit');
543
+ }, graceMs);
544
+ this.terminalExitGraceTimers.set(taskId, timer);
545
+ }
546
+ async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
547
+ const job = await this.store.load(jobId);
548
+ const task = job.tasks.find(item => item.id === taskId);
549
+ if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
550
+ return;
551
+ const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
552
+ const message = workerStarted
553
+ ? `Worker ${taskId} exited after task.started without publishing a terminal task event`
554
+ : `Worker ${taskId} exited before publishing task.started`;
555
+ 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 });
556
+ await this.applyTerminalEvent(event);
557
+ await this.emit(event);
506
558
  }
507
559
  async timeoutTask(jobId, taskId, executionId) {
508
560
  const live = this.live.get(taskId);
package/dist/scheduler.js CHANGED
@@ -31,7 +31,7 @@ export function schedule(job) {
31
31
  const waiting = job.tasks.some(t => ['QUEUED', 'WAITING'].includes(t.state));
32
32
  const failed = job.tasks.some(t => t.state === 'FAILED');
33
33
  const cancelled = job.tasks.every(t => t.state === 'CANCELLED');
34
- const next = failed ? 'FAILED' : active ? 'RUNNING' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
34
+ const next = active ? 'RUNNING' : failed ? 'FAILED' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
35
35
  if (job.state !== next) {
36
36
  assertJobTransition(job.state, next);
37
37
  job.state = next;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.22",
3
+ "version": "1.0.24",
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
@@ -0,0 +1,30 @@
1
+ """Bounded polling helpers for ChatGPT submit readiness."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from collections.abc import Callable, Mapping
6
+
7
+
8
+ def wait_until_stable(
9
+ read_state: Callable[[], Mapping[str, object]],
10
+ ready: Callable[[Mapping[str, object]], bool],
11
+ *,
12
+ timeout: float,
13
+ interval: float = 0.25,
14
+ stable_samples: int = 2,
15
+ phase: str,
16
+ ) -> Mapping[str, object]:
17
+ """Return only after the observable state is ready for stable_samples polls."""
18
+ deadline = time.monotonic() + timeout
19
+ stable = 0
20
+ last_state: Mapping[str, object] = {}
21
+ while time.monotonic() < deadline:
22
+ last_state = read_state()
23
+ if ready(last_state):
24
+ stable += 1
25
+ if stable >= stable_samples:
26
+ return last_state
27
+ else:
28
+ stable = 0
29
+ time.sleep(interval)
30
+ raise RuntimeError(f"Temporary Chat {phase} was not observed ready")
@@ -58,6 +58,21 @@ def _composer_text(selector):
58
58
  }})()""") or ""
59
59
 
60
60
 
61
+ def _wait_for_composer(timeout=30):
62
+ def read_selector():
63
+ try:
64
+ return _composer()
65
+ except RuntimeError:
66
+ return None
67
+
68
+ return wait_until_stable(
69
+ lambda: {"selector": read_selector()},
70
+ lambda state: bool(state["selector"]),
71
+ timeout=timeout,
72
+ phase="composer readiness",
73
+ )["selector"]
74
+
75
+
61
76
  def _same_text(observed, expected):
62
77
  normalize = lambda value: re.sub(r"\\s+", " ", value or "").strip()
63
78
  return normalize(expected) in normalize(observed)
@@ -73,6 +88,38 @@ def _attachment_names():
73
88
  .filter(Boolean))()""") or []
74
89
 
75
90
 
91
+ def _attachment_state():
92
+ return js(r"""(() => {
93
+ const names = [...document.querySelectorAll('button[aria-label^="Remove file"]')]
94
+ .map(b => (b.getAttribute('aria-label') || '').replace(/^Remove file\s+\d+:\s*/, ''))
95
+ .filter(Boolean);
96
+ const pending = [...document.querySelectorAll(
97
+ '[role="progressbar"], [aria-busy="true"], [data-state="loading"]'
98
+ )].some(e => {
99
+ const r=e.getBoundingClientRect();
100
+ return r.width > 0 && r.height > 0;
101
+ });
102
+ return {names, pending};
103
+ })()""") or {"names": [], "pending": True}
104
+
105
+
106
+ def _send_state():
107
+ return js(r"""(() => {
108
+ const b=document.querySelector('button[data-testid="send-button"],button[aria-label="Send prompt"]');
109
+ return {present: !!b, enabled: !!b && !b.disabled && b.getAttribute('aria-disabled') !== 'true'};
110
+ })()""") or {"present": False, "enabled": False}
111
+
112
+
113
+ def _wait_for_attachments(expected, timeout=45):
114
+ expected = set(expected)
115
+ wait_until_stable(
116
+ _attachment_state,
117
+ lambda state: expected.issubset(state["names"]) and not state["pending"],
118
+ timeout=timeout,
119
+ phase="attachment upload readiness",
120
+ )
121
+
122
+
76
123
  def _upload_files(paths):
77
124
  if not paths:
78
125
  return []
@@ -87,13 +134,17 @@ def _upload_files(paths):
87
134
  if not os.path.isfile(path):
88
135
  raise RuntimeError(f"attachment does not exist: {path}")
89
136
  upload_file(selector, path)
90
- expected = os.path.basename(path)
91
- deadline = time.time() + 20
92
- while time.time() < deadline and expected not in _attachment_names():
93
- time.sleep(.25)
94
- if expected not in _attachment_names():
95
- raise RuntimeError(f"attachment was not observed ready: {expected}")
96
- return [os.path.basename(path) for path in paths]
137
+ expected = [os.path.basename(path) for path in paths]
138
+ if expected:
139
+ # Presence is a selection signal; upload readiness is checked separately.
140
+ wait_until_stable(
141
+ _attachment_state,
142
+ lambda state: set(expected).issubset(state["names"]),
143
+ timeout=30,
144
+ phase="attachment presence",
145
+ )
146
+ _wait_for_attachments(expected)
147
+ return expected
97
148
 
98
149
 
99
150
  def _user_turns():
@@ -109,17 +160,41 @@ def _click_send():
109
160
  b.click(); return true;
110
161
  })()""")
111
162
  if not ok:
112
- raise RuntimeError("Temporary Chat Send prompt button was not observed ready")
163
+ raise RuntimeError("Temporary Chat send readiness changed before click")
164
+
165
+
166
+ def _wait_for_send_ready(selector, prompt, attachments):
167
+ expected = set(attachments)
168
+
169
+ def read_state():
170
+ return {
171
+ "text": _composer_text(selector),
172
+ "attachments": _attachment_state(),
173
+ "send": _send_state(),
174
+ }
175
+
176
+ wait_until_stable(
177
+ read_state,
178
+ lambda state: (
179
+ _normalized_text(prompt) in _normalized_text(state["text"])
180
+ and expected.issubset(state["attachments"]["names"])
181
+ and not state["attachments"]["pending"]
182
+ and state["send"]["enabled"]
183
+ ),
184
+ timeout=45 if expected else 15,
185
+ phase="send readiness",
186
+ )
113
187
 
114
188
 
115
189
  def _wait_user_turn(before_count, prompt, timeout=20):
116
190
  deadline = time.time() + timeout
117
191
  while time.time() < deadline:
118
192
  turns = _user_turns()
119
- if len(turns) > before_count and _normalized_text(prompt) in _normalized_text(turns[-1]["text"]):
120
- return turns[-1]
193
+ for turn in turns[before_count:]:
194
+ if _normalized_text(prompt) in _normalized_text(turn["text"]):
195
+ return turn
121
196
  time.sleep(.25)
122
- raise RuntimeError("Temporary Chat prompt did not become an observed user turn")
197
+ raise RuntimeError("Temporary Chat submission verification did not observe a new user turn")
123
198
 
124
199
 
125
200
  def _diagnostic_thread_id():
@@ -145,30 +220,59 @@ deadline = time.time() + 20
145
220
  while time.time() < deadline:
146
221
  if "temporary-chat=true" in page_info().get("url", ""):
147
222
  try:
148
- _composer()
149
223
  break
150
224
  except RuntimeError:
151
225
  pass
152
226
  time.sleep(.25)
153
227
  else:
154
- raise RuntimeError("Temporary Chat mode did not become ready")
228
+ raise RuntimeError("Temporary Chat composer readiness was not observed")
155
229
 
156
230
  attachments = _upload_files(CFG.get("file", []))
157
- selector = _composer()
231
+ selector = _wait_for_composer()
158
232
  before_count = len(_user_turns())
159
- fill_input(selector, CFG["prompt"], clear_first=True)
160
- if not _same_text(_composer_text(selector), CFG["prompt"]):
161
- info = js(f"""(() => {{
233
+ prompt = CFG["prompt"]
234
+ is_contenteditable = bool(js(f"""(() => {{
235
+ const e=document.querySelector({json.dumps(selector)});
236
+ return !!e && e.getAttribute('contenteditable') === 'true';
237
+ }})()"""))
238
+ if is_contenteditable:
239
+ cleared = js(f"""(() => {{
162
240
  const e=document.querySelector({json.dumps(selector)});
163
- const r=e.getBoundingClientRect();
164
- return {{x:r.x+r.width/2,y:r.y+r.height/2}};
241
+ if (!e) return false;
242
+ e.focus();
243
+ const sel=window.getSelection();
244
+ const range=document.createRange();
245
+ range.selectNodeContents(e);
246
+ sel.removeAllRanges();
247
+ sel.addRange(range);
248
+ document.execCommand('delete', false, null);
249
+ return true;
165
250
  }})()""")
166
- click_at_xy(info["x"], info["y"])
167
- press_key("CTRL+A")
168
- type_text(CFG["prompt"])
169
- if _normalized_text(CFG["prompt"]) not in _normalized_text(_composer_text(selector)):
170
- raise RuntimeError("Temporary Chat prompt was not observed in the composer")
251
+ if not cleared:
252
+ raise RuntimeError("Temporary Chat contenteditable composer could not be cleared")
253
+ for offset in range(0, len(prompt), 256):
254
+ chunk = prompt[offset:offset + 256]
255
+ inserted = js(f"""(() => {{
256
+ const e=document.querySelector({json.dumps(selector)});
257
+ if (!e) return false;
258
+ e.focus();
259
+ const ok=document.execCommand('insertText', false, {json.dumps(chunk)});
260
+ e.dispatchEvent(new InputEvent('input', {{bubbles:true,inputType:'insertText',data:{json.dumps(chunk)}}}));
261
+ return ok;
262
+ }})()""")
263
+ if not inserted:
264
+ raise RuntimeError(f"Temporary Chat composer rejected prompt chunk at offset {offset}")
265
+ else:
266
+ fill_input(selector, prompt, clear_first=True)
267
+
268
+ wait_until_stable(
269
+ lambda: {"text": _composer_text(selector)},
270
+ lambda state: _normalized_text(CFG["prompt"]) in _normalized_text(state["text"]),
271
+ timeout=30,
272
+ phase="composer readiness",
273
+ )
171
274
 
275
+ _wait_for_send_ready(selector, CFG["prompt"], attachments)
172
276
  _click_send()
173
277
  user_turn = _wait_user_turn(before_count, CFG["prompt"])
174
278
 
@@ -10,6 +10,7 @@ import tempfile
10
10
 
11
11
 
12
12
  BH_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_temporary_bh.py")
13
+ READINESS_HELPER = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_submit_readiness.py")
13
14
 
14
15
 
15
16
  def main() -> int:
@@ -23,7 +24,9 @@ def main() -> int:
23
24
  json.dump(vars(args), handle, ensure_ascii=False)
24
25
  config_path = handle.name
25
26
  try:
26
- code = open(BH_SCRIPT, encoding="utf-8").read().replace("__CFG_PATH__", config_path)
27
+ helper = open(READINESS_HELPER, encoding="utf-8").read()
28
+ code = helper + "\n" + open(BH_SCRIPT, encoding="utf-8").read()
29
+ code = code.replace("__CFG_PATH__", config_path)
27
30
  return subprocess.run(["browser-harness"], input=code, text=True, timeout=180).returncode
28
31
  finally:
29
32
  os.unlink(config_path)