pluginbench 0.5.0__py3-none-any.whl

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 (58) hide show
  1. pluginbench/__init__.py +3 -0
  2. pluginbench/__main__.py +3 -0
  3. pluginbench/assessment-charts.html +790 -0
  4. pluginbench/assessment.py +316 -0
  5. pluginbench/assessment_charts.py +1225 -0
  6. pluginbench/assessment_metrics.py +1956 -0
  7. pluginbench/assessment_models.py +127 -0
  8. pluginbench/baselines.py +792 -0
  9. pluginbench/claude.py +374 -0
  10. pluginbench/cli.py +672 -0
  11. pluginbench/code_diff.py +207 -0
  12. pluginbench/codex.py +451 -0
  13. pluginbench/codex_events.py +585 -0
  14. pluginbench/collectors.py +591 -0
  15. pluginbench/compare.py +927 -0
  16. pluginbench/components.py +129 -0
  17. pluginbench/coverage_report.py +610 -0
  18. pluginbench/datasets.py +207 -0
  19. pluginbench/evaluation.example.yaml +45 -0
  20. pluginbench/evaluation.py +1651 -0
  21. pluginbench/evaluation_report.py +1004 -0
  22. pluginbench/fallow_parse.mjs +101 -0
  23. pluginbench/harbor_backend.py +2946 -0
  24. pluginbench/isolation.py +314 -0
  25. pluginbench/judge.py +683 -0
  26. pluginbench/language_analysis.py +915 -0
  27. pluginbench/ledger.py +217 -0
  28. pluginbench/measurement_profiles.py +616 -0
  29. pluginbench/native_treatment_runtime.js +221 -0
  30. pluginbench/native_treatments.py +155 -0
  31. pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
  32. pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
  33. pluginbench/observer_data/go-analysis-v1/main.go +64 -0
  34. pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
  35. pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
  36. pluginbench/parallel.py +101 -0
  37. pluginbench/processes.py +100 -0
  38. pluginbench/remeasure.py +992 -0
  39. pluginbench/run_lock.py +227 -0
  40. pluginbench/run_status.py +518 -0
  41. pluginbench/study.py +1858 -0
  42. pluginbench/study_review.py +924 -0
  43. pluginbench/submissions.py +486 -0
  44. pluginbench/task_sources.py +403 -0
  45. pluginbench/treatments.py +388 -0
  46. pluginbench/trial_containers.py +94 -0
  47. pluginbench/vendor/ECHARTS-LICENSE +222 -0
  48. pluginbench/vendor/ECHARTS-NOTICE +5 -0
  49. pluginbench/vendor/ZRENDER-LICENSE +29 -0
  50. pluginbench/vendor/echarts.min.js +48 -0
  51. pluginbench-0.5.0.dist-info/METADATA +294 -0
  52. pluginbench-0.5.0.dist-info/RECORD +58 -0
  53. pluginbench-0.5.0.dist-info/WHEEL +4 -0
  54. pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
  55. pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
  56. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
  57. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
  58. pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
