cdx-manager 0.24.6 → 0.24.8

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 CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.6-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.24.8-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
10
10
 
@@ -42,6 +42,14 @@ cdx work
42
42
 
43
43
  CDX checks authentication before interactive launches. Handoff keeps the source transcript in place and asks the receiving agent to verify the conversation and workspace before acting. No account switch or prompt submission happens just because you run `cdx next`.
44
44
 
45
+ ## Codex model capacity recovery
46
+
47
+ `cdx run` (including `--detach`) automatically recovers from Codex's "Selected model is at capacity" failure. It retries the same prompt, profile, and model after 1 and 2 seconds, then tries the profile's `fallback_model` list in configured order, removing duplicates. Configure alternatives with `cdx set work --fallback-model MODEL_A,MODEL_B`.
48
+
49
+ After exhausting those models, CDX selects another ready Codex profile using the normal ranking, including profile priority. Disabled, logged-out, `never`, and `ask-first` profiles are excluded from automatic selection. To choose an `ask-first` profile explicitly, name it in a new command (`cdx run NAME ...`). Each successor uses `--model` if supplied, otherwise its own configured/default model, followed by its own fallback list. Each model gets at most three attempts; at most four profile switches are allowed. Settings are not changed. Each attempt retains the command's timeout; the backoff adds at most three seconds per model.
50
+
51
+ Recovery does not require `--failover`, which still controls account quota exhaustion separately. Other provider failures and timeouts do not trigger capacity recovery. Attempts restart the prompt in the same workspace; files from earlier attempts remain there. Each retry, model fallback, profile fallback, and exhausted budget is logged to stderr and persisted in `recovery_events` in the run registry and final JSON (`cdx run-status` / `cdx run-report`). Events include archived attempt output paths. Recovery keeps one run ID and reports the final effective model. If no eligible alternative remains or the profile switch budget is reached, the run fails with `capacity_recovery_exhausted`.
52
+
45
53
  ## Documentation
46
54
 
47
55
  - [Installation and first steps](docs/getting-started.md) — prerequisites, npm/pipx/uv, Windows, configuration.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.24.6
