Skip to content

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 pins entity.status to event.entityStatus so a backlogged status-changed run evaluates the transition it fired for. resume.ts does 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.ts guards on automations.status === "active" and automations.version === wait.automationVersion — activating an automation already bumps version and resets activatedAt elsewhere 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 failed with the same per-domain ENTITY_NOT_FOUND_ERRORS message execute.ts’s first leg uses.

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 readyIssues message (“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.

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.

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.

lib/automations/engine/resume.ts — resumeAutomationRun(db, event, deps):

  1. claimWait(db, event.waitId). null means an at-least-once redelivery already handled this wait — log and return, not an error.
  2. Load the automation; if it’s missing, not active, or its version no longer matches wait.automationVersion, complete the wait step as skipped with a plain-English reason (paused vs. edited-and-reactivated are worded differently) and finish the run cancelled.
  3. Resolve the entity fresh, using exactly the same multi-domain path execute.ts’s first leg uses: resolveDomain(wait.event.triggerType), then isEntitylessDomain(domain) → buildEntitylessEntity(...) for an inbound-webhook or schedule trigger, otherwise entityLoaders[domain] and ENTITY_NOT_FOUND_ERRORS[domain] on a miss. Deliberately no entityStatus pin (see the semantics section above). An earlier if (loaded.domain !== "order") throw guard from a discarded prior attempt at this feature is gone — conditions/executors/interpolation are already DomainEntity-wide since the multi-domain work landed.
  4. planFrom(automation.activeGraph, wait.nodeId, ...) and run the result through the same executeSteps the first leg uses — so a second wait re-pauses through the identical code path (step recorded waiting, new wait row + job, run status back to waiting).
  5. Complete the wait step success, then settleRun(db, wait.runId): if any pending waits remain for the run it stays waiting; 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), with durationMs measured from the run’s original startedAt, 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.

  • Canvas. WaitNode.tsx (clock icon, zinc chrome — amber is reserved for conditions, blue for the design system elsewhere) renders as autoWait; a new Timing palette group sits between Triggers and Conditions in NodePalette.tsx. summariseNode renders "Wait 2 days" / "Wait 1 hour" (singular for 1).
  • Config. WaitConfig.tsx — a duration number input and a unit select, wired into NodeConfigPanel’s ConfigForNode switch — 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. waiting and cancelled badges (both zinc, in STATUS_COLORS) and a resumes <relative time> line driven by a new resumeAt field on AutomationRunRow (the earliest pending wait for that run, joined in listAutomationRuns). The step <li> React key changed from `${run.id}-${step.nodeId}` to include a per-nodeId occurrence 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? } in ai/spec.ts, with a WAIT_TYPES enum derived from automationRegistry.byCategory("wait") the same way the existing enums are derived; a "wait" branch in compile.ts’s walk that follows next on the out handle; 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.

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.