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.
Files changed (56) hide show
  1. syncmill/__init__.py +11 -0
  2. syncmill/agents.py +2064 -0
  3. syncmill/artifact_io.py +201 -0
  4. syncmill/artifacts.py +1946 -0
  5. syncmill/board.py +2060 -0
  6. syncmill/breaker.py +327 -0
  7. syncmill/cli.py +4986 -0
  8. syncmill/command_errors.py +32 -0
  9. syncmill/config.py +1118 -0
  10. syncmill/dashboard.py +935 -0
  11. syncmill/gates.py +179 -0
  12. syncmill/gateway.py +234 -0
  13. syncmill/git_env.py +99 -0
  14. syncmill/isolated_session.py +431 -0
  15. syncmill/matcher.py +127 -0
  16. syncmill/memory.py +1067 -0
  17. syncmill/memory_backend.py +1907 -0
  18. syncmill/memory_migration.py +962 -0
  19. syncmill/model_routing.py +482 -0
  20. syncmill/models.py +244 -0
  21. syncmill/monitor.py +775 -0
  22. syncmill/phoenix_exporter.py +339 -0
  23. syncmill/planning.py +512 -0
  24. syncmill/platform_support.py +14 -0
  25. syncmill/preflight.py +1129 -0
  26. syncmill/project.py +1067 -0
  27. syncmill/report.py +502 -0
  28. syncmill/review_candidates.py +107 -0
  29. syncmill/room_compare.py +2380 -0
  30. syncmill/room_raw.py +484 -0
  31. syncmill/room_viewer.py +604 -0
  32. syncmill/rooms.py +4044 -0
  33. syncmill/session.py +444 -0
  34. syncmill/strategies/__init__.py +82 -0
  35. syncmill/strategies/base.py +910 -0
  36. syncmill/strategies/compete.py +233 -0
  37. syncmill/strategies/council.py +429 -0
  38. syncmill/strategies/decompose.py +570 -0
  39. syncmill/strategies/pipeline.py +221 -0
  40. syncmill/strategies/route.py +130 -0
  41. syncmill/supervisor.py +1423 -0
  42. syncmill/teamfile.py +571 -0
  43. syncmill/telemetry_hook.py +231 -0
  44. syncmill/terminal.py +245 -0
  45. syncmill/tracer.py +548 -0
  46. syncmill/tui.py +3537 -0
  47. syncmill/tui_collaboration.py +499 -0
  48. syncmill/tui_jobs.py +1407 -0
  49. syncmill/tui_render.py +156 -0
  50. syncmill/workflow.py +3746 -0
  51. syncmill/worktree.py +1045 -0
  52. syncmill-0.2.0.dist-info/METADATA +853 -0
  53. syncmill-0.2.0.dist-info/RECORD +56 -0
  54. syncmill-0.2.0.dist-info/WHEEL +4 -0
  55. syncmill-0.2.0.dist-info/entry_points.txt +2 -0
  56. 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
+ )