diff --git a/Agent.md b/Agent.md index 8da596bb..23b10ab7 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,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` (499) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (502) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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/README.md b/README.md index c4e266b1..84faed54 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 499 items) +uv run pytest tests/ -v # run tests (currently 502 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 424c30ff..fdf71b79 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -924,12 +924,44 @@ def _migrate_from_projects(self) -> None: ) def _ensure_self_evolution_task(self) -> None: - """Ensure tasks.yml has an emrg self-evolution task (idempotent). + """Ensure projects.yml has an emrg entry and tasks.yml has the task. 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. + + The projects.yml emrg entry is ensured here too (rant 02:58): the only + other writer (_ensure_evolution_workspace's clone branch) requires a + first tick + network. If projects.yml lacks the entry, + _resolve_project_path("emrg") returns None and the handler's + _source_dir degenerates to the relative string "emrg" (dangling cwd). + The path is fixed to ~/.emrg/evolution/emrg; an existing entry is + preserved as-is (dev machines may configure a custom path). """ + # 1. projects.yml — add name=emrg entry if missing (preserve existing). + 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)] + if not any(e.get("name") == "emrg" for e in entries): + entries.append({ + "name": "emrg", + "path": str(EVOLUTION_CWD / "emrg"), + "last_active": datetime.now().isoformat(), + }) + atomic_write_yaml(entries, projects_file, prefix=".projects_") + logger.info( + "TaskScheduler: self-heal — added emrg entry to projects.yml" + ) + except (yaml.YAMLError, OSError) as e: + logger.warning( + "TaskScheduler: projects.yml self-heal failed: %s", e + ) + + # 2. tasks.yml — add emrg-task if missing (existing logic unchanged). tasks = self._load_tasks() for t in tasks: cfg = t.get("config") if isinstance(t.get("config"), dict) else {} diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 7d84cd46..11109c6c 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -603,6 +603,82 @@ def test_ensure_self_evolution_task_idempotent_when_present(tmp_path): assert data[0]["name"] == "emrg-task" +def test_ensure_self_evolution_task_adds_project_entry_when_missing(tmp_path): + """Missing projects.yml emrg entry gets added (fixed path, no network).""" + from emrg.server import scheduler as mod + from emrg.server.scheduler import EVOLUTION_CWD, TaskScheduler + + sched = TaskScheduler(InstanceIdentity()) + + 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 + + projects_yml = tmp_path / "projects.yml" + assert projects_yml.exists() + data = yaml.safe_load(projects_yml.read_text(encoding="utf-8")) + assert isinstance(data, list) + emrg = next(e for e in data if e.get("name") == "emrg") + assert emrg["path"] == str(EVOLUTION_CWD / "emrg") + assert len([e for e in data if e.get("name") == "emrg"]) == 1 # no dup + + +def test_ensure_self_evolution_task_preserves_existing_project_entry(tmp_path): + """Existing emrg project entry (dev-machine path) is preserved as-is.""" + from emrg.server import scheduler as mod + from emrg.server.scheduler import TaskScheduler + + projects_yml = tmp_path / "projects.yml" + projects_yml.write_text(yaml.safe_dump([ + {"name": "emrg", "path": "/dev/machine/custom/emrg", + "last_active": "2026-01-01T00:00:00"}, + ])) + + sched = TaskScheduler(InstanceIdentity()) + + 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(projects_yml.read_text(encoding="utf-8")) + assert len(data) == 1 + assert data[0]["name"] == "emrg" + assert data[0]["path"] == "/dev/machine/custom/emrg" # untouched + + +def test_ensure_self_evolution_task_other_entries_preserved(tmp_path): + """Non-emrg project entries survive the self-heal.""" + from emrg.server import scheduler as mod + from emrg.server.scheduler import TaskScheduler + + projects_yml = tmp_path / "projects.yml" + projects_yml.write_text(yaml.safe_dump([ + {"name": "paper", "path": "/some/paper"}, + ])) + + sched = TaskScheduler(InstanceIdentity()) + + 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(projects_yml.read_text(encoding="utf-8")) + names = [e.get("name") for e in data] + assert "paper" in names + assert "emrg" in names + assert len(names) == 2 + + 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