1
+ 0.24.8
@@ -0,0 +1,22 @@
1
+ # CDX Manager 0.24.7
2
+
3
+ Release date: 2026-10-29
4
+
5
+ This patch fixes command execution in isolated Codex workspace runs. (#65)
6
+
7
+ ## Workspace runs
8
+
9
+ - Drop Codex's nested sandbox when `--isolate-workspace` is enabled, avoiding the user namespace mapping failure that prevented commands from running. The outer workspace sandbox remains the isolation boundary. (#65)
10
+ - Remove Codex's sandbox mode and network overrides inside the isolated workspace and pass `--dangerously-bypass-approvals-and-sandbox`; the outer sandbox controls network access and keeps review runs read-only through its workspace mount. (#65)
11
+
12
+ ## Codex capacity recovery
13
+
14
+ - Retry headless Codex model capacity failures after 1 and 2 seconds before trying configured fallback models, then ready Codex profiles in the existing priority-aware order. This also applies to detached runs and does not require `--failover`.
15
+ - Exclude disabled, logged-out, `never`, and `ask-first` profiles from automatic fallback. Bound recovery to three attempts per model and four profile switches, with a distinct `capacity_recovery_exhausted` outcome.
16
+ - Log each recovery action to stderr and structured run data, preserve failed attempt output, and report the effective model without changing profile settings.
17
+ - Cover capacity detection, retry and profile budgets, fallback order, selection policies, and backoff cancellation with tests.
18
+
19
+ ## Maintenance
20
+
21
+ - Record tray companion checksums for v0.24.6. (`87aba5d2cffdfb1a96e386abcdabe54df9ee4808`)
22
+ - Record release archive checksums for v0.24.6. (`6b92094143ea28bf0cc096aef88d1015565aba18`)
@@ -0,0 +1,16 @@
1
+ # CDX Manager 0.24.8
2
+
3
+ Release date: 2026-10-30
4
+
5
+ This patch improves recovery from Codex model capacity failures. (#67)
6
+
7
+ ## Codex capacity recovery
8
+
9
+ - Retry headless Codex capacity failures after 1 and 2 seconds before trying configured fallback models, then ready Codex profiles in priority-aware order. Recovery also applies to detached runs and does not require `--failover`. (#67)
10
+ - Exclude disabled, logged-out, `never`, and `ask-first` profiles from automatic fallback. Limit recovery to three attempts per model and four profile switches, reporting `capacity_recovery_exhausted` when no eligible alternative remains or the budget is exhausted. (#67)
11
+ - Log recovery actions to stderr and structured run data, preserve failed attempt output, and report the effective model without changing profile settings. (#67)
12
+
13
+ ## Maintenance
14
+
15
+ - Record tray companion checksums for v0.24.7. (`16938c0d22de4589f5b4757fd7ff55e848127e7d`)
16
+ - Record release archive checksums for v0.24.7. (`a169c3eee41fcbc97477ca4d74bfc8f77c1330d5`)
@@ -639,6 +639,28 @@
639
639
  "x86_64-pc-windows-msvc": "74124b2e40a58a58b55724ef453c4b543a83eefab9a5f92685b239c5d97e7f67",
640
640
  "x86_64-unknown-linux-musl": "089e552a4dfae4080a2e76cedb6fe72fc8013a9cd08cf3c8b333f916198da57c"
641
641
  }
642
+ },
643
+ "v0.24.6": {
644
+ "github_tarball_sha256": "4b61861ae26fc7904e751571b65d0783c16dcf35d3b485379434469e27511fb4",
645
+ "github_zip_sha256": "6c5cfa2139eb53a20a8eca8624437a06b18392b0df7774d9c7c64a74b8066f59",
646
+ "tray_assets": {
647
+ "aarch64-apple-darwin": "7200f658e3413b2614a7ffa5d03e1dac173fc1a653dc659d92f2bef40f2bf5d4",
648
+ "aarch64-unknown-linux-musl": "b892a9a3bf009e5069dc798ab910b20e77ac9d03b9b575a80cb19af1dc13533a",
649
+ "x86_64-apple-darwin": "d1e1b619feab8eae1e2a25e17942d8d690fc1e6184f294599a15858a1f0c146e",
650
+ "x86_64-pc-windows-msvc": "0d125d8e0f7d9880a2ed9e0a794964f3d69ef3353b90a1d594b17f9392a0a0af",
651
+ "x86_64-unknown-linux-musl": "d6c369ace10a6d373366cd12e4e6cd7f2fd278fbce2fd9a13c5bf51047847441"
652
+ }
653
+ },
654
+ "v0.24.7": {
655
+ "github_tarball_sha256": "170df61319347dc133442c6750c31812a55a3e8a002fae60bd4822771839750f",
656
+ "github_zip_sha256": "fa3eda26dbfec1af36e25f72a0fcf01a18fa064dc442ffc39b64c7bb2ab7e1ae",
657
+ "tray_assets": {
658
+ "aarch64-apple-darwin": "f775ae9e140f1147368c4658a27e6f0751585a5b001b633fcfccfe252b85721e",
659
+ "aarch64-unknown-linux-musl": "528d61bb9dc5c395f74aad98d35f8cebea958ba2561fd15302846eb8bc036343",
660
+ "x86_64-apple-darwin": "e85cd5d2cb2f5dd1a2dd89ff9b532432cffd86175e709d147ff4595551d4ff50",
661
+ "x86_64-pc-windows-msvc": "41df35daa664802b87c052d6a9f634b240eb4e4a915868f1dd629b6734eab76a",
662
+ "x86_64-unknown-linux-musl": "2492e137e5c883fa16f502843a867e0325744dfc66d7d38be2c00eb362425af6"
663
+ }
642
664
  }
643
665
  }
644
666
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.24.6",
3
+ "version": "0.24.8",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.24.6"
7
+ version = "0.24.8"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli_args.py CHANGED
@@ -543,6 +543,7 @@ def cdx_schema():
543
543
  "too_many_runs",
544
544
  "failover_exhausted",
545
545
  "failover_limit_reached",
546
+ "capacity_recovery_exhausted",
546
547
  "invalid_request",
547
548
  "cdx_error",
548
549
  ],
@@ -5,8 +5,10 @@ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
5
5
 
6
6
  import importlib.util
7
7
  import os
8
+ import shutil
8
9
  import subprocess
9
10
  import sys
11
+ import time
10
12
 
11
13
  from ..cli_args import (
12
14
  RUN_REPORT_USAGE,
@@ -39,10 +41,18 @@ from ..provider_runtime import (
39
41
  _build_launch_spec,
40
42
  _ensure_session_authentication,
41
43
  _headless_artifact_paths,
44
+ _restore_signal_handlers,
45
+ _route_termination_to_interrupt,
42
46
  _run_headless_provider_command,
43
47
  )
44
48
  from ..run_command import read_run_prompt, run_cdx_error_code, run_launch_payload, run_result_payload
45
- from ..run_failover import MAX_FAILOVER_TRANSITIONS, failover_reason
49
+ from ..run_failover import (
50
+ CAPACITY_RETRY_DELAYS,
51
+ MAX_FAILOVER_TRANSITIONS,
52
+ capacity_models,
53
+ failover_reason,
54
+ looks_model_at_capacity,
55
+ )
46
56
  from ..run_registry import RunRegistry, build_code_review_report, final_message
47
57
  from ..run_usage import extract_run_observation
48
58
  from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
@@ -676,7 +686,13 @@ def handle_run(rest, ctx):
676
686
 
677
687
  attempted = [run_session["name"]]
678
688
  failover_error_code = None
689
+ recovery_events = []
690
+ attempt = 0
691
+ models = capacity_models(run_session)
692
+ model_index = 0
693
+ capacity_retries = 0
679
694
  while True:
695
+ attempt += 1
680
696
  run_info = _run_headless_provider_command(
681
697
  run_session,
682
698
  cwd=cwd,
@@ -690,16 +706,70 @@ def handle_run(rest, ctx):
690
706
  run_session.get("provider"), run_info.get("stdout_path"),
691
707
  run_session.get("authHome"),
692
708
  )
693
- run_info = {**run_info, "usage": usage, "usage_model": usage_model}
709
+ run_info = {**run_info, "usage": usage, "usage_model": usage_model,
710
+ "effective_model": (run_session.get("launch") or {}).get("model"),
711
+ "recovery_events": recovery_events}
694
712
  ctx["service"]["record_launch_history"](run_session["name"], {
695
713
  "status": "success" if run_info.get("returncode") == 0 else "failed",
696
714
  "action": "run",
697
715
  "run_id": run_id,
698
- "attempt": len(attempted),
716
+ "attempt": attempt,
699
717
  "cwd": cwd,
700
718
  "exit_code": run_info.get("returncode"),
701
719
  **run_info,
702
720
  })
721
+ if looks_model_at_capacity(run_session.get("provider"), run_info):
722
+ event = {"reason": "model_at_capacity", "attempt": attempt,
723
+ "session": run_session["name"], "model": models[model_index]}
724
+ if capacity_retries < len(CAPACITY_RETRY_DELAYS):
725
+ delay = CAPACITY_RETRY_DELAYS[capacity_retries]
726
+ capacity_retries += 1
727
+ event.update(action="retry", retry=capacity_retries, delay_seconds=delay)
728
+ elif model_index + 1 < len(models):
729
+ model_index += 1
730
+ capacity_retries = 0
731
+ run_session["launch"]["model"] = models[model_index]
732
+ event.update(action="fallback_model", target_model=models[model_index])
733
+ else:
734
+ successor = None if len(attempted) > MAX_FAILOVER_TRANSITIONS else _select_headless_session(
735
+ ctx, "codex", min_reasoning_effort=parsed["reasoning_effort"],
736
+ require_ready=True, exclude=attempted,
737
+ )
738
+ if not successor:
739
+ failover_error_code = "capacity_recovery_exhausted"
740
+ event.update(action="exhausted")
741
+ else:
742
+ selection_row = successor.get("row") or {}
743
+ next_session = successor["session"]
744
+ run_session = {**next_session, "launch": {**(next_session.get("launch") or {}), **launch_updates}}
745
+ # Each new profile starts with the requested model, or its own default.
746
+ models = capacity_models(run_session)
747
+ model_index = capacity_retries = 0
748
+ attempted.append(run_session["name"])
749
+ event.update(action="fallback_profile", target_session=run_session["name"], target_model=models[0])
750
+ _record_capacity_recovery(ctx, registry, run_id, event, run_info, recovery_events)
751
+ if failover_error_code:
752
+ break
753
+ if event["action"] == "retry":
754
+ previous_handlers = _route_termination_to_interrupt()
755
+ try:
756
+ time.sleep(event["delay_seconds"])
757
+ except KeyboardInterrupt as interrupt:
758
+ error = CdxError("Codex capacity recovery was cancelled.", 130)
759
+ error.cancelled = True
760
+ error.history_recorded = True
761
+ error.run_info = {**run_info, "returncode": 130}
762
+ raise error from interrupt
763
+ finally:
764
+ _restore_signal_handlers(previous_handlers)
765
+ elif event["action"] == "fallback_profile":
766
+ _ensure_session_authentication(
767
+ run_session, ctx["service"], spawn=ctx.get("spawn"), spawn_sync=ctx.get("spawn_sync"),
768
+ env_override=ctx.get("env"), stdin_is_tty=False, behavior="launch",
769
+ signal_emitter=ctx.get("signal_emitter"), trust_local_credentials=False,
770
+ )
771
+ registry.migrate(run_id, session=run_session["name"], provider="codex", reason="model_at_capacity")
772
+ continue
703
773
  if run_info.get("returncode") == 0 or not parsed.get("failover"):
704
774
  break
705
775
  reason = failover_reason(
@@ -731,6 +801,8 @@ def handle_run(rest, ctx):
731
801
  "launch": {**(successor["session"].get("launch") or {}), **launch_updates},
732
802
  }
733
803
  attempted.append(run_session["name"])
804
+ models = capacity_models(run_session)
805
+ model_index = capacity_retries = 0
734
806
  # A different account means different credentials; an unauthenticated
735
807
  # successor would fail for a reason unrelated to the task.
736
808
  _ensure_session_authentication(
@@ -769,7 +841,9 @@ def handle_run(rest, ctx):
769
841
  )
770
842
  _write_json(ctx, final_payload)
771
843
  return 0
772
- if failover_error_code == "failover_exhausted":
844
+ if failover_error_code == "capacity_recovery_exhausted":
845
+ message = "Codex model capacity recovery exhausted its retry, model, or profile budget."
846
+ elif failover_error_code == "failover_exhausted":
773
847
  message = f"Every eligible session was rate limited: {', '.join(attempted)}."
774
848
  elif failover_error_code == "failover_limit_reached":
775
849
  message = f"Failover stopped after {MAX_FAILOVER_TRANSITIONS} transitions: {', '.join(attempted)}."
@@ -806,7 +880,9 @@ def handle_run(rest, ctx):
806
880
  except CdxError as error:
807
881
  run_info = getattr(error, "run_info", None)
808
882
  cancelled = bool(getattr(error, "cancelled", False))
809
- if run_info and run_id:
883
+ if run_info is not None:
884
+ run_info = {**run_info, "recovery_events": locals().get("recovery_events", [])}
885
+ if run_info and run_id and not getattr(error, "history_recorded", False):
810
886
  usage, usage_model = extract_run_observation(
811
887
  run_session.get("provider"), run_info.get("stdout_path"),
812
888
  run_session.get("authHome"),
@@ -816,7 +892,7 @@ def handle_run(rest, ctx):
816
892
  "status": "failed",
817
893
  "action": "run",
818
894
  "run_id": run_id,
819
- "attempt": len(attempted) if "attempted" in locals() else 1,
895
+ "attempt": locals().get("attempt", 1),
820
896
  "cwd": cwd,
821
897
  "exit_code": error.exit_code,
822
898
  **run_info,
@@ -825,13 +901,14 @@ def handle_run(rest, ctx):
825
901
  API_SCHEMA_VERSION,
826
902
  False,
827
903
  locals().get("parsed", {}) or {},
828
- locals().get("session"),
904
+ locals().get("run_session", locals().get("session")),
829
905
  run_info=run_info,
830
906
  error=error,
831
907
  error_source="provider" if cancelled else "cdx",
832
908
  error_code="run_cancelled" if cancelled else run_cdx_error_code(error),
833
909
  selection_row=selection_row,
834
910
  )
911
+ final_payload["recovery_events"] = locals().get("recovery_events", [])
835
912
  if registry and run_id:
836
913
  registry.finish(
837
914
  run_id,
@@ -845,6 +922,22 @@ def handle_run(rest, ctx):
845
922
  _write_json(ctx, final_payload)
846
923
  return error.exit_code
847
924
 
925
+ def _record_capacity_recovery(ctx, registry, run_id, event, run_info, events):
926
+ # Subsequent attempts reuse the run's paths; retain the failed output first.
927
+ artifacts = {}
928
+ for key in ("stdout_path", "stderr_path", "transcript_path"):
929
+ path = run_info.get(key)
930
+ if path and os.path.isfile(path):
931
+ archived = f"{path}.attempt-{event['attempt']}"
932
+ shutil.copyfile(path, archived)
933
+ artifacts[key] = archived
934
+ event["artifacts"] = artifacts
935
+ events.append(event)
936
+ registry.record_recovery(run_id, event)
937
+ details = ", ".join(f"{key}={value}" for key, value in event.items() if key != "artifacts")
938
+ ctx["err"](f"cdx capacity recovery: {details}\n")
939
+
940
+
848
941
  def _select_headless_session(
849
942
  ctx, provider, min_reasoning_effort=None, require_ready=False, force_refresh=False, exclude=None,
850
943
  ):
@@ -1212,7 +1212,10 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1212
1212
  )
1213
1213
  launch = session.get("launch") or {}
1214
1214
  if launch.get("network_access"):
1215
- if session.get("provider") == PROVIDER_CODEX:
1215
+ if session.get("provider") == PROVIDER_CODEX and launch.get("isolate_workspace"):
1216
+ # The outer sandbox owns the network; Codex's own sandbox is dropped below.
1217
+ pass
1218
+ elif session.get("provider") == PROVIDER_CODEX:
1216
1219
  # Keep the workspace sandbox; append overrides before the prompt separator.
1217
1220
  index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
1218
1221
  spec["args"][index:index] = ["-c", 'sandbox_workspace_write.network_access=true', '-c', 'sandbox_workspace_write.writable_roots=["/cache", "/home/agent"]']
@@ -1226,6 +1229,17 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1226
1229
  # The outer OS sandbox is the permission boundary for unattended Bash.
1227
1230
  index = spec["args"].index("--") if "--" in spec["args"] else len(spec["args"])
1228
1231
  spec["args"][index:index] = ["--permission-mode", "bypassPermissions"]
1232
+ elif session.get("provider") == PROVIDER_CODEX:
1233
+ # Codex's bubblewrap sandbox cannot nest inside ours (no uid map), so the
1234
+ # outer box is the boundary: drop `-s <mode>` and bypass the inner one.
1235
+ # A review run stays read-only through the outer mount.
1236
+ args = spec["args"]
1237
+ while "-s" in args:
1238
+ index = args.index("-s")
1239
+ del args[index:index + 2]
1240
+ if "--dangerously-bypass-approvals-and-sandbox" not in args:
1241
+ index = args.index("--") if "--" in args else len(args)
1242
+ args[index:index] = ["--dangerously-bypass-approvals-and-sandbox"]
1229
1243
  try:
1230
1244
  argv, child_env = sandbox_command(
1231
1245
  [spec["command"], *spec["args"]], Path(cwd),
@@ -210,7 +210,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
210
210
  "launcher": "cdx",
211
211
  "session": session.get("name") if session else None,
212
212
  "provider": session.get("provider") if session else parsed.get("provider"),
213
- "model": parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None),
213
+ "model": run_info.get("effective_model", parsed.get("model") or ((session.get("launch") or {}).get("model") if session else None)),
214
+ "recovery_events": run_info.get("recovery_events", []),
214
215
  "reasoning_effort": run_payload_reasoning_effort(parsed, session),
215
216
  "power": parsed.get("power") or ((session.get("launch") or {}).get("power") if session else None),
216
217
  "cwd": os.path.abspath(parsed.get("cwd") or os.getcwd()),
@@ -1,6 +1,6 @@
1
- """Deciding whether a finished run stopped because its account ran out.
1
+ """Classify finished runs for account exhaustion or model capacity recovery.
2
2
 
3
- Two independent signals have to agree before cdx moves a task to another
3
+ For account exhaustion, two independent signals must agree before cdx moves a task to another
4
4
  account, because the two ways of being wrong are not symmetric. Missing a rate
5
5
  limit costs nothing new: the run fails exactly as it does today. Inventing one
6
6
  migrates a healthy run off a working account, discards its progress, and
@@ -22,6 +22,7 @@ codebase reads provider output for this purpose.
22
22
 
23
23
  import json
24
24
  import os
25
+ import re
25
26
 
26
27
  from .config import PROVIDER_CLAUDE, PROVIDER_CODEX
27
28
 
@@ -235,3 +236,32 @@ def failover_reason(provider, run_info, status_row):
235
236
 
236
237
  def should_fail_over(provider, run_info, status_row):
237
238
  return failover_reason(provider, run_info, status_row) is not None
239
+
240
+
241
+ CAPACITY_RETRY_DELAYS = (1, 2)
242
+ _CAPACITY_MESSAGE = re.compile(r"(?:selected model|(?:the )?model(?: [\w.-]+)?) is at capacity\b", re.IGNORECASE)
243
+
244
+
245
+ def looks_model_at_capacity(provider, run_info):
246
+ """Recognize Codex failures, excluding successful output and assistant prose."""
247
+ if provider != PROVIDER_CODEX or run_info.get("timed_out") or run_info.get("returncode") in (None, 0):
248
+ return False
249
+ for key in ("stdout_path", "stderr_path"):
250
+ text = _read_tail(run_info.get(key))
251
+ for record in _structured_records(provider, text):
252
+ if any(_CAPACITY_MESSAGE.search(message) for message in _error_texts(provider, record)):
253
+ return True
254
+ # Some Codex versions print this diagnostic as a standalone plain line.
255
+ for line in text.splitlines():
256
+ line = line.strip()
257
+ if _CAPACITY_MESSAGE.match(re.sub(r"^error:\s*", "", line, flags=re.IGNORECASE)):
258
+ return True
259
+ return False
260
+
261
+
262
+ def capacity_models(session):
263
+ """Configured alternatives in order, without retrying the current model."""
264
+ launch = session.get("launch") or {}
265
+ models = [launch.get("model")]
266
+ models.extend(str(launch.get("fallback_model") or "").split(","))
267
+ return list(dict.fromkeys(model.strip() if model else None for model in models if model is None or model.strip()))
@@ -321,6 +321,16 @@ class RunRegistry:
321
321
  return run
322
322
  return None
323
323
 
324
+ def record_recovery(self, run_id, event):
325
+ """Publish retries and fallbacks while a supervised run is still active."""
326
+ with _registry_lock(self.path):
327
+ data = _read_registry(self.path)
328
+ for run in data["runs"]:
329
+ if run.get("run_id") == run_id:
330
+ run.setdefault("recovery_events", []).append({"at": utc_now_iso(), **event})
331
+ _write_registry(self.path, data)
332
+ return
333
+
324
334
  def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
325
335
  with _registry_lock(self.path):
326
336
  return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
@@ -359,6 +369,7 @@ class RunRegistry:
359
369
  # used to store whichever shape the producing reader emitted.
360
370
  run["usage"] = coerce_usage(final_payload.get("usage"))
361
371
  run["usage_model"] = final_payload.get("usage_model")
372
+ run["model"] = final_payload.get("model")
362
373
  run["final_payload"] = final_payload
363
374
  if error:
364
375
  run["error"] = error