Human-in-the-Loop Patterns in simstudioai/sim: How the Executor Pauses for User Input
The simstudioai/sim workflow executor pauses execution at Human-in-the-Loop (HiTL) blocks by creating a PausePoint, persisting a snapshot to the pausedExecutions table, and waiting for user input via a FIFO resumeQueue before rewiring the DAG and continuing.
The simstudioai/sim repository implements a robust Human-in-the-Loop (HiTL) system that allows workflows to stop mid-execution and wait for human intervention. This pattern relies on a transactional pause-resume mechanism that preserves execution state, queues user submissions, and safely rewires the workflow DAG to continue processing once input is received.
Core Architecture of HiTL Patterns
The executor implements three coordinated concepts to manage human-in-the-loop patterns:
- Pause Points – Markers created when the executor hits a block of type
"human_in_the_loop". EachPausePoint(defined inapps/sim/lib/workflows/executor/human-in-the-loop-manager.ts) captures thecontextId,blockId, current snapshot, and aresumeStatusflag set to"paused". - Paused Execution Records – Database rows in the
pausedExecutionstable (@sim/db/schema) that store the fullexecutionSnapshotand a JSONB map of pause points, enabling the UI to list paused runs and track queue position. - Resume Queue – A FIFO queue implemented in the
resumeQueuetable that guarantees only one resume runs at a time per execution while allowing multiple users to claim the same pause point.
Detailed Execution Flow
When the executor encounters a HiTL block, it follows a precise sequence to pause safely and resume transactionally.
Detecting the HiTL Block
Each block executor checks its block type against the registry. When the type matches "human_in_the_loop", the executor creates a PausePoint and returns a paused ExecutionResult instead of completing. This triggers the pause handling logic in human-in-the-loop-manager.ts.
Persisting the Pause State
The PauseResumeManager.persistPauseResult method (lines 26–84 in human-in-the-loop-manager.ts) handles the transaction:
- Stores the
executionSnapshotand a map ofpausePoints. - Executes an
INSERT … ON CONFLICT DO UPDATEagainst thepausedExecutionstable, settingstatus = "paused"and incrementingtotalPauseCount. - Immediately calls
processQueuedResumesto check for any already-queued user inputs awaiting processing.
Simultaneously, the system streams an execution:paused event to the client containing the block output, pause ID, and optional resumeLinks.
Queueing User Input
User submissions arrive via the HTTP endpoint POST /api/v1/human-in-the-loop/:executionId/:contextId implemented in apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts. The handler delegates to PauseResumeManager.enqueueOrStartResume (lines 86–123):
- If a resume is already active, the request inserts into
resumeQueuewithstatus = "pending"and returns aqueuePositionto the user. - If no resume is active, the entry inserts with
status = "claimed"and the manager immediately invokesstartResumeExecution.
Resuming and Rewiring the Workflow
The startResumeExecution method orchestrates the continuation:
- Load Snapshot: Retrieves the stored
pausedExecution.executionSnapshotfrom the database. - Merge Input: Combines the user’s payload into the pause block’s output, creating fields
submission,submittedAt,_resumed, and_pauseDurationMs(lines 526–590). - Rewire DAG: Removes edges originating from the completed pause block by updating
stateCopy.remainingEdgesandcompletedPauseContexts(lines 670–720). - Update State: Sets
pauseBlockState.executed = trueand updates execution time metrics. - Continue Execution: Creates a fresh
ExecutionSnapshot(resumeSnapshot) and passes it toexecuteWorkflowCore, which runs exactly like a fresh execution using the updated DAG.
When the resumed run finishes, markResumeCompleted (or markResumeFailed) updates the record and triggers processQueuedResumes again, allowing the next queued entry to start.
Cancellation Support
The PauseResumeManager.cancelPausedExecution method can abort a paused workflow, clearing both the pausedExecutions row and the workflow execution log, effectively resetting the state as if the pause never occurred.
Code Implementation Details
Creating a Pause Point
Inside a block executor when detecting a HiTL block:
// Simplified excerpt from the executor core
const pausePoint: PausePoint = {
contextId,
blockId,
response: blockOutput.response,
resumeStatus: 'paused',
registeredAt: new Date(),
snapshotReady: true,
};
return {
status: 'paused',
pausePoints: [pausePoint],
snapshotSeed
};
Persisting to the Database
The manager persists the pause atomically:
await PauseResumeManager.persistPauseResult({
workflowId,
executionId,
pausePoints: result.pausePoints,
snapshotSeed: result.snapshotSeed,
executorUserId,
});
Enqueueing or Starting Resume
Handling concurrent user submissions:
const enqueueResult = await PauseResumeManager.enqueueOrStartResume({
executionId,
contextId,
resumeInput,
userId,
});
// Returns either:
// { status: 'queued', queuePosition: number }
// { status: 'starting', resumeEntryId: string, ... }
Merging User Input and Continuing
During runResumeExecution, the system merges inputs and continues:
const mergedOutput = {
...existingOutput,
response: mergedResponse,
submission: submissionPayload,
submittedAt: new Date(),
_resumed: true,
_pauseDurationMs: pauseDurationMs,
};
pauseBlockState.output = mergedOutput;
pauseBlockState.executed = true;
// Remove edges from the completed pause block
stateCopy.remainingEdges = edgesToRemove;
stateCopy.completedPauseContexts = Array.from(completedPauseContexts);
// Continue execution
const result = await executeWorkflowCore({
snapshot: resumeSnapshot,
callbacks,
loggingSession,
abortSignal,
});
Key Source Files
| Path | Role |
|---|---|
apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts |
Central pause/resume logic, PauseResumeManager class, DB persistence, and queue handling. |
apps/sim/executor/handlers/human-in-the-loop/human-in-the-loop-handler.ts |
HTTP API endpoint receiving user submissions and delegating to the manager. |
apps/sim/executor/execution/executor.ts |
Entry point for workflow execution that routes to executeWorkflowCore. |
apps/sim/executor/execution/core.ts |
Detects pause points and returns paused ExecutionResult to upstream callers. |
apps/sim/executor/execution/engine.ts |
DAG execution engine that processes the updated snapshot after resume. |
apps/sim/db/schema.ts |
Defines pausedExecutions and resumeQueue tables with JSONB support for snapshots. |
Summary
- Pause Creation: HiTL blocks generate
PausePointobjects that stop execution and triggerpersistPauseResultto save snapshots to thepausedExecutionstable. - Event Streaming: The executor emits
execution:pausedevents containing block output and resume metadata for UI consumption. - Input Queueing: The
resumeQueuetable manages FIFO ordering of user submissions, ensuring single active execution per workflow withstatustracking (pendingvsclaimed). - State Restoration: The
startResumeExecutionmethod reloads snapshots, mergessubmissiondata into block outputs, and rewires the DAG by removing completed pause edges. - Core Reuse: Resumed workflows call
executeWorkflowCorewith a freshresumeSnapshot, ensuring consistent behavior between initial and resumed executions.
Frequently Asked Questions
How does the executor ensure only one resume runs at a time per execution?
The PauseResumeManager uses the resumeQueue table with a status field. When a user submits input, enqueueOrStartResume checks for existing claimed entries. If found, the new request inserts with status = "pending" and receives a queuePosition. The processQueuedResumes worker only starts the next resume after the current one calls markResumeCompleted, ensuring atomic, sequential processing.
What data structure stores the paused workflow state?
The pausedExecutions table stores a JSONB executionSnapshot containing the complete DAG state, variable context, and a pausePointsRecord map keyed by contextId. Each PausePoint includes blockId, response, resumeStatus, and registeredAt timestamps, allowing full state restoration during startResumeExecution.
How is user input merged into the workflow when resuming?
During runResumeExecution, the manager creates a mergedOutput object that spreads the existing block output and adds the user's submission payload alongside metadata fields: submittedAt, _resumed: true, and _pauseDurationMs. This merged output attaches to the pauseBlockState before the DAG edges are removed and executeWorkflowCore continues processing downstream blocks.
Can a paused execution be cancelled before user input arrives?
Yes. The PauseResumeManager.cancelPausedExecution method deletes the pausedExecutions row and clears the associated workflow execution log. This effectively discards the snapshot and pause points, preventing any future resumes from that state and freeing the execution ID for fresh runs.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →