agent-orchestration-process 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (29) hide show
  1. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/PKG-INFO +1 -1
  2. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/pyproject.toml +1 -1
  3. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/pyproject.toml.orig +1 -1
  4. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/cli.py +71 -3
  5. agent_orchestration_process-0.3.0/src/agent_orchestration_process/migration.py +519 -0
  6. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/models.py +18 -0
  7. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/pricing.py +81 -12
  8. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/provider_versions.py +6 -0
  9. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/runner.py +658 -548
  10. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/worktrees.py +40 -9
  11. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/zcode.py +3 -15
  12. agent_orchestration_process-0.2.1/src/agent_orchestration_process/migration.py +0 -283
  13. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/LICENSE +0 -0
  14. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/README.md +0 -0
  15. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/__init__.py +0 -0
  16. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/__main__.py +0 -0
  17. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/batch.py +0 -0
  18. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/codex_auth.py +0 -0
  19. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/codex_routes.py +0 -0
  20. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/dsh_runner.mjs +0 -0
  21. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/egress.py +0 -0
  22. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/egress_bridge.py +0 -0
  23. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/integration.py +0 -0
  24. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/isolation.py +0 -0
  25. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/locks.py +0 -0
  26. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/model_catalog.py +0 -0
  27. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/model_listing.py +0 -0
  28. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/prompt_transport.py +0 -0
  29. {agent_orchestration_process-0.2.1 → agent_orchestration_process-0.3.0}/src/agent_orchestration_process/zcode_protocol.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-orchestration-process
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance
5
5
  Keywords: agent-orchestration,ai-agents,autoresearch,coding-agents,git-worktrees
6
6
  Author: AOP contributors
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "agent-orchestration-process"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -4,7 +4,7 @@ build-backend = "uv_build"
4
4
 
5
5
  [project]
6
6
  name = "agent-orchestration-process"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Run bounded, non-interactive jobs across agent harnesses with isolation, exact resume, and provenance"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -23,13 +23,22 @@ from .locks import exclusive_lock, task_lock_path
23
23
  from .model_catalog import ModelCatalog, ensure_catalog_fresh
24
24
  from .model_listing import AGENTS, AvailableModel, list_models
25
25
  from .models import PromptPart, RunRequest, RunResult
26
- from .runner import AgentRunner, RunStore, adapter_for, provider_runtime_record
27
- from .migration import migrate_runs, migration_roots, print_counts
26
+ from .runner import (
27
+ AgentRunner,
28
+ RunStore,
29
+ adapter_for,
30
+ plan_inputs,
31
+ provider_runtime_record,
32
+ )
33
+ from .migration import migrate_archive, migrate_runs, migration_roots, print_counts
28
34
  from .worktrees import (
29
35
  AOPError,
30
36
  WorktreeManager,
37
+ compact_state_roots,
31
38
  register_state_root,
32
39
  registered_state_roots,
40
+ results_archive_dir,
41
+ state_roots_file,
33
42
  )
34
43
 
35
44
 
@@ -396,6 +405,25 @@ def main(argv: Sequence[str] | None = None) -> int:
396
405
  input_names=tuple(Path(path).name for path in args.input_paths),
397
406
  ).to_dict()
398
407
  preview["provider"] = args.agent
408
+ planned = plan_inputs(
409
+ args.input_paths,
410
+ excludes=args.input_excludes,
411
+ skip_external_links=args.skip_external_links,
412
+ protected=(manager.state_dir, manager.sealed_runtime_dir),
413
+ )
414
+ preview["inputs"]["planned"] = [
415
+ {
416
+ "name": plan.alias,
417
+ "source": os.fspath(plan.source),
418
+ "files": len(plan.entries) if plan.entries else 1,
419
+ "size_bytes": sum(path.stat().st_size for _, path in plan.entries)
420
+ if plan.source.is_dir()
421
+ else plan.source.stat().st_size,
422
+ "excluded": list(plan.excluded),
423
+ "links": list(plan.links),
424
+ }
425
+ for plan in planned
426
+ ]
399
427
  preview["provider_runtime"] = provider_runtime_record(
400
428
  adapter_for(args.agent)
401
429
  )
@@ -424,6 +452,8 @@ def main(argv: Sequence[str] | None = None) -> int:
424
452
  timeout_seconds=args.timeout,
