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.
@@ -101,6 +101,12 @@ def _refresh_stale_runs(data):
101
101
  for run in data["runs"]:
102
102
  if run.get("status") != "running":
103
103
  continue
104
+ if run.get("background_path") == "provider_native":
105
+ # The provider owns this agent's lifecycle and the pid it reports
106
+ # belongs to a daemon helper, not the run. Sweeping on pid liveness
107
+ # would mark a healthy delegated run stale; its terminal status
108
+ # comes from the provider instead.
109
+ continue
104
110
  pid = run.get("pid")
105
111
  if pid and _is_pid_alive(pid):
106
112
  continue
@@ -124,11 +130,31 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
124
130
  # the liveness proxy until finish() records the provider child's pid.
125
131
  "pid": os.getpid(),
126
132
  "started_at": utc_now_iso(),
133
+ # A failover run occupies several sessions in turn while staying one
134
+ # run. `session` and `provider` above name the current occupant so every
135
+ # existing reader keeps working; this is the ordered history behind
136
+ # them, and it exists from the start so no run is ever written in a
137
+ # shape the reporting cannot express.
138
+ "occupancies": [{
139
+ "session": session,
140
+ "provider": provider,
141
+ "started_at": utc_now_iso(),
142
+ "ended_at": None,
143
+ "reason": "initial_selection",
144
+ }],
127
145
  "ended_at": None,
128
146
  "duration_seconds": None,
129
147
  "exit_code": None,
130
148
  "usage": None,
131
149
  "artifacts": dict(artifacts or {}),
150
+ # Which mechanism is carrying a detached run, and the provider's own
151
+ # handle when it is the provider's. Present on every record so a reader
152
+ # never has to infer it from absence.
153
+ "background_path": None,
154
+ # Two handles, because the provider uses two: the session id names the
155
+ # transcript file, the short agent id is what `claude stop` accepts.
156
+ "provider_session_id": None,
157
+ "provider_agent_id": None,
132
158
  "error": None,
133
159
  "task_report": None,
134
160
  "final_payload": None,
@@ -166,6 +192,50 @@ class RunRegistry:
166
192
  return run
167
193
  return None
168
194
 
195
+ def record_background(self, run_id, *, path, provider_session_id=None, provider_agent_id=None):
196
+ with _registry_lock(self.path):
197
+ data = _read_registry(self.path)
198
+ for run in data["runs"]:
199
+ if run.get("run_id") != run_id:
200
+ continue
201
+ run["background_path"] = path
202
+ run["provider_session_id"] = provider_session_id
203
+ run["provider_agent_id"] = provider_agent_id
204
+ _write_registry(self.path, data)
205
+ return run
206
+ return None
207
+
208
+ def migrate(self, run_id, *, session, provider, reason):
209
+ """Move a still-running run onto another session, keeping one run_id.
210
+
211
+ Closes the current occupancy with the reason it ended and opens the
212
+ next one. The top-level `session`/`provider` follow the new occupant so
213
+ readers that know nothing about occupancies stay correct.
214
+ """
215
+ with _registry_lock(self.path):
216
+ data = _read_registry(self.path)
217
+ now = utc_now_iso()
218
+ for run in data["runs"]:
219
+ if run.get("run_id") != run_id:
220
+ continue
221
+ occupancies = run.get("occupancies") or []
222
+ if occupancies and occupancies[-1].get("ended_at") is None:
223
+ occupancies[-1]["ended_at"] = now
224
+ occupancies[-1]["reason"] = reason
225
+ occupancies.append({
226
+ "session": session,
227
+ "provider": provider,
228
+ "started_at": now,
229
+ "ended_at": None,
230
+ "reason": "failover_target",
231
+ })
232
+ run["occupancies"] = occupancies
233
+ run["session"] = session
234
+ run["provider"] = provider
235
+ _write_registry(self.path, data)
236
+ return run
237
+ return None
238
+
169
239
  def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
170
240
  with _registry_lock(self.path):
171
241
  return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
@@ -178,6 +248,10 @@ class RunRegistry:
178
248
  continue
179
249
  run["status"] = status
180
250
  run["ended_at"] = now
251
+ occupancies = run.get("occupancies") or []
252
+ if occupancies and occupancies[-1].get("ended_at") is None:
253
+ occupancies[-1]["ended_at"] = now
254
+ occupancies[-1]["reason"] = status
181
255
  if run.get("started_at"):
182
256
  try:
183
257
  start = datetime.fromisoformat(str(run["started_at"]).replace("Z", "+00:00"))
@@ -35,6 +35,8 @@ from functools import partial
35
35
 
36
36
  from .codex_usage import fetch_codex_rate_limits
37
37
  from .config import (
38
+ MAX_LAUNCH_BUDGET_USD,
39
+ MAX_LAUNCH_EXTRA_ARGS_LENGTH,
38
40
  PERMISSION_INPUT_VALUES,
39
41
  PERMISSION_VALUES,
40
42
  PROVIDER_CLAUDE,
@@ -42,6 +44,7 @@ from .config import (
42
44
  REASONING_EFFORT_VALUES,
43
45
  get_cdx_home,
44
46
  normalize_permission,
47
+ split_extra_args,
45
48
  )
46
49
  from .errors import CdxError
47
50
  from .fs_utils import atomic_write, remove_tree
@@ -107,6 +110,7 @@ from .session_status import ( # noqa: F401 (re-exported for callers and tests)
107
110
  record_status,
108
111
  )
109
112
  from .session_store import create_session_store
113
+ from .status_source import find_latest_codex_conversation_id
110
114
 
111
115
  MAX_SESSION_LABEL_LENGTH = 64
112
116
  # Each live Codex probe spawns app-server, which can refresh+rotate the OAuth
@@ -214,6 +218,40 @@ def _normalize_launch_settings(settings, mark_fast_service_tier=True):
214
218
  if len(model) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in model):
215
219
  raise CdxError("Model contains unsupported characters.")
216
220
  normalized["model"] = model
221
+ if "fallback_model" in settings and settings["fallback_model"] is not None:
222
+ # Claude accepts a comma-separated list here and tries each in order, so
223
+ # this validates every element rather than the string as one model name.
224
+ raw = str(settings["fallback_model"]).strip()
225
+ if not raw:
226
+ raise CdxError("Fallback model cannot be empty.")
227
+ elements = [element.strip() for element in raw.split(",")]
228
+ if any(not element for element in elements):
229
+ raise CdxError("Fallback model cannot contain an empty entry.")
230
+ for element in elements:
231
+ if len(element) > MAX_LAUNCH_MODEL_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in element):
232
+ raise CdxError("Fallback model contains unsupported characters.")
233
+ normalized["fallback_model"] = ",".join(elements)
234
+ if "extra_args" in settings and settings["extra_args"] is not None:
235
+ extra = str(settings["extra_args"]).strip()
236
+ if not extra:
237
+ raise CdxError("Extra args cannot be empty.")
238
+ if len(extra) > MAX_LAUNCH_EXTRA_ARGS_LENGTH or any(ord(ch) < 32 or ord(ch) == 127 for ch in extra):
239
+ raise CdxError("Extra args contain unsupported characters.")
240
+ # Parsed here, not at launch: a string that cannot be split into argv
241
+ # should be rejected when it is set, not when a run fails days later.
242
+ if split_extra_args(extra) is None:
243
+ raise CdxError("Extra args are not a valid argument list (unbalanced quotes).")
244
+ normalized["extra_args"] = extra
245
+ if "budget" in settings and settings["budget"] is not None:
246
+ try:
247
+ budget = float(settings["budget"])
248
+ except (TypeError, ValueError) as error:
249
+ raise CdxError(f"Unsupported budget: {settings['budget']}") from error
250
+ # NaN fails every comparison below, so it has to be rejected explicitly
251
+ # or it would sail through the range check.
252
+ if budget != budget or budget <= 0 or budget > MAX_LAUNCH_BUDGET_USD:
253
+ raise CdxError(f"Unsupported budget: {settings['budget']}")
254
+ normalized["budget"] = budget
217
255
  if "priority" in settings and settings["priority"] is not None:
218
256
  try:
219
257
  priority = int(settings["priority"])
@@ -310,7 +348,10 @@ def unset_launch_settings(store, name, keys):
310
348
  raise CdxError(f"Unknown session: {name}")
311
349
  if not keys:
312
350
  raise CdxError("At least one launch setting is required.")
313
- allowed = {"power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics", "model", "priority"}
351
+ allowed = {
352
+ "power", "reasoning_effort", "reasoningEffort", "permission", "fast", "rtk", "logics",
353
+ "model", "fallback_model", "budget", "extra_args", "priority",
354
+ }
314
355
  unknown = [key for key in keys if key not in allowed]
315
356
  if unknown:
316
357
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
@@ -600,9 +641,39 @@ def record_launch_history(store, name, payload):
600
641
  **payload,
601
642
  }
602
643
  store["append_launch_history"](entry)
644
+ _observe_codex_conversation(store, session)
603
645
  return entry
604
646
 
605
647
 
648
+ def _observe_codex_conversation(store, session):
649
+ """Read Codex's conversation id back after a run, if it wrote one.
650
+
651
+ Codex mints the id itself, so unlike Claude this can only be observed once
652
+ the rollout exists. Failing to find one is normal and leaves the session on
653
+ the recency resume path; it is never an error worth surfacing to a launch
654
+ that otherwise succeeded.
655
+ """
656
+ if session.get("provider") != PROVIDER_CODEX:
657
+ return
658
+ auth_home = session.get("authHome")
659
+ if not auth_home:
660
+ return
661
+ try:
662
+ identifier = find_latest_codex_conversation_id(auth_home)
663
+ except OSError:
664
+ return
665
+ if not identifier or identifier == (session.get("conversation") or {}).get("id"):
666
+ return
667
+ store["update_session"](session["name"], lambda s: {
668
+ **s,
669
+ "conversation": {
670
+ "id": identifier,
671
+ "provenance": "observed",
672
+ "recordedAt": _local_now_iso(),
673
+ },
674
+ })
675
+
676
+
606
677
  def get_launch_history(store, name=None, limit=20):
607
678
  if name and not store["get_session"](name):
608
679
  raise CdxError(f"Unknown session: {name}")
@@ -684,8 +755,60 @@ def launch_session(store, name):
684
755
  return {**state, "rehydratedAt": now}
685
756
 
686
757
  store["update_session_state"](name, state_updater)
758
+
759
+ def session_updater(s):
760
+ updated = {**s, "updatedAt": now, "lastLaunchedAt": now}
761
+ conversation = _conversation_identity_for_launch(s)
762
+ if conversation:
763
+ updated["conversation"] = conversation
764
+ return updated
765
+
766
+ return store["update_session"](name, session_updater)
767
+
768
+
769
+ def _conversation_identity_for_launch(session):
770
+ """The conversation id this launch will carry, or None to leave it alone.
771
+
772
+ Claude accepts a caller-supplied `--session-id`, so cdx mints one here,
773
+ before the launch spec is built, and knows the conversation's identity
774
+ without having to find it afterwards. A fresh id per launch rather than one
775
+ reused forever: a launch starts a new conversation, and `cdx resume` should
776
+ mean the last one, so the stored value is always the newest. Reusing a
777
+ single id across launches would also depend on how the provider treats a
778
+ repeated `--session-id`, which is a behaviour cdx does not need to rely on.
779
+
780
+ Codex has no equivalent flag; its identity is read back from the rollout
781
+ after the run, so nothing is minted here.
782
+ """
783
+ if session.get("provider") != PROVIDER_CLAUDE:
784
+ return None
785
+ return {
786
+ "id": str(uuid.uuid4()),
787
+ "provenance": "imposed",
788
+ "recordedAt": _local_now_iso(),
789
+ }
790
+
791
+
792
+ def begin_conversation(store, name):
793
+ """Mint the conversation id a Claude run is about to carry, and store it.
794
+
795
+ `launch_session` does this for interactive launches, but a headless run
796
+ never goes through it, so `cdx run` used to leave Claude sessions with no
797
+ identity at all - `cdx resume` then fell back to recency for Claude while
798
+ working by id for Codex, which records its own after the fact. Same shape
799
+ for both providers now: whatever ran last is what resume names.
800
+
801
+ Returns the updated session, or the unchanged one for providers that mint
802
+ nothing here.
803
+ """
804
+ session = store["get_session"](name)
805
+ if not session:
806
+ raise CdxError(f"Unknown session: {name}")
807
+ conversation = _conversation_identity_for_launch(session)
808
+ if not conversation:
809
+ return session
687
810
  return store["update_session"](name, lambda s: {
688
- **s, "updatedAt": now, "lastLaunchedAt": now
811
+ **s, "conversation": conversation, "updatedAt": _local_now_iso(),
689
812
  })
