Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/sandbox-durability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/ai-sandbox': minor
---

Durable **sandbox instance** resume for multi-process / multi-replica deploy.

- **`SandboxInstanceStore` / `SandboxInstanceRecord` / `InMemorySandboxInstanceStore` / `SandboxInstanceStoreCapability`** in `@tanstack/ai-sandbox`
- **`withSandbox(sandbox, { instances, locks? })`** takes the store directly, so it cannot be mis-ordered (in-memory fallback when absent). `SandboxInstanceStoreCapability` + `provideSandboxInstanceStore` remain for ambient/platform wiring; an explicit option wins over the bus.
- **`defineSandboxInstanceStore`** for inline BYO typing (same pattern as `defineLock` / `defineMessageStore`)
- Pair multi-instance with **`withLocks`** from `@tanstack/ai/locks` (distributed lock)
- Independent of chat persistence — compose both when the app needs transcript durability _and_ instance reuse
- Conformance: `runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`
5 changes: 3 additions & 2 deletions docs/advanced/locks.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ section for the same key:

- **Sandbox resume-or-create** (`withSandbox` / `ensure`) — two concurrent runs
for the same thread must not both create a provider sandbox. See
[Sandboxes](../sandbox/overview).
[Sandbox Instance Durability](../sandbox/durability).
- **Your own middleware** — any multi-writer work you want to serialize across
workers (e.g. a custom “one active job per thread” gate).

Expand Down Expand Up @@ -182,6 +182,7 @@ Or provide without `withLocks` by calling `provideLocks` in your own
## See also

- [Middleware](./middleware) — capability bus and lifecycle
- [Sandboxes](../sandbox/overview) — primary product consumer of locks today
- [Sandboxes](../sandbox/overview) — sandbox middleware overview
- [Sandbox Instance Durability](../sandbox/durability) — primary product consumer (`withSandbox` / `ensure`)
- [Persistence Controls](../persistence/controls) — compose state stores from different systems
- [Build Your Own Adapter](../persistence/build-your-own-adapter) — chat store contracts
8 changes: 7 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,8 @@
{
"label": "Locks",
"to": "advanced/locks",
"addedAt": "2026-07-27"
"addedAt": "2026-07-27",
"updatedAt": "2026-07-28"
},
{
"label": "OpenTelemetry",
Expand Down Expand Up @@ -514,6 +515,11 @@
"addedAt": "2026-06-29",
"updatedAt": "2026-07-09"
},
{
"label": "Instance Durability",
"to": "sandbox/durability",
"addedAt": "2026-07-27"
},
{
"label": "Events",
"to": "sandbox/events",
Expand Down
194 changes: 194 additions & 0 deletions docs/sandbox/durability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
title: Sandbox Instance Durability
id: sandbox-durability
order: 9
description: "Durable sandbox instance resume across processes with a SandboxInstanceStore passed to withSandbox."
---

Your agent runs behind more than one server instance, or at the edge. A run
spins up a sandbox, clones the repo, installs deps, does its work. The next run
for the same thread should pick that sandbox back up. Instead it builds a fresh
one every time and pays the whole cold-start cost again.

[Lifecycle & Snapshots](./lifecycle) already knows how to resume, but its
bookkeeping is in-memory, so it only holds within one process. The moment a run
lands on a different replica (or a fresh isolate), that instance has never seen
the sandbox and re-creates it.

**Sandbox instance durability** is runtime placement — not chat history. It is
owned by `@tanstack/ai-sandbox`, independent of `@tanstack/ai-persistence`
(transcript / runs / interrupts). You may share a database with chat stores, but
you compose a separate middleware.

Two pieces:

- **`SandboxInstanceStore`**: map of compound key → provider sandbox id (+
optional snapshot). Durable, shared across instances.
- **`LockStore`** (from `@tanstack/ai/locks`): mutual exclusion around resume-or-create.
Multi-instance needs a distributed lock. See [Locks](../advanced/locks).

## Wire it up

Hand the store to `withSandbox`. The lock is a separate middleware because other
middleware can share it, and it must come **before** `withSandbox`.

```ts
import { chat } from '@tanstack/ai'
import { InMemoryLockStore, withLocks } from '@tanstack/ai/locks'
import { grokBuildText } from '@tanstack/ai-grok-build'
import {
InMemorySandboxInstanceStore,
defineSandbox,
defineWorkspace,
withSandbox,
} from '@tanstack/ai-sandbox'
import type { ModelMessage } from '@tanstack/ai'

// Single-process: in-memory is fine for local dev.
// Multi-instance: your durable SandboxInstanceStore + distributed LockStore.
const instanceStore = new InMemorySandboxInstanceStore()
const messages: Array<ModelMessage> = [{ role: 'user', content: 'hi' }]

const sandbox = defineSandbox({
id: 'repo',
provider: {
name: 'example',
capabilities: () => ({
fs: true,
exec: true,
env: true,
ports: false,
backgroundProcesses: false,
writableStdin: false,
snapshots: false,
networkPolicy: false,
durableFilesystem: false,
fork: false,
}),
create: () => {
throw new Error('example provider — wire a real SandboxProvider')
},
resume: () => Promise.resolve(null),
destroy: () => Promise.resolve(),
},
workspace: defineWorkspace({ source: { type: 'none' } }),
})

chat({
adapter: grokBuildText('grok-build'),
messages,
middleware: [
withLocks(new InMemoryLockStore()),
withSandbox(sandbox, { instances: instanceStore }),
],
})
```

With `reuse: 'thread'` (the default), the first run creates and records the
instance. A later run for the same `threadId` resumes it when the store (and
lock) are shared across processes.

Single sandbox, nothing else sharing the lock? Pass both as options and skip the
extra middleware:

```ts
import { InMemoryLockStore } from '@tanstack/ai/locks'
import { withSandbox } from '@tanstack/ai-sandbox'
import { instanceStore } from './instance-store'
import { sandbox } from './sandbox'

const middleware = [
withSandbox(sandbox, {
instances: instanceStore,
locks: new InMemoryLockStore(), // multi-replica: a distributed LockStore
}),
]
```

Optional chat persistence is independent:

```ts
import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
import { withPersistence, memoryPersistence } from '@tanstack/ai-persistence'
import { withSandbox } from '@tanstack/ai-sandbox'
import { instanceStore } from './instance-store'
import { sandbox } from './sandbox'

const middleware = [
withPersistence(memoryPersistence()), // chat state only
withLocks(new InMemoryLockStore()), // multi-replica: distributed LockStore
withSandbox(sandbox, { instances: instanceStore }),
]
```

## Implement `SandboxInstanceStore`

```ts
import {
defineSandboxInstanceStore,
} from '@tanstack/ai-sandbox'
import type { SandboxInstanceRecord } from '@tanstack/ai-sandbox'

export const instanceStore = defineSandboxInstanceStore({
async get(_key) {
return null
},
async upsert(_record: SandboxInstanceRecord) {
// insert or FULLY replace by record.key
},
async delete(_key) {
// no-op if missing
},
})
```

### Invariants (conformance)

```ts
import {
runSandboxInstanceStoreConformance,
} from '@tanstack/ai-sandbox/testkit'
import type { SandboxInstanceStore } from '@tanstack/ai-sandbox'

declare const instanceStore: SandboxInstanceStore

runSandboxInstanceStoreConformance('my-instance-store', () => instanceStore)
```

| Method | Invariant |
| --- | --- |
| `get` | Missing key → `null` (not throw). |
| `upsert` | **Full replace** by `record.key`. Omitted optionals clear prior values. |
| `delete` | Missing key is a **no-op**. |
| timestamps | `updatedAt` is epoch **milliseconds**. |

### Suggested schema (SQLite)

```sql
CREATE TABLE IF NOT EXISTS sandbox_instances (
key text PRIMARY KEY NOT NULL,
provider text NOT NULL,
provider_sandbox_id text NOT NULL,
latest_snapshot_id text,
thread_id text NOT NULL,
latest_run_id text,
updated_at integer NOT NULL
);
```

You can put this table next to chat tables in the same DB — that is an **app**
choice, not a requirement of `@tanstack/ai-persistence`.

## Locks

A durable instance map without a distributed lock is still wrong across
replicas: two concurrent runs for one thread both find no record and both
create. Pair the store with a lock, either `withLocks` from
`@tanstack/ai/locks` or the `locks` option above. Full guide:
[Locks](../advanced/locks).

## See also

- [Locks](../advanced/locks)
- [Lifecycle](./lifecycle)
- [Persistence overview](../persistence/overview) — chat state only
9 changes: 5 additions & 4 deletions docs/sandbox/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ hands back a live preview URL, see `examples/sandbox-web` — one app with harne
(Claude Code / Codex / OpenCode / Grok) and provider (Docker / local / Vercel /
Daytona) pickers.

> **Persistence-ready:** the sandbox layer ships with in-memory stores for
> resume bookkeeping. A future persistence package can provide durable
> `SandboxStore` / `LockStore` implementations (and event-log replay) by
> supplying those optional capabilities — no changes to the sandbox layer.
> **Durable instance resume:** bookkeeping defaults to in-memory (single-process).
> For cross-process / multi-instance reuse, see
> [Sandbox Instance Durability](./durability): implement a
> `SandboxInstanceStore`, pass it as `withSandbox(sandbox, { instances })`, and pair a
> distributed `LockStore` via `withLocks` from `@tanstack/ai/locks`.
13 changes: 11 additions & 2 deletions packages/ai-sandbox/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
"./ngrok": {
"types": "./dist/esm/ngrok.d.ts",
"import": "./dist/esm/ngrok.js"
},
"./testkit": {
"types": "./dist/esm/testkit/conformance.d.ts",
"import": "./dist/esm/testkit/conformance.js"
}
},
"files": [
Expand All @@ -55,16 +59,21 @@
},
"peerDependencies": {
"@ngrok/ngrok": "^1.0.0",
"@tanstack/ai": "workspace:^"
"@tanstack/ai": "workspace:^",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use workspace:* for the internal peer dependency.

@tanstack/ai uses workspace:^, which does not follow the required internal dependency protocol.

Proposed fix
-    "`@tanstack/ai`": "workspace:^",
+    "`@tanstack/ai`": "workspace:*",

As per coding guidelines, “Use the workspace:* protocol for internal package dependencies in package.json.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"@tanstack/ai": "workspace:^",
"`@tanstack/ai`": "workspace:*",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/ai-sandbox/package.json` at line 62, Update the `@tanstack/ai`
dependency entry in package.json from the workspace:^ protocol to workspace:*
while preserving the existing internal package reference.

Source: Coding guidelines

"vitest": "^4.1.10"
},
"peerDependenciesMeta": {
"@ngrok/ngrok": {
"optional": true
},
"vitest": {
"optional": true
}
},
"devDependencies": {
"@ngrok/ngrok": "^1.7.0",
"@tanstack/ai": "workspace:*",
"@vitest/coverage-v8": "4.0.14"
"@vitest/coverage-v8": "4.0.14",
"vitest": "^4.1.10"
}
}
35 changes: 35 additions & 0 deletions packages/ai-sandbox/skills/ai-sandbox/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,41 @@ const policy = defineSandboxPolicy({
provider + workspace hash + tenant so changing the repo/setup/image starts
fresh. Ensure order: resume running → restore snapshot → create + bootstrap.

## Instance durability (durable resume)

Resume bookkeeping defaults to in-memory (single-process). For cross-process /
multi-replica resume, implement a durable `SandboxInstanceStore` (BYO) and pass
it as `withSandbox(sandbox, { instances })`. Pair multi-replica with a
distributed lock: either `withLocks` from `@tanstack/ai/locks` (ordered
**before** `withSandbox`) or the `locks` option.

```typescript
import { chat } from '@tanstack/ai'
import { InMemoryLockStore, withLocks } from '@tanstack/ai/locks'
import { withSandbox } from '@tanstack/ai-sandbox'
// Production: your BYO store — docs/sandbox/durability.md
import { instanceStore } from './sandbox-instance-store'

chat({
adapter,
messages,
middleware: [
withLocks(new InMemoryLockStore()), // multi-replica: distributed lock
withSandbox(sandbox, { instances: instanceStore }),
],
})
```

The store option takes precedence over an ambient `SandboxInstanceStoreCapability`
(provided by a platform layer via `provideSandboxInstanceStore`), which in turn
beats the in-memory fallback.

Chat transcript durability (`withPersistence`) is independent — compose both
when the app needs history _and_ instance reuse. Prove adapters with
`runSandboxInstanceStoreConformance` from `@tanstack/ai-sandbox/testkit`.
Use `defineSandboxInstanceStore({ get, upsert, delete })` for inline typing of a
BYO store (same pattern as `defineLock` / `defineMessageStore`).

## File-event hooks

Watch the workspace for create/change/delete events. Provider-agnostic: native
Expand Down
11 changes: 3 additions & 8 deletions packages/ai-sandbox/src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,17 @@
*
* - `SandboxCapability` is PROVIDED by `withSandbox` and REQUIRED by harness
* adapters (`requires: [SandboxCapability]`).
* - `SandboxStoreCapability` is OPTIONALLY required by `withSandbox` (in-memory
* fallback when absent).
* - `LocksCapability` lives in `@tanstack/ai/locks` and is not re-exported here.
* - `SandboxInstanceStoreCapability` lives in
* `./instance-store` (same package). `LocksCapability` / `withLocks` live in
* `@tanstack/ai/locks` and are not re-exported here.
*/
import { createCapability } from '@tanstack/ai'
import type { SandboxHandle } from './contracts'
import type { SandboxStore } from './store'
import type { SandboxPolicy } from './policy'
import type { ToolBridgeProvisioner } from './tool-bridge'

export const SandboxCapability = createCapability<SandboxHandle>()('sandbox')

export const SandboxStoreCapability =
createCapability<SandboxStore>()('sandbox-store')

/**
* The active sandbox policy, provided by `withSandbox` from the definition.
* Harness adapters read it to map allow/ask/deny rules onto their native
Expand All @@ -37,7 +33,6 @@ export const ToolBridgeProvisionerCapability =

/** Destructured accessors for adapters: `getSandbox(ctx)` reads the handle. */
export const [getSandbox, provideSandbox] = SandboxCapability
export const [getSandboxStore, provideSandboxStore] = SandboxStoreCapability
export const [getSandboxPolicy, provideSandboxPolicy] = SandboxPolicyCapability
export const [getToolBridgeProvisioner, provideToolBridgeProvisioner] =
ToolBridgeProvisionerCapability
Loading
Loading