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,2096 @@
|
|
|
1
|
+
"""Guided rescue: `rescue plan` (a decision packet) and `rescue status`
|
|
2
|
+
(what was observed), docs/DESIGN.md 4.11, unit M4.
|
|
3
|
+
|
|
4
|
+
Both commands are read-only and stateless. Neither writes a file, runs a
|
|
5
|
+
check, or touches git beyond what `observe.py` already does. The main
|
|
6
|
+
Claude session or the human performs every action (`.reviews/proposals/
|
|
7
|
+
2026-09-v0.2-guided-rescue.md` section 3): this module only reads
|
|
8
|
+
transcripts, observes repo state through `observe.observe_groups`, and prints
|
|
9
|
+
facts. Per R8, it never claims to have confirmed something by running it --
|
|
10
|
+
it reports what it observed and names what it could not.
|
|
11
|
+
|
|
12
|
+
Two phases per command: a builder (`build_plan`/`build_status`) that
|
|
13
|
+
produces a plain dataclass tree of RAW, unredacted values, and a renderer
|
|
14
|
+
(text or JSON) that applies the active Redactor at the very end -- the same
|
|
15
|
+
split `report.py` uses (`_story_records` then both text and JSON consume
|
|
16
|
+
it), so text and JSON output can never drift apart on the facts, only on
|
|
17
|
+
formatting.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from . import commands, ledger, observe, reader, report
|
|
30
|
+
from . import config as config_module
|
|
31
|
+
from .models import FACTS_UNKNOWN, Attempt, Finding, LedgerEntry, LogicalAgent, Run
|
|
32
|
+
from .redact import Redactor
|
|
33
|
+
|
|
34
|
+
SCHEMA_VERSION = 3
|
|
35
|
+
|
|
36
|
+
# docs/DESIGN.md 4.9: the same outcomes observe.py's own candidate-path rule
|
|
37
|
+
# uses -- a unit of work is one whose attempts include at least one of these.
|
|
38
|
+
_RISK_OUTCOMES = frozenset({"killed", "failed", "superseded"})
|
|
39
|
+
|
|
40
|
+
# docs/DESIGN.md 4.11: the NEVER list -- a rescue must never undo with any of
|
|
41
|
+
# these, and `rescue status` flags one found in the transcript after the run
|
|
42
|
+
# ended.
|
|
43
|
+
_NEVER_SUBCOMMANDS = ("checkout", "restore", "reset", "stash", "clean")
|
|
44
|
+
_NEVER_LIST_RE = re.compile(r"\bgit\s+(" + "|".join(_NEVER_SUBCOMMANDS) + r")\b")
|
|
45
|
+
|
|
46
|
+
# docs/DESIGN.md 4.11 (F3 hardening): whether a Bash command run after the
|
|
47
|
+
# last counted check is PROVEN read-only, for rescue's own "did this session
|
|
48
|
+
# possibly write to the repository" question -- deliberately its own,
|
|
49
|
+
# narrower heuristic from reader._is_read_only_bash_command (docs/DESIGN.md
|
|
50
|
+
# 4.3), which answers a different question (can this tool_result's own text
|
|
51
|
+
# be trusted as a faithful quote of something already on disk) and, for that
|
|
52
|
+
# reason, treats `git status` as NOT read-only (it can trigger an index
|
|
53
|
+
# refresh) -- see tests/test_reader.py::test_bash_git_status_is_not_read_
|
|
54
|
+
# only_output, a deliberately pinned, unrelated distinction this function
|
|
55
|
+
# must not disturb. Here the question is only "did it write": `git status`
|
|
56
|
+
# never does, and neither does a `sed` invocation without an in-place flag;
|
|
57
|
+
# everything else follows the same shape reader._is_read_only_bash_command
|
|
58
|
+
# already uses (a fixed read command list, a redirection check, an
|
|
59
|
+
# interpreter/test-runner denylist).
|
|
60
|
+
_RESCUE_READ_ONLY_RE = re.compile(
|
|
61
|
+
r"\b(?:cat|sed|head|tail|grep|rg|nl|less|more|bat|wc|diff|ls|find)\b"
|
|
62
|
+
r"|\bgit\s+(?:show|diff|log|grep|blame|status)\b"
|
|
63
|
+
)
|
|
64
|
+
_RESCUE_NOT_READ_ONLY_RE = re.compile(
|
|
65
|
+
r"\b(?:node|codex|python|pytest|npm|npx|curl|make|tee)\b|\buv\s+run\b"
|
|
66
|
+
)
|
|
67
|
+
_SED_INPLACE_RE = re.compile(r"\bsed\b[^\n]*(?:-i\b|--in-place\b)")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _proven_read_only(command: str | None) -> bool:
|
|
71
|
+
if not isinstance(command, str) or not command.strip():
|
|
72
|
+
return False
|
|
73
|
+
if ">" in command:
|
|
74
|
+
return False
|
|
75
|
+
if _SED_INPLACE_RE.search(command):
|
|
76
|
+
return False
|
|
77
|
+
if _RESCUE_NOT_READ_ONLY_RE.search(command):
|
|
78
|
+
return False
|
|
79
|
+
return bool(_RESCUE_READ_ONLY_RE.search(command))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# A timestamp floor for sorting tool calls that carry no `ts` at all (a
|
|
83
|
+
# missing timestamp) alongside ones that do, so a None never crashes the
|
|
84
|
+
# sort and always sorts first (oldest), never last.
|
|
85
|
+
_MIN_TS = datetime.min.replace(tzinfo=timezone.utc)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- shared data model -------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class PathRow:
|
|
93
|
+
raw_path: str
|
|
94
|
+
display_key: str # repo-relative when resolvable, else raw_path
|
|
95
|
+
state: str # observe.DIRTY_NOW / CLEAN_NOW / MISSING_NOW / UNKNOWN
|
|
96
|
+
state_reason: str | None
|
|
97
|
+
attempt_ordinals: list[int]
|
|
98
|
+
later_write: bool
|
|
99
|
+
# docs/DESIGN.md 5.3b, 4.13.11: one (attempt ordinal, ledger entry) pair per
|
|
100
|
+
# ledger entry that names this path, in attempt order, nothing collapsed.
|
|
101
|
+
ledger: list[tuple[int, LedgerEntry]] = field(default_factory=list)
|
|
102
|
+
# docs/DESIGN.md 5.3b (N5c-1): the index of the observation group that lists
|
|
103
|
+
# this path (None when none does), the path's own observation (None when its
|
|
104
|
+
# group was refused, unknown, or not checked, or the path lay outside the
|
|
105
|
+
# toplevel), and whether the path still needs a person to look at it because
|
|
106
|
+
# its group was never checked.
|
|
107
|
+
group: int | None = None
|
|
108
|
+
observation: "observe.PathObservation | None" = None
|
|
109
|
+
unchecked: bool = False
|
|
110
|
+
# docs/DESIGN.md 4.11 (N5 fix 1, C3): the normalized absolute target the row is keyed by
|
|
111
|
+
# (with `group`); None for a hand-built row.
|
|
112
|
+
target: str | None = None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class InFlightRow:
|
|
117
|
+
tool: str | None
|
|
118
|
+
raw_path: str | None
|
|
119
|
+
display_key: str | None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class TaskInfo:
|
|
124
|
+
transcript_name: str
|
|
125
|
+
line_no: int
|
|
126
|
+
text: str # full, untruncated, harness-noise-stripped
|
|
127
|
+
length: int # true length of `text`, not merely len(excerpt)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class UnitPlan:
|
|
132
|
+
key: str
|
|
133
|
+
label: str
|
|
134
|
+
phase: str
|
|
135
|
+
attempts_count: int
|
|
136
|
+
last_end_word: str
|
|
137
|
+
task: TaskInfo | None
|
|
138
|
+
reported: dict | None # the LAST attempt's own Attempt.reported
|
|
139
|
+
paths: list[PathRow]
|
|
140
|
+
commits: list[str]
|
|
141
|
+
in_flight: list[InFlightRow]
|
|
142
|
+
agent_ids: list[str] = field(default_factory=list)
|
|
143
|
+
# docs/DESIGN.md 4.11 (F6 hardening): true when at least one of this
|
|
144
|
+
# unit's own killed/failed/superseded attempts has facts is None (no
|
|
145
|
+
# transcript at all) -- distinct from the checked/reason UNKNOWN line
|
|
146
|
+
# below, which is about the repo observation, not the transcript.
|
|
147
|
+
facts_unknown: bool = False
|
|
148
|
+
# docs/DESIGN.md 4.11 (N5c-2, plan S7): the declared `[[repos]]` roots that own
|
|
149
|
+
# this unit's paths (lexically, in order of first appearance) and their declared
|
|
150
|
+
# checks, in that order. Never taken from an observation.
|
|
151
|
+
repos: list[str] = field(default_factory=list)
|
|
152
|
+
declared_checks: list[str] = field(default_factory=list)
|
|
153
|
+
# docs/DESIGN.md 4.11 (N5 fix 1, S1): see `_unit_unknown_group`.
|
|
154
|
+
unknown_group: bool = False
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass
|
|
158
|
+
class RescuePlan:
|
|
159
|
+
run_id: str
|
|
160
|
+
kind: str
|
|
161
|
+
finished: bool
|
|
162
|
+
possibly_live: bool
|
|
163
|
+
nothing_to_rescue: bool
|
|
164
|
+
# docs/DESIGN.md 4.11 (F6 hardening): meaningful only when
|
|
165
|
+
# nothing_to_rescue is True. True is the cheerful "every attempt
|
|
166
|
+
# completed" case (run.finished and every attempt's outcome is
|
|
167
|
+
# 'completed'); False is "NOTHING TO RESCUE YET" -- an unfinished run,
|
|
168
|
+
# or one with an attempt that is neither completed nor killed/failed/
|
|
169
|
+
# superseded (for example 'unknown'), so the claim "every attempt
|
|
170
|
+
# completed" would not be true.
|
|
171
|
+
nothing_to_rescue_ok: bool
|
|
172
|
+
units: list[UnitPlan]
|
|
173
|
+
checked: bool
|
|
174
|
+
observation_reason: str | None
|
|
175
|
+
repo_root: str | None
|
|
176
|
+
config_warnings: list[str]
|
|
177
|
+
killed_brief: list[dict]
|
|
178
|
+
failed_brief: list[dict]
|
|
179
|
+
already_exists_paths: list[str]
|
|
180
|
+
already_exists_commits: list[str]
|
|
181
|
+
has_unknown_fact: bool
|
|
182
|
+
exit_code: int = 0
|
|
183
|
+
# N8 fix 1, S2: with no unit, the size of the risk population when it is not empty or not
|
|
184
|
+
# complete on a finished all-completed run (the sentence names it); None otherwise.
|
|
185
|
+
unchecked_attempts: int | None = None
|
|
186
|
+
# N3b P19: display string -> the raw spelling it came from, so local can
|
|
187
|
+
# place a repo-relative key by its original path.
|
|
188
|
+
already_exists_sources: dict[str, str] = field(default_factory=dict)
|
|
189
|
+
# docs/DESIGN.md 4.9, 5.3b (N5c-1): the run's observation, whose groups the
|
|
190
|
+
# JSON export lists and whose indexes the path rows name.
|
|
191
|
+
observation: "observe.RunObservation | None" = None
|
|
192
|
+
# docs/DESIGN.md 4.11 (N8b, K4.2): true only for a workflow run in which two attempts of
|
|
193
|
+
# different keys overlapped in time; the relaunch brief's fan-out caveat prints only then.
|
|
194
|
+
fan_out_overlap: bool = False
|
|
195
|
+
# docs/DESIGN.md 4.11 (R1, v0.3.1): why a plan with no unit has none, and for a session whose
|
|
196
|
+
# children have no established return the five-class breakdown; None whenever units exist
|
|
197
|
+
empty_state: str | None = None
|
|
198
|
+
return_breakdown: dict | None = None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- building the packet ------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _facts_lists(facts) -> tuple[list, list]:
|
|
205
|
+
if facts is None:
|
|
206
|
+
return [], []
|
|
207
|
+
written = facts.paths_written if isinstance(facts.paths_written, list) else []
|
|
208
|
+
in_flight = facts.in_flight if isinstance(facts.in_flight, list) else []
|
|
209
|
+
return written, in_flight
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _in_flight_paths(in_flight: list) -> list[str]:
|
|
213
|
+
paths = []
|
|
214
|
+
for entry in in_flight:
|
|
215
|
+
if isinstance(entry, dict):
|
|
216
|
+
path = entry.get("path")
|
|
217
|
+
if isinstance(path, str) and path:
|
|
218
|
+
paths.append(path)
|
|
219
|
+
return paths
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _unit_risk_attempts(unit: LogicalAgent) -> list[Attempt]:
|
|
223
|
+
return [a for a in unit.attempts if a.outcome in _RISK_OUTCOMES]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def qualifying_units(run: Run) -> list[LogicalAgent]:
|
|
227
|
+
"""docs/DESIGN.md 4.11: one unit of work per logical agent whose
|
|
228
|
+
attempts include a killed, failed, or superseded one."""
|
|
229
|
+
return [unit for unit in run.agents if _unit_risk_attempts(unit)]
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class _Contribution:
|
|
234
|
+
"""One way a unit's killed, failed, or superseded attempt names a path (N5 fix 1, C3 and
|
|
235
|
+
S1): from its facts (a file tool's `paths_written` or `in_flight`, with the spelling the call
|
|
236
|
+
used) or from a ledger entry (any tool the ledger gives a path). `target` is the normalized
|
|
237
|
+
absolute path (the raw spelling itself when nothing places a relative one); `repo` is the
|
|
238
|
+
call's candidate repository, the group key the ledger gives it."""
|
|
239
|
+
|
|
240
|
+
attempt: Attempt
|
|
241
|
+
target: str
|
|
242
|
+
raw: str | None
|
|
243
|
+
repo: str | None
|
|
244
|
+
entry: LedgerEntry | None = None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
_PATH_EFFECTS = ("observed", "claimed_target", "attempted")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _unit_contributions(unit: LogicalAgent) -> list[_Contribution]:
|
|
251
|
+
"""Every path contribution of the unit's risk attempts: the facts paths of all attempts
|
|
252
|
+
first (the same union `observe.py`'s own rule 2 computes, scoped to this one unit), then
|
|
253
|
+
every ledger entry path (so a MultiEdit or a path named in a shell command has a row)."""
|
|
254
|
+
found: list[_Contribution] = []
|
|
255
|
+
attempts = _unit_risk_attempts(unit)
|
|
256
|
+
for attempt in attempts:
|
|
257
|
+
written, in_flight = _facts_lists(attempt.facts)
|
|
258
|
+
seen: set[str] = set()
|
|
259
|
+
for raw in list(written) + _in_flight_paths(in_flight):
|
|
260
|
+
if raw in seen:
|
|
261
|
+
continue
|
|
262
|
+
seen.add(raw)
|
|
263
|
+
placed = False
|
|
264
|
+
for call in attempt.tool_calls:
|
|
265
|
+
if call.path != raw:
|
|
266
|
+
continue
|
|
267
|
+
cwd = call.cwd if isinstance(call.cwd, str) and call.cwd.startswith("/") else None
|
|
268
|
+
base = commands.normalize_path(cwd) if cwd is not None else None
|
|
269
|
+
resolved = commands.resolve_target(base, raw)
|
|
270
|
+
if resolved is not None and resolved[0] is not None:
|
|
271
|
+
found.append(_Contribution(attempt, resolved[0], raw, base))
|
|
272
|
+
placed = True
|
|
273
|
+
if not placed:
|
|
274
|
+
target = commands.normalize_path(raw) if raw.startswith("/") else raw
|
|
275
|
+
found.append(_Contribution(attempt, target, raw, None))
|
|
276
|
+
for attempt in sorted(attempts, key=lambda item: item.ordinal):
|
|
277
|
+
if attempt.ledger is None:
|
|
278
|
+
continue
|
|
279
|
+
for entry in attempt.ledger.entries:
|
|
280
|
+
if entry.path is not None and entry.effect in _PATH_EFFECTS:
|
|
281
|
+
found.append(_Contribution(attempt, entry.path, None, entry.repo, entry))
|
|
282
|
+
return found
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _unit_targets(unit: LogicalAgent) -> dict[str, list[_Contribution]]:
|
|
286
|
+
"""target -> its contributions, targets in first-seen order (facts paths first)."""
|
|
287
|
+
targets: dict[str, list[_Contribution]] = {}
|
|
288
|
+
for contribution in _unit_contributions(unit):
|
|
289
|
+
targets.setdefault(contribution.target, []).append(contribution)
|
|
290
|
+
return targets
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _listed_spellings(unit: LogicalAgent) -> list[str]:
|
|
294
|
+
"""The spellings the status text may need to pseudonymize, first seen first: the raw
|
|
295
|
+
spelling a facts path used, else the target of a ledger-only path."""
|
|
296
|
+
listed: list[str] = []
|
|
297
|
+
for contribution in _unit_contributions(unit):
|
|
298
|
+
spelling = contribution.raw if contribution.raw is not None else contribution.target
|
|
299
|
+
if spelling not in listed:
|
|
300
|
+
listed.append(spelling)
|
|
301
|
+
return listed
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@dataclass
|
|
305
|
+
class _RowSpec:
|
|
306
|
+
"""One future path row: a target, the observation group that lists it (None when no group
|
|
307
|
+
does), and the contributions that belong to that group."""
|
|
308
|
+
|
|
309
|
+
target: str
|
|
310
|
+
index: int | None
|
|
311
|
+
group: "observe.GroupObservation | None"
|
|
312
|
+
contributions: list[_Contribution]
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _row_specs(unit: LogicalAgent, observation) -> list[_RowSpec]:
|
|
316
|
+
"""Rows keyed by (normalized absolute target, group), never by the raw spelling (C3). A
|
|
317
|
+
contribution goes to the listing group whose candidate repository it names, else to the
|
|
318
|
+
first listing group; a group that lists the target but holds none of this unit's
|
|
319
|
+
contributions gets no row."""
|
|
320
|
+
specs: list[_RowSpec] = []
|
|
321
|
+
for target, contributions in _unit_targets(unit).items():
|
|
322
|
+
listing = [
|
|
323
|
+
(index, group) for index, group in enumerate(observation.groups)
|
|
324
|
+
if target in group.paths or target in group.unobserved_paths
|
|
325
|
+
]
|
|
326
|
+
if not listing:
|
|
327
|
+
specs.append(_RowSpec(target, None, None, contributions))
|
|
328
|
+
continue
|
|
329
|
+
assigned: dict[int, list[_Contribution]] = {index: [] for index, _group in listing}
|
|
330
|
+
for contribution in contributions:
|
|
331
|
+
home = next(
|
|
332
|
+
(index for index, group in listing if group.repo == contribution.repo),
|
|
333
|
+
listing[0][0],
|
|
334
|
+
)
|
|
335
|
+
assigned[home].append(contribution)
|
|
336
|
+
for index, group in listing:
|
|
337
|
+
if assigned[index]:
|
|
338
|
+
specs.append(_RowSpec(target, index, group, assigned[index]))
|
|
339
|
+
return specs
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _display_key(raw_path: str, cwd: str | None, repo_root: str | None) -> str:
|
|
343
|
+
"""The repo-relative form of raw_path, the same normalization
|
|
344
|
+
observe.py's own containment rule (4.9 rule 6) applies -- a pure string
|
|
345
|
+
operation, no filesystem access. Falls back to raw_path itself when
|
|
346
|
+
repo_root or cwd is unknown, or when the path does not resolve inside
|
|
347
|
+
repo_root (observe.py would have recorded it as UNKNOWN too)."""
|
|
348
|
+
if not repo_root:
|
|
349
|
+
return raw_path
|
|
350
|
+
abs_path = raw_path if os.path.isabs(raw_path) else os.path.join(cwd or "", raw_path)
|
|
351
|
+
norm = os.path.normpath(abs_path)
|
|
352
|
+
prefix = repo_root.rstrip(os.sep) + os.sep
|
|
353
|
+
if norm.startswith(prefix):
|
|
354
|
+
return norm[len(prefix):]
|
|
355
|
+
return raw_path
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# --- declared repositories (docs/DESIGN.md 4.11, N5c-2, plan S7) -----------------------
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def declared_roots(cfg: config_module.Config) -> dict[str, list[str]]:
|
|
362
|
+
"""The `[[repos]]` roots of the configuration, lexically normalized, never resolved
|
|
363
|
+
through git, each with its declared checks (the first table wins for a duplicated
|
|
364
|
+
root, which `duplicate_roots` reports as a configuration error)."""
|
|
365
|
+
roots: dict[str, list[str]] = {}
|
|
366
|
+
for repo in cfg.repos:
|
|
367
|
+
roots.setdefault(commands.normalize_path(repo.path), list(repo.checks))
|
|
368
|
+
return roots
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def duplicate_roots(cfg: config_module.Config) -> list[str]:
|
|
372
|
+
"""Normalized roots that two or more `[[repos]]` tables name, in order of first repeat."""
|
|
373
|
+
seen: set[str] = set()
|
|
374
|
+
repeated: list[str] = []
|
|
375
|
+
for repo in cfg.repos:
|
|
376
|
+
root = commands.normalize_path(repo.path)
|
|
377
|
+
if root in seen and root not in repeated:
|
|
378
|
+
repeated.append(root)
|
|
379
|
+
seen.add(root)
|
|
380
|
+
return repeated
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _owner(path: str | None, roots) -> str | None:
|
|
384
|
+
"""The longest declared root that contains `path` component-wise (`/work/repo2` is not
|
|
385
|
+
inside `/work/repo`), or None. Pure string logic; both must be absolute."""
|
|
386
|
+
if not isinstance(path, str) or not path.startswith("/"):
|
|
387
|
+
return None
|
|
388
|
+
best: str | None = None
|
|
389
|
+
for root in roots:
|
|
390
|
+
if not root.startswith("/"):
|
|
391
|
+
continue
|
|
392
|
+
try:
|
|
393
|
+
inside = os.path.commonpath([root, path]) == root
|
|
394
|
+
except ValueError:
|
|
395
|
+
inside = False
|
|
396
|
+
if inside and (best is None or len(root) > len(best)):
|
|
397
|
+
best = root
|
|
398
|
+
return best
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _target_owners(specs: list[_RowSpec], roots) -> dict[str, list[str]]:
|
|
402
|
+
"""target -> the declared roots that own it: the owner of the target itself, and when it
|
|
403
|
+
has none, the owner of the candidate repository of each group that lists it (a candidate
|
|
404
|
+
repository belongs to the longest declared root too)."""
|
|
405
|
+
owners: dict[str, list[str]] = {}
|
|
406
|
+
for spec in specs:
|
|
407
|
+
found = owners.setdefault(spec.target, [])
|
|
408
|
+
root = _owner(spec.target, roots)
|
|
409
|
+
if root is None and spec.group is not None and spec.group.repo is not None:
|
|
410
|
+
root = _owner(commands.normalize_path(spec.group.repo), roots)
|
|
411
|
+
if root is not None and root not in found:
|
|
412
|
+
found.append(root)
|
|
413
|
+
return owners
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _unit_repos(owners: dict[str, list[str]]) -> list[str]:
|
|
417
|
+
repos: list[str] = []
|
|
418
|
+
for roots_of_target in owners.values():
|
|
419
|
+
for root in roots_of_target:
|
|
420
|
+
if root not in repos:
|
|
421
|
+
repos.append(root)
|
|
422
|
+
return repos
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _path_ledger(unit: LogicalAgent, raw_path: str) -> list[tuple[int, LedgerEntry]]:
|
|
426
|
+
"""The ledger entries of the unit's killed, failed, or superseded attempts
|
|
427
|
+
that name `raw_path`, as (ordinal, entry) in attempt order. The facts keep
|
|
428
|
+
the path as the file tool spelled it; the ledger keeps it normalized
|
|
429
|
+
against the call's working directory (4.13.1), so each call that wrote
|
|
430
|
+
`raw_path` contributes its own normalized spelling to match on."""
|
|
431
|
+
pairs: list[tuple[int, LedgerEntry]] = []
|
|
432
|
+
for attempt in sorted(_unit_risk_attempts(unit), key=lambda item: item.ordinal):
|
|
433
|
+
if attempt.ledger is None:
|
|
434
|
+
continue
|
|
435
|
+
spellings: set[str] = set()
|
|
436
|
+
for call in attempt.tool_calls:
|
|
437
|
+
if call.path != raw_path:
|
|
438
|
+
continue
|
|
439
|
+
cwd = call.cwd if isinstance(call.cwd, str) and call.cwd.startswith("/") else None
|
|
440
|
+
base = commands.normalize_path(cwd) if cwd is not None else None
|
|
441
|
+
resolved = commands.resolve_target(base, raw_path)
|
|
442
|
+
if resolved is not None and resolved[0] is not None:
|
|
443
|
+
spellings.add(resolved[0])
|
|
444
|
+
if not spellings:
|
|
445
|
+
continue
|
|
446
|
+
pairs.extend(
|
|
447
|
+
(attempt.ordinal, entry) for entry in attempt.ledger.entries
|
|
448
|
+
if entry.path in spellings
|
|
449
|
+
)
|
|
450
|
+
return pairs
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _unit_path_rows(
|
|
454
|
+
run: Run, unit: LogicalAgent, observation, global_cwd: str | None,
|
|
455
|
+
) -> list[PathRow]:
|
|
456
|
+
rows: list[PathRow] = []
|
|
457
|
+
for spec in _row_specs(unit, observation):
|
|
458
|
+
writer_attempts: list[Attempt] = []
|
|
459
|
+
for contribution in spec.contributions:
|
|
460
|
+
if not any(contribution.attempt is seen for seen in writer_attempts):
|
|
461
|
+
writer_attempts.append(contribution.attempt)
|
|
462
|
+
ordinals = sorted({attempt.ordinal for attempt in writer_attempts})
|
|
463
|
+
raws = [c.raw for c in spec.contributions if c.raw is not None]
|
|
464
|
+
raw_path = raws[0] if raws else spec.target
|
|
465
|
+
later = observe._later_write(run, writer_attempts, spec.target) or any(
|
|
466
|
+
observe._later_write_observed(run, writer_attempts, raw) for raw in raws
|
|
467
|
+
)
|
|
468
|
+
path_obs = None
|
|
469
|
+
unchecked = False
|
|
470
|
+
display = raw_path
|
|
471
|
+
if spec.group is None:
|
|
472
|
+
state, reason = observe.UNKNOWN, "not found in the observation"
|
|
473
|
+
else:
|
|
474
|
+
group = spec.group
|
|
475
|
+
path_obs = group.paths.get(spec.target)
|
|
476
|
+
if path_obs is not None:
|
|
477
|
+
state, reason = path_obs.state, path_obs.reason
|
|
478
|
+
if group.repo_root:
|
|
479
|
+
display = spec.target[len(group.repo_root.rstrip(os.sep)) + 1:]
|
|
480
|
+
else:
|
|
481
|
+
state = observe.UNKNOWN
|
|
482
|
+
reason = group.reason if not group.checked else observe.REASON_OUTSIDE
|
|
483
|
+
unchecked = not group.checked
|
|
484
|
+
ledger_pairs = sorted(
|
|
485
|
+
((c.attempt.ordinal, c.entry) for c in spec.contributions if c.entry is not None),
|
|
486
|
+
key=lambda pair: pair[0],
|
|
487
|
+
)
|
|
488
|
+
rows.append(PathRow(
|
|
489
|
+
raw_path=raw_path, display_key=display, state=state, state_reason=reason,
|
|
490
|
+
attempt_ordinals=ordinals, later_write=later, ledger=ledger_pairs,
|
|
491
|
+
group=spec.index, observation=path_obs, unchecked=unchecked, target=spec.target,
|
|
492
|
+
))
|
|
493
|
+
return rows
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _unit_in_flight_rows(unit: LogicalAgent, global_cwd, repo_root, checked) -> list[InFlightRow]:
|
|
497
|
+
rows: list[InFlightRow] = []
|
|
498
|
+
for attempt in _unit_risk_attempts(unit):
|
|
499
|
+
_written, in_flight = _facts_lists(attempt.facts)
|
|
500
|
+
for entry in in_flight:
|
|
501
|
+
if not isinstance(entry, dict):
|
|
502
|
+
continue
|
|
503
|
+
tool = entry.get("tool")
|
|
504
|
+
raw_path = entry.get("path")
|
|
505
|
+
raw_path = raw_path if isinstance(raw_path, str) and raw_path else None
|
|
506
|
+
display = None
|
|
507
|
+
if raw_path is not None:
|
|
508
|
+
display = _display_key(raw_path, global_cwd, repo_root) if checked else raw_path
|
|
509
|
+
rows.append(InFlightRow(
|
|
510
|
+
tool=tool if isinstance(tool, str) else None, raw_path=raw_path,
|
|
511
|
+
display_key=display,
|
|
512
|
+
))
|
|
513
|
+
return rows
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _unit_commits(unit: LogicalAgent) -> list[str]:
|
|
517
|
+
seen: list[str] = []
|
|
518
|
+
for attempt in _unit_risk_attempts(unit):
|
|
519
|
+
facts = attempt.facts
|
|
520
|
+
commits = facts.commits if facts is not None and isinstance(facts.commits, list) else []
|
|
521
|
+
for commit_id in commits:
|
|
522
|
+
if commit_id not in seen:
|
|
523
|
+
seen.append(commit_id)
|
|
524
|
+
return seen
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _unit_has_unknown_facts(unit: LogicalAgent) -> bool:
|
|
528
|
+
"""docs/DESIGN.md 4.11 (F6 hardening): a risk attempt (killed/failed/
|
|
529
|
+
superseded) with facts is None -- no transcript at all -- counts as
|
|
530
|
+
UNKNOWN too, not only one whose transcript exists but could not be read
|
|
531
|
+
(FACTS_UNKNOWN). The old `continue` here silently dropped the None case
|
|
532
|
+
from the UNKNOWN accounting, so a unit whose only risk attempt had no
|
|
533
|
+
transcript could pass as fully known."""
|
|
534
|
+
for attempt in _unit_risk_attempts(unit):
|
|
535
|
+
facts = attempt.facts
|
|
536
|
+
if facts is None:
|
|
537
|
+
return True
|
|
538
|
+
for value in (
|
|
539
|
+
facts.paths_written, facts.in_flight, facts.commits, facts.cwds,
|
|
540
|
+
facts.background_unfinished, facts.last_tool,
|
|
541
|
+
facts.seconds_since_last_tool_at_end,
|
|
542
|
+
):
|
|
543
|
+
if value == FACTS_UNKNOWN:
|
|
544
|
+
return True
|
|
545
|
+
return False
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _first_user_event(attempt: Attempt):
|
|
549
|
+
for event in attempt.events:
|
|
550
|
+
if event.kind == "user":
|
|
551
|
+
return event
|
|
552
|
+
return None
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _read_raw_line_text(path: Path, line_no: int) -> str | None:
|
|
556
|
+
"""The full, untruncated user-message text on line_no of path, flattened
|
|
557
|
+
the same way reader._user_events does, then harness-noise-stripped the
|
|
558
|
+
same way reader._excerpt does -- everything but the 400-char bound,
|
|
559
|
+
since this is used only to report a true prompt length and to quote the
|
|
560
|
+
first 300 characters, both under the caller's own control. Read-only;
|
|
561
|
+
returns None on any parse trouble rather than raising."""
|
|
562
|
+
try:
|
|
563
|
+
with reader._transcript_reader(path) as lines:
|
|
564
|
+
for current, line, torn in lines:
|
|
565
|
+
if current != line_no:
|
|
566
|
+
continue
|
|
567
|
+
if torn:
|
|
568
|
+
return None
|
|
569
|
+
line = line.strip()
|
|
570
|
+
if not line:
|
|
571
|
+
return None
|
|
572
|
+
try:
|
|
573
|
+
entry = json.loads(line)
|
|
574
|
+
except (json.JSONDecodeError, ValueError, RecursionError):
|
|
575
|
+
return None
|
|
576
|
+
if not isinstance(entry, dict):
|
|
577
|
+
return None
|
|
578
|
+
message = entry.get("message")
|
|
579
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
580
|
+
if isinstance(content, str):
|
|
581
|
+
text = content
|
|
582
|
+
else:
|
|
583
|
+
text = reader._flatten_block_text(content)
|
|
584
|
+
return reader._strip_harness_noise(text)
|
|
585
|
+
except (OSError, UnicodeError):
|
|
586
|
+
return None
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _unit_task(unit: LogicalAgent) -> TaskInfo | None:
|
|
591
|
+
first_attempt = min(unit.attempts, key=lambda attempt: attempt.ordinal)
|
|
592
|
+
if first_attempt.transcript_path is None:
|
|
593
|
+
return None
|
|
594
|
+
event = _first_user_event(first_attempt)
|
|
595
|
+
if event is None:
|
|
596
|
+
return None
|
|
597
|
+
text = _read_raw_line_text(first_attempt.transcript_path, event.line_no)
|
|
598
|
+
if text is None:
|
|
599
|
+
text = event.text # already truncated/stripped fallback
|
|
600
|
+
return TaskInfo(
|
|
601
|
+
transcript_name=first_attempt.transcript_path.name, line_no=event.line_no,
|
|
602
|
+
text=text, length=len(text),
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
def _unit_unknown_group(run: Run, unit: LogicalAgent, observation) -> bool:
|
|
607
|
+
"""docs/DESIGN.md 4.11 (N5 fix 1, S1): one of the unit's risk attempts sits in a refused
|
|
608
|
+
or unknown group, or in a group with unobserved paths, so what the unit wrote is not fully
|
|
609
|
+
seen (a shell write, an attempt with no recorded path)."""
|
|
610
|
+
refs = ledger.attempt_refs(run)
|
|
611
|
+
mine = {refs[id(attempt)] for attempt in _unit_risk_attempts(unit) if id(attempt) in refs}
|
|
612
|
+
return any(
|
|
613
|
+
(group.refused or group.unobserved_paths) and mine.intersection(group.attempts)
|
|
614
|
+
for group in observation.groups
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _unit_plan(
|
|
619
|
+
run: Run, unit: LogicalAgent, observation, global_cwd: str | None,
|
|
620
|
+
roots: dict[str, list[str]],
|
|
621
|
+
) -> UnitPlan:
|
|
622
|
+
attempts_sorted = sorted(unit.attempts, key=lambda attempt: attempt.ordinal)
|
|
623
|
+
last_attempt = attempts_sorted[-1]
|
|
624
|
+
repos = _unit_repos(_target_owners(_row_specs(unit, observation), roots))
|
|
625
|
+
declared_checks: list[str] = []
|
|
626
|
+
for root in repos:
|
|
627
|
+
for check in roots[root]:
|
|
628
|
+
if check not in declared_checks:
|
|
629
|
+
declared_checks.append(check)
|
|
630
|
+
return UnitPlan(
|
|
631
|
+
key=unit.key,
|
|
632
|
+
label=unit.label,
|
|
633
|
+
phase=unit.phase,
|
|
634
|
+
attempts_count=len(attempts_sorted),
|
|
635
|
+
last_end_word=_end_word(last_attempt),
|
|
636
|
+
task=_unit_task(unit),
|
|
637
|
+
reported=last_attempt.reported,
|
|
638
|
+
paths=_unit_path_rows(run, unit, observation, global_cwd),
|
|
639
|
+
commits=_unit_commits(unit),
|
|
640
|
+
in_flight=_unit_in_flight_rows(
|
|
641
|
+
unit, global_cwd, observation.repo_root, observation.checked,
|
|
642
|
+
),
|
|
643
|
+
agent_ids=[attempt.agent_id for attempt in unit.attempts],
|
|
644
|
+
facts_unknown=any(a.facts is None for a in _unit_risk_attempts(unit)),
|
|
645
|
+
repos=repos,
|
|
646
|
+
declared_checks=declared_checks,
|
|
647
|
+
unknown_group=_unit_unknown_group(run, unit, observation),
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
_END_KILLED_CAUSE = {"parent_stop": "stopped by parent", "human": "interrupted"}
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _end_word(attempt: Attempt) -> str:
|
|
655
|
+
if attempt.outcome == "killed":
|
|
656
|
+
cause = attempt.interrupt.cause if attempt.interrupt is not None else None
|
|
657
|
+
return _END_KILLED_CAUSE.get(cause, "killed")
|
|
658
|
+
return attempt.outcome
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _killed_brief_rows(run: Run) -> list[dict]:
|
|
662
|
+
rows = []
|
|
663
|
+
for attempt in run.attempts:
|
|
664
|
+
if attempt.outcome != "killed" or attempt.interrupt is None:
|
|
665
|
+
continue
|
|
666
|
+
kill_ts = attempt.interrupt.kill_ts
|
|
667
|
+
time_str = kill_ts.astimezone(timezone.utc).strftime("%H:%MZ") if kill_ts else None
|
|
668
|
+
rows.append({
|
|
669
|
+
"label": attempt.label, "ordinal": attempt.ordinal, "time": time_str,
|
|
670
|
+
"silence_seconds": attempt.interrupt.silence_seconds,
|
|
671
|
+
})
|
|
672
|
+
return rows
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _failed_brief_rows(run: Run, findings: list[Finding]) -> list[dict]:
|
|
676
|
+
label_by_agent_id = {attempt.agent_id: attempt.label for attempt in run.attempts}
|
|
677
|
+
rows = []
|
|
678
|
+
for finding in findings:
|
|
679
|
+
if finding.detector != "attempt_failed":
|
|
680
|
+
continue
|
|
681
|
+
label = label_by_agent_id.get(finding.agent_id, finding.agent_id or "?")
|
|
682
|
+
rows.append({
|
|
683
|
+
"label": label,
|
|
684
|
+
"cause": finding.metrics.get("cause", "unattributed"),
|
|
685
|
+
"retry_now": finding.metrics.get("retry_now"),
|
|
686
|
+
"reset_time": finding.metrics.get("reset_time"),
|
|
687
|
+
})
|
|
688
|
+
return rows
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
_CONFIRMED_WRITE_EFFECTS = frozenset({"observed"})
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _already_exists(run: Run) -> tuple[list[str], list[str]]:
|
|
695
|
+
"""The brief's `already exists` (docs/DESIGN.md 4.11, 4.13.8, K4.4): confirmed writes only,
|
|
696
|
+
every attempt's ledger paths of effect `observed` plus `facts.paths_written`. A
|
|
697
|
+
`claimed_target` or `attempted` path never enters it, because neither effect proves that a
|
|
698
|
+
write happened. Commit ids are the summary-shaped ones the facts carry."""
|
|
699
|
+
paths: list[str] = []
|
|
700
|
+
commits: list[str] = []
|
|
701
|
+
for attempt in run.attempts:
|
|
702
|
+
for path in ledger.attempt_ledger_paths(
|
|
703
|
+
attempt, _CONFIRMED_WRITE_EFFECTS, include_facts_written=True,
|
|
704
|
+
):
|
|
705
|
+
if path not in paths:
|
|
706
|
+
paths.append(path)
|
|
707
|
+
facts = attempt.facts
|
|
708
|
+
if facts is None:
|
|
709
|
+
continue
|
|
710
|
+
commit_ids = facts.commits if isinstance(facts.commits, list) else []
|
|
711
|
+
for commit_id in commit_ids:
|
|
712
|
+
if commit_id not in commits:
|
|
713
|
+
commits.append(commit_id)
|
|
714
|
+
return paths, commits
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _fan_out_overlap(run: Run) -> bool:
|
|
718
|
+
"""docs/DESIGN.md 4.11 (K4.2): a workflow run in which two attempts of different keys
|
|
719
|
+
overlapped in time. Both attempts need a known first and last timestamp and the closed
|
|
720
|
+
intervals must intersect; an unknown timestamp never triggers it."""
|
|
721
|
+
if run.kind != "workflow":
|
|
722
|
+
return False
|
|
723
|
+
timed = [
|
|
724
|
+
(attempt.key, attempt.first_ts, attempt.last_ts)
|
|
725
|
+
for attempt in run.attempts
|
|
726
|
+
if attempt.key is not None and attempt.first_ts is not None
|
|
727
|
+
and attempt.last_ts is not None
|
|
728
|
+
]
|
|
729
|
+
for index, (key_a, first_a, last_a) in enumerate(timed):
|
|
730
|
+
for key_b, first_b, last_b in timed[index + 1:]:
|
|
731
|
+
if key_a != key_b and first_a <= last_b and first_b <= last_a:
|
|
732
|
+
return True
|
|
733
|
+
return False
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def empty_unit_state(run: Run) -> tuple[str, int]:
|
|
737
|
+
"""docs/DESIGN.md 4.11 (F6 hardening, N8b K4.3, N8 fix 1 S2): what a run with no unit may say.
|
|
738
|
+
('ok', 0): a FINISHED run whose every attempt's own outcome is `completed` AND whose 4.13.1
|
|
739
|
+
risk population is empty and complete, so "every attempt completed" is the whole truth.
|
|
740
|
+
('risk', n): the same finished all-completed run, but the risk population has `n` attempts
|
|
741
|
+
or is not complete, so WORK AT RISK may still hold unchecked work. ('yet', 0): everything
|
|
742
|
+
else. One rule for the sentence and the exit status of both `rescue plan` and `rescue
|
|
743
|
+
status`."""
|
|
744
|
+
if run.finished is not True or not all(a.outcome == "completed" for a in run.attempts):
|
|
745
|
+
return "yet", 0
|
|
746
|
+
population = ledger.risk_population(run)
|
|
747
|
+
completeness = run.risk_population_completeness
|
|
748
|
+
if completeness is None:
|
|
749
|
+
completeness = ledger.risk_population_completeness(run)[0]
|
|
750
|
+
if population or completeness != "complete":
|
|
751
|
+
return "risk", len(population)
|
|
752
|
+
return "ok", 0
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
NO_UNIT_UNESTABLISHED = "no_rescue_unit_unestablished_returns"
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def no_unit_state(run: Run, *, live: bool = False) -> tuple[str, dict | None]:
|
|
759
|
+
"""docs/DESIGN.md 4.11 (R1): (empty_state, return_breakdown) of a run with no unit. A session
|
|
760
|
+
with a subagent whose return is not established first; then the F6/N8 branches."""
|
|
761
|
+
unestablished = report.unestablished_returns(run)
|
|
762
|
+
if unestablished:
|
|
763
|
+
return NO_UNIT_UNESTABLISHED, report.return_breakdown(unestablished)
|
|
764
|
+
state, _count = empty_unit_state(run)
|
|
765
|
+
if state == "risk":
|
|
766
|
+
return "unchecked_risk", None
|
|
767
|
+
if state == "ok" and not live:
|
|
768
|
+
return "nothing_to_rescue", None
|
|
769
|
+
return "nothing_yet", None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _no_unit_sentence(run_id: str, breakdown: dict) -> str:
|
|
773
|
+
k = sum(breakdown.values())
|
|
774
|
+
return (
|
|
775
|
+
f"NO RESCUE UNIT: {k} {report._plural(k, 'subagent')} {report._have(k)} no established "
|
|
776
|
+
f"return ({report.breakdown_text(breakdown)}); run agent-postmortem run {run_id} to see "
|
|
777
|
+
"WORK AT RISK"
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def nothing_to_rescue_ok(run: Run) -> bool:
|
|
782
|
+
"""True exactly when `empty_unit_state` is 'ok': the one predicate for the cheerful
|
|
783
|
+
sentence and the exit 0 of both `rescue plan` and `rescue status` (K4.3, S2)."""
|
|
784
|
+
return empty_unit_state(run)[0] == "ok"
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _unchecked_sentence(count: int, run_id: str) -> str:
|
|
788
|
+
if count == 0: # N8 fix 2, R2-1: an empty but incomplete population has no exact count
|
|
789
|
+
return (
|
|
790
|
+
"NO KILLED OR FAILED ATTEMPT: the tool could not establish which attempts were at "
|
|
791
|
+
f"risk; see WORK AT RISK in agent-postmortem run {run_id}."
|
|
792
|
+
)
|
|
793
|
+
return (
|
|
794
|
+
f"NO KILLED OR FAILED ATTEMPT: {count} attempt(s) may still hold unchecked work; "
|
|
795
|
+
f"see WORK AT RISK in agent-postmortem run {run_id}."
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _lifecycle_unknown(run: Run) -> bool:
|
|
800
|
+
"""docs/DESIGN.md 4.13.3: the run's lifecycle terminal is `unknown` (a session has no
|
|
801
|
+
lifecycle, so never)."""
|
|
802
|
+
return run.lifecycle is not None and run.lifecycle.terminal == "unknown"
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _observation_reason(observation) -> str | None:
|
|
806
|
+
"""Why the repo state was not checked, for the plan's one-line note: the
|
|
807
|
+
first distinct reason of a group that was not checked, or the fixed sentence
|
|
808
|
+
when there is no group at all; None when some group was checked."""
|
|
809
|
+
if observation.checked:
|
|
810
|
+
return None
|
|
811
|
+
for group in observation.groups:
|
|
812
|
+
if group.reason:
|
|
813
|
+
return group.reason
|
|
814
|
+
return "no paths written by a killed or failed attempt"
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def build_plan(
|
|
818
|
+
run: Run, observation, findings: list[Finding], cfg: config_module.Config, *,
|
|
819
|
+
possibly_live: bool = False,
|
|
820
|
+
) -> RescuePlan:
|
|
821
|
+
"""The full decision packet's raw facts (docs/DESIGN.md 4.11). `observation`
|
|
822
|
+
is always observe.observe_groups(run) (N5c-1) -- the caller runs it once and
|
|
823
|
+
passes it in so a test can also pass a hand-built one. `possibly_live` is
|
|
824
|
+
computed by the caller (report.is_possibly_live), since it needs a file
|
|
825
|
+
stat this module has no business doing on its own."""
|
|
826
|
+
units_raw = qualifying_units(run)
|
|
827
|
+
global_cwd = observe._discover_cwd(run) if observation.checked else None
|
|
828
|
+
roots = declared_roots(cfg)
|
|
829
|
+
units = [_unit_plan(run, unit, observation, global_cwd, roots) for unit in units_raw]
|
|
830
|
+
|
|
831
|
+
already_raw_paths, already_commits = _already_exists(run)
|
|
832
|
+
already_paths: list[str] = []
|
|
833
|
+
already_sources: dict[str, str] = {}
|
|
834
|
+
for raw_path in already_raw_paths:
|
|
835
|
+
if observation.checked:
|
|
836
|
+
display = _display_key(raw_path, global_cwd, observation.repo_root)
|
|
837
|
+
else:
|
|
838
|
+
display = raw_path
|
|
839
|
+
if display not in already_paths:
|
|
840
|
+
already_paths.append(display)
|
|
841
|
+
already_sources[display] = raw_path
|
|
842
|
+
has_unknown = (not observation.checked) or any(
|
|
843
|
+
row.state == observe.UNKNOWN for unit in units for row in unit.paths
|
|
844
|
+
) or any(_unit_has_unknown_facts(unit) for unit in units_raw) or any(
|
|
845
|
+
group.refused or group.unobserved_paths for group in observation.groups
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
empty_state, unchecked_count = empty_unit_state(run) if not units else ("yet", 0)
|
|
849
|
+
unchecked = empty_state == "risk"
|
|
850
|
+
plan_state, plan_breakdown = (
|
|
851
|
+
no_unit_state(run, live=possibly_live) if not units else (None, None)
|
|
852
|
+
)
|
|
853
|
+
unestablished = plan_state == NO_UNIT_UNESTABLISHED
|
|
854
|
+
nothing_to_rescue = not units and not unchecked and not unestablished
|
|
855
|
+
# docs/DESIGN.md 4.11 (F6 hardening): "every attempt completed" is a
|
|
856
|
+
# claim about a FINISHED run where every attempt's own outcome is
|
|
857
|
+
# 'completed' -- not merely one with no killed/failed/superseded unit,
|
|
858
|
+
# which is also true of an unfinished run, or one whose only attempt is
|
|
859
|
+
# 'unknown' (no transcript at all, so not proven completed either).
|
|
860
|
+
all_completed = empty_state == "ok"
|
|
861
|
+
|
|
862
|
+
# docs/DESIGN.md 4.11, 4.13.11 (N5c-2): a lifecycle terminal `unknown` is an UNKNOWN
|
|
863
|
+
# fact of the whole run, whatever the units say.
|
|
864
|
+
lifecycle_unknown = _lifecycle_unknown(run)
|
|
865
|
+
if possibly_live or lifecycle_unknown or unchecked or unestablished:
|
|
866
|
+
exit_code = 4
|
|
867
|
+
elif nothing_to_rescue:
|
|
868
|
+
exit_code = 0 if all_completed else 4
|
|
869
|
+
elif has_unknown:
|
|
870
|
+
exit_code = 4
|
|
871
|
+
else:
|
|
872
|
+
exit_code = 0
|
|
873
|
+
|
|
874
|
+
return RescuePlan(
|
|
875
|
+
run_id=run.run_id,
|
|
876
|
+
kind=run.kind,
|
|
877
|
+
finished=run.finished,
|
|
878
|
+
possibly_live=possibly_live,
|
|
879
|
+
nothing_to_rescue=nothing_to_rescue,
|
|
880
|
+
nothing_to_rescue_ok=all_completed,
|
|
881
|
+
units=units,
|
|
882
|
+
checked=observation.checked,
|
|
883
|
+
observation_reason=_observation_reason(observation),
|
|
884
|
+
repo_root=observation.repo_root if observation.checked else None,
|
|
885
|
+
config_warnings=list(cfg.warnings),
|
|
886
|
+
killed_brief=_killed_brief_rows(run),
|
|
887
|
+
failed_brief=_failed_brief_rows(run, findings),
|
|
888
|
+
already_exists_paths=already_paths,
|
|
889
|
+
already_exists_commits=already_commits,
|
|
890
|
+
has_unknown_fact=has_unknown,
|
|
891
|
+
exit_code=exit_code,
|
|
892
|
+
already_exists_sources=already_sources,
|
|
893
|
+
observation=observation,
|
|
894
|
+
fan_out_overlap=_fan_out_overlap(run),
|
|
895
|
+
unchecked_attempts=unchecked_count if unchecked else None,
|
|
896
|
+
empty_state=plan_state,
|
|
897
|
+
return_breakdown=plan_breakdown,
|
|
898
|
+
)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
# --- text rendering ------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
_PATH_COL = 27
|
|
905
|
+
_STATE_COL = 11
|
|
906
|
+
_ATTEMPTS_COL = 10
|
|
907
|
+
_PATH_INDENT = " " * 12
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _register_plan_paths(plan: RescuePlan, redactor: Redactor, run: Run | None = None) -> None:
|
|
911
|
+
"""Every path the plan text may print, in one namespace. At local (N3b
|
|
912
|
+
P19) each display key is placed by the path function from the raw
|
|
913
|
+
spelling it came from, against the run's working directory; the
|
|
914
|
+
repository root is the one the plan's own observation resolved (D-n)."""
|
|
915
|
+
cwd = observe._discover_cwd(run) if run is not None else None
|
|
916
|
+
for unit in plan.units:
|
|
917
|
+
for row in unit.paths:
|
|
918
|
+
redactor.names.path(row.display_key, cwd, resolve_from=row.raw_path)
|
|
919
|
+
for entry in unit.in_flight:
|
|
920
|
+
if entry.display_key is not None:
|
|
921
|
+
redactor.names.path(entry.display_key, cwd, resolve_from=entry.raw_path)
|
|
922
|
+
for path in plan.already_exists_paths:
|
|
923
|
+
redactor.names.path(path, cwd, resolve_from=plan.already_exists_sources.get(path))
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _register_plan_names(run: Run, redactor: Redactor, *, detailed: bool = False) -> None:
|
|
927
|
+
report._register_names(run, redactor, detailed=detailed)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _piece(redactor: Redactor, value):
|
|
931
|
+
"""One piece of a rescue line or record, scanned where it enters it (docs/DESIGN.md 5.1; F fix
|
|
932
|
+
7, W7-4): a string goes through the one free-text scanner, anything else (a number, a bool,
|
|
933
|
+
None) is not text and is returned as it is. An assembled line is never scanned again, so
|
|
934
|
+
every piece that can hold text goes through this (or a pseudonym call) before it is joined."""
|
|
935
|
+
return redactor(value) if isinstance(value, str) else value
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def _raw_claim_value(value) -> str:
|
|
939
|
+
if isinstance(value, bool):
|
|
940
|
+
return "true" if value else "false"
|
|
941
|
+
if isinstance(value, list):
|
|
942
|
+
return ",".join(str(item) for item in value)
|
|
943
|
+
return str(value)
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _format_claim_value(key: str, value, redactor: Redactor) -> str:
|
|
947
|
+
# docs/DESIGN.md 4.11 (F7 hardening): reported.status is arbitrary,
|
|
948
|
+
# up-to-32-char free text (docs/DESIGN.md section 3, _REPORTED_STATUS_
|
|
949
|
+
# LIMIT) an agent can put almost anything into -- by default it prints
|
|
950
|
+
# as the fixed word "set", never its own value; --no-redact still shows
|
|
951
|
+
# the real string.
|
|
952
|
+
if key == "status":
|
|
953
|
+
# at local the claim is free text of up to 32 characters: scanned like any other value
|
|
954
|
+
return "set" if redactor.level == "share" else redactor(_raw_claim_value(value))
|
|
955
|
+
if key == "report_path" and isinstance(value, str):
|
|
956
|
+
return redactor.names.path(value)
|
|
957
|
+
if key == "commits" and isinstance(value, list):
|
|
958
|
+
# docs/DESIGN.md 4.13.5 (M9): the result's own commit list, commit-N at share
|
|
959
|
+
return ",".join(
|
|
960
|
+
redactor.names.commit(item) if isinstance(item, str) else str(item) for item in value
|
|
961
|
+
)
|
|
962
|
+
# a claim value is free text: scanned here, as its own piece (the assembled line is not
|
|
963
|
+
# scanned again, docs/DESIGN.md 5.1: the end of a line belongs to the path that starts in it)
|
|
964
|
+
return redactor(_raw_claim_value(value))
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _reported_text(reported: dict | None, redactor: Redactor) -> str:
|
|
968
|
+
if reported is None:
|
|
969
|
+
return "no result event"
|
|
970
|
+
if reported.get("type") == "text":
|
|
971
|
+
return f"text result, {reported.get('text_length', 0)} chars, not kept"
|
|
972
|
+
if reported.get("type") == "dict":
|
|
973
|
+
claims = reported.get("claims") or {}
|
|
974
|
+
if not claims:
|
|
975
|
+
return "dict result, no allowlisted claims"
|
|
976
|
+
parts = [
|
|
977
|
+
f"{key}: {_format_claim_value(key, value, redactor)}" for key, value in claims.items()
|
|
978
|
+
]
|
|
979
|
+
return ", ".join(parts)
|
|
980
|
+
return "no result event"
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _state_display(row: PathRow, redactor: Redactor) -> str:
|
|
984
|
+
if row.state == observe.UNKNOWN and row.state_reason:
|
|
985
|
+
return f"{observe.UNKNOWN} ({redactor(row.state_reason)})"
|
|
986
|
+
return row.state
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def _unit_header_line(unit: UnitPlan, redactor: Redactor) -> str:
|
|
990
|
+
agent = redactor.names.agent(unit.label) or "agent"
|
|
991
|
+
phase_part = ""
|
|
992
|
+
if unit.phase:
|
|
993
|
+
phase_part = f" ({redactor.names.phase(unit.phase)})"
|
|
994
|
+
plural = "attempt" if unit.attempts_count == 1 else "attempts"
|
|
995
|
+
return (
|
|
996
|
+
f"UNIT {agent}{phase_part} {unit.attempts_count} {plural}, "
|
|
997
|
+
f"last one {unit.last_end_word}"
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _task_lines(unit: UnitPlan, view: "report.EvidenceView", redactor: Redactor) -> list[str]:
|
|
1002
|
+
"""The prompt is quoted only with --evidence at local and raw (N3b D10,
|
|
1003
|
+
D-o), exactly as v0.2 printed it under --no-redact: the first 300
|
|
1004
|
+
characters, then the pointer. Otherwise the pointer to where it is and
|
|
1005
|
+
its length. At share the pointer names an aliased file; at local and raw
|
|
1006
|
+
the real one."""
|
|
1007
|
+
if unit.task is None:
|
|
1008
|
+
return [" TASK no prompt recorded"]
|
|
1009
|
+
pointer = view.pointer(unit.task.transcript_name, unit.task.line_no)
|
|
1010
|
+
if view.show_excerpts and pointer is not None:
|
|
1011
|
+
return [
|
|
1012
|
+
f' TASK "{redactor(unit.task.text[:300])}"', f" full text: {pointer}",
|
|
1013
|
+
]
|
|
1014
|
+
lines = []
|
|
1015
|
+
if pointer is not None:
|
|
1016
|
+
lines.append(f" TASK full text: {pointer}")
|
|
1017
|
+
lines.append(f" prompt length: {unit.task.length} chars")
|
|
1018
|
+
else:
|
|
1019
|
+
lines.append(f" TASK prompt length: {unit.task.length} chars")
|
|
1020
|
+
return lines
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _paths_lines(unit: UnitPlan, redactor: Redactor) -> list[str]:
|
|
1024
|
+
lines = [" PATHS " + f"{'path':<{_PATH_COL}}{'state':<{_STATE_COL}}"
|
|
1025
|
+
f"{'attempts':<{_ATTEMPTS_COL}}later write"]
|
|
1026
|
+
if not unit.paths:
|
|
1027
|
+
lines.append(_PATH_INDENT + "none")
|
|
1028
|
+
return lines
|
|
1029
|
+
states = [_state_display(row, redactor) for row in unit.paths]
|
|
1030
|
+
# The state column grows to the widest state in this table (an UNKNOWN
|
|
1031
|
+
# with its reason is longer than the fixed width), so the columns after
|
|
1032
|
+
# it never run together.
|
|
1033
|
+
state_col = max(_STATE_COL, max(len(state) for state in states) + 2)
|
|
1034
|
+
for row, state in zip(unit.paths, states):
|
|
1035
|
+
display = redactor(row.display_key)
|
|
1036
|
+
attempts_str = ", ".join(str(o) for o in row.attempt_ordinals)
|
|
1037
|
+
later = "yes" if row.later_write else "no"
|
|
1038
|
+
lines.append(
|
|
1039
|
+
_PATH_INDENT
|
|
1040
|
+
+ f"{display:<{_PATH_COL}}{state:<{state_col}}{attempts_str:<{_ATTEMPTS_COL}}{later}"
|
|
1041
|
+
)
|
|
1042
|
+
return lines
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def _commits_line(unit: UnitPlan, redactor: Redactor) -> str:
|
|
1046
|
+
ids = (
|
|
1047
|
+
", ".join(redactor.names.commit(commit) for commit in unit.commits)
|
|
1048
|
+
if unit.commits else "0"
|
|
1049
|
+
)
|
|
1050
|
+
return f" COMMITS reported by these attempts: {ids}"
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _in_flight_lines(unit: UnitPlan, redactor: Redactor) -> list[str]:
|
|
1054
|
+
if not unit.in_flight:
|
|
1055
|
+
return []
|
|
1056
|
+
lines = []
|
|
1057
|
+
for index, entry in enumerate(unit.in_flight):
|
|
1058
|
+
tool = redactor(entry.tool or "tool")
|
|
1059
|
+
path_display = redactor(entry.display_key) if entry.display_key else "(no path)"
|
|
1060
|
+
text = f"{tool} {path_display} (no result recorded)"
|
|
1061
|
+
prefix = " IN FLIGHT at the kill: " if index == 0 else _PATH_INDENT
|
|
1062
|
+
lines.append(prefix + text)
|
|
1063
|
+
return lines
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
_UNKNOWN_GROUP_SENTENCE = (
|
|
1067
|
+
"(repo state not seen for some of this unit's attempts: a shell command or an "
|
|
1068
|
+
"unobserved path may hide what they wrote)"
|
|
1069
|
+
)
|
|
1070
|
+
|
|
1071
|
+
_UNKNOWN_SENTENCE = (
|
|
1072
|
+
"whose content is in the dirty paths; whether the task's goal was reached; "
|
|
1073
|
+
"what remains of the task"
|
|
1074
|
+
)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _unknown_lines(unit: UnitPlan, plan: RescuePlan, redactor: Redactor) -> list[str]:
|
|
1078
|
+
lines = [f" UNKNOWN {_UNKNOWN_SENTENCE}"]
|
|
1079
|
+
if not plan.checked and plan.observation_reason:
|
|
1080
|
+
lines.append(f" (repo state not checked: {redactor(plan.observation_reason)})")
|
|
1081
|
+
# docs/DESIGN.md 4.11 (F6 hardening): a risk attempt with no transcript
|
|
1082
|
+
# at all (facts is None) is its own, distinct UNKNOWN reason.
|
|
1083
|
+
if unit.facts_unknown:
|
|
1084
|
+
lines.append(" UNKNOWN facts: transcript missing")
|
|
1085
|
+
if unit.unknown_group and plan.checked:
|
|
1086
|
+
# (when nothing was checked, the line above already says the state was not seen)
|
|
1087
|
+
lines.append(f" {_UNKNOWN_GROUP_SENTENCE}")
|
|
1088
|
+
return lines
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def _checks_line(unit: UnitPlan, plan: RescuePlan, redactor: Redactor) -> str:
|
|
1092
|
+
if unit.declared_checks:
|
|
1093
|
+
# docs/DESIGN.md 4.11/5.1 (F7 hardening): a declared check's own
|
|
1094
|
+
# text gets its own check-N pseudonym by default -- the whole
|
|
1095
|
+
# command hidden, not merely a path embedded inside it.
|
|
1096
|
+
joined = "; ".join(redactor.check_text(check) for check in unit.declared_checks)
|
|
1097
|
+
if len(unit.repos) > 1:
|
|
1098
|
+
return f" CHECKS declared across {len(unit.repos)} repositories: {joined}"
|
|
1099
|
+
return f" CHECKS declared for this repo: {joined}"
|
|
1100
|
+
if plan.checked and plan.repo_root:
|
|
1101
|
+
repo_display = plan.repo_root if redactor.level == "raw" else "this repository"
|
|
1102
|
+
return (
|
|
1103
|
+
f' CHECKS no checks declared for {repo_display}: add [[repos]] to the '
|
|
1104
|
+
"config, see docs"
|
|
1105
|
+
)
|
|
1106
|
+
return (
|
|
1107
|
+
" CHECKS no checks declared for this repository: add [[repos]] to the "
|
|
1108
|
+
"config, see docs"
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _step_paths(unit: UnitPlan, plan: RescuePlan) -> tuple[list[PathRow], str]:
|
|
1113
|
+
"""The rows the `git status`/`git diff` steps name, and the note that goes
|
|
1114
|
+
with them. Dirty paths always; when the repo state was not checked at all,
|
|
1115
|
+
every path written; a path whose group was refused or not checked is listed
|
|
1116
|
+
too, since nothing looked at it."""
|
|
1117
|
+
dirty = [row for row in unit.paths if row.state == observe.DIRTY_NOW]
|
|
1118
|
+
if not plan.checked and unit.paths:
|
|
1119
|
+
return (dirty or list(unit.paths)), (
|
|
1120
|
+
"" if dirty else " (repo state not checked; every path written is listed)"
|
|
1121
|
+
)
|
|
1122
|
+
unchecked = [row for row in unit.paths if row.unchecked]
|
|
1123
|
+
listed = [
|
|
1124
|
+
row for row in unit.paths if row.state == observe.DIRTY_NOW or row.unchecked
|
|
1125
|
+
]
|
|
1126
|
+
if unchecked and listed:
|
|
1127
|
+
return listed, " (repo state not checked for some paths; those are listed too)"
|
|
1128
|
+
return dirty, ""
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _run_check_text(unit: UnitPlan, redactor: Redactor) -> str:
|
|
1132
|
+
"""The `[decide]` step that runs the unit's declared checks: from the repo root for
|
|
1133
|
+
one repository, from each repository's own root for several."""
|
|
1134
|
+
joined = "; ".join(redactor.check_text(check) for check in unit.declared_checks)
|
|
1135
|
+
if len(unit.repos) > 1:
|
|
1136
|
+
return f"run the declared checks, each from its own repository root: {joined}"
|
|
1137
|
+
return f"run the declared check from the repo root: {joined}"
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def _steps_lines(unit: UnitPlan, plan: RescuePlan, redactor: Redactor) -> list[str]:
|
|
1141
|
+
step_paths, unchecked_note = _step_paths(unit, plan)
|
|
1142
|
+
|
|
1143
|
+
if not step_paths and plan.checked:
|
|
1144
|
+
# Observation ran and nothing the killed attempts wrote is dirty
|
|
1145
|
+
# now: there is nothing to inspect, keep, or commit. Say so instead
|
|
1146
|
+
# of printing commands with no paths. A unit whose attempts sit in a
|
|
1147
|
+
# refused or unobserved group (N5 fix 1, S1) is never told that: what
|
|
1148
|
+
# it wrote was not seen.
|
|
1149
|
+
if unit.unknown_group:
|
|
1150
|
+
first = (
|
|
1151
|
+
" 1. [look] no path was observed for this unit: a shell command or an "
|
|
1152
|
+
"unobserved repository may hide what it wrote; run git status --short in the "
|
|
1153
|
+
"repository the agent worked in"
|
|
1154
|
+
)
|
|
1155
|
+
else:
|
|
1156
|
+
first = (
|
|
1157
|
+
" 1. [look] no path is dirty now: what the killed attempts wrote is "
|
|
1158
|
+
"committed (CLEAN_NOW) or absent (MISSING_NOW); see PATHS"
|
|
1159
|
+
)
|
|
1160
|
+
lines = [" STEPS", first]
|
|
1161
|
+
if plan.possibly_live:
|
|
1162
|
+
return lines
|
|
1163
|
+
if unit.declared_checks:
|
|
1164
|
+
lines.append(
|
|
1165
|
+
f" 2. [decide] {_run_check_text(unit, redactor)}"
|
|
1166
|
+
)
|
|
1167
|
+
else:
|
|
1168
|
+
lines.append(" 2. [decide] declare a check first")
|
|
1169
|
+
lines.append(" 3. [decide] relaunch the remaining work")
|
|
1170
|
+
return lines
|
|
1171
|
+
|
|
1172
|
+
# docs/DESIGN.md 4.11 (K4.1): a unit with no path to name gets both whole-tree commands, no
|
|
1173
|
+
# pathspec; the literal `<none>` is never printed.
|
|
1174
|
+
if step_paths:
|
|
1175
|
+
path_str = " ".join(redactor(row.display_key) for row in step_paths)
|
|
1176
|
+
status_command = f"git status --short -- {path_str}{unchecked_note}"
|
|
1177
|
+
diff_command = f"git diff -- {path_str}"
|
|
1178
|
+
else:
|
|
1179
|
+
status_command = "git status --short"
|
|
1180
|
+
diff_command = "git diff"
|
|
1181
|
+
|
|
1182
|
+
lines = [
|
|
1183
|
+
" STEPS",
|
|
1184
|
+
f" 1. [look] {status_command}",
|
|
1185
|
+
f" 2. [look] {diff_command}",
|
|
1186
|
+
]
|
|
1187
|
+
# docs/DESIGN.md 4.11 (LIVE rule): a possibly-live run only ever gets the
|
|
1188
|
+
# two read-only look steps -- committing, relaunching, or even deciding
|
|
1189
|
+
# what to keep is unsafe advice while the run might still be writing to
|
|
1190
|
+
# this same repo.
|
|
1191
|
+
if plan.possibly_live:
|
|
1192
|
+
return lines
|
|
1193
|
+
|
|
1194
|
+
if unit.declared_checks:
|
|
1195
|
+
check_text = _run_check_text(unit, redactor)
|
|
1196
|
+
else:
|
|
1197
|
+
check_text = "declare a check first"
|
|
1198
|
+
|
|
1199
|
+
lines.extend([
|
|
1200
|
+
f" 3. [decide] {check_text}",
|
|
1201
|
+
" 4. [decide] keep, fix, or set aside each dirty path. A passing check is "
|
|
1202
|
+
"evidence, not a decision.",
|
|
1203
|
+
" 5. [decide] commit, after reading the diff",
|
|
1204
|
+
" 6. [decide] relaunch the remaining work",
|
|
1205
|
+
])
|
|
1206
|
+
return lines
|
|
1207
|
+
|
|
1208
|
+
|
|
1209
|
+
def _never_line() -> str:
|
|
1210
|
+
return " NEVER git checkout / restore / reset / stash / clean on these paths"
|
|
1211
|
+
|
|
1212
|
+
|
|
1213
|
+
def _render_unit_text(
|
|
1214
|
+
unit: UnitPlan, plan: RescuePlan, redactor: Redactor, view: "report.EvidenceView",
|
|
1215
|
+
) -> str:
|
|
1216
|
+
lines = [_unit_header_line(unit, redactor)]
|
|
1217
|
+
lines.extend(_task_lines(unit, view, redactor))
|
|
1218
|
+
lines.append(f" REPORTED {_reported_text(unit.reported, redactor)}")
|
|
1219
|
+
lines.extend(_paths_lines(unit, redactor))
|
|
1220
|
+
lines.append(_commits_line(unit, redactor))
|
|
1221
|
+
lines.extend(_in_flight_lines(unit, redactor))
|
|
1222
|
+
lines.extend(_unknown_lines(unit, plan, redactor))
|
|
1223
|
+
lines.append(_checks_line(unit, plan, redactor))
|
|
1224
|
+
lines.extend(_steps_lines(unit, plan, redactor))
|
|
1225
|
+
lines.append(_never_line())
|
|
1226
|
+
# every piece above was scanned where it entered the line; an assembled line is not scanned
|
|
1227
|
+
# again, because a path hides the rest of its line (docs/DESIGN.md 5.1)
|
|
1228
|
+
return "\n".join(lines)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
def _brief_text(plan: RescuePlan, redactor: Redactor) -> str:
|
|
1232
|
+
killed_parts = []
|
|
1233
|
+
for row in plan.killed_brief:
|
|
1234
|
+
label = redactor.names.agent(row["label"])
|
|
1235
|
+
time_str = redactor(row["time"]) if row["time"] else "unknown time"
|
|
1236
|
+
silence = row["silence_seconds"]
|
|
1237
|
+
silence_str = f"{int(silence)}s" if silence is not None else "unknown"
|
|
1238
|
+
killed_parts.append(
|
|
1239
|
+
f"{label} attempt {row['ordinal']} at {time_str}, after {silence_str} of silence"
|
|
1240
|
+
)
|
|
1241
|
+
killed_text = "; ".join(killed_parts) if killed_parts else "none"
|
|
1242
|
+
|
|
1243
|
+
failed_parts = []
|
|
1244
|
+
for row in plan.failed_brief:
|
|
1245
|
+
label = redactor.names.agent(row["label"])
|
|
1246
|
+
cause_phrase = redactor(report._cause_phrase(row["cause"]))
|
|
1247
|
+
piece = f"{label}: {cause_phrase}, retry_now {_piece(redactor, row['retry_now'])}"
|
|
1248
|
+
if row["reset_time"]:
|
|
1249
|
+
piece += f", resets {redactor(row['reset_time'])}"
|
|
1250
|
+
failed_parts.append(piece)
|
|
1251
|
+
failed_text = "; ".join(failed_parts) if failed_parts else "none"
|
|
1252
|
+
|
|
1253
|
+
paths = [redactor(path) for path in plan.already_exists_paths]
|
|
1254
|
+
commits = [redactor.names.commit(commit) for commit in plan.already_exists_commits]
|
|
1255
|
+
# docs/DESIGN.md 4.11, 4.13.1 (N5c-2): a commit id is a summary-shaped output
|
|
1256
|
+
# line the tool has not confirmed, so the brief says commits were reported, never
|
|
1257
|
+
# that they exist.
|
|
1258
|
+
paths_text = f"{len(paths)} paths ({', '.join(paths)})" if paths else "0 paths"
|
|
1259
|
+
commits_text = (
|
|
1260
|
+
f"commits reported: {len(commits)} ({', '.join(commits)})" if commits
|
|
1261
|
+
else "commits reported: 0"
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
lines = [
|
|
1265
|
+
"BRIEF FOR A RELAUNCH",
|
|
1266
|
+
f" killed: {killed_text} failed: {failed_text}",
|
|
1267
|
+
f" already exists: {paths_text}; {commits_text}",
|
|
1268
|
+
" start from what exists; do not redo it",
|
|
1269
|
+
]
|
|
1270
|
+
# docs/DESIGN.md 4.11 (K4.2): only when two attempts of different keys overlapped in time.
|
|
1271
|
+
if plan.fan_out_overlap:
|
|
1272
|
+
lines.append(
|
|
1273
|
+
" caveat: resume re-runs downstream agents after a parallel fan-out "
|
|
1274
|
+
"(anthropics/claude-code #95076)"
|
|
1275
|
+
)
|
|
1276
|
+
return "\n".join(lines)
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
_NOTHING_YET = (
|
|
1280
|
+
"NOTHING TO RESCUE YET: the run has not finished and no attempt was killed or failed."
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
# docs/DESIGN.md 4.11 (field milestone F, FB3): the first line of `rescue plan` text on a
|
|
1284
|
+
# possibly-live run, one of three, by who may still be writing.
|
|
1285
|
+
_NOTHING_YET_LIVE = (
|
|
1286
|
+
"NOTHING TO RESCUE YET: the run may still be running; no attempt has been killed or "
|
|
1287
|
+
"failed so far."
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
_LIVE_OTHER = (
|
|
1291
|
+
"POSSIBLY LIVE: the run may still be running, and another agent may be writing the files "
|
|
1292
|
+
"listed below. Do not modify them until it has stopped; confirm with the human."
|
|
1293
|
+
)
|
|
1294
|
+
|
|
1295
|
+
_LIVE_OWN = (
|
|
1296
|
+
"YOUR CURRENT SESSION: this run is the session you are working in. Check the files listed "
|
|
1297
|
+
"below yourself before relaunching the killed work."
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
def _is_callers_session(run: Run, caller_session: str | None) -> bool:
|
|
1302
|
+
"""FB3: a SESSION run whose transcript stem equals the caller's own session id (a
|
|
1303
|
+
canonical UUID, compared in lowercase). A workflow run is never the caller's session,
|
|
1304
|
+
even one its session launched: its workers may still be writing."""
|
|
1305
|
+
return (
|
|
1306
|
+
caller_session is not None
|
|
1307
|
+
and run.kind == "session"
|
|
1308
|
+
and run.path.stem.lower() == caller_session.lower()
|
|
1309
|
+
)
|
|
1310
|
+
|
|
1311
|
+
|
|
1312
|
+
def render_plan_text(
|
|
1313
|
+
run: Run, plan: RescuePlan, redactor: Redactor, *, evidence: bool = False,
|
|
1314
|
+
caller_session: str | None = None,
|
|
1315
|
+
) -> str:
|
|
1316
|
+
_register_plan_names(run, redactor, detailed=True)
|
|
1317
|
+
_register_plan_paths(plan, redactor, run)
|
|
1318
|
+
view = report.EvidenceView(run, redactor, show_excerpts=evidence)
|
|
1319
|
+
|
|
1320
|
+
blocks: list[str] = []
|
|
1321
|
+
if evidence and view.note() is not None:
|
|
1322
|
+
blocks.append(view.note())
|
|
1323
|
+
if plan.possibly_live and plan.units:
|
|
1324
|
+
# FB3: who may still be writing the listed files decides the wording.
|
|
1325
|
+
blocks.append(_LIVE_OWN if _is_callers_session(run, caller_session) else _LIVE_OTHER)
|
|
1326
|
+
if plan.empty_state == NO_UNIT_UNESTABLISHED:
|
|
1327
|
+
blocks.append(redactor(_no_unit_sentence(plan.run_id, plan.return_breakdown)))
|
|
1328
|
+
return "\n".join(blocks) + "\n"
|
|
1329
|
+
if plan.nothing_to_rescue:
|
|
1330
|
+
# docs/DESIGN.md 4.11: "every attempt completed" is a claim about a
|
|
1331
|
+
# finished run. A possibly-live run with no qualifying unit yet is a
|
|
1332
|
+
# different, honest claim -- nothing has needed rescuing SO FAR, not
|
|
1333
|
+
# that everything is done -- so it gets its own sentence (FB3), never
|
|
1334
|
+
# the cheerful one and never a do-not-modify warning with no file to protect.
|
|
1335
|
+
if plan.possibly_live:
|
|
1336
|
+
blocks.append(_NOTHING_YET_LIVE)
|
|
1337
|
+
else:
|
|
1338
|
+
if plan.nothing_to_rescue_ok:
|
|
1339
|
+
blocks.append("NOTHING TO RESCUE: every attempt completed.")
|
|
1340
|
+
else:
|
|
1341
|
+
# F6 hardening: an unfinished run (or one with an attempt
|
|
1342
|
+
# that is neither completed nor killed/failed/superseded)
|
|
1343
|
+
# has no qualifying unit YET, not because it is done.
|
|
1344
|
+
blocks.append(_NOTHING_YET)
|
|
1345
|
+
return "\n".join(blocks) + "\n"
|
|
1346
|
+
|
|
1347
|
+
if plan.unchecked_attempts is not None:
|
|
1348
|
+
# N8 fix 1, S2: no unit, but the risk population is not empty or not complete
|
|
1349
|
+
if plan.possibly_live:
|
|
1350
|
+
# unreachable from the CLI (an unchecked population needs a finished run); a
|
|
1351
|
+
# hand-built plan keeps the warning rather than printing nothing
|
|
1352
|
+
blocks.append(_LIVE_OTHER)
|
|
1353
|
+
else:
|
|
1354
|
+
blocks.append(redactor(_unchecked_sentence(plan.unchecked_attempts, plan.run_id)))
|
|
1355
|
+
return "\n".join(blocks) + "\n"
|
|
1356
|
+
|
|
1357
|
+
for unit in plan.units:
|
|
1358
|
+
blocks.append(_render_unit_text(unit, plan, redactor, view))
|
|
1359
|
+
|
|
1360
|
+
if not plan.possibly_live:
|
|
1361
|
+
blocks.append(_brief_text(plan, redactor))
|
|
1362
|
+
|
|
1363
|
+
return "\n".join(blocks) + "\n"
|
|
1364
|
+
|
|
1365
|
+
|
|
1366
|
+
# --- JSON rendering -----------------------------------------------------
|
|
1367
|
+
|
|
1368
|
+
|
|
1369
|
+
def _export_path_row(row: PathRow, redactor: Redactor, view: "report.EvidenceView") -> dict:
|
|
1370
|
+
"""docs/DESIGN.md 5.3b: observation state and ledger facts in separate
|
|
1371
|
+
fields. `group` is the index of the path's group in the plan's `groups`
|
|
1372
|
+
array (None when no group lists the path); `observation` is the path's own
|
|
1373
|
+
observation, null when its group was refused, unknown, or not checked (N5c-1);
|
|
1374
|
+
`ledger` is the list of this path's ledger entries in attempt order, `[]`
|
|
1375
|
+
when none names it."""
|
|
1376
|
+
return {
|
|
1377
|
+
"path": redactor(row.display_key),
|
|
1378
|
+
"group": row.group,
|
|
1379
|
+
"observation": (
|
|
1380
|
+
{
|
|
1381
|
+
"state": row.state,
|
|
1382
|
+
"reason": redactor(row.state_reason) if row.state_reason else None,
|
|
1383
|
+
}
|
|
1384
|
+
if row.observation is not None else None
|
|
1385
|
+
),
|
|
1386
|
+
"ledger": [
|
|
1387
|
+
{
|
|
1388
|
+
"attempt": ordinal,
|
|
1389
|
+
"effect": entry.effect,
|
|
1390
|
+
"basis": entry.basis,
|
|
1391
|
+
"completion": entry.completion,
|
|
1392
|
+
"line": view.ledger_line(entry.line),
|
|
1393
|
+
}
|
|
1394
|
+
for ordinal, entry in row.ledger
|
|
1395
|
+
],
|
|
1396
|
+
"attempts": row.attempt_ordinals,
|
|
1397
|
+
"later_write_observed": row.later_write,
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
|
|
1401
|
+
def _export_reported(reported: dict | None, redactor: Redactor) -> dict | None:
|
|
1402
|
+
if reported is None:
|
|
1403
|
+
return None
|
|
1404
|
+
if reported.get("type") == "text":
|
|
1405
|
+
return {"type": "text", "text_length": reported.get("text_length")}
|
|
1406
|
+
if reported.get("type") == "dict":
|
|
1407
|
+
claims = reported.get("claims") or {}
|
|
1408
|
+
exported_claims = {}
|
|
1409
|
+
for key, value in claims.items():
|
|
1410
|
+
if key == "report_path" and isinstance(value, str):
|
|
1411
|
+
exported_claims[key] = redactor.names.path(value)
|
|
1412
|
+
elif key == "status":
|
|
1413
|
+
# F7 hardening: same "status: set" treatment as the text
|
|
1414
|
+
# renderer's _format_claim_value.
|
|
1415
|
+
exported_claims[key] = value if not redactor.enabled else "set"
|
|
1416
|
+
else:
|
|
1417
|
+
# a number, a bool, or a list of commit ids: every string in it is scanned
|
|
1418
|
+
exported_claims[key] = report.redact_value(
|
|
1419
|
+
report.number_claim_commits({key: value}, redactor)[key], redactor,
|
|
1420
|
+
)
|
|
1421
|
+
return {"type": "dict", "claims": exported_claims}
|
|
1422
|
+
return None
|
|
1423
|
+
|
|
1424
|
+
|
|
1425
|
+
def _export_unit(
|
|
1426
|
+
unit: UnitPlan, plan: RescuePlan, redactor: Redactor, view: "report.EvidenceView",
|
|
1427
|
+
) -> dict:
|
|
1428
|
+
task = None
|
|
1429
|
+
if unit.task is not None:
|
|
1430
|
+
# docs/DESIGN.md 5.3b: the pointer follows the `line` convention (null
|
|
1431
|
+
# for an attempt with no canonical agent id at share); `text` exists
|
|
1432
|
+
# only at local and raw with --evidence, structurally redacted there.
|
|
1433
|
+
task = {
|
|
1434
|
+
"pointer": view.pointer(unit.task.transcript_name, unit.task.line_no),
|
|
1435
|
+
"length": unit.task.length,
|
|
1436
|
+
}
|
|
1437
|
+
if view.show_excerpts:
|
|
1438
|
+
task["text"] = redactor(unit.task.text[:300])
|
|
1439
|
+
unknown = [_UNKNOWN_SENTENCE]
|
|
1440
|
+
if not plan.checked and plan.observation_reason:
|
|
1441
|
+
unknown.append(redactor(plan.observation_reason))
|
|
1442
|
+
if unit.facts_unknown:
|
|
1443
|
+
unknown.append("UNKNOWN facts: transcript missing")
|
|
1444
|
+
if unit.unknown_group and plan.checked:
|
|
1445
|
+
unknown.append(_UNKNOWN_GROUP_SENTENCE)
|
|
1446
|
+
|
|
1447
|
+
step_paths, _note = _step_paths(unit, plan)
|
|
1448
|
+
path_str = " ".join(redactor(row.display_key) for row in step_paths) if step_paths else None
|
|
1449
|
+
# docs/DESIGN.md 4.11 (K4.1): both look commands are always non-null strings, the
|
|
1450
|
+
# whole-tree forms when no path is named.
|
|
1451
|
+
steps = [
|
|
1452
|
+
{"grade": "look", "text": "git status", "command":
|
|
1453
|
+
f"git status --short -- {path_str}" if path_str else "git status --short"},
|
|
1454
|
+
{"grade": "look", "text": "git diff", "command":
|
|
1455
|
+
f"git diff -- {path_str}" if path_str else "git diff"},
|
|
1456
|
+
]
|
|
1457
|
+
# docs/DESIGN.md 4.11 (LIVE rule): steps 3-6 (decide/commit/relaunch) are
|
|
1458
|
+
# never offered for a possibly-live run, in JSON any more than in text.
|
|
1459
|
+
if not plan.possibly_live:
|
|
1460
|
+
if unit.declared_checks:
|
|
1461
|
+
check_command = "; ".join(
|
|
1462
|
+
redactor.check_text(check) for check in unit.declared_checks
|
|
1463
|
+
)
|
|
1464
|
+
step3_text = _run_check_text(unit, redactor)
|
|
1465
|
+
else:
|
|
1466
|
+
check_command = None
|
|
1467
|
+
step3_text = "declare a check first"
|
|
1468
|
+
step3_command = check_command if check_command else None
|
|
1469
|
+
steps.extend([
|
|
1470
|
+
{"grade": "decide", "text": step3_text, "command": step3_command},
|
|
1471
|
+
{"grade": "decide", "text": "keep, fix, or set aside each dirty path. A passing "
|
|
1472
|
+
"check is evidence, not a decision.", "command": None},
|
|
1473
|
+
{"grade": "decide", "text": "commit, after reading the diff", "command": None},
|
|
1474
|
+
{"grade": "decide", "text": "relaunch the remaining work", "command": None},
|
|
1475
|
+
])
|
|
1476
|
+
|
|
1477
|
+
return {
|
|
1478
|
+
"agent": redactor.names.agent(unit.label),
|
|
1479
|
+
"phase": redactor.names.phase(unit.phase) if unit.phase else None,
|
|
1480
|
+
"attempts": unit.attempts_count,
|
|
1481
|
+
"end": unit.last_end_word,
|
|
1482
|
+
"task": task,
|
|
1483
|
+
"reported": _export_reported(unit.reported, redactor),
|
|
1484
|
+
"paths": [_export_path_row(row, redactor, view) for row in unit.paths],
|
|
1485
|
+
"commits": [redactor.names.commit(commit) for commit in unit.commits],
|
|
1486
|
+
"in_flight": [
|
|
1487
|
+
{
|
|
1488
|
+
"tool": _piece(redactor, entry.tool),
|
|
1489
|
+
"path": redactor(entry.display_key) if entry.display_key else None,
|
|
1490
|
+
}
|
|
1491
|
+
for entry in unit.in_flight
|
|
1492
|
+
],
|
|
1493
|
+
"unknown": unknown,
|
|
1494
|
+
"checks": [redactor.check_text(check) for check in unit.declared_checks],
|
|
1495
|
+
"steps": steps,
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def plan_to_json(
|
|
1500
|
+
run: Run, plan: RescuePlan, redactor: Redactor, *, evidence: bool = False,
|
|
1501
|
+
) -> dict:
|
|
1502
|
+
_register_plan_names(run, redactor, detailed=True)
|
|
1503
|
+
_register_plan_paths(plan, redactor, run)
|
|
1504
|
+
view = report.EvidenceView(run, redactor, show_excerpts=evidence)
|
|
1505
|
+
|
|
1506
|
+
killed = [
|
|
1507
|
+
{
|
|
1508
|
+
"agent": redactor.names.agent(row["label"]),
|
|
1509
|
+
"ordinal": row["ordinal"],
|
|
1510
|
+
"time": _piece(redactor, row["time"]),
|
|
1511
|
+
"silence_seconds": row["silence_seconds"],
|
|
1512
|
+
}
|
|
1513
|
+
for row in plan.killed_brief
|
|
1514
|
+
]
|
|
1515
|
+
failed = [
|
|
1516
|
+
{
|
|
1517
|
+
"agent": redactor.names.agent(row["label"]),
|
|
1518
|
+
"cause": _piece(redactor, row["cause"]),
|
|
1519
|
+
"retry_now": _piece(redactor, row["retry_now"]),
|
|
1520
|
+
"reset_time": _piece(redactor, row["reset_time"]),
|
|
1521
|
+
}
|
|
1522
|
+
for row in plan.failed_brief
|
|
1523
|
+
]
|
|
1524
|
+
# numbers go in order of first appearance in the output: units come first, then the brief
|
|
1525
|
+
units = [_export_unit(unit, plan, redactor, view) for unit in plan.units]
|
|
1526
|
+
brief = {
|
|
1527
|
+
"killed": killed,
|
|
1528
|
+
"failed": failed,
|
|
1529
|
+
"already_exists": {
|
|
1530
|
+
"paths": [redactor(path) for path in plan.already_exists_paths],
|
|
1531
|
+
"commits": [redactor.names.commit(commit) for commit in plan.already_exists_commits],
|
|
1532
|
+
},
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
return {
|
|
1536
|
+
"schema": SCHEMA_VERSION,
|
|
1537
|
+
"disclosure": redactor.level,
|
|
1538
|
+
"run": {
|
|
1539
|
+
"run_id": plan.run_id, "kind": plan.kind, "finished": plan.finished,
|
|
1540
|
+
"possibly_live": plan.possibly_live,
|
|
1541
|
+
},
|
|
1542
|
+
"nothing_to_rescue": plan.nothing_to_rescue,
|
|
1543
|
+
"empty_state": plan.empty_state,
|
|
1544
|
+
"return_breakdown": plan.return_breakdown,
|
|
1545
|
+
"groups": (
|
|
1546
|
+
report.export_observation_groups(plan.observation, redactor, omit_empty_unknown=True)
|
|
1547
|
+
if plan.observation is not None else []
|
|
1548
|
+
),
|
|
1549
|
+
"units": units,
|
|
1550
|
+
"brief": (
|
|
1551
|
+
brief if (not plan.possibly_live and plan.units) else None
|
|
1552
|
+
),
|
|
1553
|
+
"exit_code": plan.exit_code,
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
# =============================================================================
|
|
1558
|
+
# rescue status: what was observed (docs/DESIGN.md 4.11, proposal 8.3)
|
|
1559
|
+
# =============================================================================
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
@dataclass
|
|
1563
|
+
class NotCountedRow:
|
|
1564
|
+
line_no: int
|
|
1565
|
+
reason: str
|
|
1566
|
+
excerpt: str
|
|
1567
|
+
category: str = "command" # fixed text from code; never transcript bytes
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
@dataclass
|
|
1571
|
+
class CountedRun:
|
|
1572
|
+
line_no: int
|
|
1573
|
+
ts: datetime | None
|
|
1574
|
+
command: str
|
|
1575
|
+
completion: str # 'completed_ok' | 'completed_error'
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
@dataclass
|
|
1579
|
+
class NeverRow:
|
|
1580
|
+
line_no: int
|
|
1581
|
+
excerpt: str
|
|
1582
|
+
category: str = "command" # the matched rule's own spelling, e.g. "git checkout"
|
|
1583
|
+
|
|
1584
|
+
|
|
1585
|
+
@dataclass
|
|
1586
|
+
class UnitStatus:
|
|
1587
|
+
key: str
|
|
1588
|
+
label: str
|
|
1589
|
+
checks_declared: int
|
|
1590
|
+
last_counted: CountedRun | None
|
|
1591
|
+
word: str # EXIT_0_OBSERVED | NONZERO_OBSERVED | NOT_OBSERVED
|
|
1592
|
+
writes_after: int
|
|
1593
|
+
cycles: int
|
|
1594
|
+
# docs/DESIGN.md 4.11 (F3 hardening): a NEVER-list command was observed
|
|
1595
|
+
# anywhere in the relevant transcript window. Kept separate from `word`
|
|
1596
|
+
# itself (never_matches is transcript-wide, not attributable to one
|
|
1597
|
+
# unit) so equality checks against the plain word ("NONZERO_OBSERVED",
|
|
1598
|
+
# etc.) elsewhere are unaffected; a renderer appends "NEVER_COMMAND_
|
|
1599
|
+
# OBSERVED" to the displayed word when this is true.
|
|
1600
|
+
never_command_observed: bool = False
|
|
1601
|
+
# docs/DESIGN.md 4.11 (N5c-2): the declared repository this row is about (None for a
|
|
1602
|
+
# unit no declared repository contains).
|
|
1603
|
+
repo: str | None = None
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
@dataclass
|
|
1607
|
+
class RescueStatus:
|
|
1608
|
+
run_id: str
|
|
1609
|
+
units: list[UnitStatus]
|
|
1610
|
+
not_counted: list[NotCountedRow]
|
|
1611
|
+
never_matches: list[NeverRow]
|
|
1612
|
+
transcript_unknown: bool
|
|
1613
|
+
checked: bool
|
|
1614
|
+
repo_root: str | None
|
|
1615
|
+
exit_code: int = 0
|
|
1616
|
+
listed_paths: tuple[str, ...] = () # display keys of every unit's candidate paths
|
|
1617
|
+
# docs/DESIGN.md 4.11 (K4.3): with no unit, true only for a finished run whose every attempt
|
|
1618
|
+
# completed (the one predicate `rescue plan` uses).
|
|
1619
|
+
nothing_to_rescue_ok: bool = False
|
|
1620
|
+
# N8 fix 1, S2: see RescuePlan.unchecked_attempts.
|
|
1621
|
+
unchecked_attempts: int | None = None
|
|
1622
|
+
# docs/DESIGN.md 4.11 (R1): see RescuePlan.empty_state
|
|
1623
|
+
empty_state: str | None = None
|
|
1624
|
+
return_breakdown: dict | None = None
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _never_list_match(command: str) -> bool:
|
|
1628
|
+
return bool(_NEVER_LIST_RE.search(command))
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def _never_category(command: str) -> str:
|
|
1632
|
+
"""The matched NEVER rule's own canonical spelling (`git checkout`),
|
|
1633
|
+
built from the rule's fixed vocabulary, never from the transcript's
|
|
1634
|
+
bytes (N3b C1). A command that matches no rule gets the fixed word
|
|
1635
|
+
`command`."""
|
|
1636
|
+
match = _NEVER_LIST_RE.search(command)
|
|
1637
|
+
if match is None:
|
|
1638
|
+
return "command"
|
|
1639
|
+
return f"git {match.group(1)}"
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _matched_check(command: str, declared_checks: list[str]) -> str | None:
|
|
1643
|
+
"""The declared check this command is, or merely contains: an exact (trailing
|
|
1644
|
+
whitespace stripped) match first, else the first one it contains."""
|
|
1645
|
+
stripped = command.rstrip()
|
|
1646
|
+
if stripped in declared_checks:
|
|
1647
|
+
return stripped
|
|
1648
|
+
for check in declared_checks:
|
|
1649
|
+
if check in command:
|
|
1650
|
+
return check
|
|
1651
|
+
return None
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
def _classify_bash_call(call, declared_checks: list[str], repo_root: str | None):
|
|
1655
|
+
"""(matched_check, reason) for one Bash call already known to be after
|
|
1656
|
+
run.ended_at: reason is None when it counts, else one of 'other cwd',
|
|
1657
|
+
'compound command', 'backgrounded', 'no result' -- docs/DESIGN.md 4.11's
|
|
1658
|
+
exact vocabulary. matched_check is the declared check string this call's
|
|
1659
|
+
command contains, or None when it contains none of them at all (the call
|
|
1660
|
+
is then irrelevant and never listed). `repo_root` is the declared repository
|
|
1661
|
+
the checks belong to: a call counts only at its exact working directory."""
|
|
1662
|
+
command = call.command
|
|
1663
|
+
if not command:
|
|
1664
|
+
return None, None
|
|
1665
|
+
matched = _matched_check(command, declared_checks)
|
|
1666
|
+
if matched is None:
|
|
1667
|
+
return None, None
|
|
1668
|
+
|
|
1669
|
+
# docs/DESIGN.md 4.2/4.11 (F5 hardening): a call whose tool_use_id was
|
|
1670
|
+
# duplicated in the transcript can never be trusted as a genuine check
|
|
1671
|
+
# run -- neither its exit state nor which result truly belongs to it is
|
|
1672
|
+
# known -- so it is never counted, regardless of what its own command
|
|
1673
|
+
# text says.
|
|
1674
|
+
if call.completion == "ambiguous":
|
|
1675
|
+
return matched, "ambiguous tool id"
|
|
1676
|
+
if call.completion == "no_result":
|
|
1677
|
+
return matched, "no result"
|
|
1678
|
+
if call.completion == "backgrounded":
|
|
1679
|
+
return matched, "backgrounded"
|
|
1680
|
+
normalized_cwd = os.path.normpath(call.cwd) if call.cwd else None
|
|
1681
|
+
normalized_root = os.path.normpath(repo_root) if repo_root else None
|
|
1682
|
+
if normalized_cwd != normalized_root:
|
|
1683
|
+
return matched, "other cwd"
|
|
1684
|
+
if command.rstrip() != matched:
|
|
1685
|
+
return matched, "compound command"
|
|
1686
|
+
return matched, None
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
_WRITE_SHELL_TOOLS = ("Bash", "Monitor")
|
|
1690
|
+
_WRITE_FILE_TOOLS = ("Edit", "Write", "MultiEdit", "NotebookEdit")
|
|
1691
|
+
|
|
1692
|
+
|
|
1693
|
+
def _file_tool_path(call) -> str | None:
|
|
1694
|
+
"""The target a file-tool call named: MultiEdit keeps it in `multiedit_path`
|
|
1695
|
+
(4.13.1), the other file tools in `path`."""
|
|
1696
|
+
path = call.multiedit_path if call.tool_name == "MultiEdit" else call.path
|
|
1697
|
+
return path if isinstance(path, str) and path else None
|
|
1698
|
+
|
|
1699
|
+
|
|
1700
|
+
def _shell_cwd(call) -> str | None:
|
|
1701
|
+
"""The working directory a shell call's later segments run in (4.13.1, L9): the call's
|
|
1702
|
+
own absolute cwd, replaced by a literal absolute leading `cd DIR &&`; None when it is
|
|
1703
|
+
missing, relative, or changed by any other shape (an opaque, unrecorded, or later cd)."""
|
|
1704
|
+
if not call.command:
|
|
1705
|
+
return None
|
|
1706
|
+
lexed = commands.lex(call.command)
|
|
1707
|
+
if not lexed.placed or not lexed.segments:
|
|
1708
|
+
return None
|
|
1709
|
+
bases = commands.segment_bases(lexed, call.cwd)
|
|
1710
|
+
start = 1 if commands.cd_operand(lexed.segments[0]) is not None else 0
|
|
1711
|
+
rest = bases[start:] or bases[:1]
|
|
1712
|
+
if any(base is None for base in rest):
|
|
1713
|
+
return None
|
|
1714
|
+
return rest[0]
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
def _write_order(call) -> tuple:
|
|
1718
|
+
return (call.ts or _MIN_TS, call.line_no)
|
|
1719
|
+
|
|
1720
|
+
|
|
1721
|
+
def _file_write_key(call) -> str:
|
|
1722
|
+
"""The absolute, normalized spelling of a file-tool call's target, or its own text when
|
|
1723
|
+
nothing places it."""
|
|
1724
|
+
path = _file_tool_path(call) or ""
|
|
1725
|
+
if path.startswith("/"):
|
|
1726
|
+
return commands.normalize_path(path)
|
|
1727
|
+
if isinstance(call.cwd, str) and call.cwd.startswith("/"):
|
|
1728
|
+
return commands.normalize_path(os.path.join(call.cwd, path))
|
|
1729
|
+
return path
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def build_status(run: Run, cfg: config_module.Config, observation) -> RescueStatus:
|
|
1733
|
+
parent_path = reader._parent_transcript_path(run)
|
|
1734
|
+
transcript_unknown = False
|
|
1735
|
+
tool_calls: list = []
|
|
1736
|
+
if parent_path is not None and parent_path.is_file():
|
|
1737
|
+
main_attempt, _version, _sid, diagnostics, incomplete, _ts = reader._parse_main_attempt(
|
|
1738
|
+
parent_path
|
|
1739
|
+
)
|
|
1740
|
+
tool_calls = list(main_attempt.tool_calls)
|
|
1741
|
+
transcript_unknown = bool(diagnostics) or incomplete
|
|
1742
|
+
else:
|
|
1743
|
+
transcript_unknown = True
|
|
1744
|
+
|
|
1745
|
+
ended_at = run.ended_at
|
|
1746
|
+
relevant_calls = [
|
|
1747
|
+
call for call in tool_calls
|
|
1748
|
+
if ended_at is None or (call.ts is not None and call.ts > ended_at)
|
|
1749
|
+
]
|
|
1750
|
+
|
|
1751
|
+
# docs/DESIGN.md 4.11 (N5c-2, plan S7): everything below is per (unit, declared
|
|
1752
|
+
# repository). The roots are the configuration's own, lexical, never from git.
|
|
1753
|
+
roots = declared_roots(cfg)
|
|
1754
|
+
units = qualifying_units(run)
|
|
1755
|
+
unit_rows = [] # (unit, listed spellings, target -> owning roots, repos)
|
|
1756
|
+
relevant_roots: list[str] = []
|
|
1757
|
+
for unit in units:
|
|
1758
|
+
specs = _row_specs(unit, observation)
|
|
1759
|
+
owners = _target_owners(specs, roots)
|
|
1760
|
+
repos = _unit_repos(owners)
|
|
1761
|
+
for root in repos:
|
|
1762
|
+
if root not in relevant_roots:
|
|
1763
|
+
relevant_roots.append(root)
|
|
1764
|
+
unit_rows.append((unit, _listed_spellings(unit), owners, repos))
|
|
1765
|
+
|
|
1766
|
+
counted: dict[str, list[CountedRun]] = {root: [] for root in relevant_roots}
|
|
1767
|
+
counted_call_ids: set[int] = set()
|
|
1768
|
+
not_counted: list[NotCountedRow] = []
|
|
1769
|
+
never_matches: list[NeverRow] = []
|
|
1770
|
+
|
|
1771
|
+
for call in relevant_calls:
|
|
1772
|
+
if call.tool_name != "Bash" or not call.command:
|
|
1773
|
+
continue
|
|
1774
|
+
if _never_list_match(call.command):
|
|
1775
|
+
never_matches.append(NeverRow(
|
|
1776
|
+
line_no=call.line_no, excerpt=call.command[:400],
|
|
1777
|
+
category=_never_category(call.command),
|
|
1778
|
+
))
|
|
1779
|
+
counted_somewhere = False
|
|
1780
|
+
first_reason = None
|
|
1781
|
+
for root in relevant_roots:
|
|
1782
|
+
matched, reason = _classify_bash_call(call, roots[root], root)
|
|
1783
|
+
if matched is None:
|
|
1784
|
+
continue
|
|
1785
|
+
if reason is None:
|
|
1786
|
+
counted[root].append(CountedRun(
|
|
1787
|
+
line_no=call.line_no, ts=call.ts, command=matched,
|
|
1788
|
+
completion=call.completion,
|
|
1789
|
+
))
|
|
1790
|
+
counted_somewhere = True
|
|
1791
|
+
counted_call_ids.add(id(call))
|
|
1792
|
+
elif first_reason is None:
|
|
1793
|
+
first_reason = reason
|
|
1794
|
+
if not counted_somewhere and first_reason is not None:
|
|
1795
|
+
not_counted.append(NotCountedRow(
|
|
1796
|
+
line_no=call.line_no, reason=first_reason, excerpt=call.command[:400],
|
|
1797
|
+
category=_never_category(call.command),
|
|
1798
|
+
))
|
|
1799
|
+
|
|
1800
|
+
for root in relevant_roots:
|
|
1801
|
+
counted[root].sort(key=lambda c: (c.ts or _MIN_TS, c.line_no))
|
|
1802
|
+
|
|
1803
|
+
# docs/DESIGN.md 4.11 (F3 hardening, N5c-2): a shell call that is not proven read-only
|
|
1804
|
+
# is a possible write. It belongs to the one declared repository that owns its working
|
|
1805
|
+
# directory (after a literal absolute leading cd); a call whose directory is missing,
|
|
1806
|
+
# unknown, or owned by no declared root may have written in every repository of the run
|
|
1807
|
+
# (fail closed). Only a call counted as a declared check for some repository is skipped
|
|
1808
|
+
# (N5 fix 1, C1): a call that merely contains a check string but was rejected (other
|
|
1809
|
+
# cwd, compound, backgrounded, no result, ambiguous id) is a possible write like any other.
|
|
1810
|
+
shell_writes: list[tuple[tuple, object, frozenset[str]]] = []
|
|
1811
|
+
for call in relevant_calls:
|
|
1812
|
+
if call.tool_name not in _WRITE_SHELL_TOOLS:
|
|
1813
|
+
continue
|
|
1814
|
+
if id(call) in counted_call_ids:
|
|
1815
|
+
continue
|
|
1816
|
+
if call.command and _proven_read_only(call.command):
|
|
1817
|
+
continue
|
|
1818
|
+
cwd = _shell_cwd(call)
|
|
1819
|
+
owner = _owner(cwd, roots) if cwd is not None else None
|
|
1820
|
+
affected = frozenset({owner}) if owner is not None else frozenset(relevant_roots)
|
|
1821
|
+
shell_writes.append((_write_order(call), call, affected))
|
|
1822
|
+
|
|
1823
|
+
file_writes = [
|
|
1824
|
+
call for call in relevant_calls
|
|
1825
|
+
if call.tool_name in _WRITE_FILE_TOOLS and call.completion == "completed_ok"
|
|
1826
|
+
and _file_tool_path(call)
|
|
1827
|
+
]
|
|
1828
|
+
|
|
1829
|
+
flagged_shell_calls: set[int] = set()
|
|
1830
|
+
unit_statuses: list[UnitStatus] = []
|
|
1831
|
+
all_listed: list[str] = []
|
|
1832
|
+
global_cwd = observe._discover_cwd(run) if observation.checked else None
|
|
1833
|
+
for unit, spellings, owners, repos in unit_rows:
|
|
1834
|
+
listed_keys = {
|
|
1835
|
+
_display_key(raw, global_cwd, observation.repo_root) if observation.checked else raw
|
|
1836
|
+
for raw in spellings
|
|
1837
|
+
}
|
|
1838
|
+
all_listed.extend(key for key in listed_keys if key not in all_listed)
|
|
1839
|
+
|
|
1840
|
+
for repo in (repos or [None]):
|
|
1841
|
+
counted_here = counted.get(repo, []) if repo is not None else []
|
|
1842
|
+
last_counted = counted_here[-1] if counted_here else None
|
|
1843
|
+
if last_counted is None:
|
|
1844
|
+
word = "NOT_OBSERVED"
|
|
1845
|
+
elif last_counted.completion == "completed_ok":
|
|
1846
|
+
word = "EXIT_0_OBSERVED"
|
|
1847
|
+
else:
|
|
1848
|
+
word = "NONZERO_OBSERVED"
|
|
1849
|
+
|
|
1850
|
+
writes: list[tuple] = []
|
|
1851
|
+
shell_after: list[object] = []
|
|
1852
|
+
if repo is not None:
|
|
1853
|
+
for call in file_writes:
|
|
1854
|
+
if repo in owners.get(_file_write_key(call), ()):
|
|
1855
|
+
writes.append(_write_order(call))
|
|
1856
|
+
for order_key, call, affected in shell_writes:
|
|
1857
|
+
if repo in affected:
|
|
1858
|
+
writes.append(order_key)
|
|
1859
|
+
shell_after.append((order_key, call))
|
|
1860
|
+
|
|
1861
|
+
writes_after = 0
|
|
1862
|
+
if last_counted is not None:
|
|
1863
|
+
last_order = (last_counted.ts or _MIN_TS, last_counted.line_no)
|
|
1864
|
+
writes_after = sum(1 for key in writes if key > last_order)
|
|
1865
|
+
for order_key, call in shell_after:
|
|
1866
|
+
if order_key > last_order:
|
|
1867
|
+
flagged_shell_calls.add(id(call))
|
|
1868
|
+
|
|
1869
|
+
# cycles: a counted run preceded, since the previous counted run, by at least one
|
|
1870
|
+
# write this repository owns.
|
|
1871
|
+
timeline = [((c.ts or _MIN_TS, c.line_no), "check") for c in counted_here]
|
|
1872
|
+
timeline.extend((key, "write") for key in writes)
|
|
1873
|
+
timeline.sort(key=lambda item: (item[0], item[1] == "check"))
|
|
1874
|
+
cycles = 0
|
|
1875
|
+
write_since_last_check = False
|
|
1876
|
+
for _key, kind in timeline:
|
|
1877
|
+
if kind == "write":
|
|
1878
|
+
write_since_last_check = True
|
|
1879
|
+
else:
|
|
1880
|
+
if write_since_last_check:
|
|
1881
|
+
cycles += 1
|
|
1882
|
+
write_since_last_check = False
|
|
1883
|
+
|
|
1884
|
+
unit_statuses.append(UnitStatus(
|
|
1885
|
+
key=unit.key, label=unit.label,
|
|
1886
|
+
checks_declared=len(roots[repo]) if repo is not None else 0,
|
|
1887
|
+
last_counted=last_counted, word=word, writes_after=writes_after,
|
|
1888
|
+
cycles=cycles, never_command_observed=bool(never_matches), repo=repo,
|
|
1889
|
+
))
|
|
1890
|
+
|
|
1891
|
+
listed_lines = {row.line_no for row in not_counted}
|
|
1892
|
+
for _order_key, call, _affected in shell_writes:
|
|
1893
|
+
if id(call) in flagged_shell_calls and call.line_no not in listed_lines:
|
|
1894
|
+
command = call.command or ""
|
|
1895
|
+
not_counted.append(NotCountedRow(
|
|
1896
|
+
line_no=call.line_no,
|
|
1897
|
+
reason="a Bash command that may write ran after the check",
|
|
1898
|
+
excerpt=command[:400],
|
|
1899
|
+
category=_never_category(command),
|
|
1900
|
+
))
|
|
1901
|
+
|
|
1902
|
+
# docs/DESIGN.md 4.11, 4.13.11 (N5c-2), the first condition that applies wins: 3 for
|
|
1903
|
+
# any failed check, ahead of every 4 condition (a NEVER-list match, an unknown
|
|
1904
|
+
# lifecycle, a transcript that could not be read, a check not observed, a write after
|
|
1905
|
+
# a check); else 0.
|
|
1906
|
+
# K4.3: no unit at all exits 0 only for a finished run whose every attempt completed.
|
|
1907
|
+
empty_state, empty_count = empty_unit_state(run) if not unit_statuses else ("yet", 0)
|
|
1908
|
+
status_state, status_breakdown = no_unit_state(run) if not unit_statuses else (None, None)
|
|
1909
|
+
empty_not_ok = not unit_statuses and (
|
|
1910
|
+
empty_state != "ok" or status_state == NO_UNIT_UNESTABLISHED
|
|
1911
|
+
)
|
|
1912
|
+
if any(u.word == "NONZERO_OBSERVED" for u in unit_statuses):
|
|
1913
|
+
exit_code = 3
|
|
1914
|
+
elif (
|
|
1915
|
+
transcript_unknown or never_matches or _lifecycle_unknown(run) or empty_not_ok
|
|
1916
|
+
or any(u.word == "NOT_OBSERVED" or u.writes_after > 0 for u in unit_statuses)
|
|
1917
|
+
):
|
|
1918
|
+
exit_code = 4
|
|
1919
|
+
else:
|
|
1920
|
+
exit_code = 0
|
|
1921
|
+
|
|
1922
|
+
return RescueStatus(
|
|
1923
|
+
run_id=run.run_id, units=unit_statuses, not_counted=not_counted,
|
|
1924
|
+
never_matches=never_matches, transcript_unknown=transcript_unknown,
|
|
1925
|
+
checked=observation.checked, repo_root=observation.repo_root,
|
|
1926
|
+
exit_code=exit_code, listed_paths=tuple(all_listed),
|
|
1927
|
+
nothing_to_rescue_ok=empty_state == "ok",
|
|
1928
|
+
unchecked_attempts=empty_count if empty_state == "risk" else None,
|
|
1929
|
+
empty_state=status_state,
|
|
1930
|
+
return_breakdown=status_breakdown,
|
|
1931
|
+
)
|
|
1932
|
+
|
|
1933
|
+
|
|
1934
|
+
_FRESHNESS_SENTENCE = (
|
|
1935
|
+
"Writes made outside this session after the check cannot be seen; "
|
|
1936
|
+
"freshness beyond the transcript is UNKNOWN."
|
|
1937
|
+
)
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
def _display_word(unit: UnitStatus) -> str:
|
|
1941
|
+
"""docs/DESIGN.md 4.11 (F3 hardening): the plain word (EXIT_0_OBSERVED /
|
|
1942
|
+
NONZERO_OBSERVED / NOT_OBSERVED) with NEVER_COMMAND_OBSERVED appended
|
|
1943
|
+
when a NEVER-list command was observed -- appended, never substituted,
|
|
1944
|
+
so both signals stay visible on the same line."""
|
|
1945
|
+
if unit.never_command_observed:
|
|
1946
|
+
return f"{unit.word} NEVER_COMMAND_OBSERVED"
|
|
1947
|
+
return unit.word
|
|
1948
|
+
|
|
1949
|
+
|
|
1950
|
+
def _unit_status_lines(
|
|
1951
|
+
unit: UnitStatus, status: RescueStatus, redactor: Redactor, *, evidence: bool = False,
|
|
1952
|
+
) -> list[str]:
|
|
1953
|
+
agent = redactor.names.agent(unit.label)
|
|
1954
|
+
repo_part = f" repository {redactor.names.path(unit.repo)}" if unit.repo is not None else ""
|
|
1955
|
+
lines = [f"UNIT {agent}{repo_part} checks declared: {unit.checks_declared}"]
|
|
1956
|
+
display_word = _display_word(unit)
|
|
1957
|
+
if unit.last_counted is not None:
|
|
1958
|
+
exit_word = "0" if unit.last_counted.completion == "completed_ok" else "non-zero"
|
|
1959
|
+
time_str = (
|
|
1960
|
+
unit.last_counted.ts.astimezone(timezone.utc).strftime("%H:%MZ")
|
|
1961
|
+
if unit.last_counted.ts else "unknown"
|
|
1962
|
+
)
|
|
1963
|
+
lines.append(
|
|
1964
|
+
f" last counted run: {redactor.check_text(unit.last_counted.command)} "
|
|
1965
|
+
f"exit {exit_word} at {time_str} -> {display_word}"
|
|
1966
|
+
)
|
|
1967
|
+
else:
|
|
1968
|
+
lines.append(f" last counted run: none -> {display_word}")
|
|
1969
|
+
write_suffix = " -> WRITE_AFTER_CHECK" if unit.writes_after > 0 else ""
|
|
1970
|
+
lines.append(f" writes to listed paths after that run: {unit.writes_after}{write_suffix}")
|
|
1971
|
+
lines.append(f" check cycles observed: {unit.cycles} of 2")
|
|
1972
|
+
for row in status.not_counted:
|
|
1973
|
+
lines.append(f" not counted: line {row.line_no}: {row.reason}")
|
|
1974
|
+
for row in status.never_matches:
|
|
1975
|
+
if evidence and redactor.level != "share":
|
|
1976
|
+
# D-o: v0.2's --no-redact line: the paths replaced the way the
|
|
1977
|
+
# PATHS table spells them at this level, the first 60 characters.
|
|
1978
|
+
excerpt = _pseudonymize_excerpt(row.excerpt, status, redactor)[:60]
|
|
1979
|
+
else:
|
|
1980
|
+
excerpt = row.category
|
|
1981
|
+
lines.append(f" commands matching the NEVER list: line {row.line_no}: {excerpt}")
|
|
1982
|
+
return lines
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def _pseudonymize_excerpt(excerpt: str, status: RescueStatus, redactor: Redactor) -> str:
|
|
1986
|
+
"""A command excerpt as `rescue status --evidence` prints it: every listed path, in its
|
|
1987
|
+
display (repo-relative) and absolute spellings, registered in the active path namespace so it
|
|
1988
|
+
prints as the path-N the PATHS table uses (at local the P19 display), and the excerpt then
|
|
1989
|
+
goes through the one free-text scanner (docs/DESIGN.md 5.1). No raw substring replacement:
|
|
1990
|
+
the scanner renders the complete shell word that holds a listed path, so a continuation
|
|
1991
|
+
after an escaped space or a quote is never left behind, and a prefix-related or a second
|
|
1992
|
+
listed path is found independently."""
|
|
1993
|
+
if not redactor.enabled:
|
|
1994
|
+
return excerpt
|
|
1995
|
+
for display in status.listed_paths:
|
|
1996
|
+
redactor.names.path(display)
|
|
1997
|
+
if status.repo_root:
|
|
1998
|
+
redactor.names.alias_path(os.path.join(status.repo_root, display), display)
|
|
1999
|
+
return redactor(excerpt)
|
|
2000
|
+
|
|
2001
|
+
|
|
2002
|
+
def _register_status_paths(run: Run, status: RescueStatus, redactor: Redactor) -> None:
|
|
2003
|
+
"""The same path-N namespace `_register_plan_paths` fills for `rescue
|
|
2004
|
+
plan` (docs/DESIGN.md 5.1), so a declared check's own text -- shown
|
|
2005
|
+
verbatim on the 'last counted run' line -- is redacted consistently with
|
|
2006
|
+
the PATHS table `rescue plan` already prints for the same paths."""
|
|
2007
|
+
global_cwd = observe._discover_cwd(run) if status.checked else None
|
|
2008
|
+
cwd = observe._discover_cwd(run)
|
|
2009
|
+
for row in status.units:
|
|
2010
|
+
if row.repo is not None:
|
|
2011
|
+
redactor.names.path(row.repo)
|
|
2012
|
+
for unit in qualifying_units(run):
|
|
2013
|
+
for raw_path in _listed_spellings(unit):
|
|
2014
|
+
if status.checked:
|
|
2015
|
+
display = _display_key(raw_path, global_cwd, status.repo_root)
|
|
2016
|
+
else:
|
|
2017
|
+
display = raw_path
|
|
2018
|
+
redactor.names.path(display, cwd, resolve_from=raw_path)
|
|
2019
|
+
|
|
2020
|
+
|
|
2021
|
+
def render_status_text(
|
|
2022
|
+
run: Run, status: RescueStatus, redactor: Redactor, *, evidence: bool = False,
|
|
2023
|
+
) -> str:
|
|
2024
|
+
report._register_names(run, redactor, detailed=True)
|
|
2025
|
+
_register_status_paths(run, status, redactor)
|
|
2026
|
+
blocks: list[str] = []
|
|
2027
|
+
if evidence and redactor.level == "share":
|
|
2028
|
+
blocks.append(report.EXCERPT_NOTE)
|
|
2029
|
+
if not status.units:
|
|
2030
|
+
if status.empty_state == NO_UNIT_UNESTABLISHED:
|
|
2031
|
+
blocks.append(redactor(_no_unit_sentence(status.run_id, status.return_breakdown)))
|
|
2032
|
+
elif status.unchecked_attempts is not None:
|
|
2033
|
+
blocks.append(_unchecked_sentence(status.unchecked_attempts, status.run_id))
|
|
2034
|
+
elif status.nothing_to_rescue_ok:
|
|
2035
|
+
blocks.append("NOTHING TO RESCUE: every attempt completed.")
|
|
2036
|
+
else:
|
|
2037
|
+
blocks.append(_NOTHING_YET)
|
|
2038
|
+
else:
|
|
2039
|
+
for unit in status.units:
|
|
2040
|
+
blocks.append("\n".join(_unit_status_lines(unit, status, redactor, evidence=evidence)))
|
|
2041
|
+
blocks.append(_FRESHNESS_SENTENCE)
|
|
2042
|
+
return "\n".join(blocks) + "\n"
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def status_to_json(
|
|
2046
|
+
run: Run, status: RescueStatus, redactor: Redactor, *, evidence: bool = False,
|
|
2047
|
+
) -> dict:
|
|
2048
|
+
report._register_names(run, redactor, detailed=True)
|
|
2049
|
+
_register_status_paths(run, status, redactor)
|
|
2050
|
+
units = []
|
|
2051
|
+
for unit in status.units:
|
|
2052
|
+
last = unit.last_counted
|
|
2053
|
+
units.append({
|
|
2054
|
+
"agent": redactor.names.agent(unit.label),
|
|
2055
|
+
"repo": redactor.names.path(unit.repo) if unit.repo is not None else None,
|
|
2056
|
+
"checks_declared": unit.checks_declared,
|
|
2057
|
+
"last_counted_run": None if last is None else {
|
|
2058
|
+
"command": redactor.check_text(last.command),
|
|
2059
|
+
"completion": last.completion,
|
|
2060
|
+
"at": last.ts.isoformat() if last.ts else None,
|
|
2061
|
+
},
|
|
2062
|
+
"word": _display_word(unit),
|
|
2063
|
+
"writes_after": unit.writes_after,
|
|
2064
|
+
"write_after_check": unit.writes_after > 0,
|
|
2065
|
+
"cycles": unit.cycles,
|
|
2066
|
+
})
|
|
2067
|
+
# docs/DESIGN.md 5.3b: `category` is the matched rule's fixed spelling (or
|
|
2068
|
+
# the word `command`), never transcript text; the quoted excerpt exists
|
|
2069
|
+
# only at local and raw with --evidence, the same 60 characters the text
|
|
2070
|
+
# shows there.
|
|
2071
|
+
quote = evidence and redactor.level != "share"
|
|
2072
|
+
|
|
2073
|
+
def excerpt_part(row) -> dict:
|
|
2074
|
+
if not quote:
|
|
2075
|
+
return {}
|
|
2076
|
+
return {"excerpt": _pseudonymize_excerpt(row.excerpt, status, redactor)[:60]}
|
|
2077
|
+
|
|
2078
|
+
return {
|
|
2079
|
+
"schema": SCHEMA_VERSION,
|
|
2080
|
+
"disclosure": redactor.level,
|
|
2081
|
+
"run": {"run_id": status.run_id},
|
|
2082
|
+
"units": units,
|
|
2083
|
+
"not_counted": [
|
|
2084
|
+
{"line": row.line_no, "reason": row.reason, "category": row.category,
|
|
2085
|
+
**excerpt_part(row)}
|
|
2086
|
+
for row in status.not_counted
|
|
2087
|
+
],
|
|
2088
|
+
"never_matches": [
|
|
2089
|
+
{"line": row.line_no, "category": row.category, **excerpt_part(row)}
|
|
2090
|
+
for row in status.never_matches
|
|
2091
|
+
],
|
|
2092
|
+
"transcript_unknown": status.transcript_unknown,
|
|
2093
|
+
"empty_state": status.empty_state,
|
|
2094
|
+
"return_breakdown": status.return_breakdown,
|
|
2095
|
+
"exit_code": status.exit_code,
|
|
2096
|
+
}
|