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/worker.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
"""The scheduler's issue worker: the unchanged engine on one issue's namespace (ADR 0008 D9; #182).
|
|
2
|
+
|
|
3
|
+
A worker is one process, ``sys.executable -P -m autoforge.cli epic _worker
|
|
4
|
+
...`` (a hidden subcommand), that the scheduler starts for one admitted
|
|
5
|
+
issue and that runs one :class:`~autoforge.engine.ControllerEngine` on that
|
|
6
|
+
issue's namespace until the issue's single-issue settlement boundary.
|
|
7
|
+
|
|
8
|
+
**Startup** (:func:`run_worker`, D9.2), each step before the next and every
|
|
9
|
+
refusal an exit 2 before any state is read or written:
|
|
10
|
+
|
|
11
|
+
1. adopt the inherited L0 and L2 descriptors through #180's fresh-descriptor
|
|
12
|
+
proof of holding (:meth:`ControllerLock.adopt
|
|
13
|
+
<autoforge.locking.ControllerLock.adopt>`, :meth:`IssueRunLock.adopt
|
|
14
|
+
<autoforge.locking.IssueRunLock.adopt>`), never a re-asserted ``flock``;
|
|
15
|
+
2. mark them non-inheritable (the adoption does), so no launch of this
|
|
16
|
+
process, an agent's included, inherits either;
|
|
17
|
+
3. strictly load the namespace's binding and its claim file, and require the
|
|
18
|
+
open claim to be argv's ``(claim_id, generation, invocation_id)`` and both
|
|
19
|
+
files to name argv's issue and EPIC;
|
|
20
|
+
4. require ``scheduler.json`` to name argv's invocation and the
|
|
21
|
+
configuration this process loaded to have its ``config_digest``;
|
|
22
|
+
5. bind the engine to the namespace's state directory, anchored at the git
|
|
23
|
+
common dir (:meth:`~autoforge.engine.ControllerEngine.bind_issue_namespace`),
|
|
24
|
+
under the adopted L0.
|
|
25
|
+
|
|
26
|
+
**Policy** (D9.3): the engine runs with ``allow_merge=False`` whatever
|
|
27
|
+
``safety.allow_merge`` says, ``extra_stop_phases`` {``MERGE``,
|
|
28
|
+
``UPDATE_EPIC``} (so ``next_issue_url`` is never read and
|
|
29
|
+
``reset_for_new_issue`` never runs), and a ``should_continue`` predicate
|
|
30
|
+
that, before every step, proves L2 is still held and reads the stop request
|
|
31
|
+
(a request for this invocation, or one that does not parse, ends the run at
|
|
32
|
+
that boundary). A fresh issue starts exactly as ``autoforge run`` starts
|
|
33
|
+
it (INITIALIZING, with no ownership predicate); a namespace holding a state
|
|
34
|
+
passes #219's recorded-configuration gate first, as ``resume`` does
|
|
35
|
+
(D9.4). stdin is ``/dev/null``, and the worker sets no interaction resolver,
|
|
36
|
+
so an agent's request for a decision has no one to ask and fails the
|
|
37
|
+
invocation under every ``interaction_policy`` (ADR 0007 D7.4, #134).
|
|
38
|
+
|
|
39
|
+
**Signals.** The worker installs two handlers, so the executor's existing
|
|
40
|
+
teardown of a running agent runs: SIGTERM raises ``KeyboardInterrupt``, and
|
|
41
|
+
SIGINT gets Python's own ``default_int_handler`` again, since a scheduler
|
|
42
|
+
started with SIGINT ignored (a background job of a non-interactive shell)
|
|
43
|
+
passes that disposition on, and Python then installs none. Either signal
|
|
44
|
+
ends the worker with exit 130 and the state as the last step saved it.
|
|
45
|
+
|
|
46
|
+
**Output** goes to the worker log the scheduler opened as stdout and stderr:
|
|
47
|
+
the ordinary, already-redacted CLI output, cut after
|
|
48
|
+
:data:`MAX_WORKER_OUTPUT_BYTES` bytes. It is never read for recovery.
|
|
49
|
+
|
|
50
|
+
Nothing here names a provider, writes GitHub, parses ``CONTROL_RESULT`` or
|
|
51
|
+
records an effect: everything the issue does is the engine's.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import argparse
|
|
57
|
+
import io
|
|
58
|
+
import os
|
|
59
|
+
import re
|
|
60
|
+
import signal
|
|
61
|
+
import sys
|
|
62
|
+
import threading
|
|
63
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
64
|
+
from contextlib import contextmanager
|
|
65
|
+
from dataclasses import dataclass
|
|
66
|
+
from pathlib import Path
|
|
67
|
+
from types import FrameType
|
|
68
|
+
from typing import TYPE_CHECKING, NoReturn, TextIO
|
|
69
|
+
|
|
70
|
+
from .config import AutoForgeConfig, config_digest, load_config_file
|
|
71
|
+
from .engine import ControllerEngine, StepOutcome, config_file_placement_problem
|
|
72
|
+
from .errors import (
|
|
73
|
+
AutoForgeError,
|
|
74
|
+
ConfigurationError,
|
|
75
|
+
LockError,
|
|
76
|
+
StateError,
|
|
77
|
+
StateTransitionError,
|
|
78
|
+
WorkerStartupError,
|
|
79
|
+
)
|
|
80
|
+
from .locking import ControllerLock, IssueRunLock, repository_lock_path
|
|
81
|
+
from .redaction import redact
|
|
82
|
+
from .run_namespace import IssueNamespace, issue_namespace, load_binding
|
|
83
|
+
from .scheduler_state import ClaimKind, load_claim_file, load_scheduler_state
|
|
84
|
+
from .scheduler_stop import read_stop_request
|
|
85
|
+
from .transitions import STOP_PHASES, Phase, issue_settlement
|
|
86
|
+
from .validation import parse_issue_url
|
|
87
|
+
|
|
88
|
+
if TYPE_CHECKING:
|
|
89
|
+
from .github import GitHubClient
|
|
90
|
+
from .providers import ProviderRegistry
|
|
91
|
+
|
|
92
|
+
# The phases a worker never executes, on top of the closed merge gate's stop
|
|
93
|
+
# phases (ADR 0008 D9.3): it returns before them, so a worker never merges,
|
|
94
|
+
# never updates the EPIC and never selects a next issue.
|
|
95
|
+
WORKER_EXTRA_STOP_PHASES = frozenset({Phase.MERGE, Phase.UPDATE_EPIC})
|
|
96
|
+
WORKER_STOP_PHASES = frozenset(STOP_PHASES) | WORKER_EXTRA_STOP_PHASES
|
|
97
|
+
# Steps per worker, as for a standalone ``run``: a run that needs more is left
|
|
98
|
+
# in progress, its claim closes ``interrupted`` and the next invocation resumes it.
|
|
99
|
+
WORKER_MAX_STEPS = 50
|
|
100
|
+
# The worker log's bound (D9.6): the CLI output of one issue's run, cut there.
|
|
101
|
+
MAX_WORKER_OUTPUT_BYTES = 8 * 1024 * 1024
|
|
102
|
+
|
|
103
|
+
EXIT_REFUSED = 2
|
|
104
|
+
EXIT_INTERRUPTED = 130
|
|
105
|
+
|
|
106
|
+
_HEX32 = re.compile(r"[0-9a-f]{32}")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class WorkerArguments:
|
|
111
|
+
"""What a worker is started for: everything its argv carries (D9.1).
|
|
112
|
+
|
|
113
|
+
``repository`` is the operator's checkout the scheduler runs in, and
|
|
114
|
+
``config`` the absolute path of the configuration file ("" for the
|
|
115
|
+
built-in defaults). ``l0_fd`` and ``l2_fd`` are the inherited descriptor
|
|
116
|
+
numbers; no environment variable carries anything.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
repository: str
|
|
120
|
+
config: str
|
|
121
|
+
epic: int
|
|
122
|
+
issue: int
|
|
123
|
+
claim_id: str
|
|
124
|
+
generation: int
|
|
125
|
+
invocation_id: str
|
|
126
|
+
l0_fd: int
|
|
127
|
+
l2_fd: int
|
|
128
|
+
accept_config_change: bool = False
|
|
129
|
+
|
|
130
|
+
def argv(self) -> list[str]:
|
|
131
|
+
"""The arguments after ``epic _worker``; :func:`parse_worker_arguments` reads them back."""
|
|
132
|
+
words = ["--repository", self.repository]
|
|
133
|
+
if self.config:
|
|
134
|
+
words += ["--config", self.config]
|
|
135
|
+
words += [
|
|
136
|
+
"--epic",
|
|
137
|
+
str(self.epic),
|
|
138
|
+
"--issue",
|
|
139
|
+
str(self.issue),
|
|
140
|
+
"--claim-id",
|
|
141
|
+
self.claim_id,
|
|
142
|
+
"--generation",
|
|
143
|
+
str(self.generation),
|
|
144
|
+
"--invocation-id",
|
|
145
|
+
self.invocation_id,
|
|
146
|
+
"--l0-fd",
|
|
147
|
+
str(self.l0_fd),
|
|
148
|
+
"--l2-fd",
|
|
149
|
+
str(self.l2_fd),
|
|
150
|
+
]
|
|
151
|
+
if self.accept_config_change:
|
|
152
|
+
words.append("--accept-config-change")
|
|
153
|
+
return words
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class _Parser(argparse.ArgumentParser):
|
|
157
|
+
def error(self, message: str) -> NoReturn:
|
|
158
|
+
raise WorkerStartupError(f"invalid worker arguments: {message}")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _number(text: str) -> int:
|
|
162
|
+
if not text.isdigit() or text.startswith("0"):
|
|
163
|
+
raise argparse.ArgumentTypeError(f"not a positive decimal number: {text!r:.40}")
|
|
164
|
+
return int(text)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _descriptor(text: str) -> int:
|
|
168
|
+
if not text.isdigit() or int(text) < 3:
|
|
169
|
+
raise argparse.ArgumentTypeError(f"not an inherited descriptor number: {text!r:.40}")
|
|
170
|
+
return int(text)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _hex32(text: str) -> str:
|
|
174
|
+
if not _HEX32.fullmatch(text):
|
|
175
|
+
raise argparse.ArgumentTypeError(f"not a 32-hex id: {text!r:.40}")
|
|
176
|
+
return text
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _absolute(text: str) -> str:
|
|
180
|
+
if not os.path.isabs(text):
|
|
181
|
+
raise argparse.ArgumentTypeError(f"not an absolute path: {text!r:.200}")
|
|
182
|
+
return text
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_worker_arguments(argv: Sequence[str]) -> WorkerArguments:
|
|
186
|
+
"""Strictly parse a worker's argv; :class:`~autoforge.errors.WorkerStartupError` otherwise."""
|
|
187
|
+
parser = _Parser(prog="autoforge epic _worker", add_help=False, allow_abbrev=False)
|
|
188
|
+
parser.add_argument("--repository", type=_absolute, required=True)
|
|
189
|
+
# No string default: argparse would run it through ``type`` (and refuse "").
|
|
190
|
+
parser.add_argument("--config", type=_absolute, default=None)
|
|
191
|
+
parser.add_argument("--epic", type=_number, required=True)
|
|
192
|
+
parser.add_argument("--issue", type=_number, required=True)
|
|
193
|
+
parser.add_argument("--claim-id", type=_hex32, required=True)
|
|
194
|
+
parser.add_argument("--generation", type=_number, required=True)
|
|
195
|
+
parser.add_argument("--invocation-id", type=_hex32, required=True)
|
|
196
|
+
parser.add_argument("--l0-fd", type=_descriptor, required=True)
|
|
197
|
+
parser.add_argument("--l2-fd", type=_descriptor, required=True)
|
|
198
|
+
parser.add_argument("--accept-config-change", action="store_true")
|
|
199
|
+
args = parser.parse_args(list(argv))
|
|
200
|
+
if args.l0_fd == args.l2_fd:
|
|
201
|
+
raise WorkerStartupError("invalid worker arguments: L0 and L2 name one descriptor")
|
|
202
|
+
return WorkerArguments(
|
|
203
|
+
repository=args.repository,
|
|
204
|
+
config=args.config or "",
|
|
205
|
+
epic=args.epic,
|
|
206
|
+
issue=args.issue,
|
|
207
|
+
claim_id=args.claim_id,
|
|
208
|
+
generation=args.generation,
|
|
209
|
+
invocation_id=args.invocation_id,
|
|
210
|
+
l0_fd=args.l0_fd,
|
|
211
|
+
l2_fd=args.l2_fd,
|
|
212
|
+
accept_config_change=args.accept_config_change,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# -- output -------------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _Budget:
|
|
220
|
+
def __init__(self, limit: int) -> None:
|
|
221
|
+
self.limit = limit
|
|
222
|
+
self.remaining = limit
|
|
223
|
+
self.cut = False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _BoundedStream(io.TextIOBase):
|
|
227
|
+
"""A text stream that passes at most the shared budget's bytes on, then one notice."""
|
|
228
|
+
|
|
229
|
+
def __init__(self, inner: TextIO, budget: _Budget) -> None:
|
|
230
|
+
super().__init__()
|
|
231
|
+
self._inner = inner
|
|
232
|
+
self._budget = budget
|
|
233
|
+
|
|
234
|
+
def writable(self) -> bool:
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def encoding(self) -> str: # type: ignore[override]
|
|
239
|
+
return getattr(self._inner, "encoding", "utf-8")
|
|
240
|
+
|
|
241
|
+
def write(self, text: str) -> int:
|
|
242
|
+
budget = self._budget
|
|
243
|
+
if budget.cut:
|
|
244
|
+
return len(text)
|
|
245
|
+
data = text.encode("utf-8", "replace")
|
|
246
|
+
if len(data) <= budget.remaining:
|
|
247
|
+
budget.remaining -= len(data)
|
|
248
|
+
self._inner.write(text)
|
|
249
|
+
return len(text)
|
|
250
|
+
self._inner.write(data[: budget.remaining].decode("utf-8", "ignore"))
|
|
251
|
+
self._inner.write(
|
|
252
|
+
f"\n[autoforge worker: output cut after {budget.limit} bytes; the run goes on, "
|
|
253
|
+
"and its state and run log are complete]\n"
|
|
254
|
+
)
|
|
255
|
+
budget.remaining = 0
|
|
256
|
+
budget.cut = True
|
|
257
|
+
return len(text)
|
|
258
|
+
|
|
259
|
+
def flush(self) -> None:
|
|
260
|
+
self._inner.flush()
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@contextmanager
|
|
264
|
+
def _bounded_output(limit: int = MAX_WORKER_OUTPUT_BYTES) -> Iterator[None]:
|
|
265
|
+
budget = _Budget(limit)
|
|
266
|
+
previous = (sys.stdout, sys.stderr)
|
|
267
|
+
sys.stdout = _BoundedStream(previous[0], budget) # type: ignore[assignment]
|
|
268
|
+
sys.stderr = _BoundedStream(previous[1], budget) # type: ignore[assignment]
|
|
269
|
+
try:
|
|
270
|
+
yield
|
|
271
|
+
finally:
|
|
272
|
+
for stream in (sys.stdout, sys.stderr):
|
|
273
|
+
try:
|
|
274
|
+
stream.flush()
|
|
275
|
+
except (OSError, ValueError):
|
|
276
|
+
pass
|
|
277
|
+
sys.stdout, sys.stderr = previous
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _say(text: str) -> None:
|
|
281
|
+
print(redact(f"autoforge worker: {text}"), file=sys.stderr, flush=True)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _progress_line(line: str) -> None:
|
|
285
|
+
print(line, file=sys.stderr, flush=True)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _print_outcome(outcome: StepOutcome) -> None:
|
|
289
|
+
from .cli import print_step_outcome # the CLI's own rendering of a step
|
|
290
|
+
|
|
291
|
+
print_step_outcome(outcome)
|
|
292
|
+
sys.stdout.flush()
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# -- signals ------------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _sigterm(signum: int, frame: FrameType | None) -> None:
|
|
299
|
+
raise KeyboardInterrupt
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@contextmanager
|
|
303
|
+
def _interrupts() -> Iterator[None]:
|
|
304
|
+
"""SIGINT and SIGTERM as ``KeyboardInterrupt`` for the block (D9.3), then restored."""
|
|
305
|
+
if threading.current_thread() is not threading.main_thread():
|
|
306
|
+
yield
|
|
307
|
+
return
|
|
308
|
+
previous = {
|
|
309
|
+
signal.SIGINT: signal.signal(signal.SIGINT, signal.default_int_handler),
|
|
310
|
+
signal.SIGTERM: signal.signal(signal.SIGTERM, _sigterm),
|
|
311
|
+
}
|
|
312
|
+
try:
|
|
313
|
+
yield
|
|
314
|
+
finally:
|
|
315
|
+
for signum, handler in previous.items():
|
|
316
|
+
signal.signal(signum, handler)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# -- startup ------------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _refuse(message: str) -> NoReturn:
|
|
323
|
+
raise WorkerStartupError(message)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@dataclass
|
|
327
|
+
class _Started:
|
|
328
|
+
"""What a worker that passed its startup protocol holds."""
|
|
329
|
+
|
|
330
|
+
args: WorkerArguments
|
|
331
|
+
root: Path
|
|
332
|
+
namespace: IssueNamespace
|
|
333
|
+
l0: ControllerLock
|
|
334
|
+
l2: IssueRunLock
|
|
335
|
+
config: AutoForgeConfig
|
|
336
|
+
kind: ClaimKind
|
|
337
|
+
state_ref_present: bool
|
|
338
|
+
epic_url: str
|
|
339
|
+
issue_url: str
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _adopt(args: WorkerArguments) -> tuple[Path, IssueNamespace, ControllerLock, IssueRunLock]:
|
|
343
|
+
"""D9.2 steps 1 and 2: both descriptors proven and made non-inheritable."""
|
|
344
|
+
try:
|
|
345
|
+
root = repository_lock_path(args.repository).parent.parent
|
|
346
|
+
except (LockError, OSError) as exc:
|
|
347
|
+
_refuse(f"{args.repository} is not a git checkout whose common dir can be read ({exc})")
|
|
348
|
+
namespace = issue_namespace(root, args.issue)
|
|
349
|
+
try:
|
|
350
|
+
l0 = ControllerLock.adopt(args.l0_fd, expected_root=root)
|
|
351
|
+
except (LockError, OSError) as exc:
|
|
352
|
+
_refuse(f"the inherited L0 descriptor {args.l0_fd} was not proven: {exc}")
|
|
353
|
+
try:
|
|
354
|
+
l2 = IssueRunLock.adopt(args.l2_fd, namespace)
|
|
355
|
+
except (LockError, OSError) as exc:
|
|
356
|
+
_refuse(f"the inherited L2 descriptor {args.l2_fd} was not proven: {exc}")
|
|
357
|
+
return root, namespace, l0, l2
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _start(args: WorkerArguments) -> _Started:
|
|
361
|
+
"""D9.2 steps 1 to 4. Nothing of the namespace's state is read here."""
|
|
362
|
+
root, namespace, l0, l2 = _adopt(args)
|
|
363
|
+
try:
|
|
364
|
+
binding = load_binding(namespace)
|
|
365
|
+
claim_file = load_claim_file(namespace)
|
|
366
|
+
scheduler = load_scheduler_state(root, args.epic)
|
|
367
|
+
except (StateError, OSError) as exc:
|
|
368
|
+
_refuse(f"the namespace or scheduler file does not load: {exc}")
|
|
369
|
+
if binding is None:
|
|
370
|
+
_refuse(f"{namespace.binding_file} does not exist; the issue was never admitted")
|
|
371
|
+
if binding.epic.number != args.epic:
|
|
372
|
+
_refuse(f"the namespace is bound under EPIC #{binding.epic.number}, not #{args.epic}")
|
|
373
|
+
current = None if claim_file is None else claim_file.current
|
|
374
|
+
if claim_file is None or current is None:
|
|
375
|
+
_refuse(f"{namespace.claim_file} holds no open claim; the worker has nothing to run")
|
|
376
|
+
if (claim_file.run_key, claim_file.issue_id, claim_file.epic) != (
|
|
377
|
+
binding.run_key,
|
|
378
|
+
binding.issue_id,
|
|
379
|
+
binding.epic,
|
|
380
|
+
):
|
|
381
|
+
_refuse(f"{namespace.claim_file} names another run than {namespace.binding_file}")
|
|
382
|
+
started_for = (args.claim_id, args.generation, args.invocation_id)
|
|
383
|
+
on_disk = (current.claim_id, current.generation, current.invocation_id)
|
|
384
|
+
if on_disk != started_for:
|
|
385
|
+
_refuse(
|
|
386
|
+
f"the open claim is generation {current.generation} (claim {current.claim_id}, "
|
|
387
|
+
f"invocation {current.invocation_id}), not the generation {args.generation} (claim "
|
|
388
|
+
f"{args.claim_id}, invocation {args.invocation_id}) this worker was started for"
|
|
389
|
+
)
|
|
390
|
+
if scheduler is None:
|
|
391
|
+
_refuse(f"no scheduler.json for EPIC #{args.epic}; no scheduler started this worker")
|
|
392
|
+
if scheduler.invocation.invocation_id != args.invocation_id:
|
|
393
|
+
_refuse(
|
|
394
|
+
f"scheduler.json names invocation {scheduler.invocation.invocation_id}, not "
|
|
395
|
+
f"{args.invocation_id}"
|
|
396
|
+
)
|
|
397
|
+
if (scheduler.repository_id, scheduler.epic) != (binding.run_key.repository_id, binding.epic):
|
|
398
|
+
_refuse("scheduler.json names another repository or EPIC than the namespace's binding")
|
|
399
|
+
try:
|
|
400
|
+
config = load_config_file(args.config or None)
|
|
401
|
+
except ConfigurationError as exc:
|
|
402
|
+
_refuse(f"the configuration does not load: {exc}")
|
|
403
|
+
if config_digest(config) != scheduler.config.config_digest:
|
|
404
|
+
_refuse(
|
|
405
|
+
f"the configuration {args.config or '(built-in defaults)'} is not the one the "
|
|
406
|
+
"scheduler started with (its digest differs from scheduler.json's): it was "
|
|
407
|
+
"edited since; stop the scheduler and start it again"
|
|
408
|
+
)
|
|
409
|
+
# As every command refuses it (#280): an enforced launch could rewrite it.
|
|
410
|
+
placement = config_file_placement_problem(config, args.config, args.repository)
|
|
411
|
+
if placement:
|
|
412
|
+
_refuse(placement)
|
|
413
|
+
repository = f"https://github.com/{scheduler.repository_owner}/{scheduler.repository_name}"
|
|
414
|
+
return _Started(
|
|
415
|
+
args=args,
|
|
416
|
+
root=root,
|
|
417
|
+
namespace=namespace,
|
|
418
|
+
l0=l0,
|
|
419
|
+
l2=l2,
|
|
420
|
+
config=config,
|
|
421
|
+
kind=current.kind,
|
|
422
|
+
state_ref_present=current.state_ref.present,
|
|
423
|
+
epic_url=f"{repository}/issues/{args.epic}",
|
|
424
|
+
issue_url=f"{repository}/issues/{args.issue}",
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _engine(
|
|
429
|
+
started: _Started,
|
|
430
|
+
github_factory: Callable[[AutoForgeConfig], GitHubClient] | None,
|
|
431
|
+
provider_factory: Callable[[AutoForgeConfig], ProviderRegistry] | None,
|
|
432
|
+
) -> ControllerEngine:
|
|
433
|
+
"""D9.2 step 5: the engine on the namespace's state, with the scheduler policy's hints."""
|
|
434
|
+
args = started.args
|
|
435
|
+
config = started.config
|
|
436
|
+
engine = ControllerEngine(
|
|
437
|
+
config=config,
|
|
438
|
+
workdir=args.repository,
|
|
439
|
+
github=github_factory(config) if github_factory is not None else None,
|
|
440
|
+
providers=provider_factory(config) if provider_factory is not None else None,
|
|
441
|
+
)
|
|
442
|
+
engine.bind_issue_namespace(started.namespace)
|
|
443
|
+
config_flags: tuple[str, ...] = ("--config", args.config) if args.config else ()
|
|
444
|
+
engine.cli_global_flags = (*config_flags, "--state-dir", str(started.namespace.state_dir))
|
|
445
|
+
engine.continue_command = (*config_flags, "epic", "resume", started.epic_url)
|
|
446
|
+
engine.config_file = args.config
|
|
447
|
+
engine.require_local_lock_filesystem = True
|
|
448
|
+
engine.progress_output = _progress_line
|
|
449
|
+
return engine
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _should_continue(started: _Started) -> Callable[[], bool]:
|
|
453
|
+
"""The step-boundary predicate (D13.3, D16.4): L2 still held, no stop request for us."""
|
|
454
|
+
|
|
455
|
+
def should_continue() -> bool:
|
|
456
|
+
try:
|
|
457
|
+
started.l2.prove_held()
|
|
458
|
+
except (LockError, OSError) as exc:
|
|
459
|
+
_say(f"stopping before the next step: the run lock (L2) is not proven held ({exc})")
|
|
460
|
+
return False
|
|
461
|
+
try:
|
|
462
|
+
stop = read_stop_request(started.root, started.args.epic)
|
|
463
|
+
except (StateError, OSError) as exc:
|
|
464
|
+
_say(f"stopping before the next step: the stop request cannot be read ({exc})")
|
|
465
|
+
return False
|
|
466
|
+
if stop.applies_to(started.args.invocation_id):
|
|
467
|
+
if stop.request is None:
|
|
468
|
+
_say(f"a stop request that does not parse is a drain ({stop.problem})")
|
|
469
|
+
_say("stopping at this step boundary: the scheduler is draining")
|
|
470
|
+
return False
|
|
471
|
+
return True
|
|
472
|
+
|
|
473
|
+
return should_continue
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def _run(
|
|
477
|
+
started: _Started,
|
|
478
|
+
github_factory: Callable[[AutoForgeConfig], GitHubClient] | None,
|
|
479
|
+
provider_factory: Callable[[AutoForgeConfig], ProviderRegistry] | None,
|
|
480
|
+
) -> int:
|
|
481
|
+
args = started.args
|
|
482
|
+
engine = _engine(started, github_factory, provider_factory)
|
|
483
|
+
with engine.locked(held=started.l0):
|
|
484
|
+
existing = engine.existing_run()
|
|
485
|
+
if existing is None:
|
|
486
|
+
if started.state_ref_present:
|
|
487
|
+
_refuse(
|
|
488
|
+
"the claim was admitted with a state, and the namespace holds none; "
|
|
489
|
+
"the scheduler classifies it again"
|
|
490
|
+
)
|
|
491
|
+
engine.validate_config()
|
|
492
|
+
# The start path of ``autoforge run <epic> <issue>`` (D9.5).
|
|
493
|
+
engine.new_run(started.epic_url, started.issue_url)
|
|
494
|
+
engine.record_run_configuration()
|
|
495
|
+
engine.save()
|
|
496
|
+
else:
|
|
497
|
+
if started.kind is ClaimKind.START:
|
|
498
|
+
_refuse("the claim was admitted with no state, and the namespace holds one")
|
|
499
|
+
state = engine.load()
|
|
500
|
+
if (
|
|
501
|
+
parse_issue_url(state.current_issue_url).number != args.issue
|
|
502
|
+
or parse_issue_url(state.epic_url).number != args.epic
|
|
503
|
+
):
|
|
504
|
+
_refuse(f"the namespace's state is not issue #{args.issue} of EPIC #{args.epic}")
|
|
505
|
+
if state.phase not in WORKER_STOP_PHASES:
|
|
506
|
+
notice = engine.check_run_configuration(
|
|
507
|
+
("epic", "resume", started.epic_url),
|
|
508
|
+
accept_change=args.accept_config_change,
|
|
509
|
+
)
|
|
510
|
+
if notice:
|
|
511
|
+
_say(notice)
|
|
512
|
+
engine.run(
|
|
513
|
+
max_steps=WORKER_MAX_STEPS,
|
|
514
|
+
allow_merge=False,
|
|
515
|
+
on_outcome=_print_outcome,
|
|
516
|
+
extra_stop_phases=WORKER_EXTRA_STOP_PHASES,
|
|
517
|
+
should_continue=_should_continue(started),
|
|
518
|
+
)
|
|
519
|
+
final = engine.state
|
|
520
|
+
assert final is not None
|
|
521
|
+
_say(
|
|
522
|
+
f"issue #{args.issue} stopped in phase {final.phase.value} (settlement "
|
|
523
|
+
f"{issue_settlement(final).value})"
|
|
524
|
+
)
|
|
525
|
+
if final.phase in (Phase.BLOCKED, Phase.FAILED):
|
|
526
|
+
_say(f"run {final.run_id} is {final.phase.value}: {final.block_reason or '-'}")
|
|
527
|
+
return 1
|
|
528
|
+
return 0
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def run_worker(
|
|
532
|
+
argv: Sequence[str],
|
|
533
|
+
*,
|
|
534
|
+
github_factory: Callable[[AutoForgeConfig], GitHubClient] | None = None,
|
|
535
|
+
provider_factory: Callable[[AutoForgeConfig], ProviderRegistry] | None = None,
|
|
536
|
+
) -> int:
|
|
537
|
+
"""``autoforge epic _worker ARGV``: run one admitted issue; the process's exit status.
|
|
538
|
+
|
|
539
|
+
0 once the run stopped at its boundary (READY_FOR_MERGE, a stop
|
|
540
|
+
request, a step limit), 1 for a BLOCKED or FAILED run or another
|
|
541
|
+
controller error, 2 for a refused start or a configuration, state or
|
|
542
|
+
lock error (nothing was written by a refused start), 130 after SIGINT or
|
|
543
|
+
SIGTERM. The status is a diagnostic: the scheduler decides by the state
|
|
544
|
+
the worker left (#181's ``finalize_worker``). ``github_factory`` and
|
|
545
|
+
``provider_factory`` build the engine's client and providers from the
|
|
546
|
+
loaded configuration (tests inject fakes); by default the engine builds
|
|
547
|
+
its own.
|
|
548
|
+
"""
|
|
549
|
+
with _bounded_output(), _interrupts():
|
|
550
|
+
try:
|
|
551
|
+
try:
|
|
552
|
+
started = _start(parse_worker_arguments(argv))
|
|
553
|
+
except WorkerStartupError as exc:
|
|
554
|
+
_say(f"refused to start, nothing was read or written: {exc}")
|
|
555
|
+
return EXIT_REFUSED
|
|
556
|
+
return _run(started, github_factory, provider_factory)
|
|
557
|
+
except KeyboardInterrupt:
|
|
558
|
+
_say("interrupted; the state is as the last step saved it")
|
|
559
|
+
return EXIT_INTERRUPTED
|
|
560
|
+
except WorkerStartupError as exc:
|
|
561
|
+
_say(f"refused to run, nothing was written: {exc}")
|
|
562
|
+
return EXIT_REFUSED
|
|
563
|
+
except (ConfigurationError, StateError, StateTransitionError, LockError) as exc:
|
|
564
|
+
_say(f"error: {exc}")
|
|
565
|
+
return EXIT_REFUSED
|
|
566
|
+
except AutoForgeError as exc:
|
|
567
|
+
_say(f"error: {exc}")
|
|
568
|
+
return 1
|