diff --git a/Agent.md b/Agent.md index 375ec46..f6fd557 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.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (994) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (995) — 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 + 128 renderer smoke + 15 i18n + 8 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/__main__.py b/emrg/__main__.py index a42cd8a..15baf7f 100644 --- a/emrg/__main__.py +++ b/emrg/__main__.py @@ -70,7 +70,7 @@ def _build_parser() -> argparse.ArgumentParser: # (no action = foreground run, handled in main()) # emrg stop - sub.add_parser( + stop_parser = sub.add_parser( "stop", help="Stop ALL running emrg processes (daemon, TUI, GUI)", description="Stop every running emrg process: the daemon, TUI clients and " @@ -78,6 +78,13 @@ def _build_parser() -> argparse.ArgumentParser: "bundled git tree on Windows and exits non-zero when residual processes " "remain (used by the Windows installer pre-stop).", ) + stop_parser.add_argument( + "--skip-gui", + action="store_true", + help="Do not stop the GUI app and exclude it from the residual verify " + "(used by the GUI's restart-to-apply: the GUI is the caller and " + "relaunches itself after stop_all exits).", + ) # emrg update sub.add_parser( @@ -123,7 +130,9 @@ def main() -> None: elif parsed.command == "stop": # Windows installer pre-stop depends on the non-zero exit code when # residual processes remain (emrg/_stop_all.py owns the full logic). - sys.exit(_stop_all()) + # --skip-gui (rant 2026-08-21T12:44:34): the GUI's restart-to-apply + # calls `emrg stop --skip-gui` so its own process survives to relaunch. + sys.exit(_stop_all(skip_gui=getattr(parsed, "skip_gui", False))) elif parsed.command == "update": _run_update() else: diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index e258e91..936a0a7 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -249,6 +249,30 @@ class DaemonClient { return path.join(bin, name); } + // Rant 2026-08-21T12:44:34(restart-to-apply 跟进):打包模式下 `emrg` 包位于 + // ~/.emrg/install/{source,lib},宿主 PATH 的 python3 上没有 `emrg` 可导入—— + // 重启流程必须用安装版运行时 python(与 bin/emrgd 启动器同一解析逻辑)。 + _findInstalledPython() { + const bin = path.join(os.homedir(), ".emrg", "install", "bin"); + if (process.platform === "win32") { + // R100:bin/python(复制品)缺 DLL 不可用(DLL 在 python-dist/ 根) + for (const n of ["python-dist\\python.exe", "python-dist\\python3.13.exe", "python.exe"]) { + const p = path.join(bin, n); + try { fs.accessSync(p); return p; } catch { /* next candidate */ } + } + return "python"; + } + const p = path.join(bin, "python"); + try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* fallthrough */ } + return "python3"; + } + + // 安装版 PYTHONPATH 前缀(等价 bin/emrgd 的 PYTHONPATH="$PREFIX/source:$PREFIX/lib")。 + _installedPythonPath() { + const prefix = path.join(os.homedir(), ".emrg", "install"); + return [path.join(prefix, "source"), path.join(prefix, "lib")].join(path.delimiter); + } + _findPython() { // G59/G61/G126:优先项目 .venv,其次 PATH python3/python const root = path.resolve(__dirname, "..", ".."); diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 039d79a..c7420fd 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -332,13 +332,22 @@ vision = false // relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) → // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知 // 重启(直接被杀,不会进重连循环)。 + // 打包模式:宿主 PATH python3 无 `emrg` 可导入 → 用安装版运行时 python + + // PYTHONPATH(daemon_client._findInstalledPython/_installedPythonPath)。 ipcMain.handle("emrg:restartDaemon", async () => { - const python = connManager?.daemonConn()?._findPython() || "python3"; + const dc = connManager?.daemonConn(); + const isPackaged = !!dc?._isPackaged; + const python = isPackaged && dc + ? dc._findInstalledPython() + : (dc?._findPython() || "python3"); + const spawnOpts = { + cwd: os.homedir(), + stdio: ["ignore", "ignore", "pipe"], + }; + if (isPackaged && dc) spawnOpts.env = { ...process.env, PYTHONPATH: dc._installedPythonPath() }; + if (process.platform === "win32") spawnOpts.windowsHide = true; // 防黑窗(源码模式同 G68) const result = await new Promise((resolve) => { - const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], { - cwd: os.homedir(), - stdio: ["ignore", "ignore", "pipe"], - }); + const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], spawnOpts); let err = ""; child.stderr?.on("data", (d) => { err += String(d); }); child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` })); diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 39e5f6a..584dc9e 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -811,6 +811,11 @@ test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.r assert.ok(mainSrc.includes("app.relaunch()"), "stop_all exit 0 → app.relaunch()"); assert.ok(mainSrc.includes("app.exit(0)"), "relaunch 后立即 app.exit(0) 退出旧进程"); assert.ok(!mainSrc.includes('conn.sendCommand("shutdown"'), "不再只发 shutdown(daemon 会被 TUI 拉回 + GUI 永不重连)"); + assert.ok(mainSrc.includes("_findInstalledPython()"), "打包模式应使用安装版运行时 python"); + assert.ok(mainSrc.includes("_installedPythonPath()"), "打包模式应注入安装版 PYTHONPATH"); + const clientSrc = fs.readFileSync(path.join(GUI_DIR, "daemon_client.js"), "utf8"); + assert.ok(clientSrc.includes("_findInstalledPython()"), "daemon_client 应提供安装版 python 解析"); + assert.ok(clientSrc.includes("_installedPythonPath()"), "daemon_client 应提供安装版 PYTHONPATH"); }); test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => { diff --git a/tests/test_installer_stop.py b/tests/test_installer_stop.py index 00485a1..436b614 100644 --- a/tests/test_installer_stop.py +++ b/tests/test_installer_stop.py @@ -240,7 +240,11 @@ def test_main_delegates_stop_to_stop_all(): # stop 子命令帮助文案不再引用 stop-emrg.cmd assert "stop-emrg.cmd" not in content # main() 对 stop 分支 sys.exit(_stop_all());_stop_all 委托 emrg._stop_all - assert "sys.exit(_stop_all())" in content + assert "sys.exit(_stop_all(" in content + # --skip-gui(rant 2026-08-21T12:44:34):GUI restart-to-apply 走 + # `emrg stop --skip-gui`,经安装版启动器 PYTHONPATH 使 emrg 可导入 + assert "--skip-gui" in content + assert "skip_gui=getattr(parsed, \"skip_gui\", False)" in content assert "from emrg._stop_all import stop_all" in content diff --git a/tests/test_main.py b/tests/test_main.py index fb2f896..fbf49ba 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -66,6 +66,16 @@ def test_stop_command(self): parser = _build_parser() args = parser.parse_args(["stop"]) assert args.command == "stop" + assert args.skip_gui is False + + def test_stop_command_skip_gui(self): + """emrg stop --skip-gui (rant 2026-08-21T12:44:34): the GUI's + restart-to-apply calls the CLI so the install launcher's PYTHONPATH + makes `emrg` importable for the packaged app.""" + parser = _build_parser() + args = parser.parse_args(["stop", "--skip-gui"]) + assert args.command == "stop" + assert args.skip_gui is True class TestMatchEmrgClient: