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.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +176 -0
- package/docs/publishing.md +72 -0
- package/docs/troubleshooting.md +49 -0
- package/package.json +47 -4
- package/python/Menu.swift +188 -0
- package/python/broker.py +297 -0
- package/python/commands.py +900 -0
- package/python/compatibility.py +227 -0
- package/python/desktop.py +171 -0
- package/python/execution.py +551 -0
- package/python/launch.py +417 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +586 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2288 -0
- package/python/runtime.py +97 -0
- package/python/service.py +737 -0
- package/python/settings.py +110 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +240 -0
- package/tests/test_audit.py +320 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +517 -0
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +479 -0
|
@@ -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()
|