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,215 @@
1
+ """Resume-target resolution.
2
+
3
+ ``find_resumable_runs`` lists eligible run-ids; ``resolve_resume_target`` turns
4
+ a CLI ``--resume`` target ("latest" or a concrete id) into a validated run-id.
5
+ Pure read-from-disk; raises :class:`ResumeError` on a non-resolvable target.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from syncade.persistence import RUN_INIT_FILENAME
14
+ from syncade.persistence.decision_needed import DECISION_NEEDED_FILENAME
15
+
16
+ from .resume_types import _RESUMABLE_EXIT_CODES, LOOP_MANIFEST_FILENAME, ResumeError
17
+
18
+ # Unique heading written only by persist_deactivated_blockers_decision_needed,
19
+ # never by the producer-escalation persist_decision_needed. When loop-manifest.json
20
+ # is missing (partial-finalization window: decision-needed.md written before manifest),
21
+ # this marker lets us identify the non-resumable shape without the manifest.
22
+ _DEACTIVATED_BLOCKERS_MARKER = "## What each reviewer actually said"
23
+
24
+
25
+ def _decision_needed_is_deactivated_shape(run_dir: Path) -> bool:
26
+ """Return True when ``decision-needed.md`` exists and carries the
27
+ blockers-all-deactivated marker (meaning the run is NOT resumable).
28
+
29
+ Used when ``loop-manifest.json`` is missing — the normal eligibility
30
+ predicate — to close the partial-finalization window where
31
+ ``decision-needed.md`` is written before ``loop-manifest.json`` is.
32
+ """
33
+ dn_path = run_dir / DECISION_NEEDED_FILENAME
34
+ try:
35
+ text = dn_path.read_text(encoding="utf-8")
36
+ except OSError:
37
+ return False
38
+ return _DEACTIVATED_BLOCKERS_MARKER in text
39
+
40
+
41
+ def find_resumable_runs(runs_root: Path) -> list[str]:
42
+ """Return run-ids under ``runs_root`` that are eligible to resume.
43
+
44
+ Newest first (sort by run-id string descending — run-id is a
45
+ UTC timestamp so lexical order matches chronological order).
46
+
47
+ Eligibility (per the design's "resume mental model" table):
48
+
49
+ - ``run-init.json`` present AND ``loop-manifest.json`` missing
50
+ → ELIGIBLE (interrupted: operator Ctrl-C / crash / SIGTERM).
51
+ - ``run-init.json`` present AND ``loop-manifest.json`` malformed/unreadable
52
+ → ELIGIBLE enough to surface during resume-target resolution; the later
53
+ planning path reports the specific corruption instead of silently hiding
54
+ the run.
55
+ - ``run-init.json`` present AND ``loop-manifest.json::final_exit_code``
56
+ in ``{10, 25, 40, 60, 70}`` → ELIGIBLE (decision-needed checkpoint, a
57
+ budget-or-quota stop (25), or environment failure mid-loop).
58
+ - Any other state → NOT eligible.
59
+
60
+ Args:
61
+ runs_root: The ``<repo>/.syncade/runs/`` directory.
62
+
63
+ Returns:
64
+ Sorted list of eligible run-ids, newest first. Empty list
65
+ when ``runs_root`` doesn't exist or contains no eligible
66
+ runs.
67
+
68
+ Does NOT raise on a malformed ``loop-manifest.json`` — that run is returned
69
+ as eligible enough to protect it from GC and let the resume planner surface
70
+ the specific corruption. The intent is that this function never surfaces an
71
+ error path the CLI has to handle directly; it just returns what's available.
72
+ The specific-id resume path (:func:`resolve_resume_target`) is responsible
73
+ for surfacing "run-id not eligible" with the SPECIFIC reason.
74
+ """
75
+ if not runs_root.is_dir():
76
+ return []
77
+
78
+ eligible: list[str] = []
79
+ for entry in runs_root.iterdir():
80
+ if not entry.is_dir():
81
+ continue
82
+ if not (entry / RUN_INIT_FILENAME).is_file():
83
+ # Not a syncade run, or missing the required resume artifact.
84
+ continue
85
+ loop_manifest_path = entry / LOOP_MANIFEST_FILENAME
86
+ if not loop_manifest_path.is_file():
87
+ # Missing manifest: usually an interrupted run — eligible, UNLESS
88
+ # decision-needed.md already carries the blockers-all-deactivated
89
+ # shape (written before the manifest in that path). That shape is
90
+ # non-resumable even without the manifest to confirm it.
91
+ if _decision_needed_is_deactivated_shape(entry):
92
+ continue
93
+ eligible.append(entry.name)
94
+ continue
95
+ try:
96
+ data = json.loads(loop_manifest_path.read_text(encoding="utf-8"))
97
+ except (OSError, json.JSONDecodeError):
98
+ # Malformed/unreadable loop-manifest — protect and surface rather
99
+ # than silently skipping. A later specific-plan path names the
100
+ # concrete failure.
101
+ eligible.append(entry.name)
102
+ continue
103
+ exit_code = data.get("final_exit_code")
104
+ if not (isinstance(exit_code, int) and exit_code in _RESUMABLE_EXIT_CODES):
105
+ continue
106
+ # Exit 10 with blockers_all_deactivated is not resumable: no producer
107
+ # ran, no blocker is active, and resume would re-review unchanged code.
108
+ # plan_resume also refuses this shape, but excluding it here prevents
109
+ # it from shadowing an older decision_needed run in --resume latest.
110
+ if exit_code == 10 and data.get("termination_reason") == "blockers_all_deactivated":
111
+ continue
112
+ eligible.append(entry.name)
113
+
114
+ eligible.sort(reverse=True)
115
+ return eligible
116
+
117
+
118
+ def _read_operator_branch(run_dir: Path) -> str | None:
119
+ """Best-effort read of ``run-init.json::operator_branch``."""
120
+ run_init = run_dir / RUN_INIT_FILENAME
121
+ try:
122
+ data = json.loads(run_init.read_text(encoding="utf-8"))
123
+ return data.get("operator_branch")
124
+ except (OSError, json.JSONDecodeError):
125
+ return None
126
+
127
+
128
+ def resolve_resume_target(
129
+ runs_root: Path,
130
+ target: str,
131
+ current_branch: str | None = None,
132
+ ) -> str:
133
+ """Resolve a CLI ``--resume`` target to a concrete run-id.
134
+
135
+ Two target shapes:
136
+
137
+ - ``"latest"`` → return the newest eligible run on
138
+ ``current_branch`` (when supplied) or the absolute newest
139
+ eligible run (when ``current_branch`` is ``None``, e.g.
140
+ detached HEAD).
141
+ - Any other string → treat as a concrete run-id and validate
142
+ its eligibility. The specific run-id's branch is NOT checked
143
+ here — that's the tree-drift check's job in T3.
144
+
145
+ Args:
146
+ runs_root: The ``<repo>/.syncade/runs/`` directory.
147
+ target: ``"latest"`` or a concrete run-id.
148
+ current_branch: The branch the operator is currently on.
149
+ When supplied, ``"latest"`` resolution filters by
150
+ ``operator_branch == current_branch``. When ``None``
151
+ (detached HEAD or no branch info), the absolute newest
152
+ eligible run is returned.
153
+
154
+ Returns:
155
+ The concrete run-id to resume.
156
+
157
+ Raises:
158
+ ResumeError: On any non-resolvable state. The message is
159
+ operator-facing and names the specific reason (no
160
+ eligible runs, run-id not found, completed normally,
161
+ etc.).
162
+ """
163
+ if target == "latest":
164
+ eligible = find_resumable_runs(runs_root)
165
+ if current_branch is not None:
166
+ eligible = [
167
+ r for r in eligible if _read_operator_branch(runs_root / r) == current_branch
168
+ ]
169
+ if not eligible:
170
+ if current_branch is not None:
171
+ raise ResumeError(
172
+ f"no eligible runs to resume on branch {current_branch!r} under {runs_root}"
173
+ )
174
+ raise ResumeError(f"no eligible runs to resume under {runs_root}")
175
+ return eligible[0]
176
+
177
+ # Specific run-id — validate eligibility with a helpful message.
178
+ run_dir = runs_root / target
179
+ if not run_dir.is_dir():
180
+ raise ResumeError(f"run-id {target!r} not found under {runs_root} (directory missing)")
181
+ if not (run_dir / RUN_INIT_FILENAME).is_file():
182
+ raise ResumeError(
183
+ f"run-id {target!r} is not a syncade run "
184
+ f"(no {RUN_INIT_FILENAME}). The directory is corrupted "
185
+ f"or missing the required resume artifact."
186
+ )
187
+ loop_manifest_path = run_dir / LOOP_MANIFEST_FILENAME
188
+ if loop_manifest_path.is_file():
189
+ try:
190
+ data = json.loads(loop_manifest_path.read_text(encoding="utf-8"))
191
+ except (OSError, json.JSONDecodeError) as e:
192
+ raise ResumeError(
193
+ f"run-id {target!r} has a malformed {LOOP_MANIFEST_FILENAME}: "
194
+ f"{e}. Investigate manually before resuming."
195
+ ) from e
196
+ exit_code = data.get("final_exit_code")
197
+ # Mirror find_resumable_runs's concrete-exit-code predicate: require an
198
+ # int in the resumable set.
199
+ # Without the isinstance guard a JSON float like 40.0 would slip through
200
+ # here (40.0 in {10,40,60,70} is True via numeric ==) while
201
+ # find_resumable_runs would treat it as NOT eligible
202
+ # (isinstance(40.0, int) is False). (bool is an int subclass but no bool
203
+ # is in the set.)
204
+ if not (isinstance(exit_code, int) and exit_code in _RESUMABLE_EXIT_CODES):
205
+ raise ResumeError(
206
+ f"run-id {target!r} completed normally "
207
+ f"(final_exit_code={exit_code}); resume only applies "
208
+ f"to runs paused for a decision (exit 10), stopped at a budget "
209
+ f"ceiling or a provider usage limit (exit 25), aborted by "
210
+ f"environment failure (exit 40 / 60 / 70), or interrupted "
211
+ f"before the loop terminator wrote {LOOP_MANIFEST_FILENAME}. "
212
+ f"Start a fresh run."
213
+ )
214
+ # loop-manifest missing → interrupted run → eligible. Fall through.
215
+ return target
@@ -0,0 +1,182 @@
1
+ """Shared types + constants for the resume layer.
2
+
3
+ Leaf module: the filename constants, the resumable-exit-code set, the git
4
+ timeout, and the three public types (:class:`ResumeError`, :class:`ResumePlan`,
5
+ :class:`TreeDriftError`). Imported by the ``resume_*`` siblings; re-exported by
6
+ the ``resume.py`` shim so ``syncade.orchestrator.resume.<name>`` import paths are
7
+ unchanged.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from syncade.exit_codes import (
17
+ BUDGET_EXCEEDED,
18
+ CLARIFICATION_NEEDED,
19
+ REVIEWER_FAILURE,
20
+ REVIEWER_OUTPUT_UNPARSEABLE,
21
+ WORKTREE_ERROR,
22
+ )
23
+
24
+ LOOP_MANIFEST_FILENAME: str = "loop-manifest.json"
25
+ """The top-level loop-manifest filename. Lockstep with
26
+ :mod:`syncade.persistence.loop_manifest`."""
27
+
28
+ ROUND_MANIFEST_FILENAME: str = "manifest.json"
29
+ """The per-round manifest filename. Lockstep with
30
+ :mod:`syncade.persistence.round_manifest`."""
31
+
32
+ # Final exit codes the loop can RESUME from. Reviewer/worktree/parse failures are
33
+ # environment/subprocess failures mid-loop (restart the errored round).
34
+ # 10 (CLARIFICATION_NEEDED): a producer escalation checkpoint — resume re-runs the
35
+ # escalated round with the operator's recorded decision.
36
+ # 25 (BUDGET_EXCEEDED, PR-v2-11): a deliberate cost-ceiling stop at a phase boundary.
37
+ # A before-producer stop's review bundle is persisted whole; plan_resume detects this
38
+ # case via the loop-manifest's termination_reason and sets budget_aborted_before_producer_round
39
+ # so the loop rehydrates the review bundle and dispatches only the producer on the fresh tally.
40
+ # Everything else (0, 20, 30) is a clean completion / NO-SHIP and the operator's next
41
+ # move is a fresh run.
42
+ _RESUMABLE_EXIT_CODES: frozenset[int] = frozenset(
43
+ {
44
+ CLARIFICATION_NEEDED,
45
+ BUDGET_EXCEEDED,
46
+ REVIEWER_FAILURE,
47
+ WORKTREE_ERROR,
48
+ REVIEWER_OUTPUT_UNPARSEABLE,
49
+ }
50
+ )
51
+
52
+ # Wall-clock ceiling for the git rev-parse calls in check_tree_drift.
53
+ # Sub-second on every reasonable repo; 5s is generous and bounded.
54
+ _GIT_TIMEOUT_SECONDS: float = 5.0
55
+
56
+
57
+ class ResumeError(Exception):
58
+ """Raised when a target run-id is not eligible to resume or its
59
+ on-disk state is malformed.
60
+
61
+ The CLI maps this to exit 60 (worktree/environment error class)
62
+ on the failing-resume path. The message is operator-facing and
63
+ should name the specific reason: not found, completed normally,
64
+ malformed run-init, etc.
65
+ """
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class ResumePlan:
70
+ """The orchestrator's input contract for a resumed run.
71
+
72
+ Built by :func:`plan_resume` from on-disk artifacts. Carries
73
+ everything ``run_review`` needs to skip the original-run setup
74
+ steps (run-id generation, run-init.json write) and pick up at
75
+ ``resumed_round`` with the right snapshot SHA.
76
+
77
+ Attributes:
78
+ run_id: The original run's id. The orchestrator reuses
79
+ this instead of generating a fresh one — that's the
80
+ point of resume.
81
+ run_dir: ``<repo>/.syncade/runs/<run_id>/``. Must exist.
82
+ pr_doc_path: PR doc the original run was invoked against.
83
+ Resumed reviewers see the CURRENT content of this file
84
+ (no diff/refuse on brief edits — operator's
85
+ responsibility).
86
+ operator_branch: Branch the original run was started on.
87
+ Read from ``run-init.json``. ``None`` for detached HEAD.
88
+ expected_sha: The full object ID the resumed round should
89
+ snapshot from. For round 0, the original run's
90
+ ``starting_sha``; for round N>0, the prior round's
91
+ advancement target (producer ending SHA, or snapshot
92
+ SHA if the producer didn't commit).
93
+ expected_branch: The branch the operator should still be on
94
+ at resume time. Identical to ``operator_branch``;
95
+ duplicated for symmetry with the tree-drift API.
96
+ resumed_round: 0-indexed round the resumed loop will run
97
+ FIRST. The orchestrator's for-loop enters at
98
+ ``range(resumed_round, max_rounds)``.
99
+ completed_rounds: 0-indexed list of rounds that completed
100
+ cleanly. Used by ``run_review`` (T4) to rehydrate
101
+ their ``RoundResult`` state via
102
+ :func:`load_completed_round`. Always
103
+ ``[0, 1, ..., resumed_round - 1]`` in v1 — the resume
104
+ walk is contiguous.
105
+ max_rounds: The original run's max_rounds. The CLI may
106
+ override this via ``--resume --max-rounds N``; that
107
+ override applies to the orchestrator's loop bound,
108
+ not to this field (which remains the original value
109
+ for diagnostic purposes).
110
+ syncade_version: The syncade version the original run was
111
+ launched with. Compared against the current
112
+ :data:`syncade.__version__` to emit a stderr warning
113
+ on mismatch (NOT a hard refusal — see brief's
114
+ "Out of scope").
115
+ config_snapshot_path: Path to the original ``run-init.json``.
116
+ Used by T4 for the "config drift" diagnostic
117
+ annotation on the new round's ``summary.md``.
118
+ budget_aborted_before_producer_round: When not None, the run was
119
+ budget-aborted (exit 25) before this round's producer ran.
120
+ The round's review bundle is complete and in ``completed_rounds``
121
+ for rehydration; the loop skips ``_run_one_round`` for this index
122
+ and dispatches only the producer under the fresh budget tally.
123
+ """
124
+
125
+ run_id: str
126
+ run_dir: Path
127
+ pr_doc_path: Path
128
+ operator_branch: str | None
129
+ expected_sha: str
130
+ expected_branch: str | None
131
+ resumed_round: int
132
+ completed_rounds: list[int] = field(default_factory=list)
133
+ max_rounds: int = 1
134
+ syncade_version: str = ""
135
+ config_snapshot_path: Path | None = None
136
+ base_ref: str | None = None
137
+ base_oid: str | None = None
138
+ budget_aborted_before_producer_round: int | None = None
139
+
140
+
141
+ class TreeDriftError(Exception):
142
+ """Raised by :func:`check_tree_drift` when the operator's current
143
+ HEAD or branch differs from the expected resume target.
144
+
145
+ The CLI maps this to exit 60 (worktree/environment error class)
146
+ unless the operator passed ``--force-drift``. The message names
147
+ BOTH the expected and actual values so the operator can see what
148
+ moved without consulting another tool.
149
+
150
+ Attributes:
151
+ kind: ``"branch"`` (branch mismatch — operator switched
152
+ branches between abort and resume) or ``"sha"`` (same
153
+ branch, different HEAD — operator committed or reset
154
+ between abort and resume).
155
+ expected: The expected branch name or SHA.
156
+ actual: The current branch name or SHA at resume time.
157
+ """
158
+
159
+ kind: Literal["branch", "sha"]
160
+ expected: str
161
+ actual: str
162
+
163
+ def __init__(self, kind: Literal["branch", "sha"], expected: str, actual: str) -> None:
164
+ self.kind = kind
165
+ self.expected = expected
166
+ self.actual = actual
167
+ if kind == "branch":
168
+ msg = (
169
+ f"branch drift: expected {expected!r} (the branch the "
170
+ f"original run was started on), got {actual!r} at resume "
171
+ f"time. Switch back to {expected!r} or pass --force-drift "
172
+ f"to accept the drift."
173
+ )
174
+ else:
175
+ msg = (
176
+ f"HEAD drift: expected {expected} (the SHA the resumed "
177
+ f"round was supposed to snapshot from), got {actual} at "
178
+ f"resume time. Reset / check out {expected} or pass "
179
+ f"--force-drift to accept the drift (the resumed round "
180
+ f"will snapshot from the new HEAD)."
181
+ )
182
+ super().__init__(msg)
@@ -0,0 +1,99 @@
1
+ """Artifact builder for reviewer-template render failures."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from syncade.config import SyncadeConfig
9
+ from syncade.dispatcher import DispatchResult, ReviewerRunResult
10
+ from syncade.exit_codes import REVIEWER_FAILURE
11
+ from syncade.persistence import persist_round_manifest, persist_run_summary
12
+ from syncade.process import SubprocessError
13
+ from syncade.snapshot import Snapshot
14
+
15
+ from .results import RoundArtifacts, RoundResult
16
+
17
+
18
+ def _reviewer_template_failure_result(
19
+ *,
20
+ round_idx: int,
21
+ snapshot: Snapshot,
22
+ round_dir: Path,
23
+ config: SyncadeConfig,
24
+ started_at: datetime,
25
+ resumed_under_drift: bool,
26
+ error: Exception,
27
+ filtered_diff_bytes: int | None = None,
28
+ raw_diff_bytes: int | None = None,
29
+ ) -> RoundResult:
30
+ """Build a phase-failure result for reviewer-template load/render errors."""
31
+ message = f"reviewer template render failed: {error}"
32
+ dispatch_result = DispatchResult(
33
+ results=[
34
+ ReviewerRunResult(
35
+ reviewer_name=reviewer.name,
36
+ provider=reviewer.provider,
37
+ output=None,
38
+ error=SubprocessError(message),
39
+ duration_seconds=0.0,
40
+ raw_subprocess_result=None,
41
+ )
42
+ for reviewer in config.reviewers
43
+ ],
44
+ total_duration_seconds=0.0,
45
+ )
46
+ persist_round_manifest(
47
+ round_dir,
48
+ snapshot,
49
+ dispatch_result,
50
+ REVIEWER_FAILURE,
51
+ started_at,
52
+ None,
53
+ None,
54
+ None,
55
+ round_idx=round_idx,
56
+ producer_result=None,
57
+ producer_provider=config.producer.provider,
58
+ producer_model=config.producer.model,
59
+ check_results=[],
60
+ filtered_diff_bytes=filtered_diff_bytes,
61
+ raw_diff_bytes=raw_diff_bytes,
62
+ )
63
+ persist_run_summary(
64
+ round_dir,
65
+ snapshot,
66
+ dispatch_result,
67
+ REVIEWER_FAILURE,
68
+ started_at,
69
+ None,
70
+ None,
71
+ None,
72
+ resumed_under_drift=resumed_under_drift,
73
+ check_results=[],
74
+ )
75
+ artifacts = RoundArtifacts(
76
+ round_idx=round_idx,
77
+ round_dir=round_dir,
78
+ manifest_path=round_dir / "manifest.json",
79
+ summary_path=round_dir / "summary.md",
80
+ findings_md_path=None,
81
+ synthesizer_paths=None,
82
+ test_run_paths=None,
83
+ producer_paths=None,
84
+ )
85
+ return RoundResult(
86
+ round_idx=round_idx,
87
+ snapshot=snapshot,
88
+ dispatch_result=dispatch_result,
89
+ synth_result=None,
90
+ test_result=None,
91
+ test_skip_reason=None,
92
+ test_worktree_error=None,
93
+ producer_result=None,
94
+ round_exit_code=REVIEWER_FAILURE,
95
+ artifacts=artifacts,
96
+ check_results=[],
97
+ filtered_diff_bytes=filtered_diff_bytes,
98
+ raw_diff_bytes=raw_diff_bytes,
99
+ )