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@@ -118,7 +118,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (1062) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (1064) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + 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
49 changes: 38 additions & 11 deletions emrg/gui/main.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,6 +52,26 @@ function main() {
let openSessions = new Map();
let guiStateTimer = null;
const GUI_STATE_DEBOUNCE_MS = 1000;
// Rant 2026-08-25T17:38:56(GUI 会话串线/空历史,根因 1+2):会话级命令的 cwd
// 必须取该会话**真实所属项目**——兜底 DEFAULT_CWD(homedir)会让所有 GUI 会话
// 历史从 ~/.emrg/sessions/ 读取 → 一律空窗口,且 resume/task 在错误 cwd 误建
// 0-message 幽灵会话。解析顺序:openSessions 簿记 projectPath → 全局
// sessions_index.json(sid → 会话目录,cwd = 目录上溯 3 级)→ null(未知)。
// 调用方对 null 决策:新会话兜底 DEFAULT_CWD,已存在会话交由 daemon not_found
// 处理(switchSession 已有 fallback),绝不隐式回退 homedir。
function resolveSessionCwd(sessionId) {
const known = openSessions.get(sessionId)?.projectPath;
if (known) return known;
try {
const idx = JSON.parse(fs.readFileSync(path.join(os.homedir(), ".emrg", "sessions_index.json"), "utf8"));
const dir = idx[sessionId];
if (typeof dir === "string" && dir) {
// dir = <cwd>/.emrg/sessions/<sid> → cwd = 上溯 3 级(兼容 home 级会话 ~/.emrg/sessions/…)
return path.dirname(path.dirname(path.dirname(dir)));
}
} catch { /* index 缺失/损坏 → 未知(null) */ }
return null;
}
// P2.3(rant 12:20:35):HTML 预览 WebContentsView——懒创建、单实例复用、右对齐 bounds 同步。
// renderer 崩溃 reload 后由 renderer 侧拉取(emrg:getPreviewState)恢复(main 是真相源)。
let previewView = null; // WebContentsView 实例(首次打开 HTML tab 才创建)
Expand DownExpand Up@@ -300,7 +320,7 @@ vision = false
}
// P2:每会话独立连接——首条消息前自动打开(新会话不 resume,daemon 隐式订阅)
// P5 slice 2:cwd 取该会话所属项目(跨项目会话用其项目路径,非全局 projectDir)
const sessionCwd = openSessions.get(sessionId)?.projectPath || DEFAULT_CWD;
const sessionCwd = resolveSessionCwd(sessionId) || DEFAULT_CWD;
let conn = connManager?.get(sessionId);
if (!conn || !conn.connected) {
conn = await openSession(sessionId, sessionCwd, { resume: false });
Expand DownExpand Up@@ -388,7 +408,10 @@ vision = false
// G110:切会话清空旧连接分组缓存(含 timer),防广播"幽灵"残留
connManager?.get(prevSid)?.clearGroups();
// P5 slice 2:跨项目打开——用该项目路径 resume(非全局 projectDir)
const targetPath = projectPath || openSessions.get(sessionId)?.projectPath || DEFAULT_CWD;
// Rant 2026-08-25T17:38:56 根因 2:fallback 链去掉 DEFAULT_CWD——未知会话
// 交 daemon not_found 处理(下方已有 fallback 到最近会话),绝不隐式回退
// homedir 误建幽灵会话。
const targetPath = projectPath || resolveSessionCwd(sessionId);
logger.info(`[gui:switch] openSession start sid=${sessionId} targetPath=${targetPath}`);
try {
await openSession(sessionId, targetPath); // 打开(新)会话连接 + resume_session 自动订阅
Expand DownExpand Up@@ -417,7 +440,7 @@ vision = false

ipcMain.handle("emrg:deleteSession", async (_e, { sessionId }) => {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
await requireConn().sendCommandAndWait("delete_session", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000);
await requireConn().sendCommandAndWait("delete_session", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }, 5000);
connManager?.close(sessionId); // P2:删除会话 → 关闭该会话连接(若打开)
openSessions.delete(sessionId); // P4:删除(删数据)→ 一并移出打开会话簿记
schedulePersistGuiState();
Expand All@@ -429,7 +452,7 @@ vision = false
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
const clean = String(title || "").trim().slice(0, 80); // 截断超长标题
if (!clean) throw new Error("empty title");
const frame = await requireConn().sendCommandAndWait("rename_session", { session_id: sessionId, cwd: DEFAULT_CWD, title: clean }, 5000);
const frame = await requireConn().sendCommandAndWait("rename_session", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD, title: clean }, 5000);
// 跨项目会话重命名成功后立即同步侧边栏标题(rant 12:01:44)
const v = openSessions.get(sessionId);
if (v) {
Expand DownExpand Up@@ -474,21 +497,23 @@ vision = false
ipcMain.handle("emrg:clearSession", async (_e, { sessionId }) => {
// GUI / 指令 P1:/clear — 清空当前会话(daemon 协议 clear_session 已存在)
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
await requireConn().sendCommandAndWait("clear_session", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000);
await requireConn().sendCommandAndWait("clear_session", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }, 5000);
return { ok: true };
});

ipcMain.handle("emrg:compactSession", async (_e, { sessionId }) => {
// GUI / 指令 P1:/compact — 压缩当前会话历史(daemon 协议 compact 已存在)
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
await requireConn().sendCommandAndWait("compact", { session_id: sessionId, cwd: DEFAULT_CWD }, 5000);
await requireConn().sendCommandAndWait("compact", { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD }, 5000);
return { ok: true };
});

ipcMain.handle("emrg:listHistory", async (_e, { sessionId, limit, offset } = {}) => {
// GUI / 指令 P2:/rewind + rant 14:15:12 历史按需加载(limit/offset 可选)
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
const payload = { session_id: sessionId, cwd: DEFAULT_CWD };
// Rant 2026-08-25T17:38:56 根因 1(P0):历史/记忆命令的 cwd 必须取会话真实
// 项目路径——硬编码 DEFAULT_CWD 使所有 GUI 会话从 ~/.emrg/sessions/ 读取 → 空历史。
const payload = { session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD };
if (limit != null) payload.limit = limit;
if (offset != null) payload.offset = offset;
const frame = await requireConn().sendCommandAndWait("list_history", payload, 5000);
Expand All@@ -503,15 +528,17 @@ vision = false
}
const frame = await requireConn().sendCommandAndWait(
"rewind_session",
{ session_id: sessionId, cwd: DEFAULT_CWD, record_index: recordIndex },
{ session_id: sessionId, cwd: resolveSessionCwd(sessionId) || DEFAULT_CWD, record_index: recordIndex },
5000
);
return { ok: true, removedCount: frame.removed_count ?? 0 };
});

ipcMain.handle("emrg:listMemories", async (_e, { scope = "project", sessionId } = {}) => {
// GUI / 指令 P3:/memory — 列出记忆(daemon list_memories → memories_list)
const params = { scope, cwd: DEFAULT_CWD };
// 记忆命令同样按会话真实项目取 cwd(scope=session 时用该会话,project 时用当前会话)
const memCwd = resolveSessionCwd(sessionId || currentSessionId) || DEFAULT_CWD;
const params = { scope, cwd: memCwd };
if (scope === "session") {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
params.session_id = sessionId;
Expand All@@ -523,7 +550,7 @@ vision = false
ipcMain.handle("emrg:readMemory", async (_e, { memoryId, scope = "project", sessionId } = {}) => {
// GUI / 指令 P3:/memory <id> — 读取单条记忆(daemon read_memory → memory_content)
if (typeof memoryId !== "string" || !memoryId.trim()) throw new Error("invalid memory_id");
const params = { scope, memory_id: memoryId.trim(), cwd: DEFAULT_CWD };
const params = { scope, memory_id: memoryId.trim(), cwd: resolveSessionCwd(sessionId || currentSessionId) || DEFAULT_CWD };
if (scope === "session") {
if (!validateSessionId(sessionId)) throw new Error("invalid session_id");
params.session_id = sessionId;
Expand DownExpand Up@@ -1144,7 +1171,7 @@ vision = false
if (connManager.daemonConn()?.connected) {
// G41(P2 改写):恢复当前会话连接(若 daemon 重启后未由 recoverAll 重开)
if (currentSessionId && !connManager.get(currentSessionId)) {
try { await openSession(currentSessionId, DEFAULT_CWD); } catch { /* 会话可能已删 */ }
try { await openSession(currentSessionId, resolveSessionCwd(currentSessionId) || DEFAULT_CWD); } catch { /* 会话可能已删 */ }
}
const sessions = await listSessions();
sendToRenderer("sessions", { sessions });
Expand Down
81 changes: 75 additions & 6 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -258,7 +258,11 @@ def __init__(self, llm_config: LlmConfig) -> None:
self._rants_log = runtime_dir / "rants.jsonl"

# ── Phase 2 broadcast model (protocol-contract §2.6) ──
self._session_subscribers: dict[str, set] = {} # session_id → set[ws]
# Rant 2026-08-25T17:38:56 根因 3(P1):订阅按 session_id 键控、与 cwd 无关
# → 错误 cwd 的客户端(GUI 幽灵连接)与真实连接同组,实时消息串线。改为
# session_id → {ws: cwd},广播时按运行中任务的 cwd 过滤(见 _session_task_cwds)。
self._session_subscribers: dict[str, dict] = {} # session_id → {ws: cwd_str}
self._session_task_cwds: dict[str, str] = {} # session_id → 运行中任务的 cwd
self._session_busy: dict[str, bool] = {} # session_id → active task?
# P1 queue-injection (rant 2026-08-10T21:55:37): per-session FIFO of
# (TaskRequest, allow_tools) received while a tool loop is busy —
Expand DownExpand Up@@ -751,8 +755,9 @@ async def _handle_client(self, ws) -> None:
new_sid = data["session_id"]
if new_sid != last_session_id:
if last_session_id: # unsubscribe from previous session
self._session_subscribers.get(last_session_id, set()).discard(ws)
self._session_subscribers.setdefault(new_sid, set()).add(ws)
self._session_subscribers.get(last_session_id, {}).pop(ws, None)
# 订阅记录该连接的 cwd——广播按 (session, cwd) 过滤(rant 17:38:56 根因 3)
self._session_subscribers.setdefault(new_sid, {})[ws] = data.get("cwd") or last_cwd or ""
last_session_id = new_sid
if data.get("cwd"):
last_cwd = data["cwd"]
Expand DownExpand Up@@ -866,7 +871,7 @@ async def _handle_client(self, ws) -> None:
pass
# Phase 2 broadcast: unsubscribe on disconnect (protocol-contract §2.6.2)
if last_session_id:
self._session_subscribers.get(last_session_id, set()).discard(ws)
self._session_subscribers.get(last_session_id, {}).pop(ws, None)
self._all_connections.discard(ws)
try:
await ws.close()
Expand DownExpand Up@@ -898,9 +903,21 @@ async def _send(self, ws, data: dict) -> bool:
async def _broadcast(self, session_id: str, data: dict) -> None:
"""Send data to all subscribers of session_id (including the originator).

Rant 2026-08-25T17:38:56 根因 3(P1):同一 session_id 可能有不同 cwd 的
订阅者(GUI 幽灵连接 vs TUI 真实连接)。运行中任务的事件按任务 cwd 过滤,
只投递给同一 (session, cwd) 的订阅者——幽灵连接不再收到真实会话的实时流。
无运行中任务(compact_result 等)时广播全部订阅者(内容非会话实时流,
无串线风险)。

Best-effort: a single dead subscriber must not affect the others.
"""
for w in list(self._session_subscribers.get(session_id, ())):
subs = self._session_subscribers.get(session_id, {})
task_cwd = self._session_task_cwds.get(session_id)
if task_cwd:
targets = [w for w, wcwd in subs.items() if wcwd == task_cwd]
else:
targets = list(subs)
for w in targets:
try:
await self._send(w, data)
except Exception:
Expand DownExpand Up@@ -2269,12 +2286,16 @@ async def _run_tool_loop_locked(
) -> None:
"""Run _run_tool_loop and release the session busy lock on exit."""
session_id = session.session_id
# Rant 2026-08-25T17:38:56 根因 3:广播按任务 cwd 过滤——记录本任务真实 cwd,
# 幽灵连接(错误 cwd 订阅)在任务期间收不到该会话实时流。
self._session_task_cwds[session_id] = str(session.cwd)
normal_end = False
try:
await self._run_tool_loop(req, ws, session, cancel_event, allow_tools)
normal_end = True
finally:
self._session_busy[session_id] = False
self._session_task_cwds.pop(session_id, None)
# P1 (rant 21:55:37): messages still queued when the loop ends are
# not lost. We do NOT start a follow-up task here (_tool_task /
# _cancel_event are read-loop locals — a hand-off would break
Expand DownExpand Up@@ -3478,16 +3499,44 @@ async def _handle_resume_session(

The client reads history.jsonl directly from disk for display.
We only confirm the session exists — no records over the wire.

Rant 2026-08-25T17:38:56 (GUI 会话串线/空历史,根因 2):此前只查
session_dir.exists() —— 错误 cwd 的客户端(GUI homedir 兜底)会在
~/.emrg/sessions/<sid>/ 误建 0-message 幽灵会话,resume 照常返回成功
(0 messages),真实会话所在项目反而永远打不开。修复:meta.json 缺失
或 message_count==0 时,按全局 sessions_index.json 校验请求 cwd 是否为
该会话真实项目路径;不匹配 → not_found + warning(幽灵目录不再掩盖错误)。
"""
session_dir = cwd / ".emrg" / "sessions" / session_id
if not session_dir.exists():
meta_path = session_dir / "meta.json"
if not session_dir.exists() or not meta_path.exists():
await self._send(ws, {
"type": "resume_result",
"session_id": session_id,
"error": f"Session {session_id} not found",
})
return

try:
meta = json.loads(meta_path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
meta = {}
if meta.get("message_count", 0) <= 0:
canonical = self._canonical_session_cwd(session_id)
if canonical is None or str(Path(canonical).resolve()) != str(Path(cwd).resolve()):
logger.warning(
"resume rejected: ghost session %s (0 messages) at %s — "
"real session lives at %s (canonical cwd from sessions index)",
session_id, session_dir, canonical or "unknown",
)
await self._send(ws, {
"type": "resume_result",
"session_id": session_id,
"error": f"Session {session_id} not found (no messages at this cwd; "
"real session lives in its project — open from the project's session list)",
})
return

session = Session.load(session_id, cwd)

await self._send(ws, {
Expand All@@ -3502,6 +3551,26 @@ async def _handle_resume_session(
},
})

def _canonical_session_cwd(self, session_id: str) -> str | None:
"""Resolve a session's canonical project cwd from the global sessions index.

Index maps sid → absolute session dir (<cwd>/.emrg/sessions/<sid>);
the cwd is the dir 3 levels up (covers home-level sessions too:
~/.emrg/sessions/<sid> → cwd = ~).
"""
try:
from emrg.sessions_index import sessions_index_path, _load

data = _load(sessions_index_path())
dir_str = data.get(session_id)
if not dir_str:
return None
p = Path(dir_str)
return str(p.parent.parent.parent)
except Exception:
logger.debug("canonical session cwd lookup failed", exc_info=True)
return None

# ── GUI workspace panel: list_files / read_file (rant 2026-08-11T12:20:35 P1.1) ──
# 单目录条目上限:超出截断 + truncated 提示(与 ReadTool 的防爆理念一致)
_MAX_LIST_ENTRIES = 5000
Expand Down
Loading
Loading