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
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""Delegating detached runs to a provider's own background agents.
|
|
2
|
+
|
|
3
|
+
cdx has always detached runs itself: fork the child into its own session, keep
|
|
4
|
+
its pid, sweep the record when that pid dies. Claude Code now ships the same
|
|
5
|
+
thing natively - `--bg` starts a background agent and `claude agents --json`
|
|
6
|
+
lists them - so on that provider cdx can stop owning the mechanism and own the
|
|
7
|
+
routing instead, which is the part no provider will do.
|
|
8
|
+
|
|
9
|
+
Two deliberate limits:
|
|
10
|
+
|
|
11
|
+
Claude only. codex-cli exposes `cloud` and `exec-server`, both marked
|
|
12
|
+
EXPERIMENTAL in its own --help. Trading a working in-house path for a surface
|
|
13
|
+
the provider may change without notice is a bad trade, so Codex keeps the
|
|
14
|
+
in-house launcher until that surface settles.
|
|
15
|
+
|
|
16
|
+
Capability, never version. The installed CLI is asked what it supports, the
|
|
17
|
+
same way `cdx doctor --check-provider-flags` already does, because a version
|
|
18
|
+
comparison goes stale the moment either side ships.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import subprocess
|
|
25
|
+
|
|
26
|
+
from .config import PROVIDER_CLAUDE
|
|
27
|
+
|
|
28
|
+
BACKGROUND_PATH_PROVIDER = "provider_native"
|
|
29
|
+
BACKGROUND_PATH_CDX = "cdx_detached"
|
|
30
|
+
|
|
31
|
+
_CAPABILITY_PROBE_TIMEOUT_SECONDS = 10
|
|
32
|
+
|
|
33
|
+
# Observed on Claude Code 2.1.226 by watching one agent from launch to finish:
|
|
34
|
+
# completion is carried by `state`, not by `status`. `status` alternates
|
|
35
|
+
# idle/busy - it describes activity, not outcome - and an agent waiting on
|
|
36
|
+
# something sits at `state: "blocked"` with `status: "idle"`, which reads
|
|
37
|
+
# exactly like a finished one if you look at `status`.
|
|
38
|
+
_TERMINAL_STATES = {"done", "failed", "cancelled", "canceled", "error", "stopped"}
|
|
39
|
+
_SUCCESS_STATE = "done"
|
|
40
|
+
|
|
41
|
+
# `claude --bg` prints `backgrounded · <id>` on stdout. That is the authoritative
|
|
42
|
+
# handle: the provider assigns the id itself and ignores --session-id, so this
|
|
43
|
+
# line is the only thing that reliably names the agent just started.
|
|
44
|
+
_BACKGROUNDED = re.compile(r"backgrounded\s*[^\w]?\s*([0-9a-fA-F]{6,})")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def parse_backgrounded_id(output):
|
|
48
|
+
"""The agent id `claude --bg` reported, or None."""
|
|
49
|
+
if not output:
|
|
50
|
+
return None
|
|
51
|
+
match = _BACKGROUNDED.search(output)
|
|
52
|
+
return match.group(1) if match else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
EXPERIMENTAL_ENV = "CDX_EXPERIMENTAL_NATIVE_BG"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def supports_native_background(provider, env=None, spawn_sync=None):
|
|
59
|
+
"""Whether the installed provider CLI can run and report background agents.
|
|
60
|
+
|
|
61
|
+
Off unless `CDX_EXPERIMENTAL_NATIVE_BG=1`. Two live trials against Claude
|
|
62
|
+
Code 2.1.226 fixed the identification problems and exposed a deeper one.
|
|
63
|
+
|
|
64
|
+
Fixed: the provider assigns its own id and ignores `--session-id`, so the
|
|
65
|
+
agent is now named from the `backgrounded - <id>` line `--bg` prints, and
|
|
66
|
+
completion is read from `state` rather than `status` (which alternates
|
|
67
|
+
idle/busy and says nothing about outcome).
|
|
68
|
+
|
|
69
|
+
Not fixed, and the reason this stays off:
|
|
70
|
+
|
|
71
|
+
1. A delegated run cannot honour the `cdx run --json` contract. The
|
|
72
|
+
in-house path runs `claude --print --output-format json` and gets a
|
|
73
|
+
structured result, token usage, and a final payload. `--bg` runs the
|
|
74
|
+
interactive TUI; `claude logs <id>` returns an ANSI screen dump. So
|
|
75
|
+
usage accounting, the final payload, and the rate-limit classification
|
|
76
|
+
that `--failover` depends on all have no source.
|
|
77
|
+
2. Nothing yet drives a delegated run to a terminal status. `agent_terminal_status`
|
|
78
|
+
exists but no caller consults the provider, so the record relies on pid
|
|
79
|
+
liveness and a blocked agent reads as running indefinitely.
|
|
80
|
+
3. cdx's own launch spec makes the agent block. A hand-run
|
|
81
|
+
`claude --bg --permission-mode bypassPermissions "..."` reaches
|
|
82
|
+
`state: done`; the same task launched through cdx sits at
|
|
83
|
+
`state: blocked`, most likely because the spec passes a `--session-id`
|
|
84
|
+
that already exists.
|
|
85
|
+
|
|
86
|
+
Delegation therefore needs its own request, not a flag flip.
|
|
87
|
+
"""
|
|
88
|
+
if provider != PROVIDER_CLAUDE:
|
|
89
|
+
return False
|
|
90
|
+
environment = env if env is not None else os.environ
|
|
91
|
+
if str(environment.get(EXPERIMENTAL_ENV, "")).strip() not in ("1", "true", "on", "yes"):
|
|
92
|
+
return False
|
|
93
|
+
runner = spawn_sync or _default_spawn_sync
|
|
94
|
+
try:
|
|
95
|
+
help_text = runner(["claude", "--help"], env=env)
|
|
96
|
+
agents_help = runner(["claude", "agents", "--help"], env=env)
|
|
97
|
+
except (OSError, subprocess.SubprocessError):
|
|
98
|
+
return False
|
|
99
|
+
if help_text is None or agents_help is None:
|
|
100
|
+
return False
|
|
101
|
+
return "--bg" in help_text and "--json" in agents_help
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _default_spawn_sync(argv, env=None):
|
|
105
|
+
try:
|
|
106
|
+
completed = subprocess.run(
|
|
107
|
+
argv,
|
|
108
|
+
capture_output=True,
|
|
109
|
+
text=True,
|
|
110
|
+
timeout=_CAPABILITY_PROBE_TIMEOUT_SECONDS,
|
|
111
|
+
env=env,
|
|
112
|
+
check=False,
|
|
113
|
+
)
|
|
114
|
+
except (OSError, subprocess.SubprocessError):
|
|
115
|
+
return None
|
|
116
|
+
return f"{completed.stdout or ''}{completed.stderr or ''}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_background_agents(env=None, cwd=None, spawn_sync=None):
|
|
120
|
+
"""Every background agent the provider knows about, completed ones included.
|
|
121
|
+
|
|
122
|
+
Returns [] rather than raising for any failure: this feeds a status refresh
|
|
123
|
+
that must never take down the command that asked for it.
|
|
124
|
+
"""
|
|
125
|
+
runner = spawn_sync or _default_spawn_sync
|
|
126
|
+
argv = ["claude", "agents", "--json", "--all"]
|
|
127
|
+
if cwd:
|
|
128
|
+
argv += ["--cwd", cwd]
|
|
129
|
+
output = runner(argv, env=env)
|
|
130
|
+
if not output:
|
|
131
|
+
return []
|
|
132
|
+
start = output.find("[")
|
|
133
|
+
if start < 0:
|
|
134
|
+
return []
|
|
135
|
+
try:
|
|
136
|
+
agents = json.loads(output[start:])
|
|
137
|
+
except ValueError:
|
|
138
|
+
return []
|
|
139
|
+
return agents if isinstance(agents, list) else []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def find_agent(agents, identifier):
|
|
143
|
+
"""The agent with this id, matching either the short id or the session id."""
|
|
144
|
+
for agent in agents:
|
|
145
|
+
if not isinstance(agent, dict):
|
|
146
|
+
continue
|
|
147
|
+
if identifier in (agent.get("id"), agent.get("sessionId")):
|
|
148
|
+
return agent
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def stop_background_agent(identifier, env=None, spawn_sync=None):
|
|
153
|
+
"""Release a parked session once its task is done.
|
|
154
|
+
|
|
155
|
+
`--bg` starts an interactive session that outlives the turn it was given,
|
|
156
|
+
so a one-shot run leaves it alive unless cdx stops it. Six were leaked
|
|
157
|
+
during development before this existed.
|
|
158
|
+
"""
|
|
159
|
+
if not identifier:
|
|
160
|
+
return False
|
|
161
|
+
runner = spawn_sync or _default_spawn_sync
|
|
162
|
+
return runner(["claude", "stop", str(identifier)], env=env) is not None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def turn_is_complete(outcome):
|
|
166
|
+
"""Whether the provider recorded an answer for the prompt cdx submitted.
|
|
167
|
+
|
|
168
|
+
This, not the agent's state, is what says a one-shot delegated task
|
|
169
|
+
finished. A `--bg` session parks at `state: blocked, status: idle` after
|
|
170
|
+
answering and stays there indefinitely, waiting for a next prompt that a
|
|
171
|
+
one-shot run will never send - indistinguishable, by state alone, from an
|
|
172
|
+
agent still thinking.
|
|
173
|
+
"""
|
|
174
|
+
return bool(outcome and outcome.get("result") is not None and outcome.get("usage"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def agent_terminal_status(agent):
|
|
178
|
+
"""The cdx run status a provider agent maps to, or None while it runs.
|
|
179
|
+
|
|
180
|
+
An agent the provider no longer lists has finished in a way cdx cannot
|
|
181
|
+
name, so it reports `failed` rather than `succeeded`: claiming success for
|
|
182
|
+
a run whose outcome was never observed is the one answer that must not be
|
|
183
|
+
guessed.
|
|
184
|
+
"""
|
|
185
|
+
if agent is None:
|
|
186
|
+
return "failed"
|
|
187
|
+
state = str(agent.get("state") or "").strip().lower()
|
|
188
|
+
if state not in _TERMINAL_STATES:
|
|
189
|
+
return None
|
|
190
|
+
return "succeeded" if state == _SUCCESS_STATE else "failed"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def find_session_transcript(auth_home, session_id):
|
|
194
|
+
"""The provider's own JSONL transcript for a background session, or None.
|
|
195
|
+
|
|
196
|
+
Claude Code writes one file per session under `.claude/projects/<cwd>/`,
|
|
197
|
+
named by session id. Located by walking rather than by reconstructing the
|
|
198
|
+
encoded cwd directory, because that encoding is the provider's and is not
|
|
199
|
+
part of any contract cdx should depend on.
|
|
200
|
+
"""
|
|
201
|
+
if not auth_home or not session_id:
|
|
202
|
+
return None
|
|
203
|
+
root = os.path.join(auth_home, ".claude", "projects")
|
|
204
|
+
if not os.path.isdir(root):
|
|
205
|
+
return None
|
|
206
|
+
target = f"{session_id}.jsonl"
|
|
207
|
+
for dirpath, _dirnames, filenames in os.walk(root):
|
|
208
|
+
if target in filenames:
|
|
209
|
+
return os.path.join(dirpath, target)
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def read_transcript_outcome(path):
|
|
214
|
+
"""The final assistant text and summed token usage from a session transcript.
|
|
215
|
+
|
|
216
|
+
This is what makes a delegated run able to honour the `cdx run --json`
|
|
217
|
+
contract at all: `--bg` produces no structured stdout, but the provider
|
|
218
|
+
records the same facts here. Usage is summed across assistant messages
|
|
219
|
+
because a session can hold several turns.
|
|
220
|
+
"""
|
|
221
|
+
outcome = {"result": None, "usage": None}
|
|
222
|
+
if not path:
|
|
223
|
+
return outcome
|
|
224
|
+
totals = {"input_tokens": 0, "output_tokens": 0, "reasoning_tokens": 0, "total_tokens": 0}
|
|
225
|
+
seen = False
|
|
226
|
+
try:
|
|
227
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
228
|
+
for line in handle:
|
|
229
|
+
line = line.strip()
|
|
230
|
+
if not line.startswith("{"):
|
|
231
|
+
continue
|
|
232
|
+
try:
|
|
233
|
+
record = json.loads(line)
|
|
234
|
+
except ValueError:
|
|
235
|
+
continue
|
|
236
|
+
if record.get("type") != "assistant":
|
|
237
|
+
continue
|
|
238
|
+
message = record.get("message")
|
|
239
|
+
if not isinstance(message, dict):
|
|
240
|
+
continue
|
|
241
|
+
content = message.get("content")
|
|
242
|
+
if isinstance(content, list):
|
|
243
|
+
text = " ".join(
|
|
244
|
+
block.get("text", "") for block in content
|
|
245
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
246
|
+
).strip()
|
|
247
|
+
if text:
|
|
248
|
+
outcome["result"] = text
|
|
249
|
+
usage = message.get("usage")
|
|
250
|
+
if isinstance(usage, dict):
|
|
251
|
+
seen = True
|
|
252
|
+
inp = (usage.get("input_tokens") or 0) + (usage.get("cache_creation_input_tokens") or 0) \
|
|
253
|
+
+ (usage.get("cache_read_input_tokens") or 0)
|
|
254
|
+
out = usage.get("output_tokens") or 0
|
|
255
|
+
totals["input_tokens"] += inp
|
|
256
|
+
totals["output_tokens"] += out
|
|
257
|
+
totals["total_tokens"] += inp + out
|
|
258
|
+
except OSError:
|
|
259
|
+
return outcome
|
|
260
|
+
if seen:
|
|
261
|
+
totals["reasoning_tokens"] = None
|
|
262
|
+
outcome["usage"] = totals
|
|
263
|
+
return outcome
|
package/src/provider_runtime.py
CHANGED
|
@@ -18,6 +18,7 @@ from .config import (
|
|
|
18
18
|
PROVIDER_CODEX,
|
|
19
19
|
PROVIDER_OLLAMA,
|
|
20
20
|
REASONING_EFFORT_VALUES,
|
|
21
|
+
split_extra_args,
|
|
21
22
|
)
|
|
22
23
|
from .errors import CdxError
|
|
23
24
|
|
|
@@ -354,6 +355,45 @@ def _claude_cli_model(model):
|
|
|
354
355
|
return raw
|
|
355
356
|
|
|
356
357
|
|
|
358
|
+
def _conversation_id(session):
|
|
359
|
+
conversation = session.get("conversation") or {}
|
|
360
|
+
identifier = conversation.get("id")
|
|
361
|
+
return identifier if identifier else None
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _claude_fallback_model(value):
|
|
365
|
+
"""Normalize each element of the comma-separated fallback list.
|
|
366
|
+
|
|
367
|
+
Claude tries the entries in order, so the separator has to survive while
|
|
368
|
+
each name goes through the same aliasing a primary `--model` gets.
|
|
369
|
+
"""
|
|
370
|
+
if not value:
|
|
371
|
+
return value
|
|
372
|
+
return ",".join(_claude_cli_model(element) for element in str(value).split(","))
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _format_budget_arg(value):
|
|
376
|
+
"""Render a budget without the float artefacts a bare str() would show.
|
|
377
|
+
|
|
378
|
+
A stored 5.0 must reach the CLI as `5`, not `5.0`, and 2.50 as `2.5`.
|
|
379
|
+
"""
|
|
380
|
+
number = float(value)
|
|
381
|
+
if number == int(number):
|
|
382
|
+
return str(int(number))
|
|
383
|
+
return f"{number:g}"
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _extra_args(session):
|
|
387
|
+
"""Unvalidated provider arguments, appended after everything cdx maps.
|
|
388
|
+
|
|
389
|
+
Last wins in both provider CLIs, so a passthrough value overrides the
|
|
390
|
+
mapped setting it duplicates. That is the useful default - the escape hatch
|
|
391
|
+
would be pointless if `cdx set` always won - but it is why `cdx configs`
|
|
392
|
+
shows the raw string: the override has to be visible somewhere.
|
|
393
|
+
"""
|
|
394
|
+
return split_extra_args((session.get("launch") or {}).get("extra_args")) or []
|
|
395
|
+
|
|
396
|
+
|
|
357
397
|
def _launch_config_args(session):
|
|
358
398
|
launch = session.get("launch") or {}
|
|
359
399
|
args = []
|
|
@@ -482,9 +522,13 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
|
|
|
482
522
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
483
523
|
launch = session.get("launch") or {}
|
|
484
524
|
args = ["--name", session["name"]]
|
|
525
|
+
conversation_id = _conversation_id(session)
|
|
526
|
+
if conversation_id:
|
|
527
|
+
args += ["--session-id", conversation_id]
|
|
485
528
|
if launch.get("model"):
|
|
486
529
|
args += ["--model", _claude_cli_model(launch["model"])]
|
|
487
530
|
args += _launch_config_args(session)
|
|
531
|
+
args += _extra_args(session)
|
|
488
532
|
if initial_prompt:
|
|
489
533
|
args.append(initial_prompt)
|
|
490
534
|
auth_home = _get_auth_home(session)
|
|
@@ -555,20 +599,48 @@ def _redacted_resume_command_preview(session, cwd=None):
|
|
|
555
599
|
def get_resume_capability(session, cwd=None):
|
|
556
600
|
provider = session.get("provider")
|
|
557
601
|
cwd = cwd or os.getcwd()
|
|
602
|
+
conversation = session.get("conversation") or {}
|
|
603
|
+
conversation_id = _conversation_id(session)
|
|
558
604
|
if provider == PROVIDER_CODEX:
|
|
605
|
+
if conversation_id:
|
|
606
|
+
return {
|
|
607
|
+
"resumable": True,
|
|
608
|
+
"provider": provider,
|
|
609
|
+
"strategy": "provider_conversation_id",
|
|
610
|
+
"identity": conversation_id,
|
|
611
|
+
"provenance": conversation.get("provenance"),
|
|
612
|
+
"reason": "supported",
|
|
613
|
+
"command_preview": ["codex", "resume", conversation_id, "--cd", cwd],
|
|
614
|
+
}
|
|
559
615
|
return {
|
|
560
616
|
"resumable": True,
|
|
561
617
|
"provider": provider,
|
|
562
618
|
"strategy": "provider_last",
|
|
563
|
-
"
|
|
619
|
+
"identity": None,
|
|
620
|
+
"provenance": None,
|
|
621
|
+
# Codex only reveals a conversation id once a run has written its
|
|
622
|
+
# rollout, so a session that has never run legitimately has none.
|
|
623
|
+
"reason": "no_recorded_conversation",
|
|
564
624
|
"command_preview": ["codex", "resume", "--last", "--cd", cwd],
|
|
565
625
|
}
|
|
566
626
|
if provider == PROVIDER_CLAUDE:
|
|
627
|
+
if conversation_id:
|
|
628
|
+
return {
|
|
629
|
+
"resumable": True,
|
|
630
|
+
"provider": provider,
|
|
631
|
+
"strategy": "provider_conversation_id",
|
|
632
|
+
"identity": conversation_id,
|
|
633
|
+
"provenance": conversation.get("provenance"),
|
|
634
|
+
"reason": "supported",
|
|
635
|
+
"command_preview": ["claude", "--resume", conversation_id],
|
|
636
|
+
}
|
|
567
637
|
return {
|
|
568
638
|
"resumable": True,
|
|
569
639
|
"provider": provider,
|
|
570
640
|
"strategy": "provider_continue",
|
|
571
|
-
"
|
|
641
|
+
"identity": None,
|
|
642
|
+
"provenance": None,
|
|
643
|
+
"reason": "no_recorded_conversation",
|
|
572
644
|
"command_preview": ["claude", "--continue"],
|
|
573
645
|
}
|
|
574
646
|
return {
|
|
@@ -593,10 +665,16 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
593
665
|
|
|
594
666
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
595
667
|
launch = session.get("launch") or {}
|
|
596
|
-
|
|
668
|
+
# Resuming by id targets the conversation this session actually last
|
|
669
|
+
# opened; --continue only means "the most recent one in this directory",
|
|
670
|
+
# which another session or another cwd can silently change.
|
|
671
|
+
conversation_id = _conversation_id(session)
|
|
672
|
+
args = ["--resume", conversation_id] if conversation_id else ["--continue"]
|
|
673
|
+
args += ["--name", session["name"]]
|
|
597
674
|
if launch.get("model"):
|
|
598
675
|
args += ["--model", _claude_cli_model(launch["model"])]
|
|
599
676
|
args += _launch_config_args(session)
|
|
677
|
+
args += _extra_args(session)
|
|
600
678
|
if resume_prompt:
|
|
601
679
|
args.append(resume_prompt)
|
|
602
680
|
auth_home = _get_auth_home(session)
|
|
@@ -615,10 +693,13 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
|
|
|
615
693
|
}, capture_transcript=capture_transcript, env=env)
|
|
616
694
|
|
|
617
695
|
launch = session.get("launch") or {}
|
|
618
|
-
|
|
696
|
+
conversation_id = _conversation_id(session)
|
|
697
|
+
args = ["resume", conversation_id] if conversation_id else ["resume", "--last"]
|
|
698
|
+
args += ["--cd", cwd]
|
|
619
699
|
if launch.get("model"):
|
|
620
700
|
args += ["--model", launch["model"]]
|
|
621
701
|
args += _launch_config_args(session)
|
|
702
|
+
args += _extra_args(session)
|
|
622
703
|
if resume_prompt:
|
|
623
704
|
args.append(resume_prompt)
|
|
624
705
|
return _wrap_launch_with_transcript(session, {
|
|
@@ -652,9 +733,20 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
|
|
|
652
733
|
|
|
653
734
|
if session["provider"] == PROVIDER_CLAUDE:
|
|
654
735
|
args = ["--print", "--output-format", "json", "--name", session["name"]]
|
|
736
|
+
conversation_id = _conversation_id(session)
|
|
737
|
+
if conversation_id:
|
|
738
|
+
args += ["--session-id", conversation_id]
|
|
655
739
|
if model:
|
|
656
740
|
args += ["--model", _claude_cli_model(model)]
|
|
741
|
+
# --max-budget-usd and --fallback-model are --print-only in the claude
|
|
742
|
+
# CLI, so they belong to this spec alone: the interactive and resume
|
|
743
|
+
# specs would be rejected by the provider for carrying them.
|
|
744
|
+
if launch.get("budget") is not None:
|
|
745
|
+
args += ["--max-budget-usd", _format_budget_arg(launch["budget"])]
|
|
746
|
+
if launch.get("fallback_model"):
|
|
747
|
+
args += ["--fallback-model", _claude_fallback_model(launch["fallback_model"])]
|
|
657
748
|
args += _launch_config_args(session)
|
|
749
|
+
args += _extra_args(session)
|
|
658
750
|
if initial_prompt:
|
|
659
751
|
args.append(initial_prompt)
|
|
660
752
|
auth_home = _get_auth_home(session)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Deciding whether a finished run stopped because its account ran out.
|
|
2
|
+
|
|
3
|
+
Two independent signals have to agree before cdx moves a task to another
|
|
4
|
+
account, because the two ways of being wrong are not symmetric. Missing a rate
|
|
5
|
+
limit costs nothing new: the run fails exactly as it does today. Inventing one
|
|
6
|
+
migrates a healthy run off a working account, discards its progress, and
|
|
7
|
+
consumes a second account's quota to redo it. So every ambiguous case here
|
|
8
|
+
resolves to "not rate limited".
|
|
9
|
+
|
|
10
|
+
Signal one is the provider's own structured output, which both headless paths
|
|
11
|
+
already produce - `codex exec --json` emits JSONL events, and the claude spec
|
|
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 - 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.
|
|
16
|
+
|
|
17
|
+
The matchers below are deliberately the only place that knows what exhaustion
|
|
18
|
+
looks like on the wire. That shape is version-specific and is the part most
|
|
19
|
+
likely to drift, so it is one function per provider and nothing else in the
|
|
20
|
+
codebase reads provider output for this purpose.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
|
|
26
|
+
from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
|
|
27
|
+
|
|
28
|
+
MAX_FAILOVER_TRANSITIONS = 4
|
|
29
|
+
|
|
30
|
+
# Read from the tail: a rate limit ends the run, so its marker is near the end,
|
|
31
|
+
# and a long transcript should not be loaded whole to find it.
|
|
32
|
+
_MAX_TAIL_BYTES = 64 * 1024
|
|
33
|
+
|
|
34
|
+
# Observed against codex-cli 0.147.0 on an exhausted account:
|
|
35
|
+
# {"type":"error","message":"Your workspace is out of credits. Add credits to continue."}
|
|
36
|
+
# {"type":"turn.failed","error":{"message":"Your workspace is out of credits..."}}
|
|
37
|
+
# "out of credits" was missing from the first version of this list, which is why
|
|
38
|
+
# it never matched anything real. Credit exhaustion and a rate limit are the
|
|
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.
|
|
48
|
+
_RATE_LIMIT_MARKERS = (
|
|
49
|
+
"out of credits",
|
|
50
|
+
"rate_limit",
|
|
51
|
+
"rate limit",
|
|
52
|
+
"usage limit",
|
|
53
|
+
"quota exceeded",
|
|
54
|
+
"insufficient_quota",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# Records the provider itself labelled as failures. Scoping the search to these
|
|
58
|
+
# is what keeps assistant prose out of the decision - the model's own words are
|
|
59
|
+
# never inside a record of this shape. The first version instead excluded the
|
|
60
|
+
# `message` key everywhere, which also excluded the field codex actually puts
|
|
61
|
+
# its error text in, so nothing could ever match.
|
|
62
|
+
_CODEX_ERROR_TYPES = ("error", "turn.failed", "thread.failed")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_tail(path):
|
|
66
|
+
if not path or not os.path.isfile(path):
|
|
67
|
+
return ""
|
|
68
|
+
try:
|
|
69
|
+
size = os.path.getsize(path)
|
|
70
|
+
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
71
|
+
if size > _MAX_TAIL_BYTES:
|
|
72
|
+
handle.seek(size - _MAX_TAIL_BYTES)
|
|
73
|
+
return handle.read()
|
|
74
|
+
except OSError:
|
|
75
|
+
return ""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _error_texts(provider, record):
|
|
79
|
+
"""Message strings from a record the provider marked as a failure.
|
|
80
|
+
|
|
81
|
+
Returns nothing for any other record, which is what keeps the assistant's
|
|
82
|
+
own words out of the decision: prose lives in successful results, never in
|
|
83
|
+
an error record.
|
|
84
|
+
"""
|
|
85
|
+
if not isinstance(record, dict):
|
|
86
|
+
return []
|
|
87
|
+
texts = []
|
|
88
|
+
if provider == PROVIDER_CODEX:
|
|
89
|
+
if str(record.get("type") or "") not in _CODEX_ERROR_TYPES:
|
|
90
|
+
return []
|
|
91
|
+
texts.append(record.get("message"))
|
|
92
|
+
error = record.get("error")
|
|
93
|
+
if isinstance(error, dict):
|
|
94
|
+
texts.append(error.get("message"))
|
|
95
|
+
elif provider == PROVIDER_CLAUDE:
|
|
96
|
+
# `--print --output-format json` returns one result object; its own
|
|
97
|
+
# is_error flag says whether `result` holds an error or an answer.
|
|
98
|
+
if record.get("is_error") is not True:
|
|
99
|
+
return []
|
|
100
|
+
texts.append(record.get("result"))
|
|
101
|
+
texts.append(record.get("subtype"))
|
|
102
|
+
error = record.get("error")
|
|
103
|
+
if isinstance(error, dict):
|
|
104
|
+
texts.append(error.get("message"))
|
|
105
|
+
return [text for text in texts if isinstance(text, str)]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _mentions_exhaustion(texts):
|
|
109
|
+
return any(
|
|
110
|
+
marker in text.lower()
|
|
111
|
+
for text in texts
|
|
112
|
+
for marker in _RATE_LIMIT_MARKERS
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _structured_records(provider, text):
|
|
117
|
+
"""The structured payloads a provider wrote, newest last.
|
|
118
|
+
|
|
119
|
+
Codex emits one JSON object per line; claude emits a single object. Lines
|
|
120
|
+
that do not decode are skipped rather than pattern-matched as raw text:
|
|
121
|
+
unparsed output is not a signal, it is an absence of one.
|
|
122
|
+
"""
|
|
123
|
+
records = []
|
|
124
|
+
if not text:
|
|
125
|
+
return records
|
|
126
|
+
if provider == PROVIDER_CODEX:
|
|
127
|
+
for line in text.splitlines():
|
|
128
|
+
line = line.strip()
|
|
129
|
+
if not line.startswith("{"):
|
|
130
|
+
continue
|
|
131
|
+
try:
|
|
132
|
+
records.append(json.loads(line))
|
|
133
|
+
except ValueError:
|
|
134
|
+
continue
|
|
135
|
+
return records
|
|
136
|
+
if provider == PROVIDER_CLAUDE:
|
|
137
|
+
stripped = text.strip()
|
|
138
|
+
start = stripped.find("{")
|
|
139
|
+
if start >= 0:
|
|
140
|
+
try:
|
|
141
|
+
records.append(json.loads(stripped[start:]))
|
|
142
|
+
except ValueError:
|
|
143
|
+
return records
|
|
144
|
+
return records
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def looks_rate_limited(provider, run_info):
|
|
148
|
+
"""Whether the provider's structured output says the account is exhausted.
|
|
149
|
+
|
|
150
|
+
This is the weaker of the two signals and is never acted on alone; see
|
|
151
|
+
`should_fail_over`.
|
|
152
|
+
"""
|
|
153
|
+
if provider not in (PROVIDER_CODEX, PROVIDER_CLAUDE):
|
|
154
|
+
return False
|
|
155
|
+
if run_info.get("timed_out"):
|
|
156
|
+
# A timeout is cdx's own deadline, not the provider's verdict.
|
|
157
|
+
return False
|
|
158
|
+
if run_info.get("returncode") == 0:
|
|
159
|
+
return False
|
|
160
|
+
for record in _structured_records(provider, _read_tail(run_info.get("stdout_path"))):
|
|
161
|
+
if _mentions_exhaustion(_error_texts(provider, record)):
|
|
162
|
+
return True
|
|
163
|
+
return False
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def status_confirms_exhaustion(row):
|
|
167
|
+
"""Whether a freshly refreshed status row agrees the account is spent.
|
|
168
|
+
|
|
169
|
+
An unreadable or empty row is not a confirmation. Treating "no status" as
|
|
170
|
+
exhaustion would make every probe failure look like a rate limit.
|
|
171
|
+
"""
|
|
172
|
+
if not row:
|
|
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
|
|
180
|
+
if row.get("blocking"):
|
|
181
|
+
return True
|
|
182
|
+
for key in ("remaining_5h_pct", "remaining_week_pct"):
|
|
183
|
+
value = row.get(key)
|
|
184
|
+
if isinstance(value, (int, float)) and value <= 0:
|
|
185
|
+
return True
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def provider_reported_failure(provider, run_info):
|
|
190
|
+
"""A failure the provider itself emitted, whatever it says.
|
|
191
|
+
|
|
192
|
+
Deliberately wording-free. A timeout is cdx's own deadline, and a zero exit
|
|
193
|
+
is not a failure; everything else that produced a provider error record
|
|
194
|
+
counts.
|
|
195
|
+
"""
|
|
196
|
+
if provider not in (PROVIDER_CODEX, PROVIDER_CLAUDE):
|
|
197
|
+
return False
|
|
198
|
+
if run_info.get("timed_out") or run_info.get("returncode") == 0:
|
|
199
|
+
return False
|
|
200
|
+
for record in _structured_records(provider, _read_tail(run_info.get("stdout_path"))):
|
|
201
|
+
if _error_texts(provider, record):
|
|
202
|
+
return True
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def failover_reason(provider, run_info, status_row):
|
|
207
|
+
"""Why this run should move, or None to stay put.
|
|
208
|
+
|
|
209
|
+
Two independent routes, because relying on either alone has been shown to
|
|
210
|
+
fail:
|
|
211
|
+
|
|
212
|
+
`provider_reported_exhaustion` - the provider said so in words cdx
|
|
213
|
+
recognises. Precise, but the wording is not one thing: an enterprise
|
|
214
|
+
workspace out of credits, a personal plan hitting its 5-hour window, and
|
|
215
|
+
Claude's own phrasing are all different sentences, and only the first has
|
|
216
|
+
ever been observed. Matching prose alone would silently miss the rest.
|
|
217
|
+
|
|
218
|
+
`account_status_exhausted` - the account's own rate-limit status says it has
|
|
219
|
+
nothing left, and the run failed. This is the authoritative route: it comes
|
|
220
|
+
from the provider's rate-limit API rather than from English, so it holds for
|
|
221
|
+
plans and providers whose wording nobody has seen. An account at zero cannot
|
|
222
|
+
serve the task no matter which sentence it used to say so.
|
|
223
|
+
|
|
224
|
+
Status confirmation is required either way, so a healthy account is never
|
|
225
|
+
abandoned on the strength of a matched phrase.
|
|
226
|
+
"""
|
|
227
|
+
if not status_confirms_exhaustion(status_row):
|
|
228
|
+
return None
|
|
229
|
+
if looks_rate_limited(provider, run_info):
|
|
230
|
+
return "provider_reported_exhaustion"
|
|
231
|
+
if provider_reported_failure(provider, run_info):
|
|
232
|
+
return "account_status_exhausted"
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def should_fail_over(provider, run_info, status_row):
|
|
237
|
+
return failover_reason(provider, run_info, status_row) is not None
|