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
autoforge/state.py ADDED
@@ -0,0 +1,2417 @@
1
+ """Persistent controller state with atomic file persistence.
2
+
3
+ State file: ``<state_dir>/state.json`` (default ``.autoforge/state.json``)
4
+ Run logs: ``<state_dir>/logs/<run-id>/``
5
+ Lock file: ``<git common dir>/autoforge/controller.lock`` -- keyed by the
6
+ repository, not by ``state_dir``; see :mod:`autoforge.locking`
7
+
8
+ Saves are atomic (temp file in the same directory + fsync + os.replace) so
9
+ a crash mid-write never leaves a half-written JSON file. A corrupted state
10
+ file (unparseable, wrong protocol, invalid UTF-8, a dangling symlink, or a
11
+ non-regular entry such as a FIFO, socket, device or directory) raises
12
+ StateError with a meaningful message and is never silently overwritten with
13
+ a fresh state: ``run`` refuses (exit 2) unless ``--force`` is given, and
14
+ even then the unreadable entry is moved aside as
15
+ ``state.json.corrupt-<timestamp>`` by :func:`quarantine_state_file` rather
16
+ than deleted (a directory entry cannot be archived automatically and stays
17
+ put with an error). The archive name is reserved by ``link(2)``, or by an
18
+ exclusive create / ``symlink(2)`` where the filesystem has no hard links,
19
+ and the original is unlinked only afterwards; a crash between the two
20
+ leaves ``state.json`` in place beside its archive, and the next
21
+ ``run --force`` archives it again under a new name -- a duplicate copy,
22
+ never a lost one. The entry is inspected with ``lstat``/``fstat`` and
23
+ opened non-blocking before it is read, so a FIFO without a writer fails
24
+ loudly instead of hanging the command. ``run`` inspects, decides, quarantines, writes the first
25
+ state and executes it under one continuous controller lock (``step`` and
26
+ ``resume`` load and execute under it likewise), so the verdict on an
27
+ existing entry is never taken from a view another controller may have
28
+ changed since, and no second controller can take over between the first
29
+ save and the execution.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import re
37
+ import stat
38
+ from dataclasses import asdict, dataclass, field, fields
39
+ from datetime import UTC, datetime
40
+ from pathlib import Path
41
+
42
+ from . import __prompt_version__, __protocol_version__, __version__
43
+ from .config_fingerprint import RecordedConfiguration
44
+ from .effects import (
45
+ ACTION_ID_RE,
46
+ EVIDENCE_CLASSES,
47
+ LABEL_AGENT_PUBLISHES,
48
+ LABEL_NONE,
49
+ LAUNCH_LABELS,
50
+ MAX_CONFLICT_REASON_CHARS,
51
+ OPERATOR_ACTION_ID_RE,
52
+ PUBLISHING_PHASES,
53
+ Binding,
54
+ EffectKind,
55
+ PhaseEffects,
56
+ load_phase_effects,
57
+ )
58
+ from .errors import ConfigurationError, StateError
59
+ from .evidence import EvidenceFields, RecordView, check_evidence
60
+ from .loop_guard import validate_review_history
61
+ from .replan_txn import LEGACY_JOURNAL_PROTOCOLS, legacy_journal_refusal
62
+ from .result_parser import FINDING_ID_RE, MAX_FINDING_ID_CHARS, MAX_OPERATOR_ACTIONS_PER_FIX
63
+ from .run_contract import LocalRunContract
64
+ from .runlog import validate_run_id
65
+ from .safefs import HardLinksUnavailable, ReadLimitExceeded, SafeRoot, entry_kind
66
+ from .sandbox_pin import SandboxPin
67
+ from .transitions import LOCAL_PHASES, LOCAL_WRITE_PHASES, Phase, WorkflowMode
68
+ from .validation import parse_comment_url, parse_issue_url, parse_pr_url
69
+
70
+ STATE_FILENAME = "state.json"
71
+ LOGS_DIRNAME = "logs"
72
+ CORRUPT_SUFFIX = ".corrupt-"
73
+ # What :func:`archive_state_file` appends to ``state.json`` when an operator
74
+ # retires a scheduler namespace's run (``epic archive``, ADR 0008 D17.4).
75
+ ARCHIVED_SUFFIX = ".archived-"
76
+ # Prefix/suffix of the temporary file :func:`save_state` renames into place.
77
+ TMP_PREFIX = ".state-"
78
+ TMP_SUFFIX = ".tmp"
79
+ # The most a state file may be before :func:`load_state` refuses it as
80
+ # corrupt without reading it. Everything the controller persists is bounded
81
+ # (findings per round, resolution text, digests, verification failures), and
82
+ # a real state file is a few tens of kilobytes; the budget is generous so a
83
+ # controller upgrade never trips it, and finite so an oversized or sparse
84
+ # ``state.json`` -- a same-user process can plant either -- is refused
85
+ # rather than materialised into memory.
86
+ MAX_STATE_FILE_BYTES = 64 * 1024 * 1024
87
+ # Bound on the persisted ``block_reason`` (#88), applied by
88
+ # :func:`bound_block_reason` at every engine writer of the field, after
89
+ # redaction. The reason is operator diagnostics -- what ``status`` prints and
90
+ # what a human reads before deciding -- not work a later phase acts on, so it
91
+ # may be clipped, unlike a finding or a resolution (rejected at the parser,
92
+ # never clipped). Without a bound it was the one agent-derived field with no
93
+ # bound of its own: a LOCAL FIX that leaves every finding ``unresolved``
94
+ # concatenates up to ``MAX_RESOLUTIONS_PER_FIX`` rationales of up to
95
+ # ``MAX_REQUIRED_RESOLUTION_CHARS`` each (~300 000 characters), and an agent's
96
+ # ``status: blocked`` ``message`` is bounded only by the whole CONTROL_RESULT
97
+ # block (1 MiB); either would be carried by every later load and rewrite of
98
+ # ``state.json`` and printed whole by ``status``. The bound keeps a couple of
99
+ # full rationales and their surrounding text; what it drops is still in
100
+ # ``last_fix_resolutions`` (persisted whole) or the run log's ``stdout.log``.
101
+ MAX_BLOCK_REASON_CHARS = 8000
102
+ # What :func:`bound_block_reason` keeps from the *end* of an over-long reason:
103
+ # the controller composes its reasons as ``<what happened> (<detail>). <what
104
+ # stays where, what a human must do>``, and the detail is the part that
105
+ # grows, so the head and the tail are the parts that tell the operator what
106
+ # to do and the middle is what is dropped.
107
+ BLOCK_REASON_TAIL_CHARS = 1000
108
+ # Room reserved for the omission marker between the head and the tail. The
109
+ # marker names the count omitted, so its length varies; the reserve covers
110
+ # the longest rendering (a count of at most the state file's own byte bound)
111
+ # and :func:`bound_block_reason` keeps the total at or under
112
+ # ``MAX_BLOCK_REASON_CHARS`` whatever the input.
113
+ _BLOCK_REASON_MARKER_RESERVE = 200
114
+
115
+
116
+ # Names AutoForge gives the entries it writes into a state directory. They
117
+ # are *names*, and nothing here treats a name as evidence of authorship: a
118
+ # LOCAL state directory lives outside the reviewed working tree (see
119
+ # :meth:`autoforge.local_workspace.LocalWorkspace.check_state_dir_location`),
120
+ # so the controller never has to decide whether a file it found is one of its
121
+ # own. The old `is_runtime_artifact` name-shape allowlist existed only to
122
+ # carve a state directory out of the workspace fingerprint, and there is no
123
+ # longer anything to carve.
124
+
125
+
126
+ def utcnow_iso() -> str:
127
+ return datetime.now(UTC).isoformat()
128
+
129
+
130
+ def bound_block_reason(reason: str) -> str:
131
+ """``reason`` at or under :data:`MAX_BLOCK_REASON_CHARS`, head and tail kept.
132
+
133
+ A reason within the bound is returned unchanged. A longer one keeps its
134
+ head, then a marker naming how many characters were omitted, then its
135
+ last :data:`BLOCK_REASON_TAIL_CHARS` characters, so the sentence that
136
+ says what happened and the sentence that says what the operator must do
137
+ both survive and only the middle -- the concatenated detail -- is
138
+ dropped. Call it on *redacted* text: it clips by character count and
139
+ knows nothing about secrets, so a secret cut in half by the clip would
140
+ no longer match its redaction pattern. The engine's writers redact
141
+ first, then bound (:meth:`autoforge.engine.ControllerEngine._block` and
142
+ the two agent-message writers); ``status`` redacts what it prints on top
143
+ of that, as it does for every state field.
144
+ """
145
+ if len(reason) <= MAX_BLOCK_REASON_CHARS:
146
+ return reason
147
+ head_len = MAX_BLOCK_REASON_CHARS - BLOCK_REASON_TAIL_CHARS - _BLOCK_REASON_MARKER_RESERVE
148
+ head = reason[:head_len]
149
+ tail = reason[-BLOCK_REASON_TAIL_CHARS:]
150
+ omitted = len(reason) - len(head) - len(tail)
151
+ marker = (
152
+ f" [autoforge: {omitted} characters of the block reason omitted; the bound is "
153
+ f"{MAX_BLOCK_REASON_CHARS} characters, the first {len(head)} and last {len(tail)} "
154
+ "were kept] "
155
+ )
156
+ bounded = head + marker + tail
157
+ if len(bounded) > MAX_BLOCK_REASON_CHARS:
158
+ # Unreachable while the reserve covers the marker; fail loudly rather
159
+ # than persist an over-long reason if a wording change outgrows it.
160
+ raise StateError(
161
+ f"bounded block reason is {len(bounded)} characters, over the "
162
+ f"{MAX_BLOCK_REASON_CHARS} bound: the omission marker outgrew its reserve"
163
+ )
164
+ return bounded
165
+
166
+
167
+ def _validate_findings_field(name: str, findings: object) -> None:
168
+ """Refuse a persisted findings list (open or prior) that is not one.
169
+
170
+ A persisted finding's id is rendered into controller syntax (the
171
+ follow-up marker of the FIX prompt, the prior-findings block of the
172
+ REVIEW prompt), so the id is held to the parser's rule on load rather
173
+ than crashing the renderer.
174
+ """
175
+ if not isinstance(findings, list) or not all(isinstance(f, dict) for f in findings):
176
+ raise StateError(f"state field {name!r} must be a list of objects")
177
+ for finding in findings:
178
+ fid = finding.get("id")
179
+ if (
180
+ not isinstance(fid, str)
181
+ or len(fid) > MAX_FINDING_ID_CHARS
182
+ or FINDING_ID_RE.match(fid) is None
183
+ ):
184
+ raise StateError(
185
+ f"state field {name!r} holds an entry whose 'id' is not a finding id "
186
+ "of the form R<round>-F<n>"
187
+ )
188
+
189
+
190
+ def _validate_local_pending(state: AutoForgeState) -> None:
191
+ """Check the LOCAL pending-invocation checkpoint is internally consistent.
192
+
193
+ ``local_pending_phase`` records that a write-capable LOCAL phase was
194
+ launched and its work may already be in the working tree; the fingerprint
195
+ beside it is the tree from *before* the first attempt, and the attempt
196
+ count is what bounds re-entry. The three fields are one record, so a file
197
+ carrying only some of them is corruption — and corruption that matters:
198
+ a lost ``local_pending_phase`` makes an already-launched phase look like a
199
+ first invocation (so "the tree is unchanged, nothing was implemented"
200
+ becomes unprovable), while a lost fingerprint silently rebases that
201
+ question onto the tree the failed attempt left behind. A typo in the
202
+ phase value would do the same, which is why it is matched against
203
+ :data:`~autoforge.transitions.LOCAL_WRITE_PHASES` rather than merely being
204
+ a string. And the record belongs to *one* phase entry: a checkpoint for
205
+ FIX under a run whose phase is REVIEW would be closed by the next clean
206
+ review without anyone looking at the work it records, so a mismatch is
207
+ refused unless the run has already ended in BLOCKED or FAILED, where the
208
+ checkpoint is crash evidence rather than a pending resumption (R9-F4).
209
+ """
210
+ pending = state.local_pending_phase
211
+ allowed = ", ".join(p.value for p in LOCAL_WRITE_PHASES)
212
+ if not pending:
213
+ if state.local_pending_fingerprint or state.local_pending_attempts:
214
+ raise StateError(
215
+ "state fields 'local_pending_fingerprint' and 'local_pending_attempts' "
216
+ "must be empty when 'local_pending_phase' is not set (found "
217
+ f"{state.local_pending_fingerprint!r} / {state.local_pending_attempts}); "
218
+ "a pending invocation without the phase it belongs to is corruption"
219
+ )
220
+ return
221
+ try:
222
+ pending_phase = Phase(pending)
223
+ except ValueError:
224
+ raise StateError(
225
+ f"state file has unknown local_pending_phase {pending!r} (expected one of {allowed})"
226
+ ) from None
227
+ if pending_phase not in LOCAL_WRITE_PHASES:
228
+ raise StateError(
229
+ f"state field 'local_pending_phase' must be one of {allowed}, not {pending!r}: "
230
+ "only a write-capable LOCAL phase can leave work in the tree to recover"
231
+ )
232
+ if state.mode != WorkflowMode.LOCAL:
233
+ raise StateError(
234
+ f"state field 'local_pending_phase' is set ({pending!r}) on a "
235
+ f"{state.mode.value} run, which has no local working-tree checkpoint"
236
+ )
237
+ if not state.local_pending_fingerprint:
238
+ raise StateError(
239
+ "state field 'local_pending_fingerprint' must be set whenever "
240
+ f"'local_pending_phase' is ({pending!r}): without the fingerprint from before "
241
+ "the first attempt the controller cannot tell work that was already produced "
242
+ "from work that was never done"
243
+ )
244
+ if state.local_pending_attempts < 1:
245
+ raise StateError(
246
+ "state field 'local_pending_attempts' must be at least 1 whenever "
247
+ f"'local_pending_phase' is ({pending!r}): the checkpoint is written when an "
248
+ "invocation is launched, so zero attempts is corruption"
249
+ )
250
+ if state.phase != pending_phase and state.phase not in (Phase.BLOCKED, Phase.FAILED):
251
+ # The checkpoint belongs to the phase entry that wrote it. A run
252
+ # whose current phase is a different, live phase would resolve that
253
+ # phase and then close a checkpoint it never examined, silently
254
+ # discarding the unverified work the checkpoint exists to recover.
255
+ # A terminal phase may keep it: a run that blocked or failed on top
256
+ # of an unverified launch carries the evidence of that launch, and
257
+ # nothing runs after it that could misread the record.
258
+ raise StateError(
259
+ f"state field 'local_pending_phase' ({pending!r}) does not match the current "
260
+ f"phase {state.phase.value!r}: a pending write-phase checkpoint can only be "
261
+ "resumed by the phase that wrote it, or kept as evidence by a terminal "
262
+ "BLOCKED/FAILED phase"
263
+ )
264
+
265
+
266
+ # Protocol labels an older controller wrote that this one can still read,
267
+ # each subject to the boundary rules its successors introduced (see
268
+ # :meth:`AutoForgeState.from_dict`). Protocols 1-4 changed the replan
269
+ # journal, so those are the journal's; protocols 2 and 3 also changed the
270
+ # review binding (``_REVIEW_BINDING_GAPS``), protocol 4 only the journal.
271
+ # Protocol 5 is the last label before the controller owned external effects
272
+ # (ADR 0004 D13.1, #160): it is the immediate predecessor of protocol 7 (6 is
273
+ # skipped for good, never written) and is loaded under D13.2.
274
+ _LEGACY_PROTOCOLS = LEGACY_JOURNAL_PROTOCOLS | {"5"}
275
+ # The label before the controller could publish an acceptance comment
276
+ # (#211). A protocol-7 file is protocol 8's shape without the acceptance
277
+ # state: it carries the effect fields and is loaded with them, in every
278
+ # phase, and relabelled.
279
+ _PRE_ACCEPTANCE_PROTOCOL = "7"
280
+ # The label before a REMOTE run recorded its configuration (#219). A
281
+ # protocol-8 file is protocol 9's shape without ``config_fingerprint``: it
282
+ # is loaded in every phase with none, which is "not checked", and
283
+ # relabelled.
284
+ _PRE_CONFIG_PROTOCOL = "8"
285
+ # The label before candidate validation evidence (#172, ADR 0006 D15.1). A
286
+ # protocol-9 file is protocol 10's shape without the six Wave 2B fields: it
287
+ # is loaded in every phase with them empty (D15.4), and relabelled.
288
+ _PRE_VALIDATION_PROTOCOL = "9"
289
+ # The label before operator acceptance actions (#243, ADR 0004 D13.10). A
290
+ # protocol-10 file is protocol 11's shape without the operator-action
291
+ # state: it is loaded in every phase with ``operator_comment_outcomes: []``,
292
+ # no ``operator_targets``, ``operator_sources: null`` and
293
+ # ``operator_evidence: []``, and relabelled.
294
+ _PRE_OPERATOR_PROTOCOL = "10"
295
+ # The label before a run pinned its sandbox mode (#280). A protocol-11 file
296
+ # is this protocol's shape without ``sandbox_pin``: it is loaded in every
297
+ # phase with none, which is "not checked", and relabelled.
298
+ _PREVIOUS_PROTOCOL = "11"
299
+ # The fields protocol 7 added (#160): the effect records, the entry
300
+ # observation and the completion context of the current phase entry, and the
301
+ # launch label. A legacy file never carries one; one that does is a hand edit
302
+ # or a foreign file, not an older controller's state.
303
+ _EFFECT_FIELDS = ("effect_records", "entry_observation", "completion_context", "launch_label")
304
+ # The field protocol 8 added (#211): the outcomes of this PR's acceptance
305
+ # actions. Protocol 8 also added a K9 effect kind, an ``acceptance_source``
306
+ # to each FIX context resolution, an ``acceptance_comment_url`` to each
307
+ # REMOTE ``last_fix_resolutions`` entry and an optional ``action`` to an
308
+ # ``unblock_history`` entry (:func:`_acceptance_state_in`).
309
+ _ACCEPTANCE_FIELDS = ("acceptance_outcomes",)
310
+ # The field protocol 9 added (#219): the configuration a REMOTE run was
311
+ # recorded under (:mod:`autoforge.config_fingerprint`), ``{}`` when none is.
312
+ _CONFIG_FIELDS = ("config_fingerprint",)
313
+ # The fields protocol 10 added (#172, ADR 0006 D11.2): the validation
314
+ # records, the attempt marker, the REVIEW evidence binding, the review
315
+ # certificate, the producer failure counter and the override watermark
316
+ # (:mod:`autoforge.evidence`). An older file never carries one.
317
+ _VALIDATION_FIELDS = (
318
+ "validation_records",
319
+ "validation_attempt",
320
+ "review_evidence",
321
+ "review_certificate",
322
+ "producer_validation_failure",
323
+ "validation_override_seq",
324
+ )
325
+ # The field protocol 11 added (#243): the outcomes of this PR's operator
326
+ # actions. Protocol 11 also added the K10 effect kind, a FIX entry
327
+ # observation's ``operator_targets``, a FIX context resolution's
328
+ # ``operator_sources`` and a REMOTE ``last_fix_resolutions`` entry's
329
+ # ``operator_evidence`` (:func:`_operator_state_in`).
330
+ _OPERATOR_FIELDS = ("operator_comment_outcomes",)
331
+ # The field protocol 12 added (#280): the sandbox mode the run is pinned to
332
+ # (:mod:`autoforge.sandbox_pin`), ``{}`` when none is.
333
+ _PIN_FIELDS = ("sandbox_pin",)
334
+ # ``acceptance_outcomes`` (#211): what became of each acceptance action of
335
+ # the current PR. Bounded: at most one action is requested per FIX result,
336
+ # and the writer drops the oldest entries past the bound
337
+ # (:meth:`AutoForgeState.bound_acceptance_outcomes`), but never a decline of
338
+ # the round being fixed. That decline is what refuses the same action again,
339
+ # and a decline re-enters FIX without advancing the round, so dropping one
340
+ # would let the action through unchanged (PR #221 R3-F1). The round's
341
+ # declines are bounded instead, below the history's bound: once a round
342
+ # holds MAX_DECLINES_PER_ROUND, FIX refuses any further acceptance action of
343
+ # that round, so there is never one more to decline.
344
+ ACCEPTANCE_OUTCOMES = ("verified", "declined", "stale")
345
+ MAX_ACCEPTANCE_OUTCOMES = 64
346
+ MAX_DECLINES_PER_ROUND = 16
347
+ _ACCEPTANCE_OUTCOME_KEYS = (
348
+ "action_id",
349
+ "round",
350
+ "finding_id",
351
+ "outcome",
352
+ "at",
353
+ "comment_url",
354
+ "reason",
355
+ )
356
+ # ``operator_comment_outcomes`` (#243): what became of each operator action
357
+ # (K10) of the current PR. ``verified`` is an observed comment (``exact`` or
358
+ # ``bound`` evidence), ``declined`` an action the operator declined,
359
+ # ``withdrawn`` a pending action dropped with a decline (it may be requested
360
+ # again), ``stale`` one the PR's revision left behind. Bounded like
361
+ # ``acceptance_outcomes``, never dropping an entry of the round being fixed:
362
+ # one round holds at most MAX_DECLINES_PER_ROUND decline cycles of at most
363
+ # MAX_OPERATOR_ACTIONS_PER_FIX entries each, plus the round's completion or
364
+ # drift, so the bound below that product (68) is what always fits one round.
365
+ OPERATOR_COMMENT_OUTCOMES = ("verified", "declined", "withdrawn", "stale")
366
+ MAX_OPERATOR_COMMENT_OUTCOMES = 80
367
+ MIN_OPERATOR_COMMENT_OUTCOMES = (MAX_DECLINES_PER_ROUND + 1) * MAX_OPERATOR_ACTIONS_PER_FIX
368
+ assert MAX_OPERATOR_COMMENT_OUTCOMES >= MIN_OPERATOR_COMMENT_OUTCOMES
369
+ _OPERATOR_OUTCOME_KEYS = (
370
+ "action_id",
371
+ "round",
372
+ "finding_id",
373
+ "target",
374
+ "outcome",
375
+ "evidence",
376
+ "comment_url",
377
+ "body_sha256",
378
+ "payload_sha256",
379
+ "at",
380
+ "reason",
381
+ )
382
+ _OPERATOR_EVIDENCE_KEYS = (
383
+ "action_id",
384
+ "target",
385
+ "comment_url",
386
+ "evidence",
387
+ "body_sha256",
388
+ "payload_sha256",
389
+ )
390
+ _HEX64_RE = re.compile(r"[0-9a-f]{64}")
391
+ # The phases in which the persisted clean review is consumed by the merge
392
+ # gate, and so the phases a state without the review's full binding cannot
393
+ # be loaded in.
394
+ _MERGE_PHASES = (Phase.READY_FOR_MERGE, Phase.MERGE)
395
+ # What each pre-protocol-4 label failed to record about the completed
396
+ # review, for the refusal text: the binding the merge gate requires that
397
+ # the file cannot supply. A legacy label absent here (protocol 4) recorded
398
+ # the review whole; only its replan journal is subject to a boundary rule.
399
+ _REVIEW_BINDING_GAPS = {
400
+ "1": "which PR and base branch that review was posted on",
401
+ "2": "which PR and base branch that review was posted on",
402
+ "3": "which merge base that review's diff was computed from",
403
+ }
404
+ # A persisted commit id is a full lower-case SHA or empty (nothing bound).
405
+ # Matched with ``fullmatch``: ``$`` alone would accept a trailing newline.
406
+ _SHA40_RE = re.compile(r"[0-9a-f]{40}")
407
+
408
+
409
+ def _legacy_review_binding_refusal(
410
+ data: dict, phase: Phase, *, protocol: str, written_by: str
411
+ ) -> str:
412
+ """Why a pre-protocol-4 state cannot be loaded, or ``""`` when it can.
413
+
414
+ Protocol 3 added ``reviewed_pr_url`` and ``reviewed_base_ref`` next to
415
+ ``reviewed_head_sha``, protocol 4 added ``reviewed_merge_base_sha``
416
+ (#96), and the merge gate refuses a clean review that lacks any of
417
+ them. A file in any phase but READY_FOR_MERGE or MERGE is loaded as is:
418
+ nothing in it consumes the binding before the next completed review
419
+ writes it. A file parked in one of the merge phases holds a clean
420
+ review that the gate could only accept by binding it, now, to the run's
421
+ current PR, base and merge base -- and a binding reconstructed from the
422
+ very fields it exists to check is no binding. It is refused at the
423
+ boundary, with the PR and HEAD named so the operator can decide on
424
+ GitHub whether the PR is done, and never read as corruption: it was
425
+ written whole.
426
+
427
+ ``data`` is read defensively: this runs before any schema check.
428
+ """
429
+ if phase not in _MERGE_PHASES or protocol not in _REVIEW_BINDING_GAPS:
430
+ return ""
431
+
432
+ def _text(name: str) -> str:
433
+ value = data.get(name)
434
+ return value if isinstance(value, str) and value else "(none)"
435
+
436
+ return (
437
+ f"state file was written by controller {written_by or '(unknown)'} under "
438
+ f"protocol_version {protocol!r} and is in phase {phase.value} with a clean review of "
439
+ f"PR {_text('current_pr_url')} at HEAD {_text('reviewed_head_sha')}; protocol "
440
+ f"{protocol!r} did not record {_REVIEW_BINDING_GAPS[protocol]}, and this controller "
441
+ "does not bind it to the run's current PR, base and merge base after the fact. "
442
+ "Nothing was merged or counted by this controller. Check the PR on GitHub: if "
443
+ "it is already MERGED the issue is done and the run should continue from the next "
444
+ "issue; if it is OPEN, start a new run for the issue, which adopts the PR and "
445
+ "reviews it again. The state file was left unchanged"
446
+ )
447
+
448
+
449
+ @dataclass
450
+ class AutoForgeState:
451
+ protocol_version: str = __protocol_version__
452
+ controller_version: str = __version__
453
+ prompt_version: str = __prompt_version__
454
+
455
+ run_id: str = ""
456
+
457
+ # Which workflow this run executes. Explicit controller state, never
458
+ # inferred: a LOCAL run has no repository/EPIC/issue/PR at all, and a
459
+ # state file written before local mode existed has no 'mode' key and
460
+ # loads as REMOTE (see from_dict).
461
+ mode: WorkflowMode = WorkflowMode.REMOTE
462
+
463
+ repository: str = ""
464
+ epic_url: str = ""
465
+
466
+ current_issue_url: str = ""
467
+ current_pr_url: str = ""
468
+ current_branch: str = ""
469
+
470
+ phase: Phase = Phase.INITIALIZING
471
+
472
+ # Completed review rounds for the current PR; the upcoming round is +1.
473
+ review_round: int = 0
474
+
475
+ # The revision the last completed review is bound to: the PR it was
476
+ # posted on (canonical URL), the HEAD the reviewer saw, the base branch
477
+ # the reviewed diff was against and the merge base that diff was
478
+ # computed from. The four are written together by the review and read
479
+ # together by the merge gate: a clean review is a decision about one
480
+ # PR's diff against one base at one commit, so before MERGE the run's
481
+ # ``current_pr_url`` must be this PR by identity and the PR GitHub
482
+ # returns for it must still have this HEAD, this base and this merge
483
+ # base. A substituted ``current_pr_url`` (a same-repository PR at the
484
+ # same HEAD on the same branch against another base, say), a retargeted
485
+ # base, or a base rewritten under the same name (force-push, reset; the
486
+ # merge base moves, the name and HEAD do not, #96) is therefore never
487
+ # merged on the strength of a review it did not receive. Ordinary
488
+ # commits landing on the base leave the merge base where it was.
489
+ reviewed_pr_url: str = ""
490
+ reviewed_head_sha: str = ""
491
+ reviewed_base_ref: str = ""
492
+ reviewed_merge_base_sha: str = ""
493
+ # Latest PR HEAD, base branch and merge base the controller observed
494
+ # through `gh`; all are re-bound right before a review, and the
495
+ # post-review read must find them unchanged for the round to be current
496
+ # rather than stale.
497
+ current_head_sha: str = ""
498
+ current_base_ref: str = ""
499
+ current_merge_base_sha: str = ""
500
+
501
+ last_review_comment_url: str = ""
502
+ last_review_result: str = "" # "needs_fix" | "clean" | "stale" | ""
503
+ last_review_needs_fix: bool | None = None
504
+ # Findings from the last review that still require a FIX round
505
+ # (each: {"id","classification","required_resolution",...}).
506
+ open_findings: list[dict] = field(default_factory=list)
507
+ # Findings of the latest review of this PR that no FIX round resolved
508
+ # because the PR's revision moved out from under them: the round went
509
+ # stale while the reviewer worked, or the HEAD was already past the
510
+ # reviewed one when FIX was entered. Same shape as `open_findings`, but
511
+ # no fixer is asked to resolve them; they are handed to the next REVIEW
512
+ # as findings to re-check at the actual HEAD (`PRIOR_FINDINGS`). When
513
+ # non-empty they are the findings of round `review_round` at
514
+ # `reviewed_head_sha`, published in `last_review_comment_url`. Replaced
515
+ # by every later stale round (a stale clean round leaves none), cleared
516
+ # by the next completed round of the actual revision, and per PR.
517
+ prior_findings: list[dict] = field(default_factory=list)
518
+ # Resolutions reported by the last FIX round (verified by the controller).
519
+ # A REMOTE entry also carries ``acceptance_comment_url``: the comment
520
+ # the controller verified for an ``acceptance_action`` resolution, else
521
+ # "" (#211); and ``operator_evidence``: the operator comments verified
522
+ # for the finding in this round (#243), ``[]`` when none.
523
+ last_fix_resolutions: list[dict] = field(default_factory=list)
524
+ # The outcomes of the current PR's acceptance actions (#211), oldest
525
+ # first: ``action_id``, ``round``, ``finding_id``, ``outcome``
526
+ # (verified | declined | stale), ``at``, ``comment_url`` (the verified
527
+ # comment, else "") and ``reason`` (the operator's, redacted, for a
528
+ # decline; the drift for a stale action). Read by ``status``, by the FIX
529
+ # prompt and by the FIX result check (a declined action is not
530
+ # requested again in its round). Per PR, REMOTE only.
531
+ acceptance_outcomes: list[dict] = field(default_factory=list)
532
+ # The outcomes of the current PR's operator actions (K10, #243), oldest
533
+ # first: ``action_id``, ``round``, ``finding_id``, ``target`` (the issue
534
+ # the comment is for), ``outcome`` (verified | declined | withdrawn |
535
+ # stale), ``evidence`` (exact | bound for a verified one, else ""),
536
+ # ``comment_url`` and ``body_sha256`` (the verified comment, else ""),
537
+ # ``payload_sha256`` (the requested text's rendering), ``at`` and
538
+ # ``reason``. Read by ``status``, the FIX prompt and result check and
539
+ # REVIEW's ``operator-evidence.md``. Per PR, REMOTE only.
540
+ operator_comment_outcomes: list[dict] = field(default_factory=list)
541
+ # One entry per completed review round of the current PR (see
542
+ # loop_guard.review_record): round, reviewed_head_sha, result
543
+ # (needs_fix | clean | stale), finding_count, fingerprint (digest of the
544
+ # round's normalised required_resolution texts), resolutions (one digest
545
+ # per distinct non-empty normalised required_resolution, clipped to
546
+ # loop_guard.MAX_PERSISTED_RESOLUTION_DIGESTS) and resolutions_truncated
547
+ # (True when that clip dropped digests). No review text is persisted.
548
+ # An entry written before per-finding digests existed has no
549
+ # 'resolutions' key at all and keeps the count-only stagnation rule; a
550
+ # present but malformed field is corruption and fails loudly on load.
551
+ # Drives the review-round cap and stagnation detection; cleared per PR.
552
+ review_history: list[dict] = field(default_factory=list)
553
+ # Bounded controller-side verification failures relevant to this issue;
554
+ # supplied to a fresh reimplementation as constraints, not prompt policy.
555
+ verification_failures: list[str] = field(default_factory=list)
556
+
557
+ # Per-issue implementation lifecycle. The original attempt is 1; only a
558
+ # verified replacement increments it.
559
+ execution_attempt: int = 1
560
+ escalation_count: int = 0
561
+ superseded_prs: list[dict] = field(default_factory=list)
562
+ # Durable REPLAN_REEXECUTE transaction (see autoforge.replan_txn). Empty
563
+ # when no replan is in flight; a serialised ``ReplanTransaction`` while
564
+ # one is. It is the controller's intent record: recovery replays it
565
+ # rather than re-deriving what the safe disposition should have been.
566
+ replan_transaction: dict = field(default_factory=dict)
567
+
568
+ # -- LOCAL mode -----------------------------------------------------
569
+ # Repository-relative path of the frozen feature specification.
570
+ feature_spec_path: str = ""
571
+ # SHA-256 of the specification's bytes at run creation. Re-checked before
572
+ # and after every agent phase: an agent that rewrites its own acceptance
573
+ # criteria must not be able to make the run easier.
574
+ feature_spec_sha256: str = ""
575
+ # The *git anchor* a local run is pinned to: HEAD and the checked-out
576
+ # branch when the run was created ("" for an unborn HEAD, "" for a
577
+ # detached one). A local run never requires HEAD to move and never allows
578
+ # it to: the controller re-reads both around every agent phase and enters
579
+ # BLOCKED when either changed, so an agent that commits, resets or
580
+ # switches branches is caught by the controller rather than only
581
+ # forbidden by the prompt.
582
+ base_head_sha: str = ""
583
+ base_branch: str = ""
584
+ # The run's durable contract (see :mod:`autoforge.run_contract`): the
585
+ # repository root, state root, workspace policy, validation commands,
586
+ # fix-round budget and prompt version the run was *defined* with. Every
587
+ # later invocation compares what it would define against this before it
588
+ # executes anything, and execution reads these values from here -- never
589
+ # from the configuration of the moment -- so a resume can revalidate the
590
+ # contract but cannot redefine it. Persisted as the contract's own dict
591
+ # form and parsed strictly on load; ``{}`` for REMOTE runs.
592
+ local_run_contract: dict = field(default_factory=dict)
593
+ # Workspace fingerprint the controller bound before the current review
594
+ # (the local analogue of ``current_head_sha``).
595
+ workspace_fingerprint: str = ""
596
+ # Fingerprint the last completed review was bound to (the local analogue
597
+ # of ``reviewed_head_sha``).
598
+ reviewed_workspace_fingerprint: str = ""
599
+ # Completed local FIX rounds; bounded by ``local.max_fix_rounds``.
600
+ local_fix_rounds: int = 0
601
+ # Durable checkpoint for a LOCAL agent invocation that may already have
602
+ # written to the working tree. Persisted *before* the agent is launched,
603
+ # so a crash (or a rejected result) cannot make `resume` mistake work that
604
+ # already exists for work that was never done. ``local_pending_phase`` is
605
+ # the phase value, ``local_pending_fingerprint`` the fingerprint from
606
+ # *before the first* attempt of that phase entry, and
607
+ # ``local_pending_attempts`` how many invocations that entry has launched
608
+ # (bounded, so a phase that can never be completed blocks instead of
609
+ # looping over `resume`).
610
+ local_pending_phase: str = ""
611
+ local_pending_fingerprint: str = ""
612
+ local_pending_attempts: int = 0
613
+ # Working-tree paths that were already dirty when the run was created,
614
+ # other than the feature specification itself. Normally empty: `local run`
615
+ # refuses a dirty tree unless the operator passes --allow-dirty, and then
616
+ # these are named in status and in the review prompt rather than silently
617
+ # absorbed into the feature's implementation.
618
+ baseline_dirty_paths: list[str] = field(default_factory=list)
619
+
620
+ # The HEAD (and the exact command list) that ``merge.verification_commands``
621
+ # last passed on, so MERGE does not repeat what READY_FOR_MERGE proved
622
+ # for the same commit. Either differing means the commands run again.
623
+ premerge_verified_head_sha: str = ""
624
+ premerge_verified_commands: list[list[str]] = field(default_factory=list)
625
+
626
+ merged_since_epic_update: int = 0
627
+ counted_merged_prs: list[str] = field(default_factory=list)
628
+ # Reasons the controller rejected the UPDATE_EPIC agent's next_issue_url
629
+ # (bounded; the last one is rendered into the retry prompt). Cleared once
630
+ # a next issue verified or the EPIC completed.
631
+ next_issue_rejections: list[str] = field(default_factory=list)
632
+
633
+ # -- controller-owned external effects (ADR 0004, #160) ---------------
634
+ # The external writes of the current phase entry that the controller
635
+ # performs itself, one record each, in plan order (see
636
+ # :class:`autoforge.effects.EffectRecord`): written as ``intended`` in
637
+ # the same save as the completion context, before the first write, and
638
+ # advanced to ``attempted`` before each issue (D4.1) and to ``observed``
639
+ # or ``conflict`` after its read-back. The phase advances only when every
640
+ # record is observed.
641
+ effect_records: list[dict] = field(default_factory=list)
642
+ # What the phase read before its first launch (refs, base, the objects
643
+ # carrying its identities; D4.4), persisted in the pre-launch save. Empty
644
+ # until a phase records one.
645
+ entry_observation: dict = field(default_factory=dict)
646
+ # The validated result data the phase consumes once its records are
647
+ # observed (D4.6), in the closed schema of the phase it names. Recovery
648
+ # completes from it, never from a rendered payload or the run log.
649
+ # Records, observation and context belong to one phase entry: the save
650
+ # that commits the phase drops all three (D2.4).
651
+ completion_context: dict = field(default_factory=dict)
652
+ # The contract the current entry's last launch ran under (D13.3):
653
+ # ``agent_publishes`` when the agent was told to perform the phase's
654
+ # writes, ``controller_publishes`` when the controller performs them, ""
655
+ # before a launch. Every pre-launch save records it; it is read only while
656
+ # ``attempt >= 1``.
657
+ launch_label: str = ""
658
+
659
+ # Agent invocations attempted for the current phase (reset on transition).
660
+ attempt: int = 0
661
+ # Total executed steps across the run (never reset).
662
+ step_count: int = 0
663
+ # Human-readable reason when phase is BLOCKED/FAILED. Redacted and then
664
+ # bounded to ``MAX_BLOCK_REASON_CHARS`` by every engine writer (#88); a
665
+ # longer value in a file is accepted on load (an older controller wrote
666
+ # it) and is never rewritten by the load.
667
+ block_reason: str = ""
668
+ # The operator's explicit exits from BLOCKED (``autoforge unblock``), oldest
669
+ # first, one object per unblock that was applied: ``at`` (UTC timestamp),
670
+ # ``reason`` (the operator's text), ``block_reason`` (the reason that was
671
+ # cleared), ``phase`` (the phase re-entered) and ``detail`` (why the
672
+ # controller chose it). A refused unblock is not recorded here -- nothing
673
+ # changed -- but in the run log. Kept across issues: it is the run's audit
674
+ # trail, not per-PR bookkeeping.
675
+ unblock_history: list[dict] = field(default_factory=list)
676
+ # The configuration a REMOTE run was recorded under (#219): per-profile
677
+ # provider, model, effort and limits, the agent worktree root, the
678
+ # ``--config`` file and how it was recorded, in the closed form of
679
+ # :class:`autoforge.config_fingerprint.RecordedConfiguration`. Written
680
+ # when the run is created; ``resume``, ``step`` and ``unblock`` refuse a
681
+ # configuration that differs from it unless the operator accepts the
682
+ # change, which replaces it. ``{}`` for a LOCAL run (its run contract
683
+ # defines it) and for a run created before the field existed (not
684
+ # checked; the next command records one). Kept across issues.
685
+ config_fingerprint: dict = field(default_factory=dict)
686
+ # The sandbox mode the run is pinned to (#280), LOCAL and REMOTE alike:
687
+ # the mode, how and when it was pinned, in the closed form of
688
+ # :class:`autoforge.sandbox_pin.SandboxPin`. Written when the run is
689
+ # created; ``resume``, ``step`` and ``unblock`` refuse a weaker mode
690
+ # unless the operator accepts the change, and pin a stronger one. ``{}``
691
+ # for a run created before the field existed (not checked; the next
692
+ # command pins one). Kept across issues.
693
+ sandbox_pin: dict = field(default_factory=dict)
694
+
695
+ # -- candidate validation evidence (ADR 0006, #172) -------------------
696
+ # Every field below is strictly parsed, bounded (D10.3), digest-checked
697
+ # and checked for referential integrity on load
698
+ # (:func:`autoforge.evidence.check_evidence`), and cleared when the run
699
+ # moves to the next issue. #173 writes the binding, the counter and the
700
+ # watermark; #174 the certificate.
701
+ #
702
+ # What the controller observed running one trusted definition against
703
+ # one exact candidate under one policy, oldest first, at most 64; the
704
+ # records the binding, the certificate or the counter name are never
705
+ # evicted (D11.4).
706
+ validation_records: list[dict] = field(default_factory=list)
707
+ # The marker written immediately before a validator's launch and cleared
708
+ # in the save that appends its record (D11.2). One left by a crash is
709
+ # turned into an ``INTERRUPTED`` record by the next ``step``/``resume``
710
+ # under the lock, never by a load.
711
+ validation_attempt: dict = field(default_factory=dict)
712
+ # The set and the records a reviewer was presented (D11.2; #173 writes it).
713
+ review_evidence: dict = field(default_factory=dict)
714
+ # The controller's review certificate (D12.1; #174 issues it).
715
+ review_certificate: dict = field(default_factory=dict)
716
+ # A REMOTE producer's validation failures in its current phase entry
717
+ # (D5.2; #173): cleared by every save in another phase.
718
+ producer_validation_failure: dict = field(default_factory=dict)
719
+ # The operator's override watermark (D14.2; #173 writes it): None until
720
+ # an explicit REMOTE unblock sets it, 0 for an unblock that found no
721
+ # record; automatic recovery never does.
722
+ validation_override_seq: int | None = None
723
+
724
+ created_at: str = ""
725
+ updated_at: str = ""
726
+
727
+ # -- the LOCAL run contract ------------------------------------------
728
+ def local_contract(self) -> LocalRunContract:
729
+ """The persisted contract of this LOCAL run (StateError for a REMOTE one)."""
730
+ if self.mode != WorkflowMode.LOCAL:
731
+ raise StateError("a REMOTE run has no local run contract")
732
+ return LocalRunContract.from_dict(self.local_run_contract)
733
+
734
+ # -- serialization -------------------------------------------------
735
+ def to_dict(self) -> dict:
736
+ d = asdict(self)
737
+ d["phase"] = self.phase.value
738
+ d["mode"] = self.mode.value
739
+ return d
740
+
741
+ @property
742
+ def is_local(self) -> bool:
743
+ return self.mode == WorkflowMode.LOCAL
744
+
745
+ @classmethod
746
+ def from_dict(cls, data: dict) -> AutoForgeState:
747
+ if not isinstance(data, dict):
748
+ raise StateError("state file must contain a JSON object")
749
+ try:
750
+ phase = Phase(data["phase"])
751
+ except KeyError:
752
+ raise StateError("state file missing required field 'phase'") from None
753
+ except ValueError:
754
+ raise StateError(f"state file has unknown phase {data.get('phase')!r}") from None
755
+ # A state file written before LOCAL mode existed carries no 'mode'
756
+ # key; it is a REMOTE run and must keep loading unchanged.
757
+ raw_mode = data.get("mode", WorkflowMode.REMOTE.value)
758
+ if isinstance(raw_mode, WorkflowMode):
759
+ mode = raw_mode
760
+ else:
761
+ try:
762
+ mode = WorkflowMode(raw_mode)
763
+ except ValueError:
764
+ raise StateError(f"state file has unknown mode {raw_mode!r}") from None
765
+ # The protocol version decides what "unknown field" even means, so it
766
+ # is checked before the fields are: a file written by a newer
767
+ # controller must be reported as an unsupported protocol, not as a
768
+ # pile of typos.
769
+ #
770
+ # Each protocol step added bindings, and an old file is loaded
771
+ # exactly when nothing in it depends on the binding it lacks:
772
+ #
773
+ # - 1 -> 2: the replan journal records the PR and issue its decision
774
+ # was made on. A protocol-1 file with no replan in flight is a
775
+ # protocol-2 file with an old label; one with a replan in flight is
776
+ # refused with the transaction described, never migrated by filling
777
+ # the decision from the run's current PR and issue (the rebinding
778
+ # the fields forbid) and never handed to the journal loader, which
779
+ # would call it corrupt (:func:`replan_txn.legacy_journal_refusal`).
780
+ # - 2 -> 3: the completed review records the PR it was posted on and
781
+ # the base branch it was bound to, and the merge gate requires
782
+ # both; the replan journal records the base its decision was bound
783
+ # to and the base it checkpointed, and the supersede requires both.
784
+ # A protocol-2 file with a replan in flight is refused exactly as a
785
+ # protocol-1 one is, for the base instead of the PR and issue. A
786
+ # protocol-2 file parked in READY_FOR_MERGE or MERGE holds a clean
787
+ # review the gate could only accept by binding it to the run's
788
+ # current PR and base -- the substitution the fields exist to catch
789
+ # -- so it is refused with the PR and HEAD named
790
+ # (:func:`_legacy_review_binding_refusal`). In any other phase the
791
+ # next review writes the binding, and the file is loaded as is.
792
+ # - 3 -> 4: the completed review records the merge base its diff was
793
+ # computed from, and the merge gate requires it; the replan journal
794
+ # records the merge base its decision was bound to and the one it
795
+ # checkpointed, and the supersede requires both (#96). A protocol-3
796
+ # file with a replan in flight is refused exactly as a protocol-2
797
+ # one is, for the merge base instead of the base, and never
798
+ # migrated by reading the merge base GitHub reports now (the base
799
+ # may have been rewritten since, which is what the field detects);
800
+ # one parked in READY_FOR_MERGE or MERGE is refused exactly as a
801
+ # protocol-2 one is, for the merge base instead of the PR and base;
802
+ # in any other phase the next review writes the binding and the
803
+ # file is loaded as is.
804
+ # - 4 -> 5: the replan journal records, with the close intent, the
805
+ # source PR's closed issue-event count and the number of close
806
+ # attempts, which is what lets a resume that finds the source OPEN
807
+ # prove the close never ran and retry it once (#69). The review
808
+ # binding did not change, so a protocol-4 file is loaded in every
809
+ # phase; only a replan journal at SUPERSEDE_INTENT or later is
810
+ # refused, and never migrated by reading the count GitHub reports
811
+ # now (which would record the close the field exists to detect as
812
+ # if it predated the intent). A journal before the write is written
813
+ # exactly as protocol 5 writes it and is loaded as such.
814
+ #
815
+ # The label is rewritten on the next save. A file that is refused is
816
+ # left unchanged (``run --force`` moves it aside like any unreadable
817
+ # one).
818
+ # - 5 -> 7: the controller performs a phase's external writes itself
819
+ # and journals each one (effect records, the entry observation, the
820
+ # completion context, the launch label; ADR 0004 D13.1). A legacy
821
+ # file has none of them and is loaded with none (D13.2): there is
822
+ # nothing to reconstruct, because an agent-performed write was never
823
+ # journaled and is classified by the next entry from GitHub (D9).
824
+ # Its launch label is ``agent_publishes`` when the file records a
825
+ # launch of a publishing phase (``attempt >= 1``), which is what
826
+ # lets the first re-entry of a phase this version publishes itself
827
+ # adopt an agent-posted object once (D13.3, D13.7). A legacy file
828
+ # that carries a non-empty value in any of the new fields is
829
+ # refused, never merged.
830
+ # - 7 -> 8: a FIX may publish one acceptance comment on the run's
831
+ # issue (K9, #211), recorded in ``acceptance_outcomes``, a FIX
832
+ # context resolution's ``acceptance_source``, a REMOTE
833
+ # ``last_fix_resolutions`` entry's ``acceptance_comment_url`` and
834
+ # an unblock entry's ``action``. An older file carries none of them
835
+ # and is loaded in every phase with none: ``acceptance_source``
836
+ # null and ``acceptance_comment_url`` "" (nothing was published).
837
+ # An older file that carries any of them is refused, never merged.
838
+ # - 8 -> 9: a REMOTE run records the configuration it was created
839
+ # under (``config_fingerprint``, #219), which ``resume``, ``step``
840
+ # and ``unblock`` compare their own with. An older file has none
841
+ # and is loaded in every phase with none, which is "not checked"
842
+ # (the next of those commands records one). It is never filled
843
+ # from the configuration of the moment at load: that is the
844
+ # rebinding the record exists to catch, and a load (``status``)
845
+ # runs on no configuration. An older file that carries the field,
846
+ # even empty, is refused, never merged.
847
+ # - 9 -> 10: candidate validation evidence (#172, ADR 0006 D15.1):
848
+ # the validation records, the attempt marker, the REVIEW evidence
849
+ # binding, the review certificate, the producer failure counter and
850
+ # the override watermark. An older file has none of them and is
851
+ # loaded in every phase with them empty (D15.4): nothing is
852
+ # fabricated, and what an empty field means is the gates' to
853
+ # decide. An older file that carries any of them, even empty, is
854
+ # refused, never merged.
855
+ # - 10 -> 11: operator acceptance actions (K10, #243): the operator
856
+ # posts a comment on another issue and the controller verifies it.
857
+ # ``operator_comment_outcomes``, a K10 effect record, a FIX entry
858
+ # observation's ``operator_targets``, a FIX context resolution's
859
+ # ``operator_sources`` and a REMOTE ``last_fix_resolutions``
860
+ # entry's ``operator_evidence``. An older file has none of them and
861
+ # is loaded in every phase with none (a FIX entry observed under
862
+ # protocol 10 therefore offers no operator action). An older file
863
+ # that carries any of them is refused, never merged.
864
+ # - 11 -> 12: every run pins its sandbox mode (``sandbox_pin``,
865
+ # #280), which ``resume``, ``step`` and ``unblock`` hold their own
866
+ # mode against. An older file has none and is loaded in every phase
867
+ # with none, which is "not checked" (the next of those commands
868
+ # pins one). It is never filled at load from the configuration of
869
+ # the moment: a load (``status``) runs on no configuration, and the
870
+ # mode a command runs under is what the pin is compared with. An
871
+ # older file that carries the field, even empty, is refused, never
872
+ # merged.
873
+ raw_protocol = data.get("protocol_version", __protocol_version__)
874
+ written_by = str(data.get("controller_version", ""))
875
+ legacy = isinstance(raw_protocol, str) and raw_protocol in _LEGACY_PROTOCOLS
876
+ if legacy:
877
+ if raw_protocol in LEGACY_JOURNAL_PROTOCOLS:
878
+ refusal = legacy_journal_refusal(
879
+ data.get("replan_transaction", {}),
880
+ protocol=raw_protocol,
881
+ written_by=written_by,
882
+ )
883
+ if refusal:
884
+ raise StateError(refusal)
885
+ refusal = _legacy_review_binding_refusal(
886
+ data, phase, protocol=str(raw_protocol), written_by=written_by
887
+ )
888
+ if refusal:
889
+ raise StateError(refusal)
890
+ # The label decides, not the shape: a protocol-7 effect field
891
+ # may sit in a legacy file at its empty value, but effect state
892
+ # the labelled controller could never have written is a hand
893
+ # edit or a foreign file, and is refused rather than merged.
894
+ # Protocol 8's acceptance state is refused whatever its value.
895
+ planted = sorted(
896
+ name for name in _EFFECT_FIELDS if data.get(name, "") not in ([], {}, "")
897
+ )
898
+ if planted:
899
+ raise StateError(
900
+ f"state file is labelled protocol_version {raw_protocol!r}, which never "
901
+ f"wrote {', '.join(repr(n) for n in planted)}; refusing to load a "
902
+ "legacy file that carries protocol-7 effect state -- it is a hand edit "
903
+ "or a foreign file, not an older controller's state"
904
+ )
905
+ _refuse_acceptance_state(data, str(raw_protocol))
906
+ _refuse_config_fingerprint(data, str(raw_protocol))
907
+ _refuse_validation_fields(data, str(raw_protocol))
908
+ _refuse_operator_state(data, str(raw_protocol))
909
+ _refuse_sandbox_pin(data, str(raw_protocol))
910
+ elif raw_protocol == _PRE_ACCEPTANCE_PROTOCOL:
911
+ # 7 -> 8 (#211): the same shape without the acceptance-action
912
+ # state. Every protocol-7 save wrote the effect fields, exactly
913
+ # as a protocol-8 one does; the acceptance state it never wrote
914
+ # is refused, never merged, and so is a recorded configuration.
915
+ _require_fields(data, raw_protocol, _EFFECT_FIELDS, "effect state")
916
+ _refuse_acceptance_state(data, raw_protocol)
917
+ _refuse_config_fingerprint(data, raw_protocol)
918
+ _refuse_validation_fields(data, raw_protocol)
919
+ _refuse_operator_state(data, raw_protocol)
920
+ _refuse_sandbox_pin(data, raw_protocol)
921
+ elif raw_protocol == _PRE_CONFIG_PROTOCOL:
922
+ # 8 -> 9 (#219): the same shape without the recorded
923
+ # configuration, which it never wrote and which is refused.
924
+ _require_fields(data, raw_protocol, _EFFECT_FIELDS + _ACCEPTANCE_FIELDS, "effect state")
925
+ _refuse_config_fingerprint(data, raw_protocol)
926
+ _refuse_validation_fields(data, raw_protocol)
927
+ _refuse_operator_state(data, raw_protocol)
928
+ _refuse_sandbox_pin(data, raw_protocol)
929
+ elif raw_protocol == _PRE_VALIDATION_PROTOCOL:
930
+ # 9 -> 10 (#172): the same shape without the validation
931
+ # evidence, which it never wrote and which is refused.
932
+ _require_fields(data, raw_protocol, _EFFECT_FIELDS + _ACCEPTANCE_FIELDS, "effect state")
933
+ _require_fields(
934
+ data,
935
+ raw_protocol,
936
+ _CONFIG_FIELDS,
937
+ "recorded configuration",
938
+ "not a run with none recorded",
939
+ )
940
+ _refuse_validation_fields(data, raw_protocol)
941
+ _refuse_operator_state(data, raw_protocol)
942
+ _refuse_sandbox_pin(data, raw_protocol)
943
+ elif raw_protocol == _PRE_OPERATOR_PROTOCOL:
944
+ # 10 -> 11 (#243): the same shape without the operator-action
945
+ # state, which it never wrote and which is refused.
946
+ _require_fields(data, raw_protocol, _EFFECT_FIELDS + _ACCEPTANCE_FIELDS, "effect state")
947
+ _require_fields(
948
+ data,
949
+ raw_protocol,
950
+ _CONFIG_FIELDS,
951
+ "recorded configuration",
952
+ "not a run with none recorded",
953
+ )
954
+ _require_fields(
955
+ data,
956
+ raw_protocol,
957
+ _VALIDATION_FIELDS,
958
+ "validation evidence",
959
+ "not a run with no evidence",
960
+ )
961
+ _refuse_operator_state(data, raw_protocol)
962
+ _refuse_sandbox_pin(data, raw_protocol)
963
+ elif raw_protocol == _PREVIOUS_PROTOCOL:
964
+ # 11 -> 12 (#280): the same shape without the sandbox pin, which
965
+ # it never wrote and which is refused.
966
+ _require_fields(data, raw_protocol, _EFFECT_FIELDS + _ACCEPTANCE_FIELDS, "effect state")
967
+ _require_fields(
968
+ data,
969
+ raw_protocol,
970
+ _CONFIG_FIELDS,
971
+ "recorded configuration",
972
+ "not a run with none recorded",
973
+ )
974
+ _require_fields(
975
+ data,
976
+ raw_protocol,
977
+ _VALIDATION_FIELDS,
978
+ "validation evidence",
979
+ "not a run with no evidence",
980
+ )
981
+ _require_fields(
982
+ data,
983
+ raw_protocol,
984
+ _OPERATOR_FIELDS,
985
+ "operator-action outcomes",
986
+ "not a PR with none",
987
+ )
988
+ _refuse_sandbox_pin(data, raw_protocol)
989
+ elif raw_protocol != __protocol_version__:
990
+ raise StateError(
991
+ f"unsupported protocol_version {raw_protocol!r} "
992
+ f"(controller speaks {__protocol_version__!r})"
993
+ )
994
+ else:
995
+ # Only a legacy label loads with no effect state (D13.2). Every
996
+ # protocol-7 save writes all four fields, so one that is absent
997
+ # is a truncation or a hand edit, never "nothing planned": a
998
+ # missing ``effect_records`` read as the empty default would let
999
+ # the phase complete from its context with a pending effect never
1000
+ # performed.
1001
+ _require_fields(data, raw_protocol, _EFFECT_FIELDS + _ACCEPTANCE_FIELDS, "effect state")
1002
+ # Every protocol-9 save writes it, ``{}`` included, so an absent
1003
+ # one is not "not recorded": read as the empty default it would
1004
+ # switch the configuration check off for the rest of the run.
1005
+ _require_fields(
1006
+ data,
1007
+ raw_protocol,
1008
+ _CONFIG_FIELDS,
1009
+ "recorded configuration",
1010
+ "not a run with none recorded",
1011
+ )
1012
+ # Every protocol-10 save writes all six, so an absent one is a
1013
+ # truncation or a hand edit: a missing ``validation_attempt``
1014
+ # read as "no marker" would hide a crash the resume path must
1015
+ # record, and a missing ``review_certificate`` would read as one
1016
+ # never issued.
1017
+ _require_fields(
1018
+ data,
1019
+ raw_protocol,
1020
+ _VALIDATION_FIELDS,
1021
+ "validation evidence",
1022
+ "not a run with no evidence",
1023
+ )
1024
+ # Every protocol-11 save writes it, ``[]`` included, so an
1025
+ # absent one is a truncation or a hand edit: read as the empty
1026
+ # default it would drop a declined action of the round and let
1027
+ # the same request through again.
1028
+ _require_fields(
1029
+ data,
1030
+ raw_protocol,
1031
+ _OPERATOR_FIELDS,
1032
+ "operator-action outcomes",
1033
+ "not a PR with none",
1034
+ )
1035
+ # Every protocol-12 save writes it, ``{}`` included, so an
1036
+ # absent one is not "not pinned": read as the empty default it
1037
+ # would switch the sandbox check off for the rest of the run.
1038
+ _require_fields(
1039
+ data,
1040
+ raw_protocol,
1041
+ _PIN_FIELDS,
1042
+ "sandbox pin",
1043
+ "not a run with none pinned",
1044
+ )
1045
+ # A LOCAL state written before the run contract existed has no
1046
+ # persisted definition of what it reviewed under. It is not migrated:
1047
+ # the only source a migration could fill the contract from is the
1048
+ # *current* configuration, and "missing field -> fill from current
1049
+ # config" is precisely the silent rebinding the contract forbids.
1050
+ if mode == WorkflowMode.LOCAL and (
1051
+ "local_workspace_policy" in data or "local_run_contract" not in data
1052
+ ):
1053
+ raise StateError(
1054
+ "state file is a LOCAL run created by a pre-release controller that did "
1055
+ "not persist the run contract; it is not migrated, because the contract "
1056
+ "cannot be reconstructed from the current configuration without "
1057
+ "redefining the run -- start a new run"
1058
+ )
1059
+ kwargs = dict(data)
1060
+ kwargs["phase"] = phase
1061
+ kwargs["mode"] = mode
1062
+ kwargs["protocol_version"] = __protocol_version__
1063
+ if raw_protocol not in (
1064
+ _PRE_CONFIG_PROTOCOL,
1065
+ _PRE_VALIDATION_PROTOCOL,
1066
+ _PRE_OPERATOR_PROTOCOL,
1067
+ _PREVIOUS_PROTOCOL,
1068
+ __protocol_version__,
1069
+ ) and (mode == WorkflowMode.REMOTE):
1070
+ # Before protocol 8 only: a protocol-8 file carries its own
1071
+ # acceptance state, which the migration would overwrite.
1072
+ kwargs.update(_acceptance_migration(data))
1073
+ if (
1074
+ raw_protocol not in (_PREVIOUS_PROTOCOL, __protocol_version__)
1075
+ and mode == WorkflowMode.REMOTE
1076
+ ):
1077
+ # Before protocol 11 (#243): over the acceptance migration, so a
1078
+ # pre-8 file gains both.
1079
+ kwargs.update(_operator_migration(kwargs))
1080
+ if legacy and mode == WorkflowMode.REMOTE:
1081
+ attempt = data.get("attempt", 0)
1082
+ if isinstance(attempt, int) and not isinstance(attempt, bool) and attempt >= 1:
1083
+ kwargs["launch_label"] = (
1084
+ LABEL_AGENT_PUBLISHES if phase in PUBLISHING_PHASES else LABEL_NONE
1085
+ )
1086
+ # Unknown fields are corruption, not forward compatibility: at this
1087
+ # protocol version the controller knows every field it writes, so an
1088
+ # unexpected key is a hand edit, a truncated merge or a foreign file.
1089
+ # Dropping it silently would let `local_fix_round` (say) sit next to a
1090
+ # `local_fix_rounds` that quietly kept its dataclass default of 0 —
1091
+ # substituting a default for a bound the operator meant to set.
1092
+ known = {f for f in cls.__dataclass_fields__}
1093
+ unknown = sorted(set(kwargs) - known)
1094
+ if unknown:
1095
+ raise StateError(
1096
+ "state file has unknown field(s) "
1097
+ + ", ".join(repr(u) for u in unknown)
1098
+ + f" for protocol_version {__protocol_version__!r}; refusing to load — "
1099
+ "a field the controller does not know is corruption, not state"
1100
+ )
1101
+ try:
1102
+ state = cls(**{k: v for k, v in kwargs.items() if k in known})
1103
+ except TypeError as exc:
1104
+ raise StateError(f"state file has invalid fields: {exc}") from exc
1105
+ # A phase that exists is not a phase this run can be in. `Phase(...)`
1106
+ # above only proves the value is one the controller knows; LOCAL and
1107
+ # REMOTE are two topologies over that one enum, and the LOCAL one has
1108
+ # no REPLAN_REEXECUTE, READY_FOR_MERGE, MERGE or UPDATE_EPIC in it
1109
+ # (see transitions.LOCAL_PHASES). Without this a corrupt or hand-edited
1110
+ # `mode: LOCAL, phase: READY_FOR_MERGE` loads cleanly and `resume`
1111
+ # reads it as the remote merge hold -- printing a merge banner, and
1112
+ # with the gate open entering the GitHub pre-merge verification for a
1113
+ # run that has no repository, no PR and nothing to merge. A state whose
1114
+ # phase its own mode can never execute is corruption.
1115
+ if state.mode == WorkflowMode.LOCAL and state.phase not in LOCAL_PHASES:
1116
+ raise StateError(
1117
+ f"state file is a LOCAL run in phase {state.phase.value}, which belongs to "
1118
+ "the GitHub workflow and no local run can reach; refusing to load — "
1119
+ "a phase outside the mode's own topology is corruption, not state"
1120
+ )
1121
+ # Required-field sanity. A LOCAL run has no repository/EPIC at all;
1122
+ # its identity is the frozen feature specification instead.
1123
+ required = ["run_id", "created_at", "updated_at"]
1124
+ if state.mode == WorkflowMode.LOCAL:
1125
+ required += ["feature_spec_path", "feature_spec_sha256", "local_run_contract"]
1126
+ else:
1127
+ required += ["repository", "epic_url"]
1128
+ for req in required:
1129
+ if not getattr(state, req, None):
1130
+ raise StateError(f"state file missing required field {req!r}")
1131
+ # `run_id` is not just an identifier: it names `<state_dir>/logs/<run_id>`,
1132
+ # the directory every artifact of the run is written into. A hand-edited
1133
+ # or truncated state carrying "../escape" (or an absolute path) would
1134
+ # redirect those writes out of the state directory entirely, so the
1135
+ # shape is checked here — corruption fails on load, naming the state
1136
+ # file, rather than at the first log write.
1137
+ validate_run_id(state.run_id)
1138
+ # Types come from the dataclass, not from a list maintained by hand.
1139
+ # A `str` field that arrives as `null`, a list or a number is
1140
+ # corruption for the same reason whichever field it is, and a
1141
+ # hand-written enumeration only closes the fields somebody thought of:
1142
+ # `workspace_fingerprint: null` once loaded cleanly and reached the
1143
+ # review binding as a value no reviewer's fingerprint could equal.
1144
+ # Declaring the type *is* declaring the contract, so the declaration
1145
+ # is what gets checked.
1146
+ for name, annotation in _SCALAR_FIELDS.items():
1147
+ value = getattr(state, name)
1148
+ # `bool` is an `int` in Python and would sail through as 0 or 1.
1149
+ if not isinstance(value, annotation) or isinstance(value, bool):
1150
+ raise StateError(f"state field {name!r} must be {annotation.__name__}")
1151
+ # Range, not just type: `local_fix_rounds: -1` would sail past the
1152
+ # `>= max_fix_rounds` budget guard and buy the run unlimited extra fix
1153
+ # rounds, and a negative `step_count` does the same to the cumulative
1154
+ # step budget. A counter that cannot be trusted is not a counter.
1155
+ for name in _NON_NEGATIVE_FIELDS:
1156
+ if getattr(state, name) < 0:
1157
+ raise StateError(f"state field {name!r} must be a non-negative integer")
1158
+ _validate_local_pending(state)
1159
+ if not isinstance(state.local_run_contract, dict):
1160
+ raise StateError("state field 'local_run_contract' must be an object")
1161
+ if state.mode == WorkflowMode.LOCAL:
1162
+ try:
1163
+ LocalRunContract.from_dict(state.local_run_contract)
1164
+ except StateError as exc:
1165
+ raise StateError(f"state field 'local_run_contract': {exc}") from None
1166
+ elif state.local_run_contract:
1167
+ raise StateError("state field 'local_run_contract' must be empty for a REMOTE run")
1168
+ if not isinstance(state.baseline_dirty_paths, list) or not all(
1169
+ isinstance(path, str) for path in state.baseline_dirty_paths
1170
+ ):
1171
+ raise StateError("state field 'baseline_dirty_paths' must be a list of strings")
1172
+ if not isinstance(state.counted_merged_prs, list) or not all(
1173
+ isinstance(url, str) for url in state.counted_merged_prs
1174
+ ):
1175
+ raise StateError("state field 'counted_merged_prs' must be a list of strings")
1176
+ if not isinstance(state.premerge_verified_commands, list) or not all(
1177
+ isinstance(argv, list) and all(isinstance(arg, str) for arg in argv)
1178
+ for argv in state.premerge_verified_commands
1179
+ ):
1180
+ raise StateError(
1181
+ "state field 'premerge_verified_commands' must be a list of string lists"
1182
+ )
1183
+ _validate_findings_field("open_findings", state.open_findings)
1184
+ _validate_findings_field("prior_findings", state.prior_findings)
1185
+ if not isinstance(state.last_fix_resolutions, list) or not all(
1186
+ isinstance(resolution, dict) for resolution in state.last_fix_resolutions
1187
+ ):
1188
+ raise StateError("state field 'last_fix_resolutions' must be a list of objects")
1189
+ if not isinstance(state.next_issue_rejections, list) or not all(
1190
+ isinstance(reason, str) for reason in state.next_issue_rejections
1191
+ ):
1192
+ raise StateError("state field 'next_issue_rejections' must be a list of strings")
1193
+ try:
1194
+ validate_review_history(state.review_history)
1195
+ except StateError as exc:
1196
+ raise StateError(f"state field {exc}") from None
1197
+ if not isinstance(state.verification_failures, list) or not all(
1198
+ isinstance(reason, str) for reason in state.verification_failures
1199
+ ):
1200
+ raise StateError("state field 'verification_failures' must be a list of strings")
1201
+ if not isinstance(state.superseded_prs, list) or not all(
1202
+ isinstance(item, dict) for item in state.superseded_prs
1203
+ ):
1204
+ raise StateError("state field 'superseded_prs' must be a list of objects")
1205
+ if not isinstance(state.replan_transaction, dict):
1206
+ raise StateError("state field 'replan_transaction' must be an object")
1207
+ _validate_unblock_history(state.unblock_history)
1208
+ # After the shapes it reads (last_fix_resolutions, unblock_history).
1209
+ _validate_acceptance_state(state)
1210
+ _validate_operator_state(state)
1211
+ _validate_config_fingerprint(state)
1212
+ _validate_sandbox_pin(state)
1213
+ for name in _MINIMUM_ONE_FIELDS:
1214
+ if getattr(state, name) < 1:
1215
+ raise StateError(f"state field {name!r} must be a valid integer")
1216
+ if state.last_review_needs_fix is not None and not isinstance(
1217
+ state.last_review_needs_fix, bool
1218
+ ):
1219
+ raise StateError("state field 'last_review_needs_fix' must be a boolean or null")
1220
+ # The reviewed PR is compared by GitHub identity at the merge gate,
1221
+ # so it must parse as a PR URL of the run's repository whenever it is
1222
+ # set; a value the gate could not parse would otherwise surface as a
1223
+ # refusal to merge that names no cause. (Empty is "no review bound",
1224
+ # which the gate refuses on its own, exactly as for the HEAD.)
1225
+ if state.reviewed_pr_url:
1226
+ try:
1227
+ reviewed = parse_pr_url(state.reviewed_pr_url)
1228
+ except ConfigurationError as exc:
1229
+ raise StateError(f"state field 'reviewed_pr_url': {exc}") from None
1230
+ if state.repository and not reviewed.same_repository(state.repository):
1231
+ raise StateError(
1232
+ f"state field 'reviewed_pr_url' names PR {state.reviewed_pr_url}, which "
1233
+ f"is not in repository {state.repository!r}"
1234
+ )
1235
+ # The merge bases are compared for equality with the one GitHub
1236
+ # reports (a lower-case full SHA), so a value of any other shape
1237
+ # could only ever differ from it and would send every merge back to
1238
+ # REVIEW without naming a cause. (Empty is "nothing bound".)
1239
+ for name in ("reviewed_merge_base_sha", "current_merge_base_sha"):
1240
+ value = getattr(state, name)
1241
+ if value and not _SHA40_RE.fullmatch(value):
1242
+ raise StateError(
1243
+ f"state field {name!r} must be a full lower-case commit SHA or empty, "
1244
+ f"got {value!r:.60}"
1245
+ )
1246
+ state.validation_evidence()
1247
+ state.phase_effects()
1248
+ return state
1249
+
1250
+ # -- candidate validation evidence (ADR 0006) --------------------------
1251
+ def validation_evidence(self) -> list[RecordView]:
1252
+ """The six evidence fields, checked together; the records, parsed (D11.1).
1253
+
1254
+ Read-only (D11.3): a leftover marker is reported and validated here,
1255
+ never converted or cleared.
1256
+ """
1257
+ return check_evidence(
1258
+ EvidenceFields(
1259
+ validation_records=self.validation_records,
1260
+ validation_attempt=self.validation_attempt,
1261
+ review_evidence=self.review_evidence,
1262
+ review_certificate=self.review_certificate,
1263
+ producer_validation_failure=self.producer_validation_failure,
1264
+ validation_override_seq=self.validation_override_seq,
1265
+ ),
1266
+ local=self.mode == WorkflowMode.LOCAL,
1267
+ repository=self.repository,
1268
+ run_id=self.run_id,
1269
+ )
1270
+
1271
+ def clear_validation_evidence(self) -> None:
1272
+ """All six fields, empty (D11.4): the next issue starts with none."""
1273
+ self.validation_records = []
1274
+ self.validation_attempt = {}
1275
+ self.review_evidence = {}
1276
+ self.review_certificate = {}
1277
+ self.producer_validation_failure = {}
1278
+ self.validation_override_seq = None
1279
+
1280
+ # -- controller-owned effects (ADR 0004) -------------------------------
1281
+ def effect_binding(self) -> Binding:
1282
+ """What this state's effect records, observation and context must agree with."""
1283
+ txn = self.replan_transaction.get("transaction_id", "")
1284
+ return Binding(
1285
+ run_id=self.run_id,
1286
+ phase=self.phase,
1287
+ issue_url=self.current_issue_url,
1288
+ pr_url=self.current_pr_url,
1289
+ review_round=self.review_round,
1290
+ transaction_id=txn if isinstance(txn, str) else "",
1291
+ open_finding_ids=tuple(
1292
+ str(f.get("id", "")) for f in self.open_findings if isinstance(f, dict)
1293
+ ),
1294
+ )
1295
+
1296
+ def phase_effects(self) -> PhaseEffects:
1297
+ """The persisted effect state of the current phase entry, strictly validated.
1298
+
1299
+ Every value is checked against the state it is loaded with (the
1300
+ phase, issue, PR, round, open findings and replan transaction it is
1301
+ bound to; D4.6) and against the others (one phase, the context saved
1302
+ with the records, D2.4's bounds). A value that fails is corruption
1303
+ and raises :class:`StateError`; a LOCAL run carries none (D13.2).
1304
+ """
1305
+ if not isinstance(self.launch_label, str) or self.launch_label not in LAUNCH_LABELS:
1306
+ raise StateError(
1307
+ f"state field 'launch_label' must be one of {sorted(LAUNCH_LABELS)}, got "
1308
+ f"{self.launch_label!r:.60}"
1309
+ )
1310
+ if self.mode == WorkflowMode.LOCAL:
1311
+ present = [name for name in _EFFECT_FIELDS if getattr(self, name) not in ([], {}, "")]
1312
+ if present:
1313
+ raise StateError(
1314
+ f"state field(s) {', '.join(repr(n) for n in present)} are set on a LOCAL "
1315
+ "run, which performs no external effects; refusing to load"
1316
+ )
1317
+ return PhaseEffects()
1318
+ return load_phase_effects(
1319
+ self.effect_records,
1320
+ self.entry_observation,
1321
+ self.completion_context,
1322
+ self.effect_binding(),
1323
+ )
1324
+
1325
+ def bound_acceptance_outcomes(self) -> None:
1326
+ """Drop the oldest ``acceptance_outcomes`` past the bound, never an active decline.
1327
+
1328
+ A decline of the round being fixed refuses its action again for as
1329
+ long as the round lasts, so it is kept (PR #221 R3-F1); the entries
1330
+ dropped are earlier rounds' and the round's verified or stale ones.
1331
+ The round's declines are at most :data:`MAX_DECLINES_PER_ROUND`,
1332
+ below :data:`MAX_ACCEPTANCE_OUTCOMES`, so the bound always holds.
1333
+ """
1334
+ excess = len(self.acceptance_outcomes) - MAX_ACCEPTANCE_OUTCOMES
1335
+ kept: list[dict] = []
1336
+ for outcome in self.acceptance_outcomes:
1337
+ active = outcome["outcome"] == "declined" and outcome["round"] == self.review_round
1338
+ if excess > 0 and not active:
1339
+ excess -= 1
1340
+ continue
1341
+ kept.append(outcome)
1342
+ self.acceptance_outcomes = kept
1343
+
1344
+ def round_declines(self) -> int:
1345
+ """Declines of the round being fixed, K9's and K10's together (#243).
1346
+
1347
+ The one bound on decline-driven relaunches
1348
+ (:data:`MAX_DECLINES_PER_ROUND`) is shared by both kinds; a
1349
+ withdrawal is not a decline and is not counted.
1350
+ """
1351
+ return sum(
1352
+ 1
1353
+ for outcomes in (self.acceptance_outcomes, self.operator_comment_outcomes)
1354
+ for o in outcomes
1355
+ if o["outcome"] == "declined" and o["round"] == self.review_round
1356
+ )
1357
+
1358
+ def bound_operator_comment_outcomes(self) -> None:
1359
+ """Drop the oldest ``operator_comment_outcomes`` past the bound, never this round's.
1360
+
1361
+ An entry of the round being fixed is what refuses a declined
1362
+ request again, and what tells the fixer and REVIEW what was
1363
+ verified, so it is kept (#243). One round fits the bound
1364
+ (:data:`MIN_OPERATOR_COMMENT_OUTCOMES`), so the bound always holds.
1365
+ """
1366
+ excess = len(self.operator_comment_outcomes) - MAX_OPERATOR_COMMENT_OUTCOMES
1367
+ kept: list[dict] = []
1368
+ for outcome in self.operator_comment_outcomes:
1369
+ if excess > 0 and outcome["round"] != self.review_round:
1370
+ excess -= 1
1371
+ continue
1372
+ kept.append(outcome)
1373
+ self.operator_comment_outcomes = kept
1374
+
1375
+ def drop_phase_effects(self) -> None:
1376
+ """Forget the current entry's effect state: the save that commits the phase (D2.4)."""
1377
+ self.effect_records = []
1378
+ self.entry_observation = {}
1379
+ self.completion_context = {}
1380
+ self.launch_label = LABEL_NONE
1381
+
1382
+ def settle_phase_effects(self) -> None:
1383
+ """D2.4 at every save: a phase entry's effect state does not outlive it.
1384
+
1385
+ Records, observation and context name the phase they belong to; a
1386
+ save in another phase (the transition out of it) drops them, unless
1387
+ the run is ``BLOCKED`` or ``FAILED`` in that phase, which keeps them
1388
+ for the operator and for ``unblock`` back into it. The launch label
1389
+ describes the launches of the current entry, so it is dropped while
1390
+ none has been made (``attempt`` is reset on every transition).
1391
+ """
1392
+ if self.phase in (Phase.BLOCKED, Phase.FAILED):
1393
+ return
1394
+ named = {
1395
+ piece.get("phase")
1396
+ for piece in (self.completion_context, self.entry_observation)
1397
+ if isinstance(piece, dict) and piece
1398
+ }
1399
+ for record in self.effect_records if isinstance(self.effect_records, list) else ():
1400
+ owner = record.get("owner") if isinstance(record, dict) else None
1401
+ if isinstance(owner, dict):
1402
+ named.add(owner.get("phase"))
1403
+ if named and named != {self.phase.value}:
1404
+ self.drop_phase_effects()
1405
+ elif self.attempt == 0:
1406
+ self.launch_label = LABEL_NONE
1407
+
1408
+ def settle_validation_failure(self) -> None:
1409
+ """ADR 0006 D5.2 at every save: a producer's failure counter is its phase's.
1410
+
1411
+ Counted per phase entry, so the write that changes the phase (to
1412
+ the next phase, or to ``BLOCKED``) clears it, and the next entry of
1413
+ the phase counts from nothing.
1414
+ """
1415
+ counter = self.producer_validation_failure
1416
+ if isinstance(counter, dict) and counter and counter.get("phase") != self.phase.value:
1417
+ self.producer_validation_failure = {}
1418
+
1419
+ def touch(self) -> None:
1420
+ self.updated_at = utcnow_iso()
1421
+
1422
+ # -- merge accounting (idempotent) ----------------------------------
1423
+ def record_merge(self, pr_url: str) -> bool:
1424
+ """Record a merged PR. Returns True if newly counted.
1425
+
1426
+ Idempotent: recording the same ``pr_url`` twice only counts once, so
1427
+ a retried MERGE step after a crash can never double-count.
1428
+ """
1429
+ if pr_url and pr_url not in self.counted_merged_prs:
1430
+ self.counted_merged_prs.append(pr_url)
1431
+ self.merged_since_epic_update += 1
1432
+ return True
1433
+ return False
1434
+
1435
+ def record_epic_update(self) -> None:
1436
+ self.merged_since_epic_update = 0
1437
+
1438
+ # -- per-issue bookkeeping ------------------------------------------
1439
+ def reset_for_new_issue(self, issue_url: str) -> None:
1440
+ """Clear all PR/review bookkeeping when moving to another issue.
1441
+
1442
+ ``step_count`` is deliberately kept: it is the run's cumulative step
1443
+ budget and must survive issue switches and ``resume`` alike.
1444
+ """
1445
+ self.current_issue_url = issue_url
1446
+ self.current_pr_url = ""
1447
+ self.current_branch = ""
1448
+ self.current_head_sha = ""
1449
+ self.current_base_ref = ""
1450
+ self.current_merge_base_sha = ""
1451
+ self.reviewed_pr_url = ""
1452
+ self.reviewed_head_sha = ""
1453
+ self.reviewed_base_ref = ""
1454
+ self.reviewed_merge_base_sha = ""
1455
+ self.review_round = 0
1456
+ self.last_review_result = ""
1457
+ self.last_review_needs_fix = None
1458
+ self.last_review_comment_url = ""
1459
+ self.open_findings = []
1460
+ self.prior_findings = []
1461
+ self.last_fix_resolutions = []
1462
+ self.acceptance_outcomes = []
1463
+ self.operator_comment_outcomes = []
1464
+ self.review_history = []
1465
+ self.verification_failures = []
1466
+ self.premerge_verified_head_sha = ""
1467
+ self.premerge_verified_commands = []
1468
+ self.clear_validation_evidence()
1469
+ self.execution_attempt = 1
1470
+ self.escalation_count = 0
1471
+ self.superseded_prs = []
1472
+ self.replan_transaction = {}
1473
+ self.next_issue_rejections = []
1474
+ self.drop_phase_effects()
1475
+ self.attempt = 0
1476
+
1477
+ @property
1478
+ def last_review_round(self) -> int:
1479
+ return self.review_round
1480
+
1481
+
1482
+ _UNBLOCK_ENTRY_FIELDS = ("at", "reason", "block_reason", "phase", "detail")
1483
+
1484
+
1485
+ def _require_fields(
1486
+ data: dict,
1487
+ protocol: object,
1488
+ names: tuple[str, ...],
1489
+ what: str,
1490
+ absent: str = "not an empty plan",
1491
+ ) -> None:
1492
+ """Refuse a file of ``protocol`` that lacks a field every save of it writes.
1493
+
1494
+ ``absent`` is what the missing field must not be read as.
1495
+ """
1496
+ missing = [name for name in names if name not in data]
1497
+ if not missing:
1498
+ return
1499
+ label = (
1500
+ f"is labelled protocol_version {protocol!r}"
1501
+ if "protocol_version" in data
1502
+ else f"has no protocol_version label (read as {protocol!r})"
1503
+ )
1504
+ raise StateError(
1505
+ f"state file {label} but is missing "
1506
+ f"required field(s) {', '.join(repr(n) for n in missing)}; refusing to "
1507
+ f"load -- every save of this protocol writes the {what}, so an absent "
1508
+ f"field is corruption, {absent}"
1509
+ )
1510
+
1511
+
1512
+ def _refuse_config_fingerprint(data: dict, protocol: str) -> None:
1513
+ """A file labelled before protocol 9 never recorded a configuration (#219).
1514
+
1515
+ The label decides, not the value: ``config_fingerprint`` is protocol 9's
1516
+ own field, so an older file that carries it at all, even ``{}``, was not
1517
+ written by the controller its label names.
1518
+ """
1519
+ if "config_fingerprint" in data:
1520
+ raise StateError(
1521
+ f"state file is labelled protocol_version {protocol!r}, which never wrote "
1522
+ "'config_fingerprint'; refusing to load a file that carries protocol-9 "
1523
+ "recorded configuration -- it is a hand edit or a foreign file, not an older "
1524
+ "controller's state"
1525
+ )
1526
+
1527
+
1528
+ def _refuse_sandbox_pin(data: dict, protocol: str) -> None:
1529
+ """A file labelled before protocol 12 never pinned a sandbox mode (#280).
1530
+
1531
+ The label decides, not the value: ``sandbox_pin`` is protocol 12's own
1532
+ field, so an older file that carries it at all, even ``{}``, was not
1533
+ written by the controller its label names.
1534
+ """
1535
+ if "sandbox_pin" in data:
1536
+ raise StateError(
1537
+ f"state file is labelled protocol_version {protocol!r}, which never wrote "
1538
+ "'sandbox_pin'; refusing to load a file that carries a protocol-12 sandbox pin "
1539
+ "-- it is a hand edit or a foreign file, not an older controller's state"
1540
+ )
1541
+
1542
+
1543
+ def _refuse_validation_fields(data: dict, protocol: str) -> None:
1544
+ """A file labelled before protocol 10 never wrote validation evidence (#172).
1545
+
1546
+ The label decides, not the value: an older file that carries one of the
1547
+ six fields at all, even empty, was not written by the controller its
1548
+ label names (ADR 0006 D15.4), and nothing it holds is merged.
1549
+ """
1550
+ planted = [name for name in _VALIDATION_FIELDS if name in data]
1551
+ if planted:
1552
+ raise StateError(
1553
+ f"state file is labelled protocol_version {protocol!r}, which never wrote "
1554
+ f"{', '.join(repr(n) for n in planted)}; refusing to load a file that carries "
1555
+ "protocol-10 validation evidence -- it is a hand edit or a foreign file, not an "
1556
+ "older controller's state"
1557
+ )
1558
+
1559
+
1560
+ def _acceptance_state_in(data: dict) -> list[str]:
1561
+ """What of protocol 8's acceptance-action state (#211) ``data`` carries, named.
1562
+
1563
+ The label decides, not the value: ``acceptance_outcomes`` is protocol
1564
+ 8's own field, so an older file that carries it at all, even empty, was
1565
+ not written by the controller its label names.
1566
+ """
1567
+ found: list[str] = []
1568
+ if "acceptance_outcomes" in data:
1569
+ found.append("'acceptance_outcomes'")
1570
+ records = data.get("effect_records")
1571
+ if isinstance(records, list) and any(
1572
+ isinstance(r, dict) and r.get("kind") == EffectKind.ACCEPTANCE_COMMENT.value
1573
+ for r in records
1574
+ ):
1575
+ found.append("an 'acceptance_comment' effect record")
1576
+ context = data.get("completion_context")
1577
+ resolutions = context.get("resolutions") if isinstance(context, dict) else None
1578
+ if isinstance(resolutions, list) and any(
1579
+ isinstance(r, dict) and "acceptance_source" in r for r in resolutions
1580
+ ):
1581
+ found.append("'acceptance_source' in 'completion_context'")
1582
+ last = data.get("last_fix_resolutions")
1583
+ if isinstance(last, list) and any(
1584
+ isinstance(r, dict) and "acceptance_comment_url" in r for r in last
1585
+ ):
1586
+ found.append("'acceptance_comment_url' in 'last_fix_resolutions'")
1587
+ history = data.get("unblock_history")
1588
+ if isinstance(history, list) and any(isinstance(e, dict) and "action" in e for e in history):
1589
+ found.append("an 'action' in 'unblock_history'")
1590
+ return found
1591
+
1592
+
1593
+ def _refuse_acceptance_state(data: dict, protocol: str) -> None:
1594
+ """A file labelled before protocol 8 never wrote acceptance-action state (#211)."""
1595
+ planted = _acceptance_state_in(data)
1596
+ if planted:
1597
+ raise StateError(
1598
+ f"state file is labelled protocol_version {protocol!r}, which never wrote "
1599
+ f"{', '.join(planted)}; refusing to load a file that carries protocol-8 "
1600
+ "acceptance-action state -- it is a hand edit or a foreign file, not an older "
1601
+ "controller's state"
1602
+ )
1603
+
1604
+
1605
+ def _operator_state_in(data: dict) -> list[str]:
1606
+ """What of protocol 11's operator-action state (#243) ``data`` carries, named.
1607
+
1608
+ The label decides, not the value: ``operator_comment_outcomes`` is
1609
+ protocol 11's own field, so an older file that carries it at all, even
1610
+ empty, was not written by the controller its label names.
1611
+ """
1612
+ found: list[str] = []
1613
+ if "operator_comment_outcomes" in data:
1614
+ found.append("'operator_comment_outcomes'")
1615
+ records = data.get("effect_records")
1616
+ if isinstance(records, list) and any(
1617
+ isinstance(r, dict) and r.get("kind") == EffectKind.OPERATOR_COMMENT.value for r in records
1618
+ ):
1619
+ found.append("an 'operator_comment' effect record")
1620
+ observation = data.get("entry_observation")
1621
+ if isinstance(observation, dict) and "operator_targets" in observation:
1622
+ found.append("'operator_targets' in 'entry_observation'")
1623
+ context = data.get("completion_context")
1624
+ resolutions = context.get("resolutions") if isinstance(context, dict) else None
1625
+ if isinstance(resolutions, list) and any(
1626
+ isinstance(r, dict) and "operator_sources" in r for r in resolutions
1627
+ ):
1628
+ found.append("'operator_sources' in 'completion_context'")
1629
+ last = data.get("last_fix_resolutions")
1630
+ if isinstance(last, list) and any(
1631
+ isinstance(r, dict) and "operator_evidence" in r for r in last
1632
+ ):
1633
+ found.append("'operator_evidence' in 'last_fix_resolutions'")
1634
+ return found
1635
+
1636
+
1637
+ def _refuse_operator_state(data: dict, protocol: str) -> None:
1638
+ """A file labelled before protocol 11 never wrote operator-action state (#243)."""
1639
+ planted = _operator_state_in(data)
1640
+ if planted:
1641
+ raise StateError(
1642
+ f"state file is labelled protocol_version {protocol!r}, which never wrote "
1643
+ f"{', '.join(planted)}; refusing to load a file that carries protocol-11 "
1644
+ "operator-action state -- it is a hand edit or a foreign file, not an older "
1645
+ "controller's state"
1646
+ )
1647
+
1648
+
1649
+ def _operator_migration(data: dict) -> dict:
1650
+ """The fields of an older REMOTE file as protocol 11 writes them (#243).
1651
+
1652
+ A FIX context resolution gains ``operator_sources: null`` and a
1653
+ ``last_fix_resolutions`` entry ``operator_evidence: []``: an older
1654
+ controller never recorded an operator action. Malformed values are
1655
+ passed through for validation to refuse.
1656
+ """
1657
+ out: dict = {"operator_comment_outcomes": []}
1658
+ last = data.get("last_fix_resolutions")
1659
+ if isinstance(last, list):
1660
+ out["last_fix_resolutions"] = [
1661
+ {**r, "operator_evidence": []} if isinstance(r, dict) else r for r in last
1662
+ ]
1663
+ context = data.get("completion_context")
1664
+ if isinstance(context, dict) and isinstance(context.get("resolutions"), list):
1665
+ out["completion_context"] = {
1666
+ **context,
1667
+ "resolutions": [
1668
+ {**r, "operator_sources": None} if isinstance(r, dict) else r
1669
+ for r in context["resolutions"]
1670
+ ],
1671
+ }
1672
+ return out
1673
+
1674
+
1675
+ def _acceptance_migration(data: dict) -> dict:
1676
+ """The fields of an older REMOTE file as protocol 8 writes them (#211).
1677
+
1678
+ A FIX context resolution gains ``acceptance_source: null`` and a
1679
+ ``last_fix_resolutions`` entry ``acceptance_comment_url: ""``: an older
1680
+ controller never published an acceptance comment. Malformed values are
1681
+ passed through for validation to refuse.
1682
+ """
1683
+ out: dict = {}
1684
+ last = data.get("last_fix_resolutions")
1685
+ if isinstance(last, list):
1686
+ out["last_fix_resolutions"] = [
1687
+ {**r, "acceptance_comment_url": ""} if isinstance(r, dict) else r for r in last
1688
+ ]
1689
+ context = data.get("completion_context")
1690
+ if isinstance(context, dict) and isinstance(context.get("resolutions"), list):
1691
+ out["completion_context"] = {
1692
+ **context,
1693
+ "resolutions": [
1694
+ {**r, "acceptance_source": None} if isinstance(r, dict) else r
1695
+ for r in context["resolutions"]
1696
+ ],
1697
+ }
1698
+ return out
1699
+
1700
+
1701
+ def _validate_config_fingerprint(state: AutoForgeState) -> None:
1702
+ """``config_fingerprint`` (#219): ``{}`` or a whole recorded configuration.
1703
+
1704
+ A LOCAL run is defined by its run contract and records none. A REMOTE
1705
+ record that does not parse is corruption, never "not recorded": read as
1706
+ none, it would switch the check off.
1707
+ """
1708
+ recorded = state.config_fingerprint
1709
+ if not isinstance(recorded, dict):
1710
+ raise StateError("state field 'config_fingerprint' must be an object")
1711
+ if not recorded:
1712
+ return
1713
+ if state.mode == WorkflowMode.LOCAL:
1714
+ raise StateError(
1715
+ "state field 'config_fingerprint' is set on a LOCAL run, which its run contract "
1716
+ "defines; refusing to load"
1717
+ )
1718
+ try:
1719
+ RecordedConfiguration.from_dict(recorded)
1720
+ except StateError as exc:
1721
+ raise StateError(f"state field 'config_fingerprint': {exc}") from None
1722
+
1723
+
1724
+ def _validate_sandbox_pin(state: AutoForgeState) -> None:
1725
+ """``sandbox_pin`` (#280): ``{}`` or a whole pin, for a LOCAL and a REMOTE run alike.
1726
+
1727
+ A pin that does not parse is corruption, never "not pinned": read as
1728
+ none, it would switch the check off.
1729
+ """
1730
+ pin = state.sandbox_pin
1731
+ if not isinstance(pin, dict):
1732
+ raise StateError("state field 'sandbox_pin' must be an object")
1733
+ if not pin:
1734
+ return
1735
+ try:
1736
+ SandboxPin.from_dict(pin)
1737
+ except StateError as exc:
1738
+ raise StateError(f"state field 'sandbox_pin': {exc}") from None
1739
+
1740
+
1741
+ def _validate_acceptance_state(state: AutoForgeState) -> None:
1742
+ """``acceptance_outcomes`` and ``acceptance_comment_url`` (#211), strictly.
1743
+
1744
+ A LOCAL run publishes nothing, so it carries neither, nor an unblock
1745
+ ``action``. A REMOTE ``last_fix_resolutions`` entry carries the URL of
1746
+ the comment the controller verified for an ``acceptance_action``
1747
+ resolution, and "" for any other.
1748
+ """
1749
+ outcomes = state.acceptance_outcomes
1750
+ if not isinstance(outcomes, list) or not all(isinstance(o, dict) for o in outcomes):
1751
+ raise StateError("state field 'acceptance_outcomes' must be a list of objects")
1752
+ if state.mode == WorkflowMode.LOCAL:
1753
+ if outcomes:
1754
+ raise StateError(
1755
+ "state field 'acceptance_outcomes' is set on a LOCAL run, which publishes "
1756
+ "nothing; refusing to load"
1757
+ )
1758
+ if any("acceptance_comment_url" in r for r in state.last_fix_resolutions):
1759
+ raise StateError(
1760
+ "state field 'last_fix_resolutions' carries 'acceptance_comment_url' on a "
1761
+ "LOCAL run, which publishes nothing; refusing to load"
1762
+ )
1763
+ if any(isinstance(e, dict) and "action" in e for e in state.unblock_history):
1764
+ raise StateError(
1765
+ "state field 'unblock_history' carries an acceptance 'action' on a LOCAL run, "
1766
+ "which has no acceptance actions; refusing to load"
1767
+ )
1768
+ return
1769
+ if len(outcomes) > MAX_ACCEPTANCE_OUTCOMES:
1770
+ raise StateError(
1771
+ f"state field 'acceptance_outcomes' holds {len(outcomes)} entries, over "
1772
+ f"{MAX_ACCEPTANCE_OUTCOMES}"
1773
+ )
1774
+ declines: dict[int, int] = {}
1775
+ for i, entry in enumerate(outcomes):
1776
+ what = f"state field 'acceptance_outcomes'[{i}]"
1777
+ if sorted(entry) != sorted(_ACCEPTANCE_OUTCOME_KEYS):
1778
+ raise StateError(f"{what} must hold exactly {', '.join(_ACCEPTANCE_OUTCOME_KEYS)}")
1779
+ if not isinstance(entry["action_id"], str) or not ACTION_ID_RE.match(entry["action_id"]):
1780
+ raise StateError(f"{what}.action_id must be an action id of the form A-<16 hex>")
1781
+ round_ = entry["round"]
1782
+ if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
1783
+ raise StateError(f"{what}.round must be an integer >= 1")
1784
+ fid = entry["finding_id"]
1785
+ match = (
1786
+ FINDING_ID_RE.match(fid)
1787
+ if isinstance(fid, str) and len(fid) <= MAX_FINDING_ID_CHARS
1788
+ else None
1789
+ )
1790
+ if match is None or int(match.group("round")) != round_:
1791
+ raise StateError(f"{what}.finding_id must be a finding id of its round")
1792
+ if entry["outcome"] not in ACCEPTANCE_OUTCOMES:
1793
+ raise StateError(f"{what}.outcome must be one of {ACCEPTANCE_OUTCOMES}")
1794
+ if not isinstance(entry["at"], str) or not entry["at"]:
1795
+ raise StateError(f"{what}.at must be a non-empty timestamp")
1796
+ reason = entry["reason"]
1797
+ if not isinstance(reason, str) or len(reason) > MAX_CONFLICT_REASON_CHARS:
1798
+ raise StateError(
1799
+ f"{what}.reason must be a string of at most {MAX_CONFLICT_REASON_CHARS} characters"
1800
+ )
1801
+ _comment_url_field(
1802
+ state, entry["comment_url"], f"{what}.comment_url", entry["outcome"] == "verified"
1803
+ )
1804
+ if entry["outcome"] == "declined":
1805
+ declines[round_] = declines.get(round_, 0) + 1
1806
+ for round_, count in declines.items():
1807
+ if count > MAX_DECLINES_PER_ROUND:
1808
+ raise StateError(
1809
+ f"state field 'acceptance_outcomes' holds {count} declines of review round "
1810
+ f"{round_}, over {MAX_DECLINES_PER_ROUND}, the most one round records"
1811
+ )
1812
+ for i, entry in enumerate(state.last_fix_resolutions):
1813
+ what = f"state field 'last_fix_resolutions'[{i}].acceptance_comment_url"
1814
+ if "acceptance_comment_url" not in entry:
1815
+ raise StateError(f"{what} is missing")
1816
+ _comment_url_field(
1817
+ state,
1818
+ entry["acceptance_comment_url"],
1819
+ what,
1820
+ entry.get("resolution") == "acceptance_action",
1821
+ )
1822
+
1823
+
1824
+ def _operator_target_field(state: AutoForgeState, value: object, what: str) -> str:
1825
+ """``value`` is the canonical URL of another issue of the run's repository (#243)."""
1826
+ if not isinstance(value, str):
1827
+ raise StateError(f"{what} must be an issue URL string")
1828
+ try:
1829
+ target = parse_issue_url(value)
1830
+ except ConfigurationError as exc:
1831
+ raise StateError(f"{what}: {exc}") from None
1832
+ if target.canonical != value:
1833
+ raise StateError(f"{what} must be stored in its canonical form")
1834
+ if state.repository and not target.same_repository(state.repository):
1835
+ raise StateError(f"{what} is not an issue of repository {state.repository!r}")
1836
+ try:
1837
+ own = parse_issue_url(state.current_issue_url) if state.current_issue_url else None
1838
+ except ConfigurationError as exc:
1839
+ raise StateError(f"{what}: the run's issue URL is malformed ({exc})") from None
1840
+ if own is not None and target.same_target(own):
1841
+ raise StateError(f"{what} is the run's own issue, which an operator action never targets")
1842
+ return value
1843
+
1844
+
1845
+ def _operator_comment_field(value: object, target: str, what: str, required: bool) -> None:
1846
+ """``value`` is a comment URL on ``target`` when ``required``, else ""."""
1847
+ if not isinstance(value, str):
1848
+ raise StateError(f"{what} must be a string")
1849
+ if not required:
1850
+ if value:
1851
+ raise StateError(f"{what} must be empty: no operator comment was verified")
1852
+ return
1853
+ try:
1854
+ comment = parse_comment_url(value)
1855
+ except ConfigurationError as exc:
1856
+ raise StateError(f"{what}: {exc}") from None
1857
+ if comment.canonical != value or not comment.on(parse_issue_url(target)):
1858
+ raise StateError(f"{what} is not a canonical comment URL on its target {target}")
1859
+
1860
+
1861
+ def _digest_field(value: object, what: str, required: bool) -> None:
1862
+ if not isinstance(value, str) or not (
1863
+ _HEX64_RE.fullmatch(value) if required else value in ("",)
1864
+ ):
1865
+ raise StateError(
1866
+ f"{what} must be a 64-character lowercase SHA-256 hex digest"
1867
+ if required
1868
+ else f"{what} must be empty"
1869
+ )
1870
+
1871
+
1872
+ def _validate_operator_evidence(state: AutoForgeState, value: object, what: str) -> None:
1873
+ """One ``last_fix_resolutions`` entry's ``operator_evidence`` (#243)."""
1874
+ if not isinstance(value, list) or len(value) > MAX_OPERATOR_COMMENT_OUTCOMES:
1875
+ raise StateError(
1876
+ f"{what} must be a list of at most {MAX_OPERATOR_COMMENT_OUTCOMES} objects"
1877
+ )
1878
+ for j, item in enumerate(value):
1879
+ where = f"{what}[{j}]"
1880
+ if not isinstance(item, dict) or sorted(item) != sorted(_OPERATOR_EVIDENCE_KEYS):
1881
+ raise StateError(f"{where} must hold exactly {', '.join(_OPERATOR_EVIDENCE_KEYS)}")
1882
+ action_id = item["action_id"]
1883
+ if not isinstance(action_id, str) or not OPERATOR_ACTION_ID_RE.match(action_id):
1884
+ raise StateError(f"{where}.action_id must be an action id of the form X-<16 hex>")
1885
+ target = _operator_target_field(state, item["target"], f"{where}.target")
1886
+ _operator_comment_field(item["comment_url"], target, f"{where}.comment_url", True)
1887
+ if item["evidence"] not in EVIDENCE_CLASSES:
1888
+ raise StateError(f"{where}.evidence must be one of {EVIDENCE_CLASSES}")
1889
+ _digest_field(item["body_sha256"], f"{where}.body_sha256", True)
1890
+ _digest_field(item["payload_sha256"], f"{where}.payload_sha256", True)
1891
+
1892
+
1893
+ def _validate_operator_state(state: AutoForgeState) -> None:
1894
+ """``operator_comment_outcomes`` and ``operator_evidence`` (#243), strictly.
1895
+
1896
+ A LOCAL run has no GitHub and no operator actions, so it carries
1897
+ neither. A REMOTE ``last_fix_resolutions`` entry carries the operator
1898
+ comments verified for its finding, ``[]`` when none. A ``verified``
1899
+ outcome is unique per (action id, comment), and the round's declines,
1900
+ K9's and K10's together, stay within :data:`MAX_DECLINES_PER_ROUND`.
1901
+ """
1902
+ outcomes = state.operator_comment_outcomes
1903
+ if not isinstance(outcomes, list) or not all(isinstance(o, dict) for o in outcomes):
1904
+ raise StateError("state field 'operator_comment_outcomes' must be a list of objects")
1905
+ if state.mode == WorkflowMode.LOCAL:
1906
+ if outcomes:
1907
+ raise StateError(
1908
+ "state field 'operator_comment_outcomes' is set on a LOCAL run, which has no "
1909
+ "operator actions; refusing to load"
1910
+ )
1911
+ if any("operator_evidence" in r for r in state.last_fix_resolutions):
1912
+ raise StateError(
1913
+ "state field 'last_fix_resolutions' carries 'operator_evidence' on a LOCAL run, "
1914
+ "which has no operator actions; refusing to load"
1915
+ )
1916
+ return
1917
+ if len(outcomes) > MAX_OPERATOR_COMMENT_OUTCOMES:
1918
+ raise StateError(
1919
+ f"state field 'operator_comment_outcomes' holds {len(outcomes)} entries, over "
1920
+ f"{MAX_OPERATOR_COMMENT_OUTCOMES}"
1921
+ )
1922
+ verified: set[tuple[str, int]] = set()
1923
+ for i, entry in enumerate(outcomes):
1924
+ what = f"state field 'operator_comment_outcomes'[{i}]"
1925
+ if sorted(entry) != sorted(_OPERATOR_OUTCOME_KEYS):
1926
+ raise StateError(f"{what} must hold exactly {', '.join(_OPERATOR_OUTCOME_KEYS)}")
1927
+ action_id = entry["action_id"]
1928
+ if not isinstance(action_id, str) or not OPERATOR_ACTION_ID_RE.match(action_id):
1929
+ raise StateError(f"{what}.action_id must be an action id of the form X-<16 hex>")
1930
+ round_ = entry["round"]
1931
+ if not isinstance(round_, int) or isinstance(round_, bool) or round_ < 1:
1932
+ raise StateError(f"{what}.round must be an integer >= 1")
1933
+ fid = entry["finding_id"]
1934
+ match = (
1935
+ FINDING_ID_RE.match(fid)
1936
+ if isinstance(fid, str) and len(fid) <= MAX_FINDING_ID_CHARS
1937
+ else None
1938
+ )
1939
+ if match is None or int(match.group("round")) != round_:
1940
+ raise StateError(f"{what}.finding_id must be a finding id of its round")
1941
+ target = _operator_target_field(state, entry["target"], f"{what}.target")
1942
+ outcome = entry["outcome"]
1943
+ if outcome not in OPERATOR_COMMENT_OUTCOMES:
1944
+ raise StateError(f"{what}.outcome must be one of {OPERATOR_COMMENT_OUTCOMES}")
1945
+ is_verified = outcome == "verified"
1946
+ if is_verified:
1947
+ if entry["evidence"] not in EVIDENCE_CLASSES:
1948
+ raise StateError(f"{what}.evidence must be one of {EVIDENCE_CLASSES}")
1949
+ elif entry["evidence"] != "":
1950
+ raise StateError(f"{what}.evidence must be empty for a {outcome} action")
1951
+ _operator_comment_field(entry["comment_url"], target, f"{what}.comment_url", is_verified)
1952
+ _digest_field(entry["body_sha256"], f"{what}.body_sha256", is_verified)
1953
+ _digest_field(entry["payload_sha256"], f"{what}.payload_sha256", True)
1954
+ if not isinstance(entry["at"], str) or not entry["at"]:
1955
+ raise StateError(f"{what}.at must be a non-empty timestamp")
1956
+ reason = entry["reason"]
1957
+ if not isinstance(reason, str) or len(reason) > MAX_CONFLICT_REASON_CHARS:
1958
+ raise StateError(
1959
+ f"{what}.reason must be a string of at most {MAX_CONFLICT_REASON_CHARS} characters"
1960
+ )
1961
+ if is_verified:
1962
+ key = (action_id, parse_comment_url(entry["comment_url"]).comment_id)
1963
+ if key in verified:
1964
+ raise StateError(f"{what} verifies action {action_id} on one comment twice")
1965
+ verified.add(key)
1966
+ declines: dict[int, int] = {}
1967
+ for entry in (*state.acceptance_outcomes, *outcomes):
1968
+ if entry["outcome"] == "declined":
1969
+ declines[entry["round"]] = declines.get(entry["round"], 0) + 1
1970
+ for round_, count in declines.items():
1971
+ if count > MAX_DECLINES_PER_ROUND:
1972
+ raise StateError(
1973
+ f"state fields 'acceptance_outcomes' and 'operator_comment_outcomes' hold "
1974
+ f"{count} declines of review round {round_}, over {MAX_DECLINES_PER_ROUND}, the "
1975
+ "most one round records"
1976
+ )
1977
+ for i, entry in enumerate(state.last_fix_resolutions):
1978
+ what = f"state field 'last_fix_resolutions'[{i}].operator_evidence"
1979
+ if "operator_evidence" not in entry:
1980
+ raise StateError(f"{what} is missing")
1981
+ _validate_operator_evidence(state, entry["operator_evidence"], what)
1982
+
1983
+
1984
+ def _comment_url_field(state: AutoForgeState, value: object, what: str, required: bool) -> None:
1985
+ """``value`` is a comment URL on the current issue when ``required``, else ""."""
1986
+ if not isinstance(value, str):
1987
+ raise StateError(f"{what} must be a string")
1988
+ if not required:
1989
+ if value:
1990
+ raise StateError(f"{what} must be empty: no acceptance comment was verified")
1991
+ return
1992
+ try:
1993
+ comment = parse_comment_url(value)
1994
+ issue = parse_issue_url(state.current_issue_url)
1995
+ except ConfigurationError as exc:
1996
+ raise StateError(f"{what}: {exc}") from None
1997
+ if not comment.on(issue):
1998
+ raise StateError(f"{what} is not a comment on the current issue")
1999
+
2000
+
2001
+ def _validate_unblock_history(history: object) -> None:
2002
+ """``unblock_history`` is a list of complete, string-valued entries.
2003
+
2004
+ Each entry names the phase the operator re-entered, so a value that is
2005
+ not a phase (or an entry missing a field) is corruption: the trail would
2006
+ otherwise claim a transition the topology has no name for.
2007
+ """
2008
+ if not isinstance(history, list):
2009
+ raise StateError("state field 'unblock_history' must be a list of objects")
2010
+ for entry in history:
2011
+ if not isinstance(entry, dict):
2012
+ raise StateError("state field 'unblock_history' must be a list of objects")
2013
+ for key in _UNBLOCK_ENTRY_FIELDS:
2014
+ if not isinstance(entry.get(key), str):
2015
+ raise StateError(
2016
+ f"state field 'unblock_history': every entry needs a string {key!r}"
2017
+ )
2018
+ if not entry["at"] or not entry["phase"]:
2019
+ raise StateError("state field 'unblock_history': 'at' and 'phase' must be non-empty")
2020
+ if "action" in entry and (not isinstance(entry["action"], str) or not entry["action"]):
2021
+ # #211: the acceptance action an unblock approved or declined.
2022
+ raise StateError("state field 'unblock_history': 'action' must be a non-empty string")
2023
+ try:
2024
+ Phase(entry["phase"])
2025
+ except ValueError:
2026
+ raise StateError(
2027
+ f"state field 'unblock_history': {entry['phase']!r} is not a phase"
2028
+ ) from None
2029
+
2030
+
2031
+ # Every field the dataclass declares as a plain `str` or `int`, derived from
2032
+ # the declaration itself so that adding a field cannot forget to validate it.
2033
+ # Fields with richer types (the enums, the lists, the dicts) are checked
2034
+ # individually in `from_dict`, because "is a list" is rarely the whole
2035
+ # contract for them.
2036
+ _SCALAR_FIELDS: dict[str, type] = {
2037
+ f.name: {"str": str, "int": int}[f.type]
2038
+ for f in fields(AutoForgeState)
2039
+ if f.type in ("str", "int")
2040
+ }
2041
+
2042
+ # The counters that bound the run. A negative one does not merely look wrong:
2043
+ # it disables the bound it is compared against.
2044
+ _NON_NEGATIVE_FIELDS = (
2045
+ "local_fix_rounds",
2046
+ "local_pending_attempts",
2047
+ "step_count",
2048
+ "review_round",
2049
+ "attempt",
2050
+ "merged_since_epic_update",
2051
+ "escalation_count",
2052
+ )
2053
+
2054
+ # `execution_attempt` counts from one: attempt zero never happened.
2055
+ _MINIMUM_ONE_FIELDS = ("execution_attempt",)
2056
+
2057
+
2058
+ # -- paths ---------------------------------------------------------------
2059
+ @dataclass(frozen=True)
2060
+ class StatePaths:
2061
+ """Where a run's state and logs live, and the directory they are reached from.
2062
+
2063
+ ``anchor`` is the one directory in the chain that AutoForge did not
2064
+ create and therefore resolves by pathname; ``relative`` is the path from
2065
+ it to the state directory, and every component of it is opened
2066
+ descriptor-relative with ``O_NOFOLLOW`` (see :mod:`autoforge.safefs`).
2067
+ That split is the whole of the trust statement: for a LOCAL run the
2068
+ anchor is the repository's git directory and the components below it are
2069
+ AutoForge's own, so replacing one of them with a symbolic link cannot
2070
+ move a controller write.
2071
+
2072
+ The controller lock is *not* here: it is keyed by the repository
2073
+ identity, not by the caller-selectable state directory (see
2074
+ :func:`autoforge.locking.repository_lock_path`).
2075
+ """
2076
+
2077
+ state_dir: Path
2078
+ state_file: Path
2079
+ logs_dir: Path
2080
+ anchor: Path
2081
+ relative: tuple[str, ...]
2082
+
2083
+ @classmethod
2084
+ def from_state_dir(
2085
+ cls, state_dir: str | Path, *, anchor: str | Path | None = None
2086
+ ) -> StatePaths:
2087
+ d = Path(state_dir)
2088
+ absolute = Path(os.path.abspath(d))
2089
+ if anchor is None:
2090
+ anchor_path = absolute.parent
2091
+ relative: tuple[str, ...] = (absolute.name,)
2092
+ else:
2093
+ anchor_path = Path(os.path.abspath(anchor))
2094
+ try:
2095
+ relative = absolute.relative_to(anchor_path).parts
2096
+ except ValueError:
2097
+ raise StateError(
2098
+ f"state directory {absolute} is not inside its anchor {anchor_path}"
2099
+ ) from None
2100
+ return cls(
2101
+ state_dir=d,
2102
+ state_file=d / STATE_FILENAME,
2103
+ logs_dir=d / LOGS_DIRNAME,
2104
+ anchor=anchor_path,
2105
+ relative=tuple(relative),
2106
+ )
2107
+
2108
+ def canonical_state_dir(self) -> str:
2109
+ """The state directory's canonical absolute pathname (contract form)."""
2110
+ return os.path.realpath(os.path.abspath(self.state_dir))
2111
+
2112
+ def open_root(self, *, create: bool = True) -> SafeRoot:
2113
+ """Open the state directory as a capability (the caller closes it).
2114
+
2115
+ This is a *pathname* operation and is therefore performed once per
2116
+ run by the engine, which then holds the capability for its lifetime
2117
+ (see ``Engine.state_root``): every later write goes through the held
2118
+ descriptor and ``SafeRoot.verify_identity`` proves the pathname still
2119
+ names it. Opening a second time would bind whatever the pathname
2120
+ names *now*, which is the rebinding the held capability exists to
2121
+ prevent, so nothing in the controller calls this twice for one run.
2122
+ """
2123
+ root = SafeRoot.open(self.anchor, create=create)
2124
+ if not self.relative:
2125
+ return root
2126
+ try:
2127
+ return root.subroot("/".join(self.relative), create=create)
2128
+ finally:
2129
+ root.close()
2130
+
2131
+
2132
+ # -- persistence ----------------------------------------------------------
2133
+ def _root_for(
2134
+ path: Path, root: SafeRoot | None, *, create: bool = True
2135
+ ) -> tuple[SafeRoot, str, bool]:
2136
+ """The capability a state-file operation runs through, and the entry name.
2137
+
2138
+ Callers that already hold a root (the engine does, for the whole run)
2139
+ pass it and keep the stronger anchor described on :class:`StatePaths`.
2140
+ Callers with only a pathname get a root on the file's own directory:
2141
+ weaker in that the directory itself is resolved by pathname, identical in
2142
+ every other respect -- the entry, its temporary and the rename that
2143
+ publishes it are all named relative to one descriptor.
2144
+
2145
+ ``create`` is false for reads: looking for state must never bring the
2146
+ directory that would hold it into existence.
2147
+ """
2148
+ if root is not None:
2149
+ return root, Path(path).name, False
2150
+ dest = Path(os.path.abspath(path))
2151
+ return SafeRoot.open(dest.parent, create=create), dest.name, True
2152
+
2153
+
2154
+ def save_state(state: AutoForgeState, path: str | Path, *, root: SafeRoot | None = None) -> None:
2155
+ """Atomically persist state: temp file + fsync + atomic replace + fsync dir.
2156
+
2157
+ The replace publishes a *name*, never a truncation of whatever inode the
2158
+ name happened to reach, so a hard link planted at ``state.json`` keeps its
2159
+ contents and a concurrent reader sees one whole version or the other.
2160
+ """
2161
+ state.touch()
2162
+ payload = json.dumps(state.to_dict(), indent=2, sort_keys=True) + "\n"
2163
+ fs, name, owned = _root_for(Path(path), root)
2164
+ try:
2165
+ fs.write_bytes(name, payload.encode("utf-8"), mode=0o600)
2166
+ finally:
2167
+ if owned:
2168
+ fs.close()
2169
+
2170
+
2171
+ def _not_regular(p: Path, kind: str) -> StateError:
2172
+ """The state file's name holds something the controller did not write.
2173
+
2174
+ ``kind`` comes from ``lstat``, so a symbolic link is reported as a
2175
+ symbolic link and never resolved. What it points at is deliberately not
2176
+ part of the message: the controller refuses the link itself, dangling or
2177
+ not, so classifying the target would only invite the reader to think some
2178
+ targets would have been acceptable.
2179
+ """
2180
+ # A directory cannot be archived by quarantine_state_file (no hard links
2181
+ # to directories), so 'run --force' is no way out of it.
2182
+ hint = (
2183
+ "move it out of the way by hand"
2184
+ if kind == "directory"
2185
+ else "move it aside or use 'run --force'"
2186
+ )
2187
+ return StateError(
2188
+ f"corrupted state file {p}: it is a {kind}, not a regular file; "
2189
+ f"refusing to overwrite — {hint}"
2190
+ )
2191
+
2192
+
2193
+ def no_state_error(path: str | Path) -> StateError:
2194
+ """There is no run at ``path`` (and a read never creates one)."""
2195
+ return StateError(
2196
+ f"no state file at {Path(path)} — run 'autoforge run --epic ... --issue ...' first; "
2197
+ "'resume' never creates a new run silently"
2198
+ )
2199
+
2200
+
2201
+ def load_state(path: str | Path, *, root: SafeRoot | None = None) -> AutoForgeState:
2202
+ """Load state; raises StateError (never silently re-inits) on problems."""
2203
+ p = Path(path)
2204
+ missing = no_state_error(p)
2205
+ try:
2206
+ fs, name, owned = _root_for(p, root, create=False)
2207
+ except FileNotFoundError:
2208
+ raise missing from None
2209
+ try:
2210
+ st = fs.lstat(name)
2211
+ if st is None:
2212
+ raise missing
2213
+ kind = entry_kind(st.st_mode)
2214
+ if kind is not None:
2215
+ # A symbolic link is refused as such, dangling or not: the
2216
+ # controller reads and replaces its own regular file, and
2217
+ # following a link would put state where the operator is not
2218
+ # looking for it.
2219
+ raise _not_regular(p, kind)
2220
+ try:
2221
+ raw_bytes = fs.read_bytes(name, limit=MAX_STATE_FILE_BYTES)
2222
+ except ReadLimitExceeded as exc:
2223
+ # Refused before it is held: the bounded read stops one byte past
2224
+ # the limit, so a sparse or oversized file costs at most that.
2225
+ raise StateError(
2226
+ f"corrupted state file {p}: larger than {MAX_STATE_FILE_BYTES} bytes, "
2227
+ "which no controller state can be; "
2228
+ "refusing to overwrite — restore from backup or re-run"
2229
+ ) from exc
2230
+ except OSError as exc:
2231
+ raise StateError(f"cannot read state file {p}: {exc}") from exc
2232
+ if raw_bytes is None: # pragma: no cover - lstat above just found it
2233
+ raise StateError(f"no state file at {p}")
2234
+ finally:
2235
+ if owned:
2236
+ fs.close()
2237
+ try:
2238
+ raw = raw_bytes.decode("utf-8")
2239
+ except UnicodeDecodeError as exc:
2240
+ # Invalid UTF-8 is a corrupt file, not a read failure: it must take
2241
+ # the same fail-loud / quarantine path as unparseable JSON.
2242
+ raise StateError(
2243
+ f"corrupted state file {p}: not valid UTF-8 ({exc}); "
2244
+ "refusing to overwrite — restore from backup or re-run"
2245
+ ) from exc
2246
+ try:
2247
+ data = json.loads(raw)
2248
+ except json.JSONDecodeError as exc:
2249
+ raise StateError(
2250
+ f"corrupted state file {p}: invalid JSON ({exc}); "
2251
+ "refusing to overwrite — restore from backup or re-run"
2252
+ ) from exc
2253
+ except (ValueError, RecursionError) as exc:
2254
+ # The decoder's own limits, which are not JSONDecodeError: an integer
2255
+ # past ``sys.get_int_max_str_digits()`` and nesting past the recursion
2256
+ # limit or the C stack, both inside MAX_STATE_FILE_BYTES. Corrupt like
2257
+ # unparseable JSON, reported by type only; an interrupt still propagates.
2258
+ raise StateError(
2259
+ f"corrupted state file {p}: exceeds the JSON decoder's integer or nesting "
2260
+ f"limit ({type(exc).__name__}); "
2261
+ "refusing to overwrite — restore from backup or re-run"
2262
+ ) from exc
2263
+ return AutoForgeState.from_dict(data)
2264
+
2265
+
2266
+ _QUARANTINE_MAX_ATTEMPTS = 1000
2267
+
2268
+
2269
+ def quarantine_state_file(
2270
+ path: str | Path, *, root: SafeRoot | None = None, what: str = "corrupted state file"
2271
+ ) -> Path:
2272
+ """Move an unreadable state file aside instead of deleting it.
2273
+
2274
+ Renames ``<path>`` to ``<path>.corrupt-<UTC timestamp>`` (a numeric
2275
+ suffix is appended if that name is already taken) and returns the new
2276
+ path. Never overwrites an existing file: the destination is reserved
2277
+ with :meth:`~autoforge.safefs.SafeRoot.link`, which fails atomically with
2278
+ ``EEXIST`` when the name is already taken (a plain rename would silently
2279
+ replace a file created between the existence check and the move). The
2280
+ directory entry itself is moved: a symbolic link (dangling or not) is
2281
+ archived as a link and the file it points to is never followed, modified
2282
+ or removed; a FIFO, socket or device entry is archived as that entry
2283
+ without being opened. A directory cannot be hard-linked and is refused:
2284
+ it stays untouched and must be moved aside by hand. Raises StateError
2285
+ when the move fails; the original entry is left untouched in that case.
2286
+
2287
+ Where the filesystem makes no hard links (vfat/exFAT, some FUSE, SMB and
2288
+ overlay mounts; :class:`~autoforge.safefs.HardLinksUnavailable`) the
2289
+ name is reserved with
2290
+ :meth:`~autoforge.safefs.SafeRoot.copy_entry_exclusive` instead, which
2291
+ keeps the no-replace guarantee: a regular file is copied into a name
2292
+ created ``O_CREAT | O_EXCL`` and fsynced, a symbolic link is recreated
2293
+ with ``symlink(2)``. A FIFO, socket or device cannot be reproduced that
2294
+ way and is refused there, untouched, like a directory is everywhere.
2295
+
2296
+ Reservation and removal of the source are two syscalls on either path.
2297
+ A crash between them leaves ``path`` in place beside a complete archive
2298
+ of it; the next ``run --force`` archives it again under a new name, so
2299
+ the outcome is a duplicate copy and never a lost one. (On the copy path
2300
+ a crash *during* the copy leaves a partial archive under a name later
2301
+ reservations will not reuse, beside the untouched source.) The caller
2302
+ must hold the controller lock (the CLI does, via
2303
+ ``ControllerEngine.locked()``): a writer replacing ``path`` between the
2304
+ two syscalls would see the replacement removed.
2305
+
2306
+ ``what`` names the file in every refusal: the scheduler quarantines its
2307
+ own ``scheduler.json`` with this primitive (``epic run --force``, ADR
2308
+ 0008 D6.3, :func:`autoforge.scheduler_state.quarantine_scheduler_state`).
2309
+ """
2310
+ return _move_state_file_aside(
2311
+ path, root=root, suffix=CORRUPT_SUFFIX, what=what, verb="quarantined"
2312
+ )
2313
+
2314
+
2315
+ def archive_state_file(
2316
+ path: str | Path, *, root: SafeRoot | None = None, what: str = "state file"
2317
+ ) -> Path:
2318
+ """Move a *readable* state file aside as ``<path>.archived-<UTC timestamp>``.
2319
+
2320
+ The same primitive as :func:`quarantine_state_file`, with its own
2321
+ suffix: the name is reserved by ``link(2)`` (or an exclusive copy where
2322
+ the filesystem makes no hard links) and only then is ``path`` unlinked,
2323
+ so a crash between the two leaves both names and a re-run moves
2324
+ ``path`` aside again, under a new name; a state is never lost. Used by a
2325
+ scheduler namespace's archive (ADR 0008 D17.4,
2326
+ :func:`autoforge.run_namespace.archive_issue_state`), whose caller holds
2327
+ the issue's exclusive authority as quarantining's holds the controller
2328
+ lock. Raises StateError when there is nothing at ``path`` or the move
2329
+ fails; the original entry is left untouched in that case. ``what`` names
2330
+ the file in every refusal: ``epic archive`` moves a corrupt ``claim.json``
2331
+ aside with this primitive (:func:`autoforge.scheduler_state.archive_claim`).
2332
+ """
2333
+ return _move_state_file_aside(
2334
+ path, root=root, suffix=ARCHIVED_SUFFIX, what=what, verb="archived"
2335
+ )
2336
+
2337
+
2338
+ def _move_state_file_aside(
2339
+ path: str | Path, *, root: SafeRoot | None, suffix: str, what: str, verb: str
2340
+ ) -> Path:
2341
+ """Rename ``path`` to ``<path><suffix><UTC timestamp>`` without replacing anything.
2342
+
2343
+ The link-then-unlink primitive behind :func:`quarantine_state_file` (its
2344
+ docstring is the contract) and :func:`archive_state_file`. ``what`` names
2345
+ the file and ``verb`` the operation in every refusal.
2346
+ """
2347
+ src = Path(path)
2348
+ fs, name, owned = _root_for(src, root)
2349
+ try:
2350
+ st = fs.lstat(name)
2351
+ if st is None:
2352
+ raise StateError(f"cannot move {what} {src} aside: it is gone")
2353
+ if stat.S_ISDIR(st.st_mode):
2354
+ raise StateError(
2355
+ f"cannot move {what} {src} aside: it is a directory; "
2356
+ "move it out of the way by hand and re-run"
2357
+ )
2358
+ source_identity = (st.st_dev, st.st_ino, st.st_mode)
2359
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
2360
+ base = f"{name}{suffix}{stamp}"
2361
+ candidate = base
2362
+ hard_links = True
2363
+
2364
+ def reserve(candidate: str) -> None:
2365
+ # link(2) reserves the name and the bytes in one syscall. Where
2366
+ # the filesystem has none, the entry is reproduced under a name
2367
+ # created O_EXCL (or by symlink(2)), which is equally no-replace;
2368
+ # once that has been learned, later attempts skip the link.
2369
+ nonlocal hard_links
2370
+ if hard_links:
2371
+ try:
2372
+ fs.link(name, candidate)
2373
+ return
2374
+ except HardLinksUnavailable:
2375
+ hard_links = False
2376
+ fs.copy_entry_exclusive(name, candidate)
2377
+
2378
+ for n in range(1, _QUARANTINE_MAX_ATTEMPTS + 1):
2379
+ try:
2380
+ reserve(candidate)
2381
+ except FileExistsError:
2382
+ candidate = f"{base}.{n}"
2383
+ continue
2384
+ except StateError as exc:
2385
+ raise StateError(f"cannot move {what} {src} aside: {exc}") from exc
2386
+ current = fs.lstat(name)
2387
+ current_identity = (
2388
+ None if current is None else (current.st_dev, current.st_ino, current.st_mode)
2389
+ )
2390
+ if current_identity != source_identity:
2391
+ try:
2392
+ fs.unlink(candidate)
2393
+ except StateError: # pragma: no cover - cleanup best effort
2394
+ pass
2395
+ raise StateError(
2396
+ f"cannot move {what} {src} aside: it changed while being "
2397
+ f"{verb}; refusing to remove the replacement"
2398
+ )
2399
+ try:
2400
+ fs.unlink(name)
2401
+ except StateError as exc:
2402
+ # Drop the reservation so the original is the only copy again,
2403
+ # and report the operation that failed rather than the syscall:
2404
+ # the caller asked to quarantine a file, not to unlink a name.
2405
+ try:
2406
+ fs.unlink(candidate)
2407
+ except StateError: # pragma: no cover - cleanup best effort
2408
+ pass
2409
+ raise StateError(f"cannot move {what} {src} aside: {exc}") from exc
2410
+ return src.with_name(candidate)
2411
+ raise StateError(
2412
+ f"cannot move {what} {src} aside: "
2413
+ f"no free name after {_QUARANTINE_MAX_ATTEMPTS} attempts (last tried {candidate})"
2414
+ )
2415
+ finally:
2416
+ if owned:
2417
+ fs.close()