From 3fb4f940a13e9ae3d8cf09d572a0373f1adac2da Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 14 Aug 2026 21:54:10 +0800 Subject: [PATCH] =?UTF-8?q?emrg:=20fix=20GUI=20task=20save=20no-response?= =?UTF-8?q?=20=E2=80=94=20wire=20type=20field=20collision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rant 2026-08-14T21:48:00: GUI task management save appeared to do nothing (form stays open, no list refresh). Root cause: daemon_client.js sendCommand built frames as { type, ...params } — the task CRUD payload carries a "type" field (the task type, e.g. "evolution") which spread over the wire message type, so the daemon routed "evolution" as the message type and replied "unknown message type". The error was written to the chat area while the tasks panel is open (view exclusivity), so it looked like no response. Fixes: - daemon_client.js sendCommand: { ...params, type } — wire message type last, never overridden by payload fields (generic defense) - main.js taskCreate/taskUpdate: send the task type as "task_type" (never occupying the message-type field name) - daemon.py task_create reads task_type; task_update maps task_type to type for scheduler.task_update (internal name unchanged) - tests: +1 daemon_client frame-shape regression (payload type must not override wire type); +3 wire e2e tests (task_create reads task_type / missing task_type never falls back to wire type / task_update maps task_type to type); Agent.md counts 806->809, GUI 246->247 --- Agent.md | 6 +- emrg/gui/daemon_client.js | 4 +- emrg/gui/main.js | 6 +- emrg/gui/test/daemon_client.test.js | 12 +++ emrg/server/daemon.py | 6 +- tests/test_ws_e2e.py | 117 ++++++++++++++++++++++++++++ 6 files changed, 143 insertions(+), 8 deletions(-) diff --git a/Agent.md b/Agent.md index 9643c40..9aa4881 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` (246: 44 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `npm test` (247: 45 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` - **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711) - Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/.md` → fallback - Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`) @@ -118,8 +118,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` (806) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (246: 44 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` +Python: `uv run pytest tests/ -v` (809) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && npm test` (247: 45 daemon_client + 19 conn-manager + 22 app-commands + 121 renderer smoke + 16 i18n + 7 integration + 3 commands + 5 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 路径不受影响) diff --git a/emrg/gui/daemon_client.js b/emrg/gui/daemon_client.js index 78d827f..3ccfc27 100644 --- a/emrg/gui/daemon_client.js +++ b/emrg/gui/daemon_client.js @@ -523,7 +523,9 @@ class DaemonClient { } sendCommand(type, params = {}) { - this.ws.send(JSON.stringify({ type, ...params })); + // Wire message type last: a payload field named "type" (e.g. the task type in + // task CRUD) must never override the wire message type (rant 2026-08-14T21:48:00). + this.ws.send(JSON.stringify({ ...params, type })); } // G93/G103:命令-响应配对(pending FIFO,按响应帧 type 配对)。 diff --git a/emrg/gui/main.js b/emrg/gui/main.js index d64e662..527a6e3 100644 --- a/emrg/gui/main.js +++ b/emrg/gui/main.js @@ -626,7 +626,7 @@ vision = false if (typeof type !== "string" || !type.trim()) throw new Error("invalid task type"); if (typeof project !== "string" || !project.trim()) throw new Error("invalid project"); const frame = await requireConn().sendCommandAndWait("task_create", { - name: name.trim(), type: type.trim(), project: project.trim(), + name: name.trim(), task_type: type.trim(), project: project.trim(), interval, enabled, repo, description, }, 8000); if (!frame.ok && frame.error) throw new Error(frame.error); @@ -634,8 +634,10 @@ vision = false }); ipcMain.handle("emrg:taskUpdate", async (_e, payload) => { - const { name, ...fields } = payload || {}; + const { name, type, ...fields } = payload || {}; if (typeof name !== "string" || !name.trim()) throw new Error("invalid task name"); + if (type !== undefined && typeof type !== "string") throw new Error("invalid task type"); + if (type !== undefined && type.trim()) fields.task_type = type.trim(); const frame = await requireConn().sendCommandAndWait("task_update", { name: name.trim(), ...fields }, 8000); if (!frame.ok && frame.error) throw new Error(frame.error); return frame; diff --git a/emrg/gui/test/daemon_client.test.js b/emrg/gui/test/daemon_client.test.js index 15dc371..c65b873 100644 --- a/emrg/gui/test/daemon_client.test.js +++ b/emrg/gui/test/daemon_client.test.js @@ -576,6 +576,18 @@ test("sendCommand payload + cancel 无多余字段(G24)", async () => { assert.deepStrictEqual(f2, { type: "set_model", model: "gpt-4o" }); }); +test("sendCommand 帧形状:payload 带 type 字段不覆盖消息类型(rant 2026-08-14T21:48)", async () => { + // task CRUD payload 含任务类型字段 type(如 "evolution")——消息类型必须保留, + // 否则 daemon 路由失败返回 unknown message type,GUI 保存无响应。 + const client = new DaemonClient({ projectDir: tmpHome }); + await connectClient(client); + client.sendCommand("task_create", { type: "evolution", name: "t1", project: "p1" }); + const frame = JSON.parse(currentMockWs.sent.at(-1)); + assert.strictEqual(frame.type, "task_create", "wire 消息类型必须保留,不被 payload 的 type 覆盖"); + assert.strictEqual(frame.name, "t1"); + assert.strictEqual(frame.type, "task_create"); +}); + test("帧分类(G21+G58):各帧事件分发正确", async () => { const client = new DaemonClient({ projectDir: tmpHome }); await connectClient(client); diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 5220de2..8267770 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -1267,7 +1267,7 @@ async def _process_message( return ok, res = self._scheduler.task_create( name=msg.get("name", "").strip(), - task_type=msg.get("type", "").strip(), + task_type=msg.get("task_type", "").strip(), project=msg.get("project", "").strip(), interval=msg.get("interval"), enabled=msg.get("enabled", True), @@ -1284,7 +1284,9 @@ async def _process_message( if not self._scheduler: await self._send(ws, {"type": "task_result", "error": "scheduler not running"}) return - fields = {k: msg[k] for k in ("type", "project", "interval", "enabled", "repo", "description") if k in msg} + fields = {k: msg[k] for k in ("task_type", "project", "interval", "enabled", "repo", "description") if k in msg} + if "task_type" in fields: + fields["type"] = fields.pop("task_type") ok, res = self._scheduler.task_update(msg.get("name", "").strip(), **fields) if not ok: await self._send(ws, {"type": "task_result", "error": res}) diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 4f357ba..3e7f857 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -1262,3 +1262,120 @@ async def _test(): finally: await cleanup() asyncio.run(_test()) + + +class TestWSTaskWire: + """GUI task CRUD wire frames — task type field contract (rant 2026-08-14T21:48:00). + + The GUI sends the task type under `task_type` (never `type`) so it cannot + collide with the wire message type. The daemon must read `task_type` — + reading `msg["type"]` would swallow the wire message type after the + sendCommand fix (`{ ...params, type }`), e.g. writing "task_update" as the + task type. + """ + + @staticmethod + async def _cmd(ws, payload): + await ws.send(json.dumps(payload)) + return json.loads(await asyncio.wait_for(ws.recv(), timeout=5)) + + @staticmethod + def _mock_scheduler(create_side_effect=None, update_side_effect=None): + """Scheduler mock with async methods the daemon awaits (apply_tasks/wait_all).""" + from unittest.mock import AsyncMock, Mock + sched = Mock() + sched.task_create = Mock(side_effect=create_side_effect or (lambda **kw: (True, {"name": "x"}))) + sched.task_update = Mock(side_effect=update_side_effect or (lambda name, **fields: (True, {"name": name}))) + sched.apply_tasks = AsyncMock(return_value="") + sched.wait_all = AsyncMock() + sched.stop_all = Mock() + sched._load_tasks = Mock(return_value=[]) + return sched + + def test_task_create_reads_task_type(self): + """task_create with task_type → scheduler receives task_type, not the wire type.""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + server, _, cleanup = await _boot_server(cwd) + try: + calls = {} + + def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None): + calls.update(name=name, task_type=task_type, project=project) + return True, {"name": name, "type": task_type} + + server._scheduler = self._mock_scheduler(create_side_effect=fake_create) + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "task_create", "name": "daily", + "task_type": "evolution", "project": "emrg", + }) + assert resp["type"] == "task_result" + assert resp.get("ok") is True + assert calls["task_type"] == "evolution", "daemon must read task_type" + assert calls["project"] == "emrg" + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_task_create_missing_task_type_not_read_from_wire_type(self): + """Missing task_type → scheduler receives '' (never the wire type 'task_create').""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + server, _, cleanup = await _boot_server(cwd) + try: + calls = {} + + def fake_create(name, task_type, project, interval=None, enabled=True, repo=None, description=None): + calls.update(name=name, task_type=task_type, project=project) + return True, {"name": name, "type": task_type} + + server._scheduler = self._mock_scheduler(create_side_effect=fake_create) + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "task_create", "name": "daily", "project": "emrg", + }) + assert resp["type"] == "task_result" + assert calls["task_type"] == "", "must not fall back to wire type 'task_create'" + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test()) + + def test_task_update_maps_task_type_to_type(self): + """task_update with task_type → scheduler receives fields['type'] (its internal name).""" + async def _test(): + with tempfile.TemporaryDirectory() as tmp: + cwd = Path(tmp) + server, _, cleanup = await _boot_server(cwd) + try: + calls = {} + + def fake_update(name, **fields): + calls.update(name=name, fields=fields) + return True, {"name": name, "type": fields.get("type", "evolution")} + + server._scheduler = self._mock_scheduler(update_side_effect=fake_update) + ws = await connect_to_server() + try: + resp = await self._cmd(ws, { + "type": "task_update", "name": "daily", + "task_type": "open-source", "interval": 300, + }) + assert resp["type"] == "task_result" + assert resp.get("ok") is True + assert calls["fields"]["type"] == "open-source", "task_type mapped to scheduler type field" + assert "task_type" not in calls["fields"] + assert calls["fields"]["interval"] == 300 + finally: + await ws.close() + finally: + await cleanup() + asyncio.run(_test())