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,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
|
+
)
|