cdx-manager 0.12.4 → 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.
@@ -12,7 +12,14 @@ from datetime import datetime, timezone
12
12
 
13
13
  from .claude_usage import _clean_oauth_token, _decode_jwt_claims
14
14
  from .codex_usage import codex_auth_lock
15
- from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
15
+ from .config import (
16
+ PROVIDER_ANTIGRAVITY,
17
+ PROVIDER_CLAUDE,
18
+ PROVIDER_CODEX,
19
+ PROVIDER_OLLAMA,
20
+ REASONING_EFFORT_VALUES,
21
+ split_extra_args,
22
+ )
16
23
  from .errors import CdxError
17
24
 
18
25
  LOG_ROTATE_BYTES = 10 * 1024 * 1024 # 10 MB
@@ -21,7 +28,6 @@ CODEX_INTERACTIVE_AUTH_LOCK_TIMEOUT_SECONDS = 10
21
28
  AUTH_PROBE_AUTHENTICATED = "authenticated"
22
29
  AUTH_PROBE_LOGGED_OUT = "logged_out"
23
30
  AUTH_PROBE_DEGRADED = "degraded"
24
- REASONING_EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"}
25
31
  RTK_PROMPT = (
26
32
  "When running noisy shell commands, prefer RTK wrappers (`rtk <command>`) if `rtk` is available. "
27
33
  "Use raw commands when exact, unfiltered output is required."
@@ -47,6 +53,15 @@ LAUNCH_PERMISSION_ARGS = {
47
53
  "auto": ["-s", "workspace-write", "-a", "never"],
48
54
  "full": ["-s", "danger-full-access", "-a", "never"],
49
55
  },
56
+ PROVIDER_ANTIGRAVITY: {
57
+ "review": ["--sandbox"],
58
+ "full": ["--dangerously-skip-permissions"],
59
+ },
60
+ # ollama has no sandbox or permission concept, so it maps to nothing. The
61
+ # entry is present and deliberately empty: that is what tells the
62
+ # provider-flag health check there is nothing to verify here, as opposed to
63
+ # a provider whose mapping nobody ever declared.
64
+ PROVIDER_OLLAMA: {},
50
65
  }
51
66
  HEADLESS_CODEX_PERMISSION_ARGS = {
52
67
  "review": ["-s", "read-only"],
@@ -54,6 +69,30 @@ HEADLESS_CODEX_PERMISSION_ARGS = {
54
69
  "auto": ["-s", "workspace-write", "-c", 'approval_policy="never"'],
55
70
  "full": ["--dangerously-bypass-approvals-and-sandbox"],
56
71
  }
72
+
73
+
74
+ def headless_permission_disables_network(provider, permission):
75
+ """True when this provider/permission pair silently costs the run network access.
76
+
77
+ Codex ties network access to its sandbox: every permission except `full`
78
+ maps to `-s <sandbox>` above, and inside that sandbox a tool like `gh`
79
+ cannot resolve DNS. The run still exits zero, so without a warning the
80
+ caller only finds out by reading the transcript.
81
+
82
+ Read off the same mapping the launch spec uses rather than a parallel list
83
+ of permissions, so adding a permission cannot leave this behind.
84
+
85
+ No permission at all is the common case, not an exemption: the launch spec
86
+ simply appends no sandbox flag, and `codex exec` then applies its own
87
+ sandboxed default. Only an explicit `full` actually buys network access.
88
+ """
89
+ if provider != PROVIDER_CODEX:
90
+ return False
91
+ if not permission:
92
+ return True
93
+ return "-s" in HEADLESS_CODEX_PERMISSION_ARGS.get(permission, [])
94
+
95
+
57
96
  REDACTED_PROMPT_ARG = "[prompt redacted]"
58
97
  CLAUDE_CLI_MODEL_ALIASES = {
59
98
  "claude-sonnet": "sonnet",
@@ -316,6 +355,45 @@ def _claude_cli_model(model):
316
355
  return raw
317
356
 
318
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
+
319
397
  def _launch_config_args(session):
320
398
  launch = session.get("launch") or {}
321
399
  args = []
@@ -328,15 +406,12 @@ def _launch_config_args(session):
328
406
  if permission:
329
407
  args += LAUNCH_PERMISSION_ARGS[PROVIDER_CLAUDE].get(permission, [])
330
408
  return args
331
- if provider == PROVIDER_ANTIGRAVITY:
332
- if permission == "review":
333
- args += ["--sandbox"]
334
- if permission == "full":
335
- args += ["--dangerously-skip-permissions"]
336
- return args
337
- if provider == PROVIDER_OLLAMA:
338
- if permission == "full":
339
- args += ["--experimental-yolo"]
409
+ if provider in (PROVIDER_ANTIGRAVITY, PROVIDER_OLLAMA):
410
+ # Both read their mapping from the shared table rather than inline
411
+ # branches, so the provider-flag health check can verify what they emit
412
+ # instead of a hand-copied restatement of it.
413
+ if permission:
414
+ args += LAUNCH_PERMISSION_ARGS[provider].get(permission, [])
340
415
  return args
341
416
  if power:
342
417
  args += ["-c", f'model_reasoning_effort="{power}"']
@@ -447,9 +522,13 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
447
522
  if session["provider"] == PROVIDER_CLAUDE:
448
523
  launch = session.get("launch") or {}
449
524
  args = ["--name", session["name"]]
525
+ conversation_id = _conversation_id(session)
526
+ if conversation_id:
527
+ args += ["--session-id", conversation_id]
450
528
  if launch.get("model"):
451
529
  args += ["--model", _claude_cli_model(launch["model"])]
452
530
  args += _launch_config_args(session)
531
+ args += _extra_args(session)
453
532
  if initial_prompt:
454
533
  args.append(initial_prompt)
455
534
  auth_home = _get_auth_home(session)
@@ -520,20 +599,48 @@ def _redacted_resume_command_preview(session, cwd=None):
520
599
  def get_resume_capability(session, cwd=None):
521
600
  provider = session.get("provider")
522
601
  cwd = cwd or os.getcwd()
602
+ conversation = session.get("conversation") or {}
603
+ conversation_id = _conversation_id(session)
523
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
+ }
524
615
  return {
525
616
  "resumable": True,
526
617
  "provider": provider,
527
618
  "strategy": "provider_last",
528
- "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",
529
624
  "command_preview": ["codex", "resume", "--last", "--cd", cwd],
530
625
  }
531
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
+ }
532
637
  return {
533
638
  "resumable": True,
534
639
  "provider": provider,
535
640
  "strategy": "provider_continue",
536
- "reason": "supported",
641
+ "identity": None,
642
+ "provenance": None,
643
+ "reason": "no_recorded_conversation",
537
644
  "command_preview": ["claude", "--continue"],
538
645
  }
