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,306 @@
1
+ """Result dataclasses + categorical Literals.
2
+
3
+ Holds the immutable per-round and per-run result dataclasses plus the two
4
+ ``Literal`` types they reference.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from syncade.dispatcher import DispatchResult
14
+ from syncade.persistence import (
15
+ ProducerArtifactPaths,
16
+ SynthesizerArtifactPaths,
17
+ TestRunArtifactPaths,
18
+ )
19
+ from syncade.producer import ProducerResult
20
+ from syncade.snapshot import Snapshot
21
+ from syncade.synthesizer import SynthesizerResult
22
+ from syncade.test_runner import TestRunResult
23
+ from syncade.usage import Usage
24
+ from syncade.worktree import WorktreeError
25
+
26
+ TestSkipReason = Literal[
27
+ "test_command_unset",
28
+ "reviewer_failed",
29
+ "synth_failed",
30
+ "synth_blocker",
31
+ "test_worktree_error",
32
+ ]
33
+ """Why the test re-run leg was skipped on a given round.
34
+ See the original ``TestSkipReason`` docstring for the per-
35
+ value semantics; this carries the type unchanged."""
36
+
37
+
38
+ TerminationReason = Literal[
39
+ "ship",
40
+ "no_changes_to_review",
41
+ "producer_emptied_diff",
42
+ "findings_present",
43
+ "max_rounds_reached",
44
+ "budget_exceeded",
45
+ "provider_usage_limit",
46
+ "producer_stalled",
47
+ "producer_subprocess_error",
48
+ "reviewer_failure",
49
+ "synth_failure",
50
+ "test_subprocess_error",
51
+ "check_subprocess_error",
52
+ "decision_needed",
53
+ "blockers_all_deactivated",
54
+ "worktree_error",
55
+ "diff_malformed",
56
+ "diff_too_large",
57
+ "prompt_too_large",
58
+ "parse_failure",
59
+ "config_error",
60
+ ]
61
+ """Why the loop terminated.
62
+
63
+ - ``ship`` — a round produced exit 0 (synth-clean + tests passed,
64
+ if configured). The loop terminates with SUCCESS.
65
+ - ``no_changes_to_review`` — the diff resolved to empty before any
66
+ reviewer was dispatched in round 0: the base resolved (``base_oid``
67
+ set) but the reviewer-facing diff was empty (either truly no changes,
68
+ or every section was legitimate repo-context stripped by the filter —
69
+ D1 and D3 in PR-h-02d). Zero subprocesses are dispatched for the whole
70
+ run. Loop terminates with SUCCESS (exit 0). Distinct from ``ship`` so
71
+ callers can tell "reviewed and approved" from "nothing to approve".
72
+ - ``producer_emptied_diff`` — same empty-diff condition as
73
+ ``no_changes_to_review``, but fired in a later round (round 1+) after
74
+ prior rounds already dispatched reviewers and a producer. The producer's
75
+ commits reduced all reviewable changes to nothing. Loop terminates with
76
+ SUCCESS (exit 0). Distinct from ``no_changes_to_review`` because model
77
+ work WAS spent in prior rounds.
78
+ - ``findings_present`` — a single-pass run produced NO-SHIP. The loop ends with
79
+ exit 30 without implying a retry budget was exhausted.
80
+ - ``max_rounds_reached`` — the last round was NO-SHIP and
81
+ ``round_idx + 1 == max_rounds``. Loop ends with exit 20.
82
+ - ``budget_exceeded`` — the running token/dollar tally crossed a configured
83
+ ``[loop]`` budget at a phase boundary (before starting the next round, or
84
+ before a NO-SHIP round's producer). Loop ends with exit 25 (PR-v2-11).
85
+ - ``producer_stalled`` — a NO-SHIP round's producer subprocess
86
+ exited cleanly but didn't move HEAD. The next round would see
87
+ identical input. Loop ends with exit 30 + this reason. Since
88
+ this also covers a producer escalation that was NOT honored
89
+ (its ``finding_indices`` left an active blocker uncovered, or
90
+ referenced a non-blocker / out-of-range index) — a rejected
91
+ escalation is treated as an ordinary stall, not ``decision_needed``.
92
+ - ``producer_subprocess_error`` — a producer subprocess failed
93
+ (timeout, binary missing, adapter ReviewerInvocationError,
94
+ ReviewerOutputError). Loop ends with exit 40.
95
+ - ``reviewer_failure`` — a reviewer subprocess failed; the
96
+ synthesizer was skipped per the no-silent-degradation rule;
97
+ the loop terminates with the appropriate reviewer-failure exit
98
+ code (40, 50, 70 depending on failure shape).
99
+ - ``synth_failure`` — every reviewer succeeded but the
100
+ synthesizer subprocess failed; loop terminates with the
101
+ appropriate synth-failure exit code (40 or 70).
102
+ - ``test_subprocess_error`` — synth was clean but the test leg's
103
+ subprocess failed (binary missing, timeout); loop terminates
104
+ with exit 40.
105
+ - ``check_subprocess_error`` — every reviewer + the synth
106
+ succeeded, but a BLOCKING mechanical check's subprocess couldn't
107
+ run (binary missing, timeout); loop terminates with exit 40.
108
+ - ``decision_needed`` — a NO-SHIP
109
+ round's producer ESCALATED a finding as an operator decision (a
110
+ spec/design conflict it cannot fix in code) rather than committing
111
+ or stalling silently. The loop honors the escalation — checkpoints
112
+ and terminates with exit 10 (CLARIFICATION_NEEDED), writing
113
+ ``decision-needed.md`` — ONLY when the escalation's
114
+ ``finding_indices`` cover EVERY active blocker in the round
115
+ (mechanical set-containment via
116
+ ``escalation_coverage.escalation_covers_active_blockers``). An
117
+ escalation that leaves an active blocker uncovered, or references a
118
+ non-blocker / out-of-range index, is NOT honored: it maps to
119
+ ``producer_stalled`` (exit 30) and ``decision-needed.md`` is not
120
+ written. Either way the mechanical verdict is unchanged (still
121
+ NO-SHIP); when honored, the operator records a decision and resumes.
122
+ - ``worktree_error`` — a worktree could not be provisioned for
123
+ the reviewer or test phase; loop terminates with exit 60.
124
+ - ``diff_malformed`` — the reviewer-facing diff contained sections
125
+ whose headers could not be identified (unparseable, malformed
126
+ escapes, or invalid UTF-8). The run is refused before any
127
+ dispatcher is invoked (D2, PR-h-02d); loop terminates with exit
128
+ 60. Distinct from ``worktree_error`` so tooling can tell "reviewer
129
+ worktree failed" from "diff could not be safely filtered".
130
+ - ``diff_too_large`` — the reviewer-facing diff (after stripping and
131
+ binary elision) exceeded ``[loop] max_diff_bytes``. The run is
132
+ refused before any dispatcher is invoked; loop terminates with exit
133
+ 60 (PR-h-field-01 item 3 / PR-h-02e D2). The refusal message names
134
+ the measured size, the cap, and three ways to reduce the diff.
135
+ - ``prompt_too_large`` — the assembled reviewer prompt (diff + spec +
136
+ template overhead) exceeded the hard PROVIDER ceiling. There is no
137
+ ``[loop] max_prompt_bytes`` knob and this must not imply one: the diff
138
+ is what an operator controls (``[loop] max_diff_bytes``), while the
139
+ prompt ceiling belongs to the provider (``codex exec`` refuses over
140
+ 1,048,576 characters) and is not ours to relax. The run is refused
141
+ before any dispatcher is invoked; loop terminates with exit 60.
142
+ - ``parse_failure`` — reviewer or synthesizer output couldn't be
143
+ parsed; loop terminates with exit 70.
144
+ - ``config_error`` — an adapter-lookup failure (e.g. unknown
145
+ provider) surfaced during dispatch; loop terminates with exit
146
+ 50.
147
+ """
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class RoundArtifacts:
152
+ """Per-round artifact paths.
153
+
154
+ One :class:`RoundArtifacts` per round in the loop. The
155
+ aggregate :class:`RunArtifacts` carries
156
+ ``rounds: list[RoundArtifacts]``; back-compat flat fields
157
+ (``manifest_path``, ``summary_path``, etc.) on
158
+ :class:`RunArtifacts` point at the LAST round's entry.
159
+ """
160
+
161
+ round_idx: int
162
+ round_dir: Path
163
+ manifest_path: Path
164
+ summary_path: Path
165
+ findings_md_path: Path | None = None
166
+ synthesizer_paths: SynthesizerArtifactPaths | None = None
167
+ test_run_paths: TestRunArtifactPaths | None = None
168
+ producer_paths: ProducerArtifactPaths | None = None
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class RoundResult:
173
+ """Outcome of one round of the loop.
174
+
175
+ Preserves per-round state on the aggregate :class:`RunResult.rounds` list.
176
+ The orchestrator's flat fields on :class:`RunResult` (``dispatch_result``,
177
+ ``synth_result``, ``test_result``, etc.) point at the LAST round's entry;
178
+ for ``max_rounds=1`` runs that's round 0.
179
+ """
180
+
181
+ round_idx: int
182
+ snapshot: Snapshot
183
+ dispatch_result: DispatchResult
184
+ synth_result: SynthesizerResult | None
185
+ test_result: TestRunResult | None
186
+ test_skip_reason: TestSkipReason | None
187
+ test_worktree_error: WorktreeError | None
188
+ producer_result: ProducerResult | None
189
+ round_exit_code: int
190
+ artifacts: RoundArtifacts
191
+ check_results: list[TestRunResult] = field(default_factory=list)
192
+ # Set by _no_changes_to_review_result so renderers can distinguish
193
+ # "reviewed and approved" (round_exit_code=0, this=False) from
194
+ # "nothing dispatched because diff was empty" (round_exit_code=0, this=True).
195
+ no_changes_to_review: bool = False
196
+ # UTF-8 byte count of the reviewer-facing (filtered) diff, carried so the
197
+ # producer-phase manifest rewrite can reproduce what round.py measured without
198
+ # a second computation. None when not measured (e.g. diff_malformed refusal).
199
+ filtered_diff_bytes: int | None = None
200
+ # UTF-8 byte count of the raw diff (before repo-context stripping), carried so
201
+ # the producer-phase manifest rewrite and the no-changes path can supply the
202
+ # measured value without re-deriving it from the snapshot (which carries a
203
+ # sentinel on resume, making derivation fabricate a byte count).
204
+ # None when not measured (diff_malformed refusal; resume paths).
205
+ raw_diff_bytes: int | None = None
206
+ # Set by _fail_closed_refusal_result (D2, PR-h-02d). Non-None means the run
207
+ # was refused because these diff headers could not be identified. Allows
208
+ # verdict.py to return "diff_malformed" instead of the generic "worktree_error".
209
+ fail_closed_headers: list[str] | None = None
210
+ # Set by _diff_too_large_result (PR-h-02e D2, landed in PR-h-field-01). Non-None means the
211
+ # run was refused because the REVIEWER-FACING diff exceeded [loop] max_diff_bytes;
212
+ # it carries the measured size. Lets verdict.py return "diff_too_large" rather than
213
+ # the generic "worktree_error", so the manifest names the cause.
214
+ oversize_diff_bytes: int | None = None
215
+ # Set by _assembled_prompt_too_large_result. Non-None means the run was refused
216
+ # because an assembled reviewer prompt exceeded the provider's character ceiling;
217
+ # it carries the oversized char count. Lets verdict.py return "prompt_too_large".
218
+ oversize_prompt_chars: int | None = None
219
+
220
+
221
+ @dataclass(frozen=True)
222
+ class RunArtifacts:
223
+ """Aggregate artifact paths for one syncade run.
224
+
225
+ Flat fields (``round_dir``, ``manifest_path``, ``summary_path``,
226
+ ``findings_md_path``, ``synthesizer_paths``, ``test_run_paths``) point at
227
+ the LAST round's artifacts. For ``max_rounds=1`` (single-pass), these are
228
+ the paths under ``round-0``.
229
+
230
+ Additional fields:
231
+
232
+ - :attr:`rounds`: list of :class:`RoundArtifacts`, one per round
233
+ that executed. ``len(rounds)`` is the number of rounds the loop
234
+ ran. For per-round inspection, traverse this list rather than
235
+ the flat fields.
236
+ - :attr:`loop_summary_path`: top-level ``loop-summary.md``
237
+ written at ``<run-id>/`` rolling up every round + final
238
+ verdict. ``None`` when the loop never reached the summary-
239
+ writing step (very early failures).
240
+ - :attr:`producer_paths` (): list of
241
+ :class:`ProducerArtifactPaths` | ``None``, one per round.
242
+ ``producer_paths[N]`` is ``None`` when round N didn't run a
243
+ producer (the SHIP round; the final round under
244
+ max_rounds_reached; producer-stalled before a producer fired
245
+ — etc.). Parallel to the brief's documented shape; mirrors
246
+ ``rounds[].producer_paths`` for callers that want the flat
247
+ per-round access pattern without traversing the nested list.
248
+ - :attr:`run_root_findings_md_path` (): top-level
249
+ ``<run_dir>/findings.md`` — copy of the latest round's
250
+ ``findings.md``. ``None`` when no round produced findings.md
251
+ (synth failed every round).
252
+ """
253
+
254
+ run_dir: Path
255
+ round_dir: Path
256
+ manifest_path: Path
257
+ summary_path: Path
258
+ findings_md_path: Path | None = None
259
+ synthesizer_paths: SynthesizerArtifactPaths | None = None
260
+ test_run_paths: TestRunArtifactPaths | None = None
261
+ rounds: list[RoundArtifacts] = field(default_factory=list)
262
+ loop_summary_path: Path | None = None
263
+ producer_paths: list[ProducerArtifactPaths | None] = field(default_factory=list)
264
+ run_root_findings_md_path: Path | None = None
265
+
266
+
267
+ @dataclass(frozen=True)
268
+ class RunResult:
269
+ """Outcome of one orchestrator invocation.
270
+
271
+ Flat fields (``snapshot``, ``dispatch_result``, ``synth_result``,
272
+ ``test_result``, ``test_skip_reason``, ``test_worktree_error``) point at the
273
+ LAST round's entry. For ``max_rounds=1`` (single-pass), these are the round
274
+ 0 values.
275
+
276
+ Additional fields:
277
+
278
+ - :attr:`rounds`: list of :class:`RoundResult`, one per round
279
+ executed. ``len(rounds) <= config.loop.max_rounds``.
280
+ - :attr:`final_round`: 0-indexed round that terminated the loop.
281
+ Equals ``len(rounds) - 1`` on every termination path.
282
+ - :attr:`termination_reason`: categorical label for tooling
283
+ (see :data:`TerminationReason` for values).
284
+ """
285
+
286
+ artifacts: RunArtifacts
287
+ snapshot: Snapshot
288
+ dispatch_result: DispatchResult
289
+ exit_code: int
290
+ synth_result: SynthesizerResult | None = None
291
+ test_result: TestRunResult | None = None
292
+ test_skip_reason: TestSkipReason | None = None
293
+ test_worktree_error: WorktreeError | None = None
294
+ rounds: list[RoundResult] = field(default_factory=list)
295
+ final_round: int = 0
296
+ termination_reason: TerminationReason | None = None
297
+ # The budget ENFORCEMENT tally and the ceiling that crossed, carried so the terminal
298
+ # notice can state the numbers that actually tripped (PR-h-field-06). Deliberately not
299
+ # recomputed from `rounds`: on a --resume the enforcement tally is the FRESH resumed
300
+ # spend, not the rehydrated originals, so a recomputation would print a different number
301
+ # from loop-summary.md and --metrics — the "one number, three surfaces" rule.
302
+ budget_usages: list[Usage] = field(default_factory=list)
303
+ budget_ceiling: str | None = None
304
+ # The CONFIGURED ceilings, so the notice can name the number the operator would change.
305
+ budget_tokens: int | None = None
306
+ budget_usd: float | None = None
@@ -0,0 +1,96 @@
1
+ """Resume detection + eligibility helpers.
2
+
3
+ Pure-function layer: takes a ``runs_root`` (or a specific ``run_dir``)
4
+ and returns resume plans without invoking any syncade subprocess.
5
+ The CLI handler (``_run_resume`` in :mod:`syncade.cli`) wires these
6
+ into ``run_review`` after resolving the target run-id.
7
+
8
+ Eligibility rule:
9
+
10
+ - ``run-init.json`` present AND ``loop-manifest.json`` MISSING →
11
+ ELIGIBLE (operator Ctrl-C, crash, SIGTERM — the loop terminator
12
+ never ran).
13
+ - ``run-init.json`` present AND ``loop-manifest.json`` malformed/unreadable →
14
+ ELIGIBLE enough to surface during resume-target resolution; the planner
15
+ reports the specific corruption.
16
+ - ``run-init.json`` present AND ``loop-manifest.json`` present AND
17
+ ``final_exit_code in {10, 25, 40, 60, 70}`` → ELIGIBLE (decision-needed
18
+ checkpoint, a budget stop (25), or environment failure mid-loop —
19
+ subprocess error, worktree provisioning, parse failure).
20
+ - ``loop-manifest.json`` present AND ``final_exit_code in (0, 20, 30)``
21
+ → NOT ELIGIBLE (loop completed normally; operator's next step is
22
+ a fresh run).
23
+ - ``run-init.json`` MISSING → NOT ELIGIBLE (not a syncade run, or missing the
24
+ required resume artifact).
25
+
26
+ Within an eligible run, the "round to retry" is determined by
27
+ walking ``round-0/``, ``round-1/``, ... up to ``max_rounds`` and
28
+ stopping at the FIRST round that didn't complete cleanly.
29
+
30
+ this module was decomposed into ``resume_types`` (shared
31
+ constants + the ``ResumeError`` / ``ResumePlan`` / ``TreeDriftError``
32
+ types), ``resume_target`` (find/resolve eligible runs), ``resume_plan``
33
+ (``plan_resume`` + the phase-failure classifier + decision read + SHA
34
+ derivation), and ``resume_load`` (tree-drift checks + completed-round
35
+ rehydration). This module is now a thin re-export shim so every
36
+ ``syncade.orchestrator.resume.<name>`` import path is unchanged.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from syncade.persistence import read_operator_decision as read_operator_decision
42
+
43
+ from .resume_load import (
44
+ _current_head_branch,
45
+ _current_head_sha,
46
+ _safe_read,
47
+ check_tree_drift,
48
+ load_completed_round,
49
+ )
50
+ from .resume_plan import (
51
+ _expected_sha_for_resumed_round,
52
+ _round_manifest_indicates_phase_failure,
53
+ plan_resume,
54
+ read_resume_decision,
55
+ )
56
+ from .resume_target import (
57
+ _read_operator_branch,
58
+ find_resumable_runs,
59
+ resolve_resume_target,
60
+ )
61
+ from .resume_types import (
62
+ _GIT_TIMEOUT_SECONDS,
63
+ _RESUMABLE_EXIT_CODES,
64
+ LOOP_MANIFEST_FILENAME,
65
+ ROUND_MANIFEST_FILENAME,
66
+ ResumeError,
67
+ ResumePlan,
68
+ TreeDriftError,
69
+ )
70
+
71
+ __all__ = [
72
+ # types + constants
73
+ "ResumeError",
74
+ "ResumePlan",
75
+ "TreeDriftError",
76
+ "LOOP_MANIFEST_FILENAME",
77
+ "ROUND_MANIFEST_FILENAME",
78
+ "_RESUMABLE_EXIT_CODES",
79
+ "_GIT_TIMEOUT_SECONDS",
80
+ # target resolution
81
+ "find_resumable_runs",
82
+ "resolve_resume_target",
83
+ "_read_operator_branch",
84
+ # planning
85
+ "plan_resume",
86
+ "read_operator_decision",
87
+ "read_resume_decision",
88
+ "_round_manifest_indicates_phase_failure",
89
+ "_expected_sha_for_resumed_round",
90
+ # drift + rehydration
91
+ "check_tree_drift",
92
+ "load_completed_round",
93
+ "_current_head_sha",
94
+ "_current_head_branch",
95
+ "_safe_read",
96
+ ]