relay-codex 0.7.1 → 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/commands.md CHANGED
@@ -140,7 +140,9 @@ Each session menu can switch to another enabled account after work and approvals
140
140
  finish. The worker checks allowance and session capacity before switching, then
141
141
  checks again that the session is idle before changing its login. Rejected actions
142
142
  show their reason. Pending menu actions cannot be submitted twice, and results
143
- remain visible until dismissed or another action starts. Stopped workers offer
143
+ disappear 30 seconds after first being viewed in the menu. Unseen results wait
144
+ until the menu is opened; action progress remains until the action finishes.
145
+ Starting another action replaces its previous result. Stopped workers offer
144
146
  Restart; restarting retains any review hold.
145
147
 
146
148
  Account menus can disable or enable future routing. Disabling an account does not
@@ -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.1
12
- npx --yes github:blackspider-ops/relay#v0.7.1 --version
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-codex",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Keep your Codex sessions moving between your own accounts.",
5
5
  "license": "MIT",
6
6
  "author": "blackspider-ops",
package/python/Menu.swift CHANGED
@@ -6,7 +6,11 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
6
6
  var buffer = Data()
7
7
  var current: [String: Any] = [:]
8
8
  var message: String?
9
- var actionNotice: String?
9
+ var actionNotice: String? {
10
+ didSet { noticeVersion += 1; noticeExpiry?.cancel(); noticeExpiry = nil }
11
+ }
12
+ var noticeVersion = 0
13
+ var noticeExpiry: DispatchWorkItem?
10
14
  var actionFailed = false
11
15
  var actionRunning = false
12
16
  var reconnect: DispatchWorkItem?
@@ -194,6 +198,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
194
198
  if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
195
199
  else { item.menu = menu }
196
200
  if menuOpen { scheduleResetExpiry() }
201
+ scheduleNoticeExpiry()
197
202
  }
198
203
  func makeMenu() -> NSMenu {
199
204
  let sessions = current["sessions"] as? [[String: Any]] ?? []
@@ -205,7 +210,6 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
205
210
  if let message = message { text(message, into: menu) }
206
211
  if let notice = actionNotice {
207
212
  text(notice, into: menu)
208
- if !actionRunning { action("Dismiss message", args: ["dismiss"], into: menu) }
209
213
  }
210
214
  if accounts.isEmpty { text("Add an account with relay setup in Terminal.", into: menu) }
211
215
  if liveSessions.isEmpty && !accounts.isEmpty { text("No Relay sessions running. Start with relay run.", into: menu) }
@@ -335,6 +339,21 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
335
339
  }
336
340
  }
337
341
  func menuDidClose(_ menu: NSMenu) { menuOpen = false; resetExpiry?.cancel(); resetExpiry = nil }
