본문으로 건너뛰기

인계 및 분리 실행

샌드박스 코딩 에이전트는 10분 동안 작업할 수 있지만 브라우저 탭이 그만큼 오래 유지된다는 보장은 없습니다. 사용자는 페이지를 새로고침하거나 노트북을 닫고, Wi-Fi 연결을 잃기도 합니다. 다음 요청이 로드 밸런서를 거쳐 다른 복제본으로 전달될 수도 있습니다.

영속성을 연결하지 않았다면 연결 끊김은 치명적입니다. withSandbox의 중단 경로는 중단이 발생할 때마다 의도적으로 샌드박스를 삭제합니다. 에이전트의 IO 스트림을 닫아도 에이전트 프로세스가 종료되지 않기 때문입니다(Docker exec는 클라이언트가 종료된 뒤에도 살아남습니다). 따라서 컨테이너 삭제만이 토큰 소비를 확실히 중단할 수 있습니다. 명시적인 취소에는 올바른 동작이지만 새로고침에는 지나치게 파괴적입니다.

영속적 실행은 두 경우를 구분합니다. 연결이 끊기면 실행을 분리합니다. 에이전트는 계속 작업하고 샌드박스는 유지되며, 실행 레코드에는 현재 관찰자가 없다는 사실이 기록됩니다. 이후 요청은 실행을 인계받아 이미 전달된 내용을 재생하고 나머지 스트리밍을 이어갑니다.

이 페이지에서는 이 연결 방식을 설명합니다. 먼저 실행 저널(에이전트 출력이 저장되는 위치와 파이프 대신 파일을 사용하는 이유)과 재개 가능한 스트림(클라이언트가 다시 연결하는 전달 로그)을 읽는 것이 좋습니다.

영속성은 두 개가 아닌 하나의 옵트인입니다

withSandboxrunsdurability를 받습니다. 두 값이 모두 있어야 실행이 영속적입니다. 이벤트 로그가 없는 레코드는 재생할 수 없고, 레코드가 없는 로그는 찾거나 소유권을 확보하거나 수거할 수 없으므로 어느 한쪽만으로는 쓸모가 없습니다. 반만 구성된 상태는 없습니다. 하나만 전달하면 영속성을 요청하지 않은 것으로 간주하며, 경고 없이 기존 동작을 그대로 사용합니다.

채팅 영속성에서 사용하는 것과 동일한 RunStore를 전달하세요. 그러면 서로 충돌하는 두 레코드가 아니라 하나의 레코드가 실행을 설명합니다.

두 경로는 동일한 백엔드 스트림을 가리켜야 합니다

예제를 보기 전에 이 원칙을 이해해야 합니다. StreamDurability하나의 실행에 바인딩됩니다. durableStream(request, options)는 코어의 resolveResumeRunId를 통해 실행을 확인합니다. 먼저 X-Run-Id 헤더를 보고, 그다음 요청 URL의 ?runId를 확인합니다. 코어의 memoryStream도 같은 해석기를 사용하므로 두 영속성 어댑터가 요청이 가리키는 실행을 서로 다르게 판단할 수 없습니다.

  • @tanstack/ai-client에서 보내는 POST는 실행 ID를 URL이 아닌 X-Run-Id 헤더에 담습니다. POST URL은 영속성을 사용하지 않는 일반 채팅 요청과 바이트 단위로 같습니다.
  • GET 연결은 실행 ID를 URL에 담습니다. joinRun?offset=-1&runId=<runId>를 요청합니다.

따라서 두 경로 모두에서 durableStream(request, durableOptions)는 run id가 어떤 방식으로 전달되었든 동일한 agent-runs/<runId> stream을 확인합니다. 다시 작성할 것도, 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',
}

어떤 run도 지정하지 않은 요청(header와 query 모두 없음)은, attach 요청이 절대 지정할 수 없는 stream에 조용히 output을 생성하는 대신 예외를 발생시킵니다.

durableStream: a runId is required: send it as an X-Run-Id header or a
?runId query param

이는 @tanstack/ai-client를 우회하는 client(예: custom fetch 또는 직접 작성한 reconnect)에만 영향을 줍니다. 두 가지 중 하나를 전송해야 합니다. POST의 X-Run-Id header 또는 URL의 ?runId입니다. 중간 스트림 SSE POST route에 다시 연결해도 여전히 작동합니다. Last-Event-ID 및 원래 POST에서 사용한 것과 동일한 X-Run-Id header를 함께 전달하므로, durableStream이 실행을 확인하고 재개 오프셋을 준수합니다.

실행 중인 Request가 전혀 없는 호출자(예: cron, Durable Object alarm())는 대신 처음부터 하나를 합성합니다. reaper 자체의 durabilityFor수거와 보존을 참조하세요.

서버: 영속 실행 시작

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

