From b0f19eb0c5f968f0cd75467b9f42de08f4a1927c Mon Sep 17 00:00:00 2001 From: Huskies Agent Date: Sat, 18 Jul 2026 10:29:40 +0000 Subject: [PATCH] huskies: merge 1212 story Sled chat messages show "workspace" instead of the real project name --- .../src/service/notifications/io/listener.rs | 10 +- server/src/service/notifications/io/mod.rs | 112 ++++++++++++++++++ server/src/service/notifications/mod.rs | 1 + server/src/startup/tick_loop.rs | 14 +-- 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/server/src/service/notifications/io/listener.rs b/server/src/service/notifications/io/listener.rs index f24e4015..8315dd68 100644 --- a/server/src/service/notifications/io/listener.rs +++ b/server/src/service/notifications/io/listener.rs @@ -21,10 +21,10 @@ use super::super::format::{ format_disk_recovery_notification, format_disk_warning_notification, format_error_notification, format_merge_auto_retry_notification, format_new_items_notification, format_oauth_account_swapped, format_oauth_accounts_exhausted, format_rate_limit_notification, - project_display_name, truncate_gate_output, + truncate_gate_output, }; use super::super::route::rooms_for_notification; -use super::{find_story_name_any_stage, read_story_name}; +use super::{find_story_name_any_stage, read_story_name, resolve_project_display_name}; /// Format and send any pending new-item-creation notifications as a single /// combined message, then clear the pending buffer. @@ -70,8 +70,7 @@ pub fn spawn_notification_listener( let mut rx = watcher_rx; // Load initial config; re-loaded on ConfigChanged events. let mut config = ProjectConfig::load(&project_root).unwrap_or_default(); - let mut project_name = - project_display_name(config.gateway_project.as_deref(), &project_root); + let mut project_name = resolve_project_display_name(&config, &project_root); // Tracks when a rate-limit notification was last sent for each // "story_id:agent_name" key, to debounce repeated warnings. let mut rate_limit_last_notified: HashMap = HashMap::new(); @@ -465,8 +464,7 @@ pub fn spawn_notification_listener( EventAction::ReloadConfig => { if let Ok(new_cfg) = ProjectConfig::load(&project_root) { config = new_cfg; - project_name = - project_display_name(config.gateway_project.as_deref(), &project_root); + project_name = resolve_project_display_name(&config, &project_root); } } EventAction::Skip => {} diff --git a/server/src/service/notifications/io/mod.rs b/server/src/service/notifications/io/mod.rs index 94faffaa..4fd95c00 100644 --- a/server/src/service/notifications/io/mod.rs +++ b/server/src/service/notifications/io/mod.rs @@ -6,6 +6,10 @@ use std::path::Path; +use crate::config::ProjectConfig; + +use super::format::project_display_name; + mod listener; pub use listener::spawn_notification_listener; @@ -39,3 +43,111 @@ pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Str fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> String { read_story_name(project_root, "", item_id) } + +/// Resolve the human-readable project display name using the full precedence +/// chain (story 1212): config `gateway_project` → `HUSKIES_GATEWAY_PROJECT` +/// env → `HUSKIES_PROJECT_NAME` env → project-root directory basename → +/// literal `"project"`. +/// +/// Shared by the notification listener (initial load and `ReloadConfig`) and +/// the gateway relay startup path so precedence cannot drift between them — +/// the env-var overrides matter because sled containers commonly mount the +/// project at a generic path (e.g. `/workspace`), so the directory-basename +/// fallback alone produces a useless display name. +pub fn resolve_project_display_name(config: &ProjectConfig, project_root: &Path) -> String { + let gateway_project = config + .gateway_project + .clone() + .or_else(|| std::env::var("HUSKIES_GATEWAY_PROJECT").ok()) + .or_else(|| std::env::var("HUSKIES_PROJECT_NAME").ok()); + project_display_name(gateway_project.as_deref(), project_root) +} + +#[cfg(test)] +mod resolve_project_display_name_tests { + use super::*; + + /// `HUSKIES_GATEWAY_PROJECT` and `HUSKIES_PROJECT_NAME` are read by + /// `resolve_project_display_name` and are process-global, so tests that + /// touch them must not run concurrently with each other. + fn env_test_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + LOCK.lock().unwrap_or_else(|p| p.into_inner()) + } + + #[test] + fn falls_back_to_huskies_project_name_when_config_and_gateway_env_absent() { + let _guard = env_test_lock(); + // SAFETY: serialised by `env_test_lock` — no other test in this + // process reads/writes these two vars concurrently. + unsafe { + std::env::remove_var("HUSKIES_GATEWAY_PROJECT"); + std::env::set_var("HUSKIES_PROJECT_NAME", "my-sled-project"); + } + + let config = ProjectConfig::default(); + let root = Path::new("/workspace"); + let result = resolve_project_display_name(&config, root); + + unsafe { + std::env::remove_var("HUSKIES_PROJECT_NAME"); + } + + assert_eq!(result, "my-sled-project"); + } + + #[test] + fn config_gateway_project_wins_over_all_env_vars() { + let _guard = env_test_lock(); + unsafe { + std::env::set_var("HUSKIES_GATEWAY_PROJECT", "env-gateway-project"); + std::env::set_var("HUSKIES_PROJECT_NAME", "env-project-name"); + } + + let config = ProjectConfig { + gateway_project: Some("configured-name".to_string()), + ..Default::default() + }; + let result = resolve_project_display_name(&config, Path::new("/workspace")); + + unsafe { + std::env::remove_var("HUSKIES_GATEWAY_PROJECT"); + std::env::remove_var("HUSKIES_PROJECT_NAME"); + } + + assert_eq!(result, "configured-name"); + } + + #[test] + fn huskies_gateway_project_env_wins_over_huskies_project_name_env() { + let _guard = env_test_lock(); + unsafe { + std::env::set_var("HUSKIES_GATEWAY_PROJECT", "env-gateway-project"); + std::env::set_var("HUSKIES_PROJECT_NAME", "env-project-name"); + } + + let config = ProjectConfig::default(); + let result = resolve_project_display_name(&config, Path::new("/workspace")); + + unsafe { + std::env::remove_var("HUSKIES_GATEWAY_PROJECT"); + std::env::remove_var("HUSKIES_PROJECT_NAME"); + } + + assert_eq!(result, "env-gateway-project"); + } + + #[test] + fn falls_back_to_directory_basename_when_nothing_configured() { + let _guard = env_test_lock(); + unsafe { + std::env::remove_var("HUSKIES_GATEWAY_PROJECT"); + std::env::remove_var("HUSKIES_PROJECT_NAME"); + } + + let config = ProjectConfig::default(); + let result = resolve_project_display_name(&config, Path::new("/tmp/my-project-dir")); + + assert_eq!(result, "my-project-dir"); + } +} diff --git a/server/src/service/notifications/mod.rs b/server/src/service/notifications/mod.rs index f3cd17ee..7b04f858 100644 --- a/server/src/service/notifications/mod.rs +++ b/server/src/service/notifications/mod.rs @@ -22,6 +22,7 @@ pub use format::{ format_blocked_notification, format_disk_recovery_notification, format_disk_warning_notification, format_error_notification, format_stage_notification, }; +pub use io::resolve_project_display_name; pub use io::spawn_notification_listener; pub use io::spawn_stage_notification_subscriber; diff --git a/server/src/startup/tick_loop.rs b/server/src/startup/tick_loop.rs index 277e37f2..b105b71f 100644 --- a/server/src/startup/tick_loop.rs +++ b/server/src/startup/tick_loop.rs @@ -411,16 +411,14 @@ pub(crate) fn spawn_gateway_relay(startup_root: &Option, status: Arc