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/docs/publishing.md +2 -2
- package/docs/troubleshooting.md +18 -0
- package/package.json +1 -1
- package/python/broker.py +5 -0
- package/python/commands.py +0 -2
- package/python/execution.py +1 -1
- package/python/pool.py +7 -0
- package/python/router.py +299 -26
- package/python/service.py +14 -4
- package/python/terminal.py +28 -0
- package/tests/fake_native.py +54 -0
- package/tests/test_activity.py +318 -0
- package/tests/test_audit.py +1 -0
- package/tests/test_recovery_boundary.py +422 -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.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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
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",
|
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/execution.py
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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(
|
|
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.
|
|
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
|
-
|
|
963
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1038
|
-
if
|
|
1039
|
-
self.
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
1247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1757
|
-
|
|
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
|
{
|