본문으로 건너뛰기

회수 및 보존

Takeover & Detached Runs는 연결이 끊겨도 실행을 계속할 수 있게 합니다. 에이전트는 계속 작업하고, 샌드박스는 실행 상태를 유지하며, 실행 기록에는 아무도 지켜보고 있지 않다는 사실이 남습니다. 하지만 이는 생명주기의 절반에 불과합니다. 어떤 뷰어도 다시 돌아오지 않은 실행을 종료할 무언가가 필요합니다.

그 무언가는 reapDetachedRuns이며, 스케줄러가 아니라 함수입니다.

먼저 읽기: 아무것도 대신 예약해 주지 않습니다

runs + durability을 연결하고 reapDetachedRuns을 예약하지 않는 애플리케이션은 이 기능의 축소 버전을 실행하는 것이 아닙니다. 동시에 다음 세 가지 방식으로 고장 난 버전을 실행하고 있습니다:

  • 별도의 실행의 전달 로그는 결코 닫히지 않습니다. 별도의 실행의 로그는 의도적으로 OPEN 상태로 남아 비종결화되어 takeover 가 이를 계속할 수 있도록 합니다. 만약 아무것도 이를 종결화하지 않으면, 모든 클라이언트는 영원히 도착하지 않을 이벤트를 기다리며 대기합니다.
  • detachedRunTtlMs 은 아무것도 강제하지 않습니다. 이는 타이머가 아닙니다. 이는 스위프가 detachedSince 와 비교하는 절단점이며, reapDetachedRuns 의 인자로만 존재하며 withSandbox 와는 완전히 다릅니다. 스위프가 없다면 아무도 읽지 않는 숫자이며, 버려진 에이전트는 다른 것이 이를 죽이기 전까지 토큰을 소모합니다.
  • 샌드박스는 무한히 과금됩니다. 연결 해제 시 샌드박스 파괴는 방지하기 위해 disconnect 시 detach 가 존재합니다. 샌드박스를 회수하는 것은 스위프의 일입니다.

기본 cron 이나 백그라운드 타이머가 없으며 설정 시 경고도 없습니다. 샌드박스 패키지가 대상으로 하는 모든 서버리스 플랫폼에서 라이브러리 내부의 타이머는 잘못된 방식입니다. 따라서 durability 을 연결할 때 스케줄링은 LockStore 을 실제로 전달하는 것과 동일한 필수 요구사항으로 간주해야 합니다.

왜 리퍼는 비용이 아닌 정확성 때문입니다

비용 이야기는 명백한 이야기입니다. 정확성 이야기가 'finalized' 가 결과물로서 존재하는 이유입니다.

withPersistenceonFinish 에서 실행의 전사본을 저장합니다. 디테치된 상태에서 완료되는 실행은 누구의 onFinish 도 도달하지 않습니다: 해당 실행을 수행해야 할 호스트는 클라이언트가 연결을 끊었을 때 떠난 호스트입니다. 에이전트는 종료되었고, 그 바이트는 샌드박스 내 저널에 있으며, 전달 로그는 실제로 전달된 마지막 청크에서 동결되었고, 메시지 스토어에는 아무것도 없습니다.

그것은 스스로 복구되지 않습니다. 후속 인수권은 — 하지만 인수권은 사용자가 다시 돌아올 때만 발생하며, 디테치된 실행의 전제 조건은 그들이 그렇게 하지 않을 수 있다는 것입니다. reapDetachedRunschat() 의 정상 미들웨어 경로를 통해 그러한 실행을 주도하는 행위자이며 onFinish 가 발화되고 전사본이 착륙합니다. 그것이 'finalized' 의 의미이며, 리퍼를 조용히 스케줄하지 않아도 완료된 작업을 단순히 돈을 낭비하는 대신 잃어버리는 이유입니다.

reapDetachedRuns

import { reapDetachedRuns } from '@tanstack/ai-sandbox'

한 번의 스윕 순서:

  1. RunStore.listReclaimable({ now, ttlMs: 0 })한 번만 요청합니다. ttlMs: 0모든 분리된 실행마다 수행됩니다. 이는 최종화 대상 후보 집합이기 때문입니다: 뷰어가 떠난 1 초 후에 센티넬을 맞은 실행은 저장되지 않은 전사본이 있어 TTL 을 기다려서는 안 됩니다. 만료는 now - detachedRunTtlMs 에 대해 포함적으로 분류되며, 스토어에 두 번 계산하라고 요청받지 않습니다.
  2. 배치 내 각 실행마다 (maxRuns 로 제한, 기본값 25): 만료 상태를 먼저 분류하고, 그렇지 않으면 탐지하며, 그 다음에만 주장, 정지, 구동 및 회수합니다.

listReclaimableRunStore 에서 선택 사항입니다. 이를 생략하는 백엔드는 { considered: 0 } 와 한 줄의 로그만 반환하며, 전혀 회수할 수 없습니다.

이 함수는 절대 거부하지 않습니다. 크론, alarm(), 또는 waitUntil 에서 실행되며, 이를 잡을 사람이 없으므로 각 실행의 모든 실패는 로그에 기록되어 반환되는 ReapResult 에 통합됩니다.

결과

ReapResult.runs 는 한 실행마다 하나 ReapRunEntry 이며, ReapResult.outcomesReapRunOutcome 로 카운트합니다:

결과의미
finalized탐지기가 종료 센티넬을 보았으며, 실행이 종단까지 진행되었고, 전사본이 저장되었습니다. 성공 경로입니다.
expired과거 detachedRunTtlMs. 먼저 취소 (따라서 해체는 샌드박스를 파괴하는 명시적 취소), 그 다음 터미널로 유도됨. 탐지는 건너뜀 — 결과는 어쨌든 터미널임. runBudgetMs 가 구동을 종료한 경우에도 보고됨: 이 경로에서 그것이 메커니즘이지 이상 현상이 아님. 실행의 status 가 둘을 구분함 — 이미 완료된 에이전트는 completed 로 재생되며, 예산이 발동했을 때 여전히 생성 중인 것은 aborted 임.
producing여전히 작업 중. pipeToRunLog 는 들어오지 않음: 아무것도 추가되지 않고 기록이 작성되지 않으며, close() 가 호출되지 않고 detachedSince 는 건드리지 않음.
unknown탐지는 답할 수 없었음. producing 와 정확히 같이 건드리지 않은 상태로 남겨두지만, 운영자는 이를 확인해야 함.
budget-exceeded이상 상태이며 최종화만 수행합니다. 저널에서 이미 완료됐다고 확인된 실행이 runBudgetMs를 초과했습니다. 레코드는 종결 상태이고 로그도 닫혀 있으므로, 이는 누수가 아니라 진단 정보입니다. 만료된 실행이 예산을 초과하면 대신 expired을 보고합니다. 항목에는 terminalizedAnyway도 포함되며, 이 값은 이 이상 상태가 발생한 경우에만 설정됩니다. 따라서 이후의 회수 실패가 결과를 덮어써도 해당 정보는 유지됩니다.
not-claimed다른 호스트가 주장을 보유하고 있거나 드라이브 중반에 가져갔습니다. 정상입니다 — 실제 뷰어가 스윕 중반에 연결하는 것이 바로 이것입니다.
reclaim-failed런치가 종료되었고 그 트랜스크립트 ReapOptions.reclaim 만 던졌으므로 샌드박스는 여전히 작동 중입니다. 스윕으로 재시도 불가: 기록은 이미 종료가 되었으므로 listReclaimable 를 완전히 떠났고, 다시 스윕되지 않습니다. 이는 리퍼가 막아야 할 비용 누수가 여전히 누출되고 있음을 나타내는 결과이며, 엔트리의 error 만 당신이 받는 유일한 통지입니다. sandboxReclaimerdestroy-failed 암에서 반려하므로, reclaim 없이도 이 상태에 도달할 수 있습니다. budget-exceeded 가 두 가지 모두 발생했을 때 덮어씁니다 — 누수가 조치해야 할 부분이며, terminalizedAnyway 는 나머지 절반을 보존합니다.
failed무언가가 예외를 발생시켰습니다. 로그에 기록하고 집계한 후 스윕을 계속했습니다.

의도적으로 producing와 구별되는 "아직 실행 중" 결과는 없으며, "실행했지만 완료되지 않은 것으로 드러난" 상태를 의미하는 결과도 없습니다 — 그 상태가 구조적으로 도달 불가능한 이유는 아래를 참조하세요.

완료 여부를 확인하기 위해 실행을 진행하지 않습니다

이 규칙 하나가 모듈 전체의 형태를 결정하며, 무엇이든 연결하기 전에 이해할 가치가 있습니다. 겉보기에는 당연한 대안이 실제보다 더 나쁘기 때문입니다.

유혹적인 설계는 다음과 같습니다. 짧은 예산으로 실행을 pipeToRunLog에 넘기고 종료 상태가 되는지 확인합니다. 그러나 작동하지 않습니다. pipeToRunLog은 구조적으로 완전하기 때문입니다 — 모든 경로에서 항상 종료 상태를 기록하고 durability.close()항상 호출합니다. 아직 완료되지 않은 실행에 대해서는 세 가지 생산자 형태 모두 파괴적입니다:

