2026-03-22 19:07:07 +00:00
|
|
|
// matrix-sdk-crypto's deeply nested types require a higher recursion limit
|
|
|
|
|
// when the `e2e-encryption` feature is enabled.
|
|
|
|
|
#![recursion_limit = "256"]
|
|
|
|
|
|
|
|
|
|
mod agent_log;
|
|
|
|
|
mod agents;
|
2026-03-27 12:31:08 +00:00
|
|
|
mod chat;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod config;
|
2026-04-07 16:12:19 +00:00
|
|
|
pub mod crdt_state;
|
2026-04-09 19:46:29 +01:00
|
|
|
pub mod crdt_sync;
|
2026-04-10 15:31:22 +00:00
|
|
|
pub mod crdt_wire;
|
2026-04-07 13:09:48 +00:00
|
|
|
mod db;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod http;
|
|
|
|
|
mod io;
|
|
|
|
|
mod llm;
|
|
|
|
|
pub mod log_buffer;
|
|
|
|
|
pub mod rebuild;
|
|
|
|
|
mod state;
|
|
|
|
|
mod store;
|
|
|
|
|
mod workflow;
|
2026-04-09 21:24:11 +00:00
|
|
|
pub(crate) mod pipeline_state;
|
2026-03-22 19:07:07 +00:00
|
|
|
mod worktree;
|
|
|
|
|
|
|
|
|
|
use crate::agents::AgentPool;
|
2026-03-27 12:31:08 +00:00
|
|
|
use crate::chat::transport::whatsapp::WhatsAppConversationHistory;
|
2026-03-22 19:07:07 +00:00
|
|
|
use crate::http::build_routes;
|
|
|
|
|
use crate::http::context::AppContext;
|
|
|
|
|
use crate::http::{remove_port_file, resolve_port, write_port_file};
|
|
|
|
|
use crate::io::fs::find_story_kit_root;
|
2026-03-22 19:08:41 +00:00
|
|
|
use crate::rebuild::{BotShutdownNotifier, ShutdownReason};
|
2026-03-22 19:07:07 +00:00
|
|
|
use crate::state::SessionState;
|
|
|
|
|
use crate::store::JsonFileStore;
|
|
|
|
|
use crate::workflow::WorkflowState;
|
|
|
|
|
use poem::Server;
|
|
|
|
|
use poem::listener::TcpListener;
|
|
|
|
|
use std::path::PathBuf;
|
|
|
|
|
use std::sync::Arc;
|
|
|
|
|
use tokio::sync::broadcast;
|
|
|
|
|
|
2026-03-28 13:47:02 +00:00
|
|
|
/// Parsed CLI arguments.
|
2026-03-23 12:51:59 +00:00
|
|
|
#[derive(Debug, PartialEq)]
|
2026-03-28 13:47:02 +00:00
|
|
|
struct CliArgs {
|
|
|
|
|
/// Value from `--port <VALUE>` flag, if supplied.
|
|
|
|
|
port: Option<u16>,
|
|
|
|
|
/// Positional project path argument, if supplied.
|
|
|
|
|
path: Option<String>,
|
|
|
|
|
/// Whether the `init` subcommand was given.
|
|
|
|
|
init: bool,
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:47:02 +00:00
|
|
|
/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`.
|
|
|
|
|
fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
|
|
|
|
|
let mut port: Option<u16> = None;
|
|
|
|
|
let mut path: Option<String> = None;
|
|
|
|
|
let mut init = false;
|
|
|
|
|
let mut i = 0;
|
|
|
|
|
|
|
|
|
|
while i < args.len() {
|
|
|
|
|
match args[i].as_str() {
|
|
|
|
|
"--help" | "-h" => {
|
|
|
|
|
print_help();
|
|
|
|
|
std::process::exit(0);
|
|
|
|
|
}
|
|
|
|
|
"--version" | "-V" => {
|
2026-04-03 16:12:52 +01:00
|
|
|
println!("huskies {}", env!("CARGO_PKG_VERSION"));
|
2026-03-28 13:47:02 +00:00
|
|
|
std::process::exit(0);
|
|
|
|
|
}
|
|
|
|
|
"--port" => {
|
|
|
|
|
i += 1;
|
|
|
|
|
if i >= args.len() {
|
|
|
|
|
return Err("--port requires a value".to_string());
|
|
|
|
|
}
|
|
|
|
|
match args[i].parse::<u16>() {
|
|
|
|
|
Ok(p) => port = Some(p),
|
|
|
|
|
Err(_) => return Err(format!("invalid port value: '{}'", args[i])),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
a if a.starts_with("--port=") => {
|
|
|
|
|
let val = &a["--port=".len()..];
|
|
|
|
|
match val.parse::<u16>() {
|
|
|
|
|
Ok(p) => port = Some(p),
|
|
|
|
|
Err(_) => return Err(format!("invalid port value: '{val}'")),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
"init" => {
|
|
|
|
|
init = true;
|
|
|
|
|
}
|
|
|
|
|
a if a.starts_with('-') => {
|
|
|
|
|
return Err(format!("unknown option: {a}"));
|
|
|
|
|
}
|
|
|
|
|
a => {
|
|
|
|
|
if path.is_some() {
|
|
|
|
|
return Err(format!("unexpected argument: {a}"));
|
|
|
|
|
}
|
|
|
|
|
path = Some(a.to_string());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
i += 1;
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
2026-03-28 13:47:02 +00:00
|
|
|
|
|
|
|
|
Ok(CliArgs { port, path, init })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn print_help() {
|
2026-04-03 16:12:52 +01:00
|
|
|
println!("huskies [OPTIONS] [PATH]");
|
|
|
|
|
println!("huskies init [OPTIONS] [PATH]");
|
2026-03-28 13:47:02 +00:00
|
|
|
println!();
|
2026-04-03 16:12:52 +01:00
|
|
|
println!("Serve a huskies project.");
|
2026-03-28 13:47:02 +00:00
|
|
|
println!();
|
|
|
|
|
println!("COMMANDS:");
|
2026-04-03 16:12:52 +01:00
|
|
|
println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard.");
|
2026-03-28 13:47:02 +00:00
|
|
|
println!();
|
|
|
|
|
println!("ARGS:");
|
|
|
|
|
println!(
|
|
|
|
|
" PATH Path to an existing project directory. \
|
2026-04-03 16:12:52 +01:00
|
|
|
If omitted, huskies searches parent directories for a .huskies/ root."
|
2026-03-28 13:47:02 +00:00
|
|
|
);
|
|
|
|
|
println!();
|
|
|
|
|
println!("OPTIONS:");
|
|
|
|
|
println!(" -h, --help Print this help and exit");
|
|
|
|
|
println!(" -V, --version Print the version and exit");
|
|
|
|
|
println!(" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml.");
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:47:02 +00:00
|
|
|
/// Resolve the optional positional path argument into an absolute `PathBuf`.
|
|
|
|
|
fn resolve_path_arg(path_str: Option<&str>, cwd: &std::path::Path) -> Option<PathBuf> {
|
|
|
|
|
path_str.map(|s| io::fs::resolve_cli_path(cwd, s))
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[tokio::main]
|
|
|
|
|
async fn main() -> Result<(), std::io::Error> {
|
2026-04-02 10:27:34 +00:00
|
|
|
// Reap zombie grandchildren on Unix (for native deployments without tini/init).
|
|
|
|
|
// Docker containers with `init: true` in docker-compose.yml already have tini
|
|
|
|
|
// as PID 1 for this. For native macOS/Linux, poll waitpid(-1, WNOHANG) in a
|
|
|
|
|
// background thread so orphaned grandchildren don't accumulate as zombies.
|
|
|
|
|
#[cfg(unix)]
|
2026-04-09 17:58:29 +01:00
|
|
|
std::thread::spawn(|| {
|
|
|
|
|
loop {
|
|
|
|
|
// SAFETY: waitpid(-1, ...) with WNOHANG is always safe to call.
|
|
|
|
|
unsafe { while libc::waitpid(-1, std::ptr::null_mut(), libc::WNOHANG) > 0 {} }
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
2026-04-02 10:27:34 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
let app_state = Arc::new(SessionState::default());
|
|
|
|
|
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
2026-04-03 16:12:52 +01:00
|
|
|
// Migrate legacy root-level store.json into .huskies/ if the new path does
|
2026-04-02 13:24:15 +00:00
|
|
|
// not yet exist. This keeps existing deployments working after upgrade.
|
|
|
|
|
let legacy_store_path = cwd.join("store.json");
|
2026-04-03 16:12:52 +01:00
|
|
|
let store_path = cwd.join(".huskies").join("store.json");
|
2026-04-02 13:24:15 +00:00
|
|
|
if legacy_store_path.exists() && !store_path.exists() {
|
|
|
|
|
if let Some(parent) = store_path.parent() {
|
|
|
|
|
let _ = std::fs::create_dir_all(parent);
|
|
|
|
|
}
|
|
|
|
|
let _ = std::fs::rename(&legacy_store_path, &store_path);
|
|
|
|
|
}
|
2026-04-09 17:58:29 +01:00
|
|
|
let store = Arc::new(JsonFileStore::from_path(store_path).map_err(std::io::Error::other)?);
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
// Collect CLI args, skipping the binary name (argv[0]).
|
2026-03-28 13:47:02 +00:00
|
|
|
let raw_args: Vec<String> = std::env::args().skip(1).collect();
|
|
|
|
|
|
|
|
|
|
let cli = match parse_cli_args(&raw_args) {
|
|
|
|
|
Ok(args) => args,
|
|
|
|
|
Err(msg) => {
|
|
|
|
|
eprintln!("error: {msg}");
|
2026-04-03 16:12:52 +01:00
|
|
|
eprintln!("Run 'huskies --help' for usage.");
|
2026-03-23 12:51:59 +00:00
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
2026-03-28 13:26:29 +00:00
|
|
|
};
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-03-28 13:47:02 +00:00
|
|
|
let is_init = cli.init;
|
|
|
|
|
let explicit_path = resolve_path_arg(cli.path.as_deref(), &cwd);
|
|
|
|
|
|
|
|
|
|
// Port resolution: CLI flag > project.toml (loaded later) > default.
|
|
|
|
|
// Use the CLI port for scaffolding .mcp.json; final port is resolved
|
|
|
|
|
// after the project root is known.
|
|
|
|
|
let port = cli.port.unwrap_or_else(resolve_port);
|
|
|
|
|
|
2026-03-23 12:51:59 +00:00
|
|
|
// When a path is given explicitly on the CLI, it must already exist as a
|
|
|
|
|
// directory. We do not create directories from the command line.
|
|
|
|
|
if let Some(ref path) = explicit_path {
|
|
|
|
|
if !path.exists() {
|
2026-03-27 12:31:08 +00:00
|
|
|
eprintln!("error: path does not exist: {}", path.display());
|
2026-03-23 12:51:59 +00:00
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
if !path.is_dir() {
|
2026-03-27 12:31:08 +00:00
|
|
|
eprintln!("error: path is not a directory: {}", path.display());
|
2026-03-23 12:51:59 +00:00
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:26:29 +00:00
|
|
|
if is_init {
|
2026-04-03 16:12:52 +01:00
|
|
|
// `huskies init [PATH]` — always scaffold, never search parents.
|
2026-03-28 13:26:29 +00:00
|
|
|
let init_root = explicit_path.unwrap_or_else(|| cwd.clone());
|
|
|
|
|
if !init_root.exists() {
|
|
|
|
|
std::fs::create_dir_all(&init_root).unwrap_or_else(|e| {
|
2026-04-09 17:58:29 +01:00
|
|
|
eprintln!(
|
|
|
|
|
"error: cannot create directory {}: {e}",
|
|
|
|
|
init_root.display()
|
|
|
|
|
);
|
2026-03-28 13:26:29 +00:00
|
|
|
std::process::exit(1);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
match io::fs::open_project(
|
|
|
|
|
init_root.to_string_lossy().to_string(),
|
|
|
|
|
&app_state,
|
|
|
|
|
store.as_ref(),
|
|
|
|
|
port,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
if let Some(root) = app_state.project_root.lock().unwrap().as_ref() {
|
|
|
|
|
config::ProjectConfig::load(root)
|
|
|
|
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
|
|
|
|
// Initialize wizard state for the setup flow.
|
|
|
|
|
io::wizard::WizardState::init_if_missing(root);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("error: {e}");
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else if let Some(explicit_root) = explicit_path {
|
2026-03-22 19:07:07 +00:00
|
|
|
// An explicit path was given on the command line.
|
2026-04-03 16:12:52 +01:00
|
|
|
// Open it directly — scaffold .huskies/ if it is missing — and
|
2026-03-22 19:07:07 +00:00
|
|
|
// exit with a clear error message if the path is invalid.
|
|
|
|
|
match io::fs::open_project(
|
|
|
|
|
explicit_root.to_string_lossy().to_string(),
|
|
|
|
|
&app_state,
|
|
|
|
|
store.as_ref(),
|
2026-03-23 12:57:49 +00:00
|
|
|
port,
|
2026-03-22 19:07:07 +00:00
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
{
|
|
|
|
|
Ok(_) => {
|
|
|
|
|
if let Some(root) = app_state.project_root.lock().unwrap().as_ref() {
|
|
|
|
|
config::ProjectConfig::load(root)
|
|
|
|
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
eprintln!("error: {e}");
|
|
|
|
|
std::process::exit(1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
2026-04-03 16:12:52 +01:00
|
|
|
// No path argument — auto-detect a .huskies/ project in cwd or
|
2026-03-22 19:07:07 +00:00
|
|
|
// parent directories (preserves existing behaviour).
|
|
|
|
|
if let Some(project_root) = find_story_kit_root(&cwd) {
|
|
|
|
|
io::fs::open_project(
|
|
|
|
|
project_root.to_string_lossy().to_string(),
|
|
|
|
|
&app_state,
|
|
|
|
|
store.as_ref(),
|
2026-03-23 12:57:49 +00:00
|
|
|
port,
|
2026-03-22 19:07:07 +00:00
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_else(|e| {
|
|
|
|
|
slog!("Warning: failed to auto-open project at {project_root:?}: {e}");
|
|
|
|
|
project_root.to_string_lossy().to_string()
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Validate agent config for the detected project root.
|
|
|
|
|
config::ProjectConfig::load(&project_root)
|
|
|
|
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
|
|
|
|
} else {
|
2026-04-03 16:12:52 +01:00
|
|
|
// No .huskies/ found in cwd or parents — scaffold cwd as a new
|
|
|
|
|
// project, exactly like `huskies .` does.
|
2026-03-23 14:06:05 +00:00
|
|
|
io::fs::open_project(
|
|
|
|
|
cwd.to_string_lossy().to_string(),
|
|
|
|
|
&app_state,
|
|
|
|
|
store.as_ref(),
|
|
|
|
|
port,
|
|
|
|
|
)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap_or_else(|e| {
|
|
|
|
|
slog!("Warning: failed to scaffold project at {cwd:?}: {e}");
|
|
|
|
|
cwd.to_string_lossy().to_string()
|
|
|
|
|
});
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enable persistent server log file now that the project root is known.
|
|
|
|
|
if let Some(ref root) = *app_state.project_root.lock().unwrap() {
|
2026-04-03 16:12:52 +01:00
|
|
|
let log_dir = root.join(".huskies").join("logs");
|
2026-03-22 19:07:07 +00:00
|
|
|
let _ = std::fs::create_dir_all(&log_dir);
|
|
|
|
|
log_buffer::global().set_log_file(log_dir.join("server.log"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 16:12:19 +00:00
|
|
|
// Initialise the SQLite pipeline shadow-write database and CRDT state layer.
|
2026-04-07 13:09:48 +00:00
|
|
|
// Clone the path out before the await so we don't hold the MutexGuard across
|
|
|
|
|
// an await point.
|
|
|
|
|
let pipeline_db_path = app_state
|
|
|
|
|
.project_root
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap()
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|root| root.join(".huskies").join("pipeline.db"));
|
2026-04-07 16:12:19 +00:00
|
|
|
if let Some(ref db_path) = pipeline_db_path {
|
|
|
|
|
if let Err(e) = db::init(db_path).await {
|
|
|
|
|
slog!("[db] Failed to initialise pipeline.db: {e}");
|
|
|
|
|
}
|
|
|
|
|
if let Err(e) = crdt_state::init(db_path).await {
|
|
|
|
|
slog!("[crdt] Failed to initialise CRDT state layer: {e}");
|
|
|
|
|
}
|
2026-04-07 13:09:48 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-08 01:14:55 +00:00
|
|
|
// (CRDT state layer is initialised above alongside the legacy pipeline.db.)
|
2026-04-07 16:15:38 +00:00
|
|
|
|
2026-04-09 19:46:29 +01:00
|
|
|
// Start the CRDT sync rendezvous client if configured in project.toml.
|
|
|
|
|
if let Some(ref root) = *app_state.project_root.lock().unwrap()
|
|
|
|
|
&& let Ok(cfg) = config::ProjectConfig::load(root)
|
|
|
|
|
&& let Some(rendezvous_url) = cfg.rendezvous
|
|
|
|
|
{
|
|
|
|
|
crdt_sync::spawn_rendezvous_client(rendezvous_url);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
|
|
|
|
|
|
2026-04-08 01:14:55 +00:00
|
|
|
// Event bus: broadcast channel for pipeline lifecycle events.
|
2026-03-22 19:07:07 +00:00
|
|
|
// Created before AgentPool so the pool can emit AgentStateChanged events.
|
|
|
|
|
let (watcher_tx, _) = broadcast::channel::<io::watcher::WatcherEvent>(1024);
|
|
|
|
|
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
|
|
|
|
|
|
|
|
|
|
// Start the background watchdog that detects and cleans up orphaned Running agents.
|
|
|
|
|
// When orphans are found, auto-assign is triggered to reassign free agents.
|
|
|
|
|
let watchdog_root: Option<PathBuf> = app_state.project_root.lock().unwrap().clone();
|
|
|
|
|
AgentPool::spawn_watchdog(Arc::clone(&agents), watchdog_root);
|
2026-04-08 01:14:55 +00:00
|
|
|
|
|
|
|
|
// Filesystem watcher: only watches config files (project.toml, agents.toml) and
|
|
|
|
|
// handles the sweep of done→archived. Work-item pipeline events are now driven
|
|
|
|
|
// by CRDT state transitions via crdt_state::subscribe().
|
2026-03-22 19:07:07 +00:00
|
|
|
if let Some(ref root) = *app_state.project_root.lock().unwrap() {
|
2026-04-03 16:12:52 +01:00
|
|
|
let work_dir = root.join(".huskies").join("work");
|
2026-03-22 19:07:07 +00:00
|
|
|
if work_dir.is_dir() {
|
|
|
|
|
let watcher_config = config::ProjectConfig::load(root)
|
|
|
|
|
.map(|c| c.watcher)
|
|
|
|
|
.unwrap_or_default();
|
|
|
|
|
io::watcher::start_watcher(work_dir, root.clone(), watcher_tx.clone(), watcher_config);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-08 01:14:55 +00:00
|
|
|
// Bridge CRDT state-transition events to the watcher broadcast channel.
|
|
|
|
|
// This replaces the filesystem watcher as the source of WorkItem events.
|
|
|
|
|
{
|
|
|
|
|
let crdt_watcher_tx = watcher_tx.clone();
|
|
|
|
|
if let Some(mut crdt_rx) = crdt_state::subscribe() {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
while let Ok(evt) = crdt_rx.recv().await {
|
|
|
|
|
let (action, commit_msg) =
|
|
|
|
|
io::watcher::stage_metadata(&evt.to_stage, &evt.story_id)
|
|
|
|
|
.unwrap_or(("update", format!("huskies: update {}", evt.story_id)));
|
|
|
|
|
let watcher_evt = io::watcher::WatcherEvent::WorkItem {
|
|
|
|
|
stage: evt.to_stage,
|
|
|
|
|
item_id: evt.story_id,
|
|
|
|
|
action: action.to_string(),
|
|
|
|
|
commit_msg,
|
|
|
|
|
from_stage: evt.from_stage,
|
|
|
|
|
};
|
|
|
|
|
let _ = crdt_watcher_tx.send(watcher_evt);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
// Subscribe to watcher events so that auto-assign triggers when a work item
|
2026-04-08 01:14:55 +00:00
|
|
|
// enters an active pipeline stage (2_current/, 3_qa/, 4_merge/).
|
2026-03-22 19:07:07 +00:00
|
|
|
{
|
|
|
|
|
let watcher_auto_rx = watcher_tx.subscribe();
|
|
|
|
|
let watcher_auto_agents = Arc::clone(&agents);
|
|
|
|
|
let watcher_auto_root: Option<PathBuf> = app_state.project_root.lock().unwrap().clone();
|
|
|
|
|
if let Some(root) = watcher_auto_root {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let mut rx = watcher_auto_rx;
|
|
|
|
|
while let Ok(event) = rx.recv().await {
|
|
|
|
|
if let io::watcher::WatcherEvent::WorkItem { ref stage, .. } = event
|
|
|
|
|
&& matches!(stage.as_str(), "2_current" | "3_qa" | "4_merge")
|
|
|
|
|
{
|
|
|
|
|
slog!(
|
2026-04-08 01:14:55 +00:00
|
|
|
"[auto-assign] CRDT transition detected in {stage}/; \
|
2026-03-22 19:07:07 +00:00
|
|
|
triggering auto-assign."
|
|
|
|
|
);
|
|
|
|
|
watcher_auto_agents.auto_assign_available_work(&root).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Reconciliation progress channel: startup reconciliation → WebSocket clients.
|
|
|
|
|
let (reconciliation_tx, _) = broadcast::channel::<agents::ReconciliationEvent>(64);
|
|
|
|
|
|
|
|
|
|
// Permission channel: MCP prompt_permission → WebSocket handler.
|
|
|
|
|
let (perm_tx, perm_rx) = tokio::sync::mpsc::unbounded_channel();
|
|
|
|
|
|
|
|
|
|
// Clone watcher_tx for the Matrix bot before it is moved into AppContext.
|
|
|
|
|
let watcher_tx_for_bot = watcher_tx.clone();
|
2026-03-25 15:35:19 +00:00
|
|
|
// Subscribe to watcher events for WhatsApp/Slack notification listeners
|
|
|
|
|
// before watcher_tx is moved into AppContext.
|
|
|
|
|
let watcher_rx_for_whatsapp = watcher_tx.subscribe();
|
|
|
|
|
let watcher_rx_for_slack = watcher_tx.subscribe();
|
2026-04-04 12:08:39 +00:00
|
|
|
let watcher_rx_for_discord = watcher_tx.subscribe();
|
2026-03-22 19:07:07 +00:00
|
|
|
// Wrap perm_rx in Arc<Mutex> so it can be shared with both the WebSocket
|
|
|
|
|
// handler (via AppContext) and the Matrix bot.
|
|
|
|
|
let perm_rx = Arc::new(tokio::sync::Mutex::new(perm_rx));
|
|
|
|
|
let perm_rx_for_bot = Arc::clone(&perm_rx);
|
2026-03-25 15:31:54 +00:00
|
|
|
let perm_rx_for_whatsapp = Arc::clone(&perm_rx);
|
|
|
|
|
let perm_rx_for_slack = Arc::clone(&perm_rx);
|
2026-04-04 12:08:39 +00:00
|
|
|
let perm_rx_for_discord = Arc::clone(&perm_rx);
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
// Capture project root, agents Arc, and reconciliation sender before ctx
|
|
|
|
|
// is consumed by build_routes.
|
|
|
|
|
let startup_root: Option<PathBuf> = app_state.project_root.lock().unwrap().clone();
|
|
|
|
|
let startup_agents = Arc::clone(&agents);
|
|
|
|
|
let startup_reconciliation_tx = reconciliation_tx.clone();
|
|
|
|
|
// Clone for shutdown cleanup — kill orphaned PTY children before exiting.
|
|
|
|
|
let agents_for_shutdown = Arc::clone(&agents);
|
|
|
|
|
|
|
|
|
|
// Build WhatsApp webhook context if bot.toml configures transport = "whatsapp".
|
2026-03-24 17:54:51 +00:00
|
|
|
let whatsapp_ctx: Option<Arc<chat::transport::whatsapp::WhatsAppWebhookContext>> = startup_root
|
2026-03-22 19:07:07 +00:00
|
|
|
.as_ref()
|
2026-03-24 17:54:51 +00:00
|
|
|
.and_then(|root| chat::transport::matrix::BotConfig::load(root))
|
2026-03-22 19:07:07 +00:00
|
|
|
.filter(|cfg| cfg.transport == "whatsapp")
|
|
|
|
|
.map(|cfg| {
|
2026-03-24 17:33:56 +00:00
|
|
|
let provider = cfg.whatsapp_provider.clone();
|
2026-03-27 12:31:08 +00:00
|
|
|
let transport: Arc<dyn crate::chat::ChatTransport> = if provider == "twilio" {
|
|
|
|
|
Arc::new(chat::transport::whatsapp::TwilioWhatsAppTransport::new(
|
|
|
|
|
cfg.twilio_account_sid.clone().unwrap_or_default(),
|
|
|
|
|
cfg.twilio_auth_token.clone().unwrap_or_default(),
|
|
|
|
|
cfg.twilio_whatsapp_number.clone().unwrap_or_default(),
|
|
|
|
|
))
|
|
|
|
|
} else {
|
|
|
|
|
let template_name = cfg
|
|
|
|
|
.whatsapp_notification_template
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| "pipeline_notification".to_string());
|
|
|
|
|
Arc::new(chat::transport::whatsapp::WhatsAppTransport::new(
|
|
|
|
|
cfg.whatsapp_phone_number_id.clone().unwrap_or_default(),
|
|
|
|
|
cfg.whatsapp_access_token.clone().unwrap_or_default(),
|
|
|
|
|
template_name,
|
|
|
|
|
))
|
|
|
|
|
};
|
2026-03-22 19:07:07 +00:00
|
|
|
let bot_name = cfg
|
|
|
|
|
.display_name
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| "Assistant".to_string());
|
|
|
|
|
let root = startup_root.clone().unwrap();
|
2026-03-24 17:54:51 +00:00
|
|
|
let history = chat::transport::whatsapp::load_whatsapp_history(&root);
|
|
|
|
|
Arc::new(chat::transport::whatsapp::WhatsAppWebhookContext {
|
2026-03-22 19:07:07 +00:00
|
|
|
verify_token: cfg.whatsapp_verify_token.clone().unwrap_or_default(),
|
2026-03-24 17:33:56 +00:00
|
|
|
provider,
|
2026-03-22 19:07:07 +00:00
|
|
|
transport,
|
|
|
|
|
project_root: root,
|
|
|
|
|
agents: Arc::clone(&startup_agents),
|
|
|
|
|
bot_name,
|
|
|
|
|
bot_user_id: "whatsapp-bot".to_string(),
|
|
|
|
|
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
|
|
|
|
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
|
|
|
|
history_size: cfg.history_size,
|
2026-03-24 17:54:51 +00:00
|
|
|
window_tracker: Arc::new(chat::transport::whatsapp::MessagingWindowTracker::new()),
|
2026-03-25 13:39:44 +00:00
|
|
|
allowed_phones: cfg.whatsapp_allowed_phones.clone(),
|
2026-03-25 15:31:54 +00:00
|
|
|
perm_rx: perm_rx_for_whatsapp,
|
|
|
|
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(
|
|
|
|
|
std::collections::HashMap::new(),
|
|
|
|
|
)),
|
|
|
|
|
permission_timeout_secs: cfg.permission_timeout_secs,
|
2026-03-22 19:07:07 +00:00
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Build Slack webhook context if bot.toml configures transport = "slack".
|
2026-03-24 17:54:51 +00:00
|
|
|
let slack_ctx: Option<Arc<chat::transport::slack::SlackWebhookContext>> = startup_root
|
2026-03-22 19:07:07 +00:00
|
|
|
.as_ref()
|
2026-03-24 17:54:51 +00:00
|
|
|
.and_then(|root| chat::transport::matrix::BotConfig::load(root))
|
2026-03-22 19:07:07 +00:00
|
|
|
.filter(|cfg| cfg.transport == "slack")
|
|
|
|
|
.map(|cfg| {
|
2026-03-24 17:54:51 +00:00
|
|
|
let transport = Arc::new(chat::transport::slack::SlackTransport::new(
|
2026-03-22 19:07:07 +00:00
|
|
|
cfg.slack_bot_token.clone().unwrap_or_default(),
|
|
|
|
|
));
|
|
|
|
|
let bot_name = cfg
|
|
|
|
|
.display_name
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| "Assistant".to_string());
|
|
|
|
|
let root = startup_root.clone().unwrap();
|
2026-03-24 17:54:51 +00:00
|
|
|
let history = chat::transport::slack::load_slack_history(&root);
|
2026-03-22 19:07:07 +00:00
|
|
|
let channel_ids: std::collections::HashSet<String> =
|
|
|
|
|
cfg.slack_channel_ids.iter().cloned().collect();
|
2026-03-24 17:54:51 +00:00
|
|
|
Arc::new(chat::transport::slack::SlackWebhookContext {
|
2026-03-22 19:07:07 +00:00
|
|
|
signing_secret: cfg.slack_signing_secret.clone().unwrap_or_default(),
|
|
|
|
|
transport,
|
|
|
|
|
project_root: root,
|
|
|
|
|
agents: Arc::clone(&startup_agents),
|
|
|
|
|
bot_name,
|
|
|
|
|
bot_user_id: "slack-bot".to_string(),
|
|
|
|
|
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
|
|
|
|
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
|
|
|
|
history_size: cfg.history_size,
|
|
|
|
|
channel_ids,
|
2026-03-25 15:31:54 +00:00
|
|
|
perm_rx: perm_rx_for_slack,
|
|
|
|
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(
|
|
|
|
|
std::collections::HashMap::new(),
|
|
|
|
|
)),
|
|
|
|
|
permission_timeout_secs: cfg.permission_timeout_secs,
|
2026-03-22 19:07:07 +00:00
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-04 12:08:39 +00:00
|
|
|
// Build Discord context if bot.toml configures transport = "discord".
|
|
|
|
|
let discord_ctx: Option<Arc<chat::transport::discord::DiscordContext>> = startup_root
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|root| chat::transport::matrix::BotConfig::load(root))
|
|
|
|
|
.filter(|cfg| cfg.transport == "discord")
|
|
|
|
|
.map(|cfg| {
|
|
|
|
|
let transport = Arc::new(chat::transport::discord::DiscordTransport::new(
|
|
|
|
|
cfg.discord_bot_token.clone().unwrap_or_default(),
|
|
|
|
|
));
|
|
|
|
|
let bot_name = cfg
|
|
|
|
|
.display_name
|
|
|
|
|
.clone()
|
|
|
|
|
.unwrap_or_else(|| "Assistant".to_string());
|
|
|
|
|
let root = startup_root.clone().unwrap();
|
|
|
|
|
let history = chat::transport::discord::load_discord_history(&root);
|
|
|
|
|
let channel_ids: std::collections::HashSet<String> =
|
|
|
|
|
cfg.discord_channel_ids.iter().cloned().collect();
|
|
|
|
|
let allowed_users: std::collections::HashSet<String> =
|
|
|
|
|
cfg.discord_allowed_users.iter().cloned().collect();
|
|
|
|
|
Arc::new(chat::transport::discord::DiscordContext {
|
|
|
|
|
bot_token: cfg.discord_bot_token.clone().unwrap_or_default(),
|
|
|
|
|
transport,
|
|
|
|
|
project_root: root,
|
|
|
|
|
agents: Arc::clone(&startup_agents),
|
|
|
|
|
bot_name,
|
|
|
|
|
bot_user_id: "discord-bot".to_string(),
|
|
|
|
|
ambient_rooms: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
|
|
|
|
|
history: std::sync::Arc::new(tokio::sync::Mutex::new(history)),
|
|
|
|
|
history_size: cfg.history_size,
|
|
|
|
|
channel_ids,
|
|
|
|
|
allowed_users,
|
|
|
|
|
perm_rx: perm_rx_for_discord,
|
|
|
|
|
pending_perm_replies: Arc::new(tokio::sync::Mutex::new(
|
|
|
|
|
std::collections::HashMap::new(),
|
|
|
|
|
)),
|
|
|
|
|
permission_timeout_secs: cfg.permission_timeout_secs,
|
|
|
|
|
})
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-22 19:08:41 +00:00
|
|
|
// Build a best-effort shutdown notifier for webhook-based transports.
|
|
|
|
|
//
|
|
|
|
|
// • Slack: channels are fixed at startup (channel_ids from bot.toml).
|
2026-04-04 12:08:39 +00:00
|
|
|
// • Discord: channels are fixed at startup (channel_ids from bot.toml).
|
2026-03-22 19:08:41 +00:00
|
|
|
// • WhatsApp: active senders are tracked at runtime in ambient_rooms.
|
|
|
|
|
// We keep the WhatsApp context Arc so we can read the rooms at shutdown.
|
|
|
|
|
// • Matrix: the bot task manages its own announcement via matrix_shutdown_tx.
|
2026-03-27 12:31:08 +00:00
|
|
|
let bot_shutdown_notifier: Option<Arc<BotShutdownNotifier>> = if let Some(ref ctx) = slack_ctx {
|
|
|
|
|
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
|
|
|
|
Some(Arc::new(BotShutdownNotifier::new(
|
|
|
|
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
channels,
|
|
|
|
|
ctx.bot_name.clone(),
|
|
|
|
|
)))
|
2026-04-04 12:08:39 +00:00
|
|
|
} else if let Some(ref ctx) = discord_ctx {
|
|
|
|
|
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
|
|
|
|
Some(Arc::new(BotShutdownNotifier::new(
|
|
|
|
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
channels,
|
|
|
|
|
ctx.bot_name.clone(),
|
|
|
|
|
)))
|
2026-03-27 12:31:08 +00:00
|
|
|
} else {
|
|
|
|
|
None
|
|
|
|
|
};
|
2026-03-22 19:08:41 +00:00
|
|
|
// Retain a reference to the WhatsApp context for shutdown notifications.
|
|
|
|
|
// At shutdown time we read ambient_rooms to get the current set of active senders.
|
2026-03-24 17:54:51 +00:00
|
|
|
let whatsapp_ctx_for_shutdown: Option<Arc<chat::transport::whatsapp::WhatsAppWebhookContext>> =
|
2026-03-22 19:08:41 +00:00
|
|
|
whatsapp_ctx.clone();
|
|
|
|
|
|
2026-03-26 20:19:57 +00:00
|
|
|
// ── Startup announcements (WhatsApp & Slack) ──────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// Send "{bot_name} is online." to all known contacts so users know the bot
|
|
|
|
|
// is ready. This mirrors the Matrix bot's startup announcement and fires
|
|
|
|
|
// on every fresh process start — including after a rebuild/re-exec.
|
|
|
|
|
//
|
|
|
|
|
// • WhatsApp: send to all phone numbers present in persisted history.
|
|
|
|
|
// • Slack: send to all configured channel IDs (channel_ids from bot.toml).
|
|
|
|
|
// • Matrix: handled by spawn_bot() below; no action needed here.
|
|
|
|
|
if let Some(ref ctx) = whatsapp_ctx {
|
|
|
|
|
let transport = Arc::clone(&ctx.transport);
|
|
|
|
|
let bot_name = ctx.bot_name.clone();
|
2026-03-27 12:31:08 +00:00
|
|
|
let history: WhatsAppConversationHistory = Arc::clone(&ctx.history);
|
2026-03-26 20:19:57 +00:00
|
|
|
tokio::spawn(async move {
|
|
|
|
|
let senders: Vec<String> = history.lock().await.keys().cloned().collect();
|
|
|
|
|
if senders.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-27 12:31:08 +00:00
|
|
|
let notifier = crate::rebuild::BotShutdownNotifier::new(transport, senders, bot_name);
|
2026-03-26 20:19:57 +00:00
|
|
|
notifier.notify_startup().await;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if let Some(ref ctx) = slack_ctx {
|
|
|
|
|
let transport = Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>;
|
|
|
|
|
let bot_name = ctx.bot_name.clone();
|
|
|
|
|
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
if channels.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-27 12:31:08 +00:00
|
|
|
let notifier = crate::rebuild::BotShutdownNotifier::new(transport, channels, bot_name);
|
2026-03-26 20:19:57 +00:00
|
|
|
notifier.notify_startup().await;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-04 12:08:39 +00:00
|
|
|
if let Some(ref ctx) = discord_ctx {
|
|
|
|
|
let transport = Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>;
|
|
|
|
|
let bot_name = ctx.bot_name.clone();
|
|
|
|
|
let channels: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
if channels.is_empty() {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
let notifier = crate::rebuild::BotShutdownNotifier::new(transport, channels, bot_name);
|
|
|
|
|
notifier.notify_startup().await;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-03-26 20:19:57 +00:00
|
|
|
|
2026-03-22 19:08:41 +00:00
|
|
|
// Watch channel: signals the Matrix bot task to send a shutdown announcement.
|
|
|
|
|
// `None` initial value means "server is running".
|
|
|
|
|
let (matrix_shutdown_tx, matrix_shutdown_rx) =
|
|
|
|
|
tokio::sync::watch::channel::<Option<ShutdownReason>>(None);
|
|
|
|
|
let matrix_shutdown_tx = Arc::new(matrix_shutdown_tx);
|
|
|
|
|
let matrix_shutdown_tx_for_rebuild = Arc::clone(&matrix_shutdown_tx);
|
|
|
|
|
|
2026-04-09 21:28:48 +01:00
|
|
|
// Bug 501: shared rate-limit retry timer store, accessible from MCP tools
|
|
|
|
|
// via AppContext so manual interventions (move_story → backlog, stop_agent)
|
|
|
|
|
// can cancel pending timers in-memory rather than only on disk.
|
|
|
|
|
//
|
|
|
|
|
// TODO(bug 501): the matrix bot currently spawns its own TimerStore instance
|
|
|
|
|
// in `chat::transport::matrix::bot::run::spawn_bot`. Refactor to consume this
|
|
|
|
|
// shared instance via `AppContext.timer_store` so cancellations from MCP
|
|
|
|
|
// tools and the bot's tick loop see the same in-memory state.
|
|
|
|
|
let timer_store = std::sync::Arc::new(crate::chat::timer::TimerStore::load(
|
|
|
|
|
startup_root
|
|
|
|
|
.as_ref()
|
|
|
|
|
.map(|r| r.join(".huskies").join("timers.json"))
|
|
|
|
|
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/huskies-timers.json")),
|
|
|
|
|
));
|
|
|
|
|
|
2026-03-22 19:08:41 +00:00
|
|
|
let ctx = AppContext {
|
|
|
|
|
state: app_state,
|
|
|
|
|
store,
|
|
|
|
|
workflow,
|
|
|
|
|
agents,
|
|
|
|
|
watcher_tx,
|
|
|
|
|
reconciliation_tx,
|
|
|
|
|
perm_tx,
|
|
|
|
|
perm_rx,
|
|
|
|
|
qa_app_process: Arc::new(std::sync::Mutex::new(None)),
|
|
|
|
|
bot_shutdown: bot_shutdown_notifier.clone(),
|
|
|
|
|
matrix_shutdown_tx: Some(Arc::clone(&matrix_shutdown_tx)),
|
2026-04-09 21:28:48 +01:00
|
|
|
timer_store,
|
2026-03-22 19:08:41 +00:00
|
|
|
};
|
|
|
|
|
|
2026-03-31 14:52:18 +00:00
|
|
|
let app = build_routes(ctx, whatsapp_ctx.clone(), slack_ctx.clone(), port);
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
// Optional Matrix bot: connect to the homeserver and start listening for
|
2026-04-03 16:12:52 +01:00
|
|
|
// messages if `.huskies/bot.toml` is present and enabled.
|
2026-03-22 19:07:07 +00:00
|
|
|
if let Some(ref root) = startup_root {
|
2026-03-24 17:54:51 +00:00
|
|
|
chat::transport::matrix::spawn_bot(
|
2026-03-22 19:07:07 +00:00
|
|
|
root,
|
|
|
|
|
watcher_tx_for_bot,
|
|
|
|
|
perm_rx_for_bot,
|
|
|
|
|
Arc::clone(&startup_agents),
|
2026-03-22 19:08:41 +00:00
|
|
|
matrix_shutdown_rx,
|
2026-03-22 19:07:07 +00:00
|
|
|
);
|
2026-03-22 19:08:41 +00:00
|
|
|
} else {
|
|
|
|
|
// Keep the receiver alive (drop it) so the sender never errors.
|
|
|
|
|
drop(matrix_shutdown_rx);
|
2026-03-22 19:07:07 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-25 15:35:19 +00:00
|
|
|
// Spawn stage-transition notification listeners for WhatsApp and Slack.
|
|
|
|
|
// These mirror the listener that the Matrix bot spawns internally.
|
|
|
|
|
if let (Some(ctx), Some(root)) = (&whatsapp_ctx, &startup_root) {
|
|
|
|
|
let ambient_rooms = Arc::clone(&ctx.ambient_rooms);
|
|
|
|
|
chat::transport::matrix::notifications::spawn_notification_listener(
|
|
|
|
|
Arc::clone(&ctx.transport),
|
|
|
|
|
move || ambient_rooms.lock().unwrap().iter().cloned().collect(),
|
|
|
|
|
watcher_rx_for_whatsapp,
|
|
|
|
|
root.clone(),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
drop(watcher_rx_for_whatsapp);
|
|
|
|
|
}
|
|
|
|
|
if let (Some(ctx), Some(root)) = (&slack_ctx, &startup_root) {
|
|
|
|
|
let channel_ids: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
|
|
|
|
chat::transport::matrix::notifications::spawn_notification_listener(
|
|
|
|
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
move || channel_ids.clone(),
|
|
|
|
|
watcher_rx_for_slack,
|
|
|
|
|
root.clone(),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
drop(watcher_rx_for_slack);
|
|
|
|
|
}
|
2026-04-04 12:08:39 +00:00
|
|
|
if let (Some(ctx), Some(root)) = (&discord_ctx, &startup_root) {
|
|
|
|
|
// Spawn the Discord Gateway WebSocket listener.
|
|
|
|
|
chat::transport::discord::gateway::spawn_gateway(Arc::clone(ctx));
|
|
|
|
|
|
|
|
|
|
// Spawn stage-transition notification listener for Discord.
|
|
|
|
|
let channel_ids: Vec<String> = ctx.channel_ids.iter().cloned().collect();
|
|
|
|
|
chat::transport::matrix::notifications::spawn_notification_listener(
|
|
|
|
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
|
|
|
|
move || channel_ids.clone(),
|
|
|
|
|
watcher_rx_for_discord,
|
|
|
|
|
root.clone(),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
drop(watcher_rx_for_discord);
|
|
|
|
|
}
|
2026-03-25 15:35:19 +00:00
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
// On startup:
|
|
|
|
|
// 1. Reconcile any stories whose agent work was committed while the server was
|
|
|
|
|
// offline (worktree has commits ahead of master but pipeline didn't advance).
|
|
|
|
|
// 2. Auto-assign free agents to remaining unassigned work in the pipeline.
|
|
|
|
|
if let Some(root) = startup_root {
|
|
|
|
|
tokio::spawn(async move {
|
|
|
|
|
slog!("[startup] Reconciling completed worktrees from previous session.");
|
|
|
|
|
startup_agents
|
|
|
|
|
.reconcile_on_startup(&root, &startup_reconciliation_tx)
|
|
|
|
|
.await;
|
|
|
|
|
slog!("[auto-assign] Scanning pipeline stages for unassigned work.");
|
|
|
|
|
startup_agents.auto_assign_available_work(&root).await;
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-03 16:12:52 +01:00
|
|
|
let host = std::env::var("HUSKIES_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
|
2026-03-24 21:26:48 +00:00
|
|
|
let addr = format!("{host}:{port}");
|
2026-03-22 19:07:07 +00:00
|
|
|
|
2026-04-03 21:38:58 +01:00
|
|
|
println!("\x1b[97;1m");
|
|
|
|
|
println!(" /\\_/\\ \x1b[96;1m _ _ _ _ \x1b[97;1m");
|
|
|
|
|
println!(" / o o \\ \x1b[96;1m| | | |_ _ ___| | _(_) ___ ___\x1b[97;1m");
|
|
|
|
|
println!(" ( Y ) \x1b[96;1m| |_| | | | / __| |/ / |/ _ \\/ __|\x1b[97;1m");
|
|
|
|
|
println!(" \\ ^ / \x1b[96;1m| _ | |_| \\__ \\ <| | __/\\__ \\\x1b[97;1m");
|
|
|
|
|
println!(" )===( \\ \x1b[96;1m|_| |_|\\__,_|___/_|\\_\\_|\\___||___/\x1b[97;1m");
|
|
|
|
|
println!(" / \\ \\ \x1b[90mStory-driven development, powered by AI\x1b[97;1m");
|
|
|
|
|
println!(" | | | |");
|
|
|
|
|
println!(" /| | |\\|");
|
|
|
|
|
println!(" \\|__|__|/\x1b[0m");
|
2026-04-03 21:03:54 +01:00
|
|
|
println!();
|
2026-04-03 16:12:52 +01:00
|
|
|
println!("HUSKIES_PORT={port}");
|
2026-03-22 19:07:07 +00:00
|
|
|
println!("\x1b[96;1mFrontend:\x1b[0m \x1b[94mhttp://{addr}\x1b[0m");
|
|
|
|
|
println!("\x1b[92;1mOpenAPI Docs:\x1b[0m \x1b[94mhttp://{addr}/docs\x1b[0m");
|
|
|
|
|
|
|
|
|
|
let port_file = write_port_file(&cwd, port);
|
|
|
|
|
|
|
|
|
|
let result = Server::new(TcpListener::bind(&addr)).run(app).await;
|
|
|
|
|
|
2026-03-22 19:08:41 +00:00
|
|
|
// ── Shutdown notifications (best-effort) ─────────────────────────────
|
|
|
|
|
//
|
|
|
|
|
// The server is stopping (SIGINT / SIGTERM). Notify active bot channels
|
|
|
|
|
// so participants know the bot is going offline. We do this before killing
|
|
|
|
|
// PTY children so network I/O can still complete.
|
|
|
|
|
|
|
|
|
|
// Slack: notifier holds the fixed channel list.
|
|
|
|
|
if let Some(ref notifier) = bot_shutdown_notifier {
|
|
|
|
|
notifier.notify(ShutdownReason::Manual).await;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// WhatsApp: read the current set of ambient rooms and notify each sender.
|
|
|
|
|
if let Some(ref ctx) = whatsapp_ctx_for_shutdown {
|
|
|
|
|
let rooms: Vec<String> = ctx.ambient_rooms.lock().unwrap().iter().cloned().collect();
|
|
|
|
|
if !rooms.is_empty() {
|
|
|
|
|
let wa_notifier = BotShutdownNotifier::new(
|
2026-03-24 17:54:51 +00:00
|
|
|
Arc::clone(&ctx.transport) as Arc<dyn crate::chat::ChatTransport>,
|
2026-03-22 19:08:41 +00:00
|
|
|
rooms,
|
|
|
|
|
ctx.bot_name.clone(),
|
|
|
|
|
);
|
|
|
|
|
wa_notifier.notify(ShutdownReason::Manual).await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Matrix: signal the bot task and give it a short window to send its message.
|
|
|
|
|
let _ = matrix_shutdown_tx_for_rebuild.send(Some(ShutdownReason::Manual));
|
|
|
|
|
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
|
|
|
|
|
|
|
|
|
|
// ── Cleanup ──────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-03-22 19:07:07 +00:00
|
|
|
// Kill all active PTY child processes before exiting to prevent orphaned
|
|
|
|
|
// Claude Code processes from running after the server restarts.
|
|
|
|
|
agents_for_shutdown.kill_all_children();
|
|
|
|
|
|
|
|
|
|
if let Some(ref path) = port_file {
|
|
|
|
|
remove_port_file(path);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
#[should_panic(expected = "Invalid project.toml: Duplicate agent name")]
|
|
|
|
|
fn panics_on_duplicate_agent_names() {
|
|
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
2026-04-03 16:12:52 +01:00
|
|
|
let sk = tmp.path().join(".huskies");
|
2026-03-22 19:07:07 +00:00
|
|
|
std::fs::create_dir_all(&sk).unwrap();
|
|
|
|
|
std::fs::write(
|
|
|
|
|
sk.join("project.toml"),
|
|
|
|
|
r#"
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder"
|
|
|
|
|
|
|
|
|
|
[[agent]]
|
|
|
|
|
name = "coder"
|
|
|
|
|
"#,
|
|
|
|
|
)
|
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
|
|
config::ProjectConfig::load(tmp.path())
|
|
|
|
|
.unwrap_or_else(|e| panic!("Invalid project.toml: {e}"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:47:02 +00:00
|
|
|
// ── parse_cli_args ─────────────────────────────────────────────────
|
2026-03-23 12:51:59 +00:00
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_no_args() {
|
|
|
|
|
let result = parse_cli_args(&[]).unwrap();
|
|
|
|
|
assert_eq!(result.port, None);
|
|
|
|
|
assert_eq!(result.path, None);
|
|
|
|
|
assert!(!result.init);
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_unknown_flag_is_error() {
|
|
|
|
|
let args = vec!["--serve".to_string()];
|
|
|
|
|
assert!(parse_cli_args(&args).is_err());
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_path_only() {
|
|
|
|
|
let args = vec!["/some/path".to_string()];
|
|
|
|
|
let result = parse_cli_args(&args).unwrap();
|
|
|
|
|
assert_eq!(result.path, Some("/some/path".to_string()));
|
|
|
|
|
assert_eq!(result.port, None);
|
|
|
|
|
assert!(!result.init);
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_port_flag() {
|
|
|
|
|
let args = vec!["--port".to_string(), "4000".to_string()];
|
|
|
|
|
let result = parse_cli_args(&args).unwrap();
|
|
|
|
|
assert_eq!(result.port, Some(4000));
|
|
|
|
|
assert_eq!(result.path, None);
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_port_equals_syntax() {
|
|
|
|
|
let args = vec!["--port=5000".to_string()];
|
|
|
|
|
let result = parse_cli_args(&args).unwrap();
|
|
|
|
|
assert_eq!(result.port, Some(5000));
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_port_with_path() {
|
2026-04-09 17:58:29 +01:00
|
|
|
let args = vec![
|
|
|
|
|
"--port".to_string(),
|
|
|
|
|
"4200".to_string(),
|
|
|
|
|
"/some/path".to_string(),
|
|
|
|
|
];
|
2026-03-28 13:47:02 +00:00
|
|
|
let result = parse_cli_args(&args).unwrap();
|
|
|
|
|
assert_eq!(result.port, Some(4200));
|
|
|
|
|
assert_eq!(result.path, Some("/some/path".to_string()));
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_port_missing_value_is_error() {
|
|
|
|
|
let args = vec!["--port".to_string()];
|
|
|
|
|
assert!(parse_cli_args(&args).is_err());
|
2026-03-23 12:51:59 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-28 13:47:02 +00:00
|
|
|
#[test]
|
|
|
|
|
fn parse_port_invalid_value_is_error() {
|
|
|
|
|
let args = vec!["--port".to_string(), "abc".to_string()];
|
|
|
|
|
assert!(parse_cli_args(&args).is_err());
|
|
|
|
|
}
|
2026-03-22 19:07:07 +00:00
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn parse_init_subcommand() {
|
|
|
|
|
let args = vec!["init".to_string()];
|
|
|
|
|
let result = parse_cli_args(&args).unwrap();
|
|
|
|
|
assert!(result.init);
|
|
|
|
|
assert_eq!(result.path, None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn parse_init_with_path_and_port() {
|
2026-04-09 17:58:29 +01:00
|
|
|
let args = vec![
|
|
|
|
|
"init".to_string(),
|
|
|
|
|
"--port".to_string(),
|
|
|
|
|
"3000".to_string(),
|
|
|
|
|
"/my/project".to_string(),
|
|
|
|
|
];
|
2026-03-28 13:47:02 +00:00
|
|
|
let result = parse_cli_args(&args).unwrap();
|
|
|
|
|
assert!(result.init);
|
|
|
|
|
assert_eq!(result.port, Some(3000));
|
|
|
|
|
assert_eq!(result.path, Some("/my/project".to_string()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── resolve_path_arg ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn resolve_path_arg_none_when_no_path() {
|
2026-03-22 19:07:07 +00:00
|
|
|
let cwd = PathBuf::from("/home/user/project");
|
2026-03-28 13:47:02 +00:00
|
|
|
let result = resolve_path_arg(None, &cwd);
|
2026-03-22 19:07:07 +00:00
|
|
|
assert!(result.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn resolve_path_arg_returns_path_for_absolute_arg() {
|
2026-03-22 19:07:07 +00:00
|
|
|
let cwd = PathBuf::from("/home/user/project");
|
2026-03-28 13:47:02 +00:00
|
|
|
let result = resolve_path_arg(Some("/some/absolute/path"), &cwd).unwrap();
|
2026-03-22 19:07:07 +00:00
|
|
|
assert!(
|
|
|
|
|
result.ends_with("absolute/path") || result == PathBuf::from("/some/absolute/path")
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
2026-03-28 13:47:02 +00:00
|
|
|
fn resolve_path_arg_resolves_dot_to_cwd() {
|
2026-03-22 19:07:07 +00:00
|
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
|
|
|
let cwd = tmp.path().to_path_buf();
|
2026-03-28 13:47:02 +00:00
|
|
|
let result = resolve_path_arg(Some("."), &cwd).unwrap();
|
2026-03-22 19:07:07 +00:00
|
|
|
assert_eq!(result, cwd.canonicalize().unwrap_or(cwd));
|
|
|
|
|
}
|
|
|
|
|
}
|