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.
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +176 -0
- package/docs/publishing.md +72 -0
- package/docs/troubleshooting.md +49 -0
- package/package.json +47 -4
- package/python/Menu.swift +188 -0
- package/python/broker.py +297 -0
- package/python/commands.py +900 -0
- package/python/compatibility.py +227 -0
- package/python/desktop.py +171 -0
- package/python/execution.py +551 -0
- package/python/launch.py +417 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +586 -0
- package/python/probe_native.py +91 -0
- package/python/processes.py +12 -0
- package/python/requirements.lock +6 -0
- package/python/requirements.txt +2 -0
- package/python/router.py +2288 -0
- package/python/runtime.py +97 -0
- package/python/service.py +737 -0
- package/python/settings.py +110 -0
- package/python/tools.py +233 -0
- package/python/watch.py +74 -0
- package/tests/fake_native.py +240 -0
- package/tests/test_audit.py +320 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +517 -0
- package/tests/test_features.py +350 -0
- package/tests/test_launch.py +150 -0
- package/tests/test_pool.py +446 -0
- package/tests/test_relay.py +173 -0
- package/tests/test_router.py +314 -0
- package/tests/test_services.py +479 -0
|
@@ -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
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Capacity recovery and audit regressions, without real accounts or requests."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
import io
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
import unittest
|
|
11
|
+
from unittest.mock import patch
|
|
12
|
+
|
|
13
|
+
from pool import CAPACITY_MESSAGE, Pool, failure_kind
|
|
14
|
+
from router import Controller
|
|
15
|
+
from test_router import FakeAccount, FakeBackend, FakeClient, completed
|
|
16
|
+
from test_pool import Clock
|
|
17
|
+
from test_services import wait_for
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CapacityClassifierTests(unittest.TestCase):
|
|
21
|
+
def test_structured_and_exact_legacy_capacity_errors(self):
|
|
22
|
+
self.assertEqual(failure_kind("policyCapacityUnavailable"), "capacity")
|
|
23
|
+
for code in (
|
|
24
|
+
None,
|
|
25
|
+
"other",
|
|
26
|
+
"policyCapacityUnavailable",
|
|
27
|
+
"serverOverloaded",
|
|
28
|
+
"flexUnavailable",
|
|
29
|
+
):
|
|
30
|
+
for message in (CAPACITY_MESSAGE, "■ " + CAPACITY_MESSAGE):
|
|
31
|
+
self.assertEqual(failure_kind(code, message), "capacity")
|
|
32
|
+
|
|
33
|
+
def test_policy_denials_are_not_reclassified_by_message(self):
|
|
34
|
+
for code in (
|
|
35
|
+
"cyberPolicy",
|
|
36
|
+
"misalignmentPolicyViolation",
|
|
37
|
+
"badRequest",
|
|
38
|
+
"sandboxError",
|
|
39
|
+
):
|
|
40
|
+
self.assertIsNone(failure_kind(code, CAPACITY_MESSAGE))
|
|
41
|
+
self.assertIsNone(failure_kind("other", "This request mentions capacity"))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CapacityRecoveryTests(unittest.IsolatedAsyncioTestCase):
|
|
45
|
+
async def capacity(self, turn):
|
|
46
|
+
message = completed(code="policyCapacityUnavailable", turn=turn)
|
|
47
|
+
message["params"]["turn"]["error"]["message"] = CAPACITY_MESSAGE
|
|
48
|
+
await self.c.event(message)
|
|
49
|
+
|
|
50
|
+
async def asyncSetUp(self):
|
|
51
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
52
|
+
self.clock = Clock()
|
|
53
|
+
self.pool = Pool(Path(self.tmp.name), clock=self.clock)
|
|
54
|
+
self.backend = FakeBackend()
|
|
55
|
+
self.c = Controller(
|
|
56
|
+
[FakeAccount("one"), FakeAccount("two")],
|
|
57
|
+
self.backend,
|
|
58
|
+
pool=self.pool,
|
|
59
|
+
clock=self.clock,
|
|
60
|
+
sleep=self.clock.sleep,
|
|
61
|
+
report=lambda _: None,
|
|
62
|
+
)
|
|
63
|
+
self.c.client = FakeClient()
|
|
64
|
+
self.c.last_turn["thread-1"] = {"threadId": "thread-1", "model": "chosen-model"}
|
|
65
|
+
|
|
66
|
+
async def asyncTearDown(self):
|
|
67
|
+
if self.c.recovery:
|
|
68
|
+
self.c.recovery.cancel()
|
|
69
|
+
await asyncio.gather(self.c.recovery, return_exceptions=True)
|
|
70
|
+
self.pool.close()
|
|
71
|
+
self.tmp.cleanup()
|
|
72
|
+
|
|
73
|
+
async def test_capacity_retries_every_five_minutes_without_cycling_accounts_or_models(
|
|
74
|
+
self,
|
|
75
|
+
):
|
|
76
|
+
for i in range(4):
|
|
77
|
+
await self.capacity(str(i))
|
|
78
|
+
await self.c.recovery
|
|
79
|
+
self.assertEqual(self.clock.waits, [300, 300, 300, 300])
|
|
80
|
+
self.assertEqual(self.c.index, 0)
|
|
81
|
+
self.assertEqual(self.c.model_for("thread-1"), "chosen-model")
|
|
82
|
+
starts = [p for m, p in self.backend.calls if m == "turn/start"]
|
|
83
|
+
self.assertEqual(len(starts), 4)
|
|
84
|
+
self.assertTrue(all(p["threadId"] == "thread-1" for p in starts))
|
|
85
|
+
self.assertFalse(
|
|
86
|
+
any(
|
|
87
|
+
m in {"thread/resume", "account/login/start"}
|
|
88
|
+
for m, _ in self.backend.calls
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
with self.pool.db() as db:
|
|
92
|
+
reasons = [r[0] for r in db.execute("SELECT reason FROM blocks")]
|
|
93
|
+
self.assertFalse(reasons)
|
|
94
|
+
|
|
95
|
+
async def test_legacy_message_continues_saved_thread(self):
|
|
96
|
+
message = completed(code="other")
|
|
97
|
+
message["params"]["turn"]["error"]["message"] = CAPACITY_MESSAGE
|
|
98
|
+
await self.c.event(message)
|
|
99
|
+
await self.c.recovery
|
|
100
|
+
self.assertEqual(self.clock.waits, [300])
|
|
101
|
+
self.assertTrue(any(m == "turn/start" for m, _ in self.backend.calls))
|
|
102
|
+
|
|
103
|
+
async def test_final_policy_failure_cancels_an_earlier_capacity_retry(self):
|
|
104
|
+
await self.c.event(
|
|
105
|
+
{
|
|
106
|
+
"method": "error",
|
|
107
|
+
"params": {
|
|
108
|
+
"threadId": "thread-1",
|
|
109
|
+
"turnId": "turn-1",
|
|
110
|
+
"willRetry": False,
|
|
111
|
+
"error": {
|
|
112
|
+
"codexErrorInfo": "policyCapacityUnavailable",
|
|
113
|
+
"message": CAPACITY_MESSAGE,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
await self.c.event(completed(code="cyberPolicy"))
|
|
119
|
+
await asyncio.gather(self.c.recovery, return_exceptions=True)
|
|
120
|
+
self.assertFalse(any(m == "turn/start" for m, _ in self.backend.calls))
|
|
121
|
+
|
|
122
|
+
async def test_native_error_and_final_turn_do_not_schedule_two_retries(self):
|
|
123
|
+
params = {
|
|
124
|
+
"threadId": "thread-1",
|
|
125
|
+
"turnId": "turn-1",
|
|
126
|
+
"error": {
|
|
127
|
+
"codexErrorInfo": "policyCapacityUnavailable",
|
|
128
|
+
"message": CAPACITY_MESSAGE,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
await self.c.event({"method": "error", "params": {**params, "willRetry": True}})
|
|
132
|
+
self.assertIsNone(self.c.recovery)
|
|
133
|
+
await self.c.event(
|
|
134
|
+
{"method": "error", "params": {**params, "willRetry": False}}
|
|
135
|
+
)
|
|
136
|
+
await self.capacity("turn-1")
|
|
137
|
+
await self.c.recovery
|
|
138
|
+
self.assertEqual(self.clock.waits, [300])
|
|
139
|
+
self.assertEqual(sum(m == "turn/start" for m, _ in self.backend.calls), 1)
|
|
140
|
+
|
|
141
|
+
async def test_capacity_fallback_only_uses_explicitly_configured_model(self):
|
|
142
|
+
self.c.preferences["fallback_models"] = ["chosen-fallback"]
|
|
143
|
+
for i in range(4):
|
|
144
|
+
await self.capacity(str(i))
|
|
145
|
+
await self.c.recovery
|
|
146
|
+
self.assertEqual(self.c.model_for("thread-1"), "chosen-fallback")
|
|
147
|
+
|
|
148
|
+
async def test_restored_capacity_retry_retains_its_deadline(self):
|
|
149
|
+
await self.c.restore_recovery(
|
|
150
|
+
"thread-1",
|
|
151
|
+
{
|
|
152
|
+
"accepted": True,
|
|
153
|
+
"kind": "capacity",
|
|
154
|
+
"next_wake": 1500,
|
|
155
|
+
"account_label": "one",
|
|
156
|
+
"episode": {"account": 0, "start": 1000, "attempt": 1},
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
self.assertEqual(self.clock.waits, [500])
|
|
160
|
+
self.assertEqual(sum(m == "turn/start" for m, _ in self.backend.calls), 1)
|
|
161
|
+
|
|
162
|
+
async def test_missing_continuation_baseline_cannot_accept_old_completed_turn_as_new(
|
|
163
|
+
self,
|
|
164
|
+
):
|
|
165
|
+
async def lost(method, params=None, **kwargs):
|
|
166
|
+
if method == "turn/start":
|
|
167
|
+
raise asyncio.TimeoutError()
|
|
168
|
+
if method == "thread/turns/list":
|
|
169
|
+
return {"data": [{"id": "old", "status": "completed"}]}
|
|
170
|
+
if method == "thread/read":
|
|
171
|
+
return {"thread": {"status": {"type": "idle"}}}
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
self.backend.call = lost
|
|
175
|
+
await self.c.continue_thread("thread-1", 0)
|
|
176
|
+
self.assertIn("thread-1", self.c.paused_uncertain)
|
|
177
|
+
self.assertFalse(self.c.pending_intents["thread-1"]["accepted"])
|
|
178
|
+
|
|
179
|
+
async def test_unrelated_wake_does_not_shorten_service_retry(self):
|
|
180
|
+
self.c.clock = time.time
|
|
181
|
+
self.c.sleep = asyncio.sleep
|
|
182
|
+
self.c.recovery = asyncio.create_task(self.c.recover("thread-1", 0, "capacity"))
|
|
183
|
+
await wait_for(lambda: self.c.episodes.get("thread-1", {}).get("attempt") == 1)
|
|
184
|
+
self.c.wake.set()
|
|
185
|
+
await asyncio.sleep(0.03)
|
|
186
|
+
self.assertFalse(self.c.recovery.done())
|
|
187
|
+
self.assertFalse(any(m == "turn/start" for m, _ in self.backend.calls))
|
|
188
|
+
# Explicit Retry is allowed to override the saved timer.
|
|
189
|
+
await self.c.control("retry", {})
|
|
190
|
+
await asyncio.wait_for(self.c.recovery, 1)
|
|
191
|
+
self.assertTrue(any(m == "turn/start" for m, _ in self.backend.calls))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class DoctorRegressionTests(unittest.IsolatedAsyncioTestCase):
|
|
195
|
+
async def test_incompatible_native_runtime_is_failure_exit(self):
|
|
196
|
+
import commands
|
|
197
|
+
|
|
198
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
199
|
+
state = Path(folder)
|
|
200
|
+
Pool(state).close()
|
|
201
|
+
|
|
202
|
+
async def absent(*args, **kw):
|
|
203
|
+
raise OSError()
|
|
204
|
+
|
|
205
|
+
with (
|
|
206
|
+
patch("compatibility.check", side_effect=ValueError()),
|
|
207
|
+
patch("broker.exchange", absent),
|
|
208
|
+
contextlib.redirect_stdout(io.StringIO()),
|
|
209
|
+
):
|
|
210
|
+
code = await commands.doctor(
|
|
211
|
+
SimpleNamespace(
|
|
212
|
+
restore_backup=False, self_test=False, backup=False
|
|
213
|
+
),
|
|
214
|
+
state,
|
|
215
|
+
"fake",
|
|
216
|
+
)
|
|
217
|
+
self.assertEqual(code, 1)
|