autoforge-cli 0.1.0a0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/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