Skip to content

Charge Crossfire and Sparring per round, refund runs that produce nothing (batch T5) - #201

Merged
Programcode merged 12 commits into
mainfrom
fix/t5-session-quota-refunds
Oct 22, 2026
Merged

Programcode merged 12 commits into
mainfrom
fix/t5-session-quota-refunds

Conversation

@Programcode

Copy link
Copy Markdown
Owner

Batch T5 from the 22 Oct 2026 QA report: Crossfire and Sparring are charged once per round, and a run that produces nothing is refunded.

Per issue

QB-01 (High), Sparring needed all 5 trial uses. A Sparring round (every opponent reply and the judge) now costs one use, weighted by model. The server enforces this through a new session ledger, keyed by the round's validated sessionId. Before Start, the setup asks the server what the round costs and how many uses are left ("This round costs 1 AI request, however many turns it takes. You have N left today."). A round the server would refuse never opens, so a speech can't be written into it. Verified: the usage-session-sparring recipe starts with 4 of 5 used on Claude Haiku 5.5, runs two opponent replies and the judge to a verdict, and the counter goes from 4 to 5 once (ai_quota_sessions.calls_used = 3).

QB-05 (Medium), Crossfire charged per answer. A Crossfire round (opening turn, answers, debrief) is charged once. The page sends its round id as quotaSession beside input, so an older server ignores it and charges per call. Verified: usage-session-crossfire makes four calls and one charge.

MD-02 (Medium), Crossfire started the clock when the opening turn was refused. The same pre-start quota check runs first. A refused round opens the limit dialog and keeps the setup. The old fallback (reset to setup when the first turn is refused) stays. Verified: unit test crossfire-daily-limit.test.tsx and the cross-daily-limit recipe.

MD-03 (Low), empty-state text next to T1. The live transcript no longer shows the setup hint. Verified in usage-session-crossfire.

MD-01 (Medium), Ask lost the message on the limit modal. The question stays in the chat under "Not sent: daily limit reached" with Retry, in EN, ES and HU. A streamed 402 no longer falls back to a second request. Retry uses whatever model is selected when you press it. Verified: usage-ask-not-sent on both viewports. The question stays, Retry sends it once, and it appears once.

QB-04 (Medium) and MD-13 (Medium), failed, aborted and lost runs were charged. ai and ai-stream give the reserved units back when a run fails, times out, or the browser leaves (reload, closed tab, Stop) before it shows 200 characters of streamed output and before anything was saved. Abuse guards:

  • A run whose answer the model finished (any finish_reason except length) is never refunded, however the request ended afterwards, even when the usage trailer was lost.
  • A session refund names its charge (charge_id), so a failed call can't give back a later call's charge.
  • The weekly cost cap still counts what the run really cost.

The error carries charged: false, and the client adds "You were not charged for this request." Verified: Deno tests in ai/quota-sessions_test.ts and provider-errors_test.ts, plus the usage-refund-reload recipe (reload while Brainstorm streams: row cancelled/aborted, counter back to 0). For MD-13 I implemented the minimum (refund). Brainstorm does not keep running server-side after a reload the way Generate does.

SC-07 (Low), raw provider text in ai_requests.error_code. logUsage keeps short snake_case codes and maps anything else to aborted, timeout, rate_limited or provider_error. The raw text goes only to the function log, truncated. ai and ai-stream now write provider_error, timeout, aborted or claude_unavailable.

Also changed

  • Sparring model choice ignored (pre-existing). SparringPage memoized its model config on a function that never changes, so a model picked in setup was ignored until the page remounted, and the round ran on the old model. It now follows the store.
  • Settings usage count. Settings now reads ai_usage_today(), the caller's charged units, instead of counting ai_requests rows, which overstated usage once rounds are charged once. Against a database without the function it falls back to counting rows. QB-03's wording and display are untouched.
  • Stale recipe on main. usage-weighted failed because demo users now default to Sonnet. It now picks Opus itself, and its dialogs close with Escape.

