From 4bd691ff9bccb102696b10e08ebef261aa78cb0c Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 10 Aug 2026 16:18:52 +0800 Subject: [PATCH] emrg: ConnManager daemon restart recovery (GUI multi-session rant P2 slice 4) --- Agent.md | 4 +- README.cn.md | 2 +- README.md | 2 +- emrg/gui/conn-manager.js | 65 +++++++++++++++++++++++- emrg/gui/test/conn-manager.test.js | 81 ++++++++++++++++++++++++++++++ 5 files changed, 149 insertions(+), 5 deletions(-) diff --git a/Agent.md b/Agent.md index be01370..95403da 100644 --- a/Agent.md +++ b/Agent.md @@ -66,7 +66,7 @@ EMRG is a self-evolving AI agent architecture experiment. Python implementation, - 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` (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (121: 31 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Auto project tracking** — Automatically detects and records working directories; project-scoped sessions - **Rant-driven evolution** — User feedback via `/rant` drives automatic self-improvement cycles - **Headless GitHub auth** — Non-interactive evolution auto-extracts `GH_TOKEN` from git credential store (osxkeychain / credential helper); PR comment/LGTM queries fall back to REST API (GraphQL needs `read:org` scope) @@ -94,7 +94,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (680) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (121: 31 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/README.cn.md b/README.cn.md index f604a7d..7feb0cd 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(118 项:31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(121 项:31 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 2bc5467..2c64e9a 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ uv run python -m emrg # launch TUI cd emrg/gui npm ci # install deps (production: --omit=dev) npm start # launch GUI (auto-starts daemon) -npm test # run Node tests (118: 31 daemon_client + 5 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (121: 31 daemon_client + 8 conn-manager + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config; integration runs in CI, local: npm run test:integration) ``` CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`). diff --git a/emrg/gui/conn-manager.js b/emrg/gui/conn-manager.js index 8bcbbb9..2986ff1 100644 --- a/emrg/gui/conn-manager.js +++ b/emrg/gui/conn-manager.js @@ -20,11 +20,16 @@ const { DaemonClient } = require("./daemon_client.js"); class ConnManager { - constructor({ projectDir, logger = console, isPackaged = false } = {}) { + constructor({ projectDir, logger = console, isPackaged = false, restartWindowMs = 1000 } = {}) { this.projectDir = projectDir; this.logger = logger; this.isPackaged = isPackaged; this._conns = new Map(); // sid -> { conn, projectPath } + // daemon 重启恢复(rant 15:07:19 P2):短窗口内所有连接同时断 → 判定 daemon 重启 + // → 全部重连重订阅;单条断 → 不做全量恢复(留给独立退避重试)。 + this._restartWindowMs = restartWindowMs; + this._disconnects = new Map(); // sid -> timestamp(最近一次断连) + this._recovering = false; // 恢复中守卫(防 close→disconnect→recoverAll 递归) } // 确保 daemon 已运行(connManager = daemon 生命周期唯一 owner)。 @@ -56,6 +61,13 @@ class ConnManager { }); await conn.ensureConnected({ skipStart: true }); // daemon 已就绪 → 只连不拉 await conn.sendCommandAndWait("resume_session", { session_id: sid, cwd: projectPath }, 5000); + // 断开监听 → 重启恢复判定(仅当所有打开会话在同一短窗口内断开) + conn.onEvent((type) => { + if (type === "disconnected") { + this._disconnects.set(sid, Date.now()); + this._onDisconnect(sid); + } + }); this._conns.set(sid, { conn, projectPath }); return conn; } @@ -66,6 +78,7 @@ class ConnManager { if (!entry) return false; entry.conn.close(); this._conns.delete(sid); + this._disconnects.delete(sid); return true; } @@ -82,6 +95,56 @@ class ConnManager { closeAll() { for (const sid of [...this._conns.keys()]) this.close(sid); } + + // ── daemon 重启恢复(rant 15:07:19 P2)────────────────────────────── + + // 所有当前打开会话都在重启窗口内断开 → 判定 daemon 重启。 + _restartDetected() { + const open = [...this._conns.keys()]; + if (open.length === 0) return false; + const now = Date.now(); + return open.every((sid) => { + const t = this._disconnects.get(sid); + return t !== undefined && now - t <= this._restartWindowMs; + }); + } + + _onDisconnect(sid) { + if (this._recovering) return; // 恢复中自己触发的断开不递归 + if (this._restartDetected()) { + this.logger.info( + `[gui] connManager: all ${this._conns.size} connection(s) dropped within ${this._restartWindowMs}ms — daemon restart detected, recovering` + ); + this.recoverAll().catch((e) => + this.logger.warn(`[gui] connManager recover failed: ${e.message}`) + ); + } + } + + // 全部重连重订阅(复用 open 序列:引导 daemon 就绪 → skipStart 会话连接 → + // resume_session)。单会话恢复失败跳过不阻塞其余(写盘/重试由后续片处理)。 + async recoverAll() { + if (this._recovering) return; + this._recovering = true; + const sessions = [...this._conns.entries()].map(([sid, entry]) => ({ + sid, + projectPath: entry.projectPath, + })); + try { + for (const { sid } of sessions) this.close(sid); + this._disconnects.clear(); + for (const { sid, projectPath } of sessions) { + try { + await this.open(sid, projectPath); + this.logger.info(`[gui] connManager recovered session ${sid}`); + } catch (e) { + this.logger.warn(`[gui] connManager recover: session ${sid} reopen failed: ${e.message}`); + } + } + } finally { + this._recovering = false; + } + } } module.exports = { ConnManager }; diff --git a/emrg/gui/test/conn-manager.test.js b/emrg/gui/test/conn-manager.test.js index 89b7162..bba0d15 100644 --- a/emrg/gui/test/conn-manager.test.js +++ b/emrg/gui/test/conn-manager.test.js @@ -175,3 +175,84 @@ test("P2 closeAll: 全部关闭", async () => { manager.closeAll(); assert.strictEqual(manager.all().length, 0); }); + +test("P2 重启恢复: 所有连接同窗口断开 → 判定重启并自动 recoverAll", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + const s1 = await driveOpen(manager, "sess-1", "/proj/a"); + const s2 = await driveOpen(manager, "sess-2", "/proj/b"); + assert.deepStrictEqual(manager.all().sort(), ["sess-1", "sess-2"]); + + // 第一条断 → 未全部断,不应判定重启 + let recoverCalls = 0; + manager.recoverAll = async () => { recoverCalls += 1; }; // 防真恢复挂起 + s1.sessionWs.emit("close"); + assert.strictEqual(manager._restartDetected(), false); + await new Promise((r) => setTimeout(r, 30)); + assert.strictEqual(recoverCalls, 0, "single drop must not trigger recovery"); + + // 第二条断(同窗口)→ 全部断 → 判定重启 + 自动 recoverAll + s2.sessionWs.emit("close"); + assert.strictEqual(manager._restartDetected(), true); + await new Promise((r) => setTimeout(r, 30)); + assert.strictEqual(recoverCalls, 1, "recoverAll must be triggered once on full drop"); +}); + +test("P2 重启恢复: 单条断开 → 不判定重启、不 recoverAll", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + const s1 = await driveOpen(manager, "sess-1", "/proj/a"); + await driveOpen(manager, "sess-2", "/proj/b"); + let recoverCalls = 0; + manager.recoverAll = async () => { recoverCalls += 1; }; + s1.sessionWs.emit("close"); // 只有一条断 + await new Promise((r) => setTimeout(r, 30)); + assert.strictEqual(manager._restartDetected(), false, "single drop must not detect restart"); + assert.strictEqual(recoverCalls, 0, "recoverAll must not be triggered on single drop"); +}); + +test("P2 重启恢复: recoverAll 重连重订阅全部会话(复用 open 序列)", async () => { + const manager = new ConnManager({ projectDir: tmpHome }); + const s1 = await driveOpen(manager, "sess-1", "/proj/a"); + const s2 = await driveOpen(manager, "sess-2", "/proj/b"); + // 关闭自动触发(本测试专注 recoverAll 本体;自动触发已在上一测试覆盖) + const origRecover = manager.recoverAll.bind(manager); + manager.recoverAll = async () => {}; + s1.sessionWs.emit("close"); + s2.sessionWs.emit("close"); + await new Promise((r) => setTimeout(r, 30)); + manager.recoverAll = origRecover; + + // 并发驱动恢复期间出现的每条连接(2 引导 + 2 会话 = 4 条) + // processed 集合:循环内创建的 ws 不会被跳过(prev 快照法会漏掉内循环期间 + // 新建的连接 → 死锁:等"下一个" ws 而当前未驱动的 ws 正卡住 open) + // stale 集合:恢复前已关闭的旧连接 ws(s1/s2 sessionWs)不算"新连接", + // 否则驱动会拿旧连接凑数(已发过 resume → 误判 reopened 提前退出) + let reopened = 0; + const processed = new Set(); + const stale = new Set([s1.sessionWs, s2.sessionWs]); + const deadline = Date.now() + 4000; + const driver = (async () => { + while (reopened < 2 && Date.now() < deadline) { + const ws = await waitForWs(() => !processed.has(currentMockWs) && !stale.has(currentMockWs)); + processed.add(ws); + await driveAuth(ws); + // 等 resume 帧(仅会话连接发);引导连接不发则直接跳过 + const d2 = Date.now() + 300; + while (ws.sent.length < 2 && Date.now() < d2) await new Promise((r) => setTimeout(r, 5)); + if (ws.sent.length >= 2) { + const f = JSON.parse(ws.sent.at(-1)); + if (f.type === "resume_session") { + ws.emit("message", Buffer.from(JSON.stringify({ type: "resume_result", session_id: f.session_id }))); + reopened += 1; + } + } + } + })(); + + await manager.recoverAll(); + await driver; + assert.strictEqual(reopened, 2, "both sessions reopened"); + assert.deepStrictEqual(manager.all().sort(), ["sess-1", "sess-2"], "sessions restored"); + for (const sid of manager.all()) { + assert.strictEqual(manager.get(sid).connected, true, `session ${sid} reconnected`); + } +});