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