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.
Files changed (177) hide show
  1. syncade/__init__.py +3 -0
  2. syncade/__main__.py +6 -0
  3. syncade/adapters/__init__.py +0 -0
  4. syncade/adapters/anthropic.py +457 -0
  5. syncade/adapters/base.py +221 -0
  6. syncade/adapters/fake.py +73 -0
  7. syncade/adapters/fake_common.py +29 -0
  8. syncade/adapters/fake_producer_audit_draft.py +460 -0
  9. syncade/adapters/fake_reviewer_synth.py +310 -0
  10. syncade/adapters/openai.py +484 -0
  11. syncade/adapters/openai_parsing.py +119 -0
  12. syncade/adapters/producer.py +221 -0
  13. syncade/adapters/producer_anthropic.py +300 -0
  14. syncade/adapters/producer_openai.py +226 -0
  15. syncade/adapters/registry.py +81 -0
  16. syncade/auth_check.py +554 -0
  17. syncade/auth_preflight.py +342 -0
  18. syncade/base_resolution.py +214 -0
  19. syncade/billing.py +141 -0
  20. syncade/checks_config.py +113 -0
  21. syncade/cli/__init__.py +546 -0
  22. syncade/cli/auth_gate.py +59 -0
  23. syncade/cli/config_keys.py +135 -0
  24. syncade/cli/config_list.py +82 -0
  25. syncade/cli/config_menu_rows.py +166 -0
  26. syncade/cli/config_mode.py +609 -0
  27. syncade/cli/config_overrides.py +122 -0
  28. syncade/cli/config_tui.py +476 -0
  29. syncade/cli/doctor_mode.py +72 -0
  30. syncade/cli/gc_mode.py +109 -0
  31. syncade/cli/install_skill.py +514 -0
  32. syncade/cli/metrics_mode.py +363 -0
  33. syncade/cli/modes.py +573 -0
  34. syncade/cli/parser.py +450 -0
  35. syncade/cli/parser_types.py +137 -0
  36. syncade/cli/paths.py +38 -0
  37. syncade/cli/preflight_paths.py +90 -0
  38. syncade/cli/resolve.py +116 -0
  39. syncade/cli/resume_mode.py +324 -0
  40. syncade/cli/toml_writer.py +410 -0
  41. syncade/cli/validate.py +421 -0
  42. syncade/config.py +478 -0
  43. syncade/config_auth.py +310 -0
  44. syncade/config_cold.py +209 -0
  45. syncade/config_gc.py +55 -0
  46. syncade/config_loader.py +182 -0
  47. syncade/config_loop.py +282 -0
  48. syncade/config_producer.py +222 -0
  49. syncade/config_retry.py +49 -0
  50. syncade/config_types.py +59 -0
  51. syncade/diff_filter.py +437 -0
  52. syncade/dispatcher.py +571 -0
  53. syncade/doctor.py +425 -0
  54. syncade/doctor_env.py +218 -0
  55. syncade/doctor_preview.py +524 -0
  56. syncade/doctor_types.py +28 -0
  57. syncade/exit_codes.py +82 -0
  58. syncade/findings.py +242 -0
  59. syncade/findings_json.py +456 -0
  60. syncade/gc.py +211 -0
  61. syncade/gc_execute.py +372 -0
  62. syncade/gc_protection.py +129 -0
  63. syncade/gc_types.py +50 -0
  64. syncade/gc_worktrees.py +200 -0
  65. syncade/git_object_id.py +12 -0
  66. syncade/git_preconditions.py +389 -0
  67. syncade/logging.py +289 -0
  68. syncade/metrics/__init__.py +32 -0
  69. syncade/metrics/aggregate.py +550 -0
  70. syncade/metrics/schema.py +221 -0
  71. syncade/orchestrator/__init__.py +61 -0
  72. syncade/orchestrator/_runs_dir.py +24 -0
  73. syncade/orchestrator/branch_advance.py +165 -0
  74. syncade/orchestrator/branch_guard.py +98 -0
  75. syncade/orchestrator/budget.py +107 -0
  76. syncade/orchestrator/escalation_coverage.py +81 -0
  77. syncade/orchestrator/loop.py +611 -0
  78. syncade/orchestrator/loop_dispatch_check.py +112 -0
  79. syncade/orchestrator/loop_finalize.py +404 -0
  80. syncade/orchestrator/loop_preflight.py +131 -0
  81. syncade/orchestrator/loop_resume.py +91 -0
  82. syncade/orchestrator/loop_rmtree.py +70 -0
  83. syncade/orchestrator/loop_round_step.py +599 -0
  84. syncade/orchestrator/prior_round.py +336 -0
  85. syncade/orchestrator/producer_phase.py +169 -0
  86. syncade/orchestrator/results.py +306 -0
  87. syncade/orchestrator/resume.py +96 -0
  88. syncade/orchestrator/resume_load.py +483 -0
  89. syncade/orchestrator/resume_plan.py +554 -0
  90. syncade/orchestrator/resume_target.py +215 -0
  91. syncade/orchestrator/resume_types.py +182 -0
  92. syncade/orchestrator/reviewer_template_failure.py +99 -0
  93. syncade/orchestrator/round.py +573 -0
  94. syncade/orchestrator/round_checks.py +91 -0
  95. syncade/orchestrator/round_no_changes.py +369 -0
  96. syncade/orchestrator/round_predispatch.py +212 -0
  97. syncade/orchestrator/verdict.py +279 -0
  98. syncade/persistence/__init__.py +189 -0
  99. syncade/persistence/_atomic.py +33 -0
  100. syncade/persistence/_clusters.py +70 -0
  101. syncade/persistence/_findings_verdict.py +201 -0
  102. syncade/persistence/_markdown.py +286 -0
  103. syncade/persistence/_validation.py +37 -0
  104. syncade/persistence/checks.py +249 -0
  105. syncade/persistence/decision_needed.py +289 -0
  106. syncade/persistence/findings_md.py +389 -0
  107. syncade/persistence/handoff.py +389 -0
  108. syncade/persistence/handoff_classify.py +196 -0
  109. syncade/persistence/last_reviewed.py +67 -0
  110. syncade/persistence/loop_manifest.py +165 -0
  111. syncade/persistence/loop_summary.py +352 -0
  112. syncade/persistence/loop_summary_text.py +428 -0
  113. syncade/persistence/producer.py +250 -0
  114. syncade/persistence/reviewer.py +198 -0
  115. syncade/persistence/round_manifest.py +238 -0
  116. syncade/persistence/run_init.py +153 -0
  117. syncade/persistence/run_summary.py +585 -0
  118. syncade/persistence/run_summary_next_steps.py +443 -0
  119. syncade/persistence/synth.py +242 -0
  120. syncade/persistence/test_run.py +152 -0
  121. syncade/presets.py +36 -0
  122. syncade/pricing_config.py +72 -0
  123. syncade/process.py +600 -0
  124. syncade/producer.py +189 -0
  125. syncade/producer_attempt.py +463 -0
  126. syncade/producer_escalation.py +146 -0
  127. syncade/producer_git.py +199 -0
  128. syncade/producer_result.py +205 -0
  129. syncade/prompts.py +448 -0
  130. syncade/prompts_loader.py +238 -0
  131. syncade/retry.py +159 -0
  132. syncade/run_inputs.py +40 -0
  133. syncade/run_status.py +198 -0
  134. syncade/selfcheck.py +471 -0
  135. syncade/skills/claude/README.md +221 -0
  136. syncade/skills/claude/SKILL.md +625 -0
  137. syncade/skills/codex/README.md +116 -0
  138. syncade/skills/codex/SKILL.md +574 -0
  139. syncade/snapshot.py +598 -0
  140. syncade/spec_audit.py +437 -0
  141. syncade/spec_audit_schema.py +190 -0
  142. syncade/spec_draft.py +423 -0
  143. syncade/spec_source.py +135 -0
  144. syncade/synthesis.py +428 -0
  145. syncade/synthesis_clusters.py +203 -0
  146. syncade/synthesis_repair.py +230 -0
  147. syncade/synthesis_schema.py +65 -0
  148. syncade/synthesizer/__init__.py +38 -0
  149. syncade/synthesizer/constants.py +33 -0
  150. syncade/synthesizer/driver.py +531 -0
  151. syncade/synthesizer/rendering.py +63 -0
  152. syncade/synthesizer/result.py +73 -0
  153. syncade/synthesizer/validation.py +421 -0
  154. syncade/synthesizer/workspace.py +208 -0
  155. syncade/templates/presets/balanced.toml +13 -0
  156. syncade/templates/presets/cheap.toml +12 -0
  157. syncade/templates/presets/thorough.toml +9 -0
  158. syncade/templates/producer.md +231 -0
  159. syncade/templates/reviewer.md +279 -0
  160. syncade/templates/reviewer_adversarial.md +164 -0
  161. syncade/templates/reviewer_codex.md +165 -0
  162. syncade/templates/spec_audit.md +168 -0
  163. syncade/templates/spec_draft.md +62 -0
  164. syncade/templates/synthesizer.md +204 -0
  165. syncade/test_runner.py +476 -0
  166. syncade/test_runner_classify.py +98 -0
  167. syncade/transcript.py +150 -0
  168. syncade/usage.py +407 -0
  169. syncade/worktree.py +497 -0
  170. syncade/worktree_env.py +133 -0
  171. syncade/worktree_paths.py +139 -0
  172. syncade-0.6.2.dist-info/METADATA +314 -0
  173. syncade-0.6.2.dist-info/RECORD +177 -0
  174. syncade-0.6.2.dist-info/WHEEL +5 -0
  175. syncade-0.6.2.dist-info/entry_points.txt +2 -0
  176. syncade-0.6.2.dist-info/licenses/LICENSE +202 -0
  177. 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)