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.
- package/README.md +84 -18
- package/changelogs/CHANGELOGS_0_15_0.md +139 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli_args.py +51 -7
- package/src/cli_helpers.py +13 -0
- package/src/cli_render.py +17 -0
- package/src/commands/runs.py +306 -19
- package/src/commands/status.py +41 -9
- package/src/config.py +29 -0
- package/src/provider_background.py +263 -0
- package/src/provider_runtime.py +96 -4
- package/src/run_failover.py +221 -0
- package/src/run_registry.py +74 -0
- package/src/session_service.py +126 -2
- package/src/session_status.py +17 -1
- package/src/status_source.py +45 -0
package/src/run_registry.py
CHANGED
|
@@ -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"))
|
package/src/session_service.py
CHANGED
|
@@ -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 = {
|
|
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, "
|
|
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),
|
package/src/session_status.py
CHANGED
|
@@ -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
|
-
|
|
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):
|
package/src/status_source.py
CHANGED
|
@@ -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 = []
|