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.
@@ -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.2
12
- npx --yes github:blackspider-ops/relay#v0.7.2 --version
11
+ npm install -g github:blackspider-ops/relay#v0.7.4
12
+ npx --yes github:blackspider-ops/relay#v0.7.4 --version
13
13
  ```
14
14
 
15
15
  `npx` runs packages; it is not a separate publishing service. Publishing
@@ -49,6 +49,24 @@ 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.4. Background terminals
55
+ and missed tool notifications could leave a finished turn marked as active.
56
+ Relay checks native history and the current backend's status. Commands explicitly
57
+ listed by Codex as background terminals can keep running while accounts change
58
+ after the model turn ends. The usage panel does not need to be clicked. Relay keeps
59
+ waiting when work is still running or completion cannot be verified; approvals
60
+ and paused recovery still need attention. Existing workers retain the code they
61
+ started with, so an update takes effect after that worker is relaunched.
62
+
63
+ **Codex says “auth refresh request failed: code=-32000.”**
64
+
65
+ Relay retries authentication on its normal retry schedule, then tries another
66
+ available account. If Codex reports that a refresh token was revoked, sign in
67
+ again with `relay account reauth LABEL`. Switch any sessions using that account
68
+ first. A revoked login cannot be repaired by retrying the same token.
69
+
52
70
  **Tab completion is missing.**
53
71
 
54
72
  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.2",
3
+ "version": "0.7.4",
4
4
  "description": "Keep your Codex sessions moving between your own accounts.",
5
5
  "license": "MIT",
6
6
  "author": "blackspider-ops",
package/python/broker.py CHANGED
@@ -225,6 +225,11 @@ async def serve(state, binary):
225
225
  ):
226
226
  value = candidate
227
227
  if value is None:
228
+ if request.get("refresh"):
229
+ # A retained native manager can hold a refresh
230
+ # grant rotated by another process. Reload the
231
+ # Keychain under this account's shared lock.
232
+ await account.close()
228
233
  await account.start()
229
234
  result = await account.rpc.call(
230
235
  "account/read",
@@ -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:
@@ -350,7 +350,7 @@ async def _run(args, state, binary):
350
350
  controller.launch_overrides = overrides
351
351
  controller.client = output
352
352
  output.controller = controller
353
- backend.handler = controller.event
353
+ backend.handler = lambda message: controller.event(message, origin=backend)
354
354
  init = await backend.initialize()
355
355
  heartbeat = asyncio.create_task(controller.maintain())
356
356
  model = values.get("--model")
package/python/pool.py CHANGED
@@ -545,6 +545,13 @@ CAPACITY_MESSAGE = "Selected model is at capacity. Please try a different model.
545
545
 
546
546
 
547
547
  def failure_kind(info, message=None):
548
+ if (
549
+ info in (None, "other", "unauthorized")
550
+ and isinstance(message, str)
551
+ and message.strip().removeprefix("■").strip().lower()
552
+ == "auth refresh request failed: code=-32000"
553
+ ):
554
+ return "auth"
548
555
  if info == "policyCapacityUnavailable":
549
556
  return "capacity"
550
557
  # Older/generic error envelopes can omit the enum. Match only the known
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.2"
27
+ VERSION = "0.7.4"
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"
@@ -95,6 +95,17 @@ def clean_env(home: Path) -> dict:
95
95
  return env
96
96
 
97
97
 
98
+ def rejected_failure_kind(payload):
99
+ if payload.get("code") == -32001:
100
+ return "transient"
101
+ if (
102
+ payload.get("code") in {-32000, -32603}
103
+ and failure_kind("other", payload.get("message")) == "auth"
104
+ ):
105
+ return "auth"
106
+ return None
107
+
108
+
98
109
  def codex_path() -> str:
99
110
  path = shutil.which("codex")
100
111
  if not path:
@@ -193,6 +204,9 @@ class RPC:
193
204
  self.reader = asyncio.create_task(self.read_loop())
194
205
  self.startup_guard = None
195
206
 
207
+ def disconnected(self):
208
+ return self.proc.returncode is not None or self.reader.done()
209
+
196
210
  @classmethod
197
211
  async def start(
198
212
  cls, binary: str, home: Path, *, helper=False, handler=None, extra_args=None
@@ -330,7 +344,7 @@ class Account:
330
344
  self.lock = asyncio.Lock()
331
345
 
332
346
  async def start(self):
333
- if self.rpc and self.rpc.proc.returncode is not None:
347
+ if self.rpc and self.rpc.disconnected():
334
348
  await self.rpc.close()
335
349
  self.rpc = None
336
350
  if not self.rpc:
@@ -384,6 +398,7 @@ class Account:
384
398
  async def close(self):
385
399
  if self.rpc:
386
400
  await self.rpc.close()
401
+ self.rpc = None
387
402
 
388
403
 
389
404
  class Controller:
@@ -433,7 +448,11 @@ class Controller:
433
448
  self.turn_ids = {}
434
449
  self.recovery_jobs = {}
435
450
  self.pending_intents = {}
451
+ self.rejected_turns = {}
436
452
  self.active_items = set()
453
+ self.active_item_threads = {}
454
+ self.item_revision = 0
455
+ self.auth_account = None
437
456
  self.backend_factory = None
438
457
  self.restart_lock = asyncio.Lock()
439
458
  self.restart_task = None
@@ -673,6 +692,7 @@ class Controller:
673
692
  model=next((self.model_for(t) for t in self.last_turn), None),
674
693
  paused=self.paused,
675
694
  background=self.background,
695
+ activity=self.activity_snapshot(),
676
696
  )
677
697
  last_heartbeat = now
678
698
  new_revision = self.pool.revision()
@@ -741,7 +761,7 @@ class Controller:
741
761
  pass
742
762
  if now - last_watch >= self.preferences["watchdog_interval"]:
743
763
  last_watch = now
744
- if self.backend.proc.returncode is not None and self.backend_factory:
764
+ if self.backend_disconnected() and self.backend_factory:
745
765
  if not self.restart_task or self.restart_task.done():
746
766
  self.restart_task = asyncio.create_task(self.restart_backend())
747
767
  elif self.active_threads:
@@ -786,7 +806,10 @@ class Controller:
786
806
  if self.active_threads or self.active_items:
787
807
  raise RouterError("Session is busy")
788
808
  if thread:
789
- await self.backend.call("thread/resume", {"threadId": thread, **params})
809
+ await self.backend.call(
810
+ "thread/resume",
811
+ {"threadId": thread, "excludeTurns": True, **params},
812
+ )
790
813
  self.merge_settings(thread, params)
791
814
  self.launch_overrides = {**self.launch_overrides, **params}
792
815
  if params.get("permissions"):
@@ -800,6 +823,7 @@ class Controller:
800
823
  )
801
824
  if index is None:
802
825
  raise ControlRejected("unknown_account")
826
+ await self.reconcile_active_items()
803
827
  if self.active_threads or self.active_items or self.server_requests:
804
828
  raise ControlRejected("busy")
805
829
  options = self.pool.options(label) if self.pool else {}
@@ -854,20 +878,24 @@ class Controller:
854
878
 
855
879
  async def restart_backend(self):
856
880
  async with self.restart_lock:
857
- if self.backend.proc.returncode is None:
881
+ if not self.backend_disconnected():
858
882
  return
859
883
  self.note("restarting")
860
884
  in_flight = bool(self.active_items or self.server_requests)
861
885
  old = self.backend
886
+ old_auth_account = self.auth_account
862
887
  candidate = None
863
888
  try:
864
889
  candidate = await self.backend_factory()
865
- candidate.handler = self.event
890
+ candidate.handler = lambda message: self.event(
891
+ message, origin=candidate
892
+ )
866
893
  await candidate.initialize()
867
894
  self.backend = candidate
868
895
  await self.login(self.index)
869
896
  except Exception:
870
897
  self.backend = old
898
+ self.auth_account = old_auth_account
871
899
  if candidate:
872
900
  await candidate.close()
873
901
  self.note("backend_restart_failed", reason="startup_or_login_failed")
@@ -877,8 +905,10 @@ class Controller:
877
905
  self.pending_server_requests.clear()
878
906
  self.replayed_requests.clear()
879
907
  self.active_items.clear()
908
+ self.active_item_threads.clear()
909
+ self.item_revision += 1
880
910
  for thread in list(self.last_turn):
881
- params = {"threadId": thread}
911
+ params = {"threadId": thread, "excludeTurns": True}
882
912
  params.update(
883
913
  {
884
914
  k: v
@@ -959,9 +989,17 @@ class Controller:
959
989
  )
960
990
  ):
961
991
  raise ControlRejected("account_unavailable")
962
- await self.backend.call(
963
- "account/login/start", {"type": "chatgptAuthTokens", **credentials}
964
- )
992
+ # Native refresh callbacks can arrive before the login response.
993
+ # Bind them to the credentials being installed, not the old index.
994
+ previous_binding = self.auth_account
995
+ self.auth_account = self.accounts[index]
996
+ try:
997
+ await self.backend.call(
998
+ "account/login/start", {"type": "chatgptAuthTokens", **credentials}
999
+ )
1000
+ except RemoteError:
1001
+ self.auth_account = previous_binding
1002
+ raise
965
1003
  self.index = index
966
1004
  # Acceptance of token syntax alone isn't a verified, usable login.
967
1005
  result = await self.backend.call("account/read", {"refreshToken": False})
@@ -977,25 +1015,34 @@ class Controller:
977
1015
  except Exception:
978
1016
  pass
979
1017
 
980
- async def event(self, message):
1018
+ async def event(self, message, *, origin=None):
1019
+ if origin is not None and origin is not self.backend:
1020
+ return
981
1021
  method, params = message.get("method"), message.get("params", {})
982
1022
  if method == "account/chatgptAuthTokens/refresh":
1023
+ account = self.auth_account or self.account
1024
+ transport = self.backend
983
1025
  try:
984
1026
  # Never return another identity in response to a refresh request.
985
- credentials = await self.account.credentials(refresh=True)
1027
+ credentials = await account.credentials(refresh=True)
986
1028
  previous = params.get("previousAccountId")
987
1029
  if previous and previous != credentials["chatgptAccountId"]:
988
1030
  raise RouterError(
989
1031
  "Refresh account mismatch; re-enroll the account."
990
1032
  )
991
- await self.backend.send({"id": message["id"], "result": credentials})
1033
+ if transport is not self.backend:
1034
+ return
1035
+ await transport.send({"id": message["id"], "result": credentials})
992
1036
  except (RouterError, asyncio.TimeoutError):
993
- await self.backend.send(
1037
+ if transport is not self.backend:
1038
+ return
1039
+ self.note("auth_refresh_failed", account=account.label)
1040
+ await transport.send(
994
1041
  {
995
1042
  "id": message["id"],
996
1043
  "error": {
997
1044
  "code": -32000,
998
- "message": "Account requires sign-in.",
1045
+ "message": "Account credentials could not be refreshed. Relay will retry after this turn ends.",
999
1046
  },
1000
1047
  }
1001
1048
  )
@@ -1017,6 +1064,7 @@ class Controller:
1017
1064
  if self.pool and thread.get("id"):
1018
1065
  self.pool.claim(thread["id"])
1019
1066
  if method == "turn/started":
1067
+ self.rejected_turns.pop(params["threadId"], None)
1020
1068
  self.active_threads.add(params["threadId"])
1021
1069
  self.turn_ids[params["threadId"]] = params.get("turn", {}).get("id")
1022
1070
  if params["threadId"] in self.pending_intents:
@@ -1027,6 +1075,7 @@ class Controller:
1027
1075
  accepted=True,
1028
1076
  turn=params.get("turn", {}).get("id"),
1029
1077
  state="working",
1078
+ rejected=False,
1030
1079
  )
1031
1080
  elif method == "item/started" and params.get("item", {}).get("type") in {
1032
1081
  "commandExecution",
@@ -1034,11 +1083,18 @@ class Controller:
1034
1083
  "fileChange",
1035
1084
  "dynamicToolCall",
1036
1085
  }:
1037
- self.active_items.add(params["item"]["id"])
1038
- if params.get("threadId"):
1039
- self.persist(params["threadId"], active_items=True)
1086
+ item = params["item"]
1087
+ if item.get("status") not in {"completed", "failed", "declined"}:
1088
+ self.active_items.add(item["id"])
1089
+ self.item_revision += 1
1090
+ if params.get("threadId"):
1091
+ self.active_item_threads[item["id"]] = params["threadId"]
1092
+ self.persist(params["threadId"], active_items=True)
1040
1093
  elif method == "item/completed":
1041
- self.active_items.discard(params.get("item", {}).get("id"))
1094
+ item = params.get("item", {}).get("id")
1095
+ self.active_items.discard(item)
1096
+ self.active_item_threads.pop(item, None)
1097
+ self.item_revision += 1
1042
1098
  if params.get("threadId"):
1043
1099
  self.persist(params["threadId"], active_items=bool(self.active_items))
1044
1100
  elif method == "model/rerouted":
@@ -1173,6 +1229,14 @@ class Controller:
1173
1229
  ):
1174
1230
  if generation != self.user_generation or not self.attached():
1175
1231
  return False
1232
+ await self.reconcile_active_items()
1233
+ if not (
1234
+ self.active_threads
1235
+ or self.active_items
1236
+ or self.server_requests
1237
+ or self.paused
1238
+ ):
1239
+ break
1176
1240
  self.status_changed.clear()
1177
1241
  try:
1178
1242
  await asyncio.wait_for(
@@ -1184,7 +1248,9 @@ class Controller:
1184
1248
  await self.reconcile(thread)
1185
1249
  if time.monotonic() - began > self.preferences["idle_warning_after"]:
1186
1250
  self.note(
1187
- "waiting_for_activity", reason="actions_or_approvals_not_idle"
1251
+ "waiting_for_activity",
1252
+ reason="actions_or_approvals_not_idle",
1253
+ activity=self.activity_snapshot(),
1188
1254
  )
1189
1255
  if self.paused:
1190
1256
  self.wake.clear()
@@ -1194,6 +1260,170 @@ class Controller:
1194
1260
  pass
1195
1261
  return generation == self.user_generation and self.attached()
1196
1262
 
1263
+ async def reconcile_active_items(self):
1264
+ """Reconcile finished actions and native background jobs at idle boundaries."""
1265
+ owners = dict(self.active_item_threads)
1266
+ revision = self.item_revision
1267
+ for thread in set(owners.values()):
1268
+ pending = {
1269
+ item
1270
+ for item, owner in owners.items()
1271
+ if owner == thread and item in self.active_items
1272
+ }
1273
+ if not pending:
1274
+ continue
1275
+ await self.reconcile_background_activity(thread, pending, revision)
1276
+ pending &= self.active_items
1277
+ if not pending:
1278
+ continue
1279
+ cursor = None
1280
+ visited = set()
1281
+ for _ in range(8):
1282
+ params = {
1283
+ "threadId": thread,
1284
+ "limit": 1,
1285
+ "sortDirection": "desc",
1286
+ "itemsView": "full",
1287
+ }
1288
+ if cursor:
1289
+ params["cursor"] = cursor
1290
+ try:
1291
+ result = await self.backend.call(
1292
+ "thread/turns/list", params, timeout=15
1293
+ )
1294
+ except (RouterError, asyncio.TimeoutError):
1295
+ break
1296
+ # A concurrent start invalidates this history read. Never clear
1297
+ # a newly started action using a snapshot taken before it began.
1298
+ if revision != self.item_revision:
1299
+ return
1300
+ confirmed = {
1301
+ item.get("id")
1302
+ for turn in result.get("data", [])
1303
+ if turn.get("itemsView") == "full"
1304
+ for item in turn.get("items", [])
1305
+ if item.get("type")
1306
+ in {
1307
+ "commandExecution",
1308
+ "mcpToolCall",
1309
+ "fileChange",
1310
+ "dynamicToolCall",
1311
+ }
1312
+ and item.get("status") in {"completed", "failed", "declined"}
1313
+ }
1314
+ cleared = pending & confirmed
1315
+ for item in cleared:
1316
+ if self.active_item_threads.get(item) == thread:
1317
+ self.active_items.discard(item)
1318
+ self.active_item_threads.pop(item, None)
1319
+ if cleared:
1320
+ pending -= cleared
1321
+ self.persist(thread, active_items=bool(self.active_items))
1322
+ if self.pool:
1323
+ self.pool.record("tool_activity_reconciled", count=len(cleared))
1324
+ self.status_changed.set()
1325
+ cursor = result.get("nextCursor")
1326
+ if not pending or not cursor or cursor in visited:
1327
+ break
1328
+ visited.add(cursor)
1329
+
1330
+ def backend_disconnected(self):
1331
+ check = getattr(self.backend, "disconnected", None)
1332
+ return (
1333
+ check()
1334
+ if check
1335
+ else getattr(getattr(self.backend, "proc", None), "returncode", None)
1336
+ is not None
1337
+ )
1338
+
1339
+ def activity_snapshot(self):
1340
+ """Private diagnostic metadata; never tool arguments or credentials."""
1341
+ return {
1342
+ "threads": sorted(self.active_threads),
1343
+ "items": sorted(self.active_items),
1344
+ "owners": dict(self.active_item_threads),
1345
+ "requests": sorted(
1346
+ {
1347
+ m.get("method", "unknown")
1348
+ for m in self.pending_server_requests.values()
1349
+ }
1350
+ ),
1351
+ "request_count": len(self.server_requests),
1352
+ "paused": self.paused,
1353
+ "disconnected": self.backend_disconnected(),
1354
+ }
1355
+
1356
+ async def reconcile_background_activity(self, thread, pending, revision):
1357
+ """After a turn ends, known background terminals need not block login.
1358
+
1359
+ These jobs remain alive in the same backend. Unknown tools and pending
1360
+ approvals still block recovery; a terminal turn alone cannot clear them.
1361
+ """
1362
+ turn_id = self.turn_ids.get(thread)
1363
+ generation = self.user_generation
1364
+ if not turn_id or self.server_requests:
1365
+ return
1366
+ try:
1367
+ turn = await self.latest(thread) or {}
1368
+ if turn.get("id") != turn_id or turn.get("status") not in {
1369
+ "completed",
1370
+ "failed",
1371
+ "interrupted",
1372
+ }:
1373
+ return
1374
+ background = set()
1375
+ cursor = None
1376
+ visited = set()
1377
+ for _ in range(8):
1378
+ params = {"threadId": thread, "limit": 100}
1379
+ if cursor:
1380
+ params["cursor"] = cursor
1381
+ result = await self.backend.call(
1382
+ "thread/backgroundTerminals/list", params, timeout=15
1383
+ )
1384
+ background.update(item.get("itemId") for item in result.get("data", []))
1385
+ cursor = result.get("nextCursor")
1386
+ if not cursor or cursor in visited or pending <= background:
1387
+ break
1388
+ visited.add(cursor)
1389
+ if not pending & background:
1390
+ return
1391
+ info = await self.backend.call(
1392
+ "thread/read", {"threadId": thread}, timeout=15
1393
+ )
1394
+ except (RouterError, asyncio.TimeoutError):
1395
+ return
1396
+ if (
1397
+ revision != self.item_revision
1398
+ or generation != self.user_generation
1399
+ or self.turn_ids.get(thread) != turn_id
1400
+ or self.server_requests
1401
+ or info.get("thread", {}).get("status", {}).get("type")
1402
+ not in {"idle", "systemError"}
1403
+ or info.get("thread", {}).get("canAcceptDirectInput") is False
1404
+ ):
1405
+ return
1406
+ cleared = {
1407
+ item
1408
+ for item in pending & background
1409
+ if self.active_item_threads.get(item) == thread
1410
+ and item in self.active_items
1411
+ }
1412
+ for item in cleared:
1413
+ self.active_items.discard(item)
1414
+ self.active_item_threads.pop(item, None)
1415
+ if cleared:
1416
+ self.active_threads.discard(thread)
1417
+ self.persist(thread, active_items=bool(self.active_items))
1418
+ if self.pool:
1419
+ self.pool.record(
1420
+ "background_activity_reconciled",
1421
+ count=len(cleared),
1422
+ thread=thread,
1423
+ turn=turn_id,
1424
+ )
1425
+ self.status_changed.set()
1426
+
1197
1427
  async def continue_thread(self, thread, generation):
1198
1428
  if not self.can_continue(thread, generation):
1199
1429
  return
@@ -1210,6 +1440,19 @@ class Controller:
1210
1440
  params[key] = self.thread_settings[thread][key]
1211
1441
  params["clientUserMessageId"] = str(uuid.uuid4())
1212
1442
  baseline = self.turn_ids.get(thread) or "__unknown_before_submission__"
1443
+ rejected = self.rejected_turns.get(thread)
1444
+ if rejected:
1445
+ baseline = rejected["baseline"]
1446
+ try:
1447
+ latest = await self.latest(thread)
1448
+ except (RouterError, asyncio.TimeoutError):
1449
+ self.hold_for_review(thread, "rejected_request_history_unavailable")
1450
+ return
1451
+ observed = (latest or {}).get("id") or "__unknown_before_submission__"
1452
+ if observed != baseline:
1453
+ self.hold_for_review(thread, "rejected_request_history_changed")
1454
+ return
1455
+ params = dict(rejected["params"])
1213
1456
  try:
1214
1457
  while True:
1215
1458
  async with self.login_lock:
@@ -1227,6 +1470,7 @@ class Controller:
1227
1470
  accepted=False,
1228
1471
  state="submitting",
1229
1472
  baseline=baseline,
1473
+ rejected=bool(rejected),
1230
1474
  )
1231
1475
  result = await self.backend.call("turn/start", params)
1232
1476
  break
@@ -1234,6 +1478,7 @@ class Controller:
1234
1478
  return
1235
1479
  turn = result.get("turn", {})
1236
1480
  if turn.get("id"):
1481
+ self.rejected_turns.pop(thread, None)
1237
1482
  self.turn_ids[thread] = turn["id"]
1238
1483
  self.pending_intents[thread]["accepted"] = True
1239
1484
  self.persist(
@@ -1241,18 +1486,35 @@ class Controller:
1241
1486
  accepted=True,
1242
1487
  turn=turn["id"],
1243
1488
  state=turn.get("status", "working"),
1489
+ rejected=False,
1244
1490
  )
1245
1491
  except RemoteError as error:
1246
- if error.payload.get("code") == -32001:
1247
- return await self.recover(thread, generation, "transient")
1492
+ kind = rejected_failure_kind(error.payload)
1493
+ if kind:
1494
+ if self.pending_intents.get(thread, {}).get("accepted"):
1495
+ return await self.uncertain(thread, generation, baseline)
1496
+ self.remember_rejected(thread, params, baseline)
1497
+ return await self.recover(thread, generation, kind)
1248
1498
  raise
1249
1499
  except (RouterError, asyncio.TimeoutError):
1250
1500
  return await self.uncertain(thread, generation, baseline)
1251
1501
  self.report(f"Continued the saved thread with {self.account_name()}.")
1252
1502
 
1503
+ def remember_rejected(self, thread, params, baseline):
1504
+ # Only explicit pre-acceptance rejections qualify. Keep the original
1505
+ # input in memory; a worker restart requires resubmission, not a guess.
1506
+ self.rejected_turns[thread] = {
1507
+ "params": dict(params),
1508
+ "baseline": baseline or "__unknown_before_submission__",
1509
+ }
1510
+ self.persist(thread, rejected=True)
1511
+
1253
1512
  async def restore_recovery(self, thread, persisted):
1254
1513
  """Honor an accepted retry's original deadline after a process restart."""
1255
1514
  generation = self.user_generation
1515
+ if persisted.get("rejected"):
1516
+ self.hold_for_review(thread, "rejected_request_requires_resubmission")
1517
+ return
1256
1518
  if persisted.get("review_required"):
1257
1519
  self.hold_for_review(
1258
1520
  thread, persisted.get("review_reason") or "review_required"
@@ -1618,6 +1880,7 @@ class Controller:
1618
1880
  self.fallback_tried.pop(message["params"]["threadId"], None)
1619
1881
  self.status_changed.set()
1620
1882
  if method == "turn/start":
1883
+ self.rejected_turns.pop(message["params"]["threadId"], None)
1621
1884
  self.clear_review(message["params"]["threadId"])
1622
1885
  self.last_turn[message["params"]["threadId"]] = message["params"]
1623
1886
  self.merge_settings(message["params"]["threadId"], message["params"])
@@ -1683,6 +1946,7 @@ class Controller:
1683
1946
  accepted=False,
1684
1947
  baseline=self.turn_ids.get(thread),
1685
1948
  state="submitting",
1949
+ rejected=False,
1686
1950
  )
1687
1951
  async with guard:
1688
1952
  result = await self.backend.call(
@@ -1749,13 +2013,22 @@ class Controller:
1749
2013
  await self.emit({"id": request_id, "error": error.payload})
1750
2014
  # Ingress overload rejects a turn before a turn/completed event
1751
2015
  # exists. Its documented code is explicit, not message matching.
1752
- if method == "turn/start" and error.payload.get("code") == -32001:
2016
+ kind = rejected_failure_kind(error.payload)
2017
+ if method == "turn/start" and kind:
1753
2018
  if self.recovery and not self.recovery.done():
1754
2019
  self.recovery.cancel()
1755
2020
  thread = message["params"]["threadId"]
1756
- self.recovery = asyncio.create_task(
1757
- self.recover(thread, self.user_generation, "transient")
1758
- )
2021
+ intent = self.pending_intents.get(thread, {})
2022
+ baseline = intent.get("baseline", "__unknown_before_submission__")
2023
+ if intent.get("accepted"):
2024
+ self.recovery = asyncio.create_task(
2025
+ self.uncertain(thread, self.user_generation, baseline)
2026
+ )
2027
+ else:
2028
+ self.remember_rejected(thread, message["params"], baseline)
2029
+ self.recovery = asyncio.create_task(
2030
+ self.recover(thread, self.user_generation, kind)
2031
+ )
1759
2032
  except (RouterError, asyncio.TimeoutError):
1760
2033
  await self.emit(
1761
2034
  {