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,483 @@
|
|
|
1
|
+
"""Tree-drift checks + completed-round rehydration.
|
|
2
|
+
|
|
3
|
+
``check_tree_drift`` (with the ``_current_head_*`` helpers) validates the
|
|
4
|
+
operator's HEAD/branch against the expected resume target; ``load_completed_round``
|
|
5
|
+
rebuilds an in-memory :class:`RoundResult` from a completed round's artifacts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pydantic import ValidationError
|
|
14
|
+
|
|
15
|
+
from syncade.git_object_id import is_full_git_object_id
|
|
16
|
+
from syncade.process import SubprocessError, run_subprocess
|
|
17
|
+
from syncade.usage import usage_from_fields
|
|
18
|
+
|
|
19
|
+
from .resume_types import (
|
|
20
|
+
_GIT_TIMEOUT_SECONDS,
|
|
21
|
+
ROUND_MANIFEST_FILENAME,
|
|
22
|
+
ResumeError,
|
|
23
|
+
TreeDriftError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
_REHYDRATED_DIFF_PRESENT_SENTINEL = "[syncade resume: persisted diff was present]\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _current_head_sha(repo_root: Path) -> str:
|
|
30
|
+
"""Return current HEAD as a full object ID. Wraps git rev-parse."""
|
|
31
|
+
try:
|
|
32
|
+
result = run_subprocess(
|
|
33
|
+
["git", "rev-parse", "HEAD"],
|
|
34
|
+
cwd=repo_root,
|
|
35
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
36
|
+
)
|
|
37
|
+
except SubprocessError as e:
|
|
38
|
+
raise ResumeError(f"could not resolve HEAD in {repo_root} for tree-drift check: {e}") from e
|
|
39
|
+
if result.returncode != 0:
|
|
40
|
+
raise ResumeError(f"git rev-parse HEAD failed in {repo_root}: {result.stderr.strip()}")
|
|
41
|
+
sha = result.stdout.strip()
|
|
42
|
+
if not is_full_git_object_id(sha):
|
|
43
|
+
raise ResumeError(
|
|
44
|
+
f"git rev-parse HEAD returned unexpected value {sha!r} in "
|
|
45
|
+
f"{repo_root} (expected a full SHA-1/SHA-256 object ID)"
|
|
46
|
+
)
|
|
47
|
+
return sha
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _current_head_branch(repo_root: Path) -> str | None:
|
|
51
|
+
"""Return current branch name, or ``None`` for detached HEAD.
|
|
52
|
+
|
|
53
|
+
``git rev-parse --abbrev-ref HEAD`` returns the literal string
|
|
54
|
+
``"HEAD"`` when detached; this helper normalizes that to
|
|
55
|
+
``None`` so the caller can compare against
|
|
56
|
+
:attr:`Snapshot.branch` (which uses the same convention).
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
result = run_subprocess(
|
|
60
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
61
|
+
cwd=repo_root,
|
|
62
|
+
timeout=_GIT_TIMEOUT_SECONDS,
|
|
63
|
+
)
|
|
64
|
+
except SubprocessError as e:
|
|
65
|
+
raise ResumeError(
|
|
66
|
+
f"could not resolve branch in {repo_root} for tree-drift check: {e}"
|
|
67
|
+
) from e
|
|
68
|
+
if result.returncode != 0:
|
|
69
|
+
raise ResumeError(
|
|
70
|
+
f"git rev-parse --abbrev-ref HEAD failed in {repo_root}: {result.stderr.strip()}"
|
|
71
|
+
)
|
|
72
|
+
branch = result.stdout.strip()
|
|
73
|
+
if branch == "HEAD" or not branch:
|
|
74
|
+
return None
|
|
75
|
+
return branch
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def check_tree_drift(
|
|
79
|
+
repo_root: Path,
|
|
80
|
+
expected_sha: str,
|
|
81
|
+
expected_branch: str | None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Validate that ``repo_root``'s current HEAD matches the
|
|
84
|
+
expected resume target.
|
|
85
|
+
|
|
86
|
+
Branch check FIRST, then SHA check — the rationale (from the
|
|
87
|
+
the design): "Refusing on SHA alone misses the case where the
|
|
88
|
+
operator switched branches between abort and resume (the new
|
|
89
|
+
branch might happen to share a SHA with the run's recorded
|
|
90
|
+
SHA, which would silently resume against the wrong tree)."
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
repo_root: The git repository root to check.
|
|
94
|
+
expected_sha: The full object ID the resumed round should
|
|
95
|
+
snapshot from (derived by :func:`plan_resume`).
|
|
96
|
+
expected_branch: The branch the original run was started
|
|
97
|
+
on. ``None`` when the original run was on detached
|
|
98
|
+
HEAD; in that case the operator must still be on a
|
|
99
|
+
detached HEAD (or pass ``--force-drift``).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
``None`` when no drift is detected.
|
|
103
|
+
|
|
104
|
+
Raises:
|
|
105
|
+
TreeDriftError: With ``kind="branch"`` for branch drift or
|
|
106
|
+
``kind="sha"`` for SHA drift. ``kind="branch"`` is
|
|
107
|
+
checked first so a branch mismatch isn't masked by a
|
|
108
|
+
coincidental SHA match.
|
|
109
|
+
ResumeError: When git itself can't answer (binary missing,
|
|
110
|
+
timeout, non-zero exit code from rev-parse). The CLI
|
|
111
|
+
maps this to exit 60 the same way TreeDriftError does
|
|
112
|
+
without --force-drift, since the operator's environment
|
|
113
|
+
is in a state where we can't validate.
|
|
114
|
+
"""
|
|
115
|
+
current_branch = _current_head_branch(repo_root)
|
|
116
|
+
# Branch check first. Treat both sides as a comparable string:
|
|
117
|
+
# detached HEAD is encoded as ``None`` on both the expected and
|
|
118
|
+
# current sides so they compare equal when both are detached.
|
|
119
|
+
if current_branch != expected_branch:
|
|
120
|
+
# str() to coerce None → "None" for the operator message;
|
|
121
|
+
# the kind="branch" marker tells the CLI which case to
|
|
122
|
+
# surface.
|
|
123
|
+
raise TreeDriftError(
|
|
124
|
+
kind="branch",
|
|
125
|
+
expected=expected_branch if expected_branch is not None else "(detached HEAD)",
|
|
126
|
+
actual=current_branch if current_branch is not None else "(detached HEAD)",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
current_sha = _current_head_sha(repo_root)
|
|
130
|
+
if current_sha != expected_sha:
|
|
131
|
+
raise TreeDriftError(
|
|
132
|
+
kind="sha",
|
|
133
|
+
expected=expected_sha,
|
|
134
|
+
actual=current_sha,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _safe_read(path: Path) -> str:
|
|
139
|
+
"""Read a text file, returning empty string if missing."""
|
|
140
|
+
try:
|
|
141
|
+
return path.read_text(encoding="utf-8")
|
|
142
|
+
except (OSError, FileNotFoundError):
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def load_completed_round(round_dir: Path):
|
|
147
|
+
"""Rebuild an in-memory :class:`RoundResult` from a completed
|
|
148
|
+
round's on-disk artifacts.
|
|
149
|
+
|
|
150
|
+
Called by :func:`run_review` (the resume entry path) to
|
|
151
|
+
pre-populate ``round_results`` with each completed round's state
|
|
152
|
+
so the loop's downstream consumers (loop-summary rendering,
|
|
153
|
+
handoff rendering, cross-round context disk reads) see a
|
|
154
|
+
consistent picture.
|
|
155
|
+
|
|
156
|
+
The rehydration is LOSSY by design: ``Snapshot.diff_text``,
|
|
157
|
+
``Snapshot.dirty_state``, raw subprocess returncodes (for
|
|
158
|
+
reviewer / synth) are not persisted as such and cannot be
|
|
159
|
+
recovered. They get reasonable placeholder values (empty diff,
|
|
160
|
+
``"clean"`` dirty state, ``None`` for raw_subprocess_result on
|
|
161
|
+
reviewer / synth). What MUST round-trip cleanly: per-reviewer
|
|
162
|
+
``ReviewerOutput`` (from ``.parsed.json``), per-synth
|
|
163
|
+
``SynthesizerOutput`` (from ``synthesizer.parsed.json``),
|
|
164
|
+
per-test outcome + exit code, per-producer outcome + SHAs +
|
|
165
|
+
narrative. Diff byte counts are deliberately NOT rehydrated (see
|
|
166
|
+
the ``filtered_diff_bytes`` note at the construction site).
|
|
167
|
+
|
|
168
|
+
PRECONDITION: this round is in :attr:`ResumePlan.completed_rounds`
|
|
169
|
+
— i.e., :func:`_round_manifest_indicates_phase_failure` returned
|
|
170
|
+
False. Every reviewer.outcome is ``"success"``, every synth.outcome
|
|
171
|
+
(when present) is ``"success"``, no test_run / producer
|
|
172
|
+
``subprocess_error``. The function does NOT handle the failure
|
|
173
|
+
paths because they would have been dropped by ``plan_resume``.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
round_dir: ``<run_dir>/round-N/``. Must contain a clean
|
|
177
|
+
``manifest.json``.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
A :class:`RoundResult` matching the rehydrated state, or
|
|
181
|
+
``None`` if the manifest is missing or unreadable (caller
|
|
182
|
+
should treat this as an incomplete round and drop the
|
|
183
|
+
directory).
|
|
184
|
+
|
|
185
|
+
Raises:
|
|
186
|
+
ResumeError: When the manifest exists but is malformed
|
|
187
|
+
(e.g. missing a required field). Distinct from "missing
|
|
188
|
+
manifest" so the caller can distinguish "round was
|
|
189
|
+
never persisted" (None) from "round was persisted but
|
|
190
|
+
corrupt" (raise).
|
|
191
|
+
"""
|
|
192
|
+
# Imports deferred to call time to avoid a circular import:
|
|
193
|
+
# syncade.orchestrator.results imports from syncade.persistence,
|
|
194
|
+
# which doesn't import back, but the dispatch / synthesizer /
|
|
195
|
+
# test_runner / producer modules form a heavier graph the resume
|
|
196
|
+
# module shouldn't touch at module load.
|
|
197
|
+
from syncade.adapters.producer import ProducerOutput
|
|
198
|
+
from syncade.dispatcher import DispatchResult, ReviewerRunResult
|
|
199
|
+
from syncade.findings import ReviewerOutput
|
|
200
|
+
from syncade.producer import ProducerResult
|
|
201
|
+
from syncade.snapshot import Snapshot
|
|
202
|
+
from syncade.synthesis import SynthesizerOutput
|
|
203
|
+
from syncade.synthesizer import SynthesizerResult
|
|
204
|
+
from syncade.test_runner import TestRunResult
|
|
205
|
+
|
|
206
|
+
from .results import RoundArtifacts, RoundResult
|
|
207
|
+
|
|
208
|
+
manifest_path = round_dir / ROUND_MANIFEST_FILENAME
|
|
209
|
+
if not manifest_path.is_file():
|
|
210
|
+
return None
|
|
211
|
+
try:
|
|
212
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
213
|
+
except json.JSONDecodeError as e:
|
|
214
|
+
raise ResumeError(f"completed-round manifest at {manifest_path} is malformed: {e}") from e
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
round_idx = int(manifest["round"])
|
|
218
|
+
snap_data = manifest["snapshot"]
|
|
219
|
+
reviewers_data = manifest["reviewers"]
|
|
220
|
+
round_exit_code = int(manifest["round_exit_code"])
|
|
221
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
222
|
+
raise ResumeError(
|
|
223
|
+
f"{manifest_path} is missing a required field for rehydration: {e}"
|
|
224
|
+
) from e
|
|
225
|
+
|
|
226
|
+
# --- Snapshot ---------------------------------------------------
|
|
227
|
+
# repo_root inferred from round_dir's grandparent-grandparent. The
|
|
228
|
+
# convention is .syncade/runs/<run-id>/round-N → 4 .parents up is
|
|
229
|
+
# the repo root. Best-effort; downstream consumers of rehydrated
|
|
230
|
+
# rounds should read snapshot.commit_sha + branch + base_ref but
|
|
231
|
+
# NOT operate on snapshot.repo_root (which is the running
|
|
232
|
+
# orchestrator's responsibility).
|
|
233
|
+
inferred_repo_root = round_dir.parent.parent.parent.parent
|
|
234
|
+
try:
|
|
235
|
+
diff_present = snap_data.get("diff_present", False)
|
|
236
|
+
if not isinstance(diff_present, bool):
|
|
237
|
+
raise ResumeError(
|
|
238
|
+
f"{manifest_path} has malformed snapshot block: "
|
|
239
|
+
f"snapshot.diff_present must be bool, got {diff_present!r}"
|
|
240
|
+
)
|
|
241
|
+
snapshot = Snapshot(
|
|
242
|
+
repo_root=inferred_repo_root,
|
|
243
|
+
commit_sha=str(snap_data["commit_sha"]),
|
|
244
|
+
branch=snap_data.get("branch"),
|
|
245
|
+
base_ref=snap_data.get("base_ref"),
|
|
246
|
+
base_oid=snap_data.get("base_oid"),
|
|
247
|
+
diff_text=_REHYDRATED_DIFF_PRESENT_SENTINEL if diff_present else "",
|
|
248
|
+
dirty_state="clean", # Not persisted; assume clean (completed round).
|
|
249
|
+
)
|
|
250
|
+
except ResumeError:
|
|
251
|
+
raise
|
|
252
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
253
|
+
raise ResumeError(f"{manifest_path} has malformed snapshot block: {e}") from e
|
|
254
|
+
|
|
255
|
+
# --- Reviewer dispatch ------------------------------------------
|
|
256
|
+
reviewer_results: list[ReviewerRunResult] = []
|
|
257
|
+
try:
|
|
258
|
+
for r in reviewers_data:
|
|
259
|
+
name = r["name"]
|
|
260
|
+
if not isinstance(name, str) or not name:
|
|
261
|
+
raise ResumeError(
|
|
262
|
+
f"{manifest_path} has malformed reviewers block: "
|
|
263
|
+
f"reviewer name must be a non-empty string, got {name!r}"
|
|
264
|
+
)
|
|
265
|
+
provider = str(r["provider"])
|
|
266
|
+
model = r.get("model") if isinstance(r.get("model"), str) else ""
|
|
267
|
+
duration = float(r.get("duration_seconds", 0.0))
|
|
268
|
+
parsed_path = round_dir / f"{name}.parsed.json"
|
|
269
|
+
if not parsed_path.is_file():
|
|
270
|
+
raise ResumeError(
|
|
271
|
+
f"reviewer {name!r} in {round_dir} has no "
|
|
272
|
+
f"{parsed_path.name}; cannot rehydrate ReviewerOutput"
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
output = ReviewerOutput.model_validate_json(parsed_path.read_text(encoding="utf-8"))
|
|
276
|
+
except (OSError, ValidationError) as e:
|
|
277
|
+
raise ResumeError(
|
|
278
|
+
f"{parsed_path} could not be parsed as ReviewerOutput: {e}"
|
|
279
|
+
) from e
|
|
280
|
+
reviewer_results.append(
|
|
281
|
+
ReviewerRunResult(
|
|
282
|
+
reviewer_name=name,
|
|
283
|
+
provider=provider,
|
|
284
|
+
output=output,
|
|
285
|
+
error=None,
|
|
286
|
+
duration_seconds=duration,
|
|
287
|
+
raw_subprocess_result=None, # not recoverable; not read downstream
|
|
288
|
+
# Rehydrate persisted usage so a resumed run's loop-manifest +
|
|
289
|
+
# metrics keep the prior round's spend (finding #2).
|
|
290
|
+
usage=usage_from_fields(
|
|
291
|
+
r.get("tokens"),
|
|
292
|
+
r.get("cost_usd"),
|
|
293
|
+
r.get("cost_source"),
|
|
294
|
+
model=model,
|
|
295
|
+
auth_mode=r.get("auth_mode"),
|
|
296
|
+
),
|
|
297
|
+
model=model,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
except ResumeError:
|
|
301
|
+
raise
|
|
302
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
303
|
+
raise ResumeError(f"{manifest_path} has malformed reviewers block: {e}") from e
|
|
304
|
+
|
|
305
|
+
total_duration = sum(r.duration_seconds for r in reviewer_results)
|
|
306
|
+
dispatch_result = DispatchResult(
|
|
307
|
+
results=reviewer_results,
|
|
308
|
+
total_duration_seconds=total_duration,
|
|
309
|
+
reviewer_subprocess_started=bool(reviewer_results),
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
# --- Synthesizer ------------------------------------------------
|
|
313
|
+
synth_block = manifest.get("synthesizer")
|
|
314
|
+
synth_result = None
|
|
315
|
+
if synth_block is not None:
|
|
316
|
+
synth_duration = float(synth_block.get("duration_seconds", 0.0))
|
|
317
|
+
synth_provider = (
|
|
318
|
+
synth_block.get("provider") if isinstance(synth_block.get("provider"), str) else None
|
|
319
|
+
)
|
|
320
|
+
synth_model = (
|
|
321
|
+
synth_block.get("model") if isinstance(synth_block.get("model"), str) else None
|
|
322
|
+
)
|
|
323
|
+
parsed_path = round_dir / "synthesizer.parsed.json"
|
|
324
|
+
if not parsed_path.is_file():
|
|
325
|
+
raise ResumeError(
|
|
326
|
+
f"synthesizer success recorded but {parsed_path} missing "
|
|
327
|
+
f"in {round_dir}; cannot rehydrate SynthesizerOutput"
|
|
328
|
+
)
|
|
329
|
+
try:
|
|
330
|
+
synth_output = SynthesizerOutput.model_validate_json(
|
|
331
|
+
parsed_path.read_text(encoding="utf-8")
|
|
332
|
+
)
|
|
333
|
+
except (OSError, ValidationError) as e:
|
|
334
|
+
raise ResumeError(f"{parsed_path} could not be parsed as SynthesizerOutput: {e}") from e
|
|
335
|
+
synth_result = SynthesizerResult(
|
|
336
|
+
output=synth_output,
|
|
337
|
+
error=None,
|
|
338
|
+
duration_seconds=synth_duration,
|
|
339
|
+
raw_subprocess_result=None,
|
|
340
|
+
usage=usage_from_fields(
|
|
341
|
+
synth_block.get("tokens"),
|
|
342
|
+
synth_block.get("cost_usd"),
|
|
343
|
+
synth_block.get("cost_source"),
|
|
344
|
+
model=synth_model or "",
|
|
345
|
+
auth_mode=synth_block.get("auth_mode"),
|
|
346
|
+
),
|
|
347
|
+
provider=synth_provider,
|
|
348
|
+
model=synth_model,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# --- Test re-run ------------------------------------------------
|
|
352
|
+
test_block = manifest.get("test_run")
|
|
353
|
+
test_result: TestRunResult | None = None
|
|
354
|
+
if test_block is not None:
|
|
355
|
+
try:
|
|
356
|
+
outcome = str(test_block["outcome"])
|
|
357
|
+
# Rehydration contract: outcome is "passed" or "failed". The
|
|
358
|
+
# "subprocess_error" outcome would have flagged the round as
|
|
359
|
+
# a phase failure and plan_resume would not have admitted it
|
|
360
|
+
# to completed_rounds. Defensive guard below.
|
|
361
|
+
if outcome == "subprocess_error":
|
|
362
|
+
raise ResumeError(
|
|
363
|
+
f"{manifest_path}: test_run.outcome=='subprocess_error' "
|
|
364
|
+
f"but plan_resume admitted this round to "
|
|
365
|
+
f"completed_rounds — contract violation"
|
|
366
|
+
)
|
|
367
|
+
if outcome not in ("passed", "failed"):
|
|
368
|
+
raise ResumeError(
|
|
369
|
+
f"{manifest_path} has malformed test_run block: unexpected outcome {outcome!r}"
|
|
370
|
+
)
|
|
371
|
+
stdout = _safe_read(round_dir / "test-run.stdout")
|
|
372
|
+
stderr = _safe_read(round_dir / "test-run.stderr")
|
|
373
|
+
test_result = TestRunResult(
|
|
374
|
+
exit_code=int(test_block["exit_code"]),
|
|
375
|
+
outcome=outcome,
|
|
376
|
+
duration_seconds=float(test_block.get("duration_seconds", 0.0)),
|
|
377
|
+
stdout=stdout,
|
|
378
|
+
stderr=stderr,
|
|
379
|
+
error=None,
|
|
380
|
+
command=str(test_block.get("command", "")),
|
|
381
|
+
)
|
|
382
|
+
except ResumeError:
|
|
383
|
+
raise
|
|
384
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
385
|
+
raise ResumeError(f"{manifest_path} has malformed test_run block: {e}") from e
|
|
386
|
+
|
|
387
|
+
# --- Producer ---------------------------------------------------
|
|
388
|
+
producer_block = manifest.get("producer")
|
|
389
|
+
producer_result: ProducerResult | None = None
|
|
390
|
+
if producer_block is not None:
|
|
391
|
+
try:
|
|
392
|
+
outcome = str(producer_block["outcome"])
|
|
393
|
+
if outcome == "subprocess_error":
|
|
394
|
+
raise ResumeError(
|
|
395
|
+
f"{manifest_path}: producer.outcome=='subprocess_error' "
|
|
396
|
+
f"but plan_resume admitted this round to "
|
|
397
|
+
f"completed_rounds — contract violation"
|
|
398
|
+
)
|
|
399
|
+
starting_sha = str(producer_block["starting_sha"])
|
|
400
|
+
ending_sha = str(producer_block["ending_sha"])
|
|
401
|
+
producer_provider = (
|
|
402
|
+
producer_block.get("provider")
|
|
403
|
+
if isinstance(producer_block.get("provider"), str)
|
|
404
|
+
else None
|
|
405
|
+
)
|
|
406
|
+
producer_model = (
|
|
407
|
+
producer_block.get("model")
|
|
408
|
+
if isinstance(producer_block.get("model"), str)
|
|
409
|
+
else None
|
|
410
|
+
)
|
|
411
|
+
if not starting_sha or not ending_sha:
|
|
412
|
+
raise ResumeError(
|
|
413
|
+
f"{manifest_path} has malformed producer block: "
|
|
414
|
+
"missing starting_sha or ending_sha"
|
|
415
|
+
)
|
|
416
|
+
if outcome not in ("committed", "stalled"):
|
|
417
|
+
raise ResumeError(
|
|
418
|
+
f"{manifest_path} has malformed producer block: unexpected outcome {outcome!r}"
|
|
419
|
+
)
|
|
420
|
+
narrative = _safe_read(round_dir / "producer.stdout")
|
|
421
|
+
producer_result = ProducerResult(
|
|
422
|
+
outcome=outcome,
|
|
423
|
+
starting_sha=starting_sha,
|
|
424
|
+
ending_sha=ending_sha,
|
|
425
|
+
duration_seconds=float(producer_block.get("duration_seconds", 0.0)),
|
|
426
|
+
output=ProducerOutput(narrative_text=narrative),
|
|
427
|
+
error=None,
|
|
428
|
+
raw_subprocess_result=None,
|
|
429
|
+
retries=int(producer_block.get("retried", 0)),
|
|
430
|
+
usage=usage_from_fields(
|
|
431
|
+
producer_block.get("tokens"),
|
|
432
|
+
producer_block.get("cost_usd"),
|
|
433
|
+
producer_block.get("cost_source"),
|
|
434
|
+
model=producer_model or "",
|
|
435
|
+
auth_mode=producer_block.get("auth_mode"),
|
|
436
|
+
),
|
|
437
|
+
provider=producer_provider,
|
|
438
|
+
model=producer_model,
|
|
439
|
+
)
|
|
440
|
+
except ResumeError:
|
|
441
|
+
raise
|
|
442
|
+
except (KeyError, TypeError, ValueError) as e:
|
|
443
|
+
raise ResumeError(f"{manifest_path} has malformed producer block: {e}") from e
|
|
444
|
+
|
|
445
|
+
# --- Artifacts (paths only) -------------------------------------
|
|
446
|
+
findings_md = round_dir / "findings.md"
|
|
447
|
+
artifacts = RoundArtifacts(
|
|
448
|
+
round_idx=round_idx,
|
|
449
|
+
round_dir=round_dir,
|
|
450
|
+
manifest_path=manifest_path,
|
|
451
|
+
summary_path=round_dir / "summary.md",
|
|
452
|
+
findings_md_path=findings_md if findings_md.is_file() else None,
|
|
453
|
+
# synthesizer_paths / test_run_paths / producer_paths: omitted
|
|
454
|
+
# for rehydrated rounds. Resumed rounds don't re-run persistence, so
|
|
455
|
+
# these stay None. Nothing downstream of run_review reads them for
|
|
456
|
+
# rehydrated rounds.
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return RoundResult(
|
|
460
|
+
round_idx=round_idx,
|
|
461
|
+
snapshot=snapshot,
|
|
462
|
+
dispatch_result=dispatch_result,
|
|
463
|
+
synth_result=synth_result,
|
|
464
|
+
test_result=test_result,
|
|
465
|
+
test_skip_reason=manifest.get("test_skip_reason"),
|
|
466
|
+
test_worktree_error=None,
|
|
467
|
+
producer_result=producer_result,
|
|
468
|
+
round_exit_code=round_exit_code,
|
|
469
|
+
artifacts=artifacts,
|
|
470
|
+
# Diff sizes are NOT rehydrated. A resumed round re-persists with `null`, which is
|
|
471
|
+
# honest: this process did not measure that diff. Carrying the numbers forward meant
|
|
472
|
+
# trusting (and therefore validating) arbitrary on-disk values, and mixing a
|
|
473
|
+
# force-drift resume's current snapshot with the original round's counts. Losing a
|
|
474
|
+
# few resumed rounds from the size history is cheaper than a history that can lie.
|
|
475
|
+
# check_results: intentionally left at its empty-list default —
|
|
476
|
+
# not rehydrated, by the same lossy contract as raw_subprocess_result
|
|
477
|
+
# above. Resumed rounds don't re-run persistence, and nothing
|
|
478
|
+
# downstream of run_review reads a rehydrated round's check_results
|
|
479
|
+
# (loop-manifest.json / loop-summary.md derive their per-round entries
|
|
480
|
+
# without it). The subprocess_error outcome also can't round-trip
|
|
481
|
+
# faithfully — only error_type is serialized, but TestRunResult
|
|
482
|
+
# requires a live error object — so a partial rehydration would lie.
|
|
483
|
+
)
|