relay-codex 0.7.2 → 0.7.4

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,422 @@
1
+ """Quota recovery at native idle boundaries and account refresh races."""
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ import tempfile
7
+ import unittest
8
+ from unittest.mock import patch
9
+ import sys
10
+
11
+ from pool import failure_kind
12
+ from router import Controller, RemoteError, RouterError, RPC
13
+ from settings import atomic_json
14
+ from test_router import FakeAccount, FakeBackend, FakeClient, completed
15
+ from test_services import fake_executable, wait_for
16
+ from test_pool import Clock
17
+
18
+
19
+ class BoundaryTests(unittest.IsolatedAsyncioTestCase):
20
+ def controller(
21
+ self,
22
+ *,
23
+ status="systemError",
24
+ turn_status="failed",
25
+ tool_status="completed",
26
+ backgrounds=True,
27
+ ):
28
+ c = Controller(
29
+ [FakeAccount("one"), FakeAccount("two")],
30
+ FakeBackend(),
31
+ report=lambda _: None,
32
+ )
33
+ c.client = FakeClient()
34
+ c.last_turn["thread-1"] = {"threadId": "thread-1"}
35
+ c.turn_ids["thread-1"] = "turn-1"
36
+ c.active_items.add("orphaned-lifecycle-id")
37
+ c.active_item_threads["orphaned-lifecycle-id"] = "thread-1"
38
+ original = c.backend.call
39
+
40
+ async def call(method, params=None, **kw):
41
+ if method == "thread/backgroundTerminals/list":
42
+ return {
43
+ "data": [
44
+ {
45
+ "itemId": "orphaned-lifecycle-id",
46
+ "processId": "background-job",
47
+ }
48
+ ]
49
+ if backgrounds
50
+ else []
51
+ }
52
+ if method == "thread/turns/list":
53
+ return {
54
+ "data": [
55
+ {
56
+ "id": "turn-1",
57
+ "status": turn_status,
58
+ "itemsView": "full",
59
+ "items": [
60
+ {
61
+ "id": "persisted-action",
62
+ "type": "commandExecution",
63
+ "status": tool_status,
64
+ }
65
+ ],
66
+ }
67
+ ]
68
+ }
69
+ if method == "thread/read":
70
+ return {
71
+ "thread": {"status": {"type": status}, "canAcceptDirectInput": True}
72
+ }
73
+ return await original(method, params, **kw)
74
+
75
+ c.backend.call = call
76
+ return c
77
+
78
+ async def test_known_background_terminal_unblocks_quota_without_replaying_request(
79
+ self,
80
+ ):
81
+ c = self.controller()
82
+ await c.event(completed())
83
+ await asyncio.wait_for(c.recovery, 1)
84
+ self.assertFalse(c.active_items)
85
+ self.assertEqual(c.account.label, "two")
86
+ starts = [p for m, p in c.backend.calls if m == "turn/start"]
87
+ self.assertEqual(len(starts), 1)
88
+ self.assertEqual(starts[0]["threadId"], "thread-1")
89
+
90
+ async def test_active_or_incomplete_native_work_still_blocks(self):
91
+ for options in (
92
+ {"status": "active"},
93
+ {"status": "notLoaded"},
94
+ {"turn_status": "inProgress"},
95
+ {"tool_status": "inProgress", "backgrounds": False},
96
+ {"tool_status": None, "backgrounds": False},
97
+ {"backgrounds": False},
98
+ ):
99
+ with self.subTest(options=options):
100
+ c = self.controller(**options)
101
+ await c.reconcile_active_items()
102
+ self.assertTrue(c.active_items)
103
+
104
+ async def test_manual_switch_keeps_known_background_job_without_new_turn(self):
105
+ c = self.controller()
106
+ await c.control("switch", {"account": "two"})
107
+ self.assertEqual(c.account.label, "two")
108
+ self.assertFalse(c.active_items)
109
+ self.assertFalse(any(m == "turn/start" for m, _ in c.backend.calls))
110
+
111
+ async def test_pending_approval_and_different_current_turn_still_block(self):
112
+ for blocker in ("approval", "different-turn"):
113
+ c = self.controller()
114
+ if blocker == "approval":
115
+ c.server_requests.add(99)
116
+ else:
117
+ c.turn_ids["thread-1"] = "newer-turn"
118
+ await c.reconcile_active_items()
119
+ self.assertTrue(c.active_items)
120
+
121
+ async def test_new_user_work_during_status_read_invalidates_boundary(self):
122
+ c = self.controller()
123
+ original = c.backend.call
124
+
125
+ async def call(method, params=None, **kw):
126
+ result = await original(method, params, **kw)
127
+ if method == "thread/read":
128
+ c.user_generation += 1
129
+ return result
130
+
131
+ c.backend.call = call
132
+ await c.reconcile_active_items()
133
+ self.assertTrue(c.active_items)
134
+
135
+ async def test_refresh_callback_during_login_uses_new_account(self):
136
+ c = self.controller()
137
+ original = c.backend.call
138
+
139
+ async def call(method, params=None, **kw):
140
+ if method == "account/login/start":
141
+ await c.event(
142
+ {
143
+ "id": "refresh",
144
+ "method": "account/chatgptAuthTokens/refresh",
145
+ "params": {"previousAccountId": "two"},
146
+ }
147
+ )
148
+ return await original(method, params, **kw)
149
+
150
+ c.backend.call = call
151
+ await asyncio.wait_for(c.login(1), 1)
152
+ self.assertEqual(c.backend.sent[-1]["result"]["chatgptAccountId"], "two")
153
+ self.assertFalse(c.client.messages)
154
+
155
+ async def test_failed_refresh_is_private_and_classified_for_auth_retry(self):
156
+ c = self.controller()
157
+
158
+ async def failed(refresh=False):
159
+ raise RouterError("PRIVATE_EXCEPTION_DETAIL")
160
+
161
+ c.accounts[0].credentials = failed
162
+ await c.event(
163
+ {"id": 3, "method": "account/chatgptAuthTokens/refresh", "params": {}}
164
+ )
165
+ self.assertEqual(c.backend.sent[-1]["error"]["code"], -32000)
166
+ self.assertNotIn("PRIVATE_EXCEPTION_DETAIL", json.dumps(c.backend.sent))
167
+ self.assertFalse(c.client.messages)
168
+ self.assertEqual(
169
+ failure_kind("other", "Auth refresh request failed: code=-32000"), "auth"
170
+ )
171
+ self.assertIsNone(
172
+ failure_kind("badRequest", "Auth refresh request failed: code=-32000")
173
+ )
174
+ self.assertIsNone(
175
+ failure_kind("other", "unrelated request failed: code=-32000")
176
+ )
177
+
178
+ async def test_refresh_from_replaced_backend_cannot_reach_new_connection(self):
179
+ c = self.controller()
180
+ old = c.backend
181
+
182
+ async def rotating(refresh=False):
183
+ c.backend = FakeBackend()
184
+ return {"accessToken": "FAKE", "chatgptAccountId": "one"}
185
+
186
+ c.account.credentials = rotating
187
+ await c.event(
188
+ {"id": 12, "method": "account/chatgptAuthTokens/refresh", "params": {}},
189
+ origin=old,
190
+ )
191
+ self.assertFalse(c.backend.sent)
192
+ self.assertFalse(old.sent)
193
+ await c.event(completed(), origin=old)
194
+ self.assertIsNone(c.recovery)
195
+
196
+ async def test_auth_rejection_before_acceptance_retries_original_input_once(self):
197
+ c = self.controller()
198
+ c.active_items.clear()
199
+ c.active_item_threads.clear()
200
+ clock = Clock()
201
+ c.clock, c.sleep = clock, clock.sleep
202
+ c.retry_interval = 300
203
+ original = c.backend.call
204
+ requests = []
205
+
206
+ async def call(method, params=None, **kw):
207
+ if method == "turn/start":
208
+ requests.append(dict(params))
209
+ if len(requests) == 1:
210
+ raise RemoteError(
211
+ {
212
+ "code": -32603,
213
+ "message": "auth refresh request failed: code=-32000",
214
+ }
215
+ )
216
+ return {"turn": {"id": "accepted-turn", "status": "inProgress"}}
217
+ return await original(method, params, **kw)
218
+
219
+ c.backend.call = call
220
+ await c.forward(
221
+ {
222
+ "id": 1,
223
+ "method": "turn/start",
224
+ "params": {
225
+ "threadId": "thread-1",
226
+ "input": [
227
+ {
228
+ "type": "text",
229
+ "text": "UNIQUE_NEW_REQUEST",
230
+ "text_elements": [],
231
+ }
232
+ ],
233
+ },
234
+ },
235
+ {},
236
+ )
237
+ await asyncio.wait_for(c.recovery, 1)
238
+ self.assertEqual(len(requests), 2)
239
+ self.assertEqual(requests[0]["input"], requests[1]["input"])
240
+ self.assertEqual(
241
+ requests[0]["clientUserMessageId"], requests[1]["clientUserMessageId"]
242
+ )
243
+ self.assertEqual(c.account.label, "one")
244
+ self.assertFalse(c.rejected_turns)
245
+ self.assertEqual(c.turn_ids["thread-1"], "accepted-turn")
246
+
247
+ async def test_changed_history_prevents_replay_of_rejected_input(self):
248
+ c = self.controller()
249
+ c.active_items.clear()
250
+ c.remember_rejected(
251
+ "thread-1", {"threadId": "thread-1", "input": []}, "older-turn"
252
+ )
253
+ await c.continue_thread("thread-1", 0)
254
+ self.assertIn("thread-1", c.paused_uncertain)
255
+ self.assertFalse(any(m == "turn/start" for m, _ in c.backend.calls))
256
+
257
+ async def test_worker_restart_does_not_guess_rejected_prompt(self):
258
+ c = self.controller()
259
+ await c.restore_recovery(
260
+ "thread-1",
261
+ {"rejected": True, "accepted": False, "state": "retry_scheduled"},
262
+ )
263
+ self.assertIn("thread-1", c.paused_uncertain)
264
+ self.assertFalse(any(m == "turn/start" for m, _ in c.backend.calls))
265
+
266
+ async def test_watchdog_detects_eof_while_native_process_is_still_alive(self):
267
+ proc = await asyncio.create_subprocess_exec(
268
+ sys.executable,
269
+ "-c",
270
+ "import os,time; os.close(1); time.sleep(30)",
271
+ stdin=asyncio.subprocess.PIPE,
272
+ stdout=asyncio.subprocess.PIPE,
273
+ )
274
+ rpc = RPC(proc)
275
+ c = Controller([FakeAccount("one")], rpc, report=lambda _: None)
276
+ c.backend_factory = lambda: None
277
+ restarted = []
278
+
279
+ async def restart():
280
+ restarted.append(True)
281
+ c.stopping.set()
282
+
283
+ c.restart_backend = restart
284
+ try:
285
+ await asyncio.wait_for(asyncio.shield(rpc.reader), 3)
286
+ self.assertIsNone(proc.returncode)
287
+ await asyncio.wait_for(c.maintain(), 3)
288
+ await c.restart_task
289
+ self.assertTrue(restarted)
290
+ finally:
291
+ await rpc.close()
292
+
293
+ async def test_broker_refresh_reloads_persisted_grant_under_shared_lock(self):
294
+ import base64
295
+ import time
296
+ import broker
297
+
298
+ with tempfile.TemporaryDirectory() as folder:
299
+ state = Path(folder)
300
+ home = state / "accounts" / "one"
301
+ home.mkdir(parents=True)
302
+ (home / "enrolled").write_text("{}")
303
+ events = []
304
+
305
+ class Account:
306
+ def __init__(self, label, home, binary, pool):
307
+ self.home = home
308
+ self.rpc = FakeBackend()
309
+
310
+ async def start(self):
311
+ events.append("start")
312
+
313
+ async def close(self):
314
+ events.append("close")
315
+
316
+ part = (
317
+ base64.urlsafe_b64encode(
318
+ json.dumps({"exp": time.time() + 3600}).encode()
319
+ )
320
+ .decode()
321
+ .rstrip("=")
322
+ )
323
+ credentials = {
324
+ "accessToken": "fake." + part + ".fake",
325
+ "chatgptAccountId": "one",
326
+ }
327
+ with (
328
+ patch("router.Account", Account),
329
+ patch("router.read_keychain", return_value=credentials),
330
+ ):
331
+ server = asyncio.create_task(broker.serve(state, "unused"))
332
+ try:
333
+ await wait_for(lambda: broker.paths(state)[1].exists())
334
+ await broker.exchange(state, {"op": "usage", "label": "one"})
335
+ value = await broker.exchange(
336
+ state, {"op": "credentials", "label": "one", "refresh": True}
337
+ )
338
+ self.assertEqual(value, credentials)
339
+ self.assertEqual(events[:3], ["start", "close", "start"])
340
+ finally:
341
+ await broker.exchange(state, {"op": "stop"})
342
+ await asyncio.wait_for(server, 3)
343
+
344
+ async def test_background_activity_over_real_jsonl_switches_without_restart(self):
345
+ class ResetPromptClient(FakeClient):
346
+ def __init__(self):
347
+ super().__init__()
348
+ self.blocked = False
349
+ self.saw_reset_prompt = False
350
+
351
+ async def send(self, raw):
352
+ message = json.loads(raw)
353
+ params = message.get("params", {})
354
+ error = params.get("error") or params.get("turn", {}).get("error") or {}
355
+ if (
356
+ message.get("method") in {"error", "turn/completed"}
357
+ and error.get("codexErrorInfo") == "usageLimitExceeded"
358
+ ):
359
+ self.blocked = True
360
+ self.saw_reset_prompt = True
361
+ if message.get("method") == "account/updated":
362
+ self.blocked = False
363
+ await super().send(raw)
364
+
365
+ with tempfile.TemporaryDirectory() as folder:
366
+ root = Path(folder)
367
+ atomic_json(
368
+ root / "fault.json", {"quota_turns": 1, "background_activity_turns": 1}
369
+ )
370
+ rpc = await RPC.start(fake_executable(root), root)
371
+ c = Controller(
372
+ [FakeAccount("one"), FakeAccount("two")], rpc, report=lambda _: None
373
+ )
374
+ c.client = ResetPromptClient()
375
+ rpc.handler = c.event
376
+ try:
377
+ pid = rpc.proc.pid
378
+ await rpc.initialize()
379
+ await c.forward(
380
+ {"id": "thread", "method": "thread/start", "params": {}}, {}
381
+ )
382
+ thread = next(iter(c.last_turn))
383
+ await c.forward(
384
+ {
385
+ "id": "turn",
386
+ "method": "turn/start",
387
+ "params": {
388
+ "threadId": thread,
389
+ "input": [
390
+ {"type": "text", "text": "work", "text_elements": []}
391
+ ],
392
+ },
393
+ },
394
+ {},
395
+ )
396
+
397
+ def recovered():
398
+ turns = json.loads((root / "fake-history.json").read_text())[
399
+ "threads"
400
+ ][thread]["turns"]
401
+ return len(turns) == 2 and turns[-1]["status"] == "completed"
402
+
403
+ await wait_for(recovered)
404
+ self.assertEqual(c.account.label, "two")
405
+ self.assertFalse(c.active_items)
406
+ self.assertEqual(rpc.proc.pid, pid)
407
+ history = json.loads((root / "fake-history.json").read_text())
408
+ self.assertTrue(history["backgrounds"][thread])
409
+ self.assertTrue(c.client.saw_reset_prompt)
410
+ self.assertFalse(c.client.blocked)
411
+ self.assertFalse(c.server_requests)
412
+ self.assertFalse(
413
+ any(
414
+ c["method"] == "account/rateLimitResetCredit/consume"
415
+ for c in history["calls"]
416
+ )
417
+ )
418
+ finally:
419
+ if c.recovery:
420
+ c.recovery.cancel()
421
+ await asyncio.gather(c.recovery, return_exceptions=True)
422
+ await rpc.close()