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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -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)