Review rounds

  • Codex round 1 (01574a5b), five findings, all fixed in 9af788f1:
    • A finished answer was refunded when a disconnect lost the usage trailer (High).
    • A stale Crossfire start mixed the new motion with the old debate.
    • A delayed Sparring Start could overwrite a restored round.
    • Ask Retry reused the refused model.
    • Settings overcounted usage.
  • Codex round 2 (9af788f1), three findings, all fixed with tests:
    • Sparring Start could cross accounts. The account is now captured at Start.
    • A finished answer was refunded when the upstream read failed while the caller stayed connected.
    • A stalled quota check left Start disabled. It now has an 8 s timeout.
  • Round 3: Codex returned 429 (plan limit), so a fresh-context Claude (Opus) reviewer did it instead, focused on the refund paths and the deploy window.
    • It found no defect in the refund paths at 009a0c1c.
    • Fixed in 3c4914b6:
      • Medium: while the quota check is unknown (old server, timeout), the setup claimed "costs 1" and Sparring lost its shortfall warning. Now the cost line is hidden, and Sparring warns from the browser's per-call estimate.
      • Deploy order: without the migration, Sparring would 500. checkRateLimit now falls back to per-call consume_ai_quota when the ledger functions are missing.
      • Low: a refund that crossed midnight UTC credited the wrong day. Per-call refunds are now skipped once the UTC day has changed.
      • Low: [DONE] without a finish_reason didn't count as finished. It now does.
      • Pre-existing: ai-stream's "OpenRouter is not configured" exit didn't refund. It now does.
    • Left as is (Low): a streamed answer that finished but fails validation stays charged, while the non-streamed equivalent is refunded.
    • The same reviewer then checked those fixes at 3c4914b6. They hold, with one Low regression: an empty reply counted as finished. Fixed with a test in 28678c80.

Migrations

supabase/migrations/20261022220000_ai_quota_sessions.sql, additive only, nothing destructive:

  • creates table ai_quota_sessions (RLS on, no client grants, cascades with auth.users);
  • creates functions claim_ai_session_quota, refund_ai_session_quota and peek_ai_quota (service role only);
  • creates function ai_usage_today() (signed-in users, their own row only; anon revoked).

It drops, rewrites or deletes nothing, and no existing function changes. Its version sorts after main's 20261022210100. Nothing has been applied anywhere.

Needs applying or deploying (not done)

  1. Migration 20261022220000_ai_quota_sessions.sql, staging then production.
  2. Edge Functions ai and ai-stream.

Deploy order: any order is safe. Migration first is still preferred.

  • Frontend first (it ships on merge): against the old backend, the quota check answers 400 and is treated as unknown. The cost line is hidden and Sparring warns from the per-call estimate. Settings falls back to counting rows, and the old handler ignores Crossfire's quotaSession.
  • ai/ai-stream before the migration: Sparring and Crossfire fall back to per-call charging, and the quota check answers 500, treated as unknown. Nothing breaks, but per-round charging starts only once the migration is applied.
  • Old client + new functions: works. Sparring is charged per round, and old Crossfire clients per call.