일반적인 샌드박스 채팅 엔드포인트와 비교하면 세 가지가 변경되었으며, 각각은 부하를 지탱하는 핵심 요소입니다:

  • runs + durability detach-on-disconnect를 활성화하고 버스에 게시합니다 DetachableRunCapability을(를) 버스에 게시하며, 이것이 withPersistence이(가) 학습하는 방식입니다 이 실행의 abort가 cancel이 아닌 detach라는 것을 학습합니다.
  • runId은(는) 전달되며, 생성되지 않습니다. 저널 경로와 결정적 message-id generator, 그리고 백엔드 스트림 이름은 모두 이를 기반으로 파생되므로, 후속 호스트는 재계산할 수 있는 runId을(를) 가진 실행만 재개할 수 있습니다.
  • 어댑터 인스턴스는 withSandbox 과 응답 사이에 공유되며, 바로 그 동일한 runId 을 키로 사용합니다. 이 때문에 아래의 연결 경로가 바로 이 스트림을 가리킬 수 있습니다.

반드시 존재해서는 안 되는 것이 하나 있습니다: abortControllerrequest.signal 을 반영하는 경우입니다.

플레인 샌드박스 엔드포인트는 이를 반영합니다. 연결이 끊기면 실행이 종료되어야 합니다. 내구성 있는 실행에서는 보호하려는 것을 파괴합니다. 실행을 중단하면 chat() 가 미들웨어 setup 직후 취소 확인에서 반환되므로, harness 어댑터의 chatStream 는 호출되지 않으며 샌드박스 내 에이전트는 시작되지 않습니다. UI 가 여전히 "샌드박스 시작 중"이라고 표시하는 동안 탭을 전환하면 아무것도 하지 않은 실행에 대해 빈 로그로 돌아옵니다. 재수집도 이를 복구할 수 없습니다. 시작되지 않은 에이전트는 재생할 저널을 기록하지 않았기 때문입니다.

연결은 withSandbox 을 도달합니다. 내구성 있는 전송은 응답 바디가 취소되는 순간 실행을 알립니다. 중단하지 않고 그런 다음:

  • withSandboxdetachedSincesandboxKey 를 찍고 분리 결과를 게시합니다.
  • 실행은 여전히 열린 전달 로그로 계속 배출됩니다.
  • 재결합 클라이언트는 해당 로그를 추적합니다.

runsdurability 를 전달하면 모든 것이 해결됩니다. 더 이상 연결할 것은 없습니다.

진정한 정지는 대역 밖에서 도착하므로 영향을 받지 않습니다 (참조 Detach vs cancel). 이것이 "사용자가 이 작업을 중지 원한다"와 "사용자가 탭을 닫았다"를 구분할 수 있는 유일한 채널입니다. 둘 다 와이어에서 동일한 소켓 닫힘이기 때문입니다.

memoryStream 에서 첫 번째 청크의 데드라인을 높입니다.

샌드박스 빌드 중 재결합은 Memory stream run produced no data within 100ms 로 실패하며, 호출된 런은 건강합니다. memoryStream 는 런이 firstChunkDeadlineMs 내 청크를 생성하지 않으면 시작부터의 재결합을 포기합니다. 이 기본값은 100ms 입니다. 이 기본값은 채팅에 적합하며, 비행 중인 런의 로그에는 이미 청크가 포함됩니다. 샌드박스화된 런은 ensure 가 샌드박스를 빌드하고 저장소를 복제할 때까지 아무것도 출력하지 않습니다.

런을 위해 핸들마다 모든 것에 대해 이를 제기합니다. 재결합을 제공하는 GET 는 실제로 이를 적용하는 호출입니다:

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

재결합을 findActiveRun에 제한하면 빠른 실패는 아무것도 얻지 못합니다. 이는 이미 재결합이 시도되기 전에 실제로 사라진 런을 제외합니다.

두 가지가 이를 주요 방어책이 아닌 백업으로 만듭니다:

  • durableStream 는 첫 번째 청크 마감 시간을 전혀 실행하지 않습니다. read 는 라이브 reader 이므로 비어 있는 in-flight 로그는 단순히 대기합니다.
  • 새로운 내구성 있는 프로듀서는 한 CUSTOM run.accepted chunk 를 추가합니다. (RUN_ACCEPTED_EVENT) 프로듀서 스트림을 가져오기 전에 수행되므로, 재결합이 연결됩니다. 하네스 대기 대신 밀리초 단위로 완료됩니다. 해당 청크는 클라이언트가 연결을 포기하지 않도록 합니다. 재결합을 포기하는 것인데, ai-client 은 아무것도 받지 못하는 재결합을 포기하고 2 초가 걸리고 샌드박스 빌드에는 항상 더 오래 걸립니다.

