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/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