diff --git a/Agent.md b/Agent.md index 7a2edacc..a047db65 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` (929) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (933) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 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/_stop_all.py b/emrg/_stop_all.py index 99eda2a9..4035107e 100644 --- a/emrg/_stop_all.py +++ b/emrg/_stop_all.py @@ -53,6 +53,7 @@ import subprocess import sys import time +from datetime import datetime from pathlib import Path # Build stamp printed at the start of every run so the operator can tell at a @@ -999,6 +1000,54 @@ def _pythonpath_install_warning(line: str) -> str | None: return None +class _Tee: + """Duplicate write()/flush() to BOTH the original stdout and a log file + (rant 2026-08-18T11:20:54). The Inno installer redirects stop_all stdout + to a random temp dir ({tmp}\\stop_all.log) that is deleted when the + install ends or is cancelled — the tee keeps a persistent fixed-path copy + (~/.emrg/logs/stop_all-.log) for post-mortem forensics. All existing + print() calls keep working untouched (they write to sys.stdout, which is + replaced with a _Tee during stop_all).""" + + def __init__(self, orig, f): + self.orig = orig + self.f = f + + def write(self, data): + self.orig.write(data) + self.f.write(data) + # Crash-safe: if the installer force-kills this process mid-write, + # append-mode + per-write flush guarantees the fixed-path copy has + # everything printed so far (rant: 不依赖 finally). + self.f.flush() + return len(data) + + def flush(self): + self.orig.flush() + self.f.flush() + + def isatty(self): + return self.orig.isatty() if hasattr(self.orig, "isatty") else False + + def fileno(self): + return self.orig.fileno() + + +def _open_stop_log() -> object | None: + """Open the fixed-path dual-write log (rant 2026-08-18T11:20:54): + ``~/.emrg/logs/stop_all-YYYYMMDD-HHMMSS.log`` (local time; the timestamp + name makes concurrent stop_all runs naturally isolated). Returns the file + object or None on any failure — best-effort, never breaks the stop flow. + The handle closes naturally at process exit (no finally dependency).""" + try: + d = os.path.join(os.path.expanduser("~"), ".emrg", "logs") + os.makedirs(d, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d-%H%M%S") + return open(os.path.join(d, f"stop_all-{ts}.log"), "a", encoding="utf-8") + except OSError: + return None + + def _step_plan() -> list[tuple[str, object]]: """Ordered stop steps. Clients (GUI/TUI) FIRST, daemon LAST (rant 2026-08-17T14:15:33): both clients auto-spawn the daemon when it @@ -1030,6 +1079,15 @@ def stop_all() -> int: ``ERROR : `` and the run continues to the final exit code). """ t0 = time.monotonic() + # Dual-write log to a fixed path (rant 2026-08-18T11:20:54): the Inno + # {tmp} redirect vanishes when the install ends/cancels — tee a persistent + # copy to ~/.emrg/logs/stop_all-.log. Every print below automatically + # lands in both. The line is also printed so the Inno-side log and the + # operator both see the fixed location. + _log_f = _open_stop_log() + if _log_f is not None: + sys.stdout = _Tee(sys.stdout, _log_f) + print(f"emrg stop: log also written to {_log_f.name}") print( f"emrg stop: stop_all.py {_STOP_ALL_STAMP} | " f"python {platform.python_version()} {platform.system()}-{platform.machine()} " diff --git a/tests/test_stop_all.py b/tests/test_stop_all.py index af874edf..9265fa45 100644 --- a/tests/test_stop_all.py +++ b/tests/test_stop_all.py @@ -39,7 +39,7 @@ def test_no_nonstdlib_imports(self): allowed = { "base64", "json", "os", "re", "secrets", "signal", "socket", "subprocess", "sys", "time", "pathlib", "platform", "ctypes", "ast", - "pytest", "annotations", "__future__", "winreg", + "pytest", "annotations", "__future__", "winreg", "datetime", } for node in ast.walk(tree): if isinstance(node, ast.Import): @@ -956,3 +956,62 @@ def test_install_warning_positive_and_negative(self): # unrelated install\lib path must NOT warn (pm25coder review note, PR #832) assert _stop_all._pythonpath_install_warning( r"PYTHONPATH(User)=C:\python\install\lib") is None + + +# ── Fixed-path dual-write log (rant 2026-08-18T11:20:54) ──────── + +class TestTeeDualWrite: + def test_tee_writes_to_both(self): + class _FakeFile: + def __init__(self): + self.data = [] + + def write(self, d): + self.data.append(d) + + def flush(self): + pass + + orig = _FakeFile() + logf = _FakeFile() + tee = _stop_all._Tee(orig, logf) + tee.write("line1\n") + tee.write("line2\n") + tee.flush() + assert orig.data == ["line1\n", "line2\n"] + assert logf.data == ["line1\n", "line2\n"] + + def test_open_stop_log_creates_logs_dir(self, monkeypatch, tmp_path): + """~/.emrg/logs is created and the file matches the timestamp pattern + (stop_all-YYYYMMDD-HHMMSS.log).""" + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + f = _stop_all._open_stop_log() + assert f is not None + name = f.name + f.close() + assert str(tmp_path / ".emrg" / "logs") in name + import re + assert re.search(r"stop_all-\d{8}-\d{6}\.log$", name), name + + def test_open_stop_log_oserror_returns_none(self, monkeypatch, tmp_path): + def boom(*a, **k): + raise OSError("cannot create logs dir") + + monkeypatch.setattr(_stop_all.os, "makedirs", boom) + assert _stop_all._open_stop_log() is None + + def test_stop_all_prints_tee_path(self, monkeypatch, tmp_path, capsys): + """stop_all() with a tee open prints the fixed-path line; stdout output + still flows (POSIX regression: emrg stop output unchanged).""" + monkeypatch.setattr(_stop_all, "is_win", lambda: False) + monkeypatch.setattr(_stop_all.os.path, "expanduser", lambda _: str(tmp_path)) + monkeypatch.setattr(_stop_all, "stop_gui", lambda: None) + monkeypatch.setattr(_stop_all, "stop_tui", lambda: None) + monkeypatch.setattr(_stop_all, "stop_daemon", lambda: None) + monkeypatch.setattr(_stop_all, "_stop_scan_pids", lambda own: []) + monkeypatch.setattr(_stop_all, "verify", lambda: []) + assert _stop_all.stop_all() == 0 + out = capsys.readouterr().out + assert "log also written to" in out + assert str(tmp_path / ".emrg" / "logs") in out + assert "exit code 0 (clean)" in out