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.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. 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
+ )