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.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- 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"
|