Skip to main content
These five rules keep biting workflow-authoring agents at runtime, hours after writing a workflow, instead of surfacing at the first bunx smthrs graph. Each is documented elsewhere in full; this page collects all five in one read before you write JSX, instead of hitting them one at a time across failed runs. See the postmortem this page was written to close.

1. Output schemas cannot reuse the reserved key columns

Output tables get a fixed run_id / node_id / iteration key prefix; input tables get run_id only. Why: these columns correlate a row back to the run, node, and loop iteration that produced it, so a schema field named runId, nodeId, or iteration collides with the reserved one.
A collision throws INVALID_INPUT at construction (schema build time), not at run time; see zodToTable.

2. No nested loops, use the queue-based backfill pattern instead

A <Loop>/<Ralph> as the literal immediate JSX child of another throws NESTED_LOOP at graph-extraction time (before any agent runs). Why: with nothing between them, there’s no clear “whose iteration is this” for the inner loop, the same gap Effect combinator builder’s G.loop rejects unconditionally.
Fix, per the error message: route inner work through a queue like <MergeQueue> and re-enter via the outer loop’s next iteration. Narrower than it sounds: a <Loop> reached through a <Sequence>/<Parallel>/<Worktree> wrapper (not the literal immediate child) is a different, genuinely-supported shape. Each <Parallel>-forked lane (one per array item or isolated <Worktree>) may run its own bounded correction loop, scoped to the outer loop’s iteration: the sanctioned “per-item lanes, each with a correction loop” pattern, exactly what close-issues.tsx and studio-parity-swarm.tsx do (outer discover step, then a <Parallel> of per-item <Worktree> lanes, each with its own correction <Loop>), regression-guarded by nested-loop-runtime.test.jsx (issue #117): the inner loop’s state and cache reset correctly per outer iteration. Avoid only a second loop governing the same lane with nothing forking between them.

3. ctx.latest vs outputMaybe({ nodeId, iteration }) for loop bindings

Inside a <Loop>’s until, read the most recent iteration with ctx.latest:
Why: ctx.outputMaybe(schema, { nodeId }) with no explicit iteration resolves the current render iteration, which equals the loop’s own iteration only for a single, non-nested loop, and is 0 when several coexist (siblings, or one nested under a <Parallel>/<Worktree> fork per rule 2). An outputMaybe-based until built on that ambient iteration can silently never advance: it spins to maxIterations and returns the last result, with no error explaining why. To use outputMaybe directly, pass the loop’s own scoped node id and iteration: ctx.outputMaybe(schema, { nodeId: "review", iteration: N }).

4. Workflow tests must render the real graph via renderWorkflow, not a hand-built one

Authoring a workflow and writing its testing-library test are one indivisible change. A workflow delivered without that test is unfinished work, not a follow-up opportunity. Import the actual workflow module and drive it through renderWorkflow (smthrs/testing):
Why: a test that hand-builds its own plan/graph object (bypassing extractGraph/buildPlanTree) can pass while the real workflow file has a typo, a NESTED_LOOP, or a reserved-column collision: it validates a stand-in that merely resembles the workflow. renderWorkflow exercises the same extraction path bunx smthrs graph/bunx smthrs up do, so a graph-level defect fails the test the same way it fails a real run. Assert real graph behavior: exact node ids and dependency order, representative valid and malformed values through each task’s outputSchema, and branch or loop behavior under the outputs that drive it. A truthiness smoke test such as expect(graph).toBeTruthy() does not meet this rule.

5. New .smithers test files must be registered in .smithers/package.json

.smithers/package.json’s test script is an explicit, space-separated list of test file paths, not a glob. Why: pack tests run outside the normal per-package bun test tests convention (they share fixtures/agents across many workflow files), so there’s no directory-wide default to fall back on. An unregistered test file is silently never run by pnpm test or CI: it can sit green-looking in the repo indefinitely while contributing zero coverage. node scripts/check-smithers-test-script.mjs (part of the root pnpm test gate) catches this; run it after adding a test file, or just add the path yourself.

See also