@@ -0,0 +1,585 @@
1
+ """Codex session event and rollout parsing: usage, models, effort and version evidence.
2
+
3
+ Kept out of `codex.py` so the vanilla cache's launcher hash covers only how attempts are
4
+ launched. A parser-only change re-reads saved traces; it does not change how a model was called.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ from typing import Any
12
+
13
+
14
+ def observed_cli_version(startup: object, *, host: str = "codex") -> str | None:
15
+ """Return the exact agreed version from native session metadata, or unknown."""
16
+ if not isinstance(startup, list):
17
+ return None
18
+ if host == "claude":
19
+ from pluginbench import claude
20
+
21
+ return claude.observed_cli_version(startup)
22
+ versions: set[str] = set()
23
+ for event in startup:
24
+ if not isinstance(event, dict) or event.get("type") != "session_meta":
25
+ continue
26
+ payload = event.get("payload")
27
+ if not isinstance(payload, dict):
28
+ return None
29
+ version = payload.get("cli_version")
30
+ if not isinstance(version, str) or not version.strip():
31
+ return None
32
+ versions.add(version)
33
+ return next(iter(versions)) if len(versions) == 1 else None
34
+
35
+
36
+ def parse_events(
37
+ stdout: str,
38
+ traces: list[str],
39
+ host: str,
40
+ *,
41
+ requested_effort: str = "medium",
42
+ include_descendants: bool = False,
43
+ ) -> dict[str, Any]:
44
+ if host == "claude":
45
+ from pluginbench import claude
46
+
47
+ return claude.parse_events(stdout, traces, requested_effort=requested_effort)
48
+ if host != "codex":
49
+ raise ValueError(f"unsupported native host: {host}")
50
+ commands: list[dict[str, Any]] = []
51
+ models = set()
52
+ usage = None
53
+ startup = []
54
+ errors: list[Any] = []
55
+ tools = []
56
+ final = []
57
+ successful_completion = False
58
+ turn_usage: list[dict[str, int]] = []
59
+ stdout_events: list[dict[str, Any]] = []
60
+ for line in stdout.splitlines():
61
+ try:
62
+ event = json.loads(line)
63
+ except ValueError:
64
+ errors.append("malformed stdout JSONL")
65
+ continue
66
+ if not isinstance(event, dict):
67
+ errors.append("stdout JSONL event is not an object")
68
+ continue
69
+ stdout_events.append(event)
70
+ item = event.get("item", {})
71
+ if not isinstance(item, dict):
72
+ errors.append("stdout item is not an object")
73
+ continue
74
+ if event.get("type") == "item.completed" and item.get("type") == "command_execution":
75
+ commands.append(item)
76
+ if item.get("type") == "agent_message" and event.get("type") == "item.completed":
77
+ message_text = item.get("text", "")
78
+ if isinstance(message_text, str):
79
+ final.append(message_text)
80
+ else:
81
+ errors.append("invalid agent message text")
82
+ if event.get("type") == "turn.completed":
83
+ reported = event.get("usage")
84
+ if isinstance(reported, dict) and all(
85
+ isinstance(value, int) and not isinstance(value, bool) and value >= 0
86
+ for value in reported.values()
87
+ ):
88
+ turn_usage.append(reported)
89
+ else:
90
+ errors.append("invalid turn usage")
91
+ successful_completion = True
92
+ if event.get("type") in {"error", "turn.failed"}:
93
+ errors.append(event)
94
+ if item.get("type") not in {None, "command_execution", "agent_message", "reasoning"}:
95
+ tools.append(item)
96
+ effort = set()
97
+ partial_usage = []
98
+ trace_events: list[list[dict[str, Any]]] = []
99
+ for trace in traces:
100
+ last_observed_usage = None
101
+ decoded_trace = []
102
+ for line in trace.splitlines():
103
+ try:
104
+ event = json.loads(line)
105
+ except ValueError:
106
+ errors.append("malformed trace JSONL")
107
+ continue
108
+ if not isinstance(event, dict):
109
+ errors.append("trace JSONL event is not an object")
110
+ continue
111
+ decoded_trace.append(event)
112
+ if event.get("type") == "event_msg":
113
+ payload = event.get("payload", {})
114
+ info = payload.get("info") if isinstance(payload, dict) else None
115
+ observed = info.get("total_token_usage") if isinstance(info, dict) else None
116
+ if (
117
+ isinstance(payload, dict)
118
+ and payload.get("type") == "token_count"
119
+ and isinstance(observed, dict)
120
+ ):
121
+ if observed and all(
122
+ isinstance(value, int) and not isinstance(value, bool) and value >= 0
123
+ for value in observed.values()
124
+ ):
125
+ last_observed_usage = observed
126
+ if event.get("type") in {"session_meta", "turn_context"}:
127
+ payload = event.get("payload", {})
128
+ startup.append({"type": event["type"], "payload": payload})
129
+ if not isinstance(payload, dict):
130
+ errors.append("trace payload is not an object")
131
+ continue
132
+ if event["type"] == "turn_context":
133
+ if isinstance(payload.get("model"), str):
134
+ models.add(payload["model"])
135
+ elif payload.get("model") is not None:
136
+ errors.append("invalid trace model")
137
+ if isinstance(payload.get("effort"), str):
138
+ effort.add(payload["effort"])
139
+ elif payload.get("effort") is not None:
140
+ errors.append("invalid trace effort")
141
+ trace_events.append(decoded_trace)
142
+ if last_observed_usage is not None:
143
+ partial_usage.append(
144
+ {
145
+ "trace_sha256": "sha256:" + hashlib.sha256(trace.encode()).hexdigest(),
146
+ "tokens": last_observed_usage,
147
+ "status": "last observed session total; incomplete usage lower bound",
148
+ }
149
+ )
150
+ if turn_usage:
151
+ # Missing usage fields stay unknown instead of being silently undercounted.
152
+ common_keys = set.intersection(*(set(turn) for turn in turn_usage))
153
+ usage = {key: sum(turn[key] for turn in turn_usage) for key in sorted(common_keys)}
154
+ result = {
155
+ "command_items": commands,
156
+ "actual_primary_models": sorted(models),
157
+ "usage": usage,
158
+ "partial_usage": partial_usage if usage is None else [],
159
+ "native_dollars": None,
160
+ "dollars_status": "unknown",
161
+ "requested_effort": requested_effort,
162
+ "runtime_effort_evidence": sorted(effort),
163
+ "startup": startup,
164
+ "observed_cli_version": observed_cli_version(startup),
165
+ "other_tools": tools,
166
+ "stream_errors": errors,
167
+ "final": final[-1] if final else "",
168
+ "agent_messages": final,
169
+ "successful_native_completion": successful_completion and not errors,
170
+ }
171
+ if include_descendants:
172
+ accounting = _descendant_usage(stdout_events, trace_events, errors)
173
+ result.update(accounting)
174
+ return result
175
+
176
+
177
+ def failed_before_first_turn(execution: dict[str, Any]) -> str | None:
178
+ """The error of a Codex session that failed before any model turn, else None.
179
+
180
+ Such a session, typically a network or backend outage, never let the agent work, so its
181
+ empty submission is not an outcome. A timeout, or a failure after any usage, command, or
182
+ agent message, is the agent's attempt and stays scored.
183
+ """
184
+ if (
185
+ execution.get("timed_out") is True
186
+ or execution.get("usage") is not None
187
+ or any(execution.get(key) for key in ("partial_usage", "command_items", "agent_messages"))
188
+ ):
189
+ return None
190
+ for error in execution.get("stream_errors") or []:
191
+ if isinstance(error, dict) and error.get("type") == "turn.failed":
192
+ detail = error.get("error")
193
+ message = detail.get("message") if isinstance(detail, dict) else None
194
+ return message if isinstance(message, str) and message else "turn.failed"
195
+ return None
196
+
197
+
198
+ def _sum_usage(totals: list[dict[str, int]]) -> dict[str, int] | None:
199
+ if not totals:
200
+ return None
201
+ common_keys = set.intersection(*(set(total) for total in totals))
202
+ return {key: sum(total[key] for total in totals) for key in sorted(common_keys)}
203
+
204
+
205
+ def _token_usage(value: object) -> dict[str, int] | None:
206
+ if (
207
+ not isinstance(value, dict)
208
+ or not value
209
+ or not all(
210
+ isinstance(count, int) and not isinstance(count, bool) and count >= 0
211
+ for count in value.values()
212
+ )
213
+ ):
214
+ return None
215
+ return value
216
+
217
+
218
+ # Codex versions whose forked-child rollout layout has been checked against real traces.
219
+ _INHERITED_HISTORY_VERSIONS = frozenset({"0.162.1", "0.167.0"})
220
+
221
+
222
+ def admit_version(version: str) -> None:
223
+ """Refuse a Codex CLI whose session traces the usage parser has not been checked against."""
224
+ if version not in _INHERITED_HISTORY_VERSIONS:
225
+ raise ValueError(
226
+ f"Codex CLI {version} is not supported: token usage is parsed only for "
227
+ + ", ".join(sorted(_INHERITED_HISTORY_VERSIONS))
228
+ + ". Install a supported version, or check this version's rollouts and add it."
229
+ )
230
+
231
+
232
+ def _session_history(
233
+ events: list[dict[str, Any]], reasons: set[str]
234
+ ) -> tuple[dict[str, Any], list[dict[str, Any]], bool] | None:
235
+ metadata = [event.get("payload") for event in events if event.get("type") == "session_meta"]
236
+ first = events[0].get("payload") if events and events[0].get("type") == "session_meta" else None
237
+ inherited = isinstance(first, dict) and (
238
+ first.get("forked_from_id") is not None
239
+ or first.get("history_base") is not None
240
+ or first.get("subagent_history_start_ordinal") is not None
241
+ )
242
+ if not inherited:
243
+ if len(metadata) != 1 or not isinstance(metadata[0], dict):
244
+ reasons.add("trace lacks one unambiguous session identity")
245
+ return None
246
+ return metadata[0], events, False
247
+
248
+ # 0.162.1 protocol.rs defines this ordinal as the first own-history record.
249
+ # Earlier records are inherited model context, including other session headers.
250
+ # 0.167.0 child rollouts keep the same header fields, boundary, and ordinals.
251
+ assert isinstance(first, dict)
252
+ boundary = first.get("subagent_history_start_ordinal")
253
+ source = first.get("source")
254
+ subagent = source.get("subagent") if isinstance(source, dict) else None
255
+ spawned = subagent.get("thread_spawn") if isinstance(subagent, dict) else None
256
+ parent = first.get("parent_thread_id")
257
+ path = first.get("agent_path")
258
+ valid = (
259
+ first.get("cli_version") in _INHERITED_HISTORY_VERSIONS
260
+ and first.get("history_mode") == "paginated"
261
+ and first.get("history_base") is None
262
+ and isinstance(parent, str)
263
+ and bool(parent)
264
+ and first.get("forked_from_id") == parent
265
+ and isinstance(first.get("session_id"), str)
266
+ and isinstance(path, str)
267
+ and bool(path)
268
+ and isinstance(spawned, dict)
269
+ and spawned.get("parent_thread_id") == parent
270
+ and spawned.get("agent_path") == path
271
+ and isinstance(boundary, int)
272
+ and not isinstance(boundary, bool)
273
+ and 1 <= boundary < len(events)
274
+ and all(
275
+ isinstance(event.get("ordinal"), int)
276
+ and not isinstance(event.get("ordinal"), bool)
277
+ and event["ordinal"] == ordinal
278
+ for ordinal, event in enumerate(events)
279
+ )
280
+ and not any(event.get("type") == "session_meta" for event in events[boundary:])
281
+ )
282
+ if not valid:
283
+ reasons.add(f"inherited history is unsupported for session {first.get('id')}")
284
+ return None
285
+ return first, [events[0], *events[boundary:]], True
286
+
287
+
288
+ def _descendant_usage(
289
+ stdout: list[dict[str, Any]],
290
+ traces: list[list[dict[str, Any]]],
291
+ stream_errors: list[Any],
292
+ ) -> dict[str, Any]:
293
+ """Account once per native thread; unproven coverage remains a lower bound.
294
+
295
+ Codex 0.162.1 protocol.rs defines session_meta.id as the thread identity,
296
+ source.subagent.thread_spawn as lineage, and token_count as cumulative usage.
297
+ A successful task_complete closes a turn, not a delta to add to that usage.
298
+ """
299
+ reasons: set[str] = set()
300
+ sessions: dict[str, dict[str, Any]] = {}
301
+ for events in traces:
302
+ history = _session_history(events, reasons)
303
+ if history is None:
304
+ continue
305
+ meta, events, inherited = history
306
+ identity = meta.get("id")
307
+ if not isinstance(identity, str) or not identity:
308
+ reasons.add("trace lacks one unambiguous session identity")
309
+ continue
310
+ previous = sessions.get(identity)
311
+ if previous is not None:
312
+ old_events = previous["events"]
313
+ if events[: len(old_events)] == old_events:
314
+ previous["events"] = events
315
+ elif old_events[: len(events)] != events:
316
+ reasons.add(f"conflicting traces for session {identity}")
317
+ continue
318
+ source = meta.get("source")
319
+ subagent = source.get("subagent") if isinstance(source, dict) else None
320
+ spawned = subagent.get("thread_spawn") if isinstance(subagent, dict) else None
321
+ source_parent = spawned.get("parent_thread_id") if isinstance(spawned, dict) else None
322
+ parent = meta.get("parent_thread_id", source_parent)
323
+ if parent is None:
324
+ parent = source_parent
325
+ if source_parent is not None and parent != source_parent:
326
+ reasons.add(f"conflicting parent identity for session {identity}")
327
+ if parent is not None and (not isinstance(parent, str) or not parent):
328
+ reasons.add(f"invalid parent identity for session {identity}")
329
+ parent = None
330
+ if subagent is not None and parent is None:
331
+ reasons.add(f"unresolved subagent lineage for session {identity}")
332
+ sessions[identity] = {
333
+ "events": events,
334
+ "parent_thread_id": parent,
335
+ "inherited": inherited,
336
+ "agent_path": meta.get("agent_path"),
337
+ "root_session_id": meta.get("session_id"),
338
+ }
339
+
340
+ identity_safe = not reasons
341
+ roots = sorted(
342
+ identity for identity, session in sessions.items() if session["parent_thread_id"] is None
343
+ )
344
+ root = roots[0] if len(roots) == 1 else None
345
+ if root is None:
346
+ reasons.add("expected exactly one root session")
347
+ identity_safe = False
348
+ stdout_roots = set()
349
+ for event in stdout:
350
+ if event.get("type") == "thread.started":
351
+ if isinstance(event.get("thread_id"), str):
352
+ stdout_roots.add(event["thread_id"])
353
+ else:
354
+ reasons.add("invalid stdout root identity")
355
+ identity_safe = False
356
+ if stdout_roots and stdout_roots != {root}:
357
+ reasons.add("stdout root identity does not match captured sessions")
358
+ identity_safe = False
359
+
360
+ expected_children: set[str] = set()
361
+ expected_agent_paths: set[tuple[str, str]] = set()
362
+ models: set[str] = set()
363
+ child_models: set[str] = set()
364
+ effort: set[str] = set()
365
+ records = []
366
+ for identity, session in sorted(sessions.items()):
367
+ prior_reasons = set(reasons)
368
+ parent = session["parent_thread_id"]
369
+ ancestors = {identity}
370
+ while parent is not None and parent in sessions and parent not in ancestors:
371
+ ancestors.add(parent)
372
+ parent = sessions[parent]["parent_thread_id"]
373
+ if parent is not None or root not in ancestors:
374
+ reasons.add(f"session {identity} is not linked to the root")
375
+ identity_safe = False
376
+ if session["inherited"] and session["root_session_id"] != root:
377
+ reasons.add(f"inherited root identity does not match session {identity}")
378
+ identity_safe = False
379
+ total: dict[str, int] | None = None
380
+ complete = False
381
+ failed = False
382
+ pending_spawns: set[str] = set()
383
+ named_spawns: dict[str, str | None] = {}
384
+ own_turn: str | None = None
385
+ for event in session["events"]:
386
+ payload = event.get("payload")
387
+ if not isinstance(payload, dict):
388
+ continue
389
+ kind = payload.get("type") if event.get("type") == "event_msg" else None
390
+ if event.get("type") == "response_item":
391
+ call_id = payload.get("call_id")
392
+ if (
393
+ payload.get("type") == "function_call"
394
+ and payload.get("namespace") == "collaboration"
395
+ and payload.get("name") == "spawn_agent"
396
+ ):
397
+ if isinstance(call_id, str) and call_id:
398
+ if call_id in named_spawns:
399
+ reasons.add(f"duplicate named spawn for session {identity}")
400
+ named_spawns[call_id] = None
401
+ else:
402
+ reasons.add(f"unidentified named spawn for session {identity}")
403
+ if (
404
+ payload.get("type") == "function_call_output"
405
+ and isinstance(call_id, str)
406
+ and call_id in named_spawns
407
+ ):
408
+ try:
409
+ output = (
410
+ json.loads(payload["output"])
411
+ if isinstance(payload.get("output"), str)
412
+ else None
413
+ )
414
+ except ValueError:
415
+ output = None
416
+ path = output.get("task_name") if isinstance(output, dict) else None
417
+ if not isinstance(path, str) or not path:
418
+ reasons.add(f"unresolved named spawn for session {identity}")
419
+ else:
420
+ if named_spawns[call_id] not in (None, path):
421
+ reasons.add(f"conflicting named spawn result for session {identity}")
422
+ named_spawns[call_id] = path
423
+ expected_agent_paths.add((identity, path))
424
+ if event.get("type") == "turn_context":
425
+ complete = False
426
+ if session["inherited"] and (
427
+ own_turn is None or payload.get("turn_id") != own_turn
428
+ ):
429
+ reasons.add(f"unverified own turn for session {identity}")
430
+ identity_safe = False
431
+ model = payload.get("model")
432
+ if isinstance(model, str):
433
+ if identity == root:
434
+ models.add(model)
435
+ elif session["parent_thread_id"] is not None:
436
+ child_models.add(model)
437
+ if identity == root and isinstance(payload.get("effort"), str):
438
+ effort.add(payload["effort"])
439
+ if kind in {"task_started", "turn_started"}:
440
+ complete = False
441
+ own_turn = (
442
+ payload.get("turn_id") if isinstance(payload.get("turn_id"), str) else None
443
+ )
444
+ if kind in {"task_complete", "turn_complete"}:
445
+ complete = True
446
+ if session["inherited"] and (
447
+ own_turn is None or payload.get("turn_id") != own_turn
448
+ ):
449
+ reasons.add(f"unverified own turn for session {identity}")
450
+ identity_safe = False
451
+ if kind in {"error", "turn_aborted", "turn_failed"}:
452
+ failed = True
453
+ if kind == "token_count":
454
+ info = payload.get("info")
455
+ observed = (
456
+ _token_usage(info.get("total_token_usage")) if isinstance(info, dict) else None
457
+ )
458
+ if observed is not None:
459
+ if session["inherited"] and own_turn is None:
460
+ reasons.add(f"unverified own turn for session {identity}")
461
+ identity_safe = False
462
+ if session["inherited"] and observed != total:
463
+ last = (
464
+ _token_usage(info.get("last_token_usage"))
465
+ if isinstance(info, dict)
466
+ else None
467
+ )
468
+ if last is None or any(
469
+ last.get(key) != value - (total.get(key, 0) if total else 0)
470
+ for key, value in observed.items()
471
+ ):
472
+ reasons.add(f"unverified cumulative origin for session {identity}")
473
+ identity_safe = False
474
+ if total is not None and any(
475
+ key not in observed or observed[key] < value for key, value in total.items()
476
+ ):
477
+ reasons.add(f"nonmonotonic cumulative usage for session {identity}")
478
+ total = observed
479
+ elif isinstance(info, dict):
480
+ reasons.add(f"invalid cumulative usage for session {identity}")
481
+ if kind == "collab_agent_spawn_begin":
482
+ call_id = payload.get("call_id")
483
+ if isinstance(call_id, str):
484
+ pending_spawns.add(call_id)
485
+ else:
486
+ reasons.add(f"unidentified spawn for session {identity}")
487
+ if kind == "collab_agent_spawn_end":
488
+ call_id = payload.get("call_id")
489
+ if isinstance(call_id, str):
490
+ pending_spawns.discard(call_id)
491
+ child = payload.get("new_thread_id")
492
+ if isinstance(child, str) and child:
493
+ expected_children.add(child)
494
+ if isinstance(payload.get("model"), str):
495
+ child_models.add(payload["model"])
496
+ if child in sessions and sessions[child]["parent_thread_id"] != identity:
497
+ reasons.add(f"spawn lineage does not match session {child}")
498
+ identity_safe = False
499
+ has_usage = total is not None and {"input_tokens", "output_tokens"}.issubset(total)
500
+ if not has_usage:
501
+ reasons.add(f"missing usage for session {identity}")
502
+ if pending_spawns:
503
+ reasons.add(f"unresolved spawns for session {identity}")
504
+ if any(path is None for path in named_spawns.values()):
505
+ reasons.add(f"unresolved named spawn for session {identity}")
506
+ if not complete or failed:
507
+ reasons.add(f"incomplete or failed session {identity}")
508
+ session_reasons = reasons - prior_reasons
509
+ records.append(
510
+ {
511
+ "session_id": identity,
512
+ "parent_thread_id": session["parent_thread_id"],
513
+ "observed_usage": total,
514
+ "status": "complete"
515
+ if complete and not failed and has_usage and not session_reasons
516
+ else "incomplete",
517
+ "reasons": sorted(session_reasons),
518
+ }
519
+ )
520
+
521
+ # exec's stdout exposes collab tool receivers even if a child rollout was lost.
522
+ for event in stdout:
523
+ item = event.get("item")
524
+ if isinstance(item, dict) and item.get("type") == "collab_tool_call":
525
+ receivers = item.get("receiver_thread_ids")
526
+ if isinstance(receivers, list):
527
+ expected_children.update(value for value in receivers if isinstance(value, str))
528
+ for child in sorted(expected_children - sessions.keys()):
529
+ reasons.add(f"missing descendant session {child}")
530
+ for parent, path in sorted(expected_agent_paths):
531
+ matching = [
532
+ identity
533
+ for identity, session in sessions.items()
534
+ if session["parent_thread_id"] == parent and session["agent_path"] == path
535
+ ]
536
+ if not matching:
537
+ reasons.add(f"missing descendant agent {path} from session {parent}")
538
+ elif len(matching) != 1:
539
+ reasons.add(f"conflicting descendant identities for agent {path} from session {parent}")
540
+ identity_safe = False
541
+ if stream_errors:
542
+ reasons.add("native event stream contains errors")
543
+ root_turns = [
544
+ reported
545
+ for event in stdout
546
+ if event.get("type") == "turn.completed"
547
+ if (reported := _token_usage(event.get("usage"))) is not None
548
+ ]
549
+ root_usage = _sum_usage(root_turns)
550
+ if root is not None and root_usage is not None:
551
+ captured = next(
552
+ record["observed_usage"] for record in records if record["session_id"] == root
553
+ )
554
+ if captured is None or any(captured.get(key) != value for key, value in root_usage.items()):
555
+ reasons.add("root stdout usage does not match cumulative session usage")
556
+ observed_totals = [
557
+ record["observed_usage"] for record in records if record["observed_usage"] is not None
558
+ ]
559
+ observed_usage = _sum_usage(observed_totals)
560
+ # An ambiguous identity/history can include the same work more than once.
561
+ # Retain individual observations, but do not label their sum a lower bound.
562
+ complete = not reasons
563
+ return {
564
+ "usage": observed_usage if complete else None,
565
+ "partial_usage": [
566
+ {
567
+ "session_id": record["session_id"],
568
+ "tokens": record["observed_usage"],
569
+ "status": "last observed session total; incomplete usage lower bound",
570
+ }
571
+ for record in records
572
+ if not complete and identity_safe and record["observed_usage"] is not None
573
+ ],
574
+ "actual_primary_models": sorted(models),
575
+ "actual_descendant_models": sorted(child_models),
576
+ "runtime_effort_evidence": sorted(effort),
577
+ "usage_accounting": {
578
+ "status": "complete" if complete else "incomplete",
579
+ "scope": "root_and_descendants",
580
+ "root_session_id": root,
581
+ "reasons": sorted(reasons),
582
+ "sessions": records,
583
+ "observed_usage_lower_bound": observed_usage if identity_safe else None,
584
+ },
585
+ }