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/roadmap.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The EPIC's managed roadmap section: locate, splice, and prove the rest untouched.
|
|
2
|
+
|
|
3
|
+
The EPIC body is the operator's document. AutoForge owns exactly one part
|
|
4
|
+
of it, the section between two marker lines::
|
|
5
|
+
|
|
6
|
+
<!-- ai-controller-roadmap:start -->
|
|
7
|
+
...
|
|
8
|
+
<!-- ai-controller-roadmap:end -->
|
|
9
|
+
|
|
10
|
+
and nothing else. The UPDATE_EPIC agent never edits the body: it returns
|
|
11
|
+
the section's new content in its ``CONTROL_RESULT`` and the controller
|
|
12
|
+
performs the edit (``gh issue edit --body-file``), reads the body back, and
|
|
13
|
+
requires every byte outside the markers to be identical to what it read
|
|
14
|
+
before writing. That read-back is the only evidence the update happened;
|
|
15
|
+
the counter of merges awaiting a roadmap update is reset after it and
|
|
16
|
+
never before.
|
|
17
|
+
|
|
18
|
+
This module is pure text logic so the splice and its acceptance rules can
|
|
19
|
+
be tested without an engine or a GitHub fake: :func:`split_roadmap` finds
|
|
20
|
+
the section (or proves there is none), :func:`splice_roadmap` builds the
|
|
21
|
+
body that carries a new one, and :func:`outside_roadmap` is the text the
|
|
22
|
+
read-back compares. A body whose markers cannot be read unambiguously (a
|
|
23
|
+
marker repeated, an end before a start, one without the other) raises
|
|
24
|
+
:class:`RoadmapError`: the controller then blocks rather than guess which
|
|
25
|
+
part of the operator's document is its own.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from dataclasses import dataclass
|
|
31
|
+
|
|
32
|
+
from .errors import AutoForgeError
|
|
33
|
+
|
|
34
|
+
ROADMAP_START_MARKER = "<!-- ai-controller-roadmap:start -->"
|
|
35
|
+
ROADMAP_END_MARKER = "<!-- ai-controller-roadmap:end -->"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RoadmapError(AutoForgeError):
|
|
39
|
+
"""The EPIC body's managed section cannot be located unambiguously."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class RoadmapSplit:
|
|
44
|
+
"""An EPIC body cut around its managed section.
|
|
45
|
+
|
|
46
|
+
``body`` is the text as read. ``before`` ends just after the start
|
|
47
|
+
marker (or is the whole body when there is no section), ``section`` is
|
|
48
|
+
the content between the marker lines (``None`` when absent), and
|
|
49
|
+
``after`` starts at the end marker. ``outside`` is what a read-back after
|
|
50
|
+
the controller's write must find byte-identical.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
body: str
|
|
54
|
+
before: str
|
|
55
|
+
section: str | None
|
|
56
|
+
after: str
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def present(self) -> bool:
|
|
60
|
+
return self.section is not None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def outside(self) -> str:
|
|
64
|
+
"""Every byte that is not managed-section content."""
|
|
65
|
+
return self.before + self.after
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _count(body: str, marker: str) -> int:
|
|
69
|
+
return body.count(marker)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def split_roadmap(body: str) -> RoadmapSplit:
|
|
73
|
+
"""Locate the managed section of ``body``.
|
|
74
|
+
|
|
75
|
+
Exactly one start marker followed by exactly one end marker is a
|
|
76
|
+
section; neither marker at all is "no section yet" (the splice appends
|
|
77
|
+
one). Anything else is ambiguous and raises :class:`RoadmapError`.
|
|
78
|
+
"""
|
|
79
|
+
starts = _count(body, ROADMAP_START_MARKER)
|
|
80
|
+
ends = _count(body, ROADMAP_END_MARKER)
|
|
81
|
+
if starts == 0 and ends == 0:
|
|
82
|
+
return RoadmapSplit(body=body, before=body, section=None, after="")
|
|
83
|
+
if starts != 1 or ends != 1:
|
|
84
|
+
raise RoadmapError(
|
|
85
|
+
f"the EPIC body carries {starts} start and {ends} end roadmap marker(s); the "
|
|
86
|
+
f"managed section is exactly one {ROADMAP_START_MARKER!r} followed by exactly one "
|
|
87
|
+
f"{ROADMAP_END_MARKER!r}"
|
|
88
|
+
)
|
|
89
|
+
start = body.index(ROADMAP_START_MARKER)
|
|
90
|
+
end = body.index(ROADMAP_END_MARKER)
|
|
91
|
+
if end < start:
|
|
92
|
+
raise RoadmapError(
|
|
93
|
+
"the EPIC body's roadmap end marker comes before its start marker; the managed "
|
|
94
|
+
"section cannot be located"
|
|
95
|
+
)
|
|
96
|
+
inner_start = start + len(ROADMAP_START_MARKER)
|
|
97
|
+
inner = body[inner_start:end]
|
|
98
|
+
# The section is written as ``START\n<content>\nEND``; the newline that
|
|
99
|
+
# ends the start marker's line and the one that ends the content's last
|
|
100
|
+
# line are structure, not content. A body edited by hand may lack either.
|
|
101
|
+
content = inner
|
|
102
|
+
if content.startswith("\n"):
|
|
103
|
+
content = content[1:]
|
|
104
|
+
if content.endswith("\n"):
|
|
105
|
+
content = content[:-1]
|
|
106
|
+
return RoadmapSplit(body=body, before=body[:inner_start], section=content, after=body[end:])
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def outside_roadmap(body: str) -> str:
|
|
110
|
+
"""Every byte of ``body`` that is not managed-section content."""
|
|
111
|
+
return split_roadmap(body).outside
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_roadmap_block(section: str) -> str:
|
|
115
|
+
"""The managed section as it is written into a body."""
|
|
116
|
+
return f"{ROADMAP_START_MARKER}\n{section}\n{ROADMAP_END_MARKER}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def splice_roadmap(body: str, section: str) -> str:
|
|
120
|
+
"""``body`` with its managed section replaced by ``section``, or appended.
|
|
121
|
+
|
|
122
|
+
Replacing keeps every byte outside the marker lines; appending adds the
|
|
123
|
+
block after the existing text, separated by a blank line, and never
|
|
124
|
+
changes a byte of it. ``section`` must not carry a marker itself (the
|
|
125
|
+
result parser refuses one), so the spliced body always splits back into
|
|
126
|
+
exactly this section.
|
|
127
|
+
"""
|
|
128
|
+
if ROADMAP_START_MARKER in section or ROADMAP_END_MARKER in section:
|
|
129
|
+
raise RoadmapError("a roadmap section must not contain the roadmap markers")
|
|
130
|
+
split = split_roadmap(body)
|
|
131
|
+
if split.present:
|
|
132
|
+
# ``before`` ends with the start marker; ``after`` starts with the end
|
|
133
|
+
# marker. The content goes between them on lines of its own.
|
|
134
|
+
return f"{split.before}\n{section}\n{split.after}"
|
|
135
|
+
block = render_roadmap_block(section)
|
|
136
|
+
if not body:
|
|
137
|
+
return block + "\n"
|
|
138
|
+
separator = "" if body.endswith("\n\n") else ("\n" if body.endswith("\n") else "\n\n")
|
|
139
|
+
return f"{body}{separator}{block}\n"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
__all__ = [
|
|
143
|
+
"ROADMAP_END_MARKER",
|
|
144
|
+
"ROADMAP_START_MARKER",
|
|
145
|
+
"RoadmapError",
|
|
146
|
+
"RoadmapSplit",
|
|
147
|
+
"outside_roadmap",
|
|
148
|
+
"render_roadmap_block",
|
|
149
|
+
"splice_roadmap",
|
|
150
|
+
"split_roadmap",
|
|
151
|
+
]
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""The durable contract of a LOCAL run: what defines it, persisted once.
|
|
2
|
+
|
|
3
|
+
Why this module exists
|
|
4
|
+
----------------------
|
|
5
|
+
|
|
6
|
+
A LOCAL run has no GitHub to be the source of truth. Its meaning -- *which*
|
|
7
|
+
tree is reviewed, *as classified by which rules*, verified by *which*
|
|
8
|
+
commands, bounded by *how many* fix rounds, with state kept *where* -- is
|
|
9
|
+
decided when the run is created, from the configuration and the environment
|
|
10
|
+
of that moment. Every later step of the run (a ``step``, a ``resume``, a
|
|
11
|
+
crash recovery, a ``status``) runs in a new process that loads the state
|
|
12
|
+
file and *also* loads today's configuration and observes today's
|
|
13
|
+
environment. The failure mode this module closes is letting those two
|
|
14
|
+
sources be confused: a step that re-derives a run-defining input from the
|
|
15
|
+
current environment and treats the result as the run's definition has
|
|
16
|
+
silently **rebound** the run. Review rounds 6 and 7 each found one instance
|
|
17
|
+
of that pattern (``local.exclude`` re-read on resume; the state directory
|
|
18
|
+
re-resolved by pathname at bootstrap) and each fix froze one input. That
|
|
19
|
+
cannot converge: every un-enumerated input is the next finding.
|
|
20
|
+
|
|
21
|
+
The closed formulation is a contract:
|
|
22
|
+
|
|
23
|
+
* :class:`WorkspacePolicy` -- everything about the workspace reader that
|
|
24
|
+
shapes a snapshot, in one canonical, unambiguous, digested form.
|
|
25
|
+
* :class:`LocalRunContract` -- every run-defining input, classified
|
|
26
|
+
**IMMUTABLE** (persisted at creation; a later invocation must present the
|
|
27
|
+
same value or the run refuses to continue).
|
|
28
|
+
* :func:`validate_local_run_contract` -- the single gate that compares the
|
|
29
|
+
persisted contract with what the current invocation would define, before
|
|
30
|
+
anything executes.
|
|
31
|
+
|
|
32
|
+
The master invariant: *a resumed run may revalidate its contract, but must
|
|
33
|
+
never silently redefine it.* Concretely, execution reads the run-defining
|
|
34
|
+
values **from the contract** (``contract.validation_commands``, never
|
|
35
|
+
``config.local.validation_commands``), and the gate is what proves the
|
|
36
|
+
operator's current configuration still agrees with it. Adding a
|
|
37
|
+
run-defining input is one field on :class:`LocalRunContract`; the
|
|
38
|
+
persistence, the comparison and the drift message follow from the
|
|
39
|
+
dataclass, so there is no second list to forget.
|
|
40
|
+
|
|
41
|
+
Classification of the run's inputs
|
|
42
|
+
----------------------------------
|
|
43
|
+
|
|
44
|
+
IMMUTABLE (in the contract, compared by the gate, drift refuses):
|
|
45
|
+
repository root, state root, workspace policy (exclusion rules, cost
|
|
46
|
+
bounds, snapshot algorithm), validation commands, fix-round budget,
|
|
47
|
+
cumulative step budget, prompt version. Also, outside the contract object but with the same
|
|
48
|
+
semantics: the protocol version (checked on load) and the feature
|
|
49
|
+
specification path (persisted; re-supplied by nothing, so it cannot
|
|
50
|
+
drift).
|
|
51
|
+
|
|
52
|
+
REVALIDATED (re-read from the environment and proven equivalent):
|
|
53
|
+
the feature specification's bytes (SHA-256, VerificationError), the git
|
|
54
|
+
anchor (HEAD and branch, BLOCKED). These are *content* checks of the
|
|
55
|
+
world the contract names; they are performed where the run touches that
|
|
56
|
+
content, and they never write what they read back into the contract.
|
|
57
|
+
|
|
58
|
+
DYNAMIC (observed, never defining):
|
|
59
|
+
the pre-phase workspace fingerprint (a binding for the *next* agent
|
|
60
|
+
phase, persisted so the reviewer's claim can be checked against it -- a
|
|
61
|
+
persisted safety judgment never reads a stale one back), git-directory
|
|
62
|
+
inodes (re-read per snapshot from a location the contract names),
|
|
63
|
+
provider/model/effort/command/extra_args/timeouts (which agent runs, not
|
|
64
|
+
what it is asked to do), the invocation's cwd and PATH, counters. No
|
|
65
|
+
persisted safety judgment depends on any of them, which is the proof
|
|
66
|
+
that they may change.
|
|
67
|
+
|
|
68
|
+
The invocation's cwd earns that classification only because nothing is
|
|
69
|
+
*executed* from it: agents and validation commands are launched from
|
|
70
|
+
the contract's ``repository_root`` (``ControllerEngine._execution_cwd``),
|
|
71
|
+
so the cwd decides where the repository is found and the gate then
|
|
72
|
+
proves it is the recorded one. An argv is a program only relative to a
|
|
73
|
+
directory -- ``["./verify"]`` from ``src/`` is a different verifier --
|
|
74
|
+
so launching from the cwd would have made it a run-defining input the
|
|
75
|
+
contract did not record (round 11, R11-F1).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
from __future__ import annotations
|
|
79
|
+
|
|
80
|
+
import hashlib
|
|
81
|
+
import json
|
|
82
|
+
import re
|
|
83
|
+
from collections.abc import Sequence
|
|
84
|
+
from dataclasses import dataclass, field, fields
|
|
85
|
+
from typing import TYPE_CHECKING, Any
|
|
86
|
+
|
|
87
|
+
from . import __prompt_version__
|
|
88
|
+
from .errors import StateError, VerificationError
|
|
89
|
+
|
|
90
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
91
|
+
from .config import AutoForgeConfig
|
|
92
|
+
from .local_workspace import LocalWorkspace
|
|
93
|
+
from .state import StatePaths
|
|
94
|
+
|
|
95
|
+
#: Schema of the persisted contract. Bumped when a field is added, removed or
|
|
96
|
+
#: reinterpreted; an older schema is refused, never migrated (see
|
|
97
|
+
#: :meth:`LocalRunContract.from_dict`). 2 added ``max_total_steps``: a run
|
|
98
|
+
#: created under schema 1 could resume under a larger cumulative step budget
|
|
99
|
+
#: than the one it was created with, without any drift being reported.
|
|
100
|
+
CONTRACT_SCHEMA = 2
|
|
101
|
+
|
|
102
|
+
#: Schema of the persisted workspace policy. ``v1`` (the pre-release form:
|
|
103
|
+
#: one comma-joined line that did not name the snapshot algorithm and could
|
|
104
|
+
#: not tell ``["a,b", "c"]`` from ``["a", "b,c"]``) is refused.
|
|
105
|
+
POLICY_SCHEMA = "v2"
|
|
106
|
+
|
|
107
|
+
_POLICY_FIELDS = ("snapshot_tag", "exclude", "max_entries", "max_bytes")
|
|
108
|
+
_SNAPSHOT_TAG_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _digest(text: str) -> str:
|
|
112
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _fmt(value: Any) -> str:
|
|
116
|
+
"""Render a contract value for a drift line (JSON, so [] and "" are visible)."""
|
|
117
|
+
if isinstance(value, tuple):
|
|
118
|
+
return json.dumps([list(v) if isinstance(v, tuple) else v for v in value])
|
|
119
|
+
return json.dumps(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class WorkspacePolicy:
|
|
124
|
+
"""Everything about the workspace reader that shapes a snapshot.
|
|
125
|
+
|
|
126
|
+
A fingerprint binds the working tree *as this reader classifies it*, so
|
|
127
|
+
the reader's configuration is part of what a review means. Comparing
|
|
128
|
+
fingerprints cannot detect a policy change: a re-snapshot after the
|
|
129
|
+
change computes both sides under the new policy. The policy is therefore
|
|
130
|
+
part of the run's contract, whole: the bounds only ever turn a snapshot
|
|
131
|
+
into a refusal, so carving them out would be sound today -- and would be
|
|
132
|
+
one more enumeration of which knobs happen to be benign, the shape of
|
|
133
|
+
reasoning this design replaced. The snapshot algorithm's tag is in it
|
|
134
|
+
for the same reason: a controller whose walk classifies entries
|
|
135
|
+
differently produces fingerprints that mean something else.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
exclude: tuple[str, ...]
|
|
139
|
+
max_entries: int
|
|
140
|
+
max_bytes: int
|
|
141
|
+
snapshot_tag: str
|
|
142
|
+
|
|
143
|
+
def __post_init__(self) -> None:
|
|
144
|
+
# The policy is a *set* of rules: one canonical spelling per policy,
|
|
145
|
+
# so equality, the digest and the drift message never depend on the
|
|
146
|
+
# order the operator wrote the list in.
|
|
147
|
+
object.__setattr__(self, "exclude", tuple(sorted(set(self.exclude))))
|
|
148
|
+
|
|
149
|
+
def canonical(self) -> str:
|
|
150
|
+
"""The one canonical spelling of this policy; what is digested.
|
|
151
|
+
|
|
152
|
+
A JSON object with sorted keys and no whitespace. Every exclusion
|
|
153
|
+
pattern is its own JSON string, so the encoding is unambiguous for
|
|
154
|
+
any pattern the loader accepts: a comma, a bracket or a quote inside
|
|
155
|
+
a pattern is quoted, never a delimiter. (The pre-release ``v1`` text
|
|
156
|
+
joined the patterns with commas, and ``["a,b", "c"]`` and
|
|
157
|
+
``["a", "b,c"]`` were one policy under it.)
|
|
158
|
+
"""
|
|
159
|
+
return json.dumps(
|
|
160
|
+
{
|
|
161
|
+
"version": POLICY_SCHEMA,
|
|
162
|
+
"snapshot_tag": self.snapshot_tag,
|
|
163
|
+
"exclude": list(self.exclude),
|
|
164
|
+
"max_entries": self.max_entries,
|
|
165
|
+
"max_bytes": self.max_bytes,
|
|
166
|
+
},
|
|
167
|
+
sort_keys=True,
|
|
168
|
+
separators=(",", ":"),
|
|
169
|
+
ensure_ascii=True,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def digest(self) -> str:
|
|
173
|
+
return _digest(self.canonical())
|
|
174
|
+
|
|
175
|
+
def to_dict(self) -> dict:
|
|
176
|
+
"""The persisted form: the fields, readable as they are, plus the digest."""
|
|
177
|
+
return {
|
|
178
|
+
"version": POLICY_SCHEMA,
|
|
179
|
+
"snapshot_tag": self.snapshot_tag,
|
|
180
|
+
"exclude": list(self.exclude),
|
|
181
|
+
"max_entries": self.max_entries,
|
|
182
|
+
"max_bytes": self.max_bytes,
|
|
183
|
+
"digest": self.digest(),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def from_dict(cls, data: Any) -> WorkspacePolicy:
|
|
188
|
+
"""Strictly decode the persisted form; the digest must match the fields.
|
|
189
|
+
|
|
190
|
+
The fields are required to be *already canonical* (sorted, unique,
|
|
191
|
+
loader-normalised patterns): a policy the controller wrote is, and
|
|
192
|
+
one that is not was written by something else.
|
|
193
|
+
"""
|
|
194
|
+
expected = {"version", "digest", *_POLICY_FIELDS}
|
|
195
|
+
if not isinstance(data, dict) or set(data) != expected:
|
|
196
|
+
raise StateError(
|
|
197
|
+
"workspace policy must be an object with exactly the fields "
|
|
198
|
+
+ ", ".join(sorted(expected))
|
|
199
|
+
)
|
|
200
|
+
if data["version"] != POLICY_SCHEMA:
|
|
201
|
+
raise StateError(
|
|
202
|
+
f"workspace policy version {data['version']!r} is not {POLICY_SCHEMA!r}; "
|
|
203
|
+
"this run was created by a different controller release and is not "
|
|
204
|
+
"migrated -- start a new run"
|
|
205
|
+
)
|
|
206
|
+
tag = data["snapshot_tag"]
|
|
207
|
+
if not isinstance(tag, str) or not _SNAPSHOT_TAG_RE.match(tag):
|
|
208
|
+
raise StateError(f"workspace policy snapshot tag is not usable: {tag!r}")
|
|
209
|
+
exclude = data["exclude"]
|
|
210
|
+
if (
|
|
211
|
+
not isinstance(exclude, list)
|
|
212
|
+
or not all(isinstance(p, str) and p and "\0" not in p for p in exclude)
|
|
213
|
+
or exclude != sorted(set(exclude))
|
|
214
|
+
):
|
|
215
|
+
raise StateError(f"workspace policy exclusions are not canonical: {exclude!r}")
|
|
216
|
+
bounds = {}
|
|
217
|
+
for name in ("max_entries", "max_bytes"):
|
|
218
|
+
value = data[name]
|
|
219
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
220
|
+
raise StateError(f"workspace policy field {name!r} must be a non-negative integer")
|
|
221
|
+
bounds[name] = value
|
|
222
|
+
if not isinstance(data["digest"], str):
|
|
223
|
+
raise StateError("workspace policy digest must be a string")
|
|
224
|
+
policy = cls(exclude=tuple(exclude), snapshot_tag=tag, **bounds)
|
|
225
|
+
if data["digest"] != policy.digest():
|
|
226
|
+
raise StateError("workspace policy digest does not match its fields")
|
|
227
|
+
return policy
|
|
228
|
+
|
|
229
|
+
def drift(self, current: WorkspacePolicy) -> list[str]:
|
|
230
|
+
"""Per-field differences, named by the config setting that moves them."""
|
|
231
|
+
lines: list[str] = []
|
|
232
|
+
labels = {
|
|
233
|
+
"exclude": "local.exclude",
|
|
234
|
+
"max_entries": "local.max_workspace_entries",
|
|
235
|
+
"max_bytes": "local.max_workspace_bytes",
|
|
236
|
+
"snapshot_tag": "workspace snapshot algorithm",
|
|
237
|
+
}
|
|
238
|
+
for f in fields(self):
|
|
239
|
+
mine, theirs = getattr(self, f.name), getattr(current, f.name)
|
|
240
|
+
if mine != theirs:
|
|
241
|
+
lines.append(f"{labels[f.name]}: run: {_fmt(mine)} current: {_fmt(theirs)}")
|
|
242
|
+
return lines
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@dataclass(frozen=True)
|
|
246
|
+
class LocalRunContract:
|
|
247
|
+
"""The persisted definition of one LOCAL run (every field IMMUTABLE).
|
|
248
|
+
|
|
249
|
+
Each field carries the name the operator knows it by, so a drift line
|
|
250
|
+
reads ``local.exclude: run: [] current: ["src"]``.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
repository_root: str = field(metadata={"label": "repository root"})
|
|
254
|
+
state_root: str = field(metadata={"label": "state directory"})
|
|
255
|
+
workspace_policy: WorkspacePolicy = field(metadata={"label": "workspace policy"})
|
|
256
|
+
validation_commands: tuple[tuple[str, ...], ...] = field(
|
|
257
|
+
metadata={"label": "local.validation_commands"}
|
|
258
|
+
)
|
|
259
|
+
max_fix_rounds: int = field(metadata={"label": "local.max_fix_rounds"})
|
|
260
|
+
# The run's cumulative step budget. It bounds the run as a whole (every
|
|
261
|
+
# phase entry, across `resume`), so it is as run-defining as the fix
|
|
262
|
+
# budget: enforced from here, never from the configuration of the day.
|
|
263
|
+
max_total_steps: int = field(metadata={"label": "workflow.max_total_steps"})
|
|
264
|
+
prompt_version: str = field(metadata={"label": "prompt_version"})
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def max_review_rounds(self) -> int:
|
|
268
|
+
"""Review passes the run may complete: the fix rounds plus the first."""
|
|
269
|
+
return self.max_fix_rounds + 1
|
|
270
|
+
|
|
271
|
+
# -- construction ------------------------------------------------------
|
|
272
|
+
@classmethod
|
|
273
|
+
def from_config(
|
|
274
|
+
cls, config: AutoForgeConfig, workspace: LocalWorkspace, paths: StatePaths
|
|
275
|
+
) -> LocalRunContract:
|
|
276
|
+
"""What *this* invocation would define the run as.
|
|
277
|
+
|
|
278
|
+
Used once to create the contract and afterwards only to compare
|
|
279
|
+
against it; a comparison never writes anything back.
|
|
280
|
+
"""
|
|
281
|
+
return cls(
|
|
282
|
+
repository_root=str(workspace.root()),
|
|
283
|
+
state_root=paths.canonical_state_dir(),
|
|
284
|
+
workspace_policy=workspace.policy(),
|
|
285
|
+
validation_commands=tuple(
|
|
286
|
+
tuple(str(a) for a in argv) for argv in config.local.validation_commands
|
|
287
|
+
),
|
|
288
|
+
max_fix_rounds=int(config.local.max_fix_rounds),
|
|
289
|
+
max_total_steps=int(config.workflow.max_total_steps),
|
|
290
|
+
prompt_version=config.prompt_version or __prompt_version__,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# -- persistence -------------------------------------------------------
|
|
294
|
+
def to_dict(self) -> dict:
|
|
295
|
+
return {
|
|
296
|
+
"schema": CONTRACT_SCHEMA,
|
|
297
|
+
"repository_root": self.repository_root,
|
|
298
|
+
"state_root": self.state_root,
|
|
299
|
+
"workspace_policy": self.workspace_policy.to_dict(),
|
|
300
|
+
"validation_commands": [list(argv) for argv in self.validation_commands],
|
|
301
|
+
"max_fix_rounds": self.max_fix_rounds,
|
|
302
|
+
"max_total_steps": self.max_total_steps,
|
|
303
|
+
"prompt_version": self.prompt_version,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
@classmethod
|
|
307
|
+
def from_dict(cls, data: Any) -> LocalRunContract:
|
|
308
|
+
"""Strict decode. Missing, extra or mistyped fields are StateError.
|
|
309
|
+
|
|
310
|
+
There is deliberately no ``missing field -> fill from current
|
|
311
|
+
config`` path: that would be the silent rebinding this contract
|
|
312
|
+
exists to make impossible. A contract an older controller wrote is
|
|
313
|
+
refused with a message that says to start a new run.
|
|
314
|
+
"""
|
|
315
|
+
if not isinstance(data, dict):
|
|
316
|
+
raise StateError("local run contract must be an object")
|
|
317
|
+
expected = {"schema"} | {f.name for f in fields(cls)}
|
|
318
|
+
missing = sorted(expected - set(data))
|
|
319
|
+
unknown = sorted(set(data) - expected)
|
|
320
|
+
if missing or unknown:
|
|
321
|
+
raise StateError(
|
|
322
|
+
"local run contract has "
|
|
323
|
+
+ (f"missing field(s) {missing}" if missing else "")
|
|
324
|
+
+ (" and " if missing and unknown else "")
|
|
325
|
+
+ (f"unknown field(s) {unknown}" if unknown else "")
|
|
326
|
+
+ "; a contract the controller cannot read whole is not reconstructed from "
|
|
327
|
+
"the current configuration -- start a new run"
|
|
328
|
+
)
|
|
329
|
+
if data["schema"] != CONTRACT_SCHEMA:
|
|
330
|
+
raise StateError(
|
|
331
|
+
f"local run contract schema {data['schema']!r} is not {CONTRACT_SCHEMA}; "
|
|
332
|
+
"this run was created by a different controller release and is not "
|
|
333
|
+
"migrated -- start a new run"
|
|
334
|
+
)
|
|
335
|
+
for name in ("repository_root", "state_root", "prompt_version"):
|
|
336
|
+
if not isinstance(data[name], str) or not data[name]:
|
|
337
|
+
raise StateError(f"local run contract field {name!r} must be a non-empty string")
|
|
338
|
+
rounds = data["max_fix_rounds"]
|
|
339
|
+
if not isinstance(rounds, int) or isinstance(rounds, bool) or rounds < 0:
|
|
340
|
+
raise StateError(
|
|
341
|
+
"local run contract field 'max_fix_rounds' must be a non-negative integer"
|
|
342
|
+
)
|
|
343
|
+
steps = data["max_total_steps"]
|
|
344
|
+
if not isinstance(steps, int) or isinstance(steps, bool) or steps < 1:
|
|
345
|
+
# The loader refuses a budget below 1, so a persisted one is
|
|
346
|
+
# something the controller never wrote.
|
|
347
|
+
raise StateError(
|
|
348
|
+
"local run contract field 'max_total_steps' must be a positive integer"
|
|
349
|
+
)
|
|
350
|
+
commands = data["validation_commands"]
|
|
351
|
+
if not isinstance(commands, list) or not all(
|
|
352
|
+
isinstance(argv, list) and argv and all(isinstance(a, str) for a in argv)
|
|
353
|
+
for argv in commands
|
|
354
|
+
):
|
|
355
|
+
raise StateError(
|
|
356
|
+
"local run contract field 'validation_commands' must be a list of "
|
|
357
|
+
"non-empty argv lists of strings"
|
|
358
|
+
)
|
|
359
|
+
return cls(
|
|
360
|
+
repository_root=data["repository_root"],
|
|
361
|
+
state_root=data["state_root"],
|
|
362
|
+
workspace_policy=WorkspacePolicy.from_dict(data["workspace_policy"]),
|
|
363
|
+
validation_commands=tuple(tuple(argv) for argv in commands),
|
|
364
|
+
max_fix_rounds=rounds,
|
|
365
|
+
max_total_steps=steps,
|
|
366
|
+
prompt_version=data["prompt_version"],
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
# -- comparison --------------------------------------------------------
|
|
370
|
+
def drift(self, current: LocalRunContract) -> list[str]:
|
|
371
|
+
"""Every field on which ``current`` disagrees with this contract.
|
|
372
|
+
|
|
373
|
+
Generic over the dataclass: a new field is compared and reported
|
|
374
|
+
without a new branch here.
|
|
375
|
+
"""
|
|
376
|
+
lines: list[str] = []
|
|
377
|
+
for f in fields(self):
|
|
378
|
+
mine, theirs = getattr(self, f.name), getattr(current, f.name)
|
|
379
|
+
if isinstance(mine, WorkspacePolicy):
|
|
380
|
+
lines.extend(mine.drift(theirs))
|
|
381
|
+
elif mine != theirs:
|
|
382
|
+
lines.append(f"{f.metadata['label']}: run: {_fmt(mine)} current: {_fmt(theirs)}")
|
|
383
|
+
return lines
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def contract_drift_error(lines: Sequence[str]) -> VerificationError:
|
|
387
|
+
return VerificationError(
|
|
388
|
+
"this LOCAL run's contract does not match the current invocation:\n "
|
|
389
|
+
+ "\n ".join(lines)
|
|
390
|
+
+ "\nA run is defined once, when it is created; resuming it under a different "
|
|
391
|
+
"definition would silently rebind what was reviewed, verified or bounded. Restore "
|
|
392
|
+
"the run's settings (and location) to resume it, or start a new run under the "
|
|
393
|
+
"current ones. Nothing was changed."
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def validate_local_run_contract(
|
|
398
|
+
recorded: LocalRunContract, current: LocalRunContract
|
|
399
|
+
) -> LocalRunContract:
|
|
400
|
+
"""The gate: refuse (VerificationError, per-field) unless the two agree.
|
|
401
|
+
|
|
402
|
+
Returns the recorded contract, which is what execution then reads from.
|
|
403
|
+
Nothing is persisted on either outcome.
|
|
404
|
+
"""
|
|
405
|
+
lines = recorded.drift(current)
|
|
406
|
+
if lines:
|
|
407
|
+
raise contract_drift_error(lines)
|
|
408
|
+
return recorded
|