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/replan_txn.py
ADDED
|
@@ -0,0 +1,1930 @@
|
|
|
1
|
+
"""The REPLAN_REEXECUTE transaction: typed durable state and pure verifiers.
|
|
2
|
+
|
|
3
|
+
REPLAN_REEXECUTE is the only phase in which the controller performs a
|
|
4
|
+
*destructive* GitHub write on work an agent produced: it closes the PR that
|
|
5
|
+
holds the accumulated review findings and activates a replacement in its
|
|
6
|
+
place. Nothing about that write is recoverable by re-running it, so the phase
|
|
7
|
+
is modelled as one explicit transaction rather than as a sequence of
|
|
8
|
+
independent checks.
|
|
9
|
+
|
|
10
|
+
Everything here is pure. GitHub I/O and state persistence live in the engine;
|
|
11
|
+
this module owns the *decisions*, so the normal path and the crash-recovery
|
|
12
|
+
path cannot drift apart by construction — they call the same predicates on
|
|
13
|
+
the same persisted intent.
|
|
14
|
+
|
|
15
|
+
Lifecycle (monotonic; ``REJECTED`` is terminal)::
|
|
16
|
+
|
|
17
|
+
PENDING ──► PREPARED ──► VERIFIED ──► SUPERSEDE_INTENT ──► SUPERSEDED ──► (activated)
|
|
18
|
+
│ │ │ │ │
|
|
19
|
+
│ │ │ ▼ │
|
|
20
|
+
│ │ │ COMPENSATING │
|
|
21
|
+
│ │ │ │ │
|
|
22
|
+
└───────────┴─────────────┴────────────────┴────────────────┴──► REJECTED
|
|
23
|
+
|
|
24
|
+
``COMPENSATING`` is entered when the close landed on facts that had already
|
|
25
|
+
moved; it is persisted *before* the reopen, so a crash after a successful
|
|
26
|
+
reopen resumes into "finish undoing", never into "close again".
|
|
27
|
+
|
|
28
|
+
``SUPERSEDE_INTENT`` is written *before* ``gh pr close``, together with the
|
|
29
|
+
source PR's ``closed`` issue-event count. Issue events are append-only, so a
|
|
30
|
+
resume that finds the source still OPEN can prove from that count whether any
|
|
31
|
+
close landed after the intent: none did, and the close is retried once
|
|
32
|
+
(:func:`verify_close_never_ran`); one did, and the reopen that followed is a
|
|
33
|
+
human decision the controller never overrides.
|
|
34
|
+
|
|
35
|
+
``PREPARED`` is written *before* the agent is invoked, and carries the
|
|
36
|
+
transaction id. Because that id cannot exist anywhere before it is persisted,
|
|
37
|
+
"crashed before invoking the agent" and "crashed while the agent ran" are the
|
|
38
|
+
same recoverable state: either a PR carrying the id exists, or none does.
|
|
39
|
+
|
|
40
|
+
Causal provenance
|
|
41
|
+
-----------------
|
|
42
|
+
A replacement belongs to a transaction only if its PR body carries the
|
|
43
|
+
controller-generated marker for *that* transaction id:
|
|
44
|
+
|
|
45
|
+
.. code-block:: text
|
|
46
|
+
|
|
47
|
+
<!-- autoforge-replan-transaction: {"transaction_id": "...", ...} -->
|
|
48
|
+
|
|
49
|
+
The id is random, generated by the controller and persisted before
|
|
50
|
+
invocation; the controller renders the marker itself on the replacement PR
|
|
51
|
+
it creates (#164), and reads it back from GitHub. Shape alone
|
|
52
|
+
("an open PR linked to the issue", "the only other PR") is never proof:
|
|
53
|
+
:func:`select_bound_candidate` ignores every unmarked PR and every PR that
|
|
54
|
+
already existed when the transaction was prepared. The marker is also
|
|
55
|
+
re-checked on the last read before the destructive write
|
|
56
|
+
(:func:`verify_target_marker`), because a body can be edited after the
|
|
57
|
+
controller verified it.
|
|
58
|
+
|
|
59
|
+
The same marker carries the agent's *attestation* — how many historical
|
|
60
|
+
findings it accounted for, and whether its own tests passed — rendered by the
|
|
61
|
+
controller from the validated CONTROL_RESULT. Putting the attestation on
|
|
62
|
+
GitHub rather than only in the CONTROL_RESULT is what lets recovery enforce
|
|
63
|
+
exactly the same acceptance rules as the normal path: after a crash there is
|
|
64
|
+
no CONTROL_RESULT to consult, but the marker is still there.
|
|
65
|
+
|
|
66
|
+
Publication (#164)
|
|
67
|
+
------------------
|
|
68
|
+
The agent commits on its worktree's detached ``HEAD`` and publishes nothing.
|
|
69
|
+
The controller pushes the candidate to a branch it derives from the issue and
|
|
70
|
+
the transaction id (:func:`replacement_branch_for`, compared against
|
|
71
|
+
"absent"), then creates the replacement PR, as two effect records (ADR 0004
|
|
72
|
+
K1, K7) owned by the transaction id and journaled before either is sent. A
|
|
73
|
+
journaled plan is completed before anything else, so a crash after the push
|
|
74
|
+
or the create resumes the effects, never the agent; binding then requires
|
|
75
|
+
the PR's HEAD to be the journaled candidate.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import json
|
|
81
|
+
import re
|
|
82
|
+
import secrets
|
|
83
|
+
from collections.abc import Callable, Iterable
|
|
84
|
+
from dataclasses import asdict, dataclass, field, fields
|
|
85
|
+
from enum import StrEnum
|
|
86
|
+
|
|
87
|
+
from .claims import IMPLEMENTATION, collect, scan
|
|
88
|
+
from .errors import ClaimConflictError, ConfigurationError
|
|
89
|
+
from .github import PRInfo
|
|
90
|
+
from .validation import GitHubPullRequestRef, parse_issue_url, parse_pr_url, same_pr_url
|
|
91
|
+
|
|
92
|
+
MARKER_NAME = "autoforge-replan-transaction"
|
|
93
|
+
# Matches *every* complete comment bearing this name, whatever the payload
|
|
94
|
+
# looks like -- the payload is then classified, and anything that is not a
|
|
95
|
+
# usable attestation is reported as malformed rather than passed over. A
|
|
96
|
+
# pattern that only recognised well-formed payloads would silently downgrade
|
|
97
|
+
# "this PR carries a broken attestation" (a conclusive refusal) to "this PR
|
|
98
|
+
# carries no attestation", which is exactly the ambiguity the phase must fail
|
|
99
|
+
# closed on. The payload is therefore *any* text, angle brackets included.
|
|
100
|
+
#
|
|
101
|
+
# The one thing it may not contain is a comment delimiter. Tempering it with
|
|
102
|
+
# `(?!-->|<!--)` stops a marker whose own `-->` is missing from swallowing the
|
|
103
|
+
# body up to some later `-->` and hiding a valid marker inside the match: an
|
|
104
|
+
# unterminated marker is not a complete comment and is evidence of nothing,
|
|
105
|
+
# while every complete one is classified.
|
|
106
|
+
#
|
|
107
|
+
# The pattern is linear in the body it scans (see ``claims.MarkerKind.pattern``
|
|
108
|
+
# for the rule): possessive whitespace runs and no whitespace quantifier next
|
|
109
|
+
# to the lazy payload, which :func:`scan_replan_markers` strips instead.
|
|
110
|
+
MARKER_RE = re.compile(rf"<!--\s*+{MARKER_NAME}\s*+:(?P<payload>(?:(?!-->|<!--)[\s\S])*?)-->")
|
|
111
|
+
|
|
112
|
+
# The controller's receipt for its own destructive close, posted with a
|
|
113
|
+
# separate `gh pr comment` after the close is observed (see
|
|
114
|
+
# :func:`render_close_receipt`) -- never inside `gh pr close --comment`,
|
|
115
|
+
# whose comment predates the close.
|
|
116
|
+
CLOSE_RECEIPT_NAME = "autoforge-replan-close"
|
|
117
|
+
CLOSE_RECEIPT_RE = re.compile(rf"<!--\s*{CLOSE_RECEIPT_NAME}\s*:\s*(?P<txn>[0-9a-f]{{32}})\s*-->")
|
|
118
|
+
|
|
119
|
+
TRANSACTION_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def new_transaction_id() -> str:
|
|
123
|
+
"""A fresh, unguessable transaction id (controller-generated, never agent)."""
|
|
124
|
+
return secrets.token_hex(16)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def replacement_branch_for(issue_number: int, transaction_id: str) -> str:
|
|
128
|
+
"""The branch the controller pushes the replacement to (#164).
|
|
129
|
+
|
|
130
|
+
Derived from controller state alone, never chosen by the agent: the
|
|
131
|
+
issue and the transaction id, which is random and persisted before the
|
|
132
|
+
agent runs. So the name differs from the source branch (``autoforge/<n>``
|
|
133
|
+
or whatever the source PR was headed at, never carrying this id) and from
|
|
134
|
+
every earlier replan's branch, and the push compares it against "absent".
|
|
135
|
+
"""
|
|
136
|
+
if issue_number < 1 or not TRANSACTION_ID_RE.match(transaction_id):
|
|
137
|
+
raise ValueError("a replacement branch needs the issue number and the transaction id")
|
|
138
|
+
return f"autoforge/{issue_number}-replan-{transaction_id}"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render_marker(attestation: ReplanAttestation) -> str:
|
|
142
|
+
"""The exact marker a replacement PR body must contain (used in the prompt)."""
|
|
143
|
+
payload = json.dumps(asdict(attestation), sort_keys=True)
|
|
144
|
+
return f"<!-- {MARKER_NAME}: {payload} -->"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def render_close_receipt(transaction_id: str) -> str:
|
|
148
|
+
"""The receipt the controller publishes *after* it closes the source PR.
|
|
149
|
+
|
|
150
|
+
``SUPERSEDE_INTENT`` is persisted before ``gh pr close`` runs, so on its
|
|
151
|
+
own it proves an intended write, never a performed one: after a crash in
|
|
152
|
+
that window, a source a human closed and one this transaction closed look
|
|
153
|
+
identical from the journal. The receipt is the missing half. It is posted
|
|
154
|
+
with a separate ``gh pr comment`` only after the controller observed its
|
|
155
|
+
own close landing -- never inside ``gh pr close --comment``, whose comment
|
|
156
|
+
predates the close and could therefore be present even when the close never
|
|
157
|
+
landed. So a source found CLOSED *carrying* the receipt for this
|
|
158
|
+
transaction was closed by this transaction, and one found CLOSED without it
|
|
159
|
+
cannot be attributed to it -- a human may have closed it, or the controller
|
|
160
|
+
may have crashed between its close and the receipt -- which is a refusal,
|
|
161
|
+
not an adoption. The same gap is why the receipt alone never licenses a
|
|
162
|
+
resume to close a source found OPEN under a recorded intent: without it,
|
|
163
|
+
"the close never ran" and "it landed, lost its receipt to a crash, and a
|
|
164
|
+
human reopened the PR" are the same evidence. What tells them apart is the
|
|
165
|
+
source's ``closed`` issue-event count, recorded with the intent and
|
|
166
|
+
compared by :func:`verify_close_never_ran` (#69).
|
|
167
|
+
|
|
168
|
+
This is crash-recovery attribution, not authentication: the transaction id
|
|
169
|
+
is published in the replacement PR body, so a human who wanted to could
|
|
170
|
+
copy it into a comment. It is trusted against crashes and concurrent
|
|
171
|
+
humans, not against a forger.
|
|
172
|
+
"""
|
|
173
|
+
return f"<!-- {CLOSE_RECEIPT_NAME}: {transaction_id} -->"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def has_close_receipt(comment_bodies: Iterable[str], transaction_id: str) -> bool:
|
|
177
|
+
"""True when some comment carries this transaction's close receipt."""
|
|
178
|
+
if not TRANSACTION_ID_RE.match(transaction_id or ""):
|
|
179
|
+
return False
|
|
180
|
+
return any(
|
|
181
|
+
match.group("txn") == transaction_id
|
|
182
|
+
for body in comment_bodies
|
|
183
|
+
for match in CLOSE_RECEIPT_RE.finditer(body or "")
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
#: How many times the controller may call ``gh pr close`` on one source PR
|
|
188
|
+
#: under one transaction: the write itself, and one retry from a resume that
|
|
189
|
+
#: proved the write never landed (#69). Every retry is safe on the evidence,
|
|
190
|
+
#: but a close that keeps failing to land is a fault a human should look at,
|
|
191
|
+
#: not one to keep retrying; the bound is what "retries exactly once" means.
|
|
192
|
+
MAX_CLOSE_ATTEMPTS = 2
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class ReplanStage(StrEnum):
|
|
196
|
+
"""Monotonic transaction stages. ``REJECTED`` is terminal for the run."""
|
|
197
|
+
|
|
198
|
+
# Recorded by REVIEW when it routes to REPLAN_REEXECUTE: the decision to
|
|
199
|
+
# replan, before the controller has checkpointed anything. It carries the
|
|
200
|
+
# escalation metadata and no transaction id.
|
|
201
|
+
PENDING = "pending"
|
|
202
|
+
PREPARED = "prepared"
|
|
203
|
+
VERIFIED = "verified"
|
|
204
|
+
SUPERSEDE_INTENT = "supersede_intent"
|
|
205
|
+
# The controller closed the source, then found a checkpoint had moved
|
|
206
|
+
# inside the close window. Persisted *before* `gh pr reopen` so that the
|
|
207
|
+
# decision to undo survives a crash: from here the only outcomes are a
|
|
208
|
+
# confirmed reopen or a block naming the manual step. Never a second close.
|
|
209
|
+
COMPENSATING = "compensating"
|
|
210
|
+
SUPERSEDED = "superseded"
|
|
211
|
+
REJECTED = "rejected"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class Disposition(StrEnum):
|
|
215
|
+
"""How conclusive an observation is — the distinction recovery turns on.
|
|
216
|
+
|
|
217
|
+
``UNKNOWN`` (a transient GitHub failure) must stay resumable; only
|
|
218
|
+
``REJECTED`` and ``AMBIGUOUS`` are terminal, and they are persisted so a
|
|
219
|
+
later resume replays the decision instead of recomputing it.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
OK = "ok"
|
|
223
|
+
NONE = "none" # no candidate yet; the agent has not (verifiably) produced one
|
|
224
|
+
AMBIGUOUS = "ambiguous" # more than one candidate claims this transaction
|
|
225
|
+
REJECTED = "rejected" # conclusively unacceptable
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True)
|
|
229
|
+
class ReplanAttestation:
|
|
230
|
+
"""The agent's machine-readable claim, published on the replacement PR."""
|
|
231
|
+
|
|
232
|
+
transaction_id: str
|
|
233
|
+
execution_attempt: int
|
|
234
|
+
findings_considered: int
|
|
235
|
+
unique_constraints: int
|
|
236
|
+
tests_passed: bool
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@dataclass(frozen=True)
|
|
240
|
+
class MarkerScan:
|
|
241
|
+
attestations: list[ReplanAttestation]
|
|
242
|
+
malformed: list[str] # human-readable reasons, one per unusable marker
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _attestation_from_payload(payload: dict) -> ReplanAttestation:
|
|
246
|
+
"""Strictly typed marker payload; raises ValueError with a usable reason."""
|
|
247
|
+
txn_id = payload.get("transaction_id")
|
|
248
|
+
if not isinstance(txn_id, str) or not TRANSACTION_ID_RE.match(txn_id):
|
|
249
|
+
raise ValueError("transaction_id must be 32 lowercase hex characters")
|
|
250
|
+
ints: dict[str, int] = {}
|
|
251
|
+
for key in ("execution_attempt", "findings_considered", "unique_constraints"):
|
|
252
|
+
value = payload.get(key)
|
|
253
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
254
|
+
raise ValueError(f"{key} must be an integer >= 0")
|
|
255
|
+
ints[key] = value
|
|
256
|
+
tests_passed = payload.get("tests_passed")
|
|
257
|
+
if not isinstance(tests_passed, bool):
|
|
258
|
+
raise ValueError("tests_passed must be a boolean")
|
|
259
|
+
return ReplanAttestation(
|
|
260
|
+
transaction_id=txn_id,
|
|
261
|
+
execution_attempt=ints["execution_attempt"],
|
|
262
|
+
findings_considered=ints["findings_considered"],
|
|
263
|
+
unique_constraints=ints["unique_constraints"],
|
|
264
|
+
tests_passed=tests_passed,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def scan_replan_markers(body: str) -> MarkerScan:
|
|
269
|
+
"""Every replan marker in ``body``, split into usable and malformed.
|
|
270
|
+
|
|
271
|
+
A malformed marker is reported rather than skipped: a PR body that tries
|
|
272
|
+
to carry an attestation and fails is evidence of a broken agent, not of an
|
|
273
|
+
unrelated PR, and the caller fails closed on it. "Malformed" therefore
|
|
274
|
+
covers every complete marker whose payload is not a usable attestation --
|
|
275
|
+
unparsable text, a JSON scalar or array, and a JSON object with bad
|
|
276
|
+
fields alike.
|
|
277
|
+
"""
|
|
278
|
+
attestations: list[ReplanAttestation] = []
|
|
279
|
+
malformed: list[str] = []
|
|
280
|
+
for match in MARKER_RE.finditer(body or ""):
|
|
281
|
+
raw = match.group("payload").strip()
|
|
282
|
+
try:
|
|
283
|
+
payload = json.loads(raw)
|
|
284
|
+
except ValueError as exc:
|
|
285
|
+
malformed.append(f"marker payload is not valid JSON ({exc})")
|
|
286
|
+
continue
|
|
287
|
+
if not isinstance(payload, dict):
|
|
288
|
+
malformed.append("marker payload must be a JSON object")
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
attestations.append(_attestation_from_payload(payload))
|
|
292
|
+
except ValueError as exc:
|
|
293
|
+
malformed.append(f"marker payload is invalid ({exc})")
|
|
294
|
+
return MarkerScan(attestations, malformed)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def sole_attestation(
|
|
298
|
+
scan: MarkerScan, transaction_id: str, ref: str, label: str = "PR"
|
|
299
|
+
) -> tuple[ReplanAttestation | None, str]:
|
|
300
|
+
"""The one attestation ``ref`` publishes for ``transaction_id``, or a defect.
|
|
301
|
+
|
|
302
|
+
``(None, "")`` means the body claims nothing for this transaction -- not an
|
|
303
|
+
error, just a PR that is none of this transaction's business. A non-empty
|
|
304
|
+
defect is *conclusive*: the body carries marker evidence for this
|
|
305
|
+
transaction that cannot be accepted.
|
|
306
|
+
|
|
307
|
+
The prompt asks for exactly one marker and nothing marker-shaped beside it,
|
|
308
|
+
so that is what is enforced: a second copy of ours, an unusable marker
|
|
309
|
+
beside the valid one, and a *valid* marker belonging to another transaction
|
|
310
|
+
are all defects. The last one matters as much as the others -- counting
|
|
311
|
+
only our own markers would let a body publish two provenances at once while
|
|
312
|
+
every objective check still passed, and provenance that names two
|
|
313
|
+
transactions proves neither.
|
|
314
|
+
|
|
315
|
+
Candidate binding and the final read before the destructive close share
|
|
316
|
+
this function, so "acceptable enough to adopt" and "still acceptable to
|
|
317
|
+
close the source for" cannot drift apart into two different rules.
|
|
318
|
+
"""
|
|
319
|
+
mine = [a for a in scan.attestations if a.transaction_id == transaction_id]
|
|
320
|
+
if not mine:
|
|
321
|
+
return None, ""
|
|
322
|
+
if len(mine) > 1:
|
|
323
|
+
return None, f"{label} {ref} carries {len(mine)} markers for this transaction"
|
|
324
|
+
if scan.malformed:
|
|
325
|
+
return None, (
|
|
326
|
+
f"{label} {ref} carries the marker for replan transaction {transaction_id} "
|
|
327
|
+
f"alongside an unusable one ({'; '.join(scan.malformed)})"
|
|
328
|
+
)
|
|
329
|
+
others = len(scan.attestations) - 1
|
|
330
|
+
if others:
|
|
331
|
+
return None, (
|
|
332
|
+
f"{label} {ref} carries the marker for replan transaction {transaction_id} "
|
|
333
|
+
f"alongside {others} valid marker(s) for other transactions; a replacement "
|
|
334
|
+
"body must publish exactly one attestation"
|
|
335
|
+
)
|
|
336
|
+
return mine[0], ""
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def source_marker_defect(
|
|
340
|
+
scan: MarkerScan, transaction_id: str, ref: str, *, preexisting: bool
|
|
341
|
+
) -> str:
|
|
342
|
+
"""Why the *source* PR's own body refuses this replan, or ``""``.
|
|
343
|
+
|
|
344
|
+
The source can never be adopted as its own replacement, but excluding it
|
|
345
|
+
*before* reading its body would turn "the agent marked the PR being
|
|
346
|
+
superseded" into "no candidate exists" -- and that observation is what
|
|
347
|
+
decides whether the agent is invoked again. Marker-bearing work would then
|
|
348
|
+
exist while a second implementation attempt started on top of it, which is
|
|
349
|
+
exactly what crash idempotency forbids. The prompt says putting the marker
|
|
350
|
+
on the superseded PR rejects the replan, so it has to be *found and
|
|
351
|
+
refused*, never missed.
|
|
352
|
+
|
|
353
|
+
A valid marker for another transaction is left alone: a source PR may
|
|
354
|
+
itself be an earlier replan's replacement, and that older marker is
|
|
355
|
+
legitimate history. An unusable marker is refused only when the source
|
|
356
|
+
postdates the transaction id (``preexisting`` is false, the same
|
|
357
|
+
watermark/snapshot classification every other candidate gets): a body
|
|
358
|
+
that tried to carry an attestation and failed is then evidence of a
|
|
359
|
+
botched attempt. A source that already existed when the transaction was
|
|
360
|
+
prepared is evidence of nothing about it -- its unusable markers, like
|
|
361
|
+
those on any other pre-existing PR, neither adopt nor block -- while a
|
|
362
|
+
*valid* marker for this transaction is a copied one and is refused
|
|
363
|
+
whatever the source's age.
|
|
364
|
+
"""
|
|
365
|
+
if any(a.transaction_id == transaction_id for a in scan.attestations):
|
|
366
|
+
return (
|
|
367
|
+
f"source PR {ref} carries the marker for replan transaction {transaction_id}; "
|
|
368
|
+
"the marker belongs on the replacement the agent creates, and a replan whose "
|
|
369
|
+
"attestation sits on the PR being superseded is refused, never restarted"
|
|
370
|
+
)
|
|
371
|
+
if scan.malformed and not preexisting:
|
|
372
|
+
return (
|
|
373
|
+
f"source PR {ref} carries an unusable replan marker "
|
|
374
|
+
f"({'; '.join(scan.malformed)}), which cannot be ruled out as a botched "
|
|
375
|
+
f"attestation for replan transaction {transaction_id}"
|
|
376
|
+
)
|
|
377
|
+
return ""
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# Persisted URL and id fields are validated by shape as well as by type: a
|
|
381
|
+
# string that is not a GitHub URL would otherwise surface as a
|
|
382
|
+
# ``ConfigurationError`` from the first ``parse_*_url`` on the resume path -- a
|
|
383
|
+
# crash, not a refusal -- and a transaction id that is not one would be
|
|
384
|
+
# silently overwritten (at PENDING) or could bind nothing (later) without the
|
|
385
|
+
# journal ever being called what it is: corrupt.
|
|
386
|
+
_ISSUE_URL_FIELDS = frozenset({"issue_url"})
|
|
387
|
+
_PR_URL_FIELDS = frozenset(
|
|
388
|
+
{"decision_pr_url", "source_pr_url", "replacement_pr_url", "rejected_pr_url"}
|
|
389
|
+
)
|
|
390
|
+
_PR_URL_LIST_FIELDS = frozenset({"preexisting_pr_urls"})
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _url_defect(name: str, value: str, parser: Callable[[str], object]) -> str:
|
|
394
|
+
try:
|
|
395
|
+
parser(value)
|
|
396
|
+
except ConfigurationError as exc:
|
|
397
|
+
return f"{name} is not a GitHub URL ({exc})"
|
|
398
|
+
return ""
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _field_defect(name: str, annotation: str, value: object) -> str:
|
|
402
|
+
"""Why ``value`` cannot be loaded into the field ``name``, or ``""``.
|
|
403
|
+
|
|
404
|
+
The check is driven by the dataclass annotation (a string, because the
|
|
405
|
+
module defers annotation evaluation), so a field added to the transaction
|
|
406
|
+
is validated by construction: an annotation this does not understand is a
|
|
407
|
+
programming error and is raised, never waved through.
|
|
408
|
+
"""
|
|
409
|
+
kind = type(value).__name__
|
|
410
|
+
if annotation == "str":
|
|
411
|
+
if not isinstance(value, str):
|
|
412
|
+
return f"{name} must be a string, got {kind}"
|
|
413
|
+
if value and name in _ISSUE_URL_FIELDS | _PR_URL_FIELDS:
|
|
414
|
+
parser = parse_issue_url if name in _ISSUE_URL_FIELDS else parse_pr_url
|
|
415
|
+
return _url_defect(name, value, parser)
|
|
416
|
+
if value and name == "transaction_id" and not TRANSACTION_ID_RE.match(value):
|
|
417
|
+
return f"{name} must be 32 lowercase hex characters, got {value!r}"
|
|
418
|
+
return ""
|
|
419
|
+
if annotation == "int":
|
|
420
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
421
|
+
return f"{name} must be an integer >= 0, got {value!r}"
|
|
422
|
+
return ""
|
|
423
|
+
if annotation == "list[str]":
|
|
424
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
425
|
+
return f"{name} must be a list of strings, got {kind}"
|
|
426
|
+
if name in _PR_URL_LIST_FIELDS:
|
|
427
|
+
for item in value:
|
|
428
|
+
defect = _url_defect(f"{name} entry {item!r}", item, parse_pr_url)
|
|
429
|
+
if defect:
|
|
430
|
+
return defect
|
|
431
|
+
return ""
|
|
432
|
+
if annotation == "dict":
|
|
433
|
+
if not isinstance(value, dict):
|
|
434
|
+
return f"{name} must be an object, got {kind}"
|
|
435
|
+
return ""
|
|
436
|
+
raise TypeError(f"ReplanTransaction field {name!r} has unvalidated type {annotation!r}")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# The fields each stage's writer fills *together with* the stage. A journal at
|
|
440
|
+
# a stage in which one of them is empty was not written by this controller (a
|
|
441
|
+
# hand edit, a truncated write, an older version), so the type-valid fields
|
|
442
|
+
# around it prove nothing either: it is corruption, not a checkpoint with a
|
|
443
|
+
# gap. Requirements accumulate along the lifecycle, and ``REJECTED`` -- which
|
|
444
|
+
# any stage can reach -- requires nothing. Type validity alone would let a
|
|
445
|
+
# ``VERIFIED`` journal with ``source_branch == ""`` reach the close with a
|
|
446
|
+
# checkpoint that was never proven (#35 R2-F1); this table is what rules that
|
|
447
|
+
# out, and the verifiers refuse the same gap again in depth.
|
|
448
|
+
#
|
|
449
|
+
# The table must cover *every* field its stage writes, not only the ones a
|
|
450
|
+
# verifier compares: an omitted field falls back to the dataclass default,
|
|
451
|
+
# and a default is a value the acceptance predicates will happily enforce.
|
|
452
|
+
# ``evidence_finding_count`` defaulting to 0 is the case that shows why (#35
|
|
453
|
+
# R5-F1): ``verify_attestation`` would then accept a replacement attesting it
|
|
454
|
+
# considered 0 findings, and the source holding the real ones would be closed
|
|
455
|
+
# against an acknowledgement nobody required. So the evidence group is
|
|
456
|
+
# required whole, and the values the writer records there are never
|
|
457
|
+
# legitimately empty -- a replan is only ever decided after a review that
|
|
458
|
+
# ended with findings, so the preserved count is >= 1 and its rendering is
|
|
459
|
+
# non-empty; the observations and verification failures render "(none)" when
|
|
460
|
+
# there are none; the review round that decided the replan is >= 1; and the
|
|
461
|
+
# open-PR snapshot contains the source itself, which ``_prepare_replan`` reads
|
|
462
|
+
# as OPEN and requires to be listed. A field whose legitimate value *can* be
|
|
463
|
+
# falsy is required to be present instead (``_PRESENT_AT_STAGE``): the
|
|
464
|
+
# replacement's ``unique_constraints`` may honestly be 0, but a journal at
|
|
465
|
+
# ``VERIFIED`` that does not record it at all was not written whole.
|
|
466
|
+
_STAGE_ORDER: tuple[ReplanStage, ...] = (
|
|
467
|
+
ReplanStage.PENDING,
|
|
468
|
+
ReplanStage.PREPARED,
|
|
469
|
+
ReplanStage.VERIFIED,
|
|
470
|
+
ReplanStage.SUPERSEDE_INTENT,
|
|
471
|
+
)
|
|
472
|
+
_REQUIRED_AT_STAGE: dict[ReplanStage, tuple[str, ...]] = {
|
|
473
|
+
# REVIEW records the issue whose lifecycle it was reviewing, the PR it
|
|
474
|
+
# decided on, both halves of the revision it reviewed there and the base
|
|
475
|
+
# it reviewed that revision against; a branch-less decision could only be
|
|
476
|
+
# compared vacuously (#35 R3-F2), a PR-less one would let the prepare
|
|
477
|
+
# step take its source from whatever the run happens to hold (#35
|
|
478
|
+
# R4-F1), an issue-less one would let it take the issue the replacement
|
|
479
|
+
# must be linked to from whatever the run happens to hold (#35 R6-F1),
|
|
480
|
+
# and a base-less one would let a source retargeted to another base --
|
|
481
|
+
# a different diff, which the findings never described -- be superseded
|
|
482
|
+
# on the strength of the reviewed one (#68). The merge base completes
|
|
483
|
+
# the diff: a base rewritten under its name moves it while the HEAD,
|
|
484
|
+
# the branch and the base name all stay put (#96), so a decision without
|
|
485
|
+
# it would let the source be superseded for a diff no review saw. The
|
|
486
|
+
# policy metadata is recorded with the decision and always names its
|
|
487
|
+
# trigger.
|
|
488
|
+
ReplanStage.PENDING: (
|
|
489
|
+
"issue_url",
|
|
490
|
+
"decision_pr_url",
|
|
491
|
+
"decision_head_sha",
|
|
492
|
+
"decision_branch",
|
|
493
|
+
"decision_base_ref",
|
|
494
|
+
"decision_merge_base_sha",
|
|
495
|
+
"escalation",
|
|
496
|
+
),
|
|
497
|
+
ReplanStage.PREPARED: (
|
|
498
|
+
"transaction_id",
|
|
499
|
+
"source_pr_url",
|
|
500
|
+
"source_branch",
|
|
501
|
+
"source_head_sha",
|
|
502
|
+
"source_base_ref",
|
|
503
|
+
"source_merge_base_sha",
|
|
504
|
+
"source_review_round",
|
|
505
|
+
"base_branch",
|
|
506
|
+
"evidence_finding_count",
|
|
507
|
+
"rendered_findings",
|
|
508
|
+
"rendered_observations",
|
|
509
|
+
"rendered_verification_failures",
|
|
510
|
+
"preexisting_pr_urls",
|
|
511
|
+
"pr_number_watermark",
|
|
512
|
+
"expected_execution_attempt",
|
|
513
|
+
),
|
|
514
|
+
# The verified attestation is re-checked exactly on the last read before
|
|
515
|
+
# the close and again before activation; it must be >= the preserved
|
|
516
|
+
# evidence count, so it is never legitimately 0 either.
|
|
517
|
+
ReplanStage.VERIFIED: (
|
|
518
|
+
"replacement_pr_url",
|
|
519
|
+
"replacement_branch",
|
|
520
|
+
"replacement_head_sha",
|
|
521
|
+
"attested_findings_considered",
|
|
522
|
+
),
|
|
523
|
+
# The intent is written with the time, the number of close attempts it
|
|
524
|
+
# covers (1 for the write, 2 once a resume retried it) and the source
|
|
525
|
+
# PR's ``closed`` event count read just before it (#69). The count is
|
|
526
|
+
# what a resume compares GitHub against before it may retry, so an
|
|
527
|
+
# intent without it can only be refused; it is 0 for a source that was
|
|
528
|
+
# never closed before, so it is required present, not non-zero.
|
|
529
|
+
ReplanStage.SUPERSEDE_INTENT: ("close_intent_at", "close_attempts"),
|
|
530
|
+
# Both follow SUPERSEDE_INTENT and are alternatives to each other.
|
|
531
|
+
ReplanStage.COMPENSATING: ("compensating_at", "compensation_reason"),
|
|
532
|
+
ReplanStage.SUPERSEDED: ("superseded_at",),
|
|
533
|
+
}
|
|
534
|
+
# Written together with the stage, but 0 is an honest value: present-or-corrupt.
|
|
535
|
+
_PRESENT_AT_STAGE: dict[ReplanStage, tuple[str, ...]] = {
|
|
536
|
+
ReplanStage.VERIFIED: ("attested_unique_constraints",),
|
|
537
|
+
ReplanStage.SUPERSEDE_INTENT: ("source_closed_event_count",),
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
#: Stages at which the controller has committed to closing the source PR:
|
|
542
|
+
#: the intent is durable, and the close may already have landed. From here
|
|
543
|
+
#: the reducer only reads, confirms, and then activates the replacement or
|
|
544
|
+
#: undoes the close; it invokes no agent. The one write it may still perform
|
|
545
|
+
#: is the bounded retry of the close at ``SUPERSEDE_INTENT``, and only once
|
|
546
|
+
#: the source's issue events have proven the first attempt never landed
|
|
547
|
+
#: (:func:`verify_close_never_ran`).
|
|
548
|
+
CLOSE_BEGUN_STAGES: frozenset[ReplanStage] = frozenset(
|
|
549
|
+
{ReplanStage.SUPERSEDE_INTENT, ReplanStage.COMPENSATING, ReplanStage.SUPERSEDED}
|
|
550
|
+
)
|
|
551
|
+
#: Stages at which the controller has performed no write the run would have to
|
|
552
|
+
#: account for: refusing to go on costs nothing here, so this is where a loop
|
|
553
|
+
#: bound may stop the phase before the reducer runs.
|
|
554
|
+
_PRE_WRITE_STAGES: frozenset[ReplanStage] = frozenset(
|
|
555
|
+
{ReplanStage.PENDING, ReplanStage.PREPARED, ReplanStage.VERIFIED}
|
|
556
|
+
)
|
|
557
|
+
#: Stages from which the reducer may still launch the replan agent: no PR
|
|
558
|
+
#: bound to the transaction exists yet. Binding and ``VERIFIED`` are saved
|
|
559
|
+
#: together, so a persisted journal at any later stage is finished by the
|
|
560
|
+
#: controller alone -- read, verify, close, activate, reopen or refuse.
|
|
561
|
+
_AGENT_STAGES: frozenset[ReplanStage] = frozenset({ReplanStage.PENDING, ReplanStage.PREPARED})
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _stages_reached(stage: ReplanStage) -> tuple[ReplanStage, ...]:
|
|
565
|
+
if stage is ReplanStage.REJECTED:
|
|
566
|
+
return ()
|
|
567
|
+
if stage in _STAGE_ORDER:
|
|
568
|
+
return _STAGE_ORDER[: _STAGE_ORDER.index(stage) + 1]
|
|
569
|
+
return (*_STAGE_ORDER, stage)
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def required_fields_at(stage: ReplanStage) -> tuple[str, ...]:
|
|
573
|
+
"""Every field a journal at ``stage`` must carry non-empty, in lifecycle order."""
|
|
574
|
+
return tuple(name for earlier in _stages_reached(stage) for name in _REQUIRED_AT_STAGE[earlier])
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def present_fields_at(stage: ReplanStage) -> tuple[str, ...]:
|
|
578
|
+
"""Every field a journal at ``stage`` must carry, though possibly falsy."""
|
|
579
|
+
return tuple(
|
|
580
|
+
name for earlier in _stages_reached(stage) for name in _PRESENT_AT_STAGE.get(earlier, ())
|
|
581
|
+
)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _stage_defects(stage: ReplanStage, values: dict[str, object]) -> list[str]:
|
|
585
|
+
"""Required-but-empty (or forbidden-but-set) fields for ``stage``."""
|
|
586
|
+
defects: list[str] = []
|
|
587
|
+
for name in required_fields_at(stage):
|
|
588
|
+
if name not in values:
|
|
589
|
+
defects.append(f"{name} is required at stage {stage.value!r} but missing")
|
|
590
|
+
elif not values[name]:
|
|
591
|
+
defects.append(f"{name} is required at stage {stage.value!r} but empty")
|
|
592
|
+
for name in present_fields_at(stage):
|
|
593
|
+
if name not in values:
|
|
594
|
+
defects.append(f"{name} is required at stage {stage.value!r} but missing")
|
|
595
|
+
# The id is created together with PREPARED and can exist nowhere before
|
|
596
|
+
# it (see the module docstring); a PENDING journal carrying one was not
|
|
597
|
+
# written by this controller.
|
|
598
|
+
if stage is ReplanStage.PENDING and values.get("transaction_id"):
|
|
599
|
+
defects.append(f"transaction_id is set at stage {stage.value!r}, before it is created")
|
|
600
|
+
return defects
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
@dataclass
|
|
604
|
+
class ReplanTransaction:
|
|
605
|
+
"""Durable intent for one replan. Persisted as ``state.replan_transaction``.
|
|
606
|
+
|
|
607
|
+
The field groups mirror the invariants: a *source* checkpoint (the PR that
|
|
608
|
+
may be closed), a *base* the replacement must be built on, the *evidence*
|
|
609
|
+
the replacement must answer for, the *provenance* that binds a candidate
|
|
610
|
+
to this transaction, the verified *target*, and the record of the
|
|
611
|
+
destructive write.
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
transaction_id: str = ""
|
|
615
|
+
stage: ReplanStage = ReplanStage.PENDING
|
|
616
|
+
|
|
617
|
+
# -- decision point: the review state that routed to this replan -------
|
|
618
|
+
# Recorded by REVIEW when the transaction is created, before the
|
|
619
|
+
# controller looks at GitHub again. The source may only be superseded at
|
|
620
|
+
# exactly this revision of exactly this PR: it is the one whose findings
|
|
621
|
+
# justify the replan. The PR identity is recorded alongside the revision
|
|
622
|
+
# because the prepare step otherwise has only ``state.current_pr_url`` to
|
|
623
|
+
# take its source from, and two open PRs can share a HEAD and a branch
|
|
624
|
+
# (one branch, two bases); a decision that binds the revision but not the
|
|
625
|
+
# PR could be checkpointed onto the wrong one. The issue is recorded here
|
|
626
|
+
# for the same reason: it is the lifecycle the source belongs to and the
|
|
627
|
+
# one the replacement must be linked to, and the prepare step would
|
|
628
|
+
# otherwise take it from ``state.current_issue_url``. It is never
|
|
629
|
+
# re-derived later; :func:`verify_run_binding` binds it to the run's
|
|
630
|
+
# active issue at every stage instead. The base branch the review was
|
|
631
|
+
# bound to completes the revision: a PR's diff is HEAD against base, so
|
|
632
|
+
# the same HEAD on the same branch retargeted to another base is a
|
|
633
|
+
# different change from the one the findings describe. The merge base
|
|
634
|
+
# is the commit that diff was computed from: a base rewritten under its
|
|
635
|
+
# name (force-push, reset) moves it while the HEAD, the branch and the
|
|
636
|
+
# base name stay equal, so it is bound with them (#96). It is the SHA the
|
|
637
|
+
# review bound (``state.current_merge_base_sha`` at REVIEW entry), never
|
|
638
|
+
# re-read from GitHub after the decision.
|
|
639
|
+
issue_url: str = ""
|
|
640
|
+
decision_pr_url: str = ""
|
|
641
|
+
decision_head_sha: str = ""
|
|
642
|
+
decision_branch: str = ""
|
|
643
|
+
decision_base_ref: str = ""
|
|
644
|
+
decision_merge_base_sha: str = ""
|
|
645
|
+
|
|
646
|
+
# -- source checkpoint: exactly what may be superseded -----------------
|
|
647
|
+
source_pr_url: str = ""
|
|
648
|
+
source_branch: str = ""
|
|
649
|
+
source_head_sha: str = ""
|
|
650
|
+
source_base_ref: str = ""
|
|
651
|
+
source_merge_base_sha: str = ""
|
|
652
|
+
source_review_round: int = 0
|
|
653
|
+
|
|
654
|
+
# -- base: independently verified default branch ----------------------
|
|
655
|
+
base_branch: str = ""
|
|
656
|
+
|
|
657
|
+
# -- evidence the replacement must account for ------------------------
|
|
658
|
+
evidence_finding_count: int = 0
|
|
659
|
+
rendered_findings: str = ""
|
|
660
|
+
rendered_observations: str = ""
|
|
661
|
+
rendered_verification_failures: str = ""
|
|
662
|
+
|
|
663
|
+
# -- provenance -------------------------------------------------------
|
|
664
|
+
# Largest PR number in the repository when this transaction was prepared,
|
|
665
|
+
# read *before* the transaction id existed. GitHub allocates PR numbers
|
|
666
|
+
# from a monotonic per-repository counter, so any PR at or below this
|
|
667
|
+
# predates the id and therefore cannot be the replacement -- whether or not
|
|
668
|
+
# it was linked to the issue, named for it, or even open at the time.
|
|
669
|
+
pr_number_watermark: int = 0
|
|
670
|
+
# Defence in depth over the watermark: the issue's open PRs at PREPARED,
|
|
671
|
+
# by identity. None of them can ever be adopted as the replacement.
|
|
672
|
+
preexisting_pr_urls: list[str] = field(default_factory=list)
|
|
673
|
+
expected_execution_attempt: int = 0
|
|
674
|
+
|
|
675
|
+
# -- target: the verified replacement ---------------------------------
|
|
676
|
+
replacement_pr_url: str = ""
|
|
677
|
+
replacement_branch: str = ""
|
|
678
|
+
replacement_head_sha: str = ""
|
|
679
|
+
attested_findings_considered: int = 0
|
|
680
|
+
attested_unique_constraints: int = 0
|
|
681
|
+
|
|
682
|
+
# -- destructive write ------------------------------------------------
|
|
683
|
+
close_intent_at: str = ""
|
|
684
|
+
# The source PR's ``closed`` issue-event count, read from GitHub after
|
|
685
|
+
# the pre-close checkpoint held and immediately before the intent was
|
|
686
|
+
# persisted (#69). Issue events are append-only and undeletable, so a
|
|
687
|
+
# resume that finds the source OPEN can compare: a higher count proves a
|
|
688
|
+
# close landed after the intent (this transaction's, or a human's; either
|
|
689
|
+
# way the reopen that followed is the last word) and refuses; an equal
|
|
690
|
+
# count proves no close has happened since and licenses the one retry.
|
|
691
|
+
# Absence of a comment never decides a write; this count does.
|
|
692
|
+
source_closed_event_count: int = 0
|
|
693
|
+
# How many times ``gh pr close`` has been called under this intent: 1 by
|
|
694
|
+
# the write, 2 by the retry a resume performed. Bounded by
|
|
695
|
+
# ``MAX_CLOSE_ATTEMPTS``.
|
|
696
|
+
close_attempts: int = 0
|
|
697
|
+
superseded_at: str = ""
|
|
698
|
+
|
|
699
|
+
# -- compensation -----------------------------------------------------
|
|
700
|
+
# Written together with stage COMPENSATING, before `gh pr reopen` is
|
|
701
|
+
# called. ``compensation_reason`` is the drift that made the close
|
|
702
|
+
# unacceptable; it is replayed verbatim by a resume so the undo cannot be
|
|
703
|
+
# re-derived (and possibly re-decided) from GitHub facts that have since
|
|
704
|
+
# settled back into place.
|
|
705
|
+
compensating_at: str = ""
|
|
706
|
+
compensation_reason: str = ""
|
|
707
|
+
|
|
708
|
+
# -- disposition ------------------------------------------------------
|
|
709
|
+
rejection_reason: str = ""
|
|
710
|
+
rejected_pr_url: str = ""
|
|
711
|
+
|
|
712
|
+
escalation: dict = field(default_factory=dict)
|
|
713
|
+
|
|
714
|
+
# -- journal integrity (never persisted) ------------------------------
|
|
715
|
+
# Why :meth:`from_dict` coerced this transaction to ``REJECTED``: one
|
|
716
|
+
# reason per persisted field it could not read. Empty for a well-formed
|
|
717
|
+
# journal. Kept off disk so the corrupt evidence is never overwritten by
|
|
718
|
+
# the defaults that stand in for it here.
|
|
719
|
+
journal_defects: list[str] = field(default_factory=list)
|
|
720
|
+
|
|
721
|
+
# -- persistence ------------------------------------------------------
|
|
722
|
+
def to_dict(self) -> dict:
|
|
723
|
+
data = asdict(self)
|
|
724
|
+
data["stage"] = self.stage.value
|
|
725
|
+
del data["journal_defects"]
|
|
726
|
+
return data
|
|
727
|
+
|
|
728
|
+
@classmethod
|
|
729
|
+
def from_dict(cls, data: dict) -> ReplanTransaction:
|
|
730
|
+
"""Load persisted intent; unknown/omitted fields fall back to defaults.
|
|
731
|
+
|
|
732
|
+
Every *present* field is schema-checked. A field that cannot be read
|
|
733
|
+
-- an unknown stage, a watermark stored as a string, an escalation
|
|
734
|
+
stored as a list -- is *not* coerced to a runnable value: the
|
|
735
|
+
transaction becomes ``REJECTED`` with the defects named, so a
|
|
736
|
+
hand-edited, truncated or future-version journal fails closed
|
|
737
|
+
instead of resuming a destructive write from an unknown position, and
|
|
738
|
+
instead of crashing on the first use of the field so that every
|
|
739
|
+
``resume`` dies identically. The unreadable fields fall back to their
|
|
740
|
+
defaults in memory only (see ``journal_defects``).
|
|
741
|
+
|
|
742
|
+
Omission is only tolerated where the stage tolerates it: every field
|
|
743
|
+
the recorded stage's writer fills is required to be present and
|
|
744
|
+
non-empty (:func:`required_fields_at`), or at least present where 0
|
|
745
|
+
is an honest value (:func:`present_fields_at`), so an incomplete
|
|
746
|
+
checkpoint is corruption rather than a checkpoint some later verifier
|
|
747
|
+
might compare vacuously -- or, for the evidence fields, enforce at
|
|
748
|
+
their defaults.
|
|
749
|
+
"""
|
|
750
|
+
if not isinstance(data, dict):
|
|
751
|
+
raise ValueError("replan transaction must be a JSON object")
|
|
752
|
+
kwargs: dict = {}
|
|
753
|
+
defects: list[str] = []
|
|
754
|
+
raw_stage = data.get("stage", ReplanStage.PENDING.value)
|
|
755
|
+
try:
|
|
756
|
+
if not isinstance(raw_stage, str):
|
|
757
|
+
raise ValueError(raw_stage)
|
|
758
|
+
stage = ReplanStage(raw_stage)
|
|
759
|
+
except ValueError:
|
|
760
|
+
stage = ReplanStage.REJECTED
|
|
761
|
+
defects.append(f"unknown stage {raw_stage!r}")
|
|
762
|
+
for spec in fields(cls):
|
|
763
|
+
if spec.name in ("stage", "journal_defects") or spec.name not in data:
|
|
764
|
+
continue
|
|
765
|
+
value = data[spec.name]
|
|
766
|
+
defect = _field_defect(spec.name, str(spec.type), value)
|
|
767
|
+
if defect:
|
|
768
|
+
defects.append(defect)
|
|
769
|
+
continue
|
|
770
|
+
kwargs[spec.name] = value
|
|
771
|
+
if not defects:
|
|
772
|
+
# Only a journal whose fields all read cleanly is judged for
|
|
773
|
+
# completeness: a field that already failed its type check has
|
|
774
|
+
# been reported once and must not be reported again as "empty".
|
|
775
|
+
defects.extend(_stage_defects(stage, kwargs))
|
|
776
|
+
if defects:
|
|
777
|
+
stage = ReplanStage.REJECTED
|
|
778
|
+
prior = kwargs.get("rejection_reason", "")
|
|
779
|
+
kwargs["rejection_reason"] = (
|
|
780
|
+
"persisted replan transaction is corrupt: "
|
|
781
|
+
+ "; ".join(defects)
|
|
782
|
+
+ f" (recorded stage {raw_stage!r}"
|
|
783
|
+
+ (f", recorded rejection: {prior}" if prior else "")
|
|
784
|
+
+ ")"
|
|
785
|
+
)
|
|
786
|
+
return cls(stage=stage, journal_defects=defects, **kwargs)
|
|
787
|
+
|
|
788
|
+
@property
|
|
789
|
+
def is_bound(self) -> bool:
|
|
790
|
+
return bool(self.replacement_pr_url)
|
|
791
|
+
|
|
792
|
+
def rendered_marker(self, findings_considered: int, unique_constraints: int) -> str:
|
|
793
|
+
"""The marker the controller renders on the replacement PR it creates (#164).
|
|
794
|
+
|
|
795
|
+
The transaction id and the execution attempt are the checkpoint's;
|
|
796
|
+
the counts are the validated result's, and ``tests_passed`` is
|
|
797
|
+
``True`` because a result reporting otherwise is rejected before
|
|
798
|
+
anything is published.
|
|
799
|
+
"""
|
|
800
|
+
return render_marker(
|
|
801
|
+
ReplanAttestation(
|
|
802
|
+
transaction_id=self.transaction_id,
|
|
803
|
+
execution_attempt=self.expected_execution_attempt,
|
|
804
|
+
findings_considered=findings_considered,
|
|
805
|
+
unique_constraints=unique_constraints,
|
|
806
|
+
tests_passed=True,
|
|
807
|
+
)
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def budget_may_stop(txn: ReplanTransaction) -> bool:
|
|
812
|
+
"""Whether the run's step budget may stop the replan step before the reducer runs.
|
|
813
|
+
|
|
814
|
+
True only while the journal proves the controller has written nothing
|
|
815
|
+
(``PENDING``, ``PREPARED``, ``VERIFIED``): blocking there leaves the
|
|
816
|
+
source open with its findings and needs nothing recorded. From
|
|
817
|
+
``SUPERSEDE_INTENT`` on, the persisted intent is a decision the
|
|
818
|
+
controller finishes -- what remains is read, confirm and activate, read,
|
|
819
|
+
confirm and reopen, or the one retry of a close GitHub proves never ran,
|
|
820
|
+
with no agent -- so
|
|
821
|
+
the step counts and the budget ends the run at the next phase boundary
|
|
822
|
+
instead of inside the transaction, where a plain budget block would
|
|
823
|
+
strand a source the controller closed with nothing recorded about it
|
|
824
|
+
(#71). A journal the reducer will refuse anyway -- ``REJECTED``, or one
|
|
825
|
+
that could not be read whole and therefore cannot prove it is before the
|
|
826
|
+
write -- is handed to the reducer too: its refusal names the transaction
|
|
827
|
+
and the source PR's fate, which the budget text cannot.
|
|
828
|
+
"""
|
|
829
|
+
return txn.stage in _PRE_WRITE_STAGES
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def may_invoke_agent(txn: ReplanTransaction) -> bool:
|
|
833
|
+
"""Whether a REPLAN_REEXECUTE step from this journal may launch the replan agent.
|
|
834
|
+
|
|
835
|
+
True only at ``PENDING`` and ``PREPARED``, where no replacement PR is
|
|
836
|
+
bound to the transaction and the reducer falls through to the
|
|
837
|
+
invocation when it finds none. From ``VERIFIED`` on the reducer resolves
|
|
838
|
+
the step itself, and a ``REJECTED`` journal -- recorded, or coerced there
|
|
839
|
+
because it could not be read whole -- replays its refusal. The dry-run
|
|
840
|
+
plan follows this partition so it shows an agent command, template and
|
|
841
|
+
prompt only for a step that can launch one.
|
|
842
|
+
"""
|
|
843
|
+
return txn.stage in _AGENT_STAGES
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
# ---------------------------------------------------------------------------
|
|
847
|
+
# State-protocol migration. The journal's schema is part of the state
|
|
848
|
+
# protocol, and each protocol step so far added one binding to it: 1 -> 2
|
|
849
|
+
# when REVIEW started recording the PR and the issue its replan decision was
|
|
850
|
+
# made on (``decision_pr_url``; ``issue_url`` at PENDING) so that every later
|
|
851
|
+
# stage could be bound to that decision rather than to whatever the run
|
|
852
|
+
# happened to hold, and 2 -> 3 when it started recording the base branch the
|
|
853
|
+
# deciding review was bound to (``decision_base_ref`` at PENDING,
|
|
854
|
+
# ``source_base_ref`` at PREPARED) so that a source retargeted to another
|
|
855
|
+
# base could not be superseded on the strength of a review of a different
|
|
856
|
+
# diff (#68). A journal written under an older protocol was written whole by
|
|
857
|
+
# the controller of its day and is not corrupt; it simply never recorded the
|
|
858
|
+
# binding, and there is nothing to reconstruct it from: "missing field ->
|
|
859
|
+
# fill from the run's current PR and issue" or "-> fill from the source PR's
|
|
860
|
+
# current base" is precisely the rebinding the fields exist to forbid (#35
|
|
861
|
+
# R4-F1, R6-F1), and #66 R7-F1 is where an in-flight protocol-1 journal was
|
|
862
|
+
# found to be refused as *corruption* under a protocol that still called
|
|
863
|
+
# itself 1. 3 -> 4 added the merge base the deciding review's diff was
|
|
864
|
+
# computed from (``decision_merge_base_sha`` at PENDING,
|
|
865
|
+
# ``source_merge_base_sha`` at PREPARED), so that a base rewritten under its
|
|
866
|
+
# name between the review and the close -- same HEAD, same branch, same
|
|
867
|
+
# base name, another diff -- could not be superseded on the strength of a
|
|
868
|
+
# review of the diff it used to show (#96). Filling it from the merge base
|
|
869
|
+
# GitHub reports *now* would bind the decision to exactly the rewrite the
|
|
870
|
+
# field exists to detect, so a protocol-3 journal in flight is refused like
|
|
871
|
+
# the others. 4 -> 5 added the source PR's ``closed`` issue-event count and
|
|
872
|
+
# the close attempt count to the intent (``source_closed_event_count`` and
|
|
873
|
+
# ``close_attempts`` at SUPERSEDE_INTENT), so that a resume finding the
|
|
874
|
+
# source OPEN under an intent could prove the close never ran and retry it
|
|
875
|
+
# once instead of blocking (#69). This step touched no stage before the
|
|
876
|
+
# write: a protocol-4 journal at PENDING, PREPARED or VERIFIED is exactly
|
|
877
|
+
# what protocol 5 writes there and is loaded as such. One at
|
|
878
|
+
# SUPERSEDE_INTENT, COMPENSATING or SUPERSEDED is refused like the others:
|
|
879
|
+
# reading the count *now* would record the very close it exists to detect
|
|
880
|
+
# as if it predated the intent, and would license the retry the field was
|
|
881
|
+
# added to forbid in that case.
|
|
882
|
+
# ---------------------------------------------------------------------------
|
|
883
|
+
|
|
884
|
+
#: The binding each legacy protocol's journal lacks, as the refusal states
|
|
885
|
+
#: it: what was not recorded, and what this controller will not fill it from.
|
|
886
|
+
_LEGACY_JOURNAL_GAPS: dict[str, str] = {
|
|
887
|
+
"1": (
|
|
888
|
+
"did not record the PR and issue the replan decision was made on, and this controller "
|
|
889
|
+
"does not reconstruct them from the run's current PR and issue"
|
|
890
|
+
),
|
|
891
|
+
"2": (
|
|
892
|
+
"did not record the base branch the review that decided the replan was bound to, and "
|
|
893
|
+
"this controller does not reconstruct it from the source PR's current base"
|
|
894
|
+
),
|
|
895
|
+
"3": (
|
|
896
|
+
"did not record the merge base the review that decided the replan was bound to, and "
|
|
897
|
+
"this controller does not reconstruct it from the merge base GitHub reports now"
|
|
898
|
+
),
|
|
899
|
+
"4": (
|
|
900
|
+
"did not record the source PR's closed-event count and the number of close attempts "
|
|
901
|
+
"with the close intent, and this controller does not reconstruct them from the events "
|
|
902
|
+
"GitHub reports now"
|
|
903
|
+
),
|
|
904
|
+
}
|
|
905
|
+
#: The in-flight stages at which each legacy protocol's gap matters. A
|
|
906
|
+
#: protocol whose step changed a field written at ``PENDING`` lacks it at
|
|
907
|
+
#: every later stage, so every in-flight stage is refused; a step that only
|
|
908
|
+
#: added to a later stage's record leaves the earlier stages' journals
|
|
909
|
+
#: identical to the current schema, and those load as current.
|
|
910
|
+
_LEGACY_GAP_STAGES: dict[str, frozenset[str]] = {
|
|
911
|
+
"4": frozenset(stage.value for stage in CLOSE_BEGUN_STAGES),
|
|
912
|
+
}
|
|
913
|
+
_STAGE_VALUES: frozenset[str] = frozenset(stage.value for stage in ReplanStage)
|
|
914
|
+
#: Protocol labels whose journal schema this controller can still describe
|
|
915
|
+
#: (and so refuse in flight) but cannot load whole.
|
|
916
|
+
LEGACY_JOURNAL_PROTOCOLS: frozenset[str] = frozenset(_LEGACY_JOURNAL_GAPS)
|
|
917
|
+
|
|
918
|
+
_LEGACY_STAGE_FATES: dict[str, str] = {
|
|
919
|
+
ReplanStage.PENDING.value: (
|
|
920
|
+
"the decision to replan was recorded but nothing was checkpointed, no transaction id "
|
|
921
|
+
"exists and no PR was closed"
|
|
922
|
+
),
|
|
923
|
+
ReplanStage.PREPARED.value: (
|
|
924
|
+
"a transaction id was created and the replan agent may have been invoked, so a "
|
|
925
|
+
"replacement PR carrying its marker may exist; no PR was closed by it"
|
|
926
|
+
),
|
|
927
|
+
ReplanStage.VERIFIED.value: (
|
|
928
|
+
"a replacement PR was verified against the checkpoint; no PR was closed by it"
|
|
929
|
+
),
|
|
930
|
+
ReplanStage.SUPERSEDE_INTENT.value: (
|
|
931
|
+
"the controller intended to close the source PR and may have done so: a CLOSED source "
|
|
932
|
+
f"carrying an `<!-- {CLOSE_RECEIPT_NAME}: <transaction id> -->` comment was closed by "
|
|
933
|
+
"this transaction, and one without it was not; the replacement was not activated"
|
|
934
|
+
),
|
|
935
|
+
ReplanStage.COMPENSATING.value: (
|
|
936
|
+
"the controller closed the source PR, found a checkpoint had moved, and decided to "
|
|
937
|
+
"reopen it; the reopen may not have been performed or confirmed, so the source PR may "
|
|
938
|
+
"still be CLOSED; the replacement was not activated"
|
|
939
|
+
),
|
|
940
|
+
ReplanStage.SUPERSEDED.value: (
|
|
941
|
+
"the controller closed the source PR and confirmed the close; the replacement was not "
|
|
942
|
+
"activated in the run's state"
|
|
943
|
+
),
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
def legacy_journal_refusal(raw: object, *, protocol: str, written_by: str) -> str:
|
|
948
|
+
"""Why a state's journal under legacy ``protocol`` cannot be loaded, or ``""``.
|
|
949
|
+
|
|
950
|
+
As far as the journal is concerned, a legacy state whose journal is
|
|
951
|
+
empty -- no replan in flight -- or terminal (``REJECTED``, of which every
|
|
952
|
+
protocol reads nothing but the recorded reason) *is* a current state and
|
|
953
|
+
is loaded as one. A journal at any other stage is in flight under a
|
|
954
|
+
schema that never bound what the current one binds, and it is refused at
|
|
955
|
+
the state boundary: never migrated, since the binding cannot be
|
|
956
|
+
reconstructed without re-deciding the replan, and never read as
|
|
957
|
+
corruption, since it was written whole. The refusal names the stage, the
|
|
958
|
+
PRs, the binding the protocol lacks, what that stage implies about the
|
|
959
|
+
source PR, and the two ways out -- finish or undo the transaction with
|
|
960
|
+
the controller that wrote it, or resolve it by hand on GitHub and start a
|
|
961
|
+
new run. Where the protocol step only added to a later stage's record
|
|
962
|
+
(``_LEGACY_GAP_STAGES``), a journal at an earlier stage is written
|
|
963
|
+
exactly as the current schema writes it and loads as current.
|
|
964
|
+
|
|
965
|
+
``raw`` is read defensively: this runs before any schema check, so a
|
|
966
|
+
non-object journal is left for the state's own type check, and an
|
|
967
|
+
unreadable stage or PR is reported as such rather than trusted.
|
|
968
|
+
"""
|
|
969
|
+
if protocol not in _LEGACY_JOURNAL_GAPS:
|
|
970
|
+
raise ValueError(f"protocol {protocol!r} is not a legacy journal protocol")
|
|
971
|
+
if not isinstance(raw, dict) or not raw:
|
|
972
|
+
return ""
|
|
973
|
+
stage = raw.get("stage")
|
|
974
|
+
if stage == ReplanStage.REJECTED.value:
|
|
975
|
+
return ""
|
|
976
|
+
scoped = _LEGACY_GAP_STAGES.get(protocol)
|
|
977
|
+
if scoped is not None and stage in _STAGE_VALUES and stage not in scoped:
|
|
978
|
+
return ""
|
|
979
|
+
|
|
980
|
+
def _text(name: str) -> str:
|
|
981
|
+
value = raw.get(name)
|
|
982
|
+
return value if isinstance(value, str) and value else "(none)"
|
|
983
|
+
|
|
984
|
+
fate = _LEGACY_STAGE_FATES.get(stage) if isinstance(stage, str) else None
|
|
985
|
+
if fate is None:
|
|
986
|
+
fate = (
|
|
987
|
+
f"the journal records an unreadable stage {stage!r}, so what it did to the source "
|
|
988
|
+
"PR cannot be determined from local state"
|
|
989
|
+
)
|
|
990
|
+
return (
|
|
991
|
+
f"state file was written by controller {written_by or '(unknown)'} under "
|
|
992
|
+
f"protocol_version {protocol!r} with a replan in flight (stage "
|
|
993
|
+
f"{stage!r}, transaction {_text('transaction_id')}, source PR {_text('source_pr_url')}, "
|
|
994
|
+
f"replacement PR {_text('replacement_pr_url')}); protocol {protocol!r} "
|
|
995
|
+
f"{_LEGACY_JOURNAL_GAPS[protocol]}. At that stage "
|
|
996
|
+
f"{fate}. Finish or undo the replan with the controller that wrote it, then upgrade; "
|
|
997
|
+
"or resolve it by hand on GitHub and start a new run. The state file was left unchanged"
|
|
998
|
+
)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
# ---------------------------------------------------------------------------
|
|
1002
|
+
# Verification predicates. Each returns "" when the check passes, or a
|
|
1003
|
+
# human-readable reason why the transaction must not proceed. They are the
|
|
1004
|
+
# single definition of "acceptable" for the normal path and for `resume`:
|
|
1005
|
+
# there is exactly one, so the two cannot drift.
|
|
1006
|
+
# ---------------------------------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _canonical(url: str) -> str:
|
|
1010
|
+
"""A PR URL as text, for messages -- never for an identity comparison.
|
|
1011
|
+
|
|
1012
|
+
The canonical form keeps the owner/repository spelling its author used,
|
|
1013
|
+
and GitHub treats ``Owner/Repo`` and ``owner/repo`` as one repository, so
|
|
1014
|
+
two canonical strings can differ while naming the same PR. Identity is
|
|
1015
|
+
:func:`same_pr_url` for a pair and :func:`_identity` for a set.
|
|
1016
|
+
"""
|
|
1017
|
+
try:
|
|
1018
|
+
return parse_pr_url(url).canonical
|
|
1019
|
+
except Exception:
|
|
1020
|
+
return ""
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _identity(url: str) -> tuple[str, str, str, int] | None:
|
|
1024
|
+
"""The PR as GitHub identifies it (:attr:`GitHubRef.identity`), or None.
|
|
1025
|
+
|
|
1026
|
+
``None`` for an unusable URL, and a caller must never compare two ``None``
|
|
1027
|
+
values as equal: an unusable checkpoint matches nothing, exactly as
|
|
1028
|
+
:func:`same_pr_url` returns False for it.
|
|
1029
|
+
"""
|
|
1030
|
+
try:
|
|
1031
|
+
return parse_pr_url(url).identity
|
|
1032
|
+
except Exception:
|
|
1033
|
+
return None
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _same_sha(observed: str, expected: str) -> bool:
|
|
1037
|
+
"""One rule for every SHA comparison: case-insensitive, and never vacuous.
|
|
1038
|
+
|
|
1039
|
+
`gh` reports hexadecimal SHAs in lower case, but a checkpoint can come
|
|
1040
|
+
from anywhere a human can type (state recorded from a CONTROL_RESULT, a
|
|
1041
|
+
hand-repaired journal), so case must not decide a supersede. An empty
|
|
1042
|
+
side never matches: an unreadable HEAD is not "the same" as anything.
|
|
1043
|
+
"""
|
|
1044
|
+
return bool(observed) and bool(expected) and observed.lower() == expected.lower()
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _source_branch_drift(pr: PRInfo, txn: ReplanTransaction, when: str = "") -> str:
|
|
1048
|
+
"""The source branch comparison, shared by the pre- and post-close reads.
|
|
1049
|
+
|
|
1050
|
+
Like :func:`_same_sha`, never vacuous: the branch is checkpointed by
|
|
1051
|
+
``PREPARED`` together with the HEAD and is half of what "exactly the
|
|
1052
|
+
implementation the controller decided to replace" means, so a checkpoint
|
|
1053
|
+
that records no branch has not proven it and must not be closed on.
|
|
1054
|
+
:meth:`ReplanTransaction.from_dict` already refuses such a journal at any
|
|
1055
|
+
stage from ``PREPARED`` on; this is the same rule again at the point of
|
|
1056
|
+
use, so a transaction assembled any other way cannot slip past it.
|
|
1057
|
+
"""
|
|
1058
|
+
if not txn.source_branch:
|
|
1059
|
+
return (
|
|
1060
|
+
f"the replan transaction records no branch for source PR {txn.source_pr_url}, so "
|
|
1061
|
+
"the checkpoint cannot prove which branch it decided to supersede"
|
|
1062
|
+
)
|
|
1063
|
+
if pr.head_ref != txn.source_branch:
|
|
1064
|
+
return (
|
|
1065
|
+
f"source PR {txn.source_pr_url} moved from the checkpointed branch "
|
|
1066
|
+
f"{txn.source_branch!r} to {pr.head_ref!r}{when}"
|
|
1067
|
+
)
|
|
1068
|
+
return ""
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def _source_base_drift(pr: PRInfo, txn: ReplanTransaction, when: str = "") -> str:
|
|
1072
|
+
"""The source base comparison, shared by the pre- and post-close reads.
|
|
1073
|
+
|
|
1074
|
+
The third half of the checkpointed revision, next to the HEAD and the
|
|
1075
|
+
branch: a PR's diff is its HEAD against its base, so a source retargeted
|
|
1076
|
+
to another base (``gh pr edit --base``) carries a change no review of
|
|
1077
|
+
this transaction ever saw, at an unchanged HEAD. Never vacuous, for the
|
|
1078
|
+
same reason as :func:`_source_branch_drift`, and an unreadable base on
|
|
1079
|
+
the PR is drift too: it is not "the same" as anything.
|
|
1080
|
+
"""
|
|
1081
|
+
if not txn.source_base_ref:
|
|
1082
|
+
return (
|
|
1083
|
+
f"the replan transaction records no base branch for source PR {txn.source_pr_url}, "
|
|
1084
|
+
"so the checkpoint cannot prove which change it decided to supersede"
|
|
1085
|
+
)
|
|
1086
|
+
if pr.base_ref != txn.source_base_ref:
|
|
1087
|
+
return (
|
|
1088
|
+
f"source PR {txn.source_pr_url} was retargeted from the checkpointed base "
|
|
1089
|
+
f"{txn.source_base_ref!r} to {pr.base_ref!r}{when}"
|
|
1090
|
+
)
|
|
1091
|
+
return ""
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def _source_merge_base_drift(merge_base_sha: str, txn: ReplanTransaction, when: str = "") -> str:
|
|
1095
|
+
"""The source merge-base comparison, shared by the pre- and post-close reads.
|
|
1096
|
+
|
|
1097
|
+
The last part of the checkpointed revision: the diff is HEAD against base
|
|
1098
|
+
*from their merge base*, and a base branch rewritten under its name
|
|
1099
|
+
(force-push, reset) moves that commit while the HEAD, the branch and the
|
|
1100
|
+
base name all read back equal (#96). ``merge_base_sha`` is what GitHub
|
|
1101
|
+
reports for the checkpointed base and the observed HEAD right now, read
|
|
1102
|
+
by the caller beside the PR; it is never on the PR object itself. Never
|
|
1103
|
+
vacuous, for the reason :func:`_source_branch_drift` gives, and an
|
|
1104
|
+
unreadable merge base is drift too: it is not "the same" as anything.
|
|
1105
|
+
"""
|
|
1106
|
+
if not txn.source_merge_base_sha:
|
|
1107
|
+
return (
|
|
1108
|
+
f"the replan transaction records no merge base for source PR {txn.source_pr_url}, "
|
|
1109
|
+
"so the checkpoint cannot prove which diff it decided to supersede"
|
|
1110
|
+
)
|
|
1111
|
+
if not _same_sha(merge_base_sha, txn.source_merge_base_sha):
|
|
1112
|
+
return (
|
|
1113
|
+
f"source PR {txn.source_pr_url} moved from the checkpointed merge base "
|
|
1114
|
+
f"{txn.source_merge_base_sha} to {merge_base_sha or '(unreadable)'}{when}; base "
|
|
1115
|
+
f"{txn.source_base_ref!r} was rewritten under its name, so the PR's diff is no "
|
|
1116
|
+
"longer the one the review that decided this replan saw"
|
|
1117
|
+
)
|
|
1118
|
+
return ""
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def verify_run_binding(
|
|
1122
|
+
txn: ReplanTransaction, repository: str, current_pr_url: str, current_issue_url: str
|
|
1123
|
+
) -> str:
|
|
1124
|
+
"""Every PR the journal names as the source must be the PR this run holds,
|
|
1125
|
+
and the issue it names must be the issue this run is working on.
|
|
1126
|
+
|
|
1127
|
+
Every other source verifier compares GitHub against the journal, so a
|
|
1128
|
+
journal whose ``source_pr_url`` was substituted for the well-formed URL of
|
|
1129
|
+
some *other* PR would only ever be compared against itself: that PR reads
|
|
1130
|
+
back as OPEN, on the checkpointed branch, at the checkpointed HEAD, and
|
|
1131
|
+
the close goes to a PR outside this run's lifecycle -- in another
|
|
1132
|
+
repository, if the operator's credentials reach it (#35 R3-F1). The
|
|
1133
|
+
journal is recovery input, not authority over what the run is working on,
|
|
1134
|
+
so before any stage reads or writes the source it is re-bound to the
|
|
1135
|
+
controller's own ``repository`` and ``current_pr_url``, which
|
|
1136
|
+
``_prepare_replan`` took it from in the first place. Identity is compared
|
|
1137
|
+
as GitHub does (repository case-insensitively, then the number), never as
|
|
1138
|
+
URL strings.
|
|
1139
|
+
|
|
1140
|
+
The binding is two-sided. ``current_pr_url`` is itself persisted state,
|
|
1141
|
+
and a ``PENDING`` journal names no checkpointed source yet: the prepare
|
|
1142
|
+
step creates one from ``current_pr_url``. So the decision ``REVIEW``
|
|
1143
|
+
recorded must name the PR it reviewed, and that ``decision_pr_url`` is
|
|
1144
|
+
bound to the run exactly like the checkpoint is; otherwise a run whose
|
|
1145
|
+
``current_pr_url`` was substituted between the review and the prepare
|
|
1146
|
+
step would checkpoint -- and later close -- a PR no review decided on
|
|
1147
|
+
(#35 R4-F1). From ``PREPARED`` on both are required and both must bind,
|
|
1148
|
+
which also pins the checkpoint to the decision.
|
|
1149
|
+
|
|
1150
|
+
The issue is bound the same way, because the PR binding alone proves only
|
|
1151
|
+
*which PR* may be closed, not *for which lifecycle*. The replacement is
|
|
1152
|
+
accepted on the strength of being linked to ``txn.issue_url``
|
|
1153
|
+
(:func:`verify_target_pr`), so a well-formed journal whose ``issue_url``
|
|
1154
|
+
was substituted for another issue of the same repository would let a
|
|
1155
|
+
marker-bearing PR linked only to that other issue be adopted, the source
|
|
1156
|
+
of *this* issue be closed for it, and the run carry on with
|
|
1157
|
+
``current_issue_url`` pointing at an issue the active PR no longer
|
|
1158
|
+
implements (#35 R6-F1). So ``REVIEW`` records the issue it was reviewing
|
|
1159
|
+
for together with the decision, the prepare step never re-derives it, and
|
|
1160
|
+
here it is compared by identity against the run's ``current_issue_url``
|
|
1161
|
+
at every stage -- which also pins the linkage requirement to the issue
|
|
1162
|
+
whose source is being superseded.
|
|
1163
|
+
"""
|
|
1164
|
+
try:
|
|
1165
|
+
held = parse_pr_url(current_pr_url)
|
|
1166
|
+
except ConfigurationError as exc:
|
|
1167
|
+
return (
|
|
1168
|
+
f"the run's current PR URL is unusable ({exc}), so the replan transaction "
|
|
1169
|
+
"cannot be bound to it"
|
|
1170
|
+
)
|
|
1171
|
+
if txn.source_pr_url:
|
|
1172
|
+
drift = _bind_to_run("checkpointed source PR", txn.source_pr_url, repository, held)
|
|
1173
|
+
if drift:
|
|
1174
|
+
return drift
|
|
1175
|
+
elif txn.stage is not ReplanStage.PENDING:
|
|
1176
|
+
return "the replan transaction names no source PR, so there is nothing it may act on"
|
|
1177
|
+
if not txn.decision_pr_url:
|
|
1178
|
+
return (
|
|
1179
|
+
"the replan transaction does not record the PR whose review decided it, so the "
|
|
1180
|
+
"source cannot be bound to that decision"
|
|
1181
|
+
)
|
|
1182
|
+
drift = _bind_to_run("decision PR", txn.decision_pr_url, repository, held)
|
|
1183
|
+
if drift:
|
|
1184
|
+
return drift
|
|
1185
|
+
return _bind_issue_to_run(txn, repository, current_issue_url)
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
def _bind_issue_to_run(txn: ReplanTransaction, repository: str, current_issue_url: str) -> str:
|
|
1189
|
+
"""The issue the journal names must be the issue the run is working on."""
|
|
1190
|
+
try:
|
|
1191
|
+
held = parse_issue_url(current_issue_url)
|
|
1192
|
+
except ConfigurationError as exc:
|
|
1193
|
+
return (
|
|
1194
|
+
f"the run's current issue URL is unusable ({exc}), so the replan transaction "
|
|
1195
|
+
"cannot be bound to it"
|
|
1196
|
+
)
|
|
1197
|
+
if not txn.issue_url:
|
|
1198
|
+
return (
|
|
1199
|
+
"the replan transaction does not record the issue whose review decided it, so "
|
|
1200
|
+
"the replacement cannot be required to belong to the same lifecycle as the source"
|
|
1201
|
+
)
|
|
1202
|
+
try:
|
|
1203
|
+
ref = parse_issue_url(txn.issue_url)
|
|
1204
|
+
except ConfigurationError as exc:
|
|
1205
|
+
return f"the replan issue URL is unusable: {exc}"
|
|
1206
|
+
if not ref.same_repository(repository):
|
|
1207
|
+
return (
|
|
1208
|
+
f"replan issue {ref.canonical} is not in {repository}; the transaction does not "
|
|
1209
|
+
"belong to this run"
|
|
1210
|
+
)
|
|
1211
|
+
if not ref.same_target(held):
|
|
1212
|
+
return (
|
|
1213
|
+
f"replan issue {ref.canonical} is not the run's current issue {held.canonical}; "
|
|
1214
|
+
"the transaction does not belong to this run"
|
|
1215
|
+
)
|
|
1216
|
+
return ""
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def _bind_to_run(what: str, url: str, repository: str, held: GitHubPullRequestRef) -> str:
|
|
1220
|
+
"""One identity rule for every PR the journal names as the source."""
|
|
1221
|
+
try:
|
|
1222
|
+
ref = parse_pr_url(url)
|
|
1223
|
+
except ConfigurationError as exc:
|
|
1224
|
+
return f"the {what} URL is unusable: {exc}"
|
|
1225
|
+
if not ref.same_repository(repository):
|
|
1226
|
+
return (
|
|
1227
|
+
f"{what} {ref.canonical} is not in {repository}; the transaction does not belong "
|
|
1228
|
+
"to this run"
|
|
1229
|
+
)
|
|
1230
|
+
if not ref.same_target(held):
|
|
1231
|
+
return (
|
|
1232
|
+
f"{what} {ref.canonical} is not the run's current PR {held.canonical}; the "
|
|
1233
|
+
"transaction does not belong to this run"
|
|
1234
|
+
)
|
|
1235
|
+
return ""
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def verify_decision_point(pr: PRInfo, txn: ReplanTransaction, merge_base_sha: str) -> str:
|
|
1239
|
+
"""The source must still be the revision whose review decided the replan.
|
|
1240
|
+
|
|
1241
|
+
Checked once, before the transaction is prepared. ``REVIEW`` records the
|
|
1242
|
+
HEAD and branch it reviewed; the checkpoint is then taken from GitHub, and
|
|
1243
|
+
between those two moments a human (or a stray push) can move the source.
|
|
1244
|
+
Superseding the moved source would close a revision this replan decision
|
|
1245
|
+
never saw -- the accumulated findings belong to the reviewed revision, not
|
|
1246
|
+
to whatever is on the branch now.
|
|
1247
|
+
|
|
1248
|
+
The PR read must also *be* the PR the decision names: the prepare step
|
|
1249
|
+
reads it from ``state.current_pr_url``, and :func:`verify_run_binding`
|
|
1250
|
+
has bound that to the decision, but the identity GitHub reports is the
|
|
1251
|
+
fact, so it is compared here rather than assumed.
|
|
1252
|
+
|
|
1253
|
+
The base branch is compared with the HEAD and the branch: the diff the
|
|
1254
|
+
review saw is HEAD against base, and retargeting the PR changes it at
|
|
1255
|
+
an unchanged HEAD (#68). The merge base is compared with all three:
|
|
1256
|
+
``merge_base_sha`` is what GitHub reports for the PR's base and HEAD
|
|
1257
|
+
now, and a base rewritten under its name moves it while the other three
|
|
1258
|
+
read back equal, which is the same new diff by another route (#96).
|
|
1259
|
+
|
|
1260
|
+
An empty ``decision_pr_url``, ``decision_head_sha``, ``decision_branch``,
|
|
1261
|
+
``decision_base_ref`` or ``decision_merge_base_sha`` is itself a
|
|
1262
|
+
refusal: without all five there is no proof the checkpoint is the
|
|
1263
|
+
decision point. :meth:`ReplanTransaction.from_dict` refuses such a
|
|
1264
|
+
journal at every stage; this is the same rule at the point of use, never
|
|
1265
|
+
vacuous.
|
|
1266
|
+
"""
|
|
1267
|
+
if not txn.decision_pr_url:
|
|
1268
|
+
return (
|
|
1269
|
+
"the replan transaction does not record the PR whose review decided it, so the "
|
|
1270
|
+
"source cannot be proven to be the PR the findings belong to"
|
|
1271
|
+
)
|
|
1272
|
+
if not same_pr_url(pr.url, txn.decision_pr_url):
|
|
1273
|
+
return (
|
|
1274
|
+
f"source PR read back as {pr.url or '(none)'}, but the review that decided this "
|
|
1275
|
+
f"replan ran on {txn.decision_pr_url}; the current work was never reviewed "
|
|
1276
|
+
"against this decision"
|
|
1277
|
+
)
|
|
1278
|
+
if not txn.decision_head_sha:
|
|
1279
|
+
return (
|
|
1280
|
+
"the replan transaction does not record the reviewed HEAD that decided it, so the "
|
|
1281
|
+
"source cannot be proven to be the revision the findings belong to"
|
|
1282
|
+
)
|
|
1283
|
+
if not txn.decision_branch:
|
|
1284
|
+
return (
|
|
1285
|
+
"the replan transaction does not record the reviewed branch that decided it, so the "
|
|
1286
|
+
"source cannot be proven to be the revision the findings belong to"
|
|
1287
|
+
)
|
|
1288
|
+
if not txn.decision_base_ref:
|
|
1289
|
+
return (
|
|
1290
|
+
"the replan transaction does not record the base branch the review that decided it "
|
|
1291
|
+
"was bound to, so the source cannot be proven to be the change the findings "
|
|
1292
|
+
"belong to"
|
|
1293
|
+
)
|
|
1294
|
+
if not txn.decision_merge_base_sha:
|
|
1295
|
+
return (
|
|
1296
|
+
"the replan transaction does not record the merge base the review that decided it "
|
|
1297
|
+
"was bound to, so the source cannot be proven to be the diff the findings belong to"
|
|
1298
|
+
)
|
|
1299
|
+
if not _same_sha(pr.head_sha, txn.decision_head_sha):
|
|
1300
|
+
return (
|
|
1301
|
+
f"source PR {txn.source_pr_url} is at HEAD {pr.head_sha or '(unreadable)'}, but the "
|
|
1302
|
+
f"review that decided this replan ran on {txn.decision_head_sha}; the current work "
|
|
1303
|
+
"was never reviewed against this decision"
|
|
1304
|
+
)
|
|
1305
|
+
if pr.head_ref != txn.decision_branch:
|
|
1306
|
+
return (
|
|
1307
|
+
f"source PR {txn.source_pr_url} is on branch {pr.head_ref!r}, but the review that "
|
|
1308
|
+
f"decided this replan ran on {txn.decision_branch!r}"
|
|
1309
|
+
)
|
|
1310
|
+
if pr.base_ref != txn.decision_base_ref:
|
|
1311
|
+
return (
|
|
1312
|
+
f"source PR {txn.source_pr_url} targets base {pr.base_ref!r}, but the review that "
|
|
1313
|
+
f"decided this replan was bound to base {txn.decision_base_ref!r}; the current "
|
|
1314
|
+
"change was never reviewed against this decision"
|
|
1315
|
+
)
|
|
1316
|
+
if not _same_sha(merge_base_sha, txn.decision_merge_base_sha):
|
|
1317
|
+
return (
|
|
1318
|
+
f"source PR {txn.source_pr_url} has merge base {merge_base_sha or '(unreadable)'} "
|
|
1319
|
+
f"with base {pr.base_ref!r}, but the review that decided this replan was bound to "
|
|
1320
|
+
f"merge base {txn.decision_merge_base_sha}; base {pr.base_ref!r} was rewritten "
|
|
1321
|
+
"under its name and the current diff was never reviewed against this decision"
|
|
1322
|
+
)
|
|
1323
|
+
return ""
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def verify_source_checkpoint(pr: PRInfo, txn: ReplanTransaction, merge_base_sha: str) -> str:
|
|
1327
|
+
"""The source PR must still be *exactly* what the transaction checkpointed.
|
|
1328
|
+
|
|
1329
|
+
Closing a PR is irreversible for the review evidence it carries, so this
|
|
1330
|
+
is the compare half of a compare-and-swap: identity, OPEN state, branch,
|
|
1331
|
+
base, the exact checkpointed HEAD and the merge base GitHub reports for
|
|
1332
|
+
that base and HEAD now (``merge_base_sha``, read by the caller beside the
|
|
1333
|
+
PR). Any drift — including a push that landed after the checkpoint, a
|
|
1334
|
+
retarget to another base, a base rewritten under its name, and a close
|
|
1335
|
+
performed by someone else — means the controller is no longer looking at
|
|
1336
|
+
the implementation it decided to replace, and must not close it.
|
|
1337
|
+
"""
|
|
1338
|
+
if not same_pr_url(pr.url, txn.source_pr_url):
|
|
1339
|
+
return (
|
|
1340
|
+
f"source PR identity mismatch: read {pr.url or '(none)'}, "
|
|
1341
|
+
f"checkpoint {txn.source_pr_url}"
|
|
1342
|
+
)
|
|
1343
|
+
if pr.state == "MERGED":
|
|
1344
|
+
return f"source PR {txn.source_pr_url} is already MERGED; refusing to supersede it"
|
|
1345
|
+
if not pr.is_open:
|
|
1346
|
+
return (
|
|
1347
|
+
f"source PR {txn.source_pr_url} is {pr.state or '(unknown)'}, expected OPEN at the "
|
|
1348
|
+
"checkpoint; it was not closed by this transaction"
|
|
1349
|
+
)
|
|
1350
|
+
drift = _source_branch_drift(pr, txn) or _source_base_drift(pr, txn)
|
|
1351
|
+
if drift:
|
|
1352
|
+
return drift
|
|
1353
|
+
if not _same_sha(pr.head_sha, txn.source_head_sha):
|
|
1354
|
+
return (
|
|
1355
|
+
f"source PR {txn.source_pr_url} advanced from the checkpointed HEAD "
|
|
1356
|
+
f"{txn.source_head_sha} to {pr.head_sha or '(unreadable)'}; the newer work was never "
|
|
1357
|
+
"reviewed against this replan decision"
|
|
1358
|
+
)
|
|
1359
|
+
drift = _source_merge_base_drift(merge_base_sha, txn)
|
|
1360
|
+
if drift:
|
|
1361
|
+
return drift
|
|
1362
|
+
# Defence in depth: the checkpoint is only allowed to hold the revision the
|
|
1363
|
+
# review decided on, so a journal in which the two disagree is unusable.
|
|
1364
|
+
if txn.decision_head_sha and not _same_sha(txn.source_head_sha, txn.decision_head_sha):
|
|
1365
|
+
return (
|
|
1366
|
+
f"checkpointed source HEAD {txn.source_head_sha} is not the reviewed HEAD "
|
|
1367
|
+
f"{txn.decision_head_sha} that decided this replan"
|
|
1368
|
+
)
|
|
1369
|
+
if txn.decision_base_ref and txn.source_base_ref != txn.decision_base_ref:
|
|
1370
|
+
return (
|
|
1371
|
+
f"checkpointed source base {txn.source_base_ref!r} is not the base "
|
|
1372
|
+
f"{txn.decision_base_ref!r} the review that decided this replan was bound to"
|
|
1373
|
+
)
|
|
1374
|
+
if txn.decision_merge_base_sha and not _same_sha(
|
|
1375
|
+
txn.source_merge_base_sha, txn.decision_merge_base_sha
|
|
1376
|
+
):
|
|
1377
|
+
return (
|
|
1378
|
+
f"checkpointed source merge base {txn.source_merge_base_sha} is not the merge base "
|
|
1379
|
+
f"{txn.decision_merge_base_sha} the review that decided this replan was bound to"
|
|
1380
|
+
)
|
|
1381
|
+
return ""
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def verify_target_pr(
|
|
1385
|
+
pr: PRInfo,
|
|
1386
|
+
txn: ReplanTransaction,
|
|
1387
|
+
repository: str,
|
|
1388
|
+
*,
|
|
1389
|
+
require_checkpoint_head: bool,
|
|
1390
|
+
) -> str:
|
|
1391
|
+
"""Objective facts a replacement must satisfy, re-derived from GitHub.
|
|
1392
|
+
|
|
1393
|
+
``require_checkpoint_head`` is off while the candidate is first being
|
|
1394
|
+
bound (its HEAD and branch *become* the checkpoint) and on for every later
|
|
1395
|
+
read, including the final read immediately before the destructive write;
|
|
1396
|
+
on those reads the checkpointed branch must exist as well as match, for
|
|
1397
|
+
the reason :func:`_source_branch_drift` gives for the source.
|
|
1398
|
+
"""
|
|
1399
|
+
ref_canonical = _canonical(pr.url)
|
|
1400
|
+
if not ref_canonical:
|
|
1401
|
+
return f"replacement PR URL {pr.url!r} is not a pull-request URL"
|
|
1402
|
+
if same_pr_url(ref_canonical, txn.source_pr_url):
|
|
1403
|
+
return "replacement PR must differ from the superseded PR"
|
|
1404
|
+
if txn.replacement_pr_url and not same_pr_url(ref_canonical, txn.replacement_pr_url):
|
|
1405
|
+
return (
|
|
1406
|
+
f"replacement PR identity mismatch: read {ref_canonical}, "
|
|
1407
|
+
f"checkpoint {txn.replacement_pr_url}"
|
|
1408
|
+
)
|
|
1409
|
+
pr_repo = parse_pr_url(ref_canonical).repository
|
|
1410
|
+
if pr_repo.lower() != repository.lower() or (
|
|
1411
|
+
pr.repository and pr.repository.lower() != repository.lower()
|
|
1412
|
+
):
|
|
1413
|
+
return f"replacement PR {ref_canonical} is not in {repository}"
|
|
1414
|
+
if not pr.is_open:
|
|
1415
|
+
return f"replacement PR {ref_canonical} is {pr.state or '(unknown)'}, expected OPEN"
|
|
1416
|
+
if not pr.head_sha:
|
|
1417
|
+
return f"replacement PR {ref_canonical} has no readable head SHA"
|
|
1418
|
+
if not pr.head_ref or pr.head_ref == txn.source_branch:
|
|
1419
|
+
return "replacement branch must be present and differ from the superseded branch"
|
|
1420
|
+
if require_checkpoint_head and not txn.replacement_branch:
|
|
1421
|
+
return (
|
|
1422
|
+
f"the replan transaction records no branch for replacement PR {ref_canonical}, so "
|
|
1423
|
+
"the verified checkpoint is incomplete"
|
|
1424
|
+
)
|
|
1425
|
+
if txn.replacement_branch and pr.head_ref != txn.replacement_branch:
|
|
1426
|
+
return (
|
|
1427
|
+
f"replacement PR {ref_canonical} moved from the checkpointed branch "
|
|
1428
|
+
f"{txn.replacement_branch!r} to {pr.head_ref!r}"
|
|
1429
|
+
)
|
|
1430
|
+
if pr.base_ref != txn.base_branch:
|
|
1431
|
+
return f"replacement PR base {pr.base_ref!r} != verified default branch {txn.base_branch!r}"
|
|
1432
|
+
try:
|
|
1433
|
+
issue = parse_issue_url(txn.issue_url)
|
|
1434
|
+
except ConfigurationError as exc:
|
|
1435
|
+
return f"replan transaction records no usable issue URL ({exc})"
|
|
1436
|
+
if not issue.same_repository(repository):
|
|
1437
|
+
# Linkage is a number within one repository, so an issue of another
|
|
1438
|
+
# repository can never be what a PR here is linked to.
|
|
1439
|
+
# ``verify_run_binding`` refuses such a journal before this is
|
|
1440
|
+
# reached; this is the same rule at the point of use.
|
|
1441
|
+
return f"replan issue {issue.canonical} is not in {repository}"
|
|
1442
|
+
if issue.number not in pr.linked_issue_numbers:
|
|
1443
|
+
return f"replacement PR {ref_canonical} is not linked to issue #{issue.number}"
|
|
1444
|
+
# The replacement is the issue's implementation PR from here on, and the
|
|
1445
|
+
# issue's implementation PR is identified by one durable claim everywhere
|
|
1446
|
+
# (``claims.IMPLEMENTATION``): ANALYZE_EXECUTE adopts the open PR carrying
|
|
1447
|
+
# it and refuses to persist one without it. A replacement without the
|
|
1448
|
+
# marker would be activated here and then be invisible to the next entry
|
|
1449
|
+
# after a lost state file, which launches a second implementation.
|
|
1450
|
+
marked = scan(IMPLEMENTATION, pr.body or "")
|
|
1451
|
+
if marked.defects:
|
|
1452
|
+
return (
|
|
1453
|
+
f"replacement PR {ref_canonical} carries an unreadable ai-implementation marker "
|
|
1454
|
+
f"({'; '.join(marked.defects)})"
|
|
1455
|
+
)
|
|
1456
|
+
if not marked.claims:
|
|
1457
|
+
return (
|
|
1458
|
+
f"replacement PR {ref_canonical} does not carry the ai-implementation marker for "
|
|
1459
|
+
f"issue #{issue.number}; without it no later entry could find it as the issue's PR"
|
|
1460
|
+
)
|
|
1461
|
+
if marked.claims[0].issue.identity != issue.identity:
|
|
1462
|
+
return (
|
|
1463
|
+
f"replacement PR {ref_canonical} carries the ai-implementation marker of "
|
|
1464
|
+
f"{marked.claims[0].issue.canonical}, not of issue #{issue.number}"
|
|
1465
|
+
)
|
|
1466
|
+
if require_checkpoint_head and not _same_sha(pr.head_sha, txn.replacement_head_sha):
|
|
1467
|
+
return (
|
|
1468
|
+
f"replacement PR {ref_canonical} advanced from the verified HEAD "
|
|
1469
|
+
f"{txn.replacement_head_sha} to {pr.head_sha}; it is no longer the implementation "
|
|
1470
|
+
"the controller verified"
|
|
1471
|
+
)
|
|
1472
|
+
return ""
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def verify_sole_implementation_claimant(
|
|
1476
|
+
open_prs: list[PRInfo], target_url: str, txn: ReplanTransaction, repository: str
|
|
1477
|
+
) -> str:
|
|
1478
|
+
"""The replacement is the issue's *only* open implementation claimant, or why not.
|
|
1479
|
+
|
|
1480
|
+
:func:`verify_target_pr` proves the replacement's own body carries the
|
|
1481
|
+
issue's ``ai-implementation`` marker; this proves nothing else does. The
|
|
1482
|
+
marker is the identity every ``ANALYZE_EXECUTE`` entry and read-back
|
|
1483
|
+
finds the issue's PR by, and both refuse two claimants rather than
|
|
1484
|
+
choose (github-safety.md, "Before/After ANALYZE_EXECUTE"). A replan that
|
|
1485
|
+
installed one of two would therefore activate a PR precisely where the
|
|
1486
|
+
entry fails closed, and the run would carry toward merge a choice the
|
|
1487
|
+
controller never makes. So the same strict open listing the entry reads
|
|
1488
|
+
is asked the same question at every checkpoint of the transaction:
|
|
1489
|
+
exactly one open PR carries the marker, and it is the replacement.
|
|
1490
|
+
|
|
1491
|
+
The source is left out of the question, claims and defects alike: it is
|
|
1492
|
+
the PR the replan is closing, it legitimately carries the marker (the
|
|
1493
|
+
read-back that accepted it required it to), and the question is what
|
|
1494
|
+
the entry will find once it is gone. Before the close that is a
|
|
1495
|
+
prediction; after it, the listing no longer holds the source anyway.
|
|
1496
|
+
Every other open PR counts, whatever its branch, linkage or age: a
|
|
1497
|
+
second marked PR is ambiguity and an unreadable marker anywhere is
|
|
1498
|
+
inconclusive, exactly as at entry.
|
|
1499
|
+
|
|
1500
|
+
``open_prs`` must be a *complete* listing (one the client could not read
|
|
1501
|
+
to the end is refused before it reaches here), because "nothing else
|
|
1502
|
+
claims the issue" is not knowable from a partial set.
|
|
1503
|
+
"""
|
|
1504
|
+
try:
|
|
1505
|
+
issue = parse_issue_url(txn.issue_url)
|
|
1506
|
+
except ConfigurationError as exc:
|
|
1507
|
+
return f"replan transaction records no usable issue URL ({exc})"
|
|
1508
|
+
if not issue.same_repository(repository):
|
|
1509
|
+
return f"replan issue {issue.canonical} is not in {repository}"
|
|
1510
|
+
others = [pr for pr in open_prs if not same_pr_url(pr.url, txn.source_pr_url)]
|
|
1511
|
+
claimants = collect(IMPLEMENTATION, others, "open PR").claimants(
|
|
1512
|
+
issue.identity, f"issue {issue.canonical}"
|
|
1513
|
+
)
|
|
1514
|
+
try:
|
|
1515
|
+
holder = claimants.exactly_one()
|
|
1516
|
+
except ClaimConflictError as exc:
|
|
1517
|
+
return (
|
|
1518
|
+
f"the replacement cannot be shown to be the one open PR implementing issue "
|
|
1519
|
+
f"#{issue.number} (the superseded PR aside): {exc}; the next ANALYZE_EXECUTE "
|
|
1520
|
+
"entry would refuse to choose, so the replan does not either"
|
|
1521
|
+
)
|
|
1522
|
+
if not same_pr_url(holder.obj.url, target_url):
|
|
1523
|
+
return (
|
|
1524
|
+
f"the open PR carrying the ai-implementation marker for issue #{issue.number} is "
|
|
1525
|
+
f"{holder.obj.url}, not the replacement {target_url}"
|
|
1526
|
+
)
|
|
1527
|
+
return ""
|
|
1528
|
+
|
|
1529
|
+
|
|
1530
|
+
def verify_attestation(attestation: ReplanAttestation, txn: ReplanTransaction) -> str:
|
|
1531
|
+
"""The agent's published claim must satisfy the controller's requirements.
|
|
1532
|
+
|
|
1533
|
+
``findings_considered`` is the acknowledgement invariant: the replacement
|
|
1534
|
+
lifecycle may only supersede the source once the agent has stated it
|
|
1535
|
+
accounted for every actionable finding the controller preserved. The
|
|
1536
|
+
controller never lowers that requirement — a transaction is only prepared
|
|
1537
|
+
when the evidence is provably complete.
|
|
1538
|
+
"""
|
|
1539
|
+
if attestation.transaction_id != txn.transaction_id:
|
|
1540
|
+
return (
|
|
1541
|
+
f"attestation belongs to transaction {attestation.transaction_id!r}, "
|
|
1542
|
+
f"not {txn.transaction_id!r}"
|
|
1543
|
+
)
|
|
1544
|
+
if attestation.execution_attempt != txn.expected_execution_attempt:
|
|
1545
|
+
return (
|
|
1546
|
+
f"attested execution_attempt {attestation.execution_attempt} != "
|
|
1547
|
+
f"{txn.expected_execution_attempt} expected by the controller"
|
|
1548
|
+
)
|
|
1549
|
+
if not attestation.tests_passed:
|
|
1550
|
+
return "the replacement attests tests_passed=false"
|
|
1551
|
+
if attestation.findings_considered < txn.evidence_finding_count:
|
|
1552
|
+
return (
|
|
1553
|
+
f"the replacement attests it considered {attestation.findings_considered} of the "
|
|
1554
|
+
f"{txn.evidence_finding_count} historical finding(s) the controller preserved"
|
|
1555
|
+
)
|
|
1556
|
+
if attestation.unique_constraints > attestation.findings_considered:
|
|
1557
|
+
return (
|
|
1558
|
+
f"attested unique_constraints {attestation.unique_constraints} exceeds "
|
|
1559
|
+
f"findings_considered {attestation.findings_considered}"
|
|
1560
|
+
)
|
|
1561
|
+
return ""
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def verify_closed_source(pr: PRInfo, txn: ReplanTransaction, merge_base_sha: str) -> str:
|
|
1565
|
+
"""The source, re-read *after* the controller's close, is still the one it closed.
|
|
1566
|
+
|
|
1567
|
+
``gh pr close`` carries no precondition, so the swap half of the
|
|
1568
|
+
compare-and-swap is completed here instead: everything the pre-close read
|
|
1569
|
+
established must still be true of the PR that is now closed, except the
|
|
1570
|
+
state itself. A HEAD or branch that moved means a push landed inside the
|
|
1571
|
+
write window, a base that moved means a retarget did, and a merge base
|
|
1572
|
+
that moved (``merge_base_sha``, read by the caller for the closed PR's
|
|
1573
|
+
base and HEAD) means the base was rewritten under its name, i.e. the
|
|
1574
|
+
close destroyed the visibility of a change that no review ever saw -- the
|
|
1575
|
+
caller undoes the close rather than accepting it.
|
|
1576
|
+
"""
|
|
1577
|
+
if not same_pr_url(pr.url, txn.source_pr_url):
|
|
1578
|
+
return (
|
|
1579
|
+
f"source PR identity mismatch after the close: read {pr.url or '(none)'}, "
|
|
1580
|
+
f"checkpoint {txn.source_pr_url}"
|
|
1581
|
+
)
|
|
1582
|
+
if pr.state != "CLOSED":
|
|
1583
|
+
return (
|
|
1584
|
+
f"source PR {txn.source_pr_url} is {pr.state or '(unknown)'} after this "
|
|
1585
|
+
"transaction's close attempt; expected CLOSED"
|
|
1586
|
+
)
|
|
1587
|
+
when = " inside the close window"
|
|
1588
|
+
drift = _source_branch_drift(pr, txn, when) or _source_base_drift(pr, txn, when)
|
|
1589
|
+
if drift:
|
|
1590
|
+
return drift
|
|
1591
|
+
if not _same_sha(pr.head_sha, txn.source_head_sha):
|
|
1592
|
+
return (
|
|
1593
|
+
f"source PR {txn.source_pr_url} advanced from the checkpointed HEAD "
|
|
1594
|
+
f"{txn.source_head_sha} to {pr.head_sha or '(unreadable)'} inside the close window; "
|
|
1595
|
+
"the newer work was never reviewed against this replan decision"
|
|
1596
|
+
)
|
|
1597
|
+
return _source_merge_base_drift(merge_base_sha, txn, when)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def verify_close_never_ran(closed_event_count: int, txn: ReplanTransaction) -> str:
|
|
1601
|
+
"""Why a source found OPEN under a recorded intent may not be closed now, or ``""``.
|
|
1602
|
+
|
|
1603
|
+
``SUPERSEDE_INTENT`` proves an intended write, never a performed one, and
|
|
1604
|
+
the receipt proves a performed one only when it was published: a crash
|
|
1605
|
+
between ``gh pr close`` landing and the receipt, followed by a human
|
|
1606
|
+
reopen, leaves an OPEN source with no receipt -- the same evidence a crash
|
|
1607
|
+
*before* the close leaves -- and only the first of those is a human
|
|
1608
|
+
decision a retry would override (R11-F1). Absence of a comment therefore
|
|
1609
|
+
never licenses the write. What does is GitHub's own timeline: the intent
|
|
1610
|
+
records the source's ``closed`` issue-event count, read immediately
|
|
1611
|
+
before it was persisted, and issue events are append-only and cannot be
|
|
1612
|
+
deleted by anyone, so ``closed_event_count`` -- the same count read now
|
|
1613
|
+
-- is proof-grade either way (#69):
|
|
1614
|
+
|
|
1615
|
+
- higher than the watermark: a close landed after the intent, this
|
|
1616
|
+
transaction's or a human's, and the PR is OPEN again, so a reopen
|
|
1617
|
+
followed it and is the last word. Refused; never closed again.
|
|
1618
|
+
- lower: impossible for an append-only log, so the watermark is not this
|
|
1619
|
+
PR's (a substituted journal) or the listing was not read whole. Refused.
|
|
1620
|
+
- equal: no close of any kind has happened since the intent. The write
|
|
1621
|
+
never ran, and the caller may perform it -- through the same pre-close
|
|
1622
|
+
revalidation as the first attempt, and at most ``MAX_CLOSE_ATTEMPTS``
|
|
1623
|
+
times in total, so a close that keeps failing to land is handed to a
|
|
1624
|
+
human rather than retried indefinitely.
|
|
1625
|
+
|
|
1626
|
+
Pure: the caller reads the count and persists the incremented attempt
|
|
1627
|
+
before the write, exactly as the first intent was persisted.
|
|
1628
|
+
"""
|
|
1629
|
+
if txn.stage is not ReplanStage.SUPERSEDE_INTENT:
|
|
1630
|
+
return (
|
|
1631
|
+
f"the replan transaction is at stage {txn.stage.value!r}, not under a recorded "
|
|
1632
|
+
"close intent, so there is no close to retry"
|
|
1633
|
+
)
|
|
1634
|
+
if closed_event_count > txn.source_closed_event_count:
|
|
1635
|
+
return (
|
|
1636
|
+
f"source PR {txn.source_pr_url} is open under a recorded close intent for replan "
|
|
1637
|
+
f"transaction {txn.transaction_id}, and its closed-event count rose from "
|
|
1638
|
+
f"{txn.source_closed_event_count} at the intent to {closed_event_count}: a close "
|
|
1639
|
+
"landed after the intent (this transaction's, or a human's) and the PR was reopened "
|
|
1640
|
+
"afterwards, so the reopen is the last word and the controller will not close it "
|
|
1641
|
+
"again"
|
|
1642
|
+
)
|
|
1643
|
+
if closed_event_count < txn.source_closed_event_count:
|
|
1644
|
+
return (
|
|
1645
|
+
f"source PR {txn.source_pr_url} reports {closed_event_count} closed event(s), fewer "
|
|
1646
|
+
f"than the {txn.source_closed_event_count} the close intent recorded; issue events "
|
|
1647
|
+
"cannot be deleted, so the recorded count is not this PR's or the listing was not "
|
|
1648
|
+
"read whole, and the controller will not close on it"
|
|
1649
|
+
)
|
|
1650
|
+
if txn.close_attempts >= MAX_CLOSE_ATTEMPTS:
|
|
1651
|
+
return (
|
|
1652
|
+
f"source PR {txn.source_pr_url} is still open after {txn.close_attempts} close "
|
|
1653
|
+
f"attempt(s) under replan transaction {txn.transaction_id}, none of which landed; "
|
|
1654
|
+
"the close is retried at most once, so a human must look at why it does not land"
|
|
1655
|
+
)
|
|
1656
|
+
return ""
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def verify_target_marker(pr: PRInfo, txn: ReplanTransaction) -> str:
|
|
1660
|
+
"""The replacement's body still *proves* it is this transaction's replacement.
|
|
1661
|
+
|
|
1662
|
+
:func:`verify_target_pr` re-derives the objective facts (identity, state,
|
|
1663
|
+
branch, base, linkage, HEAD); none of them is provenance. The marker is,
|
|
1664
|
+
and a body can be edited after the controller verified it: dropping the
|
|
1665
|
+
marker, or restating the attestation with different numbers, leaves every
|
|
1666
|
+
objective fact valid while removing the only evidence that this PR belongs
|
|
1667
|
+
to this replan. So the body is re-scanned on the final read, under exactly
|
|
1668
|
+
the rules that bound it in the first place -- :func:`sole_attestation`,
|
|
1669
|
+
literally the same function: one usable marker for this transaction and
|
|
1670
|
+
nothing marker-shaped beside it, a valid marker for another transaction
|
|
1671
|
+
included -- plus equality with the attestation the controller checkpointed
|
|
1672
|
+
at ``VERIFIED``.
|
|
1673
|
+
"""
|
|
1674
|
+
scan = scan_replan_markers(pr.body or "")
|
|
1675
|
+
ref = _canonical(pr.url) or pr.url or "(none)"
|
|
1676
|
+
attestation, defect = sole_attestation(scan, txn.transaction_id, ref, "replacement PR")
|
|
1677
|
+
if defect:
|
|
1678
|
+
return defect
|
|
1679
|
+
if attestation is None:
|
|
1680
|
+
if scan.malformed:
|
|
1681
|
+
return (
|
|
1682
|
+
f"replacement PR {ref} carries an unusable replan marker "
|
|
1683
|
+
f"({'; '.join(scan.malformed)})"
|
|
1684
|
+
)
|
|
1685
|
+
return (
|
|
1686
|
+
f"replacement PR {ref} no longer carries the marker for replan transaction "
|
|
1687
|
+
f"{txn.transaction_id}, so it can no longer be proven to be its replacement"
|
|
1688
|
+
)
|
|
1689
|
+
drift = verify_attestation(attestation, txn)
|
|
1690
|
+
if drift:
|
|
1691
|
+
return drift
|
|
1692
|
+
if (
|
|
1693
|
+
attestation.findings_considered != txn.attested_findings_considered
|
|
1694
|
+
or attestation.unique_constraints != txn.attested_unique_constraints
|
|
1695
|
+
):
|
|
1696
|
+
return (
|
|
1697
|
+
f"replacement PR {ref} now attests findings_considered="
|
|
1698
|
+
f"{attestation.findings_considered}/unique_constraints="
|
|
1699
|
+
f"{attestation.unique_constraints}, but the controller verified "
|
|
1700
|
+
f"{txn.attested_findings_considered}/{txn.attested_unique_constraints}"
|
|
1701
|
+
)
|
|
1702
|
+
return ""
|
|
1703
|
+
|
|
1704
|
+
|
|
1705
|
+
@dataclass(frozen=True)
|
|
1706
|
+
class CandidateSelection:
|
|
1707
|
+
"""The result of looking for this transaction's replacement on GitHub."""
|
|
1708
|
+
|
|
1709
|
+
disposition: Disposition
|
|
1710
|
+
pr: PRInfo | None = None
|
|
1711
|
+
attestation: ReplanAttestation | None = None
|
|
1712
|
+
reason: str = ""
|
|
1713
|
+
|
|
1714
|
+
|
|
1715
|
+
def select_bound_candidate(open_prs: list[PRInfo], txn: ReplanTransaction) -> CandidateSelection:
|
|
1716
|
+
"""Find the one PR causally bound to ``txn``, or fail closed.
|
|
1717
|
+
|
|
1718
|
+
``open_prs`` must be *every* open PR in the repository, not the subset
|
|
1719
|
+
that already looks related to the issue: an agent creates the PR and links
|
|
1720
|
+
it to the issue in separate operations, so a crash between the two leaves
|
|
1721
|
+
a marked replacement that no issue-shaped filter can see. Missing it would
|
|
1722
|
+
report "no candidate" and start a second implementation attempt — the one
|
|
1723
|
+
outcome crash idempotency forbids. Found here, it is judged on its merits
|
|
1724
|
+
instead: :func:`verify_target_pr` refuses it for the missing linkage, and
|
|
1725
|
+
the phase blocks for a human with the PR named.
|
|
1726
|
+
|
|
1727
|
+
Binding is positive proof, never shape: a candidate must carry a marker
|
|
1728
|
+
for this exact transaction id, and must have been *created* after that id
|
|
1729
|
+
existed. Everything else is ignored, so an unrelated open PR neither gets
|
|
1730
|
+
adopted nor blocks a healthy replan. Two bound candidates are ambiguous,
|
|
1731
|
+
and a candidate whose marker is unusable in any way -- malformed, doubled,
|
|
1732
|
+
accompanied by a valid marker for a different transaction, or sitting on a
|
|
1733
|
+
PR that predates the transaction -- is a conclusive rejection.
|
|
1734
|
+
|
|
1735
|
+
The source PR is not adoptable, but it is still *read*: a marker the agent
|
|
1736
|
+
put on the PR being superseded is a refusal, and skipping the body would
|
|
1737
|
+
downgrade it to "no candidate exists" -- the observation that starts a
|
|
1738
|
+
second implementation attempt.
|
|
1739
|
+
|
|
1740
|
+
Novelty is decided by ``pr_number_watermark`` rather than by the snapshot
|
|
1741
|
+
of issue PRs: GitHub numbers PRs from a monotonic per-repository counter,
|
|
1742
|
+
so "number <= watermark" covers every PR that already existed, including
|
|
1743
|
+
ones that were closed, unlinked, or named nothing like this issue at
|
|
1744
|
+
``PREPARED`` and were only linked to it afterwards.
|
|
1745
|
+
"""
|
|
1746
|
+
if not TRANSACTION_ID_RE.match(txn.transaction_id):
|
|
1747
|
+
# No id means nothing can be causally bound to this transaction; a
|
|
1748
|
+
# marker matching "" would otherwise adopt an arbitrary PR.
|
|
1749
|
+
return CandidateSelection(
|
|
1750
|
+
Disposition.REJECTED,
|
|
1751
|
+
reason=(
|
|
1752
|
+
"replan transaction has no usable transaction id; no PR can be proven to be "
|
|
1753
|
+
"its replacement"
|
|
1754
|
+
),
|
|
1755
|
+
)
|
|
1756
|
+
if txn.pr_number_watermark < 1:
|
|
1757
|
+
# Without the watermark there is no proof any candidate postdates the
|
|
1758
|
+
# transaction id, and provenance would fall back to shape.
|
|
1759
|
+
return CandidateSelection(
|
|
1760
|
+
Disposition.REJECTED,
|
|
1761
|
+
reason=(
|
|
1762
|
+
f"replan transaction {txn.transaction_id} has no pull-request number watermark, "
|
|
1763
|
+
"so no PR can be proven to have been created after it"
|
|
1764
|
+
),
|
|
1765
|
+
)
|
|
1766
|
+
# Membership and the source comparison are by GitHub identity: a listing
|
|
1767
|
+
# that spells the repository differently from the checkpoint names the
|
|
1768
|
+
# same PRs. ``source`` is None for an unusable checkpoint and a listed
|
|
1769
|
+
# PR's identity never is, so the two can never compare equal.
|
|
1770
|
+
preexisting = {ident for url in txn.preexisting_pr_urls if (ident := _identity(url))}
|
|
1771
|
+
source = _identity(txn.source_pr_url)
|
|
1772
|
+
bound: list[tuple[PRInfo, ReplanAttestation]] = []
|
|
1773
|
+
malformed: list[str] = []
|
|
1774
|
+
for pr in open_prs:
|
|
1775
|
+
canonical = _canonical(pr.url)
|
|
1776
|
+
if not canonical:
|
|
1777
|
+
continue
|
|
1778
|
+
identity = _identity(pr.url)
|
|
1779
|
+
scan = scan_replan_markers(pr.body or "")
|
|
1780
|
+
under_watermark = pr.number <= txn.pr_number_watermark
|
|
1781
|
+
predates = under_watermark or identity in preexisting
|
|
1782
|
+
if identity == source:
|
|
1783
|
+
# Read before it is excluded: a marker the agent put on the PR
|
|
1784
|
+
# being superseded must be refused, not silently reported as
|
|
1785
|
+
# "no candidate" -- see :func:`source_marker_defect`.
|
|
1786
|
+
defect = source_marker_defect(scan, txn.transaction_id, canonical, preexisting=predates)
|
|
1787
|
+
if defect:
|
|
1788
|
+
return CandidateSelection(Disposition.REJECTED, reason=defect)
|
|
1789
|
+
continue
|
|
1790
|
+
mine = [a for a in scan.attestations if a.transaction_id == txn.transaction_id]
|
|
1791
|
+
if predates:
|
|
1792
|
+
if mine:
|
|
1793
|
+
# The id did not exist when this PR was created, so a match
|
|
1794
|
+
# means the marker was copied. Never adopt it.
|
|
1795
|
+
how = (
|
|
1796
|
+
f"its number {pr.number} is at or below the watermark {txn.pr_number_watermark}"
|
|
1797
|
+
if under_watermark
|
|
1798
|
+
else "it was in the snapshot of the issue's open PRs"
|
|
1799
|
+
)
|
|
1800
|
+
return CandidateSelection(
|
|
1801
|
+
Disposition.REJECTED,
|
|
1802
|
+
reason=(
|
|
1803
|
+
f"PR {canonical} already existed when replan transaction "
|
|
1804
|
+
f"{txn.transaction_id} was prepared ({how}) but carries its marker; "
|
|
1805
|
+
"refusing to adopt a pre-existing PR as a replacement"
|
|
1806
|
+
),
|
|
1807
|
+
)
|
|
1808
|
+
continue
|
|
1809
|
+
# Exactly one attestation, and it is ours: a doubled marker, an
|
|
1810
|
+
# unusable one beside the valid one, and a valid marker for another
|
|
1811
|
+
# transaction are all conclusive refusals rather than adoptions.
|
|
1812
|
+
attestation, defect = sole_attestation(scan, txn.transaction_id, canonical)
|
|
1813
|
+
if defect:
|
|
1814
|
+
return CandidateSelection(Disposition.REJECTED, reason=defect)
|
|
1815
|
+
if scan.malformed:
|
|
1816
|
+
malformed.append(f"{canonical}: {'; '.join(scan.malformed)}")
|
|
1817
|
+
if attestation is not None:
|
|
1818
|
+
bound.append((pr, attestation))
|
|
1819
|
+
if len(bound) > 1:
|
|
1820
|
+
urls = ", ".join(sorted(_canonical(pr.url) for pr, _ in bound))
|
|
1821
|
+
return CandidateSelection(
|
|
1822
|
+
Disposition.AMBIGUOUS,
|
|
1823
|
+
reason=(
|
|
1824
|
+
f"{len(bound)} open PRs claim replan transaction {txn.transaction_id}: {urls}. "
|
|
1825
|
+
"The controller cannot tell which one is the replacement"
|
|
1826
|
+
),
|
|
1827
|
+
)
|
|
1828
|
+
if bound:
|
|
1829
|
+
pr, attestation = bound[0]
|
|
1830
|
+
return CandidateSelection(Disposition.OK, pr=pr, attestation=attestation)
|
|
1831
|
+
if malformed:
|
|
1832
|
+
return CandidateSelection(
|
|
1833
|
+
Disposition.REJECTED,
|
|
1834
|
+
reason="unusable replan marker on " + "; ".join(malformed),
|
|
1835
|
+
)
|
|
1836
|
+
return CandidateSelection(Disposition.NONE)
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def find_non_open_claimant(all_prs: list[PRInfo], txn: ReplanTransaction) -> CandidateSelection:
|
|
1840
|
+
"""A marker-bearing non-open PR for ``txn``, or NONE when there is none.
|
|
1841
|
+
|
|
1842
|
+
``select_bound_candidate`` only sees open PRs. A replacement the first
|
|
1843
|
+
agent created and that was then closed (by a human, or by the agent
|
|
1844
|
+
itself) is invisible there, and reporting "no candidate" would invoke the
|
|
1845
|
+
agent a second time -- the one outcome crash idempotency forbids. This
|
|
1846
|
+
scans the exhaustive repository-wide set (open, closed and merged alike)
|
|
1847
|
+
for a PR carrying this transaction's marker that is not open, and turns
|
|
1848
|
+
it into a durable rejection naming the PR, never into absence.
|
|
1849
|
+
|
|
1850
|
+
Pre-existing non-open PRs carrying a copied marker are reported as
|
|
1851
|
+
pre-existing, exactly as the open path does; post-watermark ones are
|
|
1852
|
+
reported with their observed state (CLOSED/MERGED), because they prove a
|
|
1853
|
+
first implementation attempt already happened. A post-watermark PR whose
|
|
1854
|
+
marker is *unusable* is reported the same way rather than skipped: the
|
|
1855
|
+
marker protocol classifies by name, so a complete marker with a garbage
|
|
1856
|
+
payload is broken evidence, never absent evidence. The source PR is read
|
|
1857
|
+
for its own marker here too, for the reason
|
|
1858
|
+
:func:`select_bound_candidate` gives.
|
|
1859
|
+
"""
|
|
1860
|
+
if not TRANSACTION_ID_RE.match(txn.transaction_id):
|
|
1861
|
+
return CandidateSelection(Disposition.NONE)
|
|
1862
|
+
if txn.pr_number_watermark < 1:
|
|
1863
|
+
return CandidateSelection(Disposition.NONE)
|
|
1864
|
+
# Membership and the source comparison are by GitHub identity: a listing
|
|
1865
|
+
# that spells the repository differently from the checkpoint names the
|
|
1866
|
+
# same PRs. ``source`` is None for an unusable checkpoint and a listed
|
|
1867
|
+
# PR's identity never is, so the two can never compare equal.
|
|
1868
|
+
preexisting = {ident for url in txn.preexisting_pr_urls if (ident := _identity(url))}
|
|
1869
|
+
source = _identity(txn.source_pr_url)
|
|
1870
|
+
for pr in all_prs:
|
|
1871
|
+
if pr.is_open:
|
|
1872
|
+
continue
|
|
1873
|
+
canonical = _canonical(pr.url)
|
|
1874
|
+
if not canonical:
|
|
1875
|
+
continue
|
|
1876
|
+
identity = _identity(pr.url)
|
|
1877
|
+
scan = scan_replan_markers(pr.body or "")
|
|
1878
|
+
under_watermark = pr.number <= txn.pr_number_watermark
|
|
1879
|
+
predates = under_watermark or identity in preexisting
|
|
1880
|
+
if identity == source:
|
|
1881
|
+
defect = source_marker_defect(scan, txn.transaction_id, canonical, preexisting=predates)
|
|
1882
|
+
if defect:
|
|
1883
|
+
return CandidateSelection(Disposition.REJECTED, reason=defect)
|
|
1884
|
+
continue
|
|
1885
|
+
mine = [a for a in scan.attestations if a.transaction_id == txn.transaction_id]
|
|
1886
|
+
if not mine and not scan.malformed:
|
|
1887
|
+
continue
|
|
1888
|
+
if predates and not mine:
|
|
1889
|
+
# A PR that predates the transaction id cannot be evidence about
|
|
1890
|
+
# it, and the open path ignores its unusable markers for exactly
|
|
1891
|
+
# the same reason. Only a *copied* valid marker is reported below.
|
|
1892
|
+
continue
|
|
1893
|
+
if not mine:
|
|
1894
|
+
# A complete marker with this name whose payload is not a usable
|
|
1895
|
+
# attestation is evidence of a first attempt, not absence of one.
|
|
1896
|
+
# Classified here as strictly as on the open path, because the
|
|
1897
|
+
# alternative is invoking the agent a second time.
|
|
1898
|
+
return CandidateSelection(
|
|
1899
|
+
Disposition.REJECTED,
|
|
1900
|
+
reason=(
|
|
1901
|
+
f"PR {canonical} carries an unusable replan marker "
|
|
1902
|
+
f"({'; '.join(scan.malformed)}) and is {pr.state or '(unknown)'}, "
|
|
1903
|
+
"expected OPEN; complete marker evidence is never absence, so this "
|
|
1904
|
+
"must be decided by a human, not by invoking the agent again"
|
|
1905
|
+
),
|
|
1906
|
+
)
|
|
1907
|
+
if predates:
|
|
1908
|
+
how = (
|
|
1909
|
+
f"its number {pr.number} is at or below the watermark {txn.pr_number_watermark}"
|
|
1910
|
+
if under_watermark
|
|
1911
|
+
else "it was in the snapshot of the issue's open PRs"
|
|
1912
|
+
)
|
|
1913
|
+
return CandidateSelection(
|
|
1914
|
+
Disposition.REJECTED,
|
|
1915
|
+
reason=(
|
|
1916
|
+
f"PR {canonical} already existed when replan transaction "
|
|
1917
|
+
f"{txn.transaction_id} was prepared ({how}) but carries its marker; "
|
|
1918
|
+
"refusing to adopt a pre-existing PR as a replacement"
|
|
1919
|
+
),
|
|
1920
|
+
)
|
|
1921
|
+
return CandidateSelection(
|
|
1922
|
+
Disposition.REJECTED,
|
|
1923
|
+
reason=(
|
|
1924
|
+
f"PR {canonical} carries the marker for replan transaction "
|
|
1925
|
+
f"{txn.transaction_id} but is {pr.state or '(unknown)'}, expected OPEN; "
|
|
1926
|
+
"a first replacement attempt already exists and must be decided by a human, "
|
|
1927
|
+
"not by invoking the agent again"
|
|
1928
|
+
),
|
|
1929
|
+
)
|
|
1930
|
+
return CandidateSelection(Disposition.NONE)
|