syncade 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
syncade/dispatcher.py ADDED
@@ -0,0 +1,571 @@
1
+ """Parallel reviewer dispatcher.
2
+
3
+ The dispatcher takes N :class:`ReviewerConfig`s, routes each to its
4
+ provider's adapter via :func:`syncade.adapters.registry.get_adapter`,
5
+ runs the configured pre-flight auth checks in parallel, then dispatches
6
+ the reviewer subprocesses in parallel, and aggregates the results into
7
+ a :class:`DispatchResult`.
8
+
9
+ Threading model: :class:`concurrent.futures.ThreadPoolExecutor`. Each
10
+ reviewer is mostly I/O-bound (waiting on its CLI subprocess), so
11
+ threads are sufficient and simpler than ``asyncio``. We expect 2-3
12
+ reviewers per dispatch in practice — not 100 — so max_workers defaults
13
+ to ``len(reviewer_configs)``.
14
+
15
+ Failure surfaces — three categories, deliberately distinct in
16
+ ``DispatchResult.failures``:
17
+
18
+ 1. **Adapter lookup failure** (unknown provider): the WHOLE batch
19
+ fails immediately. Every reviewer's :class:`ReviewerRunResult`
20
+ carries the same registry error. We don't start any parallel work.
21
+ 2. **Pre-flight auth failure**: the WHOLE batch fails. Every reviewer
22
+ carries the first auth error from the parallel pre-flight pass.
23
+ No reviewer subprocess actually runs.
24
+ 3. **Per-reviewer failure during dispatch**: only that reviewer's
25
+ :class:`ReviewerRunResult` carries the error. Others run normally.
26
+ This is the only path where partial success / partial failure
27
+ coexist in the same :class:`DispatchResult`. The orchestrator decides
28
+ whether to abort the round or surface the failure.
29
+
30
+ The dispatcher itself does NOT degrade silently to N-1 reviewers — the
31
+ panel-diversity invariant (the point of running more than one reviewer;
32
+ cross-prompt today, cross-lab by design) requires the orchestrator to
33
+ make that call explicitly. The dispatcher just records what happened.
34
+
35
+ The only inputs that make :func:`dispatch_reviewers` raise rather than
36
+ return a :class:`DispatchResult` are ``None`` for ``reviewer_configs``
37
+ or ``worktree_paths`` — those are caller bugs, not runtime conditions
38
+ worth recording per-reviewer. See the function docstring's Raises
39
+ section.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import time
45
+ from collections.abc import Callable
46
+ from concurrent.futures import ThreadPoolExecutor, as_completed
47
+ from dataclasses import dataclass, field
48
+ from pathlib import Path
49
+
50
+ from syncade import retry
51
+ from syncade.adapters.base import ReviewerAdapter
52
+ from syncade.adapters.registry import get_adapter
53
+ from syncade.config import ReviewerConfig
54
+ from syncade.findings import ReviewerOutput
55
+ from syncade.pricing_config import PricingConfig
56
+ from syncade.process import (
57
+ SubprocessResult,
58
+ SubprocessTimeoutError,
59
+ run_subprocess,
60
+ terminate_active_child_groups,
61
+ )
62
+ from syncade.usage import Usage, _add_usage, _auth_mode, usage_for
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class ReviewerRunResult:
67
+ """Outcome of a single reviewer's run within a dispatch batch.
68
+
69
+ Exactly one of ``output`` or ``error`` is non-``None``. The
70
+ dispatcher never silently drops a reviewer — every config goes
71
+ in, exactly one result comes out, success or failure recorded.
72
+
73
+ Attributes:
74
+ reviewer_name: From :attr:`ReviewerConfig.name`. The orchestrator
75
+ uses this to route findings back to the right worktree.
76
+ provider: From :attr:`ReviewerConfig.provider`. The downstream
77
+ synthesis subprocess may want to know which model family
78
+ each finding came from.
79
+ output: The parsed :class:`ReviewerOutput` on success;
80
+ ``None`` on failure.
81
+ error: The exception that fired on failure;
82
+ :class:`~syncade.adapters.base.ReviewerInvocationError` or
83
+ :class:`~syncade.findings.ReviewerOutputError` or a
84
+ :class:`~syncade.process.SubprocessError` subclass. ``None``
85
+ on success.
86
+ duration_seconds: Wall-clock duration of this reviewer's run.
87
+ For auth-fail or adapter-lookup failures, this is ``0.0``
88
+ (the reviewer never actually ran).
89
+ raw_subprocess_result: The :class:`SubprocessResult` returned
90
+ by :func:`syncade.process.run_subprocess` for this reviewer,
91
+ preserved so the orchestrator can persist the raw
92
+ stdout/stderr to disk under ``.syncade/runs/<run-id>/``.
93
+ ``None`` for any failure that happened before the
94
+ subprocess produced output — adapter lookup, auth
95
+ fail-fast, ``build_invocation`` raising, missing worktree
96
+ path, or the subprocess failing to launch at all (binary
97
+ not found). On a **timeout** this is NOT ``None``:
98
+ the dispatcher synthesizes a :class:`SubprocessResult`
99
+ (sentinel ``returncode=-1``) carrying the partial
100
+ stdout/stderr the reviewer produced before the SIGKILL, so
101
+ persistence still writes the ``.stdout`` / ``.stderr``
102
+ files. Defaults to ``None`` for existing callers/tests that
103
+ construct ``ReviewerRunResult`` directly.
104
+ retries: Number of EXTRA subprocess attempts this reviewer
105
+ consumed riding out transient provider errors (H5) — ``0``
106
+ when the first attempt resolved (success or terminal
107
+ failure). Persistence aggregates this into the round
108
+ manifest's ``"retried"`` annotation. Defaults to ``0`` for
109
+ callers that construct ``ReviewerRunResult`` directly.
110
+ """
111
+
112
+ reviewer_name: str
113
+ provider: str
114
+ output: ReviewerOutput | None
115
+ error: Exception | None
116
+ duration_seconds: float
117
+ raw_subprocess_result: SubprocessResult | None = field(default=None)
118
+ retries: int = field(default=0)
119
+ usage: Usage | None = field(default=None)
120
+ model: str = field(default="")
121
+
122
+
123
+ @dataclass(frozen=True)
124
+ class DispatchResult:
125
+ """Aggregate of all reviewers' run results for a single dispatch."""
126
+
127
+ results: list[ReviewerRunResult]
128
+ total_duration_seconds: float
129
+ # True iff Phase 3 (subprocess dispatch) was actually entered.
130
+ # Adapter-lookup and auth-preflight failures return non-empty ``results`` without
131
+ # starting any reviewer subprocess; this flag distinguishes them from real dispatch.
132
+ reviewer_subprocess_started: bool = False
133
+
134
+ @property
135
+ def all_succeeded(self) -> bool:
136
+ """True iff every reviewer produced a :class:`ReviewerOutput`."""
137
+ return bool(self.results) and all(r.output is not None for r in self.results)
138
+
139
+ @property
140
+ def successes(self) -> list[ReviewerRunResult]:
141
+ """Reviewers that produced parsed output, in input order."""
142
+ return [r for r in self.results if r.output is not None]
143
+
144
+ @property
145
+ def failures(self) -> list[ReviewerRunResult]:
146
+ """Reviewers that recorded an error, in input order."""
147
+ return [r for r in self.results if r.error is not None]
148
+
149
+
150
+ def dispatch_reviewers(
151
+ reviewer_configs: list[ReviewerConfig],
152
+ *,
153
+ worktree_paths: dict[str, Path],
154
+ prompt: str | dict[str, str],
155
+ timeout_seconds: float = 1800,
156
+ skip_auth_check: bool = False,
157
+ adapter_factory: Callable[[str], ReviewerAdapter] | None = None,
158
+ pricing: PricingConfig | None = None,
159
+ max_retries: int = retry.MAX_RETRIES,
160
+ capture_dir: Path | None = None,
161
+ on_child_spawn: Callable[[str, int], None] | None = None,
162
+ ) -> DispatchResult:
163
+ """Dispatch each ``ReviewerConfig`` to its provider's adapter in parallel.
164
+
165
+ Order of operations:
166
+
167
+ 1. Look up each adapter via :func:`adapter_factory` (default:
168
+ :func:`syncade.adapters.registry.get_adapter`). Unknown provider
169
+ → fail the whole dispatch immediately (don't start parallel
170
+ runs only to find out one is misrouted). Every reviewer's
171
+ :class:`ReviewerRunResult` carries the same registry error.
172
+ 2. Call each adapter's :meth:`ReviewerAdapter.check_auth` in
173
+ parallel. Any auth failure fails the whole dispatch
174
+ immediately — we don't want one reviewer to burn tokens while
175
+ another is dead in the water. Skipped when ``skip_auth_check``
176
+ is ``True`` (for tests using :class:`FakeAdapter`, where
177
+ ``check_auth`` is a no-op anyway).
178
+ 3. For each (config, adapter, worktree_path), build the
179
+ :class:`Invocation`, run the subprocess via
180
+ :func:`syncade.process.run_subprocess`, parse the output.
181
+ Capture either :class:`ReviewerOutput` or whichever exception
182
+ was raised.
183
+ 4. Return :class:`DispatchResult` with one
184
+ :class:`ReviewerRunResult` per input config, **in input order**
185
+ (so a caller can ``zip(reviewer_configs, result.results)``).
186
+
187
+ Args:
188
+ reviewer_configs: One or more reviewer configurations. Each
189
+ ``provider`` must be a key the adapter factory recognizes.
190
+ worktree_paths: Dict keyed by ``ReviewerConfig.name`` — the
191
+ worktree each reviewer should run in. Two reviewers from
192
+ the same provider would otherwise collide on the same
193
+ worktree; keying by name avoids that. The orchestrator populates this dict via
194
+ :class:`~syncade.worktree.WorktreeManager`. If a
195
+ ``reviewer_name`` is missing from this dict, only that
196
+ reviewer fails (with a clear ``ValueError``); others run.
197
+ prompt: The fully-rendered reviewer prompt. Two shapes:
198
+
199
+ - ``str``: every reviewer gets the same prompt. Still
200
+ accepted by the dispatcher, but the orchestrator no longer
201
+ uses this shape — it always passes the dict below.
202
+ - ``dict[str, str]``: per-reviewer prompts keyed by
203
+ :attr:`ReviewerConfig.name`. The orchestrator always uses
204
+ this shape: each reviewer renders its OWN provider-specific
205
+ template (claude vs codex get differentiated adversarial
206
+ prompts), and on multi-round runs each reviewer's
207
+ ``{prior_round_output}`` placeholder also gets that
208
+ reviewer's OWN prior-round response text (per-reviewer
209
+ isolation across rounds — round-1 claude sees claude's
210
+ round-0 output, NOT codex's). The dict MUST contain an entry for every
211
+ :attr:`ReviewerConfig.name`; a missing key surfaces as
212
+ ``KeyError`` during dispatch (a caller bug, not a
213
+ runtime condition).
214
+ timeout_seconds: The FALLBACK reviewer wall-clock timeout — used
215
+ for any reviewer whose own :attr:`ReviewerConfig.timeout_seconds`
216
+ is unset (the common case). A reviewer that sets its own value
217
+ overrides this per-reviewer (PR-v2-9). The subprocess for any
218
+ reviewer that exceeds its resolved timeout is SIGKILL'd and the
219
+ result records :class:`~syncade.process.SubprocessTimeoutError`.
220
+ Default 1800s (30 minutes), matching
221
+ :attr:`syncade.config.LoopConfig.timeout_seconds`. This
222
+ default is the floor for callers that invoke the
223
+ dispatcher directly, bypassing the orchestrator (none
224
+ today — it exists for future code); the orchestrator
225
+ always passes an explicit resolved value (CLI ``--timeout``
226
+ > ``.syncade/config.toml`` > the ``LoopConfig`` default).
227
+ skip_auth_check: When ``True``, the pre-flight auth phase is
228
+ skipped. Intended for tests; production callers leave this
229
+ False.
230
+ adapter_factory: A callable that takes a provider string and
231
+ returns a :class:`ReviewerAdapter`. Defaults to the
232
+ production registry. Tests inject a factory that returns
233
+ :class:`FakeAdapter` instances so the dispatcher's
234
+ integration with the registry can be exercised without
235
+ real CLIs.
236
+
237
+ Returns:
238
+ :class:`DispatchResult` with one :class:`ReviewerRunResult`
239
+ per input config, in input order. ``total_duration_seconds``
240
+ is the wall-clock time for the whole dispatch — for parallel
241
+ runs this is roughly the slowest reviewer's duration plus a
242
+ small auth-check overhead, not the sum of durations.
243
+
244
+ Raises:
245
+ TypeError: If ``reviewer_configs`` or ``worktree_paths`` is
246
+ ``None``. These are caller bugs (the public API requires
247
+ real containers), not runtime conditions worth turning
248
+ into ``DispatchResult.failures`` — the orchestrator can't
249
+ sensibly recover from "you passed None instead of a list."
250
+ Every other failure mode (adapter lookup, auth, build,
251
+ subprocess, parse) is captured in the returned
252
+ :class:`DispatchResult` so the orchestrator can decide
253
+ whether to abort, retry, or proceed with partial results.
254
+ """
255
+ if reviewer_configs is None:
256
+ raise TypeError(
257
+ "dispatch_reviewers: reviewer_configs must be a list (got None). "
258
+ "Pass [] if you legitimately have no reviewers to dispatch."
259
+ )
260
+ if worktree_paths is None:
261
+ raise TypeError(
262
+ "dispatch_reviewers: worktree_paths must be a dict (got None). "
263
+ "Pass {} if you have no worktrees to map — every reviewer will "
264
+ "fail individually with a missing-worktree ValueError."
265
+ )
266
+ if isinstance(prompt, dict):
267
+ missing_prompt_names = [cfg.name for cfg in reviewer_configs if cfg.name not in prompt]
268
+ if missing_prompt_names:
269
+ missing = ", ".join(repr(name) for name in missing_prompt_names)
270
+ raise KeyError(f"dispatch_reviewers: prompt mapping missing reviewer key(s): {missing}")
271
+ if adapter_factory is None:
272
+ adapter_factory = get_adapter
273
+
274
+ start = time.monotonic()
275
+
276
+ # --- Phase 1: adapter lookup -----------------------------------
277
+ # Build pairs of (config, adapter). If any lookup raises (unknown
278
+ # provider), every reviewer in the batch gets that same error and
279
+ # we skip auth + dispatch entirely.
280
+ pairs: list[tuple[ReviewerConfig, ReviewerAdapter]] = []
281
+ lookup_error: Exception | None = None
282
+ for config in reviewer_configs:
283
+ try:
284
+ adapter = adapter_factory(config.provider)
285
+ except Exception as exc: # noqa: BLE001 — surface ANY factory error
286
+ lookup_error = exc
287
+ break
288
+ pairs.append((config, adapter))
289
+
290
+ if lookup_error is not None:
291
+ total = time.monotonic() - start
292
+ return DispatchResult(
293
+ results=[
294
+ ReviewerRunResult(
295
+ reviewer_name=cfg.name,
296
+ provider=cfg.provider,
297
+ output=None,
298
+ error=lookup_error,
299
+ duration_seconds=0.0,
300
+ model=cfg.model,
301
+ )
302
+ for cfg in reviewer_configs
303
+ ],
304
+ total_duration_seconds=total,
305
+ )
306
+
307
+ # Defensive: empty configs list yields an empty result.
308
+ # ThreadPoolExecutor(max_workers=0) is illegal; guard before
309
+ # constructing one.
310
+ if not pairs:
311
+ return DispatchResult(
312
+ results=[],
313
+ total_duration_seconds=time.monotonic() - start,
314
+ )
315
+
316
+ # --- Phase 2: parallel auth pre-flight -------------------------
317
+ if not skip_auth_check:
318
+ first_auth_error: Exception | None = None
319
+ with ThreadPoolExecutor(max_workers=len(pairs)) as executor:
320
+ futures = {executor.submit(adapter.check_auth): config for config, adapter in pairs}
321
+ for future in as_completed(futures):
322
+ try:
323
+ future.result()
324
+ except Exception as exc: # noqa: BLE001
325
+ if first_auth_error is None:
326
+ first_auth_error = exc
327
+ # Continue draining the others to avoid leaked
328
+ # futures, but the whole batch fails below.
329
+
330
+ if first_auth_error is not None:
331
+ total = time.monotonic() - start
332
+ return DispatchResult(
333
+ results=[
334
+ ReviewerRunResult(
335
+ reviewer_name=config.name,
336
+ provider=config.provider,
337
+ output=None,
338
+ error=first_auth_error,
339
+ duration_seconds=0.0,
340
+ model=config.model,
341
+ )
342
+ for config, _ in pairs
343
+ ],
344
+ total_duration_seconds=total,
345
+ )
346
+
347
+ # --- Phase 3: parallel reviewer dispatch -----------------------
348
+ # Resolve the prompt argument to a per-reviewer mapping. When a
349
+ # ``str`` is passed, every reviewer gets the same prompt. When a
350
+ # ``dict`` is passed, each reviewer gets ``prompt[name]``; KeyError
351
+ # on missing names surfaces as a caller bug rather than a silent
352
+ # fallback. Round > 0 dispatch uses the dict path so each reviewer sees
353
+ # its own prior-round response text.
354
+ if isinstance(prompt, dict):
355
+ per_reviewer_prompts: dict[str, str] = prompt
356
+ else:
357
+ per_reviewer_prompts = {cfg.name: prompt for cfg, _ in pairs}
358
+
359
+ with ThreadPoolExecutor(max_workers=len(pairs)) as executor:
360
+ # Submit in input order, collect in input order (futures list
361
+ # preserves submission order; .result() blocks per-future).
362
+ futures = [
363
+ executor.submit(
364
+ _run_single_reviewer,
365
+ config,
366
+ adapter,
367
+ worktree_paths,
368
+ per_reviewer_prompts[config.name],
369
+ # Per-reviewer timeout (PR-v2-9): a reviewer's own ``timeout_seconds`` wins; None
370
+ # (the default) falls back to the resolved loop/CLI global passed in here.
371
+ config.timeout_seconds if config.timeout_seconds is not None else timeout_seconds,
372
+ pricing,
373
+ max_retries,
374
+ capture_dir,
375
+ on_child_spawn,
376
+ )
377
+ for config, adapter in pairs
378
+ ]
379
+ try:
380
+ results = [future.result() for future in futures]
381
+ except BaseException:
382
+ # A signal (or any interrupt) reached the main thread while workers are
383
+ # blocked in communicate(); their own killpg cleanup can't fire. Kill the
384
+ # in-flight reviewer groups here so communicate() returns and the executor's
385
+ # shutdown(wait=True) on __exit__ is prompt instead of hanging to timeout.
386
+ terminate_active_child_groups()
387
+ raise
388
+
389
+ return DispatchResult(
390
+ results=results,
391
+ total_duration_seconds=time.monotonic() - start,
392
+ reviewer_subprocess_started=True,
393
+ )
394
+
395
+
396
+ def _run_single_reviewer(
397
+ config: ReviewerConfig,
398
+ adapter: ReviewerAdapter,
399
+ worktree_paths: dict[str, Path],
400
+ prompt: str,
401
+ timeout_seconds: float,
402
+ pricing: PricingConfig | None = None,
403
+ max_retries: int = retry.MAX_RETRIES,
404
+ capture_dir: Path | None = None,
405
+ on_child_spawn: Callable[[str, int], None] | None = None,
406
+ ) -> ReviewerRunResult:
407
+ """Run one reviewer end-to-end: build_invocation, run_subprocess,
408
+ parse_output. Capture either the parsed output or the exception
409
+ that fired.
410
+
411
+ Worktree lookup is per-reviewer-name — if the orchestrator forgot
412
+ to provision a worktree for this reviewer, that's a clear
413
+ ``ValueError`` recorded in this reviewer's run result. Other
414
+ reviewers proceed normally.
415
+
416
+ Timeout is special-cased: :func:`run_subprocess` raises
417
+ :class:`~syncade.process.SubprocessTimeoutError` *before* returning
418
+ a :class:`SubprocessResult`, but the exception carries the partial
419
+ stdout/stderr the reviewer produced before the SIGKILL. The
420
+ dedicated ``except`` clause synthesizes a :class:`SubprocessResult`
421
+ from it so persistence still sees the partial output.
422
+ """
423
+ run_start = time.monotonic()
424
+ worktree = worktree_paths.get(config.name)
425
+ if worktree is None:
426
+ return ReviewerRunResult(
427
+ reviewer_name=config.name,
428
+ provider=config.provider,
429
+ output=None,
430
+ error=ValueError(
431
+ f"dispatcher: no worktree_paths entry for reviewer "
432
+ f"{config.name!r} (provider={config.provider!r}). "
433
+ f"The caller must populate worktree_paths with one "
434
+ f"entry per ReviewerConfig.name before dispatching."
435
+ ),
436
+ duration_seconds=time.monotonic() - run_start,
437
+ model=config.model,
438
+ )
439
+ # Validate the reviewer name before constructing capture_prefix. Persistence
440
+ # enforces the same rule, but only AFTER the child has already run and streamed
441
+ # files. Catching an invalid name here prevents files from landing outside the
442
+ # round directory before the rejection fires.
443
+ # Deferred import: persistence.__init__ imports from dispatcher (for DispatchResult),
444
+ # so a top-level import here creates a circular dependency at module-load time. By
445
+ # the time this function is called, dispatcher is fully initialized, so the deferred
446
+ # import resolves cleanly.
447
+ if capture_dir is not None:
448
+ from syncade.persistence._validation import ( # noqa: PLC0415
449
+ _validate_reviewer_filename_basename,
450
+ )
451
+
452
+ try:
453
+ _validate_reviewer_filename_basename(config.name)
454
+ except ValueError as exc:
455
+ return ReviewerRunResult(
456
+ reviewer_name=config.name,
457
+ provider=config.provider,
458
+ output=None,
459
+ error=exc,
460
+ duration_seconds=time.monotonic() - run_start,
461
+ model=config.model,
462
+ )
463
+ # Track the subprocess result separately so we can attach it to
464
+ # the ReviewerRunResult on both success AND parse-failure paths.
465
+ # On launch-failure (binary missing, OS error) it stays None and
466
+ # the result records only the exception. A timeout is special-cased
467
+ # below: run_subprocess raises before assigning subprocess_result,
468
+ # but SubprocessTimeoutError carries the partial output, which the
469
+ # dedicated except clause synthesizes into a SubprocessResult.
470
+ subprocess_result: SubprocessResult | None = None
471
+ retries = 0
472
+ accumulated_usage: Usage | None = None
473
+ for attempt in range(1, max_retries + 2):
474
+ try:
475
+ invocation = adapter.build_invocation(config, worktree, prompt)
476
+ subprocess_result = run_subprocess(
477
+ invocation.argv,
478
+ cwd=invocation.cwd,
479
+ env=invocation.env,
480
+ timeout=timeout_seconds,
481
+ input_text=invocation.stdin_text,
482
+ # Tee'd to <round>/<name>.{stdout,stderr} — the same two files persistence
483
+ # writes afterwards — so a run killed mid-review still leaves what the
484
+ # reviewer had already said, minus at most the chunk in flight
485
+ # (PR-h-field-03).
486
+ capture_prefix=capture_dir / config.name if capture_dir is not None else None,
487
+ # The pid is knowable only here, and only now. Named per reviewer because a
488
+ # post-mortem needs to know WHICH child a surviving process was.
489
+ on_spawn=(
490
+ (lambda pid, name=config.name: on_child_spawn(name, pid))
491
+ if on_child_spawn is not None
492
+ else None
493
+ ),
494
+ )
495
+ attempt_usage = usage_for(
496
+ subprocess_result, config.provider, config.model, pricing, _auth_mode(config)
497
+ )
498
+ output = adapter.parse_output(subprocess_result)
499
+ return ReviewerRunResult(
500
+ reviewer_name=config.name,
501
+ provider=config.provider,
502
+ output=output,
503
+ error=None,
504
+ duration_seconds=time.monotonic() - run_start,
505
+ raw_subprocess_result=subprocess_result,
506
+ retries=retries,
507
+ usage=_add_usage(accumulated_usage, attempt_usage),
508
+ model=config.model,
509
+ )
510
+ except SubprocessTimeoutError as exc:
511
+ # run_subprocess raised before assigning subprocess_result, so
512
+ # the partial output the reviewer produced before the SIGKILL
513
+ # lives ONLY on the exception's .stdout / .stderr. Synthesize a
514
+ # SubprocessResult from it — sentinel returncode -1, since the
515
+ # process was killed and never exited cleanly — so persistence
516
+ # still writes the partial .stdout / .stderr files. The error
517
+ # field keeps the exception, so the orchestrator's decision
518
+ # table still sees SubprocessTimeoutError -> REVIEWER_FAILURE.
519
+ # (SubprocessNotFoundError, the other SubprocessError subclass,
520
+ # carries no partial output — the process never started — so it
521
+ # falls through to the generic handler with raw_result None.)
522
+ elapsed = time.monotonic() - run_start
523
+ partial = SubprocessResult(
524
+ returncode=-1,
525
+ stdout=exc.stdout,
526
+ stderr=exc.stderr,
527
+ duration_seconds=elapsed,
528
+ )
529
+ attempt_usage = usage_for(
530
+ partial, config.provider, config.model, pricing, _auth_mode(config)
531
+ )
532
+ return ReviewerRunResult(
533
+ reviewer_name=config.name,
534
+ provider=config.provider,
535
+ output=None,
536
+ error=exc,
537
+ duration_seconds=elapsed,
538
+ raw_subprocess_result=partial,
539
+ retries=retries,
540
+ usage=_add_usage(accumulated_usage, attempt_usage),
541
+ model=config.model,
542
+ )
543
+ except Exception as exc: # noqa: BLE001 — capture every failure shape
544
+ # Bounded retry (H5): a transient provider blip — a 429/5xx or a
545
+ # dropped socket wrapped in ReviewerInvocationError — gets up to
546
+ # max_retries extra fresh attempts with jittered backoff,
547
+ # rather than aborting the whole round at exit 40. Parse/contract
548
+ # failures, missing binaries, and timeouts are terminal and never
549
+ # retried (see syncade.retry.is_transient_api_error).
550
+ attempt_usage = usage_for(
551
+ subprocess_result, config.provider, config.model, pricing, _auth_mode(config)
552
+ )
553
+ if retry.is_transient_api_error(exc) and attempt <= max_retries:
554
+ accumulated_usage = _add_usage(accumulated_usage, attempt_usage)
555
+ retries += 1
556
+ retry.backoff_sleep(attempt)
557
+ subprocess_result = None
558
+ continue
559
+ return ReviewerRunResult(
560
+ reviewer_name=config.name,
561
+ provider=config.provider,
562
+ output=None,
563
+ error=exc,
564
+ duration_seconds=time.monotonic() - run_start,
565
+ raw_subprocess_result=subprocess_result,
566
+ retries=retries,
567
+ usage=_add_usage(accumulated_usage, attempt_usage),
568
+ model=config.model,
569
+ )
570
+
571
+ raise AssertionError("unreachable reviewer retry loop exit")