Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,8 @@ Client save-nudge (per tool):
## Deployment

- **Docker / Compose.** The included `Dockerfile` builds the binaries; `docker-compose.yml` runs the full stack.
- **Kubernetes.** daimon-mcp is a stateless `Deployment` (with an HPA); PostgreSQL and Qdrant are `StatefulSet`s. The embedder needs **AVX2**; schedule daimon-mcp + daimon-indexer onto an AVX2 node. Build the image (for example with kaniko) and apply your manifests, or sync via GitOps.
- **Kubernetes.** daimon-mcp is a stateless `Deployment` (with an HPA); PostgreSQL and Qdrant are `StatefulSet`s. The embedder needs **AVX2** on x86_64; schedule daimon-mcp + daimon-indexer onto an AVX2 node. Build the image (for example with kaniko) and apply your manifests, or sync via GitOps.
- **AVX2 and recall tiers.** Without AVX2 the stack still runs, but recall degrades to keyword-only: the embedder refuses to load (clear warning, no crash), the indexer parks instead of restart-looping, and both `/readyz` and `daimon health` report `recall_tier` (`hybrid | keyword | unhealthy`). `install.sh` preflights AVX2 after bringing the stack up. To backfill semantic recall after moving to a capable host, run `daimon reindex`.

## Backup and restore

