relay-codex 0.0.0-stage → 0.4.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.
@@ -0,0 +1,663 @@
1
+ """Detached session supervision and attachment metadata. No public service."""
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import secrets
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import uuid
12
+
13
+ from settings import atomic_json
14
+ from pool import Pool, process_birth
15
+ from processes import spawn
16
+
17
+
18
+ def folder(state):
19
+ root = state / "services"
20
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
21
+ return root
22
+
23
+
24
+ def start(
25
+ state,
26
+ *,
27
+ name=None,
28
+ cwd=None,
29
+ first=None,
30
+ arguments=None,
31
+ resume=None,
32
+ prompt=None,
33
+ profile=None,
34
+ interactive=False,
35
+ binary=None,
36
+ ):
37
+ ident = uuid.uuid4().hex
38
+ root = folder(state)
39
+ spec = {
40
+ "id": ident,
41
+ "name": name or "Codex task",
42
+ "cwd": str(Path(cwd or os.getcwd()).resolve()),
43
+ "first": first,
44
+ "arguments": arguments or [],
45
+ "resume": resume,
46
+ "prompt": prompt,
47
+ "profile": profile,
48
+ "created": time.time(),
49
+ "state": "starting",
50
+ "interactive": interactive,
51
+ "binary": binary,
52
+ }
53
+ if not Path(spec["cwd"]).is_dir():
54
+ raise ValueError("Working directory does not exist")
55
+ if not interactive:
56
+ headless_options(spec["arguments"], spec["cwd"])
57
+ atomic_json(root / (ident + ".job.json"), spec)
58
+ process = spawn(
59
+ [
60
+ sys.executable,
61
+ str(Path(__file__).with_name("router.py")),
62
+ "_supervise",
63
+ ident,
64
+ ],
65
+ env={
66
+ **os.environ,
67
+ "CODEX_ACCOUNTS_STATE": str(state),
68
+ "RELAY_STATE": str(state),
69
+ },
70
+ stdin=subprocess.DEVNULL,
71
+ stdout=subprocess.DEVNULL,
72
+ stderr=subprocess.DEVNULL,
73
+ start_new_session=True,
74
+ cwd=spec["cwd"],
75
+ )
76
+ atomic_json(
77
+ root / (ident + ".supervisor.json"),
78
+ {"pid": process.pid, "birth": process_birth(process.pid)},
79
+ )
80
+ return ident
81
+
82
+
83
+ def resolve(state, prefix):
84
+ candidates = list(
85
+ {
86
+ p.name.split(".")[0]
87
+ for pattern in ["*.job.json", "*.connection.json"]
88
+ for p in folder(state).glob(pattern)
89
+ if p.name.startswith(prefix)
90
+ }
91
+ )
92
+ if len(candidates) != 1:
93
+ raise ValueError("Session prefix is missing or ambiguous")
94
+ return candidates[0]
95
+
96
+
97
+ async def supervise(state, ident):
98
+ root = folder(state)
99
+ spec_path = root / (ident + ".job.json")
100
+ delay = 1
101
+ crashes = []
102
+ while spec_path.exists():
103
+ spec = json.loads(spec_path.read_text())
104
+ if spec.get("state") in {"stopped", "removed"}:
105
+ return 0
106
+ process = await asyncio.create_subprocess_exec(
107
+ sys.executable,
108
+ str(Path(__file__).with_name("router.py")),
109
+ "_serve",
110
+ ident,
111
+ env={
112
+ **os.environ,
113
+ "CODEX_ACCOUNTS_STATE": str(state),
114
+ "RELAY_STATE": str(state),
115
+ },
116
+ stdin=asyncio.subprocess.DEVNULL,
117
+ stdout=asyncio.subprocess.DEVNULL,
118
+ stderr=asyncio.subprocess.DEVNULL,
119
+ )
120
+ code = await process.wait()
121
+ if code == 0:
122
+ return 0
123
+ pool = Pool(state)
124
+ pool.session = ident
125
+ pool.record("worker_restart", reason="worker_exit", delay=delay)
126
+ pool.close()
127
+ crashes = [t for t in crashes if time.time() - t < 120]
128
+ crashes.append(time.time())
129
+ if code == 2 or len(crashes) >= 5:
130
+ spec = json.loads(spec_path.read_text())
131
+ spec["state"] = "needs_attention"
132
+ atomic_json(spec_path, spec)
133
+ return 0
134
+ await asyncio.sleep(delay)
135
+ delay = min(delay * 2, 30)
136
+ return 0
137
+
138
+
139
+ async def attach(
140
+ state, prefix, binary, arguments=None, wait_forever=False, takeover=False
141
+ ):
142
+ from router import RouterError, clean_env
143
+
144
+ ident = resolve(state, prefix)
145
+ 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())
149
+ binary = spec.get("binary") or binary
150
+ 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)
161
+ 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]:
168
+ print(
169
+ "Waiting until "
170
+ + time.strftime("%m-%d %H:%M:%S", time.localtime(message[1]))
171
+ + " for account allowance.",
172
+ flush=True,
173
+ )
174
+ last_message = message
175
+ await asyncio.sleep(0.5)
176
+ if not meta.exists():
177
+ raise RouterError(
178
+ "Session is still waiting for an account. Check status; attach when it is ready."
179
+ )
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())
202
+ 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)
245
+
246
+
247
+ def headless_options(args, cwd):
248
+ """Common background launch options. Interactive run still uses native parsing."""
249
+ from launch import translate
250
+
251
+ 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:
271
+ raise ValueError(
272
+ "Background start does not accept "
273
+ + arg
274
+ + "; use --resume/--prompt or attach for interactive CLI options"
275
+ )
276
+ return cwd, thread, backend
277
+
278
+
279
+ async def serve(state, ident, binary):
280
+ from pool import file_lock
281
+ from router import RouterError
282
+
283
+ async with file_lock(state / "metadata.lock", shared=True):
284
+ try:
285
+ return await _serve(state, ident, binary)
286
+ except RouterError as error:
287
+ job = folder(state) / (ident + ".job.json")
288
+ spec = json.loads(job.read_text())
289
+ spec.update(state="needs_attention", last_error=str(error))
290
+ atomic_json(job, spec)
291
+ return 2
292
+
293
+
294
+ def restart(state, prefix):
295
+ from router import RouterError
296
+
297
+ ident = resolve(state, prefix)
298
+ root = folder(state)
299
+ pool = Pool(state)
300
+ try:
301
+ if any(s["id"] == ident for s in pool.detail()):
302
+ raise RouterError("That session is already running")
303
+ finally:
304
+ pool.close()
305
+ supervisor = root / (ident + ".supervisor.json")
306
+ if supervisor.exists():
307
+ value = json.loads(supervisor.read_text())
308
+ if Pool.alive(value["pid"]) and process_birth(value["pid"]) == value.get(
309
+ "birth"
310
+ ):
311
+ raise RouterError("That session is already recovering; check status")
312
+ path = root / (ident + ".job.json")
313
+ spec = json.loads(path.read_text())
314
+ if not Path(spec["cwd"]).is_dir():
315
+ raise RouterError("The project folder no longer exists")
316
+ spec.update(state="starting")
317
+ spec.pop("last_error", None)
318
+ atomic_json(path, spec)
319
+ process = spawn(
320
+ [
321
+ sys.executable,
322
+ str(Path(__file__).with_name("router.py")),
323
+ "_supervise",
324
+ ident,
325
+ ],
326
+ env={
327
+ **os.environ,
328
+ "CODEX_ACCOUNTS_STATE": str(state),
329
+ "RELAY_STATE": str(state),
330
+ },
331
+ stdin=subprocess.DEVNULL,
332
+ stdout=subprocess.DEVNULL,
333
+ stderr=subprocess.DEVNULL,
334
+ start_new_session=True,
335
+ cwd=spec["cwd"],
336
+ )
337
+ atomic_json(supervisor, {"pid": process.pid, "birth": process_birth(process.pid)})
338
+ return ident
339
+
340
+
341
+ async def _serve(state, ident, binary):
342
+ from websockets.asyncio.server import serve as ws_serve
343
+ from router import (
344
+ RPC,
345
+ Controller,
346
+ labels,
347
+ account_home,
348
+ transport_guard,
349
+ RouterError,
350
+ )
351
+ from broker import ClientAccount
352
+ import settings
353
+
354
+ root = folder(state)
355
+ spec_path = root / (ident + ".job.json")
356
+ spec = json.loads(spec_path.read_text())
357
+ binary = spec.get("binary") or binary
358
+ if spec.get("interactive"):
359
+ from launch import translate, backend_arguments
360
+
361
+ frontend, overrides = translate(spec.get("arguments", []))
362
+ cwd = spec["cwd"]
363
+ backend_args = backend_arguments(frontend)
364
+ else:
365
+ cwd, overrides, backend_args = headless_options(
366
+ spec.get("arguments", []), spec["cwd"]
367
+ )
368
+ pool = Pool(state)
369
+ pool.session = ident
370
+ pool.assign("")
371
+ pool.touch(name=spec["name"], cwd=cwd, state="starting", background=True)
372
+ enrolled = labels(state)
373
+ if not enrolled:
374
+ raise RouterError(
375
+ "Enroll at least one account before starting a background task"
376
+ )
377
+ accounts = [
378
+ ClientAccount(label, account_home(state, label), binary, pool)
379
+ for label in enrolled
380
+ ]
381
+ home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).resolve()
382
+ factory = lambda: RPC.start(binary, home, extra_args=backend_args)
383
+ backend = await factory()
384
+ controller = Controller(accounts, backend, pool=pool, report=lambda message: None)
385
+ with pool.db() as db:
386
+ saved = db.execute(
387
+ "SELECT details FROM session_details WHERE id=?", (ident,)
388
+ ).fetchone()
389
+ controller.paused = bool(json.loads(saved[0]).get("paused")) if saved else False
390
+ controller.background = True
391
+ controller.starting = True
392
+ controller.launch_overrides = overrides
393
+ controller.project = cwd
394
+ controller.profile = spec.get("profile")
395
+
396
+ async def reload_accounts(old):
397
+ current = controller.account.label
398
+ mapping = {a.label: a for a in old}
399
+ new = [
400
+ mapping.get(label)
401
+ or ClientAccount(label, account_home(state, label), binary, pool)
402
+ for label in labels(state)
403
+ ]
404
+ if new:
405
+ controller.index = next(
406
+ (i for i, a in enumerate(new) if a.label == current), 0
407
+ )
408
+ return new or old
409
+
410
+ controller.extra_accounts = reload_accounts
411
+ controller.preferences = settings.load(state, cwd, spec.get("profile"))
412
+ controller.retry_interval = controller.preferences["retry_interval"]
413
+ controller.retry_attempts = controller.preferences["retry_attempts"]
414
+ controller.interval = controller.preferences["unknown_reset_interval"]
415
+ backend.handler = controller.event
416
+ heartbeat = asyncio.create_task(controller.maintain())
417
+ connection = root / (ident + ".connection.json")
418
+ try:
419
+ init_result = await backend.initialize()
420
+ # Restore configuration-only recovery intent from a previous worker.
421
+ with pool.db() as db:
422
+ old = db.execute(
423
+ "SELECT thread,details FROM intents WHERE owner=?", (ident,)
424
+ ).fetchall()
425
+ old = [
426
+ row
427
+ for row in old
428
+ if json.loads(row[1]).get("role", "foreground") == "foreground"
429
+ ]
430
+ old_thread = (spec.get("resume") if old else None) or (
431
+ old[-1][0] if old else None
432
+ )
433
+ persisted = json.loads(next((r[1] for r in old if r[0] == old_thread), "{}"))
434
+ preferred = next(
435
+ (
436
+ i
437
+ for i, a in enumerate(accounts)
438
+ if a.label == (persisted.get("account_label") or spec.get("first"))
439
+ ),
440
+ None,
441
+ )
442
+ while not controller.stopping.is_set():
443
+ index, deadline = await controller.choose(
444
+ model=overrides.get("model")
445
+ or persisted.get("settings", {}).get("model"),
446
+ preferred=preferred,
447
+ )
448
+ if index is not None:
449
+ try:
450
+ await controller.login(index)
451
+ break
452
+ except (RouterError, asyncio.TimeoutError):
453
+ await controller.mark(index, None, "unavailable")
454
+ else:
455
+ pool.touch(state="waiting_for_reset", next_wake=deadline)
456
+ await controller.wait_until(deadline, controller.user_generation)
457
+ if controller.stopping.is_set():
458
+ return 0
459
+ controller.backend_factory = factory
460
+ thread_id = old_thread or spec.get("resume")
461
+ if thread_id:
462
+ params = {"threadId": thread_id, **overrides}
463
+ if old:
464
+ controller.merge_settings(thread_id, persisted.get("settings", {}))
465
+ params.update(
466
+ {
467
+ k: v
468
+ 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
+ }
478
+ }
479
+ )
480
+ result = await backend.call("thread/resume", params)
481
+ elif not spec.get("interactive"):
482
+ result = await backend.call("thread/start", {"cwd": cwd, **overrides})
483
+ thread_id = result["thread"]["id"]
484
+ else:
485
+ result = {}
486
+ if thread_id:
487
+ if result.get("thread", {}).get("canAcceptDirectInput") is False:
488
+ raise RouterError(
489
+ "Conversation is owned by another backend; no work was stopped"
490
+ )
491
+ if not pool.claim(thread_id):
492
+ raise RouterError(
493
+ "Conversation already belongs to another wrapper session"
494
+ )
495
+ controller.last_turn[thread_id] = {"threadId": thread_id}
496
+ controller.merge_settings(thread_id, result)
497
+ controller.merge_settings(thread_id, overrides)
498
+ controller.persist(thread_id, role="foreground")
499
+ controller.starting = False
500
+ pool.touch(
501
+ thread=thread_id,
502
+ threads=[thread_id] if thread_id else [],
503
+ state="idle",
504
+ model=controller.model_for(thread_id) if thread_id else None,
505
+ )
506
+ token = secrets.token_urlsafe(32)
507
+ meta = {"token": token}
508
+
509
+ async def handle(ws):
510
+ await controller.connect(ws, init_result)
511
+
512
+ async with ws_serve(
513
+ handle,
514
+ "127.0.0.1",
515
+ 0,
516
+ process_request=transport_guard(lambda: meta["token"]),
517
+ max_size=32 * 1024 * 1024,
518
+ ) as server:
519
+ port = server.sockets[0].getsockname()[1]
520
+ meta = {
521
+ "pid": os.getpid(),
522
+ "birth": process_birth(os.getpid()),
523
+ "token": token,
524
+ "url": f"ws://127.0.0.1:{port}",
525
+ "thread": thread_id,
526
+ "home": str(home),
527
+ "cwd": cwd,
528
+ }
529
+
530
+ async def selected(ident, values):
531
+ nonlocal thread_id
532
+ thread_id = ident
533
+ meta["thread"] = ident
534
+ atomic_json(connection, meta)
535
+ # Preserve the accepted conversation identity across worker restart.
536
+ latest = json.loads(spec_path.read_text())
537
+ latest["resume"] = ident
538
+ latest["state"] = "running"
539
+ spec.update(latest)
540
+ atomic_json(spec_path, spec)
541
+
542
+ controller.on_thread = selected
543
+
544
+ async def detach():
545
+ meta["token"] = secrets.token_urlsafe(32)
546
+ atomic_json(connection, meta)
547
+
548
+ controller.on_detach = detach
549
+ if spec.get("prompt") and not old_thread:
550
+ request = {
551
+ "id": "headless-start",
552
+ "method": "turn/start",
553
+ "params": {
554
+ "threadId": thread_id,
555
+ "input": [
556
+ {
557
+ "type": "text",
558
+ "text": spec["prompt"],
559
+ "text_elements": [],
560
+ }
561
+ ],
562
+ },
563
+ }
564
+ # Preserve the initial prompt privately until native acceptance is
565
+ # known. Do not put it in diagnostic events or status output.
566
+ await controller.forward(request, init_result)
567
+ if controller.pending_intents.get(thread_id, {}).get("accepted"):
568
+ spec["prompt"] = None
569
+ spec["resume"] = thread_id
570
+ spec["state"] = "running"
571
+ atomic_json(spec_path, spec)
572
+ elif old_thread:
573
+ state_name, turn = await controller.reconcile(
574
+ thread_id,
575
+ baseline=persisted.get("baseline")
576
+ if not persisted.get("accepted")
577
+ else None,
578
+ )
579
+ if state_name == "completed":
580
+ controller.persist(
581
+ thread_id,
582
+ state="completed",
583
+ accepted=True,
584
+ active_items=False,
585
+ next_wake=None,
586
+ kind=None,
587
+ episode=None,
588
+ )
589
+ elif persisted.get("active_items") or not persisted.get("accepted"):
590
+ controller.paused_uncertain.add(thread_id)
591
+ controller.note(
592
+ "needs_attention",
593
+ reason="worker_restarted_during_uncertain_action",
594
+ thread=thread_id,
595
+ )
596
+ elif persisted.get("state") in {"retry_scheduled", "waiting_for_reset"}:
597
+ controller.recovery = asyncio.create_task(
598
+ controller.restore_recovery(thread_id, persisted)
599
+ )
600
+ elif state_name == "failed" and turn:
601
+ from pool import failure_kind
602
+
603
+ kind = failure_kind(
604
+ (turn.get("error") or {}).get("codexErrorInfo"),
605
+ (turn.get("error") or {}).get("message"),
606
+ )
607
+ if kind:
608
+ controller.recovery = asyncio.create_task(
609
+ controller.recover(
610
+ thread_id, controller.user_generation, kind
611
+ )
612
+ )
613
+ elif persisted.get("state") not in {"completed", "idle"}:
614
+ controller.recovery = asyncio.create_task(
615
+ controller.continue_thread(
616
+ thread_id, controller.user_generation
617
+ )
618
+ )
619
+ # Publish the attachment only after accepted work and uncertainty
620
+ # have been reconciled. A frontend must not race recovery setup.
621
+ atomic_json(connection, meta)
622
+ await controller.stopping.wait()
623
+ spec["state"] = "stopped"
624
+ atomic_json(spec_path, spec)
625
+ return 0
626
+ finally:
627
+ controller.stopping.set()
628
+ heartbeat.cancel()
629
+ for task in (controller.recovery, controller.restart_task):
630
+ if task:
631
+ task.cancel()
632
+ await asyncio.gather(
633
+ heartbeat,
634
+ *[t for t in (controller.recovery, controller.restart_task) if t],
635
+ return_exceptions=True,
636
+ )
637
+ await controller.backend.close()
638
+ pool.close()
639
+ connection.unlink(missing_ok=True)
640
+
641
+
642
+ async def send_control(state, ident, op, params=None):
643
+ from router import RouterError
644
+
645
+ pool = Pool(state)
646
+ 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")
662
+ finally:
663
+ pool.close()