서버: 실행을 넘겨받기

takeover 는 GET 핸들러에서 발생하며, 이미 재개 요청을 처리합니다. driver 를 추가하고, 로그를 재생하는 동일한 요청이 실행을 주장하여 계속 진행합니다.

여기 있는 모든 어댑터는 동일한 durableStream(request, durableOptions) POST 라우트를 사용하므로, 해당 라우트가 재생하는 로그, 드라이브 앱이 추가하는 로그, 그리고 생성한 라우트가 작성한 로그는 증명 가능한 하나의 스트림입니다: 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,
}),
})
}

driver 를 전달하든 아니든 응답은 byte-identical 합니다: 여전히 내구성 로그에서 재생됩니다. 드라이브는 그 옆에서 실행되어 런의 프로듀서 측 로그에 추가하고, 응답은 착륙한 내용을 따라갑니다. 이 분리는 chat() 의 정상 미들웨어 경로를 계속 유지하도록 허용하므로, withPersistenceonFinish 는 분리된 상태에서 완료된 런의 트랜스크립트를 여전히 저장합니다.

타오버에 관한 모든 것은 구조적으로 완전합니다. 모든 실패는 기록되고 삼켜지며, 응답은 여전히 로그를 제공합니다:

상황발생 사항
요청에 실행 ID가 없거나 레코드가 없음로그를 제공하고 실행은 진행하지 않습니다.
레코드가 이미 종결 상태임로그를 제공하고 실행은 진행하지 않습니다. 완료된 실행에 연결하는 두 번째 탭도 트랜스크립트를 볼 수 있어야 합니다.
다른 호스트가 점유권을 보유함로그를 제공하고 실행은 진행하지 않습니다. 두 탭이 동시에 연결되면 하나가 임대를 획득해 실행하고 다른 하나는 로그를 따라갑니다.
실행 중 예외가 발생함서버 측에 기록됩니다. 이미 스트리밍 중인 응답에는 보고할 수 없으며, 실행 자체의 RUN_ERROR 이벤트가 해당 채널입니다.

서버리스 플랫폼은 백그라운드 구동을 위해 keep-alive 가 필요합니다. waitUntil: (promise) => ctx.waitUntil(promise) 을 전달합니다.

클라이언트: 재연결 및 계속 진행

중간 스트림 중단은 useChat 이 마지막 오프셋으로 재연결하고 서버가 로그에서 재생합니다.

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

완전한 리로드는 다릅니다. 페이지는 Last-Event-ID 없이 돌아오므로, 어떤 실행이 여전히 진행 중인지 물어보고 joinRun (읽기 전용 GEToffset=-1) 로부터 시작하여 참여해야 합니다. 이는 위의 핸들러와 정확히 같습니다. GET 이 실행을 주장하므로, 참여하고 인수하는 것은 동일한 요청입니다.

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

"진행 중인 실행 찾기" 엔드포인트는 RunStore.findActiveRun을 사용해 안정적인 threadId에서 활성 실행을 찾습니다. 단일 턴이 끝날 때 함께 사라질 수 있는 일회성 실행 ID를 사용하지 않습니다. 이 저장소 메서드는 선택 사항이므로 기능 감지가 필요합니다.

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

네 가지 HTTP 연결 어댑터(fetchServerSentEvents, fetchHttpStream, xhrServerSentEvents, xhrHttpStream)는 모두 joinRun를 제공합니다. NDJSON에서는 서버의 resumeHttpResponse와 함께 사용합니다. 드라이버 연결 방식은 동일합니다.

분리와 취소

이 부분은 잘못 이해하기 쉽고, 잘못 이해하면 비용이 큽니다. 사용자가 Stop을 누르는 경우와 탭을 닫는 경우에는 완전히 동일한 연결 종료가 발생합니다. 연결 끊김만으로는 두 경우를 구분할 수 없습니다.

따라서 연결 끊김에서 의도를 추론하지 않습니다. 의도는 대역 외부에서 전달되며, 정확히 두 채널 중 하나가 권위 있는 출처가 됩니다:

  1. 영속적: requestRunCancel(runs, runId)은 실행 레코드에 cancelRequested을 기록합니다. 이는 취소 요청이 도착한 호스트와 다른 호스트에서 구동되는 실행에 도달하는 유일한 채널이며, 분리된 실행에서는 일반적인 경우입니다.
  2. 프로세스 내: 실행 자체의 AbortControllerRUN_CANCEL_REASON으로 중단합니다. 코어는 AbortInfo를 만들 때 그 사유를 다시 읽습니다. 따라서 해당 중단에서는 AbortInfo.cancelRequestedtrue이고, 일반 연결 끊김에서는 false입니다. 취소가 실행 호스트에 도달했을 때 사용하는 빠른 경로입니다.

