MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-3422

(motir-core) The `step` SHIM — memoized `step.run` and `step.sleep` as a durable yield-and-reschedule, so 84 call sites do not change

Done
Description

⚠️ AMENDMENT — the step.run ENUMERATION in this card is WRONG (motir run MOTIR-3414, 2026-08-23)

Re-measured before the first line of work, on the ref rather than on a working tree, per the run-time rule that a card counting a population is re-measured against the ref it names (or origin/main when it names none). Branch base: origin/main@165f1485.

$ git grep -c 'step\.run(' origin/main -- '*.ts' '*.tsx'
  60 occurrences across 27 files
$ git grep -c 'step\.run(' origin/main -- '*.ts' '*.tsx' ':!tests/*'
  58 occurrences across 26 files

"84 call sites across 37 files" is in fact 58 across 26 (60 across 27 if the one test file is counted). Read every "84" below as 58, and every "37 files" as 26.

The second limb is falsified outright, and it is the one that was load-bearing. The story's explanationMd argued the shim earns its cost because the call sites live "including services well outside lib/jobs/". They do not:

$ git grep -l 'step\.run(' origin/main -- '*.ts' '*.tsx' ':!tests/*'
  25 of 26 files are under lib/jobs/ ;
  the 26th is scripts/plan-seed/data/story-1.6.ts — seed FIXTURE DATA, not a service

No service file outside lib/jobs/ calls step.run. The blast radius of rewriting the call sites is lib/jobs/ and nothing else — which is where the seam already is.

What changes and what does not. The DELIVERABLE is untouched and the CONCLUSION stands: 58 call sites are still 58 that should not be rewritten, and a shim that keeps the existing job definitions compiling unchanged is still the right shape. What weakens is the COST argument — the shim is bought for compatibility, risk and reversibility, not for a blast radius that reaches outside the jobs layer. Recorded rather than quietly deleted, because an amendment with evidence and a silently-dropped clause look identical in the diff and are opposite in kind.

Where 37 probably came from: lib/jobs/registry.ts exports 37 job functions across 24 definition files. Two real populations, correctly observed, fused into a sentence about a third.

Planning bug: MOTIR-3428. Nothing else in this card is changed by the amendment — no deliverable, edge, repo pin, estimate or acceptance criterion.


The compatibility layer that makes this migration affordable. It reimplements the part of Inngest's step contract our code actually uses, over job_step, so that 84 step.run call sites across 37 files change by zero characters.

step.run(id, fn)

Look up (run_id, id) in job_step. If a row exists, return its stored result without executing. Otherwise execute fn, persist the result, return it. That is the entire contract our jobs rely on: a step already done is not redone when the run is retried or resumed.

Two details that are easy to get wrong:

  • The stored result crosses a JSON boundary, exactly as it does today — a Date returned from a step comes back as a string on replay. Production already behaves this way and tests already account for it; the shim must not accidentally be more faithful in-process than it is on resume, or a bug appears only after a restart.
  • A step that throws is not memoized, or a transient failure would be frozen permanently.

step.sleep(id, ms)

Persist a wake deadline, then throw a typed Yield the worker catches and turns into a re-enqueue with run_at = deadline. The run leaves memory entirely and resumes later, which is what lets a 30-minute supervisor survive a deploy.

And this is the piece that has to be durable rather than convenient. An in-process await would be simpler and would work until the first deploy, at which point a sleeping supervisor vanishes and leaves a container running with nothing watching it.

Explicitly NOT implemented

waitForEvent. It has zero real call sites — the single grep hit in the tree is a comment inside codeGraphRefresh.ts quoting Inngest's documentation. Building it would be building for a consumer that does not exist; if a later card needs it, that card adds it.

Acceptance criteria

  • A run that fails after step 3 and is retried re-executes only steps 4 onward — asserted by a test that inspects which handlers actually ran, not by inspecting job_step rows.
  • A step that throws is not memoized: the retry re-executes it.
  • step.sleep survives a worker restart — the run resumes at the right step after the process is killed and started again. This is the card's load-bearing test and it must genuinely restart, not simulate.
  • A step's stored result round-trips through JSON exactly as production does today, and a test pins that rather than leaving it incidental.
  • The signature is unchanged: no existing step.run / step.sleep call site in lib/ is edited by this card. The diff proves it.
  • Unit and real-Postgres integration tests ship with the change.

Context refs

  • lib/jobs/indexFleetSteps.ts — the heaviest consumer, and the source of the memoization rules the shim must honour (step ids keyed by projectId and iteration, never by loop position)
  • lib/jobs/defineJob.ts — where ctx.step is handed to a handler
  • prisma/schema.prismajob_step from the schema card
  • tests/jobs/ — the existing job tests, several of which stub step.sleep by id and will exercise the new implementation