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/scheduler.py ADDED
@@ -0,0 +1,1143 @@
1
+ """The parallel EPIC scheduler: bounded issue workers it owns (ADR 0008 D2, D9, D16; #182).
2
+
3
+ One foreground process runs up to ``scheduler.max_parallel`` issues of one
4
+ EPIC at once. It holds the repository's controller lock (L0) for its whole
5
+ life, admits issues one transaction at a time, and starts one worker process
6
+ per admitted issue (:mod:`autoforge.worker`), which runs the unchanged
7
+ single-issue engine on that issue's namespace and stops at its settlement
8
+ boundary. The scheduler itself never invokes an agent, never writes GitHub
9
+ (its only GitHub calls are #179's four graph reads), never records an
10
+ effect and never parses an agent's result.
11
+
12
+ **Startup.** Take L0 and switch it to close-only release at once
13
+ (:meth:`~autoforge.locking.ControllerLock.share`), so neither a normal exit
14
+ nor an exit by exception releases it while a worker that inherited it
15
+ lives; refuse a coordination root whose ``flock`` is not local; read the
16
+ repository id and the EPIC; call the caller's ``before_start`` (``epic
17
+ run``'s ``--force`` and its refusal of open claims, #183); refuse a corrupt
18
+ ``scheduler.json`` or one of another repository (global uncertainty, D14.3)
19
+ without writing it; write ``scheduler.json`` for a new invocation; remove
20
+ stale stop requests; print the ``allow_merge`` notice when the
21
+ configuration enables merging (the configuration is not changed); install
22
+ the SIGINT, SIGTERM and SIGHUP handlers, which only set a flag; run #181's
23
+ reconciliation, whose global problems admit nothing; apply the reverse
24
+ guard (the configured standalone state's issue is ``held:standalone-state``
25
+ in any phase but ``DONE``).
26
+
27
+ **Each tick** (D9.7): reap exited workers and finalize their claims under a
28
+ re-acquired L2 (#181's ``finalize_worker``); handle signals and the stop
29
+ request; then, while admission is open and slots are free, take a snapshot
30
+ (at most once per ``poll_interval_seconds``, or at once after a stale
31
+ re-read), reconcile again under L2 each namespace whose run lock was seen
32
+ held (D14.2 step 2; one still held stays ``owned``), close each earlier
33
+ invocation's ``resumable`` claim whose issue is no longer ready
34
+ (``close_unready``), those the re-check found included (one whose run lock
35
+ is held then is seen held too, and the next snapshot tries again), and
36
+ admit ready issues in order up to the free slots. It exits once no worker
37
+ is live and nothing is admissible: each issue seen held gets one more
38
+ snapshot first, and one still held then is reported.
39
+
40
+ **Admission** (D2.2), holding L0: L2 non-blocking; under it, the strict
41
+ read and classification of the namespace (only none, ``fresh`` and
42
+ ``resumable`` go on); #179's re-read of the issue and its blockers (a
43
+ ``stale`` one writes no claim and makes the next tick re-snapshot); the
44
+ binding on first admission; the claim (superseding a still open
45
+ ``resumable`` one); the worker spawned with L0 and L2 by inheritance, its
46
+ diagnostics recorded, and the scheduler's L2 copy closed (``close`` only).
47
+ A spawn that raised is D2.3's: L2 probed afresh, the claim closed
48
+ ``abandoned`` and the issue ``held:spawn-failed`` when it is free, the claim
49
+ kept open and the issue live when it is not.
50
+
51
+ **No respawn within an invocation:** a worker that exits leaves its issue
52
+ held for the rest of the invocation (settled, ``resumable`` or uncertain).
53
+
54
+ **Stop** (D16): the first signal, or a ``now`` stop request, closes
55
+ admission, sends SIGINT to every live worker and waits up to
56
+ ``stop_grace_seconds``, finalizing each worker that exits; a second signal,
57
+ or the grace expiring, forwards a second SIGINT and exits at once. A
58
+ ``drain`` request closes admission and lets every worker return at its next
59
+ step boundary. The scheduler never sends SIGKILL, and a terminal write that
60
+ fails (``EIO`` after SIGHUP) is ignored, never aborting finalization.
61
+
62
+ Nothing here names a provider: every provider runs through the same worker.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import os
68
+ import signal
69
+ import subprocess
70
+ import sys
71
+ import threading
72
+ import time
73
+ from collections.abc import Callable, Mapping, Sequence
74
+ from dataclasses import dataclass, field
75
+ from enum import Enum
76
+ from pathlib import Path
77
+ from types import FrameType
78
+
79
+ from .config import MAX_PARALLEL_BOUNDS, AutoForgeConfig, config_digest
80
+ from .engine import ControllerEngine
81
+ from .epic_graph import (
82
+ ChildVerdict,
83
+ EpicSnapshot,
84
+ GraphReader,
85
+ LocalFact,
86
+ VerdictKind,
87
+ build_epic_snapshot,
88
+ evaluate,
89
+ ready_set,
90
+ reread_node,
91
+ )
92
+ from .errors import (
93
+ AutoForgeError,
94
+ ConfigurationError,
95
+ ExecutionError,
96
+ GitHubError,
97
+ IssueBusy,
98
+ LockError,
99
+ SnapshotRefused,
100
+ StateError,
101
+ )
102
+ from .executor_supervised import SupervisedChild, process_start_time, spawn_supervised
103
+ from .locking import (
104
+ ControllerLock,
105
+ IssueRunLock,
106
+ check_local_lock_filesystem,
107
+ repository_lock_path,
108
+ )
109
+ from .ownership import standalone_hold
110
+ from .redaction import redact
111
+ from .run_namespace import (
112
+ WORKERS_DIRNAME,
113
+ EpicRef,
114
+ IssueNamespace,
115
+ NamespaceKind,
116
+ NamespaceLookup,
117
+ RunBinding,
118
+ RunKey,
119
+ issue_namespace,
120
+ write_binding,
121
+ )
122
+ from .safefs import SafeRoot
123
+ from .scheduler_state import (
124
+ Admission,
125
+ AdmissionReason,
126
+ AdmissionState,
127
+ AdmissionStatus,
128
+ Claim,
129
+ ClaimClassification,
130
+ ClaimKind,
131
+ ClassificationKind,
132
+ EndReason,
133
+ ExpectedIdentity,
134
+ Invocation,
135
+ ReconcileReport,
136
+ SchedulerConfig,
137
+ SchedulerState,
138
+ SnapshotSummary,
139
+ StateRef,
140
+ WorkerDiagnostics,
141
+ bounded_detail,
142
+ classify,
143
+ close_claim,
144
+ close_unready,
145
+ finalize_worker,
146
+ load_scheduler_state,
147
+ new_invocation_id,
148
+ open_claim,
149
+ read_namespace,
150
+ reconcile_epic,
151
+ record_worker,
152
+ write_scheduler_state,
153
+ )
154
+ from .scheduler_stop import StopMode, read_stop_request, remove_stale_stop_requests
155
+ from .state import utcnow_iso
156
+ from .validation import parse_issue_url
157
+ from .worker import MAX_WORKER_OUTPUT_BYTES, WorkerArguments
158
+
159
+ # How a worker is started (D9.1): the scheduler's own interpreter, by
160
+ # absolute path, never a PATH lookup; ``-P`` keeps the working directory off
161
+ # ``sys.path``, so a checkout's own ``autoforge/`` directory is never imported.
162
+ DEFAULT_WORKER_COMMAND: tuple[str, ...] = (sys.executable, "-P", "-m", "autoforge.cli")
163
+ # The loop's tick: how soon an exited worker is reaped and a signal handled.
164
+ TICK_SECONDS = 0.1
165
+ # Stale admission re-reads in a row that each take a new snapshot at once;
166
+ # after that the next snapshot, and with it the next re-read, waits for the
167
+ # poll interval.
168
+ MAX_STALE_RESNAPSHOTS = 3
169
+
170
+ ALLOW_MERGE_NOTICE = (
171
+ "safety.allow_merge is enabled in configuration, but scheduler workers stop at "
172
+ "READY_FOR_MERGE and never merge in this version; configuration is not changed."
173
+ )
174
+
175
+ # How the claim of a worker that never started is closed (D2.3): the
176
+ # ``detail`` of its ``abandoned`` claim starts with one of these, so ``epic
177
+ # status`` (#183) shows the issue held for the rest of the invocation.
178
+ WORKER_LOG_UNOPENED = "the worker log cannot be opened"
179
+ WORKER_NOT_STARTED = "the worker could not be started"
180
+ SPAWN_FAILURE_DETAILS = (WORKER_LOG_UNOPENED, WORKER_NOT_STARTED)
181
+
182
+ EXIT_CODES = {
183
+ EndReason.NOTHING_ADMISSIBLE: 0,
184
+ EndReason.DRAINED: 0,
185
+ EndReason.STOPPED: 130,
186
+ EndReason.INTERRUPTED: 130,
187
+ EndReason.DEGRADED: 1,
188
+ }
189
+
190
+ _SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP)
191
+
192
+ Spawn = Callable[..., SupervisedChild]
193
+
194
+
195
+ @dataclass
196
+ class SchedulerInputs:
197
+ """What one scheduler invocation runs on; the CLI (#183) fills it in.
198
+
199
+ ``repository`` is the operator's checkout (absolute), ``epic_url`` the
200
+ EPIC, ``config`` the loaded configuration and ``config_path`` its
201
+ absolute file path ("" for the built-in defaults), which every worker
202
+ loads again. ``graph`` makes #179's four reads. ``max_parallel``
203
+ overrides ``scheduler.max_parallel`` within the same bounds.
204
+ ``accept_config_change`` is forwarded to every ``resume`` worker (D9.4).
205
+ ``standalone_state_dir`` is the configured standalone state directory
206
+ the reverse guard reads (``config.state_dir``, resolved against the
207
+ current directory, by default). ``env`` is the workers' environment
208
+ (this process's by default). ``before_start`` is called holding L0, once
209
+ the repository and the EPIC are read and before ``scheduler.json`` is
210
+ read or anything is written, with the coordination root and the EPIC:
211
+ ``epic run``'s ``--force`` and its refusal of open claims (#183); what it
212
+ raises ends the invocation with nothing written. ``worker_command``,
213
+ ``spawn``, ``output``, ``on_tick``, ``clock`` and ``sleep`` are
214
+ injection points.
215
+ """
216
+
217
+ repository: str
218
+ epic_url: str
219
+ config: AutoForgeConfig
220
+ graph: GraphReader
221
+ config_path: str = ""
222
+ max_parallel: int | None = None
223
+ accept_config_change: bool = False
224
+ standalone_state_dir: str | None = None
225
+ env: Mapping[str, str] | None = None
226
+ before_start: Callable[[Path, EpicRef], None] | None = None
227
+ worker_command: Sequence[str] = DEFAULT_WORKER_COMMAND
228
+ spawn: Spawn = spawn_supervised
229
+ output: Callable[[str], None] | None = None
230
+ on_tick: Callable[[EpicScheduler], None] | None = None
231
+ tick_seconds: float = TICK_SECONDS
232
+ clock: Callable[[], float] = time.monotonic
233
+ sleep: Callable[[float], None] = time.sleep
234
+
235
+
236
+ @dataclass
237
+ class _Worker:
238
+ """One live worker: its issue, its claim and its supervision handle."""
239
+
240
+ issue: int
241
+ namespace: IssueNamespace
242
+ claim: Claim
243
+ child: SupervisedChild
244
+ status: int | None = None
245
+
246
+
247
+ class _Admit(Enum):
248
+ ADMITTED = "admitted"
249
+ SKIPPED = "skipped"
250
+ STALE = "stale"
251
+
252
+
253
+ @dataclass
254
+ class _Snapshot:
255
+ snapshot: EpicSnapshot
256
+ taken_at: float
257
+ expected: ExpectedIdentity
258
+ verdicts: tuple[ChildVerdict, ...] = field(default_factory=tuple)
259
+ # An admission re-read found it stale after the re-snapshot budget was
260
+ # spent: nothing more is admitted from it, and so nothing re-read.
261
+ stale: bool = False
262
+
263
+
264
+ def _default_output(line: str) -> None:
265
+ """One line on stderr; a terminal that is gone (``EIO`` after SIGHUP) is ignored (D16.1)."""
266
+ try:
267
+ print(line, file=sys.stderr, flush=True)
268
+ except (OSError, ValueError):
269
+ pass
270
+
271
+
272
+ class EpicScheduler:
273
+ """One scheduler invocation for one EPIC; :meth:`run` is the whole of it."""
274
+
275
+ def __init__(self, inputs: SchedulerInputs) -> None:
276
+ self.inputs = inputs
277
+ self.config = inputs.config
278
+ settings = inputs.config.scheduler
279
+ self.max_parallel = (
280
+ settings.max_parallel if inputs.max_parallel is None else inputs.max_parallel
281
+ )
282
+ low, high = MAX_PARALLEL_BOUNDS
283
+ if (
284
+ isinstance(self.max_parallel, bool)
285
+ or not isinstance(self.max_parallel, int)
286
+ or not low <= self.max_parallel <= high
287
+ ):
288
+ raise ConfigurationError(
289
+ f"max_parallel must be between {low} and {high}, got {self.max_parallel!r} "
290
+ "(there is no unbounded mode)"
291
+ )
292
+ self.poll_interval = settings.poll_interval_seconds
293
+ self.stop_grace = settings.stop_grace_seconds
294
+ self.epic = parse_issue_url(inputs.epic_url)
295
+ self.repository = self.epic.repository
296
+ self.repository_root = os.path.abspath(inputs.repository)
297
+ self.coordination_root = Path("/")
298
+ self.invocation_id = new_invocation_id()
299
+ self.repository_id = 0
300
+ self.epic_ref: EpicRef | None = None
301
+ self.admission = AdmissionState(AdmissionStatus.OPEN)
302
+ self.end_reason: EndReason | None = None
303
+ self._output = inputs.output or _default_output
304
+ self._lock: ControllerLock | None = None
305
+ self._l0_fd = -1
306
+ self._state: SchedulerState | None = None
307
+ self._live: dict[int, _Worker] = {}
308
+ self._unfinalized: dict[int, _Worker] = {}
309
+ # Facts held for the rest of the invocation (D4.1, D9.6).
310
+ self._held: dict[int, LocalFact] = {}
311
+ # Live owners this scheduler did not start (``observed_live``), reconciled
312
+ # again under L2 at every snapshot (D14.2 step 2), and those already
313
+ # given their one more snapshot before the scheduler ends.
314
+ self._observed_live: set[int] = set()
315
+ self._observed_rechecked: set[int] = set()
316
+ # Earlier invocations' open resumable claims (D14.2): superseded by an
317
+ # admission or closed by close_unready.
318
+ self._resumable_open: dict[int, Claim] = {}
319
+ self._snapshot: _Snapshot | None = None
320
+ self._force_snapshot = False
321
+ self._stale_streak = 0
322
+ self._signal_count = 0
323
+ self._now_requested = False
324
+ self._interrupting = False
325
+ self._grace_deadline: float | None = None
326
+ self._exit_now = False
327
+ self._previous_handlers: dict[int, object] = {}
328
+ self._path_engine: ControllerEngine | None = None
329
+ self.children: list[SupervisedChild] = []
330
+
331
+ # -- output ---------------------------------------------------------------------------
332
+
333
+ def say(self, text: str) -> None:
334
+ """One line of the scheduler's output, redacted; a failed write is ignored (D16.1)."""
335
+ try:
336
+ self._output(redact(f"autoforge epic: {text}"))
337
+ except (OSError, ValueError):
338
+ pass
339
+
340
+ # -- facts ----------------------------------------------------------------------------
341
+
342
+ def live_issues(self) -> tuple[int, ...]:
343
+ """The issues whose worker this scheduler started and has not reaped, ascending."""
344
+ return tuple(sorted(self._live))
345
+
346
+ def local_facts(self) -> dict[int, LocalFact]:
347
+ """The facts :func:`~autoforge.epic_graph.evaluate` is given now (D4.1)."""
348
+ facts = dict(self._held)
349
+ for number in (*self._observed_live, *self._live, *self._unfinalized):
350
+ facts[number] = LocalFact.OWNED
351
+ return facts
352
+
353
+ # -- signals --------------------------------------------------------------------------
354
+
355
+ def _on_signal(self, signum: int, frame: FrameType | None) -> None:
356
+ self._signal_count += 1
357
+
358
+ def request_interrupt(self) -> None:
359
+ """What a signal does: count it (D16.1); the loop acts at its next tick."""
360
+ self._signal_count += 1
361
+
362
+ def _install_handlers(self) -> None:
363
+ if threading.current_thread() is not threading.main_thread():
364
+ return
365
+ for signum in _SIGNALS:
366
+ self._previous_handlers[signum] = signal.signal(signum, self._on_signal)
367
+
368
+ def _restore_handlers(self) -> None:
369
+ for signum, previous in self._previous_handlers.items():
370
+ signal.signal(signum, previous) # type: ignore[arg-type]
371
+ self._previous_handlers.clear()
372
+
373
+ # -- scheduler.json -------------------------------------------------------------------
374
+
375
+ def _write_state(self, **changes: object) -> None:
376
+ state = self._state
377
+ assert state is not None
378
+ updated = SchedulerState(
379
+ repository_id=state.repository_id,
380
+ repository_owner=state.repository_owner,
381
+ repository_name=state.repository_name,
382
+ epic=state.epic,
383
+ invocation=changes.get("invocation", state.invocation), # type: ignore[arg-type]
384
+ config=state.config,
385
+ admission=self.admission,
386
+ last_snapshot=changes.get("last_snapshot", state.last_snapshot), # type: ignore[arg-type]
387
+ created_at=state.created_at,
388
+ updated_at=state.updated_at,
389
+ )
390
+ try:
391
+ self._state = write_scheduler_state(self.coordination_root, updated)
392
+ except (StateError, OSError) as exc:
393
+ self.say(f"cannot write scheduler.json: {exc}")
394
+ self._state = updated
395
+
396
+ def _close_admission(self, status: AdmissionStatus, reason: AdmissionReason) -> None:
397
+ if self.admission.status is not AdmissionStatus.OPEN:
398
+ return
399
+ self.admission = AdmissionState(status, reason)
400
+ self._write_state()
401
+
402
+ # -- the run --------------------------------------------------------------------------
403
+
404
+ def run(self) -> int:
405
+ """Run the invocation to its end; the exit status (``EXIT_CODES``).
406
+
407
+ A held controller lock is :class:`~autoforge.errors.LockError`, raised
408
+ before anything is written; so are the refused-filesystem and other
409
+ startup refusals of their own types.
410
+ """
411
+ lock_path = repository_lock_path(self.repository_root)
412
+ self.coordination_root = lock_path.parent.parent
413
+ lock = ControllerLock(lock_path).acquire()
414
+ self._lock = lock
415
+ # Close-only from here on (D10.3): no exit of this process, by
416
+ # exception included, releases L0 while a worker that inherited it lives.
417
+ self._l0_fd = lock.share()
418
+ try:
419
+ check_local_lock_filesystem(
420
+ self.coordination_root, what=f"the coordination root {self.coordination_root}"
421
+ )
422
+ if not self._start():
423
+ return EXIT_CODES[EndReason.DEGRADED]
424
+ try:
425
+ self._loop()
426
+ finally:
427
+ self._restore_handlers()
428
+ return self._finish()
429
+ finally:
430
+ self._restore_handlers()
431
+ self._lock = None
432
+ lock.release()
433
+
434
+ def _start(self) -> bool:
435
+ """The startup path up to the first tick; ``False`` when nothing may be admitted."""
436
+ try:
437
+ self.repository_id = self.inputs.graph.get_repository_id(self.repository)
438
+ epic_node = self.inputs.graph.get_issue_node(self.epic.canonical)
439
+ except GitHubError as exc:
440
+ self.say(f"cannot read the repository or the EPIC on GitHub: {exc}")
441
+ return False
442
+ self.epic_ref = EpicRef(self.epic.number, epic_node.id)
443
+ if self.inputs.before_start is not None:
444
+ self.inputs.before_start(self.coordination_root, self.epic_ref)
445
+ try:
446
+ existing = load_scheduler_state(self.coordination_root, self.epic.number)
447
+ except StateError as exc:
448
+ self.say(
449
+ f"scheduler.json cannot be read, so nothing is admitted ({exc}); "
450
+ "'epic run --force' moves it aside"
451
+ )
452
+ return False
453
+ if existing is not None and existing.repository_id != self.repository_id:
454
+ self.say(
455
+ f"scheduler.json names repository id {existing.repository_id}, and the live "
456
+ f"repository is {self.repository_id}: nothing is admitted"
457
+ )
458
+ return False
459
+ owner, _, name = self.repository.partition("/")
460
+ now = utcnow_iso()
461
+ self._state = write_scheduler_state(
462
+ self.coordination_root,
463
+ SchedulerState(
464
+ repository_id=self.repository_id,
465
+ repository_owner=owner,
466
+ repository_name=name,
467
+ epic=self.epic_ref,
468
+ invocation=Invocation(self.invocation_id, now),
469
+ config=SchedulerConfig(
470
+ self.max_parallel,
471
+ self.poll_interval,
472
+ self.stop_grace,
473
+ config_digest(self.config),
474
+ ),
475
+ admission=self.admission,
476
+ created_at=existing.created_at if existing is not None else now,
477
+ updated_at=now,
478
+ ),
479
+ )
480
+ for name in remove_stale_stop_requests(
481
+ self.coordination_root, self.epic.number, self.invocation_id, at_start=True
482
+ ):
483
+ self.say(f"removed the stale {name} of an earlier invocation")
484
+ self.say(
485
+ f"EPIC #{self.epic.number} of {self.repository}: invocation {self.invocation_id}, "
486
+ f"at most {self.max_parallel} worker(s)"
487
+ )
488
+ if self.config.merge_allowed_by_config:
489
+ self.say(ALLOW_MERGE_NOTICE)
490
+ self._install_handlers()
491
+ expected = ExpectedIdentity(self.repository_id, repository=self.repository)
492
+ report = reconcile_epic(self.coordination_root, self.epic_ref, expected=expected)
493
+ if not self._take_reconciliation(report):
494
+ self.end_reason = EndReason.DEGRADED
495
+ self._finish()
496
+ return False
497
+ self._apply_reverse_guard()
498
+ return True
499
+
500
+ def _take_reconciliation(self, report: ReconcileReport, *, again: bool = False) -> bool:
501
+ """Record what reconciliation found (D14.2); ``False`` on global uncertainty.
502
+
503
+ A live owner is ``observed_live``; a fact is held for the invocation;
504
+ an open ``resumable`` claim waits for an admission's supersession or
505
+ ``close_unready``. Global uncertainty (D14.3) degrades admission.
506
+ ``again`` is the re-check of namespaces seen live before: one still
507
+ held is not reported a second time.
508
+ """
509
+ for problem in report.global_problems:
510
+ self.say(f"global uncertainty, nothing is admitted: {problem}")
511
+ for item in report.issues:
512
+ number = item.issue_number
513
+ if item.observed_live:
514
+ self._observed_live.add(number)
515
+ if not again:
516
+ self.say(f"issue #{number}: its run lock is held ({item.detail})")
517
+ continue
518
+ if item.fact is LocalFact.HELD_UNCERTAIN:
519
+ self.say(f"issue #{number} is uncertain: {item.detail}")
520
+ elif again:
521
+ self.say(f"issue #{number}: its run lock is free now ({item.detail})")
522
+ if item.fact is not None:
523
+ self._held[number] = item.fact
524
+ classification = item.classification
525
+ if (
526
+ classification is not None
527
+ and classification.kind is ClassificationKind.RESUMABLE
528
+ and classification.open_claim is not None
529
+ ):
530
+ self._resumable_open[number] = classification.open_claim
531
+ if report.admissible:
532
+ return True
533
+ self.admission = AdmissionState(
534
+ AdmissionStatus.DEGRADED, AdmissionReason.EPIC_NOT_ADMISSIBLE
535
+ )
536
+ return False
537
+
538
+ def _apply_reverse_guard(self) -> None:
539
+ """D17.2: the configured standalone state's issue is not admitted."""
540
+ state_dir = self.inputs.standalone_state_dir or os.path.abspath(self.config.state_dir)
541
+ hold = standalone_hold(state_dir)
542
+ if hold.problem:
543
+ self.say(f"the standalone state {hold.state_file} cannot be read: {hold.problem}")
544
+ if not hold.holds:
545
+ return
546
+ assert hold.issue_number is not None
547
+ try:
548
+ same = parse_issue_url(hold.issue_url).repository.lower() == self.repository.lower()
549
+ except ConfigurationError:
550
+ same = False
551
+ if same:
552
+ self._held.setdefault(hold.issue_number, LocalFact.HELD_STANDALONE_STATE)
553
+ self.say(
554
+ f"issue #{hold.issue_number} is held: the standalone state {hold.state_file} "
555
+ f"runs it (phase {hold.phase})"
556
+ )
557
+
558
+ def _loop(self) -> None:
559
+ while True:
560
+ self._reap()
561
+ self._handle_stop()
562
+ if self._exit_now:
563
+ return
564
+ if self.admission.status is AdmissionStatus.OPEN and self._free_slots() > 0:
565
+ self._admit_ready()
566
+ if not self._live and not self._unfinalized and not self._admissible():
567
+ return
568
+ if self.inputs.on_tick is not None:
569
+ self.inputs.on_tick(self)
570
+ self.inputs.sleep(self.inputs.tick_seconds)
571
+
572
+ def _free_slots(self) -> int:
573
+ return self.max_parallel - len(self._live)
574
+
575
+ def _admissible(self) -> bool:
576
+ """Whether the loop has anything left to admit: an open admission and a ready issue."""
577
+ if self.admission.status is not AdmissionStatus.OPEN:
578
+ return False
579
+ if self._force_snapshot or self._snapshot is None:
580
+ return True
581
+ if ready_set(evaluate(self._snapshot.snapshot, self.local_facts())):
582
+ return True
583
+ unchecked = self._observed_live - self._observed_rechecked
584
+ if unchecked:
585
+ # A run lock seen held once may have been a status probe: one more
586
+ # snapshot re-checks it before the scheduler ends (D14.2 step 2), and
587
+ # tries again a close_unready it refused. Once per issue: one held
588
+ # at every re-check is reported, never waited for.
589
+ self._observed_rechecked |= unchecked
590
+ self._force_snapshot = True
591
+ return True
592
+ for number in sorted(self._observed_live):
593
+ left = (
594
+ "; the earlier invocation's claim stays open for the next start"
595
+ if number in self._resumable_open
596
+ else ""
597
+ )
598
+ self.say(
599
+ f"issue #{number}: its run lock stays held by a process this scheduler did "
600
+ f"not start; it was not admitted{left}"
601
+ )
602
+ return False
603
+
604
+ def _finish(self) -> int:
605
+ reason = self.end_reason
606
+ if reason is None:
607
+ reason = (
608
+ EndReason.DEGRADED
609
+ if self.admission.status is AdmissionStatus.DEGRADED
610
+ else EndReason.NOTHING_ADMISSIBLE
611
+ )
612
+ self.end_reason = reason
613
+ state = self._state
614
+ if state is not None:
615
+ self._write_state(
616
+ invocation=Invocation(
617
+ state.invocation.invocation_id,
618
+ state.invocation.started_at,
619
+ utcnow_iso(),
620
+ reason,
621
+ )
622
+ )
623
+ remaining = sorted({*self._live, *self._unfinalized})
624
+ if remaining:
625
+ listed = ", ".join(f"#{n}" for n in remaining)
626
+ self.say(
627
+ f"exiting ({reason.value}) while the workers of {listed} still run: they keep "
628
+ "the controller lock until they exit, and their claims are classified at the "
629
+ "next start"
630
+ )
631
+ else:
632
+ self.say(f"done ({reason.value})")
633
+ return EXIT_CODES[reason]
634
+
635
+ # -- reaping --------------------------------------------------------------------------
636
+
637
+ def _reap(self) -> None:
638
+ for number in sorted(self._live):
639
+ worker = self._live[number]
640
+ status = worker.child.poll()
641
+ if status is None:
642
+ continue
643
+ del self._live[number]
644
+ worker.status = status
645
+ self._unfinalized[number] = worker
646
+ for number in sorted(self._unfinalized):
647
+ if self._finalize(self._unfinalized[number]):
648
+ del self._unfinalized[number]
649
+
650
+ def _expected(self) -> ExpectedIdentity:
651
+ if self._snapshot is not None:
652
+ return self._snapshot.expected
653
+ return ExpectedIdentity(self.repository_id, repository=self.repository)
654
+
655
+ def _finalize(self, worker: _Worker) -> bool:
656
+ """Close a reaped worker's claim by the state it left; ``False`` to retry next tick."""
657
+ number = worker.issue
658
+ lock = IssueRunLock(worker.namespace)
659
+ try:
660
+ lock.acquire()
661
+ except IssueBusy:
662
+ return False # a status probe at this instant: the next tick takes it
663
+ except (LockError, StateError, OSError) as exc:
664
+ self._held[number] = LocalFact.HELD_UNCERTAIN
665
+ self.say(f"issue #{number}: the run lock cannot be taken to finalize ({exc})")
666
+ return True
667
+ try:
668
+ finalization = finalize_worker(
669
+ worker.namespace,
670
+ lock,
671
+ worker.claim,
672
+ worker.status,
673
+ expected=self._expected(),
674
+ )
675
+ except (StateError, OSError) as exc:
676
+ self._held[number] = LocalFact.HELD_UNCERTAIN
677
+ self.say(f"issue #{number}: its claim cannot be finalized ({exc}); it stays open")
678
+ return True
679
+ finally:
680
+ lock.release()
681
+ classification = finalization.classification
682
+ if classification.kind is ClassificationKind.SETTLED:
683
+ self._held[number] = LocalFact.HELD_SETTLED_NAMESPACE
684
+ elif classification.kind is ClassificationKind.RESUMABLE:
685
+ self._held[number] = LocalFact.HELD_RESUMABLE
686
+ else:
687
+ self._held[number] = LocalFact.HELD_UNCERTAIN
688
+ closed = (
689
+ f"claim closed {finalization.closed.classification.value}"
690
+ if finalization.closed is not None
691
+ else "claim left open"
692
+ )
693
+ self.say(
694
+ f"issue #{number}: worker exited with status {worker.status}: {classification} "
695
+ f"({closed})"
696
+ )
697
+ return True
698
+
699
+ # -- stop and signals -----------------------------------------------------------------
700
+
701
+ def _handle_stop(self) -> None:
702
+ try:
703
+ stop = read_stop_request(self.coordination_root, self.epic.number)
704
+ except (StateError, OSError) as exc:
705
+ self.say(f"the stop request cannot be read ({exc}); it is taken as a drain")
706
+ stop = None
707
+ self._drain()
708
+ if stop is not None:
709
+ if stop.bound_elsewhere(self.invocation_id):
710
+ try:
711
+ remove_stale_stop_requests(
712
+ self.coordination_root,
713
+ self.epic.number,
714
+ self.invocation_id,
715
+ at_start=False,
716
+ )
717
+ except (StateError, OSError) as exc:
718
+ self.say(f"cannot remove a stale stop request ({exc})")
719
+ elif stop.applies_to(self.invocation_id):
720
+ if stop.request is None and self.admission.status is AdmissionStatus.OPEN:
721
+ self.say(f"a stop request that does not parse is a drain ({stop.problem})")
722
+ if stop.mode is StopMode.NOW:
723
+ self._now_requested = True
724
+ else:
725
+ self._drain()
726
+ interrupts = self._signal_count + (1 if self._now_requested else 0)
727
+ if interrupts >= 1 and not self._interrupting:
728
+ # The first signal, or a ``now`` request: as Ctrl-C (D16.1, D16.4).
729
+ self._interrupting = True
730
+ if self._signal_count:
731
+ self.end_reason = EndReason.INTERRUPTED
732
+ self._close_admission(AdmissionStatus.CLOSED, AdmissionReason.INTERRUPTED)
733
+ else:
734
+ self.end_reason = EndReason.STOPPED
735
+ self._close_admission(AdmissionStatus.CLOSED, AdmissionReason.STOP_REQUESTED)
736
+ self._grace_deadline = self.inputs.clock() + self.stop_grace
737
+ self._signal_live(signal.SIGINT)
738
+ self.say(
739
+ f"stopping: admission closed; SIGINT sent to {len(self._live)} worker(s), "
740
+ f"waiting up to {self.stop_grace}s for them to return"
741
+ )
742
+ return
743
+ if not self._interrupting:
744
+ return
745
+ expired = self._grace_deadline is not None and self.inputs.clock() >= self._grace_deadline
746
+ if interrupts >= 2 or expired:
747
+ self._signal_live(signal.SIGINT)
748
+ self._exit_now = True
749
+ self.say(
750
+ "a second interrupt"
751
+ if interrupts >= 2
752
+ else f"the stop grace of {self.stop_grace}s expired"
753
+ )
754
+
755
+ def _drain(self) -> None:
756
+ if self.admission.status is AdmissionStatus.OPEN:
757
+ self._close_admission(AdmissionStatus.CLOSED, AdmissionReason.STOP_REQUESTED)
758
+ if self.end_reason is None:
759
+ self.end_reason = EndReason.DRAINED
760
+ self.say("draining: admission closed; each worker returns at its next step boundary")
761
+
762
+ def _signal_live(self, sig: int) -> None:
763
+ """``sig`` to every unreaped worker through its handle: never a pid read from a file."""
764
+ for worker in self._live.values():
765
+ worker.child.signal(sig)
766
+
767
+ # -- snapshots ------------------------------------------------------------------------
768
+
769
+ def _take_snapshot(self) -> _Snapshot | None:
770
+ try:
771
+ snapshot = build_epic_snapshot(
772
+ self.inputs.graph, self.epic.canonical, repository_id=self.repository_id
773
+ )
774
+ except SnapshotRefused as exc:
775
+ later = " (it may succeed later: run the scheduler again)" if exc.transient else ""
776
+ self.say(
777
+ f"the EPIC snapshot was refused ({exc.reason}){later}: {exc}; admission closes, "
778
+ "running workers continue"
779
+ )
780
+ self.admission = AdmissionState(
781
+ AdmissionStatus.DEGRADED, AdmissionReason.SNAPSHOT_REFUSED
782
+ )
783
+ self._write_state()
784
+ return None
785
+ home = self.repository.lower()
786
+ expected = ExpectedIdentity(
787
+ self.repository_id,
788
+ {
789
+ child.number: child.id
790
+ for child in snapshot.children
791
+ if (child.repository or "").lower() == home
792
+ },
793
+ repository=self.repository,
794
+ )
795
+ if not self._recheck_observed_live(expected):
796
+ return None
797
+ taken = _Snapshot(snapshot, self.inputs.clock(), expected)
798
+ self._snapshot = taken
799
+ self._force_snapshot = False
800
+ self._close_unready(taken)
801
+ taken.verdicts = evaluate(snapshot, self.local_facts())
802
+ self._write_state(last_snapshot=self._summary(taken))
803
+ return taken
804
+
805
+ def _recheck_observed_live(self, expected: ExpectedIdentity) -> bool:
806
+ """D14.2 step 2: reconcile again each namespace whose run lock was seen held.
807
+
808
+ Before the snapshot's verdicts are used: a namespace whose L2 is free
809
+ now is classified under it as at startup, so an open ``resumable``
810
+ claim found only now reaches ``close_unready`` with this snapshot;
811
+ one still held stays ``observed_live``. Global uncertainty found now
812
+ closes admission as at startup; ``False`` then.
813
+ """
814
+ own = {*self._live, *self._unfinalized}
815
+ numbers = {number for number in self._observed_live if number not in own}
816
+ self._observed_live.clear()
817
+ if not numbers:
818
+ return True
819
+ assert self.epic_ref is not None
820
+ report = reconcile_epic(
821
+ self.coordination_root, self.epic_ref, only=numbers, expected=expected
822
+ )
823
+ if self._take_reconciliation(report, again=True):
824
+ return True
825
+ self._write_state()
826
+ return False
827
+
828
+ def _summary(self, taken: _Snapshot) -> SnapshotSummary:
829
+ counts = dict.fromkeys(("ready", "blocked", "closed", "unsupported", "owned", "held"), 0)
830
+ for verdict in taken.verdicts:
831
+ counts[verdict.kind.value] += 1
832
+ return SnapshotSummary(
833
+ taken.snapshot.digest,
834
+ taken.snapshot.observed_at,
835
+ children=len(taken.verdicts),
836
+ **counts,
837
+ )
838
+
839
+ def _close_unready(self, taken: _Snapshot) -> None:
840
+ """D14.2: an earlier invocation's resumable claim of an issue no longer ready is closed."""
841
+ verdicts = {verdict.number: verdict for verdict in evaluate(taken.snapshot, {})}
842
+ for number in sorted(self._resumable_open):
843
+ verdict = verdicts.get(number)
844
+ if verdict is not None and verdict.kind not in (
845
+ VerdictKind.CLOSED,
846
+ VerdictKind.BLOCKED,
847
+ VerdictKind.UNSUPPORTED,
848
+ ):
849
+ continue
850
+ why = (
851
+ "the issue is no longer a child of the EPIC"
852
+ if verdict is None
853
+ else f"the snapshot gives it {verdict.reason}"
854
+ )
855
+ namespace = issue_namespace(self.coordination_root, number)
856
+ lock = IssueRunLock(namespace)
857
+ try:
858
+ lock.acquire()
859
+ except IssueBusy:
860
+ # Live now, a status probe perhaps: observed live, so the next
861
+ # snapshot, one more before the scheduler ends included,
862
+ # reconciles it under L2 and tries again (D14.2 step 2).
863
+ self._observed_live.add(number)
864
+ continue
865
+ except (LockError, StateError, OSError) as exc:
866
+ self.say(f"issue #{number}: the run lock cannot be taken ({exc})")
867
+ continue
868
+ try:
869
+ closed = close_unready(
870
+ namespace,
871
+ lock,
872
+ self._resumable_open[number],
873
+ detail=bounded_detail(f"not ready at invocation {self.invocation_id}: {why}"),
874
+ expected=taken.expected,
875
+ )
876
+ except (StateError, OSError) as exc:
877
+ self.say(f"issue #{number}: its open claim is not closed ({exc})")
878
+ continue
879
+ finally:
880
+ lock.release()
881
+ del self._resumable_open[number]
882
+ if verdict is None:
883
+ self._held[number] = LocalFact.HELD_ORPHANED
884
+ self.say(
885
+ f"issue #{number}: the earlier invocation's claim is closed "
886
+ f"{closed.classification.value}: {why}"
887
+ )
888
+
889
+ # -- admission ------------------------------------------------------------------------
890
+
891
+ def _admit_ready(self) -> None:
892
+ taken = self._snapshot
893
+ if (
894
+ taken is None
895
+ or self._force_snapshot
896
+ or self.inputs.clock() - taken.taken_at >= self.poll_interval
897
+ ):
898
+ taken = self._take_snapshot()
899
+ if taken is None:
900
+ return
901
+ elif taken.stale:
902
+ return
903
+ for verdict in ready_set(evaluate(taken.snapshot, self.local_facts())):
904
+ if self._free_slots() <= 0 or self.admission.status is not AdmissionStatus.OPEN:
905
+ return
906
+ outcome = self._admit(taken, verdict)
907
+ if outcome is _Admit.STALE:
908
+ # No claim; the next tick takes a new snapshot (D2.2 step 4). A
909
+ # graph that keeps changing waits for the poll interval instead,
910
+ # and its stale snapshot is not re-read every tick meanwhile.
911
+ self._stale_streak += 1
912
+ if self._stale_streak <= MAX_STALE_RESNAPSHOTS:
913
+ self._force_snapshot = True
914
+ else:
915
+ taken.stale = True
916
+ return
917
+ if outcome is _Admit.ADMITTED:
918
+ self._stale_streak = 0
919
+
920
+ def _worker_log(self, namespace: IssueNamespace, generation: int) -> Path:
921
+ return namespace.workers_dir / f"{generation}.log"
922
+
923
+ def _worktree_path(self, number: int) -> str:
924
+ if self._path_engine is None:
925
+ # Path derivation only (ADR 0008 D5.4): this engine runs nothing.
926
+ self._path_engine = ControllerEngine(config=self.config, workdir=self.repository_root)
927
+ return str(self._path_engine.issue_worktree_path(number))
928
+
929
+ def _admit(self, taken: _Snapshot, verdict: ChildVerdict) -> _Admit:
930
+ """D2.2 steps 2 to 6 for one ready issue."""
931
+ number = verdict.number
932
+ namespace = issue_namespace(self.coordination_root, number)
933
+ lock = IssueRunLock(namespace)
934
+ try:
935
+ lock.acquire()
936
+ except IssueBusy:
937
+ self._observed_live.add(number)
938
+ self.say(f"issue #{number}: its run lock is held; it is skipped for now")
939
+ return _Admit.SKIPPED
940
+ except (LockError, StateError, OSError) as exc:
941
+ self._held[number] = LocalFact.HELD_UNCERTAIN
942
+ self.say(f"issue #{number}: the run lock cannot be taken ({exc})")
943
+ return _Admit.SKIPPED
944
+ try:
945
+ return self._admit_holding(taken, verdict, namespace, lock)
946
+ except (StateError, OSError) as exc:
947
+ # A binding or claim write refused: nothing was spawned for it.
948
+ self._held[number] = LocalFact.HELD_UNCERTAIN
949
+ self.say(f"issue #{number} is not admitted: {exc}")
950
+ return _Admit.SKIPPED
951
+ finally:
952
+ lock.release()
953
+
954
+ def _admit_holding(
955
+ self,
956
+ taken: _Snapshot,
957
+ verdict: ChildVerdict,
958
+ namespace: IssueNamespace,
959
+ lock: IssueRunLock,
960
+ ) -> _Admit:
961
+ number = verdict.number
962
+ assert self.epic_ref is not None
963
+ read = read_namespace(namespace)
964
+ supersedes: Claim | None = None
965
+ bound = False
966
+ if read.absent:
967
+ kind, state_ref = ClaimKind.START, StateRef.absent()
968
+ else:
969
+ lookup = read.lookup
970
+ if (
971
+ isinstance(lookup, NamespaceLookup)
972
+ and lookup.binding is not None
973
+ and lookup.binding.epic != self.epic_ref
974
+ ):
975
+ self._held[number] = LocalFact.HELD_OTHER_EPIC
976
+ self.say(f"issue #{number} is bound under EPIC #{lookup.binding.epic.number}")
977
+ return _Admit.SKIPPED
978
+ classification = classify(lookup, read.claim, expected=taken.expected)
979
+ if classification.kind is ClassificationKind.UNCERTAIN:
980
+ self._held[number] = LocalFact.HELD_UNCERTAIN
981
+ self.say(f"issue #{number} is {classification}: {classification.detail}")
982
+ return _Admit.SKIPPED
983
+ if classification.kind is ClassificationKind.SETTLED:
984
+ self._held[number] = LocalFact.HELD_SETTLED_NAMESPACE
985
+ if classification.open_claim is not None:
986
+ close_claim(
987
+ namespace,
988
+ lock,
989
+ classification.open_claim,
990
+ ClaimClassification.SETTLED,
991
+ settlement=classification.settlement,
992
+ detail=f"closed at admission: {classification.detail}",
993
+ )
994
+ return _Admit.SKIPPED
995
+ assert isinstance(lookup, NamespaceLookup)
996
+ bound = lookup.kind is NamespaceKind.BOUND
997
+ supersedes = classification.open_claim
998
+ if classification.kind is ClassificationKind.FRESH:
999
+ kind, state_ref = ClaimKind.START, StateRef.absent()
1000
+ else:
1001
+ kind, state_ref = ClaimKind.RESUME, StateRef.from_lookup(lookup)
1002
+ try:
1003
+ reread = reread_node(self.inputs.graph, taken.snapshot, number)
1004
+ except GitHubError as exc:
1005
+ self.say(f"issue #{number}: the admission re-read failed ({exc}); re-snapshotting")
1006
+ return _Admit.STALE
1007
+ if not reread.unchanged:
1008
+ listed = "; ".join(reread.differences)
1009
+ self.say(f"issue #{number} changed since the snapshot ({listed}); no claim")
1010
+ return _Admit.STALE
1011
+ if not bound:
1012
+ owner, _, name = self.repository.partition("/")
1013
+ write_binding(
1014
+ namespace,
1015
+ RunBinding(
1016
+ run_key=RunKey(self.repository_id, number),
1017
+ issue_id=verdict.issue_id,
1018
+ repository_owner=owner,
1019
+ repository_name=name,
1020
+ epic=self.epic_ref,
1021
+ worktree_path=self._worktree_path(number),
1022
+ ),
1023
+ )
1024
+ claim = open_claim(
1025
+ namespace,
1026
+ lock,
1027
+ kind=kind,
1028
+ invocation_id=self.invocation_id,
1029
+ admission=Admission(
1030
+ taken.snapshot.digest, reread.node_digest, taken.snapshot.observed_at
1031
+ ),
1032
+ state_ref=state_ref,
1033
+ supersedes=supersedes,
1034
+ expected=taken.expected,
1035
+ )
1036
+ self._resumable_open.pop(number, None)
1037
+ self._spawn(namespace, lock, claim)
1038
+ return _Admit.ADMITTED
1039
+
1040
+ def _spawn(self, namespace: IssueNamespace, lock: IssueRunLock, claim: Claim) -> None:
1041
+ """D2.2 step 6 and D2.3: the worker started with L0 and L2, or the claim closed."""
1042
+ number = namespace.issue_number
1043
+ log_path = self._worker_log(namespace, claim.generation)
1044
+ arguments = WorkerArguments(
1045
+ repository=self.repository_root,
1046
+ config=self.inputs.config_path,
1047
+ epic=self.epic.number,
1048
+ issue=number,
1049
+ claim_id=claim.claim_id,
1050
+ generation=claim.generation,
1051
+ invocation_id=self.invocation_id,
1052
+ l0_fd=self._l0_fd,
1053
+ l2_fd=lock.handoff(),
1054
+ accept_config_change=(
1055
+ self.inputs.accept_config_change and claim.kind is ClaimKind.RESUME
1056
+ ),
1057
+ )
1058
+ argv = [*self.inputs.worker_command, "epic", "_worker", *arguments.argv()]
1059
+ try:
1060
+ with SafeRoot.open(self.coordination_root) as anchor:
1061
+ log = anchor.open_append(
1062
+ "/".join((*namespace.relative, WORKERS_DIRNAME, log_path.name)),
1063
+ limit=MAX_WORKER_OUTPUT_BYTES,
1064
+ )
1065
+ except (StateError, OSError) as exc:
1066
+ # No process exists, and this scheduler holds L2: the claim is closed now.
1067
+ self._abandon(namespace, lock, claim, f"{WORKER_LOG_UNOPENED} ({exc})")
1068
+ return
1069
+ try:
1070
+ try:
1071
+ child = self.inputs.spawn(
1072
+ argv,
1073
+ pass_fds=(self._l0_fd, arguments.l2_fd),
1074
+ stdout_fd=log.fd,
1075
+ cwd=self.repository_root,
1076
+ env=dict(os.environ if self.inputs.env is None else self.inputs.env),
1077
+ )
1078
+ except (ExecutionError, OSError, subprocess.SubprocessError) as exc:
1079
+ self._spawn_failed(namespace, lock, claim, exc)
1080
+ return
1081
+ finally:
1082
+ log.close()
1083
+ self.children.append(child)
1084
+ self._live[number] = _Worker(number, namespace, claim, child)
1085
+ try:
1086
+ self._live[number].claim = record_worker(
1087
+ namespace,
1088
+ claim,
1089
+ WorkerDiagnostics(
1090
+ child.pid, process_start_time(child.pid), utcnow_iso(), str(log_path)
1091
+ ),
1092
+ )
1093
+ except (AutoForgeError, OSError) as exc:
1094
+ # A worker exists: its claim stays open and is finalized after the reap (D2.3).
1095
+ self.say(f"issue #{number}: the worker's diagnostics were not recorded ({exc})")
1096
+ self.say(
1097
+ f"admitted issue #{number} ({claim.kind.value}, generation {claim.generation}): "
1098
+ f"worker pid {child.pid}, log {log_path}"
1099
+ )
1100
+
1101
+ def _abandon(
1102
+ self, namespace: IssueNamespace, lock: IssueRunLock, claim: Claim, why: str
1103
+ ) -> None:
1104
+ number = namespace.issue_number
1105
+ try:
1106
+ close_claim(namespace, lock, claim, ClaimClassification.ABANDONED, detail=why)
1107
+ except (StateError, OSError) as exc:
1108
+ self._held[number] = LocalFact.HELD_UNCERTAIN
1109
+ self.say(f"issue #{number}: {why}, and its claim is not closed ({exc})")
1110
+ return
1111
+ self._held[number] = LocalFact.HELD_SPAWN_FAILED
1112
+ self.say(f"issue #{number}: {why}; its claim is closed abandoned")
1113
+
1114
+ def _spawn_failed(
1115
+ self, namespace: IssueNamespace, lock: IssueRunLock, claim: Claim, exc: BaseException
1116
+ ) -> None:
1117
+ """D2.3: our L2 copy closed, then L2 probed on a fresh descriptor."""
1118
+ number = namespace.issue_number
1119
+ lock.release()
1120
+ probe = IssueRunLock(namespace)
1121
+ try:
1122
+ probe.acquire()
1123
+ except IssueBusy:
1124
+ # A child exists after all: it holds L2, so the claim stays open.
1125
+ self._held[number] = LocalFact.OWNED
1126
+ self.say(
1127
+ f"issue #{number}: the worker start failed ({exc}), and its run lock is still "
1128
+ "held: a child exists, so the claim stays open"
1129
+ )
1130
+ return
1131
+ except (LockError, StateError, OSError) as probe_exc:
1132
+ self._held[number] = LocalFact.HELD_UNCERTAIN
1133
+ self.say(f"issue #{number}: the worker start failed ({exc}); {probe_exc}")
1134
+ return
1135
+ try:
1136
+ self._abandon(namespace, probe, claim, f"{WORKER_NOT_STARTED} ({exc})")
1137
+ finally:
1138
+ probe.release()
1139
+
1140
+
1141
+ def run_scheduler(inputs: SchedulerInputs) -> int:
1142
+ """Run one scheduler invocation (:class:`EpicScheduler`); its exit status."""
1143
+ return EpicScheduler(inputs).run()