Skip to main content
Smithers is durable by design, and most of its sharp edges trace back to one fact: a run is replayed from persisted state, not from memory. The mistakes below are the ones people hit first. Each links to the canonical reference for the full story.

Resume and state

Unstable task IDs break resume

The runtime keys completed work by task id. A changed id looks like a brand new task, and an id that disappears is dropped from the plan. Derive ids from data, never from a loop index or a timestamp.
It is the same rule as React keys. See How It Works.

Input is immutable after the first run

A run’s --input is persisted when it starts. Resuming with different input is an error, not a silent override. If you need different input, start a new run.

Code changes block resume, they do not merge

A workflow source change is a different workflow. Resume validates the source hash of the original run, so editing the file and then resuming is blocked. Start a new run instead. To change a workflow that is still running, use hot reload (up --hot): edits apply to newly scheduled tasks while in-flight tasks finish on their original code. See Recipes.

useState is not durable

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

Evidence files must be run-scoped or cleaned

If a workflow writes verdict files such as artifacts/.../verify.json and later reads them to decide whether work is done, include ctx.runId in the evidence path or delete the evidence directory at run start. A prior run’s verify.json must never be able to satisfy a fresh run’s done-check.

Caching

Do not cache side-effecting tasks

cache is for pure work that is expensive to recompute. Caching a deploy, an email, or a mutation means it silently does not run on a cache hit. The cache key is cache.by(ctx) plus cache.version plus the output schema signature, so a schema change invalidates the cache automatically and a stale cached row fails validation and misses safely. See How It Works.

Side effects and retries

Mark side-effecting tools and key them

Tasks retry, and a retried agent loop can call a tool again. A custom tool that writes to the world should declare sideEffect: true and pass ctx.idempotencyKey through to the downstream system so a retry is a no-op rather than a second charge. ctx.idempotencyKey is stable across retries and resumes for the same task iteration.

Decide in one task, act in another

Marking and keying the tool keeps a retry from double-charging. It does not make the charge reviewable. An agent that both decides to send money and sends it cannot have its decision inspected or rerun without risking a second charge. Have the deciding task return a typed decision ({ shouldPay, amount, reason }), then put the actual payout in its own downstream task behind an <Approval>. The decision is reversible and replayable; the act is 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 and grep, an implementer gets write, edit, and bash, and an agent with no tools cannot 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 constructed with just a model will prompt for permission before it edits files. In an interactive session you click through; in a detached run (up -d) there is no one to click, so the task stalls until its heartbeat timeout. For autonomous runs construct agents with the bypass flags:
The named pools in .smithers/agents.ts are intentionally bypass-free for interactive use, so do not 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 process that is still running. It does not let you edit a workflow and then --resume a previously suspended run (for example a detached run paused at an approval gate): resume validates the source hash and rejects the changed file with RESUME_METADATA_MISMATCH, with or without --hot. Changing task IDs or the module graph always requires a fresh run. To iterate on a detached run that suspends at gates, either keep a live up --hot process attached, or start a new run and gate the already-finished phases off with an input flag so they skip.

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

A pinned cwd takes precedence over the per-task root, so the agent reads and writes the launch directory and commits to the 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

These rewrite filesystem state, so treat them the way you would treat git checkout over uncommitted work.
  • revert restores the workspace to a previous attempt’s filesystem state and discards graph snapshots recorded after that attempt. It restores files only and lands them as a new change on top of 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 same source as the original run.

revert requires jj

Smithers prefers .jj over .git. Pure Git repos run fine but cannot use revert, because there is no per-attempt change to restore. Install jj if you want attempt-level revert. See VCS.

Worktree runs auto-rebase on resume

On resume of a worktree run, Smithers rebases onto the base branch (default main) and continues 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 a described commit. Do not pass the current working-copy commit (for example jj log -r @ output): a jj @ snapshots the launcher’s uncommitted changes into an undescribed commit, so the worktree inherits that dirty tree and its branch ends up based on a commit jj refuses to push (“Won’t push commit … since it has no description”). Omit baseBranch to use main, or pass an explicit clean branch name.

Outputs

ctx.outputMaybe is undefined until the task runs

Reading a downstream output before its task has completed returns undefined, not a default. Guard it so a not-yet-run task does not crash the render.
When a task’s only job is waiting on one upstream output, <Task deps={{ analyze: outputs.analysis }}> with a (deps) => ... children callback is the more direct form: 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> is every row written for that schema by every node and every loop iteration. When you fan out and later read “the latest row for item X” by filtering on a discriminator field, any other task that writes a row to the same schema lands in that pool too. A setup or sentinel task that reuses a filtered schema (for example writing a placeholder validation row from a prepare step) can be picked up as if it were real per-item state. Give unrelated streams their own schema, and always match on a discriminator you set on every row.
  • How It Works: the execution model these rules come from.
  • Recipes: caching, hot reload, and VCS revert in context.