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.
Superseded 2026-10-03: turns now run inside the backstage, so a restart kills the turn itself and there is no idempotency guard to hit. The redelivered wake task replays the turn’s recorded model calls and tool runs from its Absurd checkpoints, its appends dedupe by producer sequence, and the reply lands in one append with its director outcome. A tool that changes something and was cut off mid-run is not repeated; the bot is told it may or may not have run.
- 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>