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` (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)
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` (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 路径不受影响)

Expand Down
4 changes: 2 additions & 2 deletions README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)。
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 (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`).
Expand Down
181 changes: 141 additions & 40 deletions emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -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() {
Expand Down Expand Up @@ -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:连接失败删文件重拉一次)
Expand All @@ -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();
}
Expand Down Expand Up @@ -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));
Expand Down
54 changes: 54 additions & 0 deletions emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")), "复用日志");
});
Loading
Loading