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.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 路径不受影响)
Expand Down
12 changes: 10 additions & 2 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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, atin ordered
for (p, at), repo in zip(ordered, repos)
]
except (yaml.YAMLError, OSError):
logger.exception("Failed to read projects.yml")
Expand Down
34 changes: 29 additions & 5 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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()))
Expand DownExpand Up@@ -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]:
Expand Down
32 changes: 32 additions & 0 deletions tests/test_scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading