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.
- syncade/__init__.py +3 -0
- syncade/__main__.py +6 -0
- syncade/adapters/__init__.py +0 -0
- syncade/adapters/anthropic.py +457 -0
- syncade/adapters/base.py +221 -0
- syncade/adapters/fake.py +73 -0
- syncade/adapters/fake_common.py +29 -0
- syncade/adapters/fake_producer_audit_draft.py +460 -0
- syncade/adapters/fake_reviewer_synth.py +310 -0
- syncade/adapters/openai.py +484 -0
- syncade/adapters/openai_parsing.py +119 -0
- syncade/adapters/producer.py +221 -0
- syncade/adapters/producer_anthropic.py +300 -0
- syncade/adapters/producer_openai.py +226 -0
- syncade/adapters/registry.py +81 -0
- syncade/auth_check.py +554 -0
- syncade/auth_preflight.py +342 -0
- syncade/base_resolution.py +214 -0
- syncade/billing.py +141 -0
- syncade/checks_config.py +113 -0
- syncade/cli/__init__.py +546 -0
- syncade/cli/auth_gate.py +59 -0
- syncade/cli/config_keys.py +135 -0
- syncade/cli/config_list.py +82 -0
- syncade/cli/config_menu_rows.py +166 -0
- syncade/cli/config_mode.py +609 -0
- syncade/cli/config_overrides.py +122 -0
- syncade/cli/config_tui.py +476 -0
- syncade/cli/doctor_mode.py +72 -0
- syncade/cli/gc_mode.py +109 -0
- syncade/cli/install_skill.py +514 -0
- syncade/cli/metrics_mode.py +363 -0
- syncade/cli/modes.py +573 -0
- syncade/cli/parser.py +450 -0
- syncade/cli/parser_types.py +137 -0
- syncade/cli/paths.py +38 -0
- syncade/cli/preflight_paths.py +90 -0
- syncade/cli/resolve.py +116 -0
- syncade/cli/resume_mode.py +324 -0
- syncade/cli/toml_writer.py +410 -0
- syncade/cli/validate.py +421 -0
- syncade/config.py +478 -0
- syncade/config_auth.py +310 -0
- syncade/config_cold.py +209 -0
- syncade/config_gc.py +55 -0
- syncade/config_loader.py +182 -0
- syncade/config_loop.py +282 -0
- syncade/config_producer.py +222 -0
- syncade/config_retry.py +49 -0
- syncade/config_types.py +59 -0
- syncade/diff_filter.py +437 -0
- syncade/dispatcher.py +571 -0
- syncade/doctor.py +425 -0
- syncade/doctor_env.py +218 -0
- syncade/doctor_preview.py +524 -0
- syncade/doctor_types.py +28 -0
- syncade/exit_codes.py +82 -0
- syncade/findings.py +242 -0
- syncade/findings_json.py +456 -0
- syncade/gc.py +211 -0
- syncade/gc_execute.py +372 -0
- syncade/gc_protection.py +129 -0
- syncade/gc_types.py +50 -0
- syncade/gc_worktrees.py +200 -0
- syncade/git_object_id.py +12 -0
- syncade/git_preconditions.py +389 -0
- syncade/logging.py +289 -0
- syncade/metrics/__init__.py +32 -0
- syncade/metrics/aggregate.py +550 -0
- syncade/metrics/schema.py +221 -0
- syncade/orchestrator/__init__.py +61 -0
- syncade/orchestrator/_runs_dir.py +24 -0
- syncade/orchestrator/branch_advance.py +165 -0
- syncade/orchestrator/branch_guard.py +98 -0
- syncade/orchestrator/budget.py +107 -0
- syncade/orchestrator/escalation_coverage.py +81 -0
- syncade/orchestrator/loop.py +611 -0
- syncade/orchestrator/loop_dispatch_check.py +112 -0
- syncade/orchestrator/loop_finalize.py +404 -0
- syncade/orchestrator/loop_preflight.py +131 -0
- syncade/orchestrator/loop_resume.py +91 -0
- syncade/orchestrator/loop_rmtree.py +70 -0
- syncade/orchestrator/loop_round_step.py +599 -0
- syncade/orchestrator/prior_round.py +336 -0
- syncade/orchestrator/producer_phase.py +169 -0
- syncade/orchestrator/results.py +306 -0
- syncade/orchestrator/resume.py +96 -0
- syncade/orchestrator/resume_load.py +483 -0
- syncade/orchestrator/resume_plan.py +554 -0
- syncade/orchestrator/resume_target.py +215 -0
- syncade/orchestrator/resume_types.py +182 -0
- syncade/orchestrator/reviewer_template_failure.py +99 -0
- syncade/orchestrator/round.py +573 -0
- syncade/orchestrator/round_checks.py +91 -0
- syncade/orchestrator/round_no_changes.py +369 -0
- syncade/orchestrator/round_predispatch.py +212 -0
- syncade/orchestrator/verdict.py +279 -0
- syncade/persistence/__init__.py +189 -0
- syncade/persistence/_atomic.py +33 -0
- syncade/persistence/_clusters.py +70 -0
- syncade/persistence/_findings_verdict.py +201 -0
- syncade/persistence/_markdown.py +286 -0
- syncade/persistence/_validation.py +37 -0
- syncade/persistence/checks.py +249 -0
- syncade/persistence/decision_needed.py +289 -0
- syncade/persistence/findings_md.py +389 -0
- syncade/persistence/handoff.py +389 -0
- syncade/persistence/handoff_classify.py +196 -0
- syncade/persistence/last_reviewed.py +67 -0
- syncade/persistence/loop_manifest.py +165 -0
- syncade/persistence/loop_summary.py +352 -0
- syncade/persistence/loop_summary_text.py +428 -0
- syncade/persistence/producer.py +250 -0
- syncade/persistence/reviewer.py +198 -0
- syncade/persistence/round_manifest.py +238 -0
- syncade/persistence/run_init.py +153 -0
- syncade/persistence/run_summary.py +585 -0
- syncade/persistence/run_summary_next_steps.py +443 -0
- syncade/persistence/synth.py +242 -0
- syncade/persistence/test_run.py +152 -0
- syncade/presets.py +36 -0
- syncade/pricing_config.py +72 -0
- syncade/process.py +600 -0
- syncade/producer.py +189 -0
- syncade/producer_attempt.py +463 -0
- syncade/producer_escalation.py +146 -0
- syncade/producer_git.py +199 -0
- syncade/producer_result.py +205 -0
- syncade/prompts.py +448 -0
- syncade/prompts_loader.py +238 -0
- syncade/retry.py +159 -0
- syncade/run_inputs.py +40 -0
- syncade/run_status.py +198 -0
- syncade/selfcheck.py +471 -0
- syncade/skills/claude/README.md +221 -0
- syncade/skills/claude/SKILL.md +625 -0
- syncade/skills/codex/README.md +116 -0
- syncade/skills/codex/SKILL.md +574 -0
- syncade/snapshot.py +598 -0
- syncade/spec_audit.py +437 -0
- syncade/spec_audit_schema.py +190 -0
- syncade/spec_draft.py +423 -0
- syncade/spec_source.py +135 -0
- syncade/synthesis.py +428 -0
- syncade/synthesis_clusters.py +203 -0
- syncade/synthesis_repair.py +230 -0
- syncade/synthesis_schema.py +65 -0
- syncade/synthesizer/__init__.py +38 -0
- syncade/synthesizer/constants.py +33 -0
- syncade/synthesizer/driver.py +531 -0
- syncade/synthesizer/rendering.py +63 -0
- syncade/synthesizer/result.py +73 -0
- syncade/synthesizer/validation.py +421 -0
- syncade/synthesizer/workspace.py +208 -0
- syncade/templates/presets/balanced.toml +13 -0
- syncade/templates/presets/cheap.toml +12 -0
- syncade/templates/presets/thorough.toml +9 -0
- syncade/templates/producer.md +231 -0
- syncade/templates/reviewer.md +279 -0
- syncade/templates/reviewer_adversarial.md +164 -0
- syncade/templates/reviewer_codex.md +165 -0
- syncade/templates/spec_audit.md +168 -0
- syncade/templates/spec_draft.md +62 -0
- syncade/templates/synthesizer.md +204 -0
- syncade/test_runner.py +476 -0
- syncade/test_runner_classify.py +98 -0
- syncade/transcript.py +150 -0
- syncade/usage.py +407 -0
- syncade/worktree.py +497 -0
- syncade/worktree_env.py +133 -0
- syncade/worktree_paths.py +139 -0
- syncade-0.6.2.dist-info/METADATA +314 -0
- syncade-0.6.2.dist-info/RECORD +177 -0
- syncade-0.6.2.dist-info/WHEEL +5 -0
- syncade-0.6.2.dist-info/entry_points.txt +2 -0
- syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
- 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}")
|
syncade/gc_protection.py
ADDED
|
@@ -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
|