Questify

2.27.1

Released

Fixes

  • Claude investigations work again on the production worker. 2.27.0 moved the worker to Anthropic workload identity, which reads a token over Fly's /.fly/api socket, but Fly creates that socket owned by root and the image ran as node. Every Claude investigation therefore failed with connect EACCES /.fly/api. The image now starts in apps/worker/docker-entrypoint.sh, which gives the socket to the node group and then runs the worker as node with setpriv. Fly documents this workaround for non-root access. (#737)

  • When the model picked for an investigation fails, the worker now tries up to two other models before giving up. It picks from active personas the payer's plan includes and that the worker can reach. A substitute's full hold, as ai_credit_cost() prices it with its provider's search allowance, may not exceed what the current attempt holds. List price is not enough: DeepSeek holds 8 credits, but GPT Luna, at the same list price, holds 11. Other providers come first, then the most capable. DeepSeek is never a substitute for a question with images or documents, which it can't see, and a DeepSeek substitute gets the prompt that says it can't search. The whole chain, including the model that was picked, is bounded at 150 seconds, and each attempt's HTTP request is aborted when the budget runs out. Neither the picked model nor a substitute starts once the worker begins shutting down, including when the shutdown arrives while attachments and pages are being read. An attempt already running gets 75 seconds to finish and is then abandoned, so a deploy still finds the row recorded, failed and refunded, inside Fly's 90-second kill window. The fallback lookup runs inside the same deadline and is skipped once the worker is shutting down. The answer is repriced to the model that gave it, and only for the run in progress, so an investigation that is run again keeps the charges from its earlier runs. That repricing can only refund credits. The card's badge and note name the model that actually answered, taken from the worker's result. A re-run that answers directly, or fails, clears the earlier run's fallback note. Even when the reprice fails and model_type stays on the charged model, the card does not contradict itself. (#737)

  • A failed investigation's card no longer prints the worker's raw error, which exposed provider bodies, socket paths and credential hints. It shows one plain sentence instead: a file or link couldn't be read, the model ran out of room, the model declined to answer (its content filter refused it), the model came back without an answer, the models couldn't answer right now, or something went wrong. The worker keeps these apart using the provider's stop reason, so a filtered or otherwise empty answer isn't blamed on length. Every version says the user wasn't charged. The raw text stays in task_investigations.error, and details.attempts lists each model's error when more than one was tried. (#737)

Technical changes· 3 entries for database, tooling, CI, tests and docs

Database

  • 20261025120000_reprice_investigation_attempt.sql adds reprice_investigation_attempt(_investigation_id, _model_type, _image_count), which re-books only the current attempt of an investigation against the model that answered it. It returns only what that attempt still holds (its newest investigation spend, less refunds that already point at it, bounded by what the reference nets), writes that refund against the spend (settles_entry_id), charges the account that paid the attempt for the new model, and moves the row's model_type to that model, all in one transaction under the settlement lock. Because the row and the attempt's newest charge always name the same model, a worker that dies after repricing leaves a row the stale-row reclaim can run and reprice correctly. When the new charge cannot be covered (42501), the refund rolls back. It returns NULL when the attempt booked no charge. That includes a paid investigation re-run on a free persona: its newest spend is the earlier run's, so the function only reprices a spend booked for the model the row is queued against. Before this, the worker repriced with reprice_ai_credits(), which refunds everything ever charged against the reference. A fallback on a re-queued investigation would therefore have erased earlier runs' settled charges and could have moved what was left between Party Quest members. The same migration re-issues settle_investigation_credits() (20260918120000) with that same rule: the spend it settles must be for the model the row is charged for. Otherwise a paid investigation re-run on a free persona settled the earlier run's charge with the free run's token counts and refunded part of it. This bug predates #737. The function is service role only, and reprice_ai_credits() is unchanged for the coach and narrative routes. supabase/tests/investigation_reprice_attempt.sql covers the single run, the re-queued run, the payer, the free run, a paid run re-queued on a free persona, a reclaimed row that falls back again, settling a free re-run, and the rollback, and rls.sql pins the grants. (#737)

Tests

  • scripts/worker-deploy.test.mjs pins the worker image's entrypoint: no USER line in the runner stage, the socket hand-off, and the setpriv drop to node. New worker tests cover the fallback ranking (price cap, other providers first, plan tier, configured providers, DeepSeek with documents or images, inactive personas, the three-model cap) and the processor's chain (reprice to the model that answered, the attempt record, no search on a free substitute, each failure reason, the time budget on the picked model and its substitutes, the shutdown grace, a stalled fallback lookup, DeepSeek's no-search prompt, the abort reaching the provider request, and repricing through reprice_investigation_attempt() without the worker naming a payer). Web tests check that the card never renders error. (#737)

Documentation

  • docs/ai-investigations.md describes the fallback rule, the per-attempt repricing and the plain-language failure copy. docs/schema.md lists the new migration and its pgTAP coverage. docs/ops/deployments.md explains why the worker image starts as root and how an EACCES /.fly/api failure shows up. (#737)