diff --git a/README-zh.md b/README-zh.md index 7747626a..a548df64 100755 --- a/README-zh.md +++ b/README-zh.md @@ -136,7 +136,7 @@ make dev # 从项目根目录运行 uv run python server_main.py --port 6400 --reload ``` - > 若输出文件(如 GameDev)触发重启导致任务中断、进度丢失,请去掉 `--reload`。 + > `--reload` 仅监听服务端 Python 源代码目录,`WareHouse/` 下的智能体生成文件不会再触发重启。可通过 `--reload-dir` / `--reload-exclude`(可多次指定)自定义。 2. **启动前端**: ```bash diff --git a/README.md b/README.md index 0c6420d8..237a1f3d 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ make dev # Run from the project root 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**: ```bash diff --git a/server_main.py b/server_main.py index c3418231..1dc752eb 100755 --- a/server_main.py +++ b/server_main.py @@ -9,9 +9,50 @@ from server.app import app ensure_schema_registry_populated() -def main(): - import uvicorn +# Directories containing the server's Python sources. When --reload is +# 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.add_argument( "--host", @@ -36,17 +77,43 @@ def main(): action="store_true", help="Enable auto-reload for development" ) - - args = parser.parse_args() - + parser.add_argument( + "--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 import os os.environ['LOG_LEVEL'] = args.log_level.upper() - + # Ensure log directory exists log_dir = Path("logs") log_dir.mkdir(exist_ok=True) - + # Configure logging logging.basicConfig( level=getattr(logging, args.log_level.upper()), @@ -56,10 +123,10 @@ def main(): logging.StreamHandler() ] ) - + logger = logging.getLogger(__name__) logger.info(f"Starting DevAll Workflow Server on {args.host}:{args.port}") - + # Launch the server uvicorn.run( "server.app:app", @@ -68,6 +135,7 @@ def main(): reload=args.reload, log_level=args.log_level, ws="wsproto", + **build_reload_kwargs(args), ) diff --git a/tests/test_server_main_reload.py b/tests/test_server_main_reload.py new file mode 100644 index 00000000..24b727f6 --- /dev/null +++ b/tests/test_server_main_reload.py @@ -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