예산 신호에 대한 생산자의 반응저장된 상태close()
무시하고 계속 생성함aborted호출됨
중단 시 반환함 (현실적인 drive)aborted호출됨
AbortError를 발생시킴failed호출됨

가운데 행은 이전에 completed로 표시되어 있었으며, 이것이 치명적인 문제였습니다. 다음과 같은 신호 인식 생산자 — 실제로 올바르게 동작하는 drive가 갖는 형태 — 는 종료됩니다. 루프를 정상적으로 종료하며, pipeToRunLog은 청크마다 한 번만 중단 신호를 확인했기 때문에 정상적인 진행 중 실행이 'completed'finishedAt로 기록되었습니다. 이 간극은 수정되었습니다. pipeToRunLog은 루프 후 신호를 다시 확인하므로 중단된 실행이 완료된 것으로 기록되는 일이 없습니다.

하지만 규칙은 변경되지 않았습니다. 상태가 피해의 전부가 아니었기 때문입니다. 위의 모든 행은 종료 레코드를 기록하고 의도적으로 열어 둔 로그를 닫아 연결된 모든 클라이언트의 스트림을 종료합니다 — 또한 종료 레코드는 listReclaimable에서 영원히 빠지므로 TTL 만료로 해당 실행의 샌드박스를 회수할 수 없습니다. 복구 경로가 없는 비용 누수입니다.

따라서 완료 여부는 대역 외부에서, 샌드박스 내부 저널을 통해 파악하며, pipeToRunLog은 이미 완료된 것으로 알려진 실행 또는 TTL이 만료된 실행(어느 쪽이든 종료 상태)에 대해서만 입력됩니다. 따라서 최종화 경로에서 runBudgetMs는 핵심 메커니즘에서 만료가 실제 이상 현상인 안전망으로 역할이 축소됩니다. 만료 경로에서는 여전히 핵심 역할을 합니다. 취소를 폴링하는 항목이 없고 리퍼가 기록하므로, 에이전트가 아직 생성 중인 만료된 실행을 중지하는 것은 예산입니다.

스윕은 또한 detachedSince을 절대 지우지 않습니다. 이 필드는 리퍼가 선택에 사용하고 TTL 계산의 근거로 사용합니다. 이를 지우면 모든 스윕에서 TTL이 재설정되어 분리된 실행이 절대 만료되지 않습니다. (인수 경로에서는 실제 뷰어가 시계를 멈추었으므로 이 필드를 지웁니다.)

probeRunExithasFinished을 주입하는 이유

ReapOptions.hasFinished은 사용자가 제공하는 필수 옵션입니다. probeRunExit은 제공되는 구현입니다:

import { probeRunExit } from '@tanstack/ai-sandbox'

실행 저널의 끝부분(기본값 4KB, DEFAULT_EXIT_PROBE_BYTES)을 읽고 {"__exit":N} 센티넬이 있는지 응답합니다. 읽기 전용이므로 추가, 레코드 기록, close()이 없습니다. 세 갈래의 응답 — finished / producing / unknown — 은 의도적으로 불리언이 아니므로, 거부된 provider exec를 "에이전트가 종료됨"으로 오인할 수 없습니다. 모든 실패는 unknown에 응답하고, 빈 끝부분은 실패 안전 방향인 producing에 응답합니다. 아직 존재하지 않는 저널은 센티넬이 없는 저널과 구별할 수 없으며, 둘 다 이 실행을 건드리지 말 것을 의미합니다).

이는 구조적인 두 가지 이유로 리퍼 내부에서 해결하지 않고 주입합니다:

  • 전달 로그로는 이 질문에 답할 수 없습니다. 분리 후에는 아무것도 추가되지 않습니다. 이를 추가했을 호스트가 떠난 호스트이기 때문입니다 — 따라서 로그는 마지막으로 전달된 청크에서 멈추지만 저널은 계속 커집니다. 로그는 오직 "새 소식 없음"만 말할 수 있습니다.
  • SandboxHandle은 애플리케이션만 해결할 수 있습니다. SandboxInstanceStoreget / upsert / delete이며 list가 없습니다 (다음 명명된 제한 사항 참조). 그리고 RunRecord.sandboxKey을 활성 핸들에 매핑하는 것은 애플리케이션 지식입니다.

같은 이유로 ReapOptions.reclaim을 주입하며, sandboxReclaimer은 바로 사용할 수 있는 구현입니다.

서버: 스윕을 한 번만 연결합니다

스윕에 필요한 모든 것은 사용자의 POST 라우트가 이미 가진 것에 키로부터 샌드박스 핸들을 해결하는 방법을 더한 것입니다. 이를 하나의 모듈에 넣고 각 스케줄이 호출하도록 합니다.

import {
probeRunExit,
reapDetachedRuns,
sandboxReclaimer,
} from '@tanstack/ai-sandbox'
import { durableStream } from '@tanstack/ai-durable-stream'
import type { RunRecord } from '@tanstack/ai'
import type { ReapResult, RunExitProbe } from '@tanstack/ai-sandbox'
// Your distributed LockStore, the same one `withSandbox` gets.
import { locks } from './locks'
// Your persistence — the SAME RunStore the chat routes use.
import { persistence } from './persistence'
// Your `defineSandbox(...)` result and the `SandboxInstanceStore` you passed to
// `withSandbox(sandbox, { instances })`.
import { instances, sandbox } from './sandbox'
// The same `drive` the attach route passes to `sandboxRunDriver` — a function of
// `{ runId, threadId, signal }` that runs `chat()` with `durability.attach: true`.
// See ./takeover, "Server: take the run over".
import { driveRun } from './drive-run'

const { runs } = persistence.stores

// The per-run log factory, and it MUST resolve the same log the producing route
// wrote — otherwise the sweep terminalizes an empty log while the real one stays
// open. A cron has no incoming request, so synthesize one naming the run.
//
// `?runId` is the form to use here: `durableStream` resolves a run from the
// `X-Run-Id` header first, then `?runId` (the same precedence core's own
// `memoryStream` uses), but a synthesized `Request` has no reason to carry a
// header when a query param is just as easy to set — either addresses the
// same `agent-runs/<runId>` the chat routes' `durableStream(request,
// durableOptions)` addresses. No resume offset is set, because the reaper is
// a producer, not a replaying client.
//
// The same backend and options the chat routes use — see ../resumable-streams/
// advanced for the full option set. `durableStream` talks to it over HTTP, so a
// synthesized request works exactly like a real one: the run's state lives in
// the backend, not in this process.
const durableOptions = {
server: 'https://streams.example.com',
streamPrefix: 'agent-runs',
}

function durabilityFor(runId: string) {
const url = new URL('https://reaper.internal/')
url.searchParams.set('runId', runId)
return durableStream(new Request(url), durableOptions)
}

// Only the application can map a recorded `sandboxKey` back to a live handle:
// the instance store answers `get(key)` but never enumerates, and resolving the
// provider sandbox id it holds is your provider's `resume`. 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,
durability: durabilityFor,
hasFinished,
drive: driveRun,
now: Date.now(),
// The only place this TTL is configured — there is no `withSandbox`
// equivalent. In milliseconds.
detachedRunTtlMs: 30 * 60 * 1000,
// Sequential by design — each run costs a lock, a provider round-trip, and a
// full replay. Keep the batch inside your platform's invocation budget.
maxRuns: 25,
reclaim: sandboxReclaimer({
provider: sandbox.provider,
instances,
}),
})
}

reapDetachedRuns은 거부하지 않고 해결하므로 요약을 로그에 기록하고 스케줄이 주기를 유지하도록 합니다:

import type { ReapResult } from '@tanstack/ai-sandbox'
import { sweepDetachedRuns } from './sweep'

// Plain Node, no platform cron. One in flight at a time: a sweep that overruns
// its interval must not be started twice, or two invocations race for the same
// claims and every second one reports `not-claimed`.
let inFlight = false

async function tick(): Promise<void> {
if (inFlight) return
inFlight = true
try {
const result: ReapResult = await sweepDetachedRuns()
console.log('reap', result.considered, result.outcomes)
for (const run of result.runs) {
if (run.outcome === 'failed' || run.outcome === 'unknown') {
console.warn('reap needs attention', run.runId, run.outcome, run.error)
}
}
} finally {
inFlight = false
}
}

setInterval(() => void tick(), 60_000)

Vercel Cron

Cron 라우트는 일반적인 GET입니다. 이를 보호해야 합니다. 실행을 진행하고 샌드박스를 파괴하므로 공개적으로 호출할 수 있어서는 안 됩니다.

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,
})
}

vercel.jsonpathschedule과 함께 등록합니다 (*/5 * * * *는 합리적인 시작점입니다 — 크기 산정 참조).

Cloudflare: 내구성 객체 alarm()

