diff --git a/Agent.md b/Agent.md index 97cba06..5fdbd44 100644 --- a/Agent.md +++ b/Agent.md @@ -112,7 +112,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` (744) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (746) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (229: 44 daemon_client + 19 conn-manager + 22 app-commands + 107 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` + 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/git_utils.py b/emrg/server/git_utils.py index e6df14f..77e6996 100644 --- a/emrg/server/git_utils.py +++ b/emrg/server/git_utils.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging import os import re import shutil @@ -15,6 +16,13 @@ INSTALL_BIN = Path.home() / ".emrg" / "install" / "bin" INSTALL_INFO = config_dir() / "install-info.json" +logger = logging.getLogger(__name__) + +# One-shot guard: when no git executable can be resolved at all, log the +# root cause once per process (2026-08-12 incident follow-up — the daemon +# restarted without PATH git and cycles were silently skipped for 18 min). +_GIT_MISSING_WARNED = False + # ── Non-interactive subprocess environment (rant 2026-08-07T10:17:27) ── # @@ -231,11 +239,38 @@ def resolve_git_gh() -> tuple[str, str]: else: gh = _tool_in_install("gh") or (shutil.which("gh") or "") - if git or gh: + if git: _cache_tool_paths(git, gh) + else: + # git is the failure mode that silently disables evolution (2026-08-12 + # incident) — warn regardless of whether gh resolved. Also skip the + # cache write so a previously valid cached git_path is not clobbered + # with '' (review #714 note). + _warn_git_missing_once() return git, gh +def _warn_git_missing_once() -> None: + """Log one actionable WARNING when no git executable can be resolved. + + 2026-08-12 incident follow-up: a daemon restart in an environment with + neither bundled nor PATH git made every evolution git call raise + FileNotFoundError; _is_usable_git_repo() swallowed the OSError and the + cycle log only said "workspace not ready — skipping cycle" for 18 + minutes. Log the root cause once per process so the daemon log is + diagnosable. + """ + global _GIT_MISSING_WARNED + if _GIT_MISSING_WARNED: + return + _GIT_MISSING_WARNED = True + logger.warning( + "git executable not found (install-info / ~/.emrg/install / PATH all " + "empty) — evolution cycles will be skipped as 'not a git repo'; " + "install git or add it to PATH, then restart the daemon" + ) + + def git_cmd(*args: str, cwd: str | None = None, timeout: int = 10) -> subprocess.CompletedProcess: """Run a git command using the resolved git binary. diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py index dd5f650..aef94b6 100644 --- a/tests/test_git_utils.py +++ b/tests/test_git_utils.py @@ -288,3 +288,64 @@ def test_git_origin_url_missing_remote(tmp_path): real_subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) assert git_origin_url(str(tmp_path)) == "" + + +def test_resolve_git_gh_warns_once_when_git_missing(monkeypatch, caplog): + """Total git absence logs one actionable WARNING (2026-08-12 incident). + + A daemon restart with neither bundled nor PATH git used to fail silently + (FileNotFoundError swallowed by _is_usable_git_repo → cycles skipped as + "not a git repo"). The one-shot warning makes the root cause visible. + """ + import logging + + from emrg.server import git_utils as gu + + # Force all three resolution sources empty (no install-info / bundled / PATH). + monkeypatch.setattr(gu, "_cached_tool_path", lambda tool: None) + monkeypatch.setattr(gu, "_tool_in_install", lambda tool: None) + monkeypatch.setattr(gu.shutil, "which", lambda tool: None) + monkeypatch.setattr(gu, "_GIT_MISSING_WARNED", False) + + with caplog.at_level(logging.WARNING, logger="emrg.server.git_utils"): + git, gh = gu.resolve_git_gh() + assert git == "" and gh == "" + gu.resolve_git_gh() # second call must NOT re-warn + + warns = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert len(warns) == 1 + assert "git executable not found" in warns[0].getMessage() + + +def test_resolve_git_gh_warns_when_git_missing_but_gh_present(monkeypatch, caplog): + """Git-missing must warn even when gh resolves (review #714 finding). + + The failure mode is git-specific: a dev box with gh on PATH but no git + would previously skip the warning entirely (the old `else` of `if git or + gh`) yet still silently disable evolution. The warning is gated on git + alone; the cache write is skipped so a previously valid cached git_path + is not clobbered with ''. + """ + import logging + + from emrg.server import git_utils as gu + + def fake_cache(git: str, gh: str) -> None: + assert git == "", f"cache must not be written with empty git, got {git=} {gh=}" + raise AssertionError("_cache_tool_paths should not be called when git is missing") + + monkeypatch.setattr(gu, "_cached_tool_path", lambda tool: None) + monkeypatch.setattr(gu, "_tool_in_install", lambda tool: None) + # gh resolves via PATH, git does not. + monkeypatch.setattr(gu.shutil, "which", lambda tool: "/usr/bin/gh" if tool == "gh" else None) + monkeypatch.setattr(gu, "_cache_tool_paths", fake_cache) + monkeypatch.setattr(gu, "_GIT_MISSING_WARNED", False) + + with caplog.at_level(logging.WARNING, logger="emrg.server.git_utils"): + git, gh = gu.resolve_git_gh() + assert git == "" and gh == "/usr/bin/gh" + gu.resolve_git_gh() # second call must NOT re-warn + + warns = [r for r in caplog.records if r.levelno >= logging.WARNING] + assert len(warns) == 1 + assert "git executable not found" in warns[0].getMessage()