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,198 @@
|
|
|
1
|
+
"""Reviewer subprocess persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<round_dir>/<reviewer_name>.{stdout,stderr,parsed.json,error.txt}``
|
|
4
|
+
and the matching round-manifest entry. Called once per reviewer in the
|
|
5
|
+
fan-out (multiple per round).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import traceback
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from syncade.dispatcher import ReviewerRunResult
|
|
18
|
+
from syncade.process import SubprocessResult
|
|
19
|
+
from syncade.usage import usage_fields
|
|
20
|
+
|
|
21
|
+
from ._atomic import atomic_write_text
|
|
22
|
+
from ._validation import _validate_reviewer_filename_basename
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def persist_reviewer_result(
|
|
26
|
+
round_dir: Path,
|
|
27
|
+
run_result: ReviewerRunResult,
|
|
28
|
+
raw_subprocess_result: SubprocessResult | None,
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Write one reviewer's outputs to ``<round_dir>/<name>.*``.
|
|
31
|
+
|
|
32
|
+
Files written:
|
|
33
|
+
|
|
34
|
+
- ``<name>.stdout`` and ``<name>.stderr`` — always created.
|
|
35
|
+
Contain the captured subprocess streams when available;
|
|
36
|
+
empty when ``raw_subprocess_result`` is ``None`` (the failure
|
|
37
|
+
happened before the subprocess actually ran — e.g. unknown
|
|
38
|
+
provider, auth fail-fast, or build_invocation raising).
|
|
39
|
+
- ``<name>.parsed.json`` — written only when
|
|
40
|
+
``run_result.output is not None``. Uses
|
|
41
|
+
:meth:`pydantic.BaseModel.model_dump_json(indent=2)` so the
|
|
42
|
+
file diffs cleanly across runs.
|
|
43
|
+
- ``<name>.error.txt`` — written only when
|
|
44
|
+
``run_result.error is not None``. Contains the exception's
|
|
45
|
+
class name, message, and full traceback (when available).
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
round_dir: The round directory to write into. Must already
|
|
49
|
+
exist (the orchestrator creates it during run setup).
|
|
50
|
+
run_result: One :class:`ReviewerRunResult` from a
|
|
51
|
+
:class:`DispatchResult`. The ``reviewer_name`` field is
|
|
52
|
+
used as the filename basename and validated against
|
|
53
|
+
path traversal.
|
|
54
|
+
raw_subprocess_result: The :class:`SubprocessResult` from
|
|
55
|
+
the reviewer's subprocess, if one ran. ``None`` when
|
|
56
|
+
the failure happened before any subprocess launched.
|
|
57
|
+
|
|
58
|
+
Raises:
|
|
59
|
+
ValueError: If ``run_result.reviewer_name`` is not a safe
|
|
60
|
+
basename.
|
|
61
|
+
FileNotFoundError: If ``round_dir`` does not exist (caller
|
|
62
|
+
bug — orchestrator is responsible for creating it).
|
|
63
|
+
"""
|
|
64
|
+
_validate_reviewer_filename_basename(run_result.reviewer_name)
|
|
65
|
+
if not round_dir.is_dir():
|
|
66
|
+
raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
|
|
67
|
+
|
|
68
|
+
# Use name-append, not with_suffix: a reviewer named "team.a" must produce
|
|
69
|
+
# "team.a.stdout", not "team.stdout" (which would collide with "team.b").
|
|
70
|
+
base_name = run_result.reviewer_name
|
|
71
|
+
|
|
72
|
+
stdout_text = raw_subprocess_result.stdout if raw_subprocess_result is not None else ""
|
|
73
|
+
stderr_text = raw_subprocess_result.stderr if raw_subprocess_result is not None else ""
|
|
74
|
+
atomic_write_text(round_dir / (base_name + ".stdout"), stdout_text)
|
|
75
|
+
atomic_write_text(round_dir / (base_name + ".stderr"), stderr_text)
|
|
76
|
+
|
|
77
|
+
if run_result.output is not None:
|
|
78
|
+
atomic_write_text(
|
|
79
|
+
round_dir / (base_name + ".parsed.json"),
|
|
80
|
+
run_result.output.model_dump_json(indent=2),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if run_result.error is not None:
|
|
84
|
+
# Compose: class name, message, traceback. The class name is
|
|
85
|
+
# essential for downstream tooling that wants to bucket
|
|
86
|
+
# failures by type without parsing the message.
|
|
87
|
+
exc = run_result.error
|
|
88
|
+
lines = [
|
|
89
|
+
f"{type(exc).__name__}: {exc}",
|
|
90
|
+
"",
|
|
91
|
+
]
|
|
92
|
+
# __traceback__ may be None when the exception was constructed
|
|
93
|
+
# (not raised) — e.g. ReviewerInvocationError that the adapter
|
|
94
|
+
# built defensively. Surface that distinction in the file.
|
|
95
|
+
tb = exc.__traceback__
|
|
96
|
+
if tb is not None:
|
|
97
|
+
lines.extend(traceback.format_exception(type(exc), exc, tb))
|
|
98
|
+
else:
|
|
99
|
+
lines.append("(no traceback available — exception was constructed, not raised)")
|
|
100
|
+
atomic_write_text(round_dir / (base_name + ".error.txt"), "\n".join(lines))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _reviewer_manifest_entry(run_result: ReviewerRunResult) -> dict[str, object]:
|
|
104
|
+
"""Build the per-reviewer entry for the manifest's ``reviewers``
|
|
105
|
+
array. Pulls out the fields tooling cares about while keeping the
|
|
106
|
+
full raw output behind the .stdout / .parsed.json files."""
|
|
107
|
+
model = run_result.model or (run_result.usage.model if run_result.usage is not None else "")
|
|
108
|
+
if run_result.output is not None:
|
|
109
|
+
return {
|
|
110
|
+
"name": run_result.reviewer_name,
|
|
111
|
+
"provider": run_result.provider,
|
|
112
|
+
"model": model,
|
|
113
|
+
"verdict": run_result.output.verdict,
|
|
114
|
+
"finding_count": len(run_result.output.findings),
|
|
115
|
+
"duration_seconds": run_result.duration_seconds,
|
|
116
|
+
**usage_fields(run_result.usage),
|
|
117
|
+
"outcome": "success",
|
|
118
|
+
"error_type": None,
|
|
119
|
+
}
|
|
120
|
+
error_type = type(run_result.error).__name__ if run_result.error is not None else None
|
|
121
|
+
return {
|
|
122
|
+
"name": run_result.reviewer_name,
|
|
123
|
+
"provider": run_result.provider,
|
|
124
|
+
"model": model,
|
|
125
|
+
"verdict": None,
|
|
126
|
+
"finding_count": None,
|
|
127
|
+
"duration_seconds": run_result.duration_seconds,
|
|
128
|
+
**usage_fields(run_result.usage),
|
|
129
|
+
"outcome": "failure",
|
|
130
|
+
"error_type": error_type,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def persist_dispatch_record(
|
|
135
|
+
round_dir: Path,
|
|
136
|
+
*,
|
|
137
|
+
round_index: int,
|
|
138
|
+
reviewers: list,
|
|
139
|
+
timeout_seconds: float,
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Record that reviewers were DISPATCHED, before any of them returns (PR-h-field-02).
|
|
142
|
+
|
|
143
|
+
Every other artifact in a round is written after the panel completes, so a run that dies
|
|
144
|
+
mid-dispatch leaves the round directory EMPTY. Three field runs did exactly that — killed in
|
|
145
|
+
``round-2: reviewing`` with nothing on disk — and the post-mortem had only a log line in a
|
|
146
|
+
terminal and a ``status.json`` phase to work from. The provider error, the child pids, even
|
|
147
|
+
the fact that dispatch had begun, were all unrecoverable.
|
|
148
|
+
|
|
149
|
+
This is the cheapest possible fix for that: one small file, written before the panel starts,
|
|
150
|
+
saying what was in flight and since when. It never changes a verdict and is never read by the
|
|
151
|
+
loop — it exists purely so the NEXT unexplained death is diagnosable.
|
|
152
|
+
|
|
153
|
+
Deliberately records the parent pid: cross-referenced with ``status.json``'s pid it tells a
|
|
154
|
+
reader whether the parent survived its children, which is the difference between "the
|
|
155
|
+
provider rejected us" and "something killed the process".
|
|
156
|
+
"""
|
|
157
|
+
payload = {
|
|
158
|
+
"round": round_index,
|
|
159
|
+
"dispatched_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
160
|
+
"parent_pid": os.getpid(),
|
|
161
|
+
"timeout_seconds": timeout_seconds,
|
|
162
|
+
"reviewers": [
|
|
163
|
+
{"name": r.name, "provider": r.provider, "model": r.model} for r in reviewers
|
|
164
|
+
],
|
|
165
|
+
}
|
|
166
|
+
atomic_write_text(round_dir / "dispatch.json", json.dumps(payload, indent=2) + "\n")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# Reviewers spawn in parallel threads, so two children can reach record_child_pid at once and
|
|
170
|
+
# a plain read-modify-write would drop one of the pids. One process writes this file, so a
|
|
171
|
+
# threading lock is the whole requirement — no file locking needed.
|
|
172
|
+
_dispatch_record_lock = threading.Lock()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def record_child_pid(round_dir: Path, reviewer_name: str, pid: int) -> None:
|
|
176
|
+
"""Add a reviewer's child pid to ``dispatch.json``, once that child EXISTS.
|
|
177
|
+
|
|
178
|
+
The record is written before the panel starts, when no pid is knowable yet; this fills them
|
|
179
|
+
in as each ``Popen`` returns. That answers the question the PR-h-field-02 post-mortem could
|
|
180
|
+
not: whether the children outlived the parent — "something killed syncade and orphaned
|
|
181
|
+
them" — or died first and it followed. A reader can ``ps`` these, or observe their absence.
|
|
182
|
+
|
|
183
|
+
A RETRY overwrites: the previous attempt's child is already dead by construction (retry only
|
|
184
|
+
follows a failure), and the diagnostic question is about what was alive at the time of death.
|
|
185
|
+
|
|
186
|
+
Never raises. Every failure mode here — the file missing, half-written, unparseable, a name
|
|
187
|
+
that is not in it — costs a breadcrumb, and a breadcrumb is never worth failing a review for.
|
|
188
|
+
"""
|
|
189
|
+
path = round_dir / "dispatch.json"
|
|
190
|
+
with _dispatch_record_lock:
|
|
191
|
+
try:
|
|
192
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
193
|
+
for entry in payload["reviewers"]:
|
|
194
|
+
if entry.get("name") == reviewer_name:
|
|
195
|
+
entry["pid"] = pid
|
|
196
|
+
atomic_write_text(path, json.dumps(payload, indent=2) + "\n")
|
|
197
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
198
|
+
return
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Per-round manifest.json persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<round_dir>/manifest.json`` — the round-level entry point for
|
|
4
|
+
tooling that wants to know what happened without reading every
|
|
5
|
+
reviewer's output. The schema includes reviewer, synthesizer, test,
|
|
6
|
+
producer, and check sections, with ``round_exit_code`` aligned to
|
|
7
|
+
``loop-manifest.json``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from syncade import __version__
|
|
16
|
+
from syncade.dispatcher import DispatchResult
|
|
17
|
+
from syncade.producer import ProducerResult
|
|
18
|
+
from syncade.snapshot import Snapshot
|
|
19
|
+
from syncade.synthesizer import SynthesizerResult
|
|
20
|
+
from syncade.test_runner import TestRunResult
|
|
21
|
+
|
|
22
|
+
from ._atomic import atomic_write_json
|
|
23
|
+
from .checks import _check_manifest_entry
|
|
24
|
+
from .producer import _producer_manifest_entry
|
|
25
|
+
from .reviewer import _reviewer_manifest_entry
|
|
26
|
+
from .synth import _synthesizer_manifest_entry
|
|
27
|
+
from .test_run import _test_run_manifest_entry
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def persist_round_manifest(
|
|
31
|
+
round_dir: Path,
|
|
32
|
+
snapshot: Snapshot,
|
|
33
|
+
dispatch_result: DispatchResult,
|
|
34
|
+
exit_code: int,
|
|
35
|
+
started_at: datetime,
|
|
36
|
+
synth_result: SynthesizerResult | None = None,
|
|
37
|
+
test_result: TestRunResult | None = None,
|
|
38
|
+
test_skip_reason: str | None = None,
|
|
39
|
+
*,
|
|
40
|
+
round_idx: int = 0,
|
|
41
|
+
producer_result: ProducerResult | None = None,
|
|
42
|
+
producer_provider: str | None = None,
|
|
43
|
+
producer_model: str | None = None,
|
|
44
|
+
check_results: list[TestRunResult] | None = None,
|
|
45
|
+
diff_filter_refusal_headers: list[str] | None = None,
|
|
46
|
+
filtered_diff_bytes: int | None = None,
|
|
47
|
+
raw_diff_bytes: int | None = None,
|
|
48
|
+
oversize_prompt_chars: int | None = None,
|
|
49
|
+
refusal_reason: str | None = None,
|
|
50
|
+
) -> Path:
|
|
51
|
+
"""Write ``<round_dir>/manifest.json`` summarizing the round.
|
|
52
|
+
|
|
53
|
+
The manifest is the round-level entry point for tooling that wants
|
|
54
|
+
to know what happened without reading every reviewer's output.
|
|
55
|
+
The loop and skill bridge read it to surface findings counts and round
|
|
56
|
+
status to the user.
|
|
57
|
+
|
|
58
|
+
Schema (matches PRD Appendix C usage):
|
|
59
|
+
|
|
60
|
+
.. code-block:: json
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
"syncade_version": "0.X.0",
|
|
64
|
+
"run_id": "2026-05-12T15-30-04",
|
|
65
|
+
"round": 0,
|
|
66
|
+
"started_at_utc": "2026-05-12T15:30:04Z",
|
|
67
|
+
"snapshot": {
|
|
68
|
+
"commit_sha": "...",
|
|
69
|
+
"branch": "main",
|
|
70
|
+
"base_ref": null,
|
|
71
|
+
"base_oid": null,
|
|
72
|
+
"diff_present": false
|
|
73
|
+
},
|
|
74
|
+
"reviewers": [
|
|
75
|
+
{
|
|
76
|
+
"name": "claude-reviewer",
|
|
77
|
+
"provider": "anthropic",
|
|
78
|
+
"verdict": "SHIP",
|
|
79
|
+
"finding_count": 0,
|
|
80
|
+
"duration_seconds": 12.4,
|
|
81
|
+
"outcome": "success",
|
|
82
|
+
"error_type": null
|
|
83
|
+
}
|
|
84
|
+
],
|
|
85
|
+
"synthesizer": {
|
|
86
|
+
"outcome": "success",
|
|
87
|
+
"stdout_path": "synthesizer.stdout",
|
|
88
|
+
"stderr_path": "synthesizer.stderr",
|
|
89
|
+
"parsed_path": "synthesizer.parsed.json",
|
|
90
|
+
"error_path": null,
|
|
91
|
+
"duration_seconds": 12.4,
|
|
92
|
+
"dismissed_count": 1,
|
|
93
|
+
"active_blocker_count": 0,
|
|
94
|
+
"active_minor_count": 2,
|
|
95
|
+
"active_nit_count": 1
|
|
96
|
+
},
|
|
97
|
+
"test_run": {
|
|
98
|
+
"outcome": "passed",
|
|
99
|
+
"exit_code": 0,
|
|
100
|
+
"command": "pytest -q",
|
|
101
|
+
"duration_seconds": 8.3,
|
|
102
|
+
"stdout_path": "test-run.stdout",
|
|
103
|
+
"stderr_path": "test-run.stderr",
|
|
104
|
+
"exit_code_path": "test-run.exit-code.txt",
|
|
105
|
+
"error_type": null
|
|
106
|
+
},
|
|
107
|
+
"test_skip_reason": null,
|
|
108
|
+
"producer": null,
|
|
109
|
+
"retried": 0,
|
|
110
|
+
"round_exit_code": 0
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
The ``synthesizer`` section is ``null`` when the phase was
|
|
114
|
+
skipped (any reviewer failed). On synthesizer success the
|
|
115
|
+
consolidation counts populate. On synthesizer failure the
|
|
116
|
+
counts are ``null`` and ``error_path`` points at the
|
|
117
|
+
``synthesizer.error.txt`` artifact.
|
|
118
|
+
|
|
119
|
+
The ``test_run`` section is ``null`` when the leg was skipped
|
|
120
|
+
(``[loop] test_command`` is not configured OR a prior phase
|
|
121
|
+
failed/produced blockers). When the leg ran, it carries the
|
|
122
|
+
``outcome``, the operator-configured ``command`` echoed back
|
|
123
|
+
verbatim, and pointers to the three artifact files. On
|
|
124
|
+
``subprocess_error``, ``error_type`` names the failure shape
|
|
125
|
+
(``SubprocessTimeoutError``, ``SubprocessNotFoundError``, or
|
|
126
|
+
other :class:`~syncade.process.SubprocessError` subclasses).
|
|
127
|
+
|
|
128
|
+
``started_at`` is the run-start instant captured once by the
|
|
129
|
+
orchestrator and shared with :func:`persist_run_summary`, so the
|
|
130
|
+
two files agree on when the run *began* — not when each file
|
|
131
|
+
happened to be written (a long run finishes minutes after it
|
|
132
|
+
starts).
|
|
133
|
+
|
|
134
|
+
Returns the path of the written manifest.
|
|
135
|
+
"""
|
|
136
|
+
if not round_dir.is_dir():
|
|
137
|
+
raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
|
|
138
|
+
|
|
139
|
+
# Run id is the parent directory's name (orchestrator's layout
|
|
140
|
+
# convention). Decoupled here rather than passed in, so a future
|
|
141
|
+
# caller that reorganizes the run hierarchy doesn't have to remember
|
|
142
|
+
# to update this argument list — they only have to keep the
|
|
143
|
+
# parent.name convention.
|
|
144
|
+
run_id = round_dir.parent.name
|
|
145
|
+
|
|
146
|
+
manifest = {
|
|
147
|
+
"syncade_version": __version__,
|
|
148
|
+
"run_id": run_id,
|
|
149
|
+
# ``round`` is the per-round index (0-indexed). Top-level loop
|
|
150
|
+
# aggregation lives in <run-id>/loop-manifest.json.
|
|
151
|
+
"round": round_idx,
|
|
152
|
+
"started_at_utc": started_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
153
|
+
"snapshot": {
|
|
154
|
+
"commit_sha": snapshot.commit_sha,
|
|
155
|
+
"branch": snapshot.branch,
|
|
156
|
+
"base_ref": snapshot.base_ref,
|
|
157
|
+
"base_oid": snapshot.base_oid,
|
|
158
|
+
"diff_present": bool(snapshot.diff_text),
|
|
159
|
+
# Size history for a future diff cap. There is no cap today, and picking a
|
|
160
|
+
# threshold without history would be a guess; recording costs nothing and cannot
|
|
161
|
+
# be backfilled, so every run from here on is a datapoint.
|
|
162
|
+
#
|
|
163
|
+
# Two numbers because they answer different questions: `diff_bytes` is what the
|
|
164
|
+
# repo produced, `diff_bytes_reviewed` is what a reviewer was actually handed
|
|
165
|
+
# AFTER repo-context stripping — and a cap must threshold on the latter, since
|
|
166
|
+
# that is what reaches the model's context.
|
|
167
|
+
#
|
|
168
|
+
# BOTH are supplied by the caller that MEASURED them, and this writer never
|
|
169
|
+
# infers one. Deriving `diff_bytes` from `snapshot.diff_text` looked free and was
|
|
170
|
+
# not: a resumed round's snapshot carries a SENTINEL rather than the real diff, so
|
|
171
|
+
# the derivation fabricated a byte count for a diff nobody had. `null` means "not
|
|
172
|
+
# measured on this path" and is honest; a fabricated integer is not.
|
|
173
|
+
"diff_bytes": raw_diff_bytes,
|
|
174
|
+
"diff_bytes_reviewed": filtered_diff_bytes,
|
|
175
|
+
},
|
|
176
|
+
"reviewers": [_reviewer_manifest_entry(r) for r in dispatch_result.results],
|
|
177
|
+
"synthesizer": _synthesizer_manifest_entry(synth_result),
|
|
178
|
+
"test_run": _test_run_manifest_entry(test_result),
|
|
179
|
+
# persist the test_skip_reason alongside test_run.
|
|
180
|
+
# Tooling that wants to know WHY the test leg didn't fire
|
|
181
|
+
# would otherwise have to infer from dispatch + synth
|
|
182
|
+
# state. Surfacing the explicit reason in the
|
|
183
|
+
# machine-readable manifest closes the loop: the CLI,
|
|
184
|
+
# the future update loop, and any external tooling all
|
|
185
|
+
# get the same signal the Logger emitted at run time.
|
|
186
|
+
# ``None`` when test_result is not None (the leg ran).
|
|
187
|
+
"test_skip_reason": test_skip_reason if test_result is None else None,
|
|
188
|
+
# per-round producer section. ``None`` when this round
|
|
189
|
+
# didn't run a producer (the round that SHIPped, or the
|
|
190
|
+
# final round under max-rounds-reached). Populated when the
|
|
191
|
+
# producer ran with outcome / starting_sha / ending_sha /
|
|
192
|
+
# provider + model echo.
|
|
193
|
+
"producer": _producer_manifest_entry(
|
|
194
|
+
producer_result,
|
|
195
|
+
producer_config_provider=producer_provider,
|
|
196
|
+
producer_config_model=producer_model,
|
|
197
|
+
),
|
|
198
|
+
# Total EXTRA subprocess attempts consumed this round riding out
|
|
199
|
+
# transient provider errors (H5). A 429/5xx/dropped-socket blip in a
|
|
200
|
+
# reviewer, the synthesizer, OR the producer subprocess is retried with
|
|
201
|
+
# jittered backoff instead of aborting the loop at exit 40; surfacing the
|
|
202
|
+
# count makes a flaky run visible in the artifact. Sums the reviewer-dispatch
|
|
203
|
+
# retries, the synthesizer's own (SynthesizerResult.retries), and the
|
|
204
|
+
# producer's (ProducerResult.retries, PR-v2-22).
|
|
205
|
+
"retried": sum(r.retries for r in dispatch_result.results)
|
|
206
|
+
+ (synth_result.retries if synth_result is not None else 0)
|
|
207
|
+
+ (producer_result.retries if producer_result is not None else 0),
|
|
208
|
+
# Use ``round_exit_code`` for cross-surface consistency with
|
|
209
|
+
# ``loop-manifest.json``'s ``rounds[].round_exit_code``.
|
|
210
|
+
"round_exit_code": exit_code,
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
# append the checks array ONLY when checks ran, so a zero-config
|
|
214
|
+
# round's manifest stays byte-identical (no 'checks' key at all).
|
|
215
|
+
if check_results:
|
|
216
|
+
manifest["checks"] = [_check_manifest_entry(c) for c in check_results]
|
|
217
|
+
|
|
218
|
+
# present ONLY on a fail-closed diff-filter refusal (D2, PR-h-02d).
|
|
219
|
+
# Named "diff_filter_refusal_headers" so tooling can distinguish
|
|
220
|
+
# "reviewer worktree failed" from "diff section(s) were unidentifiable".
|
|
221
|
+
if diff_filter_refusal_headers is not None:
|
|
222
|
+
manifest["diff_filter_refusal_headers"] = diff_filter_refusal_headers
|
|
223
|
+
|
|
224
|
+
# present ONLY on a before-dispatch size refusal. Machine-readable discriminator
|
|
225
|
+
# for the resume planner and tooling: "diff_malformed", "diff_too_large", or
|
|
226
|
+
# "prompt_too_large". Distinct from diff_filter_refusal_headers (legacy field for
|
|
227
|
+
# diff_malformed only) so all three refusal reasons are uniformly detectable.
|
|
228
|
+
if refusal_reason is not None:
|
|
229
|
+
manifest["refusal_reason"] = refusal_reason
|
|
230
|
+
|
|
231
|
+
# present ONLY on a prompt_too_large refusal. Carries the measured assembled prompt
|
|
232
|
+
# size so tooling can tell the operator by how much and for which reviewer.
|
|
233
|
+
if oversize_prompt_chars is not None:
|
|
234
|
+
manifest["oversize_prompt_chars"] = oversize_prompt_chars
|
|
235
|
+
|
|
236
|
+
manifest_path = round_dir / "manifest.json"
|
|
237
|
+
atomic_write_json(manifest_path, manifest, sort_keys=False)
|
|
238
|
+
return manifest_path
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Run-init persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<run_dir>/run-init.json`` — the top-level "what was this run
|
|
4
|
+
about" record, captured at the very start of every fresh ``syncade
|
|
5
|
+
<pr-doc>`` invocation (before round 0's directory exists).
|
|
6
|
+
|
|
7
|
+
The file exists so ``--resume <run-id>`` can
|
|
8
|
+
reconstruct enough of the original invocation's context to decide
|
|
9
|
+
eligibility (was the operator on this branch? did the original run
|
|
10
|
+
start from this SHA?) and to validate against tree drift between
|
|
11
|
+
abort and resume.
|
|
12
|
+
|
|
13
|
+
Schema:
|
|
14
|
+
|
|
15
|
+
.. code-block:: json
|
|
16
|
+
|
|
17
|
+
{
|
|
18
|
+
"syncade_version": "0.1.0",
|
|
19
|
+
"started_at_utc": "2026-05-28T16:43:59Z",
|
|
20
|
+
"pr_doc_path": "design docs",
|
|
21
|
+
"base_ref": "1dbbca3",
|
|
22
|
+
"base_oid": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2",
|
|
23
|
+
"starting_sha": "3442e72ab60b43153cdbee83f3187203b289f14a",
|
|
24
|
+
"operator_branch": "main",
|
|
25
|
+
"max_rounds": 3,
|
|
26
|
+
"config_snapshot": { ... }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
The ``config_snapshot`` is informational only — the resumed loop
|
|
30
|
+
reads the CURRENT ``.syncade/config.toml`` from disk; the snapshot
|
|
31
|
+
exists so the operator (or a future agent) inspecting an aborted run
|
|
32
|
+
knows what config produced it.
|
|
33
|
+
|
|
34
|
+
It is a ``model_dump`` of :class:`SyncadeConfig` echoing every field at
|
|
35
|
+
its serialized value, with ONE carve-out: a
|
|
36
|
+
default-empty ``checks`` list is omitted, so a zero-config run-init.json does
|
|
37
|
+
not carry an empty optional section. A configured ``[[checks]]`` list is still
|
|
38
|
+
echoed; only the empty default is dropped.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
from datetime import datetime
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
from syncade.config import SyncadeConfig
|
|
47
|
+
|
|
48
|
+
from ._atomic import atomic_write_json
|
|
49
|
+
|
|
50
|
+
RUN_INIT_FILENAME: str = "run-init.json"
|
|
51
|
+
"""Filename used for the run-init artifact. Single source of
|
|
52
|
+
truth so the persistence writer, the resume detector, and any future
|
|
53
|
+
tooling all agree on the path."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _config_snapshot(config: SyncadeConfig) -> dict:
|
|
57
|
+
"""Serialize the config for the diagnostic ``config_snapshot`` block.
|
|
58
|
+
|
|
59
|
+
A default-empty ``checks`` list is omitted so zero-config runs do not carry
|
|
60
|
+
an empty optional section. A configured ``[[checks]]`` list is still echoed
|
|
61
|
+
for diagnostics. Every other field is echoed verbatim.
|
|
62
|
+
"""
|
|
63
|
+
snapshot = config.model_dump(mode="json")
|
|
64
|
+
if snapshot.get("checks") == []:
|
|
65
|
+
del snapshot["checks"]
|
|
66
|
+
return snapshot
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def persist_run_init(
|
|
70
|
+
run_dir: Path,
|
|
71
|
+
*,
|
|
72
|
+
syncade_version: str,
|
|
73
|
+
started_at: datetime,
|
|
74
|
+
pr_doc_path: Path,
|
|
75
|
+
base_ref: str | None,
|
|
76
|
+
base_oid: str | None,
|
|
77
|
+
starting_sha: str,
|
|
78
|
+
operator_branch: str | None,
|
|
79
|
+
max_rounds: int,
|
|
80
|
+
config: SyncadeConfig,
|
|
81
|
+
) -> Path:
|
|
82
|
+
"""Write ``<run_dir>/run-init.json`` capturing the run's initial
|
|
83
|
+
state.
|
|
84
|
+
|
|
85
|
+
Called ONCE per run, at the very start of
|
|
86
|
+
:func:`syncade.orchestrator.loop.run_review` (after the run directory
|
|
87
|
+
exists, before round 0's directory is created). A
|
|
88
|
+
resumed run does NOT rewrite this file — the snapshot captured
|
|
89
|
+
at the original run start is the authoritative record across
|
|
90
|
+
resume boundaries.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
run_dir: Top-level run directory (``<repo>/.syncade/runs/<id>/``).
|
|
94
|
+
Must already exist; the run-id mkdir happens before this
|
|
95
|
+
call in ``run_review``.
|
|
96
|
+
syncade_version: The :data:`syncade.__version__` at run start.
|
|
97
|
+
Recorded so a resume across a syncade upgrade can emit a
|
|
98
|
+
stderr warning.
|
|
99
|
+
started_at: The :func:`datetime.now(tz=UTC)` instant captured
|
|
100
|
+
at run start. Same value threaded into manifest /
|
|
101
|
+
loop-manifest / summary so they all agree on when the
|
|
102
|
+
run began.
|
|
103
|
+
pr_doc_path: Path to the PR doc the run was invoked against.
|
|
104
|
+
Serialized as the ``str(...)`` of the path so the file
|
|
105
|
+
stays JSON-safe.
|
|
106
|
+
base_ref: The ``--base`` value the CLI received, or ``None``
|
|
107
|
+
if no diff was requested. Distinct from ``starting_sha``
|
|
108
|
+
(the resolved HEAD SHA at run start).
|
|
109
|
+
base_oid: The resolved full object ID of ``base_ref`` at
|
|
110
|
+
snapshot time, or ``None`` when ``base_ref`` is ``None``.
|
|
111
|
+
Unlike ``base_ref``, this is immutable even if the ref
|
|
112
|
+
moves after the run starts.
|
|
113
|
+
starting_sha: The full HEAD object ID the orchestrator
|
|
114
|
+
snapshotted at round 0. The authoritative tree state
|
|
115
|
+
the run began against.
|
|
116
|
+
operator_branch: The branch the operator was on at run
|
|
117
|
+
start (``Snapshot.branch``). ``None`` for detached
|
|
118
|
+
HEAD. Captured so resume can validate the operator
|
|
119
|
+
hasn't switched branches between abort and resume.
|
|
120
|
+
max_rounds: The ``config.loop.max_rounds`` cap the run was
|
|
121
|
+
launched with. Captured because a future ``--resume
|
|
122
|
+
--max-rounds N`` may override the original cap; the
|
|
123
|
+
original is preserved here for diagnostic comparison.
|
|
124
|
+
config: The :class:`SyncadeConfig` instance loaded at run
|
|
125
|
+
start. Serialized via :meth:`pydantic.BaseModel.model_dump`
|
|
126
|
+
with ``mode="json"`` so enums + paths flatten to
|
|
127
|
+
JSON-safe primitives.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Path of the written ``run-init.json``.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
FileNotFoundError: If ``run_dir`` does not exist.
|
|
134
|
+
"""
|
|
135
|
+
if not run_dir.is_dir():
|
|
136
|
+
raise FileNotFoundError(f"run_dir does not exist: {run_dir}")
|
|
137
|
+
|
|
138
|
+
record = {
|
|
139
|
+
"syncade_version": syncade_version,
|
|
140
|
+
"started_at_utc": started_at.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
141
|
+
"pr_doc_path": str(pr_doc_path),
|
|
142
|
+
"base_ref": base_ref,
|
|
143
|
+
"base_oid": base_oid,
|
|
144
|
+
"starting_sha": starting_sha,
|
|
145
|
+
"operator_branch": operator_branch,
|
|
146
|
+
"max_rounds": max_rounds,
|
|
147
|
+
# Full config echo. See module docstring.
|
|
148
|
+
"config_snapshot": _config_snapshot(config),
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
run_init_path = run_dir / RUN_INIT_FILENAME
|
|
152
|
+
atomic_write_json(run_init_path, record, sort_keys=False)
|
|
153
|
+
return run_init_path
|