A long agent turn can outlive the process that started it: a deploy restarts the server, or the machine crashes in the middle of a tool call. Every host keeps each thread in a session log. Put that log in a store that survives a restart, and the next host continues the turn. A turn that crashes the host every time stops after a limit.
The host needs one store: log. It keeps the events, the transcript, the inputs, the runs, the interrupts, the tool steps, and the state of each thread. It also records which host owns each thread.
import { createHarnessHost } from '@tanstack/ai-harness'
import { memoryPersistence } from '@tanstack/ai-persistence'
// Swap in your own stores. Memory stores do not survive a restart.
const host = createHarnessHost({ persistence: memoryPersistence() })A thread that runs for weeks has a long log, and a new host reads all of it to open the thread. The owner of the log writes a snapshot of the folded log to the log store. A new host opens from the snapshot and reads only the records after it. Set when the owner writes one:
const snapshotHost = createHarnessHost({
persistence: memoryPersistence(),
log: { saveSnapshotAfter: { bytes: 500_000, ms: 60_000 } },
})To also drop the old records, set lifecycle.retention.keepLogRecords to 'since-snapshot'. See Drop the records before a snapshot.
Each run of an input is one attempt: the first run, and each run after a crash. Set the limits on the harness:
import { defineHarness } from '@tanstack/ai-harness'
import { openaiText } from '@tanstack/ai-openai'
const assistant = defineHarness({
name: 'acme/assistant',
adapter: openaiText('gpt-6.1-sol'),
durability: { maxAttempts: 5, timeoutMs: 30 * 60_000 },
})A turn that passes its time limit is stopped. It ends with the error code timeout.
A host that stops also stops renewing its ownership of the thread. When the ownership expires, the next host that takes the thread finds the turn that did not end. It checks these, in order:
An input that was stored but never ran, runs now. session.settled({ inputId }) resolves to how an input ended. See Send inputs safely.
A background agent that you started with runAgainAfterCrash: true runs again on the next host. A background child that the model started with background: true runs again too, unless you set durability: { resumeBackgroundAgents: false }. See Keep a background agent going after a crash. The children and groups that it started before the stop do not start again: they give their stored results. See Children of a run that runs again. For any other background agent whose host stopped, the next host:
durability.recover does not run for background agents.
host.close() closes every session of the host and refuses later opens. It ends each running turn aborted, so no host runs it again. When the server stops only for a deploy, close it with recoverable: true. The running turns stop, but they stay open in the log, and another host continues them at once:
process.on('SIGTERM', async () => {
await host.close({ recoverable: true })
process.exit(0)
})During a rolling deploy, old and new hosts run at the same time. A host that reads a log record type from a newer version does not write to that log. Its session stops with LogVersionError, and the new hosts continue the thread.
A host recovers a thread when something opens it. After a deploy, nobody opens a thread whose user closed the tab, so its queued turn or background agent waits. Call host.resumePending to find that work and run it:
const resumed = await host.resumePending({ harnesses: [assistant] })
console.log(`Resumed ${resumed.length} threads`, resumed)host.resumePending({ harnesses, close, limit }):
It resolves with the threads that it took, as { threadId, harness, tenantId } entries. Each thread opens in its own tenant, with no user.
If a thread that the sweep took does not open, the host gives it back with a wake in 30 seconds. The next sweep or worker tries it again.
The close option sets what happens to a session that the sweep opened:
Call it once at boot, or from a cron route or a Durable Object alarm. To run this work all the time, on as many machines as you want, use a worker. See Run background work on workers.
If this host already has the thread open, the sweep does not recover it again. Call host.recover(threadId) for that thread. See Check a skipped turn again.
A model call can fail for a short time: a 429, a 5xx, an overloaded provider, or a network error. Add retryTransientErrors() to run the model again in the same turn, after a backoff:
import { retryTransientErrors } from '@tanstack/ai-harness'
const resilient = defineHarness({
name: 'acme/resilient',
adapter: openaiText('gpt-6.1-sol'),
turn: { onModelError: retryTransientErrors() },
})Other errors fail the turn. To set the limits, see Control how a turn ends.
The log keeps the retry count. The host writes it when a model call fails, before the backoff starts. So a turn that a crash stopped, also during the backoff, goes on with the count that it had, and a crash does not give it new retries. The count starts again at 0 after a finished tool phase and at a new turn.
The steps above are the default. To change them for one input, add durability.recover. It runs for each input that a crashed host left. It gets { session, input, messages, decision, interruptedTools }:
Return undefined to keep the decision. Or return one of these:
This example fails an input with your own error code after 2 runs:
const guarded = defineHarness({
name: 'acme/guarded',
adapter: openaiText('gpt-6.1-sol'),
durability: {
recover: ({ input, decision }) => {
if (decision.action === 'run' && input.attempt >= 2) {
return {
action: 'settle',
outcome: 'failed',
error: { message: 'This request crashed the host twice.', code: 'poison_input' },
}
}
return undefined
},
},
})A crash can stop a run after a tool started but before its result was stored. Tell the harness what to do with replay:
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const lookup = toolDefinition({
name: 'lookup_order',
description: 'Read an order',
inputSchema: z.object({ id: z.string() }),
replay: 'safe',
}).server(async ({ id }) => ({ id, status: 'shipped' }))A tool that finished before the crash keeps its result. It does not run again. For a tool with side effects, such as a payment, see Run side effects once.
The model reads these tool results after a crash. To give them in your own words, set them on the harness:
const worded = defineHarness({
name: 'acme/worded',
adapter: openaiText('gpt-6.1-sol'),
durability: {
interruptedToolResult:
'The tool stopped before it finished. Check the result before you call it again.',
truncatedToolResult: (call) =>
`Tool call "${call.toolName}" did not run: your answer hit the output limit. Call it again with the complete input.`,
},
})With truncatedToolResult, a turn uses the same text live: the cut calls get their results, and the model runs again, so it can call the tool again. Recovery gives the text to a cut call that has no result in the log. See When the output limit cuts a tool call. Without it, a cut answer ends the turn, and a cut call gets the default text The answer was cut off at the output limit before this tool call was complete. The call did not run.
A crash in the middle of a long answer loses the text that the model already wrote, and the next attempt starts the answer again. Set continueCutOff, and the next host keeps that text and asks the model to go on from it:
const continuing = defineHarness({
name: 'acme/continuing',
adapter: openaiText('gpt-6.1-sol'),
durability: { continueCutOff: true },
})When a turn runs again after a crash, the host adds two messages to the transcript, in one log write:
Then the model continues after the note. To use your own note, set continueCutOff: { note: 'Go on from where you stopped.' }. To send more than one, give a list. Each note is a user message, in that order:
const twoNotes = defineHarness({
name: 'acme/two-notes',
adapter: openaiText('gpt-6.1-sol'),
durability: {
continueCutOff: {
note: [
'The previous assistant stream was interrupted.',
'Continue from the partial answer.',
],
},
},
})On a serverless platform, two requests for one thread often reach two instances. Only one host writes to the log of a thread at a time. Give every instance the same log store, and the log decides who owns the thread:
Set the timing with the ownership option of the host:
const ownedHost = createHarnessHost({
persistence: memoryPersistence(),
ownership: { expiresAfterMs: 60_000, renewEveryMs: 20_000 },
})An input that waits for the owner keeps its JSON overrides (model, modelOptions, reasoning, and promptCache). The adapter and tools overrides are objects, so the owner runs the input without them.
A wait for a person does not always keep a host busy:
An answer that reaches another host goes to the owner through the log. Until then, a plain answer waits in the log store as the user typed it. The log keeps a secret answer as [secret].
A question with secret: true asks for a key or a password. The answer must not wait in the log store as the user typed it. So when the answer reaches a host that does not own the thread, it goes through the credential store:
If the host does not find the question in the log, it treats the answer as a secret too.
Pass the same credentials store to every host. A secret answer can be rejected:
The store keeps each reference as a credential of the type secret_answer:
A host can stop renewing for a while: a long garbage collection pause, or a lost network. Then another host takes the thread. When the first host sees this, it stops its work on the thread:
The store refuses each late write of the first host, so the log never gets the records of two owners.
Your payment system or job queue can refuse a call from a host that lost the thread, or a second call for the same side effect. Each run gives you two values for that in ctx.context.run:
import type { HarnessRunContext } from '@tanstack/ai-harness'
declare const payments: {
charge: (charge: { amount: number; idempotencyKey: string }) => Promise<{ id: string }>
}
const charge = toolDefinition({
name: 'charge',
description: 'Charge the card',
}).server<HarnessRunContext>(async (_args, ctx) => {
const key = ctx.context.run.idempotencyKey('charge', ctx.toolCallId)
return payments.charge({ amount: 100, idempotencyKey: key })
})Pass the tool call id as scope in a tool, so two calls of one tool get two keys. A step of a durable tool gets its own key as idempotencyKey.
A host checks the log of a thread when it opens the thread. A turn that another host still runs at that time is skipped. If that other host stops later, the turn waits until something checks again. Call host.recover() on a timer:
setInterval(() => {
void ownedHost.recover()
}, 15_000)A worker does this for the threads that no host has open.