425
453
  artifacts=args.artifact,
426
454
  input_paths=args.input_paths,
455
+ input_excludes=args.input_excludes,
456
+ skip_external_links=args.skip_external_links,
427
457
  system_prompt=_read_system_prompt(args),
428
458
  tools=args.tools,
429
459
  provider_base_url=args.provider_base_url,
@@ -465,7 +495,11 @@ def main(argv: Sequence[str] | None = None) -> int:
465
495
  print(f"aop: {error}", file=sys.stderr)
466
496
  return 2
467
497
  except FileNotFoundError as error:
468
- print(f"aop: command not found: {error.filename}", file=sys.stderr)
498
+ print(
499
+ f"aop: command not found: {error.filename} "
500
+ f"(PATH={os.environ.get('PATH', '')})",
501
+ file=sys.stderr,
502
+ )
469
503
  return 127
470
504
  except KeyboardInterrupt:
471
505
  print("aop: interrupted", file=sys.stderr)
@@ -514,6 +548,25 @@ def _add_input_arguments(
514
548
  metavar="PATH",
515
549
  help=help_text,
516
550
  )
551
+ if resume:
552
+ return
553
+ parser.add_argument(
554
+ "--input-exclude",
555
+ action="append",
556
+ default=[],
557
+ dest="input_excludes",
558
+ metavar="GLOB",
559
+ help="leave matching paths out of every input directory; a pattern "
560
+ "without '/' matches names at any depth, one with '/' matches paths "
561
+ "from the input's root; repeat for more",
562
+ )
563
+ parser.add_argument(
564
+ "--skip-external-links",
565
+ action="store_true",
566
+ help="leave out symlinks that point outside their input instead of "
567
+ "failing; links that stay inside an input are always copied as their "
568
+ "targets",
569
+ )
517
570
 
518
571
 
519
572
  def _tool_list(value: str) -> tuple[str, ...]:
@@ -606,6 +659,15 @@ def _report_policy(
606
659
  f"{prefix}inputs: {inputs['mode']} ({len(inputs.get('names', []))} declared)",
607
660
  file=stream,
608
661
  )
