Skip to content
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
15 changes: 14 additions & 1 deletion emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"])
Expand Down
53 changes: 53 additions & 0 deletions tests/conftest.py
Original file line numberDiff line numberDiff line change
@@ -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)
7 changes: 6 additions & 1 deletion tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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):
Expand Down
58 changes: 58 additions & 0 deletions tests/test_hermeticity_guard.py
Original file line numberDiff line numberDiff line change
@@ -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_")
6 changes: 5 additions & 1 deletion tests/test_ws_e2e.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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())
Expand DownExpand Up@@ -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
Expand Down
Loading