fix(gateway): gate declared multi-instance deployments and require the redis stream bridge (#6328)

* fix(gateway): gate declared multi-instance deployments and require the redis stream bridge

GATEWAY_WORKERS / WEB_CONCURRENCY only count the uvicorn workers of one
process tree, so a Kubernetes Deployment with replicas > 1 (one worker
per Pod) passed every multi-worker startup check while each Pod's
startup orphan reconciliation wrote the other Pods' lease-less runs off
as crashed on every rolling update.

Add `deployment.multi_instance` as an explicit declaration that runs the
same gate, plus a DEER_FLOW_MULTI_INSTANCE env override so deploy tooling
can set the declaration from its replica count without editing
config.yaml (wiring the Helm chart to export it is a separate change).
Both the worker count and the declaration now also require a Redis
stream bridge and refuse an explicit memory sandbox ownership store,
since the memory bridge and ownership store are process-local. The
agent-storage divergence warning, the inbound webhook dedupe warning and
the WeChat QR-login guard honor the same declaration.

* docs(changelog): link the multi-instance gate entry to #6328

* fix(ci): bump chart config_version to 53 and keep AGENTS.md under the size limits

config.example.yaml moved to config_version 53 for the new deployment
section, so the Helm chart's embedded config and its README example
follow. The multi-instance notes in backend/AGENTS.md and
backend/app/gateway/AGENTS.md shrink to one line each: main already sits
within 55 bytes of the per-file hard limit for the gateway guide and
within 163 bytes of the chain hard limit under backend/, and the full
contract lives in backend/docs/CONFIGURATION.md.

* fix(gateway): name every active knob in the multi-process rollback

When GATEWAY_WORKERS > 1 and a multi-instance declaration are both
active, the refusal offered only "Set GATEWAY_WORKERS=1", so an operator
who followed it bounced through a second refusal on
DEER_FLOW_MULTI_INSTANCE / deployment.multi_instance at the next start.
The rollback now withdraws both knobs, and the browser-tools refusal
takes the same path instead of the worker-count-only message.

multi_instance_declaration() returns a frozen MultiInstanceDeclaration
(source, value) with knob/rollback properties instead of a display
string, so deps.py no longer branches on message prose. Tests pin the
combined rollback text and that following it actually clears the gate.

* docs(gateway): keep the gateway AGENTS.md under the size hard limit after merging main

main grew backend/app/gateway/AGENTS.md by 30 bytes, which left it 25 bytes
under the 49152-byte hard limit; the one-sentence multi-instance note from
this branch pushed the merged file to 49164. Shorten that sentence by 18
bytes so scripts/check_agent_guidance.py passes on the merge result.

* ci: re-run checks after test_stop_cancels_a_hung_driver_poll flaked on shard 4

tests/test_mcp_task_service.py::test_stop_cancels_a_hung_driver_poll timed
out once on backend-unit-tests (4) for 2c16206ac. The MCP task service and
its test are identical to main, main's run for cfe2023df passed, and 15
consecutive local runs pass; the test gives service.stop() a 1s budget
while cancellation drain allows 5s, so a loaded runner can exceed it.
Fork contributors cannot re-run a job, hence this empty commit.

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Nan Gao 2026-10-05 22:34:57 +08:00 committed by GitHub
parent c2f31e440e
commit 5872d07e9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 556 additions and 28 deletions

View File

@ -135,6 +135,21 @@ This release closes that milestone with **301 merged pull requests**.
duplicate keys, and syntactically valid large numbers are allowed; a UTF-8 BOM
is rejected. Syntax only — no schema or business-field checks; existing
criteria and verdict semantics are unchanged. ([#5947])
- **gateway:** The multi-process startup gate now fires on an explicit
`deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`) as well
as `GATEWAY_WORKERS > 1`. The worker-count variables only see one process
tree, so a Kubernetes Deployment with several one-worker Pods passed every
check while each Pod's startup orphan reconciliation wrote the other Pods'
lease-less runs off as crashed on every rolling update. Both paths require
Postgres, `run_events.backend: db`, `run_ownership.heartbeat_enabled: true`
and now also a Redis stream bridge, and refuse an explicit
`sandbox.ownership.type: memory`, process-local browser tools and a
scheduler without `scheduler.multi_instance`. The agent-storage divergence
warning, the inbound webhook dedupe warning and the WeChat QR-login guard
honor the same declaration. **Behavior change:** `GATEWAY_WORKERS > 1` with
the memory stream bridge no longer starts; configure `stream_bridge.type:
redis` or `DEER_FLOW_STREAM_BRIDGE_REDIS_URL` (docker-compose and the Helm
chart already inject it). ([#6328])
#### Memory
@ -7799,3 +7814,4 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6313]: https://github.com/bytedance/deer-flow/pull/6313
[#6319]: https://github.com/bytedance/deer-flow/pull/6319
[#6326]: https://github.com/bytedance/deer-flow/pull/6326
[#6328]: https://github.com/bytedance/deer-flow/pull/6328

View File

@ -472,7 +472,7 @@ Browser login uses `HttpOnly` session cookies. The login page offers a "keep me
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. Kubernetes replicas run one worker per Pod, which the worker count cannot see: declare them with `deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`, which deploy tooling such as a Helm chart can set from its replica count) so the startup gate enforces the same prerequisites instead of staying inert. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
>
> In single-process JSONL deployments, cancelling an admitted event-store mutation waits for its background file I/O, rollback, and bookkeeping to settle before releasing the thread write lock. This prevents an older cancelled write from recreating deleted records or rolling back a later successful write. Cancellation can therefore wait on slow storage; it does not stop an in-flight filesystem operation. Callers still waiting to acquire the lock can cancel without starting a mutation. A batch spanning multiple threads drains its current thread group before propagating cancellation; subsequent thread groups do not start.
>

View File

@ -18,6 +18,7 @@ The backend runs a LangGraph-based super agent with sandbox execution, persisten
- Scheduled tasks dispatch through the normal Gateway run path. `launch_scheduled_thread_run` reads `get_app_config()` at dispatch and passes `scheduler.recursion_limit` (default 1000, matching the web UI; clamped by `max_recursion_limit`), so YAML changes apply on the next run without restarting Gateway.
- Run-history `status` filters are occurrence states, not task states. `ScheduledTaskRunStatus` in `persistence/scheduled_tasks/model.py` is the shared API/repository vocabulary and must match the active and terminal occurrence-status sets. Keep owner lookup before reading history, and apply SQL task/status predicates before pagination; omitted status preserves the existing response.
- The background scheduler is single-instance by default. `scheduler.multi_instance=true` opts into lease-aware recovery across Gateway instances and requires shared Postgres, `run_ownership.heartbeat_enabled=true`, and `run_events.backend=db`; otherwise startup rejects the configuration. Live scheduled runs are preserved when a peer starts; expired launch claims return to the durable queue, expired run leases are atomically taken over, stale launch writes are fenced by lease ownership, and the Postgres advisory-locked budget makes `max_concurrent_runs` a shared global cap for `launching`/`running` rows.
- The multi-process startup gate also fires on `deployment.multi_instance: true`; contract in `docs/CONFIGURATION.md`.
- Long-running MCP work uses a separate durable task runtime (`McpTaskService` + `mcp_tasks`, lease-based recovery) rather than keeping remote task IDs or status polling inside the Agent loop; only submit remains Agent-visible, the database is the source of truth, and `ThreadState` receives only a bounded current-thread projection. Full contract (leases, cancellation fencing, delivery idempotency, management-tool exposure): [packages/harness/deerflow/mcp/AGENTS.md](packages/harness/deerflow/mcp/AGENTS.md).
- MCP task notification retries, dead-lettering, and the cancel endpoint's worker-stopped 503 are part of that same contract — see [packages/harness/deerflow/mcp/AGENTS.md](packages/harness/deerflow/mcp/AGENTS.md).
- Scheduled-task dispatch permits one active occurrence per task via `uq_scheduled_task_run_active` (`task_id WHERE status IN ('queued','launching','running')`). Durable `queued` rows survive restarts; only lease-fenced `launching` may call Gateway launch; `running` references the durable run. Stable admission idempotency keys reuse that run after recovery. Reused-thread `ConflictError` returns `launching` to `queued`; other launch errors become `failed`. Atomic queue claims enforce `max_concurrent_runs`, excluding waiting rows; the budget count and its UPDATE are separate statements, so writers must serialize before the count (Postgres advisory lock; SQLite `BEGIN IMMEDIATE`, whose deferred transaction otherwise reserves the writer only at the UPDATE) or claims on distinct rows overshoot the cap. Repeated triggers coalesce; same-thread FIFO blocks behind older active rows. Queue admission, PATCH/resume, pause and delete lock the parent before the occurrence, freezing active task definitions. Pause/delete atomically cancel `queued` work but reject `launching`/`running`; PATCH/resume reject all active states. Only queued conflicts offer pause cancellation. Manual triggers may queue/run while paused. Recovery locks task/run pairs in task-id/run-id order and restores `run_id`, `started_at` and live errors before releasing launch claims. Launch/failure/timeout updates use one parent-first transaction to prevent interleaved claims. Queue timeout fails the occurrence and advances scheduled work to prevent immediate requeue. Repository boundaries coerce serialized timestamps before SQL `DateTime` binding.

View File

@ -17,6 +17,8 @@ from typing import Any, Protocol
from sqlalchemy import text
from deerflow.config.deployment_config import multi_instance_declaration
logger = logging.getLogger(__name__)
INBOUND_DEDUPE_TTL_SECONDS = 10 * 60
@ -269,7 +271,11 @@ def make_inbound_dedupe_store(app_config: Any | None = None) -> InboundDedupeSto
db_is_postgres = db_backend == "postgres"
workers, worker_env = _gateway_workers()
multi_worker = workers > 1
# The worker count only sees one process tree; Kubernetes replicas declare
# themselves through deployment.multi_instance / DEER_FLOW_MULTI_INSTANCE.
declaration = multi_instance_declaration(app_config)
multi_worker = workers > 1 or declaration is not None
topology = f"{worker_env}>1" if workers > 1 else getattr(declaration, "knob", None)
if backend == "postgres":
if not db_is_postgres:
@ -282,7 +288,7 @@ def make_inbound_dedupe_store(app_config: Any | None = None) -> InboundDedupeSto
if backend == "memory":
if multi_worker:
logger.warning(
f"dedupe_storage=memory with {worker_env}>1: inbound webhook dedupe "
f"dedupe_storage=memory with {topology}: inbound webhook dedupe "
"is per-pod and will NOT drop redeliveries routed to a different replica. "
"Use dedupe_storage=postgres (or remove the setting to let 'auto' pick it) "
"for multi-worker deployments. See issue #4120."

View File

@ -180,8 +180,8 @@ without changed outputs keep ordinary chat behavior. Journal mechanics
(callback attribution, receipt idempotency and retries, orphan recovery):
`packages/harness/deerflow/runtime/AGENTS.md`. Multi-worker deployments
require `run_events.backend: db` for shared, ordered delivery events; the
startup gate rejects process-local memory and JSONL event stores when
`GATEWAY_WORKERS > 1`.
startup gate rejects memory/JSONL event stores when `GATEWAY_WORKERS > 1`
or `deployment.multi_instance: true`.
**RunManager / RunStore contract**:
- LangGraph-compatible run requests validate their supported subset before creating a run. `runtime/stream_modes.py` is the shared backend contract for public stream modes and the worker's `graph.astream` mapping; the public `messages-tuple` mode maps to LangGraph's internal `messages` mode, while public `messages`, `events`, and other unsupported modes are rejected instead of being dropped or replaced with `values`. `app/gateway/run_models.py::RunCreateRequest` is shared by HTTP and internal scheduled launch paths, retains only truthful compatibility defaults for unimplemented options (`if_not_exists="create"` plus `None` placeholders), returns 422 for unsupported values including `on_completion="complete"`, `on_completion="continue"`, and `multitask_strategy="enqueue"`, and forbids undeclared SDK options so fields such as `checkpoint_during` and `durability` cannot be silently discarded. A placeholder must still accept the stock SDK's own default: `langgraph_sdk` drops only `None` from its run payload, so `stream_resumable=False` reaches every request and means "non-resumable", which is what DeerFlow serves — rejecting it 422'd every IM channel run (#4466). `tests/test_run_request_validation.py::test_gateway_accepts_langgraph_sdk_default_payload` pins the real SDK payload against this boundary; channel tests mock the SDK client and cannot catch this class of drift.

View File

@ -29,6 +29,7 @@ from langgraph.types import Checkpointer
from deerflow.community.browser_automation.session import browser_multi_worker_error
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.deployment_config import multi_instance_declaration
from deerflow.persistence.feedback import FeedbackRepository
from deerflow.runtime import ORPHAN_RECOVERY_STOP_REASON, STARTUP_ORPHAN_RECOVERY_ERROR, RunContext, RunManager, StreamBridge
from deerflow.runtime.events.store.base import RunEventStore
@ -86,9 +87,55 @@ def _gateway_worker_count() -> tuple[int, str]:
return 1, _WORKER_COUNT_ENV_VARS[0]
def _multi_process_signal(config: AppConfig) -> tuple[str, str] | None:
"""Return ``(reason, rollback)`` when this process must assume peer Gateway processes.
``reason`` names the knob that established the topology (``GATEWAY_WORKERS=2``,
``DEER_FLOW_MULTI_INSTANCE=1`` or ``deployment.multi_instance=true``) and
``rollback`` is the single-instance remediation a refusal message offers.
When the worker count and a declaration are both active, ``rollback``
withdraws both: resetting only the worker count would bounce the operator
through a second refusal on the declaration at the next start.
The worker-count variables only see one process tree, so a Kubernetes
Deployment with ``replicas > 1`` and one worker per Pod is invisible to
them; the explicit declaration exists for exactly that topology.
"""
workers, worker_env = _gateway_worker_count()
declaration = multi_instance_declaration(config)
if workers > 1:
rollback = f"Set {worker_env}=1"
if declaration is not None:
rollback = f"{rollback} and {declaration.rollback}"
return f"{worker_env}={workers}", rollback
if declaration is None:
return None
step = declaration.rollback
return declaration.knob, f"{step[0].upper()}{step[1:]} and run a single Gateway instance"
def _stream_bridge_is_cross_process(config: AppConfig) -> bool:
"""Return whether live run events can reach SSE clients on every instance.
Mirrors ``runtime/stream_bridge/async_provider.py::_resolve_config``: the
config.yaml section wins, and an omitted section falls back to the
``DEER_FLOW_STREAM_BRIDGE_REDIS_URL`` variable the Docker and Helm
deployments inject.
"""
bridge = getattr(config, "stream_bridge", None)
if bridge is not None:
return getattr(bridge, "type", None) == "redis"
return bool((os.environ.get("DEER_FLOW_STREAM_BRIDGE_REDIS_URL") or "").strip())
def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
"""Refuse unsafe multi-process configurations before persistence starts.
A deployment counts as multi-process when ``GATEWAY_WORKERS`` /
``WEB_CONCURRENCY`` is above 1, or when the operator declares peers with
``deployment.multi_instance: true`` / ``DEER_FLOW_MULTI_INSTANCE=1`` (the
worker count cannot see other Pods, and a Pod that starts next to a peer
without these prerequisites writes the peer's live runs off as orphans).
Multi-instance scheduler recovery also needs the durable run ownership
contract even when each Pod runs a single Gateway worker.
@ -105,13 +152,16 @@ def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
every run has a NULL lease, so reconciliation treats all inflight
runs as orphans and Worker B would kill Worker A's live runs on
every rolling update or scale-up.
6. The stream bridge must be Redis. The memory bridge is process-local,
so SSE streams, reconnects and ``/wait`` only work on the owner.
7. ``sandbox.ownership.type`` must not be an explicit ``memory``: an
in-process ownership store cannot see peers, so reconciliation would
adopt and idle-destroy another instance's live containers (#4206).
This gate runs once at startup before any persistence engine is
initialised so the error message is clear and the process exits
immediately.
"""
workers, worker_env = _gateway_worker_count()
scheduler = getattr(config, "scheduler", None)
multi_instance_requested = bool(getattr(scheduler, "multi_instance", False))
multi_instance_scheduler = bool(getattr(scheduler, "enabled", False) and multi_instance_requested)
@ -127,34 +177,54 @@ def _enforce_postgres_for_multi_worker(config: AppConfig) -> None:
if multi_instance_requested and (run_ownership is None or not run_ownership.heartbeat_enabled):
raise SystemExit("scheduler.multi_instance=true requires run_ownership.heartbeat_enabled=true so peer runs retain a valid lease. Set scheduler.multi_instance=false or enable run ownership heartbeats.")
if workers <= 1:
signal = _multi_process_signal(config)
if signal is None:
return
reason, rollback = signal
if config.scheduler.enabled and not multi_instance_scheduler:
raise SystemExit(f"{worker_env}={workers} cannot run with scheduler.enabled=true because each worker starts its own scheduler. Set {worker_env}=1, scheduler.multi_instance=true, or scheduler.enabled=false.")
raise SystemExit(f"{reason} cannot run with scheduler.enabled=true because each worker starts its own scheduler. {rollback}, scheduler.multi_instance=true, or scheduler.enabled=false.")
if _browser_tools_enabled_in_config(config):
raise SystemExit(browser_multi_worker_error(workers))
workers, _worker_env = _gateway_worker_count()
if workers > 1 and multi_instance_declaration(config) is None:
raise SystemExit(browser_multi_worker_error(workers))
raise SystemExit(f"{reason} cannot enable agentic browser tools: browser sessions are process-local and a request can land on any instance. {rollback} or disable the browser_navigate tool.")
if backend != "postgres":
raise SystemExit(f"{worker_env}={workers} requires database.backend='postgres', but database.backend is '{backend}'. SQLite cannot support concurrent multi-process access. Set {worker_env}=1 or switch to Postgres.")
raise SystemExit(f"{reason} requires database.backend='postgres', but database.backend is '{backend}'. SQLite cannot support concurrent multi-process access. {rollback} or switch to Postgres.")
if run_events_backend != "db":
raise SystemExit(
f"{worker_env}={workers} requires run_events.backend='db', but run_events.backend is '{run_events_backend}'. "
f"{reason} requires run_events.backend='db', but run_events.backend is '{run_events_backend}'. "
"Memory and JSONL event stores are process-local, so delivery receipt singleton guarantees cannot hold across workers. "
f"Set {worker_env}=1 or configure run_events.backend: db."
f"{rollback} or configure run_events.backend: db."
)
if run_ownership is None or not run_ownership.heartbeat_enabled:
raise SystemExit(
f"{worker_env}={workers} requires run_ownership.heartbeat_enabled=true. "
f"{reason} requires run_ownership.heartbeat_enabled=true. "
"Without heartbeat, every run has a NULL lease, so reconciliation "
"treats all inflight runs as orphans — Worker B would kill Worker A's "
"live runs on every rolling update or scale-up. "
"treats all inflight runs as orphans — a starting instance would kill its "
"peers' live runs on every rolling update or scale-up. "
"Set run_ownership.heartbeat_enabled=true in config.yaml."
)
if not _stream_bridge_is_cross_process(config):
raise SystemExit(
f"{reason} requires stream_bridge.type='redis' (or DEER_FLOW_STREAM_BRIDGE_REDIS_URL), "
"but the stream bridge is the process-local memory bridge: SSE streams, reconnects and /wait "
f"only work on the instance that owns the run. {rollback} or configure the redis stream bridge."
)
ownership = getattr(getattr(config, "sandbox", None), "ownership", None)
if ownership is not None and getattr(ownership, "type", None) == "memory":
raise SystemExit(
f"{reason} cannot run with sandbox.ownership.type='memory': an in-process ownership store cannot see "
"peer instances, so startup reconciliation would adopt and idle-destroy containers another instance is "
"using (#4206). Set sandbox.ownership.type='redis', or omit the section so it is inferred from the redis stream bridge."
)
def _validate_agent_storage(config: AppConfig) -> None:
"""Fail fast on an agent-storage backend the database cannot support.
@ -178,14 +248,13 @@ def _validate_agent_storage(config: AppConfig) -> None:
f"but database.backend is '{db_backend}'. A 'memory' database is per-process and cannot "
"share agent definitions across nodes. Set database.backend, or use agent_storage.backend='file'."
)
workers, worker_env = _gateway_worker_count()
if workers > 1 and db_backend == "postgres" and backend == "file":
signal = _multi_process_signal(config)
if signal is not None and db_backend == "postgres" and backend == "file":
logger.warning(
"%s=%s with database.backend='postgres' but agent_storage.backend='file': "
"%s with database.backend='postgres' but agent_storage.backend='file': "
"custom agents and managed subagents are stored per-node on local disk and are not visible "
"across workers/nodes. Set agent_storage.backend='db' to share them.",
worker_env,
workers,
signal[0],
)

View File

@ -21,6 +21,7 @@ from app.channels.wechat_qr_login import QRLoginError, WechatQRLogin
from app.gateway.deps import require_admin_user
from app.gateway.persistent_writes import run_drained_write
from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.config.deployment_config import multi_instance_declaration
from deerflow.persistence.channel_connections import ChannelConnectionRepository
from deerflow.persistence.engine import get_session_factory
from deerflow.utils.file_io import await_drained
@ -772,10 +773,14 @@ async def _require_wechat_qr_login(request: Request) -> ChannelConnectionsConfig
workers = int(os.environ.get("GATEWAY_WORKERS") or os.environ.get("WEB_CONCURRENCY") or "1")
except ValueError:
workers = 0
if workers != 1:
# Kubernetes replicas run one worker each; they declare their peers instead.
if workers != 1 or multi_instance_declaration(_get_app_config()) is not None:
raise HTTPException(
status_code=503,
detail="WeChat QR login requires a single Gateway worker. Set GATEWAY_WORKERS=1 (or WEB_CONCURRENCY=1 when using Uvicorn directly), or enter a bot token manually.",
detail=(
"WeChat QR login requires a single Gateway worker. Set GATEWAY_WORKERS=1 (or WEB_CONCURRENCY=1 when using Uvicorn directly), "
"run one Gateway instance without deployment.multi_instance / DEER_FLOW_MULTI_INSTANCE, or enter a bot token manually."
),
)
return config

View File

@ -518,7 +518,7 @@ Notes:
- **Upgrade note:** before upgrading a deployment with `GATEWAY_WORKERS > 1` and `scheduler.enabled: true`, either run the scheduler on exactly one Gateway worker or enable `scheduler.multi_instance: true` with shared Postgres, `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`. The startup gate now rejects the unsafe combination instead of allowing it to start silently.
- **Upgrade note:** in multi-instance mode, `max_concurrent_runs` is cluster-wide rather than per Pod and counts `launching`/`running` occurrences. Waiting `queued` rows remain outside the execution cap; capacity does not multiply with the replica count.
- **Upgrade note:** `scheduler.multi_instance` and its related scheduler, ownership, and run-event settings are startup-only. Restart all Gateway Pods together after changing them; a ConfigMap update without a coordinated restart leaves the running service on its previous mode.
- Multi-worker deployments (`GATEWAY_WORKERS > 1`) must use the Postgres database backend, enable run ownership heartbeats, and set `run_events.backend: db`. SQLite silently ignores row-level locks, while memory and JSONL run-event stores are process-local and cannot enforce singleton delivery receipts across workers; startup rejects these combinations. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep the Gateway to one worker process while `browser_navigate` is enabled — `GATEWAY_WORKERS=1`, plus `WEB_CONCURRENCY` unset or `1` on launches that pass uvicorn no worker count (`backend/Dockerfile`, `scripts/serve.sh`), where uvicorn takes the process count from it. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available.
- Multi-worker deployments (`GATEWAY_WORKERS > 1`) and declared multi-instance deployments (`deployment.multi_instance: true` / `DEER_FLOW_MULTI_INSTANCE=1`, see [Deployment topology](#deployment-topology-multi-instance)) must use the Postgres database backend, enable run ownership heartbeats, set `run_events.backend: db`, and use the Redis stream bridge. SQLite silently ignores row-level locks, while memory and JSONL run-event stores are process-local and cannot enforce singleton delivery receipts across workers; startup rejects these combinations. The process-local agentic browser tool group is incompatible with multiple Gateway workers; keep the Gateway to one worker process while `browser_navigate` is enabled — `GATEWAY_WORKERS=1`, plus `WEB_CONCURRENCY` unset or `1` on launches that pass uvicorn no worker count (`backend/Dockerfile`, `scripts/serve.sh`), where uvicorn takes the process count from it. Browser control also requires the backend `browser` extra (`cd backend && uv sync --extra browser && uv run playwright install chromium`); startup detects enabled browser config and fails fast when Playwright is missing, and `/api/features` reports `browser_control.enabled=false` until the runtime is available.
- The MVP supports thread reuse and fresh-thread-per-run execution modes.
- Create/update accept optional `assistant_id` (`lead_agent` by default, or an existing custom agent for the task owner).
- Create/update accept `once`, `cron`, and `interval`. Interval uses `schedule_spec.every_seconds` (UTC `now + N`, no missed-beat catch-up). N is at least `min_once_delay_seconds` (default 60) and at most 30 days.
@ -526,6 +526,21 @@ Notes:
- Scheduled task definitions and task-run history are persisted in the application database.
- With `channel_connections.enabled: true`, the scheduler enqueues IM outcome notifications for the task owner's connected identities. A delivery worker (same poll cadence as the scheduler) pushes them. Scheduled runs that finish as success or failed notify. For goal tasks, an `unmet` scheduled occurrence sends a goal-unmet notice instead, and an automatic pause after three unmet occurrences sends an auto-pause notice; a stop requested by the agent adds no separate notice. Manual triggers and interrupts stay silent, and so do occurrences that end without a finished run (launch error, queue timeout, restart recovery). Channel/transport outages park rows without consuming the retry budget, for up to about a day; platform rejections exhaust ~15 minutes of counted retries then settle `failed`. The worker re-checks the binding right before sending: a target the owner has disconnected since enqueue is dropped as `failed`, never pushed. Only WeCom currently implements proactive `send_notification`.
### Deployment topology (multi-instance)
```yaml
deployment:
multi_instance: false
```
Notes:
- `GATEWAY_WORKERS` / `WEB_CONCURRENCY` only count the uvicorn workers of one process tree. A Kubernetes Deployment with `replicas > 1` runs one worker per Pod, so every Pod reports a single worker and the multi-worker startup gate stays inert — while each Pod's startup orphan reconciliation still writes the other Pods' lease-less runs off as crashed on every rolling update.
- Set `multi_instance: true` (or export `DEER_FLOW_MULTI_INSTANCE=1`, which lets deploy tooling such as a Helm chart set it from its replica count) on every instance that shares one database. Startup then enforces the same prerequisites as `GATEWAY_WORKERS > 1`: `database.backend: postgres`, `run_events.backend: db`, `run_ownership.heartbeat_enabled: true`, and a Redis stream bridge (`stream_bridge.type: redis` or `DEER_FLOW_STREAM_BRIDGE_REDIS_URL`). It refuses an explicit `sandbox.ownership.type: memory`, process-local browser tools, and `scheduler.enabled: true` without `scheduler.multi_instance: true`.
- `DEER_FLOW_MULTI_INSTANCE` treats blank, `0`, `false`, `no` and `off` as "not declared"; any other value declares a multi-instance deployment, so a typo fails closed.
- The declaration also drives the `agent_storage.backend: file` divergence warning, the inbound webhook dedupe warning, and the WeChat QR-login guard, which otherwise only look at the worker count.
- Restart-required: the gate runs once at startup. Restart all Gateway instances together after changing it.
### Agent Storage
Custom agent **definitions** (`config.yaml` + `SOUL.md`) are stored per-user on

View File

@ -20,6 +20,7 @@ from deerflow.config.channel_connections_config import ChannelConnectionsConfig
from deerflow.config.checkpointer_config import CheckpointerConfig, load_checkpointer_config_from_dict
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.dedupe_storage_config import DedupeStorageConfig
from deerflow.config.deployment_config import DeploymentConfig
from deerflow.config.extensions_config import ExtensionsConfig
from deerflow.config.file_signature import ConfigSignature as _ConfigSignature
from deerflow.config.file_signature import get_config_signature as _get_config_signature
@ -386,6 +387,13 @@ class AppConfig(BaseModel):
field_doc="Stream bridge connecting agent workers to SSE endpoints.",
),
)
deployment: DeploymentConfig = Field(
default_factory=DeploymentConfig,
description=format_field_description(
"deployment",
field_doc="Deployment topology declaration: whether more than one Gateway instance shares this database (drives the multi-process startup safety gate).",
),
)
run_ownership: RunOwnershipConfig = Field(
default_factory=RunOwnershipConfig,
description=format_field_description(

View File

@ -0,0 +1,107 @@
"""Deployment topology declaration for the Gateway startup safety gates.
``GATEWAY_WORKERS`` / ``WEB_CONCURRENCY`` only count the uvicorn workers of one
process tree. A Kubernetes Deployment with ``replicas > 1`` runs one worker per
Pod, so every Pod reports a single worker and the multi-process safety gate in
``app/gateway/deps.py`` stays inert -- while each Pod's startup orphan
reconciliation still treats the other Pods' lease-less runs as crashed.
This module is the explicit declaration that closes that gap: an operator (or
deploy tooling such as a Helm chart, which can export the env variable from
its replica count) states that more than one Gateway instance shares the
database, and the gate then enforces the same prerequisites it enforces for
``GATEWAY_WORKERS > 1``.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any, Literal
from pydantic import BaseModel, Field
#: Environment override for ``deployment.multi_instance``, so deploy tooling can
#: set the declaration from its replica count instead of editing config.yaml.
MULTI_INSTANCE_ENV_VAR = "DEER_FLOW_MULTI_INSTANCE"
_FALSY = frozenset({"", "0", "false", "no", "off"})
class DeploymentConfig(BaseModel):
"""Operator-declared deployment topology."""
multi_instance: bool = Field(
default=False,
description=(
"Declare that more than one Gateway instance (uvicorn worker, container, or Kubernetes Pod) shares this database. "
"GATEWAY_WORKERS counts only one process tree, so replicas > 1 with one worker per Pod are invisible to the startup "
"safety gate without this flag. When true, startup requires database.backend='postgres', run_events.backend='db', "
"run_ownership.heartbeat_enabled=true, and a Redis stream bridge; it refuses an explicit memory sandbox ownership store, "
"process-local browser tools, and an enabled scheduler without scheduler.multi_instance. "
"DEER_FLOW_MULTI_INSTANCE=1 declares the same thing from the environment."
),
)
@dataclass(frozen=True)
class MultiInstanceDeclaration:
"""An explicit multi-instance declaration and the knob that made it.
Callers branch on ``source`` and compose messages from ``knob`` and
``rollback``; the display text is never the thing to inspect.
"""
source: Literal["env", "config"]
value: str
@property
def knob(self) -> str:
"""The setting as the operator spelled it, for refusal and warning text."""
if self.source == "env":
return f"{MULTI_INSTANCE_ENV_VAR}={self.value}"
return "deployment.multi_instance=true"
@property
def rollback(self) -> str:
"""The one step that withdraws this declaration, as a lower-case imperative."""
if self.source == "env":
return f"unset {MULTI_INSTANCE_ENV_VAR}"
return "set deployment.multi_instance=false"
def __str__(self) -> str:
return self.knob
def multi_instance_declared_by_env() -> str | None:
"""Return the ``DEER_FLOW_MULTI_INSTANCE`` value when it declares a multi-instance deployment.
Blank, ``0``, ``false``, ``no`` and ``off`` mean "not declared", so a
templated ``DEER_FLOW_MULTI_INSTANCE=false`` stays inert. Every other value
is a declaration: a typo in this variable must fail closed (run the gate)
rather than silently disable it.
"""
raw = os.environ.get(MULTI_INSTANCE_ENV_VAR)
if raw is None:
return None
value = raw.strip()
if value.lower() in _FALSY:
return None
return value
def multi_instance_declaration(config: Any) -> MultiInstanceDeclaration | None:
"""Return the explicit multi-instance declaration in effect, or ``None``.
The environment is consulted first because deploy tooling that sets it
knows the actual topology; ``deployment.multi_instance`` in config.yaml is
the operator's manual spelling. The result carries the knob that made the
declaration so a refusal message tells the operator what to change.
"""
env_value = multi_instance_declared_by_env()
if env_value is not None:
return MultiInstanceDeclaration(source="env", value=env_value)
deployment = getattr(config, "deployment", None)
if bool(getattr(deployment, "multi_instance", False)):
return MultiInstanceDeclaration(source="config", value="true")
return None

View File

@ -82,6 +82,10 @@ STARTUP_ONLY_FIELDS: dict[str, str] = {
),
"subagent_runtime": ("the shared native-subagent admission controller and isolated execution loop are configured once during Gateway lifespan startup; changing process slots, queue policy, or queue bounds requires a restart."),
"subagent_batches": ("the durable subagent batch service is constructed and started once during Gateway lifespan startup; scheduler limits, leases, and recovery behavior are captured by that service instance."),
"deployment": (
"langgraph_runtime() evaluates deployment.multi_instance once at startup inside the multi-process safety gate (_enforce_postgres_for_multi_worker); "
"the gate never re-runs on config.yaml edits, so declaring or withdrawing a multi-instance topology needs a Gateway restart on every instance."
),
"run_ownership": (
"RunOwnershipConfig is captured once into RunManager at langgraph_runtime() startup; the lease heartbeat background task is created and "
"started there, and heartbeat_enabled / lease_seconds / grace_seconds are not re-read on config.yaml edits."

View File

@ -26,6 +26,7 @@ def _stub_app_config(monkeypatch):
monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "0")
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
monkeypatch.delenv("DEER_FLOW_MULTI_INSTANCE", raising=False)
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
yield
reset_app_config()
@ -1452,6 +1453,8 @@ def test_wechat_qr_confirmation_preserves_bot_id_unless_replaced(monkeypatch, re
{"GATEWAY_WORKERS": "invalid"},
# A blank GATEWAY_WORKERS is unset, so WEB_CONCURRENCY still decides.
{"GATEWAY_WORKERS": "", "WEB_CONCURRENCY": "2"},
# Kubernetes replicas run one worker each and declare their peers instead.
{"GATEWAY_WORKERS": "1", "DEER_FLOW_MULTI_INSTANCE": "1"},
],
)
@pytest.mark.parametrize("method,suffix", [("POST", ""), ("POST", "/session/poll"), ("DELETE", "/session")])

View File

@ -28,6 +28,7 @@ def isolated_worker_env(monkeypatch):
"""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
monkeypatch.delenv("DEER_FLOW_MULTI_INSTANCE", raising=False)
class _FakeDedupe:
@ -107,6 +108,29 @@ def test_factory_warns_when_memory_explicit_under_multi_worker(monkeypatch, capl
assert any("dedupe_storage=memory with GATEWAY_WORKERS>1" in r.message for r in caplog.records)
def test_factory_warns_when_memory_explicit_under_a_declared_multi_instance_deployment(monkeypatch, caplog):
"""Kubernetes replicas run one worker each and declare their peers instead of a worker count."""
import logging
monkeypatch.setenv("DEER_FLOW_MULTI_INSTANCE", "1")
app = _FakeApp("memory", "sqlite")
with caplog.at_level(logging.WARNING):
store = make_inbound_dedupe_store(app)
assert isinstance(store, MemoryInboundDedupeStore)
assert any("dedupe_storage=memory with DEER_FLOW_MULTI_INSTANCE=1" in r.message for r in caplog.records)
def test_factory_warns_when_auto_resolves_memory_under_a_declared_multi_instance_deployment(monkeypatch, caplog):
import logging
monkeypatch.setenv("DEER_FLOW_MULTI_INSTANCE", "true")
app = _FakeApp("auto", "sqlite")
with caplog.at_level(logging.WARNING):
store = make_inbound_dedupe_store(app)
assert isinstance(store, MemoryInboundDedupeStore)
assert any("Multi-worker deployment detected but dedupe_storage=auto" in r.message for r in caplog.records)
def test_factory_warns_when_auto_resolves_memory_under_multi_worker(monkeypatch, caplog):
import logging

View File

@ -21,7 +21,9 @@ from fastapi import FastAPI
from app.gateway.deps import _enforce_postgres_for_multi_worker, _validate_agent_storage, langgraph_runtime
from app.gateway.routers.browser import _browser_tools_enabled
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.deployment_config import MULTI_INSTANCE_ENV_VAR, DeploymentConfig, multi_instance_declaration
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.config.stream_bridge_config import StreamBridgeConfig
def _config_with_backend(
@ -32,18 +34,33 @@ def _config_with_backend(
run_events_backend: str = "db",
scheduler_enabled: bool = False,
scheduler_multi_instance: bool = False,
deployment_multi_instance: bool = False,
stream_bridge_type: str | None = None,
ownership_type: str | None = None,
) -> SimpleNamespace:
run_ownership = RunOwnershipConfig(heartbeat_enabled=heartbeat_enabled) if heartbeat_enabled is not None else None
tools = [SimpleNamespace(name="browser_navigate")] if browser_enabled else []
stream_bridge = StreamBridgeConfig(type=stream_bridge_type) if stream_bridge_type is not None else None
ownership = SimpleNamespace(type=ownership_type) if ownership_type is not None else None
return SimpleNamespace(
database=DatabaseConfig(backend=backend),
run_ownership=run_ownership,
run_events=SimpleNamespace(backend=run_events_backend),
scheduler=SimpleNamespace(enabled=scheduler_enabled, multi_instance=scheduler_multi_instance),
tools=tools,
deployment=DeploymentConfig(multi_instance=deployment_multi_instance),
stream_bridge=stream_bridge,
sandbox=SimpleNamespace(ownership=ownership),
)
def _cluster_ready(**overrides):
"""A Postgres deployment with every multi-instance prerequisite satisfied."""
kwargs = dict(heartbeat_enabled=True, stream_bridge_type="redis")
kwargs.update(overrides)
return _config_with_backend("postgres", **kwargs)
@pytest.fixture(autouse=True)
def isolated_worker_env(monkeypatch):
"""Keep the suite independent of the invoking shell's worker count.
@ -56,6 +73,8 @@ def isolated_worker_env(monkeypatch):
"""
monkeypatch.delenv("GATEWAY_WORKERS", raising=False)
monkeypatch.delenv("WEB_CONCURRENCY", raising=False)
monkeypatch.delenv(MULTI_INSTANCE_ENV_VAR, raising=False)
monkeypatch.delenv("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", raising=False)
# ---------------------------------------------------------------------------
@ -181,7 +200,7 @@ def test_agent_storage_warning_names_the_variable_that_set_the_count(monkeypatch
def test_gate_allows_multi_worker_with_postgres_and_heartbeat(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True))
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True, stream_bridge_type="redis"))
def test_gate_rejects_multi_worker_with_scheduler_enabled(monkeypatch):
@ -227,6 +246,7 @@ def test_gate_allows_multi_instance_scheduler_with_multiple_workers(monkeypatch)
heartbeat_enabled=True,
scheduler_enabled=True,
scheduler_multi_instance=True,
stream_bridge_type="redis",
)
)
@ -458,3 +478,232 @@ async def test_langgraph_runtime_invokes_gate_before_persistence_setup(monkeypat
init_engine_from_config.assert_not_called()
make_stream_bridge.assert_not_called()
make_store.assert_not_called()
# ---------------------------------------------------------------------------
# Cross-process stream bridge: multi-worker now needs it too
# ---------------------------------------------------------------------------
def test_multi_worker_requires_a_cross_process_stream_bridge(monkeypatch):
"""GATEWAY_WORKERS > 1 with the memory bridge used to start and then 409 every SSE join on a peer."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
for bridge in (None, "memory"):
with pytest.raises(SystemExit, match="stream_bridge.type='redis'"):
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True, stream_bridge_type=bridge))
def test_multi_worker_accepts_the_env_redis_stream_bridge(monkeypatch):
"""docker-compose and the Helm chart inject DEER_FLOW_STREAM_BRIDGE_REDIS_URL instead of a config section."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", "redis://redis:6379/0")
_enforce_postgres_for_multi_worker(_config_with_backend("postgres", heartbeat_enabled=True))
def test_multi_worker_rejects_explicit_memory_sandbox_ownership(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit, match="sandbox.ownership.type='memory'"):
_enforce_postgres_for_multi_worker(_cluster_ready(ownership_type="memory"))
# ---------------------------------------------------------------------------
# Explicit multi-instance declaration: Kubernetes replicas run one worker per Pod
# ---------------------------------------------------------------------------
def test_declared_multi_instance_is_gated_with_a_single_worker(monkeypatch):
"""replicas > 1 with GATEWAY_WORKERS=1 per Pod must not bypass the gate.
Without the declaration every Pod reports one worker, passes every check,
and its startup orphan reconciliation writes the peers' lease-less runs off
as crashed on every rolling update.
"""
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", deployment_multi_instance=True))
msg = str(exc_info.value)
assert "deployment.multi_instance=true" in msg
assert "requires database.backend='postgres'" in msg
assert "deployment.multi_instance=false" in msg, "must name the rollback knob"
def test_declared_multi_instance_accepts_a_fully_shared_deployment(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "1")
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True))
def test_declared_multi_instance_requires_heartbeat():
with pytest.raises(SystemExit, match="heartbeat_enabled=true"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, heartbeat_enabled=False))
@pytest.mark.parametrize("run_events_backend", ["memory", "jsonl"])
def test_declared_multi_instance_requires_db_run_events(run_events_backend):
with pytest.raises(SystemExit, match="run_events.backend='db'"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, run_events_backend=run_events_backend))
def test_declared_multi_instance_requires_a_redis_stream_bridge():
"""The memory bridge is process-local, so a peer never sees the owner's SSE events."""
for bridge in (None, "memory"):
with pytest.raises(SystemExit, match="stream_bridge.type='redis'"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, stream_bridge_type=bridge))
def test_declared_multi_instance_accepts_the_env_redis_stream_bridge(monkeypatch):
monkeypatch.setenv("DEER_FLOW_STREAM_BRIDGE_REDIS_URL", "redis://redis:6379/0")
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, stream_bridge_type=None))
def test_declared_multi_instance_rejects_explicit_memory_sandbox_ownership():
with pytest.raises(SystemExit, match="sandbox.ownership.type='memory'"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, ownership_type="memory"))
def test_declared_multi_instance_accepts_redis_or_inferred_sandbox_ownership():
"""An omitted ownership section is inferred from the redis stream bridge (ownership/factory.py)."""
for ownership in (None, "redis"):
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, ownership_type=ownership))
def test_declared_multi_instance_rejects_single_instance_scheduler():
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, scheduler_enabled=True))
msg = str(exc_info.value)
assert "each worker starts its own scheduler" in msg
assert "scheduler.multi_instance=true" in msg
assert "deployment.multi_instance=false" in msg
def test_declared_multi_instance_accepts_multi_instance_scheduler():
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, scheduler_enabled=True, scheduler_multi_instance=True))
def test_declared_multi_instance_rejects_process_local_browser():
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_cluster_ready(deployment_multi_instance=True, browser_enabled=True))
msg = str(exc_info.value)
assert "process-local" in msg
assert "deployment.multi_instance=false" in msg
@pytest.mark.parametrize("value", ["1", "true", "True", "yes", "on", "replicas"])
def test_env_declaration_is_gated(monkeypatch, value):
"""DEER_FLOW_MULTI_INSTANCE is what deploy tooling sets from the replica count.
Any spelling that is not an explicit "off" counts: a typo must fail closed
(run the gate) rather than silently disable it.
"""
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, value)
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
msg = str(exc_info.value)
assert f"{MULTI_INSTANCE_ENV_VAR}={value}" in msg
assert f"Unset {MULTI_INSTANCE_ENV_VAR}" in msg, "must name the rollback knob"
@pytest.mark.parametrize("value", ["", "0", "false", "False", "no", "off", " "])
def test_falsy_env_declaration_stays_inert(monkeypatch, value):
"""A templated DEER_FLOW_MULTI_INSTANCE=false (one replica) must not trip the gate."""
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, value)
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", scheduler_enabled=True))
def test_env_declaration_accepts_a_fully_shared_deployment(monkeypatch):
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
_enforce_postgres_for_multi_worker(_cluster_ready())
def test_worker_count_is_reported_over_the_declaration(monkeypatch):
"""When both apply, the refusal names the worker count: that is the knob uvicorn acts on."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite"))
assert "GATEWAY_WORKERS=2" in str(exc_info.value)
@pytest.mark.parametrize(
("declared_by", "expected_rollback"),
[
("env", f"Set GATEWAY_WORKERS=1 and unset {MULTI_INSTANCE_ENV_VAR}"),
("config", "Set GATEWAY_WORKERS=1 and set deployment.multi_instance=false"),
],
)
def test_worker_count_rollback_also_withdraws_the_declaration(monkeypatch, declared_by, expected_rollback):
"""With both knobs active, a rollback that only resets the worker count is not enough.
Following ``Set GATEWAY_WORKERS=1`` alone leaves the declaration tripping the
gate at the next start, so the operator would bounce through a second
refusal before learning about the other knob. The message names both.
"""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
if declared_by == "env":
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
config = _config_with_backend("sqlite", deployment_multi_instance=declared_by == "config")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(config)
assert expected_rollback in str(exc_info.value)
def test_offered_env_rollback_clears_the_gate(monkeypatch):
"""The remediation a refusal offers must make the next start succeed."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
sqlite = _config_with_backend("sqlite")
with pytest.raises(SystemExit):
_enforce_postgres_for_multi_worker(sqlite)
# Resetting only the worker count is the half-step the message must not suggest.
monkeypatch.setenv("GATEWAY_WORKERS", "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(sqlite)
assert f"{MULTI_INSTANCE_ENV_VAR}=1" in str(exc_info.value)
# The full rollback the message offered clears the gate.
monkeypatch.delenv(MULTI_INSTANCE_ENV_VAR)
_enforce_postgres_for_multi_worker(sqlite)
def test_offered_config_rollback_clears_the_gate(monkeypatch):
monkeypatch.setenv("GATEWAY_WORKERS", "2")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", deployment_multi_instance=True))
assert "Set GATEWAY_WORKERS=1 and set deployment.multi_instance=false" in str(exc_info.value)
monkeypatch.setenv("GATEWAY_WORKERS", "1")
_enforce_postgres_for_multi_worker(_config_with_backend("sqlite", deployment_multi_instance=False))
def test_browser_refusal_names_the_declaration_when_both_knobs_are_active(monkeypatch):
"""The browser refusal must not send the operator through the same double bounce."""
monkeypatch.setenv("GATEWAY_WORKERS", "2")
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
with pytest.raises(SystemExit) as exc_info:
_enforce_postgres_for_multi_worker(_cluster_ready(browser_enabled=True))
msg = str(exc_info.value)
assert "browser" in msg
assert f"Set GATEWAY_WORKERS=1 and unset {MULTI_INSTANCE_ENV_VAR}" in msg
def test_agent_storage_warning_fires_for_a_declared_multi_instance_deployment(caplog):
with caplog.at_level("WARNING"):
_validate_agent_storage(_cluster_ready(deployment_multi_instance=True))
messages = [r.message for r in caplog.records if "not visible across workers" in r.message]
assert messages and "deployment.multi_instance=true" in messages[0]
def test_deployment_declaration_helpers(monkeypatch):
assert DeploymentConfig().multi_instance is False
assert multi_instance_declaration(None) is None
by_config = multi_instance_declaration(SimpleNamespace(deployment=DeploymentConfig(multi_instance=True)))
assert by_config is not None
assert (by_config.source, by_config.knob, by_config.rollback) == ("config", "deployment.multi_instance=true", "set deployment.multi_instance=false")
assert str(by_config) == by_config.knob
# The environment wins over config.yaml and keeps the operator's spelling.
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "replicas")
by_env = multi_instance_declaration(SimpleNamespace(deployment=DeploymentConfig(multi_instance=True)))
assert by_env is not None
assert (by_env.source, by_env.knob, by_env.rollback) == ("env", f"{MULTI_INSTANCE_ENV_VAR}=replicas", f"unset {MULTI_INSTANCE_ENV_VAR}")

