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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.15.0-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.15.1-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
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 and of Claude has not
135
- been observed; only an enterprise workspace out of credits has. The
136
- `account_status_exhausted` route covers the unobserved cases, so this costs
137
- the precise reason rather than the failover itself.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.15.0",
3
+ "version": "0.15.1",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.15.0"
7
+ version = "0.15.1"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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"):
@@ -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.seek(0)
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:
@@ -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"),
@@ -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 = []