From 601bd14571876c8fa4b547b70e16484b0ce9d14f Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 19 Aug 2026 21:04:33 +0800 Subject: [PATCH] emrg: persist task execution records to JSONL + fix task-row flex-wrap (rants 2026-08-19T20:49:52 + 20:50:36) --- Agent.md | 4 +- emrg/gui/renderer/css/components.css | 1 + emrg/gui/test/renderer.smoke.test.js | 7 ++ emrg/server/scheduler.py | 106 +++++++++++++++++++- tests/test_scheduler.py | 140 +++++++++++++++++++++++++++ 5 files changed, 253 insertions(+), 5 deletions(-) diff --git a/Agent.md b/Agent.md index 5338ea89..e4ea35d3 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` (259: 45 daemon_client + 19 conn-manager + 22 app-commands + 130 renderer smoke + 16 i18n + 7 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py` + - Unit tests `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); 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,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` (983) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (987) — 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/gui/renderer/css/components.css b/emrg/gui/renderer/css/components.css index e37ed069..c6c161ad 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -822,6 +822,7 @@ dialog::backdrop { } .task-row { display: flex; + flex-wrap: wrap; align-items: center; gap: 8px; padding: 6px 8px; diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 9948a358..557fc652 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -2706,6 +2706,13 @@ test("rant 21:32:32:任务卡点击展开最近运行子表(时间/干了什 // 无 recent_runs → 占位文案 const emptyTxt = vm.runInContext(`document.getElementById("task-list").children[1].querySelector(".task-run-empty").textContent`, ctx); assert.ok(emptyTxt.includes("暂无运行记录"), `无 recent_runs 应显示占位,实际: ${emptyTxt}`); + // rant 2026-08-19T20:49:52:.task-row 必须 flex-wrap —— 否则子表 + // (flex-basis:100%) 被压到同一行右边,宿主实测"跑到右边了" + const taskCss = fs.readFileSync(path.join(__dirname, "..", "renderer", "css", "components.css"), "utf8"); + const rowRule = taskCss.slice(taskCss.indexOf(".task-row"), taskCss.indexOf(".task-name")); + assert.ok(rowRule.includes("flex-wrap: wrap"), "task-row 应含 flex-wrap: wrap(子表换行到任务卡下方)"); + const detailRule = taskCss.slice(taskCss.indexOf(".task-run-detail"), taskCss.indexOf(".task-run-detail.hidden")); + assert.ok(detailRule.includes("flex-basis: 100%"), "task-run-detail 应保持 flex-basis:100%(配合 flex-wrap 换行)"); }); test("P3:新增任务表单 —— 间隔 <60 客户端拒绝;≥60 提交 taskCreate", async () => { diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 53ebdd43..7a242ac2 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -218,6 +218,16 @@ def __init__( self._logs_dir = config_dir() / "logs" self._logs_dir.mkdir(parents=True, exist_ok=True) self.evolutions: list[EvolutionLog] = [] + # Rant 2026-08-19T20:50:36 (host Plan B): execution records persist to + # disk (~/.emrg/logs/task-runs/.jsonl, append-only JSONL) so the + # GUI task recent-runs survive daemon restarts. In-memory + # self.evolutions stays the primary source (status() reads it); the + # JSONL is a durable copy restored on init (bounded to the last N). + self._task_runs_dir = self._logs_dir / "task-runs" + self._task_runs_file = self._task_runs_dir / f"{self.name}.jsonl" + restored = self._load_task_runs() + if restored: + self.evolutions = restored # ── Saturation — slow down, never stop (rant 2026-08-09T09:35:55) ── # Track consecutive empty cycles (rant 2026-08-17T11:39:19: the agent @@ -308,6 +318,92 @@ def _save_saturation_state(self) -> None: except Exception: pass + # ── Task-run persistence (rant 2026-08-19T20:50:36, host Plan B) ── + # Execution records persist to ~/.emrg/logs/task-runs/.jsonl + # (append-only JSONL, one line per cycle) so the GUI task recent-runs + # secondary list survives daemon restarts. self.evolutions (in-memory) + # stays the primary source for status(); the JSONL is a durable copy + # restored on init, bounded to the most recent _TASK_RUNS_MAX records. + _TASK_RUNS_MAX = 50 + + def _load_task_runs(self) -> list[EvolutionLog]: + """Restore the most recent execution records from the task JSONL. + + Fault-tolerant (rant 2026-08-19T20:50:36): a missing or corrupt file + yields an empty list (never raises); a single corrupt line is skipped + while the rest is kept. + """ + records: list[EvolutionLog] = [] + try: + if self._task_runs_file.exists(): + lines = self._task_runs_file.read_text(encoding="utf-8").splitlines() + for line in lines[-self._TASK_RUNS_MAX:]: + line = line.strip() + if not line: + continue + try: + data = json.loads(line) + except (ValueError, TypeError): + self._logger.warning( + "TaskHandler[%s]: skipping corrupt task-run line in %s", + self.name, self._task_runs_file, + ) + continue + records.append(EvolutionLog( + timestamp=str(data.get("timestamp") or ""), + summary=str(data.get("summary") or ""), + meaningful=data.get("meaningful"), + recommend_slowdown=bool(data.get("recommend_slowdown")), + reason=str(data.get("reason") or ""), + tool_count=int(data.get("tool_count") or 0), + )) + if records: + self._logger.info( + "TaskHandler[%s]: restored %d execution record(s) from %s", + self.name, len(records), self._task_runs_file, + ) + except Exception: + # unreadable file → start empty, never crash the handler + records = [] + self._logger.warning( + "TaskHandler[%s]: failed to read task-run file %s (starting empty)", + self.name, self._task_runs_file, + ) + return records + + def _append_task_run(self, log: EvolutionLog) -> None: + """Append one execution record to the task JSONL (bounded append). + + Writes a JSON line for the completed cycle, then trims the file to the + most recent _TASK_RUNS_MAX records. Fault-tolerant: a write failure + only logs a warning and never affects the running cycle. + """ + try: + self._task_runs_dir.mkdir(parents=True, exist_ok=True) + with open(self._task_runs_file, "a", encoding="utf-8") as f: + f.write(json.dumps({ + "timestamp": log.timestamp, + "summary": log.summary, + "meaningful": log.meaningful, + "recommend_slowdown": log.recommend_slowdown, + "reason": log.reason, + "tool_count": log.tool_count, + }, ensure_ascii=False) + "\n") + # Trim to the last _TASK_RUNS_MAX records (rewrite in place only + # when over the cap, mirroring the old 27-file rotation). + try: + lines = self._task_runs_file.read_text(encoding="utf-8").splitlines() + if len(lines) > self._TASK_RUNS_MAX: + with open(self._task_runs_file, "w", encoding="utf-8") as f: + f.write("\n".join(lines[-self._TASK_RUNS_MAX:]) + "\n") + except Exception: + pass # trimming is best-effort; the append already succeeded + except Exception as exc: + self._logger.warning( + "TaskHandler[%s]: failed to persist task-run record: %s", + self.name, exc, + ) + def _saturation_threshold(self) -> int: """Empty-cycle threshold before dropping to heartbeat cadence. @@ -797,10 +893,14 @@ async def _run_evolution_cycle(self) -> None: reason=reason, tool_count=tool_count, ) - # Rant 2026-08-19T14:18:40 — no disk archival: the evolution log lives - # in the in-memory list only (GUI recent-runs + evolution_count both - # read self.evolutions); evolution-*.json was never consumed. + # Rant 2026-08-19T14:18:40 — no more evolution-*.json single-file + # archival (unconsumed). Rant 2026-08-19T20:50:36 (host Plan B): + # execution records persist as append-only JSONL per task + # (~/.emrg/logs/task-runs/.jsonl) so the GUI recent-runs + # secondary list survives daemon restarts — a durable copy of the + # in-memory self.evolutions list, restored on init. self.evolutions.append(log) + self._append_task_run(log) def _build_evolution_prompt(self) -> str: """Build evolution prompt from a Jinja2 template. diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 1a0feb6e..48005dff 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -614,6 +614,146 @@ def test_evolution_handler_recent_runs_capped_at_five(): assert runs[-1]["timestamp"] == "2026-08-18T10:06:00" +# ── Task-run persistence (rant 2026-08-19T20:50:36, host Plan B) ───── +# Execution records append to ~/.emrg/logs/task-runs/.jsonl so the GUI +# recent-runs survive daemon restarts; restored on handler init. + + +def test_task_handler_task_runs_persist_across_restart(tmp_path): + """Appended run records are restored by a fresh handler (daemon restart). + + Plan B (rant 2026-08-19T20:50:36): cycle records persist to + /logs/task-runs/.jsonl; a new TaskHandler over the same + config_dir loads them back into self.evolutions. + """ + from emrg.protocol import EvolutionLog + from emrg.server import scheduler as mod + orig = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + h1 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + h1.evolutions.append(EvolutionLog( + timestamp="2026-08-19T20:10:00", + trigger="evolution-emrg-task-ts", + summary="fixed vibe-check 400, submitted PR #874", + meaningful=True, + recommend_slowdown=False, + reason="meaningful work done", + tool_count=7, + )) + h1._append_task_run(h1.evolutions[-1]) + # second record + h1.evolutions.append(EvolutionLog( + timestamp="2026-08-19T20:20:00", + trigger="evolution-emrg-task-ts2", + summary="", + meaningful=False, + recommend_slowdown=True, + reason="too many empty cycles", + tool_count=0, + )) + h1._append_task_run(h1.evolutions[-1]) + + # "daemon restart": a brand-new handler over the same config_dir + h2 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + assert len(h2.evolutions) == 2 + first = h2.evolutions[0] + assert first.timestamp == "2026-08-19T20:10:00" + assert first.summary == "fixed vibe-check 400, submitted PR #874" + assert first.meaningful is True + assert first.reason == "meaningful work done" + assert first.tool_count == 7 + second = h2.evolutions[1] + assert second.summary == "" + assert second.meaningful is False + assert second.recommend_slowdown is True + assert second.reason == "too many empty cycles" + # GUI secondary list shows the restored records + runs = h2.status()["recent_runs"] + assert [r["timestamp"] for r in runs] == [ + "2026-08-19T20:10:00", "2026-08-19T20:20:00", + ] + assert runs[1]["reason"] == "too many empty cycles" + assert runs[1]["recommend_slowdown"] is True + # JSONL file exists under logs/task-runs/.jsonl + f = tmp_path / "logs" / "task-runs" / "emrg-task.jsonl" + assert f.exists() + lines = f.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + finally: + mod.config_dir = orig + + +def test_task_handler_task_runs_capped_at_fifty(tmp_path): + """JSONL keeps only the most recent 50 records (bounded append).""" + from emrg.protocol import EvolutionLog + from emrg.server import scheduler as mod + orig = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + h1 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + for i in range(60): + h1.evolutions.append(EvolutionLog( + timestamp=f"2026-08-19T20:{i % 60:02d}:00", + summary=f"run-{i}", + tool_count=i, + )) + h1._append_task_run(h1.evolutions[-1]) + + f = tmp_path / "logs" / "task-runs" / "emrg-task.jsonl" + lines = f.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 50, "file trimmed to the last 50 records" + # fresh handler restores the most recent 50 + h2 = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + assert len(h2.evolutions) == 50 + assert h2.evolutions[-1].summary == "run-59" + assert h2.evolutions[0].summary == "run-10" + finally: + mod.config_dir = orig + + +def test_task_handler_task_runs_corrupt_file_ignored(tmp_path): + """Corrupt/unreadable JSONL is ignored — handler starts empty, no crash.""" + from emrg.server import scheduler as mod + orig = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + runs_dir = tmp_path / "logs" / "task-runs" + runs_dir.mkdir(parents=True, exist_ok=True) + (runs_dir / "emrg-task.jsonl").write_text( + "not-json-at-all\n{broken json\n{\"timestamp\": \"ok\", \"summary\": \"kept\"}\n", + encoding="utf-8", + ) + handler = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + # corrupt lines skipped; valid line kept + assert len(handler.evolutions) == 1 + assert handler.evolutions[0].summary == "kept" + finally: + mod.config_dir = orig + + +def test_task_handler_task_runs_write_failure_tolerated(tmp_path): + """A failed append only logs a warning — never breaks the running cycle.""" + from emrg.protocol import EvolutionLog + from emrg.server import scheduler as mod + orig = mod.config_dir + try: + mod.config_dir = lambda: tmp_path + handler = TaskHandler(name="emrg-task", config={}, interval=60, identity=InstanceIdentity()) + # sabotage: make the target file path a directory → append raises + runs_dir = tmp_path / "logs" / "task-runs" + runs_dir.mkdir(parents=True, exist_ok=True) + (runs_dir / "emrg-task.jsonl").mkdir() # dir where the file should be + log = EvolutionLog(timestamp="2026-08-19T20:30:00", summary="x") + handler.evolutions.append(log) + # must not raise; cycle continues with the in-memory record + handler._append_task_run(log) + assert len(handler.evolutions) == 1 + assert handler.evolutions[0].summary == "x" + finally: + mod.config_dir = orig + + def test_evolution_handler_default_owner(): """When no git remote is detectable, falls back to EMRG defaults.""" handler = TaskHandler(