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
@@ -0,0 +1,112 @@
1
+ """Pre-dispatch classifier for the review loop.
2
+
3
+ Split out of :mod:`syncade.orchestrator.loop` to keep that module under the LOC cap;
4
+ re-exported from there so existing imports remain stable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import TYPE_CHECKING
12
+
13
+ from syncade.config import SyncadeConfig
14
+ from syncade.diff_filter import (
15
+ concealed_destinations,
16
+ elide_binary_hunks,
17
+ filter_diff_for_reviewer,
18
+ unidentifiable_sections,
19
+ )
20
+
21
+ if TYPE_CHECKING:
22
+ from syncade.snapshot import Snapshot
23
+
24
+
25
+ def _diff_will_dispatch(
26
+ snapshot: Snapshot,
27
+ config: SyncadeConfig,
28
+ repo_root: Path | None = None,
29
+ pr_doc_path: Path | None = None,
30
+ ) -> bool:
31
+ """Return False when the round will terminate before dispatching any subprocess.
32
+
33
+ A False result means no reviewer, judge, or producer will run, so commit-safety
34
+ guards (dirty-tree refusal, default-branch guard) are irrelevant for this run.
35
+ Four cases: diff is malformed (diff_malformed, exit 60), reviewer-facing diff
36
+ exceeds the size ceiling (diff_too_large, exit 60), diff is known-empty
37
+ (no_changes_to_review, exit 0), or an assembled reviewer prompt exceeds the provider
38
+ character ceiling (prompt_too_large, exit 60). All are pre-dispatch terminal decisions
39
+ in _run_one_round; classifying here avoids the dirty/default-branch guards firing
40
+ before the prompt-size refusal for runs that will never dispatch any reviewer.
41
+
42
+ The prompt_too_large check renders the full round-0 prompt and requires repo_root to load
43
+ the reviewer templates. When repo_root is None the check is skipped and the exact check
44
+ inside _run_one_round catches it instead (though only after commit-safety guards have
45
+ already run). pr_doc_path is the resolved PR doc path; when provided it is converted to
46
+ the worktree-local reference the real reviewer sees (relative path inside the repo, or a
47
+ collision-free .syncade-inputs/ path for out-of-repo docs). When omitted the placeholder
48
+ "<pr-doc>" is used, which can undercount prompt size if the template repeats {pr_doc_path}.
49
+ """
50
+ # Only refuse on unidentifiable headers when there are strip targets: with an empty
51
+ # strip list filter_diff_for_reviewer keeps every section byte-for-byte, so an
52
+ # undecodable header is not a security concern (nothing is being hidden).
53
+ if config.review.strip_repo_context_files and unidentifiable_sections(snapshot.diff_text):
54
+ return False # diff_malformed path — refuses at exit 60, no producer runs
55
+ filtered = filter_diff_for_reviewer(snapshot.diff_text, config.review.strip_repo_context_files)
56
+ filtered_elided, _ = elide_binary_hunks(filtered)
57
+ if len(filtered_elided.encode("utf-8")) > config.loop.max_diff_bytes:
58
+ return False # diff_too_large path — refuses at exit 60, no producer runs
59
+ if (
60
+ snapshot.base_oid is not None
61
+ and not filtered_elided
62
+ # A boundary rename empties the filtered diff while concealing a reviewable
63
+ # destination; that run DOES dispatch, so the commit guards still apply.
64
+ and not concealed_destinations(snapshot.diff_text, config.review.strip_repo_context_files)
65
+ ):
66
+ return False # no_changes_to_review path — exits 0, no producer runs
67
+ # Exact assembled-prompt check: render the full round-0 prompt and measure it directly.
68
+ # The former template+diff estimate omitted the JSON schema (~517 chars), adversarial-lens
69
+ # block (~3,316 chars), and prior-round sentinel — a lower bound that let the real rendered
70
+ # prompt exceed the provider ceiling (1,048,576 chars) while this predicate returned True,
71
+ # causing commit-safety guards to fire first (dirty-tree or default-branch refusal) instead
72
+ # of the intended prompt_too_large refusal. Fails open on template-load or render errors —
73
+ # the exact check in _run_one_round catches them (though only after commit-safety guards run).
74
+ if repo_root is not None:
75
+ from syncade.findings import get_findings_schema_string
76
+ from syncade.orchestrator.round import _NO_DIFF_SENTINEL
77
+ from syncade.orchestrator.round_no_changes import _CODEX_CHAR_CEILING
78
+ from syncade.prompts import load_reviewer_template_for, render_reviewer_prompt
79
+
80
+ # Compute the worktree-local PR doc reference the same way _build_reviewer_prompt
81
+ # does. Using the real path matters when the template repeats {pr_doc_path} — with
82
+ # the placeholder "<pr-doc>" (8 chars) the rendered size is always lower than the
83
+ # real prompt, so the guard can false-green for a prompt that later refuses.
84
+ if pr_doc_path is not None:
85
+ try:
86
+ _pr_doc_ref = str(pr_doc_path.relative_to(repo_root))
87
+ except ValueError:
88
+ _digest = hashlib.sha256(str(pr_doc_path).encode("utf-8")).hexdigest()[:16]
89
+ _pr_doc_ref = f".syncade-inputs/pr-doc-{_digest}-{pr_doc_path.name}"
90
+ else:
91
+ _pr_doc_ref = "<pr-doc>"
92
+ _diff_text = filtered_elided if filtered_elided else _NO_DIFF_SENTINEL
93
+ _json_schema = get_findings_schema_string()
94
+ for reviewer in config.reviewers:
95
+ try:
96
+ template = load_reviewer_template_for(
97
+ repo_root, provider=reviewer.provider, template=reviewer.template
98
+ )
99
+ rendered = render_reviewer_prompt(
100
+ template,
101
+ pr_doc_path=_pr_doc_ref,
102
+ diff=_diff_text,
103
+ master_plan_path=None,
104
+ json_schema=_json_schema,
105
+ adversarial_lens=reviewer.adversarial_lens,
106
+ bug_class_sweep=reviewer.bug_class_sweep,
107
+ )
108
+ except Exception: # noqa: BLE001 — fail open; exact check runs inside round
109
+ continue
110
+ if len(rendered) > _CODEX_CHAR_CEILING:
111
+ return False # prompt_too_large path — refuses at exit 60, no producer runs
112
+ return True
@@ -0,0 +1,404 @@
1
+ """Loop finalization.
2
+
3
+ ``_finalize_run`` is the post-loop tail of ``run_review``: it writes the
4
+ loop-level artifacts (loop-summary.md / loop-manifest.json / handoff.md),
5
+ records the per-branch last-reviewed SHA, emits the working-tree-not-synced
6
+ note, performs the terminal worktree cleanup-vs-preserve decision, builds the
7
+ aggregate :class:`RunResult`, and returns it.
8
+
9
+ ``completed_at`` is computed by the caller and passed in.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from syncade import run_status as _run_status
18
+ from syncade.exit_codes import (
19
+ CLARIFICATION_NEEDED,
20
+ FINDINGS_PRESENT,
21
+ MAX_ROUNDS_REACHED,
22
+ SUCCESS,
23
+ )
24
+ from syncade.git_object_id import is_full_git_object_id
25
+ from syncade.persistence import (
26
+ ProducerArtifactPaths,
27
+ persist_handoff,
28
+ persist_loop_manifest,
29
+ persist_loop_summary,
30
+ )
31
+
32
+ from .results import RunArtifacts, RunResult
33
+
34
+
35
+ def _warn_branch_advanced(*, branch: str, repo_root, logger) -> None:
36
+ """Emit the working-tree-not-synced warning after a branch advance.
37
+
38
+ Shared by the normal finalize tail and the early ``test_worktree_error``
39
+ re-raise path so the operator hears the identical message on both (N3).
40
+
41
+ Uses ``logger.safety``, not ``logger.warning``: under ``--quiet`` this was suppressed
42
+ entirely (PR-h-13), and it is the ONLY thing standing between an operator and a
43
+ ``git commit`` that silently reverts the producer's work — the tree still holds the
44
+ pre-advance content, so `git status` shows a fully staged revert.
45
+ """
46
+ logger.safety(
47
+ f"orchestrator: branch refs/heads/{branch} "
48
+ f"has been advanced to the latest producer commit, "
49
+ f"but your working tree at {repo_root} is NOT "
50
+ f"automatically synced. To pick up the producer's fix in your "
51
+ f"working tree: `git reset --hard HEAD` (discards "
52
+ f"any local edits) or `git stash && git reset "
53
+ f"--hard HEAD && git stash pop` (preserves them). "
54
+ f"See loop-summary.md for the commit series and "
55
+ f"per-round artifacts."
56
+ )
57
+
58
+
59
+ def _cleanup_prior_round_managers(managers, *, current_round_dir) -> None:
60
+ """Best-effort ``cleanup_all`` of every manager whose worktrees are NOT
61
+ under ``current_round_dir`` (N4).
62
+
63
+ The current round's worktrees are deliberately preserved for diagnostics
64
+ on the ``test_worktree_error`` early-raise path; only the now-superseded
65
+ prior-round reviewer/producer worktrees are reclaimed.
66
+ """
67
+ for manager in managers:
68
+ run_dir = manager.run_dir
69
+ if run_dir == current_round_dir or current_round_dir in run_dir.parents:
70
+ continue
71
+ try:
72
+ manager.cleanup_all()
73
+ except Exception:
74
+ # Best-effort: one manager's failure must not block the others
75
+ # or the re-raise of the captured WorktreeError.
76
+ pass
77
+
78
+
79
+ def _finalize_run(
80
+ *,
81
+ run_dir,
82
+ run_id,
83
+ repo_root,
84
+ snapshot,
85
+ config,
86
+ pr_doc_path,
87
+ round_results,
88
+ round_artifacts_list,
89
+ final_exit_code,
90
+ termination_reason,
91
+ started_at,
92
+ completed_at,
93
+ branch_advanced_during_run,
94
+ managers_to_cleanup,
95
+ effective_worktree_base,
96
+ logger,
97
+ run_usages=None,
98
+ budget_ceiling=None,
99
+ ) -> RunResult:
100
+ """Write loop-level artifacts, build the aggregate :class:`RunResult`,
101
+ handle last-reviewed recording + worktree cleanup, and return the result.
102
+
103
+ ``completed_at`` is supplied by the caller (``run_review``) so the patched
104
+ ``datetime.now`` lookup stays in the rebound caller body.
105
+ """
106
+ # --- Loop terminated --------------------------------------------
107
+ final_round_idx = len(round_results) - 1
108
+ last_round = round_results[-1]
109
+
110
+ # write the loop-level artifacts (top-level
111
+ # loop-summary.md + loop-manifest.json). Always written —
112
+ # operators inspecting a multi-round run want the aggregate
113
+ # view, and even single-pass runs benefit from a top-level
114
+ # summary that doesn't require traversing into round-0/.
115
+ #
116
+ # ``termination_reason`` is always set by here — every break
117
+ # path above assigns it. Defensive default just in case a
118
+ # future code path forgets to.
119
+ safe_termination_reason: str = termination_reason or "unknown"
120
+ try:
121
+ loop_summary_path = persist_loop_summary(
122
+ run_dir,
123
+ final_exit_code=final_exit_code,
124
+ final_round=final_round_idx,
125
+ termination_reason=safe_termination_reason,
126
+ rounds=round_results,
127
+ max_rounds=config.loop.max_rounds,
128
+ started_at=started_at,
129
+ completed_at=completed_at,
130
+ # pass repo_root so the loop-summary
131
+ # commit series can look up producer commit subjects via
132
+ # ``git log -1 --pretty=format:%s <ending_sha>``.
133
+ repo_root=repo_root,
134
+ # PR-v2-11: the configured ceiling(s), so the Budget section on a
135
+ # budget_exceeded run can name them alongside the tally.
136
+ budget_tokens=config.loop.budget_tokens,
137
+ budget_usd=config.loop.budget_usd,
138
+ # The ENFORCEMENT tally itself — fresh on --resume, so the reported Budget number
139
+ # is exactly what tripped, not original+resume re-summed from rehydrated rounds.
140
+ budget_usages=run_usages,
141
+ # Which ceiling crossed, so the Budget section names it (both set → first-to-trip).
142
+ budget_ceiling=budget_ceiling,
143
+ )
144
+ persist_loop_manifest(
145
+ run_dir,
146
+ final_exit_code=final_exit_code,
147
+ final_round=final_round_idx,
148
+ termination_reason=safe_termination_reason,
149
+ rounds=round_results,
150
+ max_rounds=config.loop.max_rounds,
151
+ started_at=started_at,
152
+ producer_provider=config.producer.provider,
153
+ producer_model=config.producer.model,
154
+ )
155
+ # structured operator handoff on exit 20 / exit 30
156
+ # paths. persist_handoff gates internally on exit code +
157
+ # active-blocker count so the call is safe to make
158
+ # unconditionally; returns None when the gate skips.
159
+ persist_handoff(
160
+ run_dir,
161
+ final_exit_code=final_exit_code,
162
+ final_round=final_round_idx,
163
+ termination_reason=safe_termination_reason,
164
+ rounds=round_results,
165
+ max_rounds=config.loop.max_rounds,
166
+ pr_doc_path=pr_doc_path,
167
+ repo_root=repo_root,
168
+ )
169
+ except Exception:
170
+ # Loop-level persistence failures shouldn't mask the
171
+ # actual exit code — the per-round artifacts are already
172
+ # on disk. Log via the existing channel and continue.
173
+ loop_summary_path = None
174
+ logger.warning(
175
+ "orchestrator: loop-level persistence failed (per-round "
176
+ "artifacts are still on disk; the top-level "
177
+ "loop-summary.md / loop-manifest.json / handoff.md may be "
178
+ "missing or partial)"
179
+ )
180
+
181
+ # Build the aggregate RunArtifacts pointing flat fields at the
182
+ # LAST round (back-compat with single-pass RunArtifacts shape).
183
+ # producer_paths is the brief-documented flat list
184
+ # parallel to rounds[] — one entry per round, None when the
185
+ # round didn't run a producer (SHIP rounds + the final round
186
+ # under max_rounds_reached).
187
+ producer_paths_list: list[ProducerArtifactPaths | None] = [
188
+ r.producer_paths for r in round_artifacts_list
189
+ ]
190
+ # the run-root findings.md (if it was written) lives
191
+ # at <run_dir>/findings.md regardless of round count.
192
+ run_root_findings = run_dir / "findings.md"
193
+ run_root_findings_path: Path | None = run_root_findings if run_root_findings.is_file() else None
194
+ artifacts = RunArtifacts(
195
+ run_dir=run_dir,
196
+ round_dir=last_round.artifacts.round_dir,
197
+ manifest_path=last_round.artifacts.manifest_path,
198
+ summary_path=last_round.artifacts.summary_path,
199
+ findings_md_path=last_round.artifacts.findings_md_path,
200
+ synthesizer_paths=last_round.artifacts.synthesizer_paths,
201
+ test_run_paths=last_round.artifacts.test_run_paths,
202
+ rounds=round_artifacts_list,
203
+ loop_summary_path=loop_summary_path,
204
+ producer_paths=producer_paths_list,
205
+ run_root_findings_md_path=run_root_findings_path,
206
+ )
207
+
208
+ result = RunResult(
209
+ artifacts=artifacts,
210
+ snapshot=last_round.snapshot,
211
+ dispatch_result=last_round.dispatch_result,
212
+ exit_code=final_exit_code,
213
+ synth_result=last_round.synth_result,
214
+ test_result=last_round.test_result,
215
+ test_skip_reason=last_round.test_skip_reason,
216
+ test_worktree_error=last_round.test_worktree_error,
217
+ rounds=round_results,
218
+ final_round=final_round_idx,
219
+ termination_reason=termination_reason,
220
+ # Same values persist_loop_summary renders, so the terminal notice and the artifact
221
+ # cannot disagree about what was spent (PR-h-field-06).
222
+ budget_usages=list(run_usages or []),
223
+ budget_ceiling=budget_ceiling,
224
+ budget_tokens=config.loop.budget_tokens,
225
+ budget_usd=config.loop.budget_usd,
226
+ )
227
+
228
+ # When the final round had a test-leg worktree provisioning failure, that
229
+ # WorktreeError must be re-raised so the CLI maps to exit 60. The per-round
230
+ # persistence already wrote everything; this is just the bubble-up.
231
+ if last_round.test_worktree_error is not None:
232
+ # The branch-advance warning and the prior-round cleanup both live
233
+ # BELOW this raise, so replay the parts that still apply here:
234
+ # N3 — an earlier round's producer may have advanced the branch; the
235
+ # operator must hear that even on this exit-60 path.
236
+ if snapshot.branch is not None and branch_advanced_during_run:
237
+ _warn_branch_advanced(branch=snapshot.branch, repo_root=repo_root, logger=logger)
238
+ # N4 — the terminal cleanup below would otherwise reclaim every prior
239
+ # round's reviewer/producer worktrees. Do that here, preserving only
240
+ # the current (failed) round's worktrees for diagnostics.
241
+ _cleanup_prior_round_managers(
242
+ managers_to_cleanup,
243
+ current_round_dir=effective_worktree_base / run_id / f"round-{final_round_idx}",
244
+ )
245
+ logger.summary(result)
246
+ # Finalize with the mechanical reason before re-raising so the CLI's
247
+ # typed WorktreeError handler sees _active=None and is a no-op.
248
+ # Without this, status.json would be overwritten as
249
+ # exception:WorktreeError even though loop-manifest already recorded
250
+ # the mechanical termination_reason (worktree_error).
251
+ _run_status.finalize_active(safe_termination_reason, final_exit_code)
252
+ raise last_round.test_worktree_error
253
+
254
+ # --- record the per-branch last-reviewed SHA --------------
255
+ # On a COMPLETED verdict where reviewers actually ran (exits 0/20/30, but NOT
256
+ # no_changes_to_review or producer_emptied_diff which are exit-0 with zero reviewers
257
+ # dispatched), record the SHA a reviewer saw so `--scope since-last-review` bounds its
258
+ # diff to new work. Skipped on phase failures (40/50/60/70), decision_needed (10,
259
+ # resume-pending), detached HEAD (no branch), and empty-diff terminals where no
260
+ # reviewer actually ran (recording an unreviewed anchor would silently skip real work
261
+ # on the next since-last-review diff).
262
+ _skip_reasons = ("no_changes_to_review", "producer_emptied_diff")
263
+ if (
264
+ snapshot.branch is not None
265
+ and final_exit_code in (SUCCESS, MAX_ROUNDS_REACHED, FINDINGS_PRESENT)
266
+ and termination_reason not in _skip_reasons
267
+ ):
268
+ # Record the SHA a reviewer ACTUALLY saw, not a re-read of the branch (R6).
269
+ # `last_round.snapshot` is the final round's — NOT the `snapshot` parameter, which
270
+ # is round 0's and differs on any multi-round run.
271
+ #
272
+ # Re-reading `refs/heads/<branch>` returned whatever the ref pointed at when the
273
+ # run ended, which is the reviewed SHA only because the TERMINAL round never runs a
274
+ # producer (loop_round_step.py special-cases `round_idx == max_rounds - 1`). When
275
+ # the ref moved anyway — a `producer_stalled` run whose fast-forward was refused, or
276
+ # an external commit landing mid-run — it recorded a SHA nobody reviewed, and
277
+ # `--scope since-last-review` then SKIPS that unreviewed work. Reading the value
278
+ # already in hand cannot drift.
279
+ _reviewed_sha = last_round.snapshot.commit_sha
280
+ if is_full_git_object_id(_reviewed_sha):
281
+ from syncade.persistence import persist_last_reviewed as _persist_last_reviewed
282
+
283
+ try:
284
+ _persist_last_reviewed(
285
+ repo_root,
286
+ branch=snapshot.branch,
287
+ sha=_reviewed_sha,
288
+ run_id=run_id,
289
+ recorded_at_utc=completed_at.isoformat(),
290
+ )
291
+ except OSError as exc:
292
+ # Narrowed from `except Exception: pass` (audit rank 17). Only a real I/O
293
+ # failure is tolerable here — losing last-reviewed costs a wider next diff,
294
+ # never a wrong verdict, so it must not abort a completed run. Anything
295
+ # else (a bug in the writer, a bad SHA) is a defect and must surface.
296
+ #
297
+ # Printed directly to stderr, NOT via logger.warning, which is suppressed
298
+ # in --quiet mode. A persistence failure must be visible regardless of
299
+ # verbosity — the operator needs to know the anchor was not updated.
300
+ print(
301
+ f"[syncade] warning: could not record last-reviewed SHA for "
302
+ f"{snapshot.branch}: {exc}",
303
+ file=sys.stderr,
304
+ flush=True,
305
+ )
306
+
307
+ # --- Do NOT touch the operator's working tree -----
308
+ # The design is explicit ("Notes for the implementing agent"):
309
+ #
310
+ # > do not use `git checkout` in the user's working tree
311
+ # > (don't touch their checkout). The worktree's shared
312
+ # > `.git` is the contract surface.
313
+ #
314
+ # Branch advance moves the ref; the operator syncs their working tree
315
+ # themselves using the documented command in the loop summary.
316
+ #
317
+ # The remaining UX gap (operator runs ``cat foo.py`` post-loop
318
+ # and sees old content) is addressed via documentation:
319
+ # loop-summary.md's "Next steps" block names the sync command
320
+ # explicitly on every SHIP termination. Operators handle the
321
+ # checkout themselves — same contract as a remote ``git push``
322
+ # to their branch from elsewhere.
323
+ if snapshot.branch is not None and branch_advanced_during_run:
324
+ _warn_branch_advanced(branch=snapshot.branch, repo_root=repo_root, logger=logger)
325
+
326
+ # terminal cleanup decision. Per the PRD,
327
+ # exits 10/20/30 keep worktrees for inspection; exit 0 +
328
+ # environmental failures (40/50/60/70) clean up. Each
329
+ # WorktreeManager was constructed with defer_cleanup=True so
330
+ # the per-round + per-producer ``with`` blocks didn't
331
+ # auto-clean; the orchestrator now decides based on
332
+ # final_exit_code.
333
+ preserve_worktrees = final_exit_code in (
334
+ CLARIFICATION_NEEDED,
335
+ MAX_ROUNDS_REACHED,
336
+ FINDINGS_PRESENT,
337
+ )
338
+ if not preserve_worktrees:
339
+ for manager in managers_to_cleanup:
340
+ try:
341
+ manager.cleanup_all()
342
+ except Exception:
343
+ # Best-effort: a cleanup failure on one manager
344
+ # doesn't block the others or block the final
345
+ # ``rmdir(<base>/<run_id>/)``. Surface via a
346
+ # generic warning rather than the per-manager
347
+ # stderr the WorktreeManager.cleanup_all already
348
+ # writes; we don't want to double-warn.
349
+ pass
350
+ else:
351
+ # Surface the preservation in the operator's terminal
352
+ # log so they know where to look.
353
+ if managers_to_cleanup:
354
+ logger.warning(
355
+ f"orchestrator: worktrees PRESERVED on disk for "
356
+ f"inspection (exit {final_exit_code} keeps them per "
357
+ f"PRD). Inspect at {effective_worktree_base / run_id}/."
358
+ )
359
+
360
+ # final cleanup of the per-run worktree-base parent. The
361
+ # per-round WorktreeManagers each cleaned up their round-N
362
+ # subdirs, but the shared ``<worktree_base>/<run_id>/`` parent
363
+ # they were nested under is still on disk.
364
+ #
365
+ # the per-round managers' cleanup_all rmdirs
366
+ # their own ``round-N/`` subdirs, BUT on a NO-SHIP round where
367
+ # the producer also ran, the reviewer-manager's cleanup_all
368
+ # tries to rmdir ``round-N/`` BEFORE the producer-manager
369
+ # cleans up its own ``round-N/producer-worktree/`` — the rmdir
370
+ # fails silently (dir not empty), the producer phase runs and
371
+ # cleans its subdir, but nothing goes back to retry the
372
+ # ``round-N/`` rmdir. Result: empty ``round-N/`` directories
373
+ # accumulate under ``<base>/<run_id>/`` even on successful
374
+ # runs, and the post-loop ``rmdir(<base>/<run_id>/)`` fails
375
+ # because of the empty children.
376
+ #
377
+ # Fix: walk the run_dir bottom-up and rmdir every empty subdir
378
+ # before the final ``rmdir(run_dir)``. Best-effort: any OSError
379
+ # is swallowed (caller may have left intentional state).
380
+ #
381
+ # skipped when ``preserve_worktrees`` is True — the
382
+ # whole point is to leave the dirs on disk for inspection.
383
+ shared_run_dir = effective_worktree_base / run_id
384
+ if not preserve_worktrees and shared_run_dir.exists():
385
+ # Walk bottom-up so deeper empty dirs get rmdir'd first,
386
+ # making their parents potentially empty for the next step.
387
+ for child_dir in sorted(
388
+ (p for p in shared_run_dir.rglob("*") if p.is_dir()),
389
+ key=lambda p: len(p.parts),
390
+ reverse=True,
391
+ ):
392
+ try:
393
+ child_dir.rmdir()
394
+ except OSError:
395
+ # Not empty (operator left something) — skip.
396
+ pass
397
+ try:
398
+ shared_run_dir.rmdir()
399
+ except OSError:
400
+ # Not empty; leave it for inspection.
401
+ pass
402
+
403
+ logger.summary(result)
404
+ return result
@@ -0,0 +1,131 @@
1
+ """Run-level commit-safety gates: everything a RUN passes before the loop may spend.
2
+
3
+ The run-level twin of :mod:`syncade.orchestrator.round_predispatch`, split out of ``loop`` in
4
+ PR-h-field-06 after three consecutive dogfoods flagged that module's size while budget state
5
+ was being threaded into it. It was 481 of a 500 code-LOC cap — passing, with 19 lines of
6
+ headroom, which is the position ``producer.py`` was in when the next change broke the gate.
7
+
8
+ **The ORDER is load-bearing**, exactly as it is for the round-level gates: the diff is
9
+ classified FIRST, because a run that cannot dispatch must not be refused for being on the
10
+ default branch or for a dirty tree — there is nothing for it to commit. The block is moved
11
+ intact rather than reassembled for that reason.
12
+
13
+ Refusals RAISE (``WorktreeError``); nothing returns early, which is what makes the extraction
14
+ faithful. The four values the loop needs come back on :class:`RunPreflight`.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sys
20
+
21
+ from syncade.worktree import WorktreeError
22
+
23
+ from .branch_guard import guard_default_branch
24
+ from .loop_dispatch_check import _diff_will_dispatch
25
+
26
+
27
+ def run_preflight(
28
+ *,
29
+ config,
30
+ repo_root,
31
+ pr_doc_path,
32
+ snapshot,
33
+ state,
34
+ branch,
35
+ resume_plan,
36
+ logger,
37
+ force_dirty: bool,
38
+ allow_default_branch: bool,
39
+ ) -> None:
40
+ """Run every run-level gate, in order. Returns nothing; refusals RAISE.
41
+
42
+ Measured during the extraction: every value this block computes
43
+ (``_will_dispatch``, ``_dirty_state``, ``effective_max_rounds``, ``will_commit``,
44
+ ``short_sha``) is used only by the gates themselves — the loop needs none of them
45
+ afterwards. That is what makes this a seam rather than a cut: nothing has to be threaded
46
+ back, so there is no carrier object and no chance of the two halves disagreeing.
47
+ """
48
+ short_sha = snapshot.commit_sha[:12]
49
+ # --- Pre-classify diff before commit-safety guards (PR-h-02d) ---
50
+ # A known-empty or malformed diff terminates before any subprocess — no producer
51
+ # runs, so commit-only guards are irrelevant and must not refuse valid no-change runs.
52
+ _will_dispatch = _diff_will_dispatch(
53
+ snapshot, config, repo_root=repo_root, pr_doc_path=pr_doc_path
54
+ )
55
+
56
+ # --- Pre-flight dirty-tree refusal in loop mode -----------------
57
+ # max_rounds > 1 → the loop will run a producer that commits to
58
+ # the operator's branch. A tracked-modified WIP would race
59
+ # against the producer's writes (the operator's working tree
60
+ # would interleave with new commits in confusing ways).
61
+ # Untracked-only is fine — those files don't enter any
62
+ # worktree, so they're invisible to both reviewers and the
63
+ # producer. The --force-dirty escape hatch is for operators
64
+ # who understand the consequences.
65
+ #
66
+ # max_rounds == 1 is warning-only because it runs reviewers + synth and
67
+ # exits; nothing writes to the operator branch.
68
+ #
69
+ # Resumed loop-mode runs are refused on the same grounds: a resume
70
+ # still runs the producer and commits to the branch, so a dirty WIP
71
+ # races just as it would on a fresh run. --force-dirty is the only
72
+ # escape (resume does not exempt itself).
73
+ # The refusal must use the EFFECTIVE cap: a resume rehydrates
74
+ # max_rounds to max(config, resume_plan.max_rounds) later (see
75
+ # loop_resume._rehydrate_resume_state), so reading the un-bumped config
76
+ # here would let `--resume --max-rounds 1` — or a config drifted to
77
+ # max_rounds=1 — on a multi-round run slip the gate and then race the
78
+ # producer's commit against the dirty WIP (H3).
79
+ effective_max_rounds = config.loop.max_rounds
80
+ if resume_plan is not None:
81
+ effective_max_rounds = max(effective_max_rounds, resume_plan.max_rounds)
82
+ _dirty_state = state in ("tracked", "both")
83
+ if _will_dispatch and effective_max_rounds > 1 and _dirty_state and not force_dirty:
84
+ raise WorktreeError(
85
+ f"uncommitted tracked changes (dirty_state={state!r}); "
86
+ f"loop mode (max_rounds={effective_max_rounds}) "
87
+ f"commits to your branch and would race against this "
88
+ f"WIP. Commit, stash, or pass --force-dirty to override. "
89
+ f"To run single-pass with warning-only tracked changes, set max_rounds=1 in "
90
+ f"[loop] or pass --max-rounds 1."
91
+ )
92
+
93
+ # --- Default-branch commit guard (PR-v2-26) ---------------------
94
+ # A committing run (loop mode) fast-forwards the CURRENT branch, so refuse the
95
+ # default branch unless the operator opted in, and announce the target branch
96
+ # BEFORE any dispatch. Placed at the run-entry choke so a direct run_review call
97
+ # and a --resume are covered too, not only the CLI wrapper.
98
+ # will_commit is False when no dispatch will happen: no producer runs on a no-change
99
+ # or malformed-diff run, so the default-branch guard and commit announcement are moot.
100
+ will_commit = _will_dispatch and effective_max_rounds > 1
101
+ guard_default_branch(
102
+ repo_root, snapshot.branch, allow=allow_default_branch, will_commit=will_commit
103
+ )
104
+ if will_commit:
105
+ # Printed directly to stderr, NOT via logger.event, so it survives --quiet — the
106
+ # same reason the auth block bypasses quiet. Which branch receives commits is a
107
+ # safety disclosure, and it matters MOST under `--quiet --allow-default-branch`.
108
+ print(
109
+ f"[syncade] producer commits will land on: {snapshot.branch or '(detached HEAD)'}",
110
+ file=sys.stderr,
111
+ )
112
+
113
+ # Dirty-tree warnings fire on every round-0 snapshot regardless of
114
+ # max_rounds; the loop-mode refusal above is additive.
115
+ if state in ("tracked", "both"):
116
+ logger.warning(
117
+ f"working tree has uncommitted modifications to tracked "
118
+ f"files — reviewers will only see HEAD ({short_sha}); "
119
+ f"your local changes are invisible to them. Commit "
120
+ f"before running syncade if you want them reviewed."
121
+ )
122
+ if state in ("untracked", "both"):
123
+ count = snapshot.untracked_count
124
+ plural = "files" if count != 1 else "file"
125
+ logger.warning(
126
+ f"working tree has untracked files (not reviewed): "
127
+ f"{count} {plural}. These are invisible to reviewers, "
128
+ f"which is usually intentional. Run 'git status' to see them."
129
+ )
130
+
131
+ # --- Run-directory layout ---------------------------------------