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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"""The scheduler's stop request: ``epics/<n>/stop-request`` (ADR 0008 D16; #182).
|
|
2
|
+
|
|
3
|
+
``autoforge.scheduler-stop/1`` holds ``invocation_id`` (the scheduler
|
|
4
|
+
invocation the request is for), ``mode`` (``drain``: admit nothing more and
|
|
5
|
+
let every worker return at its next step boundary; ``now``: as a Ctrl-C of
|
|
6
|
+
the scheduler) and ``requested_at``. It lives beside ``scheduler.json`` in
|
|
7
|
+
``<common>/autoforge/scheduler/epics/<n>/``.
|
|
8
|
+
|
|
9
|
+
It is the one scheduler file with a second writer that holds no lock, so
|
|
10
|
+
the protocol is publication by ``link(2)``:
|
|
11
|
+
|
|
12
|
+
* :func:`request_stop` (the library function behind ``epic stop``, #183)
|
|
13
|
+
refuses when ``scheduler.json`` is absent and then creates no directory.
|
|
14
|
+
It reads the invocation id from ``scheduler.json``, writes and fsyncs a
|
|
15
|
+
uniquely named temporary in the same directory, links it to
|
|
16
|
+
``stop-request`` and unlinks the temporary. The file is never replaced,
|
|
17
|
+
so no reader ever sees a partial request; when one exists (``EEXIST``) it
|
|
18
|
+
reports the existing request and writes nothing. After the link it reads
|
|
19
|
+
``scheduler.json`` again and reports "scheduler changed; stop not
|
|
20
|
+
delivered" when the invocation id is another one.
|
|
21
|
+
* Every reader (:func:`read_stop_request`) is lock-free and strict, and
|
|
22
|
+
treats a request that does not parse as a drain, never as a crash; a
|
|
23
|
+
leftover temporary is not a request.
|
|
24
|
+
* Only the L0 holder removes anything (:func:`remove_stale_stop_requests`):
|
|
25
|
+
a request bound to another invocation at its start and on every tick, a
|
|
26
|
+
request that does not parse and every leftover temporary at its next
|
|
27
|
+
start.
|
|
28
|
+
|
|
29
|
+
Nothing here signals a process: the scheduler reads the request and acts.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import errno
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import secrets
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from datetime import datetime
|
|
41
|
+
from enum import Enum
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import NoReturn
|
|
44
|
+
|
|
45
|
+
from .errors import SchedulerStateError, StateError
|
|
46
|
+
from .locking import assert_shared_git_lock_free
|
|
47
|
+
from .run_namespace import AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME
|
|
48
|
+
from .safefs import ReadLimitExceeded, SafeRoot, entry_kind
|
|
49
|
+
from .scheduler_state import EPICS_DIRNAME, load_scheduler_state
|
|
50
|
+
from .state import utcnow_iso
|
|
51
|
+
|
|
52
|
+
STOP_SCHEMA = "autoforge.scheduler-stop/1"
|
|
53
|
+
STOP_FILENAME = "stop-request"
|
|
54
|
+
# A request is a few hundred bytes; anything past this is not one.
|
|
55
|
+
MAX_STOP_BYTES = 4096
|
|
56
|
+
MAX_TIMESTAMP_CHARS = 32
|
|
57
|
+
# A refusal quotes at most this many characters of what it read.
|
|
58
|
+
MAX_PROBLEM_CHARS = 500
|
|
59
|
+
|
|
60
|
+
_TMP_PREFIX = ".stop-request."
|
|
61
|
+
_TMP_SUFFIX = ".tmp"
|
|
62
|
+
_TMP_RE = re.compile(r"\.stop-request\.[0-9a-f]{16}\.tmp")
|
|
63
|
+
_HEX32 = re.compile(r"[0-9a-f]{32}")
|
|
64
|
+
_KEYS = ("schema", "invocation_id", "mode", "requested_at")
|
|
65
|
+
_TMP_FLAGS = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
66
|
+
|
|
67
|
+
SCHEDULER_CHANGED = "scheduler changed; stop not delivered"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StopMode(Enum):
|
|
71
|
+
DRAIN = "drain"
|
|
72
|
+
NOW = "now"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _fail(message: str) -> NoReturn:
|
|
76
|
+
raise SchedulerStateError(f"stop request {message}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class StopRequest:
|
|
81
|
+
"""One parsed ``stop-request``; validated on construction."""
|
|
82
|
+
|
|
83
|
+
invocation_id: str
|
|
84
|
+
mode: StopMode
|
|
85
|
+
requested_at: str
|
|
86
|
+
|
|
87
|
+
def __post_init__(self) -> None:
|
|
88
|
+
if not isinstance(self.invocation_id, str) or not _HEX32.fullmatch(self.invocation_id):
|
|
89
|
+
_fail(f"invocation_id is not an invocation id: {self.invocation_id!r:.80}")
|
|
90
|
+
if not isinstance(self.mode, StopMode):
|
|
91
|
+
_fail(f"mode must be a StopMode, got {type(self.mode).__name__}")
|
|
92
|
+
text = self.requested_at
|
|
93
|
+
if not isinstance(text, str) or len(text) > MAX_TIMESTAMP_CHARS:
|
|
94
|
+
_fail(f"requested_at is not a timestamp: {text!r:.80}")
|
|
95
|
+
try:
|
|
96
|
+
parsed = datetime.fromisoformat(text)
|
|
97
|
+
except ValueError:
|
|
98
|
+
_fail(f"requested_at is not a timestamp: {text!r:.80}")
|
|
99
|
+
if parsed.tzinfo is None:
|
|
100
|
+
_fail(f"requested_at has no time zone: {text!r:.80}")
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict:
|
|
103
|
+
return {
|
|
104
|
+
"schema": STOP_SCHEMA,
|
|
105
|
+
"invocation_id": self.invocation_id,
|
|
106
|
+
"mode": self.mode.value,
|
|
107
|
+
"requested_at": self.requested_at,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_dict(cls, raw: object) -> StopRequest:
|
|
112
|
+
"""Strictly parse a request: an unknown schema or field is refused, never skipped."""
|
|
113
|
+
if not isinstance(raw, dict):
|
|
114
|
+
_fail(f"must be a JSON object, got {type(raw).__name__}")
|
|
115
|
+
if raw.get("schema") != STOP_SCHEMA:
|
|
116
|
+
_fail(f"has schema {raw.get('schema')!r:.80}; this controller reads {STOP_SCHEMA}")
|
|
117
|
+
if sorted(raw) != sorted(_KEYS):
|
|
118
|
+
unknown = sorted(str(key)[:80] for key in raw if key not in _KEYS)[:5]
|
|
119
|
+
missing = [key for key in _KEYS if key not in raw]
|
|
120
|
+
_fail(f"has the fields {unknown} it may not have and lacks {missing}")
|
|
121
|
+
mode = raw["mode"]
|
|
122
|
+
# A string first: ``[]`` or ``{}`` is unhashable, and set membership would raise.
|
|
123
|
+
if not isinstance(mode, str) or mode not in {m.value for m in StopMode}:
|
|
124
|
+
_fail(f"mode is not drain or now: {mode!r:.80}")
|
|
125
|
+
return cls(raw["invocation_id"], StopMode(mode), raw["requested_at"])
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class StopRead:
|
|
130
|
+
"""What a reader found at ``stop-request``.
|
|
131
|
+
|
|
132
|
+
``present`` is whether an entry of that name exists; ``request`` is set
|
|
133
|
+
when it parsed and ``problem`` (bounded) when it did not. A request that
|
|
134
|
+
does not parse is read as a drain for every invocation (D16).
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
present: bool
|
|
138
|
+
request: StopRequest | None = None
|
|
139
|
+
problem: str = ""
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def mode(self) -> StopMode | None:
|
|
143
|
+
if self.request is not None:
|
|
144
|
+
return self.request.mode
|
|
145
|
+
return StopMode.DRAIN if self.present else None
|
|
146
|
+
|
|
147
|
+
def applies_to(self, invocation_id: str) -> bool:
|
|
148
|
+
"""Whether invocation ``invocation_id`` stops on it: its own, or one that does not parse."""
|
|
149
|
+
if not self.present:
|
|
150
|
+
return False
|
|
151
|
+
return self.request is None or self.request.invocation_id == invocation_id
|
|
152
|
+
|
|
153
|
+
def bound_elsewhere(self, invocation_id: str) -> bool:
|
|
154
|
+
"""A request that parsed and names another invocation than ``invocation_id``."""
|
|
155
|
+
return self.request is not None and self.request.invocation_id != invocation_id
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _relative(epic_number: int) -> str:
|
|
159
|
+
if isinstance(epic_number, bool) or not isinstance(epic_number, int) or epic_number < 1:
|
|
160
|
+
raise SchedulerStateError(f"epic number must be a positive integer, got {epic_number!r}")
|
|
161
|
+
return "/".join((AUTOFORGE_DIRNAME, SCHEDULER_DIRNAME, EPICS_DIRNAME, str(epic_number)))
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def stop_request_path(coordination_root: str | Path, epic_number: int) -> Path:
|
|
165
|
+
"""``<common>/autoforge/scheduler/epics/<n>/stop-request``."""
|
|
166
|
+
return Path(coordination_root).joinpath(*_relative(epic_number).split("/"), STOP_FILENAME)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _epic_root(coordination_root: str | Path, epic_number: int) -> SafeRoot | None:
|
|
170
|
+
"""The EPIC's directory, never created; ``None`` when it (or a parent) does not exist."""
|
|
171
|
+
try:
|
|
172
|
+
anchor = SafeRoot.open(coordination_root)
|
|
173
|
+
except FileNotFoundError:
|
|
174
|
+
raise SchedulerStateError(f"coordination root {coordination_root} does not exist") from None
|
|
175
|
+
with anchor:
|
|
176
|
+
try:
|
|
177
|
+
return anchor.subroot(_relative(epic_number))
|
|
178
|
+
except FileNotFoundError:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _bounded(text: str) -> str:
|
|
183
|
+
return text if len(text) <= MAX_PROBLEM_CHARS else text[: MAX_PROBLEM_CHARS - 3] + "..."
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _unique(pairs: list[tuple[str, object]]) -> dict:
|
|
187
|
+
data: dict = {}
|
|
188
|
+
for key, value in pairs:
|
|
189
|
+
if key in data:
|
|
190
|
+
raise SchedulerStateError(f"has the field {key!r:.80} more than once")
|
|
191
|
+
data[key] = value
|
|
192
|
+
return data
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _no_constant(name: str) -> NoReturn:
|
|
196
|
+
raise SchedulerStateError(f"holds the non-JSON number {name}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_at(root: SafeRoot, where: str) -> StopRead:
|
|
200
|
+
st = root.lstat(STOP_FILENAME)
|
|
201
|
+
if st is None:
|
|
202
|
+
return StopRead(False)
|
|
203
|
+
kind = entry_kind(st.st_mode)
|
|
204
|
+
if kind is not None:
|
|
205
|
+
return StopRead(True, problem=f"{where} is a {kind}, not a regular file")
|
|
206
|
+
try:
|
|
207
|
+
raw = root.read_bytes(STOP_FILENAME, limit=MAX_STOP_BYTES)
|
|
208
|
+
except ReadLimitExceeded:
|
|
209
|
+
return StopRead(True, problem=f"{where} is larger than {MAX_STOP_BYTES} bytes")
|
|
210
|
+
except (StateError, OSError) as exc:
|
|
211
|
+
return StopRead(True, problem=_bounded(f"{where} cannot be read ({exc})"))
|
|
212
|
+
if raw is None:
|
|
213
|
+
return StopRead(False) # removed between the lstat and the open
|
|
214
|
+
try:
|
|
215
|
+
document = json.loads(
|
|
216
|
+
raw.decode("utf-8"), object_pairs_hook=_unique, parse_constant=_no_constant
|
|
217
|
+
)
|
|
218
|
+
return StopRead(True, StopRequest.from_dict(document))
|
|
219
|
+
except (SchedulerStateError, UnicodeDecodeError, ValueError, TypeError, RecursionError) as exc:
|
|
220
|
+
return StopRead(True, problem=_bounded(f"{where} does not parse ({exc})"))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def read_stop_request(coordination_root: str | Path, epic_number: int) -> StopRead:
|
|
224
|
+
"""EPIC ``epic_number``'s stop request, strictly and lock-free; read-only.
|
|
225
|
+
|
|
226
|
+
Never raises for what it finds: a request that does not parse (a link,
|
|
227
|
+
a FIFO, another schema, bad JSON) is ``present`` with a ``problem`` and
|
|
228
|
+
reads as a drain. No directory is created. A link at a directory above
|
|
229
|
+
the file raises :class:`~autoforge.safefs.UnsafePathError` (a
|
|
230
|
+
:class:`~autoforge.errors.StateError`), for the caller to fail closed.
|
|
231
|
+
"""
|
|
232
|
+
where = str(stop_request_path(coordination_root, epic_number))
|
|
233
|
+
root = _epic_root(coordination_root, epic_number)
|
|
234
|
+
if root is None:
|
|
235
|
+
return StopRead(False)
|
|
236
|
+
with root:
|
|
237
|
+
return _read_at(root, where)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@dataclass(frozen=True)
|
|
241
|
+
class StopDelivery:
|
|
242
|
+
"""What :func:`request_stop` did.
|
|
243
|
+
|
|
244
|
+
``written`` is whether this call published a request. ``mode`` is the
|
|
245
|
+
request's mode, or the existing request's when one was there.
|
|
246
|
+
``scheduler_changed`` is the after-write check: ``scheduler.json`` names
|
|
247
|
+
another invocation than the request, so no live scheduler of the
|
|
248
|
+
request's invocation reads it. ``scheduler_ended`` is whether the
|
|
249
|
+
invocation the request is for had already ended: running workers still
|
|
250
|
+
drain, and ``now`` cannot signal them. ``message`` says it in a line.
|
|
251
|
+
"""
|
|
252
|
+
|
|
253
|
+
written: bool
|
|
254
|
+
mode: StopMode
|
|
255
|
+
invocation_id: str
|
|
256
|
+
message: str
|
|
257
|
+
existing: StopRead | None = None
|
|
258
|
+
scheduler_changed: bool = False
|
|
259
|
+
scheduler_ended: bool = False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _write_temporary(root: SafeRoot, payload: bytes) -> str:
|
|
263
|
+
for _ in range(8):
|
|
264
|
+
name = f"{_TMP_PREFIX}{secrets.token_hex(8)}{_TMP_SUFFIX}"
|
|
265
|
+
try:
|
|
266
|
+
fd = os.open(name, _TMP_FLAGS, 0o600, dir_fd=root.fd)
|
|
267
|
+
except FileExistsError:
|
|
268
|
+
continue
|
|
269
|
+
try:
|
|
270
|
+
view = memoryview(payload)
|
|
271
|
+
while view:
|
|
272
|
+
view = view[os.write(fd, view) :]
|
|
273
|
+
os.fsync(fd)
|
|
274
|
+
except BaseException:
|
|
275
|
+
os.close(fd)
|
|
276
|
+
_unlink_quietly(root, name)
|
|
277
|
+
raise
|
|
278
|
+
os.close(fd)
|
|
279
|
+
return name
|
|
280
|
+
raise SchedulerStateError( # pragma: no cover - 64 random bits, eight times
|
|
281
|
+
"cannot name a temporary for the stop request"
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _unlink_quietly(root: SafeRoot, name: str) -> None:
|
|
286
|
+
try:
|
|
287
|
+
os.unlink(name, dir_fd=root.fd)
|
|
288
|
+
except OSError:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _fsync_dir(root: SafeRoot) -> None:
|
|
293
|
+
try:
|
|
294
|
+
os.fsync(root.fd)
|
|
295
|
+
except OSError as exc: # pragma: no cover - fsync of a directory descriptor
|
|
296
|
+
if exc.errno not in (errno.EINVAL, errno.EBADF):
|
|
297
|
+
raise
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def request_stop(coordination_root: str | Path, epic_number: int, mode: StopMode) -> StopDelivery:
|
|
301
|
+
"""Publish a stop request for EPIC ``epic_number``'s scheduler (D16; see the module).
|
|
302
|
+
|
|
303
|
+
Refused (:class:`~autoforge.errors.SchedulerStateError`) when there is
|
|
304
|
+
no ``scheduler.json`` (nothing is created then, no directory either) or
|
|
305
|
+
when it does not load. Otherwise the request is bound to
|
|
306
|
+
``scheduler.json``'s invocation id and linked into place, or, when a
|
|
307
|
+
request is already there, nothing is written and the existing one is
|
|
308
|
+
reported.
|
|
309
|
+
"""
|
|
310
|
+
if not isinstance(mode, StopMode):
|
|
311
|
+
raise SchedulerStateError(f"stop mode must be drain or now, got {mode!r}")
|
|
312
|
+
where = str(stop_request_path(coordination_root, epic_number))
|
|
313
|
+
state = load_scheduler_state(coordination_root, epic_number)
|
|
314
|
+
if state is None:
|
|
315
|
+
raise SchedulerStateError(
|
|
316
|
+
f"no scheduler has run for EPIC #{epic_number} in this repository (no "
|
|
317
|
+
"scheduler.json); there is nothing to stop, and nothing was written"
|
|
318
|
+
)
|
|
319
|
+
invocation = state.invocation
|
|
320
|
+
ended = invocation.ended_at is not None
|
|
321
|
+
request = StopRequest(invocation.invocation_id, mode, utcnow_iso())
|
|
322
|
+
payload = (json.dumps(request.to_dict(), indent=2, sort_keys=True) + "\n").encode("utf-8")
|
|
323
|
+
assert_shared_git_lock_free(f"write {where}")
|
|
324
|
+
root = _epic_root(coordination_root, epic_number)
|
|
325
|
+
if root is None: # pragma: no cover - scheduler.json was just read there
|
|
326
|
+
raise SchedulerStateError(f"the directory of {where} is gone; nothing was written")
|
|
327
|
+
with root:
|
|
328
|
+
name = _write_temporary(root, payload)
|
|
329
|
+
try:
|
|
330
|
+
os.link(
|
|
331
|
+
name,
|
|
332
|
+
STOP_FILENAME,
|
|
333
|
+
src_dir_fd=root.fd,
|
|
334
|
+
dst_dir_fd=root.fd,
|
|
335
|
+
follow_symlinks=False,
|
|
336
|
+
)
|
|
337
|
+
except FileExistsError:
|
|
338
|
+
existing = _read_at(root, where)
|
|
339
|
+
mode_now = existing.mode or StopMode.DRAIN
|
|
340
|
+
if existing.bound_elsewhere(invocation.invocation_id):
|
|
341
|
+
message = (
|
|
342
|
+
f"a stop request of an earlier scheduler invocation is still at {where}; "
|
|
343
|
+
"the scheduler removes it at its next tick: repeat the stop then. "
|
|
344
|
+
"Nothing was written."
|
|
345
|
+
)
|
|
346
|
+
elif existing.request is None:
|
|
347
|
+
message = (
|
|
348
|
+
f"a stop request that does not parse is at {where}; every reader takes "
|
|
349
|
+
"it as a drain. Nothing was written."
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
message = (
|
|
353
|
+
f"a {mode_now.value} stop request is already there ({where}); nothing "
|
|
354
|
+
"was written"
|
|
355
|
+
)
|
|
356
|
+
return StopDelivery(
|
|
357
|
+
False,
|
|
358
|
+
mode_now,
|
|
359
|
+
invocation.invocation_id,
|
|
360
|
+
message,
|
|
361
|
+
existing=existing,
|
|
362
|
+
scheduler_ended=ended,
|
|
363
|
+
)
|
|
364
|
+
except OSError as exc:
|
|
365
|
+
raise SchedulerStateError(f"cannot publish {where}: {exc}") from exc
|
|
366
|
+
finally:
|
|
367
|
+
_unlink_quietly(root, name)
|
|
368
|
+
_fsync_dir(root)
|
|
369
|
+
after = load_scheduler_state(coordination_root, epic_number)
|
|
370
|
+
if after is None or after.invocation.invocation_id != invocation.invocation_id:
|
|
371
|
+
return StopDelivery(
|
|
372
|
+
True,
|
|
373
|
+
mode,
|
|
374
|
+
invocation.invocation_id,
|
|
375
|
+
SCHEDULER_CHANGED,
|
|
376
|
+
scheduler_changed=True,
|
|
377
|
+
)
|
|
378
|
+
if ended:
|
|
379
|
+
message = (
|
|
380
|
+
f"{mode.value} stop requested for scheduler invocation "
|
|
381
|
+
f"{invocation.invocation_id}, which has ended: workers still running drain at "
|
|
382
|
+
"their next step boundary"
|
|
383
|
+
+ ("; 'now' cannot signal them" if mode is StopMode.NOW else "")
|
|
384
|
+
)
|
|
385
|
+
else:
|
|
386
|
+
message = f"{mode.value} stop requested for scheduler invocation {invocation.invocation_id}"
|
|
387
|
+
return StopDelivery(True, mode, invocation.invocation_id, message, scheduler_ended=ended)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def remove_stale_stop_requests(
|
|
391
|
+
coordination_root: str | Path, epic_number: int, invocation_id: str, *, at_start: bool
|
|
392
|
+
) -> tuple[str, ...]:
|
|
393
|
+
"""The L0 holder's cleanup of ``stop-request`` (D16); the names it removed.
|
|
394
|
+
|
|
395
|
+
A request bound to another invocation than ``invocation_id`` is removed
|
|
396
|
+
always; one that does not parse, and every leftover temporary of an
|
|
397
|
+
interrupted :func:`request_stop`, only ``at_start``. Called by the
|
|
398
|
+
scheduler only, under L0: no other process removes anything here.
|
|
399
|
+
"""
|
|
400
|
+
where = str(stop_request_path(coordination_root, epic_number))
|
|
401
|
+
root = _epic_root(coordination_root, epic_number)
|
|
402
|
+
if root is None:
|
|
403
|
+
return ()
|
|
404
|
+
removed: list[str] = []
|
|
405
|
+
with root:
|
|
406
|
+
found = _read_at(root, where)
|
|
407
|
+
if found.present and (
|
|
408
|
+
found.bound_elsewhere(invocation_id) or (at_start and found.request is None)
|
|
409
|
+
):
|
|
410
|
+
root.unlink(STOP_FILENAME)
|
|
411
|
+
removed.append(STOP_FILENAME)
|
|
412
|
+
if at_start:
|
|
413
|
+
try:
|
|
414
|
+
names = os.listdir(root.fd)
|
|
415
|
+
except OSError as exc:
|
|
416
|
+
raise SchedulerStateError(f"cannot list the directory of {where}: {exc}") from exc
|
|
417
|
+
for name in sorted(names):
|
|
418
|
+
if _TMP_RE.fullmatch(name):
|
|
419
|
+
_unlink_quietly(root, name)
|
|
420
|
+
removed.append(name)
|
|
421
|
+
return tuple(removed)
|