syncade 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. syncade-0.6.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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
+ )