feat(#1137): rich unread summary in loose ends and wake signal

- hive-sh4re: UnreadMatrix gains summary: String field (per-room breakdown)
- hive-matrix-mcp/protocol: add RoomUnread struct + UnreadSummary request
- hive-matrix-mcp/handlers: collect_unread() fetches per-room data;
  single-unread rooms include truncated last-message body + sender;
  multi-unread rooms carry count only
- hive-matrix-mcp/wake: format_unread_summary() builds wake body from
  RoomUnread slice; terse one-liner for single-room/single-message,
  bulleted list for multi-room; always appends read-hint
- hive-matrix-mcp/timeline: wake body now covers all rooms with unread
  at fire time, not just the triggering event; falls back to per-event
  teaser if notification counts haven't updated yet
- hive-ag3nt/mcp: matrix_unread_summary() replaces matrix_unread_rooms();
  UnreadMatrix loose end carries per-room summary lines; render shows
  room breakdown with sender: body for single-unread rooms
This commit is contained in:
atlas 2026-06-03 12:52:24 +02:00
commit 68e30b857c
19 changed files with 421 additions and 108 deletions

View file

@ -115,7 +115,11 @@ pub fn agent_network_ip(name: &str, subnet_cidr: &str) -> Option<String> {
}
let base_u32 = u32::from_be_bytes([octets[0], octets[1], octets[2], octets[3]]);
// Mask off host bits to get the true network address.
let mask = if prefix_len == 0 { 0u32 } else { !0u32 << (32 - prefix_len) };
let mask = if prefix_len == 0 {
0u32
} else {
!0u32 << (32 - prefix_len)
};
let network_base = base_u32 & mask;
let host_count: u32 = 1u32.checked_shl(32 - prefix_len).unwrap_or(0);
// `.0` = network, `.1` = bridge gateway, last = broadcast → 3 reserved.
@ -625,7 +629,9 @@ async fn prebuild_toplevel(name: &str, flake_ref: &str) -> Result<()> {
pub async fn list() -> Result<Vec<String>> {
let stdout = crate::priv_client::list_containers().await?;
Ok(stdout.lines().map(str::trim)
Ok(stdout
.lines()
.map(str::trim)
.filter(|line| line.starts_with(AGENT_PREFIX))
.map(str::to_owned)
.collect())
@ -1025,7 +1031,8 @@ pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<(
/// our default resource caps. Goes under `/run/systemd/system/...` so it's
/// ephemeral (regenerated on every spawn / rebuild).
async fn set_resource_limits(container: &str) -> Result<()> {
crate::priv_client::write_resource_limits(container, DEFAULT_MEMORY_MAX, DEFAULT_CPU_QUOTA).await
crate::priv_client::write_resource_limits(container, DEFAULT_MEMORY_MAX, DEFAULT_CPU_QUOTA)
.await
}
async fn systemd_daemon_reload() -> Result<()> {
@ -1076,9 +1083,21 @@ fn bind_child_agent_dirs(child: &str, binds: &mut Vec<BindMount>) {
for dir in [&state_dir, &harness_dir, &config_dir] {
let _ = std::fs::create_dir_all(dir);
}
binds.push(BindMount { host_path: state_dir, container_path: format!("/agents/{child}/state"), read_only: false });
binds.push(BindMount { host_path: harness_dir, container_path: format!("/agents/{child}/harness"), read_only: false });
binds.push(BindMount { host_path: config_dir, container_path: format!("/agents/{child}/config"), read_only: false });
binds.push(BindMount {
host_path: state_dir,
container_path: format!("/agents/{child}/state"),
read_only: false,
});
binds.push(BindMount {
host_path: harness_dir,
container_path: format!("/agents/{child}/harness"),
read_only: false,
});
binds.push(BindMount {
host_path: config_dir,
container_path: format!("/agents/{child}/config"),
read_only: false,
});
}
async fn set_nspawn_flags(
@ -1103,25 +1122,49 @@ async fn set_nspawn_flags(
let claude_mount = container_claude_mount(agent_name);
let mut binds: Vec<BindMount> = vec![
BindMount { host_path: runtime_dir.to_string_lossy().into_owned(), container_path: CONTAINER_RUNTIME_MOUNT.to_owned(), read_only: false },
BindMount { host_path: claude_dir.to_string_lossy().into_owned(), container_path: claude_mount, read_only: false },
BindMount { host_path: HOST_SHARED_ROOT.to_owned(), container_path: CONTAINER_SHARED_MOUNT.to_owned(), read_only: false },
BindMount {
host_path: runtime_dir.to_string_lossy().into_owned(),
container_path: CONTAINER_RUNTIME_MOUNT.to_owned(),
read_only: false,
},
BindMount {
host_path: claude_dir.to_string_lossy().into_owned(),
container_path: claude_mount,
read_only: false,
},
BindMount {
host_path: HOST_SHARED_ROOT.to_owned(),
container_path: CONTAINER_SHARED_MOUNT.to_owned(),
read_only: false,
},
];
// Own state, harness, and config dirs — same for every agent including
// the manager. Config is RO: an agent must not edit its own config; changes
// only ever flow through the approval queue.
binds.push(BindMount { host_path: notes_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/state"), read_only: false });
binds.push(BindMount {
host_path: notes_dir.to_string_lossy().into_owned(),
container_path: format!("/agents/{agent_name}/state"),
read_only: false,
});
if let Some(state_parent) = notes_dir.parent() {
let harness_dir = state_parent.join("harness");
if !harness_dir.exists() {
let _ = std::fs::create_dir_all(&harness_dir);
}
binds.push(BindMount { host_path: harness_dir.to_string_lossy().into_owned(), container_path: format!("/agents/{agent_name}/harness"), read_only: false });
binds.push(BindMount {
host_path: harness_dir.to_string_lossy().into_owned(),
container_path: format!("/agents/{agent_name}/harness"),
read_only: false,
});
}
let own_config = format!("{HOST_AGENTS_ROOT}/{agent_name}/config");
std::fs::create_dir_all(&own_config).with_context(|| format!("create {own_config}"))?;
binds.push(BindMount { host_path: own_config, container_path: format!("/agents/{agent_name}/config"), read_only: true });
binds.push(BindMount {
host_path: own_config,
container_path: format!("/agents/{agent_name}/config"),
read_only: true,
});
// Topology-driven child mounts: every direct child of this agent gets
// its state, harness, and config dirs bind-mounted RW so the parent
@ -1152,8 +1195,16 @@ async fn set_nspawn_flags(
// fires first (e.g. cold start with no agents).
std::fs::create_dir_all(HOST_META_ROOT)
.with_context(|| format!("create {HOST_META_ROOT}"))?;
binds.push(BindMount { host_path: HOST_APPLIED_ROOT.to_owned(), container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(), read_only: true });
binds.push(BindMount { host_path: HOST_META_ROOT.to_owned(), container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(), read_only: true });
binds.push(BindMount {
host_path: HOST_APPLIED_ROOT.to_owned(),
container_path: CONTAINER_MANAGER_APPLIED_MOUNT.to_owned(),
read_only: true,
});
binds.push(BindMount {
host_path: HOST_META_ROOT.to_owned(),
container_path: crate::meta::CONTAINER_MANAGER_META_MOUNT.to_owned(),
read_only: true,
});
}
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
@ -1174,7 +1225,11 @@ async fn set_nspawn_flags(
} else if let Err(e) = crate::priv_client::chmod_socket_dir(agent_name, 0o777).await {
tracing::warn!(%agent_name, error = ?e, "chmod socket dir failed");
}
binds.push(BindMount { host_path: socket_dir.to_string_lossy().into_owned(), container_path: socket_dir.to_string_lossy().into_owned(), read_only: false });
binds.push(BindMount {
host_path: socket_dir.to_string_lossy().into_owned(),
container_path: socket_dir.to_string_lossy().into_owned(),
read_only: false,
});
// Network isolation: when HIVE_NETWORK_ISOLATION=1 is set (by the
// hive-network.nix module's `isolateContainers` option), flip the
@ -1382,7 +1437,11 @@ mod tests {
let ip = agent_network_ip("alice", "10.42.0.0/24").expect("should produce an IP");
let octets: Vec<u8> = ip.split('.').map(|o| o.parse().unwrap()).collect();
assert_eq!(&octets[..3], &[10, 42, 0], "wrong /24 prefix");
assert!(octets[3] >= 2 && octets[3] <= 254, "host byte {}", octets[3]);
assert!(
octets[3] >= 2 && octets[3] <= 254,
"host byte {}",
octets[3]
);
}
#[test]
@ -1414,7 +1473,7 @@ mod tests {
assert!(agent_network_ip("alice", "notanip/24").is_none());
assert!(agent_network_ip("alice", "10.0.0.0/33").is_none()); // prefix > 32
assert!(agent_network_ip("alice", "10.0.0.0/31").is_none()); // too small
assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix
assert!(agent_network_ip("alice", "10.0.0.0").is_none()); // no prefix
}
#[test]
@ -1424,8 +1483,10 @@ mod tests {
// after host-bit masking.
let from_bridge = agent_network_ip("alice", "10.42.0.1/24");
let from_canonical = agent_network_ip("alice", "10.42.0.0/24");
assert_eq!(from_bridge, from_canonical,
"bridge-IP and canonical-network form should normalize to the same result");
assert_eq!(
from_bridge, from_canonical,
"bridge-IP and canonical-network form should normalize to the same result"
);
// Result must still be in .2-.254.
let ip = from_bridge.unwrap();
let last: u8 = ip.rsplit('.').next().unwrap().parse().unwrap();