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,573 @@
|
|
|
1
|
+
"""Per-round reviewer, synthesizer, test, and check orchestration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from functools import partial
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from syncade import run_status
|
|
12
|
+
from syncade.adapters.base import ReviewerAdapter
|
|
13
|
+
from syncade.adapters.producer import ProducerAdapter
|
|
14
|
+
from syncade.config import SyncadeConfig
|
|
15
|
+
from syncade.diff_filter import (
|
|
16
|
+
elide_binary_hunks,
|
|
17
|
+
filter_diff_for_reviewer,
|
|
18
|
+
)
|
|
19
|
+
from syncade.dispatcher import DispatchResult, dispatch_reviewers
|
|
20
|
+
from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
|
|
21
|
+
from syncade.findings import get_findings_schema_string
|
|
22
|
+
from syncade.logging import Logger
|
|
23
|
+
from syncade.persistence import (
|
|
24
|
+
SynthesizerArtifactPaths,
|
|
25
|
+
TestRunArtifactPaths,
|
|
26
|
+
persist_current_findings_md,
|
|
27
|
+
persist_dispatch_record,
|
|
28
|
+
persist_findings_md,
|
|
29
|
+
persist_reviewer_result,
|
|
30
|
+
persist_round_manifest,
|
|
31
|
+
persist_run_summary,
|
|
32
|
+
persist_synthesizer_result,
|
|
33
|
+
persist_test_run_result,
|
|
34
|
+
record_child_pid,
|
|
35
|
+
)
|
|
36
|
+
from syncade.prompts import load_reviewer_template_for, render_reviewer_prompt
|
|
37
|
+
from syncade.snapshot import Snapshot
|
|
38
|
+
from syncade.synthesis import has_active_blocker
|
|
39
|
+
from syncade.synthesizer import SynthesizerResult, run_synthesizer
|
|
40
|
+
from syncade.test_runner import TestRunResult, run_tests
|
|
41
|
+
from syncade.worktree import TEST_WORKTREE_NAME, WorktreeError, WorktreeManager
|
|
42
|
+
|
|
43
|
+
from .prior_round import load_prior_reviewer_response_text
|
|
44
|
+
from .results import RoundArtifacts, RoundResult, TestSkipReason
|
|
45
|
+
from .round_checks import _run_checks_leg
|
|
46
|
+
from .round_predispatch import run_predispatch_gates
|
|
47
|
+
from .verdict import _compute_exit_code
|
|
48
|
+
|
|
49
|
+
_NO_DIFF_SENTINEL: str = "(diff not provided; review against the full repo state at HEAD)"
|
|
50
|
+
|
|
51
|
+
# Test-leg basename exported from :mod:`syncade.worktree` as a single source.
|
|
52
|
+
_TEST_WORKTREE_NAME = TEST_WORKTREE_NAME
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _build_reviewer_prompt(
|
|
56
|
+
*,
|
|
57
|
+
repo_root: Path,
|
|
58
|
+
snapshot: Snapshot,
|
|
59
|
+
config: SyncadeConfig,
|
|
60
|
+
round_idx: int,
|
|
61
|
+
run_dir: Path,
|
|
62
|
+
pr_doc_path: Path,
|
|
63
|
+
) -> tuple[str | dict[str, str], str, bool]:
|
|
64
|
+
"""Load + render the reviewer prompt(s) for one round.
|
|
65
|
+
|
|
66
|
+
Each reviewer renders its OWN provider-specific template (anthropic →
|
|
67
|
+
``reviewer_adversarial.md``, openai → ``reviewer_codex.md``, any other provider
|
|
68
|
+
→ the generic ``reviewer.md``), so the prompt differs per reviewer even on
|
|
69
|
+
round 0 — the result is always a per-reviewer ``{name: prompt}`` dict.
|
|
70
|
+
Round N>0 additionally folds in that reviewer's own prior-round response
|
|
71
|
+
(per-reviewer isolation).
|
|
72
|
+
|
|
73
|
+
Returns ``(prompt_arg, pr_doc_ref, pr_doc_is_out_of_repo)``: ``prompt_arg``
|
|
74
|
+
is a per-reviewer ``{name: prompt}`` dict;
|
|
75
|
+
``pr_doc_ref`` is the worktree-LOCAL doc reference handed to reviewers;
|
|
76
|
+
``pr_doc_is_out_of_repo`` flags a doc whose copy into each worktree must
|
|
77
|
+
never be skipped (see the caller's copy site).
|
|
78
|
+
|
|
79
|
+
Raises :class:`KeyError` / :class:`ValueError` when the (operator-
|
|
80
|
+
overridden) template references an unknown placeholder or a malformed
|
|
81
|
+
format spec — the caller maps that to a phase-failure round, mirroring the
|
|
82
|
+
producer/synth template-render contract.
|
|
83
|
+
"""
|
|
84
|
+
# Strip context-file hunks from the reviewer-facing diff so reviewers do
|
|
85
|
+
# not see edits to files absent from their worktrees. Uses the same strip
|
|
86
|
+
# list as worktree creation in the caller.
|
|
87
|
+
filtered_diff = filter_diff_for_reviewer(
|
|
88
|
+
snapshot.diff_text, config.review.strip_repo_context_files
|
|
89
|
+
)
|
|
90
|
+
# Binary content never reaches the prompt (PR-h-field-01 item 2). `snapshot` renders with
|
|
91
|
+
# `--text`, so a committed PNG is emitted as raw bytes — measured, 12 screenshot
|
|
92
|
+
# baselines turned 66 KB of real diff into 3.1 MB. That is unreadable to a reviewer,
|
|
93
|
+
# displaces the diff it should be judging, and exceeds both providers' prompt ceilings
|
|
94
|
+
# (codex 1,048,576 chars; claude 1,000,000 tokens). Headers and a byte-count notice
|
|
95
|
+
# survive, so the change is disclosed rather than silently dropped.
|
|
96
|
+
filtered_diff, _ = elide_binary_hunks(filtered_diff)
|
|
97
|
+
diff_text = filtered_diff if filtered_diff else _NO_DIFF_SENTINEL
|
|
98
|
+
# Worktree-escape fix: reviewers run with cwd = their own worktree. Hand
|
|
99
|
+
# them a worktree-LOCAL reference to the PR doc (relative to the worktree
|
|
100
|
+
# root) — NOT the operator's absolute MAIN path. An absolute MAIN path lures
|
|
101
|
+
# the reviewer OUT of its isolated, CLAUDE.md/AGENTS.md-stripped worktree to
|
|
102
|
+
# review the live repo, defeating the isolation + blindness invariants
|
|
103
|
+
# (empirically: run 2026-05-30T21-22-19 round 0, claude-reviewer cd'd to
|
|
104
|
+
# MAIN for 25/32 bash commands + every Read). The doc is copied into each
|
|
105
|
+
# worktree by the caller. Same relative ref for every reviewer (each
|
|
106
|
+
# resolves it against its own cwd), so the round-0 shared prompt and the
|
|
107
|
+
# round-N per-reviewer prompts both work.
|
|
108
|
+
pr_doc_is_out_of_repo = False
|
|
109
|
+
try:
|
|
110
|
+
pr_doc_ref = str(pr_doc_path.relative_to(repo_root))
|
|
111
|
+
except ValueError:
|
|
112
|
+
# Out-of-repo doc: its basename may collide with a tracked file (e.g.
|
|
113
|
+
# README.md). A bare-basename ref would resolve to the repo's tracked
|
|
114
|
+
# file and the caller's copy would be skipped because the path already
|
|
115
|
+
# exists — reviewers would silently read the WRONG spec. Render a
|
|
116
|
+
# reserved, collision-free worktree-local ref under `.syncade-inputs/`
|
|
117
|
+
# (hashed on the resolved source path) so the supplied doc can ALWAYS
|
|
118
|
+
# be copied there without shadowing a tracked file.
|
|
119
|
+
pr_doc_is_out_of_repo = True
|
|
120
|
+
digest = hashlib.sha256(str(pr_doc_path).encode("utf-8")).hexdigest()[:16]
|
|
121
|
+
pr_doc_ref = f".syncade-inputs/pr-doc-{digest}-{pr_doc_path.name}"
|
|
122
|
+
# Each reviewer renders its own provider-specific template, so we always
|
|
123
|
+
# build a per-reviewer dict — there is no shared-str fast path now that the
|
|
124
|
+
# claude and codex prompts differ. Round 0 omits prior_round_output → the
|
|
125
|
+
# renderer's "(no prior round)" sentinel; round N>0 passes THIS reviewer's
|
|
126
|
+
# own prior-round response (per-reviewer isolation preserved).
|
|
127
|
+
prior_round_dir = run_dir / f"round-{round_idx - 1}" if round_idx > 0 else None
|
|
128
|
+
per_reviewer_prompts: dict[str, str] = {}
|
|
129
|
+
for reviewer in config.reviewers:
|
|
130
|
+
template = load_reviewer_template_for(
|
|
131
|
+
repo_root, provider=reviewer.provider, template=reviewer.template
|
|
132
|
+
)
|
|
133
|
+
render_kwargs: dict[str, object] = {
|
|
134
|
+
"pr_doc_path": pr_doc_ref,
|
|
135
|
+
"diff": diff_text,
|
|
136
|
+
"master_plan_path": None,
|
|
137
|
+
"json_schema": get_findings_schema_string(),
|
|
138
|
+
"adversarial_lens": reviewer.adversarial_lens,
|
|
139
|
+
"bug_class_sweep": reviewer.bug_class_sweep,
|
|
140
|
+
}
|
|
141
|
+
if prior_round_dir is not None:
|
|
142
|
+
render_kwargs["prior_round_output"] = load_prior_reviewer_response_text(
|
|
143
|
+
prior_round_dir=prior_round_dir,
|
|
144
|
+
reviewer_name=reviewer.name,
|
|
145
|
+
reviewer_provider=reviewer.provider,
|
|
146
|
+
)
|
|
147
|
+
per_reviewer_prompts[reviewer.name] = render_reviewer_prompt(template, **render_kwargs)
|
|
148
|
+
prompt_arg: str | dict[str, str] = per_reviewer_prompts
|
|
149
|
+
return prompt_arg, pr_doc_ref, pr_doc_is_out_of_repo
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _run_one_round(
|
|
153
|
+
*,
|
|
154
|
+
round_idx: int,
|
|
155
|
+
snapshot: Snapshot,
|
|
156
|
+
repo_root: Path,
|
|
157
|
+
pr_doc_path: Path,
|
|
158
|
+
run_id: str,
|
|
159
|
+
run_dir: Path,
|
|
160
|
+
round_dir: Path,
|
|
161
|
+
config: SyncadeConfig,
|
|
162
|
+
resolved_timeout: float,
|
|
163
|
+
resolved_producer_timeout: float,
|
|
164
|
+
adapter_factory: Callable[[str], ReviewerAdapter] | None,
|
|
165
|
+
synthesizer_adapter: ReviewerAdapter | None,
|
|
166
|
+
producer_adapter: ProducerAdapter | None,
|
|
167
|
+
worktree_base: Path,
|
|
168
|
+
logger: Logger,
|
|
169
|
+
started_at: datetime,
|
|
170
|
+
managers_to_cleanup: list[WorktreeManager],
|
|
171
|
+
resumed_under_drift: bool = False,
|
|
172
|
+
) -> RoundResult:
|
|
173
|
+
"""Execute one round of the per-round pipeline.
|
|
174
|
+
|
|
175
|
+
The round handles reviewer worktree provisioning, dispatch, synthesis,
|
|
176
|
+
optional test rerun, checks, and persistence. Producer and branch-advance
|
|
177
|
+
happen after this returns.
|
|
178
|
+
|
|
179
|
+
Returns the round's :class:`RoundResult` regardless of
|
|
180
|
+
outcome. The caller decides whether to terminate the loop or
|
|
181
|
+
dispatch a producer based on ``round_exit_code``.
|
|
182
|
+
|
|
183
|
+
"""
|
|
184
|
+
_gate = run_predispatch_gates(
|
|
185
|
+
repo_root=repo_root,
|
|
186
|
+
snapshot=snapshot,
|
|
187
|
+
config=config,
|
|
188
|
+
round_idx=round_idx,
|
|
189
|
+
run_dir=run_dir,
|
|
190
|
+
round_dir=round_dir,
|
|
191
|
+
pr_doc_path=pr_doc_path,
|
|
192
|
+
started_at=started_at,
|
|
193
|
+
resumed_under_drift=resumed_under_drift,
|
|
194
|
+
logger=logger,
|
|
195
|
+
build_prompt=_build_reviewer_prompt,
|
|
196
|
+
)
|
|
197
|
+
if isinstance(_gate, RoundResult):
|
|
198
|
+
return _gate
|
|
199
|
+
prompt_arg = _gate.prompt_arg
|
|
200
|
+
pr_doc_ref = _gate.pr_doc_ref
|
|
201
|
+
pr_doc_is_out_of_repo = _gate.pr_doc_is_out_of_repo
|
|
202
|
+
_filtered_for_check = _gate.filtered_for_check
|
|
203
|
+
|
|
204
|
+
# --- Worktrees + dispatch ---------------------------------------
|
|
205
|
+
# Scope the WorktreeManager at <run-id>/round-N/ so each round's
|
|
206
|
+
# worktrees are isolated.
|
|
207
|
+
#
|
|
208
|
+
# When the test-leg worktree fails to provision, preserve reviewer
|
|
209
|
+
# worktrees on disk for inspection. The ``with`` block's ``__exit__`` runs
|
|
210
|
+
# ``cleanup_all`` only on clean exit; raising inside the block
|
|
211
|
+
# skips it. We raise the test_worktree_error inside the block
|
|
212
|
+
# to trigger preservation, then catch it here so
|
|
213
|
+
# ``_run_one_round`` returns a :class:`RoundResult` rather than
|
|
214
|
+
# bubbling the exception up to the loop (which would terminate
|
|
215
|
+
# the loop without recording the round's artifacts).
|
|
216
|
+
round_worktree_id = f"{run_id}/round-{round_idx}"
|
|
217
|
+
# Forward-declare round-result variables so they're bound in
|
|
218
|
+
# ``_run_one_round``'s scope across the with block (the
|
|
219
|
+
# test_worktree_error raise-then-catch flow needs to read them
|
|
220
|
+
# after the with block unwinds).
|
|
221
|
+
dispatch_result: DispatchResult | None = None
|
|
222
|
+
synth_result: SynthesizerResult | None = None
|
|
223
|
+
synth_paths: SynthesizerArtifactPaths | None = None
|
|
224
|
+
findings_md_path: Path | None = None
|
|
225
|
+
test_result: TestRunResult | None = None
|
|
226
|
+
test_skip_reason: TestSkipReason | None = None
|
|
227
|
+
test_worktree_error: WorktreeError | None = None
|
|
228
|
+
test_run_paths: TestRunArtifactPaths | None = None
|
|
229
|
+
check_results: list[TestRunResult] = []
|
|
230
|
+
round_exit_code: int = SUCCESS
|
|
231
|
+
|
|
232
|
+
# Defer cleanup so the loop terminator decides cleanup-vs-preserve from
|
|
233
|
+
# the final exit code.
|
|
234
|
+
try:
|
|
235
|
+
round_manager = WorktreeManager(
|
|
236
|
+
repo_root,
|
|
237
|
+
round_worktree_id,
|
|
238
|
+
base_dir=worktree_base,
|
|
239
|
+
defer_cleanup=True,
|
|
240
|
+
)
|
|
241
|
+
managers_to_cleanup.append(round_manager)
|
|
242
|
+
with round_manager as manager:
|
|
243
|
+
logger.event(
|
|
244
|
+
f"provisioning {len(config.reviewers)} worktree(s) under {manager.run_dir}"
|
|
245
|
+
)
|
|
246
|
+
worktree_paths: dict[str, Path] = {}
|
|
247
|
+
for reviewer in config.reviewers:
|
|
248
|
+
worktree = manager.create(
|
|
249
|
+
reviewer_name=reviewer.name,
|
|
250
|
+
commit_sha=snapshot.commit_sha,
|
|
251
|
+
strip_files=config.review.strip_repo_context_files,
|
|
252
|
+
)
|
|
253
|
+
worktree_paths[reviewer.name] = worktree.path
|
|
254
|
+
# Ensure the PR doc is readable from INSIDE the worktree so the
|
|
255
|
+
# worktree-local `pr_doc_ref` resolves without the reviewer
|
|
256
|
+
# leaving its sandbox. An out-of-repo doc ALWAYS copies to its
|
|
257
|
+
# reserved collision-free ref (never skip — a same-basename
|
|
258
|
+
# tracked file, e.g. README.md, must not shadow the supplied
|
|
259
|
+
# doc and feed reviewers the WRONG spec). A tracked in-repo doc
|
|
260
|
+
# is already present (no-op); an untracked in-repo doc copies in.
|
|
261
|
+
pr_doc_in_worktree = worktree.path / pr_doc_ref
|
|
262
|
+
if pr_doc_is_out_of_repo or not pr_doc_in_worktree.exists():
|
|
263
|
+
import shutil
|
|
264
|
+
|
|
265
|
+
pr_doc_in_worktree.parent.mkdir(parents=True, exist_ok=True)
|
|
266
|
+
shutil.copy2(pr_doc_path, pr_doc_in_worktree)
|
|
267
|
+
logger.event(f" worktree ready: {reviewer.name} -> {worktree.path}")
|
|
268
|
+
|
|
269
|
+
# Report the timeout honestly: "each" only holds when every reviewer uses the fallback.
|
|
270
|
+
# A per-reviewer ``timeout_seconds`` override makes "1800s each" a lie (dogfood R2-M4).
|
|
271
|
+
if any(r.timeout_seconds is not None for r in config.reviewers):
|
|
272
|
+
timeout_note = f"per-reviewer timeouts, {resolved_timeout:g}s fallback"
|
|
273
|
+
else:
|
|
274
|
+
timeout_note = f"timeout {resolved_timeout:g}s each"
|
|
275
|
+
logger.event(
|
|
276
|
+
f"dispatching {len(config.reviewers)} reviewer(s) in parallel ({timeout_note})"
|
|
277
|
+
)
|
|
278
|
+
for reviewer in config.reviewers:
|
|
279
|
+
logger.event(f" -> {reviewer.name} ({reviewer.provider}) started")
|
|
280
|
+
|
|
281
|
+
# Written BEFORE the panel runs: every other artifact lands after it returns, so a
|
|
282
|
+
# death mid-dispatch left an empty round dir and nothing to diagnose from.
|
|
283
|
+
persist_dispatch_record(
|
|
284
|
+
round_dir,
|
|
285
|
+
round_index=round_idx,
|
|
286
|
+
reviewers=config.reviewers,
|
|
287
|
+
timeout_seconds=resolved_timeout,
|
|
288
|
+
)
|
|
289
|
+
dispatch_result = dispatch_reviewers(
|
|
290
|
+
config.reviewers,
|
|
291
|
+
worktree_paths=worktree_paths,
|
|
292
|
+
prompt=prompt_arg,
|
|
293
|
+
timeout_seconds=resolved_timeout,
|
|
294
|
+
adapter_factory=adapter_factory,
|
|
295
|
+
pricing=config.pricing,
|
|
296
|
+
max_retries=config.retry.max_retries,
|
|
297
|
+
# Reviewers write their output here as they produce it, so a run that dies
|
|
298
|
+
# mid-panel leaves it behind (PR-h-field-03). Same directory dispatch.json
|
|
299
|
+
# was just written to, and the same filenames persistence uses.
|
|
300
|
+
capture_dir=round_dir,
|
|
301
|
+
# Fills each reviewer's pid into the dispatch record as its child appears.
|
|
302
|
+
on_child_spawn=partial(record_child_pid, round_dir),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
for run_result in dispatch_result.results:
|
|
306
|
+
prefix = (
|
|
307
|
+
f" <- {run_result.reviewer_name} ({run_result.provider}) "
|
|
308
|
+
f"finished in {run_result.duration_seconds:.1f}s — "
|
|
309
|
+
)
|
|
310
|
+
if run_result.output is None:
|
|
311
|
+
err_cls = type(run_result.error).__name__ if run_result.error else "Unknown"
|
|
312
|
+
logger.event(f"{prefix}FAILED ({err_cls})", error=True)
|
|
313
|
+
else:
|
|
314
|
+
logger.event(
|
|
315
|
+
f"{prefix}{run_result.output.verdict}, "
|
|
316
|
+
f"{len(run_result.output.findings)} finding(s)"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
logger.event(f"persisting reviewer outputs to {round_dir}")
|
|
320
|
+
for run_result in dispatch_result.results:
|
|
321
|
+
persist_reviewer_result(
|
|
322
|
+
round_dir,
|
|
323
|
+
run_result,
|
|
324
|
+
run_result.raw_subprocess_result,
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# --- Synthesizer phase --------------------------------------
|
|
328
|
+
# PR-v2-11: the budget is deliberately NOT re-checked between the reviewer panel and
|
|
329
|
+
# the judge. The judge is part of the atomic "review-bundle" — a cheap (~$0.05)
|
|
330
|
+
# deterministic consolidation whose output IS this round's findings.md. Gating it out
|
|
331
|
+
# on a reviewer-panel budget crossing would leave the crossing round with no
|
|
332
|
+
# consolidated verdict, breaking the "round is whole" guarantee (C2). The budget's
|
|
333
|
+
# two enforced boundaries (before-round in loop.py, before-producer in
|
|
334
|
+
# loop_round_step.py) skip the EXPENSIVE producer instead; overshoot is bounded to
|
|
335
|
+
# one review-bundle or one producer, never a running call. See the PR-v2-11 brief.
|
|
336
|
+
if dispatch_result.all_succeeded:
|
|
337
|
+
run_status.update_phase(f"round-{round_idx}: synthesizing", round_idx)
|
|
338
|
+
logger.event(f"synthesizing reviewer outputs (cold {config.synthesizer.provider})")
|
|
339
|
+
synth_result = run_synthesizer(
|
|
340
|
+
dispatch_result.results,
|
|
341
|
+
repo_root=repo_root,
|
|
342
|
+
pr_doc_path=pr_doc_path,
|
|
343
|
+
timeout_seconds=resolved_timeout,
|
|
344
|
+
config=config.synthesizer,
|
|
345
|
+
adapter=synthesizer_adapter,
|
|
346
|
+
pricing=config.pricing,
|
|
347
|
+
max_retries=config.retry.max_retries,
|
|
348
|
+
)
|
|
349
|
+
prefix = f" synthesizer finished in {synth_result.duration_seconds:.1f}s — "
|
|
350
|
+
if synth_result.output is None:
|
|
351
|
+
err_cls = type(synth_result.error).__name__ if synth_result.error else "Unknown"
|
|
352
|
+
logger.event(f"{prefix}FAILED ({err_cls})", error=True)
|
|
353
|
+
else:
|
|
354
|
+
findings = len(synth_result.output.consolidated_findings)
|
|
355
|
+
dismissed = sum(
|
|
356
|
+
1 for f in synth_result.output.consolidated_findings if f.dismissed
|
|
357
|
+
)
|
|
358
|
+
active = findings - dismissed
|
|
359
|
+
logger.event(
|
|
360
|
+
f"{prefix}{active} active finding(s), {dismissed} dismissed "
|
|
361
|
+
f"(of {findings} consolidated)"
|
|
362
|
+
)
|
|
363
|
+
else:
|
|
364
|
+
logger.event(" synthesizer skipped (one or more reviewers failed)")
|
|
365
|
+
|
|
366
|
+
if synth_result is not None:
|
|
367
|
+
synth_paths = persist_synthesizer_result(round_dir, synth_result)
|
|
368
|
+
|
|
369
|
+
# --- Test re-run leg ---------------------------------------
|
|
370
|
+
if (
|
|
371
|
+
dispatch_result.all_succeeded
|
|
372
|
+
and synth_result is not None
|
|
373
|
+
and synth_result.output is not None
|
|
374
|
+
and not has_active_blocker(synth_result.output)
|
|
375
|
+
and config.loop.test_command is not None
|
|
376
|
+
):
|
|
377
|
+
test_timeout = (
|
|
378
|
+
config.loop.test_timeout_seconds
|
|
379
|
+
if config.loop.test_timeout_seconds is not None
|
|
380
|
+
else resolved_timeout
|
|
381
|
+
)
|
|
382
|
+
try:
|
|
383
|
+
test_worktree = manager.create(
|
|
384
|
+
reviewer_name=_TEST_WORKTREE_NAME,
|
|
385
|
+
commit_sha=snapshot.commit_sha,
|
|
386
|
+
strip_files=config.review.strip_repo_context_files,
|
|
387
|
+
)
|
|
388
|
+
except WorktreeError as exc:
|
|
389
|
+
test_worktree_error = exc
|
|
390
|
+
test_skip_reason = "test_worktree_error"
|
|
391
|
+
logger.event(
|
|
392
|
+
f" test re-run skipped (test worktree provisioning failed: {exc})"
|
|
393
|
+
)
|
|
394
|
+
else:
|
|
395
|
+
run_status.update_phase(f"round-{round_idx}: testing", round_idx)
|
|
396
|
+
logger.event(f" worktree ready: {_TEST_WORKTREE_NAME} -> {test_worktree.path}")
|
|
397
|
+
logger.event(
|
|
398
|
+
f"running test re-run leg: {config.loop.test_command!r} "
|
|
399
|
+
f"(timeout {test_timeout:g}s)"
|
|
400
|
+
)
|
|
401
|
+
test_result = run_tests(
|
|
402
|
+
worktree_path=test_worktree.path,
|
|
403
|
+
test_command=config.loop.test_command,
|
|
404
|
+
timeout_seconds=test_timeout,
|
|
405
|
+
)
|
|
406
|
+
prefix = f" test re-run finished in {test_result.duration_seconds:.1f}s — "
|
|
407
|
+
if test_result.outcome == "subprocess_error":
|
|
408
|
+
err_cls = (
|
|
409
|
+
type(test_result.error).__name__ if test_result.error else "Unknown"
|
|
410
|
+
)
|
|
411
|
+
logger.event(f"{prefix}SUBPROCESS_ERROR ({err_cls})", error=True)
|
|
412
|
+
else:
|
|
413
|
+
logger.event(
|
|
414
|
+
f"{prefix}{test_result.outcome.upper()} (exit {test_result.exit_code})"
|
|
415
|
+
)
|
|
416
|
+
elif not dispatch_result.all_succeeded:
|
|
417
|
+
test_skip_reason = "reviewer_failed"
|
|
418
|
+
logger.event(" test re-run skipped (a reviewer failed in dispatch)")
|
|
419
|
+
elif synth_result is None or synth_result.error is not None:
|
|
420
|
+
test_skip_reason = "synth_failed"
|
|
421
|
+
logger.event(" test re-run skipped (the synthesizer phase failed)")
|
|
422
|
+
elif (
|
|
423
|
+
synth_result is not None
|
|
424
|
+
and synth_result.output is not None
|
|
425
|
+
and has_active_blocker(synth_result.output)
|
|
426
|
+
):
|
|
427
|
+
test_skip_reason = "synth_blocker"
|
|
428
|
+
logger.event(
|
|
429
|
+
" test re-run skipped (the synthesizer surfaced an active blocker "
|
|
430
|
+
"with a NO-SHIP verdict)"
|
|
431
|
+
)
|
|
432
|
+
else:
|
|
433
|
+
test_skip_reason = "test_command_unset"
|
|
434
|
+
logger.event(
|
|
435
|
+
" test re-run skipped (test_command is not configured in .syncade/config.toml)"
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if test_result is not None:
|
|
439
|
+
test_run_paths = persist_test_run_result(round_dir, test_result)
|
|
440
|
+
|
|
441
|
+
# --- Mechanical checks leg ---------------------------------
|
|
442
|
+
run_status.update_phase(f"round-{round_idx}: checking", round_idx)
|
|
443
|
+
check_results = _run_checks_leg(
|
|
444
|
+
manager=manager,
|
|
445
|
+
config=config,
|
|
446
|
+
snapshot=snapshot,
|
|
447
|
+
dispatch_result=dispatch_result,
|
|
448
|
+
synth_result=synth_result,
|
|
449
|
+
resolved_timeout=resolved_timeout,
|
|
450
|
+
round_dir=round_dir,
|
|
451
|
+
logger=logger,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# --- Compute round verdict ----------------------------------
|
|
455
|
+
if test_worktree_error is not None:
|
|
456
|
+
round_exit_code = WORKTREE_ERROR
|
|
457
|
+
else:
|
|
458
|
+
# Only blocking check results reach the verdict; advisory
|
|
459
|
+
# results are structurally never passed in, so they cannot gate.
|
|
460
|
+
round_exit_code = _compute_exit_code(
|
|
461
|
+
dispatch_result,
|
|
462
|
+
synth_result,
|
|
463
|
+
test_result,
|
|
464
|
+
blocking_check_results=[c for c in check_results if c.severity == "blocking"],
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
# --- Persistence block --------------------------------------
|
|
468
|
+
try:
|
|
469
|
+
if synth_result is not None and synth_result.output is not None:
|
|
470
|
+
findings_md_path = persist_findings_md(
|
|
471
|
+
round_dir,
|
|
472
|
+
synth_result,
|
|
473
|
+
started_at,
|
|
474
|
+
test_result,
|
|
475
|
+
dispatch_result,
|
|
476
|
+
test_skip_reason=test_skip_reason,
|
|
477
|
+
snapshot_sha=snapshot.commit_sha,
|
|
478
|
+
check_results=check_results,
|
|
479
|
+
)
|
|
480
|
+
# Refresh the run-root findings.md convenience copy so the
|
|
481
|
+
# operator can address latest findings without knowing the
|
|
482
|
+
# round number. Best-effort: a copy failure
|
|
483
|
+
# doesn't block the per-round persistence —
|
|
484
|
+
# the round-N/findings.md is the source of
|
|
485
|
+
# truth and lives on disk regardless.
|
|
486
|
+
persist_current_findings_md(run_dir, findings_md_path)
|
|
487
|
+
|
|
488
|
+
persist_round_manifest(
|
|
489
|
+
round_dir,
|
|
490
|
+
snapshot,
|
|
491
|
+
dispatch_result,
|
|
492
|
+
round_exit_code,
|
|
493
|
+
started_at,
|
|
494
|
+
synth_result,
|
|
495
|
+
test_result,
|
|
496
|
+
test_skip_reason,
|
|
497
|
+
round_idx=round_idx,
|
|
498
|
+
producer_result=None,
|
|
499
|
+
producer_provider=config.producer.provider,
|
|
500
|
+
producer_model=config.producer.model,
|
|
501
|
+
check_results=check_results,
|
|
502
|
+
filtered_diff_bytes=len(_filtered_for_check.encode("utf-8")),
|
|
503
|
+
raw_diff_bytes=len((snapshot.diff_text or "").encode("utf-8")),
|
|
504
|
+
)
|
|
505
|
+
persist_run_summary(
|
|
506
|
+
round_dir,
|
|
507
|
+
snapshot,
|
|
508
|
+
dispatch_result,
|
|
509
|
+
round_exit_code,
|
|
510
|
+
started_at,
|
|
511
|
+
synth_result,
|
|
512
|
+
test_result,
|
|
513
|
+
test_skip_reason,
|
|
514
|
+
resumed_under_drift=resumed_under_drift,
|
|
515
|
+
check_results=check_results,
|
|
516
|
+
)
|
|
517
|
+
except Exception as persist_exc:
|
|
518
|
+
if test_worktree_error is not None:
|
|
519
|
+
raise persist_exc from test_worktree_error
|
|
520
|
+
raise
|
|
521
|
+
|
|
522
|
+
# Raise the captured test_worktree_error inside the with block so
|
|
523
|
+
# __exit__ skips cleanup and reviewer worktrees survive on disk.
|
|
524
|
+
if test_worktree_error is not None:
|
|
525
|
+
raise test_worktree_error
|
|
526
|
+
except WorktreeError:
|
|
527
|
+
# Re-enter here only when the captured ``test_worktree_error`` was
|
|
528
|
+
# raised inside the with block to trigger worktree preservation.
|
|
529
|
+
# Distinguish this case from a *different* WorktreeError (e.g. reviewer-name
|
|
530
|
+
# duplicate during reviewer-worktree provisioning, which
|
|
531
|
+
# fires BEFORE we ever set test_worktree_error). The
|
|
532
|
+
# pre-dispatch WorktreeError path must propagate to the
|
|
533
|
+
# CLI which maps it to exit 60 with the original message;
|
|
534
|
+
# falling through to RoundResult assembly here would lose
|
|
535
|
+
# the exception AND populate the result with a None
|
|
536
|
+
# dispatch_result that downstream rendering (logger.summary,
|
|
537
|
+
# persist_*) can't handle.
|
|
538
|
+
if test_worktree_error is None:
|
|
539
|
+
raise
|
|
540
|
+
# Otherwise: the captured test_worktree_error was the cause.
|
|
541
|
+
# Fall through to RoundResult assembly. The variables
|
|
542
|
+
# (dispatch_result, synth_result, etc.) are bound in
|
|
543
|
+
# _run_one_round's scope, so they remain accessible after
|
|
544
|
+
# the with block unwinds. The orchestrator treats
|
|
545
|
+
# ``round_exit_code == WORKTREE_ERROR`` + non-None
|
|
546
|
+
# ``test_worktree_error`` as the terminator for this round.
|
|
547
|
+
|
|
548
|
+
# --- Assemble round artifacts + result ------------------------
|
|
549
|
+
artifacts = RoundArtifacts(
|
|
550
|
+
round_idx=round_idx,
|
|
551
|
+
round_dir=round_dir,
|
|
552
|
+
manifest_path=round_dir / "manifest.json",
|
|
553
|
+
summary_path=round_dir / "summary.md",
|
|
554
|
+
findings_md_path=findings_md_path,
|
|
555
|
+
synthesizer_paths=synth_paths,
|
|
556
|
+
test_run_paths=test_run_paths,
|
|
557
|
+
producer_paths=None, # filled in by run_review after producer phase
|
|
558
|
+
)
|
|
559
|
+
return RoundResult(
|
|
560
|
+
round_idx=round_idx,
|
|
561
|
+
snapshot=snapshot,
|
|
562
|
+
dispatch_result=dispatch_result,
|
|
563
|
+
synth_result=synth_result,
|
|
564
|
+
test_result=test_result,
|
|
565
|
+
test_skip_reason=test_skip_reason,
|
|
566
|
+
test_worktree_error=test_worktree_error,
|
|
567
|
+
producer_result=None,
|
|
568
|
+
round_exit_code=round_exit_code,
|
|
569
|
+
artifacts=artifacts,
|
|
570
|
+
check_results=check_results,
|
|
571
|
+
filtered_diff_bytes=len(_filtered_for_check.encode("utf-8")),
|
|
572
|
+
raw_diff_bytes=len((snapshot.diff_text or "").encode("utf-8")),
|
|
573
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Mechanical-checks leg of the per-round pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from syncade.persistence import persist_check_result
|
|
6
|
+
from syncade.test_runner import TestRunResult, run_tests
|
|
7
|
+
from syncade.worktree import WorktreeError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _run_checks_leg(
|
|
11
|
+
*,
|
|
12
|
+
manager,
|
|
13
|
+
config,
|
|
14
|
+
snapshot,
|
|
15
|
+
dispatch_result,
|
|
16
|
+
synth_result,
|
|
17
|
+
resolved_timeout,
|
|
18
|
+
round_dir,
|
|
19
|
+
logger,
|
|
20
|
+
) -> list[TestRunResult]:
|
|
21
|
+
"""Run the configured mechanical checks in stripped worktrees and return
|
|
22
|
+
their results (``[]`` when the gate isn't met / no checks configured).
|
|
23
|
+
|
|
24
|
+
Gate: reviewers succeeded AND the synth produced output — INCLUDING
|
|
25
|
+
synth-blocker rounds, so advisory drift surfaces on every reviewable round
|
|
26
|
+
(the check scenario). Skipped entirely when no checks are configured
|
|
27
|
+
(zero-config → byte-identical) or when reviewers failed / synth errored.
|
|
28
|
+
"""
|
|
29
|
+
check_results: list[TestRunResult] = []
|
|
30
|
+
if not (
|
|
31
|
+
config.checks
|
|
32
|
+
and dispatch_result.all_succeeded
|
|
33
|
+
and synth_result is not None
|
|
34
|
+
and synth_result.output is not None
|
|
35
|
+
):
|
|
36
|
+
return check_results
|
|
37
|
+
|
|
38
|
+
check_timeout = (
|
|
39
|
+
config.loop.test_timeout_seconds
|
|
40
|
+
if config.loop.test_timeout_seconds is not None
|
|
41
|
+
else resolved_timeout
|
|
42
|
+
)
|
|
43
|
+
logger.event(f"running {len(config.checks)} mechanical check(s)")
|
|
44
|
+
for check in config.checks:
|
|
45
|
+
try:
|
|
46
|
+
check_worktree = manager.create(
|
|
47
|
+
reviewer_name=check.name,
|
|
48
|
+
commit_sha=snapshot.commit_sha,
|
|
49
|
+
strip_files=config.review.strip_repo_context_files,
|
|
50
|
+
)
|
|
51
|
+
except WorktreeError as exc:
|
|
52
|
+
# A check whose worktree can't be provisioned counts as a
|
|
53
|
+
# subprocess_error (the check couldn't run): a blocking
|
|
54
|
+
# check then → REVIEWER_FAILURE, advisory → surfaced
|
|
55
|
+
# only. Name collisions are rejected at config-load, so
|
|
56
|
+
# this is a disk/path failure, not a name clash.
|
|
57
|
+
check_results.append(
|
|
58
|
+
TestRunResult(
|
|
59
|
+
name=check.name,
|
|
60
|
+
severity=check.severity,
|
|
61
|
+
exit_code=-1,
|
|
62
|
+
outcome="subprocess_error",
|
|
63
|
+
duration_seconds=0.0,
|
|
64
|
+
stdout="",
|
|
65
|
+
stderr="",
|
|
66
|
+
error=exc,
|
|
67
|
+
command=check.command,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
continue
|
|
71
|
+
logger.event(f" worktree ready: {check.name} -> {check_worktree.path}")
|
|
72
|
+
check_result = run_tests(
|
|
73
|
+
worktree_path=check_worktree.path,
|
|
74
|
+
test_command=check.command,
|
|
75
|
+
timeout_seconds=check_timeout,
|
|
76
|
+
name=check.name,
|
|
77
|
+
severity=check.severity,
|
|
78
|
+
)
|
|
79
|
+
prefix = (
|
|
80
|
+
f" check {check_result.name!r} ({check_result.severity}) finished in "
|
|
81
|
+
f"{check_result.duration_seconds:.1f}s — "
|
|
82
|
+
)
|
|
83
|
+
if check_result.outcome == "subprocess_error":
|
|
84
|
+
err_cls = type(check_result.error).__name__ if check_result.error else "Unknown"
|
|
85
|
+
logger.event(f"{prefix}SUBPROCESS_ERROR ({err_cls})", error=True)
|
|
86
|
+
else:
|
|
87
|
+
logger.event(f"{prefix}{check_result.outcome.upper()} (exit {check_result.exit_code})")
|
|
88
|
+
check_results.append(check_result)
|
|
89
|
+
for cr in check_results:
|
|
90
|
+
persist_check_result(round_dir, cr)
|
|
91
|
+
return check_results
|