relay-codex 0.4.1 → 0.5.1

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/python/service.py CHANGED
@@ -11,6 +11,7 @@ import time
11
11
  import uuid
12
12
 
13
13
  from settings import atomic_json
14
+ from launch import RESUME_FIELDS
14
15
  from pool import Pool, process_birth
15
16
  from processes import spawn
16
17
 
@@ -49,11 +50,18 @@ def start(
49
50
  "state": "starting",
50
51
  "interactive": interactive,
51
52
  "binary": binary,
53
+ "relay_runtime": str(Path(__file__).parent.resolve()),
52
54
  }
53
55
  if not Path(spec["cwd"]).is_dir():
54
56
  raise ValueError("Working directory does not exist")
55
57
  if not interactive:
56
- headless_options(spec["arguments"], spec["cwd"])
58
+ effective_cwd, _, _ = headless_options(spec["arguments"], spec["cwd"])
59
+ from launch import frontend_arguments
60
+
61
+ spec["arguments"] = frontend_arguments(spec["arguments"], spec["cwd"])
62
+ spec["cwd"] = effective_cwd
63
+ if not Path(effective_cwd).is_dir():
64
+ raise ValueError("Working directory does not exist")
57
65
  atomic_json(root / (ident + ".job.json"), spec)
58
66
  process = spawn(
59
67
  [
@@ -137,34 +145,53 @@ async def supervise(state, ident):
137
145
 
138
146
 
139
147
  async def attach(
140
- state, prefix, binary, arguments=None, wait_forever=False, takeover=False
148
+ state, prefix, binary, arguments=None, wait_forever=False, takeover=False, cwd=None
141
149
  ):
142
150
  from router import RouterError, clean_env
151
+ from launch import translate, frontend_arguments
143
152
 
144
153
  ident = resolve(state, prefix)
145
154
  root = folder(state)
146
- # Frontend and backend must keep the same native runtime when the global CLI
147
- # is updated while this task remains alive.
148
- spec = json.loads((root / (ident + ".job.json")).read_text())
155
+ spec_path = root / (ident + ".job.json")
156
+ spec = json.loads(spec_path.read_text())
149
157
  binary = spec.get("binary") or binary
150
158
  meta = root / (ident + ".connection.json")
151
- deadline = time.monotonic() + 60
152
- last_message = None
153
- while not meta.exists() and (wait_forever or time.monotonic() < deadline):
154
- spec = json.loads((root / (ident + ".job.json")).read_text())
155
- if spec.get("state") in {"stopped", "needs_attention"}:
156
- raise RouterError(
157
- "Session stopped or needs attention. Check events and doctor."
158
- )
159
- if wait_forever:
160
- pool = Pool(state)
159
+ invocation_cwd = cwd
160
+ reconnect = False
161
+
162
+ async def connection(previous=None):
163
+ deadline = time.monotonic() + 60
164
+ last_message = None
165
+ while wait_forever or time.monotonic() < deadline:
166
+ job = json.loads(spec_path.read_text())
167
+ if job.get("state") in {"stopped", "needs_attention", "removed"}:
168
+ raise RouterError(
169
+ "Session stopped or needs attention. Check status and events."
170
+ )
161
171
  try:
162
- detail = next((s for s in pool.detail() if s["id"] == ident), {})
163
- finally:
164
- pool.close()
165
- message = (detail.get("state"), detail.get("next_wake"))
166
- if message != last_message:
167
- if message[1]:
172
+ candidate = json.loads(meta.read_text())
173
+ if (
174
+ Pool.alive(candidate["pid"])
175
+ and candidate.get("birth") == process_birth(candidate["pid"])
176
+ and (
177
+ not previous
178
+ or any(
179
+ candidate.get(k) != previous.get(k)
180
+ for k in ("pid", "birth", "token")
181
+ )
182
+ )
183
+ ):
184
+ return candidate
185
+ except (FileNotFoundError, json.JSONDecodeError):
186
+ pass
187
+ if wait_forever:
188
+ pool = Pool(state)
189
+ try:
190
+ detail = next((s for s in pool.detail() if s["id"] == ident), {})
191
+ finally:
192
+ pool.close()
193
+ message = (detail.get("state"), detail.get("next_wake"))
194
+ if message != last_message and message[1]:
168
195
  print(
169
196
  "Waiting until "
170
197
  + time.strftime("%m-%d %H:%M:%S", time.localtime(message[1]))
@@ -172,108 +199,116 @@ async def attach(
172
199
  flush=True,
173
200
  )
174
201
  last_message = message
175
- await asyncio.sleep(0.5)
176
- if not meta.exists():
202
+ await asyncio.sleep(0.5)
177
203
  raise RouterError(
178
- "Session is still waiting for an account. Check status; attach when it is ready."
204
+ "Session is still recovering or waiting for an account. Check status and attach when ready."
179
205
  )
180
- value = json.loads(meta.read_text())
181
- if not Pool.alive(value["pid"]) or value.get("birth") != process_birth(
182
- value["pid"]
183
- ):
184
- raise RouterError(
185
- "This session connection is stale. The supervisor is recovering it; check status."
186
- )
187
- env = clean_env(Path(value["home"]))
188
- env["RELAY_TRANSPORT_TOKEN"] = value["token"]
189
- # Permission settings belong to the server's persisted task. They are not
190
- # passed as unsupported resume overrides to the native remote frontend.
191
- args = arguments or ["resume", value["thread"]]
192
- if not arguments and not value.get("thread"):
193
- args = []
194
- from launch import translate
195
-
196
- args, overrides = translate(args)
197
- if overrides:
198
- await send_control(state, ident, "permissions", overrides)
199
- if takeover:
200
- await send_control(state, ident, "detach")
201
- value = json.loads(meta.read_text())
206
+
207
+ value = await connection()
208
+ while True:
209
+ env = clean_env(Path(value["home"]))
202
210
  env["RELAY_TRANSPORT_TOKEN"] = value["token"]
203
- proc = await asyncio.create_subprocess_exec(
204
- binary,
205
- "--remote",
206
- value["url"],
207
- "--remote-auth-token-env",
208
- "RELAY_TRANSPORT_TOKEN",
209
- *args,
210
- env=env,
211
- cwd=value.get("cwd") or None,
212
- )
213
- waiter = asyncio.create_task(proc.wait())
214
- try:
215
- while proc.returncode is None:
216
- try:
217
- return await asyncio.wait_for(asyncio.shield(waiter), 1)
218
- except asyncio.TimeoutError:
219
- pass
220
- try:
221
- latest = json.loads(meta.read_text())
222
- if (
223
- latest.get("pid") == value["pid"]
224
- and latest.get("token") != value["token"]
225
- ):
226
- # A takeover moved the attachment. Prevent the old native
227
- # frontend from reconnecting forever with its expired token.
228
- return 0
229
- except FileNotFoundError:
230
- if (
231
- json.loads((root / (ident + ".job.json")).read_text()).get("state")
232
- == "stopped"
233
- ):
234
- return 0
235
- return proc.returncode
236
- finally:
237
- if proc.returncode is None:
238
- proc.terminate()
239
- try:
240
- await asyncio.wait_for(asyncio.shield(waiter), 5)
241
- except asyncio.TimeoutError:
242
- proc.kill()
243
- await proc.wait()
244
- await asyncio.gather(waiter, return_exceptions=True)
211
+ if reconnect:
212
+ args = ["resume", value["thread"]] if value.get("thread") else []
213
+ if "--no-alt-screen" in (arguments or []):
214
+ args.append("--no-alt-screen")
215
+ invocation_cwd = value.get("cwd") or invocation_cwd
216
+ else:
217
+ args = (
218
+ arguments
219
+ if arguments is not None
220
+ else (["resume", value["thread"]] if value.get("thread") else [])
221
+ )
222
+ invocation_cwd = invocation_cwd or value.get("cwd") or os.getcwd()
223
+ args, overrides = translate(frontend_arguments(args, invocation_cwd))
224
+ if overrides:
225
+ await send_control(state, ident, "permissions", overrides)
226
+ if takeover and not reconnect:
227
+ await send_control(state, ident, "detach")
228
+ value = json.loads(meta.read_text())
229
+ env["RELAY_TRANSPORT_TOKEN"] = value["token"]
230
+ proc = await asyncio.create_subprocess_exec(
231
+ binary,
232
+ "--remote",
233
+ value["url"],
234
+ "--remote-auth-token-env",
235
+ "RELAY_TRANSPORT_TOKEN",
236
+ *args,
237
+ env=env,
238
+ cwd=invocation_cwd,
239
+ )
240
+ waiter = asyncio.create_task(proc.wait())
241
+ replacement = False
242
+ missing = False
243
+ try:
244
+ while True:
245
+ try:
246
+ latest = json.loads(meta.read_text())
247
+ if latest.get("pid") != value["pid"] or latest.get(
248
+ "birth"
249
+ ) != value.get("birth"):
250
+ replacement = True
251
+ break
252
+ if latest.get("token") != value["token"]:
253
+ # Explicit takeover closes the old terminal, not the task.
254
+ return 0
255
+ except (FileNotFoundError, json.JSONDecodeError):
256
+ missing = True
257
+ if json.loads(spec_path.read_text()).get("state") in {
258
+ "stopped",
259
+ "needs_attention",
260
+ }:
261
+ return 0
262
+ try:
263
+ result = await asyncio.wait_for(asyncio.shield(waiter), 0.5)
264
+ if missing:
265
+ replacement = True
266
+ break
267
+ return result
268
+ except asyncio.TimeoutError:
269
+ pass
270
+ finally:
271
+ if proc.returncode is None:
272
+ proc.terminate()
273
+ try:
274
+ await asyncio.wait_for(asyncio.shield(waiter), 5)
275
+ except asyncio.TimeoutError:
276
+ proc.kill()
277
+ await proc.wait()
278
+ await asyncio.gather(waiter, return_exceptions=True)
279
+ if replacement:
280
+ print("Reconnecting to the recovered task…", flush=True)
281
+ value = await connection(previous=value)
282
+ reconnect = True
245
283
 
246
284
 
247
285
  def headless_options(args, cwd):
248
- """Common background launch options. Interactive run still uses native parsing."""
249
- from launch import translate
286
+ """Use the same native option boundaries for background and interactive work."""
287
+ from launch import translate, tokens, working_directory, backend_arguments
250
288
 
251
289
  frontend, thread = translate(args)
252
- backend = []
253
- i = 0
254
- while i < len(frontend):
255
- arg = frontend[i]
256
- if arg in ("-m", "--model", "-C", "--cd", "-c", "--config", "-p", "--profile"):
257
- if i + 1 == len(frontend):
258
- raise ValueError(arg + " requires a value")
259
- value = frontend[i + 1]
260
- i += 2
261
- if arg in ("-m", "--model"):
262
- thread["model"] = value
263
- elif arg in ("-C", "--cd"):
264
- cwd = str(Path(value).resolve())
265
- else:
266
- backend.extend([arg, value])
267
- elif arg == "--search":
268
- backend.extend(["-c", 'web_search="live"'])
269
- i += 1
270
- else:
290
+ cwd = working_directory(frontend, cwd)
291
+ supported = {
292
+ "--model",
293
+ "--cd",
294
+ "--config",
295
+ "--profile",
296
+ "--enable",
297
+ "--disable",
298
+ "--search",
299
+ "--strict-config",
300
+ "--add-dir",
301
+ }
302
+ for name, value, raw in tokens(frontend):
303
+ if name not in supported:
271
304
  raise ValueError(
272
305
  "Background start does not accept "
273
- + arg
274
- + "; use --resume/--prompt or attach for interactive CLI options"
306
+ + raw[0]
307
+ + "; use --resume/--prompt or relay run -- for native CLI commands"
275
308
  )
276
- return cwd, thread, backend
309
+ if name == "--model":
310
+ thread["model"] = value
311
+ return cwd, thread, backend_arguments(args)
277
312
 
278
313
 
279
314
  async def serve(state, ident, binary):
@@ -283,10 +318,15 @@ async def serve(state, ident, binary):
283
318
  async with file_lock(state / "metadata.lock", shared=True):
284
319
  try:
285
320
  return await _serve(state, ident, binary)
286
- except RouterError as error:
321
+ except (RouterError, ValueError, OSError) as error:
287
322
  job = folder(state) / (ident + ".job.json")
288
323
  spec = json.loads(job.read_text())
289
- spec.update(state="needs_attention", last_error=str(error))
324
+ spec.update(
325
+ state="needs_attention",
326
+ last_error=str(error)
327
+ if isinstance(error, RouterError)
328
+ else "Launch settings or project files are unavailable. Check relay explain and relay doctor.",
329
+ )
290
330
  atomic_json(job, spec)
291
331
  return 2
292
332
 
@@ -360,7 +400,7 @@ async def _serve(state, ident, binary):
360
400
 
361
401
  frontend, overrides = translate(spec.get("arguments", []))
362
402
  cwd = spec["cwd"]
363
- backend_args = backend_arguments(frontend)
403
+ backend_args = backend_arguments(spec.get("arguments", []))
364
404
  else:
365
405
  cwd, overrides, backend_args = headless_options(
366
406
  spec.get("arguments", []), spec["cwd"]
@@ -390,6 +430,14 @@ async def _serve(state, ident, binary):
390
430
  controller.background = True
391
431
  controller.starting = True
392
432
  controller.launch_overrides = overrides
433
+ from launch import profile_config
434
+
435
+ private_config = profile_config(spec.get("arguments", []))
436
+ if private_config:
437
+ controller.launch_overrides["config"] = private_config
438
+ from launch import additional_roots
439
+
440
+ controller.additional_roots = additional_roots(spec.get("arguments", []), cwd)
393
441
  controller.project = cwd
394
442
  controller.profile = spec.get("profile")
395
443
 
@@ -417,6 +465,16 @@ async def _serve(state, ident, binary):
417
465
  connection = root / (ident + ".connection.json")
418
466
  try:
419
467
  init_result = await backend.initialize()
468
+ if controller.additional_roots:
469
+ effective = await backend.call(
470
+ "config/read", {"cwd": cwd, "includeLayers": False}
471
+ )
472
+ configured = (
473
+ effective.get("config", {}).get("sandbox_workspace_write") or {}
474
+ ).get("writable_roots", [])
475
+ controller.additional_roots = list(
476
+ dict.fromkeys([*configured, *controller.additional_roots])
477
+ )
420
478
  # Restore configuration-only recovery intent from a previous worker.
421
479
  with pool.db() as db:
422
480
  old = db.execute(
@@ -466,15 +524,7 @@ async def _serve(state, ident, binary):
466
524
  {
467
525
  k: v
468
526
  for k, v in controller.thread_settings[thread_id].items()
469
- if k
470
- in {
471
- "model",
472
- "cwd",
473
- "approvalPolicy",
474
- "sandbox",
475
- "permissions",
476
- "serviceTier",
477
- }
527
+ if k in RESUME_FIELDS
478
528
  }
479
529
  )
480
530
  result = await backend.call("thread/resume", params)
@@ -531,10 +581,24 @@ async def _serve(state, ident, binary):
531
581
  nonlocal thread_id
532
582
  thread_id = ident
533
583
  meta["thread"] = ident
584
+ effective_cwd = values.get("cwd")
585
+ if effective_cwd:
586
+ meta["cwd"] = effective_cwd
587
+ controller.project = effective_cwd
588
+ controller.preferences = settings.load(
589
+ state, effective_cwd, controller.profile
590
+ )
591
+ controller.retry_interval = controller.preferences["retry_interval"]
592
+ controller.retry_attempts = controller.preferences["retry_attempts"]
593
+ controller.interval = controller.preferences[
594
+ "unknown_reset_interval"
595
+ ]
534
596
  atomic_json(connection, meta)
535
597
  # Preserve the accepted conversation identity across worker restart.
536
598
  latest = json.loads(spec_path.read_text())
537
599
  latest["resume"] = ident
600
+ if effective_cwd:
601
+ latest["cwd"] = effective_cwd
538
602
  latest["state"] = "running"
539
603
  spec.update(latest)
540
604
  atomic_json(spec_path, spec)
@@ -586,8 +650,12 @@ async def _serve(state, ident, binary):
586
650
  kind=None,
587
651
  episode=None,
588
652
  )
589
- elif persisted.get("active_items") or not persisted.get("accepted"):
590
- controller.paused_uncertain.add(thread_id)
653
+ elif (
654
+ persisted.get("review_required")
655
+ or persisted.get("active_items")
656
+ or not persisted.get("accepted")
657
+ ):
658
+ controller.hold_for_review(thread_id)
591
659
  controller.note(
592
660
  "needs_attention",
593
661
  reason="worker_restarted_during_uncertain_action",
@@ -634,30 +702,36 @@ async def _serve(state, ident, binary):
634
702
  *[t for t in (controller.recovery, controller.restart_task) if t],
635
703
  return_exceptions=True,
636
704
  )
705
+ if controller.awake_process and controller.awake_process.returncode is None:
706
+ controller.awake_process.terminate()
707
+ await controller.awake_process.wait()
637
708
  await controller.backend.close()
638
709
  pool.close()
639
710
  connection.unlink(missing_ok=True)
640
711
 
641
712
 
642
- async def send_control(state, ident, op, params=None):
713
+ async def wait_control(pool, command, timeout=15):
643
714
  from router import RouterError
644
715
 
716
+ deadline = time.monotonic() + timeout
717
+ while time.monotonic() < deadline:
718
+ with pool.db() as db:
719
+ row = db.execute(
720
+ "SELECT status FROM commands WHERE id=?", (command,)
721
+ ).fetchone()
722
+ if row and row[0] == "done":
723
+ return
724
+ if row and row[0] == "failed":
725
+ raise RouterError(
726
+ "Session rejected that action. Check status and events; no change was assumed."
727
+ )
728
+ await asyncio.sleep(0.1)
729
+ raise RouterError("Session control timed out; check status before retrying.")
730
+
731
+
732
+ async def send_control(state, ident, op, params=None):
645
733
  pool = Pool(state)
646
734
  try:
647
- command = pool.command(ident, op, params)
648
- deadline = time.monotonic() + 10
649
- while time.monotonic() < deadline:
650
- with pool.db() as db:
651
- row = db.execute(
652
- "SELECT status FROM commands WHERE id=?", (command,)
653
- ).fetchone()
654
- if row and row[0] == "done":
655
- return
656
- if row and row[0] == "failed":
657
- raise RouterError(
658
- "Session is busy or rejected that control; no permission change was assumed"
659
- )
660
- await asyncio.sleep(0.1)
661
- raise RouterError("Session control timed out; check status before retrying")
735
+ await wait_control(pool, pool.command(ident, op, params), timeout=15)
662
736
  finally:
663
737
  pool.close()
@@ -23,6 +23,7 @@ DEFAULTS = {
23
23
  "offline_retry": 30,
24
24
  "background_on_disconnect": False,
25
25
  "broker_idle_seconds": 600,
26
+ "keep_awake": False,
26
27
  }
27
28
 
28
29