cdx-manager 0.14.0 → 0.15.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.
@@ -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
@@ -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
- "reason": "supported",
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
- "reason": "supported",
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
- args = ["--continue", "--name", session["name"]]
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
- args = ["resume", "--last", "--cd", cwd]
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,221 @@
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.
14
+
15
+ The matchers below are deliberately the only place that knows what exhaustion
16
+ looks like on the wire. That shape is version-specific and is the part most
17
+ likely to drift, so it is one function per provider and nothing else in the
18
+ codebase reads provider output for this purpose.
19
+ """
20
+
21
+ import json
22
+ import os
23
+
24
+ from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
25
+
26
+ MAX_FAILOVER_TRANSITIONS = 4
27
+
28
+ # Read from the tail: a rate limit ends the run, so its marker is near the end,
29
+ # and a long transcript should not be loaded whole to find it.
30
+ _MAX_TAIL_BYTES = 64 * 1024
31
+
32
+ # Observed against codex-cli 0.147.0 on an exhausted account:
33
+ # {"type":"error","message":"Your workspace is out of credits. Add credits to continue."}
34
+ # {"type":"turn.failed","error":{"message":"Your workspace is out of credits..."}}
35
+ # "out of credits" was missing from the first version of this list, which is why
36
+ # it never matched anything real. Credit exhaustion and a rate limit are the
37
+ # same thing for this decision: the account cannot serve the task, try another.
38
+ _RATE_LIMIT_MARKERS = (
39
+ "out of credits",
40
+ "rate_limit",
41
+ "rate limit",
42
+ "usage limit",
43
+ "quota exceeded",
44
+ "insufficient_quota",
45
+ )
46
+
47
+ # Records the provider itself labelled as failures. Scoping the search to these
48
+ # is what keeps assistant prose out of the decision - the model's own words are
49
+ # never inside a record of this shape. The first version instead excluded the
50
+ # `message` key everywhere, which also excluded the field codex actually puts
51
+ # its error text in, so nothing could ever match.
52
+ _CODEX_ERROR_TYPES = ("error", "turn.failed", "thread.failed")
53
+
54
+
55
+ def _read_tail(path):
56
+ if not path or not os.path.isfile(path):
57
+ return ""
58
+ try:
59
+ size = os.path.getsize(path)
60
+ with open(path, encoding="utf-8", errors="replace") as handle:
61
+ if size > _MAX_TAIL_BYTES:
62
+ handle.seek(size - _MAX_TAIL_BYTES)
63
+ return handle.read()
64
+ except OSError:
65
+ return ""
66
+
67
+
68
+ def _error_texts(provider, record):
69
+ """Message strings from a record the provider marked as a failure.
70
+
71
+ Returns nothing for any other record, which is what keeps the assistant's
72
+ own words out of the decision: prose lives in successful results, never in
73
+ an error record.
74
+ """
75
+ if not isinstance(record, dict):
76
+ return []
77
+ texts = []
78
+ if provider == PROVIDER_CODEX:
79
+ if str(record.get("type") or "") not in _CODEX_ERROR_TYPES:
80
+ return []
81
+ texts.append(record.get("message"))
82
+ error = record.get("error")
83
+ if isinstance(error, dict):
84
+ texts.append(error.get("message"))
85
+ elif provider == PROVIDER_CLAUDE:
86
+ # `--print --output-format json` returns one result object; its own
87
+ # is_error flag says whether `result` holds an error or an answer.
88
+ if record.get("is_error") is not True:
89
+ return []
90
+ texts.append(record.get("result"))
91
+ texts.append(record.get("subtype"))
92
+ error = record.get("error")
93
+ if isinstance(error, dict):
94
+ texts.append(error.get("message"))
95
+ return [text for text in texts if isinstance(text, str)]
96
+
97
+
98
+ def _mentions_exhaustion(texts):
99
+ return any(
100
+ marker in text.lower()
101
+ for text in texts
102
+ for marker in _RATE_LIMIT_MARKERS
103
+ )
104
+
105
+
106
+ def _structured_records(provider, text):
107
+ """The structured payloads a provider wrote, newest last.
108
+
109
+ Codex emits one JSON object per line; claude emits a single object. Lines
110
+ that do not decode are skipped rather than pattern-matched as raw text:
111
+ unparsed output is not a signal, it is an absence of one.
112
+ """
113
+ records = []
114
+ if not text:
115
+ return records
116
+ if provider == PROVIDER_CODEX:
117
+ for line in text.splitlines():
118
+ line = line.strip()
119
+ if not line.startswith("{"):
120
+ continue
121
+ try:
122
+ records.append(json.loads(line))
123
+ except ValueError:
124
+ continue
125
+ return records
126
+ if provider == PROVIDER_CLAUDE:
127
+ stripped = text.strip()
128
+ start = stripped.find("{")
129
+ if start >= 0:
130
+ try:
131
+ records.append(json.loads(stripped[start:]))
132
+ except ValueError:
133
+ return records
134
+ return records
135
+
136
+
137
+ def looks_rate_limited(provider, run_info):
138
+ """Whether the provider's structured output says the account is exhausted.
139
+
140
+ This is the weaker of the two signals and is never acted on alone; see
141
+ `should_fail_over`.
142
+ """
143
+ if provider not in (PROVIDER_CODEX, PROVIDER_CLAUDE):
144
+ return False
145
+ if run_info.get("timed_out"):
146
+ # A timeout is cdx's own deadline, not the provider's verdict.
147
+ return False
148
+ if run_info.get("returncode") == 0:
149
+ return False
150
+ for record in _structured_records(provider, _read_tail(run_info.get("stdout_path"))):
151
+ if _mentions_exhaustion(_error_texts(provider, record)):
152
+ return True
153
+ return False
154
+
155
+
156
+ def status_confirms_exhaustion(row):
157
+ """Whether a freshly refreshed status row agrees the account is spent.
158
+
159
+ An unreadable or empty row is not a confirmation. Treating "no status" as
160
+ exhaustion would make every probe failure look like a rate limit.
161
+ """
162
+ if not row:
163
+ return False
164
+ if row.get("blocking"):
165
+ return True
166
+ for key in ("remaining_5h_pct", "remaining_week_pct"):
167
+ value = row.get(key)
168
+ if isinstance(value, (int, float)) and value <= 0:
169
+ return True
170
+ return False
171
+
172
+
173
+ def provider_reported_failure(provider, run_info):
174
+ """A failure the provider itself emitted, whatever it says.
175
+
176
+ Deliberately wording-free. A timeout is cdx's own deadline, and a zero exit
177
+ is not a failure; everything else that produced a provider error record
178
+ counts.
179
+ """
180
+ if provider not in (PROVIDER_CODEX, PROVIDER_CLAUDE):
181
+ return False
182
+ if run_info.get("timed_out") or run_info.get("returncode") == 0:
183
+ return False
184
+ for record in _structured_records(provider, _read_tail(run_info.get("stdout_path"))):
185
+ if _error_texts(provider, record):
186
+ return True
187
+ return False
188
+
189
+
190
+ def failover_reason(provider, run_info, status_row):
191
+ """Why this run should move, or None to stay put.
192
+
193
+ Two independent routes, because relying on either alone has been shown to
194
+ fail:
195
+
196
+ `provider_reported_exhaustion` - the provider said so in words cdx
197
+ recognises. Precise, but the wording is not one thing: an enterprise
198
+ workspace out of credits, a personal plan hitting its 5-hour window, and
199
+ Claude's own phrasing are all different sentences, and only the first has
200
+ ever been observed. Matching prose alone would silently miss the rest.
201
+
202
+ `account_status_exhausted` - the account's own rate-limit status says it has
203
+ nothing left, and the run failed. This is the authoritative route: it comes
204
+ from the provider's rate-limit API rather than from English, so it holds for
205
+ plans and providers whose wording nobody has seen. An account at zero cannot
206
+ serve the task no matter which sentence it used to say so.
207
+
208
+ Status confirmation is required either way, so a healthy account is never
209
+ abandoned on the strength of a matched phrase.
210
+ """
211
+ if not status_confirms_exhaustion(status_row):
212
+ return None
213
+ if looks_rate_limited(provider, run_info):
214
+ return "provider_reported_exhaustion"
215
+ if provider_reported_failure(provider, run_info):
216
+ return "account_status_exhausted"
217
+ return None
218
+
219
+
220
+ def should_fail_over(provider, run_info, status_row):
221
+ return failover_reason(provider, run_info, status_row) is not None