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,275 @@
1
+ """Severity as a function of recorded inputs (docs/DESIGN.md 4.3 "Inputs and
2
+ rule per detector", 4.13.4; milestone N5b).
3
+
4
+ Every detector asks `resolve()` for its finding's severity, passing the inputs
5
+ its row names; none of them chooses a severity any other way. A tuple no row
6
+ lists (including a value outside an input's vocabulary) is a contract error:
7
+ the answer is the detector's highest row, the run's status becomes
8
+ `INCOMPLETE`, and the run's diagnostics gain one line naming the detector and
9
+ the tuple. The module also holds the readers of the derived inputs
10
+ (`recovered`, `work_risk`, `absence`, `position`, `silence_class`), each one
11
+ fail-closed: whatever cannot be established is `unknown`.
12
+
13
+ Standard library only; no filesystem access except the one `os.stat` in
14
+ `absence`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ from . import ledger
22
+ from .models import Attempt, Run
23
+
24
+ SEVERITY_ORDER = ("critical", "major", "minor", "info")
25
+
26
+ # The one 300 s silence rule of docs/DESIGN.md 4.4.
27
+ LONG_SILENCE_SECONDS = 300.0
28
+
29
+ _TERMINAL_WORDS = frozenset({"completed", "failed", "stopped", "journal_resolved"})
30
+
31
+ # 4.13.4: the ledger effects that make `work_risk` present.
32
+ _PRESENT_EFFECTS = frozenset({"observed", "claimed_target", "attempted"})
33
+
34
+ # Vocabulary of every input (4.13.4 and the 4.3 table). A value outside its
35
+ # vocabulary matches no row.
36
+ VOCAB: dict[str, frozenset[str]] = {
37
+ "terminal": frozenset({
38
+ "completed", "failed", "stopped", "journal_resolved", "unknown", "none",
39
+ }),
40
+ "recovered": frozenset({"yes", "no", "unknown"}),
41
+ "work_risk": frozenset({"none", "present", "unknown"}),
42
+ "cause": frozenset({"runtime", "unattributed", "parent_stop", "human"}),
43
+ "silence": frozenset({"long", "short", "unknown"}),
44
+ "position": frozenset({"final", "non_final"}),
45
+ "clause": frozenset({
46
+ "tests_failed", "suite_failed", "ok_false", "ready_false", "blockers",
47
+ "report_path_missing", "self_reported_failure", "self_reported_blocked",
48
+ }),
49
+ "absence": frozenset({"present_now", "absent_now", "unknown", "none"}),
50
+ "obligation": frozenset({"none", "prompt_path", "claimed_path"}),
51
+ "outcome": frozenset({"completed", "other"}),
52
+ "later_success": frozenset({"yes", "no"}),
53
+ }
54
+
55
+ # detector id -> its input names, in the order the diagnostic prints them.
56
+ INPUT_NAMES: dict[str, tuple[str, ...]] = {
57
+ "workflow_failed": ("terminal",),
58
+ "attempt_failed": ("recovered",),
59
+ "silent_retry": ("recovered", "work_risk"),
60
+ "mislabeled_kill": ("cause", "silence", "position"),
61
+ "claim_contradiction": ("clause", "absence"),
62
+ "missing_artifact": ("obligation", "absence"),
63
+ "structured_output_failure": ("recovered",),
64
+ "codex_write_denied": (),
65
+ "codex_unavailable": ("outcome", "later_success"),
66
+ "bash_backgrounded": (),
67
+ "bash_timeout": (),
68
+ "tool_json_error": (),
69
+ "classifier_unavailable": (),
70
+ "git_cwd_mismatch": (),
71
+ "permission_denied": (),
72
+ "wasted_time": (),
73
+ }
74
+
75
+ _ANY = None # a row element that matches every value in the input's vocabulary
76
+
77
+ # detector id -> rows of (one element per input, severity). An element is a value,
78
+ # a tuple of values, or _ANY. First match wins (the rows never overlap).
79
+ RULES: dict[str, list[tuple[tuple, str]]] = {
80
+ "workflow_failed": [
81
+ (("failed",), "major"),
82
+ ],
83
+ "attempt_failed": [
84
+ (("no",), "major"),
85
+ (("unknown",), "major"),
86
+ (("yes",), "minor"),
87
+ ],
88
+ "silent_retry": [
89
+ ((("no", "unknown"), _ANY), "major"),
90
+ (("yes", ("present", "unknown")), "major"),
91
+ (("yes", "none"), "minor"),
92
+ ],
93
+ "mislabeled_kill": [
94
+ ((("runtime", "unattributed"), "long", "final"), "major"),
95
+ ((("runtime", "unattributed"), "long", "non_final"), "minor"),
96
+ ((("runtime", "unattributed"), ("short", "unknown"), _ANY), "info"),
97
+ ((("parent_stop", "human"), _ANY, _ANY), "info"),
98
+ ],
99
+ "claim_contradiction": [
100
+ ((("tests_failed", "suite_failed", "ok_false", "ready_false", "blockers",
101
+ "self_reported_failure", "self_reported_blocked"), "none"),
102
+ "major"),
103
+ (("report_path_missing", ("absent_now", "unknown")), "info"),
104
+ ],
105
+ "missing_artifact": [
106
+ (("prompt_path", ("absent_now", "unknown")), "info"),
107
+ ],
108
+ "structured_output_failure": [
109
+ (("no",), "major"),
110
+ (("unknown",), "major"),
111
+ (("yes",), "minor"),
112
+ ],
113
+ "codex_write_denied": [((), "minor")],
114
+ "codex_unavailable": [
115
+ (("completed", "no"), "major"),
116
+ (("completed", "yes"), "minor"),
117
+ (("other", _ANY), "minor"),
118
+ ],
119
+ "bash_backgrounded": [((), "minor")],
120
+ "bash_timeout": [((), "minor")],
121
+ "tool_json_error": [((), "minor")],
122
+ "classifier_unavailable": [((), "minor")],
123
+ "git_cwd_mismatch": [((), "minor")],
124
+ "permission_denied": [((), "minor")],
125
+ "wasted_time": [((), "info")],
126
+ }
127
+
128
+
129
+ def highest(detector: str) -> str:
130
+ """The most severe row the detector has: what a contract error answers."""
131
+ severities = {severity for _pattern, severity in RULES[detector]}
132
+ return next(level for level in SEVERITY_ORDER if level in severities)
133
+
134
+
135
+ def worst(levels: list[str]) -> str:
136
+ """The most severe of several severities (a finding with several parts)."""
137
+ return next(level for level in SEVERITY_ORDER if level in levels)
138
+
139
+
140
+ def _element_matches(element, name: str, value) -> bool:
141
+ if not isinstance(value, str) or value not in VOCAB[name]:
142
+ return False
143
+ if element is _ANY:
144
+ return True
145
+ if isinstance(element, tuple):
146
+ return value in element
147
+ return value == element
148
+
149
+
150
+ def _row_for(detector: str, inputs: dict) -> str | None:
151
+ names = INPUT_NAMES[detector]
152
+ if set(inputs) != set(names):
153
+ return None
154
+ for pattern, severity in RULES[detector]:
155
+ if all(
156
+ _element_matches(element, name, inputs[name])
157
+ for element, name in zip(pattern, names)
158
+ ):
159
+ return severity
160
+ return None
161
+
162
+
163
+ def resolve(run: Run, detector: str, **inputs) -> str:
164
+ """The severity the table gives `detector` for these inputs. A tuple the table
165
+ does not list is a contract error (4.13.4): the highest row, the run's status
166
+ `INCOMPLETE`, and one diagnostic per distinct detector and tuple. An unknown
167
+ detector id is a programming error (KeyError), not a contract error."""
168
+ names = INPUT_NAMES[detector]
169
+ found = _row_for(detector, inputs)
170
+ if found is not None:
171
+ return found
172
+ answer = highest(detector)
173
+ shown = ", ".join(f"{name}={inputs.get(name, '<missing>')}" for name in names)
174
+ line = (
175
+ f"severity: {detector} has no row for ({shown}); used the highest row, {answer}"
176
+ )
177
+ if line not in run.diagnostics:
178
+ run.diagnostics.append(line)
179
+ run.status = "INCOMPLETE"
180
+ return answer
181
+
182
+
183
+ # --- derived inputs -----------------------------------------------------------------------
184
+
185
+
186
+ def silence_class(seconds: float | None) -> str:
187
+ """`long` at 300 s or more (the one 4.4 rule), `short` below, `unknown` for None."""
188
+ if seconds is None:
189
+ return "unknown"
190
+ return "long" if seconds >= LONG_SILENCE_SECONDS else "short"
191
+
192
+
193
+ def run_is_terminal(run: Run) -> bool:
194
+ """The run's lifecycle terminal (4.13.3) is one of completed, failed, stopped,
195
+ journal_resolved. A session has no lifecycle and is never terminal."""
196
+ lifecycle = run.lifecycle
197
+ return lifecycle is not None and lifecycle.terminal in _TERMINAL_WORDS
198
+
199
+
200
+ def _same_key(run: Run, attempt: Attempt) -> list[Attempt]:
201
+ if attempt.key is None:
202
+ return [attempt]
203
+ group = [other for other in run.attempts if other.key == attempt.key]
204
+ return sorted(group, key=lambda other: other.ordinal)
205
+
206
+
207
+ def _completed_with_result(attempt: Attempt) -> bool:
208
+ """The journal closed this attempt with a `result` event. An attempt with no
209
+ journal record never completes with a result: nothing recorded one."""
210
+ return attempt.journal_state == "completed"
211
+
212
+
213
+ def position(run: Run, attempt: Attempt) -> str:
214
+ """`final` when no attempt of the same key has a higher ordinal (or the
215
+ attempt has no key), `non_final` otherwise."""
216
+ if attempt.key is None:
217
+ return "final"
218
+ later = any(
219
+ other.key == attempt.key and other.ordinal > attempt.ordinal for other in run.attempts
220
+ )
221
+ return "non_final" if later else "final"
222
+
223
+
224
+ def recovered(run: Run, attempt: Attempt, *, include_self: bool) -> str:
225
+ """`yes`, `no`, or `unknown` (4.13.4, 4.3 "How the derived inputs are read").
226
+
227
+ Candidates are the attempts of the same key with a higher ordinal, plus the
228
+ attempt itself when `include_self`. `yes` when any candidate completed with a
229
+ result. Otherwise the key's last attempt decides: `no` when it ended without
230
+ completing or the run is terminal, `unknown` when it has no terminal record
231
+ and the run is not terminal."""
232
+ group = _same_key(run, attempt)
233
+ later = [other for other in group if other.ordinal > attempt.ordinal]
234
+ candidates = ([attempt] if include_self else []) + later
235
+ if any(_completed_with_result(candidate) for candidate in candidates):
236
+ return "yes"
237
+ last = later[-1] if later else attempt
238
+ if last.journal_state in ("failed", "superseded") or last.outcome in (
239
+ "killed", "failed", "superseded",
240
+ ):
241
+ return "no"
242
+ if run_is_terminal(run):
243
+ return "no"
244
+ return "unknown"
245
+
246
+
247
+ def work_risk(run: Run) -> str:
248
+ """Run-level, in S2's order: a partial population or any partial (or absent)
249
+ population ledger is `unknown`; otherwise an `observed`, `claimed_target`, or
250
+ `attempted` entry in a population ledger is `present`; otherwise `none`."""
251
+ if run.risk_population_completeness != "complete":
252
+ return "unknown"
253
+ population = ledger.risk_population(run)
254
+ ledgers = [attempt.ledger for attempt in population]
255
+ if any(item is None or item.completeness != "complete" for item in ledgers):
256
+ return "unknown"
257
+ for item in ledgers:
258
+ if any(entry.effect in _PRESENT_EFFECTS for entry in item.entries):
259
+ return "present"
260
+ return "none"
261
+
262
+
263
+ def absence(path: str) -> str:
264
+ """`present_now`, `absent_now`, or `unknown` for one path. Not absolute, a
265
+ stat error other than not-found, or a path stat rejects outright is
266
+ `unknown`; every check is `observed_later` (D2), never an end-time fact."""
267
+ if not isinstance(path, str) or not os.path.isabs(path):
268
+ return "unknown"
269
+ try:
270
+ os.stat(path)
271
+ except (FileNotFoundError, NotADirectoryError):
272
+ return "absent_now"
273
+ except (OSError, ValueError):
274
+ return "unknown"
275
+ return "present_now"