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.
- package/README.md +214 -19
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/changelogs/CHANGELOGS_0_15_0.md +139 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +324 -39
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +190 -2
- package/src/cli_render.py +17 -0
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +849 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +713 -0
- package/src/config.py +70 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +142 -15
- package/src/run_command.py +112 -4
- package/src/run_failover.py +221 -0
- package/src/run_registry.py +127 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +698 -1426
- package/src/session_status.py +550 -0
- package/src/status_source.py +45 -0
- package/src/status_view.py +15 -34
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
|
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
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
package/src/run_command.py
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import sys
|
|
2
3
|
|
|
3
|
-
from .
|
|
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
|
-
|
|
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
|