agent-postmortem 0.3.4__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 (56) hide show
  1. agent_postmortem/__init__.py +9 -0
  2. agent_postmortem/argerrors.py +160 -0
  3. agent_postmortem/claims.py +43 -0
  4. agent_postmortem/cli.py +2278 -0
  5. agent_postmortem/commands.py +1543 -0
  6. agent_postmortem/config.py +435 -0
  7. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
  8. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
  9. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
  10. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
  11. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
  12. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
  13. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
  14. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
  15. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
  16. agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
  17. agent_postmortem/detectors/__init__.py +98 -0
  18. agent_postmortem/detectors/attempt_failed.py +261 -0
  19. agent_postmortem/detectors/base.py +319 -0
  20. agent_postmortem/detectors/bash_backgrounded.py +72 -0
  21. agent_postmortem/detectors/bash_timeout.py +94 -0
  22. agent_postmortem/detectors/claim_contradiction.py +251 -0
  23. agent_postmortem/detectors/classifier_unavailable.py +73 -0
  24. agent_postmortem/detectors/codex_unavailable.py +102 -0
  25. agent_postmortem/detectors/codex_write_denied.py +100 -0
  26. agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
  27. agent_postmortem/detectors/mislabeled_kill.py +164 -0
  28. agent_postmortem/detectors/missing_artifact.py +159 -0
  29. agent_postmortem/detectors/permission_denied.py +99 -0
  30. agent_postmortem/detectors/silent_retry.py +163 -0
  31. agent_postmortem/detectors/structured_output_failure.py +91 -0
  32. agent_postmortem/detectors/tool_json_error.py +77 -0
  33. agent_postmortem/detectors/wasted_time.py +125 -0
  34. agent_postmortem/detectors/workflow_failed.py +62 -0
  35. agent_postmortem/disclosure.py +931 -0
  36. agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
  37. agent_postmortem/hooks_cli.py +1269 -0
  38. agent_postmortem/ledger.py +624 -0
  39. agent_postmortem/markers.py +505 -0
  40. agent_postmortem/models.py +815 -0
  41. agent_postmortem/observe.py +792 -0
  42. agent_postmortem/reader.py +4611 -0
  43. agent_postmortem/redact.py +1981 -0
  44. agent_postmortem/report.py +3010 -0
  45. agent_postmortem/rescue.py +2096 -0
  46. agent_postmortem/returns.py +499 -0
  47. agent_postmortem/rules.py +627 -0
  48. agent_postmortem/severity.py +275 -0
  49. agent_postmortem/state.py +1261 -0
  50. agent_postmortem/trend.py +391 -0
  51. agent_postmortem/watch.py +973 -0
  52. agent_postmortem-0.3.4.dist-info/METADATA +264 -0
  53. agent_postmortem-0.3.4.dist-info/RECORD +56 -0
  54. agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
  55. agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
  56. agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,499 @@
