diff --git a/README.md b/README.md index bcf6136e..bb3f177c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@

Open-source Agent OS built in Rust. 137K LOC. 14 crates. 1,767+ tests. Zero clippy warnings.
- One binary. Production-grade. Agents that actually work for you. + One binary. Battle-tested. Agents that actually work for you.

@@ -34,7 +34,7 @@ ## What is OpenFang? -OpenFang is a **production-grade Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust. +OpenFang is an **open-source Agent Operating System** — not a chatbot framework, not a Python wrapper around an LLM, not a "multi-agent orchestrator." It is a full operating system for autonomous agents, built from scratch in Rust. Traditional agent frameworks wait for you to type something. OpenFang runs **autonomous agents that work for you** — on schedules, 24/7, building knowledge graphs, monitoring targets, generating leads, managing your social media, and reporting results to your dashboard. @@ -370,7 +370,7 @@ cargo fmt --all -- --check ## Stability Notice -OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is production-grade. That said: +OpenFang v0.1.0 is the first public release. The architecture is solid, the test suite is comprehensive, and the security model is comprehensive. That said: - **Breaking changes** may occur between minor versions until v1.0 - **Some Hands** are more mature than others (Browser and Researcher are the most battle-tested) diff --git a/crates/openfang-api/src/routes.rs b/crates/openfang-api/src/routes.rs index 7b00fe92..832f0319 100644 --- a/crates/openfang-api/src/routes.rs +++ b/crates/openfang-api/src/routes.rs @@ -31,6 +31,8 @@ pub struct AppState { pub bridge_manager: tokio::sync::Mutex>, /// Live channel config — updated on every hot-reload so list_channels() reflects reality. pub channels_config: tokio::sync::RwLock, + /// Notify handle to trigger graceful HTTP server shutdown from the API. + pub shutdown_notify: Arc, } /// POST /api/agents — Spawn a new agent. @@ -492,6 +494,8 @@ pub async fn shutdown(State(state): State>) -> impl IntoResponse { "ok", ); state.kernel.shutdown(); + // Signal the HTTP server to initiate graceful shutdown so the process exits. + state.shutdown_notify.notify_one(); Json(serde_json::json!({"status": "shutting_down"})) } diff --git a/crates/openfang-api/src/server.rs b/crates/openfang-api/src/server.rs index a1f36344..0e289352 100644 --- a/crates/openfang-api/src/server.rs +++ b/crates/openfang-api/src/server.rs @@ -48,6 +48,7 @@ pub async fn build_router( peer_registry: kernel.peer_registry.as_ref().map(|r| Arc::new(r.clone())), bridge_manager: tokio::sync::Mutex::new(bridge), channels_config: tokio::sync::RwLock::new(channels_config), + shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); // CORS: allow localhost origins by default. If API key is set, the API @@ -710,11 +711,12 @@ pub async fn run_daemon( // Run server with graceful shutdown. // SECURITY: `into_make_service_with_connect_info` injects the peer // SocketAddr so the auth middleware can check for loopback connections. + let api_shutdown = state.shutdown_notify.clone(); axum::serve( listener, app.into_make_service_with_connect_info::(), ) - .with_graceful_shutdown(shutdown_signal()) + .with_graceful_shutdown(shutdown_signal(api_shutdown)) .await?; // Clean up daemon info file @@ -752,11 +754,11 @@ pub fn read_daemon_info(home_dir: &Path) -> Option { serde_json::from_str(&contents).ok() } -/// Wait for an OS termination signal. +/// Wait for an OS termination signal OR an API shutdown request. /// -/// On Unix: listens for SIGINT and SIGTERM. -/// On Windows: listens for Ctrl+C. -async fn shutdown_signal() { +/// On Unix: listens for SIGINT, SIGTERM, and API notify. +/// On Windows: listens for Ctrl+C and API notify. +async fn shutdown_signal(api_shutdown: Arc) { #[cfg(unix)] { use tokio::signal::unix::{signal, SignalKind}; @@ -770,15 +772,22 @@ async fn shutdown_signal() { _ = sigterm.recv() => { info!("Received SIGTERM, shutting down..."); } + _ = api_shutdown.notified() => { + info!("Shutdown requested via API, shutting down..."); + } } } #[cfg(not(unix))] { - tokio::signal::ctrl_c() - .await - .expect("Failed to install Ctrl+C handler"); - info!("Ctrl+C received, shutting down..."); + tokio::select! { + _ = tokio::signal::ctrl_c() => { + info!("Ctrl+C received, shutting down..."); + } + _ = api_shutdown.notified() => { + info!("Shutdown requested via API, shutting down..."); + } + } } } diff --git a/crates/openfang-api/tests/api_integration_test.rs b/crates/openfang-api/tests/api_integration_test.rs index c00effa7..87667aa1 100644 --- a/crates/openfang-api/tests/api_integration_test.rs +++ b/crates/openfang-api/tests/api_integration_test.rs @@ -75,6 +75,7 @@ async fn start_test_server_with_provider( peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), + shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let app = Router::new() @@ -700,6 +701,7 @@ async fn start_test_server_with_auth(api_key: &str) -> TestServer { peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), + shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let api_key_state = state.kernel.config.api_key.clone(); diff --git a/crates/openfang-api/tests/daemon_lifecycle_test.rs b/crates/openfang-api/tests/daemon_lifecycle_test.rs index 86028309..81fcfa7a 100644 --- a/crates/openfang-api/tests/daemon_lifecycle_test.rs +++ b/crates/openfang-api/tests/daemon_lifecycle_test.rs @@ -112,6 +112,7 @@ async fn test_full_daemon_lifecycle() { peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), + shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let app = Router::new() @@ -234,6 +235,7 @@ async fn test_server_immediate_responsiveness() { peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), + shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let app = Router::new() diff --git a/crates/openfang-api/tests/load_test.rs b/crates/openfang-api/tests/load_test.rs index 08dfbd01..5840a79f 100644 --- a/crates/openfang-api/tests/load_test.rs +++ b/crates/openfang-api/tests/load_test.rs @@ -56,6 +56,7 @@ async fn start_test_server() -> TestServer { peer_registry: None, bridge_manager: tokio::sync::Mutex::new(None), channels_config: tokio::sync::RwLock::new(Default::default()), + shutdown_notify: Arc::new(tokio::sync::Notify::new()), }); let app = Router::new() diff --git a/crates/openfang-cli/src/main.rs b/crates/openfang-cli/src/main.rs index fb819a3f..5d908ff4 100644 --- a/crates/openfang-cli/src/main.rs +++ b/crates/openfang-cli/src/main.rs @@ -1332,7 +1332,23 @@ fn cmd_stop() { let client = daemon_client(); match client.post(format!("{base}/api/shutdown")).send() { Ok(r) if r.status().is_success() => { - ui::success("Daemon is shutting down"); + // Wait for daemon to actually stop (up to 5 seconds) + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if find_daemon().is_none() { + ui::success("Daemon stopped"); + return; + } + } + // Still alive — force kill via PID + if let Some(home) = dirs::home_dir() { + let of_dir = home.join(".openfang"); + if let Some(info) = read_daemon_info(&of_dir) { + force_kill_pid(info.pid); + let _ = std::fs::remove_file(of_dir.join("daemon.json")); + } + } + ui::success("Daemon stopped (forced)"); } Ok(r) => { ui::error(&format!("Shutdown request failed ({})", r.status())); @@ -1351,6 +1367,21 @@ fn cmd_stop() { } } +fn force_kill_pid(pid: u32) { + #[cfg(unix)] + { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output(); + } + #[cfg(windows)] + { + let _ = std::process::Command::new("taskkill") + .args(["/PID", &pid.to_string(), "/F"]) + .output(); + } +} + /// Show context-aware error for kernel boot failures. fn boot_kernel_error(e: &openfang_kernel::error::KernelError) { let msg = e.to_string(); diff --git a/docker-compose.yml b/docker-compose.yml index c264f40e..b09f85f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,12 @@ +# NOTE: The GHCR image (ghcr.io/rightnow-ai/openfang) is not yet public. +# For now, build from source using `docker compose up --build`. +# See: https://github.com/RightNow-AI/openfang/issues/12 + version: "3.8" services: openfang: build: . - image: ghcr.io/rightnow-ai/openfang:latest + # image: ghcr.io/rightnow-ai/openfang:latest # Uncomment when GHCR is public ports: - "4200:4200" volumes: