diff --git a/Agent.md b/Agent.md index 6db0d4c..69cbb7b 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` (101: 27 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (103: 29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands); 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` (652) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (101: 27 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (103: 29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands) — 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 9e13d92..8bf7808 100644 --- a/README.cn.md +++ b/README.cn.md @@ -282,8 +282,8 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(99 项:25 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) -npm test # 运行 Node 测试(98 项:24 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(103 项:29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(103 项:29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) ``` CI 通过 GitHub Actions 自动运行测试并检查冲突标记(`.github/workflows/test.yml`)。 diff --git a/README.md b/README.md index 87cf8f0..8806b64 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 (101: 27 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; integration runs in CI, local: npm run test:integration) +npm test # run Node tests (103: 29 daemon_client + 22 app-commands + 27 renderer smoke + 15 i18n + 7 integration + 3 commands; 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/daemon_client.js b/emrg/gui/daemon_client.js index 4e3599f..017746b 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -25,6 +25,15 @@ const WebSocket = require("ws"); // 所有调用点必须传 this.projectDir(默认 os.homedir() 保持生产行为不变)。 const PORT_FILE = (projectDir = os.homedir()) => path.join(projectDir, ".emrg", "emrgd.port"); const EMRGD_LOG = (projectDir = os.homedir()) => path.join(projectDir, ".emrg", "emrgd.log"); +// Rant 2026-08-09T18:47:37(GUI 连不上 daemon 回归):daemon 的规范运行时目录永远是 +// ~/.emrg(daemon.py config_dir() = Path.home()/".emrg";connect.py 无条件读 +// ~/.emrg/emrgd.port)。GUI 的 projectDir 若被 config gui.project_dir 指向别处 +// (非 home),按 projectDir 读 port/pid/log 全部落空 → 误判 daemon 不存在 → +// 反复 spawn 撞 PID 锁 → "failed to start after 3 attempts" 假错误,而真 daemon 一直活着。 +// 规范位置常量:作为 projectDir 读取失败时的权威回退。 +const HOME_PORT_FILE = () => path.join(os.homedir(), ".emrg", "emrgd.port"); +const HOME_PID_FILE = () => path.join(os.homedir(), ".emrg", "emrgd.pid"); +const HOME_EMRGD_LOG = () => path.join(os.homedir(), ".emrg", "emrgd.log"); const MAX_PAYLOAD = 16 * 1024 * 1024; // G62/G105:16MB 双向一致(工具输出上限 200KB) const AUTH_TIMEOUT_MS = 10_000; const SPAWN_WAIT_MS = 5_000; @@ -82,32 +91,57 @@ class DaemonClient { // ── 生命周期 ──────────────────────────────────────────── - isRunning(timeoutMs = 1500) { - // G43/G90:TCP 探测(不可简化为 port 文件存在) - try { - const port = Number(fs.readFileSync(PORT_FILE(this.projectDir), "utf8").split("\n")[0]); - return new Promise((resolve) => { - const sock = net.connect({ host: "127.0.0.1", port, timeout: timeoutMs }); - sock.once("connect", () => { sock.destroy(); resolve(true); }); - sock.once("error", () => { sock.destroy(); resolve(false); }); - sock.once("timeout", () => { sock.destroy(); resolve(false); }); - }); - } catch { - return Promise.resolve(false); + // Rant 2026-08-09T18:47:37:读 port/token 的权威入口。先试 projectDir(G129 语义), + // 缺失/畸形时回退 daemon 规范位置 ~/.emrg。返回 {port, token, source} 或 null。 + _readPortToken() { + const tryRead = (file) => { + try { + const text = fs.readFileSync(file, "utf8"); + const [port, token] = text.split(/\s+/); + if (port && token) return { port, token }; + } catch { /* missing/unreadable → try next */ } + return null; + }; + const project = tryRead(PORT_FILE(this.projectDir)); + if (project) return { ...project, source: "projectDir" }; + const home = tryRead(HOME_PORT_FILE()); + if (home) { + this.logger.warn( + `[gui] port file not found at projectDir (${PORT_FILE(this.projectDir)}) — ` + + `reusing canonical ~/.emrg/emrgd.port (port=${home.port})` + ); + return { ...home, source: "home" }; } + return null; + } + + isRunning(timeoutMs = 1500) { + // G43/G90:TCP 探测(不可简化为 port 文件存在)。18:47:37:port 源改为权威读取 + // (projectDir 回退 ~/.emrg),否则 projectDir≠home 时永远探测假路径 → 假 false。 + const pt = this._readPortToken(); + if (!pt) return Promise.resolve(false); + const port = Number(pt.port); + return new Promise((resolve) => { + const sock = net.connect({ host: "127.0.0.1", port, timeout: timeoutMs }); + sock.once("connect", () => { sock.destroy(); resolve(true); }); + sock.once("error", () => { sock.destroy(); resolve(false); }); + sock.once("timeout", () => { sock.destroy(); resolve(false); }); + }); } _readLogTail(lines = 15) { // R124 对应(daemon_manager.py):spawn 超时后读 emrgd.log 尾部, // 让宿主看到真实失败原因(缺 DLL / PATH / 端口冲突),而不是干巴巴的 // "failed to start within timeout"(rant 2026-08-09T13:16:36 验收项 ②)。 - try { - const data = fs.readFileSync(EMRGD_LOG(this.projectDir), "utf8"); - const tail = data.trim().split("\n").slice(-lines).join("\n"); - return tail ? `\n emrgd.log tail:\n${tail}` : ""; - } catch { - return ""; - } + // 18:47:37:log 也在规范 ~/.emrg 下——projectDir 读不到就回退 home。 + for (const file of [EMRGD_LOG(this.projectDir), HOME_EMRGD_LOG()]) { + try { + const data = fs.readFileSync(file, "utf8"); + const tail = data.trim().split("\n").slice(-lines).join("\n"); + return tail ? `\n emrgd.log tail (${file}):\n${tail}` : ""; + } catch { /* try next */ } + } + return ""; } async startDaemon() { @@ -138,6 +172,7 @@ class DaemonClient { const child = spawn(emrgdPath, [], opts); child.unref(); this._daemonChild = child; + this.logger.info(`[gui] daemon spawned: pid=${child.pid} (packaged emrgd)`); // 18:47:37 B2 const deadline = Date.now() + SPAWN_WAIT_MS; while (Date.now() < deadline) { if (await this.isRunning(500)) return child; @@ -159,6 +194,7 @@ class DaemonClient { }); child.unref(); // GUI 退出不带走 daemon this._daemonChild = child; // 暴露 child(集成测试 after 清理用) + this.logger.info(`[gui] daemon spawned: pid=${child.pid} (source mode)`); // 18:47:37 B2 // 等最多 SPAWN_WAIT_MS 就绪 const deadline = Date.now() + SPAWN_WAIT_MS; while (Date.now() < deadline) { @@ -171,17 +207,24 @@ class DaemonClient { // Rant 2026-08-09T13:16:36 G43 加固:daemon 进程是否存活(emrgd.pid 探测)。 // 存活 → ws 连接失败视为瞬时(daemon 重启/启动中),保留 port 文件交给退避重试; // 死亡 → 允许 G43 删文件重拉。 + // 18:47:37:pid 文件也在规范 ~/.emrg —— projectDir 读不到回退 home。 _daemonProcessAlive() { - try { - const pidFile = path.join(this.projectDir, ".emrg", "emrgd.pid"); - const pid = Number(String(fs.readFileSync(pidFile, "utf8")).trim()); - if (!Number.isInteger(pid) || pid <= 0) return false; - process.kill(pid, 0); // 信号 0 = 仅探测存在性 - return true; - } catch (err) { - if (err && err.code === "EPERM") return true; // 进程存在但权限不同(Windows) - return false; // ESRCH(不存在)/ ENOENT(无 pid 文件) + const pidFiles = [ + path.join(this.projectDir, ".emrg", "emrgd.pid"), + HOME_PID_FILE(), + ]; + for (const pidFile of pidFiles) { + try { + const pid = Number(String(fs.readFileSync(pidFile, "utf8")).trim()); + if (!Number.isInteger(pid) || pid <= 0) return false; + process.kill(pid, 0); // 信号 0 = 仅探测存在性 + return true; + } catch (err) { + if (err && err.code === "EPERM") return true; // 进程存在但权限不同(Windows) + // ESRCH(不存在)/ ENOENT(无 pid 文件)→ 试下一个候选 + } } + return false; } _findDaemonExecutable() { @@ -210,17 +253,71 @@ class DaemonClient { return "python3"; } - async ensureConnected() { - // 1. 读 port 文件 → 无则拉 daemon - let port, token; + // Rant 2026-08-09T18:47:37(A1 + B1):探测"已存在的 daemon"——4 状态诊断日志 + // (port_file_exists / port_file_content / daemon_alive(ping) / spawn_result)。 + // spawn 失败 ≠ daemon 不存在:GUI 可能因 projectDir≠home 读错 port 文件, + // 或 daemon 早已被 scheduler/TUI 拉起。返回 {port, token} 或 null。 + async _probeExistingDaemon(spawnResult = "n/a") { + const pt = this._readPortToken(); + const portFileExists = !!(pt || this._readPortTokenRaw()); + const alive = pt ? await this.isRunning(1000) : false; + this.logger.info( + `[gui] probe: port_file_exists=${portFileExists}, port_file_content=${pt ? pt.port : "—"}, ` + + `daemon_alive(ping)=${alive}, spawn_result=${spawnResult}` + ); + if (pt && alive) return pt; + return null; + } + + // 读 port 文件原始存在性(不含解析),供 probe 日志用。 + _readPortTokenRaw() { + for (const file of [PORT_FILE(this.projectDir), HOME_PORT_FILE()]) { + try { if (fs.readFileSync(file, "utf8").trim()) return true; } catch { /* next */ } + } + return false; + } + + // Rant 2026-08-09T18:47:37(A1):spawn 失败(含 3 次节流)→ 探测已有 daemon → + // 活着直接复用;确实无 daemon 才抛原始错误。spawn 成功则读回 port/token。 + async _spawnOrProbe() { try { - const text = fs.readFileSync(PORT_FILE(this.projectDir), "utf8"); - [port, token] = text.split(/\s+/); - if (!port || !token) throw new Error("malformed port file"); - } catch { await this.startDaemon(); - const text = fs.readFileSync(PORT_FILE(this.projectDir), "utf8"); - [port, token] = text.split(/\s+/); + } catch (spawnErr) { + const existing = await this._probeExistingDaemon(`failed(${String(spawnErr.message).slice(0, 60)})`); + if (existing) { + this.logger.warn( + `[gui] spawn failed (${spawnErr.message}) — existing daemon detected at port=${existing.port}, reusing` + ); + return existing; + } + this.logger.warn(`[gui] spawn failed (${spawnErr.message}) — no existing daemon reachable, giving up`); + throw spawnErr; + } + // spawn 成功:daemon 永远写规范 ~/.emrg/emrgd.port(daemon.py config_dir()), + // 用权威读取(projectDir 回退 home),不假设 projectDir==home。 + const pt = this._readPortToken(); + if (!pt) throw new Error("port file not written after spawn"); + this.logger.info(`[gui] daemon spawned ok: port=${pt.port}`); + return pt; + } + + async ensureConnected() { + // Rant 2026-08-09T18:47:37:1. 读 port 文件(projectDir → 规范 ~/.emrg 回退)→ + // 无则拉 daemon;spawn 失败先探测已有 daemon,活着直接复用,不再盲报 + // "failed to start after 3 attempts"。每步打结构化诊断日志(B1-B5)。 + let port, token; + const pt = this._readPortToken(); + if (pt) { + port = pt.port; + token = pt.token; + this.logger.info( + `[gui] ensureConnected: port_file_exists=true, port_file_content=${port}, source=${pt.source}` + ); + } else { + this.logger.info(`[gui] ensureConnected: port_file_exists=false — spawning daemon`); + const r = await this._spawnOrProbe(); + port = r.port; + token = r.token; } // 2. ws 连接(G43 stale port:连接失败删文件重拉一次) @@ -247,9 +344,9 @@ class DaemonClient { this.logger.warn(`[gui] ws connect failed: ${e.message} — stale port, respawning daemon`); try { this.ws.close(); } catch { /* ignore */ } try { fs.unlinkSync(PORT_FILE(this.projectDir)); } catch { /* ignore */ } - await this.startDaemon(); - const text = fs.readFileSync(PORT_FILE(this.projectDir), "utf8"); - [port, token] = text.split(/\s+/); + const r = await this._spawnOrProbe(); + port = r.port; + token = r.token; this.ws = new WebSocket(`ws://127.0.0.1:${port}`, { maxPayload: MAX_PAYLOAD }); await this._awaitOpen(); } @@ -294,6 +391,10 @@ class DaemonClient { this.connected = true; this._authFailed = false; this._spawnAttempts = 0; // 连接生命周期成功 → 重置 spawn 节流计数 + // Rant 2026-08-09T18:47:37 B5:最终状态一行自证——GUI 连的是谁、连没连上。 + this.logger.info( + `[gui] ensureConnected result=connected, daemon_running=true, port=${port}, token_set=${!!token}` + ); // 5. 注册 message/close 监听 → 事件流分发 this.ws.on("message", (data) => this._onFrame(data)); diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index b782003..aa92490 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -645,3 +645,57 @@ test("断连 pending 请求全部 reject + disconnected(G89)", async () => { assert.strictEqual(client._pending.size, 0); assert.strictEqual(client._pendingFifo.length, 0); }); + +// ── Rant 2026-08-09T18:47:37(GUI 连不上 daemon 回归)────────────────── + +test("18:47:37: projectDir port 文件缺失 → 回退规范 ~/.emrg(home)→ 不 spawn 直接连接", async () => { + // 宿主场景:config gui.project_dir 指向非 home 目录 → projectDir/.emrg 无 port 文件, + // 真 daemon 写在 ~/.emrg/emrgd.port(setupTempHome 已预写 41234)。 + const elsewhere = path.join(tmpHome, "elsewhere"); + fs.mkdirSync(path.join(elsewhere, ".emrg"), { recursive: true }); + const client = new DaemonClient({ projectDir: elsewhere }); + let spawned = false; + client.startDaemon = async function () { spawned = true; }; + await connectClient(client); + assert.strictEqual(spawned, false, "home 有 port 文件 → 必须复用,不 spawn"); + assert.strictEqual(client.connected, true); + assert.strictEqual(currentMockWs.url, "ws://127.0.0.1:41234", "连接 canonical home port"); +}); + +test("18:47:37: stale projectDir port + spawn 节流失败 → probe 复用 canonical home daemon", async () => { + // 宿主场景变体:projectDir 有 STALE port 文件(ws 连不上),真 daemon 在 home。 + // ws 失败 → 删 projectDir stale 文件 → spawn 节流抛错 → probe 发现 home 活着 → 复用。 + const elsewhere = path.join(tmpHome, "elsewhere"); + fs.mkdirSync(path.join(elsewhere, ".emrg"), { recursive: true }); + fs.writeFileSync(PORT_FILE(elsewhere), "41299\nstale-token"); // stale:无 daemon 监听 + const client = new DaemonClient({ projectDir: elsewhere }); + // spawn 命中节流(正是宿主看到的假错误 "after 3 attempts") + client.startDaemon = async function () { + throw new Error("daemon failed to start after 3 attempts — please start it manually"); + }; + // TCP 探测:home port 文件指向的 41234 "可连接"(模拟真 daemon 在跑) + client.isRunning = async () => true; + // 捕获日志 → 断言 4 状态诊断字段齐全(B1/B3) + const logs = []; + client.logger = { info: (...a) => logs.push(a.join(" ")), warn: (...a) => logs.push(a.join(" ")) }; + const p = client.ensureConnected(); + await waitForWs(); + const firstWs = currentMockWs; + firstWs.emit("error", new Error("connect ECONNREFUSED")); // 41299 拒绝 + // probe 复用 → 新 ws 到 canonical home 41234 → open → auth → auth_ok + await waitForWs(() => currentMockWs !== firstWs); + assert.strictEqual(currentMockWs.url, "ws://127.0.0.1:41234", "复用 canonical home port"); + currentMockWs.emit("open"); + await waitForAuthSent(currentMockWs); + currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" }))); + await p; + assert.strictEqual(client.connected, true, "probe 到已有 daemon → 直接连接"); + const probeLine = logs.find((l) => l.includes("probe:")); + assert.ok(probeLine, "必须输出 probe 诊断日志"); + assert.match(probeLine, /port_file_exists=/); + assert.match(probeLine, /port_file_content=/); + assert.match(probeLine, /daemon_alive\(ping\)=/); + assert.match(probeLine, /spawn_result=/); + assert.match(probeLine, /failed\(daemon failed to start after 3 attempts/); + assert.ok(logs.some((l) => l.includes("existing daemon detected at port=41234, reusing")), "复用日志"); +}); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index bec78a9..82453d0 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -265,10 +265,15 @@ async def serve(self) -> None: port = self._server.sockets[0].getsockname()[1] self._auth_token = secrets.token_urlsafe(32) self._assert_port_file(port) + # Rant 2026-08-09T18:47:37 B4:启动完成一行自证——pid/port/port 文件路径/写入成功, + # 宿主拿到 emrgd.log 就知道 daemon 到底起没起、写没写对文件。 logger.info( - "emrgd listening on 127.0.0.1:%d | identity=%s", + "emrgd listening on 127.0.0.1:%d | identity=%s | pid=%d | port_file=%s | port_file_written_ok=%s", port, self.identity.instance_id[:8], + os.getpid(), + config_dir() / "emrgd.port", + (config_dir() / "emrgd.port").exists(), ) # Rant 2026-08-09T13:16:36 root-cause self-heal: G43 stale-port logic