Shell Provider Lifecycle#
The shell provider lifecycle governs how Dify's agent runtime creates, reuses, and destroys sandbox execution environments across conversation turns. The core abstraction is the ExecutionBindingBackend protocol, which defines four operations:
| Operation | Method | Purpose |
|---|---|---|
| Create | create_binding(spec) | Provision a new sandbox; materialize Home + Workspace. Returns opaque binding_ref. |
| Acquire | acquire(binding_ref) | Reconnect to an existing sandbox for one operation without creating a replacement. Returns a RuntimeLease. |
| Release | release(lease) | End operation-local access; close clients. Does not delete the sandbox. |
| Destroy | destroy_binding(spec) | Permanently delete the sandbox and its materialized Home/Workspace. |
The acquire/release pair maps to what earlier code called attach/suspend: the sandbox pod stays alive between operations. destroy_binding is the equivalent of the earlier delete. PR #38528 introduced the enterprise sandbox lifecycle and, through subsequent refactoring, promoted these operations from shell-adapterβlevel suspend()/delete() methods into the runtime backend protocol where they live today.
Conversation-Scoped Binding Ownership#
A binding_ref is per-conversation, created once and reused across every message turn.
First run: AgentAppWorkspaceStore.load_or_create() detects that the conversation has no agent_workspace_binding_id, calls AgentWorkspaceService.create_binding() to provision the backend sandbox, and stores the returned backend_binding_ref in the DB row.
Subsequent runs: The same backend_binding_ref is retrieved and passed through runtime_request_builder.py to DifyRuntimeLayerConfig.backend_binding_ref. DifyRuntimeLayer.resource_context() then calls open_runtime_lease(backend, binding_ref), which invokes backend.acquire(binding_ref) to get a fresh RuntimeLease scoped to this one operation. The lease is released (but the sandbox preserved) when resource_context() exits.
Conversation (turn N)
ββ AgentAppWorkspaceStore.load_or_create() # returns StoredAgentAppSession
ββ backend_binding_ref β DifyRuntimeLayerConfig
ββ DifyRuntimeLayer.resource_context()
ββ open_runtime_lease()
ββ backend.acquire(binding_ref) # reconnect
yield RuntimeLease
ββ backend.release(lease) # close clients, keep pod alive
Enterprise Backend Implementation (post-v1.16.1 / PR #38528)#
The EnterpriseExecutionBindingBackend in dify-agent/src/dify_agent/runtime_backend/enterprise.py implements this protocol against the enterprise sandbox gateway:
create_binding(): Posts toPOST /v1/sandboxes(gateway control plane), initializes the canonicalhome_dir/workspace_dirlayout via a shellctl control command, then closes the data-plane connection before returning. On any failure, the newly created sandbox is deleted immediately (best-effort).acquire(): Connects shellctl to{gateway}/proxy/withX-Sandbox-Idrouting, validates thathome_dirandworkspace_dirstill exist with a short 5-second probe, raisesBindingLostErrorif the sandbox is gone (404 orsandbox_expired/not_foundcodes).release(): Closes the operation-local shellctl HTTP client. The sandbox pod keeps running.destroy_binding(): SendsDELETE /v1/sandboxes/{sandboxId}to the gateway (404 is treated as already-gone). Requiresdestroy_workspace=Truebecause the enterprise backend couples the Binding and Workspace to one physical pod.
_is_missing_sandbox() classifies a ShellProviderError as a lost sandbox when status_code == 404 or the error code is one of not_found, sandbox_expired, sandbox_not_found (case-insensitive). This is the bridge between gateway-level HTTP errors and the BindingLostError the protocol layer expects.
Per-Operation Runtime Layer#
DifyRuntimeLayer wraps one RuntimeLease per operation. It does not own binding creation or destruction β it only acquires and releases. The lifecycle hooks (on_context_create, on_context_resume, on_context_suspend, on_context_delete) all simply assert that a lease is available . Actual sandbox provisioning and teardown happen at the API layer.
DifyShellLayer reads the lease from DifyRuntimeLayer.lease at call time and executes shell commands through it. It does not own any part of the sandbox lifecycle.
Binding Retirement and Garbage Collection#
Termination is two-phase to guard against orphans:
- Retire:
AgentWorkspaceService.retire_binding()marks the DB rowRETIREDinside the same transaction as the trigger event (e.g., conversation deleted). - Collect:
collect_retired_binding()callsdestroy_execution_binding_sync()(βbackend.destroy_binding()), then deletes the DB row. If the workspace is also retired,collect_retired_workspace()runs first (which calls destroy withdestroy_workspace=True).
Error Handling#
| Error | Backend Method | API behavior |
|---|---|---|
BindingLostError | acquire() | app_runner.py maps "binding_lost" reason to user-facing AgentBackendError: "The retained agent working environment is no longer available." |
BindingAcquireError | acquire() | Generic backend failure |
WorkspacePreservationUnsupportedError | destroy_binding() | Enterprise: raised if destroy_workspace=False |
Key Source Files#
| File | Purpose |
|---|---|
runtime_backend/protocols.py | ExecutionBindingBackend protocol definition |
runtime_backend/enterprise.py | Enterprise gateway implementation |
layers/runtime/layer.py | DifyRuntimeLayer β per-operation acquire/release |
layers/shell/layer.py | DifyShellLayer β shell execution using the lease |
core/app/apps/agent_app/session_store.py | Binding creation on first conversation turn |
services/agent/workspace_service.py | Full binding/workspace lifecycle service |
core/app/apps/agent_app/app_runner.py | binding_lost error mapping |
| PR #38528 | Original EE sandbox lifecycle introduction |