diff --git a/Agent.md b/Agent.md index 63fbdb0f..541afb13 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (773) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (777) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (232: 44 daemon_client + 19 conn-manager + 22 app-commands + 110 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 1b090614..05c19eb3 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -1105,12 +1105,25 @@ class TaskScheduler: def __init__(self, identity: InstanceIdentity) -> None: self.identity = identity - self._tasks_file = config_dir() / "tasks.yml" + self._tasks_file: Path | None = None self._handlers: list[TaskHandler] = [] self._coros: list[asyncio.Task] = [] # cfg (from tasks.yml) used to start each handler — for hot-reload diffing. self._handler_cfgs: dict[str, dict] = {} + @property + def _tasks_file(self) -> Path: + """tasks.yml path — resolved lazily so config_dir() patches work + (hermeticity guard #738: tests patch config_dir after construction, + and tests may override _tasks_file directly with a tmp path).""" + if self.__tasks_file is None: + self.__tasks_file = config_dir() / "tasks.yml" + return self.__tasks_file + + @_tasks_file.setter + def _tasks_file(self, value: Path | None) -> None: + self.__tasks_file = value + def _start_handler_for(self, cfg: dict) -> TaskHandler: """Create + start a handler for a task cfg; returns the handler.""" template_path = _resolve_task_template(cfg["type"]) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..b59820fc --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,53 @@ +"""Shared pytest fixtures — hermeticity guards. + +2026-08-13 incident: the full test suite intermittently wrote pytest +temp paths into the real ~/.emrg/projects.yml. The mechanism is a +feedback loop — once a stale entry lands in the file (seeded by the +workspace self-heal family), subsequent tests re-read it and re-write +it, advancing the pytest-N counter on every full run. The daemon's +per-cycle repair (#734) eventually cleans it, but until then the host's +GUI project picker shows a dead path. + +CI finding (PR #738): test_ws_e2e._boot_server only patched +daemon/connect config_dir, but EmrgServer.serve() builds a real +TaskScheduler whose load_and_start() → _ensure_self_evolution_task() +writes config_dir()/projects.yml AND tasks.yml via scheduler.py's own +(unpatched) config_dir → on a fresh runner this hits the real +~/.emrg/ files. + +This autouse fixture makes any write to the REAL ~/.emrg/projects.yml +or ~/.emrg/tasks.yml a hard test failure: the offending test is named +immediately instead of the pollution being discovered later (precedent: +#583 assertPortFileInTmp sandbox guard for the emrgd.port file). +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + +_REAL_CONFIG_FILES = ( + (Path.home() / ".emrg" / "projects.yml").resolve(), + (Path.home() / ".emrg" / "tasks.yml").resolve(), +) + + +@pytest.fixture(autouse=True) +def _guard_real_config_files(monkeypatch): + """Fail any test that writes the real ~/.emrg/projects.yml / tasks.yml.""" + import emrg.server.daemon as daemon_mod + import emrg.server.scheduler as sched_mod + + orig = sched_mod.atomic_write_yaml + assert orig is daemon_mod.atomic_write_yaml, "both modules must share atomic_write_yaml" + + def guarded(data, path, **kwargs): + if Path(path).resolve() in _REAL_CONFIG_FILES: + raise AssertionError( + "test attempted to write a real ~/.emrg config file; " + f"keep tests hermetic (target={path!r})" + ) + return orig(data, path, **kwargs) + + monkeypatch.setattr(sched_mod, "atomic_write_yaml", guarded) + monkeypatch.setattr(daemon_mod, "atomic_write_yaml", guarded) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 0dfa8d50..780aa383 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -216,7 +216,12 @@ def _migrate_wrapper(): def _make_server() -> EmrgServer: """Create a minimal EmrgServer for testing.""" - return EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) + server = EmrgServer(LlmConfig(base_url="http://localhost", api_key="test")) + # Point the project log at a tmp file by default so context/message tests + # can never write the real ~/.emrg/projects.yml (2026-08-13 leak). Tests + # that exercise _projects_log override it explicitly afterwards. + server._projects_log = Path(tempfile.mkdtemp()) / "projects.yml" + return server def test_context_section_no_files(tmp_path): diff --git a/tests/test_hermeticity_guard.py b/tests/test_hermeticity_guard.py new file mode 100644 index 00000000..efe03b9e --- /dev/null +++ b/tests/test_hermeticity_guard.py @@ -0,0 +1,58 @@ +"""Hermeticity guard tests (2026-08-13 ~/.emrg/projects.yml leak). + +Verifies the autouse conftest fixture is wired (both server modules +carry the guarded wrapper) and that the discriminator is reliable in +BOTH states (#455 lesson): +- tmp projects.yml writes pass through unchanged (negative state); +- the real ~/.emrg/projects.yml / tasks.yml paths raise AssertionError + (positive state) — the raise happens before any write, so this test + is safe. +""" +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def test_guard_installed_in_both_modules(): + """Both scheduler and daemon carry the guarded atomic_write_yaml.""" + import emrg.server.daemon as daemon_mod + import emrg.server.scheduler as sched_mod + + wrapped = sched_mod.atomic_write_yaml + assert wrapped is daemon_mod.atomic_write_yaml + assert wrapped.__name__ == "guarded" + + +def test_guard_allows_tmp_projects_yml(tmp_path): + """Writes to a tmp projects.yml pass through to the original writer.""" + import emrg.server.scheduler as sched_mod + + target = tmp_path / "projects.yml" + sched_mod.atomic_write_yaml( + [{"name": "x", "path": "p", "last_active": "t"}], + target, + prefix=".projects_", + ) + assert target.exists() + data = target.read_text(encoding="utf-8") + assert "name: x" in data + + +def test_guard_rejects_real_projects_yml(): + """Writing the real ~/.emrg/projects.yml is a hard error.""" + import emrg.server.scheduler as sched_mod + + real = (Path.home() / ".emrg" / "projects.yml").resolve() + with pytest.raises(AssertionError, match="hermetic"): + sched_mod.atomic_write_yaml([], real, prefix=".projects_") + + +def test_guard_rejects_real_tasks_yml(): + """Writing the real ~/.emrg/tasks.yml is a hard error (same class).""" + import emrg.server.scheduler as sched_mod + + real = (Path.home() / ".emrg" / "tasks.yml").resolve() + with pytest.raises(AssertionError, match="hermetic"): + sched_mod.atomic_write_yaml([], real, prefix=".tasks_") diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 7035f6f8..574abd14 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -73,13 +73,16 @@ async def _boot_server(tmp: Path): wait for the port file to appear. Returns (server, serve_task). """ import emrg.server.daemon as daemon_mod + import emrg.server.scheduler as sched_mod import emrg.connect as connect_mod _orig_daemon_cfg = daemon_mod.config_dir + _orig_sched_cfg = sched_mod.config_dir _orig_connect_cfg = connect_mod.config_dir - # Isolate config dir to tmp (port file, tasks.yml, etc.) + # Isolate config dir to tmp (port file, tasks.yml, projects.yml, etc.) daemon_mod.config_dir = lambda: tmp + sched_mod.config_dir = lambda: tmp # scheduler builds its own projects_file (#738) connect_mod.config_dir = lambda: tmp server = daemon_mod.EmrgServer(_make_config()) @@ -108,6 +111,7 @@ async def _cleanup(): except asyncio.CancelledError: pass daemon_mod.config_dir = _orig_daemon_cfg + sched_mod.config_dir = _orig_sched_cfg connect_mod.config_dir = _orig_connect_cfg return server, task, _cleanup