diff --git a/Agent.md b/Agent.md index e6f742a4..5c257937 100644 --- a/Agent.md +++ b/Agent.md @@ -54,7 +54,7 @@ What EMRG does today — capability statements, not a changelog. One line per command: what it answers. Run the daemon in the foreground with `python -m emrg`. - Python: `uv run pytest tests/ -v` — the number is **measured, never stored**: `uv run --no-sync python3 scripts/check-doc-count.py --measure`. Import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (168: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 11 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` +GUI: `cd emrg/gui && npm test` (169: 100 daemon_client + 20 conn-manager + 7 integration + 7 nav-policy + 7 gui-state + 9 build-config + 12 boot-contract + 3 preload-api + 2 theme-guard + 2 image-hash) — syntax: `node --check main.js preload.js daemon_client.js` Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (740: 5 snapshot-store + 13 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 13 markdown + 29 transcript + 28 TranscriptView + 22 history + 25 historyReplay + 40 composer + 75 Composer + 6 LinkDialog + 16 sidebar + 18 Sidebar + 9 fileTree + 9 FileTree + 17 resultPanel + 8 ResultPanel + 27 workspaceView + 13 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 12 dialogLists + 3 HelpDialog + 9 MemoryDialog + 8 SkillsDialog + 8 openSession + 6 WelcomeDialog + 9 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 59 daemonBridge + 9 DaemonBridgeProvider + 50 Shell + 25 DialogHost + 26 SettingsPanel + 18 TaskFormDialog + 5 RantDialog + 4 vendorMarkdown + 6 ApprovalDialog + 16 SandboxRootsDialog) - CI: pytest on ubuntu **and windows-2025**, the GUI suites, and an actionlint gate over `.github/workflows/`; re-trigger a dropped push with `scripts/re-trigger-ci.sh `, never an empty commit - Merge gates, run before merging — each asks what no other gate asks: `uv run --no-sync python3 scripts/check-vote-count.py` (approvals still valid) · `scripts/check-pr-base.py` (base reaches master) · `scripts/check-merge-freshness.py` (that green CI is about the landing tree) · `scripts/check-merge-order.py` (dirt and what it lands) · `scripts/check-merge-landing-diff.py` (what the landing changes) · `scripts/check-merge-pairs.py` / `scripts/check-merge-sequence.py` (a pair, or a whole order) · `scripts/check-merge-tree-health.py` (the merged tree passes one guard) · `scripts/check-merge-plan-suite.py` (the plan's final tree passes the suite) — all under `uv run --no-sync python3` diff --git a/emrg/gui/main.js b/emrg/gui/main.js index 516fae1e..12f49b4b 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -584,11 +584,9 @@ vision = false logger.warn(`[gui:switch] REJECT too many open sessions (${openSessions.size}/${DEFAULT_CAP}) sid=${sessionId}`); throw new Error(`too many open sessions (${DEFAULT_CAP}) — close some first`); } - // G65:自有流运行中禁止切会话(每连接独立锁,查当前激活连接) - if (connManager?.get(currentSessionId)?.ownStream) { - logger.warn(`[gui:switch] REJECT own stream in progress (sid=${currentSessionId})`); - throw new Error("stream in progress — cannot switch"); - } + // issue #2637:切会话不看 ownStream。自有流跑在它自己会话的连接上,切换不动它 + // (P4:切走不关闭旧连接),所以「流运行中禁止切」只拒绝、不保护。G65 锁仍归 + // close() / cancel 用:conn-manager 关闭在忙连接时先 cancel 再断 ws。 const prevSid = currentSessionId; logger.info(`[gui:switch] prevSid=${prevSid} → target=${sessionId}, openSessions.size=${openSessions.size}`); // G110:切会话清空旧连接分组缓存(含 timer),防广播"幽灵"残留 diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 2daa880d..cf5a4ca9 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -322,7 +322,19 @@ export function Shell() { async function selectProjectSession(p: AgentRec, sid: string) { const b = wsBridge(); - if (b?.switchSession) await b.switchSession({ sessionId: sid }); + // issue #2637:这里原来没有 catch —— 一次拒绝就是未处理的 promise 拒绝,界面毫无 + // 反应(与任务面板的 catch 不对称)。拒绝要有落点。 + if (b?.switchSession) { + try { + await b.switchSession({ sessionId: sid }); + } catch (e) { + transcript.addSystemMessage( + t("openSession.loadFailed", { msg: (e as Error)?.message ?? String(e) }), + activeSid, + ); + return; + } + } setActiveSid(sid); setActiveView("sessions"); void loadHistory(sid); diff --git a/emrg/gui/test/boot-contract.test.js b/emrg/gui/test/boot-contract.test.js index 77639c58..5126ce55 100644 --- a/emrg/gui/test/boot-contract.test.js +++ b/emrg/gui/test/boot-contract.test.js @@ -338,3 +338,34 @@ test("issue #2490: emrg:taskLimits reports the daemon's own minimum, never a lit "no literal floor may live in this handler — that is the copy issue #2490 removes" ); }); + +// issue #2637:切会话不再因「当前会话自有流运行中」被拒。 +// 那条拒绝成立过一个年代:切换当时会关闭旧连接,而 close() 对 ownStream 连接先发 +// cancel 再断 ws(conn-manager.test.js 的 P6 正钉着「先 cancel 再 close」),于是放行 +// 切换就等于静默杀掉正在跑的那一轮。此后切换改为不动旧连接(关闭是 emrg:closeSession +// 的事),每会话各有 ws 与记录桶 ⇒ 那条拒绝只剩拒绝本身,而它的两个调用方都把拒绝说 +// 在用户看不见的地方:侧边栏没有 catch(未处理的 promise 拒绝、界面毫无反应),任务 +// 面板的 catch 写进「正在离开的」那个会话的记录。 +test("issue #2637: emrg:switchSession does not consult ownStream to refuse a switch", () => { + // 「查了没有」问的是代码,不是注释:注释里点到这把锁的正当改版必须静默,代码里真去 + // 读它必须变红 —— 两条都在下面各钉一次,否则这个谓词读不出任何东西。 + const withoutLineComments = (s) => s.replace(/^\s*\/\/.*$/gm, ""); + const region = handlerRegion(read("main.js"), "emrg:switchSession"); + assert.ok( + !/ownStream/.test(withoutLineComments(region)), + "a streaming turn lives on its own session's connection and outlives the switch, so " + + "refusing the navigation protects nothing; the lock still matters to close(), which " + + "cancels a busy connection — it just must not gate switching", + ); + assert.ok( + !/ownStream/.test(withoutLineComments("// 切会话不查 ownStream\nfoo();")), + "a comment naming the lock is not a consultation of it", + ); + assert.ok( + /ownStream/.test(withoutLineComments('if (c?.ownStream) throw new Error("x");')), + "reading it in code is still caught — else this guard reads nothing at all", + ); + // 反方向:同一段里别的拒绝不许被一起带走。 + assert.match(region, /REJECT invalid session_id/, "session-id validation stays a refusal"); + assert.match(region, /too many open sessions/, "the open-session cap stays a refusal"); +});