Verification

  • Local npm run verify on 28678c80: pass, exit 0. This is a local pass, not a CI pass; Actions minutes are used up, so CI won't run.
    • docs check, lint and typecheck
    • 452 unit tests and 30 collaboration tests
    • 417 edge tests
    • build and the service-worker check
    • 43 desktop @smoke e2e tests
  • New tests:
    • ai/quota-sessions_test.ts, 10 tests: session charging, refund rules, the trailer lost to a disconnect, the upstream read failing after finish, empty replies, the missing-ledger fallback, the quota check.
    • Updated provider-errors_test.ts.
    • supabase/tests/ai_quota_sessions_test.sql, 22 pgTAP assertions: grants, charge once, the call cap and lifetime, heavier models, races serialized with FOR UPDATE, refunds by charge_id, midnight, ai_usage_today.
    • Vitest: Crossfire start and quota tests, the Sparring account switch, the quota-check timeout, the Settings usage count.
    • I confirmed the refund, account-switch and empty-reply tests fail without their fixes.
  • DB gate (./.superset/db-gate.sh --db-only): the last full run passed (26 files, 492 assertions, plus the concurrency scripts). It ran this migration's exact SQL, then numbered 20261022190000. Since then the migration was only renamed and main's two migrations were merged in. The rerun on the merged order could not run, because another worktree's verify stack held the shared Supabase lock for over an hour. Rerun before applying.
  • Real app (verify-debatelab, fake provider, desktop and mobile Chromium, all green before the review fixes): recipes/usage-limits (6 tests: usage-weighted, usage-weekly-sparring, usage-session-sparring, usage-session-crossfire, usage-refund-reload, usage-ask-not-sent) and recipes/practice (5 tests). I did not re-drive them after the review fixes. The unit, edge and e2e tests above cover those changes.
  • The affected e2e specs (crossfire, sparring, voice-rounds) passed in full on desktop and mobile after I added a quota-check answer to their mocks (e2e/helpers/quota.ts).

Still to check by hand

  • On a real deployed edge runtime, a reload mid-stream should still be detected as a disconnect. It is detected locally, and staging's 41 s error row suggests the function sees it there.
  • Live AI (--live) was not run. The change doesn't touch prompts or model output.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 22, 2026 22:33
@coderabbitai

coderabbitai Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Next included review available in 44 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
📥 Commits

Reviewing files that changed from the base of the PR and between 1b00d89 and 386c2b7.

📒 Files selected for processing: 47
  • .claude/skills/verify-debatelab/features/practice.md
  • .claude/skills/verify-debatelab/features/usage-limits.md
  • .claude/skills/verify-debatelab/recipes/practice.spec.ts
  • .claude/skills/verify-debatelab/recipes/usage-limits.spec.ts
  • docs/architecture.md
  • e2e/crossfire.spec.ts
  • e2e/fixtures/ai-responses.ts
  • e2e/helpers/quota.ts
  • e2e/sparring.spec.ts
  • e2e/voice-rounds.spec.ts
  • src/components/features/sparring/SparringSetup.tsx
  • src/components/features/subscription/SessionCostNote.tsx
  • src/components/features/subscription/index.ts
  • src/hooks/use-session-quota.ts
  • src/hooks/use-subscription.test.tsx
  • src/hooks/use-subscription.ts
  • src/lib/ai/client.ts
  • src/lib/ai/error-display.ts
  • src/lib/ai/errors.ts
  • src/lib/ai/features/crossfire.ts
  • src/lib/ai/features/session-quota.test.ts
  • src/lib/ai/features/session-quota.ts
  • src/lib/ai/request.ts
  • src/locales/en.json
  • src/locales/es.json
  • src/locales/hu.json
  • src/pages/AskPage.tsx
  • src/pages/CrossfirePage.tsx
  • src/pages/SparringPage.tsx
  • src/test/crossfire/crossfire-daily-limit.test.tsx
  • src/test/pages/CrossfireVoice.test.tsx
  • src/test/pages/SparringVoice.test.tsx
  • src/test/sparring/sparring-quota-start.test.tsx
  • supabase/README.md
  • supabase/functions/_shared/entitlements.test.ts
  • supabase/functions/_shared/entitlements.ts
  • supabase/functions/_shared/usage-limits.ts
  • supabase/functions/ai-stream/index.ts
  • supabase/functions/ai/index.ts
  • supabase/functions/ai/provider-errors_test.ts
  • supabase/functions/ai/provider.ts
  • supabase/functions/ai/quota-sessions_test.ts
  • supabase/functions/ai/stream.test.ts
  • supabase/functions/ai/voice.test.ts
  • supabase/migrations/20261022220000_ai_quota_sessions.sql
  • supabase/tests/account_deletion_test.sql
  • supabase/tests/ai_quota_sessions_test.sql
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Note

🎁 Summarized by CodeRabbit Free

Your organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Essentials by visiting https://app.coderabbit.ai/settings/billing.

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants