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,363 @@
|
|
|
1
|
+
"""``syncade --metrics`` mode handler.
|
|
2
|
+
|
|
3
|
+
A one-shot, read-only pass: aggregate the ``.syncade/runs/`` artifact corpus
|
|
4
|
+
into ``.syncade/metrics.db`` (idempotent) and print a cumulative report. Split
|
|
5
|
+
into its own module so :mod:`syncade.cli.modes` stays under the blocking
|
|
6
|
+
file-length cap; re-exported from ``modes`` so the public import path is
|
|
7
|
+
unchanged. Never mutates run artifacts and never touches the review path.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import sqlite3
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from syncade import billing
|
|
17
|
+
from syncade.billing import Billing
|
|
18
|
+
from syncade.exit_codes import SUCCESS, WORKTREE_ERROR
|
|
19
|
+
from syncade.metrics.aggregate import backfill
|
|
20
|
+
from syncade.metrics.schema import open_db
|
|
21
|
+
from syncade.snapshot import SnapshotError, discover_repo_root
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _spend_lines(
|
|
25
|
+
tokens: object,
|
|
26
|
+
cost: object,
|
|
27
|
+
incomplete_tokens: object,
|
|
28
|
+
totals: Billing,
|
|
29
|
+
) -> list[str]:
|
|
30
|
+
"""The honest spend block.
|
|
31
|
+
|
|
32
|
+
``billed`` is money. ``API-equiv`` is a VALUATION of the same traffic at API list
|
|
33
|
+
price -- worth showing (it is what a subscription is saving you) but it is NOT spend
|
|
34
|
+
and must never be labelled as such. Whatever cannot be classified is named, never
|
|
35
|
+
quietly counted as free.
|
|
36
|
+
|
|
37
|
+
Every pre-existing honesty signal is preserved deliberately: "usage unavailable" must
|
|
38
|
+
NOT collapse to "0 tokens" (unknown is not zero), and cost-incomplete tokens are still
|
|
39
|
+
flagged "not free". Replacing one lie with another would be a poor trade.
|
|
40
|
+
"""
|
|
41
|
+
if tokens is None and cost is None and not incomplete_tokens:
|
|
42
|
+
return [" usage: unavailable (legacy/no usage recorded)"]
|
|
43
|
+
|
|
44
|
+
b = totals
|
|
45
|
+
tok = int(tokens or 0)
|
|
46
|
+
incomplete = int(incomplete_tokens or 0)
|
|
47
|
+
tok_line = f" tokens: {tok}"
|
|
48
|
+
if incomplete:
|
|
49
|
+
tok_line += f" ({incomplete} cost-incomplete tokens — priced at $0, but NOT free)"
|
|
50
|
+
lines = [tok_line]
|
|
51
|
+
lines.extend(billing.render(b, indent=" "))
|
|
52
|
+
return lines
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _usd(value: float) -> str:
|
|
56
|
+
if 0 < value < 0.005:
|
|
57
|
+
return "<$0.01"
|
|
58
|
+
return f"${value:.2f}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _merge_auth_mode(current: str, incoming: str) -> str:
|
|
62
|
+
"""Aggregate auth modes for one actor/model. Two different modes -> "mixed"."""
|
|
63
|
+
if not incoming:
|
|
64
|
+
return current
|
|
65
|
+
if current in ("", incoming):
|
|
66
|
+
return incoming
|
|
67
|
+
return "mixed"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _billing_totals(conn: sqlite3.Connection, run_ids: list[str] | None = None) -> Billing:
|
|
71
|
+
"""``(billed, api_equivalent, unclassified)`` in USD.
|
|
72
|
+
|
|
73
|
+
THE distinction this whole PR exists for. ``cost_usd`` is an API-EQUIVALENT
|
|
74
|
+
VALUATION, not money: it is fiction whenever the call rode a subscription. Before
|
|
75
|
+
this, ``--metrics`` summed it and called the result "spend" — reporting $71.18
|
|
76
|
+
across a corpus whose real marginal cost was ~$0, because every call ran on a
|
|
77
|
+
ChatGPT plan or a claude.ai plan.
|
|
78
|
+
|
|
79
|
+
- ``billed`` — auth_mode == "api". Money that actually left the account.
|
|
80
|
+
- ``api_equiv`` — every priced token, regardless of how it was paid for. Still
|
|
81
|
+
useful: it is what the traffic WOULD cost on API pricing, and
|
|
82
|
+
it is the number that tells a subscription user what their plan
|
|
83
|
+
is worth.
|
|
84
|
+
- ``unclassified`` — auth mode not recorded (runs from before this PR). Reported
|
|
85
|
+
separately and NEVER folded into either: we do not know, and
|
|
86
|
+
guessing is what got us here.
|
|
87
|
+
|
|
88
|
+
**Computed from the RAW table, never from display rows.** The first version summed
|
|
89
|
+
``_actor_spend_rows()``, which groups by ``(provider, model)`` for the per-model
|
|
90
|
+
lines and merges differing auth modes into ``"mixed"`` on the way. That grouping
|
|
91
|
+
destroyed the api/subscription split BEFORE billing was computed, so a corpus with
|
|
92
|
+
genuine API spend alongside subscription traffic on the same model reported
|
|
93
|
+
**$0.00 billed** — real money, silently unreported. Caught by syncade's own panel,
|
|
94
|
+
unanimously; it is the exact case the PR brief dared them to find.
|
|
95
|
+
|
|
96
|
+
Display aggregation and money must never share a code path. SQL, grouped by
|
|
97
|
+
auth_mode, no collapsing.
|
|
98
|
+
"""
|
|
99
|
+
where = ""
|
|
100
|
+
params: list[str] = []
|
|
101
|
+
if run_ids:
|
|
102
|
+
where = f"WHERE run_id IN ({','.join('?' * len(run_ids))})"
|
|
103
|
+
params = run_ids
|
|
104
|
+
rows = conn.execute(
|
|
105
|
+
# cost_incomplete_tokens, NOT `CASE WHEN cost_usd IS NULL`. A single actor_stats
|
|
106
|
+
# row aggregates several rounds, so it can carry BOTH a real cost AND unpriced
|
|
107
|
+
# tokens -- one priced round plus one unpriced round. The NULL test then sees a
|
|
108
|
+
# non-null cost and reports `billed` as complete when it is not. The schema has
|
|
109
|
+
# carried cost_incomplete_tokens for exactly this since PR-v2-04; I invented a
|
|
110
|
+
# worse proxy instead of reading it.
|
|
111
|
+
"SELECT auth_mode, COALESCE(SUM(cost_usd), 0), "
|
|
112
|
+
"COALESCE(SUM(cost_incomplete_tokens), 0) "
|
|
113
|
+
f"FROM actor_stats {where} GROUP BY auth_mode",
|
|
114
|
+
params,
|
|
115
|
+
).fetchall()
|
|
116
|
+
b = billing.from_rows(rows)
|
|
117
|
+
|
|
118
|
+
# Reconcile against `runs`, the authoritative run-level total. actor_stats is DERIVED
|
|
119
|
+
# from the same manifests, so normally it sums to runs and the delta is 0. But a run can
|
|
120
|
+
# persist a `runs` row with NO actor_stats rows (legacy runs; a run whose per-actor
|
|
121
|
+
# aggregation produced nothing) -- and then its usage vanished from billing entirely.
|
|
122
|
+
# Whatever `runs` has that actor_stats does not represent has no auth_mode, so its honest
|
|
123
|
+
# home is `unclassified`: unknown-but-real, never dropped.
|
|
124
|
+
#
|
|
125
|
+
# BOTH axes must be reconciled. Round 13 reconciled COST and left TOKENS -- a run with
|
|
126
|
+
# 1M tokens and no cost then still read as free. Same bug, one column over. `runs`
|
|
127
|
+
# carries both; both are matched here.
|
|
128
|
+
# Reconcile ONLY runs with NO actor_stats row at all. A represented run is trusted
|
|
129
|
+
# entirely to actor_stats -- crucially, its cost_incomplete_tokens are finer-grained than
|
|
130
|
+
# runs (a run can have a KNOWN total cost yet some cost-incomplete tokens), so an
|
|
131
|
+
# aggregate `runs - actor_stats` delta subtracts across different populations and is
|
|
132
|
+
# wrong. Per-unrepresented-run is the clean unit: cost known-but-unauthed -> unclassified
|
|
133
|
+
# cost; tokens with no cost -> unclassified unpriced. (Found by mutating my own round-13
|
|
134
|
+
# reconciliation, which used the aggregate delta.)
|
|
135
|
+
unrep = f"{'AND' if where else 'WHERE'} run_id NOT IN (SELECT run_id FROM actor_stats)"
|
|
136
|
+
unrep_cost, unrep_unpriced_tokens = conn.execute(
|
|
137
|
+
"SELECT COALESCE(SUM(cost_usd), 0), "
|
|
138
|
+
"COALESCE(SUM(CASE WHEN cost_usd IS NULL THEN tokens ELSE 0 END), 0) "
|
|
139
|
+
f"FROM runs {where} {unrep}",
|
|
140
|
+
params,
|
|
141
|
+
).fetchone()
|
|
142
|
+
if unrep_cost or unrep_unpriced_tokens:
|
|
143
|
+
b = b._replace(
|
|
144
|
+
api_equiv=b.api_equiv + unrep_cost,
|
|
145
|
+
unclassified=b.unclassified + unrep_cost,
|
|
146
|
+
unclassified_unpriced_tokens=b.unclassified_unpriced_tokens + unrep_unpriced_tokens,
|
|
147
|
+
total_unpriced_tokens=b.total_unpriced_tokens + unrep_unpriced_tokens,
|
|
148
|
+
)
|
|
149
|
+
return b
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _merge_cost_source(current: str, incoming: str) -> str:
|
|
153
|
+
if not incoming:
|
|
154
|
+
return current
|
|
155
|
+
if incoming == "unknown" or current == "unknown":
|
|
156
|
+
return "unknown"
|
|
157
|
+
if current in ("", incoming):
|
|
158
|
+
return incoming
|
|
159
|
+
return "estimated"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _actor_spend_rows(
|
|
163
|
+
conn: sqlite3.Connection, run_ids: list[str] | None = None
|
|
164
|
+
) -> list[tuple[str, str, int, float, int, str, str]]:
|
|
165
|
+
where = ""
|
|
166
|
+
params: list[str] = []
|
|
167
|
+
if run_ids:
|
|
168
|
+
where = f"WHERE run_id IN ({','.join('?' * len(run_ids))})"
|
|
169
|
+
params = run_ids
|
|
170
|
+
rows = conn.execute(
|
|
171
|
+
"SELECT provider, model, role, tokens, cost_usd, cost_incomplete_tokens, cost_source, "
|
|
172
|
+
f"auth_mode FROM actor_stats {where}",
|
|
173
|
+
params,
|
|
174
|
+
).fetchall()
|
|
175
|
+
grouped: dict[tuple[str, str], dict[str, object]] = {}
|
|
176
|
+
for provider, model, role, tokens, cost, incomplete_tokens, source, auth in rows:
|
|
177
|
+
key = (provider or "", model or "")
|
|
178
|
+
entry = grouped.setdefault(
|
|
179
|
+
key,
|
|
180
|
+
{
|
|
181
|
+
"tokens": 0,
|
|
182
|
+
"cost": 0.0,
|
|
183
|
+
"incomplete": 0,
|
|
184
|
+
"source": "",
|
|
185
|
+
"auth": "",
|
|
186
|
+
"roles": set(),
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
tok = int(tokens or 0)
|
|
190
|
+
entry["tokens"] = int(entry["tokens"]) + tok
|
|
191
|
+
entry["cost"] = float(entry["cost"]) + float(cost or 0.0)
|
|
192
|
+
entry["incomplete"] = int(entry["incomplete"]) + int(incomplete_tokens or 0)
|
|
193
|
+
entry["source"] = _merge_cost_source(str(entry["source"]), source or "")
|
|
194
|
+
entry["auth"] = _merge_auth_mode(str(entry["auth"]), auth or "")
|
|
195
|
+
if role:
|
|
196
|
+
entry["roles"].add(role)
|
|
197
|
+
out = []
|
|
198
|
+
for (provider, model), entry in grouped.items():
|
|
199
|
+
roles = ",".join(sorted(entry["roles"]))
|
|
200
|
+
out.append(
|
|
201
|
+
(
|
|
202
|
+
provider,
|
|
203
|
+
model,
|
|
204
|
+
int(entry["tokens"]),
|
|
205
|
+
float(entry["cost"]),
|
|
206
|
+
int(entry["incomplete"]),
|
|
207
|
+
str(entry["source"]),
|
|
208
|
+
roles,
|
|
209
|
+
str(entry["auth"]),
|
|
210
|
+
)
|
|
211
|
+
)
|
|
212
|
+
return sorted(out, key=lambda r: (r[0], r[1]))
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _cost_incomplete_tokens(conn: sqlite3.Connection, run_ids: list[str] | None = None) -> int:
|
|
216
|
+
"""Total tokens whose cost we could not establish, for the top ``tokens:`` line.
|
|
217
|
+
|
|
218
|
+
Derived from :func:`_billing_totals` -- the SAME reconciliation the billing block uses --
|
|
219
|
+
so the two CANNOT disagree. The old version summed only ``actor_stats`` whenever any
|
|
220
|
+
actor_stats row existed, so a mixed corpus (current rows + a legacy run-level-only row)
|
|
221
|
+
undercounted the top line while the billing block reported the full count. No money was
|
|
222
|
+
misstated, but two lines in one report disagreeing is the same two-sources bug; the fix
|
|
223
|
+
is the same -- one computation.
|
|
224
|
+
"""
|
|
225
|
+
return _billing_totals(conn, run_ids).total_unpriced_tokens
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def render_report(conn: sqlite3.Connection, last_n: int | None = None) -> str:
|
|
229
|
+
"""Render the cumulative metrics report from the DB (pure). When ``last_n`` is
|
|
230
|
+
set, append a billed / API-equivalent / unclassified breakdown scoped to the
|
|
231
|
+
``last_n`` most recent runs (see :mod:`syncade.billing`)."""
|
|
232
|
+
runs = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
|
233
|
+
if not runs:
|
|
234
|
+
return "syncade metrics — no runs recorded yet"
|
|
235
|
+
|
|
236
|
+
ship = conn.execute("SELECT COUNT(*) FROM runs WHERE verdict = 'SHIP'").fetchone()[0]
|
|
237
|
+
blockers, minors, nits, dismissed = conn.execute(
|
|
238
|
+
"SELECT COALESCE(SUM(blockers),0), COALESCE(SUM(minors),0), COALESCE(SUM(nits),0), "
|
|
239
|
+
"COALESCE(SUM(dismissed),0) FROM runs"
|
|
240
|
+
).fetchone()
|
|
241
|
+
rounds = conn.execute("SELECT COALESCE(SUM(rounds_executed),0) FROM runs").fetchone()[0]
|
|
242
|
+
handoffs, decisions = conn.execute(
|
|
243
|
+
"SELECT COALESCE(SUM(handoff),0), COALESCE(SUM(decision_needed),0) FROM runs"
|
|
244
|
+
).fetchone()
|
|
245
|
+
p_committed, p_stalled, p_errors, p_escalated = conn.execute(
|
|
246
|
+
"SELECT COALESCE(SUM(producer_commits),0), COALESCE(SUM(producer_stalled),0), "
|
|
247
|
+
"COALESCE(SUM(producer_errors),0), COALESCE(SUM(producer_escalated),0) FROM runs"
|
|
248
|
+
).fetchone()
|
|
249
|
+
total_tokens, total_cost = conn.execute(
|
|
250
|
+
"SELECT SUM(tokens), SUM(cost_usd) FROM runs"
|
|
251
|
+
).fetchone()
|
|
252
|
+
actor_rows = _actor_spend_rows(conn)
|
|
253
|
+
incomplete_tokens = _cost_incomplete_tokens(conn)
|
|
254
|
+
|
|
255
|
+
lines = [
|
|
256
|
+
f"syncade metrics — {runs} runs",
|
|
257
|
+
f" ship-rate: {ship}/{runs} ({round(100 * ship / runs)}%)",
|
|
258
|
+
f" findings: {blockers} blockers, {minors} minors, {nits} nits, {dismissed} dismissed",
|
|
259
|
+
f" rounds: {rounds} total",
|
|
260
|
+
f" handoffs: {handoffs} decisions: {decisions}",
|
|
261
|
+
f" producer: committed={p_committed} stalled={p_stalled}"
|
|
262
|
+
f" errors={p_errors} escalated={p_escalated}",
|
|
263
|
+
]
|
|
264
|
+
lines.extend(_spend_lines(total_tokens, total_cost, incomplete_tokens, _billing_totals(conn)))
|
|
265
|
+
lines.append(" verdicts:")
|
|
266
|
+
# group by exit code too, so distinct unrecognized codes are not merged under one label
|
|
267
|
+
for verdict, code, count in conn.execute(
|
|
268
|
+
"SELECT verdict, final_exit_code, COUNT(*) FROM runs "
|
|
269
|
+
"GROUP BY verdict, final_exit_code ORDER BY COUNT(*) DESC, verdict"
|
|
270
|
+
):
|
|
271
|
+
label = f"{verdict or '?'} ({code if code is not None else '?'})"
|
|
272
|
+
lines.append(f" {label:20} {count}")
|
|
273
|
+
|
|
274
|
+
if actor_rows:
|
|
275
|
+
lines.append(" by model (cost = API-equivalent; see `billed` above for money):")
|
|
276
|
+
for provider, model, tok, cost, incomplete, source, roles, auth in actor_rows:
|
|
277
|
+
label = f"{provider}/{model}" if model else (provider or "?")
|
|
278
|
+
src = f" ({source})" if source else ""
|
|
279
|
+
# The auth mode is what says whether this line is money or a valuation, so it
|
|
280
|
+
# is rendered right next to the dollars rather than buried in a footnote.
|
|
281
|
+
auth_label = f" auth={auth}" if auth else " auth=unrecorded"
|
|
282
|
+
cost_label = "known_cost" if incomplete else "cost"
|
|
283
|
+
incomplete_text = f" cost-incomplete-tok={int(incomplete)}" if incomplete else ""
|
|
284
|
+
lines.append(
|
|
285
|
+
f" {label:24} {cost_label}={_usd(cost)}{src}{auth_label} "
|
|
286
|
+
f"tok={int(tok)} roles={roles}{incomplete_text}"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
reviewer_rows = conn.execute(
|
|
290
|
+
"SELECT provider, model, COALESCE(SUM(finding_count),0), COUNT(DISTINCT run_id), "
|
|
291
|
+
"COALESCE(SUM(duration_s),0), COALESCE(SUM(tokens),0) "
|
|
292
|
+
"FROM reviewer_stats GROUP BY provider, model ORDER BY provider, model"
|
|
293
|
+
).fetchall()
|
|
294
|
+
if reviewer_rows:
|
|
295
|
+
lines.append(" reviewers by model (findings/wall-clock):")
|
|
296
|
+
for provider, model, findings, run_count, wall, tok in reviewer_rows:
|
|
297
|
+
label = f"{provider}/{model}" if model else (provider or "?")
|
|
298
|
+
lines.append(
|
|
299
|
+
f" {label:24} findings={findings} runs={run_count} "
|
|
300
|
+
f"wall={int(wall)}s tok={int(tok)}"
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
if last_n:
|
|
304
|
+
ids = [
|
|
305
|
+
r[0]
|
|
306
|
+
for r in conn.execute(
|
|
307
|
+
"SELECT run_id FROM runs ORDER BY run_id DESC LIMIT ?", (last_n,)
|
|
308
|
+
).fetchall()
|
|
309
|
+
]
|
|
310
|
+
if ids:
|
|
311
|
+
ph = ",".join("?" * len(ids))
|
|
312
|
+
w_tok, w_cost = conn.execute(
|
|
313
|
+
f"SELECT SUM(tokens), SUM(cost_usd) FROM runs WHERE run_id IN ({ph})",
|
|
314
|
+
ids,
|
|
315
|
+
).fetchone()
|
|
316
|
+
window_actor_rows = _actor_spend_rows(conn, ids)
|
|
317
|
+
window_incomplete = _cost_incomplete_tokens(conn, ids)
|
|
318
|
+
# The windowed section gets the SAME honest block as the totals. Fixing the
|
|
319
|
+
# top line and leaving this one summing valuations as "spend" would just move
|
|
320
|
+
# the lie down the page.
|
|
321
|
+
lines.append(f" last {len(ids)} run(s):")
|
|
322
|
+
lines.extend(_spend_lines(w_tok, w_cost, window_incomplete, _billing_totals(conn, ids)))
|
|
323
|
+
for provider, model, tok, cost, incomplete, source, roles, auth in window_actor_rows:
|
|
324
|
+
label = f"{provider}/{model}" if model else (provider or "?")
|
|
325
|
+
src = f" ({source})" if source else ""
|
|
326
|
+
auth_label = f" auth={auth}" if auth else " auth=unrecorded"
|
|
327
|
+
cost_label = "known_cost" if incomplete else "cost"
|
|
328
|
+
incomplete_text = f" cost-incomplete-tok={int(incomplete)}" if incomplete else ""
|
|
329
|
+
lines.append(
|
|
330
|
+
f" {label:24} {cost_label}={_usd(cost)}{src}{auth_label} "
|
|
331
|
+
f"tok={int(tok)} roles={roles}{incomplete_text}"
|
|
332
|
+
)
|
|
333
|
+
return "\n".join(lines)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _run_metrics(args) -> int:
|
|
337
|
+
"""Dispatch ``syncade --metrics``: aggregate the corpus + print the report.
|
|
338
|
+
|
|
339
|
+
Read-only over ``.syncade/runs/``; the DB is a derived, rebuildable view.
|
|
340
|
+
Exit codes follow the CLI mode-handler convention (CLAUDE.md): success → 0;
|
|
341
|
+
repo-discovery failure → 60 (WORKTREE_ERROR).
|
|
342
|
+
"""
|
|
343
|
+
repo_root_hint = Path(args.repo_root).expanduser() if args.repo_root else Path.cwd()
|
|
344
|
+
try:
|
|
345
|
+
repo_root = discover_repo_root(repo_root_hint)
|
|
346
|
+
except SnapshotError as exc:
|
|
347
|
+
print(f"[syncade] snapshot error: {exc}", file=sys.stderr)
|
|
348
|
+
return WORKTREE_ERROR
|
|
349
|
+
|
|
350
|
+
try:
|
|
351
|
+
conn = open_db(repo_root / ".syncade" / "metrics.db")
|
|
352
|
+
except (sqlite3.Error, OSError) as exc:
|
|
353
|
+
print(f"[syncade] metrics: cannot open metrics DB: {exc}", file=sys.stderr)
|
|
354
|
+
return WORKTREE_ERROR
|
|
355
|
+
try:
|
|
356
|
+
backfill(conn, repo_root / ".syncade" / "runs")
|
|
357
|
+
print(render_report(conn, getattr(args, "metrics_last", None)))
|
|
358
|
+
except (sqlite3.Error, OSError) as exc:
|
|
359
|
+
print(f"[syncade] metrics: aggregation failed: {exc}", file=sys.stderr)
|
|
360
|
+
return WORKTREE_ERROR
|
|
361
|
+
finally:
|
|
362
|
+
conn.close()
|
|
363
|
+
return SUCCESS
|