취소 엔드포인트는 두 작업을 모두 수행해야 합니다. requestRunCancel은 의도적으로 상태를 기록하지 않습니다. 의도를 기록하는 것과 실행이 중지된 것은 다르며, 에이전트가 실제로 종료되고 샌드박스가 제거된 시점은 드라이버만 알 수 있습니다.

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

driving은 실행의 AbortController를 생성하는 지점에서 채웁니다. 새 실행의 POST 핸들러와 takeover 경로의 controllerFor에서 각각 설정합니다.

클라이언트에서 chat.stop()만으로는 취소되지 않습니다. 이 호출은 로컬 AbortController만 중단하고 서버에는 아무것도 보내지 않습니다. 영속적 실행에서는 새로고침과 구분할 수 없으므로 에이전트가 계속 실행됩니다. 취소 엔드포인트도 함께 호출해야 합니다:

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

각 경로에서 기록하는 내용

이벤트withSandboxwithPersistence전달 로그
연결 끊김, 영속적 실행, detachOnDisconnect 활성화, 취소 기록 없음샌드박스를 유지하고 detachedSincesandboxKey 기록아무것도 기록하지 않으며 레코드는 'running' 상태로 유지터미널 이벤트를 추가하지 않고 열린 상태로 유지
취소(두 채널 중 하나)destroyOnComplete와 관계없이 항상 샌드박스 삭제'aborted'finishedAt와 함께 기록터미널 RUN_ERROR를 추가한 뒤 닫음
비영속적 실행의 연결 끊김샌드박스 삭제'aborted' 기록터미널 RUN_ERROR를 추가한 뒤 닫음

전달 로그 열의 동작 덕분에 takeover가 가능합니다. 분리된 실행의 로그는 열린 상태이며 터미널 이벤트가 없어야 합니다. 로그가 닫히면 연결하는 클라이언트의 재생이 접두부에서 끝납니다. 또한 저장된 합성 RUN_ERROR는 takeover의 저널 재생으로 재현할 수 없는 청크이므로 정렬이 어긋나고, 정상적인 실행조차 'failed'로 기록됩니다. 판정이 전송 계층에 전달되는 방식은 RunDetachedCapability을 참고하세요.

keepAlive / destroyOnComplete: false정상 완료만 제어합니다. 취소된 경우에는 샌드박스를 계속 유지하지 않습니다.

어느 쪽이든 반대로 이해하면 문제가 발생합니다. Stop 버튼에서 chat.stop()만 호출하면 아무도 지켜보지 않는 상태로 토큰을 계속 소비하는 샌드박스가 남고, 모든 연결 끊김을 취소로 처리하면 Wi-Fi가 잠깐 끊겼을 뿐인데 사용자의 10분짜리 리팩터링 작업이 종료됩니다.

실행을 종료할 수 없는 프로바이더에서 취소가 의미하는 것

기능에 killableProcesses: false가 선언된 프로바이더(번들 프로바이더 중 Daytona, Vercel, Cloudflare이며, 측정 결과는 측정 표 참고)에서는 에이전트 프로세스에 어떤 신호도 전달되지 않습니다. kill()로는 프로세스를 중지할 수 없고, AbortSignal도 프로바이더 경계를 통과하지 않습니다. 이 경우에는 취소 경로가 수행하는 샌드박스 삭제가 단순한 정리 작업이 아니라 취소 자체입니다. 실제로 에이전트를 중지할 수 있는 유일한 메커니즘이기 때문입니다. 취소 동작을 이보다 약하게 구성하면(레코드를 'aborted'로 표시하고 로그만 닫은 채 삭제를 건너뛰면) 결국 "취소된 것으로 표시됐지만 여전히 실행 중"인 상태가 됩니다. UI에는 중지된 것으로 보이지만 에이전트는 계속 작업하고 샌드박스 비용도 계속 발생합니다.

단일 작성자 안전성

하나의 실행은 한 호스트만 기록할 수 있으며, 클라이언트에는 오프셋 중복 제거보다 하위 수준의 안전장치가 없습니다. 두 호스트가 모두 로그를 스냅샷하고 각각 "나머지"를 계산해 추가하면, 동일한 논리적 청크가 서로 다른 두 오프셋에 기록됩니다. 클라이언트는 이를 새로운 청크로 인식하고 스트림 프로세서는 텍스트와 도구 인수 델타를 조건 없이 적용합니다. 결과적으로 본문과 {"a":1}{"a":1} 도구 인수가 중복됩니다. takeover는 본질적으로 두 호스트가 하나의 실행을 차지하려는 과정이므로, 배타적 소유권을 실제로 보장해야 합니다.

