cdx-manager 0.15.0 → 0.16.0

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.
@@ -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:
@@ -211,6 +211,18 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
211
211
  normalized["logics"] = False
212
212
  else:
213
213
  raise CdxError(f"Unsupported logics value: {settings['logics']}")
214
+ if "notify" in settings and settings["notify"] is not None:
215
+ value = settings["notify"]
216
+ if isinstance(value, bool):
217
+ normalized["notify"] = value
218
+ else:
219
+ text = str(value).strip().lower()
220
+ if text in ("on", "true", "1", "yes"):
221
+ normalized["notify"] = True
222
+ elif text in ("off", "false", "0", "no"):
223
+ normalized["notify"] = False
224
+ else:
225
+ raise CdxError(f"Unsupported notify value: {settings['notify']}")
214
226
  if "model" in settings and settings["model"] is not None:
215
227
  model = str(settings["model"]).strip()
216
228
  if not model:
@@ -349,7 +361,7 @@ def unset_launch_settings(store, name, keys):
349
361
  if not keys:
350
362
  raise CdxError("At least one launch setting is required.")
351
363
  allowed = {
352
- "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics",
364
+ "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "notify",
353
365
  "model", "fallback_model", "budget", "extra_args", "priority",
354
366
  }
355
367
  unknown = [key for key in keys if key not in allowed]
@@ -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 = []