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,428 @@
1
+ """Next-steps + empty-series text tables and small render helpers for
2
+ loop-summary.md.
3
+
4
+ Holds the per-termination-reason next-steps guidance, the empty-commit-series
5
+ notes, and the four small render helpers (`_round_verdict_label`,
6
+ `_producer_commit_subject`, `_empty_commit_series_note`,
7
+ `_round_duration_seconds`). ``loop_summary.py`` keeps the headline labels and
8
+ ``persist_loop_summary`` and imports these helpers.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ _LOOP_NEXT_STEPS: dict[str, str] = {
16
+ "ship": (
17
+ "- Loop terminated SHIP — the operator's branch has been "
18
+ "advanced to the latest producer commit (if any). Inspect "
19
+ "`loop-summary.md`'s commit series above to see what landed "
20
+ "on your branch; the operator's branch is at the SHIP "
21
+ "round's snapshot SHA. `findings.md` in the SHIP round's "
22
+ "directory carries the consolidated review (zero "
23
+ "blockers); the operator's repo is ready to push."
24
+ ),
25
+ "no_changes_to_review": (
26
+ "- The diff resolved to empty before any reviewer was dispatched: "
27
+ "the base ref resolved but no reviewable changes were found "
28
+ "(either no files changed, or every changed file was a repo-context "
29
+ "file stripped from the reviewer diff). No model work was spent. "
30
+ "If you expected changes to be reviewed, check that the correct "
31
+ "`--base` / `--scope` is specified and that the changed files are "
32
+ "not all listed in `strip_repo_context_files`."
33
+ ),
34
+ "producer_emptied_diff": (
35
+ "- The producer's commits in a prior round removed all reviewable "
36
+ "changes: the diff from the original base to the current HEAD is now "
37
+ "empty (all sections were either legitimate repo-context files or the "
38
+ "producer reverted the reviewable changes). Prior rounds DID spend "
39
+ "model work. If this is unexpected, inspect the per-round commit series "
40
+ "in `loop-summary.md` and the producer's commits on your branch."
41
+ ),
42
+ "findings_present": (
43
+ "- The single-pass run found remaining work. Read the round's "
44
+ "`findings.md` for the active blockers or failed checks, address them, "
45
+ "and re-run syncade when ready."
46
+ ),
47
+ "max_rounds_reached": (
48
+ "- The loop exhausted ``max_rounds`` (the configured cap) "
49
+ "without converging. Read the FINAL round's `findings.md` "
50
+ "for the remaining active blockers — they may require a "
51
+ "human eye to resolve. Consider: (a) bumping ``max_rounds`` "
52
+ "in `.syncade/config.toml`, (b) addressing the findings "
53
+ "manually and re-running, or (c) inspecting the per-round "
54
+ "directories to see what the producer attempted at each "
55
+ "step. The producer's commits ARE on your branch — if you "
56
+ "want to roll them back, use `git reset --hard "
57
+ "<round-0-starting-sha>` (see the commit series above)."
58
+ ),
59
+ "provider_usage_limit": (
60
+ "- The loop stopped because the PROVIDER refused on an exhausted usage limit — not "
61
+ "your configured budget, and not a fault in the code under review. It stopped at a "
62
+ "phase boundary, so completed rounds and their artifacts are intact. Retrying "
63
+ "immediately would fail the same way: the window has not moved. Next: consume a reset "
64
+ "(`codex` → `/usage`) or wait for the window, then `syncade --resume <run-id>` to "
65
+ "continue from the completed rounds rather than paying for them twice. Any producer "
66
+ "commits so far ARE on your branch."
67
+ ),
68
+ "budget_exceeded": (
69
+ "- The loop stopped because the running token or cost tally crossed your "
70
+ "configured ``[loop]`` budget (``--budget-tokens`` / ``--budget-usd``). "
71
+ "It aborted at a phase boundary, so NO provider call was interrupted "
72
+ "and the crossing round's `findings.md` / artifacts are complete. See "
73
+ "the **Budget** section above for the API-EQUIVALENT tally — a "
74
+ "VALUATION of the work, not billed money (PR-v2-24), and a LOWER BOUND "
75
+ "if any actor's cost was unpriced. Next: raise the budget in "
76
+ "`.syncade/config.toml` (or via the flag) and re-run, or address the "
77
+ "round's findings manually. Any producer commits so far ARE on your "
78
+ "branch."
79
+ ),
80
+ "producer_stalled": (
81
+ "- The producer subprocess exited cleanly but didn't commit "
82
+ "— either it made no edits, or it made edits without "
83
+ "committing. Read the producer's `producer.stdout` in the "
84
+ "round directory to see what the producer was trying to "
85
+ "do; common causes are under-specified findings ('this is "
86
+ "wrong but the spec doesn't say what's right') or the "
87
+ "producer concluding the existing code is correct. The "
88
+ "operator's next step is to clarify the spec / address the "
89
+ "finding manually and re-run."
90
+ ),
91
+ "producer_subprocess_error": (
92
+ "- The producer subprocess failed before it could attempt "
93
+ "the fix. Read `producer.stderr` and `producer.error.txt` "
94
+ "in the round directory for the failure shape; common "
95
+ "causes are auth (run `claude login` / `codex login`), "
96
+ "network errors (retry), or a missing CLI binary. The "
97
+ "operator's branch was NOT advanced for this round."
98
+ ),
99
+ "reviewer_failure": (
100
+ "- A reviewer subprocess failed before the loop could even "
101
+ "compute a verdict for the round. Read the per-reviewer "
102
+ "`.error.txt` files in the round directory; the failure "
103
+ "shape (timeout / auth / binary missing) determines the "
104
+ "fix. The loop did not advance any branch — the operator's "
105
+ "tree is unchanged from where it started."
106
+ ),
107
+ "synth_failure": (
108
+ "- The synthesizer subprocess failed. Read "
109
+ "`synthesizer.error.txt` and `synthesizer.stderr` in the "
110
+ "round directory. The loop did not advance any branch."
111
+ ),
112
+ "test_subprocess_error": (
113
+ "- The test re-run subprocess failed (binary missing, "
114
+ "timeout, OS launch error). Read `test-run.stderr` and the "
115
+ "manifest's `test_run.error_type` in the round directory. "
116
+ "The synth was clean for this round; the test failure is "
117
+ "environmental, not code-side."
118
+ ),
119
+ "check_subprocess_error": (
120
+ "- A blocking mechanical check's subprocess could not run to "
121
+ "completion (every reviewer succeeded; the synthesizer "
122
+ "succeeded). The `## Mechanical checks` section of the round's "
123
+ "`findings.md` names which check; read that check's "
124
+ "`<name>.check.stderr` and the manifest's `checks[].error_type` "
125
+ "in the round directory. Common shapes are a missing binary "
126
+ "(the check `command` references a tool not on PATH in the "
127
+ "check worktree) or a timeout. The loop did not advance any "
128
+ "branch — the check signal is indeterminate until the "
129
+ "environment problem is fixed."
130
+ ),
131
+ "decision_needed": (
132
+ "- A NO-SHIP round's producer ESCALATED a finding it determined "
133
+ "is an operator decision (a spec/design conflict), not a code "
134
+ "defect it can fix. The loop checkpointed and terminated (exit "
135
+ "10). Read `decision-needed.md` at the run root for the "
136
+ "producer's case + concrete options; record your decision in "
137
+ "`decision.txt` and run `syncade --resume <run-id>` to continue "
138
+ "(the escalated round re-runs with your decision fed to the "
139
+ "producer). The mechanical verdict is unchanged — the finding "
140
+ "stays open until the decision is applied."
141
+ ),
142
+ "blockers_all_deactivated": (
143
+ "- Two or more independent reviewers EACH raised a blocker, and the "
144
+ "synthesizer deactivated every one of them (dismissed, downgraded, or "
145
+ "split into separate single-reviewer findings). That may be correct, "
146
+ "but discarding all independent corroboration is not a call the "
147
+ "mechanical verdict will make silently, so the loop terminated at exit "
148
+ "10 instead of reporting a SHIP it cannot justify. No producer ran. "
149
+ "Read `decision-needed.md` at the run root: it quotes what each "
150
+ "reviewer actually said next to what the synthesizer did with it. If "
151
+ "the synthesizer was right, this round is effectively a SHIP; if it "
152
+ "was wrong about any one of them, that concern is real and unfixed. "
153
+ "There is nothing to resume — no blocker is active for a producer to "
154
+ "fix, so `decision.txt` and `--resume` do not apply here."
155
+ ),
156
+ "worktree_error": (
157
+ "- A worktree could not be provisioned. Common cause: "
158
+ "stale `<worktree_base>/<run-id>/` from a prior interrupted "
159
+ "run. The loop did not advance any branch."
160
+ ),
161
+ "diff_too_large": (
162
+ "- The reviewer-facing diff exceeded `[loop] max_diff_bytes`, so syncade refused "
163
+ "before dispatching any reviewer — nothing was spent. The measured size and the "
164
+ "ceiling are in the refusing round's `diff-refused.txt`. Syncade refuses rather "
165
+ "than truncating: a verdict on a deliberately partial diff is a verdict on the "
166
+ "wrong code. Narrow `--base` to a smaller range, split the PR, or raise "
167
+ "`[loop] max_diff_bytes` in `.syncade/config.toml`."
168
+ ),
169
+ "prompt_too_large": (
170
+ "- An assembled reviewer prompt exceeded the provider's character ceiling "
171
+ "(1,048,576 chars for codex), so syncade refused before dispatching any reviewer "
172
+ "— nothing was spent. The affected reviewer and the measured size are in the "
173
+ "refusing round's `diff-refused.txt`. The assembled prompt includes the diff, "
174
+ "the reviewer template, and any prior-round context. Reduce prompt size by "
175
+ "narrowing `--base`, trimming the reviewer template, or lowering "
176
+ "`[loop] max_diff_bytes` in `.syncade/config.toml`."
177
+ ),
178
+ "diff_malformed": (
179
+ "- The reviewer-facing diff had section(s) with unidentifiable "
180
+ "headers (unparseable, malformed C-quoted escape, or invalid UTF-8). "
181
+ "The dropped headers are in the refusing round's `diff-refused.txt` and "
182
+ "in its `manifest.json` under `diff_filter_refusal_headers`. "
183
+ "No model cost was incurred."
184
+ ),
185
+ "parse_failure": (
186
+ "- A reviewer or synthesizer ran cleanly but its output "
187
+ "didn't parse. The raw subprocess output is preserved at "
188
+ "the round's `.stdout` / `.error.txt` files; the verdict "
189
+ "may still be readable by hand."
190
+ ),
191
+ "config_error": (
192
+ "- The configuration is invalid. Read the per-reviewer "
193
+ "`.error.txt` files; common causes are unknown provider "
194
+ "names in `[[reviewers]]` blocks."
195
+ ),
196
+ }
197
+ """Per-:data:`syncade.orchestrator.TerminationReason` next-steps
198
+ guidance for loop-summary.md. Mirrors the per-exit-code
199
+ :data:`_NEXT_STEPS` table but keyed on the loop-level termination
200
+ reason rather than per-round exit code."""
201
+
202
+
203
+ def _round_verdict_label(round_result) -> str:
204
+ """Human-readable verdict label for one round."""
205
+ if getattr(round_result, "no_changes_to_review", False):
206
+ return "nothing to review"
207
+ if round_result.round_exit_code == 0:
208
+ return "SHIP"
209
+ if round_result.round_exit_code == 30:
210
+ return "NO-SHIP"
211
+ if round_result.round_exit_code == 10:
212
+ return "DECISION NEEDED"
213
+ return f"ERROR (exit {round_result.round_exit_code})"
214
+
215
+
216
+ def _producer_commit_subject(repo_root: Path | None, ending_sha: str) -> str:
217
+ """look up the commit subject for the
218
+ producer's commit so the loop-summary commit series can render
219
+ it inline.
220
+
221
+ Returns the commit's subject line on success, or an empty
222
+ string on any failure (missing repo_root, git not on PATH,
223
+ ref not found, etc.). Failures degrade the rendering to the
224
+ subject-less form rather than crashing the summary write —
225
+ the loop-summary.md is operator-facing and best-effort is
226
+ appropriate for the subject lookup.
227
+
228
+ Uses :func:`syncade.process.run_subprocess` for the same
229
+ timeout / process-group cleanup discipline the rest of
230
+ syncade relies on. Capped at a 5-second timeout — looking up
231
+ a commit subject is sub-second on every reasonable repo, and
232
+ timing out is a clearer signal than blocking the summary
233
+ write indefinitely.
234
+ """
235
+ if repo_root is None or not ending_sha:
236
+ return ""
237
+ try:
238
+ from syncade.process import run_subprocess
239
+
240
+ result = run_subprocess(
241
+ ["git", "log", "-1", "--pretty=format:%s", ending_sha],
242
+ cwd=repo_root,
243
+ timeout=5.0,
244
+ )
245
+ except Exception:
246
+ return ""
247
+ if result.returncode != 0:
248
+ return ""
249
+ return result.stdout.strip()
250
+
251
+
252
+ _EMPTY_SERIES_REASON_NOTES: dict[str, str] = {
253
+ "ship": "- (no producer commits — round 0 shipped without needing a fix)",
254
+ "no_changes_to_review": (
255
+ "- (no producer commits — the diff was empty; no reviewers or producer were dispatched)"
256
+ ),
257
+ "producer_emptied_diff": (
258
+ "- (see prior-round producer commits above"
259
+ " — they reduced the reviewable change set to empty)"
260
+ ),
261
+ "findings_present": "- (no producer commits — single-pass run ended with findings present)",
262
+ "max_rounds_reached": (
263
+ "- (no producer commits landed — every producer round stalled or errored before committing)"
264
+ ),
265
+ "budget_exceeded": (
266
+ "- (no producer commits — the budget was crossed before a producer round committed)"
267
+ ),
268
+ "provider_usage_limit": (
269
+ "- (no producer commits — the provider's usage limit was hit before a producer round "
270
+ "committed)"
271
+ ),
272
+ "producer_stalled": (
273
+ "- (no producer commits — the producer subprocess stalled without committing on this round)"
274
+ ),
275
+ "producer_subprocess_error": (
276
+ "- (no producer commits — the producer subprocess failed before it could commit)"
277
+ ),
278
+ "reviewer_failure": (
279
+ "- (no producer commits — a reviewer subprocess failed before any producer round could run)"
280
+ ),
281
+ "synth_failure": (
282
+ "- (no producer commits — the synthesizer subprocess failed "
283
+ "before any producer round could run)"
284
+ ),
285
+ "test_subprocess_error": (
286
+ "- (no producer commits — the test re-run subprocess failed "
287
+ "before any producer round could run)"
288
+ ),
289
+ "check_subprocess_error": (
290
+ "- (no producer commits — a blocking mechanical check's "
291
+ "subprocess failed before any producer round could run)"
292
+ ),
293
+ "decision_needed": (
294
+ "- (no producer commits — the producer escalated a finding for an "
295
+ "operator decision instead of committing a fix)"
296
+ ),
297
+ "blockers_all_deactivated": (
298
+ "- (no producer commits — the round ended at the reviewers/synthesizer "
299
+ "stage; no producer ran)"
300
+ ),
301
+ "worktree_error": (
302
+ "- (no producer commits — worktree provisioning failed before any producer round could run)"
303
+ ),
304
+ "diff_malformed": (
305
+ "- (no producer commits — the diff filter refused the run before any reviewer dispatched)"
306
+ ),
307
+ "diff_too_large": (
308
+ "- (no producer commits — the diff exceeded [loop] max_diff_bytes and the run was "
309
+ "refused before any reviewer dispatched)"
310
+ ),
311
+ "prompt_too_large": (
312
+ "- (no producer commits — an assembled reviewer prompt exceeded the provider ceiling "
313
+ "and the run was refused before any reviewer dispatched)"
314
+ ),
315
+ "parse_failure": (
316
+ "- (no producer commits — a reviewer / synthesizer output "
317
+ "couldn't be parsed; no producer round ran)"
318
+ ),
319
+ "config_error": (
320
+ "- (no producer commits — the configuration is invalid; no producer round ran)"
321
+ ),
322
+ }
323
+ """Per-:data:`TerminationReason` empty-commit-series wording."""
324
+
325
+
326
+ def _empty_commit_series_note(termination_reason: str) -> str:
327
+ """render the empty-commit-series line for
328
+ the loop summary based on the loop's termination reason.
329
+
330
+ Falls back to a neutral string when the reason isn't in the
331
+ table — defensive against a future :data:`TerminationReason`
332
+ expansion that lands before this table is updated.
333
+ """
334
+ return _EMPTY_SERIES_REASON_NOTES.get(
335
+ termination_reason,
336
+ "- (no producer commits this run)",
337
+ )
338
+
339
+
340
+ def _round_duration_seconds(round_result) -> float:
341
+ """Sum the per-phase durations the round captured. Approximate
342
+ (excludes worktree provisioning and persistence overhead) but
343
+ gives an operator-meaningful "how long did this round take to
344
+ review" number."""
345
+ total = 0.0
346
+ if round_result.dispatch_result is not None:
347
+ total += round_result.dispatch_result.total_duration_seconds
348
+ if round_result.synth_result is not None:
349
+ total += round_result.synth_result.duration_seconds
350
+ if round_result.test_result is not None:
351
+ total += round_result.test_result.duration_seconds
352
+ if round_result.producer_result is not None:
353
+ total += round_result.producer_result.duration_seconds
354
+ return total
355
+
356
+
357
+ def _run_usages(rounds: list) -> list:
358
+ """Every model-actor ``Usage`` across all rounds (reviewers + judge + producer).
359
+
360
+ Mirrors :func:`syncade.orchestrator.budget.round_usages` — duplicated (it is ~8 lines)
361
+ because persistence must NOT import ``orchestrator`` (the orchestrator imports
362
+ persistence; the reverse would cycle)."""
363
+ usages: list = []
364
+ for r in rounds:
365
+ usages.extend(x.usage for x in r.dispatch_result.results if x.usage is not None)
366
+ if r.synth_result is not None and r.synth_result.usage is not None:
367
+ usages.append(r.synth_result.usage)
368
+ if r.producer_result is not None and r.producer_result.usage is not None:
369
+ usages.append(r.producer_result.usage)
370
+ return usages
371
+
372
+
373
+ def _budget_section(
374
+ usages: list,
375
+ budget_tokens: int | None,
376
+ budget_usd: float | None,
377
+ budget_ceiling: str | None = None,
378
+ ) -> list:
379
+ """The ``## Budget`` block for a ``budget_exceeded`` run (PR-v2-11).
380
+
381
+ ``usages`` is the loop's ENFORCEMENT tally — the exact list of ``Usage`` records the budget
382
+ check summed. On a ``--resume`` this is the FRESH resumed-run spend, NOT the rehydrated
383
+ original rounds (the enforcement tally starts empty on resume), so the reported number is
384
+ the one that actually tripped the resumed budget. The configured ceiling(s) + that tally
385
+ are rendered through :mod:`syncade.billing` so the numbers AND the money-vs-valuation
386
+ wording are IDENTICAL to ``--metrics`` and each round's ``summary.md`` (C1: one number,
387
+ three surfaces). ``billing.render`` supplies the lower-bound honesty when any actor's cost
388
+ is unpriced (C4)."""
389
+ from syncade import billing
390
+
391
+ total_tokens = sum(u.total_tokens for u in usages)
392
+ # Name the ceiling that actually tripped. Both budgets set → first-to-trip (tokens are
393
+ # checked first); ``budget_ceiling`` carries over_budget's authoritative answer, so a
394
+ # token-only crossing never mis-reports "cost" and vice versa.
395
+ if budget_ceiling == "budget_tokens":
396
+ crossed = "the running TOKEN tally crossed your configured `budget_tokens` ceiling"
397
+ elif budget_ceiling == "budget_usd":
398
+ crossed = "the running COST tally crossed your configured `budget_usd` ceiling"
399
+ else:
400
+ crossed = "the running tally crossed your configured budget"
401
+ lines: list = [
402
+ "## Budget",
403
+ "",
404
+ f"The loop stopped because {crossed}. It aborted at a dispatch boundary — no running "
405
+ "provider call was interrupted — so the tally can exceed the ceiling by up to one "
406
+ "review-bundle (reviewers + judge) or one producer, whichever was in flight when it "
407
+ "crossed.",
408
+ "",
409
+ "**Configured ceiling:**",
410
+ ]
411
+ tok_mark = " ← CROSSED" if budget_ceiling == "budget_tokens" else ""
412
+ usd_mark = " ← CROSSED" if budget_ceiling == "budget_usd" else ""
413
+ if budget_tokens: # 0 is the opt-out sentinel (PR-h-field-06), not a real ceiling
414
+ lines.append(
415
+ f"- total tokens ≤ {budget_tokens:,} (`budget_tokens` — tightest bound; exact "
416
+ f"unless an actor reported no usage){tok_mark}"
417
+ )
418
+ if budget_usd: # 0 is the opt-out sentinel (PR-h-field-06), not a real ceiling
419
+ lines.append(
420
+ f"- API-equivalent cost ≤ ${budget_usd:.4f} (`budget_usd`, a LOWER-BOUND "
421
+ f"tally){usd_mark}"
422
+ )
423
+ if budget_tokens is None and budget_usd is None:
424
+ lines.append("- (ceiling value not recorded)")
425
+ lines += ["", f"**Tally this run:** {total_tokens:,} tokens", ""]
426
+ lines += billing.render(billing.from_usages(usages), bullet=True)
427
+ lines.append("")
428
+ return lines
@@ -0,0 +1,250 @@
1
+ """Producer subprocess persistence.
2
+
3
+ Writes ``<round_dir>/producer.{stdout,stderr,commit.txt[,error.txt]}``
4
+ and the matching round-manifest entry. The orchestrator only calls
5
+ these on rounds where the producer actually ran (NO-SHIP rounds with
6
+ ``max_rounds > 1``).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import traceback
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from syncade.producer import ProducerResult
16
+ from syncade.usage import usage_fields
17
+
18
+ from ._atomic import atomic_write_text
19
+ from ._validation import _validate_reviewer_filename_basename
20
+
21
+ # Hardcoded basename for the producer artifacts. There is exactly one
22
+ # producer run per round (where the producer ran at all), so the
23
+ # hardcoded basename mirrors the synthesizer's single-instance design.
24
+ PRODUCER_NAME = "producer"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ProducerArtifactPaths:
29
+ """Where the producer subprocess's artifacts land on disk.
30
+
31
+ Returned by :func:`persist_producer_result` and attached to a
32
+ :class:`RoundArtifacts` so the orchestrator + CLI can address
33
+ the files without re-deriving the layout convention.
34
+
35
+ All four paths are absolute and rooted at the round directory.
36
+ The producer doesn't emit structured JSON like the reviewer +
37
+ synthesizer phases, so there's no ``parsed.json`` — the
38
+ narrative text goes into ``producer.stdout`` directly.
39
+ """
40
+
41
+ stdout: Path
42
+ stderr: Path
43
+ commit_sha: Path
44
+ error: Path | None
45
+
46
+
47
+ def persist_producer_result(
48
+ round_dir: Path,
49
+ producer_result: ProducerResult,
50
+ ) -> ProducerArtifactPaths:
51
+ """Write the producer subprocess's outputs to
52
+ ``<round_dir>/producer.{stdout,stderr,commit.txt[,error.txt]}``.
53
+
54
+ Mirrors :func:`persist_synthesizer_result`'s and
55
+ :func:`persist_test_run_result`'s file-layout conventions so a
56
+ tool inspecting the round directory sees producer artifacts in
57
+ the same shape as the per-reviewer, synthesizer, and test-leg
58
+ artifacts.
59
+
60
+ Files written:
61
+
62
+ - ``producer.stdout`` — the producer's narrative text from
63
+ :attr:`ProducerOutput.narrative_text` on ``"committed"`` /
64
+ ``"stalled"`` outcomes; the raw subprocess stdout when
65
+ ``raw_subprocess_result`` is preserved (timeout, parse failure);
66
+ empty string when the subprocess never ran
67
+ (``SubprocessNotFoundError``).
68
+ - ``producer.stderr`` — the raw subprocess stderr when available;
69
+ empty string when ``raw_subprocess_result is None``.
70
+ - ``producer.commit.txt`` — one-line hex string: the
71
+ :attr:`ProducerResult.ending_sha`. On ``"stalled"`` this equals
72
+ :attr:`ProducerResult.starting_sha`; on ``"committed"`` it
73
+ differs. On ``"subprocess_error"`` it may differ when the
74
+ producer moved HEAD before failing; that file then includes an
75
+ indeterminate-commit note. Same shell-grep pattern as
76
+ ``test-run.exit-code.txt``: a shell-script consumer
77
+ (CI integration, ``grep``) can pull the SHA without parsing
78
+ JSON.
79
+ - ``producer.error.txt`` — written ONLY on
80
+ ``outcome="subprocess_error"``; carries the exception class
81
+ name + message + traceback (when available). Matches the
82
+ reviewer + synthesizer ``.error.txt`` shape so downstream
83
+ tooling treats all subprocess failures uniformly.
84
+
85
+ Args:
86
+ round_dir: The round directory to write into. Must already
87
+ exist (the orchestrator creates it during round setup).
88
+ producer_result: The :class:`ProducerResult` from
89
+ :func:`syncade.producer.run_producer`. The caller (the
90
+ orchestrator) only calls this on rounds where the
91
+ producer actually ran; rounds without a producer
92
+ (SHIP at round 0, max-rounds-reached) never reach here.
93
+
94
+ Returns:
95
+ :class:`ProducerArtifactPaths` naming all written files
96
+ (``error`` is ``None`` on the success / stall paths).
97
+
98
+ Raises:
99
+ FileNotFoundError: If ``round_dir`` does not exist (caller
100
+ bug — the orchestrator creates it during round setup).
101
+ """
102
+ _validate_reviewer_filename_basename(PRODUCER_NAME)
103
+ if not round_dir.is_dir():
104
+ raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
105
+
106
+ base = round_dir / PRODUCER_NAME
107
+ stdout_path = base.with_suffix(".stdout")
108
+ stderr_path = base.with_suffix(".stderr")
109
+ # The commit-SHA file uses a compound suffix (.commit.txt) so the
110
+ # ``shell-grep <round_dir>/producer.commit.txt`` recipe stays
111
+ # legible. Path.with_suffix would replace ``.stdout`` whole;
112
+ # build the name directly.
113
+ commit_sha_path = round_dir / f"{PRODUCER_NAME}.commit.txt"
114
+
115
+ # stdout: prefer the producer's narrative_text on success/stall;
116
+ # fall back to the raw subprocess stdout on the subprocess-error
117
+ # path (where the adapter never produced a ProducerOutput).
118
+ if producer_result.output is not None:
119
+ stdout_text = producer_result.output.narrative_text
120
+ elif producer_result.raw_subprocess_result is not None:
121
+ stdout_text = producer_result.raw_subprocess_result.stdout
122
+ else:
123
+ stdout_text = ""
124
+ atomic_write_text(stdout_path, stdout_text)
125
+
126
+ raw_result = producer_result.raw_subprocess_result
127
+ stderr_text = raw_result.stderr if raw_result is not None else ""
128
+ atomic_write_text(stderr_path, stderr_text)
129
+
130
+ # One-line hex string + trailing newline — shell-grep convention
131
+ # matches test-run.exit-code.txt.
132
+ commit_sha_text = f"{producer_result.ending_sha}\n"
133
+ indeterminate_commit = (
134
+ producer_result.outcome == "subprocess_error"
135
+ and producer_result.ending_sha != producer_result.starting_sha
136
+ )
137
+ atomic_write_text(commit_sha_path, commit_sha_text)
138
+
139
+ error_path: Path | None = None
140
+ if producer_result.error is not None:
141
+ exc = producer_result.error
142
+ lines = [
143
+ f"{type(exc).__name__}: {exc}",
144
+ "",
145
+ ]
146
+ tb = exc.__traceback__
147
+ if tb is not None:
148
+ lines.extend(traceback.format_exception(type(exc), exc, tb))
149
+ else:
150
+ lines.append("(no traceback available — exception was constructed, not raised)")
151
+ error_path = base.with_suffix(".error.txt")
152
+ if indeterminate_commit:
153
+ lines.extend(
154
+ [
155
+ "",
156
+ "Indeterminate producer commit: HEAD moved from "
157
+ f"{producer_result.starting_sha} to {producer_result.ending_sha} "
158
+ "before the subprocess_error outcome.",
159
+ ]
160
+ )
161
+ atomic_write_text(error_path, "\n".join(lines))
162
+
163
+ return ProducerArtifactPaths(
164
+ stdout=stdout_path,
165
+ stderr=stderr_path,
166
+ commit_sha=commit_sha_path,
167
+ error=error_path,
168
+ )
169
+
170
+
171
+ def _producer_manifest_entry(
172
+ producer_result: ProducerResult | None,
173
+ producer_config_provider: str | None = None,
174
+ producer_config_model: str | None = None,
175
+ ) -> dict[str, object] | None:
176
+ """Build the ``producer`` section of the round manifest.
177
+
178
+ Returns ``None`` when the producer phase was skipped (round
179
+ SHIPped at the verdict stage; this round was the last round
180
+ of the loop). Returns a dict with the documented synthesizer schema
181
+ otherwise.
182
+
183
+ Schema:
184
+
185
+ .. code-block:: json
186
+
187
+ {
188
+ "outcome": "committed" | "stalled" | "subprocess_error" | "escalated",
189
+ "provider": "anthropic",
190
+ "model": "claude-sonnet-4-6",
191
+ "starting_sha": "<full-sha1-or-sha256-hex>",
192
+ "ending_sha": "<full-sha1-or-sha256-hex>",
193
+ "duration_seconds": float,
194
+ "retried": int, // OMITTED when retries == 0 (C5: happy-path byte-identical)
195
+ "stdout_path": "producer.stdout",
196
+ "stderr_path": "producer.stderr",
197
+ "commit_sha_path": "producer.commit.txt",
198
+ "error_path": null | "producer.error.txt",
199
+ "error_type": null | "SubprocessTimeoutError" | ...,
200
+ "escalation": { "finding", "decision", "options", "rationale" }
201
+ // present ONLY on outcome=="escalated"; omitted otherwise
202
+ }
203
+
204
+ The provider + model are echoed from the producer config so a
205
+ consumer reading manifest.json knows which adapter ran without
206
+ cross-referencing back to ``.syncade/config.toml``.
207
+ """
208
+ if producer_result is None:
209
+ return None
210
+ provider = producer_result.provider or producer_config_provider
211
+ model = producer_result.model or producer_config_model
212
+ entry: dict[str, object] = {
213
+ "outcome": producer_result.outcome,
214
+ "provider": provider,
215
+ "model": model,
216
+ "starting_sha": producer_result.starting_sha,
217
+ "ending_sha": producer_result.ending_sha,
218
+ "duration_seconds": producer_result.duration_seconds,
219
+ **usage_fields(producer_result.usage),
220
+ "stdout_path": f"{PRODUCER_NAME}.stdout",
221
+ "stderr_path": f"{PRODUCER_NAME}.stderr",
222
+ "commit_sha_path": f"{PRODUCER_NAME}.commit.txt",
223
+ "error_path": (f"{PRODUCER_NAME}.error.txt" if producer_result.error is not None else None),
224
+ "error_type": (
225
+ type(producer_result.error).__name__ if producer_result.error is not None else None
226
+ ),
227
+ }
228
+ # C5: omit "retried" entirely when no retries occurred so the happy-path
229
+ # producer block is byte-identical to pre-PR-v2-22. Presence implies > 0.
230
+ if producer_result.retries > 0:
231
+ entry["retried"] = producer_result.retries
232
+ if (
233
+ producer_result.outcome == "subprocess_error"
234
+ and producer_result.ending_sha != producer_result.starting_sha
235
+ ):
236
+ entry["indeterminate_commit"] = True
237
+ # (QA finding 3): on the escalated outcome, carry the structured
238
+ # escalation so a tool reading manifest.json alone can reconstruct WHY the
239
+ # loop paused for a decision (not just decision-needed.md / producer.stdout).
240
+ # The key is OMITTED on every other outcome (escalation is None) → the
241
+ # committed/stalled/subprocess_error manifest entries are byte-identical.
242
+ if producer_result.escalation is not None:
243
+ esc = producer_result.escalation
244
+ entry["escalation"] = {
245
+ "finding": esc.finding,
246
+ "decision": esc.decision,
247
+ "options": list(esc.options),
248
+ "rationale": esc.rationale,
249
+ }
250
+ return entry