mirror of
https://github.com/OpenBMB/ChatDev.git
synced 2026-05-22 16:34:20 +00:00
fix(server): scope --reload watching to source dirs (#569)
When ``python server_main.py --reload`` was used, uvicorn's default reload_dirs is the current working directory and StatReload walks the whole tree for ``*.py`` files. Agent-generated code under ``WareHouse/session_<uuid>/code_workspace/<file>.py`` therefore triggers a server restart mid-workflow; the webui is left waiting indefinitely and the in-flight session is cancelled. The project already ships a warning in both READMEs telling users to drop ``--reload``, but that is exactly the tool dev loops need. Fix: pass an explicit ``reload_dirs`` list containing only the server's Python source folders (check, entity, functions, mcp_example, runtime, schema_registry, server, tools, utils, workflow) and a matching ``reload_excludes`` set (WareHouse, logs, data, temp, node_modules) so watchfiles-backed installs also stop observing output directories. Users can override either list via repeatable ``--reload-dir`` and ``--reload-exclude`` flags. The reload-kwargs construction is extracted into a pure helper so the behaviour is unit-tested without spinning up a real server; nine new tests cover the default behaviour, user overrides, argparse wiring, and that the returned lists are defensive copies. README / README-zh have been updated to reflect the new default. Fixes #569
This commit is contained in:
parent
68fdac8a05
commit
7c69226bcf
@ -136,7 +136,7 @@ make dev
|
|||||||
# 从项目根目录运行
|
# 从项目根目录运行
|
||||||
uv run python server_main.py --port 6400 --reload
|
uv run python server_main.py --port 6400 --reload
|
||||||
```
|
```
|
||||||
> 若输出文件(如 GameDev)触发重启导致任务中断、进度丢失,请去掉 `--reload`。
|
> `--reload` 仅监听服务端 Python 源代码目录,`WareHouse/` 下的智能体生成文件不会再触发重启。可通过 `--reload-dir` / `--reload-exclude`(可多次指定)自定义。
|
||||||
|
|
||||||
2. **启动前端**:
|
2. **启动前端**:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -147,7 +147,7 @@ make dev
|
|||||||
# Run from the project root
|
# Run from the project root
|
||||||
uv run python server_main.py --port 6400 --reload
|
uv run python server_main.py --port 6400 --reload
|
||||||
```
|
```
|
||||||
> Remove `--reload` if output files (e.g., GameDev) trigger restarts, which interrupts tasks and loses progress.
|
> `--reload` watches the server's Python source folders only; agent-generated files under `WareHouse/` no longer trigger restarts. Pass `--reload-dir` or `--reload-exclude` (repeatable) to customise.
|
||||||
|
|
||||||
2. **Start Frontend**:
|
2. **Start Frontend**:
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -9,9 +9,50 @@ from server.app import app
|
|||||||
ensure_schema_registry_populated()
|
ensure_schema_registry_populated()
|
||||||
|
|
||||||
|
|
||||||
def main():
|
# Directories containing the server's Python sources. When --reload is
|
||||||
import uvicorn
|
# enabled, only these are watched so that agent-generated files under
|
||||||
|
# WareHouse/, logs/, etc. never trigger a StatReload restart mid-workflow
|
||||||
|
# (issue #569).
|
||||||
|
RELOAD_SOURCE_DIRS = [
|
||||||
|
"check",
|
||||||
|
"entity",
|
||||||
|
"functions",
|
||||||
|
"mcp_example",
|
||||||
|
"runtime",
|
||||||
|
"schema_registry",
|
||||||
|
"server",
|
||||||
|
"tools",
|
||||||
|
"utils",
|
||||||
|
"workflow",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Glob patterns excluded from reload watching. Only has effect when
|
||||||
|
# ``watchfiles`` is installed (StatReload ignores these patterns), but
|
||||||
|
# matches .gitignore intent as a second line of defence.
|
||||||
|
RELOAD_EXCLUDES = [
|
||||||
|
"WareHouse/*",
|
||||||
|
"logs/*",
|
||||||
|
"data/*",
|
||||||
|
"temp/*",
|
||||||
|
"node_modules/*",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_reload_kwargs(args: argparse.Namespace) -> dict:
|
||||||
|
"""Build the reload-related kwargs passed to ``uvicorn.run``.
|
||||||
|
|
||||||
|
Extracted so the configuration is unit-testable without spinning up
|
||||||
|
a real server. When ``--reload`` is off the return value is empty.
|
||||||
|
"""
|
||||||
|
if not args.reload:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"reload_dirs": list(args.reload_dir) if args.reload_dir else list(RELOAD_SOURCE_DIRS),
|
||||||
|
"reload_excludes": list(args.reload_exclude) if args.reload_exclude else list(RELOAD_EXCLUDES),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
parser = argparse.ArgumentParser(description="DevAll Workflow Server")
|
parser = argparse.ArgumentParser(description="DevAll Workflow Server")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--host",
|
"--host",
|
||||||
@ -36,17 +77,43 @@ def main():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Enable auto-reload for development"
|
help="Enable auto-reload for development"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
args = parser.parse_args()
|
"--reload-dir",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
metavar="DIR",
|
||||||
|
help=(
|
||||||
|
"Directory to watch when --reload is active (repeatable). "
|
||||||
|
"Defaults to the server's Python source folders, which excludes "
|
||||||
|
"WareHouse/ and other output dirs."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reload-exclude",
|
||||||
|
action="append",
|
||||||
|
default=None,
|
||||||
|
metavar="GLOB",
|
||||||
|
help=(
|
||||||
|
"Glob pattern excluded from reload watching (repeatable). "
|
||||||
|
"Requires watchfiles to take effect."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
|
||||||
# Configure structured logging
|
# Configure structured logging
|
||||||
import os
|
import os
|
||||||
os.environ['LOG_LEVEL'] = args.log_level.upper()
|
os.environ['LOG_LEVEL'] = args.log_level.upper()
|
||||||
|
|
||||||
# Ensure log directory exists
|
# Ensure log directory exists
|
||||||
log_dir = Path("logs")
|
log_dir = Path("logs")
|
||||||
log_dir.mkdir(exist_ok=True)
|
log_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=getattr(logging, args.log_level.upper()),
|
level=getattr(logging, args.log_level.upper()),
|
||||||
@ -56,10 +123,10 @@ def main():
|
|||||||
logging.StreamHandler()
|
logging.StreamHandler()
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.info(f"Starting DevAll Workflow Server on {args.host}:{args.port}")
|
logger.info(f"Starting DevAll Workflow Server on {args.host}:{args.port}")
|
||||||
|
|
||||||
# Launch the server
|
# Launch the server
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
"server.app:app",
|
"server.app:app",
|
||||||
@ -68,6 +135,7 @@ def main():
|
|||||||
reload=args.reload,
|
reload=args.reload,
|
||||||
log_level=args.log_level,
|
log_level=args.log_level,
|
||||||
ws="wsproto",
|
ws="wsproto",
|
||||||
|
**build_reload_kwargs(args),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
134
tests/test_server_main_reload.py
Normal file
134
tests/test_server_main_reload.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""Tests for ``server_main.build_reload_kwargs``.
|
||||||
|
|
||||||
|
Regression coverage for issue #569: when ``--reload`` is active the default
|
||||||
|
watch configuration must exclude the WareHouse/ output directory, otherwise
|
||||||
|
agent-generated files trigger a StatReload restart mid-workflow and the
|
||||||
|
webui hangs indefinitely.
|
||||||
|
|
||||||
|
The tests load ``server_main`` through an isolated ``importlib`` spec so
|
||||||
|
that the stubs we inject for its heavy dependencies (``runtime.bootstrap``
|
||||||
|
and ``server.app``) are cleaned up automatically and do not leak into the
|
||||||
|
``sys.modules`` cache shared with the rest of the test suite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
SERVER_MAIN_PATH = Path(__file__).resolve().parent.parent / "server_main.py"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def server_main(monkeypatch: pytest.MonkeyPatch) -> ModuleType:
|
||||||
|
"""Load ``server_main`` with heavy imports stubbed, cleaning up after."""
|
||||||
|
stubs = {}
|
||||||
|
|
||||||
|
def _stub(name: str) -> ModuleType:
|
||||||
|
stub = MagicMock(name=name)
|
||||||
|
stubs[name] = stub
|
||||||
|
return stub
|
||||||
|
|
||||||
|
for name in (
|
||||||
|
"runtime",
|
||||||
|
"runtime.bootstrap",
|
||||||
|
"runtime.bootstrap.schema",
|
||||||
|
"server",
|
||||||
|
"server.app",
|
||||||
|
):
|
||||||
|
monkeypatch.setitem(sys.modules, name, _stub(name))
|
||||||
|
|
||||||
|
# Expose ensure_schema_registry_populated as a no-op callable on its module.
|
||||||
|
stubs["runtime.bootstrap.schema"].ensure_schema_registry_populated = lambda: None
|
||||||
|
stubs["server.app"].app = MagicMock(name="app")
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"server_main_under_test", SERVER_MAIN_PATH
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
yield module
|
||||||
|
finally:
|
||||||
|
sys.modules.pop("server_main_under_test", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _args(**overrides) -> argparse.Namespace:
|
||||||
|
defaults = dict(
|
||||||
|
reload=False,
|
||||||
|
reload_dir=None,
|
||||||
|
reload_exclude=None,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return argparse.Namespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildReloadKwargs:
|
||||||
|
def test_reload_disabled_returns_empty(self, server_main):
|
||||||
|
assert server_main.build_reload_kwargs(_args(reload=False)) == {}
|
||||||
|
|
||||||
|
def test_default_watches_source_dirs_only(self, server_main):
|
||||||
|
kw = server_main.build_reload_kwargs(_args(reload=True))
|
||||||
|
assert "server" in kw["reload_dirs"]
|
||||||
|
assert "runtime" in kw["reload_dirs"]
|
||||||
|
# The output dir that caused the bug must not be watched.
|
||||||
|
assert "WareHouse" not in kw["reload_dirs"]
|
||||||
|
|
||||||
|
def test_default_excludes_cover_output_dirs(self, server_main):
|
||||||
|
kw = server_main.build_reload_kwargs(_args(reload=True))
|
||||||
|
excludes = kw["reload_excludes"]
|
||||||
|
# Core regression: WareHouse/ writes must be ignored by watchfiles.
|
||||||
|
assert any("WareHouse" in p for p in excludes)
|
||||||
|
# Logs, data, temp, node_modules live in .gitignore too.
|
||||||
|
assert any("logs" in p for p in excludes)
|
||||||
|
|
||||||
|
def test_returned_lists_are_copies(self, server_main):
|
||||||
|
"""Callers mutating the result must not poison the defaults."""
|
||||||
|
kw = server_main.build_reload_kwargs(_args(reload=True))
|
||||||
|
kw["reload_dirs"].append("WareHouse")
|
||||||
|
kw["reload_excludes"].append("junk")
|
||||||
|
fresh = server_main.build_reload_kwargs(_args(reload=True))
|
||||||
|
assert "WareHouse" not in fresh["reload_dirs"]
|
||||||
|
assert "junk" not in fresh["reload_excludes"]
|
||||||
|
|
||||||
|
def test_user_reload_dir_overrides_default(self, server_main):
|
||||||
|
kw = server_main.build_reload_kwargs(
|
||||||
|
_args(reload=True, reload_dir=["app", "lib"])
|
||||||
|
)
|
||||||
|
assert kw["reload_dirs"] == ["app", "lib"]
|
||||||
|
# Excludes keep their defaults.
|
||||||
|
assert any("WareHouse" in p for p in kw["reload_excludes"])
|
||||||
|
|
||||||
|
def test_user_reload_exclude_overrides_default(self, server_main):
|
||||||
|
kw = server_main.build_reload_kwargs(
|
||||||
|
_args(reload=True, reload_exclude=["*.md"])
|
||||||
|
)
|
||||||
|
assert kw["reload_excludes"] == ["*.md"]
|
||||||
|
# Dirs keep their defaults.
|
||||||
|
assert "server" in kw["reload_dirs"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestParserFlags:
|
||||||
|
def test_reload_dir_is_repeatable(self, server_main):
|
||||||
|
parser = server_main.build_parser()
|
||||||
|
args = parser.parse_args(["--reload", "--reload-dir", "a", "--reload-dir", "b"])
|
||||||
|
assert args.reload_dir == ["a", "b"]
|
||||||
|
|
||||||
|
def test_reload_exclude_is_repeatable(self, server_main):
|
||||||
|
parser = server_main.build_parser()
|
||||||
|
args = parser.parse_args(
|
||||||
|
["--reload", "--reload-exclude", "x/*", "--reload-exclude", "y/*"]
|
||||||
|
)
|
||||||
|
assert args.reload_exclude == ["x/*", "y/*"]
|
||||||
|
|
||||||
|
def test_defaults_produce_empty_override_slots(self, server_main):
|
||||||
|
parser = server_main.build_parser()
|
||||||
|
args = parser.parse_args([])
|
||||||
|
assert args.reload is False
|
||||||
|
assert args.reload_dir is None
|
||||||
|
assert args.reload_exclude is None
|
||||||
Loading…
x
Reference in New Issue
Block a user