Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a47a837e5 | ||
|
|
a32f37c3ba | ||
|
|
5640832116 |
7 changed files with 138 additions and 2 deletions
|
|
@ -8,6 +8,7 @@
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::coordinator::Coordinator;
|
use crate::coordinator::Coordinator;
|
||||||
|
|
@ -63,6 +64,17 @@ pub struct ContainerView {
|
||||||
/// for stopped containers.
|
/// for stopped containers.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub active_model: Option<String>,
|
pub active_model: Option<String>,
|
||||||
|
/// The agent's current free-text status (`set_status`). `None` when
|
||||||
|
/// unset, or when the container isn't running — see
|
||||||
|
/// [`read_agent_status_live`] for why a stopped agent's on-disk
|
||||||
|
/// status is treated as stale rather than surfaced.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_text: Option<String>,
|
||||||
|
/// When `status_text` was last set, RFC 3339 UTC on the wire (see
|
||||||
|
/// `hive_sh4re::wire_time`). `None` exactly when `status_text` is
|
||||||
|
/// `None`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_set_at: Option<DateTime<Utc>>,
|
||||||
/// The agent's turn loop is parked: the container is up and still
|
/// The agent's turn loop is parked: the container is up and still
|
||||||
/// serving its web UI / MCP daemons, but it drives no turns and its
|
/// serving its web UI / MCP daemons, but it drives no turns and its
|
||||||
/// inbox messages queue unacked until it resumes. Sourced from the
|
/// inbox messages queue unacked until it resumes. Sourced from the
|
||||||
|
|
@ -155,6 +167,17 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec<ContainerView>
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// Same running-gate as `active_model` just above, and the same
|
||||||
|
// rule `read_agent_status_live` applies for its other callers —
|
||||||
|
// duplicated inline rather than calling that helper because it
|
||||||
|
// would re-derive `running` with its own `lifecycle::is_running`
|
||||||
|
// shell-out when this loop already has it for free.
|
||||||
|
let (status_text, status_set_at) = if running {
|
||||||
|
let (text, set_at) = read_agent_status(&logical);
|
||||||
|
(text, set_at.map(hive_sh4re::wire_time::from_secs))
|
||||||
|
} else {
|
||||||
|
(None, None)
|
||||||
|
};
|
||||||
let paused = Coordinator::is_paused(&logical);
|
let paused = Coordinator::is_paused(&logical);
|
||||||
let (cpu_quota, memory_max) = crate::resource_limits::effective_from(
|
let (cpu_quota, memory_max) = crate::resource_limits::effective_from(
|
||||||
&limits,
|
&limits,
|
||||||
|
|
@ -173,6 +196,8 @@ pub async fn build_all(hive: &crate::coordinator::HiveEnv) -> Vec<ContainerView>
|
||||||
deployed_sha,
|
deployed_sha,
|
||||||
parent,
|
parent,
|
||||||
active_model,
|
active_model,
|
||||||
|
status_text,
|
||||||
|
status_set_at,
|
||||||
paused,
|
paused,
|
||||||
cpu_quota,
|
cpu_quota,
|
||||||
memory_max,
|
memory_max,
|
||||||
|
|
|
||||||
|
|
@ -817,6 +817,8 @@ mod tests {
|
||||||
deployed_sha: None,
|
deployed_sha: None,
|
||||||
parent: None,
|
parent: None,
|
||||||
active_model: None,
|
active_model: None,
|
||||||
|
status_text: None,
|
||||||
|
status_set_at: None,
|
||||||
paused: false,
|
paused: false,
|
||||||
cpu_quota: "200%".to_owned(),
|
cpu_quota: "200%".to_owned(),
|
||||||
memory_max: "4G".to_owned(),
|
memory_max: "4G".to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -384,6 +384,9 @@ async fn handle_agent_status(coord: &Arc<Coordinator>) -> HostResponse {
|
||||||
pending_reminders: 0,
|
pending_reminders: 0,
|
||||||
parent: v.parent,
|
parent: v.parent,
|
||||||
paused: v.paused,
|
paused: v.paused,
|
||||||
|
active_model: v.active_model,
|
||||||
|
status_text: v.status_text,
|
||||||
|
status_set_at: v.status_set_at,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
HostResponse::agent_statuses(rows)
|
HostResponse::agent_statuses(rows)
|
||||||
|
|
|
||||||
|
|
@ -254,6 +254,8 @@ mod tests {
|
||||||
deployed_sha: None,
|
deployed_sha: None,
|
||||||
parent: None,
|
parent: None,
|
||||||
active_model: None,
|
active_model: None,
|
||||||
|
status_text: None,
|
||||||
|
status_set_at: None,
|
||||||
paused: false,
|
paused: false,
|
||||||
cpu_quota: "200%".to_owned(),
|
cpu_quota: "200%".to_owned(),
|
||||||
memory_max: "4G".to_owned(),
|
memory_max: "4G".to_owned(),
|
||||||
|
|
|
||||||
|
|
@ -517,8 +517,27 @@ pub struct HiveUrls {
|
||||||
pub matrix: Option<String>,
|
pub matrix: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
/// Wire-schema version for [`HostResponse`]. Bump only for a breaking
|
||||||
|
/// change — removing, renaming, or retyping an existing field; adding a
|
||||||
|
/// new `Option`/`skip_serializing_if` field (the pattern every field on
|
||||||
|
/// this struct already follows) is not breaking and needs no bump. A
|
||||||
|
/// client compares this against its own compiled-in copy of the constant
|
||||||
|
/// and warns (does not fail the request) on mismatch — see
|
||||||
|
/// `hivectl::client::version_mismatch_hint`, the one place that reads it.
|
||||||
|
pub const HOST_SOCK_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct HostResponse {
|
pub struct HostResponse {
|
||||||
|
/// Wire-schema version this response was built against, see
|
||||||
|
/// [`HOST_SOCK_VERSION`]. `#[serde(default)]` so a pre-version daemon
|
||||||
|
/// (or a hand-built test fixture) deserializes as `0` rather than
|
||||||
|
/// failing to parse — `0` reads as "older than any real version" to a
|
||||||
|
/// mismatch check, which is the correct answer for wire predating
|
||||||
|
/// this field. Always populated on the write side: every constructor
|
||||||
|
/// below routes through `Self::default()`, whose `Default` impl (not
|
||||||
|
/// derived — see below) sets this to the current version.
|
||||||
|
#[serde(default)]
|
||||||
|
pub version: u32,
|
||||||
pub ok: bool,
|
pub ok: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
|
|
@ -568,6 +587,31 @@ pub struct HostResponse {
|
||||||
pub quota: Option<Vec<QuotaRow>>,
|
pub quota: Option<Vec<QuotaRow>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hand-written rather than `#[derive(Default)]` for one reason:
|
||||||
|
/// `version` must default to [`HOST_SOCK_VERSION`], not `u32::default()`
|
||||||
|
/// (`0`). Every field below it is the same all-`None`/`false`/empty shape
|
||||||
|
/// `derive(Default)` would have produced — only `version` differs — and
|
||||||
|
/// every constructor in `impl HostResponse` builds through `..Self::default()`,
|
||||||
|
/// so this is the one place that has to get it right.
|
||||||
|
impl Default for HostResponse {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
version: HOST_SOCK_VERSION,
|
||||||
|
ok: false,
|
||||||
|
error: None,
|
||||||
|
agents: None,
|
||||||
|
approvals: None,
|
||||||
|
urls: None,
|
||||||
|
agent_statuses: None,
|
||||||
|
agent_exists: None,
|
||||||
|
queued_dags: None,
|
||||||
|
nodes: None,
|
||||||
|
messages: Vec::new(),
|
||||||
|
quota: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl HostResponse {
|
impl HostResponse {
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn success() -> Self {
|
pub fn success() -> Self {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
//! `HostRequest::AgentStatus` return, plus the per-account matrix
|
//! `HostRequest::AgentStatus` return, plus the per-account matrix
|
||||||
//! identity shape surfaced by `GetAgentMeta`.
|
//! identity shape surfaced by `GetAgentMeta`.
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// One entry in a `ListDescendants` result.
|
/// One entry in a `ListDescendants` result.
|
||||||
|
|
@ -64,6 +65,24 @@ pub struct AgentStatusRow {
|
||||||
/// Parent in the topology tree. `None` marks a root-level agent.
|
/// Parent in the topology tree. `None` marks a root-level agent.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub parent: Option<String>,
|
pub parent: Option<String>,
|
||||||
|
/// The Claude model the agent's harness is currently using. Mirrors
|
||||||
|
/// `container_view::ContainerView::active_model`: `None` when the
|
||||||
|
/// agent has never started a turn, the field is absent from its
|
||||||
|
/// harness state file, or the container isn't running.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub active_model: Option<String>,
|
||||||
|
/// The agent's current free-text status, set via the `set_status`
|
||||||
|
/// tool. `None` when unset or the container isn't running — a
|
||||||
|
/// stopped agent's on-disk status is a stale snapshot from before
|
||||||
|
/// the stop (same rule `container_view::read_agent_status_live`
|
||||||
|
/// applies for every other reader of this data).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_text: Option<String>,
|
||||||
|
/// When `status_text` was last set, RFC 3339 UTC on the wire (see
|
||||||
|
/// `hive_sh4re::wire_time`). `None` exactly when `status_text` is
|
||||||
|
/// `None`.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub status_set_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One matrix identity an agent can act as, surfaced in `GetAgentMeta`'s
|
/// One matrix identity an agent can act as, surfaced in `GetAgentMeta`'s
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,53 @@ pub async fn request(socket: &Path, req: HostRequest) -> Result<HostResponse> {
|
||||||
bail!("server closed connection without responding");
|
bail!("server closed connection without responding");
|
||||||
}
|
}
|
||||||
let resp: HostResponse = serde_json::from_str(line.trim())?;
|
let resp: HostResponse = serde_json::from_str(line.trim())?;
|
||||||
|
if let Some(hint) = version_mismatch_hint(resp.version) {
|
||||||
|
eprintln!("{hint}");
|
||||||
|
}
|
||||||
Ok(resp)
|
Ok(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Message to print (stderr, non-fatal) when the daemon's `HostResponse`
|
||||||
|
/// wire version differs from what this `hivectl` build was compiled
|
||||||
|
/// against. `None` when they match. Split out from the print site so it's
|
||||||
|
/// unit-testable, same pattern as `connect_hint` above — a mismatch is not
|
||||||
|
/// a request failure (most fields still decode fine across a one-version
|
||||||
|
/// skew), just something the operator should know before trusting a
|
||||||
|
/// response that might be missing a field this build expects.
|
||||||
|
fn version_mismatch_hint(daemon_version: u32) -> Option<String> {
|
||||||
|
(daemon_version != hive_host_sock::HOST_SOCK_VERSION).then(|| {
|
||||||
|
format!(
|
||||||
|
"warning: hive-c0re host.sock wire version {daemon_version} does not match \
|
||||||
|
hivectl's {} — upgrade hivectl or the daemon; some response fields may be \
|
||||||
|
missing or misread",
|
||||||
|
hive_host_sock::HOST_SOCK_VERSION
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{ErrorKind, Path, connect_hint};
|
use super::{ErrorKind, Path, connect_hint, version_mismatch_hint};
|
||||||
|
|
||||||
|
/// A daemon on the same version this `hivectl` was built against gets
|
||||||
|
/// no warning — the common case, and the only one that must stay silent.
|
||||||
|
#[test]
|
||||||
|
fn matching_version_has_no_hint() {
|
||||||
|
assert!(version_mismatch_hint(hive_host_sock::HOST_SOCK_VERSION).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mismatch names both versions, so the operator knows which side
|
||||||
|
/// (hivectl or the daemon) is behind, not just that they disagree.
|
||||||
|
#[test]
|
||||||
|
fn mismatched_version_names_both_numbers() {
|
||||||
|
let other = hive_host_sock::HOST_SOCK_VERSION + 1;
|
||||||
|
let h = version_mismatch_hint(other).expect("versions differ");
|
||||||
|
assert!(h.contains(&other.to_string()), "{h}");
|
||||||
|
assert!(
|
||||||
|
h.contains(&hive_host_sock::HOST_SOCK_VERSION.to_string()),
|
||||||
|
"{h}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn hint(kind: ErrorKind) -> String {
|
fn hint(kind: ErrorKind) -> String {
|
||||||
connect_hint(kind, Path::new("/run/hyperhive/host.sock"))
|
connect_hint(kind, Path::new("/run/hyperhive/host.sock"))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue