cdx-manager 0.15.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 +1 -1
- package/changelogs/CHANGELOGS_0_15_0.md +9 -4
- package/changelogs/CHANGELOGS_0_15_1.md +85 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/run_failover.py +17 -1
- package/src/run_registry.py +41 -3
- package/src/session_status.py +1 -0
- package/src/status_source.py +14 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
|
|
6
6
|
|
|
@@ -131,9 +131,14 @@ synthetic home (`scripts/seed_demo_home.py`) so they carry no account names.
|
|
|
131
131
|
|
|
132
132
|
## Known gaps
|
|
133
133
|
|
|
134
|
-
- The rate-limit wording of a personal-plan Codex account
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
- The rate-limit wording of a personal-plan Codex account has not been
|
|
135
|
+
observed; only an enterprise workspace out of credits has. Claude's wording
|
|
136
|
+
was recovered from local transcripts after the release was cut - it reports
|
|
137
|
+
`type: rate_limit_error` under HTTP 429, which the matcher already covers.
|
|
138
|
+
Worth knowing: that same 429 is also how an org policy disabling a model is
|
|
139
|
+
reported, a case no failover can fix, and the status corroboration is what
|
|
140
|
+
stops a healthy account being abandoned for it. The
|
|
141
|
+
`account_status_exhausted` route covers whatever wording remains unseen, so
|
|
142
|
+
this costs the precise reason rather than the failover itself.
|
|
138
143
|
- Background delegation has been exercised on one account with one prompt
|
|
139
144
|
shape, which is why it is opt-in.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# CDX Manager 0.15.1
|
|
2
|
+
|
|
3
|
+
> Two fixes, both for failures that only appear under the conditions cdx is
|
|
4
|
+
> built for: an account whose credits ran out while its usage windows still
|
|
5
|
+
> read healthy, and twenty parallel runs writing the registry at once on
|
|
6
|
+
> Windows.
|
|
7
|
+
|
|
8
|
+
## Fixes
|
|
9
|
+
|
|
10
|
+
### Failover missed an exhausted account when its windows looked fine
|
|
11
|
+
|
|
12
|
+
`--failover` corroborates a rate-limited run against the account's own
|
|
13
|
+
refreshed status before migrating, so a healthy account is never abandoned on
|
|
14
|
+
the strength of a matched phrase. That check read only the 5-hour and weekly
|
|
15
|
+
percentages, which cannot see credit exhaustion: an account with 88% of its
|
|
16
|
+
5-hour window and 70% of its weekly window remaining, and no credits, was
|
|
17
|
+
reported as not spent. The run failed instead of moving, on an account that
|
|
18
|
+
could not have succeeded.
|
|
19
|
+
|
|
20
|
+
Codex publishes a typed reason for this — `rate_limit_reached_type`, null in
|
|
21
|
+
normal operation and set to a value such as `workspace_owner_credits_depleted`
|
|
22
|
+
when a limit is actually reached. cdx was already fetching that payload and
|
|
23
|
+
dropping the field; it survived only inside `raw_status_text`, unparsed. It is
|
|
24
|
+
now surfaced and treated as authoritative.
|
|
25
|
+
|
|
26
|
+
Any non-null value counts. The enum belongs to the provider, and carrying a
|
|
27
|
+
list of its members in cdx would reintroduce exactly the drift this removes —
|
|
28
|
+
a value nobody has seen yet would silently mean "not exhausted".
|
|
29
|
+
|
|
30
|
+
### Concurrent registry writes failed on Windows
|
|
31
|
+
|
|
32
|
+
`_registry_lock` used `msvcrt.locking(LK_LOCK)`, which does its own waiting —
|
|
33
|
+
ten attempts, one second apart — and then raises `EDEADLOCK`. POSIX
|
|
34
|
+
`fcntl.flock` blocks until the lock frees. Identical contention therefore
|
|
35
|
+
produced a wait on one supported platform and an `OSError` on the other.
|
|
36
|
+
|
|
37
|
+
Twenty parallel runs writing the run registry is not an edge case for this
|
|
38
|
+
tool; it is the load it exists to carry. Windows now takes the non-blocking
|
|
39
|
+
form and waits in a loop cdx controls, bounded by a deadline cdx chooses rather
|
|
40
|
+
than by `LK_LOCK`'s fixed budget. An exhausted wait raises a named error
|
|
41
|
+
instead of a raw `OSError`, so contention is distinguishable from a bug.
|
|
42
|
+
|
|
43
|
+
This predates 0.15.0 — it reproduces on the `v0.14.0` tag — and CI never showed
|
|
44
|
+
it, a hosted runner being less contended than a physical desktop. Which also
|
|
45
|
+
means CI could not be the evidence for the fix.
|
|
46
|
+
|
|
47
|
+
## Also in this release
|
|
48
|
+
|
|
49
|
+
- A test now fails if a flag is declared in a parser's schema and never used,
|
|
50
|
+
which is how `--failover` shipped in 0.15.0 parsing, validating and doing
|
|
51
|
+
nothing until an integration test caught it by chance.
|
|
52
|
+
- `scripts/measure_option_sites.py` records the measurement behind that
|
|
53
|
+
decision: of the nine declaration sites an option touches, six restate one
|
|
54
|
+
fact and three encode a real decision, so the answer was a check rather than
|
|
55
|
+
a consolidation.
|
|
56
|
+
|
|
57
|
+
## Validation
|
|
58
|
+
|
|
59
|
+
- `npm run lint`
|
|
60
|
+
- `npm test` — 639 tests.
|
|
61
|
+
- CI green on ubuntu-latest and windows-latest.
|
|
62
|
+
- On a physical Windows machine: 632 passed, 5 skipped, **no failures** —
|
|
63
|
+
including `test_concurrent_starts_do_not_lose_records`, which had failed
|
|
64
|
+
there on every run since before v0.14.0.
|
|
65
|
+
- The credit-exhaustion case verified against the real payload shape recovered
|
|
66
|
+
from local transcripts: with 88% and 70% of the windows remaining and a typed
|
|
67
|
+
reason present, corroboration now confirms where it previously did not.
|
|
68
|
+
|
|
69
|
+
## What closed from 0.15.0's known gaps
|
|
70
|
+
|
|
71
|
+
- **Claude's rate-limit wording** was unobserved and now is not. Recovered from
|
|
72
|
+
local session transcripts: `type: rate_limit_error` under HTTP 429, already
|
|
73
|
+
covered by the existing matcher. Worth knowing, and now pinned by tests: the
|
|
74
|
+
same 429 is how an org policy disabling a model is reported, a case no
|
|
75
|
+
failover can fix, and the status corroboration is what keeps a healthy
|
|
76
|
+
account from being abandoned for it.
|
|
77
|
+
|
|
78
|
+
## Still open
|
|
79
|
+
|
|
80
|
+
- The rate-limit wording of a personal-plan Codex account remains unobserved.
|
|
81
|
+
The `account_status_exhausted` route covers it, so this costs the precise
|
|
82
|
+
reason rather than the failover itself.
|
|
83
|
+
- Background delegation is still opt-in behind `CDX_EXPERIMENTAL_NATIVE_BG=1`.
|
|
84
|
+
It works and is verified end to end, but on one account with one prompt
|
|
85
|
+
shape — an exposure judgement, not a correctness one.
|
|
@@ -172,6 +172,10 @@
|
|
|
172
172
|
"v0.14.0": {
|
|
173
173
|
"github_tarball_sha256": "5da107556442d3566a7b0787708ae700e38afdf99e74209fffd2484ce3a33d94",
|
|
174
174
|
"github_zip_sha256": "bd4e070582bfc641e6176a8b8be8d7afd1ce9d76fc59c1dbeb238bae548bc699"
|
|
175
|
+
},
|
|
176
|
+
"v0.15.0": {
|
|
177
|
+
"github_tarball_sha256": "93d0a7305a5235f20e89a3e2a404029546926437952ee61d43a1f69c3ae98e4e",
|
|
178
|
+
"github_zip_sha256": "23e4e42fb746f4af5fa407f853e979ffce91e147dbb623c250e276fdcaa9638a"
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/run_failover.py
CHANGED
|
@@ -10,7 +10,9 @@ resolves to "not rate limited".
|
|
|
10
10
|
Signal one is the provider's own structured output, which both headless paths
|
|
11
11
|
already produce - `codex exec --json` emits JSONL events, and the claude spec
|
|
12
12
|
passes `--print --output-format json`. Signal two is the account's rate-limit
|
|
13
|
-
status, refreshed once and read through the existing status pipeline
|
|
13
|
+
status, refreshed once and read through the existing status pipeline - where
|
|
14
|
+
`rate_limit_reached` is the strongest evidence available, being the provider's
|
|
15
|
+
own typed reason rather than a threshold or a sentence.
|
|
14
16
|
|
|
15
17
|
The matchers below are deliberately the only place that knows what exhaustion
|
|
16
18
|
looks like on the wire. That shape is version-specific and is the part most
|
|
@@ -35,6 +37,14 @@ _MAX_TAIL_BYTES = 64 * 1024
|
|
|
35
37
|
# "out of credits" was missing from the first version of this list, which is why
|
|
36
38
|
# it never matched anything real. Credit exhaustion and a rate limit are the
|
|
37
39
|
# same thing for this decision: the account cannot serve the task, try another.
|
|
40
|
+
# Claude's own wording, captured from a real transcript on this machine:
|
|
41
|
+
# API error: 429 {"type":"error","error":{"type":"rate_limit_error",
|
|
42
|
+
# "message":"Usage credits are required for this model.", ...}}
|
|
43
|
+
# `rate_limit_error` is already covered by "rate_limit" below. Worth knowing:
|
|
44
|
+
# that same 429 is also how an org policy disabling a model is reported
|
|
45
|
+
# (`disabled_reason: org_level_disabled`), which no amount of failing over
|
|
46
|
+
# would fix - every account in the org hits it. The status corroboration in
|
|
47
|
+
# `failover_reason` is what keeps that case from migrating a healthy run.
|
|
38
48
|
_RATE_LIMIT_MARKERS = (
|
|
39
49
|
"out of credits",
|
|
40
50
|
"rate_limit",
|
|
@@ -161,6 +171,12 @@ def status_confirms_exhaustion(row):
|
|
|
161
171
|
"""
|
|
162
172
|
if not row:
|
|
163
173
|
return False
|
|
174
|
+
# The provider's own typed reason, when it gives one. Authoritative and
|
|
175
|
+
# language-free: `workspace_owner_credits_depleted` says the account cannot
|
|
176
|
+
# serve the task even when both percentage windows still read healthy,
|
|
177
|
+
# which is a case the thresholds below would miss entirely.
|
|
178
|
+
if row.get("rate_limit_reached"):
|
|
179
|
+
return True
|
|
164
180
|
if row.get("blocking"):
|
|
165
181
|
return True
|
|
166
182
|
for key in ("remaining_5h_pct", "remaining_week_pct"):
|
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:
|
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"),
|
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 = []
|