539
646
  return {
@@ -558,10 +665,16 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
558
665
 
559
666
  if session["provider"] == PROVIDER_CLAUDE:
560
667
  launch = session.get("launch") or {}
561
- 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"]]
562
674
  if launch.get("model"):
563
675
  args += ["--model", _claude_cli_model(launch["model"])]
564
676
  args += _launch_config_args(session)
677
+ args += _extra_args(session)
565
678
  if resume_prompt:
566
679
  args.append(resume_prompt)
567
680
  auth_home = _get_auth_home(session)
@@ -580,10 +693,13 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
580
693
  }, capture_transcript=capture_transcript, env=env)
581
694
 
582
695
  launch = session.get("launch") or {}
583
- 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]
584
699
  if launch.get("model"):
585
700
  args += ["--model", launch["model"]]
586
701
  args += _launch_config_args(session)
702
+ args += _extra_args(session)
587
703
  if resume_prompt:
588
704
  args.append(resume_prompt)
589
705
  return _wrap_launch_with_transcript(session, {
@@ -617,9 +733,20 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
617
733
 
618
734
  if session["provider"] == PROVIDER_CLAUDE:
619
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]
620
739
  if model:
621
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"])]
622
748
  args += _launch_config_args(session)
749
+ args += _extra_args(session)
623
750
  if initial_prompt:
624
751
  args.append(initial_prompt)
625
752
  auth_home = _get_auth_home(session)
@@ -1,12 +1,27 @@
1
1
  import os
2
+ import sys
2
3
 
3
- from .errors import CdxError
4
+ from .cli_args import ARG_CODE_INVALID_VALUE
5
+ from .errors import CdxArgumentError, CdxError
6
+ from .provider_runtime import headless_permission_disables_network
4
7
  from .run_usage import empty_usage, extract_run_usage
5
8
 
9
+ PROMPT_STDIN = "-"
6
10
 
7
- def read_run_prompt(parsed):
11
+
12
+ def read_run_prompt(parsed, stdin=None):
8
13
  if parsed.get("prompt") is not None:
9
14
  return parsed["prompt"]
15
+ if parsed.get("prompt_file") == PROMPT_STDIN:
16
+ stream = stdin if stdin is not None else sys.stdin
17
+ if stream is None or getattr(stream, "isatty", lambda: False)():
18
+ raise CdxArgumentError(
19
+ "cdx run: --prompt-file - reads the prompt from standard input, but standard "
20
+ "input is a terminal. Pipe the prompt in, or pass a file path.",
21
+ code=ARG_CODE_INVALID_VALUE,
22
+ arguments=["--prompt-file"],
23
+ )
24
+ return stream.read()
10
25
  try:
11
26
  with open(parsed["prompt_file"], encoding="utf-8") as handle:
12
27
  return handle.read()
@@ -15,6 +30,9 @@ def read_run_prompt(parsed):
15
30
 
16
31
 
17
32
  def run_cdx_error_code(error):
33
+ code = getattr(error, "code", None)
34
+ if code:
35
+ return code
18
36
  message = str(error)
19
37
  if message.startswith("Usage:"):
20
38
  return "invalid_request"
@@ -73,8 +91,96 @@ def _provider_failure_message(error, run_info, error_code):
73
91
  return f"{base}: {detail}" if detail else base
74
92
 
75
93
 
94
+ def run_error_arguments(error):
95
+ """The argument names a `CdxArgumentError` blames, as data.
96
+
97
+ Always a list so the error object keeps one shape: an empty list means the
98
+ failure is not about specific arguments, never that the caller should go
99
+ parse the message to find out.
100
+ """
101
+ return list(getattr(error, "arguments", ()) or [])
102
+
103
+
104
+ def run_error_allowed_values(error):
105
+ allowed = getattr(error, "allowed", None)
106
+ return list(allowed) if allowed else None
107
+
108
+
109
+ def run_effective_permission(parsed, session):
110
+ launch = (session.get("launch") or {}) if session else {}
111
+ return parsed.get("permission") or launch.get("permission")
112
+
113
+
114
+ def run_warnings(parsed, session, selection_row=None):
115
+ """Known degradations that a zero exit code would otherwise hide.
116
+
117
+ Emitted on success as much as on failure: the whole point is that the run
118
+ looks fine and quietly did less than it was asked to.
119
+ """
120
+ warnings = []
121
+ if selection_row is not None and selection_row.get("available_pct") is None:
122
+ # Auto-selection ranked this session without ever having seen its
123
+ # status. That is not "low availability" — it is no information, and on
124
+ # a freshly imported or long-idle set of sessions it is the normal case.
125
+ warnings.append({
126
+ "code": "session_selected_without_status",
127
+ "message": (
128
+ f"Session {selection_row.get('session_name')!r} was auto-selected without any "
129
+ "recorded availability; the choice was made on missing status data. "
130
+ "Pass --refresh to fetch status before selecting."
131
+ ),
132
+ "session": selection_row.get("session_name"),
133
+ })
134
+ provider = session.get("provider") if session else parsed.get("provider")
135
+ permission = run_effective_permission(parsed, session)
136
+ if headless_permission_disables_network(provider, permission):
137
+ described = repr(permission) if permission else "its default (none set)"
138
+ warnings.append({
139
+ "code": "network_disabled_by_permission",
140
+ "message": (
141
+ f"{provider} runs at permission {described} inside a sandbox with no network "
142
+ "access, so tools needing the network (DNS, HTTPS) fail inside this run even "
143
+ "though it exits successfully. Use --permission full if the run needs network."
144
+ ),
145
+ "provider": provider,
146
+ "permission": permission,
147
+ })
148
+ return warnings
149
+
150
+
151
+ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
152
+ pid=None, launch_log_path=None, selection_row=None):
153
+ """Payload for a detached launch: identity and artifact paths, no outcome.
154
+
155
+ Deliberately shaped like `run_result_payload` minus the fields only a
156
+ finished run can fill (`exit_code`, `duration_seconds`, `usage`), so a
157
+ caller reads `run_id` from the same place either way.
158
+ """
159
+ return {
160
+ "schema_version": api_schema_version,
161
+ "ok": True,
162
+ "action": "run",
163
+ "launcher": "cdx",
164
+ "detached": True,
165
+ "session": session.get("name") if session else None,
166
+ "provider": session.get("provider") if session else parsed.get("provider"),
167
+ "model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
168
+ "reasoning_effort": run_payload_reasoning_effort(parsed, session),
169
+ "power": parsed.get("power") or ((session.get("launch") or {}).get("power") if session else None),
170
+ "cwd": os.path.abspath(cwd or parsed.get("cwd") or os.getcwd()),
171
+ "run_id": artifacts.get("run_id"),
172
+ "pid": pid,
173
+ "transcript_path": artifacts.get("transcript_path"),
174
+ "stdout_path": artifacts.get("stdout_path"),
175
+ "stderr_path": artifacts.get("stderr_path"),
176
+ "launch_log_path": launch_log_path,
177
+ "warnings": run_warnings(parsed, session, selection_row),
178
+ "error": None,
179
+ }
180
+
181
+
76
182
  def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
77
- error=None, error_source=None, error_code=None):
183
+ error=None, error_source=None, error_code=None, selection_row=None):
78
184
  run_info = run_info or {}
79
185
  usage = run_info.get("usage") if isinstance(run_info.get("usage"), dict) else (
80
186
  extract_run_usage(session.get("provider"), run_info.get("stdout_path"))
@@ -104,11 +210,13 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
104
210
  "stdout_path": run_info.get("stdout_path"),
105
211
  "stderr_path": run_info.get("stderr_path"),
106
212
  "usage": usage,
107
- "warnings": [],
213
+ "warnings": run_warnings(parsed, session, selection_row),
108
214
  "error": None if ok else {
109
215
  "source": error_source or "cdx",
110
216
  "code": error_code or "cdx_error",
111
217
  "message": message,
218
+ "arguments": run_error_arguments(error),
219
+ "allowed_values": run_error_allowed_values(error),
112
220
  "provider_code": run_info.get("returncode") if error_source == "provider" else None,
113
221
  },
114
222
  }
@@ -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