syncmill 0.2.0__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.
- syncmill/__init__.py +11 -0
- syncmill/agents.py +2064 -0
- syncmill/artifact_io.py +201 -0
- syncmill/artifacts.py +1946 -0
- syncmill/board.py +2060 -0
- syncmill/breaker.py +327 -0
- syncmill/cli.py +4986 -0
- syncmill/command_errors.py +32 -0
- syncmill/config.py +1118 -0
- syncmill/dashboard.py +935 -0
- syncmill/gates.py +179 -0
- syncmill/gateway.py +234 -0
- syncmill/git_env.py +99 -0
- syncmill/isolated_session.py +431 -0
- syncmill/matcher.py +127 -0
- syncmill/memory.py +1067 -0
- syncmill/memory_backend.py +1907 -0
- syncmill/memory_migration.py +962 -0
- syncmill/model_routing.py +482 -0
- syncmill/models.py +244 -0
- syncmill/monitor.py +775 -0
- syncmill/phoenix_exporter.py +339 -0
- syncmill/planning.py +512 -0
- syncmill/platform_support.py +14 -0
- syncmill/preflight.py +1129 -0
- syncmill/project.py +1067 -0
- syncmill/report.py +502 -0
- syncmill/review_candidates.py +107 -0
- syncmill/room_compare.py +2380 -0
- syncmill/room_raw.py +484 -0
- syncmill/room_viewer.py +604 -0
- syncmill/rooms.py +4044 -0
- syncmill/session.py +444 -0
- syncmill/strategies/__init__.py +82 -0
- syncmill/strategies/base.py +910 -0
- syncmill/strategies/compete.py +233 -0
- syncmill/strategies/council.py +429 -0
- syncmill/strategies/decompose.py +570 -0
- syncmill/strategies/pipeline.py +221 -0
- syncmill/strategies/route.py +130 -0
- syncmill/supervisor.py +1423 -0
- syncmill/teamfile.py +571 -0
- syncmill/telemetry_hook.py +231 -0
- syncmill/terminal.py +245 -0
- syncmill/tracer.py +548 -0
- syncmill/tui.py +3537 -0
- syncmill/tui_collaboration.py +499 -0
- syncmill/tui_jobs.py +1407 -0
- syncmill/tui_render.py +156 -0
- syncmill/workflow.py +3746 -0
- syncmill/worktree.py +1045 -0
- syncmill-0.2.0.dist-info/METADATA +853 -0
- syncmill-0.2.0.dist-info/RECORD +56 -0
- syncmill-0.2.0.dist-info/WHEEL +4 -0
- syncmill-0.2.0.dist-info/entry_points.txt +2 -0
- syncmill-0.2.0.dist-info/licenses/LICENSE +191 -0
syncmill/artifacts.py
ADDED
|
@@ -0,0 +1,1946 @@
|
|
|
1
|
+
"""Mandatory, durable run artifacts and explicit patch application."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import contextlib
|
|
9
|
+
import os
|
|
10
|
+
import stat
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable, Collection, Iterator
|
|
16
|
+
from contextlib import ExitStack, contextmanager
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from functools import wraps
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
from uuid import uuid4
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
import fcntl
|
|
26
|
+
except ImportError: # pragma: no cover - exercised on Windows
|
|
27
|
+
fcntl = None # type: ignore[assignment]
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
import msvcrt
|
|
31
|
+
except ImportError: # pragma: no cover - exercised on POSIX
|
|
32
|
+
msvcrt = None # type: ignore[assignment]
|
|
33
|
+
|
|
34
|
+
from syncmill.artifact_io import ArtifactIO, validate_id
|
|
35
|
+
from syncmill.platform_support import require_supported_platform
|
|
36
|
+
from syncmill.command_errors import command_error
|
|
37
|
+
from syncmill.models import Task, TaskResult, TaskStatus
|
|
38
|
+
from syncmill.git_env import isolated_git_env
|
|
39
|
+
from syncmill.gates import CommandGate
|
|
40
|
+
from syncmill.worktree import (
|
|
41
|
+
WorktreePool,
|
|
42
|
+
WorkspaceError,
|
|
43
|
+
_run_git,
|
|
44
|
+
capture_repository_snapshot,
|
|
45
|
+
repository_fingerprint,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
#: Hang bound for every git subprocess on the apply/rollback path (via
|
|
49
|
+
#: worktree._run_git, the owned-group runner — a wedged git can no longer hang
|
|
50
|
+
#: an apply forever, and its filter/fsmonitor children die with it). Matches
|
|
51
|
+
#: _run_git's own default: hang protection, not a latency bound.
|
|
52
|
+
_APPLY_GIT_TIMEOUT_SECONDS = 600.0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _git_timed_out(proc: subprocess.CompletedProcess) -> bool:
|
|
56
|
+
"""Exact _run_git-timeout detection: rc 124 AND its timeout note.
|
|
57
|
+
|
|
58
|
+
``git apply`` fails with 1/128, never 124 in practice, but the stderr note
|
|
59
|
+
check makes this exact rather than convention-based — a timed-out apply
|
|
60
|
+
routes into recovery handling, never into a site's ordinary-failure idiom.
|
|
61
|
+
"""
|
|
62
|
+
return proc.returncode == 124 and str(proc.stderr).startswith(
|
|
63
|
+
"git command timed out"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _run_patch_git(patch: bytes, cmd: list[str], **kwargs):
|
|
68
|
+
"""Use exactly the digest-verified bytes, including non-UTF8 filenames."""
|
|
69
|
+
kwargs.pop("text", None)
|
|
70
|
+
result = _run_git(cmd, input=patch, **kwargs)
|
|
71
|
+
for field in ("stdout", "stderr"):
|
|
72
|
+
value = getattr(result, field)
|
|
73
|
+
if isinstance(value, bytes):
|
|
74
|
+
setattr(result, field, value.decode("utf-8", errors="replace"))
|
|
75
|
+
return result
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ArtifactError(RuntimeError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class ArtifactRecoveryRequired(ArtifactError):
|
|
83
|
+
"""Apply could not restore a single trustworthy source/artifact state."""
|
|
84
|
+
|
|
85
|
+
def __init__(self, message: str, *, source_changed: bool):
|
|
86
|
+
super().__init__(message)
|
|
87
|
+
self.source_changed = source_changed
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ApplyLockBusy(ArtifactError):
|
|
91
|
+
"""A non-blocking apply-lock acquisition found the lock already held."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if "_APPLY_LOCKS_GUARD" not in globals():
|
|
95
|
+
_APPLY_LOCKS_GUARD = threading.Lock()
|
|
96
|
+
if "_APPLY_PROCESS_LOCKS" not in globals():
|
|
97
|
+
_APPLY_PROCESS_LOCKS: dict[Path, threading.RLock] = {}
|
|
98
|
+
if "_APPLY_THREAD_STATE" not in globals():
|
|
99
|
+
_APPLY_THREAD_STATE = threading.local()
|
|
100
|
+
if "_APPLY_FORK_GUARD" not in globals():
|
|
101
|
+
_APPLY_FORK_GUARD = threading.RLock()
|
|
102
|
+
if "_APPLY_LOCK_FDS" not in globals():
|
|
103
|
+
_APPLY_LOCK_FDS: set[int] = set()
|
|
104
|
+
if "_APPLY_AT_FORK_REGISTERED" not in globals():
|
|
105
|
+
_APPLY_AT_FORK_REGISTERED = False
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _before_apply_fork() -> None:
|
|
109
|
+
"""Keep lock descriptors and their registry consistent across fork."""
|
|
110
|
+
_APPLY_FORK_GUARD.acquire()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _after_apply_fork_parent() -> None:
|
|
114
|
+
_APPLY_FORK_GUARD.release()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _after_apply_fork_child() -> None:
|
|
118
|
+
"""Drop inherited advisory locks and unusable thread state in the child."""
|
|
119
|
+
global _APPLY_FORK_GUARD
|
|
120
|
+
global _APPLY_LOCK_FDS
|
|
121
|
+
global _APPLY_LOCKS_GUARD
|
|
122
|
+
global _APPLY_PROCESS_LOCKS
|
|
123
|
+
global _APPLY_THREAD_STATE
|
|
124
|
+
|
|
125
|
+
inherited_fds = _APPLY_LOCK_FDS
|
|
126
|
+
_APPLY_LOCK_FDS = set()
|
|
127
|
+
for fd in inherited_fds:
|
|
128
|
+
try:
|
|
129
|
+
# Do not explicitly unlock: the parent still owns the shared open
|
|
130
|
+
# file description and must retain its advisory lock.
|
|
131
|
+
os.close(fd)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
# Locks owned by threads that no longer exist cannot safely be acquired or
|
|
136
|
+
# released. Replace every piece of process-local bookkeeping instead.
|
|
137
|
+
_APPLY_FORK_GUARD = threading.RLock()
|
|
138
|
+
_APPLY_LOCKS_GUARD = threading.Lock()
|
|
139
|
+
_APPLY_PROCESS_LOCKS = {}
|
|
140
|
+
_APPLY_THREAD_STATE = threading.local()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
register_at_fork = getattr(os, "register_at_fork", None)
|
|
144
|
+
if register_at_fork is not None and not _APPLY_AT_FORK_REGISTERED:
|
|
145
|
+
register_at_fork(
|
|
146
|
+
before=_before_apply_fork,
|
|
147
|
+
after_in_parent=_after_apply_fork_parent,
|
|
148
|
+
after_in_child=_after_apply_fork_child,
|
|
149
|
+
)
|
|
150
|
+
_APPLY_AT_FORK_REGISTERED = True
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _now() -> str:
|
|
154
|
+
return datetime.now(UTC).isoformat()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _digest(data: bytes) -> str:
|
|
158
|
+
return hashlib.sha256(data).hexdigest()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Apply receipts are a few hundred bytes; anything far larger is a bad file, not
|
|
162
|
+
# evidence — the reconciler skips it without parsing (a cheap DoS/oversize guard).
|
|
163
|
+
_MAX_RECEIPT_BYTES = 64 * 1024
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _worker_alive(lease_owner: str | None) -> bool:
|
|
167
|
+
"""Best-effort local PID check for an expired detached apply lease.
|
|
168
|
+
|
|
169
|
+
The owner string is ``"{pid}-{token}"`` (the same format rooms and the
|
|
170
|
+
apply manifest both write), so one parser serves both callers. A missing
|
|
171
|
+
or unparsable owner reports dead, matching the recovery precedent that a
|
|
172
|
+
strand we cannot prove live is safe to reconcile.
|
|
173
|
+
"""
|
|
174
|
+
try:
|
|
175
|
+
pid = int((lease_owner or "").split("-", 1)[0])
|
|
176
|
+
if pid <= 0 or pid > 0xFFFFFFFF:
|
|
177
|
+
return False
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
# On Windows, ``os.kill(pid, 0)`` is destructive: signals other than the
|
|
182
|
+
# console control events are implemented with TerminateProcess. Use a
|
|
183
|
+
# read-only process handle there, and never touch the POSIX zombie APIs.
|
|
184
|
+
if os.name == "nt":
|
|
185
|
+
return _windows_worker_alive(pid)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
os.kill(pid, 0)
|
|
189
|
+
except ProcessLookupError:
|
|
190
|
+
return False
|
|
191
|
+
except PermissionError:
|
|
192
|
+
return True
|
|
193
|
+
if os.name != "posix":
|
|
194
|
+
return True
|
|
195
|
+
# A zombie still responds to ``kill(pid, 0)``. Detached workers may be
|
|
196
|
+
# direct children of the caller, so a non-blocking wait both detects and
|
|
197
|
+
# reaps that state before recovery decides the worker is alive.
|
|
198
|
+
try:
|
|
199
|
+
waited, _ = os.waitpid(pid, os.WNOHANG)
|
|
200
|
+
except (ChildProcessError, OSError):
|
|
201
|
+
# The background Popen waiter may have reaped the child between kill(0)
|
|
202
|
+
# and waitpid(). Probe once more instead of reporting that dead child as
|
|
203
|
+
# alive until the next explicit recovery command.
|
|
204
|
+
try:
|
|
205
|
+
os.kill(pid, 0)
|
|
206
|
+
except ProcessLookupError:
|
|
207
|
+
return False
|
|
208
|
+
except PermissionError:
|
|
209
|
+
return True
|
|
210
|
+
except OSError:
|
|
211
|
+
return False
|
|
212
|
+
return True
|
|
213
|
+
else:
|
|
214
|
+
if waited == pid:
|
|
215
|
+
return False
|
|
216
|
+
return True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _windows_worker_alive(pid: int) -> bool:
|
|
220
|
+
"""Probe one Windows PID without signaling or mutating the process."""
|
|
221
|
+
import ctypes
|
|
222
|
+
from ctypes import wintypes
|
|
223
|
+
|
|
224
|
+
process_query_limited_information = 0x1000
|
|
225
|
+
synchronize = 0x00100000
|
|
226
|
+
error_invalid_parameter = 87
|
|
227
|
+
wait_object_0 = 0
|
|
228
|
+
wait_timeout = 0x00000102
|
|
229
|
+
try:
|
|
230
|
+
kernel32 = getattr(ctypes, "WinDLL")("kernel32", use_last_error=True)
|
|
231
|
+
get_last_error = getattr(ctypes, "get_last_error")
|
|
232
|
+
except (AttributeError, OSError):
|
|
233
|
+
# An unavailable system probe must fail closed: a possibly live owner
|
|
234
|
+
# is safer than recovering and racing its apply.
|
|
235
|
+
return True
|
|
236
|
+
|
|
237
|
+
open_process = kernel32.OpenProcess
|
|
238
|
+
open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD]
|
|
239
|
+
open_process.restype = wintypes.HANDLE
|
|
240
|
+
wait_for_single_object = kernel32.WaitForSingleObject
|
|
241
|
+
wait_for_single_object.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
242
|
+
wait_for_single_object.restype = wintypes.DWORD
|
|
243
|
+
close_handle = kernel32.CloseHandle
|
|
244
|
+
close_handle.argtypes = [wintypes.HANDLE]
|
|
245
|
+
close_handle.restype = wintypes.BOOL
|
|
246
|
+
|
|
247
|
+
handle = open_process(
|
|
248
|
+
process_query_limited_information | synchronize, False, pid
|
|
249
|
+
)
|
|
250
|
+
if not handle:
|
|
251
|
+
# ERROR_INVALID_PARAMETER is the documented missing-PID result. Access
|
|
252
|
+
# denial and every other uncertain probe failure must fail closed so a
|
|
253
|
+
# possibly live owner is never raced by recovery.
|
|
254
|
+
return get_last_error() != error_invalid_parameter
|
|
255
|
+
try:
|
|
256
|
+
wait_result = wait_for_single_object(handle, 0)
|
|
257
|
+
if wait_result == wait_object_0:
|
|
258
|
+
return False
|
|
259
|
+
if wait_result == wait_timeout:
|
|
260
|
+
return True
|
|
261
|
+
return True
|
|
262
|
+
finally:
|
|
263
|
+
close_handle(handle)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _lock_file_descriptor(fd: int, *, blocking: bool = True) -> None:
|
|
267
|
+
"""Acquire an inherited-safe advisory lock.
|
|
268
|
+
|
|
269
|
+
Blocking waits until the lock is available. Non-blocking raises
|
|
270
|
+
``ApplyLockBusy`` when another holder owns it (the only "contended" signal),
|
|
271
|
+
and propagates every other error unchanged so a genuine locking failure is
|
|
272
|
+
never misread as contention.
|
|
273
|
+
"""
|
|
274
|
+
if fcntl is not None:
|
|
275
|
+
if blocking:
|
|
276
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
277
|
+
return
|
|
278
|
+
while True:
|
|
279
|
+
try:
|
|
280
|
+
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
281
|
+
return
|
|
282
|
+
except BlockingIOError as exc:
|
|
283
|
+
raise ApplyLockBusy("apply lock is held") from exc
|
|
284
|
+
except OSError as exc:
|
|
285
|
+
if exc.errno == errno.EINTR:
|
|
286
|
+
continue
|
|
287
|
+
if exc.errno in {errno.EACCES, errno.EAGAIN, errno.EWOULDBLOCK}:
|
|
288
|
+
raise ApplyLockBusy("apply lock is held") from exc
|
|
289
|
+
raise
|
|
290
|
+
if msvcrt is None: # pragma: no cover - every supported platform has one
|
|
291
|
+
raise ArtifactError("cross-process apply locking is unavailable")
|
|
292
|
+
|
|
293
|
+
if os.fstat(fd).st_size == 0: # Windows byte-range locks need a real byte.
|
|
294
|
+
os.write(fd, b"\0")
|
|
295
|
+
os.fsync(fd)
|
|
296
|
+
while True: # LK_LOCK gives up after ten seconds; an apply may verify longer.
|
|
297
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
298
|
+
try:
|
|
299
|
+
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
|
|
300
|
+
return
|
|
301
|
+
except OSError as exc: # pragma: no cover - exercised on Windows
|
|
302
|
+
if exc.errno not in {errno.EACCES, errno.EAGAIN, errno.EDEADLK}:
|
|
303
|
+
raise
|
|
304
|
+
if not blocking:
|
|
305
|
+
raise ApplyLockBusy("apply lock is held") from exc
|
|
306
|
+
time.sleep(0.05)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _unlock_file_descriptor(fd: int) -> None:
|
|
310
|
+
if fcntl is not None:
|
|
311
|
+
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
312
|
+
return
|
|
313
|
+
if msvcrt is not None: # pragma: no branch - paired with acquisition
|
|
314
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
315
|
+
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _open_apply_lock_descriptor(
|
|
319
|
+
lock_path: Path, opener: Callable[[Path, int], int] | None = None,
|
|
320
|
+
) -> tuple[int, int]:
|
|
321
|
+
"""Open and register a lock fd atomically with respect to fork."""
|
|
322
|
+
with _APPLY_FORK_GUARD:
|
|
323
|
+
fd = (
|
|
324
|
+
opener(lock_path, os.O_CREAT | os.O_RDWR) if opener else
|
|
325
|
+
os.open(str(lock_path), os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
326
|
+
)
|
|
327
|
+
try:
|
|
328
|
+
owner_pid = os.getpid()
|
|
329
|
+
_APPLY_LOCK_FDS.add(fd)
|
|
330
|
+
except BaseException:
|
|
331
|
+
os.close(fd)
|
|
332
|
+
raise
|
|
333
|
+
return fd, owner_pid
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _close_apply_lock_descriptor(fd: int) -> None:
|
|
337
|
+
"""Close and unregister a lock fd atomically with respect to fork."""
|
|
338
|
+
with _APPLY_FORK_GUARD:
|
|
339
|
+
try:
|
|
340
|
+
os.close(fd)
|
|
341
|
+
finally:
|
|
342
|
+
_APPLY_LOCK_FDS.discard(fd)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@contextmanager
|
|
346
|
+
def _cross_process_lock(
|
|
347
|
+
lock_path: Path, *, blocking: bool = True,
|
|
348
|
+
opener: Callable[[Path, int], int] | None = None,
|
|
349
|
+
) -> Iterator[None]:
|
|
350
|
+
"""Hold one re-entrant in-process and advisory cross-process file lock.
|
|
351
|
+
|
|
352
|
+
With ``blocking=False`` a cross-process holder raises ``ApplyLockBusy``
|
|
353
|
+
rather than waiting; a re-entrant same-thread hold always succeeds.
|
|
354
|
+
"""
|
|
355
|
+
with _APPLY_LOCKS_GUARD:
|
|
356
|
+
process_lock = _APPLY_PROCESS_LOCKS.setdefault(
|
|
357
|
+
lock_path, threading.RLock()
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
# A non-blocking acquire must not wait on the in-process RLock either: if
|
|
361
|
+
# another thread of this process holds it, that is contention.
|
|
362
|
+
if not process_lock.acquire(blocking=blocking):
|
|
363
|
+
raise ApplyLockBusy(f"apply lock {lock_path} is held")
|
|
364
|
+
try:
|
|
365
|
+
held: dict[Path, int] | None = getattr(_APPLY_THREAD_STATE, "held", None)
|
|
366
|
+
if held is None:
|
|
367
|
+
held = {}
|
|
368
|
+
_APPLY_THREAD_STATE.held = held
|
|
369
|
+
if lock_path in held:
|
|
370
|
+
held[lock_path] += 1
|
|
371
|
+
try:
|
|
372
|
+
yield
|
|
373
|
+
finally:
|
|
374
|
+
held[lock_path] -= 1
|
|
375
|
+
return
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
fd, owner_pid = (
|
|
379
|
+
_open_apply_lock_descriptor(lock_path, opener) if opener
|
|
380
|
+
else _open_apply_lock_descriptor(lock_path)
|
|
381
|
+
)
|
|
382
|
+
except OSError as exc:
|
|
383
|
+
raise ArtifactError(f"could not open apply lock {lock_path}: {exc}") from exc
|
|
384
|
+
try:
|
|
385
|
+
# `os.fchmod` is Unix-only: on Windows it does not exist at all, and
|
|
386
|
+
# an AttributeError is outside the OSError boundary this module
|
|
387
|
+
# converts, so it would escape `__enter__` and fail every locked
|
|
388
|
+
# operation before the lock is even taken (`_restore_mode` in
|
|
389
|
+
# teamfile.py hit the same edge). No path fallback is needed here
|
|
390
|
+
# the way it is there: `_open_apply_lock_descriptor` already creates
|
|
391
|
+
# the file 0600, the lock file holds nothing, and Windows honours
|
|
392
|
+
# only the read-only bit regardless.
|
|
393
|
+
fchmod = getattr(os, "fchmod", None)
|
|
394
|
+
if fchmod is not None:
|
|
395
|
+
try:
|
|
396
|
+
fchmod(fd, 0o600)
|
|
397
|
+
except OSError: # pragma: no cover - permission model is platform-specific
|
|
398
|
+
pass
|
|
399
|
+
try:
|
|
400
|
+
_lock_file_descriptor(fd, blocking=blocking)
|
|
401
|
+
except ApplyLockBusy:
|
|
402
|
+
raise
|
|
403
|
+
except OSError as exc:
|
|
404
|
+
raise ArtifactError(
|
|
405
|
+
f"could not acquire apply lock {lock_path}: {exc}"
|
|
406
|
+
) from exc
|
|
407
|
+
held[lock_path] = 1
|
|
408
|
+
try:
|
|
409
|
+
yield
|
|
410
|
+
finally:
|
|
411
|
+
held.pop(lock_path, None)
|
|
412
|
+
if os.getpid() == owner_pid:
|
|
413
|
+
_unlock_file_descriptor(fd)
|
|
414
|
+
finally:
|
|
415
|
+
if os.getpid() == owner_pid:
|
|
416
|
+
_close_apply_lock_descriptor(fd)
|
|
417
|
+
finally:
|
|
418
|
+
process_lock.release()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
@dataclass(frozen=True)
|
|
422
|
+
class RunArtifact:
|
|
423
|
+
run_id: str
|
|
424
|
+
path: Path
|
|
425
|
+
manifest: dict[str, Any]
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@dataclass(frozen=True)
|
|
429
|
+
class SkippedRun:
|
|
430
|
+
"""A run-store entry a listing could not load (#170).
|
|
431
|
+
|
|
432
|
+
``name`` is the raw directory entry (render it through
|
|
433
|
+
:func:`printable_name` — run ids may carry control characters); ``reason``
|
|
434
|
+
is a fixed category that never embeds the entry name, a path, or manifest
|
|
435
|
+
content, so a hostile name cannot forge a diagnostic line.
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
name: str
|
|
439
|
+
reason: str
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@dataclass(frozen=True)
|
|
443
|
+
class ArtifactListing:
|
|
444
|
+
"""Healthy runs (newest first) plus the entries omitted from them.
|
|
445
|
+
|
|
446
|
+
Listing results are shared snapshots: a ``RunArtifact`` may be served again
|
|
447
|
+
by the next listing while its manifest is unchanged on disk (#168), so
|
|
448
|
+
callers treat ``manifest`` as read-only.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
artifacts: list[RunArtifact]
|
|
452
|
+
skipped: list[SkippedRun]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# Upper bound on manifests a store instance keeps parsed between listings. A
|
|
456
|
+
# bound, not an LRU: beyond it the oldest cached entries are evicted and simply
|
|
457
|
+
# re-read next time. Only a long-lived store (the TUI) ever fills it.
|
|
458
|
+
_LISTING_CACHE_MAX = 4096
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def printable_name(value: str) -> str:
|
|
462
|
+
"""Escape control characters in a run-store entry name for display.
|
|
463
|
+
|
|
464
|
+
``validate_id`` only forbids separators, so a directory name may contain a
|
|
465
|
+
newline or an escape sequence; echoed raw it could forge a second
|
|
466
|
+
diagnostic line or drive the terminal. Printable text (including non-ASCII)
|
|
467
|
+
is kept as is.
|
|
468
|
+
"""
|
|
469
|
+
out: list[str] = []
|
|
470
|
+
for ch in value:
|
|
471
|
+
if ch.isprintable():
|
|
472
|
+
out.append(ch)
|
|
473
|
+
elif ord(ch) < 0x100:
|
|
474
|
+
out.append(f"\\x{ord(ch):02x}")
|
|
475
|
+
else:
|
|
476
|
+
out.append(f"\\u{ord(ch):04x}")
|
|
477
|
+
return "".join(out)
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def printable_text(value: str) -> str:
|
|
481
|
+
"""Escape a multi-line body (agent output, an error, a prompt) for display.
|
|
482
|
+
|
|
483
|
+
The line structure survives: LF and TAB stay literal. A byte that was not
|
|
484
|
+
valid UTF-8 (a ``surrogateescape`` surrogate) is shown as ``\\xNN``, and every
|
|
485
|
+
other character ``printable_name`` escapes is escaped the same way, so an
|
|
486
|
+
agent's text cannot drive the terminal (C1 CSI, OSC, bidi overrides) or
|
|
487
|
+
overwrite a line with CR. A consequence: ANSI colour in agent output shows
|
|
488
|
+
as ``\\x1b[...`` text.
|
|
489
|
+
"""
|
|
490
|
+
out: list[str] = []
|
|
491
|
+
for ch in value:
|
|
492
|
+
if "\udc80" <= ch <= "\udcff":
|
|
493
|
+
out.append(f"\\x{ord(ch) - 0xDC00:02x}")
|
|
494
|
+
elif ch in "\n\t" or ch.isprintable():
|
|
495
|
+
out.append(ch)
|
|
496
|
+
else:
|
|
497
|
+
out.append(printable_name(ch))
|
|
498
|
+
return "".join(out)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def terminal_json(value: Any, *, indent: int = 2, sort_keys: bool = False) -> str:
|
|
502
|
+
"""JSON for a terminal: ``json.dumps`` with non-ASCII kept literal, except
|
|
503
|
+
that every character ``printable_name`` would escape is written as a
|
|
504
|
+
``\\uXXXX`` escape.
|
|
505
|
+
|
|
506
|
+
``ensure_ascii=False`` escapes C0 controls only, so a stored, partly
|
|
507
|
+
agent-chosen string could carry C1 controls (U+009B is CSI) or bidi
|
|
508
|
+
overrides to the terminal. Here ``json.dumps`` has already escaped every C0
|
|
509
|
+
control inside strings, so the only raw non-printable left outside a string
|
|
510
|
+
is the newline of ``indent``; every other one is inside a string literal,
|
|
511
|
+
where its escape decodes to the same character. The result decodes exactly
|
|
512
|
+
as ``ensure_ascii=True`` output does, while Korean stays readable. That
|
|
513
|
+
holds only with the default separators and an integer indent, which is why
|
|
514
|
+
neither is configurable.
|
|
515
|
+
"""
|
|
516
|
+
if isinstance(indent, bool) or not isinstance(indent, int):
|
|
517
|
+
raise TypeError("indent must be an int")
|
|
518
|
+
if indent < 0:
|
|
519
|
+
raise ValueError("indent must not be negative")
|
|
520
|
+
text = json.dumps(value, ensure_ascii=False, indent=indent, sort_keys=sort_keys)
|
|
521
|
+
out: list[str] = []
|
|
522
|
+
for ch in text:
|
|
523
|
+
if ch == "\n" or ch.isprintable():
|
|
524
|
+
out.append(ch)
|
|
525
|
+
continue
|
|
526
|
+
code = ord(ch)
|
|
527
|
+
if code > 0xFFFF:
|
|
528
|
+
code -= 0x10000
|
|
529
|
+
out.append(f"\\u{0xD800 + (code >> 10):04x}\\u{0xDC00 + (code & 0x3FF):04x}")
|
|
530
|
+
else:
|
|
531
|
+
out.append(f"\\u{code:04x}")
|
|
532
|
+
return "".join(out)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _skip_reason(exc: ArtifactError) -> str:
|
|
536
|
+
message = str(exc)
|
|
537
|
+
if message.startswith("unknown or corrupt run"):
|
|
538
|
+
return "corrupt or unreadable manifest"
|
|
539
|
+
if "mismatch" in message:
|
|
540
|
+
return "manifest identity mismatch"
|
|
541
|
+
if message.startswith("invalid run ID"):
|
|
542
|
+
return "invalid run id"
|
|
543
|
+
return "unreadable"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def is_room_bound(manifest: dict[str, Any]) -> bool:
|
|
547
|
+
"""Room-bound runs are reconciled/applied by the room store, never
|
|
548
|
+
standalone. Public so viewers (the TUI Artifacts pane) can mark them."""
|
|
549
|
+
if manifest.get("strategy") == "room":
|
|
550
|
+
return True
|
|
551
|
+
plan = manifest.get("plan") or {}
|
|
552
|
+
return isinstance(plan, dict) and plan.get("origin") == "room"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def workflow_origin_id(manifest: dict[str, Any]) -> str | None:
|
|
556
|
+
"""The workflow a run artifact came from, or ``None`` if it came from none.
|
|
557
|
+
|
|
558
|
+
The manifest is what an artifact SAYS about itself, so it — not the shape of
|
|
559
|
+
the run id — decides whether a workflow has to approve applying it. A
|
|
560
|
+
workflow-origin artifact carrying no usable id returns the empty string: it
|
|
561
|
+
is still workflow-bound, and a caller must not read "no workflow to ask" out
|
|
562
|
+
of a missing field (`is_room_bound` reads the same block the same way).
|
|
563
|
+
"""
|
|
564
|
+
plan = manifest.get("plan") or {}
|
|
565
|
+
if not isinstance(plan, dict) or plan.get("origin") != "workflow":
|
|
566
|
+
return None
|
|
567
|
+
workflow_id = plan.get("workflow_id")
|
|
568
|
+
return workflow_id if isinstance(workflow_id, str) else ""
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def strip_approval(plan: dict[str, Any] | None) -> dict[str, Any]:
|
|
572
|
+
"""A producer-supplied plan with any approval claim removed.
|
|
573
|
+
|
|
574
|
+
Approval is written by exactly one method, after a workflow has committed
|
|
575
|
+
its completion. Everything else that builds an artifact — including the
|
|
576
|
+
workflow coordinator itself — passes its plan through here, so "the run was
|
|
577
|
+
approved" can never be something a producer simply asserted at save time.
|
|
578
|
+
"""
|
|
579
|
+
return {
|
|
580
|
+
key: value for key, value in (plan or {}).items() if key != "approved"
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def workflow_approved(manifest: dict[str, Any]) -> bool:
|
|
585
|
+
"""Whether the workflow that produced this run recorded its completion.
|
|
586
|
+
|
|
587
|
+
A workflow publishes its final artifact BEFORE the lease-fenced write that
|
|
588
|
+
commits the completion, so for a moment the artifact exists for a workflow
|
|
589
|
+
that may still end up cancelled. The approval is written after that commit
|
|
590
|
+
succeeds, which makes the artifact carry its own authorization instead of
|
|
591
|
+
depending on a workflow row that ``workflow prune`` is entitled to delete.
|
|
592
|
+
|
|
593
|
+
It is a RECORD, not a flag, and it is checked against the artifact it sits
|
|
594
|
+
in: it names the workflow that granted it, the run it was granted for, and
|
|
595
|
+
the digest of the patch that was approved. A bare boolean would authorize
|
|
596
|
+
whatever it was pasted onto — an artifact copied from another project, a
|
|
597
|
+
run whose patch was replaced afterwards, or a manifest that simply claimed
|
|
598
|
+
it. Each of those now fails a comparison against the surrounding manifest.
|
|
599
|
+
|
|
600
|
+
What this cannot do is turn approval into a secret. Anyone able to write
|
|
601
|
+
files into the run directory can write a consistent record, just as they
|
|
602
|
+
could write an ordinary artifact with any patch and apply that — a boundary
|
|
603
|
+
this guard was never standing on. It rules out the accidents that actually
|
|
604
|
+
happen: a producer asserting its own approval, a copied or re-published
|
|
605
|
+
artifact carrying someone else's, and a patch changed after the fact.
|
|
606
|
+
"""
|
|
607
|
+
plan = manifest.get("plan")
|
|
608
|
+
if not isinstance(plan, dict):
|
|
609
|
+
return False
|
|
610
|
+
approval = plan.get("approved")
|
|
611
|
+
if not isinstance(approval, dict):
|
|
612
|
+
return False
|
|
613
|
+
workflow_id = plan.get("workflow_id")
|
|
614
|
+
return bool(
|
|
615
|
+
workflow_id
|
|
616
|
+
and approval.get("workflow_id") == workflow_id
|
|
617
|
+
and approval.get("run_id") == manifest.get("run_id")
|
|
618
|
+
and approval.get("patch_sha256") == manifest.get("patch_sha256")
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
@dataclass(frozen=True)
|
|
623
|
+
class ApplyReconciliation:
|
|
624
|
+
"""One reconciler verdict for a single interrupted ``applying`` run."""
|
|
625
|
+
|
|
626
|
+
run_id: str
|
|
627
|
+
# completed | not_applied | manual_recovery | skipped_live_owner
|
|
628
|
+
# | skipped_room_bound | skipped_not_applying | error
|
|
629
|
+
outcome: str
|
|
630
|
+
detail: str
|
|
631
|
+
receipt: str | None = None
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
# The full run-manifest state vocabulary. The reconciler uses it to tell a
|
|
635
|
+
# benign non-applying run from a corrupt object manifest whose state is
|
|
636
|
+
# missing or unrecognized — the latter COULD be a mangled applying strand, so
|
|
637
|
+
# it must surface as an error row, never a silent skip.
|
|
638
|
+
_RUN_STATES = frozenset(
|
|
639
|
+
{
|
|
640
|
+
"running",
|
|
641
|
+
"pending",
|
|
642
|
+
"applying",
|
|
643
|
+
"applied",
|
|
644
|
+
"no_changes",
|
|
645
|
+
"failed",
|
|
646
|
+
"discarded",
|
|
647
|
+
"superseded",
|
|
648
|
+
"recovery_required",
|
|
649
|
+
}
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _artifact_access(method):
|
|
654
|
+
@wraps(method)
|
|
655
|
+
def wrapped(self, *args, **kwargs):
|
|
656
|
+
with self._access():
|
|
657
|
+
# A public call owns only handles it opened. Nested mutation steps
|
|
658
|
+
# retain handles pinned by their caller until that caller finishes.
|
|
659
|
+
pinned = set(self._io.fds)
|
|
660
|
+
try:
|
|
661
|
+
return method(self, *args, **kwargs)
|
|
662
|
+
finally:
|
|
663
|
+
for path in reversed(list(self._io.fds)):
|
|
664
|
+
if path not in pinned:
|
|
665
|
+
self._io.release(path)
|
|
666
|
+
return wrapped
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
class ArtifactStore:
|
|
670
|
+
def __init__(self, root: Path, project_id: str):
|
|
671
|
+
require_supported_platform()
|
|
672
|
+
try:
|
|
673
|
+
validate_id(project_id)
|
|
674
|
+
self._base = root.expanduser().resolve()
|
|
675
|
+
self._base.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
676
|
+
self.root = self._base / project_id / "runs"
|
|
677
|
+
self._local = threading.local()
|
|
678
|
+
# name -> (manifest signature, parsed artifact); see list_detailed
|
|
679
|
+
self._listing_cache: dict[str, tuple[tuple, RunArtifact]] = {}
|
|
680
|
+
self._listing_lock = threading.Lock()
|
|
681
|
+
io = ArtifactIO(self._base)
|
|
682
|
+
try:
|
|
683
|
+
fd = io.directory(self.root, create=True)
|
|
684
|
+
if hasattr(os, "fchmod"):
|
|
685
|
+
os.fchmod(fd, 0o700)
|
|
686
|
+
info = os.fstat(fd)
|
|
687
|
+
self._identity = (info.st_dev, info.st_ino)
|
|
688
|
+
finally:
|
|
689
|
+
io.close()
|
|
690
|
+
except (OSError, ValueError) as exc:
|
|
691
|
+
raise ArtifactError(f"invalid artifact store: {exc}") from exc
|
|
692
|
+
|
|
693
|
+
@contextmanager
|
|
694
|
+
def _access(self) -> Iterator[None]:
|
|
695
|
+
if getattr(self._local, "io", None) is not None:
|
|
696
|
+
yield
|
|
697
|
+
return
|
|
698
|
+
io = None
|
|
699
|
+
try:
|
|
700
|
+
io = ArtifactIO(self._base)
|
|
701
|
+
info = os.fstat(io.directory(self.root))
|
|
702
|
+
if (info.st_dev, info.st_ino) != self._identity:
|
|
703
|
+
raise ArtifactError("artifact store directory changed; restart or reopen the store")
|
|
704
|
+
self._local.io = io
|
|
705
|
+
yield
|
|
706
|
+
except FileExistsError as exc:
|
|
707
|
+
raise ArtifactError(f"artifact already exists: {exc.filename}") from exc
|
|
708
|
+
except FileNotFoundError as exc:
|
|
709
|
+
raise ArtifactError(f"artifact not found: {exc.filename}") from exc
|
|
710
|
+
except (OSError, ValueError) as exc:
|
|
711
|
+
raise ArtifactError(f"unsafe or unreadable artifact: {exc}") from exc
|
|
712
|
+
finally:
|
|
713
|
+
self._local.io = None
|
|
714
|
+
if io is not None:
|
|
715
|
+
io.close()
|
|
716
|
+
|
|
717
|
+
@property
|
|
718
|
+
def _io(self) -> ArtifactIO:
|
|
719
|
+
return self._local.io
|
|
720
|
+
|
|
721
|
+
def _run_path(self, run_id: str) -> Path:
|
|
722
|
+
try:
|
|
723
|
+
validate_id(run_id)
|
|
724
|
+
except ValueError as exc:
|
|
725
|
+
raise ArtifactError(f"invalid run ID: {run_id!r}") from exc
|
|
726
|
+
return self.root / run_id
|
|
727
|
+
|
|
728
|
+
def _check_binding(self, run_id: str, manifest: dict) -> None:
|
|
729
|
+
for key, expected in (("run_id", run_id), ("project_id", self.root.parent.name)):
|
|
730
|
+
if key in manifest and manifest[key] != expected:
|
|
731
|
+
raise ArtifactError(f"artifact {key} mismatch for run {run_id}")
|
|
732
|
+
|
|
733
|
+
@_artifact_access
|
|
734
|
+
def read_result(
|
|
735
|
+
self, run_id: str, *, limit: int = -1, missing_ok: bool = False,
|
|
736
|
+
) -> bytes | None:
|
|
737
|
+
"""Read a sidecar safely; optionally distinguish absence for the TUI."""
|
|
738
|
+
artifact = self._load_pinned(run_id)
|
|
739
|
+
try:
|
|
740
|
+
return self._io.read(artifact.path / "result.json", limit)
|
|
741
|
+
except FileNotFoundError:
|
|
742
|
+
if missing_ok:
|
|
743
|
+
return None
|
|
744
|
+
raise
|
|
745
|
+
|
|
746
|
+
@staticmethod
|
|
747
|
+
def _resolve_repository_root(repo_root: Path) -> Path:
|
|
748
|
+
"""Resolve an existing repository with one stable error contract.
|
|
749
|
+
|
|
750
|
+
Python 3.13 changed ``Path.resolve(strict=False)`` so a symlink loop can
|
|
751
|
+
be returned partially unresolved instead of raising ``RuntimeError``.
|
|
752
|
+
Repositories used for apply/recovery must already exist, so strict
|
|
753
|
+
resolution is both correct and version-independent. Normalize the
|
|
754
|
+
platform/Python-specific exception before it reaches a reconciliation
|
|
755
|
+
report.
|
|
756
|
+
"""
|
|
757
|
+
candidate = repo_root.expanduser()
|
|
758
|
+
try:
|
|
759
|
+
return candidate.resolve(strict=True)
|
|
760
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
761
|
+
raise ArtifactError(
|
|
762
|
+
f"could not resolve repository root: {candidate}"
|
|
763
|
+
) from exc
|
|
764
|
+
|
|
765
|
+
@staticmethod
|
|
766
|
+
def _repository_apply_lock_path(repo_root: Path) -> Path:
|
|
767
|
+
root = ArtifactStore._resolve_repository_root(repo_root)
|
|
768
|
+
try:
|
|
769
|
+
common_dir = _run_git(
|
|
770
|
+
["git", "rev-parse", "--git-common-dir"],
|
|
771
|
+
cwd=str(root),
|
|
772
|
+
env=isolated_git_env(),
|
|
773
|
+
capture_output=True,
|
|
774
|
+
text=True,
|
|
775
|
+
check=True,
|
|
776
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
777
|
+
).stdout.strip()
|
|
778
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
779
|
+
raise ArtifactError(
|
|
780
|
+
f"could not resolve repository apply lock for {root}"
|
|
781
|
+
) from exc
|
|
782
|
+
if not common_dir:
|
|
783
|
+
raise ArtifactError(f"could not resolve repository apply lock for {root}")
|
|
784
|
+
common_path = Path(common_dir)
|
|
785
|
+
if not common_path.is_absolute():
|
|
786
|
+
common_path = root / common_path
|
|
787
|
+
try:
|
|
788
|
+
common_path = common_path.resolve(strict=True)
|
|
789
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
790
|
+
raise ArtifactError(
|
|
791
|
+
f"could not resolve repository apply lock for {root}"
|
|
792
|
+
) from exc
|
|
793
|
+
if not common_path.is_dir():
|
|
794
|
+
raise ArtifactError(
|
|
795
|
+
f"repository common directory does not exist: {common_path}"
|
|
796
|
+
)
|
|
797
|
+
return common_path / ".syncmill-apply.lock"
|
|
798
|
+
|
|
799
|
+
@contextmanager
|
|
800
|
+
def apply_lock(
|
|
801
|
+
self, repo_root: Path | None = None, *, blocking: bool = True
|
|
802
|
+
) -> Iterator[None]:
|
|
803
|
+
"""Serialize project source mutation and apply-state reconciliation.
|
|
804
|
+
|
|
805
|
+
The artifact-store lock protects manifest state. When ``repo_root`` is
|
|
806
|
+
supplied, a second lock in Git's canonical common directory serializes
|
|
807
|
+
source mutation even when another ArtifactStore root/project id points at
|
|
808
|
+
the same repository. Locks are always acquired store-first, repo-second,
|
|
809
|
+
and are re-entrant within one thread. With ``blocking=False`` a
|
|
810
|
+
cross-process holder raises ``ApplyLockBusy`` instead of waiting (and the
|
|
811
|
+
ExitStack unwinds any lock already taken), so the caller can hold the
|
|
812
|
+
lock through its whole critical section without ever blocking on a live
|
|
813
|
+
holder.
|
|
814
|
+
"""
|
|
815
|
+
lock_paths = [self.root / ".apply.lock"]
|
|
816
|
+
if repo_root is not None:
|
|
817
|
+
repository_lock = self._repository_apply_lock_path(repo_root)
|
|
818
|
+
if repository_lock not in lock_paths:
|
|
819
|
+
lock_paths.append(repository_lock)
|
|
820
|
+
with self._access(), ExitStack() as stack:
|
|
821
|
+
for lock_path in lock_paths:
|
|
822
|
+
stack.enter_context(_cross_process_lock(
|
|
823
|
+
lock_path, blocking=blocking,
|
|
824
|
+
opener=self._io.open_file if lock_path == self.root / ".apply.lock" else None,
|
|
825
|
+
))
|
|
826
|
+
yield
|
|
827
|
+
|
|
828
|
+
@_artifact_access
|
|
829
|
+
def _compare_and_swap_manifest(
|
|
830
|
+
self,
|
|
831
|
+
run_dir: Path,
|
|
832
|
+
expected: dict[str, Any],
|
|
833
|
+
replacement: dict[str, Any],
|
|
834
|
+
) -> None:
|
|
835
|
+
"""Replace a manifest only when its complete on-disk revision matches."""
|
|
836
|
+
try:
|
|
837
|
+
current = json.loads(
|
|
838
|
+
self._io.read(run_dir / "manifest.json")
|
|
839
|
+
)
|
|
840
|
+
except (OSError, ValueError, TypeError) as exc:
|
|
841
|
+
raise ArtifactError(
|
|
842
|
+
f"run {run_dir.name} changed or disappeared concurrently"
|
|
843
|
+
) from exc
|
|
844
|
+
if current != expected:
|
|
845
|
+
raise ArtifactError(
|
|
846
|
+
f"run {run_dir.name} manifest changed concurrently; retry"
|
|
847
|
+
)
|
|
848
|
+
self._write_manifest(run_dir, replacement)
|
|
849
|
+
|
|
850
|
+
@_artifact_access
|
|
851
|
+
def save(
|
|
852
|
+
self,
|
|
853
|
+
*,
|
|
854
|
+
run_id: str,
|
|
855
|
+
task: Task,
|
|
856
|
+
result: TaskResult,
|
|
857
|
+
repo_root: Path,
|
|
858
|
+
snapshot_commit: str,
|
|
859
|
+
source_fingerprint: str,
|
|
860
|
+
strategy: str,
|
|
861
|
+
parent_run_id: str | None = None,
|
|
862
|
+
plan: dict[str, Any] | None = None,
|
|
863
|
+
) -> RunArtifact:
|
|
864
|
+
if result.status == TaskStatus.COMPLETED and result.patch is None:
|
|
865
|
+
raise ArtifactError("completed result is missing its mandatory patch")
|
|
866
|
+
run_dir = self._run_path(run_id)
|
|
867
|
+
self._io.mkdir(run_dir)
|
|
868
|
+
existing: dict[str, Any] = {}
|
|
869
|
+
manifest_path = run_dir / "manifest.json"
|
|
870
|
+
if self._io.exists(manifest_path):
|
|
871
|
+
try:
|
|
872
|
+
existing = json.loads(self._io.read(manifest_path))
|
|
873
|
+
except (OSError, ValueError) as exc:
|
|
874
|
+
raise ArtifactError(f"corrupt running artifact: {run_id}") from exc
|
|
875
|
+
if not isinstance(existing, dict):
|
|
876
|
+
raise ArtifactError(f"corrupt running artifact: {run_id}")
|
|
877
|
+
self._check_binding(run_id, existing)
|
|
878
|
+
if existing.get("state") != "running":
|
|
879
|
+
raise ArtifactError(f"run artifact already exists: {run_id}")
|
|
880
|
+
patch = (result.patch or "").encode("utf-8", errors="surrogateescape")
|
|
881
|
+
state = "pending" if result.status == TaskStatus.COMPLETED and patch else (
|
|
882
|
+
"no_changes" if result.status == TaskStatus.COMPLETED else "failed"
|
|
883
|
+
)
|
|
884
|
+
manifest = {
|
|
885
|
+
**existing,
|
|
886
|
+
"schema_version": 1,
|
|
887
|
+
"run_id": run_id,
|
|
888
|
+
"project_id": self.root.parent.name,
|
|
889
|
+
"parent_run_id": parent_run_id,
|
|
890
|
+
"created_at": existing.get("created_at", _now()),
|
|
891
|
+
"updated_at": _now(),
|
|
892
|
+
"state": state,
|
|
893
|
+
"repo_root": str(repo_root.resolve()),
|
|
894
|
+
"source_fingerprint": source_fingerprint,
|
|
895
|
+
"snapshot_commit": snapshot_commit,
|
|
896
|
+
"task_id": str(task.id),
|
|
897
|
+
"prompt": task.prompt,
|
|
898
|
+
"strategy": strategy,
|
|
899
|
+
"agent_id": result.agent_id,
|
|
900
|
+
"files_changed": sorted(result.files_changed),
|
|
901
|
+
"patch_sha256": _digest(patch),
|
|
902
|
+
"patch_bytes": len(patch),
|
|
903
|
+
"provider_session_id": result.provider_session_id,
|
|
904
|
+
"conversation_id": result.conversation_id,
|
|
905
|
+
"plan": strip_approval(plan),
|
|
906
|
+
}
|
|
907
|
+
self._io.write(run_dir / "winner.patch", patch)
|
|
908
|
+
self._io.write(
|
|
909
|
+
run_dir / "result.json",
|
|
910
|
+
(json.dumps({
|
|
911
|
+
"status": result.status.value,
|
|
912
|
+
"output": result.output,
|
|
913
|
+
"error": result.error,
|
|
914
|
+
"exit_code": result.exit_code,
|
|
915
|
+
"duration_seconds": result.duration_seconds,
|
|
916
|
+
}, ensure_ascii=False, indent=2) + "\n").encode(),
|
|
917
|
+
)
|
|
918
|
+
self._write_manifest(run_dir, manifest)
|
|
919
|
+
return RunArtifact(run_id, run_dir, manifest)
|
|
920
|
+
|
|
921
|
+
@_artifact_access
|
|
922
|
+
def begin(
|
|
923
|
+
self,
|
|
924
|
+
*,
|
|
925
|
+
run_id: str,
|
|
926
|
+
task: Task,
|
|
927
|
+
repo_root: Path,
|
|
928
|
+
snapshot_commit: str,
|
|
929
|
+
source_fingerprint: str,
|
|
930
|
+
strategy: str,
|
|
931
|
+
agents: list[str],
|
|
932
|
+
plan: dict[str, Any],
|
|
933
|
+
parent_run_id: str | None = None,
|
|
934
|
+
) -> RunArtifact:
|
|
935
|
+
"""Create the crash-visible running manifest before any agent starts."""
|
|
936
|
+
run_dir = self._run_path(run_id)
|
|
937
|
+
self._io.mkdir(run_dir, exist_ok=False)
|
|
938
|
+
manifest = {
|
|
939
|
+
"schema_version": 1,
|
|
940
|
+
"run_id": run_id,
|
|
941
|
+
"project_id": self.root.parent.name,
|
|
942
|
+
"parent_run_id": parent_run_id,
|
|
943
|
+
"created_at": _now(),
|
|
944
|
+
"updated_at": _now(),
|
|
945
|
+
"state": "running",
|
|
946
|
+
"repo_root": str(repo_root.resolve()),
|
|
947
|
+
"source_fingerprint": source_fingerprint,
|
|
948
|
+
"snapshot_commit": snapshot_commit,
|
|
949
|
+
"task_id": str(task.id),
|
|
950
|
+
"prompt": task.prompt,
|
|
951
|
+
"strategy": strategy,
|
|
952
|
+
"agents": agents,
|
|
953
|
+
"plan": strip_approval(plan),
|
|
954
|
+
}
|
|
955
|
+
self._write_manifest(run_dir, manifest)
|
|
956
|
+
return RunArtifact(run_id, run_dir, manifest)
|
|
957
|
+
|
|
958
|
+
@_artifact_access
|
|
959
|
+
def load(self, run_id: str) -> RunArtifact:
|
|
960
|
+
"""Read one manifest without retaining per-run descriptors on return."""
|
|
961
|
+
return self._load_pinned(run_id)
|
|
962
|
+
|
|
963
|
+
def _load_pinned(self, run_id: str) -> RunArtifact:
|
|
964
|
+
"""Internal read: keep the directory pinned through the caller's mutation."""
|
|
965
|
+
path = self._run_path(run_id)
|
|
966
|
+
try:
|
|
967
|
+
data = self._io.read(path / "manifest.json")
|
|
968
|
+
except OSError as exc:
|
|
969
|
+
raise ArtifactError(f"unknown or corrupt run: {run_id}") from exc
|
|
970
|
+
return self._parse_manifest(run_id, path, data)
|
|
971
|
+
|
|
972
|
+
def _parse_manifest(self, run_id: str, path: Path, data: bytes) -> RunArtifact:
|
|
973
|
+
try:
|
|
974
|
+
manifest = json.loads(data)
|
|
975
|
+
except (ValueError, RecursionError) as exc:
|
|
976
|
+
# RecursionError: a pathologically-nested manifest is corrupt, not
|
|
977
|
+
# a crash (the _claude_events / parse_board_update lesson).
|
|
978
|
+
raise ArtifactError(f"unknown or corrupt run: {run_id}") from exc
|
|
979
|
+
if not isinstance(manifest, dict):
|
|
980
|
+
# Valid JSON but not an object (a list/scalar) would crash every
|
|
981
|
+
# `manifest.get` downstream — it is a corrupt run, same as bad JSON.
|
|
982
|
+
raise ArtifactError(f"unknown or corrupt run: {run_id}")
|
|
983
|
+
self._check_binding(run_id, manifest)
|
|
984
|
+
return RunArtifact(run_id, path, manifest)
|
|
985
|
+
|
|
986
|
+
@_artifact_access
|
|
987
|
+
def list_detailed(self) -> ArtifactListing:
|
|
988
|
+
"""Enumerate the run store without hiding what it could not read.
|
|
989
|
+
|
|
990
|
+
Healthy runs are returned exactly as :meth:`list` does; every directory
|
|
991
|
+
(or symlink) entry that could not be loaded is reported in ``skipped``
|
|
992
|
+
instead of vanishing — a store whose only manifest is corrupt must not
|
|
993
|
+
look like a healthy empty one (#170). Nothing here repairs, resets or
|
|
994
|
+
deletes an entry. Regular files (the apply lock) are not run candidates
|
|
995
|
+
and stay unreported. A store-level failure — the runs root replaced
|
|
996
|
+
while enumerating, which the descriptor layer reports as ``ESTALE`` on
|
|
997
|
+
the root — still raises rather than being misfiled as per-run damage.
|
|
998
|
+
"""
|
|
999
|
+
artifacts: list[RunArtifact] = []
|
|
1000
|
+
skipped: list[SkippedRun] = []
|
|
1001
|
+
seen: dict[str, tuple[tuple, RunArtifact]] = {}
|
|
1002
|
+
with self._listing_lock:
|
|
1003
|
+
cached = dict(self._listing_cache)
|
|
1004
|
+
for name in self._io.names(self.root):
|
|
1005
|
+
path = self.root / name
|
|
1006
|
+
try:
|
|
1007
|
+
mode = self._io.entry_mode(path)
|
|
1008
|
+
if stat.S_ISLNK(mode):
|
|
1009
|
+
skipped.append(SkippedRun(name, "refused: symlink"))
|
|
1010
|
+
elif stat.S_ISDIR(mode):
|
|
1011
|
+
signature, artifact = self._cached_or_loaded(name, cached.get(name))
|
|
1012
|
+
artifacts.append(artifact)
|
|
1013
|
+
if signature is not None:
|
|
1014
|
+
seen[name] = (signature, artifact)
|
|
1015
|
+
except ArtifactError as exc:
|
|
1016
|
+
skipped.append(SkippedRun(name, _skip_reason(exc)))
|
|
1017
|
+
except OSError as exc:
|
|
1018
|
+
self._raise_if_root_replaced(exc)
|
|
1019
|
+
# strerror only: str(exc) carries a filename
|
|
1020
|
+
detail = exc.strerror or type(exc).__name__
|
|
1021
|
+
skipped.append(SkippedRun(name, f"unreadable: {detail}"))
|
|
1022
|
+
try:
|
|
1023
|
+
self._io.directory(self.root)
|
|
1024
|
+
except OSError as exc:
|
|
1025
|
+
self._raise_if_root_replaced(exc)
|
|
1026
|
+
raise
|
|
1027
|
+
if len(seen) > _LISTING_CACHE_MAX:
|
|
1028
|
+
# insertion order == directory order; keep the tail, drop the head
|
|
1029
|
+
overflow = len(seen) - _LISTING_CACHE_MAX
|
|
1030
|
+
for name in list(seen)[:overflow]:
|
|
1031
|
+
del seen[name]
|
|
1032
|
+
with self._listing_lock:
|
|
1033
|
+
# names no longer present fall out here (deletions); skipped
|
|
1034
|
+
# entries were never added, so they are re-examined each time
|
|
1035
|
+
self._listing_cache = seen
|
|
1036
|
+
return ArtifactListing(
|
|
1037
|
+
sorted(
|
|
1038
|
+
artifacts,
|
|
1039
|
+
key=lambda artifact: str(artifact.manifest.get("created_at", "")),
|
|
1040
|
+
reverse=True,
|
|
1041
|
+
),
|
|
1042
|
+
sorted(skipped, key=lambda entry: entry.name),
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
@_artifact_access
|
|
1046
|
+
def _cached_or_loaded(
|
|
1047
|
+
self, name: str, hit: tuple[tuple, RunArtifact] | None
|
|
1048
|
+
) -> tuple[tuple | None, RunArtifact]:
|
|
1049
|
+
"""One open per entry (#168): the manifest is opened with
|
|
1050
|
+
``open_file``'s checks and fstat'ed; an unchanged signature returns the
|
|
1051
|
+
cached parse without reading, otherwise the SAME descriptor is read and
|
|
1052
|
+
parsed — so a cold listing costs what ``load`` costs plus one fstat.
|
|
1053
|
+
Decorated so the run-directory handle is released on return, like
|
|
1054
|
+
``load`` (a listing must not hold one descriptor per run). An open
|
|
1055
|
+
failure falls through to ``load`` for the error classification."""
|
|
1056
|
+
path = self._run_path(name)
|
|
1057
|
+
try:
|
|
1058
|
+
signature, data = self._io.read_if_changed(
|
|
1059
|
+
path / "manifest.json", hit[0] if hit is not None else None
|
|
1060
|
+
)
|
|
1061
|
+
except OSError:
|
|
1062
|
+
return None, self.load(name)
|
|
1063
|
+
if data is None:
|
|
1064
|
+
return signature, hit[1] # unchanged: the cached parse stands
|
|
1065
|
+
return signature, self._parse_manifest(name, path, data)
|
|
1066
|
+
|
|
1067
|
+
def _raise_if_root_replaced(self, exc: OSError) -> None:
|
|
1068
|
+
if exc.errno == errno.ESTALE and exc.filename == str(self.root):
|
|
1069
|
+
raise ArtifactError(
|
|
1070
|
+
"artifact store directory changed; restart or reopen the store"
|
|
1071
|
+
) from exc
|
|
1072
|
+
|
|
1073
|
+
def list(self) -> list[RunArtifact]:
|
|
1074
|
+
return self.list_detailed().artifacts
|
|
1075
|
+
|
|
1076
|
+
@_artifact_access
|
|
1077
|
+
def prune(
|
|
1078
|
+
self, keep: int, *, dry_run: bool = False, protect: str | None = None,
|
|
1079
|
+
blocking: bool = True,
|
|
1080
|
+
) -> list[str]:
|
|
1081
|
+
"""Remove old terminal runs; pending/recovery state is never pruned.
|
|
1082
|
+
|
|
1083
|
+
``protect`` (the Supervisor's just-saved run) is ALWAYS retained and
|
|
1084
|
+
counts toward ``keep`` — the ``report._sweep_retention`` rule. Retention
|
|
1085
|
+
ranks by ``created_at``, which is stamped when a run *begins*, so a
|
|
1086
|
+
long-running run that finishes after ``keep`` newer runs started would
|
|
1087
|
+
otherwise fall outside the protected window and delete its own artifact
|
|
1088
|
+
moments after saving it, leaving ``result.artifact_path`` dangling.
|
|
1089
|
+
|
|
1090
|
+
``blocking=False`` raises :class:`ApplyLockBusy` instead of waiting, for
|
|
1091
|
+
callers on a latency-sensitive path: ``_apply_locked`` holds this same
|
|
1092
|
+
lock across a full verification (a clone plus every gate command), so a
|
|
1093
|
+
concurrent ``apply --verify`` would otherwise stall the caller for
|
|
1094
|
+
minutes. Retention is housekeeping — skipping a busy sweep costs
|
|
1095
|
+
nothing, since the next run repeats it.
|
|
1096
|
+
"""
|
|
1097
|
+
with self.apply_lock(blocking=blocking):
|
|
1098
|
+
removable_states = {
|
|
1099
|
+
"applied", "no_changes", "failed", "discarded", "superseded"
|
|
1100
|
+
}
|
|
1101
|
+
artifacts = self.list()
|
|
1102
|
+
if protect is None:
|
|
1103
|
+
protected = set(artifact.run_id for artifact in artifacts[:keep])
|
|
1104
|
+
else:
|
|
1105
|
+
others = [a for a in artifacts if a.run_id != protect]
|
|
1106
|
+
protected = {a.run_id for a in others[: max(keep - 1, 0)]}
|
|
1107
|
+
protected.add(protect)
|
|
1108
|
+
removed: list[str] = []
|
|
1109
|
+
for artifact in artifacts:
|
|
1110
|
+
if artifact.run_id in protected:
|
|
1111
|
+
continue
|
|
1112
|
+
if artifact.manifest.get("state") not in removable_states:
|
|
1113
|
+
continue
|
|
1114
|
+
removed.append(artifact.run_id)
|
|
1115
|
+
if not dry_run:
|
|
1116
|
+
self._io.rmtree(artifact.path)
|
|
1117
|
+
return removed
|
|
1118
|
+
|
|
1119
|
+
@_artifact_access
|
|
1120
|
+
def patch(self, run_id: str) -> bytes:
|
|
1121
|
+
artifact = self._load_pinned(run_id)
|
|
1122
|
+
data = self._io.read(artifact.path / "winner.patch")
|
|
1123
|
+
if _digest(data) != artifact.manifest.get("patch_sha256"):
|
|
1124
|
+
raise ArtifactError(f"patch digest mismatch for run {run_id}")
|
|
1125
|
+
return data
|
|
1126
|
+
|
|
1127
|
+
@_artifact_access
|
|
1128
|
+
def validate_applicable(self, run_id: str) -> RunArtifact:
|
|
1129
|
+
"""Structural preflight for announcing a detached apply.
|
|
1130
|
+
|
|
1131
|
+
Verifies the manifest carries the fields apply needs and that the
|
|
1132
|
+
patch bytes match their recorded digest — so "detached job launched"
|
|
1133
|
+
can never precede a guaranteed worker crash on a hollow/hand-edited
|
|
1134
|
+
manifest. The worker still fully revalidates under the apply lock.
|
|
1135
|
+
"""
|
|
1136
|
+
artifact = self._load_pinned(run_id)
|
|
1137
|
+
manifest = artifact.manifest
|
|
1138
|
+
repo_root = manifest.get("repo_root")
|
|
1139
|
+
if not isinstance(repo_root, str) or not repo_root:
|
|
1140
|
+
raise ArtifactError(
|
|
1141
|
+
f"unknown or corrupt run: {run_id} (missing repo_root)"
|
|
1142
|
+
)
|
|
1143
|
+
try:
|
|
1144
|
+
# A malformed path (embedded NUL → ValueError, symlink loop →
|
|
1145
|
+
# RuntimeError, I/O failure → OSError) must fail the preflight,
|
|
1146
|
+
# not crash the worker after "detached job launched".
|
|
1147
|
+
Path(repo_root).resolve()
|
|
1148
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
1149
|
+
raise ArtifactError(
|
|
1150
|
+
f"unknown or corrupt run: {run_id} (invalid repo_root: {exc})"
|
|
1151
|
+
) from exc
|
|
1152
|
+
if not isinstance(manifest.get("source_fingerprint"), str):
|
|
1153
|
+
raise ArtifactError(
|
|
1154
|
+
f"unknown or corrupt run: {run_id} (missing source_fingerprint)"
|
|
1155
|
+
)
|
|
1156
|
+
try:
|
|
1157
|
+
self.patch(run_id) # digest-verified read
|
|
1158
|
+
except OSError as exc:
|
|
1159
|
+
raise ArtifactError(
|
|
1160
|
+
f"unknown or corrupt run: {run_id} (missing patch)"
|
|
1161
|
+
) from exc
|
|
1162
|
+
return artifact
|
|
1163
|
+
|
|
1164
|
+
@_artifact_access
|
|
1165
|
+
def approve_workflow_run(
|
|
1166
|
+
self, run_id: str, repo_root: Path, *, workflow_id: str
|
|
1167
|
+
) -> None:
|
|
1168
|
+
"""Record that ``workflow_id`` committed the completion of THIS run.
|
|
1169
|
+
|
|
1170
|
+
Written AFTER the workflow's lease-fenced completion write, so the
|
|
1171
|
+
artifact carries its own authorization: `workflow prune` is entitled to
|
|
1172
|
+
delete a terminal workflow row, and once it has, the row can no longer
|
|
1173
|
+
answer whether the run it produced was ever approved. Idempotent, so the
|
|
1174
|
+
healing path can stamp a run whose producer died between the two writes.
|
|
1175
|
+
|
|
1176
|
+
``workflow_id`` is the caller's statement of WHICH workflow it verified,
|
|
1177
|
+
and it must match what this artifact claims. Approval is otherwise only
|
|
1178
|
+
bound to "some completed workflow exists", which any artifact can name:
|
|
1179
|
+
the manifest is data, and a copied or hand-written one naming a
|
|
1180
|
+
genuinely completed workflow would launder itself into an applyable run.
|
|
1181
|
+
An artifact that names no workflow, or names it emptily, is refused for
|
|
1182
|
+
the same reason.
|
|
1183
|
+
|
|
1184
|
+
Under the apply lock and through a compare-and-swap, like every other
|
|
1185
|
+
manifest transition: a plain read-modify-write here would let a stamp
|
|
1186
|
+
that loaded a `pending` manifest overwrite a concurrent `discard` and
|
|
1187
|
+
resurrect the run as pending AND approved.
|
|
1188
|
+
"""
|
|
1189
|
+
with self.apply_lock(repo_root):
|
|
1190
|
+
artifact = self._load_pinned(run_id)
|
|
1191
|
+
current = artifact.manifest
|
|
1192
|
+
claimed = workflow_origin_id(current)
|
|
1193
|
+
if not claimed:
|
|
1194
|
+
raise ArtifactError(
|
|
1195
|
+
f"run {run_id} does not identify the workflow that produced it"
|
|
1196
|
+
)
|
|
1197
|
+
if claimed != workflow_id:
|
|
1198
|
+
raise ArtifactError(
|
|
1199
|
+
f"run {run_id} came from workflow {claimed}, not {workflow_id}"
|
|
1200
|
+
)
|
|
1201
|
+
if workflow_approved(current):
|
|
1202
|
+
return
|
|
1203
|
+
replacement = json.loads(json.dumps(current))
|
|
1204
|
+
replacement["plan"] = {
|
|
1205
|
+
**(replacement.get("plan") or {}),
|
|
1206
|
+
"approved": {
|
|
1207
|
+
"workflow_id": workflow_id,
|
|
1208
|
+
"run_id": run_id,
|
|
1209
|
+
# The patch AS RECORDED for this run: `patch()` already
|
|
1210
|
+
# refuses bytes that do not match it, so an approval naming
|
|
1211
|
+
# it cannot survive the patch being swapped underneath.
|
|
1212
|
+
"patch_sha256": current.get("patch_sha256"),
|
|
1213
|
+
"approved_at": _now(),
|
|
1214
|
+
},
|
|
1215
|
+
}
|
|
1216
|
+
replacement["updated_at"] = _now()
|
|
1217
|
+
self._compare_and_swap_manifest(artifact.path, current, replacement)
|
|
1218
|
+
|
|
1219
|
+
@_artifact_access
|
|
1220
|
+
def apply(
|
|
1221
|
+
self,
|
|
1222
|
+
run_id: str,
|
|
1223
|
+
repo_root: Path,
|
|
1224
|
+
source_fingerprint: str,
|
|
1225
|
+
*,
|
|
1226
|
+
verify_commands: list[str] | None = None,
|
|
1227
|
+
verify_timeout: int = 300,
|
|
1228
|
+
allow_room_bound: bool = False,
|
|
1229
|
+
verify_workspace_root: Path | None = None,
|
|
1230
|
+
) -> Path:
|
|
1231
|
+
with self.apply_lock(repo_root):
|
|
1232
|
+
return self._apply_locked(
|
|
1233
|
+
run_id,
|
|
1234
|
+
repo_root,
|
|
1235
|
+
source_fingerprint,
|
|
1236
|
+
verify_commands=verify_commands,
|
|
1237
|
+
verify_timeout=verify_timeout,
|
|
1238
|
+
allow_room_bound=allow_room_bound,
|
|
1239
|
+
verify_workspace_root=verify_workspace_root,
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
@_artifact_access
|
|
1243
|
+
def _apply_locked(
|
|
1244
|
+
self,
|
|
1245
|
+
run_id: str,
|
|
1246
|
+
repo_root: Path,
|
|
1247
|
+
source_fingerprint: str,
|
|
1248
|
+
*,
|
|
1249
|
+
verify_commands: list[str] | None = None,
|
|
1250
|
+
verify_timeout: int = 300,
|
|
1251
|
+
allow_room_bound: bool = False,
|
|
1252
|
+
verify_workspace_root: Path | None = None,
|
|
1253
|
+
) -> Path:
|
|
1254
|
+
artifact = self._load_pinned(run_id)
|
|
1255
|
+
git_env = isolated_git_env()
|
|
1256
|
+
manifest = artifact.manifest
|
|
1257
|
+
if is_room_bound(manifest) and not allow_room_bound:
|
|
1258
|
+
# Room-bound runs are applied ONLY through RoomCoordinator.apply
|
|
1259
|
+
# (which passes allow_room_bound=True after its selection/revision
|
|
1260
|
+
# CAS): a standalone apply would bypass the Room lifecycle, mutate
|
|
1261
|
+
# the source, and leave Room state inconsistent.
|
|
1262
|
+
raise ArtifactError(
|
|
1263
|
+
f"run {run_id} is room-bound; use 'syncmill room apply'"
|
|
1264
|
+
)
|
|
1265
|
+
if workflow_origin_id(manifest) is not None and not workflow_approved(manifest):
|
|
1266
|
+
# Enforced HERE, beside the Room guard, rather than in the CLI: the
|
|
1267
|
+
# locked apply path is the only place every caller passes through,
|
|
1268
|
+
# so a guard above it is advice that a direct caller silently skips.
|
|
1269
|
+
# A workflow publishes this artifact before committing its
|
|
1270
|
+
# completion, so an unapproved one is a run whose workflow may have
|
|
1271
|
+
# been cancelled in that window — or one whose approval was never
|
|
1272
|
+
# written. `syncmill apply` heals the second case; nothing may
|
|
1273
|
+
# apply the first.
|
|
1274
|
+
raise ArtifactError(
|
|
1275
|
+
f"run {run_id} came from a workflow that has not recorded its "
|
|
1276
|
+
"completion; its result is not approved for apply"
|
|
1277
|
+
)
|
|
1278
|
+
if manifest.get("state") != "pending":
|
|
1279
|
+
raise ArtifactError(
|
|
1280
|
+
f"run {run_id} is {manifest.get('state')!r}, not pending"
|
|
1281
|
+
)
|
|
1282
|
+
recorded_root = manifest.get("repo_root")
|
|
1283
|
+
if not isinstance(recorded_root, str) or not recorded_root:
|
|
1284
|
+
# A hollow/hand-edited manifest must fail as a corrupt run, never
|
|
1285
|
+
# as a raw KeyError escaping the worker's error boundary.
|
|
1286
|
+
raise ArtifactError(
|
|
1287
|
+
f"unknown or corrupt run: {run_id} (missing repo_root)"
|
|
1288
|
+
)
|
|
1289
|
+
try:
|
|
1290
|
+
resolved_recorded = Path(recorded_root).resolve()
|
|
1291
|
+
except (ValueError, OSError, RuntimeError) as exc:
|
|
1292
|
+
# Embedded NUL / symlink loop / I/O failure: corrupt run, not a
|
|
1293
|
+
# raw exception escaping the worker's error boundary.
|
|
1294
|
+
raise ArtifactError(
|
|
1295
|
+
f"unknown or corrupt run: {run_id} (invalid repo_root: {exc})"
|
|
1296
|
+
) from exc
|
|
1297
|
+
if resolved_recorded != repo_root.resolve():
|
|
1298
|
+
raise ArtifactError("run belongs to a different repository")
|
|
1299
|
+
if manifest.get("source_fingerprint") != source_fingerprint:
|
|
1300
|
+
raise ArtifactError(
|
|
1301
|
+
"working tree changed since the run; review the diff and rerun"
|
|
1302
|
+
)
|
|
1303
|
+
patch_data = self.patch(run_id)
|
|
1304
|
+
check_apply = _run_patch_git(
|
|
1305
|
+
patch_data,
|
|
1306
|
+
["git", "apply", "--check", "--binary", "-"],
|
|
1307
|
+
cwd=str(repo_root), env=git_env, capture_output=True, text=True,
|
|
1308
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1309
|
+
)
|
|
1310
|
+
if check_apply.returncode != 0:
|
|
1311
|
+
# A timed-out --check is read-only — the timeout note in stderr
|
|
1312
|
+
# names it; degrading into the ordinary check-failure idiom is safe.
|
|
1313
|
+
raise command_error(ArtifactError, "git apply check failed", check_apply.stderr)
|
|
1314
|
+
|
|
1315
|
+
if verify_commands:
|
|
1316
|
+
self._verify_in_isolation(
|
|
1317
|
+
run_id=run_id,
|
|
1318
|
+
repo_root=repo_root,
|
|
1319
|
+
patch=patch_data,
|
|
1320
|
+
commands=verify_commands,
|
|
1321
|
+
timeout=verify_timeout,
|
|
1322
|
+
workspace_root=verify_workspace_root,
|
|
1323
|
+
)
|
|
1324
|
+
if repository_fingerprint(repo_root) != source_fingerprint:
|
|
1325
|
+
raise ArtifactError(
|
|
1326
|
+
"working tree changed during isolated verification; retry"
|
|
1327
|
+
)
|
|
1328
|
+
check_apply = _run_patch_git(
|
|
1329
|
+
patch_data,
|
|
1330
|
+
["git", "apply", "--check", "--binary", "-"],
|
|
1331
|
+
cwd=str(repo_root), env=git_env, capture_output=True, text=True,
|
|
1332
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1333
|
+
)
|
|
1334
|
+
if check_apply.returncode != 0:
|
|
1335
|
+
raise command_error(
|
|
1336
|
+
ArtifactError, "git apply recheck failed", check_apply.stderr
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
# No fingerprint here: the pre-mutation check below (`final_fingerprint`)
|
|
1340
|
+
# runs moments later and is the one that gates source mutation. A full
|
|
1341
|
+
# fingerprint reads every untracked file — one redundant pass per apply
|
|
1342
|
+
# was the cost of failing a few statements earlier; a changed tree now
|
|
1343
|
+
# fails at the pre-mutation check with the manifest restored to pending.
|
|
1344
|
+
original_manifest = dict(manifest)
|
|
1345
|
+
applying_manifest = {
|
|
1346
|
+
**manifest,
|
|
1347
|
+
"state": "applying",
|
|
1348
|
+
"apply_id": str(uuid4()),
|
|
1349
|
+
"apply_owner": f"{os.getpid()}-{uuid4().hex[:8]}",
|
|
1350
|
+
"apply_started_at": _now(),
|
|
1351
|
+
"updated_at": _now(),
|
|
1352
|
+
}
|
|
1353
|
+
# Crash visibility comes before source mutation. A process death after the
|
|
1354
|
+
# next write is distinguishable from a never-started pending apply.
|
|
1355
|
+
self._compare_and_swap_manifest(
|
|
1356
|
+
artifact.path, original_manifest, applying_manifest
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
def restore_pending_before_source_mutation() -> None:
|
|
1360
|
+
try:
|
|
1361
|
+
self._compare_and_swap_manifest(
|
|
1362
|
+
artifact.path, applying_manifest, original_manifest
|
|
1363
|
+
)
|
|
1364
|
+
except BaseException as restore_exc:
|
|
1365
|
+
raise ArtifactRecoveryRequired(
|
|
1366
|
+
"source was not modified, but the run manifest could not be restored",
|
|
1367
|
+
source_changed=False,
|
|
1368
|
+
) from restore_exc
|
|
1369
|
+
|
|
1370
|
+
try:
|
|
1371
|
+
final_fingerprint = repository_fingerprint(repo_root)
|
|
1372
|
+
self._io.directory(artifact.path)
|
|
1373
|
+
except BaseException:
|
|
1374
|
+
restore_pending_before_source_mutation()
|
|
1375
|
+
raise
|
|
1376
|
+
if final_fingerprint != source_fingerprint:
|
|
1377
|
+
restore_pending_before_source_mutation()
|
|
1378
|
+
raise ArtifactError("working tree changed before patch application; retry")
|
|
1379
|
+
|
|
1380
|
+
try:
|
|
1381
|
+
apply_patch = _run_patch_git(
|
|
1382
|
+
patch_data,
|
|
1383
|
+
["git", "apply", "--binary", "-"],
|
|
1384
|
+
cwd=str(repo_root), env=git_env, capture_output=True, text=True,
|
|
1385
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1386
|
+
)
|
|
1387
|
+
except OSError as spawn_exc:
|
|
1388
|
+
restore_pending_before_source_mutation()
|
|
1389
|
+
raise ArtifactError(f"could not start git apply: {spawn_exc}") from spawn_exc
|
|
1390
|
+
if _git_timed_out(apply_patch):
|
|
1391
|
+
# A timed-out (group-killed) apply may have half-mutated the tree —
|
|
1392
|
+
# it must NEVER take the clean "patch did not apply → restore
|
|
1393
|
+
# pending" branch below over a mutated source. Attempt a bounded
|
|
1394
|
+
# rollback, then let the fingerprint decide: verified-unchanged
|
|
1395
|
+
# restores pending like a clean failure; anything else is the
|
|
1396
|
+
# recovery-required path (the manifest stays `applying` for the
|
|
1397
|
+
# reconciler / `syncmill recover`).
|
|
1398
|
+
try:
|
|
1399
|
+
undo = _run_patch_git(
|
|
1400
|
+
patch_data,
|
|
1401
|
+
["git", "apply", "--reverse", "--check", "--binary",
|
|
1402
|
+
"-"],
|
|
1403
|
+
cwd=str(repo_root), env=git_env, capture_output=True,
|
|
1404
|
+
text=True, timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1405
|
+
)
|
|
1406
|
+
if undo.returncode == 0:
|
|
1407
|
+
_run_patch_git(
|
|
1408
|
+
patch_data,
|
|
1409
|
+
["git", "apply", "--reverse", "--binary", "-"],
|
|
1410
|
+
cwd=str(repo_root), env=git_env, capture_output=True,
|
|
1411
|
+
text=True, timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1412
|
+
)
|
|
1413
|
+
except OSError:
|
|
1414
|
+
pass # the fingerprint below is the verdict, not the rollback
|
|
1415
|
+
try:
|
|
1416
|
+
source_unchanged = (
|
|
1417
|
+
repository_fingerprint(repo_root) == source_fingerprint
|
|
1418
|
+
)
|
|
1419
|
+
except Exception:
|
|
1420
|
+
source_unchanged = False
|
|
1421
|
+
if not source_unchanged:
|
|
1422
|
+
raise ArtifactRecoveryRequired(
|
|
1423
|
+
"git apply timed out and the source could not be verified "
|
|
1424
|
+
"restored; manual recovery is required",
|
|
1425
|
+
source_changed=True,
|
|
1426
|
+
)
|
|
1427
|
+
restore_pending_before_source_mutation()
|
|
1428
|
+
raise ArtifactError("git apply timed out; source verified unchanged")
|
|
1429
|
+
if apply_patch.returncode != 0:
|
|
1430
|
+
restore_pending_before_source_mutation()
|
|
1431
|
+
raise command_error(ArtifactError, "git apply failed", apply_patch.stderr)
|
|
1432
|
+
|
|
1433
|
+
receipt_dir = artifact.path / "applications"
|
|
1434
|
+
receipt: Path | None = None
|
|
1435
|
+
try:
|
|
1436
|
+
self._io.mkdir(receipt_dir)
|
|
1437
|
+
receipt = receipt_dir / (
|
|
1438
|
+
f"{datetime.now(UTC).strftime('%Y%m%dT%H%M%S%fZ')}.json"
|
|
1439
|
+
)
|
|
1440
|
+
self._io.write(
|
|
1441
|
+
receipt,
|
|
1442
|
+
(
|
|
1443
|
+
json.dumps(
|
|
1444
|
+
{
|
|
1445
|
+
"applied_at": _now(),
|
|
1446
|
+
"repo_root": str(repo_root.resolve()),
|
|
1447
|
+
"patch_sha256": manifest["patch_sha256"],
|
|
1448
|
+
"apply_id": applying_manifest["apply_id"],
|
|
1449
|
+
"run_id": run_id,
|
|
1450
|
+
},
|
|
1451
|
+
indent=2,
|
|
1452
|
+
)
|
|
1453
|
+
+ "\n"
|
|
1454
|
+
).encode(),
|
|
1455
|
+
)
|
|
1456
|
+
applied_manifest = {
|
|
1457
|
+
**applying_manifest,
|
|
1458
|
+
"state": "applied",
|
|
1459
|
+
"updated_at": _now(),
|
|
1460
|
+
}
|
|
1461
|
+
self._compare_and_swap_manifest(
|
|
1462
|
+
artifact.path, applying_manifest, applied_manifest
|
|
1463
|
+
)
|
|
1464
|
+
return receipt
|
|
1465
|
+
except BaseException as bookkeeping_exc:
|
|
1466
|
+
# Invalidate the bound receipt BEFORE reversing the source. The
|
|
1467
|
+
# receipt is proof the patch is applied; if a crash interrupted the
|
|
1468
|
+
# rollback with the receipt still present over a reverted tree, the
|
|
1469
|
+
# reconciler would later read "bound receipt + drifted fingerprint"
|
|
1470
|
+
# as completed and mark the run applied with the change absent. Abort
|
|
1471
|
+
# the rollback if the receipt cannot be removed, rather than reverse
|
|
1472
|
+
# the source underneath a surviving receipt.
|
|
1473
|
+
if receipt is not None:
|
|
1474
|
+
try:
|
|
1475
|
+
self._io.unlink(receipt)
|
|
1476
|
+
except OSError as unlink_exc:
|
|
1477
|
+
raise ArtifactRecoveryRequired(
|
|
1478
|
+
"apply bookkeeping failed and the receipt could not be "
|
|
1479
|
+
"invalidated before rollback; manual recovery is required",
|
|
1480
|
+
source_changed=True,
|
|
1481
|
+
) from unlink_exc
|
|
1482
|
+
rollback = _run_patch_git(
|
|
1483
|
+
patch_data,
|
|
1484
|
+
["git", "apply", "--reverse", "--check", "--binary", "-"],
|
|
1485
|
+
cwd=str(repo_root), env=git_env, capture_output=True, text=True,
|
|
1486
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1487
|
+
)
|
|
1488
|
+
if rollback.returncode == 0:
|
|
1489
|
+
rollback = _run_patch_git(
|
|
1490
|
+
patch_data,
|
|
1491
|
+
["git", "apply", "--reverse", "--binary", "-"],
|
|
1492
|
+
cwd=str(repo_root), env=git_env, capture_output=True, text=True,
|
|
1493
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1494
|
+
)
|
|
1495
|
+
rolled_back = (
|
|
1496
|
+
rollback.returncode == 0
|
|
1497
|
+
and repository_fingerprint(repo_root) == source_fingerprint
|
|
1498
|
+
)
|
|
1499
|
+
if not rolled_back:
|
|
1500
|
+
# A timed-out rollback already lands here via the fingerprint
|
|
1501
|
+
# check; name the timeout so the freeze reason is honest.
|
|
1502
|
+
detail = (
|
|
1503
|
+
" (rollback timed out)" if _git_timed_out(rollback) else ""
|
|
1504
|
+
)
|
|
1505
|
+
raise ArtifactRecoveryRequired(
|
|
1506
|
+
"source patch was applied but durable bookkeeping failed"
|
|
1507
|
+
f"{detail}; manual recovery is required",
|
|
1508
|
+
source_changed=True,
|
|
1509
|
+
) from bookkeeping_exc
|
|
1510
|
+
try:
|
|
1511
|
+
self._compare_and_swap_manifest(
|
|
1512
|
+
artifact.path, applying_manifest, original_manifest
|
|
1513
|
+
)
|
|
1514
|
+
except BaseException as restore_exc:
|
|
1515
|
+
raise ArtifactRecoveryRequired(
|
|
1516
|
+
"source was rolled back but apply bookkeeping could not be restored",
|
|
1517
|
+
source_changed=False,
|
|
1518
|
+
) from restore_exc
|
|
1519
|
+
raise ArtifactError(
|
|
1520
|
+
"apply bookkeeping failed; source patch was rolled back"
|
|
1521
|
+
) from bookkeeping_exc
|
|
1522
|
+
|
|
1523
|
+
@staticmethod
|
|
1524
|
+
def _verify_in_isolation(
|
|
1525
|
+
*,
|
|
1526
|
+
run_id: str,
|
|
1527
|
+
repo_root: Path,
|
|
1528
|
+
patch: bytes,
|
|
1529
|
+
commands: list[str],
|
|
1530
|
+
timeout: int,
|
|
1531
|
+
workspace_root: Path | None = None,
|
|
1532
|
+
) -> None:
|
|
1533
|
+
"""Verify the candidate in a disposable clone; never run gates in source.
|
|
1534
|
+
|
|
1535
|
+
With ``workspace_root`` (the configured ``workspace.root``, threaded by
|
|
1536
|
+
the CLI/room callers) the clone lands under the directory
|
|
1537
|
+
``sweep_orphaned_workspaces`` actually inspects, so a SIGKILL
|
|
1538
|
+
mid-verification — which skips every finally — leaves a sweepable,
|
|
1539
|
+
eventually-reclaimed workspace instead of an unreclaimable clone in a
|
|
1540
|
+
one-off tempdir. Without it (a direct library caller) the tempdir
|
|
1541
|
+
fallback keeps the old behavior, SIGKILL residue included.
|
|
1542
|
+
"""
|
|
1543
|
+
with contextlib.ExitStack() as stack:
|
|
1544
|
+
if workspace_root is None:
|
|
1545
|
+
base = Path(
|
|
1546
|
+
stack.enter_context(
|
|
1547
|
+
tempfile.TemporaryDirectory(prefix="syncmill-apply-verify-")
|
|
1548
|
+
)
|
|
1549
|
+
)
|
|
1550
|
+
else:
|
|
1551
|
+
base = workspace_root
|
|
1552
|
+
# A unique run-dir name: a retried verify of the same run must not
|
|
1553
|
+
# collide with a SIGKILL'd predecessor's leftover (run_root-exists
|
|
1554
|
+
# is a WorkspaceError).
|
|
1555
|
+
snapshot = capture_repository_snapshot(
|
|
1556
|
+
repo_root,
|
|
1557
|
+
f"verify-{run_id}-{uuid4().hex[:8]}",
|
|
1558
|
+
base,
|
|
1559
|
+
sweepable=True,
|
|
1560
|
+
)
|
|
1561
|
+
pool = WorktreePool(repo_root, f"verify-{run_id}", snapshot=snapshot)
|
|
1562
|
+
try:
|
|
1563
|
+
worktree = pool.acquire("apply-verify")
|
|
1564
|
+
restored = _run_git(
|
|
1565
|
+
["git", "apply", "--binary", "-"],
|
|
1566
|
+
cwd=str(worktree.path), env=isolated_git_env(),
|
|
1567
|
+
input=patch, capture_output=True,
|
|
1568
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1569
|
+
)
|
|
1570
|
+
if restored.returncode != 0:
|
|
1571
|
+
raise command_error(
|
|
1572
|
+
ArtifactError,
|
|
1573
|
+
"could not restore patch for verification",
|
|
1574
|
+
restored.stderr.decode(errors="replace"),
|
|
1575
|
+
)
|
|
1576
|
+
for command in commands:
|
|
1577
|
+
report = CommandGate([command], timeout).evaluate(worktree)
|
|
1578
|
+
if not report.passed:
|
|
1579
|
+
raise command_error(
|
|
1580
|
+
ArtifactError,
|
|
1581
|
+
f"isolated verification failed ({command})",
|
|
1582
|
+
report.checks[0].output,
|
|
1583
|
+
)
|
|
1584
|
+
finally:
|
|
1585
|
+
pool.cleanup_all()
|
|
1586
|
+
|
|
1587
|
+
@_artifact_access
|
|
1588
|
+
def discard(self, run_id: str) -> None:
|
|
1589
|
+
with self.apply_lock():
|
|
1590
|
+
artifact = self._load_pinned(run_id)
|
|
1591
|
+
current = artifact.manifest
|
|
1592
|
+
if current.get("state") != "pending":
|
|
1593
|
+
raise ArtifactError("only a pending run can be discarded")
|
|
1594
|
+
replacement = {
|
|
1595
|
+
**current,
|
|
1596
|
+
"state": "discarded",
|
|
1597
|
+
"updated_at": _now(),
|
|
1598
|
+
}
|
|
1599
|
+
self._compare_and_swap_manifest(artifact.path, current, replacement)
|
|
1600
|
+
|
|
1601
|
+
@_artifact_access
|
|
1602
|
+
def supersede(self, run_id: str, child_run_id: str) -> None:
|
|
1603
|
+
with self.apply_lock():
|
|
1604
|
+
artifact = self._load_pinned(run_id)
|
|
1605
|
+
current = artifact.manifest
|
|
1606
|
+
if current.get("state") != "pending":
|
|
1607
|
+
return
|
|
1608
|
+
replacement = {
|
|
1609
|
+
**current,
|
|
1610
|
+
"state": "superseded",
|
|
1611
|
+
"superseded_by": child_run_id,
|
|
1612
|
+
"updated_at": _now(),
|
|
1613
|
+
}
|
|
1614
|
+
self._compare_and_swap_manifest(artifact.path, current, replacement)
|
|
1615
|
+
|
|
1616
|
+
@_artifact_access
|
|
1617
|
+
def close_conversation(self, run_id: str) -> None:
|
|
1618
|
+
with self.apply_lock():
|
|
1619
|
+
artifact = self._load_pinned(run_id)
|
|
1620
|
+
current = artifact.manifest
|
|
1621
|
+
replacement = {
|
|
1622
|
+
**current,
|
|
1623
|
+
"conversation_closed": True,
|
|
1624
|
+
"updated_at": _now(),
|
|
1625
|
+
}
|
|
1626
|
+
self._compare_and_swap_manifest(artifact.path, current, replacement)
|
|
1627
|
+
|
|
1628
|
+
@_artifact_access
|
|
1629
|
+
def recovery_required(
|
|
1630
|
+
self,
|
|
1631
|
+
run_id: str,
|
|
1632
|
+
reason: str,
|
|
1633
|
+
*,
|
|
1634
|
+
expected_states: Collection[str] = ("running", "pending", "applying"),
|
|
1635
|
+
blocking: bool = True,
|
|
1636
|
+
) -> bool:
|
|
1637
|
+
"""Freeze an unfinished run without overwriting a terminal apply state.
|
|
1638
|
+
|
|
1639
|
+
``blocking=False`` raises :class:`ApplyLockBusy` instead of waiting on a
|
|
1640
|
+
live lock holder — the Supervisor's crash path uses it so a Ctrl-C
|
|
1641
|
+
handler never hangs behind another process's long apply/verify.
|
|
1642
|
+
"""
|
|
1643
|
+
with self.apply_lock(blocking=blocking):
|
|
1644
|
+
artifact = self._load_pinned(run_id)
|
|
1645
|
+
current = artifact.manifest
|
|
1646
|
+
if current.get("state") not in expected_states:
|
|
1647
|
+
return False
|
|
1648
|
+
replacement = {
|
|
1649
|
+
**current,
|
|
1650
|
+
"state": "recovery_required",
|
|
1651
|
+
"recovery_reason": reason[:1000],
|
|
1652
|
+
"updated_at": _now(),
|
|
1653
|
+
}
|
|
1654
|
+
self._compare_and_swap_manifest(artifact.path, current, replacement)
|
|
1655
|
+
return True
|
|
1656
|
+
|
|
1657
|
+
@staticmethod
|
|
1658
|
+
def _is_room_bound(manifest: dict[str, Any]) -> bool:
|
|
1659
|
+
"""Room-bound runs are reconciled by the room store, never here."""
|
|
1660
|
+
return is_room_bound(manifest)
|
|
1661
|
+
|
|
1662
|
+
@_artifact_access
|
|
1663
|
+
def _bound_receipt(self, run_dir: Path, manifest: dict[str, Any]) -> Path | None:
|
|
1664
|
+
"""Return a receipt that provably belongs to *this* apply attempt.
|
|
1665
|
+
|
|
1666
|
+
A receipt is evidence only when it is a JSON object carrying this run's
|
|
1667
|
+
``apply_id``, ``run_id`` and ``patch_sha256``. Legacy receipts without
|
|
1668
|
+
``apply_id`` are non-evidence (fail closed): an in-process rollback
|
|
1669
|
+
leaves a stale receipt behind on the crash path, so an unbound receipt
|
|
1670
|
+
can never prove the source was mutated. A malformed or non-object
|
|
1671
|
+
receipt file is skipped, never raised (one bad file must not abort the
|
|
1672
|
+
whole recovery sweep), and a receipt whose ``run_id`` differs is another
|
|
1673
|
+
run's evidence, never accepted for this one.
|
|
1674
|
+
"""
|
|
1675
|
+
apply_id = manifest.get("apply_id")
|
|
1676
|
+
patch_sha256 = manifest.get("patch_sha256")
|
|
1677
|
+
run_id = manifest.get("run_id")
|
|
1678
|
+
if not apply_id:
|
|
1679
|
+
return None
|
|
1680
|
+
receipt_dir = run_dir / "applications"
|
|
1681
|
+
try:
|
|
1682
|
+
names = self._io.names(receipt_dir)
|
|
1683
|
+
except OSError:
|
|
1684
|
+
return None
|
|
1685
|
+
for name in sorted(names):
|
|
1686
|
+
if not name.endswith(".json"):
|
|
1687
|
+
continue
|
|
1688
|
+
receipt = receipt_dir / name
|
|
1689
|
+
try:
|
|
1690
|
+
raw = self._io.read(receipt, _MAX_RECEIPT_BYTES + 1)
|
|
1691
|
+
if len(raw) > _MAX_RECEIPT_BYTES:
|
|
1692
|
+
# Our receipts are a few hundred bytes; an oversized file is
|
|
1693
|
+
# a bad artifact, not evidence — skip it without parsing.
|
|
1694
|
+
continue
|
|
1695
|
+
# ``json.loads`` raises RecursionError (a RuntimeError, not a
|
|
1696
|
+
# ValueError) on pathologically nested input; catch it so one
|
|
1697
|
+
# malformed file can never abort the whole recovery sweep.
|
|
1698
|
+
payload = json.loads(raw)
|
|
1699
|
+
except (OSError, ValueError, RecursionError):
|
|
1700
|
+
continue
|
|
1701
|
+
if not isinstance(payload, dict):
|
|
1702
|
+
continue
|
|
1703
|
+
if (
|
|
1704
|
+
payload.get("apply_id") == apply_id
|
|
1705
|
+
and payload.get("run_id") == run_id
|
|
1706
|
+
and payload.get("patch_sha256") == patch_sha256
|
|
1707
|
+
):
|
|
1708
|
+
return receipt
|
|
1709
|
+
return None
|
|
1710
|
+
|
|
1711
|
+
@staticmethod
|
|
1712
|
+
def _reverse_check(repo_root: Path, patch_data: bytes) -> str:
|
|
1713
|
+
"""Advisory probe: is the run's patch reverse-applicable right now?
|
|
1714
|
+
|
|
1715
|
+
Never drives a verdict — a reverse-applicable patch only proves the
|
|
1716
|
+
content is present now, not that this attempt performed the mutation.
|
|
1717
|
+
The result is embedded in the freeze reason for an operator to read.
|
|
1718
|
+
"""
|
|
1719
|
+
try:
|
|
1720
|
+
probe = _run_patch_git(
|
|
1721
|
+
patch_data,
|
|
1722
|
+
["git", "apply", "--reverse", "--check", "--binary", "-"],
|
|
1723
|
+
cwd=str(repo_root),
|
|
1724
|
+
env=isolated_git_env(),
|
|
1725
|
+
capture_output=True,
|
|
1726
|
+
text=True,
|
|
1727
|
+
timeout=_APPLY_GIT_TIMEOUT_SECONDS,
|
|
1728
|
+
)
|
|
1729
|
+
except OSError as exc:
|
|
1730
|
+
return f"reverse-check unavailable ({exc})"
|
|
1731
|
+
if _git_timed_out(probe):
|
|
1732
|
+
# "not reverse-applicable" would be a false verdict — the probe
|
|
1733
|
+
# never finished, and this string is operator-facing.
|
|
1734
|
+
return "reverse-check unavailable (timed out)"
|
|
1735
|
+
return (
|
|
1736
|
+
"patch is reverse-applicable"
|
|
1737
|
+
if probe.returncode == 0
|
|
1738
|
+
else "patch is not reverse-applicable"
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
@_artifact_access
|
|
1742
|
+
def _run_directories(self) -> list[Path]:
|
|
1743
|
+
return [self.root / name for name in sorted(self._io.names(self.root))
|
|
1744
|
+
if self._io.is_directory(self.root / name)]
|
|
1745
|
+
|
|
1746
|
+
def reconcile_interrupted_applies(
|
|
1747
|
+
self, *, run_id: str | None = None
|
|
1748
|
+
) -> list[ApplyReconciliation]:
|
|
1749
|
+
"""Classify and resolve standalone ``applying`` runs left by a crash.
|
|
1750
|
+
|
|
1751
|
+
Scans (or targets one run), skips live owners and room-bound runs, and
|
|
1752
|
+
for each dead-owner ``applying`` strand decides — under the project
|
|
1753
|
+
apply lock — one of: completed (finish the interrupted flip), not
|
|
1754
|
+
applied (restore to pending), or manual recovery (freeze). Never raises
|
|
1755
|
+
per run: an environment or store error becomes an ``error`` row that
|
|
1756
|
+
leaves the run untouched (``applying`` stays prune-protected, so the run
|
|
1757
|
+
can be reconciled again once the environment returns).
|
|
1758
|
+
"""
|
|
1759
|
+
# One ordered sequence: a corrupt run's "error" row keeps its place in
|
|
1760
|
+
# the sorted enumeration beside the healthy runs around it.
|
|
1761
|
+
scanned: list[RunArtifact | ApplyReconciliation] = []
|
|
1762
|
+
if run_id is not None:
|
|
1763
|
+
try:
|
|
1764
|
+
scanned = [self.load(run_id)]
|
|
1765
|
+
except ArtifactError as exc:
|
|
1766
|
+
return [ApplyReconciliation(run_id, "error", str(exc))]
|
|
1767
|
+
elif self.root.exists():
|
|
1768
|
+
# Enumerate run dirs directly rather than via list(), which
|
|
1769
|
+
# silently skips corrupt manifests — a sweep must surface an
|
|
1770
|
+
# unreadable run as an "error" row, never a false all-clear.
|
|
1771
|
+
for path in self._run_directories():
|
|
1772
|
+
try:
|
|
1773
|
+
scanned.append(self.load(path.name))
|
|
1774
|
+
except ArtifactError as exc:
|
|
1775
|
+
scanned.append(ApplyReconciliation(path.name, "error", str(exc)))
|
|
1776
|
+
reports: list[ApplyReconciliation] = []
|
|
1777
|
+
for item in scanned:
|
|
1778
|
+
if isinstance(item, ApplyReconciliation):
|
|
1779
|
+
reports.append(item)
|
|
1780
|
+
continue
|
|
1781
|
+
artifact = item
|
|
1782
|
+
manifest = artifact.manifest
|
|
1783
|
+
state = manifest.get("state")
|
|
1784
|
+
if state != "applying":
|
|
1785
|
+
if not isinstance(state, str) or state not in _RUN_STATES:
|
|
1786
|
+
# A dict manifest with a missing/unrecognized state is
|
|
1787
|
+
# corrupt, not benign — skipping it silently would
|
|
1788
|
+
# recreate the false all-clear this sweep exists to
|
|
1789
|
+
# prevent (it COULD be a mangled applying strand).
|
|
1790
|
+
reports.append(
|
|
1791
|
+
ApplyReconciliation(
|
|
1792
|
+
artifact.run_id,
|
|
1793
|
+
"error",
|
|
1794
|
+
f"corrupt manifest: unrecognized state {state!r}",
|
|
1795
|
+
)
|
|
1796
|
+
)
|
|
1797
|
+
elif run_id is not None:
|
|
1798
|
+
# An explicit target that is not mid-apply is nothing to do,
|
|
1799
|
+
# not a failure — a benign skip, so `recover` still exits 0.
|
|
1800
|
+
reports.append(
|
|
1801
|
+
ApplyReconciliation(
|
|
1802
|
+
artifact.run_id,
|
|
1803
|
+
"skipped_not_applying",
|
|
1804
|
+
f"run is {state!r}, not applying",
|
|
1805
|
+
)
|
|
1806
|
+
)
|
|
1807
|
+
continue
|
|
1808
|
+
reports.append(self._reconcile_one(artifact))
|
|
1809
|
+
return reports
|
|
1810
|
+
|
|
1811
|
+
@_artifact_access
|
|
1812
|
+
def _reconcile_one(self, artifact: RunArtifact) -> ApplyReconciliation:
|
|
1813
|
+
run_id = artifact.run_id
|
|
1814
|
+
manifest = artifact.manifest
|
|
1815
|
+
if self._is_room_bound(manifest):
|
|
1816
|
+
return ApplyReconciliation(
|
|
1817
|
+
run_id,
|
|
1818
|
+
"skipped_room_bound",
|
|
1819
|
+
"room-bound run; use 'syncmill room recover'",
|
|
1820
|
+
)
|
|
1821
|
+
try:
|
|
1822
|
+
repo_root = self._resolve_repository_root(Path(manifest["repo_root"]))
|
|
1823
|
+
# Ownership is decided by who holds the repository apply lock, not by
|
|
1824
|
+
# the recorded PID (which a crash could see reused): a held lock is a
|
|
1825
|
+
# live apply, a free lock is a crashed one. A single NON-BLOCKING
|
|
1826
|
+
# acquire held through classification is both the liveness check and
|
|
1827
|
+
# the mutation guard — there is no probe/acquire gap in which a new
|
|
1828
|
+
# apply could start and make recovery block on it.
|
|
1829
|
+
try:
|
|
1830
|
+
with self.apply_lock(repo_root, blocking=False):
|
|
1831
|
+
current = self._load_pinned(run_id)
|
|
1832
|
+
cur = current.manifest
|
|
1833
|
+
if cur.get("state") != "applying":
|
|
1834
|
+
return ApplyReconciliation(
|
|
1835
|
+
run_id,
|
|
1836
|
+
"skipped_live_owner",
|
|
1837
|
+
f"resolved concurrently to {cur.get('state')!r}",
|
|
1838
|
+
)
|
|
1839
|
+
return self._classify_and_act(current, repo_root)
|
|
1840
|
+
except ApplyLockBusy:
|
|
1841
|
+
# The lock is held, but by WHOM is unknowable here: it may be an
|
|
1842
|
+
# unrelated concurrent apply, and the recorded owner's process may
|
|
1843
|
+
# already be gone. Report the recorded owner as a hint, never as
|
|
1844
|
+
# the identified holder.
|
|
1845
|
+
owner = manifest.get("apply_owner")
|
|
1846
|
+
detail = (
|
|
1847
|
+
f"the repository apply lock is held (recorded apply owner: {owner})"
|
|
1848
|
+
if owner
|
|
1849
|
+
else "the repository apply lock is held by a live apply"
|
|
1850
|
+
)
|
|
1851
|
+
return ApplyReconciliation(run_id, "skipped_live_owner", detail)
|
|
1852
|
+
except (
|
|
1853
|
+
ArtifactError,
|
|
1854
|
+
WorkspaceError,
|
|
1855
|
+
OSError,
|
|
1856
|
+
# A corrupt/hand-edited manifest (missing or non-string repo_root,
|
|
1857
|
+
# a NUL-bearing path) must yield this run's "error" row, never
|
|
1858
|
+
# abort the whole sweep — the docstring's "never raises per run".
|
|
1859
|
+
KeyError,
|
|
1860
|
+
TypeError,
|
|
1861
|
+
ValueError,
|
|
1862
|
+
# RuntimeError: Path.resolve() on a symlink loop — reachable from
|
|
1863
|
+
# the manifest repo_root above AND from apply_lock's git-common-dir
|
|
1864
|
+
# resolution — plus RecursionError (its subclass) from a
|
|
1865
|
+
# pathological concurrent manifest rewrite.
|
|
1866
|
+
RuntimeError,
|
|
1867
|
+
) as exc:
|
|
1868
|
+
# KeyError's str() is just the missing key — keep the type name so
|
|
1869
|
+
# the row explains itself.
|
|
1870
|
+
return ApplyReconciliation(run_id, "error", f"{type(exc).__name__}: {exc}")
|
|
1871
|
+
|
|
1872
|
+
@_artifact_access
|
|
1873
|
+
def _classify_and_act(
|
|
1874
|
+
self, artifact: RunArtifact, repo_root: Path
|
|
1875
|
+
) -> ApplyReconciliation:
|
|
1876
|
+
run_id = artifact.run_id
|
|
1877
|
+
manifest = artifact.manifest
|
|
1878
|
+
receipt = self._bound_receipt(artifact.path, manifest)
|
|
1879
|
+
current_fingerprint = repository_fingerprint(repo_root)
|
|
1880
|
+
source_matches = current_fingerprint == manifest.get("source_fingerprint")
|
|
1881
|
+
|
|
1882
|
+
if receipt is not None and not source_matches:
|
|
1883
|
+
# completed: the patch was applied and receipted; only the final
|
|
1884
|
+
# applying -> applied flip was interrupted. Finish it.
|
|
1885
|
+
applied_manifest = {
|
|
1886
|
+
**manifest,
|
|
1887
|
+
"state": "applied",
|
|
1888
|
+
"updated_at": _now(),
|
|
1889
|
+
}
|
|
1890
|
+
self._compare_and_swap_manifest(artifact.path, manifest, applied_manifest)
|
|
1891
|
+
return ApplyReconciliation(
|
|
1892
|
+
run_id,
|
|
1893
|
+
"completed",
|
|
1894
|
+
"interrupted apply finished",
|
|
1895
|
+
receipt=str(receipt),
|
|
1896
|
+
)
|
|
1897
|
+
|
|
1898
|
+
if receipt is None and source_matches:
|
|
1899
|
+
# not applied: the source was never mutated, so the applying record
|
|
1900
|
+
# is safe to unwind back to a fresh pending run.
|
|
1901
|
+
pending_manifest = {
|
|
1902
|
+
key: value
|
|
1903
|
+
for key, value in manifest.items()
|
|
1904
|
+
if key not in ("apply_id", "apply_owner", "apply_started_at")
|
|
1905
|
+
}
|
|
1906
|
+
pending_manifest["state"] = "pending"
|
|
1907
|
+
pending_manifest["updated_at"] = _now()
|
|
1908
|
+
self._compare_and_swap_manifest(
|
|
1909
|
+
artifact.path, manifest, pending_manifest
|
|
1910
|
+
)
|
|
1911
|
+
return ApplyReconciliation(
|
|
1912
|
+
run_id, "not_applied", "restored to pending; source was not modified"
|
|
1913
|
+
)
|
|
1914
|
+
|
|
1915
|
+
# Everything else is undecidable evidence: a bound receipt with a
|
|
1916
|
+
# matching fingerprint (patch applied then externally reverted), or no
|
|
1917
|
+
# receipt with a mismatched fingerprint (mutated with no proof of a
|
|
1918
|
+
# complete apply). Freeze for an operator; never auto-roll-back, which
|
|
1919
|
+
# could destroy identical work a human applied by hand.
|
|
1920
|
+
try:
|
|
1921
|
+
reverse = self._reverse_check(repo_root, self.patch(run_id))
|
|
1922
|
+
except (ArtifactError, OSError) as exc:
|
|
1923
|
+
reverse = f"winner.patch unavailable: {exc}"
|
|
1924
|
+
reason = (
|
|
1925
|
+
"interrupted apply is undecidable: "
|
|
1926
|
+
f"receipt={'present' if receipt else 'absent'}, "
|
|
1927
|
+
f"source_matches={source_matches}, {reverse}. "
|
|
1928
|
+
"Inspect the repository and resolve manually."
|
|
1929
|
+
)
|
|
1930
|
+
self.recovery_required(run_id, reason, expected_states=("applying",))
|
|
1931
|
+
return ApplyReconciliation(
|
|
1932
|
+
run_id,
|
|
1933
|
+
"manual_recovery",
|
|
1934
|
+
reason,
|
|
1935
|
+
receipt=str(receipt) if receipt else None,
|
|
1936
|
+
)
|
|
1937
|
+
|
|
1938
|
+
@_artifact_access
|
|
1939
|
+
def _write_manifest(self, run_dir: Path, manifest: dict[str, Any]) -> None:
|
|
1940
|
+
if run_dir != self._run_path(run_dir.name):
|
|
1941
|
+
raise ArtifactError("manifest directory is outside this store")
|
|
1942
|
+
self._check_binding(run_dir.name, manifest)
|
|
1943
|
+
self._io.write(
|
|
1944
|
+
run_dir / "manifest.json",
|
|
1945
|
+
(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode(),
|
|
1946
|
+
)
|