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/ownership.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Who may act on an issue: a standalone run or the parallel EPIC scheduler (ADR 0008 D17.2).
|
|
2
|
+
|
|
3
|
+
L0 excludes a standalone command and a scheduler *in time*; these guards
|
|
4
|
+
exclude them *in ownership*, so that no issue ever has two states that both
|
|
5
|
+
continue it.
|
|
6
|
+
|
|
7
|
+
* **Forward guard.** A standalone controller refuses an issue a scheduler
|
|
8
|
+
namespace owns. :func:`scheduler_ownership` is the predicate: a namespace
|
|
9
|
+
owns its issue when it holds any ``state/state.json``, read with #178's
|
|
10
|
+
lookup (:func:`~autoforge.run_namespace.load_issue_namespace`), when its
|
|
11
|
+
``claim.json`` holds an open scheduler claim (#181,
|
|
12
|
+
:func:`~autoforge.scheduler_state.load_claim_file`), or when a live owner
|
|
13
|
+
holds its run lock (L2, the lock-free probe
|
|
14
|
+
:func:`~autoforge.locking.issue_run_lock_live`). It fails closed: a
|
|
15
|
+
namespace or a claim file that cannot be read (an unknown claim schema
|
|
16
|
+
included), or a run lock that cannot be probed, owns its issue. The
|
|
17
|
+
CLI applies it under L0 to the issue ``run``, ``step``, ``resume`` and
|
|
18
|
+
``unblock`` would act on (:func:`refuse_scheduler_owned_issue`), and hands
|
|
19
|
+
a standalone engine the same predicate
|
|
20
|
+
(:func:`standalone_ownership_predicate`) as one more rejection fact of
|
|
21
|
+
``_verify_issue_selectable``, which covers a sequential run's
|
|
22
|
+
``next_issue_url``; the engine never reads scheduler files itself. A
|
|
23
|
+
worker engine is given no predicate, and ``unblock`` of a namespace's own
|
|
24
|
+
state directory exempts that namespace.
|
|
25
|
+
* **Reverse guard.** :func:`standalone_hold` reports the issue the
|
|
26
|
+
configured standalone state names as ``current_issue_url`` while its phase
|
|
27
|
+
is anything but ``DONE`` (``BLOCKED`` and ``FAILED`` included), so a
|
|
28
|
+
scheduler namespace never launders a standalone run's loop bounds or
|
|
29
|
+
bypasses its ``unblock``; #182's admission holds it as
|
|
30
|
+
``held:standalone-state``. An unreadable standalone state is reported and
|
|
31
|
+
holds nothing.
|
|
32
|
+
|
|
33
|
+
Everything here only reads: nothing is created, written, quarantined or
|
|
34
|
+
locked, and no GitHub or git process runs.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import Callable
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
from .errors import ConfigurationError, LockError, SchedulerOwnedIssueError, StateError
|
|
44
|
+
from .locking import issue_run_lock_live
|
|
45
|
+
from .redaction import redact
|
|
46
|
+
from .run_namespace import StateStatus, issue_namespace, load_issue_namespace
|
|
47
|
+
from .safefs import SafeRoot
|
|
48
|
+
from .scheduler_state import load_claim_file
|
|
49
|
+
from .state import STATE_FILENAME, load_state
|
|
50
|
+
from .transitions import Phase, WorkflowMode
|
|
51
|
+
from .validation import parse_issue_url
|
|
52
|
+
|
|
53
|
+
# A reason quotes a loader's or a probe's message: redacted, then bounded.
|
|
54
|
+
MAX_REASON_CHARS = 1000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _bounded(text: str) -> str:
|
|
58
|
+
text = redact(text)
|
|
59
|
+
if len(text) <= MAX_REASON_CHARS:
|
|
60
|
+
return text
|
|
61
|
+
marker = " [... truncated]"
|
|
62
|
+
return text[: MAX_REASON_CHARS - len(marker)] + marker
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scheduler_ownership(coordination_root: str | Path, issue_number: int) -> str:
|
|
66
|
+
"""Why a scheduler namespace owns issue ``issue_number``; "" when none does.
|
|
67
|
+
|
|
68
|
+
ADR 0008 D17.2's ownership predicate: the namespace holds a state
|
|
69
|
+
(loaded or not, #180), its claim file holds an open claim (#181), or a
|
|
70
|
+
live owner holds its run lock (#180). Fail-closed: a namespace whose
|
|
71
|
+
binding or directories cannot be read, a claim file that cannot be read
|
|
72
|
+
or is of an unknown schema, or a run lock that cannot be probed, owns
|
|
73
|
+
its issue. A claim file holding only closed claims owns nothing: the
|
|
74
|
+
namespace's next run starts fresh. Read-only.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
namespace = issue_namespace(coordination_root, issue_number)
|
|
78
|
+
except StateError as exc:
|
|
79
|
+
return f"no scheduler namespace can be named for it ({_bounded(str(exc))})"
|
|
80
|
+
where = namespace.directory
|
|
81
|
+
try:
|
|
82
|
+
lookup = load_issue_namespace(namespace.coordination_root, issue_number)
|
|
83
|
+
except (StateError, OSError) as exc:
|
|
84
|
+
return (
|
|
85
|
+
f"its scheduler namespace {where} cannot be read ({_bounded(str(exc))}), so "
|
|
86
|
+
"it may own the issue"
|
|
87
|
+
)
|
|
88
|
+
if lookup.state.status is StateStatus.LOADED:
|
|
89
|
+
assert lookup.state.state is not None
|
|
90
|
+
return (
|
|
91
|
+
f"its scheduler namespace {where} holds a state (phase "
|
|
92
|
+
f"{lookup.state.state.phase.value})"
|
|
93
|
+
)
|
|
94
|
+
if lookup.state.status is StateStatus.CORRUPT:
|
|
95
|
+
return (
|
|
96
|
+
f"its scheduler namespace {where} holds a state that cannot be read "
|
|
97
|
+
f"({lookup.state.reason})"
|
|
98
|
+
)
|
|
99
|
+
if lookup.claim_present:
|
|
100
|
+
try:
|
|
101
|
+
claim_file = load_claim_file(namespace)
|
|
102
|
+
except (StateError, OSError) as exc:
|
|
103
|
+
return (
|
|
104
|
+
f"its scheduler claim {namespace.claim_file} cannot be read "
|
|
105
|
+
f"({_bounded(str(exc))}), so it may hold an open claim"
|
|
106
|
+
)
|
|
107
|
+
if claim_file is not None and claim_file.current is not None:
|
|
108
|
+
current = claim_file.current
|
|
109
|
+
return (
|
|
110
|
+
f"its scheduler namespace {where} holds an open scheduler claim (generation "
|
|
111
|
+
f"{current.generation}, a {current.kind.value} admission)"
|
|
112
|
+
)
|
|
113
|
+
try:
|
|
114
|
+
live = issue_run_lock_live(namespace)
|
|
115
|
+
except (LockError, OSError) as exc:
|
|
116
|
+
return (
|
|
117
|
+
f"its run lock {namespace.run_lock_file} cannot be probed "
|
|
118
|
+
f"({_bounded(str(exc))}), so a worker may own the issue"
|
|
119
|
+
)
|
|
120
|
+
if live:
|
|
121
|
+
return f"a live scheduler worker holds its run lock {namespace.run_lock_file}"
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def standalone_ownership_predicate(
|
|
126
|
+
coordination_root: str | Path, *, exempt_issue: int | None = None
|
|
127
|
+
) -> Callable[[int], str]:
|
|
128
|
+
"""The predicate a standalone engine is given (``ControllerEngine.issue_ownership``).
|
|
129
|
+
|
|
130
|
+
``exempt_issue`` is the namespace whose own state directory ``unblock``
|
|
131
|
+
acts on; it is never refused. A worker engine is given no predicate.
|
|
132
|
+
"""
|
|
133
|
+
root = Path(coordination_root)
|
|
134
|
+
|
|
135
|
+
def owner(issue_number: int) -> str:
|
|
136
|
+
if exempt_issue is not None and issue_number == exempt_issue:
|
|
137
|
+
return ""
|
|
138
|
+
return scheduler_ownership(root, issue_number)
|
|
139
|
+
|
|
140
|
+
return owner
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def refuse_scheduler_owned_issue(
|
|
144
|
+
coordination_root: str | Path,
|
|
145
|
+
issue_url: str,
|
|
146
|
+
command: str,
|
|
147
|
+
*,
|
|
148
|
+
exempt_issue: int | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""The CLI's forward guard: refuse ``command`` on a scheduler-owned issue.
|
|
151
|
+
|
|
152
|
+
Called under L0, before anything is written, with the issue the command
|
|
153
|
+
would act on (the ``run`` argument, or the loaded state's
|
|
154
|
+
``current_issue_url``). Raises
|
|
155
|
+
:class:`~autoforge.errors.SchedulerOwnedIssueError` (exit 2) naming
|
|
156
|
+
``epic archive``. An issue URL that does not parse is the caller's
|
|
157
|
+
validation to report, not a refusal here.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
ref = parse_issue_url(issue_url)
|
|
161
|
+
except ConfigurationError:
|
|
162
|
+
return
|
|
163
|
+
if exempt_issue is not None and ref.number == exempt_issue:
|
|
164
|
+
return
|
|
165
|
+
reason = scheduler_ownership(coordination_root, ref.number)
|
|
166
|
+
if reason:
|
|
167
|
+
raise SchedulerOwnedIssueError(
|
|
168
|
+
f"'{command}' refuses issue {ref.canonical}: {reason}. Only the parallel EPIC "
|
|
169
|
+
"scheduler continues an issue its namespace owns ('autoforge epic resume', "
|
|
170
|
+
"inspected with 'autoforge epic status'); 'autoforge epic archive <epic> "
|
|
171
|
+
f"{ref.number}' releases it to a standalone run. Nothing was written."
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass(frozen=True)
|
|
176
|
+
class StandaloneHold:
|
|
177
|
+
"""What the configured standalone state holds against a scheduler admission.
|
|
178
|
+
|
|
179
|
+
``issue_number`` (with ``issue_url`` and ``phase``) is set when the state
|
|
180
|
+
names an issue in a phase other than ``DONE``; ``problem`` (redacted and
|
|
181
|
+
bounded) when the state exists but cannot be read, which holds nothing
|
|
182
|
+
and is reported.
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
state_file: Path
|
|
186
|
+
issue_number: int | None = None
|
|
187
|
+
issue_url: str = ""
|
|
188
|
+
phase: str = ""
|
|
189
|
+
problem: str = ""
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def holds(self) -> bool:
|
|
193
|
+
return self.issue_number is not None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def standalone_hold(state_dir: str | Path) -> StandaloneHold:
|
|
197
|
+
"""The issue the standalone state in ``state_dir`` holds; read-only (ADR 0008 D17.2).
|
|
198
|
+
|
|
199
|
+
``state_dir`` is the configured one (``config.state_dir``); other
|
|
200
|
+
``--state-dir`` locations are unknowable, and the forward guard refuses
|
|
201
|
+
to continue them instead. An absent state, a ``DONE`` state, a LOCAL
|
|
202
|
+
state and a state naming no issue hold nothing. Nothing is created,
|
|
203
|
+
quarantined or written.
|
|
204
|
+
"""
|
|
205
|
+
state_file = Path(state_dir) / STATE_FILENAME
|
|
206
|
+
try:
|
|
207
|
+
# Read through a held root, like every state read (load_state's
|
|
208
|
+
# ``root=``): the directory cannot be swapped between the checks.
|
|
209
|
+
root = SafeRoot.open(state_dir)
|
|
210
|
+
except FileNotFoundError:
|
|
211
|
+
return StandaloneHold(state_file)
|
|
212
|
+
except (StateError, OSError) as exc:
|
|
213
|
+
return StandaloneHold(state_file, problem=_bounded(str(exc)))
|
|
214
|
+
with root:
|
|
215
|
+
try:
|
|
216
|
+
if root.lstat(STATE_FILENAME) is None:
|
|
217
|
+
return StandaloneHold(state_file)
|
|
218
|
+
state = load_state(state_file, root=root)
|
|
219
|
+
except StateError as exc:
|
|
220
|
+
try:
|
|
221
|
+
gone = root.lstat(STATE_FILENAME) is None # removed since: nothing is there
|
|
222
|
+
except StateError:
|
|
223
|
+
gone = False
|
|
224
|
+
if gone:
|
|
225
|
+
return StandaloneHold(state_file)
|
|
226
|
+
return StandaloneHold(state_file, problem=_bounded(str(exc)))
|
|
227
|
+
if state.mode is WorkflowMode.LOCAL or state.phase is Phase.DONE:
|
|
228
|
+
return StandaloneHold(state_file, phase=state.phase.value)
|
|
229
|
+
if not state.current_issue_url:
|
|
230
|
+
return StandaloneHold(state_file, phase=state.phase.value)
|
|
231
|
+
try:
|
|
232
|
+
ref = parse_issue_url(state.current_issue_url)
|
|
233
|
+
except ConfigurationError as exc:
|
|
234
|
+
return StandaloneHold(state_file, phase=state.phase.value, problem=_bounded(str(exc)))
|
|
235
|
+
return StandaloneHold(
|
|
236
|
+
state_file,
|
|
237
|
+
issue_number=ref.number,
|
|
238
|
+
issue_url=ref.canonical,
|
|
239
|
+
phase=state.phase.value,
|
|
240
|
+
)
|