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.
- package/README.md +41 -6
- package/changelogs/CHANGELOGS_0_15_0.md +9 -4
- package/changelogs/CHANGELOGS_0_15_1.md +85 -0
- package/changelogs/CHANGELOGS_0_16_0.md +97 -0
- package/checksums/release-archives.json +8 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/agent_notify.py +324 -0
- package/src/cli.py +4 -6
- package/src/cli_args.py +4 -2
- package/src/cli_commands.py +1 -1
- package/src/cli_helpers.py +5 -2
- package/src/commands/auth.py +35 -52
- package/src/commands/launch.py +20 -1
- package/src/notify.py +125 -149
- package/src/provider_runtime.py +5 -0
- package/src/run_failover.py +17 -1
- package/src/run_registry.py +41 -3
- package/src/session_service.py +13 -1
- package/src/session_status.py +1 -0
- package/src/status_source.py +14 -0
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_service.py
CHANGED
|
@@ -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]
|
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 = []
|