modernx-admin — Automations: Wait/delay step
As of 2026-08-05, an automation can pause mid-run for a merchant-chosen
duration and then continue. This is the highest-risk deferred capability
called out by the flow-engine spec (docs/superpowers/specs/2026-07-24-automation-flow-engine-design.md,
§3) — it unlocks post-purchase sequences, win-back timing, and “escalate if
still unhandled after N hours”, none of which were expressible while every
automation ran to completion inside one graphile-worker job.
The Shopify gotcha, and the semantics we chose
Section titled “The Shopify gotcha, and the semantics we chose”Shopify Flow re-fetches the entity after a wait finishes, so something that qualified when the flow paused can un-qualify by the time it wakes (e.g. a cancelled order). This engine matches that deliberately:
- Fresh reload on wake, no status pin.
execute.ts’s first leg pinsentity.statustoevent.entityStatusso a backlogged status-changed run evaluates the transition it fired for.resume.tsdoes not re-apply that pin — a run that has slept for two days must see the entity’s status as it is now. Conditions after the wait therefore evaluate against current state, which is the entire point of “escalate if still unhandled”. - Automation edited or turned off while waiting → resume is cancelled,
not silently continued.
resume.tsguards onautomations.status === "active"andautomations.version === wait.automationVersion— activating an automation already bumpsversionand resetsactivatedAtelsewhere in the codebase, so this reuses that existing signal rather than adding a new one. Firing actions the merchant no longer authorised (because they paused or edited the flow) would be worse than skipping them. - Entity deleted from the warehouse by the time the wait resolves → the
run finishes
failedwith the same per-domainENTITY_NOT_FOUND_ERRORSmessageexecute.ts’s first leg uses.
Node: auto.control.wait
Section titled “Node: auto.control.wait”Defined in lib/automations/nodes/automation-pack.ts. Params are { duration: integer >= 1, unit: "minutes" | "hours" | "days" }
(WaitParamsSchema, defaultParams() → { duration: 1, unit: "hours" });
minutes exists specifically so the feature is hand-testable in under two
minutes. Handles are { in: true, out: ["out"] }.
It carries no entityDomain — a wait is domain-universal.
validate.ts only domain-checks a node that is a condition or that declares
entityDomain explicitly, so a wait sits legally under an order, cart,
customer, product, refund, shipment, invoice, webhook, or schedule trigger
with no change to the domain-compatibility check from
Automations engine entity model. This
is asserted by a dedicated test (a wait under the cart-abandoned trigger
raises no domain-mismatch issue), not assumed.
The 30-day cap is enforced two ways for two different reasons:
- As a
readyIssuesmessage (“A wait can be at most 30 days.”) rather than a schema bound, so a graph that already has an over-long wait still parses and gets a friendly repair message instead of “params are invalid”. - As a hard clamp (1 minute .. 30 days,
waitDurationMs/MAX_WAIT_MS) at the point a resume is actually scheduled.
A fourth NodeCategory, "wait", was added alongside the existing
trigger/condition/action — it’s neither an action (no real-world
effect, nothing to tally success/failure on) nor a condition (no branch).
validate.ts also gains a reachability check: a reachable wait node with no
outgoing edge raises "This wait leads nowhere.", the wait-specific sibling
of the existing “This condition leads nowhere.” check.
Interpreter: segmenting the plan at a wait
Section titled “Interpreter: segmenting the plan at a wait”interpreter.ts is split into planFrom(graph, startNodeId, registry, evaluate) (the walk itself, now parameterised on a start node) and
planSteps(graph, triggerType, registry, evaluate) (finds the trigger node,
delegates to planFrom) — planSteps’s existing behaviour, including
returning [] when no node of triggerType exists, is unchanged.
A wait node is collected into the plan (as PlannedWaitStep { kind: "wait", nodeId, nodeType, params }) at the same point actions are collected,
so step ordering is preserved — but its out edges are not followed when
it’s dequeued, unless it is the walk’s own start node. That’s the whole
mechanism resume relies on: resume.ts calls planFrom(graph, wait.nodeId, ...), and because the wait is now the start node, its downstream gets
planned. One consequence worth knowing: a branch that contains no wait still
plans and executes fully in the first leg, even if a sibling branch pauses —
only the waiting branch’s own downstream is deferred. Two sequential waits
yield only the first per leg; the second surfaces once the first leg’s
resume replans from where it left off.
Persistence: automation_run_wait (migration 0052)
Section titled “Persistence: automation_run_wait (migration 0052)”New table, appended to schema.ts after automationScanState without
touching automation_tasks, automation_hook_tokens, or
automation_scan_state:
| column | notes |
|---|---|
id |
uuid pk — also the resume job’s job_key |
tenant_id, run_id, automation_id |
FKs, cascade delete |
automation_version |
compared against the automation’s current version on wake |
node_id, node_type |
the wait node to resume from |
step_id |
FK to the automation_run_step row completed on wake |
event |
jsonb, the original TriggerEvent — resume gets entityId/triggerType/payload without new plumbing |
status |
pending | resumed | cancelled | failed |
resume_at, created_at, resolved_at |
timestamptz |
Indexes: (tenant_id, run_id) and (status, resume_at) for the overdue
sweep (see below). automation_run.status and automation_run_step.status
are plain text columns, so widening them to add waiting/cancelled
(run) and waiting (step) needed no enum migration — finishRun’s and
recordStep’s status unions just gained the new values. The wait’s own step
row is inserted as waiting when the run pauses and updated to success on
wake, so run history shows the pause rather than a gap.
reapStuckRuns only sweeps status = 'running', so a run parked in
waiting for up to 30 days is never touched by it — this is asserted by a
test, not left to inspection. A separate mechanism (below) handles a wait
that never woke up.
Scheduling the resume
Section titled “Scheduling the resume”TASK_AUTOMATION_RESUME = "automation.resume" and ResumeEventSchema = { tenantId, waitId }.strict() (events.ts) — the dot in the task name is
fine here because this task is only ever enqueued via add_job, never
embedded in the crontab string that graphile-worker’s CRONTAB_COMMAND
parser rejects dots in (that restriction is why TASK_AUTOMATION_REAP and
TASK_AUTOMATION_SCAN are dot-free).
scheduleResume(db, {tenantId, waitId}, resumeAt) in enqueue.ts calls
graphile_worker.add_job with job_key = waitId, making re-scheduling
idempotent at the queue level. Unlike enqueueTriggerEvents, this throws
on failure rather than swallowing it — a silently dropped resume is a run
that hangs in waiting forever, which is worse than a graphile-worker retry
of the transaction that created the wait row. The wait-row insert and the
job insert happen in one transaction, so a wait row can never exist
without a corresponding scheduled job. claimWait (a conditional UPDATE ... WHERE status = 'pending' RETURNING, the same at-most-once shape as the
existing claimFired) makes redelivery idempotent at the app level: a
redelivered resume job finds the wait already claimed and is a no-op — no
duplicate actions, no duplicate step rows, no thrown error.
Scheduling is injected as ExecuteDeps.scheduleResumeImpl because pglite,
the unit-test database, has no graphile_worker schema.
The existing 5-minute reap cron also calls failOverdueWaits, which fails
any pending wait more than 60 minutes past its resume_at with “Scheduled
resume never ran.” — a backstop distinct from reapStuckRuns, for the case
where the resume job itself was lost rather than a run hanging mid-execution.
Pause path: shared execution core
Section titled “Pause path: shared execution core”executeSteps was previously a private function in execute.ts; resume
needs to call it too, so the reusable core — executeSteps, resolveDomain,
ENTITY_NOT_FOUND_ERRORS, ExecuteDeps — moved to a new
lib/automations/engine/execute-shared.ts. execute.ts re-exports
resolveDomain and ExecuteDeps so every existing importer (worker.ts,
execute.test.ts’s assertions on resolveDomain) still resolves from its
original path unchanged. This was a pure move — no first-leg behaviour
changed.
executeSteps gained a wait branch: on a PlannedWaitStep it records the
step row as waiting, creates the wait row and its delayed job in one
transaction (via deps.scheduleResumeImpl), marks the run waiting, and
stops planning further steps for that branch — while still executing any
already-planned steps of a parallel branch that has no wait. It does not
call finishRun: the run stays waiting until every pending wait for it
resolves.
Resume path
Section titled “Resume path”lib/automations/engine/resume.ts — resumeAutomationRun(db, event, deps):
claimWait(db, event.waitId).nullmeans an at-least-once redelivery already handled this wait — log and return, not an error.- Load the automation; if it’s missing, not
active, or itsversionno longer matcheswait.automationVersion, complete the wait step asskippedwith a plain-English reason (paused vs. edited-and-reactivated are worded differently) and finish the runcancelled. - Resolve the entity fresh, using exactly the same multi-domain path
execute.ts’s first leg uses:resolveDomain(wait.event.triggerType), thenisEntitylessDomain(domain)→buildEntitylessEntity(...)for an inbound-webhook or schedule trigger, otherwiseentityLoaders[domain]andENTITY_NOT_FOUND_ERRORS[domain]on a miss. Deliberately noentityStatuspin (see the semantics section above). An earlierif (loaded.domain !== "order") throwguard from a discarded prior attempt at this feature is gone — conditions/executors/interpolation are alreadyDomainEntity-wide since the multi-domain work landed. planFrom(automation.activeGraph, wait.nodeId, ...)and run the result through the sameexecuteStepsthe first leg uses — so a second wait re-pauses through the identical code path (step recordedwaiting, new wait row + job, run status back towaiting).- Complete the wait step
success, thensettleRun(db, wait.runId): if anypendingwaits remain for the run it stayswaiting; otherwise the run’s status is recomputed from every recorded step row across all legs (action-category steps only — wait/condition rows are excluded from the tally), withdurationMsmeasured from the run’s originalstartedAt, not from the resume.
Everything from step 2 onward is wrapped in a try/catch: a thrown error
completes the wait step failed and finishes the run failed, so an error
anywhere in resume can’t leave a run parked in waiting forever — the wait
is already claimed by that point, so rethrowing for a graphile-worker retry
would just retry nothing useful.
Surfaces
Section titled “Surfaces”- Canvas.
WaitNode.tsx(clock icon, zinc chrome — amber is reserved for conditions, blue for the design system elsewhere) renders asautoWait; a newTimingpalette group sits between Triggers and Conditions inNodePalette.tsx.summariseNoderenders"Wait 2 days"/"Wait 1 hour"(singular for 1). - Config.
WaitConfig.tsx— a duration number input and a unit select, wired intoNodeConfigPanel’sConfigForNodeswitch — plus static helper text: “When the wait finishes we look at the order again — if it no longer matches the conditions after this step, the rest of the flow won’t run.” - Run history.
waitingandcancelledbadges (bothzinc, inSTATUS_COLORS) and aresumes <relative time>line driven by a newresumeAtfield onAutomationRunRow(the earliest pending wait for that run, joined inlistAutomationRuns). The step<li>React key changed from`${run.id}-${step.nodeId}`to include a per-nodeIdoccurrence count, because a multi-leg run can now record two step rows for the same node (the accepted “re-run a node after the wait” diamond case) and the old key would collide. - AI builder.
FlowWaitSpec { kind: "wait", type, params, next? }inai/spec.ts, with aWAIT_TYPESenum derived fromautomationRegistry.byCategory("wait")the same way the existing enums are derived; a"wait"branch incompile.ts’swalkthat followsnexton theouthandle; and the system prompt documents the node and its re-fetch caveat via the same registry-driven type list described in AI automation builder: trigger params.
Why this exists
Section titled “Why this exists”Fills the highest-risk deferred capability in the flow-engine spec: every
automation previously ran to completion inside one graphile-worker job, so
post-purchase sequences (“2 days after delivery, ask for a review”),
win-back timing, and “escalate if still unhandled after N hours” were all
inexpressible. A first attempt at this (PR #421) was branched 24 commits
behind main and discarded — it predates automation_tasks, the
multi-domain entity model, and the E2E gate, and its diff deleted the
automationTasks table. This is a from-scratch re-spec against current
main; the store-function names (createWait, claimWait, cancelWait,
markRunWaiting, completeStep, getRun, countPendingWaits, settleRun,
failOverdueWaits) are the one thing kept from that reference.
See also Automations engine entity model for the multi-domain resolution resume reuses, and AI automation builder: trigger params for the registry-driven pattern the AI builder’s wait support follows.
Source: modernx-admin PR #422.