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` (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`
- Unit tests `npm test` (142: 43 daemon_client + 17 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` (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`
GUI: `cd emrg/gui && npm test` (142: 43 daemon_client + 17 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 测试(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)
npm test # 运行 Node 测试(142 项:43 daemon_client + 17 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 (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)
npm test # run Node tests (142: 43 daemon_client + 17 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
132 changes: 113 additions & 19 deletions emrg/gui/conn-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,78 +4,118 @@
// open session (each session = one independent websocket connection, aligned
// with the TUI multi-open model).
//
// This slice establishes the open/close/get contract and the daemon-ownership
// bootstrap. main.js rewiring to use this manager (single-session no-regression
// target) lands in a later slice — until then main.js keeps its single client.
// This slice completes the P2 wiring contract:
// - `ensureDaemon()` keeps a daemon-level connection (`_daemonConn`) used for
// non-session commands (ping / list_sessions / set_model / github_* / ...).
// Session connections only connect to the already-running daemon
// (ensureConnected({ skipStart: true }) — never spawn).
// - `open(sid, projectPath)` creates the per-session connection with
// per-connection delta batching (deltaBatchMs=16, #626) and resumes the
// session (auto-subscribe). `{ resume: false }` skips resume for brand-new
// sessions (daemon implicitly subscribes on first task message).
// - restart recovery: short-window all-drop → recoverAll (re-open + re-subscribe).
// - `onOpen` / `onRecovered` hooks let main.js attach the renderer event
// bridge (sid-tagged) and refresh UI state after recovery.
//
// Design notes (from the rant):
// - Each open session = one independent ws connection → natural isolation, no
// event routing.
// - connManager ensures the daemon is ready (spawn if missing) before opening;
// session connections then use ensureConnected({ skipStart: true }) — they
// only connect to the already-running daemon, never spawn.
// - resume_session(sid, cwd=projectPath) auto-subscribes the connection.
// - Already-open sid → reuse the existing connection (no duplicate).

const { DaemonClient } = require("./daemon_client.js");

class ConnManager {
constructor({ projectDir, logger = console, isPackaged = false, restartWindowMs = 1000 } = {}) {
constructor({ projectDir, logger = console, isPackaged = false, restartWindowMs = 1000, singleRetryDelayMs = 1000 } = {}) {
this.projectDir = projectDir;
this.logger = logger;
this.isPackaged = isPackaged;
this._conns = new Map(); // sid -> { conn, projectPath }
this._daemonConn = null; // daemon 级连接(ping/list_sessions 等非会话命令)
this._openHooks = new Set(); // (sid, conn) => void(新会话连接建立时)
this._recoverHooks = new Set(); // () => void(recoverAll 完成后)
// daemon 重启恢复(rant 15:07:19 P2):短窗口内所有连接同时断 → 判定 daemon 重启
// → 全部重连重订阅;单条断 → 不做全量恢复(留给独立退避重试)。
// → 全部重连重订阅;单条断 → 独立退避重试(多会话场景,单会话全断走恢复)。
this._restartWindowMs = restartWindowMs;
this._disconnects = new Map(); // sid -> timestamp(最近一次断连)
this._recovering = false; // 恢复中守卫(防 close→disconnect→recoverAll 递归)
this._singleRetryDelayMs = singleRetryDelayMs;
this._singleRetries = new Map(); // sid -> timer(单连接独立退避在途)
}

// 确保 daemon 已运行(connManager = daemon 生命周期唯一 owner)。
// 引导 client ensureConnected():port 文件缺失 → spawn;已运行 → 直连。
// 连接后立即关闭引导连接——会话连接统一走 skipStart(只连不拉)。
async _ensureDaemon() {
// 引导连接保留为 _daemonConn:供 ping/list_sessions 等非会话命令使用;
// 会话连接统一 skipStart(只连不拉)。已连接 → 直接复用。
async ensureDaemon() {
if (this._daemonConn && this._daemonConn.connected) return this._daemonConn;
const boot = new DaemonClient({
projectDir: this.projectDir,
logger: this.logger,
isPackaged: this.isPackaged,
});
try {
await boot.ensureConnected();
} finally {
} catch (e) {
boot.close();
throw e;
}
this._daemonConn = boot;
return boot;
}

// daemon 级连接访问器(main.js 非会话命令用;未建立返回 null)
daemonConn() {
return this._daemonConn || null;
}

// open(sid, projectPath):创建 DaemonClient → ensureConnected(skipStart) →
// resume_session(自动订阅)。已打开的 sid 直接复用现有连接。
async open(sid, projectPath) {
// resume_session(自动订阅;resume:false 跳过,供新会话首条消息前用)。
// 已打开的 sid 直接复用现有连接。失败时关闭连接不泄漏。
async open(sid, projectPath, { resume = true } = {}) {
const existing = this._conns.get(sid);
if (existing) return existing.conn;
await this._ensureDaemon();
if (existing) {
if (existing.conn.connected) return existing.conn;
this.close(sid); // 残留断连连接 → 关闭重开(_intentionalClose 标记抑制断线横幅)
}
await this.ensureDaemon();
const conn = new DaemonClient({
projectDir: this.projectDir,
logger: this.logger,
isPackaged: this.isPackaged,
deltaBatchMs: 16, // P2:delta 批量(G122 16ms)每连接一份(#626)
});
await conn.ensureConnected({ skipStart: true }); // daemon 已就绪 → 只连不拉
await conn.sendCommandAndWait("resume_session", { session_id: sid, cwd: projectPath }, 5000);
try {
await conn.ensureConnected({ skipStart: true }); // daemon 已就绪 → 只连不拉
if (resume) {
await conn.sendCommandAndWait("resume_session", { session_id: sid, cwd: projectPath }, 5000);
}
} catch (e) {
conn.close(); // resume 失败(会话已删等)→ 不泄漏连接
throw e;
}
// 断开监听 → 重启恢复判定(仅当所有打开会话在同一短窗口内断开)
conn.onEvent((type) => {
if (type === "disconnected") {
if (conn._intentionalClose) return; // 主动关闭(切走/删除)不参与重启判定
this._disconnects.set(sid, Date.now());
this._onDisconnect(sid);
}
});
this._conns.set(sid, { conn, projectPath });
for (const cb of this._openHooks) {
try { cb(sid, conn); } catch (e) { this.logger.warn(`[gui] connManager onOpen hook error: ${e.message}`); }
}
return conn;
}

// close(sid):conn.close(断开 ws)→ 移除。返回是否有关闭对象。
// 标记 _intentionalClose:主动关闭(切走/删除)不触发 renderer 断线横幅
// (桥检查该标记;真断连/daemon 重启的 disconnected 照常转发)。
close(sid) {
const entry = this._conns.get(sid);
if (!entry) return false;
this._cancelSingleRetry(sid); // 主动关闭 → 取消该会话的独立退避
entry.conn._intentionalClose = true;
entry.conn.close();
this._conns.delete(sid);
this._disconnects.delete(sid);
Expand All @@ -94,6 +134,20 @@ class ConnManager {

closeAll() {
for (const sid of [...this._conns.keys()]) this.close(sid);
if (this._daemonConn) {
this._daemonConn.close();
this._daemonConn = null;
}
}

// 新会话连接建立钩子(main.js 挂 renderer 事件桥;recoverAll 重开路径同样触发)
onOpen(callback) {
this._openHooks.add(callback);
}

// recoverAll 完成钩子(main.js 刷新 UI 状态:status connected + sessions + pong)
onRecovered(callback) {
this._recoverHooks.add(callback);
}

// ── daemon 重启恢复(rant 15:07:19 P2)──────────────────────────────
Expand All @@ -118,10 +172,45 @@ class ConnManager {
this.recoverAll().catch((e) =>
this.logger.warn(`[gui] connManager recover failed: ${e.message}`)
);
} else {
// 单条断(多会话场景,非重启)→ 独立退避重试
this._scheduleSingleRetry(sid);
}
}

// 全部重连重订阅(复用 open 序列:引导 daemon 就绪 → skipStart 会话连接 →
// 单连接独立退避(rant 15:07:19 P2:单条断 → 独立退避重试)。
// 退避到期后若该会话连接仍断 → open() 重开(stale → 关闭重开 + resume 重订阅)。
_scheduleSingleRetry(sid) {
if (this._singleRetries.has(sid)) return; // 已有退避在途
this.logger.info(
`[gui] connManager: session ${sid} dropped (not all) — independent backoff retry in ${this._singleRetryDelayMs}ms`
);
const timer = setTimeout(() => {
this._singleRetries.delete(sid);
this._retrySingle(sid).catch((e) =>
this.logger.warn(`[gui] connManager: session ${sid} retry failed: ${e.message}`)
);
}, this._singleRetryDelayMs);
timer.unref?.();
this._singleRetries.set(sid, timer);
}

_cancelSingleRetry(sid) {
const t = this._singleRetries.get(sid);
if (t) {
clearTimeout(t);
this._singleRetries.delete(sid);
}
}

async _retrySingle(sid) {
const entry = this._conns.get(sid);
if (!entry || entry.conn.connected) return; // 已重开/已主动关闭
this.logger.info(`[gui] connManager retrying session ${sid}`);
await this.open(sid, entry.projectPath); // stale → close + reopen(含 resume 重订阅)
}

// 全部重连重订阅(复用 open 序列:ensureDaemon → skipStart 会话连接 →
// resume_session)。单会话恢复失败跳过不阻塞其余(写盘/重试由后续片处理)。
async recoverAll() {
if (this._recovering) return;
Expand All @@ -132,6 +221,8 @@ class ConnManager {
}));
try {
for (const { sid } of sessions) this.close(sid);
for (const t of this._singleRetries.values()) clearTimeout(t);
this._singleRetries.clear();
this._disconnects.clear();
for (const { sid, projectPath } of sessions) {
try {
Expand All @@ -144,6 +235,9 @@ class ConnManager {
} finally {
this._recovering = false;
}
for (const cb of this._recoverHooks) {
try { cb(); } catch (e) { this.logger.warn(`[gui] connManager onRecovered hook error: ${e.message}`); }
}
}
}

Expand Down
28 changes: 28 additions & 0 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,18 @@ class DaemonClient {
this._deltaBatchMs = deltaBatchMs;
this._deltaBuf = [];
this._deltaTimer = null;
// P2 connManager(rant 2026-08-10T15:07:19):G65 自有流锁每连接一份。
// 从 main.js 全局移入——多会话各自独立:本连接发出的 task 流运行中 →
// ownStream=true,切会话/关连接前必须释放。
this.ownStream = false;
this.ownStreamRequestId = null;
}

// 释放自有流锁(G65)。done(request 匹配或 timeout 兜底)、session busy 即发
// 错误、cancelled(request 匹配)与断连时调用;sendTask 抛异常时由调用方清理。
_releaseOwnStream() {
this.ownStream = false;
this.ownStreamRequestId = null;
}

// ── 生命周期 ────────────────────────────────────────────
Expand Down Expand Up @@ -492,6 +504,12 @@ class DaemonClient {
};
if (mode && mode !== "auto") payload.mode = mode;
this._setCurrentStream(rid);
// G65:自有流锁——本连接发出流式 task 即标记,done/error/cancelled/断连释放
// (多会话各自独立;main.js emrg:sendMessage 的 G65 切会话检查读本字段)
if (stream) {
this.ownStream = true;
this.ownStreamRequestId = rid;
}
this.ws.send(JSON.stringify(payload));
return rid;
}
Expand Down Expand Up @@ -579,6 +597,8 @@ class DaemonClient {
}
if (frame.type === "cancelled") {
this._flushDeltaBuf(); // 终态前冲刷(rant 14:11 同源:delta 不晚于终态)
// 自有流取消 → 释放 G65 锁(带 request_id 的 cancelled 明确是本流的终态)
if (frame.request_id === this.ownStreamRequestId) this._releaseOwnStream();
this._emit("cancelled", frame);
return;
}
Expand Down Expand Up @@ -610,6 +630,9 @@ class DaemonClient {
}
if (frame.error) {
this._flushDeltaBuf(); // 终态前冲刷(rant 14:11 同源)
// session busy 是即发错误(daemon 返回后无 done 跟随)——释放自有流锁,防 G65 锁泄漏
// (流式错误如 LLM error 则有 done 跟随,由 done 分支释放,不在此处理)
if (frame.error && String(frame.error).includes("session busy")) this._releaseOwnStream();
this._emit("error", frame);
return;
}
Expand Down Expand Up @@ -644,6 +667,10 @@ class DaemonClient {
if (rid && this._currentStream && this._currentStream.requestId === rid) {
this.clearActiveStream();
}
// G65:仅自有流的 done 释放锁(广播 done 不影响);timeout 兜底同样只清自有
if (rid === this.ownStreamRequestId || (frame.timeout && this.ownStream)) {
this._releaseOwnStream();
}
// G83:done 清理分组缓存(DOM 保留)
if (rid) this._cleanupGroup(rid, true);
}
Expand Down Expand Up @@ -717,6 +744,7 @@ class DaemonClient {
// G41/G89/G97:断连处理
_onClose() {
this.connected = false;
this._releaseOwnStream(); // 断连即释放 G65 自有流锁(防锁泄漏)
this._rejectAllPending("connection closed");
this.clearActiveStream(); // G94 timer 清理:断连后 30s 超时 timer 不应再触发(防虚假"响应超时"提示)
this.clearGroups(); // G97:断连清空广播分组缓存(含 10 分钟 timer),防"幽灵"分组残留
Expand Down
Loading
Loading