690
813
 
691
814
 
@@ -726,6 +849,7 @@ def create_session_service(options=None):
726
849
  "copy_session": partial(copy_session, base_dir, store),
727
850
  "rename_session": partial(rename_session, base_dir, store),
728
851
  "launch_session": partial(launch_session, store),
852
+ "begin_conversation": partial(begin_conversation, store),
729
853
  "active_session_runtime": partial(_session_runtime, store),
730
854
  "start_session_runtime": partial(start_session_runtime, store),
731
855
  "finish_session_runtime": partial(finish_session_runtime, store),
@@ -146,6 +146,18 @@ def _status_has_more_detail(candidate, current):
146
146
  ]
147
147
  return any(current.get(field) is None and candidate.get(field) is not None for field in fields)
148
148
 
149
+ def _later_status_timestamp(current_at, candidate_at):
150
+ if not candidate_at:
151
+ return current_at
152
+ if not current_at:
153
+ return candidate_at
154
+ parsed_current = _parse_status_timestamp(current_at)
155
+ parsed_candidate = _parse_status_timestamp(candidate_at)
156
+ if not parsed_current or not parsed_candidate:
157
+ return current_at
158
+ return candidate_at if parsed_candidate > parsed_current else current_at
159
+
160
+
149
161
  def _merge_status_payload(current, candidate):
150
162
  if not current:
151
163
  return candidate
@@ -175,7 +187,11 @@ def _merge_status_payload(current, candidate):
175
187
  if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
176
188
  merged["structured"] = bool(candidate.get("structured"))
177
189
 
178
- merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
190
+ # The merge is only reached when the candidate is not newer, so adopting its
191
+ # timestamp would date the record by its stalest contributor. That is not
192
+ # cosmetic: updated_at drives the freshness check, so a regressed stamp
193
+ # makes a just-resolved status read as expired and re-probes every call.
194
+ merged["updated_at"] = _later_status_timestamp(current.get("updated_at"), candidate.get("updated_at"))
179
195
  return merged
180
196
 
181
197
  def _compute_available_pct(status):
@@ -597,6 +597,51 @@ def extract_named_statuses_from_text(text):
597
597
  }
598
598
 
599
599
 
600
+ _ROLLOUT_NAME = re.compile(r"^rollout-.*-([0-9a-fA-F-]{36})\.jsonl$")
601
+
602
+
603
+ def find_latest_codex_conversation_id(root_dir):
604
+ """The conversation id of the newest rollout under a Codex home, or None.
605
+
606
+ Codex exposes no flag that imposes a conversation id, so the only way to
607
+ learn one is to read it back after the run. Each rollout opens with a
608
+ session-meta record carrying `payload.session_id`, and the filename repeats
609
+ it; the record is preferred and the filename is the fallback for a file
610
+ whose first line is truncated.
611
+
612
+ Returns None rather than guessing whenever the newest rollout yields
613
+ nothing usable: a session left on the recency path behaves as it does
614
+ today, while a wrong id would resume somebody else's conversation.
615
+ """
616
+ sessions_dir = os.path.join(root_dir, "sessions")
617
+ if not os.path.isdir(sessions_dir):
618
+ return None
619
+ newest = None
620
+ for dirpath, _dirnames, filenames in os.walk(sessions_dir):
621
+ for fname in filenames:
622
+ if not _ROLLOUT_NAME.match(fname):
623
+ continue
624
+ fp = os.path.join(dirpath, fname)
625
+ stat = _safe_stat(fp)
626
+ if stat and (newest is None or stat.st_mtime > newest[0]):
627
+ newest = (stat.st_mtime, fp)
628
+ if not newest:
629
+ return None
630
+
631
+ path = newest[1]
632
+ try:
633
+ with open(path, encoding="utf-8", errors="replace") as handle:
634
+ record = json.loads(handle.readline())
635
+ identifier = (record.get("payload") or {}).get("session_id")
636
+ if isinstance(identifier, str) and identifier.strip():
637
+ return identifier.strip()
638
+ except (OSError, ValueError):
639
+ pass
640
+
641
+ match = _ROLLOUT_NAME.match(os.path.basename(path))
642
+ return match.group(1) if match else None
643
+
644
+
600
645
  def _collect_candidate_files(root_dir):
601
646
  priority_candidates = []
602
647
  history_candidates = []