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
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"""Cold-synth subprocess driver.
|
|
2
|
+
|
|
3
|
+
:func:`run_synthesizer` runs once per round after reviewer output parsing. It
|
|
4
|
+
owns cold-workspace provisioning, prompt rendering, codex invocation, output
|
|
5
|
+
extraction, and provenance validation.
|
|
6
|
+
|
|
7
|
+
See the package docstring for the cold-synth architectural invariants
|
|
8
|
+
(no worktree, active sandbox via trusted permissions, env scrub, no
|
|
9
|
+
verdict, cold inputs).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import shutil
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from syncade import retry
|
|
19
|
+
from syncade.adapters.base import ReviewerAdapter, ReviewerInvocationError
|
|
20
|
+
from syncade.adapters.registry import get_adapter
|
|
21
|
+
from syncade.config import ReviewerConfig
|
|
22
|
+
from syncade.config_cold import SynthesizerConfig
|
|
23
|
+
from syncade.dispatcher import ReviewerRunResult
|
|
24
|
+
from syncade.pricing_config import PricingConfig
|
|
25
|
+
from syncade.process import (
|
|
26
|
+
SubprocessError,
|
|
27
|
+
SubprocessNotFoundError,
|
|
28
|
+
SubprocessResult,
|
|
29
|
+
SubprocessTimeoutError,
|
|
30
|
+
run_subprocess,
|
|
31
|
+
)
|
|
32
|
+
from syncade.prompts import load_synthesizer_template, render_synthesizer_prompt
|
|
33
|
+
from syncade.synthesis import (
|
|
34
|
+
SynthesizerOutputError,
|
|
35
|
+
get_synthesizer_schema_string,
|
|
36
|
+
parse_synthesizer_output,
|
|
37
|
+
)
|
|
38
|
+
from syncade.usage import Usage, _add_usage, _auth_mode, usage_for
|
|
39
|
+
|
|
40
|
+
from .constants import SYNTHESIZER_NAME
|
|
41
|
+
from .rendering import render_reviewer_outputs_blob
|
|
42
|
+
from .result import SynthesizerResult
|
|
43
|
+
from .validation import (
|
|
44
|
+
_validate_cluster_quotes_against_reviewers,
|
|
45
|
+
_validate_duplicate_blockers_not_split_deactivated,
|
|
46
|
+
_validate_provenance_against_reviewers,
|
|
47
|
+
_validate_reviewer_blockers_passed_through,
|
|
48
|
+
)
|
|
49
|
+
from .workspace import _init_workspace_git, _scrub_env_for_cold_synth
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_synthesizer(
|
|
53
|
+
reviewer_results: list[ReviewerRunResult],
|
|
54
|
+
*,
|
|
55
|
+
repo_root: Path,
|
|
56
|
+
pr_doc_path: Path,
|
|
57
|
+
timeout_seconds: float,
|
|
58
|
+
master_plan_path: Path | None = None,
|
|
59
|
+
config: SynthesizerConfig | None = None,
|
|
60
|
+
adapter: ReviewerAdapter | None = None,
|
|
61
|
+
pricing: PricingConfig | None = None,
|
|
62
|
+
max_retries: int = retry.MAX_RETRIES,
|
|
63
|
+
) -> SynthesizerResult:
|
|
64
|
+
"""Run the cold synthesizer subprocess.
|
|
65
|
+
|
|
66
|
+
Lifecycle:
|
|
67
|
+
|
|
68
|
+
1. Build the synthesizer prompt: load the template via
|
|
69
|
+
:func:`syncade.prompts.load_synthesizer_template` (per-repo override at
|
|
70
|
+
``.syncade/templates/synthesizer.md`` wins), render via
|
|
71
|
+
:func:`syncade.prompts.render_synthesizer_prompt` with the
|
|
72
|
+
reviewer outputs serialized as the
|
|
73
|
+
``reviewer_outputs_json`` placeholder.
|
|
74
|
+
2. Build the invocation from ``config`` (the ``[synthesizer]`` block) routed
|
|
75
|
+
through ``adapter`` — which, when not injected, is resolved from the
|
|
76
|
+
ADAPTER REGISTRY by ``config.provider``, exactly like a reviewer's. The
|
|
77
|
+
``worktree_path`` arg is the isolated tempdir workspace, so the CLI's
|
|
78
|
+
cwd/add-dir flags both scope to that workspace rather than to
|
|
79
|
+
``repo_root``.
|
|
80
|
+
3. Run the subprocess via :func:`syncade.process.run_subprocess`. The
|
|
81
|
+
per-reviewer timeout is reused.
|
|
82
|
+
4. Extract the model's final text via
|
|
83
|
+
:meth:`~syncade.adapters.base.ReviewerAdapter.extract_final_text`, passing
|
|
84
|
+
:class:`SynthesizerOutputError` for empty-output diagnostics.
|
|
85
|
+
5. Parse the text via :func:`syncade.synthesis.parse_synthesizer_output` — the
|
|
86
|
+
shared extractor handles fenced JSON, JSON-in-prose, and JSX-shaped
|
|
87
|
+
snippets.
|
|
88
|
+
|
|
89
|
+
Returns a :class:`SynthesizerResult` regardless of outcome. The
|
|
90
|
+
caller (orchestrator) inspects it to decide the exit code:
|
|
91
|
+
|
|
92
|
+
- ``output is not None`` → mechanical verdict from
|
|
93
|
+
``consolidated_findings``.
|
|
94
|
+
- ``isinstance(error, SynthesizerOutputError)`` → exit 70
|
|
95
|
+
(parse failure).
|
|
96
|
+
- ``isinstance(error, (ReviewerInvocationError, SubprocessError))``
|
|
97
|
+
→ exit 40 (subprocess failure).
|
|
98
|
+
|
|
99
|
+
The synthesizer is NOT given a worktree. See module docstring for
|
|
100
|
+
why this is a deliberate architecture choice.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
reviewer_results: The dispatcher's per-reviewer results. Only
|
|
104
|
+
successful reviewers (with ``output is not None``)
|
|
105
|
+
contribute to the prompt; failures would not have reached
|
|
106
|
+
here under normal orchestrator flow.
|
|
107
|
+
repo_root: The git repo root. Used only for (a) per-repo
|
|
108
|
+
template-override lookup
|
|
109
|
+
via :func:`~syncade.prompts.load_synthesizer_template`,
|
|
110
|
+
which checks
|
|
111
|
+
``<repo_root>/.syncade/templates/synthesizer.md``, and
|
|
112
|
+
(b) the env-scrub substring check via
|
|
113
|
+
:func:`_scrub_env_for_cold_synth`. It is NOT passed to the codex
|
|
114
|
+
subprocess as ``cwd``, ``-C``, or
|
|
115
|
+
``--add-dir`` — those scope to the tempdir workspace.
|
|
116
|
+
The synth never reads files directly from ``repo_root``.
|
|
117
|
+
pr_doc_path: Absolute path to the PR doc. Substituted into the
|
|
118
|
+
prompt's ``{pr_doc_path}`` placeholder.
|
|
119
|
+
timeout_seconds: Per-process wall-clock timeout. Reuses the
|
|
120
|
+
same value as the reviewer dispatch (v1 doesn't split
|
|
121
|
+
timeouts per phase).
|
|
122
|
+
master_plan_path: Optional path to the master plan. Mirrors the
|
|
123
|
+
reviewer prompt's master-plan handling: ``None`` renders
|
|
124
|
+
as ``"(none)"`` in the prompt.
|
|
125
|
+
config: The ``[synthesizer]`` block. Defaults to
|
|
126
|
+
:class:`~syncade.config_cold.SynthesizerConfig` defaults, which
|
|
127
|
+
reproduce the pre-PR-v2-23 hardcoded constants exactly.
|
|
128
|
+
adapter: Optional :class:`~syncade.adapters.base.ReviewerAdapter`.
|
|
129
|
+
Defaults to ``get_adapter(config.provider)`` — the same registry the
|
|
130
|
+
dispatcher uses, which is what makes the judge provider-agnostic
|
|
131
|
+
instead of codex-only. Tests pass duck-typed fakes so the subprocess
|
|
132
|
+
can be short-circuited without a real CLI.
|
|
133
|
+
|
|
134
|
+
Returns: :class:`SynthesizerResult` carrying either the parsed output
|
|
135
|
+
or the captured exception, plus the raw subprocess result
|
|
136
|
+
(when available) for persistence.
|
|
137
|
+
"""
|
|
138
|
+
import time
|
|
139
|
+
|
|
140
|
+
run_start = time.monotonic()
|
|
141
|
+
# H5: count the extra synth subprocess attempts spent riding out transient
|
|
142
|
+
# provider blips and stamp it onto EVERY SynthesizerResult, so persistence
|
|
143
|
+
# surfaces the synth retry count next to the reviewer one. _SR aliases the
|
|
144
|
+
# dataclass; _result injects ``retries=`` so the many return sites below
|
|
145
|
+
# stay DRY (mirrors the dispatcher's per-result ``retries=``).
|
|
146
|
+
synth_retries = 0
|
|
147
|
+
synth_usage: Usage | None = None # set once the subprocess returns (finding #5)
|
|
148
|
+
_SR = SynthesizerResult
|
|
149
|
+
|
|
150
|
+
synth_cfg = config if config is not None else SynthesizerConfig()
|
|
151
|
+
|
|
152
|
+
def _result(**kwargs: object) -> SynthesizerResult:
|
|
153
|
+
# Default usage to the completed subprocess's, so failure returns keep it
|
|
154
|
+
# too — not only the success path (dogfood finding #5).
|
|
155
|
+
kwargs.setdefault("usage", synth_usage)
|
|
156
|
+
kwargs.setdefault("provider", synth_cfg.provider)
|
|
157
|
+
kwargs.setdefault("model", synth_cfg.model)
|
|
158
|
+
return _SR(retries=synth_retries, **kwargs)
|
|
159
|
+
|
|
160
|
+
# The registry — not a hardcoded class — is what lets an all-Anthropic user
|
|
161
|
+
# finish a run on a box with no codex installed.
|
|
162
|
+
adapter = adapter if adapter is not None else get_adapter(synth_cfg.provider)
|
|
163
|
+
|
|
164
|
+
# --- Cold workspace -----------------------------------------
|
|
165
|
+
# Provision an isolated tempdir workspace containing ONLY the PR
|
|
166
|
+
# doc (and the master plan if supplied). The synth subprocess
|
|
167
|
+
# runs from here; codex's -C / --add-dir flags scope to this
|
|
168
|
+
# workspace, NOT to the repo root.
|
|
169
|
+
#
|
|
170
|
+
# The workspace is also git-init'd up front (trusted-execute
|
|
171
|
+
# codex requires cwd to be a git working tree), and the env is
|
|
172
|
+
# scrubbed of repo_root-leaking values before launch. Combined
|
|
173
|
+
# with permissions=trusted-execute, the codex sandbox enforces the
|
|
174
|
+
# workspace boundary structurally instead of relying on the
|
|
175
|
+
# model honoring the prompt's "do not read the diff" instruction.
|
|
176
|
+
#
|
|
177
|
+
# The TemporaryDirectory is the with-block scope; the workspace
|
|
178
|
+
# disappears after we exit (even on early-return failure paths).
|
|
179
|
+
# The captured SubprocessResult contains strings (stdout/stderr),
|
|
180
|
+
# not paths, so persistence after the with-block exit is safe.
|
|
181
|
+
with tempfile.TemporaryDirectory(prefix="syncade-synth-") as workspace_str:
|
|
182
|
+
workspace = Path(workspace_str)
|
|
183
|
+
|
|
184
|
+
# git-init the workspace so trusted-execute codex accepts
|
|
185
|
+
# it as a working tree. Failures bucket as SubprocessError →
|
|
186
|
+
# exit 40 with a clear synthesizer.error.txt.
|
|
187
|
+
try:
|
|
188
|
+
_init_workspace_git(workspace)
|
|
189
|
+
except SubprocessError as exc:
|
|
190
|
+
return _result(
|
|
191
|
+
output=None,
|
|
192
|
+
error=exc,
|
|
193
|
+
duration_seconds=time.monotonic() - run_start,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Copy the PR doc into the workspace. Each input file
|
|
197
|
+
# lives in its OWN subdirectory so basename collisions
|
|
198
|
+
# between pr_doc and master_plan can't make one overwrite
|
|
199
|
+
# the other (e.g. ``acme/pr-7/spec.md`` and
|
|
200
|
+
# ``acme/master/spec.md`` both have basename ``spec.md``;
|
|
201
|
+
# without per-input subdirs, the second ``shutil.copy2`` would
|
|
202
|
+
# silently clobber the first and the prompt would reference
|
|
203
|
+
# the wrong content).
|
|
204
|
+
pr_doc_subdir = workspace / "pr-doc"
|
|
205
|
+
try:
|
|
206
|
+
pr_doc_subdir.mkdir()
|
|
207
|
+
workspace_pr_doc = pr_doc_subdir / pr_doc_path.name
|
|
208
|
+
shutil.copy2(pr_doc_path, workspace_pr_doc)
|
|
209
|
+
except (OSError, shutil.SameFileError) as exc:
|
|
210
|
+
# Copy failure (permissions, missing source) is a workspace
|
|
211
|
+
# setup problem, not a model problem. Surface as a generic
|
|
212
|
+
# SubprocessError-bucket failure so the operator gets
|
|
213
|
+
# exit 40 + a clear .error.txt.
|
|
214
|
+
return _result(
|
|
215
|
+
output=None,
|
|
216
|
+
error=SubprocessError(
|
|
217
|
+
f"synthesizer: failed to set up cold workspace at "
|
|
218
|
+
f"{workspace}: could not copy {pr_doc_path} — {exc}"
|
|
219
|
+
),
|
|
220
|
+
duration_seconds=time.monotonic() - run_start,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
workspace_master_plan: Path | None = None
|
|
224
|
+
if master_plan_path is not None:
|
|
225
|
+
master_plan_subdir = workspace / "master-plan"
|
|
226
|
+
try:
|
|
227
|
+
master_plan_subdir.mkdir()
|
|
228
|
+
workspace_master_plan = master_plan_subdir / master_plan_path.name
|
|
229
|
+
shutil.copy2(master_plan_path, workspace_master_plan)
|
|
230
|
+
except (OSError, shutil.SameFileError) as exc:
|
|
231
|
+
return _result(
|
|
232
|
+
output=None,
|
|
233
|
+
error=SubprocessError(
|
|
234
|
+
f"synthesizer: failed to set up cold workspace — "
|
|
235
|
+
f"could not copy master plan {master_plan_path}: {exc}"
|
|
236
|
+
),
|
|
237
|
+
duration_seconds=time.monotonic() - run_start,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
# --- Build the prompt -------------------------------------------
|
|
241
|
+
# The workspace input path the prompt references is the WORKSPACE-relative
|
|
242
|
+
# one (so a model that reads it gets the workspace copy, not the
|
|
243
|
+
# original in the repo).
|
|
244
|
+
try:
|
|
245
|
+
template = load_synthesizer_template(repo_root)
|
|
246
|
+
reviewer_outputs_blob = render_reviewer_outputs_blob(reviewer_results)
|
|
247
|
+
prompt = render_synthesizer_prompt(
|
|
248
|
+
template,
|
|
249
|
+
pr_doc_path=str(workspace_pr_doc),
|
|
250
|
+
reviewer_outputs_json=reviewer_outputs_blob,
|
|
251
|
+
master_plan_path=(
|
|
252
|
+
str(workspace_master_plan) if workspace_master_plan is not None else None
|
|
253
|
+
),
|
|
254
|
+
json_schema=get_synthesizer_schema_string(),
|
|
255
|
+
)
|
|
256
|
+
except (KeyError, ValueError) as exc:
|
|
257
|
+
return _result(
|
|
258
|
+
output=None,
|
|
259
|
+
error=SubprocessError(f"synthesizer template render failed: {exc}"),
|
|
260
|
+
duration_seconds=time.monotonic() - run_start,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# --- Build the invocation ---------------------------------------
|
|
264
|
+
# The judge's knobs, carried on a synthetic ReviewerConfig so we can reuse
|
|
265
|
+
# the adapter's build_invocation rather than open-coding each provider's
|
|
266
|
+
# flag plumbing. The "worktree_path" arg is the WORKSPACE, not repo_root —
|
|
267
|
+
# that's the cold-isolation invariant: the CLI's cwd/add-dir flags scope to
|
|
268
|
+
# the workspace.
|
|
269
|
+
synth_config = ReviewerConfig(
|
|
270
|
+
name=SYNTHESIZER_NAME,
|
|
271
|
+
provider=synth_cfg.provider,
|
|
272
|
+
model=synth_cfg.model,
|
|
273
|
+
thinking=synth_cfg.thinking,
|
|
274
|
+
permissions=synth_cfg.permissions,
|
|
275
|
+
# PR-v2-24: carry the auth declaration onto the synthetic config the
|
|
276
|
+
# adapter sees. Without this the cold actors would silently run
|
|
277
|
+
# unenforced -- the classic 'guarded four of five actors' leak.
|
|
278
|
+
auth=synth_cfg.auth,
|
|
279
|
+
api_key_env=synth_cfg.api_key_env,
|
|
280
|
+
)
|
|
281
|
+
try:
|
|
282
|
+
invocation = adapter.build_invocation(synth_config, workspace, prompt)
|
|
283
|
+
except ValueError as exc:
|
|
284
|
+
# narrowed from `except Exception`. The only
|
|
285
|
+
# exception class build_invocation legitimately raises is
|
|
286
|
+
# ValueError (every adapter's provider/permissions validation
|
|
287
|
+
# raises ValueError on bad input — e.g. a `safe` judge, which
|
|
288
|
+
# would prompt and hang). Programming bugs (TypeError, AttributeError,
|
|
289
|
+
# KeyError, ImportError, etc.) SHOULD crash the
|
|
290
|
+
# orchestrator visibly rather than getting bucketed as a
|
|
291
|
+
# normal exit-40 subprocess failure with a misleading
|
|
292
|
+
# `synthesizer.error.txt` — those bugs need a stack
|
|
293
|
+
# trace, not a polite failure mode.
|
|
294
|
+
return _result(
|
|
295
|
+
output=None,
|
|
296
|
+
error=exc,
|
|
297
|
+
duration_seconds=time.monotonic() - run_start,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
# --- Run the subprocess + extract (with bounded transient retry) -
|
|
301
|
+
# cwd is the WORKSPACE — not repo_root. Codex defaults relative
|
|
302
|
+
# file access to here. Trusted permissions keep the codex
|
|
303
|
+
# sandbox active and scoped to this workspace.
|
|
304
|
+
#
|
|
305
|
+
# Bounded retry (H5): a transient provider blip surfaced by
|
|
306
|
+
# extract_final_text as a ReviewerInvocationError
|
|
307
|
+
# (a 429/5xx or a dropped socket) re-runs the codex subprocess up
|
|
308
|
+
# to max_retries extra times with jittered backoff, instead
|
|
309
|
+
# of aborting the round at exit 40. Re-running the subprocess (not
|
|
310
|
+
# just re-parsing the same stdout) is required: the transient
|
|
311
|
+
# failure lives in the subprocess output, so a fresh invocation is
|
|
312
|
+
# the only way to get a clean turn. Timeouts, missing-binary, and
|
|
313
|
+
# parse/contract failures are terminal and never retried (see
|
|
314
|
+
# syncade.retry).
|
|
315
|
+
subprocess_result: SubprocessResult | None = None
|
|
316
|
+
final_text: str | None = None
|
|
317
|
+
for attempt in range(1, max_retries + 2):
|
|
318
|
+
subprocess_result = None
|
|
319
|
+
try:
|
|
320
|
+
subprocess_result = run_subprocess(
|
|
321
|
+
invocation.argv,
|
|
322
|
+
cwd=workspace,
|
|
323
|
+
# scrub repo-root-leaking vars (PWD,
|
|
324
|
+
# OLDPWD, repo-local path-list segments, and scalar
|
|
325
|
+
# repo-root path references) from the env before
|
|
326
|
+
# launch. Combined with
|
|
327
|
+
# workspace-scoped cwd/-C/--add-dir AND trusted
|
|
328
|
+
# permissions (active codex sandbox), this enforces
|
|
329
|
+
# the cold-isolation invariant: the synth subprocess
|
|
330
|
+
# cannot trivially discover or read repo_root.
|
|
331
|
+
# Pass the scrubbed env directly; do not fall back to
|
|
332
|
+
# os.environ.
|
|
333
|
+
env=_scrub_env_for_cold_synth(invocation.env, repo_root),
|
|
334
|
+
timeout=timeout_seconds,
|
|
335
|
+
input_text=invocation.stdin_text,
|
|
336
|
+
)
|
|
337
|
+
except SubprocessTimeoutError as exc:
|
|
338
|
+
# Same partial-output preservation pattern as
|
|
339
|
+
# syncade.dispatcher._run_single_reviewer: synthesize a
|
|
340
|
+
# SubprocessResult from the timeout exception's partial
|
|
341
|
+
# stdout/stderr so persistence still writes the .stdout /
|
|
342
|
+
# stderr files. Sentinel returncode=-1 marks "killed
|
|
343
|
+
# before exit". A timeout is the budget, not a blip — terminal.
|
|
344
|
+
elapsed = time.monotonic() - run_start
|
|
345
|
+
partial = SubprocessResult(
|
|
346
|
+
returncode=-1,
|
|
347
|
+
stdout=exc.stdout,
|
|
348
|
+
stderr=exc.stderr,
|
|
349
|
+
duration_seconds=elapsed,
|
|
350
|
+
)
|
|
351
|
+
synth_usage = _add_usage(
|
|
352
|
+
synth_usage,
|
|
353
|
+
usage_for(
|
|
354
|
+
partial, synth_cfg.provider, synth_cfg.model, pricing, _auth_mode(synth_cfg)
|
|
355
|
+
),
|
|
356
|
+
)
|
|
357
|
+
return _result(
|
|
358
|
+
output=None,
|
|
359
|
+
error=exc,
|
|
360
|
+
duration_seconds=elapsed,
|
|
361
|
+
raw_subprocess_result=partial,
|
|
362
|
+
)
|
|
363
|
+
except SubprocessNotFoundError as exc:
|
|
364
|
+
# Codex binary missing — the subprocess never started, so
|
|
365
|
+
# there's no partial output to preserve. Persistence writes
|
|
366
|
+
# empty .stdout/.stderr and the .error.txt.
|
|
367
|
+
return _result(
|
|
368
|
+
output=None,
|
|
369
|
+
error=exc,
|
|
370
|
+
duration_seconds=time.monotonic() - run_start,
|
|
371
|
+
)
|
|
372
|
+
except SubprocessError as exc:
|
|
373
|
+
# Other launch failures (bad cwd, OS error). Same shape as
|
|
374
|
+
# SubprocessNotFoundError — no subprocess output to preserve.
|
|
375
|
+
return _result(
|
|
376
|
+
output=None,
|
|
377
|
+
error=exc,
|
|
378
|
+
duration_seconds=time.monotonic() - run_start,
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# The subprocess completed — extract usage now so a later extraction /
|
|
382
|
+
# parse / validation failure still records it (dogfood finding #5).
|
|
383
|
+
synth_usage = _add_usage(
|
|
384
|
+
synth_usage,
|
|
385
|
+
usage_for(
|
|
386
|
+
subprocess_result,
|
|
387
|
+
synth_cfg.provider,
|
|
388
|
+
synth_cfg.model,
|
|
389
|
+
pricing,
|
|
390
|
+
_auth_mode(synth_cfg),
|
|
391
|
+
),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
# --- Extract the final agent message ------------------------
|
|
395
|
+
# Pull the final agent_message text out of codex's JSONL
|
|
396
|
+
# stream, then hand it to parse_synthesizer_output. The extract
|
|
397
|
+
# helper raises ReviewerInvocationError on subprocess-side
|
|
398
|
+
# failures (turn.failed, auth, non-zero rc) and raises
|
|
399
|
+
# SynthesizerOutputError if no agent_message is present
|
|
400
|
+
# (caller-configurable via the
|
|
401
|
+
# empty_output_exception_class kwarg).
|
|
402
|
+
try:
|
|
403
|
+
final_text = adapter.extract_final_text(
|
|
404
|
+
subprocess_result,
|
|
405
|
+
empty_output_exception_class=SynthesizerOutputError,
|
|
406
|
+
)
|
|
407
|
+
except ReviewerInvocationError as exc:
|
|
408
|
+
# Subprocess-side provider failure. A transient blip
|
|
409
|
+
# (429/5xx/dropped socket) earns another fresh subprocess
|
|
410
|
+
# attempt; every other provider verdict is terminal.
|
|
411
|
+
# Preserve the raw subprocess result on the terminal path so
|
|
412
|
+
# persistence writes the .stdout / .stderr files — the user
|
|
413
|
+
# inspecting exit 40 can read the raw codex output.
|
|
414
|
+
if retry.is_transient_api_error(exc) and attempt <= max_retries:
|
|
415
|
+
synth_retries += 1
|
|
416
|
+
retry.backoff_sleep(attempt)
|
|
417
|
+
continue
|
|
418
|
+
return _result(
|
|
419
|
+
output=None,
|
|
420
|
+
error=exc,
|
|
421
|
+
duration_seconds=time.monotonic() - run_start,
|
|
422
|
+
raw_subprocess_result=subprocess_result,
|
|
423
|
+
)
|
|
424
|
+
except SynthesizerOutputError as exc:
|
|
425
|
+
# No agent_message present — a contract failure, not a blip.
|
|
426
|
+
# Preserve the raw subprocess result so persistence
|
|
427
|
+
# writes the .stdout / .stderr files — the user
|
|
428
|
+
# inspecting exit 70 can read the raw codex output
|
|
429
|
+
# even when the typed extraction failed.
|
|
430
|
+
return _result(
|
|
431
|
+
output=None,
|
|
432
|
+
error=exc,
|
|
433
|
+
duration_seconds=time.monotonic() - run_start,
|
|
434
|
+
raw_subprocess_result=subprocess_result,
|
|
435
|
+
)
|
|
436
|
+
except Exception as exc: # noqa: BLE001 — preserve subprocess failure contract
|
|
437
|
+
# Unexpected exception classes from the extractor (e.g.
|
|
438
|
+
# AttributeError / IndexError / ValueError raised against a bizarre
|
|
439
|
+
# JSONL shape from the codex subprocess) must become a persisted
|
|
440
|
+
# subprocess failure rather than crashing run_review and leaving no
|
|
441
|
+
# manifest.json, summary.md,
|
|
442
|
+
# or synthesizer.error.txt — the operator had nothing to
|
|
443
|
+
# diagnose with.
|
|
444
|
+
#
|
|
445
|
+
# The extractor processes stdout from a foreign process
|
|
446
|
+
# (codex), so unexpected exception shapes are more
|
|
447
|
+
# plausibly triggered by model-output variation than by
|
|
448
|
+
# programming bugs in our own code. Map to a polite
|
|
449
|
+
# SynthesizerResult so persistence writes every artifact
|
|
450
|
+
# the operator needs. _compute_exit_code's defensive
|
|
451
|
+
# branch maps this to exit 40 (REVIEWER_FAILURE) — same
|
|
452
|
+
# bucket as a codex subprocess failure but with the
|
|
453
|
+
# original exception class preserved in synthesizer.error.txt
|
|
454
|
+
# so the operator can route by failure shape.
|
|
455
|
+
#
|
|
456
|
+
# Compare with the build_invocation catch: build_invocation
|
|
457
|
+
# receives only OUR input, so unexpected
|
|
458
|
+
# exceptions there ARE programming bugs and we re-raise.
|
|
459
|
+
# Different threat models, different catch widths.
|
|
460
|
+
return _result(
|
|
461
|
+
output=None,
|
|
462
|
+
error=exc,
|
|
463
|
+
duration_seconds=time.monotonic() - run_start,
|
|
464
|
+
raw_subprocess_result=subprocess_result,
|
|
465
|
+
)
|
|
466
|
+
break
|
|
467
|
+
|
|
468
|
+
# The loop breaks with final_text set on success; every failure path
|
|
469
|
+
# returns, and the transient branch is the only `continue` (it falls
|
|
470
|
+
# through to a terminal return once attempts are exhausted). So this
|
|
471
|
+
# guard is unreachable — it narrows the type and mirrors the
|
|
472
|
+
# dispatcher's unreachable-exit assertion.
|
|
473
|
+
if final_text is None:
|
|
474
|
+
raise AssertionError("unreachable synthesizer retry loop exit")
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
output = parse_synthesizer_output(final_text)
|
|
478
|
+
except SynthesizerOutputError as exc:
|
|
479
|
+
# The text was present but didn't parse — e.g. the model
|
|
480
|
+
# invented a finding (empty provenance) or tried to dismiss
|
|
481
|
+
# a unanimous blocker. Preserve the raw subprocess result
|
|
482
|
+
# so persistence has the full context.
|
|
483
|
+
return _result(
|
|
484
|
+
output=None,
|
|
485
|
+
error=exc,
|
|
486
|
+
duration_seconds=time.monotonic() - run_start,
|
|
487
|
+
raw_subprocess_result=subprocess_result,
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
# cross-input provenance validation. The schema's
|
|
491
|
+
# `min_length=1` on provenance prevents the most obvious
|
|
492
|
+
# "invented finding" case (zero provenance entries) but does
|
|
493
|
+
# NOT catch the model fabricating a provenance entry with a
|
|
494
|
+
# wrong reviewer_name OR an out-of-range original_index — both
|
|
495
|
+
# would render into findings.md as if the synth had real
|
|
496
|
+
# attribution. The schema can't check this because it has no
|
|
497
|
+
# awareness of the input reviewer set; the orchestrator does,
|
|
498
|
+
# so the check lives here.
|
|
499
|
+
try:
|
|
500
|
+
_prov_repairs = _validate_provenance_against_reviewers(output, reviewer_results)
|
|
501
|
+
# cluster quotes must be verbatim substrings of the
|
|
502
|
+
# reviewer-original finding text — the cannot-invent guarantee for
|
|
503
|
+
# the descriptive-only root-cause clusters. Runs after provenance
|
|
504
|
+
# validation so each member's provenance is already known-valid.
|
|
505
|
+
_validate_cluster_quotes_against_reviewers(output, reviewer_results)
|
|
506
|
+
# Finding R: every reviewer-surfaced blocker must pass through to
|
|
507
|
+
# consolidated_findings (active or dismissed-with-rationale) — the
|
|
508
|
+
# cannot-omit partner of the cannot-invent provenance check. An
|
|
509
|
+
# omitted blocker would falsely SHIP (the verdict reads only
|
|
510
|
+
# consolidated_findings). Runs last so the referenced provenance
|
|
511
|
+
# pairs are already known-valid.
|
|
512
|
+
_validate_reviewer_blockers_passed_through(output, reviewer_results)
|
|
513
|
+
# Defense-in-depth for the mechanically provable split-evasion
|
|
514
|
+
# case: exact duplicate source blocker text from distinct reviewers
|
|
515
|
+
# must not be split into separate downgraded/dismissed findings.
|
|
516
|
+
_validate_duplicate_blockers_not_split_deactivated(output, reviewer_results)
|
|
517
|
+
except SynthesizerOutputError as exc:
|
|
518
|
+
return _result(
|
|
519
|
+
output=None,
|
|
520
|
+
error=exc,
|
|
521
|
+
duration_seconds=time.monotonic() - run_start,
|
|
522
|
+
raw_subprocess_result=subprocess_result,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
return _result(
|
|
526
|
+
output=output,
|
|
527
|
+
error=None,
|
|
528
|
+
duration_seconds=time.monotonic() - run_start,
|
|
529
|
+
raw_subprocess_result=subprocess_result,
|
|
530
|
+
provenance_repairs=tuple(_prov_repairs),
|
|
531
|
+
)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Reviewer-output blob rendering for the synthesizer prompt.
|
|
2
|
+
|
|
3
|
+
The synthesizer's prompt has a single ``reviewer_outputs_json``
|
|
4
|
+
placeholder. This module owns the serialization of the dispatcher's
|
|
5
|
+
per-reviewer results into the labeled-blob form the prompt expects.
|
|
6
|
+
|
|
7
|
+
Cold-input invariant (see package docstring): only structured
|
|
8
|
+
:class:`~syncade.findings.ReviewerOutput` payloads enter the blob —
|
|
9
|
+
no producer narrative, no test output, no raw stdout prose. The
|
|
10
|
+
helper is the single place to enforce that.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from syncade.dispatcher import ReviewerRunResult
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_reviewer_outputs_blob(reviewer_results: list[ReviewerRunResult]) -> str:
|
|
19
|
+
"""Serialize each reviewer's :class:`ReviewerOutput` into the
|
|
20
|
+
labeled-blob form the synthesizer prompt expects.
|
|
21
|
+
|
|
22
|
+
Format: each entry is the reviewer's ``name`` on its own line,
|
|
23
|
+
followed by the pydantic ``model_dump_json(indent=2)`` of its
|
|
24
|
+
output. Entries are separated by a blank line so the model can
|
|
25
|
+
visually parse the boundary::
|
|
26
|
+
|
|
27
|
+
claude-reviewer:
|
|
28
|
+
{
|
|
29
|
+
"verdict": "NO-SHIP",
|
|
30
|
+
...
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
codex-reviewer:
|
|
34
|
+
{
|
|
35
|
+
"verdict": "SHIP",
|
|
36
|
+
...
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Only successful reviewers (those with ``output is not None``) are
|
|
40
|
+
included; the caller should ensure all reviewers succeeded before
|
|
41
|
+
invoking the synthesizer (the orchestrator enforces this), but the
|
|
42
|
+
filter is defensive — a failed reviewer with no output cannot
|
|
43
|
+
contribute consolidation surface.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
reviewer_results: The dispatcher's per-reviewer results. Order
|
|
47
|
+
is preserved in the rendered blob so the synthesizer sees
|
|
48
|
+
the same reviewer ordering the operator sees in
|
|
49
|
+
``summary.md``.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
A single string ready for the prompt's
|
|
53
|
+
``reviewer_outputs_json`` placeholder. Empty string if no
|
|
54
|
+
reviewer had output — in that case the orchestrator would not
|
|
55
|
+
be calling this function, but the empty fallback keeps the
|
|
56
|
+
helper safe to call.
|
|
57
|
+
"""
|
|
58
|
+
sections: list[str] = []
|
|
59
|
+
for r in reviewer_results:
|
|
60
|
+
if r.output is None:
|
|
61
|
+
continue
|
|
62
|
+
sections.append(f"{r.reviewer_name}:\n{r.output.model_dump_json(indent=2)}")
|
|
63
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""SynthesizerResult — outcome of one synthesizer subprocess run.
|
|
2
|
+
|
|
3
|
+
Mirrors :class:`~syncade.dispatcher.ReviewerRunResult`'s shape so
|
|
4
|
+
persistence and the exit-code decision table can treat reviewer
|
|
5
|
+
outcomes and synthesizer outcomes with the same vocabulary.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
from syncade.process import SubprocessResult
|
|
13
|
+
from syncade.synthesis import SynthesizerOutput
|
|
14
|
+
from syncade.usage import Usage
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class SynthesizerResult:
|
|
19
|
+
"""Outcome of one synthesizer subprocess run.
|
|
20
|
+
|
|
21
|
+
Mirrors :class:`~syncade.dispatcher.ReviewerRunResult`'s shape so
|
|
22
|
+
persistence and the exit-code decision table can treat reviewer
|
|
23
|
+
outcomes and synthesizer outcomes with the same vocabulary.
|
|
24
|
+
|
|
25
|
+
Attributes:
|
|
26
|
+
output: The parsed :class:`SynthesizerOutput` on success;
|
|
27
|
+
``None`` on failure.
|
|
28
|
+
error: The exception that fired on failure
|
|
29
|
+
(:class:`SynthesizerOutputError`,
|
|
30
|
+
:class:`ReviewerInvocationError`, a
|
|
31
|
+
:class:`~syncade.process.SubprocessError` subclass, or any
|
|
32
|
+
unexpected exception). ``None`` on success.
|
|
33
|
+
duration_seconds: Wall-clock duration of the synthesizer
|
|
34
|
+
subprocess. ``0.0`` for failures that happened before any
|
|
35
|
+
subprocess ran (none today, but kept for symmetry with
|
|
36
|
+
:class:`ReviewerRunResult`).
|
|
37
|
+
raw_subprocess_result: The :class:`SubprocessResult` from the
|
|
38
|
+
codex subprocess, preserved so persistence can write
|
|
39
|
+
``synthesizer.stdout`` / ``synthesizer.stderr`` even on
|
|
40
|
+
timeouts and parse failures. ``None`` only when the
|
|
41
|
+
subprocess never produced output (binary missing — a
|
|
42
|
+
``SubprocessNotFoundError`` from ``run_subprocess``). On
|
|
43
|
+
timeout this is NOT ``None``: synthesized from
|
|
44
|
+
:class:`SubprocessTimeoutError`'s partial stdout/stderr
|
|
45
|
+
with sentinel ``returncode=-1``, same convention as
|
|
46
|
+
:class:`ReviewerRunResult`.
|
|
47
|
+
provenance_repairs: Provenance quotations corrected from the reviewer's
|
|
48
|
+
own text (PR-h-field-01 item 5). Empty on the normal path. Non-empty means
|
|
49
|
+
the synthesizer miscopied a source it had correctly attributed; the
|
|
50
|
+
rendered text is the reviewer's, and this records that it happened.
|
|
51
|
+
retries: Number of EXTRA synth subprocess attempts consumed
|
|
52
|
+
riding out transient provider blips (429/5xx/dropped
|
|
53
|
+
socket) before this outcome. ``0`` when the first attempt
|
|
54
|
+
settled it. Mirrors :attr:`ReviewerRunResult.retries` so
|
|
55
|
+
persistence can sum one round-level ``retried`` count.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
output: SynthesizerOutput | None
|
|
59
|
+
error: Exception | None
|
|
60
|
+
duration_seconds: float
|
|
61
|
+
raw_subprocess_result: SubprocessResult | None = field(default=None)
|
|
62
|
+
retries: int = 0
|
|
63
|
+
usage: Usage | None = field(default=None)
|
|
64
|
+
provider: str | None = None
|
|
65
|
+
provenance_repairs: tuple[object, ...] = ()
|
|
66
|
+
model: str | None = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
"""Enforce the success/failure contract before persistence sees it."""
|
|
70
|
+
if (self.output is None) == (self.error is None):
|
|
71
|
+
raise ValueError(
|
|
72
|
+
"SynthesizerResult requires exactly one of output or error to be non-None"
|
|
73
|
+
)
|