Workflow Gates
Signal/timer/webhook gates, suspension, and onTimeout.
Gates are nodes that suspend a running workflow instance until an external condition is satisfied - a human approval, a timer expiry, or an inbound webhook signal. When the DAG executor reaches a gate node it returns status: 'waiting', which causes the orchestrator to persist the instance as suspended and exit. Later, when the triggering event arrives (or the timer fires), the executor resumes from the tier immediately after the gate, replaying no already-completed steps.
Gate types
The GateSystem supports three gate types, all stored in Redis:
Prop
Type
Redis key patterns
All gate state lives in Redis so the platform can survive process restarts without losing suspension state:
| Key | Type | Purpose |
|---|---|---|
wf:gate:{gateId} | String (JSON) | Full GateRegistration - config, status, timestamps |
wf:gate:signal:{eventType} | Set | Gate IDs waiting for a specific event type |
wf:gate:instance:{instanceId} | Set | All gate IDs for a workflow instance |
wf:gate:timers | Sorted set | Gate IDs scored by expiresAt ms |
On process restart, GateSystem.rehydrateGates() scans wf:gate:* and re-indexes any still-waiting registrations into the signal and timer indexes, restoring the system to a consistent state.
The human_query gate node
gate.human_query is the only palette-registered gate node. It creates a row on the Approvals page, optionally emails reviewers, and suspends the workflow until a reviewer approves, rejects, or the configured timeout fires.
# Minimal human_query gate
- id: require-approval
type: gate
config:
nodeId: gate.human_query
timeoutValue: 48
timeoutUnit: hours
onTimeout: approve # auto-approve if nobody acts in 48hThe bespoke config panel handles the timeout/onTimeout fields. Internally, resolveStepGateConfig projects these onto the executor's V2GateData shape so suspension, timer registration, and approval row creation all happen automatically.
onTimeout actions
Prop
Type
Sub-minute gate precision is explicitly not supported. The gate-timer cron runs once per minute on Vercel Cron. For sub-minute SLAs, the architecture notes suggest migrating to an Inngest durable event queue - this is not yet implemented.
Gate output and branch routing
When a gate is resumed, the DAG executor receives a synthetic StepResult for the gate node. The output and branch label are derived from the CloudEvent:
| Resume path | Branch label | Gate output |
|---|---|---|
| Human approved | 'approved' | { result: 'approved', ...approvalData } |
| Human rejected | 'rejected' | { result: 'rejected', ...approvalData } |
| Timeout → escalate / skip | 'timeout' | 'timeout' (bare string) |
| Timeout → approve | 'approved' | { result: 'approved', autoApproved: true, ... } |
| Timeout → deny | 'rejected' | { result: 'rejected', autoRejected: true, ... } |
| Signal / webhook (unlabeled edges) | 'default' | Raw CloudEvent data |
If the gate has outgoing edges labeled 'approved' and 'rejected', the executor routes accordingly. An unlabeled gate (no labeled edges) continues all downstream nodes regardless of the approval outcome.
Gate handles must be serialized as step.branches in the workflow definition - not as step.next with edge labels - for branch routing to work. The canvas does this automatically via canvasToDefinitionV2. If you author YAML by hand, use the branches field for conditional gate routing.
Signal gates and agent session completion
The platform synthesises a com.linear.AgentSessionEvent.completed CloudEvent when a session reaches a terminal status. This makes gate.signal nodes waiting on that event type resume automatically when an agent finishes - no external webhook needed.
- id: wait-for-agent
type: gate
config:
gateType: signal
signalEventType: com.linear.AgentSessionEvent.completed
signalFilter:
agentSession:
id: "{{ nodes.dispatch-agent.output.agentSessionId }}"
branches:
default: next-stepThe synthetic CloudEvent is produced by dispatchGateSignal in the session lifecycle hooks. Its id is deterministic on (sessionId, status) so retried lifecycle hook calls produce the same event - and since GateSystem.matchSignal skips non-waiting gates, idempotency is preserved end-to-end.
Resuming a gate via the API
Approval decisions are submitted through the Approvals page UI, which calls the platform's approval-gate API. The API emits a workflow.approval.decision CloudEvent internally.
Inbound webhooks from Linear, GitHub, or Vercel are normalized to CloudEvents by the webhook ingest gateway. The ingest route calls findMatchingGates then resumeGate + resumeInstanceById for every matching waiting gate.
# Pause, resume, cancel, or retry an instance via operator API
curl -X POST https://app.rensei.ai/api/workflows/{workflowId}/instances/{instanceId}/resume \
-H "Authorization: Bearer rsk_live_..." \
-H "Content-Type: application/json" \
-d '{ "signal": "approved" }'Re-entry guard
runInstanceFromGate enforces a re-entry guard: if the instance's suspendedGateNodeId does not match the gateNodeId passed to the resume, the call is a no-op. This prevents a stale approval-page row click from re-firing a resume on an instance that has already advanced past that gate and is now waiting at a downstream gate.
Resume execution flow
Fetch and validate instance - the instance must be in 'suspended' status. Re-entry guard checks suspendedGateNodeId.
Parse frozen definition snapshot - every suspended instance carries the workflow YAML it was running, making it immune to edits made after suspension.
Rebuild execution graph - DAGExecutor.buildGraph(definition) reconstructs the same graph that was running at suspension time.
Hydrate step results - loadCompletedStepResults(instanceId) reads step_executions rows and rebuilds the completedSteps map so already-run nodes are not re-executed.
Transition to running - InstanceLifecycle.resume(instanceId) moves the instance from suspended → running.
Execute with resumeFrom - DAGExecutor.execute is called with opts.resumeFrom. Tiers up to and including the gate's tier are skipped; the gate's synthetic result is injected; condition branching is applied for approved/rejected/timeout edges; execution continues from the next tier.
Finalize - mirrors normal run: lifecycle.complete, lifecycle.suspend (hit another gate), or lifecycle.fail.
Timer cron
The GET /api/cron/gate-timer route runs on a Vercel Cron at minute granularity. It:
- Calls
GateSystem.processTimeouts()which scanswf:gate:timersfor scores<= Date.now()and marks themtimed_out. - Calls
resumeTimedOutGates(processed)which drives each timed-out gate through the appropriate resume path based on itsonTimeoutaction.
The cron is idempotent: a gate that is already in a non-waiting state is skipped during timeout processing.
Related pages
- DAG Executor - tier computation, memoization, and skip semantics
- Execution Streaming - live canvas updates via Redis Pub/Sub SSE
- Approval Gates (Compliance) - BFSI human_query integration
- gate.human_query node - config panel reference