Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
emrg: GUI restart-to-apply — full-chain stop via _stop_all --skip-gui + app.relaunch (rant 2026-08-21T12:44:34) by argszero · Pull Request #915 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: GUI restart-to-apply — full-chain stop via _stop_all --skip-gui + app.relaunch (rant 2026-08-21T12:44:34) by argszero · Pull Request #915 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: GUI restart-to-apply — full-chain stop via _stop_all --skip-gui + app.relaunch (rant 2026-08-21T12:44:34) by argszero · Pull Request #915 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' emrg: GUI restart-to-apply — full-chain stop via _stop_all --skip-gui + app.relaunch (rant 2026-08-21T12:44:34) by argszero · Pull Request #915 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: GUI restart-to-apply — full-chain stop via _stop_all --skip-gui + app.relaunch (rant 2026-08-21T12:44:34) by argszero · Pull Request #915 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' emrg: GUI restart-to-apply — full-chain stop via _stop_all --skip-gui + app.relaunch (rant 2026-08-21T12:44:34) by argszero · Pull Request #915 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); emrg: GUI restart-to-apply — full-chain stop via _stop_all --skip-gui + app.relaunch (rant 2026-08-21T12:44:34) by argszero · Pull Request #915 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,8 @@ 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` (989) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (256: 45 daemon_client + 19 conn-manager + 22 app-commands + 127 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`
Python: `uv run pytest tests/ -v` (994) — 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 路径不受影响)

Expand Down
140 changes: 105 additions & 35 deletions emrg/_stop_all.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,15 @@
them immediately re-spawn it, so the stop "stops nothing" and the installer
still hits locked files. With the daemon last, no client remains to bring
it back, and verify() sees the true final state.

``--skip-gui`` mode (host rant 2026-08-21T12:44:34, GUI "restart to apply"):
the GUI itself invokes this module as ``python -m emrg._stop_all --skip-gui``
to tear down every TUI client + the daemon before relaunching itself. The
GUI process MUST be skipped by both the step plan and the residual verify —
otherwise ``stop_gui`` (taskkill /IM EMRG.exe / ps-scan EMRG.app) kills the
GUI main process that is supposed to ``app.relaunch()`` right after, and
verify() would report the (intentionally still-alive) GUI as a residual and
exit 1. The GUI performs the relaunch itself.
"""

from __future__ import annotations
Expand All@@ -58,7 +67,7 @@

# Build stamp printed at the start of every run so the operator can tell at a
# glance which stop_all.py generation executed (rant 2026-08-17T21:06:31).
_STOP_ALL_STAMP = "built 2026-08-19 (read-only lock probe + stop-chain caller log — rants 13:08:41 + 13:11:34)"
_STOP_ALL_STAMP = "built 2026-08-21 (--skip-gui mode for GUI restart-to-apply — rant 12:44:34)"

# Fixed daemon port (host rant 2026-08-19T08:05:21): the daemon binds a fixed
# loopback port as its single-instance admission. This module is pure stdlib
Expand DownExpand Up@@ -100,20 +109,46 @@ def _no_window() -> dict:
_WIN_PY_NAME_RE = r"^python.*\.exe$"


def _is_gui_cmdline(cmd: str) -> bool:
"""True when a command line belongs to the GUI app (EMRG.app / AppImage).

Used by ``--skip-gui`` mode to exclude the (intentionally alive) GUI
caller from the residual verify, and by :func:`match_cmdline` for the
plain stop scan.
"""
return "EMRG.app" in cmd or bool(_APPIMAGE_RE.search(cmd))


def match_cmdline(cmd: str) -> bool:
"""True if a command line belongs to an emrg process.

Matches ``-m emrg`` / ``-m emrg.server`` (TUI + daemon), ``EMRG.app``
(macOS GUI) and ``EMRG-*.AppImage`` (Linux AppImage). Does NOT match
lookalikes such as ``-m emrg.serverless`` or ``-m emrgx``.
"""
if "EMRG.app" in cmd:
return True
if _APPIMAGE_RE.search(cmd):
if _is_gui_cmdline(cmd):
return True
return bool(_EMRG_CLIENT_RE.search(cmd))


def _iter_ps_lines(ps_output: str) -> list[tuple[int, str]]:
"""Parse ``ps -axww -o pid=,command=`` output into ``(pid, cmdline)``
pairs (used by the ``--skip-gui`` verify filter to identify GUI pids)."""
pairs: list[tuple[int, str]] = []
for line in ps_output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(None, 1)
if len(parts) != 2:
continue
try:
pairs.append((int(parts[0]), parts[1]))
except ValueError:
continue
return pairs


def scan_pids(ps_output: str, own_pid: int) -> list[int]:
"""Parse ``ps -axww -o pid=,command=`` output → pids of emrg processes.

Expand DownExpand Up@@ -1222,25 +1257,30 @@ def _to_rel(p: str) -> str:
return self_held, residual


def _verify_windows_categories() -> list[tuple[str, list[str]]]:
def _verify_windows_categories(skip_gui: bool = False) -> list[tuple[str, list[str]]]:
"""Windows residual scan, one ``(category, residual_strings)`` entry per
check — so the operator can see each check's result instead of guessing
(rant 2026-08-17T21:06:31 #3). Result is cached in ``_windows_cats_cache``
so _verify_windows_summary() does not re-run the expensive scan."""
so _verify_windows_summary() does not re-run the expensive scan.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI is
the caller and must not be reported as a residual (it intentionally
survives to relaunch itself)."""
global _windows_cats_cache
cats: list[tuple[str, list[str]]] = []

# GUI residual
gui: list[str] = []
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
if not skip_gui:
try:
out = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq EMRG.exe"],
capture_output=True, text=True, timeout=10, **_no_window(),
).stdout
for m in re.finditer(r"EMRG\.exe\s+(\d+)", out):
gui.append(f"EMRG.exe (pid {m.group(1)})")
except (OSError, subprocess.SubprocessError, TimeoutError):
pass
cats.append(("GUI", gui))

# daemon residual (emrgd.pid still alive)
Expand DownExpand Up@@ -1368,21 +1408,34 @@ def _verify_windows_summary() -> str:
return " / ".join(f"{name} {len(items)}" for name, items in cats)


def _verify_windows() -> list[str]:
def _verify_windows(skip_gui: bool = False) -> list[str]:
residuals: list[str] = []
for _name, items in _verify_windows_categories():
for _name, items in _verify_windows_categories(skip_gui=skip_gui):
residuals.extend(items)
return residuals


def _verify_posix() -> list[str]:
return [f"emrg process (pid {pid})" for pid in _stop_scan_pids(os.getpid())]


def verify() -> list[str]:
def _verify_posix(skip_gui: bool = False) -> list[str]:
"""POSIX residual scan. ``skip_gui=True`` (``--skip-gui``) drops the
GUI's own pids (EMRG.app / EMRG-*.AppImage) from the residual list —
the GUI is the caller and intentionally stays alive to relaunch."""
pids = _stop_scan_pids(os.getpid())
if skip_gui:
out = _ps_output()
if out is not None:
gui_pids = {
pid
for pid, cmd in _iter_ps_lines(out)
if _is_gui_cmdline(cmd)
}
pids = [p for p in pids if p not in gui_pids]
return [f"emrg process (pid {pid})" for pid in pids]


def verify(skip_gui: bool = False) -> list[str]:
"""Scan for residual emrg processes. Returns a list of human-readable
``"name (pid N)"`` entries (empty = clean)."""
return _verify_windows() if is_win() else _verify_posix()
return _verify_windows(skip_gui=skip_gui) if is_win() else _verify_posix(skip_gui=skip_gui)


# ── Orchestration ───────────────────────────────────────────────
Expand DownExpand Up@@ -1514,25 +1567,33 @@ def _caller_context() -> str:
return f"caller pid {ppid} ({parent or 'unknown parent'}) | argv: {argv}"


def _step_plan() -> list[tuple[str, object]]:
def _step_plan(skip_gui: bool = False) -> 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
disappears, so stopping the daemon first would let a live client
immediately bring it back — leaving locked files for the installer.
Bundled git + RM lock-owner kill are Windows-only."""
Bundled git + RM lock-owner kill are Windows-only.

``skip_gui=True`` (``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
itself is the caller and relaunches after stop_all exits — its stop step
must be omitted, or the GUI main process gets killed before relaunch."""
if is_win():
return [
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
("bundled git", stop_bundled_git),
("file-lock owners", stop_lock_owners),
]
return [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
else:
steps = [
("GUI", stop_gui),
("TUI", stop_tui),
("daemon", stop_daemon),
]
if skip_gui:
steps = [s for s in steps if s[0] != "GUI"]
return steps


def _is_lock_residual(r: str) -> bool:
Expand All@@ -1547,9 +1608,14 @@ def _is_lock_residual(r: str) -> bool:
))


def stop_all() -> int:
def stop_all(skip_gui: bool = False) -> int:
"""Run every stop step, then verify. Returns 0 (clean) or 1 (residuals).

``skip_gui=True`` (CLI ``--skip-gui``, rant 2026-08-21T12:44:34): the GUI
invokes this to tear down TUI + daemon before relaunching itself — the
GUI stop step is skipped AND the GUI process is excluded from the
residual verify (it intentionally stays alive as the caller).

Logging follows the standard from rant 2026-08-17T21:06:31: header with
build stamp / python / platform / pid, ``[N/T] step -> result (elapsed)``
per step, per-category verify summary, exit-code line with total elapsed,
Expand DownExpand Up@@ -1592,7 +1658,7 @@ def stop_all() -> int:
_pp_warn = _pythonpath_install_warning(_pp)
if _pp_warn:
print(f"emrg stop: WARNING {_pp_warn}")
steps = _step_plan()
steps = _step_plan(skip_gui=skip_gui)
for i, (name, fn) in enumerate(steps, 1):
s = time.monotonic()
try:
Expand DownExpand Up@@ -1639,7 +1705,7 @@ def stop_all() -> int:
f"stop_all runtime itself (python-dist DLL) — released when "
f"stop_all exits; installer continues"
)
residuals = verify()
residuals = verify(skip_gui=skip_gui)
# Lock-related residuals are ADVISORY after escalation (rant
# 2026-08-18T21:24:48 #2c/#5): an unkillable external lock holder is
# logged in detail and the install CONTINUES — the installer's own
Expand DownExpand Up@@ -1683,7 +1749,11 @@ def stop_all() -> int:


