Workflow Resume Architecture#
Overview#
Dify workflows pause at Human Input (HITL) nodes and resume in a separate Celery task, process, and thread from the original execution. The resume path reuses the same _generate() method as a fresh run but supplies a restored GraphRuntimeState and a new AppQueueManager, bypassing the initial input-preparation and SSE-subscribe handshake that a first-run goes through.
Two invariants must hold across the pause boundary:
GraphRuntimeState(variable pool, LLM usage counters, execution context) must be fully restored.ResponseStreamFilter(graphon streaming gate,paths_mapedge tracking) must be restored β otherwise downstream Answer nodes are permanently blocked from streaming.
Pause: What Gets Persisted#
PauseStatePersistenceLayer is a GraphEngineLayer that fires on every GraphRunPausedEvent . On each pause it:
- Serializes
GraphRuntimeStateviaself.graph_runtime_state.dumps(). - Serializes the
ResponseStreamFilterviaself._response_stream_filter.dumps(). The filter must be the same instance thatWorkflowEntryis using β the layer's docstring explicitly warns that a different instance would silently persist the wrong (empty) state . - Wraps both plus the
generate_entity(discriminated union ofWorkflowAppGenerateEntityorAdvancedChatAppGenerateEntity) into aWorkflowResumptionContext. - Translates graphon session IDs to Dify form IDs via
enrich_graph_pause_reasons()and callsrepo.create_workflow_pause().
WorkflowResumptionContext is a Pydantic model with:
generate_entityβ discriminated union (type-discriminated byAppMode)serialized_graph_runtime_stateβGraphRuntimeState.dumps()outputserialized_response_stream_filter_stateβ optional (Nonefor runs paused before PR #38540; degrades to fresh-filter on resume for those stale runs)
PauseStatePersistenceLayer is injected as a GraphEngineLayer only when a PauseStateLayerConfig is provided to _generate() β it is absent during single-iteration/debug runs .
Resume Entry Points#
There are two resume entry points, both calling _resume_app_execution() in workflow_execute_task.py:
1. Celery task (resume_app_execution)#
resume_app_execution is a @shared_task on the workflow_based_app_execution queue. It delegates directly to _resume_app_execution().
2. UI-driven human-input resume#
The same _resume_app_execution() function is called from the human-input controller when a user submits a form.
- Fetches the persisted pause entity from the DB .
- Deserializes via
WorkflowResumptionContext.loads(). - Restores
GraphRuntimeStateviaGraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state). - Restores
ResponseStreamFilterviaresumption_context.get_response_stream_filter(). - Re-fetches live ORM objects (
WorkflowRun,Workflow,App, user) from the DB . - Dispatches to either
_resume_advanced_chat()or_resume_workflow()based on entity type .
Bypass of the Normal generate() Flow#
A fresh execution goes through generate() β _generate() β new worker thread β WorkflowAppRunner.run(). The generate() call prepares user inputs and β in the streaming path dispatched via Celery β the SSE subscriber must attach before events are consumed.
On resume, both WorkflowAppGenerator.resume() and AdvancedChatAppGenerator.resume() skip directly to _generate(), passing the pre-restored graph_runtime_state and response_stream_filter. Crucially:
- Events are published immediately:
_resume_advanced_chat()and_resume_workflow()call_publish_streaming_response()directly after the generator is returned, pushing events to the Redis topic without waiting for an SSE subscriber to attach first . - No user-input preparation:
resume()does not call_prepare_user_inputs(). trace_manageris rebuilt: excluded from Pydantic serialization, it is reconstructed as a newTraceQueueManageron resume before_generate()is called .stream=Trueis forced:resumed_generate_entityis built withmodel_copy(update={"stream": True})regardless of the persisted stream flag .
New AppQueueManager on Resume#
_generate() always constructs a fresh AppQueueManager . This new instance uses the task_id from the deserialized generate entity β preserved across the pause boundary as part of WorkflowResumptionContext.generate_entity. The queue manager is wired to the same Redis topic key derived from workflow_run_id, so SSE consumers can subscribe to events from the resumed execution.
The restored graph_runtime_state is attached to the new queue manager before WorkflowEntry is initialized .
GraphRuntimeState Restoration in WorkflowAppRunner#
WorkflowAppRunner.run() branches on whether self._resume_graph_runtime_state is set :
- Resume path: uses the provided
graph_runtime_statedirectly; variable pool comes fromgraph_runtime_state.variable_poolβ nobuild_system_variablesorVariablePool()construction. - Fresh path: builds a new
VariablePool, adds system + bootstrap variables, constructs a newGraphRuntimeState.
Both paths pass graph_runtime_state into WorkflowEntry, where the graph engine picks up execution from the paused position.
Key Source Files#
| File | Role |
|---|---|
api/core/app/layers/pause_state_persist_layer.py | PauseStatePersistenceLayer + WorkflowResumptionContext β serialization on pause, deserialization on resume |
api/tasks/app_generate/workflow_execute_task.py | resume_app_execution Celery task, _resume_app_execution(), _resume_workflow(), _resume_advanced_chat() |
api/core/app/apps/workflow/app_generator.py | WorkflowAppGenerator.resume() β _generate() |
api/core/app/apps/advanced_chat/app_generator.py | AdvancedChatAppGenerator.resume() β _generate() |
api/core/app/apps/workflow/app_runner.py | WorkflowAppRunner.run() β resume vs. fresh branching on _resume_graph_runtime_state |