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,369 @@
|
|
|
1
|
+
"""Pre-dispatch early-exit results for the known-empty and fail-closed diff cases.
|
|
2
|
+
|
|
3
|
+
Split from :mod:`.round` to keep that module under the LOC cap.
|
|
4
|
+
|
|
5
|
+
D1/D3 (PR-h-02d): when ``snapshot.base_oid`` is set but the reviewer-facing diff
|
|
6
|
+
is empty (nothing changed, or all changed files were legitimate repo-context),
|
|
7
|
+
terminate before any subprocess is dispatched.
|
|
8
|
+
|
|
9
|
+
D2 (PR-h-02d): when ``filter_diff_for_reviewer`` dropped sections fail-closed
|
|
10
|
+
(headers that cannot be parsed or decoded), refuse the run (exit 60) rather than
|
|
11
|
+
treating "unreadable change" as "no change".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from syncade.config import SyncadeConfig
|
|
20
|
+
from syncade.dispatcher import DispatchResult
|
|
21
|
+
from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
|
|
22
|
+
from syncade.logging import Logger
|
|
23
|
+
from syncade.persistence import persist_round_manifest, persist_run_summary
|
|
24
|
+
from syncade.snapshot import Snapshot
|
|
25
|
+
|
|
26
|
+
from .results import RoundArtifacts, RoundResult
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _no_changes_to_review_result(
|
|
30
|
+
*,
|
|
31
|
+
round_idx: int,
|
|
32
|
+
snapshot: Snapshot,
|
|
33
|
+
round_dir: Path,
|
|
34
|
+
config: SyncadeConfig,
|
|
35
|
+
started_at: datetime,
|
|
36
|
+
resumed_under_drift: bool,
|
|
37
|
+
) -> RoundResult:
|
|
38
|
+
"""Terminal result for a run whose reviewer-facing diff is empty (D1/D3, PR-h-02d).
|
|
39
|
+
|
|
40
|
+
``snapshot.base_oid`` is set (a real base resolved) but the diff filtered to
|
|
41
|
+
empty — either no files changed, or every section was legitimate repo-context
|
|
42
|
+
stripped by the filter. Zero subprocesses are dispatched.
|
|
43
|
+
|
|
44
|
+
The round exit code is ``SUCCESS``, NOT a private sentinel. An earlier cut used
|
|
45
|
+
a sentinel ``1`` so the loop could distinguish this from a real SHIP, but ``1``
|
|
46
|
+
is not in ``_EXIT_CODE_LABELS`` — so the durable artifacts rendered the round as
|
|
47
|
+
``Exit code: 1 (UNKNOWN)`` and ``ERROR (exit 1)`` while the top-level API
|
|
48
|
+
correctly reported exit 0. The distinction is carried by
|
|
49
|
+
``termination_reason="no_changes_to_review"``, which is what the renderers key
|
|
50
|
+
on; the exit code does not need to encode it twice.
|
|
51
|
+
"""
|
|
52
|
+
raw_diff_bytes = len((snapshot.diff_text or "").encode("utf-8"))
|
|
53
|
+
empty_dispatch = DispatchResult(results=[], total_duration_seconds=0.0)
|
|
54
|
+
persist_round_manifest(
|
|
55
|
+
round_dir,
|
|
56
|
+
snapshot,
|
|
57
|
+
empty_dispatch,
|
|
58
|
+
SUCCESS,
|
|
59
|
+
started_at,
|
|
60
|
+
None,
|
|
61
|
+
None,
|
|
62
|
+
None,
|
|
63
|
+
round_idx=round_idx,
|
|
64
|
+
producer_result=None,
|
|
65
|
+
producer_provider=config.producer.provider,
|
|
66
|
+
producer_model=config.producer.model,
|
|
67
|
+
check_results=[],
|
|
68
|
+
filtered_diff_bytes=0,
|
|
69
|
+
raw_diff_bytes=raw_diff_bytes,
|
|
70
|
+
)
|
|
71
|
+
persist_run_summary(
|
|
72
|
+
round_dir,
|
|
73
|
+
snapshot,
|
|
74
|
+
empty_dispatch,
|
|
75
|
+
SUCCESS,
|
|
76
|
+
started_at,
|
|
77
|
+
None,
|
|
78
|
+
None,
|
|
79
|
+
None,
|
|
80
|
+
resumed_under_drift=resumed_under_drift,
|
|
81
|
+
check_results=[],
|
|
82
|
+
no_changes_to_review=True,
|
|
83
|
+
)
|
|
84
|
+
artifacts = RoundArtifacts(
|
|
85
|
+
round_idx=round_idx,
|
|
86
|
+
round_dir=round_dir,
|
|
87
|
+
manifest_path=round_dir / "manifest.json",
|
|
88
|
+
summary_path=round_dir / "summary.md",
|
|
89
|
+
)
|
|
90
|
+
return RoundResult(
|
|
91
|
+
round_idx=round_idx,
|
|
92
|
+
snapshot=snapshot,
|
|
93
|
+
dispatch_result=empty_dispatch,
|
|
94
|
+
synth_result=None,
|
|
95
|
+
test_result=None,
|
|
96
|
+
test_skip_reason=None,
|
|
97
|
+
test_worktree_error=None,
|
|
98
|
+
producer_result=None,
|
|
99
|
+
round_exit_code=SUCCESS,
|
|
100
|
+
artifacts=artifacts,
|
|
101
|
+
check_results=[],
|
|
102
|
+
no_changes_to_review=True,
|
|
103
|
+
filtered_diff_bytes=0,
|
|
104
|
+
raw_diff_bytes=raw_diff_bytes,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _fail_closed_refusal_result(
|
|
109
|
+
*,
|
|
110
|
+
round_idx: int,
|
|
111
|
+
snapshot: Snapshot,
|
|
112
|
+
round_dir: Path,
|
|
113
|
+
config: SyncadeConfig,
|
|
114
|
+
started_at: datetime,
|
|
115
|
+
resumed_under_drift: bool,
|
|
116
|
+
unidentifiable: list[str],
|
|
117
|
+
logger: Logger,
|
|
118
|
+
) -> RoundResult:
|
|
119
|
+
"""Refusal result for a diff with sections that cannot be identified (D2, PR-h-02d).
|
|
120
|
+
|
|
121
|
+
``filter_diff_for_reviewer`` dropped one or more sections fail-closed because their
|
|
122
|
+
headers could not be parsed or decoded. Treating such a diff as "nothing to review"
|
|
123
|
+
would ship a change BECAUSE we could not read it — the exact false-SHIP path
|
|
124
|
+
PR-h-01 closed, reintroduced through a different door. Exit 60 (WORKTREE_ERROR)
|
|
125
|
+
names the dropped headers and refuses before any subprocess is dispatched.
|
|
126
|
+
"""
|
|
127
|
+
headers = "\n ".join(unidentifiable)
|
|
128
|
+
logger.event(
|
|
129
|
+
f"refusing run: {len(unidentifiable)} diff section(s) had unidentifiable headers "
|
|
130
|
+
f"(fail-closed per D2 — cannot determine whether they are repo-context or real change); "
|
|
131
|
+
f"unidentifiable headers:\n {headers}",
|
|
132
|
+
error=True,
|
|
133
|
+
)
|
|
134
|
+
# Write a durable named artifact so the refusal cause is preserved after the
|
|
135
|
+
# run completes. manifest.json also carries diff_filter_refusal_headers for
|
|
136
|
+
# machine-readable access.
|
|
137
|
+
refused_path = round_dir / "diff-refused.txt"
|
|
138
|
+
refused_path.write_text(
|
|
139
|
+
f"Syncade refused this run (exit 60, diff_malformed).\n\n"
|
|
140
|
+
f"{len(unidentifiable)} diff section(s) could not be identified "
|
|
141
|
+
f"(D2, PR-h-02d):\n\n"
|
|
142
|
+
+ "\n".join(f" {h}" for h in unidentifiable)
|
|
143
|
+
+ "\n\nSee summary.md for next steps.\n",
|
|
144
|
+
encoding="utf-8",
|
|
145
|
+
)
|
|
146
|
+
return _persist_refusal(
|
|
147
|
+
round_idx=round_idx,
|
|
148
|
+
snapshot=snapshot,
|
|
149
|
+
round_dir=round_dir,
|
|
150
|
+
config=config,
|
|
151
|
+
started_at=started_at,
|
|
152
|
+
resumed_under_drift=resumed_under_drift,
|
|
153
|
+
fail_closed_headers=unidentifiable,
|
|
154
|
+
oversize_diff_bytes=None,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _persist_refusal(
|
|
159
|
+
*,
|
|
160
|
+
round_idx: int,
|
|
161
|
+
snapshot: Snapshot,
|
|
162
|
+
round_dir: Path,
|
|
163
|
+
config: SyncadeConfig,
|
|
164
|
+
started_at: datetime,
|
|
165
|
+
resumed_under_drift: bool,
|
|
166
|
+
fail_closed_headers: list[str] | None,
|
|
167
|
+
oversize_diff_bytes: int | None,
|
|
168
|
+
oversize_prompt_chars: int | None = None,
|
|
169
|
+
) -> RoundResult:
|
|
170
|
+
"""Shared tail for every before-dispatch refusal: exit 60, zero reviewers, artifacts.
|
|
171
|
+
|
|
172
|
+
The three refusals differ only in WHY (which discriminator is set) — everything after
|
|
173
|
+
that is identical, and duplicating it is how the three would drift apart.
|
|
174
|
+
"""
|
|
175
|
+
if fail_closed_headers is not None:
|
|
176
|
+
_refusal_reason = "diff_malformed"
|
|
177
|
+
elif oversize_diff_bytes is not None:
|
|
178
|
+
_refusal_reason = "diff_too_large"
|
|
179
|
+
elif oversize_prompt_chars is not None:
|
|
180
|
+
_refusal_reason = "prompt_too_large"
|
|
181
|
+
else:
|
|
182
|
+
_refusal_reason = None
|
|
183
|
+
empty_dispatch = DispatchResult(results=[], total_duration_seconds=0.0)
|
|
184
|
+
persist_round_manifest(
|
|
185
|
+
round_dir,
|
|
186
|
+
snapshot,
|
|
187
|
+
empty_dispatch,
|
|
188
|
+
WORKTREE_ERROR,
|
|
189
|
+
started_at,
|
|
190
|
+
None,
|
|
191
|
+
None,
|
|
192
|
+
None,
|
|
193
|
+
round_idx=round_idx,
|
|
194
|
+
producer_result=None,
|
|
195
|
+
producer_provider=config.producer.provider,
|
|
196
|
+
producer_model=config.producer.model,
|
|
197
|
+
check_results=[],
|
|
198
|
+
diff_filter_refusal_headers=fail_closed_headers,
|
|
199
|
+
# Pass measured sizes when they are known (diff_too_large path).
|
|
200
|
+
# The "only the path that measured it writes it" rule from PR-h-03.5 applies:
|
|
201
|
+
# fail_closed_headers path has no measured bytes (we refuse before measuring),
|
|
202
|
+
# so filtered_diff_bytes stays None there.
|
|
203
|
+
filtered_diff_bytes=oversize_diff_bytes,
|
|
204
|
+
raw_diff_bytes=(
|
|
205
|
+
len((snapshot.diff_text or "").encode("utf-8"))
|
|
206
|
+
if oversize_diff_bytes is not None
|
|
207
|
+
else None
|
|
208
|
+
),
|
|
209
|
+
oversize_prompt_chars=oversize_prompt_chars,
|
|
210
|
+
refusal_reason=_refusal_reason,
|
|
211
|
+
)
|
|
212
|
+
persist_run_summary(
|
|
213
|
+
round_dir,
|
|
214
|
+
snapshot,
|
|
215
|
+
empty_dispatch,
|
|
216
|
+
WORKTREE_ERROR,
|
|
217
|
+
started_at,
|
|
218
|
+
None,
|
|
219
|
+
None,
|
|
220
|
+
None,
|
|
221
|
+
resumed_under_drift=resumed_under_drift,
|
|
222
|
+
check_results=[],
|
|
223
|
+
fail_closed_headers=fail_closed_headers,
|
|
224
|
+
oversize_diff_bytes=oversize_diff_bytes,
|
|
225
|
+
oversize_prompt_chars=oversize_prompt_chars,
|
|
226
|
+
)
|
|
227
|
+
artifacts = RoundArtifacts(
|
|
228
|
+
round_idx=round_idx,
|
|
229
|
+
round_dir=round_dir,
|
|
230
|
+
manifest_path=round_dir / "manifest.json",
|
|
231
|
+
summary_path=round_dir / "summary.md",
|
|
232
|
+
)
|
|
233
|
+
return RoundResult(
|
|
234
|
+
round_idx=round_idx,
|
|
235
|
+
snapshot=snapshot,
|
|
236
|
+
dispatch_result=empty_dispatch,
|
|
237
|
+
synth_result=None,
|
|
238
|
+
test_result=None,
|
|
239
|
+
test_skip_reason=None,
|
|
240
|
+
test_worktree_error=None,
|
|
241
|
+
producer_result=None,
|
|
242
|
+
round_exit_code=WORKTREE_ERROR,
|
|
243
|
+
artifacts=artifacts,
|
|
244
|
+
check_results=[],
|
|
245
|
+
fail_closed_headers=fail_closed_headers,
|
|
246
|
+
oversize_diff_bytes=oversize_diff_bytes,
|
|
247
|
+
oversize_prompt_chars=oversize_prompt_chars,
|
|
248
|
+
# Mirror the sizes passed to persist_round_manifest so persist_loop_manifest
|
|
249
|
+
# (which reads from RoundResult, not from the on-disk manifest) also carries
|
|
250
|
+
# them. Only populated on the diff_too_large path where they were measured.
|
|
251
|
+
filtered_diff_bytes=oversize_diff_bytes,
|
|
252
|
+
raw_diff_bytes=(
|
|
253
|
+
len((snapshot.diff_text or "").encode("utf-8"))
|
|
254
|
+
if oversize_diff_bytes is not None
|
|
255
|
+
else None
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _diff_too_large_result(
|
|
261
|
+
*,
|
|
262
|
+
round_idx: int,
|
|
263
|
+
snapshot: Snapshot,
|
|
264
|
+
round_dir: Path,
|
|
265
|
+
config: SyncadeConfig,
|
|
266
|
+
started_at: datetime,
|
|
267
|
+
resumed_under_drift: bool,
|
|
268
|
+
diff_bytes: int,
|
|
269
|
+
logger: Logger,
|
|
270
|
+
) -> RoundResult:
|
|
271
|
+
"""Refusal for a reviewer-facing diff over ``[loop] max_diff_bytes`` (PR-h-02e D2).
|
|
272
|
+
|
|
273
|
+
Refuses rather than truncating: a verdict on a deliberately partial diff is a verdict
|
|
274
|
+
on the wrong code, which is the failure PR-h-02 exists to prevent. Refuses rather than
|
|
275
|
+
warning-and-proceeding: that is today's behaviour with extra text.
|
|
276
|
+
|
|
277
|
+
The point of refusing HERE is that the alternative is not "no cap" but an opaque
|
|
278
|
+
provider error after the operator has already paid for the run — measured, `codex exec`
|
|
279
|
+
returns `input_too_large` naming only a character count, and before PR-h-field-01 item 1 the
|
|
280
|
+
same input produced a bare `[Errno 7] Argument list too long` with no cause at all.
|
|
281
|
+
"""
|
|
282
|
+
cap = config.loop.max_diff_bytes
|
|
283
|
+
over = diff_bytes - cap
|
|
284
|
+
logger.event(
|
|
285
|
+
f"refusing run: the reviewer-facing diff is {diff_bytes:,} bytes, over the "
|
|
286
|
+
f"[loop] max_diff_bytes ceiling of {cap:,} by {over:,}. Narrow --base, split the "
|
|
287
|
+
f"PR, or raise the ceiling. Refused before dispatching any reviewer, so nothing "
|
|
288
|
+
f"was spent.",
|
|
289
|
+
error=True,
|
|
290
|
+
)
|
|
291
|
+
(round_dir / "diff-refused.txt").write_text(
|
|
292
|
+
f"Syncade refused this run (exit 60, diff_too_large).\n\n"
|
|
293
|
+
f"Reviewer-facing diff: {diff_bytes:,} bytes\n"
|
|
294
|
+
f"[loop] max_diff_bytes: {cap:,} bytes\n"
|
|
295
|
+
f"Over by: {over:,} bytes\n\n"
|
|
296
|
+
f"This is measured AFTER repo-context stripping and binary elision — it is what a "
|
|
297
|
+
f"reviewer would actually be handed.\n\n"
|
|
298
|
+
f"Next steps: narrow --base to a smaller range, split the PR, or raise "
|
|
299
|
+
f"[loop] max_diff_bytes in .syncade/config.toml.\n",
|
|
300
|
+
encoding="utf-8",
|
|
301
|
+
)
|
|
302
|
+
return _persist_refusal(
|
|
303
|
+
round_idx=round_idx,
|
|
304
|
+
snapshot=snapshot,
|
|
305
|
+
round_dir=round_dir,
|
|
306
|
+
config=config,
|
|
307
|
+
started_at=started_at,
|
|
308
|
+
resumed_under_drift=resumed_under_drift,
|
|
309
|
+
fail_closed_headers=None,
|
|
310
|
+
oversize_diff_bytes=diff_bytes,
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Provider character ceilings, measured live (PR-h-field-01 item 1). The codex ceiling
|
|
315
|
+
# (1,048,576 chars) is the tighter of the two known limits, so it governs cross-provider
|
|
316
|
+
# checks. A prompt below this is safe for both providers currently in use.
|
|
317
|
+
_CODEX_CHAR_CEILING = 1_048_576
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _assembled_prompt_too_large_result(
|
|
321
|
+
*,
|
|
322
|
+
round_idx: int,
|
|
323
|
+
snapshot: Snapshot,
|
|
324
|
+
round_dir: Path,
|
|
325
|
+
config: SyncadeConfig,
|
|
326
|
+
started_at: datetime,
|
|
327
|
+
resumed_under_drift: bool,
|
|
328
|
+
reviewer_name: str,
|
|
329
|
+
prompt_chars: int,
|
|
330
|
+
logger: Logger,
|
|
331
|
+
) -> RoundResult:
|
|
332
|
+
"""Refusal when an assembled reviewer prompt exceeds the provider's character ceiling.
|
|
333
|
+
|
|
334
|
+
The diff-size cap ([loop] max_diff_bytes) gates on the reviewer-facing diff, but a
|
|
335
|
+
large reviewer template or prior-round context can push the full assembled prompt over
|
|
336
|
+
the provider limit while the diff alone stays under the cap. This refuses before
|
|
337
|
+
dispatch so the operator gets syncade's actionable message rather than the provider's
|
|
338
|
+
opaque `input_too_large` error after paying for the round.
|
|
339
|
+
"""
|
|
340
|
+
over = prompt_chars - _CODEX_CHAR_CEILING
|
|
341
|
+
logger.event(
|
|
342
|
+
f"refusing run: the assembled prompt for reviewer {reviewer_name!r} is "
|
|
343
|
+
f"{prompt_chars:,} chars, over the provider ceiling of {_CODEX_CHAR_CEILING:,} "
|
|
344
|
+
f"by {over:,}. Trim the reviewer template or reduce prior-round context. "
|
|
345
|
+
f"Refused before dispatching any reviewer, so nothing was spent.",
|
|
346
|
+
error=True,
|
|
347
|
+
)
|
|
348
|
+
(round_dir / "diff-refused.txt").write_text(
|
|
349
|
+
f"Syncade refused this run (exit 60, prompt_too_large).\n\n"
|
|
350
|
+
f"Assembled prompt for {reviewer_name!r}: {prompt_chars:,} chars\n"
|
|
351
|
+
f"Provider ceiling (codex): {_CODEX_CHAR_CEILING:,} chars\n"
|
|
352
|
+
f"Over by: {over:,} chars\n\n"
|
|
353
|
+
f"The assembled prompt includes the diff, the reviewer template, and any "
|
|
354
|
+
f"prior-round context. Reduce prompt size by narrowing --base, trimming the "
|
|
355
|
+
f"reviewer template, or lowering [loop] max_diff_bytes so the diff contributes "
|
|
356
|
+
f"fewer characters.\n",
|
|
357
|
+
encoding="utf-8",
|
|
358
|
+
)
|
|
359
|
+
return _persist_refusal(
|
|
360
|
+
round_idx=round_idx,
|
|
361
|
+
snapshot=snapshot,
|
|
362
|
+
round_dir=round_dir,
|
|
363
|
+
config=config,
|
|
364
|
+
started_at=started_at,
|
|
365
|
+
resumed_under_drift=resumed_under_drift,
|
|
366
|
+
fail_closed_headers=None,
|
|
367
|
+
oversize_diff_bytes=None,
|
|
368
|
+
oversize_prompt_chars=prompt_chars,
|
|
369
|
+
)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Every gate a round passes BEFORE it is allowed to spend anything.
|
|
2
|
+
|
|
3
|
+
Split out of ``round.py`` (PR-h-field-01): `_run_one_round` had grown to 388 code LOC and a
|
|
4
|
+
reviewer flagged it in all five dogfood rounds, tying it to "coordination failures around
|
|
5
|
+
prompt rendering, guard ordering, and persistence". That diagnosis was right, and the
|
|
6
|
+
coupling is the point of this module — **every way a round can refuse before dispatching now
|
|
7
|
+
lives in one place, in priority order**, next to the result constructors in
|
|
8
|
+
``round_no_changes``.
|
|
9
|
+
|
|
10
|
+
The order is load-bearing and is the reason these belong together:
|
|
11
|
+
|
|
12
|
+
1. **unidentifiable headers** (D2) before **known-empty** (D1/D3) — a diff with both is a
|
|
13
|
+
refusal, not a no-op: we could not read it, so we cannot ship it.
|
|
14
|
+
2. **known-empty** before the size gates — nothing to review beats too much to review.
|
|
15
|
+
3. **diff size** before **prompt render** — no point rendering a prompt for a diff already
|
|
16
|
+
over the cap.
|
|
17
|
+
4. **prompt size** last, because it is the only gate that needs the rendered prompt.
|
|
18
|
+
|
|
19
|
+
Everything here runs before any worktree is provisioned, so a refusal costs nothing. That
|
|
20
|
+
is the whole claim: five dogfood rounds' worth of blockers were about a refusal reaching
|
|
21
|
+
this point correctly and being reported honestly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from syncade.config import SyncadeConfig
|
|
31
|
+
from syncade.diff_filter import (
|
|
32
|
+
concealed_destinations,
|
|
33
|
+
elide_binary_hunks,
|
|
34
|
+
filter_diff_for_reviewer,
|
|
35
|
+
unidentifiable_sections,
|
|
36
|
+
)
|
|
37
|
+
from syncade.logging import Logger
|
|
38
|
+
from syncade.snapshot import Snapshot
|
|
39
|
+
|
|
40
|
+
from .results import RoundResult
|
|
41
|
+
from .reviewer_template_failure import _reviewer_template_failure_result
|
|
42
|
+
from .round_no_changes import (
|
|
43
|
+
_CODEX_CHAR_CEILING,
|
|
44
|
+
_assembled_prompt_too_large_result,
|
|
45
|
+
_diff_too_large_result,
|
|
46
|
+
_fail_closed_refusal_result,
|
|
47
|
+
_no_changes_to_review_result,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class ReadyToDispatch:
|
|
53
|
+
"""The pre-dispatch gates passed; here is what dispatch needs."""
|
|
54
|
+
|
|
55
|
+
prompt_arg: str | dict[str, str]
|
|
56
|
+
pr_doc_ref: str
|
|
57
|
+
pr_doc_is_out_of_repo: bool
|
|
58
|
+
filtered_for_check: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_predispatch_gates(
|
|
62
|
+
*,
|
|
63
|
+
repo_root: Path,
|
|
64
|
+
snapshot: Snapshot,
|
|
65
|
+
config: SyncadeConfig,
|
|
66
|
+
round_idx: int,
|
|
67
|
+
run_dir: Path,
|
|
68
|
+
round_dir: Path,
|
|
69
|
+
pr_doc_path: Path,
|
|
70
|
+
started_at: datetime,
|
|
71
|
+
resumed_under_drift: bool,
|
|
72
|
+
logger: Logger,
|
|
73
|
+
build_prompt,
|
|
74
|
+
) -> RoundResult | ReadyToDispatch:
|
|
75
|
+
"""Run every pre-dispatch gate. A :class:`RoundResult` means REFUSED; otherwise proceed.
|
|
76
|
+
|
|
77
|
+
``build_prompt`` is injected rather than imported to keep this module free of a cycle
|
|
78
|
+
back into ``round``; it is ``round._build_reviewer_prompt``.
|
|
79
|
+
"""
|
|
80
|
+
# --- Pre-dispatch diff checks (PR-h-02d) ------------------------
|
|
81
|
+
# Both checks run before any worktree is provisioned — zero subprocesses
|
|
82
|
+
# is the whole point (acceptance claim 1). Priority: D2 (unidentifiable)
|
|
83
|
+
# before D1/D3 (known-empty), because a diff with BOTH conditions is a
|
|
84
|
+
# refusal, not a no-changes exit (we could not read it → cannot ship it).
|
|
85
|
+
# Unidentifiable headers are only a problem when strip_repo_context_files is
|
|
86
|
+
# non-empty: with an empty strip list every section is kept byte-for-byte, so
|
|
87
|
+
# an undecodable header cannot hide any change from the reviewer.
|
|
88
|
+
_unidentifiable = (
|
|
89
|
+
unidentifiable_sections(snapshot.diff_text)
|
|
90
|
+
if config.review.strip_repo_context_files
|
|
91
|
+
else []
|
|
92
|
+
)
|
|
93
|
+
if _unidentifiable:
|
|
94
|
+
return _fail_closed_refusal_result(
|
|
95
|
+
round_idx=round_idx,
|
|
96
|
+
snapshot=snapshot,
|
|
97
|
+
round_dir=round_dir,
|
|
98
|
+
config=config,
|
|
99
|
+
started_at=started_at,
|
|
100
|
+
resumed_under_drift=resumed_under_drift,
|
|
101
|
+
unidentifiable=_unidentifiable,
|
|
102
|
+
logger=logger,
|
|
103
|
+
)
|
|
104
|
+
_filtered_for_check, _elided_binaries = elide_binary_hunks(
|
|
105
|
+
filter_diff_for_reviewer(snapshot.diff_text, config.review.strip_repo_context_files)
|
|
106
|
+
)
|
|
107
|
+
if _elided_binaries:
|
|
108
|
+
_shown = ", ".join(_elided_binaries[:5])
|
|
109
|
+
_more = f", … ({len(_elided_binaries) - 5} more)" if len(_elided_binaries) > 5 else ""
|
|
110
|
+
logger.event(
|
|
111
|
+
f"omitted binary file content from the reviewer diff: "
|
|
112
|
+
f"{len(_elided_binaries)} file(s) — {_shown}{_more}. "
|
|
113
|
+
f"Paths and withheld byte counts are disclosed in the diff itself."
|
|
114
|
+
)
|
|
115
|
+
# A drop is only legitimate when the DESTINATION is a strip target. A boundary
|
|
116
|
+
# rename (`git mv CLAUDE.md app.py`) empties the filtered diff while concealing a
|
|
117
|
+
# path a reviewer must see — concluding "nothing to review" there is a false SHIP
|
|
118
|
+
# over a real change (PR-h-02c.5).
|
|
119
|
+
_concealed = concealed_destinations(snapshot.diff_text, config.review.strip_repo_context_files)
|
|
120
|
+
if snapshot.base_oid is not None and not _filtered_for_check and not _concealed:
|
|
121
|
+
# D1: base resolved + empty diff (no real changes).
|
|
122
|
+
# D3: all sections were legitimate repo-context — same outcome.
|
|
123
|
+
logger.event(
|
|
124
|
+
"diff is empty after filtering — base resolved but no reviewable changes; "
|
|
125
|
+
"terminating without dispatching reviewers (no_changes_to_review)"
|
|
126
|
+
)
|
|
127
|
+
return _no_changes_to_review_result(
|
|
128
|
+
round_idx=round_idx,
|
|
129
|
+
snapshot=snapshot,
|
|
130
|
+
round_dir=round_dir,
|
|
131
|
+
config=config,
|
|
132
|
+
started_at=started_at,
|
|
133
|
+
resumed_under_drift=resumed_under_drift,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
# Diff-size ceiling (PR-h-02e D2, landed in PR-h-field-01). Measured on the REVIEWER-FACING
|
|
137
|
+
# text — after stripping and binary elision — because that is what reaches the model's
|
|
138
|
+
# context; capping the raw diff would threshold bytes nobody sends. Checked here, after
|
|
139
|
+
# the known-empty classification and before any worktree or subprocess, so a refusal
|
|
140
|
+
# costs nothing. Without it the ceiling is enforced by the provider instead: `codex exec`
|
|
141
|
+
# returns `input_too_large` naming only a character count, after the operator has
|
|
142
|
+
# already confirmed the run.
|
|
143
|
+
_reviewed_bytes = len(_filtered_for_check.encode("utf-8"))
|
|
144
|
+
if _reviewed_bytes > config.loop.max_diff_bytes:
|
|
145
|
+
return _diff_too_large_result(
|
|
146
|
+
round_idx=round_idx,
|
|
147
|
+
snapshot=snapshot,
|
|
148
|
+
round_dir=round_dir,
|
|
149
|
+
config=config,
|
|
150
|
+
started_at=started_at,
|
|
151
|
+
resumed_under_drift=resumed_under_drift,
|
|
152
|
+
diff_bytes=_reviewed_bytes,
|
|
153
|
+
logger=logger,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# --- Render reviewer prompt(s) ----------------------------------
|
|
157
|
+
# Template load + render runs BEFORE any worktree is provisioned. A
|
|
158
|
+
# malformed operator override (`.syncade/templates/reviewer.md` with an
|
|
159
|
+
# unknown placeholder) raises out of the render. Mirror the producer/synth
|
|
160
|
+
# template-render contract: catch it, record the phase failure, persist the
|
|
161
|
+
# round artifacts, and return a phase-failure RoundResult — rather than
|
|
162
|
+
# letting the exception escape run_review with no manifest/summary written.
|
|
163
|
+
try:
|
|
164
|
+
prompt_arg, pr_doc_ref, pr_doc_is_out_of_repo = build_prompt(
|
|
165
|
+
repo_root=repo_root,
|
|
166
|
+
snapshot=snapshot,
|
|
167
|
+
config=config,
|
|
168
|
+
round_idx=round_idx,
|
|
169
|
+
run_dir=run_dir,
|
|
170
|
+
pr_doc_path=pr_doc_path,
|
|
171
|
+
)
|
|
172
|
+
except (KeyError, ValueError, FileNotFoundError) as exc:
|
|
173
|
+
logger.event(f"reviewer template render failed: {exc}", error=True)
|
|
174
|
+
return _reviewer_template_failure_result(
|
|
175
|
+
round_idx=round_idx,
|
|
176
|
+
snapshot=snapshot,
|
|
177
|
+
round_dir=round_dir,
|
|
178
|
+
config=config,
|
|
179
|
+
started_at=started_at,
|
|
180
|
+
resumed_under_drift=resumed_under_drift,
|
|
181
|
+
error=exc,
|
|
182
|
+
filtered_diff_bytes=len(_filtered_for_check.encode("utf-8")),
|
|
183
|
+
raw_diff_bytes=len((snapshot.diff_text or "").encode("utf-8")),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# --- Assembled-prompt size guard --------------------------------
|
|
187
|
+
# The diff-size cap ([loop] max_diff_bytes) gates on the reviewer-facing diff, but
|
|
188
|
+
# the assembled prompt also includes the template and prior-round context. A verbose
|
|
189
|
+
# round-0 reviewer or a large custom template can push the full prompt over the codex
|
|
190
|
+
# ceiling (1,048,576 chars) while the diff alone stays under the cap. Refuse here,
|
|
191
|
+
# before any worktree is provisioned, so the operator gets a useful message rather
|
|
192
|
+
# than an opaque provider-side `input_too_large` error after paying for the round.
|
|
193
|
+
for _reviewer_name, _prompt_text in prompt_arg.items():
|
|
194
|
+
if len(_prompt_text) > _CODEX_CHAR_CEILING:
|
|
195
|
+
return _assembled_prompt_too_large_result(
|
|
196
|
+
round_idx=round_idx,
|
|
197
|
+
snapshot=snapshot,
|
|
198
|
+
round_dir=round_dir,
|
|
199
|
+
config=config,
|
|
200
|
+
started_at=started_at,
|
|
201
|
+
resumed_under_drift=resumed_under_drift,
|
|
202
|
+
reviewer_name=_reviewer_name,
|
|
203
|
+
prompt_chars=len(_prompt_text),
|
|
204
|
+
logger=logger,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return ReadyToDispatch(
|
|
208
|
+
prompt_arg=prompt_arg,
|
|
209
|
+
pr_doc_ref=pr_doc_ref,
|
|
210
|
+
pr_doc_is_out_of_repo=pr_doc_is_out_of_repo,
|
|
211
|
+
filtered_for_check=_filtered_for_check,
|
|
212
|
+
)
|