Workflow Resume Architecture#
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 _generate() β the same internal method as a fresh run β but supplies a restored GraphRuntimeState and a newly constructed AppQueueManager, skipping the input-preparation and SSE-subscribe handshake that a first-run requires .
Two invariants must hold across every pause boundary :
GraphRuntimeStateβ variable pool, LLM usage counters, execution context.ResponseStreamFilterβ graphon's streaming gate; itspaths_maptracks which edges must be traversed before an Answer node may emit tokens. A stale or fresh filter will permanently block downstream Answer nodes after resume.
Pause: What Gets Persisted#
PauseStatePersistenceLayer is a GraphEngineLayer that fires synchronously on every GraphRunPausedEvent β the persistence runs in the execution thread to guarantee the pause record exists before the human-input form is surfaced . It:
- Serializes
GraphRuntimeStateviaGraphRuntimeState.dumps(). - Serializes
ResponseStreamFilterviaResponseStreamFilter.dumps()β must be the exact same instanceWorkflowEntryis using; a different instance silently persists empty state . - Wraps both plus the
generate_entity(discriminated union ofWorkflowAppGenerateEntity|AdvancedChatAppGenerateEntity) into aWorkflowResumptionContextPydantic model . - Translates graphon session IDs to Dify form IDs via
enrich_graph_pause_reasons()and writes the context to object storage; the DBWorkflowPauserecord stores only thestate_object_key.
PauseStatePersistenceLayer is injected only when _generate() receives a PauseStateLayerConfig β absent in debug/single-iteration runs .
Resume Entry Points#
Both entry points converge on _resume_app_execution() :
| Trigger | Entry point |
|---|---|
| Celery task (trigger/webhook) | resume_app_execution @shared_task on the workflow_based_app_execution queue |
| UI human-input form submit | Human-input controller calls _resume_app_execution() directly |
_resume_app_execution() executes these steps :
- Fetches
WorkflowPausefrom DB β loads state bytes from object storage viapause_entity.get_state(). - Deserializes via
WorkflowResumptionContext.loads(). - Restores
GraphRuntimeStateviaGraphRuntimeState.from_snapshot(...). - Restores
ResponseStreamFilterviaresumption_context.get_response_stream_filter(). - Re-fetches live ORM objects (
WorkflowRun,Workflow,App, user) from the DB. - Dispatches to
_resume_workflow()or_resume_advanced_chat()based on entity type.
Bypass of the Normal generate() Flow#
A fresh run goes generate() β _generate() β worker thread β WorkflowAppRunner.run(), with generate() preparing user inputs and establishing the SSE subscriber before events flow.
On resume, both WorkflowAppGenerator.resume() and AdvancedChatAppGenerator.resume() skip directly to _generate() with the pre-restored state . Key differences from a fresh run:
_prepare_user_inputs()is not called. User inputs were already embedded inGraphRuntimeState.stream=Trueis forced viamodel_copy(update={"stream": True})regardless of the persisted flag .trace_manageris rebuilt as a newTraceQueueManagerβ it is excluded from Pydantic serialization .- Events publish immediately β
_resume_workflow()and_resume_advanced_chat()call_publish_streaming_response()directly after the generator is returned, without waiting for an SSE subscriber .
New AppQueueManager on Resume#
_generate() always constructs a fresh AppQueueManager . The new instance uses the task_id preserved in WorkflowResumptionContext.generate_entity and wires to the same Redis topic key derived from workflow_run_id β so SSE consumers that subscribe to that key will receive events from the resumed execution.
The restored GraphRuntimeState 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
GraphRuntimeStatedirectly; the 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.
Known Issues and Active Work#
Race condition with CeleryWorkflowExecutionRepository (issue #40445): When CORE_WORKFLOW_EXECUTION_REPOSITORY=CeleryWorkflowExecutionRepository is set, save() enqueues save_workflow_execution_task asynchronously. If the workflow reaches a Human Input node before that task is processed, create_workflow_pause() queries for the WorkflowRun row synchronously and raises ValueError β leaving no durable pause record or resumption snapshot. The default SQLAlchemyWorkflowExecutionRepository commits synchronously and is not affected .
Durable-stream proposal (issue #41020): The existing Redis broadcast-channel abstraction does not carry a resume cursor, which means reconnecting API clients can miss or replay events. A maintainer-proposed DurableStreamTopic interface adds cursor-based subscriptions (subscribe_from_beginning, subscribe_from_cursor, subscribe_from_tail) with explicit CursorUnavailableError semantics. The first adapter will use Redis Streams; PostgreSQL is a future candidate .
Key Source Files#
| File | Role |
|---|---|
api/core/app/layers/pause_state_persist_layer.py | PauseStatePersistenceLayer + WorkflowResumptionContext β serialization on pause, deserialization context 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() β branches on _resume_graph_runtime_state |