Expand Down
11 changes: 10 additions & 1 deletion crates/daimon-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,18 @@ async fn health() -> Result<()> {
Ok(vs) => vs.ensure().await.is_ok(),
Err(_) => false,
};
// Mirrors /readyz: keyword recall needs Postgres only; hybrid additionally needs
// Qdrant plus a CPU the embedder can run on (AVX2 on x86_64).
let recall_tier = if !pg_ok {
"unhealthy"
} else if qd_ok && daimon_vec::embedder_supported() {
"hybrid"
} else {
"keyword"
};
println!(
"{}",
json!({"postgres": pg_ok, "qdrant": qd_ok, "healthy": pg_ok && qd_ok})
json!({"postgres": pg_ok, "qdrant": qd_ok, "recall_tier": recall_tier, "healthy": pg_ok && qd_ok})
);
if pg_ok && qd_ok {
Ok(())
Expand Down
21 changes: 17 additions & 4 deletions crates/daimon-indexer/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,16 +52,29 @@ async fn main() -> anyhow::Result<()> {
std::env::var("DAIMON_QDRANT_URL").unwrap_or_else(|_| "http://127.0.0.1:6334".to_string());
let store = VectorStore::connect(&qdrant_url).map_err(to_anyhow)?;
store.ensure().await.map_err(to_anyhow)?;
tracing::info!("indexer: loading embedder (bge-small, first run downloads the model)…");
let embedder = Embedder::new().map_err(to_anyhow)?;
tracing::info!(%qdrant_url, "indexer: ready");

// Graceful shutdown: flips on SIGTERM/Ctrl-C. Each batch is already crash-safe (Qdrant
// upsert before the processed_at mark, idempotent by record id), so we just stop cleanly
// between batches rather than getting SIGKILLed mid-sleep.
let shutdown = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
spawn_signal_watcher(shutdown.clone());

tracing::info!("indexer: loading embedder (bge-small, first run downloads the model)…");
let embedder = match Embedder::new() {
Ok(e) => e,
Err(e) if !once => {
// Degrade, don't crash: exiting here puts compose/k8s into a restart loop. The
// server still serves keyword-only recall (recall_tier=keyword on /readyz); the
// outbox accumulates and is drained by `daimon reindex` once on capable hardware.
tracing::warn!(%e, "indexer: embedder unavailable; semantic indexing disabled, parking (recall stays keyword-only)");
while !shutdown.load(std::sync::atomic::Ordering::Relaxed) {
sleep_or_shutdown(Duration::from_secs(3600), &shutdown).await;
}
return Ok(());
}
Err(e) => return Err(to_anyhow(e)),
};
tracing::info!(%qdrant_url, "indexer: ready");

loop {
if shutdown.load(std::sync::atomic::Ordering::Relaxed) {
tracing::info!("indexer: shutdown signal received; exiting between batches");
Expand Down
31 changes: 27 additions & 4 deletions crates/daimon-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {

#[cfg(test)]
mod auth_tests {
use super::token_matches;
use super::{recall_tier, token_matches};

fn keys() -> Vec<String> {
vec!["admin-aaa".into(), "claude-bbb".into(), "izu-ccc".into()]
Expand All @@ -251,13 +251,33 @@ mod auth_tests {
fn no_configured_tokens_matches_nothing() {
assert!(!token_matches("anything", &[]));
}

#[test]
fn recall_tier_needs_both_semantic_halves() {
assert_eq!(recall_tier(true, true), "hybrid");
assert_eq!(recall_tier(false, true), "keyword"); // no AVX2 / embedder init failed
assert_eq!(recall_tier(true, false), "keyword"); // qdrant absent
assert_eq!(recall_tier(false, false), "keyword");
}
}

/// Which recall path this process can serve: "hybrid" (keyword + semantic) when both the
/// embedder and Qdrant were available at startup, else "keyword". "unhealthy" is reported
/// by /readyz when Postgres (the keyword tier itself) is unreachable.
fn recall_tier(has_embedder: bool, has_vector: bool) -> &'static str {
if has_embedder && has_vector {
"hybrid"
} else {
"keyword"
}
}

async fn health() -> impl IntoResponse {
async fn health(State(st): State<AppState>) -> impl IntoResponse {
Json(json!({
"status": "ok",
"service": "daimon-mcp",
"version": env!("CARGO_PKG_VERSION"),
"recall_tier": recall_tier(st.embedder.is_some(), st.vector.is_some()),
}))
}

Expand All @@ -269,13 +289,16 @@ async fn readyz(State(st): State<AppState>) -> impl IntoResponse {
if !st.store.ping().await {
return (
StatusCode::SERVICE_UNAVAILABLE,
Json(json!({"ready": false, "reason": "postgres unreachable"})),
Json(json!({"ready": false, "reason": "postgres unreachable", "recall_tier": "unhealthy"})),
);
}
// Readiness is PG-reachability only. Outbox lag is advisory: a stalled/dead indexer
// (the known failure mode the retired monitoring stack used to catch) shows up here as
// a warning field without flapping the probe.
let mut body = json!({"ready": true});
let mut body = json!({
"ready": true,
"recall_tier": recall_tier(st.embedder.is_some(), st.vector.is_some()),
});
if let Some((pending, oldest)) = st.store.outbox_lag().await {
body["outbox_pending"] = json!(pending);
body["outbox_oldest_age_secs"] = json!(oldest);
Expand Down
20 changes: 20 additions & 0 deletions crates/daimon-vec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,33 @@ fn qe<E: std::fmt::Display>(e: E) -> MemoryError {
MemoryError::Backend(e.to_string())
}

/// True when this CPU can run the embedder. The ort/ONNX build behind fastembed executes
/// AVX2 instructions unconditionally on x86_64, so an AVX-only CPU dies with SIGILL (not a
/// catchable error) at first inference, long after startup looked healthy. Gating init on
/// this turns that crash into the normal degradation path (keyword-only recall).
pub fn embedder_supported() -> bool {
#[cfg(target_arch = "x86_64")]
{
std::arch::is_x86_feature_detected!("avx2")
}
#[cfg(not(target_arch = "x86_64"))]
{
true // aarch64 (Apple silicon, Graviton) runs the NEON path fine
}
}

/// In-process dense embedder (bge-small, 384-d).
pub struct Embedder {
inner: Mutex<TextEmbedding>,
}

impl Embedder {
pub fn new() -> Result<Self> {
if !embedder_supported() {
return Err(MemoryError::Backend(
"avx2 unavailable; embedder disabled, recall degrades to keyword-only".into(),
));
}
let opts = TextInitOptions::new(EmbeddingModel::BGESmallENV15);
let inner = TextEmbedding::try_new(opts)
.map_err(|e| MemoryError::Backend(format!("embedder init: {e}")))?;
Expand Down
12 changes: 12 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,18 @@ for _ in $(seq 1 30); do
sleep 2
done
echo

# AVX2 preflight (x86_64 only; arm64 runs the NEON path). Without AVX2 the embedder cannot
# run and recall silently degrades to keyword-only, which otherwise takes log-spelunking to
# discover. /readyz reports the resulting tier either way.
( cd "$SELF_DIR" && docker compose exec -T daimon-mcp sh -c \
'[ "$(uname -m)" != "x86_64" ] || grep -qm1 avx2 /proc/cpuinfo' ) 2>/dev/null \
|| {
echo " WARN: AVX2 not detected on this host."
echo " The embedder is disabled and recall falls back to keyword-only."
echo " Run the stack on an AVX2-capable host for hybrid (semantic) recall;"
echo " check with: curl -s localhost:${API_PORT}/readyz (recall_tier field)"
}
# Seed the default operating protocols (behavioral + save discipline) that every tool loads
# at session start. Idempotent (Update-mode supersedes). Same in-image binary; import your own
# later with: docker compose exec daimon-mcp daimon protocol import <file-or-dir>.
Expand Down
Loading