DO 알람은 Workers에서 자연스러운 스케줄러입니다. 단일 인스턴스이므로 "동시에 하나의 스윕만 실행" 가드가 자동으로 적용되며, 스스로 다시 설정됩니다.

import { sweepDetachedRuns } from './sweep'

// The slice of `DurableObjectState` this needs. In a real Worker this is
// `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 rescheduling stops
// reaping forever, which is exactly the failure mode this page is about.
await this.storage.setAlarm(Date.now() + 60_000)
}
}
}

다음과 혼동해서는 안 됩니다. @tanstack/ai-sandbox-cloudflare의 코디네이터는 정체 감시를 제공합니다 — 로그가 너무 오랫동안 조용할 때 실행 레코드를 실패 처리하는 알람입니다. 이는 로그 정리이지 리핑이 아닙니다. 종료 센티넬을 확인하기 위해 저널을 검사하지 않으며 샌드박스를 회수하지도 않습니다. Cloudflare에서는 여전히 sweepDetachedRuns을 스케줄링해야 하며, 위와 같은 DO 알람이 이를 위한 자연스러운 위치입니다.

pruneJournals: 저널 디렉터리 범위 제한

reaper는 실행을 종료하고 샌드박스를 회수합니다. 아직 실행 중인 샌드박스 내부keepAlive 샌드박스 저널 디렉터리까지 정리하지는 않습니다. 여러 턴을 처리하는 샌드박스에는 종료 센티널을 아무도 관찰하지 못한 모든 실행의 저널이 누적됩니다.

import { pruneJournals } from '@tanstack/ai-sandbox'
import { persistence } from './persistence'
import { handleForSandbox } from './sandbox-handles'

const { runs } = persistence.stores

export async function sweepJournals(sandboxKey: string) {
const result = await pruneJournals({
handle: await handleForSandbox(sandboxKey),
// Only `get` is used: the sweep asks about the runIds it found on disk and
// never enumerates the store, so no optional `RunStore` method is needed.
runs,
})
if (result.ageGate === 'unavailable') {
console.warn('journal sweep could not age-gate; kept every orphan')
}
return result
}

어디서나 폐쇄적으로 실패합니다. 저널은 중단된 호스트가 포기한 실행을 후속 호스트가 재생하는 데 필요한 바이트의 유일한 사본이므로, 결정 절차는 "보존할 이유가 없으면 삭제"가 아닙니다. 그 반대이며, 안전한 삭제가 입증되지 않은 모든 분기는 보존합니다:

스토어가 말하는 내용작업이유
Terminal (isTerminalRunStatus)삭제늦은 인수인계가 기준으로 삼는 레코드는 저널이 아니라 전달 로그입니다. 0이 아닌 종료도 terminal입니다.
Non-terminal — 'interrupted' 포함보존인터럽트는 human-in-the-loop 일시 중지입니다. 인터럽트-재개는 해당 저널에서 계속됩니다.
아무것도 없음(알 수 없는 runId)orphanTtlMs까지 보존리더는 레코드가 존재하기 전에 저널을 생성하므로, "알 수 없는 runId"는 방금 시작된 실행의 정상적인 상태입니다.
조회에서 예외 발생보존답을 얻지 못한 질문은 삭제를 허가하지 않습니다.
파일 이름을 디코딩하지 못함보존잘린 이름은 그럴듯하지만 잘못된 runId로 디코딩되므로, 스토어에 질문하면 다른, 어쩌면 실행 중인 실행에 대한 답을 받게 됩니다.
mtime 기간 게이트를 사용할 수 없음기간 게이트가 적용된 모든 항목을 보존기간 게이트를 적용할 수 없으면 만료할 수 없습니다. unavailable은 빈 목록이 아니라 일급 결과입니다.

마지막 행이 바로 이 모듈이 빠지지 않도록 존재하는 함정입니다. BusyBox find는 "인식할 수 없는 옵션" 진단을 stderr에 출력하고 빈 stdout과 함께 1로 종료합니다. 이를 "컷오프보다 최신인 파일이 없음"으로 읽고 "그러므로 모든 파일이 오래됨"이라고 결론 내리는 코드는 실행 중인 실행까지 포함해 디렉터리 전체를 삭제합니다. mtime 목록은 자기 증명 행을 포함하고 unavailable[] 대신 보고하며, pruneJournals는 이를 "보존"으로 처리합니다.

