relay-codex 0.0.0-stage → 0.4.1
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 +99 -2
- package/assets/relay.svg +15 -0
- package/bin/relay.cjs +148 -0
- package/docs/commands.md +92 -0
- package/docs/publishing.md +56 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +47 -4
- package/python/Menu.swift +70 -0
- package/python/broker.py +297 -0
- package/python/commands.py +701 -0
- package/python/compatibility.py +225 -0
- package/python/desktop.py +52 -0
- package/python/launch.py +117 -0
- package/python/metadata.py +82 -0
- package/python/notifications.py +36 -0
- package/python/pool.py +579 -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 +2053 -0
- package/python/runtime.py +97 -0
- package/python/service.py +663 -0
- package/python/settings.py +109 -0
- package/tests/fake_native.py +238 -0
- package/tests/test_capacity.py +217 -0
- package/tests/test_controller.py +519 -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 +473 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Validated local preferences; no automatic model fallback or extra billing."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
"retry_interval": 300,
|
|
11
|
+
"retry_attempts": 3,
|
|
12
|
+
"unknown_reset_interval": 300,
|
|
13
|
+
"watchdog_interval": 30,
|
|
14
|
+
"idle_warning_after": 120,
|
|
15
|
+
"routing": "balanced",
|
|
16
|
+
"notifications": False,
|
|
17
|
+
"fallback_models": [],
|
|
18
|
+
"preferred_accounts": [],
|
|
19
|
+
"max_sessions_per_account": 0,
|
|
20
|
+
"reservation_seconds": 120,
|
|
21
|
+
"history_days": 30,
|
|
22
|
+
"event_limit": 2000,
|
|
23
|
+
"offline_retry": 30,
|
|
24
|
+
"background_on_disconnect": False,
|
|
25
|
+
"broker_idle_seconds": 600,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def validate(values):
|
|
30
|
+
unknown = set(values) - set(DEFAULTS)
|
|
31
|
+
if unknown:
|
|
32
|
+
raise ValueError("Unknown settings: " + ", ".join(sorted(unknown)))
|
|
33
|
+
for key, value in values.items():
|
|
34
|
+
expected = DEFAULTS[key]
|
|
35
|
+
if isinstance(expected, bool):
|
|
36
|
+
if not isinstance(value, bool):
|
|
37
|
+
raise ValueError(f"{key} must be true or false")
|
|
38
|
+
elif isinstance(expected, int):
|
|
39
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
40
|
+
raise ValueError(f"{key} must be a nonnegative integer")
|
|
41
|
+
if key not in {"max_sessions_per_account", "retry_attempts"} and value < 1:
|
|
42
|
+
raise ValueError(f"{key} must be positive")
|
|
43
|
+
elif isinstance(expected, list):
|
|
44
|
+
if not isinstance(value, list) or not all(
|
|
45
|
+
isinstance(v, str) for v in value
|
|
46
|
+
):
|
|
47
|
+
raise ValueError(f"{key} must be a list of strings")
|
|
48
|
+
if values.get("routing", "balanced") not in {
|
|
49
|
+
"balanced",
|
|
50
|
+
"quota",
|
|
51
|
+
"priority",
|
|
52
|
+
"round-robin",
|
|
53
|
+
}:
|
|
54
|
+
raise ValueError("routing must be balanced, quota, priority, or round-robin")
|
|
55
|
+
if values.get("reservation_seconds", 120) < 30:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
"reservation_seconds must be at least 30 to allow heartbeat renewal"
|
|
58
|
+
)
|
|
59
|
+
return values
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load(state, project=None, profile=None):
|
|
63
|
+
result = copy.deepcopy(DEFAULTS)
|
|
64
|
+
for path in [
|
|
65
|
+
state / "settings.json",
|
|
66
|
+
Path(project) / ".codex-accounts.toml" if project else None,
|
|
67
|
+
Path(project) / ".relay.toml" if project else None,
|
|
68
|
+
]:
|
|
69
|
+
if not path or not path.exists():
|
|
70
|
+
continue
|
|
71
|
+
data = (
|
|
72
|
+
json.loads(path.read_text())
|
|
73
|
+
if path.suffix == ".json"
|
|
74
|
+
else tomllib.loads(path.read_text())
|
|
75
|
+
)
|
|
76
|
+
result.update(validate(data.get("settings", {})))
|
|
77
|
+
if profile:
|
|
78
|
+
result.update(validate(data.get("profiles", {}).get(profile, {})))
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def edit(state, key, raw, profile=None):
|
|
83
|
+
try:
|
|
84
|
+
value = json.loads(raw)
|
|
85
|
+
except ValueError:
|
|
86
|
+
value = raw
|
|
87
|
+
validate({key: value})
|
|
88
|
+
path = state / "settings.json"
|
|
89
|
+
data = json.loads(path.read_text()) if path.exists() else {}
|
|
90
|
+
target = (
|
|
91
|
+
data.setdefault("profiles", {}).setdefault(profile, {})
|
|
92
|
+
if profile
|
|
93
|
+
else data.setdefault("settings", {})
|
|
94
|
+
)
|
|
95
|
+
target[key] = value
|
|
96
|
+
atomic_json(path, data)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def atomic_json(path, data):
|
|
100
|
+
path = Path(path)
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
102
|
+
temporary = path.with_name(path.name + f".{os.getpid()}.tmp")
|
|
103
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
104
|
+
with os.fdopen(fd, "w") as stream:
|
|
105
|
+
json.dump(data, stream, indent=2)
|
|
106
|
+
stream.write("\n")
|
|
107
|
+
stream.flush()
|
|
108
|
+
os.fsync(stream.fileno())
|
|
109
|
+
temporary.replace(path)
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Offline test double for native JSONL transport. Never contacts OpenAI."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
import fcntl
|
|
12
|
+
|
|
13
|
+
home = Path(os.environ.get("CODEX_HOME", "."))
|
|
14
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
state_file = home / "fake-history.json"
|
|
16
|
+
guard = threading.RLock()
|
|
17
|
+
out = threading.Lock()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def send(value):
|
|
21
|
+
with out:
|
|
22
|
+
print(json.dumps(value), flush=True)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def change(operation):
|
|
26
|
+
with guard:
|
|
27
|
+
with (home / "fake-history.lock").open("a") as lock:
|
|
28
|
+
fcntl.flock(lock, fcntl.LOCK_EX)
|
|
29
|
+
data = (
|
|
30
|
+
json.loads(state_file.read_text())
|
|
31
|
+
if state_file.exists()
|
|
32
|
+
else {"threads": {}, "calls": []}
|
|
33
|
+
)
|
|
34
|
+
result = operation(data)
|
|
35
|
+
temporary = state_file.with_suffix(".tmp")
|
|
36
|
+
temporary.write_text(json.dumps(data))
|
|
37
|
+
temporary.replace(state_file)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def finish(thread, turn, text, mode):
|
|
42
|
+
status = "failed" if mode in {"quota", "capacity"} else "completed"
|
|
43
|
+
error = (
|
|
44
|
+
{"message": "synthetic limit", "codexErrorInfo": "usageLimitExceeded"}
|
|
45
|
+
if mode == "quota"
|
|
46
|
+
else None
|
|
47
|
+
)
|
|
48
|
+
if mode == "capacity":
|
|
49
|
+
error = {
|
|
50
|
+
"message": "Selected model is at capacity. Please try a different model.",
|
|
51
|
+
"codexErrorInfo": "policyCapacityUnavailable",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
def apply(data):
|
|
55
|
+
value = data["threads"][thread]
|
|
56
|
+
value["status"] = {"type": "idle"}
|
|
57
|
+
value["turns"][-1].update(status=status, error=error)
|
|
58
|
+
return value["turns"][-1].copy()
|
|
59
|
+
|
|
60
|
+
value = change(apply)
|
|
61
|
+
if status == "completed":
|
|
62
|
+
send(
|
|
63
|
+
{
|
|
64
|
+
"method": "item/completed",
|
|
65
|
+
"params": {
|
|
66
|
+
"threadId": thread,
|
|
67
|
+
"item": {
|
|
68
|
+
"id": "reply-" + turn,
|
|
69
|
+
"type": "agentMessage",
|
|
70
|
+
"text": "FAKE_OK",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
if mode == "capacity":
|
|
76
|
+
send(
|
|
77
|
+
{
|
|
78
|
+
"method": "error",
|
|
79
|
+
"params": {
|
|
80
|
+
"threadId": thread,
|
|
81
|
+
"turnId": turn,
|
|
82
|
+
"error": error,
|
|
83
|
+
"willRetry": False,
|
|
84
|
+
},
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
send({"method": "turn/completed", "params": {"threadId": thread, "turn": value}})
|
|
88
|
+
send(
|
|
89
|
+
{
|
|
90
|
+
"method": "thread/status/changed",
|
|
91
|
+
"params": {"threadId": thread, "status": {"type": "idle"}},
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if "--version" in sys.argv:
|
|
97
|
+
print("codex-cli 0.166.0")
|
|
98
|
+
raise SystemExit()
|
|
99
|
+
if "--help" in sys.argv:
|
|
100
|
+
print("--remote --remote-auth-token-env")
|
|
101
|
+
raise SystemExit()
|
|
102
|
+
|
|
103
|
+
for raw in sys.stdin:
|
|
104
|
+
try:
|
|
105
|
+
request = json.loads(raw)
|
|
106
|
+
method = request.get("method")
|
|
107
|
+
params = request.get("params") or {}
|
|
108
|
+
ident = request.get("id")
|
|
109
|
+
if ident is None:
|
|
110
|
+
continue
|
|
111
|
+
change(
|
|
112
|
+
lambda d: d["calls"].append(
|
|
113
|
+
{"method": method, "thread": params.get("threadId")}
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
result = {}
|
|
117
|
+
if method == "initialize":
|
|
118
|
+
result = {
|
|
119
|
+
"userAgent": "fake/1",
|
|
120
|
+
"codexHome": str(home),
|
|
121
|
+
"platformFamily": "unix",
|
|
122
|
+
"platformOs": "macos",
|
|
123
|
+
}
|
|
124
|
+
elif method == "account/read":
|
|
125
|
+
result = {"account": {"type": "chatgpt", "planType": "plus"}}
|
|
126
|
+
elif method == "account/login/start":
|
|
127
|
+
result = {"type": params["type"]}
|
|
128
|
+
elif method == "account/rateLimits/read":
|
|
129
|
+
result = {
|
|
130
|
+
"ordinaryUsageAllowed": True,
|
|
131
|
+
"rateLimits": {
|
|
132
|
+
"primary": {"usedPercent": 20, "resetsAt": int(time.time()) + 3600}
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
elif method in ("thread/start", "thread/fork"):
|
|
136
|
+
thread = str(uuid.uuid4())
|
|
137
|
+
value = {
|
|
138
|
+
"id": thread,
|
|
139
|
+
"cwd": params.get("cwd", str(home)),
|
|
140
|
+
"model": params.get("model", "fake-model"),
|
|
141
|
+
"canAcceptDirectInput": True,
|
|
142
|
+
"status": {"type": "idle"},
|
|
143
|
+
"turns": [],
|
|
144
|
+
}
|
|
145
|
+
change(lambda d: d["threads"].update({thread: value}))
|
|
146
|
+
result = {
|
|
147
|
+
"thread": value,
|
|
148
|
+
"model": value["model"],
|
|
149
|
+
"cwd": value["cwd"],
|
|
150
|
+
"approvalPolicy": params.get("approvalPolicy", "never"),
|
|
151
|
+
"sandbox": params.get("sandbox", "read-only"),
|
|
152
|
+
}
|
|
153
|
+
send({"method": "thread/started", "params": {"thread": value}})
|
|
154
|
+
elif method == "thread/resume":
|
|
155
|
+
value = change(lambda d: d["threads"].get(params["threadId"]))
|
|
156
|
+
if not value:
|
|
157
|
+
raise ValueError("no thread")
|
|
158
|
+
if (
|
|
159
|
+
value.get("runtime") != os.getpid()
|
|
160
|
+
and value.get("status", {}).get("type") == "active"
|
|
161
|
+
):
|
|
162
|
+
value["status"] = {"type": "idle"}
|
|
163
|
+
if value["turns"]:
|
|
164
|
+
value["turns"][-1].update(status="interrupted", error=None)
|
|
165
|
+
value.update({k: v for k, v in params.items() if k in {"model", "cwd"}})
|
|
166
|
+
change(lambda d: d["threads"].update({value["id"]: value}))
|
|
167
|
+
result = {"thread": value, "model": value["model"], "cwd": value["cwd"]}
|
|
168
|
+
elif method == "thread/read":
|
|
169
|
+
result = {"thread": change(lambda d: d["threads"][params["threadId"]])}
|
|
170
|
+
elif method == "thread/turns/list":
|
|
171
|
+
result = {
|
|
172
|
+
"data": change(
|
|
173
|
+
lambda d: list(reversed(d["threads"][params["threadId"]]["turns"]))[
|
|
174
|
+
: params.get("limit", 1)
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
elif method == "thread/loaded/list":
|
|
179
|
+
result = {"data": change(lambda d: list(d["threads"]))}
|
|
180
|
+
elif method == "turn/start":
|
|
181
|
+
thread = params["threadId"]
|
|
182
|
+
turn = str(uuid.uuid4())
|
|
183
|
+
value = {"id": turn, "status": "inProgress", "items": [], "error": None}
|
|
184
|
+
|
|
185
|
+
def begin(data):
|
|
186
|
+
data["threads"][thread]["turns"].append(value)
|
|
187
|
+
data["threads"][thread]["status"] = {
|
|
188
|
+
"type": "active",
|
|
189
|
+
"activeFlags": [],
|
|
190
|
+
}
|
|
191
|
+
data["threads"][thread]["runtime"] = os.getpid()
|
|
192
|
+
return sum(c["method"] == "turn/start" for c in data["calls"])
|
|
193
|
+
|
|
194
|
+
count = change(begin)
|
|
195
|
+
fault = (
|
|
196
|
+
json.loads((home / "fault.json").read_text())
|
|
197
|
+
if (home / "fault.json").exists()
|
|
198
|
+
else {}
|
|
199
|
+
)
|
|
200
|
+
mode = "quota" if count <= fault.get("quota_turns", 0) else "ok"
|
|
201
|
+
if count <= fault.get("capacity_turns", 0):
|
|
202
|
+
mode = "capacity"
|
|
203
|
+
if not fault.get("drop_ack"):
|
|
204
|
+
send({"id": ident, "result": {"turn": value}})
|
|
205
|
+
send(
|
|
206
|
+
{
|
|
207
|
+
"method": "turn/started",
|
|
208
|
+
"params": {"threadId": thread, "turn": value},
|
|
209
|
+
}
|
|
210
|
+
)
|
|
211
|
+
if fault.get("external"):
|
|
212
|
+
send(
|
|
213
|
+
{
|
|
214
|
+
"method": "item/started",
|
|
215
|
+
"params": {
|
|
216
|
+
"threadId": thread,
|
|
217
|
+
"item": {"id": "external1", "type": "commandExecution"},
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
if fault.get("die_once") and count == 1:
|
|
222
|
+
os._exit(11)
|
|
223
|
+
timer = threading.Timer(
|
|
224
|
+
float(fault.get("delay", 0.02)), finish, args=(thread, turn, "", mode)
|
|
225
|
+
)
|
|
226
|
+
timer.daemon = True
|
|
227
|
+
timer.start()
|
|
228
|
+
continue
|
|
229
|
+
elif method == "turn/interrupt":
|
|
230
|
+
result = {}
|
|
231
|
+
send({"id": ident, "result": result})
|
|
232
|
+
except Exception:
|
|
233
|
+
send(
|
|
234
|
+
{
|
|
235
|
+
"id": request.get("id"),
|
|
236
|
+
"error": {"code": -32600, "message": "Synthetic request rejected"},
|
|
237
|
+
}
|
|
238
|
+
)
|
|
@@ -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)
|