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,624 @@
|
|
|
1
|
+
"""The write-evidence ledger, the risk population, and the repository grouping
|
|
2
|
+
(docs/DESIGN.md 4.13.1; milestone N4, part N4b-2).
|
|
3
|
+
|
|
4
|
+
Pure: standard library only, no filesystem access, no subprocess. The reader
|
|
5
|
+
fills each attempt's `ToolCall` list and a `TranscriptState` (what reading the
|
|
6
|
+
transcript saw); this module turns them into a `Ledger` per attempt, decides
|
|
7
|
+
which attempts are in the risk population and whether that population is
|
|
8
|
+
complete, and groups the population's paths by repository.
|
|
9
|
+
|
|
10
|
+
Nothing here renders and nothing here runs git: per-repository observation is
|
|
11
|
+
N5's. A ledger claims less, never more: whatever the recorded text cannot prove
|
|
12
|
+
becomes a `possible` entry or a partial reason.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from . import commands
|
|
20
|
+
from .models import (
|
|
21
|
+
LAUNCH_DIAGNOSTIC_PREFIXES,
|
|
22
|
+
Attempt,
|
|
23
|
+
Generation,
|
|
24
|
+
Ledger,
|
|
25
|
+
LedgerEntry,
|
|
26
|
+
Run,
|
|
27
|
+
ToolCall,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# --- the truth table's tool names (4.13.1, L6) ----------------------------------------
|
|
31
|
+
|
|
32
|
+
# Row 1: tool name -> where its target lives on the ToolCall.
|
|
33
|
+
FILE_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
34
|
+
# Row 7: the read-only tools, which never get an entry.
|
|
35
|
+
READ_ONLY_TOOLS = frozenset({
|
|
36
|
+
"Read", "Grep", "Glob", "NotebookRead", "WebFetch", "WebSearch", "ToolSearch", "TaskOutput",
|
|
37
|
+
"TaskGet", "TaskList", "TaskCreate", "TaskUpdate", "TaskStop", "ListAgents", "SendUserFile",
|
|
38
|
+
"AskUserQuestion", "SendFeedback", "Skill", "StructuredOutput", "SubagentHandback",
|
|
39
|
+
"Artifact", "ScheduleWakeup", "TodoWrite",
|
|
40
|
+
})
|
|
41
|
+
# The shell tools: a call to either carries `shell_context_unverified` (C-15).
|
|
42
|
+
SHELL_TOOLS = ("Bash", "Monitor")
|
|
43
|
+
|
|
44
|
+
# Completion mapping (4.13.11): ToolCall.completion -> LedgerEntry.completion.
|
|
45
|
+
COMPLETION = {
|
|
46
|
+
"completed_ok": "success",
|
|
47
|
+
"completed_error": "error",
|
|
48
|
+
"backgrounded": "no_result",
|
|
49
|
+
"no_result": "no_result",
|
|
50
|
+
"ambiguous": "ambiguous",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# The closed partial-reason codes, in the fixed order of 4.13.1.
|
|
54
|
+
REASON_ORDER = (
|
|
55
|
+
"transcript_missing",
|
|
56
|
+
"transcript_unreadable",
|
|
57
|
+
"undecodable_line",
|
|
58
|
+
"unexpected_entry_shape",
|
|
59
|
+
"unknown_entry_type",
|
|
60
|
+
"truncated_field",
|
|
61
|
+
"ambiguous_tool_id",
|
|
62
|
+
"unpaired_write_call",
|
|
63
|
+
"shell_context_unverified",
|
|
64
|
+
"possible_opaque_command",
|
|
65
|
+
"possible_delegation",
|
|
66
|
+
"possible_background_process",
|
|
67
|
+
"possible_unknown_tool",
|
|
68
|
+
"unassignable_attempt",
|
|
69
|
+
"discovery_incomplete",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Entry types a transcript may carry that the ledger ignores (4.13.1); anything
|
|
73
|
+
# else that is not `user` or `assistant` gives `unknown_entry_type`.
|
|
74
|
+
KNOWN_ENTRY_TYPES = frozenset({
|
|
75
|
+
"attachment", "system", "summary", "queue-operation", "last-prompt", "ai-title",
|
|
76
|
+
"atis-latch", "mode", "permission-mode", "pr-link", "agent-name", "bridge-session",
|
|
77
|
+
"custom-title", "file-history-snapshot", "file-history-delta", "cost-state", "frame-link",
|
|
78
|
+
# Claude Code 2.1.300+: the request log (an api-request-blob echoes earlier messages and is
|
|
79
|
+
# never a call the attempt made).
|
|
80
|
+
"api-request", "api-request-shape", "api-request-blob",
|
|
81
|
+
})
|
|
82
|
+
_ARTIFACT_PREFIX = "artifact-"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def entry_type_known(entry_type: str) -> bool:
|
|
86
|
+
"""Whether a transcript entry type other than user and assistant is one the
|
|
87
|
+
ledger ignores (4.13.1's known set, plus any type starting `artifact-`)."""
|
|
88
|
+
return entry_type in KNOWN_ENTRY_TYPES or entry_type.startswith(_ARTIFACT_PREFIX)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class TranscriptState:
|
|
93
|
+
"""What reading one transcript observed beyond its tool calls; filled by
|
|
94
|
+
the reader while it streams (reader-private, never exported)."""
|
|
95
|
+
|
|
96
|
+
missing: bool = False # the expected transcript does not exist (or C-5)
|
|
97
|
+
unreadable: bool = False # the file could not be opened
|
|
98
|
+
undecodable: bool = False # some line is not JSON
|
|
99
|
+
unexpected_shape: bool = False # a 4.13.1 shape rule was broken
|
|
100
|
+
unknown_type: bool = False # an entry type outside the known set
|
|
101
|
+
# docs/DESIGN.md 4.13.6 (N6): the top-level string `cwd` values seen, first-seen order
|
|
102
|
+
# (a dict used as an ordered set); cleared when the read is discarded as unreadable.
|
|
103
|
+
cwds: dict = field(default_factory=dict)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _ordered(reasons) -> list[str]:
|
|
107
|
+
known = [reason for reason in REASON_ORDER if reason in reasons]
|
|
108
|
+
extra = sorted(reason for reason in reasons if reason not in REASON_ORDER)
|
|
109
|
+
return known + extra
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- one call -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(frozen=True)
|
|
116
|
+
class CallLedger:
|
|
117
|
+
"""The entries one tool call produces, and the partial reasons that call
|
|
118
|
+
alone gives rise to."""
|
|
119
|
+
|
|
120
|
+
entries: list[LedgerEntry]
|
|
121
|
+
reasons: frozenset[str]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _absolute_cwd(cwd: str | None) -> str | None:
|
|
125
|
+
"""A call cwd is a repository candidate only when it is absolute (L9)."""
|
|
126
|
+
if isinstance(cwd, str) and cwd.startswith("/"):
|
|
127
|
+
return commands.normalize_path(cwd)
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _classification(call: ToolCall):
|
|
132
|
+
if call.classification is not None:
|
|
133
|
+
return call.classification
|
|
134
|
+
if call.tool_name in SHELL_TOOLS or call.tool_name in commands.DELEGATION_TOOLS:
|
|
135
|
+
return commands.classify_call(
|
|
136
|
+
call.tool_name, call.command, run_in_background=call.run_in_background, cwd=call.cwd
|
|
137
|
+
)
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def call_entries(call: ToolCall, transcript_name: str) -> CallLedger:
|
|
142
|
+
"""The ledger entries of one call by the per-call truth table (4.13.1, first
|
|
143
|
+
match wins): file tools (row 1), then the classification of N4a for a shell or
|
|
144
|
+
delegation call (rows 2 to 6), then the read-only tools (row 7), then every
|
|
145
|
+
other tool name (row 8)."""
|
|
146
|
+
tool = call.tool_name
|
|
147
|
+
reasons: set[str] = set()
|
|
148
|
+
if call.ambiguous:
|
|
149
|
+
reasons.add("ambiguous_tool_id")
|
|
150
|
+
if tool in SHELL_TOOLS:
|
|
151
|
+
reasons.add("shell_context_unverified")
|
|
152
|
+
completion = COMPLETION[call.completion]
|
|
153
|
+
success = completion == "success"
|
|
154
|
+
result_line = call.result_line if call.result_line is not None else call.line_no
|
|
155
|
+
line = f"{transcript_name}:{result_line}"
|
|
156
|
+
entries: list[LedgerEntry] = []
|
|
157
|
+
|
|
158
|
+
if tool in FILE_TOOLS:
|
|
159
|
+
raw = call.multiedit_path if tool == "MultiEdit" else call.path
|
|
160
|
+
cwd = _absolute_cwd(call.cwd)
|
|
161
|
+
path = None
|
|
162
|
+
if raw is None:
|
|
163
|
+
reasons.add(
|
|
164
|
+
"truncated_field" if call._path_state == "truncated" else "unexpected_entry_shape"
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
# S3: the lexical normalization; empty or relative with no
|
|
168
|
+
# absolute cwd is a shape failure, over 1,024 characters truncated.
|
|
169
|
+
resolved = commands.resolve_target(cwd, raw)
|
|
170
|
+
if resolved is None:
|
|
171
|
+
reasons.add("unexpected_entry_shape")
|
|
172
|
+
else:
|
|
173
|
+
path = resolved[0]
|
|
174
|
+
if resolved[1]:
|
|
175
|
+
reasons.add("truncated_field")
|
|
176
|
+
entries.append(LedgerEntry(
|
|
177
|
+
"observed" if success else "attempted", "tool_result", completion, path, cwd, line,
|
|
178
|
+
))
|
|
179
|
+
else:
|
|
180
|
+
classified = _classification(call)
|
|
181
|
+
if classified is not None:
|
|
182
|
+
if classified.effect is None: # row 4: read-only, no entry
|
|
183
|
+
pass
|
|
184
|
+
elif classified.effect == "claimed_target": # row 5
|
|
185
|
+
for target in classified.targets:
|
|
186
|
+
if target.truncated:
|
|
187
|
+
reasons.add("truncated_field")
|
|
188
|
+
entries.append(LedgerEntry(
|
|
189
|
+
"claimed_target" if success else "attempted", "command_grammar",
|
|
190
|
+
completion, target.path, target.repo, line,
|
|
191
|
+
))
|
|
192
|
+
else: # rows 2, 3, 6
|
|
193
|
+
reasons.add(f"possible_{classified.basis}")
|
|
194
|
+
# One entry per call (C3). A call with commit segments takes the
|
|
195
|
+
# single distinct candidate repository of those segments, or
|
|
196
|
+
# null when they name more than one; else the call's candidate.
|
|
197
|
+
repo = classified.repo
|
|
198
|
+
if classified.commit.segments:
|
|
199
|
+
distinct = {seg.repo for seg in classified.commit.segments}
|
|
200
|
+
repo = next(iter(distinct)) if len(distinct) == 1 else None
|
|
201
|
+
entries.append(LedgerEntry(
|
|
202
|
+
"possible", classified.basis, completion, None, repo, line,
|
|
203
|
+
))
|
|
204
|
+
elif tool in READ_ONLY_TOOLS: # row 7
|
|
205
|
+
pass
|
|
206
|
+
else: # row 8: every other tool name, including every mcp__ tool
|
|
207
|
+
reasons.add("possible_unknown_tool")
|
|
208
|
+
entries.append(LedgerEntry(
|
|
209
|
+
"possible", "unknown_tool", completion, None, _absolute_cwd(call.cwd), line,
|
|
210
|
+
))
|
|
211
|
+
|
|
212
|
+
if entries and call.result_line is None:
|
|
213
|
+
reasons.add("unpaired_write_call")
|
|
214
|
+
return CallLedger(entries, frozenset(reasons))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# --- one attempt ----------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def build_ledger(
|
|
221
|
+
tool_calls: list[ToolCall],
|
|
222
|
+
transcript_name: str | None,
|
|
223
|
+
state: TranscriptState | None = None,
|
|
224
|
+
*,
|
|
225
|
+
run_reasons=(),
|
|
226
|
+
) -> Ledger:
|
|
227
|
+
"""The ledger of one attempt: every call's entries in tool-use order, the
|
|
228
|
+
transcript-level reasons `state` records, and the run-level reasons the
|
|
229
|
+
caller passes. 'complete' exactly when no reason applies."""
|
|
230
|
+
state = state if state is not None else TranscriptState()
|
|
231
|
+
reasons: set[str] = set(run_reasons)
|
|
232
|
+
if state.missing:
|
|
233
|
+
reasons.add("transcript_missing")
|
|
234
|
+
if state.unreadable:
|
|
235
|
+
reasons.add("transcript_unreadable")
|
|
236
|
+
if state.undecodable:
|
|
237
|
+
reasons.add("undecodable_line")
|
|
238
|
+
if state.unexpected_shape:
|
|
239
|
+
reasons.add("unexpected_entry_shape")
|
|
240
|
+
if state.unknown_type:
|
|
241
|
+
reasons.add("unknown_entry_type")
|
|
242
|
+
entries: list[LedgerEntry] = []
|
|
243
|
+
if not (state.missing or state.unreadable):
|
|
244
|
+
for call in tool_calls:
|
|
245
|
+
produced = call_entries(call, transcript_name or "")
|
|
246
|
+
entries.extend(produced.entries)
|
|
247
|
+
reasons |= produced.reasons
|
|
248
|
+
ordered = _ordered(reasons)
|
|
249
|
+
return Ledger(entries, "partial" if ordered else "complete", ordered)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# --- generation assignment (workflow runs only) -----------------------------------------
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _generations(run: Run) -> list[Generation]:
|
|
256
|
+
"""The run's generations of the lifecycle's own parent-index snapshot, in
|
|
257
|
+
launch-line order (never a second parent read)."""
|
|
258
|
+
lifecycle = run.lifecycle
|
|
259
|
+
found = getattr(lifecycle, "_generations", None) if lifecycle is not None else None
|
|
260
|
+
return sorted(found or [], key=lambda generation: generation.launch_line)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _timestamps_usable(generations: list[Generation]) -> bool:
|
|
264
|
+
"""Every generation's launch call timestamp is present, and they strictly
|
|
265
|
+
increase in launch-line order (4.13.11, FA2)."""
|
|
266
|
+
previous = None
|
|
267
|
+
for generation in generations:
|
|
268
|
+
stamp = generation.launch_ts
|
|
269
|
+
if stamp is None:
|
|
270
|
+
return False
|
|
271
|
+
if previous is not None:
|
|
272
|
+
try:
|
|
273
|
+
if not stamp > previous:
|
|
274
|
+
return False
|
|
275
|
+
except TypeError:
|
|
276
|
+
return False
|
|
277
|
+
previous = stamp
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _at_or_before(stamp, first) -> bool:
|
|
282
|
+
"""Whether a launch timestamp is at or before an attempt's first timestamp;
|
|
283
|
+
two timestamps that cannot be compared (naive against aware) are not."""
|
|
284
|
+
try:
|
|
285
|
+
return stamp <= first
|
|
286
|
+
except TypeError:
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _timestamp_unplaceable(attempt: Attempt) -> bool:
|
|
291
|
+
"""4.13.1 (A5): the attempt has an event-bearing line with no usable `timestamp`
|
|
292
|
+
(absent, not a string, or unparseable) at or before the earliest line carrying
|
|
293
|
+
`first_ts`, so an event of it may precede `first_ts` and the attempt cannot be
|
|
294
|
+
placed in time."""
|
|
295
|
+
unusable = min((event.line_no for event in attempt.events if event.ts is None), default=None)
|
|
296
|
+
if unusable is None:
|
|
297
|
+
return False
|
|
298
|
+
first_line = min(
|
|
299
|
+
(event.line_no for event in attempt.events if event.ts == attempt.first_ts),
|
|
300
|
+
default=None,
|
|
301
|
+
)
|
|
302
|
+
return first_line is None or unusable <= first_line
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def generation_of(run: Run, attempt: Attempt) -> Generation | None:
|
|
306
|
+
"""The generation a workflow attempt belongs to: the one whose Workflow
|
|
307
|
+
`tool_use` (launch call) timestamp is the latest at or before the attempt's
|
|
308
|
+
`first_ts` (FA2: the call, not its result, bounds the generation). None when
|
|
309
|
+
the attempt cannot be placed: no `first_ts`, before the first launch call, no
|
|
310
|
+
generation at all, or any generation launch timestamp missing or not strictly
|
|
311
|
+
increasing (then nobody can be placed)."""
|
|
312
|
+
generations = _generations(run)
|
|
313
|
+
if not _timestamps_usable(generations):
|
|
314
|
+
return None
|
|
315
|
+
first = attempt.first_ts
|
|
316
|
+
if first is None or _timestamp_unplaceable(attempt):
|
|
317
|
+
return None
|
|
318
|
+
found = None
|
|
319
|
+
for generation in generations:
|
|
320
|
+
if _at_or_before(generation.launch_ts, first):
|
|
321
|
+
found = generation
|
|
322
|
+
return found
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def is_unassignable(run: Run, attempt: Attempt) -> bool:
|
|
326
|
+
"""A workflow attempt generation assignment could not place; a session
|
|
327
|
+
attempt is never unassignable (assignment applies to workflow attempts)."""
|
|
328
|
+
return run.kind == "workflow" and generation_of(run, attempt) is None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _launch_found_unused(run: Run) -> bool:
|
|
332
|
+
"""R2-4: the run has no generation but its parent snapshot holds launch evidence for it
|
|
333
|
+
that was deliberately not used: an `ambiguous_launch` lifecycle, or a launch diagnostic
|
|
334
|
+
(ambiguous, result-less, rejected, malformed) in the run's parent discovery diagnostics."""
|
|
335
|
+
lifecycle = run.lifecycle
|
|
336
|
+
if lifecycle is not None and lifecycle.source == "ambiguous_launch":
|
|
337
|
+
return True
|
|
338
|
+
parent = (getattr(run, "discovery", None) or {}).get("parent") or []
|
|
339
|
+
return any(
|
|
340
|
+
isinstance(text, str) and text.startswith(LAUNCH_DIAGNOSTIC_PREFIXES) for text in parent
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def unassignable_reason(run: Run, attempt: Attempt) -> str | None:
|
|
345
|
+
"""Why generation assignment could not place a workflow attempt (docs/DESIGN.md 4.7, field
|
|
346
|
+
milestone F P6, fix round 1 S-m4), checked in the order `generation_of` checks:
|
|
347
|
+
`no_launch_record` (the run has no generation and the parent holds no launch evidence for
|
|
348
|
+
it), `launch_unusable` (no generation, but a launch was found and not used),
|
|
349
|
+
`launch_times_unusable` (a generation's launch timestamp is missing or the
|
|
350
|
+
timestamps are not strictly increasing), then `no_usable_timestamp` (no first timestamp,
|
|
351
|
+
or an early line without one), else `before_launch` (the attempt starts before every
|
|
352
|
+
launch call). None for a placed attempt and for a session attempt."""
|
|
353
|
+
if not is_unassignable(run, attempt):
|
|
354
|
+
return None
|
|
355
|
+
generations = _generations(run)
|
|
356
|
+
if not generations:
|
|
357
|
+
return "launch_unusable" if _launch_found_unused(run) else "no_launch_record"
|
|
358
|
+
if not _timestamps_usable(generations):
|
|
359
|
+
return "launch_times_unusable"
|
|
360
|
+
if attempt.first_ts is None or _timestamp_unplaceable(attempt):
|
|
361
|
+
return "no_usable_timestamp"
|
|
362
|
+
return "before_launch"
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# --- the risk population (4.13.1) ---------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
_OUTCOMES_AT_RISK = frozenset({"killed", "failed", "superseded", "unknown"})
|
|
368
|
+
_NEWEST_TERMINALS_AT_RISK = frozenset({"failed", "stopped", "unknown"})
|
|
369
|
+
_NOT_ACTIVITY_KINDS = frozenset({"thinking", "attachment"})
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _workflow_attempt_at_risk(run: Run, attempt: Attempt, newest: Generation | None) -> bool:
|
|
373
|
+
# An attempt with no journal key (journal_state None) can never be closed by
|
|
374
|
+
# the journal: it is treated like an open one (4.13.1, S2).
|
|
375
|
+
if (
|
|
376
|
+
attempt.outcome in _OUTCOMES_AT_RISK
|
|
377
|
+
or attempt.journal_state == "open"
|
|
378
|
+
or attempt.journal_state is None
|
|
379
|
+
):
|
|
380
|
+
return True
|
|
381
|
+
placed = generation_of(run, attempt)
|
|
382
|
+
if placed is None: # unassignable: never assumed safe
|
|
383
|
+
return True
|
|
384
|
+
return (
|
|
385
|
+
newest is not None
|
|
386
|
+
and placed is newest
|
|
387
|
+
and newest.terminal in _NEWEST_TERMINALS_AT_RISK
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _meaningful(event) -> bool:
|
|
392
|
+
return event.kind not in _NOT_ACTIVITY_KINDS and bool(event.text.strip())
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _main_ends_in_its_interrupt(main: Attempt) -> bool:
|
|
396
|
+
"""The interrupt is the final meaningful main-thread event in physical order:
|
|
397
|
+
the main attempt has an interrupt and no meaningful event follows the last
|
|
398
|
+
user event (the kill sentinel)."""
|
|
399
|
+
if main.interrupt is None:
|
|
400
|
+
return False
|
|
401
|
+
last_user = None
|
|
402
|
+
for index, event in enumerate(main.events):
|
|
403
|
+
if event.kind == "user":
|
|
404
|
+
last_user = index
|
|
405
|
+
after = main.events[last_user + 1:] if last_user is not None else main.events
|
|
406
|
+
return not any(_meaningful(event) for event in after)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def risk_population(run: Run) -> list[Attempt]:
|
|
410
|
+
"""The attempts whose ledgers must be accounted for, in the total order
|
|
411
|
+
`main` first and then `Run.attempts` index order (4.13.1). Workflow runs:
|
|
412
|
+
outcome killed, failed, superseded, open, or unknown; the newest generation's
|
|
413
|
+
attempts when that generation failed, stopped, or is unknown; and every
|
|
414
|
+
unassignable attempt. Session runs: every subagent with an interrupt, and the
|
|
415
|
+
main attempt when the interrupt is its final meaningful event."""
|
|
416
|
+
members: list[Attempt] = []
|
|
417
|
+
if run.kind == "session":
|
|
418
|
+
if run.main is not None and _main_ends_in_its_interrupt(run.main):
|
|
419
|
+
members.append(run.main)
|
|
420
|
+
members.extend(attempt for attempt in run.attempts if attempt.interrupt is not None)
|
|
421
|
+
return members
|
|
422
|
+
generations = _generations(run)
|
|
423
|
+
newest = generations[-1] if generations else None
|
|
424
|
+
return [
|
|
425
|
+
attempt for attempt in run.attempts if _workflow_attempt_at_risk(run, attempt, newest)
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _discovery_incomplete(run: Run) -> bool:
|
|
430
|
+
return any(bool(diagnostics) for diagnostics in run.discovery.values())
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def risk_population_completeness(run: Run) -> tuple[str, list[str]]:
|
|
434
|
+
"""('complete' | 'partial', reasons) for the run's own risk population: the
|
|
435
|
+
reasons are `unassignable_attempt` (some workflow attempt could not be
|
|
436
|
+
placed) and `discovery_incomplete` (any discovery source reported a
|
|
437
|
+
diagnostic), in that order."""
|
|
438
|
+
reasons: list[str] = []
|
|
439
|
+
if run.kind == "workflow" and any(is_unassignable(run, a) for a in run.attempts):
|
|
440
|
+
reasons.append("unassignable_attempt")
|
|
441
|
+
if _discovery_incomplete(run):
|
|
442
|
+
reasons.append("discovery_incomplete")
|
|
443
|
+
return ("partial" if reasons else "complete", reasons)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _attempt_ledger(run: Run, attempt: Attempt) -> Ledger:
|
|
447
|
+
run_reasons = []
|
|
448
|
+
if is_unassignable(run, attempt):
|
|
449
|
+
run_reasons.append("unassignable_attempt")
|
|
450
|
+
if _discovery_incomplete(run):
|
|
451
|
+
run_reasons.append("discovery_incomplete")
|
|
452
|
+
state = attempt._transcript_state
|
|
453
|
+
if not isinstance(state, TranscriptState):
|
|
454
|
+
state = TranscriptState()
|
|
455
|
+
if attempt.transcript_path is None:
|
|
456
|
+
state = TranscriptState(missing=True)
|
|
457
|
+
name = attempt.transcript_path.name if attempt.transcript_path is not None else None
|
|
458
|
+
return build_ledger(attempt.tool_calls, name, state, run_reasons=run_reasons)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def attach_ledgers(run: Run) -> None:
|
|
462
|
+
"""Set every attempt's ledger (the session main thread included) and the
|
|
463
|
+
run's own risk-population completeness. Called by the reader once the run is
|
|
464
|
+
fully assembled (its discovery, lifecycle, and generations are final)."""
|
|
465
|
+
attempts = ([run.main] if run.main is not None else []) + list(run.attempts)
|
|
466
|
+
for attempt in attempts:
|
|
467
|
+
attempt.ledger = _attempt_ledger(run, attempt)
|
|
468
|
+
completeness, reasons = risk_population_completeness(run)
|
|
469
|
+
run.risk_population_completeness = completeness
|
|
470
|
+
run.risk_population_completeness_reasons = reasons
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# --- repository grouping (4.13.1, L14) ------------------------------------------------------
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
@dataclass
|
|
477
|
+
class RepoGroup:
|
|
478
|
+
paths: list[str] = field(default_factory=list)
|
|
479
|
+
attempts: list[str] = field(default_factory=list)
|
|
480
|
+
refused: bool = False
|
|
481
|
+
reasons: list[str] = field(default_factory=list)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@dataclass
|
|
485
|
+
class RepoGroups:
|
|
486
|
+
repos: dict[str, RepoGroup] = field(default_factory=dict)
|
|
487
|
+
unknown: RepoGroup = field(default_factory=RepoGroup)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class _Accumulator:
|
|
491
|
+
def __init__(self) -> None:
|
|
492
|
+
self.paths: set[str] = set()
|
|
493
|
+
self.attempts: list[str] = []
|
|
494
|
+
self.refused = False
|
|
495
|
+
self.reasons: set[str] = set()
|
|
496
|
+
|
|
497
|
+
def add(self, ref: str, *, path: str | None, refused: bool, reasons) -> None:
|
|
498
|
+
if ref not in self.attempts:
|
|
499
|
+
self.attempts.append(ref)
|
|
500
|
+
if path is not None:
|
|
501
|
+
self.paths.add(path)
|
|
502
|
+
if refused:
|
|
503
|
+
self.refused = True
|
|
504
|
+
self.reasons.update(reasons)
|
|
505
|
+
|
|
506
|
+
def freeze(self) -> RepoGroup:
|
|
507
|
+
return RepoGroup(sorted(self.paths), list(self.attempts), self.refused,
|
|
508
|
+
_ordered(self.reasons))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
_PATH_EFFECTS = frozenset({"observed", "claimed_target", "attempted"})
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def attempt_refs(run: Run) -> dict[int, str]:
|
|
515
|
+
"""`main` for a session's main thread, otherwise `attempts[<index in
|
|
516
|
+
Run.attempts>]` (it exists for every attempt, canonical id or not)."""
|
|
517
|
+
refs = {id(attempt): f"attempts[{index}]" for index, attempt in enumerate(run.attempts)}
|
|
518
|
+
if run.main is not None:
|
|
519
|
+
refs[id(run.main)] = "main"
|
|
520
|
+
return refs
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _ledger_of(attempt: Attempt) -> Ledger:
|
|
524
|
+
if attempt.ledger is not None:
|
|
525
|
+
return attempt.ledger
|
|
526
|
+
return Ledger([], "partial", ["transcript_missing"])
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _facts_written_targets(attempt: Attempt) -> list[str]:
|
|
530
|
+
"""`facts.paths_written` as normalized targets: a raw path a file tool wrote is resolved
|
|
531
|
+
against the call's own absolute cwd (the same placement the rescue plan uses), and the raw
|
|
532
|
+
spelling stands when nothing places a relative one."""
|
|
533
|
+
facts = attempt.facts
|
|
534
|
+
written = facts.paths_written if facts is not None else None
|
|
535
|
+
if not isinstance(written, list):
|
|
536
|
+
return []
|
|
537
|
+
targets: list[str] = []
|
|
538
|
+
for raw in written:
|
|
539
|
+
placed = False
|
|
540
|
+
for call in attempt.tool_calls:
|
|
541
|
+
if call.path != raw:
|
|
542
|
+
continue
|
|
543
|
+
cwd = call.cwd if isinstance(call.cwd, str) and call.cwd.startswith("/") else None
|
|
544
|
+
base = commands.normalize_path(cwd) if cwd is not None else None
|
|
545
|
+
resolved = commands.resolve_target(base, raw)
|
|
546
|
+
if resolved is not None and resolved[0] is not None:
|
|
547
|
+
targets.append(resolved[0])
|
|
548
|
+
placed = True
|
|
549
|
+
if not placed:
|
|
550
|
+
targets.append(commands.normalize_path(raw) if raw.startswith("/") else raw)
|
|
551
|
+
return targets
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def attempt_ledger_paths(
|
|
555
|
+
attempt: Attempt, effects: frozenset[str], *, include_facts_written: bool = False,
|
|
556
|
+
) -> list[str]:
|
|
557
|
+
"""The distinct normalized paths one attempt's ledger names with an effect in `effects`, in
|
|
558
|
+
first-seen order (docs/DESIGN.md 4.13.1, 4.13.8). `include_facts_written` adds
|
|
559
|
+
`facts.paths_written`, normalized the way the rescue plan normalizes it. Each surface that
|
|
560
|
+
counts paths (the AGENT table, scan, the relaunch brief) chooses its effect set and its
|
|
561
|
+
population itself; this is the one place that reads an attempt's paths."""
|
|
562
|
+
found: list[str] = []
|
|
563
|
+
seen: set[str] = set()
|
|
564
|
+
|
|
565
|
+
def add(path: str) -> None:
|
|
566
|
+
if path not in seen:
|
|
567
|
+
seen.add(path)
|
|
568
|
+
found.append(path)
|
|
569
|
+
|
|
570
|
+
if include_facts_written:
|
|
571
|
+
for target in _facts_written_targets(attempt):
|
|
572
|
+
add(target)
|
|
573
|
+
if attempt.ledger is not None:
|
|
574
|
+
for entry in attempt.ledger.entries:
|
|
575
|
+
if entry.path is not None and entry.effect in effects:
|
|
576
|
+
add(entry.path)
|
|
577
|
+
return found
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def risk_paths(run: Run) -> tuple[list[str], bool]:
|
|
581
|
+
"""(distinct paths, complete) over the run's 4.13.1 risk population: every ledger path of
|
|
582
|
+
effect `observed`, `claimed_target`, or `attempted`, plus `facts.paths_written`. `complete`
|
|
583
|
+
is true only when the population itself is complete and every ledger in it is (a
|
|
584
|
+
population attempt with no ledger is partial); the count is a lower bound otherwise."""
|
|
585
|
+
paths: list[str] = []
|
|
586
|
+
seen: set[str] = set()
|
|
587
|
+
complete = risk_population_completeness(run)[0] == "complete"
|
|
588
|
+
for attempt in risk_population(run):
|
|
589
|
+
if _ledger_of(attempt).completeness != "complete":
|
|
590
|
+
complete = False
|
|
591
|
+
for path in attempt_ledger_paths(attempt, _PATH_EFFECTS, include_facts_written=True):
|
|
592
|
+
if path not in seen:
|
|
593
|
+
seen.add(path)
|
|
594
|
+
paths.append(path)
|
|
595
|
+
return paths, complete
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def group_by_repo(run: Run) -> RepoGroups:
|
|
599
|
+
"""The risk population's ledger paths grouped by candidate repository (L14).
|
|
600
|
+
A partial ledger keeps its repositories and paths and marks them refused; a
|
|
601
|
+
shell call, not a ledger entry, sends its attempt to `unknown`."""
|
|
602
|
+
refs = attempt_refs(run)
|
|
603
|
+
repos: dict[str, _Accumulator] = {}
|
|
604
|
+
unknown = _Accumulator()
|
|
605
|
+
for attempt in risk_population(run):
|
|
606
|
+
ref = refs[id(attempt)]
|
|
607
|
+
built = _ledger_of(attempt)
|
|
608
|
+
partial = built.completeness == "partial"
|
|
609
|
+
for entry in built.entries:
|
|
610
|
+
if entry.repo is not None:
|
|
611
|
+
target = repos.setdefault(entry.repo, _Accumulator())
|
|
612
|
+
else:
|
|
613
|
+
target = unknown
|
|
614
|
+
target.add(
|
|
615
|
+
ref, path=entry.path if entry.effect in _PATH_EFFECTS else None,
|
|
616
|
+
refused=partial, reasons=built.reasons,
|
|
617
|
+
)
|
|
618
|
+
if partial and all(entry.repo is None for entry in built.entries):
|
|
619
|
+
unknown.add(ref, path=None, refused=True, reasons=built.reasons)
|
|
620
|
+
if any(call.tool_name in SHELL_TOOLS for call in attempt.tool_calls):
|
|
621
|
+
unknown.add(ref, path=None, refused=True, reasons=built.reasons)
|
|
622
|
+
return RepoGroups(
|
|
623
|
+
{repo: repos[repo].freeze() for repo in sorted(repos)}, unknown.freeze()
|
|
624
|
+
)
|