cdx-manager 0.25.1 → 0.25.2

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.
@@ -5,7 +5,6 @@ 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
9
8
  import subprocess
10
9
  import sys
11
10
  import time
@@ -58,7 +57,7 @@ from ..run_failover import (
58
57
  looks_model_at_capacity,
59
58
  )
60
59
  from ..run_registry import RunRegistry, build_code_review_report, final_message
61
- from ..run_usage import empty_usage, estimate_cost, extract_run_conversation_id, extract_run_observation, token_prices
60
+ from ..run_usage import cost_fields, empty_usage, estimate_cost, extract_run_conversation_id, extract_run_observation, token_prices
62
61
  from ..session_ranking import FACTOR_DESCRIPTIONS, rank_sessions, selection_policy
63
62
  from ..status_view import _now_timestamp, _priority_reset_timestamp
64
63
 
@@ -204,6 +203,7 @@ def _detached_child_argv(parsed, prompt_path, session_name, cwd):
204
203
  # from the package root, so a relative "--cwd ." would resolve there.
205
204
  argv = ["run", session_name, "--cwd", cwd, "--prompt-file", prompt_path]
206
205
  for flag, key in (
206
+ ("--project", "project"),
207
207
  ("--budget", "budget"),
208
208
  ("--continue-run", "continue_run"),
209
209
  ("--model", "model"),
@@ -423,6 +423,8 @@ def _reconcile_background_runs(ctx):
423
423
  "run_id": run["run_id"],
424
424
  "attempt": 1,
425
425
  "cwd": run.get("cwd"),
426
+ "project": run.get("project"),
427
+ "project_source": run.get("project_source", "unknown"),
426
428
  "usage": outcome["usage"],
427
429
  "usage_model": outcome["usage_model"],
428
430
  })
@@ -544,9 +546,19 @@ def _env_max_runs(ctx):
544
546
 
545
547
 
546
548
  def _observe_headless_usage(session, run_info, baseline=None):
547
- usage, model = extract_run_observation(session.get("provider"), run_info.get("stdout_path"), session.get("authHome"))
548
- observation = {"usage": usage, "usage_model": model}
549
- if baseline is not None:
549
+ isolated = (session.get("launch") or {}).get("isolate_workspace")
550
+ evidence = run_info.get("usage_evidence")
551
+ observed_models = []
552
+ usage, model = extract_run_observation(session.get("provider"), run_info.get("stdout_path"),
553
+ None if isolated else session.get("authHome"), evidence=evidence, models_out=observed_models)
554
+ raw_measured = usage.get("total_tokens") is not None
555
+ observation = {"usage": usage, "usage_model": model, "usage_models": observed_models}
556
+ if evidence is not None:
557
+ observation["usage_evidence"] = evidence
558
+ if baseline is not None and isolated:
559
+ observation.update(usage=empty_usage(), usage_model=None, usage_attribution="continuation_usage_unavailable",
560
+ measurement_unavailable=raw_measured)
561
+ elif baseline is not None:
550
562
  cumulative, path, match, transcript_model = extract_interactive_usage(
551
563
  session["provider"], session.get("authHome"), conversation_id=(session.get("conversation") or {}).get("id"))
552
564
  before, before_path, _before_match, _before_model = baseline
@@ -559,13 +571,18 @@ def _observe_headless_usage(session, run_info, baseline=None):
559
571
  # establish this run's share; stdout cannot replace a missing boundary.
560
572
  observation.update(usage=delta if delta is not None else empty_usage(), usage_cumulative=cumulative,
561
573
  usage_attribution="launch_boundary" if delta is not None else "continuation_usage_unavailable",
574
+ measurement_unavailable=delta is None and bool(raw_measured or (cumulative and cumulative.get("total_tokens") is not None)),
562
575
  provider_transcript_path=path, provider_transcript_match=match,
563
- usage_model=model or transcript_model)
576
+ usage_model=model if session.get("provider") == "codex" else model or transcript_model)
577
+ observation.update(cost_fields(observation["usage"], observation["usage_model"],
578
+ models=observed_models, attribution=observation.get("usage_attribution")))
564
579
  return observation
565
580
 
566
581
 
567
582
  def _budget_observation(session, paths, baseline):
568
583
  observation = _observe_headless_usage(session, paths, baseline if session["provider"] == "codex" else None)