스윕당 삭제 수는 maxDeletes(기본값 DEFAULT_MAX_DELETES, 200)으로 제한됩니다. 나머지는 max-deletes 사유로 보존된 것으로 보고되며 다음에 처리됩니다. orphanTtlMs의 기본값은 DEFAULT_ORPHAN_TTL_MS(1시간)입니다. 생성 후 기록 경쟁에 대해 세 자릿수 규모의 여유를 둡니다. 그 이유는 너무 긴 경우의 비용은 바이트인 반면, 너무 짧은 경우의 비용은 실행 중인 실행의 파괴이기 때문입니다. pruneJournals도 절대 거부하지 않습니다. 실패는 PruneJournalsResult.failures.

reclaimSandboxsandboxReclaimer

import { reclaimSandbox, sandboxReclaimer } from '@tanstack/ai-sandbox'

reclaimSandbox(record, { provider, instances })는 terminal 실행이 연결되어 있던 샌드박스를 RunRecord.sandboxKey을 사용해 파괴합니다. 이는 detach 경로가 복합 키를 아직 알고 있던 순간에 기록한 것입니다. reaper에는 다음과 같은 입력(threadId, workspace hash, tenant, reuse strategy)이 없으므로 이를 다시 도출할 수 없습니다. 다음에 답합니다: 'destroyed', 'destroy-failed', 'no-sandbox-key', 'not-found', or 'provider-mismatch'.

순서가 중요한 두 가지 사항이 있습니다:

  • provider 확인이 먼저 수행됩니다. destroy 또는 delete보다 앞서야 합니다. 그렇지 않으면 여러 provider를 사용하는 앱이 Docker 컨테이너 ID를 Daytona의 destroy에 전달하게 됩니다. 최선의 경우에는 오류가 발생하고, 최악의 경우에는 다른 provider의 ID 네임스페이스에서 관련 없는 샌드박스와 일치합니다. 따라서 불일치가 발생하면 아무것도 건드리지 않으며, 올바른 provider에 여전히 필요한 인스턴스 레코드도 포함됩니다.
  • destroy이(가) delete보다 먼저이고, deletedestroy 여부와 관계없이 던졌습니다. Provider 샌드박스는 이미 사라졌을 수 있습니다(유휴 상태로 회수되었거나, 리전이 삭제되었거나, 컨테이너가 정리되었을 수 있습니다). 아무것도 가리키지 않는 인스턴스 레코드를 유지하면 스레드의 다음 턴에서 실패한 resume이 보장됩니다. 즉, 사용자 경험이 손상됩니다. 반면 고아가 된 Provider 샌드박스는 Provider 자체가 회수하는 제한된 비용입니다. 따라서 delete은 무조건 수행되지만, 결과destroy이 던져졌을 때 성공을 주장해서는 안 됩니다. 대신 'destroy-failed''destroyed' 대신 보고됩니다. 어느 쪽이든 인스턴스 레코드는 사라지며 운영자는 "정상적으로 해제됨"과 "여전히 요금이 청구되고 있을 수 있으며, 이제 여기서는 연결할 수 없음"을 구분해야 합니다. 이는 SandboxInstanceStorelist이 없기 때문입니다. sandboxReclaimer은 로그를 정확히 이러한 이유로 디버그 수준보다 높게 'destroy-failed'을 기록합니다. 다른 모든 결과는 운영자가 볼 필요가 전혀 없는 장부 기록입니다.

sandboxReclaimer(options)ReapOptions.reclaim에 맞게 조정한 동일한 동작입니다. 결과를 로그에 기록하고 resolve합니다. 단, 'destroy-failed'에서는 reject합니다. 그리고 SandboxReclaimFailedError과 함께 reject합니다. Reaper는 기록이 실제로 종료 상태에 도달한 후에만 한 번 호출하며, drive가 반환한 기록이 아니라 처음 나열된 기록을 사용합니다. 실패한 terminal updatesandboxKey가 없는 로컬 재구성 기록을 생성하며, 이는 'no-sandbox-key'에 답하고 정확히 이미 문제가 발생한 경로에서 샌드박스를 조용히 유출하게 됩니다. 무언가가 이미 잘못되었습니다.

이 reject는 의도된 것이며, 이것이 reclaim-failed 결과에 도달할 수 있게 합니다. ReapOptions.reclaim(record) => Promise<void>이므로, reject는 "샌드박스가 회수되지 않음"을 알리는 sweep의 유일한 채널입니다.

위에 표시된 대로 reclaimer를 연결한 다음(reclaim: sandboxReclaimer({ provider, instances })), sweep이 반환하는 요약을 확인합니다.

import { SandboxReclaimFailedError } from '@tanstack/ai-sandbox'
import type { ReapResult } from '@tanstack/ai-sandbox'

export function alertOnLeakedSandboxes(
result: ReapResult,
alert: (message: string, detail: Record<string, unknown>) => void,
): void {
// Watch this counter — it is the leak alarm.
if (result.outcomes['reclaim-failed'] === 0) return

for (const run of result.runs) {
if (run.outcome !== 'reclaim-failed') continue
// The transcript IS saved and the record IS terminal; only the teardown
// failed. `status`/`exitCode` are reported exactly as `finalized` reports
// them, which is what distinguishes this from a `failed` sweep.
const leakedKey =
run.error instanceof SandboxReclaimFailedError
? run.error.sandboxKey
: undefined

alert('sandbox may still be billing', {
runId: run.runId,
status: run.status,
sandboxKey: leakedKey,
// Present ONLY if the drive also outran `runBudgetMs`. `reclaim-failed`
// overwrites the `budget-exceeded` outcome, so this field is what keeps
// that second diagnostic on the entry.
budgetAnomaly: run.terminalizedAnyway !== undefined,
})
}
}

reclaim-failed 실행은 이미 listReclaimable을 완전히 남겼으므로 이후 sweep에서는 재시도하지 않습니다. 이 항목이 받을 수 있는 유일한 알림입니다. 사용자 지정 reclaim도 동일한 규칙을 따라야 합니다. 샌드박스가 해제되지 않았으면 reject하고, 해제할 것이 없으면 resolve합니다.

detachedRunTtlMs 크기와 sweep 간격

detachedRunTtlMs아무도 지켜보지 않는 실행 중인 에이전트에 적용되는 밀리초 단위의 wall-clock 상한입니다. 기본값이 없고 문자열 파싱도 수행하지 않습니다. ReapOptions에 필요한 일반 숫자이며, 의도적으로 그곳에만 존재합니다. withSandbox은 자체적으로 TTL을 적용할 수 없습니다. reapDetachedRuns은 진행 중인 채팅 요청 없이 cron에서 실행되며, 이를 읽을 capability bus도 없기 때문입니다. 이를 sweep에 직접 전달해야 두 설정이 조용히 불일치하는 대신 단일 진실 공급원을 유지할 수 있습니다.

사용자의 인내심이 아니라 에이전트의 실제 p99 작업 시간에 맞춰 설정합니다. 너무 짧으면 사용자가 돌아오려던 실제 작업을 취소하게 되고, 너무 길면 중단된 실행이 그 시간만큼 비용을 발생시킵니다. 코딩 에이전트가 정당하게 20분 동안 실행된다면 30분(30 * 60 * 1000)은 빠듯하고, 1시간도 타당합니다.

그런 다음 sweep 간격을 TTL보다 훨씬 짧게 유지합니다. 간격은 TTL이 지난 후 만료된 실행이 살아남는 시간을 제한하며, 분리된 동안 완료된 실행이 transcript를 남기기 전에 대기하는 시간도 제한합니다. 30분 TTL에 1분 알람을 사용하면 매분 저렴한 store 쿼리 하나만 발생하고 두 시간 창이 모두 무시할 수 있을 정도로 짧아집니다. 30분 TTL에 30분마다 sweep하면 만료가 한 시간 늦어질 수 있습니다.

maxRuns(기본값 25)와 실행별 순차 루프는 한 번의 호출이 Worker의 CPU 예산이나 Lambda timeout을 초과하여 중간에 종료되지 않도록 존재합니다. backlog가 batch를 초과하면 다음 tick에서 다음 batch를 처리합니다. cap을 높이는 대신 간격을 줄입니다.

보존: 세 개의 시계, 그리고 우리에게 속한 것은 하나뿐입니다

reaper는 실행을 종료하고 샌드박스를 회수합니다. 저장된 데이터를 garbage-collect하지 않으며, 이 분리는 의도된 것입니다:

  • Event-log 보존은 내구성 backend의 역할이며, 연결한 StreamDurability 내부에서 처리됩니다. framework는 storage를 소유하지 않으므로 여기서 삭제하지 않습니다. memoryStream은 grace window 후 완료된 실행을 제거하고, durableStream는 backend 자체 정책에 따라 보존하며, custom adapter는 사용자의 정책에 따라 보존합니다. 30일은 합리적인 기본값입니다. 다음 날 아침 thread를 다시 연 사용자에게 실행의 정확한 replay를 제공할 만큼 길고, 대화가 많은 deployment가 원시 chunk 로그를 영원히 쌓지 않을 만큼 짧습니다.
  • Message 보존은 message store의 역할이며 event 보존보다 더 오래 유지되어야 합니다. transcript가 내구성 있는 산출물이고 event log는 전달 세부 사항입니다. 실행의 event가 만료된 후에도 thread는 계속 렌더링되어야 합니다. 아래 client 부분을 참고합니다.
  • Journal은 샌드박스 내부에서 pruneJournals에 의해 제한되며, reclaimSandbox가 샌드박스를 삭제하면 함께 사라집니다.

