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,242 @@
|
|
|
1
|
+
"""Synthesizer subprocess persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<round_dir>/synthesizer.{stdout,stderr,parsed.json[,error.txt]}``
|
|
4
|
+
and the matching round-manifest entry. There is exactly one
|
|
5
|
+
synthesizer per round, so the basename is hardcoded (``SYNTHESIZER_NAME``
|
|
6
|
+
from :mod:`syncade.synthesizer`).
|
|
7
|
+
|
|
8
|
+
The module is named ``synth.py`` (not ``synthesizer.py``) to
|
|
9
|
+
disambiguate from :mod:`syncade.synthesizer`, which contains the actual
|
|
10
|
+
synthesizer driver.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import traceback
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from syncade.synthesizer import (
|
|
20
|
+
SYNTHESIZER_MODEL,
|
|
21
|
+
SYNTHESIZER_NAME,
|
|
22
|
+
SYNTHESIZER_PROVIDER,
|
|
23
|
+
SynthesizerResult,
|
|
24
|
+
)
|
|
25
|
+
from syncade.usage import usage_fields
|
|
26
|
+
|
|
27
|
+
from ._atomic import atomic_write_text
|
|
28
|
+
from ._validation import _validate_reviewer_filename_basename
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SynthesizerArtifactPaths:
|
|
33
|
+
"""Where the synthesizer subprocess's artifacts land on disk.
|
|
34
|
+
|
|
35
|
+
All four paths are absolute and rooted at ``<round_dir>``.
|
|
36
|
+
``parsed`` is ``None`` when the synthesizer failed (no
|
|
37
|
+
:class:`SynthesizerOutput` to serialize); ``error`` is ``None``
|
|
38
|
+
when it succeeded (no exception to record).
|
|
39
|
+
|
|
40
|
+
Returned by :func:`persist_synthesizer_result` and attached to
|
|
41
|
+
:class:`~syncade.orchestrator.RunArtifacts` so the CLI / future
|
|
42
|
+
loop can address the files without re-deriving the layout
|
|
43
|
+
convention.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
stdout: Path
|
|
47
|
+
stderr: Path
|
|
48
|
+
parsed: Path | None
|
|
49
|
+
error: Path | None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def persist_synthesizer_result(
|
|
53
|
+
round_dir: Path, synth_result: SynthesizerResult
|
|
54
|
+
) -> SynthesizerArtifactPaths:
|
|
55
|
+
"""Write the synthesizer subprocess's outputs to
|
|
56
|
+
``<round_dir>/synthesizer.*``.
|
|
57
|
+
|
|
58
|
+
Mirrors :func:`persist_reviewer_result`'s file-layout convention
|
|
59
|
+
so a tool inspecting the round directory sees the synthesizer
|
|
60
|
+
artifacts in the same shape as the per-reviewer artifacts. The
|
|
61
|
+
only difference is the fixed ``"synthesizer"`` basename — there
|
|
62
|
+
is exactly one synthesizer per round, so no per-reviewer-name
|
|
63
|
+
collision risk.
|
|
64
|
+
|
|
65
|
+
Files written:
|
|
66
|
+
|
|
67
|
+
- ``synthesizer.stdout`` and ``synthesizer.stderr`` — always
|
|
68
|
+
created. Carry the captured codex subprocess streams when
|
|
69
|
+
:attr:`SynthesizerResult.raw_subprocess_result` is present;
|
|
70
|
+
empty when the failure happened before any subprocess output
|
|
71
|
+
(binary missing).
|
|
72
|
+
- ``synthesizer.parsed.json`` — written only when
|
|
73
|
+
``synth_result.output is not None``. Pretty-printed via
|
|
74
|
+
:meth:`pydantic.BaseModel.model_dump_json(indent=2)` so the
|
|
75
|
+
file diffs cleanly across runs.
|
|
76
|
+
- ``synthesizer.error.txt`` — written only when
|
|
77
|
+
``synth_result.error is not None``. Class name, message, and
|
|
78
|
+
traceback (when available).
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
round_dir: The round directory to write into. Must already
|
|
82
|
+
exist.
|
|
83
|
+
synth_result: The :class:`SynthesizerResult` from
|
|
84
|
+
:func:`syncade.synthesizer.run_synthesizer`.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
:class:`SynthesizerArtifactPaths` naming all written files.
|
|
88
|
+
|
|
89
|
+
Raises:
|
|
90
|
+
FileNotFoundError: If ``round_dir`` does not exist (caller
|
|
91
|
+
bug — the orchestrator creates it during run setup).
|
|
92
|
+
"""
|
|
93
|
+
_validate_reviewer_filename_basename(SYNTHESIZER_NAME)
|
|
94
|
+
if not round_dir.is_dir():
|
|
95
|
+
raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
|
|
96
|
+
|
|
97
|
+
base = round_dir / SYNTHESIZER_NAME
|
|
98
|
+
|
|
99
|
+
raw_result = synth_result.raw_subprocess_result
|
|
100
|
+
stdout_text = raw_result.stdout if raw_result is not None else ""
|
|
101
|
+
stderr_text = raw_result.stderr if raw_result is not None else ""
|
|
102
|
+
stdout_path = base.with_suffix(".stdout")
|
|
103
|
+
stderr_path = base.with_suffix(".stderr")
|
|
104
|
+
atomic_write_text(stdout_path, stdout_text)
|
|
105
|
+
atomic_write_text(stderr_path, stderr_text)
|
|
106
|
+
|
|
107
|
+
parsed_path: Path | None = None
|
|
108
|
+
if synth_result.output is not None:
|
|
109
|
+
parsed_path = base.with_suffix(".parsed.json")
|
|
110
|
+
atomic_write_text(parsed_path, synth_result.output.model_dump_json(indent=2))
|
|
111
|
+
|
|
112
|
+
error_path: Path | None = None
|
|
113
|
+
if synth_result.error is not None:
|
|
114
|
+
exc = synth_result.error
|
|
115
|
+
lines = [
|
|
116
|
+
f"{type(exc).__name__}: {exc}",
|
|
117
|
+
"",
|
|
118
|
+
]
|
|
119
|
+
tb = exc.__traceback__
|
|
120
|
+
if tb is not None:
|
|
121
|
+
lines.extend(traceback.format_exception(type(exc), exc, tb))
|
|
122
|
+
else:
|
|
123
|
+
lines.append("(no traceback available — exception was constructed, not raised)")
|
|
124
|
+
error_path = base.with_suffix(".error.txt")
|
|
125
|
+
atomic_write_text(error_path, "\n".join(lines))
|
|
126
|
+
|
|
127
|
+
return SynthesizerArtifactPaths(
|
|
128
|
+
stdout=stdout_path,
|
|
129
|
+
stderr=stderr_path,
|
|
130
|
+
parsed=parsed_path,
|
|
131
|
+
error=error_path,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _synthesizer_manifest_entry(synth_result: SynthesizerResult | None) -> dict[str, object] | None:
|
|
136
|
+
"""Build the ``synthesizer`` section of the round manifest.
|
|
137
|
+
|
|
138
|
+
Returns ``None`` when the synthesizer phase was skipped (any
|
|
139
|
+
reviewer failed); a dict otherwise.
|
|
140
|
+
|
|
141
|
+
Schema:
|
|
142
|
+
|
|
143
|
+
.. code-block:: json
|
|
144
|
+
|
|
145
|
+
{
|
|
146
|
+
"outcome": "success" | "failure",
|
|
147
|
+
"stdout_path": "synthesizer.stdout",
|
|
148
|
+
"stderr_path": "synthesizer.stderr",
|
|
149
|
+
"parsed_path": "synthesizer.parsed.json" | null,
|
|
150
|
+
"error_path": "synthesizer.error.txt" | null,
|
|
151
|
+
"duration_seconds": float,
|
|
152
|
+
"error_type": null | "ExceptionClassName",
|
|
153
|
+
"dismissed_count": int | null,
|
|
154
|
+
"active_blocker_count": int | null,
|
|
155
|
+
"active_minor_count": int | null,
|
|
156
|
+
"active_nit_count": int | null,
|
|
157
|
+
"provenance_repairs": [ {reviewer_name, original_index,
|
|
158
|
+
synthesizer_text, reviewer_text} ] | []
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
On success: counts populated, ``error_path`` AND ``error_type``
|
|
162
|
+
are null (no .error.txt was written; no exception class to
|
|
163
|
+
record).
|
|
164
|
+
|
|
165
|
+
On failure: counts are null, ``error_path`` names the .error.txt
|
|
166
|
+
artifact IFF persistence will actually write one — i.e. when
|
|
167
|
+
``synth_result.error is not None``. ``SynthesizerResult`` enforces
|
|
168
|
+
exactly one of output/error at construction, so the null-error
|
|
169
|
+
fallback is defensive only.
|
|
170
|
+
"""
|
|
171
|
+
if synth_result is None:
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
paths = {
|
|
175
|
+
"provider": synth_result.provider or SYNTHESIZER_PROVIDER,
|
|
176
|
+
"model": synth_result.model
|
|
177
|
+
or (synth_result.usage.model if synth_result.usage is not None else SYNTHESIZER_MODEL),
|
|
178
|
+
"stdout_path": f"{SYNTHESIZER_NAME}.stdout",
|
|
179
|
+
"stderr_path": f"{SYNTHESIZER_NAME}.stderr",
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if synth_result.output is not None:
|
|
183
|
+
consolidated = synth_result.output.consolidated_findings
|
|
184
|
+
dismissed = sum(1 for f in consolidated if f.dismissed)
|
|
185
|
+
active_by_sev = {"blocker": 0, "minor": 0, "nit": 0}
|
|
186
|
+
for f in consolidated:
|
|
187
|
+
if not f.dismissed:
|
|
188
|
+
active_by_sev[f.severity] += 1
|
|
189
|
+
return {
|
|
190
|
+
"outcome": "success",
|
|
191
|
+
**paths,
|
|
192
|
+
"parsed_path": f"{SYNTHESIZER_NAME}.parsed.json",
|
|
193
|
+
"error_path": None,
|
|
194
|
+
"duration_seconds": synth_result.duration_seconds,
|
|
195
|
+
**usage_fields(synth_result.usage),
|
|
196
|
+
# Include error_type on success (null) for schema symmetry;
|
|
197
|
+
# downstream tools don't have to KeyError-guard or treat absence as
|
|
198
|
+
# a separate signal.
|
|
199
|
+
"error_type": None,
|
|
200
|
+
"dismissed_count": dismissed,
|
|
201
|
+
"active_blocker_count": active_by_sev["blocker"],
|
|
202
|
+
"active_minor_count": active_by_sev["minor"],
|
|
203
|
+
"active_nit_count": active_by_sev["nit"],
|
|
204
|
+
# Non-empty means the synthesizer miscopied a source it had correctly
|
|
205
|
+
# attributed, and syncade corrected the quotation from the reviewer's own
|
|
206
|
+
# text (PR-h-field-01 item 5). Recorded rather than silent: it is a signal about
|
|
207
|
+
# that model's fidelity, and rewriting a model's output without saying so
|
|
208
|
+
# is not something to hide. Both strings are kept so the operator can judge.
|
|
209
|
+
"provenance_repairs": [
|
|
210
|
+
{
|
|
211
|
+
"reviewer_name": r.reviewer_name,
|
|
212
|
+
"original_index": r.original_index,
|
|
213
|
+
"consolidated_index": r.consolidated_index,
|
|
214
|
+
"provenance_index": r.provenance_index,
|
|
215
|
+
"synthesizer_text": r.synthesizer_text,
|
|
216
|
+
"reviewer_text": r.reviewer_text,
|
|
217
|
+
}
|
|
218
|
+
for r in synth_result.provenance_repairs
|
|
219
|
+
],
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
# Failure path. error_path is null when no .error.txt will be
|
|
223
|
+
# written — that's the contract-violation case (output=None AND
|
|
224
|
+
# error=None) where persist_synthesizer_result intentionally
|
|
225
|
+
# skips the error.txt write. Without this guard the manifest
|
|
226
|
+
# would point at a file that doesn't exist on disk.
|
|
227
|
+
error_type = type(synth_result.error).__name__ if synth_result.error is not None else None
|
|
228
|
+
error_path = f"{SYNTHESIZER_NAME}.error.txt" if synth_result.error is not None else None
|
|
229
|
+
return {
|
|
230
|
+
"outcome": "failure",
|
|
231
|
+
**paths,
|
|
232
|
+
"parsed_path": None,
|
|
233
|
+
"error_path": error_path,
|
|
234
|
+
"duration_seconds": synth_result.duration_seconds,
|
|
235
|
+
**usage_fields(synth_result.usage),
|
|
236
|
+
"error_type": error_type,
|
|
237
|
+
"dismissed_count": None,
|
|
238
|
+
"active_blocker_count": None,
|
|
239
|
+
"active_minor_count": None,
|
|
240
|
+
"active_nit_count": None,
|
|
241
|
+
"provenance_repairs": [],
|
|
242
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Test re-run leg persistence.
|
|
2
|
+
|
|
3
|
+
Writes ``<round_dir>/test-run.{stdout,stderr,exit-code.txt}`` and the
|
|
4
|
+
matching round-manifest entry. The orchestrator only calls these on
|
|
5
|
+
rounds where the operator configured ``[loop] test_command`` AND every
|
|
6
|
+
prior phase succeeded AND the synthesizer was clean.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from syncade.test_runner import TestRunResult
|
|
15
|
+
|
|
16
|
+
from ._atomic import atomic_write_text
|
|
17
|
+
from ._validation import _validate_reviewer_filename_basename
|
|
18
|
+
|
|
19
|
+
# Hardcoded basename for the test-leg artifacts. There is exactly one
|
|
20
|
+
# test run per round, so no per-test-command collision risk. The
|
|
21
|
+
# hardcoded basename also keeps the operator's ``test_command`` string
|
|
22
|
+
# (which can be anything) entirely out of the filesystem layer —
|
|
23
|
+
# manifest.json echoes the command but no file is ever named from it.
|
|
24
|
+
TEST_RUN_NAME = "test-run"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class TestRunArtifactPaths:
|
|
29
|
+
"""Where the test re-run leg's artifacts land on disk.
|
|
30
|
+
|
|
31
|
+
Returned by :func:`persist_test_run_result` and attached to
|
|
32
|
+
:class:`~syncade.orchestrator.RunArtifacts` so the CLI / future
|
|
33
|
+
loop can address the files without re-deriving the layout
|
|
34
|
+
convention.
|
|
35
|
+
|
|
36
|
+
All three paths are absolute and rooted at ``<round_dir>``. They
|
|
37
|
+
are always written when the test leg ran (the orchestrator only
|
|
38
|
+
calls :func:`persist_test_run_result` on a non-None
|
|
39
|
+
``test_result``); the unconfigured-leg path keeps the field
|
|
40
|
+
``None`` on :class:`~syncade.orchestrator.RunArtifacts` so the
|
|
41
|
+
no-files-written-on-disk signal stays unambiguous.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
stdout: Path
|
|
45
|
+
stderr: Path
|
|
46
|
+
exit_code: Path
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def persist_test_run_result(
|
|
50
|
+
round_dir: Path,
|
|
51
|
+
test_result: TestRunResult,
|
|
52
|
+
) -> TestRunArtifactPaths:
|
|
53
|
+
"""Write the test re-run leg's outputs to
|
|
54
|
+
``<round_dir>/test-run.{stdout,stderr,exit-code.txt}``.
|
|
55
|
+
|
|
56
|
+
Mirrors :func:`persist_reviewer_result` and
|
|
57
|
+
:func:`persist_synthesizer_result`'s file-layout convention so a
|
|
58
|
+
tool inspecting the round directory sees the test artifacts in
|
|
59
|
+
the same shape as the per-reviewer and synthesizer artifacts.
|
|
60
|
+
|
|
61
|
+
Files written:
|
|
62
|
+
|
|
63
|
+
- ``test-run.stdout`` — captured test command stdout (whatever
|
|
64
|
+
reached the pipe buffer before the subprocess exited or was
|
|
65
|
+
killed). Empty on the subprocess-error path where the
|
|
66
|
+
subprocess never started (binary missing, bad cwd).
|
|
67
|
+
- ``test-run.stderr`` — captured test command stderr.
|
|
68
|
+
- ``test-run.exit-code.txt`` — one-line integer terminated by
|
|
69
|
+
``\\n`` (``0`` for passed, the actual exit code for failed,
|
|
70
|
+
``-1`` for subprocess error). Stored as a hardcoded basename
|
|
71
|
+
file rather than embedded only in ``manifest.json`` so a
|
|
72
|
+
shell-script consumer (CI integration, ``grep`` against the
|
|
73
|
+
round directory) can pull it without parsing JSON.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
round_dir: The round directory to write into. Must already
|
|
77
|
+
exist (the orchestrator creates it during run setup).
|
|
78
|
+
test_result: The :class:`TestRunResult` from
|
|
79
|
+
:func:`syncade.test_runner.run_tests`. The caller (the
|
|
80
|
+
orchestrator) only calls this on a non-None test_result;
|
|
81
|
+
the unconfigured-leg path never reaches here.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
:class:`TestRunArtifactPaths` naming all three written files.
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
FileNotFoundError: If ``round_dir`` does not exist (caller
|
|
88
|
+
bug — the orchestrator creates it during run setup).
|
|
89
|
+
"""
|
|
90
|
+
_validate_reviewer_filename_basename(TEST_RUN_NAME)
|
|
91
|
+
if not round_dir.is_dir():
|
|
92
|
+
raise FileNotFoundError(f"round_dir does not exist: {round_dir}")
|
|
93
|
+
|
|
94
|
+
base = round_dir / TEST_RUN_NAME
|
|
95
|
+
stdout_path = base.with_suffix(".stdout")
|
|
96
|
+
stderr_path = base.with_suffix(".stderr")
|
|
97
|
+
# ``test-run.exit-code.txt`` — Path.with_suffix would replace the
|
|
98
|
+
# ``.stdout`` suffix wholesale, leaving us with
|
|
99
|
+
# ``test-run.exit-code.txt`` only if we hand-stitch. Build the
|
|
100
|
+
# filename directly rather than relying on with_suffix; the
|
|
101
|
+
# hardcoded basename keeps the layout self-explanatory.
|
|
102
|
+
exit_code_path = round_dir / f"{TEST_RUN_NAME}.exit-code.txt"
|
|
103
|
+
|
|
104
|
+
atomic_write_text(stdout_path, test_result.stdout)
|
|
105
|
+
atomic_write_text(stderr_path, test_result.stderr)
|
|
106
|
+
atomic_write_text(exit_code_path, f"{test_result.exit_code}\n")
|
|
107
|
+
|
|
108
|
+
return TestRunArtifactPaths(
|
|
109
|
+
stdout=stdout_path,
|
|
110
|
+
stderr=stderr_path,
|
|
111
|
+
exit_code=exit_code_path,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _test_run_manifest_entry(test_result: TestRunResult | None) -> dict[str, object] | None:
|
|
116
|
+
"""Build the ``test_run`` section of the round manifest.
|
|
117
|
+
|
|
118
|
+
Returns ``None`` when the leg was skipped (either ``test_command``
|
|
119
|
+
is not configured OR a prior phase failed/produced blockers).
|
|
120
|
+
Returns a dict with the documented synthesizer schema otherwise.
|
|
121
|
+
|
|
122
|
+
Schema:
|
|
123
|
+
|
|
124
|
+
.. code-block:: json
|
|
125
|
+
|
|
126
|
+
{
|
|
127
|
+
"outcome": "passed" | "failed" | "subprocess_error",
|
|
128
|
+
"exit_code": 0 | int | -1,
|
|
129
|
+
"command": "<operator-configured string>",
|
|
130
|
+
"duration_seconds": float,
|
|
131
|
+
"stdout_path": "test-run.stdout",
|
|
132
|
+
"stderr_path": "test-run.stderr",
|
|
133
|
+
"exit_code_path": "test-run.exit-code.txt",
|
|
134
|
+
"error_type": null | "SubprocessTimeoutError" | ...
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
``command`` is echoed verbatim from the operator's config so a
|
|
138
|
+
consumer reading manifest.json knows what was run without
|
|
139
|
+
cross-referencing back to ``.syncade/config.toml``.
|
|
140
|
+
"""
|
|
141
|
+
if test_result is None:
|
|
142
|
+
return None
|
|
143
|
+
return {
|
|
144
|
+
"outcome": test_result.outcome,
|
|
145
|
+
"exit_code": test_result.exit_code,
|
|
146
|
+
"command": test_result.command,
|
|
147
|
+
"duration_seconds": test_result.duration_seconds,
|
|
148
|
+
"stdout_path": f"{TEST_RUN_NAME}.stdout",
|
|
149
|
+
"stderr_path": f"{TEST_RUN_NAME}.stderr",
|
|
150
|
+
"exit_code_path": f"{TEST_RUN_NAME}.exit-code.txt",
|
|
151
|
+
"error_type": (type(test_result.error).__name__ if test_result.error is not None else None),
|
|
152
|
+
}
|
syncade/presets.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Bundled config presets (PR-v2-9): ``--preset cheap|balanced|thorough``.
|
|
2
|
+
|
|
3
|
+
Loads a packaged TOML preset as a plain ``dict`` — stdlib + importlib only, NO ``config`` import, so
|
|
4
|
+
:func:`syncade.config_loader.load_config` can deep-merge it without a cycle. The preset is the run's
|
|
5
|
+
BASE config; the user's ``.syncade/config.toml`` layers on top (user wins).
|
|
6
|
+
|
|
7
|
+
Presets vary ONLY safe loop dimensions (rounds / timeout) and NEVER the reviewer model or effort
|
|
8
|
+
tier — a cheaper panel that audits leniently is the pr-29 hazard. ``tests/config/test_presets.py``
|
|
9
|
+
pins that: every preset yields the SAME reviewer roster as the shipped defaults, and ``balanced`` is
|
|
10
|
+
behaviourally the defaults.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import tomllib
|
|
16
|
+
from importlib.resources import files
|
|
17
|
+
|
|
18
|
+
# The bundled presets. Explicit (not directory-discovered) so ``--preset``'s argparse choices and
|
|
19
|
+
# this tuple cannot drift, and an unrelated ``.toml`` dropped in the dir can't become a preset.
|
|
20
|
+
PRESET_NAMES: tuple[str, ...] = ("cheap", "balanced", "thorough")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PresetError(Exception):
|
|
24
|
+
"""An unknown ``--preset`` name (the CLI's argparse ``choices`` normally catches this first)."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_preset(name: str) -> dict:
|
|
28
|
+
"""Return the bundled preset ``name`` as a parsed TOML dict.
|
|
29
|
+
|
|
30
|
+
Read via :func:`importlib.resources.files` so it resolves in both a source checkout and an
|
|
31
|
+
installed wheel. Raises :class:`PresetError` for an unknown name.
|
|
32
|
+
"""
|
|
33
|
+
if name not in PRESET_NAMES:
|
|
34
|
+
raise PresetError(f"unknown preset {name!r}; choose from {list(PRESET_NAMES)}")
|
|
35
|
+
text = (files("syncade") / "templates" / "presets" / f"{name}.toml").read_text(encoding="utf-8")
|
|
36
|
+
return tomllib.loads(text)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Config-driven per-model pricing (PR-v2-04).
|
|
2
|
+
|
|
3
|
+
Codex reports tokens but no cost, so cost is derived here. Ships a conservative
|
|
4
|
+
default table so zero-config runs still estimate cost (labeled ``"estimated"``);
|
|
5
|
+
operators override via a ``[pricing]`` table in ``.syncade/config.toml``. Prices
|
|
6
|
+
are list prices as of 2026-07 (USD per 1M tokens) and go stale — override for
|
|
7
|
+
accuracy. Split into its own module (not ``config.py``, which is at its LOC cap).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ModelPrice(BaseModel):
|
|
16
|
+
"""USD per 1M tokens for one model."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(extra="forbid", allow_inf_nan=False)
|
|
19
|
+
|
|
20
|
+
input_per_mtok: float = Field(ge=0)
|
|
21
|
+
output_per_mtok: float = Field(ge=0)
|
|
22
|
+
cached_input_per_mtok: float | None = Field(
|
|
23
|
+
default=None, ge=0
|
|
24
|
+
) # unset → priced at the input rate
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# List prices as of 2026-07 (USD / 1M tokens). Estimates — override via [pricing].
|
|
28
|
+
DEFAULT_PRICES: dict[str, ModelPrice] = {
|
|
29
|
+
"claude-opus-4-8": ModelPrice(
|
|
30
|
+
input_per_mtok=15.0, output_per_mtok=75.0, cached_input_per_mtok=1.5
|
|
31
|
+
),
|
|
32
|
+
"claude-sonnet-4-6": ModelPrice(
|
|
33
|
+
input_per_mtok=3.0, output_per_mtok=15.0, cached_input_per_mtok=0.3
|
|
34
|
+
),
|
|
35
|
+
# No default uses gpt-5.6-sol any more (PR-28 put the reviewers + judge on it;
|
|
36
|
+
# PR-29 moved both back to gpt-5.5). It STAYS priced: runs 2026-07-11T15-36-39
|
|
37
|
+
# and 2026-07-12T10-04-35 ran on it, and `syncade --metrics` reads the whole
|
|
38
|
+
# historical corpus — dropping the entry would silently turn those runs' cost
|
|
39
|
+
# into "unknown". Prices are append-mostly for exactly this reason.
|
|
40
|
+
"gpt-5.6-sol": ModelPrice(input_per_mtok=5.0, output_per_mtok=30.0, cached_input_per_mtok=0.5),
|
|
41
|
+
# Codex-harness producer default.
|
|
42
|
+
"gpt-5.6-terra": ModelPrice(
|
|
43
|
+
input_per_mtok=2.5, output_per_mtok=15.0, cached_input_per_mtok=0.25
|
|
44
|
+
),
|
|
45
|
+
"gpt-5.5": ModelPrice(input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125),
|
|
46
|
+
"gpt-5.4": ModelPrice(input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125),
|
|
47
|
+
"gpt-5.4-mini": ModelPrice(
|
|
48
|
+
input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
|
|
49
|
+
),
|
|
50
|
+
"gpt-5.3-codex": ModelPrice(
|
|
51
|
+
input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
|
|
52
|
+
),
|
|
53
|
+
"gpt-5.3-codex-spark": ModelPrice(
|
|
54
|
+
input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
|
|
55
|
+
),
|
|
56
|
+
"gpt-5.2": ModelPrice(input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125),
|
|
57
|
+
"gpt-5-codex": ModelPrice(
|
|
58
|
+
input_per_mtok=1.25, output_per_mtok=10.0, cached_input_per_mtok=0.125
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class PricingConfig(BaseModel):
|
|
64
|
+
"""The ``[pricing]`` config section. A user-supplied ``models`` table replaces
|
|
65
|
+
the packaged default (provide the full table to override)."""
|
|
66
|
+
|
|
67
|
+
model_config = ConfigDict(extra="forbid")
|
|
68
|
+
|
|
69
|
+
models: dict[str, ModelPrice] = Field(default_factory=lambda: dict(DEFAULT_PRICES))
|
|
70
|
+
|
|
71
|
+
def price_for(self, model: str) -> ModelPrice | None:
|
|
72
|
+
return self.models.get(model)
|