From 0b556972099f51ffbd9d0f41825575cc3da365f2 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 18 Aug 2026 14:37:06 +0800 Subject: [PATCH] emrg: GUI tasks panel shows last-run + saturation meta (rant 2026-08-18T10:45:52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tasks panel only showed name/type/running/next-run/interval/enabled — no last-execution dimension. Users couldn't see when a task last ran, what it did, or whether it was throttled (saturation heartbeat). - scheduler.py TaskHandler.status(): add last_run_at (latest evolution log timestamp), last_cycle_summary (impact summary, e.g. tools-executed=24, cycle-complete), saturation {empty_cycles, threshold, heartbeat_interval, heartbeat_active} — all in-memory, no extra I/O. - daemon list_tasks passthrough unchanged (status() already flows through). - dialogs.js renderTaskList: each row gets a .task-meta second line — last run (relative time via new formatRelativeTime), summary, and a saturation badge when throttled. - i18n.js: zh/en keys (taskLastRun/taskLastRunSummary/taskNoRunYet/taskSaturation). - components.css: .task-meta / .task-meta-summary / .task-saturation-badge. - Tests: scheduler status() last-run fields; GUI smoke test for meta row + saturation badge. Agent.md counts 938→939 pytest, 258→259 GUI. --- Agent.md | 6 ++--- emrg/gui/renderer/css/components.css | 29 ++++++++++++++++++++++++ emrg/gui/renderer/js/dialogs.js | 30 +++++++++++++++++++++++++ emrg/gui/renderer/js/i18n.js | 8 +++++++ emrg/gui/test/renderer.smoke.test.js | 33 ++++++++++++++++++++++++++++ emrg/server/scheduler.py | 21 ++++++++++++++++++ tests/test_scheduler.py | 30 +++++++++++++++++++++++++ 7 files changed, 154 insertions(+), 3 deletions(-) diff --git a/Agent.md b/Agent.md index 8a6f99d0..52b105e8 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` (258: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 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` (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` - **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` (938) — import check: `uv run python -c "from emrg.client.app import run_client"` -GUI: `cd emrg/gui && npm test` (258: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 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` +Python: `uv run pytest tests/ -v` (939) — import check: `uv run python -c "from emrg.client.app import run_client"` +GUI: `cd emrg/gui && 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) — 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 837fc9de..9c514828 100644 --- a/emrg/gui/renderer/css/components.css +++ b/emrg/gui/renderer/css/components.css @@ -996,6 +996,35 @@ dialog::backdrop { font-variant-numeric: tabular-nums; /* 倒计时走秒不抖动 */ } +/* rant 2026-08-18T10:45:52:任务行"上次执行"元信息(运行时间/摘要/降频标识) */ +.task-meta { + flex-basis: 100%; + display: flex; + align-items: center; + gap: 6px; + font-size: var(--fs-aux); + color: var(--text-3); + min-width: 0; + flex-wrap: wrap; +} +.task-meta-item { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.task-meta-summary { + max-width: 60%; + color: var(--text-2); +} +.task-saturation-badge { + background: var(--warn-soft, var(--bg-soft)); + color: var(--warn, var(--text-3)); + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + border: 1px solid var(--warn-soft, var(--border)); +} + /* rant 09:17:45:提示词编辑器 Monaco 挂载容器(长提示词 300px 起步) */ .monaco-host { width: 100%; diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 4e84e3d2..845d58d0 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -638,6 +638,19 @@ const Dialogs = (() => { return `${h}h${String(m).padStart(2, "0")}m`; } + // rant 2026-08-18T10:45:52:相对时间显示("5m ago" / "3h ago" / 日期)—— + // 用于任务行"上次运行"元信息。ISO 时间戳解析失败时回退为原文。 + function formatRelativeTime(isoStr) { + if (!isoStr) return ""; + const t = Date.parse(isoStr); + if (Number.isNaN(t)) return String(isoStr); + const diffSec = Math.max(0, Math.floor((Date.now() - t) / 1000)); + if (diffSec < 60) return `${diffSec}s ago`; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`; + return `${Math.floor(diffSec / 86400)}d ago`; + } + function updateTaskCountdowns() { let expired = false; for (const e of taskCountdowns) { @@ -726,6 +739,23 @@ const Dialogs = (() => { if (t.interval != null) hints.push(_t("app.taskInterval", { n: t.interval ?? "-" })); if (t.enabled === false) hints.push(_t("app.taskDisabled")); row.appendChild(el("span", { class: "task-hint" }, hints.join(" · "))); + // rant 2026-08-18T10:45:52:上次执行维度 —— 最近一次运行时间 + 摘要 + 降频(saturation)标识。 + // 数据来自 daemon list_tasks → handler.status()(last_run_at/last_cycle_summary/saturation)。 + const meta = el("div", { class: "task-meta" }); + if (t.last_run_at) { + const runTxt = el("span", { class: "task-meta-item" }, _t("app.taskLastRun", { n: formatRelativeTime(t.last_run_at) })); + meta.appendChild(runTxt); + if (t.last_cycle_summary) { + meta.appendChild(el("span", { class: "task-meta-item task-meta-summary" }, _t("app.taskLastRunSummary", { n: t.last_cycle_summary }))); + } + } else { + meta.appendChild(el("span", { class: "task-meta-item" }, _t("app.taskNoRunYet"))); + } + const sat = (t.saturation && typeof t.saturation === "object") ? t.saturation : null; + if (sat && sat.empty_cycles > 0 && sat.heartbeat_active) { + meta.appendChild(el("span", { class: "task-badge task-saturation-badge" }, _t("app.taskSaturation", { n: sat.empty_cycles, m: formatCountdown(sat.heartbeat_interval) }))); + } + row.appendChild(meta); const actions = el("span", { class: "task-actions" }); // 触发(复用 /trigger 语义;rant 09:23:10:running 时给 info 反馈而非假成功) const trigBtn = el("button", { type: "button", class: "model-action-btn", title: _t("settings.taskTrigger") }, _t("settings.taskTrigger")); diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index f7c79136..0d800db8 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -408,6 +408,10 @@ const I18N = (() => { "app.triggered": "已触发任务 {n}。", "app.taskRunning": "任务 {n} 正在运行中(无法重复触发)", "app.taskRunningBadge": "运行中", + "app.taskLastRun": "上次运行:{n}", + "app.taskLastRunSummary": "干了:{n}", + "app.taskNoRunYet": "尚未运行", + "app.taskSaturation": "空转 {n} 轮,降频至 {m}s heartbeat", "app.deletedSwitch": "这个对话已被删除,已帮你切到最近的对话。", "app.switched": "已切换对话。", "app.sessionDisconnected": "该会话连接已断开,正在自动重连…", @@ -828,6 +832,10 @@ const I18N = (() => { "app.triggered": "Task {n} triggered.", "app.taskRunning": "Task {n} is running (cannot re-trigger)", "app.taskRunningBadge": "running", + "app.taskLastRun": "last run: {n}", + "app.taskLastRunSummary": "did: {n}", + "app.taskNoRunYet": "never ran", + "app.taskSaturation": "{n} empty cycles, throttled to {m}s heartbeat", "app.deletedSwitch": "This conversation was deleted — switched to the most recent one.", "app.switched": "Conversation switched.", "app.sessionDisconnected": "This session's connection is lost — reconnecting automatically…", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 7c4ad285..0dd80596 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -2839,6 +2839,39 @@ test("rant 10:36:39:倒计时生命周期 —— 渲染启动 interval、离 assert.strictEqual(win2._intervalCount || 0, s2, "全 running 无倒计时 → 不启动 interval"); }); +test("rant 10:45:52:任务行显示上次执行元信息 + 降频标识", async () => { + const nowMs = 1_700_000_000_000; + const { ctx, win } = makeSandbox({ + listTasks: async () => [ + { name: "with-run", type: "evolution", running: false, interval: 1800, + next_run_in_seconds: 43, + last_run_at: new Date(nowMs - 5 * 60_000).toISOString(), + last_cycle_summary: "tools-executed=24, cycle-complete", + saturation: { empty_cycles: 3, threshold: 5, heartbeat_interval: 7200, heartbeat_active: true } }, + { name: "never-run", type: "evolution", running: false, interval: 3600, + next_run_in_seconds: null, enabled: true, last_run_at: null, last_cycle_summary: null, + saturation: { empty_cycles: 0, threshold: 5, heartbeat_interval: 7200, heartbeat_active: false } }, + ], + listProjects: async () => [], + }); + win.Date = class extends Date { static now() { return nowMs; } }; + await tick(); + await vm.runInContext("App.openTasksPanel()", ctx); + await tick(); + const rows = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => { + const name = r.querySelector(".task-name").textContent; + const meta = r.querySelector(".task-meta"); + const metaText = meta ? Array.from(meta.querySelectorAll(".task-meta-item")).map((e) => e.textContent).join("|") : ""; + const satText = meta && meta.querySelector(".task-saturation-badge") ? meta.querySelector(".task-saturation-badge").textContent : ""; + return name + " => " + metaText + " ## " + satText; + })`, ctx); + assert.ok(rows[0].includes("上次运行:5m ago"), `运行时间相对显示:${rows[0]}`); + assert.ok(rows[0].includes("干了:tools-executed=24, cycle-complete"), `执行摘要:${rows[0]}`); + assert.ok(rows[0].includes("空转 3 轮"), `降频徽标:${rows[0]}`); + assert.ok(rows[1].includes("尚未运行"), `未运行提示:${rows[1]}`); + assert.ok(!rows[1].includes("空转"), `无降频时无徽标:${rows[1]}`); +}); + test("rant 2026-08-14T15:41:52:快速点击添加任务 —— 元数据未加载完也填充下拉 + 保存成功", async () => { let created = null; const { ctx, els } = makeSandbox({ diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index d3071eb5..75af0524 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -743,11 +743,32 @@ def status(self) -> dict: remaining = None if self._next_run_at is not None: remaining = max(0, int(self._next_run_at - time.time())) + # rant 2026-08-18T10:45:52: expose the LAST-execution dimension so the + # GUI tasks panel can show when a task last ran, what it did, and + # whether it is being throttled (saturation). All data is in-memory / + # on disk already — no extra I/O beyond the in-memory evolutions list. + last_run_at: str | None = None + last_cycle_summary: str | None = None + if self.evolutions: + last = self.evolutions[-1] + last_run_at = last.timestamp + # brief summary: tools-executed / cycle-complete etc. from impact + parts = [str(i) for i in last.impact if i] + last_cycle_summary = ", ".join(parts[:3]) if parts else None + saturation = { + "empty_cycles": self._empty_cycles, + "threshold": self._saturation_threshold(), + "heartbeat_interval": self._heartbeat_interval(), + "heartbeat_active": self._saturation_heartbeat_active(), + } return { "name": self.name, "running": self._cycle_running, "next_run_in_seconds": remaining, "interval": self.interval, + "last_run_at": last_run_at, + "last_cycle_summary": last_cycle_summary, + "saturation": saturation, } def _remote_advanced(self) -> bool: diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 9c80ccf4..f6981569 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -490,6 +490,36 @@ def test_evolution_handler_stop(): assert handler._running is False +def test_evolution_handler_status_last_run_fields(): + """status() exposes last-run + saturation (rant 2026-08-18T10:45:52).""" + from emrg.protocol import EvolutionLog + handler = TaskHandler( + name="test", config={}, interval=60, + identity=InstanceIdentity(), + ) + # no evolutions yet → null last-run fields, saturation defaults + st = handler.status() + assert st["name"] == "test" + assert st["last_run_at"] is None + assert st["last_cycle_summary"] is None + assert st["saturation"]["empty_cycles"] == 0 + assert "threshold" in st["saturation"] + assert "heartbeat_interval" in st["saturation"] + assert "heartbeat_active" in st["saturation"] + # after one evolution → last-run populated from the latest log + handler.evolutions.append(EvolutionLog( + timestamp="2026-08-18T10:00:00", + trigger="evolution-test-ts", + impact=["tools-executed=24", "cycle-complete"], + operations=["llm-reflection", "tool-execution"], + )) + handler._empty_cycles = 3 + st = handler.status() + assert st["last_run_at"] == "2026-08-18T10:00:00" + assert st["last_cycle_summary"] == "tools-executed=24, cycle-complete" + assert st["saturation"]["empty_cycles"] == 3 + + def test_evolution_handler_default_owner(): """When no git remote is detectable, falls back to EMRG defaults.""" handler = TaskHandler(