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
13 changes: 11 additions & 2 deletions emrg/gui/renderer/js/app.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 }));
}
Expand DownExpand Up@@ -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);
Expand All@@ -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();
}
}
Expand Down
43 changes: 42 additions & 1 deletion emrg/gui/renderer/js/dialogs.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) {
Expand DownExpand Up@@ -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)。
Expand DownExpand Up@@ -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();
Expand DownExpand Up@@ -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) }));
Expand DownExpand Up@@ -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 面板初始化
Expand Down
2 changes: 2 additions & 0 deletions emrg/gui/renderer/js/i18n.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand DownExpand Up@@ -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",
Expand Down
14 changes: 10 additions & 4 deletions emrg/gui/test/renderer.smoke.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 },
Expand All@@ -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]}`);
Expand All@@ -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;
Expand DownExpand Up@@ -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 },
Expand All@@ -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 () => {
Expand Down
12 changes: 11 additions & 1 deletion emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand DownExpand Up@@ -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()
Expand All@@ -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()
Expand DownExpand Up@@ -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,
Expand Down
8 changes: 8 additions & 0 deletions tests/test_scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
Loading