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 number Diff line number Diff line change
Expand Up @@ -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` (124: 37 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` (127: 37 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)
Expand Down Expand Up @@ -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` (124: 37 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` (127: 37 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 路径不受影响)

Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI
cd emrg/gui
npm ci # 安装依赖(生产模式可 --omit=dev)
npm start # 启动 GUI(自动拉起 daemon)
npm test # 运行 Node 测试(124 项:37 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 测试(127 项:37 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`)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (124: 37 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 (127: 37 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`).
Expand Down
65 changes: 64 additions & 1 deletion emrg/gui/conn-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)。
Expand Down Expand Up @@ -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;
}
Expand All @@ -66,6 +78,7 @@ class ConnManager {
if (!entry) return false;
entry.conn.close();
this._conns.delete(sid);
this._disconnects.delete(sid);
return true;
}

Expand All @@ -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 };
81 changes: 81 additions & 0 deletions emrg/gui/test/conn-manager.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
});
Loading