중요한 것은 순서입니다. event가 먼저 만료될 수 있어야 하며, message는 만료되어서는 안 됩니다.

Client: 만료된 event log를 허용하기

일반적인 경우에는 아무 작업도 필요하지 않습니다. useChatpersistence: true와 함께 mount 시 transcript를 가져온 후, 아직 생성 중인 실행만 tail합니다. 따라서 event log가 사라진 실행도 message에서 단순히 렌더링됩니다.

import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'

export function Thread({ threadId }: { threadId: string }) {
// On mount this GETs the transcript from the message store, then tails an
// `activeRun` if `reconstructChat` reports one. An aged-out event log means
// the tail yields nothing — the transcript is already painted, so the thread
// still renders correctly. This is why message retention must outlive event
// retention.
const { messages, sendMessage } = useChat({
threadId,
connection: fetchServerSentEvents('/api/chat'),
persistence: true,
})

return (
<div>
<p>{messages.length} messages</p>
<button onClick={() => void sendMessage('Continue')}>Continue</button>
</div>
)
}

직접 rejoin을 처리한다면 fallback을 명시적으로 구현합니다. 실행에 join하고, 아무것도 반환되지 않으면 빈 thread를 표시하는 대신 저장된 transcript로 fallback합니다.

import { fetchServerSentEvents } from '@tanstack/ai-client'
import { useEffect, useState } from 'react'
import type { StreamChunk } from '@tanstack/ai'

export function ThreadView({ threadId, runId }: {
threadId: string
runId: string
}) {
const [status, setStatus] = useState('joining')
const [chunks, setChunks] = useState<Array<StreamChunk>>([])

useEffect(() => {
const controller = new AbortController()
const connection = fetchServerSentEvents('/api/chat')

async function join(): Promise<void> {
let received = 0
for await (const chunk of connection.joinRun(runId, controller.signal)) {
received += 1
setChunks((previous) => [...previous, chunk])
}
// Zero chunks from a run the server knew about means its delivery log
// aged out (or was pruned). The transcript is still authoritative.
if (received === 0) setStatus('replaced-by-transcript')
else setStatus('joined')
}

void join().catch(() => setStatus('replaced-by-transcript'))
return () => controller.abort()
}, [runId])

// Reconstructed from the message store — always render this, and let the
// joined events refine it. Never gate the thread on the event log existing.
return (
<div>
<p>
{status}: {chunks.length} live events, thread {threadId}
</p>
</div>
)
}

같은 규칙이 server-side에도 적용됩니다. log만 replay하는 GET는 만료된 실행에 대해 빈 stream을 제공합니다. Persistence Overview에서 보여 주듯이, 먼저 message에서 재구성하도록 라우팅한 후 resume합니다.

제한 사항: instance-store list는 없습니다

SandboxInstanceStoreget / upsert / delete이며 enumeration이 없습니다. 이는 의도적인 거부입니다. list를 추가하면 하나의 가상 caller를 위해 모든 backend와 conformance suite에 enumeration을 추가해야 하기 때문입니다.

그 결과는 숨겨져 있지 않고 실제로 문서화되어 있습니다. sweep이 확인하기 전에 run record가 삭제된 샌드박스는 접근할 수 없습니다. 해당 샌드박스의 key를 지정할 수 없으므로 reclaimSandbox를 호출할 수 없으며, provider 자체의 idle reclamation이 회수할 때까지 남습니다. 다음 두 가지로 이러한 경우를 드물게 유지합니다. 실행이 terminal 상태가 되고 샌드박스가 회수된 후에만 run record를 prune하며, backstop으로 provider-side idle timeout을 설정합니다.

함께 보기

  • Durable Runs Explained: 이 sweep이 전체 흐름에서 어떻게 작동하는지 일반적인 언어와 코드 없이 설명합니다
  • Takeover & Detached Runs: 실행이 처음 분리되는 방식, sandboxRunDriver와 sweep가 재사용하는 단일 writer fencing
  • The Run Journal: journal을 probeRunExit이 읽는 방식과 pruneJournals의 제한
  • 참조 저장 listReclaimable와 reap 가능한 backend가 왕복 변환해야 하는 실행 필드
  • Sandbox Instance Durability: instance store에서 reclaimSandbox가 삭제하는 항목