(motir-core) The `step` SHIM — memoized `step.run` and `step.sleep` as a durable yield-and-reschedule, so 84 call sites do not change
⚠️ AMENDMENT — the
step.runENUMERATION 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/mainwhen 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
explanationMdargued the shim earns its cost because the call sites live "including services well outsidelib/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 serviceNo service file outside
lib/jobs/callsstep.run. The blast radius of rewriting the call sites islib/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
37probably came from:lib/jobs/registry.tsexports 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
Datereturned 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_steprows. - A step that throws is not memoized: the retry re-executes it.
step.sleepsurvives 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.sleepcall site inlib/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 byprojectIdand iteration, never by loop position)lib/jobs/defineJob.ts— wherectx.stepis handed to a handlerprisma/schema.prisma—job_stepfrom the schema cardtests/jobs/— the existing job tests, several of which stubstep.sleepby id and will exercise the new implementation