584
+ if (session.get("launch") or {}).get("isolate_workspace"):
585
+ return observation
569
586
  identifier = (extract_run_conversation_id(session["provider"], paths.get("stdout_path"))
570
587
  or ((session.get("conversation") or {}).get("id")
571
588
  if session["provider"] == "claude" or session.get("continue_run") else None))
@@ -580,8 +597,14 @@ def _budget_observation(session, paths, baseline):
580
597
  comparable = (before and path == before_path
581
598
  and all((usage.get(key) is None) == (before.get(key) is None) for key in MEASURED_KEYS))
582
599
  usage = usage_delta(usage, before, provider=session["provider"]) if comparable else None
600
+ if usage is not None and session["provider"] == "codex":
601
+ from ..run_usage import _codex_run_models
602
+ models = _codex_run_models([{"type": "thread.started", "thread_id": identifier}], session.get("authHome"))
603
+ model = next(iter(models)) if len(models) == 1 else None
583
604
  if usage is not None:
584
- return {**observation, "usage": usage, "usage_model": model, "provider_transcript_path": path,
605
+ observed_models = sorted(models) if session["provider"] == "codex" else [model] if model else []
606
+ return {**observation, "usage": usage, "usage_model": model, "usage_models": observed_models,
607
+ **cost_fields(usage, model, models=observed_models), "provider_transcript_path": path,
585
608
  "provider_transcript_match": match}
586
609
  return observation
587
610
 
@@ -629,6 +652,12 @@ def _continue_run_session(parsed, ctx):
629
652
  if arg.split("=", 1)[0] in pinned_options or arg.startswith(("-r", "-c")):
630
653
  raise CdxError("--continue-run cannot use Claude extra_args that override model, effort, or conversation.",
631
654
  code="continue_run_settings_mismatch")
655
+ from ..project_identity import recorded_project
656
+
657
+ project = recorded_project(previous)
658
+ if parsed.get("project") and parsed["project"] != project:
659
+ raise CdxError("--continue-run requires the previous run's recorded project.")
660
+ parsed["continued_project"] = {"project": project, "project_source": previous.get("project_source", "unknown")}
632
661
  parsed.update(name=name, model=model, reasoning_effort=effort, power=effort)
633
662
  launch = {**(session.get("launch") or {}), "model": model, "reasoning_effort": effort, "power": effort}
634
663
  # A provider fallback would break the model pin even though cdx stayed on this profile.
@@ -684,6 +713,14 @@ def handle_run(rest, ctx):
684
713
  cwd = os.path.abspath(parsed["cwd"])
685
714
  if not os.path.isdir(cwd):
686
715
  raise CdxError(f"Invalid cwd: {parsed['cwd']}", code="invalid_cwd")
716
+ from ..project_identity import resolve_project
717
+
718
+ project = parsed.get("continued_project") or resolve_project(cwd, parsed.get("project"))
719
+ if detached_run_id:
720
+ previous = _run_registry(ctx).get(detached_run_id)
721
+ if previous:
722
+ project = {"project": previous.get("project"), "project_source": previous.get("project_source", "unknown")}
723
+ parsed.update(project)
687
724
  prompt = read_run_prompt(parsed, stdin=ctx.get("prompt_stdin"))
688
725
  _consume_detached_prompt_file(parsed, detached_run_id)
689
726
  launch_updates = {key: parsed.get(key, False) for key in ("network_access", "isolate_workspace")}
@@ -743,6 +780,7 @@ def handle_run(rest, ctx):
743
780
  provider=run_session.get("provider"),
744
781
  model=parsed.get("model") or ((run_session.get("launch") or {}).get("model")),
745
782
  cwd=cwd,
783
+ **project,
746
784
  artifacts={
747
785
  "transcript_path": artifacts.get("transcript_path"),
748
786
  "stdout_path": artifacts.get("stdout_path"),
@@ -797,23 +835,35 @@ def handle_run(rest, ctx):
797
835
  failover_error_code = None
798
836
  recovery_events = []
799
837
  attempt = 0
838
+ attempt_observations = []
800
839
  spent_usd = 0.0
801
840
  models = capacity_models(run_session)
802
841
  model_index = 0
803
842
  capacity_retries = 0
804
843
  while True:
805
844
  attempt += 1
806
- baseline = extract_interactive_usage(
807
- run_session["provider"], run_session.get("authHome"),
808
- conversation_id=(run_session.get("conversation") or {}).get("id"),
809
- ) if parsed.get("continue_run") and (run_session["provider"] == "codex" or parsed.get("budget")) else None
845
+ registry.record_attempt(run_id, _headless_artifact_paths(run_session, run_id=run_id, attempt=attempt))
846
+ baseline = None
847
+ if parsed.get("continue_run") and (run_session["provider"] == "codex" or parsed.get("budget")):
848
+ if (run_session.get("launch") or {}).get("isolate_workspace"):
849
+ baseline = (None, None, None, None)
850
+ else:
851
+ baseline = extract_interactive_usage(
852
+ run_session["provider"], run_session.get("authHome"),
853
+ conversation_id=(run_session.get("conversation") or {}).get("id"))
810
854
  attempt_cost = None
811
855
  budget_observation = None
856
+ budget_unverifiable = False
812
857
 
813
858
  def check_budget(paths, session=run_session, before=baseline, already_spent=spent_usd):
814
- nonlocal attempt_cost, budget_observation
859
+ nonlocal attempt_cost, budget_observation, budget_unverifiable
815
860
  observation = _budget_observation(session, paths, before)
816
861
  cost = estimate_cost(observation["usage"], observation["usage_model"], budget_prices)
862
+ measured = observation["usage"].get("total_tokens") is not None or observation.get("measurement_unavailable", False)
863
+ if cost is None and measured:
864
+ budget_unverifiable = True
865
+ budget_observation = observation
866
+ return True
817
867
  if cost is not None:
818
868
  if attempt_cost is None or cost >= attempt_cost:
819
869
  attempt_cost = cost
@@ -828,6 +878,7 @@ def handle_run(rest, ctx):
828
878
  timeout_seconds=parsed.get("timeout_seconds"),
829
879
  spawn=ctx.get("spawn_headless") or ctx.get("spawn"),
830
880
  run_id=run_id,
881
+ attempt=attempt,
831
882
  **({"budget_check": check_budget} if parsed.get("budget") is not None else {}),
832
883
  )
833
884
  observation = _observe_headless_usage(run_session, run_info, baseline if run_session["provider"] == "codex" else None)
@@ -843,8 +894,20 @@ def handle_run(rest, ctx):
843
894
  "recovery_events": recovery_events}
