Repository navigation
T18: keep AI streams alive, recover dropped results, fix Auto Pro charge, refunds, Sparring judge errors - #224
Conversation
…nnet (T18) - Register every streamed ai and ai-stream run with EdgeRuntime.waitUntil until the last event is read, so Supabase cannot retire the worker mid-run (EarlyDrop). A run past its budget ends with a retryable timeout result instead of a cut connection. - Store each streamed run's final result in ai_run_results (new, additive table) before sending it; the client recovers it after a dropped, stalled, or unreadable stream instead of showing "check your connection". - Continue a Claude reply cut off at its cap from where it stopped (assistant prefill) instead of writing the whole answer again; no reset, no duplicate text. Other families restart as before. - Derive Auto unit costs from the candidate pools: Auto Pro reserves 2 (Sonnet), not 5. - Refund a run the student got nothing whole from, however much streamed; refund at most once; log exactly one ai_requests row per run (routing and ranking failures included). - Retry the Sparring judge once inside the request on an invalid scorecard or a transient provider failure; mark transient failures retryable and keep them out of Sentry exceptions. The round header shows an error state when scoring fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nuation
- Sparring speech and scorecard calls send a strict json_schema. The parser
names the first failing field (logged without model text); a reply in the
wrong shape gets one repair retry naming that field, then fails as a typed
AIResponseValidationError: 502 invalid_output, logged as invalid_output,
refunded once, shown as a localized "malformed" message (EN/ES/HU).
- The normalizer moves a stray top-level overallFeedback into the scorecard
and flattens {text|moment|description} list items into strings.
- Claude Haiku 5.5 and Sonnet 5.5 reject assistant prefill, so the cut-off
continuation is removed. The Generate cap rises to 12,000 tokens (the old
retry size) so a long Haiku case finishes on its first attempt; a cut-off
reply restarts once as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A run the browser left stays charged once 200+ characters streamed or the model finished (anti-abuse, as before); a run that fails on the server is refunded however much streamed. - The result finish waits for the real start write, so a slow upsert cannot land after it. - Recovery runs only for a broken live stream, and stops waiting on an unfinished row 30 s after a drop (a stalled stream keeps the full window). - The deadline test now fails if the result is sent before it is stored. - Docs and comments match the refund rules. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ℹ️ Recent review info
📝 Walkthrough
Note 🎁 Summarized by CodeRabbit Free
Comment |
Cancelling the reader on a 45 s stall closed the fetch, so the function cancelled the run the client was about to recover (students behind an SSE-buffering proxy). The stalled stream now stays open while the stored result is polled, and is cancelled when the caller aborts or after 160 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ai: after the 142 s deadline abort, wait up to 2 s for the run's own error answer, so a refunded run's result says charged:false. A step that ignores the abort still gets the plain retryable timeout. - ai: the start-write wait, the result write and the drain wait are bounded by the time left before the 150 s worker wall clock. - ai-stream: hold the worker until the browser has read the final event (at most 10 s, never past the wall clock) instead of a fixed 1 s settle after close. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Batch T18: AI stream reliability, Auto Pro charging, refunds, and the Sparring judge error state (QA round 3, bugs 1, 2, 3, 4 and 6), plus Sentry JAVASCRIPT-REACT-A.
What changed, by bug
Bug 1 (Auto Fast Generate dies at ~90 s).
aiandai-streamnow register their whole lifecycle withEdgeRuntime.waitUntil(_shared/background.ts). That covers the run, the stored result, the final event, and the close. Without it, Supabase can retire the worker (EarlyDrop) once the stream's headers are out. In tests and plain Deno the helper does nothing.ai/stream.tsis now pull-driven, so the worker is held until the HTTP layer has read the last chunk (with a 10 s cap and a 0.5 s settle).result, instead of a connection cut at the 150 s wall clock.resetand no text streamed twice. The cap only bounds the reply, so it costs nothing unless the model uses it.Bug 2 (Auto Pro succeeds on the server, the client shows failure).
resultKey(its request id), the function writes anai_run_resultsrow as the run starts. It stores the finalresultthere before sending it (ai/run-results.ts).src/lib/ai/live.ts) turns each failure into a typedLiveStreamError:dropped: a read error, such as ERR_CONNECTION_CLOSED;stalled: no bytes for 45 s, which is three missed keepalives;ended: the body ended without a result;unreadable: aresultevent whose data does not parse.src/lib/ai/run-recovery.tspolls the stored row and then resolves or fails exactly as if the event had arrived. It polls up to 30 s for a dropped stream, and up to the 155 s window for a stalled one.Bug 3 (Auto Pro charges 5).
usage-limits.tsnow derives the Auto unit costs fromFAST_CANDIDATESandPRO_CANDIDATES. Auto Pro reserves 2 (Sonnet), and the cost can't drift from the pools again.quotaCheck, so the displayed cost and uses-left follow automatically. No client file hard-codes Auto units.Bug 4 (dropped runs keep their charge, no
ai_requestsrow).refundOnce, so a run is refunded at most once.ai_requestsrow. Model-routing and source-ranking failures now logrouting_failedandsource_ranking_failed. A failure after a handler has logged its success row doesn't log a second row.ai_run_resultsthat still has no status long afterstarted_atis a run whose worker never finished.Bug 6 (the Sparring judge error leaves a stale status).
retryable: true. The client no longer reports those, or Claudeprovider_unavailable, as Sentry exceptions;ai_request_failedstill records them. Other provider failures are still reported.Sentry JAVASCRIPT-REACT-A
The root cause, from Cody: Sonnet returns valid JSON for the score action, but in a shape the parser rejected, and that surfaced as a provider 500.
json_schema(sparringOutputSchema), modelled on the Crossfire schemas. It has four named categories, awinnerenum, andadditionalProperties: falseeverywhere. Anthropic's structured outputs rejectminimum/maximumandmaxItems, so the 0–25 range, the exact count of four categories, and the 12-item lists are stated in field descriptions and enforced by the parser.checkSparringProviderOutput/checkSparringScorecardreturn the first failing field (for examplescorecard.categories[1].userScore). Only that path is logged, never model text.withJsonRepairPrompt, with the field named in the system prompt. If it fails again, the handler throwsAIResponseValidationError.ai/index.tsthen answers 502 with codeinvalid_outputand logserror_code = invalid_output. JSON that is still invalid aftercallAI's own repair maps the same way.overallFeedbackintoscorecard, and flattens{text|moment|description}objects inkeyMomentsandimprovementAreasinto strings.refund_ai_session_quotafor the call that paid). A judge call inside a round that already paid has nothing to refund. That charge covers the whole round, the failed attempt doesn't charge again, and "Try scoring again" is free.Deploy checklist (I did none of this)
supabase/migrations/20261023140000_ai_run_results.sqlcreatespublic.ai_run_resultswith owner-only SELECT and service-role writes. It also re-createsclear_current_user_data()with the same body plus oneDELETEfor the new table. Apply it before the functions.aiandai-stream. Both use the new_shared/background.ts;aialso changes_shared/usage-limits.ts,_shared/output-caps.tsand_shared/entitlements.ts(comment only).Compatibility, in any deploy order:
resultKey, so nothing is stored and streaming works as before. The extraretryablefield is ignored.invalid_outputon an old client shows the generic server error.Verification
npm run verify(full ladder, local, at the final head 7391338; also passed at 5346a03): passed, exit 0. docs:check, lint, typecheck; vitest 645/645 (89 files); collaboration 30/30; edge 468/468; build; test:sw; e2e smoke 47/47 (desktop Chromium). This is a local pass: GitHub Actions minutes are used up, so CI did not run../.superset/db-gate.sh --db-only: all 29 pgTAP files pass (522 tests), including the newai_run_results_test.sql, plus the default-model migration check, exit 0. Account deletion and export now include the new table;account_deletion_test.sqlcaught that it was missing.case-generation,live-generation,practiceandai-tools: all passed (18, 6, 10 and 14 tests) on the first commit.practice,live-generationandai-toolspassed.case-generationpassed 18/18 on a clean re-run, after two failures in an earlier run that overlapped my edits under the hot-reloading stack.ai_requestsrow (success) and one finishedai_run_resultsrow (status 200).resultevent was cut off by a Playwright route still showed the stored case and saved it.EarlyDrop, so the prod drop can't be reproduced locally. ThewaitUntilregistration is covered by a unit test with a fakeEdgeRuntime.waitUntilheld until the body is drained;resultKey;charged: false, a still-running row, a missing row, another run's key, the dropped-run limit);invalid_outputwith one repair retry, one refund and one usage row;SparringRound.Review rounds
claude-opus-5-5, on 37c1baa): no Critical or High findings. Two were Medium:claude-opus-5-5, on 5346a03): one Medium finding, no Critical or High.charged: false. The refund happens later, in the aborted run's own error path.resultpast the 150 s wall clock.successBodythrows after a handler logged its success row, the run is refunded but keeps that success label. There is still exactly one row.ai-streamstill enqueues fromstartand gives the last bytes a 1 s settle, not the pull-driven drain thataiuses.🤖 Generated with Claude Code
Summary by CodeRabbit