Where in-process reply turns run
Decision for arbe-8e14 (2026-07-07). Status: decided, implementation in flight.
Goal
A thread never goes silent. Any message to any agent gets a visible “working” state within seconds, and exactly one visible ending — the reply, or a failure that says why — even when the model thinks for minutes. Turn duration is bounded by our turn cap, never by infrastructure.
The acceptance drill (glm-5.2 on openrouter as the adversarially slow model):
- Slow happy path: new thread, standard agent, ask for a long generation. 201 immediately; turn visibly running; reply lands after >30s wall; exactly one usage row. (The arbe-8e14 repro, promoted to regression test.)
- Visible failure: a failing turn (e.g. bogus model id) shows
dispatch.failedwith a readable reason within seconds. - Crash survival: restart the backstage mid-turn; the turn is redelivered and still ends exactly once.
- Fast path unharmed: haiku on a short question stays at today’s latency (queue hop adds ~a second).
Problem
In-process chat reply turns are handed to waitUntil() in apps/www/src/routes/api/threads/[id]/entries/+server.ts after the POST returns 201. Cloudflare cancels waitUntil work ~30s after the response, and cancellation is not a throw — so a slow turn (slow model, long generation) strands: no pi.assistant, no terminal signal, no usage row. Reproduced in thread qvnoqtpyqwtr.
Decision
A reply turn is an Absurd task. We already run the exact architecture this needs: Absurd (Postgres-native durable execution) driven by the backstage daemon on Fly (arbe-backstage, always-on, outbound-only).
Shape, mirroring the existing /api/wf/step seam:
POST /entriesspawns an Absurd task (reply-turn, queuedispatch— its own queue so chat never waits behind workflow steps) instead of callingwaitUntil. Poster still gets 201 immediately.- The conductor claims it and stays thin: one held-open POST to
/api/dispatch/runon www (behindBACKSTAGE_SECRET, same auth seam as/api/wf/step). A Worker has no wall-clock limit while the client holds the connection — the 30s cap is post-responsewaitUntilonly. LLM keys and@arbe/corestay solely in www. - The endpoint executes the turn during the request and returns when the terminal signal is written. It starts with an idempotency guard: terminal signal already present → skip and 200. That makes Absurd’s at-least-once redelivery (lease expiry, bounded retry) the self-heal for crashes.
Parameters that must line up:
- Turn cap: the endpoint aborts at ~10 min and writes
signal.dispatch.failed. - Queue
claimTimeoutis 120s: the conductor heartbeats in-flight directed turns (ctx.heartbeat(120)every 30s, arbe-796a), so the lease stays fresh for long turns while an interrupted one frees its claim in ≤2 min instead of waiting out a giant timeout. - The conductor’s request timeout for this handler sits between the two (~12 min); 4xx = permanent failure, 5xx/network = throw for Absurd’s retry, same split as
driveStep.
Sequencing: the seam (endpoint + handler + migration creating the queue/spawn RPC) lands inert first; the producer flip in /entries (and any other waitUntil(dispatch) call site — /api/wf/step dispatches the same way, so workflow step turns share the strand class and the fix) follows once the migration is applied. Interim stopgap regardless: a ~25s deadline race on the waitUntil path that writes signal.dispatch.failed so a stall is visible and reclaimable.
Rejected
- Cloudflare Queues — new platform concept (binding, custom SvelteKit worker entry, DLQ config), 15-min cap, buys redelivery Absurd already gives us.
- Hand-rolled Postgres claim table (SKIP LOCKED + lease) — Absurd is this, already installed, already operated.
- Streaming the reply on the open POST — the Worker only lives while the poster stays connected; wrong reliability model for “201 now, reply lands on the durable stream later”.
- Durable Objects / Cloudflare Workflows — compute+state or step-shaped durable execution we don’t need; state already lives in the durable stream.
- Hetzner VPS runner — viable someday-home for the daemon (it is just a process), but the Fly conductor already exists; no new host.
The sandbox/pi coding path (child thread) is unchanged — this decision covers in-process replies only.
Drill runbook
Prerequisites: both deploys live (conductor fly deploy --ha=false, then www). Task/queue inspection SQL and conductor log tailing → docs/system/ops/debugging.md (“Reply turns are Absurd tasks”).
Setup — do NOT drop messages into an existing shared house (a bare house thread gets filtered: no bot is a thread member, and mention-gating on stale fixture agents is unreliable). Make a deterministic target instead:
arbe agent create drillbot --house <fresh-or-own-house> --trigger always --json # plain bot, no prompt straitjacketarbe thread create <drillbot-id> [-m <model>] # 1:1 thread under the bot — always dispatchesTiming: arbe thread entries create returning IS the 201; arbe thread entries read blocks until the dispatch terminal — wrap the pair in shell timestamps.
- Slow happy path (measured 2026-07-07: 201 in 0.7s,
started+4.4s, reply +completed+61s, 1 usage row):
arbe thread create <drillbot-id> -m openrouter/z-ai/glm-5.2arbe thread entries create <thread> "Write a ~1500-word story about a lighthouse keeper. Full story, no summary."arbe thread entries read <thread> # expect signal.dispatch.started within seconds, reply + signal.dispatch.completed after >30s wallcd packages && bunx supabase db query --linked "select count(*) from public.usage_events where thread_id = '<thread>' and seam = 'reply'" # exactly 1 row- Visible failure (this IS the bogus-model test —
resolveReplythrows on an unknown slug,withDispatchSignalsnarrates; measured: failed terminal in 5.5s, task completes attempt 1, no retry):
arbe thread create <drillbot-id> -m openrouter/nope/does-not-existarbe thread entries create <thread> "hi"arbe thread entries read <thread> # signal.dispatch.failed: Unknown reply model "..." within seconds- Crash survival: repeat drill 1’s post, then mid-turn (after
signal.dispatch.started, before the reply):
fly apps restart arbe-backstagearbe thread entries read <thread>Measured: the Worker finishes the held request even though its client died — the reply and terminal landed on time (+71s). The orphaned claim then waited out the 15-min lease; redelivery hit the entryId idempotency guard (run 2 result {"outcome": "skipped"}, task completed attempts=2) — one reply, one terminal, ever. All four drills passed against prod 2026-07-07.
- Fast path unharmed (measured: 7.7s pre-flip → 8.2s post-flip; the queue hop costs ~0.5s):
arbe thread create <drillbot-id> # no override → default openrouter/z-ai/glm-5.3-flasharbe thread entries create <thread> "What is 2+2?"arbe thread entries read <thread>