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,238 @@
|
|
|
1
|
+
"""Prompt template loader + the reviewer prompt blocks.
|
|
2
|
+
|
|
3
|
+
``load_template`` (the per-repo-override-then-packaged-default resolver with the
|
|
4
|
+
symlink-containment guard), ``ADVERSARIAL_LENS_BLOCK`` (the opt-in reviewer
|
|
5
|
+
edge-enumeration text), and ``BUG_CLASS_BLOCK`` (the opt-in directed
|
|
6
|
+
bug-class sweep). All are free of any dependency on ``prompts`` itself —
|
|
7
|
+
stdlib + importlib only — so they extract without a circular import. Re-exported
|
|
8
|
+
from ``prompts`` so the ``syncade.prompts.<name>`` import paths are unchanged.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from importlib.resources import files
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ADVERSARIAL_LENS_BLOCK = """
|
|
17
|
+
## Adversarial edge enumeration (before any SHIP)
|
|
18
|
+
|
|
19
|
+
Confirming the code does what the brief says is NOT enough — that is exactly how
|
|
20
|
+
real defects survive review, and it is the specific failure this instruction
|
|
21
|
+
exists to stop. The brief's acceptance criteria are the FLOOR of your review,
|
|
22
|
+
never the ceiling: ticking off "each AC is implemented" is the confirmatory trap
|
|
23
|
+
that ships bugs. Before any SHIP, treat the implementation as guilty until proven
|
|
24
|
+
innocent and do all three of the following — your `summary` must show you did.
|
|
25
|
+
|
|
26
|
+
1. **Enumerate the spec-omitted combinations, in writing.** List the cases the
|
|
27
|
+
brief does NOT discuss — flag interactions (e.g. a verbosity/quiet flag
|
|
28
|
+
crossed with the feature), empty / missing / malformed / unrelated inputs,
|
|
29
|
+
absent state (no record, no upstream, no config), ordering / concurrency
|
|
30
|
+
edges. This list is SEPARATE from the acceptance criteria; if your enumeration
|
|
31
|
+
just restates the ACs, you have not done it.
|
|
32
|
+
2. **Falsify every invariant the brief asserts — do not confirm the mechanism.**
|
|
33
|
+
For each absolute claim the brief makes ("byte-identical", "unchanged",
|
|
34
|
+
"always …", "never silently …", "exactly one", "only X"), construct and RUN
|
|
35
|
+
the comparison that would DISPROVE it, then cite the command and its output.
|
|
36
|
+
Confirming the relevant code path exists or executes is NOT verifying the
|
|
37
|
+
claim: "byte-identical" means you diff the actual bytes of both outputs, not
|
|
38
|
+
that a branch is taken; "never silently" means you run the silent mode and
|
|
39
|
+
inspect the channel, not that a log line exists in source. An asserted
|
|
40
|
+
invariant you did not try to break is unverified.
|
|
41
|
+
3. **Run each enumerated edge in your worktree.** A probed edge that misbehaves
|
|
42
|
+
is a finding (cite the reproduction). An edge you could NOT probe is a
|
|
43
|
+
`coverage_gap`. SHIP is permitted ONLY when your `summary` shows the
|
|
44
|
+
enumeration (1) AND a falsification attempt for each asserted invariant (2);
|
|
45
|
+
without those you have not finished reviewing — emit NO-SHIP or coverage_gaps,
|
|
46
|
+
never a silent clean SHIP.
|
|
47
|
+
4. **Verify the END-TO-END journey, and a real defect is a blocker — do not
|
|
48
|
+
downgrade it to a nit to justify SHIP.** Two failure modes this stops, both
|
|
49
|
+
seen in validation:
|
|
50
|
+
- *Unit verified, journey not.* Trace each feature from invocation to its
|
|
51
|
+
FINAL output / printed message / side-effect, and check every brief
|
|
52
|
+
invariant holds at EVERY surface — not just the first. A `--base` correctly
|
|
53
|
+
passed to one function but DROPPED from the printed next-step is a real
|
|
54
|
+
blocker; the running test suite passing is not the journey.
|
|
55
|
+
- *Happy path verified, consequence-of-bad-input not.* For each parse / IO /
|
|
56
|
+
input operation, feed the corrupt / missing / malformed case and ask what
|
|
57
|
+
the user GETS: a loud error, or a plausible-but-WRONG result? A
|
|
58
|
+
silently-wrong result (a partial draft from a corrupt input that looks
|
|
59
|
+
complete; a value silently defaulted) is a blocker, not an edge note.
|
|
60
|
+
When you find a defect that loses data, changes behavior, or contradicts a
|
|
61
|
+
brief invariant, your verdict is **NO-SHIP and the finding is a `blocker`** —
|
|
62
|
+
however small the fix looks. Do not downgrade a genuine defect to a `nit` or
|
|
63
|
+
`minor` so you can SHIP; "the fix is one line" is not a reason to ship the bug.
|
|
64
|
+
"""
|
|
65
|
+
"""The adversarial edge-enumeration block.
|
|
66
|
+
|
|
67
|
+
Substituted into ``reviewer.md``'s ``{adversarial_lens_block}`` placeholder
|
|
68
|
+
only for reviewers configured ``adversarial_lens=True`` (see
|
|
69
|
+
:class:`~syncade.config.ReviewerConfig`); other reviewers render the placeholder
|
|
70
|
+
as the empty string.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
BUG_CLASS_BLOCK = """## Directed bug-class sweep (before any SHIP)
|
|
74
|
+
|
|
75
|
+
The adversarial disposition above tells you to attack the change; this tells you
|
|
76
|
+
WHERE to aim, so recall does not depend on inspiration. Work each angle below
|
|
77
|
+
against the diff AND the enclosing functions, and surface every candidate with a
|
|
78
|
+
nameable failure — as a *candidate*. This is find-phase guidance: raising one here
|
|
79
|
+
does NOT relax the rule that a `blocker` needs reproduction, and a candidate you
|
|
80
|
+
drop silently is never verified — the single largest source of missed defects.
|
|
81
|
+
|
|
82
|
+
Severity follows verification state, not your confidence:
|
|
83
|
+
- A candidate you REPRODUCE (cite `evidence_cmd` / `evidence_output`) takes the
|
|
84
|
+
severity its consequence warrants — `blocker` if it loses or corrupts data,
|
|
85
|
+
breaks a user path, or violates an invariant the brief asserts. Do NOT downgrade
|
|
86
|
+
a reproduced defect so you can SHIP (see the adversarial block above).
|
|
87
|
+
- A candidate whose mechanism is real but whose trigger you could not reproduce is
|
|
88
|
+
a `minor`, or goes in `coverage_gaps` — surfaced, never dropped, and never an
|
|
89
|
+
unreproduced `blocker`. This keeps recall high without spending a producer round
|
|
90
|
+
on a maybe-bug.
|
|
91
|
+
|
|
92
|
+
Your `summary` must name which of these angles you ran.
|
|
93
|
+
|
|
94
|
+
- **Removed-behavior audit.** For every line the diff DELETES or replaces —
|
|
95
|
+
including a rewritten docstring or comment that stated an invariant — name the
|
|
96
|
+
guard, validation, error path, or invariant it enforced, then find where the new
|
|
97
|
+
code re-establishes it. If you cannot, that is a candidate. Watch especially for
|
|
98
|
+
an invariant only PARTIALLY re-established: a new code path that reaches the same
|
|
99
|
+
sink (a delete, a write, a network call) without the guard the old path carried.
|
|
100
|
+
- **Caller/callee trace.** For every function the diff changes, grep its callers
|
|
101
|
+
and check each call site against the NEW contract: a new precondition, a changed
|
|
102
|
+
return shape or nullability, a new exception, a new empty/zero case the caller
|
|
103
|
+
does not handle. Then check the callees the diff adds: does a new call feed a
|
|
104
|
+
value into an existing sink (sweep / delete / overwrite / commit) whose guard
|
|
105
|
+
assumed the old path's guarantees? A *success* that now carries an empty or
|
|
106
|
+
absent result into a destructive sink is the highest-value find here. (Distinct
|
|
107
|
+
from the consistency-class rule above: that rule is the same fact restated in
|
|
108
|
+
many places; this is a changed contract breaking a call site.)
|
|
109
|
+
- **Language / framework pitfall.** Flag the classic footguns the diff introduces
|
|
110
|
+
for its language: falsy-zero and `==` coercion (JS); mutable-default-arg and
|
|
111
|
+
late-binding closures (Python); nil-map write and range-var capture (Go);
|
|
112
|
+
unanchored regex; float equality; timezone/DST drift; SQL injection.
|
|
113
|
+
- **Wrapper / proxy correctness.** When the change adds or edits a type that wraps
|
|
114
|
+
another (cache, proxy, decorator, adapter), check every method routes to the
|
|
115
|
+
wrapped instance and not back through a registry/session/global (which re-enters
|
|
116
|
+
or recurses), and that it forwards every method its callers actually use.
|
|
117
|
+
"""
|
|
118
|
+
"""The directed bug-class sweep block.
|
|
119
|
+
|
|
120
|
+
Substituted into every reviewer template's ``{bug_class_block}`` placeholder for
|
|
121
|
+
reviewers configured ``bug_class_sweep=True``. OPT-IN, like
|
|
122
|
+
:data:`ADVERSARIAL_LENS_BLOCK` (see :class:`~syncade.config.ReviewerConfig`): a reviewer that
|
|
123
|
+
does not ask for it renders the placeholder as the empty string. The contributed design
|
|
124
|
+
defaulted this ON; it is held opt-in until an ablation measures whether the checklist raises
|
|
125
|
+
recall or narrows the search.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def load_template(repo_root: Path, template_name: str) -> str:
|
|
130
|
+
"""Load a prompt template by basename.
|
|
131
|
+
|
|
132
|
+
Resolution order:
|
|
133
|
+
|
|
134
|
+
1. ``<repo_root>/.syncade/templates/<template_name>`` — per-repo
|
|
135
|
+
override. If this file exists, its contents are returned. Any
|
|
136
|
+
read failure (permissions, encoding) raises ``OSError`` and is
|
|
137
|
+
NOT silently swallowed — a broken override is a config bug, not
|
|
138
|
+
a fall-through-to-default condition.
|
|
139
|
+
2. Packaged default at ``syncade/templates/<template_name>``,
|
|
140
|
+
loaded via :func:`importlib.resources.files` so it works both
|
|
141
|
+
for installed wheels and ``pip install -e .`` editable
|
|
142
|
+
installs.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
repo_root: The git repo root to check for an override under
|
|
146
|
+
``.syncade/templates/``.
|
|
147
|
+
template_name: The template's basename (e.g. ``"reviewer.md"``,
|
|
148
|
+
``"synthesizer.md"``). Must be a plain basename — no path
|
|
149
|
+
separators, no parent references, no leading ``.``. The
|
|
150
|
+
check is intentionally strict: a template loader that
|
|
151
|
+
accepts arbitrary paths could be tricked into reading
|
|
152
|
+
anywhere on the filesystem by a malicious config.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The raw template string with ``{placeholder}`` tokens
|
|
156
|
+
unsubstituted. Substitution is the per-template renderer's
|
|
157
|
+
concern.
|
|
158
|
+
|
|
159
|
+
Raises:
|
|
160
|
+
ValueError: If ``template_name`` is not a safe basename OR if
|
|
161
|
+
the override file (or any parent directory) is a symlink
|
|
162
|
+
that escapes ``<repo_root>/.syncade/templates/``. The
|
|
163
|
+
containment guard prevents a malicious or
|
|
164
|
+
accidental symlink (e.g. ``.syncade/templates/reviewer.md``
|
|
165
|
+
pointing at ``/etc/passwd``) from making the template
|
|
166
|
+
loader read arbitrary files.
|
|
167
|
+
FileNotFoundError: If neither the override nor the packaged
|
|
168
|
+
default exists. The packaged default should always be
|
|
169
|
+
present for templates syncade ships; a missing default is
|
|
170
|
+
a packaging bug, not a runtime condition.
|
|
171
|
+
"""
|
|
172
|
+
if (
|
|
173
|
+
not template_name
|
|
174
|
+
or "/" in template_name
|
|
175
|
+
or "\\" in template_name
|
|
176
|
+
or template_name in (".", "..")
|
|
177
|
+
or Path(template_name).is_absolute()
|
|
178
|
+
):
|
|
179
|
+
raise ValueError(
|
|
180
|
+
f"template_name {template_name!r} must be a plain basename "
|
|
181
|
+
"(no separators, parent refs, or absolute paths). The "
|
|
182
|
+
"loader resolves against `.syncade/templates/` or the "
|
|
183
|
+
"packaged default; an arbitrary path is unsafe."
|
|
184
|
+
)
|
|
185
|
+
override = repo_root / ".syncade" / "templates" / template_name
|
|
186
|
+
if override.is_file():
|
|
187
|
+
# Template override containment guard. Three layered
|
|
188
|
+
# checks against symlink-based escape:
|
|
189
|
+
#
|
|
190
|
+
# 1. Parent directories must be REAL dirs, not symlinks.
|
|
191
|
+
# Without this, ``<repo>/.syncade/templates`` could be a
|
|
192
|
+
# symlink to e.g. ``/etc/``, and the file-level
|
|
193
|
+
# ``relative_to`` check below would pass because both
|
|
194
|
+
# resolve to the same target.
|
|
195
|
+
#
|
|
196
|
+
# 2. The override path itself (if a symlink) must resolve
|
|
197
|
+
# to a target inside the REAL template dir. This is the
|
|
198
|
+
# file-level containment check.
|
|
199
|
+
#
|
|
200
|
+
# 3. The template-dir resolution uses ``strict=False`` so
|
|
201
|
+
# we don't follow symlinks on the parent components;
|
|
202
|
+
# they should NOT be symlinks per (1).
|
|
203
|
+
syncade_dir = repo_root / ".syncade"
|
|
204
|
+
template_dir = syncade_dir / "templates"
|
|
205
|
+
for parent in (syncade_dir, template_dir):
|
|
206
|
+
if parent.is_symlink():
|
|
207
|
+
raise ValueError(
|
|
208
|
+
f"template override path contains a symlinked parent "
|
|
209
|
+
f"directory ({parent} is a symlink). Symlinks on the "
|
|
210
|
+
"parent components of the template directory are "
|
|
211
|
+
"refused: a symlink at <repo>/.syncade or "
|
|
212
|
+
"<repo>/.syncade/templates can redirect template "
|
|
213
|
+
"loading to arbitrary filesystem locations. Make "
|
|
214
|
+
"the parent components real directories."
|
|
215
|
+
)
|
|
216
|
+
# File-level containment check: the override (which
|
|
217
|
+
# MAY itself be a file-level symlink) must resolve to a path
|
|
218
|
+
# inside the now-confirmed-real template directory.
|
|
219
|
+
resolved = override.resolve()
|
|
220
|
+
try:
|
|
221
|
+
# template_dir.resolve() is safe now — we verified above
|
|
222
|
+
# that neither it nor its parent .syncade is a symlink.
|
|
223
|
+
resolved.relative_to(template_dir.resolve())
|
|
224
|
+
except ValueError as exc:
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"template override at {override} resolves to "
|
|
227
|
+
f"{resolved}, which is outside the template directory "
|
|
228
|
+
f"{template_dir}. Symlinks that escape the template "
|
|
229
|
+
"directory are refused — the override path must be a "
|
|
230
|
+
"real file (or a symlink to a real file inside the "
|
|
231
|
+
"template directory)."
|
|
232
|
+
) from exc
|
|
233
|
+
return override.read_text(encoding="utf-8")
|
|
234
|
+
# `files("syncade")` resolves to the package root; `/ "templates" /
|
|
235
|
+
# <name>` is the bundled template. Works for both editable and
|
|
236
|
+
# installed-wheel layouts because importlib.resources abstracts
|
|
237
|
+
# over them.
|
|
238
|
+
return (files("syncade") / "templates" / template_name).read_text(encoding="utf-8")
|
syncade/retry.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Bounded retry on transient reviewer / synthesizer / producer API errors (H5).
|
|
2
|
+
|
|
3
|
+
A single transient provider blip — an HTTP 429, a 5xx, or a dropped socket —
|
|
4
|
+
in a reviewer, the synthesizer, or the producer subprocess otherwise aborts
|
|
5
|
+
the whole review loop at exit 40 with no second attempt, which makes
|
|
6
|
+
real-provider runs non-deterministic (the H5 finding: a transient anthropic
|
|
7
|
+
reviewer error in round 1 took down an entire run). This module is the ONE
|
|
8
|
+
place that decides (a) whether a failure is transient enough to retry and
|
|
9
|
+
(b) how long to wait between attempts, so the per-reviewer dispatch
|
|
10
|
+
(:mod:`syncade.dispatcher`), the cold-synth driver
|
|
11
|
+
(:mod:`syncade.synthesizer.driver`), and the producer (:mod:`syncade.producer`
|
|
12
|
+
— its retry is SIDE-EFFECT-safe, PR-v2-22) cannot drift apart on that definition.
|
|
13
|
+
|
|
14
|
+
Classification — deliberately minimal (H5 scope guard). The taxonomy is a
|
|
15
|
+
*whitelist*: when in doubt, do NOT retry.
|
|
16
|
+
|
|
17
|
+
- ONLY a :class:`~syncade.adapters.base.ReviewerInvocationError` is ever
|
|
18
|
+
retried, and only when it wraps a recognizable transient API/network
|
|
19
|
+
condition. That is the single exception type both the reviewer adapter's
|
|
20
|
+
``parse_output`` and the synthesizer adapter's
|
|
21
|
+
``extract_final_text`` raise to wrap a *subprocess-side*
|
|
22
|
+
provider failure (auth, model-unavailable, rate-limit, network).
|
|
23
|
+
- Transient ⇔ HTTP status 429 or 5xx, OR — when the adapter could not surface
|
|
24
|
+
a status — a stderr/message substring in :data:`_TRANSIENT_MARKERS`.
|
|
25
|
+
- EVERYTHING else is permanent and never retried: parse/contract failures
|
|
26
|
+
(:class:`~syncade.findings.ReviewerOutputError`,
|
|
27
|
+
:class:`~syncade.synthesis.SynthesizerOutputError`), a missing CLI binary
|
|
28
|
+
(:class:`~syncade.process.SubprocessNotFoundError`), a wall-clock timeout
|
|
29
|
+
(:class:`~syncade.process.SubprocessTimeoutError` — that is the budget, not
|
|
30
|
+
a blip; note its message contains "timed out", so the type gate below MUST
|
|
31
|
+
fire before the substring scan), bad config, and any programming bug.
|
|
32
|
+
Retrying a deterministic failure only burns budget on an outcome that
|
|
33
|
+
cannot change.
|
|
34
|
+
- A status-bearing error that is NOT 429/5xx (400/401/403/404) is permanent:
|
|
35
|
+
bad-request / auth / permission / missing-model do not get better on a
|
|
36
|
+
second identical attempt.
|
|
37
|
+
|
|
38
|
+
ASSUMPTION / open question: the no-status path leans on substring markers,
|
|
39
|
+
which is heuristic. It only applies when an adapter raised a
|
|
40
|
+
``ReviewerInvocationError`` without an ``api_error_status`` — today the real
|
|
41
|
+
adapters set the status when they have one, so the markers are a fallback for
|
|
42
|
+
network-layer failures (dropped sockets) that never reached an HTTP status.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import random
|
|
48
|
+
import time
|
|
49
|
+
|
|
50
|
+
from syncade.adapters.base import ReviewerInvocationError
|
|
51
|
+
|
|
52
|
+
# Extra attempts AFTER the first try, i.e. up to 3 subprocess attempts total.
|
|
53
|
+
# Bounded so a hard provider outage still fails the leg promptly rather than
|
|
54
|
+
# looping indefinitely.
|
|
55
|
+
MAX_RETRIES = 2
|
|
56
|
+
|
|
57
|
+
# Lowercased substrings checked ONLY when a ReviewerInvocationError carries no
|
|
58
|
+
# api_error_status. Kept small and high-signal: a marker that also matched a
|
|
59
|
+
# deterministic provider verdict would wrongly trigger retries.
|
|
60
|
+
_TRANSIENT_MARKERS = (
|
|
61
|
+
"429",
|
|
62
|
+
"rate limit",
|
|
63
|
+
"rate_limit",
|
|
64
|
+
"temporarily unavailable",
|
|
65
|
+
"temporary failure",
|
|
66
|
+
"connection reset",
|
|
67
|
+
"connection aborted",
|
|
68
|
+
"connection refused",
|
|
69
|
+
"socket",
|
|
70
|
+
"network",
|
|
71
|
+
"timed out",
|
|
72
|
+
"timeout",
|
|
73
|
+
"econnreset",
|
|
74
|
+
"etimedout",
|
|
75
|
+
# A provider dropping the response stream mid-flight. Empirically the most
|
|
76
|
+
# common real transient we hit, and it matched NONE of the markers above:
|
|
77
|
+
# codex reports it as `stream disconnected before completion: error sending
|
|
78
|
+
# request for url (...)` with no HTTP status, so the status gate cannot
|
|
79
|
+
# catch it either. Two dogfood runs died at exit 40 — losing every
|
|
80
|
+
# remaining round — because the retry that exists for exactly this case
|
|
81
|
+
# never fired.
|
|
82
|
+
"stream disconnected",
|
|
83
|
+
"error sending request",
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Provider QUOTA EXHAUSTION — deliberately NOT in _TRANSIENT_MARKERS (PR-h-field-02).
|
|
88
|
+
#
|
|
89
|
+
# The two need opposite handling and merging them would be worse than the bug. Retrying a
|
|
90
|
+
# usage limit is pointless — the window has not moved — so folding these into the transient
|
|
91
|
+
# list would fire MAX_RETRIES more doomed reviewer pairs against an exhausted quota before
|
|
92
|
+
# dying anyway. Classifying it permanent (today's behaviour, by omission) is also wrong: the
|
|
93
|
+
# run is resumable and the operator only has to wait.
|
|
94
|
+
#
|
|
95
|
+
# Read out of the shipped codex binary (codex-cli 0.145.0): its error enum carries
|
|
96
|
+
# `UsageLimitReached` and `QuotaExceeded`, and the user-facing text is
|
|
97
|
+
# "You've hit your usage limit for ".
|
|
98
|
+
#
|
|
99
|
+
# Markers are SPECIFIC on purpose. A bare "quota" or "usage limit" would match a reviewer
|
|
100
|
+
# discussing rate limiting in the code under review — which is exactly the string that turned
|
|
101
|
+
# up in the field runs' stdout and briefly looked like evidence. A false positive here tells an
|
|
102
|
+
# operator to wait out a limit they have not hit, so the cost of looseness is a wrong diagnosis.
|
|
103
|
+
_USAGE_LIMIT_MARKERS = (
|
|
104
|
+
"usagelimitreached",
|
|
105
|
+
"quotaexceeded",
|
|
106
|
+
# snake_case, and the MOST common form in the binary (35 occurrences, more than any other).
|
|
107
|
+
# Also the safest: prose says "usage limit" with a space, so the underscore cannot collide
|
|
108
|
+
# with a reviewer discussing rate limiting. The first version of this tuple measured that
|
|
109
|
+
# number, wrote it into the brief, and then left the marker out.
|
|
110
|
+
"usage_limit",
|
|
111
|
+
"usage limit reached",
|
|
112
|
+
"hit your usage limit",
|
|
113
|
+
"exceeded your quota",
|
|
114
|
+
"quota exceeded",
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def is_usage_limit_error(exc: BaseException) -> bool:
|
|
119
|
+
"""Return ``True`` iff ``exc`` is the provider refusing on exhausted quota.
|
|
120
|
+
|
|
121
|
+
Neither transient nor permanent: the correct response is to stop cleanly and let the
|
|
122
|
+
operator resume once the window resets, which is exit 25's existing contract.
|
|
123
|
+
"""
|
|
124
|
+
if not isinstance(exc, ReviewerInvocationError):
|
|
125
|
+
return False
|
|
126
|
+
text = f"{exc} {exc.stderr}".lower()
|
|
127
|
+
return any(marker in text for marker in _USAGE_LIMIT_MARKERS)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_transient_api_error(exc: BaseException) -> bool:
|
|
131
|
+
"""Return ``True`` iff ``exc`` is a transient provider/network failure
|
|
132
|
+
worth one more subprocess attempt. See the module docstring for the
|
|
133
|
+
(whitelist) taxonomy."""
|
|
134
|
+
if not isinstance(exc, ReviewerInvocationError):
|
|
135
|
+
return False
|
|
136
|
+
if is_usage_limit_error(exc):
|
|
137
|
+
# Quota is its own outcome; retrying it just burns the remaining attempts.
|
|
138
|
+
return False
|
|
139
|
+
status = exc.api_error_status
|
|
140
|
+
if status == 429 or (status is not None and 500 <= status <= 599):
|
|
141
|
+
return True
|
|
142
|
+
if status is not None:
|
|
143
|
+
# A concrete non-5xx/429 status is a terminal provider verdict.
|
|
144
|
+
return False
|
|
145
|
+
text = f"{exc} {exc.stderr}".lower()
|
|
146
|
+
return any(marker in text for marker in _TRANSIENT_MARKERS)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def backoff_sleep(retry_index: int) -> None:
|
|
150
|
+
"""Sleep a short, fully-jittered interval before retry ``retry_index``
|
|
151
|
+
(1-based).
|
|
152
|
+
|
|
153
|
+
Exponential base (0.5s, 1s, …) with full jitter so concurrent reviewers
|
|
154
|
+
that all hit a rate limit don't re-fire in lockstep. Small by design: the
|
|
155
|
+
goal is to ride out a brief blip, not to implement a production backoff
|
|
156
|
+
schedule. Isolated here so tests can monkeypatch it to a no-op.
|
|
157
|
+
"""
|
|
158
|
+
base = 0.5 * (2 ** (retry_index - 1))
|
|
159
|
+
time.sleep(random.uniform(0.0, base))
|
syncade/run_inputs.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Cheap, certain validation of a run's inputs — one predicate, several call sites.
|
|
2
|
+
|
|
3
|
+
A LEAF (imports only ``pathlib``), and deliberately so. The CLI must run this BEFORE
|
|
4
|
+
anything mutates the operator's directory (PR-h-04 item A), and making the CLI import
|
|
5
|
+
``orchestrator.loop`` just to check whether a file exists would drag the whole loop in for
|
|
6
|
+
a `Path.exists()`. It also keeps ``loop.py`` under the file-length cap.
|
|
7
|
+
|
|
8
|
+
The value is having ONE implementation: ``run_review`` calls it and so does the CLI, so a
|
|
9
|
+
pre-flight cannot refuse what the library would accept. A private copy in the CLI is
|
|
10
|
+
precisely the drift PR-h-02d.5 spent four rounds on — and reintroducing one here silently
|
|
11
|
+
dropped the "is it a file?" half, which is why a directory passed as the brief slipped
|
|
12
|
+
through in calibration.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate_run_inputs(repo_root: Path, pr_doc_path: Path) -> None:
|
|
21
|
+
"""Cheap, certain input checks. Raises ``NotADirectoryError`` / ``FileNotFoundError``.
|
|
22
|
+
|
|
23
|
+
Lifted out of :func:`run_review` so the CLI can run it BEFORE anything mutates the
|
|
24
|
+
operator's directory (PR-h-04 item A). Previously a mistyped brief path reached this
|
|
25
|
+
point only after ``ensure_repo_initialized`` had created a repo and a baseline commit,
|
|
26
|
+
and in an existing repo the default-branch guard refused FIRST — so a typo was reported
|
|
27
|
+
as a branch problem while the real mistake was a filename.
|
|
28
|
+
|
|
29
|
+
It lives here, in the module that owns the authoritative check, and the CLI calls this
|
|
30
|
+
same function: one predicate, two call sites, so the pre-flight cannot drift from what
|
|
31
|
+
``run_review`` accepts. That drift is the defect PR-h-02d.5 spent four rounds on.
|
|
32
|
+
"""
|
|
33
|
+
if not repo_root.exists():
|
|
34
|
+
raise NotADirectoryError(f"repo_root does not exist: {repo_root}")
|
|
35
|
+
if not repo_root.is_dir():
|
|
36
|
+
raise NotADirectoryError(f"repo_root is not a directory: {repo_root}")
|
|
37
|
+
if not pr_doc_path.exists():
|
|
38
|
+
raise FileNotFoundError(f"pr_doc_path does not exist: {pr_doc_path}")
|
|
39
|
+
if not pr_doc_path.is_file():
|
|
40
|
+
raise FileNotFoundError(f"pr_doc_path is not a file: {pr_doc_path}")
|
syncade/run_status.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Live run-status breadcrumb: never terminate without a discoverable reason.
|
|
2
|
+
|
|
3
|
+
One ``.syncade/runs/<id>/status.json`` per run, rewritten at each phase transition
|
|
4
|
+
(``state: running``) and finalized on every exit path (normal reason / OS signal /
|
|
5
|
+
exception). A ``running`` file whose pid is dead IS the evidence of a hard kill
|
|
6
|
+
(SIGKILL) at that phase — see :func:`is_stale_running`. syncade runs one review
|
|
7
|
+
per process, so the active status is a module global rather than threaded through
|
|
8
|
+
the orchestrator call chain.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import signal
|
|
15
|
+
import sys
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import UTC, datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from syncade.persistence._atomic import atomic_write_json
|
|
23
|
+
|
|
24
|
+
STATUS_FILENAME = "status.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RunStatus:
|
|
29
|
+
"""The live breadcrumb for one run. ``running()`` and ``finalize()`` each
|
|
30
|
+
rewrite ``status.json`` atomically with the current phase."""
|
|
31
|
+
|
|
32
|
+
path: Path
|
|
33
|
+
started_at: datetime
|
|
34
|
+
pid: int
|
|
35
|
+
phase: str = "starting"
|
|
36
|
+
round_index: int | None = None
|
|
37
|
+
|
|
38
|
+
def _write(self, state: str, **extra: object) -> None:
|
|
39
|
+
record = {
|
|
40
|
+
"state": state,
|
|
41
|
+
"phase": self.phase,
|
|
42
|
+
"round": self.round_index,
|
|
43
|
+
"pid": self.pid,
|
|
44
|
+
"started_at_utc": self.started_at.isoformat(),
|
|
45
|
+
"updated_at_utc": datetime.now(tz=UTC).isoformat(),
|
|
46
|
+
**extra,
|
|
47
|
+
}
|
|
48
|
+
atomic_write_json(self.path, record, sort_keys=False)
|
|
49
|
+
|
|
50
|
+
def running(self, phase: str, round_index: int | None = None) -> None:
|
|
51
|
+
self.phase = phase
|
|
52
|
+
self.round_index = round_index
|
|
53
|
+
self._write("running")
|
|
54
|
+
|
|
55
|
+
def finalize(self, reason: str, exit_code: int | None) -> None:
|
|
56
|
+
self._write("terminated", reason=reason, exit_code=exit_code)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_active: RunStatus | None = None
|
|
60
|
+
_began: bool = False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def begin(run_dir: Path, started_at: datetime) -> RunStatus:
|
|
64
|
+
"""Create + register the active breadcrumb and write the first ``running`` record."""
|
|
65
|
+
global _active, _began
|
|
66
|
+
_active = RunStatus(Path(run_dir) / STATUS_FILENAME, started_at, os.getpid())
|
|
67
|
+
_began = True
|
|
68
|
+
_active.running("starting")
|
|
69
|
+
return _active
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def update_phase(phase: str, round_index: int | None = None) -> None:
|
|
73
|
+
"""Advance the active breadcrumb's phase. No-op when no run is active."""
|
|
74
|
+
if _active is not None:
|
|
75
|
+
_active.running(phase, round_index)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def finalize_active(reason: str, exit_code: int | None) -> None:
|
|
79
|
+
"""Finalize + clear the active breadcrumb. No-op when no run is active."""
|
|
80
|
+
global _active
|
|
81
|
+
if _active is not None:
|
|
82
|
+
_active.finalize(reason, exit_code)
|
|
83
|
+
_active = None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def clear_active() -> None:
|
|
87
|
+
global _active, _began
|
|
88
|
+
_active = None
|
|
89
|
+
_began = False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def active() -> RunStatus | None:
|
|
93
|
+
return _active
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def began() -> bool:
|
|
97
|
+
"""Did :func:`begin` run in this process since the last :func:`clear_active`?
|
|
98
|
+
|
|
99
|
+
``active()`` cannot answer this: :func:`finalize_active` clears it, so by the time a
|
|
100
|
+
caller handles an exception ``run_review`` re-raised, ``active()`` reads ``None`` for a
|
|
101
|
+
run that had very much begun. A caller that used it to decide "was this a clean refusal?"
|
|
102
|
+
deleted the operator's repository and its artifacts after a real mid-run failure.
|
|
103
|
+
|
|
104
|
+
This flag is set at the one moment the run takes ownership — immediately after the run
|
|
105
|
+
directory is created — and only ``clear_active`` (in-process reuse) resets it.
|
|
106
|
+
"""
|
|
107
|
+
return _began
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _pid_alive(pid: int) -> bool:
|
|
111
|
+
try:
|
|
112
|
+
os.kill(pid, 0)
|
|
113
|
+
except ProcessLookupError:
|
|
114
|
+
return False
|
|
115
|
+
except PermissionError:
|
|
116
|
+
return True # exists but owned by another user
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def is_stale_running(status: dict) -> bool:
|
|
121
|
+
"""A ``running`` status whose pid is no longer alive = hard-killed at its phase."""
|
|
122
|
+
if status.get("state") != "running":
|
|
123
|
+
return False
|
|
124
|
+
pid = status.get("pid")
|
|
125
|
+
return isinstance(pid, int) and pid > 0 and not _pid_alive(pid)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
_received_signal: int | None = None
|
|
129
|
+
|
|
130
|
+
_SIGNALS = [signal.SIGTERM, signal.SIGINT]
|
|
131
|
+
if hasattr(signal, "SIGHUP"):
|
|
132
|
+
_SIGNALS.append(signal.SIGHUP)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _handler(signum: int, _frame: object) -> None:
|
|
136
|
+
global _received_signal
|
|
137
|
+
_received_signal = signum
|
|
138
|
+
raise KeyboardInterrupt
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@contextmanager
|
|
142
|
+
def install_signal_handlers() -> Iterator[None]:
|
|
143
|
+
"""Install parent-process SIGTERM/SIGINT/SIGHUP handlers that raise
|
|
144
|
+
KeyboardInterrupt — so process.py's existing child-group cleanup fires and the
|
|
145
|
+
interrupt bubbles to the run's finalizer. Restores prior handlers on exit.
|
|
146
|
+
|
|
147
|
+
Must be called from the main thread (Python signal constraint); the CLI review
|
|
148
|
+
dispatch is the main thread.
|
|
149
|
+
"""
|
|
150
|
+
global _received_signal
|
|
151
|
+
_received_signal = None # clear any stale signum from a prior in-process run
|
|
152
|
+
previous = {sig: signal.signal(sig, _handler) for sig in _SIGNALS}
|
|
153
|
+
try:
|
|
154
|
+
yield
|
|
155
|
+
finally:
|
|
156
|
+
for sig, prev in previous.items():
|
|
157
|
+
signal.signal(sig, prev)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def received_signal() -> int | None:
|
|
161
|
+
return _received_signal
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def signal_exit_code(signum: int | None) -> int:
|
|
165
|
+
"""Shell convention: a process killed by signal N exits ``128 + N``."""
|
|
166
|
+
return 128 + int(signum if signum is not None else signal.SIGINT)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def signal_name(signum: int | None) -> str:
|
|
170
|
+
if signum is None:
|
|
171
|
+
return "UNKNOWN"
|
|
172
|
+
try:
|
|
173
|
+
return signal.Signals(signum).name
|
|
174
|
+
except ValueError:
|
|
175
|
+
return f"SIG{signum}"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def finalize_signal() -> int:
|
|
179
|
+
"""Finalize the active breadcrumb with the received signal, log one stderr
|
|
180
|
+
line, and return the ``128+signum`` exit code. Used by the CLI's
|
|
181
|
+
KeyboardInterrupt handler."""
|
|
182
|
+
signum = _received_signal
|
|
183
|
+
name = signal_name(signum)
|
|
184
|
+
rs = active()
|
|
185
|
+
phase = rs.phase if rs is not None else "startup"
|
|
186
|
+
if rs is not None:
|
|
187
|
+
print(
|
|
188
|
+
f"[syncade] terminated by signal {name} during '{phase}' — recorded in status.json",
|
|
189
|
+
file=sys.stderr,
|
|
190
|
+
)
|
|
191
|
+
else:
|
|
192
|
+
print(
|
|
193
|
+
f"[syncade] terminated by signal {name} during '{phase}'"
|
|
194
|
+
" — no active run, status.json not written",
|
|
195
|
+
file=sys.stderr,
|
|
196
|
+
)
|
|
197
|
+
finalize_active(f"signal:{name}", signal_exit_code(signum))
|
|
198
|
+
return signal_exit_code(signum)
|