Durable Agents 2026: Leased Executors in Practice
💡 Tool Tip:Debugging agent-state JSON? Try Evergreen Tools' JSON Validator and Diff Checker — all free!
In 2026, AI agents started doing real work: twenty minutes researching a problem, ten minutes waiting on a build, asking a user for approval, or coming back hours later when an external job finishes. If you still write an agent as a long-running process — a worker receives a request, enters an agent loop, calls models and tools, waits for results, and eventually returns an answer — you waste resources and make failures expensive: a deploy, crash, or machine restart destroys work that already happened. In his August 26, 2026 essay "Agents Should Be Durable, Not Long-Lived," Ju Lin lays out a cleaner model: separate the agent run from the process executing it. This guide implements that pattern end to end.
1. Why the Long-Lived Process Pattern Fails
In the traditional implementation, one agent equals one long-running process. State lives in RAM and the worker must survive the entire run. Real workflows make agents wait constantly: twenty minutes of research, ten minutes of builds, human approvals, external callbacks. Keeping a worker alive for the whole run wastes resources and makes failures expensive — a deployment, crash, or machine restart can destroy progress that already happened. This is exactly why production agent systems in 2026 are moving to durable runs.
// The old way: one agent = one long-lived process
// Problems: a 20-min research task holds a worker hostage,
// a deploy or crash destroys in-flight work.
class LongLivedAgent {
constructor(private request: Request) {
this.messages = []; // state lives in RAM
}
async run() {
const research = await this.research(20 * 60_000); // blocks
const build = await this.waitForCI(10 * 60_000); // blocks
const approval = await this.askHuman(); // blocks
return this.finalize(research, build, approval);
}
}2. The Core Pattern: Durable State + Leased Execution
The useful abstraction is not a short-lived agent; it is a durable agent with a leased executor. The agent run is durable — its state, messages, tool results, budget, and current position live outside the worker. The worker is temporary: it leases a runnable agent, performs useful work for a short period, checkpoints the new state, and disappears. Another worker can later continue from the checkpoint. This does not mean every model or tool call needs its own process — a worker can hold a 30- or 60-second lease and execute several agent steps while progress is being made.
// Durable run: state lives outside the worker.
// A worker "leases" a runnable agent for a short window,
// checkpoints progress, then disappears.
interface AgentState {
runId: string;
messages: Message[];
toolResults: ToolResult[];
budgetSpent: number;
position: string; // where in the plan we are
waitingOn: null | { type: "ci" | "human" | "timer"; ref: string };
}
async function executeLease(state: AgentState, leaseSeconds = 60) {
const deadline = Date.now() + leaseSeconds * 1000;
while (Date.now() < deadline) {
const step = await nextStep(state); // one agent step
state = await checkpoint(state); // persist after every step
if (step.kind === "wait") return { state, status: "waiting" };
}
return { state, status: "lease-expired" }; // another worker continues
}3. State Must Be Cheap to Reconstruct
The pattern imposes one hard constraint: state must be cheap. The good news is modern databases make checkpoints trivial — write once after every executed step. With an edge database like libSQL/Turso, an upsert keyed by run_id is all you need. The harder part is that stateful services like browsers, shells, and sandboxes may need their own longer-lived services, and streaming must be independent of whichever worker currently owns the run. These are infrastructure problems we already know how to solve — large web systems stopped assigning a permanent server process to every user long ago.
// Checkpoint storage: cheap to write, cheap to rebuild.
import { Database } from "@libsql/client";
const db = new Database({ url: process.env.TURSO_URL! });
export async function checkpoint(state: AgentState) {
await db.execute({
sql: `INSERT INTO agent_runs (run_id, state_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(run_id) DO UPDATE SET state_json = excluded.state_json`,
args: [state.runId, JSON.stringify(state), Date.now()],
});
}
export async function loadRun(runId: string): Promise<AgentState> {
const row = await db.execute({
sql: "SELECT state_json FROM agent_runs WHERE run_id = ?",
args: [runId],
});
return JSON.parse(row.rows[0].state_json as string);
}4. The Waiting Boundary Is the Design
The important boundary is waiting. If an agent needs five minutes of CI, it should not sleep for five minutes. It records that it is waiting and exits; when CI finishes — or a timer fires — the run becomes runnable again. The same pattern works for rate limits, human approval, scheduled actions, external callbacks, and communication between agents. This "wait-and-exit" semantics is what lets the pattern scale to millions of concurrent runs.
// The important boundary is WAITING.
// If an agent needs 5 minutes of CI, it must not sleep 5 minutes.
// It records "waiting on ci" and exits. A timer or webhook re-activates it.
const RUNNABLE_QUEUE = "agent:runnable";
async function waitForCI(state: AgentState, buildId: string): Promise<AgentState> {
await checkpoint({ ...state, waitingOn: { type: "ci", ref: buildId } });
await redis.lpush(RUNNABLE_QUEUE, state.runId); // parked, not running
return state; // worker exits now
}
// CI webhook: when the build finishes, make the run runnable again.
app.post("/webhooks/ci/:buildId", async (req) => {
const runId = await redis.get(`ci:${req.params.buildId}`);
await redis.rpush(RUNNABLE_QUEUE, runId);
});5. Idempotency and Side-Effect Safety
Leased execution means retries are normal, so side effects must never run twice. In practice, wrap every external side effect in an idempotency key: check Redis for an existing result first, return it if present, otherwise execute and store. Add a scheduler that pops run_ids from a runnable queue, acquires a worker, executes a lease, and releases it — and you have a system that supports a million active agent runs without a million processes.
// One million active runs, far fewer processes.
// Most agents are waiting; only runnable ones get compute.
async function scheduler() {
while (true) {
const runId = await redis.blpop(RUNNABLE_QUEUE, 0);
const state = await loadRun(runId);
const worker = acquireWorker(); // short lease
try {
const { state: next } = await executeLease(state, 60);
await checkpoint(next);
} finally {
releaseWorker(worker);
}
}
}
// Idempotency: side effects must not run twice after a retry.
export async function withIdempotency<T>(key: string, fn: () => Promise<T>): Promise<T> {
const done = await redis.get(`effect:${key}`);
if (done) return JSON.parse(done);
const result = await fn();
await redis.set(`effect:${key}`, JSON.stringify(result), { EX: 86_400 });
return result;
}6. When to Start Migrating
If your agents are simple sub-second queries, a long-lived process is perfectly fine. But the moment an agent waits on a build, requests approval, or runs across hours, it is time to migrate. The path is incremental: extract state into a database, add checkpoints, then introduce leases and wait boundaries. Each step delivers incremental value, and the final step unlocks true horizontal scaling.
📌 Frequently Asked Questions
What is the fundamental difference between durable agents and long-lived processes?
A long-lived process keeps state in worker memory and the worker must stay alive for the whole run. A durable agent stores state (messages, tool results, budget, position) externally, and workers only hold short leases to execute productive steps before disappearing.
Do I need a new process for every model call?
No. That would create unnecessary scheduling and state-reconstruction overhead. A worker can hold a 30-60 second lease and execute several steps while making progress. The key boundary is waiting: when the agent needs to wait, record state and exit.
How do I guarantee side effects don't run twice after a retry?
Use idempotency keys. For every external side effect (email, API call, DB write), check Redis for an existing result under that key first; if present return it, otherwise execute and store. Leased retries then become completely safe.
How does streaming work in this model?
Streaming must be independent from whichever worker currently owns the run. Route output through a persistent pub/sub channel (like Redis Streams) so any worker that takes over the run can keep pushing to the client.
Is this pattern right for every agent?
Simple sub-second agents are fine as long-lived processes. Once an agent waits on builds, requests human approval, or runs across hours, migrate. The migration can be incremental: extract state, add checkpoints, then introduce leases and wait boundaries.