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.
- package/docs/publishing.md +2 -2
- package/docs/troubleshooting.md +18 -0
- package/package.json +1 -1
- package/python/broker.py +5 -0
- package/python/commands.py +0 -2
- package/python/execution.py +1 -1
- package/python/pool.py +7 -0
- package/python/router.py +299 -26
- package/python/service.py +14 -4
- package/python/terminal.py +28 -0
- package/tests/fake_native.py +54 -0
- package/tests/test_activity.py +318 -0
- package/tests/test_audit.py +1 -0
- package/tests/test_recovery_boundary.py +422 -0
|
@@ -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()
|