diff --git a/Agent.md b/Agent.md index 01a01844..c7581076 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (634) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (636) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (96: 22 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 c8a1fbd0..b65f4a96 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 634 项) +uv run pytest tests/ -v # 跑测试(当前 636 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 @@ -282,7 +282,7 @@ uv run python -m emrg # 启动 TUI cd emrg/gui npm ci # 安装依赖(生产模式可 --omit=dev) npm start # 启动 GUI(自动拉起 daemon) -npm test # 运行 Node 测试(91 项:22 daemon_client + 22 app-commands + 22 renderer smoke + 15 i18n + 7 integration + 3 commands;集成测试在 CI 跑,本地可 npm run test:integration) +npm test # 运行 Node 测试(96 项:22 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 f21604a2..8d00258f 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 634 items) +uv run pytest tests/ -v # run tests (currently 636 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index a179b4d0..cbcedcc1 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -18,7 +18,12 @@ const crypto = require("crypto"); const { spawn } = require("child_process"); const WebSocket = require("ws"); -const PORT_FILE = () => path.join(os.homedir(), ".emrg", "emrgd.port"); +// G129 (rant 2026-08-09T08:03:46): PORT_FILE 必须接受 projectDir——硬编码 +// os.homedir() 时,Windows 测试的 setupTempHome() 只设 HOME 不设 USERPROFILE, +// os.homedir() 仍读 USERPROFILE(真实用户目录)→ 测试把假 port/token 写进 +// 真实的 ~/.emrg/emrgd.port → 演化周期 10 小时连不上 daemon(WinError 1225)。 +// 所有调用点必须传 this.projectDir(默认 os.homedir() 保持生产行为不变)。 +const PORT_FILE = (projectDir = os.homedir()) => path.join(projectDir, ".emrg", "emrgd.port"); const MAX_PAYLOAD = 16 * 1024 * 1024; // G62/G105:16MB 双向一致(工具输出上限 200KB) const AUTH_TIMEOUT_MS = 10_000; const SPAWN_WAIT_MS = 5_000; @@ -74,7 +79,7 @@ class DaemonClient { isRunning(timeoutMs = 1500) { // G43/G90:TCP 探测(不可简化为 port 文件存在) try { - const port = Number(fs.readFileSync(PORT_FILE(), "utf8").split("\n")[0]); + 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); }); @@ -166,12 +171,12 @@ class DaemonClient { // 1. 读 port 文件 → 无则拉 daemon let port, token; try { - const text = fs.readFileSync(PORT_FILE(), "utf8"); + 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(), "utf8"); + const text = fs.readFileSync(PORT_FILE(this.projectDir), "utf8"); [port, token] = text.split(/\s+/); } @@ -188,9 +193,9 @@ class DaemonClient { // G43:port 文件存在但连不上(daemon 已死/端口被占)→ 删文件重拉一次 this.logger.warn(`[gui] ws connect failed: ${e.message} — stale port, respawning daemon`); try { this.ws.close(); } catch { /* ignore */ } - try { fs.unlinkSync(PORT_FILE()); } catch { /* ignore */ } + try { fs.unlinkSync(PORT_FILE(this.projectDir)); } catch { /* ignore */ } await this.startDaemon(); - const text = fs.readFileSync(PORT_FILE(), "utf8"); + const text = fs.readFileSync(PORT_FILE(this.projectDir), "utf8"); [port, token] = text.split(/\s+/); this.ws = new WebSocket(`ws://127.0.0.1:${port}`, { maxPayload: MAX_PAYLOAD }); await this._awaitOpen(); diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 54465b6f..727808b8 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -63,18 +63,39 @@ Module._load = function (request, parent, isMain) { const { DaemonClient, generateSessionId, PORT_FILE } = require("../daemon_client.js"); let tmpHome = null; let origHome = null; +let origUserProfile = null; + +// G129 (rant 2026-08-09T08:03:46): 测试隔离守卫——写 port 文件前断言目标路径 +// 位于临时目录内。Windows 上 Node os.homedir() 优先读 USERPROFILE(HOME 无效), +// 若无此守卫,PORT_FILE() 会解析到真实 ~/.emrg/emrgd.port,测试假值 +// ("41234\nseekrit-token") 会覆盖真实 daemon 端口文件 → 演化周期 10h 连不上。 +function assertPortFileInTmp(portFile) { + const resolved = path.resolve(portFile); + const tmpResolved = path.resolve(tmpHome); + assert.ok( + resolved.startsWith(tmpResolved + path.sep), + `port file ${resolved} escapes tmpHome ${tmpResolved} — refusing to write`, + ); +} function setupTempHome() { tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "emrg-gui-test-")); fs.mkdirSync(path.join(tmpHome, ".emrg"), { recursive: true }); origHome = process.env.HOME; + origUserProfile = process.env.USERPROFILE; process.env.HOME = tmpHome; - // 预写 port 文件(模拟已运行 daemon) - fs.writeFileSync(PORT_FILE(), "41234\nseekrit-token"); + // G129: Windows os.homedir() 读 USERPROFILE —— 必须一并重定向,否则 + // os.homedir() 仍返回真实用户目录(这是 10h daemon gap 的直接根因)。 + process.env.USERPROFILE = tmpHome; + // 预写 port 文件(模拟已运行 daemon)—— 路径必须落在 tmpHome 内 + const portFile = PORT_FILE(tmpHome); + assertPortFileInTmp(portFile); + fs.writeFileSync(portFile, "41234\nseekrit-token"); } function teardownTempHome() { if (origHome !== undefined) process.env.HOME = origHome; else delete process.env.HOME; + if (origUserProfile !== undefined) process.env.USERPROFILE = origUserProfile; else delete process.env.USERPROFILE; if (tmpHome) fs.rmSync(tmpHome, { recursive: true, force: true }); tmpHome = null; } @@ -130,13 +151,13 @@ test("ensureConnected: port 文件读取 + auth 首帧 + auth_ok", async () => { }); test("ensureConnected: port 文件缺失 → 拉起 daemon(spawn 参数正确 G28/G68/G125)", async () => { - fs.rmSync(PORT_FILE(), { force: true }); + fs.rmSync(PORT_FILE(tmpHome), { force: true }); const client = new DaemonClient({ projectDir: tmpHome }); // stub startDaemon:模拟拉起后写 port 文件 let spawnCalls = null; client.startDaemon = async function () { spawnCalls = { python: this._findPython(), projectDir: this.projectDir }; - fs.writeFileSync(PORT_FILE(), "41235\nseekrit-token"); + fs.writeFileSync(PORT_FILE(tmpHome), "41235\nseekrit-token"); }; await connectClient(client); assert.ok(spawnCalls, "startDaemon should be called"); @@ -205,7 +226,7 @@ test("G43 stale port: 连接失败(port 文件存在但拒绝)→ 删文件 let respawned = false; client.startDaemon = async function () { respawned = true; - fs.writeFileSync(PORT_FILE(), "41236\nseekrit-token"); + fs.writeFileSync(PORT_FILE(tmpHome), "41236\nseekrit-token"); }; const p = client.ensureConnected(); await waitForWs(); @@ -214,7 +235,7 @@ test("G43 stale port: 连接失败(port 文件存在但拒绝)→ 删文件 // 重拉后创建第二个 ws → open → auth → auth_ok await waitForWs(() => currentMockWs !== firstWs); assert.ok(respawned, "startDaemon should respawn after stale port"); - assert.strictEqual(fs.existsSync(PORT_FILE()), true); + assert.strictEqual(fs.existsSync(PORT_FILE(tmpHome)), true); assert.strictEqual(currentMockWs.url, "ws://127.0.0.1:41236"); currentMockWs.emit("open"); await waitForAuthSent(currentMockWs); @@ -512,7 +533,7 @@ test("isRunning:TCP 探测(G43/G90)", async () => { net.connect = origConnect; } // port 文件缺失 → false - fs.rmSync(PORT_FILE(), { force: true }); + fs.rmSync(PORT_FILE(tmpHome), { force: true }); assert.strictEqual(await client.isRunning(), false); }); diff --git a/emrg/gui/test/integration.test.js b/emrg/gui/test/integration.test.js index d18c8949..1c3f98a9 100644 --- a/emrg/gui/test/integration.test.js +++ b/emrg/gui/test/integration.test.js @@ -41,7 +41,11 @@ process.env.HOME = tmp; process.env.USERPROFILE = tmp; function findPython() { - const root = path.resolve(__dirname, "..", ".."); // emrg/gui → 项目根 + // G129: 本文件位于 emrg/gui/test/ —— 必须上溯 3 级到仓库根(emrg/gui/ 下的 + // daemon_client.js 才是 2 级)。此前只上溯 2 级解析到 emrg/ 包目录,找不到 + // .venv → 回退 PATH python3/python;本机 PATH python 是损坏的 + // ~/.emrg/install/bin/python.exe(Failed to import encodings)→ daemon 起不来。 + const root = path.resolve(__dirname, "..", "..", ".."); // test → gui → emrg → 仓库根 const candidates = [ path.join(root, ".venv", "bin", "python"), path.join(root, ".venv", "Scripts", "python.exe"), @@ -65,7 +69,7 @@ function waitForPortFile(timeoutMs = 15000) { return new Promise((resolve, reject) => { const check = () => { try { - const text = fs.readFileSync(PORT_FILE(), "utf8"); + const text = fs.readFileSync(PORT_FILE(tmp), "utf8"); if (text && text.trim()) return resolve(text); } catch { /* not yet */ } if (Date.now() > deadline) return reject(new Error("daemon port file timeout")); @@ -180,7 +184,7 @@ test("daemon 被杀 → ensureConnected 重连(G43 stale port 流程)", { sk // ensureConnected 应自动重拉(G43 stale port) await client.ensureConnected(); assert.strictEqual(client.connected, true); - assert.ok(fs.existsSync(PORT_FILE()), "new port file written"); + assert.ok(fs.existsSync(PORT_FILE(tmp)), "new port file written"); // 重连后可继续通信 const frame = await client.sendCommandAndWait("list_sessions", { cwd: tmp }, 5000); assert.strictEqual(frame.type, "sessions_list"); diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index c9c48a7f..63dcf250 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -142,6 +142,10 @@ def __init__( # # Counter is persisted to disk to survive daemon restarts. self._IDLE_HALT_THRESHOLD = 30 + # G129: 连续连接失败告警阈值——达到后升级为 ERROR(防静默吞掉, + # rant 2026-08-09T08:03:46:GUI 测试覆盖真实 emrgd.port 致 10h 连不上)。 + self._CONNECT_FAIL_ALERT = 3 + self._connect_failures = 0 self._saturation_dir = config_dir() / "saturation" self._saturation_dir.mkdir(parents=True, exist_ok=True) self._saturation_file = self._saturation_dir / f"{self.name}.json" @@ -684,10 +688,27 @@ async def _run_evolution_cycle(self) -> None: try: ws = await connect_to_server() logger.info("EvolutionHandler[%s]: connected", self.name) - except (ConnectionRefusedError, FileNotFoundError) as e: - logger.warning( - "EvolutionHandler[%s]: cannot connect: %s", self.name, e - ) + self._connect_failures = 0 + except (ConnectionRefusedError, FileNotFoundError, OSError) as e: + # G129 (rant 2026-08-09T08:03:46): 连接失败不得静默吞掉——GUI 测试曾把 + # 假 port 值写进真实 ~/.emrg/emrgd.port,导致演化周期 10 小时连不上 + # daemon(WinError 1225)只留下 WARNING。累计失败达到阈值后升级为 + # ERROR 告警,提示 port 文件可能被外部覆盖(检查 ~/.emrg/emrgd.port)。 + self._connect_failures += 1 + port_path = config_dir() / "emrgd.port" + if self._connect_failures >= self._CONNECT_FAIL_ALERT: + logger.error( + "EvolutionHandler[%s]: cannot connect for %d consecutive cycles " + "(%s) — daemon unreachable. Check %s (may have been overwritten " + "by GUI tests or stale after daemon restart); run 'emrg server' " + "or restart the daemon to recover.", + self.name, self._connect_failures, e, port_path, + ) + else: + logger.warning( + "EvolutionHandler[%s]: cannot connect (%d/%d): %s", + self.name, self._connect_failures, self._CONNECT_FAIL_ALERT, e, + ) return task_msg = json.dumps( diff --git a/tests/test_doc_counts.py b/tests/test_doc_counts.py index df4584a8..b148a407 100644 --- a/tests/test_doc_counts.py +++ b/tests/test_doc_counts.py @@ -4,6 +4,12 @@ removed, the documented counts drift and require a follow-up doc PR. This module asserts the documented Python count matches the real collection, and that the documented GUI breakdown sums to its headline number. + +#584: README.cn.md was the only test-count doc NOT guarded — it drifted to +91 (22 renderer smoke) while README.md/Agent.md said 96 (27 renderer smoke) +after #580 added 3 GUI tests. Both checks now cover all three docs +(README.md, README.cn.md, Agent.md); CJK full-width parens and the +"项:" separator are normalized before matching. """ import re @@ -29,11 +35,14 @@ def _collected_pytest_count() -> int: def _gui_breakdowns() -> list[tuple[str, int, list[int]]]: """Extract (label, headline, parts) for every documented GUI count.""" found = [] - for doc in ("README.md", "Agent.md"): + for doc in ("README.md", "README.cn.md", "Agent.md"): text = (REPO_ROOT / doc).read_text(encoding="utf-8") for line in text.splitlines(): if "npm test" not in line: continue + # CJK docs use full-width parens and "(N 项:..." instead of "(N: ..." + line = line.replace("(", "(").replace(")", ")") + line = re.sub(r"(\d+) 项:", r"\1: ", line) m = re.search(r"\((\d+): ([^)]+)\)", line) if not m: continue @@ -51,10 +60,13 @@ def _gui_breakdowns() -> list[tuple[str, int, list[int]]]: def test_python_count_matches_docs() -> None: collected = _collected_pytest_count() - for doc in ("README.md", "Agent.md"): + for doc in ("README.md", "README.cn.md", "Agent.md"): text = (REPO_ROOT / doc).read_text(encoding="utf-8") - # README: "run tests (currently N items)" | Agent.md: "pytest tests/ -v` (N)" + # README: "run tests (currently N items)" | README.cn: "(当前 N 项)" + # Agent.md: "pytest tests/ -v` (N)" m = re.search(r"currently (\d+) items", text) or re.search( + r"当前 (\d+) 项", text + ) or re.search( r"uv run pytest tests/ -v` \((\d+)\)", text ) assert m, f"no documented Python count found in {doc}" diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index c6fe813e..cb0b1180 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -1137,6 +1137,87 @@ def test_evolution_cycle_aborted_resets_empty_streak(tmp_path): assert handler._empty_cycles == 0, "abort resets the streak (not a real empty cycle)" +# ── Connect-failure alerting (G129, rant 2026-08-09T08:03:46) ───── +# GUI tests once overwrote the real ~/.emrg/emrgd.port with fake values, +# so the evolution cycle failed to reach the daemon for 10 hours with only +# a WARNING log. Consecutive failures must escalate to ERROR + carry an +# actionable hint (check the port file), never silently swallow. + +def test_evolution_cycle_connect_failure_escalates_to_error(tmp_path, caplog): + """Repeated connect failures must escalate from warning to error alert.""" + import logging + + from emrg.server import scheduler as mod + + handler, captured = _make_cycle_handler(tmp_path, frames=[]) + async def _refuse(): + raise ConnectionRefusedError("no daemon") + mod.connect_to_server = _refuse + try: + for i in range(handler._CONNECT_FAIL_ALERT): + with caplog.at_level(logging.ERROR, logger="emrg.server.scheduler"): + asyncio.run(handler._run_evolution_cycle()) + assert "log" not in captured, "connect failure must not write an evolution log" + assert handler.evolutions == [] + assert handler._empty_cycles == 0, "connect failure ≠ empty cycle" + assert handler._connect_failures == handler._CONNECT_FAIL_ALERT + # 第 3 次(达到阈值)必须出现 ERROR 告警,且提示检查 port 文件 + error_msgs = [r.message for r in caplog.records if r.levelno >= logging.ERROR] + assert error_msgs, "expected an ERROR alert after threshold" + assert any("emrgd.port" in m for m in error_msgs), error_msgs + assert any("consecutive" in m for m in error_msgs), error_msgs + finally: + mod.connect_to_server = _original_connect_to_server() + + +def test_evolution_cycle_connect_failure_resets_on_success(tmp_path, caplog): + """A successful connection resets the consecutive-failure counter.""" + from emrg.server import scheduler as mod + + handler, captured = _make_cycle_handler(tmp_path, frames=[]) + async def _refuse(): + raise ConnectionRefusedError("no daemon") + mod.connect_to_server = _refuse + try: + asyncio.run(handler._run_evolution_cycle()) + assert handler._connect_failures == 1 + # 成功连接 → 计数归零 + async def _fake_connect(): + return _FakeWsForCycle([{"request_id": "r1", "content": "Done", "done": True, + "delta": False, "session_id": "s"}]) + mod.connect_to_server = _fake_connect + asyncio.run(handler._run_evolution_cycle()) + assert handler._connect_failures == 0, "success must reset the failure counter" + finally: + mod.connect_to_server = _original_connect_to_server() + + +class _FakeWsForCycle: + """Minimal ws stand-in for the reset-on-success test.""" + def __init__(self, frames): + import json as _json + from websockets.exceptions import ConnectionClosed as _Closed + self._frames = list(frames) + self._json = _json + self._Closed = _Closed + self.sent = [] + async def send(self, msg): + self.sent.append(msg) + async def recv(self): + if self._frames: + return self._json.dumps(self._frames.pop(0), ensure_ascii=False) + raise self._Closed() + async def close(self): + pass + + +def _original_connect_to_server(): + """Restore the real connect_to_server after a test replaced it.""" + import importlib + from emrg.server import scheduler as mod + return importlib.import_module("emrg.connect").connect_to_server + + # ── Saturation halt auto-resume on upstream advance ─────────────── # The halt skips scheduled runs entirely, so a halted handler can never # detect a HEAD change itself (only /trigger could resume it). If every