autoforge-cli 0.1.0a0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/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()
|