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: