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.
- package/LICENSE +21 -0
- package/README.md +99 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +92 -0
- package/docs/publishing.md +56 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +47 -4
- package/python/Menu.swift +70 -0
- package/python/broker.py +297 -0
- package/python/commands.py +701 -0
- package/python/compatibility.py +225 -0
- package/python/desktop.py +52 -0
- package/python/launch.py +117 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +579 -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 +2053 -0
- package/python/runtime.py +97 -0
- package/python/service.py +663 -0
- package/python/settings.py +109 -0
- package/tests/fake_native.py +238 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +519 -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 +473 -0
|
@@ -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()
|