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