From ec16b6650d2f6fa534bea07cbb6d432679a60f5b Mon Sep 17 00:00:00 2001 From: Huixin615 Date: Wed, 17 Jun 2026 22:57:46 +0800 Subject: [PATCH] fix(channel): force reload config on channel restart (#3619) * fix: force reload config on channel restart * fix: detect config content changes for reload --- backend/CLAUDE.md | 2 +- backend/app/channels/service.py | 5 ++- .../harness/deerflow/config/app_config.py | 39 ++++++++++++++++--- backend/tests/test_app_config_reload.py | 39 +++++++++++++++++++ backend/tests/test_channels.py | 8 ++-- 5 files changed, 79 insertions(+), 14 deletions(-) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index e978e8712..ae1e63eae 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -230,7 +230,7 @@ Setup: Copy `config.example.yaml` to `config.yaml` in the **project root** direc **Config Versioning**: `config.example.yaml` has a `config_version` field. On startup, `AppConfig.from_file()` compares user version vs example version and emits a warning if outdated. Missing `config_version` = version 0. Run `make config-upgrade` to auto-merge missing fields. When changing the config schema, bump `config_version` in `config.example.yaml`. -**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path changes or the file's mtime increases. This keeps Gateway and LangGraph reads aligned with `config.yaml` edits without requiring a manual process restart. +**Config Caching**: `get_app_config()` caches the parsed config, but automatically reloads it when the resolved config path or file content signature changes. The signature includes file metadata and a content digest, so Gateway and LangGraph reads stay aligned with `config.yaml` edits even on object-store or network mounts where mtime can remain stale. **Config Hot-Reload Boundary**: Gateway dependencies route through `get_app_config()` on every request, so per-run fields like `models[*].max_tokens`, `summarization.*`, `title.*`, `memory.*`, `subagents.*`, `tools[*]`, and the agent system prompt pick up `config.yaml` edits on the next message. `AppConfig` is intentionally **not** cached on `app.state` — `lifespan()` keeps a local `startup_config` variable for one-shot bootstrap work and passes it to `langgraph_runtime(app, startup_config)`. diff --git a/backend/app/channels/service.py b/backend/app/channels/service.py index b689c9651..b6bdf647f 100644 --- a/backend/app/channels/service.py +++ b/backend/app/channels/service.py @@ -234,8 +234,9 @@ class ChannelService: def _load_channel_config(self, name: str) -> dict[str, Any] | None: """Load the latest config for a specific channel from disk. - Uses ``get_app_config()`` which detects file changes via mtime, - so edits to ``config.yaml`` are picked up without a process restart. + Uses ``get_app_config()`` which detects file changes via config + signature, so edits to ``config.yaml`` are picked up without a process + restart. The UI runtime-config overlay applied at startup is re-applied here so a file-driven reload neither drops credentials entered from the browser nor resurrects a channel disconnected from it. diff --git a/backend/packages/harness/deerflow/config/app_config.py b/backend/packages/harness/deerflow/config/app_config.py index 311824782..b114b2cdf 100644 --- a/backend/packages/harness/deerflow/config/app_config.py +++ b/backend/packages/harness/deerflow/config/app_config.py @@ -1,3 +1,4 @@ +import hashlib import logging import os from collections.abc import Mapping @@ -400,6 +401,8 @@ class AppConfig(BaseModel): _app_config: AppConfig | None = None _app_config_path: Path | None = None _app_config_mtime: float | None = None +_ConfigSignature = tuple[float | None, int | None, str | None] +_app_config_signature: _ConfigSignature | None = None _app_config_is_custom = False _current_app_config: ContextVar[AppConfig | None] = ContextVar("deerflow_current_app_config", default=None) _current_app_config_stack: ContextVar[tuple[AppConfig | None, ...]] = ContextVar("deerflow_current_app_config_stack", default=()) @@ -413,14 +416,33 @@ def _get_config_mtime(config_path: Path) -> float | None: return None +def _get_config_signature(config_path: Path) -> _ConfigSignature | None: + """Get cache metadata for a config file, including a content digest.""" + try: + stat_result = config_path.stat() + except OSError: + return None + + digest = hashlib.sha256() + try: + with config_path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + digest.update(chunk) + except OSError: + return (stat_result.st_mtime, stat_result.st_size, None) + + return (stat_result.st_mtime, stat_result.st_size, digest.hexdigest()) + + def _load_and_cache_app_config(config_path: str | None = None) -> AppConfig: """Load config from disk and refresh cache metadata.""" - global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom + global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom resolved_path = AppConfig.resolve_config_path(config_path) _app_config = AppConfig.from_file(str(resolved_path)) _app_config_path = resolved_path _app_config_mtime = _get_config_mtime(resolved_path) + _app_config_signature = _get_config_signature(resolved_path) _app_config_is_custom = False return _app_config @@ -429,11 +451,11 @@ def get_app_config() -> AppConfig: """Get the DeerFlow config instance. Returns a cached singleton instance and automatically reloads it when the - underlying config file path or modification time changes. Use + underlying config file path or content signature changes. Use `reload_app_config()` to force a reload, or `reset_app_config()` to clear the cache. """ - global _app_config, _app_config_path, _app_config_mtime + global _app_config, _app_config_path, _app_config_mtime, _app_config_signature runtime_override = _current_app_config.get() if runtime_override is not None: @@ -444,8 +466,9 @@ def get_app_config() -> AppConfig: resolved_path = AppConfig.resolve_config_path() current_mtime = _get_config_mtime(resolved_path) + current_signature = _get_config_signature(resolved_path) - should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_mtime != current_mtime + should_reload = _app_config is None or _app_config_path != resolved_path or _app_config_signature != current_signature if should_reload: if _app_config_path == resolved_path and _app_config_mtime is not None and current_mtime is not None and _app_config_mtime != current_mtime: logger.info( @@ -453,6 +476,8 @@ def get_app_config() -> AppConfig: _app_config_mtime, current_mtime, ) + elif _app_config_path == resolved_path and _app_config_signature != current_signature: + logger.info("Config file content signature changed, reloading AppConfig") _load_and_cache_app_config(str(resolved_path)) return _app_config @@ -480,10 +505,11 @@ def reset_app_config() -> None: `get_app_config()` to reload from file. Useful for testing or when switching between different configurations. """ - global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom + global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom _app_config = None _app_config_path = None _app_config_mtime = None + _app_config_signature = None _app_config_is_custom = False @@ -495,10 +521,11 @@ def set_app_config(config: AppConfig) -> None: Args: config: The AppConfig instance to use. """ - global _app_config, _app_config_path, _app_config_mtime, _app_config_is_custom + global _app_config, _app_config_path, _app_config_mtime, _app_config_signature, _app_config_is_custom _app_config = config _app_config_path = None _app_config_mtime = None + _app_config_signature = None _app_config_is_custom = True diff --git a/backend/tests/test_app_config_reload.py b/backend/tests/test_app_config_reload.py index 7a7fd02df..ca98759bd 100644 --- a/backend/tests/test_app_config_reload.py +++ b/backend/tests/test_app_config_reload.py @@ -216,6 +216,45 @@ def test_get_app_config_reloads_when_file_changes(tmp_path, monkeypatch): reset_app_config() +def test_get_app_config_reloads_when_content_digest_changes_without_metadata(tmp_path, monkeypatch): + config_path = tmp_path / "config.yaml" + extensions_path = tmp_path / "extensions_config.json" + _write_extensions_config(extensions_path) + _write_config(config_path, model_name="model-a", supports_thinking=False) + + monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path)) + monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path)) + _reset_config_singletons() + + try: + initial = get_app_config() + initial_mtime = app_config_module._app_config_mtime + initial_signature = app_config_module._app_config_signature + assert initial.models[0].name == "model-a" + assert initial_signature is not None + + _write_config(config_path, model_name="model-b", supports_thinking=False) + + real_get_config_signature = app_config_module._get_config_signature + + def stale_metadata_signature(path: Path): + current_signature = real_get_config_signature(path) + assert current_signature is not None + return (initial_signature[0], initial_signature[1], current_signature[2]) + + monkeypatch.setattr(app_config_module, "_get_config_mtime", lambda _path: initial_mtime) + monkeypatch.setattr(app_config_module, "_get_config_signature", stale_metadata_signature) + + reloaded = get_app_config() + assert reloaded.models[0].name == "model-b" + assert reloaded is not initial + assert app_config_module._app_config_signature is not None + assert app_config_module._app_config_signature[:2] == initial_signature[:2] + assert app_config_module._app_config_signature[2] != initial_signature[2] + finally: + _reset_config_singletons() + + def test_get_app_config_reloads_when_config_path_changes(tmp_path, monkeypatch): config_a = tmp_path / "config-a.yaml" config_b = tmp_path / "config-b.yaml" diff --git a/backend/tests/test_channels.py b/backend/tests/test_channels.py index cdfd0d8db..5c99f7c9f 100644 --- a/backend/tests/test_channels.py +++ b/backend/tests/test_channels.py @@ -4474,12 +4474,10 @@ class TestChannelService: """ from app.channels.service import ChannelService - stale_file_config = {"feishu": {"enabled": True, "app_id": "file_id", "app_secret": "file_secret"}} + def fail_get_app_config(): + raise AssertionError("configure_channel must not reload file config") - def mock_get_app_config(): - return SimpleNamespace(model_extra={"channels": stale_file_config}) - - monkeypatch.setattr("deerflow.config.app_config.get_app_config", mock_get_app_config) + monkeypatch.setattr("deerflow.config.app_config.get_app_config", fail_get_app_config) service = ChannelService(channels_config={}) service._running = True