From cbd716a00d2615c5ee2e9ba2047f7309eb7adede Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 6 Aug 2026 20:48:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20evolution=20workspace=20self-heal?= =?UTF-8?q?=20=E2=80=94=20clone=20on=20demand=20+=20projects/tasks=20boots?= =?UTF-8?q?trap=20(rant=2020:42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Packaged installs run the daemon from a .git-less source snapshot, so evolution cannot commit/push/PR. Per 方案 C: - EvolutionHandler._ensure_evolution_workspace() at each cycle start: - dev machine (source_dir is a real git repo) → untouched - otherwise clone EMRG into ~/.emrg/evolution/emrg/ (repo URL from install-info.json 'repo' field, fallback built-in default), align to the installed release tag (v, fallback master), set git identity if missing (GIT_AUTHOR_NAME/EMAIL, defaults EMRG Evolution) - clone failure (no network) → log + skip cycle (GUI unaffected) - projects.yml self-heal: add/update emrg entry → evolution workspace - TaskScheduler._ensure_self_evolution_task() at load_and_start(): - tasks.yml missing emrg evolution task → append emrg-task (type=evolution, config.project=emrg, interval=60, enabled) - idempotent; runs at scheduler level because a missing task means no handler is ever started Tests: 5 new (task bootstrap, idempotency, dev-repo untouched, clone+align +projects self-heal, clone-failure skip) + 5 updated for new behavior. Full suite: 478 passed. --- emrg/server/scheduler.py | 238 ++++++++++++++++++++++++++++++- tests/test_scheduler.py | 295 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 515 insertions(+), 18 deletions(-) diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index d633297..41ae572 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -16,6 +16,7 @@ import asyncio import json import logging +import os import subprocess import time from datetime import datetime @@ -27,7 +28,7 @@ from websockets.exceptions import ConnectionClosed from emrg.protocol import EvolutionLog, InstanceIdentity from emrg.server.atomic import atomic_write_yaml -from emrg.server.git_utils import _detect_git_remote, resolve_git_gh +from emrg.server.git_utils import INSTALL_INFO, _detect_git_remote, resolve_git_gh logger = logging.getLogger("emrg.server.scheduler") @@ -173,6 +174,204 @@ def _get_git_head(self) -> str | None: pass return None + # ── Evolution workspace self-heal (rant 2026-08-06T20:42:05, 方案 C) ── + # + # Packaged installs run the daemon from ~/.emrg/install/source/emrg — a + # .git-less source snapshot — so evolution cannot commit/push/PR. Each + # cycle starts by ensuring the workspace is a usable git repo: + # - dev machine (source_dir is a real git repo) → untouched + # - otherwise → clone EMRG into ~/.emrg/evolution/emrg/, align it to the + # installed release tag, and self-heal projects.yml/tasks.yml entries. + # Idempotent and failure-tolerant (no network → skip cycle, GUI unaffected). + + def _repo_url_from_install_info(self) -> str | None: + """Read the repo URL from install-info.json 'repo' field, if present.""" + try: + data = json.loads(INSTALL_INFO.read_text(encoding="utf-8")) + value = data.get("repo") + return str(value) if value else None + except (OSError, json.JSONDecodeError, AttributeError): + return None + + def _is_usable_git_repo(self, path: str) -> bool: + """True if path is a git repo with a working tree we can commit to.""" + if not path or not Path(path).is_dir(): + return False + try: + result = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=path, + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + if result.returncode != 0 or result.stdout.strip() != "true": + return False + return os.access(path, os.W_OK) + except (subprocess.SubprocessError, OSError): + return False + + def _ensure_git_identity(self, repo_dir: Path) -> None: + """Set git user.name/user.email if missing (fresh clones have none).""" + name = os.environ.get("GIT_AUTHOR_NAME", "") or "EMRG Evolution" + email = os.environ.get("GIT_AUTHOR_EMAIL", "") or "emrg@argszero.dev" + try: + for key, default in (("user.name", name), ("user.email", email)): + result = subprocess.run( + ["git", "config", key], + cwd=repo_dir, + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + if not result.stdout.strip(): + subprocess.run( + ["git", "config", key, default], + cwd=repo_dir, + capture_output=True, + timeout=5, + ) + except (subprocess.SubprocessError, OSError): + pass + + def _align_to_installed_version(self, repo_dir: Path) -> None: + """Point the local master branch at the installed release tag. + + Reads ~/.emrg/install/version.txt (e.g. "0.2.7"); checks out + ``v0.2.7`` if the tag exists, otherwise stays on the clone's + default branch (latest master). A named branch (not detached HEAD) + keeps the evolution flow (branch-from-master, push, PR) working. + """ + tag = None + try: + version_file = Path.home() / ".emrg" / "install" / "version.txt" + if version_file.exists(): + ver = version_file.read_text(encoding="utf-8").strip() + if ver: + tag = f"v{ver}" + except OSError: + tag = None + if not tag: + return + try: + result = subprocess.run( + ["git", "tag", "-l", tag], + cwd=repo_dir, + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + ) + if result.returncode == 0 and tag in result.stdout.split(): + subprocess.run( + ["git", "checkout", "-B", "master", tag], + cwd=repo_dir, + capture_output=True, + text=True, + encoding="utf-8", + timeout=30, + check=True, + ) + logger.info( + "EvolutionHandler[%s]: evolution workspace aligned to %s", + self.name, tag, + ) + except (subprocess.CalledProcessError, OSError) as e: + logger.warning( + "EvolutionHandler[%s]: tag checkout %s failed (stay on master): %s", + self.name, tag, e, + ) + + def _ensure_project_entry(self) -> None: + """Add/update the emrg project entry in projects.yml (idempotent).""" + projects_file = config_dir() / "projects.yml" + try: + entries: list[dict] = [] + if projects_file.exists(): + data = yaml.safe_load(projects_file.read_text(encoding="utf-8")) + if isinstance(data, list): + entries = [e for e in data if isinstance(e, dict)] + new_path = str(self._source_dir) + for entry in entries: + if entry.get("name") == "emrg": + if entry.get("path") != new_path: + entry["path"] = new_path + entry["last_active"] = datetime.now().isoformat() + atomic_write_yaml(entries, projects_file, prefix=".projects_") + logger.info( + "EvolutionHandler[%s]: projects.yml self-heal — emrg → %s", + self.name, new_path, + ) + return + entries.append({ + "name": "emrg", + "path": new_path, + "last_active": datetime.now().isoformat(), + }) + atomic_write_yaml(entries, projects_file, prefix=".projects_") + logger.info( + "EvolutionHandler[%s]: projects.yml self-heal — added emrg → %s", + self.name, new_path, + ) + except (yaml.YAMLError, OSError) as e: + logger.warning( + "EvolutionHandler[%s]: projects.yml self-heal failed: %s", + self.name, e, + ) + + def _ensure_evolution_workspace(self) -> bool: + """Self-heal the evolution workspace; returns False to skip the cycle. + + Only applies to the EMRG self-evolution task (config.project == emrg). + Returns True when the workspace is usable (existing dev repo, or a + successful clone into ``~/.emrg/evolution/emrg/``). + """ + if self._project_name != "emrg" or self._repo != self.REPO: + return True # paper/open-source/promote tasks: not our concern + if self._is_usable_git_repo(self._source_dir): + return True # dev machine — use the existing repo as-is + repo_url = self._repo_url_from_install_info() or self._repo_url + evolve_dir = EVOLUTION_CWD / self.REPO + if evolve_dir.exists(): + if self._is_usable_git_repo(str(evolve_dir)): + self._source_dir = str(evolve_dir) + self.project_path = str(evolve_dir) + return True + logger.warning( + "EvolutionHandler[%s]: %s exists but is not a git repo — " + "skipping self-heal to avoid data loss", + self.name, evolve_dir, + ) + return False + try: + logger.info( + "EvolutionHandler[%s]: cloning %s → %s (workspace self-heal)", + self.name, repo_url, evolve_dir, + ) + subprocess.run( + ["git", "clone", repo_url, str(evolve_dir)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + check=True, + ) + self._align_to_installed_version(evolve_dir) + self._ensure_git_identity(evolve_dir) + self._source_dir = str(evolve_dir) + self.project_path = str(evolve_dir) + self._ensure_project_entry() + return True + except (subprocess.CalledProcessError, OSError) as e: + logger.warning( + "EvolutionHandler[%s]: evolution workspace self-heal failed " + "(network down?): %s — skipping cycle", + self.name, e, + ) + return False + def _load_saturation_state(self) -> int: """Restore _empty_cycles counter from disk (survives daemon restarts).""" try: @@ -306,6 +505,15 @@ def status(self) -> dict: async def _run_evolution_cycle(self) -> None: """Connect to server, send evolution prompt, read streaming response.""" + # Self-heal the evolution workspace first (rant 20:42 方案 C): + # packaged installs lack a writable git repo; clone on demand. + if not self._ensure_evolution_workspace(): + logger.warning( + "EvolutionHandler[%s]: workspace not ready — skipping cycle", + self.name, + ) + return + cycle_time = datetime.now() prompt = self._build_evolution_prompt() logger.info( @@ -520,6 +728,11 @@ def __init__(self, identity: InstanceIdentity) -> None: def load_and_start(self) -> list[asyncio.Task]: """Load tasks.yml, start all enabled tasks, return coroutine list.""" + # Self-heal: packaged installs may lack the emrg self-evolution task. + # This must run here (not inside the handler) because a missing task + # means no EvolutionHandler is ever started (rant 20:42 方案 C). + self._ensure_self_evolution_task() + tasks_config = self._load_tasks() if not tasks_config: # Bootstrap: if projects.yml has auto_evolve entries but @@ -637,6 +850,29 @@ def _migrate_from_projects(self) -> None: len(new_tasks), ) + def _ensure_self_evolution_task(self) -> None: + """Ensure tasks.yml has an emrg self-evolution task (idempotent). + + Packaged installs (or first runs) may lack tasks.yml entirely, or lack + the emrg-task entry. Without it, no EvolutionHandler is ever created, + so the workspace self-heal (which lives inside the handler) cannot run. + """ + tasks = self._load_tasks() + for t in tasks: + cfg = t.get("config") if isinstance(t.get("config"), dict) else {} + if t.get("type") == "evolution" and cfg.get("project") == "emrg": + return # already present — idempotent + tasks.append({ + "name": "emrg-task", + "type": "evolution", + "config": {"project": "emrg"}, + "interval": 60, + "enabled": True, + "last_run": None, + }) + self._save_tasks(tasks) + logger.info("TaskScheduler: self-heal — added emrg-task to tasks.yml") + def create_task(self, name: str, task_type: str, config: dict, interval: int) -> None: """Add a new task entry (used by init_auto_evolve). diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 1cdbd2a..d8a09a8 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import subprocess import tempfile from pathlib import Path @@ -233,19 +234,31 @@ def test_migrate_auto_evolve_entries_real(tmp_path): def test_load_and_start_no_file(tmp_path): - """Returns empty coro list when tasks.yml doesn't exist.""" + """No tasks.yml → self-heal creates emrg-task and starts it (rant 20:42 方案 C). + + Previously returned an empty list; now a packaged install without + tasks.yml gets the emrg self-evolution task bootstrapped automatically. + """ from emrg.server import scheduler as mod - sched = TaskScheduler(InstanceIdentity()) - sched._tasks_file = tmp_path / "nonexistent" / "tasks.yml" + + async def _run(): + sched = TaskScheduler(InstanceIdentity()) + sched._tasks_file = tmp_path / "tasks.yml" + return sched.load_and_start(), sched orig_config = mod.config_dir try: mod.config_dir = lambda: tmp_path - coros = sched.load_and_start() + (coros, sched) = asyncio.run(_run()) finally: mod.config_dir = orig_config - assert coros == [] + assert len(coros) == 1 + assert sched._handlers[0].name == "emrg-task" + assert sched._handlers[0].interval == 60 + sched.stop_all() + for c in coros: + c.cancel() def test_load_and_start_enabled_task(tmp_path): @@ -253,7 +266,7 @@ def test_load_and_start_enabled_task(tmp_path): from emrg.server import scheduler as mod tasks_yml = tmp_path / "tasks.yml" tasks_yml.write_text(yaml.safe_dump([ - {"name": "emrg", "type": "evolution", "config": {"path": "/tmp"}, "interval": 99, "enabled": True}, + {"name": "emrg", "type": "evolution", "config": {"project": "emrg"}, "interval": 99, "enabled": True}, ])) async def _run(): @@ -268,7 +281,7 @@ async def _run(): finally: mod.config_dir = orig_config - assert len(coros) == 1 + assert len(coros) == 1 # emrg-task is the self-evolution task — no duplicate assert len(sched._handlers) == 1 assert sched._handlers[0].name == "emrg" assert sched._handlers[0].interval == 99 @@ -282,7 +295,7 @@ def test_load_and_start_skips_disabled(tmp_path): """Disabled tasks are not started.""" tasks_yml = tmp_path / "tasks.yml" tasks_yml.write_text(yaml.safe_dump([ - {"name": "enabled", "type": "evolution", "config": {"path": "/tmp"}, "enabled": True}, + {"name": "enabled", "type": "evolution", "config": {"project": "emrg"}, "enabled": True}, {"name": "disabled", "type": "evolution", "config": {"path": "/tmp"}, "enabled": False}, ])) @@ -307,24 +320,30 @@ async def _run(): def test_load_and_start_unknown_type(tmp_path): - """Tasks with unknown handler type are skipped gracefully.""" + """Tasks with unknown handler type are skipped; self-heal still adds emrg-task.""" tasks_yml = tmp_path / "tasks.yml" tasks_yml.write_text(yaml.safe_dump([ {"name": "bad", "type": "nonexistent_handler", "config": {}, "enabled": True}, ])) - sched = TaskScheduler(InstanceIdentity()) - sched._tasks_file = tasks_yml + async def _run(): + sched = TaskScheduler(InstanceIdentity()) + sched._tasks_file = tasks_yml + return sched.load_and_start(), sched from emrg.server import scheduler as mod orig_config = mod.config_dir try: mod.config_dir = lambda: tmp_path - coros = sched.load_and_start() + (coros, sched) = asyncio.run(_run()) finally: mod.config_dir = orig_config - assert coros == [] + assert len(coros) == 1 # the self-healed emrg-task + assert sched._handlers[0].name == "emrg-task" + sched.stop_all() + for c in coros: + c.cancel() # ── Template task types (paper / open-source / promote) ──────────── @@ -374,10 +393,11 @@ async def _run(): finally: mod.config_dir = orig_config - assert len(coros) == 1 - handler = sched._handlers[0] - assert handler.name == "olr-promote" - assert handler._template_path.name == "promote_prompt.md" + # promote task + self-healed emrg-task + assert len(coros) == 2 + by_name = {h.name: h for h in sched._handlers} + assert by_name["olr-promote"]._template_path.name == "promote_prompt.md" + assert by_name["emrg-task"]._template_path.name == "evolution_prompt.md" sched.stop_all() for c in coros: c.cancel() @@ -450,3 +470,244 @@ def test_paper_template_renders_with_context(): assert "paper_state.md" in out, "状态文件指引应渲染" assert "latexmk" in out, "LaTeX 检查指引应渲染" assert "literature" in out, "文献去重指引应渲染" + + +# ── Evolution workspace self-heal (rant 2026-08-06T20:42:05, 方案 C) ────── + + +def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): + """Build an EvolutionHandler pointed at a tmp config dir.""" + from emrg.server import scheduler as mod + orig_config = mod.config_dir + mod.config_dir = lambda: tmp_path + handler = EvolutionHandler( + name=name, + config={"project": project} if project else {}, + interval=60, + identity=InstanceIdentity(), + ) + mod.config_dir = orig_config + if path is not None: + handler._source_dir = str(path) + handler.project_path = str(path) + return handler + + +class FakeGitRun: + """Controllable subprocess.run fake for git commands.""" + + def __init__(self, git_repo=True, tags="v0.2.7", clone_fails=False): + self.calls = [] + self.git_repo = git_repo + self.tags = tags + self.clone_fails = clone_fails + + def __call__(self, cmd, *args, **kwargs): + self.calls.append((list(cmd), kwargs.get("cwd"))) + cwd = kwargs.get("cwd") or "" + if cmd[0] == "git": + sub = cmd[1] + if sub == "rev-parse": + if "--is-inside-work-tree" in cmd: + return _R(0, "true\n" if self.git_repo else "false\n") + if "HEAD" in cmd: + return _R(0, "abc123\n") + if sub == "clone": + if self.clone_fails: + raise _CalledProcessErrorStub("clone failed") + target = Path(cmd[-1]) + target.mkdir(parents=True, exist_ok=True) + return _R(0, "") + if sub == "tag": + return _R(0, self.tags + "\n") + if sub == "checkout": + return _R(0, "") + if sub == "config": + return _R(0, "") # getter → empty → setter will run + return _R(0, "") + + +class _R: + def __init__(self, returncode, stdout): + self.returncode = returncode + self.stdout = stdout + self.stderr = "" + + +class _CalledProcessErrorStub(subprocess.CalledProcessError): + def __init__(self, msg): + super().__init__(returncode=1, cmd=["git", "clone"], output=msg) + + +def test_ensure_self_evolution_task_adds_when_missing(tmp_path): + """tasks.yml without an emrg evolution task gets emrg-task appended.""" + from emrg.server import scheduler as mod + from emrg.server.scheduler import TaskScheduler + + tasks_yml = tmp_path / "tasks.yml" + tasks_yml.write_text(yaml.safe_dump([ + {"name": "other", "type": "evolution", "config": {"project": "other"}, "enabled": True}, + ])) + + sched = TaskScheduler(InstanceIdentity()) + sched._tasks_file = tasks_yml + + orig_config = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + sched._ensure_self_evolution_task() + sched._ensure_self_evolution_task() # idempotent + finally: + mod.config_dir = orig_config + + data = yaml.safe_load(tasks_yml.read_text(encoding="utf-8")) + names = [e["name"] for e in data] + assert "emrg-task" in names + assert "other" in names + emrg = next(e for e in data if e["name"] == "emrg-task") + assert emrg["type"] == "evolution" + assert emrg["config"] == {"project": "emrg"} + assert emrg["interval"] == 60 + assert emrg["enabled"] is True + assert len(names) == 2 # no duplicate from second call + + +def test_ensure_self_evolution_task_idempotent_when_present(tmp_path): + """Existing emrg evolution task is left untouched (no duplicate).""" + from emrg.server import scheduler as mod + from emrg.server.scheduler import TaskScheduler + + tasks_yml = tmp_path / "tasks.yml" + tasks_yml.write_text(yaml.safe_dump([ + {"name": "emrg-task", "type": "evolution", + "config": {"project": "emrg"}, "interval": 60, "enabled": True, + "last_run": None}, + ])) + + sched = TaskScheduler(InstanceIdentity()) + sched._tasks_file = tasks_yml + + orig_config = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + sched._ensure_self_evolution_task() + finally: + mod.config_dir = orig_config + + data = yaml.safe_load(tasks_yml.read_text(encoding="utf-8")) + assert len(data) == 1 + assert data[0]["name"] == "emrg-task" + + +def test_ensure_evolution_workspace_dev_repo_untouched(tmp_path): + """A real writable git repo (dev machine) is used as-is — no clone.""" + import subprocess as real_subprocess + + from emrg.server import scheduler as mod + + repo = tmp_path / "dev-emrg" + repo.mkdir() + real_subprocess.run(["git", "init", "-q", str(repo)], check=True) + real_subprocess.run( + ["git", "-C", str(repo), "config", "user.email", "t@t"], check=True) + real_subprocess.run( + ["git", "-C", str(repo), "config", "user.name", "t"], check=True) + (repo / "f.txt").write_text("x", encoding="utf-8") + real_subprocess.run( + ["git", "-C", str(repo), "add", "."], check=True) + real_subprocess.run( + ["git", "-C", str(repo), "commit", "-qm", "init"], check=True) + + orig_config = mod.config_dir + mod.config_dir = lambda: tmp_path + try: + handler = EvolutionHandler( + name="emrg-task", + config={"project": "emrg"}, + interval=60, + identity=InstanceIdentity(), + ) + finally: + mod.config_dir = orig_config + handler._source_dir = str(repo) + handler.project_path = str(repo) + + fake = FakeGitRun() + orig_run = mod.subprocess.run + mod.subprocess.run = fake + try: + ok = handler._ensure_evolution_workspace() + finally: + mod.subprocess.run = orig_run + + assert ok is True + assert handler._source_dir == str(repo) # unchanged + assert not any("clone" in c[0] for c in fake.calls), f"unexpected clone: {fake.calls}" + + +def test_ensure_evolution_workspace_clones_and_aligns(tmp_path): + """Non-git source_dir → clone into evolution workspace + align + projects.yml self-heal.""" + from emrg.server import scheduler as mod + + evolve_dir = tmp_path / "evolution" / "emrg" + mod.EVOLUTION_CWD = tmp_path / "evolution" + + projects_yml = tmp_path / "projects.yml" + projects_yml.write_text(yaml.safe_dump([])) + + handler = _make_handler(tmp_path, path=str(tmp_path / "install" / "source" / "emrg")) + + # Installed version hint → tag alignment + (tmp_path / "install").mkdir() + (tmp_path / "install" / "version.txt").write_text("0.2.7", encoding="utf-8") + + fake = FakeGitRun(git_repo=False, tags="v0.2.7") + orig_run = mod.subprocess.run + orig_evolve = mod.EVOLUTION_CWD + orig_config = mod.config_dir + mod.subprocess.run = fake + mod.config_dir = lambda: tmp_path + try: + ok = handler._ensure_evolution_workspace() + finally: + mod.subprocess.run = orig_run + mod.config_dir = orig_config + mod.EVOLUTION_CWD = orig_evolve + + assert ok is True + assert handler._source_dir == str(evolve_dir) + # clone called with repo URL + target + clone_calls = [c for c in fake.calls if c[0][1] == "clone"] + assert len(clone_calls) == 1 + # tag alignment: checkout -B master v0.2.7 + checkout_calls = [c for c in fake.calls if c[0][1] == "checkout"] + assert any("v0.2.7" in c[0] for c in checkout_calls), f"no tag checkout: {checkout_calls}" + # git identity configured + config_calls = [c for c in fake.calls if c[0][1] == "config"] + assert any("user.name" in c[0] for c in config_calls) + assert any("user.email" in c[0] for c in config_calls) + # projects.yml self-heal + data = yaml.safe_load(projects_yml.read_text(encoding="utf-8")) + assert any(e.get("name") == "emrg" and e.get("path") == str(evolve_dir) for e in data) + + +def test_ensure_evolution_workspace_clone_failure_skips(tmp_path): + """Clone failure (no network) → returns False so the cycle is skipped.""" + from emrg.server import scheduler as mod + + mod.EVOLUTION_CWD = tmp_path / "evolution" + handler = _make_handler(tmp_path, path=str(tmp_path / "nonexistent")) + handler._repo_url = "https://github.com/argszero/emrg.git" + + fake = FakeGitRun(git_repo=False, clone_fails=True) + orig_run = mod.subprocess.run + orig_evolve = mod.EVOLUTION_CWD + mod.subprocess.run = fake + try: + ok = handler._ensure_evolution_workspace() + finally: + mod.subprocess.run = orig_run + mod.EVOLUTION_CWD = orig_evolve + + assert ok is False + assert handler._source_dir != str(mod.EVOLUTION_CWD / "emrg") From 5dbcc9746cd563e1baeebd890629658f7e09f7e6 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Thu, 6 Aug 2026 20:52:00 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20fix=20hermetic=20version.txt=20test?= =?UTF-8?q?=20=E2=80=94=20patch=20Path.home=20(CI=20has=20no=20~/.emrg/ins?= =?UTF-8?q?tall)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_ensure_evolution_workspace_clones_and_aligns read the REAL ~/.emrg/install/version.txt via Path.home() — passed on dev hosts but failed in CI (no ~/.emrg/install → no tag → no checkout). Patch pathlib.Path.home to tmp_path so the test is self-contained. --- tests/test_scheduler.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index d8a09a8..db1f398 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -657,22 +657,29 @@ def test_ensure_evolution_workspace_clones_and_aligns(tmp_path): handler = _make_handler(tmp_path, path=str(tmp_path / "install" / "source" / "emrg")) - # Installed version hint → tag alignment - (tmp_path / "install").mkdir() - (tmp_path / "install" / "version.txt").write_text("0.2.7", encoding="utf-8") + # Installed version hint → tag alignment. The code reads + # Path.home()/.emrg/install/version.txt — patch home so the test is + # hermetic (CI hosts don't have ~/.emrg/install). + import pathlib as _pathlib + install_dir = tmp_path / ".emrg" / "install" + install_dir.mkdir(parents=True) + (install_dir / "version.txt").write_text("0.2.7", encoding="utf-8") fake = FakeGitRun(git_repo=False, tags="v0.2.7") orig_run = mod.subprocess.run orig_evolve = mod.EVOLUTION_CWD orig_config = mod.config_dir + orig_home = _pathlib.Path.home mod.subprocess.run = fake mod.config_dir = lambda: tmp_path + _pathlib.Path.home = classmethod(lambda cls: tmp_path) try: ok = handler._ensure_evolution_workspace() finally: mod.subprocess.run = orig_run mod.config_dir = orig_config mod.EVOLUTION_CWD = orig_evolve + _pathlib.Path.home = orig_home assert ok is True assert handler._source_dir == str(evolve_dir)