def main() -> None:
code = stop_all()
# Rant 2026-08-21T12:44:34: --skip-gui — the GUI calls
# ``python -m emrg._stop_all --skip-gui`` to tear down TUI + daemon
# before relaunching itself; its own stop/verify checks are skipped.
skip_gui = "--skip-gui" in sys.argv[1:]
code = stop_all(skip_gui=skip_gui)
sys.exit(code)


Expand Down
38 changes: 28 additions & 10 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -322,17 +322,35 @@ vision = false

ipcMain.handle("emrg:listSessions", async () => listSessions());

// Rant 2026-08-20T18:30:57:一键"重启生效"——发 shutdown(source=gui-restart)让
// daemon 停;connManager 检测到全部掉线 → restart-recovery → ensureDaemon 用新
// 安装代码重新 spawn(GUI 本就是 daemon 生命周期 owner,不发子进程调 CLI)。
// Rant 2026-08-21T12:44:34:一键"重启生效"。旧实现只发 shutdown —— daemon 会被
// TUI 客户端拉回(TUI 断线自动重连+自动 spawn,emrg/client/app.py:378-394),
// 而 GUI 自己永不重连(实证 emrg-gui.log 11:41:杀 daemon 后 36 分钟无重连,
// 状态栏绿点假象 + "daemon not connected")。
// 新实现:spawn `python -m emrg._stop_all --skip-gui` —— 复用全链路 stop
// (顺序 GUI→TUI→daemon,客户端先死不会重拉 daemon;--skip-gui 跳过 stop_gui,
// 否则 taskkill /IM EMRG.exe / ps-scan EMRG.app 会杀掉 GUI 主进程本身,
// relaunch 永不执行)→ 等 exit 0 → GUI 自己 app.relaunch() + app.exit(0) →
// 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon。TUI 不需要感知
// 重启(直接被杀,不会进重连循环)。
ipcMain.handle("emrg:restartDaemon", async () => {
const conn = activeConn();
if (!conn || !conn.connected) throw new Error("daemon not connected");
try {
conn.sendCommand("shutdown", { source: "gui-restart" });
} catch (e) {
throw new Error(`shutdown failed: ${e.message}`);
}
const python = connManager?.daemonConn()?._findPython() || "python3";
const result = await new Promise((resolve) => {
const child = spawn(python, ["-m", "emrg._stop_all", "--skip-gui"], {
cwd: os.homedir(),
stdio: ["ignore", "ignore", "pipe"],
});
let err = "";
child.stderr?.on("data", (d) => { err += String(d); });
child.on("error", (e) => resolve({ ok: false, error: `spawn failed: ${e.message}` }));
child.on("close", (code) => resolve(
code === 0
? { ok: true }
: { ok: false, error: `stop_all exit ${code}: ${err.slice(-500)}` }
));
});
if (!result.ok) throw new Error(result.error);
app.relaunch(); // 新 GUI 进程启动 → ensureDaemon 用新安装代码 spawn 新 daemon
app.exit(0);
return { ok: true };
});

Expand Down
10 changes: 10 additions & 0 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -803,6 +803,16 @@ test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", asy
assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳");
});

test("rant 12:44:34: 重启生效走 _stop_all --skip-gui 全链路 stop + app.relaunch", async () => {
const GUI_DIR = path.join(__dirname, "..");
const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8");
assert.ok(mainSrc.includes("emrg:restartDaemon"), "main.js 应注册 emrg:restartDaemon IPC");
assert.ok(mainSrc.includes('"-m", "emrg._stop_all", "--skip-gui"'), "重启应 spawn python -m emrg._stop_all --skip-gui");
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 永不重连)");
});

test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => {
let renamed = null;
const { ctx, els } = makeSandbox({
Expand Down
Loading
Loading