Questify

2.26.0

Released

Features

  • A guild's owner can now rename it, and delete it, from a Guild settings card on /guilds/[id] that only the owner sees. renameGuildAction and deleteGuildAction had existed since #564 — validated, unit-tested and RLS-gated — but nothing in the UI called either one, so a name mistyped at creation was set forever and a guild made by mistake could only be abandoned. The new GuildSettings component wires both: the rename form runs renameGuildSchema in the browser before submitting, so an empty or over-long name reads the same 1–100 character messages the action would send back, without the round trip; the delete sits behind a dialog that arms only once the guild's name has been typed, because the roster, every open claim code and every party seat pointing at one of its members go with it, and sends the owner back to /guilds on success. Transfer-ownership stays on the roster row, and neither action leans on the page for its guard — each is its own POST endpoint and refuses a non-owner (or a NEXT_PUBLIC_FEATURE_GUILDS=false deployment) on its own. Nothing server-side changed. (#705)

  • A stalled quest's nudge is now written by the quest's own model persona and names the task that actually went quiet, and a check-in reply comes back with one concrete thing to do about it — split the task, shrink it, mark it blocked, or snooze the quest — as a single button that performs it. Behind NEXT_PUBLIC_FEATURE_STALL_AI, registered in .env.example, apps/web/src/lib/env.ts, apps/web/Dockerfile, docker-compose.yml, turbo.json, apps/web/playwright.config.ts and apps/worker/src/env.ts, and copied from Vercel's production environment to the worker on every release like the three email flags before it, with the same refusal to deploy when a Fly secret of that name would shadow it. It is a separate switch from NEXT_PUBLIC_FEATURE_STALL_DETECTION because this half costs money and that half does not. The flag gates the read as well as the generation, which is what makes it a rollback rather than a stop switch: with it off, rows generated while it was on read as though they never had been, and the applier endpoint answers 404 for a page that still has its button. The one place it cannot reach is a digest already enqueued, because that copy was frozen by a pg_cron job and a flag is read by the app, never by the database. (#543)

  • The stall banner leads with the nudge written for that quest and keeps "No activity for N days" underneath it, since written copy names the task rather than reciting how long it has been. With no copy it reads exactly as it did before. When a reply has been read it also carries what was proposed and one button that performs it, labelled with the verb rather than "Apply" (Split the task, Make it smaller, Mark it blocked, Snooze this quest) because it changes the person's tasks without a second confirmation; pressing it takes the banner away, since every action but a snooze ends the stall. Saving a check-in retires the proposal on screen at the same moment the database retires it. When the plain wording is there because their credits ran out, the banner says so — the one skip reason they can act on. POST /api/quests/[id]/stall/suggestion validates its body with a strict schema and carries the notification id and nothing else: what is applied is what the worker stored and the person read, and a body naming an action is refused rather than ignored. It answers 403 for somebody else's notification, 409 for the two ordinary races, and a fixed line for anything unexpected, with the database's own message kept to the server log. (#543)

  • Stall digest emails carry the same copy, under the quest it belongs to, in both the text and the HTML parts, and escape it like every other value. renderStallDigest() stays pure — the copy arrives frozen in the batch, an item without the field renders as it always did, and a retry under the same idempotency key is byte-identical. With the AI flag off the paragraph is dropped before the send and the item renders as one written before this feature existed. (#543)

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

Database

  • A stall notification can now hold a nudge written for it and one action proposed from the reply, and a worker has an outbox to produce both through. quest_stall_notifications gains nudge_status/nudge_copy/nudge_task_id and suggestion_status/suggestion_action/suggestion_copy/suggestion_payload/suggestion_task_id, each half with its own attempt count, lease stamp and skip reason, under two new enums (stall_ai_status, stall_suggestion_action) and three new stall_event_type values (nudge_generated, suggested, suggestion_applied). Copy is bounded at 600 characters and a suggestion's at 400, because "at most one short paragraph" is a rule the reader should not have to trust a model to keep: a model that ignores it fails the write, and nudge_copy is null — the only condition any reader tests — is also what an outage, a spent attempt budget, a refused charge and a catalog with no usable persona all produce. A before update trigger queues a fresh nudge and drops the old copy when stalled_since moves to a different idle period; the suggestion is deliberately not reset with it, because it belongs to the note, and the note survives re-detection. (#543)

  • The generation outbox is a lease, and every transition out of it presents that lease. claim_stall_nudge() / claim_stall_suggestion() take one row with for update skip locked and a five-minute lease, return everything the prompt needs — the quest title, the persona, the idle count, the task that went quiet (stall_focus_task()), the note for the reply half — and hand back the claimed_at they wrote. complete_*, fail_* and skip_* all match it and return false, writing nothing, when the row no longer holds it. Without that, a worker whose provider call outran its lease could overwrite a row another worker had since reclaimed, answered and charged the owner for, and queue a third chargeable generation of the same nudge; it also covers a note rewritten mid-flight and an idle period that ended before the answer arrived. stall_persona_for() decides which model may speak for a quest: its own persona when the owner's current plan_tier covers it, else the cheapest zero-cost persona that tier allows — a quest keeps its model_persona_id through a downgrade on purpose, and this is a service-role path where user_can_use_model_persona() cannot be used, because it reads auth.uid(). A skip records why in a closed vocabulary (insufficient_credits, no_persona, other), gives the attempt back, and is retried a day later, because "no credits right now" is not "never". (#543)

  • stall_checkin is charged to the quest owner's own monthly allowance rather than to the platform, and both _kind guards — charge_ai_credits_for() and charge_system_ai_credits() — are replaced together so the vocabulary can never be accepted by one and refused by the other. This reverses, deliberately and narrowly, the policy 20260908120000_system_ai_credits.sql set for background work; what makes this feature different is that the only way it can cost anything is if the person chose a paid persona for that quest themselves. A quest with no persona falls back to Questify Free at zero credits, which books no ledger row and touches no balance; and since every paid persona is required_tier = 'pro' while the free tier's allowance is 0, a free-tier account can neither select a persona that would charge it nor be charged. An exhausted balance raises 42501, the nudge is skipped, and every reader falls back to the static notification. system_ai_budgets' stall_checkin row is deleted and the admin platform-spend surface no longer lists it. (#543)

  • log_stall_check_in() finally does something with what it is given: a note queues itself to be read, and every check-in retires whatever the last one produced — a new note because the proposal answered a reply that has been replaced, a cleared one because it answered a reply that has been withdrawn. A check-in with no note still records the acknowledgement and queues nothing. apply_stall_suggestion() performs the one proposed action as the owner (security invoker, so their own policies and triggers decide, template locking included), reads the row for update so a double click or a retried POST applies once, and logs its event and stamp before the mutation, because every action that touches a task ends the stall through #605's triggers and the row is gone by the time it returns. stall_suggestion_actions_for() narrows the fixed set to what a quest would actually accept before the model sees it — a template task may be worked but not reworded, a quest with a fixed list takes no new tasks — and the write re-checks at store time, because a one-click button that only ever errors is worse than no button. A split rewrites the original task into the first piece and appends the rest, setting no sort_order at all: tasks_assign_sort_order() overwrites what a client supplies and tasks_guard_placement() (#576) refuses any later change. (#543)

  • get_active_stall_notifications() and enqueue_stall_email_digests() carry the generated copy to the two places a nudge is read. The digest freezes it into stall_email_batches.items at enqueue time, and only when it is ready: the worker retries a batch under the same Resend idempotency key and Resend refuses a retry whose body differs, so copy that arrives after the batch was written goes out with the next stall rather than this one. The banner read was dropped and recreated — adding OUT parameters changes a function's row type, which create or replace cannot do — and its grants re-applied explicitly, closing an anon grant the original's revoke … from public had left in place. (#543)

  • A stall generation's credits no longer depend on the worker surviving it. The ledger reference a nudge or suggestion is charged under is now minted by claim_stall_*() and stored on the row beside the lease (nudge_credit_ref, suggestion_credit_ref), rather than made up in the worker and refunded only by its own catch — which a deploy, an OOM or a killed machine never runs. reap_stall_claims(), run at the top of both claims, takes back every lease that has lapsed: it refunds the reference and returns the row to pending, or to terminal failed when its attempts are spent, so a worker that dies on the last permitted attempt no longer leaves the row in generating where no claim could ever reach it again. The next claim of a row also settles whatever the previous one left behind, and a before-delete trigger refunds a row #605's triggers delete mid-flight. refund_ai_credits() returns only the net still owed, so all of these are safe to overlap with a live worker's own refund; only delivery keeps the charge, because the move to ready clears the reference. Both claims were dropped and recreated for the new credit_ref column and keep their service-role-only grants. (#543)

  • A client can no longer choose what a stall row refunds, and no in-flight charge is stranded when a claim is retired. The owner-wide UPDATE policy on quest_stall_notifications had covered the new credit-reference columns, so an owner could copy any ref_id from their own ledger onto the row, touch a task, and have the delete trigger refund it. stall_notification_guard_credit_refs() now refuses any client write to either reference before any other trigger runs, and every refund a client action can reach goes through stall_refund_credit_ref(), which refunds only when every entry under the reference is the row owner's own stall_checkin charge or refund. And a reference now exists only while its half is generating: moving to ready keeps the charge, and moving anywhere else — a cleared or rewritten check-in note, a new idle period, a failure, a skip, the reaper — refunds it in the same statement. The settle trigger fires on every update, because a new idle period moves nudge_status from inside the reset trigger. (#543)

  • Deleting the task a ready suggestion names now retires that suggestion instead of leaving its button on the banner. The FK nulled suggestion_task_id and nothing else — #605's resolution fires on task insert and update, not delete — so the banner kept offering to split, shrink or block a task that no longer existed and the applier refused it. A trigger on the notification (the FK's own SET NULL is an update of it, whatever deleted the task) now clears a ready, unapplied non-snooze suggestion the way a withdrawn note does, and queues nothing in its place: the note was about the task the person just removed, and re-reading it would charge them for it. A snooze needs no task and is kept; an applied suggestion is history and is kept. Resolving the whole stall on delete was rejected because the daily scan, which measures tasks.updated_at, would raise it again the next morning. The migration also retires any suggestion already in that state, and the applier route now answers the "names no task" race with 409 rather than 500. (#543)

  • A stall generation's charge and reprice are now bound to the claim's lease. The worker had charged through charge_ai_credits_for() and repriced through reprice_ai_credits(), which know nothing of the notification, so a worker paused between its claim and its charge could lose the lease to the reaper — which settled the still-empty reference — and then debit a reference no row pointed to any more, stranding the charge if it died. charge_stall_claim() and reprice_stall_claim() lock the row, check it is still generating under that claim's lease and reference, and only then move money, in the same transaction; they read the payer from the row rather than taking one, and return NULL, charging nothing, when the claim no longer holds its row. The worker treats that as a superseded claim: no provider call, nothing written. (#543)

  • The same goes for the nudge: deleting the task a ready nudge names now retires its copy instead of leaving the banner quoting a task that no longer exists. The copy is cleared, so every reader falls back to the static "No activity for N days" wording, and nudge_status goes to failed rather than back to the queue, because a new nudge would charge the owner for having deleted a task; a new idle period re-queues one as it always has. A nudge written about the quest itself, naming no task, is untouched, and the migration retires any nudge already in that state. A digest enqueued before the deletion keeps the copy it froze, the same limit the AI flag has. (#543)

  • A stall charge no longer outlives a rollback or a dismissal. The reaper that refunds a lapsed claim ran only from inside the two claim functions, which the worker calls only while NEXT_PUBLIC_FEATURE_STALL_AI is on — so a worker killed after charging, followed by a deploy that turns the flag off, left the owner's credits spent. reap_stall_claims() now also runs from pg_cron every five minutes as reap-stall-claims (listed in docs/ops/scheduled-jobs.md), independent of the flag and the worker. And dismissing a stall now retires whatever is being written for it in the same statement — a generating nudge goes to failed, a generating reply is cleared — which the settle trigger refunds; before, the completion still landed and kept its charge for copy a dismissed row never shows. It is a trigger on dismissed_at, not an edit to dismiss_stall_notification(), because the owner's UPDATE policy lets a client set the column directly. Anything already ready when the person dismisses was delivered and keeps its charge. (#543)

Tooling & Configuration

  • The worker runs a fourth loop beside investigations, stall emails, event reminders and deadline reminders. It drains both halves of the stall check-in from one poll, replies before nudges — someone who took the trouble to type why they are stuck has earned the spend ahead of someone who has not been asked yet — and is gated on NEXT_PUBLIC_FEATURE_STALL_AI and NEXT_PUBLIC_FEATURE_STALL_DETECTION, reporting "AI stall check-ins are NOT running" rather than sitting quiet when the first is on without the second or without any provider key (OPENROUTER_API_KEY alone is enough). Provider calls go through the worker's existing generateInvestigation() rather than a second copy of the fetch logic, with no images, no documents and no web search. The spend is reserved before the call and returned if anything downstream of it fails — an empty answer, a completion the database refuses, a lease that has moved on — because the charge buys a nudge on the row, not an API response; and when a provider substitutes a cheaper model for the one asked for, the reservation is repriced to what actually answered, the same correction processNextInvestigation() has made since #528. STALL_NUDGE_POLL_INTERVAL_MS defaults to a minute. (#543)

  • The thirteen type aliases the app imports from its database types — QuestStatus, TaskStatus, PartyMemberRole and the rest — moved out of apps/web/src/types/supabase.ts and into a new apps/web/src/types/database.ts, which re-exports the generated types beside them. pnpm supabase:typegen:local redirects supabase gen types over the whole of supabase.ts, so those aliases survived only until somebody regenerated: the first real run on this branch deleted all thirteen and left twenty-six files unable to compile. Imports that name an alias now come from @/types/database; the other fifty-five importers are untouched, because everything they use is generated and a regeneration keeps it. types/database.test.ts pins that supabase.ts exports exactly the eight names the generator writes and nothing else, so the next person to hand-add something there finds out from a test rather than from a broken build. Four aliases are spelled out rather than read from an enum, which the test also records: those columns are text with a CHECK, so the generator can only see string. (#543)

  • The stall check-in worker no longer charges for a nudge that ignores the one thing it is for, and no longer loses a skip it could not write. A nudge whose copy does not quote the stalled task's title — compared forgiving case, spacing and curly quotes, not paraphrase — is refused like any other unusable answer: the credits go back and the attempt is failed, because a generic paragraph is what the static notification already says for free. Every skip_stall_nudge / skip_stall_suggestion call now reads its result. A skip that errors falls back to fail_stall_*() under the same lease, so the row returns to pending or goes terminal instead of sitting in generating with its attempt spent — which, on the last attempt, claim_stall_*() would never pick up again; only when the failure cannot be written either does the error reach the loop's backoff. (#543)

  • STALL_NUDGE_POLL_INTERVAL_MS now actually reaches the worker under pnpm dev and pnpm build, along with five older worker settings that never did: STALL_EMAIL_POLL_INTERVAL_MS, EVENT_REMINDER_POLL_INTERVAL_MS, DEADLINE_REMINDER_POLL_INTERVAL_MS, ATTACHMENT_REAPER_INTERVAL_MS and QUESTIFY_SITE_URL. Turbo runs those tasks in strict env mode, which passes a task only the variables named in its env list, and none of the six were in turbo.json — so each was silently dropped and the worker fell back to its default. All six are now in the build and dev lists, and the nudge interval is documented in .env.example beside the other three. scripts/worker-env-catalog.test.mjs reads every key apps/worker/src/env.ts loads and fails when a non-NEXT_PUBLIC_ one is missing from either Turbo list, or when any is missing from .env.example; against the files before this change it fails three of its four checks. (#543)

Tests

  • supabase/tests/stall_check_in.sql (90 assertions) runs a real check_stalled_quests(7) over the fixture quests and then follows the whole loop: which task the nudge names and why, the claim's lease and attempt count, that a second claim never takes the leased row, the copy and its event, blank copy refused and over-length copy caught by the constraint, a failure returning to the queue and then going terminal, a skip that is not a failure and is retried a day later, a moved stalled_since queueing a fresh nudge, a note queueing itself to be read while a bare acknowledgement does not and a withdrawn note retiring the proposal it earned, a proposal the quest would refuse rejected rather than stored, the four applications, and a stranger who cannot even name the row because RLS hides it. The persona is asserted from both sides of a downgrade, and the lease from both: an answer, a failure or a skip presented under a stamp the row no longer holds is refused and writes nothing, while the one it does hold is recorded. The money is asserted from both ends — a free persona books no ledger row, a paid one costs its own weight with no multiplier, an empty balance refuses it with 42501, and every row written is the owner's own spend rather than a platform-funded one. supabase/tests/stall_check_in_concurrency.sql (8 assertions, dblink) double-clicks one suggestion from two sessions and asserts both refusals the applier can give: a split resolves the stall, so the second caller finds no row, while a snooze leaves the row standing and the apply-once stamp is what refuses — the case that would pass with the guard deleted if only the split were tested. rls.sql 405 → 414: no stall-generation or platform-spend function is reachable by a client role, the applier and the banner read are signed-in only, the reset trigger is not an RPC, and neither anon nor another signed-in user can read a notification's generated copy or check-in note. (#543)

  • Worker Vitest for the new loop across prompt, generate, loop and the digest sender: that the prompt names the persona, quotes the stalled task and offers only the actions this quest would accept; that a model's JSON survives a code fence and a preamble but prose, broken JSON, a one-piece split and a disallowed action are refused; that over-long copy is cut at a sentence boundary; that the spend is reserved before the provider is called and returned when the answer is empty, the completion errors or the lease has moved on, while a superseded lease spends no attempt belonging to the claim that replaced it; that a substituted model is repriced and an unrepriceable one is recorded honestly at what it cost; and that the digest carries the paragraph with the flag on and drops it with the flag off. Web Vitest for the banner and the applier: the static wording stands when no copy was written and appears once when it did, the proposal shows only while it is ready and unapplied and disappears when a check-in is saved, the button says what it will do, applying posts the id alone, a refusal leaves the banner up with the reason, a raw database error never reaches the response body, and the flag off strips the generated fields while keeping what the person did. (#543)

  • apps/web/e2e/stall-check-in.spec.ts covers the flow end to end, which is the part every test above mocks away: the owner's own policies deciding what the applier may write, #605's triggers deleting the notification the moment a task is touched, and the page that comes back. A stall is two weeks of not touching something and a proposal is a worker's answer to a reply, so neither can be clicked into existence — the fixture writes the row a worker would have written and the test drives it from there. Five cases: the static wording with the AI flag off on a row generated while it was on, the generated nudge and its verb-labelled button with it on, a block that moves the task and takes the stall and the banner with it while leaving its event behind, a snooze that leaves the row standing and stamped and does not come back on reload, and a check-in that retires the proposal on screen and in the row. Live mode only, like every other seeded spec here. (#543)

  • supabase/tests/stall_check_in_crash_safety.sql (56 assertions) simulates a worker dying the only way SQL can — a charge under the claim's reference, then nothing until the lease lapses — and asserts the refund on reclaim, the fresh reference and counted attempt, that the dead lease completes nothing, that a delivered nudge keeps its charge through a later idle period, that a failure refunds its own charge even when the worker's refund never ran, that a dead final attempt goes failed and is refunded while a live lease is left alone, that a row deleted by task activity mid-flight is refunded, the same for the reply half; that an owner cannot write either credit reference while the rest of their row stays theirs, that a reference which is not a stall charge of the row's owner is never refunded, that clearing a check-in note refunds the reply in flight and a new idle period the nudge in flight; that a charge or reprice presented under a lease or reference the row no longer holds moves no money — including a worker that wakes after its lease was reaped — while the claim that does hold the row is charged to the quest's owner; and that none of it is a client RPC. Removing the reaper from the claims fails seven of them, dropping 20261007120000 fails seven more, dropping the lease check from the charge fails three, and dropping 20261011120000 fails six — the scheduled reaper, and a dismissal (through the function or written straight to the row) refunding both halves in flight while the late completion writes nothing. (#543)

  • supabase/tests/stall_suggestion_orphaned_task.sql (14 assertions): deleting a task leaves the stall standing but retires the ready block suggestion that named it — status, action, copy and payload cleared, the note kept, nothing re-queued — and a stale button then gets the ordinary "nothing to apply" refusal; a snooze survives its task's deletion and still applies; an applied suggestion keeps its record; a nudge quoting the deleted task loses its copy and is not re-queued until a new idle period, while one that named no task is untouched. Dropping 20261008120000 fails three of them, and 20261010120000 two more. (#543)