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/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()
|