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,473 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from pool import Pool
|
|
12
|
+
from settings import atomic_json
|
|
13
|
+
import service
|
|
14
|
+
import broker
|
|
15
|
+
from test_router import FakeAccount
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fake_executable(root):
|
|
19
|
+
path = root / "codex"
|
|
20
|
+
path.write_text(
|
|
21
|
+
"#!"
|
|
22
|
+
+ str(Path(sys.executable).resolve())
|
|
23
|
+
+ "\n"
|
|
24
|
+
+ Path(__file__).with_name("fake_native.py").read_text()
|
|
25
|
+
)
|
|
26
|
+
path.chmod(0o700)
|
|
27
|
+
return str(path)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def wait_for(predicate, timeout=8):
|
|
31
|
+
deadline = asyncio.get_running_loop().time() + timeout
|
|
32
|
+
while asyncio.get_running_loop().time() < deadline:
|
|
33
|
+
value = predicate()
|
|
34
|
+
if value:
|
|
35
|
+
return value
|
|
36
|
+
await asyncio.sleep(0.03)
|
|
37
|
+
raise AssertionError("Timed out waiting for test state")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class BrokerTests(unittest.IsolatedAsyncioTestCase):
|
|
41
|
+
async def test_coordinator_shares_native_helper_and_usage_read_across_six_clients(
|
|
42
|
+
self,
|
|
43
|
+
):
|
|
44
|
+
with tempfile.TemporaryDirectory(prefix="codex-broker-test-") as folder:
|
|
45
|
+
root = Path(folder)
|
|
46
|
+
binary = fake_executable(root)
|
|
47
|
+
home = root / "accounts" / "only"
|
|
48
|
+
home.mkdir(parents=True)
|
|
49
|
+
(home / "enrolled").write_text("{}")
|
|
50
|
+
try:
|
|
51
|
+
await broker.ensure(root, binary)
|
|
52
|
+
results = await asyncio.gather(
|
|
53
|
+
*(
|
|
54
|
+
broker.exchange(root, {"op": "usage", "label": "only"})
|
|
55
|
+
for _ in range(6)
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
self.assertTrue(
|
|
59
|
+
all(
|
|
60
|
+
r["rateLimits"]["primary"]["usedPercent"] == 20 for r in results
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
calls = json.loads((home / "fake-history.json").read_text())["calls"]
|
|
64
|
+
self.assertEqual(sum(c["method"] == "initialize" for c in calls), 1)
|
|
65
|
+
self.assertEqual(
|
|
66
|
+
sum(c["method"] == "account/rateLimits/read" for c in calls), 1
|
|
67
|
+
)
|
|
68
|
+
_, meta = broker.paths(root)
|
|
69
|
+
self.assertEqual(meta.stat().st_mode & 0o777, 0o600)
|
|
70
|
+
# Wrong capability cannot fetch a response or credentials.
|
|
71
|
+
original = json.loads(meta.read_text())
|
|
72
|
+
bad = dict(original, token="wrong")
|
|
73
|
+
atomic_json(meta, bad)
|
|
74
|
+
with self.assertRaises(RuntimeError):
|
|
75
|
+
await broker.exchange(root, {"op": "usage", "label": "only"})
|
|
76
|
+
atomic_json(meta, original)
|
|
77
|
+
finally:
|
|
78
|
+
with contextlib.suppress(Exception):
|
|
79
|
+
await broker.exchange(root, {"op": "stop"})
|
|
80
|
+
await wait_for(lambda: not broker.paths(root)[1].exists())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class ServiceTests(unittest.IsolatedAsyncioTestCase):
|
|
84
|
+
async def asyncSetUp(self):
|
|
85
|
+
self.tmp = tempfile.TemporaryDirectory(prefix="codex-service-test-")
|
|
86
|
+
self.root = Path(self.tmp.name)
|
|
87
|
+
self.state = self.root / "state"
|
|
88
|
+
self.state.mkdir()
|
|
89
|
+
self.home = self.root / "home"
|
|
90
|
+
self.home.mkdir()
|
|
91
|
+
self.binary = fake_executable(self.root)
|
|
92
|
+
account = self.state / "accounts" / "only"
|
|
93
|
+
account.mkdir(parents=True)
|
|
94
|
+
(account / "enrolled").write_text("{}")
|
|
95
|
+
atomic_json(
|
|
96
|
+
self.state / "settings.json",
|
|
97
|
+
{"settings": {"watchdog_interval": 1, "retry_interval": 1}},
|
|
98
|
+
)
|
|
99
|
+
self.ident = "service-test-owner"
|
|
100
|
+
self.job = service.folder(self.state) / (self.ident + ".job.json")
|
|
101
|
+
atomic_json(
|
|
102
|
+
self.job,
|
|
103
|
+
{
|
|
104
|
+
"id": self.ident,
|
|
105
|
+
"name": "unit task",
|
|
106
|
+
"cwd": str(self.home),
|
|
107
|
+
"first": None,
|
|
108
|
+
"arguments": [],
|
|
109
|
+
"resume": None,
|
|
110
|
+
"prompt": "test only",
|
|
111
|
+
"profile": None,
|
|
112
|
+
"state": "starting",
|
|
113
|
+
"binary": self.binary,
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
class Account(FakeAccount):
|
|
118
|
+
def __init__(self, label, *args):
|
|
119
|
+
super().__init__(label)
|
|
120
|
+
|
|
121
|
+
async def close(self):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
self.account_patch = patch("broker.ClientAccount", Account)
|
|
125
|
+
self.account_patch.start()
|
|
126
|
+
self.env = patch.dict(os.environ, {"CODEX_HOME": str(self.home)})
|
|
127
|
+
self.env.start()
|
|
128
|
+
self.task = asyncio.create_task(
|
|
129
|
+
service.serve(self.state, self.ident, self.binary)
|
|
130
|
+
)
|
|
131
|
+
self.meta = service.folder(self.state) / (self.ident + ".connection.json")
|
|
132
|
+
await wait_for(self.meta.exists)
|
|
133
|
+
|
|
134
|
+
async def asyncTearDown(self):
|
|
135
|
+
pool = Pool(self.state)
|
|
136
|
+
pool.command(self.ident, "stop")
|
|
137
|
+
pool.close()
|
|
138
|
+
try:
|
|
139
|
+
await asyncio.wait_for(self.task, 5)
|
|
140
|
+
except (asyncio.TimeoutError, Exception):
|
|
141
|
+
self.task.cancel()
|
|
142
|
+
await asyncio.gather(self.task, return_exceptions=True)
|
|
143
|
+
self.env.stop()
|
|
144
|
+
self.account_patch.stop()
|
|
145
|
+
self.tmp.cleanup()
|
|
146
|
+
|
|
147
|
+
async def test_background_work_completes_without_frontend(self):
|
|
148
|
+
def completed():
|
|
149
|
+
path = self.home / "fake-history.json"
|
|
150
|
+
value = json.loads(path.read_text())
|
|
151
|
+
return any(
|
|
152
|
+
t["turns"] and t["turns"][-1]["status"] == "completed"
|
|
153
|
+
for t in value["threads"].values()
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
await wait_for(completed)
|
|
157
|
+
self.assertFalse(self.task.done())
|
|
158
|
+
self.assertIsNone(json.loads(self.job.read_text())["prompt"])
|
|
159
|
+
self.assertEqual(self.meta.stat().st_mode & 0o777, 0o600)
|
|
160
|
+
|
|
161
|
+
async def test_capacity_message_continues_over_real_jsonl_transport_without_relogin(
|
|
162
|
+
self,
|
|
163
|
+
):
|
|
164
|
+
from websockets.asyncio.client import connect
|
|
165
|
+
|
|
166
|
+
history = self.home / "fake-history.json"
|
|
167
|
+
await wait_for(
|
|
168
|
+
lambda: any(
|
|
169
|
+
t["turns"] and t["turns"][-1]["status"] == "completed"
|
|
170
|
+
for t in json.loads(history.read_text())["threads"].values()
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
meta = json.loads(self.meta.read_text())
|
|
174
|
+
thread = meta["thread"]
|
|
175
|
+
atomic_json(self.home / "fault.json", {"capacity_turns": 2})
|
|
176
|
+
async with connect(
|
|
177
|
+
meta["url"],
|
|
178
|
+
additional_headers={"Authorization": "Bearer " + meta["token"]},
|
|
179
|
+
proxy=None,
|
|
180
|
+
) as ws:
|
|
181
|
+
await ws.send(
|
|
182
|
+
json.dumps(
|
|
183
|
+
{
|
|
184
|
+
"id": "capacity-test",
|
|
185
|
+
"method": "turn/start",
|
|
186
|
+
"params": {
|
|
187
|
+
"threadId": thread,
|
|
188
|
+
"input": [
|
|
189
|
+
{
|
|
190
|
+
"type": "text",
|
|
191
|
+
"text": "fake only",
|
|
192
|
+
"text_elements": [],
|
|
193
|
+
}
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
while True:
|
|
200
|
+
message = json.loads(await ws.recv())
|
|
201
|
+
if message.get("id") == "capacity-test":
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
def recovered():
|
|
205
|
+
saved = json.loads(history.read_text())
|
|
206
|
+
turns = saved["threads"][thread]["turns"]
|
|
207
|
+
return len(turns) == 3 and turns[-1]["status"] == "completed"
|
|
208
|
+
|
|
209
|
+
await wait_for(recovered)
|
|
210
|
+
saved = json.loads(history.read_text())
|
|
211
|
+
self.assertEqual(
|
|
212
|
+
saved["threads"][thread]["turns"][1]["error"]["codexErrorInfo"],
|
|
213
|
+
"policyCapacityUnavailable",
|
|
214
|
+
)
|
|
215
|
+
self.assertEqual(
|
|
216
|
+
sum(c["method"] == "account/login/start" for c in saved["calls"]), 1
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
async def test_pause_resume_and_control_acknowledgments(self):
|
|
220
|
+
await service.send_control(self.state, self.ident, "pause")
|
|
221
|
+
pool = Pool(self.state)
|
|
222
|
+
try:
|
|
223
|
+
self.assertTrue(
|
|
224
|
+
next(s for s in pool.detail() if s["id"] == self.ident)["paused"]
|
|
225
|
+
)
|
|
226
|
+
finally:
|
|
227
|
+
pool.close()
|
|
228
|
+
await service.send_control(self.state, self.ident, "resume")
|
|
229
|
+
|
|
230
|
+
async def test_pause_survives_worker_reconstruction(self):
|
|
231
|
+
await service.send_control(self.state, self.ident, "pause")
|
|
232
|
+
self.task.cancel()
|
|
233
|
+
await asyncio.gather(self.task, return_exceptions=True)
|
|
234
|
+
self.task = asyncio.create_task(
|
|
235
|
+
service.serve(self.state, self.ident, self.binary)
|
|
236
|
+
)
|
|
237
|
+
await wait_for(self.meta.exists)
|
|
238
|
+
pool = Pool(self.state)
|
|
239
|
+
try:
|
|
240
|
+
self.assertTrue(
|
|
241
|
+
next(s for s in pool.detail() if s["id"] == self.ident)["paused"]
|
|
242
|
+
)
|
|
243
|
+
finally:
|
|
244
|
+
pool.close()
|
|
245
|
+
|
|
246
|
+
async def test_worker_restore_keeps_review_pause_even_if_latest_error_is_recoverable(
|
|
247
|
+
self,
|
|
248
|
+
):
|
|
249
|
+
history = self.home / "fake-history.json"
|
|
250
|
+
await wait_for(
|
|
251
|
+
lambda: any(
|
|
252
|
+
t["turns"] and t["turns"][-1]["status"] == "completed"
|
|
253
|
+
for t in json.loads(history.read_text())["threads"].values()
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
self.task.cancel()
|
|
257
|
+
await asyncio.gather(self.task, return_exceptions=True)
|
|
258
|
+
saved = json.loads(history.read_text())
|
|
259
|
+
thread = next(iter(saved["threads"]))
|
|
260
|
+
saved["threads"][thread]["turns"][-1].update(
|
|
261
|
+
status="failed",
|
|
262
|
+
error={"codexErrorInfo": "usageLimitExceeded", "message": "synthetic"},
|
|
263
|
+
)
|
|
264
|
+
atomic_json(history, saved)
|
|
265
|
+
pool = Pool(self.state)
|
|
266
|
+
pool.session = self.ident
|
|
267
|
+
details = pool.intent(thread)
|
|
268
|
+
details.update(accepted=True, active_items=True, state="failed")
|
|
269
|
+
pool.intent(thread, details)
|
|
270
|
+
starts = sum(c["method"] == "turn/start" for c in saved["calls"])
|
|
271
|
+
self.task = asyncio.create_task(
|
|
272
|
+
service.serve(self.state, self.ident, self.binary)
|
|
273
|
+
)
|
|
274
|
+
await wait_for(self.meta.exists)
|
|
275
|
+
try:
|
|
276
|
+
row = next(s for s in pool.detail() if s["id"] == self.ident)
|
|
277
|
+
self.assertEqual(row["state"], "needs_attention")
|
|
278
|
+
self.assertEqual(
|
|
279
|
+
sum(
|
|
280
|
+
c["method"] == "turn/start"
|
|
281
|
+
for c in json.loads(history.read_text())["calls"]
|
|
282
|
+
),
|
|
283
|
+
starts,
|
|
284
|
+
)
|
|
285
|
+
finally:
|
|
286
|
+
pool.close()
|
|
287
|
+
|
|
288
|
+
async def test_restore_distinguishes_old_completion_from_accepted_new_work(self):
|
|
289
|
+
history = self.home / "fake-history.json"
|
|
290
|
+
await wait_for(
|
|
291
|
+
lambda: any(
|
|
292
|
+
t["turns"] and t["turns"][-1]["status"] == "completed"
|
|
293
|
+
for t in json.loads(history.read_text())["threads"].values()
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
saved = json.loads(history.read_text())
|
|
297
|
+
thread = next(iter(saved["threads"]))
|
|
298
|
+
turn = saved["threads"][thread]["turns"][-1]["id"]
|
|
299
|
+
starts = sum(c["method"] == "turn/start" for c in saved["calls"])
|
|
300
|
+
pool = Pool(self.state)
|
|
301
|
+
pool.session = self.ident
|
|
302
|
+
try:
|
|
303
|
+
for accepted, expected in [(False, "needs_attention"), (True, "idle")]:
|
|
304
|
+
self.task.cancel()
|
|
305
|
+
await asyncio.gather(self.task, return_exceptions=True)
|
|
306
|
+
details = pool.intent(thread)
|
|
307
|
+
details.update(
|
|
308
|
+
accepted=accepted,
|
|
309
|
+
active_items=False,
|
|
310
|
+
baseline=turn,
|
|
311
|
+
state="retry_scheduled" if accepted else "submitting",
|
|
312
|
+
next_wake=1,
|
|
313
|
+
)
|
|
314
|
+
pool.intent(thread, details)
|
|
315
|
+
self.task = asyncio.create_task(
|
|
316
|
+
service.serve(self.state, self.ident, self.binary)
|
|
317
|
+
)
|
|
318
|
+
await wait_for(self.meta.exists)
|
|
319
|
+
self.assertEqual(
|
|
320
|
+
next(s for s in pool.detail() if s["id"] == self.ident)["state"],
|
|
321
|
+
expected,
|
|
322
|
+
)
|
|
323
|
+
self.assertEqual(
|
|
324
|
+
sum(
|
|
325
|
+
c["method"] == "turn/start"
|
|
326
|
+
for c in json.loads(history.read_text())["calls"]
|
|
327
|
+
),
|
|
328
|
+
starts,
|
|
329
|
+
)
|
|
330
|
+
finally:
|
|
331
|
+
pool.session = "inspector"
|
|
332
|
+
pool.close()
|
|
333
|
+
|
|
334
|
+
async def test_selected_thread_restores_its_own_model_and_permissions(self):
|
|
335
|
+
await wait_for(lambda: json.loads(self.job.read_text()).get("resume"))
|
|
336
|
+
thread = json.loads(self.job.read_text())["resume"]
|
|
337
|
+
await wait_for(
|
|
338
|
+
lambda: any(
|
|
339
|
+
t["turns"] and t["turns"][-1]["status"] == "completed"
|
|
340
|
+
for t in json.loads((self.home / "fake-history.json").read_text())[
|
|
341
|
+
"threads"
|
|
342
|
+
].values()
|
|
343
|
+
)
|
|
344
|
+
)
|
|
345
|
+
self.task.cancel()
|
|
346
|
+
await asyncio.gather(self.task, return_exceptions=True)
|
|
347
|
+
pool = Pool(self.state)
|
|
348
|
+
pool.session = self.ident
|
|
349
|
+
pool.intent(
|
|
350
|
+
thread,
|
|
351
|
+
{
|
|
352
|
+
"accepted": True,
|
|
353
|
+
"state": "completed",
|
|
354
|
+
"role": "foreground",
|
|
355
|
+
"settings": {
|
|
356
|
+
"model": "model-a",
|
|
357
|
+
"sandbox": "read-only",
|
|
358
|
+
"approvalPolicy": "on-request",
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
)
|
|
362
|
+
pool.intent(
|
|
363
|
+
"zzzzz-other-thread",
|
|
364
|
+
{
|
|
365
|
+
"accepted": True,
|
|
366
|
+
"state": "completed",
|
|
367
|
+
"role": "foreground",
|
|
368
|
+
"settings": {
|
|
369
|
+
"model": "other-model",
|
|
370
|
+
"sandbox": "danger-full-access",
|
|
371
|
+
"approvalPolicy": "never",
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
)
|
|
375
|
+
self.task = asyncio.create_task(
|
|
376
|
+
service.serve(self.state, self.ident, self.binary)
|
|
377
|
+
)
|
|
378
|
+
await wait_for(self.meta.exists)
|
|
379
|
+
try:
|
|
380
|
+
restored = pool.intent(thread)["settings"]
|
|
381
|
+
self.assertEqual(restored["model"], "model-a")
|
|
382
|
+
self.assertEqual(restored["sandbox"], "read-only")
|
|
383
|
+
finally:
|
|
384
|
+
pool.close()
|
|
385
|
+
|
|
386
|
+
async def test_reattach_uses_workers_runtime_after_global_cli_update(self):
|
|
387
|
+
from unittest.mock import AsyncMock
|
|
388
|
+
|
|
389
|
+
process = type(
|
|
390
|
+
"Process", (), {"wait": AsyncMock(return_value=0), "returncode": 0}
|
|
391
|
+
)()
|
|
392
|
+
with patch(
|
|
393
|
+
"asyncio.create_subprocess_exec", new=AsyncMock(return_value=process)
|
|
394
|
+
) as spawn:
|
|
395
|
+
self.assertEqual(
|
|
396
|
+
await service.attach(self.state, self.ident, "/new/global/codex"), 0
|
|
397
|
+
)
|
|
398
|
+
self.assertEqual(spawn.call_args.args[0], self.binary)
|
|
399
|
+
|
|
400
|
+
async def test_takeover_rotates_capability_and_old_frontend_cannot_reconnect(self):
|
|
401
|
+
from websockets.asyncio.client import connect
|
|
402
|
+
from websockets.exceptions import InvalidStatus
|
|
403
|
+
|
|
404
|
+
original = json.loads(self.meta.read_text())
|
|
405
|
+
async with connect(
|
|
406
|
+
original["url"],
|
|
407
|
+
additional_headers={"Authorization": "Bearer " + original["token"]},
|
|
408
|
+
proxy=None,
|
|
409
|
+
) as ws:
|
|
410
|
+
await service.send_control(self.state, self.ident, "detach")
|
|
411
|
+
await asyncio.wait_for(ws.wait_closed(), 2)
|
|
412
|
+
new = json.loads(self.meta.read_text())
|
|
413
|
+
self.assertEqual(original["pid"], new["pid"])
|
|
414
|
+
self.assertNotEqual(original["token"], new["token"])
|
|
415
|
+
with self.assertRaises(InvalidStatus):
|
|
416
|
+
async with connect(
|
|
417
|
+
new["url"],
|
|
418
|
+
additional_headers={"Authorization": "Bearer " + original["token"]},
|
|
419
|
+
proxy=None,
|
|
420
|
+
):
|
|
421
|
+
pass
|
|
422
|
+
async with connect(
|
|
423
|
+
new["url"],
|
|
424
|
+
additional_headers={"Authorization": "Bearer " + new["token"]},
|
|
425
|
+
proxy=None,
|
|
426
|
+
):
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
async def test_failed_backend_restart_is_retried_without_poisoning_controller(self):
|
|
430
|
+
# Exercise the real worker through a failed start, then a successful one.
|
|
431
|
+
from router import Controller, RouterError
|
|
432
|
+
|
|
433
|
+
class Dead:
|
|
434
|
+
proc = type("Process", (), {"returncode": 9})()
|
|
435
|
+
|
|
436
|
+
old = Dead()
|
|
437
|
+
c = Controller([FakeAccount("only")], old, report=lambda x: None)
|
|
438
|
+
|
|
439
|
+
async def unavailable():
|
|
440
|
+
raise RouterError("test startup failure")
|
|
441
|
+
|
|
442
|
+
c.backend_factory = unavailable
|
|
443
|
+
await c.restart_backend()
|
|
444
|
+
self.assertIs(c.backend, old)
|
|
445
|
+
|
|
446
|
+
async def test_native_backend_crash_is_restarted_without_replaying_completed_prompt(
|
|
447
|
+
self,
|
|
448
|
+
):
|
|
449
|
+
def ready():
|
|
450
|
+
value = json.loads((self.home / "fake-history.json").read_text())
|
|
451
|
+
return any(
|
|
452
|
+
t["turns"] and t["turns"][-1]["status"] == "completed"
|
|
453
|
+
for t in value["threads"].values()
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
await wait_for(ready)
|
|
457
|
+
# The fake process records its own PID on the turn; no unrelated process is targeted.
|
|
458
|
+
state = json.loads((self.home / "fake-history.json").read_text())
|
|
459
|
+
thread = next(iter(state["threads"].values()))
|
|
460
|
+
os.kill(thread["runtime"], 9)
|
|
461
|
+
|
|
462
|
+
def restored():
|
|
463
|
+
value = json.loads((self.home / "fake-history.json").read_text())
|
|
464
|
+
return sum(c["method"] == "initialize" for c in value["calls"]) >= 2
|
|
465
|
+
|
|
466
|
+
await wait_for(restored)
|
|
467
|
+
await asyncio.sleep(0.2)
|
|
468
|
+
value = json.loads((self.home / "fake-history.json").read_text())
|
|
469
|
+
self.assertEqual(sum(c["method"] == "turn/start" for c in value["calls"]), 1)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
if __name__ == "__main__":
|
|
473
|
+
unittest.main()
|