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
syncade/selfcheck.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""``syncade --selfcheck`` — verify the configured producer can headless-commit.
|
|
2
|
+
|
|
3
|
+
Pre-flight smoke for the producer's commit path: provisions a throwaway git
|
|
4
|
+
repo + stub ``findings.md``, runs the
|
|
5
|
+
configured producer adapter against the stub, and verifies that
|
|
6
|
+
HEAD moved AND the requested marker text is present in the seed
|
|
7
|
+
file.
|
|
8
|
+
|
|
9
|
+
Motivation: sandboxed producer modes cannot headless-commit reliably. The fix
|
|
10
|
+
was to make ``"yolo"`` the only supported producer permission. Provider
|
|
11
|
+
semantics can still evolve, and a real loop
|
|
12
|
+
would only surface that as a stall at the end of round 0. ``syncade
|
|
13
|
+
--selfcheck`` runs in ~30 seconds and surfaces drift before the next loop
|
|
14
|
+
hits it.
|
|
15
|
+
|
|
16
|
+
The selfcheck verifies whatever permission level the operator's
|
|
17
|
+
``[producer]`` block configures. If ``permissions="yolo"`` is what
|
|
18
|
+
works on their machine, that's what gets verified.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import tempfile
|
|
27
|
+
from datetime import UTC, datetime
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from syncade.adapters.producer import ProducerAdapter
|
|
31
|
+
from syncade.config import SyncadeConfig
|
|
32
|
+
from syncade.exit_codes import (
|
|
33
|
+
FINDINGS_PRESENT,
|
|
34
|
+
SUCCESS,
|
|
35
|
+
WORKTREE_ERROR,
|
|
36
|
+
)
|
|
37
|
+
from syncade.persistence import persist_findings_md, persist_producer_result
|
|
38
|
+
from syncade.process import SubprocessError, SubprocessResult, run_subprocess
|
|
39
|
+
from syncade.producer import run_producer
|
|
40
|
+
from syncade.synthesis import (
|
|
41
|
+
ConsolidatedFinding,
|
|
42
|
+
FindingProvenance,
|
|
43
|
+
SynthesizerOutput,
|
|
44
|
+
)
|
|
45
|
+
from syncade.synthesizer import SynthesizerResult
|
|
46
|
+
from syncade.worktree import WorktreeError, WorktreeManager
|
|
47
|
+
|
|
48
|
+
SELFCHECK_MARKER = "# Selfcheck PASSED"
|
|
49
|
+
"""The literal marker text the producer must add to the seed file.
|
|
50
|
+
|
|
51
|
+
Pinned as a module constant so tests and the producer-facing
|
|
52
|
+
instruction stay in lockstep — a refactor that changes the marker
|
|
53
|
+
in the instruction text but not in the verifier (or vice versa)
|
|
54
|
+
would let a broken producer pass selfcheck."""
|
|
55
|
+
|
|
56
|
+
SELFCHECK_SEED_BASENAME = "selfcheck.py"
|
|
57
|
+
"""Basename of the seed file the producer edits. A ``.py`` extension
|
|
58
|
+
keeps the file looking like real code (a producer that refuses to
|
|
59
|
+
edit non-source files still treats this as code-shaped)."""
|
|
60
|
+
|
|
61
|
+
SELFCHECK_PR_DOC_BASENAME = "pr-doc.md"
|
|
62
|
+
"""Basename of the stub PR doc passed to the producer's
|
|
63
|
+
``{pr_doc_path}`` placeholder."""
|
|
64
|
+
|
|
65
|
+
_SELFCHECK_GIT_USER_NAME = "Syncade Selfcheck"
|
|
66
|
+
_SELFCHECK_GIT_USER_EMAIL = "selfcheck@syncade.test"
|
|
67
|
+
|
|
68
|
+
_KNOWN_GIT_SETUP_STEPS = ("init", "symbolic-ref", "add", "commit", "rev-parse")
|
|
69
|
+
"""Setup-phase git subcommands the selfcheck issues against the
|
|
70
|
+
throwaway repo. Used by :func:`_identify_git_step` to extract a
|
|
71
|
+
human-readable step name from a failed git invocation's ``cmd``
|
|
72
|
+
list when mapping :class:`subprocess.CalledProcessError` to
|
|
73
|
+
``WORKTREE_ERROR``."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _identify_git_step(cmd: list[str]) -> str:
|
|
77
|
+
"""Return the git subcommand from a failed git invocation's
|
|
78
|
+
``cmd`` list (``init`` / ``symbolic-ref`` / ``add`` / ``commit`` /
|
|
79
|
+
``rev-parse``).
|
|
80
|
+
|
|
81
|
+
Handles both the real :func:`_git` cmd shape
|
|
82
|
+
(``["git", "-c", "user.email=...", "-c", "user.name=...",
|
|
83
|
+
<subcommand>, ...]``) and the test-monkey-patched shape
|
|
84
|
+
(``["git", <subcommand>, ...]``) by scanning for the first
|
|
85
|
+
known subcommand token rather than indexing by position. Falls
|
|
86
|
+
back to ``"<unknown>"`` if no known token is found — the
|
|
87
|
+
surrounding error message still names the failure (the rc and
|
|
88
|
+
stderr are emitted alongside).
|
|
89
|
+
"""
|
|
90
|
+
for arg in cmd or []:
|
|
91
|
+
if arg in _KNOWN_GIT_SETUP_STEPS:
|
|
92
|
+
return arg
|
|
93
|
+
return "<unknown>"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_SELFCHECK_GIT_TIMEOUT_SECONDS = 30.0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _git(cwd: Path, *args: str) -> SubprocessResult:
|
|
100
|
+
"""Run a git command with selfcheck-scoped author identity,
|
|
101
|
+
raising :class:`subprocess.CalledProcessError` on non-zero rc.
|
|
102
|
+
|
|
103
|
+
Used only for the throwaway selfcheck repo's setup commits. It
|
|
104
|
+
still routes through :func:`syncade.process.run_subprocess` so git
|
|
105
|
+
setup inherits the same timeout and launch-failure behavior as the
|
|
106
|
+
orchestrator's other critical git calls.
|
|
107
|
+
"""
|
|
108
|
+
argv = [
|
|
109
|
+
"git",
|
|
110
|
+
"-c",
|
|
111
|
+
f"user.email={_SELFCHECK_GIT_USER_EMAIL}",
|
|
112
|
+
"-c",
|
|
113
|
+
f"user.name={_SELFCHECK_GIT_USER_NAME}",
|
|
114
|
+
*args,
|
|
115
|
+
]
|
|
116
|
+
result = run_subprocess(
|
|
117
|
+
argv,
|
|
118
|
+
cwd=cwd,
|
|
119
|
+
timeout=_SELFCHECK_GIT_TIMEOUT_SECONDS,
|
|
120
|
+
)
|
|
121
|
+
if result.returncode != 0:
|
|
122
|
+
raise subprocess.CalledProcessError(
|
|
123
|
+
returncode=result.returncode,
|
|
124
|
+
cmd=argv,
|
|
125
|
+
output=result.stdout,
|
|
126
|
+
stderr=result.stderr,
|
|
127
|
+
)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _build_stub_synth_output() -> SynthesizerOutput:
|
|
132
|
+
"""Construct a :class:`SynthesizerOutput` with one blocker
|
|
133
|
+
finding instructing the producer to add the marker line.
|
|
134
|
+
|
|
135
|
+
The finding is shaped to look like what a real synthesizer
|
|
136
|
+
would emit for a one-line fix request — a blocker with single-
|
|
137
|
+
reviewer provenance, file-scoped, with an actionable
|
|
138
|
+
description. This way the producer's reading of ``findings.md``
|
|
139
|
+
encounters the same structure it would in a real loop, so a
|
|
140
|
+
drift in the findings.md format (e.g. a new required section)
|
|
141
|
+
breaks selfcheck the same way it would break the real loop.
|
|
142
|
+
"""
|
|
143
|
+
return SynthesizerOutput(
|
|
144
|
+
consolidated_findings=[
|
|
145
|
+
ConsolidatedFinding(
|
|
146
|
+
description=(
|
|
147
|
+
f"`{SELFCHECK_SEED_BASENAME}` is missing a "
|
|
148
|
+
f"`{SELFCHECK_MARKER}` comment. Add a single new "
|
|
149
|
+
"line of comment text below the existing comment "
|
|
150
|
+
"with that exact text, then commit. Commit subject: "
|
|
151
|
+
"`selfcheck: add passed marker`. No other changes."
|
|
152
|
+
),
|
|
153
|
+
file=SELFCHECK_SEED_BASENAME,
|
|
154
|
+
severity="blocker",
|
|
155
|
+
provenance=[
|
|
156
|
+
FindingProvenance(
|
|
157
|
+
reviewer_name="selfcheck",
|
|
158
|
+
original_severity="blocker",
|
|
159
|
+
original_index=0,
|
|
160
|
+
original_description=(
|
|
161
|
+
f"Missing `{SELFCHECK_MARKER}` marker comment in "
|
|
162
|
+
f"`{SELFCHECK_SEED_BASENAME}`."
|
|
163
|
+
),
|
|
164
|
+
)
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
],
|
|
168
|
+
synthesis_summary=(
|
|
169
|
+
"Selfcheck stub: producer must add the marker comment "
|
|
170
|
+
f"to {SELFCHECK_SEED_BASENAME} and commit."
|
|
171
|
+
),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _resolve_timeout(config: SyncadeConfig, override: float | None) -> float:
|
|
176
|
+
"""Resolve the per-producer wall-clock timeout.
|
|
177
|
+
|
|
178
|
+
Same precedence the orchestrator's producer phase uses:
|
|
179
|
+
``override`` (CLI ``--timeout``) > ``config.producer.timeout_seconds``
|
|
180
|
+
> ``config.loop.timeout_seconds``.
|
|
181
|
+
"""
|
|
182
|
+
if override is not None:
|
|
183
|
+
return override
|
|
184
|
+
if config.producer.timeout_seconds is not None:
|
|
185
|
+
return config.producer.timeout_seconds
|
|
186
|
+
return config.loop.timeout_seconds
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def run_selfcheck(
|
|
190
|
+
config: SyncadeConfig,
|
|
191
|
+
*,
|
|
192
|
+
timeout_seconds: float | None = None,
|
|
193
|
+
adapter: ProducerAdapter | None = None,
|
|
194
|
+
quiet: bool = False,
|
|
195
|
+
always_cleanup: bool = False,
|
|
196
|
+
) -> int:
|
|
197
|
+
"""Run the producer selfcheck and return a CLI exit code.
|
|
198
|
+
|
|
199
|
+
Steps:
|
|
200
|
+
|
|
201
|
+
1. Create a tmp_path git repo with one sentinel file
|
|
202
|
+
(``selfcheck.py``) seeded with a single-line comment, and
|
|
203
|
+
commit it.
|
|
204
|
+
2. Render a stub ``findings.md`` via the real
|
|
205
|
+
:func:`persist_findings_md` renderer so the producer sees a
|
|
206
|
+
structurally-correct input. Hand-rolling the markdown would
|
|
207
|
+
risk diverging from the real findings.md schema and the
|
|
208
|
+
selfcheck would stop catching real producer failures if
|
|
209
|
+
findings.md format ever changes.
|
|
210
|
+
3. Provision a producer worktree at the seed commit
|
|
211
|
+
(``strip_files=[]`` — same asymmetry as the real producer
|
|
212
|
+
path).
|
|
213
|
+
4. Call :func:`syncade.producer.run_producer` once with the
|
|
214
|
+
operator's ``[producer]`` config and the stub findings.md.
|
|
215
|
+
5. Verify ``outcome == "committed"``, HEAD moved, and the marker
|
|
216
|
+
text is present in the worktree's copy of ``selfcheck.py``.
|
|
217
|
+
6. Persist the producer's artifacts via
|
|
218
|
+
:func:`persist_producer_result` into ``<workspace>/round-0/``
|
|
219
|
+
(``producer.stdout``, ``producer.stderr``,
|
|
220
|
+
``producer.commit.txt``, and on subprocess_error
|
|
221
|
+
``producer.error.txt``). Same file layout as a real round.
|
|
222
|
+
7. Clean up the tmp_path workspace on success; preserve it on
|
|
223
|
+
failure with a printed path so the operator can
|
|
224
|
+
``cat <workspace>/round-0/producer.stdout`` (or
|
|
225
|
+
``producer.error.txt`` on the subprocess-error path) to
|
|
226
|
+
inspect the raw producer output. The selfcheck does NOT
|
|
227
|
+
write a manifest / summary.md — it's a one-shot smoke, not
|
|
228
|
+
a review run. ``always_cleanup=True`` removes the workspace
|
|
229
|
+
even on failure (no preserved-path print): ``syncade --doctor``
|
|
230
|
+
reuses this smoke as an inert preflight and must leave nothing
|
|
231
|
+
behind — it points the operator at ``syncade --selfcheck`` (which
|
|
232
|
+
DOES preserve) for the raw output instead.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
config: The operator's loaded :class:`SyncadeConfig`. The
|
|
236
|
+
``[producer]`` block drives provider / model / thinking
|
|
237
|
+
/ permissions. ``[loop] timeout_seconds`` is the
|
|
238
|
+
fallback when ``[producer] timeout_seconds`` is unset
|
|
239
|
+
AND the ``timeout_seconds`` arg is ``None``.
|
|
240
|
+
timeout_seconds: Optional ``--timeout`` override. Same
|
|
241
|
+
resolution as the orchestrator: this >
|
|
242
|
+
``config.producer.timeout_seconds`` >
|
|
243
|
+
``config.loop.timeout_seconds``.
|
|
244
|
+
adapter: Optional :class:`ProducerAdapter` injection for
|
|
245
|
+
tests. ``None`` (default) routes through the real
|
|
246
|
+
registry per ``config.producer.provider``.
|
|
247
|
+
quiet: Suppress informational stdout. Stderr (failure
|
|
248
|
+
paths) is unaffected. Wired from ``--quiet``.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
``SUCCESS`` (0) when the producer committed AND the marker
|
|
252
|
+
is present.
|
|
253
|
+
|
|
254
|
+
``FINDINGS_PRESENT`` (30) when the producer committed but
|
|
255
|
+
did not add the marker — producer ran but didn't follow the
|
|
256
|
+
instruction.
|
|
257
|
+
|
|
258
|
+
``WORKTREE_ERROR`` (60) for producer subprocess error,
|
|
259
|
+
producer stall, or worktree provisioning failure — any
|
|
260
|
+
path where the producer couldn't make a verifiable commit.
|
|
261
|
+
"""
|
|
262
|
+
|
|
263
|
+
def _info(msg: str) -> None:
|
|
264
|
+
if not quiet:
|
|
265
|
+
print(msg)
|
|
266
|
+
|
|
267
|
+
def _err(msg: str) -> None:
|
|
268
|
+
print(msg, file=sys.stderr)
|
|
269
|
+
|
|
270
|
+
workspace = Path(tempfile.mkdtemp(prefix="syncade-selfcheck-"))
|
|
271
|
+
preserve_on_failure = False
|
|
272
|
+
try:
|
|
273
|
+
# 1. Create the throwaway repo with one seed commit. The
|
|
274
|
+
# init/add/commit + post-subprocess rev-parse calls are
|
|
275
|
+
# wrapped so a CalledProcessError (git binary missing,
|
|
276
|
+
# workspace permissions, corrupt git config, ...) maps to
|
|
277
|
+
# WORKTREE_ERROR with the preserved-workspace contract
|
|
278
|
+
# instead of leaking a Python traceback through the CLI.
|
|
279
|
+
repo_root = workspace / "repo"
|
|
280
|
+
repo_root.mkdir()
|
|
281
|
+
try:
|
|
282
|
+
# init --quiet suppresses the noisy git-2.30+ "hint:"
|
|
283
|
+
# output about the default branch name. Set the branch
|
|
284
|
+
# with symbolic-ref (git 1.7+) rather than
|
|
285
|
+
# --initial-branch=main (git 2.28+) so selfcheck also
|
|
286
|
+
# runs on older platforms; it does not affect the test
|
|
287
|
+
# surface (the seed commit is what matters).
|
|
288
|
+
_git(repo_root, "init", "--quiet")
|
|
289
|
+
_git(repo_root, "symbolic-ref", "HEAD", "refs/heads/main")
|
|
290
|
+
seed = repo_root / SELFCHECK_SEED_BASENAME
|
|
291
|
+
seed.write_text("# selfcheck seed\n", encoding="utf-8")
|
|
292
|
+
_git(repo_root, "add", SELFCHECK_SEED_BASENAME)
|
|
293
|
+
_git(repo_root, "commit", "--quiet", "-m", "selfcheck: seed commit")
|
|
294
|
+
head_result = _git(repo_root, "rev-parse", "HEAD")
|
|
295
|
+
starting_sha = head_result.stdout.strip()
|
|
296
|
+
except (subprocess.CalledProcessError, SubprocessError) as exc:
|
|
297
|
+
preserve_on_failure = True
|
|
298
|
+
if isinstance(exc, subprocess.CalledProcessError) and exc.cmd:
|
|
299
|
+
step = _identify_git_step(list(exc.cmd))
|
|
300
|
+
else:
|
|
301
|
+
step = "<unknown>"
|
|
302
|
+
_err(
|
|
303
|
+
f"[syncade] selfcheck FAILED: git setup error: "
|
|
304
|
+
f"`git {step}` returned rc={getattr(exc, 'returncode', '<launch-error>')}."
|
|
305
|
+
)
|
|
306
|
+
stderr = getattr(exc, "stderr", None)
|
|
307
|
+
if stderr:
|
|
308
|
+
stderr_text = str(stderr).strip()
|
|
309
|
+
if stderr_text:
|
|
310
|
+
_err(f"[syncade] git stderr: {stderr_text}")
|
|
311
|
+
_err(
|
|
312
|
+
"[syncade] git failed during selfcheck repo setup. "
|
|
313
|
+
"Check git installation and workspace permissions; "
|
|
314
|
+
"the preserved workspace below has the partial repo "
|
|
315
|
+
"state for inspection."
|
|
316
|
+
)
|
|
317
|
+
return WORKTREE_ERROR
|
|
318
|
+
|
|
319
|
+
# 2. Stub PR doc the producer template's {pr_doc_path}
|
|
320
|
+
# points at. The producer reads it for context; making it
|
|
321
|
+
# a real file lets a "where's the spec?" producer
|
|
322
|
+
# implementation see something coherent.
|
|
323
|
+
pr_doc = workspace / SELFCHECK_PR_DOC_BASENAME
|
|
324
|
+
pr_doc.write_text(
|
|
325
|
+
(
|
|
326
|
+
"# Selfcheck PR doc\n\n"
|
|
327
|
+
"This is the `syncade --selfcheck` stub PR. The producer is "
|
|
328
|
+
"expected to read `findings.md`, edit "
|
|
329
|
+
f"`{SELFCHECK_SEED_BASENAME}` to add a "
|
|
330
|
+
f"`{SELFCHECK_MARKER}` comment, and commit.\n"
|
|
331
|
+
),
|
|
332
|
+
encoding="utf-8",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# 3. Render findings.md via persist_findings_md so the
|
|
336
|
+
# producer sees the real artifact format.
|
|
337
|
+
round_dir = workspace / "round-0"
|
|
338
|
+
round_dir.mkdir()
|
|
339
|
+
synth_result = SynthesizerResult(
|
|
340
|
+
output=_build_stub_synth_output(),
|
|
341
|
+
error=None,
|
|
342
|
+
duration_seconds=0.0,
|
|
343
|
+
raw_subprocess_result=None,
|
|
344
|
+
)
|
|
345
|
+
findings_md_path = persist_findings_md(
|
|
346
|
+
round_dir,
|
|
347
|
+
synth_result,
|
|
348
|
+
datetime.now(UTC),
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# 4. Provision the producer worktree. defer_cleanup=True
|
|
352
|
+
# so the with-exit doesn't try to remove worktrees we'll
|
|
353
|
+
# rmtree along with the workspace anyway; the workspace's
|
|
354
|
+
# finally branch is the single cleanup point.
|
|
355
|
+
worktree_base = workspace / "worktrees"
|
|
356
|
+
producer_manager = WorktreeManager(
|
|
357
|
+
repo_root,
|
|
358
|
+
"selfcheck",
|
|
359
|
+
base_dir=worktree_base,
|
|
360
|
+
defer_cleanup=True,
|
|
361
|
+
)
|
|
362
|
+
resolved_timeout = _resolve_timeout(config, timeout_seconds)
|
|
363
|
+
|
|
364
|
+
_info(
|
|
365
|
+
f"[syncade] selfcheck: provisioning producer worktree "
|
|
366
|
+
f"({config.producer.provider}, model={config.producer.model}, "
|
|
367
|
+
f"permissions={config.producer.permissions})"
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
try:
|
|
371
|
+
with producer_manager as mgr:
|
|
372
|
+
producer_worktree = mgr.create(
|
|
373
|
+
reviewer_name="producer",
|
|
374
|
+
commit_sha=starting_sha,
|
|
375
|
+
strip_files=[],
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
# 5. Run the producer once.
|
|
379
|
+
_info(f"[syncade] selfcheck: dispatching producer (timeout {resolved_timeout:g}s)")
|
|
380
|
+
producer_result = run_producer(
|
|
381
|
+
worktree_path=producer_worktree.path,
|
|
382
|
+
starting_sha=starting_sha,
|
|
383
|
+
pr_doc_path=pr_doc,
|
|
384
|
+
findings_md_path=findings_md_path,
|
|
385
|
+
test_run_stdout_path=None,
|
|
386
|
+
producer_config=config.producer,
|
|
387
|
+
timeout_seconds=resolved_timeout,
|
|
388
|
+
round_number=0,
|
|
389
|
+
max_rounds=1,
|
|
390
|
+
repo_root=repo_root,
|
|
391
|
+
adapter=adapter,
|
|
392
|
+
max_retries=config.retry.max_retries,
|
|
393
|
+
# Same round dir persist_producer_result writes to below. Selfcheck is a
|
|
394
|
+
# DIAGNOSTIC — the run most likely to be interrupted by an operator who is
|
|
395
|
+
# already debugging — so losing its producer output is the worst case, not
|
|
396
|
+
# an acceptable one (dogfood finding, PR-h-field-03).
|
|
397
|
+
capture_dir=round_dir,
|
|
398
|
+
)
|
|
399
|
+
# Persist producer artifacts
|
|
400
|
+
# (producer.stdout / stderr / commit.txt / error.txt)
|
|
401
|
+
# into the round dir on every outcome so the
|
|
402
|
+
# "operator can `cat worktree/producer.stdout`"
|
|
403
|
+
# contract is met. Without this, only the tmp_path workspace's
|
|
404
|
+
# findings.md + repo were
|
|
405
|
+
# preserved; the producer's raw output (the most
|
|
406
|
+
# interesting diagnostic file) was lost.
|
|
407
|
+
persist_producer_result(round_dir, producer_result)
|
|
408
|
+
|
|
409
|
+
# 6. Verify.
|
|
410
|
+
if producer_result.outcome == "subprocess_error":
|
|
411
|
+
preserve_on_failure = True
|
|
412
|
+
err_cls = type(producer_result.error).__name__
|
|
413
|
+
_err(
|
|
414
|
+
f"[syncade] selfcheck FAILED: producer subprocess "
|
|
415
|
+
f"error ({err_cls}): {producer_result.error}"
|
|
416
|
+
)
|
|
417
|
+
_err(
|
|
418
|
+
f"[syncade] your `{config.producer.provider}` "
|
|
419
|
+
"producer is in a state where it cannot run "
|
|
420
|
+
"headlessly. Inspect the preserved worktree below."
|
|
421
|
+
)
|
|
422
|
+
return WORKTREE_ERROR
|
|
423
|
+
if producer_result.outcome == "stalled":
|
|
424
|
+
preserve_on_failure = True
|
|
425
|
+
_err(
|
|
426
|
+
"[syncade] selfcheck FAILED: producer stalled "
|
|
427
|
+
"(no commit made). The producer ran without error "
|
|
428
|
+
"but did not move HEAD."
|
|
429
|
+
)
|
|
430
|
+
_err(
|
|
431
|
+
f"[syncade] your `{config.producer.provider}` "
|
|
432
|
+
f"producer with permissions="
|
|
433
|
+
f"{config.producer.permissions!r} is in a state "
|
|
434
|
+
"where it cannot commit headlessly. Common "
|
|
435
|
+
"causes: claude prompts for bash (use "
|
|
436
|
+
'permissions="yolo") or codex sandbox blocks '
|
|
437
|
+
'`.git/index.lock` writes (use permissions="yolo").'
|
|
438
|
+
)
|
|
439
|
+
return WORKTREE_ERROR
|
|
440
|
+
|
|
441
|
+
# outcome == "committed" — but did the producer add
|
|
442
|
+
# the marker the instruction asked for?
|
|
443
|
+
seed_in_worktree = producer_worktree.path / SELFCHECK_SEED_BASENAME
|
|
444
|
+
content = seed_in_worktree.read_text(encoding="utf-8")
|
|
445
|
+
if SELFCHECK_MARKER not in content:
|
|
446
|
+
preserve_on_failure = True
|
|
447
|
+
_err(
|
|
448
|
+
"[syncade] selfcheck FAILED: producer committed "
|
|
449
|
+
f"(ending_sha={producer_result.ending_sha[:12]}) "
|
|
450
|
+
f"but `{SELFCHECK_SEED_BASENAME}` does not contain "
|
|
451
|
+
f"{SELFCHECK_MARKER!r}. Producer ran headlessly "
|
|
452
|
+
"but did not follow the instruction."
|
|
453
|
+
)
|
|
454
|
+
return FINDINGS_PRESENT
|
|
455
|
+
|
|
456
|
+
_info(
|
|
457
|
+
f"[syncade] selfcheck OK: producer committed with "
|
|
458
|
+
f"marker ({producer_result.ending_sha[:12]}, "
|
|
459
|
+
f"{producer_result.duration_seconds:.1f}s)"
|
|
460
|
+
)
|
|
461
|
+
return SUCCESS
|
|
462
|
+
except WorktreeError as exc:
|
|
463
|
+
preserve_on_failure = True
|
|
464
|
+
_err(f"[syncade] selfcheck FAILED: worktree error: {exc}")
|
|
465
|
+
return WORKTREE_ERROR
|
|
466
|
+
finally:
|
|
467
|
+
if preserve_on_failure and not always_cleanup:
|
|
468
|
+
_err(f"[syncade] selfcheck workspace preserved at: {workspace}")
|
|
469
|
+
else:
|
|
470
|
+
# Success, or an inert caller (doctor) that must leave nothing behind.
|
|
471
|
+
shutil.rmtree(workspace, ignore_errors=True)
|