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,517 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import tempfile
|
|
6
|
+
import unittest
|
|
7
|
+
|
|
8
|
+
from router import Controller, RPC, RouterError, parser, labels
|
|
9
|
+
from launch import translate, backend_arguments
|
|
10
|
+
from pool import Pool
|
|
11
|
+
from test_router import FakeAccount, FakeBackend, FakeClient
|
|
12
|
+
from test_pool import Clock
|
|
13
|
+
import settings
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LaunchTests(unittest.TestCase):
|
|
17
|
+
def test_resume_yolo_is_backend_override_not_remote_frontend_flag(self):
|
|
18
|
+
args, overrides = translate(["--", "resume", "--last", "--yolo"])
|
|
19
|
+
self.assertEqual(args, ["resume", "--last"])
|
|
20
|
+
self.assertEqual(
|
|
21
|
+
overrides, {"approvalPolicy": "never", "sandbox": "danger-full-access"}
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
def test_explicit_permission_modes_translate(self):
|
|
25
|
+
args, value = translate(
|
|
26
|
+
["resume", "-s", "workspace-write", "--ask-for-approval=on-request"]
|
|
27
|
+
)
|
|
28
|
+
self.assertEqual(args, ["resume"])
|
|
29
|
+
self.assertEqual(value["sandbox"], "workspace-write")
|
|
30
|
+
|
|
31
|
+
def test_prompt_after_separator_is_not_parsed_as_permissions(self):
|
|
32
|
+
args, value = translate(["resume", "--", "--yolo"])
|
|
33
|
+
self.assertEqual(args, ["resume", "--", "--yolo"])
|
|
34
|
+
self.assertEqual(value, {})
|
|
35
|
+
|
|
36
|
+
def test_config_forwarded_without_native_only_profile_flag(self):
|
|
37
|
+
self.assertEqual(
|
|
38
|
+
backend_arguments(["resume", "-c", 'model_reasoning_effort="high"']),
|
|
39
|
+
["-c", 'model_reasoning_effort="high"'],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def test_setup_supports_one_or_arbitrary_count(self):
|
|
43
|
+
for count in [1, 4, 7, 32]:
|
|
44
|
+
self.assertEqual(
|
|
45
|
+
parser().parse_args(["setup", "--count", str(count)]).count, count
|
|
46
|
+
)
|
|
47
|
+
self.assertEqual(
|
|
48
|
+
parser().parse_args(["setup", "work", "personal"]).labels,
|
|
49
|
+
["work", "personal"],
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def test_account_discovery_not_fixed_to_four(self):
|
|
53
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
54
|
+
root = Path(folder)
|
|
55
|
+
for i in range(17):
|
|
56
|
+
path = root / "accounts" / f"person{i}"
|
|
57
|
+
path.mkdir(parents=True)
|
|
58
|
+
(path / "enrolled").write_text("{}")
|
|
59
|
+
self.assertEqual(len(labels(root)), 17)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RegistryTests(unittest.TestCase):
|
|
63
|
+
def test_maintenance_and_reservations_cannot_race(self):
|
|
64
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
65
|
+
root = Path(folder)
|
|
66
|
+
one = Pool(root)
|
|
67
|
+
two = Pool(root)
|
|
68
|
+
self.assertTrue(one.reserve("a"))
|
|
69
|
+
self.assertFalse(two.begin_maintenance("a"))
|
|
70
|
+
one.release_reservation()
|
|
71
|
+
self.assertTrue(two.begin_maintenance("a"))
|
|
72
|
+
self.assertFalse(one.reserve("a"))
|
|
73
|
+
two.set_options("a", maintenance=False, enabled=False)
|
|
74
|
+
self.assertFalse(one.reserve("a"))
|
|
75
|
+
|
|
76
|
+
def test_interrupted_maintenance_does_not_permanently_disable_account(self):
|
|
77
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
78
|
+
pool = Pool(Path(folder))
|
|
79
|
+
pool.set_options(
|
|
80
|
+
"a",
|
|
81
|
+
maintenance=True,
|
|
82
|
+
maintenance_pid=os.getpid(),
|
|
83
|
+
maintenance_birth="old process generation",
|
|
84
|
+
)
|
|
85
|
+
self.assertFalse(pool.options("a")["maintenance"])
|
|
86
|
+
self.assertTrue(pool.reserve("a"))
|
|
87
|
+
|
|
88
|
+
def test_inflight_reservation_is_renewed_until_assignment(self):
|
|
89
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
90
|
+
now = [1000]
|
|
91
|
+
root = Path(folder)
|
|
92
|
+
one = Pool(root, clock=lambda: now[0])
|
|
93
|
+
two = Pool(root, clock=lambda: now[0])
|
|
94
|
+
one.reserve("a", ttl=120)
|
|
95
|
+
now[0] = 1100
|
|
96
|
+
one.renew_reservation(120)
|
|
97
|
+
now[0] = 1150
|
|
98
|
+
self.assertFalse(two.reserve("a", limit=1))
|
|
99
|
+
one.assign("a")
|
|
100
|
+
self.assertFalse(two.reserve("a", limit=1))
|
|
101
|
+
|
|
102
|
+
def test_metadata_corruption_restore_retains_replaced_database(self):
|
|
103
|
+
import sqlite3
|
|
104
|
+
from metadata import restore_backup
|
|
105
|
+
|
|
106
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
107
|
+
root = Path(folder)
|
|
108
|
+
pool = Pool(root)
|
|
109
|
+
pool.set_options("one", priority=7)
|
|
110
|
+
source = sqlite3.connect(root / "pool.sqlite")
|
|
111
|
+
backup = sqlite3.connect(root / "pool.backup.sqlite")
|
|
112
|
+
source.backup(backup)
|
|
113
|
+
source.close()
|
|
114
|
+
backup.close()
|
|
115
|
+
(root / "pool.sqlite").write_bytes(b"synthetic corruption")
|
|
116
|
+
saved = Path(restore_backup(root))
|
|
117
|
+
self.assertEqual(
|
|
118
|
+
(saved / "pool.sqlite").read_bytes(), b"synthetic corruption"
|
|
119
|
+
)
|
|
120
|
+
self.assertEqual(Pool(root).options("one")["priority"], 7)
|
|
121
|
+
|
|
122
|
+
def test_restore_refuses_live_worker_without_changing_database(self):
|
|
123
|
+
import sqlite3
|
|
124
|
+
from metadata import restore_backup
|
|
125
|
+
|
|
126
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
127
|
+
root = Path(folder)
|
|
128
|
+
pool = Pool(root)
|
|
129
|
+
pool.assign("one")
|
|
130
|
+
source = sqlite3.connect(root / "pool.sqlite")
|
|
131
|
+
backup = sqlite3.connect(root / "pool.backup.sqlite")
|
|
132
|
+
source.backup(backup)
|
|
133
|
+
source.close()
|
|
134
|
+
backup.close()
|
|
135
|
+
with self.assertRaises(ValueError):
|
|
136
|
+
restore_backup(root)
|
|
137
|
+
self.assertEqual(len(pool.detail()), 1)
|
|
138
|
+
|
|
139
|
+
def test_replacing_identity_clears_all_model_blocks(self):
|
|
140
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
141
|
+
pool = Pool(Path(folder))
|
|
142
|
+
pool.mark("one", "model-a")
|
|
143
|
+
pool.mark("one", "model-b")
|
|
144
|
+
pool.mark("one")
|
|
145
|
+
pool.reset_identity("one")
|
|
146
|
+
self.assertEqual(pool.deadline("one", "model-a"), 0)
|
|
147
|
+
self.assertEqual(pool.deadline("one", "model-b"), 0)
|
|
148
|
+
|
|
149
|
+
def test_reservations_are_atomic_and_expire(self):
|
|
150
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
151
|
+
now = [1000]
|
|
152
|
+
one = Pool(Path(folder), clock=lambda: now[0])
|
|
153
|
+
two = Pool(Path(folder), clock=lambda: now[0])
|
|
154
|
+
self.assertTrue(one.reserve("a", expected_load=0, ttl=10))
|
|
155
|
+
self.assertFalse(two.reserve("a", expected_load=0, ttl=10))
|
|
156
|
+
now[0] = 1011
|
|
157
|
+
self.assertTrue(two.reserve("a", expected_load=0))
|
|
158
|
+
|
|
159
|
+
def test_process_birth_prevents_pid_reuse_lock(self):
|
|
160
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
161
|
+
one = Pool(Path(folder))
|
|
162
|
+
one.assign("a")
|
|
163
|
+
one.claim("t")
|
|
164
|
+
with one.db() as db:
|
|
165
|
+
db.execute(
|
|
166
|
+
"UPDATE session_details SET birth='wrong process generation' WHERE id=?",
|
|
167
|
+
(one.session,),
|
|
168
|
+
)
|
|
169
|
+
two = Pool(Path(folder))
|
|
170
|
+
self.assertTrue(two.claim("t"))
|
|
171
|
+
|
|
172
|
+
def test_configuration_validation_and_profiles(self):
|
|
173
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
174
|
+
root = Path(folder)
|
|
175
|
+
settings.edit(root, "retry_interval", "10", "quick")
|
|
176
|
+
self.assertEqual(settings.load(root, profile="quick")["retry_interval"], 10)
|
|
177
|
+
self.assertEqual(settings.load(root)["retry_interval"], 300)
|
|
178
|
+
with self.assertRaises(ValueError):
|
|
179
|
+
settings.edit(root, "retry_interval", "-1")
|
|
180
|
+
with self.assertRaises(ValueError):
|
|
181
|
+
settings.edit(root, "not_a_setting", "1")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class RecoveryTests(unittest.IsolatedAsyncioTestCase):
|
|
185
|
+
async def asyncSetUp(self):
|
|
186
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
187
|
+
self.root = Path(self.tmp.name)
|
|
188
|
+
self.pool = Pool(self.root)
|
|
189
|
+
self.backend = FakeBackend()
|
|
190
|
+
self.c = Controller(
|
|
191
|
+
[FakeAccount("one"), FakeAccount("two")],
|
|
192
|
+
self.backend,
|
|
193
|
+
pool=self.pool,
|
|
194
|
+
report=lambda x: None,
|
|
195
|
+
)
|
|
196
|
+
self.c.client = FakeClient()
|
|
197
|
+
|
|
198
|
+
async def asyncTearDown(self):
|
|
199
|
+
if self.c.recovery:
|
|
200
|
+
self.c.recovery.cancel()
|
|
201
|
+
await asyncio.gather(self.c.recovery, return_exceptions=True)
|
|
202
|
+
self.pool.close()
|
|
203
|
+
self.tmp.cleanup()
|
|
204
|
+
|
|
205
|
+
async def test_sticky_model_does_not_revert_after_partial_turn(self):
|
|
206
|
+
self.c.thread_settings["t"] = {"model": "old"}
|
|
207
|
+
await self.c.forward(
|
|
208
|
+
{
|
|
209
|
+
"id": 1,
|
|
210
|
+
"method": "turn/start",
|
|
211
|
+
"params": {"threadId": "t", "model": "new", "input": []},
|
|
212
|
+
},
|
|
213
|
+
{},
|
|
214
|
+
)
|
|
215
|
+
await self.c.forward(
|
|
216
|
+
{"id": 2, "method": "turn/start", "params": {"threadId": "t", "input": []}},
|
|
217
|
+
{},
|
|
218
|
+
)
|
|
219
|
+
self.assertEqual(self.c.model_for("t"), "new")
|
|
220
|
+
|
|
221
|
+
async def test_resume_yolo_injected_into_server_resume(self):
|
|
222
|
+
self.c.launch_overrides = {
|
|
223
|
+
"sandbox": "danger-full-access",
|
|
224
|
+
"approvalPolicy": "never",
|
|
225
|
+
}
|
|
226
|
+
await self.c.forward(
|
|
227
|
+
{"id": 1, "method": "thread/resume", "params": {"threadId": "t"}}, {}
|
|
228
|
+
)
|
|
229
|
+
params = next(p for m, p in self.backend.calls if m == "thread/resume")
|
|
230
|
+
self.assertEqual(params["sandbox"], "danger-full-access")
|
|
231
|
+
self.assertEqual(params["approvalPolicy"], "never")
|
|
232
|
+
|
|
233
|
+
async def test_readonly_native_conversation_is_not_claimed(self):
|
|
234
|
+
async def read_only(method, params=None, **kw):
|
|
235
|
+
return {"thread": {"id": "t", "canAcceptDirectInput": False}}
|
|
236
|
+
|
|
237
|
+
self.backend.call = read_only
|
|
238
|
+
await self.c.forward(
|
|
239
|
+
{"id": 1, "method": "thread/resume", "params": {"threadId": "t"}}, {}
|
|
240
|
+
)
|
|
241
|
+
self.assertIn("error", self.c.client.messages[-1])
|
|
242
|
+
self.assertIsNone(self.pool.owner("t"))
|
|
243
|
+
|
|
244
|
+
async def test_single_account_waits_until_reset_then_continues(self):
|
|
245
|
+
clock = Clock()
|
|
246
|
+
self.c.clock = clock
|
|
247
|
+
self.c.sleep = clock.sleep
|
|
248
|
+
self.pool.clock = clock
|
|
249
|
+
self.c.accounts = [FakeAccount("one")]
|
|
250
|
+
self.c.last_turn["t"] = {"model": "m"}
|
|
251
|
+
calls = [0]
|
|
252
|
+
|
|
253
|
+
async def usage(force=False):
|
|
254
|
+
calls[0] += 1
|
|
255
|
+
if clock() < 1500:
|
|
256
|
+
return {
|
|
257
|
+
"ordinaryUsageAllowed": False,
|
|
258
|
+
"rateLimits": {"primary": {"usedPercent": 100, "resetsAt": 1500}},
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
"ordinaryUsageAllowed": True,
|
|
262
|
+
"rateLimits": {"primary": {"usedPercent": 0, "resetsAt": 3000}},
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
self.c.accounts[0].usage = usage
|
|
266
|
+
await self.c.recover("t", 0, "quota")
|
|
267
|
+
self.assertEqual(clock.waits, [502])
|
|
268
|
+
self.assertEqual(calls[0], 2)
|
|
269
|
+
self.assertEqual(self.c.index, 0)
|
|
270
|
+
self.assertTrue(any(m == "turn/start" for m, _ in self.backend.calls))
|
|
271
|
+
|
|
272
|
+
async def test_dropped_ack_reconciles_completed_turn_without_replay(self):
|
|
273
|
+
async def read(method, params=None, **kw):
|
|
274
|
+
if method == "thread/turns/list":
|
|
275
|
+
return {"data": [{"id": "new", "status": "completed"}]}
|
|
276
|
+
if method == "thread/read":
|
|
277
|
+
return {"thread": {"status": {"type": "idle"}}}
|
|
278
|
+
raise AssertionError("No turn should be submitted during reconciliation")
|
|
279
|
+
|
|
280
|
+
self.backend.call = read
|
|
281
|
+
self.c.last_turn["t"] = {"threadId": "t"}
|
|
282
|
+
await self.c.uncertain("t", 0, "old")
|
|
283
|
+
self.assertFalse(self.c.paused_uncertain)
|
|
284
|
+
|
|
285
|
+
async def test_unknown_acceptance_is_not_blindly_replayed(self):
|
|
286
|
+
clock = Clock()
|
|
287
|
+
self.c.clock = clock
|
|
288
|
+
self.c.sleep = clock.sleep
|
|
289
|
+
self.c.last_turn["t"] = {}
|
|
290
|
+
await self.c.uncertain("t", 0, "old")
|
|
291
|
+
self.assertIn("t", self.c.paused_uncertain)
|
|
292
|
+
self.assertFalse(any(m == "turn/start" for m, _ in self.backend.calls))
|
|
293
|
+
self.assertEqual(clock.waits, [300, 300, 300])
|
|
294
|
+
|
|
295
|
+
async def test_disabled_account_skipped(self):
|
|
296
|
+
self.pool.set_options("two", enabled=False)
|
|
297
|
+
index, _ = await self.c.choose("m")
|
|
298
|
+
self.assertEqual(index, 0)
|
|
299
|
+
|
|
300
|
+
async def test_session_cap_is_honored(self):
|
|
301
|
+
other = Pool(self.root)
|
|
302
|
+
other.assign("two")
|
|
303
|
+
self.pool.set_options("two", max_sessions=1)
|
|
304
|
+
try:
|
|
305
|
+
index, _ = await self.c.choose("m")
|
|
306
|
+
self.assertEqual(index, 0)
|
|
307
|
+
finally:
|
|
308
|
+
other.close()
|
|
309
|
+
|
|
310
|
+
async def test_manual_switch_honors_caps_and_disabled_accounts(self):
|
|
311
|
+
self.pool.set_options("two", enabled=False)
|
|
312
|
+
with self.assertRaises(RouterError):
|
|
313
|
+
await self.c.control("switch", {"account": "two"})
|
|
314
|
+
self.pool.set_options("two", enabled=True, max_sessions=1)
|
|
315
|
+
other = Pool(self.root)
|
|
316
|
+
other.assign("two")
|
|
317
|
+
try:
|
|
318
|
+
with self.assertRaises(RouterError):
|
|
319
|
+
await self.c.control("switch", {"account": "two"})
|
|
320
|
+
self.assertEqual(self.c.index, 0)
|
|
321
|
+
finally:
|
|
322
|
+
other.close()
|
|
323
|
+
|
|
324
|
+
async def test_restored_retry_keeps_original_deadline_and_attempt(self):
|
|
325
|
+
clock = Clock()
|
|
326
|
+
self.c.clock = clock
|
|
327
|
+
self.c.sleep = clock.sleep
|
|
328
|
+
self.c.last_turn["t"] = {"threadId": "t"}
|
|
329
|
+
await self.c.restore_recovery(
|
|
330
|
+
"t",
|
|
331
|
+
{
|
|
332
|
+
"accepted": True,
|
|
333
|
+
"kind": "transient",
|
|
334
|
+
"next_wake": 1250,
|
|
335
|
+
"account_label": "one",
|
|
336
|
+
"episode": {"account": 99, "start": 900, "attempt": 2},
|
|
337
|
+
},
|
|
338
|
+
)
|
|
339
|
+
self.assertEqual(clock.waits, [250])
|
|
340
|
+
self.assertEqual(
|
|
341
|
+
self.c.episodes["t"], {"account": 0, "start": 900, "attempt": 2}
|
|
342
|
+
)
|
|
343
|
+
self.assertEqual(sum(m == "turn/start" for m, _ in self.backend.calls), 1)
|
|
344
|
+
|
|
345
|
+
async def test_restored_uncertain_action_pauses_without_replay(self):
|
|
346
|
+
await self.c.restore_recovery("t", {"accepted": True, "active_items": True})
|
|
347
|
+
self.assertIn("t", self.c.paused_uncertain)
|
|
348
|
+
self.assertFalse(any(m == "turn/start" for m, _ in self.backend.calls))
|
|
349
|
+
|
|
350
|
+
async def test_pending_approval_replayed_once_after_resume(self):
|
|
351
|
+
async def read(method, params=None, **kw):
|
|
352
|
+
return {"thread": {"id": "t"}}
|
|
353
|
+
|
|
354
|
+
self.backend.call = read
|
|
355
|
+
self.c.client = None
|
|
356
|
+
pending = {
|
|
357
|
+
"id": "approval-1",
|
|
358
|
+
"method": "item/commandExecution/requestApproval",
|
|
359
|
+
"params": {"threadId": "t"},
|
|
360
|
+
}
|
|
361
|
+
await self.c.event(pending)
|
|
362
|
+
self.c.client = FakeClient()
|
|
363
|
+
for i in range(2):
|
|
364
|
+
await self.c.forward(
|
|
365
|
+
{"id": i, "method": "thread/resume", "params": {"threadId": "t"}}, {}
|
|
366
|
+
)
|
|
367
|
+
self.assertEqual(self.c.client.messages.count(pending), 1)
|
|
368
|
+
await self.c.forward(
|
|
369
|
+
{"id": "approval-1", "result": {"decision": "decline"}}, {}
|
|
370
|
+
)
|
|
371
|
+
self.assertFalse(self.c.pending_server_requests)
|
|
372
|
+
self.assertEqual(self.backend.sent[-1]["result"]["decision"], "decline")
|
|
373
|
+
|
|
374
|
+
async def test_model_fallback_is_opt_in_and_does_not_loop(self):
|
|
375
|
+
self.c.last_turn["t"] = {"threadId": "t", "model": "original"}
|
|
376
|
+
self.c.preferences["fallback_models"] = ["fallback"]
|
|
377
|
+
await self.c.recover("t", 0, "model")
|
|
378
|
+
self.assertEqual(self.c.model_for("t"), "fallback")
|
|
379
|
+
await self.c.recover("t", 0, "model")
|
|
380
|
+
self.assertEqual(sum(m == "turn/start" for m, _ in self.backend.calls), 1)
|
|
381
|
+
|
|
382
|
+
async def test_six_simultaneous_quota_handoffs_obey_shared_capacity(self):
|
|
383
|
+
controllers = []
|
|
384
|
+
registries = []
|
|
385
|
+
|
|
386
|
+
class YieldingAccount(FakeAccount):
|
|
387
|
+
async def usage(self, force=False):
|
|
388
|
+
await asyncio.sleep(0.001)
|
|
389
|
+
return await super().usage(force)
|
|
390
|
+
|
|
391
|
+
for i in range(6):
|
|
392
|
+
pool = Pool(self.root)
|
|
393
|
+
registries.append(pool)
|
|
394
|
+
pool.assign("spent")
|
|
395
|
+
c = Controller(
|
|
396
|
+
[YieldingAccount("spent", blocked=True)]
|
|
397
|
+
+ [YieldingAccount("spare" + str(j)) for j in range(3)],
|
|
398
|
+
FakeBackend(),
|
|
399
|
+
pool=pool,
|
|
400
|
+
report=lambda x: None,
|
|
401
|
+
)
|
|
402
|
+
c.client = FakeClient()
|
|
403
|
+
c.last_turn["t" + str(i)] = {"threadId": "t" + str(i)}
|
|
404
|
+
controllers.append(c)
|
|
405
|
+
for j in range(3):
|
|
406
|
+
self.pool.set_options("spare" + str(j), max_sessions=2)
|
|
407
|
+
try:
|
|
408
|
+
await asyncio.wait_for(
|
|
409
|
+
asyncio.gather(
|
|
410
|
+
*(
|
|
411
|
+
c.recover("t" + str(i), 0, "quota")
|
|
412
|
+
for i, c in enumerate(controllers)
|
|
413
|
+
)
|
|
414
|
+
),
|
|
415
|
+
12,
|
|
416
|
+
)
|
|
417
|
+
loads = self.pool.loads()
|
|
418
|
+
self.assertEqual(
|
|
419
|
+
[loads.get("spare" + str(j), 0) for j in range(3)], [2, 2, 2]
|
|
420
|
+
)
|
|
421
|
+
self.assertTrue(
|
|
422
|
+
all(
|
|
423
|
+
sum(m == "turn/start" for m, _ in c.backend.calls) == 1
|
|
424
|
+
for c in controllers
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
finally:
|
|
428
|
+
for pool in registries:
|
|
429
|
+
pool.close()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
class NativeTransportTests(unittest.IsolatedAsyncioTestCase):
|
|
433
|
+
async def asyncSetUp(self):
|
|
434
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
435
|
+
self.home = Path(self.tmp.name)
|
|
436
|
+
script = self.home / "codex"
|
|
437
|
+
script.write_text(
|
|
438
|
+
"#!"
|
|
439
|
+
+ str(Path(__import__("sys").executable).resolve())
|
|
440
|
+
+ "\n"
|
|
441
|
+
+ Path(__file__).with_name("fake_native.py").read_text()
|
|
442
|
+
)
|
|
443
|
+
script.chmod(0o700)
|
|
444
|
+
self.binary = str(script)
|
|
445
|
+
self.rpc = await RPC.start(self.binary, self.home)
|
|
446
|
+
await self.rpc.initialize()
|
|
447
|
+
|
|
448
|
+
async def asyncTearDown(self):
|
|
449
|
+
await self.rpc.close()
|
|
450
|
+
self.tmp.cleanup()
|
|
451
|
+
|
|
452
|
+
async def completed_turn(self, thread):
|
|
453
|
+
async with asyncio.timeout(5):
|
|
454
|
+
while True:
|
|
455
|
+
result = await self.rpc.call(
|
|
456
|
+
"thread/turns/list", {"threadId": thread, "limit": 1}
|
|
457
|
+
)
|
|
458
|
+
if result["data"] and result["data"][0]["status"] == "completed":
|
|
459
|
+
return result["data"][0]
|
|
460
|
+
await asyncio.sleep(0.02)
|
|
461
|
+
|
|
462
|
+
async def test_actual_jsonl_pipe_roundtrip_and_persisted_history(self):
|
|
463
|
+
result = await self.rpc.call("thread/start", {"cwd": str(self.home)})
|
|
464
|
+
thread = result["thread"]["id"]
|
|
465
|
+
await self.rpc.call("turn/start", {"threadId": thread, "input": []})
|
|
466
|
+
result = await self.completed_turn(thread)
|
|
467
|
+
self.assertEqual(result["status"], "completed")
|
|
468
|
+
|
|
469
|
+
async def test_watchdog_clears_stale_active_thread_from_native_status(self):
|
|
470
|
+
result = await self.rpc.call("thread/start", {"cwd": str(self.home)})
|
|
471
|
+
thread = result["thread"]["id"]
|
|
472
|
+
c = Controller([FakeAccount("one")], self.rpc, report=lambda x: None)
|
|
473
|
+
c.client = FakeClient()
|
|
474
|
+
c.active_threads.add(thread)
|
|
475
|
+
await c.reconcile(thread)
|
|
476
|
+
self.assertNotIn(thread, c.active_threads)
|
|
477
|
+
|
|
478
|
+
async def test_background_socket_disconnect_does_not_cancel_task(self):
|
|
479
|
+
from websockets.asyncio.client import connect
|
|
480
|
+
from websockets.asyncio.server import serve
|
|
481
|
+
from router import transport_guard
|
|
482
|
+
|
|
483
|
+
c = Controller([FakeAccount("one")], self.rpc, report=lambda x: None)
|
|
484
|
+
c.background = True
|
|
485
|
+
self.rpc.handler = c.event
|
|
486
|
+
result = await self.rpc.call("thread/start", {"cwd": str(self.home)})
|
|
487
|
+
thread = result["thread"]["id"]
|
|
488
|
+
async with serve(
|
|
489
|
+
lambda ws: c.connect(ws, {}),
|
|
490
|
+
"127.0.0.1",
|
|
491
|
+
0,
|
|
492
|
+
process_request=transport_guard("test"),
|
|
493
|
+
) as server:
|
|
494
|
+
url = f"ws://127.0.0.1:{server.sockets[0].getsockname()[1]}"
|
|
495
|
+
async with connect(
|
|
496
|
+
url, additional_headers={"Authorization": "Bearer test"}, proxy=None
|
|
497
|
+
) as ws:
|
|
498
|
+
await ws.send(
|
|
499
|
+
json.dumps(
|
|
500
|
+
{
|
|
501
|
+
"id": 1,
|
|
502
|
+
"method": "turn/start",
|
|
503
|
+
"params": {"threadId": thread, "input": []},
|
|
504
|
+
}
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
while True:
|
|
508
|
+
value = json.loads(await ws.recv())
|
|
509
|
+
if value.get("id") == 1:
|
|
510
|
+
break
|
|
511
|
+
latest = await self.completed_turn(thread)
|
|
512
|
+
self.assertEqual(c.user_generation, 1)
|
|
513
|
+
self.assertEqual(latest["status"], "completed")
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
if __name__ == "__main__":
|
|
517
|
+
unittest.main()
|