sandboxRunDriver는 이를 세 계층으로 구성합니다:

  1. 임대. 전체 드라이브가 실행별 키를 사용해 LockStore.withLock 내부에서 동작하므로, 스냅샷과 이후의 모든 추가 작업이 하나의 임계 구역에 속합니다. 임대 기반 잠금은 소유권을 잃는 즉시 드라이브의 신호를 중단합니다.
  2. 에포크. 클레임에 성공할 때마다 RunRecord.driverEpoch가 증가합니다. 로그는 이를 주기적으로 다시 읽고, 더 높은 에포크가 있으면 추가 작업을 거부합니다. 이는 임대만으로 처리할 수 없는 경우, 즉 잠금 갱신 주기가 실행의 로그 추가 주기보다 길거나 잠금의 신호가 전혀 발생하지 않는 경우를 보완합니다.
  3. 정지 상태. 후속 호스트는 처음 추가하기 전에 저장된 로그가 더 이상 늘어나지 않을 때까지 기다립니다. 따라서 여전히 기록 중인 이전 호스트와 경쟁하지 않고 그 상태를 관찰합니다. 기본 대기 시간은 DEFAULT_FENCE_QUIET_MS(5초)이며 fenceQuietMs로 설정할 수 있습니다. 로그가 끝내 정지 상태가 되지 않으면, 다른 호스트가 기록 중인 로그에 추가하지 않고 드라이브가 실패합니다.

두 권위 채널 모두에 적용되는 펜싱

실행에 관한 사실은 이벤트 로그와 레코드라는 위치에 존재합니다. 로그에만 펜싱을 적용하면 피해가 사라지는 것이 아니라 위치만 바뀝니다. 대체된 드라이버는 추가 요청이 거부되면 그 거부를 터미널 runs.update로 처리하고, 후속 호스트가 정상적으로 스트리밍 중인 실행의 레코드가 'failed' 상태로 바뀝니다. 그러면 터미널 상태에 따라 분기하는 모든 소비자(isTerminalRunStatus, findActiveRun, 상태 폴러, 리퍼)가 더 이상 실행을 소유하지 않는 호스트의 판단을 근거로 살아 있는 실행이 종료됐다고 오인합니다.

따라서 동일한 클레임을 기준으로 두 경계를 모두 차단합니다:

  • 로그. 대체된 드라이버의 append를 거부합니다. 첫 번째 거부가 펜스를 영구적으로 닫으며, 이후의 모든 추가 요청은 다시 읽지 않고 즉시 거부됩니다. 이러한 영속성이 중요한 이유는 거부된 추가 요청 바로 뒤에 복구 경로 자체의 터미널 RUN_ERROR가 이어지고, 해당 로그는 후속 호스트의 것이기 때문입니다. 이전 호스트의 오류 청크가 기록되면 정상 실행에 연결된 모든 클라이언트의 스트림이 실패합니다.
  • 레코드. 잃어버린 클레임에서 발생한 터미널 상태 update억제되어 아무것도 기록하지 않고 완료됩니다. 오래된 detachedSince 또는 sandboxKey을 포함한 비터미널 기록은 계속 허용됩니다. 이 값들은 실행 중인 작업을 완료된 것처럼 보이게 만들 수 없으며, 어차피 후속 호스트가 소유하고 덮어씁니다.

실무적으로는 레코드의 터미널 상태를 신뢰할 수 있습니다. 상태 폴러도 이를 신뢰해도 됩니다.

close()은 의도적으로 두 펜스 바깥에 있습니다. 클레임 상실로 인해 시작된 종료 경로를 포함해 모든 종료 경로에서 실행됩니다. close에 펜싱을 적용하면 레코드가 'running' 상태로 고정되고, 실행 중인 모든 tailer가 영원히 대기하게 됩니다. 영속성 read는 로그가 닫혀야 끝나기 때문입니다. 클라이언트가 대기한 채 멈춘 실행은 해당 기록을 막지 않는 것보다 더 나쁩니다.

분기 처리할 수 있는 오류

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
}

처음 두 오류는 takeover 경합에서 발생할 수 있는 일반적인 결과이며, resumeServerSentEventsResponse가 이미 둘 다 처리합니다. 응답이 아니라 로그에서 확인하게 됩니다.

보장하지 않는 사항

완벽한 펜싱을 보장하지는 않습니다. 마지막 펜스 확인과 백엔드에 추가 요청이 도착하는 시점 사이에 이전 호스트가 정지(GC 또는 VM 일시 중단)한 시간이 정지 상태 대기 시간보다 길면 한 배치를 추가로 기록할 수 있습니다. 이 가능성을 없애려면 영속성 기록에 compare-and-set이 필요하지만 StreamDurability.append는 이를 제공하지 않습니다. 배포 수준에서 완화하려면 임대 기반 분산 LockStore를 사용하고, fenceQuietMs을 임대 갱신 간격보다 길게 설정하세요.

재생과 불일치

