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/worktree.py
ADDED
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
"""Workspace isolation from an immutable snapshot of the complete Git state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import logging
|
|
7
|
+
import hashlib
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import weakref
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from syncmill.platform_support import require_supported_platform
|
|
19
|
+
from syncmill.command_errors import command_error
|
|
20
|
+
from syncmill.git_env import isolated_git_env
|
|
21
|
+
|
|
22
|
+
# Private cross-import (the session.py precedent) — one implementation of the
|
|
23
|
+
# reaped/pid-recycling-safe group kill; agents.py imports nothing from here, so
|
|
24
|
+
# the graph stays acyclic.
|
|
25
|
+
from syncmill.agents import _close_process_streams, _kill_process_group
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
#: Written into a supervisor run's private workspace as soon as the directory
|
|
30
|
+
#: exists, holding the owning process's pid. Proof of ownership AND of liveness
|
|
31
|
+
#: for ``sweep_orphaned_workspaces``.
|
|
32
|
+
_RUN_SENTINEL = ".syncmill-run"
|
|
33
|
+
|
|
34
|
+
#: Written by ``retain()`` to exempt a recovery workspace from the sweep
|
|
35
|
+
#: permanently. A separate file rather than removing the run sentinel: adding a
|
|
36
|
+
#: file is the operation more likely to succeed, and the exemption has to be
|
|
37
|
+
#: recorded on disk for a LATER process to honor.
|
|
38
|
+
_RECOVERY_MARKER = ".syncmill-recovery"
|
|
39
|
+
|
|
40
|
+
#: How often a live pool rewrites its run sentinel. The sweep's age backstop
|
|
41
|
+
#: overrides a live pid (``workspace.stale_seconds``, floor one hour), and
|
|
42
|
+
#: ``acquire`` alone cannot keep the marker fresh: the concurrent strategies
|
|
43
|
+
#: acquire every worktree up front, so a run longer than the backstop would
|
|
44
|
+
#: freeze its own mtime and be collected mid-flight by another process's
|
|
45
|
+
#: startup sweep. Well under the one-hour floor so a single refresh may fail.
|
|
46
|
+
_SENTINEL_REFRESH_SECONDS = 60.0
|
|
47
|
+
|
|
48
|
+
#: Backstop for the one case pid liveness can't decide: the owner is gone but
|
|
49
|
+
#: its pid has since been reused by an unrelated process, which would otherwise
|
|
50
|
+
#: protect the leak forever. Deliberately long — the pid check already collects
|
|
51
|
+
#: the ordinary crash promptly, so this only bounds a rare case, and being wrong
|
|
52
|
+
#: here deletes a live run's workspace. Operators who need a different window
|
|
53
|
+
#: set ``workspace.stale_seconds`` (floor: an hour) — this stays the default
|
|
54
|
+
#: every direct caller gets.
|
|
55
|
+
_STALE_WORKSPACE_SECONDS = 7 * 24 * 3600
|
|
56
|
+
|
|
57
|
+
#: Hang bound for every git subprocess in this module — a wedged git (a stale
|
|
58
|
+
#: index.lock left by a crashed peer, a prompting clean/smudge/LFS filter, a
|
|
59
|
+
#: broken fsmonitor) must never hang a run, or the Supervisor's finally-time
|
|
60
|
+
#: cleanup, forever. Deliberately generous: this is hang PROTECTION, not a
|
|
61
|
+
#: latency bound — a snapshot of a large repository legitimately takes minutes.
|
|
62
|
+
_GIT_TIMEOUT_SECONDS = 600.0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _run_git(cmd, **kwargs):
|
|
66
|
+
"""Bounded git runner that kills the WHOLE process tree on timeout.
|
|
67
|
+
|
|
68
|
+
``subprocess.run(timeout=...)`` terminates only the direct child, so a
|
|
69
|
+
clean/smudge/LFS filter or fsmonitor daemon git spawned survived the
|
|
70
|
+
timeout as an orphan (codex round-4 major, reproduced). Git therefore
|
|
71
|
+
launches in its own process group (POSIX) and a timeout — or an interrupt
|
|
72
|
+
unwinding this frame, which the detached group would otherwise never
|
|
73
|
+
receive — SIGKILLs the group before the direct child.
|
|
74
|
+
|
|
75
|
+
A timeout still degrades into each call site's EXISTING failure idiom
|
|
76
|
+
instead of introducing a new exception type at 23 sites: non-``check``
|
|
77
|
+
callers get a failed ``CompletedProcess`` (rc 124, the coreutils timeout
|
|
78
|
+
convention) whose stderr names the timeout, and ``check=True`` callers get
|
|
79
|
+
the ``CalledProcessError`` they already handle.
|
|
80
|
+
"""
|
|
81
|
+
require_supported_platform()
|
|
82
|
+
timeout = kwargs.pop("timeout", _GIT_TIMEOUT_SECONDS)
|
|
83
|
+
check = kwargs.pop("check", False)
|
|
84
|
+
input_data = kwargs.pop("input", None) # run()-style; feeds communicate()
|
|
85
|
+
if input_data is not None:
|
|
86
|
+
kwargs["stdin"] = subprocess.PIPE
|
|
87
|
+
if kwargs.pop("capture_output", False):
|
|
88
|
+
kwargs["stdout"] = subprocess.PIPE
|
|
89
|
+
kwargs["stderr"] = subprocess.PIPE
|
|
90
|
+
kwargs.setdefault("start_new_session", os.name == "posix")
|
|
91
|
+
text = bool(kwargs.get("text"))
|
|
92
|
+
# NOT a `with Popen(...)` block — Popen.__exit__ ends with an UNBOUNDED
|
|
93
|
+
# wait(), so an unreapable child would hang this frame on the way out,
|
|
94
|
+
# defeating the timeout contract (the _sync_run rule; codex round-5).
|
|
95
|
+
proc = subprocess.Popen(cmd, **kwargs)
|
|
96
|
+
try:
|
|
97
|
+
try:
|
|
98
|
+
stdout, stderr = proc.communicate(input=input_data, timeout=timeout)
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
_kill_process_group(proc, owned=True) # we launched this group
|
|
101
|
+
proc.kill()
|
|
102
|
+
with contextlib.suppress(Exception):
|
|
103
|
+
proc.wait(timeout=5.0)
|
|
104
|
+
note = f"git command timed out after {timeout}s: {' '.join(cmd)}"
|
|
105
|
+
empty = "" if text else b""
|
|
106
|
+
note_b = note if text else note.encode()
|
|
107
|
+
if check:
|
|
108
|
+
raise subprocess.CalledProcessError(
|
|
109
|
+
124, cmd, output=empty, stderr=note_b
|
|
110
|
+
) from None
|
|
111
|
+
return subprocess.CompletedProcess(cmd, 124, stdout=empty, stderr=note_b)
|
|
112
|
+
except BaseException:
|
|
113
|
+
# A Ctrl-C that unwinds this frame never reaches the detached
|
|
114
|
+
# group — kill the tree before propagating (the _sync_run rule).
|
|
115
|
+
with contextlib.suppress(Exception):
|
|
116
|
+
_kill_process_group(proc, owned=True)
|
|
117
|
+
proc.kill()
|
|
118
|
+
with contextlib.suppress(Exception):
|
|
119
|
+
proc.wait(timeout=5.0)
|
|
120
|
+
raise
|
|
121
|
+
finally:
|
|
122
|
+
_close_process_streams(proc)
|
|
123
|
+
if check and proc.returncode != 0:
|
|
124
|
+
raise subprocess.CalledProcessError(
|
|
125
|
+
proc.returncode, cmd, output=stdout, stderr=stderr
|
|
126
|
+
)
|
|
127
|
+
return subprocess.CompletedProcess(cmd, proc.returncode, stdout, stderr)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class Worktree:
|
|
132
|
+
path: Path
|
|
133
|
+
branch: str | None
|
|
134
|
+
agent_id: str
|
|
135
|
+
is_git: bool
|
|
136
|
+
is_temp: bool = False
|
|
137
|
+
base_commit: str | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class WorkspaceError(RuntimeError):
|
|
141
|
+
"""A repository cannot be snapshotted safely."""
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class RepositorySnapshot:
|
|
146
|
+
source_root: Path
|
|
147
|
+
run_root: Path
|
|
148
|
+
control_repo: Path
|
|
149
|
+
commit: str
|
|
150
|
+
source_head: str
|
|
151
|
+
source_fingerprint: str
|
|
152
|
+
sweepable: bool = False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class PatchCaptureError(WorkspaceError):
|
|
156
|
+
"""Mandatory patch capture failed; the workspace must be retained."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class CapturedChanges:
|
|
161
|
+
files: tuple[str, ...]
|
|
162
|
+
patch: str
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def capture_changes(workdir: Path, base_commit: str | None = None) -> CapturedChanges:
|
|
166
|
+
"""Capture a binary patch with a private index; never alter the live index."""
|
|
167
|
+
require_supported_platform()
|
|
168
|
+
base = base_commit or "HEAD"
|
|
169
|
+
try:
|
|
170
|
+
with tempfile.TemporaryDirectory(prefix="syncmill-patch-") as directory:
|
|
171
|
+
env = isolated_git_env({"GIT_INDEX_FILE": str(Path(directory) / "index")})
|
|
172
|
+
|
|
173
|
+
def run(*args: str) -> bytes:
|
|
174
|
+
proc = _run_git(
|
|
175
|
+
["git", *args], cwd=str(workdir), env=env,
|
|
176
|
+
capture_output=True, timeout=120.0,
|
|
177
|
+
)
|
|
178
|
+
if proc.returncode != 0:
|
|
179
|
+
raise command_error(
|
|
180
|
+
PatchCaptureError,
|
|
181
|
+
f"patch capture failed: git {' '.join(args)}",
|
|
182
|
+
proc.stderr.decode(errors="replace"),
|
|
183
|
+
)
|
|
184
|
+
return proc.stdout
|
|
185
|
+
|
|
186
|
+
run("read-tree", base)
|
|
187
|
+
run("add", "-A")
|
|
188
|
+
names = run("diff", "--cached", "--name-only", "-z", base)
|
|
189
|
+
patch = run("diff", "--cached", "--binary", base).decode(
|
|
190
|
+
"utf-8", errors="surrogateescape"
|
|
191
|
+
)
|
|
192
|
+
files = tuple(sorted(os.fsdecode(name) for name in names.split(b"\0") if name))
|
|
193
|
+
if files and not patch:
|
|
194
|
+
raise PatchCaptureError("changed files produced no applyable patch")
|
|
195
|
+
return CapturedChanges(files, patch)
|
|
196
|
+
except PatchCaptureError:
|
|
197
|
+
raise
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
raise PatchCaptureError(f"patch capture failed: {exc}") from exc
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def repository_fingerprint(repo_root: Path) -> str:
|
|
203
|
+
"""Digest HEAD plus tracked/untracked changes, excluding ignored files.
|
|
204
|
+
|
|
205
|
+
The commit identity is part of the source contract even for a clean checkout:
|
|
206
|
+
two different clean HEADs must never compare equal merely because both have an
|
|
207
|
+
empty working-tree diff.
|
|
208
|
+
"""
|
|
209
|
+
digest = hashlib.sha256()
|
|
210
|
+
env = isolated_git_env()
|
|
211
|
+
commands = (
|
|
212
|
+
["git", "rev-parse", "--verify", "HEAD"],
|
|
213
|
+
["git", "status", "--porcelain=v2", "--untracked-files=all"],
|
|
214
|
+
["git", "diff", "--binary", "HEAD"],
|
|
215
|
+
)
|
|
216
|
+
for command in commands:
|
|
217
|
+
proc = _run_git(
|
|
218
|
+
command, cwd=str(repo_root), env=env, capture_output=True
|
|
219
|
+
)
|
|
220
|
+
if proc.returncode != 0:
|
|
221
|
+
raise command_error(
|
|
222
|
+
WorkspaceError,
|
|
223
|
+
f"{' '.join(command)} failed",
|
|
224
|
+
proc.stderr.decode(errors="replace"),
|
|
225
|
+
)
|
|
226
|
+
digest.update(proc.stdout)
|
|
227
|
+
untracked = _run_git(
|
|
228
|
+
["git", "ls-files", "--others", "--exclude-standard", "-z"],
|
|
229
|
+
cwd=str(repo_root), env=env, capture_output=True,
|
|
230
|
+
)
|
|
231
|
+
if untracked.returncode != 0:
|
|
232
|
+
raise WorkspaceError("could not enumerate untracked files")
|
|
233
|
+
for raw in sorted(p for p in untracked.stdout.split(b"\0") if p):
|
|
234
|
+
rel = os.fsdecode(raw)
|
|
235
|
+
path = repo_root / rel
|
|
236
|
+
digest.update(raw + b"\0")
|
|
237
|
+
try:
|
|
238
|
+
if path.is_symlink():
|
|
239
|
+
digest.update(b"L" + os.readlink(path).encode(errors="surrogateescape"))
|
|
240
|
+
else:
|
|
241
|
+
digest.update(b"F" + path.read_bytes())
|
|
242
|
+
except OSError as exc:
|
|
243
|
+
raise WorkspaceError(f"could not read untracked file {rel}: {exc}") from exc
|
|
244
|
+
return digest.hexdigest()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _seed_snapshot_index(source: Path, index: Path, env: dict) -> bool:
|
|
248
|
+
"""Seed the snapshot index from the source repo's LIVE index, best-effort.
|
|
249
|
+
|
|
250
|
+
A fresh index populated by ``git read-tree`` carries no stat cache, so the
|
|
251
|
+
following ``git add -A`` re-hashes the content of EVERY tracked file — tens
|
|
252
|
+
of seconds per run on a large repository. The live index's stat cache lets
|
|
253
|
+
``add -A`` skip files whose stat still matches (git's racily-clean handling
|
|
254
|
+
re-hashes anything ambiguous), so only genuinely dirty paths are hashed.
|
|
255
|
+
The resulting tree is identical either way: ``add -A`` syncs the index to
|
|
256
|
+
the exact worktree state regardless of its starting point. Returns False on
|
|
257
|
+
any failure — the caller falls back to the read-tree path (which also covers
|
|
258
|
+
an incompatible seed: split index, torn copy — those fail the subsequent git
|
|
259
|
+
command and are rebuilt unseeded).
|
|
260
|
+
"""
|
|
261
|
+
# REFUSE the seed when the live index carries suppressive per-entry flags.
|
|
262
|
+
# `assume-unchanged` (lowercase tag) and `skip-worktree` ('S'/'s') make git
|
|
263
|
+
# trust the index over the worktree, so a seeded `git add -A` would silently
|
|
264
|
+
# snapshot STALE content for a genuinely edited file — an incomplete
|
|
265
|
+
# snapshot, the one failure mode worse than a slow one. The unseeded
|
|
266
|
+
# read-tree path starts from a flag-free index and re-hashes everything,
|
|
267
|
+
# which is exactly the pre-seed behavior for these repos. `ls-files -v`
|
|
268
|
+
# tags every regular entry 'H'; anything else is grounds to fall back
|
|
269
|
+
# (conservative: an unrecognized tag costs only the slow path).
|
|
270
|
+
flags = _run_git(
|
|
271
|
+
["git", "ls-files", "-v"], cwd=str(source),
|
|
272
|
+
env=env, capture_output=True, text=True,
|
|
273
|
+
)
|
|
274
|
+
if flags.returncode != 0 or any(
|
|
275
|
+
line[:1] != "H" for line in flags.stdout.splitlines() if line
|
|
276
|
+
):
|
|
277
|
+
return False
|
|
278
|
+
probe = _run_git(
|
|
279
|
+
["git", "rev-parse", "--git-path", "index"], cwd=str(source),
|
|
280
|
+
env=env, capture_output=True, text=True,
|
|
281
|
+
)
|
|
282
|
+
if probe.returncode != 0 or not probe.stdout.strip():
|
|
283
|
+
return False
|
|
284
|
+
src = Path(probe.stdout.strip())
|
|
285
|
+
if not src.is_absolute():
|
|
286
|
+
src = source / src
|
|
287
|
+
try:
|
|
288
|
+
shutil.copyfile(src, index)
|
|
289
|
+
except OSError:
|
|
290
|
+
return False
|
|
291
|
+
return True
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _populate_snapshot_index(
|
|
295
|
+
source: Path, source_head: str, snapshot_env: dict, *, seeded: bool
|
|
296
|
+
) -> None:
|
|
297
|
+
"""Bring the snapshot index to the exact worktree state (raises on failure)."""
|
|
298
|
+
commands = (
|
|
299
|
+
[["git", "add", "-A"]]
|
|
300
|
+
if seeded
|
|
301
|
+
else [["git", "read-tree", source_head], ["git", "add", "-A"]]
|
|
302
|
+
)
|
|
303
|
+
for command in commands:
|
|
304
|
+
proc = _run_git(
|
|
305
|
+
command, cwd=str(source), env=snapshot_env,
|
|
306
|
+
capture_output=True, text=True,
|
|
307
|
+
)
|
|
308
|
+
if proc.returncode != 0:
|
|
309
|
+
raise command_error(WorkspaceError, "snapshot index failed", proc.stderr)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def capture_repository_snapshot(
|
|
313
|
+
repo_root: Path, run_id: str, workspace_root: Path, *, sweepable: bool = False
|
|
314
|
+
) -> RepositorySnapshot:
|
|
315
|
+
"""Capture HEAD + tracked dirty state + untracked files in a private clone.
|
|
316
|
+
|
|
317
|
+
The source index, branch, and object database are never modified. Ignored
|
|
318
|
+
files stay out of the snapshot by normal Git rules.
|
|
319
|
+
|
|
320
|
+
``sweepable`` marks the workspace as a disposable supervisor run, claiming it
|
|
321
|
+
for ``sweep_orphaned_workspaces`` the moment the directory exists — before
|
|
322
|
+
the clone, which is the slow part an interrupt is most likely to land in, and
|
|
323
|
+
an unmarked leftover is one the sweep can never reclaim. Rooms and
|
|
324
|
+
interactive sessions leave it False: their workspaces are tracked externally
|
|
325
|
+
and may outlive any run, so nothing may reclaim them on liveness or age.
|
|
326
|
+
"""
|
|
327
|
+
require_supported_platform()
|
|
328
|
+
source = repo_root.resolve()
|
|
329
|
+
env = isolated_git_env()
|
|
330
|
+
if not _is_git_repo(source):
|
|
331
|
+
raise WorkspaceError(f"not a Git repository: {source}")
|
|
332
|
+
top = _run_git(
|
|
333
|
+
["git", "rev-parse", "--show-toplevel"], cwd=str(source),
|
|
334
|
+
env=env, capture_output=True, text=True,
|
|
335
|
+
)
|
|
336
|
+
if top.returncode != 0 or not top.stdout.strip():
|
|
337
|
+
# Without this check an empty stdout resolves Path("") to the PROCESS
|
|
338
|
+
# CWD, which would then anchor every subsequent git command.
|
|
339
|
+
raise command_error(
|
|
340
|
+
WorkspaceError, f"cannot resolve git toplevel for {source}", top.stderr
|
|
341
|
+
)
|
|
342
|
+
source = Path(top.stdout.strip()).resolve()
|
|
343
|
+
head = _run_git(
|
|
344
|
+
["git", "rev-parse", "--verify", "HEAD"], cwd=str(source),
|
|
345
|
+
env=env, capture_output=True, text=True,
|
|
346
|
+
)
|
|
347
|
+
if head.returncode != 0:
|
|
348
|
+
raise WorkspaceError("repository has no initial commit")
|
|
349
|
+
source_head = head.stdout.strip()
|
|
350
|
+
unmerged = _run_git(
|
|
351
|
+
["git", "diff", "--name-only", "--diff-filter=U"], cwd=str(source),
|
|
352
|
+
env=env, capture_output=True, text=True,
|
|
353
|
+
)
|
|
354
|
+
if unmerged.stdout.strip():
|
|
355
|
+
raise WorkspaceError("repository has unresolved merge conflicts")
|
|
356
|
+
for marker in ("MERGE_HEAD", "REBASE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD"):
|
|
357
|
+
probe = _run_git(
|
|
358
|
+
["git", "rev-parse", "--verify", "-q", marker], cwd=str(source),
|
|
359
|
+
env=env, capture_output=True,
|
|
360
|
+
)
|
|
361
|
+
if probe.returncode == 0:
|
|
362
|
+
raise WorkspaceError(f"repository operation in progress: {marker}")
|
|
363
|
+
sparse = _run_git(
|
|
364
|
+
["git", "config", "--bool", "core.sparseCheckout"], cwd=str(source),
|
|
365
|
+
env=env, capture_output=True, text=True,
|
|
366
|
+
)
|
|
367
|
+
if sparse.returncode == 0 and sparse.stdout.strip() == "true":
|
|
368
|
+
raise WorkspaceError("sparse checkouts are not supported yet")
|
|
369
|
+
# Gitlinks, not .gitmodules, are authoritative (including staged changes).
|
|
370
|
+
for command in (
|
|
371
|
+
["git", "ls-tree", "-r", "-z", "HEAD"],
|
|
372
|
+
["git", "ls-files", "--stage", "-z"],
|
|
373
|
+
):
|
|
374
|
+
links = _run_git(command, cwd=str(source), env=env, capture_output=True)
|
|
375
|
+
if links.returncode != 0:
|
|
376
|
+
raise WorkspaceError("could not validate repository submodules")
|
|
377
|
+
if any(record.startswith(b"160000 ") for record in links.stdout.split(b"\0")):
|
|
378
|
+
raise WorkspaceError(
|
|
379
|
+
"submodule repositories are not supported; "
|
|
380
|
+
"use a repository without gitlinks"
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
for operation_dir in ("rebase-merge", "rebase-apply"):
|
|
384
|
+
probe = _run_git(
|
|
385
|
+
["git", "rev-parse", "--git-path", operation_dir], cwd=str(source),
|
|
386
|
+
env=env, capture_output=True, text=True,
|
|
387
|
+
)
|
|
388
|
+
operation_path = Path(probe.stdout.strip())
|
|
389
|
+
if not operation_path.is_absolute():
|
|
390
|
+
operation_path = source / operation_path
|
|
391
|
+
if probe.returncode == 0 and operation_path.exists():
|
|
392
|
+
raise WorkspaceError(f"repository operation in progress: {operation_dir}")
|
|
393
|
+
|
|
394
|
+
fingerprint_before = repository_fingerprint(source)
|
|
395
|
+
|
|
396
|
+
run_root = workspace_root.expanduser().resolve() / run_id
|
|
397
|
+
control = run_root / "control"
|
|
398
|
+
if run_root.exists():
|
|
399
|
+
raise WorkspaceError(f"workspace already exists for run {run_id}")
|
|
400
|
+
try:
|
|
401
|
+
run_root.mkdir(parents=True, mode=0o700)
|
|
402
|
+
if sweepable:
|
|
403
|
+
write_run_sentinel(run_root)
|
|
404
|
+
clone = _run_git(
|
|
405
|
+
["git", "clone", "--shared", "--no-checkout", str(source), str(control)],
|
|
406
|
+
env=env, capture_output=True, text=True,
|
|
407
|
+
)
|
|
408
|
+
if clone.returncode != 0:
|
|
409
|
+
raise command_error(WorkspaceError, "private clone failed", clone.stderr)
|
|
410
|
+
index = run_root / "snapshot.index"
|
|
411
|
+
snapshot_env = isolated_git_env({
|
|
412
|
+
"GIT_DIR": str(control / ".git"),
|
|
413
|
+
"GIT_WORK_TREE": str(source),
|
|
414
|
+
"GIT_INDEX_FILE": str(index),
|
|
415
|
+
"GIT_AUTHOR_NAME": "syncmill",
|
|
416
|
+
"GIT_AUTHOR_EMAIL": "syncmill@local",
|
|
417
|
+
"GIT_COMMITTER_NAME": "syncmill",
|
|
418
|
+
"GIT_COMMITTER_EMAIL": "syncmill@local",
|
|
419
|
+
})
|
|
420
|
+
seeded = _seed_snapshot_index(source, index, env)
|
|
421
|
+
try:
|
|
422
|
+
_populate_snapshot_index(source, source_head, snapshot_env, seeded=seeded)
|
|
423
|
+
except WorkspaceError:
|
|
424
|
+
if not seeded:
|
|
425
|
+
raise
|
|
426
|
+
# The seed was incompatible (split index, torn copy) — rebuild the
|
|
427
|
+
# index from scratch on the always-correct read-tree path.
|
|
428
|
+
index.unlink(missing_ok=True)
|
|
429
|
+
_populate_snapshot_index(source, source_head, snapshot_env, seeded=False)
|
|
430
|
+
tree = _run_git(
|
|
431
|
+
["git", "write-tree"], cwd=str(source), env=snapshot_env,
|
|
432
|
+
capture_output=True, text=True, check=True,
|
|
433
|
+
).stdout.strip()
|
|
434
|
+
commit = _run_git(
|
|
435
|
+
["git", "commit-tree", tree, "-p", source_head, "-m", f"syncmill snapshot {run_id}"],
|
|
436
|
+
cwd=str(source), env=snapshot_env,
|
|
437
|
+
capture_output=True, text=True, check=True,
|
|
438
|
+
).stdout.strip()
|
|
439
|
+
fingerprint = repository_fingerprint(source)
|
|
440
|
+
if fingerprint != fingerprint_before:
|
|
441
|
+
raise WorkspaceError(
|
|
442
|
+
"working tree changed while the snapshot was being captured; retry"
|
|
443
|
+
)
|
|
444
|
+
index.unlink(missing_ok=True)
|
|
445
|
+
return RepositorySnapshot(
|
|
446
|
+
source, run_root, control, commit, source_head, fingerprint, sweepable
|
|
447
|
+
)
|
|
448
|
+
except Exception as exc:
|
|
449
|
+
shutil.rmtree(run_root, ignore_errors=True)
|
|
450
|
+
if isinstance(exc, WorkspaceError):
|
|
451
|
+
raise
|
|
452
|
+
raise WorkspaceError(f"could not create repository snapshot: {exc}") from exc
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def create_worktree(
|
|
456
|
+
repo_root: Path, agent_id: str, task_id: str, slot: int | None = None
|
|
457
|
+
) -> Worktree:
|
|
458
|
+
"""Create an isolated workspace for an agent.
|
|
459
|
+
|
|
460
|
+
Uses git worktree if in a git repo, falls back to tempdir otherwise.
|
|
461
|
+
|
|
462
|
+
``slot`` disambiguates repeat worktrees for the **same** ``(agent_id,
|
|
463
|
+
task_id)`` within one run. The branch and path are derived from
|
|
464
|
+
``agent_id`` + ``task_id`` alone, so a second ``git worktree add`` for the same
|
|
465
|
+
agent would otherwise collide on an existing branch/path, fail, and silently
|
|
466
|
+
fall back to a (non-git, empty) tempdir. Most strategies acquire one worktree
|
|
467
|
+
per agent and pass ``None`` (the original bare naming); ``DecomposeStrategy``
|
|
468
|
+
acquires several per agent (planner + per-subtask + per-synthesizer), so
|
|
469
|
+
``WorktreePool`` threads a per-acquire ``slot`` to keep every worktree distinct.
|
|
470
|
+
"""
|
|
471
|
+
require_supported_platform()
|
|
472
|
+
if not _is_git_repo(repo_root):
|
|
473
|
+
return _create_tempdir(agent_id)
|
|
474
|
+
|
|
475
|
+
tag = task_id[:8] if slot is None else f"{task_id[:8]}-{slot}"
|
|
476
|
+
branch = f"syncmill/{agent_id}/{tag}"
|
|
477
|
+
worktree_dir = repo_root.parent / ".syncmill-worktrees"
|
|
478
|
+
worktree_path = worktree_dir / f"{agent_id}-{tag}"
|
|
479
|
+
|
|
480
|
+
try:
|
|
481
|
+
worktree_dir.mkdir(parents=True, exist_ok=True)
|
|
482
|
+
_run_git(
|
|
483
|
+
["git", "worktree", "add", "-b", branch, str(worktree_path)],
|
|
484
|
+
capture_output=True, text=True, cwd=str(repo_root),
|
|
485
|
+
env=isolated_git_env(),
|
|
486
|
+
check=True,
|
|
487
|
+
)
|
|
488
|
+
logger.debug("Created worktree at %s (branch: %s)", worktree_path, branch)
|
|
489
|
+
return Worktree(
|
|
490
|
+
path=worktree_path,
|
|
491
|
+
branch=branch,
|
|
492
|
+
agent_id=agent_id,
|
|
493
|
+
is_git=True,
|
|
494
|
+
base_commit=_rev_parse(repo_root, "HEAD"),
|
|
495
|
+
)
|
|
496
|
+
except (subprocess.CalledProcessError, OSError) as exc:
|
|
497
|
+
logger.warning("Failed to create git worktree: %s. Falling back to tempdir.", exc)
|
|
498
|
+
return _create_tempdir(agent_id)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def cleanup_worktree(repo_root: Path, worktree: Worktree) -> bool:
|
|
502
|
+
"""Remove a worktree and its branch. Returns True if the path is gone after."""
|
|
503
|
+
require_supported_platform()
|
|
504
|
+
if worktree.is_temp:
|
|
505
|
+
try:
|
|
506
|
+
shutil.rmtree(worktree.path, ignore_errors=True)
|
|
507
|
+
logger.debug("Cleaned up tempdir %s", worktree.path)
|
|
508
|
+
except Exception:
|
|
509
|
+
pass
|
|
510
|
+
return not worktree.path.exists()
|
|
511
|
+
|
|
512
|
+
if not worktree.is_git or not worktree.branch:
|
|
513
|
+
return False
|
|
514
|
+
|
|
515
|
+
try:
|
|
516
|
+
env = isolated_git_env()
|
|
517
|
+
rm = _run_git(
|
|
518
|
+
["git", "worktree", "remove", str(worktree.path), "--force"],
|
|
519
|
+
capture_output=True, text=True, cwd=str(repo_root), env=env,
|
|
520
|
+
)
|
|
521
|
+
if rm.returncode != 0:
|
|
522
|
+
logger.warning(
|
|
523
|
+
"git worktree remove failed for %s: %s", worktree.path, rm.stderr.strip()
|
|
524
|
+
)
|
|
525
|
+
br = _run_git(
|
|
526
|
+
["git", "branch", "-D", worktree.branch],
|
|
527
|
+
capture_output=True, text=True, cwd=str(repo_root), env=env,
|
|
528
|
+
)
|
|
529
|
+
if br.returncode != 0:
|
|
530
|
+
logger.warning(
|
|
531
|
+
"git branch -D failed for %s: %s", worktree.branch, br.stderr.strip()
|
|
532
|
+
)
|
|
533
|
+
logger.debug("Cleaned up worktree %s (branch: %s)", worktree.path, worktree.branch)
|
|
534
|
+
return not worktree.path.exists()
|
|
535
|
+
except Exception as exc:
|
|
536
|
+
logger.warning("Failed to cleanup worktree: %s", exc)
|
|
537
|
+
return False
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
class WorktreePool:
|
|
541
|
+
"""Creates per-run worktrees on demand and tracks every one for cleanup.
|
|
542
|
+
|
|
543
|
+
A run's worktree topology is the *strategy's* decision, but their lifetime is
|
|
544
|
+
the *Supervisor's*: it owns one pool per run and calls ``cleanup_all`` in a
|
|
545
|
+
``finally``, so however many worktrees a strategy acquires (one per agent for
|
|
546
|
+
concurrent isolation, or a single shared one for a sequential pipeline) all
|
|
547
|
+
normally get torn down. Explicit recovery retention exempts the private run
|
|
548
|
+
workspace so it can be inspected after an undecidable failure.
|
|
549
|
+
|
|
550
|
+
Worktrees are still created by ``create_worktree``, so each lands on its own
|
|
551
|
+
``syncmill/{agent}/{taskid}-{slot}`` branch beside ``repo_root`` (tempdir
|
|
552
|
+
fallback for non-git repos). Every ``acquire`` gets a distinct ``slot`` (a
|
|
553
|
+
per-pool monotonic counter), so even repeat acquisitions for the **same** agent
|
|
554
|
+
— which ``DecomposeStrategy`` makes (planner + per-subtask + per-synthesizer) —
|
|
555
|
+
get distinct branches/paths and never collide on ``git worktree add``.
|
|
556
|
+
"""
|
|
557
|
+
|
|
558
|
+
supports_snapshots = True
|
|
559
|
+
|
|
560
|
+
def __init__(
|
|
561
|
+
self,
|
|
562
|
+
repo_root: Path,
|
|
563
|
+
task_id: str,
|
|
564
|
+
snapshot: RepositorySnapshot | None = None,
|
|
565
|
+
seed_patch: bytes | None = None,
|
|
566
|
+
):
|
|
567
|
+
require_supported_platform()
|
|
568
|
+
self._snapshot = snapshot
|
|
569
|
+
self._seed_patch = seed_patch
|
|
570
|
+
self._repo_root = snapshot.control_repo if snapshot is not None else repo_root
|
|
571
|
+
self._task_id = task_id
|
|
572
|
+
self._worktrees: list[Worktree] = []
|
|
573
|
+
self._next_slot = 0 # per-acquire discriminator → distinct repeat worktrees
|
|
574
|
+
self._retained = False
|
|
575
|
+
if snapshot is not None and snapshot.sweepable:
|
|
576
|
+
# Re-claim: capture_repository_snapshot already wrote the sentinel, but
|
|
577
|
+
# this process may not be the one that did (a resumed/handed-off
|
|
578
|
+
# workspace), and the sweep reads the pid to decide liveness.
|
|
579
|
+
with contextlib.suppress(OSError):
|
|
580
|
+
write_run_sentinel(snapshot.run_root)
|
|
581
|
+
self._stop_keepalive = threading.Event()
|
|
582
|
+
self._keepalive: threading.Thread | None = None
|
|
583
|
+
self._start_keepalive()
|
|
584
|
+
|
|
585
|
+
def _start_keepalive(self) -> None:
|
|
586
|
+
"""Keep this run's sentinel fresh for as long as the pool is alive.
|
|
587
|
+
|
|
588
|
+
The sweep asks the OWNING PID whether the run is alive, but ages out a
|
|
589
|
+
live pid after ``workspace.stale_seconds`` as the pid-reuse backstop.
|
|
590
|
+
A run that legitimately outlives that window (a long council, a slow
|
|
591
|
+
provider) must not be mistaken for a recycled pid, and its only proof of
|
|
592
|
+
activity is this marker — an agent thinking for an hour writes nothing.
|
|
593
|
+
Daemon thread: it must never hold up interpreter exit, and a run whose
|
|
594
|
+
process is gone SHOULD age out.
|
|
595
|
+
"""
|
|
596
|
+
if self._snapshot is None or not self._snapshot.sweepable:
|
|
597
|
+
return
|
|
598
|
+
# Bind the Event and the path LOCALLY: a closure over `self` would make
|
|
599
|
+
# the running thread keep the pool alive, so a pool dropped without
|
|
600
|
+
# cleanup_all() could never be collected — one leak per run in a
|
|
601
|
+
# long-lived process (`board run --loop`, the TUI).
|
|
602
|
+
run_root = self._snapshot.run_root
|
|
603
|
+
stop = self._stop_keepalive
|
|
604
|
+
|
|
605
|
+
def _refresh() -> None:
|
|
606
|
+
while not stop.wait(_SENTINEL_REFRESH_SECONDS):
|
|
607
|
+
# Best-effort, like every other sentinel write: a failed refresh
|
|
608
|
+
# forfeits THIS tick only. Returning here would make one
|
|
609
|
+
# transient error (a full disk, a brief NFS hiccup) permanently
|
|
610
|
+
# disable protection and restore the very failure mode this
|
|
611
|
+
# thread exists to prevent — the sweep deleting a live run.
|
|
612
|
+
with contextlib.suppress(Exception):
|
|
613
|
+
write_run_sentinel(run_root)
|
|
614
|
+
|
|
615
|
+
self._keepalive = threading.Thread(
|
|
616
|
+
target=_refresh, name="syncmill-workspace-keepalive", daemon=True
|
|
617
|
+
)
|
|
618
|
+
self._keepalive.start()
|
|
619
|
+
# A pool dropped without cleanup_all()/retain() would otherwise keep
|
|
620
|
+
# refreshing forever, holding a dead run's workspace out of the sweep.
|
|
621
|
+
# The finalizer only touches the Event, so it never resurrects the pool.
|
|
622
|
+
self._keepalive_finalizer = weakref.finalize(self, stop.set)
|
|
623
|
+
|
|
624
|
+
def _stop_keepalive_thread(self) -> None:
|
|
625
|
+
"""Stop refreshing. Safe to call repeatedly and from any exit path."""
|
|
626
|
+
self._stop_keepalive.set()
|
|
627
|
+
finalizer = getattr(self, "_keepalive_finalizer", None)
|
|
628
|
+
if finalizer is not None:
|
|
629
|
+
finalizer.detach() # already stopped; nothing left for GC to do
|
|
630
|
+
|
|
631
|
+
def retain(self) -> None:
|
|
632
|
+
"""Keep the private workspace for manual recovery after artifact failure."""
|
|
633
|
+
self._retained = True
|
|
634
|
+
# A retained workspace carries _RECOVERY_MARKER and is exempt from the
|
|
635
|
+
# sweep, so the liveness refresh has nothing left to protect.
|
|
636
|
+
self._stop_keepalive_thread()
|
|
637
|
+
if self._snapshot is not None:
|
|
638
|
+
# ADD an exemption marker rather than removing the run sentinel:
|
|
639
|
+
# creating a file is the operation more likely to succeed, and if it
|
|
640
|
+
# does fail the workspace is still marked as a run rather than being
|
|
641
|
+
# left in a state where neither file says anything.
|
|
642
|
+
with contextlib.suppress(OSError):
|
|
643
|
+
(self._snapshot.run_root / _RECOVERY_MARKER).write_text("")
|
|
644
|
+
|
|
645
|
+
def acquire(self, agent_id: str) -> Worktree:
|
|
646
|
+
"""Create and track an isolated worktree for ``agent_id``.
|
|
647
|
+
|
|
648
|
+
Each call advances ``slot`` so acquiring the same agent twice in one run
|
|
649
|
+
(Decompose's planner/subtask/synthesis phases) never collides.
|
|
650
|
+
"""
|
|
651
|
+
# Claim the slot up front: a failed `git worktree add` used to leave
|
|
652
|
+
# `_next_slot` unadvanced, so a retried acquire recomputed the SAME
|
|
653
|
+
# branch/path — now guaranteed to fail again against the partial
|
|
654
|
+
# leftovers of the first attempt. A skipped slot is just a gap.
|
|
655
|
+
slot = self._next_slot
|
|
656
|
+
self._next_slot += 1
|
|
657
|
+
if self._snapshot is None:
|
|
658
|
+
worktree = create_worktree(
|
|
659
|
+
self._repo_root, agent_id, self._task_id, slot=slot
|
|
660
|
+
)
|
|
661
|
+
else:
|
|
662
|
+
# Refresh the liveness marker: the sweep's age backstop exists for a
|
|
663
|
+
# reused pid, and a run doing real work must not age into it.
|
|
664
|
+
if self._snapshot.sweepable:
|
|
665
|
+
with contextlib.suppress(OSError):
|
|
666
|
+
write_run_sentinel(self._snapshot.run_root)
|
|
667
|
+
tag = f"{self._task_id[:8]}-{slot}"
|
|
668
|
+
branch = f"syncmill/{agent_id}/{tag}"
|
|
669
|
+
path = self._snapshot.run_root / "worktrees" / f"{agent_id}-{tag}"
|
|
670
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
671
|
+
proc = _run_git(
|
|
672
|
+
["git", "worktree", "add", "-b", branch, str(path), self._snapshot.commit],
|
|
673
|
+
cwd=str(self._snapshot.control_repo), env=isolated_git_env(),
|
|
674
|
+
capture_output=True, text=True,
|
|
675
|
+
)
|
|
676
|
+
if proc.returncode != 0:
|
|
677
|
+
raise command_error(WorkspaceError, "worktree creation failed", proc.stderr)
|
|
678
|
+
worktree = Worktree(
|
|
679
|
+
path, branch, agent_id, True, base_commit=self._snapshot.commit
|
|
680
|
+
)
|
|
681
|
+
if self._seed_patch:
|
|
682
|
+
proc = _run_git(
|
|
683
|
+
["git", "apply", "--binary", "-"],
|
|
684
|
+
cwd=str(path), env=isolated_git_env(),
|
|
685
|
+
input=self._seed_patch, capture_output=True,
|
|
686
|
+
)
|
|
687
|
+
if proc.returncode != 0:
|
|
688
|
+
cleanup_worktree(self._snapshot.control_repo, worktree)
|
|
689
|
+
raise command_error(
|
|
690
|
+
WorkspaceError,
|
|
691
|
+
"could not restore parent patch",
|
|
692
|
+
proc.stderr.decode(errors="replace"),
|
|
693
|
+
)
|
|
694
|
+
self._worktrees.append(worktree)
|
|
695
|
+
return worktree
|
|
696
|
+
|
|
697
|
+
@property
|
|
698
|
+
def worktrees(self) -> list[Worktree]:
|
|
699
|
+
"""Every worktree acquired so far (a fresh copy)."""
|
|
700
|
+
return list(self._worktrees)
|
|
701
|
+
|
|
702
|
+
def cleanup_all(self) -> int:
|
|
703
|
+
"""Remove all acquired worktrees + branches. Returns the count removed.
|
|
704
|
+
|
|
705
|
+
Each removal is independent — one failure never skips the rest — so a
|
|
706
|
+
single stuck worktree can't strand the others.
|
|
707
|
+
"""
|
|
708
|
+
self._stop_keepalive_thread()
|
|
709
|
+
if self._retained:
|
|
710
|
+
logger.warning("Retaining recovery workspace at %s", self.recovery_path)
|
|
711
|
+
return 0
|
|
712
|
+
if self._snapshot is not None:
|
|
713
|
+
# Snapshot pools place every worktree, its branch, and the worktree
|
|
714
|
+
# registration metadata INSIDE run_root (the control clone) — the
|
|
715
|
+
# rmtree below removes all of it, so the per-worktree
|
|
716
|
+
# `git worktree remove` + `git branch -D` pair spawned two
|
|
717
|
+
# subprocesses per worktree only to delete files about to be
|
|
718
|
+
# deleted anyway (Decompose acquires the most: planner + per-subtask
|
|
719
|
+
# + per-synthesizer).
|
|
720
|
+
removed = len(self._worktrees)
|
|
721
|
+
self._worktrees.clear()
|
|
722
|
+
shutil.rmtree(self._snapshot.run_root, ignore_errors=True)
|
|
723
|
+
return removed
|
|
724
|
+
removed = 0
|
|
725
|
+
for worktree in self._worktrees:
|
|
726
|
+
try:
|
|
727
|
+
if cleanup_worktree(self._repo_root, worktree):
|
|
728
|
+
removed += 1
|
|
729
|
+
except Exception as exc: # pragma: no cover - cleanup_worktree guards itself
|
|
730
|
+
logger.warning("Failed to clean up worktree %s: %s", worktree.path, exc)
|
|
731
|
+
self._worktrees.clear()
|
|
732
|
+
return removed
|
|
733
|
+
|
|
734
|
+
@property
|
|
735
|
+
def recovery_path(self) -> Path | None:
|
|
736
|
+
return self._snapshot.run_root if self._snapshot is not None else None
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def write_run_sentinel(run_root: Path) -> None:
|
|
740
|
+
"""Claim ``run_root`` for this process: ownership plus a liveness handle.
|
|
741
|
+
|
|
742
|
+
Written as soon as the directory exists — BEFORE the expensive clone — so an
|
|
743
|
+
interrupt partway through setup still leaves a directory the sweep can
|
|
744
|
+
recognise. An unmarked directory is never swept, so a marker written late is
|
|
745
|
+
a permanent leak.
|
|
746
|
+
"""
|
|
747
|
+
require_supported_platform()
|
|
748
|
+
(run_root / _RUN_SENTINEL).write_text(f"{os.getpid()}\n", encoding="utf-8")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _process_is_alive(pid: int) -> bool:
|
|
752
|
+
require_supported_platform()
|
|
753
|
+
if pid <= 0:
|
|
754
|
+
return False
|
|
755
|
+
try:
|
|
756
|
+
os.kill(pid, 0)
|
|
757
|
+
except ProcessLookupError:
|
|
758
|
+
return False
|
|
759
|
+
except PermissionError:
|
|
760
|
+
return True # exists, owned by another user
|
|
761
|
+
except OSError:
|
|
762
|
+
return True # undecidable — assume alive, never delete on a guess
|
|
763
|
+
return True
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _sentinel_owner_pid(sentinel: Path) -> int | None:
|
|
767
|
+
"""The pid recorded in a run sentinel, or ``None`` if it has none.
|
|
768
|
+
|
|
769
|
+
Sentinels written before this carried no pid; those fall back to age alone.
|
|
770
|
+
"""
|
|
771
|
+
try:
|
|
772
|
+
return int(sentinel.read_text(encoding="utf-8").strip())
|
|
773
|
+
except (OSError, ValueError):
|
|
774
|
+
return None
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
def sweep_orphaned_workspaces(
|
|
778
|
+
workspace_root: Path, *, older_than: float = _STALE_WORKSPACE_SECONDS
|
|
779
|
+
) -> int:
|
|
780
|
+
"""Remove abandoned private run workspaces under ``workspace_root``.
|
|
781
|
+
|
|
782
|
+
``WorktreePool.cleanup_all`` removes a run's workspace on every ordinary exit,
|
|
783
|
+
but a SIGKILL or power loss skips it, and nothing else reclaims the control
|
|
784
|
+
clone plus its worktrees — so they accumulate under ``workspace.root``
|
|
785
|
+
forever. This is the counterpart of ``cleanup_orphaned_worktrees``, which only
|
|
786
|
+
ever sees the legacy in-repo worktree layout.
|
|
787
|
+
|
|
788
|
+
Three guards keep it from touching anything it shouldn't. ``workspace.root``
|
|
789
|
+
is shared with room and interactive-session workspaces, whose lifecycles are
|
|
790
|
+
tracked externally and can legitimately outlive any run, so a directory is
|
|
791
|
+
only a candidate if it carries ``_RUN_SENTINEL``. A workspace ``retain()``
|
|
792
|
+
marked for recovery is exempt permanently. And the run is only abandoned if
|
|
793
|
+
**the process that created it is gone** — asked directly, not inferred from
|
|
794
|
+
file timestamps, so no amount of idleness, tree size, or scan budget can make
|
|
795
|
+
a live run look dead. That is the whole reason liveness is a pid and not an
|
|
796
|
+
mtime: an agent thinking for hours writes nothing, and a heuristic that
|
|
797
|
+
guesses wrong deletes the workspace out from under it.
|
|
798
|
+
|
|
799
|
+
Age is only the backstop for the case a pid cannot decide — the owner is gone
|
|
800
|
+
but its pid has been reused, which would otherwise protect the leak forever —
|
|
801
|
+
so a live pid stops protecting a workspace once it is that stale. ``acquire``
|
|
802
|
+
refreshes the sentinel, so a run that is doing anything at all stays fresh, and
|
|
803
|
+
the backstop only reaches a workspace nothing has touched for ``older_than``
|
|
804
|
+
(the CLI passes ``workspace.stale_seconds``; a week by default). A sentinel
|
|
805
|
+
with no pid (written by an older version) falls back to age alone.
|
|
806
|
+
|
|
807
|
+
Fully best-effort: every error is ignored, like the sweeps in ``monitor.py``
|
|
808
|
+
and ``session.py``. Returns the number of workspaces removed.
|
|
809
|
+
"""
|
|
810
|
+
require_supported_platform()
|
|
811
|
+
try:
|
|
812
|
+
entries = list(workspace_root.expanduser().iterdir())
|
|
813
|
+
except OSError:
|
|
814
|
+
return 0
|
|
815
|
+
now = time.time()
|
|
816
|
+
removed = 0
|
|
817
|
+
for entry in entries:
|
|
818
|
+
if entry.name.startswith(("session-", "room-")):
|
|
819
|
+
continue
|
|
820
|
+
try:
|
|
821
|
+
sentinel = entry / _RUN_SENTINEL
|
|
822
|
+
if not (entry.is_dir() and sentinel.is_file()):
|
|
823
|
+
continue
|
|
824
|
+
if (entry / _RECOVERY_MARKER).exists():
|
|
825
|
+
continue
|
|
826
|
+
pid = _sentinel_owner_pid(sentinel)
|
|
827
|
+
stale = now - sentinel.stat().st_mtime > older_than
|
|
828
|
+
if pid is not None and _process_is_alive(pid) and not stale:
|
|
829
|
+
continue
|
|
830
|
+
if pid is None and not stale:
|
|
831
|
+
continue # no pid recorded: age is all there is to go on
|
|
832
|
+
except OSError:
|
|
833
|
+
continue
|
|
834
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
835
|
+
if not entry.exists():
|
|
836
|
+
removed += 1
|
|
837
|
+
logger.info("Swept abandoned run workspace %s", entry)
|
|
838
|
+
return removed
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@dataclass
|
|
842
|
+
class RetainedWorkspace:
|
|
843
|
+
"""One recovery-retained workspace under ``workspace.root`` (#118)."""
|
|
844
|
+
|
|
845
|
+
name: str # the workspace directory name (the run's dir name)
|
|
846
|
+
path: Path
|
|
847
|
+
age_seconds: float
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
def list_retained_workspaces(workspace_root: Path) -> list[RetainedWorkspace]:
|
|
851
|
+
"""Enumerate recovery-retained workspaces (oldest first).
|
|
852
|
+
|
|
853
|
+
These are the dirs ``retain()`` marked with ``_RECOVERY_MARKER`` —
|
|
854
|
+
permanently exempt from ``sweep_orphaned_workspaces``, so without this
|
|
855
|
+
surface they accumulate forever. Read-only and best-effort: enumeration
|
|
856
|
+
errors yield an empty/partial list, never an exception.
|
|
857
|
+
|
|
858
|
+
BOTH sentinels are required: ``_RUN_SENTINEL`` is this module's ownership
|
|
859
|
+
proof, and the recovery marker alone is just a filename a foreign
|
|
860
|
+
directory could happen to contain — which this listing feeds to a
|
|
861
|
+
destructive verb. Room and interactive-session workspaces are created
|
|
862
|
+
``sweepable=False`` (no run sentinel) and so are deliberately out of
|
|
863
|
+
scope here; a room's retained workspace is tracked in the rooms store,
|
|
864
|
+
where ``syncmill room recover`` reconciles its ownership and
|
|
865
|
+
``syncmill room close`` is what actually reclaims the directory.
|
|
866
|
+
"""
|
|
867
|
+
retained: list[RetainedWorkspace] = []
|
|
868
|
+
now = time.time()
|
|
869
|
+
require_supported_platform()
|
|
870
|
+
try:
|
|
871
|
+
entries = list(workspace_root.expanduser().iterdir())
|
|
872
|
+
except OSError:
|
|
873
|
+
return retained
|
|
874
|
+
for entry in entries:
|
|
875
|
+
if entry.name.startswith(("session-", "room-")):
|
|
876
|
+
continue
|
|
877
|
+
try:
|
|
878
|
+
marker = entry / _RECOVERY_MARKER
|
|
879
|
+
if not (
|
|
880
|
+
entry.is_dir()
|
|
881
|
+
and marker.is_file()
|
|
882
|
+
and (entry / _RUN_SENTINEL).is_file()
|
|
883
|
+
):
|
|
884
|
+
continue
|
|
885
|
+
age = max(0.0, now - marker.stat().st_mtime)
|
|
886
|
+
except OSError:
|
|
887
|
+
continue
|
|
888
|
+
retained.append(RetainedWorkspace(entry.name, entry, age))
|
|
889
|
+
retained.sort(key=lambda ws: -ws.age_seconds)
|
|
890
|
+
return retained
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def discard_retained_workspace(workspace_root: Path, name: str) -> Path:
|
|
894
|
+
"""Explicitly delete ONE recovery-retained workspace; fail closed.
|
|
895
|
+
|
|
896
|
+
Deletion stays explicit/human (#118): nothing ever auto-reaps a retained
|
|
897
|
+
workspace, this is the operator's reclaim verb. Destructive-boundary
|
|
898
|
+
exactness: the caller's ``name`` is matched as an exact child directory —
|
|
899
|
+
never a glob, never a path that can escape ``workspace_root`` — and BOTH
|
|
900
|
+
the ``_RECOVERY_MARKER`` written by ``retain()`` and this module's
|
|
901
|
+
``_RUN_SENTINEL`` ownership proof must be present, so an ordinary (possibly
|
|
902
|
+
live) run workspace, a room/session workspace, or a foreign directory that
|
|
903
|
+
merely contains a file of that name is refused with ``WorkspaceError``
|
|
904
|
+
rather than removed.
|
|
905
|
+
"""
|
|
906
|
+
require_supported_platform()
|
|
907
|
+
root = workspace_root.expanduser()
|
|
908
|
+
target = root / name
|
|
909
|
+
if target.name.startswith(("session-", "room-")):
|
|
910
|
+
raise WorkspaceError(
|
|
911
|
+
"not a recovery-retained run workspace; "
|
|
912
|
+
"use the session/room lifecycle to discard this workspace"
|
|
913
|
+
)
|
|
914
|
+
try:
|
|
915
|
+
if target.resolve().parent != root.resolve():
|
|
916
|
+
raise WorkspaceError(f"not a workspace name: {name!r}")
|
|
917
|
+
except OSError as exc:
|
|
918
|
+
raise WorkspaceError(f"could not resolve workspace {name!r}: {exc}") from exc
|
|
919
|
+
if not target.is_dir():
|
|
920
|
+
raise WorkspaceError(f"no retained workspace named {name!r}")
|
|
921
|
+
if not (
|
|
922
|
+
(target / _RECOVERY_MARKER).is_file() and (target / _RUN_SENTINEL).is_file()
|
|
923
|
+
):
|
|
924
|
+
raise WorkspaceError(
|
|
925
|
+
f"{name!r} is not a recovery-retained run workspace; refusing to delete"
|
|
926
|
+
)
|
|
927
|
+
shutil.rmtree(target)
|
|
928
|
+
return target
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def cleanup_orphaned_worktrees(repo_root: Path) -> int:
|
|
932
|
+
"""Remove orphaned syncmill worktrees left by crashed executions."""
|
|
933
|
+
require_supported_platform()
|
|
934
|
+
if not _is_git_repo(repo_root):
|
|
935
|
+
return 0
|
|
936
|
+
|
|
937
|
+
syncmill_dir = (repo_root.parent / ".syncmill-worktrees").resolve()
|
|
938
|
+
cleaned = 0
|
|
939
|
+
try:
|
|
940
|
+
proc = _run_git(
|
|
941
|
+
["git", "worktree", "list", "--porcelain"],
|
|
942
|
+
capture_output=True, text=True, cwd=str(repo_root),
|
|
943
|
+
env=isolated_git_env(),
|
|
944
|
+
)
|
|
945
|
+
if proc.returncode != 0:
|
|
946
|
+
return 0
|
|
947
|
+
|
|
948
|
+
entry: dict[str, str | None] = {"path": None, "branch": None}
|
|
949
|
+
|
|
950
|
+
def _flush() -> int:
|
|
951
|
+
path, branch = entry["path"], entry["branch"]
|
|
952
|
+
if not (branch and branch.startswith("syncmill/") and path):
|
|
953
|
+
return 0
|
|
954
|
+
wt_path = Path(path)
|
|
955
|
+
# Only ever touch worktrees inside our own .syncmill-worktrees dir —
|
|
956
|
+
# never force-remove a worktree/branch git reports from elsewhere.
|
|
957
|
+
if not _within(wt_path, syncmill_dir):
|
|
958
|
+
logger.warning("Skipping worktree outside %s: %s", syncmill_dir, wt_path)
|
|
959
|
+
return 0
|
|
960
|
+
if not wt_path.exists() or _is_empty_worktree(wt_path):
|
|
961
|
+
removed = cleanup_worktree(
|
|
962
|
+
repo_root,
|
|
963
|
+
Worktree(path=wt_path, branch=branch, agent_id="orphan", is_git=True),
|
|
964
|
+
)
|
|
965
|
+
return 1 if removed else 0
|
|
966
|
+
return 0
|
|
967
|
+
|
|
968
|
+
for line in proc.stdout.splitlines():
|
|
969
|
+
if line.startswith("worktree "):
|
|
970
|
+
entry = {"path": line[len("worktree "):], "branch": None}
|
|
971
|
+
elif line.startswith("branch refs/heads/"):
|
|
972
|
+
entry["branch"] = line[len("branch refs/heads/"):]
|
|
973
|
+
elif line == "":
|
|
974
|
+
cleaned += _flush()
|
|
975
|
+
entry = {"path": None, "branch": None}
|
|
976
|
+
# Flush the final entry if porcelain output had no trailing blank line.
|
|
977
|
+
cleaned += _flush()
|
|
978
|
+
except Exception as exc:
|
|
979
|
+
logger.warning("Error during orphan cleanup: %s", exc)
|
|
980
|
+
|
|
981
|
+
if cleaned:
|
|
982
|
+
logger.info("Cleaned up %d orphaned worktree(s)", cleaned)
|
|
983
|
+
return cleaned
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _within(path: Path, parent: Path) -> bool:
|
|
987
|
+
"""True if ``path`` is ``parent`` or nested under it (containment guard)."""
|
|
988
|
+
try:
|
|
989
|
+
path.resolve().relative_to(parent)
|
|
990
|
+
return True
|
|
991
|
+
except (ValueError, OSError):
|
|
992
|
+
return False
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
def _is_git_repo(path: Path) -> bool:
|
|
996
|
+
try:
|
|
997
|
+
proc = _run_git(
|
|
998
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
999
|
+
capture_output=True, text=True, cwd=str(path),
|
|
1000
|
+
env=isolated_git_env(),
|
|
1001
|
+
)
|
|
1002
|
+
return proc.returncode == 0
|
|
1003
|
+
except Exception:
|
|
1004
|
+
return False
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _rev_parse(path: Path, revision: str) -> str | None:
|
|
1008
|
+
try:
|
|
1009
|
+
proc = _run_git(
|
|
1010
|
+
["git", "rev-parse", "--verify", revision],
|
|
1011
|
+
capture_output=True, text=True, cwd=str(path),
|
|
1012
|
+
env=isolated_git_env(),
|
|
1013
|
+
)
|
|
1014
|
+
return proc.stdout.strip() if proc.returncode == 0 else None
|
|
1015
|
+
except OSError:
|
|
1016
|
+
return None
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _create_tempdir(agent_id: str) -> Worktree:
|
|
1020
|
+
tmpdir = Path(tempfile.mkdtemp(prefix=f"syncmill-{agent_id}-"))
|
|
1021
|
+
logger.debug("Created tempdir workspace at %s", tmpdir)
|
|
1022
|
+
return Worktree(
|
|
1023
|
+
path=tmpdir,
|
|
1024
|
+
branch=None,
|
|
1025
|
+
agent_id=agent_id,
|
|
1026
|
+
is_git=False,
|
|
1027
|
+
is_temp=True,
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _is_empty_worktree(path: Path) -> bool:
|
|
1032
|
+
"""Check if a worktree directory has no meaningful changes.
|
|
1033
|
+
|
|
1034
|
+
Returns False if emptiness can't be proven (status error/exception), so a
|
|
1035
|
+
transient failure never causes a worktree with real work to be deleted.
|
|
1036
|
+
"""
|
|
1037
|
+
try:
|
|
1038
|
+
proc = _run_git(
|
|
1039
|
+
["git", "status", "--porcelain"],
|
|
1040
|
+
capture_output=True, text=True, cwd=str(path),
|
|
1041
|
+
env=isolated_git_env(),
|
|
1042
|
+
)
|
|
1043
|
+
return proc.returncode == 0 and not proc.stdout.strip()
|
|
1044
|
+
except Exception:
|
|
1045
|
+
return False
|