662
+ for item in inputs.get("planned", []):
663
+ skipped = sum(link["action"] == "skipped" for link in item["links"])
664
+ print(
665
+ f"{prefix} {item['name']}: {item['files']} files, "
666
+ f"{item['size_bytes'] / 1e6:.1f} MB; {len(item['excluded'])} paths "
667
+ f"excluded; {len(item['links']) - skipped} symlinks followed, "
668
+ f"{skipped} skipped",
669
+ file=stream,
670
+ )
609
671
  print(
610
672
  f"{prefix}inherited local instructions: {instructions['inherited_local']}; "
611
673
  f"provider built-in prompt: {instructions['provider_builtin']}; "
@@ -689,6 +751,12 @@ def _migrate_results(runs_dirs: Sequence[Path], *, dry_run: bool) -> int:
689
751
  errors += sum(
690
752
  count for outcome, count in counts.items() if outcome.endswith("error")
691
753
  )
754
+ counts = migrate_archive(dry_run=dry_run)
755
+ print_counts(results_archive_dir(), counts)
756
+ errors += counts["archive: error"]
757
+ if not dry_run:
758
+ kept, removed = compact_state_roots()
759
+ print(f"{state_roots_file()}: kept {kept} roots, removed {removed}")
692
760
  return 1 if errors else 0
693
761
 
694
762
 
@@ -0,0 +1,519 @@
1
+ """One-time migration of derived run records to their current formats.
2
+
3
+ Raw provider artifacts such as events.jsonl are never changed. Each rewritten
4
+ record keeps its original beside it and records the conversions applied.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ from collections import Counter
12
+ from collections.abc import Iterable
13
+ from dataclasses import replace
14
+ from datetime import UTC, datetime
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .models import TOKEN_USAGE_SCHEMA, RunRequest, RunResult
19
+ from .pricing import TokenUsage, estimate_api_cost, nonnegative_int
20
+ from .runner import (
21
+ AgyAdapter,
22
+ ClaudeAdapter,
23
+ CursorAdapter,
24
+ DeepSeekHarnessAdapter,
25
+ GrokAdapter,
26
+ OpenCodeAdapter,
27
+ RunStore,
28
+ )
29
+ from .worktrees import results_archive_dir
30
+
31
+ ORIGINAL_SUFFIX = ".pre-v3.json"
32
+ LEGACY_TOKEN_USAGE_SCHEMA = "aop-token-usage-v1"
33
+ # v2 is v3's convention, except that Agy runs recorded Agy's result usage, the
34
+ # conversation's running total across every invocation that resumed it.
35
+ PREVIOUS_TOKEN_USAGE_SCHEMA = "aop-token-usage-v2"
36
+ _CACHE_WRITE_EVENT_PROVIDERS = frozenset(
37
+ {"claude", "cursor", "dsh", "grok", "opencode"}
38
+ )
39
+ _DERIVED_RESULT_FIELDS = (
40
+ "execution_completed",
41
+ "response_available",
42
+ "status",
43
+ "succeeded",
44
+ )
45
+
46
+
47
+ def migrate_runs(runs_dir: Path, *, dry_run: bool = False) -> Counter[str]:
48
+ """Bring every run record under runs_dir to the current formats, and copy
49
+ each current result into the results archive."""
50
+ counts: Counter[str] = Counter()
51
+ for run_dir in sorted(path for path in runs_dir.iterdir() if path.is_dir()):
52
+ for name, upgrade in (
53
+ ("request.json", _upgraded_request),
54
+ ("result.json", _upgraded_result),
55
+ ):
56
+ path = run_dir / name
57
+ value = _migrate_file(path, run_dir, upgrade, name, counts, dry_run)
58
+ if name == "result.json" and value is not None:
59
+ _archive(value, counts, dry_run)
60
+ return counts
61
+
62
+
63
+ def migrate_archive(*, dry_run: bool = False) -> Counter[str]:
64
+ """Bring archived results whose run directory is gone to the current format.
65
+
66
+ A result whose run directory still exists is migrated there and copied
67
+ here by migrate_runs, so the two stay identical.
68
+ """
69
+ counts: Counter[str] = Counter()
70
+ archive = results_archive_dir()
71
+ if not archive.is_dir():
72
+ return counts
73
+ for path in sorted(archive.glob("*.json")):
74
+ try:
75
+ raw = json.loads(path.read_text())
76
+ run_dir = Path(raw["state_root"]) / "runs" / raw["run_id"]
77
+ except (OSError, ValueError, TypeError, KeyError) as error:
78
+ counts["archive: error"] += 1
79
+ print(f"aop: could not read archived result {path}: {error}", flush=True)
80
+ continue
81
+ if (run_dir / "result.json").is_file():
82
+ continue
83
+ _migrate_file(path, run_dir, _upgraded_result, "archive", counts, dry_run)
84
+ return counts
85
+
86
+
87
+ def _migrate_file(
88
+ path: Path,
89
+ run_dir: Path,
90
+ upgrade: Any,
91
+ label: str,
92
+ counts: Counter[str],
93
+ dry_run: bool,
94
+ ) -> dict[str, Any] | None:
95
+ """Upgrade one record in place, keeping its original; return the current value."""
96
+ if not path.is_file():
97
+ return None
98
+ try:
99
+ raw = json.loads(path.read_text())
100
+ upgraded = upgrade(raw, run_dir)
101
+ except (OSError, ValueError, TypeError, KeyError) as error:
102
+ counts[f"{label}: error"] += 1
103
+ print(f"aop: could not migrate {path}: {error}", flush=True)
104
+ return None
105
+ if upgraded is None:
106
+ counts[f"{label}: current"] += 1
107
+ return raw
108
+ value, outcome = upgraded
109
+ counts[f"{label}: {outcome}"] += 1
110
+ if not dry_run:
111
+ # Archived originals go in a subdirectory, so the archive's top level
112
+ # holds exactly one current record per run.
113
+ folder = path.parent / "originals" if label == "archive" else path.parent
114
+ original = folder / (path.name.removesuffix(".json") + ORIGINAL_SUFFIX)
115
+ if not original.exists():
116
+ folder.mkdir(exist_ok=True)
117
+ shutil.copy2(path, original)
118
+ RunStore.write_json(path, value)
119
+ return value
120
+
121
+
122
+ def _archive(value: dict[str, Any], counts: Counter[str], dry_run: bool) -> None:
123
+ path = results_archive_dir() / f"{value['run_id']}.json"
124
+ try:
125
+ current = json.loads(path.read_text()) == value
126
+ except (OSError, ValueError):
127
+ current = False
128
+ if current:
129
+ counts["archive: current"] += 1
130
+ return
131
+ counts["archive: copied"] += 1
132
+ if not dry_run:
133
+ RunStore.archive_result(value)
134
+
135
+
136
+ def _upgraded_request(
137
+ raw: dict[str, Any], run_dir: Path
138
+ ) -> tuple[dict[str, Any], str] | None:
139
+ try:
140
+ RunRequest.from_dict(raw)
141
+ return None
142
+ except (TypeError, ValueError, KeyError):
143
+ pass
144
+ fields = dict(raw)
145
+ conversions = []
146
+ if "sandbox" in fields:
147
+ # A Codex sandbox mode that no execution profile resolves, so a resume
148
+ # of such a run fails instead of guessing a profile.
149
+ fields.setdefault("profile", fields.pop("sandbox"))
150
+ conversions.append("sandbox-to-profile")
151
+ if "read_paths" in fields:
152
+ fields.setdefault("inputs", fields.pop("read_paths"))
153
+ conversions.append("read-paths-to-inputs")
154
+ for key, default in (
155
+ ("mode", "agent"),
156
+ ("inference_provider", None),
157
+ ("inference_route", None),
158
+ ("no_web", False),
159
+ ("effective_policy", {}),
160
+ ("artifacts", []),
161
+ ("inputs", []),
162
+ ):
163
+ if key not in fields:
164
+ fields[key] = default
165
+ conversions.append(f"default-{key.replace('_', '-')}")
166
+ fields["migration"] = _record(None, conversions, "request.json")
167
+ request = RunRequest.from_dict(fields)
168
+ return request.to_dict(), "+".join(conversions) or "normalized"
169
+
170
+
171
+ def _upgraded_result(
172
+ raw: dict[str, Any], run_dir: Path
173
+ ) -> tuple[dict[str, Any], str] | None:
174
+ if raw.get("state_root"):
175
+ return _upgraded_result_fields(raw, run_dir)
176
+ # Results recorded before state_root take it from where they are stored.
177
+ raw = {**raw, "state_root": str(run_dir.parent.parent.resolve())}
178
+ upgraded = _upgraded_result_fields(raw, run_dir)
179
+ if upgraded is None:
180
+ result = RunResult.from_dict(raw)
181
+ result = replace(
182
+ result,
183
+ migration=_record(
184
+ raw.get("usage_schema"), ["state-root"], "result.json"
185
+ ),
186
+ )
187
+ return result.to_dict(), "state-root"
188
+ value, outcome = upgraded
189
+ value["migration"]["conversions"].append("state-root")
190
+ conversions, note, rest = outcome.partition(" (")
191
+ return value, f"{conversions}+state-root{note}{rest}"
192
+
193
+
194
+ def _upgraded_result_fields(
195
+ raw: dict[str, Any], run_dir: Path
196
+ ) -> tuple[dict[str, Any], str] | None:
197
+ try:
198
+ current = RunResult.from_dict(raw)
199
+ return _upgraded_v3_result(raw, current, run_dir)
200
+ except (TypeError, ValueError, KeyError):
201
+ pass
202
+ schema = raw.get("usage_schema")
203
+ previous_usage = _raw_usage_counts(raw.get("usage"))
204
+ fields = {
205
+ key: value for key, value in raw.items() if key not in _DERIVED_RESULT_FIELDS
206
+ }
207
+ conversions = []
208
+ for key in (
209
+ "mode",
210
+ "model",
211
+ "effort",
212
+ "time_to_first_event_seconds",
213
+ "time_to_first_response_seconds",
214
+ "provider_duration_seconds",
215
+ "provider_status",
216
+ "provider_error",
217
+ "inference_provider",
218
+ "provider_reported_cost",
219
+ ):
220
+ fields.setdefault(key, "agent" if key == "mode" else None)
221
+ if "read_paths" in fields:
222
+ fields.setdefault("inputs", fields.pop("read_paths"))
223
+ conversions.append("read-paths-to-inputs")
224
+ _fold_legacy_costs(fields, conversions)
225
+
226
+ if schema is None:
227
+ conversions.append("unversioned-usage")
228
+ _legacy_accounting(fields, _legacy_usage(fields))
229
+ elif schema == LEGACY_TOKEN_USAGE_SCHEMA:
230
+ conversions.append("v1-accounting")
231
+ _legacy_accounting(fields, TokenUsage.from_dict(fields.get("usage")))
232
+ elif schema == PREVIOUS_TOKEN_USAGE_SCHEMA:
233
+ conversions.append("v2-schema")
234
+ elif schema != TOKEN_USAGE_SCHEMA:
235
+ raise ValueError(f"unsupported token usage schema: {schema}")
236
+ if fields.get("provider") == "agy" and schema != TOKEN_USAGE_SCHEMA:
237
+ conversions.append("agy-step-usage")
238
+ _agy_step_usage(fields, run_dir)
239
+ if isinstance(fields.get("usage"), TokenUsage):
240
+ fields["usage"] = fields["usage"].to_dict()
241
+
242
+ fields["usage_schema"] = TOKEN_USAGE_SCHEMA
243
+ fields["migration"] = _record(schema, conversions, "result.json")
244
+ result = RunResult.from_dict(fields)
245
+ recovered, event_conversions = _recover_event_accounting(result, run_dir)
246
+ if event_conversions:
247
+ conversions.extend(event_conversions)
248
+ result = replace(
249
+ recovered,
250
+ migration=_record(schema, conversions, "result.json"),
251
+ )
252
+ outcome = "+".join(conversions) or "normalized"
253
+ if result.usage != previous_usage:
254
+ if "claude-resume-cost" not in event_conversions:
255
+ result = _repriced(result)
256
+ outcome += " (usage changed)"
257
+ return result.to_dict(), outcome
258
+
259
+
260
+ def _upgraded_v3_result(
261
+ raw: dict[str, Any], current: RunResult, run_dir: Path
262
+ ) -> tuple[dict[str, Any], str] | None:
263
+ raw_usage = raw.get("usage")
264
+ if (
265
+ current.usage is None
266
+ or not isinstance(raw_usage, dict)
267
+ or "cache_write_input_tokens" in raw_usage
268
+ or current.provider not in _CACHE_WRITE_EVENT_PROVIDERS
269
+ ):
270
+ return None
271
+ recovered, conversions = _recover_event_accounting(current, run_dir)
272
+ if not conversions:
273
+ return None
274
+ usage_changed = recovered.usage != current.usage
275
+ if usage_changed and "claude-resume-cost" not in conversions:
276
+ recovered = _repriced(recovered)
277
+ recovered = replace(
278
+ recovered,
279
+ migration=_record(TOKEN_USAGE_SCHEMA, conversions, "result.json"),
280
+ )
281
+ outcome = "+".join(conversions)
282
+ if usage_changed:
283
+ outcome += " (usage changed)"
284
+ return recovered.to_dict(), outcome
285
+
286
+
287
+ def _recover_event_accounting(
288
+ result: RunResult, run_dir: Path
289
+ ) -> tuple[RunResult, list[str]]:
290
+ if result.usage is None or result.provider not in _CACHE_WRITE_EVENT_PROVIDERS:
291
+ return result, []
292
+ try:
293
+ events_text = (run_dir / "events.jsonl").read_text()
294
+ except OSError:
295
+ return result, []
296
+ conversions: list[str] = []
297
+ new_usage = result.usage
298
+ new_cost = result.calculated_cost
299
+ if result.provider == "claude":
300
+ parent_run_id = _parent_run_id(run_dir)
301
+ previous_total_cost_usd = (
302
+ ClaudeAdapter._reported_total_cost_usd(
303
+ run_dir.parent / parent_run_id / "events.jsonl"
304
+ )
305
+ if parent_run_id
306
+ else None
307
+ )
308
+ parsed = ClaudeAdapter._parse(
309
+ events_text,
310
+ resumed=parent_run_id is not None,
311
+ previous_total_cost_usd=previous_total_cost_usd,
312
+ )
313
+ if not parsed["usage_observed"]:
314
+ return result, []
315
+ event_usage = parsed["usage"]
316
+ if (
317
+ event_usage.cache_write_input_tokens > 0
318
+ and result.usage.cached_input_tokens + event_usage.cache_write_input_tokens
319
+ <= result.usage.input_tokens
320
+ ):
321
+ new_usage = replace(
322
+ result.usage,
323
+ cache_write_input_tokens=event_usage.cache_write_input_tokens,
324
+ cache_write_1h_input_tokens=event_usage.cache_write_1h_input_tokens,
325
+ )
326
+ if parsed["cost_is_cumulative"]:
327
+ new_cost = parsed["cost"]
328
+ if new_cost is None and result.model:
329
+ new_cost = estimate_api_cost(
330
+ result.model,
331
+ new_usage,
332
+ provider="anthropic",
333
+ started_at=result.started_at,
334
+ finished_at=result.finished_at,
335
+ )
336
+ else:
337
+ if result.provider == "grok":
338
+ parsed = GrokAdapter._parse(events_text, result.model)
339
+ elif result.provider == "cursor":
340
+ parsed = CursorAdapter._parse(events_text)
341
+ elif result.provider == "opencode":
342
+ parsed = OpenCodeAdapter._parse(events_text, result.model)
343
+ else:
344
+ parsed = DeepSeekHarnessAdapter._parse(events_text)
345
+ if not parsed["usage_observed"]:
346
+ return result, []
347
+ event_usage = parsed["usage"]
348
+ if (
349
+ event_usage.cache_write_input_tokens > 0
350
+ and result.usage.cached_input_tokens + event_usage.cache_write_input_tokens
351
+ <= result.usage.input_tokens
352
+ ):
353
+ new_usage = replace(
354
+ result.usage,
355
+ cache_write_input_tokens=event_usage.cache_write_input_tokens,
356
+ cache_write_1h_input_tokens=event_usage.cache_write_1h_input_tokens,
357
+ )
358
+ if new_usage != result.usage:
359
+ conversions.append("cache-write-usage")
360
+ if new_cost != result.calculated_cost:
361
+ conversions.append("claude-resume-cost")
362
+ if not conversions:
363
+ return result, []
364
+ return replace(result, usage=new_usage, calculated_cost=new_cost), conversions
365
+
366
+
367
+ def _parent_run_id(run_dir: Path) -> str | None:
368
+ try:
369
+ raw_request = json.loads((run_dir / "request.json").read_text())
370
+ except (OSError, ValueError):
371
+ return None
372
+ if not isinstance(raw_request, dict):
373
+ return None
374
+ parent_run_id = raw_request.get("parent_run_id")
375
+ return parent_run_id if isinstance(parent_run_id, str) and parent_run_id else None
376
+
377
+
378
+ def _record(schema: str | None, conversions: list[str], name: str) -> dict[str, Any]:
379
+ return {
380
+ "from_usage_schema": schema,
381
+ "conversions": conversions,
382
+ "original": name.removesuffix(".json") + ORIGINAL_SUFFIX,
383
+ "migrated_at": datetime.now(UTC).isoformat(),
384
+ }
385
+
386
+
387
+ def _fold_legacy_costs(fields: dict[str, Any], conversions: list[str]) -> None:
388
+ legacy_cost = fields.pop("api_equivalent_cost", None)
389
+ billing = fields.get("billing")
390
+ reported = isinstance(billing, dict) and billing.get("actual_cost_known") is True
391
+ if isinstance(billing, dict) and "actual_cost_known" in billing:
392
+ fields["billing"] = {
393
+ key: value for key, value in billing.items() if key != "actual_cost_known"
394
+ }
395
+ if legacy_cost is not None:
396
+ conversions.append("api-equivalent-cost")
397
+ if reported and fields.get("provider_reported_cost") is None:
398
+ fields["provider_reported_cost"] = {
399
+ "amount_usd": legacy_cost["amount_usd"],
400
+ "currency": legacy_cost.get("currency", "USD"),
401
+ "source": legacy_cost.get("pricing_source", "legacy run result"),
402
+ }
403
+ elif not reported:
404
+ fields.setdefault("calculated_cost", legacy_cost)
405
+ fields.setdefault("calculated_cost", None)
406
+ cost = fields.get("calculated_cost")
407
+ if isinstance(cost, dict) and "estimated" in cost:
408
+ fields["calculated_cost"] = {
409
+ key: value for key, value in cost.items() if key != "estimated"
410
+ }
411
+
412
+
413
+ def _raw_usage_counts(raw: object) -> TokenUsage | None:
414
+ if not isinstance(raw, dict):
415
+ return None
416
+ try:
417
+ return TokenUsage.from_dict(raw)
418
+ except ValueError:
419
+ return None
420
+
421
+
422
+ def _legacy_usage(fields: dict[str, Any]) -> TokenUsage:
423
+ raw = fields.get("usage") if isinstance(fields.get("usage"), dict) else {}
424
+ counts = {
425
+ key: nonnegative_int(raw.get(key))
426
+ for key in (
427
+ "input_tokens",
428
+ "cached_input_tokens",
429
+ "cache_write_input_tokens",
430
+ "cache_write_1h_input_tokens",
431
+ "output_tokens",
432
+ "reasoning_output_tokens",
433
+ )
434
+ }
435
+ # These adapters persisted their provider's disjoint uncached input
436
+ # bucket; OpenCode persisted visible output without its reasoning.
437
+ if fields.get("provider") in {"agy", "cursor", "dsh"}:
438
+ counts["input_tokens"] += (
439
+ counts["cached_input_tokens"] + counts["cache_write_input_tokens"]
440
+ )
441
+ if fields.get("provider") == "opencode":
442
+ counts["output_tokens"] += counts["reasoning_output_tokens"]
443
+ return TokenUsage(**counts)
444
+
445
+
446
+ def _legacy_accounting(fields: dict[str, Any], usage: TokenUsage) -> None:
447
+ if not fields.get("timed_out"):
448
+ fields["usage"] = usage
449
+ fields["accounting_status"] = "complete"
450
+ return
451
+ observed = usage.total_tokens > 0
452
+ if not observed:
453
+ fields["calculated_cost"] = None
454
+ fields["usage"] = usage if observed else None
455
+ fields["accounting_status"] = (
456
+ "partial"
457
+ if observed or fields.get("provider_reported_cost") is not None
458
+ else "unavailable"
459
+ )
460
+
461
+
462
+ def _agy_step_usage(fields: dict[str, Any], run_dir: Path) -> None:
463
+ try:
464
+ parsed = AgyAdapter._parse((run_dir / "events.jsonl").read_text())
465
+ except OSError:
466
+ parsed = {"usage_observed": False}
467
+ if parsed["usage_observed"]:
468
+ fields["usage"] = parsed["usage"]
469
+ if fields.get("accounting_status") == "unavailable":
470
+ fields["accounting_status"] = "partial"
471
+ return
472
+ fields.update(
473
+ usage=None,
474
+ calculated_cost=None,
475
+ provider_reported_cost=None,
476
+ accounting_status="unavailable",
477
+ )
478
+
479
+
480
+ def _repriced(result: RunResult) -> RunResult:
481
+ """Price changed usage at the rates in effect when the run happened."""
482
+ if (
483
+ result.provider == "claude"
484
+ and result.calculated_cost is not None
485
+ and result.calculated_cost.pricing_version == "claude-cli-reported"
486
+ ):
487
+ return result
488
+ provider = {
489
+ "agy": "google",
490
+ "claude": "anthropic",
491
+ "dsh": DeepSeekHarnessAdapter.pricing_provider(result.inference_provider),
492
+ "grok": "xai",
493
+ }.get(result.provider)
494
+ priced = result.provider in {"agy", "claude", "dsh", "grok", "opencode"}
495
+ cost = (
496
+ estimate_api_cost(
497
+ result.model,
498
+ result.usage,
499
+ provider=provider,
500
+ started_at=result.started_at,
501
+ finished_at=result.finished_at,
502
+ )
503
+ if priced and result.usage is not None and result.model
504
+ else None
505
+ )
506
+ return replace(result, calculated_cost=cost)
507
+
508
+
509
+ def migration_roots(registered: Iterable[Path], current: Path | None) -> list[Path]:
510
+ roots = [root / "runs" for root in registered]
511
+ if current is not None:
512
+ roots.append(current / "runs")
513
+ return [root for root in dict.fromkeys(roots) if root.is_dir()]
514
+
515
+
516
+ def print_counts(root: Path, counts: Counter[str]) -> None:
517
+ print(root)
518
+ for outcome, count in sorted(counts.items()):
519
+ print(f" {count:>7} {outcome}")