844
895
  cost = estimate_cost(observation["usage"], observation["usage_model"], budget_prices)
845
896
  spent_usd += max(attempt_cost or 0.0, cost or 0.0)
846
- if parsed.get("budget") is not None and (run_info.get("budget_exceeded") or spent_usd >= parsed["budget"]):
897
+ if parsed.get("budget") is not None and (budget_unverifiable or (
898
+ cost is None and (observation["usage"].get("total_tokens") is not None or observation.get("measurement_unavailable")))):
899
+ run_info.update(budget_unverifiable=True, budget_exceeded=False, returncode=125,
900
+ cost_usd=None, cost_status="unknown", budget_status="unverifiable")
901
+ elif parsed.get("budget") is not None and (run_info.get("budget_exceeded") or spent_usd >= parsed["budget"]):
847
902
  run_info.update(budget_exceeded=True, returncode=125, cost_usd=spent_usd)
903
+ attempt_observations.append({
904
+ "attempt": attempt, "attempt_id": run_info.get("attempt_id"),
905
+ "session": run_session["name"], "provider": run_session["provider"],
906
+ **{key: run_info.get(key) for key in ("usage", "usage_model", "usage_models", "usage_evidence")},
907
+ **cost_fields(observation["usage"], observation["usage_model"], budget_prices,
908
+ models=observation.get("usage_models", []), attribution=observation.get("usage_attribution")),
909
+ })
910
+ run_info["attempts"] = list(attempt_observations)
848
911
  ctx["service"]["record_launch_history"](run_session["name"], {
849
912
  "status": "success" if run_info.get("returncode") == 0 else "failed",
850
913
  "action": "run",
@@ -853,8 +916,9 @@ def handle_run(rest, ctx):
853
916
  "cwd": cwd,
854
917
  "exit_code": run_info.get("returncode"),
855
918
  **run_info,
919
+ **project,
856
920
  })
857
- if run_info.get("budget_exceeded") or parsed.get("continue_run"):
921
+ if run_info.get("budget_exceeded") or run_info.get("budget_unverifiable") or parsed.get("continue_run"):
858
922
  break
859
923
  if looks_model_at_capacity(run_session.get("provider"), run_info):
860
924
  event = {"reason": "model_at_capacity", "attempt": attempt,
@@ -979,7 +1043,10 @@ def handle_run(rest, ctx):
979
1043
  )
980
1044
  _write_json(ctx, final_payload)
981
1045
  return 0
982
- if run_info.get("budget_exceeded"):
1046
+ if run_info.get("budget_unverifiable"):
1047
+ failover_error_code = "budget_unverifiable"
1048
+ message = "Measured usage cannot be priced; USD budget enforcement is unavailable. Check cost_reason and observed model evidence."
1049
+ elif run_info.get("budget_exceeded"):
983
1050
  failover_error_code = "budget_exceeded"
984
1051
  message = "Run exceeded its estimated USD budget."
985
1052
  elif failover_error_code == "capacity_recovery_exhausted":
@@ -1000,7 +1067,7 @@ def handle_run(rest, ctx):
1000
1067
  run_session,
1001
1068
  run_info=run_info,
1002
1069
  error=error,
1003
- error_source="cdx" if run_info.get("budget_exceeded") else "provider",
1070
+ error_source="cdx" if run_info.get("budget_exceeded") or run_info.get("budget_unverifiable") else "provider",
1004
1071
  # Running out of accounts is a different outcome from the task
1005
1072
  # failing, and a caller has to be able to branch on which happened.
1006
1073
  error_code=failover_error_code or (
@@ -1038,6 +1105,7 @@ def handle_run(rest, ctx):
1038
1105
  "cwd": cwd,
1039
1106
  "exit_code": error.exit_code,
1040
1107
  **run_info,
1108
+ **project,
1041
1109
  })
