From 2b699b5eeaac9f0fdd4d90efd8fabc031a5f435b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 21 Aug 2026 12:59:13 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20GUI=20daemon=20heartbeat=20=E2=80=94=20?= =?UTF-8?q?periodic=20liveness=20probe=20+=20active=20reconnect=20(rant=20?= =?UTF-8?q?2026-08-21T12:44:34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 4 +-- emrg/gui/main.js | 43 ++++++++++++++++++++++++++-- emrg/gui/test/renderer.smoke.test.js | 13 +++++++++ 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/Agent.md b/Agent.md index 2530a48a..28bc637d 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand) - Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端" - Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout) - - Unit tests `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); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `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); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -119,7 +119,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg ``` Python: `uv run pytest tests/ -v` (988) — 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` +GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 128 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/gui/main.js b/emrg/gui/main.js index fb998f55..56a62a0d 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -42,6 +42,11 @@ function main() { // (对称 TUI app.py _throttle_warned,PR #594)。成功连接后复位。 let daemonStoppedNotified = false; let stopping = false; + // Rant 2026-08-21T12:44:34:定时探活心跳——daemon 级 disconnected 事件此前无人 + // 监听(grep 证实),断连只能靠 ws close 被动通知;心跳每 15s ping 一次, + // 无 pong 或连接已断 → 主动 scheduleReconnect(指数退避已有)。 + let heartbeatTimer = null; + const HEARTBEAT_MS = 15_000; // P4(rant 15:07:19):跨项目打开的会话状态——sid → {projectName, projectPath, // lastActive}。写盘防抖 1s(打开/关闭/切换时更新,镜像 rant 设计)。 let openSessions = new Map(); @@ -1053,6 +1058,7 @@ vision = false cancelReconnect(); reconnectDelayMs = 1000; // 退避复位 daemonStoppedNotified = false; // 节流提示复位(下个生命周期可再提示) + startHeartbeat(); // rant 2026-08-21T12:44:34:定时探活(断连主动重连) sendToRenderer("status", { connected: true }); } catch (e) { const dm = connManager.daemonConn(); @@ -1077,6 +1083,7 @@ vision = false // 时 daemon 连接不可用"的初始/空闲场景)。 function scheduleReconnect() { if (stopping || reconnectTimer) return; + stopHeartbeat(); // 重连期间不再心跳(心跳失败路径会再调本函数,防叠) const delay = reconnectDelayMs; reconnectDelayMs = Math.min(reconnectDelayMs * 2, MAX_RECONNECT_DELAY_MS); // 指数退避 reconnectTimer = setTimeout(async () => { @@ -1100,8 +1107,8 @@ vision = false if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } } - async function waitForPong(timeoutMs = 3000) { - const conn = activeConn(); + async function waitForPong(timeoutMs = 3000, connOverride = null) { + const conn = connOverride || activeConn(); if (!conn || !conn.connected) return null; // 未连接 → 直接超时语义(不抛) return new Promise((resolve) => { const off = conn.onEvent((type, data) => { @@ -1116,6 +1123,37 @@ vision = false }); } + // ── 心跳探活(rant 2026-08-21T12:44:34)───────────────────── + function startHeartbeat() { + if (heartbeatTimer || stopping) return; + heartbeatTimer = setInterval(() => { _heartbeatTick(); }, HEARTBEAT_MS); + logger.info(`[gui] heartbeat started (every ${HEARTBEAT_MS / 1000}s)`); + } + + function stopHeartbeat() { + if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; } + } + + async function _heartbeatTick() { + if (stopping) { stopHeartbeat(); return; } + const conn = connManager?.daemonConn(); + if (!conn) return; + if (!conn.connected) { + // daemon 级断连无人监听(旧缺陷)→ 心跳主动补触发重连 + logger.warn("[gui] heartbeat: daemon connection dropped — scheduling reconnect"); + stopHeartbeat(); + scheduleReconnect(); + return; + } + const pong = await waitForPong(3000, conn); + if (!pong) { + logger.warn("[gui] heartbeat: no pong from daemon — scheduling reconnect"); + stopHeartbeat(); + try { conn.close(); } catch { /* ignore */ } + scheduleReconnect(); + } + } + async function listSessions(cwd = DEFAULT_CWD) { try { const frame = await requireConn().sendCommandAndWait("list_sessions", { cwd }, 5000); @@ -1252,6 +1290,7 @@ vision = false app.on("window-all-closed", () => { stopping = true; cancelReconnect(); + stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器 persistGuiStateNow(); // P4:退出前冲刷未落盘的打开会话状态(防抖 timer 取消) connManager?.closeAll(); // P2:关闭全部会话连接 + daemon 级连接 // P2.3:窗口关闭自动销毁子 view(R5-⑤ 无需手动清理);复位状态防二次使用 diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 98169ae2..93274ced 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -787,6 +787,19 @@ test("rant 18:30:57:版本变化 → 升级横幅出现 + 重启按钮触发 r assert.ok(preloadSrc.includes("restartDaemon"), "preload 应暴露 restartDaemon"); }); +test("rant 12:44:34: main.js 心跳探活已接入(断连主动重连)", async () => { + const GUI_DIR = path.join(__dirname, ".."); + const mainSrc = fs.readFileSync(path.join(GUI_DIR, "main.js"), "utf8"); + assert.ok(mainSrc.includes("HEARTBEAT_MS"), "应有心跳间隔常量"); + assert.ok(mainSrc.includes("function startHeartbeat"), "应有 startHeartbeat"); + assert.ok(mainSrc.includes("function stopHeartbeat"), "应有 stopHeartbeat"); + assert.ok(mainSrc.includes("function _heartbeatTick"), "应有心跳 tick"); + assert.ok(mainSrc.includes("waitForPong"), "心跳复用 waitForPong 探测"); + assert.ok(mainSrc.includes("scheduleReconnect"), "无 pong/连接断开 → 触发重连"); + assert.ok(mainSrc.includes("startHeartbeat(); // rant 2026-08-21T12:44:34"), "连接成功后启动心跳"); + assert.ok(mainSrc.includes("stopHeartbeat(); // rant 2026-08-21T12:44:34:退出清理心跳定时器"), "窗口关闭清理心跳"); +}); + test("右键菜单:重命名对话框 → renameSession 调用(设计 §3.2)", async () => { let renamed = null; const { ctx, els } = makeSandbox({