takeover는 이전 호스트가 중단한 위치부터 저널을 재개하지 않습니다. 저널을 0번째 바이트부터 다시 읽고 변환하므로, 클라이언트가 이미 보유한 청크도 다시 생성됩니다. 이 과정을 안전하게 만드는 것이 정렬입니다. 저장된 로그를 한 번 읽고, 재생 결과를 지문으로 검증하며, 일치하는 접두부를 억제한 뒤 나머지만 추가하고 전달합니다. 로그 자체가 체크포인트이므로 체크포인트와 로그가 서로 달라질 수 있는 구간이 없습니다. 이 방식이 구현하는 우선순위 규칙은 다음과 같습니다. 클라이언트에 보이는 내용은 로그를 따르고, takeover 드라이버가 재개할 위치는 저널을 따릅니다.

다음 두 속성 덕분에 비교할 수 있으며, 저널을 사용하는 경로에서는 이미 둘 다 충족합니다:

  • ID가 결정적입니다. 저널을 사용하는 실행은 타임스탬프와 난수 대신 실행 범위 카운터 (<runId>-0, <runId>-1, …)에서 메시지 ID를 생성합니다.
  • 정렬은 연결할 때만 실행됩니다. 새 실행에서는 변환의 전제("이 스트림은 이미 저장된 내용의 재생이다")가 거짓입니다. 이때 정렬하면 새 실행의 청크를 기존 로그 항목과 일치시키고 조용히 억제할 수 있습니다. 이는 느린 경로가 아니라 데이터 손실입니다.

JournalReplayDivergedError

재생이 해당 인덱스에 이미 저장된 로그 청크와 다른 청크를 생성하면, 인덱스와 두 지문을 포함한 JournalReplayDivergedError가 발생합니다.

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
}

의미는 명확합니다. 에이전트의 재생이 로그가 이미 전달한 이벤트와 다른 순서를 생성했습니다. 변환이 더 이상 결정적이지 않다는 뜻입니다. 현실적인 원인은 실행 범위가 아닌 ID 생성기, 시간을 참조하는 변환기, 또는 다시 작성된 저널(대부분 재사용된 runId)입니다.

이를 복구 가능한 상태가 아니라 수정해야 할 버그로 취급하세요. 오류를 잡고 계속 진행해서는 안 됩니다. 로그는 권위 있는 출처이며 이미 클라이언트에 전달됐습니다. 불일치 이후의 내용을 계속 전달하면 메시지 ID에 관해 접두부와 접미부가 서로 모순되는 스트림이 만들어지고, 클라이언트는 이를 처리할 수 없습니다. 인덱스와 두 지문을 로그에 남기고, 실행을 실패 처리한 다음 runId의 고유성을 먼저 확인하세요.

한 가지 예외 허용이 있습니다. 호스트 도구 브리지 이벤트를 출력에 삽입하는 어댑터(@tanstack/ai-codex, @tanstack/ai-claude-code)에서는 실제 도구 실행 중 발생한 CUSTOM 청크가 로그에 포함됩니다. 재생 중에는 도구를 실행하지 않으므로 이 청크를 재현할 수 없습니다. 정렬은 이를 대역 외부 이벤트로 간주해 연속 DEFAULT_MAX_OUT_OF_BAND_SKIP개(64개)까지 건너뜁니다. 이 제한 덕분에 해당 동작이 검색이 아니라 예외 허용으로 유지됩니다. 제한이 없다면 실제 결정성 회귀가 우연히 일치하는 지문을 찾을 때까지 계속 앞으로 검색하게 됩니다.

구성

다음 옵션은 모두 withSandbox(sandbox, { durability: { … } }) 아래에 있습니다.

옵션기본값동작
adapter필수실행의 전달 영속성을 보장하는 이벤트 로그입니다. 전송 계층에 전달하는 것과 같은 인스턴스입니다.
journal/tmp/tanstack-runs(DEFAULT_JOURNAL_DIR)샌드박스 내부의 저널 디렉터리입니다.
detachOnDisconnect영속성이 연결된 경우 항상 true연결이 끊겼을 때 샌드박스를 삭제하지 않고 분리할지 여부입니다.
attachfalse에이전트를 시작하는 대신 기존 실행의 저널을 읽습니다. 연결 경로의 drive에서 설정하며, POST 핸들러에서는 설정하지 않습니다.
pollIntervalMs어댑터 기본값파일을 follow할 수 없는 프로바이더에서 사용하는 저널 폴링 간격입니다.

