From cd7846b3488826882674db50864dfab58cfc515a Mon Sep 17 00:00:00 2001 From: iris Date: Fri, 19 Jun 2026 12:50:04 +0200 Subject: [PATCH] bash-tasks page: live interval poll + spawn_blocking the dir scan (review) Two fixes from review: - Liveness: /api/state isn't polled while online (only during login), so hooking refreshBashTasks to it only populated on cold load. Bash tasks start + finish asynchronously between turns, so add a light ~4s interval to keep the tasks pill live; the /api/state-time call now just does the first-paint populate. Doc note corrected to match. - Move the blocking dir scan + per-file reads in /api/bash-tasks off the async executor via tokio::task::spawn_blocking (damocles nit). --- docs/web-ui/agent.md | 8 ++++--- frontend/packages/agent/src/app.js | 14 +++++++++--- hive-ag3nt/src/web_ui.rs | 34 ++++++++++++++++++------------ 3 files changed, 37 insertions(+), 19 deletions(-) diff --git a/docs/web-ui/agent.md b/docs/web-ui/agent.md index a9d8549e..3365a486 100644 --- a/docs/web-ui/agent.md +++ b/docs/web-ui/agent.md @@ -63,9 +63,11 @@ through. Three flex columns: click opens the loose-ends flyout. - **Tasks pill** (`⚙ tasks · N`): hidden when empty; click opens the running-bash-tasks flyout (`GET /api/bash-tasks` — the in-flight - Pending/Running tasks from the in-container `bash-tasks/` dir). Polled - on the same `/api/state` cycle as loose-ends, since tasks complete - asynchronously between turns. Snapshot only (no SSE push yet). + Pending/Running tasks from the in-container `bash-tasks/` dir). Unlike + loose-ends (refreshed on turn_end), tasks start + finish asynchronously + between turns and `/api/state` isn't polled while online, so the pill + polls the endpoint on a light interval (≈4s). Snapshot only (no SSE push + yet). - **Overflow button** (`⋯`): always visible. Opens a frosted popover (`#overflow-menu`, positioned outside the header to escape any stacking context) with four management rows followed by a model diff --git a/frontend/packages/agent/src/app.js b/frontend/packages/agent/src/app.js index cbc5ab4c..26f3c074 100644 --- a/frontend/packages/agent/src/app.js +++ b/frontend/packages/agent/src/app.js @@ -1354,9 +1354,11 @@ window.marked = marked; // db, fetched via the per-agent socket). Cold-load fetches // it here; turn_end refreshes it via the renderer below. refreshLooseEnds(); - // Running bash tasks live in the in-container bash-tasks dir (not - // /api/state); same poll cadence as loose-ends. They complete async - // between turns, so polling on each state cycle keeps the count live. + // Cold-load populate of the running-bash-tasks pill. Tasks complete + // asynchronously between turns (independent of turn_end SSE) and + // /api/state isn't polled while online, so a dedicated interval (set + // up next to the initial refreshState() below) keeps the count live; + // this call just fills it immediately on first paint. refreshBashTasks(); // Skip the re-render if nothing structurally changed. The most // common case is `online` polling itself — without this guard, the @@ -1387,6 +1389,12 @@ window.marked = marked; } } refreshState(); + // Keep the running-bash-tasks pill live. Unlike loose-ends (refreshed on + // turn_end SSE), bash tasks start + finish asynchronously between turns and + // /api/state isn't polled while online — so poll the cheap snapshot endpoint + // on a light interval. Fails silently (renders zero) when offline. v1 is + // polling; an SSE push for task state could replace this later. + setInterval(refreshBashTasks, 4000); // ─── live event stream ────────────────────────────────────────────────── // Scrolling, pill, backfill + SSE plumbing live in hive-fr0nt::TERMINAL_JS diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index fe68ea69..113da430 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -539,8 +539,13 @@ async fn api_loose_ends(State(state): State) -> Response { /// fail the whole list. async fn api_bash_tasks() -> Response { let dir = crate::paths::harness_dir().join("bash-tasks"); - let mut tasks: Vec = Vec::new(); - if let Ok(rd) = std::fs::read_dir(&dir) { + // The dir scan + per-file reads are blocking fs I/O; run them off the + // async executor so a slow or large tasks dir can't stall other requests. + let tasks = tokio::task::spawn_blocking(move || { + let mut tasks: Vec = Vec::new(); + let Ok(rd) = std::fs::read_dir(&dir) else { + return tasks; + }; for entry in rd.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("json") { @@ -559,17 +564,20 @@ async fn api_bash_tasks() -> Response { tasks.push(task); } } - } - // Running before Pending, then oldest-first so a long-runner sits on top. - tasks.sort_by(|a, b| { - let rank = |s: &hive_sh4re::TaskStatus| match s { - hive_sh4re::TaskStatus::Running => 0, - _ => 1, - }; - rank(&a.status) - .cmp(&rank(&b.status)) - .then(a.created_at.cmp(&b.created_at)) - }); + // Running before Pending, then oldest-first so a long-runner sits on top. + tasks.sort_by(|a, b| { + let rank = |s: &hive_sh4re::TaskStatus| match s { + hive_sh4re::TaskStatus::Running => 0, + _ => 1, + }; + rank(&a.status) + .cmp(&rank(&b.status)) + .then(a.created_at.cmp(&b.created_at)) + }); + tasks + }) + .await + .unwrap_or_default(); axum::Json(serde_json::json!({ "tasks": tasks })).into_response() }