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,
hyperhive_rev: Option<String>,
status_text: Option<String>,
status_set_at: Option<i64>,
},
}
@ -76,11 +77,13 @@ impl From<hive_sh4re::AgentResponse> for SocketReply {
role,
hyperhive_rev,
status_text,
status_set_at,
} => Self::Whoami {
name,
role,
hyperhive_rev,
status_text,
status_set_at,
},
}
}
@ -106,11 +109,13 @@ impl From<hive_sh4re::ManagerResponse> for SocketReply {
role,
hyperhive_rev,
status_text,
status_set_at,
} => Self::Whoami {
name,
role,
hyperhive_rev,
status_text,
status_set_at,
},
}
}
@ -275,12 +280,23 @@ pub fn format_whoami(resp: Result<SocketReply, anyhow::Error>) -> String {
role,
hyperhive_rev,
status_text,
status_set_at,
}) => {
let rev = hyperhive_rev.as_deref().unwrap_or("<unknown>");
let mut out = format!("name: {name}\nrole: {role}\nhyperhive_rev: {rev}");
if let Some(s) = status_text {
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
}
Ok(SocketReply::Err(m)) => format!("whoami failed: {m}"),
@ -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 →
/// 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

View file

@ -17,6 +17,8 @@
// ─── helpers ────────────────────────────────────────────────────────────
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) =>
({ '&':'&amp;', '<':'&lt;', '>':'&gt;', '"':'&quot;' }[c])
);
@ -729,9 +731,16 @@
// ── agent 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' }, '◈ '),
c.status_text,
el('span', { class: 'status-age' }, ageStr),
));
}

View file

@ -216,6 +216,7 @@ a:hover {
text-overflow: ellipsis;
}
.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 {
border-style: dashed;

View file

@ -221,12 +221,13 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
}
}
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 {
name: agent.to_owned(),
role: "agent".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text,
status_set_at,
}
}
AgentRequest::SetStatus { text } => {

View file

@ -82,6 +82,11 @@ pub struct ContainerView {
/// is absent or empty — the agent hasn't set one yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
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
@ -124,7 +129,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
.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 = read_status_text(&logical);
let (status_text, status_set_at) = read_status(&logical);
out.push(ContainerView {
port: lifecycle::agent_web_port(&logical),
running: lifecycle::is_running(&logical).await,
@ -140,6 +145,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
rate_limited,
extra_links,
status_text,
status_set_at,
});
}
out
@ -179,18 +185,25 @@ fn is_rate_limited(name: &str) -> bool {
.exists()
}
/// Read the agent's free-text status set via `mcp__hyperhive__set_status`.
/// Returns `None` when the file is absent or empty — best-effort, never panics.
/// `pub` so `agent_server` and `manager_server` can include it in `Whoami` responses.
pub fn read_agent_status_text(name: &str) -> Option<String> {
/// 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 `Whoami`.
pub fn read_agent_status(name: &str) -> (Option<String>, Option<i64>) {
let path = Coordinator::agent_notes_dir(name).join("hyperhive-status");
let s = std::fs::read_to_string(path).ok()?;
let trimmed = s.trim();
if trimmed.is_empty() { None } else { Some(trimmed.to_owned()) }
let meta = std::fs::metadata(&path).ok();
let s = std::fs::read_to_string(&path).ok();
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> {
read_agent_status_text(name)
fn read_status(name: &str) -> (Option<String>, Option<i64>) {
read_agent_status(name)
}
/// 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 => {
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 {
name: MANAGER_AGENT.to_owned(),
role: "manager".to_owned(),
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text,
status_set_at,
}
}
ManagerRequest::SetStatus { text } => {

View file

@ -513,6 +513,8 @@ pub enum AgentResponse {
/// response). `hyperhive_rev` is `None` only when the configured
/// flake URL has no canonical path. `status_text` is the last
/// 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 {
name: String,
role: String,
@ -520,6 +522,8 @@ pub enum AgentResponse {
hyperhive_rev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status_text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
status_set_at: Option<i64>,
},
}