Skip to content
Sandboxes (Advanced)

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.

  1. Write the sweep once.
ts
// 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 }),
  })
}
  1. 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.

ts
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.

ts
// 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,
  })
}
json
{
  "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.

ts
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)
    }
  }
}
  1. 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:

  1. Lists every detached run once, with RunStore.listReclaimable.
  2. Takes up to maxRuns of them (25 by default) and handles them one at a time.
  3. Never rejects. Every failure is logged and counted in the returned ReapResult.

For each run:

The run isThe sweepOutcome
Past detachedRunTtlMsCancels 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 workingTouches nothing.producing
Not readable (no sandbox, a failed probe)Touches nothing.unknown
Claimed by another hostTouches 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.