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
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 路径不受影响)
Expand Down
4 changes: 2 additions & 2 deletions README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -274,15 +274,15 @@ 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 即失败

# 可选:Electron GUI(非开发者主入口,Phase 3)
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`)。
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
17 changes: 11 additions & 6 deletions emrg/gui/daemon_client.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
Expand DownExpand Up@@ -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); });
Expand DownExpand Up@@ -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+/);
}

Expand All@@ -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();
Expand Down
35 changes: 28 additions & 7 deletions emrg/gui/test/daemon_client.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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;
}
Expand DownExpand Up@@ -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");
Expand DownExpand Up@@ -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();
Expand All@@ -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);
Expand DownExpand Up@@ -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);
});

Expand Down
10 changes: 7 additions & 3 deletions emrg/gui/test/integration.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"),
Expand All@@ -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"));
Expand DownExpand Up@@ -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");
Expand Down
29 changes: 25 additions & 4 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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"
Expand DownExpand Up@@ -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(
Expand Down
18 changes: 15 additions & 3 deletions tests/test_doc_counts.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
Expand All@@ -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}"
Expand Down
81 changes: 81 additions & 0 deletions tests/test_scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading