relay-codex 0.4.1 → 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/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 +1 -1
- package/python/Menu.swift +169 -51
- package/python/commands.py +219 -20
- 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 +305 -70
- 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 +320 -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/tools.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Local launch explanations, redacted diagnostics, saved workspaces, and cleanup."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
from settings import atomic_json
|
|
11
|
+
from pool import Pool
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def explain(arguments, cwd, binary=None):
|
|
15
|
+
from launch import (
|
|
16
|
+
translate,
|
|
17
|
+
working_directory,
|
|
18
|
+
native_mode,
|
|
19
|
+
additional_roots,
|
|
20
|
+
tokens,
|
|
21
|
+
arguments_only,
|
|
22
|
+
)
|
|
23
|
+
from router import VERSION
|
|
24
|
+
|
|
25
|
+
if binary is None:
|
|
26
|
+
from router import codex_path
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
binary = codex_path()
|
|
30
|
+
except Exception:
|
|
31
|
+
binary = None
|
|
32
|
+
native = native_mode(arguments, binary)
|
|
33
|
+
_, overrides = translate(arguments)
|
|
34
|
+
model = next(
|
|
35
|
+
(
|
|
36
|
+
value
|
|
37
|
+
for name, value, _ in tokens(arguments_only(arguments))
|
|
38
|
+
if name == "--model"
|
|
39
|
+
),
|
|
40
|
+
None,
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
"relay_version": VERSION,
|
|
44
|
+
"mode": "native" if native else "managed",
|
|
45
|
+
"account_recovery": not native,
|
|
46
|
+
"cwd": working_directory(arguments, cwd),
|
|
47
|
+
"model": model or "Native Codex default",
|
|
48
|
+
"permissions": overrides or "Saved thread or native Codex defaults",
|
|
49
|
+
"additional_directories": additional_roots(
|
|
50
|
+
arguments, working_directory(arguments, cwd)
|
|
51
|
+
),
|
|
52
|
+
"codex": binary,
|
|
53
|
+
"note": "Native utilities and external/local-provider endpoints retain native behavior."
|
|
54
|
+
if native
|
|
55
|
+
else "Relay manages account resets, temporary failures, and the saved conversation.",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def diagnostics(state, output):
|
|
60
|
+
"""Allowlisted operational facts only; never read credentials or raw job arguments."""
|
|
61
|
+
from router import VERSION
|
|
62
|
+
import platform
|
|
63
|
+
|
|
64
|
+
pool = Pool(state)
|
|
65
|
+
try:
|
|
66
|
+
details = pool.detail()
|
|
67
|
+
with pool.db() as db:
|
|
68
|
+
health = db.execute("PRAGMA quick_check").fetchone()[0]
|
|
69
|
+
events = [
|
|
70
|
+
r[0]
|
|
71
|
+
for r in db.execute(
|
|
72
|
+
"SELECT kind FROM events ORDER BY id DESC LIMIT 100"
|
|
73
|
+
)
|
|
74
|
+
]
|
|
75
|
+
value = {
|
|
76
|
+
"relay_version": VERSION,
|
|
77
|
+
"python": platform.python_version(),
|
|
78
|
+
"platform": sys.platform,
|
|
79
|
+
"database": "ok" if health == "ok" else "failed",
|
|
80
|
+
"active_sessions": len(details),
|
|
81
|
+
"session_states": [r.get("state", "unknown") for r in details],
|
|
82
|
+
"recent_event_types": events,
|
|
83
|
+
}
|
|
84
|
+
destination = Path(output).expanduser()
|
|
85
|
+
with destination.open("x", encoding="utf-8") as stream:
|
|
86
|
+
os.chmod(destination, 0o600)
|
|
87
|
+
json.dump(value, stream, indent=2)
|
|
88
|
+
stream.write("\n")
|
|
89
|
+
return str(destination)
|
|
90
|
+
finally:
|
|
91
|
+
pool.close()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def workspace(state, action, name=None, sessions=None, binary=None):
|
|
95
|
+
import service
|
|
96
|
+
|
|
97
|
+
path = state / "workspaces.json"
|
|
98
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
99
|
+
pool = Pool(state)
|
|
100
|
+
try:
|
|
101
|
+
if action == "list":
|
|
102
|
+
return {key: len(value) for key, value in data.items()}
|
|
103
|
+
if action == "remove":
|
|
104
|
+
data.pop(name, None)
|
|
105
|
+
atomic_json(path, data)
|
|
106
|
+
return {"removed": name}
|
|
107
|
+
if action == "save":
|
|
108
|
+
live = pool.detail()
|
|
109
|
+
chosen = [
|
|
110
|
+
r
|
|
111
|
+
for r in live
|
|
112
|
+
if not sessions or any(r["id"].startswith(s) for s in sessions)
|
|
113
|
+
]
|
|
114
|
+
if not chosen or sessions and len(chosen) != len(sessions):
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"Choose live session IDs from relay status, or omit IDs to save every live session"
|
|
117
|
+
)
|
|
118
|
+
jobs = []
|
|
119
|
+
for row in chosen:
|
|
120
|
+
job_path = service.folder(state) / (row["id"] + ".job.json")
|
|
121
|
+
if not job_path.exists():
|
|
122
|
+
continue
|
|
123
|
+
jobs.append(row["id"])
|
|
124
|
+
if not jobs:
|
|
125
|
+
raise ValueError("No supervised sessions were available to save")
|
|
126
|
+
data[name] = jobs
|
|
127
|
+
atomic_json(path, data)
|
|
128
|
+
return {"saved": name, "sessions": len(jobs)}
|
|
129
|
+
if name not in data:
|
|
130
|
+
raise ValueError("Unknown workspace")
|
|
131
|
+
alive = {r["id"] for r in pool.detail()}
|
|
132
|
+
result = []
|
|
133
|
+
for ident in data[name]:
|
|
134
|
+
if ident not in alive:
|
|
135
|
+
service.restart(state, ident)
|
|
136
|
+
result.append({"session": ident, "attach": "relay attach " + ident})
|
|
137
|
+
return result
|
|
138
|
+
finally:
|
|
139
|
+
pool.close()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cleanup(state, *, apply=False, days=30, jobs=False):
|
|
143
|
+
"""Default to a dry run. Preserve runtimes used by live or restartable jobs."""
|
|
144
|
+
import runtime
|
|
145
|
+
|
|
146
|
+
pool = Pool(state)
|
|
147
|
+
try:
|
|
148
|
+
live = {r["id"] for r in pool.detail()}
|
|
149
|
+
finally:
|
|
150
|
+
pool.close()
|
|
151
|
+
threshold = time.time() - days * 86400
|
|
152
|
+
protected = {str(Path(__file__).parent.resolve())}
|
|
153
|
+
workspaces_file = state / "workspaces.json"
|
|
154
|
+
saved_workspaces = (
|
|
155
|
+
json.loads(workspaces_file.read_text()) if workspaces_file.exists() else {}
|
|
156
|
+
)
|
|
157
|
+
workspace_jobs = {ident for group in saved_workspaces.values() for ident in group}
|
|
158
|
+
value = runtime.index(state)
|
|
159
|
+
native_protected = {
|
|
160
|
+
row["sha256"][:20] for row in value["runtimes"][-1:] if row.get("sha256")
|
|
161
|
+
}
|
|
162
|
+
if value.get("selected"):
|
|
163
|
+
native_protected.add(value["selected"][:20])
|
|
164
|
+
candidates = []
|
|
165
|
+
protect_all_python = False
|
|
166
|
+
for path in (state / "services").glob("*.job.json"):
|
|
167
|
+
try:
|
|
168
|
+
job = json.loads(path.read_text())
|
|
169
|
+
except (OSError, ValueError):
|
|
170
|
+
protect_all_python = True
|
|
171
|
+
continue
|
|
172
|
+
ident = path.name.removesuffix(".job.json")
|
|
173
|
+
old_stopped = (
|
|
174
|
+
ident not in live
|
|
175
|
+
and job.get("state") in {"stopped", "removed"}
|
|
176
|
+
and path.stat().st_mtime < threshold
|
|
177
|
+
)
|
|
178
|
+
if old_stopped and jobs and ident not in workspace_jobs:
|
|
179
|
+
candidates.extend(
|
|
180
|
+
p
|
|
181
|
+
for p in path.parent.glob(ident + ".*")
|
|
182
|
+
if p.is_file() and not p.is_symlink()
|
|
183
|
+
)
|
|
184
|
+
continue
|
|
185
|
+
if job.get("binary"):
|
|
186
|
+
protected.add(str(Path(job["binary"]).resolve()))
|
|
187
|
+
if job.get("relay_runtime"):
|
|
188
|
+
protected.add(str(Path(job["relay_runtime"]).resolve()))
|
|
189
|
+
elif ident in live:
|
|
190
|
+
protect_all_python = True
|
|
191
|
+
for folder in ["relay-runtimes", "native-runtimes"]:
|
|
192
|
+
root = state / folder
|
|
193
|
+
if not root.exists() or folder == "relay-runtimes" and protect_all_python:
|
|
194
|
+
continue
|
|
195
|
+
for path in root.iterdir():
|
|
196
|
+
resolved = str(path.resolve())
|
|
197
|
+
if (
|
|
198
|
+
path.is_symlink()
|
|
199
|
+
or not path.is_dir()
|
|
200
|
+
or path.stat().st_mtime >= threshold
|
|
201
|
+
):
|
|
202
|
+
continue
|
|
203
|
+
if folder == "native-runtimes" and path.name in native_protected:
|
|
204
|
+
continue
|
|
205
|
+
if any(p == resolved or p.startswith(resolved + os.sep) for p in protected):
|
|
206
|
+
continue
|
|
207
|
+
candidates.append(path)
|
|
208
|
+
# Recheck live references immediately before mutations. Never delete from another user's state.
|
|
209
|
+
if apply:
|
|
210
|
+
guard = Pool(state)
|
|
211
|
+
try:
|
|
212
|
+
if {r["id"] for r in guard.detail()} != live:
|
|
213
|
+
raise ValueError("Sessions changed during cleanup; rerun the dry run")
|
|
214
|
+
finally:
|
|
215
|
+
guard.close()
|
|
216
|
+
for path in candidates:
|
|
217
|
+
if path.is_symlink():
|
|
218
|
+
raise ValueError(
|
|
219
|
+
"Cleanup encountered a changed symlink; nothing further was removed"
|
|
220
|
+
)
|
|
221
|
+
if path.is_dir():
|
|
222
|
+
shutil.rmtree(path)
|
|
223
|
+
else:
|
|
224
|
+
path.unlink(missing_ok=True)
|
|
225
|
+
value["runtimes"] = [
|
|
226
|
+
r for r in value["runtimes"] if Path(r["binary"]).is_file()
|
|
227
|
+
]
|
|
228
|
+
atomic_json(state / "runtimes.json", value)
|
|
229
|
+
return {
|
|
230
|
+
"dry_run": not apply,
|
|
231
|
+
"paths": [str(p) for p in candidates],
|
|
232
|
+
"count": len(candidates),
|
|
233
|
+
}
|
package/python/watch.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Wait for local filesystem changes; never fetch allowance or run inference."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import select
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def fingerprint(value):
|
|
11
|
+
"""Heartbeats and sample time do not represent a dashboard change."""
|
|
12
|
+
value = {**value, "at": None}
|
|
13
|
+
value["sessions"] = [
|
|
14
|
+
{k: v for k, v in item.items() if k != "seen"}
|
|
15
|
+
for item in value.get("sessions", [])
|
|
16
|
+
]
|
|
17
|
+
return json.dumps(value, sort_keys=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class LocalChanges:
|
|
21
|
+
def __init__(self, state):
|
|
22
|
+
self.state = Path(state)
|
|
23
|
+
self.queue = select.kqueue() if hasattr(select, "kqueue") else None
|
|
24
|
+
self.files = []
|
|
25
|
+
self.signature = None
|
|
26
|
+
|
|
27
|
+
def close(self):
|
|
28
|
+
if self.queue:
|
|
29
|
+
self.queue.close()
|
|
30
|
+
for fd in self.files:
|
|
31
|
+
os.close(fd)
|
|
32
|
+
self.files.clear()
|
|
33
|
+
|
|
34
|
+
def wait(self, timeout=30):
|
|
35
|
+
paths = [
|
|
36
|
+
self.state,
|
|
37
|
+
self.state / "services",
|
|
38
|
+
self.state / "accounts",
|
|
39
|
+
self.state / "pool.sqlite",
|
|
40
|
+
self.state / "pool.sqlite-wal",
|
|
41
|
+
self.state / "settings.json",
|
|
42
|
+
]
|
|
43
|
+
if not self.queue:
|
|
44
|
+
import time
|
|
45
|
+
|
|
46
|
+
time.sleep(timeout)
|
|
47
|
+
return
|
|
48
|
+
for fd in self.files:
|
|
49
|
+
os.close(fd)
|
|
50
|
+
self.files = []
|
|
51
|
+
registrations = []
|
|
52
|
+
for path in paths:
|
|
53
|
+
try:
|
|
54
|
+
fd = os.open(path, os.O_RDONLY)
|
|
55
|
+
except FileNotFoundError:
|
|
56
|
+
continue
|
|
57
|
+
self.files.append(fd)
|
|
58
|
+
registrations.append(
|
|
59
|
+
select.kevent(
|
|
60
|
+
fd,
|
|
61
|
+
filter=select.KQ_FILTER_VNODE,
|
|
62
|
+
flags=select.KQ_EV_ADD | select.KQ_EV_CLEAR,
|
|
63
|
+
fflags=select.KQ_NOTE_WRITE
|
|
64
|
+
| select.KQ_NOTE_RENAME
|
|
65
|
+
| select.KQ_NOTE_DELETE
|
|
66
|
+
| select.KQ_NOTE_ATTRIB,
|
|
67
|
+
)
|
|
68
|
+
)
|
|
69
|
+
self.queue.control(registrations, max(1, len(registrations)), timeout)
|
|
70
|
+
|
|
71
|
+
async def changed(self, timeout=30):
|
|
72
|
+
await asyncio.to_thread(self.wait, timeout)
|
|
73
|
+
# Coalesce a burst of updates into one consistent snapshot.
|
|
74
|
+
await asyncio.sleep(0.15)
|
package/tests/fake_native.py
CHANGED
|
@@ -177,7 +177,7 @@ for raw in sys.stdin:
|
|
|
177
177
|
}
|
|
178
178
|
elif method == "thread/loaded/list":
|
|
179
179
|
result = {"data": change(lambda d: list(d["threads"]))}
|
|
180
|
-
elif method
|
|
180
|
+
elif method in {"turn/start", "review/start"}:
|
|
181
181
|
thread = params["threadId"]
|
|
182
182
|
turn = str(uuid.uuid4())
|
|
183
183
|
value = {"id": turn, "status": "inProgress", "items": [], "error": None}
|
|
@@ -189,7 +189,9 @@ for raw in sys.stdin:
|
|
|
189
189
|
"activeFlags": [],
|
|
190
190
|
}
|
|
191
191
|
data["threads"][thread]["runtime"] = os.getpid()
|
|
192
|
-
return sum(
|
|
192
|
+
return sum(
|
|
193
|
+
c["method"] in {"turn/start", "review/start"} for c in data["calls"]
|
|
194
|
+
)
|
|
193
195
|
|
|
194
196
|
count = change(begin)
|
|
195
197
|
fault = (
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
"""Regression cases reproduced by the audit. No real accounts or model calls."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import tempfile
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
from router import Controller, RemoteError, parse_arguments, resolve_resume_target
|
|
14
|
+
from test_router import FakeAccount, FakeBackend, FakeClient, completed
|
|
15
|
+
from test_pool import Clock
|
|
16
|
+
from pool import Pool, process_birth
|
|
17
|
+
from settings import atomic_json
|
|
18
|
+
import commands
|
|
19
|
+
import service
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RecoveryRegressions(unittest.IsolatedAsyncioTestCase):
|
|
23
|
+
async def test_late_failure_cannot_bypass_review_hold(self):
|
|
24
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
25
|
+
clock = Clock()
|
|
26
|
+
pool = Pool(Path(folder), clock=clock)
|
|
27
|
+
backend = FakeBackend()
|
|
28
|
+
c = Controller(
|
|
29
|
+
[FakeAccount("one")],
|
|
30
|
+
backend,
|
|
31
|
+
pool=pool,
|
|
32
|
+
clock=clock,
|
|
33
|
+
sleep=clock.sleep,
|
|
34
|
+
report=lambda _: None,
|
|
35
|
+
)
|
|
36
|
+
c.client = FakeClient()
|
|
37
|
+
c.last_turn["thread-1"] = {"threadId": "thread-1"}
|
|
38
|
+
c.hold_for_review("thread-1")
|
|
39
|
+
await c.event(completed(code="policyCapacityUnavailable"))
|
|
40
|
+
await c.recovery
|
|
41
|
+
self.assertFalse(any(method == "turn/start" for method, _ in backend.calls))
|
|
42
|
+
self.assertTrue(pool.intent("thread-1")["review_required"])
|
|
43
|
+
await c.control("retry", {"thread": "thread-1"})
|
|
44
|
+
await c.recovery
|
|
45
|
+
self.assertTrue(any(method == "turn/start" for method, _ in backend.calls))
|
|
46
|
+
pool.close()
|
|
47
|
+
|
|
48
|
+
async def test_picker_permission_control_applies_before_thread_selection(self):
|
|
49
|
+
c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
|
|
50
|
+
c.launch_overrides = {
|
|
51
|
+
"sandbox": "danger-full-access",
|
|
52
|
+
"approvalPolicy": "never",
|
|
53
|
+
}
|
|
54
|
+
await c.control(
|
|
55
|
+
"permissions", {"sandbox": "read-only", "approvalPolicy": "on-request"}
|
|
56
|
+
)
|
|
57
|
+
self.assertEqual(
|
|
58
|
+
c.launch_overrides, {"sandbox": "read-only", "approvalPolicy": "on-request"}
|
|
59
|
+
)
|
|
60
|
+
c.client = FakeClient()
|
|
61
|
+
await c.forward({"id": 1, "method": "thread/start", "params": {}}, {})
|
|
62
|
+
self.assertEqual(c.backend.calls[-1][1]["sandbox"], "read-only")
|
|
63
|
+
|
|
64
|
+
async def test_more_than_one_thousand_overloads_do_not_grow_call_stack(self):
|
|
65
|
+
clock = Clock()
|
|
66
|
+
backend = FakeBackend()
|
|
67
|
+
rejections = 1100
|
|
68
|
+
original = backend.call
|
|
69
|
+
|
|
70
|
+
async def overloaded(method, params=None, **kw):
|
|
71
|
+
nonlocal rejections
|
|
72
|
+
if method == "turn/start" and rejections:
|
|
73
|
+
rejections -= 1
|
|
74
|
+
raise RemoteError({"code": -32001})
|
|
75
|
+
return await original(method, params, **kw)
|
|
76
|
+
|
|
77
|
+
backend.call = overloaded
|
|
78
|
+
c = Controller(
|
|
79
|
+
[FakeAccount("one"), FakeAccount("two")],
|
|
80
|
+
backend,
|
|
81
|
+
clock=clock,
|
|
82
|
+
sleep=clock.sleep,
|
|
83
|
+
report=lambda _: None,
|
|
84
|
+
)
|
|
85
|
+
c.client = FakeClient()
|
|
86
|
+
c.last_turn["thread-1"] = {"threadId": "thread-1"}
|
|
87
|
+
await c.recover("thread-1", 0, "transient")
|
|
88
|
+
self.assertEqual(rejections, 0)
|
|
89
|
+
self.assertTrue(any(method == "turn/start" for method, _ in backend.calls))
|
|
90
|
+
self.assertFalse(c.paused_uncertain)
|
|
91
|
+
|
|
92
|
+
async def test_resume_lookup_uses_native_filtering_and_names(self):
|
|
93
|
+
class Backend:
|
|
94
|
+
def __init__(self):
|
|
95
|
+
self.calls = []
|
|
96
|
+
|
|
97
|
+
async def call(self, method, params):
|
|
98
|
+
self.calls.append((method, dict(params)))
|
|
99
|
+
return {
|
|
100
|
+
"data": [{"id": "interactive", "name": "named", "cwd": "/project"}]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
backend = Backend()
|
|
104
|
+
selected = await resolve_resume_target(
|
|
105
|
+
["resume", "--last"], Path("/home"), "/project", backend=backend
|
|
106
|
+
)
|
|
107
|
+
self.assertEqual(selected, "interactive")
|
|
108
|
+
self.assertNotIn("sourceKinds", backend.calls[-1][1])
|
|
109
|
+
self.assertEqual(backend.calls[-1][1]["cwd"], "/project")
|
|
110
|
+
selected = await resolve_resume_target(
|
|
111
|
+
["resume", "named", "--all", "--include-non-interactive"],
|
|
112
|
+
Path("/home"),
|
|
113
|
+
"/project",
|
|
114
|
+
backend=backend,
|
|
115
|
+
)
|
|
116
|
+
self.assertEqual(selected, "interactive")
|
|
117
|
+
self.assertIn("exec", backend.calls[-1][1]["sourceKinds"])
|
|
118
|
+
self.assertNotIn("cwd", backend.calls[-1][1])
|
|
119
|
+
|
|
120
|
+
async def test_watch_ignores_heartbeats_and_redraws_only_meaningful_changes(self):
|
|
121
|
+
args = type(
|
|
122
|
+
"Args", (), {"watch": True, "json": True, "all": False, "interval": 30}
|
|
123
|
+
)()
|
|
124
|
+
count = 0
|
|
125
|
+
values = [
|
|
126
|
+
{"at": 1, "sessions": [{"id": "a", "seen": 1, "state": "idle"}]},
|
|
127
|
+
{"at": 2, "sessions": [{"id": "a", "seen": 2, "state": "idle"}]},
|
|
128
|
+
{"at": 3, "sessions": [{"id": "a", "seen": 3, "state": "working"}]},
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
class Watch:
|
|
132
|
+
def __init__(self, state):
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
async def changed(self, timeout):
|
|
136
|
+
nonlocal count
|
|
137
|
+
count += 1
|
|
138
|
+
if count == 3:
|
|
139
|
+
raise asyncio.CancelledError()
|
|
140
|
+
|
|
141
|
+
def close(self):
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
output = io.StringIO()
|
|
145
|
+
with (
|
|
146
|
+
patch("watch.LocalChanges", Watch),
|
|
147
|
+
patch("commands.snapshot", side_effect=values),
|
|
148
|
+
contextlib.redirect_stdout(output),
|
|
149
|
+
):
|
|
150
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
151
|
+
await commands.status(args, Path("/unused"))
|
|
152
|
+
self.assertEqual(len(output.getvalue().splitlines()), 2)
|
|
153
|
+
|
|
154
|
+
async def test_control_wait_reports_rejection(self):
|
|
155
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
156
|
+
state = Path(folder)
|
|
157
|
+
pool = Pool(state)
|
|
158
|
+
pool.session = "worker"
|
|
159
|
+
pool.assign("one")
|
|
160
|
+
args = parse_arguments(["pause", "worker", "--wait"])
|
|
161
|
+
|
|
162
|
+
async def reject(pool, ident, timeout):
|
|
163
|
+
raise ValueError("rejected")
|
|
164
|
+
|
|
165
|
+
with patch("service.wait_control", reject):
|
|
166
|
+
with self.assertRaisesRegex(ValueError, "rejected"):
|
|
167
|
+
await commands.control(args, state)
|
|
168
|
+
pool.close()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class WorkerRegressions(unittest.IsolatedAsyncioTestCase):
|
|
172
|
+
async def asyncSetUp(self):
|
|
173
|
+
from test_services import ServiceTests
|
|
174
|
+
|
|
175
|
+
self.case = ServiceTests("test_background_work_completes_without_frontend")
|
|
176
|
+
await self.case.asyncSetUp()
|
|
177
|
+
|
|
178
|
+
async def asyncTearDown(self):
|
|
179
|
+
await self.case.asyncTearDown()
|
|
180
|
+
|
|
181
|
+
async def test_worker_restores_reviewer_and_workspace_roots(self):
|
|
182
|
+
from test_services import wait_for
|
|
183
|
+
from router import RPC
|
|
184
|
+
|
|
185
|
+
c = self.case
|
|
186
|
+
await wait_for(lambda: json.loads(c.job.read_text()).get("resume"))
|
|
187
|
+
await wait_for(
|
|
188
|
+
lambda: any(
|
|
189
|
+
t["turns"] and t["turns"][-1]["status"] == "completed"
|
|
190
|
+
for t in json.loads((c.home / "fake-history.json").read_text())[
|
|
191
|
+
"threads"
|
|
192
|
+
].values()
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
thread = json.loads(c.job.read_text())["resume"]
|
|
196
|
+
c.task.cancel()
|
|
197
|
+
await asyncio.gather(c.task, return_exceptions=True)
|
|
198
|
+
pool = Pool(c.state)
|
|
199
|
+
pool.session = c.ident
|
|
200
|
+
pool.intent(
|
|
201
|
+
thread,
|
|
202
|
+
{
|
|
203
|
+
"role": "foreground",
|
|
204
|
+
"accepted": True,
|
|
205
|
+
"state": "completed",
|
|
206
|
+
"settings": {
|
|
207
|
+
"approvalsReviewer": "auto_review",
|
|
208
|
+
"runtimeWorkspaceRoots": [str(c.home), "/private/tmp/audit-extra"],
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
observed = []
|
|
213
|
+
original = RPC.call
|
|
214
|
+
|
|
215
|
+
async def capture(rpc, method, params=None, **kw):
|
|
216
|
+
if method == "thread/resume":
|
|
217
|
+
observed.append(params)
|
|
218
|
+
return await original(rpc, method, params, **kw)
|
|
219
|
+
|
|
220
|
+
with patch.object(RPC, "call", capture):
|
|
221
|
+
c.task = asyncio.create_task(service.serve(c.state, c.ident, c.binary))
|
|
222
|
+
await wait_for(c.meta.exists)
|
|
223
|
+
self.assertEqual(observed[0]["approvalsReviewer"], "auto_review")
|
|
224
|
+
self.assertIn("/private/tmp/audit-extra", observed[0]["runtimeWorkspaceRoots"])
|
|
225
|
+
pool.close()
|
|
226
|
+
|
|
227
|
+
async def test_project_selection_updates_restart_and_attach_metadata(self):
|
|
228
|
+
from websockets.asyncio.client import connect
|
|
229
|
+
|
|
230
|
+
c = self.case
|
|
231
|
+
other = c.home / "other"
|
|
232
|
+
other.mkdir()
|
|
233
|
+
value = json.loads(c.meta.read_text())
|
|
234
|
+
async with connect(
|
|
235
|
+
value["url"],
|
|
236
|
+
additional_headers={"Authorization": "Bearer " + value["token"]},
|
|
237
|
+
proxy=None,
|
|
238
|
+
) as ws:
|
|
239
|
+
await ws.send(
|
|
240
|
+
json.dumps(
|
|
241
|
+
{
|
|
242
|
+
"id": "select",
|
|
243
|
+
"method": "thread/start",
|
|
244
|
+
"params": {"cwd": str(other)},
|
|
245
|
+
}
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
while json.loads(await ws.recv()).get("id") != "select":
|
|
249
|
+
pass
|
|
250
|
+
self.assertEqual(json.loads(c.meta.read_text())["cwd"], str(other))
|
|
251
|
+
self.assertEqual(json.loads(c.job.read_text())["cwd"], str(other))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class AttachmentRegressions(unittest.IsolatedAsyncioTestCase):
|
|
255
|
+
async def test_new_worker_reconnects_without_replaying_original_prompt(self):
|
|
256
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
257
|
+
state = Path(folder)
|
|
258
|
+
root = service.folder(state)
|
|
259
|
+
job = root / "task.job.json"
|
|
260
|
+
meta = root / "task.connection.json"
|
|
261
|
+
atomic_json(job, {"binary": "/codex", "state": "running"})
|
|
262
|
+
old = {
|
|
263
|
+
"pid": os.getpid(),
|
|
264
|
+
"birth": process_birth(os.getpid()),
|
|
265
|
+
"url": "ws://127.0.0.1:1",
|
|
266
|
+
"token": "FAKE",
|
|
267
|
+
"home": folder,
|
|
268
|
+
"cwd": folder,
|
|
269
|
+
"thread": "saved",
|
|
270
|
+
}
|
|
271
|
+
atomic_json(meta, old)
|
|
272
|
+
|
|
273
|
+
class Process:
|
|
274
|
+
def __init__(self, done=False):
|
|
275
|
+
self.returncode = 0 if done else None
|
|
276
|
+
self.finished = asyncio.Event()
|
|
277
|
+
|
|
278
|
+
async def wait(self):
|
|
279
|
+
if self.returncode is None:
|
|
280
|
+
await self.finished.wait()
|
|
281
|
+
return self.returncode
|
|
282
|
+
|
|
283
|
+
def terminate(self):
|
|
284
|
+
self.returncode = 0
|
|
285
|
+
self.finished.set()
|
|
286
|
+
|
|
287
|
+
def kill(self):
|
|
288
|
+
self.terminate()
|
|
289
|
+
|
|
290
|
+
spawned = []
|
|
291
|
+
|
|
292
|
+
async def spawn(*argv, **kwargs):
|
|
293
|
+
spawned.append(argv)
|
|
294
|
+
if len(spawned) == 1:
|
|
295
|
+
atomic_json(
|
|
296
|
+
meta,
|
|
297
|
+
{
|
|
298
|
+
**old,
|
|
299
|
+
"pid": os.getppid(),
|
|
300
|
+
"birth": process_birth(os.getppid()),
|
|
301
|
+
"url": "ws://127.0.0.1:2",
|
|
302
|
+
"token": "NEW_FAKE",
|
|
303
|
+
},
|
|
304
|
+
)
|
|
305
|
+
return Process(done=len(spawned) > 1)
|
|
306
|
+
|
|
307
|
+
with patch("asyncio.create_subprocess_exec", spawn):
|
|
308
|
+
self.assertEqual(
|
|
309
|
+
await service.attach(state, "task", "/codex", ["original prompt"]),
|
|
310
|
+
0,
|
|
311
|
+
)
|
|
312
|
+
self.assertEqual(len(spawned), 2)
|
|
313
|
+
self.assertIn("saved", spawned[1])
|
|
314
|
+
self.assertNotIn("original prompt", spawned[1])
|
|
315
|
+
|
|
316
|
+
def test_takeover_can_follow_session_but_not_native_separator(self):
|
|
317
|
+
self.assertTrue(parse_arguments(["attach", "task", "--takeover"]).takeover)
|
|
318
|
+
self.assertFalse(
|
|
319
|
+
parse_arguments(["attach", "task", "--", "--takeover"]).takeover
|
|
320
|
+
)
|
package/tests/test_controller.py
CHANGED
|
@@ -33,12 +33,10 @@ class LaunchTests(unittest.TestCase):
|
|
|
33
33
|
self.assertEqual(args, ["resume", "--", "--yolo"])
|
|
34
34
|
self.assertEqual(value, {})
|
|
35
35
|
|
|
36
|
-
def
|
|
36
|
+
def test_config_forwarded_without_native_only_profile_flag(self):
|
|
37
37
|
self.assertEqual(
|
|
38
|
-
backend_arguments(
|
|
39
|
-
|
|
40
|
-
),
|
|
41
|
-
["-p", "work", "-c", 'model_reasoning_effort="high"'],
|
|
38
|
+
backend_arguments(["resume", "-c", 'model_reasoning_effort="high"']),
|
|
39
|
+
["-c", 'model_reasoning_effort="high"'],
|
|
42
40
|
)
|
|
43
41
|
|
|
44
42
|
def test_setup_supports_one_or_arbitrary_count(self):
|