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.
@@ -0,0 +1,93 @@
1
+ """Supervisor-owned, content-free snapshots of isolated provider observations."""
2
+
3
+ import json
4
+ import os
5
+ import stat
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from .history_models import _exact_transcript
10
+ from .interactive_usage import MAX_TRANSCRIPT_BYTES
11
+ from .run_usage import _usage_from_dict, extract_run_conversation_id, extract_run_usage
12
+
13
+
14
+ def _open_rollout(root, path):
15
+ """Walk relative to the pinned attempt directory without following symlinks."""
16
+ parts = Path(path).relative_to(root).parts
17
+ directory = os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
18
+ try:
19
+ for part in parts[:-1]:
20
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory)
21
+ os.close(directory)
22
+ directory = child
23
+ return os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory)
24
+ finally:
25
+ os.close(directory)
26
+
27
+
28
+ def codex_attempt_evidence(auth_home, stdout_path, started_at):
29
+ """Read only the exact thread in a fresh attempt's volatile sessions mount.
30
+
31
+ Do not import claimed costs, requested models, messages, or arbitrary files
32
+ from the workspace. The exported object is never writable by the sandbox.
33
+ """
34
+ usage = extract_run_usage("codex", stdout_path)
35
+ identifier = extract_run_conversation_id("codex", stdout_path)
36
+ evidence = {"source": "isolated_codex_rollout", "stdout_path": str(stdout_path),
37
+ "thread_id": identifier, "started_at": started_at, "models": [],
38
+ "turns": [], "usage": usage}
39
+ if not identifier:
40
+ return evidence
41
+ path, error = _exact_transcript("codex", auth_home, identifier)
42
+ if error or not path:
43
+ return evidence
44
+ models = set()
45
+ turns = []
46
+ identity = False
47
+ complete_models = True
48
+ counters = None
49
+ ended_at = datetime.now(timezone.utc)
50
+ try:
51
+ descriptor = _open_rollout(auth_home, path)
52
+ with os.fdopen(descriptor, encoding="utf-8") as handle:
53
+ if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
54
+ return evidence
55
+ text = handle.read(MAX_TRANSCRIPT_BYTES + 1)
56
+ if len(text) > MAX_TRANSCRIPT_BYTES:
57
+ return evidence
58
+ for line in text.splitlines():
59
+ record = json.loads(line)
60
+ payload = record.get("payload") or {}
61
+ if record.get("type") == "session_meta":
62
+ if payload.get("id") != identifier:
63
+ return evidence
64
+ identity = True
65
+ if record.get("type") == "event_msg" and payload.get("type") == "token_count":
66
+ counters = _usage_from_dict((payload.get("info") or {}).get("total_token_usage") or {}, provider="codex")
67
+ if record.get("type") != "turn_context":
68
+ continue
69
+ timestamp = record.get("timestamp")
70
+ if (not timestamp or datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
71
+ < datetime.fromisoformat(started_at.replace("Z", "+00:00"))):
72
+ complete_models = False
73
+ continue
74
+ if datetime.fromisoformat(timestamp.replace("Z", "+00:00")) > ended_at:
75
+ complete_models = False
76
+ continue
77
+ model = payload.get("model")
78
+ if not isinstance(model, str) or not model:
79
+ complete_models = False
80
+ continue
81
+ models.add(model)
82
+ turn_id = payload.get("turn_id")
83
+ turns.append({"turn_id": turn_id if isinstance(turn_id, str) else None, "timestamp": timestamp, "model": model})
84
+ except (OSError, ValueError, TypeError, AttributeError):
85
+ return evidence
86
+ # Rollout counters must corroborate exec's measured counters. A free-form
87
+ # model/cost claim or a previous conversation's context is insufficient.
88
+ matches = counters is not None and all(value is None or counters.get(key) == value for key, value in usage.items())
89
+ if identity and matches:
90
+ evidence.update(models=sorted(models) if complete_models else [], turns=turns, ended_at=ended_at.isoformat())
91
+ if usage.get("total_tokens") is None:
92
+ evidence["measurement_usage"] = counters
93
+ return evidence
@@ -244,7 +244,7 @@ class RunRegistry:
244
244
  _check_capacity(_read_registry(self.path), max_parallel)
245
245
 
246
246
  def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None, label=None, max_parallel=None,
