cdx-manager 0.14.0 → 0.15.1

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.
@@ -2,9 +2,12 @@ import json
2
2
  import os
3
3
  import sys
4
4
  import tempfile
5
+ import time
5
6
  from contextlib import contextmanager
6
7
  from datetime import datetime, timezone
7
8
 
9
+ from .errors import CdxError
10
+
8
11
 
9
12
  def utc_now_iso():
10
13
  return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
@@ -14,14 +17,49 @@ def registry_path(base_dir):
14
17
  return os.path.join(base_dir, "runs.json")
15
18
 
16
19
 
20
+ # A waiter waits. `fcntl.flock` blocks until the lock is free, but Windows'
21
+ # `LK_LOCK` retries ten times at one-second intervals and then raises
22
+ # EDEADLOCK, so the same contention produced a wait on one platform and an
23
+ # OSError on the other. Twenty parallel runs writing this registry is the load
24
+ # cdx exists to carry, and on a physical Windows machine it exhausted that
25
+ # budget - CI never showed it, being less contended than a real desktop.
26
+ REGISTRY_LOCK_TIMEOUT_SECONDS = 120
27
+ _LOCK_RETRY_SECONDS = 0.05
28
+
29
+
30
+ def _acquire_windows_lock(handle, timeout_seconds):
31
+ """Block until the byte is locked, or give up with a nameable error.
32
+
33
+ Uses the non-blocking form and does the waiting here, so the wait is
34
+ bounded by a deadline cdx chooses rather than by `LK_LOCK`'s fixed ten
35
+ attempts. Unbounded would be worse than the original: a stuck holder would
36
+ hang every later command with nothing to report.
37
+ """
38
+ import msvcrt
39
+
40
+ deadline = time.monotonic() + timeout_seconds
41
+ while True:
42
+ handle.seek(0)
43
+ try:
44
+ msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
45
+ return
46
+ except OSError:
47
+ if time.monotonic() >= deadline:
48
+ raise CdxError(
49
+ "Timed out waiting for the run registry lock after "
50
+ f"{timeout_seconds}s. Another cdx process may be stuck holding it.",
51
+ 75,
52
+ ) from None
53
+ time.sleep(_LOCK_RETRY_SECONDS)
54
+
55
+
17
56
  @contextmanager
18
- def _registry_lock(path):
57
+ def _registry_lock(path, timeout_seconds=REGISTRY_LOCK_TIMEOUT_SECONDS):
19
58
  os.makedirs(os.path.dirname(path), exist_ok=True)
20
59
  with open(path + ".lock", "a") as handle:
21
60
  if sys.platform == "win32":
22
61
  import msvcrt
23
- handle.seek(0)
24
- msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
62
+ _acquire_windows_lock(handle, timeout_seconds)
25
63
  try:
26
64
  yield
27
65
  finally:
@@ -101,6 +139,12 @@ def _refresh_stale_runs(data):
101
139
  for run in data["runs"]:
102
140
  if run.get("status") != "running":
103
141
  continue
142
+ if run.get("background_path") == "provider_native":
143
+ # The provider owns this agent's lifecycle and the pid it reports
144
+ # belongs to a daemon helper, not the run. Sweeping on pid liveness
145
+ # would mark a healthy delegated run stale; its terminal status
146
+ # comes from the provider instead.
147
+ continue
104
148
  pid = run.get("pid")
105
149
  if pid and _is_pid_alive(pid):
106
150
  continue
@@ -124,11 +168,31 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
124
168
  # the liveness proxy until finish() records the provider child's pid.
125
169
  "pid": os.getpid(),
126
170
  "started_at": utc_now_iso(),
171
+ # A failover run occupies several sessions in turn while staying one
172
+ # run. `session` and `provider` above name the current occupant so every
173
+ # existing reader keeps working; this is the ordered history behind
174
+ # them, and it exists from the start so no run is ever written in a
175
+ # shape the reporting cannot express.
176
+ "occupancies": [{
177
+ "session": session,
178
+ "provider": provider,
179
+ "started_at": utc_now_iso(),
180
+ "ended_at": None,
181
+ "reason": "initial_selection",
182
+ }],
127
183
  "ended_at": None,
128
184
  "duration_seconds": None,
129
185
  "exit_code": None,
130
186
  "usage": None,
131
187
  "artifacts": dict(artifacts or {}),
188
+ # Which mechanism is carrying a detached run, and the provider's own
189
+ # handle when it is the provider's. Present on every record so a reader
190
+ # never has to infer it from absence.
191
+ "background_path": None,
192
+ # Two handles, because the provider uses two: the session id names the
193
+ # transcript file, the short agent id is what `claude stop` accepts.
194
+ "provider_session_id": None,
195
+ "provider_agent_id": None,
132
196
  "error": None,
133
197
  "task_report": None,
134
198
  "final_payload": None,
@@ -166,6 +230,50 @@ class RunRegistry:
166
230
  return run
167
231
  return None
168
232
 
233
+ def record_background(self, run_id, *, path, provider_session_id=None, provider_agent_id=None):
234
+ with _registry_lock(self.path):
235
+ data = _read_registry(self.path)
236
+ for run in data["runs"]:
237
+ if run.get("run_id") != run_id:
238
+ continue
239
+ run["background_path"] = path
240
+ run["provider_session_id"] = provider_session_id
241
+ run["provider_agent_id"] = provider_agent_id
242
+ _write_registry(self.path, data)
243
+ return run
244
+ return None
245
+
246
+ def migrate(self, run_id, *, session, provider, reason):
247
+ """Move a still-running run onto another session, keeping one run_id.
248
+
249
+ Closes the current occupancy with the reason it ended and opens the
250
+ next one. The top-level `session`/`provider` follow the new occupant so
251
+ readers that know nothing about occupancies stay correct.
252
+ """
253
+ with _registry_lock(self.path):
254
+ data = _read_registry(self.path)
255
+ now = utc_now_iso()
256
+ for run in data["runs"]:
257
+ if run.get("run_id") != run_id:
258
+ continue
259
+ occupancies = run.get("occupancies") or []
260
+ if occupancies and occupancies[-1].get("ended_at") is None:
261
+ occupancies[-1]["ended_at"] = now
262
+ occupancies[-1]["reason"] = reason
263
+ occupancies.append({
264
+ "session": session,
265
+ "provider": provider,
266
+ "started_at": now,
267
+ "ended_at": None,
268
+ "reason": "failover_target",
269
+ })
270
+ run["occupancies"] = occupancies
271
+ run["session"] = session
272
+ run["provider"] = provider
273
+ _write_registry(self.path, data)
274
+ return run
275
+ return None
276
+
169
277
  def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
170
278
  with _registry_lock(self.path):
171
279
  return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
@@ -178,6 +286,10 @@ class RunRegistry:
178
286
  continue
179
287
  run["status"] = status
180
288
  run["ended_at"] = now
289
+ occupancies = run.get("occupancies") or []
290
+ if occupancies and occupancies[-1].get("ended_at") is None:
291
+ occupancies[-1]["ended_at"] = now
292
+ occupancies[-1]["reason"] = status
181
293
  if run.get("started_at"):
182
294
  try:
183
295
  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),
@@ -79,6 +79,7 @@ def _normalize_status_payload(payload=None):
79
79
  "reset_5h_at": payload.get("reset_5h_at"),
80
80
  "reset_week_at": payload.get("reset_week_at"),
81
81
  "reset_at": payload.get("reset_at") or payload.get("reset_week_at") or payload.get("reset_5h_at"),
82
+ "rate_limit_reached": payload.get("rate_limit_reached"),
82
83
  "updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
83
84
  "raw_status_text": payload.get("raw_status_text"),
84
85
  "source_ref": payload.get("source_ref"),
@@ -146,6 +147,18 @@ def _status_has_more_detail(candidate, current):
146
147
  ]
147
148
  return any(current.get(field) is None and candidate.get(field) is not None for field in fields)
148
149
 
150
+ def _later_status_timestamp(current_at, candidate_at):
151
+ if not candidate_at:
152
+ return current_at
153
+ if not current_at:
154
+ return candidate_at
155
+ parsed_current = _parse_status_timestamp(current_at)
156
+ parsed_candidate = _parse_status_timestamp(candidate_at)
157
+ if not parsed_current or not parsed_candidate:
158
+ return current_at
159
+ return candidate_at if parsed_candidate > parsed_current else current_at
160
+
161
+
149
162
  def _merge_status_payload(current, candidate):
150
163
  if not current:
151
164
  return candidate
@@ -175,7 +188,11 @@ def _merge_status_payload(current, candidate):
175
188
  if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
176
189
  merged["structured"] = bool(candidate.get("structured"))
177
190
 
178
- merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
191
+ # The merge is only reached when the candidate is not newer, so adopting its
192
+ # timestamp would date the record by its stalest contributor. That is not
193
+ # cosmetic: updated_at drives the freshness check, so a regressed stamp
194
+ # makes a just-resolved status read as expired and re-probes every call.
195
+ merged["updated_at"] = _later_status_timestamp(current.get("updated_at"), candidate.get("updated_at"))
179
196
  return merged
180
197
 
181
198
  def _compute_available_pct(status):
@@ -153,6 +153,13 @@ def _extract_structured_rate_limits(record):
153
153
  "reset_5h_at": _normalize_timestamp_value(_get_rate_limit_reset_at(five_hour)),
154
154
  "reset_week_at": _normalize_timestamp_value(_get_rate_limit_reset_at(weekly)),
155
155
  "reset_at": None,
156
+ # A typed reason the account is blocked, when the provider gives one:
157
+ # null in normal operation, and a value such as
158
+ # "workspace_owner_credits_depleted" when a limit is actually reached.
159
+ # Better evidence than any percentage - credits can run out while the
160
+ # 5-hour and weekly windows still read healthy - and better than
161
+ # matching prose, since it is an enum rather than a sentence.
162
+ "rate_limit_reached": _clean_reached_type(rate_limits.get("rate_limit_reached_type")),
156
163
  "raw_status_text": json.dumps(rate_limits, sort_keys=True),
157
164
  }
158
165
  if not five_hour and weekly:
@@ -172,6 +179,13 @@ def _extract_structured_rate_limits(record):
172
179
  return result
173
180
 
174
181
 
182
+ def _clean_reached_type(value):
183
+ if not isinstance(value, str):
184
+ return None
185
+ text = value.strip()
186
+ return text or None
187
+
188
+
175
189
  def _collect_structured_rate_limit_statuses(value, output=None):
176
190
  if output is None:
177
191
  output = []
@@ -597,6 +611,51 @@ def extract_named_statuses_from_text(text):
597
611
  }
598
612
 
599
613
 
614
+ _ROLLOUT_NAME = re.compile(r"^rollout-.*-([0-9a-fA-F-]{36})\.jsonl$")
615
+
616
+
617
+ def find_latest_codex_conversation_id(root_dir):
618
+ """The conversation id of the newest rollout under a Codex home, or None.
619
+
620
+ Codex exposes no flag that imposes a conversation id, so the only way to
621
+ learn one is to read it back after the run. Each rollout opens with a
622
+ session-meta record carrying `payload.session_id`, and the filename repeats
623
+ it; the record is preferred and the filename is the fallback for a file
624
+ whose first line is truncated.
625
+
626
+ Returns None rather than guessing whenever the newest rollout yields
627
+ nothing usable: a session left on the recency path behaves as it does
628
+ today, while a wrong id would resume somebody else's conversation.
629
+ """
630
+ sessions_dir = os.path.join(root_dir, "sessions")
631
+ if not os.path.isdir(sessions_dir):
632
+ return None
633
+ newest = None
634
+ for dirpath, _dirnames, filenames in os.walk(sessions_dir):
635
+ for fname in filenames:
636
+ if not _ROLLOUT_NAME.match(fname):
637
+ continue
638
+ fp = os.path.join(dirpath, fname)
639
+ stat = _safe_stat(fp)
640
+ if stat and (newest is None or stat.st_mtime > newest[0]):
641
+ newest = (stat.st_mtime, fp)
642
+ if not newest:
643
+ return None
644
+
645
+ path = newest[1]
646
+ try:
647
+ with open(path, encoding="utf-8", errors="replace") as handle:
648
+ record = json.loads(handle.readline())
649
+ identifier = (record.get("payload") or {}).get("session_id")
650
+ if isinstance(identifier, str) and identifier.strip():
651
+ return identifier.strip()
652
+ except (OSError, ValueError):
653
+ pass
654
+
655
+ match = _ROLLOUT_NAME.match(os.path.basename(path))
656
+ return match.group(1) if match else None
657
+
658
+
600
659
  def _collect_candidate_files(root_dir):
601
660
  priority_candidates = []
602
661
  history_candidates = []