From 22397ec3dce51688441c23c47156dc326c5dc6f3 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 14:21:32 +0800 Subject: [PATCH 1/5] emrg: guard tests against writing the real ~/.emrg/projects.yml --- Agent.md | 2 +- tests/conftest.py | 43 +++++++++++++++++++++++++++++ tests/test_daemon.py | 7 ++++- tests/test_hermeticity_guard.py | 48 +++++++++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/test_hermeticity_guard.py diff --git a/Agent.md b/Agent.md index c67ce4e3..5c336238 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` (764) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (767) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 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/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..90adb3ff --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +"""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. + +This autouse fixture makes any write to the REAL ~/.emrg/projects.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 + + +@pytest.fixture(autouse=True) +def _guard_real_projects_yml(monkeypatch): + """Fail any test that writes the real ~/.emrg/projects.yml.""" + real = (Path.home() / ".emrg" / "projects.yml").resolve() + + 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() == real: + raise AssertionError( + "test attempted to write the real ~/.emrg/projects.yml; " + 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..0b2eac9d --- /dev/null +++ b/tests/test_hermeticity_guard.py @@ -0,0 +1,48 @@ +"""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 path raises 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_") From 37c35c2f836623ece72f7e66e79207255d5348c3 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 14:28:03 +0800 Subject: [PATCH 2/5] emrg: isolate scheduler config_dir in ws e2e boot (hermeticity guard fix) --- tests/test_ws_e2e.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 31e3a54f..4bbae995 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 From e6402fd682d44e40f9436a92db93e8563a6793cd Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 14:34:47 +0800 Subject: [PATCH 3/5] emrg: extend hermeticity guard to real tasks.yml --- Agent.md | 2 +- tests/conftest.py | 30 ++++++++++++++++++++---------- tests/test_hermeticity_guard.py | 14 ++++++++++++-- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/Agent.md b/Agent.md index 75dc00f8..b6179550 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` (772) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (773) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 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/tests/conftest.py b/tests/conftest.py index 90adb3ff..b59820fc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,10 +8,17 @@ per-cycle repair (#734) eventually cleans it, but until then the host's GUI project picker shows a dead path. -This autouse fixture makes any write to the REAL ~/.emrg/projects.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). +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 @@ -19,12 +26,15 @@ import pytest +_REAL_CONFIG_FILES = ( + (Path.home() / ".emrg" / "projects.yml").resolve(), + (Path.home() / ".emrg" / "tasks.yml").resolve(), +) -@pytest.fixture(autouse=True) -def _guard_real_projects_yml(monkeypatch): - """Fail any test that writes the real ~/.emrg/projects.yml.""" - real = (Path.home() / ".emrg" / "projects.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 @@ -32,9 +42,9 @@ def _guard_real_projects_yml(monkeypatch): assert orig is daemon_mod.atomic_write_yaml, "both modules must share atomic_write_yaml" def guarded(data, path, **kwargs): - if Path(path).resolve() == real: + if Path(path).resolve() in _REAL_CONFIG_FILES: raise AssertionError( - "test attempted to write the real ~/.emrg/projects.yml; " + "test attempted to write a real ~/.emrg config file; " f"keep tests hermetic (target={path!r})" ) return orig(data, path, **kwargs) diff --git a/tests/test_hermeticity_guard.py b/tests/test_hermeticity_guard.py index 0b2eac9d..efe03b9e 100644 --- a/tests/test_hermeticity_guard.py +++ b/tests/test_hermeticity_guard.py @@ -4,8 +4,9 @@ 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 path raises AssertionError (positive - state) — the raise happens before any write, so this test is safe. +- 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 @@ -46,3 +47,12 @@ def test_guard_rejects_real_projects_yml(): 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_") From 47716d35fa1df2c1168e73fa02115f3b43ed74c2 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 14:44:46 +0800 Subject: [PATCH 4/5] emrg: make TaskScheduler._tasks_file lazy so config_dir patches apply (hermeticity guard #738) --- emrg/server/scheduler.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) 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"]) From 636005ee90b312fb5070da5a82d6c5ad2b1b9808 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 13 Aug 2026 14:49:51 +0800 Subject: [PATCH 5/5] emrg: sync Agent.md pytest count 773->777 after master merge (#738) --- Agent.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Agent.md b/Agent.md index b6179550..74cddb2f 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` (231: 44 daemon_client + 19 conn-manager + 22 app-commands + 109 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 路径不受影响)