2.14.0
Released
Features
Added
generateStructured()besidegenerateText(), so an AI surface that needs a shape can ask for one instead of parsing JSON back out of prose. It uses each provider's own structured mode — OpenAI's strictjson_schema, an Anthropic tool call the model is forced to make, DeepSeek's JSON mode — and falls back to describing the schema in the prompt for OpenRouter's free router, whose models do not agree onresponse_format. Every route ends at the same validation step, and it returns the same envelope asgenerateText(), so callers still reprice against the model that actually answered. (#540)Both
generateText()andgenerateStructured()now accept an optional per-calltimeoutMs. The 55-second default is a ceiling on one call, which is no use to a route that makes several inside a 60-second function: without a way to pass down what is left, the last call can still be in flight when the runtime kills the function. (#540)
Fixes
A coaching interview whose closing summary arrived incomplete no longer asks the user to send another message and sit through a whole extra turn. The reply is still read first, so nothing changes when the model got it right; when it did not, the server asks the model that answered — or a cheaper one — for the draft as a schema, and gives up if that fails too or if too little of the route's 60 seconds is left to try. A lost draft costs the user one more message; a turn killed mid-repair has already been charged for and cannot be recovered at all. Small and free models produce the broken payload most often, and they are the ones a schema helps most. (#540)
Whether a coaching interview has finished is now decided by the server rather than re-derived in the browser, so the two can no longer disagree about the same reply. (#540)
An investigation started from a task no longer sits at "queued" forever. Production ran no investigation worker at all:
apps/workerhad a Dockerfile, a Compose service and a documented Fly app, but nothing in CI or anywhere else deployed it, so nothing ever calledclaim_next_task_investigation()and every row inserted byPOST /api/tasks/:id/investigationsstayedpending— no error, no failed status, nothing for the user or the logs to go on. Production deploys now ship the worker to Fly alongside the Vercel app. (#556)An investigation is now run against exactly what it was charged for. Images are a surcharge on the persona weight, priced from the row's own
imagescolumn when it is inserted — and the worker sent neither more nor less than that column by accident in two different directions. It re-read the quest's images at claim time, so an image attached to the quest after the investigation was queued went to the provider having never been paid for; and it trimmed, de-duplicated and capped that list again on the way out, so a row written straight through PostgREST paid for images no model was shown. It now sends the stored array verbatim. (#556)A substituted model no longer goes unpriced on an investigation. The OpenRouter free-router retry, which stands in when a pinned slug has been retired, was invisible past the provider call: the row and the ledger both kept naming the model that was rejected. The worker now learns which model answered, re-books the charge against it in one transaction, and records the correction on the row — the same repricing the narrative and coach routes got in #528, which investigations were left out of. A substitution that had no charge to correct, which is what every one of them is today, still gets recorded on the row; only a repricing that actually failed leaves it naming the model that was asked for. Both sides of that swap are free personas today, so nothing changes for anyone's balance now;
credit_costis a catalog column, and this is what stops that staying true by luck. (#556)An investigation queued without a prompt — reachable only by inserting the row straight through PostgREST, which is the path this table's triggers exist for — is no longer analysed by an anonymous assistant asked for markdown. The worker's fallback prompt now names the quest's persona and asks for the one paragraph of plain prose the rest of the app asks for and renders. (#556)
Technical changes· 7 entries for database, tooling, CI, tests and docs
Database
task_investigations.imagesis normalized before the row is written — trimmed, blanks dropped, duplicates removed keeping the first occurrence, capped at ten — so the charge is computed from the same array the worker later sends. Those rules previously lived only in the route, and the charge is a trigger precisely because that table is insertable straight through PostgREST: a row written around the route was billed for fifteen entries and run with the ten that survived. Existing rows that differ are normalized in the same migration, which changes no charge already booked. (#556)
CI/CD
The Release workflow now deploys
apps/workerto Fly.io as its own job, checked in asapps/worker/fly.toml. It refuses to ship a worker whose required secrets are missing — a worker started without them throws ingetWorkerEnv()and restarts forever, which from the app is indistinguishable from a worker with nothing to do — warns for each provider key that is absent, since that takes down every persona on that provider one investigation at a time, and after deploying it asserts a machine is actually running and looks for the line the poll loop writes when it first reaches Supabase. It runs in theproductionenvironment, so aFLY_API_TOKENstored there is actually visible to it, and passes--ha=false, which stops Fly adding a spare machine nobody budgeted for and the config does not describe. The job carries noneedson the Vercel deploy, so neither surface can hold the other back. (#556)Pinned the worker's Fly configuration to a poller rather than a server: no service block, since it opens no listening socket and Fly would health-check a port nothing binds, and a 90-second shutdown grace period, because the loop only checks for a stop signal between iterations and Fly's 5-second default would kill through an in-flight generation and strand the claimed row in
runninguntil the ten-minute stale reclaim. (#556)
Tests
Covered what the worker does with a claimed row, which nothing had: that it calls the model the investigation was queued against, sends only the images that were charged for, reprices a free-router substitution against the model that answered, takes the payer from the ledger rather than from the client-writable
initiated_by_id, leaves both the charge and the row alone when a repricing fails, and reprices before reconciling so the token counts land on the entry that survives. Each assertion was checked against a mutation of the behaviour it guards. (#556)Pinned the worker's deployment to the things that make it real, all of which fail silently: that the workflow and
fly.tomlname the same Fly app, that the build keeps the repo root as its context (the Dockerfile copiespnpm-lock.yaml,pnpm-workspace.yamlandpackages/, none of which exist underapps/worker), that the secrets the deploy checks for are exactly the onesgetWorkerEnv()requires, that the shutdown grace period is a top-level key rather than one buried in a table where TOML would ignore it, and that the log line the health probe greps for is one the worker still writes. (#556)Covered
generateStructured()against a valid payload, malformed JSON, a well-formed payload that fails its schema, a payload wrapped in a code fence, and an OpenRouter free-router substitution — plus the exact request each provider is sent, and that a plain text call is not sent any of it. (#540)Pinned the coach draft schema to the parser that consumes it: the two describe the same fields, both normalize a payload identically whichever way it arrived, and the schema satisfies the constraints OpenAI's strict mode puts on one — which would otherwise surface as a 400 in production that the fallback chain would quietly paper over. (#540)