autoforge-cli 0.1.0a0__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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/loop_guard.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Loop bounds for the REVIEW <-> FIX cycle (pure, easily tested logic).
|
|
2
|
+
|
|
3
|
+
The engine records one entry per *completed* review round of the current PR
|
|
4
|
+
in ``state.review_history`` (see :func:`review_record`) and asks these
|
|
5
|
+
functions whether the loop must stop:
|
|
6
|
+
|
|
7
|
+
- :func:`round_cap_reason` — the hard cap ``workflow.max_review_rounds``.
|
|
8
|
+
- :func:`stagnation_reason` — consecutive rounds with findings that show no
|
|
9
|
+
progress: identical required resolutions (normalised text) for
|
|
10
|
+
``workflow.stagnation_identical_rounds`` rounds, or an unchanged finding
|
|
11
|
+
count for ``workflow.stagnation_unchanged_count_rounds`` rounds *while at
|
|
12
|
+
least one required resolution recurs inside that window* (the same demand
|
|
13
|
+
keeps coming back, e.g. an A/B/A ping-pong). Rounds whose findings are all
|
|
14
|
+
new are progress, not stagnation, however many of them there are: a picky
|
|
15
|
+
reviewer that raises one fresh finding per round is bounded by the hard
|
|
16
|
+
cap only. A clean or stale round breaks the streak. Both rules compare
|
|
17
|
+
rounds *against each other*, so a window of 1 has no meaning and the
|
|
18
|
+
configuration rejects it: the settings are 0 (disabled) or >= 2.
|
|
19
|
+
- :func:`step_budget_reason` — the cumulative ``workflow.max_total_steps``
|
|
20
|
+
budget, measured on the persisted ``step_count`` so ``resume`` continues
|
|
21
|
+
the same budget instead of starting a new one.
|
|
22
|
+
|
|
23
|
+
:func:`review_record` keeps that history bounded, so it also records when a
|
|
24
|
+
round's findings had to be dropped or clipped; :func:`truncated_evidence_rounds`
|
|
25
|
+
reports those rounds so the replan policy can refuse to discard a PR whose
|
|
26
|
+
findings the controller can no longer reproduce in full.
|
|
27
|
+
|
|
28
|
+
Every function returns a human-readable reason (non-empty -> BLOCKED) or
|
|
29
|
+
``""``. Nothing here reads GitHub or invokes an agent.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import hashlib
|
|
35
|
+
import re
|
|
36
|
+
|
|
37
|
+
from .errors import StateError
|
|
38
|
+
from .redaction import MAX_GROWTH_FACTOR
|
|
39
|
+
from .result_parser import MAX_FINDING_RESOLUTION_CHARS
|
|
40
|
+
|
|
41
|
+
_WS_RE = re.compile(r"\s+")
|
|
42
|
+
# Per-round bounds on the persisted findings. Both are at or above what the
|
|
43
|
+
# parser lets a REVIEW result carry (``result_parser.MAX_FINDINGS_PER_REVIEW``,
|
|
44
|
+
# ``MAX_FINDING_RESOLUTION_CHARS``), so a round the controller accepted is
|
|
45
|
+
# always retained complete and the clipping below only ever applies to state
|
|
46
|
+
# that did not come through the parser. The count bound is an independent
|
|
47
|
+
# number above the parser's; ``tests/test_result_parser.py`` pins that
|
|
48
|
+
# relation. The resolution bound is *computed* from the parser bound and
|
|
49
|
+
# ``redaction.MAX_GROWTH_FACTOR`` (2000 x 3 = 6000) rather than restated: the
|
|
50
|
+
# engine redacts a finding between the parser and this record, and redaction
|
|
51
|
+
# can lengthen a text (a one-character secret becomes a 14-character marker),
|
|
52
|
+
# so a bound equal to the parser's would clip an accepted resolution and mark
|
|
53
|
+
# the round truncated, which refuses every later replan of the PR (#33).
|
|
54
|
+
# Deriving it means neither input can drift away from it unnoticed.
|
|
55
|
+
MAX_PERSISTED_FINDINGS_PER_ROUND = 100
|
|
56
|
+
MAX_REQUIRED_RESOLUTION_CHARS = MAX_FINDING_RESOLUTION_CHARS * MAX_GROWTH_FACTOR
|
|
57
|
+
|
|
58
|
+
# ``result`` values recorded per review round.
|
|
59
|
+
RESULT_NEEDS_FIX = "needs_fix"
|
|
60
|
+
RESULT_CLEAN = "clean"
|
|
61
|
+
RESULT_STALE = "stale"
|
|
62
|
+
RESULTS = (RESULT_NEEDS_FIX, RESULT_CLEAN, RESULT_STALE)
|
|
63
|
+
|
|
64
|
+
# Per-round bound on the persisted recurrence evidence. A review round may
|
|
65
|
+
# return arbitrarily many findings, and ``review_history`` keeps one entry per
|
|
66
|
+
# round for the whole PR, so an unbounded digest list would let a single
|
|
67
|
+
# verbose reviewer grow the state file without limit. A clipped round is
|
|
68
|
+
# marked ``resolutions_truncated`` and can then no longer prove the *absence*
|
|
69
|
+
# of a recurrence (see :func:`_recurring_resolutions`).
|
|
70
|
+
MAX_PERSISTED_RESOLUTION_DIGESTS = 50
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def normalize_resolution(text: object) -> str:
|
|
74
|
+
"""Whitespace-collapsed, lower-cased ``required_resolution`` text."""
|
|
75
|
+
return _WS_RE.sub(" ", str(text or "")).strip().lower()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def findings_fingerprint(findings: list[dict]) -> str:
|
|
79
|
+
"""Order-independent digest of the findings' normalised required resolutions.
|
|
80
|
+
|
|
81
|
+
Finding IDs (``R1-F1``, ``R2-F1``) change every round and are ignored;
|
|
82
|
+
two rounds fingerprint the same when they request the same resolutions.
|
|
83
|
+
"""
|
|
84
|
+
texts = sorted(normalize_resolution(f.get("required_resolution")) for f in findings)
|
|
85
|
+
digest = hashlib.sha256("\n".join(texts).encode("utf-8")).hexdigest()
|
|
86
|
+
return digest[:16]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def resolution_digests(findings: list[dict]) -> list[str]:
|
|
90
|
+
"""Sorted per-finding digests of the normalised required resolutions.
|
|
91
|
+
|
|
92
|
+
Unlike :func:`findings_fingerprint` (one digest per round) this keeps one
|
|
93
|
+
digest per finding, so a later round can be checked for *recurring*
|
|
94
|
+
demands without persisting the review text itself.
|
|
95
|
+
|
|
96
|
+
A resolution that normalises to the empty string demands nothing, so it
|
|
97
|
+
is not recurrence evidence and gets no digest: otherwise two rounds that
|
|
98
|
+
each carry a blank ``required_resolution`` would look like the same
|
|
99
|
+
demand coming back.
|
|
100
|
+
"""
|
|
101
|
+
digests = {
|
|
102
|
+
hashlib.sha256(text.encode("utf-8")).hexdigest()[:16]
|
|
103
|
+
for text in (normalize_resolution(f.get("required_resolution")) for f in findings)
|
|
104
|
+
if text
|
|
105
|
+
}
|
|
106
|
+
return sorted(digests)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _retained_finding(finding: dict) -> tuple[dict, bool]:
|
|
110
|
+
"""The persisted summary of ``finding`` plus whether its text was clipped.
|
|
111
|
+
|
|
112
|
+
Only ``id``, ``classification`` and ``required_resolution`` are kept. A
|
|
113
|
+
finding's ``title``, ``location`` and any other field the reviewer
|
|
114
|
+
supplied are dropped *by design* and that is not truncation: the
|
|
115
|
+
persisted evidence exists so a replan can show the replacement agent
|
|
116
|
+
every demand the reviews made, and the demand is the
|
|
117
|
+
``required_resolution`` text. So "complete" in
|
|
118
|
+
:func:`round_evidence_is_complete` means *complete resolutions* -- every
|
|
119
|
+
finding retained and none of their resolution texts clipped -- never a
|
|
120
|
+
verbatim copy of the review comment, which stays on GitHub.
|
|
121
|
+
"""
|
|
122
|
+
text = str(finding.get("required_resolution", ""))
|
|
123
|
+
clipped = text[:MAX_REQUIRED_RESOLUTION_CHARS]
|
|
124
|
+
return (
|
|
125
|
+
{
|
|
126
|
+
"id": str(finding.get("id", "")),
|
|
127
|
+
"classification": str(finding.get("classification", "")),
|
|
128
|
+
"required_resolution": clipped,
|
|
129
|
+
},
|
|
130
|
+
len(clipped) != len(text),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def round_evidence_is_complete(record: dict) -> bool:
|
|
135
|
+
"""True when ``record`` holds a complete copy of its round's findings.
|
|
136
|
+
|
|
137
|
+
"Complete" means complete *resolutions*: every finding of the round is
|
|
138
|
+
retained and no ``required_resolution`` text was clipped. The retained
|
|
139
|
+
summary never carries a finding's ``title`` or ``location`` (see
|
|
140
|
+
:func:`_retained_finding`), and their absence is not truncation.
|
|
141
|
+
|
|
142
|
+
Only rounds that ended with findings carry evidence a replacement must
|
|
143
|
+
consider; clean and stale rounds are trivially complete. The length
|
|
144
|
+
comparison is a second, independent check on the ``evidence_truncated``
|
|
145
|
+
marker (it also covers history written before that marker existed).
|
|
146
|
+
"""
|
|
147
|
+
if record.get("result") != RESULT_NEEDS_FIX:
|
|
148
|
+
return True
|
|
149
|
+
if record.get("evidence_truncated"):
|
|
150
|
+
return False
|
|
151
|
+
try:
|
|
152
|
+
expected = int(record.get("finding_count", 0) or 0)
|
|
153
|
+
except (TypeError, ValueError):
|
|
154
|
+
return False
|
|
155
|
+
return len(record.get("findings") or []) == expected
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def truncated_evidence_rounds(history: list[dict]) -> list[int]:
|
|
159
|
+
"""Rounds whose persisted findings are an incomplete copy of the review.
|
|
160
|
+
|
|
161
|
+
Replacing a PR deletes the controller's only record of its findings, so a
|
|
162
|
+
replan is refused while any of that record is missing: no acknowledgement
|
|
163
|
+
the replacement agent can make covers a finding it was never shown.
|
|
164
|
+
"""
|
|
165
|
+
return [
|
|
166
|
+
int(record.get("round", 0) or 0)
|
|
167
|
+
for record in history
|
|
168
|
+
if not round_evidence_is_complete(record)
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def review_record(
|
|
173
|
+
round: int,
|
|
174
|
+
reviewed_head_sha: str,
|
|
175
|
+
result: str,
|
|
176
|
+
findings: list[dict],
|
|
177
|
+
review_comment_url: str = "",
|
|
178
|
+
timestamp: str = "",
|
|
179
|
+
) -> dict:
|
|
180
|
+
"""One ``review_history`` entry (plain dict: it is persisted as JSON).
|
|
181
|
+
|
|
182
|
+
The digest list is clipped to ``MAX_PERSISTED_RESOLUTION_DIGESTS``; a
|
|
183
|
+
clipped entry carries ``resolutions_truncated: True`` so the stagnation
|
|
184
|
+
rule knows its recurrence evidence is incomplete.
|
|
185
|
+
"""
|
|
186
|
+
if result not in RESULTS:
|
|
187
|
+
raise ValueError(f"unknown review result {result!r}")
|
|
188
|
+
digests = resolution_digests(findings)
|
|
189
|
+
record = {
|
|
190
|
+
"round": int(round),
|
|
191
|
+
"reviewed_head_sha": reviewed_head_sha,
|
|
192
|
+
"result": result,
|
|
193
|
+
"finding_count": len(findings),
|
|
194
|
+
"fingerprint": findings_fingerprint(findings),
|
|
195
|
+
"resolutions": digests[:MAX_PERSISTED_RESOLUTION_DIGESTS],
|
|
196
|
+
"resolutions_truncated": len(digests) > MAX_PERSISTED_RESOLUTION_DIGESTS,
|
|
197
|
+
}
|
|
198
|
+
# Keep compact, bounded evidence. GitHub remains authoritative for comment
|
|
199
|
+
# bodies and the full finding list if this history is truncated. Whenever
|
|
200
|
+
# anything was dropped or clipped the round is marked explicitly, because a
|
|
201
|
+
# replan discards the PR that carries the untruncated originals — see
|
|
202
|
+
# :func:`truncated_evidence_rounds`.
|
|
203
|
+
if findings:
|
|
204
|
+
retained = [_retained_finding(f) for f in findings[:MAX_PERSISTED_FINDINGS_PER_ROUND]]
|
|
205
|
+
record["findings"] = [item for item, _ in retained]
|
|
206
|
+
if len(findings) > MAX_PERSISTED_FINDINGS_PER_ROUND or any(cut for _, cut in retained):
|
|
207
|
+
record["evidence_truncated"] = True
|
|
208
|
+
if review_comment_url:
|
|
209
|
+
record["review_comment_url"] = review_comment_url
|
|
210
|
+
if timestamp:
|
|
211
|
+
record["timestamp"] = timestamp
|
|
212
|
+
return record
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def validate_review_history(history: object) -> None:
|
|
216
|
+
"""Raise :class:`StateError` unless ``history`` is a list of valid entries.
|
|
217
|
+
|
|
218
|
+
Persisted ``review_history`` drives terminal decisions (BLOCKED) and, via
|
|
219
|
+
:func:`round_evidence_is_complete`, the destructive close of a replan, so
|
|
220
|
+
a malformed entry must fail loudly instead of being reinterpreted. Only a
|
|
221
|
+
*missing* optional key (``resolutions`` on an entry written before
|
|
222
|
+
per-finding digests existed, ``findings`` on a clean round) is
|
|
223
|
+
compatibility; a present field is validated like any other.
|
|
224
|
+
"""
|
|
225
|
+
if not isinstance(history, list):
|
|
226
|
+
raise StateError("'review_history' must be a list")
|
|
227
|
+
for i, entry in enumerate(history):
|
|
228
|
+
where = f"'review_history[{i}]'"
|
|
229
|
+
if not isinstance(entry, dict):
|
|
230
|
+
raise StateError(f"{where} must be an object")
|
|
231
|
+
for key, typ in (("round", int), ("finding_count", int)):
|
|
232
|
+
value = entry.get(key)
|
|
233
|
+
if not isinstance(value, typ) or isinstance(value, bool):
|
|
234
|
+
raise StateError(f"{where}.{key} must be an integer, got {value!r}")
|
|
235
|
+
for key in ("reviewed_head_sha", "fingerprint"):
|
|
236
|
+
if not isinstance(entry.get(key), str):
|
|
237
|
+
raise StateError(f"{where}.{key} must be a string, got {entry.get(key)!r}")
|
|
238
|
+
if entry.get("result") not in RESULTS:
|
|
239
|
+
raise StateError(
|
|
240
|
+
f"{where}.result must be one of {RESULTS}, got {entry.get('result')!r}"
|
|
241
|
+
)
|
|
242
|
+
if "resolutions" in entry:
|
|
243
|
+
digests = entry["resolutions"]
|
|
244
|
+
if not isinstance(digests, list):
|
|
245
|
+
raise StateError(
|
|
246
|
+
f"{where}.resolutions must be a list of digest strings, got {digests!r}"
|
|
247
|
+
)
|
|
248
|
+
for d in digests:
|
|
249
|
+
if not isinstance(d, str) or not d.strip():
|
|
250
|
+
raise StateError(
|
|
251
|
+
f"{where}.resolutions must contain non-empty digest strings, got {d!r}"
|
|
252
|
+
)
|
|
253
|
+
if "resolutions_truncated" in entry and not isinstance(
|
|
254
|
+
entry["resolutions_truncated"], bool
|
|
255
|
+
):
|
|
256
|
+
raise StateError(
|
|
257
|
+
f"{where}.resolutions_truncated must be a boolean, "
|
|
258
|
+
f"got {entry['resolutions_truncated']!r}"
|
|
259
|
+
)
|
|
260
|
+
if "findings" in entry:
|
|
261
|
+
retained = entry["findings"]
|
|
262
|
+
if not isinstance(retained, list) or not all(isinstance(f, dict) for f in retained):
|
|
263
|
+
raise StateError(f"{where}.findings must be a list of objects, got {retained!r}")
|
|
264
|
+
if "evidence_truncated" in entry and not isinstance(entry["evidence_truncated"], bool):
|
|
265
|
+
raise StateError(
|
|
266
|
+
f"{where}.evidence_truncated must be a boolean, got {entry['evidence_truncated']!r}"
|
|
267
|
+
)
|
|
268
|
+
for key in ("review_comment_url", "timestamp"):
|
|
269
|
+
if key in entry and not isinstance(entry[key], str):
|
|
270
|
+
raise StateError(f"{where}.{key} must be a string, got {entry[key]!r}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def round_cap_reason(review_round: int, max_review_rounds: int, *, has_findings: bool) -> str:
|
|
274
|
+
"""Reason when review round ``review_round`` (just completed) hits the cap.
|
|
275
|
+
|
|
276
|
+
A round *at* the cap that still has findings must not start another FIX
|
|
277
|
+
(its result could never be reviewed); a round *past* the cap must never
|
|
278
|
+
have run at all. A clean round at the cap is fine (``""``).
|
|
279
|
+
"""
|
|
280
|
+
if review_round > max_review_rounds:
|
|
281
|
+
return f"review round {review_round} exceeds workflow.max_review_rounds={max_review_rounds}"
|
|
282
|
+
if has_findings and review_round >= max_review_rounds:
|
|
283
|
+
return (
|
|
284
|
+
f"review round {review_round} still has findings and workflow.max_review_rounds="
|
|
285
|
+
f"{max_review_rounds} is reached; no further FIX round is started"
|
|
286
|
+
)
|
|
287
|
+
return ""
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def next_round_cap_reason(completed_rounds: int, max_review_rounds: int) -> str:
|
|
291
|
+
"""Reason when review round ``completed_rounds + 1`` must not start."""
|
|
292
|
+
if completed_rounds >= max_review_rounds:
|
|
293
|
+
return (
|
|
294
|
+
f"{completed_rounds} review round(s) completed for this PR and "
|
|
295
|
+
f"workflow.max_review_rounds={max_review_rounds} is reached; review round "
|
|
296
|
+
f"{completed_rounds + 1} is not started"
|
|
297
|
+
)
|
|
298
|
+
return ""
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _trailing_needs_fix(history: list[dict], window: int) -> list[dict]:
|
|
302
|
+
"""The last ``window`` entries when all of them are needs_fix rounds, else []."""
|
|
303
|
+
if window < 1 or len(history) < window:
|
|
304
|
+
return []
|
|
305
|
+
tail = history[-window:]
|
|
306
|
+
if any(r.get("result") != RESULT_NEEDS_FIX for r in tail):
|
|
307
|
+
return []
|
|
308
|
+
return tail
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _recurring_resolutions(tail: list[dict]) -> tuple[set[str], str]:
|
|
312
|
+
"""``(digests requested by more than one round, incomplete-evidence reason)``.
|
|
313
|
+
|
|
314
|
+
The reason is ``""`` when every entry of the window carries its complete
|
|
315
|
+
per-finding digests. It is non-empty when an entry predates the
|
|
316
|
+
``resolutions`` field (a run persisted by an older controller) or was
|
|
317
|
+
clipped by :data:`MAX_PERSISTED_RESOLUTION_DIGESTS`: such a window can
|
|
318
|
+
still *prove* a recurrence, but it can never prove there was none, so the
|
|
319
|
+
count-only behaviour applies. Malformed entries raise (see
|
|
320
|
+
:func:`validate_review_history`) rather than being read as either.
|
|
321
|
+
"""
|
|
322
|
+
validate_review_history(tail)
|
|
323
|
+
seen: set[str] = set()
|
|
324
|
+
recurring: set[str] = set()
|
|
325
|
+
incomplete = ""
|
|
326
|
+
for r in tail:
|
|
327
|
+
if "resolutions" not in r:
|
|
328
|
+
incomplete = incomplete or (
|
|
329
|
+
f"round {r.get('round')} was written by an older controller and has no "
|
|
330
|
+
"per-finding resolution digests, so recurrence cannot be ruled out"
|
|
331
|
+
)
|
|
332
|
+
continue
|
|
333
|
+
if r.get("resolutions_truncated"):
|
|
334
|
+
incomplete = (
|
|
335
|
+
f"round {r.get('round')} persisted only the first "
|
|
336
|
+
f"{MAX_PERSISTED_RESOLUTION_DIGESTS} resolution digest(s) of "
|
|
337
|
+
f"{r.get('finding_count')} finding(s), so recurrence cannot be ruled out"
|
|
338
|
+
)
|
|
339
|
+
digests = set(r["resolutions"])
|
|
340
|
+
recurring |= seen & digests
|
|
341
|
+
seen |= digests
|
|
342
|
+
return recurring, incomplete
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def stagnation_reason(
|
|
346
|
+
history: list[dict], identical_rounds: int, unchanged_count_rounds: int
|
|
347
|
+
) -> str:
|
|
348
|
+
"""Reason when the trailing review rounds show no progress; ``""`` otherwise.
|
|
349
|
+
|
|
350
|
+
``identical_rounds`` / ``unchanged_count_rounds`` of 0 disable the
|
|
351
|
+
respective rule. Only *consecutive* rounds that ended with findings are
|
|
352
|
+
considered: a clean or stale round in between resets both rules.
|
|
353
|
+
|
|
354
|
+
The unchanged-count rule needs, on top of the constant count, at least
|
|
355
|
+
one required resolution that was requested by two different rounds of
|
|
356
|
+
the window: the count alone cannot tell an A/B/A ping-pong from a
|
|
357
|
+
reviewer that raises one genuinely new finding per round (every earlier
|
|
358
|
+
finding was resolved), and the latter is progress bounded by
|
|
359
|
+
``workflow.max_review_rounds``. A window whose recurrence evidence is
|
|
360
|
+
incomplete — an entry written before the per-finding digests existed, or
|
|
361
|
+
one clipped by :data:`MAX_PERSISTED_RESOLUTION_DIGESTS` — cannot rule a
|
|
362
|
+
recurrence out and keeps the count-only behaviour; the reason says which.
|
|
363
|
+
|
|
364
|
+
Both windows are 0 (disabled) or >= 2: a window of 1 compares a round
|
|
365
|
+
with nothing and is rejected by the configuration loader.
|
|
366
|
+
|
|
367
|
+
Raises :class:`StateError` when ``history`` holds a malformed entry.
|
|
368
|
+
"""
|
|
369
|
+
validate_review_history(history)
|
|
370
|
+
tail = _trailing_needs_fix(history, identical_rounds)
|
|
371
|
+
if tail and len({r.get("fingerprint") for r in tail}) == 1:
|
|
372
|
+
rounds = ", ".join(str(r.get("round")) for r in tail)
|
|
373
|
+
return (
|
|
374
|
+
f"review rounds {rounds} requested identical resolutions "
|
|
375
|
+
f"({tail[-1].get('finding_count')} finding(s) each); the FIX rounds between them "
|
|
376
|
+
f"changed nothing the reviewer accepts (workflow.stagnation_identical_rounds="
|
|
377
|
+
f"{identical_rounds})"
|
|
378
|
+
)
|
|
379
|
+
tail = _trailing_needs_fix(history, unchanged_count_rounds)
|
|
380
|
+
if tail and len({r.get("finding_count") for r in tail}) == 1:
|
|
381
|
+
recurring, incomplete = _recurring_resolutions(tail)
|
|
382
|
+
detail = (
|
|
383
|
+
f"{len(recurring)} required resolution(s) recur across them"
|
|
384
|
+
if recurring
|
|
385
|
+
else incomplete
|
|
386
|
+
)
|
|
387
|
+
if detail:
|
|
388
|
+
rounds = ", ".join(str(r.get("round")) for r in tail)
|
|
389
|
+
return (
|
|
390
|
+
f"review rounds {rounds} each ended with {tail[-1].get('finding_count')} "
|
|
391
|
+
f"finding(s); the finding count has not changed and {detail} "
|
|
392
|
+
f"(workflow.stagnation_unchanged_count_rounds={unchanged_count_rounds})"
|
|
393
|
+
)
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def step_budget_reason(step_count: int, max_total_steps: int) -> str:
|
|
398
|
+
"""Reason when the run's cumulative step budget is exhausted."""
|
|
399
|
+
if step_count >= max_total_steps:
|
|
400
|
+
return (
|
|
401
|
+
f"{step_count} step(s) executed in this run and workflow.max_total_steps="
|
|
402
|
+
f"{max_total_steps} is reached; no further step is executed"
|
|
403
|
+
)
|
|
404
|
+
return ""
|