autoforge-cli 0.1.0a0__py3-none-any.whl

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