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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_25_2.md +15 -0
- package/checksums/release-archives.json +11 -0
- package/docs/cli.md +90 -4
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +7 -5
- package/src/cli_args.py +5 -2
- package/src/commands/launch.py +13 -5
- package/src/commands/runs.py +88 -24
- package/src/commands/status.py +32 -4
- package/src/history_models.py +10 -1
- package/src/project_identity.py +71 -0
- package/src/provider_runtime.py +35 -5
- package/src/run_command.py +14 -1
- package/src/run_observation.py +93 -0
- package/src/run_registry.py +17 -2
- package/src/run_usage.py +38 -4
- package/src/session_service.py +11 -0
- package/src/session_store.py +62 -0
- package/src/workspace_sandbox.py +4 -1
package/src/commands/runs.py
CHANGED
|
@@ -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
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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 (
|
|
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("
|
|
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
|
-
#
|
|
1069
|
-
|
|
1070
|
-
for key in ("stdout_path", "stderr_path", "transcript_path")
|
|
1071
|
-
|
|
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)
|
package/src/commands/status.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
532
|
+
"""The recorded source repository or serving model a run is counted under."""
|
|
528
533
|
if by == "project":
|
|
529
|
-
|
|
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
|
-
|
|
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"]:
|
package/src/history_models.py
CHANGED
|
@@ -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
|
package/src/provider_runtime.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/src/run_command.py
CHANGED
|
@@ -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 {
|