Workflow Run State Management#
Dify workflow runs transition through a defined set of states managed entirely by an event-driven persistence layer. Understanding these transitions β and where they can fail to complete β is essential for diagnosing "stuck running" incidents.
State Machine#
Workflow executions use the WorkflowExecutionStatus enum (from the graphon package) with these values:
| Status | Meaning |
|---|---|
running | Execution is active (initial state on creation) |
succeeded | All nodes completed successfully |
partial_succeeded | Completed with some node exceptions |
failed | Execution failed with an error |
stopped | Manually aborted |
paused | Paused, awaiting resumption (HITL) |
A WorkflowRun DB record is created with status=running as soon as GraphRunStartedEvent fires . All subsequent status transitions are driven by events handled in WorkflowPersistenceLayer.on_event(): GraphRunSucceededEvent β succeeded , GraphRunFailedEvent β failed , GraphRunAbortedEvent β stopped , GraphRunPausedEvent β paused . When a workflow terminates with an error, the persistence layer also marks all in-flight node executions as failed via _fail_running_node_executions().
How Runs Get Stuck in running#
A run remains in running if no terminal graph event reaches the persistence layer. There are several documented root causes:
1. Pre-execution exception (SSE gap)
If an exception is raised in WorkflowAppGenerator.generate() after the WorkflowRun record is created but before the worker thread enters its try/except, no error event is published to the SSE queue and no terminal graph event fires. The client hangs until timeout and the DB record stays running. This gap was tracked in . Several PRs addressed different manifestations:
- PR #36973 β adds
StreamEvent.ERRORto the terminal event set so the stream closes on error - PR #37100 β publishes a
workflow_finishedevent after early-failure error payloads - PR #37244 β wraps conversation/message lookup in
try/exceptto publish errors instead of silent hangs
2. Legacy stop signal interception
PR #37129 identified that legacy Redis stop flags could interrupt GraphEngine runs before terminal events were emitted, leaving runs in running .
3. Celery worker restart (warm shutdown)
Workflow execution is dispatched as Celery tasks via tier-specific tasks in async_workflow_tasks.py. If a Celery worker is restarted before an in-flight task completes, the task is lost and the WorkflowRun record stays running indefinitely β there is no built-in startup recovery scan.
The warm-shutdown handler in workflow_warm_shutdown.py mitigates this for graceful restarts: it hooks worker_shutting_down and worker_shutdown Celery signals and uses the process-local registry in active_workflow_tasks.py to track in-flight task IDs. On warm shutdown, it sets a _celery_warm_shutdown_started flag that execution code checks to abort cleanly. However, this only works when Celery receives a warm-shutdown signal β an abrupt kill or OOM leaves no chance for cleanup .
Key Files#
| File | Role |
|---|---|
api/core/app/workflow/layers/persistence.py | Event-driven layer that writes all status transitions to the DB |
api/extensions/workflow_warm_shutdown.py | Hooks Celery warm-shutdown signals to abort active runs gracefully |
api/core/app/apps/workflow/active_workflow_tasks.py | Process-local registry (set[task_id]) for in-flight workflow Celery tasks |
api/tasks/async_workflow_tasks.py | Celery task entry points for workflow execution (professional/team/sandbox tiers) |
api/core/repositories/sqlalchemy_workflow_execution_repository.py | SQLAlchemy adapter that persists WorkflowExecution domain models to WorkflowRun DB rows |
Active Task Tracking#
active_workflow_tasks.py maintains a thread-safe set[str] of active Celery task IDs using an RLock . The active_workflow_task(task_id) context manager registers a task on entry and removes it on exit . The warm-shutdown handler reads get_active_workflow_task_count() to decide whether to wait . This registry is process-local and is reset at worker initialization via reset_active_workflow_tasks() β it has no cross-process visibility and does not survive a crash.
Pause / Resume#
The resume_workflow_execution Celery task in async_workflow_tasks.py handles resumption of paused runs, reloading serialized GraphRuntimeState from the pause record and re-invoking WorkflowAppGenerator.resume(). PR #39706 (open at time of writing) targets extending this to full resume-across-rolling-updates durability .
Operational Guidance#
- Stuck runs after normal restarts: If warm-shutdown is configured and the worker receives SIGTERM, in-flight runs should abort and the DB records should update. If records remain
running, check logs for whether_on_worker_shutdownlogged remaining tasks . - Stuck runs after crash/OOM: There is no automatic recovery. Orphaned
runningrecords must be identified and manually marked asfailed(or via a migration/script) after the fact. - SSE stream hangs on startup errors: If the UI shows a workflow stuck "running" without a visible error, look for exceptions raised in the generate path before the worker thread starts β these may leave both the DB record and the SSE stream in a terminal-unreachable state.