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
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""Run domain commands: run, runs, run-status, run-report, run-tail, select, schema.
|
|
2
|
+
|
|
3
|
+
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from ..cli_args import (
|
|
11
|
+
RUN_REPORT_USAGE,
|
|
12
|
+
RUN_STATUS_USAGE,
|
|
13
|
+
RUNS_USAGE,
|
|
14
|
+
_parse_run_args,
|
|
15
|
+
_parse_run_id_json_args,
|
|
16
|
+
_parse_run_tail_args,
|
|
17
|
+
_parse_runs_args,
|
|
18
|
+
_parse_schema_args,
|
|
19
|
+
_parse_select_args,
|
|
20
|
+
cdx_schema,
|
|
21
|
+
)
|
|
22
|
+
from ..cli_helpers import API_SCHEMA_VERSION, _json_failure, _json_success, _write_json
|
|
23
|
+
from ..errors import CdxError
|
|
24
|
+
from ..provider_background import (
|
|
25
|
+
BACKGROUND_PATH_CDX,
|
|
26
|
+
BACKGROUND_PATH_PROVIDER,
|
|
27
|
+
agent_terminal_status,
|
|
28
|
+
find_agent,
|
|
29
|
+
find_session_transcript,
|
|
30
|
+
list_background_agents,
|
|
31
|
+
parse_backgrounded_id,
|
|
32
|
+
read_transcript_outcome,
|
|
33
|
+
stop_background_agent,
|
|
34
|
+
supports_native_background,
|
|
35
|
+
turn_is_complete,
|
|
36
|
+
)
|
|
37
|
+
from ..provider_runtime import (
|
|
38
|
+
_build_launch_spec,
|
|
39
|
+
_ensure_session_authentication,
|
|
40
|
+
_headless_artifact_paths,
|
|
41
|
+
_run_headless_provider_command,
|
|
42
|
+
)
|
|
43
|
+
from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
|
|
44
|
+
from ..run_failover import MAX_FAILOVER_TRANSITIONS, failover_reason
|
|
45
|
+
from ..run_registry import RunRegistry, build_code_review_report
|
|
46
|
+
from ..run_usage import extract_run_usage
|
|
47
|
+
from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
|
|
48
|
+
from ..status_view import _now_timestamp, _priority_reset_timestamp
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def handle_select(rest, ctx):
|
|
52
|
+
parsed = _parse_select_args(rest)
|
|
53
|
+
selected = _select_headless_session(
|
|
54
|
+
ctx,
|
|
55
|
+
parsed["provider"],
|
|
56
|
+
min_reasoning_effort=parsed["min_reasoning_effort"],
|
|
57
|
+
require_ready=parsed["require_ready"],
|
|
58
|
+
force_refresh=parsed["refresh"],
|
|
59
|
+
)
|
|
60
|
+
# Derived from the ranking rather than written by hand: the previous
|
|
61
|
+
# literal omitted the reasoning-effort tie-break and described a candidate
|
|
62
|
+
# filter as a sort stage, and nothing failed when the sort key moved on
|
|
63
|
+
# without it.
|
|
64
|
+
policy = selection_policy()
|
|
65
|
+
if not selected:
|
|
66
|
+
message = "No suitable session found."
|
|
67
|
+
_write_json(ctx, _json_failure(
|
|
68
|
+
"select",
|
|
69
|
+
"no_suitable_session",
|
|
70
|
+
message,
|
|
71
|
+
provider=parsed["provider"],
|
|
72
|
+
min_reasoning_effort=parsed["min_reasoning_effort"],
|
|
73
|
+
require_ready=parsed["require_ready"],
|
|
74
|
+
selection_policy=policy,
|
|
75
|
+
))
|
|
76
|
+
return 1
|
|
77
|
+
session = selected["session"]
|
|
78
|
+
row = selected.get("row") or {}
|
|
79
|
+
_write_json(ctx, _json_success(
|
|
80
|
+
"select",
|
|
81
|
+
f"Selected session {session['name']}",
|
|
82
|
+
session=session["name"],
|
|
83
|
+
provider=session["provider"],
|
|
84
|
+
reason=_selection_reason(selected.get("decision")),
|
|
85
|
+
deciding_factor=selected.get("decision"),
|
|
86
|
+
selection_policy=policy,
|
|
87
|
+
min_reasoning_effort=parsed["min_reasoning_effort"],
|
|
88
|
+
reasoning_effort=row.get("reasoning_effort"),
|
|
89
|
+
priority=row.get("priority"),
|
|
90
|
+
available_pct=row.get("available_pct"),
|
|
91
|
+
auth_status=row.get("auth_status"),
|
|
92
|
+
))
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
def _selection_reason(decision):
|
|
96
|
+
"""Why this session won, for this call.
|
|
97
|
+
|
|
98
|
+
Names the factor that actually separated it from the runner-up instead of
|
|
99
|
+
the factor that usually does: the old fixed sentence claimed availability
|
|
100
|
+
decided even when priority or the alphabetical fallback did.
|
|
101
|
+
"""
|
|
102
|
+
if decision is None:
|
|
103
|
+
return "only suitable session"
|
|
104
|
+
return f"decided on {decision}: {FACTOR_DESCRIPTIONS[decision]}"
|
|
105
|
+
|
|
106
|
+
def _run_registry(ctx):
|
|
107
|
+
return RunRegistry(ctx["service"]["base_dir"])
|
|
108
|
+
|
|
109
|
+
DETACHED_RUN_ID_ENV = "CDX_RUN_ID"
|
|
110
|
+
|
|
111
|
+
RUN_TAIL_READ_BYTES = 1 << 20
|
|
112
|
+
|
|
113
|
+
def _tail_file_lines(path, lines):
|
|
114
|
+
"""Last `lines` lines of a run's output, safe to call while it is still being written.
|
|
115
|
+
|
|
116
|
+
Reads a bounded tail of the file in binary and decodes with replacement, so
|
|
117
|
+
provider output containing undecodable bytes returns those lines instead of
|
|
118
|
+
raising. A final line that is still mid-write is returned as-is: the caller
|
|
119
|
+
asked what the run is doing right now, not for a complete record.
|
|
120
|
+
"""
|
|
121
|
+
with open(path, "rb") as handle:
|
|
122
|
+
handle.seek(0, os.SEEK_END)
|
|
123
|
+
size = handle.tell()
|
|
124
|
+
truncated = size > RUN_TAIL_READ_BYTES
|
|
125
|
+
handle.seek(max(0, size - RUN_TAIL_READ_BYTES))
|
|
126
|
+
text = handle.read().decode("utf-8", errors="replace")
|
|
127
|
+
found = text.splitlines()
|
|
128
|
+
if truncated and found:
|
|
129
|
+
# The window opened mid-line, so the first entry is a fragment. Codex
|
|
130
|
+
# emits very long single-line JSON events, so this is reachable, and
|
|
131
|
+
# handing a caller half a line as if it were whole is worse than one
|
|
132
|
+
# line short.
|
|
133
|
+
found = found[1:]
|
|
134
|
+
return found[-lines:]
|
|
135
|
+
|
|
136
|
+
def _cdx_self_command():
|
|
137
|
+
"""argv prefix that re-runs this same cdx as a child process.
|
|
138
|
+
|
|
139
|
+
`--detach` re-invokes cdx rather than forking: the child then walks the
|
|
140
|
+
ordinary blocking run path, so registry bookkeeping, usage extraction,
|
|
141
|
+
history, and the code-review report all happen exactly once, in code that
|
|
142
|
+
is already tested, without a second implementation that only detached runs
|
|
143
|
+
exercise. `os.fork()` would avoid the re-exec but is not available on
|
|
144
|
+
Windows, which this CLI supports.
|
|
145
|
+
|
|
146
|
+
Always goes through `sys.executable -m`, never `sys.argv[0]`: the console
|
|
147
|
+
script has no reliable executable bit on Windows (`os.access(X_OK)` is true
|
|
148
|
+
for any existing file there) and a shebang script handed to CreateProcess
|
|
149
|
+
does not start at all.
|
|
150
|
+
"""
|
|
151
|
+
package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
152
|
+
return [sys.executable, "-m", f"{__package__}.cli"], package_root
|
|
153
|
+
|
|
154
|
+
def _detached_spawn_options():
|
|
155
|
+
"""Platform flags that keep the child alive after this process exits.
|
|
156
|
+
|
|
157
|
+
`start_new_session` is silently ignored on Windows, where a child stays
|
|
158
|
+
attached to the console and dies with the launcher unless it is explicitly
|
|
159
|
+
detached and given its own process group.
|
|
160
|
+
"""
|
|
161
|
+
if sys.platform == "win32":
|
|
162
|
+
detached_process = getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
|
163
|
+
new_group = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
|
164
|
+
return {"creationflags": detached_process | new_group}
|
|
165
|
+
return {"start_new_session": True}
|
|
166
|
+
|
|
167
|
+
def _detached_child_argv(parsed, prompt_path, session_name, cwd):
|
|
168
|
+
"""Child argv for a detached run.
|
|
169
|
+
|
|
170
|
+
Always names the session the parent already resolved, never `--provider`:
|
|
171
|
+
the parent may have auto-selected an account, and letting the child select
|
|
172
|
+
again could land it on a different session than the one the launch payload
|
|
173
|
+
just reported to the caller.
|
|
174
|
+
"""
|
|
175
|
+
# The resolved absolute cwd, not the raw user string: the child is started
|
|
176
|
+
# from the package root, so a relative "--cwd ." would resolve there.
|
|
177
|
+
argv = ["run", session_name, "--cwd", cwd, "--prompt-file", prompt_path]
|
|
178
|
+
for flag, key in (
|
|
179
|
+
("--model", "model"),
|
|
180
|
+
("--kind", "kind"),
|
|
181
|
+
("--permission", "permission"),
|
|
182
|
+
):
|
|
183
|
+
value = parsed.get(key)
|
|
184
|
+
if value:
|
|
185
|
+
argv += [flag, str(value)]
|
|
186
|
+
# reasoning_effort and power are normalized to the same value upstream;
|
|
187
|
+
# passing one back is enough and avoids re-tripping the must-match check.
|
|
188
|
+
if parsed.get("reasoning_effort"):
|
|
189
|
+
argv += ["--reasoning-effort", str(parsed["reasoning_effort"])]
|
|
190
|
+
if parsed.get("timeout_seconds"):
|
|
191
|
+
argv += ["--timeout-seconds", str(parsed["timeout_seconds"])]
|
|
192
|
+
argv.append("--json")
|
|
193
|
+
return argv
|
|
194
|
+
|
|
195
|
+
DETACHED_PROMPT_SUFFIX = ".prompt.txt"
|
|
196
|
+
|
|
197
|
+
def _consume_detached_prompt_file(parsed, detached_run_id):
|
|
198
|
+
"""Delete the prompt file a detached launch staged for this child.
|
|
199
|
+
|
|
200
|
+
The prompt is arbitrary, often sensitive text, and the rest of the codebase
|
|
201
|
+
keeps it out of anything persisted (`REDACTED_PROMPT_ARG`, `sensitive_args`).
|
|
202
|
+
Leaving the staged copy behind would put a permanent cleartext prompt in the
|
|
203
|
+
session's log directory for every detached run. Only the child that was
|
|
204
|
+
handed a run id removes it, and only a file this code wrote.
|
|
205
|
+
"""
|
|
206
|
+
prompt_file = parsed.get("prompt_file")
|
|
207
|
+
if not detached_run_id or not prompt_file or not prompt_file.endswith(DETACHED_PROMPT_SUFFIX):
|
|
208
|
+
return
|
|
209
|
+
try:
|
|
210
|
+
os.remove(prompt_file)
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
def _spawn_detached_run(parsed, prompt, artifacts, ctx, session_name, cwd):
|
|
215
|
+
"""Stage the prompt, then launch the child detached and return immediately.
|
|
216
|
+
|
|
217
|
+
The prompt is written beside the run's other artifacts rather than to a
|
|
218
|
+
temporary file so that nothing has to survive this process in order to
|
|
219
|
+
clean it up, and so the child cannot lose its input if the launcher exits
|
|
220
|
+
first.
|
|
221
|
+
"""
|
|
222
|
+
# transcript_path is "<prefix>.log"; the other artifacts share that prefix.
|
|
223
|
+
prefix = os.path.splitext(artifacts["transcript_path"])[0]
|
|
224
|
+
prompt_path = f"{prefix}{DETACHED_PROMPT_SUFFIX}"
|
|
225
|
+
launch_log_path = f"{prefix}.launch.log"
|
|
226
|
+
with open(prompt_path, "w", encoding="utf-8") as handle:
|
|
227
|
+
handle.write(prompt)
|
|
228
|
+
|
|
229
|
+
command, package_root = _cdx_self_command()
|
|
230
|
+
env = {**os.environ, DETACHED_RUN_ID_ENV: artifacts["run_id"]}
|
|
231
|
+
if package_root:
|
|
232
|
+
existing = env.get("PYTHONPATH")
|
|
233
|
+
env["PYTHONPATH"] = f"{package_root}{os.pathsep}{existing}" if existing else package_root
|
|
234
|
+
|
|
235
|
+
spawn = ctx.get("spawn_detached") or subprocess.Popen
|
|
236
|
+
try:
|
|
237
|
+
with open(launch_log_path, "w", encoding="utf-8", errors="replace") as log_file:
|
|
238
|
+
child = spawn(
|
|
239
|
+
command + _detached_child_argv(parsed, prompt_path, session_name, cwd),
|
|
240
|
+
stdout=log_file,
|
|
241
|
+
stderr=subprocess.STDOUT,
|
|
242
|
+
stdin=subprocess.DEVNULL,
|
|
243
|
+
# Detach so the run outlives the launcher, including when cdx
|
|
244
|
+
# was invoked over a non-interactive SSH command that closes as
|
|
245
|
+
# soon as it returns.
|
|
246
|
+
cwd=package_root or None,
|
|
247
|
+
env=env,
|
|
248
|
+
**_detached_spawn_options(),
|
|
249
|
+
)
|
|
250
|
+
except OSError as error:
|
|
251
|
+
# Surface as a CdxError so handle_run's own handler marks the already
|
|
252
|
+
# registered run failed, instead of letting a raw OSError escape as an
|
|
253
|
+
# unhandled traceback on a --json call.
|
|
254
|
+
raise CdxError(f"Failed to start detached cdx run: {error}", 126) from error
|
|
255
|
+
return {
|
|
256
|
+
"pid": getattr(child, "pid", None),
|
|
257
|
+
"launch_log_path": launch_log_path,
|
|
258
|
+
"background_path": BACKGROUND_PATH_CDX,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _spawn_provider_background_run(run_session, prompt, artifacts, ctx, cwd):
|
|
263
|
+
"""Start the run as the provider's own background agent.
|
|
264
|
+
|
|
265
|
+
Returns the same shape as the in-house launcher so the caller does not
|
|
266
|
+
branch on which path ran, plus the provider handle needed to observe it
|
|
267
|
+
later. Returns None when the provider declines to start one, which sends
|
|
268
|
+
the caller back to the in-house path rather than failing the run.
|
|
269
|
+
"""
|
|
270
|
+
# Derived from the interactive spec, not the headless one. `--bg` starts a
|
|
271
|
+
# session; it does not pass `--print`, and the headless spec is built around
|
|
272
|
+
# flags the provider accepts only alongside `--print` - `--output-format`,
|
|
273
|
+
# `--max-budget-usd`, `--fallback-model`. Reusing it and stripping `--print`
|
|
274
|
+
# would hand the CLI print-only flags without `--print` and have the launch
|
|
275
|
+
# rejected.
|
|
276
|
+
spec = _build_launch_spec(
|
|
277
|
+
run_session, cwd=cwd, env_override=ctx.get("env"),
|
|
278
|
+
initial_prompt=prompt, capture_transcript=False,
|
|
279
|
+
)
|
|
280
|
+
argv = [spec["command"], "--bg"] + list(spec["args"])
|
|
281
|
+
env = (spec.get("options") or {}).get("env")
|
|
282
|
+
spawn = ctx.get("spawn_detached") or subprocess.Popen
|
|
283
|
+
prefix = os.path.splitext(artifacts["transcript_path"])[0]
|
|
284
|
+
launch_log_path = f"{prefix}.launch.log"
|
|
285
|
+
try:
|
|
286
|
+
with open(launch_log_path, "w", encoding="utf-8", errors="replace") as log_file:
|
|
287
|
+
child = spawn(
|
|
288
|
+
argv,
|
|
289
|
+
stdout=log_file,
|
|
290
|
+
stderr=subprocess.STDOUT,
|
|
291
|
+
stdin=subprocess.DEVNULL,
|
|
292
|
+
cwd=cwd,
|
|
293
|
+
env=env,
|
|
294
|
+
**_detached_spawn_options(),
|
|
295
|
+
)
|
|
296
|
+
# --bg returns immediately, so this waits for the launcher itself, not
|
|
297
|
+
# for the task: the id line is printed before it exits.
|
|
298
|
+
if hasattr(child, "wait"):
|
|
299
|
+
child.wait()
|
|
300
|
+
except OSError:
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
with open(launch_log_path, encoding="utf-8", errors="replace") as handle:
|
|
304
|
+
identifier = parse_backgrounded_id(handle.read())
|
|
305
|
+
if not identifier:
|
|
306
|
+
# The agent may nonetheless be running. Falling back to the in-house
|
|
307
|
+
# launcher here would run the same task twice, once untracked, on the
|
|
308
|
+
# user's account - so this fails loudly instead.
|
|
309
|
+
raise CdxError(
|
|
310
|
+
"Started a provider background agent but it did not report an id; "
|
|
311
|
+
"refusing to launch a second run for the same task.",
|
|
312
|
+
126,
|
|
313
|
+
)
|
|
314
|
+
agent = find_agent(
|
|
315
|
+
list_background_agents(env=env, cwd=cwd, spawn_sync=ctx.get("spawn_text")),
|
|
316
|
+
identifier,
|
|
317
|
+
) or {}
|
|
318
|
+
return {
|
|
319
|
+
"pid": agent.get("pid"),
|
|
320
|
+
"launch_log_path": launch_log_path,
|
|
321
|
+
"background_path": BACKGROUND_PATH_PROVIDER,
|
|
322
|
+
"provider_session_id": agent.get("sessionId") or identifier,
|
|
323
|
+
"provider_agent_id": identifier,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
def handle_schema(rest, ctx):
|
|
327
|
+
_parse_schema_args(rest)
|
|
328
|
+
_write_json(ctx, _json_success("schema", "Schema loaded.", **cdx_schema()))
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
def _reconcile_background_runs(ctx):
|
|
332
|
+
"""Close out delegated runs the provider has finished.
|
|
333
|
+
|
|
334
|
+
A delegated run has no cdx child to report its own completion, so the
|
|
335
|
+
registry would leave it `running` forever. This asks the provider once, on
|
|
336
|
+
the read paths a caller actually uses, and closes what has finished -
|
|
337
|
+
including the usage and final result read back from the session transcript,
|
|
338
|
+
which is the only place a `--bg` run records them.
|
|
339
|
+
|
|
340
|
+
Never raises: a provider that is slow, missing, or unparseable must not
|
|
341
|
+
take down `cdx runs`.
|
|
342
|
+
"""
|
|
343
|
+
registry = _run_registry(ctx)
|
|
344
|
+
try:
|
|
345
|
+
pending = [run for run in registry.list(limit=50)
|
|
346
|
+
if run.get("status") == "running"
|
|
347
|
+
and run.get("background_path") == BACKGROUND_PATH_PROVIDER]
|
|
348
|
+
except Exception:
|
|
349
|
+
return
|
|
350
|
+
if not pending:
|
|
351
|
+
return
|
|
352
|
+
for run in pending:
|
|
353
|
+
session = ctx["service"]["get_session"](run.get("session") or "")
|
|
354
|
+
if not session:
|
|
355
|
+
continue
|
|
356
|
+
auth_home = session.get("authHome")
|
|
357
|
+
env = {**os.environ, "HOME": auth_home} if auth_home else None
|
|
358
|
+
try:
|
|
359
|
+
outcome = read_transcript_outcome(
|
|
360
|
+
find_session_transcript(auth_home, run.get("provider_session_id"))
|
|
361
|
+
)
|
|
362
|
+
# The transcript decides, not the agent state: a --bg session parks
|
|
363
|
+
# after answering and never reports a terminal state on its own.
|
|
364
|
+
if turn_is_complete(outcome):
|
|
365
|
+
status = "succeeded"
|
|
366
|
+
else:
|
|
367
|
+
agents = list_background_agents(env=env, cwd=run.get("cwd"), spawn_sync=ctx.get("spawn_text"))
|
|
368
|
+
status = agent_terminal_status(find_agent(agents, run.get("provider_session_id")))
|
|
369
|
+
if not status:
|
|
370
|
+
continue
|
|
371
|
+
# `claude stop` accepts the short agent id only; the session id
|
|
372
|
+
# names the transcript file and is rejected here.
|
|
373
|
+
stop_background_agent(run.get("provider_agent_id"), env=env, spawn_sync=ctx.get("spawn_text"))
|
|
374
|
+
registry.finish(
|
|
375
|
+
run["run_id"],
|
|
376
|
+
status=status,
|
|
377
|
+
final_payload={
|
|
378
|
+
"usage": outcome["usage"],
|
|
379
|
+
"result": outcome["result"],
|
|
380
|
+
"background_path": BACKGROUND_PATH_PROVIDER,
|
|
381
|
+
},
|
|
382
|
+
)
|
|
383
|
+
except Exception:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def handle_runs(rest, ctx):
|
|
388
|
+
parsed = _parse_runs_args(rest)
|
|
389
|
+
if not parsed["json"]:
|
|
390
|
+
raise CdxError(RUNS_USAGE)
|
|
391
|
+
since = parsed.get("since")
|
|
392
|
+
_reconcile_background_runs(ctx)
|
|
393
|
+
runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
|
|
394
|
+
warnings = []
|
|
395
|
+
if since is not None and parsed.get("limit_given"):
|
|
396
|
+
warnings.append({
|
|
397
|
+
"code": "limit_ignored_with_since",
|
|
398
|
+
"message": "--since returns every run completed after the cursor; --limit was ignored.",
|
|
399
|
+
})
|
|
400
|
+
_write_json(ctx, _json_success(
|
|
401
|
+
"runs",
|
|
402
|
+
"Runs loaded.",
|
|
403
|
+
warnings=warnings,
|
|
404
|
+
runs=runs,
|
|
405
|
+
since=since.isoformat() if since is not None else None,
|
|
406
|
+
))
|
|
407
|
+
return 0
|
|
408
|
+
|
|
409
|
+
def handle_run_tail(rest, ctx):
|
|
410
|
+
parsed = _parse_run_tail_args(rest)
|
|
411
|
+
run = _run_registry(ctx).get(parsed["run_id"])
|
|
412
|
+
if not run:
|
|
413
|
+
_write_json(ctx, _json_failure("run-tail", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
414
|
+
return 1
|
|
415
|
+
stdout_path = (run.get("artifacts") or {}).get("stdout_path")
|
|
416
|
+
if not stdout_path:
|
|
417
|
+
_write_json(ctx, _json_failure(
|
|
418
|
+
"run-tail",
|
|
419
|
+
"run_output_unavailable",
|
|
420
|
+
f"No stdout_path recorded for run: {parsed['run_id']}",
|
|
421
|
+
run_id=parsed["run_id"],
|
|
422
|
+
status=run.get("status"),
|
|
423
|
+
))
|
|
424
|
+
return 1
|
|
425
|
+
try:
|
|
426
|
+
lines = _tail_file_lines(stdout_path, parsed["lines"])
|
|
427
|
+
except FileNotFoundError:
|
|
428
|
+
if run.get("status") != "running":
|
|
429
|
+
_write_json(ctx, _json_failure(
|
|
430
|
+
"run-tail",
|
|
431
|
+
"run_output_unreadable",
|
|
432
|
+
f"Missing output file for a finished run: {stdout_path}",
|
|
433
|
+
run_id=parsed["run_id"],
|
|
434
|
+
stdout_path=stdout_path,
|
|
435
|
+
))
|
|
436
|
+
return 1
|
|
437
|
+
# A run registered a moment ago has not written its first byte yet.
|
|
438
|
+
# That is "no output so far", not a failure — the advertised flow is
|
|
439
|
+
# launch detached, then tail immediately, and a polling caller must not
|
|
440
|
+
# read that window as fatal.
|
|
441
|
+
lines = []
|
|
442
|
+
except OSError as error:
|
|
443
|
+
_write_json(ctx, _json_failure(
|
|
444
|
+
"run-tail",
|
|
445
|
+
"run_output_unreadable",
|
|
446
|
+
f"Failed to read {stdout_path}: {error}",
|
|
447
|
+
run_id=parsed["run_id"],
|
|
448
|
+
stdout_path=stdout_path,
|
|
449
|
+
))
|
|
450
|
+
return 1
|
|
451
|
+
_write_json(ctx, _json_success(
|
|
452
|
+
"run-tail",
|
|
453
|
+
"Run output loaded.",
|
|
454
|
+
run_id=parsed["run_id"],
|
|
455
|
+
status=run.get("status"),
|
|
456
|
+
stdout_path=stdout_path,
|
|
457
|
+
lines=lines,
|
|
458
|
+
))
|
|
459
|
+
return 0
|
|
460
|
+
|
|
461
|
+
def handle_run_status(rest, ctx):
|
|
462
|
+
parsed = _parse_run_id_json_args(rest, RUN_STATUS_USAGE)
|
|
463
|
+
_reconcile_background_runs(ctx)
|
|
464
|
+
run = _run_registry(ctx).get(parsed["run_id"])
|
|
465
|
+
if not run:
|
|
466
|
+
_write_json(ctx, _json_failure("run-status", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
467
|
+
return 1
|
|
468
|
+
_write_json(ctx, _json_success("run-status", "Run loaded.", run=run))
|
|
469
|
+
return 0
|
|
470
|
+
|
|
471
|
+
def handle_run_report(rest, ctx):
|
|
472
|
+
parsed = _parse_run_id_json_args(rest, RUN_REPORT_USAGE)
|
|
473
|
+
_reconcile_background_runs(ctx)
|
|
474
|
+
run = _run_registry(ctx).get(parsed["run_id"])
|
|
475
|
+
if not run:
|
|
476
|
+
_write_json(ctx, _json_failure("run-report", "run_not_found", f"Unknown run: {parsed['run_id']}"))
|
|
477
|
+
return 1
|
|
478
|
+
_write_json(ctx, _json_success("run-report", "Run report loaded.", report={
|
|
479
|
+
"run": run,
|
|
480
|
+
"final_payload": run.get("final_payload"),
|
|
481
|
+
"task_report": run.get("task_report"),
|
|
482
|
+
"artifacts": run.get("artifacts") or {},
|
|
483
|
+
"usage": run.get("usage"),
|
|
484
|
+
"error": run.get("error"),
|
|
485
|
+
}))
|
|
486
|
+
return 0
|
|
487
|
+
|
|
488
|
+
def handle_run(rest, ctx):
|
|
489
|
+
registry = None
|
|
490
|
+
run_id = None
|
|
491
|
+
selection_row = None
|
|
492
|
+
# pop, not get: the variable is consumed once. Left in the environment it is
|
|
493
|
+
# inherited by the provider process, and any nested `cdx run` the agent
|
|
494
|
+
# itself makes would claim this same run_id — deleting this run's registry
|
|
495
|
+
# record and truncating its stdout/stderr/transcript files.
|
|
496
|
+
detached_run_id = os.environ.pop(DETACHED_RUN_ID_ENV, None) or None
|
|
497
|
+
try:
|
|
498
|
+
parsed = _parse_run_args(rest)
|
|
499
|
+
session = None
|
|
500
|
+
if parsed["name"]:
|
|
501
|
+
session = ctx["service"]["get_session"](parsed["name"])
|
|
502
|
+
if not session:
|
|
503
|
+
raise CdxError(f"Unknown session: {parsed['name']}")
|
|
504
|
+
if session.get("enabled", True) is False:
|
|
505
|
+
raise CdxError(f"Session is disabled: {parsed['name']}")
|
|
506
|
+
else:
|
|
507
|
+
selected = _select_headless_session(
|
|
508
|
+
ctx,
|
|
509
|
+
parsed["provider"],
|
|
510
|
+
min_reasoning_effort=parsed["reasoning_effort"],
|
|
511
|
+
require_ready=True,
|
|
512
|
+
# Cached by default: refreshing would make every auto-selected
|
|
513
|
+
# run pay for a provider probe. `--refresh` is there for callers
|
|
514
|
+
# that would rather pay than choose on stale data.
|
|
515
|
+
force_refresh=parsed.get("refresh", False),
|
|
516
|
+
)
|
|
517
|
+
if not selected:
|
|
518
|
+
_write_json(ctx, _json_failure(
|
|
519
|
+
"run",
|
|
520
|
+
"no_suitable_session",
|
|
521
|
+
"No suitable session found.",
|
|
522
|
+
launcher="cdx",
|
|
523
|
+
provider=parsed["provider"],
|
|
524
|
+
))
|
|
525
|
+
return 1
|
|
526
|
+
session = selected["session"]
|
|
527
|
+
selection_row = selected.get("row") or {}
|
|
528
|
+
|
|
529
|
+
cwd = os.path.abspath(parsed["cwd"])
|
|
530
|
+
if not os.path.isdir(cwd):
|
|
531
|
+
raise CdxError(f"Invalid cwd: {parsed['cwd']}")
|
|
532
|
+
prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
|
|
533
|
+
_consume_detached_prompt_file(parsed, detached_run_id)
|
|
534
|
+
launch_updates = {}
|
|
535
|
+
if parsed.get("model"):
|
|
536
|
+
launch_updates["model"] = parsed["model"]
|
|
537
|
+
if parsed.get("permission"):
|
|
538
|
+
launch_updates["permission"] = parsed["permission"]
|
|
539
|
+
if parsed.get("reasoning_effort"):
|
|
540
|
+
launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
|
|
541
|
+
launch_updates["power"] = parsed["reasoning_effort"]
|
|
542
|
+
# Claude needs its conversation id before the spec is built; Codex
|
|
543
|
+
# records its own afterwards, from the rollout. Without this a headless
|
|
544
|
+
# Claude run left the session with no identity and `cdx resume` fell
|
|
545
|
+
# back to recency for Claude while working by id for Codex.
|
|
546
|
+
session = ctx["service"]["begin_conversation"](session["name"])
|
|
547
|
+
run_session = {
|
|
548
|
+
**session,
|
|
549
|
+
"launch": {
|
|
550
|
+
**(session.get("launch") or {}),
|
|
551
|
+
**launch_updates,
|
|
552
|
+
},
|
|
553
|
+
}
|
|
554
|
+
_ensure_session_authentication(
|
|
555
|
+
run_session,
|
|
556
|
+
ctx["service"],
|
|
557
|
+
spawn=ctx.get("spawn"),
|
|
558
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
559
|
+
env_override=ctx.get("env"),
|
|
560
|
+
stdin_is_tty=False,
|
|
561
|
+
behavior="launch",
|
|
562
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
563
|
+
trust_local_credentials=False,
|
|
564
|
+
)
|
|
565
|
+
registry = _run_registry(ctx)
|
|
566
|
+
# A detached child is handed its parent's run_id so both agree on the
|
|
567
|
+
# identity the launch payload already reported to the caller.
|
|
568
|
+
# pop, not get: the variable is consumed once. Left in the environment
|
|
569
|
+
# it is inherited by the provider process, and any nested `cdx run` the
|
|
570
|
+
# agent itself makes would claim this same run_id — deleting this run's
|
|
571
|
+
# registry record and truncating its stdout/stderr/transcript files.
|
|
572
|
+
artifacts = _headless_artifact_paths(run_session, run_id=detached_run_id)
|
|
573
|
+
run_id = artifacts["run_id"]
|
|
574
|
+
registry.start(
|
|
575
|
+
run_id,
|
|
576
|
+
kind=parsed.get("kind"),
|
|
577
|
+
session=run_session.get("name"),
|
|
578
|
+
provider=run_session.get("provider"),
|
|
579
|
+
model=parsed.get("model") or ((run_session.get("launch") or {}).get("model")),
|
|
580
|
+
cwd=cwd,
|
|
581
|
+
artifacts={
|
|
582
|
+
"transcript_path": artifacts.get("transcript_path"),
|
|
583
|
+
"stdout_path": artifacts.get("stdout_path"),
|
|
584
|
+
"stderr_path": artifacts.get("stderr_path"),
|
|
585
|
+
},
|
|
586
|
+
)
|
|
587
|
+
if parsed.get("detach"):
|
|
588
|
+
# Delegate where the installed CLI can both start a background
|
|
589
|
+
# agent and report on it; fall back to the in-house launcher
|
|
590
|
+
# otherwise, and whenever the provider declines to start one.
|
|
591
|
+
launch = None
|
|
592
|
+
if supports_native_background(
|
|
593
|
+
run_session.get("provider"),
|
|
594
|
+
env=ctx.get("env"),
|
|
595
|
+
spawn_sync=ctx.get("spawn_text"),
|
|
596
|
+
):
|
|
597
|
+
launch = _spawn_provider_background_run(run_session, prompt, artifacts, ctx, cwd)
|
|
598
|
+
if launch is None:
|
|
599
|
+
launch = _spawn_detached_run(parsed, prompt, artifacts, ctx, run_session["name"], cwd)
|
|
600
|
+
registry.record_background(
|
|
601
|
+
run_id,
|
|
602
|
+
path=launch.get("background_path"),
|
|
603
|
+
provider_session_id=launch.get("provider_session_id"),
|
|
604
|
+
provider_agent_id=launch.get("provider_agent_id"),
|
|
605
|
+
)
|
|
606
|
+
# Hand the record the child's pid: this process is about to exit,
|
|
607
|
+
# and a run pointing at a dead pid gets swept up as stale.
|
|
608
|
+
if launch.get("pid"):
|
|
609
|
+
registry.set_pid(run_id, launch["pid"])
|
|
610
|
+
_write_json(ctx, run_launch_payload(
|
|
611
|
+
API_SCHEMA_VERSION,
|
|
612
|
+
parsed,
|
|
613
|
+
run_session,
|
|
614
|
+
artifacts,
|
|
615
|
+
cwd=cwd,
|
|
616
|
+
pid=launch.get("pid"),
|
|
617
|
+
launch_log_path=launch.get("launch_log_path"),
|
|
618
|
+
selection_row=selection_row,
|
|
619
|
+
))
|
|
620
|
+
return 0
|
|
621
|
+
|
|
622
|
+
attempted = [run_session["name"]]
|
|
623
|
+
failover_error_code = None
|
|
624
|
+
while True:
|
|
625
|
+
run_info = _run_headless_provider_command(
|
|
626
|
+
run_session,
|
|
627
|
+
cwd=cwd,
|
|
628
|
+
env_override=ctx.get("env"),
|
|
629
|
+
initial_prompt=prompt,
|
|
630
|
+
timeout_seconds=parsed.get("timeout_seconds"),
|
|
631
|
+
spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
|
|
632
|
+
run_id=run_id,
|
|
633
|
+
)
|
|
634
|
+
usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
|
|
635
|
+
run_info = {**run_info, "usage": usage}
|
|
636
|
+
if run_info.get("returncode") == 0 or not parsed.get("failover"):
|
|
637
|
+
break
|
|
638
|
+
reason = failover_reason(
|
|
639
|
+
run_session.get("provider"),
|
|
640
|
+
run_info,
|
|
641
|
+
_status_row_for(ctx, run_session["name"]),
|
|
642
|
+
)
|
|
643
|
+
if not reason:
|
|
644
|
+
break
|
|
645
|
+
if len(attempted) > MAX_FAILOVER_TRANSITIONS:
|
|
646
|
+
# A systematic misclassification must not be able to walk one
|
|
647
|
+
# task through every account the user owns.
|
|
648
|
+
failover_error_code = "failover_limit_reached"
|
|
649
|
+
break
|
|
650
|
+
successor = _select_headless_session(
|
|
651
|
+
ctx,
|
|
652
|
+
parsed["provider"] or run_session.get("provider"),
|
|
653
|
+
min_reasoning_effort=parsed["reasoning_effort"],
|
|
654
|
+
require_ready=True,
|
|
655
|
+
force_refresh=False,
|
|
656
|
+
exclude=attempted,
|
|
657
|
+
)
|
|
658
|
+
if not successor:
|
|
659
|
+
failover_error_code = "failover_exhausted"
|
|
660
|
+
break
|
|
661
|
+
selection_row = successor.get("row") or {}
|
|
662
|
+
run_session = {
|
|
663
|
+
**successor["session"],
|
|
664
|
+
"launch": {**(successor["session"].get("launch") or {}), **launch_updates},
|
|
665
|
+
}
|
|
666
|
+
attempted.append(run_session["name"])
|
|
667
|
+
# A different account means different credentials; an unauthenticated
|
|
668
|
+
# successor would fail for a reason unrelated to the task.
|
|
669
|
+
_ensure_session_authentication(
|
|
670
|
+
run_session,
|
|
671
|
+
ctx["service"],
|
|
672
|
+
spawn=ctx.get("spawn"),
|
|
673
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
674
|
+
env_override=ctx.get("env"),
|
|
675
|
+
stdin_is_tty=False,
|
|
676
|
+
behavior="launch",
|
|
677
|
+
signal_emitter=ctx.get("signal_emitter"),
|
|
678
|
+
trust_local_credentials=False,
|
|
679
|
+
)
|
|
680
|
+
registry.migrate(
|
|
681
|
+
run_id,
|
|
682
|
+
session=run_session["name"],
|
|
683
|
+
provider=run_session.get("provider"),
|
|
684
|
+
reason=reason,
|
|
685
|
+
)
|
|
686
|
+
# The successor is a different account with its own auth, so the
|
|
687
|
+
# context the task built so far travels with it as the prompt.
|
|
688
|
+
prompt = _failover_prompt(prompt, attempted)
|
|
689
|
+
ok = run_info.get("returncode") == 0
|
|
690
|
+
if ok:
|
|
691
|
+
final_payload = run_result_payload(
|
|
692
|
+
API_SCHEMA_VERSION, True, parsed, run_session, run_info=run_info,
|
|
693
|
+
selection_row=selection_row,
|
|
694
|
+
)
|
|
695
|
+
task_report = build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None
|
|
696
|
+
registry.finish(
|
|
697
|
+
run_id,
|
|
698
|
+
status="succeeded",
|
|
699
|
+
final_payload=final_payload,
|
|
700
|
+
run_info=run_info,
|
|
701
|
+
task_report=task_report,
|
|
702
|
+
)
|
|
703
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
704
|
+
"status": "success",
|
|
705
|
+
"cwd": cwd,
|
|
706
|
+
"exit_code": 0,
|
|
707
|
+
**run_info,
|
|
708
|
+
})
|
|
709
|
+
_write_json(ctx, final_payload)
|
|
710
|
+
return 0
|
|
711
|
+
if failover_error_code == "failover_exhausted":
|
|
712
|
+
message = f"Every eligible session was rate limited: {', '.join(attempted)}."
|
|
713
|
+
elif failover_error_code == "failover_limit_reached":
|
|
714
|
+
message = f"Failover stopped after {MAX_FAILOVER_TRANSITIONS} transitions: {', '.join(attempted)}."
|
|
715
|
+
elif run_info.get("timed_out"):
|
|
716
|
+
message = "Provider process timed out."
|
|
717
|
+
else:
|
|
718
|
+
message = "Provider process exited with a non-zero status."
|
|
719
|
+
error = CdxError(message, run_info.get("returncode") or 1)
|
|
720
|
+
final_payload = run_result_payload(
|
|
721
|
+
API_SCHEMA_VERSION,
|
|
722
|
+
False,
|
|
723
|
+
parsed,
|
|
724
|
+
run_session,
|
|
725
|
+
run_info=run_info,
|
|
726
|
+
error=error,
|
|
727
|
+
error_source="provider",
|
|
728
|
+
# Running out of accounts is a different outcome from the task
|
|
729
|
+
# failing, and a caller has to be able to branch on which happened.
|
|
730
|
+
error_code=failover_error_code or (
|
|
731
|
+
"provider_timeout" if run_info.get("timed_out") else "provider_failed"
|
|
732
|
+
),
|
|
733
|
+
selection_row=selection_row,
|
|
734
|
+
)
|
|
735
|
+
registry.finish(
|
|
736
|
+
run_id,
|
|
737
|
+
status="timed_out" if run_info.get("timed_out") else "failed",
|
|
738
|
+
final_payload=final_payload,
|
|
739
|
+
run_info=run_info,
|
|
740
|
+
error=final_payload.get("error"),
|
|
741
|
+
task_report=build_code_review_report(run_id, final_payload) if parsed.get("kind") == "code-review" else None,
|
|
742
|
+
)
|
|
743
|
+
ctx["service"]["record_launch_history"](session["name"], {
|
|
744
|
+
"status": "failed",
|
|
745
|
+
"cwd": cwd,
|
|
746
|
+
"error": str(error),
|
|
747
|
+
"exit_code": error.exit_code,
|
|
748
|
+
**run_info,
|
|
749
|
+
})
|
|
750
|
+
_write_json(ctx, final_payload)
|
|
751
|
+
return error.exit_code or 1
|
|
752
|
+
except CdxError as error:
|
|
753
|
+
run_info = getattr(error, "run_info", None)
|
|
754
|
+
final_payload = run_result_payload(
|
|
755
|
+
API_SCHEMA_VERSION,
|
|
756
|
+
False,
|
|
757
|
+
locals().get("parsed", {}) or {},
|
|
758
|
+
locals().get("session"),
|
|
759
|
+
run_info=run_info,
|
|
760
|
+
error=error,
|
|
761
|
+
error_source="cdx",
|
|
762
|
+
error_code=run_cdx_error_code(error),
|
|
763
|
+
selection_row=selection_row,
|
|
764
|
+
)
|
|
765
|
+
if registry and run_id:
|
|
766
|
+
registry.finish(
|
|
767
|
+
run_id,
|
|
768
|
+
status="failed",
|
|
769
|
+
final_payload=final_payload,
|
|
770
|
+
run_info=run_info,
|
|
771
|
+
error=final_payload.get("error"),
|
|
772
|
+
)
|
|
773
|
+
_write_json(ctx, final_payload)
|
|
774
|
+
return error.exit_code
|
|
775
|
+
|
|
776
|
+
def _select_headless_session(
|
|
777
|
+
ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False, exclude=None,
|
|
778
|
+
):
|
|
779
|
+
"""The best session for a headless run, using the one shared ranking.
|
|
780
|
+
|
|
781
|
+
Returns the winning session with its status row and why it won, or None if
|
|
782
|
+
no candidate survived the filters. `exclude` drops sessions a failover has
|
|
783
|
+
already tried, which is the only selection input failover adds - the
|
|
784
|
+
ranking itself is untouched.
|
|
785
|
+
"""
|
|
786
|
+
excluded = set(exclude or ())
|
|
787
|
+
sessions_by_name = {
|
|
788
|
+
session["name"]: session for session in ctx["service"]["list_sessions"]()
|
|
789
|
+
}
|
|
790
|
+
rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
|
|
791
|
+
ordered, decision = rank_sessions(
|
|
792
|
+
rows,
|
|
793
|
+
_now_timestamp(),
|
|
794
|
+
_priority_reset_timestamp,
|
|
795
|
+
provider=provider,
|
|
796
|
+
require_ready=require_ready,
|
|
797
|
+
min_reasoning_effort=min_reasoning_effort,
|
|
798
|
+
)
|
|
799
|
+
for row in ordered:
|
|
800
|
+
name = row.get("session_name")
|
|
801
|
+
if name in excluded:
|
|
802
|
+
continue
|
|
803
|
+
session = sessions_by_name.get(name)
|
|
804
|
+
if session:
|
|
805
|
+
return {"session": session, "row": row, "decision": decision}
|
|
806
|
+
return None
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def _failover_prompt(prompt, attempted):
|
|
810
|
+
"""The prompt the successor account receives.
|
|
811
|
+
|
|
812
|
+
What travels is the task, plus the fact that earlier accounts ran out
|
|
813
|
+
mid-way. What does NOT travel is the provider conversation: the successor
|
|
814
|
+
is a different account with its own auth and its own history, so it starts
|
|
815
|
+
fresh. Any work the previous attempt left on disk is still on disk, which
|
|
816
|
+
is why the note tells the assistant to check before redoing it.
|
|
817
|
+
"""
|
|
818
|
+
previous = ", ".join(attempted[:-1])
|
|
819
|
+
return (
|
|
820
|
+
"This task was already started on another account "
|
|
821
|
+
f"({previous}) and stopped when that account hit its rate limit. "
|
|
822
|
+
"Work already written to the working tree is still there; check the "
|
|
823
|
+
"current state before redoing anything.\n\n"
|
|
824
|
+
f"{prompt}"
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _status_row_for(ctx, name):
|
|
829
|
+
"""The status to corroborate a rate-limited termination against.
|
|
830
|
+
|
|
831
|
+
Forced refresh on purpose: this runs once per rate-limited termination. The
|
|
832
|
+
Codex probe spawns app-server and can rotate the OAuth token, which is why
|
|
833
|
+
the cache exists, so this must stay rare and must never be called in a loop.
|
|
834
|
+
|
|
835
|
+
A refresh that comes back with no readable window falls back to the stored
|
|
836
|
+
status rather than being taken as "not exhausted": a failed probe is an
|
|
837
|
+
absence of evidence, and the account may well have a recorded 0% from the
|
|
838
|
+
moment it stopped answering.
|
|
839
|
+
"""
|
|
840
|
+
def _read(force_refresh):
|
|
841
|
+
try:
|
|
842
|
+
return ctx["service"]["get_status_row"](name, force_refresh=force_refresh)
|
|
843
|
+
except CdxError:
|
|
844
|
+
return None
|
|
845
|
+
|
|
846
|
+
row = _read(True)
|
|
847
|
+
if row and any(row.get(key) is not None for key in ("remaining_5h_pct", "remaining_week_pct")):
|
|
848
|
+
return row
|
|
849
|
+
return _read(False) or row
|