247
- parent_run_id=None, conversation_id=None, launch=None, reasoning_effort=None):
247
+ parent_run_id=None, conversation_id=None, launch=None, reasoning_effort=None, project=None, project_source="unknown"):
248
248
  with _registry_lock(self.path):
249
249
  data = _read_registry(self.path)
250
250
  data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
@@ -254,7 +254,8 @@ class RunRegistry:
254
254
  _check_capacity(data, max_parallel)
255
255
  record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
256
256
  record.update(parent_run_id=parent_run_id, conversation_id=conversation_id,
257
- launch=dict(launch or {}), reasoning_effort=reasoning_effort)
257
+ launch=dict(launch or {}), reasoning_effort=reasoning_effort,
258
+ project=project, project_source=project_source)
258
259
  if label:
259
260
  record["label"] = label
260
261
  data["runs"].insert(0, record)
@@ -280,6 +281,18 @@ class RunRegistry:
280
281
  return run
281
282
  return None
282
283
 
284
+ def record_attempt(self, run_id, artifacts):
285
+ """Keep live tail paths on this supervisor's current attempt."""
286
+ with _registry_lock(self.path):
287
+ data = _read_registry(self.path)
288
+ for run in data["runs"]:
289
+ if run.get("run_id") == run_id and run.get("status") == "running":
290
+ run["attempt_id"] = artifacts.get("attempt_id")
291
+ run["artifacts"] = {key: artifacts.get(key) for key in ("transcript_path", "stdout_path", "stderr_path")}
292
+ _write_registry(self.path, data)
293
+ return run
294
+ return None
295
+
283
296
  def record_background(self, run_id, *, path, provider_session_id=None, provider_agent_id=None):
284
297
  with _registry_lock(self.path):
285
298
  data = _read_registry(self.path)
@@ -376,6 +389,8 @@ class RunRegistry:
376
389
  # used to store whichever shape the producing reader emitted.
377
390
  run["usage"] = coerce_usage(final_payload.get("usage"), provider=run.get("provider"))
378
391
  run["usage_model"] = final_payload.get("usage_model")
392
+ for key in ("usage_evidence", "usage_models", "attempt_id", "attempts", "cost_usd", "cost_status", "cost_reason", "budget_status"):
393
+ run[key] = final_payload.get(key)
379
394
  run["conversation_id"] = final_payload.get("conversation_id") or run.get("conversation_id")
380
395
  run["parent_run_id"] = final_payload.get("parent_run_id") or run.get("parent_run_id")
381
396
  if "reasoning_effort" in final_payload:
package/src/run_usage.py CHANGED
@@ -363,6 +363,25 @@ def estimate_cost(usage, model, prices=None):
363
363
  return sum(present) / 1_000_000
364
364
 
365
365
 
366
+ def cost_fields(usage, model, prices=None, *, models=(), attribution=None):
367
+ """Explain nullable estimates consistently on every JSON surface."""
368
+ cost = estimate_cost(usage, model, prices)
369
+ reason = None
370
+ if cost is None:
371
+ if attribution == "continuation_usage_unavailable":
372
+ reason = "attempt_usage_unavailable"
373
+ elif not _has_usage(usage):
374
+ reason = "usage_not_measured"
375
+ elif len(set(models)) > 1:
376
+ reason = "mixed_models_without_counters"
377
+ elif not model:
378
+ reason = "model_not_observed"
379
+ else:
380
+ reason = "model_price_unknown"
381
+ return {"cost_usd": cost, "cost_status": "estimated" if cost is not None else "unknown",
382
+ "cost_reason": reason}
383
+
384
+
366
385
  def _long_ttl_writes(usage):
367
386
  """One-hour cache writes, bounded by the writes they are a part of."""
368
387
  long_ttl = usage.get("cache_creation_1h_tokens")
@@ -475,7 +494,7 @@ def extract_run_usage(provider, stdout_path):
475
494
  return extract_run_observation(provider, stdout_path)[0]
476
495
 
477
496
 
478
- def extract_run_observation(provider, stdout_path, auth_home=None):
497
+ def extract_run_observation(provider, stdout_path, auth_home=None, *, evidence=None, models_out=None):
479
498
  if not stdout_path or not provider:
480
499
  return empty_usage(), None
481
500
  if provider not in SUPPORTED_PROVIDERS:
@@ -493,6 +512,10 @@ def extract_run_observation(provider, stdout_path, auth_home=None):
493
512
  return empty_usage(), None
494
513
 
495
514
  usage = _extract_usage_from_records(records, provider=provider)