1
+ """The return state of a session subagent (docs/DESIGN.md 4.1, 4.13.1 "Return evidence", G-A).
2
+
3
+ A pure decision over what the parent side recorded: the launcher transcript's launches and their
4
+ results, every inspected transcript's SendMessage requests (resume rounds), and the main
5
+ transcript's accepted task notifications. The reader gathers the facts while it streams a
6
+ transcript (`ReturnFacts`, filled by reader._LaunchLog and by the main transcript's ParentIndex)
7
+ and calls `decide`; nothing here touches the filesystem, and nothing reads a modification time.
8
+ Standard library only.
9
+
10
+ Truth table T0 to T5 (4.13.1). T0: only the measured shapes are recognized, every other presence
11
+ or type is `unknown`/`malformed`. Whatever the table does not assign is `unknown`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import json
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+
22
+ ABSENT = object()
23
+
24
+ AGENT_LAUNCH_TOOLS = ("Agent", "Task")
25
+ SEND_TOOL = "SendMessage"
26
+ RECORDED_TOOLS = (*AGENT_LAUNCH_TOOLS, SEND_TOOL)
27
+ _TERMINAL_WORDS = frozenset({"completed", "failed", "killed", "stopped", "cancelled"})
28
+ _STOPPED_WORDS = frozenset({"killed", "stopped", "cancelled"})
29
+ QUEUED_PREFIX = "Message queued for delivery"
30
+
31
+ RETURNED = "returned"
32
+ REPORTED_FAILED = "reported_failed"
33
+ REPORTED_KILLED = "reported_killed"
34
+ PENDING = "pending"
35
+ UNKNOWN = "unknown"
36
+
37
+
38
+ def _id_key(value: object) -> str:
39
+ try:
40
+ return json.dumps(value, sort_keys=True, ensure_ascii=True)
41
+ except (TypeError, ValueError):
42
+ return repr(value)
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class UseInfo:
47
+ """One assistant `tool_use` block of a launch or resume tool."""
48
+
49
+ name: str | None
50
+ line: int
51
+ ts: datetime | None
52
+ to: object = ABSENT
53
+ recipient: object = ABSENT
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class ResultInfo:
58
+ """One `tool_result` block and the entry's `toolUseResult`, for the tools above."""
59
+
60
+ line: int
61
+ digest: str | None
62
+ is_error: bool
63
+ tur_kind: str # absent | object | string | other
64
+ agent_ids: tuple[str, ...]
65
+ bad_id: bool # an agentId is present and not a string
66
+ status: object = ABSENT
67
+ is_async: object = ABSENT
68
+ resumed: object = ABSENT
69
+ success: object = ABSENT
70
+ message: object = ABSENT
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class Note:
75
+ """An accepted main-transcript task notification (4.13.3)."""
76
+
77
+ line: int
78
+ task_id: str
79
+ tool_use_id: str
80
+ status: str
81
+ ts: datetime | None = None
82
+
83
+
84
+ @dataclass
85
+ class ReturnFacts:
86
+ """What one transcript recorded about launches and resume requests."""
87
+
88
+ path: Path | None = None
89
+ unreadable: bool = False
90
+ uses: dict[str, list[UseInfo]] = field(default_factory=dict)
91
+ results: dict[str, list[ResultInfo]] = field(default_factory=dict)
92
+ use_counts: dict[str, int] = field(default_factory=dict)
93
+ # GA-4 (4.13.1 T0): an Agent, Task or SendMessage `tool_use` whose id is not a string cannot
94
+ # key a launch or a request. `bad_named` is the bounded fact kept for it: every string named
95
+ # by its `to` or `recipient`, and every string agent id a result with the same non-string
96
+ # `tool_use_id` carries. `bad_links` keys those ids (as JSON text) to let such a result in.
97
+ bad_named: set[str] = field(default_factory=set)
98
+ bad_links: dict[str, str] = field(default_factory=dict)
99
+
100
+ def note_use(
101
+ self, name: str | None, tool_id: object, line: int, ts: datetime | None, tool_input: object,
102
+ ) -> None:
103
+ if not isinstance(tool_id, str):
104
+ if name in RECORDED_TOOLS:
105
+ self.bad_links[_id_key(tool_id)] = name
106
+ if isinstance(tool_input, dict):
107
+ self.bad_named.update(
108
+ v for v in (tool_input.get("to"), tool_input.get("recipient"))
109
+ if isinstance(v, str)
110
+ )
111
+ return
112
+ self.use_counts[tool_id] = self.use_counts.get(tool_id, 0) + 1
113
+ if name not in RECORDED_TOOLS:
114
+ return
115
+ data = tool_input if isinstance(tool_input, dict) else {}
116
+ self.uses.setdefault(tool_id, []).append(UseInfo(
117
+ name, line, ts,
118
+ data["to"] if "to" in data else ABSENT,
119
+ data["recipient"] if "recipient" in data else ABSENT,
120
+ ))
121
+
122
+ def note_bad_result(self, tool_id: object, block: dict, tur: object) -> None:
123
+ """A `tool_result` whose `tool_use_id` is not a string: when it is the id of a recorded
124
+ malformed use, the children it names are malformed too."""
125
+ if _id_key(tool_id) not in self.bad_links:
126
+ return
127
+ data = tur if isinstance(tur, dict) else {}
128
+ self.bad_named.update(
129
+ v for v in (block.get("agentId"), data.get("agentId"), data.get("resumedAgentId"))
130
+ if isinstance(v, str)
131
+ )
132
+
133
+ def note_result(
134
+ self, tool_id: str, block: dict, tur: object, tur_present: bool, line: int,
135
+ ) -> None:
136
+ if tool_id not in self.uses:
137
+ return
138
+ is_error = block.get("is_error") is True
139
+ ids: list[str] = []
140
+ bad = False
141
+ for value in (block.get("agentId", ABSENT), tur.get("agentId", ABSENT) if isinstance(
142
+ tur, dict) else ABSENT):
143
+ if value is ABSENT:
144
+ continue
145
+ if isinstance(value, str):
146
+ ids.append(value)
147
+ else:
148
+ bad = True
149
+ kind = "absent"
150
+ if tur_present:
151
+ kind = "other"
152
+ if isinstance(tur, dict):
153
+ kind = "object"
154
+ elif isinstance(tur, str):
155
+ kind = "string"
156
+ data = tur if isinstance(tur, dict) else {}
157
+ try:
158
+ digest = hashlib.sha256(
159
+ json.dumps([block, tur if tur_present else None, tur_present], sort_keys=True,
160
+ ensure_ascii=True).encode("ascii")
161
+ ).hexdigest()
162
+ except (TypeError, ValueError):
163
+ digest = None
164
+ self.results.setdefault(tool_id, []).append(ResultInfo(
165
+ line, digest, is_error, kind, tuple(ids), bad,
166
+ data.get("status", ABSENT), data.get("isAsync", ABSENT),
167
+ data.get("resumedAgentId", ABSENT), data.get("success", ABSENT),
168
+ data.get("message", ABSENT),
169
+ ))
170
+
171
+
172
+ @dataclass
173
+ class Decision:
174
+ state: str
175
+ reason: str = "none"
176
+ resumes: int = 0
177
+ # what decided a `reported_failed` state: ("launch_result" | "notification", line, in_launcher)
178
+ evidence: tuple[str, int, bool] | None = None
179
+
180
+
181
+ def normalized_status(word: object) -> str:
182
+ """completed | failed | killed (killed, stopped, cancelled) | unrecognized."""
183
+ if word == "completed":
184
+ return "completed"
185
+ if word == "failed":
186
+ return "failed"
187
+ if isinstance(word, str) and word in _STOPPED_WORDS:
188
+ return "killed"
189
+ return "unrecognized"
190
+
191
+
192
+ _STATE_OF_WORD = {"completed": RETURNED, "failed": REPORTED_FAILED, "killed": REPORTED_KILLED}
193
+
194
+
195
+ def launcher_of(meta: dict, sibling_ids, own_id: str) -> tuple[str | None, str]:
196
+ """T0/T1 over the child's meta: ("main", "none"), (parent subagent id, "none"), or
197
+ (None, reason) with reason `malformed` or `parent_missing`."""
198
+ parent = meta.get("parentAgentId", ABSENT)
199
+ depth = meta.get("spawnDepth", ABSENT)
200
+ if parent is not ABSENT and (
201
+ not isinstance(parent, str) or not parent or parent == own_id
202
+ ):
203
+ return None, "malformed"
204
+ if depth is not ABSENT and (
205
+ not isinstance(depth, int) or isinstance(depth, bool) or depth < 1
206
+ ):
207
+ return None, "malformed"
208
+ if meta.get("toolUseId", ABSENT) is not ABSENT and not isinstance(meta["toolUseId"], str):
209
+ return None, "malformed"
210
+ if parent is ABSENT:
211
+ return ("main", "none") if depth is ABSENT or depth < 2 else (None, "parent_missing")
212
+ if parent in sibling_ids:
213
+ return parent, "none"
214
+ return None, "parent_missing"
215
+
216
+
217
+ def _aware(ts: datetime) -> datetime:
218
+ return ts if ts.tzinfo is not None else ts.replace(tzinfo=timezone.utc)
219
+
220
+
221
+ def _distinct(results: list[ResultInfo]) -> list[ResultInfo]:
222
+ seen: set[str] = set()
223
+ out = []
224
+ for result in results:
225
+ if result.digest is not None:
226
+ if result.digest in seen:
227
+ continue
228
+ seen.add(result.digest)
229
+ out.append(result)
230
+ return out
231
+
232
+
233
+ def _send_uses(facts: ReturnFacts):
234
+ for tool_id, uses in facts.uses.items():
235
+ if uses and uses[0].name == SEND_TOOL:
236
+ yield tool_id, uses
237
+
238
+
239
+ @dataclass
240
+ class _Effect:
241
+ """What one SendMessage request does to one child."""
242
+
243
+ kind: str # resume | malformed | delivery_unknown
244
+ tool_id: str = ""
245
+ ts: datetime | None = None
246
+ line: int = 0
247
+
248
+
249
+ def request_effects(
250
+ facts: ReturnFacts, tool_id: str, uses: list[UseInfo], siblings: frozenset[str] = frozenset(),
251
+ ) -> dict[str, _Effect]:
252
+ """{child id: effect} of one SendMessage request (T0, T3). A rejected request, and a queued
253
+ one, touch no child. `siblings` are the ids of the session's children: a `to` or
254
+ `recipient` that is none of them is an agent name (GA-7), never a contradiction."""
255
+ use = uses[0]
256
+ results = _distinct(facts.results.get(tool_id, []))
257
+ if results and all(r.is_error for r in results):
258
+ return {}
259
+ present = [v for v in (use.to, use.recipient) if v is not ABSENT]
260
+ strings = {v for v in present if isinstance(v, str)}
261
+ children = strings & siblings
262
+ # well formed: at least one target field, every present field a string, and no two of them
263
+ # naming different children (the runtime writes both `to` and `recipient`, with one value;
264
+ # a value that is no child's id is an agent name and contradicts nothing)
265
+ fields_bad = (
266
+ (not present) or any(not isinstance(v, str) for v in present) or len(children) > 1
267
+ )
268
+ if not results:
269
+ kind = "malformed" if fields_bad else "delivery_unknown"
270
+ return {c: _Effect(kind, tool_id, use.ts, use.line) for c in strings}
271
+ if len(results) > 1:
272
+ kind = "malformed" if fields_bad else "delivery_unknown"
273
+ return {c: _Effect(kind, tool_id, use.ts, use.line) for c in strings}
274
+ result = results[0]
275
+ resume_shape = (
276
+ result.tur_kind == "object" and result.success is True
277
+ and isinstance(result.resumed, str)
278
+ )
279
+ queued_shape = (
280
+ result.tur_kind == "object" and result.success is True and result.resumed is ABSENT
281
+ and isinstance(result.message, str) and result.message.startswith(QUEUED_PREFIX)
282
+ )
283
+ named = set(strings)
284
+ if isinstance(result.resumed, str):
285
+ named.add(result.resumed)
286
+ if fields_bad or not (resume_shape or queued_shape):
287
+ return {c: _Effect("malformed", tool_id, use.ts, use.line) for c in named}
288
+ if queued_shape:
289
+ return {}
290
+ if children - {result.resumed}:
291
+ return {c: _Effect("malformed", tool_id, use.ts, use.line) for c in named}
292
+ return {result.resumed: _Effect("resume", tool_id, use.ts, use.line)}
293
+
294
+
295
+ def resume_count(
296
+ child: str, issuers: list[ReturnFacts], siblings: frozenset[str] = frozenset(),
297
+ ) -> int:
298
+ count = 0
299
+ for facts in issuers:
300
+ for tool_id, uses in _send_uses(facts):
301
+ effect = request_effects(facts, tool_id, uses, siblings).get(child)
302
+ if effect is not None and effect.kind == "resume":
303
+ count += 1
304
+ return count
305
+
306
+
307
+ def _launch_candidates(
308
+ child: str, launcher: ReturnFacts, notes: list[Note], meta_tool_use: object = ABSENT,
309
+ ):
310
+ agent_uses = {
311
+ tool_id for tool_id, uses in launcher.uses.items()
312
+ if uses and uses[0].name in AGENT_LAUNCH_TOOLS
313
+ }
314
+ by_result = {
315
+ tool_id for tool_id in agent_uses
316
+ if any(child in r.agent_ids for r in launcher.results.get(tool_id, []))
317
+ }
318
+ by_note = {n.tool_use_id for n in notes if n.tool_use_id in agent_uses}
319
+ # the child's meta `toolUseId` (a string) names the launching tool_use in its launcher
320
+ by_meta = (
321
+ {meta_tool_use}
322
+ if isinstance(meta_tool_use, str) and meta_tool_use in agent_uses else set()
323
+ )
324
+ return by_result, by_note, by_meta
325
+
326
+
327
+ def main_side_digest(
328
+ child: str, main: ReturnFacts, notes: list[Note], meta_tool_use: object = ABSENT,
329
+ ) -> str:
330
+ """H1: a bounded digest of the main-side evidence for this child: its launch and resume
331
+ `tool_use` ids, lines, timestamps and result classes, and its accepted notifications' ids,
332
+ statuses and lines."""
333
+ facts: list = []
334
+ by_result, by_note, by_meta = _launch_candidates(child, main, notes, meta_tool_use)
335
+ for tool_id in sorted(by_result | by_note | by_meta):
336
+ use = main.uses[tool_id][0]
337
+ facts.append([
338
+ "launch", tool_id, use.line, use.ts.isoformat() if use.ts else None,
339
+ main.use_counts.get(tool_id, 0),
340
+ [
341
+ [r.line, r.is_error, r.tur_kind, sorted(r.agent_ids), r.bad_id, repr(r.status),
342
+ repr(r.is_async)]
343
+ for r in _distinct(main.results.get(tool_id, []))
344
+ ],
345
+ ])
346
+ for tool_id, uses in _send_uses(main):
347
+ use = uses[0]
348
+ present = {v for v in (use.to, use.recipient) if isinstance(v, str)}
349
+ results = _distinct(main.results.get(tool_id, []))
350
+ resumed = any(r.resumed == child for r in results)
351
+ if child not in present and not resumed:
352
+ continue
353
+ facts.append([
354
+ "request", tool_id, use.line, use.ts.isoformat() if use.ts else None,
355
+ main.use_counts.get(tool_id, 0), repr(use.to), repr(use.recipient),
356
+ [
357
+ [r.line, r.is_error, r.tur_kind, repr(r.success), repr(r.resumed), repr(r.message)]
358
+ for r in results
359
+ ],
360
+ ])
361
+ facts.append(["meta_link", meta_tool_use if isinstance(meta_tool_use, str) else None])
362
+ facts.append(["bad", child in main.bad_named])
363
+ facts.append(["notes", sorted((n.tool_use_id, n.status, n.line) for n in notes)])
364
+ text = json.dumps(facts, sort_keys=True, default=repr)
365
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
366
+
367
+
368
+ def decide(
369
+ child: str, launcher: ReturnFacts, main: ReturnFacts, notes: list[Note],
370
+ issuers: list[ReturnFacts], siblings: frozenset[str] = frozenset(),
371
+ meta_tool_use: object = ABSENT,
372
+ ) -> Decision:
373
+ """T0 to T5 for `child` (T1's launcher choice is `launcher_of`; its failures never reach here).
374
+
375
+ `launcher` is the launcher transcript's facts (the main transcript's own when the child is
376
+ launched by the main thread), `main` the main transcript's, `notes` the accepted main
377
+ notifications whose task-id is the child, `issuers` every inspected transcript's facts that
378
+ may hold a resume request; `siblings` the ids of the session's children (GA-7)."""
379
+ resumes = resume_count(child, issuers, siblings)
380
+
381
+ def unknown(reason: str) -> Decision:
382
+ return Decision(UNKNOWN, reason, resumes)
383
+
384
+ # T0 (GA-4): a launch or request with a non-string tool_use id names this child
385
+ if any(child in f.bad_named for f in (launcher, main, *issuers)):
386
+ return unknown("malformed")
387
+
388
+ # T2: the launch
389
+ by_result, by_note, by_meta = _launch_candidates(child, launcher, notes, meta_tool_use)
390
+ candidates = by_result | by_note | by_meta
391
+ if not candidates:
392
+ return unknown("no_evidence")
393
+ if len(candidates) > 1:
394
+ return unknown("conflict")
395
+ launch_id = next(iter(candidates))
396
+ launch_use = launcher.uses[launch_id][0]
397
+ results = _distinct(launcher.results.get(launch_id, []))
398
+ if len(results) > 1:
399
+ return unknown("conflict")
400
+
401
+ sync_value: tuple[str, int] | None = None
402
+ if results:
403
+ result = results[0]
404
+ if (
405
+ result.is_error or result.tur_kind in ("string", "other") or result.bad_id
406
+ or (result.status is not ABSENT and not isinstance(result.status, str))
407
+ ):
408
+ return unknown("malformed")
409
+ ids = set(result.agent_ids)
410
+ if ids and ids != {child}:
411
+ return unknown("conflict")
412
+ status = result.status
413
+ if result.is_async is True and status in _TERMINAL_WORDS:
414
+ return unknown("malformed")
415
+ asynchronous = (
416
+ status == "async_launched"
417
+ or (result.is_async is True and status is ABSENT)
418
+ or (not ids and launch_id in by_note)
419
+ )
420
+ if not asynchronous:
421
+ if status is ABSENT or status == "completed":
422
+ sync_value = (RETURNED, result.line)
423
+ elif status == "failed":
424
+ sync_value = (REPORTED_FAILED, result.line)
425
+ elif status in _STOPPED_WORDS:
426
+ sync_value = (REPORTED_KILLED, result.line)
427
+ else:
428
+ return unknown("status_unrecognized")
429
+ elif launch_id not in by_note:
430
+ return unknown("no_evidence")
431
+
432
+ # T3: requests and rounds
433
+ named: list[tuple[str, UseInfo, ReturnFacts, _Effect]] = []
434
+ for facts in issuers:
435
+ for tool_id, uses in _send_uses(facts):
436
+ effect = request_effects(facts, tool_id, uses, siblings).get(child)
437
+ if effect is not None:
438
+ named.append((tool_id, uses[0], facts, effect))
439
+ unique = {id(f): f for f in [launcher, *issuers]}.values()
440
+
441
+ def carried_once(tool_id: str) -> bool:
442
+ return sum(f.use_counts.get(tool_id, 0) for f in unique) == 1
443
+
444
+ if not carried_once(launch_id) or not all(carried_once(item[0]) for item in named):
445
+ return unknown("conflict")
446
+ for _tool_id, _use, _facts, effect in named:
447
+ if effect.kind != "resume":
448
+ return unknown(effect.kind)
449
+ request_rounds = [(tool_id, use, facts) for tool_id, use, facts, _e in named]
450
+ rounds: list[tuple[str, UseInfo, ReturnFacts]] = [(launch_id, launch_use, launcher)]
451
+ if request_rounds:
452
+ stamps = [
453
+ _aware(use.ts) if use.ts is not None else None
454
+ for _t, use, _f in rounds + request_rounds
455
+ ]
456
+ if any(s is None for s in stamps) or len(set(stamps)) != len(stamps):
457
+ return unknown("ambiguous")
458
+ if any(s <= stamps[0] for s in stamps[1:]):
459
+ return unknown("ambiguous")
460
+ rounds = [rounds[0]] + [
461
+ r for _s, r in sorted(
462
+ zip(stamps[1:], request_rounds), key=lambda pair: pair[0]
463
+ )
464
+ ]
465
+ round_by_id = {tool_id: (use, facts) for tool_id, use, facts in rounds}
466
+
467
+ # T4: notifications
468
+ for n in notes:
469
+ if n.tool_use_id not in round_by_id:
470
+ return unknown("conflict")
471
+ use, facts = round_by_id[n.tool_use_id]
472
+ if facts is main:
473
+ if n.line <= use.line:
474
+ return unknown("ambiguous")
475
+ elif n.ts is None or use.ts is None or _aware(n.ts) <= _aware(use.ts):
476
+ return unknown("ambiguous")
477
+ latest_id = rounds[-1][0]
478
+ in_latest = [n for n in notes if n.tool_use_id == latest_id]
479
+
480
+ # T5
481
+ words = {normalized_status(n.status) for n in in_latest}
482
+ if "unrecognized" in words:
483
+ return unknown("status_unrecognized")
484
+ if len(words) > 1:
485
+ return unknown("conflict")
486
+ note_state = _STATE_OF_WORD[next(iter(words))] if words else None
487
+ sync = sync_value if latest_id == launch_id else None
488
+ if sync is not None and note_state is not None and sync[0] != note_state:
489
+ return unknown("conflict")
490
+ decided, evidence = PENDING, None
491
+ if note_state is not None:
492
+ decided = note_state
493
+ evidence = ("notification", min(n.line for n in in_latest), False)
494
+ elif sync is not None:
495
+ decided = sync[0]
496
+ evidence = ("launch_result", sync[1], True)
497
+ if decided != REPORTED_FAILED:
498
+ evidence = None
499
+ return Decision(decided, "none", resumes, evidence)