set_status: add status_set_at timestamp (mtime of status file)
This commit is contained in:
parent
fe2933b213
commit
8e8e8a771f
7 changed files with 75 additions and 14 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
({ '&':'&', '<':'<', '>':'>', '"':'"' }[c])
|
({ '&':'&', '<':'<', '>':'>', '"':'"' }[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),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 } => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 } => {
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue