- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
Reaping & Retention (Advanced)
The reaper stops a detached run that the user never resumes.
- The sandbox bills forever. Detach exists so the sandbox is not destroyed, so something else must destroy it.
- Clients wait forever. A detached run's log stays open on purpose, so a client that joins waits for an event that never comes.
- Finished work is lost. withPersistence saves the transcript in onFinish, on the host that drives the run. A run that finishes while detached has no such host, so its transcript is never saved.
reapDetachedRuns ends these runs. It is a function, not a scheduler. Your app must call it on a schedule. Nothing does it for you, and nothing warns you when you forget. A timer inside a library does not work on serverless platforms, so the library ships none.
Set up the sweep#
This setup uses the run-setup.ts module from Takeover & Detached Runs.
- Write the sweep once.
// sweep.ts
import {
probeRunExit,
reapDetachedRuns,
sandboxReclaimer,
} from '@tanstack/ai-sandbox'
import type { RunRecord } from '@tanstack/ai'
import type { ReapResult, RunExitProbe } from '@tanstack/ai-sandbox'
import {
durabilityFor,
driveRun,
instances,
locks,
runs,
sandbox,
} from './run-setup'
// Did the agent exit? Read the journal tail in the run's sandbox.
// Anything this cannot answer must be 'unknown', never 'finished'.
async function hasFinished(record: RunRecord): Promise<RunExitProbe> {
if (record.sandboxKey === undefined) return { state: 'unknown' }
try {
const instance = await instances.get(record.sandboxKey)
if (instance === null) return { state: 'unknown' }
const handle = await sandbox.provider.resume({
id: instance.providerSandboxId,
})
if (handle === null) return { state: 'unknown' }
return await probeRunExit({ handle, runId: record.runId })
} catch (error) {
return { state: 'unknown', error }
}
}
export function sweepDetachedRuns(): Promise<ReapResult> {
return reapDetachedRuns({
runs,
locks,
// Must resolve the same log the chat routes wrote.
durability: durabilityFor,
hasFinished,
// The same drive the takeover route uses.
drive: driveRun,
now: Date.now(),
// The only place this time limit is set. In milliseconds.
detachedRunTtlMs: 30 * 60 * 1000,
maxRuns: 25,
reclaim: sandboxReclaimer({ provider: sandbox.provider, instances }),
})
}- Call it on a schedule. Pick the one that fits your platform.
Plain Node. Keep one sweep in flight at a time. Two at once race for the same runs, and every second one reports not-claimed.
import { sweepDetachedRuns } from './sweep'
let inFlight = false
async function tick(): Promise<void> {
if (inFlight) return
inFlight = true
try {
const result = await sweepDetachedRuns()
console.log('reap', result.considered, result.outcomes)
for (const run of result.runs) {
if (
run.outcome === 'failed' ||
run.outcome === 'unknown' ||
run.outcome === 'reclaim-failed'
) {
console.warn('reap needs attention', run.runId, run.outcome, run.error)
}
}
} finally {
inFlight = false
}
}
setInterval(() => void tick(), 60_000)Vercel Cron. The route drives runs and destroys sandboxes, so guard it with CRON_SECRET.
// app/api/cron/reap/route.ts
import { sweepDetachedRuns } from '../../../../sweep'
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET
if (
secret === undefined ||
request.headers.get('authorization') !== `Bearer ${secret}`
) {
return new Response('Unauthorized', { status: 401 })
}
const result = await sweepDetachedRuns()
return Response.json({
considered: result.considered,
probed: result.probed,
outcomes: result.outcomes,
})
}{
"crons": [{ "path": "/api/cron/reap", "schedule": "*/5 * * * *" }]
}Cloudflare Durable Object alarm(). A Durable Object runs one alarm at a time, so you get the "one sweep in flight" guard for free.
import { sweepDetachedRuns } from './sweep'
// The part of DurableObjectState this needs. In a real Worker, pass
// `state.storage` from your Workers types.
interface AlarmStorage {
setAlarm: (scheduledTime: number) => Promise<void>
}
export class RunReaper {
constructor(private readonly storage: AlarmStorage) {}
async alarm(): Promise<void> {
try {
const result = await sweepDetachedRuns()
console.log('reap', result.considered, result.outcomes)
} finally {
// Re-arm in `finally`. An alarm that throws without a new alarm stops
// reaping forever.
await this.storage.setAlarm(Date.now() + 60_000)
}
}
}- Leave a run detached and wait one sweep. Its sandbox is destroyed, and if the agent had finished, its transcript is in the message store.
Warning
Watch the reclaim-failed count. It means the transcript is saved but the sandbox destroy failed, so the sandbox can still bill. No later sweep retries it. That log line is the only notice you get. See Reaping Internals.
How it works#
Each sweep:
- Lists every detached run once, with RunStore.listReclaimable.
- Takes up to maxRuns of them (25 by default) and handles them one at a time.
- Never rejects. Every failure is logged and counted in the returned ReapResult.
For each run:
| The run is | The sweep | Outcome |
|---|---|---|
| Past detachedRunTtlMs | Cancels it, drives it to a final status, destroys the sandbox. | expired |
| Finished (the journal has the exit line) | Drives it to a final status so onFinish saves the transcript, then destroys the sandbox. | finalized |
| Still working | Touches nothing. | producing |
| Not readable (no sandbox, a failed probe) | Touches nothing. | unknown |
| Claimed by another host | Touches nothing. A viewer probably came back. | not-claimed |
The sweep learns that a run finished from the journal in the sandbox, never by driving the run to see. It also never clears detachedSince, so the time limit keeps counting. The full outcome list, and why it works this way, is in Reaping Internals.
The reaper does not tidy journals inside a sandbox that stays alive. If you use keepAlive sandboxes, also schedule pruneJournals.
Size the time limit and the interval#
- Set detachedRunTtlMs from your agent's p99 task time, not from user patience. If an agent can run 20 minutes, 30 minutes is tight and an hour is safe. Too short cancels real work. Too long lets an abandoned run bill for that long.
- Sweep well under the time limit. The interval is how late an expired run can be, and how long a finished run waits for its transcript. Every minute against a 30-minute limit is cheap: one store query per minute. Every 30 minutes against a 30-minute limit can be an hour late.
- Do not raise maxRuns for a backlog. The cap keeps one call inside a Worker CPU budget or a Lambda timeout. Sweep more often instead.
Go deeper#
For every outcome, the journal probe, pruneJournals, sandbox reclaim, data retention, and the instance-store limit, see Reaping Internals.