# Zidane × Restate — durable agents

`zidane/restate` is an **optional** adapter that runs the agent loop inside a [Restate](https://docs.restate.dev/) handler so every LLM call and completed tool execution becomes a journaled side effect. Crash mid-run → resume from the journal without re-billing tokens or re-running persisted tool calls.

It's a thin layer over the existing agent surface — no fork, no separate runtime. The core harness has zero dependency on Restate; you opt in by wiring four wrappers.

```
zidane (core)
   └── zidane/restate  ← this adapter (Bun + Node, structurally typed against @restatedev/restate-sdk)
```

A production-style runnable example lives at `examples/restate/agent.ts`. The `bun start --restate` CLI path is a local/demo runner around the same adapter; it is useful for exercising the flow against a local Restate runtime, but production services should wire `zidane/restate` inside their own Restate handlers.

## When to use it (and when not to)

**Use Restate when** you need a server-side, long-running agent that must survive process restarts mid-run: an unattended job, a webhook-driven worker, a multi-hour pipeline that calls expensive providers and writes to external systems. The journal turns crash recovery from "replay from a session snapshot" into "replay from the last completed side effect, mid-turn".

**Skip Restate when** you're running:
- A normal interactive TUI / CLI session (the user owns process lifetime; sessions are the recovery story).
- A request/response API where the agent finishes inside the request lifetime.
- A worker where crash → start-over is acceptable (sessions still persist conversation; you just lose the in-flight turn).

Restate adds a deployment surface (runtime + SDK + journal storage). Don't pay for it unless mid-run crashes are an actual concern.

## Wrappers

Adapter helpers — pick the ones you need. Pass-through for everything else (`Provider.formatTools`, `ToolDef.spec`, etc.) so the rest of Zidane is unchanged.

| Helper | Wraps / configures | What it does |
|---|---|---|
| `restateBehavior()` | `AgentBehavior` | Strict Restate defaults: sequential tools, background tasks disabled for non-durable contexts, cache on, Restate control errors re-thrown |
| `restateClock(ctx)` | `AgentClock` | `now()`, `randomUUID()` — timestamps, run IDs, and fallback turn IDs stable across replays |
| `restateProvider(provider, ctx)` | `Provider.stream` | Each LLM turn's `TurnResult` — replay returns cached value, no re-billing |
| `restateTool(tool, ctx)` / `wrapAgentTools(tools, ctx)` | `ToolDef.execute` | Each completed tool invocation's result — replay skips re-running persisted tools |
| `restateToolBatchExecutor({ promises })` | Tool scheduler | Opt-in Restate-native parallel join for concurrency-safe tool fleets |
| `restateSessionStore(ctx)` | `SessionStore` | Session header + turns + runs as virtual-object K/V state, header timestamps journal-sourced |
| `restateAwakeableTool(options)` | `ToolDef` | Tool that parks on a Restate awakeable until an external resumer resolves it (human-in-the-loop waits) |
| `awakeableWithTimeout(ctx, opts)` | awakeable + `ctx.sleep` | Awakeable raced against a durable timer — resolves the resumer payload, or `null` on deadline. The 3-line building block for `waitBackground` |

### Minimal wiring

```ts
import * as restate from '@restatedev/restate-sdk'
import { createAgent } from 'zidane'
import { anthropic } from 'zidane/providers'
import { restateBehavior, restateClock, restateProvider, restateSessionStore, wrapAgentTools } from 'zidane/restate'
import { createSession } from 'zidane/session'
import { shell } from 'zidane/tools'

const zidaneAgent = restate.object({
  name: 'zidane-agent',
  handlers: {
    async run(ctx, { prompt }: { prompt: string }) {
      const session = await createSession({
        id: ctx.key,
        store: restateSessionStore(ctx),
      })

      const agent = createAgent({
        provider: restateProvider(anthropic({ defaultModel: 'claude-sonnet-4-5' }), ctx),
        tools: wrapAgentTools({ shell }, ctx),
        clock: restateClock(ctx),
        session,
        behavior: restateBehavior(),
      })

      return await agent.run({ prompt })
    },
  },
})

restate.serve({ services: [zidaneAgent] })
```

### Local CLI runner

`bun start --restate` starts a local Restate endpoint in the current process, waits for the Admin API, probes the local service port, registers the deployment, and invokes the `zidane-start-agent` virtual object through ingress. It is intentionally a convenience runner for local validation and demos.

```bash
bun run restate:fixture:up
bun start \
  --restate \
  --prompt "exercise the durable runner" \
  --session-id demo-1
```

Useful flags:

- `--restate-admin-url` / `--restate-ingress-url` — Restate control-plane and ingress URLs. Defaults match the Docker fixture.
- `--restate-service-port` — local Bun endpoint port. Defaults to `19081`. The Docker smoke fixture's standalone service uses `19080`; the start CLI uses `19081` to avoid colliding with it.
- `--restate-deployment-uri` — URI Restate uses to reach the local endpoint. Defaults to `http://host.docker.internal:<service-port>` for the Docker fixture.
- `--restate-keep-alive` — keep the local endpoint alive after the first invocation so Restate can retry or you can inspect/reinvoke it.
- `--restate-invocation-timeout-ms` — optional client-side timeout for the CLI's ingress `fetch`. This is not a durable handler execution timeout. Omit it for long-running local agent runs.
- `--restate-provider-retry-attempts` / `--restate-tool-retry-attempts` — advanced overrides for the `ctx.run` retry attempts used by the CLI runner's provider and tool wrappers.
- `--restate-random-failure` — local chaos switch that injects retryable provider failures for exercising replay/retry behavior.

`--session-db` is rejected in Restate mode because the virtual-object state owns the session projection.

The CLI service exposes:

- `run` — drive an agent run for the object key.
- `status` — read the last run status: `idle`, `running`, `completed`, or `failed`.
- `history` — read the `restateSessionStore` projection for the object key.

When a run completes, the service stores the input fingerprint plus a small completion summary. A later `run` with the same object key and same input returns the persisted session result; a completed object key with different input is rejected with a 409-style terminal error. Use a new `--session-id` for a distinct local run.

## How replay works

Restate journals each `ctx.run(name, fn)` call. The original execution runs `fn` and stores its return value (or error). On any subsequent invocation of the same handler attempt — typically a crash recovery — `ctx.run(name, fn)` looks the entry up by `name` and returns the cached value **without invoking `fn`**.

```
                    ┌──────────────── live ─────────────────┐
                    │                                       │
turn N:  loop ──▶ restateProvider.stream() ──▶ ctx.run("llm-call-N", () => anthropic.stream(...))
                                                  │
                                                  ├─ journal entry: TurnResult
                                                  │
                                              ┌───┴───┐
                                              │ crash │
                                              └───┬───┘
                                                  │
turn N:  loop ──▶ restateProvider.stream() ──▶ ctx.run("llm-call-N", () => anthropic.stream(...))
                                                  │
                                                  └─ returns journaled TurnResult (no model call)
```

Journal-entry names are scoped to the handler invocation:

- **LLM calls**: `llm-call-1`, `llm-call-2`, … (monotonic per wrapper instance).
- **Tools**: `tool-<name>-1`, `tool-<name>-2`, … (per wrapper, per tool name).

Both names are configurable via the `entryName` option if multiple agents share one service and you need disambiguation.

### What re-fires on replay (and why that's fine)

Restate replays only what's inside `ctx.run`. Everything else re-executes — including Zidane's hooks:

- `tool:gate`, `tool:transform`, `tool:after` re-fire. Built-in handlers (truncation, image-stripping, `<edit-outcomes>` merge) are pure on journaled inputs, so the substitution is replay-stable.
- `turn:before`, `turn:after`, `usage` re-fire. They read the journaled `TurnResult`, so emitted values match the original.
- `stream:text`, `stream:thinking` do **not** re-fire — those callbacks are part of the live stream, which doesn't replay. UIs should reconstruct transcripts from `session.turns` (`eventsFromTurns` in `zidane/chat`) rather than relying on hook re-emission. The chat layer already does this.
- Consumer hooks that hit external services (logging APIs, metrics endpoints) should wrap those side effects in `ctx.run` themselves — otherwise they'll fire twice across the original + replay.

### Per-key serialization

Restate gives one concurrent invocation per virtual-object key. With sessions bound 1:1 to `ctx.key`, concurrent users hit different keys and never race; the runtime serializes invocations on the same key. No application-level locks, no `BEGIN IMMEDIATE` dance.

## Wrapper details

### `restateClock`

`AgentClock` backed by `ctx.date.now()` + `ctx.rand.uuidv4()`. Pass it via `AgentRunOptions.clock` (or `createAgent({ clock })`) so journaled metadata — timestamps, run IDs, and fallback turn IDs — stays byte-identical across replays.

`ctx.date.now()` is async (the SDK routes it through `ctx.run` internally; replay returns the journaled timestamp). `ctx.rand.uuidv4()` is sync (the SDK seeds an in-memory RNG from the invocation id, itself replay-stable).

**Gotcha**: when you pair a `restateSessionStore` with a session, the store's own `generateTurnId` runs ahead of `clock.randomUUID()` (the loop short-circuits via `session?.generateTurnId() ?? clock.randomUUID()`). The store derives deterministic IDs from the persisted turn count (`turn_1`, `turn_2`, …) so turn IDs stay stable across replay and across later invocations on the same object key. If you wire a custom store, keep turn ID generation deterministic from persisted state or let the Restate-backed clock provide the fallback UUIDs.

### `restateProvider`

Wraps `Provider.stream` in `ctx.run`. One journal entry per assistant turn carrying the full `TurnResult`. All other `Provider` methods (`formatTools`, `userMessage`, `toolResultsMessage`, `classifyError`) pass through unchanged — they're pure over already-deterministic inputs.

Default `runOptions`: `{ maxRetryAttempts: 3 }`. Provider transients (rate limits, 5xx, connection resets) get retried inside the journal entry. Once the cap is reached the failure becomes terminal and surfaces through Zidane's normal `AgentProviderError` path.

`streamCallbacks: 'suppress'` disables live `onText` / `onThinking` / server-tool callbacks inside the durable step. Use this for strict replay-safe deployments where UIs reconstruct from persisted `session.turns`; the default remains `'live'` for compatibility.

### `restateTool` / `wrapAgentTools`

Wraps `ToolDef.execute` in `ctx.run`. Once Restate persists the result, replay returns the journaled value without re-executing the tool. If the process dies before that journal entry is durable, the latest in-flight tool can run again; tools that hit the network, write to disk, or poke external systems still need application-level idempotency keys.

Default `runOptions`: `{ maxRetryAttempts: 1 }`. Tool errors are typically deterministic (validation failures, missing files); blind retries waste budget. Override per-tool for network-heavy custom tools that benefit from SDK-level retries.

`isConcurrencySafe` is preserved. `restateBehavior()` defaults `maxConcurrentTools` to `1` so strict Restate mode is deterministic first. If you want parallel tools, opt into the Restate scheduler below instead of relying on native promise fan-out.

`wrapAgentTools(tools, ctx, { exclude })` is a batch helper. Pass-through for `exclude`-listed names (fully-deterministic local helpers where journaling is overhead). By default it hides and rejects the built-in `shell` tool's `run_in_background` mode because host-local background processes are not recoverable by Restate. Two opt-outs:

- `{ detachedTasks: 'durable' }` — declare the execution context's `ContextCapabilities.detachedTasks` tier. A `'durable'` context (remote runner whose tasks survive worker crashes) keeps background mode automatically.
- `{ disableShellBackground: false }` — explicit boolean override; always wins over the capability.

#### `durableStart` — crash-safe long tools (re-attach instead of re-run)

The default wrapping journals only the *completed* result, so a crash mid-execute re-runs the tool on replay — unacceptable for builds, migrations, deploys. `restateTool`'s `durableStart` option splits the execution into two journal entries:

```ts
restateTool(deployTool, ctx, {
  durableStart: {
    // Journaled BEFORE the body — kick off the work, return a re-attach token.
    start: async (input, toolCtx) => startDeploy(input),          // → { jobId }
    // On replay, receives the SAME journaled token — re-attach, don't re-run.
    attach: async (token, input, toolCtx) => awaitDeploy(token.jobId),
  },
})
```

A crash after `start` journals replays into the wait phase with the recorded token. The wrapped tool deliberately does not advertise `durableExecute` (the two-phase composition is native-async); the batch scheduler runs it sequentially, which is the right shape for long re-attachable work.

**`attach` holds the wait inside one `ctx.run` entry** — fine for waits bounded well below the server's inactivity timeout, but a long busy-poll in there starves the journal: the inactivity timeout fires, the abort timeout kills the attempt, and each retry re-runs `attach` with a fresh deadline. The invocation can never complete. Keep `attach` only as the degraded mode for hosts without a push seam.

**`wait` is the awakeable-based alternative for long-running work.** Hosts that can push completion (e.g. a runner firing `task:exit` → resumer → `ctx.resolveAwakeable`) configure it instead of `attach` (it wins when both are set):

```ts
restateTool(shellTool, ctx, {
  durableStart: {
    start: async input => startBackgroundTask(input),             // → { taskId }
    wait: {
      // Journaled — hand the awakeable id to the host as the exit resumer.
      register: (token, awakeableId) => registerExitResumer(token.taskId, awakeableId),
      // The model's own timeout becomes the durable park deadline.
      timeoutMs: input => input.timeout as number,
      promises: RestatePromise,                                   // durable race combinator
      // Journaled — synthesize the foreground-shaped result. `null` = timeout:
      // kill the job and shape the timeout result here.
      result: async (token, resumed) =>
        resumed ? formatExit(token.taskId, resumed) : killAndFormatTimeout(token.taskId),
    },
  },
})
```

The orchestration follows `restateAwakeableTool`'s journal discipline, keyed by the same entry naming: `register` runs in a journaled entry (`…-register`, exactly once across replays, marker invariant included), the park happens **outside** any `ctx.run` so the invocation suspends cleanly (no held worker, immune to inactivity/abort timeouts), and `result` runs in the final journaled result entry. With `timeoutMs` the park is an `awakeableWithTimeout` race against a durable `ctx.sleep` — replay-stable, unlike a `Date.now()` deadline that resets per attempt.

The optional `onAbort(token, input, toolCtx)` is the abort seam: when the per-call signal fires before the wait settles (run abort, `cancelTool`, `destroy()` mid-run), it runs once, best-effort, so the started job doesn't keep running with nobody attached. In `wait` mode the abort also unblocks the park (the call rejects instead of waiting on a resumer nobody will consume). It is NOT called on process death — that's not termination; the journaled token re-attaches on replay. It runs outside the journal (an aborting invocation can't reliably open new `ctx.run` entries); journal host-side cleanup yourself if it must be durable.

### `restateAwakeableTool`

Builds a `ToolDef` that parks on a Restate awakeable until an external resumer calls `ctx.resolveAwakeable(id, value)` — the canonical shape for `ask_user` / `request_review` tools inside durable handlers.

```ts
const requestReview = restateAwakeableTool<{ approved: boolean }>({
  name: 'request_review',
  description: 'Ask a human to review before continuing.',
  inputSchema: { type: 'object', properties: { summary: { type: 'string' } }, required: ['summary'] },
  ctx,
  notify: ({ awakeableId, input }) => publishToReviewQueue(awakeableId, input),
  toResult: value => (value.approved ? 'approved' : 'rejected'),
})
```

Replay contract: the `notify` broadcast runs inside `ctx.run`, so it fires exactly once across replays; the journaled marker records the awakeable id and the tool refuses to park if a replay mints a different id (journal drift). Do not additionally wrap the returned tool in `restateTool` — the suspension point must not nest inside a journaled closure.

### `restateSessionStore`

A `SessionStore` backed by the bound virtual object's K/V state. Three slots per session — `session-data` (header), `session-turns` (history), `session-runs` (run records).

**When to use it.** Pick this store when **external** consumers (a TUI subscribed to the runtime, a web GUI, support tooling) need to read a session without re-entering the agent handler. Virtual-object state is exposed via the SDK's read APIs and the Restate UI — same data, different access path.

**When to skip it.** If you only care about durable execution within one invocation, an in-memory store is enough: the journaled `ctx.run` entries already replay the loop to byte-identical state after a crash. Turns rematerialize from the journal on every attempt.

Limitations baked into the contract:
- `list()` returns `[]` — virtual objects don't expose cross-key enumeration. Maintain a separate index handler if discovery matters.
- `delete()` only clears the current object's keys (no cross-key fan-out).
- Single-tenant per virtual-object key: every method ignores the `sessionId` argument because the object is already keyed. Calling `appendTurns('other-id', …)` writes to the CURRENT object. Bind one virtual-object key per session and the contract holds.

### Restate-native parallel tools

```ts
import { RestatePromise } from '@restatedev/restate-sdk'
import { restateBehavior, restateToolBatchExecutor } from 'zidane/restate'

createAgent({
  tools: wrapAgentTools(tools, ctx),
  behavior: restateBehavior({
    maxConcurrentTools: 4,
    toolBatchExecutor: restateToolBatchExecutor({ promises: RestatePromise }),
  }),
})
```

The scheduler starts concurrency-safe, Restate-wrapped tools in submission order, joins each chunk through `RestatePromise.all()`, and emits tool results back to the model in original call order. Unsafe tools remain barriers. If a safe tool is not wrapped with `restateTool()` / `wrapAgentTools()`, Restate mode runs that chunk sequentially instead of falling back to native promise parallelism. This mode is opt-in so the TUI/headless harness keeps its native sliding-window scheduler.

## Recommended `AgentBehavior` for Restate deployments

```ts
createAgent({
  // ... wrappers as above
  behavior: restateBehavior({
    maxCostUsd: 5,
    maxTotalTokens: 2_000_000,
  }),
})
```

- **`maxConcurrentTools: 1`** — strict Restate default. Enable parallelism explicitly with `restateToolBatchExecutor`.
- **`disableBackgroundTasks: 'non-durable'`** — capability-aware: background tasks (`shell` with `run_in_background: true`) stay enabled only when the execution context declares `capabilities.detachedTasks: 'durable'` (tasks live on a remote runner and survive worker crashes). For host-local `ProcessContext` this is equivalent to the old blanket `true` — those processes aren't journaled and don't survive process death.
- **`cache: true`** (default) — prompt-cache breakpoints reward stable system/tool prefixes. Cuts cold-start cost on retries when the cache is still warm.
- **`shouldRethrowToolError`** — Restate terminal/suspension control errors escape the tool-result path.
- **`maxCostUsd` / `maxTotalTokens`** — server-side budget circuit breaker. Throws `AgentBudgetExceededError` and finalizes the session run as `aborted`. Especially useful here because durable replay + retry means a runaway loop is a runaway *bill*; a soft cap stops it cleanly. See ARCHITECTURE.md → run-level budget circuit breaker.

MCP servers re-bootstrap on every handler invocation (`warmup()` is lazy + idempotent); their tool *results* are journaled by `restateTool` so the model sees the same outputs on replay. Hosts that journal bootstrap outcomes should hook the deterministic `mcp:bootstrap:settled` aggregate (one name-sorted event per batch) instead of the per-server `mcp:bootstrap:end` stream, which fires in network-completion order.

Host-only configuration rides in `behavior.extensions` — a namespaced bag the framework never reads but threads through behavior resolution onto `ToolContext.behavior.extensions`. Keep custom keys there instead of patching `AgentBehavior` with unknown top-level fields.

## Long-running background jobs (durable contexts)

A remote execution context whose `capabilities.detachedTasks` is `'durable'` can run `shell({ run_in_background: true })` jobs that outlive worker crashes. Three seams make this journal-safe:

1. **Pull-based exit reconcile.** The context does NOT need to call `execBackground`'s `onExit` (it's optional — a remote context has no legal way to push from a timer into a journal). At the start of every `agent.run()` the loop sweeps `listBackground` for terminated tasks the model hasn't seen and merges them with any push-fed exits. `TaskEntry.endedAt` makes `durationMs` sweep-time-independent.
2. **`background:reconcile` hook.** Fires on the merged exit list inside the `run()` await chain — replace `ctx.exits` with a journaled copy to make the rendered `<task-notification>` blocks replay-stable:

   ```ts
   agent.hooks.hook('background:reconcile', async (hookCtx) => {
     hookCtx.exits = await restateCtx.run('background-reconcile', () => hookCtx.exits)
   })
   ```

3. **`waitBackground` / `wait_task`.** When the model genuinely needs a task's outcome before continuing, the `wait_task` tool delegates to `ExecutionContext.waitBackground` — an injectable seam. In-process contexts park on their own `onExit` machinery; durable hosts implement it as an awakeable park (runner fires task-exit over their bridge → `resolveAwakeable`), so a crashed worker re-attaches to the same wait on replay. `wrapAgentTools({ detachedTasks: 'durable' })` automatically leaves `wait_task` unwrapped — a Restate suspension point must not nest inside a journaled `ctx.run` closure (same rule as `restateAwakeableTool`). Hosts whose wait is journal-safe (e.g. a bridge RPC poll) can re-wrap it explicitly via `restateTool`.

   The durable deadline is packaged for you — `RestateContextLike` carries `sleep(millis)` and `awakeableWithTimeout` does the race:

   ```ts
   import { RestatePromise } from '@restatedev/restate-sdk'
   import { awakeableWithTimeout } from 'zidane/restate'

   async function waitBackground(handle, taskId, opts) {
     const { id, promise } = awakeableWithTimeout<TaskExitInfo>(ctx, { timeoutMs: opts?.timeoutMs, promises: RestatePromise })
     await registerExitResumer(taskId, id) // runner → ctx.resolveAwakeable(id, exitInfo)
     return promise                        // TaskExitInfo, or null on timeout
   }
   ```
4. **Cross-invocation dedupe is history-derived.** The agent's in-memory notified-task latch dies with the handler invocation, but the persisted session turns carry every acknowledgment (`<task-notification>` blocks, `shell_kill` calls, successful `wait_task` results, post-exit `read_file`s of the output path). The reconcile sweep re-derives the latch from the **full** `session.turns` history — subagent-run acks included — so a fresh agent on a resumed session (the normal Restate shape) never re-notifies an exit a prior invocation already delivered. This survives the built-in compaction: `compact-summary` markers elide turns at the wire level only, persisted turns are append-only by contract (the same assumption lazy-disclosure replay and read-state hydration make). Read-acks cross two clock domains (host `AgentClock` vs runner wall clock), so they apply only when the read landed ≥ 5 s after the exit — within that window the agent fails open (one redundant notification, never false suppression). Only a host that rewrites `session.turns` destructively forfeits the dedupe; remote registries can prune terminated entries after delivery as a belt-and-braces measure there.

Guardrails for unattended jobs (any context): `behavior.backgroundOutputCap` caps each task's log file without killing the process (a `<output-truncated bytes-dropped="N"/>` marker is appended at settle), and `behavior.backgroundStallWatchdogMs` queues a one-shot `<task-stall>` notification when a task goes quiet — typically a process stuck at an interactive `(y/n)` prompt that the model should `shell_kill` and re-run non-interactively.

### Skills in worker/runner deployments

If the agent loop runs on a Restate worker but the skill files live on a separate runner (the `skills_read` / `skills_run_script` tools route file access through `ctx.execution`), pre-resolve the catalog on the worker and pass it via `SkillsConfig.catalog` instead of `scan` / `write`. The default disk discovery is bypassed — `parseSkillFile` is never called — so the worker never tries to `readFileSync` SKILL.md files that don't live on its disk.

```ts
createAgent({
  // … wrappers as above
  skills: {
    skipDefaultPaths: true,           // worker has no skill dirs of its own
    catalog: hostProvidedCatalog,     // pre-resolved by the worker bootstrap
  },
})
```

`baseDir` / `location` on each catalog entry must point to paths resolvable on the runner (where `skills_read` / `skills_run_script` ultimately execute). Catalog entries seed resolution first and shadow disk-discovered skills on name collision — useful if a project still wants to layer `.zidane/skills/*` on top of a host-provided catalog.

## Failure modes

| Scenario | What happens |
|---|---|
| Crash between turn N and N+1 | Re-enter the handler. Journal replays turns 1..N from cache. Turn N+1 runs live. |
| Crash mid-tool-call | Re-enter. Journal replays prior turns + already-completed tools. The in-flight tool re-executes (Restate didn't see it complete). Tools that should not be re-run on retry need application-level idempotency (e.g. a `requestId` in the API call). |
| Provider rate limit / 5xx | `restateProvider` wraps `Provider.stream` in `ctx.run` and applies its `runOptions` (default `{ maxRetryAttempts: 3 }`) to that durable side effect. The agent loop's own provider retry policy can still retry failed turns before the error escapes the loop; each retry calls the wrapped provider again and therefore gets its own durable side-effect boundary. Beyond the configured retries, the failure surfaces as `AgentProviderError`. |
| Tool throws | `restateTool`'s default `maxRetryAttempts: 1` — no blind retry. The error is journaled; replay returns the same error. |
| Restate runtime down | Handler invocations queue / fail at the SDK transport layer; Zidane never sees them. Recovery is operational, not in-app. |

## Tests

`test/restate.test.ts` covers each wrapper individually + end-to-end agent.run() with the clock + provider in place. A fake `RestateContextLike` records `ctx.run` calls and can be pre-seeded with a journal to verify replay semantics (inner fn not invoked, cached value returned verbatim).

`test/start.test.ts` covers the local `bun start --restate` runner helpers: option parsing, deployment registration, ingress invocation timeout wiring, readiness/service probing, runtime-state cleanup, cached completion, changed-input rejection, and `status` / `history` handlers.

The fake structural types live in `src/restate/types.ts` — the adapter typechecks **without** `@restatedev/restate-sdk` installed, useful in monorepos that vend `zidane` upstream of Restate. Consumers wiring the real SDK pass `restate.Context` / `restate.ObjectContext` directly; they're structurally assignable to the in-house shapes.

## Related

- `examples/restate/agent.ts` — runnable end-to-end virtual object.
- `docs/ARCHITECTURE.md` — agent lifecycle, hook ordering, run-level budget breaker.
- `docs/SKILL.md` — `createAgent`, `Provider`, `ToolDef`, `SessionStore` interfaces this adapter wraps.
- [Restate docs](https://docs.restate.dev/) — runtime, SDK, deployment model.
