cdx-manager 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,8 +21,27 @@ from ..cli_args import (
21
21
  )
22
22
  from ..cli_helpers import API_SCHEMA_VERSION, _json_failure, _json_success, _write_json
23
23
  from ..errors import CdxError
24
- from ..provider_runtime import _ensure_session_authentication, _headless_artifact_paths, _run_headless_provider_command
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
+ )
25
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
26
45
  from ..run_registry import RunRegistry, build_code_review_report
27
46
  from ..run_usage import extract_run_usage
28
47
  from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
@@ -233,18 +252,144 @@ def _spawn_detached_run(parsed, prompt, artifacts, ctx, session_name, cwd):
233
252
  # registered run failed, instead of letting a raw OSError escape as an
234
253
  # unhandled traceback on a --json call.
235
254
  raise CdxError(f"Failed to start detached cdx run: {error}", 126) from error
236
- return {"pid": getattr(child, "pid", None), "launch_log_path": launch_log_path}
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
+ }
237
325
 
238
326
  def handle_schema(rest, ctx):
239
327
  _parse_schema_args(rest)
240
328
  _write_json(ctx, _json_success("schema", "Schema loaded.", **cdx_schema()))
241
329
  return 0
242
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
+
243
387
  def handle_runs(rest, ctx):
244
388
  parsed = _parse_runs_args(rest)
245
389
  if not parsed["json"]:
246
390
  raise CdxError(RUNS_USAGE)
247
391
  since = parsed.get("since")
392
+ _reconcile_background_runs(ctx)
248
393
  runs = _run_registry(ctx).list(limit=parsed["limit"], since=since)
249
394
  warnings = []
250
395
  if since is not None and parsed.get("limit_given"):
@@ -315,6 +460,7 @@ def handle_run_tail(rest, ctx):
315
460
 
316
461
  def handle_run_status(rest, ctx):
317
462
  parsed = _parse_run_id_json_args(rest, RUN_STATUS_USAGE)
463
+ _reconcile_background_runs(ctx)
318
464
  run = _run_registry(ctx).get(parsed["run_id"])
319
465
  if not run:
320
466
  _write_json(ctx, _json_failure("run-status", "run_not_found", f"Unknown run: {parsed['run_id']}"))
@@ -324,6 +470,7 @@ def handle_run_status(rest, ctx):
324
470
 
325
471
  def handle_run_report(rest, ctx):
326
472
  parsed = _parse_run_id_json_args(rest, RUN_REPORT_USAGE)
473
+ _reconcile_background_runs(ctx)
327
474
  run = _run_registry(ctx).get(parsed["run_id"])
328
475
  if not run:
329
476
  _write_json(ctx, _json_failure("run-report", "run_not_found", f"Unknown run: {parsed['run_id']}"))
@@ -392,6 +539,11 @@ def handle_run(rest, ctx):
392
539
  if parsed.get("reasoning_effort"):
393
540
  launch_updates["reasoning_effort"] = parsed["reasoning_effort"]
394
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"])
395
547
  run_session = {
396
548
  **session,
397
549
  "launch": {
@@ -433,7 +585,24 @@ def handle_run(rest, ctx):
433
585
  },
434
586
  )
435
587
  if parsed.get("detach"):
436
- launch = _spawn_detached_run(parsed, prompt, artifacts, ctx, run_session["name"], cwd)
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
+ )
437
606
  # Hand the record the child's pid: this process is about to exit,
438
607
  # and a run pointing at a dead pid gets swept up as stale.
439
608
  if launch.get("pid"):
@@ -450,17 +619,73 @@ def handle_run(rest, ctx):
450
619
  ))
451
620
  return 0
452
621
 
453
- run_info = _run_headless_provider_command(
454
- run_session,
455
- cwd=cwd,
456
- env_override=ctx.get("env"),
457
- initial_prompt=prompt,
458
- timeout_seconds=parsed.get("timeout_seconds"),
459
- spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
460
- run_id=run_id,
461
- )
462
- usage = extract_run_usage(run_session.get("provider"), run_info.get("stdout_path"))
463
- run_info = {**run_info, "usage": usage}
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)
464
689
  ok = run_info.get("returncode") == 0
465
690
  if ok:
466
691
  final_payload = run_result_payload(
@@ -483,7 +708,14 @@ def handle_run(rest, ctx):
483
708
  })
484
709
  _write_json(ctx, final_payload)
485
710
  return 0
486
- message = "Provider process timed out." if run_info.get("timed_out") else "Provider process exited with a non-zero status."
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."
487
719
  error = CdxError(message, run_info.get("returncode") or 1)
488
720
  final_payload = run_result_payload(
489
721
  API_SCHEMA_VERSION,
@@ -493,7 +725,11 @@ def handle_run(rest, ctx):
493
725
  run_info=run_info,
494
726
  error=error,
495
727
  error_source="provider",
496
- error_code="provider_timeout" if run_info.get("timed_out") else "provider_failed",
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
+ ),
497
733
  selection_row=selection_row,
498
734
  )
499
735
  registry.finish(
@@ -537,12 +773,17 @@ def handle_run(rest, ctx):
537
773
  _write_json(ctx, final_payload)
538
774
  return error.exit_code
539
775
 
540
- def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False):
776
+ def _select_headless_session(
777
+ ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False, exclude=None,
778
+ ):
541
779
  """The best session for a headless run, using the one shared ranking.
542
780
 
543
781
  Returns the winning session with its status row and why it won, or None if
544
- no candidate survived the filters.
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.
545
785
  """
