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,573 @@
1
+ """Per-round reviewer, synthesizer, test, and check orchestration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from collections.abc import Callable
7
+ from datetime import datetime
8
+ from functools import partial
9
+ from pathlib import Path
10
+
11
+ from syncade import run_status
12
+ from syncade.adapters.base import ReviewerAdapter
13
+ from syncade.adapters.producer import ProducerAdapter
14
+ from syncade.config import SyncadeConfig
15
+ from syncade.diff_filter import (
16
+ elide_binary_hunks,
17
+ filter_diff_for_reviewer,
18
+ )
19
+ from syncade.dispatcher import DispatchResult, dispatch_reviewers
20
+ from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
21
+ from syncade.findings import get_findings_schema_string
22
+ from syncade.logging import Logger
23
+ from syncade.persistence import (
24
+ SynthesizerArtifactPaths,
25
+ TestRunArtifactPaths,
26
+ persist_current_findings_md,
27
+ persist_dispatch_record,
28
+ persist_findings_md,
29
+ persist_reviewer_result,
30
+ persist_round_manifest,
31
+ persist_run_summary,
32
+ persist_synthesizer_result,
33
+ persist_test_run_result,
34
+ record_child_pid,
35
+ )
36
+ from syncade.prompts import load_reviewer_template_for, render_reviewer_prompt
37
+ from syncade.snapshot import Snapshot
38
+ from syncade.synthesis import has_active_blocker
39
+ from syncade.synthesizer import SynthesizerResult, run_synthesizer
40
+ from syncade.test_runner import TestRunResult, run_tests
41
+ from syncade.worktree import TEST_WORKTREE_NAME, WorktreeError, WorktreeManager
42
+
43
+ from .prior_round import load_prior_reviewer_response_text
44
+ from .results import RoundArtifacts, RoundResult, TestSkipReason
45
+ from .round_checks import _run_checks_leg
46
+ from .round_predispatch import run_predispatch_gates
47
+ from .verdict import _compute_exit_code
48
+
49
+ _NO_DIFF_SENTINEL: str = "(diff not provided; review against the full repo state at HEAD)"
50
+
51
+ # Test-leg basename exported from :mod:`syncade.worktree` as a single source.
52
+ _TEST_WORKTREE_NAME = TEST_WORKTREE_NAME
53
+
54
+
55
+ def _build_reviewer_prompt(
56
+ *,
57
+ repo_root: Path,
58
+ snapshot: Snapshot,
59
+ config: SyncadeConfig,
60
+ round_idx: int,
61
+ run_dir: Path,
62
+ pr_doc_path: Path,
63
+ ) -> tuple[str | dict[str, str], str, bool]:
64
+ """Load + render the reviewer prompt(s) for one round.
65
+
66
+ Each reviewer renders its OWN provider-specific template (anthropic →
67
+ ``reviewer_adversarial.md``, openai → ``reviewer_codex.md``, any other provider
68
+ → the generic ``reviewer.md``), so the prompt differs per reviewer even on
69
+ round 0 — the result is always a per-reviewer ``{name: prompt}`` dict.
70
+ Round N>0 additionally folds in that reviewer's own prior-round response
71
+ (per-reviewer isolation).
72
+
73
+ Returns ``(prompt_arg, pr_doc_ref, pr_doc_is_out_of_repo)``: ``prompt_arg``
74
+ is a per-reviewer ``{name: prompt}`` dict;
75
+ ``pr_doc_ref`` is the worktree-LOCAL doc reference handed to reviewers;
76
+ ``pr_doc_is_out_of_repo`` flags a doc whose copy into each worktree must
77
+ never be skipped (see the caller's copy site).
78
+
79
+ Raises :class:`KeyError` / :class:`ValueError` when the (operator-
80
+ overridden) template references an unknown placeholder or a malformed
81
+ format spec — the caller maps that to a phase-failure round, mirroring the
82
+ producer/synth template-render contract.
83
+ """
84
+ # Strip context-file hunks from the reviewer-facing diff so reviewers do
85
+ # not see edits to files absent from their worktrees. Uses the same strip
86
+ # list as worktree creation in the caller.
87
+ filtered_diff = filter_diff_for_reviewer(
88
+ snapshot.diff_text, config.review.strip_repo_context_files
89
+ )
90
+ # Binary content never reaches the prompt (PR-h-field-01 item 2). `snapshot` renders with
91
+ # `--text`, so a committed PNG is emitted as raw bytes — measured, 12 screenshot
92
+ # baselines turned 66 KB of real diff into 3.1 MB. That is unreadable to a reviewer,
93
+ # displaces the diff it should be judging, and exceeds both providers' prompt ceilings
94
+ # (codex 1,048,576 chars; claude 1,000,000 tokens). Headers and a byte-count notice
95
+ # survive, so the change is disclosed rather than silently dropped.
96
+ filtered_diff, _ = elide_binary_hunks(filtered_diff)
97
+ diff_text = filtered_diff if filtered_diff else _NO_DIFF_SENTINEL
98
+ # Worktree-escape fix: reviewers run with cwd = their own worktree. Hand
99
+ # them a worktree-LOCAL reference to the PR doc (relative to the worktree
100
+ # root) — NOT the operator's absolute MAIN path. An absolute MAIN path lures
101
+ # the reviewer OUT of its isolated, CLAUDE.md/AGENTS.md-stripped worktree to
102
+ # review the live repo, defeating the isolation + blindness invariants
103
+ # (empirically: run 2026-05-30T21-22-19 round 0, claude-reviewer cd'd to
104
+ # MAIN for 25/32 bash commands + every Read). The doc is copied into each
105
+ # worktree by the caller. Same relative ref for every reviewer (each
106
+ # resolves it against its own cwd), so the round-0 shared prompt and the
107
+ # round-N per-reviewer prompts both work.
108
+ pr_doc_is_out_of_repo = False
109
+ try:
110
+ pr_doc_ref = str(pr_doc_path.relative_to(repo_root))
111
+ except ValueError:
112
+ # Out-of-repo doc: its basename may collide with a tracked file (e.g.
113
+ # README.md). A bare-basename ref would resolve to the repo's tracked
114
+ # file and the caller's copy would be skipped because the path already
115
+ # exists — reviewers would silently read the WRONG spec. Render a
116
+ # reserved, collision-free worktree-local ref under `.syncade-inputs/`
117
+ # (hashed on the resolved source path) so the supplied doc can ALWAYS
118
+ # be copied there without shadowing a tracked file.
119
+ pr_doc_is_out_of_repo = True
120
+ digest = hashlib.sha256(str(pr_doc_path).encode("utf-8")).hexdigest()[:16]
121
+ pr_doc_ref = f".syncade-inputs/pr-doc-{digest}-{pr_doc_path.name}"
122
+ # Each reviewer renders its own provider-specific template, so we always
123
+ # build a per-reviewer dict — there is no shared-str fast path now that the
124
+ # claude and codex prompts differ. Round 0 omits prior_round_output → the
125
+ # renderer's "(no prior round)" sentinel; round N>0 passes THIS reviewer's
126
+ # own prior-round response (per-reviewer isolation preserved).
127
+ prior_round_dir = run_dir / f"round-{round_idx - 1}" if round_idx > 0 else None
128
+ per_reviewer_prompts: dict[str, str] = {}
129
+ for reviewer in config.reviewers:
130
+ template = load_reviewer_template_for(
131
+ repo_root, provider=reviewer.provider, template=reviewer.template
132
+ )
133
+ render_kwargs: dict[str, object] = {
134
+ "pr_doc_path": pr_doc_ref,
135
+ "diff": diff_text,
136
+ "master_plan_path": None,
137
+ "json_schema": get_findings_schema_string(),
138
+ "adversarial_lens": reviewer.adversarial_lens,
139
+ "bug_class_sweep": reviewer.bug_class_sweep,
140
+ }
141
+ if prior_round_dir is not None:
142
+ render_kwargs["prior_round_output"] = load_prior_reviewer_response_text(
143
+ prior_round_dir=prior_round_dir,
144
+ reviewer_name=reviewer.name,
145
+ reviewer_provider=reviewer.provider,
146
+ )
147
+ per_reviewer_prompts[reviewer.name] = render_reviewer_prompt(template, **render_kwargs)
148
+ prompt_arg: str | dict[str, str] = per_reviewer_prompts
149
+ return prompt_arg, pr_doc_ref, pr_doc_is_out_of_repo
150
+
151
+
152
+ def _run_one_round(
153
+ *,
154
+ round_idx: int,
155
+ snapshot: Snapshot,
156
+ repo_root: Path,
157
+ pr_doc_path: Path,
158
+ run_id: str,
159
+ run_dir: Path,
160
+ round_dir: Path,
161
+ config: SyncadeConfig,
162
+ resolved_timeout: float,
163
+ resolved_producer_timeout: float,
164
+ adapter_factory: Callable[[str], ReviewerAdapter] | None,
165
+ synthesizer_adapter: ReviewerAdapter | None,
166
+ producer_adapter: ProducerAdapter | None,
167
+ worktree_base: Path,
168
+ logger: Logger,
169
+ started_at: datetime,
170
+ managers_to_cleanup: list[WorktreeManager],
171
+ resumed_under_drift: bool = False,
172
+ ) -> RoundResult:
173
+ """Execute one round of the per-round pipeline.
174
+
175
+ The round handles reviewer worktree provisioning, dispatch, synthesis,
176
+ optional test rerun, checks, and persistence. Producer and branch-advance
177
+ happen after this returns.
178
+
179
+ Returns the round's :class:`RoundResult` regardless of
180
+ outcome. The caller decides whether to terminate the loop or
181
+ dispatch a producer based on ``round_exit_code``.
182
+
183
+ """
184
+ _gate = run_predispatch_gates(
185
+ repo_root=repo_root,
186
+ snapshot=snapshot,
187
+ config=config,
188
+ round_idx=round_idx,
189
+ run_dir=run_dir,
190
+ round_dir=round_dir,
191
+ pr_doc_path=pr_doc_path,
192
+ started_at=started_at,
193
+ resumed_under_drift=resumed_under_drift,
194
+ logger=logger,
195
+ build_prompt=_build_reviewer_prompt,
196
+ )
197
+ if isinstance(_gate, RoundResult):
198
+ return _gate
199
+ prompt_arg = _gate.prompt_arg
200
+ pr_doc_ref = _gate.pr_doc_ref
201
+ pr_doc_is_out_of_repo = _gate.pr_doc_is_out_of_repo
202
+ _filtered_for_check = _gate.filtered_for_check
203
+
204
+ # --- Worktrees + dispatch ---------------------------------------
205
+ # Scope the WorktreeManager at <run-id>/round-N/ so each round's
206
+ # worktrees are isolated.
207
+ #
208
+ # When the test-leg worktree fails to provision, preserve reviewer
209
+ # worktrees on disk for inspection. The ``with`` block's ``__exit__`` runs
210
+ # ``cleanup_all`` only on clean exit; raising inside the block
211
+ # skips it. We raise the test_worktree_error inside the block
212
+ # to trigger preservation, then catch it here so
213
+ # ``_run_one_round`` returns a :class:`RoundResult` rather than
214
+ # bubbling the exception up to the loop (which would terminate
215
+ # the loop without recording the round's artifacts).
216
+ round_worktree_id = f"{run_id}/round-{round_idx}"
217
+ # Forward-declare round-result variables so they're bound in
218
+ # ``_run_one_round``'s scope across the with block (the
219
+ # test_worktree_error raise-then-catch flow needs to read them
220
+ # after the with block unwinds).
221
+ dispatch_result: DispatchResult | None = None
222
+ synth_result: SynthesizerResult | None = None
223
+ synth_paths: SynthesizerArtifactPaths | None = None
224
+ findings_md_path: Path | None = None
225
+ test_result: TestRunResult | None = None
226
+ test_skip_reason: TestSkipReason | None = None
227
+ test_worktree_error: WorktreeError | None = None
228
+ test_run_paths: TestRunArtifactPaths | None = None
229
+ check_results: list[TestRunResult] = []
230
+ round_exit_code: int = SUCCESS
231
+
232
+ # Defer cleanup so the loop terminator decides cleanup-vs-preserve from
233
+ # the final exit code.
234
+ try:
235
+ round_manager = WorktreeManager(
236
+ repo_root,
237
+ round_worktree_id,
238
+ base_dir=worktree_base,
239
+ defer_cleanup=True,
240
+ )
241
+ managers_to_cleanup.append(round_manager)
242
+ with round_manager as manager:
243
+ logger.event(
244
+ f"provisioning {len(config.reviewers)} worktree(s) under {manager.run_dir}"
245
+ )
246
+ worktree_paths: dict[str, Path] = {}
247
+ for reviewer in config.reviewers:
248
+ worktree = manager.create(
249
+ reviewer_name=reviewer.name,
250
+ commit_sha=snapshot.commit_sha,
251
+ strip_files=config.review.strip_repo_context_files,
252
+ )
253
+ worktree_paths[reviewer.name] = worktree.path
254
+ # Ensure the PR doc is readable from INSIDE the worktree so the
255
+ # worktree-local `pr_doc_ref` resolves without the reviewer
256
+ # leaving its sandbox. An out-of-repo doc ALWAYS copies to its
257
+ # reserved collision-free ref (never skip — a same-basename
258
+ # tracked file, e.g. README.md, must not shadow the supplied
259
+ # doc and feed reviewers the WRONG spec). A tracked in-repo doc
260
+ # is already present (no-op); an untracked in-repo doc copies in.
261
+ pr_doc_in_worktree = worktree.path / pr_doc_ref
262
+ if pr_doc_is_out_of_repo or not pr_doc_in_worktree.exists():
263
+ import shutil
264
+
265
+ pr_doc_in_worktree.parent.mkdir(parents=True, exist_ok=True)
266
+ shutil.copy2(pr_doc_path, pr_doc_in_worktree)
267
+ logger.event(f" worktree ready: {reviewer.name} -> {worktree.path}")
268
+
269
+ # Report the timeout honestly: "each" only holds when every reviewer uses the fallback.
270
+ # A per-reviewer ``timeout_seconds`` override makes "1800s each" a lie (dogfood R2-M4).
271
+ if any(r.timeout_seconds is not None for r in config.reviewers):
272
+ timeout_note = f"per-reviewer timeouts, {resolved_timeout:g}s fallback"
273
+ else:
274
+ timeout_note = f"timeout {resolved_timeout:g}s each"
275
+ logger.event(
276
+ f"dispatching {len(config.reviewers)} reviewer(s) in parallel ({timeout_note})"
277
+ )
278
+ for reviewer in config.reviewers:
279
+ logger.event(f" -> {reviewer.name} ({reviewer.provider}) started")
280
+
281
+ # Written BEFORE the panel runs: every other artifact lands after it returns, so a
282
+ # death mid-dispatch left an empty round dir and nothing to diagnose from.
283
+ persist_dispatch_record(
284
+ round_dir,
285
+ round_index=round_idx,
286
+ reviewers=config.reviewers,
287
+ timeout_seconds=resolved_timeout,
288
+ )
289
+ dispatch_result = dispatch_reviewers(
290
+ config.reviewers,
291
+ worktree_paths=worktree_paths,
292
+ prompt=prompt_arg,
293
+ timeout_seconds=resolved_timeout,
294
+ adapter_factory=adapter_factory,
295
+ pricing=config.pricing,
296
+ max_retries=config.retry.max_retries,
297
+ # Reviewers write their output here as they produce it, so a run that dies
298
+ # mid-panel leaves it behind (PR-h-field-03). Same directory dispatch.json
299
+ # was just written to, and the same filenames persistence uses.
300
+ capture_dir=round_dir,
301
+ # Fills each reviewer's pid into the dispatch record as its child appears.
302
+ on_child_spawn=partial(record_child_pid, round_dir),
303
+ )
304
+
305
+ for run_result in dispatch_result.results:
306
+ prefix = (
307
+ f" <- {run_result.reviewer_name} ({run_result.provider}) "
308
+ f"finished in {run_result.duration_seconds:.1f}s — "
309
+ )
310
+ if run_result.output is None:
311
+ err_cls = type(run_result.error).__name__ if run_result.error else "Unknown"
312
+ logger.event(f"{prefix}FAILED ({err_cls})", error=True)
313
+ else:
314
+ logger.event(
315
+ f"{prefix}{run_result.output.verdict}, "
316
+ f"{len(run_result.output.findings)} finding(s)"
317
+ )
318
+
319
+ logger.event(f"persisting reviewer outputs to {round_dir}")
320
+ for run_result in dispatch_result.results:
321
+ persist_reviewer_result(
322
+ round_dir,
323
+ run_result,
324
+ run_result.raw_subprocess_result,
325
+ )
326
+
327
+ # --- Synthesizer phase --------------------------------------
328
+ # PR-v2-11: the budget is deliberately NOT re-checked between the reviewer panel and
329
+ # the judge. The judge is part of the atomic "review-bundle" — a cheap (~$0.05)
330
+ # deterministic consolidation whose output IS this round's findings.md. Gating it out
331
+ # on a reviewer-panel budget crossing would leave the crossing round with no
332
+ # consolidated verdict, breaking the "round is whole" guarantee (C2). The budget's
333
+ # two enforced boundaries (before-round in loop.py, before-producer in
334
+ # loop_round_step.py) skip the EXPENSIVE producer instead; overshoot is bounded to
335
+ # one review-bundle or one producer, never a running call. See the PR-v2-11 brief.
336
+ if dispatch_result.all_succeeded:
337
+ run_status.update_phase(f"round-{round_idx}: synthesizing", round_idx)
338
+ logger.event(f"synthesizing reviewer outputs (cold {config.synthesizer.provider})")
339
+ synth_result = run_synthesizer(
340
+ dispatch_result.results,
341
+ repo_root=repo_root,
342
+ pr_doc_path=pr_doc_path,
343
+ timeout_seconds=resolved_timeout,
344
+ config=config.synthesizer,
345
+ adapter=synthesizer_adapter,
346
+ pricing=config.pricing,
347
+ max_retries=config.retry.max_retries,
348
+ )
349
+ prefix = f" synthesizer finished in {synth_result.duration_seconds:.1f}s — "
350
+ if synth_result.output is None:
351
+ err_cls = type(synth_result.error).__name__ if synth_result.error else "Unknown"
352
+ logger.event(f"{prefix}FAILED ({err_cls})", error=True)
353
+ else:
354
+ findings = len(synth_result.output.consolidated_findings)
355
+ dismissed = sum(
356
+ 1 for f in synth_result.output.consolidated_findings if f.dismissed
357
+ )
358
+ active = findings - dismissed
359
+ logger.event(
360
+ f"{prefix}{active} active finding(s), {dismissed} dismissed "
361
+ f"(of {findings} consolidated)"
362
+ )
363
+ else:
364
+ logger.event(" synthesizer skipped (one or more reviewers failed)")
365
+
366
+ if synth_result is not None:
367
+ synth_paths = persist_synthesizer_result(round_dir, synth_result)
368
+
369
+ # --- Test re-run leg ---------------------------------------
370
+ if (
371
+ dispatch_result.all_succeeded
372
+ and synth_result is not None
373
+ and synth_result.output is not None
374
+ and not has_active_blocker(synth_result.output)
375
+ and config.loop.test_command is not None
376
+ ):
377
+ test_timeout = (
378
+ config.loop.test_timeout_seconds
379
+ if config.loop.test_timeout_seconds is not None
380
+ else resolved_timeout
381
+ )
382
+ try:
383
+ test_worktree = manager.create(
384
+ reviewer_name=_TEST_WORKTREE_NAME,
385
+ commit_sha=snapshot.commit_sha,
386
+ strip_files=config.review.strip_repo_context_files,
387
+ )
388
+ except WorktreeError as exc:
389
+ test_worktree_error = exc
390
+ test_skip_reason = "test_worktree_error"
391
+ logger.event(
392
+ f" test re-run skipped (test worktree provisioning failed: {exc})"
393
+ )
394
+ else:
395
+ run_status.update_phase(f"round-{round_idx}: testing", round_idx)
396
+ logger.event(f" worktree ready: {_TEST_WORKTREE_NAME} -> {test_worktree.path}")
397
+ logger.event(
398
+ f"running test re-run leg: {config.loop.test_command!r} "
399
+ f"(timeout {test_timeout:g}s)"
400
+ )
401
+ test_result = run_tests(
402
+ worktree_path=test_worktree.path,
403
+ test_command=config.loop.test_command,
404
+ timeout_seconds=test_timeout,
405
+ )
406
+ prefix = f" test re-run finished in {test_result.duration_seconds:.1f}s — "
407
+ if test_result.outcome == "subprocess_error":
408
+ err_cls = (
409
+ type(test_result.error).__name__ if test_result.error else "Unknown"
410
+ )
411
+ logger.event(f"{prefix}SUBPROCESS_ERROR ({err_cls})", error=True)
412
+ else:
413
+ logger.event(
414
+ f"{prefix}{test_result.outcome.upper()} (exit {test_result.exit_code})"
415
+ )
416
+ elif not dispatch_result.all_succeeded:
417
+ test_skip_reason = "reviewer_failed"
418
+ logger.event(" test re-run skipped (a reviewer failed in dispatch)")
419
+ elif synth_result is None or synth_result.error is not None:
420
+ test_skip_reason = "synth_failed"
421
+ logger.event(" test re-run skipped (the synthesizer phase failed)")
422
+ elif (
423
+ synth_result is not None
424
+ and synth_result.output is not None
425
+ and has_active_blocker(synth_result.output)
426
+ ):
427
+ test_skip_reason = "synth_blocker"
428
+ logger.event(
429
+ " test re-run skipped (the synthesizer surfaced an active blocker "
430
+ "with a NO-SHIP verdict)"
431
+ )
432
+ else:
433
+ test_skip_reason = "test_command_unset"
434
+ logger.event(
435
+ " test re-run skipped (test_command is not configured in .syncade/config.toml)"
436
+ )
437
+
438
+ if test_result is not None:
439
+ test_run_paths = persist_test_run_result(round_dir, test_result)
440
+
441
+ # --- Mechanical checks leg ---------------------------------
442
+ run_status.update_phase(f"round-{round_idx}: checking", round_idx)
443
+ check_results = _run_checks_leg(
444
+ manager=manager,
445
+ config=config,
446
+ snapshot=snapshot,
447
+ dispatch_result=dispatch_result,
448
+ synth_result=synth_result,
449
+ resolved_timeout=resolved_timeout,
450
+ round_dir=round_dir,
451
+ logger=logger,
452
+ )
453
+
454
+ # --- Compute round verdict ----------------------------------
455
+ if test_worktree_error is not None:
456
+ round_exit_code = WORKTREE_ERROR
457
+ else:
458
+ # Only blocking check results reach the verdict; advisory
459
+ # results are structurally never passed in, so they cannot gate.
460
+ round_exit_code = _compute_exit_code(
461
+ dispatch_result,
462
+ synth_result,
463
+ test_result,
464
+ blocking_check_results=[c for c in check_results if c.severity == "blocking"],
465
+ )
466
+
467
+ # --- Persistence block --------------------------------------
468
+ try:
469
+ if synth_result is not None and synth_result.output is not None:
470
+ findings_md_path = persist_findings_md(
471
+ round_dir,
472
+ synth_result,
473
+ started_at,
474
+ test_result,
475
+ dispatch_result,
476
+ test_skip_reason=test_skip_reason,
477
+ snapshot_sha=snapshot.commit_sha,
478
+ check_results=check_results,
479
+ )
480
+ # Refresh the run-root findings.md convenience copy so the
481
+ # operator can address latest findings without knowing the
482
+ # round number. Best-effort: a copy failure
483
+ # doesn't block the per-round persistence —
484
+ # the round-N/findings.md is the source of
485
+ # truth and lives on disk regardless.
486
+ persist_current_findings_md(run_dir, findings_md_path)
487
+
488
+ persist_round_manifest(
489
+ round_dir,
490
+ snapshot,
491
+ dispatch_result,
492
+ round_exit_code,
493
+ started_at,
494
+ synth_result,
495
+ test_result,
496
+ test_skip_reason,
497
+ round_idx=round_idx,
498
+ producer_result=None,
499
+ producer_provider=config.producer.provider,
500
+ producer_model=config.producer.model,
501
+ check_results=check_results,
502
+ filtered_diff_bytes=len(_filtered_for_check.encode("utf-8")),
503
+ raw_diff_bytes=len((snapshot.diff_text or "").encode("utf-8")),
504
+ )
505
+ persist_run_summary(
506
+ round_dir,
507
+ snapshot,
508
+ dispatch_result,
509
+ round_exit_code,
510
+ started_at,
511
+ synth_result,
512
+ test_result,
513
+ test_skip_reason,
514
+ resumed_under_drift=resumed_under_drift,
515
+ check_results=check_results,
516
+ )
517
+ except Exception as persist_exc:
518
+ if test_worktree_error is not None:
519
+ raise persist_exc from test_worktree_error
520
+ raise
521
+
522
+ # Raise the captured test_worktree_error inside the with block so
523
+ # __exit__ skips cleanup and reviewer worktrees survive on disk.
524
+ if test_worktree_error is not None:
525
+ raise test_worktree_error
526
+ except WorktreeError:
527
+ # Re-enter here only when the captured ``test_worktree_error`` was
528
+ # raised inside the with block to trigger worktree preservation.
529
+ # Distinguish this case from a *different* WorktreeError (e.g. reviewer-name
530
+ # duplicate during reviewer-worktree provisioning, which
531
+ # fires BEFORE we ever set test_worktree_error). The
532
+ # pre-dispatch WorktreeError path must propagate to the
533
+ # CLI which maps it to exit 60 with the original message;
534
+ # falling through to RoundResult assembly here would lose
535
+ # the exception AND populate the result with a None
536
+ # dispatch_result that downstream rendering (logger.summary,
537
+ # persist_*) can't handle.
538
+ if test_worktree_error is None:
539
+ raise
540
+ # Otherwise: the captured test_worktree_error was the cause.
541
+ # Fall through to RoundResult assembly. The variables
542
+ # (dispatch_result, synth_result, etc.) are bound in
543
+ # _run_one_round's scope, so they remain accessible after
544
+ # the with block unwinds. The orchestrator treats
545
+ # ``round_exit_code == WORKTREE_ERROR`` + non-None
546
+ # ``test_worktree_error`` as the terminator for this round.
547
+
548
+ # --- Assemble round artifacts + result ------------------------
549
+ artifacts = RoundArtifacts(
550
+ round_idx=round_idx,
551
+ round_dir=round_dir,
552
+ manifest_path=round_dir / "manifest.json",
553
+ summary_path=round_dir / "summary.md",
554
+ findings_md_path=findings_md_path,
555
+ synthesizer_paths=synth_paths,
556
+ test_run_paths=test_run_paths,
557
+ producer_paths=None, # filled in by run_review after producer phase
558
+ )
559
+ return RoundResult(
560
+ round_idx=round_idx,
561
+ snapshot=snapshot,
562
+ dispatch_result=dispatch_result,
563
+ synth_result=synth_result,
564
+ test_result=test_result,
565
+ test_skip_reason=test_skip_reason,
566
+ test_worktree_error=test_worktree_error,
567
+ producer_result=None,
568
+ round_exit_code=round_exit_code,
569
+ artifacts=artifacts,
570
+ check_results=check_results,
571
+ filtered_diff_bytes=len(_filtered_for_check.encode("utf-8")),
572
+ raw_diff_bytes=len((snapshot.diff_text or "").encode("utf-8")),
573
+ )
@@ -0,0 +1,91 @@
1
+ """Mechanical-checks leg of the per-round pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from syncade.persistence import persist_check_result
6
+ from syncade.test_runner import TestRunResult, run_tests
7
+ from syncade.worktree import WorktreeError
8
+
9
+
10
+ def _run_checks_leg(
11
+ *,
12
+ manager,
13
+ config,
14
+ snapshot,
15
+ dispatch_result,
16
+ synth_result,
17
+ resolved_timeout,
18
+ round_dir,
19
+ logger,
20
+ ) -> list[TestRunResult]:
21
+ """Run the configured mechanical checks in stripped worktrees and return
22
+ their results (``[]`` when the gate isn't met / no checks configured).
23
+
24
+ Gate: reviewers succeeded AND the synth produced output — INCLUDING
25
+ synth-blocker rounds, so advisory drift surfaces on every reviewable round
26
+ (the check scenario). Skipped entirely when no checks are configured
27
+ (zero-config → byte-identical) or when reviewers failed / synth errored.
28
+ """
29
+ check_results: list[TestRunResult] = []
30
+ if not (
31
+ config.checks
32
+ and dispatch_result.all_succeeded
33
+ and synth_result is not None
34
+ and synth_result.output is not None
35
+ ):
36
+ return check_results
37
+
38
+ check_timeout = (
39
+ config.loop.test_timeout_seconds
40
+ if config.loop.test_timeout_seconds is not None
41
+ else resolved_timeout
42
+ )
43
+ logger.event(f"running {len(config.checks)} mechanical check(s)")
44
+ for check in config.checks:
45
+ try:
46
+ check_worktree = manager.create(
47
+ reviewer_name=check.name,
48
+ commit_sha=snapshot.commit_sha,
49
+ strip_files=config.review.strip_repo_context_files,
50
+ )
51
+ except WorktreeError as exc:
52
+ # A check whose worktree can't be provisioned counts as a
53
+ # subprocess_error (the check couldn't run): a blocking
54
+ # check then → REVIEWER_FAILURE, advisory → surfaced
55
+ # only. Name collisions are rejected at config-load, so
56
+ # this is a disk/path failure, not a name clash.
57
+ check_results.append(
58
+ TestRunResult(
59
+ name=check.name,
60
+ severity=check.severity,
61
+ exit_code=-1,
62
+ outcome="subprocess_error",
63
+ duration_seconds=0.0,
64
+ stdout="",
65
+ stderr="",
66
+ error=exc,
67
+ command=check.command,
68
+ )
69
+ )
70
+ continue
71
+ logger.event(f" worktree ready: {check.name} -> {check_worktree.path}")
72
+ check_result = run_tests(
73
+ worktree_path=check_worktree.path,
74
+ test_command=check.command,
75
+ timeout_seconds=check_timeout,
76
+ name=check.name,
77
+ severity=check.severity,
78
+ )
79
+ prefix = (
80
+ f" check {check_result.name!r} ({check_result.severity}) finished in "
81
+ f"{check_result.duration_seconds:.1f}s — "
82
+ )
83
+ if check_result.outcome == "subprocess_error":
84
+ err_cls = type(check_result.error).__name__ if check_result.error else "Unknown"
85
+ logger.event(f"{prefix}SUBPROCESS_ERROR ({err_cls})", error=True)
86
+ else:
87
+ logger.event(f"{prefix}{check_result.outcome.upper()} (exit {check_result.exit_code})")
88
+ check_results.append(check_result)
89
+ for cr in check_results:
90
+ persist_check_result(round_dir, cr)
91
+ return check_results