Resume and state
Unstable task IDs break resume
The runtime keys completed work by taskid: 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.
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 likeartifacts/.../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: declaresideEffect: 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
AClaudeCodeAgent 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 likegit checkout over uncommitted work.
revertrestores 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-vcschecks 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 (defaultmain), 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.
<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.
Read next
- How It Works: the execution model these rules come from.
- Recipes: caching, hot reload, and VCS revert in context.