여기에는 의도적으로 detachedRunTtl이 없습니다. TTL을 적용하는 유일한 주체는 reapDetachedRuns이며, 채팅 요청이 진행 중이지 않을 때 cron에서 실행됩니다. 따라서 요청별 기능 버스에 withSandbox가 게시한 값을 읽을 수 없습니다. 여기에 저장한 TTL은 읽히지 않는 반면 sweep은 자체 detachedRunTtlMs를 사용하므로, 두 값이 조용히 달라질 수 있습니다. 따라서 sweep의 detachedRunTtlMs(밀리초 단위, 기본값 없음, 문자열 파싱 없음)이 단일 진실 공급원입니다. 값을 정하는 방법은 수거와 보존을 참고하세요.

리퍼는 제공되지만 자동으로 예약되지는 않습니다. reapDetachedRuns@tanstack/ai-sandbox에서 제공하는 sweep입니다. 이 함수는 선택적 RunStore.listReclaimable({ now, ttlMs })를 읽습니다. 이 메서드는 'running' 상태이고 detachedSince가 있으며, detachedSince <= now - ttlMs인 실행을 반환합니다(경계값 포함). 대역 외부 저널 프로브에서 이미 완료됐다고 확인된 각 실행을 터미널 상태까지 진행해 트랜스크립트를 저장하고, detachedRunTtlMs를 지난 실행은 취소하고 터미널 상태로 만든 뒤 sandboxKey가 지정한 샌드박스를 sandboxReclaimer를 통해 삭제합니다. detachedSince는 절대 지우지 않습니다. 이 표시는 TTL 계산에서 해당 실행을 선택했다는 근거입니다. (takeover 경로에서는 뷰어가 다시 연결됐으므로 이 표시를 지웁니다.)

타이머나 데몬이 없는 일반 비동기 함수이므로 일정에 따라 호출하는 것은 사용자의 책임입니다. cron 경로, 큐 소비자, Durable Object의 alarm(), waitUntil 등을 사용할 수 있습니다. 이를 durability 연결의 선택 사항이 아니라 필수 요구 사항으로 취급하세요. 호출하는 주체가 없으면 분리된 실행의 전달 로그가 닫히지 않아 연결한 모든 클라이언트가 영원히 대기하고, detachedRunTtlMs를 적용하는 주체도 없으며, 샌드박스 비용이 무기한 발생합니다.

수거와 보존에서 전체 흐름을 설명합니다. sweep의 결과, 실행 완료 여부를 확인하려고 실행을 구동하지 않는 이유, pruneJournals, reclaimSandbox, Node와 Vercel Cron 및 Cloudflare alarm()에 바로 붙여 넣을 수 있는 일정 예제, 그리고 sweep 간격에 맞춰 TTL을 정하는 방법을 다룹니다.

detachOnDisconnect: false로 설정하면 현재의 연결 끊김 시 삭제 비용 특성을 유지하면서도 재개 가능한 전달을 사용할 수 있습니다. 페이지를 새로고침하면 로그는 재생되지만 에이전트는 연결 끊김 이후에도 계속 실행되지 않습니다. 명시적으로 취소하면 설정과 관계없이 샌드박스를 삭제합니다.

DetachableRunCapability

코어가 소유하는 중립적인 불리언입니다. withSandbox은 실행이 실제로 영속적일 때만 이를 true로 제공합니다. withPersistencegetOptional으로 값을 읽어 중단을 터미널 처리할지('aborted'), 아니면 아무것도 기록하지 않고 분리할지 결정합니다. 두 패키지 중 어느 쪽도 다른 패키지를 가져오지 않고 같은 결정을 내릴 수 있도록 코어에 둡니다. persistence에서 sandbox를 가져오면 계층이 뒤집힙니다. 자체 미들웨어에서 같은 구분이 필요하면 이 값을 읽으세요. 값이 없으면 "분리할 수 없음"을 의미하며, 영속성을 연결하지 않은 모든 앱이 이에 해당합니다.

RunDetachedCapability

과거 시점의 상태를 나타내는 대응 값이며 역시 코어가 소유합니다. DetachableRunCapability은 설정 시 게시되며 연결 끊김 이후에도 실행이 계속될 수 있음만 나타냅니다. RunDetachedCapability은 중단 경로에서 withSandbox의 분리 분기가 게시하며, 실행이 실제로 분리됐음을 나타냅니다. 에이전트는 계속 작업 중이고 나중에 다시 연결해 takeover할 수 있습니다.

이 값을 사용하는 곳은 toServerSentEventsResponse / toHttpResponse 뒤의 영속적 전달 sink입니다. 판정이 없으면 sink가 모든 중단을 터미널 처리하므로 takeover가 작동하지 않습니다(위 표 참고). 이 정보는 스트림 객체 자체를 통해 전달되므로 별도로 연결할 것이 없습니다. chat()의 스트림을 응답 헬퍼에 전달하는 것은 이미 필수이며, 양쪽이 같은 객체를 보유합니다.

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

