set_status: add status_set_at timestamp (mtime of status file)

This commit is contained in:
damocles 2026-05-23 01:08:50 +02:00 committed by Mara
parent fe2933b213
commit 8e8e8a771f
7 changed files with 75 additions and 14 deletions

View file

@ -54,6 +54,7 @@ pub enum SocketReply {
role: String, role: String,
hyperhive_rev: Option<String>, hyperhive_rev: Option<String>,
status_text: Option<String>, status_text: Option<String>,
status_set_at: Option<i64>,
}, },
} }
@ -76,11 +77,13 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
role, role,
hyperhive_rev, hyperhive_rev,
status_text, status_text,
status_set_at,
} => Self::Whoami { } => Self::Whoami {
name, name,
role, role,
hyperhive_rev, hyperhive_rev,
status_text, status_text,
status_set_at,
}, },
} }
} }
@ -106,11 +109,13 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
role, role,
hyperhive_rev, hyperhive_rev,
status_text, status_text,
status_set_at,
} => Self::Whoami { } => Self::Whoami {
name, name,
role, role,
hyperhive_rev, hyperhive_rev,
status_text, status_text,
status_set_at,
}, },
} }
} }
@ -275,11 +280,22 @@ pub fn format_whoami(resp: Result<SocketReply, anyhow::Error>) -> String {
role, role,
hyperhive_rev, hyperhive_rev,
status_text, status_text,
status_set_at,
}) => { }) => {
let rev = hyperhive_rev.as_deref().unwrap_or("<unknown>"); let rev = hyperhive_rev.as_deref().unwrap_or("<unknown>");
let mut out = format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}"); let mut out = format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}");
if let Some(s) = status_text { if let Some(s) = status_text {
out.push_str(&format!("\nstatus: {s}")); let age = status_set_at.and_then(|ts| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs();
let secs = now.saturating_sub(ts as u64);
Some(format_age_secs(secs))
});
if let Some(a) = age {
out.push_str(&format!("\nstatus: {s} (set {a} ago)"));
} else {
out.push_str(&format!("\nstatus: {s}"));
}
} }
out out
} }
@ -289,6 +305,19 @@ pub fn format_whoami(resp: Result<SocketReply, anyhow::Error>) -> String {
} }
} }
/// Format a duration in seconds as a human-readable age string.
fn format_age_secs(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86400)
}
}
/// Common envelope around every MCP tool handler: pre-log → run → /// Common envelope around every MCP tool handler: pre-log → run →
/// post-log. The inbox-status hint used to be appended to every tool /// post-log. The inbox-status hint used to be appended to every tool
/// result; that lives in the wake prompt + UI header now, so tool /// result; that lives in the wake prompt + UI header now, so tool

View file

@ -17,6 +17,8 @@
// ─── helpers ──────────────────────────────────────────────────────────── // ─── helpers ────────────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
const fmtAgeSecs = (s) => s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s/60)}m`
: s < 86400 ? `${Math.floor(s/3600)}h` : `${Math.floor(s/86400)}d`;
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => const esc = (s) => String(s).replace(/[&<>"]/g, (c) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c]) ({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
); );
@ -729,9 +731,16 @@
// ── agent status text ───────────────────────────────────────── // ── agent status text ─────────────────────────────────────────
if (c.status_text) { if (c.status_text) {
body.append(el('div', { class: 'agent-status', title: 'agent self-reported status' }, const nowUnix = Math.floor(Date.now() / 1000);
const ageStr = c.status_set_at != null
? ` (set ${fmtAgeSecs(nowUnix - c.status_set_at)} ago)` : '';
body.append(el('div', {
class: 'agent-status',
title: `agent self-reported status${ageStr}`,
},
el('span', { class: 'status-icon' }, '◈ '), el('span', { class: 'status-icon' }, '◈ '),
c.status_text, c.status_text,
el('span', { class: 'status-age' }, ageStr),
)); ));
} }

View file

@ -216,6 +216,7 @@ a:hover {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.agent-status .status-icon { opacity: 0.65; } .agent-status .status-icon { opacity: 0.65; }
.agent-status .status-age { opacity: 0.5; font-size: 0.9em; margin-left: 0.2em; }
.container-row.tombstone { .container-row.tombstone {
border-style: dashed; border-style: dashed;

View file

@ -221,12 +221,13 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
} }
} }
AgentRequest::Whoami => { AgentRequest::Whoami => {
let status_text = crate::container_view::read_agent_status_text(agent); let (status_text, status_set_at) = crate::container_view::read_agent_status(agent);
AgentResponse::Whoami { AgentResponse::Whoami {
name: agent.to_owned(), name: agent.to_owned(),
role: "agent".to_owned(), role: "agent".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text, status_text,
status_set_at,
} }
} }
AgentRequest::SetStatus { text } => { AgentRequest::SetStatus { text } => {

View file

@ -82,6 +82,11 @@ pub struct ContainerView {
/// is absent or empty — the agent hasn't set one yet. /// is absent or empty — the agent hasn't set one yet.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub status_text: Option<String>, pub status_text: Option<String>,
/// Unix timestamp (seconds since epoch) when the status was last written.
/// Derived from the `hyperhive-status` file's mtime. `None` when no
/// status is set.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub status_set_at: Option<i64>,
} }
/// Build the full container list. Wraps `lifecycle::list()` and /// Build the full container list. Wraps `lifecycle::list()` and
@ -124,7 +129,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
.and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens)); .and_then(|(_, model)| resolve_ctx_window(model, &coord.context_window_tokens));
let rate_limited = is_rate_limited(&logical); let rate_limited = is_rate_limited(&logical);
let extra_links = read_dashboard_links(&logical); let extra_links = read_dashboard_links(&logical);
let status_text = read_status_text(&logical); let (status_text, status_set_at) = read_status(&logical);
out.push(ContainerView { out.push(ContainerView {
port: lifecycle::agent_web_port(&logical), port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await, running: lifecycle::is_running(&logical).await,
@ -140,6 +145,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
rate_limited, rate_limited,
extra_links, extra_links,
status_text, status_text,
status_set_at,
}); });
} }
out out
@ -179,18 +185,25 @@ fn is_rate_limited(name: &str) -> bool {
.exists() .exists()
} }
/// Read the agent's free-text status set via `mcp__hyperhive__set_status`. /// Read the agent's free-text status and the Unix timestamp when it was last set
/// Returns `None` when the file is absent or empty — best-effort, never panics. /// (derived from the file's mtime). Returns `(None, None)` when the file is absent
/// `pub` so `agent_server` and `manager_server` can include it in `Whoami` responses. /// or empty. `pub` so `agent_server` and `manager_server` can populate `Whoami`.
pub fn read_agent_status_text(name: &str) -> Option<String> { pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status"); let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
let s = std::fs::read_to_string(path).ok()?; let meta = std::fs::metadata(&path).ok();
let trimmed = s.trim(); let s = std::fs::read_to_string(&path).ok();
if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) } let text = s.as_deref().map(str::trim).filter(|t| !t.is_empty()).map(str::to_owned);
let mtime = meta.and_then(|m| {
m.modified().ok().and_then(|t| {
t.duration_since(std::time::UNIX_EPOCH).ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
})
});
if text.is_none() { (None, None) } else { (text, mtime) }
} }
fn read_status_text(name: &str) -> Option<String> { fn read_status(name: &str) -> (Option<String>, Option<i64>) {
read_agent_status_text(name) read_agent_status(name)
} }
/// Read the agent's most recent completed turn from its turn-stats /// Read the agent's most recent completed turn from its turn-stats

View file

@ -481,12 +481,14 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResp
} }
} }
ManagerRequest::Whoami => { ManagerRequest::Whoami => {
let status_text = crate::container_view::read_agent_status_text(MANAGER_AGENT); let (status_text, status_set_at) =
crate::container_view::read_agent_status(MANAGER_AGENT);
ManagerResponse::Whoami { ManagerResponse::Whoami {
name: MANAGER_AGENT.to_owned(), name: MANAGER_AGENT.to_owned(),
role: "manager".to_owned(), role: "manager".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake), hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text, status_text,
status_set_at,
} }
} }
ManagerRequest::SetStatus { text } => { ManagerRequest::SetStatus { text } => {

View file

@ -513,6 +513,8 @@ pub enum AgentResponse {
/// response). `hyperhive_rev` is `None` only when the configured /// response). `hyperhive_rev` is `None` only when the configured
/// flake URL has no canonical path. `status_text` is the last /// flake URL has no canonical path. `status_text` is the last
/// value written via `SetStatus`, or `None` if none has been set. /// value written via `SetStatus`, or `None` if none has been set.
/// `status_set_at` is a Unix timestamp (seconds since epoch) of
/// when the status was last written; `None` when no status is set.
Whoami { Whoami {
name: String, name: String,
role: String, role: String,
@ -520,6 +522,8 @@ pub enum AgentResponse {
hyperhive_rev: Option<String>, hyperhive_rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
status_text: Option<String>, status_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status_set_at: Option<i64>,
}, },
} }
@ -936,5 +940,7 @@ pub enum ManagerResponse {
hyperhive_rev: Option<String>, hyperhive_rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
status_text: Option<String>, status_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status_set_at: Option<i64>,
}, },
} }