huskies: merge 1144 story Gateway trampoline-restart: detached helper survives the gateway's own death

This commit is contained in:
dave
2026-05-19 18:13:26 +00:00
parent 20ec690e22
commit de638603cd
12 changed files with 656 additions and 1 deletions
@@ -99,6 +99,11 @@ pub struct BotContext {
/// each event is processed at most once. Insert the event ID before any
/// side-effecting work; return early if the insert returns `false`.
pub handled_incoming_event_ids: Arc<TokioMutex<SeenEventIds>>,
/// In gateway mode: the port the gateway is listening on.
///
/// Used by the "rebuild gateway" command to construct the health-check URL
/// passed to the trampoline. `None` in standalone single-project mode.
pub gateway_port: Option<u16>,
}
impl BotContext {
@@ -293,6 +298,7 @@ mod tests {
handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new(
SEEN_EVENT_IDS_CAP,
))),
gateway_port: None,
}
}
@@ -9,6 +9,23 @@ pub fn format_startup_announcement(bot_name: &str) -> String {
format!("{bot_name} is online.")
}
/// Format the ready announcement sent after a successful gateway trampoline restart.
///
/// Returns "gateway X.Y.Z ready" using the compiled-in crate version so the
/// operator can confirm which binary is running after a rebuild.
pub fn format_gateway_ready_announcement() -> String {
format!("gateway {} ready", env!("CARGO_PKG_VERSION"))
}
/// Format the failure announcement sent when the trampoline rolls back to the
/// previous binary.
///
/// `reason` is the human-readable failure description from the trampoline
/// (e.g. "port 3000 already in use").
pub fn format_gateway_rollback_announcement(reason: &str) -> String {
format!("Gateway rebuild failed: {reason}. Previous version restored.")
}
/// Convert a Markdown string to an HTML string using pulldown-cmark.
///
/// Enables the standard extension set (tables, footnotes, strikethrough,
@@ -19,6 +19,28 @@ use super::super::verification::check_sender_verified;
use super::handle_message;
/// Return `true` when the message is a "rebuild gateway" command addressed to the bot.
///
/// The command is recognised case-insensitively as `rebuild gateway` after stripping
/// the bot mention prefix so both `@Timmy rebuild gateway` and `Timmy rebuild gateway`
/// match.
fn extract_rebuild_gateway_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool {
let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let (cmd, rest) = match trimmed.split_once(char::is_whitespace) {
Some((c, r)) => (c, r.trim()),
None => return false,
};
cmd.eq_ignore_ascii_case("rebuild")
&& rest
.split_whitespace()
.next()
.map(|w| w.eq_ignore_ascii_case("gateway"))
.unwrap_or(false)
}
/// Evaluate a `switch <arg>` command against the live project store.
///
/// Reads valid project names from the store at call time so newly added
@@ -657,6 +679,87 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
return;
}
// In gateway mode, intercept "rebuild gateway" and route it through the
// detached trampoline so the process swap survives any bash-tool kill cascade.
if ctx.gateway_active_project.is_some()
&& extract_rebuild_gateway_command(
&user_message,
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
)
{
slog!("[matrix-bot] Handling 'rebuild gateway' command from {sender}");
let ack = "Rebuilding gateway\u{2026} this may take a moment.";
let ack_html = markdown_to_html(ack);
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, ack, &ack_html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
let config_dir = ctx.services.project_root.clone();
let gateway_port: u16 = ctx.gateway_port.unwrap_or(3000);
match crate::gateway::rebuild::rebuild_gateway(&config_dir, gateway_port).await {
Ok(()) => {
// Trampoline is running detached — it kills this gateway and starts
// the new one, which will post "gateway X.Y.Z ready" on startup.
}
Err(e) => {
let msg = format!("Gateway rebuild failed: {e}");
let html = markdown_to_html(&msg);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &msg, &html).await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
}
}
return;
}
// In gateway mode, intercept "rebuild gateway" before the plain "rebuild"
// handler so the trampoline path is used instead of a direct re-exec.
if ctx.gateway_port.is_some()
&& super::super::super::rebuild::extract_rebuild_gateway_command(
&user_message,
&ctx.services.bot_name,
ctx.matrix_user_id.as_str(),
)
.is_some()
{
slog!("[matrix-bot] Handling rebuild-gateway command from {sender}");
let ack = "Rebuilding gateway… this may take a moment. \
The gateway will announce itself when the new version is ready.";
let ack_html = markdown_to_html(ack);
if let Ok(msg_id) = ctx
.transport
.send_message(&room_id_str, ack, &ack_html)
.await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
let port = ctx.gateway_port.unwrap_or(3000);
match crate::gateway::rebuild::rebuild_gateway(&ctx.services.project_root, port).await {
Ok(()) => {
// Trampoline is running — this gateway will be killed shortly.
// No further reply needed; the new gateway posts "gateway X.Y.Z ready".
}
Err(e) => {
let msg = format!("Gateway rebuild failed: {e}");
let html = markdown_to_html(&msg);
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &msg, &html).await
&& let Ok(event_id) = msg_id.parse()
{
ctx.bot_sent_event_ids.lock().await.insert(event_id);
}
}
}
return;
}
// Check for the rebuild command, which requires async agent and process ops
// and cannot be handled by the sync command registry.
if super::super::super::rebuild::extract_rebuild_command(
+13 -1
View File
@@ -39,6 +39,7 @@ pub async fn run_bot(
gateway_event_rx: Option<
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
>,
gateway_port: Option<u16>,
) -> Result<(), String> {
let project_root = &services.project_root;
let store_path = project_root.join(".huskies").join("matrix_store");
@@ -334,6 +335,7 @@ pub async fn run_bot(
handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new(
super::context::SEEN_EVENT_IDS_CAP,
))),
gateway_port,
};
slog!(
@@ -408,7 +410,17 @@ pub async fn run_bot(
// bot is online. This runs once per process start — the sync loop handles
// reconnects internally so this code is never reached again on a network
// blip or sync resumption.
let announce_msg = format_startup_announcement(&announce_bot_name);
//
// When started by the trampoline the message is specialised:
// - HUSKIES_TRAMPOLINE_STARTED=1 → "gateway X.Y.Z ready"
// - HUSKIES_TRAMPOLINE_FAILURE=<reason> → rollback failure notice
let announce_msg = if let Ok(reason) = std::env::var("HUSKIES_TRAMPOLINE_FAILURE") {
super::format::format_gateway_rollback_announcement(&reason)
} else if std::env::var("HUSKIES_TRAMPOLINE_STARTED").is_ok() {
super::format::format_gateway_ready_announcement()
} else {
format_startup_announcement(&announce_bot_name)
};
let announce_html = markdown_to_html(&announce_msg);
slog!("[matrix-bot] Sending startup announcement: {announce_msg}");
for room_id in &announce_room_ids {
+2
View File
@@ -94,6 +94,7 @@ pub fn spawn_bot(
gateway_event_rx: Option<
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
>,
gateway_port: Option<u16>,
) -> Option<tokio::task::AbortHandle> {
let config = match BotConfig::load(project_root) {
Some(c) => c,
@@ -132,6 +133,7 @@ pub fn spawn_bot(
gateway_projects_store,
timer_store,
gateway_event_rx,
gateway_port,
)
.await
{
@@ -40,6 +40,43 @@ pub fn extract_rebuild_command(
}
}
/// Parse a "rebuild gateway" command from a raw message body.
///
/// Returns `Some(RebuildCommand)` only when the stripped message begins with
/// "rebuild gateway" (case-insensitive). A plain "rebuild" without the
/// "gateway" qualifier returns `None` so it falls through to the standard
/// server rebuild handler.
pub fn extract_rebuild_gateway_command(
message: &str,
bot_name: &str,
bot_user_id: &str,
) -> Option<RebuildCommand> {
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
let trimmed = stripped
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let (cmd, rest) = trimmed.split_once(char::is_whitespace)?;
if !cmd.eq_ignore_ascii_case("rebuild") {
return None;
}
let qualifier = rest
.trim()
.trim_start_matches(|c: char| !c.is_alphanumeric());
let first_word = match qualifier.split_once(char::is_whitespace) {
Some((w, _)) => w,
None => qualifier,
};
if first_word.eq_ignore_ascii_case("gateway") {
Some(RebuildCommand)
} else {
None
}
}
/// Handle a rebuild command: trigger server rebuild and restart.
///
/// Returns a string describing the outcome. On build failure the error