Skip to main content
Smithers is durable by design: a run replays from persisted state, not memory, and that’s the source of most of the sharp edges below.

Resume and state

Unstable task IDs break resume

The runtime keys completed work by task id: a changed id looks like a new task, a disappeared one drops from the plan. Derive ids from data, never a loop index or timestamp.
Same rule as React keys; see How It Works.

Input is immutable after the first run

A run’s --input is persisted at start; resuming with different input errors rather than overriding it silently. Start a new run instead.

Code changes block resume, they do not merge

A workflow source change is a different workflow: resume checks the source hash, so editing the file blocks it. Start a new run, or hot-reload (up --hot) a still-running one: edits apply to newly scheduled tasks while in-flight tasks finish on their original code. See Recipes.

useState is not durable

React state resets every render, i.e. every frame here. Anything that must survive a crash belongs in a Task output read back through ctx, not component state.

Evidence files must be run-scoped or cleaned

If a workflow writes verdict files like artifacts/.../verify.json and reads them back for a done-check, put ctx.runId in the path, or delete the evidence directory at run start: a prior run’s verify.json must never satisfy a fresh run’s done-check.

Caching

Do not cache side-effecting tasks

cache is for pure, expensive-to-recompute work: caching a deploy, email, or mutation means it silently skips on a hit. The key is cache.by(ctx) plus cache.version plus the output schema signature, so a schema change auto-invalidates it, and a stale cached row fails validation, missing safely. See How It Works.

Side effects and retries

Mark side-effecting tools and key them

Tasks retry, and a retried tool call can fire twice: declare sideEffect: true on a custom tool and pass ctx.idempotencyKey through to the downstream system so a retry is a no-op, not a second charge. The key stays stable across retries and resumes for the same task iteration.

Decide in one task, act in another

Marking and keying the tool stops double-charging on retry but doesn’t make the charge reviewable: an agent that decides to send money and sends it can’t have that decision inspected or rerun without risking a second charge. Have the deciding task return a typed decision ({ shouldPay, amount, reason }) and put the payout in its own downstream task behind an <Approval>: the decision stays reversible and replayable, the act isolated, gated, and keyed. See Sequence for reversibility for the full pattern.

Tools and sandbox

Agents get only the tools you grant

The five built-in tools (read, write, edit, grep, bash) are sandboxed to rootDir: symlinks, network, and long-running calls are denied by default (--allow-network opens bash to the network). Grant least privilege per task: a reviewer gets read/grep, an implementer gets write/edit/bash, and an agent with no tools can’t touch the filesystem at all. See How It Works.

Autonomous and detached runs

Agents need permission-bypass flags or a detached run hangs

A ClaudeCodeAgent or CodexAgent built with just a model prompts for permission before editing files: fine interactively, but in a detached run (up -d) nothing clicks to approve it, so the task stalls until its heartbeat timeout. Construct autonomous-run agents with the bypass flags:
.smithers/agents.ts’s named pools are intentionally bypass-free for interactive use; don’t reach for them in a detached workflow without adding the flags.

--hot is for a live process, not for resuming after edits

Hot reload (up --hot) applies workflow and prompt edits on the next render frame of a live process; it can’t retroactively unblock a suspended run, like one paused at an approval gate. Resume still rejects an edited file, now with RESUME_METADATA_MISMATCH; changing task IDs or the module graph always needs a fresh run. To iterate on a detached run stuck at a gate, keep a live up --hot process attached, or start a new run and gate finished phases off with an input flag to skip them.

Never pin cwd on an agent you use inside <Worktree>

A pinned cwd takes precedence over the per-task root: the agent reads, writes, and commits to the launch directory’s base branch instead of its worktree. Leave cwd unset and let <Worktree> (or the launch root) control the directory; the engine logs a “pinned cwd overrides Worktree” warning when you get this wrong.

Time travel and VCS

Revert and VCS-restoring replay change your working tree

Both rewrite filesystem state; treat them like git checkout over uncommitted work.
  • revert restores the workspace to a previous attempt’s filesystem state, discarding graph snapshots recorded after it; it touches files only, landing as a new change atop the current working copy. See Revert to Attempt.
  • replay --restore-vcs checks out the jj revision the snapshot was taken at, so re-execution sees the original run’s source.

revert requires jj

Smithers prefers .jj over .git: pure Git repos run fine but can’t use revert, since there’s no per-attempt change to restore. Install jj for attempt-level revert. See VCS.

Worktree runs auto-rebase on resume

On resume, Smithers rebases a worktree run onto the base branch (default main), continuing even if the rebase fails: expect the branch to move.

<Worktree baseBranch> must be a stable branch, not the current change

baseBranch defaults to main and should name a committed branch or described commit, not the current working-copy commit (e.g. jj log -r @ output): a jj @ snapshots uncommitted launcher changes into an undescribed commit, so the worktree inherits that dirty tree, with a branch based on a commit jj refuses to push (“Won’t push commit … since it has no description”). Omit baseBranch for main, or name an explicit clean branch.

Outputs

ctx.outputMaybe is undefined until the task runs

Reading a downstream output before its task completes returns undefined, not a default: guard it so a not-yet-run task doesn’t crash the render.
When a task only waits on one upstream output, <Task deps={{ analyze: outputs.analysis }}> with a (deps) => ... callback is more direct: it defers until the row exists and hands it straight to children, no guard variable needed.

An output schema is a shared pool; do not write stray rows into one you filter

ctx.outputs.<schema> pools every row written for that schema, across every node and loop iteration. Fan out, then filter on a discriminator field for “the latest row for item X”: any other task writing to that schema lands in the same pool, so a setup or sentinel task reusing a filtered schema (say a placeholder validation row from a prepare step) can be mistaken for real per-item state. Give unrelated streams their own schema, and match on a discriminator set on every row.
  • How It Works: the execution model these rules come from.
  • Recipes: caching, hot reload, and VCS revert in context.