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
6 changes: 3 additions & 3 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` (98: 24 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` (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`
- **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 All @@ -93,8 +93,8 @@ 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` (647) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (98: 24 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`
Python: `uv run pytest tests/ -v` (650) — 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`
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
3 changes: 2 additions & 1 deletion README.cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,15 @@ EMRG 不只是追赶——它自己追上来。
git clone https://github.com/argszero/emrg.git
cd emrg
uv sync # 安装依赖
uv run pytest tests/ -v # 跑测试(当前 647 项)
uv run pytest tests/ -v # 跑测试(当前 650 项)
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 测试(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)
```

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,15 +273,15 @@ 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 647 items)
uv run pytest tests/ -v # run tests (currently 650 items)
uv run python -m emrg # launch TUI
# CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI

# Optional: Electron GUI (non-developer entry point, Phase 3)
cd emrg/gui
npm ci # install deps (production: --omit=dev)
npm start # launch GUI (auto-starts daemon)
npm test # run Node tests (98: 24 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 (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)
```

CI runs tests and checks for conflict markers automatically via GitHub Actions (`.github/workflows/test.yml`).
Expand Down
28 changes: 27 additions & 1 deletion emrg/gui/daemon_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,22 @@ class DaemonClient {
throw new Error(`emrgd failed to start within timeout${this._readLogTail()}`);
}

// Rant 2026-08-09T13:16:36 G43 加固:daemon 进程是否存活(emrgd.pid 探测)。
// 存活 → ws 连接失败视为瞬时(daemon 重启/启动中),保留 port 文件交给退避重试;
// 死亡 → 允许 G43 删文件重拉。
_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 文件)
}
}