1042
1110
  final_payload = run_result_payload(
1043
1111
  API_SCHEMA_VERSION,
@@ -1065,14 +1133,10 @@ def handle_run(rest, ctx):
1065
1133
  return error.exit_code
1066
1134
 
1067
1135
  def _record_capacity_recovery(ctx, registry, run_id, event, run_info, events):
1068
- # Subsequent attempts reuse the run's paths; retain the failed output first.
1069
- artifacts = {}
1070
- for key in ("stdout_path", "stderr_path", "transcript_path"):
1071
- path = run_info.get(key)
1072
- if path and os.path.isfile(path):
1073
- archived = f"{path}.attempt-{event['attempt']}"
1074
- shutil.copyfile(path, archived)
1075
- artifacts[key] = archived
1136
+ # Attempt paths are immutable; recovery events reference the same evidence
1137
+ # as the history row instead of copying output that a retry will overwrite.
1138
+ artifacts = {key: run_info[key] for key in ("stdout_path", "stderr_path", "transcript_path")
1139
+ if run_info.get(key) and os.path.isfile(run_info[key])}
1076
1140
  event["artifacts"] = artifacts
1077
1141
  events.append(event)
1078
1142
  registry.record_recovery(run_id, event)
@@ -55,6 +55,7 @@ from ..rtk_savings import rtk_saved
55
55
  from ..run_usage import (
56
56
  TOKEN_USAGE_KEYS,
57
57
  cache_hit_ratio,
58
+ cost_fields,
58
59
  estimate_cost,
59
60
  format_cache_ratio,
60
61
  is_vouched,
@@ -287,6 +288,7 @@ def _summarize_stats(entries):
287
288
  "unweighted_runs": 0,
288
289
  "priced_runs": 0,
289
290
  "unpriced_models": set(),
291
+ "unpriced_reasons": {},
290
292
  "unvouched_runs": 0,
291
293
  "input_tokens": 0,
292
294
  "cached_input_tokens": 0,
@@ -342,7 +344,10 @@ def _summarize_stats(entries):
342
344
  if cost is not None:
343
345
  row["cost_usd"] += cost
344
346
  row["priced_runs"] += 1
345
- elif entry.get("usage_model"):
347
+ else:
348
+ reason = cost_fields(usage, entry.get("usage_model"), models=entry.get("usage_models", (entry.get("usage_evidence") or {}).get("models", [])), attribution=entry.get("usage_attribution"))["cost_reason"]
349
+ row["unpriced_reasons"][reason] = row["unpriced_reasons"].get(reason, 0) + 1
350
+ if cost is None and entry.get("usage_model"):
346
351
  # Named the model but could not price it. Recording which one
347
352
  # turns an unexplained "-" into something actionable: that
348
353
  # string is exactly what CDX_TOKEN_PRICES needs a key for.
@@ -524,9 +529,11 @@ def _format_rtk_saved(rtk):
524
529
 
525
530
 
526
531
  def _stats_group(entry, by):
527
- """The project (launch folder) or the model a run is counted under."""
532
+ """The recorded source repository or serving model a run is counted under."""
528
533
  if by == "project":
529
- return str(entry.get("cwd") or "").rstrip("/\\") or "-"
534
+ from ..project_identity import recorded_project
535
+
536
+ return recorded_project(entry) or "-"
530
537
  model = entry.get("usage_model") or "-"
531
538
  return model.removesuffix("[1m]") if model.startswith("claude-") else model
532
539
 
@@ -640,6 +647,26 @@ def handle_configs(rest, ctx):
640
647
  return 0
641
648
 
642
649
  def handle_history(rest, ctx):
650
+ if rest and rest[0] == "backfill-projects":
651
+ usage = "Usage: cdx history backfill-projects [--mapping FILE] [--dry-run | --apply] [--json]"
652
+ parsed = _parse_flag_args(rest[1:], {
653
+ "--mapping": {"key": "mapping", "type": "str", "default": None},
654
+ "--dry-run": {"key": "dry_run", "type": "bool", "default": False},
655
+ "--apply": {"key": "apply", "type": "bool", "default": False},
656
+ "--json": {"key": "json", "type": "bool", "default": False},
657
+ }, usage)
658
+ if parsed["apply"] and parsed["dry_run"]:
659
+ raise CdxError(usage)
660
+ result = ctx["service"]["backfill_history_projects"](mapping_path=parsed["mapping"], dry_run=not parsed["apply"])
661
+ if parsed["json"]:
662
+ payload = (_json_failure("history", "project_attribution_conflict", "Conflicting project evidence; no changes applied.", **result)
663
+ if result["conflicting"] else _json_success("history", "Backfilled history projects", **result))
664
+ _write_json(ctx, payload)
665
+ else:
666
+ mode = "Dry run" if result["dry_run"] else "Not applied (conflicts)" if result["conflicting"] else "Applied"
667
+ ctx["out"](f"{mode}: {result['recovered']} recovered; {result['unchanged']} unchanged; "
668
+ f"{result['unresolved']} unresolved; {result['conflicting']} conflicting.\n")
669
+ return 1 if result["conflicting"] else 0
643
670
  if rest and rest[0] == "backfill-models":
644
671
  usage = "Usage: cdx history backfill-models [--dry-run | --apply] [--json]"
645
672
  parsed = _parse_flag_args(rest[1:], {
@@ -672,7 +699,8 @@ def handle_history(rest, ctx):
672
699
  entries = ctx["service"]["get_launch_history"](parsed["name"], limit=limit)
673
700
  entries = _filter_history_period(entries, parsed["period"])
674
701
  entries = [{**entry, "parent_run_id": entry.get("parent_run_id"),
675
- "cost_usd": estimate_cost(entry.get("usage"), entry.get("usage_model")) if is_vouched(entry.get("usage")) else None,
702
+ **cost_fields(entry.get("usage") if is_vouched(entry.get("usage")) else None, entry.get("usage_model"),
703
+ models=entry.get("usage_models", (entry.get("usage_evidence") or {}).get("models", [])), attribution=entry.get("usage_attribution")),
676
704
  "usage": {**entry["usage"], "cache_hit_ratio": cache_hit_ratio(entry["usage"], provider=entry.get("provider")) if is_vouched(entry["usage"]) else None}
677
705
  if isinstance(entry.get("usage"), dict) else entry.get("usage")} for entry in entries]
678
706
  if has_period and not parsed["summary"]:
@@ -7,7 +7,7 @@ import time
7
7
  from .interactive_usage import MAX_TRANSCRIPT_BYTES, MAX_TRANSCRIPT_SCAN_SECONDS
8
8
  from .run_registry import registry_path
9
9
 
10
- EVIDENCE_KINDS = ("stdout", "run_report", "codex_rollout", "claude_transcript")
10
+ EVIDENCE_KINDS = ("isolated_observation", "stdout", "run_report", "codex_rollout", "claude_transcript")
11
11
 
12
12
 
13
13
  def missing_model(value):
@@ -108,6 +108,15 @@ def _exact_transcript(provider, auth_home, identifier):
108
108
  def recover_model(entry, session=None, reports=()):
109
109
  """Return (model, evidence kind, reason); never consult live model settings."""
110
110
  provider = entry.get("provider")
111
+ evidence = entry.get("usage_evidence")
112
+ if isinstance(evidence, dict):
113
+ if (evidence.get("stdout_path") != entry.get("stdout_path")
114
+ or evidence.get("attempt_id") != entry.get("attempt_id")
115
+ or evidence.get("run_id") != entry.get("run_id")
116
+ or (evidence.get("measurement_usage") or evidence.get("usage")) != entry.get("usage")):
117
+ return None, "isolated_observation", "observation_attempt_mismatch"
118
+ model, reason = _single_model(evidence.get("models") or [])
119
+ return model, "isolated_observation", reason or (None if model else "no_turn_models")
111
120
  records, error = _read_records(entry.get("stdout_path"))
112
121
  if error:
113
122
  return None, None, error
@@ -0,0 +1,71 @@
1
+ """Repository attribution captured by the trusted launcher, separate from cwd."""
2
+
3
+ import json
4
+ import ntpath
5
+ import os
6
+ import posixpath
7
+ import subprocess
8
+ from pathlib import PureWindowsPath
9
+
10
+ from .errors import CdxError
11
+ from .tray_logics import _repository_root
12
+
13
+ PROJECT_SOURCES = {"git", "operator", "operator_mapping", "run_report"}
14
+
15
+
16
+ def canonical_project(value):
17
+ """Validate recorded identities without consulting a possibly deleted checkout."""
18
+ return (isinstance(value, str) and bool(value.strip()) and "\x00" not in value
19
+ and ((posixpath.isabs(value) and posixpath.normpath(value) == value)
20
+ or (PureWindowsPath(value).is_absolute() and ntpath.normpath(value) == value)))
21
+
22
+
23
+ def recorded_project(entry):
24
+ value = entry.get("project")
25
+ source = entry.get("project_source")
26
+ return value if canonical_project(value) and isinstance(source, str) and source in PROJECT_SOURCES else None
27
+
28
+
29
+ def resolve_project(cwd, explicit=None):
30
+ """Resolve before isolation. Never infer clone provenance from remote URLs."""
31
+ if explicit is not None:
32
+ if not canonical_project(explicit):
33
+ raise CdxError("--project must be an absolute canonical source repository path.")
34
+ return {"project": explicit, "project_source": "operator"}
35
+ root = _repository_root(cwd)
36
+ if root:
37
+ if os.path.isdir(os.path.join(root, ".git")):
38
+ return {"project": root, "project_source": "git"}
39
+ try:
40
+ # Ignore ambient Git overrides: this lookup belongs to the launch cwd.
41
+ env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
42
+ common = subprocess.run(["git", "-C", root, "rev-parse", "--git-common-dir"],
43
+ env=env, capture_output=True, text=True, timeout=5, check=True).stdout.strip()
44
+ common = os.path.realpath(os.path.join(root, common))
45
+ if os.path.isdir(common):
46
+ return {"project": os.path.dirname(common) if os.path.basename(common) == ".git" else common,
47
+ "project_source": "git"}
48
+ except (OSError, subprocess.SubprocessError):
49
+ pass
50
+ return {"project": None, "project_source": "unknown"}
51
+
52
+
53
+ def load_mapping(path):
54
+ try:
55
+ with open(path, encoding="utf-8") as handle:
56
+ data = json.load(handle)
57
+ except (OSError, ValueError) as error:
58
+ raise CdxError(f"Cannot read project mapping: {error}") from error
59
+ if (not isinstance(data, dict) or set(data) != {"version", "projects"}
60
+ or type(data["version"]) is not int or data["version"] != 1 or not isinstance(data["projects"], list)):
61
+ raise CdxError("Invalid project mapping: expected version 1 and a projects array.")
62
+ projects = {}
63
+ for item in data["projects"]:
64
+ if (not isinstance(item, dict) or set(item) != {"run_id", "project"}
65
+ or not isinstance(item["run_id"], str) or not item["run_id"].strip()
66
+ or not canonical_project(item["project"])):
67
+ raise CdxError("Invalid project mapping: each entry needs an exact run_id and absolute canonical project path.")
68
+ if item["run_id"] in projects and projects[item["run_id"]] != item["project"]:
69
+ raise CdxError(f"Conflicting project mappings for run {item['run_id']}.")
70
+ projects[item["run_id"]] = item["project"]
71
+ return projects
@@ -1068,13 +1068,15 @@ def _build_headless_launch_spec(session, cwd=None, env_override=None, initial_pr
1068
1068
  )
1069
1069
 
1070
1070
 
1071
- def _headless_artifact_paths(session, run_id=None):
1071
+ def _headless_artifact_paths(session, run_id=None, attempt=1):
1072
1072
  run_id = run_id or uuid.uuid4().hex
1073
1073
  log_dir = _get_launch_transcript_dir(session)
1074
1074
  os.makedirs(log_dir, exist_ok=True)
1075
- prefix = os.path.join(log_dir, f"cdx-run-{run_id}")
1075
+ attempt_id = f"{run_id}:{attempt}"
1076
+ prefix = os.path.join(log_dir, f"cdx-run-{run_id}-attempt-{attempt}")
1076
1077
  return {
1077
1078
  "run_id": run_id,
1079
+ "attempt_id": attempt_id,
1078
1080
  "transcript_path": os.path.abspath(f"{prefix}.log"),
1079
1081
  "stdout_path": os.path.abspath(f"{prefix}.stdout.log"),
1080
1082
  "stderr_path": os.path.abspath(f"{prefix}.stderr.log"),
@@ -1205,7 +1207,23 @@ def _read_permission_denials(stdout_path):
1205
1207
 
1206
1208
 
1207
1209
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
1208
- timeout_seconds=None, spawn=None, run_id=None, budget_check=None):
1210
+ timeout_seconds=None, spawn=None, run_id=None, budget_check=None, attempt=1):
1211
+ import tempfile
1212
+
1213
+ isolated_codex = session.get("provider") == PROVIDER_CODEX and (session.get("launch") or {}).get("isolate_workspace")
1214
+ if isolated_codex:
1215
+ # Volatile storage only: raw rollouts must not survive closeout, including
1216
+ # cancellation/start failures. Only this attempt's sessions are mounted.
1217
+ with tempfile.TemporaryDirectory(prefix="cdx-observation-", dir="/dev/shm" if sys.platform.startswith("linux") else None) as directory:
1218
+ os.mkdir(os.path.join(directory, "sessions"), mode=0o700)
1219
+ return _run_headless_provider_attempt(session, cwd, env_override, initial_prompt,
1220
+ timeout_seconds, spawn, run_id, budget_check, directory, attempt)
1221
+ return _run_headless_provider_attempt(session, cwd, env_override, initial_prompt,
1222
+ timeout_seconds, spawn, run_id, budget_check, attempt=attempt)
1223
+
1224
+
1225
+ def _run_headless_provider_attempt(session, cwd=None, env_override=None, initial_prompt=None,
1226
+ timeout_seconds=None, spawn=None, run_id=None, budget_check=None, observation_home=None, attempt=1):
1209
1227
  spawn = spawn or subprocess.Popen
1210
1228
  spec = _build_headless_launch_spec(
1211
1229
  session,
@@ -1249,13 +1267,22 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1249
1267
  network=launch.get("network_access", False),
1250
1268
  writable=launch.get("permission") != "review",
1251
1269
  source=spec["options"]["env"], auth_home=_get_auth_home(session),
1252
- provider=session.get("provider"))
1270
+ provider=session.get("provider"),
1271
+ **({"observation_home": os.path.join(observation_home, "sessions")} if observation_home else {}))
1253
1272
  except (OSError, RuntimeError) as error:
1254
1273
  raise CdxError(str(error)) from error
1255
1274
  spec.update(command=argv[0], args=argv[1:])
1256
1275
  spec["options"]["env"] = child_env
1257
- paths = _headless_artifact_paths(session, run_id=run_id)
1276
+ paths = _headless_artifact_paths(session, run_id=run_id, attempt=attempt)
1258
1277
  start_time = datetime.now(timezone.utc)
1278
+
1279
+ def snapshot():
1280
+ if observation_home:
1281
+ from .run_observation import codex_attempt_evidence
1282
+ paths["usage_evidence"] = codex_attempt_evidence(
1283
+ observation_home, paths["stdout_path"], start_time.isoformat())
1284
+ paths["usage_evidence"].update(run_id=paths["run_id"], attempt_id=paths.get("attempt_id"))
1285
+
1259
1286
  command = spec["command"]
1260
1287
  if spawn is subprocess.Popen:
1261
1288
  command = _resolve_command(command, spec.get("options", {}).get("env"))
@@ -1295,6 +1322,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1295
1322
  if budget_check is not None:
1296
1323
  deadline = time.monotonic() + timeout_seconds if timeout_seconds is not None else None
1297
1324
  while True:
1325
+ snapshot()
1298
1326
  if budget_check(paths):
1299
1327
  budget_exceeded = True
1300
1328
  _terminate_child_tree(child)
@@ -1304,6 +1332,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1304
1332
  raise subprocess.TimeoutExpired(command, timeout_seconds)
1305
1333
  try:
1306
1334
  child.wait(timeout=min(0.25, remaining) if remaining is not None else 0.25)
1335
+ snapshot()
1307
1336
  budget_exceeded = budget_check(paths)
1308
1337
  break
1309
1338
  except subprocess.TimeoutExpired:
@@ -1325,6 +1354,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
1325
1354
  cancelled = True
1326
1355
  _terminate_child_tree(child)
1327
1356
  finally:
1357
+ snapshot()
1328
1358
  _restore_signal_handlers(previous_handlers)
1329
1359
 
1330
1360
  if cancelled:
@@ -4,7 +4,7 @@ import sys
4
4
  from .cli_args import ARG_CODE_INVALID_VALUE
5
5
  from .errors import CdxArgumentError, CdxError
6
6
  from .provider_runtime import headless_permission_disables_network
7
- from .run_usage import empty_usage, estimate_cost, extract_run_observation
7
+ from .run_usage import cost_fields, empty_usage, estimate_cost, extract_run_observation
8
8
 
9
9
  PROMPT_STDIN = "-"
10
10
 
@@ -169,6 +169,8 @@ def run_launch_payload(api_schema_version, parsed, session, artifacts, cwd=None,
169
169
  "ok": True,
170
170
  "action": "run",
171
171
  "launcher": "cdx",
172
+ "project": parsed.get("project"),
173
+ "project_source": parsed.get("project_source", "unknown"),
172
174
  "parent_run_id": parsed.get("continue_run"),
173
175
  "conversation_id": conversation_id or run_conversation_id(session),
174
176
  "detached": True,
@@ -219,6 +221,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
219
221
  "ok": bool(ok),
220
222
  "action": "run",
221
223
  "launcher": "cdx",
224
+ "project": parsed.get("project"),
225
+ "project_source": parsed.get("project_source", "unknown"),
222
226
  "parent_run_id": parsed.get("continue_run"),
223
227
  "conversation_id": run_conversation_id(session, run_info),
224
228
  "session": session.get("name") if session else None,
@@ -229,6 +233,8 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
229
233
  "power": parsed.get("power") or ((session.get("launch") or {}).get("power") if session else None),
230
234
  "cwd": os.path.abspath(parsed.get("cwd") or os.getcwd()),
231
235
  "run_id": run_info.get("run_id"),
236
+ "attempt_id": run_info.get("attempt_id"),
237
+ "attempts": run_info.get("attempts", []),
232
238
  "exit_code": run_info.get("returncode"),
233
239
  "duration_seconds": (run_info.get("duration_ms") / 1000.0) if run_info.get("duration_ms") is not None else None,
234
240
  "transcript_path": run_info.get("transcript_path"),
@@ -236,7 +242,14 @@ def run_result_payload(api_schema_version, ok, parsed, session, run_info=None,
236
242
  "stderr_path": run_info.get("stderr_path"),
237
243
  "usage": usage,
238
244
  "usage_model": usage_model,
245
+ "usage_models": run_info.get("usage_models", [usage_model] if usage_model else []),
246
+ **cost_fields(usage, usage_model, models=run_info.get("usage_models", (run_info.get("usage_evidence") or {}).get("models", [])), attribution=run_info.get("usage_attribution")),
239
247
  "cost_usd": run_info.get("cost_usd", estimate_cost(usage, usage_model)),
248
+ "usage_evidence": run_info.get("usage_evidence"),
249
+ "usage_attribution": run_info.get("usage_attribution"),
250
+ "budget_status": run_info.get("budget_status", "exceeded" if run_info.get("budget_exceeded") else
251
+ "usage_not_measured" if parsed.get("budget") is not None and usage.get("total_tokens") is None else
252
+ "estimated" if parsed.get("budget") is not None else "not_requested"),
240
253
  "budget_usd": parsed.get("budget"),
241
254
  "warnings": warnings,
242
255
  "error": None if ok else {