relay-codex 0.7.2 → 0.7.4

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/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)
@@ -372,7 +376,12 @@ def restart(state, prefix):
372
376
  spec = json.loads(path.read_text())
373
377
  if not Path(spec["cwd"]).is_dir():
374
378
  raise RouterError("The project folder no longer exists")
375
- spec.update(state="starting", background_launch=True, foreground_owner=None)
379
+ spec.update(
380
+ state="starting",
381
+ background_launch=True,
382
+ foreground_owner=None,
383
+ relay_runtime=str(Path(__file__).parent.resolve()),
384
+ )
376
385
  spec.pop("last_error", None)
377
386
  atomic_json(path, spec)
378
387
  process = spawn(
@@ -530,7 +539,7 @@ async def _serve(state, ident, binary):
530
539
  controller.retry_interval = controller.preferences["retry_interval"]
531
540
  controller.retry_attempts = controller.preferences["retry_attempts"]
532
541
  controller.interval = controller.preferences["unknown_reset_interval"]
533
- backend.handler = controller.event
542
+ backend.handler = lambda message: controller.event(message, origin=backend)
534
543
  heartbeat = asyncio.create_task(controller.maintain())
535
544
  connection = root / (ident + ".connection.json")
536
545
  try:
@@ -590,7 +599,7 @@ async def _serve(state, ident, binary):
590
599
  controller.backend_factory = factory
591
600
  thread_id = old_thread or spec.get("resume")
592
601
  if thread_id:
593
- params = {"threadId": thread_id, **overrides}
602
+ params = {"threadId": thread_id, "excludeTurns": True, **overrides}
594
603
  if old:
595
604
  controller.merge_settings(thread_id, persisted.get("settings", {}))
596
605
  params.update(
@@ -713,7 +722,7 @@ async def _serve(state, ident, binary):
713
722
  if not persisted.get("accepted")
714
723
  else None,
715
724
  )
716
- if state_name == "completed":
725
+ if state_name == "completed" and not persisted.get("rejected"):
717
726
  controller.persist(
718
727
  thread_id,
719
728
  state="completed",
@@ -725,6 +734,7 @@ async def _serve(state, ident, binary):
725
734
  )
726
735
  elif (
727
736
  persisted.get("review_required")
737
+ or persisted.get("rejected")
728
738
  or persisted.get("active_items")
729
739
  or not persisted.get("accepted")
730
740
  ):
@@ -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)
@@ -125,6 +128,12 @@ for raw in sys.stdin:
125
128
  result = {"account": {"type": "chatgpt", "planType": "plus"}}
126
129
  elif method == "account/login/start":
127
130
  result = {"type": params["type"]}
131
+ send(
132
+ {
133
+ "method": "account/updated",
134
+ "params": {"authMode": "chatgptAuthTokens", "planType": "plus"},
135
+ }
136
+ )
128
137
  elif method == "account/rateLimits/read":
129
138
  reset_count = change(lambda d: d.get("reset_credit_count", 1))
130
139
  weekly = change(
@@ -208,8 +217,16 @@ for raw in sys.stdin:
208
217
  ]
209
218
  )
210
219
  }
220
+ for value in result["data"]:
221
+ value["itemsView"] = params.get("itemsView", "summary")
211
222
  elif method == "thread/loaded/list":
212
223
  result = {"data": change(lambda d: list(d["threads"]))}
224
+ elif method == "thread/backgroundTerminals/list":
225
+ result = {
226
+ "data": change(
227
+ lambda d: d.get("backgrounds", {}).get(params["threadId"], [])
228
+ )
229
+ }
213
230
  elif method in {"turn/start", "review/start"}:
214
231
  thread = params["threadId"]
215
232
  turn = str(uuid.uuid4())
@@ -243,6 +260,43 @@ for raw in sys.stdin:
243
260
  "params": {"threadId": thread, "turn": value},
244
261
  }
245
262
  )
263
+ if count <= fault.get("lost_completion_turns", 0):
264
+ tool = {
265
+ "id": "lost-completion-" + turn,
266
+ "type": "commandExecution",
267
+ "status": "inProgress",
268
+ }
269
+ change(
270
+ lambda d: d["threads"][thread]["turns"][-1]["items"].append(tool)
271
+ )
272
+ send(
273
+ {
274
+ "method": "item/started",
275
+ "params": {"threadId": thread, "item": tool},
276
+ }
277
+ )
278
+ if count <= fault.get("background_activity_turns", 0):
279
+ item_id = "background-" + turn
280
+ change(
281
+ lambda d: (
282
+ d.setdefault("backgrounds", {})
283
+ .setdefault(thread, [])
284
+ .append({"itemId": item_id, "processId": "test-background-job"})
285
+ )
286
+ )
287
+ send(
288
+ {
289
+ "method": "item/started",
290
+ "params": {
291
+ "threadId": thread,
292
+ "item": {
293
+ "id": item_id,
294
+ "type": "commandExecution",
295
+ "status": "inProgress",
296
+ },
297
+ },
298
+ }
299
+ )
246
300
  if fault.get("external"):
247
301
  send(
248
302
  {
@@ -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