docs/features/durable-execution-hitl.md
Generated on: 2026-06-23 Status: Draft Owner: Engineering Team Related PRs: #13633 (HITL v2); Epic LE-1437 Companion document:
../../CZL/HITL_FEATURE_OVERVIEW.md— the engineering deep-dive (8-stage code walk-through), and../../CZL/HITL_STATUS.md— the status/decision summary.
Human-in-the-Loop (HITL) lets a flow pause mid-run to ask a human to approve, reject, or supply input, then resume exactly where it stopped. The pause is durable: it is checkpointed to the database, survives a process restart, and resumes without re-running or re-billing already-completed work. The paused/resumed run is fully observable as a single backend trace, with the approval step and the terminal output recorded as real spans.
Agentic flows take consequential actions (calling tools, sending requests, spending tokens). Teams need a control point where a human gates a risky step before it executes, without losing the run if the browser closes or the server restarts. HITL turns a flow into a long-lived, resumable workflow rather than a single fire-and-forget request.
Flow Execution — Durable Background Jobs & Graph Checkpointing.
The pause probe is a no-op unless a component requests it, so normal flows are byte-for-byte unaffected: no checkpointing, no extra spans, no status churn.
| Term | Definition | Code Reference |
|---|---|---|
| Pause request | A component's signal that it needs a human decision before continuing | PauseRequested, GraphPausedException |
| Checkpoint | A serialized snapshot of graph state at a layer boundary, written to the DB | lfx/graph/checkpoint/schema.py, checkpoint table |
| Suspend | The job transitioning to a durable waiting state | JobStatus.SUSPENDED |
| Resume | Re-hydrating the graph from its checkpoint and continuing past the pause | resume_from_checkpoint, build_resumed_graph_and_get_order |
| Single-flight resume | An atomic SUSPENDED → IN_PROGRESS claim so a decision is applied exactly once | claim_suspended_for_resume |
| Human input request | The pending question shown to the user (tool approval or HumanInput node) | get_pending_human_request, human_input_required event |
| Decision | The human's answer: action_id (e.g. approve/reject) plus optional values | graph.human_input_decisions |
| Run id | The graph's tracing identity; equals graph_run_id on the messages | graph.set_run_id, trace id |
| Job id | The durable job identity used for checkpoints + resume | JobStatus, /api/v2/workflows/{job_id}/resume |
| Gate span | The trace span recording the resolved decision: "Human In The Loop — Approved/Rejected" | TracingService.record_event_span |
job_id)run_id), the Checkpoint, the Pending Human RequestJobStatus, Decision (action_id + values), request_id ({node_id}:{run_id}; agent tool approvals append the LangGraph interrupt id — {node_id}:{run_id}:{interrupt_id} — so each approval in one run is individually addressable and a stale resume for approval N is rejected during approval N+1)run_id.| Event | Trigger | Payload | Consumers |
|---|---|---|---|
human_input_required | A component raises a pause | card/request descriptor | Frontend (renders card/bar); job runner (suspends) |
Job SUSPENDED | Checkpoint written, run yields | job_id, request_id | Pending-requests API; UI badges |
| Resume accepted | Valid decision claimed | job_id, decision | Graph rebuild; tracing |
| Gate resolved span | Resume re-initializes tracing | "Human In The Loop — {decision}" | Trace panel (/monitor/traces/{id}) |
Job COMPLETED / terminal span | Resumed run finishes | Chat Output span + flush | Trace panel; message history |
As a flow author I want a step to pause for human approval and survive interruptions So that risky actions are gated and no run is lost if the server restarts.
SUSPENDEDhuman_input_required event surfaces an Approve/Reject cardSUSPENDED with a written checkpointSUSPENDED jobSUSPENDED → IN_PROGRESS transition; the other is rejected (NOT_RESUMABLE)Chat Input → Human In The Loop — {decision} → … → Chat Outputreroute_decision_on_timeout resolves the effective decision deterministicallyStatus: Accepted
A component needing human input must stop the graph without corrupting state or marking the run failed.
Model the pause as a dedicated exception (GraphPausedException) raised at a layer boundary, after the graph snapshots itself and writes a checkpoint. The build seam catches it and emits a non-terminal human_input_required event — the stream ends without on_end, so the run is "waiting", not "done".
Status: Accepted
A decision must apply exactly once even under duplicate clicks, retries, or concurrent callers.
Claim the job with an atomic SUSPENDED → IN_PROGRESS transition (claim_suspended_for_resume); on failure during resume, roll the status back so the decision is never silently consumed.
NOT_RESUMABLE (handled by the UI).Status: Accepted
Re-executing completed vertices on resume would re-bill LLMs, re-fire tools, and could revive dead branches.
Restore built vertices from the checkpoint; re-run only the paused vertex's non-input predecessors whose dropped output an unbuilt consumer will actually read (_rerun_non_input_predecessors / _unbuild_needed_dropped_producers). A producer behind a still-built, round-tripped consumer is left alone — re-running it is wasted work and, for a side-effecting node (an Agent re-bills its LLM and re-emits its message), surfaces as duplicate outputs on every later resume. Inputs (e.g. Chat Input) are never re-executed.
Status: Accepted (2026-06-23)
Originally, resumed runs lost trace data in the backend: the Chat Output span was missing and the Human In The Loop step existed only as a frontend-injected node persisted in localStorage — invisible to other devices/users and incomplete in the DB. Root cause: the resume path never initialized tracing (trace_context_var was unset, so post-pause vertices skipped add_trace), and the initial run traced under a throwaway uuid instead of the run_id, splitting the run into an orphan trace.
graph.initialize_run() on the checkpoint's run_id so resumed vertices trace into the same trace as the pre-pause run, and restore flow_name for the trace title.run_id in build_graph_from_data before initialize_run (forwarded by create_graph), so the initial run traces into graph_run_id — not a fresh uuid.TracingService.record_event_span ("Human In The Loop — Approved/Rejected").localStorage persistence; TraceDetailView reads the gate + output from the backend trace and only synthesizes a gate for the live window, deduped by name.Chat Input → gate → … → Chat Output) durable across refresh, devices, and users; no client-side band-aid.Status: Accepted
Component spans are recorded asynchronously via a worker queue. At end-of-run the worker was cancelled while the terminal component's end event was still enqueued, dropping the Chat Output span in ~7 of 8 runs.
Drain the queue inline after cancelling the worker (service.py::_stop), and force-complete any started-but-unended span at flush (native.py::_finalize_pending_spans).
checkpoint row (always-writable schema), and raises GraphPausedException.api/build.py) catches it, persists the human-input card to history, emits human_input_required, and ends the stream without on_end.SUSPENDED (services/background_execution/runner.py).POST /api/v2/workflows/{job_id}/resume with {request_id, decision}.FlowAction.EXECUTE (ensure_resume_execute_permission) before applying the decision — job ownership alone is not enough once shared access has been revoked.claim_suspended_for_resume performs the atomic status claim; failure → NOT_RESUMABLE.mark_card_answered using the card_message_id snapshotted before the continuation is enqueued, and only when the card's request_id matches — so a first decision can never resolve a second pause's card.request_id_targets_vertex (lfx.run.hitl), which accepts both the node shape and the nonce-suffixed tool-approval shape.build_resumed_graph_and_get_order (api/build.py):
graph = LfxGraph.resume_from_checkpoint(checkpoint, checkpoint_store=store)
graph.flow_name = graph.flow_name or flow_name
await graph.initialize_run() # re-init tracing on the original run_id
decision = reroute_decision_on_timeout(pending, resume["decision"])
graph.human_input_decisions = {resume["request_id"]: decision}
action_id = str((decision or {}).get("action_id", ""))
gate_label = "Rejected" if "reject" in action_id.lower() else "Approved"
graph.tracing_service.record_event_span(
span_id=f"hitl-{resume['request_id']}",
name=f"Human In The Loop — {gate_label}",
outputs={"decision": action_id},
)
# restore built vertices; re-run only non-input predecessors of the gated vertex
build_graph_from_data (api/utils/flow_utils.py) pins the run id before tracing starts:
if (caller_run_id := kwargs.get("run_id")) is not None:
graph.set_run_id(caller_run_id)
await graph.initialize_run()
create_graph forwards run_id=str(job_id) if job_id is not None else run_id to both build_graph_from_data and build_graph_from_db.TracingService.record_event_span(span_id, name, outputs, span_type="chain") writes a standalone start+end span into the active trace for every ready tracer; deactivated/contextless calls are no-ops.useGetTraceQuery → GET /api/v1/monitor/traces/{id} returns the span tree.TraceDetailView.tsx: renders the backend gate span; backendHasGate dedup prevents a duplicate synthetic node; executedOutputSpans bridges Chat Output from live flowPool only during the resume window (deduped by name).hitlStore.ts: in-memory only (pending slot); localStorage/persist removed.Bare lfx serve gains the same background + HITL contract without a database, backed by the single-node SQLite substrate:
lfx/services/durable/): SqliteDurableJobStore (job rows, gap-free per-job event log, control signals, atomic claim_suspended_for_resume) and SqliteCheckpointStore (the existing CheckpointStore ABC on the same DB file). Stdlib SQLite, WAL, async via asyncio.to_thread; one crash-safe file per deployment.lfx/cli/serve_durable.py): DurableServeWorkflowHost extends ServeWorkflowHost with supports_background = True; submit_background creates the job row and spawns the runner; get_job_status rebuilds a completed run's WorkflowExecutionResponse from stored output_events; stop_job records a durable STOP signal and cancels the local task._drive): runs graph.process() with checkpointing attached; GraphPausedException → job SUSPENDED + pending request persisted in job metadata; completion → terminal ComponentOutputs persisted as the job result; failure → FAILED with the error recorded.POST /api/v2/workflows/{job_id}/resume (same request/response contract as the backend, incl. 404/409 semantics and the 422 INVALID_DECISION guard against actions outside allowed_decisions) claims single-flight, restores from the checkpoint — in-process or after a restart — re-applies the caller identity, injects the decision, un-builds the gated vertex plus its opaque-dropped producers, and re-drives. Agent pause blobs persist in the same SQLite file via set_default_checkpoint_store, so tool-approval interrupts also survive a restart. GET /api/v2/workflows/{job_id}/pending exposes the pending request.GET /api/v2/workflows/{job_id}/events re-attaches to a background run — the links.events URL advertised on every job response. It replays the durable event log after Last-Event-ID (each frame keyed by its seq), then tails until the run ends or suspends; a HITL pause replays and closes the stream, so the client reconnects with Last-Event-ID after resuming. Unknown job → 404.LFX_SERVE_DURABLE_DB=<path>. Unset → serve stays stateless and mode: background keeps its 422; workers in multi-worker mode inherit the env var and share the DB file (a stop cancels the run only in the worker executing it).src/lfx/tests/unit/cli/test_serve_durable.py — background echo completes with the sync-shaped response; a HITL flow suspends, exposes its pending request, resumes only the approved branch, and resumes to completion on a fresh app instance over the same DB file (restart equivalence). The {job_id}/events SSE stream replays a completed run, surfaces the human_input_request on a suspend, and — reconnected with Last-Event-ID after a resume — delivers only the post-resume human_input_decision + end frames.| Concern | Where | Key symbols |
|---|---|---|
| Pause signal + checkpoint | lfx/graph/graph/base.py, lfx/graph/checkpoint/ | GraphPausedException, resume_from_checkpoint, set_run_id |
| Build seam + resume rebuild | api/build.py | build_resumed_graph_and_get_order, _rerun_non_input_predecessors |
| Run-id pinning | api/utils/flow_utils.py | build_graph_from_data |
| Durable job + single-flight | services/background_execution/{runner,service}.py | JobStatus.SUSPENDED, claim_suspended_for_resume |
| lfx serve durable substrate | lfx/services/durable/, lfx/cli/serve_durable.py | SqliteDurableJobStore, SqliteCheckpointStore, DurableServeWorkflowHost |
| Tracing | services/tracing/{native,service}.py | record_event_span, _finalize_pending_spans, _stop drain |
| Trace UI | TraceComponent/TraceDetailView.tsx | backendHasGate, executedOutputSpans |
| Live HITL slot | stores/hitlStore.ts | pending (in-memory) |
Chat Input → tools → Agent → Chat Output — and, for a gated run, a Human In The Loop — {decision} span. Read via GET /api/v1/monitor/traces/{id}.SUSPENDED / IN_PROGRESS / COMPLETED / FAILED exposed through the workflows API; pending requests via GET /api/v2/workflows/pending?flow_id=.Chat Input → Human In The Loop — Approved → Calculator → URL → Agent → fetch_content → … → Chat Output), confirmed in the DB, in the /monitor/traces/{id} payload, and in the trace panel after a page refresh.checkpoint and span/trace tables back the durability + observability; no destructive migrations are part of this feature.POST /api/v2/workflows, mode: background) with resume via POST /api/v2/workflows/{job_id}/resume. On bare lfx serve, the same contract is opt-in via LFX_SERVE_DURABLE_DB=<path> (SQLite substrate, no database service); unset keeps serve stateless.C4Context
title System Context — Durable HITL
Person(user, "User", "Approves/rejects a gated step")
System(langflow, "Langflow", "Flow execution platform")
SystemDb(db, "Database", "checkpoint, job status, trace/span")
Rel(user, langflow, "Runs flow; answers Approve/Reject")
Rel(langflow, db, "Persists checkpoint + status + spans")
Rel(langflow, user, "Surfaces card / trace gate")
sequenceDiagram
participant U as User
participant API as Workflows API
participant G as Graph Engine
participant DB as Database
participant T as Tracing
U->>API: POST /v2/workflows (background)
API->>G: build + run (run_id pinned)
G->>T: trace Chat Input … Agent
G->>DB: write checkpoint (layer boundary)
G-->>API: GraphPausedException → human_input_required
API->>DB: job SUSPENDED
API-->>U: Approve / Reject card
U->>API: POST /v2/workflows/{job}/resume {decision}
API->>DB: claim SUSPENDED→IN_PROGRESS (single-flight)
API->>G: resume_from_checkpoint + initialize_run(run_id)
G->>T: record "Human In The Loop — Approved" span
G->>T: trace re-run predecessors … Chat Output
G->>DB: flush spans (one trace) + job COMPLETED
API-->>U: result; trace panel shows full run
graph TB
A[POST /v2/workflows] --> B[create_graph]
B --> C[build_graph_from_data]
C --> D{run_id pinned?}
D -->|yes set_run_id job_id| E[initialize_run → trace = graph_run_id]
D -->|no - old bug| F[fresh uuid → orphan trace]
E --> G[pause → checkpoint stores run_id]
G --> H[resume_from_checkpoint set_run_id run_id]
H --> I[initialize_run → SAME trace]
I --> J[record gate span + Chat Output]
J --> K[(One complete trace)]
graph LR
P[Trace panel] --> Q[useGetTraceQuery]
Q --> R[GET /api/v1/monitor/traces/id]
R --> S[span tree incl. gate + Chat Output]
S --> T[TraceDetailView renders backend spans]
T --> U{backendHasGate?}
U -->|yes| V[use backend gate - no synth]
U -->|live window| W[synth gate / bridge Chat Output from flowPool]