diff --git a/frontend/packages/dashboard/src/app.js b/frontend/packages/dashboard/src/app.js index 04468966..24d22d83 100644 --- a/frontend/packages/dashboard/src/app.js +++ b/frontend/packages/dashboard/src/app.js @@ -499,23 +499,14 @@ window.marked = marked; // answering) the error handler falls it back to the dimmed // hyperhive mark (`/favicon.svg`, served by the dashboard // itself, always reachable). (issues #195, #202) - const iconImg = el('img', { class: 'container-icon-img', alt: '' }); + const iconImg = el('img', { class: 'container-icon-img', src: `${url}icon`, alt: '' }); const icon = el('div', { class: 'container-icon' }, iconImg); - if (c.running) { - iconImg.src = `${url}icon`; - iconImg.addEventListener('error', () => { - if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s - iconImg.dataset.fallback = '1'; - icon.classList.add('icon-unreachable'); - iconImg.src = '/favicon.svg'; - }); - } else { - // Container stopped (#432) — skip the doomed `${url}icon` fetch - // and go straight to the dimmed hyperhive mark. Avoids a noisy - // failed request in the console + the brief broken-image flash. + iconImg.addEventListener('error', () => { + if (iconImg.dataset.fallback) return; // guard: don't loop if the favicon itself 404s + iconImg.dataset.fallback = '1'; icon.classList.add('icon-unreachable'); iconImg.src = '/favicon.svg'; - } + }); // Card body: the three stacked content lines, right of the icon. const body = el('div', { class: 'card-body' }); @@ -536,43 +527,30 @@ window.marked = marked; head.append(navStrip); const forgeBase = `http://${hostname}:3000`; const containerBase = `http://${hostname}:${c.port}`; - if (c.running) { - fetch(`/api/agent/${encodeURIComponent(c.name)}/links`) - .then((r) => (r.ok ? r.json() : [])) - .then((links) => { - if (!Array.isArray(links)) return; - for (const lnk of links) { - const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') - : lnk.kind === 'external' ? (lnk.url || '') - : /* container */ containerBase + (lnk.url || ''); - const a = el('a', { - class: 'nav-link', - href, - target: '_blank', - rel: 'noopener', - title: lnk.label || '', - }); - // Plain text — agent-controlled strings stay out of innerHTML. - a.textContent = lnk.icon || lnk.label || ''; - navStrip.append(a); - } - }) - .catch(() => { /* graceful: agent down → no strip */ }); - } - // Status / runtime badges. Pending transients always win - // (start / stop / restart / rebuild is in progress). Otherwise, - // when the container is stopped, surface a single `■ not - // running` badge; the backend has already cleared rate_limited / - // needs_login / ctx_tokens / status_text in that case (#432) so - // the rest of the chain is a no-op for stopped containers — but - // we still want SOME badge there so the row doesn't look empty. + fetch(`/api/agent/${encodeURIComponent(c.name)}/links`) + .then((r) => (r.ok ? r.json() : [])) + .then((links) => { + if (!Array.isArray(links)) return; + for (const lnk of links) { + const href = lnk.kind === 'forge' ? forgeBase + (lnk.url || '') + : lnk.kind === 'external' ? (lnk.url || '') + : /* container */ containerBase + (lnk.url || ''); + const a = el('a', { + class: 'nav-link', + href, + target: '_blank', + rel: 'noopener', + title: lnk.label || '', + }); + // Plain text — agent-controlled strings stay out of innerHTML. + a.textContent = lnk.icon || lnk.label || ''; + navStrip.append(a); + } + }) + .catch(() => { /* graceful: agent down → no strip */ }); if (pending) { head.append(el('span', { class: 'pending-state' }, el('span', { class: 'spinner' }, '◐'), ' ', pending + '…')); - } else if (!c.running) { - head.append(el('span', - { class: 'badge badge-muted', title: 'container is shut down — start it to bring the harness back up' }, - '■ not running')); } else if (c.rate_limited) { head.append(el('span', { class: 'badge badge-rate-limited', title: 'API rate-limited — harness is parked, will retry automatically' }, @@ -624,11 +602,6 @@ window.marked = marked; body.append(head); // ── agent status text ───────────────────────────────────────── - // Self-reported status (via set_status MCP tool) — only fresh - // while the harness is up. The backend already clears - // `status_text` on stopped containers (#432) so we can render - // unconditionally here: a stopped container simply has no - // `status_text` and skips this block naturally. if (c.status_text) { const nowUnix = Math.floor(Date.now() / 1000); const ageStr = c.status_set_at != null diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index 1f3e3474..ee0c3cdb 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -52,7 +52,6 @@ pub enum SocketReply { AgentMeta { name: String, role: String, - running: bool, hyperhive_rev: Option, status_text: Option, status_set_at: Option, @@ -76,14 +75,12 @@ impl From for SocketReply { hive_sh4re::AgentResponse::AgentMeta { name, role, - running, hyperhive_rev, status_text, status_set_at, } => Self::AgentMeta { name, role, - running, hyperhive_rev, status_text, status_set_at, @@ -110,14 +107,12 @@ impl From for SocketReply { hive_sh4re::ManagerResponse::AgentMeta { name, role, - running, hyperhive_rev, status_text, status_set_at, } => Self::AgentMeta { name, role, - running, hyperhive_rev, status_text, status_set_at, @@ -275,28 +270,21 @@ fn loose_end_kind_label(kind: hive_sh4re::CancelLooseEndKind) -> &'static str { } /// Format helper for `get_agent_meta`: renders an agent's identity + -/// current status as a short human-readable block. `name`, `role`, -/// `hyperhive_rev`, and `running` are always shown; `status` only -/// appears when one is set, otherwise the line reads `status: `. -/// When `running` is false the host has already cleared `status_text` -/// (it would be stale from before the stop, #432) so the status line -/// is implicitly `` in that case — but the explicit `running: -/// no` line tells the caller WHY. +/// current status as a short human-readable block. `name`, `role`, and +/// `hyperhive_rev` are always shown; `status` only appears when one is +/// set, otherwise the line reads `status: `. #[must_use] pub fn format_agent_meta(resp: Result) -> String { match resp { Ok(SocketReply::AgentMeta { name, role, - running, hyperhive_rev, status_text, status_set_at, }) => { let rev = hyperhive_rev.as_deref().unwrap_or(""); - let run = if running { "yes" } else { "no" }; - let mut out = - format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}\nrunning: {run}"); + let mut out = format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}"); match status_text { None => out.push_str("\nstatus: "), Some(s) => { diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index cbf763ad..24facbc8 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -246,12 +246,8 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> } AgentRequest::GetAgentMeta { name } => { let target = name.as_deref().unwrap_or(agent); - // #432: gate status on the target's running state so a - // stopped container's stale on-disk status doesn't leak - // through. Also surface `running` itself so callers can - // tell (e.g. "iris is down" vs "iris has no status set"). - let (status_text, status_set_at, running) = - crate::container_view::read_agent_status_live(target).await; + let (status_text, status_set_at) = + crate::container_view::read_agent_status(target); let role = if target == hive_sh4re::MANAGER_AGENT { "manager" } else { @@ -261,7 +257,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> AgentResponse::AgentMeta { name: target.to_owned(), role, - running, hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), status_text, status_set_at, diff --git a/hive-c0re/src/container_view.rs b/hive-c0re/src/container_view.rs index 68c0458f..f0e56e91 100644 --- a/hive-c0re/src/container_view.rs +++ b/hive-c0re/src/container_view.rs @@ -117,6 +117,14 @@ pub async fn build_all(coord: &Coordinator) -> Vec { }; let deployed_full = locked.get(&format!("agent-{logical}")).map(std::string::String::as_str); let needs_update = crate::auto_update::agent_config_pending(&logical, deployed_full); + // needs_login fires when EITHER the claude session dir is + // missing (boot-time / fresh container) OR the harness wrote + // the auth-failed sentinel because a turn hit 401 (#419). The + // manager has its own session lifecycle and never participates + // in needs_login. + let needs_login = !is_manager + && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) + || auth_failed_sentinel(&logical)); let deployed_sha = deployed_full.map(|s| s[..s.len().min(12)].to_owned()); // Recipient name the broker uses for this agent — sub-agents // are addressed by logical name, the manager by the @@ -132,41 +140,18 @@ pub async fn build_all(coord: &Coordinator) -> Vec { .broker .count_pending_reminders_for(reminder_recipient) .unwrap_or(0); + let last_turn = read_last_turn(&logical); + let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks); + let context_window_tokens = last_turn + .as_ref() + .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens)); + let rate_limited = is_rate_limited(&logical); let extra_links = read_dashboard_links(&logical); + let (status_text, status_set_at) = read_status(&logical); let parent = topology.get(&logical).cloned().flatten(); - let running = lifecycle::is_running(&logical).await; - // Live-only fields (#432) — only meaningful while the harness - // is up. When the container is stopped, sentinel files + - // turn-stats rows + the on-disk status blob are all stale - // snapshots from before the stop, so we clear them here - // rather than letting the dashboard / `get_agent_meta` surface - // misleading values. Static / declared fields (extra_links, - // deployed_sha, pending_reminders, needs_update, parent) stay - // populated regardless of run state. - let (needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at) = - if running { - // needs_login fires when EITHER the claude session dir is - // missing (boot-time / fresh container) OR the harness wrote - // the auth-failed sentinel because a turn hit 401 (#419). The - // manager has its own session lifecycle and never participates - // in needs_login. - let needs_login = !is_manager - && (!claude_has_session(&Coordinator::agent_claude_dir(&logical)) - || auth_failed_sentinel(&logical)); - let last_turn = read_last_turn(&logical); - let ctx_tokens = last_turn.as_ref().map(|(toks, _)| *toks); - let context_window_tokens = last_turn - .as_ref() - .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens)); - let rate_limited = is_rate_limited(&logical); - let (status_text, status_set_at) = read_status(&logical); - (needs_login, ctx_tokens, context_window_tokens, rate_limited, status_text, status_set_at) - } else { - (false, None, None, false, None, None) - }; out.push(ContainerView { port: lifecycle::agent_web_port(&logical), - running, + running: lifecycle::is_running(&logical).await, container: c.clone(), name: logical, is_manager, @@ -234,10 +219,6 @@ fn auth_failed_sentinel(name: &str) -> bool { /// Read the agent's free-text status and the Unix timestamp when it was last set /// (derived from the file's mtime). Returns `(None, None)` when the file is absent /// or empty. `pub` so `agent_server` and `manager_server` can populate `AgentMeta`. -/// -/// NB: callers building `AgentMeta` for a *stopped* container should -/// clear the result — the on-disk status is a stale snapshot from -/// before the stop (#432). Use `read_agent_status_live` for that. pub fn read_agent_status(name: &str) -> (Option, Option) { let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let meta = std::fs::metadata(&path).ok(); @@ -256,34 +237,6 @@ fn read_status(name: &str) -> (Option, Option) { read_agent_status(name) } -/// Wraps `read_agent_status` with the same "stopped containers have -/// stale state" gate `build_all` uses (#432). Returns -/// `(None, None, false)` when the container isn't running so callers -/// don't have to know about the sentinel rules — they just hand back -/// what we give them. -/// -/// Returned tuple is `(status_text, status_set_at, running)`. The -/// `name` argument is the broker-side recipient — `MANAGER_AGENT` for -/// the manager, the logical agent name otherwise — so callers can -/// reuse the same string they used to look the agent up. -pub async fn read_agent_status_live(name: &str) -> (Option, Option, bool) { - // The lifecycle helper wants the on-disk name (`hm1nd` for the - // manager, the bare logical name for sub-agents) and internally - // adds the `h-` prefix. Map the broker-side `MANAGER_AGENT` - // sentinel back to the lifecycle name here so callers don't have - // to bother. - let lifecycle_name = if name == hive_sh4re::MANAGER_AGENT { - lifecycle::MANAGER_NAME - } else { - name - }; - if !lifecycle::is_running(lifecycle_name).await { - return (None, None, false); - } - let (text, set_at) = read_agent_status(name); - (text, set_at, true) -} - /// Read the agent's most recent completed turn from its turn-stats /// `SQLite`: the context-window size (prompt tokens) and the model name. /// Returns `None` when the file is absent or has no rows. Best-effort diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index a89ae68d..fb0ef127 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -494,17 +494,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp } ManagerRequest::GetAgentMeta { name } => { let target = name.as_deref().unwrap_or(MANAGER_AGENT); - // #432: gate status on the target's running state so a - // stopped container's stale on-disk status doesn't leak - // through. Also surface `running` itself so callers can - // tell (e.g. "iris is down" vs "iris has no status set"). - let (status_text, status_set_at, running) = - crate::container_view::read_agent_status_live(target).await; + let (status_text, status_set_at) = + crate::container_view::read_agent_status(target); let role = if target == MANAGER_AGENT { "manager" } else { "agent" }.to_owned(); ManagerResponse::AgentMeta { name: target.to_owned(), role, - running, hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), status_text, status_set_at, diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 32170968..55c9b031 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -517,20 +517,14 @@ pub enum AgentResponse { /// `GetAgentMeta` result: identity + status metadata for an agent. /// `role` is `"agent"` for sub-agents and `"manager"` for the /// manager. `hyperhive_rev` is `None` only when the configured - /// flake URL has no canonical path. `running` reflects whether the - /// target's container is currently up (#432); when it's false, - /// `status_text` / `status_set_at` are intentionally cleared by the - /// host because the on-disk values are stale snapshots from before - /// the stop. `status_text` is the last value written via - /// `SetStatus`, or `None` when none has been set or the agent name - /// is unknown. `status_set_at` is a Unix timestamp (seconds since - /// epoch) of when the status was last written; `None` when no - /// status is set. + /// flake URL has no canonical path. `status_text` is the last value + /// written via `SetStatus`, or `None` when none has been set or the + /// agent name is unknown. `status_set_at` is a Unix timestamp + /// (seconds since epoch) of when the status was last written; + /// `None` when no status is set. AgentMeta { name: String, role: String, - #[serde(default = "default_true")] - running: bool, #[serde(default, skip_serializing_if = "Option::is_none")] hyperhive_rev: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -540,14 +534,6 @@ pub enum AgentResponse { }, } -/// Serde default for the `running` field on legacy wire payloads that -/// predate #432 — older harnesses never serialised it, and `true` -/// matches the historical assumption (the host only knew how to ask -/// about live containers). -fn default_true() -> bool { - true -} - // ----------------------------------------------------------------------------- // Manager socket — /run/hyperhive/manager/mcp.sock on the host, bind-mounted // into the manager container at /run/hive/mcp.sock. @@ -958,15 +944,10 @@ pub enum ManagerResponse { ReminderRollup(ReminderStats), /// Mirror of `AgentResponse::AgentMeta` on the manager surface. /// `role` is `"manager"` for the manager and `"agent"` for any - /// sub-agent looked up by name. `running` is false when the - /// target's container is stopped (#432) — in that case - /// `status_text` / `status_set_at` are cleared by the host so - /// stale pre-stop values don't leak through. + /// sub-agent looked up by name. AgentMeta { name: String, role: String, - #[serde(default = "default_true")] - running: bool, #[serde(default, skip_serializing_if = "Option::is_none")] hyperhive_rev: Option, #[serde(default, skip_serializing_if = "Option::is_none")]