diff --git a/Agent.md b/Agent.md index 730c0fd8..e177d0e2 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` (979) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (968) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — 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 570265c4..c29d24e1 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -18,25 +18,18 @@ import logging import os import re -import subprocess import time from datetime import datetime from pathlib import Path import yaml -from emrg._win import win32_no_window_kwargs from emrg.config import config_dir from emrg.connect import connect_to_server 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 ( - INSTALL_INFO, _detect_git_remote, - git_origin_url, - https_to_ssh_url, - is_git_connection_error, - no_prompt_env, resolve_git_gh, ) @@ -247,13 +240,6 @@ def __init__( # rant 2026-08-09T08:03:46:GUI 测试覆盖真实 emrgd.port 致 10h 连不上)。 self._CONNECT_FAIL_ALERT = 3 self._connect_failures = 0 - # ── Workspace self-heal backoff (rant 2026-08-19T00:54:32) ── - # clone/self-heal failure (network down) must not retry every cycle: - # each failed clone blocks up to ~30s and (on Windows) wedges the - # websocket clients. Exponential backoff 5min → 10min → 20min → 30min - # cap; reset on success. - self._workspace_heal_failures = 0 - self._workspace_heal_next_retry_at = 0.0 self._saturation_dir = config_dir() / "saturation" self._saturation_dir.mkdir(parents=True, exist_ok=True) self._saturation_file = self._saturation_dir / f"{self.name}.json" @@ -267,10 +253,9 @@ def __init__( self.project_path = path or name # default to name for emrg itself # Derive owner/repo/git from config override → path git remote → defaults. - # _repo_configured gates the workspace self-heal (rant 2026-08-12T18:14:46): - # any task with a real repo (config owner/repo, or a git remote in its - # project path) gets clone/align self-heal; the emrg evolution task - # always counts as configured (defaults to argszero/emrg). + # (rant 2026-08-19T14:20:52: workspace self-heal deleted — these fields + # remain for prompt context {repo}/{owner} and task-config resolution; + # the agent manages its own git workspace via tools.) repo_spec = _detect_git_remote(path) if path else "" cfg_owner = config.get("owner") cfg_repo = config.get("repo") @@ -289,338 +274,8 @@ def __init__( self._repo_configured = project_name == "emrg" self._session_id = f"emrg-evolution-{name}" self._source_dir = path or name - # 解析一次 git 可执行路径(install-info.json → bundled → PATH 回退)。 - # 2026-08-12 事故:daemon 从无 PATH git 的环境重启后,裸 `git` 调用 - # FileNotFoundError → _is_usable_git_repo() 误判 "not a git repo" → - # 演化周期全部跳过。此后所有 git 调用走 resolve_git_gh() 的确定性解析。 - self._git_exe = resolve_git_gh()[0] or "git" - # One-shot https-origin probe per handler lifetime (see - # _ensure_origin_reachable) — avoids re-probing every cycle. - self._origin_probed = False - - # ── 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( - [self._git_exe, "rev-parse", "--is-inside-work-tree"], - cwd=path, - capture_output=True, - text=True, - encoding="utf-8", - timeout=5, - env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - 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( - [self._git_exe, "config", key], - cwd=repo_dir, - capture_output=True, - text=True, - encoding="utf-8", - timeout=5, - env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - if not result.stdout.strip(): - subprocess.run( - [self._git_exe, "config", key, default], - cwd=repo_dir, - capture_output=True, - timeout=5, - env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - 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( - [self._git_exe, "tag", "-l", tag], - cwd=repo_dir, - capture_output=True, - text=True, - encoding="utf-8", - timeout=10, - env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - if result.returncode == 0 and tag in result.stdout.split(): - subprocess.run( - [self._git_exe, "checkout", "-B", "master", tag], - cwd=repo_dir, - capture_output=True, - text=True, - encoding="utf-8", - timeout=30, - check=True, - env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - self._logger.info( - "TaskHandler[%s]: evolution workspace aligned to %s", - self.name, tag, - ) - except (subprocess.CalledProcessError, OSError) as e: - self._logger.warning( - "TaskHandler[%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_") - self._logger.info( - "TaskHandler[%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_") - self._logger.info( - "TaskHandler[%s]: projects.yml self-heal — added emrg → %s", - self.name, new_path, - ) - except (yaml.YAMLError, OSError) as e: - self._logger.warning( - "TaskHandler[%s]: projects.yml self-heal failed: %s", - self.name, e, - ) - def _ensure_evolution_workspace(self) -> bool: - """Self-heal the task workspace; returns False to skip the cycle. - - Applies to any task that has a repo configured (the emrg evolution - task, or a paper/open-source/promote task with config owner/repo or - a git remote in its project path — rant 2026-08-12T18:14:46). - Returns True when the workspace is usable (existing dev repo, or a - successful clone into ``~/.emrg/evolution//``). - """ - if not self._repo_configured: - return True # no repo configured for this task — nothing to self-heal - if self._is_usable_git_repo(self._source_dir): - self._ensure_origin_reachable() - 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) - # Persist the corrected path (idempotent — writes only when the - # emrg entry differs). #716 repairs a stale emrg entry (deleted - # pytest-temp dir leaked into projects.yml) at scheduler startup - # only; this re-persists every cycle so a mid-run pollution on a - # long-running daemon self-heals within one cycle without a - # restart (list_projects/GUI pickers stay correct). - if self._project_name == "emrg": - self._ensure_project_entry() - self._ensure_origin_reachable() - return True - self._logger.warning( - "TaskHandler[%s]: %s exists but is not a git repo — " - "skipping self-heal to avoid data loss", - self.name, evolve_dir, - ) - return False - # Backoff gate (rant 2026-08-19T00:54:32): after a failed clone / - # self-heal (network down), don't retry every cycle — each failed - # clone blocks up to ~30s and wedges websocket clients. Skip until - # the backoff window expires. - if self._workspace_heal_failures > 0: - remaining = self._workspace_heal_next_retry_at - time.time() - if remaining > 0: - self._logger.debug( - "TaskHandler[%s]: workspace self-heal backoff " - "(%ds left) — skipping cycle", - self.name, int(remaining), - ) - return False - try: - self._logger.info( - "TaskHandler[%s]: cloning %s → %s (workspace self-heal)", - self.name, repo_url, evolve_dir, - ) - self._clone_workspace(repo_url, evolve_dir) - 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() - self._workspace_heal_failures = 0 - self._workspace_heal_next_retry_at = 0.0 - return True - except (subprocess.CalledProcessError, OSError) as e: - self._workspace_heal_failures += 1 - delay = min(300 * (2 ** (self._workspace_heal_failures - 1)), 1800) - self._workspace_heal_next_retry_at = time.time() + delay - self._logger.warning( - "TaskHandler[%s]: evolution workspace self-heal failed " - "(network down?): %s — skipping cycle; next retry in %ds", - self.name, e, delay, - ) - return False - - def _clone_workspace(self, repo_url: str, target: Path) -> None: - """Clone the evolution repo, retrying via SSH when https is blocked. - - Uses a short ``http.connectTimeout`` so a blocked github.com:443 - fails fast (seconds) instead of hanging; on a connection-type - failure the clone is retried with the SSH URL - (``git@github.com:owner/repo.git``), which works on networks that - block https git transport (observed on the packaged host). Other - failures (auth / 404 / repo-specific) propagate unchanged. - """ - cmd = [self._git_exe, "-c", "http.connectTimeout=5", "clone", repo_url, str(target)] - reason = "" - try: - subprocess.run( - cmd, capture_output=True, text=True, encoding="utf-8", - timeout=120, check=True, env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - return - except subprocess.CalledProcessError as e: - ssh_url = https_to_ssh_url(repo_url) - if not ssh_url or not is_git_connection_error(e.stderr or ""): - raise - # NB: `e` is deleted when the except block exits — capture first. - reason = (e.stderr.strip() or str(e))[:80] - self._logger.warning( - "TaskHandler[%s]: https clone failed (%s) — retrying via SSH", - self.name, reason, - ) - # rant 2026-08-19T00:54:32 — bound the SSH connect too (GIT_SSH_COMMAND - # ConnectTimeout=5), so a blocked SSH port fails fast instead of eating - # the full timeout while blocking the event loop offload thread. - subprocess.run( - [self._git_exe, "clone", ssh_url, str(target)], - capture_output=True, text=True, encoding="utf-8", - timeout=120, check=True, - env={**no_prompt_env(), "GIT_SSH_COMMAND": "ssh -o ConnectTimeout=5"}, - **win32_no_window_kwargs(), - ) - - def _ensure_origin_reachable(self) -> None: - """Probe the github.com https origin; switch to SSH when blocked. - - Some networks block github.com:443 while SSH port 22 stays open. - With an https origin every evolution pull/push hangs ~75 s and the - saturation-halt auto-resume (``git ls-remote``) never fires, - silently starving the cycle. One cheap probe per handler lifetime - (bounded by ``http.connectTimeout``) detects the blocked case; on - success nothing changes; on a connection-type failure the origin is - switched to the equivalent SSH URL so pull/push/ls-remote keep - working. Auth/404 errors never trigger a switch. - """ - if self._origin_probed: - return - self._origin_probed = True - origin = git_origin_url(self._source_dir) - ssh_url = https_to_ssh_url(origin) - if not ssh_url: - return # not a github.com https origin — nothing to switch - result = subprocess.run( - [self._git_exe, "-c", "http.connectTimeout=4", "ls-remote", origin, "HEAD"], - cwd=self._source_dir, - capture_output=True, - text=True, - encoding="utf-8", - timeout=15, - env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - if result.returncode == 0: - self._logger.debug( - "TaskHandler[%s]: origin probe: https OK", self.name, - ) - return # reachable — keep https - if not is_git_connection_error(result.stderr): - return # auth/404 etc — switching would not help - switch = subprocess.run( - [self._git_exe, "remote", "set-url", "origin", ssh_url], - cwd=self._source_dir, - capture_output=True, - text=True, - encoding="utf-8", - timeout=5, - env=no_prompt_env(), - **win32_no_window_kwargs(), - ) - if switch.returncode == 0: - self._logger.warning( - "TaskHandler[%s]: https origin unreachable (%s) — " - "switched origin to %s", - self.name, (result.stderr.strip() or "")[:80], ssh_url, - ) + # ── Saturation state (restored from disk across daemon restarts) ── def _load_saturation_state(self) -> tuple[int, int]: """Restore (empty_cycles, slowdown_hits) from disk (daemon restarts).""" @@ -909,19 +564,10 @@ async def _request_vibe_check(self, ws, prompt: str, completion_summary: str) -> async def _run_evolution_cycle(self) -> None: - # Self-heal the evolution workspace first (rant 20:42 方案 C): - # packaged installs lack a writable git repo; clone on demand. - # rant 2026-08-19T00:54:32 — the self-heal runs git subprocesses - # (clone/ls-remote/config) synchronously; offload to a worker thread - # so the asyncio event loop is never blocked (a slow/failing git - # clone used to wedge every websocket client for ~30s per cycle). - if not await asyncio.to_thread(self._ensure_evolution_workspace): - self._logger.warning( - "TaskHandler[%s]: workspace not ready — skipping cycle", - self.name, - ) - return - + # Rant 2026-08-19T14:20:52 — workspace self-heal deleted: git workspace + # management is the agent's job (bash tools). The cycle proceeds + # directly to prompt build / daemon connection; if the configured + # source path is invalid the agent discovers it via tool errors. cycle_time = datetime.now() prompt = self._build_evolution_prompt() self._logger.info( @@ -1144,7 +790,9 @@ async def _run_evolution_cycle(self) -> None: recommend_slowdown=recommend, tool_count=tool_count, ) - await self._write_evolution_log(log) + # Rant 2026-08-19T14:18:40 — no disk archival: the evolution log lives + # in the in-memory list only (GUI recent-runs + evolution_count both + # read self.evolutions); evolution-*.json was never consumed. self.evolutions.append(log) def _build_evolution_prompt(self) -> str: @@ -1191,35 +839,6 @@ def _build_evolution_prompt(self) -> str: template = env.from_string(self._template_path.read_text(encoding="utf-8")) return template.render(**context) - async def _write_evolution_log(self, entry: EvolutionLog) -> None: - filename = f"evolution-{entry.timestamp.replace(':', '-')}.json" - path = self._logs_dir / filename - data = { - "timestamp": entry.timestamp, - "trigger": entry.trigger, - "impact": entry.impact, - "operations": entry.operations, - "summary": entry.summary, - "meaningful": entry.meaningful, - "recommend_slowdown": entry.recommend_slowdown, - "tool_count": entry.tool_count, - } - path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") - - # Rotate: keep at most 27 evolution log files (oldest deleted). - # Filenames use ISO timestamps so lexical sort = chronological. - _MAX_LOG_FILES = 27 - try: - log_files = sorted([ - f for f in self._logs_dir.iterdir() - if f.is_file() and f.name.startswith("evolution-") - ]) - if len(log_files) > _MAX_LOG_FILES: - for old in log_files[:len(log_files) - _MAX_LOG_FILES]: - old.unlink(missing_ok=True) - except OSError: - pass # best-effort cleanup - async def _write_final_summary(self) -> None: if not self.evolutions: return @@ -1470,16 +1089,15 @@ def _ensure_self_evolution_task(self) -> None: """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 TaskHandler 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). + the emrg-task entry. Without it, no TaskHandler is ever created, so + the emrg task never runs. + + The projects.yml emrg entry is ensured here too (rant 02:58): 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" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 0c77d658..a0b5f86d 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -653,7 +653,7 @@ def test_task_handler_repo_configured_from_config(): def test_task_handler_no_repo_skips_self_heal(): - """Non-emrg task without any repo config → _ensure_evolution_workspace no-ops.""" + """Non-emrg task without any repo config → no repo override (defaults).""" handler = TaskHandler( name="docs-task", config={"path": "/tmp/plain-folder"}, @@ -661,7 +661,7 @@ def test_task_handler_no_repo_skips_self_heal(): identity=InstanceIdentity(), ) assert handler._repo_configured is False - assert handler._ensure_evolution_workspace() is True # skip, not block + assert handler._source_dir == "/tmp/plain-folder" def test_task_scheduler_total_evolutions(): @@ -944,93 +944,6 @@ def _make_handler(tmp_path, name="emrg-task", project="emrg", path=None): return handler -class FakeGitRun: - """Controllable subprocess.run fake for git commands.""" - - def __init__(self, git_repo=True, tags="v0.2.7", clone_fails=False, remote_head="abc123", - origin_url="", ls_remote_stderr="", clone_stderr="", clone_fail_once=False): - self.calls = [] - self.git_repo = git_repo - self.tags = tags - self.clone_fails = clone_fails - self.remote_head = remote_head - self.origin_url = origin_url - self.ls_remote_stderr = ls_remote_stderr - self.clone_stderr = clone_stderr - self.clone_fail_once = clone_fail_once - self._clone_calls = 0 - - @staticmethod - def _norm(cmd): - """Strip `git -c key=value` config pairs (http.connectTimeout=…).""" - out, i = [], 1 - args = list(cmd) - while i < len(args): - if args[i] == "-c" and i + 1 < len(args): - i += 2 - continue - out.append(args[i]) - i += 1 - return out - - def __call__(self, cmd, *args, **kwargs): - self.calls.append((list(cmd), kwargs.get("cwd"), kwargs.get("env"))) - cwd = kwargs.get("cwd") or "" - # cmd[0] 可能是字面 "git"(dev 环境)或 resolve_git_gh() 解析出的 - # 绝对路径(bundled git,2026-08-12 workspace-not-ready 事故修复后)—— - # 统一按 basename 判断,避免测试在两种环境下行为不一致。 - # Windows 上 resolve_git_gh() 返回 git.EXE(大写后缀,2026-08-12 v0.2.29 - # Build Release Windows gate 实测)→ 比较必须大小写不敏感。 - cmd_head = Path(cmd[0]).name.lower() - if cmd_head in ("git", "git.exe"): - sub = self._norm(cmd) - if sub and sub[0] == "rev-parse": - if "--is-inside-work-tree" in sub: - return _R(0, "true\n" if self.git_repo else "false\n") - if "HEAD" in sub: - return _R(0, "abc123\n") - if sub and sub[0] == "remote": - if sub[1] == "get-url": - return _R(0, self.origin_url + "\n") - if sub[1] == "set-url": - return _R(0, "") - if sub and sub[0] == "ls-remote": - # `git ls-remote origin master` → "\trefs/heads/master". - # When ls_remote_stderr is set, only the https-origin form - # fails — the SSH retry (git@github.com:…) succeeds. - # NB: list `in` is element-equality — use substring scan. - ssh_retry = any("git@github.com" in str(c) for c in cmd) - if self.ls_remote_stderr and not ssh_retry: - return _R(128, "", self.ls_remote_stderr) - return _R(0, f"{self.remote_head}\trefs/heads/master\n") - if sub and sub[0] == "clone": - self._clone_calls += 1 - if self.clone_fails and (not self.clone_fail_once or self._clone_calls == 1): - raise _CalledProcessErrorStub(self.clone_stderr or "clone failed", - stderr=self.clone_stderr) - target = Path(cmd[-1]) - target.mkdir(parents=True, exist_ok=True) - return _R(0, "") - if sub and sub[0] == "tag": - return _R(0, self.tags + "\n") - if sub and sub[0] == "checkout": - return _R(0, "") - if sub and sub[0] == "config": - return _R(0, "") # getter → empty → setter will run - return _R(0, "") - - -class _R: - def __init__(self, returncode, stdout, stderr=""): - self.returncode = returncode - self.stdout = stdout - self.stderr = stderr - - -class _CalledProcessErrorStub(subprocess.CalledProcessError): - def __init__(self, msg, stderr=""): - super().__init__(returncode=1, cmd=["git", "clone"], output=msg, stderr=stderr) - def test_ensure_self_evolution_task_adds_when_missing(tmp_path): """tasks.yml without an emrg evolution task gets emrg-task appended.""" @@ -1202,410 +1115,6 @@ def test_ensure_self_evolution_task_other_entries_preserved(tmp_path): assert "emrg" in names assert len(names) == 2 - -def test_ensure_evolution_workspace_persists_repaired_emrg_path(tmp_path): - """Long-running daemon: a stale emrg path (deleted pytest-temp dir) is - healed in-memory to the canonical workspace AND persisted back to - projects.yml on the next cycle — not only at scheduler startup (#716 - follow-up: startup-only repair leaves a dangling entry forever when the - daemon never restarts; list_projects/GUI pickers keep showing a dead path).""" - from emrg.server import scheduler as mod - - evolve_dir = tmp_path / "evolution" / "emrg" - evolve_dir.mkdir(parents=True) - - # Stale emrg entry pointing at a path that no longer exists on disk - # (exactly the 2026-08-12 pytest-temp-leak shape). - stale = tmp_path / "gone" / "emrg" - projects_yml = tmp_path / "projects.yml" - projects_yml.write_text(yaml.safe_dump([ - {"name": "emrg", "path": str(stale), "last_active": "2026-08-12T18:44:50"}, - {"name": "other", "path": str(tmp_path / "other")}, - ])) - - fake = FakeGitRun(git_repo=True) - orig_run = mod.subprocess.run - orig_evolve = mod.EVOLUTION_CWD - orig_config = mod.config_dir - mod.subprocess.run = fake - mod.EVOLUTION_CWD = tmp_path / "evolution" - mod.config_dir = lambda: tmp_path - try: - handler = TaskHandler( - name="emrg-task", - config={"project": "emrg"}, - interval=60, - identity=InstanceIdentity(), - ) - handler._source_dir = str(stale) # stale as resolved at handler start - handler.project_path = str(stale) - ok = handler._ensure_evolution_workspace() - finally: - mod.subprocess.run = orig_run - mod.EVOLUTION_CWD = orig_evolve - mod.config_dir = orig_config - - assert ok is True - assert handler._source_dir == str(evolve_dir) # in-memory heal (pre-existing) - data = yaml.safe_load(projects_yml.read_text(encoding="utf-8")) - by_name = {e["name"]: e for e in data} - assert by_name["emrg"]["path"] == str(evolve_dir) # NEW: persisted this cycle - assert by_name["other"]["path"] == str(tmp_path / "other") # untouched - assert len(data) == 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 - - 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 - fake = FakeGitRun() - orig_run = mod.subprocess.run - mod.subprocess.run = fake - try: - handler = TaskHandler( - name="emrg-task", - config={"project": "emrg"}, - interval=60, - identity=InstanceIdentity(), - ) - # config_dir must stay patched through _ensure_evolution_workspace(): - # its clone branch calls _ensure_project_entry(), which writes - # config_dir()/projects.yml — an unpatched call would pollute the real - # ~/.emrg/projects.yml (2026-08-12 incident: pytest temp path leaked - # into real home). - handler._source_dir = str(repo) - handler.project_path = str(repo) - ok = handler._ensure_evolution_workspace() - finally: - mod.subprocess.run = orig_run - mod.config_dir = orig_config - - 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. 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) - # clone called with repo URL + target - clone_calls = [c for c in fake.calls if "clone" in c[0]] - 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 - orig_config = mod.config_dir - mod.subprocess.run = fake - # config_dir patched through the call: the clone branch would call - # _ensure_project_entry() and write config_dir()/projects.yml — keep it - # hermetic so a future fake change can't pollute real ~/.emrg/projects.yml - # (2026-08-12 pytest-temp-path leak incident). - mod.config_dir = lambda: tmp_path - try: - ok = handler._ensure_evolution_workspace() - finally: - mod.subprocess.run = orig_run - mod.EVOLUTION_CWD = orig_evolve - mod.config_dir = orig_config - - assert ok is False - assert handler._source_dir != str(mod.EVOLUTION_CWD / "emrg") - - -# ── HTTPS→SSH fallback for blocked github.com:443 (2026-08-08) ───── -# Some networks block github.com:443 while SSH port 22 stays open — the -# self-heal clone and the saturation auto-resume (ls-remote) must not -# hard-depend on https reaching github.com. - -def test_ensure_origin_reachable_switches_to_ssh_when_https_blocked(tmp_path): - """https origin unreachable (connection error) → origin switched to SSH.""" - from emrg.server import scheduler as mod - - handler = _make_handler(tmp_path, path=str(tmp_path)) - handler._origin_probed = False - fake = FakeGitRun( - origin_url="https://github.com/argszero/emrg.git", - ls_remote_stderr=( - "fatal: unable to access 'https://github.com/argszero/emrg.git/': " - "Failed to connect to github.com port 443 after 4004 ms: " - "Couldn't connect to server" - ), - ) - orig_run = mod.subprocess.run - orig_origin = mod.git_origin_url - mod.subprocess.run = fake - mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" - try: - handler._ensure_origin_reachable() - finally: - mod.subprocess.run = orig_run - mod.git_origin_url = orig_origin - - set_url_calls = [ - c for c in fake.calls - if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" - ] - assert len(set_url_calls) == 1, f"expected one set-url, got {fake.calls}" - assert set_url_calls[0][0][4] == "git@github.com:argszero/emrg.git" - - -def test_ensure_origin_reachable_probes_only_once(tmp_path): - """One-shot probe: a second call never re-runs git.""" - from emrg.server import scheduler as mod - - handler = _make_handler(tmp_path, path=str(tmp_path)) - handler._origin_probed = False - fake = FakeGitRun( - origin_url="https://github.com/argszero/emrg.git", - ls_remote_stderr="fatal: unable to access: Failed to connect", - ) - orig_run = mod.subprocess.run - orig_origin = mod.git_origin_url - mod.subprocess.run = fake - mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" - try: - handler._ensure_origin_reachable() - handler._ensure_origin_reachable() - finally: - mod.subprocess.run = orig_run - mod.git_origin_url = orig_origin - - set_url_calls = [ - c for c in fake.calls - if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" - ] - assert len(set_url_calls) == 1 - - -def test_ensure_origin_reachable_keeps_https_when_reachable(tmp_path): - """ls-remote succeeds → origin untouched.""" - from emrg.server import scheduler as mod - - handler = _make_handler(tmp_path, path=str(tmp_path)) - handler._origin_probed = False - fake = FakeGitRun(origin_url="https://github.com/argszero/emrg.git") - orig_run = mod.subprocess.run - orig_origin = mod.git_origin_url - mod.subprocess.run = fake - mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" - try: - handler._ensure_origin_reachable() - finally: - mod.subprocess.run = orig_run - mod.git_origin_url = orig_origin - - set_url_calls = [ - c for c in fake.calls - if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" - ] - assert set_url_calls == [] - - -def test_ensure_origin_reachable_ignores_non_connection_errors(tmp_path): - """Auth/404 failures never switch the origin.""" - from emrg.server import scheduler as mod - - handler = _make_handler(tmp_path, path=str(tmp_path)) - handler._origin_probed = False - fake = FakeGitRun( - origin_url="https://github.com/argszero/emrg.git", - ls_remote_stderr="remote: Repository not found.", - ) - orig_run = mod.subprocess.run - orig_origin = mod.git_origin_url - mod.subprocess.run = fake - mod.git_origin_url = lambda cwd: "https://github.com/argszero/emrg.git" - try: - handler._ensure_origin_reachable() - finally: - mod.subprocess.run = orig_run - mod.git_origin_url = orig_origin - - set_url_calls = [ - c for c in fake.calls - if Path(c[0][0]).name.lower() in ("git", "git.exe") and c[0][1] == "remote" and c[0][2] == "set-url" - ] - assert set_url_calls == [] - - -def test_ensure_evolution_workspace_clone_falls_back_to_ssh(tmp_path): - """https clone connection failure → retried via SSH, workspace usable.""" - import pathlib as _pathlib - - from emrg.server import scheduler as mod - - evolve_dir = tmp_path / "evolution" / "emrg" - 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" - - 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", - clone_fails=True, clone_fail_once=True, - clone_stderr=( - "fatal: unable to access 'https://github.com/argszero/emrg.git/': " - "Failed to connect to github.com port 443 after 10013 ms: " - "Couldn't connect to server" - ), - ) - 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) - assert handler._workspace_heal_failures == 0 # success resets backoff - clone_calls = [c for c in fake.calls if "clone" in c[0]] - assert len(clone_calls) == 2, f"expected https + ssh clone, got {fake.calls}" - assert clone_calls[0][0][-2] == "https://github.com/argszero/emrg.git" - assert clone_calls[1][0][-2] == "git@github.com:argszero/emrg.git" - # rant 2026-08-19T00:54:32 — short connect timeouts on both transports: - # https bounded by http.connectTimeout=5, SSH by GIT_SSH_COMMAND - # ConnectTimeout=5 (a blocked port must fail fast, not eat ~30s per cycle). - assert any("http.connectTimeout=5" in str(c) for c in clone_calls[0][0]), clone_calls - ssh_env = clone_calls[1][2] or {} - assert ssh_env.get("GIT_SSH_COMMAND") == "ssh -o ConnectTimeout=5", ssh_env - - -def test_workspace_heal_backoff_skips_retries(tmp_path): - """rant 2026-08-19T00:54:32 — clone/self-heal failure arms exponential - backoff; cycles before the window skip the self-heal entirely (no - blocking git attempt), so a network-down daemon stops wedging websocket - clients every 60s.""" - 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 - orig_config = mod.config_dir - mod.subprocess.run = fake - mod.config_dir = lambda: tmp_path - try: - ok = handler._ensure_evolution_workspace() - assert ok is False - assert handler._workspace_heal_failures == 1 - assert handler._workspace_heal_next_retry_at > 0 - # Within the backoff window → skipped, no git attempted again - before = len(fake.calls) - ok2 = handler._ensure_evolution_workspace() - assert ok2 is False - assert len(fake.calls) == before, f"git retried during backoff: {fake.calls}" - # Window expires (simulated) → retries, failure re-arms with 2x delay - handler._workspace_heal_next_retry_at = 0 - ok3 = handler._ensure_evolution_workspace() - assert ok3 is False - assert handler._workspace_heal_failures == 2 - assert len(fake.calls) > before - finally: - mod.subprocess.run = orig_run - mod.EVOLUTION_CWD = orig_evolve - mod.config_dir = orig_config - - -def test_run_evolution_cycle_offloads_workspace_heal_to_thread(): - """rant 2026-08-19T00:54:32 — the workspace self-heal runs synchronous - git subprocesses (clone/ls-remote/config); it must be offloaded with - asyncio.to_thread so the event loop is never blocked (a slow/failing - clone previously wedged every websocket client for ~30s per cycle).""" - import inspect - - from emrg.server import scheduler as mod - - src = inspect.getsource(mod.TaskHandler._run_evolution_cycle) - assert "await asyncio.to_thread(self._ensure_evolution_workspace)" in src - - # ── TaskHandler cycle truncation detection ────────────────── # mem-repo lesson (tool-call truncation must be flagged, not silently # treated as a successful/empty cycle — #523 applied it to the chat UI; @@ -1639,14 +1148,28 @@ async def _fake_connect(): return _FakeWS(frames) handler = _make_handler(tmp_path, project="", path=str(tmp_path)) - handler._ensure_evolution_workspace = lambda: True handler._build_evolution_prompt = lambda: "test prompt" - captured = {} - async def _fake_write_log(log): - captured["log"] = log - handler._write_evolution_log = _fake_write_log mod.connect_to_server = _fake_connect - return handler, captured + + class _CapturedLog(dict): + """Lazily resolves `captured["log"]` to handler.evolutions[-1]. + + The cycle now keeps logs in the in-memory list only (rant + 2026-08-19T14:18:40 — _write_evolution_log deleted); `"log" not in + captured` stays True while no cycle completed. + """ + + def __contains__(self, key): + if key == "log": + return bool(handler.evolutions) + return super().__contains__(key) + + def __getitem__(self, key): + if key == "log": + return handler.evolutions[-1] + return super().__getitem__(key) + + return handler, _CapturedLog() def test_evolution_cycle_truncated_not_empty_not_complete(tmp_path): @@ -2000,21 +1523,17 @@ def test_saturation_heartbeat_log_message_no_skip(tmp_path, caplog): def test_saturation_heartbeat_makes_no_network_calls(tmp_path): """Saturation judgment never touches the network (rant 2026-08-18T20:32:07 — the old _remote_advanced ls-remote blocked the event loop; the check is - gone entirely, recovery happens via cycle output resetting the counter).""" + gone entirely, recovery happens via cycle output resetting the counter). + scheduler no longer imports subprocess at all (rant 2026-08-19T14:20:52 + deleted the self-heal git machinery) — no subprocess can be called.""" from emrg.server import scheduler as mod handler = _make_handler(tmp_path, project="", path=str(tmp_path)) handler._empty_cycles = 30 - def boom(*a, **kw): - raise AssertionError("subprocess.run must not be called from saturation") - - orig_run = mod.subprocess.run - mod.subprocess.run = boom - try: - assert handler._saturation_heartbeat_active() is True - finally: - mod.subprocess.run = orig_run + assert not hasattr(mod, "subprocess"), \ + "scheduler must not import subprocess anymore (self-heal deleted)" + assert handler._saturation_heartbeat_active() is True assert handler._empty_cycles == 30