huskies: merge 626_refactor_introduce_services_bundle_and_migrate_appcontext_matrix_transport

This commit is contained in:
dave
2026-04-25 15:04:37 +00:00
parent aeff0b55be
commit 4b089c1ed8
21 changed files with 403 additions and 339 deletions
+78 -151
View File
@@ -1,24 +1,27 @@
//! Matrix bot context — shared state for the Matrix bot (rooms, history, permissions).
use crate::agents::AgentPool;
use crate::chat::ChatTransport;
use crate::http::context::{PermissionDecision, PermissionForward};
use crate::service::timer::TimerStore;
use crate::services::Services;
use matrix_sdk::ruma::{OwnedEventId, OwnedRoomId, OwnedUserId};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::collections::{BTreeMap, HashSet};
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::{RwLock, mpsc, oneshot};
use tokio::sync::RwLock;
use super::history::ConversationHistory;
/// Shared context injected into Matrix event handlers.
#[derive(Clone)]
pub struct BotContext {
pub bot_user_id: OwnedUserId,
/// Shared services bundle (project root, agent pool, bot identity, permissions).
pub services: Arc<Services>,
/// Matrix-specific parsed user ID (e.g. `@timmy:homeserver.local`).
/// Transport-specific — kept separate from `services.bot_user_id` (String)
/// because Matrix SDK APIs require `OwnedUserId` for comparisons and
/// `.localpart()` extraction.
pub matrix_user_id: OwnedUserId,
/// All room IDs the bot listens in.
pub target_room_ids: Vec<OwnedRoomId>,
pub project_root: PathBuf,
pub allowed_users: Vec<String>,
/// Shared, per-room rolling conversation history.
pub history: ConversationHistory,
@@ -28,27 +31,6 @@ pub struct BotContext {
/// bot so it can continue a conversation thread without requiring an
/// explicit `@mention` on every follow-up.
pub bot_sent_event_ids: Arc<TokioMutex<HashSet<OwnedEventId>>>,
/// Receiver for permission requests from the MCP `prompt_permission` tool.
/// During an active chat the bot locks this to poll for incoming requests.
pub perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
/// Per-room pending permission reply senders. When a permission prompt is
/// posted to a room the oneshot sender is stored here; when the user
/// replies (yes/no) the event handler resolves it.
pub pending_perm_replies:
Arc<TokioMutex<HashMap<OwnedRoomId, oneshot::Sender<PermissionDecision>>>>,
/// How long to wait for a user to respond to a permission prompt before
/// denying (fail-closed).
pub permission_timeout_secs: u64,
/// The name the bot uses to refer to itself. Derived from `display_name`
/// in bot.toml; defaults to "Assistant" when unset.
pub bot_name: String,
/// Set of room IDs where ambient mode is active. In ambient mode the bot
/// responds to all messages rather than only addressed ones.
/// Uses a sync mutex since locks are never held across await points.
/// Room IDs are stored as plain strings (platform-agnostic).
pub ambient_rooms: Arc<std::sync::Mutex<HashSet<String>>>,
/// Agent pool for checking agent availability.
pub agents: Arc<AgentPool>,
/// Per-room htop monitoring sessions. Keyed by room ID; each entry holds
/// a stop-signal sender that the background task watches.
pub htop_sessions: super::super::htop::HtopSessions,
@@ -78,12 +60,12 @@ impl BotContext {
/// Each project lives in a subdirectory named after the project, so the
/// effective root for commands is `project_root / active_project_name`.
/// In standalone (single-project) mode this returns `project_root` unchanged.
pub async fn effective_project_root(&self) -> PathBuf {
pub async fn effective_project_root(&self) -> std::path::PathBuf {
if let Some(ref ap) = self.gateway_active_project {
let name = ap.read().await.clone();
self.project_root.join(&name)
self.services.project_root.join(&name)
} else {
self.project_root.clone()
self.services.project_root.clone()
}
}
@@ -138,6 +120,7 @@ impl BotContext {
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::path::PathBuf;
use tokio::sync::mpsc;
@@ -145,6 +128,52 @@ mod tests {
s.parse().unwrap()
}
/// Build a test `Services` bundle with the given project root.
fn test_services(project_root: PathBuf) -> Arc<Services> {
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
Arc::new(Services {
project_root,
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
bot_name: "Assistant".to_string(),
bot_user_id: "@bot:example.com".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
permission_timeout_secs: 120,
})
}
/// Build a minimal `BotContext` for testing with the given Services and
/// optional gateway active project.
fn test_bot_context(
services: Arc<Services>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
gateway_project_urls: BTreeMap<String, String>,
) -> BotContext {
BotContext {
services,
matrix_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
allowed_users: vec![],
history: Arc::new(TokioMutex::new(HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::service::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project,
gateway_projects,
gateway_project_urls,
}
}
#[test]
fn bot_context_is_clone() {
// BotContext must be Clone for the Matrix event handler injection.
@@ -154,36 +183,8 @@ mod tests {
#[tokio::test]
async fn effective_project_root_standalone_returns_project_root() {
// In standalone mode (gateway_active_project is None), the effective root
// must equal the project_root exactly.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/projects/myapp"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::service::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: None,
gateway_projects: vec![],
gateway_project_urls: BTreeMap::new(),
};
let services = test_services(PathBuf::from("/projects/myapp"));
let ctx = test_bot_context(services, None, vec![], BTreeMap::new());
assert_eq!(
ctx.effective_project_root().await,
PathBuf::from("/projects/myapp")
@@ -192,39 +193,17 @@ mod tests {
#[tokio::test]
async fn effective_project_root_gateway_uses_active_project_subdir() {
// In gateway mode, the effective root must be config_dir / active_project_name.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let services = test_services(PathBuf::from("/gateway"));
let active = Arc::new(RwLock::new("huskies".to_string()));
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/gateway"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::service::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: Some(Arc::clone(&active)),
gateway_projects: vec!["huskies".into(), "robot-studio".into()],
gateway_project_urls: BTreeMap::from([
let ctx = test_bot_context(
services,
Some(Arc::clone(&active)),
vec!["huskies".into(), "robot-studio".into()],
BTreeMap::from([
("huskies".into(), "http://localhost:3001".into()),
("robot-studio".into(), "http://localhost:3002".into()),
]),
};
);
assert_eq!(
ctx.effective_project_root().await,
PathBuf::from("/gateway/huskies")
@@ -233,46 +212,23 @@ mod tests {
#[tokio::test]
async fn effective_project_root_gateway_reflects_project_switch() {
// Switching the active project must change the effective root.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let services = test_services(PathBuf::from("/gateway"));
let active = Arc::new(RwLock::new("huskies".to_string()));
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/gateway"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(std::collections::HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(std::collections::HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::service::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: Some(Arc::clone(&active)),
gateway_projects: vec!["huskies".into(), "robot-studio".into()],
gateway_project_urls: BTreeMap::from([
let ctx = test_bot_context(
services,
Some(Arc::clone(&active)),
vec!["huskies".into(), "robot-studio".into()],
BTreeMap::from([
("huskies".into(), "http://localhost:3001".into()),
("robot-studio".into(), "http://localhost:3002".into()),
]),
};
);
assert_eq!(
ctx.effective_project_root().await,
PathBuf::from("/gateway/huskies")
);
// Simulate switch_project changing the active project.
*active.write().await = "robot-studio".to_string();
assert_eq!(
@@ -283,37 +239,8 @@ mod tests {
#[test]
fn bot_context_has_no_require_verified_devices_field() {
// Verification is always on — BotContext no longer has a toggle field.
// This test verifies the struct can be constructed and cloned without it.
let (_perm_tx, perm_rx) = mpsc::unbounded_channel();
let ctx = BotContext {
bot_user_id: make_user_id("@bot:example.com"),
target_room_ids: vec![],
project_root: PathBuf::from("/tmp"),
allowed_users: vec![],
history: Arc::new(TokioMutex::new(HashMap::new())),
history_size: 20,
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
perm_rx: Arc::new(TokioMutex::new(perm_rx)),
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
permission_timeout_secs: 120,
bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(std::sync::Mutex::new(HashSet::new())),
agents: Arc::new(crate::agents::AgentPool::new_test(3000)),
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
transport: Arc::new(crate::chat::transport::whatsapp::WhatsAppTransport::new(
"test-phone".to_string(),
"test-token".to_string(),
"pipeline_notification".to_string(),
)),
timer_store: Arc::new(crate::service::timer::TimerStore::load(
std::path::PathBuf::from("/tmp/timers.json"),
)),
gateway_active_project: None,
gateway_projects: vec![],
gateway_project_urls: BTreeMap::new(),
};
// Clone must work (required by Matrix SDK event handler injection).
let services = test_services(PathBuf::from("/tmp"));
let ctx = test_bot_context(services, None, vec![], BTreeMap::new());
let _cloned = ctx.clone();
}
}
@@ -52,7 +52,7 @@ pub(super) async fn on_room_message(
}
// Ignore the bot's own messages to prevent echo loops.
if ev.sender == ctx.bot_user_id {
if ev.sender == ctx.matrix_user_id {
return;
}
@@ -74,10 +74,15 @@ pub(super) async fn on_room_message(
// Only respond when the bot is directly addressed (mentioned by name/ID),
// when the message is a reply to one of the bot's own messages, or when
// ambient mode is enabled for this room.
let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.bot_user_id)
let is_addressed = mentions_bot(&body, formatted_body.as_deref(), &ctx.matrix_user_id)
|| is_reply_to_bot(ev.content.relates_to.as_ref(), &ctx.bot_sent_event_ids).await;
let room_id_str = incoming_room_id.to_string();
let is_ambient = ctx.ambient_rooms.lock().unwrap().contains(&room_id_str);
let is_ambient = ctx
.services
.ambient_rooms
.lock()
.unwrap()
.contains(&room_id_str);
// Always let "ambient on" through — it is the one command that must work
// even when the bot is not mentioned and ambient mode is off, otherwise
@@ -98,7 +103,7 @@ pub(super) async fn on_room_message(
if is_ambient
&& !is_addressed
&& !is_ambient_on
&& is_addressed_to_other(&body, &ctx.bot_user_id, &ctx.bot_name)
&& is_addressed_to_other(&body, &ctx.matrix_user_id, &ctx.services.bot_name)
{
slog!(
"[matrix-bot] Ignoring ambient message addressed to another bot (sender={})",
@@ -144,8 +149,8 @@ pub(super) async fn on_room_message(
// If there is a pending permission prompt for this room, interpret the
// message as a yes/no response instead of starting a new chat.
{
let mut pending = ctx.pending_perm_replies.lock().await;
if let Some(tx) = pending.remove(&incoming_room_id) {
let mut pending = ctx.services.pending_perm_replies.lock().await;
if let Some(tx) = pending.remove(incoming_room_id.as_str()) {
let decision = if is_permission_approval(&body) {
PermissionDecision::Approve
} else {
@@ -190,8 +195,8 @@ pub(super) async fn on_room_message(
let stripped = crate::chat::util::strip_bot_mention(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
)
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric())
@@ -257,11 +262,11 @@ pub(super) async fn on_room_message(
// the LLM. All commands are registered in commands.rs — no special-casing
// needed here.
let dispatch = super::super::commands::CommandDispatch {
bot_name: &ctx.bot_name,
bot_user_id: ctx.bot_user_id.as_str(),
bot_name: &ctx.services.bot_name,
bot_user_id: ctx.matrix_user_id.as_str(),
project_root: &effective_root,
agents: &ctx.agents,
ambient_rooms: &ctx.ambient_rooms,
agents: &ctx.services.agents,
ambient_rooms: &ctx.services.ambient_rooms,
room_id: &room_id_str,
};
if let Some((response, response_html)) =
@@ -283,8 +288,8 @@ pub(super) async fn on_room_message(
// start) and cannot be handled by the sync command registry.
if let Some(assign_cmd) = super::super::assign::extract_assign_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
) {
let response = match assign_cmd {
super::super::assign::AssignCommand::Assign {
@@ -295,18 +300,18 @@ pub(super) async fn on_room_message(
"[matrix-bot] Handling assign command from {sender}: story {story_number} model={model}"
);
super::super::assign::handle_assign(
&ctx.bot_name,
&ctx.services.bot_name,
&story_number,
&model,
&effective_root,
&ctx.agents,
&ctx.services.agents,
)
.await
}
super::super::assign::AssignCommand::BadArgs => {
format!(
"Usage: `{} assign <number> <model>` (e.g. `assign 42 opus`)",
ctx.bot_name
ctx.services.bot_name
)
}
};
@@ -326,8 +331,8 @@ pub(super) async fn on_room_message(
// and cannot be handled by the sync command registry.
if let Some(htop_cmd) = super::super::htop::extract_htop_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
) {
slog!("[matrix-bot] Handling htop command from {sender}: {htop_cmd:?}");
match htop_cmd {
@@ -344,7 +349,7 @@ pub(super) async fn on_room_message(
&ctx.transport,
&room_id_str,
&ctx.htop_sessions,
Arc::clone(&ctx.agents),
Arc::clone(&ctx.services.agents),
duration_secs,
)
.await;
@@ -357,22 +362,22 @@ pub(super) async fn on_room_message(
// and cannot be handled by the sync command registry.
if let Some(del_cmd) = super::super::delete::extract_delete_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
) {
let response = match del_cmd {
super::super::delete::DeleteCommand::Delete { story_number } => {
slog!("[matrix-bot] Handling delete command from {sender}: story {story_number}");
super::super::delete::handle_delete(
&ctx.bot_name,
&ctx.services.bot_name,
&story_number,
&effective_root,
&ctx.agents,
&ctx.services.agents,
)
.await
}
super::super::delete::DeleteCommand::BadArgs => {
format!("Usage: `{} delete <number>`", ctx.bot_name)
format!("Usage: `{} delete <number>`", ctx.services.bot_name)
}
};
let html = markdown_to_html(&response);
@@ -391,22 +396,22 @@ pub(super) async fn on_room_message(
// and cannot be handled by the sync command registry.
if let Some(rmtree_cmd) = super::super::rmtree::extract_rmtree_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
) {
let response = match rmtree_cmd {
super::super::rmtree::RmtreeCommand::Rmtree { story_number } => {
slog!("[matrix-bot] Handling rmtree command from {sender}: story {story_number}");
super::super::rmtree::handle_rmtree(
&ctx.bot_name,
&ctx.services.bot_name,
&story_number,
&effective_root,
&ctx.agents,
&ctx.services.agents,
)
.await
}
super::super::rmtree::RmtreeCommand::BadArgs => {
format!("Usage: `{} rmtree <number>`", ctx.bot_name)
format!("Usage: `{} rmtree <number>`", ctx.services.bot_name)
}
};
let html = markdown_to_html(&response);
@@ -425,8 +430,8 @@ pub(super) async fn on_room_message(
// be handled by the sync command registry.
if let Some(start_cmd) = super::super::start::extract_start_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
) {
let response = match start_cmd {
super::super::start::StartCommand::Start {
@@ -437,18 +442,18 @@ pub(super) async fn on_room_message(
"[matrix-bot] Handling start command from {sender}: story {story_number} agent={agent_hint:?}"
);
super::super::start::handle_start(
&ctx.bot_name,
&ctx.services.bot_name,
&story_number,
agent_hint.as_deref(),
&effective_root,
&ctx.agents,
&ctx.services.agents,
)
.await
}
super::super::start::StartCommand::BadArgs => {
format!(
"Usage: `{} start <number>` or `{} start <number> opus`",
ctx.bot_name, ctx.bot_name
ctx.services.bot_name, ctx.services.bot_name
)
}
};
@@ -468,17 +473,17 @@ pub(super) async fn on_room_message(
// conversation history and cannot be handled by the sync command registry.
if super::super::reset::extract_reset_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
)
.is_some()
{
slog!("[matrix-bot] Handling reset command from {sender}");
let response = super::super::reset::handle_reset(
&ctx.bot_name,
&ctx.services.bot_name,
&incoming_room_id,
&ctx.history,
&ctx.project_root,
&ctx.services.project_root,
)
.await;
let html = markdown_to_html(&response);
@@ -497,8 +502,8 @@ pub(super) async fn on_room_message(
// and cannot be handled by the sync command registry.
if super::super::rebuild::extract_rebuild_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
)
.is_some()
{
@@ -514,9 +519,12 @@ pub(super) async fn on_room_message(
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
let response =
super::super::rebuild::handle_rebuild(&ctx.bot_name, &ctx.project_root, &ctx.agents)
.await;
let response = super::super::rebuild::handle_rebuild(
&ctx.services.bot_name,
&ctx.services.project_root,
&ctx.services.agents,
)
.await;
let html = markdown_to_html(&response);
if let Ok(msg_id) = ctx
.transport
@@ -534,8 +542,8 @@ pub(super) async fn on_room_message(
if let Some(ref active_project) = ctx.gateway_active_project {
let stripped = crate::chat::util::strip_bot_mention(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
)
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric())
@@ -574,14 +582,14 @@ pub(super) async fn on_room_message(
// be handled by the sync command registry.
if let Some(timer_cmd) = crate::service::timer::extract_timer_command(
&user_message,
&ctx.bot_name,
ctx.bot_user_id.as_str(),
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
) {
slog!("[matrix-bot] Handling timer command from {sender}: {timer_cmd:?}");
let response = crate::service::timer::handle_timer_command(
timer_cmd,
&ctx.timer_store,
&ctx.project_root,
&ctx.services.project_root,
)
.await;
let html = markdown_to_html(&response);
@@ -620,7 +628,7 @@ pub(super) async fn handle_message(
// The prompt is just the current message with sender attribution.
// Prior conversation context is carried by the Claude Code session.
let bot_name = &ctx.bot_name;
let bot_name = &ctx.services.bot_name;
let active_project_ctx = if let Some(ref ap) = ctx.gateway_active_project {
let name = ap.read().await.clone();
format!("[Active project: {name}]\n")
@@ -671,7 +679,7 @@ pub(super) async fn handle_message(
// The gateway proxies tool calls to the active project automatically.
// In standalone mode, use the project root directly.
let project_root_str = if ctx.is_gateway() {
ctx.project_root.to_string_lossy().to_string()
ctx.services.project_root.to_string_lossy().to_string()
} else {
ctx.effective_project_root()
.await
@@ -701,7 +709,7 @@ pub(super) async fn handle_message(
// Lock the permission receiver for the duration of this chat session.
// Permission requests from the MCP `prompt_permission` tool arrive here.
let mut perm_rx_guard = ctx.perm_rx.lock().await;
let mut perm_rx_guard = ctx.services.perm_rx.lock().await;
let result = loop {
tokio::select! {
@@ -726,18 +734,18 @@ pub(super) async fn handle_message(
// Store the MCP oneshot sender so the event handler can
// resolve it when the user replies yes/no.
ctx.pending_perm_replies
ctx.services.pending_perm_replies
.lock()
.await
.insert(room_id.clone(), perm_fwd.response_tx);
.insert(room_id.to_string(), perm_fwd.response_tx);
// Spawn a timeout task: auto-deny if the user does not respond.
let pending = Arc::clone(&ctx.pending_perm_replies);
let timeout_room_id = room_id.clone();
let pending = Arc::clone(&ctx.services.pending_perm_replies);
let timeout_room_id = room_id.to_string();
let timeout_transport = Arc::clone(&ctx.transport);
let timeout_room_id_str = room_id_str.clone();
let timeout_sent_ids = Arc::clone(&ctx.bot_sent_event_ids);
let timeout_secs = ctx.permission_timeout_secs;
let timeout_secs = ctx.services.permission_timeout_secs;
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(timeout_secs)).await;
if let Some(tx) = pending.lock().await.remove(&timeout_room_id) {
@@ -844,7 +852,7 @@ pub(super) async fn handle_message(
}
// Persist to disk so history survives server restarts.
save_history(&ctx.project_root, &guard);
save_history(&ctx.services.project_root, &guard);
}
}
+18 -28
View File
@@ -1,14 +1,13 @@
//! Matrix bot run loop — connects to the homeserver and processes sync events.
use crate::agents::AgentPool;
use crate::services::Services;
use crate::slog;
use matrix_sdk::ruma::OwnedRoomId;
use matrix_sdk::{Client, LoopCtrl, config::SyncSettings};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::{RwLock, mpsc, watch};
use tokio::sync::{RwLock, watch};
use super::context::BotContext;
use super::format::{format_startup_announcement, markdown_to_html};
@@ -22,16 +21,15 @@ use super::verification::{on_room_verification_request, on_to_device_verificatio
#[allow(clippy::too_many_arguments)]
pub async fn run_bot(
config: super::super::config::BotConfig,
project_root: PathBuf,
services: Arc<Services>,
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
watcher_rx_auto: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<crate::http::context::PermissionForward>>>,
agents: Arc<AgentPool>,
shutdown_rx: watch::Receiver<Option<crate::rebuild::ShutdownReason>>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
gateway_project_urls: std::collections::BTreeMap<String, String>,
) -> Result<(), String> {
let project_root = &services.project_root;
let store_path = project_root.join(".huskies").join("matrix_store");
let client = Client::builder()
.homeserver_url(config.homeserver.as_deref().unwrap_or_default())
@@ -174,20 +172,22 @@ pub async fn run_bot(
let poller_poll_interval = config.aggregated_notifications_poll_interval_secs;
let poller_enabled = config.aggregated_notifications_enabled;
let persisted = load_history(&project_root);
let persisted = load_history(project_root);
slog!(
"[matrix-bot] Loaded persisted conversation history for {} room(s)",
persisted.len()
);
// Restore persisted ambient rooms from config.
let persisted_ambient: HashSet<String> = config.ambient_rooms.iter().cloned().collect();
if !persisted_ambient.is_empty() {
slog!(
"[matrix-bot] Restored ambient mode for {} room(s): {:?}",
persisted_ambient.len(),
persisted_ambient
);
// Ambient rooms are already restored in Services from bot.toml config.
{
let ambient = services.ambient_rooms.lock().unwrap();
if !ambient.is_empty() {
slog!(
"[matrix-bot] Restored ambient mode for {} room(s): {:?}",
ambient.len(),
*ambient
);
}
}
// Create the transport abstraction based on the configured transport type.
@@ -222,11 +222,7 @@ pub async fn run_bot(
}
};
let bot_name = config
.display_name
.clone()
.unwrap_or_else(|| "Assistant".to_string());
let announce_bot_name = bot_name.clone();
let announce_bot_name = services.bot_name.clone();
let timer_store = Arc::new(crate::service::timer::TimerStore::load(
project_root.join(".huskies").join("timers.json"),
@@ -238,19 +234,13 @@ pub async fn run_bot(
);
let ctx = BotContext {
bot_user_id,
services,
matrix_user_id: bot_user_id,
target_room_ids,
project_root,
allowed_users: config.allowed_users,
history: Arc::new(TokioMutex::new(persisted)),
history_size: config.history_size,
bot_sent_event_ids: Arc::new(TokioMutex::new(HashSet::new())),
perm_rx,
pending_perm_replies: Arc::new(TokioMutex::new(HashMap::new())),
permission_timeout_secs: config.permission_timeout_secs,
bot_name,
ambient_rooms: Arc::new(std::sync::Mutex::new(persisted_ambient)),
agents,
htop_sessions: Arc::new(TokioMutex::new(HashMap::new())),
transport: Arc::clone(&transport),
timer_store,
+7 -12
View File
@@ -30,13 +30,12 @@ pub mod transport_impl;
pub use bot::{ConversationEntry, ConversationRole, RoomConversation};
pub use config::BotConfig;
use crate::agents::AgentPool;
use crate::http::context::PermissionForward;
use crate::io::watcher::WatcherEvent;
use crate::rebuild::ShutdownReason;
use crate::services::Services;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
use tokio::sync::{RwLock, broadcast, watch};
/// Attempt to start the Matrix bot.
///
@@ -48,9 +47,9 @@ use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
/// posts stage-transition messages to all configured rooms whenever a work
/// item moves between pipeline stages.
///
/// `perm_rx` is the permission-request receiver shared with the MCP
/// `prompt_permission` tool. The bot locks it during active chat sessions
/// to surface permission prompts to the Matrix room and relay user decisions.
/// `services` is the shared services bundle containing the agent pool,
/// permission plumbing, and bot identity. The bot accesses these via
/// `Arc<Services>` rather than holding its own copies.
///
/// `shutdown_rx` is a watch channel that delivers a `ShutdownReason` when the
/// server is about to stop (SIGINT/SIGTERM or rebuild). The bot uses this to
@@ -65,8 +64,7 @@ use tokio::sync::{Mutex as TokioMutex, RwLock, broadcast, mpsc, watch};
pub fn spawn_bot(
project_root: &Path,
watcher_tx: broadcast::Sender<WatcherEvent>,
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
agents: Arc<AgentPool>,
services: Arc<Services>,
shutdown_rx: watch::Receiver<Option<ShutdownReason>>,
gateway_active_project: Option<Arc<RwLock<String>>>,
gateway_projects: Vec<String>,
@@ -95,17 +93,14 @@ pub fn spawn_bot(
config.effective_room_ids()
);
let root = project_root.to_path_buf();
let watcher_rx = watcher_tx.subscribe();
let watcher_rx_auto = watcher_tx.subscribe();
let handle = tokio::spawn(async move {
if let Err(e) = bot::run_bot(
config,
root,
services,
watcher_rx,
watcher_rx_auto,
perm_rx,
agents,
shutdown_rx,
gateway_active_project,
gateway_projects,