diff --git a/emrg/gui/renderer/js/app.js b/emrg/gui/renderer/js/app.js index 33f82c00..e1f318e0 100644 --- a/emrg/gui/renderer/js/app.js +++ b/emrg/gui/renderer/js/app.js @@ -431,6 +431,7 @@ const App = (() => { try { await Dialogs.loadTaskMeta(); await Dialogs.renderTaskList(); + Dialogs.startTaskPoll?.(); // rant 2026-08-22T07:18:35:面板激活 → 5s 状态轮询 } catch (e) { Chat.addSystemMessage(_t("app.tasksFailed", { msg: e.message })); } @@ -684,7 +685,11 @@ const App = (() => { if (!VIEWS.includes(name)) return; const isOpen = state.activeView === name; // rant 10:36:39:离开任务视图 → 停倒计时(防泄漏;重开由 renderTaskList 重新启动) - if (state.activeView === "tasks" && name !== "tasks") Dialogs.stopTaskCountdown?.(); + // rant 2026-08-22T07:18:35:同时停 5s 状态轮询(与倒计时同生命周期防泄漏) + if (state.activeView === "tasks" && name !== "tasks") { + Dialogs.stopTaskCountdown?.(); + Dialogs.stopTaskPoll?.(); + } for (const p of VIEWS) { const btn = $(`nav-${p}`); if (btn) btn.classList.toggle("active", false); @@ -707,7 +712,11 @@ const App = (() => { } else { // 点当前激活项(toggle 关闭)/ 点 💬 会话 → 回会话视图 // rant 10:36:39:从任务面板 toggle 关闭同样要停倒计时(activeView 即将离开 tasks) - if (state.activeView === "tasks") Dialogs.stopTaskCountdown?.(); + // rant 2026-08-22T07:18:35:同时停 5s 轮询 + if (state.activeView === "tasks") { + Dialogs.stopTaskCountdown?.(); + Dialogs.stopTaskPoll?.(); + } showSessionsView(); } } diff --git a/emrg/gui/renderer/js/dialogs.js b/emrg/gui/renderer/js/dialogs.js index 382965e1..ea733536 100644 --- a/emrg/gui/renderer/js/dialogs.js +++ b/emrg/gui/renderer/js/dialogs.js @@ -491,7 +491,10 @@ const Dialogs = (() => { // rant 2026-08-15T10:36:39:任务状态 + 下次运行倒计时 // 倒计时以"渲染时快照的 deadline"为基准每秒递减(直接改 DOM 文本,不重渲染整行防闪烁/滚动丢失) let taskCountdownTimer = null; // setInterval id(面板激活时启动,离开视图时清除防泄漏) - let taskCountdowns = []; // [{name, deadline, span}] — 每 1s 由 updateTaskCountdowns 更新 + let taskCountdowns = []; // [{name, deadline, span} | {name, startedAt, span}] — 每 1s 由 updateTaskCountdowns 更新 + // rant 2026-08-22T07:18:35:面板激活时每 5s 轮询任务状态(loadTaskMeta + renderTaskList), + // 让"运行中→待运行"切换和下次倒计时 ≤5s 内自动更新(宿主确认轮询方案,不引入事件推送)。 + let taskPollTimer = null; // setInterval id(任务面板激活时启动,离开视图时清除防泄漏) // 倒计时格式化:≤60s "43s";≤1h "1m23s";>1h "1h05m";负数/非数钳制为 0 function formatCountdown(totalSeconds) { @@ -523,6 +526,13 @@ const Dialogs = (() => { function updateTaskCountdowns() { let expired = false; for (const e of taskCountdowns) { + // rant 2026-08-22T07:18:35:running 时长与待运行倒计时同机制、反向—— + // startedAt 固定、now 递增即时长(elapsed);deadline 固定、now 递增则剩余递减。 + if (e.startedAt != null) { + const el = Math.max(0, Math.floor((Date.now() - e.startedAt) / 1000)); + e.span.textContent = _t("app.taskRunningDuration", { n: formatCountdown(el) }); + continue; + } const rem = Math.max(0, Math.ceil((e.deadline - Date.now()) / 1000)); e.span.textContent = _t("app.taskNextRun", { n: formatCountdown(rem) }); // rant 2026-08-18T11:16:32:倒计时归零后任务状态不会自动更新(pending → running 永不反映到 UI)。 @@ -555,6 +565,28 @@ const Dialogs = (() => { taskCountdownTimer = setInterval(updateTaskCountdowns, 1000); } + // rant 2026-08-22T07:18:35:5s 状态轮询(方案 B:宿主确认轮询、可接受几秒滞后)。 + // 幂等:重复启动先清旧定时器;renderTaskList 不主动重启它(避免轮询自驱死循环)。 + function startTaskPoll() { + if (taskPollTimer !== null) { + clearInterval(taskPollTimer); + taskPollTimer = null; + } + taskPollTimer = setInterval(async () => { + try { + await loadTaskMeta(); + await renderTaskList(); + } catch { /* 轮询失败静默,下轮重试 */ } + }, 5000); + } + + function stopTaskPoll() { + if (taskPollTimer !== null) { + clearInterval(taskPollTimer); + taskPollTimer = null; + } + } + async function loadTaskMeta() { try { const templates = await window.emrg.taskTemplateList(); @@ -676,6 +708,13 @@ const Dialogs = (() => { if (t.running) { const runBadge = el("span", { class: "task-badge task-running-badge" }, _t("app.taskRunningBadge")); row.appendChild(runBadge); + // rant 2026-08-22T07:18:35:running 行显示已运行时长(started_at 由 scheduler + // status() 暴露;startedAt 固定、每秒递增 → 与待运行倒计时同机制反向跳动)。 + if (t.started_at != null) { + const elapsedSpan = el("span", { class: "task-next-run" }, _t("app.taskRunningDuration", { n: formatCountdown(0) })); + row.appendChild(elapsedSpan); + countdowns.push({ name: t.name, startedAt: Number(t.started_at) * 1000, span: elapsedSpan }); + } } else if (t.next_run_in_seconds != null) { row.appendChild(el("span", { class: "task-badge task-pending-badge" }, _t("app.taskPendingBadge"))); const nextSpan = el("span", { class: "task-next-run" }, _t("app.taskNextRun", { n: formatCountdown(t.next_run_in_seconds) })); @@ -1602,6 +1641,8 @@ const Dialogs = (() => { updateTaskCountdowns, // rant 10:36:39:1s tick 更新倒计时文本(测试直接调用模拟走秒) startTaskCountdown, // rant 10:36:39:启动 1s 倒计时(幂等;renderTaskList 自动调用) stopTaskCountdown, // rant 10:36:39:停止并清引用(离开任务视图时 app.js 调用防泄漏) + startTaskPoll, // rant 2026-08-22T07:18:35:启动 5s 状态轮询(任务面板激活时 app.js 调用) + stopTaskPoll, // rant 2026-08-22T07:18:35:停止轮询(离开任务视图时 app.js 调用防泄漏) renderProjectList, // rant 14:10:14 P5:项目面板列表渲染(测试/刷新复用) showProjectSessionsInPanel, // rant 14:10:14 P5:项目面板内嵌会话列表(测试复用) initRantPanel, // rant 14:10:14 P4:rant 面板初始化 diff --git a/emrg/gui/renderer/js/i18n.js b/emrg/gui/renderer/js/i18n.js index e90d01cf..8f0d0f9a 100644 --- a/emrg/gui/renderer/js/i18n.js +++ b/emrg/gui/renderer/js/i18n.js @@ -390,6 +390,7 @@ const I18N = (() => { "app.triggered": "已触发任务 {n}。", "app.taskRunning": "任务 {n} 正在运行中(无法重复触发)", "app.taskRunningBadge": "运行中", + "app.taskRunningDuration": "已运行 {n}", "app.taskLastRun": "上次运行:{n}", "app.taskNoRunYet": "尚未运行", "app.taskThrottled": "已降频 · heartbeat {m}s", @@ -803,6 +804,7 @@ const I18N = (() => { "app.triggered": "Task {n} triggered.", "app.taskRunning": "Task {n} is running (cannot re-trigger)", "app.taskRunningBadge": "running", + "app.taskRunningDuration": "running for {n}", "app.taskLastRun": "last run: {n}", "app.taskNoRunYet": "never ran", "app.taskThrottled": "throttled · {m}s heartbeat", diff --git a/emrg/gui/test/renderer.smoke.test.js b/emrg/gui/test/renderer.smoke.test.js index 6ebd2b7a..7f23b16a 100644 --- a/emrg/gui/test/renderer.smoke.test.js +++ b/emrg/gui/test/renderer.smoke.test.js @@ -2907,7 +2907,8 @@ test("rant 10:36:39:任务行状态展示 —— 运行中/待运行+倒计时 // rant 11:16:32:mock 有状态 —— deadline(50s 后)已过 → waiting-task 转为 running, // 验证归零后 updateTaskCountdowns 自动重拉任务状态(pending → running 徽标) listTasks: async () => [ - { name: "running-task", type: "evolution", running: true, interval: 60, next_run_in_seconds: null }, + // rant 2026-08-22T07:18:35:running 任务带 started_at(scheduler status() 暴露)→ 显示已运行时长 + { name: "running-task", type: "evolution", running: true, interval: 60, next_run_in_seconds: null, started_at: 1_700_000_000 }, { name: "waiting-task", type: "evolution", running: nowMs >= 1_700_000_050_000, interval: 1800, next_run_in_seconds: 43 }, { name: "idle-task", type: "custom", running: false, interval: 3600, next_run_in_seconds: null, enabled: true }, { name: "disabled-task", type: "custom", running: false, interval: 3600, next_run_in_seconds: null, enabled: false }, @@ -2927,6 +2928,7 @@ test("rant 10:36:39:任务行状态展示 —— 运行中/待运行+倒计时 })`, ctx); assert.strictEqual(texts.length, 4, "4 任务各一行"); assert.ok(texts[0].includes("运行中"), `running 徽标:${texts[0]}`); + assert.ok(texts[0].includes("已运行 0s"), `running 显示已运行时长(初始 0s):${texts[0]}`); assert.ok(texts[0].includes("运行中") && !texts[0].includes("下次运行"), "running 无倒计时"); assert.ok(texts[1].includes("待运行") && texts[1].includes("下次运行 43s"), `等待任务:${texts[1]}`); assert.ok(texts[2].includes("待调度") && !texts[2].includes("下次运行"), `空闲任务:${texts[2]}`); @@ -2935,7 +2937,7 @@ test("rant 10:36:39:任务行状态展示 —— 运行中/待运行+倒计时 nowMs += 1000; await vm.runInContext("EMRG_Dialogs.updateTaskCountdowns()", ctx); let nextRuns = vm.runInContext(`Array.from(document.getElementById("task-list").children).map((r) => { const s = r.querySelector(".task-next-run"); return s ? s.textContent : ""; }).filter(Boolean)`, ctx); - assert.deepStrictEqual(nextRuns, ["下次运行 42s"], "1s 后递减为 42s"); + assert.deepStrictEqual(nextRuns, ["已运行 1s", "下次运行 42s"], "1s 后 elapsed 递增 + 倒计时递减"); // 快进到 deadline 之后 → rant 11:16:32:归零触发自动 renderTaskList 重拉状态(异步), // mock 此刻返回 running → UI 从"待运行+倒计时"刷新为 running 徽标(无倒计时) nowMs += 50_000; @@ -2971,7 +2973,7 @@ test("rant 10:36:39:倒计时生命周期 —— 渲染启动 interval、离 await vm.runInContext("App.openTasksPanel()", ctx); await tick(); assert.ok((win._intervalCount || 0) > start2, "重开任务面板 → 倒计时重新启动"); - // 无倒计时的任务(全 running)→ 不启动 interval + // 无倒计时的任务(全 running)→ 不启动倒计时 interval;但 5s 状态轮询 interval 照常启动 const { ctx: ctx2, win: win2 } = makeSandbox({ listTasks: async () => [ { name: "r1", type: "evolution", running: true, interval: 60, next_run_in_seconds: null }, @@ -2982,7 +2984,11 @@ test("rant 10:36:39:倒计时生命周期 —— 渲染启动 interval、离 const s2 = win2._intervalCount || 0; await vm.runInContext("App.openTasksPanel()", ctx2); await tick(); - assert.strictEqual(win2._intervalCount || 0, s2, "全 running 无倒计时 → 不启动 interval"); + assert.strictEqual(win2._intervalCount || 0, s2 + 1, "全 running 无倒计时 → 仅启动 5s 轮询 interval(rant 07:18:35)"); + // 轮询生命周期:离开任务视图 → 轮询 interval 也被清理(防泄漏) + const clear2 = win2._clearCount || 0; + await vm.runInContext("App.switchView('projects')", ctx2); + assert.ok((win2._clearCount || 0) > clear2, "离开任务视图 → 轮询 interval 同样被 clear 防泄漏"); }); test("rant 10:45:52:任务行显示上次执行元信息 + 降频标识(rant 18:25:14:不再显示摘要)", async () => { diff --git a/emrg/server/scheduler.py b/emrg/server/scheduler.py index 5cb2ed64..61de1663 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -214,7 +214,8 @@ def __init__( self.interval = interval self.identity = identity self._running = False - self._start_time: float | None = None + self._start_time: float | None = None # handler start (template uptime) + self._cycle_start_time: float | None = None # per-cycle start (rant 2026-08-22T07:18:35 elapsed display) self._trigger_event = asyncio.Event() self._cycle_running = False self._next_run_at: float | None = None @@ -500,6 +501,7 @@ async def run(self) -> None: self._logger.debug("TaskHandler[%s] tick", self.name) self._cycle_running = True + self._cycle_start_time = time.time() # per-cycle elapsed base (rant 2026-08-22T07:18:35) self._next_run_at = None # running — no next time yet try: await self._run_evolution_cycle() @@ -509,6 +511,7 @@ async def run(self) -> None: ) finally: self._cycle_running = False + self._cycle_start_time = None self._trigger_event.clear() # clear any spurious set during cycle await self._write_final_summary() @@ -573,9 +576,16 @@ def status(self) -> dict: "heartbeat_interval": self._heartbeat_interval(), "heartbeat_active": self._slowdown_active, } + # rant 2026-08-22T07:18:35: expose the CURRENT cycle's start time so the + # GUI tasks panel can show "已运行 XXs" (elapsed, ticking up). Epoch + # seconds; valid only while running — None when idle/completed. + # (Uses _cycle_start_time — per-cycle base; _start_time is handler + # uptime and would report cumulative time across cycles.) + started_at = self._cycle_start_time if self._cycle_running else None return { "name": self.name, "running": self._cycle_running, + "started_at": started_at, "next_run_in_seconds": remaining, "interval": self.interval, "last_run_at": last_run_at, diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index 25a7941f..5b89f22d 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -553,6 +553,14 @@ def test_evolution_handler_status_last_run_fields(): assert st["session_id"] == "emrg-evolution-test" assert st["project"] == "" # config={} → empty project name assert st["project_path"] == "test" # fallback path = name + # rant 2026-08-22T07:18:35: started_at = per-CYCLE start epoch (set at tick + # begin, cleared at cycle end), only valid while running; None when idle + assert st["started_at"] is None, "idle handler → started_at None" + handler._cycle_running = True + handler._cycle_start_time = 1_700_000_000.0 + assert handler.status()["started_at"] == 1_700_000_000.0, "running → cycle start epoch" + handler._cycle_running = False + assert handler.status()["started_at"] is None, "completed → started_at None again" # after one evolution → last-run populated from the latest log handler.evolutions.append(EvolutionLog( timestamp="2026-08-18T10:00:00",