diff --git a/Agent.md b/Agent.md index 7aad290..dda4aed 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` (111: 29 daemon_client + 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` (113: 31 daemon_client + 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` (111: 29 daemon_client + 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` (113: 31 daemon_client + 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 8f7225d..6671882 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 测试(111 项:29 daemon_client + 22 app-commands + 32 renderer smoke + 15 i18n + 7 integration + 3 commands + 3 build-config;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(113 项:31 daemon_client + 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 909fc56..6585230 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 (111: 29 daemon_client + 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 (113: 31 daemon_client + 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/daemon_client.js b/emrg/gui/daemon_client.js index 017746b..18fc366 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -301,10 +301,12 @@ class DaemonClient { return pt; } - async ensureConnected() { + async ensureConnected({ skipStart = false } = {}) { // Rant 2026-08-09T18:47:37:1. 读 port 文件(projectDir → 规范 ~/.emrg 回退)→ // 无则拉 daemon;spawn 失败先探测已有 daemon,活着直接复用,不再盲报 // "failed to start after 3 attempts"。每步打结构化诊断日志(B1-B5)。 + // P2 connManager(rant 2026-08-10T15:07:19):skipStart=true 时 daemon 生命周期 + // 由 connManager 独占管理——本实例只连接**已运行**的 daemon,绝不自行拉起。 let port, token; const pt = this._readPortToken(); if (pt) { @@ -314,6 +316,11 @@ class DaemonClient { `[gui] ensureConnected: port_file_exists=true, port_file_content=${port}, source=${pt.source}` ); } else { + if (skipStart) { + throw new Error( + `daemon not running (skipStart): no port file at ${PORT_FILE(this.projectDir)}` + ); + } this.logger.info(`[gui] ensureConnected: port_file_exists=false — spawning daemon`); const r = await this._spawnOrProbe(); port = r.port; @@ -341,6 +348,13 @@ class DaemonClient { try { this.ws.close(); } catch { /* ignore */ } throw new Error(`daemon unreachable (pid alive): ${e.message}`); } + if (skipStart) { + this.logger.warn( + `[gui] ws connect failed: ${e.message} — stale port, daemon dead (skipStart: not respawning)` + ); + try { this.ws.close(); } catch { /* ignore */ } + throw new Error(`daemon unreachable (skipStart): ${e.message}`); + } 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 */ } diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index aa92490..e913f68 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -169,6 +169,43 @@ test("ensureConnected: port 文件缺失 → 拉起 daemon(spawn 参数正确 assert.strictEqual(spawnCalls.projectDir, tmpHome); }); +test("P2 skipStart: port 文件缺失 → 抛错不拉起 daemon(connManager 独占 daemon 生命周期)", async () => { + fs.rmSync(PORT_FILE(tmpHome), { force: true }); + const client = new DaemonClient({ projectDir: tmpHome }); + let spawnCalls = 0; + client.startDaemon = async function () { + spawnCalls += 1; + throw new Error("startDaemon must not be called with skipStart"); + }; + await assert.rejects( + () => client.ensureConnected({ skipStart: true }), + /daemon not running \(skipStart\): no port file/ + ); + assert.strictEqual(spawnCalls, 0, "startDaemon must never be called"); + assert.strictEqual(client.connected, false); +}); + +test("P2 skipStart: stale port + daemon 已死 → 抛错不重拉(不删文件不 spawn)", async () => { + // 预写 port 文件指向一个无人监听的端口(连接必然失败) + fs.writeFileSync(PORT_FILE(tmpHome), "1\nseekrit-token"); // 127.0.0.1:1 拒绝连接 + const client = new DaemonClient({ projectDir: tmpHome }); + // daemon 进程已死(无 pid 文件)→ 旧路径会删文件重拉;skipStart 必须拒绝 + let spawnCalls = 0; + client.startDaemon = async function () { + spawnCalls += 1; + throw new Error("startDaemon must not be called with skipStart"); + }; + const p = client.ensureConnected({ skipStart: true }); + await waitForWs(); + const firstWs = currentMockWs; + firstWs.emit("error", new Error("connect ECONNREFUSED")); + await assert.rejects(p, /daemon unreachable \(skipStart\)/); + assert.strictEqual(spawnCalls, 0, "startDaemon must never be called"); + // port 文件保留(connManager 重启恢复依赖它判断 daemon 状态) + assert.ok(fs.existsSync(PORT_FILE(tmpHome)), "port file must be kept"); + assert.strictEqual(client.connected, false); +}); + test("Phase4: _findDaemonExecutable 打包模式定位捆绑 emrgd(POSIX)", async () => { const client = new DaemonClient({ projectDir: tmpHome, isPackaged: true }); const exe = client._findDaemonExecutable();