diff --git a/Agent.md b/Agent.md index d9ccd331..07f86773 100644 --- a/Agent.md +++ b/Agent.md @@ -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.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (961) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (963) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (260: 45 daemon_client + 19 conn-manager + 22 app-commands + 131 renderer smoke + 16 i18n + 7 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 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 298169cd..c938e89b 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -3000,12 +3000,20 @@ async def _latest_session_at(entry: dict) -> str: ats = await asyncio.gather(*(_latest_session_at(e) for e in entries)) ordered = sorted(zip(entries, ats), key=lambda t: t[1], reverse=True) + # rant 2026-08-19T01:05:47 — _detect_git_remote runs a sync + # git subprocess per project; offload to worker threads so a + # slow git probe never freezes the event loop (websocket + # clients would otherwise time out during list_projects). + repos = await asyncio.gather(*( + asyncio.to_thread(_detect_git_remote, p.get("path", "")) + for p, _ in ordered + )) projects = [ {"name": p.get("name", ""), - "repo": _detect_git_remote(p.get("path", "")), + "repo": repo, "path": p.get("path", ""), "latest_session_at": at} - for p, at in ordered + for (p, at), repo in zip(ordered, repos) ] except (yaml.YAMLError, OSError): logger.exception("Failed to read projects.yml") diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index a903d6ce..ee97a269 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -1270,16 +1270,40 @@ def _tasks_file(self) -> Path: def _tasks_file(self, value: Path | None) -> None: self.__tasks_file = value - def _start_handler_for(self, cfg: dict) -> TaskHandler: - """Create + start a handler for a task cfg; returns the handler.""" + def _build_handler(self, cfg: dict) -> TaskHandler: + """Construct a TaskHandler for a task cfg (pure sync construction). + + May block briefly (git remote detection in ``TaskHandler.__init__`` + + template lookup) — callers in the event loop must offload via + ``asyncio.to_thread`` (rant 2026-08-19T01:05:47: no blocking calls + in the loop). + """ template_path = _resolve_task_template(cfg["type"]) - handler = TaskHandler( + return TaskHandler( name=cfg["name"], config=cfg.get("config", {}), interval=cfg.get("interval", DEFAULT_INTERVAL), identity=self.identity, template_path=template_path, ) + + def _start_handler_for(self, cfg: dict) -> TaskHandler: + """Create + start a handler for a task cfg; returns the handler. + + Boot path (no websocket clients connected yet) — sync construction + is acceptable here. + """ + handler = self._build_handler(cfg) + self._handlers.append(handler) + self._handler_cfgs[handler.name] = cfg + self._coros.append(asyncio.create_task(handler.run())) + return handler + + async def _start_handler_async(self, cfg: dict) -> TaskHandler: + """Hot-reload path (apply_tasks, on the event loop while serving): + offload the sync handler construction to a worker thread so a slow + git probe never freezes the loop (rant 2026-08-19T01:05:47).""" + handler = await asyncio.to_thread(self._build_handler, cfg) self._handlers.append(handler) self._handler_cfgs[handler.name] = cfg self._coros.append(asyncio.create_task(handler.run())) @@ -1666,13 +1690,13 @@ async def apply_tasks(self, tasks: list[dict]) -> dict: for name, cfg in enabled.items(): if name not in current: added.append(name) - self._start_handler_for(cfg) + await self._start_handler_async(cfg) else: old = self._handler_cfgs.get(name) if old is not None and _task_cfg_signature(old) != _task_cfg_signature(cfg): updated.append(name) self._stop_handler(current[name]) - self._start_handler_for(cfg) + await self._start_handler_async(cfg) return {"added": added, "removed": removed, "updated": updated} def list_templates(self) -> list[dict]: diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 5fe62ca6..a54045c3 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -2193,6 +2193,38 @@ async def _run(): mod.config_dir = orig +def test_hot_reload_offloads_handler_construction_to_thread(): + """rant 2026-08-19T01:05:47 — apply_tasks (hot reload, on the event loop + while serving websockets) must not run TaskHandler's sync git probe on + the loop. Construction goes through _start_handler_async → + asyncio.to_thread(_build_handler); the boot path keeps the sync + _start_handler_for (no clients connected yet).""" + import inspect + + from emrg.server import scheduler as mod + + sched_src = inspect.getsource(mod.TaskScheduler) + # apply_tasks awaits the async start path + assert "await self._start_handler_async(cfg)" in sched_src + # async start path offloads the sync construction + assert "asyncio.to_thread(self._build_handler, cfg)" in sched_src + # boot path unchanged (sync, pre-serve) + assert "handler = self._build_handler(cfg)" in sched_src + assert "def _start_handler_for(self, cfg: dict) -> TaskHandler:" in sched_src + + +def test_daemon_projects_list_offloads_git_probe_to_thread(): + """rant 2026-08-19T01:05:47 — the daemon's projects_list handler probes + each project's git remote with a sync subprocess; it must run in worker + threads (asyncio.to_thread) so a slow git probe never freezes the loop.""" + from pathlib import Path as _Path + + src = _Path(__file__).resolve().parent.parent / "emrg" / "server" / "daemon.py" + content = src.read_text(encoding="utf-8") + assert "asyncio.to_thread(_detect_git_remote, p.get(\"path\", \"\"))" in content + assert "repos = await asyncio.gather(*(" in content + + def test_template_crud_and_guards(tmp_path): """Custom templates: create/list/update/delete; builtin read-only; delete-refused guard.""" from emrg.server import scheduler as mod