515
+ if (provider == "codex" and evidence is not None and evidence.get("stdout_path") == str(stdout_path)
516
+ and evidence.get("usage") == usage and evidence.get("thread_id") == extract_run_conversation_id(provider, stdout_path)
517
+ and not _has_usage(usage)):
518
+ usage = evidence.get("measurement_usage") or usage
496
519
  if not _has_usage(usage):
497
520
  return empty_usage(), None
498
521
  models = set()
@@ -506,8 +529,18 @@ def extract_run_observation(provider, stdout_path, auth_home=None):
506
529
  model_usage = record.get("modelUsage")
507
530
  if isinstance(model_usage, dict):
508
531
  models.update(model_usage)
509
- if provider == "codex" and auth_home:
532
+ if provider == "codex" and evidence is not None:
533
+ # Only the supervisor supplies this snapshot, never a file in the checkout.
534
+ if (evidence.get("stdout_path") == str(stdout_path)
535
+ and evidence.get("thread_id") == extract_run_conversation_id(provider, stdout_path)
536
+ and (evidence.get("measurement_usage") or evidence.get("usage")) == usage):
537
+ models.update(evidence.get("models") or [])
538
+ else:
539
+ return usage, None
540
+ elif provider == "codex" and auth_home:
510
541
  models.update(_codex_run_models(records, auth_home))
542
+ if models_out is not None:
543
+ models_out.extend(sorted(models))
511
544
  return usage, next(iter(models)) if len(models) == 1 else None
512
545
 
513
546
 
@@ -539,8 +572,9 @@ def _codex_run_models(records, auth_home):
539
572
  continue
540
573
  payload = record.get("payload")
541
574
  model = payload.get("model") if isinstance(payload, dict) else None
542
- if isinstance(model, str) and model:
543
- models.add(model)
575
+ if not isinstance(model, str) or not model:
576
+ return set()
577
+ models.add(model)
544
578
  except OSError:
545
579
  return set()
546
580
  return models
@@ -777,6 +777,14 @@ def backfill_history_models(store, base_dir, dry_run=True):
777
777
  )
778
778
 
779
779
 
780
+ def backfill_history_projects(store, base_dir, mapping_path=None, dry_run=True):
781
+ from .history_models import read_run_reports
782
+ from .project_identity import load_mapping
783
+
784
+ mapping = load_mapping(mapping_path) if mapping_path else {}
785
+ return store["backfill_launch_projects"](mapping, read_run_reports(base_dir), dry_run=dry_run)
786
+
787
+
780
788
  def refresh_interactive_usage_models(store, dry_run=True):
781
789
  """Replace a placeholder model with the exact transcript's real model."""
782
790
  from .interactive_usage import MATCH_CONVERSATION_ID, extract_interactive_usage
@@ -813,6 +821,8 @@ def start_session_runtime(store, name, payload=None):
813
821
  "command": payload.get("command"),
814
822
  "label": payload.get("label"),
815
823
  "cwd": payload.get("cwd"),
824
+ "project": payload.get("project"),
825
+ "project_source": payload.get("project_source", "unknown"),
816
826
  "transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
817
827
  }
818
828
 
@@ -1010,6 +1020,7 @@ def create_session_service(options=None):
1010
1020
  "backfill_interactive_usage": partial(backfill_interactive_usage, store),
1011
1021
  "refresh_interactive_usage_models": partial(refresh_interactive_usage_models, store),
1012
1022
  "backfill_history_models": partial(backfill_history_models, store, base_dir),
1023
+ "backfill_history_projects": partial(backfill_history_projects, store, base_dir),
1013
1024
  "update_auth_state": partial(update_auth_state, store),
1014
1025
  "get_status_row": partial(get_status_row, store, base_dir, env, fetch_codex_status),
1015
1026
  "get_status_rows": partial(get_status_rows, store, base_dir, env, fetch_codex_status),
@@ -395,6 +395,67 @@ def create_session_store(base_dir):
395
395
  result["backfilled"] = recovered
396
396
  return result
397
397
 
