agent-orchestration-process 0.2.2__tar.gz → 0.3.1__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.2 → agent_orchestration_process-0.3.1}/PKG-INFO +1 -1
  2. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/pyproject.toml +1 -1
  3. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/pyproject.toml.orig +1 -1
  4. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/cli.py +10 -1
  5. agent_orchestration_process-0.3.1/src/agent_orchestration_process/migration.py +519 -0
  6. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/models.py +5 -0
  7. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/pricing.py +81 -12
  8. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/prompt_transport.py +6 -2
  9. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/runner.py +599 -598
  10. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/worktrees.py +40 -9
  11. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/zcode.py +3 -15
  12. agent_orchestration_process-0.2.2/src/agent_orchestration_process/migration.py +0 -283
  13. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/LICENSE +0 -0
  14. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/README.md +0 -0
  15. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/__init__.py +0 -0
  16. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/__main__.py +0 -0
  17. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/batch.py +0 -0
  18. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/codex_auth.py +0 -0
  19. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/codex_routes.py +0 -0
  20. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/dsh_runner.mjs +0 -0
  21. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/egress.py +0 -0
  22. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/egress_bridge.py +0 -0
  23. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/integration.py +0 -0
  24. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/isolation.py +0 -0
  25. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/locks.py +0 -0
  26. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/model_catalog.py +0 -0
  27. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/model_listing.py +0 -0
  28. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/src/agent_orchestration_process/provider_versions.py +0 -0
  29. {agent_orchestration_process-0.2.2 → agent_orchestration_process-0.3.1}/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.2
3
+ Version: 0.3.1
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.2"
7
+ version = "0.3.1"
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.2"
7
+ version = "0.3.1"
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"
@@ -30,12 +30,15 @@ from .runner import (
30
30
  plan_inputs,
31
31
  provider_runtime_record,
32
32
  )
33
- from .migration import migrate_runs, migration_roots, print_counts
33
+ from .migration import migrate_archive, migrate_runs, migration_roots, print_counts
34
34
  from .worktrees import (
35
35
  AOPError,
36
36
  WorktreeManager,
37
+ compact_state_roots,
37
38
  register_state_root,
38
39
  registered_state_roots,
40
+ results_archive_dir,
41
+ state_roots_file,
39
42
  )
40
43
 
41
44
 
@@ -748,6 +751,12 @@ def _migrate_results(runs_dirs: Sequence[Path], *, dry_run: bool) -> int:
748
751
  errors += sum(
749
752
  count for outcome, count in counts.items() if outcome.endswith("error")
750
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}")
751
760
  return 1 if errors else 0
752
761
 
753
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}")
@@ -205,6 +205,9 @@ class RunResult:
205
205
  provider_error: str | None = None
206
206
  accounting_status: str = "complete"
207
207
  usage_schema: str = TOKEN_USAGE_SCHEMA
208
+ # The .aop state directory the run was recorded in, as listed in the
209
+ # state-roots registry. Set when the result is written.
210
+ state_root: str | None = None
208
211
  # How `aop migrate-results` converted an older record, if it did.
209
212
  migration: dict[str, Any] | None = None
210
213
 
@@ -285,6 +288,8 @@ class RunResult:
285
288
  f"unsupported token usage schema {fields.get('usage_schema')!r}; "
286
289
  f"{MIGRATE_HINT}"
287
290
  )
291
+ if not fields.get("state_root"):
292
+ raise ValueError(f"run result has no state_root; {MIGRATE_HINT}")
288
293
  usage = fields["usage"]
289
294
  fields["usage"] = None if usage is None else TokenUsage.from_dict(usage)
290
295
  fields["calculated_cost"] = CalculatedCost.from_dict(fields["calculated_cost"])