feat: multi-account matrix daemon (account-routed mcp surface)
This commit is contained in:
parent
73c53c7d4a
commit
8e79eb4f26
7 changed files with 613 additions and 156 deletions
|
|
@ -1,25 +1,31 @@
|
|||
//! `hive-matrix-daemon` binary — long-running matrix-sdk Client + sync
|
||||
//! loop per agent. Bridges incoming room events to hyperhive wake
|
||||
//! signals and serves the unix socket the stdio MCP bridge talks to.
|
||||
//! loop per matrix account. Bridges incoming room events to hyperhive
|
||||
//! wake signals and serves the unix socket the stdio MCP bridge talks to.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Read access token from `paths::token_file()` (fail clean if absent).
|
||||
//! 2. Whoami probe → recover `user_id` + `device_id` → restore
|
||||
//! matrix-sdk session (no login flow).
|
||||
//! 3. Install the message-event handler that fires hyperhive wakes.
|
||||
//! 4. Spawn the unix socket listener for the MCP bridge.
|
||||
//! 5. Run sync forever.
|
||||
//! 1. Read the configured account list (`accounts::configured()` —
|
||||
//! `HIVE_MATRIX_ACCOUNTS` JSON, or the single legacy account).
|
||||
//! 2. For each account: whoami probe → recover `user_id` + `device_id`
|
||||
//! → restore matrix-sdk session (no login flow), install the
|
||||
//! message-event handler, and spawn its own sync loop.
|
||||
//! 3. Serve the unix socket against an account→Client registry; each
|
||||
//! MCP request routes to the account named in its `account` field
|
||||
//! (the primary account when omitted).
|
||||
//!
|
||||
//! Standalone-degraded boot: missing token file → exit 0 cleanly so
|
||||
//! systemd's `ConditionPathExists=` doesn't have to be perfectly
|
||||
//! synced with hive-c0re's token-provisioning timing.
|
||||
//! Standalone-degraded boot: the PRIMARY account having no token file →
|
||||
//! exit 0 cleanly so systemd's path-watcher restarts us once hive-c0re
|
||||
//! provisions it. A SECONDARY account missing its token is skipped (the
|
||||
//! daemon still serves the others).
|
||||
//!
|
||||
//! Stale-token recovery: handled in `client::build_and_restore` — see
|
||||
//! that module for the `M_UNKNOWN_TOKEN` detection + cleanup flow.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use matrix_sdk::config::SyncSettings;
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use matrix_sdk::{Client, config::SyncSettings};
|
||||
|
||||
mod accounts;
|
||||
mod client;
|
||||
mod handlers;
|
||||
mod paths;
|
||||
|
|
@ -28,6 +34,14 @@ mod socket;
|
|||
mod timeline;
|
||||
mod wake;
|
||||
|
||||
use accounts::{AccountCfg, Registry};
|
||||
|
||||
/// A per-account sync loop, boxed so loops for N accounts can be driven
|
||||
/// concurrently on the main task. Deliberately NOT `Send`: matrix-sdk's
|
||||
/// sync future isn't `Send`, so these run on the current task (via
|
||||
/// `select_all`) rather than `tokio::spawn`/`JoinSet`.
|
||||
type SyncLoop = std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>>>>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
|
|
@ -38,68 +52,130 @@ async fn main() -> Result<()> {
|
|||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
|
||||
let homeserver = paths::homeserver_url();
|
||||
let token_file = paths::token_file();
|
||||
let state_dir = paths::matrix_state_dir();
|
||||
let cfgs = accounts::configured().context("read matrix account config")?;
|
||||
let mcp_socket = paths::daemon_socket();
|
||||
let hyperhive_socket = paths::hyperhive_socket();
|
||||
let multi = cfgs.len() > 1;
|
||||
let primary = cfgs[0].name.clone();
|
||||
let mut registry = Registry::new(primary);
|
||||
let mut sync_loops: Vec<SyncLoop> = Vec::new();
|
||||
|
||||
if !tokio::fs::try_exists(&token_file).await.unwrap_or(false) {
|
||||
tracing::warn!(
|
||||
path = %token_file.display(),
|
||||
"matrix token file absent; exiting cleanly (hive-c0re will provision \
|
||||
it on first agent registration, then systemd restarts us)"
|
||||
);
|
||||
for (idx, cfg) in cfgs.into_iter().enumerate() {
|
||||
let is_primary = idx == 0;
|
||||
// Account-tag the wakes only in multi-account mode so single-
|
||||
// account wake bodies stay byte-identical to the legacy format.
|
||||
let tag = multi.then(|| cfg.name.clone());
|
||||
match bring_up_account(&cfg, &hyperhive_socket, tag).await {
|
||||
Ok(Some((client, sync_loop))) => {
|
||||
registry.insert(cfg.name, client);
|
||||
sync_loops.push(sync_loop);
|
||||
}
|
||||
// No token yet: for the primary that means the daemon isn't
|
||||
// useful — exit 0 like the legacy single-account path so the
|
||||
// systemd path-watcher restarts us when the token appears.
|
||||
Ok(None) if is_primary => {
|
||||
tracing::warn!(
|
||||
account = %cfg.name,
|
||||
"primary matrix account has no token yet; exiting cleanly \
|
||||
(systemd restarts us when hive-c0re provisions it)"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(account = %cfg.name, "secondary matrix account has no token; skipping");
|
||||
}
|
||||
// The primary failing to restore is fatal (propagate so
|
||||
// systemd retries on a transient blip — matches legacy
|
||||
// behaviour); a secondary failing is logged and skipped.
|
||||
Err(e) if is_primary => return Err(e.context("bring up primary matrix account")),
|
||||
Err(e) => {
|
||||
tracing::error!(account = %cfg.name, error = %format!("{e:#}"), "secondary matrix account failed to restore; skipping");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if registry.is_empty() {
|
||||
tracing::warn!("no matrix accounts restored; exiting cleanly");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
homeserver,
|
||||
token_file = %token_file.display(),
|
||||
state_dir = %state_dir.display(),
|
||||
"hive-matrix-daemon starting"
|
||||
);
|
||||
|
||||
let matrix_client = client::build_and_restore(&homeserver, &token_file, &state_dir)
|
||||
.await
|
||||
.context("build matrix client")?;
|
||||
|
||||
timeline::install_message_handler(&matrix_client, hyperhive_socket.clone());
|
||||
|
||||
// Spawn the unix socket server before sync starts so the MCP
|
||||
// bridge can connect as soon as the first claude turn fires. The
|
||||
// socket dispatches against the same `Client` we sync on, so any
|
||||
// tool call benefits from the sync state-cache.
|
||||
let socket_client = matrix_client.clone();
|
||||
// Serve the socket against the registry. Spawned before driving the
|
||||
// sync loops so the MCP bridge can connect as soon as the first
|
||||
// claude turn fires.
|
||||
let registry = Arc::new(registry);
|
||||
let socket_listener = mcp_socket.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = socket::serve(&socket_listener, socket_client).await {
|
||||
if let Err(e) = socket::serve(&socket_listener, registry).await {
|
||||
tracing::error!(error = %e, "mcp socket server exited");
|
||||
}
|
||||
});
|
||||
|
||||
// Sync forever; matrix-sdk handles reconnection internally. After
|
||||
// every sync, sweep pending invites and wake the agent for any new
|
||||
// one: the StrippedRoomMemberEvent handler dispatched unreliably
|
||||
// (cold-start invites + handler races produced no wake), so
|
||||
// invite-waking lives on this post-sync sweep with a dedup set.
|
||||
let invite_socket = std::sync::Arc::new(hyperhive_socket);
|
||||
let invite_notified =
|
||||
std::sync::Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
let sweep_client = matrix_client.clone();
|
||||
let sync_settings = SyncSettings::default();
|
||||
matrix_client
|
||||
.sync_with_callback(sync_settings, move |_response| {
|
||||
let client = sweep_client.clone();
|
||||
let socket = invite_socket.clone();
|
||||
let notified = invite_notified.clone();
|
||||
async move {
|
||||
timeline::sweep_invites(&client, &socket, ¬ified).await;
|
||||
matrix_sdk::LoopCtrl::Continue
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("matrix-sdk sync loop exited")?;
|
||||
// Drive all per-account sync loops concurrently on this task (they
|
||||
// aren't `Send`, so no `tokio::spawn`). matrix-sdk reconnects
|
||||
// internally, so any loop returning is exceptional — log it and exit
|
||||
// so systemd restarts the whole daemon cleanly.
|
||||
let (result, idx, _rest) = futures_util::future::select_all(sync_loops).await;
|
||||
match result {
|
||||
Ok(()) => tracing::warn!(
|
||||
account_index = idx,
|
||||
"a matrix sync loop exited cleanly; restarting daemon"
|
||||
),
|
||||
Err(e) => {
|
||||
tracing::error!(account_index = idx, error = %format!("{e:#}"), "a matrix sync loop errored; restarting daemon");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore one account's client (when its token exists), install its
|
||||
/// message handler, and build its sync loop. Returns
|
||||
/// `Ok(Some((client, sync_loop)))` when the account came up, `Ok(None)`
|
||||
/// when its token file is absent (caller decides primary-vs-secondary
|
||||
/// handling). The returned sync loop is driven by the caller.
|
||||
async fn bring_up_account(
|
||||
cfg: &AccountCfg,
|
||||
hyperhive_socket: &std::path::Path,
|
||||
tag: Option<String>,
|
||||
) -> Result<Option<(Client, SyncLoop)>> {
|
||||
let homeserver = cfg.homeserver();
|
||||
if !tokio::fs::try_exists(&cfg.token_file)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
tracing::info!(
|
||||
account = %cfg.name,
|
||||
homeserver,
|
||||
token_file = %cfg.token_file.display(),
|
||||
state_dir = %cfg.state_dir.display(),
|
||||
"bringing up matrix account"
|
||||
);
|
||||
let client = client::build_and_restore(&homeserver, &cfg.token_file, &cfg.state_dir)
|
||||
.await
|
||||
.with_context(|| format!("build matrix client for account {}", cfg.name))?;
|
||||
timeline::install_message_handler(&client, hyperhive_socket.to_path_buf(), tag.clone());
|
||||
|
||||
let sync_client = client.clone();
|
||||
let cb_client = client.clone();
|
||||
let invite_socket = Arc::new(hyperhive_socket.to_path_buf());
|
||||
let invite_notified = Arc::new(tokio::sync::Mutex::new(std::collections::HashSet::new()));
|
||||
let sync_loop: SyncLoop = Box::pin(async move {
|
||||
sync_client
|
||||
.sync_with_callback(SyncSettings::default(), move |_response| {
|
||||
let client = cb_client.clone();
|
||||
let socket = invite_socket.clone();
|
||||
let notified = invite_notified.clone();
|
||||
let tag = tag.clone();
|
||||
async move {
|
||||
timeline::sweep_invites(&client, &socket, ¬ified, tag.as_deref()).await;
|
||||
matrix_sdk::LoopCtrl::Continue
|
||||
}
|
||||
})
|
||||
.await
|
||||
.context("matrix-sdk sync loop exited")?;
|
||||
Ok(())
|
||||
});
|
||||
Ok(Some((client, sync_loop)))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue