syncade 0.6.2__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 (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/worktree.py ADDED
@@ -0,0 +1,497 @@
1
+ """Worktree management for blind reviewer dispatch.
2
+
3
+ Provides the primitives that put each reviewer in its own physically isolated
4
+ git worktree: the error type, default base path, run-id helper, and
5
+ ``WorktreeManager``.
6
+
7
+ This module is intentionally decoupled from :mod:`syncade.config`.
8
+ Callers pull values out of :class:`syncade.config.SyncadeConfig`
9
+ themselves and pass them in as plain types, which keeps the worktree
10
+ code narrowly scoped and trivially testable without a config object.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import shutil
17
+ import sys
18
+ from dataclasses import dataclass
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from types import TracebackType
22
+
23
+ from syncade.process import SubprocessError, SubprocessResult, run_subprocess
24
+
25
+ from .worktree_paths import (
26
+ WorktreeError,
27
+ _registered_path_differs_from_actual,
28
+ _strip_files,
29
+ _validate_reviewer_name,
30
+ )
31
+
32
+ DEFAULT_WORKTREE_BASE: Path = Path("/tmp/syncade")
33
+ """Filesystem location under which all per-run worktrees are created.
34
+
35
+ Configurable via :class:`WorktreeManager`'s constructor argument; the
36
+ default keeps worktrees out of the user's repo and on a tmpfs where the
37
+ OS will clear them on reboot.
38
+ """
39
+
40
+
41
+ TEST_WORKTREE_NAME: str = "tests"
42
+ """Reserved reviewer-name basename used by the test re-run leg.
43
+
44
+ Single source of truth — the orchestrator uses it for
45
+ the actual provisioning, and :mod:`syncade.config` uses it (via
46
+ ``casefold()`` comparison) to reject reviewer configs
47
+ that would collide with it when ``[loop] test_command`` is set.
48
+
49
+ Keeping the name in this module gives the orchestrator and config
50
+ validator a neutral import point without creating an import cycle:
51
+ config → worktree is one-way; worktree imports no syncade modules."""
52
+
53
+
54
+ _PREFIX_DISALLOWED = re.compile(r"[^A-Za-z0-9_-]")
55
+ """Inverse character class — anything that is *not* an ASCII
56
+ alphanumeric, hyphen, or underscore. Used to strip junk from prefixes."""
57
+
58
+ _GIT_TIMEOUT_SECONDS = 30.0
59
+
60
+
61
+ def _run_git(argv: list[str], *, cwd: Path, error_prefix: str) -> SubprocessResult:
62
+ try:
63
+ return run_subprocess(argv, cwd=cwd, timeout=_GIT_TIMEOUT_SECONDS)
64
+ except SubprocessError as exc:
65
+ raise WorktreeError(f"{error_prefix}: {exc}") from exc
66
+
67
+
68
+ def _run_git_best_effort(argv: list[str], *, cwd: Path) -> None:
69
+ try:
70
+ run_subprocess(argv, cwd=cwd, timeout=_GIT_TIMEOUT_SECONDS)
71
+ except SubprocessError:
72
+ return
73
+
74
+
75
+ def generate_run_id(prefix: str | None = None) -> str:
76
+ """Return a stable, human-readable run identifier.
77
+
78
+ Format: ``YYYY-MM-DDTHH-MM-SS[-prefix]`` (e.g. ``2026-05-11T17-23-04``
79
+ or ``2026-05-11T17-23-04-pr2`` if a prefix is supplied). The
80
+ colon-free form is intentional so the id can be used as a filesystem
81
+ path component on all platforms without quoting.
82
+
83
+ The prefix is sanitized in two passes: first, only ASCII
84
+ alphanumerics, hyphens, and underscores are kept; everything else
85
+ is stripped. Then leading and trailing hyphens and underscores are
86
+ trimmed so the joined id reads cleanly (no ``...-T--leading`` or
87
+ ``...-trailing-`` shapes). A prefix that is empty after both
88
+ passes (all junk, or only separator characters) yields a bare
89
+ timestamp with no suffix. An empty string is treated the same as
90
+ ``None``.
91
+
92
+ Resolution is one second. Two calls inside the same wall-clock
93
+ second produce identical ids — callers that need uniqueness across
94
+ rapid invocations must pass distinct prefixes.
95
+ """
96
+ timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
97
+ if not prefix:
98
+ return timestamp
99
+ cleaned = _PREFIX_DISALLOWED.sub("", prefix).strip("-_")
100
+ if not cleaned:
101
+ return timestamp
102
+ return f"{timestamp}-{cleaned}"
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class Worktree:
107
+ """A single managed worktree.
108
+
109
+ Immutable record produced by :meth:`WorktreeManager.create`; the
110
+ manager owns lifecycle (creation and cleanup).
111
+
112
+ Attributes:
113
+ path: Absolute path to the worktree on disk.
114
+ reviewer_name: Identifier passed at creation time. Used as the
115
+ directory name under ``run_dir``.
116
+ commit_sha: Full object ID the worktree is checked out
117
+ at. The :meth:`WorktreeManager.create` method canonicalizes whatever
118
+ commit-ish the caller supplied (short SHA, branch name,
119
+ tag, ...) to the underlying commit, so this field is
120
+ always the authoritative identifier.
121
+ """
122
+
123
+ path: Path
124
+ reviewer_name: str
125
+ commit_sha: str
126
+
127
+
128
+ class WorktreeManager:
129
+ """Provisions and cleans up per-reviewer git worktrees for a run.
130
+
131
+ Use as a context manager — on clean exit, all worktrees created
132
+ through this manager are removed. On exception, worktrees are
133
+ intentionally left in place so the user can inspect what each
134
+ reviewer saw.
135
+
136
+ Example::
137
+
138
+ run_id = generate_run_id("pr-3")
139
+ with WorktreeManager(repo_root, run_id) as mgr:
140
+ wt = mgr.create(
141
+ "claude-reviewer",
142
+ commit_sha,
143
+ strip_files=["CLAUDE.md", "AGENTS.md"],
144
+ )
145
+ # dispatch reviewer subprocess into wt.path ...
146
+ # cleanup happens automatically on context exit
147
+ """
148
+
149
+ def __init__(
150
+ self,
151
+ repo_root: Path,
152
+ run_id: str,
153
+ base_dir: Path = DEFAULT_WORKTREE_BASE,
154
+ *,
155
+ defer_cleanup: bool = False,
156
+ ) -> None:
157
+ """Construct the manager. No filesystem side effects yet.
158
+
159
+ Args:
160
+ repo_root: The git repo whose history we're worktreeing.
161
+ run_id: Identifier for this run (use :func:`generate_run_id`).
162
+ base_dir: Where to place worktrees on disk. Defaults to
163
+ :data:`DEFAULT_WORKTREE_BASE`.
164
+ defer_cleanup: When ``True``, the context
165
+ manager's ``__exit__`` does NOT auto-cleanup on
166
+ clean exit. The caller (orchestrator) is responsible
167
+ for calling :meth:`cleanup_all` explicitly later.
168
+ Used by the loop orchestrator to defer the
169
+ cleanup-vs-preserve decision until the loop's final
170
+ exit code is known: the PRD specifies that exits
171
+ 10/20/30 keep worktrees for inspection.
172
+ Default ``False`` auto-cleans on clean exit.
173
+ """
174
+ self._repo_root = Path(repo_root)
175
+ self._run_id = run_id
176
+ self._base_dir = Path(base_dir)
177
+ self._worktrees: list[Worktree] = []
178
+ self._defer_cleanup = defer_cleanup
179
+
180
+ @property
181
+ def run_dir(self) -> Path:
182
+ """``base_dir / run_id`` — the parent directory containing all
183
+ worktrees for this run."""
184
+ return self._base_dir / self._run_id
185
+
186
+ def create(
187
+ self,
188
+ reviewer_name: str,
189
+ commit_sha: str,
190
+ strip_files: list[str] | None = None,
191
+ ) -> Worktree:
192
+ """Create a worktree at ``run_dir / reviewer_name`` checked out
193
+ at ``commit_sha``.
194
+
195
+ ``reviewer_name`` must be a plain basename — empty strings,
196
+ ``"."``, ``".."``, names containing a path separator, and
197
+ absolute paths are rejected before any filesystem or git side
198
+ effects so a malformed name cannot make the worktree escape
199
+ :attr:`run_dir`.
200
+
201
+ If ``strip_files`` is provided, those filenames (matched by
202
+ basename recursively) are deleted after worktree creation;
203
+ missing files are not an error. Returns the resulting
204
+ :class:`Worktree` record.
205
+
206
+ Raises:
207
+ WorktreeError: On invalid ``reviewer_name``, on
208
+ ``git worktree add`` failure (message includes git
209
+ stderr), or if the target worktree directory already
210
+ exists.
211
+ """
212
+ _validate_reviewer_name(reviewer_name)
213
+ target = (self.run_dir / reviewer_name).absolute()
214
+ if target.exists():
215
+ raise WorktreeError(
216
+ f"git worktree add failed: target directory already exists: {target}"
217
+ )
218
+ # `git worktree add` requires the parent of the target to exist
219
+ # but does not create intermediate directories itself. If
220
+ # `run_dir` already exists as a (non-directory) file, mkdir
221
+ # raises FileExistsError; surface that as a typed
222
+ # provisioning error rather than a raw OSError so the CLI can
223
+ # map it to exit code 60.
224
+ try:
225
+ self.run_dir.mkdir(parents=True, exist_ok=True)
226
+ except OSError as exc:
227
+ raise WorktreeError(
228
+ f"git worktree add failed: could not create run_dir {self.run_dir}: {exc}"
229
+ ) from exc
230
+ # `refs/replace/*` lives in the shared common dir and is writable from a
231
+ # producer worktree. Without --no-replace-objects, `git worktree add <sha>`
232
+ # checks out the replacement object while `Snapshot.commit_sha` names the
233
+ # original — a backdoored commit reviewed as its benign replacement.
234
+ result = _run_git(
235
+ ["git", "--no-replace-objects", "worktree", "add", str(target), commit_sha],
236
+ cwd=self._repo_root,
237
+ error_prefix="git worktree add failed",
238
+ )
239
+ if result.returncode != 0:
240
+ raise WorktreeError(f"git worktree add failed: {result.stderr.strip()}")
241
+ # `git worktree add` succeeded — from here until we append to
242
+ # self._worktrees, any failure must roll back the worktree so
243
+ # we don't orphan the directory or the git registration. The
244
+ # try / except BaseException covers normal exceptions plus
245
+ # KeyboardInterrupt and SystemExit so a poorly-timed Ctrl-C
246
+ # also leaves the manager's invariants intact.
247
+ try:
248
+ # Canonicalize commit_sha to the full object ID. Caller
249
+ # may have passed a short SHA, branch name, or tag —
250
+ # `git rev-parse HEAD` inside the new worktree is the
251
+ # authoritative source.
252
+ rev_parse = _run_git(
253
+ ["git", "rev-parse", "HEAD"],
254
+ cwd=target,
255
+ error_prefix="git rev-parse HEAD failed",
256
+ )
257
+ if rev_parse.returncode != 0:
258
+ raise WorktreeError(f"git rev-parse HEAD failed: {rev_parse.stderr.strip()}")
259
+ worktree = Worktree(
260
+ path=target,
261
+ reviewer_name=reviewer_name,
262
+ commit_sha=rev_parse.stdout.strip(),
263
+ )
264
+ if strip_files:
265
+ _strip_files(target, strip_files)
266
+ self._worktrees.append(worktree)
267
+ return worktree
268
+ except BaseException:
269
+ self._rollback_add(target)
270
+ raise
271
+
272
+ def _rollback_add(self, target: Path) -> None:
273
+ """Undo a partially-completed :meth:`create` after
274
+ ``git worktree add`` has already succeeded but a later step
275
+ failed. Best-effort — runs ``git worktree remove --force`` and
276
+ ``shutil.rmtree`` and swallows everything, because we're
277
+ already in the middle of unwinding the caller's exception and
278
+ masking it would be worse than leaving stray state.
279
+ """
280
+ _run_git_best_effort(
281
+ ["git", "worktree", "remove", "--force", str(target)],
282
+ cwd=self._repo_root,
283
+ )
284
+ shutil.rmtree(target, ignore_errors=True)
285
+
286
+ def cleanup(self, worktree: Worktree) -> None:
287
+ """Remove a single worktree.
288
+
289
+ Runs ``git worktree remove --force`` on the path, then
290
+ ``shutil.rmtree`` as a belt-and-braces filesystem clean,
291
+ then ``git worktree prune`` to drop any stale
292
+ ``.git/worktrees/<name>/`` metadata git might still hold.
293
+
294
+ Safe to call multiple times: if the worktree path no
295
+ longer exists when called, runs ``git worktree prune``
296
+ anyway (to clear stale metadata from a prior
297
+ non-git-mediated deletion) and returns. If the path
298
+ still exists but ``git worktree remove`` fails, the
299
+ recovery path detects stale-metadata drift by comparing the
300
+ registered gitdir-file path to the actual path. This is
301
+ locale-independent and avoids English-substring matching
302
+ against git's stderr. The recovery path either succeeds via a
303
+ prune-then-rmtree fallback or raises
304
+ :class:`WorktreeError` with the diagnostics.
305
+
306
+ The recovery path is structured around three goals:
307
+
308
+ 1. Leave git registration AND filesystem in a consistent
309
+ state — never end in "git no longer recognizes the
310
+ path, but the directory still exists".
311
+ 2. Tolerate metadata drift from external rm / symlink-
312
+ resolution changes / interrupted prior cleanups
313
+ without surfacing English-substring matching to the
314
+ caller.
315
+ 3. Stay best-effort idempotent: cleanup of an already-gone
316
+ worktree never raises; the only raise path is a hard
317
+ failure where the directory genuinely still exists on
318
+ disk AND git failed to remove it AND our fallback
319
+ rmtree also failed.
320
+
321
+ :meth:`__exit__` catches and warns on any such failure so
322
+ an original in-flight exception is never masked.
323
+ """
324
+ if not worktree.path.exists():
325
+ # Path is gone but git's metadata might still point at
326
+ # it. Prune now so a future provisioning attempt with
327
+ # the same name doesn't hit the stale-metadata
328
+ # validation error.
329
+ self._git_worktree_prune()
330
+ return
331
+ result = _run_git(
332
+ ["git", "worktree", "remove", "--force", str(worktree.path)],
333
+ cwd=self._repo_root,
334
+ error_prefix="git worktree remove failed",
335
+ )
336
+ if result.returncode == 0:
337
+ shutil.rmtree(worktree.path, ignore_errors=True)
338
+ # Prune is cheap and idempotent; run it even on the
339
+ # happy path in case the remove succeeded but left
340
+ # metadata fragments (defensive — git's own remove
341
+ # generally cleans this up, but edge cases exist).
342
+ self._git_worktree_prune()
343
+ return
344
+
345
+ # remove failed. If the path disappeared mid-call (a
346
+ # concurrent actor), that's still idempotent "nothing to
347
+ # do" — prune any partial metadata git may have left and
348
+ # exit cleanly.
349
+ if not worktree.path.exists():
350
+ self._git_worktree_prune()
351
+ return
352
+
353
+ # remove failed AND the path still exists. Detect the
354
+ # stale-metadata-drift case structurally: the
355
+ # registered ``.git/worktrees/<name>/gitdir`` file
356
+ # records the worktree path git thinks the registration
357
+ # points at. If it disagrees with the actual on-disk
358
+ # path (symlink-resolution drift, prior partial cleanup),
359
+ # prune + try again. Even if prune + retry fails, fall
360
+ # through to rmtree the on-disk directory — at that
361
+ # point git has dropped its registration so rmtree is
362
+ # safe and doesn't leave inconsistent state.
363
+ original_stderr = result.stderr.strip()
364
+ if _registered_path_differs_from_actual(self._repo_root, worktree):
365
+ self._git_worktree_prune()
366
+ retry = _run_git(
367
+ ["git", "worktree", "remove", "--force", str(worktree.path)],
368
+ cwd=self._repo_root,
369
+ error_prefix="git worktree remove failed",
370
+ )
371
+ if retry.returncode == 0:
372
+ shutil.rmtree(worktree.path, ignore_errors=True)
373
+ self._git_worktree_prune()
374
+ return
375
+ # prune cleared git's registration, but the
376
+ # remove retry failed (typically because git no
377
+ # longer recognizes the path as a worktree). Without
378
+ # this fallback the directory was left on disk →
379
+ # next worktree-add with the same name would hit
380
+ # "target directory already exists". rmtree the
381
+ # directory directly now that git has nothing
382
+ # registered to inconsistently affect.
383
+ try:
384
+ shutil.rmtree(worktree.path)
385
+ except OSError:
386
+ # rmtree failed too — at this point we genuinely
387
+ # can't clean up. Raise with the original git
388
+ # diagnostic so the operator sees the first cause.
389
+ raise WorktreeError(
390
+ f"git worktree remove failed AND structural-recovery "
391
+ f"rmtree of {worktree.path} also failed: "
392
+ f"{original_stderr}"
393
+ ) from None
394
+ self._git_worktree_prune()
395
+ return
396
+
397
+ # Not a stale-metadata case — surface git's original
398
+ # diagnostic verbatim.
399
+ raise WorktreeError(f"git worktree remove failed: {original_stderr}")
400
+
401
+ def _git_worktree_prune(self) -> None:
402
+ """Run ``git worktree prune`` to clean stale registered
403
+ worktree metadata. Best-effort: silently no-op on git
404
+ error (this is a cleanup path; we'd already be losing the
405
+ original failure context if we raised here).
406
+
407
+ Git's ``.git/worktrees/<name>/`` directories record the registered
408
+ worktree path. When the actual worktree directory is deleted
409
+ out-of-band (not via ``git worktree remove``), the metadata persists
410
+ and pollutes subsequent worktree-add attempts. ``git worktree prune``
411
+ is the canonical cleanup: it walks the metadata and drops any entry
412
+ whose ``gitdir`` file points at a nonexistent path.
413
+ """
414
+ _run_git_best_effort(
415
+ ["git", "worktree", "prune"],
416
+ cwd=self._repo_root,
417
+ )
418
+
419
+ def cleanup_all(self) -> None:
420
+ """Remove every worktree created through this manager.
421
+
422
+ Each worktree is cleaned independently. A per-entry failure
423
+ (e.g. a git lock or NFS stall on ``git worktree remove``) is
424
+ warned to stderr and the loop continues, so one stuck worktree
425
+ can never orphan the rest. After every entry has been attempted
426
+ the accumulated failures are re-raised as a single
427
+ :class:`WorktreeError`, preserving the existing caller contract
428
+ (:meth:`__exit__` warns; ``loop_finalize`` swallows).
429
+
430
+ Also attempts to remove :attr:`run_dir` itself if it is empty
431
+ after cleanup. A non-empty ``run_dir`` is left alone — likely
432
+ another concurrent run, or stray reviewer scratch state worth
433
+ preserving for inspection.
434
+ """
435
+ errors: list[str] = []
436
+ for worktree in self._worktrees:
437
+ try:
438
+ self.cleanup(worktree)
439
+ except WorktreeError as exc:
440
+ # Isolate the failure: warn and keep going so a single
441
+ # stuck entry never strands the worktrees behind it.
442
+ print(
443
+ f"[syncade] warning: failed to clean up worktree "
444
+ f"{worktree.reviewer_name!r} at {worktree.path}: {exc}",
445
+ file=sys.stderr,
446
+ )
447
+ errors.append(str(exc))
448
+ if self.run_dir.exists():
449
+ try:
450
+ self.run_dir.rmdir()
451
+ except OSError:
452
+ # Not empty (or otherwise un-removable) — leave it.
453
+ pass
454
+ if errors:
455
+ raise WorktreeError(
456
+ f"cleanup_all: {len(errors)} of {len(self._worktrees)} "
457
+ "worktree(s) failed to clean up: " + "; ".join(errors)
458
+ )
459
+
460
+ def __enter__(self) -> WorktreeManager:
461
+ """Enter the context manager. No side effects beyond returning
462
+ self; worktrees are only created via explicit :meth:`create`
463
+ calls."""
464
+ return self
465
+
466
+ def __exit__(
467
+ self,
468
+ exc_type: type[BaseException] | None,
469
+ exc_val: BaseException | None,
470
+ exc_tb: TracebackType | None,
471
+ ) -> None:
472
+ """Exit the context manager.
473
+
474
+ On clean exit (``exc_type is None``) AND ``defer_cleanup``
475
+ is ``False`` (the default), calls :meth:`cleanup_all`. On
476
+ exception exit, worktrees are intentionally preserved so
477
+ the user can inspect what each reviewer saw. When
478
+ ``defer_cleanup=True``, cleanup is skipped on clean exit
479
+ too — the caller (orchestrator) calls
480
+ :meth:`cleanup_all` explicitly later based on the loop's
481
+ final exit code.
482
+
483
+ Either way, this method must not raise — failures during
484
+ cleanup are warned to stderr rather than propagated, so the
485
+ original exception is not masked.
486
+ """
487
+ if exc_type is not None:
488
+ return
489
+ if self._defer_cleanup:
490
+ return
491
+ try:
492
+ self.cleanup_all()
493
+ except Exception as exc:
494
+ print(
495
+ f"[syncade] warning: worktree cleanup failed: {exc}",
496
+ file=sys.stderr,
497
+ )
@@ -0,0 +1,133 @@
1
+ """Worktree-scoped Python environment for subprocess legs.
2
+
3
+ **The bleed this closes.** ``pip install -e .`` drops an editable-install
4
+ ``.pth`` (``__editable__.syncade-<version>.pth``) into the venv's site-packages
5
+ containing a bare path to the operator's MAIN repo ``src``. A bare-path ``.pth``
6
+ line is processed by :mod:`site` at interpreter startup and added to
7
+ ``sys.path`` — **cwd-independent**. So every worktree subprocess (reviewers,
8
+ producer, the authoritative test leg, the checks leg) that inherits the
9
+ operator's environment resolves ``import syncade`` — and runs ``pytest`` — against
10
+ MAIN's ``src``, not the worktree's snapshot. That is a correctness hole (the test
11
+ leg certifies the wrong tree) and the cause of claude-reviewer's timeouts (it
12
+ burned ~20 min detecting and hand-working-around the wrong-src import).
13
+
14
+ **Isolation strategy.** The common editable install is the *plain-path* ``.pth``
15
+ variant: ``site`` appends MAIN's ``src`` after ``PYTHONPATH``, so putting
16
+ ``<worktree>/src`` near the front makes the worktree resolve first. Setuptools
17
+ can also install a strict editable hook as a ``MetaPathFinder``; that hook beats
18
+ ``sys.path`` and would still route ``syncade`` imports to MAIN. To cover both
19
+ forms, the child env prepends a tiny ``sitecustomize`` shim directory before
20
+ ``<worktree>/src``. At interpreter startup the shim removes syncade-targeting
21
+ editable finders, then normal ``sys.path`` resolution imports the worktree copy.
22
+
23
+ This is verified empirically, not assumed:
24
+ :func:`tests.test_worktree_env.test_real_subprocess_imports_worktree_src_not_main`
25
+ launches a real subprocess in *this* venv (whose ``.pth`` is live) and asserts
26
+ ``syncade.__file__`` resolves under the worktree, not MAIN. A companion test
27
+ injects a strict editable-style ``MetaPathFinder`` and verifies the startup shim
28
+ neutralizes it before the first ``syncade`` import.
29
+
30
+ One helper, all four legs — the two reviewer adapters, the two producer adapters,
31
+ ``test_runner.run_tests`` — including the mechanical-check leg —
32
+ so no leg can drift back onto the inherited env.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import os
38
+ import tempfile
39
+ from pathlib import Path
40
+
41
+ _SHIM_DIRNAME = "syncade-worktree-env-shim-v1"
42
+ _SHIM_SOURCE = """\
43
+ \"\"\"Neutralize parent editable-install hooks for syncade worktree children.\"\"\"
44
+
45
+ from __future__ import annotations
46
+
47
+ import sys
48
+
49
+
50
+ def _module_name(obj):
51
+ return getattr(obj, "__module__", None) or type(obj).__module__
52
+
53
+
54
+ def _finder_targets_syncade(finder) -> bool:
55
+ module_name = _module_name(finder)
56
+ module = sys.modules.get(module_name)
57
+ names = set()
58
+ if module is not None:
59
+ mapping = getattr(module, "MAPPING", {})
60
+ namespaces = getattr(module, "NAMESPACES", {})
61
+ if isinstance(mapping, dict):
62
+ names.update(str(name) for name in mapping)
63
+ if isinstance(namespaces, dict):
64
+ names.update(str(name) for name in namespaces)
65
+ return (
66
+ module_name.startswith("__editable___syncade")
67
+ or "syncade" in names
68
+ or any(name.startswith("syncade.") for name in names)
69
+ )
70
+
71
+
72
+ sys.meta_path[:] = [finder for finder in sys.meta_path if not _finder_targets_syncade(finder)]
73
+ sys.path[:] = [path for path in sys.path if "__editable__.syncade" not in path]
74
+ """
75
+
76
+
77
+ def _ensure_startup_shim() -> str:
78
+ shim_dir = Path(tempfile.gettempdir()) / _SHIM_DIRNAME
79
+ shim_dir.mkdir(parents=True, exist_ok=True)
80
+ shim_path = shim_dir / "sitecustomize.py"
81
+ if not shim_path.exists() or shim_path.read_text(encoding="utf-8") != _SHIM_SOURCE:
82
+ handle, tmp_name = tempfile.mkstemp(
83
+ prefix=f".sitecustomize.{os.getpid()}.",
84
+ suffix=".tmp",
85
+ dir=shim_dir,
86
+ text=True,
87
+ )
88
+ tmp_path = Path(tmp_name)
89
+ try:
90
+ with os.fdopen(handle, "w", encoding="utf-8") as tmp:
91
+ tmp.write(_SHIM_SOURCE)
92
+ os.replace(tmp_path, shim_path)
93
+ finally:
94
+ try:
95
+ tmp_path.unlink()
96
+ except FileNotFoundError:
97
+ pass
98
+ return str(shim_dir)
99
+
100
+
101
+ def worktree_scoped_env(worktree_path: Path) -> dict[str, str]:
102
+ """Build a child environment that resolves ``syncade`` to *this worktree's*
103
+ ``src`` instead of the operator's MAIN repo.
104
+
105
+ Inherits the operator's environment verbatim (so keychain / OAuth /
106
+ ``ANTHROPIC_API_KEY`` auth still flows through) and prepends a startup shim
107
+ plus ``<worktree_path>/src`` to ``PYTHONPATH`` — ahead of any pre-existing
108
+ ``PYTHONPATH`` and the editable install's MAIN path (see module docstring).
109
+
110
+ Args:
111
+ worktree_path: The git-worktree root the subprocess will run in. Its
112
+ ``src`` need not yet exist; a non-existent ``PYTHONPATH`` entry is
113
+ harmlessly ignored by the child interpreter.
114
+
115
+ Returns:
116
+ A new ``dict`` suitable for ``run_subprocess(env=...)`` / ``Invocation.env``.
117
+ The caller's own ``os.environ`` is never mutated.
118
+ """
119
+ env = dict(os.environ)
120
+ shim_dir = _ensure_startup_shim()
121
+ worktree_src = str(worktree_path / "src")
122
+ existing = env.get("PYTHONPATH")
123
+ scoped_entries = [shim_dir, worktree_src]
124
+ if existing:
125
+ scoped_entries.append(existing)
126
+ env["PYTHONPATH"] = os.pathsep.join(scoped_entries)
127
+ # Any git command run inside a worktree subprocess must see the ORIGINAL
128
+ # object graph. `refs/replace/*` lives in the shared common dir and is
129
+ # writable from a producer worktree, so without this flag a reviewer doing
130
+ # `git show HEAD:f.py` or a test doing `git diff HEAD` would silently read
131
+ # replacement objects rather than the objects named by the snapshot OID.
132
+ env["GIT_NO_REPLACE_OBJECTS"] = "1"
133
+ return env