342
+ func scheduleNoticeExpiry() {
343
+ guard menuOpen, !actionRunning, actionNotice != nil, noticeExpiry == nil else { return }
344
+ let version = noticeVersion
345
+ let expiry = DispatchWorkItem { [weak self] in
346
+ guard let self = self, self.expireNotice(version: version) else { return }
347
+ self.render()
348
+ }
349
+ noticeExpiry = expiry
350
+ DispatchQueue.main.asyncAfter(deadline: .now() + 30, execute: expiry)
351
+ }
352
+ func expireNotice(version: Int) -> Bool {
353
+ guard !stopping, !actionRunning, actionNotice != nil, noticeVersion == version else { return false }
354
+ actionNotice = nil; actionFailed = false
355
+ return true
356
+ }
338
357
  func resetAllowed(_ id: String) -> Bool {
339
358
  let accounts = current["accounts"] as? [[String: Any]] ?? []
340
359
  guard let account = accounts.first(where: { $0["id"] as? String == id }) else {
@@ -346,7 +365,6 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
346
365
  @objc func runAction(_ sender: NSMenuItem) {
347
366
  guard var args = sender.representedObject as? [String], !args.isEmpty else { return }
348
367
  if args[0] == "copy" { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(args[1], forType: .string); return }
349
- if args[0] == "dismiss" { actionNotice = nil; actionFailed = false; render(); return }
350
368
  guard !actionRunning else { return }
351
369
  if args[0] == "confirm-retry" {
352
370
  let alert = NSAlert(); alert.messageText = "Continue this task?"; alert.informativeText = "Review any interrupted command or external action in the conversation before continuing."
@@ -404,7 +422,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
404
422
  runAction(sender)
405
423
  }
406
424
  @objc func quitMenu() { stopping = true; reconnect?.cancel(); stream?.terminate(); NSApp.terminate(nil) }
407
- func applicationWillTerminate(_ notification: Notification) { stopping = true; stream?.terminate() }
425
+ func applicationWillTerminate(_ notification: Notification) { stopping = true; noticeExpiry?.cancel(); resetExpiry?.cancel(); stream?.terminate() }
408
426
  }
409
427
  if CommandLine.arguments.contains("--self-check") {
410
428
  let normal = NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: "Relay")
@@ -473,12 +491,26 @@ if CommandLine.arguments.contains("--self-check") {
473
491
  controller.actionRunning = false; controller.actionNotice = "Task is busy."
474
492
  controller.current["at"] = 2000000001.0
475
493
  let retainedResult = controller.makeMenu().items.contains { $0.title == "Task is busy." }
494
+ let noDismissButton = findAction(["dismiss"], in: controller.makeMenu()) == nil
495
+ controller.menuOpen = false; controller.actionFailed = true; controller.actionNotice = "Unseen result"
496
+ controller.scheduleNoticeExpiry()
497
+ let unseenRetained = controller.noticeExpiry == nil && controller.actionNotice == "Unseen result"
498
+ controller.menuOpen = true; controller.scheduleNoticeExpiry()
499
+ let startsOnView = controller.noticeExpiry != nil
500
+ let previousNotice = controller.noticeVersion
501
+ controller.actionNotice = "Newer result"
502
+ let newerProtected = !controller.expireNotice(version: previousNotice) && controller.actionNotice == "Newer result"
503
+ controller.actionRunning = true; controller.scheduleNoticeExpiry()
504
+ let progressRetained = controller.noticeExpiry == nil && !controller.expireNotice(version: controller.noticeVersion)
505
+ controller.actionRunning = false
506
+ let expiresAfterView = controller.expireNotice(version: controller.noticeVersion) && controller.actionNotice == nil && !controller.actionFailed
476
507
  print("Account choices and stopped controls correct: \(correctChoices && stoppedControls)")
477
508
  print("Pending action protected and result retained: \(correctPending && retainedResult)")
478
509
  print("In-use accounts and current choice marked: \(correctMarker && correctMultiple && correctEmpty)")
479
510
  print("Available and pending reset actions protected: \(correctResets && resetLocked)")
480
511
  print("Weekly zero and more-than-48-hour guard enforced: \(correctPolicy && pendingLocked)")
481
- exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked && correctPolicy && pendingLocked ? 0 : 1)
512
+ print("Viewed notices expire without hiding unseen or newer results: \(noDismissButton && unseenRetained && startsOnView && newerProtected && progressRetained && expiresAfterView)")
513
+ exit(normal != nil && attention != nil && liveUpdate && safeIdentity && correctTiming && correctChoices && stoppedControls && correctPending && retainedResult && correctMarker && correctMultiple && correctEmpty && correctResets && resetLocked && correctPolicy && pendingLocked && noDismissButton && unseenRetained && startsOnView && newerProtected && progressRetained && expiresAfterView ? 0 : 1)
482
514
  } else {
483
515
  let app = NSApplication.shared
484
516
  let delegate = MenuController(); app.delegate = delegate; app.run()
@@ -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.1"
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("thread/resume", {"threadId": thread, **params})
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
- self.active_items.add(params["item"]["id"])
1038
- if params.get("threadId"):
1039
- self.persist(params["threadId"], active_items=True)
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
- self.active_items.discard(params.get("item", {}).get("id"))
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
@@ -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()
@@ -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