relay-codex 0.7.2 → 0.7.3
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 +9 -0
- package/package.json +1 -1
- package/python/commands.py +0 -2
- package/python/router.py +92 -7
- package/python/service.py +5 -1
- package/python/terminal.py +28 -0
- package/tests/fake_native.py +20 -0
- package/tests/test_activity.py +318 -0
- package/tests/test_audit.py +1 -0
package/docs/publishing.md
CHANGED
|
@@ -8,8 +8,8 @@ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex
|
|
|
8
8
|
The GitHub repository is enough for friends to install or try Relay:
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
npm install -g github:blackspider-ops/relay#v0.7.
|
|
12
|
-
npx --yes github:blackspider-ops/relay#v0.7.
|
|
11
|
+
npm install -g github:blackspider-ops/relay#v0.7.3
|
|
12
|
+
npx --yes github:blackspider-ops/relay#v0.7.3 --version
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
`npx` runs packages; it is not a separate publishing service. Publishing
|
package/docs/troubleshooting.md
CHANGED
|
@@ -49,6 +49,15 @@ Relay rechecks this after checking the target account, so work starting during
|
|
|
49
49
|
that check also prevents switching. A rejected menu action keeps its explanation
|
|
50
50
|
visible. Use Restart for a stopped worker rather than sending it live controls.
|
|
51
51
|
|
|
52
|
+
**A usage-limit panel remains open and Relay says “waiting for activity.”**
|
|
53
|
+
|
|
54
|
+
Update Relay if you are using a version older than 0.7.3. A missed tool-completion
|
|
55
|
+
notification could leave a finished tool marked as active. Relay now checks full
|
|
56
|
+
native history for the matching tool before continuing account recovery. It keeps
|
|
57
|
+
waiting when work is still running or completion cannot be verified; approvals
|
|
58
|
+
and paused recovery still need attention. Existing workers retain the code they
|
|
59
|
+
started with, so an update takes effect after that worker is relaunched.
|
|
60
|
+
|
|
52
61
|
**Tab completion is missing.**
|
|
53
62
|
|
|
54
63
|
Load `source <(relay completion zsh)` after `compinit`, or use
|
package/package.json
CHANGED
package/python/commands.py
CHANGED
|
@@ -773,8 +773,6 @@ async def dispatch(args, state, binary):
|
|
|
773
773
|
elif args.command == "clean":
|
|
774
774
|
if args.days < 1:
|
|
775
775
|
raise ValueError("Retention must be at least one day")
|
|
776
|
-
from pool import file_lock
|
|
777
|
-
|
|
778
776
|
async with file_lock(state / "cleanup.lock"):
|
|
779
777
|
if args.apply:
|
|
780
778
|
try:
|
package/python/router.py
CHANGED
|
@@ -24,7 +24,7 @@ from pool import Pool, blocked, reset_time, failure_kind, file_lock
|
|
|
24
24
|
from launch import translate, RESUME_FIELDS
|
|
25
25
|
import settings
|
|
26
26
|
|
|
27
|
-
VERSION = "0.7.
|
|
27
|
+
VERSION = "0.7.3"
|
|
28
28
|
_support = Path.home() / "Library/Application Support"
|
|
29
29
|
_legacy = _support / "Codex Accounts"
|
|
30
30
|
_default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
|
|
@@ -434,6 +434,8 @@ class Controller:
|
|
|
434
434
|
self.recovery_jobs = {}
|
|
435
435
|
self.pending_intents = {}
|
|
436
436
|
self.active_items = set()
|
|
437
|
+
self.active_item_threads = {}
|
|
438
|
+
self.item_revision = 0
|
|
437
439
|
self.backend_factory = None
|
|
438
440
|
self.restart_lock = asyncio.Lock()
|
|
439
441
|
self.restart_task = None
|
|
@@ -786,7 +788,10 @@ class Controller:
|
|
|
786
788
|
if self.active_threads or self.active_items:
|
|
787
789
|
raise RouterError("Session is busy")
|
|
788
790
|
if thread:
|
|
789
|
-
await self.backend.call(
|
|
791
|
+
await self.backend.call(
|
|
792
|
+
"thread/resume",
|
|
793
|
+
{"threadId": thread, "excludeTurns": True, **params},
|
|
794
|
+
)
|
|
790
795
|
self.merge_settings(thread, params)
|
|
791
796
|
self.launch_overrides = {**self.launch_overrides, **params}
|
|
792
797
|
if params.get("permissions"):
|
|
@@ -877,8 +882,10 @@ class Controller:
|
|
|
877
882
|
self.pending_server_requests.clear()
|
|
878
883
|
self.replayed_requests.clear()
|
|
879
884
|
self.active_items.clear()
|
|
885
|
+
self.active_item_threads.clear()
|
|
886
|
+
self.item_revision += 1
|
|
880
887
|
for thread in list(self.last_turn):
|
|
881
|
-
params = {"threadId": thread}
|
|
888
|
+
params = {"threadId": thread, "excludeTurns": True}
|
|
882
889
|
params.update(
|
|
883
890
|
{
|
|
884
891
|
k: v
|
|
@@ -1034,11 +1041,18 @@ class Controller:
|
|
|
1034
1041
|
"fileChange",
|
|
1035
1042
|
"dynamicToolCall",
|
|
1036
1043
|
}:
|
|
1037
|
-
|
|
1038
|
-
if
|
|
1039
|
-
self.
|
|
1044
|
+
item = params["item"]
|
|
1045
|
+
if item.get("status") not in {"completed", "failed", "declined"}:
|
|
1046
|
+
self.active_items.add(item["id"])
|
|
1047
|
+
self.item_revision += 1
|
|
1048
|
+
if params.get("threadId"):
|
|
1049
|
+
self.active_item_threads[item["id"]] = params["threadId"]
|
|
1050
|
+
self.persist(params["threadId"], active_items=True)
|
|
1040
1051
|
elif method == "item/completed":
|
|
1041
|
-
|
|
1052
|
+
item = params.get("item", {}).get("id")
|
|
1053
|
+
self.active_items.discard(item)
|
|
1054
|
+
self.active_item_threads.pop(item, None)
|
|
1055
|
+
self.item_revision += 1
|
|
1042
1056
|
if params.get("threadId"):
|
|
1043
1057
|
self.persist(params["threadId"], active_items=bool(self.active_items))
|
|
1044
1058
|
elif method == "model/rerouted":
|
|
@@ -1173,6 +1187,14 @@ class Controller:
|
|
|
1173
1187
|
):
|
|
1174
1188
|
if generation != self.user_generation or not self.attached():
|
|
1175
1189
|
return False
|
|
1190
|
+
await self.reconcile_active_items()
|
|
1191
|
+
if not (
|
|
1192
|
+
self.active_threads
|
|
1193
|
+
or self.active_items
|
|
1194
|
+
or self.server_requests
|
|
1195
|
+
or self.paused
|
|
1196
|
+
):
|
|
1197
|
+
break
|
|
1176
1198
|
self.status_changed.clear()
|
|
1177
1199
|
try:
|
|
1178
1200
|
await asyncio.wait_for(
|
|
@@ -1194,6 +1216,69 @@ class Controller:
|
|
|
1194
1216
|
pass
|
|
1195
1217
|
return generation == self.user_generation and self.attached()
|
|
1196
1218
|
|
|
1219
|
+
async def reconcile_active_items(self):
|
|
1220
|
+
"""Retire missed completion events only from matching, full native history."""
|
|
1221
|
+
owners = dict(self.active_item_threads)
|
|
1222
|
+
revision = self.item_revision
|
|
1223
|
+
for thread in set(owners.values()):
|
|
1224
|
+
pending = {
|
|
1225
|
+
item
|
|
1226
|
+
for item, owner in owners.items()
|
|
1227
|
+
if owner == thread and item in self.active_items
|
|
1228
|
+
}
|
|
1229
|
+
if not pending:
|
|
1230
|
+
continue
|
|
1231
|
+
cursor = None
|
|
1232
|
+
visited = set()
|
|
1233
|
+
for _ in range(8):
|
|
1234
|
+
params = {
|
|
1235
|
+
"threadId": thread,
|
|
1236
|
+
"limit": 1,
|
|
1237
|
+
"sortDirection": "desc",
|
|
1238
|
+
"itemsView": "full",
|
|
1239
|
+
}
|
|
1240
|
+
if cursor:
|
|
1241
|
+
params["cursor"] = cursor
|
|
1242
|
+
try:
|
|
1243
|
+
result = await self.backend.call(
|
|
1244
|
+
"thread/turns/list", params, timeout=15
|
|
1245
|
+
)
|
|
1246
|
+
except (RouterError, asyncio.TimeoutError):
|
|
1247
|
+
break
|
|
1248
|
+
# A concurrent start invalidates this history read. Never clear
|
|
1249
|
+
# a newly started action using a snapshot taken before it began.
|
|
1250
|
+
if revision != self.item_revision:
|
|
1251
|
+
return
|
|
1252
|
+
confirmed = {
|
|
1253
|
+
item.get("id")
|
|
1254
|
+
for turn in result.get("data", [])
|
|
1255
|
+
if turn.get("itemsView") == "full"
|
|
1256
|
+
for item in turn.get("items", [])
|
|
1257
|
+
if item.get("type")
|
|
1258
|
+
in {
|
|
1259
|
+
"commandExecution",
|
|
1260
|
+
"mcpToolCall",
|
|
1261
|
+
"fileChange",
|
|
1262
|
+
"dynamicToolCall",
|
|
1263
|
+
}
|
|
1264
|
+
and item.get("status") in {"completed", "failed", "declined"}
|
|
1265
|
+
}
|
|
1266
|
+
cleared = pending & confirmed
|
|
1267
|
+
for item in cleared:
|
|
1268
|
+
if self.active_item_threads.get(item) == thread:
|
|
1269
|
+
self.active_items.discard(item)
|
|
1270
|
+
self.active_item_threads.pop(item, None)
|
|
1271
|
+
if cleared:
|
|
1272
|
+
pending -= cleared
|
|
1273
|
+
self.persist(thread, active_items=bool(self.active_items))
|
|
1274
|
+
if self.pool:
|
|
1275
|
+
self.pool.record("tool_activity_reconciled", count=len(cleared))
|
|
1276
|
+
self.status_changed.set()
|
|
1277
|
+
cursor = result.get("nextCursor")
|
|
1278
|
+
if not pending or not cursor or cursor in visited:
|
|
1279
|
+
break
|
|
1280
|
+
visited.add(cursor)
|
|
1281
|
+
|
|
1197
1282
|
async def continue_thread(self, thread, generation):
|
|
1198
1283
|
if not self.can_continue(thread, generation):
|
|
1199
1284
|
return
|
package/python/service.py
CHANGED
|
@@ -246,6 +246,9 @@ async def attach(
|
|
|
246
246
|
await send_control(state, ident, "detach")
|
|
247
247
|
value = json.loads(meta.read_text())
|
|
248
248
|
env["RELAY_TRANSPORT_TOKEN"] = value["token"]
|
|
249
|
+
from terminal import TerminalState
|
|
250
|
+
|
|
251
|
+
terminal_state = TerminalState()
|
|
249
252
|
proc = await asyncio.create_subprocess_exec(
|
|
250
253
|
binary,
|
|
251
254
|
"--remote",
|
|
@@ -295,6 +298,7 @@ async def attach(
|
|
|
295
298
|
proc.kill()
|
|
296
299
|
await proc.wait()
|
|
297
300
|
await asyncio.gather(waiter, return_exceptions=True)
|
|
301
|
+
terminal_state.restore()
|
|
298
302
|
if replacement:
|
|
299
303
|
print("Reconnecting to the recovered task…", flush=True)
|
|
300
304
|
value = await connection(previous=value)
|
|
@@ -590,7 +594,7 @@ async def _serve(state, ident, binary):
|
|
|
590
594
|
controller.backend_factory = factory
|
|
591
595
|
thread_id = old_thread or spec.get("resume")
|
|
592
596
|
if thread_id:
|
|
593
|
-
params = {"threadId": thread_id, **overrides}
|
|
597
|
+
params = {"threadId": thread_id, "excludeTurns": True, **overrides}
|
|
594
598
|
if old:
|
|
595
599
|
controller.merge_settings(thread_id, persisted.get("settings", {}))
|
|
596
600
|
params.update(
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Restore terminal modes even when the native frontend cannot clean up."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import termios
|
|
7
|
+
|
|
8
|
+
RESET_MODES = b"\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[?1015l\x1b[?2004l\x1b[?25h\x1b[0m"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TerminalState:
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.input_fd = None
|
|
14
|
+
self.output_fd = None
|
|
15
|
+
self.attributes = None
|
|
16
|
+
with contextlib.suppress(OSError, ValueError, AttributeError, termios.error):
|
|
17
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
18
|
+
self.input_fd = sys.stdin.fileno()
|
|
19
|
+
self.output_fd = sys.stdout.fileno()
|
|
20
|
+
self.attributes = termios.tcgetattr(self.input_fd)
|
|
21
|
+
|
|
22
|
+
def restore(self):
|
|
23
|
+
if self.attributes is None:
|
|
24
|
+
return
|
|
25
|
+
with contextlib.suppress(OSError, termios.error):
|
|
26
|
+
os.write(self.output_fd, RESET_MODES)
|
|
27
|
+
termios.tcsetattr(self.input_fd, termios.TCSANOW, self.attributes)
|
|
28
|
+
self.attributes = None
|
package/tests/fake_native.py
CHANGED
|
@@ -55,6 +55,9 @@ def finish(thread, turn, text, mode):
|
|
|
55
55
|
value = data["threads"][thread]
|
|
56
56
|
value["status"] = {"type": "idle"}
|
|
57
57
|
value["turns"][-1].update(status=status, error=error)
|
|
58
|
+
for item in value["turns"][-1]["items"]:
|
|
59
|
+
if item.get("id", "").startswith("lost-completion-"):
|
|
60
|
+
item["status"] = "completed"
|
|
58
61
|
return value["turns"][-1].copy()
|
|
59
62
|
|
|
60
63
|
value = change(apply)
|
|
@@ -208,6 +211,8 @@ for raw in sys.stdin:
|
|
|
208
211
|
]
|
|
209
212
|
)
|
|
210
213
|
}
|
|
214
|
+
for value in result["data"]:
|
|
215
|
+
value["itemsView"] = params.get("itemsView", "summary")
|
|
211
216
|
elif method == "thread/loaded/list":
|
|
212
217
|
result = {"data": change(lambda d: list(d["threads"]))}
|
|
213
218
|
elif method in {"turn/start", "review/start"}:
|
|
@@ -243,6 +248,21 @@ for raw in sys.stdin:
|
|
|
243
248
|
"params": {"threadId": thread, "turn": value},
|
|
244
249
|
}
|
|
245
250
|
)
|
|
251
|
+
if count <= fault.get("lost_completion_turns", 0):
|
|
252
|
+
tool = {
|
|
253
|
+
"id": "lost-completion-" + turn,
|
|
254
|
+
"type": "commandExecution",
|
|
255
|
+
"status": "inProgress",
|
|
256
|
+
}
|
|
257
|
+
change(
|
|
258
|
+
lambda d: d["threads"][thread]["turns"][-1]["items"].append(tool)
|
|
259
|
+
)
|
|
260
|
+
send(
|
|
261
|
+
{
|
|
262
|
+
"method": "item/started",
|
|
263
|
+
"params": {"threadId": thread, "item": tool},
|
|
264
|
+
}
|
|
265
|
+
)
|
|
246
266
|
if fault.get("external"):
|
|
247
267
|
send(
|
|
248
268
|
{
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
"""A lost item completion must not strand recovery or clear unknown work."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import pty
|
|
8
|
+
import termios
|
|
9
|
+
import tempfile
|
|
10
|
+
import tty
|
|
11
|
+
import unittest
|
|
12
|
+
from unittest.mock import patch
|
|
13
|
+
|
|
14
|
+
from router import Controller, RPC, RouterError
|
|
15
|
+
from test_router import FakeAccount, FakeBackend, FakeClient, completed
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ActivityTests(unittest.IsolatedAsyncioTestCase):
|
|
19
|
+
def test_forced_frontend_exit_restores_terminal_and_disables_mouse_reporting(self):
|
|
20
|
+
from terminal import TerminalState, RESET_MODES
|
|
21
|
+
|
|
22
|
+
master, slave = pty.openpty()
|
|
23
|
+
|
|
24
|
+
class Stream:
|
|
25
|
+
def isatty(self):
|
|
26
|
+
return True
|
|
27
|
+
|
|
28
|
+
def fileno(self):
|
|
29
|
+
return slave
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
original = termios.tcgetattr(slave)
|
|
33
|
+
with patch("sys.stdin", Stream()), patch("sys.stdout", Stream()):
|
|
34
|
+
state = TerminalState()
|
|
35
|
+
tty.setraw(slave)
|
|
36
|
+
state.restore()
|
|
37
|
+
state.restore()
|
|
38
|
+
restored = termios.tcgetattr(slave)
|
|
39
|
+
# macOS may set the transient PENDIN flag when canonical mode returns.
|
|
40
|
+
restored[3] &= ~getattr(termios, "PENDIN", 0)
|
|
41
|
+
original[3] &= ~getattr(termios, "PENDIN", 0)
|
|
42
|
+
self.assertEqual(restored, original)
|
|
43
|
+
self.assertEqual(os.read(master, 4096), RESET_MODES)
|
|
44
|
+
finally:
|
|
45
|
+
os.close(master)
|
|
46
|
+
os.close(slave)
|
|
47
|
+
|
|
48
|
+
def test_noninteractive_output_receives_no_terminal_escape_codes(self):
|
|
49
|
+
import io
|
|
50
|
+
from terminal import TerminalState
|
|
51
|
+
|
|
52
|
+
output = io.StringIO()
|
|
53
|
+
with patch("sys.stdin", io.StringIO()), patch("sys.stdout", output):
|
|
54
|
+
TerminalState().restore()
|
|
55
|
+
self.assertEqual(output.getvalue(), "")
|
|
56
|
+
|
|
57
|
+
async def test_restart_dispatch_uses_the_shared_file_lock(self):
|
|
58
|
+
import commands
|
|
59
|
+
from router import parse_arguments
|
|
60
|
+
|
|
61
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
62
|
+
with patch("service.restart", return_value="saved-session") as restart:
|
|
63
|
+
result = await commands.dispatch(
|
|
64
|
+
parse_arguments(["restart", "saved-session"]), Path(folder), None
|
|
65
|
+
)
|
|
66
|
+
self.assertEqual(result, 0)
|
|
67
|
+
restart.assert_called_once()
|
|
68
|
+
|
|
69
|
+
async def test_lost_completion_over_real_jsonl_recovers_without_backend_restart(
|
|
70
|
+
self,
|
|
71
|
+
):
|
|
72
|
+
from test_services import fake_executable, wait_for
|
|
73
|
+
from settings import atomic_json
|
|
74
|
+
|
|
75
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
76
|
+
root = Path(folder)
|
|
77
|
+
atomic_json(
|
|
78
|
+
root / "fault.json", {"quota_turns": 1, "lost_completion_turns": 1}
|
|
79
|
+
)
|
|
80
|
+
rpc = await RPC.start(fake_executable(root), root)
|
|
81
|
+
c = Controller(
|
|
82
|
+
[FakeAccount("one"), FakeAccount("two")], rpc, report=lambda _: None
|
|
83
|
+
)
|
|
84
|
+
c.client = FakeClient()
|
|
85
|
+
rpc.handler = c.event
|
|
86
|
+
try:
|
|
87
|
+
initial_pid = rpc.proc.pid
|
|
88
|
+
await rpc.initialize()
|
|
89
|
+
await c.forward(
|
|
90
|
+
{"id": "thread", "method": "thread/start", "params": {}}, {}
|
|
91
|
+
)
|
|
92
|
+
thread = next(iter(c.last_turn))
|
|
93
|
+
await c.forward(
|
|
94
|
+
{
|
|
95
|
+
"id": "turn",
|
|
96
|
+
"method": "turn/start",
|
|
97
|
+
"params": {
|
|
98
|
+
"threadId": thread,
|
|
99
|
+
"input": [
|
|
100
|
+
{
|
|
101
|
+
"type": "text",
|
|
102
|
+
"text": "fake work",
|
|
103
|
+
"text_elements": [],
|
|
104
|
+
}
|
|
105
|
+
],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{},
|
|
109
|
+
)
|
|
110
|
+
history = root / "fake-history.json"
|
|
111
|
+
|
|
112
|
+
def recovered():
|
|
113
|
+
turns = json.loads(history.read_text())["threads"][thread]["turns"]
|
|
114
|
+
return len(turns) == 2 and turns[-1]["status"] == "completed"
|
|
115
|
+
|
|
116
|
+
await wait_for(recovered)
|
|
117
|
+
self.assertEqual(c.account.label, "two")
|
|
118
|
+
self.assertFalse(c.active_items)
|
|
119
|
+
self.assertEqual(rpc.proc.pid, initial_pid)
|
|
120
|
+
self.assertIsNone(rpc.proc.returncode)
|
|
121
|
+
finally:
|
|
122
|
+
if c.recovery:
|
|
123
|
+
await asyncio.gather(c.recovery, return_exceptions=True)
|
|
124
|
+
await rpc.close()
|
|
125
|
+
|
|
126
|
+
def controller(self):
|
|
127
|
+
c = Controller(
|
|
128
|
+
[FakeAccount("one"), FakeAccount("two")],
|
|
129
|
+
FakeBackend(),
|
|
130
|
+
report=lambda _: None,
|
|
131
|
+
)
|
|
132
|
+
c.client = FakeClient()
|
|
133
|
+
c.last_turn["thread-1"] = {"threadId": "thread-1"}
|
|
134
|
+
c.preferences["watchdog_interval"] = 0.01
|
|
135
|
+
return c
|
|
136
|
+
|
|
137
|
+
async def started(self, c, ident="tool", thread="thread-1"):
|
|
138
|
+
await c.event(
|
|
139
|
+
{
|
|
140
|
+
"method": "item/started",
|
|
141
|
+
"params": {
|
|
142
|
+
"threadId": thread,
|
|
143
|
+
"item": {
|
|
144
|
+
"id": ident,
|
|
145
|
+
"type": "commandExecution",
|
|
146
|
+
"status": "inProgress",
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def history(self, c, status="completed", view="full", ident="tool"):
|
|
153
|
+
original = c.backend.call
|
|
154
|
+
|
|
155
|
+
async def call(method, params=None, **kw):
|
|
156
|
+
if method == "thread/turns/list" and params.get("itemsView") == "full":
|
|
157
|
+
return {
|
|
158
|
+
"data": [
|
|
159
|
+
{
|
|
160
|
+
"itemsView": view,
|
|
161
|
+
"items": [
|
|
162
|
+
{
|
|
163
|
+
"id": ident,
|
|
164
|
+
"type": "commandExecution",
|
|
165
|
+
"status": status,
|
|
166
|
+
}
|
|
167
|
+
],
|
|
168
|
+
}
|
|
169
|
+
]
|
|
170
|
+
}
|
|
171
|
+
return await original(method, params, **kw)
|
|
172
|
+
|
|
173
|
+
c.backend.call = call
|
|
174
|
+
|
|
175
|
+
async def test_quota_after_lost_completion_switches_and_continues_same_thread(self):
|
|
176
|
+
c = self.controller()
|
|
177
|
+
await self.started(c)
|
|
178
|
+
self.history(c)
|
|
179
|
+
await c.event(completed())
|
|
180
|
+
await asyncio.wait_for(c.recovery, 1)
|
|
181
|
+
self.assertFalse(c.active_items)
|
|
182
|
+
self.assertEqual(c.account.label, "two")
|
|
183
|
+
turns = [p for m, p in c.backend.calls if m == "turn/start"]
|
|
184
|
+
self.assertEqual(len(turns), 1)
|
|
185
|
+
self.assertEqual(turns[0]["threadId"], "thread-1")
|
|
186
|
+
|
|
187
|
+
async def test_running_missing_and_summary_items_remain_blocking(self):
|
|
188
|
+
for status, view, ident in (
|
|
189
|
+
("inProgress", "full", "tool"),
|
|
190
|
+
("completed", "summary", "tool"),
|
|
191
|
+
("completed", "full", "different"),
|
|
192
|
+
(None, "full", "tool"),
|
|
193
|
+
):
|
|
194
|
+
c = self.controller()
|
|
195
|
+
await self.started(c)
|
|
196
|
+
self.history(c, status, view, ident)
|
|
197
|
+
await c.reconcile_active_items()
|
|
198
|
+
self.assertEqual(c.active_items, {"tool"})
|
|
199
|
+
|
|
200
|
+
async def test_finished_tool_does_not_clear_pending_approval_or_child_thread(self):
|
|
201
|
+
c = self.controller()
|
|
202
|
+
await self.started(c)
|
|
203
|
+
c.server_requests.add(7)
|
|
204
|
+
c.active_threads.add("child")
|
|
205
|
+
self.history(c)
|
|
206
|
+
await c.reconcile_active_items()
|
|
207
|
+
self.assertFalse(c.active_items)
|
|
208
|
+
self.assertEqual(c.server_requests, {7})
|
|
209
|
+
self.assertEqual(c.active_threads, {"child"})
|
|
210
|
+
self.assertFalse(any(m == "account/login/start" for m, _ in c.backend.calls))
|
|
211
|
+
|
|
212
|
+
async def test_new_start_during_history_read_invalidates_the_read(self):
|
|
213
|
+
c = self.controller()
|
|
214
|
+
await self.started(c)
|
|
215
|
+
|
|
216
|
+
async def call(method, params=None, **kw):
|
|
217
|
+
await self.started(c, "new")
|
|
218
|
+
return {
|
|
219
|
+
"data": [
|
|
220
|
+
{
|
|
221
|
+
"itemsView": "full",
|
|
222
|
+
"items": [
|
|
223
|
+
{
|
|
224
|
+
"id": "tool",
|
|
225
|
+
"type": "commandExecution",
|
|
226
|
+
"status": "completed",
|
|
227
|
+
}
|
|
228
|
+
],
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
c.backend.call = call
|
|
234
|
+
await c.reconcile_active_items()
|
|
235
|
+
self.assertEqual(c.active_items, {"tool", "new"})
|
|
236
|
+
|
|
237
|
+
async def test_full_history_pages_until_matching_item_and_stops_on_repeated_cursor(
|
|
238
|
+
self,
|
|
239
|
+
):
|
|
240
|
+
c = self.controller()
|
|
241
|
+
await self.started(c)
|
|
242
|
+
calls = []
|
|
243
|
+
|
|
244
|
+
async def call(method, params=None, **kw):
|
|
245
|
+
calls.append(params)
|
|
246
|
+
if not params.get("cursor"):
|
|
247
|
+
return {"data": [], "nextCursor": "older"}
|
|
248
|
+
return {
|
|
249
|
+
"data": [
|
|
250
|
+
{
|
|
251
|
+
"itemsView": "full",
|
|
252
|
+
"items": [
|
|
253
|
+
{
|
|
254
|
+
"id": "tool",
|
|
255
|
+
"type": "commandExecution",
|
|
256
|
+
"status": "failed",
|
|
257
|
+
}
|
|
258
|
+
],
|
|
259
|
+
}
|
|
260
|
+
]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
c.backend.call = call
|
|
264
|
+
await c.reconcile_active_items()
|
|
265
|
+
self.assertFalse(c.active_items)
|
|
266
|
+
self.assertEqual(len(calls), 2)
|
|
267
|
+
await self.started(c)
|
|
268
|
+
calls.clear()
|
|
269
|
+
|
|
270
|
+
async def repeated(method, params=None, **kw):
|
|
271
|
+
calls.append(params)
|
|
272
|
+
return {"data": [], "nextCursor": "same"}
|
|
273
|
+
|
|
274
|
+
c.backend.call = repeated
|
|
275
|
+
await c.reconcile_active_items()
|
|
276
|
+
self.assertEqual(len(calls), 2)
|
|
277
|
+
self.assertEqual(c.active_items, {"tool"})
|
|
278
|
+
|
|
279
|
+
async def test_failed_read_and_unowned_item_never_clear_activity(self):
|
|
280
|
+
c = self.controller()
|
|
281
|
+
await self.started(c)
|
|
282
|
+
c.active_items.add("unknown-owner")
|
|
283
|
+
|
|
284
|
+
async def failed(*args, **kwargs):
|
|
285
|
+
raise RouterError("unavailable")
|
|
286
|
+
|
|
287
|
+
c.backend.call = failed
|
|
288
|
+
await c.reconcile_active_items()
|
|
289
|
+
self.assertEqual(c.active_items, {"tool", "unknown-owner"})
|
|
290
|
+
|
|
291
|
+
async def test_completion_and_terminal_start_do_not_leave_stale_ownership(self):
|
|
292
|
+
c = self.controller()
|
|
293
|
+
await self.started(c)
|
|
294
|
+
await c.event(
|
|
295
|
+
{
|
|
296
|
+
"method": "item/completed",
|
|
297
|
+
"params": {"threadId": "thread-1", "item": {"id": "tool"}},
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
self.assertFalse(c.active_item_threads)
|
|
301
|
+
await c.event(
|
|
302
|
+
{
|
|
303
|
+
"method": "item/started",
|
|
304
|
+
"params": {
|
|
305
|
+
"threadId": "thread-1",
|
|
306
|
+
"item": {
|
|
307
|
+
"id": "already-done",
|
|
308
|
+
"type": "commandExecution",
|
|
309
|
+
"status": "completed",
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
}
|
|
313
|
+
)
|
|
314
|
+
self.assertFalse(c.active_items)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
if __name__ == "__main__":
|
|
318
|
+
unittest.main()
|
package/tests/test_audit.py
CHANGED
|
@@ -246,6 +246,7 @@ class WorkerRegressions(unittest.IsolatedAsyncioTestCase):
|
|
|
246
246
|
c.task = asyncio.create_task(service.serve(c.state, c.ident, c.binary))
|
|
247
247
|
await wait_for(c.meta.exists)
|
|
248
248
|
self.assertEqual(observed[0]["approvalsReviewer"], "auto_review")
|
|
249
|
+
self.assertTrue(observed[0]["excludeTurns"])
|
|
249
250
|
self.assertIn("/private/tmp/audit-extra", observed[0]["runtimeWorkspaceRoots"])
|
|
250
251
|
pool.close()
|
|
251
252
|
|