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,221 @@
1
+ """Metrics DB schema + sink (stdlib ``sqlite3`` only).
2
+
3
+ Three tables — one row per run (``runs``), one per run/reviewer/model
4
+ (``reviewer_stats``), and one per usage-emitting actor/model (``actor_stats``).
5
+ Writes are idempotent upserts keyed on the primary key,
6
+ so re-aggregating the corpus rebuilds the same rows rather than duplicating
7
+ them. ``tokens`` / ``cost_usd`` (on both tables) are populated by PR-4 from each
8
+ run's manifests; legacy runs with no usage data stay NULL.
9
+
10
+ The row shapes are stdlib dataclasses (not pydantic): the data comes from
11
+ syncade's own artifacts, not an untrusted boundary, so validation buys nothing
12
+ here. Columns are generated from the dataclass fields, so the DDL below and the
13
+ dataclasses must stay aligned — the schema tests fail loudly if they drift.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import dataclasses
19
+ import sqlite3
20
+ from pathlib import Path
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class RunRow:
25
+ """One aggregated row per syncade run. ``run_id`` is the primary key."""
26
+
27
+ run_id: str
28
+ verdict: str = ""
29
+ rounds_executed: int = 0
30
+ blockers: int = 0
31
+ minors: int = 0
32
+ nits: int = 0
33
+ dismissed: int = 0
34
+ final_exit_code: int | None = None
35
+ termination_reason: str | None = None
36
+ operator_branch: str | None = None
37
+ handoff: int = 0
38
+ decision_needed: int = 0
39
+ producer_commits: int = 0
40
+ producer_stalled: int = 0
41
+ producer_errors: int = 0
42
+ producer_escalated: int = 0
43
+ tokens: int | None = None # reserved for PR-4
44
+ cost_usd: float | None = None # reserved for PR-4
45
+
46
+
47
+ @dataclasses.dataclass
48
+ class ReviewerStatRow:
49
+ """One row per (run, reviewer). PK is (``run_id``, ``name``)."""
50
+
51
+ run_id: str
52
+ name: str
53
+ provider: str = ""
54
+ model: str = ""
55
+ finding_count: int = 0
56
+ duration_s: float = 0.0
57
+ tokens: int | None = None
58
+ cost_usd: float | None = None
59
+ cost_source: str = "" # "provider" | "estimated" | "unknown" | "" (no usage)
60
+
61
+
62
+ @dataclasses.dataclass
63
+ class ActorStatRow:
64
+ """One row per usage-emitting actor. PK is
65
+ (``run_id``, ``role``, ``name``, ``provider``, ``model``, ``auth_mode``) — auth_mode is
66
+ part of the key so one actor that ran under two modes keeps a row per mode (PR-v2-24)."""
67
+
68
+ run_id: str
69
+ role: str
70
+ name: str
71
+ provider: str = ""
72
+ model: str = ""
73
+ tokens: int | None = None
74
+ cost_usd: float | None = None
75
+ cost_incomplete_tokens: int = 0
76
+ cost_source: str = "" # "provider" | "estimated" | "unknown" | "" (no usage)
77
+ auth_mode: str = "" # "subscription" | "api" | "none" | "unknown" | "" (legacy run)
78
+ rounds_with_usage: int = 0 # rounds with any usage reported; 0 = legacy/unknown
79
+
80
+
81
+ _RUNS_DDL = """
82
+ CREATE TABLE IF NOT EXISTS runs (
83
+ run_id TEXT PRIMARY KEY,
84
+ verdict TEXT,
85
+ rounds_executed INTEGER,
86
+ blockers INTEGER,
87
+ minors INTEGER,
88
+ nits INTEGER,
89
+ dismissed INTEGER,
90
+ final_exit_code INTEGER,
91
+ termination_reason TEXT,
92
+ operator_branch TEXT,
93
+ handoff INTEGER,
94
+ decision_needed INTEGER,
95
+ producer_commits INTEGER,
96
+ producer_stalled INTEGER,
97
+ producer_errors INTEGER,
98
+ producer_escalated INTEGER,
99
+ tokens INTEGER,
100
+ cost_usd REAL
101
+ )
102
+ """
103
+
104
+ _REVIEWER_STATS_DDL = """
105
+ CREATE TABLE IF NOT EXISTS reviewer_stats (
106
+ run_id TEXT NOT NULL,
107
+ name TEXT NOT NULL,
108
+ provider TEXT,
109
+ model TEXT,
110
+ finding_count INTEGER,
111
+ duration_s REAL,
112
+ tokens INTEGER,
113
+ cost_usd REAL,
114
+ cost_source TEXT,
115
+ PRIMARY KEY (run_id, name, provider, model)
116
+ )
117
+ """
118
+
119
+ _ACTOR_STATS_DDL = """
120
+ CREATE TABLE IF NOT EXISTS actor_stats (
121
+ run_id TEXT NOT NULL,
122
+ role TEXT NOT NULL,
123
+ name TEXT NOT NULL,
124
+ provider TEXT,
125
+ model TEXT,
126
+ tokens INTEGER,
127
+ cost_usd REAL,
128
+ cost_incomplete_tokens INTEGER,
129
+ cost_source TEXT,
130
+ -- PR-v2-24. cost_usd is an API-EQUIVALENT VALUATION, not spend: it is fiction
131
+ -- whenever the call rode a subscription. Only the resolved auth mode knows which,
132
+ -- and it is orthogonal to cost_source -- claude reports total_cost_usd even on an
133
+ -- OAuth session. Legacy rows stay NULL, and NULL reports as neither billed nor
134
+ -- free, because cost is never fabricated.
135
+ -- auth_mode is part of the KEY, not a merged attribute. Keyed without it, one actor
136
+ -- that ran under two modes (a run resumed with a changed env) collapsed to "mixed"
137
+ -- and its REAL API SPEND became unclassified before billing ever saw it. Fixing the
138
+ -- reader was not enough while the writer still destroyed the split -- the key is what
139
+ -- makes the loss impossible rather than merely unlikely.
140
+ auth_mode TEXT NOT NULL DEFAULT '',
141
+ -- Rounds where this actor reported any usage (tokens or cost non-null). Used by
142
+ -- the cost preview to detect incomplete multi-round coverage (0 = legacy/unknown).
143
+ rounds_with_usage INTEGER NOT NULL DEFAULT 0,
144
+ PRIMARY KEY (run_id, role, name, provider, model, auth_mode)
145
+ )
146
+ """
147
+
148
+
149
+ # Bump when the table shapes change. The DB is a derived, rebuildable view, so an
150
+ # OLDER on-disk schema is dropped + recreated (backfill repopulates from the
151
+ # corpus) rather than ALTER-migrated. A NEWER on-disk schema is left untouched —
152
+ # it may hold data this binary can't rebuild (e.g. PR-4's live token/cost).
153
+ _SCHEMA_VERSION = 12
154
+
155
+
156
+ def open_db(path: Path | str) -> sqlite3.Connection:
157
+ """Open the metrics DB, creating/rebuilding tables to the current schema.
158
+
159
+ Creates parent dirs + tables if absent; if the on-disk schema version is
160
+ stale (an older syncade wrote it), the derived tables are dropped and
161
+ recreated so a later ``upsert`` never hits a missing column.
162
+ """
163
+ path = Path(path)
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+ conn = sqlite3.connect(str(path))
166
+ conn.row_factory = sqlite3.Row
167
+ try:
168
+ if conn.execute("PRAGMA user_version").fetchone()[0] < _SCHEMA_VERSION:
169
+ # Older schema only: rebuild. A newer on-disk version is left as-is so an
170
+ # older binary never destroys data it cannot rebuild (see _SCHEMA_VERSION).
171
+ conn.executescript(
172
+ "DROP TABLE IF EXISTS runs; "
173
+ "DROP TABLE IF EXISTS reviewer_stats; "
174
+ "DROP TABLE IF EXISTS actor_stats;"
175
+ )
176
+ conn.execute(f"PRAGMA user_version = {_SCHEMA_VERSION}") # int constant, not user input
177
+ conn.execute(_RUNS_DDL)
178
+ conn.execute(_REVIEWER_STATS_DDL)
179
+ conn.execute(_ACTOR_STATS_DDL)
180
+ conn.commit()
181
+ except Exception:
182
+ conn.close()
183
+ raise
184
+ return conn
185
+
186
+
187
+ def _upsert(conn: sqlite3.Connection, table: str, row: object) -> None:
188
+ # ``table`` is a hardcoded literal and the column names come from our own
189
+ # dataclass fields (never user input); values are bound parameters. No
190
+ # injection surface.
191
+ data = dataclasses.asdict(row)
192
+ cols = ", ".join(data)
193
+ placeholders = ", ".join(f":{k}" for k in data)
194
+ conn.execute(f"INSERT OR REPLACE INTO {table} ({cols}) VALUES ({placeholders})", data)
195
+ conn.commit()
196
+
197
+
198
+ def upsert_run(conn: sqlite3.Connection, row: RunRow) -> None:
199
+ _upsert(conn, "runs", row)
200
+
201
+
202
+ def upsert_reviewer_stat(conn: sqlite3.Connection, row: ReviewerStatRow) -> None:
203
+ _upsert(conn, "reviewer_stats", row)
204
+
205
+
206
+ def upsert_actor_stat(conn: sqlite3.Connection, row: ActorStatRow) -> None:
207
+ _upsert(conn, "actor_stats", row)
208
+
209
+
210
+ def fetch_runs(conn: sqlite3.Connection) -> list[RunRow]:
211
+ """Return all run rows as :class:`RunRow`, ordered by ``run_id``."""
212
+ return [RunRow(**dict(r)) for r in conn.execute("SELECT * FROM runs ORDER BY run_id")]
213
+
214
+
215
+ def fetch_actor_stats(conn: sqlite3.Connection) -> list[ActorStatRow]:
216
+ """Return all per-actor rows as :class:`ActorStatRow`, ordered by ``run_id``. Lets a
217
+ consumer separate the per-role cost of a run (e.g. reviewers + judge, which run every
218
+ round, from the producer, which runs only on NO-SHIP rounds)."""
219
+ return [
220
+ ActorStatRow(**dict(r)) for r in conn.execute("SELECT * FROM actor_stats ORDER BY run_id")
221
+ ]
@@ -0,0 +1,61 @@
1
+ from __future__ import annotations
2
+
3
+ from ._runs_dir import _ensure_runs_gitignore as _ensure_runs_gitignore
4
+ from .branch_advance import _advance_branch_ref as _advance_branch_ref
5
+ from .escalation_coverage import (
6
+ escalation_covers_active_blockers as escalation_covers_active_blockers,
7
+ )
8
+ from .loop import run_review
9
+ from .loop_finalize import _finalize_run as _finalize_run
10
+ from .loop_resume import _rehydrate_resume_state as _rehydrate_resume_state
11
+ from .loop_round_step import _RoundStep as _RoundStep
12
+ from .loop_round_step import _run_round_step as _run_round_step
13
+ from .prior_round import (
14
+ load_prior_reviewer_response_text as load_prior_reviewer_response_text,
15
+ )
16
+ from .producer_phase import _run_producer_phase as _run_producer_phase
17
+ from .results import (
18
+ RoundArtifacts,
19
+ RoundResult,
20
+ RunArtifacts,
21
+ RunResult,
22
+ )
23
+ from .results import (
24
+ TerminationReason as TerminationReason,
25
+ )
26
+ from .results import (
27
+ TestSkipReason as TestSkipReason,
28
+ )
29
+ from .resume import (
30
+ ResumePlan as ResumePlan,
31
+ )
32
+ from .resume import (
33
+ check_tree_drift as check_tree_drift,
34
+ )
35
+ from .resume import (
36
+ load_completed_round as load_completed_round,
37
+ )
38
+ from .round import (
39
+ _NO_DIFF_SENTINEL as _NO_DIFF_SENTINEL,
40
+ )
41
+ from .round import (
42
+ _TEST_WORKTREE_NAME as _TEST_WORKTREE_NAME,
43
+ )
44
+ from .round import (
45
+ _run_one_round as _run_one_round,
46
+ )
47
+ from .round_checks import _run_checks_leg as _run_checks_leg
48
+ from .verdict import (
49
+ _classify_phase_failure as _classify_phase_failure,
50
+ )
51
+ from .verdict import (
52
+ _compute_exit_code as _compute_exit_code,
53
+ )
54
+
55
+ __all__ = [
56
+ "RoundArtifacts",
57
+ "RoundResult",
58
+ "RunArtifacts",
59
+ "RunResult",
60
+ "run_review",
61
+ ]
@@ -0,0 +1,24 @@
1
+ """Runs-directory provisioning helper.
2
+
3
+ Writes the one-line ``.gitignore`` that drops a ``*`` rule alongside
4
+ ``.syncade/runs/`` on first run. Pure file I/O.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ _RUNS_GITIGNORE_CONTENT: str = "*\n"
12
+
13
+
14
+ def _ensure_runs_gitignore(runs_root: Path) -> None:
15
+ """Auto-write ``<repo>/.syncade/runs/.gitignore`` on first run.
16
+
17
+ Every run that creates a new ``.syncade/runs/`` directory drops a ``*``
18
+ .gitignore alongside so an accidental ``git add -A`` doesn't sweep run
19
+ artifacts into source control. Idempotent.
20
+ """
21
+ gitignore_path = runs_root / ".gitignore"
22
+ if gitignore_path.exists():
23
+ return
24
+ gitignore_path.write_text(_RUNS_GITIGNORE_CONTENT, encoding="utf-8")
@@ -0,0 +1,165 @@
1
+ """Branch-advance helper + status Literal.
2
+
3
+ Holds the fast-forward ``git update-ref`` discipline that promotes the
4
+ producer's detached-HEAD commit to the operator's named branch, plus the
5
+ categorical ``BranchAdvanceStatus`` type the loop terminator switches on.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from syncade.logging import Logger
14
+ from syncade.process import SubprocessError, SubprocessResult, run_subprocess
15
+ from syncade.producer import ProducerResult
16
+ from syncade.snapshot import Snapshot
17
+
18
+ BranchAdvanceStatus = Literal[
19
+ "advanced",
20
+ "skipped_detached_head",
21
+ "non_descendant",
22
+ "update_ref_failed",
23
+ ]
24
+ """Outcome of :func:`_advance_branch_ref`.
25
+
26
+ - ``advanced`` — branch ref was successfully fast-forwarded to the
27
+ producer's ending SHA. The loop can continue safely; the next
28
+ round's snapshot will pick up the new HEAD.
29
+ - ``skipped_detached_head`` — the operator was on detached HEAD at
30
+ invocation; no named branch to advance. The producer's commits
31
+ are in ``.git`` but unreachable from any branch. The orchestrator
32
+ MUST terminate the loop: the next round's snapshot would read the
33
+ same starting SHA again, so reviewers could SHIP stale code.
34
+ - ``non_descendant`` — the producer's ending SHA is NOT a
35
+ descendant of the starting SHA. The orchestrator MUST terminate the loop;
36
+ continuing would dispatch the next round against the original
37
+ starting SHA (since update-ref didn't fire), causing the
38
+ reviewers to see stale code and potentially declare SHIP.
39
+ - ``update_ref_failed`` — ``git update-ref`` returned non-zero
40
+ (ref moved out from under us, permissions, etc.). The
41
+ orchestrator MUST terminate. Same stale-SHIP risk as
42
+ ``non_descendant``.
43
+ """
44
+
45
+ _GIT_TIMEOUT_SECONDS = 30.0
46
+
47
+
48
+ def _run_git(argv: list[str], *, cwd: Path, logger: Logger) -> SubprocessResult | None:
49
+ try:
50
+ return run_subprocess(argv, cwd=cwd, timeout=_GIT_TIMEOUT_SECONDS)
51
+ except SubprocessError as exc:
52
+ logger.safety(
53
+ f"orchestrator: git command {argv[:2]!r} failed before completion: {exc}. "
54
+ "The branch ref was not advanced. The loop will terminate as "
55
+ "producer_stalled to prevent the next round from dispatching reviewers "
56
+ "against stale code at the unadvanced ref."
57
+ )
58
+ return None
59
+
60
+
61
+ def _advance_branch_ref(
62
+ *,
63
+ repo_root: Path,
64
+ snapshot: Snapshot,
65
+ producer_result: ProducerResult,
66
+ logger: Logger,
67
+ ) -> BranchAdvanceStatus:
68
+ """Fast-forward the operator's branch to the producer's ending SHA.
69
+
70
+ Runs ``git update-ref refs/heads/<branch> <ending_sha>
71
+ <starting_sha>`` from the repo root. Refuses to advance when
72
+ the ending SHA isn't a descendant of the starting SHA (the
73
+ producer did something unusual — ``git reset --hard
74
+ <unrelated>``, force-update, orphan branch checkout).
75
+
76
+ Returns a :data:`BranchAdvanceStatus` so the caller can fold
77
+ advance failure into the loop terminator. Without that status, the loop
78
+ could continue past advance failure and create a stale-SHIP risk (the next
79
+ round's snapshot would read the original branch SHA and dispatch reviewers
80
+ against stale
81
+ code).
82
+
83
+ For ``snapshot.branch is None`` (detached HEAD at invocation),
84
+ the orchestrator can't advance any named branch — emits a
85
+ warning and returns ``skipped_detached_head``. The caller treats
86
+ this as a terminal non-SHIP state so the next reviewers never
87
+ inspect stale input. The producer's commits are still in ``.git``
88
+ (worktrees share ``.git``), so the operator can manually advance
89
+ their branch via ``git log <producer-sha>`` + ``git update-ref``
90
+ if they want to keep the commits.
91
+ """
92
+ if snapshot.branch is None:
93
+ logger.safety(
94
+ f"orchestrator: producer committed {producer_result.ending_sha[:12]} "
95
+ f"on a detached HEAD; the operator was on detached HEAD at "
96
+ f"invocation, so no named branch ref to advance. The "
97
+ f"producer's commits are in .git — inspect with `git log "
98
+ f"{producer_result.ending_sha[:12]}` and manually advance "
99
+ f"the branch you want them on."
100
+ )
101
+ return "skipped_detached_head"
102
+
103
+ # Verify the ending SHA is a descendant of the starting SHA. If
104
+ # not, the producer did something unusual (reset, force-update,
105
+ # etc.) and we shouldn't blindly fast-forward — AND we shouldn't
106
+ # continue the loop, because the next round would see stale
107
+ # code at the original starting SHA.
108
+ # `--no-replace-objects` prevents a refs/replace/* ref on the producer's
109
+ # ending SHA from substituting a fake commit whose parent is starting_sha,
110
+ # which would make --is-ancestor return 0 for an actual non-descendant.
111
+ is_ancestor = _run_git(
112
+ [
113
+ "git",
114
+ "--no-replace-objects",
115
+ "merge-base",
116
+ "--is-ancestor",
117
+ producer_result.starting_sha,
118
+ producer_result.ending_sha,
119
+ ],
120
+ cwd=repo_root,
121
+ logger=logger,
122
+ )
123
+ if is_ancestor is None:
124
+ return "update_ref_failed"
125
+ if is_ancestor.returncode != 0:
126
+ logger.safety(
127
+ f"orchestrator: producer's ending SHA "
128
+ f"({producer_result.ending_sha[:12]}) is NOT a descendant "
129
+ f"of the round-start SHA ({producer_result.starting_sha[:12]}); "
130
+ f"refusing to fast-forward {snapshot.branch}. The "
131
+ f"producer may have done something unusual "
132
+ f"(git reset, force-update). The commits exist in .git "
133
+ f"under the producer's SHA — manual intervention required. "
134
+ f"The loop will terminate as producer_stalled (per loop policy "
135
+ f"brief: 'treats the round as a stall') to prevent the "
136
+ f"next round from dispatching reviewers against stale "
137
+ f"code at the unadvanced ref."
138
+ )
139
+ return "non_descendant"
140
+
141
+ update = _run_git(
142
+ [
143
+ "git",
144
+ "update-ref",
145
+ f"refs/heads/{snapshot.branch}",
146
+ producer_result.ending_sha,
147
+ producer_result.starting_sha, # OLDVALUE — fail if ref moved out from under us
148
+ ],
149
+ cwd=repo_root,
150
+ logger=logger,
151
+ )
152
+ if update is None:
153
+ return "update_ref_failed"
154
+ if update.returncode != 0:
155
+ logger.safety(
156
+ f"orchestrator: git update-ref refs/heads/{snapshot.branch} "
157
+ f"failed: {update.stderr.strip()[:200]!r}. The producer's "
158
+ f"commits are in .git but the branch ref wasn't advanced. "
159
+ f"The loop will terminate as producer_stalled to prevent "
160
+ f"the next round from dispatching reviewers against stale "
161
+ f"code at the unadvanced ref."
162
+ )
163
+ return "update_ref_failed"
164
+
165
+ return "advanced"
@@ -0,0 +1,98 @@
1
+ """Refuse to run the committing loop on the repo's default branch (PR-v2-26).
2
+
3
+ The producer fast-forwards the CURRENT branch. With no guard, a stranger's first
4
+ `syncade <brief>` run while sitting on `main` silently lands producer commits on
5
+ their default branch — reproduced: 7 commits on `main`, warned only after they had
6
+ landed. This guard refuses that up front, before any subprocess is dispatched.
7
+
8
+ Enforced at the run-entry choke (:func:`syncade.orchestrator.run_review`), not the
9
+ CLI wrapper, so a direct library call and a `--resume` are covered too — the same
10
+ "guard where the work happens, not where the CLI happens" lesson as the PR-v2-24
11
+ auth gate.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ from syncade.base_resolution import local_default_branch, remote_default_branch
20
+ from syncade.worktree import WorktreeError
21
+
22
+ # Branch names that conventionally ARE a repo's integration branch. Consulted only in the
23
+ # remote-less path: a repo that HAS a local main/master but is checked out on one of these
24
+ # (e.g. `trunk` beside a vestigial `main`) is refused, because HEAD may be the real default
25
+ # even though it is not the local main/master.
26
+ _COMMON_DEFAULT_NAMES = frozenset({"main", "master", "trunk", "develop", "development", "default"})
27
+
28
+
29
+ def current_branch_name(repo_root: Path) -> str | None:
30
+ """The checked-out branch name, or ``None`` for detached HEAD.
31
+
32
+ Lets the CLI run :func:`guard_default_branch` before dispatching; ``run_review``
33
+ re-checks for library callers. Under D1(c) (PR-h-02d.5), baseless loop runs refuse
34
+ here before auth; based/scoped runs defer to ``run_review`` and auth runs first.
35
+ """
36
+ result = subprocess.run(
37
+ ["git", "-C", str(repo_root), "symbolic-ref", "--quiet", "--short", "HEAD"],
38
+ capture_output=True,
39
+ text=True,
40
+ )
41
+ return result.stdout.strip() or None
42
+
43
+
44
+ def guard_default_branch(
45
+ repo_root: Path,
46
+ current_branch: str | None,
47
+ *,
48
+ allow: bool,
49
+ will_commit: bool,
50
+ ) -> None:
51
+ """Raise :class:`WorktreeError` when a *committing* run could land on the default branch.
52
+
53
+ Exempt (no raise): ``will_commit`` is False (single-pass commits nothing), ``allow`` is
54
+ True (``--allow-default-branch`` — or a repo syncade itself just auto-created), or
55
+ ``current_branch`` is None (detached HEAD).
56
+
57
+ Resolution, most authoritative first:
58
+
59
+ 1. ``origin/HEAD`` present → refuse iff HEAD *is* that branch, whatever it is named;
60
+ otherwise allow. A remote proves the default exactly.
61
+ 2. No remote, but a local ``main`` / ``master`` exists → treat it as the default: refuse
62
+ iff HEAD is it, OR HEAD is another common integration name
63
+ (:data:`_COMMON_DEFAULT_NAMES`, e.g. ``trunk`` beside a vestigial ``main``). A plain
64
+ feature branch alongside ``main`` proceeds.
65
+ 3. No remote AND no local ``main`` / ``master`` → HEAD is effectively this repo's only
66
+ integration branch (a repo living on ``release`` / ``trunk`` with nothing else), so
67
+ refuse.
68
+
69
+ The fresh-dir auto-init is exempted upstream via ``allow`` so onboarding still works.
70
+ """
71
+ if not will_commit or allow or current_branch is None:
72
+ return
73
+
74
+ remote_default = remote_default_branch(repo_root)
75
+ if remote_default is not None:
76
+ if current_branch == remote_default:
77
+ raise WorktreeError(
78
+ f"refusing: HEAD is the default branch {current_branch!r}, and loop mode "
79
+ f"would fast-forward producer commits onto it. Re-run on a feature branch, "
80
+ f"or pass --allow-default-branch to commit there deliberately."
81
+ )
82
+ return
83
+
84
+ local_default = local_default_branch(repo_root)
85
+ if local_default is None:
86
+ raise WorktreeError(
87
+ f"refusing: HEAD is {current_branch!r}, and with no origin/HEAD and no local "
88
+ f"main/master there is nothing to prove it is not this repo's default branch, so "
89
+ f"producer commits could land there. Set origin/HEAD "
90
+ f"(git remote set-head origin <branch>), or pass --allow-default-branch."
91
+ )
92
+ if current_branch == local_default or current_branch in _COMMON_DEFAULT_NAMES:
93
+ raise WorktreeError(
94
+ f"refusing: HEAD ({current_branch!r}) looks like this repo's default/integration "
95
+ f"branch (local default is {local_default!r}; no origin/HEAD to prove otherwise), "
96
+ f"so producer commits could land there. Re-run on a feature branch, or pass "
97
+ f"--allow-default-branch to commit here deliberately."
98
+ )
@@ -0,0 +1,107 @@
1
+ """Per-run budget accounting (PR-v2-11): sum actor usage, decide when a ceiling is hit.
2
+
3
+ A leaf: every import is type-only, and nothing here constructs a ``Usage`` or reads config
4
+ beyond two attributes, so it cannot form an import cycle with ``results`` / ``config_loop``.
5
+ The loop owns the running tally and calls :func:`over_budget` at each phase boundary.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from syncade.config_loop import LoopConfig
14
+ from syncade.orchestrator.results import RoundResult
15
+ from syncade.usage import Usage
16
+
17
+
18
+ def review_usages(round_result: RoundResult) -> list[Usage]:
19
+ """Usages from the actors that run BEFORE the producer this round: each reviewer + the
20
+ judge. The pre-producer budget check sums these so reviewers that already blew the ceiling
21
+ don't also trigger the expensive producer leg. Test/check legs spawn no model."""
22
+ usages = [r.usage for r in round_result.dispatch_result.results if r.usage is not None]
23
+ synth = round_result.synth_result
24
+ if synth is not None and synth.usage is not None:
25
+ usages.append(synth.usage)
26
+ return usages
27
+
28
+
29
+ def round_usages(round_result: RoundResult) -> list[Usage]:
30
+ """Every model-actor Usage a round produced: reviewers + judge + producer. The full
31
+ per-round contribution the loop accumulates into the run tally."""
32
+ usages = review_usages(round_result)
33
+ producer = round_result.producer_result
34
+ if producer is not None and producer.usage is not None:
35
+ usages.append(producer.usage)
36
+ return usages
37
+
38
+
39
+ def producer_only_usages(round_result: RoundResult) -> list[Usage]:
40
+ """Only the producer Usage from a round.
41
+
42
+ Used when resuming a budget-aborted-before-producer run: the review bundle
43
+ was paid for in the prior process and must not count against the fresh tally.
44
+ """
45
+ producer = round_result.producer_result
46
+ if producer is not None and producer.usage is not None:
47
+ return [producer.usage]
48
+ return []
49
+
50
+
51
+ #: Fraction of a configured ceiling at which the loop warns, so an operator watching a long
52
+ #: run sees the stop coming while a round is still left to react in. Advisory only — it never
53
+ #: changes a verdict or an exit code.
54
+ BUDGET_WARN_FRACTION = 0.8
55
+
56
+
57
+ def approaching_budget(usages: list[Usage], loop: LoopConfig) -> str | None:
58
+ """The ceiling the running tally is APPROACHING, or ``None``.
59
+
60
+ Fires in the band ``[fraction x ceiling, ceiling)`` — at or past the warning line but still
61
+ under the ceiling — so it always precedes :func:`over_budget` rather than racing it. The
62
+ caller checks this only when ``over_budget`` returned None, so the two can never both speak
63
+ about the same boundary.
64
+
65
+ This matters more since the ceiling gained a DEFAULT (PR-h-field-06): without a warning the
66
+ first thing most operators would learn about `budget_tokens` is a run stopping.
67
+
68
+ An earlier attempt at this (abandoned on a branch, never merged) compared
69
+ ``tally >= ceiling / FRACTION`` — i.e. 125% of the ceiling, which ``over_budget`` has
70
+ already aborted at. It could never fire. Pinned below by a test that asserts the warning
71
+ band lies BELOW the abort, not merely that some tally warns.
72
+ """
73
+ if loop.budget_tokens:
74
+ tokens = sum(u.total_tokens for u in usages)
75
+ if tokens >= loop.budget_tokens * BUDGET_WARN_FRACTION:
76
+ return "budget_tokens"
77
+ if loop.budget_usd:
78
+ cost = sum(u.cost_usd for u in usages if u.cost_usd is not None)
79
+ if cost >= loop.budget_usd * BUDGET_WARN_FRACTION:
80
+ return "budget_usd"
81
+ return None
82
+
83
+
84
+ def over_budget(usages: list[Usage], loop: LoopConfig) -> str | None:
85
+ """The ceiling the running tally has crossed, or ``None`` (also ``None`` when no ceiling is
86
+ active — ``budget_tokens = 0`` is the opt-out sentinel, and ``budget_usd`` defaults unset).
87
+
88
+ Tokens are the TIGHTEST bound: ``usages`` holds only actors whose usage was recorded (the
89
+ norm), so ``total_tokens`` is exact in the normal case and a lower bound only when an actor
90
+ reported no usage at all (a provider envelope with no usage block — rare). The dollar tally
91
+ is looser still: it sums only KNOWN ``cost_usd``, so an actor WITH usage but unpriceable cost
92
+ contributes nothing to it (a LOWER BOUND — the honest limit named in the config/help; use
93
+ ``budget_tokens`` for the hardest cap). Compares ``>=``: at the ceiling, the next phase
94
+ would spend past it, so stop. Tokens are checked first; returns ``"budget_tokens"`` /
95
+ ``"budget_usd"`` naming the crossed ceiling.
96
+ """
97
+ # 0 is the explicit opt-out, not a ceiling of zero (PR-h-field-06): with a DEFAULT
98
+ # ceiling in place an omitted TOML key can no longer mean "unlimited", so a sentinel is
99
+ # the only way left to say it.
100
+ if loop.budget_tokens:
101
+ if sum(u.total_tokens for u in usages) >= loop.budget_tokens:
102
+ return "budget_tokens"
103
+ if loop.budget_usd:
104
+ cost = sum(u.cost_usd for u in usages if u.cost_usd is not None)
105
+ if cost >= loop.budget_usd:
106
+ return "budget_usd"
107
+ return None