786
+ excluded = set(exclude or ())
546
787
  sessions_by_name = {
547
788
  session["name"]: session for session in ctx["service"]["list_sessions"]()
548
789
  }
@@ -556,7 +797,53 @@ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_r
556
797
  min_reasoning_effort=min_reasoning_effort,
557
798
  )
558
799
  for row in ordered:
559
- session = sessions_by_name.get(row.get("session_name"))
800
+ name = row.get("session_name")
801
+ if name in excluded:
802
+ continue
803
+ session = sessions_by_name.get(name)
560
804
  if session:
561
805
  return {"session": session, "row": row, "decision": decision}
562
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
@@ -117,12 +117,25 @@ def _format_launch_configs(sessions, use_color=False):
117
117
  "",
118
118
  _dim(_format_launch_settings_hint(), use_color),
119
119
  ])
120
- rows = [[_style(value, "1", use_color) for value in [
121
- "SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "MODEL", "PRIORITY"
122
- ]]]
120
+ # The table is already nine columns wide, so the two headless-only settings
121
+ # follow the LABEL convention from the session list: shown once somebody
122
+ # uses them, absent otherwise.
123
+ has_headless = any(
124
+ (session.get("launch") or {}).get(key) is not None
125
+ for session in sessions
126
+ for key in ("fallback_model", "budget")
127
+ )
128
+ has_extra = any((session.get("launch") or {}).get("extra_args") for session in sessions)
129
+ headers = ["SESSION", "PROVIDER", "POWER", "PERMISSION", "FAST", "RTK", "LOGICS", "MODEL"]
130
+ if has_headless:
131
+ headers += ["FALLBACK", "BUDGET"]
132
+ if has_extra:
133
+ headers.append("EXTRA ARGS")
134
+ headers.append("PRIORITY")
135
+ rows = [[_style(value, "1", use_color) for value in headers]]
123
136
  for session in sessions:
124
137
  launch = session.get("launch") or {}
125
- rows.append([
138
+ row = [
126
139
  _style(session["name"], "36", use_color),
127
140
  _dim(session.get("provider") or "-", use_color),
128
141
  _format_launch_setting_value(launch, "power", use_color),
@@ -131,14 +144,33 @@ def _format_launch_configs(sessions, use_color=False):
131
144
  _format_launch_setting_value(launch, "rtk", use_color),
132
145
  _format_launch_setting_value(launch, "logics", use_color),
133
146
  _format_launch_setting_value(launch, "model", use_color),
134
- _format_launch_setting_value(launch, "priority", use_color),
135
- ])
136
- return "\n".join([
147
+ ]
148
+ if has_headless:
149
+ row.append(_format_launch_setting_value(launch, "fallback_model", use_color))
150
+ row.append(_format_launch_setting_value(launch, "budget", use_color))
151
+ if has_extra:
152
+ row.append(_format_launch_setting_value(launch, "extra_args", use_color))
153
+ row.append(_format_launch_setting_value(launch, "priority", use_color))
154
+ rows.append(row)
155
+ lines = [
137
156
  _style("Launch settings:", "1", use_color),
138
157
  _pad_table(rows),
139
158
  "",
140
- _dim(_format_launch_settings_hint(), use_color),
141
- ])
159
+ ]
160
+ if has_headless:
161
+ lines.append(_dim(
162
+ "FALLBACK and BUDGET apply to headless Claude runs only "
163
+ "(cdx run); the provider accepts them with --print alone.",
164
+ use_color,
165
+ ))
166
+ if has_extra:
167
+ lines.append(_dim(
168
+ "EXTRA ARGS are passed to the provider unchanged and unvalidated. "
169
+ "cdx does not check them, and cdx schema does not describe them.",
170
+ use_color,
171
+ ))
172
+ lines.append(_dim(_format_launch_settings_hint(), use_color))
173
+ return "\n".join(lines)
142
174
 
143
175
  def _format_duration_ms(value):
144
176
  if value is None:
package/src/config.py CHANGED
@@ -34,6 +34,35 @@ PERMISSION_ALIASES = {
34
34
  # Everything accepted as input for a permission, canonical values first.
35
35
  PERMISSION_INPUT_VALUES = PERMISSION_VALUES + tuple(PERMISSION_ALIASES)
36
36
 
37
+ # A budget is a ceiling, so the only bound worth enforcing is the one a caller
38
+ # could cross by accident. Zero and negatives express nothing; the upper bound
39
+ # catches a misplaced decimal rather than stating a policy. Defined here, not in
40
+ # each parser, because `cdx set` and `cdx run` validating the same setting from
41
+ # separate literals is how they came to disagree before.
42
+ MAX_LAUNCH_BUDGET_USD = 10000
43
+
44
+ MAX_LAUNCH_EXTRA_ARGS_LENGTH = 512
45
+
46
+
47
+ def split_extra_args(value):
48
+ """Passthrough arguments as an argv list, or None if the string is malformed.
49
+
50
+ POSIX splitting on every platform, deliberately. The alternative - following
51
+ the host's own quoting rules - would make the same `cdx set` mean different
52
+ things on Windows and on macOS, and these strings travel between machines
53
+ through `cdx export`. One rule everywhere is worth more than matching the
54
+ local shell, especially since cdx never invokes a shell: the result is
55
+ passed as argv, so nothing here is ever re-interpreted.
56
+ """
57
+ import shlex
58
+
59
+ if value is None:
60
+ return None
61
+ try:
62
+ return shlex.split(str(value), posix=True)
63
+ except ValueError:
64
+ return None
65
+
37
66
 
38
67
  def normalize_permission(value):
39
68
  """Canonical form of a permission input, or None if it is not one.