agents-relay 1.0.23 → 1.0.25

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}`);
@@ -61,6 +61,7 @@ export class Reconciler {
61
61
  terminalTimers = new Map();
62
62
  terminalExitGraceTimers = new Map();
63
63
  continuationLive = new Set();
64
+ launchFailureClaims = new Set();
64
65
  terminalWaiters = new Map();
65
66
  reconciling = false;
66
67
  constructor(store, options) {
@@ -202,7 +203,7 @@ export class Reconciler {
202
203
  return;
203
204
  const id = `final-review-${headSha.slice(0, 12)}`;
204
205
  const now = new Date().toISOString();
205
- const task = { jobId: job.id, id, kind: 'work', priority: job.priority, projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: job.tasks.filter(item => item.agentName !== 'reviewer').map(item => item.id), capabilities: ['review'], adapter: this.options.reviewer?.adapter ?? 'codex', input: `Review PR #${job.prNumber} at head SHA ${headSha}. Objective: ${job.objective ?? job.description ?? job.title}. First publish task.started as the worker start acknowledgement with data.worker_owned=true, 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}. Do not modify code and do not merge.`, output: { kind: 'task_pr' }, routing: this.options.reviewer?.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 ?? 300000, createdAt: now, updatedAt: now };
206
+ const task = { jobId: job.id, id, kind: 'work', priority: job.priority, projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: job.tasks.filter(item => item.agentName !== 'reviewer' && !this.isSupersededTask(item)).map(item => item.id), capabilities: ['review'], adapter: this.options.reviewer?.adapter ?? 'codex', input: `Review PR #${job.prNumber} at head SHA ${headSha}. Objective: ${job.objective ?? job.description ?? job.title}. First publish task.started as the worker start acknowledgement with data.worker_owned=true, 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}. Do not modify code and do not merge.`, output: { kind: 'task_pr' }, routing: this.options.reviewer?.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 ?? 300000, createdAt: now, updatedAt: now };
206
207
  await this.store.appendTask(task);
207
208
  job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
208
209
  job.state = 'RUNNING';
@@ -215,8 +216,11 @@ export class Reconciler {
215
216
  if (reviewTask && (this.options.maxConcurrent ?? 4) > this.live.size + this.plannerLive.size)
216
217
  await this.launch(ready, reviewTask);
217
218
  }
219
+ isSupersededTask(task) {
220
+ return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
221
+ }
218
222
  preReviewGatesPass(job) {
219
- return job.tasks.filter(task => task.agentName !== 'reviewer').every(task => task.state === 'SUCCEEDED');
223
+ return job.tasks.filter(task => task.agentName !== 'reviewer' && !this.isSupersededTask(task)).every(task => task.state === 'SUCCEEDED');
220
224
  }
221
225
  async applyReviewEvent(event) {
222
226
  const job = await this.store.load(event.job_id);
@@ -507,6 +511,12 @@ export class Reconciler {
507
511
  live.workerStarted = true;
508
512
  }
509
513
  async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
514
+ const claim = executionId ?? `${jobId}:${taskId}`;
515
+ if (this.launchFailureClaims.has(claim))
516
+ return;
517
+ this.launchFailureClaims.add(claim);
518
+ const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
519
+ cleanup.unref();
510
520
  const job = await this.store.load(jobId);
511
521
  const task = job.tasks.find(item => item.id === taskId);
512
522
  if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
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.23",
3
+ "version": "1.0.25",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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)