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.
@@ -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