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/gc_execute.py ADDED
@@ -0,0 +1,372 @@
1
+ """Best-effort destructive execution for ``syncade --gc`` plans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import signal
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from syncade.gc_protection import (
12
+ current_protected_run_ids,
13
+ orphan_worktree_still_orphan_now,
14
+ run_dir_slimmable_now,
15
+ run_id_protected_now,
16
+ )
17
+ from syncade.gc_types import BULK_ARTIFACT_SUFFIXES, GcPlan, GcReport
18
+ from syncade.gc_worktrees import tree_contains_repo_root, tree_identity
19
+ from syncade.process import SubprocessError, run_subprocess
20
+
21
+ _LSOF_TIMEOUT_SECONDS: float = 30.0
22
+ _GIT_PRUNE_TIMEOUT_SECONDS: float = 30.0
23
+
24
+
25
+ def execute_gc(plan: GcPlan, *, dry_run: bool, repo_root: Path) -> GcReport:
26
+ """Carry out a :class:`GcPlan`. Best-effort; never raises out."""
27
+ errors: list[str] = []
28
+ pids_reaped: list[int] = []
29
+ worktrees_removed: list[Path] = []
30
+ protected = set(plan.protected_run_ids) | current_protected_run_ids(repo_root)
31
+
32
+ pruned_any = False
33
+ for tree in plan.worktree_trees_to_remove:
34
+ if run_id_protected_now(repo_root, tree.name, protected):
35
+ continue
36
+ if tree_contains_repo_root(tree, repo_root):
37
+ errors.append(f"skipping worktree tree {tree}: it contains repo root {repo_root}")
38
+ continue
39
+ if not _planned_tree_identity_still_matches(plan, tree, errors, dry_run=dry_run):
40
+ continue
41
+ reaped, removed = _reap_and_remove_tree(tree, dry_run=dry_run, errors=errors)
42
+ pids_reaped.extend(reaped)
43
+ if removed:
44
+ worktrees_removed.append(tree)
45
+ pruned_any = True
46
+
47
+ for tree in plan.orphan_worktree_trees:
48
+ if tree_contains_repo_root(tree, repo_root):
49
+ errors.append(f"skipping worktree tree {tree}: it contains repo root {repo_root}")
50
+ continue
51
+ if not _planned_tree_identity_still_matches(plan, tree, errors, dry_run=dry_run):
52
+ continue
53
+ if not orphan_worktree_still_orphan_now(repo_root, tree, protected):
54
+ continue
55
+ reaped, removed = _reap_and_remove_tree(tree, dry_run=dry_run, errors=errors)
56
+ pids_reaped.extend(reaped)
57
+ if removed:
58
+ worktrees_removed.append(tree)
59
+ pruned_any = True
60
+
61
+ if pruned_any and not dry_run:
62
+ _git_worktree_prune(repo_root, errors)
63
+
64
+ runs_slimmed: list[str] = []
65
+ bytes_freed = 0
66
+ runs_root = repo_root / ".syncade" / "runs"
67
+ for run_id in plan.runs_to_slim:
68
+ run_dir = runs_root / run_id
69
+ if not run_dir_slimmable_now(run_dir, run_id, protected):
70
+ continue
71
+ slimmed = _slim_run_dir(run_dir, runs_root, dry_run=dry_run, errors=errors)
72
+ if slimmed is None:
73
+ continue
74
+ freed, removed = slimmed
75
+ bytes_freed += freed
76
+ # Report on artifacts REMOVED, not bytes freed. Bytes are the wrong proxy for
77
+ # "did anything happen": a run whose transcripts are all zero-byte gets its
78
+ # files unlinked while `freed` stays 0, so keying off bytes made the report
79
+ # claim nothing was slimmed while the tree was mutated — and made --gc-dry-run
80
+ # promise the same. An already-slim run removes nothing, so idempotence still
81
+ # holds.
82
+ if removed > 0:
83
+ runs_slimmed.append(run_id)
84
+
85
+ return GcReport(
86
+ runs_slimmed=runs_slimmed,
87
+ worktrees_removed=worktrees_removed,
88
+ pids_reaped=pids_reaped,
89
+ bytes_freed=bytes_freed,
90
+ errors=errors,
91
+ dry_run=dry_run,
92
+ )
93
+
94
+
95
+ def _slim_run_dir(
96
+ run_dir: Path, runs_root: Path, *, dry_run: bool, errors: list[str]
97
+ ) -> tuple[int, int] | None:
98
+ """Prune only the bulky subprocess transcripts under ``round-*/``.
99
+
100
+ The run directory itself, its run-root artifacts (``loop-manifest.json``,
101
+ ``run-init.json``, ``findings.md``, ``handoff.md``, ``status.json`` …) and every
102
+ structured per-round artifact (round manifests, parsed findings, summaries, exit
103
+ codes) all SURVIVE. See :data:`BULK_ARTIFACT_SUFFIXES` for why.
104
+
105
+ Returns ``(bytes_freed, artifacts_removed)`` — both 0 if the run was already slim —
106
+ or ``None`` if the run dir could not be walked at all, in which case the caller
107
+ skips it rather than reporting a slim that did not happen.
108
+
109
+ The count is returned alongside the bytes because bytes alone cannot answer "did
110
+ anything happen": a zero-byte transcript is still an artifact that gets unlinked.
111
+
112
+ **Symlinks are refused, not followed, and containment is anchored to THIS RUN's
113
+ resolved directory — not merely to the corpus root.** Four escapes have been
114
+ reproduced against earlier drafts of this function:
115
+
116
+ 1. a ``round-*`` entry that is a symlink → refused in the listing filter AND
117
+ rechecked at the top of :func:`_bulk_artifacts_under` (TOCTOU defense);
118
+ 2. a symlinked subdirectory beneath a legitimate round dir → ``os.walk`` runs with
119
+ ``followlinks=False``;
120
+ 3. **the run directory itself swapped for a symlink between plan and execute** —
121
+ :func:`~syncade.gc_protection.run_dir_slimmable_now` independently refuses a
122
+ symlinked run dir; ``run_dir_resolved.relative_to(corpus_root)`` is the
123
+ second, load-bearing containment layer;
124
+ 4. **a round directory swapped for a symlink pointing at a protected run inside the
125
+ same corpus** (intra-corpus TOCTOU, found by round-1 reviewers, 2026-07-12).
126
+ Anchoring containment on ``corpus_root`` alone would allow this: a file from
127
+ another run inside the corpus would pass the corpus-relative check. Anchoring on
128
+ ``run_dir_resolved`` closes it — a file inside a different run is not relative to
129
+ this run's resolved path.
130
+ """
131
+ if run_dir.is_symlink():
132
+ errors.append(f"skipping run dir {run_dir}: it is a symlink")
133
+ return None
134
+ try:
135
+ corpus_root = runs_root.resolve(strict=True)
136
+ # The run dir must genuinely live inside the corpus — not merely claim to.
137
+ run_dir_resolved = run_dir.resolve(strict=True)
138
+ run_dir_resolved.relative_to(corpus_root)
139
+ entries = list(run_dir.iterdir())
140
+ except (OSError, ValueError) as exc:
141
+ errors.append(
142
+ f"skipping run dir {run_dir}: not a real directory inside {runs_root} ({exc})"
143
+ )
144
+ return None
145
+ round_dirs = [
146
+ d for d in entries if d.name.startswith("round-") and not d.is_symlink() and d.is_dir()
147
+ ]
148
+
149
+ freed = 0
150
+ removed = 0
151
+ for round_dir in round_dirs:
152
+ for artifact in _bulk_artifacts_under(round_dir, run_dir_resolved, errors):
153
+ try:
154
+ size = artifact.stat().st_size
155
+ except OSError:
156
+ continue
157
+ if dry_run:
158
+ freed += size
159
+ removed += 1
160
+ continue
161
+ try:
162
+ artifact.unlink()
163
+ freed += size
164
+ removed += 1
165
+ except OSError as exc:
166
+ errors.append(f"failed to remove transcript {artifact}: {exc}")
167
+ return freed, removed
168
+
169
+
170
+ def _bulk_artifacts_under(round_dir: Path, run_root: Path, errors: list[str]) -> list[Path]:
171
+ """Transcripts under ``round_dir`` that provably live inside ``run_root``
172
+ (the resolved run directory).
173
+
174
+ Walks with ``followlinks=False`` so a symlinked subdirectory cannot widen the
175
+ blast radius, skips symlinked files, and containment-checks every survivor against
176
+ the resolved run root — not the corpus root. Anchoring on the run rather than the
177
+ corpus is the key fix for the intra-corpus round-dir TOCTOU: a symlinked round dir
178
+ pointing at a *different* run inside the same corpus would pass a corpus-anchored
179
+ check, but fails a run-anchored one. **GC never unlinks anything outside this run.**
180
+
181
+ The first line is a TOCTOU recheck: ``round_dir`` may have been swapped to a
182
+ symlink after the caller's ``not d.is_symlink()`` filter; ``os.walk`` still
183
+ traverses a top-level symlink even with ``followlinks=False``, so we must recheck
184
+ before walking.
185
+ """
186
+ if round_dir.is_symlink():
187
+ errors.append(f"skipping round dir {round_dir}: became a symlink (TOCTOU)")
188
+ return []
189
+ found: list[Path] = []
190
+ for dirpath, dirnames, filenames in os.walk(round_dir, followlinks=False):
191
+ here = Path(dirpath)
192
+ # os.walk(followlinks=False) still *lists* symlinked dirs; don't descend.
193
+ dirnames[:] = [d for d in dirnames if not (here / d).is_symlink()]
194
+ for name in filenames:
195
+ candidate = here / name
196
+ if candidate.suffix not in BULK_ARTIFACT_SUFFIXES:
197
+ continue
198
+ if candidate.is_symlink():
199
+ continue
200
+ try:
201
+ candidate.resolve(strict=True).relative_to(run_root)
202
+ except (OSError, ValueError):
203
+ errors.append(f"skipping transcript outside the run directory: {candidate}")
204
+ continue
205
+ found.append(candidate)
206
+ return found
207
+
208
+
209
+ def _planned_tree_identity_still_matches(
210
+ plan: GcPlan, tree: Path, errors: list[str], *, dry_run: bool
211
+ ) -> bool:
212
+ expected = plan.worktree_tree_identities.get(tree)
213
+ if expected is None:
214
+ if dry_run:
215
+ return True
216
+ if tree_identity(tree) is None:
217
+ return True
218
+ errors.append(f"skipping worktree tree {tree}: GC plan has no recorded directory identity")
219
+ return False
220
+ actual = tree_identity(tree)
221
+ if actual == expected:
222
+ return True
223
+ errors.append(
224
+ f"skipping worktree tree {tree}: it changed since GC planning "
225
+ f"(planned identity={expected!r}, current identity={actual!r})"
226
+ )
227
+ return False
228
+
229
+
230
+ def _reap_and_remove_tree(
231
+ tree: Path, *, dry_run: bool, errors: list[str]
232
+ ) -> tuple[list[int], bool]:
233
+ """Reap in-cwd processes, then rmtree a single worktree tree."""
234
+ try:
235
+ if tree.is_symlink():
236
+ errors.append(f"skipping unsafe symlink worktree tree {tree}")
237
+ return [], False
238
+ except OSError as exc:
239
+ errors.append(f"failed to inspect worktree tree {tree}: {exc}")
240
+ return [], False
241
+
242
+ reaped = _reap_processes_in_tree(tree, errors, dry_run=dry_run)
243
+
244
+ if dry_run:
245
+ return reaped, True
246
+
247
+ shutil.rmtree(tree, ignore_errors=True)
248
+ if tree.exists():
249
+ errors.append(
250
+ f"failed to remove worktree tree {tree} (still present after rmtree; "
251
+ f"permission denied?)"
252
+ )
253
+ return reaped, False
254
+ return reaped, True
255
+
256
+
257
+ def _reap_processes_in_tree(tree: Path, errors: list[str], *, dry_run: bool = False) -> list[int]:
258
+ """Return or reap PIDs whose current working directory is inside ``tree``."""
259
+ pids = _lsof_pids_in_tree(tree, errors)
260
+ if dry_run:
261
+ return pids
262
+ reaped: list[int] = []
263
+ for pid in pids:
264
+ if not _pid_cwd_is_still_in_tree(pid, tree, errors):
265
+ continue
266
+ try:
267
+ os.kill(pid, signal.SIGKILL)
268
+ reaped.append(pid)
269
+ except (ProcessLookupError, PermissionError) as exc:
270
+ errors.append(f"could not reap pid {pid} in {tree}: {exc}")
271
+ return reaped
272
+
273
+
274
+ def reap_processes_in_tree(tree: Path) -> list[int]:
275
+ """SIGKILL every process whose cwd is inside ``tree``; return reaped PIDs.
276
+
277
+ Public seam shared by GC and resume cleanup so both apply the SAME
278
+ cwd-scoped live-process safety before an ``rmtree`` — a directory is
279
+ never removed out from under a running (orphaned) subprocess. Best
280
+ effort: an ``lsof`` recheck failure is swallowed and that pid is skipped.
281
+ """
282
+ errors: list[str] = []
283
+ return _reap_processes_in_tree(tree, errors)
284
+
285
+
286
+ def _pid_cwd_is_still_in_tree(pid: int, tree: Path, errors: list[str]) -> bool:
287
+ try:
288
+ result = run_subprocess(
289
+ ["lsof", "-t", "-a", "-d", "cwd", "-p", str(pid), "+D", str(tree)],
290
+ timeout=_LSOF_TIMEOUT_SECONDS,
291
+ )
292
+ except SubprocessError as exc:
293
+ msg = (
294
+ f"WARNING: lsof recheck unavailable for pid {pid} in {tree} ({exc}); skipping this pid."
295
+ )
296
+ print(msg, file=sys.stderr)
297
+ errors.append(msg)
298
+ return False
299
+
300
+ if pid in _parse_lsof_pids(result.stdout):
301
+ return True
302
+ if result.returncode != 0 and result.stderr.strip():
303
+ msg = (
304
+ f"WARNING: lsof recheck errored for pid {pid} in {tree} "
305
+ f"(rc={result.returncode}: {result.stderr.strip()}); skipping this pid."
306
+ )
307
+ print(msg, file=sys.stderr)
308
+ errors.append(msg)
309
+ return False
310
+
311
+
312
+ def _lsof_pids_in_tree(tree: Path, errors: list[str]) -> list[int]:
313
+ try:
314
+ result = run_subprocess(
315
+ ["lsof", "-t", "-a", "-d", "cwd", "+D", str(tree)],
316
+ timeout=_LSOF_TIMEOUT_SECONDS,
317
+ )
318
+ except SubprocessError as exc:
319
+ msg = (
320
+ f"WARNING: lsof unavailable for {tree} ({exc}); skipping process "
321
+ f"reaping for this tree (still removing the directory)."
322
+ )
323
+ print(msg, file=sys.stderr)
324
+ errors.append(msg)
325
+ return []
326
+
327
+ if result.returncode != 0 and result.stderr.strip():
328
+ msg = (
329
+ f"WARNING: lsof errored for {tree} (rc={result.returncode}: "
330
+ f"{result.stderr.strip()}); skipping process reaping for this tree "
331
+ f"(still removing the directory)."
332
+ )
333
+ print(msg, file=sys.stderr)
334
+ errors.append(msg)
335
+ return []
336
+
337
+ return _parse_lsof_pids(result.stdout)
338
+
339
+
340
+ def _parse_lsof_pids(stdout: str) -> list[int]:
341
+ """Parse unique PIDs from terse or tabular ``lsof`` output."""
342
+ pids: list[int] = []
343
+ seen: set[int] = set()
344
+ for line in stdout.splitlines():
345
+ line = line.strip()
346
+ if not line:
347
+ continue
348
+ token = line.split()[0]
349
+ if token.startswith("p") and token[1:].isdigit():
350
+ token = token[1:]
351
+ if token.isdigit():
352
+ pid = int(token)
353
+ else:
354
+ parts = line.split()
355
+ if len(parts) < 2 or not parts[1].isdigit():
356
+ continue
357
+ pid = int(parts[1])
358
+ if pid not in seen:
359
+ seen.add(pid)
360
+ pids.append(pid)
361
+ return pids
362
+
363
+
364
+ def _git_worktree_prune(repo_root: Path, errors: list[str]) -> None:
365
+ try:
366
+ run_subprocess(
367
+ ["git", "worktree", "prune"],
368
+ cwd=repo_root,
369
+ timeout=_GIT_PRUNE_TIMEOUT_SECONDS,
370
+ )
371
+ except SubprocessError as exc:
372
+ errors.append(f"git worktree prune failed in {repo_root}: {exc}")
@@ -0,0 +1,129 @@
1
+ """Run-protection checks shared by GC planning and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from syncade.gc_worktrees import repo_owned_orphan_trees
9
+ from syncade.orchestrator.resume import find_resumable_runs
10
+ from syncade.orchestrator.resume_types import _RESUMABLE_EXIT_CODES, LOOP_MANIFEST_FILENAME
11
+ from syncade.persistence import RUN_INIT_FILENAME
12
+
13
+
14
+ def protected_run_ids_for_gc(runs_root: Path, run_dirs: list[Path] | None = None) -> set[str]:
15
+ """Return run IDs GC must not slim."""
16
+ if run_dirs is None:
17
+ run_dirs = safe_iter_subdirs(runs_root)
18
+
19
+ try:
20
+ protected = set(find_resumable_runs(runs_root))
21
+ except OSError:
22
+ protected = set()
23
+
24
+ for run_dir in run_dirs:
25
+ if gc_should_conservatively_protect(run_dir):
26
+ protected.add(run_dir.name)
27
+ return protected
28
+
29
+
30
+ def gc_should_conservatively_protect(run_dir: Path) -> bool:
31
+ run_init = run_dir / RUN_INIT_FILENAME
32
+ try:
33
+ has_run_init = run_init.is_file()
34
+ except OSError:
35
+ return True
36
+ if not has_run_init:
37
+ return True
38
+
39
+ loop_manifest = run_dir / LOOP_MANIFEST_FILENAME
40
+ try:
41
+ has_loop_manifest = loop_manifest.is_file()
42
+ except OSError:
43
+ return True
44
+ if not has_loop_manifest:
45
+ return True
46
+
47
+ try:
48
+ data = json.loads(loop_manifest.read_text(encoding="utf-8"))
49
+ except (OSError, json.JSONDecodeError):
50
+ return True
51
+
52
+ exit_code = data.get("final_exit_code")
53
+ return isinstance(exit_code, int) and exit_code in _RESUMABLE_EXIT_CODES
54
+
55
+
56
+ def current_protected_run_ids(repo_root: Path) -> set[str]:
57
+ return protected_run_ids_for_gc(repo_root / ".syncade" / "runs")
58
+
59
+
60
+ def run_id_protected_now(repo_root: Path, run_id: str, protected_run_ids: set[str]) -> bool:
61
+ if run_id in protected_run_ids:
62
+ return True
63
+ return run_dir_protected_now(repo_root / ".syncade" / "runs" / run_id)
64
+
65
+
66
+ def run_dir_slimmable_now(run_dir: Path, run_id: str, protected_run_ids: set[str]) -> bool:
67
+ """Re-checked at execution time, not just at plan time: a run that became
68
+ resume-eligible between plan and execute must not be touched.
69
+
70
+ A **symlinked run dir is refused outright**. ``plan_gc`` never selects one, so the
71
+ only way to reach here with a symlink is a plan/execute race (or a stale plan):
72
+ plan against the real ``.syncade/runs/<id>/``, swap it for a symlink to an
73
+ external directory dressed up with valid run markers, then execute. Both
74
+ reviewers reproduced exactly that, and it let GC unlink ``*.stdout`` outside the
75
+ corpus. ``is_dir()`` follows symlinks, so it cannot be the check.
76
+ """
77
+ if run_id in protected_run_ids:
78
+ return False
79
+ if run_dir_protected_now(run_dir):
80
+ return False
81
+ try:
82
+ if run_dir.is_symlink():
83
+ return False
84
+ return run_dir.is_dir()
85
+ except OSError:
86
+ return False
87
+
88
+
89
+ def run_dir_protected_now(run_dir: Path) -> bool:
90
+ try:
91
+ if not run_dir.is_dir():
92
+ return False
93
+ except OSError:
94
+ return True
95
+ return gc_should_conservatively_protect(run_dir)
96
+
97
+
98
+ def orphan_worktree_still_orphan_now(
99
+ repo_root: Path, tree: Path, protected_run_ids: set[str]
100
+ ) -> bool:
101
+ run_id = tree.name
102
+ if run_id in protected_run_ids:
103
+ return False
104
+ try:
105
+ if (repo_root / ".syncade" / "runs" / run_id).exists():
106
+ return False
107
+ except OSError:
108
+ return False
109
+ runs_root = repo_root / ".syncade" / "runs"
110
+ known_run_ids = {d.name for d in safe_iter_subdirs(runs_root)} | protected_run_ids
111
+ return tree in repo_owned_orphan_trees(repo_root, [tree], known_run_ids)
112
+
113
+
114
+ def safe_iter_subdirs(path: Path) -> list[Path]:
115
+ """Return immediate subdirectories, or ``[]`` on any ``OSError``."""
116
+ try:
117
+ entries = list(path.iterdir())
118
+ except OSError:
119
+ return []
120
+ subdirs: list[Path] = []
121
+ for entry in entries:
122
+ try:
123
+ if entry.is_symlink():
124
+ continue
125
+ if entry.is_dir():
126
+ subdirs.append(entry)
127
+ except OSError:
128
+ continue
129
+ return subdirs
syncade/gc_types.py ADDED
@@ -0,0 +1,50 @@
1
+ """Shared data shapes for ``syncade --gc`` planning and execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+
8
+ BULK_ARTIFACT_SUFFIXES = (".stdout", ".stderr")
9
+ """Raw subprocess transcripts under ``round-*/`` — the only artifacts GC removes.
10
+
11
+ Measured over the real 263-run corpus (2026-07-12): ``.stdout`` + ``.stderr`` are
12
+ **45.97 MB of 50.59 MB — 90.9%** — across 2138 files (1069 of each), while every
13
+ structured artifact combined (round manifests, parsed findings, summaries, exit
14
+ codes, and the whole run root) is **4.62 MB**, about 18 KB per run. So pruning
15
+ transcripts frees essentially all the disk while costing none of the data that
16
+ anything reads for a verdict or a metric.
17
+
18
+ GC used to ``rmtree`` whole run directories. That is now a data-loss bug, not a
19
+ cleanup: ``.syncade/metrics.db`` is a *derived, rebuildable view* over
20
+ ``.syncade/runs/`` — it drop-and-recreates itself on schema drift — so deleting a run
21
+ directory destroys that run's history permanently the next time the view rebuilds.
22
+ Retention is therefore two-tier: **transcripts are disposable, everything else is
23
+ kept forever.**"""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class GcPlan:
28
+ """The result of ``plan_gc``: what GC would do."""
29
+
30
+ protected_run_ids: list[str]
31
+ runs_to_slim: list[str]
32
+ """Runs whose ``round-*/`` transcripts will be pruned. The run directory and
33
+ every structured artifact in it SURVIVE — see :data:`BULK_ARTIFACT_SUFFIXES`."""
34
+ worktree_trees_to_remove: list[Path]
35
+ orphan_worktree_trees: list[Path]
36
+ worktree_tree_identities: dict[Path, tuple[int, int, int]] = field(default_factory=dict)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class GcReport:
41
+ """The result of ``execute_gc``: what GC did, or would do on dry-run."""
42
+
43
+ runs_slimmed: list[str]
44
+ """Runs whose transcripts were pruned. A run already slim contributes nothing
45
+ (slimming is idempotent) and does not appear here."""
46
+ worktrees_removed: list[Path]
47
+ pids_reaped: list[int]
48
+ bytes_freed: int = 0
49
+ errors: list[str] = field(default_factory=list)
50
+ dry_run: bool = False