398
+ def backfill_launch_projects(mapping, reports, dry_run=True):
399
+ """Plan and atomically apply evidence-only attribution under the history lock."""
400
+ import uuid
401
+
402
+ from .project_identity import recorded_project
403
+
404
+ result = {"dry_run": dry_run, "recovered": 0, "unchanged": 0, "unresolved": 0,
405
+ "conflicting": 0, "applied": 0, "backup_path": None}
406
+ with _file_lock(lock_file):
407
+ try:
408
+ with open(launch_history_file, "rb") as handle:
409
+ original = handle.read()
410
+ except FileNotFoundError:
411
+ original = b""
412
+ lines = original.splitlines(keepends=True)
413
+ entries = []
414
+ for line in lines:
415
+ try:
416
+ entry = json.loads(line)
417
+ except (ValueError, UnicodeError):
418
+ entry = None
419
+ entries.append(entry if isinstance(entry, dict) else None)
420
+ run_ids = {entry.get("run_id") for entry in entries if entry and isinstance(entry.get("run_id"), str)}
421
+ missing = set(mapping) - run_ids
422
+ if missing:
423
+ raise CdxError(f"Project mapping references unknown exact run ids: {', '.join(sorted(missing))}")
424
+ evidence = {}
425
+ for record in [*reports, *[entry for entry in entries if entry]]:
426
+ run_id = record.get("run_id")
427
+ project = recorded_project(record)
428
+ if isinstance(run_id, str) and project:
429
+ evidence.setdefault(run_id, set()).add(project)
430
+ for run_id, project in mapping.items():
431
+ evidence.setdefault(run_id, set()).add(project)
432
+ rewritten = []
433
+ for line, entry in zip(lines, entries):
434
+ if entry is not None:
435
+ run_id = entry.get("run_id")
436
+ candidates = evidence.get(run_id, set()) if isinstance(run_id, str) else set()
437
+ known = recorded_project(entry)
438
+ if len(candidates) > 1:
439
+ result["conflicting"] += 1
440
+ elif known:
441
+ result["unchanged"] += 1
442
+ elif candidates:
443
+ result["recovered"] += 1
444
+ entry.update(project=next(iter(candidates)),
445
+ project_source="operator_mapping" if run_id in mapping else "run_report")
446
+ line = (json.dumps(entry, separators=(",", ":")) + "\n").encode("utf-8")
447
+ else:
448
+ result["unresolved"] += 1
449
+ rewritten.append(line)
450
+ # A conflicting batch is entirely read-only, including otherwise valid rows.
451
+ if not dry_run and result["recovered"] and not result["conflicting"]:
452
+ backup = f"{launch_history_file}.projects-{uuid.uuid4().hex}.bak"
453
+ atomic_write(backup, original)
454
+ atomic_write(launch_history_file, b"".join(rewritten))
455
+ _fsync_directory(state_dir)
456
+ result.update(backup_path=backup, applied=result["recovered"])
457
+ return result
458
+
398
459
  def list_launch_history(session_name=None, limit=20):
399
460
  with _file_lock(lock_file):
400
461
  try:
@@ -433,5 +494,6 @@ def create_session_store(base_dir):
433
494
  "drop_unvouched_usage": drop_unvouched_usage,
434
495
  "backfill_launch_usage": backfill_launch_usage,
435
496
  "backfill_launch_models": backfill_launch_models,
497
+ "backfill_launch_projects": backfill_launch_projects,
436
498
  "list_launch_history": list_launch_history,
437
499
  }
@@ -62,7 +62,7 @@ def clean_env(source: dict) -> dict:
62
62
 
63
63
 
64
64
  def sandbox_command(command: list[str], tree: Path, *, network: bool, writable: bool = True,
65
- source: dict | None = None, auth_home: str = "", provider: str = "") -> tuple[list[str], dict]:
65
+ source: dict | None = None, auth_home: str = "", provider: str = "", observation_home: str = "") -> tuple[list[str], dict]:
66
66
  source = os.environ if source is None else source
67
67
  binary = shutil.which(command[0], path=source.get("PATH"))
68
68
  bwrap = shutil.which("bwrap", path=source.get("PATH"))
@@ -127,6 +127,9 @@ def sandbox_command(command: list[str], tree: Path, *, network: bool, writable:
127
127
  env = clean_env(source)
128
128
  if provider == "codex":
129
129
  env["CODEX_HOME"] = "/home/agent/.codex"
130
+ if observation_home:
131
+ # Fresh, volatile attempt storage; no host profile is exposed.
132
+ args += ["--bind", observation_home, "/home/agent/.codex/sessions"]
130
133
  args += ["--ro-bind", str(Path(auth_home) / "auth.json"), "/home/agent/.codex/auth.json"]
131
134
  elif provider == "claude":
132
135
  token = source.get("CLAUDE_CODE_OAUTH_TOKEN")