本页还没有译文
本页 103 段还没译成简体中文,下面显示的是英文原文。Takeover & Detached Runs (Advanced)
还没翻译译自英文(tanstack.com)本页 103 段都还没译
A sandboxed coding agent can work for ten minutes. A browser tab does not last ten minutes. Users refresh, close the laptop, lose wifi, and hit a load balancer that lands the next request on a different replica.
Without durability wired, the disconnect is fatal: withSandbox's abort path
destroys the sandbox on every abort, deliberately, because closing the agent's
IO stream does not kill the agent process (a Docker exec survives its
client), so destroying the container is the only reliable way to stop it burning
tokens. Correct for a cancel. Ruinous for a refresh.
Durable runs split those two cases apart. A disconnect detaches: the agent keeps working, the sandbox stays up, and the run record remembers that nobody is watching. A later request takes the run over, replays what was already delivered, and continues streaming the rest.
This page is the wiring. It builds on two pages you should read first: The Run Journal (where the agent's output lives, and why it is a file rather than a pipe) and Resumable Streams (the delivery log a client reconnects to).
Durability is one opt-in, not two
withSandbox takes runs and durability. A run is durable only when both
are present, because either alone is useless: a record with no event log cannot
be replayed, and a log with no record cannot be found, claimed, or reaped. There
is no half-configured state. Pass one and you get exactly today's behavior,
silently and with no warning, because you have not asked for durability.
Pass the same RunStore chat persistence uses, so one record describes the
run instead of two that disagree.
Both routes must address the same backend stream
Read this before the snippets. A StreamDurability is bound to one run,
and durableStream(request, options) resolves which run through core's
resolveResumeRunId: the X-Run-Id header first, then ?runId on the
request URL. Core's own memoryStream uses the same resolver, so no two
durability adapters can disagree about which run a request names.
- A POST from
@tanstack/ai-clientcarries the run id in theX-Run-Idheader, not the URL. The POST URL stays byte-identical to a plain, non-durable chat request. - A GET join carries it in the URL instead:
joinRunrequests?offset=-1&runId=<runId>.
So durableStream(request, durableOptions) on both routes resolves the same
agent-runs/<runId> stream, whichever way the run id arrived. Nothing to rewrite,
nothing to force onto a copy of the URL:
import { durableStream } from '@tanstack/ai-durable-stream'
// The external Durable Streams backend every replica can reach. See
// ../resumable-streams/advanced for the full option set (auth headers, batch
// size, reconnect tuning).
export const durableOptions = {
server: 'https://streams.example.com',
streamPrefix: 'agent-runs',
}A request naming no run at all (neither header nor query) throws rather than silently producing into a stream no attach request could ever name:
durableStream: a runId is required: send it as an X-Run-Id header or a
?runId query param
That only bites a client that bypasses @tanstack/ai-client (a custom
fetch, a hand-rolled reconnect): make sure it sends one of the two, an
X-Run-Id header on the POST or ?runId on the URL. A mid-stream SSE
reconnect to the POST route still works. It arrives carrying Last-Event-ID and the
same X-Run-Id header the original POST used, so durableStream resolves the run and
honors the resume offset.
And a caller with no live Request at all (a cron, a Durable Object alarm())
synthesizes one from scratch instead. See the reaper's own
durabilityFor in Reaping & Retention.
Server: start a durable run
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence'
import { withSandbox } from '@tanstack/ai-sandbox'
import { durableStream } from '@tanstack/ai-durable-stream'
// The options from the section above.
import { durableOptions } from './durability'
// Your distributed LockStore. `InMemoryLockStore` is NOT enough here. See
// "Requirements" below.
import { locks } from './locks'
// Your `defineSandbox(...)` result.
import { sandbox } from './sandbox'
// Development stand-in. A durable run needs a store every replica can read;
// see ../persistence/build-your-own-adapter.
const persistence = memoryPersistence()
const { runs } = persistence.stores
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
// ONE adapter instance, handed to both the middleware and the transport, so
// the journal path and the delivery log describe the same run.
// `@tanstack/ai-client` sends `X-Run-Id` on every POST, which is exactly what
// `durableStream` resolves first, so this already addresses
// `agent-runs/<runId>`: the same stream the attach route below reads.
const adapter = durableStream(request, durableOptions)
const stream = chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages,
threadId,
// Required for a durable run. Omit it and `chatStream` throws
// `DurableRunIdRequiredError`. See "Requirements".
runId,
middleware: [
withPersistence(persistence),
withLocks(locks),
withSandbox(sandbox, {
runs,
durability: { adapter },
}),
],
})
return toServerSentEventsResponse(stream, { durability: { adapter } })
}Three things changed compared with a plain sandboxed chat endpoint, and each one is load bearing:
runs+durabilityturn on detach-on-disconnect and publishDetachableRunCapabilityon the bus, which is howwithPersistencelearns that an abort on this run is a detach rather than a cancel.runIdis forwarded, not generated. The journal path, the deterministic message-id generator, and the backend stream name are all derived from it, so a successor host can only resume a run whoserunIdit can recompute.- The adapter instance is shared between
withSandboxand the response, and it is keyed by that samerunId. That is what makes the attach route below address this very stream.
One thing must not be there: an abortController that mirrors request.signal.
A plain sandboxed endpoint mirrors it, because there a disconnect should end the run.
On a durable run it destroys the thing you are trying to protect. Aborting the run
makes chat() return at its cancellation check right after middleware setup, so the
harness adapter's chatStream is never called and the agent inside the sandbox you
just spent minutes building is never launched. Switch tabs while the UI still says
"starting the sandbox" and you come back to an empty log for a run that did nothing.
No takeover can recover it either, because an agent that never started wrote no
journal to replay.
The disconnect still reaches withSandbox without it. The durable transport notifies
the run the moment the response body is cancelled, without aborting it, and then:
withSandboxstampsdetachedSinceandsandboxKey, and publishes the detach verdict.- The run keeps draining into its still-open delivery log.
- A rejoining client tails that log.
Passing runs plus durability buys all of that. There is nothing else to wire.
A genuine stop is unaffected, because it arrives out of band (see Detach vs cancel). That is the only channel that can tell "the user wants this stopped" from "the user closed a tab", since both are the same socket close on the wire.
On memoryStream, raise the first-chunk deadline
A rejoin during the sandbox build fails with
Memory stream run produced no data within 100ms, and the run it calls gone is
healthy. memoryStream gives up on a from-start rejoin when the run produces no chunk
within firstChunkDeadlineMs, which defaults to 100ms. That default fits chat, where
an in-flight run's log already holds chunks. A sandboxed run emits nothing until
ensure has built the sandbox and cloned the repo.
Raise it on every handle for the run. The GET that serves the rejoin is the call
that actually applies it:
import { memoryStream } from "@tanstack/ai";
// Longer than your slowest `ensure`.
const FIRST_CHUNK_DEADLINE_MS = 15 * 60_000;
// POST (the producer) and GET (the rejoin) alike.
export function adapterFor(request: Request) {
return memoryStream(request, {
firstChunkDeadlineMs: FIRST_CHUNK_DEADLINE_MS,
});
}Failing fast buys nothing once you gate the rejoin on findActiveRun, which already
excludes a run that is really gone before the join is attempted.
Two things make this a backstop rather than the main defence:
durableStreamruns no first-chunk deadline at all. Itsreadparks for a live reader, so an empty in-flight log simply waits.- A fresh durable producer appends one
CUSTOMrun.acceptedchunk (RUN_ACCEPTED_EVENT) before it pulls the producer stream, so a rejoin attaches in milliseconds instead of waiting on the harness. That chunk also keeps the client from abandoning the join, sinceai-clientgives up on a rejoin that receives nothing for 2s and a sandbox build always takes longer.
Server: take the run over
The takeover happens in the GET handler that already serves resumes. Add a
driver and the same request that replays the log also claims the run and keeps
driving it.
Every adapter here comes from the same durableStream(request, durableOptions)
the POST route used, so the log this route replays, the log the drive appends
to, and the log the producing route wrote are provably one stream:
agent-runs/<runId>.
import { chat, resumeServerSentEventsResponse } from '@tanstack/ai'
import { withLocks } from '@tanstack/ai/locks'
import { claudeCodeText } from '@tanstack/ai-claude-code'
import { durableStream } from '@tanstack/ai-durable-stream'
import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence'
import { sandboxRunDriver, withSandbox } from '@tanstack/ai-sandbox'
// The same backend options as the POST route.
import { durableOptions } from './durability'
import { locks } from './locks'
import { sandbox } from './sandbox'
import type { StreamChunk } from '@tanstack/ai'
const persistence = memoryPersistence()
const { messages: messageStore, runs } = persistence.stores
/**
* The claim hands `drive` an `AbortSignal` that fires the moment this host loses
* ownership; `chat()` takes an `AbortController`. Mirror one onto the other so a
* lost claim actually stops the drive.
*/
function controllerFor(signal: AbortSignal): AbortController {
const controller = new AbortController()
const abort = (): void => controller.abort(signal.reason)
if (signal.aborted) abort()
else signal.addEventListener('abort', abort, { once: true })
return controller
}
export function GET(request: Request) {
async function* driveRun(input: {
runId: string
threadId: string
signal: AbortSignal
}): AsyncIterable<StreamChunk> {
// The client sent no history: it is reconnecting, not asking a question.
const stored = await messageStore.loadThread(input.threadId)
const stream = chat({
adapter: claudeCodeText('claude-opus-4-8'),
messages: stored,
threadId: input.threadId,
runId: input.runId,
abortController: controllerFor(input.signal),
middleware: [
withPersistence(persistence),
withLocks(locks),
withSandbox(sandbox, {
runs,
// `attach: true` is the whole difference: the harness tails the run's
// EXISTING journal instead of starting a second agent. It belongs
// here and never on `chat()`, which has no sandbox vocabulary. It
// is set only by an attach route, never by a POST handler.
durability: {
// `request` already names this run (`?runId` on an attach GET),
// so `durableStream` resolves the same `agent-runs/<runId>` the
// journal replay aligns against.
adapter: durableStream(request, durableOptions),
attach: true,
},
}),
],
})
for await (const chunk of stream) yield chunk
}
return resumeServerSentEventsResponse({
// The replaying adapter is the one adapter here whose `resumeFrom()` matters,
// and the offset it must return (`?offset=-1` from a join, or an SSE
// reconnect's `Last-Event-ID`) lives on the incoming request. `durableStream`
// resolves the run the same way on every route, `X-Run-Id` header first,
// then `?runId`, so this addresses the same `agent-runs/<runId>` the POST
// route wrote, whichever way a given request names the run.
adapter: durableStream(request, durableOptions),
driver: sandboxRunDriver({
request,
runs,
locks,
// Per-run log factory. Core resolves the id from this same request
// through the same `resolveResumeRunId` `durableStream` uses, so every
// call for this run talks to the same backend stream and `snapshot()`
// sees this host's own appends. The state lives in the Durable Streams
// backend, not this process.
durability: () => durableStream(request, durableOptions),
drive: driveRun,
}),
})
}The response is byte-identical whether or not you pass driver: it still
replays from the durability log. The drive runs beside it, appending to the run's
producer-side log, and the response tails what lands. That separation is what
lets a taken-over run keep chat()'s normal middleware path, so
withPersistence's onFinish still saves the transcript of a run that finished
while detached.
Everything about the takeover is total by construction. Every failure is logged and swallowed, and the response still serves the log:
| Situation | What happens |
|---|---|
| No run id on the request, or no record | Serve the log, drive nothing. |
| The record is already terminal | Serve the log, drive nothing. A second tab attaching to a finished run must still see the transcript. |
| Another host holds the claim | Serve the log, drive nothing. Two tabs attaching at once: one wins the lease and drives, the other tails. |
| The drive throws | Logged server-side. It cannot be reported to a response that is already streaming; the run's own RUN_ERROR event is that channel. |
Serverless platforms need a keep-alive for the background drive. Pass
waitUntil: (promise) => ctx.waitUntil(promise).
Client: reconnect and continue
A mid-stream drop needs nothing: useChat reconnects with the last offset and
the server replays from the log.
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function Chat() {
const chat = useChat({
connection: fetchServerSentEvents('/api/chat'),
})
return (
<button onClick={() => void chat.sendMessage('Refactor the auth module')}>
Send ({chat.messages.length} messages)
</button>
)
}A full reload is different. The page comes back with no Last-Event-ID, so it
has to ask which run is still going and then join it from the start with
joinRun (a read-only GET with offset=-1), which is exactly the handler
above. That GET is what claims the run, so joining and taking over are the same
request.
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { useEffect, useState } from 'react'
import type { StreamChunk } from '@tanstack/ai'
export function ResumeInFlight({ threadId }: { threadId: string }) {
const [chunks, setChunks] = useState<Array<StreamChunk>>([])
useEffect(() => {
const controller = new AbortController()
const connection = fetchServerSentEvents('/api/chat')
async function rejoin(): Promise<void> {
const response = await fetch(
`/api/chat/active?threadId=${encodeURIComponent(threadId)}`,
{ signal: controller.signal },
)
const body: unknown = await response.json()
if (typeof body !== 'object' || body === null || !('runId' in body)) return
const runId = body.runId
if (typeof runId !== 'string') return
for await (const chunk of connection.joinRun(runId, controller.signal)) {
setChunks((previous) => [...previous, chunk])
}
}
void rejoin().catch(() => {
// The run finished, or there was none. Nothing to rejoin.
})
return () => controller.abort()
}, [threadId])
return <p>{chunks.length} events replayed</p>
}The "which run" endpoint is RunStore.findActiveRun, which resolves a live run
from the stable threadId rather than the ephemeral run id a single turn may
mint several of. It is an optional store method, so feature-detect it.
import { memoryPersistence } from '@tanstack/ai-persistence'
const persistence = memoryPersistence()
const { runs } = persistence.stores
export async function GET(request: Request) {
const threadId = new URL(request.url).searchParams.get('threadId')
if (threadId === null) {
return new Response('threadId is required', { status: 400 })
}
// Optional on the RunStore contract, so a backend that omits it degrades to
// "no active run" instead of throwing.
const active = await runs.findActiveRun(threadId)
return Response.json({ runId: active?.runId ?? null })
}All four HTTP connection adapters (fetchServerSentEvents, fetchHttpStream,
xhrServerSentEvents, xhrHttpStream) expose joinRun. For NDJSON, pair it
with resumeHttpResponse on the server; the driver wiring is identical.
Detach vs cancel
This is the part that is easy to get wrong, and getting it wrong is expensive. A user pressing Stop and a user closing the tab produce the identical connection close. There is nothing in the disconnect to tell them apart.
So intent is never inferred from the disconnect. It arrives out of band, and there are exactly two bands, and either one is authoritative:
- Durable:
requestRunCancel(runs, runId)recordscancelRequestedon the run record. This is the only channel that reaches a run being driven by a different host than the one the cancel request landed on, which is the normal case for a detached run. - In-process: abort the run's own
AbortControllerwithRUN_CANCEL_REASON. Core reads that reason back when it buildsAbortInfo, soAbortInfo.cancelRequestedistruefor that abort andfalsefor a plain disconnect. Fast path when the cancel reaches the driving host.
A cancel endpoint should do both. requestRunCancel deliberately writes no
status: recording intent is not the same as the run having stopped, and only the
driver knows when the agent is actually dead and the sandbox is gone.
import { RUN_CANCEL_REASON, requestRunCancel } from '@tanstack/ai'
import { memoryPersistence } from '@tanstack/ai-persistence'
const persistence = memoryPersistence()
const { runs } = persistence.stores
/**
* Runs this process is currently driving. Only ever a fast path: a run driven by
* another replica is absent here, and the durable band is what reaches it.
*/
const driving = new Map<string, AbortController>()
export async function POST(request: Request) {
const body: unknown = await request.json()
if (typeof body !== 'object' || body === null || !('threadId' in body)) {
return new Response('threadId is required', { status: 400 })
}
const threadId = body.threadId
if (typeof threadId !== 'string') {
return new Response('threadId must be a string', { status: 400 })
}
const active = await runs.findActiveRun(threadId)
if (!active) return new Response(null, { status: 204 })
// Band 1: durable, so a remote driver observes it on its next teardown.
await requestRunCancel(runs, active.runId)
// Band 2: in-process, so a co-located driver stops immediately.
driving.get(active.runId)?.abort(RUN_CANCEL_REASON)
return new Response(null, { status: 204 })
}Populate driving where you create the run's AbortController: in the POST
handler for a fresh run, and in controllerFor on the takeover path.
On the client, chat.stop() sends the server nothing. A durable run then
continues, because a refresh looks the same. Call the endpoint too.
import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
export function StoppableChat({ threadId }: { threadId: string }) {
const chat = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
})
async function stopRun(): Promise<void> {
// Local: stop rendering the stream immediately.
chat.stop()
// Remote: tell the server this was intent, not a lost connection.
await fetch('/api/chat/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threadId }),
})
}
return <button onClick={() => void stopRun()}>Stop</button>
}
What each path writes
| Event | withSandbox | withPersistence | Delivery log |
|---|---|---|---|
Disconnect, durable, detachOnDisconnect on, no cancel recorded | Keeps the sandbox; writes detachedSince and sandboxKey | Writes nothing; the record stays 'running' | Left open, no terminal appended |
| Cancel (either band) | Destroys the sandbox, always, regardless of destroyOnComplete | Writes 'aborted' with finishedAt | Terminal RUN_ERROR, then closed |
| Disconnect on a non-durable run | Destroys the sandbox | Writes 'aborted' | Terminal RUN_ERROR, then closed |
The delivery-log column is what makes takeover work at all. A detached run's log
has to stay open and terminal-free: a closed log ends the attaching client's
replay at the prefix, and a stored synthetic RUN_ERROR is a chunk the takeover's
journal replay cannot reproduce, so alignment diverges and a perfectly healthy run
is recorded as 'failed'. See RunDetachedCapability
for how the verdict reaches the transport.
keepAlive / destroyOnComplete: false govern successful completion only.
They never keep a sandbox alive through a cancel.
Get this backwards in either direction and it hurts: a Stop button that only
calls chat.stop() leaks running sandboxes that keep spending tokens with nobody
watching, and treating every disconnect as a cancel kills a user's ten-minute
refactor on a wifi blip.
What cancel means on a provider that cannot kill
On a provider whose capabilities declare killableProcesses: false (Daytona,
Vercel, and Cloudflare among the bundled ones (see
the measured table)
and no signal reaches the agent process: kill() does not stop it, and no
AbortSignal crosses the provider boundary. There, the sandbox destroy the
cancel path performs is not cleanup, it is the cancel. It is the only mechanism
that actually stops the agent. Wire a cancel to anything less (mark the record
'aborted', close the log, skip the destroy) and what you have built is
"marked cancelled, still running": the UI says stopped while the agent keeps
working and the sandbox keeps billing.
Single-writer safety
Only one host may write a run, and the client has no safety
net below its offset de-dup. If two hosts both snapshot the log, both compute a
"remainder", and both append it, the same logical chunk lands twice under two
different offsets, so it looks new to the client, and the stream processor
applies text and tool-argument deltas unconditionally. You get doubled prose and
{"a":1}{"a":1} tool arguments. Takeover is by definition two hosts wanting one
run, so the exclusion has to be real.
sandboxRunDriver wires it for you, in three layers:
- A lease. The entire drive runs inside
LockStore.withLockon a per-run key, so the snapshot and every append after it are one critical section. A lease-backed lock aborts the drive's signal the moment ownership is lost. - An epoch. Each successful claim bumps
RunRecord.driverEpoch. The log re-reads it periodically and refuses to append once a higher epoch exists. This covers what a lease cannot: a lock whose renewal is coarser than the run's append rate, or one whose signal never fires at all. - Quiescence. The successor waits for the stored log to stop growing before
its first append, so a predecessor that is still writing is observed rather
than raced. The window defaults to
DEFAULT_FENCE_QUIET_MS(5 seconds) and is configurable withfenceQuietMs. If the log never quiesces, the drive fails instead of appending into a log another host is still writing.
Both authoritative channels are fenced
A run's facts live in two places: its event log and its record. Fencing only
the log does not remove the harm, it relocates it. A superseded driver whose
append is refused folds that refusal into a terminal runs.update, and the
record then reads 'failed' for a run the successor is healthily streaming.
Every consumer that branches on terminal status (isTerminalRunStatus,
findActiveRun, a status poller, a reaper) would then believe a live run had
died, on the authority of a host that no longer owns it.
So both seams are closed, over the same claim:
- The log. A superseded driver's
appendis refused. The first refusal latches the fence permanently shut: every later append refuses immediately, without a re-read. That permanence matters, because the refused append is followed directly by the recovery path's own terminalRUN_ERROR, and that log belongs to the successor, so a dead host's error chunk would fail the stream for every client attached to the healthy run. - The record. A terminal-status
updatefrom a lost claim is suppressed: it resolves without writing. Non-terminal writes still pass through, including a staledetachedSinceorsandboxKey. Those cannot make a live run look finished, and the successor owns and overwrites them anyway.
The practical upshot for you: a terminal status on the record is trustworthy. A status poller may believe it.
close() is outside both fences, deliberately. It runs on every teardown path
including the teardown caused by losing the claim, and a fenced close would
wedge the record at 'running' with every live tailer parked forever: a
durability read only ends when the log closes. A wedged run with parked clients
is worse than the write being prevented.
Errors you can branch on
import {
RunClaimLostError,
RunClaimNotAcquiredError,
RunDriverPipeOutsideClaimError,
} from '@tanstack/ai-sandbox'
function describeDriveFailure(error: unknown): string {
if (error instanceof RunClaimNotAcquiredError) {
// 'terminal' | 'unknown' | 'superseded': normal, not a bug.
return `not driving ${error.runId}: ${error.reason}`
}
if (error instanceof RunClaimLostError) {
return `superseded mid-drive at epoch ${error.heldEpoch}`
}
if (error instanceof RunDriverPipeOutsideClaimError) {
// A programming error: the options object was taken apart and `pipe` called
// outside `claim`, so there is no epoch to fence with.
return `run ${error.runId}: pipe ran outside its claim`
}
throw error
}
The first two are ordinary outcomes of a contended takeover and
resumeServerSentEventsResponse already swallows both. You will see them in
logs, not in responses.
What this is not
It is not airtight fencing. A predecessor paused (GC, VM suspend) for longer than
the quiescence window, between its last fence check and its append landing at the
backend, can still write one batch. Closing that needs a compare-and-set on the
durability write, which StreamDurability.append does not offer. The mitigation
is deployment-level: use a lease-backed distributed LockStore, and keep
fenceQuietMs above the lease's renewal interval.
Replay and divergence
A takeover does not resume the journal from where the dead host stopped. It re-reads the journal from byte zero and re-translates it, which reproduces chunks the client already has. Alignment is what makes that safe: the stored log is read once, the replay is verified against it by fingerprint, the matching prefix is suppressed, and only the remainder is appended and delivered. The log is the checkpoint, so there is no window in which a checkpoint and the log can disagree. The precedence rule this implements: the log wins for what clients see; the journal wins for where the taking-over driver resumes.
Two properties make the comparison possible, and both are already true on the journaled path:
- Ids are deterministic. A journaled run mints message ids from a run-scoped
counter (
<runId>-0,<runId>-1, …) rather than a timestamp plus randomness. - Alignment runs only on an attach. On a fresh run the transform's premise ("this stream is a replay of what is already stored") is false, and aligning anyway would match a fresh run's own chunks against pre-existing log entries and silently suppress them. That is data loss, not a slow path.
JournalReplayDivergedError
If the replay produces a different chunk than the log already holds at that
index, JournalReplayDivergedError is thrown with the index and both
fingerprints.
import { JournalReplayDivergedError } from '@tanstack/ai-sandbox'
function report(error: unknown): string {
if (error instanceof JournalReplayDivergedError) {
return `diverged at ${error.index}: stored ${error.stored}, replayed ${error.replayed}`
}
throw error
}Read it plainly: the agent's replay produced a different sequence of events
than the log already delivered. Translation stopped being deterministic. The
realistic causes are an id generator that is not run-scoped, a translator that
consults the clock, or a journal that was rewritten (most often a reused
runId).
What to do about it: treat it as a bug to fix, not a condition to recover from.
Do not catch it and continue. The log is authoritative and already went to the
client, so forwarding past a mismatch delivers a stream whose prefix and suffix
disagree about message identity, and the client cannot survive that. Log the
index and both fingerprints, let the run fail, and check runId uniqueness
first.
One tolerance exists. On adapters that splice host-tool-bridge events into their
output (@tanstack/ai-codex, @tanstack/ai-claude-code), the log holds CUSTOM
chunks fired by live tool execution, which a replay runs no tools to reproduce.
Alignment skips those as out-of-band, up to DEFAULT_MAX_OUT_OF_BAND_SKIP (64)
consecutive entries. The bound is what keeps this a tolerance rather than a
search: unbounded, a genuine determinism regression would scan forward looking
for any fingerprint that happens to match.
Configuration
All of these live under withSandbox(sandbox, { durability: { … } }).
| Option | Default | What it does |
|---|---|---|
adapter | required | The run's delivery-durable event log. Same instance you hand the transport. |
journal | /tmp/tanstack-runs (DEFAULT_JOURNAL_DIR) | Journal directory inside the sandbox. |
detachOnDisconnect | true whenever durability is wired | Whether a disconnect detaches instead of destroying the sandbox. |
attach | false | Read an existing run's journal instead of starting an agent. Set by an attach route's drive, never by a POST handler. |
pollIntervalMs | adapter default | Journal poll interval for providers that cannot follow a file. |
There is deliberately no detachedRunTtl here. The only actor that enforces a
TTL is reapDetachedRuns, which runs from a cron with no chat request in
flight, so it cannot read anything withSandbox publishes on the
per-request capability bus, so a TTL stored here could only ever go unread,
while the sweep took its own detachedRunTtlMs, and the two would silently
disagree. So the sweep's detachedRunTtlMs (milliseconds, no default, no
string parsing) is the single source of truth; see Reaping &
Retention for sizing it.
The reaper ships, but nothing schedules it. reapDetachedRuns from
@tanstack/ai-sandbox is the sweep. It reads the optional
RunStore.listReclaimable({ now, ttlMs }), which returns runs that are
'running', have a detachedSince, and whose detachedSince <= now - ttlMs
(inclusive). It drives each run its out-of-band journal probe says already finished
to a terminal status so the transcript lands, cancels and terminalizes the ones
past detachedRunTtlMs, and destroys the sandbox named by sandboxKey through
sandboxReclaimer. It never clears detachedSince: that marker is the evidence
its TTL accounting selected the run on. (The takeover path clears it, because a
viewer is attached again.)
It is a plain async function with no timer and no daemon, so calling it on a
schedule is your job: a cron route, a queue consumer, a Durable Object
alarm(), a waitUntil. Treat that as a hard requirement of wiring
durability, not a nice-to-have: until something invokes it, nothing closes a
detached run's delivery log, so every attached client parks forever,
detachedRunTtlMs is enforced by nothing, and the sandbox bills indefinitely.
Reaping & Retention is the whole picture: the sweep's outcomes, why
it never drives a run to find out whether it finished, pruneJournals,
reclaimSandbox, ready-to-paste schedules for Node, Vercel Cron, and a
Cloudflare alarm(), and how to size the TTL against the sweep interval.
Set detachOnDisconnect: false to keep today's destroy-on-disconnect cost
profile while still getting resumable delivery: a reload replays the log, but
the agent does not survive the disconnect. An explicit cancel destroys the
sandbox either way.
DetachableRunCapability
A neutral boolean that core owns. withSandbox provides it as true only when a
run is genuinely durable; withPersistence reads it with getOptional to decide
whether an abort is terminal ('aborted') or a detach (write nothing). It lives
in core so the two packages can agree without either importing the other. A
persistence → sandbox import would invert the layering. Read it in your own
middleware if you need the same distinction; absent means "not detachable", which
is every app that has not wired durability.
RunDetachedCapability
Its past-tense counterpart, also owned by core. DetachableRunCapability is
published at setup and only says a disconnect may be survived;
RunDetachedCapability is published on the abort path by withSandbox's detach
branch and says the run was detached. The agent is still working and a later
attach can take it over.
Its consumer is the durable delivery sink behind toServerSentEventsResponse /
toHttpResponse. Without the verdict the sink terminalizes every abort, which
defeats takeover (see the table above). The fact travels on the stream object
itself, so there is nothing to wire: passing chat()'s stream to the response
helper is already mandatory, and both sides hold the same object.
import { provideRunDetached } from '@tanstack/ai'
import type { CapabilityContext } from '@tanstack/ai'
// You do not write this. `withSandbox` does, on its detach branch, from a hook
// that already holds the middleware context. Shown only so the fact is legible.
function markRunDetached(ctx: CapabilityContext): void {
provideRunDetached(ctx, true)
}
Only a plain, intentless disconnect of a detachable run publishes it. An explicit
cancel in either band, a disconnect on a non-detachable run,
detachOnDisconnect: false, a provider failure, and a normal finish all leave it
unpublished, and the sink appends its terminal and closes the log exactly as it
always has. Core additionally refuses to treat an abort carrying
RUN_CANCEL_REASON as a detach, whatever a middleware publishes. A user pressing
Stop always gets a closed, terminal log.
Requirements
A real LockStore. InMemoryLockStore cannot coordinate across hosts: it
serializes claims within one process, and the signal it hands out is a fresh
AbortController().signal that is never aborted, so the lease can never report a
loss. Two replicas can then drive one run and duplicate its log. withSandbox
emits a warning when durability is wired over an in-memory lock (including when
no lock is wired at all, since the fallback is in-memory). See
Locks.
A caller-supplied runId. A durable run throws
DurableRunIdRequiredError at the start of chatStream when none is passed.
That is deliberately loud, because the alternative is invisible: an
adapter-generated id produces a journal path no successor host can recompute, so
the run streams normally, records normally, and is silently unrecoverable. You
would only discover it during an incident.
The run record's threadId, on an attaching run. An ATTACHING durable run
throws DurableThreadIdRequiredError when driven without the run record's
threadId. threadId rides in every emitted chunk, so an attach that
generates a fresh one replays a stream whose very first chunk already differs
from the stored log. Alignment fails at index 0 with
JournalReplayThreadIdMismatchError, a subclass of
JournalReplayDivergedError reported above,
even though the agent behaved identically. This is the asymmetry to keep
straight: a durable fresh run needs no caller threadId, because that run is
what establishes it, but a durable run that is attaching must reuse the
one already on the record, which is exactly the threadId: input.threadId
forwarded by driveRun above.
A RunStore that persists every durable-run field. createOrResume,
update, and get are required; update must accept and round-trip all of
status, finishedAt, error, usage, sandboxKey, detachedSince,
cancelRequested, and driverEpoch. The last four are exactly what a
hand-written backend tends to omit, and each one breaks a specific
mechanism: no driverEpoch means no fencing, no cancelRequested means Stop
cannot reach a remote driver, no detachedSince/sandboxKey means nothing can
reclaim the sandbox. Two invariants also hold: createOrResume returns an
existing record unchanged (that is what makes resuming safe), and update on
an unknown runId is a no-op that must not throw.
findActiveRun is required: it is how you rejoin by thread. listReclaimable
is optional and feature-detected, but reapDetachedRuns needs it to have
anything to sweep: a store without it cannot be reaped at all.
Adapter authors
If you are writing a harness adapter rather than an application, three exports
are yours: getSandboxDurability reads the resolved durability off the
capability bus, journalOptionsFor turns it into the journal option
spawnNdjson takes, and alignedIfAttaching applies alignment on attach only.
Wrap the merged output stream, never the pre-merge translator, or you compare
against a stream the log never contained. See Harnesses.
See also
- Durable Runs Explained: the mental model behind this page, in plain language and with no code. Start there if any of the above felt abrupt
- The Run Journal: where the agent's output lives,
runIduniqueness, andalignToStoredLog - Sandbox Instance Durability: keeping the sandbox itself findable across processes
- Lifecycle & Snapshots:
destroyOnComplete,keepAlive, and snapshots - Resumable Streams and
Advanced: the delivery log,
joinRun, and offset ownership - Locks: the distributed lock a durable run requires
- Persistence overview and
Store Reference: the shared
RunStore