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/locking.py
ADDED
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
"""Repository-level locking: the three lock domains of ADR 0008 (D10-D12).
|
|
2
|
+
|
|
3
|
+
**L0, orchestration** (:class:`ControllerLock`): at most one orchestration
|
|
4
|
+
authority per repository. A standalone mutating command takes it for its
|
|
5
|
+
whole lifetime; a parallel EPIC scheduler takes it and shares it with the
|
|
6
|
+
workers it launches (below).
|
|
7
|
+
|
|
8
|
+
It is POSIX flock(2) on the git common directory descriptor
|
|
9
|
+
(LOCK_EX | LOCK_NB). A second instance fails fast with LockError instead of
|
|
10
|
+
operating concurrently on the same repository. ``autoforge/controller.lock``
|
|
11
|
+
remains as a PID record, but is not the lock inode: that keeps a replacement of
|
|
12
|
+
the ``autoforge`` directory from creating a second lock domain.
|
|
13
|
+
|
|
14
|
+
The lock is keyed by the *repository identity*, not by a caller-selectable
|
|
15
|
+
path: :func:`repository_lock_path` asks ``git rev-parse --git-common-dir``
|
|
16
|
+
for the git directory shared by every working tree of the repository that
|
|
17
|
+
contains the controller's working directory. The main checkout, a linked
|
|
18
|
+
``git worktree``, any subdirectory, and any ``--state-dir`` therefore all
|
|
19
|
+
resolve to the same lock file, so two controllers cannot pick independent
|
|
20
|
+
locks for one repository by choosing different state directories or by
|
|
21
|
+
being started from different directories. A working directory outside a git
|
|
22
|
+
repository cannot be locked and is refused with LockError (nothing is
|
|
23
|
+
written). Two *independent clones* of one GitHub repository are two
|
|
24
|
+
repositories to this lock; they are not coordinated.
|
|
25
|
+
|
|
26
|
+
Below the git common dir no path component is ever followed through a
|
|
27
|
+
symbolic link. The git common dir (what git reported, already resolved) is
|
|
28
|
+
opened as a directory descriptor; the ``autoforge`` directory is created with
|
|
29
|
+
``mkdir`` (which never follows a symlink at that name) and then opened
|
|
30
|
+
*relative to that descriptor* with ``O_DIRECTORY | O_NOFOLLOW`` and
|
|
31
|
+
``fstat``-checked, so an ``autoforge`` entry that is a symbolic link or not a
|
|
32
|
+
directory is refused with LockError before ``controller.lock`` is touched;
|
|
33
|
+
``controller.lock`` in turn is opened relative to the validated directory
|
|
34
|
+
descriptor (``dir_fd``) with ``O_NOFOLLOW``, so a parent replacement cannot
|
|
35
|
+
redirect the PID record. The lock itself is taken on the already-open git
|
|
36
|
+
common directory descriptor, whose inode is shared by all such attempts. The
|
|
37
|
+
lock entry must be a regular
|
|
38
|
+
file with exactly one name (``st_nlink == 1``): a ``controller.lock`` that is
|
|
39
|
+
a symbolic link, FIFO, socket, device or directory, or a hard link sharing
|
|
40
|
+
its inode with another file, is refused with LockError before anything is
|
|
41
|
+
written, so a tampered or damaged entry can neither redirect the PID write
|
|
42
|
+
to an unrelated file nor turn the refusal into an unhandled traceback. Every
|
|
43
|
+
open / flock / write failure is a LockError and every descriptor is closed on
|
|
44
|
+
the way out.
|
|
45
|
+
|
|
46
|
+
Tampering model: these checks validate the entries *as found* (damaged or
|
|
47
|
+
tampered at rest). They are taken on open descriptors, but a writer with
|
|
48
|
+
write access to the git directory who races between the ``fstat`` and the
|
|
49
|
+
PID write (for example by adding a hard link in that window) can still alter
|
|
50
|
+
the record. The lock domain itself remains the common-directory inode, so
|
|
51
|
+
replacing ``autoforge`` cannot make two controllers acquire independent locks.
|
|
52
|
+
The git directory is assumed to be writable only by the operating user.
|
|
53
|
+
|
|
54
|
+
**Sharing L0 (D10.2, D10.3).** ``flock`` belongs to an open file
|
|
55
|
+
description, so a child that inherits the descriptor (``pass_fds``) holds
|
|
56
|
+
the same lock, and ``LOCK_UN`` on any copy would release it for every
|
|
57
|
+
holder at once. :meth:`ControllerLock.share` therefore switches the lock to
|
|
58
|
+
a *close-only* release before its descriptor is handed out, and
|
|
59
|
+
:meth:`ControllerLock.adopt` wraps an inherited descriptor only after
|
|
60
|
+
:func:`prove_held` proved it, always close-only and without touching the PID
|
|
61
|
+
record. The last ``close`` anywhere releases the lock, so surviving workers
|
|
62
|
+
keep the repository closed after their scheduler died. Only an inherited
|
|
63
|
+
descriptor proves a worker: no environment variable, PID file or claim.
|
|
64
|
+
|
|
65
|
+
**L2, per-issue run** (:class:`IssueRunLock`):
|
|
66
|
+
``<common>/autoforge/scheduler/issues/<n>/run.lock``, opened with the same
|
|
67
|
+
hardening (every directory ``O_NOFOLLOW``, the file ``O_NOFOLLOW |
|
|
68
|
+
O_CLOEXEC``, a regular file with one link, never truncated, written or
|
|
69
|
+
unlinked), taken ``LOCK_EX | LOCK_NB`` only (:class:`~autoforge.errors.IssueBusy`
|
|
70
|
+
when held, never a wait), handed to exactly one child and released by
|
|
71
|
+
``close`` only, everywhere. :func:`issue_run_lock_live` is the lock-free
|
|
72
|
+
probe that answers "has a live owner?" (D8.3).
|
|
73
|
+
|
|
74
|
+
**L1, shared Git** (:class:`SharedGitLock`): ``<common>/autoforge/git.lock``,
|
|
75
|
+
the same hardening, around every controller write to git's shared data in
|
|
76
|
+
the common dir (its object store and its worktree registry, D12.1). It is
|
|
77
|
+
the one blocking acquisition, a poll bounded by
|
|
78
|
+
:data:`SHARED_GIT_LOCK_WAIT_SECONDS` whose expiry is a transient
|
|
79
|
+
:class:`~autoforge.errors.GitLockTimeout` (D11.3), scoped to one git
|
|
80
|
+
operation, and released by ``close``.
|
|
81
|
+
|
|
82
|
+
**Order L0 -> L2 -> L1 (D11.2).** L0 and L2 are only ever taken
|
|
83
|
+
non-blocking, and the holder of L1 takes nothing, so no wait-for cycle can
|
|
84
|
+
form. Standalone commands take L0 then L1 and never L2. While this process
|
|
85
|
+
holds L1 it may acquire no lock, invoke no agent, call no GitHub and save no
|
|
86
|
+
state: :func:`assert_shared_git_lock_free` raises
|
|
87
|
+
:class:`~autoforge.errors.LockOrderError` otherwise.
|
|
88
|
+
|
|
89
|
+
**Filesystem (D11.4).** :func:`check_local_lock_filesystem` refuses NFS,
|
|
90
|
+
CIFS/SMB, 9p, AFS, cluster filesystems and every FUSE filesystem, whose
|
|
91
|
+
``flock`` is not a local kernel lock: by ``statfs``/``fstatfs`` magic number
|
|
92
|
+
on Linux, by ``f_fstypename`` on macOS, and on any other platform, where it
|
|
93
|
+
cannot tell, it refuses. Scheduler mode applies it to the coordination root
|
|
94
|
+
and to every ``run.lock`` and ``git.lock`` descriptor it opens, so a mount
|
|
95
|
+
below the root cannot evade it; standalone commands never do.
|
|
96
|
+
|
|
97
|
+
No lock here has a timeout, lease or heartbeat as liveness: the kernel
|
|
98
|
+
releases every one of them when its last descriptor closes, SIGKILL
|
|
99
|
+
included, and no stale-lock cleanup exists.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
from __future__ import annotations
|
|
103
|
+
|
|
104
|
+
import ctypes
|
|
105
|
+
import errno
|
|
106
|
+
import fcntl
|
|
107
|
+
import os
|
|
108
|
+
import platform
|
|
109
|
+
import stat
|
|
110
|
+
import sys
|
|
111
|
+
import time
|
|
112
|
+
from collections.abc import Callable
|
|
113
|
+
from dataclasses import dataclass
|
|
114
|
+
from functools import cache
|
|
115
|
+
from pathlib import Path
|
|
116
|
+
from types import TracebackType
|
|
117
|
+
from typing import TYPE_CHECKING
|
|
118
|
+
|
|
119
|
+
from .errors import (
|
|
120
|
+
ExecutionError,
|
|
121
|
+
GitLockTimeout,
|
|
122
|
+
IssueBusy,
|
|
123
|
+
LockError,
|
|
124
|
+
LockOrderError,
|
|
125
|
+
LockProofError,
|
|
126
|
+
NetworkFilesystemError,
|
|
127
|
+
StateError,
|
|
128
|
+
)
|
|
129
|
+
from .executor import ExecutionRequest, ExecutionResult, execute
|
|
130
|
+
from .git_transport import NETWORK_TIMEOUT_SECONDS, local_git_request
|
|
131
|
+
from .safefs import SafeRoot, entry_kind, open_regular_at, split_relpath
|
|
132
|
+
|
|
133
|
+
if TYPE_CHECKING:
|
|
134
|
+
# Typing only: run_namespace reaches the GitHub client through the state
|
|
135
|
+
# module, and the GitHub client checks the lock order (D11.2).
|
|
136
|
+
from .run_namespace import IssueNamespace
|
|
137
|
+
|
|
138
|
+
LOCK_DIRNAME = "autoforge" # inside the git common dir
|
|
139
|
+
LOCK_FILENAME = "controller.lock"
|
|
140
|
+
GIT_LOCK_FILENAME = "git.lock" # L1, beside controller.lock (ADR 0008 D5.3)
|
|
141
|
+
|
|
142
|
+
# D11.3: the longest timeout of any operation L1 guards, plus 60 s, so a
|
|
143
|
+
# waiter queued behind one operation that stays within its own bound never
|
|
144
|
+
# times out. The network fetch is the longest: the strict import's
|
|
145
|
+
# `index-pack` (agent_worktree.IMPORT_TIMEOUT_SECONDS) is as long, the
|
|
146
|
+
# worktree check-then-create is a few local git reads and one `worktree
|
|
147
|
+
# add` of 120 s each (tests/test_lock_domains.py keeps the three in step),
|
|
148
|
+
# and the sandbox directories' creation is a few `mkdir`s.
|
|
149
|
+
SHARED_GIT_LOCK_WAIT_SECONDS = NETWORK_TIMEOUT_SECONDS + 60
|
|
150
|
+
# How often a waiter retries its non-blocking attempt.
|
|
151
|
+
SHARED_GIT_LOCK_POLL_SECONDS = 0.05
|
|
152
|
+
|
|
153
|
+
_CONTENTION = (
|
|
154
|
+
"another AutoForge controller holds {path} — refusing to run concurrently on the "
|
|
155
|
+
"same repository (an AutoForge scheduler or its surviving workers may hold it)"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
_GIT_COMMON_DIR_ARGS = ["rev-parse", "--git-common-dir"]
|
|
159
|
+
_GIT_TIMEOUT_SECONDS = 30
|
|
160
|
+
|
|
161
|
+
Runner = Callable[[ExecutionRequest], ExecutionResult]
|
|
162
|
+
|
|
163
|
+
_OPEN_FLAGS = os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC | os.O_NONBLOCK
|
|
164
|
+
# The lock's directory: must be a real directory reached without following a
|
|
165
|
+
# symlink (O_NOFOLLOW refuses a symlink at that name with ELOOP; O_DIRECTORY
|
|
166
|
+
# refuses anything that is not a directory with ENOTDIR).
|
|
167
|
+
_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
168
|
+
# The git common dir itself: already resolved by repository_lock_path.
|
|
169
|
+
_BASE_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def repository_lock_path(workdir: str | Path, runner: Runner = execute) -> Path:
|
|
173
|
+
"""Canonical lock path of the repository that contains ``workdir``.
|
|
174
|
+
|
|
175
|
+
``git rev-parse --git-common-dir`` names the git directory shared by all
|
|
176
|
+
working trees of one repository (for a linked worktree it is the main
|
|
177
|
+
repository's ``.git``, not ``.git/worktrees/<name>``), so the result is
|
|
178
|
+
the same for every subdirectory, every worktree and every state
|
|
179
|
+
directory of that repository. Relative output is resolved against
|
|
180
|
+
``workdir``; symlinks are resolved so two spellings of one checkout
|
|
181
|
+
agree. Raises LockError when ``workdir`` is not inside a git repository
|
|
182
|
+
or ``git`` cannot be run: without a repository identity there is nothing
|
|
183
|
+
to lock, and the controller must not fall back to a weaker lock.
|
|
184
|
+
"""
|
|
185
|
+
base = Path(workdir)
|
|
186
|
+
shown = base.resolve() # messages only: "." tells the operator nothing
|
|
187
|
+
try:
|
|
188
|
+
# Hardened like every controller git process (ADR 0004 D7.3), and
|
|
189
|
+
# from the same environment as the workspace reader, so an operator's
|
|
190
|
+
# `GIT_DIR` cannot key the lock to one repository and the run to
|
|
191
|
+
# another.
|
|
192
|
+
res = runner(
|
|
193
|
+
local_git_request(
|
|
194
|
+
_GIT_COMMON_DIR_ARGS, cwd=str(base), timeout_seconds=_GIT_TIMEOUT_SECONDS
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
except ExecutionError as exc:
|
|
198
|
+
raise LockError(
|
|
199
|
+
f"cannot derive the controller lock for {shown}: git could not be run ({exc})"
|
|
200
|
+
) from exc
|
|
201
|
+
if res.timed_out:
|
|
202
|
+
raise LockError(f"cannot derive the controller lock for {shown}: git rev-parse timed out")
|
|
203
|
+
if res.truncated:
|
|
204
|
+
raise LockError(
|
|
205
|
+
f"cannot derive the controller lock for {shown}: git rev-parse output was truncated"
|
|
206
|
+
)
|
|
207
|
+
if res.exit_code != 0:
|
|
208
|
+
detail = (res.stderr or res.stdout).strip().splitlines()
|
|
209
|
+
raise LockError(
|
|
210
|
+
f"cannot derive the controller lock: {shown} is not inside a git repository "
|
|
211
|
+
f"({detail[0] if detail else f'git rev-parse exited {res.exit_code}'})"
|
|
212
|
+
)
|
|
213
|
+
common = res.stdout.strip()
|
|
214
|
+
if not common:
|
|
215
|
+
raise LockError(
|
|
216
|
+
f"cannot derive the controller lock for {shown}: git rev-parse returned no git dir"
|
|
217
|
+
)
|
|
218
|
+
git_dir = (base / common).resolve()
|
|
219
|
+
return git_dir / LOCK_DIRNAME / LOCK_FILENAME
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class ControllerLock:
|
|
223
|
+
"""Non-reentrant exclusive lock: L0.
|
|
224
|
+
|
|
225
|
+
Held for a whole CLI command via ``ControllerEngine.locked()`` (from the
|
|
226
|
+
state load / creation through the last persisted step); ``engine.step()``
|
|
227
|
+
/ ``engine.run()`` acquire it per call only when used outside that block.
|
|
228
|
+
|
|
229
|
+
A scheduler shares it with its workers (:meth:`share`, :meth:`adopt`);
|
|
230
|
+
from then on it is released by ``close`` only, never ``LOCK_UN``
|
|
231
|
+
(ADR 0008 D10.3). A standalone command never shares it, so its
|
|
232
|
+
:meth:`release` unlocks and closes exactly as before.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def __init__(self, lock_path: str | Path) -> None:
|
|
236
|
+
self.lock_path = Path(lock_path)
|
|
237
|
+
self._fd: int | None = None
|
|
238
|
+
self._pid_fd: int | None = None
|
|
239
|
+
self._base_fd: int | None = None
|
|
240
|
+
# Close-only release (D10.3): set by share() before the descriptor
|
|
241
|
+
# is handed to a child, and always for an adopted descriptor.
|
|
242
|
+
self._close_only = False
|
|
243
|
+
|
|
244
|
+
def _refuse(self, reason: str) -> LockError:
|
|
245
|
+
return LockError(f"cannot use {self.lock_path} as the controller lock: {reason}")
|
|
246
|
+
|
|
247
|
+
def _describe_dir_entry(self, base_fd: int) -> str:
|
|
248
|
+
"""What sits at the lock directory's name (without following it), for messages."""
|
|
249
|
+
try:
|
|
250
|
+
st = os.lstat(self.lock_path.parent.name, dir_fd=base_fd)
|
|
251
|
+
except OSError:
|
|
252
|
+
return "not a directory; move it out of the way and re-run"
|
|
253
|
+
if stat.S_ISLNK(st.st_mode):
|
|
254
|
+
return "a symbolic link; remove it and re-run (the link target has not been touched)"
|
|
255
|
+
kind = entry_kind(st.st_mode) or "regular file"
|
|
256
|
+
return f"a {kind}, not a directory; move it out of the way and re-run"
|
|
257
|
+
|
|
258
|
+
def _open_dir(self) -> int:
|
|
259
|
+
"""Descriptor of the lock's directory, reached without following a symlink.
|
|
260
|
+
|
|
261
|
+
``lock_path`` is ``<base>/<dir>/<file>``: ``<base>`` (the git common
|
|
262
|
+
dir, resolved by :func:`repository_lock_path`) is trusted as the
|
|
263
|
+
repository identity and opened as a directory; ``<dir>`` is created
|
|
264
|
+
with ``mkdir`` -- which never follows a symbolic link at that name --
|
|
265
|
+
and then opened *relative to the base descriptor* with
|
|
266
|
+
``O_DIRECTORY | O_NOFOLLOW``, so a symlink or a non-directory in its
|
|
267
|
+
place is refused (LockError) and its target is never touched.
|
|
268
|
+
"""
|
|
269
|
+
lock_dir = self.lock_path.parent
|
|
270
|
+
try:
|
|
271
|
+
base_fd = os.open(lock_dir.parent, _BASE_FLAGS)
|
|
272
|
+
except OSError as exc:
|
|
273
|
+
raise self._refuse(f"cannot open its repository directory: {exc}") from exc
|
|
274
|
+
try:
|
|
275
|
+
try:
|
|
276
|
+
os.mkdir(lock_dir.name, 0o755, dir_fd=base_fd)
|
|
277
|
+
except FileExistsError:
|
|
278
|
+
pass # validated by the open below; may be a symlink or a file
|
|
279
|
+
except OSError as exc:
|
|
280
|
+
raise self._refuse(f"cannot create its directory {lock_dir}: {exc}") from exc
|
|
281
|
+
try:
|
|
282
|
+
dir_fd = os.open(lock_dir.name, _DIR_FLAGS, dir_fd=base_fd)
|
|
283
|
+
except OSError as exc:
|
|
284
|
+
if exc.errno in (errno.ELOOP, errno.EMLINK, errno.ENOTDIR):
|
|
285
|
+
# Linux reports a symlink as ENOTDIR under O_DIRECTORY |
|
|
286
|
+
# O_NOFOLLOW, other systems as ELOOP: name what is there.
|
|
287
|
+
raise self._refuse(
|
|
288
|
+
f"its directory {lock_dir} is {self._describe_dir_entry(base_fd)}"
|
|
289
|
+
) from exc
|
|
290
|
+
raise self._refuse(f"cannot open its directory {lock_dir}: {exc}") from exc
|
|
291
|
+
except BaseException:
|
|
292
|
+
os.close(base_fd)
|
|
293
|
+
raise
|
|
294
|
+
try:
|
|
295
|
+
st = os.fstat(dir_fd)
|
|
296
|
+
except OSError as exc:
|
|
297
|
+
os.close(dir_fd)
|
|
298
|
+
os.close(base_fd)
|
|
299
|
+
raise self._refuse(f"cannot stat its directory {lock_dir}: {exc}") from exc
|
|
300
|
+
if not stat.S_ISDIR(st.st_mode):
|
|
301
|
+
os.close(dir_fd)
|
|
302
|
+
os.close(base_fd)
|
|
303
|
+
raise self._refuse(f"its directory {lock_dir} is not a directory")
|
|
304
|
+
self._base_fd = base_fd
|
|
305
|
+
return dir_fd
|
|
306
|
+
|
|
307
|
+
def _open(self) -> int:
|
|
308
|
+
"""Open the lock entry itself, never through a symlink, as a regular file."""
|
|
309
|
+
dir_fd = self._open_dir()
|
|
310
|
+
try:
|
|
311
|
+
try:
|
|
312
|
+
# Relative to the validated directory descriptor: the parent
|
|
313
|
+
# cannot be swapped for a symlink between its check and this
|
|
314
|
+
# open. O_NOFOLLOW: a symlink named controller.lock fails with
|
|
315
|
+
# ELOOP instead of being followed (also with O_CREAT, dangling
|
|
316
|
+
# or not). O_NONBLOCK: a FIFO cannot stall the open; it is
|
|
317
|
+
# refused below.
|
|
318
|
+
fd = os.open(self.lock_path.name, _OPEN_FLAGS, 0o644, dir_fd=dir_fd)
|
|
319
|
+
except OSError as exc:
|
|
320
|
+
if exc.errno in (errno.ELOOP, errno.EMLINK):
|
|
321
|
+
raise self._refuse(
|
|
322
|
+
"it is a symbolic link; remove it and re-run "
|
|
323
|
+
"(the link target has not been touched)"
|
|
324
|
+
) from exc
|
|
325
|
+
if exc.errno == errno.EISDIR:
|
|
326
|
+
raise self._refuse("it is a directory, not a regular file") from exc
|
|
327
|
+
raise self._refuse(f"cannot open it: {exc}") from exc
|
|
328
|
+
except BaseException:
|
|
329
|
+
if self._base_fd is not None:
|
|
330
|
+
os.close(self._base_fd)
|
|
331
|
+
self._base_fd = None
|
|
332
|
+
raise
|
|
333
|
+
finally:
|
|
334
|
+
os.close(dir_fd)
|
|
335
|
+
try:
|
|
336
|
+
st = os.fstat(fd)
|
|
337
|
+
except OSError as exc:
|
|
338
|
+
os.close(fd)
|
|
339
|
+
if self._base_fd is not None:
|
|
340
|
+
os.close(self._base_fd)
|
|
341
|
+
self._base_fd = None
|
|
342
|
+
raise self._refuse(f"cannot stat it: {exc}") from exc
|
|
343
|
+
kind = entry_kind(st.st_mode)
|
|
344
|
+
if kind is not None:
|
|
345
|
+
os.close(fd)
|
|
346
|
+
if self._base_fd is not None:
|
|
347
|
+
os.close(self._base_fd)
|
|
348
|
+
self._base_fd = None
|
|
349
|
+
raise self._refuse(f"it is a {kind}, not a regular file")
|
|
350
|
+
if st.st_nlink != 1:
|
|
351
|
+
# A hard link passes O_NOFOLLOW and S_ISREG but shares its inode
|
|
352
|
+
# with another name: truncating / writing the PID would modify
|
|
353
|
+
# that other file. Refuse; nothing has been written.
|
|
354
|
+
os.close(fd)
|
|
355
|
+
if self._base_fd is not None:
|
|
356
|
+
os.close(self._base_fd)
|
|
357
|
+
self._base_fd = None
|
|
358
|
+
raise self._refuse(
|
|
359
|
+
f"it has {st.st_nlink} hard links, so it shares its inode with "
|
|
360
|
+
"another file; remove it and re-run (the other file has not been touched)"
|
|
361
|
+
)
|
|
362
|
+
return fd
|
|
363
|
+
|
|
364
|
+
def acquire(self) -> ControllerLock:
|
|
365
|
+
assert_shared_git_lock_free("take the controller lock (L0)")
|
|
366
|
+
if self.held:
|
|
367
|
+
raise self._refuse("it is already held by this controller")
|
|
368
|
+
pid_fd = self._open()
|
|
369
|
+
base_fd = self._base_fd
|
|
370
|
+
self._base_fd = None
|
|
371
|
+
if base_fd is None: # pragma: no cover - _open always retains the base
|
|
372
|
+
os.close(pid_fd)
|
|
373
|
+
raise self._refuse("its repository directory descriptor was lost")
|
|
374
|
+
try:
|
|
375
|
+
try:
|
|
376
|
+
# Lock the stable repository directory inode, not the PID
|
|
377
|
+
# record below `autoforge`, which an agent can replace.
|
|
378
|
+
fcntl.flock(base_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
379
|
+
except BlockingIOError as exc:
|
|
380
|
+
raise LockError(_CONTENTION.format(path=self.lock_path)) from exc
|
|
381
|
+
except OSError as exc:
|
|
382
|
+
raise self._refuse(f"flock failed: {exc}") from exc
|
|
383
|
+
try:
|
|
384
|
+
# Replace, do not append: the file holds exactly the current PID.
|
|
385
|
+
os.ftruncate(pid_fd, 0)
|
|
386
|
+
os.write(pid_fd, f"{os.getpid()}\n".encode("ascii"))
|
|
387
|
+
except OSError as exc:
|
|
388
|
+
raise self._refuse(f"cannot record the holder PID: {exc}") from exc
|
|
389
|
+
except BaseException:
|
|
390
|
+
os.close(pid_fd)
|
|
391
|
+
os.close(base_fd) # also drops the flock if it was taken
|
|
392
|
+
raise
|
|
393
|
+
self._fd = base_fd
|
|
394
|
+
self._pid_fd = pid_fd
|
|
395
|
+
return self
|
|
396
|
+
|
|
397
|
+
def release(self) -> None:
|
|
398
|
+
"""Release the lock: unlock and close, or only close once it is shared.
|
|
399
|
+
|
|
400
|
+
``LOCK_UN`` is the standalone path, where no other process holds the
|
|
401
|
+
descriptor. After :meth:`share`, and for an adopted lock, ``LOCK_UN``
|
|
402
|
+
would release L0 for the scheduler and every sibling at once, so the
|
|
403
|
+
descriptor is only closed: the lock stays held while any other copy
|
|
404
|
+
of it is open (ADR 0008 D10.3, D10.4).
|
|
405
|
+
"""
|
|
406
|
+
if self._fd is not None:
|
|
407
|
+
fd, self._fd = self._fd, None
|
|
408
|
+
if self._close_only:
|
|
409
|
+
os.close(fd)
|
|
410
|
+
else:
|
|
411
|
+
try:
|
|
412
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
413
|
+
finally:
|
|
414
|
+
os.close(fd)
|
|
415
|
+
if self._pid_fd is not None:
|
|
416
|
+
pid_fd, self._pid_fd = self._pid_fd, None
|
|
417
|
+
os.close(pid_fd)
|
|
418
|
+
|
|
419
|
+
@property
|
|
420
|
+
def held(self) -> bool:
|
|
421
|
+
return self._fd is not None
|
|
422
|
+
|
|
423
|
+
@property
|
|
424
|
+
def close_only(self) -> bool:
|
|
425
|
+
"""Whether :meth:`release` only closes (shared or adopted; ADR 0008 D10.3)."""
|
|
426
|
+
return self._close_only
|
|
427
|
+
|
|
428
|
+
@property
|
|
429
|
+
def coordination_root(self) -> Path:
|
|
430
|
+
"""The git common dir whose descriptor this lock is taken on."""
|
|
431
|
+
return self.lock_path.parent.parent
|
|
432
|
+
|
|
433
|
+
def share(self) -> int:
|
|
434
|
+
"""Switch to close-only release and return the held descriptor, for ``pass_fds``.
|
|
435
|
+
|
|
436
|
+
A scheduler calls it before its first spawn (ADR 0008 D10.3), so the
|
|
437
|
+
switch also covers an exit by exception: from then on no release in
|
|
438
|
+
this process calls ``LOCK_UN``, and the lock stays held while a
|
|
439
|
+
worker that inherited the descriptor lives. The descriptor stays
|
|
440
|
+
``O_CLOEXEC`` here: only a launch that names it in ``pass_fds`` (the
|
|
441
|
+
worker spawn) inherits it.
|
|
442
|
+
"""
|
|
443
|
+
if self._fd is None:
|
|
444
|
+
raise self._refuse("it is not held, so there is no descriptor to share")
|
|
445
|
+
self._close_only = True
|
|
446
|
+
return self._fd
|
|
447
|
+
|
|
448
|
+
@classmethod
|
|
449
|
+
def adopt(cls, fd: int, *, expected_root: str | Path) -> ControllerLock:
|
|
450
|
+
"""Wrap an inherited L0 descriptor once :func:`prove_held` proved it (D9.2, D10.2).
|
|
451
|
+
|
|
452
|
+
``expected_root`` is the coordination root (the git common dir) the
|
|
453
|
+
descriptor must name. The proof opens the root afresh
|
|
454
|
+
(``O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC``), requires the
|
|
455
|
+
same ``(st_dev, st_ino)`` as ``fd`` and requires ``flock(LOCK_EX |
|
|
456
|
+
LOCK_NB)`` on the fresh descriptor to fail with ``EWOULDBLOCK``; it
|
|
457
|
+
never acquires or releases the lock. A refusal is
|
|
458
|
+
:class:`~autoforge.errors.LockProofError` and leaves ``fd`` open and
|
|
459
|
+
untouched. The adopted lock is close-only, never writes the PID
|
|
460
|
+
record (which keeps naming the scheduler, for diagnostics only), and
|
|
461
|
+
its descriptor is made non-inheritable, so no launch of this process
|
|
462
|
+
passes it on.
|
|
463
|
+
"""
|
|
464
|
+
assert_shared_git_lock_free("adopt the controller lock (L0)")
|
|
465
|
+
root = Path(expected_root)
|
|
466
|
+
lock = cls(root / LOCK_DIRNAME / LOCK_FILENAME)
|
|
467
|
+
prove_held(fd, lambda: _open_fresh_root(root), what=lock._what())
|
|
468
|
+
os.set_inheritable(fd, False)
|
|
469
|
+
lock._fd = fd
|
|
470
|
+
lock._close_only = True
|
|
471
|
+
return lock
|
|
472
|
+
|
|
473
|
+
def prove_held(self) -> None:
|
|
474
|
+
"""Prove again that this lock's descriptor holds L0 (:func:`prove_held`)."""
|
|
475
|
+
if self._fd is None:
|
|
476
|
+
raise LockProofError(f"{self._what()}: it is not held by this process")
|
|
477
|
+
root = self.coordination_root
|
|
478
|
+
prove_held(self._fd, lambda: _open_fresh_root(root), what=self._what())
|
|
479
|
+
|
|
480
|
+
def _what(self) -> str:
|
|
481
|
+
return f"the controller lock (L0) on {self.coordination_root}"
|
|
482
|
+
|
|
483
|
+
def __enter__(self) -> ControllerLock:
|
|
484
|
+
return self.acquire()
|
|
485
|
+
|
|
486
|
+
def __exit__(
|
|
487
|
+
self,
|
|
488
|
+
exc_type: type[BaseException] | None,
|
|
489
|
+
exc: BaseException | None,
|
|
490
|
+
tb: TracebackType | None,
|
|
491
|
+
) -> None:
|
|
492
|
+
self.release()
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
# -- the proof of holding (ADR 0008 D9.2 step 1) ----------------------------------------
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def prove_held(fd: int, open_fresh: Callable[[], int], *, what: str) -> None:
|
|
499
|
+
"""Prove that ``fd`` names the object ``open_fresh()`` opens and that its lock is held.
|
|
500
|
+
|
|
501
|
+
``flock(LOCK_EX | LOCK_NB)`` on ``fd`` itself proves nothing: on an open
|
|
502
|
+
file description that holds the lock it is a no-op, and on one that does
|
|
503
|
+
not it silently acquires. So the proof opens the object afresh
|
|
504
|
+
(``open_fresh``: ``O_NOFOLLOW | O_CLOEXEC``, never ``O_CREAT``), requires
|
|
505
|
+
the same file type and ``(st_dev, st_ino)`` as ``fd``, and requires
|
|
506
|
+
``flock(LOCK_EX | LOCK_NB)`` on the fresh descriptor to fail with
|
|
507
|
+
``EWOULDBLOCK``, so that some open file description holds the lock. The
|
|
508
|
+
fresh descriptor is closed at once. It is a separate open file
|
|
509
|
+
description, so the proof can neither acquire nor release the lock
|
|
510
|
+
``fd`` holds; when nothing holds it, the probe held it only until that
|
|
511
|
+
close, and the refusal leaves it unacquired.
|
|
512
|
+
|
|
513
|
+
A refusal is :class:`~autoforge.errors.LockProofError` (an object that
|
|
514
|
+
cannot be opened afresh included); ``fd`` is never closed or changed.
|
|
515
|
+
Reusable: a worker proves both inherited locks at startup and its L2
|
|
516
|
+
again at each step boundary (#182).
|
|
517
|
+
"""
|
|
518
|
+
try:
|
|
519
|
+
inherited = os.fstat(fd)
|
|
520
|
+
except OSError as exc:
|
|
521
|
+
raise LockProofError(
|
|
522
|
+
f"{what}: descriptor {fd} is not an open descriptor ({exc.strerror})"
|
|
523
|
+
) from exc
|
|
524
|
+
fresh = open_fresh()
|
|
525
|
+
try:
|
|
526
|
+
st = os.fstat(fresh)
|
|
527
|
+
if stat.S_IFMT(st.st_mode) != stat.S_IFMT(inherited.st_mode) or (
|
|
528
|
+
st.st_dev,
|
|
529
|
+
st.st_ino,
|
|
530
|
+
) != (inherited.st_dev, inherited.st_ino):
|
|
531
|
+
raise LockProofError(
|
|
532
|
+
f"{what}: descriptor {fd} names another object (device {inherited.st_dev}, "
|
|
533
|
+
f"inode {inherited.st_ino}) than the lock (device {st.st_dev}, inode "
|
|
534
|
+
f"{st.st_ino}); it does not hold that lock"
|
|
535
|
+
)
|
|
536
|
+
try:
|
|
537
|
+
fcntl.flock(fresh, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
538
|
+
except BlockingIOError:
|
|
539
|
+
return # some open file description holds it: the proof
|
|
540
|
+
except OSError as exc:
|
|
541
|
+
raise LockProofError(f"{what}: the lock could not be probed ({exc})") from exc
|
|
542
|
+
raise LockProofError(
|
|
543
|
+
f"{what}: nothing holds it, so descriptor {fd} does not; the probe released it "
|
|
544
|
+
"again at once"
|
|
545
|
+
)
|
|
546
|
+
finally:
|
|
547
|
+
os.close(fresh)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _open_fresh_root(root: Path) -> int:
|
|
551
|
+
"""The coordination root opened afresh for :func:`prove_held`; never created."""
|
|
552
|
+
try:
|
|
553
|
+
return os.open(root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC)
|
|
554
|
+
except OSError as exc:
|
|
555
|
+
raise LockProofError(
|
|
556
|
+
f"the coordination root {root} cannot be opened afresh to prove its lock: {exc}"
|
|
557
|
+
) from exc
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _open_lock_file(root: Path, relpath: str, *, create: bool, what: str) -> int:
|
|
561
|
+
"""A hardened descriptor of the lock file ``relpath`` below the coordination root.
|
|
562
|
+
|
|
563
|
+
Every directory from ``root`` down is opened ``O_DIRECTORY | O_NOFOLLOW``
|
|
564
|
+
relative to the one before (:class:`~autoforge.safefs.SafeRoot`) and is
|
|
565
|
+
created when ``create``; the file is opened ``O_NOFOLLOW | O_CLOEXEC |
|
|
566
|
+
O_NONBLOCK``, read-write and created when ``create``, read-only and never
|
|
567
|
+
created otherwise, and must be a regular file with exactly one link. It
|
|
568
|
+
is never truncated, written or unlinked. A missing entry without
|
|
569
|
+
``create`` raises ``FileNotFoundError``; any other refusal (a symbolic
|
|
570
|
+
link, a FIFO, a directory, a second hard link anywhere on the way) is a
|
|
571
|
+
:class:`~autoforge.errors.LockError` naming the entry.
|
|
572
|
+
"""
|
|
573
|
+
parts = split_relpath(relpath)
|
|
574
|
+
path = root.joinpath(*parts)
|
|
575
|
+
flags = (os.O_RDWR | os.O_CREAT) if create else os.O_RDONLY
|
|
576
|
+
try:
|
|
577
|
+
with SafeRoot.open(root) as anchor:
|
|
578
|
+
if len(parts) == 1:
|
|
579
|
+
fd = open_regular_at(anchor.fd, parts[0], flags, mode=0o644, where=str(path))
|
|
580
|
+
else:
|
|
581
|
+
with anchor.subroot("/".join(parts[:-1]), create=create) as parent:
|
|
582
|
+
fd = open_regular_at(parent.fd, parts[-1], flags, mode=0o644, where=str(path))
|
|
583
|
+
except FileNotFoundError:
|
|
584
|
+
if not create:
|
|
585
|
+
raise
|
|
586
|
+
raise LockError(f"cannot use {path} as {what}: {root} does not exist") from None
|
|
587
|
+
except (StateError, OSError) as exc:
|
|
588
|
+
raise LockError(f"cannot use {path} as {what}: {exc}") from exc
|
|
589
|
+
try:
|
|
590
|
+
st = os.fstat(fd)
|
|
591
|
+
except OSError as exc:
|
|
592
|
+
os.close(fd)
|
|
593
|
+
raise LockError(f"cannot use {path} as {what}: cannot stat it: {exc}") from exc
|
|
594
|
+
if st.st_nlink != 1:
|
|
595
|
+
# A write open already refused a second name; a read-only one (the
|
|
596
|
+
# probe and the proof) is held to the same rule, and none at all is
|
|
597
|
+
# a lock file unlinked under the open.
|
|
598
|
+
os.close(fd)
|
|
599
|
+
raise LockError(
|
|
600
|
+
f"cannot use {path} as {what}: it has {st.st_nlink} hard links, so it is not "
|
|
601
|
+
"the lock file the controller created; remove it and re-run (any other file "
|
|
602
|
+
"has not been touched)"
|
|
603
|
+
)
|
|
604
|
+
return fd
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
# -- L2: the per-issue run lock (ADR 0008 D11.1) ----------------------------------------
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
class IssueRunLock:
|
|
611
|
+
"""One issue's run lock (L2): ``<common>/autoforge/scheduler/issues/<n>/run.lock``.
|
|
612
|
+
|
|
613
|
+
Exactly one worker holds it per issue. Only the L0-holding scheduler
|
|
614
|
+
acquires it (:meth:`acquire`, ``LOCK_EX | LOCK_NB``: a held lock is
|
|
615
|
+
:class:`~autoforge.errors.IssueBusy`, never a wait) and hands it to one
|
|
616
|
+
child (:meth:`handoff`), and the worker adopts it (:meth:`adopt`). Every release,
|
|
617
|
+
in the scheduler and in workers, is ``close`` only (ADR 0008 D10.3), so
|
|
618
|
+
the lock stays held while any copy of the descriptor is open, and the
|
|
619
|
+
kernel releases it when the last one closes, SIGKILL included. The file
|
|
620
|
+
is created once and never unlinked, written or truncated. Acquisition is
|
|
621
|
+
scheduler mode only, so the filesystem of the coordination root and of
|
|
622
|
+
the opened descriptor is checked (:func:`check_local_lock_filesystem`).
|
|
623
|
+
"""
|
|
624
|
+
|
|
625
|
+
def __init__(self, namespace: IssueNamespace) -> None:
|
|
626
|
+
self.namespace = namespace
|
|
627
|
+
self.lock_path = namespace.run_lock_file
|
|
628
|
+
self._fd: int | None = None
|
|
629
|
+
self._handed_off = False
|
|
630
|
+
|
|
631
|
+
@property
|
|
632
|
+
def held(self) -> bool:
|
|
633
|
+
return self._fd is not None
|
|
634
|
+
|
|
635
|
+
@property
|
|
636
|
+
def _relpath(self) -> str:
|
|
637
|
+
return "/".join((*self.namespace.relative, self.lock_path.name))
|
|
638
|
+
|
|
639
|
+
def _what(self) -> str:
|
|
640
|
+
return f"the run lock (L2) of issue {self.namespace.issue_number}"
|
|
641
|
+
|
|
642
|
+
def acquire(self) -> IssueRunLock:
|
|
643
|
+
"""Take the lock, non-blocking; :class:`~autoforge.errors.IssueBusy` when held."""
|
|
644
|
+
assert_shared_git_lock_free(f"take {self._what()}")
|
|
645
|
+
if self._fd is not None:
|
|
646
|
+
raise LockError(f"{self._what()} is already held by this process")
|
|
647
|
+
root = self.namespace.coordination_root
|
|
648
|
+
fd = _open_lock_file(root, self._relpath, create=True, what=self._what())
|
|
649
|
+
try:
|
|
650
|
+
check_local_lock_filesystem(root, what=f"the coordination root {root}")
|
|
651
|
+
check_local_lock_filesystem(fd, what=f"{self._what()} {self.lock_path}")
|
|
652
|
+
try:
|
|
653
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
654
|
+
except BlockingIOError as exc:
|
|
655
|
+
raise IssueBusy(
|
|
656
|
+
f"{self._what()} ({self.lock_path}) is held by another process: a live "
|
|
657
|
+
"worker owns the issue (or a status probe read it at this instant)"
|
|
658
|
+
) from exc
|
|
659
|
+
except OSError as exc:
|
|
660
|
+
raise LockError(f"cannot take {self._what()}: flock failed: {exc}") from exc
|
|
661
|
+
except BaseException:
|
|
662
|
+
os.close(fd) # drops the flock too, if it was taken
|
|
663
|
+
raise
|
|
664
|
+
self._fd = fd
|
|
665
|
+
self._handed_off = False
|
|
666
|
+
return self
|
|
667
|
+
|
|
668
|
+
def handoff(self) -> int:
|
|
669
|
+
"""The held descriptor, for exactly one child's ``pass_fds`` (D9.6).
|
|
670
|
+
|
|
671
|
+
Refused (:class:`~autoforge.errors.LockError`) when the lock is not
|
|
672
|
+
held, and on a second call: one issue, one worker. After the spawn
|
|
673
|
+
the caller releases its own copy (:meth:`release`, ``close`` only), and
|
|
674
|
+
the child is the holder.
|
|
675
|
+
"""
|
|
676
|
+
if self._fd is None:
|
|
677
|
+
raise LockError(f"{self._what()} is not held, so it cannot be handed to a worker")
|
|
678
|
+
if self._handed_off:
|
|
679
|
+
raise LockError(
|
|
680
|
+
f"{self._what()} was already handed to a worker; it is handed to exactly one"
|
|
681
|
+
)
|
|
682
|
+
self._handed_off = True
|
|
683
|
+
return self._fd
|
|
684
|
+
|
|
685
|
+
def release(self) -> None:
|
|
686
|
+
"""Close the descriptor; never ``LOCK_UN`` (ADR 0008 D10.3)."""
|
|
687
|
+
if self._fd is not None:
|
|
688
|
+
fd, self._fd = self._fd, None
|
|
689
|
+
os.close(fd)
|
|
690
|
+
|
|
691
|
+
@classmethod
|
|
692
|
+
def adopt(cls, fd: int, namespace: IssueNamespace) -> IssueRunLock:
|
|
693
|
+
"""Wrap an inherited L2 descriptor once :func:`prove_held` proved it (D9.2).
|
|
694
|
+
|
|
695
|
+
The fresh descriptor is this namespace's ``run.lock``, opened
|
|
696
|
+
``O_RDONLY | O_NOFOLLOW | O_CLOEXEC`` below the coordination root
|
|
697
|
+
without ``O_CREAT``. A refusal is
|
|
698
|
+
:class:`~autoforge.errors.LockProofError` and leaves ``fd`` open and
|
|
699
|
+
untouched. The adopted descriptor is made non-inheritable and is
|
|
700
|
+
never handed off again.
|
|
701
|
+
"""
|
|
702
|
+
lock = cls(namespace)
|
|
703
|
+
assert_shared_git_lock_free(f"adopt {lock._what()}")
|
|
704
|
+
lock._prove(fd)
|
|
705
|
+
os.set_inheritable(fd, False)
|
|
706
|
+
lock._fd = fd
|
|
707
|
+
lock._handed_off = True
|
|
708
|
+
return lock
|
|
709
|
+
|
|
710
|
+
def prove_held(self) -> None:
|
|
711
|
+
"""Prove again that this descriptor holds the lock (the worker's step boundary, #182)."""
|
|
712
|
+
if self._fd is None:
|
|
713
|
+
raise LockProofError(f"{self._what()}: it is not held by this process")
|
|
714
|
+
self._prove(self._fd)
|
|
715
|
+
|
|
716
|
+
def _prove(self, fd: int) -> None:
|
|
717
|
+
root = self.namespace.coordination_root
|
|
718
|
+
|
|
719
|
+
def fresh() -> int:
|
|
720
|
+
try:
|
|
721
|
+
return _open_lock_file(root, self._relpath, create=False, what=self._what())
|
|
722
|
+
except FileNotFoundError:
|
|
723
|
+
raise LockProofError(
|
|
724
|
+
f"{self._what()}: {self.lock_path} does not exist, so nothing holds it"
|
|
725
|
+
) from None
|
|
726
|
+
except LockError as exc:
|
|
727
|
+
raise LockProofError(str(exc)) from exc
|
|
728
|
+
|
|
729
|
+
prove_held(fd, fresh, what=f"{self._what()} ({self.lock_path})")
|
|
730
|
+
|
|
731
|
+
def __enter__(self) -> IssueRunLock:
|
|
732
|
+
return self.acquire()
|
|
733
|
+
|
|
734
|
+
def __exit__(
|
|
735
|
+
self,
|
|
736
|
+
exc_type: type[BaseException] | None,
|
|
737
|
+
exc: BaseException | None,
|
|
738
|
+
tb: TracebackType | None,
|
|
739
|
+
) -> None:
|
|
740
|
+
self.release()
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def issue_run_lock_live(namespace: IssueNamespace) -> bool:
|
|
744
|
+
"""Whether a live owner holds issue ``n``'s run lock; lock-free (ADR 0008 D8.3).
|
|
745
|
+
|
|
746
|
+
Opens ``run.lock`` ``O_RDONLY | O_NOFOLLOW | O_CLOEXEC`` without
|
|
747
|
+
``O_CREAT`` (a missing file, or a missing directory above it, means no
|
|
748
|
+
owner, and nothing is created), tries ``LOCK_SH | LOCK_NB`` and closes at
|
|
749
|
+
once. ``EWOULDBLOCK`` is a live owner. A shared probe never excludes
|
|
750
|
+
another probe; one that collides with an :meth:`IssueRunLock.acquire`
|
|
751
|
+
makes only that acquisition fail (``IssueBusy``), which the scheduler
|
|
752
|
+
reads as live for one tick. An entry that is not the lock file (a link,
|
|
753
|
+
a FIFO, a second hard link) is a :class:`~autoforge.errors.LockError`:
|
|
754
|
+
the caller cannot tell, and fails closed.
|
|
755
|
+
"""
|
|
756
|
+
lock = IssueRunLock(namespace)
|
|
757
|
+
try:
|
|
758
|
+
fd = _open_lock_file(
|
|
759
|
+
namespace.coordination_root, lock._relpath, create=False, what=lock._what()
|
|
760
|
+
)
|
|
761
|
+
except FileNotFoundError:
|
|
762
|
+
return False
|
|
763
|
+
try:
|
|
764
|
+
try:
|
|
765
|
+
fcntl.flock(fd, fcntl.LOCK_SH | fcntl.LOCK_NB)
|
|
766
|
+
except BlockingIOError:
|
|
767
|
+
return True
|
|
768
|
+
except OSError as exc:
|
|
769
|
+
raise LockError(f"cannot probe {lock._what()}: flock failed: {exc}") from exc
|
|
770
|
+
return False
|
|
771
|
+
finally:
|
|
772
|
+
os.close(fd) # also drops the shared probe lock
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
# -- L1: the shared-Git lock (ADR 0008 D11.1, D11.3, D12) -------------------------------
|
|
776
|
+
|
|
777
|
+
# What this process holds L1 for, "" while it holds none (ADR 0008 D11.2).
|
|
778
|
+
# Per process: a child that execs starts with none.
|
|
779
|
+
_shared_git_holder = ""
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def assert_shared_git_lock_free(action: str) -> None:
|
|
783
|
+
"""Refuse ``action`` while this process holds the shared-Git lock (D11.2).
|
|
784
|
+
|
|
785
|
+
The holder of L1 acquires no lock (L1 included), invokes no agent, calls
|
|
786
|
+
no GitHub and saves no state, so it can never wait on anything while
|
|
787
|
+
others wait on it. :class:`~autoforge.errors.LockOrderError` is a
|
|
788
|
+
controller bug, never a runtime condition.
|
|
789
|
+
"""
|
|
790
|
+
if _shared_git_holder:
|
|
791
|
+
raise LockOrderError(
|
|
792
|
+
f"cannot {action} while holding the shared-Git lock (L1) for "
|
|
793
|
+
f"{_shared_git_holder}: its holder takes no lock, invokes no agent, calls no "
|
|
794
|
+
"GitHub and saves no state (ADR 0008 D11.2); this is a controller bug"
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def shared_git_lock_path(coordination_root: str | Path) -> Path:
|
|
799
|
+
"""``<coordination root>/autoforge/git.lock``."""
|
|
800
|
+
return Path(coordination_root) / LOCK_DIRNAME / GIT_LOCK_FILENAME
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
class SharedGitLock:
|
|
804
|
+
"""The shared-Git lock (L1), scoped to one controller write to the common dir.
|
|
805
|
+
|
|
806
|
+
A context manager around exactly one critical section of ADR 0008 D12.1:
|
|
807
|
+
the agent worktree's check-then-create, the controller's fetch, the
|
|
808
|
+
strict import's ``index-pack``, or the REMOTE sandbox directories'
|
|
809
|
+
creation. Taken ``LOCK_EX`` by a poll of non-blocking attempts until
|
|
810
|
+
:data:`SHARED_GIT_LOCK_WAIT_SECONDS` have passed, then
|
|
811
|
+
:class:`~autoforge.errors.GitLockTimeout` (transient; the
|
|
812
|
+
guarded operation reports it as its own failure). Released by ``close``:
|
|
813
|
+
the descriptor is ``O_CLOEXEC`` and never shared. Standalone commands and
|
|
814
|
+
workers both take it; with ``require_local_filesystem`` (scheduler mode)
|
|
815
|
+
the root's and the descriptor's filesystems are checked first.
|
|
816
|
+
"""
|
|
817
|
+
|
|
818
|
+
def __init__(
|
|
819
|
+
self,
|
|
820
|
+
coordination_root: str | Path,
|
|
821
|
+
*,
|
|
822
|
+
operation: str = "a controller write to the shared git data",
|
|
823
|
+
wait_seconds: float = SHARED_GIT_LOCK_WAIT_SECONDS,
|
|
824
|
+
poll_seconds: float = SHARED_GIT_LOCK_POLL_SECONDS,
|
|
825
|
+
require_local_filesystem: bool = False,
|
|
826
|
+
clock: Callable[[], float] = time.monotonic,
|
|
827
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
828
|
+
) -> None:
|
|
829
|
+
self.coordination_root = Path(coordination_root)
|
|
830
|
+
self.lock_path = shared_git_lock_path(self.coordination_root)
|
|
831
|
+
self.operation = operation
|
|
832
|
+
self.wait_seconds = wait_seconds
|
|
833
|
+
self.poll_seconds = poll_seconds
|
|
834
|
+
self.require_local_filesystem = require_local_filesystem
|
|
835
|
+
self._clock = clock
|
|
836
|
+
self._sleep = sleep
|
|
837
|
+
self._fd: int | None = None
|
|
838
|
+
|
|
839
|
+
@property
|
|
840
|
+
def held(self) -> bool:
|
|
841
|
+
return self._fd is not None
|
|
842
|
+
|
|
843
|
+
def acquire(self) -> SharedGitLock:
|
|
844
|
+
global _shared_git_holder
|
|
845
|
+
assert_shared_git_lock_free(f"take the shared-Git lock (L1) for {self.operation}")
|
|
846
|
+
what = "the shared-Git lock (L1)"
|
|
847
|
+
fd = _open_lock_file(
|
|
848
|
+
self.coordination_root, f"{LOCK_DIRNAME}/{GIT_LOCK_FILENAME}", create=True, what=what
|
|
849
|
+
)
|
|
850
|
+
try:
|
|
851
|
+
if self.require_local_filesystem:
|
|
852
|
+
root = self.coordination_root
|
|
853
|
+
check_local_lock_filesystem(root, what=f"the coordination root {root}")
|
|
854
|
+
check_local_lock_filesystem(fd, what=f"{what} {self.lock_path}")
|
|
855
|
+
deadline = self._clock() + self.wait_seconds
|
|
856
|
+
while True:
|
|
857
|
+
try:
|
|
858
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
859
|
+
break
|
|
860
|
+
except BlockingIOError:
|
|
861
|
+
remaining = deadline - self._clock()
|
|
862
|
+
if remaining <= 0:
|
|
863
|
+
raise GitLockTimeout(
|
|
864
|
+
f"waited {self.wait_seconds:g}s for {what} {self.lock_path} to "
|
|
865
|
+
f"{self.operation}; another controller process held it all along"
|
|
866
|
+
) from None
|
|
867
|
+
self._sleep(min(self.poll_seconds, remaining))
|
|
868
|
+
except OSError as exc:
|
|
869
|
+
raise LockError(f"cannot take {what}: flock failed: {exc}") from exc
|
|
870
|
+
except BaseException:
|
|
871
|
+
os.close(fd)
|
|
872
|
+
raise
|
|
873
|
+
self._fd = fd
|
|
874
|
+
_shared_git_holder = self.operation
|
|
875
|
+
return self
|
|
876
|
+
|
|
877
|
+
def release(self) -> None:
|
|
878
|
+
global _shared_git_holder
|
|
879
|
+
if self._fd is not None:
|
|
880
|
+
fd, self._fd = self._fd, None
|
|
881
|
+
_shared_git_holder = ""
|
|
882
|
+
os.close(fd)
|
|
883
|
+
|
|
884
|
+
def __enter__(self) -> SharedGitLock:
|
|
885
|
+
return self.acquire()
|
|
886
|
+
|
|
887
|
+
def __exit__(
|
|
888
|
+
self,
|
|
889
|
+
exc_type: type[BaseException] | None,
|
|
890
|
+
exc: BaseException | None,
|
|
891
|
+
tb: TracebackType | None,
|
|
892
|
+
) -> None:
|
|
893
|
+
self.release()
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
# -- the filesystem of a scheduler lock (ADR 0008 D11.4) --------------------------------
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@dataclass(frozen=True)
|
|
900
|
+
class FilesystemType:
|
|
901
|
+
"""What ``statfs``/``fstatfs`` reported: Linux's ``f_type``, macOS's ``f_fstypename``."""
|
|
902
|
+
|
|
903
|
+
magic: int | None = None
|
|
904
|
+
name: str = ""
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
# Linux ``f_type`` values (statfs(2), <linux/magic.h>) of filesystems whose
|
|
908
|
+
# flock is not a local kernel lock: network, distributed and cluster
|
|
909
|
+
# filesystems, and FUSE, which every FUSE filesystem reports (sshfs,
|
|
910
|
+
# glusterfs, virtiofs among them). Fixed here and tested; anything else is a
|
|
911
|
+
# local filesystem to this check (ext4, xfs, btrfs, tmpfs, overlay, zfs, ...).
|
|
912
|
+
NETWORK_FILESYSTEM_MAGICS: dict[int, str] = {
|
|
913
|
+
0x6969: "nfs",
|
|
914
|
+
0x517B: "smb",
|
|
915
|
+
0xFF534D42: "cifs",
|
|
916
|
+
0xFE534D42: "smb2",
|
|
917
|
+
0x01021997: "9p",
|
|
918
|
+
0x5346414F: "afs",
|
|
919
|
+
0x6B414653: "kafs",
|
|
920
|
+
0x73757245: "coda",
|
|
921
|
+
0x564C: "ncp",
|
|
922
|
+
0x65735546: "fuse",
|
|
923
|
+
0x00C36400: "ceph",
|
|
924
|
+
0x01161970: "gfs2",
|
|
925
|
+
0x7461636F: "ocfs2",
|
|
926
|
+
0x0BD00BD0: "lustre",
|
|
927
|
+
0x47504653: "gpfs",
|
|
928
|
+
0x20030528: "orangefs",
|
|
929
|
+
0x19830326: "beegfs",
|
|
930
|
+
}
|
|
931
|
+
# macOS ``f_fstypename`` values of the same kinds; a name that contains
|
|
932
|
+
# "fuse" (macfuse, osxfuse, fusefs) is refused as well.
|
|
933
|
+
NETWORK_FILESYSTEM_NAMES = frozenset({"nfs", "smbfs", "cifs", "afpfs", "webdav", "ftp", "afs"})
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def network_filesystem(fs: FilesystemType) -> str:
|
|
937
|
+
"""The refused filesystem ``fs`` is, or "" when its flock is a local kernel lock."""
|
|
938
|
+
if fs.magic is not None and fs.magic in NETWORK_FILESYSTEM_MAGICS:
|
|
939
|
+
return NETWORK_FILESYSTEM_MAGICS[fs.magic]
|
|
940
|
+
name = fs.name.lower()
|
|
941
|
+
if name in NETWORK_FILESYSTEM_NAMES or "fuse" in name:
|
|
942
|
+
return fs.name
|
|
943
|
+
return ""
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def filesystem_type(target: int | str | Path) -> FilesystemType:
|
|
947
|
+
"""``fstatfs`` of a descriptor, ``statfs`` of a path; ``OSError`` when it cannot tell."""
|
|
948
|
+
if sys.platform.startswith("linux"):
|
|
949
|
+
return _linux_statfs(target)
|
|
950
|
+
if sys.platform == "darwin":
|
|
951
|
+
return _darwin_statfs(target)
|
|
952
|
+
raise OSError(errno.ENOSYS, f"no filesystem-type check on {sys.platform}")
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def check_local_lock_filesystem(target: int | str | Path, *, what: str = "") -> None:
|
|
956
|
+
"""Refuse a scheduler lock on a filesystem whose flock is not local (ADR 0008 D11.4).
|
|
957
|
+
|
|
958
|
+
``target`` is the coordination root (a path) or an opened ``run.lock``
|
|
959
|
+
or ``git.lock`` descriptor, so a mount below the root is caught by its
|
|
960
|
+
descriptor; ``what`` names it in the refusal. A filesystem the check
|
|
961
|
+
cannot identify is refused too: scheduler mode's live-ownership proof
|
|
962
|
+
depends on local locks. Standalone commands never call this.
|
|
963
|
+
"""
|
|
964
|
+
if not what:
|
|
965
|
+
what = f"lock descriptor {target}" if isinstance(target, int) else str(target)
|
|
966
|
+
try:
|
|
967
|
+
fs = filesystem_type(target)
|
|
968
|
+
except OSError as exc:
|
|
969
|
+
raise NetworkFilesystemError(
|
|
970
|
+
f"cannot tell which filesystem {what} is on ({exc}); the parallel EPIC scheduler "
|
|
971
|
+
"needs one whose flock is a local kernel lock"
|
|
972
|
+
) from exc
|
|
973
|
+
refused = network_filesystem(fs)
|
|
974
|
+
if refused:
|
|
975
|
+
raise NetworkFilesystemError(
|
|
976
|
+
f"{what} is on a {refused} filesystem, whose flock is not a local kernel lock; "
|
|
977
|
+
"the parallel EPIC scheduler refuses it, because its live-ownership proof "
|
|
978
|
+
"depends on local locks (standalone commands are unaffected)"
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
@cache
|
|
983
|
+
def _libc() -> ctypes.CDLL:
|
|
984
|
+
return ctypes.CDLL(None, use_errno=True)
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
# Larger than ``struct statfs`` (and ``statfs64``) on every Linux ABI:
|
|
988
|
+
# 120 bytes on x86_64 and aarch64.
|
|
989
|
+
_LINUX_STATFS_BYTES = 512
|
|
990
|
+
|
|
991
|
+
|
|
992
|
+
def _linux_statfs(target: int | str | Path) -> FilesystemType:
|
|
993
|
+
buf = ctypes.create_string_buffer(_LINUX_STATFS_BYTES)
|
|
994
|
+
libc = _libc()
|
|
995
|
+
if isinstance(target, int):
|
|
996
|
+
rc = libc.fstatfs(ctypes.c_int(target), buf)
|
|
997
|
+
else:
|
|
998
|
+
rc = libc.statfs(os.fsencode(target), buf)
|
|
999
|
+
if rc != 0:
|
|
1000
|
+
err = ctypes.get_errno()
|
|
1001
|
+
raise OSError(err, os.strerror(err))
|
|
1002
|
+
# ``f_type`` is the first member everywhere: a ``long`` (``__fsword_t``),
|
|
1003
|
+
# except on s390, where it is an ``unsigned int``. Every magic number
|
|
1004
|
+
# fits in 32 bits.
|
|
1005
|
+
if platform.machine().startswith("s390"):
|
|
1006
|
+
magic = ctypes.c_uint.from_buffer(buf).value
|
|
1007
|
+
else:
|
|
1008
|
+
magic = ctypes.c_long.from_buffer(buf).value
|
|
1009
|
+
return FilesystemType(magic=magic & 0xFFFFFFFF)
|
|
1010
|
+
|
|
1011
|
+
|
|
1012
|
+
class _DarwinStatfs(ctypes.Structure):
|
|
1013
|
+
"""macOS ``struct statfs`` with 64-bit inodes (the layout of ``statfs$INODE64``)."""
|
|
1014
|
+
|
|
1015
|
+
_fields_ = [
|
|
1016
|
+
("f_bsize", ctypes.c_uint32),
|
|
1017
|
+
("f_iosize", ctypes.c_int32),
|
|
1018
|
+
("f_blocks", ctypes.c_uint64),
|
|
1019
|
+
("f_bfree", ctypes.c_uint64),
|
|
1020
|
+
("f_bavail", ctypes.c_uint64),
|
|
1021
|
+
("f_files", ctypes.c_uint64),
|
|
1022
|
+
("f_ffree", ctypes.c_uint64),
|
|
1023
|
+
("f_fsid", ctypes.c_int32 * 2),
|
|
1024
|
+
("f_owner", ctypes.c_uint32),
|
|
1025
|
+
("f_type", ctypes.c_uint32),
|
|
1026
|
+
("f_flags", ctypes.c_uint32),
|
|
1027
|
+
("f_fssubtype", ctypes.c_uint32),
|
|
1028
|
+
("f_fstypename", ctypes.c_char * 16),
|
|
1029
|
+
("f_mntonname", ctypes.c_char * 1024),
|
|
1030
|
+
("f_mntfromname", ctypes.c_char * 1024),
|
|
1031
|
+
("f_flags_ext", ctypes.c_uint32),
|
|
1032
|
+
("f_reserved", ctypes.c_uint32 * 7),
|
|
1033
|
+
]
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def _darwin_statfs(target: int | str | Path) -> FilesystemType: # pragma: no cover - macOS
|
|
1037
|
+
libc = _libc()
|
|
1038
|
+
name = "fstatfs" if isinstance(target, int) else "statfs"
|
|
1039
|
+
try:
|
|
1040
|
+
# x86_64 exports the 64-bit-inode layout under this name; arm64 has
|
|
1041
|
+
# only that layout, under the plain name.
|
|
1042
|
+
func = libc[f"{name}$INODE64"]
|
|
1043
|
+
except AttributeError:
|
|
1044
|
+
func = libc[name]
|
|
1045
|
+
buf = _DarwinStatfs()
|
|
1046
|
+
arg = ctypes.c_int(target) if isinstance(target, int) else os.fsencode(target)
|
|
1047
|
+
if func(arg, ctypes.byref(buf)) != 0:
|
|
1048
|
+
err = ctypes.get_errno()
|
|
1049
|
+
raise OSError(err, os.strerror(err))
|
|
1050
|
+
return FilesystemType(name=buf.f_fstypename.decode("utf-8", "replace"))
|