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