_findDaemonExecutable() {
// Phase 4(rant #12 §4 R7):打包模式定位捆绑 emrgd。
// Windows: ~/.emrg/install/bin/emrgd.cmd;POSIX: ~/.emrg/install/bin/emrgd。
Expand Down Expand Up @@ -217,7 +233,17 @@ class DaemonClient {
try {
await this._awaitOpen();
} catch (e) {
// G43:port 文件存在但连不上(daemon 已死/端口被占)→ 删文件重拉一次
// G43 加固(rant 2026-08-09T13:16:36 根因):port 文件存在但连不上时,
// 先查 emrgd.pid —— daemon 进程还活着就【绝不删 port 文件】。旧 G43 直接
// unlink 会把健康 daemon 的 port 文件删掉 → 僵尸态(daemon 活着、scheduler
// 永远 cannot connect、PID 锁挡住新 spawn)。只有 daemon 真死了才删+重拉。
if (this._daemonProcessAlive()) {
this.logger.warn(
`[gui] ws connect failed: ${e.message} — daemon pid alive, keeping port file (transient)`
);
try { this.ws.close(); } catch { /* ignore */ }
throw new Error(`daemon unreachable (pid alive): ${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 */ }
Expand Down
49 changes: 49 additions & 0 deletions emrg/gui/test/daemon_client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,55 @@ test("G43 stale port: 连接失败(port 文件存在但拒绝)→ 删文件
assert.strictEqual(client.connected, true);
});

test("rant 13:16:36 G43 加固:daemon 进程活着 → ws 失败不删 port 文件、不重拉", async () => {
const client = new DaemonClient({ projectDir: tmpHome });
// 写入 emrgd.pid(当前进程 = 活着)
fs.writeFileSync(path.join(tmpHome, ".emrg", "emrgd.pid"), String(process.pid));
const portFile = PORT_FILE(tmpHome);
fs.writeFileSync(portFile, "41237\nseekrit-token");
assert.strictEqual(client._daemonProcessAlive(), true, "pid alive → true");

let respawned = false;
client.startDaemon = async function () { respawned = true; };
const p = client.ensureConnected();
await waitForWs();
const firstWs = currentMockWs;
firstWs.emit("error", new Error("connect ECONNREFUSED"));
// 守卫路径:不删文件、不重拉,直接抛"daemon unreachable (pid alive)"
await assert.rejects(p, /daemon unreachable \(pid alive\)/);
assert.strictEqual(fs.existsSync(portFile), true, "port 文件必须保留(daemon 还活着)");
assert.strictEqual(respawned, false, "pid 活着 → 不重拉 daemon(防风暴)");
});

test("rant 13:16:36 G43 加固:daemon 真死了(pid 不存在)→ 仍删文件重拉", async () => {
const client = new DaemonClient({ projectDir: tmpHome });
// pid 文件指向不存在的进程 → 视为死 daemon
fs.writeFileSync(path.join(tmpHome, ".emrg", "emrgd.pid"), "999999");
assert.strictEqual(client._daemonProcessAlive(), false, "pid 不存在 → false");

let respawned = false;
client.startDaemon = async function () {
respawned = true;
fs.writeFileSync(PORT_FILE(tmpHome), "41238\nseekrit-token");
};
const p = client.ensureConnected();
await waitForWs();
const firstWs = currentMockWs;
firstWs.emit("error", new Error("connect ECONNREFUSED"));
await waitForWs(() => currentMockWs !== firstWs);
assert.ok(respawned, "真死 → 重拉 daemon");
currentMockWs.emit("open");
await waitForAuthSent(currentMockWs);
currentMockWs.emit("message", Buffer.from(JSON.stringify({ type: "auth_ok" })));
await p;
assert.strictEqual(client.connected, true);
});

test("rant 13:16:36 G43 加固:无 pid 文件 → 视为死 daemon(删文件重拉)", async () => {
const client = new DaemonClient({ projectDir: tmpHome });
assert.strictEqual(client._daemonProcessAlive(), false, "无 pid 文件 → false");
});

test("rant 13:16:36 ⑤ spawn 节流:超 MAX_SPAWN_ATTEMPTS 后不再拉起 daemon", async () => {
const client = new DaemonClient({ projectDir: tmpHome });
let spawnCount = 0;
Expand Down
49 changes: 44 additions & 5 deletions emrg/server/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -264,17 +264,20 @@ async def serve(self) -> None:
)
port = self._server.sockets[0].getsockname()[1]
self._auth_token = secrets.token_urlsafe(32)
atomic_write_bytes(
f"{port}\n{self._auth_token}",
config_dir() / "emrgd.port",
mode=0o600,
)
self._assert_port_file(port)
logger.info(
"emrgd listening on 127.0.0.1:%d | identity=%s",
port,
self.identity.instance_id[:8],
)

# Rant 2026-08-09T13:16:36 root-cause self-heal: G43 stale-port logic
# deleted a healthy daemon's emrgd.port after a transient ws failure →
# the daemon's OWN scheduler lost the file (93× "cannot connect") while
# GUI respawns hit the PID lock and exited. The daemon re-asserts its
# port file periodically so any external deletion self-heals.
self._port_keepalive_task = asyncio.create_task(self._port_keepalive_loop())

self._scheduler = TaskScheduler(self.identity)
self._scheduler.load_and_start()

Expand All @@ -294,6 +297,11 @@ async def serve(self) -> None:
await self._skills_ttl_task
except (asyncio.CancelledError, Exception):
pass
self._port_keepalive_task.cancel()
try:
await self._port_keepalive_task
except (asyncio.CancelledError, Exception):
pass
self._scheduler.stop_all()
await self._scheduler.wait_all()
await self.llm.close()
Expand All @@ -306,6 +314,37 @@ async def serve(self) -> None:
except OSError:
pass

async def _port_keepalive_loop(self) -> None:
"""Re-assert the port file if it was deleted or overwritten.

Rant 2026-08-09T13:16:36 root cause: a client's stale-port unlink
(G43) can remove a healthy daemon's emrgd.port after one transient
ws failure. The daemon's own scheduler reads that file to reconnect,
so it then fails forever while the PID lock blocks new spawns —
the zombie state behind the Windows v0.2.15 storm. Re-writing the
file every 60s makes the daemon self-healing.
"""
port_path = config_dir() / "emrgd.port"
while self._running:
await asyncio.sleep(60)
try:
if not port_path.exists():
port = self._server.sockets[0].getsockname()[1]
self._assert_port_file(port)
logger.warning(
"emrgd.port was missing — re-asserted (external deletion?)"
)
except (OSError, IndexError, AttributeError):
pass

def _assert_port_file(self, port: int) -> None:
"""(Re)write the port/token file for the current listener."""
atomic_write_bytes(
f"{port}\n{self._auth_token}",
config_dir() / "emrgd.port",
mode=0o600,
)

async def _skills_ttl_loop(self) -> None:
"""Background deterministic skill update check (startup + every 24h).

Expand Down
57 changes: 57 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,63 @@ def test_rant_field_order(tmp_path, monkeypatch):
assert abs((_dt.datetime.now(ts.tzinfo) - ts).total_seconds()) < 60


# ── Port-file self-heal (rant 2026-08-09T13:16:36 root cause) ─────────
# G43 stale-port logic once deleted a healthy daemon's emrgd.port after a
# transient ws failure → daemon's own scheduler lost the file (93× "cannot
# connect") while the PID lock blocked new spawns. The daemon re-asserts
# its port file so any external deletion self-heals.

def test_assert_port_file_writes_port_and_token(tmp_path, monkeypatch):
"""_assert_port_file writes '<port>\\n<token>' with mode 0o600."""
monkeypatch.setattr("emrg.server.daemon.config_dir", lambda: tmp_path)
server = _make_server()
server._auth_token = "tok123"
server._assert_port_file(43210)
text = (tmp_path / "emrgd.port").read_text(encoding="utf-8")
assert text == "43210\ntok123"


def test_assert_port_file_rewrites_deleted_file(tmp_path, monkeypatch):
"""A deleted port file is re-asserted on the next keepalive tick."""
monkeypatch.setattr("emrg.server.daemon.config_dir", lambda: tmp_path)
server = _make_server()
server._auth_token = "tok456"
server._assert_port_file(45678)
port_path = tmp_path / "emrgd.port"
assert port_path.exists()

# 外部删除(模拟 G43 unlink 竞态)
port_path.unlink()
assert not port_path.exists()

# keepalive loop 的恢复逻辑:缺失 → 重新断言
server._assert_port_file(45678)
text = (tmp_path / "emrgd.port").read_text(encoding="utf-8")
assert text == "45678\ntok456"


def test_port_keepalive_loop_restores_missing_file(tmp_path, monkeypatch):
"""The keepalive loop re-asserts a deleted port file within one tick."""
import asyncio

monkeypatch.setattr("emrg.server.daemon.config_dir", lambda: tmp_path)
server = _make_server()
server._auth_token = "tok789"
server._server = type("S", (), {"sockets": [type("Sock", (), {"getsockname": lambda self: (None, 9999)})()]})()
server._running = True
server._assert_port_file(9999)
port_path = tmp_path / "emrgd.port"
port_path.unlink()

# 执行与 loop 相同的恢复逻辑(loop 本体 sleep 60s,测试直接驱动检查体)
async def one_tick():
if not port_path.exists():
server._assert_port_file(9999)
asyncio.run(one_tick())
assert port_path.exists()
assert port_path.read_text(encoding="utf-8") == "9999\ntok789"


# ── _redact 日志脱敏(rant 10:21 + 跨项目 base64 教训)──────────────


Expand Down
Loading