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,792 @@
|
|
|
1
|
+
"""Read-only repo observation of paths a killed or failed attempt wrote.
|
|
2
|
+
|
|
3
|
+
docs/DESIGN.md 4.9. observe_run(run) looks at the attempts of one Run whose
|
|
4
|
+
outcome is 'killed', 'failed', or 'superseded' and, for every path one of
|
|
5
|
+
them wrote or left in flight, asks git (and only git, and only five exact
|
|
6
|
+
argument lists) whether that path is dirty, clean, or missing right now.
|
|
7
|
+
|
|
8
|
+
It never writes anything: no index update, no stash, no object, no config
|
|
9
|
+
change, no repo-controlled process. Standard library only: subprocess, os,
|
|
10
|
+
re, shutil, dataclasses, datetime.
|
|
11
|
+
|
|
12
|
+
This module is not called by the reader, a detector, or a hook in this
|
|
13
|
+
unit. It is wired into `postmortem run` only behind the `--observe` flag
|
|
14
|
+
(cli.py), so it is testable end to end; a guided-rescue command that calls
|
|
15
|
+
it directly is a later unit.
|
|
16
|
+
|
|
17
|
+
Every guard below exists because the repository being observed is not
|
|
18
|
+
trusted: it can configure a clean/process filter that runs an arbitrary
|
|
19
|
+
program on `git status`, or simply not exist, or not even be a git
|
|
20
|
+
repository at all. Rule of thumb for anyone touching this file: if a change
|
|
21
|
+
would make git run something the repo's own configuration chose, it is
|
|
22
|
+
wrong -- the five argument lists below are the only ones this module is
|
|
23
|
+
ever allowed to run, and no path derived from a repository (a filter name,
|
|
24
|
+
a config value) may become part of an eventual command.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
import re
|
|
31
|
+
import shutil
|
|
32
|
+
import subprocess
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import datetime, timedelta, timezone
|
|
35
|
+
|
|
36
|
+
from . import ledger
|
|
37
|
+
from .models import Attempt, Run
|
|
38
|
+
|
|
39
|
+
# --- public states (docs/DESIGN.md 4.9) -------------------------------------
|
|
40
|
+
|
|
41
|
+
DIRTY_NOW = "DIRTY_NOW"
|
|
42
|
+
CLEAN_NOW = "CLEAN_NOW"
|
|
43
|
+
MISSING_NOW = "MISSING_NOW"
|
|
44
|
+
UNKNOWN = "UNKNOWN"
|
|
45
|
+
|
|
46
|
+
# Outcomes whose paths_written/in_flight are candidates for observation
|
|
47
|
+
# (docs/DESIGN.md 4.9 rule 2).
|
|
48
|
+
_CANDIDATE_OUTCOMES = frozenset({"killed", "failed", "superseded"})
|
|
49
|
+
|
|
50
|
+
# The exact regex argument to the filter-driver guard (rule 4/5): matches any
|
|
51
|
+
# git config key of the form filter.<name>.clean or filter.<name>.process,
|
|
52
|
+
# the two hooks `git status`/`git add` can invoke.
|
|
53
|
+
_FILTER_CONFIG_PATTERN = r"^filter\..*\.(clean|process)$"
|
|
54
|
+
|
|
55
|
+
# The `--show-scope` scopes whose filter drivers the person installed for
|
|
56
|
+
# themselves (rule 5): `git lfs install` writes Git LFS's drivers to the global
|
|
57
|
+
# config, a system package to the system config. Such a driver is switched off
|
|
58
|
+
# for the one `status` call (`_neutralize_filters`), never run. Every other
|
|
59
|
+
# scope (`local`, `worktree`, a file either of those includes, `command`,
|
|
60
|
+
# `unknown`) is one the observed repository can steer, so a driver there still
|
|
61
|
+
# refuses `status`.
|
|
62
|
+
_PERSON_FILTER_SCOPES = frozenset({"system", "global"})
|
|
63
|
+
|
|
64
|
+
# A key `_filter_drivers` accepts, capturing the driver name. A name outside
|
|
65
|
+
# this character set refuses `status` instead of being switched off: git splits
|
|
66
|
+
# a `-c` argument at its first `=`, and a subsection name may hold spaces and
|
|
67
|
+
# other characters this module would have to quote.
|
|
68
|
+
_FILTER_KEY_RE = re.compile(r"^filter\.([A-Za-z0-9][A-Za-z0-9_.-]*)\.(clean|process)$")
|
|
69
|
+
|
|
70
|
+
_GIT_VERSION_RE = re.compile(r"git version (\d+)\.(\d+)(?:\.(\d+))?")
|
|
71
|
+
_MIN_GIT_VERSION = (2, 36, 0)
|
|
72
|
+
|
|
73
|
+
_STALE_AFTER = timedelta(hours=72)
|
|
74
|
+
|
|
75
|
+
_SUBPROCESS_TIMEOUT = 10
|
|
76
|
+
|
|
77
|
+
# The closed list of group-level reasons (docs/DESIGN.md 4.9, N5c-1; plan S6).
|
|
78
|
+
REASON_REFUSED = "refused"
|
|
79
|
+
REASON_HISTORICAL = "historical"
|
|
80
|
+
REASON_NOT_GIT = "not a git repository"
|
|
81
|
+
REASON_GIT_UNAVAILABLE = "git unavailable"
|
|
82
|
+
REASON_GUARD = "guard refused"
|
|
83
|
+
REASON_OUTSIDE = "outside the work tree"
|
|
84
|
+
GROUP_REASONS = (
|
|
85
|
+
REASON_REFUSED, REASON_HISTORICAL, REASON_NOT_GIT, REASON_GIT_UNAVAILABLE, REASON_GUARD,
|
|
86
|
+
REASON_OUTSIDE,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class PathObservation:
|
|
92
|
+
"""One candidate path's observed git state (docs/DESIGN.md 4.9).
|
|
93
|
+
|
|
94
|
+
state is one of DIRTY_NOW / CLEAN_NOW / MISSING_NOW / UNKNOWN. reason is
|
|
95
|
+
set only for a path this module refused to ask git about at all (outside
|
|
96
|
+
the work tree) -- never for one it actually asked git and got an answer
|
|
97
|
+
for. attempts holds, for every attempt (of the run this observation came
|
|
98
|
+
from) that wrote or left this path in flight, {'agent_id', 'ordinal'},
|
|
99
|
+
in the order those attempts were found.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
state: str
|
|
103
|
+
later_write_observed: bool
|
|
104
|
+
attempts: list[dict] = field(default_factory=list)
|
|
105
|
+
reason: str | None = None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class RepoObservation:
|
|
110
|
+
"""The result of observe_run() (docs/DESIGN.md 4.9).
|
|
111
|
+
|
|
112
|
+
checked is False whenever no git process was ever started (the run is
|
|
113
|
+
stale, there were no candidate paths, the cwd could not be resolved, or
|
|
114
|
+
a guard refused); reason then names why. paths is keyed by the path
|
|
115
|
+
relative to repo_root for a path git was actually asked about, or by the
|
|
116
|
+
original candidate path string for one refused as outside the work tree
|
|
117
|
+
-- the same string a renderer shows under --no-redact and pseudonymizes
|
|
118
|
+
as path-N otherwise.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
checked: bool
|
|
122
|
+
reason: str | None = None
|
|
123
|
+
repo_root: str | None = None
|
|
124
|
+
head: str | None = None
|
|
125
|
+
git_version: str | None = None
|
|
126
|
+
paths: dict[str, PathObservation] = field(default_factory=dict)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _not_checked(
|
|
130
|
+
reason: str, *, git_version: str | None = None, repo_root: str | None = None,
|
|
131
|
+
head: str | None = None,
|
|
132
|
+
) -> RepoObservation:
|
|
133
|
+
return RepoObservation(
|
|
134
|
+
checked=False, reason=reason, repo_root=repo_root, head=head,
|
|
135
|
+
git_version=git_version, paths={},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# --- facts plumbing (defensive: a fact can be the literal string 'UNKNOWN') --
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _facts_lists(facts) -> tuple[list, list]:
|
|
143
|
+
"""(paths_written, in_flight) as real lists, or ([], []) when this
|
|
144
|
+
attempt has no facts or the reader could not read its transcript
|
|
145
|
+
(models.FACTS_UNKNOWN is a string, never iterate it as if it were a
|
|
146
|
+
list -- that would yield one candidate per character)."""
|
|
147
|
+
if facts is None:
|
|
148
|
+
return [], []
|
|
149
|
+
written = facts.paths_written if isinstance(facts.paths_written, list) else []
|
|
150
|
+
in_flight = facts.in_flight if isinstance(facts.in_flight, list) else []
|
|
151
|
+
return written, in_flight
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _in_flight_paths(in_flight: list) -> list[str]:
|
|
155
|
+
paths = []
|
|
156
|
+
for entry in in_flight:
|
|
157
|
+
if isinstance(entry, dict):
|
|
158
|
+
path = entry.get("path")
|
|
159
|
+
if isinstance(path, str) and path:
|
|
160
|
+
paths.append(path)
|
|
161
|
+
return paths
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _attempt_has_path(attempt, raw_path: str) -> bool:
|
|
165
|
+
written, in_flight = _facts_lists(attempt.facts)
|
|
166
|
+
if raw_path in written:
|
|
167
|
+
return True
|
|
168
|
+
return raw_path in _in_flight_paths(in_flight)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _candidate_paths(run: Run) -> tuple[list[str], dict[str, list]]:
|
|
172
|
+
"""The union of paths_written and in_flight[].path over every attempt
|
|
173
|
+
whose outcome is killed/failed/superseded (rule 2), in first-seen
|
|
174
|
+
order, plus which attempt objects wrote each one (also first-seen
|
|
175
|
+
order, and each attempt listed at most once per path even when it
|
|
176
|
+
appears in both paths_written and in_flight)."""
|
|
177
|
+
order: list[str] = []
|
|
178
|
+
writers: dict[str, list] = {}
|
|
179
|
+
for attempt in run.attempts:
|
|
180
|
+
if attempt.outcome not in _CANDIDATE_OUTCOMES:
|
|
181
|
+
continue
|
|
182
|
+
written, in_flight = _facts_lists(attempt.facts)
|
|
183
|
+
seen_this_attempt: set[str] = set()
|
|
184
|
+
for raw_path in list(written) + _in_flight_paths(in_flight):
|
|
185
|
+
if raw_path in seen_this_attempt:
|
|
186
|
+
continue
|
|
187
|
+
seen_this_attempt.add(raw_path)
|
|
188
|
+
if raw_path not in writers:
|
|
189
|
+
writers[raw_path] = []
|
|
190
|
+
order.append(raw_path)
|
|
191
|
+
writers[raw_path].append(attempt)
|
|
192
|
+
return order, writers
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _discover_cwd(run: Run) -> str | None:
|
|
196
|
+
"""The most common cwd among the candidate-outcome attempts' own
|
|
197
|
+
facts.cwds, ties broken by first-seen order (rule 3). max() over a list
|
|
198
|
+
built in first-seen order already breaks ties this way: it only
|
|
199
|
+
replaces its current pick on a strictly greater count, so the first cwd
|
|
200
|
+
to reach the eventual maximum keeps it."""
|
|
201
|
+
counts: dict[str, int] = {}
|
|
202
|
+
order: list[str] = []
|
|
203
|
+
for attempt in run.attempts:
|
|
204
|
+
if attempt.outcome not in _CANDIDATE_OUTCOMES:
|
|
205
|
+
continue
|
|
206
|
+
facts = attempt.facts
|
|
207
|
+
cwds = facts.cwds if facts is not None and isinstance(facts.cwds, list) else []
|
|
208
|
+
for cwd in cwds:
|
|
209
|
+
if not isinstance(cwd, str) or not cwd:
|
|
210
|
+
continue
|
|
211
|
+
if cwd not in counts:
|
|
212
|
+
counts[cwd] = 0
|
|
213
|
+
order.append(cwd)
|
|
214
|
+
counts[cwd] += 1
|
|
215
|
+
if not order:
|
|
216
|
+
return None
|
|
217
|
+
return max(order, key=lambda cwd: counts[cwd])
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _later_write_observed(run: Run, writer_attempts: list, raw_path: str) -> bool:
|
|
221
|
+
"""True when an attempt of the same key with a higher ordinal than one
|
|
222
|
+
of writer_attempts also wrote raw_path (rule 8)."""
|
|
223
|
+
for writer in writer_attempts:
|
|
224
|
+
if writer.key is None:
|
|
225
|
+
continue
|
|
226
|
+
for other in run.attempts:
|
|
227
|
+
if other.key == writer.key and other.ordinal > writer.ordinal:
|
|
228
|
+
if _attempt_has_path(other, raw_path):
|
|
229
|
+
return True
|
|
230
|
+
return False
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# --- the exact, from-scratch environment (rule 4) ----------------------------
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _build_env() -> dict[str, str]:
|
|
237
|
+
"""An environment built from scratch (rule 4): PATH and HOME copied from
|
|
238
|
+
the current process, a fixed set of git-behavior variables, and nothing
|
|
239
|
+
else -- no GIT_DIR, no GIT_WORK_TREE, no GIT_CONFIG_*, no
|
|
240
|
+
GIT_EXTERNAL_DIFF, none of which this process happens to have set, but
|
|
241
|
+
none of which git should ever be handed even if it did.
|
|
242
|
+
`GIT_ALLOW_PROTOCOL=` (no transport protocol allowed, overriding any
|
|
243
|
+
`protocol.*.allow` in the repository's config) and `GIT_NO_LAZY_FETCH=1`
|
|
244
|
+
stop `status` in a partial clone from fetching a missing object through a
|
|
245
|
+
transport the repository configured, such as `ext::<command>`."""
|
|
246
|
+
env: dict[str, str] = {}
|
|
247
|
+
path = os.environ.get("PATH")
|
|
248
|
+
if path is not None:
|
|
249
|
+
env["PATH"] = path
|
|
250
|
+
home = os.environ.get("HOME")
|
|
251
|
+
if home is not None:
|
|
252
|
+
env["HOME"] = home
|
|
253
|
+
env["GIT_OPTIONAL_LOCKS"] = "0"
|
|
254
|
+
env["GIT_PAGER"] = "cat"
|
|
255
|
+
env["PAGER"] = "cat"
|
|
256
|
+
env["GIT_TERMINAL_PROMPT"] = "0"
|
|
257
|
+
env["LC_ALL"] = "C"
|
|
258
|
+
env["GIT_ALLOW_PROTOCOL"] = ""
|
|
259
|
+
env["GIT_NO_LAZY_FETCH"] = "1"
|
|
260
|
+
return env
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _run_git(
|
|
264
|
+
git_path: str, args: list[str], env: dict[str, str],
|
|
265
|
+
) -> subprocess.CompletedProcess | None:
|
|
266
|
+
"""Run exactly `[git_path, *args]` with the restricted environment,
|
|
267
|
+
never a shell, never inheriting a cwd of its own (callers pass `-C
|
|
268
|
+
<dir>` instead). `git_path` is always the one absolute path
|
|
269
|
+
`observe_run` resolved once with `shutil.which("git")` -- never the
|
|
270
|
+
bare string "git" -- so every one of this module's five calls runs the
|
|
271
|
+
exact same binary rather than each doing its own separate PATH
|
|
272
|
+
resolution (docs/DESIGN.md 4.9 rule 4). Any failure to even start the
|
|
273
|
+
process, or a timeout, comes back as None so the caller turns it into
|
|
274
|
+
`checked=False` with its own reason rather than letting an exception
|
|
275
|
+
escape observe_run."""
|
|
276
|
+
try:
|
|
277
|
+
return subprocess.run(
|
|
278
|
+
[git_path, *args],
|
|
279
|
+
env=env,
|
|
280
|
+
stdin=subprocess.DEVNULL,
|
|
281
|
+
capture_output=True,
|
|
282
|
+
timeout=_SUBPROCESS_TIMEOUT,
|
|
283
|
+
encoding="utf-8",
|
|
284
|
+
errors="replace",
|
|
285
|
+
)
|
|
286
|
+
except (OSError, subprocess.TimeoutExpired, ValueError):
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _filter_drivers(
|
|
291
|
+
git_path: str, cwd: str, env: dict[str, str],
|
|
292
|
+
) -> tuple[bool | None, tuple[str, ...]]:
|
|
293
|
+
"""Rule 5, guard 4. Returns (repo_driver, person_drivers). repo_driver is
|
|
294
|
+
None when the check itself failed; True when `status` must not run: a clean
|
|
295
|
+
or process filter driver comes from a scope the repository can steer, or a
|
|
296
|
+
line is not `<scope>\\t<key> <value>` with a key `_FILTER_KEY_RE` accepts;
|
|
297
|
+
False otherwise (exit 1, git's "no such key" for --get-regexp, means no
|
|
298
|
+
driver at all). person_drivers, given only with False, names each driver
|
|
299
|
+
from the person's own system or global config, sorted; `status` runs with
|
|
300
|
+
every one of them switched off, so no driver ever runs. A multi-line
|
|
301
|
+
value's continuation line never parses, so it can only add a refusal,
|
|
302
|
+
never hide the line its own scope opens with."""
|
|
303
|
+
result = _run_git(
|
|
304
|
+
git_path,
|
|
305
|
+
["-C", cwd, "config", "--show-scope", "--get-regexp", _FILTER_CONFIG_PATTERN],
|
|
306
|
+
env,
|
|
307
|
+
)
|
|
308
|
+
if result is None:
|
|
309
|
+
return None, ()
|
|
310
|
+
if result.returncode == 1:
|
|
311
|
+
return False, ()
|
|
312
|
+
if result.returncode != 0:
|
|
313
|
+
return None, ()
|
|
314
|
+
# git ends each record with "\n" only, so split on it alone (str.splitlines
|
|
315
|
+
# would also split a value at U+2028 and other Unicode breaks) and drop only
|
|
316
|
+
# the final empty piece: an empty line inside the output is a multi-line
|
|
317
|
+
# value's continuation, which refuses like any other line that does not
|
|
318
|
+
# parse.
|
|
319
|
+
lines = result.stdout.split("\n")
|
|
320
|
+
if lines[-1] == "":
|
|
321
|
+
lines.pop()
|
|
322
|
+
if not lines:
|
|
323
|
+
return None, ()
|
|
324
|
+
names: set[str] = set()
|
|
325
|
+
for line in lines:
|
|
326
|
+
scope, tab, rest = line.partition("\t")
|
|
327
|
+
match = _FILTER_KEY_RE.match(rest.split(" ", 1)[0])
|
|
328
|
+
if not tab or scope not in _PERSON_FILTER_SCOPES or match is None:
|
|
329
|
+
return True, ()
|
|
330
|
+
names.add(match.group(1))
|
|
331
|
+
return False, tuple(sorted(names))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _neutralize_filters(names: tuple[str, ...]) -> list[str]:
|
|
335
|
+
"""The `-c` arguments that switch off each named driver for one `status`
|
|
336
|
+
call: an empty clean and process command, and `required=false` so git does
|
|
337
|
+
not stop on the missing driver. A file routed through one is then compared
|
|
338
|
+
as stored, without running anything (for Git LFS, a file whose timestamp
|
|
339
|
+
changed reads dirty even when its content did not)."""
|
|
340
|
+
args: list[str] = []
|
|
341
|
+
for name in names:
|
|
342
|
+
args += [
|
|
343
|
+
"-c", f"filter.{name}.clean=",
|
|
344
|
+
"-c", f"filter.{name}.process=",
|
|
345
|
+
"-c", f"filter.{name}.required=false",
|
|
346
|
+
]
|
|
347
|
+
return args
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _parse_git_version(version_text: str | None) -> tuple[int, int, int] | None:
|
|
351
|
+
if not version_text:
|
|
352
|
+
return None
|
|
353
|
+
match = _GIT_VERSION_RE.search(version_text)
|
|
354
|
+
if not match:
|
|
355
|
+
return None
|
|
356
|
+
major, minor, patch = match.group(1), match.group(2), match.group(3)
|
|
357
|
+
return (int(major), int(minor), int(patch) if patch is not None else 0)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _parse_porcelain_v2(raw: str) -> set[str]:
|
|
361
|
+
"""The set of repo-relative paths git reports a status entry for, from
|
|
362
|
+
`status --porcelain=v2 -z` output (rule 7).
|
|
363
|
+
|
|
364
|
+
Every record is one or more NUL-separated chunks. Type '1' (ordinary,
|
|
365
|
+
staged or not) and 'u' (unmerged) are one chunk each; '2'
|
|
366
|
+
(rename/copy -- never produced here, --no-renames is always passed, but
|
|
367
|
+
parsed correctly anyway) is two chunks, path then origPath, and only the
|
|
368
|
+
new path is dirty; '?' (untracked) and '!' (ignored) are one chunk with
|
|
369
|
+
no numeric fields at all. The path is taken as the remainder after the
|
|
370
|
+
fixed number of space-separated fields so an embedded space in a real
|
|
371
|
+
path is never split apart.
|
|
372
|
+
"""
|
|
373
|
+
chunks = [chunk for chunk in raw.split("\0") if chunk]
|
|
374
|
+
dirty: set[str] = set()
|
|
375
|
+
i = 0
|
|
376
|
+
n = len(chunks)
|
|
377
|
+
while i < n:
|
|
378
|
+
chunk = chunks[i]
|
|
379
|
+
type_char = chunk[0]
|
|
380
|
+
if type_char == "1":
|
|
381
|
+
parts = chunk.split(" ", 8)
|
|
382
|
+
dirty.add(parts[-1])
|
|
383
|
+
i += 1
|
|
384
|
+
elif type_char == "u":
|
|
385
|
+
parts = chunk.split(" ", 10)
|
|
386
|
+
dirty.add(parts[-1])
|
|
387
|
+
i += 1
|
|
388
|
+
elif type_char == "2":
|
|
389
|
+
parts = chunk.split(" ", 9)
|
|
390
|
+
dirty.add(parts[-1])
|
|
391
|
+
i += 2 # skip the origPath chunk that follows
|
|
392
|
+
elif type_char in ("?", "!"):
|
|
393
|
+
dirty.add(chunk[2:])
|
|
394
|
+
i += 1
|
|
395
|
+
else:
|
|
396
|
+
i += 1
|
|
397
|
+
return dirty
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def observe_run(run: Run, now: datetime | None = None) -> RepoObservation:
|
|
401
|
+
"""docs/DESIGN.md 4.9. Read-only. Starts a git process only after every
|
|
402
|
+
prior guard has passed, and only ever the five argument lists rule 4
|
|
403
|
+
lists -- never a subcommand a repository could steer. No exception ever
|
|
404
|
+
escapes this function: every failure mode becomes checked=False with a
|
|
405
|
+
reason naming the step."""
|
|
406
|
+
now = now if now is not None else datetime.now(timezone.utc)
|
|
407
|
+
|
|
408
|
+
# Rule 1: staleness. No process started at all for a run that ended
|
|
409
|
+
# more than 72 hours ago -- the working tree has long since moved on.
|
|
410
|
+
if run.ended_at is not None and (now - run.ended_at) > _STALE_AFTER:
|
|
411
|
+
return _not_checked("historical: run older than 72 hours")
|
|
412
|
+
|
|
413
|
+
# Rule 2: candidate paths.
|
|
414
|
+
order, writers = _candidate_paths(run)
|
|
415
|
+
if not order:
|
|
416
|
+
return _not_checked("no paths written by a killed or failed attempt")
|
|
417
|
+
|
|
418
|
+
# Rule 3: repo discovery.
|
|
419
|
+
cwd = _discover_cwd(run)
|
|
420
|
+
if cwd is None or not os.path.isdir(cwd):
|
|
421
|
+
return _not_checked("cwd not found")
|
|
422
|
+
|
|
423
|
+
# Rule 5, guard 1: git on PATH. Resolved to one absolute path here and
|
|
424
|
+
# reused for every call below (docs/DESIGN.md 4.9 rule 4) -- never a
|
|
425
|
+
# bare "git" that would let each of the five calls do its own,
|
|
426
|
+
# independently timed PATH search.
|
|
427
|
+
git_path = shutil.which("git")
|
|
428
|
+
if git_path is None:
|
|
429
|
+
return _not_checked("git not found on PATH")
|
|
430
|
+
|
|
431
|
+
env = _build_env()
|
|
432
|
+
|
|
433
|
+
# Rule 5, guard 2: git 2.36 or newer. An unparsable --version output (a
|
|
434
|
+
# failed process, a timeout, unexpected text) counts as too old -- there
|
|
435
|
+
# is no version we can trust it to be.
|
|
436
|
+
version_result = _run_git(git_path, ["--version"], env)
|
|
437
|
+
git_version = (
|
|
438
|
+
version_result.stdout.strip()
|
|
439
|
+
if version_result is not None and version_result.returncode == 0
|
|
440
|
+
else None
|
|
441
|
+
)
|
|
442
|
+
parsed_version = _parse_git_version(git_version)
|
|
443
|
+
if parsed_version is None or parsed_version < _MIN_GIT_VERSION:
|
|
444
|
+
return _not_checked("git is older than 2.36, or its version could not be read",
|
|
445
|
+
git_version=git_version)
|
|
446
|
+
|
|
447
|
+
# Rule 5, guard 3: this is actually a git work tree.
|
|
448
|
+
toplevel_result = _run_git(git_path, ["-C", cwd, "rev-parse", "--show-toplevel"], env)
|
|
449
|
+
if toplevel_result is None or toplevel_result.returncode != 0:
|
|
450
|
+
return _not_checked("cwd is not inside a git repository", git_version=git_version)
|
|
451
|
+
toplevel = toplevel_result.stdout.strip()
|
|
452
|
+
if not toplevel:
|
|
453
|
+
return _not_checked("cwd is not inside a git repository", git_version=git_version)
|
|
454
|
+
|
|
455
|
+
head_result = _run_git(git_path, ["-C", cwd, "rev-parse", "HEAD"], env)
|
|
456
|
+
if head_result is None or head_result.returncode != 0:
|
|
457
|
+
return _not_checked(
|
|
458
|
+
"git rev-parse HEAD failed", git_version=git_version, repo_root=toplevel,
|
|
459
|
+
)
|
|
460
|
+
head = head_result.stdout.strip()
|
|
461
|
+
|
|
462
|
+
# Rule 6: containment. A path outside the work tree (after being made
|
|
463
|
+
# absolute against cwd and normalized) is UNKNOWN and never reaches git.
|
|
464
|
+
paths: dict[str, PathObservation] = {}
|
|
465
|
+
contained: list[tuple[str, str]] = [] # (raw_path, repo-relative path)
|
|
466
|
+
prefix = toplevel.rstrip(os.sep) + os.sep
|
|
467
|
+
for raw_path in order:
|
|
468
|
+
writer_attempts = writers[raw_path]
|
|
469
|
+
later = _later_write_observed(run, writer_attempts, raw_path)
|
|
470
|
+
attempts = [
|
|
471
|
+
{"agent_id": attempt.agent_id, "ordinal": attempt.ordinal}
|
|
472
|
+
for attempt in writer_attempts
|
|
473
|
+
]
|
|
474
|
+
|
|
475
|
+
abs_path = raw_path if os.path.isabs(raw_path) else os.path.join(cwd, raw_path)
|
|
476
|
+
norm = os.path.normpath(abs_path)
|
|
477
|
+
if not norm.startswith(prefix):
|
|
478
|
+
paths[raw_path] = PathObservation(
|
|
479
|
+
state=UNKNOWN, later_write_observed=later, attempts=attempts,
|
|
480
|
+
reason="outside the work tree",
|
|
481
|
+
)
|
|
482
|
+
continue
|
|
483
|
+
rel_path = norm[len(prefix):]
|
|
484
|
+
contained.append((raw_path, rel_path))
|
|
485
|
+
paths[rel_path] = PathObservation(
|
|
486
|
+
state=UNKNOWN, later_write_observed=later, attempts=attempts,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
if contained:
|
|
490
|
+
# Rule 5, guard 4: no clean/process filter driver configured at a
|
|
491
|
+
# scope the repository can steer; a driver from the person's own
|
|
492
|
+
# system or global config, such as Git LFS's, is switched off for
|
|
493
|
+
# `status` instead of run. Run only when `status` is actually about
|
|
494
|
+
# to run, and as the call immediately before it -- no other
|
|
495
|
+
# subprocess call in between -- so the window in which a
|
|
496
|
+
# repository-controlled process could install a filter driver
|
|
497
|
+
# between this guard and `status` is as small as this module can
|
|
498
|
+
# make it without a stronger isolation primitive than the standard
|
|
499
|
+
# library offers (the remaining race is documented in
|
|
500
|
+
# docs/DESIGN.md 4.9 as a known limit).
|
|
501
|
+
repo_driver, person_drivers = _filter_drivers(git_path, cwd, env)
|
|
502
|
+
if repo_driver is None:
|
|
503
|
+
return _not_checked(
|
|
504
|
+
"git config --show-scope --get-regexp failed", git_version=git_version,
|
|
505
|
+
repo_root=toplevel, head=head,
|
|
506
|
+
)
|
|
507
|
+
if repo_driver:
|
|
508
|
+
return _not_checked(
|
|
509
|
+
"a clean or process filter driver is configured for this repository",
|
|
510
|
+
git_version=git_version, repo_root=toplevel, head=head,
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
rel_paths = [rel for _, rel in contained]
|
|
514
|
+
status_result = _run_git(
|
|
515
|
+
git_path,
|
|
516
|
+
[
|
|
517
|
+
"-C", cwd, "--no-optional-locks", "-c", "core.fsmonitor=false",
|
|
518
|
+
*_neutralize_filters(person_drivers),
|
|
519
|
+
"status", "--porcelain=v2", "-z", "--no-renames",
|
|
520
|
+
"--ignore-submodules=all", "--", *rel_paths,
|
|
521
|
+
],
|
|
522
|
+
env,
|
|
523
|
+
)
|
|
524
|
+
if status_result is None or status_result.returncode != 0:
|
|
525
|
+
return _not_checked(
|
|
526
|
+
"git status failed", git_version=git_version, repo_root=toplevel, head=head,
|
|
527
|
+
)
|
|
528
|
+
dirty = _parse_porcelain_v2(status_result.stdout)
|
|
529
|
+
for _raw_path, rel_path in contained:
|
|
530
|
+
observation = paths[rel_path]
|
|
531
|
+
if rel_path in dirty:
|
|
532
|
+
observation.state = DIRTY_NOW
|
|
533
|
+
else:
|
|
534
|
+
exists = os.path.exists(os.path.join(toplevel, rel_path))
|
|
535
|
+
observation.state = CLEAN_NOW if exists else MISSING_NOW
|
|
536
|
+
|
|
537
|
+
return RepoObservation(
|
|
538
|
+
checked=True, reason=None, repo_root=toplevel, head=head,
|
|
539
|
+
git_version=git_version, paths=paths,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# =============================================================================
|
|
544
|
+
# Per-repository observation (docs/DESIGN.md 4.9, N5c-1; plan S5 and S6)
|
|
545
|
+
# =============================================================================
|
|
546
|
+
|
|
547
|
+
_PATH_EFFECTS = frozenset({"observed", "claimed_target", "attempted"})
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
@dataclass
|
|
551
|
+
class GroupObservation:
|
|
552
|
+
"""One repository group of a run (docs/DESIGN.md 4.9, N5c-1). `repo` is the
|
|
553
|
+
candidate repository (None for the unknown group); `refused` is true for a
|
|
554
|
+
group a partial ledger contributes to and for the unknown group; `checked`
|
|
555
|
+
is true only when `git status` actually ran. `reason` is one of GROUP_REASONS
|
|
556
|
+
for a group that was not checked, else None. `repo_root`, `head`, and
|
|
557
|
+
`git_version` are internal (never exported). `paths` holds a PathObservation
|
|
558
|
+
per observed path (the absolute ledger path), only for a checked group;
|
|
559
|
+
`unobserved_paths` is every ledger path no process looked at. `attempts` is
|
|
560
|
+
the internal list of attempt references (`main`, `attempts[<i>]`)."""
|
|
561
|
+
|
|
562
|
+
repo: str | None
|
|
563
|
+
refused: bool
|
|
564
|
+
checked: bool
|
|
565
|
+
reason: str | None
|
|
566
|
+
repo_root: str | None = None
|
|
567
|
+
head: str | None = None
|
|
568
|
+
git_version: str | None = None
|
|
569
|
+
paths: dict[str, PathObservation] = field(default_factory=dict)
|
|
570
|
+
unobserved_paths: list[str] = field(default_factory=list)
|
|
571
|
+
attempts: list[str] = field(default_factory=list)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@dataclass
|
|
575
|
+
class RunObservation:
|
|
576
|
+
"""The result of observe_groups(): the ordered group list, named
|
|
577
|
+
repositories in sorted candidate order, then the unknown group."""
|
|
578
|
+
|
|
579
|
+
groups: list[GroupObservation] = field(default_factory=list)
|
|
580
|
+
|
|
581
|
+
@property
|
|
582
|
+
def checked(self) -> bool:
|
|
583
|
+
return any(group.checked for group in self.groups)
|
|
584
|
+
|
|
585
|
+
@property
|
|
586
|
+
def repo_root(self) -> str | None:
|
|
587
|
+
"""The first checked group's toplevel: the one repository root the
|
|
588
|
+
disclosure context knows at `local` (docs/DESIGN.md 4.13.5, D-n)."""
|
|
589
|
+
for group in self.groups:
|
|
590
|
+
if group.checked and group.repo_root:
|
|
591
|
+
return group.repo_root
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _entry_paths(attempt: Attempt) -> set[str]:
|
|
596
|
+
built = attempt.ledger
|
|
597
|
+
if built is None:
|
|
598
|
+
return set()
|
|
599
|
+
return {
|
|
600
|
+
entry.path for entry in built.entries
|
|
601
|
+
if entry.path is not None and entry.effect in _PATH_EFFECTS
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _later_write(run: Run, writers: list[Attempt], path: str) -> bool:
|
|
606
|
+
"""True when an attempt of the same key with a higher ordinal than one of the
|
|
607
|
+
path's writers also recorded the path (rule 8, on the ledger)."""
|
|
608
|
+
for writer in writers:
|
|
609
|
+
if writer.key is None:
|
|
610
|
+
continue
|
|
611
|
+
for other in run.attempts:
|
|
612
|
+
if (
|
|
613
|
+
other.key == writer.key and other.ordinal > writer.ordinal
|
|
614
|
+
and path in _entry_paths(other)
|
|
615
|
+
):
|
|
616
|
+
return True
|
|
617
|
+
return False
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _path_observation(
|
|
621
|
+
run: Run, members: list[Attempt], path: str, state: str,
|
|
622
|
+
) -> PathObservation:
|
|
623
|
+
writers = [attempt for attempt in members if path in _entry_paths(attempt)]
|
|
624
|
+
attempts = [
|
|
625
|
+
{
|
|
626
|
+
"agent_id": "main" if attempt is run.main else attempt.agent_id,
|
|
627
|
+
"ordinal": attempt.ordinal,
|
|
628
|
+
}
|
|
629
|
+
for attempt in writers
|
|
630
|
+
]
|
|
631
|
+
return PathObservation(
|
|
632
|
+
state=state, later_write_observed=_later_write(run, writers, path), attempts=attempts,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
class _GitSession:
|
|
637
|
+
"""The one absolute git path, the scrubbed environment, and the version
|
|
638
|
+
guard, resolved lazily and once for a whole run (docs/DESIGN.md 4.9 rules 4
|
|
639
|
+
and 5). `ready()` is None when git can be used, else the closed reason."""
|
|
640
|
+
|
|
641
|
+
def __init__(self) -> None:
|
|
642
|
+
self._resolved = False
|
|
643
|
+
self._reason: str | None = None
|
|
644
|
+
self.git_path: str | None = None
|
|
645
|
+
self.env: dict[str, str] = {}
|
|
646
|
+
self.version: str | None = None
|
|
647
|
+
|
|
648
|
+
def ready(self) -> str | None:
|
|
649
|
+
if self._resolved:
|
|
650
|
+
return self._reason
|
|
651
|
+
self._resolved = True
|
|
652
|
+
git_path = shutil.which("git")
|
|
653
|
+
if git_path is None:
|
|
654
|
+
self._reason = REASON_GIT_UNAVAILABLE
|
|
655
|
+
return self._reason
|
|
656
|
+
self.git_path = git_path
|
|
657
|
+
self.env = _build_env()
|
|
658
|
+
result = _run_git(git_path, ["--version"], self.env)
|
|
659
|
+
self.version = (
|
|
660
|
+
result.stdout.strip() if result is not None and result.returncode == 0 else None
|
|
661
|
+
)
|
|
662
|
+
parsed = _parse_git_version(self.version)
|
|
663
|
+
if parsed is None or parsed < _MIN_GIT_VERSION:
|
|
664
|
+
self._reason = REASON_GIT_UNAVAILABLE
|
|
665
|
+
return self._reason
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _unchecked(
|
|
669
|
+
repo: str | None, group, reason: str, *, refused: bool, session: "_GitSession | None" = None,
|
|
670
|
+
repo_root: str | None = None, head: str | None = None,
|
|
671
|
+
) -> GroupObservation:
|
|
672
|
+
return GroupObservation(
|
|
673
|
+
repo=repo, refused=refused, checked=False, reason=reason, repo_root=repo_root,
|
|
674
|
+
head=head, git_version=session.version if session is not None else None,
|
|
675
|
+
paths={}, unobserved_paths=sorted(group.paths), attempts=list(group.attempts),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _observe_named_group(
|
|
680
|
+
run: Run, repo: str, group, members: list[Attempt], session: _GitSession, stale: bool,
|
|
681
|
+
) -> GroupObservation:
|
|
682
|
+
"""The guarded 4.9 sequence for one named repository group that is not
|
|
683
|
+
refused. Every failure is an unchecked group with a closed reason; nothing
|
|
684
|
+
raises."""
|
|
685
|
+
if stale:
|
|
686
|
+
return _unchecked(repo, group, REASON_HISTORICAL, refused=False)
|
|
687
|
+
if not os.path.isdir(repo):
|
|
688
|
+
return _unchecked(repo, group, REASON_NOT_GIT, refused=False)
|
|
689
|
+
if session.ready() is not None:
|
|
690
|
+
return _unchecked(repo, group, REASON_GIT_UNAVAILABLE, refused=False)
|
|
691
|
+
git_path, env = session.git_path, session.env
|
|
692
|
+
assert git_path is not None
|
|
693
|
+
toplevel_result = _run_git(git_path, ["-C", repo, "rev-parse", "--show-toplevel"], env)
|
|
694
|
+
if toplevel_result is None or toplevel_result.returncode != 0:
|
|
695
|
+
return _unchecked(repo, group, REASON_NOT_GIT, refused=False, session=session)
|
|
696
|
+
toplevel = toplevel_result.stdout.strip()
|
|
697
|
+
if not toplevel:
|
|
698
|
+
return _unchecked(repo, group, REASON_NOT_GIT, refused=False, session=session)
|
|
699
|
+
head_result = _run_git(git_path, ["-C", repo, "rev-parse", "HEAD"], env)
|
|
700
|
+
if head_result is None or head_result.returncode != 0:
|
|
701
|
+
return _unchecked(
|
|
702
|
+
repo, group, REASON_GUARD, refused=False, session=session, repo_root=toplevel,
|
|
703
|
+
)
|
|
704
|
+
head = head_result.stdout.strip()
|
|
705
|
+
|
|
706
|
+
# Rule 6, containment: only a path inside the toplevel ever reaches git.
|
|
707
|
+
prefix = toplevel.rstrip(os.sep) + os.sep
|
|
708
|
+
contained: list[tuple[str, str]] = []
|
|
709
|
+
outside: list[str] = []
|
|
710
|
+
for path in sorted(group.paths):
|
|
711
|
+
norm = os.path.normpath(path)
|
|
712
|
+
if norm.startswith(prefix):
|
|
713
|
+
contained.append((path, norm[len(prefix):]))
|
|
714
|
+
else:
|
|
715
|
+
outside.append(path)
|
|
716
|
+
if not contained:
|
|
717
|
+
return _unchecked(
|
|
718
|
+
repo, group, REASON_OUTSIDE, refused=False, session=session,
|
|
719
|
+
repo_root=toplevel, head=head,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
# The filter-driver guard is the call immediately before `status`; a
|
|
723
|
+
# driver from the person's own system or global config is switched off
|
|
724
|
+
# for `status`, never run.
|
|
725
|
+
repo_driver, person_drivers = _filter_drivers(git_path, repo, env)
|
|
726
|
+
if repo_driver is not False:
|
|
727
|
+
return _unchecked(
|
|
728
|
+
repo, group, REASON_GUARD, refused=False, session=session,
|
|
729
|
+
repo_root=toplevel, head=head,
|
|
730
|
+
)
|
|
731
|
+
status_result = _run_git(
|
|
732
|
+
git_path,
|
|
733
|
+
[
|
|
734
|
+
"-C", repo, "--no-optional-locks", "-c", "core.fsmonitor=false",
|
|
735
|
+
*_neutralize_filters(person_drivers), "status",
|
|
736
|
+
"--porcelain=v2", "-z", "--no-renames", "--ignore-submodules=all", "--",
|
|
737
|
+
*[rel for _path, rel in contained],
|
|
738
|
+
],
|
|
739
|
+
env,
|
|
740
|
+
)
|
|
741
|
+
if status_result is None or status_result.returncode != 0:
|
|
742
|
+
return _unchecked(
|
|
743
|
+
repo, group, REASON_GUARD, refused=False, session=session,
|
|
744
|
+
repo_root=toplevel, head=head,
|
|
745
|
+
)
|
|
746
|
+
dirty = _parse_porcelain_v2(status_result.stdout)
|
|
747
|
+
paths: dict[str, PathObservation] = {}
|
|
748
|
+
for path, rel in contained:
|
|
749
|
+
if rel in dirty:
|
|
750
|
+
state = DIRTY_NOW
|
|
751
|
+
else:
|
|
752
|
+
state = CLEAN_NOW if os.path.exists(os.path.join(toplevel, rel)) else MISSING_NOW
|
|
753
|
+
paths[path] = _path_observation(run, members, path, state)
|
|
754
|
+
return GroupObservation(
|
|
755
|
+
repo=repo, refused=False, checked=True, reason=None, repo_root=toplevel, head=head,
|
|
756
|
+
git_version=session.version, paths=paths, unobserved_paths=outside,
|
|
757
|
+
attempts=list(group.attempts),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def observe_groups(
|
|
762
|
+
run: Run, now: datetime | None = None, groups: "ledger.RepoGroups | None" = None,
|
|
763
|
+
) -> RunObservation:
|
|
764
|
+
"""docs/DESIGN.md 4.9 (N5c-1), plan S5 and S6. Read-only. The repository
|
|
765
|
+
groups are partitioned before any process starts (`ledger.group_by_repo`, a
|
|
766
|
+
pure function); a refused group and the unknown group start no process at
|
|
767
|
+
all; a group that is not refused runs the guarded sequence with only its own
|
|
768
|
+
ledger paths. No exception escapes: every failure is an unchecked group with
|
|
769
|
+
a reason from GROUP_REASONS."""
|
|
770
|
+
now = now if now is not None else datetime.now(timezone.utc)
|
|
771
|
+
grouped = groups if groups is not None else ledger.group_by_repo(run)
|
|
772
|
+
ended = run.ended_at
|
|
773
|
+
if ended is not None and ended.tzinfo is None:
|
|
774
|
+
ended = ended.replace(tzinfo=timezone.utc)
|
|
775
|
+
stale = ended is not None and (now - ended) > _STALE_AFTER
|
|
776
|
+
|
|
777
|
+
by_ref: dict[str, Attempt] = {}
|
|
778
|
+
for attempt in ([run.main] if run.main is not None else []) + list(run.attempts):
|
|
779
|
+
by_ref[ledger.attempt_refs(run)[id(attempt)]] = attempt
|
|
780
|
+
session = _GitSession()
|
|
781
|
+
|
|
782
|
+
result: list[GroupObservation] = []
|
|
783
|
+
for repo, group in grouped.repos.items():
|
|
784
|
+
if group.refused:
|
|
785
|
+
result.append(_unchecked(repo, group, REASON_REFUSED, refused=True))
|
|
786
|
+
continue
|
|
787
|
+
members = [by_ref[ref] for ref in group.attempts if ref in by_ref]
|
|
788
|
+
result.append(_observe_named_group(run, repo, group, members, session, stale))
|
|
789
|
+
unknown = grouped.unknown
|
|
790
|
+
if unknown.attempts or unknown.paths:
|
|
791
|
+
result.append(_unchecked(None, unknown, REASON_REFUSED, refused=True))
|
|
792
|
+
return RunObservation(result)
|