relay-codex 0.0.0-stage → 0.5.0

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,737 @@
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 launch import RESUME_FIELDS
15
+ from pool import Pool, process_birth
16
+ from processes import spawn
17
+
18
+
19
+ def folder(state):
20
+ root = state / "services"
21
+ root.mkdir(parents=True, exist_ok=True, mode=0o700)
22
+ return root
23
+
24
+
25
+ def start(
26
+ state,
27
+ *,
28
+ name=None,
29
+ cwd=None,
30
+ first=None,
31
+ arguments=None,
32
+ resume=None,
33
+ prompt=None,
34
+ profile=None,
35
+ interactive=False,
36
+ binary=None,
37
+ ):
38
+ ident = uuid.uuid4().hex
39
+ root = folder(state)
40
+ spec = {
41
+ "id": ident,
42
+ "name": name or "Codex task",
43
+ "cwd": str(Path(cwd or os.getcwd()).resolve()),
44
+ "first": first,
45
+ "arguments": arguments or [],
46
+ "resume": resume,
47
+ "prompt": prompt,
48
+ "profile": profile,
49
+ "created": time.time(),
50
+ "state": "starting",
51
+ "interactive": interactive,
52
+ "binary": binary,
53
+ "relay_runtime": str(Path(__file__).parent.resolve()),
54
+ }
55
+ if not Path(spec["cwd"]).is_dir():
56
+ raise ValueError("Working directory does not exist")
57
+ if not interactive:
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")
65
+ atomic_json(root / (ident + ".job.json"), spec)
66
+ process = spawn(
67
+ [
68
+ sys.executable,
69
+ str(Path(__file__).with_name("router.py")),
70
+ "_supervise",
71
+ ident,
72
+ ],
73
+ env={
74
+ **os.environ,
75
+ "CODEX_ACCOUNTS_STATE": str(state),
76
+ "RELAY_STATE": str(state),
77
+ },
78
+ stdin=subprocess.DEVNULL,
79
+ stdout=subprocess.DEVNULL,
80
+ stderr=subprocess.DEVNULL,
81
+ start_new_session=True,
82
+ cwd=spec["cwd"],
83
+ )
84
+ atomic_json(
85
+ root / (ident + ".supervisor.json"),
86
+ {"pid": process.pid, "birth": process_birth(process.pid)},
87
+ )
88
+ return ident
89
+
90
+
91
+ def resolve(state, prefix):
92
+ candidates = list(
93
+ {
94
+ p.name.split(".")[0]
95
+ for pattern in ["*.job.json", "*.connection.json"]
96
+ for p in folder(state).glob(pattern)
97
+ if p.name.startswith(prefix)
98
+ }
99
+ )
100
+ if len(candidates) != 1:
101
+ raise ValueError("Session prefix is missing or ambiguous")
102
+ return candidates[0]
103
+
104
+
105
+ async def supervise(state, ident):
106
+ root = folder(state)
107
+ spec_path = root / (ident + ".job.json")
108
+ delay = 1
109
+ crashes = []
110
+ while spec_path.exists():
111
+ spec = json.loads(spec_path.read_text())
112
+ if spec.get("state") in {"stopped", "removed"}:
113
+ return 0
114
+ process = await asyncio.create_subprocess_exec(
115
+ sys.executable,
116
+ str(Path(__file__).with_name("router.py")),
117
+ "_serve",
118
+ ident,
119
+ env={
120
+ **os.environ,
121
+ "CODEX_ACCOUNTS_STATE": str(state),
122
+ "RELAY_STATE": str(state),
123
+ },
124
+ stdin=asyncio.subprocess.DEVNULL,
125
+ stdout=asyncio.subprocess.DEVNULL,
126
+ stderr=asyncio.subprocess.DEVNULL,
127
+ )
128
+ code = await process.wait()
129
+ if code == 0:
130
+ return 0
131
+ pool = Pool(state)
132
+ pool.session = ident
133
+ pool.record("worker_restart", reason="worker_exit", delay=delay)
134
+ pool.close()
135
+ crashes = [t for t in crashes if time.time() - t < 120]
136
+ crashes.append(time.time())
137
+ if code == 2 or len(crashes) >= 5:
138
+ spec = json.loads(spec_path.read_text())
139
+ spec["state"] = "needs_attention"
140
+ atomic_json(spec_path, spec)
141
+ return 0
142
+ await asyncio.sleep(delay)
143
+ delay = min(delay * 2, 30)
144
+ return 0
145
+
146
+
147
+ async def attach(
148
+ state, prefix, binary, arguments=None, wait_forever=False, takeover=False, cwd=None
149
+ ):
150
+ from router import RouterError, clean_env
151
+ from launch import translate, frontend_arguments
152
+
153
+ ident = resolve(state, prefix)
154
+ root = folder(state)
155
+ spec_path = root / (ident + ".job.json")
156
+ spec = json.loads(spec_path.read_text())
157
+ binary = spec.get("binary") or binary
158
+ meta = root / (ident + ".connection.json")
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
+ )
171
+ try:
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]:
195
+ print(
196
+ "Waiting until "
197
+ + time.strftime("%m-%d %H:%M:%S", time.localtime(message[1]))
198
+ + " for account allowance.",
199
+ flush=True,
200
+ )
201
+ last_message = message
202
+ await asyncio.sleep(0.5)
203
+ raise RouterError(
204
+ "Session is still recovering or waiting for an account. Check status and attach when ready."
205
+ )
206
+
207
+ value = await connection()
208
+ while True:
209
+ env = clean_env(Path(value["home"]))
210
+ env["RELAY_TRANSPORT_TOKEN"] = value["token"]
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
283
+
284
+
285
+ def headless_options(args, cwd):
286
+ """Use the same native option boundaries for background and interactive work."""
287
+ from launch import translate, tokens, working_directory, backend_arguments
288
+
289
+ frontend, thread = translate(args)
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:
304
+ raise ValueError(
305
+ "Background start does not accept "
306
+ + raw[0]
307
+ + "; use --resume/--prompt or relay run -- for native CLI commands"
308
+ )
309
+ if name == "--model":
310
+ thread["model"] = value
311
+ return cwd, thread, backend_arguments(args)
312
+
313
+
314
+ async def serve(state, ident, binary):
315
+ from pool import file_lock
316
+ from router import RouterError
317
+
318
+ async with file_lock(state / "metadata.lock", shared=True):
319
+ try:
320
+ return await _serve(state, ident, binary)
321
+ except (RouterError, ValueError, OSError) as error:
322
+ job = folder(state) / (ident + ".job.json")
323
+ spec = json.loads(job.read_text())
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
+ )
330
+ atomic_json(job, spec)
331
+ return 2
332
+
333
+
334
+ def restart(state, prefix):
335
+ from router import RouterError
336
+
337
+ ident = resolve(state, prefix)
338
+ root = folder(state)
339
+ pool = Pool(state)
340
+ try:
341
+ if any(s["id"] == ident for s in pool.detail()):
342
+ raise RouterError("That session is already running")
343
+ finally:
344
+ pool.close()
345
+ supervisor = root / (ident + ".supervisor.json")
346
+ if supervisor.exists():
347
+ value = json.loads(supervisor.read_text())
348
+ if Pool.alive(value["pid"]) and process_birth(value["pid"]) == value.get(
349
+ "birth"
350
+ ):
351
+ raise RouterError("That session is already recovering; check status")
352
+ path = root / (ident + ".job.json")
353
+ spec = json.loads(path.read_text())
354
+ if not Path(spec["cwd"]).is_dir():
355
+ raise RouterError("The project folder no longer exists")
356
+ spec.update(state="starting")
357
+ spec.pop("last_error", None)
358
+ atomic_json(path, spec)
359
+ process = spawn(
360
+ [
361
+ sys.executable,
362
+ str(Path(__file__).with_name("router.py")),
363
+ "_supervise",
364
+ ident,
365
+ ],
366
+ env={
367
+ **os.environ,
368
+ "CODEX_ACCOUNTS_STATE": str(state),
369
+ "RELAY_STATE": str(state),
370
+ },
371
+ stdin=subprocess.DEVNULL,
372
+ stdout=subprocess.DEVNULL,
373
+ stderr=subprocess.DEVNULL,
374
+ start_new_session=True,
375
+ cwd=spec["cwd"],
376
+ )
377
+ atomic_json(supervisor, {"pid": process.pid, "birth": process_birth(process.pid)})
378
+ return ident
379
+
380
+
381
+ async def _serve(state, ident, binary):
382
+ from websockets.asyncio.server import serve as ws_serve
383
+ from router import (
384
+ RPC,
385
+ Controller,
386
+ labels,
387
+ account_home,
388
+ transport_guard,
389
+ RouterError,
390
+ )
391
+ from broker import ClientAccount
392
+ import settings
393
+
394
+ root = folder(state)
395
+ spec_path = root / (ident + ".job.json")
396
+ spec = json.loads(spec_path.read_text())
397
+ binary = spec.get("binary") or binary
398
+ if spec.get("interactive"):
399
+ from launch import translate, backend_arguments
400
+
401
+ frontend, overrides = translate(spec.get("arguments", []))
402
+ cwd = spec["cwd"]
403
+ backend_args = backend_arguments(spec.get("arguments", []))
404
+ else:
405
+ cwd, overrides, backend_args = headless_options(
406
+ spec.get("arguments", []), spec["cwd"]
407
+ )
408
+ pool = Pool(state)
409
+ pool.session = ident
410
+ pool.assign("")
411
+ pool.touch(name=spec["name"], cwd=cwd, state="starting", background=True)
412
+ enrolled = labels(state)
413
+ if not enrolled:
414
+ raise RouterError(
415
+ "Enroll at least one account before starting a background task"
416
+ )
417
+ accounts = [
418
+ ClientAccount(label, account_home(state, label), binary, pool)
419
+ for label in enrolled
420
+ ]
421
+ home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).resolve()
422
+ factory = lambda: RPC.start(binary, home, extra_args=backend_args)
423
+ backend = await factory()
424
+ controller = Controller(accounts, backend, pool=pool, report=lambda message: None)
425
+ with pool.db() as db:
426
+ saved = db.execute(
427
+ "SELECT details FROM session_details WHERE id=?", (ident,)
428
+ ).fetchone()
429
+ controller.paused = bool(json.loads(saved[0]).get("paused")) if saved else False
430
+ controller.background = True
431
+ controller.starting = True
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)
441
+ controller.project = cwd
442
+ controller.profile = spec.get("profile")
443
+
444
+ async def reload_accounts(old):
445
+ current = controller.account.label
446
+ mapping = {a.label: a for a in old}
447
+ new = [
448
+ mapping.get(label)
449
+ or ClientAccount(label, account_home(state, label), binary, pool)
450
+ for label in labels(state)
451
+ ]
452
+ if new:
453
+ controller.index = next(
454
+ (i for i, a in enumerate(new) if a.label == current), 0
455
+ )
456
+ return new or old
457
+
458
+ controller.extra_accounts = reload_accounts
459
+ controller.preferences = settings.load(state, cwd, spec.get("profile"))
460
+ controller.retry_interval = controller.preferences["retry_interval"]
461
+ controller.retry_attempts = controller.preferences["retry_attempts"]
462
+ controller.interval = controller.preferences["unknown_reset_interval"]
463
+ backend.handler = controller.event
464
+ heartbeat = asyncio.create_task(controller.maintain())
465
+ connection = root / (ident + ".connection.json")
466
+ try:
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
+ )
478
+ # Restore configuration-only recovery intent from a previous worker.
479
+ with pool.db() as db:
480
+ old = db.execute(
481
+ "SELECT thread,details FROM intents WHERE owner=?", (ident,)
482
+ ).fetchall()
483
+ old = [
484
+ row
485
+ for row in old
486
+ if json.loads(row[1]).get("role", "foreground") == "foreground"
487
+ ]
488
+ old_thread = (spec.get("resume") if old else None) or (
489
+ old[-1][0] if old else None
490
+ )
491
+ persisted = json.loads(next((r[1] for r in old if r[0] == old_thread), "{}"))
492
+ preferred = next(
493
+ (
494
+ i
495
+ for i, a in enumerate(accounts)
496
+ if a.label == (persisted.get("account_label") or spec.get("first"))
497
+ ),
498
+ None,
499
+ )
500
+ while not controller.stopping.is_set():
501
+ index, deadline = await controller.choose(
502
+ model=overrides.get("model")
503
+ or persisted.get("settings", {}).get("model"),
504
+ preferred=preferred,
505
+ )
506
+ if index is not None:
507
+ try:
508
+ await controller.login(index)
509
+ break
510
+ except (RouterError, asyncio.TimeoutError):
511
+ await controller.mark(index, None, "unavailable")
512
+ else:
513
+ pool.touch(state="waiting_for_reset", next_wake=deadline)
514
+ await controller.wait_until(deadline, controller.user_generation)
515
+ if controller.stopping.is_set():
516
+ return 0
517
+ controller.backend_factory = factory
518
+ thread_id = old_thread or spec.get("resume")
519
+ if thread_id:
520
+ params = {"threadId": thread_id, **overrides}
521
+ if old:
522
+ controller.merge_settings(thread_id, persisted.get("settings", {}))
523
+ params.update(
524
+ {
525
+ k: v
526
+ for k, v in controller.thread_settings[thread_id].items()
527
+ if k in RESUME_FIELDS
528
+ }
529
+ )
530
+ result = await backend.call("thread/resume", params)
531
+ elif not spec.get("interactive"):
532
+ result = await backend.call("thread/start", {"cwd": cwd, **overrides})
533
+ thread_id = result["thread"]["id"]
534
+ else:
535
+ result = {}
536
+ if thread_id:
537
+ if result.get("thread", {}).get("canAcceptDirectInput") is False:
538
+ raise RouterError(
539
+ "Conversation is owned by another backend; no work was stopped"
540
+ )
541
+ if not pool.claim(thread_id):
542
+ raise RouterError(
543
+ "Conversation already belongs to another wrapper session"
544
+ )
545
+ controller.last_turn[thread_id] = {"threadId": thread_id}
546
+ controller.merge_settings(thread_id, result)
547
+ controller.merge_settings(thread_id, overrides)
548
+ controller.persist(thread_id, role="foreground")
549
+ controller.starting = False
550
+ pool.touch(
551
+ thread=thread_id,
552
+ threads=[thread_id] if thread_id else [],
553
+ state="idle",
554
+ model=controller.model_for(thread_id) if thread_id else None,
555
+ )
556
+ token = secrets.token_urlsafe(32)
557
+ meta = {"token": token}
558
+
559
+ async def handle(ws):
560
+ await controller.connect(ws, init_result)
561
+
562
+ async with ws_serve(
563
+ handle,
564
+ "127.0.0.1",
565
+ 0,
566
+ process_request=transport_guard(lambda: meta["token"]),
567
+ max_size=32 * 1024 * 1024,
568
+ ) as server:
569
+ port = server.sockets[0].getsockname()[1]
570
+ meta = {
571
+ "pid": os.getpid(),
572
+ "birth": process_birth(os.getpid()),
573
+ "token": token,
574
+ "url": f"ws://127.0.0.1:{port}",
575
+ "thread": thread_id,
576
+ "home": str(home),
577
+ "cwd": cwd,
578
+ }
579
+
580
+ async def selected(ident, values):
581
+ nonlocal thread_id
582
+ thread_id = ident
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
+ ]
596
+ atomic_json(connection, meta)
597
+ # Preserve the accepted conversation identity across worker restart.
598
+ latest = json.loads(spec_path.read_text())
599
+ latest["resume"] = ident
600
+ if effective_cwd:
601
+ latest["cwd"] = effective_cwd
602
+ latest["state"] = "running"
603
+ spec.update(latest)
604
+ atomic_json(spec_path, spec)
605
+
606
+ controller.on_thread = selected
607
+
608
+ async def detach():
609
+ meta["token"] = secrets.token_urlsafe(32)
610
+ atomic_json(connection, meta)
611
+
612
+ controller.on_detach = detach
613
+ if spec.get("prompt") and not old_thread:
614
+ request = {
615
+ "id": "headless-start",
616
+ "method": "turn/start",
617
+ "params": {
618
+ "threadId": thread_id,
619
+ "input": [
620
+ {
621
+ "type": "text",
622
+ "text": spec["prompt"],
623
+ "text_elements": [],
624
+ }
625
+ ],
626
+ },
627
+ }
628
+ # Preserve the initial prompt privately until native acceptance is
629
+ # known. Do not put it in diagnostic events or status output.
630
+ await controller.forward(request, init_result)
631
+ if controller.pending_intents.get(thread_id, {}).get("accepted"):
632
+ spec["prompt"] = None
633
+ spec["resume"] = thread_id
634
+ spec["state"] = "running"
635
+ atomic_json(spec_path, spec)
636
+ elif old_thread:
637
+ state_name, turn = await controller.reconcile(
638
+ thread_id,
639
+ baseline=persisted.get("baseline")
640
+ if not persisted.get("accepted")
641
+ else None,
642
+ )
643
+ if state_name == "completed":
644
+ controller.persist(
645
+ thread_id,
646
+ state="completed",
647
+ accepted=True,
648
+ active_items=False,
649
+ next_wake=None,
650
+ kind=None,
651
+ episode=None,
652
+ )
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)
659
+ controller.note(
660
+ "needs_attention",
661
+ reason="worker_restarted_during_uncertain_action",
662
+ thread=thread_id,
663
+ )
664
+ elif persisted.get("state") in {"retry_scheduled", "waiting_for_reset"}:
665
+ controller.recovery = asyncio.create_task(
666
+ controller.restore_recovery(thread_id, persisted)
667
+ )
668
+ elif state_name == "failed" and turn:
669
+ from pool import failure_kind
670
+
671
+ kind = failure_kind(
672
+ (turn.get("error") or {}).get("codexErrorInfo"),
673
+ (turn.get("error") or {}).get("message"),
674
+ )
675
+ if kind:
676
+ controller.recovery = asyncio.create_task(
677
+ controller.recover(
678
+ thread_id, controller.user_generation, kind
679
+ )
680
+ )
681
+ elif persisted.get("state") not in {"completed", "idle"}:
682
+ controller.recovery = asyncio.create_task(
683
+ controller.continue_thread(
684
+ thread_id, controller.user_generation
685
+ )
686
+ )
687
+ # Publish the attachment only after accepted work and uncertainty
688
+ # have been reconciled. A frontend must not race recovery setup.
689
+ atomic_json(connection, meta)
690
+ await controller.stopping.wait()
691
+ spec["state"] = "stopped"
692
+ atomic_json(spec_path, spec)
693
+ return 0
694
+ finally:
695
+ controller.stopping.set()
696
+ heartbeat.cancel()
697
+ for task in (controller.recovery, controller.restart_task):
698
+ if task:
699
+ task.cancel()
700
+ await asyncio.gather(
701
+ heartbeat,
702
+ *[t for t in (controller.recovery, controller.restart_task) if t],
703
+ return_exceptions=True,
704
+ )
705
+ if controller.awake_process and controller.awake_process.returncode is None:
706
+ controller.awake_process.terminate()
707
+ await controller.awake_process.wait()
708
+ await controller.backend.close()
709
+ pool.close()
710
+ connection.unlink(missing_ok=True)
711
+
712
+
713
+ async def wait_control(pool, command, timeout=15):
714
+ from router import RouterError
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):
733
+ pool = Pool(state)
734
+ try:
735
+ await wait_control(pool, pool.command(ident, op, params), timeout=15)
736
+ finally:
737
+ pool.close()