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,599 @@
1
+ """One iteration of the multi-round review loop.
2
+
3
+ ``_run_round_step`` runs a single round — snapshot refresh → ``_run_one_round`` →
4
+ terminator decision → (if NO-SHIP and rounds remain) producer phase → escalation
5
+ honoring → branch advance — and returns a :class:`_RoundStep` signal telling
6
+ ``run_review`` whether to ``continue`` to the next round or ``break`` out, with
7
+ the final exit code / termination reason set on a break.
8
+
9
+ This is a faithful extraction of the original ``for``-loop body: every ``break``
10
+ became a ``return _RoundStep(action="break", ...)`` and the fall-through-to-next-
11
+ round path became ``return _RoundStep(action="continue", ...)``. The mutable
12
+ ``round_results`` / ``round_artifacts_list`` / ``managers_to_cleanup`` lists are
13
+ passed in and mutated in place exactly as before; ``current_snapshot`` and the
14
+ per-round ``branch_advanced`` flag are returned so the caller can thread them.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ from syncade import run_status
22
+ from syncade.adapters.base import ReviewerAdapter
23
+ from syncade.exit_codes import (
24
+ BUDGET_EXCEEDED,
25
+ CLARIFICATION_NEEDED,
26
+ CONFIG_ERROR,
27
+ FINDINGS_PRESENT,
28
+ MAX_ROUNDS_REACHED,
29
+ REVIEWER_FAILURE,
30
+ REVIEWER_OUTPUT_UNPARSEABLE,
31
+ SUCCESS,
32
+ WORKTREE_ERROR,
33
+ )
34
+ from syncade.persistence import (
35
+ persist_producer_result,
36
+ persist_round_manifest,
37
+ persist_run_summary,
38
+ )
39
+ from syncade.snapshot import take_snapshot
40
+
41
+ from .branch_advance import _advance_branch_ref
42
+ from .budget import approaching_budget, over_budget, review_usages
43
+ from .escalation_coverage import escalation_covers_active_blockers
44
+ from .producer_phase import _run_producer_phase
45
+ from .results import RoundArtifacts, RoundResult
46
+ from .round import _run_one_round
47
+ from .verdict import (
48
+ _classify_phase_failure,
49
+ deactivated_blocker_details,
50
+ error_is_provider_usage_limit,
51
+ round_hit_provider_usage_limit,
52
+ )
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class _RoundStep:
57
+ """The outcome of one loop iteration: whether to continue or break, the
58
+ final exit code / termination reason (set on break), the (possibly
59
+ refreshed) snapshot, and whether this round advanced the branch."""
60
+
61
+ action: str # "continue" | "break"
62
+ current_snapshot: object
63
+ branch_advanced: bool
64
+ final_exit_code: int | None = None
65
+ termination_reason: str | None = None
66
+ budget_ceiling: str | None = None # "budget_tokens" | "budget_usd" on a budget abort
67
+ budget_warned: bool = False # True when the 80%-of-ceiling advisory fired this step
68
+
69
+
70
+ def _run_round_step(
71
+ *,
72
+ round_idx,
73
+ current_snapshot,
74
+ resumed_round_start,
75
+ repo_root,
76
+ pr_doc_path,
77
+ run_id,
78
+ run_dir,
79
+ config,
80
+ base_ref,
81
+ resolved_timeout,
82
+ resolved_producer_timeout,
83
+ adapter_factory,
84
+ synthesizer_adapter: ReviewerAdapter | None,
85
+ producer_adapter,
86
+ effective_worktree_base,
87
+ logger,
88
+ started_at,
89
+ managers_to_cleanup,
90
+ round_results,
91
+ round_artifacts_list,
92
+ resume_plan,
93
+ operator_decision,
94
+ force_drift,
95
+ prior_usages,
96
+ branch_advanced_during_run,
97
+ budget_warned,
98
+ ) -> _RoundStep:
99
+ """Run one round of the loop and return a continue/break signal.
100
+
101
+ ``prior_usages`` is the loop's running budget tally from PRIOR rounds (PR-v2-11). It is
102
+ read only for the pre-producer check below — the caller accumulates this round's usage
103
+ after the step returns — so a round whose reviewers already crossed the ceiling skips the
104
+ expensive producer leg instead of spending it and aborting one round later.
105
+
106
+ ``branch_advanced_during_run`` is the CUMULATIVE flag from all prior rounds — True when any
107
+ earlier round fast-forwarded the branch. The local ``branch_advanced`` variable tracks only
108
+ THIS round; the cumulative flag is what exit-10 documents must report.
109
+ """
110
+ branch_advanced = False
111
+ round_dir = run_dir / f"round-{round_idx}"
112
+
113
+ # Budget-abort-before-producer resume: the review bundle for this round
114
+ # was completed in a prior process and is already rehydrated into
115
+ # round_results[round_idx]. Skip mkdir (dir exists), skip _run_one_round,
116
+ # and proceed directly to the terminator + producer dispatch.
117
+ _budget_abort_resume = (
118
+ resume_plan is not None and resume_plan.budget_aborted_before_producer_round == round_idx
119
+ )
120
+
121
+ if _budget_abort_resume:
122
+ # round_results was pre-populated by _rehydrate_resume_state
123
+ round_result = round_results[round_idx]
124
+ run_status.update_phase(f"round-{round_idx}: producer", round_idx)
125
+ else:
126
+ round_dir.mkdir(parents=True, exist_ok=False)
127
+ run_status.update_phase(f"round-{round_idx}: reviewing", round_idx)
128
+
129
+ # First iteration: use the initial snapshot (the dirty-
130
+ # tree messaging already fired). For resume, the initial
131
+ # snapshot reflects the resumed round's expected SHA
132
+ # (validated by check_tree_drift unless --force-drift).
133
+ # Subsequent iterations: take a fresh snapshot (HEAD has
134
+ # moved thanks to the previous round's producer commits +
135
+ # branch advance). No new dirty-tree warnings — the new
136
+ # commits are intentional and produce a clean tree.
137
+ if round_idx > resumed_round_start:
138
+ run_status.update_phase(f"round-{round_idx}: snapshotting", round_idx)
139
+ # Use the immutable OID from the round-0 snapshot rather than
140
+ # re-resolving the symbolic base_ref, which can move if the base
141
+ # branch receives new commits between rounds.
142
+ _stable_base = current_snapshot.base_oid or base_ref
143
+ # `base_oid` is ALREADY the effective diff base — round 0 resolved
144
+ # the branch point once. Re-deriving a merge base here would be a
145
+ # no-op for three-dot runs (the merge base of an ancestor and its
146
+ # descendant is the ancestor) but would silently CONVERT a
147
+ # `--two-dot` run to three-dot semantics mid-loop.
148
+ current_snapshot = take_snapshot(repo_root, base_ref=_stable_base, three_dot=False)
149
+ run_status.update_phase(f"round-{round_idx}: reviewing", round_idx)
150
+
151
+ round_result = _run_one_round(
152
+ round_idx=round_idx,
153
+ snapshot=current_snapshot,
154
+ repo_root=repo_root,
155
+ pr_doc_path=pr_doc_path,
156
+ run_id=run_id,
157
+ run_dir=run_dir,
158
+ round_dir=round_dir,
159
+ config=config,
160
+ resolved_timeout=resolved_timeout,
161
+ resolved_producer_timeout=resolved_producer_timeout,
162
+ adapter_factory=adapter_factory,
163
+ synthesizer_adapter=synthesizer_adapter,
164
+ producer_adapter=producer_adapter,
165
+ worktree_base=effective_worktree_base,
166
+ logger=logger,
167
+ started_at=started_at,
168
+ managers_to_cleanup=managers_to_cleanup,
169
+ resumed_under_drift=(
170
+ force_drift and resume_plan is not None and round_idx == resumed_round_start
171
+ ),
172
+ )
173
+ round_results.append(round_result)
174
+ round_artifacts_list.append(round_result.artifacts)
175
+
176
+ # --- Terminator decision ------------------------------------
177
+ # The round itself produced an exit code. The loop terminator
178
+ # then folds in: producer outcome (if it ran), max-rounds
179
+ # cap, and the per-round exit code's category (success vs
180
+ # findings vs phase-failure).
181
+
182
+ # Phase-failure terminators take precedence — they're
183
+ # exit codes 40 / 50 / 60 / 70 indicating subprocess /
184
+ # config / worktree / parse failures. Looping wouldn't
185
+ # help, so terminate immediately.
186
+ # A provider quota refusal reaches here as a reviewer failure (exit 40), which is the
187
+ # wrong verdict: the run is not unreviewable, the operator just has to wait. Route it to
188
+ # exit 25's existing "stopped cleanly, resume later" contract BEFORE the generic branch.
189
+ if round_result.round_exit_code == REVIEWER_FAILURE and round_hit_provider_usage_limit(
190
+ round_result
191
+ ):
192
+ return _RoundStep(
193
+ action="break",
194
+ current_snapshot=current_snapshot,
195
+ branch_advanced=branch_advanced,
196
+ final_exit_code=BUDGET_EXCEEDED,
197
+ termination_reason="provider_usage_limit",
198
+ )
199
+
200
+ if round_result.round_exit_code in (
201
+ REVIEWER_FAILURE,
202
+ CONFIG_ERROR,
203
+ WORKTREE_ERROR,
204
+ REVIEWER_OUTPUT_UNPARSEABLE,
205
+ ):
206
+ return _RoundStep(
207
+ action="break",
208
+ current_snapshot=current_snapshot,
209
+ branch_advanced=branch_advanced,
210
+ final_exit_code=round_result.round_exit_code,
211
+ termination_reason=_classify_phase_failure(round_result),
212
+ )
213
+
214
+ # No-changes (PR-h-02d D1/D3): pre-dispatch check found an empty diff
215
+ # with a resolved base_oid — no reviewers or subprocesses were dispatched
216
+ # THIS round. Map to exit 0 with a reason that reflects prior-round spend:
217
+ # round 0 → no_changes_to_review (zero total spend); round 1+ → producer_emptied_diff
218
+ # (prior rounds dispatched reviewers/producer and the producer cleaned up all changes).
219
+ if round_result.no_changes_to_review:
220
+ _empty_reason = "no_changes_to_review" if round_idx == 0 else "producer_emptied_diff"
221
+ return _RoundStep(
222
+ action="break",
223
+ current_snapshot=current_snapshot,
224
+ branch_advanced=branch_advanced,
225
+ final_exit_code=SUCCESS,
226
+ termination_reason=_empty_reason,
227
+ )
228
+
229
+ # Round SHIPped (clean synth + tests passed if configured) →
230
+ # terminate with SUCCESS.
231
+ if round_result.round_exit_code == SUCCESS:
232
+ return _RoundStep(
233
+ action="break",
234
+ current_snapshot=current_snapshot,
235
+ branch_advanced=branch_advanced,
236
+ final_exit_code=SUCCESS,
237
+ termination_reason="ship",
238
+ )
239
+
240
+ # Round escalated (exit 10): two or more distinct reviewers each raised a
241
+ # blocker and every one was deactivated (PR-h-01 increment D). Terminal by
242
+ # construction — the question is whether the synthesizer was RIGHT to rule
243
+ # them all out, which only the operator can answer. Running the producer
244
+ # would be incoherent: there is no active blocker for it to fix.
245
+ if round_result.round_exit_code == CLARIFICATION_NEEDED:
246
+ # The operator gets exit 10 either way; without this document they would
247
+ # have no idea WHY, since nothing is listed as an active blocker.
248
+ if round_result.synth_result is not None and round_result.synth_result.output is not None:
249
+ from syncade.persistence import persist_deactivated_blockers_decision_needed
250
+
251
+ persist_deactivated_blockers_decision_needed(
252
+ run_dir,
253
+ round_idx=round_idx,
254
+ run_id=run_id,
255
+ deactivated=deactivated_blocker_details(
256
+ round_result.dispatch_result, round_result.synth_result.output
257
+ ),
258
+ branch_advanced=branch_advanced_during_run,
259
+ )
260
+ return _RoundStep(
261
+ action="break",
262
+ current_snapshot=current_snapshot,
263
+ branch_advanced=branch_advanced,
264
+ final_exit_code=CLARIFICATION_NEEDED,
265
+ termination_reason="blockers_all_deactivated",
266
+ )
267
+
268
+ # Round was NO-SHIP (exit 30): non-dismissed blocker or test
269
+ # failed. If we're at max_rounds-1, terminate. The exit code
270
+ # depends on max_rounds:
271
+ #
272
+ # - max_rounds == 1: single-pass back-compat. The
273
+ # operator didn't ask for retries; NO-SHIP stays at exit 30.
274
+ # Exit 20 ("max rounds reached") is semantically a
275
+ # multi-round-loop-exhausted signal — it doesn't apply when
276
+ # there was never a producer phase to potentially fix things.
277
+ # - max_rounds > 1: the operator asked for retries AND we
278
+ # used them all up without converging. Exit 20 +
279
+ # termination_reason="max_rounds_reached".
280
+ if round_result.round_exit_code != FINDINGS_PRESENT:
281
+ raise RuntimeError(
282
+ "loop round step reached NO-SHIP branch with unexpected "
283
+ f"round_exit_code={round_result.round_exit_code}"
284
+ )
285
+
286
+ if round_idx == config.loop.max_rounds - 1:
287
+ if config.loop.max_rounds == 1:
288
+ # Single-pass back-compat: don't remap exit code.
289
+ final_exit_code = FINDINGS_PRESENT
290
+ termination_reason = "findings_present"
291
+ else:
292
+ # Multi-round loop exhausted without convergence.
293
+ final_exit_code = MAX_ROUNDS_REACHED
294
+ termination_reason = "max_rounds_reached"
295
+ return _RoundStep(
296
+ action="break",
297
+ current_snapshot=current_snapshot,
298
+ branch_advanced=branch_advanced,
299
+ final_exit_code=final_exit_code,
300
+ termination_reason=termination_reason,
301
+ )
302
+
303
+ # PR-v2-11: pre-producer budget check. Reaching here means NO-SHIP with rounds remaining,
304
+ # i.e. the producer — the round's one expensive conditional leg — is about to run. If this
305
+ # round's reviewers + judge, added to the prior-rounds tally, already crossed the ceiling,
306
+ # abort BEFORE the producer so overshoot is bounded to the review phase, not a whole round.
307
+ # Skip this check for a budget-abort-before-producer resume: the review costs were
308
+ # incurred in the prior process and must not count against the fresh budget tally.
309
+ if not _budget_abort_resume:
310
+ _pre_producer_usages = prior_usages + review_usages(round_result)
311
+ budget_ceiling = over_budget(_pre_producer_usages, config.loop)
312
+ if budget_ceiling is not None:
313
+ return _RoundStep(
314
+ action="break",
315
+ current_snapshot=current_snapshot,
316
+ branch_advanced=branch_advanced,
317
+ final_exit_code=BUDGET_EXCEEDED,
318
+ termination_reason="budget_exceeded",
319
+ budget_ceiling=budget_ceiling,
320
+ )
321
+ # PR-h-field-06 item 3: the 80% advisory fires at BOTH dispatch boundaries.
322
+ # The start-of-round check in loop.py covers the case where prior rounds already
323
+ # entered the band; this catches the case where this round's reviews push the total
324
+ # into the band so the operator is warned before the producer spends more.
325
+ if not budget_warned:
326
+ _approaching = approaching_budget(_pre_producer_usages, config.loop)
327
+ if _approaching is not None:
328
+ from syncade.logging import _approaching_budget_line
329
+
330
+ logger.warning(
331
+ _approaching_budget_line(_approaching, _pre_producer_usages, config.loop)
332
+ )
333
+ budget_warned = True
334
+
335
+ # NO-SHIP and rounds remaining → run the producer. on the
336
+ # round being RESUMED after an escalation, feed the operator's
337
+ # recorded decision (read by the CLI from decision.txt) to the
338
+ # producer; every other round gets the sentinel (None here).
339
+ round_operator_decision = (
340
+ operator_decision
341
+ if (resume_plan is not None and round_idx == resumed_round_start)
342
+ else None
343
+ )
344
+ run_status.update_phase(f"round-{round_idx}: producer", round_idx)
345
+ producer_result = _run_producer_phase(
346
+ round_idx=round_idx,
347
+ round_result=round_result,
348
+ run_id=run_id,
349
+ run_dir=run_dir,
350
+ round_dir=round_dir,
351
+ snapshot=current_snapshot,
352
+ repo_root=repo_root,
353
+ pr_doc_path=pr_doc_path,
354
+ config=config,
355
+ resolved_producer_timeout=resolved_producer_timeout,
356
+ producer_adapter=producer_adapter,
357
+ worktree_base=effective_worktree_base,
358
+ logger=logger,
359
+ managers_to_cleanup=managers_to_cleanup,
360
+ operator_decision=round_operator_decision,
361
+ )
362
+
363
+ # the escalation honor decision is mechanical set-containment
364
+ # over the synth's own active-blocker set (escalation_coverage). Compute
365
+ # it ONCE here so (a) the per-round summary renders the escalation's TRUE
366
+ # disposition — honored checkpoint vs. uncovered-blocker stall — rather
367
+ # than pointing the operator at a decision-needed.md the rejected path
368
+ # never writes, and (b) the terminator below reuses the same answer. The
369
+ # two surfaces can then never disagree.
370
+ escalation_honored = producer_result.outcome == "escalated" and (
371
+ escalation_covers_active_blockers(
372
+ producer_result.escalation,
373
+ round_result.synth_result.output if round_result.synth_result is not None else None,
374
+ )
375
+ )
376
+
377
+ # Attach producer artifacts to the round's bundle.
378
+ producer_paths = persist_producer_result(round_dir, producer_result)
379
+ new_round_artifacts = RoundArtifacts(
380
+ round_idx=round_idx,
381
+ round_dir=round_result.artifacts.round_dir,
382
+ manifest_path=round_result.artifacts.manifest_path,
383
+ summary_path=round_result.artifacts.summary_path,
384
+ findings_md_path=round_result.artifacts.findings_md_path,
385
+ synthesizer_paths=round_result.artifacts.synthesizer_paths,
386
+ test_run_paths=round_result.artifacts.test_run_paths,
387
+ producer_paths=producer_paths,
388
+ )
389
+ # Replace the last round's artifacts/result with the
390
+ # producer-aware version so the rounds list reflects the
391
+ # producer phase.
392
+ round_artifacts_list[-1] = new_round_artifacts
393
+ round_results[-1] = RoundResult(
394
+ round_idx=round_result.round_idx,
395
+ snapshot=round_result.snapshot,
396
+ dispatch_result=round_result.dispatch_result,
397
+ synth_result=round_result.synth_result,
398
+ test_result=round_result.test_result,
399
+ test_skip_reason=round_result.test_skip_reason,
400
+ test_worktree_error=round_result.test_worktree_error,
401
+ producer_result=producer_result,
402
+ round_exit_code=round_result.round_exit_code,
403
+ artifacts=new_round_artifacts,
404
+ # carry the mechanical-check results through the
405
+ # producer-aware rebuild. Dropping them here would empty
406
+ # RoundResult.check_results AND erase the '## Mechanical checks'
407
+ # section / 'checks' manifest array when the manifest + summary
408
+ # are re-written below — findings.md and the raw *.check.* files
409
+ # would silently disagree with summary.md / manifest.json.
410
+ check_results=round_result.check_results,
411
+ filtered_diff_bytes=round_result.filtered_diff_bytes,
412
+ raw_diff_bytes=round_result.raw_diff_bytes,
413
+ )
414
+
415
+ # Re-write the manifest with the producer section now populated.
416
+ # Use round_result.snapshot (the snapshot the reviewers actually saw), not
417
+ # current_snapshot: on a force-drift budget-abort resume, current_snapshot
418
+ # reflects the drifted HEAD while round_result.snapshot was rehydrated from
419
+ # the completed round's persisted state. In the normal (non-resume) path the
420
+ # two are identical so this change is safe for both paths.
421
+ persist_round_manifest(
422
+ round_dir,
423
+ round_result.snapshot,
424
+ round_result.dispatch_result,
425
+ round_result.round_exit_code,
426
+ started_at,
427
+ round_result.synth_result,
428
+ round_result.test_result,
429
+ round_result.test_skip_reason,
430
+ round_idx=round_idx,
431
+ producer_result=producer_result,
432
+ producer_provider=config.producer.provider,
433
+ producer_model=config.producer.model,
434
+ check_results=round_result.check_results,
435
+ filtered_diff_bytes=round_result.filtered_diff_bytes,
436
+ raw_diff_bytes=round_result.raw_diff_bytes,
437
+ )
438
+ # Re-render summary.md after the producer phase so the per-round summary
439
+ # includes the producer section and producer-aware next-step guidance.
440
+ persist_run_summary(
441
+ round_dir,
442
+ round_result.snapshot,
443
+ round_result.dispatch_result,
444
+ round_result.round_exit_code,
445
+ started_at,
446
+ round_result.synth_result,
447
+ round_result.test_result,
448
+ round_result.test_skip_reason,
449
+ producer_result=producer_result,
450
+ producer_provider=config.producer.provider,
451
+ producer_model=config.producer.model,
452
+ resumed_under_drift=(
453
+ force_drift and resume_plan is not None and round_idx == resumed_round_start
454
+ ),
455
+ check_results=round_result.check_results,
456
+ escalation_honored=escalation_honored,
457
+ branch_already_advanced=branch_advanced_during_run,
458
+ )
459
+
460
+ # --- Producer outcome → loop continuation decision ----------
461
+ if producer_result.outcome == "stalled":
462
+ return _RoundStep(
463
+ action="break",
464
+ current_snapshot=current_snapshot,
465
+ branch_advanced=branch_advanced,
466
+ final_exit_code=FINDINGS_PRESENT,
467
+ termination_reason="producer_stalled",
468
+ )
469
+ if producer_result.outcome == "subprocess_error":
470
+ # Same reasoning as the reviewer/judge branch above: a quota refusal is resumable, not
471
+ # a permanent failure. The producer is the likeliest actor to hit one — it burned 6.6M
472
+ # tokens in a single field round — and routing it to exit 40 would throw away every
473
+ # committed round behind it.
474
+ if error_is_provider_usage_limit(producer_result.error):
475
+ return _RoundStep(
476
+ action="break",
477
+ current_snapshot=current_snapshot,
478
+ branch_advanced=branch_advanced,
479
+ final_exit_code=BUDGET_EXCEEDED,
480
+ termination_reason="provider_usage_limit",
481
+ )
482
+ return _RoundStep(
483
+ action="break",
484
+ current_snapshot=current_snapshot,
485
+ branch_advanced=branch_advanced,
486
+ final_exit_code=REVIEWER_FAILURE,
487
+ termination_reason="producer_subprocess_error",
488
+ )
489
+ if producer_result.outcome == "escalated":
490
+ # the producer flagged a finding as an operator
491
+ # decision. Honor it (checkpoint → exit 10 + decision_needed) ONLY
492
+ # when its finding_indices cover EVERY active blocker in the round
493
+ # — the mechanical set-containment computed above
494
+ # (escalation_covers_active_blockers over the synth's own
495
+ # consolidated_findings, NOT a count and NOT the producer prompt).
496
+ # AC5 ("checkpoint only when the remaining active blockers are all
497
+ # escalated") lives here.
498
+ #
499
+ # An escalation that leaves an active blocker uncovered — or
500
+ # references a non-blocker / out-of-range index (escalated ⟺
501
+ # no-commit, so the producer committed nothing) — did NOT finish
502
+ # the round's work. Treat it as an ordinary stall (exit 30): the
503
+ # operator sees NO-SHIP + the findings + the producer narrative,
504
+ # and the uncovered blocker comes back next run. This closes the
505
+ # codex repro (escalate one, leave one → stall) without false-
506
+ # downgrading a legitimate multi-blocker decision (cover all → honor).
507
+ if escalation_honored:
508
+ # Checkpoint-and-terminate (NOT a blocking wait). The mechanical
509
+ # verdict is untouched (round_exit_code stays 30); the escalation
510
+ # rides alongside as the termination reason. ``escalation_honored``
511
+ # is True only on the escalated outcome, so ``escalation`` is the
512
+ # non-None value ProducerResult enforces for it.
513
+ from syncade.persistence import persist_decision_needed
514
+
515
+ persist_decision_needed(
516
+ run_dir,
517
+ round_idx=round_idx,
518
+ escalation=producer_result.escalation,
519
+ run_id=run_id,
520
+ branch_advanced=branch_advanced_during_run,
521
+ check_results=round_result.check_results,
522
+ )
523
+ return _RoundStep(
524
+ action="break",
525
+ current_snapshot=current_snapshot,
526
+ branch_advanced=branch_advanced,
527
+ final_exit_code=CLARIFICATION_NEEDED,
528
+ termination_reason="decision_needed",
529
+ )
530
+ return _RoundStep(
531
+ action="break",
532
+ current_snapshot=current_snapshot,
533
+ branch_advanced=branch_advanced,
534
+ final_exit_code=FINDINGS_PRESENT,
535
+ termination_reason="producer_stalled",
536
+ )
537
+
538
+ if producer_result.outcome != "committed":
539
+ raise RuntimeError(
540
+ "loop round step reached branch-advance path with unexpected "
541
+ f"producer outcome={producer_result.outcome!r}"
542
+ )
543
+ run_status.update_phase(f"round-{round_idx}: branch advancing", round_idx)
544
+ advance_status = _advance_branch_ref(
545
+ repo_root=repo_root,
546
+ snapshot=current_snapshot,
547
+ producer_result=producer_result,
548
+ logger=logger,
549
+ )
550
+ # branch-advance failure is a non-SHIP terminal
551
+ # state. Per the brief, a non-descendant ending SHA is
552
+ # "treated as a stall"; update-ref failure (ref moved
553
+ # under us, permissions) has the same stale-SHIP risk
554
+ # if we kept going, so it also terminates. Detached-HEAD
555
+ # cannot advance a named ref either; continuing would make
556
+ # the next reviewers inspect the same stale SHA and could
557
+ # falsely SHIP.
558
+ if advance_status == "advanced":
559
+ branch_advanced = True
560
+ run_status.update_phase(f"round-{round_idx}: branch advanced", round_idx)
561
+ # Clear the escalation checkpoint only after the resumed producer
562
+ # durably applied the operator's decision: committed and branch
563
+ # advanced. Cleaning at resume start would lose the decision if
564
+ # the round aborted before applying it.
565
+ if (
566
+ resume_plan is not None
567
+ and round_idx == resumed_round_start
568
+ and operator_decision is not None
569
+ ):
570
+ from syncade.persistence import (
571
+ DECISION_NEEDED_FILENAME as _DN,
572
+ )
573
+ from syncade.persistence import (
574
+ OPERATOR_DECISION_FILENAME as _OD,
575
+ )
576
+
577
+ (run_dir / _DN).unlink(missing_ok=True)
578
+ (run_dir / _OD).unlink(missing_ok=True)
579
+ elif advance_status in (
580
+ "skipped_detached_head",
581
+ "non_descendant",
582
+ "update_ref_failed",
583
+ ):
584
+ return _RoundStep(
585
+ action="break",
586
+ current_snapshot=current_snapshot,
587
+ branch_advanced=branch_advanced,
588
+ final_exit_code=FINDINGS_PRESENT,
589
+ termination_reason="producer_stalled",
590
+ )
591
+ # advance_status == "advanced" — continue to next round. Every
592
+ # non-advanced status terminates above so the next reviewers never
593
+ # inspect stale input from an unadvanced ref.
594
+ return _RoundStep(
595
+ action="continue",
596
+ current_snapshot=current_snapshot,
597
+ branch_advanced=branch_advanced,
598
+ budget_warned=budget_warned,
599
+ )