cdx-manager 0.14.0 → 0.15.1
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/README.md +84 -18
- package/changelogs/CHANGELOGS_0_15_0.md +144 -0
- package/changelogs/CHANGELOGS_0_15_1.md +85 -0
- package/checksums/release-archives.json +16 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli_args.py +51 -7
- package/src/cli_helpers.py +13 -0
- package/src/cli_render.py +17 -0
- package/src/commands/runs.py +306 -19
- package/src/commands/status.py +41 -9
- package/src/config.py +29 -0
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +96 -4
- package/src/run_failover.py +237 -0
- package/src/run_registry.py +115 -3
- package/src/session_service.py +126 -2
- package/src/session_status.py +18 -1
- package/src/status_source.py +59 -0
package/src/run_registry.py
CHANGED
|
@@ -2,9 +2,12 @@ import json
|
|
|
2
2
|
import os
|
|
3
3
|
import sys
|
|
4
4
|
import tempfile
|
|
5
|
+
import time
|
|
5
6
|
from contextlib import contextmanager
|
|
6
7
|
from datetime import datetime, timezone
|
|
7
8
|
|
|
9
|
+
from .errors import CdxError
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
def utc_now_iso():
|
|
10
13
|
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
@@ -14,14 +17,49 @@ def registry_path(base_dir):
|
|
|
14
17
|
return os.path.join(base_dir, "runs.json")
|
|
15
18
|
|
|
16
19
|
|
|
20
|
+
# A waiter waits. `fcntl.flock` blocks until the lock is free, but Windows'
|
|
21
|
+
# `LK_LOCK` retries ten times at one-second intervals and then raises
|
|
22
|
+
# EDEADLOCK, so the same contention produced a wait on one platform and an
|
|
23
|
+
# OSError on the other. Twenty parallel runs writing this registry is the load
|
|
24
|
+
# cdx exists to carry, and on a physical Windows machine it exhausted that
|
|
25
|
+
# budget - CI never showed it, being less contended than a real desktop.
|
|
26
|
+
REGISTRY_LOCK_TIMEOUT_SECONDS = 120
|
|
27
|
+
_LOCK_RETRY_SECONDS = 0.05
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _acquire_windows_lock(handle, timeout_seconds):
|
|
31
|
+
"""Block until the byte is locked, or give up with a nameable error.
|
|
32
|
+
|
|
33
|
+
Uses the non-blocking form and does the waiting here, so the wait is
|
|
34
|
+
bounded by a deadline cdx chooses rather than by `LK_LOCK`'s fixed ten
|
|
35
|
+
attempts. Unbounded would be worse than the original: a stuck holder would
|
|
36
|
+
hang every later command with nothing to report.
|
|
37
|
+
"""
|
|
38
|
+
import msvcrt
|
|
39
|
+
|
|
40
|
+
deadline = time.monotonic() + timeout_seconds
|
|
41
|
+
while True:
|
|
42
|
+
handle.seek(0)
|
|
43
|
+
try:
|
|
44
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
45
|
+
return
|
|
46
|
+
except OSError:
|
|
47
|
+
if time.monotonic() >= deadline:
|
|
48
|
+
raise CdxError(
|
|
49
|
+
"Timed out waiting for the run registry lock after "
|
|
50
|
+
f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
|
|
51
|
+
75,
|
|
52
|
+
) from None
|
|
53
|
+
time.sleep(_LOCK_RETRY_SECONDS)
|
|
54
|
+
|
|
55
|
+
|
|
17
56
|
@contextmanager
|
|
18
|
-
def _registry_lock(path):
|
|
57
|
+
def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
|
|
19
58
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
20
59
|
with open(path + ".lock", "a") as handle:
|
|
21
60
|
if sys.platform == "win32":
|
|
22
61
|
import msvcrt
|
|
23
|
-
handle
|
|
24
|
-
msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
|
|
62
|
+
_acquire_windows_lock(handle, timeout_seconds)
|
|
25
63
|
try:
|
|
26
64
|
yield
|
|
27
65
|
finally:
|
|
@@ -101,6 +139,12 @@ def _refresh_stale_runs(data):
|
|
|
101
139
|
for run in data["runs"]:
|
|
102
140
|
if run.get("status") != "running":
|
|
103
141
|
continue
|
|
142
|
+
if run.get("background_path") == "provider_native":
|
|
143
|
+
# The provider owns this agent's lifecycle and the pid it reports
|
|
144
|
+
# belongs to a daemon helper, not the run. Sweeping on pid liveness
|
|
145
|
+
# would mark a healthy delegated run stale; its terminal status
|
|
146
|
+
# comes from the provider instead.
|
|
147
|
+
continue
|
|
104
148
|
pid = run.get("pid")
|
|
105
149
|
if pid and _is_pid_alive(pid):
|
|
106
150
|
continue
|
|
@@ -124,11 +168,31 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
|
|
|
124
168
|
# the liveness proxy until finish() records the provider child's pid.
|
|
125
169
|
"pid": os.getpid(),
|
|
126
170
|
"started_at": utc_now_iso(),
|
|
171
|
+
# A failover run occupies several sessions in turn while staying one
|
|
172
|
+
# run. `session` and `provider` above name the current occupant so every
|
|
173
|
+
# existing reader keeps working; this is the ordered history behind
|
|
174
|
+
# them, and it exists from the start so no run is ever written in a
|
|
175
|
+
# shape the reporting cannot express.
|
|
176
|
+
"occupancies": [{
|
|
177
|
+
"session": session,
|
|
178
|
+
"provider": provider,
|
|
179
|
+
"started_at": utc_now_iso(),
|
|
180
|
+
"ended_at": None,
|
|
181
|
+
"reason": "initial_selection",
|
|
182
|
+
}],
|
|
127
183
|
"ended_at": None,
|
|
128
184
|
"duration_seconds": None,
|
|
129
185
|
"exit_code": None,
|
|
130
186
|
"usage": None,
|
|
131
187
|
"artifacts": dict(artifacts or {}),
|
|
188
|
+
# Which mechanism is carrying a detached run, and the provider's own
|
|
189
|
+
# handle when it is the provider's. Present on every record so a reader
|
|
190
|
+
# never has to infer it from absence.
|
|
191
|
+
"background_path": None,
|
|
192
|
+
# Two handles, because the provider uses two: the session id names the
|
|
193
|
+
# transcript file, the short agent id is what `claude stop` accepts.
|
|
194
|
+
"provider_session_id": None,
|
|
195
|
+
"provider_agent_id": None,
|
|
132
196
|
"error": None,
|
|
133
197
|
"task_report": None,
|
|
134
198
|
"final_payload": None,
|
|
@@ -166,6 +230,50 @@ class RunRegistry:
|
|
|
166
230
|
return run
|
|
167
231
|
return None
|
|
168
232
|
|
|
233
|
+
def record_background(self, run_id, *, path, provider_session_id=None, provider_agent_id=None):
|
|
234
|
+
with _registry_lock(self.path):
|
|
235
|
+
data = _read_registry(self.path)
|
|
236
|
+
for run in data["runs"]:
|
|
237
|
+
if run.get("run_id") != run_id:
|
|
238
|
+
continue
|
|
239
|
+
run["background_path"] = path
|
|
240
|
+
run["provider_session_id"] = provider_session_id
|
|
241
|
+
run["provider_agent_id"] = provider_agent_id
|
|
242
|
+
_write_registry(self.path, data)
|
|
243
|
+
return run
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
def migrate(self, run_id, *, session, provider, reason):
|
|
247
|
+
"""Move a still-running run onto another session, keeping one run_id.
|
|
248
|
+
|
|
249
|
+
Closes the current occupancy with the reason it ended and opens the
|
|
250
|
+
next one. The top-level `session`/`provider` follow the new occupant so
|
|
251
|
+
readers that know nothing about occupancies stay correct.
|
|
252
|
+
"""
|
|
253
|
+
with _registry_lock(self.path):
|
|
254
|
+
data = _read_registry(self.path)
|
|
255
|
+
now = utc_now_iso()
|
|
256
|
+
for run in data["runs"]:
|
|
257
|
+
if run.get("run_id") != run_id:
|
|
258
|
+
continue
|
|
259
|
+
occupancies = run.get("occupancies") or []
|
|
260
|
+
if occupancies and occupancies[-1].get("ended_at") is None:
|
|
261
|
+
occupancies[-1]["ended_at"] = now
|
|
262
|
+
occupancies[-1]["reason"] = reason
|
|
263
|
+
occupancies.append({
|
|
264
|
+
"session": session,
|
|
265
|
+
"provider": provider,
|
|
266
|
+
"started_at": now,
|
|
267
|
+
"ended_at": None,
|
|
268
|
+
"reason": "failover_target",
|
|
269
|
+
})
|
|
270
|
+
run["occupancies"] = occupancies
|
|
271
|
+
run["session"] = session
|
|
272
|
+
run["provider"] = provider
|
|
273
|
+
_write_registry(self.path, data)
|
|
274
|
+
return run
|
|
275
|
+
return None
|
|
276
|
+
|
|
169
277
|
def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
|
|
170
278
|
with _registry_lock(self.path):
|
|
171
279
|
return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
|
|
@@ -178,6 +286,10 @@ class RunRegistry:
|
|
|
178
286
|
continue
|
|
179
287
|
run["status"] = status
|
|
180
288
|
run["ended_at"] = now
|
|
289
|
+
occupancies = run.get("occupancies") or []
|
|
290
|
+
if occupancies and occupancies[-1].get("ended_at") is None:
|
|
291
|
+
occupancies[-1]["ended_at"] = now
|
|
292
|
+
occupancies[-1]["reason"] = status
|
|
181
293
|
if run.get("started_at"):
|
|
182
294
|
try:
|
|
183
295
|
start = datetime.fromisoformat(str(run["started_at"]).replace("Z", "+00:00"))
|
package/src/session_service.py
CHANGED
|
@@ -35,6 +35,8 @@ from functools import partial
|
|
|
35
35
|
|
|
36
36
|
from .codex_usage import fetch_codex_rate_limits
|
|
37
37
|
from .config import (
|
|
38
|
+
MAX_LAUNCH_BUDGET_USD,
|
|
39
|
+
MAX_LAUNCH_EXTRA_ARGS_LENGTH,
|
|
38
40
|
PERMISSION_INPUT_VALUES,
|
|
39
41
|
PERMISSION_VALUES,
|
|
40
42
|
PROVIDER_CLAUDE,
|
|
@@ -42,6 +44,7 @@ from .config import (
|
|
|
42
44
|
REASONING_EFFORT_VALUES,
|
|
43
45
|
get_cdx_home,
|
|
44
46
|
normalize_permission,
|
|
47
|
+
split_extra_args,
|
|
45
48
|
)
|
|
46
49
|
from .errors import CdxError
|
|
47
50
|
from .fs_utils import atomic_write, remove_tree
|
|
@@ -107,6 +110,7 @@ from .session_status import ( # noqa: F401 (re-exported for callers and tests)
|
|
|
107
110
|
record_status,
|
|
108
111
|
)
|
|
109
112
|
from .session_store import create_session_store
|
|
113
|
+
from .status_source import find_latest_codex_conversation_id
|
|
110
114
|
|
|
111
115
|
MAX_SESSION_LABEL_LENGTH = 64
|
|
112
116
|
# Each live Codex probe spawns app-server, which can refresh+rotate the OAuth
|
|
@@ -214,6 +218,40 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
|
|
|
214
218
|
if len(model) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in model):
|
|
215
219
|
raise CdxError("Model contains unsupported characters.")
|
|
216
220
|
normalized["model"] = model
|
|
221
|
+
if "fallback_model" in settings and settings["fallback_model"] is not None:
|
|
222
|
+
# Claude accepts a comma-separated list here and tries each in order, so
|
|
223
|
+
# this validates every element rather than the string as one model name.
|
|
224
|
+
raw = str(settings["fallback_model"]).strip()
|
|
225
|
+
if not raw:
|
|
226
|
+
raise CdxError("Fallback model cannot be empty.")
|
|
227
|
+
elements = [element.strip() for element in raw.split(",")]
|
|
228
|
+
if any(not element for element in elements):
|
|
229
|
+
raise CdxError("Fallback model cannot contain an empty entry.")
|
|
230
|
+
for element in elements:
|
|
231
|
+
if len(element) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in element):
|
|
232
|
+
raise CdxError("Fallback model contains unsupported characters.")
|
|
233
|
+
normalized["fallback_model"] = ",".join(elements)
|
|
234
|
+
if "extra_args" in settings and settings["extra_args"] is not None:
|
|
235
|
+
extra = str(settings["extra_args"]).strip()
|
|
236
|
+
if not extra:
|
|
237
|
+
raise CdxError("Extra args cannot be empty.")
|
|
238
|
+
if len(extra) > MAX_LAUNCH_EXTRA_ARGS_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in extra):
|
|
239
|
+
raise CdxError("Extra args contain unsupported characters.")
|
|
240
|
+
# Parsed here, not at launch: a string that cannot be split into argv
|
|
241
|
+
# should be rejected when it is set, not when a run fails days later.
|
|
242
|
+
if split_extra_args(extra) is None:
|
|
243
|
+
raise CdxError("Extra args are not a valid argument list (unbalanced quotes).")
|
|
244
|
+
normalized["extra_args"] = extra
|
|
245
|
+
if "budget" in settings and settings["budget"] is not None:
|
|
246
|
+
try:
|
|
247
|
+
budget = float(settings["budget"])
|
|
248
|
+
except (TypeError, ValueError) as error:
|
|
249
|
+
raise CdxError(f"Unsupported budget: {settings['budget']}") from error
|
|
250
|
+
# NaN fails every comparison below, so it has to be rejected explicitly
|
|
251
|
+
# or it would sail through the range check.
|
|
252
|
+
if budget != budget or budget <= 0 or budget > MAX_LAUNCH_BUDGET_USD:
|
|
253
|
+
raise CdxError(f"Unsupported budget: {settings['budget']}")
|
|
254
|
+
normalized["budget"] = budget
|
|
217
255
|
if "priority" in settings and settings["priority"] is not None:
|
|
218
256
|
try:
|
|
219
257
|
priority = int(settings["priority"])
|
|
@@ -310,7 +348,10 @@ def unset_launch_settings(store, name, keys):
|
|
|
310
348
|
raise CdxError(f"Unknown session: {name}")
|
|
311
349
|
if not keys:
|
|
312
350
|
raise CdxError("At least one launch setting is required.")
|
|
313
|
-
allowed = {
|
|
351
|
+
allowed = {
|
|
352
|
+
"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics",
|
|
353
|
+
"model", "fallback_model", "budget", "extra_args", "priority",
|
|
354
|
+
}
|
|
314
355
|
unknown = [key for key in keys if key not in allowed]
|
|
315
356
|
if unknown:
|
|
316
357
|
raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
|
|
@@ -600,9 +641,39 @@ def record_launch_history(store, name, payload):
|
|
|
600
641
|
**payload,
|
|
601
642
|
}
|
|
602
643
|
store["append_launch_history"](entry)
|
|
644
|
+
_observe_codex_conversation(store, session)
|
|
603
645
|
return entry
|
|
604
646
|
|
|
605
647
|
|
|
648
|
+
def _observe_codex_conversation(store, session):
|
|
649
|
+
"""Read Codex's conversation id back after a run, if it wrote one.
|
|
650
|
+
|
|
651
|
+
Codex mints the id itself, so unlike Claude this can only be observed once
|
|
652
|
+
the rollout exists. Failing to find one is normal and leaves the session on
|
|
653
|
+
the recency resume path; it is never an error worth surfacing to a launch
|
|
654
|
+
that otherwise succeeded.
|
|
655
|
+
"""
|
|
656
|
+
if session.get("provider") != PROVIDER_CODEX:
|
|
657
|
+
return
|
|
658
|
+
auth_home = session.get("authHome")
|
|
659
|
+
if not auth_home:
|
|
660
|
+
return
|
|
661
|
+
try:
|
|
662
|
+
identifier = find_latest_codex_conversation_id(auth_home)
|
|
663
|
+
except OSError:
|
|
664
|
+
return
|
|
665
|
+
if not identifier or identifier == (session.get("conversation") or {}).get("id"):
|
|
666
|
+
return
|
|
667
|
+
store["update_session"](session["name"], lambda s: {
|
|
668
|
+
**s,
|
|
669
|
+
"conversation": {
|
|
670
|
+
"id": identifier,
|
|
671
|
+
"provenance": "observed",
|
|
672
|
+
"recordedAt": _local_now_iso(),
|
|
673
|
+
},
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
|
|
606
677
|
def get_launch_history(store, name=None, limit=20):
|
|
607
678
|
if name and not store["get_session"](name):
|
|
608
679
|
raise CdxError(f"Unknown session: {name}")
|
|
@@ -684,8 +755,60 @@ def launch_session(store, name):
|
|
|
684
755
|
return {**state, "rehydratedAt": now}
|
|
685
756
|
|
|
686
757
|
store["update_session_state"](name, state_updater)
|
|
758
|
+
|
|
759
|
+
def session_updater(s):
|
|
760
|
+
updated = {**s, "updatedAt": now, "lastLaunchedAt": now}
|
|
761
|
+
conversation = _conversation_identity_for_launch(s)
|
|
762
|
+
if conversation:
|
|
763
|
+
updated["conversation"] = conversation
|
|
764
|
+
return updated
|
|
765
|
+
|
|
766
|
+
return store["update_session"](name, session_updater)
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _conversation_identity_for_launch(session):
|
|
770
|
+
"""The conversation id this launch will carry, or None to leave it alone.
|
|
771
|
+
|
|
772
|
+
Claude accepts a caller-supplied `--session-id`, so cdx mints one here,
|
|
773
|
+
before the launch spec is built, and knows the conversation's identity
|
|
774
|
+
without having to find it afterwards. A fresh id per launch rather than one
|
|
775
|
+
reused forever: a launch starts a new conversation, and `cdx resume` should
|
|
776
|
+
mean the last one, so the stored value is always the newest. Reusing a
|
|
777
|
+
single id across launches would also depend on how the provider treats a
|
|
778
|
+
repeated `--session-id`, which is a behaviour cdx does not need to rely on.
|
|
779
|
+
|
|
780
|
+
Codex has no equivalent flag; its identity is read back from the rollout
|
|
781
|
+
after the run, so nothing is minted here.
|
|
782
|
+
"""
|
|
783
|
+
if session.get("provider") != PROVIDER_CLAUDE:
|
|
784
|
+
return None
|
|
785
|
+
return {
|
|
786
|
+
"id": str(uuid.uuid4()),
|
|
787
|
+
"provenance": "imposed",
|
|
788
|
+
"recordedAt": _local_now_iso(),
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def begin_conversation(store, name):
|
|
793
|
+
"""Mint the conversation id a Claude run is about to carry, and store it.
|
|
794
|
+
|
|
795
|
+
`launch_session` does this for interactive launches, but a headless run
|
|
796
|
+
never goes through it, so `cdx run` used to leave Claude sessions with no
|
|
797
|
+
identity at all - `cdx resume` then fell back to recency for Claude while
|
|
798
|
+
working by id for Codex, which records its own after the fact. Same shape
|
|
799
|
+
for both providers now: whatever ran last is what resume names.
|
|
800
|
+
|
|
801
|
+
Returns the updated session, or the unchanged one for providers that mint
|
|
802
|
+
nothing here.
|
|
803
|
+
"""
|
|
804
|
+
session = store["get_session"](name)
|
|
805
|
+
if not session:
|
|
806
|
+
raise CdxError(f"Unknown session: {name}")
|
|
807
|
+
conversation = _conversation_identity_for_launch(session)
|
|
808
|
+
if not conversation:
|
|
809
|
+
return session
|
|
687
810
|
return store["update_session"](name, lambda s: {
|
|
688
|
-
**s, "
|
|
811
|
+
**s, "conversation": conversation, "updatedAt": _local_now_iso(),
|
|
689
812
|
})
|
|
690
813
|
|
|
691
814
|
|
|
@@ -726,6 +849,7 @@ def create_session_service(options=None):
|
|
|
726
849
|
"copy_session": partial(copy_session, base_dir, store),
|
|
727
850
|
"rename_session": partial(rename_session, base_dir, store),
|
|
728
851
|
"launch_session": partial(launch_session, store),
|
|
852
|
+
"begin_conversation": partial(begin_conversation, store),
|
|
729
853
|
"active_session_runtime": partial(_session_runtime, store),
|
|
730
854
|
"start_session_runtime": partial(start_session_runtime, store),
|
|
731
855
|
"finish_session_runtime": partial(finish_session_runtime, store),
|
package/src/session_status.py
CHANGED
|
@@ -79,6 +79,7 @@ def _normalize_status_payload(payload=None):
|
|
|
79
79
|
"reset_5h_at": payload.get("reset_5h_at"),
|
|
80
80
|
"reset_week_at": payload.get("reset_week_at"),
|
|
81
81
|
"reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
|
|
82
|
+
"rate_limit_reached": payload.get("rate_limit_reached"),
|
|
82
83
|
"updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
|
|
83
84
|
"raw_status_text": payload.get("raw_status_text"),
|
|
84
85
|
"source_ref": payload.get("source_ref"),
|
|
@@ -146,6 +147,18 @@ def _status_has_more_detail(candidate, current):
|
|
|
146
147
|
]
|
|
147
148
|
return any(current.get(field) is None and candidate.get(field) is not None for field in fields)
|
|
148
149
|
|
|
150
|
+
def _later_status_timestamp(current_at, candidate_at):
|
|
151
|
+
if not candidate_at:
|
|
152
|
+
return current_at
|
|
153
|
+
if not current_at:
|
|
154
|
+
return candidate_at
|
|
155
|
+
parsed_current = _parse_status_timestamp(current_at)
|
|
156
|
+
parsed_candidate = _parse_status_timestamp(candidate_at)
|
|
157
|
+
if not parsed_current or not parsed_candidate:
|
|
158
|
+
return current_at
|
|
159
|
+
return candidate_at if parsed_candidate > parsed_current else current_at
|
|
160
|
+
|
|
161
|
+
|
|
149
162
|
def _merge_status_payload(current, candidate):
|
|
150
163
|
if not current:
|
|
151
164
|
return candidate
|
|
@@ -175,7 +188,11 @@ def _merge_status_payload(current, candidate):
|
|
|
175
188
|
if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
|
|
176
189
|
merged["structured"] = bool(candidate.get("structured"))
|
|
177
190
|
|
|
178
|
-
|
|
191
|
+
# The merge is only reached when the candidate is not newer, so adopting its
|
|
192
|
+
# timestamp would date the record by its stalest contributor. That is not
|
|
193
|
+
# cosmetic: updated_at drives the freshness check, so a regressed stamp
|
|
194
|
+
# makes a just-resolved status read as expired and re-probes every call.
|
|
195
|
+
merged["updated_at"] = _later_status_timestamp(current.get("updated_at"), candidate.get("updated_at"))
|
|
179
196
|
return merged
|
|
180
197
|
|
|
181
198
|
def _compute_available_pct(status):
|
package/src/status_source.py
CHANGED
|
@@ -153,6 +153,13 @@ def _extract_structured_rate_limits(record):
|
|
|
153
153
|
"reset_5h_at": _normalize_timestamp_value(_get_rate_limit_reset_at(five_hour)),
|
|
154
154
|
"reset_week_at": _normalize_timestamp_value(_get_rate_limit_reset_at(weekly)),
|
|
155
155
|
"reset_at": None,
|
|
156
|
+
# A typed reason the account is blocked, when the provider gives one:
|
|
157
|
+
# null in normal operation, and a value such as
|
|
158
|
+
# "workspace_owner_credits_depleted" when a limit is actually reached.
|
|
159
|
+
# Better evidence than any percentage - credits can run out while the
|
|
160
|
+
# 5-hour and weekly windows still read healthy - and better than
|
|
161
|
+
# matching prose, since it is an enum rather than a sentence.
|
|
162
|
+
"rate_limit_reached": _clean_reached_type(rate_limits.get("rate_limit_reached_type")),
|
|
156
163
|
"raw_status_text": json.dumps(rate_limits, sort_keys=True),
|
|
157
164
|
}
|
|
158
165
|
if not five_hour and weekly:
|
|
@@ -172,6 +179,13 @@ def _extract_structured_rate_limits(record):
|
|
|
172
179
|
return result
|
|
173
180
|
|
|
174
181
|
|
|
182
|
+
def _clean_reached_type(value):
|
|
183
|
+
if not isinstance(value, str):
|
|
184
|
+
return None
|
|
185
|
+
text = value.strip()
|
|
186
|
+
return text or None
|
|
187
|
+
|
|
188
|
+
|
|
175
189
|
def _collect_structured_rate_limit_statuses(value, output=None):
|
|
176
190
|
if output is None:
|
|
177
191
|
output = []
|
|
@@ -597,6 +611,51 @@ def extract_named_statuses_from_text(text):
|
|
|
597
611
|
}
|
|
598
612
|
|
|
599
613
|
|
|
614
|
+
_ROLLOUT_NAME = re.compile(r"^rollout-.*-([0-9a-fA-F-]{36})\.jsonl$")
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def find_latest_codex_conversation_id(root_dir):
|
|
618
|
+
"""The conversation id of the newest rollout under a Codex home, or None.
|
|
619
|
+
|
|
620
|
+
Codex exposes no flag that imposes a conversation id, so the only way to
|
|
621
|
+
learn one is to read it back after the run. Each rollout opens with a
|
|
622
|
+
session-meta record carrying `payload.session_id`, and the filename repeats
|
|
623
|
+
it; the record is preferred and the filename is the fallback for a file
|
|
624
|
+
whose first line is truncated.
|
|
625
|
+
|
|
626
|
+
Returns None rather than guessing whenever the newest rollout yields
|
|
627
|
+
nothing usable: a session left on the recency path behaves as it does
|
|
628
|
+
today, while a wrong id would resume somebody else's conversation.
|
|
629
|
+
"""
|
|
630
|
+
sessions_dir = os.path.join(root_dir, "sessions")
|
|
631
|
+
if not os.path.isdir(sessions_dir):
|
|
632
|
+
return None
|
|
633
|
+
newest = None
|
|
634
|
+
for dirpath, _dirnames, filenames in os.walk(sessions_dir):
|
|
635
|
+
for fname in filenames:
|
|
636
|
+
if not _ROLLOUT_NAME.match(fname):
|
|
637
|
+
continue
|
|
638
|
+
fp = os.path.join(dirpath, fname)
|
|
639
|
+
stat = _safe_stat(fp)
|
|
640
|
+
if stat and (newest is None or stat.st_mtime > newest[0]):
|
|
641
|
+
newest = (stat.st_mtime, fp)
|
|
642
|
+
if not newest:
|
|
643
|
+
return None
|
|
644
|
+
|
|
645
|
+
path = newest[1]
|
|
646
|
+
try:
|
|
647
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
648
|
+
record = json.loads(handle.readline())
|
|
649
|
+
identifier = (record.get("payload") or {}).get("session_id")
|
|
650
|
+
if isinstance(identifier, str) and identifier.strip():
|
|
651
|
+
return identifier.strip()
|
|
652
|
+
except (OSError, ValueError):
|
|
653
|
+
pass
|
|
654
|
+
|
|
655
|
+
match = _ROLLOUT_NAME.match(os.path.basename(path))
|
|
656
|
+
return match.group(1) if match else None
|
|
657
|
+
|
|
658
|
+
|
|
600
659
|
def _collect_candidate_files(root_dir):
|
|
601
660
|
priority_candidates = []
|
|
602
661
|
history_candidates = []
|