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:
voidborne-d 2026-04-23 20:07:30 +08:00
parent 68fdac8a05
commit 7c69226bcf
4 changed files with 213 additions and 11 deletions

View File

@ -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

View File

@ -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

View File

@ -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),
)

View 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