Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions .agents/work/out-of-credits-ux.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# Out-of-credits UX — unmissable state + fail-early on credit-gated ops

Status: active (draft)
Last updated: 2026-07-03
Owns: the out-of-credits FAILURE experience — proactive clarity + preflight early-fail. Refines Slice 1 of [`billing-usage-and-topup.md`](./billing-usage-and-topup.md) (which owns the broader billing/usage/top-up surface).
Supersedes: —
Implementation: `web/src/components/app-shell.tsx` (+ session views) · `cloudflare-workers/api-edge/src/index.ts` · sessions-api runtime provision/accept + fallback classifier
Public docs: —

## Trigger

2026-07-03: a halted autumn org (free plan, balance ≤ 0, `is_halted=1`). Every turn failed at sandbox provision with a deep `402 {"error":"credits exhausted — top up to resume"}`, retried ~6× via `runtime.fallback`, and surfaced as a generic runtime error. Two problems flagged:

- **Not unmissable.** The halted state isn't unavoidably in the user's face at the moment they act.
- **Fails late + opaque.** An op we KNOW can't succeed (org halted / balance ≤ 0) still runs deep into provisioning before dying — and then loops on a terminal 402.

## What already exists (do NOT rebuild)

- **`HaltBanner`** (`web/src/components/app-shell.tsx:247`): polls `getAutumnBilling` (`isHalted`), renders a top-of-content "you're out of prepaid credits — top up" banner on every page except `/billing`. Autumn orgs only (legacy 404s → no banner).
- **Edge halt-gates** on some paths: session create (`index.ts:~443`, with a self-heal re-check) and wake (`~859`) return early when `is_halted`.
- **Runtime credit signal:** on a 402 the runtime posts `error.runtime {code:"insufficient_credits"}` + a user message, classified `credits_exhausted`.
- **Billing top-up UI** (`web/src/pages/Billing.tsx`) + billing doc **Slice 1** (reactive exhaustion CTA, low-balance banner, fast un-halt).

This is **gap-fill on top of those**, in two parts.

## Part A — Make "out of credits" unmissable, where the user acts

The banner exists but is a thin top strip and is passive (it doesn't touch the control the user is about to click).

1. **Raise prominence** of the *halted* state (distinct from Slice 1b's low-balance banner): stronger treatment (icon + emphasis/destructive color, not the muted `pending` style) and **sticky** at the top of the content area so it stays visible while scrolling. Keep the top-up CTA.
2. **Cover the moment of action** (see Part B3): show the halted state inline at the composer / "New session" / "Run", not only as a page-top strip — a user who never scrolls up still can't miss it.
3. **Verify it's live:** the banner polls every 30s; after Part B's fast un-halt (Slice 1c) confirm `isHalted` flips within the poll window post-top-up.

No new data needed — `getAutumnBilling.isHalted` / `creditsRemainingCents` already exist.

## Part B — Fail credit-gated ops EARLY and CLEARLY

Principle: **if we know an operation can't succeed, refuse it at the boundary with a specific message — never let it run deep and die generic.**

Two facts that shape the design (verified against the deployed topology):

- **The public `/v3` API does NOT go through the CF edge.** `api.opencomputer.dev` is
sessions-api direct (Fly); only dashboard traffic and the sandbox API ride the edge.
So the existing edge halt-gates (`index.ts:443/859`) can never be the authoritative
check — an SDK/API user bypasses them entirely. Authority has to live in sessions-api.
- **sessions-api has no local halt state, and shouldn't buy one with a per-turn
cross-service read.** The halt lives in the edge D1 / Autumn. A synchronous preflight
against either adds a network hop to EVERY turn to save one doomed provision attempt
on the rare halted org — wrong trade.

The resolution: **the provision 402 IS the preflight — remember it.**

1. **Classify 402-credits at provision as TERMINAL — kill the fallback loop.** The
last-known-good-runtime fallback exists for missing/corrupt snapshots (infra);
`402 credits exhausted` is org-level — no build in the ladder can succeed. One
attempt → typed terminal `insufficient_credits` + ONE clean user-facing message
(reuse the runtime's existing `insufficient_credits` event shape so the dashboard
already knows how to render it). De-noises telemetry too (today these log ~6× as
generic `provision` errors).
2. **Halt memo (fail-fast without new coupling).** When any credit-gated op hits a
402-credits, sessions-api memoizes `halted_until` for the org (in-process or a
session-row-adjacent note, TTL ~60s, cleared by any successful provision). While the
memo is live: session-create and message-append return a **synchronous typed 402**
(`{error:{type:"insufficient_credits", message, top_up_url}}`) and turn-accept
parks instead of provisioning. First doomed op pays one provision round-trip;
everything after fails instantly; top-up self-heals via TTL — zero new cross-service
reads, no config, no sync job.
3. **Gate the UI controls.** When `isHalted`, disable/replace the credit-gated
affordances — "New session", the composer's send, "Run" — with an inline "Out of
credits — top up to resume →" (reusing the halted state + Slice 1a's CTA). If a
request fires anyway (race / API user), B2's memo returns the same typed error and
the client renders the same CTA.
4. **Decide the input's fate: park-and-resume (recommended) over consume-and-fail.**
When a turn dies on provision-402, the user's message never ran. If the turn
consumes the input (the model-402 path's anti-spam behavior), the user must RESEND
after topping up. Better: leave the input window unconsumed, suppress re-fires with
the halt memo, and on un-halt (Slice 1c's hook) re-kick sessions with pending
input — "top up and your agent picks up where you left off" is exactly the durable-
sessions promise, and it falls out of machinery that already exists (wakeups +
input cursor). The model-call-402 mid-turn keeps today's consume behavior (the turn
DID run and answered with the credit notice).

**Credit-gated = anything that provisions compute or spends managed model credits:**
create session, send message / start a turn (including watch-delivery wakeups — a
watched PR event on a halted org must park, not loop), create/wake a sandbox. Read-only
views (history, billing, settings) stay open.

## Which changes where

| Area | File | Change |
|---|---|---|
| Dashboard shell | `web/src/components/app-shell.tsx` (`HaltBanner`) | stronger + sticky treatment; keep CTA (A1) |
| Dashboard sessions | `web/src/pages/Sessions*.tsx` / `SessionDetail*` + composer | inline halted state at the action; disable send/new/run when `isHalted` (A2 + B3) |
| Dashboard client | `web/src/api/client.ts` | no data change (`getAutumnBilling` suffices); render the typed `insufficient_credits` error inline (ties to Slice 1a) |
| Edge | `cloudflare-workers/api-edge/src/index.ts` | keep the existing `:443`/`:859` gates as the dashboard-path fast refusal; nothing new — the edge is NOT the authority (public /v3 bypasses it) |
| sessions-api | provision fallback classifier + accept + create/messages routes | 402-credits terminal (no `runtime.fallback` retry, one clean user event); halt memo → sync typed 402 on create/messages + park at accept; un-halt re-kick of parked input |

## How it works (flow)

1. Balance ≤ 0 → edge `model_meter` halts the org (`is_halted=1`) — existing.
2. Dashboard reads `isHalted` → **sticky halted banner** + **credit-gated controls disabled with an inline top-up CTA** (Parts A + B3).
3. If a credit-gated request still arrives (API/SDK user, or a UI race), the FIRST one pays a single provision attempt and dies typed + terminal (B1); the halt memo then makes every subsequent create/message fail **synchronously** with the same typed 402 (B2). Pending input parks — no retry loop, nothing lost (B4).
4. Top-up → Autumn credits land → fast un-halt (Slice 1c) → `is_halted` flips → banner clears + controls re-enable within the poll window → parked sessions re-kick and resume the turn the user already asked for.

## Relationship to billing-usage-and-topup.md

Refines **Slice 1** ("clear path when credits run out"): adds (A) the *unmissable/sticky* halted state + control-gating and (B) *preflight* early-fail + terminal-402 classification — the "fail before it starts" half that Slice 1a (reactive CTA after a failure) doesn't cover. Deferred to the billing doc: usage/remaining breakdown (S2), monthly auto-top-up cap (S3), per-agent attribution (needs `usage-not-wired`).

## Open questions (1–3 resolved in Part B, kept for the record)

1. **Preflight choke point — RESOLVED: sessions-api, via the 402 memo.** The edge can't
be the authority (public `/v3` is direct-to-Fly and bypasses it); a synchronous
cross-service halt check on every turn is the wrong trade. The memo gives fail-fast
authority to sessions-api with zero new coupling; the existing edge gates stay as a
dashboard-path nicety.
2. **Credit-gated route list — RESOLVED:** `POST /v3/sessions`,
`POST /v3/sessions/:id/messages` (and anything else that kicks a turn — watch
deliveries included, at accept), sandbox create/wake (edge-owned). Read-only routes
untouched.
3. **SDK/API users — RESOLVED:** the sync 402 body is
`{error:{type:"insufficient_credits", message:"Out of prepaid credits — top up to
resume.", top_up_url}}`; same shape from the memo path and the terminal turn event,
so one client rendering covers both.
4. **Prominence bar** — sticky banner + control-gating (recommended) vs a full blocking
interstitial when fully halted. Lean: no interstitial — read-only use (reviewing
past sessions, billing) must stay frictionless; the gated controls ARE the block
where it matters.
5. **NEW — memo storage:** in-process Map (dies on deploy — fine, TTL 60s) vs a column.
Lean: in-process; the cost of a miss is one extra doomed provision attempt.
18 changes: 16 additions & 2 deletions web/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,25 @@ function errorMessage(body: unknown, status: number): string {
return `Request failed: ${status}`
}

/** An error carrying the HTTP status, so callers can branch (e.g. 404 = not-found vs a real failure). */
// The typed discriminator sessions-api puts on `{error:{type,…}}` (e.g.
// "insufficient_credits"), so callers can branch on the reason, not the status alone.
function errorType(body: unknown): string | undefined {
if (body && typeof body === 'object') {
const err = (body as Record<string, unknown>).error
if (err && typeof err === 'object' && typeof (err as Record<string, unknown>).type === 'string') {
return (err as Record<string, unknown>).type as string
}
}
return undefined
}

/** An error carrying the HTTP status + the API's typed reason, so callers can branch
* (e.g. 404 = not-found; 402 `insufficient_credits` = out of credits → top-up CTA). */
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly type?: string,
) {
super(message)
this.name = 'ApiError'
Expand Down Expand Up @@ -123,7 +137,7 @@ export async function apiFetch<T>(

if (!res.ok) {
const body: unknown = await res.json().catch(() => ({}))
throw new ApiError(errorMessage(body, res.status), res.status)
throw new ApiError(errorMessage(body, res.status), res.status, errorType(body))
}

if (res.status === 204) {
Expand Down
22 changes: 12 additions & 10 deletions web/src/components/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
Check,
Menu,
Loader2,
CircleAlert,
type LucideIcon,
} from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
Expand Down Expand Up @@ -254,17 +255,18 @@ function HaltBanner() {
})
const halted = data?.isHalted ?? false
if (!halted || location.pathname.startsWith('/billing')) return null
// Sticky + error-toned so the halt stays in view while scrolling and reads as a hard
// block, not a muted notice (out-of-credits doc, Part A1). Sits just below the top bar.
return (
<div className="border-status-pending/20 bg-status-pending-bg text-status-pending border-b px-4 py-2.5 text-center text-sm sm:px-8">
Your agent sessions and sandboxes are paused — you&apos;re out of prepaid
credits.{' '}
<Link
to="/billing"
className="font-semibold underline underline-offset-2"
>
Top up &amp; turn on auto-recharge
</Link>{' '}
to resume now and prevent future pauses.
<div className="border-destructive/40 bg-status-error-bg text-destructive sticky top-14 z-20 flex items-center justify-center gap-2 border-b px-4 py-2.5 text-center text-sm font-medium md:top-16 sm:px-8">
<CircleAlert className="size-4 shrink-0" />
<span>
Your agent sessions and sandboxes are paused — you&apos;re out of prepaid credits.{' '}
<Link to="/billing" className="font-semibold underline underline-offset-2">
Top up &amp; turn on auto-recharge
</Link>{' '}
to resume.
</span>
</div>
)
}
Expand Down
15 changes: 15 additions & 0 deletions web/src/hooks/useHalted.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { getAutumnBilling } from '@/api/client'

// Out-of-credits (halt) state, shared across the halt banner and the credit-gated
// controls (composer, new-session) so they stay in sync and share one 30s poll.
// Autumn orgs only; legacy orgs 404 on /billing/autumn → error → treated as not halted.
export function useHalted(): boolean {
const { data } = useQuery({
queryKey: ['autumn-billing'],
queryFn: getAutumnBilling,
retry: false,
refetchInterval: (q) => (q.state.error ? false : 30_000),
})
return data?.isHalted ?? false
}
52 changes: 43 additions & 9 deletions web/src/pages/SessionDetail.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Send, Wrench } from 'lucide-react'
import { ArrowLeft, Send, Wrench, CircleAlert } from 'lucide-react'
import { notifyError } from '@/lib/errors'
import { useHalted } from '@/hooks/useHalted'
import {
getSession,
getSessionEvents,
sendMessage,
cancelSession,
archiveSession,
ApiError,
type SessionEvent,
} from '@/api/client'
import { SessionEventSchema } from '@/api/schemas'
Expand Down Expand Up @@ -55,6 +57,7 @@ type LevelFilter = (typeof LEVELS)[number]['value']
export default function SessionDetail() {
const { sessionId = '' } = useParams()
const queryClient = useQueryClient()
const halted = useHalted() // top-level: must run before any early return (Rules of Hooks)
const [draft, setDraft] = useState('')
const [level, setLevel] = useState<LevelFilter>('all')
const [confirmCancel, setConfirmCancel] = useState(false)
Expand Down Expand Up @@ -119,7 +122,14 @@ export default function SessionDetail() {
setDraft('')
invalidate()
},
onError: (e) => notifyError("Couldn't send the message.", e),
onError: (e) => {
// Race/stale-state fallback: if the server refused on credits, refresh the halt
// state so the banner + composer gating appear at once (don't wait for the poll).
if (e instanceof ApiError && e.type === 'insufficient_credits') {
void queryClient.invalidateQueries({ queryKey: ['autumn-billing'] })
}
notifyError("Couldn't send the message.", e)
},
})
const cancelMutation = useMutation({
mutationFn: () => cancelSession(sessionId),
Expand Down Expand Up @@ -290,33 +300,47 @@ export default function SessionDetail() {

{/* Steer */}
<div className="border-t p-3">
{halted && !archived && (
<div className="text-destructive mb-2 flex items-center gap-1.5 text-xs">
<CircleAlert className="size-3.5 shrink-0" />
<span>
Out of credits —{' '}
<Link to="/billing" className="font-medium underline underline-offset-2">
top up to resume
</Link>
.
</span>
</div>
)}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
if (draft.trim() && canSteer) steerMutation.mutate()
if (draft.trim() && canSteer && !halted) steerMutation.mutate()
}}
>
<ChatTextarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onSend={() => {
if (draft.trim() && canSteer && !steerMutation.isPending) {
if (draft.trim() && canSteer && !halted && !steerMutation.isPending) {
steerMutation.mutate()
}
}}
placeholder={
canSteer
? 'Send a message to steer the session…'
: 'Session archived'
halted
? 'Out of credits — top up to resume'
: canSteer
? 'Send a message to steer the session…'
: 'Session archived'
}
disabled={!canSteer}
disabled={!canSteer || halted}
className="min-h-10 flex-1"
/>
<Button
type="submit"
title="Enter to send · Shift+Enter for newline"
disabled={!draft.trim() || !canSteer || steerMutation.isPending}
disabled={!draft.trim() || !canSteer || halted || steerMutation.isPending}
>
<Send className="size-4" />
Send
Expand Down Expand Up @@ -351,6 +375,11 @@ function EventRow({ ev }: { ev: SessionEvent }) {
// Conversation messages — the signal.
if (ev.type === 'user.message' || ev.type === 'agent.message') {
const isUser = ev.type === 'user.message'
// Out-of-credits notice (from the runtime's failPreExec) → make the "top up" actionable.
const outOfCredits =
ev.type === 'agent.message' &&
(ev.body as Record<string, unknown> | null | undefined)?.code ===
'insufficient_credits'
return (
<li className="px-4 py-3">
<div className="mb-1 flex items-center gap-2">
Expand All @@ -362,6 +391,11 @@ function EventRow({ ev }: { ev: SessionEvent }) {
</span>
</div>
<p className="text-foreground/90 text-sm whitespace-pre-wrap">{text}</p>
{outOfCredits && (
<Button asChild size="sm" className="mt-2">
<Link to="/billing">Top up</Link>
</Button>
)}
</li>
)
}
Expand Down
Loading