From 9f04ff374b3da5209c910aed67c6649d804a6a1c Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 11 Aug 2026 21:04:52 +0800 Subject: [PATCH 1/2] emrg: GUI queue-injection client support (P2 of #655) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The daemon-side mid-turn queue injection (#655) is unreachable from the GUI: sendMessage() silently returned while busy, and none of the 4 broadcast frames (task_queued / steer_committed / queued_requeue / queued_cancelled) had handleEvent branches. - app.js sendMessage(): busy early-return removed (wasBusy capture); sends while busy recorded in state.queuedSends (sid -> [{requestId,text,mode}]) - app.js handleEvent: 4 new cases — task_queued (position note, sid-scoped), steer_committed (dequeue), queued_requeue (silent re-send with same requestId via window.emrg.sendMessage — no duplicate user row; background sessions touch only their own sid entry), queued_cancelled (clear + note) - disconnected clears the sid queue (daemon drops it on disconnect) - i18n zh/en 3 keys (app.queued / queuedResent / queuedCancelled) - +5 GUI tests (busy send recorded / position note / steer dequeue / requeue same-id re-send + queue clear / cancel clear), 212 -> 217; Agent.md counts + quick-ref entry synced --- Agent.md | 4 +- emrg/gui/renderer/js/app.js | 64 ++++++++++++++++++++- emrg/gui/renderer/js/i18n.js | 6 ++ emrg/gui/test/renderer.smoke.test.js | 83 ++++++++++++++++++++++++++++ emrg/server/evolution_prompt.md | 1 + 5 files changed, 155 insertions(+), 3 deletions(-) diff --git a/Agent.md b/Agent.md index be57539..49e91f4 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - 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` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (217: 43 daemon_client + 19 conn-manager + 22 app-commands + 96 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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) @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (703) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (217: 43 daemon_client + 19 conn-manager + 22 app-commands + 96 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — 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/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 89ac7d4..48ebbd1 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -19,6 +19,9 @@ const App = (() => { // (defineProperty getter/setter → sidState(sessionId)),既有调用点零改动; // 事件按 sid 路由时操作对应条目(后台会话的 done 不误清激活会话的 busy)。 sessionsBySid: new Map(), // sid → { busy, ownStreamRequestId, mode, autoScroll } + // P2 queue-injection(#655):busy 时发送的消息入 daemon 队列(task_queued), + // 此处按会话记录以便 queued_requeue 以原 requestId 重发(不重加用户行)。 + queuedSends: new Map(), // sid → [{ requestId, text, mode }] // P4 slice 2(rant 15:07:19):跨项目打开的会话(侧边栏数据源,main 广播) openSessions: [], // [{ sid, projectName, projectPath, lastActive }],lastActive 倒序 apiKeyConfigured: false, @@ -133,7 +136,7 @@ const App = (() => { async function sendMessage() { const input = $("input"); const text = input.value.trim(); - if (!text || state.busy) return; + if (!text) return; // GUI / 指令(rant 19:44 P1):/ 开头 → 路由到指令 handler,不走 sendMessage const parsed = Commands.parseInput(text); if (parsed.type !== "message") { @@ -147,6 +150,9 @@ const App = (() => { Chat.addSystemMessage(_t("app.needSession")); return; } + // P2 queue-injection(#655):busy 不再拦截——daemon 排队注入(task_queued), + // 回合结束未注入则 queued_requeue 以原 requestId 重发。busy 时记录待重发条目。 + const wasBusy = state.busy; state.busy = true; setComposerDisabled(true); Chat.addUserMessage(text); @@ -157,6 +163,11 @@ const App = (() => { // G143:send 前预生成 requestId 并标记自有流——消除 IPC 往返竞态窗口 const requestId = genRequestId(); state.ownStreamRequestId = requestId; + if (wasBusy) { + const sid = state.sessionId; + if (!state.queuedSends.has(sid)) state.queuedSends.set(sid, []); + state.queuedSends.get(sid).push({ requestId, text, mode: state.mode }); + } try { const res = await window.emrg.sendMessage({ sessionId: state.sessionId, text, requestId, mode: state.mode }); state.ownStreamRequestId = res.requestId || requestId; // G124:以 daemon 回显为准 @@ -1194,6 +1205,56 @@ const App = (() => { if (!sid || sid === state.sessionId) setComposerDisabled(false); } break; + // P2 queue-injection(#655):4 个 daemon→client 广播帧(busy 排队注入协议) + case "task_queued": + Chat.addSystemMessage(_t("app.queued", { pos: data.position || 0 }), sid); + break; + case "steer_committed": + // 已注入当前回合——从待重发记录移除(回合内 deltas 会带上原 turn 的回复) + { + const q = state.queuedSends.get(sid); + if (q && data.request_id) { + const idx = q.findIndex((e) => e.requestId === data.request_id); + if (idx >= 0) q.splice(idx, 1); + if (q.length === 0) state.queuedSends.delete(sid); + } + } + break; + case "queued_requeue": + // 回合正常结束且消息从未注入——daemon 锁已释放,以原 requestId 静默重发 + // (不重加用户行;后台会话按 sid 处理,只操作该会话条目) + { + const q = state.queuedSends.get(sid); + if (q && q.length) { + const ids = new Set(data.request_ids || []); + const toResend = q.filter((e) => ids.has(e.requestId)); + if (toResend.length) { + state.queuedSends.delete(sid); + for (const item of toResend) { + const sst = sidState(sid); + sst.busy = true; + sst.ownStreamRequestId = item.requestId; + if (!sid || sid === state.sessionId) setComposerDisabled(true); + try { + const res = await window.emrg.sendMessage({ sessionId: sid, text: item.text, requestId: item.requestId, mode: item.mode }); + sst.ownStreamRequestId = res.requestId || item.requestId; + } catch (e) { + sst.busy = false; + sst.ownStreamRequestId = null; + if (!sid || sid === state.sessionId) setComposerDisabled(false); + } + } + Chat.addSystemMessage(_t("app.queuedResent", { n: toResend.length }), sid); + } + } + } + break; + case "queued_cancelled": + // 回合被取消/异常/断连——daemon 丢弃队列 + if (state.queuedSends.delete(sid)) { + Chat.addSystemMessage(_t("app.queuedCancelled"), sid); + } + break; case "error": handleError(data, sid); break; @@ -1225,6 +1286,7 @@ const App = (() => { sst.busy = false; sst.ownStreamRequestId = null; sst.disconnected = true; // P3 finalize:该会话条目标断线(P4 恢复 UI 用) + state.queuedSends.delete(sid); // P2 queue-injection:断连 daemon 丢队列 const isActive = !sid || sid === state.sessionId; if (isActive) { updateConnectionDot("red"); diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index 6856649..011e62c 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -296,6 +296,9 @@ const I18N = (() => { "app.workdirInvalid": "工作目录不可用,请到设置里改一下。", "app.bootFailed": "启动遇到了问题:{msg}", "app.needSession": "请先创建一个对话。", + "app.queued": "⏳ 已排队(位置 {pos})— 当前回合结束后处理。", + "app.queuedResent": "→ 已重新发送 {n} 条排队消息。", + "app.queuedCancelled": "⏹ 排队消息已取消。", "app.recentImprovements": "最近改进", "app.noImprovements": "还没有改进记录,输入 /rant 驱动第一次进化吧", "app.cmdUnknown": "指令 {cmd} 暂未开放。", @@ -637,6 +640,9 @@ const I18N = (() => { "app.workdirInvalid": "Working directory unavailable — update it in Settings.", "app.bootFailed": "Startup failed: {msg}", "app.needSession": "Start a conversation first.", + "app.queued": "⏳ Queued (position {pos}) — will run after the current turn.", + "app.queuedResent": "→ Re-sent {n} queued message(s).", + "app.queuedCancelled": "⏹ Queued message(s) cancelled.", "app.recentImprovements": "Recent improvements", "app.noImprovements": "No improvements recorded yet — type /rant to drive the first evolution", "app.cmdUnknown": "Command {cmd} is not available yet.", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index ef10dfb..54d95c2 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1427,6 +1427,89 @@ test("P3 s1: cancelled 带 sid → 只清该会话条目;无 sid → 清激活 assert.strictEqual(ctx.App.state.busy, false, "no-sid cancelled clears active"); }); +// ── P2 queue-injection(#655):GUI 客户端侧(busy 排队注入协议)── + +test("P2 queue: sendMessage while busy records queued send (no early-return)", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + els["input"].value = "queued msg"; + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.state.busy = true;' + + 'App.sendMessage();', + ctx + ); + await tick(); // sendMessage 内部 await window.emrg.sendMessage(mock 立即 resolve) + const q = ctx.App.state.queuedSends.get("sess-1"); + assert.ok(q && q.length === 1, "busy send recorded in queuedSends"); + assert.strictEqual(q[0].text, "queued msg"); + assert.strictEqual(q[0].requestId, "mock-uuid", "pre-generated requestId preserved"); +}); + +test("P2 queue: task_queued shows queued position note", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.handleEvent({ type: "task_queued", sid: "sess-1", data: { position: 2 } });', + ctx + ); + const texts = [...els["chat-view"].children].map((c) => c.textContent).join("|"); + assert.ok(texts.includes("位置 2"), "task_queued shows position note"); +}); + +test("P2 queue: steer_committed removes that request from queue", async () => { + const { ctx } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.state.queuedSends.set("sess-1", [{ requestId: "req-a", text: "hi", mode: "auto" }, { requestId: "req-b", text: "yo", mode: "auto" }]);' + + 'App.handleEvent({ type: "steer_committed", sid: "sess-1", data: { request_id: "req-a" } });', + ctx + ); + const q = ctx.App.state.queuedSends.get("sess-1"); + assert.strictEqual(q.length, 1, "steer_committed removes that request"); + assert.strictEqual(q[0].requestId, "req-b"); +}); + +test("P2 queue: queued_requeue re-sends with same requestId + clears queue", async () => { + const sent = []; + const { ctx, els } = makeSandbox({ + sendMessage: async (p) => { sent.push(p); return { ok: true, requestId: p.requestId }; }, + }); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.state.busy = true;' + + 'App.state.queuedSends.set("sess-1", [{ requestId: "req-queue", text: "hi", mode: "auto" }]);' + + 'App.handleEvent({ type: "queued_requeue", sid: "sess-1", data: { request_ids: ["req-queue"] } });', + ctx + ); + await tick(); // handleEvent 内部 await window.emrg.sendMessage(mock 立即 resolve) + assert.strictEqual(sent.length, 1, "queued_requeue re-sends"); + assert.strictEqual(sent[0].sessionId, "sess-1"); + assert.strictEqual(sent[0].text, "hi"); + assert.strictEqual(sent[0].requestId, "req-queue", "same requestId reused"); + assert.strictEqual(ctx.App.state.queuedSends.has("sess-1"), false, "queue cleared after requeue"); + assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").busy, true, "requeue marks session busy"); + const texts = [...els["chat-view"].children].map((c) => c.textContent).join("|"); + assert.ok(texts.includes("重新发送 1"), "requeue note shown"); +}); + +test("P2 queue: queued_cancelled clears queue + note", async () => { + const { ctx, els } = makeSandbox({}); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.state.queuedSends.set("sess-1", [{ requestId: "req-a", text: "hi", mode: "auto" }]);' + + 'App.handleEvent({ type: "queued_cancelled", sid: "sess-1", data: {} });', + ctx + ); + assert.strictEqual(ctx.App.state.queuedSends.has("sess-1"), false, "queue cleared"); + const texts = [...els["chat-view"].children].map((c) => c.textContent).join("|"); + assert.ok(texts.includes("排队消息已取消"), "cancelled note shown"); +}); + // ── P3 slice 2(rant 15:07:19):每会话 .session-view 容器 + display 切换 ── test("P3 s2: activateSessionView 建独立容器并切换 display(仅激活可见)", async () => { diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 043dbd9..836f4ef 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -403,6 +403,7 @@ When reading rants, follow these rules: > - Windows release zip asset (#649 宿主 rant 2026-08-10T20:10:41:Windows 发布资产除 exe 安装包外再提供 zip 压缩版——`build-release.yml` Windows job 在 Make installer 后新增 "Zip installer (Windows only)" 步骤(find `EMRG-*-windows-x64.exe` → PowerShell `Compress-Archive -Force` → bash `test -s` 校验非空)+ upload-artifact glob 加 `dist/artifacts/*.zip`(release job `files: artifacts/*` 自动带上);actionlint 1.7.12 全工作流验证通过;**tag-push 构建教训**:tag 触发的工作流读 tag ref 上的 workflow 文件——v0.2.21 tag 早于 #649 合并 → 该版本 release 无 zip(6 资产符合原始 release rant 验收),zip 自 v0.2.22 起随 CI 产出;验证=CI Windows job 全绿 + actionlint clean;workflow-only 改动,测试计数不变) ✅ > - GUI packaged local-module whitelist guard (#651 宿主 rant 2026-08-10T20:37:08:v0.2.21 Windows 打包版启动崩溃 `Cannot find module './conn-manager'`——main.js:15/16 require 的 **conn-manager.js 和 gui-state.js 都不在 electron-builder `files` 白名单**(多会话 P2/P4 新增模块忘更新,与 #612 vendor 同根因类);修复=①package.json build.files 加 `conn-manager.js` + `gui-state.js`;②test/build-config.test.js 新增**通用守卫**:扫描 main.js/preload.js 每个本地 `require("./x")` 断言被 files 白名单覆盖(支持 `x.js` 与 `dir/**` 形态;正反两态验证——删白名单项即红并命名缺失模块)——**#612/#651 整个"新增模块忘加白名单"类闭合**;GUI 178→179(build-config 3→4)三文档同步;验证=GUI 179/179 ✓ pytest 681 ✓ doc guard 3/3 ✓;打包版自 v0.2.22 起修复) ✅ > - Mid-turn queue injection P1 (#655 宿主 rant 2026-08-10T21:55:37:tool loop 进行中发新消息 → **daemon 排队注入**(对齐 codex steer_input)——busy 时新消息入 per-session FIFO 队列(`_session_pending`),当前 round(LLM 请求+全部工具执行)结束后、下一轮 LLM 请求前注入;**不打断工具执行、不丢消息**;busy 分支不再回 "session busy" error → `task_queued`(含 position);注入走 `_inject_pending_messages`(原子 pop 防并发 append 丢失 + `append_message` 持久化保 auto-compact + `steer_committed` 广播);round 预算=注入轮不消耗(stop/Case3/循环用尽均先重查 pending 再 return);wrapper finally:正常结束 → `queued_requeue`(request_ids,客户端自动重发)、cancel/异常/clear/delete → `queued_cancelled`(cancel_event 判定防误重发);Ask mode 注入轮空工具集;clear/delete_session 清队列;协议帧 4 个 daemon→client 广播(P2 GUI / P3 TUI 客户端侧后续 rant);pytest 681→687(+7 e2e)、GUI 179 不变;合并 535efd2) ✅ +> - GUI queue-injection client side (#655 P2 follow-up,自发现:GUI sendMessage 在 busy 时静默 return(app.js:136)——daemon 排队注入对 GUI 用户不可达,4 个广播帧 handleEvent 无分支;修复=①sendMessage 移除 busy 早退(wasBusy 捕获),busy 时记录 `state.queuedSends`(sid→[{requestId,text,mode}]);②handleEvent 新增 4 case——task_queued 显示 '⏳ 已排队(位置 N)'(sid 作用域)、steer_committed 从队列移除、queued_requeue 以原 requestId 经 window.emrg.sendMessage 静默重发(**不重加用户行**,后台会话只操作该 sid 条目 + setComposerDisabled 仅激活会话)、queued_cancelled 清队列+提示;③disconnected 清该 sid 队列(daemon 断连丢队列);④i18n zh/en 3 键(app.queued/queuedResent/queuedCancelled);+5 GUI 测试 212→217(busy 发送记录/位置提示/steer 移除/requeue 同 id 重发+清队列/取消清队列),pytest 705 不变) ✅ > - GUI multi-session deviations B1-B3 (#656 宿主 rant 2026-08-10T21:59:11:v0.2.22 实测三偏差——①B1 侧边栏"+ 新对话"按钮 + ⌘N 直接新建不弹项目列表(无法选项目/新建项目)→ 改绑 `Dialogs.showNewSessionDialog()`(既有 P5 slice 2 弹窗:项目活跃排序点选新建 / 新建项目按钮);②B2 侧边栏无"打开会话"入口(仅 /open 命令可达两步弹窗)→ 新增 `#open-chat-btn` ghost 按钮 → `Dialogs.showOpenSessionDialog()`(项目→会话)+ i18n zh/en `sidebar.openChat`/`openChatTitle` + CSS `.open-chat-btn`;③B3 切换会话草稿丢失(容器 per-session 但输入框全局单例)→ `state.drafts: Map` + saveDraft/restoreDraft:switchSession 离开前存旧 sid、切后恢复新 sid;newSession 存旧+新会话空草稿;sendMessage 发送成功 delete 该 sid 草稿;restore 重置 auto-resize 高度;回归安全(打开弹窗内"+ 新建会话…"入口与无 projectPath 兜底路径 deleteSession/closeOpenSession→newSession 不动);GUI 179→183(+4 renderer.smoke:B1 按钮弹窗不直建 / B2 入口弹窗 / B3 草稿保存恢复 / B3 发送清除+新建空草稿)三文档同步;合并 9e907aa) ✅ > - GUI proactive update prompt (#660 宿主 rant 2026-08-11T09:18:16:GUI 启动主动检查新版本 + 设置页手动检查按钮——boot() 成功路径调 refreshUpdateCheck(幂等 prompted_version 只提示一次,对齐 TUI 启动横幅;daemon 未就绪静默失败);设置页 #about-update 行旁新增"检查更新"按钮 → update_check 消息加 `force:true` → daemon 立即 `run_update_check_once()` 刷新缓存再返回(不再只读 TTL 缓存);i18n zh/en `settings.checkUpdate`/`settings.checkingUpdate`;测试 GUI 187→188;合并 e5edaaf) ✅ > - GUI workspace panel P1 (#661 宿主 rant 2026-08-11T12:20:35 阶段 1 数据层:daemon `list_files`→`files_list`(目录在前按名排序对齐 ReadTool/单目录 5000 条上限 + `truncated`/绝对路径校验相对拒绝/符号链接不展开归 file/错误返回 error 不崩溃)+ `read_file`→`file_content`(UTF-8 文本/1MB 上限 error 提示用系统工具/UnicodeDecodeError→`binary:true` content 空不走 base64/start_line+line_limit 分页显式 limit 上限 2000);GUI RESPONSE_TYPES 加 `list_files:"files_list"`/`read_file:"file_content"` + `_classify` list_result 白名单加 files_list(防 pending 超时迟到帧)+ preload `listFiles`/`readFile` + main.js `emrg:listFiles`/`emrg:readFile` IPC(requireConn 10s 当前会话连接天然认证);+6 pytest e2e TestWSWorkspacePanel(混排排序/相对拒绝/符号链接不展开/5000 截断/文本+分页/二进制+1MB)+1 build-config(preload API 存在性);pytest 688→694、GUI 187→188;合并 a83638b) ✅ From b237399e3fbe00a1ddd551692be517900c6065ce Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 11 Aug 2026 21:11:01 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20GUI=20requeue=20re-tracking=20?= =?UTF-8?q?=E2=80=94=20track=20re-sends=20the=20daemon=20will=20queue=20(r?= =?UTF-8?q?eview=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same issue as #695 review ❌: wasBusy was captured before the re-send loop. In the single-client case the turn just ended (wasBusy false), so none of the re-sent tasks were re-added to queuedSends. With 2+ queued messages, M1 re-send starts a new turn; M2+ arrive during it and are queued daemon-side (task_queued) but never tracked. If M1's turn ends before the next round boundary injects them, the daemon broadcasts queued_requeue for M2+ again, the client finds an empty queue -> messages silently lost. Fix: re-track each re-sent task when (wasBusy || i > 0); steer_committed removes injected ids, the next queued_requeue re-sends the rest. +1 GUI test (2-msg idle-turn regression), GUI 217 -> 218; Agent.md synced. --- Agent.md | 4 ++-- emrg/gui/renderer/js/app.js | 16 ++++++++++++-- emrg/gui/test/renderer.smoke.test.js | 31 +++++++++++++++++++++++++--- emrg/server/evolution_prompt.md | 2 +- 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/Agent.md b/Agent.md index 49e91f4..ac7f78a 100644 --- a/Agent.md +++ b/Agent.md @@ -85,7 +85,7 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request - 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` (217: 43 daemon_client + 19 conn-manager + 22 app-commands + 96 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (218: 43 daemon_client + 19 conn-manager + 22 app-commands + 97 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state); 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) @@ -113,7 +113,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` Python: `uv run pytest tests/ -v` (703) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (217: 43 daemon_client + 19 conn-manager + 22 app-commands + 96 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +GUI: `cd emrg/gui && npm test` (218: 43 daemon_client + 19 conn-manager + 22 app-commands + 97 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — 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/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 48ebbd1..fbe240e 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -1228,9 +1228,16 @@ const App = (() => { if (q && q.length) { const ids = new Set(data.request_ids || []); const toResend = q.filter((e) => ids.has(e.requestId)); + const remaining = q.filter((e) => !ids.has(e.requestId)); if (toResend.length) { - state.queuedSends.delete(sid); - for (const item of toResend) { + // P2 审查 ❌ 同 #695:was_busy 在循环前捕获,单客户端时首条重发 + // 开启新回合,M2+ 到达时 daemon busy 被再排队但客户端未跟踪 → 下个 + // queued_requeue 找不到 → 静默丢失。修复=每条重发若 (wasBusy || i>0) + // 重新跟踪——steer_committed 移除已注入的,下个 queued_requeue 重发 + // 其余,收敛。 + const wasBusy = sidState(sid).busy; + for (let i = 0; i < toResend.length; i++) { + const item = toResend[i]; const sst = sidState(sid); sst.busy = true; sst.ownStreamRequestId = item.requestId; @@ -1243,7 +1250,12 @@ const App = (() => { sst.ownStreamRequestId = null; if (!sid || sid === state.sessionId) setComposerDisabled(false); } + if (wasBusy || i > 0) { + remaining.push({ requestId: item.requestId, text: item.text, mode: item.mode }); + } } + if (remaining.length) state.queuedSends.set(sid, remaining); + else state.queuedSends.delete(sid); Chat.addSystemMessage(_t("app.queuedResent", { n: toResend.length }), sid); } } diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 54d95c2..9a09586 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -1472,7 +1472,7 @@ test("P2 queue: steer_committed removes that request from queue", async () => { assert.strictEqual(q[0].requestId, "req-b"); }); -test("P2 queue: queued_requeue re-sends with same requestId + clears queue", async () => { +test("P2 queue: queued_requeue re-sends with same requestId + re-tracks (review ❌ fix)", async () => { const sent = []; const { ctx, els } = makeSandbox({ sendMessage: async (p) => { sent.push(p); return { ok: true, requestId: p.requestId }; }, @@ -1480,7 +1480,7 @@ test("P2 queue: queued_requeue re-sends with same requestId + clears queue", asy await tick(); await vm.runInContext( 'App.state.sessionId = "sess-1";' + - 'App.state.busy = true;' + + 'App.state.busy = true;' + // wasBusy → re-send is re-tracked 'App.state.queuedSends.set("sess-1", [{ requestId: "req-queue", text: "hi", mode: "auto" }]);' + 'App.handleEvent({ type: "queued_requeue", sid: "sess-1", data: { request_ids: ["req-queue"] } });', ctx @@ -1490,12 +1490,37 @@ test("P2 queue: queued_requeue re-sends with same requestId + clears queue", asy assert.strictEqual(sent[0].sessionId, "sess-1"); assert.strictEqual(sent[0].text, "hi"); assert.strictEqual(sent[0].requestId, "req-queue", "same requestId reused"); - assert.strictEqual(ctx.App.state.queuedSends.has("sess-1"), false, "queue cleared after requeue"); + // 审查 ❌ 修复:busy 时重发被再排队 → 重新跟踪(steer_committed 才移除) + assert.strictEqual(ctx.App.state.queuedSends.has("sess-1"), true, "re-tracked after requeue (daemon may re-queue)"); + assert.strictEqual(ctx.App.state.queuedSends.get("sess-1")[0].requestId, "req-queue", "same requestId tracked"); assert.strictEqual(ctx.App.state.sessionsBySid.get("sess-1").busy, true, "requeue marks session busy"); const texts = [...els["chat-view"].children].map((c) => c.textContent).join("|"); assert.ok(texts.includes("重新发送 1"), "requeue note shown"); }); +test("P2 queue: requeue with 2 msgs (idle turn end) re-tracks 2nd+ (review ❌ regression)", async () => { + const sent = []; + const { ctx } = makeSandbox({ + sendMessage: async (p) => { sent.push(p); return { ok: true, requestId: p.requestId }; }, + }); + await tick(); + await vm.runInContext( + 'App.state.sessionId = "sess-1";' + + 'App.state.busy = false;' + // 单客户端:回合刚结束 → wasBusy=false + 'App.state.queuedSends.set("sess-1", [' + + ' { requestId: "req-m1", text: "m1", mode: "auto" },' + + ' { requestId: "req-m2", text: "m2", mode: "auto" }]);' + + 'App.handleEvent({ type: "queued_requeue", sid: "sess-1", data: { request_ids: ["req-m1", "req-m2"] } });', + ctx + ); + await tick(); + assert.strictEqual(sent.length, 2, "both queued messages re-sent"); + // M1 开启新回合(不再跟踪);M2 到达时 daemon busy 被再排队 → i>0 重新跟踪 + const q = ctx.App.state.queuedSends.get("sess-1"); + assert.ok(q && q.length === 1, "2nd message re-tracked"); + assert.strictEqual(q[0].requestId, "req-m2", "M2 tracked for next queued_requeue"); +}); + test("P2 queue: queued_cancelled clears queue + note", async () => { const { ctx, els } = makeSandbox({}); await tick(); diff --git a/emrg/server/evolution_prompt.md b/emrg/server/evolution_prompt.md index 836f4ef..fce2e5b 100644 --- a/emrg/server/evolution_prompt.md +++ b/emrg/server/evolution_prompt.md @@ -403,7 +403,7 @@ When reading rants, follow these rules: > - Windows release zip asset (#649 宿主 rant 2026-08-10T20:10:41:Windows 发布资产除 exe 安装包外再提供 zip 压缩版——`build-release.yml` Windows job 在 Make installer 后新增 "Zip installer (Windows only)" 步骤(find `EMRG-*-windows-x64.exe` → PowerShell `Compress-Archive -Force` → bash `test -s` 校验非空)+ upload-artifact glob 加 `dist/artifacts/*.zip`(release job `files: artifacts/*` 自动带上);actionlint 1.7.12 全工作流验证通过;**tag-push 构建教训**:tag 触发的工作流读 tag ref 上的 workflow 文件——v0.2.21 tag 早于 #649 合并 → 该版本 release 无 zip(6 资产符合原始 release rant 验收),zip 自 v0.2.22 起随 CI 产出;验证=CI Windows job 全绿 + actionlint clean;workflow-only 改动,测试计数不变) ✅ > - GUI packaged local-module whitelist guard (#651 宿主 rant 2026-08-10T20:37:08:v0.2.21 Windows 打包版启动崩溃 `Cannot find module './conn-manager'`——main.js:15/16 require 的 **conn-manager.js 和 gui-state.js 都不在 electron-builder `files` 白名单**(多会话 P2/P4 新增模块忘更新,与 #612 vendor 同根因类);修复=①package.json build.files 加 `conn-manager.js` + `gui-state.js`;②test/build-config.test.js 新增**通用守卫**:扫描 main.js/preload.js 每个本地 `require("./x")` 断言被 files 白名单覆盖(支持 `x.js` 与 `dir/**` 形态;正反两态验证——删白名单项即红并命名缺失模块)——**#612/#651 整个"新增模块忘加白名单"类闭合**;GUI 178→179(build-config 3→4)三文档同步;验证=GUI 179/179 ✓ pytest 681 ✓ doc guard 3/3 ✓;打包版自 v0.2.22 起修复) ✅ > - Mid-turn queue injection P1 (#655 宿主 rant 2026-08-10T21:55:37:tool loop 进行中发新消息 → **daemon 排队注入**(对齐 codex steer_input)——busy 时新消息入 per-session FIFO 队列(`_session_pending`),当前 round(LLM 请求+全部工具执行)结束后、下一轮 LLM 请求前注入;**不打断工具执行、不丢消息**;busy 分支不再回 "session busy" error → `task_queued`(含 position);注入走 `_inject_pending_messages`(原子 pop 防并发 append 丢失 + `append_message` 持久化保 auto-compact + `steer_committed` 广播);round 预算=注入轮不消耗(stop/Case3/循环用尽均先重查 pending 再 return);wrapper finally:正常结束 → `queued_requeue`(request_ids,客户端自动重发)、cancel/异常/clear/delete → `queued_cancelled`(cancel_event 判定防误重发);Ask mode 注入轮空工具集;clear/delete_session 清队列;协议帧 4 个 daemon→client 广播(P2 GUI / P3 TUI 客户端侧后续 rant);pytest 681→687(+7 e2e)、GUI 179 不变;合并 535efd2) ✅ -> - GUI queue-injection client side (#655 P2 follow-up,自发现:GUI sendMessage 在 busy 时静默 return(app.js:136)——daemon 排队注入对 GUI 用户不可达,4 个广播帧 handleEvent 无分支;修复=①sendMessage 移除 busy 早退(wasBusy 捕获),busy 时记录 `state.queuedSends`(sid→[{requestId,text,mode}]);②handleEvent 新增 4 case——task_queued 显示 '⏳ 已排队(位置 N)'(sid 作用域)、steer_committed 从队列移除、queued_requeue 以原 requestId 经 window.emrg.sendMessage 静默重发(**不重加用户行**,后台会话只操作该 sid 条目 + setComposerDisabled 仅激活会话)、queued_cancelled 清队列+提示;③disconnected 清该 sid 队列(daemon 断连丢队列);④i18n zh/en 3 键(app.queued/queuedResent/queuedCancelled);+5 GUI 测试 212→217(busy 发送记录/位置提示/steer 移除/requeue 同 id 重发+清队列/取消清队列),pytest 705 不变) ✅ +> - GUI queue-injection client side (#655 P2 follow-up,自发现:GUI sendMessage 在 busy 时静默 return(app.js:136)——daemon 排队注入对 GUI 用户不可达,4 个广播帧 handleEvent 无分支;修复=①sendMessage 移除 busy 早退(wasBusy 捕获),busy 时记录 `state.queuedSends`(sid→[{requestId,text,mode}]);②handleEvent 新增 4 case——task_queued 显示 '⏳ 已排队(位置 N)'(sid 作用域)、steer_committed 从队列移除、queued_requeue 以原 requestId 经 window.emrg.sendMessage 静默重发(**不重加用户行**,后台会话只操作该 sid 条目 + setComposerDisabled 仅激活会话)、queued_cancelled 清队列+提示;③disconnected 清该 sid 队列(daemon 断连丢队列);④i18n zh/en 3 键(app.queued/queuedResent/queuedCancelled);**review ❌ 同 #695**:was_busy 循环前捕获 → 单客户端回合刚结束 was_busy=false,M2+ 重发到达时 daemon busy 被再排队但未跟踪 → 下个 queued_requeue 找不到 → 静默丢失;修复=每条重发 `if (wasBusy || i > 0)` 重新跟踪(steer_committed 移除已注入,下个 queued_requeue 重发其余,收敛);+6 GUI 测试 212→218(busy 发送记录/位置提示/steer 移除/requeue 同 id 重发+重新跟踪/2 消息 idle 回合 i>0 跟踪回归/cancel 清队列),pytest 705 不变) ✅ > - GUI multi-session deviations B1-B3 (#656 宿主 rant 2026-08-10T21:59:11:v0.2.22 实测三偏差——①B1 侧边栏"+ 新对话"按钮 + ⌘N 直接新建不弹项目列表(无法选项目/新建项目)→ 改绑 `Dialogs.showNewSessionDialog()`(既有 P5 slice 2 弹窗:项目活跃排序点选新建 / 新建项目按钮);②B2 侧边栏无"打开会话"入口(仅 /open 命令可达两步弹窗)→ 新增 `#open-chat-btn` ghost 按钮 → `Dialogs.showOpenSessionDialog()`(项目→会话)+ i18n zh/en `sidebar.openChat`/`openChatTitle` + CSS `.open-chat-btn`;③B3 切换会话草稿丢失(容器 per-session 但输入框全局单例)→ `state.drafts: Map` + saveDraft/restoreDraft:switchSession 离开前存旧 sid、切后恢复新 sid;newSession 存旧+新会话空草稿;sendMessage 发送成功 delete 该 sid 草稿;restore 重置 auto-resize 高度;回归安全(打开弹窗内"+ 新建会话…"入口与无 projectPath 兜底路径 deleteSession/closeOpenSession→newSession 不动);GUI 179→183(+4 renderer.smoke:B1 按钮弹窗不直建 / B2 入口弹窗 / B3 草稿保存恢复 / B3 发送清除+新建空草稿)三文档同步;合并 9e907aa) ✅ > - GUI proactive update prompt (#660 宿主 rant 2026-08-11T09:18:16:GUI 启动主动检查新版本 + 设置页手动检查按钮——boot() 成功路径调 refreshUpdateCheck(幂等 prompted_version 只提示一次,对齐 TUI 启动横幅;daemon 未就绪静默失败);设置页 #about-update 行旁新增"检查更新"按钮 → update_check 消息加 `force:true` → daemon 立即 `run_update_check_once()` 刷新缓存再返回(不再只读 TTL 缓存);i18n zh/en `settings.checkUpdate`/`settings.checkingUpdate`;测试 GUI 187→188;合并 e5edaaf) ✅ > - GUI workspace panel P1 (#661 宿主 rant 2026-08-11T12:20:35 阶段 1 数据层:daemon `list_files`→`files_list`(目录在前按名排序对齐 ReadTool/单目录 5000 条上限 + `truncated`/绝对路径校验相对拒绝/符号链接不展开归 file/错误返回 error 不崩溃)+ `read_file`→`file_content`(UTF-8 文本/1MB 上限 error 提示用系统工具/UnicodeDecodeError→`binary:true` content 空不走 base64/start_line+line_limit 分页显式 limit 上限 2000);GUI RESPONSE_TYPES 加 `list_files:"files_list"`/`read_file:"file_content"` + `_classify` list_result 白名单加 files_list(防 pending 超时迟到帧)+ preload `listFiles`/`readFile` + main.js `emrg:listFiles`/`emrg:readFile` IPC(requireConn 10s 当前会话连接天然认证);+6 pytest e2e TestWSWorkspacePanel(混排排序/相对拒绝/符号链接不展开/5000 截断/文本+分页/二进制+1MB)+1 build-config(preload API 存在性);pytest 688→694、GUI 187→188;合并 a83638b) ✅