View File

@ -20,7 +20,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 52
config_version: 53
# Optional operator-owned literal prompt extensions (see CONFIGURATION.md):
# lead_prompt_overlay:
@ -2754,6 +2754,27 @@ mcp_tasks:
max_result_bytes: 65536
result_preview_max_chars: 2000
# ============================================================================
# Deployment Topology
# ============================================================================
# Declare whether more than one Gateway instance shares this database.
#
# GATEWAY_WORKERS / WEB_CONCURRENCY only count the uvicorn workers of one
# process tree, so a Kubernetes Deployment with replicas > 1 (one worker per
# Pod) is invisible to the startup safety gate. Without this declaration each
# Pod passes every check, and its startup orphan reconciliation writes the
# other Pods' lease-less runs off as crashed on every rolling update.
#
# With multi_instance: true (or the environment variable
# DEER_FLOW_MULTI_INSTANCE=1, which deploy tooling such as a Helm chart can
# set from its replica count) startup requires database.backend: postgres, run_events.backend: db,
# run_ownership.heartbeat_enabled: true and a Redis stream bridge, and refuses
# an explicit sandbox.ownership.type: memory, process-local browser tools, and
# scheduler.enabled: true without scheduler.multi_instance: true.
# Restart required; apply the same value to every instance.
deployment:
multi_instance: false
# ============================================================================
# Run Ownership Configuration
# ============================================================================

View File

@ -135,7 +135,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 52
config_version: 53
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -263,7 +263,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox.
config: |
config_version: 52
config_version: 53
log_level: info
recursion_limit: 100
max_recursion_limit: 1000