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,279 @@
|
|
|
1
|
+
"""Mechanical per-round verdict and phase-failure classification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from syncade.adapters.base import ReviewerInvocationError
|
|
6
|
+
from syncade.adapters.registry import UnknownProviderError
|
|
7
|
+
from syncade.dispatcher import DispatchResult
|
|
8
|
+
from syncade.exit_codes import (
|
|
9
|
+
CLARIFICATION_NEEDED,
|
|
10
|
+
CONFIG_ERROR,
|
|
11
|
+
FINDINGS_PRESENT,
|
|
12
|
+
REVIEWER_FAILURE,
|
|
13
|
+
REVIEWER_OUTPUT_UNPARSEABLE,
|
|
14
|
+
SUCCESS,
|
|
15
|
+
WORKTREE_ERROR,
|
|
16
|
+
)
|
|
17
|
+
from syncade.findings import ReviewerOutputError
|
|
18
|
+
from syncade.process import (
|
|
19
|
+
SubprocessError,
|
|
20
|
+
SubprocessNotFoundError,
|
|
21
|
+
SubprocessTimeoutError,
|
|
22
|
+
)
|
|
23
|
+
from syncade.retry import is_usage_limit_error
|
|
24
|
+
from syncade.synthesis import SynthesizerOutput, SynthesizerOutputError, has_active_blocker
|
|
25
|
+
from syncade.synthesizer import SynthesizerResult
|
|
26
|
+
from syncade.test_runner import TestRunResult, is_blocking_check_subprocess_error
|
|
27
|
+
|
|
28
|
+
from .results import RoundResult, TerminationReason
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def error_is_provider_usage_limit(error: BaseException | None) -> bool:
|
|
32
|
+
"""One actor's error, one question — so every actor asks it the same way."""
|
|
33
|
+
return error is not None and is_usage_limit_error(error)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def round_hit_provider_usage_limit(round_result: RoundResult) -> bool:
|
|
37
|
+
"""Did this round fail because the PROVIDER refused on exhausted quota? (PR-h-field-02)
|
|
38
|
+
|
|
39
|
+
Distinct from ``budget_exceeded``, which is the operator's own configured ceiling. Both
|
|
40
|
+
exit 25 — stop cleanly, resume later — but conflating them would tell someone to raise a
|
|
41
|
+
budget they never hit.
|
|
42
|
+
|
|
43
|
+
Not retried and not permanent: the classifier keeps quota out of ``_TRANSIENT_MARKERS`` on
|
|
44
|
+
purpose, so no attempts are burned against a window that has not moved, and the run stays
|
|
45
|
+
resumable instead of dying at exit 40.
|
|
46
|
+
|
|
47
|
+
Covers the REVIEWERS and the JUDGE. The first version checked only reviewers and the blind
|
|
48
|
+
panel caught it unanimously — the same refusal reaching the judge still died at exit 40. The
|
|
49
|
+
PRODUCER is checked at its own branch in the loop, because it is decided after this one, and
|
|
50
|
+
it is the likeliest of the three to exhaust a limit: 6.6M tokens in a single field round.
|
|
51
|
+
"""
|
|
52
|
+
if any(error_is_provider_usage_limit(r.error) for r in round_result.dispatch_result.results):
|
|
53
|
+
return True
|
|
54
|
+
synth = round_result.synth_result
|
|
55
|
+
return synth is not None and error_is_provider_usage_limit(synth.error)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _classify_phase_failure(round_result: RoundResult) -> TerminationReason:
|
|
59
|
+
"""Map a phase-failure round_exit_code to a termination_reason.
|
|
60
|
+
|
|
61
|
+
Called from the loop terminator when ``round_exit_code`` is
|
|
62
|
+
one of {40, 50, 60, 70}. Inspects the round's per-phase
|
|
63
|
+
outcomes to distinguish which phase actually failed (e.g.
|
|
64
|
+
exit 40 could be reviewer / synth / test-subprocess; exit 70
|
|
65
|
+
could be reviewer-parse / synth-parse).
|
|
66
|
+
"""
|
|
67
|
+
exit_code = round_result.round_exit_code
|
|
68
|
+
if exit_code == CONFIG_ERROR:
|
|
69
|
+
return "config_error"
|
|
70
|
+
if exit_code == WORKTREE_ERROR:
|
|
71
|
+
if round_result.fail_closed_headers is not None:
|
|
72
|
+
return "diff_malformed"
|
|
73
|
+
if round_result.oversize_diff_bytes is not None:
|
|
74
|
+
return "diff_too_large"
|
|
75
|
+
if round_result.oversize_prompt_chars is not None:
|
|
76
|
+
return "prompt_too_large"
|
|
77
|
+
return "worktree_error"
|
|
78
|
+
if exit_code == REVIEWER_OUTPUT_UNPARSEABLE:
|
|
79
|
+
return "parse_failure"
|
|
80
|
+
if exit_code == REVIEWER_FAILURE:
|
|
81
|
+
# Three flavors of 40: reviewer subprocess, synth subprocess,
|
|
82
|
+
# test subprocess. Distinguish by which phase has an error.
|
|
83
|
+
if not round_result.dispatch_result.all_succeeded:
|
|
84
|
+
return "reviewer_failure"
|
|
85
|
+
if round_result.synth_result is not None and round_result.synth_result.error is not None:
|
|
86
|
+
return "synth_failure"
|
|
87
|
+
if (
|
|
88
|
+
round_result.test_result is not None
|
|
89
|
+
and round_result.test_result.outcome == "subprocess_error"
|
|
90
|
+
):
|
|
91
|
+
return "test_subprocess_error"
|
|
92
|
+
# A blocking mechanical check whose subprocess could not run is its
|
|
93
|
+
# own phase, distinct from a reviewer subprocess failure.
|
|
94
|
+
if any(
|
|
95
|
+
is_blocking_check_subprocess_error(c.severity, c.outcome)
|
|
96
|
+
for c in round_result.check_results
|
|
97
|
+
):
|
|
98
|
+
return "check_subprocess_error"
|
|
99
|
+
# Fall-through default: treat as reviewer failure.
|
|
100
|
+
return "reviewer_failure"
|
|
101
|
+
# Shouldn't happen — caller filters to {40,50,60,70} before
|
|
102
|
+
# calling. Defensive fallback.
|
|
103
|
+
return "reviewer_failure"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _compute_exit_code(
|
|
107
|
+
dispatch_result: DispatchResult,
|
|
108
|
+
synth_result: SynthesizerResult | None,
|
|
109
|
+
test_result: TestRunResult | None = None,
|
|
110
|
+
blocking_check_results: list[TestRunResult] | None = None,
|
|
111
|
+
) -> int:
|
|
112
|
+
"""Compute the per-round mechanical exit code."""
|
|
113
|
+
failures = dispatch_result.failures
|
|
114
|
+
|
|
115
|
+
for r in failures:
|
|
116
|
+
if isinstance(r.error, UnknownProviderError):
|
|
117
|
+
return CONFIG_ERROR
|
|
118
|
+
|
|
119
|
+
for r in failures:
|
|
120
|
+
if isinstance(r.error, ReviewerOutputError):
|
|
121
|
+
return REVIEWER_OUTPUT_UNPARSEABLE
|
|
122
|
+
|
|
123
|
+
for r in failures:
|
|
124
|
+
if isinstance(
|
|
125
|
+
r.error,
|
|
126
|
+
(
|
|
127
|
+
ReviewerInvocationError,
|
|
128
|
+
SubprocessNotFoundError,
|
|
129
|
+
SubprocessTimeoutError,
|
|
130
|
+
SubprocessError,
|
|
131
|
+
),
|
|
132
|
+
):
|
|
133
|
+
return REVIEWER_FAILURE
|
|
134
|
+
|
|
135
|
+
if failures:
|
|
136
|
+
return REVIEWER_FAILURE
|
|
137
|
+
|
|
138
|
+
successes = dispatch_result.successes
|
|
139
|
+
if not successes:
|
|
140
|
+
return CONFIG_ERROR
|
|
141
|
+
|
|
142
|
+
if synth_result is None:
|
|
143
|
+
raise RuntimeError(
|
|
144
|
+
"_compute_exit_code: synth_result is None but every reviewer "
|
|
145
|
+
"succeeded. The orchestrator runs the synthesizer phase "
|
|
146
|
+
"whenever dispatch_result.all_succeeded is True; receiving "
|
|
147
|
+
"synth_result=None on this path means a caller bypassed "
|
|
148
|
+
"run_review's lifecycle."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if isinstance(synth_result.error, SynthesizerOutputError):
|
|
152
|
+
return REVIEWER_OUTPUT_UNPARSEABLE
|
|
153
|
+
if isinstance(
|
|
154
|
+
synth_result.error,
|
|
155
|
+
(
|
|
156
|
+
ReviewerInvocationError,
|
|
157
|
+
SubprocessNotFoundError,
|
|
158
|
+
SubprocessTimeoutError,
|
|
159
|
+
SubprocessError,
|
|
160
|
+
),
|
|
161
|
+
):
|
|
162
|
+
return REVIEWER_FAILURE
|
|
163
|
+
if synth_result.error is not None:
|
|
164
|
+
return REVIEWER_FAILURE
|
|
165
|
+
|
|
166
|
+
if synth_result.output is None:
|
|
167
|
+
raise RuntimeError(
|
|
168
|
+
"_compute_exit_code: synth_result has neither output nor "
|
|
169
|
+
"error — SynthesizerResult contract violated"
|
|
170
|
+
)
|
|
171
|
+
# Fold blocking mechanical-check results in alongside the test leg. Advisory
|
|
172
|
+
# results are never passed in by the caller and cannot gate the verdict.
|
|
173
|
+
mechanical = ([test_result] if test_result is not None else []) + list(
|
|
174
|
+
blocking_check_results or []
|
|
175
|
+
)
|
|
176
|
+
# A subprocess_error means the harness could not run, so it outranks every
|
|
177
|
+
# determinate verdict, including an active synth blocker.
|
|
178
|
+
if any(m.outcome == "subprocess_error" for m in mechanical):
|
|
179
|
+
return REVIEWER_FAILURE
|
|
180
|
+
|
|
181
|
+
if has_active_blocker(synth_result.output):
|
|
182
|
+
return FINDINGS_PRESENT
|
|
183
|
+
|
|
184
|
+
# A gate that ran and FAILED (a real defect → exit 30) is the same
|
|
185
|
+
# determinate NO-SHIP signal as a synth blocker; checked after it.
|
|
186
|
+
if any(m.outcome == "failed" for m in mechanical):
|
|
187
|
+
return FINDINGS_PRESENT
|
|
188
|
+
|
|
189
|
+
if _multi_reviewer_blockers_all_deactivated(dispatch_result):
|
|
190
|
+
return CLARIFICATION_NEEDED
|
|
191
|
+
|
|
192
|
+
return SUCCESS
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _multi_reviewer_blockers_all_deactivated(dispatch_result: DispatchResult) -> bool:
|
|
196
|
+
"""True when two or more DISTINCT reviewers each raised at least one
|
|
197
|
+
blocker-severity finding and none survived as an active blocker.
|
|
198
|
+
|
|
199
|
+
PR-h-01 increment D — the paraphrase guard, and the reason it is shaped
|
|
200
|
+
this way. The unanimous-blocker rule and the exact-duplicate split guard
|
|
201
|
+
both need to know that two reviewers named the SAME concern. Two reviewers
|
|
202
|
+
describing one bug in different words is the normal case — consolidating
|
|
203
|
+
that is the synthesizer's whole job — so the synthesizer can emit them as
|
|
204
|
+
two single-reviewer findings, dismiss each on its own merits, and neither
|
|
205
|
+
guard fires. ``has_active_blocker`` then sees nothing and the round is a
|
|
206
|
+
clean SHIP.
|
|
207
|
+
|
|
208
|
+
Semantic identity has no cheap correct implementation, and a similarity
|
|
209
|
+
heuristic manufactures FALSE locks — the worst possible failure for a tool
|
|
210
|
+
whose value is that its refusals mean something. So this does not attempt
|
|
211
|
+
to decide whether two findings are the same concern. It asks a question
|
|
212
|
+
with a mechanical answer: did multiple independent reviewers each say
|
|
213
|
+
"blocker", and did every one of those get deactivated?
|
|
214
|
+
|
|
215
|
+
That shape is not necessarily wrong — but it is not a verdict a machine
|
|
216
|
+
should render silently, so it escalates to exit 10 (decision needed) rather
|
|
217
|
+
than either shipping or blocking. It cannot false-lock: the worst case is a
|
|
218
|
+
human being asked to look. Callers reach this only when
|
|
219
|
+
``has_active_blocker`` is already False, so "none survived" needs no
|
|
220
|
+
separate check.
|
|
221
|
+
|
|
222
|
+
Deliberately silent when: only one reviewer raised a blocker (the
|
|
223
|
+
synthesizer's dismissal authority over a single-source blocker is
|
|
224
|
+
intentional), no reviewer raised one, or any blocker is still active (that
|
|
225
|
+
round is already exit 30).
|
|
226
|
+
"""
|
|
227
|
+
reviewers_with_blockers = {
|
|
228
|
+
r.reviewer_name
|
|
229
|
+
for r in dispatch_result.successes
|
|
230
|
+
if r.output is not None and any(f.severity == "blocker" for f in r.output.findings)
|
|
231
|
+
}
|
|
232
|
+
return len(reviewers_with_blockers) >= 2
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def deactivated_blocker_details(
|
|
236
|
+
dispatch_result: DispatchResult,
|
|
237
|
+
synth_output: SynthesizerOutput,
|
|
238
|
+
) -> list[tuple[str, str, str]]:
|
|
239
|
+
"""``(reviewer_name, verbatim_finding_text, disposition)`` for every source
|
|
240
|
+
blocker, for the exit-10 operator document.
|
|
241
|
+
|
|
242
|
+
The text is the REVIEWER's own, read from the parsed reviewer output rather
|
|
243
|
+
than from synthesizer-supplied provenance — the operator is being asked to
|
|
244
|
+
judge the synthesizer, so quoting the synthesizer back at them would beg the
|
|
245
|
+
question. (Increment C makes the two provably equal anyway; reading the
|
|
246
|
+
source directly means this stays true if that check is ever relaxed.)
|
|
247
|
+
|
|
248
|
+
``disposition`` reports what the synthesizer did with it, resolved by
|
|
249
|
+
matching provenance on ``(reviewer_name, original_index)``. A source blocker
|
|
250
|
+
with no consolidated finding at all is reported as dropped, which is its own
|
|
251
|
+
kind of answer.
|
|
252
|
+
"""
|
|
253
|
+
details: list[tuple[str, str, str]] = []
|
|
254
|
+
for r in dispatch_result.successes:
|
|
255
|
+
if r.output is None:
|
|
256
|
+
continue
|
|
257
|
+
for idx, source in enumerate(r.output.findings):
|
|
258
|
+
if source.severity != "blocker":
|
|
259
|
+
continue
|
|
260
|
+
details.append((r.reviewer_name, source.finding, _disposition(synth_output, r, idx)))
|
|
261
|
+
return details
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _disposition(synth_output: SynthesizerOutput, reviewer, idx: int) -> str:
|
|
265
|
+
for consolidated in synth_output.consolidated_findings:
|
|
266
|
+
if not any(
|
|
267
|
+
p.reviewer_name == reviewer.reviewer_name and p.original_index == idx
|
|
268
|
+
for p in consolidated.provenance
|
|
269
|
+
):
|
|
270
|
+
continue
|
|
271
|
+
if consolidated.dismissed:
|
|
272
|
+
rationale = (consolidated.dismissal_rationale or "").strip()
|
|
273
|
+
return f"dismissed — {rationale}" if rationale else "dismissed"
|
|
274
|
+
if consolidated.severity != "blocker":
|
|
275
|
+
rationale = (consolidated.severity_change_rationale or "").strip()
|
|
276
|
+
downgrade = f"downgraded to {consolidated.severity}"
|
|
277
|
+
return f"{downgrade} — {rationale}" if rationale else downgrade
|
|
278
|
+
return f"kept at {consolidated.severity}"
|
|
279
|
+
return "dropped — no consolidated finding carries provenance for it"
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""On-disk persistence of dispatch + synthesizer results.
|
|
2
|
+
|
|
3
|
+
Materializes a :class:`~syncade.dispatcher.DispatchResult` and
|
|
4
|
+
:class:`~syncade.synthesizer.SynthesizerResult` into the
|
|
5
|
+
``.syncade/runs/<run-id>/round-N/`` layout the PRD specifies. The
|
|
6
|
+
orchestrator is the only production caller; tests construct
|
|
7
|
+
synthesized inputs directly so they can exercise persistence without
|
|
8
|
+
spawning real subprocesses.
|
|
9
|
+
|
|
10
|
+
File layout per reviewer (one set per ``ReviewerRunResult``):
|
|
11
|
+
|
|
12
|
+
- ``<round_dir>/<name>.stdout`` — raw subprocess stdout (always
|
|
13
|
+
written; empty when no
|
|
14
|
+
subprocess ran)
|
|
15
|
+
- ``<round_dir>/<name>.stderr`` — raw subprocess stderr (same)
|
|
16
|
+
- ``<round_dir>/<name>.parsed.json`` — :class:`~syncade.findings.ReviewerOutput` as JSON
|
|
17
|
+
(success path only)
|
|
18
|
+
- ``<round_dir>/<name>.error.txt`` — exception class + message
|
|
19
|
+
+ traceback (failure path
|
|
20
|
+
only)
|
|
21
|
+
|
|
22
|
+
This adds synthesizer artifacts:
|
|
23
|
+
|
|
24
|
+
- ``<round_dir>/synthesizer.stdout`` — raw codex stdout
|
|
25
|
+
- ``<round_dir>/synthesizer.stderr`` — raw codex stderr
|
|
26
|
+
- ``<round_dir>/synthesizer.parsed.json`` — :class:`~syncade.synthesis.SynthesizerOutput`
|
|
27
|
+
as JSON (success path)
|
|
28
|
+
- ``<round_dir>/synthesizer.error.txt`` — exception + traceback
|
|
29
|
+
(failure path)
|
|
30
|
+
- ``<round_dir>/findings.md`` — operator-facing
|
|
31
|
+
consolidated review
|
|
32
|
+
report (only when synth
|
|
33
|
+
succeeded)
|
|
34
|
+
|
|
35
|
+
This adds test re-run artifacts (opt-in; written only when the
|
|
36
|
+
operator configured ``[loop] test_command`` AND every prior phase
|
|
37
|
+
succeeded AND the synthesizer was clean):
|
|
38
|
+
|
|
39
|
+
- ``<round_dir>/test-run.stdout`` — captured test command
|
|
40
|
+
stdout
|
|
41
|
+
- ``<round_dir>/test-run.stderr`` — captured test command
|
|
42
|
+
stderr
|
|
43
|
+
- ``<round_dir>/test-run.exit-code.txt`` — one-line integer (or
|
|
44
|
+
``-1\\n`` for the
|
|
45
|
+
subprocess-error path)
|
|
46
|
+
so manifest-readers
|
|
47
|
+
can grab it without
|
|
48
|
+
parsing JSON
|
|
49
|
+
|
|
50
|
+
Plus one file per round:
|
|
51
|
+
|
|
52
|
+
- ``<round_dir>/manifest.json`` — round-level summary written
|
|
53
|
+
by :func:`persist_round_manifest`
|
|
54
|
+
- ``<round_dir>/summary.md`` — human-readable dashboard
|
|
55
|
+
written by
|
|
56
|
+
:func:`persist_run_summary`
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
from ._markdown import (
|
|
62
|
+
_md_command_lines as _md_command_lines,
|
|
63
|
+
)
|
|
64
|
+
from ._markdown import (
|
|
65
|
+
_md_inline_code as _md_inline_code,
|
|
66
|
+
)
|
|
67
|
+
from .checks import (
|
|
68
|
+
CheckArtifactPaths as CheckArtifactPaths,
|
|
69
|
+
)
|
|
70
|
+
from .checks import (
|
|
71
|
+
persist_check_result as persist_check_result,
|
|
72
|
+
)
|
|
73
|
+
from .decision_needed import (
|
|
74
|
+
DECISION_NEEDED_FILENAME as DECISION_NEEDED_FILENAME,
|
|
75
|
+
)
|
|
76
|
+
from .decision_needed import (
|
|
77
|
+
OPERATOR_DECISION_FILENAME as OPERATOR_DECISION_FILENAME,
|
|
78
|
+
)
|
|
79
|
+
from .decision_needed import (
|
|
80
|
+
persist_deactivated_blockers_decision_needed as persist_deactivated_blockers_decision_needed,
|
|
81
|
+
)
|
|
82
|
+
from .decision_needed import (
|
|
83
|
+
persist_decision_needed as persist_decision_needed,
|
|
84
|
+
)
|
|
85
|
+
from .decision_needed import (
|
|
86
|
+
read_operator_decision as read_operator_decision,
|
|
87
|
+
)
|
|
88
|
+
from .findings_md import (
|
|
89
|
+
persist_current_findings_md as persist_current_findings_md,
|
|
90
|
+
)
|
|
91
|
+
from .findings_md import (
|
|
92
|
+
persist_findings_md as persist_findings_md,
|
|
93
|
+
)
|
|
94
|
+
from .handoff import (
|
|
95
|
+
persist_handoff as persist_handoff,
|
|
96
|
+
)
|
|
97
|
+
from .last_reviewed import (
|
|
98
|
+
LAST_REVIEWED_FILENAME as LAST_REVIEWED_FILENAME,
|
|
99
|
+
)
|
|
100
|
+
from .last_reviewed import (
|
|
101
|
+
persist_last_reviewed as persist_last_reviewed,
|
|
102
|
+
)
|
|
103
|
+
from .last_reviewed import (
|
|
104
|
+
read_last_reviewed as read_last_reviewed,
|
|
105
|
+
)
|
|
106
|
+
from .loop_manifest import (
|
|
107
|
+
persist_loop_manifest as persist_loop_manifest,
|
|
108
|
+
)
|
|
109
|
+
from .loop_summary import (
|
|
110
|
+
persist_loop_summary as persist_loop_summary,
|
|
111
|
+
)
|
|
112
|
+
from .producer import (
|
|
113
|
+
ProducerArtifactPaths as ProducerArtifactPaths,
|
|
114
|
+
)
|
|
115
|
+
from .producer import (
|
|
116
|
+
persist_producer_result as persist_producer_result,
|
|
117
|
+
)
|
|
118
|
+
from .reviewer import (
|
|
119
|
+
persist_dispatch_record as persist_dispatch_record,
|
|
120
|
+
)
|
|
121
|
+
from .reviewer import (
|
|
122
|
+
persist_reviewer_result as persist_reviewer_result,
|
|
123
|
+
)
|
|
124
|
+
from .reviewer import (
|
|
125
|
+
record_child_pid as record_child_pid,
|
|
126
|
+
)
|
|
127
|
+
from .round_manifest import (
|
|
128
|
+
persist_round_manifest as persist_round_manifest,
|
|
129
|
+
)
|
|
130
|
+
from .run_init import (
|
|
131
|
+
RUN_INIT_FILENAME as RUN_INIT_FILENAME,
|
|
132
|
+
)
|
|
133
|
+
from .run_init import (
|
|
134
|
+
persist_run_init as persist_run_init,
|
|
135
|
+
)
|
|
136
|
+
from .run_summary import (
|
|
137
|
+
persist_run_summary as persist_run_summary,
|
|
138
|
+
)
|
|
139
|
+
from .synth import (
|
|
140
|
+
SynthesizerArtifactPaths as SynthesizerArtifactPaths,
|
|
141
|
+
)
|
|
142
|
+
from .synth import (
|
|
143
|
+
persist_synthesizer_result as persist_synthesizer_result,
|
|
144
|
+
)
|
|
145
|
+
from .test_run import (
|
|
146
|
+
TEST_RUN_NAME as TEST_RUN_NAME,
|
|
147
|
+
)
|
|
148
|
+
from .test_run import (
|
|
149
|
+
TestRunArtifactPaths as TestRunArtifactPaths,
|
|
150
|
+
)
|
|
151
|
+
from .test_run import (
|
|
152
|
+
persist_test_run_result as persist_test_run_result,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Public re-export surface. The 2 underscore-prefixed helpers
|
|
156
|
+
# (_md_inline_code, _md_command_lines) are intentionally NOT in
|
|
157
|
+
# __all__ per Python convention, but they remain importable from
|
|
158
|
+
# syncade.persistence for tests that reach into them directly.
|
|
159
|
+
__all__ = [
|
|
160
|
+
"CheckArtifactPaths",
|
|
161
|
+
"DECISION_NEEDED_FILENAME",
|
|
162
|
+
"LAST_REVIEWED_FILENAME",
|
|
163
|
+
"OPERATOR_DECISION_FILENAME",
|
|
164
|
+
"persist_last_reviewed",
|
|
165
|
+
"read_last_reviewed",
|
|
166
|
+
"ProducerArtifactPaths",
|
|
167
|
+
"RUN_INIT_FILENAME",
|
|
168
|
+
"SynthesizerArtifactPaths",
|
|
169
|
+
"TEST_RUN_NAME",
|
|
170
|
+
"TestRunArtifactPaths",
|
|
171
|
+
"persist_check_result",
|
|
172
|
+
"persist_current_findings_md",
|
|
173
|
+
"persist_deactivated_blockers_decision_needed",
|
|
174
|
+
"persist_decision_needed",
|
|
175
|
+
"persist_findings_md",
|
|
176
|
+
"read_operator_decision",
|
|
177
|
+
"persist_handoff",
|
|
178
|
+
"persist_loop_manifest",
|
|
179
|
+
"persist_loop_summary",
|
|
180
|
+
"persist_producer_result",
|
|
181
|
+
"persist_dispatch_record",
|
|
182
|
+
"record_child_pid",
|
|
183
|
+
"persist_reviewer_result",
|
|
184
|
+
"persist_round_manifest",
|
|
185
|
+
"persist_run_init",
|
|
186
|
+
"persist_run_summary",
|
|
187
|
+
"persist_synthesizer_result",
|
|
188
|
+
"persist_test_run_result",
|
|
189
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def atomic_write_text(path: Path, text: str) -> None:
|
|
10
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
12
|
+
prefix=f".{path.name}.",
|
|
13
|
+
suffix=".tmp",
|
|
14
|
+
dir=path.parent,
|
|
15
|
+
text=True,
|
|
16
|
+
)
|
|
17
|
+
tmp_path = Path(tmp_name)
|
|
18
|
+
try:
|
|
19
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
20
|
+
handle.write(text)
|
|
21
|
+
handle.flush()
|
|
22
|
+
os.fsync(handle.fileno())
|
|
23
|
+
os.replace(tmp_path, path)
|
|
24
|
+
except BaseException:
|
|
25
|
+
try:
|
|
26
|
+
tmp_path.unlink()
|
|
27
|
+
except FileNotFoundError:
|
|
28
|
+
pass
|
|
29
|
+
raise
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def atomic_write_json(path: Path, data: object, *, sort_keys: bool = False) -> None:
|
|
33
|
+
atomic_write_text(path, json.dumps(data, indent=2, sort_keys=sort_keys) + "\n")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Root-cause cluster rendering for findings.md.
|
|
2
|
+
|
|
3
|
+
Renders the descriptive-only ``## Root-cause clusters`` section that
|
|
4
|
+
``persist_findings_md`` places ABOVE the individual findings, so the producer
|
|
5
|
+
reads "these N findings are one root cause — here is what each reviewer said
|
|
6
|
+
about it" before reading them individually.
|
|
7
|
+
|
|
8
|
+
Lives in its own module (called by ``findings_md.py``) per the LOC discipline —
|
|
9
|
+
``findings_md.py`` was already at the ~500-LOC cap. The renderer is purely
|
|
10
|
+
descriptive: it surfaces the synth's grouping + the verbatim reviewer quotes and
|
|
11
|
+
authors nothing (the synth emits no cause or fix, and neither does this).
|
|
12
|
+
|
|
13
|
+
Returns ``[]`` when there are no clusters.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from syncade.synthesis import SynthesizerOutput
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render_cluster_section(output: SynthesizerOutput) -> list[str]:
|
|
22
|
+
"""Return the ``## Root-cause clusters`` section as a list of lines, or
|
|
23
|
+
``[]`` when ``output.root_cause_clusters`` is empty.
|
|
24
|
+
|
|
25
|
+
For each cluster: the ``anchor_file`` (+ the optional ``label``, itself a
|
|
26
|
+
verbatim quote excerpt), the member finding indices, and one bullet per
|
|
27
|
+
member with the finding's first description line and that member's verbatim
|
|
28
|
+
reviewer quote. Descriptive-only — no authored cause or fix.
|
|
29
|
+
|
|
30
|
+
Member indices are schema-validated in range by the time a
|
|
31
|
+
:class:`SynthesizerOutput` exists, but the render guards defensively so a
|
|
32
|
+
stray index can never raise mid-write.
|
|
33
|
+
"""
|
|
34
|
+
clusters = output.root_cause_clusters
|
|
35
|
+
if not clusters:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
findings = output.consolidated_findings
|
|
39
|
+
lines: list[str] = [
|
|
40
|
+
"## Root-cause clusters",
|
|
41
|
+
"",
|
|
42
|
+
"_Advisory groupings of related findings (descriptive-only — they do "
|
|
43
|
+
"not change the verdict). Each member is quoted verbatim from a "
|
|
44
|
+
"reviewer's original finding; the cause is the reviewers' words, not "
|
|
45
|
+
"the synthesizer's._",
|
|
46
|
+
"",
|
|
47
|
+
]
|
|
48
|
+
for n, cluster in enumerate(clusters, start=1):
|
|
49
|
+
heading = f"### Cluster {n}: `{cluster.anchor_file}`"
|
|
50
|
+
if cluster.label:
|
|
51
|
+
heading += f" — {cluster.label}"
|
|
52
|
+
lines.append(heading)
|
|
53
|
+
lines.append("")
|
|
54
|
+
member_list = ", ".join(f"#{idx}" for idx in cluster.member_finding_indices)
|
|
55
|
+
lines.append(f"**Member findings:** {member_list} ")
|
|
56
|
+
lines.append("")
|
|
57
|
+
quote_by_index = {e.finding_index: e.quote for e in cluster.evidence}
|
|
58
|
+
for idx in cluster.member_finding_indices:
|
|
59
|
+
if 0 <= idx < len(findings):
|
|
60
|
+
member = findings[idx]
|
|
61
|
+
desc_first = member.description.strip().splitlines()[0]
|
|
62
|
+
severity = member.severity
|
|
63
|
+
else: # pragma: no cover - defensive; schema validates in range
|
|
64
|
+
desc_first = "(member index out of range)"
|
|
65
|
+
severity = "?"
|
|
66
|
+
lines.append(f"- **#{idx}** [{severity}] {desc_first}")
|
|
67
|
+
quote = quote_by_index.get(idx, "").strip().replace("\n", " ")
|
|
68
|
+
lines.append(f" - reviewer quote: {quote!r}")
|
|
69
|
+
lines.append("")
|
|
70
|
+
return lines
|