분리 가능한 실행에서 의도 없이 연결만 끊긴 경우에만 이 값을 게시합니다. 두 채널 중 하나를 통한 명시적 취소, 분리할 수 없는 실행의 연결 끊김, detachOnDisconnect: false, 프로바이더 오류, 정상 완료 시에는 게시하지 않습니다. 이때 sink는 기존과 동일하게 터미널 이벤트를 추가하고 로그를 닫습니다. 또한 코어는 미들웨어가 어떤 값을 게시하더라도 RUN_CANCEL_REASON을 포함한 중단을 분리로 처리하지 않습니다. 사용자가 Stop을 누르면 항상 닫힌 터미널 로그를 받습니다.

요구 사항

실제 LockStore. InMemoryLockStore은 여러 호스트 사이를 조정할 수 없습니다. 한 프로세스 안에서만 클레임을 직렬화하고, 반환하는 신호는 절대 중단되지 않는 새로운 AbortController().signal이므로 임대 상실을 보고할 수 없습니다. 따라서 두 복제본이 하나의 실행을 구동하고 로그를 중복 기록할 수 있습니다. 인메모리 잠금 위에 영속성을 연결하면 withSandbox이 경고를 출력합니다. 잠금을 전혀 연결하지 않은 경우도 fallback이 인메모리이므로 여기에 포함됩니다. 잠금을 참고하세요.

호출자가 제공하는 runId. 영속적 실행에서 값이 전달되지 않으면 DurableRunIdRequiredErrorchatStream 시작 시 발생합니다. 의도적으로 눈에 띄게 실패하도록 설계했습니다. 어댑터가 생성한 ID를 사용하면 후속 호스트가 다시 계산할 수 없는 저널 경로가 만들어집니다. 실행과 기록은 정상적으로 보이지만 조용히 복구 불가능한 상태가 되며, 장애가 발생해야만 이를 발견하게 됩니다.

연결하는 실행에서는 실행 레코드의 threadId. ATTACHING 상태인 영속적 실행에서는 DurableThreadIdRequiredError이 발생할 수 있습니다. 실행 레코드의 threadId 없이 구동하는 경우입니다. threadId모든 방출 청크에 포함되므로, 연결 과정에서 새 값을 생성하면 첫 청크부터 저장된 로그와 다른 스트림이 재생됩니다. 에이전트가 동일하게 동작했더라도 인덱스 0에서 정렬이 실패하며, 위에서 설명한 JournalReplayThreadIdMismatchError이 발생합니다. 이는 위에서 설명한 JournalReplayDivergedError의 하위 클래스입니다. 다음 비대칭을 구분해야 합니다. 영속적인 실행은 이 값을 직접 설정하므로 호출자의 threadId이 필요하지 않지만, 연결 중인 영속적 실행은 레코드에 이미 있는 값을 재사용해야 합니다. 바로 threadId: input.threadId이며, 위에서 driveRun이 전달합니다.

영속적 실행의 모든 필드를 보존하는 RunStore. createOrResume, update, get이 필요합니다. updatestatus, finishedAt, error, usage, sandboxKey, detachedSince, cancelRequested, driverEpoch을 모두 받아들이고 왕복 보존해야 합니다. 직접 작성한 백엔드는 마지막 네 필드를 빠뜨리기 쉽고, 각 누락은 특정 메커니즘을 망가뜨립니다. driverEpoch이 없으면 펜싱할 수 없고, cancelRequested이 없으면 Stop 요청이 원격 드라이버에 도달하지 않으며, detachedSince/sandboxKey이 없으면 샌드박스를 회수할 수 없습니다. 두 가지 불변 조건도 지켜야 합니다. createOrResume은 기존 레코드를 변경하지 않고 반환해야 하며(그래야 안전하게 재개할 수 있음), update은 알 수 없는 runId에 대해 오류를 발생시키지 않는 no-op이어야 합니다.

findActiveRun은 스레드로 다시 참여하는 데 필요하므로 필수입니다. listReclaimable은 선택 사항이며 기능 감지 방식으로 사용되지만, reapDetachedRuns이 sweep할 대상을 얻으려면 이 메서드가 필요합니다. 이를 구현하지 않은 스토어의 실행은 전혀 수거할 수 없습니다.

어댑터 작성자

어플리케이션이 아닌 harness adapter 를 작성 중이라면 세 가지 export 가 있습니다: getSandboxDurability 는 capability bus 에서 해결된 durability 를 읽습니다, journalOptionsFor 는 이를 journal option 으로 변환합니다 spawnNdjson 는 붙이기 시에만 alignedIfAttaching 정렬을 취하고 적용합니다. merged 출력 스트림을 감싸고, 사전 병합 번역기를 감싸지 마십시오. 그렇지 않으면 로그에 포함되지 않은 스트림과 비교하게 됩니다. Harnesses 를 참조하십시오.

관련 항목