relay-codex 0.0.0-stage → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ from pool import Pool
12
+ from settings import atomic_json
13
+ import service
14
+ import broker
15
+ from test_router import FakeAccount
16
+
17
+
18
+ def fake_executable(root):
19
+ path = root / "codex"
20
+ path.write_text(
21
+ "#!"
22
+ + str(Path(sys.executable).resolve())
23
+ + "\n"
24
+ + Path(__file__).with_name("fake_native.py").read_text()
25
+ )
26
+ path.chmod(0o700)
27
+ return str(path)
28
+
29
+
30
+ async def wait_for(predicate, timeout=8):
31
+ deadline = asyncio.get_running_loop().time() + timeout
32
+ while asyncio.get_running_loop().time() < deadline:
33
+ value = predicate()
34
+ if value:
35
+ return value
36
+ await asyncio.sleep(0.03)
37
+ raise AssertionError("Timed out waiting for test state")
38
+
39
+
40
+ class BrokerTests(unittest.IsolatedAsyncioTestCase):
41
+ async def test_coordinator_shares_native_helper_and_usage_read_across_six_clients(
42
+ self,
43
+ ):
44
+ with tempfile.TemporaryDirectory(prefix="codex-broker-test-") as folder:
45
+ root = Path(folder)
46
+ binary = fake_executable(root)
47
+ home = root / "accounts" / "only"
48
+ home.mkdir(parents=True)
49
+ (home / "enrolled").write_text("{}")
50
+ try:
51
+ await broker.ensure(root, binary)
52
+ results = await asyncio.gather(
53
+ *(
54
+ broker.exchange(root, {"op": "usage", "label": "only"})
55
+ for _ in range(6)
56
+ )
57
+ )
58
+ self.assertTrue(
59
+ all(
60
+ r["rateLimits"]["primary"]["usedPercent"] == 20 for r in results
61
+ )
62
+ )
63
+ calls = json.loads((home / "fake-history.json").read_text())["calls"]
64
+ self.assertEqual(sum(c["method"] == "initialize" for c in calls), 1)
65
+ self.assertEqual(
66
+ sum(c["method"] == "account/rateLimits/read" for c in calls), 1
67
+ )
68
+ _, meta = broker.paths(root)
69
+ self.assertEqual(meta.stat().st_mode & 0o777, 0o600)
70
+ # Wrong capability cannot fetch a response or credentials.
71
+ original = json.loads(meta.read_text())
72
+ bad = dict(original, token="wrong")
73
+ atomic_json(meta, bad)
74
+ with self.assertRaises(RuntimeError):
75
+ await broker.exchange(root, {"op": "usage", "label": "only"})
76
+ atomic_json(meta, original)
77
+ finally:
78
+ with contextlib.suppress(Exception):
79
+ await broker.exchange(root, {"op": "stop"})
80
+ await wait_for(lambda: not broker.paths(root)[1].exists())
81
+
82
+
83
+ class ServiceTests(unittest.IsolatedAsyncioTestCase):
84
+ async def asyncSetUp(self):
85
+ self.tmp = tempfile.TemporaryDirectory(prefix="codex-service-test-")
86
+ self.root = Path(self.tmp.name)
87
+ self.state = self.root / "state"
88
+ self.state.mkdir()
89
+ self.home = self.root / "home"
90
+ self.home.mkdir()
91
+ self.binary = fake_executable(self.root)
92
+ account = self.state / "accounts" / "only"
93
+ account.mkdir(parents=True)
94
+ (account / "enrolled").write_text("{}")
95
+ atomic_json(
96
+ self.state / "settings.json",
97
+ {"settings": {"watchdog_interval": 1, "retry_interval": 1}},
98
+ )
99
+ self.ident = "service-test-owner"
100
+ self.job = service.folder(self.state) / (self.ident + ".job.json")
101
+ atomic_json(
102
+ self.job,
103
+ {
104
+ "id": self.ident,
105
+ "name": "unit task",
106
+ "cwd": str(self.home),
107
+ "first": None,
108
+ "arguments": [],
109
+ "resume": None,
110
+ "prompt": "test only",
111
+ "profile": None,
112
+ "state": "starting",
113
+ "binary": self.binary,
114
+ },
115
+ )
116
+
117
+ class Account(FakeAccount):
118
+ def __init__(self, label, *args):
119
+ super().__init__(label)
120
+
121
+ async def close(self):
122
+ pass
123
+
124
+ self.account_patch = patch("broker.ClientAccount", Account)
125
+ self.account_patch.start()
126
+ self.env = patch.dict(os.environ, {"CODEX_HOME": str(self.home)})
127
+ self.env.start()
128
+ self.task = asyncio.create_task(
129
+ service.serve(self.state, self.ident, self.binary)
130
+ )
131
+ self.meta = service.folder(self.state) / (self.ident + ".connection.json")
132
+ await wait_for(self.meta.exists)
133
+
134
+ async def asyncTearDown(self):
135
+ pool = Pool(self.state)
136
+ pool.command(self.ident, "stop")
137
+ pool.close()
138
+ try:
139
+ await asyncio.wait_for(self.task, 5)
140
+ except (asyncio.TimeoutError, Exception):
141
+ self.task.cancel()
142
+ await asyncio.gather(self.task, return_exceptions=True)
143
+ self.env.stop()
144
+ self.account_patch.stop()
145
+ self.tmp.cleanup()
146
+
147
+ async def test_background_work_completes_without_frontend(self):
148
+ def completed():
149
+ path = self.home / "fake-history.json"
150
+ value = json.loads(path.read_text())
151
+ return any(
152
+ t["turns"] and t["turns"][-1]["status"] == "completed"
153
+ for t in value["threads"].values()
154
+ )
155
+
156
+ await wait_for(completed)
157
+ self.assertFalse(self.task.done())
158
+ self.assertIsNone(json.loads(self.job.read_text())["prompt"])
159
+ self.assertEqual(self.meta.stat().st_mode & 0o777, 0o600)
160
+
161
+ async def test_capacity_message_continues_over_real_jsonl_transport_without_relogin(
162
+ self,
163
+ ):
164
+ from websockets.asyncio.client import connect
165
+
166
+ history = self.home / "fake-history.json"
167
+ await wait_for(
168
+ lambda: any(
169
+ t["turns"] and t["turns"][-1]["status"] == "completed"
170
+ for t in json.loads(history.read_text())["threads"].values()
171
+ )
172
+ )
173
+ meta = json.loads(self.meta.read_text())
174
+ thread = meta["thread"]
175
+ atomic_json(self.home / "fault.json", {"capacity_turns": 2})
176
+ async with connect(
177
+ meta["url"],
178
+ additional_headers={"Authorization": "Bearer " + meta["token"]},
179
+ proxy=None,
180
+ ) as ws:
181
+ await ws.send(
182
+ json.dumps(
183
+ {
184
+ "id": "capacity-test",
185
+ "method": "turn/start",
186
+ "params": {
187
+ "threadId": thread,
188
+ "input": [
189
+ {
190
+ "type": "text",
191
+ "text": "fake only",
192
+ "text_elements": [],
193
+ }
194
+ ],
195
+ },
196
+ }
197
+ )
198
+ )
199
+ while True:
200
+ message = json.loads(await ws.recv())
201
+ if message.get("id") == "capacity-test":
202
+ break
203
+
204
+ def recovered():
205
+ saved = json.loads(history.read_text())
206
+ turns = saved["threads"][thread]["turns"]
207
+ return len(turns) == 3 and turns[-1]["status"] == "completed"
208
+
209
+ await wait_for(recovered)
210
+ saved = json.loads(history.read_text())
211
+ self.assertEqual(
212
+ saved["threads"][thread]["turns"][1]["error"]["codexErrorInfo"],
213
+ "policyCapacityUnavailable",
214
+ )
215
+ self.assertEqual(
216
+ sum(c["method"] == "account/login/start" for c in saved["calls"]), 1
217
+ )
218
+
219
+ async def test_pause_resume_and_control_acknowledgments(self):
220
+ await service.send_control(self.state, self.ident, "pause")
221
+ pool = Pool(self.state)
222
+ try:
223
+ self.assertTrue(
224
+ next(s for s in pool.detail() if s["id"] == self.ident)["paused"]
225
+ )
226
+ finally:
227
+ pool.close()
228
+ await service.send_control(self.state, self.ident, "resume")
229
+
230
+ async def test_pause_survives_worker_reconstruction(self):
231
+ await service.send_control(self.state, self.ident, "pause")
232
+ self.task.cancel()
233
+ await asyncio.gather(self.task, return_exceptions=True)
234
+ self.task = asyncio.create_task(
235
+ service.serve(self.state, self.ident, self.binary)
236
+ )
237
+ await wait_for(self.meta.exists)
238
+ pool = Pool(self.state)
239
+ try:
240
+ self.assertTrue(
241
+ next(s for s in pool.detail() if s["id"] == self.ident)["paused"]
242
+ )
243
+ finally:
244
+ pool.close()
245
+
246
+ async def test_worker_restore_keeps_review_pause_even_if_latest_error_is_recoverable(
247
+ self,
248
+ ):
249
+ history = self.home / "fake-history.json"
250
+ await wait_for(
251
+ lambda: any(
252
+ t["turns"] and t["turns"][-1]["status"] == "completed"
253
+ for t in json.loads(history.read_text())["threads"].values()
254
+ )
255
+ )
256
+ self.task.cancel()
257
+ await asyncio.gather(self.task, return_exceptions=True)
258
+ saved = json.loads(history.read_text())
259
+ thread = next(iter(saved["threads"]))
260
+ saved["threads"][thread]["turns"][-1].update(
261
+ status="failed",
262
+ error={"codexErrorInfo": "usageLimitExceeded", "message": "synthetic"},
263
+ )
264
+ atomic_json(history, saved)
265
+ pool = Pool(self.state)
266
+ pool.session = self.ident
267
+ details = pool.intent(thread)
268
+ details.update(accepted=True, active_items=True, state="failed")
269
+ pool.intent(thread, details)
270
+ starts = sum(c["method"] == "turn/start" for c in saved["calls"])
271
+ self.task = asyncio.create_task(
272
+ service.serve(self.state, self.ident, self.binary)
273
+ )
274
+ await wait_for(self.meta.exists)
275
+ try:
276
+ row = next(s for s in pool.detail() if s["id"] == self.ident)
277
+ self.assertEqual(row["state"], "needs_attention")
278
+ self.assertEqual(
279
+ sum(
280
+ c["method"] == "turn/start"
281
+ for c in json.loads(history.read_text())["calls"]
282
+ ),
283
+ starts,
284
+ )
285
+ finally:
286
+ pool.close()
287
+
288
+ async def test_restore_distinguishes_old_completion_from_accepted_new_work(self):
289
+ history = self.home / "fake-history.json"
290
+ await wait_for(
291
+ lambda: any(
292
+ t["turns"] and t["turns"][-1]["status"] == "completed"
293
+ for t in json.loads(history.read_text())["threads"].values()
294
+ )
295
+ )
296
+ saved = json.loads(history.read_text())
297
+ thread = next(iter(saved["threads"]))
298
+ turn = saved["threads"][thread]["turns"][-1]["id"]
299
+ starts = sum(c["method"] == "turn/start" for c in saved["calls"])
300
+ pool = Pool(self.state)
301
+ pool.session = self.ident
302
+ try:
303
+ for accepted, expected in [(False, "needs_attention"), (True, "idle")]:
304
+ self.task.cancel()
305
+ await asyncio.gather(self.task, return_exceptions=True)
306
+ details = pool.intent(thread)
307
+ details.update(
308
+ accepted=accepted,
309
+ active_items=False,
310
+ baseline=turn,
311
+ state="retry_scheduled" if accepted else "submitting",
312
+ next_wake=1,
313
+ )
314
+ pool.intent(thread, details)
315
+ self.task = asyncio.create_task(
316
+ service.serve(self.state, self.ident, self.binary)
317
+ )
318
+ await wait_for(self.meta.exists)
319
+ self.assertEqual(
320
+ next(s for s in pool.detail() if s["id"] == self.ident)["state"],
321
+ expected,
322
+ )
323
+ self.assertEqual(
324
+ sum(
325
+ c["method"] == "turn/start"
326
+ for c in json.loads(history.read_text())["calls"]
327
+ ),
328
+ starts,
329
+ )
330
+ finally:
331
+ pool.session = "inspector"
332
+ pool.close()
333
+
334
+ async def test_selected_thread_restores_its_own_model_and_permissions(self):
335
+ await wait_for(lambda: json.loads(self.job.read_text()).get("resume"))
336
+ thread = json.loads(self.job.read_text())["resume"]
337
+ await wait_for(
338
+ lambda: any(
339
+ t["turns"] and t["turns"][-1]["status"] == "completed"
340
+ for t in json.loads((self.home / "fake-history.json").read_text())[
341
+ "threads"
342
+ ].values()
343
+ )
344
+ )
345
+ self.task.cancel()
346
+ await asyncio.gather(self.task, return_exceptions=True)
347
+ pool = Pool(self.state)
348
+ pool.session = self.ident
349
+ pool.intent(
350
+ thread,
351
+ {
352
+ "accepted": True,
353
+ "state": "completed",
354
+ "role": "foreground",
355
+ "settings": {
356
+ "model": "model-a",
357
+ "sandbox": "read-only",
358
+ "approvalPolicy": "on-request",
359
+ },
360
+ },
361
+ )
362
+ pool.intent(
363
+ "zzzzz-other-thread",
364
+ {
365
+ "accepted": True,
366
+ "state": "completed",
367
+ "role": "foreground",
368
+ "settings": {
369
+ "model": "other-model",
370
+ "sandbox": "danger-full-access",
371
+ "approvalPolicy": "never",
372
+ },
373
+ },
374
+ )
375
+ self.task = asyncio.create_task(
376
+ service.serve(self.state, self.ident, self.binary)
377
+ )
378
+ await wait_for(self.meta.exists)
379
+ try:
380
+ restored = pool.intent(thread)["settings"]
381
+ self.assertEqual(restored["model"], "model-a")
382
+ self.assertEqual(restored["sandbox"], "read-only")
383
+ finally:
384
+ pool.close()
385
+
386
+ async def test_reattach_uses_workers_runtime_after_global_cli_update(self):
387
+ from unittest.mock import AsyncMock
388
+
389
+ process = type(
390
+ "Process", (), {"wait": AsyncMock(return_value=0), "returncode": 0}
391
+ )()
392
+ with patch(
393
+ "asyncio.create_subprocess_exec", new=AsyncMock(return_value=process)
394
+ ) as spawn:
395
+ self.assertEqual(
396
+ await service.attach(self.state, self.ident, "/new/global/codex"), 0
397
+ )
398
+ self.assertEqual(spawn.call_args.args[0], self.binary)
399
+ expected_cwd = json.loads(self.meta.read_text())["cwd"]
400
+ argv = spawn.call_args.args
401
+ self.assertEqual(
402
+ argv[argv.index("-C") + 1], str(Path(expected_cwd).resolve())
403
+ )
404
+ self.assertEqual(spawn.call_args.kwargs["cwd"], expected_cwd)
405
+
406
+ async def test_takeover_rotates_capability_and_old_frontend_cannot_reconnect(self):
407
+ from websockets.asyncio.client import connect
408
+ from websockets.exceptions import InvalidStatus
409
+
410
+ original = json.loads(self.meta.read_text())
411
+ async with connect(
412
+ original["url"],
413
+ additional_headers={"Authorization": "Bearer " + original["token"]},
414
+ proxy=None,
415
+ ) as ws:
416
+ await service.send_control(self.state, self.ident, "detach")
417
+ await asyncio.wait_for(ws.wait_closed(), 2)
418
+ new = json.loads(self.meta.read_text())
419
+ self.assertEqual(original["pid"], new["pid"])
420
+ self.assertNotEqual(original["token"], new["token"])
421
+ with self.assertRaises(InvalidStatus):
422
+ async with connect(
423
+ new["url"],
424
+ additional_headers={"Authorization": "Bearer " + original["token"]},
425
+ proxy=None,
426
+ ):
427
+ pass
428
+ async with connect(
429
+ new["url"],
430
+ additional_headers={"Authorization": "Bearer " + new["token"]},
431
+ proxy=None,
432
+ ):
433
+ pass
434
+
435
+ async def test_failed_backend_restart_is_retried_without_poisoning_controller(self):
436
+ # Exercise the real worker through a failed start, then a successful one.
437
+ from router import Controller, RouterError
438
+
439
+ class Dead:
440
+ proc = type("Process", (), {"returncode": 9})()
441
+
442
+ old = Dead()
443
+ c = Controller([FakeAccount("only")], old, report=lambda x: None)
444
+
445
+ async def unavailable():
446
+ raise RouterError("test startup failure")
447
+
448
+ c.backend_factory = unavailable
449
+ await c.restart_backend()
450
+ self.assertIs(c.backend, old)
451
+
452
+ async def test_native_backend_crash_is_restarted_without_replaying_completed_prompt(
453
+ self,
454
+ ):
455
+ def ready():
456
+ value = json.loads((self.home / "fake-history.json").read_text())
457
+ return any(
458
+ t["turns"] and t["turns"][-1]["status"] == "completed"
459
+ for t in value["threads"].values()
460
+ )
461
+
462
+ await wait_for(ready)
463
+ # The fake process records its own PID on the turn; no unrelated process is targeted.
464
+ state = json.loads((self.home / "fake-history.json").read_text())
465
+ thread = next(iter(state["threads"].values()))
466
+ os.kill(thread["runtime"], 9)
467
+
468
+ def restored():
469
+ value = json.loads((self.home / "fake-history.json").read_text())
470
+ return sum(c["method"] == "initialize" for c in value["calls"]) >= 2
471
+
472
+ await wait_for(restored)
473
+ await asyncio.sleep(0.2)
474
+ value = json.loads((self.home / "fake-history.json").read_text())
475
+ self.assertEqual(sum(c["method"] == "turn/start" for c in value["calls"]), 1)
476
+
477
+
478
+ if __name__ == "__main__":
479
+ unittest.main()