Skip to content

feat: durable, reconnectable sandboxed agent runs - #1015

Merged
AlemTuzlak merged 181 commits into
mainfrom
feat/durable-agent-runs
Aug 4, 2026
Merged

feat: durable, reconnectable sandboxed agent runs#1015
AlemTuzlak merged 181 commits into
mainfrom
feat/durable-agent-runs

Conversation

@AlemTuzlak

@AlemTuzlak AlemTuzlak commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Durable, reconnectable sandboxed agent runs. A client disconnect detaches the run instead of destroying the sandbox; a later request takes it over and keeps streaming; a reaper finalizes or expires runs nobody comes back for.

Verified end to end by testing/e2e/tests/durable-takeover.spec.ts — a real mid-stream disconnect, an attach, and the stream continues with the transcript intact and no duplicated prefix.

Note on the base. This PR was opened against feat/persistence-sandbox, which was later deleted upstream, so GitHub retargeted it to main. main has since squash-merged that foundation itself (#988 sandbox instance durability, #1011 generation run persistence), and main is now merged into this branch — so the PR no longer carries the foundation and there is nothing to split out. One resolution worth flagging: #1004 made RunStore.findActiveRun required, but this branch had relocated RunStore into @tanstack/ai, and that file merged cleanly — so the merge had to re-apply the requirement by hand or it would have silently reverted #1004. It is now required in core, gone from the conformance suite's skipMethods, and forwarded unconditionally by fenceRunStore.

The problem

You are building something like ChatGPT, except the assistant is a coding agent that works inside a sandbox and can take ten minutes to finish a task.

Ten minutes is a long time for a browser tab. The user refreshes. Closes the laptop. Loses wifi. Or their next request lands on a different replica than the one running the job.

What used to happen

The connection dropping killed everything. The sandbox was destroyed, the work was thrown away, and the user came back to nothing.

That was not a bug — it was the least-bad option available. When you close the pipe to an agent running in a sandbox, the agent does not stop. It keeps working and keeps spending money on tokens. So destroying the sandbox was the only reliable way to be sure a disconnected job stopped burning cash.

Which is exactly right if the user pressed Stop. And completely wrong if they just refreshed the page.

What this PR changes

It teaches the system to tell those two situations apart, and to handle the refresh case properly. Four pieces:

1. A disconnect no longer kills anything. It detaches: the agent keeps working, the sandbox stays up, and the run record notes "nobody is watching this, as of 3:42pm."

2. The agent writes its output to a file instead of down the wire. This is the key trick. If the agent talks directly to the browser, its words vanish the moment the browser leaves. If it writes to a file inside the sandbox, the words are still sitting there when someone comes back.

3. Someone comes back, and we pick up mid-sentence. A new request — possibly on a different replica — reads that file, works out how much the user already saw, and streams only the part they missed. No repeated paragraphs, no gaps.

4. Something has to clean up after the people who never come back. Otherwise a sandbox runs forever on a job nobody will ever read. So there is a sweeper: it finds abandoned runs, checks whether the agent finished on its own, and either wraps them up or shuts them down.

The two parts that sound over-engineered but are not

Making sure two replicas never both drive one run. If a user opens the same thread in two tabs, or a load balancer sends a retry elsewhere, two replicas could both try to continue one run — and the user would see doubled text and contradictory "finished" messages. So a replica has to take a numbered ticket to drive a run, and if a newer replica takes a higher number, the older one is locked out of writing anything at all. Not merely discouraged — unable to append to the log or mark the run finished.

Making "the agent finished" impossible to fake. The way we know a run ended is that a special line appears at the end of that file. But the agent itself writes that file, and agents write whatever the model says. If a model happened to print that line, the sweeper would believe a running job had ended and would shut down a live sandbox. So the line includes a secret value derived from the run's own id. The agent cannot produce it, so it cannot get its own sandbox destroyed.

What you have to do to use it

Two things, and the second is easy to forget:

  1. Turn it on — give withSandbox both a run store and a durability backend. Passing only one leaves you with exactly today's destroy-on-disconnect behavior, silently, because you have not asked for durability.
  2. Actually schedule the sweeper — cron, a queue, a Durable Object alarm(), whatever the platform offers.

Do the first and not the second and everything looks fine, then sandboxes bill indefinitely and disconnected readers wait forever on logs nothing will ever close. A real distributed LockStore is also required; the in-memory one cannot coordinate across hosts.

This explanation ships as docs/sandbox/durable-runs.md, ordered ahead of the three wiring pages.


What ships

Run lifecycle (@tanstack/ai) — RunRecord gains sandboxKey, detachedSince, driverEpoch, cancelRequested; RunStore.listReclaimable; isTerminalRunStatus / isRunStatus; out-of-band cancel via requestRunCancel / wasCancelRequested.

Detach on disconnect (@tanstack/ai-sandbox) — withSandbox({ runs, durability }) records detachedSince + sandboxKey and leaves the sandbox up. A detached run's delivery log stays open (RunDetachedCapability) so a successor can continue it.

TakeoversandboxRunDriver claims a run under a lease, fences it by epoch, waits for quiescence, replays from byte 0 and aligns against the stored log so only the remainder is appended. A superseded driver can write neither the event log nor a terminal run status.

ReapingreapDetachedRuns, pruneJournals, reclaimSandbox / sandboxReclaimer. The reaper never drives a run to discover whether it finished: an injected hasFinished probe reads the in-sandbox journal out of band, because entering the drive writes a terminal status and closes the log on every path.

Journal — a nonced, unforgeable exit sentinel; injective filename encoding; a fail-closed decoder; bounded attach preflight (JournalAttachUnavailableError).

Breaking changes

All pre-1.0, and the durability surface has never been published, so these break no released consumer.

  • onAbort writes aborted (terminal, with finishedAt); interrupted is no longer terminal-shaped.
  • RunDeps.durability is a per-run factory (runId) => StreamDurability<TOffset>.
  • SandboxDurabilityOptions.detachedRunTtl is removed — it was validated, parsed, and read by nothing. ReapOptions.detachedRunTtlMs is the only TTL.
  • SandboxCapabilities.killableProcesses is now required.

Read this before enabling durability

The reaper ships as a function, not a scheduler. An app that wires durability and never calls reapDetachedRuns has nothing closing detached delivery logs: tailers park forever, the TTL is enforced by nothing, and sandboxes bill indefinitely. Wiring durability and scheduling the sweep are two separate integration steps. See docs/sandbox/reaping.md.

A real LockStore is required — InMemoryLockStore cannot coordinate across hosts, and withSandbox warns when it is used with durability.

Review

34 fix commits across four review rounds (8 reviewers on the feature, then re-reviews of the fixes themselves). Notable findings, all fixed:

  • The detach guard included !terminalPersisted, and an agent-loop run emits one RUN_FINISHED per iteration — so detach was defeated for every tool-calling run.
  • durableStream restarted its sequence at 1 on takeover, so a takeover's appends were silently discarded by the reader's dedup.
  • Three of four killableProcesses: true declarations were false when measured: Docker's stream.destroy() detached only the client, local-process POSIX killed a forking shell, and Vercel's kill() was a no-op that never called the SDK's real kill. Vercel and Daytona are now false; Docker's is fixed and falsifiable.
  • The exit sentinel was forgeable from ordinary agent stdout, which could get a live sandbox destroyed by the reaper.
  • An attach against a run with no journal hung forever, and self-perpetuated once triggered.
  • isTerminalRunStatus used in, so 'toString' was terminal — reachable from any user-implemented store, and a consumer deletes journals.
  • One middleware's failing teardown cancelled every later middleware's teardown.
  • The conformance suite asserted the reader stopped, never that the remote process died — which is how the false capability claims survived it. It now asserts the process is gone.

Known limitations

  • Sprites killableProcesses: true is unmeasured — the one remaining true. Its server-side kill endpoint is confirmed to be issued; what it signals (process group vs. pid) is not. Needs SPRITES_API_KEY.
  • Vercel, Daytona, and Sprites journal-conformance suites are registered but render named skips without credentials. A secrets-gated nightly job would close this.
  • packages/ai-sandbox/tests/harness-cwd.test.ts › maps nested virtual paths under /workspace on local-process fails on Windows (path separators). Pre-existing and unrelated.
  • Event-log retention stays the application's job, inside its own StreamDurability backend.

Verification

ai 1390 · ai-sandbox 602/603 (the Windows case above) · ai-persistence 95 · ai-durable-stream 45 · adapters 261 · providers 214 including Docker 37/37 under REQUIRE_DOCKER=1 and BusyBox conformance · E2E 390 passed / 0 failed · kiira 894/894 · publint 15/15 · sherif, knip, format and 17 typechecks clean.

Run directly per package rather than through nx, which is unreliable on this machine.

Summary by CodeRabbit

  • New Features
    • Added durable sandbox runs that survive disconnects, support replay and takeover, and enforce single-writer safety.
    • Added persistent run journals, deterministic replay alignment, cancellation tracking, snapshots, and detached-run reaping.
    • Added unified run statuses, structured errors, resumable stream improvements, and provider capability reporting.
  • Bug Fixes
    • Improved process cleanup, Windows and Docker termination, stream handling, timeouts, teardown, and middleware error isolation.
  • Documentation
    • Added guides covering durable runs, journals, takeover, persistence, reaping, and provider behavior.

AlemTuzlak and others added 30 commits July 27, 2026 19:16
… (BYO)

Additive on top of the core persistence PR (#984, now in main): SandboxInstanceStore contract, InMemorySandboxInstanceStore, withSandboxInstanceStore, and a conformance testkit. withSandbox consumes it in ensure (in-memory fallback); pair withLocks (@tanstack/ai/locks) for multi-instance. Docs, skill, e2e.
- defineSandboxInstanceStore helper (defineLock / defineMessageStore style)
- Skill and comments use @tanstack/ai/locks (not main barrel)
- Conformance suite wired for InMemorySandboxInstanceStore
- Middleware resume test via withSandboxInstanceStore + withLocks + withSandbox
- Locks doc links to sandbox instance durability
Replaces the withSandboxInstanceStore pass-through middleware with withSandbox(sandbox, { instances, locks? }). The store had exactly one reader, so routing it through the capability bus bought nothing and cost an ordering rule whose violation silently degraded to the in-memory fallback. SandboxInstanceStoreCapability + provideSandboxInstanceStore stay exported for ambient/platform wiring; precedence is option -> bus -> in-memory.
Core's append() was widened to accept an opts.offsets array so callers can
upsert deterministic ids (Wave 1). durableStream cannot honor that: its
offsets are encodeCursor({ backendOffset, seq }), where backendOffset comes
from the backend's Next-Offset response header and seq is assigned locally
from nextSeq — there is no protocol slot for a caller-assigned id.

Because the returned object is typed StreamDurability<DurableStreamOffset>,
a narrower one-parameter append implementation stayed assignable to the
widened two-parameter member, so TypeScript would not have caught a caller
passing offsets and having them silently dropped (backend would assign its
own, and a Phase 2 re-translating successor host would duplicate replayed
events). This widens the implementation signature to accept opts and throws
DurableStreamError as the very first statement — before the chunks.length
early return and before ensureCreated() — so a rejected call has no
network side effect. Chose fail-loud over a length-mismatch validation
(as core's memoryStream does) because once any offsets are rejected
outright, a length check is unreachable and would misleadingly imply
offsets are a partially-supported path.
`@tanstack/ai-sandbox` carried its own run event-log and run-lifecycle
vocabulary alongside core's. Both consumers moved off it earlier in this
phase, so the duplicate goes away.

`RunStatus`, `TerminalRunStatus`, `RunRecord`, `RunError` and
`isTerminalRunStatus` are removed with NO replacement re-export.
Re-exporting core's versions here would recreate exactly the
two-import-paths-for-one-type duplication this phase exists to remove —
consumers import run lifecycle types from `@tanstack/ai`.

The event-log concepts (`RunEventLog`, `InMemoryRunEventLog`, `RunEvent`,
`RunError`, `RunEventLogReadOptions`) have no core equivalent and now live in
`@tanstack/ai-sandbox-cloudflare`. Their nine contract tests moved with them
rather than being dropped: `run-driver.test.ts` only uses
`InMemoryRunEventLog` as a fixture, so deleting the suite would have lost the
only coverage of gap-free sequencing, exclusive-cursor resume, blocked-reader
wake, read-signal abort, and append-after-terminal rejection.

`RunDeps` is now exported from './run'.

BREAKING CHANGE: `@tanstack/ai-sandbox` no longer exports `RunEventLog`,
`InMemoryRunEventLog`, `RunEvent`, `RunError`, `RunEventLogReadOptions`,
`RunStatus`, `TerminalRunStatus`, `RunRecord`, or `isTerminalRunStatus`.

@tombeckenham tombeckenham left a comment

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.

Noticed the focus on durable, reconnectable sandbox runs. I specialize in maintaining agent state across connection drops.

Hey @Me333-jjj what are you thoughts on the PR?

Comment thread docs/sandbox/journal.md
agent. And because the journal is a file, a reader can start at byte 0 whenever
it likes.

## You have to ask for a journal

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.

I'm starting to think we should be a opinionated. Is there any reason why the user would not want to ask for a journal? Should it be on by default?

Comment thread docs/sandbox/journal.md
Comment on lines +107 to +110
`@tanstack/ai-client` mints a fresh `runId` for every run and puts it in the
AG-UI request body, which is what `chatParamsFromRequest` hands back. So the
client half of this is nothing at all: `useChat` already sends a unique id per
run, and reconnecting behaviour is unchanged.

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.

I totally miss this section. When would they use journals without using ai-client...

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.

We should be leading with. tanstack-ai already mints the runId, so don't worry about it

Comment thread docs/sandbox/journal.md
Comment on lines +54 to +61
## Give every run an id you can recompute

The journal path is derived from `runId` alone, so a `runId` you cannot
reproduce is a journal nobody can find. Adapters fall back to a random internal
id on a **non-durable** run; on a durable one there is no fallback at all —
`chatStream` throws `DurableRunIdRequiredError`, deliberately, because an id no
successor host can recompute produces a run that streams normally and is
silently unrecoverable.

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.

This runId section makes it sound more complicated than it is. It reads like the user has to create runIds manually and store them somewhere.

Comment thread docs/sandbox/journal.md
Comment on lines +207 to +213
## The exit sentinel carries a nonce

The line a run's shell appends after the agent exits is not the bare
`{"__exit":N}` it once was. It now carries a second field too:

```json
{"__exit":0,"__nonce":"3f9c1a7b..."}

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.

This is maybe getting into detail that probably should live in comments rather than user facing docs.

…iver, with a live-data migration

The Cloudflare durable-runs path is now a platform binding of the one
portable protocol rather than a parallel architecture (PR #1015 feedback):

- The run log speaks core's vocabulary: statuses/RunError/isTerminalRunStatus
  come from @tanstack/ai, and RunLogRecord is core's RunRecord plus the log's
  lastSeq cursor and updatedAt activity clock. Records persisted under the
  legacy layout (done/error statuses, createdAt/updatedAt, optional threadId)
  are migrated in place on first read and written back
  (migrateStoredRunRecord); event rows are untouched. Wire-visible on
  GET /runs/:id and the terminal WebSocket status frame.
- The package's pipeToRunLog/RunController copy is deleted.
  SandboxCoordinator drives runs with core's RunController, bound to the DO
  log by two new adapters: runLogStore (the log as a RunStore) and
  runLogStream (one run as a StreamDurability, with the bounded snapshot()
  alignToStoredLog needs). RunEventLog gains update (must wake readers — the
  driver terminalizes through the RunStore, and record + log share one status
  field) and list; open requires threadId. The seq-based client wire protocol
  is unchanged.
- Journal conformance registers for Cloudflare as a runtime-gated NAMED skip
  instead of being silently absent, making providers.md's coverage claim true.
- Docs: name the two tiers (journal-only vs log-first) in durable-runs.md with
  the capture/delivery two-pipes model and the precedence rule ("the log wins
  for clients, the journal wins for the driver's resume position", mirrored in
  journal.md and takeover.md); new "Durable runs at the edge" section in
  cloudflare.md (three layers, non-goals incl. the sleepAfter footgun,
  cancel semantics on killableProcesses: false); watchdog-vs-reaper note in
  reaping.md; non-killable cancel semantics in takeover.md/providers.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/sandbox/takeover.md (1)

414-433: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Note the missing authorization check in the cancel-endpoint sample.

The handler takes threadId straight from the request body and cancels its active run with no ownership check. Add a short comment noting that production endpoints must authorize the caller against threadId before calling requestRunCancel, so this sample isn't copied as-is into an unauthenticated route.

Based on learnings from a related persistence-doc review: "do not rely on client-provided threadId as an authorization mechanism... authorize thread ownership/visibility at the route boundary before any persistence reads or writes."

🤖 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 `@docs/sandbox/takeover.md` around lines 414 - 433, Add a short comment in the
POST handler before the findActiveRun/requestRunCancel flow stating that
production routes must authorize the caller’s ownership or visibility of the
client-provided threadId at the route boundary before persistence access or
cancellation; leave the cancellation logic unchanged.

Source: Learnings

♻️ Duplicate comments (1)
packages/ai-sandbox-cloudflare/src/coordinator.ts (1)

190-191: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Contain exceptions from onRunSettled before handing the promise to waitUntil.

settle() calls the overridable onRunSettled hook directly. If a subclass override throws, done.then(settle, settle) returns a rejected promise, and that rejection reaches ctx.waitUntil. Wrap the call in settle so it always resolves.

🛠️ Proposed fix
-    const settle = (): void => this.onRunSettled(input.runId)
+    const settle = (): void => {
+      try {
+        this.onRunSettled(input.runId)
+      } catch (error) {
+        console.error(
+          `[sandbox-coordinator] onRunSettled failed for run ${input.runId}:`,
+          error,
+        )
+      }
+    }
🤖 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-cloudflare/src/coordinator.ts` around lines 190 - 191,
Update the local settle function in the coordinator completion flow to catch and
contain any exception thrown by the overridable onRunSettled hook, ensuring
settle always resolves before its promise is passed to ctx.waitUntil. Preserve
invocation on both fulfillment and rejection paths of done.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/sandbox/cloudflare.md`:
- Around line 158-161: Add an authorization caveat beside the threadId naming
guidance: routes must verify the caller owns or may access the client-supplied
threadId at the route boundary before naming, reusing, or reconnecting to the
sandbox. Make clear that threadId alone is not an authorization mechanism,
consistent with the existing thread-ownership guidance for persistence.

In `@docs/sandbox/durable-runs.md`:
- Around line 102-106: Add the text language tag to the fenced diagram code
block in the durable-runs documentation, changing the opening fence to use text
while preserving the diagram content unchanged.

In `@packages/ai-sandbox-cloudflare/src/durability.ts`:
- Around line 140-144: Update the durability backend’s close handler so it does
not call log.finish(runId, 'completed') after a failed terminal runs.update,
which can overwrite the original status and error. Keep close as a no-op, or
implement a separate update-failure wake-up path that preserves the run flow’s
original status/error.

In `@packages/ai-sandbox-cloudflare/tests/driver-binding.test.ts`:
- Around line 103-110: Update the test around collect(log.read('r1')) to
synchronize on explicit evidence that the live reader is parked and subscribed
before calling ac.abort() and release(). Replace the single timer-turn delay
with the existing reader-waiting signal or observable state, while preserving
the expectation that only sequence 0 is received and done.status is aborted.

---

Outside diff comments:
In `@docs/sandbox/takeover.md`:
- Around line 414-433: Add a short comment in the POST handler before the
findActiveRun/requestRunCancel flow stating that production routes must
authorize the caller’s ownership or visibility of the client-provided threadId
at the route boundary before persistence access or cancellation; leave the
cancellation logic unchanged.

---

Duplicate comments:
In `@packages/ai-sandbox-cloudflare/src/coordinator.ts`:
- Around line 190-191: Update the local settle function in the coordinator
completion flow to catch and contain any exception thrown by the overridable
onRunSettled hook, ensuring settle always resolves before its promise is passed
to ctx.waitUntil. Preserve invocation on both fulfillment and rejection paths of
done.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b4a75d56-f981-44d3-a755-75d387cab96f

📥 Commits

Reviewing files that changed from the base of the PR and between 374d49d and e607c0b.

📒 Files selected for processing (18)
  • .changeset/durable-run-types.md
  • docs/config.json
  • docs/sandbox/cloudflare.md
  • docs/sandbox/durable-runs.md
  • docs/sandbox/journal.md
  • docs/sandbox/providers.md
  • docs/sandbox/reaping.md
  • docs/sandbox/takeover.md
  • examples/sandbox-cloudflare/wrangler.jsonc
  • packages/ai-sandbox-cloudflare/src/agent.ts
  • packages/ai-sandbox-cloudflare/src/coordinator.ts
  • packages/ai-sandbox-cloudflare/src/durability.ts
  • packages/ai-sandbox-cloudflare/src/run-log-do.ts
  • packages/ai-sandbox-cloudflare/src/run-log.ts
  • packages/ai-sandbox-cloudflare/tests/driver-binding.test.ts
  • packages/ai-sandbox-cloudflare/tests/journal.conformance.test.ts
  • packages/ai-sandbox-cloudflare/tests/run-log-do.test.ts
  • packages/ai-sandbox-cloudflare/tests/run-log.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • docs/sandbox/providers.md
  • docs/sandbox/reaping.md
  • .changeset/durable-run-types.md
  • docs/config.json

Comment on lines +158 to +161
- **Name the sandbox by `threadId`.** Prefer
`defineSandbox({ id: input.threadId, … })` (as `examples/sandbox-cloudflare`
does) over a fixed id: reconnects, `exposePreview`, and `reuse: 'thread'`
then all address the same container even across DO eviction.

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Add an authorization caveat next to the threadId sandbox-naming guidance.

This section tells readers to key sandbox identity by input.threadId so reconnects and reuse: 'thread' address the same container. It does not state that threadId must be authorized server-side before use. If a route accepts a client-supplied threadId and names or reconnects to a sandbox by it without checking ownership, a caller can reconnect to, tail, or drive another user's sandbox by supplying its threadId.

Add a line stating that the route must authorize the caller against threadId (owner/session check) before naming, reusing, or reconnecting to the sandbox, the same way persistence docs require thread ownership checks before loading transcript data.

Based on learnings: "do not rely on client-provided threadId as an authorization mechanism... authorize thread ownership/visibility at the route boundary before any persistence reads or writes."

🤖 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 `@docs/sandbox/cloudflare.md` around lines 158 - 161, Add an authorization
caveat beside the threadId naming guidance: routes must verify the caller owns
or may access the client-supplied threadId at the route boundary before naming,
reusing, or reconnecting to the sandbox. Make clear that threadId alone is not
an authorization mechanism, consistent with the existing thread-ownership
guidance for persistence.

Source: Learnings

Comment on lines +102 to +106
```
agent (in sandbox) ──[capture]──▶ server host ──[delivery]──▶ client
└──▶ durable delivery log
```

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

Add a language tag to the fenced diagram block.

markdownlint (MD040) flags this fenced code block for missing a language. Add a language such as text to keep the linter clean.

📝 Suggested fix
-```
+```text
 agent (in sandbox) ──[capture]──▶ server host ──[delivery]──▶ client
                                        │
                                        └──▶ durable delivery log
</details>

<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **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.

```suggestion

🧰 Tools
🪛 markdownlint-cli2 (0.23.1)

[warning] 102-102: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 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 `@docs/sandbox/durable-runs.md` around lines 102 - 106, Add the text language
tag to the fenced diagram code block in the durable-runs documentation, changing
the opening fence to use text while preserving the diagram content unchanged.

Source: Linters/SAST tools

Comment on lines +140 to +144
// See the module header: normally a no-op (the driver's terminal
// `runs.update` already ended the shared record); `'completed'` lands only
// when that update failed, where unwedging parked readers beats leaving
// them on a log nothing will ever end.
close: () => log.finish(runId, 'completed'),

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg '(^|/)durability\.ts$|stream-durability\.ts$' || true

echo "== target excerpt =="
sed -n '1,230p' packages/ai-sandbox-cloudflare/src/durability.ts

echo "== stream durability excerpt =="
sed -n '1,260p' packages/ai/src/stream-durability.ts

echo "== usages of close fn and finish =="
rg -n "finish\\(|close:\\s*\\(|runs\\.update|terminal|unwedging|parked readers|shared record" packages/ai-sandbox-cloudflare/src/durability.ts packages/ai/src/stream-durability.ts

Repository: TanStack/ai

Length of output: 18403


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== run-log files =="
git ls-files | rg '(^|/)run-log\.ts$|run-log-store\.ts$|run-controllers|run-driver' || true

echo "== run-log close/terminal implementations =="
for f in $(git ls-files | rg 'packages/.*/(run-log|run-log-store)\.ts$'); do
  echo "--- $f ---"
  sed -n '1,260p' "$f"
done

echo "== stream durability memory close implementation =="
sed -n '420,540p' packages/ai/src/stream-durability.ts

echo "== run driver terminal patches/closes =="
rg -n "runs\.update\(|\.close\(\)|finish\\(|RUN_(FINISHED|ERROR)|run_finished|run_error|status:" packages -g '*.ts' -g '*.tsx' | head -n 250

Repository: TanStack/ai

Length of output: 40334


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== run-log implementation =="
sed -n '60,225p' packages/ai-sandbox-cloudflare/src/run-log.ts

echo "== run-log DO implementation =="
sed -n '80,180p' packages/ai-sandbox-cloudflare/src/run-log-do.ts

echo "== core run.ts finish flow =="
sed -n '160,375p' packages/ai-sandbox/src/run.ts

echo "== claim durable close flow =="
sed -n '360,470p' packages/ai-sandbox/src/claim.ts

Repository: TanStack/ai

Length of output: 23738


Don’t use close() to fix a failed terminal record write.

close() is only a no-op here when runs.update(...) already terminalized the shared record. If the update failed, the driver is still terminalizing once with the original status/error from the run flow; using close() with 'completed' can overwrite a real 'failed'/'aborted' outcome and hide the original error.

Keep close() as a no-op for this backend, or split the “wake parked readers on update failure” case into its own path instead of calling log.finish(runId, 'completed').

🤖 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-cloudflare/src/durability.ts` around lines 140 - 144,
Update the durability backend’s close handler so it does not call
log.finish(runId, 'completed') after a failed terminal runs.update, which can
overwrite the original status and error. Keep close as a no-op, or implement a
separate update-failure wake-up path that preserves the run flow’s original
status/error.

Comment on lines +103 to +110
const reading = collect(log.read('r1'))
await new Promise((resolve) => setTimeout(resolve, 0))
ac.abort()
release()

expect((await done).status).toBe('aborted')
const events = await reading
expect(events.map((e) => e.seq)).toEqual([0])

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Prove that the live reader is parked before cancellation.

Line 104 only delays for one timer turn. It does not verify that log.read('r1') is waiting. If terminalization occurs before the reader subscribes, the test still passes and does not detect a missing wake-up from runs.update().

Proposed test change
-    const reading = collect(log.read('r1'))
+    const reader = log.read('r1')[Symbol.asyncIterator]()
+    const first = await reader.next()
+    expect(first.value?.seq).toBe(0)
+
+    const terminal = reader.next()
+    let settled = false
+    void terminal.then(() => {
+      settled = true
+    })
     await new Promise((resolve) => setTimeout(resolve, 0))
+    expect(settled).toBe(false)
     ac.abort()
     release()

     expect((await done).status).toBe('aborted')
-    const events = await reading
-    expect(events.map((e) => e.seq)).toEqual([0])
+    expect((await terminal).done).toBe(true)
📝 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
const reading = collect(log.read('r1'))
await new Promise((resolve) => setTimeout(resolve, 0))
ac.abort()
release()
expect((await done).status).toBe('aborted')
const events = await reading
expect(events.map((e) => e.seq)).toEqual([0])
const reader = log.read('r1')[Symbol.asyncIterator]()
const first = await reader.next()
expect(first.value?.seq).toBe(0)
const terminal = reader.next()
let settled = false
void terminal.then(() => {
settled = true
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(settled).toBe(false)
ac.abort()
release()
expect((await done).status).toBe('aborted')
expect((await terminal).done).toBe(true)
🤖 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-cloudflare/tests/driver-binding.test.ts` around lines 103
- 110, Update the test around collect(log.read('r1')) to synchronize on explicit
evidence that the live reader is parked and subscribed before calling ac.abort()
and release(). Replace the single timer-turn delay with the existing
reader-waiting signal or observable state, while preserving the expectation that
only sequence 0 is received and done.status is aborted.

tombeckenham and others added 2 commits August 3, 2026 17:52
…to grok-4.5 on docker

sandbox-web is now the runnable demo of the durable-runs journal-only
tier: withSandbox gets runs + a memoryStream durability adapter, /api/run
serves both the producing POST and the joinRun/takeover GET
(sandboxRunDriver), /api/run/active resolves the live run from the stable
threadId, /api/run/cancel is the explicit two-band cancel (Stop is no
longer just chat.stop()), and a guarded reapDetachedRuns interval sweeps
abandoned runs. The client persists its thread identity + transcript in
localStorage, so a reload restores the thread and auto-rejoins an
in-flight run via joinRun.

The stack is deliberately fixed — Grok Build (model grok-4.5, released
July 2026; the id passes through GrokBuildModel's open union) in a Docker
sandbox — because takeover's one hard requirement is that a run be
reconstructible from its runId alone: the attach route and the reaper
have nothing else, so a per-request browser choice of harness/provider
would have to be stored server-side (the config-map this example
previously needed). One adapter on one provider makes the rebuild — and
the whole example — small; README's "Swapping the stack" section explains
the trade. Harness switching lives on in sandbox-cloudflare, and the
adapter docs now point there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rom its first chunk

Refreshing during a sandbox boot permanently orphaned a live run. A
chat() whose middleware boots a sandbox (docker create + CLI install)
legitimately emits nothing for 30-90s, and during that window the
delivery log was empty — so a joiner's empty-log fail-fast
(memoryStream's 100ms first-chunk deadline) read the run as gone, and
the client's 2s rejoin connect deadline then CLEARED the persisted
resume pointer, so no later reload ever retried. Reproduced live against
examples/sandbox-web: the agent kept running (detach worked; the record,
container, and reaper were all correct) while the client lost it for
good.

Two changes close the window:

- @tanstack/ai: a fresh durable producer appends (and forwards) a
  synthetic CUSTOM chunk — RUN_ACCEPTED_EVENT ('run.accepted'), exported
  — to the log BEFORE the producer stream is first pulled, since pulling
  is what runs the middleware chain. A join now finds a first chunk
  within milliseconds of the POST. Takeover alignment is unaffected: a
  journal replay cannot reproduce the marker, and alignment already
  skips stored CUSTOM chunks as out-of-band (isBridgeCustomChunk).
  This generalizes the existing RUN_STARTED flush-boundary rationale,
  which only helps once the stream emits RUN_STARTED at all.
- @tanstack/ai-client: a rejoin that times out before attaching now
  KEEPS the resume pointer (the run may simply not have produced yet);
  only a join the server refuses with a hard pre-attach error (unknown /
  evicted run) clears it. The next load costs one more bounded connect
  attempt instead of the run.

examples/sandbox-web raises the join adapter's firstChunkDeadlineMs to
10s as belt-and-braces for the sliver before the marker lands.

Verified live (a joinRun 2s after the POST replays the marker instantly
and live-tails), plus: unit suites updated for the leading marker across
ai / ai-durable-stream, two new ai-client tests pinning keep-on-timeout
vs clear-on-refusal, all 22 durability e2e specs, and the full 408-spec
e2e suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 8

🧹 Nitpick comments (1)
examples/sandbox-web/src/routes/api.run.cancel.ts (1)

23-36: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Validate the body with Zod.

The repository guideline requires Zod for schema validation. The sibling route examples/sandbox-web/src/routes/api.run.ts already validates its body with z.preprocess and z.object. Use the same approach here so the two run routes stay consistent. A Zod schema also rejects an empty threadId, which the current type check accepts.

As per coding guidelines: "Use Zod for schema validation and tool definition with toolDefinition()".

♻️ Proposed refactor
+import { z } from 'zod'
+
+const cancelBodySchema = z.object({ threadId: z.string().min(1) })
             let body: unknown
             try {
               body = await request.json()
             } catch {
               return new Response('invalid JSON body', { status: 400 })
             }
-            if (
-              body === null ||
-              typeof body !== 'object' ||
-              !('threadId' in body) ||
-              typeof body.threadId !== 'string'
-            ) {
-              return new Response('threadId is required', { status: 400 })
-            }
+            const parsed = cancelBodySchema.safeParse(body)
+            if (!parsed.success) {
+              return new Response('threadId is required', { status: 400 })
+            }
 
-            const active = await runs.findActiveRun(body.threadId)
+            const active = await runs.findActiveRun(parsed.data.threadId)
🤖 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 `@examples/sandbox-web/src/routes/api.run.cancel.ts` around lines 23 - 36,
Replace the manual body checks in the cancel route with Zod validation, matching
the z.preprocess and z.object pattern used by the sibling api.run route. Define
a schema requiring a non-empty string threadId, parse the JSON body through it,
and preserve the existing 400 response for invalid JSON or validation failures.

Source: Coding guidelines

🤖 Prompt for all review comments with 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.

Inline comments:
In `@docs/adapters/opencode.md`:
- Line 33: Update the sandbox-web note in docs/adapters/opencode.md:33-33 and
docs/adapters/codex.md:27-27 to remove the “one-line change” claim and state
that switching adapters also requires updating the workspace setup command to
install the corresponding CLI and configuring the provider secret; preserve the
existing adapter-swap guidance.

In `@docs/sandbox/overview.md`:
- Around line 124-137: Remove the repeated sandbox-web description from the
documentation, keeping the first complete description with its repository link
and the separate sandbox-cloudflare reference intact.

In `@examples/sandbox-web/src/routes/api.run.cancel.ts`:
- Around line 38-43: Document the missing authorization boundary before the
runs.findActiveRun lookup in examples/sandbox-web/src/routes/api.run.cancel.ts
lines 38-43: add a comment stating that multi-user deployments must derive the
user from server-side session state and authorize thread ownership before
recording the cancellation. Add the same comment before the
runs.findActiveRun(threadId) lookup in
examples/sandbox-web/src/routes/api.run.active.ts lines 15-22; no behavior
change is requested.

In `@examples/sandbox-web/src/routes/api.run.ts`:
- Around line 161-188: Wrap the successful toServerSentEventsResponse call in a
finally block that deletes driving’s runId entry when the stream completes or
aborts. Keep the existing catch cleanup for setup errors, and ensure all entries
created by driving.set in the surrounding route are removed after the produced
run finishes.

In `@examples/sandbox-web/src/routes/index.tsx`:
- Around line 381-388: Update stopRun so the promise returned by the
/api/run/cancel fetch is handled instead of discarded. Add rejection handling
that logs or surfaces cancellation failures while preserving the existing stop()
behavior and request payload.
- Around line 324-327: Guard the localStorage write in the thread persistence
useEffect by wrapping setItem(THREAD_KEY, threadId) in the same error-handling
approach used by loadOrCreateThreadId. Keep the effect’s dependency on threadId
and ensure storage failures are caught without interrupting rendering.

In `@examples/sandbox-web/src/run-durable.ts`:
- Around line 149-159: Update hasFinished to resume the existing sandbox through
the provider’s resume() operation instead of buildSandbox(...).ensure(),
preventing detached-run probes from creating replacement containers. Preserve
the existing probeRunExit call and unknown-state error handling, and only use
ensure when the container is confirmed present.

In `@packages/ai-client/src/chat-client.ts`:
- Around line 1510-1539: Update the joinRun error handling in
packages/ai-client/src/chat-client.ts:1510-1539 to distinguish an explicit
unknown-or-expired-run refusal from generic pre-attachment network, CORS,
transport, or parser failures, and set refused only for that signal so the
persisted pointer is cleared exclusively on confirmed refusal. In
packages/ai-client/tests/resume-snapshot.test.ts:597-616, update the refusal
scenario to use the explicit signal and add coverage proving a generic
pre-attachment failure preserves the pointer.

---

Nitpick comments:
In `@examples/sandbox-web/src/routes/api.run.cancel.ts`:
- Around line 23-36: Replace the manual body checks in the cancel route with Zod
validation, matching the z.preprocess and z.object pattern used by the sibling
api.run route. Define a schema requiring a non-empty string threadId, parse the
JSON body through it, and preserve the existing 400 response for invalid JSON or
validation failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0aaf5f27-a446-466a-bb7e-44aaf8eaf76a

📥 Commits

Reviewing files that changed from the base of the PR and between e607c0b and 1afae7c.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (26)
  • .changeset/run-accepted-marker.md
  • docs/adapters/claude-code.md
  • docs/adapters/codex.md
  • docs/adapters/opencode.md
  • docs/sandbox/overview.md
  • examples/sandbox-web/.env.example
  • examples/sandbox-web/README.md
  • examples/sandbox-web/package.json
  • examples/sandbox-web/src/routeTree.gen.ts
  • examples/sandbox-web/src/routes/api.run.active.ts
  • examples/sandbox-web/src/routes/api.run.cancel.ts
  • examples/sandbox-web/src/routes/api.run.ts
  • examples/sandbox-web/src/routes/index.tsx
  • examples/sandbox-web/src/run-durable.ts
  • examples/sandbox-web/src/sandbox-agent.ts
  • examples/sandbox-web/src/sandbox-options.ts
  • packages/ai-client/src/chat-client.ts
  • packages/ai-client/tests/resume-snapshot.test.ts
  • packages/ai-durable-stream/tests/durable-stream.test.ts
  • packages/ai/src/index.ts
  • packages/ai/src/stream-to-response.ts
  • packages/ai/tests/stream-delivery-contract.test.ts
  • packages/ai/tests/stream-to-response-detached.test.ts
  • packages/ai/tests/stream-to-response-durability.test.ts
  • testing/e2e/tests/delivery-durability.spec.ts
  • testing/e2e/tests/durable-takeover.spec.ts
💤 Files with no reviewable changes (1)
  • examples/sandbox-web/src/sandbox-options.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/ai/tests/stream-delivery-contract.test.ts
  • testing/e2e/tests/durable-takeover.spec.ts
  • packages/ai/src/index.ts
  • packages/ai-durable-stream/tests/durable-stream.test.ts

Comment thread docs/adapters/opencode.md Outdated
```

A runnable demo lives at [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web) — switch the harness (Claude Code, Codex, OpenCode, Grok Build) and sandbox provider per run, with session resume, the harness tool timeline, permission modes, and tool bridging, wired into a TanStack Start app.
A runnable demo lives at [`examples/sandbox-cloudflare`](https://github.com/TanStack/ai/tree/main/examples/sandbox-cloudflare) — pick Claude Code, Codex, or Grok Build in the UI, with session resume, the harness tool timeline, and tool bridging, wired into a TanStack Start app on Workers. For the same wiring on plain Node with durable, refresh-surviving runs (Grok Build on Docker), see [`examples/sandbox-web`](https://github.com/TanStack/ai/tree/main/examples/sandbox-web) — swapping in this adapter is a one-line change (`src/sandbox-agent.ts`).

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

Both adapter docs overstate the sandbox-web swap. examples/sandbox-web/src/sandbox-agent.ts fixes more than the adapter: buildAdapter() returns grokBuildText(GROK_MODEL), and the same file pins the Grok CLI install command in setup, the Grok ACP port in publishPorts, and the XAI_API_KEY secret. A reader who changes only buildAdapter() gets a sandbox without the target CLI and without credentials.

  • docs/adapters/opencode.md#L33-L33: replace "a one-line change" with a statement that the swap changes the adapter, the workspace setup command that installs opencode, and the provider secret.
  • docs/adapters/codex.md#L27-L27: apply the same correction for the Codex CLI install command and its provider secret.
📍 Affects 2 files
  • docs/adapters/opencode.md#L33-L33 (this comment)
  • docs/adapters/codex.md#L27-L27
🤖 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 `@docs/adapters/opencode.md` at line 33, Update the sandbox-web note in
docs/adapters/opencode.md:33-33 and docs/adapters/codex.md:27-27 to remove the
“one-line change” claim and state that switching adapters also requires updating
the workspace setup command to install the corresponding CLI and configuring the
provider secret; preserve the existing adapter-swap guidance.

Comment thread docs/sandbox/overview.md Outdated
Comment on lines +38 to +43
const active = await runs.findActiveRun(body.threadId)
if (!active) return new Response(null, { status: 204 })

await requestRunCancel(runs, active.runId)
driving.get(active.runId)?.abort(RUN_CANCEL_REASON)
return new Response(null, { status: 204 })

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.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Both run routes act on a caller-supplied threadId with no ownership check. The shared root cause is that each route treats the client-provided threadId as sufficient authority. Any caller who learns a threadId can read its active run or cancel it. The example is single-user, so this is not exploitable here, but readers copy example routes into multi-user apps.

  • examples/sandbox-web/src/routes/api.run.cancel.ts#L38-L43: add a comment above runs.findActiveRun(...) stating that a multi-user deployment must derive the user from server-side session state and authorize thread ownership before the cancel is recorded.
  • examples/sandbox-web/src/routes/api.run.active.ts#L15-L22: add the same note before the runs.findActiveRun(threadId) lookup.

Based on learnings: do not rely on client-provided threadId as an authorization mechanism; derive identity from server-side session state and authorize thread ownership at the route boundary before any persistence reads or writes.

📍 Affects 2 files
  • examples/sandbox-web/src/routes/api.run.cancel.ts#L38-L43 (this comment)
  • examples/sandbox-web/src/routes/api.run.active.ts#L15-L22
🤖 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 `@examples/sandbox-web/src/routes/api.run.cancel.ts` around lines 38 - 43,
Document the missing authorization boundary before the runs.findActiveRun lookup
in examples/sandbox-web/src/routes/api.run.cancel.ts lines 38-43: add a comment
stating that multi-user deployments must derive the user from server-side
session state and authorize thread ownership before recording the cancellation.
Add the same comment before the runs.findActiveRun(threadId) lookup in
examples/sandbox-web/src/routes/api.run.active.ts lines 15-22; no behavior
change is requested.

Source: Learnings

Comment thread examples/sandbox-web/src/routes/api.run.ts
Comment on lines +324 to +327
// Persist the durable identity so a reload comes back to the same thread.
useEffect(() => {
localStorage.setItem(THREAD_KEY, threadId)
}, [threadId])

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the localStorage write.

loadOrCreateThreadId wraps the read in try/catch because storage access can throw. This write is unguarded. In a restricted storage mode, or when the quota is exceeded, setItem throws inside the effect and breaks the render.

🛡️ Proposed fix
   useEffect(() => {
-    localStorage.setItem(THREAD_KEY, threadId)
+    try {
+      localStorage.setItem(THREAD_KEY, threadId)
+    } catch {
+      // Unwritable storage — the thread stays in memory for this session.
+    }
   }, [threadId])
📝 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
// Persist the durable identity so a reload comes back to the same thread.
useEffect(() => {
localStorage.setItem(THREAD_KEY, threadId)
}, [threadId])
// Persist the durable identity so a reload comes back to the same thread.
useEffect(() => {
try {
localStorage.setItem(THREAD_KEY, threadId)
} catch {
// Unwritable storage — the thread stays in memory for this session.
}
}, [threadId])
🤖 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 `@examples/sandbox-web/src/routes/index.tsx` around lines 324 - 327, Guard the
localStorage write in the thread persistence useEffect by wrapping
setItem(THREAD_KEY, threadId) in the same error-handling approach used by
loadOrCreateThreadId. Keep the effect’s dependency on threadId and ensure
storage failures are caught without interrupting rendering.

Comment on lines +381 to +388
function stopRun() {
stop()
void fetch('/api/run/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threadId }),
})
}

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle a failed cancel request.

void fetch(...) discards the promise. If the request rejects, for example when the browser is offline, the result is an unhandled promise rejection. The user also gets no signal that the cancel did not reach the server, while stop() already ended the local stream. Add a catch and log or surface the failure.

🛡️ Proposed fix
     void fetch('/api/run/cancel', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ threadId }),
-    })
+    }).catch((error: unknown) => {
+      // The run stays live server-side; the reaper's TTL is the backstop.
+      console.error('[cancel] request failed:', error)
+    })
📝 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
function stopRun() {
stop()
void fetch('/api/run/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threadId }),
})
}
function stopRun() {
stop()
void fetch('/api/run/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ threadId }),
}).catch((error: unknown) => {
// The run stays live server-side; the reaper's TTL is the backstop.
console.error('[cancel] request failed:', error)
})
}
🤖 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 `@examples/sandbox-web/src/routes/index.tsx` around lines 381 - 388, Update
stopRun so the promise returned by the /api/run/cancel fetch is handled instead
of discarded. Add rejection handling that logs or surfaces cancellation failures
while preserving the existing stop() behavior and request payload.

Comment on lines +149 to +159
async function hasFinished(record: RunRecord): Promise<RunExitProbe> {
try {
const handle = await buildSandbox(record.threadId).ensure({
threadId: record.threadId,
runId: 'run',
})
return await probeRunExit({ handle, runId: record.runId })
} catch (error) {
return { state: 'unknown', error }
}
}

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.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect SandboxDefinition for ensure/resume/exists semantics.
fd -t f 'contracts.ts|definition.ts' packages/ai-sandbox/src --exec ast-grep outline {} --items all
rg -nP --type=ts -C4 '\b(ensure|resume|lookup|exists)\s*[?(:]' packages/ai-sandbox/src | head -80

Repository: TanStack/ai

Length of output: 8158


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate relevant files"
fd -t f 'definition|contracts|run-durable|sandbox' . | sed -n '1,120p'

echo
echo "Outline run-durable"
ast-grep outline examples/sandbox-web/src/run-durable.ts --items all || true

echo
echo "Relevant run-durable sections"
nl -ba examples/sandbox-web/src/run-durable.ts | sed -n '1,260p'

echo
echo "Search for SandboxDefinition.ensure and implementations"
rg -n --type=ts -C6 'interface SandboxDefinition|type SandboxDefinition|ensure\(|resume\(|buildSandbox|createSandbox|Docker|docker|GROK_CLI_INSTALL_COMMAND' . | sed -n '1,260p'

Repository: TanStack/ai

Length of output: 2841


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Run-durable hasFinished/reaper sections"
awk '{printf "%5d\t%s\n", NR, $0}' examples/sandbox-web/src/run-durable.ts | sed -n '130,260p'

echo
echo "Find SandboxDefinition and ensure implementation candidates"
rg -n --type=ts -C4 'interface\s+SandboxDefinition|type\s+SandboxDefinition|function\s+buildSandbox|export\s+function\s+buildSandbox|buildSandbox\s*=|ensure:|ensure\(' packages examples testing | sed -n '1,240p'

echo
echo "Docker/container setup commands in sandbox-web"
rg -n --type=ts -C3 'GROK_CLI_INSTALL_COMMAND|reclaim|hasFinished|probeRunExit|ensure\(' examples/sandbox-web/src packages/ai-sandbox/src | sed -n '1,240p'

Repository: TanStack/ai

Length of output: 41126


Use provider.resume() in the detached-run probe.

ensure() is resumed-or-created, so hasFinished() can start a replacement sandbox for runs whose container was already removed. The probe then reads an empty journal and reports producing, keeping the run alive until its TTL while later reclaim destruction runs after a newly created container. Call provider.resume() for this scan instead of calling ensure() unless the container is still present.

🤖 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 `@examples/sandbox-web/src/run-durable.ts` around lines 149 - 159, Update
hasFinished to resume the existing sandbox through the provider’s resume()
operation instead of buildSandbox(...).ensure(), preventing detached-run probes
from creating replacement containers. Preserve the existing probeRunExit call
and unknown-state error handling, and only use ensure when the container is
confirmed present.

Comment thread packages/ai-client/src/chat-client.ts
tombeckenham and others added 2 commits August 3, 2026 19:04
… sandbox boot

Five stacked defects made a hard refresh during boot strand the run
(record running, log open at the run-accepted marker, client parked
forever, reaper waiting out its TTL):

1. Grok Build's default 'acp' protocol never journals — the CLI is
   driven over a bidirectional connection that bypasses the journaling
   spawn path, so detach/takeover had nothing to capture and the adapter
   refused every attach. Use protocol: 'streaming-json'.
2. Every buildSandbox() call minted a definition with its own fallback
   instance bookkeeping, so the takeover GET and every reaper probe
   created a fresh container instead of resuming the run's. Share one
   InMemorySandboxInstanceStore across withSandbox, the probes, reclaim,
   and the preview tool.
3. The run record only existed once the stream started — after boot — so
   a boot-window join found no record and core's driver (correctly,
   silently) served the log without driving. Create the record at accept
   time in the POST, mirroring the run-accepted marker.
4. The run driver and reaper are total: failures are logged, never
   thrown. With no logger wired, every failure above was invisible. Wire
   an errors-only logger into both.
5. A run whose agent never spawned (the refresh unwound the original
   drive mid-setup) had no recovery: the takeover's attach can only hit
   journal-timeout, which chat() delivers as a terminal RUN_ERROR.
   driveRun now intercepts that outcome — both the in-stream chunk and
   the thrown error, matched by name/reason because vite dev's dual
   module instances break instanceof — and restarts the run FRESH, which
   cannot duplicate anything: nothing beyond the marker was ever
   delivered. A bounded retrying claim covers the window where the
   original drive still holds the run.

Verified end-to-end against the dev server: POST, connection dropped at
t+3s (mid docker create), joinRun attaches to the marker, the takeover
claims, times out the journal wait, restarts fresh, and the same open
join stream delivers the full run to RUN_FINISHED. A no-refresh control
run also passes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cker

Swap the durable-runs demo from Grok Build (grok-4.5) to Claude Code
(claude-opus-4-8): claudeCodeText adapter, ANTHROPIC_API_KEY auth, npm CLI
install in the container, and session resume via the claude-code.session-id
event. Claude Code's one spawn path is the journaling NDJSON stream, so the
streaming-json protocol override (and the ACP port) go away. Docs that
described sandbox-web as Grok-on-Docker are corrected to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, POSIX SIGKILL

Six of the twelve CodeRabbit threads were real. Verified each against the code
before changing anything; three were false positives and are left alone with the
reasoning below.

REAL, fixed:

- `runner.ts` forwarded the request's `AbortSignal` to the journaled agent spawn.
  `toProcessOptions` strips only `onNonJsonLine`/`input`/`journal`, so `signal`
  survived into `handle.process.spawn`. Providers act on it at spawn time —
  local-process registers it to `killTree` the process GROUP — so a client
  disconnect killed the journaled agent, it wrote no exit sentinel, and a
  successor took over a run that was already dead. That is the exact inverse of
  this module's guarantee, and it was reachable: all three harness adapters
  (claude-code, codex, grok-build) pass `signal` into `spawnNdjson`. New
  `toJournaledSpawnOptions` drops it for the agent spawn only; the unjournaled
  path still forwards it (the host holds that pipe), and the tail read still
  honors it via `readJournalNdjson`. Test asserts on the KEY's absence, since
  `signal: undefined` would satisfy a value check while still letting a provider
  that tests `'signal' in opts` register an abort handler.

- `durable-stream.ts`'s `snapshot()` rejected for a stream the backend does not
  hold yet: `collectSnapshot` read straight through while `append`/`read` both
  call `ensureCreated()` first. Reachable on the FIRST producer of every durable
  run — `sandboxRunDriver`'s `pipe` runs `awaitLogQuiescence` (two `snapshot()`
  reads) before the first append — so a fresh run failed at its first chunk with
  `httpFailure('read', ...)`. e2e never caught it because it uses `memoryStream`,
  which resolves `[]` for an unknown run; the two adapters disagreed on the
  contract. Now calls `ensureCreated()` and reuses its `createdHere` memoisation.

- local-process `terminateChildren` never escalated on POSIX. `killTree` sends
  `signal ?? 'SIGTERM'` once, so a child that blocks it survived while
  `killableProcesses: true` promises forcible termination — and a survivor holds
  its CWD handle on the directory `removeDirWithRetry` is about to delete. Now
  escalates to SIGKILL after the first wait fails, in `terminateChildren` rather
  than in `killTree`, so an explicit `kill('SIGTERM')` keeps its chosen signal.

- The sqlite example had no additive migration. `CREATE TABLE IF NOT EXISTS` does
  not alter an existing table, and the app uses file DBs (`./.data/*.db`), so
  anyone who ran the example before this branch hit `no such column:
  detached_since` from `sqlitePersistence()` itself — `listReclaimable` is
  prepared eagerly and `node:sqlite` resolves columns at prepare time. Added
  `addMissingColumns` driven by `PRAGMA table_info` (SQLite has no
  `ADD COLUMN IF NOT EXISTS`), plus a test that builds a genuinely old file.

- The docker kill test asserted inside its poll loop, so a process still winding
  down failed it. Polls until empty, then asserts once; the bound is the real
  assertion, and the orphan it guards (`stream.destroy()` detaching only the
  client) never dies, so it still fails.

- Two `kill-tree` tests called `sbx.destroy()` only on the success path, leaking
  the sandbox and the very `tail.exe` the suite exists to prove killable. Both
  wrapped in try/finally.

FALSE POSITIVES, not changed:

- "Unhandled rejection from a promise lost to `Promise.race`" (flagged Critical,
  journal-reader.ts). `Promise.race` attaches handlers to every input, so an
  abandoned participant that rejects later is already handled. Measured on Node
  v24.3.0 rather than argued: a race whose loser rejects 15ms after the winner
  settles produces no `unhandledRejection`.

- "Guard the durable cancel probe" (middleware.ts). `wasCancelRequested` already
  try/catches and returns `false`; it cannot reject. Two existing tests pin that,
  including a synchronous throw.

- "Place the changed unit tests beside their source modules." Every package here
  keeps unit tests in a package-level `tests/` directory; that is the established
  layout, and moving five files would make this PR inconsistent with the ~80 test
  files around them. Worth settling repo-wide, not inside a feature PR.
…id-setup abort still detaches

`withSandbox`'s `setup` obtained the sandbox handle on its first line but did not
register its run state until its LAST line, ~150 lines later — after the git
baseline capture, workspace projection, hook dispatch, and watcher start.
`onAbort` opens with `if (!state) return`, so any disconnect landing in that
window was a silent no-op.

That window is not an edge case: it is the most common disconnect there is. A
user starts a run and switches tab (or refreshes) while the UI still says
"starting the sandbox" — which, for a provider that clones a repository, is
minutes wide.

Landing in it lost all three teardown behaviors at once:

- no `detachedSince`/`sandboxKey`, so `listReclaimable` can never surface the run
  and `reapDetachedRuns` can never reclaim it;
- no `definition.destroy`, so the sandbox leaks with no recovery path;
- no detach verdict, so core reaches
  `detached = cancelled && … && wasRunDetached(stream)` with `false`, takes its
  `!detached` branch and CLOSES the delivery log. After that no attach can ever
  tail the run: it replays a dead log and renders nothing, which presents as
  "durability does nothing" while the agent is still working in its sandbox.

Everything `onAbort` reads is already resolved immediately after `ensure()`: the
handle, the ensure context (for `definition.key`), the durability verdict, and
the logger. So the state is registered there, and the one field discovered later
(`watcher`) is assigned onto the same object rather than replacing the entry — an
abort that landed mid-setup already holds a reference to it.

`pendingDiffs` now IS `state.pendingDiffs` rather than a second array the watcher
closes over: `drainWatcher` awaits `state.pendingDiffs`, so two arrays would have
silently dropped every in-flight diff from the teardown drain.

Test: aborts from an `onReady` hook, which runs during setup after the handle
exists — deterministically inside the old window rather than racing a timer — and
asserts the run is detached, not destroyed, and that `RunDetachedCapability` is
published. Mutation-checked: disabling the early registration fails exactly this
case and nothing else.
Tailing no longer starts in the `ChatClient` constructor. `attach()` starts it and
`detach()` stops it, and every framework wrapper calls the pair around its view's
lifetime, so `useChat` / `injectChat` users need no change.

The constructor could not keep doing it. A UI framework may build a client and then
throw it away — React does on a double-invoked render — and a discarded client is
never mounted, so nothing ever calls `detach()` or `dispose()` on it and a connection
its constructor opened could never be closed. Traced with CDP: connection ids
1374/1396/1428/1437 were still held after eight thread switches, and a later request
waited 210 SECONDS for a free slot (`stallMs: 210752`). No guard inside the client can
fix that, because every guard runs on the instance the framework KEPT.

A page can own many chats — forty sandbox runs is a normal shape here — while a browser
allows about six connections per origin, so a handful of views consumed every slot and
everything else queued: a fetch issued from the page took 93s while the same request
from outside the browser took 17ms. Measured after: 12ms, and a reload that took 40s
now takes 422ms.

`detach()` keeps the transcript, the resume pointer and the run id, so re-entering a
view repaints at once and re-tails from the durable log — it is deliberately neither
`stop()` (the user ended the run) nor `dispose()` (the client is finished). Both
actions in `attach()` are gated on persistence, so an ephemeral chat issues no request
when its view mounts.

React, Preact and Svelte now release the connection the moment their view unmounts
(they deferred teardown through a timer a re-mount could cancel; Svelte had no
automatic cleanup at all). Solid, Vue and Angular already dropped it immediately and
now also attach on mount.

Also fixed: a hydration request that resolved AFTER its view was disposed went on to
open a tail on a dead client, which nothing could abort — one leaked connection per
thread switch.
…its tool history

A client disconnect could only reach `withSandbox` if the app mirrored
`request.signal` into `chat()`'s `abortController` — which aborts the run. `chat()`
then returned at its cancellation check right after middleware `setup`, so the harness
adapter's `chatStream` was never called and the agent in the sandbox that `setup` had
just spent minutes building was never launched. A disconnect is now delivered as a
NOTIFICATION: the durable transport tells the run its response body was cancelled
without aborting it, `withSandbox` records `detachedSince`/`sandboxKey`, and the run
keeps draining into its still-open log for a rejoining client to tail. An explicit stop
is unchanged — it arrives out of band.

Two more things were invisible for the whole of `ensure` (minutes: create a sandbox,
clone a repo), both fixed by doing them before it:

- The run had no record, because chat persistence creates it from `onConfig`, which
  runs after every `setup`. `findActiveRun` reported nothing running for a run that was
  demonstrably starting — measured: a status sidebar read `idle` for 6.5 minutes — and
  a crash in that window left nothing for `listReclaimable`.
- The user's turn was unstored, so a reload during the build asked the server for the
  thread and got `{"messages":[]}`.

The third gap in that window — an empty delivery log, which fails every joiner's
fast-fail and orphans a live run — is closed by core's `RUN_ACCEPTED_EVENT` for every
durable run, so `withSandbox` deliberately appends no marker of its own.

`withSandbox` also records the harness's own tool calls into the transcript, so a
FINISHED run restores its tool cards instead of only its verdict. The harness runs its
tools inside the sandbox, so `chat()` merely relays their `TOOL_CALL_*` chunks and
never wrote a message for them; persistence stores `ctx.messages`, so the tool history
lived in the delivery log alone. Away-and-back replayed it, a reload after completion
had nothing to rejoin and hydrated 4,014 characters where the live view had 510,933.
They are stored as ordinary `toolCalls` plus `role: 'tool'` messages, so no wire format
and no client code changed — `modelMessagesToUIMessages` already completes the card.

Each recorded call is marked, which does two jobs: it is stripped from the next request
to the model (those calls name tools the provider was never given, and one run of them
is far too many tokens to replay), and `isSandboxToolCall` — the one public addition
here — lets an app's own `MessageStore` cap or drop what it does not want to keep.
Results are handed over whole; trimming belongs to the store that owns them.

Verified in the browser against a real Docker sandbox: a live run streams, unmounting
releases the connection, returning replays the missed remainder and keeps streaming on
a single tail, and a reload restores the tool cards with their results.
…ired role

`TextMessageStartEvent` requires `role`, so the fake harness stream did not satisfy
`AsyncIterable<AGUIEvent>` and `@tanstack/ai-e2e:test:types` failed in CI. My local
run of the same target reported success from a stale Nx cache entry, which is why this
reached the branch; re-checked with `--skip-nx-cache`.
…en npm installs

The triage setup step built its install command by splicing the harness command
into a bigger one (`<cmd> || sudo -n env PATH=$PATH <cmd>`). The Grok CLI
installer starts with a subshell — `(curl … || curl …) | bash` — which is not a
valid argument to `sudo`/`env`, so the container shell failed at PARSE time with
`sh: syntax error: unexpected (` (exit 2) and never ran the install. The sudo
fallback was also wrong for grok: its installer writes to `$HOME/.grok`, and
`sudo env` resets HOME.

The npm harnesses had a second, intermittent failure: npm treats the
platform-specific native binary (an OPTIONAL dep) as best-effort, so a transient
download failure is not an install error. npm exits 0 and the break only surfaces
mid-run as `Missing optional dependency @openai/codex-linux-x64`.

Each harness now owns a complete, self-contained install command that verifies
the CLI and retries once, and the setup step runs it verbatim.
Same behavior, clearer pages. Each one now opens with the problem the reader
arrived with instead of the mechanism, and the sets that were buried in prose are
lists.

- `sandbox/events`: the storage section leads with reopening a thread the next
  morning, states in a table what a finished run leaves in the message store, and
  keeps one trimming recipe per goal (cap results, or drop the history with its
  results so no orphan reaches a provider).
- `sandbox/takeover`: the `memoryStream` deadline section leads with the error a
  reader actually sees, and the two reasons it is only a backstop are a list.
  `run.accepted` is named as the thing that keeps a rejoin from starving.
- `api/ai-client`: the lifecycle section explains the six-connections-per-origin
  limit first, so `attach()`/`detach()` read as a consequence rather than a rule,
  and the migration note is scoped to direct `ChatClient` users.

Also removed every em dash from the pages this PR owns, and rewrote the comma
splices that removing them created.

Review feedback, `withSandbox.onAbort`: the durable cancel probe cannot reject.
`wasCancelRequested` already answers `false` for an unreadable store, so a guard
here would be dead code. What the review is right about is the consequence, since
a rejection escaping into `onAbort` would skip BOTH branches and leave a sandbox
that is neither reclaimable nor destroyed. That composition now has a test, which
fails if core's guard is ever removed.
…ves behind

A sandboxed run persists in two halves that people were choosing between without a
page to choose from: the sandbox side (which provider sandbox to resume, whether a
run is live, its event log) and the conversation. The new page opens with that
decision as a table of four postures, then shows the wiring for each:

- Keep everything: `withPersistence` + `withSandbox`, with the SAME `RunStore` passed
  to both so one record describes the run.
- Keep only the sandbox side: no message store at all. Runs survive a refresh and can
  be taken over, and nothing anyone typed is stored, which is the posture to reach for
  when the text is sensitive.
- Keep only the conversation: `withPersistence` alone, ephemeral sandboxes.
- Keep neither.

It then implements `SandboxInstanceStore` against a real database rather than stubs,
states each invariant where it bites (a merging `upsert` leaves a stale
`latestSnapshotId`, so `ensure` resumes from a snapshot that no longer describes the
workspace), and points at the four testkit conformance suites, which no page taught
before.

Nothing here touches the chat or generation adapter docs: the sandbox needs a
`RunStore` (a core contract) and its own instance store, never a chat store.

Deduped rather than forked: `sandbox/durability` had a stub implementation, an
invariants table and a schema for the same store. It now points here, and the
invariants table moved with the content. `sandbox/events` points here for "should I
store a transcript at all", and keeps the finer "make the one I store smaller".
…hind Advanced

Setting persistence up meant reading essays to find four snippets. `overview.md`
spent its first 200 lines on the two durability layers, thread and run identity, who
owns history and reload semantics before any wiring appeared. The deep pages sat in
the same flat nav as the setup pages, so nothing told a reader what to skip.

This follows the shape `resumable-streams/` already uses: a short numbered overview,
with the contract and the edge cases on their own pages.

- `overview.md` is now 178 lines of three steps: server middleware, client option,
  then the `GET` that makes a mid-answer reload work. Generation and sandboxes get a
  short section each pointing at their own pages, and a five-row table answers "which
  setup do I want".
- `build-your-own-adapter.md` opens with the smallest adapter that works, one
  `messages` store, then which stores you need, then the conformance suite.
- The concepts moved to `internals.md`, retitled "How Persistence Works": two layers,
  thread and run identity, isolation, who owns history, what a reload restores in each
  case, and why server-authoritative is the default.
- The store table moved to `store-reference.md`, along with the ER diagram and the
  `define*Store` typing mechanics from the adapter hub.
- `chat-persistence` and `client-persistence` keep their contracts and lose the
  theory they duplicated.

Nav: a collapsed "Persistence (Advanced)" section now holds the store reference,
internals, id map, the two long adapter walkthroughs and keep-generated-files, using
the same `collapsible` shape as the API "Class References" sections. Those pages are
retitled `(Advanced)` like `Resumable Streams (Advanced)`.

No file renamed and none deleted, so every URL and inbound link still resolves.
…nced

The sandbox section listed eighteen pages flat, so a reader wanting an agent in a
container had no way to tell that the journal, takeover and reaping pages were not on
their path. Same shape as the persistence pass, and as `resumable-streams/`.

- A collapsed "Sandboxes (Advanced)" nav section now holds the run journal, takeover,
  reaping, provisioning, observability, Cloudflare and the adapter page, each retitled
  `(Advanced)`. The plain path is overview, quick start, providers, harnesses,
  workspace, tools, policy, lifecycle, instance durability, durable runs, events.
- `durable-runs.md` grew the wiring it was missing. Turning durable runs on was a
  20-line snippet buried 700 lines into `takeover.md`; it now sits on the plain page,
  followed by the two things people forget (schedule the sweeper, use a real
  distributed lock). Renamed in the nav to "Durable Runs", since it is no longer only
  an explainer.
- `overview.md` ends with a next-steps list that names only plain-path pages and says
  the Advanced group holds the rest, instead of mixing all eighteen.

Also removed every em dash from the plain path and from the two advanced pages I
wrote, and fixed the comma splices that removing them exposed. Three advanced pages
(`journal`, `reaping`, `cloudflare`) are left alone: they are Tom's prose and this PR
is under his review.

No renames, no deletions, so every URL still resolves.
… under Advanced

Every modality page opened with an architecture diagram or a model-options table and
re-documented all three transports, so `image-generation` was 724 lines and
`video-generation` 888 before a reader got anything runnable.

- `generations.md` now opens with the whole loop: one server route, one hook, and a
  line saying every other modality is the same pair of names. A three-row table picks
  a transport. The architecture diagram, the chunk protocol, the hook API and the
  three transports written out in full moved to `## Advanced` on the same page.
- Each modality page keeps overview, basic usage, options and one full-stack example,
  and grows one `## Advanced` section holding what a reader does not need to start:
  model options, response formats, model availability, error handling, env vars,
  explicit API keys, rate limits, best practices.
- The two duplicate transports on each modality page moved there too, with a pointer
  to the single explanation in `generations`. That alone took 200 to 300 lines off the
  path a reader walks.

Sections moved verbatim and were only demoted a heading level, so no reference content
changed. No page renamed or deleted.

Em dashes cleaned on the three media pages I wrote. `image-generation`,
`video-generation`, `transcription`, `audio-generation` and `realtime-chat` are other
authors' prose, so their remaining ones are left for a separate sweep.
…eory

Reading the page as a new user caught the exact fault this pass is about: the snippet
that turns durable runs on sat below every explanation, so someone who just wanted it
working had to scroll past the mental model to reach four lines of config.

"Turn it on" is now the first section. The explanation follows, and the intro says so
instead of claiming the page has no code. Retitled to "Durable Runs", since it is now
a wiring page that also explains itself.
# Conflicts:
#	docs/config.json
#	docs/media/video-generation.md
…problem

`runPersistenceConformance` made four fields mandatory that only durable sandboxed
runs use: `sandboxKey`, `detachedSince`, `cancelRequested`, `driverEpoch`, plus the
rule that an omitted patch key leaves a column alone while an explicit `undefined`
clears it. The case was deliberately non-skippable, so a Postgres adapter for a plain
chat app could not pass conformance without four columns nothing in its stack would
ever write. The fields were already optional on `RunRecord`, and `listReclaimable`
was already optional and feature-detected: the suite was the only thing making them
mandatory in practice.

The assertions moved rather than vanished. `runDurableRunFieldsConformance` now ships
from `@tanstack/ai-sandbox/testkit`, beside the takeover and reaper suites that consume
those fields, and takes the same `runs` store. Mutation-checked in its new home: making
the in-memory store filter `undefined` out of its patch fails it with
`expected 500 to be undefined`, which is the exact bug the case exists to catch.

`ai-persistence`'s suite header now says where they went, so nobody re-adds them.

Docs follow the split, and the page moved to sit with its siblings:

- `persistence/build-a-sandbox-adapter` (moved from `sandbox/`) gains "The four run
  fields": what writes each one, what breaks when it is dropped, the key-presence
  rule, and the one-line suite to prove it. It is now the third adapter walkthrough
  next to chat and generation, listed from the hub.
- `persistence/store-reference` marks the four sandbox-only with a pointer instead of
  teaching them inline.
- The chat walkthrough labels them SANDBOX ONLY in its `runs` example, since a reader
  following it for a chat app should skip them.
…signed

Audit of everything this PR adds: 21 new source files, every one imported by
something; every field of the new option interfaces (`SandboxDurabilityOptions`,
`SandboxMiddlewareOptions`, `ReapOptions`, `ReclaimSandboxOptions`) read somewhere;
and both `toolHistory.reconcile` call sites load-bearing, each with a test that fails
when it is removed. Two things were not pulling their weight:

- `TranscriptTarget` was exported from `tool-history.ts` and named by nothing outside
  that file. It is now a local interface; the exported `ToolHistoryRecorder` still
  describes its own shape structurally.
- `getDetachableRun` was exported from `@tanstack/ai` and used by nobody, because chat
  persistence read `ctx.getOptional(DetachableRunCapability)` directly while every
  other consumer of a capability uses its accessor. Persistence now calls the accessor,
  so the seam has one idiomatic entry point instead of an exported half nothing
  reached for.
`@tanstack/ai/adapter-internals` re-exported `RunDisconnect`, `PendingTurnSnapshot`
and `SandboxRuntime`, and no consumer names any of them: a middleware reaching a
capability calls the accessor and gets the value typed for free. The capabilities and
their accessors stay exported, the types no longer are. The `.d.ts` emit confirms it:
the subpath now surfaces accessors only.

`SANDBOX_OBSERVED` is internal for the same reason. An app asks `isSandboxToolCall`
instead of knowing the key, and the recorder test now pins the literal
`{ sandboxObserved: true }`, which is the stronger assertion anyway: that string ends
up inside stored `toolCalls[].metadata`, so renaming it is a data change and has to
fail a test.

`SandboxRuntime` was pre-existing and unused in the same way, so it went with them.
The PR job set `REQUIRE_DOCKER: '1'` so an unreachable Docker daemon failed the run
instead of skipping the `@tanstack/ai-sandbox-docker` matrix. The intent was to protect
the one matrix that exercises BusyBox 1.37 (`alpine:3`), which is the only shell here
that proves the journal age gate's portability. The cost was that a flaky daemon or a
runner-image change broke the PR job outright.

Removed, along with the env gate inside `docker-daemon.ts` that nothing else set: a
missing daemon is now always a NAMED `unsupported` skip carrying the ping error and the
suite name, so it still never reads as coverage in the reporter. `suite` moved into
that reason, since the throw was its only other reader.

The limit this leaves is written down where the gate lives: from inside the suite a CI
runner with no daemon is indistinguishable from a laptop with no daemon, so if a runner
image stops shipping Docker this matrix degrades to skips and CI stays green. Catching
that belongs at the CI level, asserting the suite ran, not in a test-side throw.
@AlemTuzlak
AlemTuzlak merged commit d9d1e1f into main Aug 4, 2026
9 checks passed
@AlemTuzlak
AlemTuzlak deleted the feat/durable-agent-runs branch August 4, 2026 14:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants