deer-flow/backend/tests/test_dev_multi_instance_script.py
Nan Gao d18d2937e7
feat(channels): add channels.leader_lease and gate an explicitly disabled lease under multi-process signals (#6746)
* feat(channels): add channels.leader_lease and gate it under multi-process signals

IM channels other than GitHub connect outbound to their platform. With more
than one Gateway instance each one opens that connection with the same bot
credentials: Telegram rejects the second poller with 409, Discord answers every
message twice and per-process ChannelManager state diverges. The multi-process
startup gate said nothing about channels; the only guard was a comment in the
Helm values.

Add the `channels.leader_lease` section (`enabled: auto|true|false`,
`lease_seconds`, `grace_seconds`; startup-only, registered in the reload
boundary) that the per-provider leader lease will read, validated by a Pydantic
model from the non-schema `channels` mapping. The gate now refuses
`enabled: false` under a multi-process signal while a non-GitHub provider is
enabled under `channels.*` or connectable through `channel_connections`, naming
the providers and the rollback; a malformed block is refused on every topology.
`auto` follows the multi-process signal, so a single instance is unchanged.

ChannelService strips the block before its per-provider loops and exposes it
as `leader_lease`; `ensure_ready_channels` only visits registered providers.
The two-Gateway harness overlay leaves the block alone. config_version 60 in
the example, the Helm chart and its README; docs replace the comment-only
"keep 1 replica" guidance with the gate.

* docs(changelog): link #6746

* fix(channels): stop recommending the unimplemented leader lease as a remedy

Review on #6746: the refusal told operators to switch channels.leader_lease
from false to auto/true "so one instance owns each provider's connection", but
the transport arbitration ships separately, so that change only bypassed the
clause and every worker still started the same bot.

The refusal now offers only the remedies that work today (a single instance, or
disabling the channels) and states that auto/true are reserved for the lease
that ships separately. A regression helper in the gate tests asserts the
remedies start with the rollback and that auto/true are never offered. The
config example, Helm values/README, CONFIGURATION.md, README, the deployment
guide (en/zh), the model docstrings and both changelogs describe auto/true as
reserved settings rather than an ownership guarantee.

* fix(channels): warn on an enabled unknown channel key; reword the changelog entry

Review on #6746: the registry guard in ensure_ready_channels skipped a
misspelled provider key (channels.telegam) silently, where it previously
reached _start_channel and logged 'Unknown channel type'. An enabled dict
block that is neither a registered provider nor leader_lease now logs a
warning that lists the registry.

The changelog entry claimed the chart and docs drop the "keep 1 replica"
guidance; they keep it (auto/true do not make several replicas safe yet) and
only stop relying on the comment alone.

* fix(dev): disable UI channel providers in multi-instance overlay

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-12 23:05:16 +08:00

779 lines
32 KiB
Python

"""Tests for the two-Gateway local harness (``scripts/dev_multi_instance.{sh,py}``).
The shell entry point owns containers and processes; the Python helper owns the
parts that decide whether the pair can start at all. The key contract pinned
here: the generated ``config.yaml`` passes the *real* multi-instance startup
gate (``app.gateway.deps._enforce_postgres_for_multi_worker``) for any base
config, while leaving the developer's other sections untouched.
"""
from __future__ import annotations
import base64
import importlib.util
import logging
import os
import shutil
import stat
import subprocess
import sys
from pathlib import Path
import pytest
import yaml
from app.gateway.deps import _enforce_postgres_for_multi_worker, _validate_memory_retrieval_index
from deerflow.config.app_config import AppConfig
from deerflow.config.deployment_config import MULTI_INSTANCE_ENV_VAR, multi_instance_declaration
REPO_ROOT = Path(__file__).resolve().parents[2]
HELPER_PATH = REPO_ROOT / "scripts" / "dev_multi_instance.py"
SHELL_PATH = REPO_ROOT / "scripts" / "dev_multi_instance.sh"
_spec = importlib.util.spec_from_file_location("deerflow_dev_multi_instance", HELPER_PATH)
assert _spec is not None and _spec.loader is not None
mi = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = mi # dataclasses resolve string annotations through sys.modules
_spec.loader.exec_module(mi)
DATABASE_URL = "postgresql://deerflow:secret@127.0.0.1:55432/deerflow"
REDIS_URL = "redis://127.0.0.1:56379/0"
@pytest.fixture(autouse=True)
def _isolated_gate_env(monkeypatch):
"""The gate also reads worker-count and declaration variables from the shell."""
for name in ("GATEWAY_WORKERS", "WEB_CONCURRENCY", MULTI_INSTANCE_ENV_VAR, "DEER_FLOW_STREAM_BRIDGE_REDIS_URL", mi.RETRIEVAL_INDEX_ENV_VAR):
monkeypatch.delenv(name, raising=False)
SANDBOX_PREFIX = "deer-flow-mi-0123456789-sandbox"
def _overlay(base: dict, **kwargs):
return mi.build_multi_instance_config(base, database_url=DATABASE_URL, redis_url=REDIS_URL, sandbox_container_prefix=SANDBOX_PREFIX, **kwargs)
# ---------------------------------------------------------------------------
# Overlay semantics
# ---------------------------------------------------------------------------
def test_overlay_sets_every_multi_instance_prerequisite():
config, _notes = _overlay({})
assert config["deployment"]["multi_instance"] is True
assert config["database"]["backend"] == "postgres"
assert config["database"]["postgres_url"] == DATABASE_URL
assert config["run_events"]["backend"] == "db"
assert config["run_ownership"]["heartbeat_enabled"] is True
assert config["stream_bridge"] == {"type": "redis", "redis_url": REDIS_URL}
# One shared file, a per-process index directory chosen by the environment.
assert config["memory"]["backend_config"]["retrieval_index_path"] == f"${mi.RETRIEVAL_INDEX_ENV_VAR}"
def test_overlay_preserves_unrelated_base_sections_without_mutating_the_base():
base = {
"config_version": 57,
"models": [{"name": "m1", "use": "langchain_openai:ChatOpenAI", "model": "gpt-x"}],
"tools": [{"name": "web_search", "use": "deerflow.community.ddg_search.tools:web_search_tool"}],
"database": {"backend": "sqlite", "sqlite_dir": ".deer-flow/data", "pool_recycle": 120},
"run_ownership": {"lease_seconds": 15, "grace_seconds": 5, "heartbeat_enabled": False},
"run_events": {"backend": "memory", "max_trace_content": 2048},
"memory": {"enabled": True, "backend_config": {"debounce_seconds": 5, "storage_path": ""}},
"scheduler": {"enabled": False, "multi_instance": False},
}
snapshot = yaml.safe_dump(base)
config, _notes = _overlay(base)
assert yaml.safe_dump(base) == snapshot, "the base mapping must not be mutated"
assert config["config_version"] == 57
assert config["models"] == base["models"]
assert config["tools"] == base["tools"]
assert config["database"]["pool_recycle"] == 120
assert config["run_ownership"]["lease_seconds"] == 15
assert config["run_ownership"]["grace_seconds"] == 5
assert config["run_events"]["max_trace_content"] == 2048
assert config["memory"]["backend_config"]["debounce_seconds"] == 5
# A disabled scheduler is left alone.
assert config["scheduler"] == {"enabled": False, "multi_instance": False}
def test_overlay_neutralizes_settings_the_gate_refuses():
base = {
"tools": [
{"name": "web_search", "use": "deerflow.community.ddg_search.tools:web_search_tool"},
{"name": "browser_navigate", "use": "deerflow.community.browser_automation.tools:browser_navigate_tool"},
{"name": "browser_click", "use": "deerflow.community.browser_automation.tools:browser_click_tool"},
],
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider", "ownership": {"type": "memory"}},
"scheduler": {"enabled": True},
"checkpointer": {"type": "sqlite", "connection_string": "checkpoints.db"},
"channels": {
"langgraph_url": "http://localhost:8001/api",
"session": {"assistant_id": "lead_agent"},
"telegram": {"enabled": True, "bot_token": "$TELEGRAM_BOT_TOKEN"},
"slack": {"enabled": False},
},
}
config, notes = _overlay(base)
assert [tool["name"] for tool in config["tools"]] == ["web_search"]
assert config["sandbox"]["ownership"] == {"type": "redis", "redis_url": REDIS_URL}
assert config["sandbox"]["use"] == "deerflow.sandbox.local:LocalSandboxProvider"
assert "container_prefix" not in config["sandbox"], "only the local AIO provider names containers"
assert config["scheduler"] == {"enabled": True, "multi_instance": True}
assert "checkpointer" not in config
assert config["channels"]["telegram"]["enabled"] is False
assert config["channels"]["telegram"]["bot_token"] == "$TELEGRAM_BOT_TOKEN"
assert config["channels"]["slack"]["enabled"] is False
assert config["channels"]["langgraph_url"] == "http://localhost:8001/api"
joined = "\n".join(notes)
for fragment in ("browser_navigate", "sandbox.ownership", "scheduler.multi_instance", "checkpointer", "telegram"):
assert fragment in joined
def test_overlay_keeps_channels_when_asked():
config, _notes = _overlay({"channels": {"telegram": {"enabled": True}}}, keep_channels=True)
assert config["channels"]["telegram"]["enabled"] is True
@pytest.mark.parametrize("keep_channels", [False, True])
def test_overlay_handles_ui_connectable_providers_without_mutating_settings(keep_channels):
providers = ("slack", "telegram", "discord", "feishu", "dingtalk", "wechat", "wecom", "buzz", "qq")
base = {
"channels": {"leader_lease": {"enabled": False, "lease_seconds": 15}},
"channel_connections": {
"enabled": True,
"require_bound_identity": False,
"notification_locale": "zh-CN",
**{provider: {"enabled": True} for provider in providers},
},
}
base["channel_connections"]["telegram"]["bot_username"] = "test_bot"
snapshot = yaml.safe_dump(base)
config, notes = _overlay(base, keep_channels=keep_channels)
assert yaml.safe_dump(base) == snapshot
assert config["channels"]["leader_lease"] == base["channels"]["leader_lease"]
connections = config["channel_connections"]
assert connections["enabled"] is True
assert connections["require_bound_identity"] is False
assert connections["notification_locale"] == "zh-CN"
assert connections["telegram"]["bot_username"] == "test_bot"
assert all(connections[provider]["enabled"] is keep_channels for provider in providers)
channel_notes = [note for note in notes if "channel_connections" in note]
if keep_channels:
assert channel_notes == []
else:
assert len(channel_notes) == 1
assert all(provider in channel_notes[0] for provider in providers)
def test_overlay_drops_a_postgres_checkpointer_pointing_at_the_developers_database():
# An explicit checkpointer section wins over database.postgres_url for LangGraph
# checkpoints and the Store, so keeping it would write into the real database.
base = {"checkpointer": {"type": "postgres", "connection_string": "postgresql://me@db.internal:5432/prod"}}
config, notes = _overlay(base)
assert "checkpointer" not in config
assert any("checkpointer" in note for note in notes)
def test_overlay_points_explicit_sandbox_ownership_at_the_harness_redis():
base = {
"sandbox": {
"use": "deerflow.sandbox.local:LocalSandboxProvider",
"ownership": {"type": "redis", "redis_url": "redis://prod-redis:6379/3", "renewal_interval_seconds": 7.5, "ttl_multiplier": 3},
}
}
config, notes = _overlay(base)
assert config["sandbox"]["ownership"] == {"type": "redis", "redis_url": REDIS_URL, "renewal_interval_seconds": 7.5, "ttl_multiplier": 3}
assert any("sandbox.ownership" in note for note in notes)
def test_overlay_gives_local_aio_sandboxes_a_harness_container_prefix():
base = {"sandbox": {"use": "deerflow.community.aio_sandbox:AioSandboxProvider", "container_prefix": "deer-flow-sandbox"}}
config, notes = _overlay(base)
assert config["sandbox"]["container_prefix"] == SANDBOX_PREFIX
assert any("container_prefix" in note for note in notes)
# Omitted prefix (the provider default) is replaced too.
config, _notes = _overlay({"sandbox": {"use": "deerflow.community.aio_sandbox:AioSandboxProvider"}})
assert config["sandbox"]["container_prefix"] == SANDBOX_PREFIX
def test_overlay_leaves_provisioner_backed_aio_prefixes_alone():
base = {"sandbox": {"use": "deerflow.community.aio_sandbox:AioSandboxProvider", "provisioner_url": "http://provisioner:8002"}}
config, _notes = _overlay(base)
assert "container_prefix" not in config["sandbox"]
def test_sandbox_container_prefix_is_stable_per_state_dir(tmp_path):
first = mi.sandbox_container_prefix(tmp_path / "a")
assert first == mi.sandbox_container_prefix(tmp_path / "a")
assert first != mi.sandbox_container_prefix(tmp_path / "b")
assert first.startswith("deer-flow-mi-") and first.endswith("-sandbox")
assert all(ch.isalnum() or ch in "-_." for ch in first)
@pytest.mark.parametrize(
"memory",
[
{"backend_config": {"storage_path": "/srv/real-memory"}},
{"storage_path": "/srv/real-memory"}, # legacy pre-abstraction spelling, auto-migrated on load
],
)
def test_overlay_keeps_deermem_data_inside_the_harness_home(memory):
config, notes = _overlay({"memory": memory})
assert "storage_path" not in config["memory"]
assert "storage_path" not in config["memory"]["backend_config"]
assert any("storage_path" in note for note in notes)
def test_overlay_keeps_other_data_roots_inside_the_harness():
base = {
"blob_storage": {"enabled": True, "backend": "local_fs", "backend_config": {"root": "/mnt/shared/deerflow-blobs"}},
"database": {"checkpoint_cache": {"type": "redis", "redis_url": "redis://prod-redis:6379/1", "max_entries": 64}},
}
config, notes = _overlay(base)
assert "root" not in config["blob_storage"]["backend_config"]
assert config["database"]["checkpoint_cache"] == {"type": "redis", "redis_url": REDIS_URL, "max_entries": 64}
joined = "\n".join(notes)
assert "blob_storage" in joined and "checkpoint_cache" in joined
def test_overlay_leaves_non_deermem_memory_backends_alone_but_says_so():
config, notes = _overlay({"memory": {"manager_class": "mem0", "backend_config": {"base_url": "https://mem0.example"}}})
assert config["memory"]["backend_config"] == {"base_url": "https://mem0.example"}
assert any("mem0" in note for note in notes)
# ---------------------------------------------------------------------------
# The generated file against the real loader and startup gate
# ---------------------------------------------------------------------------
def _load_generated(tmp_path: Path, monkeypatch, base_path: Path, *, index_dir: Path) -> AppConfig:
out = tmp_path / "state" / "config.yaml"
extensions = tmp_path / "extensions_config.json"
extensions.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions))
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path / "home"))
monkeypatch.setenv(mi.RETRIEVAL_INDEX_ENV_VAR, str(index_dir))
mi.render_config(base_path, out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path / "state")
return AppConfig.from_file(str(out))
def test_generated_config_from_the_example_passes_the_real_startup_gate(tmp_path, monkeypatch, caplog):
config = _load_generated(tmp_path, monkeypatch, REPO_ROOT / "config.example.yaml", index_dir=tmp_path / "index-a")
assert multi_instance_declaration(config) is not None
_enforce_postgres_for_multi_worker(config) # must not raise SystemExit
with caplog.at_level(logging.WARNING, logger="app.gateway.deps"):
_validate_memory_retrieval_index(config)
assert "retrieval index" not in caplog.text, "each process must keep its index outside the shared storage_path"
assert config.memory.backend_config["retrieval_index_path"] == str(tmp_path / "index-a")
def test_generated_config_from_a_hostile_base_passes_the_real_startup_gate(tmp_path, monkeypatch):
base_path = tmp_path / "base.yaml"
base_path.write_text(
yaml.safe_dump(
{
"config_version": 1,
"models": [],
"tools": [{"name": "browser_navigate", "use": "deerflow.community.browser_automation.tools:browser_navigate_tool", "group": "web"}],
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider", "ownership": {"type": "memory"}},
"scheduler": {"enabled": True},
"database": {"backend": "sqlite", "sqlite_dir": ".deer-flow/data"},
"run_events": {"backend": "jsonl"},
"stream_bridge": {"type": "memory"},
}
),
encoding="utf-8",
)
monkeypatch.setenv(MULTI_INSTANCE_ENV_VAR, "1")
with pytest.raises(SystemExit):
# Sanity: the base itself is refused under a multi-instance declaration.
_enforce_postgres_for_multi_worker(AppConfig.from_file(str(base_path)))
monkeypatch.delenv(MULTI_INSTANCE_ENV_VAR)
config = _load_generated(tmp_path, monkeypatch, base_path, index_dir=tmp_path / "index-b")
_enforce_postgres_for_multi_worker(config)
@pytest.mark.parametrize("keep_channels", [False, True])
@pytest.mark.parametrize("yaml_channel_enabled", [False, True])
def test_generated_config_handles_ui_channels_with_disabled_lease(tmp_path, monkeypatch, keep_channels, yaml_channel_enabled):
base = {
"config_version": 1,
"models": [],
"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"},
"channels": {"leader_lease": {"enabled": False}, "telegram": {"enabled": yaml_channel_enabled, "bot_token": "test-token"}},
"channel_connections": {"enabled": True, "telegram": {"enabled": True, "bot_username": "test_bot"}},
}
base_path = tmp_path / "base.yaml"
base_path.write_text(yaml.safe_dump(base), encoding="utf-8")
_enforce_postgres_for_multi_worker(AppConfig.from_file(str(base_path)))
out = tmp_path / "generated.yaml"
monkeypatch.setenv(mi.RETRIEVAL_INDEX_ENV_VAR, str(tmp_path / "index"))
mi.render_config(base_path, out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path / "state", keep_channels=keep_channels)
config = AppConfig.from_file(str(out))
assert config.model_extra["channels"]["leader_lease"] == {"enabled": False}
if keep_channels:
assert config.channel_connections.telegram.enabled is True
with pytest.raises(SystemExit, match="channels.leader_lease.enabled=false while IM channels telegram"):
_enforce_postgres_for_multi_worker(config)
else:
_enforce_postgres_for_multi_worker(config)
assert config.channel_connections.telegram.enabled is False
def test_each_process_resolves_its_own_retrieval_index(tmp_path, monkeypatch):
out = tmp_path / "config.yaml"
mi.render_config(REPO_ROOT / "config.example.yaml", out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path)
raw = yaml.safe_load(out.read_text(encoding="utf-8"))
resolved = []
for name in ("a", "b"):
monkeypatch.setenv(mi.RETRIEVAL_INDEX_ENV_VAR, str(tmp_path / "index" / name))
resolved.append(AppConfig.resolve_env_variables(raw)["memory"]["backend_config"]["retrieval_index_path"])
assert resolved[0] != resolved[1]
def test_render_config_writes_a_private_file_with_a_generated_header(tmp_path):
out = tmp_path / "nested" / "config.yaml"
notes = mi.render_config(REPO_ROOT / "config.example.yaml", out, database_url=DATABASE_URL, redis_url=REDIS_URL, state_dir=tmp_path)
assert isinstance(notes, list)
text = out.read_text(encoding="utf-8")
assert text.startswith("# Generated by scripts/dev_multi_instance.sh")
assert stat.S_IMODE(out.stat().st_mode) == 0o600, "the base config may carry model API keys"
assert yaml.safe_load(text)["deployment"]["multi_instance"] is True
# ---------------------------------------------------------------------------
# Shared secrets and nginx front
# ---------------------------------------------------------------------------
def test_secrets_file_is_private_complete_and_stable(tmp_path):
path = tmp_path / "secrets.env"
first = mi.ensure_secrets_file(path)
second = mi.ensure_secrets_file(path)
assert first == second, "an existing secrets file is reused, never regenerated"
assert set(first) == set(mi.SHARED_SECRET_NAMES)
assert all(value for value in first.values())
assert len(base64.urlsafe_b64decode(first["DEER_FLOW_CREDENTIALS_KEY"])) == 32, "must be a raw Fernet key"
assert first["AUTH_JWT_SECRET"] != first["DEER_FLOW_INTERNAL_AUTH_TOKEN"]
assert stat.S_IMODE(path.stat().st_mode) == 0o600
# Shell-sourceable KEY=value lines.
assert sorted(line.split("=", 1)[0] for line in path.read_text(encoding="utf-8").splitlines() if line and not line.startswith("#")) == sorted(mi.SHARED_SECRET_NAMES)
def test_nginx_conf_balances_both_gateways(tmp_path):
text = mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=tmp_path)
assert "listen 127.0.0.1:2027;" in text
assert "server 127.0.0.1:8001;" in text
assert "server 127.0.0.1:8011;" in text
assert "X-DeerFlow-Upstream $upstream_addr" in text
assert f'"{tmp_path / "run" / "nginx.pid"}"' in text
def test_nginx_conf_quotes_paths_with_whitespace(tmp_path):
state = tmp_path / "mi state"
text = mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=state)
assert f'pid "{state / "run" / "nginx.pid"}";' in text
assert f'access_log "{state / "logs" / "nginx-access.log"}";' in text
assert f'proxy_temp_path "{state / "nginx" / "temp" / "proxy"}";' in text
def test_nginx_conf_rejects_paths_nginx_would_interpolate(tmp_path):
with pytest.raises(ValueError):
mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001], frontend_port=3000, state_dir=tmp_path / "$HOME")
@pytest.mark.skipif(shutil.which("nginx") is None, reason="nginx is not installed")
@pytest.mark.parametrize("state_name", ["state", "mi state"])
def test_nginx_conf_is_accepted_by_nginx(tmp_path, state_name):
state = tmp_path / state_name
for sub in ("run", "logs", "nginx/temp"): # the shell creates these before nginx starts
(state / sub).mkdir(parents=True)
conf = state / "nginx" / "nginx.conf"
conf.write_text(mi.render_nginx_conf(listen_port=2027, gateway_ports=[8001, 8011], frontend_port=3000, state_dir=state), encoding="utf-8")
result = subprocess.run(["nginx", "-t", "-p", str(state / "nginx"), "-c", str(conf)], capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
# ---------------------------------------------------------------------------
# Check helpers
# ---------------------------------------------------------------------------
def test_parse_sse_events_keeps_ids_and_event_names():
lines = [
": comment",
"id: 1-0",
"event: metadata",
'data: {"run_id": "r"}',
"",
"event: heartbeat",
"data: {}",
"",
"id: 2-0",
"event: end",
"data: null",
"",
]
events = list(mi.parse_sse_events(lines))
assert [(event.id, event.event) for event in events] == [("1-0", "metadata"), (None, "heartbeat"), ("2-0", "end")]
assert events[0].data == '{"run_id": "r"}'
def test_check_exit_code_reflects_failures_only():
assert mi.exit_code([mi.CheckResult("a", mi.PASS, ""), mi.CheckResult("b", mi.SKIP, "")]) == 0
assert mi.exit_code([mi.CheckResult("a", mi.PASS, ""), mi.CheckResult("b", mi.FAIL, "")]) == 1
def _offline_harness(tmp_path, handler, **kwargs):
import httpx
h = mi.Harness(gateways=[("A", "http://a.test"), ("B", "http://b.test")], nginx_url=None, state_dir=tmp_path, with_llm=False, timeout=5.0, **kwargs)
h.http = httpx.Client(transport=httpx.MockTransport(handler))
return h
def test_sse_resume_never_starts_a_run_when_model_discovery_failed(tmp_path):
def refuse(request):
raise AssertionError(f"unexpected request {request.method} {request.url}")
h = _offline_harness(tmp_path, refuse)
h.thread_id = "t-1"
h.models = None # the internal-token check failed or was skipped
with pytest.raises(mi.CheckSkipped, match="--with-llm"):
mi._check_sse_resume(h)
def test_sse_read_enforces_its_budget_while_only_heartbeats_arrive(tmp_path):
import time as _time
def heartbeats():
# A live idle run: comment heartbeats only, for far longer than the budget.
for _ in range(300):
_time.sleep(0.01)
yield b": heartbeat\n\n"
import httpx
h = _offline_harness(tmp_path, lambda request: httpx.Response(200, headers={"content-type": "text/event-stream"}, content=heartbeats()))
started = _time.monotonic()
with pytest.raises(mi.CheckFailed, match="budget"):
mi._read_stream(h, "http://a.test", "/api/threads/t/runs/r/join", budget=0.3)
assert _time.monotonic() - started < 2.0
# ---------------------------------------------------------------------------
# Shell entry point smoke tests (no Docker, no processes)
# ---------------------------------------------------------------------------
def test_shell_script_parses():
result = subprocess.run(["bash", "-n", str(SHELL_PATH)], capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
@pytest.mark.parametrize("flag", ["--help", "help", "-h"])
def test_shell_script_help_lists_every_command_without_side_effects(tmp_path, flag):
env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state"), "PATH": os.environ.get("PATH", "")}
result = subprocess.run(["bash", str(SHELL_PATH), flag], capture_output=True, text=True, timeout=30, env=env)
assert result.returncode == 0, result.stderr
for command in ("up", "down", "status", "logs", "check", "restart"):
assert f" {command}" in result.stdout
assert not (tmp_path / "state").exists(), "help must not create harness state"
def test_shell_script_rejects_unknown_commands(tmp_path):
env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state")}
result = subprocess.run(["bash", str(SHELL_PATH), "frobnicate"], capture_output=True, text=True, timeout=30, env=env)
assert result.returncode != 0
assert "Unknown command" in result.stderr
def test_makefile_exposes_harness_targets():
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
for target in ("dev-multi", "dev-multi-check", "dev-multi-down"):
assert f"\n{target}:" in makefile
assert f"make {target} " in makefile, f"`make help` should list {target}"
# ---------------------------------------------------------------------------
# Shell lifecycle against stubbed docker / uv / lsof and a fake Gateway
# ---------------------------------------------------------------------------
_DOCKER_STUB = r"""#!/usr/bin/env bash
dir="$(cd "$(dirname "$0")" && pwd)"
state="$dir/containers"
mkdir -p "$state"
{ printf 'docker'; printf ' %s' "$@"; printf '\n'; } >>"$dir/docker.log"
cmd="$1"
shift
case "$cmd" in
info) exit 0 ;;
inspect)
fmt=""
if [ "$1" = "-f" ]; then fmt="$2"; shift 2; fi
[ -d "$state/$1" ] || exit 1
case "$fmt" in
*state-dir*) cat "$state/$1/deerflow.harness.state-dir" 2>/dev/null; echo ;;
*deerflow.harness*) cat "$state/$1/deerflow.harness" 2>/dev/null; echo ;;
*) echo running ;;
esac
;;
run)
name=""
labels=""
while [ $# -gt 0 ]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--label) labels="$labels$2
"; shift 2 ;;
-e | -p) shift 2 ;;
*) shift ;;
esac
done
mkdir -p "$state/$name"
printf '%s' "$labels" | while IFS= read -r kv; do
[ -n "$kv" ] && printf '%s' "${kv#*=}" >"$state/$name/${kv%%=*}"
done
echo "id-$name"
;;
exec)
case "$*" in *redis-cli*) echo PONG ;; esac
;;
rm)
for arg in "$@"; do
case "$arg" in -*) ;; *) rm -rf "$state/$arg" ;; esac
done
;;
esac
exit 0
"""
_UV_STUB = r"""#!/usr/bin/env bash
dir="$(cd "$(dirname "$0")" && pwd)"
case "$*" in
*"print(sys.executable)"*) echo "$dir/python" ;;
esac
exit 0
"""
_PYTHON_STUB = r"""#!/usr/bin/env bash
dir="$(cd "$(dirname "$0")" && pwd)"
if [ "${1:-}" = "-m" ] && [ "${2:-}" = "uvicorn" ]; then
port=""
previous=""
for arg in "$@"; do
[ "$previous" = "--port" ] && port="$arg"
previous="$arg"
done
exec "$REAL_PYTHON" "$dir/fake_gateway.py" app.gateway.app:app "$port"
fi
exec "$REAL_PYTHON" "$@"
"""
_FAKE_GATEWAY = """import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"{}")
def log_message(self, *args):
pass
HTTPServer(("127.0.0.1", int(sys.argv[2])), Handler).serve_forever()
"""
# Ports listed in busy-from-<port> report busy from that call number on; every
# other query falls through to the real lsof (or "free" when it is missing).
_LSOF_STUB = r"""#!/usr/bin/env bash
dir="$(cd "$(dirname "$0")" && pwd)"
port=""
for arg in "$@"; do
case "$arg" in -iTCP:*) port="${arg#-iTCP:}" ;; esac
done
count=$(( $(cat "$dir/lsof-count-$port" 2>/dev/null || echo 0) + 1 ))
echo "$count" >"$dir/lsof-count-$port"
if [ -f "$dir/busy-from-$port" ] && [ "$count" -ge "$(cat "$dir/busy-from-$port")" ]; then
echo 99999
exit 0
fi
[ -n "$REAL_LSOF" ] || exit 1
exec "$REAL_LSOF" "$@"
"""
def _free_port() -> int:
import socket
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
@pytest.fixture
def stubbed_harness(tmp_path):
if os.name == "nt" or shutil.which("bash") is None or shutil.which("curl") is None:
pytest.skip("POSIX shell lifecycle test")
stubs = tmp_path / "stubs"
stubs.mkdir()
for name, body in (("docker", _DOCKER_STUB), ("uv", _UV_STUB), ("python", _PYTHON_STUB), ("lsof", _LSOF_STUB), ("fake_gateway.py", _FAKE_GATEWAY)):
path = stubs / name
path.write_text(body, encoding="utf-8")
path.chmod(0o755)
state = tmp_path / "mi state" # whitespace on purpose
ports = {"a": _free_port(), "b": _free_port(), "pg": _free_port(), "redis": _free_port()}
env = {key: value for key, value in os.environ.items() if not key.startswith(("DEERFLOW_MI_", "DEER_FLOW_"))}
env.update(
PATH=f"{stubs}{os.pathsep}{os.environ.get('PATH', '')}",
REAL_PYTHON=sys.executable,
REAL_LSOF=shutil.which("lsof") or "",
DEERFLOW_MI_STATE_DIR=str(state),
DEERFLOW_MI_BASE_CONFIG=str(REPO_ROOT / "config.example.yaml"),
DEERFLOW_MI_GATEWAY_A_PORT=str(ports["a"]),
DEERFLOW_MI_GATEWAY_B_PORT=str(ports["b"]),
DEERFLOW_MI_POSTGRES_PORT=str(ports["pg"]),
DEERFLOW_MI_REDIS_PORT=str(ports["redis"]),
DEERFLOW_MI_HEALTH_TIMEOUT="20",
)
def run(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["bash", str(SHELL_PATH), *args], capture_output=True, text=True, timeout=120, env=env)
yield stubs, state, ports, run
# Never leak a fake Gateway, whatever the script did.
for pid_file in (state / "run").glob("*.pid") if (state / "run").is_dir() else []:
try:
os.kill(int(pid_file.read_text().strip()), 9)
except (ValueError, ProcessLookupError):
pass
def _port_open(port: int) -> bool:
import socket
with socket.socket() as sock:
sock.settimeout(0.5)
return sock.connect_ex(("127.0.0.1", port)) == 0
def _container(stubs: Path, name: str, *, state_dir: str) -> Path:
path = stubs / "containers" / name
path.mkdir(parents=True)
(path / "deerflow.harness").write_text("dev-multi-instance", encoding="utf-8")
(path / "deerflow.harness.state-dir").write_text(state_dir, encoding="utf-8")
return path
def test_up_tears_everything_down_when_b_loses_its_port_after_preflight(stubbed_harness):
stubs, state, ports, run = stubbed_harness
(stubs / f"busy-from-{ports['b']}").write_text("2", encoding="utf-8") # free at preflight, taken when B starts
result = run("up", "--skip-install", "--no-nginx")
assert result.returncode != 0
assert f"Port {ports['b']} is in use" in result.stderr
assert "Gateway A ready" in result.stdout, "A must have started before B failed"
assert not _port_open(ports["a"]), "Gateway A must be stopped when up fails"
assert "Terminated" not in result.stderr, "the Gateway is not a job of the script shell; killing it must not print job notices"
assert not list((stubs / "containers").iterdir()), "both containers must be removed"
removals = [line for line in (stubs / "docker.log").read_text().splitlines() if line.startswith("docker rm")]
assert removals and all(line.startswith("docker rm -f -v ") for line in removals), removals
def test_up_refuses_containers_owned_by_another_state_dir(stubbed_harness):
stubs, state, _ports, run = stubbed_harness
foreign = _container(stubs, "deerflow-mi-postgres", state_dir="/elsewhere/.deer-flow/multi-instance")
result = run("up", "--skip-install", "--no-nginx")
assert result.returncode != 0
assert "/elsewhere/.deer-flow/multi-instance" in result.stderr
assert foreign.is_dir()
commands = (stubs / "docker.log").read_text()
assert "docker rm" not in commands and "docker run" not in commands
down = run("down")
assert foreign.is_dir(), "down must not remove another harness's container either"
assert "docker rm" not in (stubs / "docker.log").read_text(), down.stdout + down.stderr
def test_up_replaces_its_own_stale_containers_with_their_volumes(stubbed_harness):
stubs, state, ports, run = stubbed_harness
_container(stubs, "deerflow-mi-redis", state_dir=str(state))
(stubs / f"busy-from-{ports['b']}").write_text("2", encoding="utf-8") # stop after A so the test stays short
run("up", "--skip-install", "--no-nginx")
log = (stubs / "docker.log").read_text().splitlines()
first_run = next(i for i, line in enumerate(log) if line.startswith("docker run"))
assert "docker rm -f -v deerflow-mi-redis" in log[:first_run]
labelled = [line for line in log if line.startswith("docker run")]
assert all(f"--label deerflow.harness.state-dir={state}" in line for line in labelled), labelled
def test_overlay_leaves_the_channel_leader_lease_block_alone():
"""``channels.leader_lease`` is settings (``enabled: auto``), not a bot to disable."""
config, notes = _overlay({"channels": {"leader_lease": {"enabled": "auto", "lease_seconds": 15}, "telegram": {"enabled": True}}})
assert config["channels"]["leader_lease"] == {"enabled": "auto", "lease_seconds": 15}
assert config["channels"]["telegram"]["enabled"] is False
joined = "\n".join(notes)
assert "telegram" in joined
assert "leader_lease" not in joined