From 26cc2cdb87653f26e35fedd1018efe1d0bbec04c Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 04:28:55 +0000 Subject: [PATCH 01/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 480 +++++++++++++++++- 1 file changed, 453 insertions(+), 27 deletions(-) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 4402759e..10de33af 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -252,41 +252,144 @@ fn degraded_reasons_for(project_root: &Path, status: &DaemonStatus) -> Vec Option { - use orchestrator_plugin_host::plugin_install_dir; - let dir = plugin_install_dir(); - let entries = std::fs::read_dir(&dir).ok()?; - let mut has_executable_subject_backend = false; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - if !name_str.starts_with("animus-subject-") { - continue; - } - let path = entry.path(); - let candidate = if path.is_dir() { path.join(name_str) } else { path }; - if is_binary_executable(&candidate) { - has_executable_subject_backend = true; - break; +/// Env var that, when set to a truthy value, instructs the daemon to run +/// without any subject-backend dispatch. Mirrors +/// `SUBJECT_PLUGINS_DISABLE_ENV` in `orchestrator-daemon-runtime`; kept as +/// a local literal so this crate stays free of an upward dependency on +/// daemon-runtime. +const SUBJECT_PLUGINS_DISABLE_ENV: &str = "ANIMUS_DAEMON_DISABLE_SUBJECT_PLUGINS"; + +/// Returns `true` when [`SUBJECT_PLUGINS_DISABLE_ENV`] is set to a truthy +/// value (`1`, `true`, `yes`, `on`, …). Mirrors +/// `orchestrator_daemon_runtime::subject_plugins_disable_env_set`. +fn subject_plugins_disabled() -> bool { + match std::env::var(SUBJECT_PLUGINS_DISABLE_ENV) { + Ok(val) => { + let trimmed = val.trim().to_ascii_lowercase(); + !trimmed.is_empty() && trimmed != "0" && trimmed != "false" && trimmed != "no" && trimmed != "off" } + Err(_) => false, } - if has_executable_subject_backend { - None - } else { - Some( +} + +/// Probe whether the configured subject-backend plugins can form a routable +/// [`orchestrator_plugin_host::SubjectRouter`]. Returns `None` (healthy) when: +/// +/// - subject plugins are deliberately disabled via +/// `ANIMUS_DAEMON_DISABLE_SUBJECT_PLUGINS`; or +/// - at least one `subject_backend` plugin is installed AND its kind routing +/// table builds without conflicts (duplicate kind claims, etc.). +/// +/// Returns a degraded reason when: +/// +/// - plugin discovery itself fails (e.g. a malformed `plugins.yaml`); +/// - no `subject_backend` plugins are installed; or +/// - two or more installed plugins claim the same subject kind, preventing +/// the router from building. +/// +/// Consolidated backends (e.g. `animus-postgres`) whose binary name does NOT +/// begin with `animus-subject-` are correctly recognized when registered in +/// `plugins.yaml` with `plugin_kind = "subject_backend"` — kind-based +/// detection never applies filename heuristics. +/// +/// Install-time kind renames recorded in `plugins.lock` are applied before +/// the duplicate-kind check, matching the logic in `resolve_subject_dispatch`. +fn subject_router_degraded_reason(project_root: &Path) -> Option { + use orchestrator_plugin_host::{discover_by_kind, SubjectPluginSpec, SubjectRouter}; + + if subject_plugins_disabled() { + return None; + } + + let plugins = match discover_by_kind(project_root, "subject_backend") { + Ok(p) => p, + Err(_) => { + return Some( + "subject_backend unroutable: plugin discovery failed — \ + run `animus plugin status` to diagnose" + .to_string(), + ); + } + }; + + if plugins.is_empty() { + return Some( "subject_backend unroutable: no executable subject-backend plugin installed — \ run `animus plugin install-defaults --include-subjects`" .to_string(), - ) + ); + } + + // Build the kind-routing table (no spawning) to detect conflicts such as + // two installed plugins claiming the same subject kind. Mirrors the logic + // in `orchestrator_daemon_runtime::resolve_subject_dispatch`. + let aliases = load_subject_kind_aliases(project_root, &plugins); + let specs: Vec = plugins + .iter() + .map(|p| SubjectPluginSpec { + name: p.name.clone(), + path: p.path.clone(), + native_kinds: p + .manifest + .capabilities + .iter() + .filter_map(|cap| cap.strip_prefix("subject_kind:")) + .map(|k| k.trim().to_string()) + .filter(|k| !k.is_empty()) + .collect(), + env_required: p.manifest.env_required.clone(), + notification_buffer_size: p.manifest.notification_buffer_size, + working_dir: None, + }) + .collect(); + + // Detect the case where all installed subject_backend plugins declare no + // subject kinds — the router would build successfully but serve nothing. + // This is distinct from "no plugins installed": plugins exist but none + // claims any subject_kind:* capability, so no request could ever be routed. + if specs.iter().all(|s| s.native_kinds.is_empty()) { + return Some( + "subject_backend unroutable: no installed subject-backend plugin declares any subject kinds — \ + run `animus plugin status` to diagnose kind registrations" + .to_string(), + ); + } + + match SubjectRouter::from_lazy_specs(specs, aliases) { + Ok(_) => None, + Err(err) => Some(format!( + "subject_backend unroutable: router construction failed ({err}) — \ + run `animus plugin status` to diagnose conflicting kind registrations" + )), } } +/// Load install-time kind renames from `plugins.lock` for the degraded-reason +/// router check. Mirrors `load_kind_aliases_from_lockfile` in +/// `orchestrator_daemon_runtime::subject_dispatch` but lives here to keep +/// `orchestrator-core` free of an upward dependency on daemon-runtime. +fn load_subject_kind_aliases( + project_root: &Path, + candidates: &[orchestrator_plugin_host::DiscoveredPlugin], +) -> orchestrator_plugin_host::KindAliasMap { + use orchestrator_plugin_host::{KindAliasMap, PluginLockfile}; + let mut aliases = KindAliasMap::default(); + let Ok(lock) = PluginLockfile::load_default(Some(project_root)) else { + return aliases; + }; + let candidate_names: std::collections::HashSet<&str> = candidates.iter().map(|p| p.name.as_str()).collect(); + for entry in &lock.plugins { + if !candidate_names.contains(entry.name.as_str()) { + continue; + } + let (Some(installed), Some(native)) = (entry.effective_installed_kind(), entry.effective_native_kind()) else { + continue; + }; + aliases.insert(&entry.name, installed, native); + } + aliases +} + /// v0.5: probe the working tree for an `animus.flavor.v1` manifest and /// return its `id`. v0.5 always emits `"default"` when the canonical /// manifest at `flavors/default.toml` is found; absent installs return @@ -877,4 +980,327 @@ mod tests { } assert!(read_daemon_health_cache(temp.path()).is_none(), "stale entry should not be returned"); } + + // ---- subject_router_degraded_reason regression tests ---------------- + // + // These tests manipulate process-global env vars that drive plugin + // discovery (`ANIMUS_CONFIG_DIR`, `ANIMUS_PLUGIN_DIR`, + // `ANIMUS_PLUGIN_PATH`). They must run serially so env mutations from + // one test don't race another. + + static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + struct EnvGuard { + key: &'static str, + prev: Option, + } + impl EnvGuard { + fn set(key: &'static str, value: impl AsRef) -> Self { + let prev = std::env::var_os(key); + std::env::set_var(key, value.as_ref()); + Self { key, prev } + } + } + impl Drop for EnvGuard { + fn drop(&mut self) { + match self.prev.take() { + Some(v) => std::env::set_var(self.key, v), + None => std::env::remove_var(self.key), + } + } + } + + /// Redirect the three env vars that drive `discover_by_kind` to isolated + /// temp dirs. Returns guards that restore the originals on drop. + fn isolate_discovery_env(config_dir: &Path, plugin_dir: &Path) -> (EnvGuard, EnvGuard, EnvGuard) { + ( + EnvGuard::set("ANIMUS_CONFIG_DIR", config_dir), + EnvGuard::set("ANIMUS_PLUGIN_DIR", plugin_dir), + EnvGuard::set("ANIMUS_PLUGIN_PATH", ""), + ) + } + + /// Regression test for TASK-211: a consolidated backend whose binary name + /// does NOT start with `animus-subject-` (e.g. `animus-postgres`) must not + /// produce a degraded reason when it declares `plugin_kind = "subject_backend"` + /// in its manifest and is registered in `plugins.yaml`. + #[cfg(unix)] + #[test] + fn subject_router_not_degraded_for_consolidated_backend_registered_as_subject_backend() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + // A fake `animus-postgres` binary: its name does not start with + // `animus-subject-`, so an old filename-prefix probe would have ignored + // it and falsely reported no subject_backend installed. + let bin = temp.path().join("animus-postgres"); + let manifest = serde_json::json!({ + "name": "animus-postgres", + "version": "0.1.0", + "plugin_kind": "subject_backend", + "description": "Consolidated PostgreSQL subject backend", + "protocol_version": "1.0.0", + "capabilities": ["subject_kind:task"] + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms).expect("chmod"); + + // Register the consolidated backend in the global plugins.yaml. + std::fs::write( + config_dir.join("plugins.yaml"), + format!("plugins:\n animus-postgres:\n binary: {}\n", bin.display()), + ) + .expect("write plugins.yaml"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_none(), + "consolidated backend `animus-postgres` declared as subject_backend must not produce a degraded reason, got: {reason:?}" + ); + } + + /// A project with no subject_backend plugins at all must produce a degraded + /// reason so operators see actionable feedback rather than a silent failure. + #[test] + fn subject_router_degraded_when_no_subject_backend_plugins_installed() { + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + // No plugins.yaml, no binaries — discover_by_kind returns an empty Vec. + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!(reason.is_some(), "no subject_backend plugins must produce a degraded reason"); + let r = reason.unwrap(); + assert!( + r.contains("subject_backend unroutable"), + "degraded reason must mention `subject_backend unroutable`, got: {r}" + ); + } + + /// When `discover_by_kind` itself returns an error (e.g. a malformed + /// `plugins.yaml`), the probe must report degraded rather than silently + /// returning `None` (false-healthy). This was the residual bug after the + /// `.ok()?` pattern was left in place post-refactor. + #[test] + fn subject_router_degraded_on_discovery_failure_not_false_healthy() { + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + // A bare integer is valid YAML but cannot be deserialized as + // PluginsConfig (a struct), so load_plugins_config returns Err and + // discover_by_kind propagates it. + std::fs::write(config_dir.join("plugins.yaml"), "12345\n").expect("write invalid yaml"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_some(), + "plugin discovery failure must produce a degraded reason, not false-healthy (None)" + ); + } + + /// When `ANIMUS_DAEMON_DISABLE_SUBJECT_PLUGINS` is set to a truthy value, + /// the operator has deliberately disabled subject-backend dispatch — this + /// is not a degraded state. The probe must return `None` even when no + /// subject_backend plugins are installed (which would otherwise trigger the + /// "no plugin installed" degraded reason). + #[test] + fn subject_router_not_degraded_when_subject_plugins_disabled_by_env() { + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + // No plugins.yaml, no binaries — would normally degrade as "no subject_backend installed". + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let _disable = EnvGuard::set(SUBJECT_PLUGINS_DISABLE_ENV, "1"); + + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_none(), + "ANIMUS_DAEMON_DISABLE_SUBJECT_PLUGINS=1 must suppress the degraded reason, got: {reason:?}" + ); + } + + /// `subject_plugins_disabled` must agree with the real boot parser in + /// `orchestrator-daemon-runtime` for every canonical falsy value, including + /// mixed-case variants. `"False"`, `"FALSE"`, `"No"`, `"Off"`, and `"0"` + /// must all be treated as falsy (plugins NOT disabled), and `"true"`, + /// `"True"`, `"TRUE"`, `"1"`, `"yes"`, `"on"` as truthy (plugins disabled). + #[test] + fn subject_plugins_disabled_matches_boot_parser_for_mixed_case_falsy_values() { + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + + let falsy = ["", "0", "false", "False", "FALSE", "no", "No", "NO", "off", "Off", "OFF"]; + for val in &falsy { + let _g = EnvGuard::set(SUBJECT_PLUGINS_DISABLE_ENV, val); + assert!( + !subject_plugins_disabled(), + "expected subject_plugins_disabled() == false for {SUBJECT_PLUGINS_DISABLE_ENV}={val:?}" + ); + } + + let truthy = ["1", "true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON"]; + for val in &truthy { + let _g = EnvGuard::set(SUBJECT_PLUGINS_DISABLE_ENV, val); + assert!( + subject_plugins_disabled(), + "expected subject_plugins_disabled() == true for {SUBJECT_PLUGINS_DISABLE_ENV}={val:?}" + ); + } + } + + /// A `subject_backend` plugin that declares NO `subject_kind:*` capabilities + /// must produce a degraded reason. The router it would build serves nothing — + /// any call would fail with "no backend registered for kind". This guards the + /// case where `discover_by_kind` returns plugins but `from_lazy_specs` would + /// succeed with an empty routing table (false-healthy). + #[cfg(unix)] + #[test] + fn subject_router_degraded_when_subject_backend_declares_no_kinds() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + let bin = temp.path().join("animus-subject-nokind"); + let manifest = serde_json::json!({ + "name": "animus-subject-nokind", + "version": "0.1.0", + "plugin_kind": "subject_backend", + "description": "subject backend with no subject kinds", + "protocol_version": "1.0.0", + "capabilities": [] + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms).expect("chmod"); + + std::fs::write( + config_dir.join("plugins.yaml"), + format!("plugins:\n animus-subject-nokind:\n binary: {}\n", bin.display()), + ) + .expect("write plugins.yaml"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_some(), + "subject_backend with no subject kinds must produce a degraded reason (false-healthy guard)" + ); + let r = reason.unwrap(); + assert!( + r.contains("subject_backend unroutable"), + "degraded reason must mention `subject_backend unroutable`, got: {r}" + ); + } + + /// Two `subject_backend` plugins that both declare `subject_kind:task` cause + /// `SubjectRouter::from_lazy_specs` to fail with a duplicate-kind error. + /// The probe must surface this as a degraded reason so the operator can + /// diagnose the conflicting installations instead of silently routing to + /// whichever plugin happened to be discovered first. + #[cfg(unix)] + #[test] + fn subject_router_degraded_on_duplicate_kind_conflict() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + let make_plugin = |name: &str| -> std::path::PathBuf { + let bin = temp.path().join(name); + let manifest = serde_json::json!({ + "name": name, + "version": "0.1.0", + "plugin_kind": "subject_backend", + "description": format!("fake {name}"), + "protocol_version": "1.0.0", + "capabilities": ["subject_kind:task"] + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms).expect("chmod"); + bin + }; + + let bin_a = make_plugin("fake-subject-a"); + let bin_b = make_plugin("fake-subject-b"); + + std::fs::write( + config_dir.join("plugins.yaml"), + format!( + "plugins:\n fake-subject-a:\n binary: {}\n fake-subject-b:\n binary: {}\n", + bin_a.display(), + bin_b.display() + ), + ) + .expect("write plugins.yaml"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_some(), + "duplicate subject_kind:task claims must produce a degraded reason" + ); + let r = reason.unwrap(); + assert!( + r.contains("subject_backend unroutable"), + "degraded reason must mention `subject_backend unroutable`, got: {r}" + ); + assert!( + r.contains("router construction failed") || r.contains("duplicate"), + "degraded reason must mention the router construction failure or duplicate kind, got: {r}" + ); + } } From e90f46fee9ab3d7963a40fc7ae8b98cb38f18c0e Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:11:32 +0000 Subject: [PATCH 02/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/orchestrator-core/src/services/daemon_impl.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 10de33af..0b1a47f2 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -295,13 +295,14 @@ fn subject_plugins_disabled() -> bool { /// Install-time kind renames recorded in `plugins.lock` are applied before /// the duplicate-kind check, matching the logic in `resolve_subject_dispatch`. fn subject_router_degraded_reason(project_root: &Path) -> Option { + use animus_plugin_protocol::PLUGIN_KIND_SUBJECT_BACKEND; use orchestrator_plugin_host::{discover_by_kind, SubjectPluginSpec, SubjectRouter}; if subject_plugins_disabled() { return None; } - let plugins = match discover_by_kind(project_root, "subject_backend") { + let plugins = match discover_by_kind(project_root, PLUGIN_KIND_SUBJECT_BACKEND) { Ok(p) => p, Err(_) => { return Some( From 8f77f8c9492b1a8b4cc59dd3fbafcc8f61ed7de2 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:45:15 +0000 Subject: [PATCH 03/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../orchestrator-core/src/services/daemon_impl.rs | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 0b1a47f2..dfd73af5 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -304,12 +304,11 @@ fn subject_router_degraded_reason(project_root: &Path) -> Option { let plugins = match discover_by_kind(project_root, PLUGIN_KIND_SUBJECT_BACKEND) { Ok(p) => p, - Err(_) => { - return Some( - "subject_backend unroutable: plugin discovery failed — \ + Err(err) => { + return Some(format!( + "subject_backend unroutable: plugin discovery failed ({err:#}) — \ run `animus plugin status` to diagnose" - .to_string(), - ); + )); } }; @@ -1126,6 +1125,11 @@ mod tests { reason.is_some(), "plugin discovery failure must produce a degraded reason, not false-healthy (None)" ); + let reason = reason.unwrap(); + assert!( + reason.contains("plugins.yaml"), + "discovery failure must retain the underlying configuration error, got: {reason}" + ); } /// When `ANIMUS_DAEMON_DISABLE_SUBJECT_PLUGINS` is set to a truthy value, From 466289f67163bd2d06cf2076cfaefa27b0f3cb13 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:50:21 +0000 Subject: [PATCH 04/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/orchestrator-core/src/services/daemon_impl.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index dfd73af5..f98fbf21 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1069,6 +1069,12 @@ mod tests { reason.is_none(), "consolidated backend `animus-postgres` declared as subject_backend must not produce a degraded reason, got: {reason:?}" ); + + let reasons = degraded_reasons_for(&project_root, &DaemonStatus::Running); + assert!( + reasons.is_empty(), + "a running daemon with a routable consolidated backend must remain healthy, got: {reasons:?}" + ); } /// A project with no subject_backend plugins at all must produce a degraded From f7c492ac40d7677a38d36f933ef5c5471916f36a Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 02:55:45 +0000 Subject: [PATCH 05/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index f98fbf21..50894581 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1195,6 +1195,32 @@ mod tests { } } + /// A falsy disable value must not accidentally mask a genuinely + /// unroutable subject router. This exercises the degraded-reason path, + /// rather than only checking the env parser in isolation. + #[test] + fn subject_router_still_degraded_when_disable_env_is_falsy() { + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let _disable = EnvGuard::set(SUBJECT_PLUGINS_DISABLE_ENV, "False"); + + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.as_deref().is_some_and(|r| r.contains("no executable subject-backend plugin")), + "a falsy disable value must preserve the no-backend degraded reason, got: {reason:?}" + ); + } + /// A `subject_backend` plugin that declares NO `subject_kind:*` capabilities /// must produce a degraded reason. The router it would build serves nothing — /// any call would fail with "no backend registered for kind". This guards the From a15e4def14d376e8f52dd9c4a90aae047e5317fe Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:00:31 +0000 Subject: [PATCH 06/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/orchestrator-core/src/services/daemon_impl.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 50894581..ee052a65 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1070,11 +1070,13 @@ mod tests { "consolidated backend `animus-postgres` declared as subject_backend must not produce a degraded reason, got: {reason:?}" ); - let reasons = degraded_reasons_for(&project_root, &DaemonStatus::Running); - assert!( - reasons.is_empty(), - "a running daemon with a routable consolidated backend must remain healthy, got: {reasons:?}" - ); + for status in [DaemonStatus::Running, DaemonStatus::Paused] { + let reasons = degraded_reasons_for(&project_root, &status); + assert!( + reasons.is_empty(), + "a live daemon ({status:?}) with a routable consolidated backend must remain healthy, got: {reasons:?}" + ); + } } /// A project with no subject_backend plugins at all must produce a degraded From a8c5d83a93cfe6d68492f89e7fd6e322a77d3fea Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:05:37 +0000 Subject: [PATCH 07/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/orchestrator-core/src/services/daemon_impl.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index ee052a65..9f62e67b 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -339,7 +339,12 @@ fn subject_router_degraded_reason(project_root: &Path) -> Option { .collect(), env_required: p.manifest.env_required.clone(), notification_buffer_size: p.manifest.notification_buffer_size, - working_dir: None, + // Keep the health-side router specification identical to the one + // built during daemon boot. Although this probe does not spawn a + // backend, preserving the project working directory prevents the + // two reconciliation paths from drifting if router validation + // starts consulting the complete spec. + working_dir: Some(project_root.to_path_buf()), }) .collect(); From 5e686cf84de3e45cbfe0b4549036421f9efe7916 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:40:49 +0000 Subject: [PATCH 08/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 9f62e67b..0ccfdaf9 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1084,6 +1084,53 @@ mod tests { } } + /// Project-scoped plugin configuration is the first discovery tier used at + /// daemon boot. Keep the health reconciliation on that same path so a + /// consolidated backend configured only for one project is not mistaken + /// for a missing subject backend. + #[cfg(unix)] + #[test] + fn subject_router_not_degraded_for_project_scoped_consolidated_backend() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + let project_root = temp.path().join("project"); + let project_config_dir = project_root.join(".animus"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + std::fs::create_dir_all(&project_config_dir).expect("mkdir project config"); + + let bin = temp.path().join("animus-postgres"); + let manifest = serde_json::json!({ + "name": "animus-postgres", + "version": "0.1.0", + "plugin_kind": "subject_backend", + "description": "Project PostgreSQL subject backend", + "protocol_version": "1.0.0", + "capabilities": ["subject_kind:task"] + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms).expect("chmod"); + + std::fs::write( + project_config_dir.join("plugins.yaml"), + format!("plugins:\n animus-postgres:\n binary: {}\n", bin.display()), + ) + .expect("write project plugins.yaml"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_none(), + "project-scoped consolidated subject backend must be routable, got: {reason:?}" + ); + } + /// A project with no subject_backend plugins at all must produce a degraded /// reason so operators see actionable feedback rather than a silent failure. #[test] From b62f071ea83517f259465662faac37cdc2191ac4 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:48:12 +0000 Subject: [PATCH 09/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 47 +++++++++++++++++-- .../orchestrator-plugin-host/src/discovery.rs | 34 +++++++++----- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 0ccfdaf9..58ae1548 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -288,9 +288,9 @@ fn subject_plugins_disabled() -> bool { /// the router from building. /// /// Consolidated backends (e.g. `animus-postgres`) whose binary name does NOT -/// begin with `animus-subject-` are correctly recognized when registered in -/// `plugins.yaml` with `plugin_kind = "subject_backend"` — kind-based -/// detection never applies filename heuristics. +/// begin with `animus-subject-` are correctly recognized from their manifest's +/// `plugin_kind = "subject_backend"`, whether found in the canonical plugin +/// directory or registered in `plugins.yaml`. /// /// Install-time kind renames recorded in `plugins.lock` are applied before /// the duplicate-kind check, matching the logic in `resolve_subject_dispatch`. @@ -1084,6 +1084,47 @@ mod tests { } } + /// Consolidated plugins installed in the normal plugin directory are also + /// discovered from their manifest kind. They do not need an explicit + /// `plugins.yaml` entry merely because their executable name lacks the + /// legacy `animus-subject-` prefix. + #[cfg(unix)] + #[test] + fn subject_router_not_degraded_for_scanned_consolidated_backend() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + let bin = plugin_dir.join("animus-postgres"); + let manifest = serde_json::json!({ + "name": "animus-postgres", + "version": "0.1.0", + "plugin_kind": "subject_backend", + "description": "Consolidated PostgreSQL subject backend", + "protocol_version": "1.0.0", + "capabilities": ["subject_kind:task"] + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms).expect("chmod"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_none(), + "scanned consolidated subject backend must be routable without a plugins.yaml entry, got: {reason:?}" + ); + } + /// Project-scoped plugin configuration is the first discovery tier used at /// daemon boot. Keep the health reconciliation on that same path so a /// consolidated backend configured only for one project is not mistaken diff --git a/crates/orchestrator-plugin-host/src/discovery.rs b/crates/orchestrator-plugin-host/src/discovery.rs index 88273154..f6145f4b 100644 --- a/crates/orchestrator-plugin-host/src/discovery.rs +++ b/crates/orchestrator-plugin-host/src/discovery.rs @@ -297,7 +297,7 @@ impl PluginDiscovery { self } - /// Opt in to scanning `$PATH` for `animus-plugin-*` / `animus-provider-*` binaries. + /// Opt in to scanning `$PATH` for `animus-*` plugin binaries. /// /// Defaults to `false`. When enabled, [`PluginDiscovery::discover`] will /// execute every matching binary on `$PATH` with `--manifest` to fetch its @@ -382,10 +382,9 @@ impl PluginDiscovery { &cache, lockfile.as_ref(), ); - // The dir scan only matches `animus-plugin-*` / - // `animus-provider-*` file names; the project registry tier - // resolves project-scoped installs of every other official - // plugin name (`animus-subject-*`, `animus-queue-*`, ...). + // The dir scan matches the `animus-*` executable namespace. The + // project registry tier additionally resolves custom executable + // names and install-time logical-name overrides. self.discover_project_registry( project_root, &mut discovered, @@ -542,11 +541,10 @@ impl PluginDiscovery { /// Project-registry tier: `/.animus/plugins.yaml`, /// written by `animus plugin install --project`. Needed because the - /// project dir scan only picks up `animus-plugin-*` / - /// `animus-provider-*` file names — official plugins like - /// `animus-subject-default` or `animus-queue-default` are only - /// discoverable through their registry entry, exactly like their - /// global counterparts resolve through `~/.animus/plugins.yaml`. + /// project dir scan picks up the `animus-*` executable namespace; this + /// registry is still needed for custom executable names and install-time + /// logical-name overrides, exactly like the global registry at + /// `~/.animus/plugins.yaml`. /// A corrupt project registry degrades to a warning (the daemon must /// not lose every plugin because one project file is broken). fn discover_project_registry( @@ -906,11 +904,13 @@ fn scan_dir( /// Whether a binary file name is picked up by the directory-scan discovery /// tiers (project-local dir, global install dir, `$ANIMUS_PLUGIN_PATH`, -/// `$PATH`). Names outside these prefixes are only discoverable via a +/// `$PATH`). All Animus plugin kinds share the `animus-*` executable +/// namespace, including consolidated plugins such as `animus-postgres`. +/// Names outside this namespace are only discoverable via a /// registry entry (`~/.animus/plugins.yaml` for global installs, /// `/.animus/plugins.yaml` for project-scoped ones). pub fn is_scanned_plugin_name(name: &str) -> bool { - name.starts_with("animus-plugin-") || name.starts_with("animus-provider-") + name.starts_with("animus-") } fn load_plugins_config(path: &Path) -> Result { @@ -1094,6 +1094,16 @@ mod tests { ); } + #[test] + fn directory_scan_accepts_all_animus_plugin_names() { + assert!(is_scanned_plugin_name("animus-plugin-task")); + assert!(is_scanned_plugin_name("animus-provider-claude")); + assert!(is_scanned_plugin_name("animus-subject-default")); + assert!(is_scanned_plugin_name("animus-postgres")); + assert!(!is_scanned_plugin_name("postgres")); + assert!(!is_scanned_plugin_name("unrelated-executable")); + } + #[cfg(unix)] #[test] fn discover_by_kind_filters_to_matching_plugin_kind() { From 2c7993340b8ed1dd2b647b844fb57e1c39239a09 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:53:51 +0000 Subject: [PATCH 10/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 58ae1548..02aa34dc 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1125,6 +1125,46 @@ mod tests { ); } + /// Manifest kind is authoritative in both directions: a legacy-looking + /// `animus-subject-*` executable must not make the router appear routable + /// when it actually declares a different plugin kind. + #[cfg(unix)] + #[test] + fn subject_router_degraded_for_subject_prefixed_non_backend() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + let bin = plugin_dir.join("animus-subject-not-a-backend"); + let manifest = serde_json::json!({ + "name": "animus-subject-not-a-backend", + "version": "0.1.0", + "plugin_kind": "provider", + "description": "Provider with a misleading legacy-style name", + "protocol_version": "1.0.0", + "capabilities": ["subject_kind:task"] + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms).expect("chmod"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.as_deref().is_some_and(|r| r.contains("no executable subject-backend plugin")), + "a filename prefix must not override a non-subject manifest kind, got: {reason:?}" + ); + } + /// Project-scoped plugin configuration is the first discovery tier used at /// daemon boot. Keep the health reconciliation on that same path so a /// consolidated backend configured only for one project is not mistaken From c9e0efdb493c9f3e1be726feb54057f0feba77a2 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:59:06 +0000 Subject: [PATCH 11/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index 02aa34dc..d3e1212f 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1125,6 +1125,46 @@ mod tests { ); } + /// Capability-aware discovery must still enforce executability. A + /// consolidated backend with the right manifest but no execute bit cannot + /// serve RPCs and therefore must not clear the degraded reason. + #[cfg(unix)] + #[test] + fn subject_router_degraded_for_non_executable_consolidated_backend() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + let bin = plugin_dir.join("animus-postgres"); + let manifest = serde_json::json!({ + "name": "animus-postgres", + "version": "0.1.0", + "plugin_kind": "subject_backend", + "description": "Non-executable consolidated PostgreSQL backend", + "protocol_version": "1.0.0", + "capabilities": ["subject_kind:task"] + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o644); + std::fs::set_permissions(&bin, perms).expect("chmod"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.as_deref().is_some_and(|r| r.contains("no executable subject-backend plugin")), + "a non-executable consolidated backend must not make the router healthy, got: {reason:?}" + ); + } + /// Manifest kind is authoritative in both directions: a legacy-looking /// `animus-subject-*` executable must not make the router appear routable /// when it actually declares a different plugin kind. From b7827dc7ae9c3be8e0b1ac4888fb95d9565b81a9 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:06:05 +0000 Subject: [PATCH 12/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index d3e1212f..ba5319a5 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1449,6 +1449,62 @@ mod tests { ); } + /// A kindless auxiliary subject backend must not poison health when another + /// configured backend contributes a routable kind. Health is about whether + /// the resolved router can serve requests, not whether every discovered + /// subject-backend manifest independently declares a kind. + #[cfg(unix)] + #[test] + fn subject_router_not_degraded_when_any_backend_serves_a_kind() { + use std::os::unix::fs::PermissionsExt; + let _lock = TEST_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let temp = tempfile::tempdir().expect("tempdir"); + + let config_dir = temp.path().join("animus-home"); + let plugin_dir = temp.path().join("plugins"); + std::fs::create_dir_all(&config_dir).expect("mkdir config_dir"); + std::fs::create_dir_all(&plugin_dir).expect("mkdir plugin_dir"); + + let make_plugin = |name: &str, capabilities: serde_json::Value| { + let bin = temp.path().join(name); + let manifest = serde_json::json!({ + "name": name, + "version": "0.1.0", + "plugin_kind": "subject_backend", + "description": format!("fake {name}"), + "protocol_version": "1.0.0", + "capabilities": capabilities + }); + std::fs::write(&bin, format!("#!/bin/sh\nprintf '{}\\n'\n", manifest)).expect("write plugin"); + let mut perms = std::fs::metadata(&bin).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&bin, perms).expect("chmod"); + bin + }; + + let serving = make_plugin("animus-postgres", serde_json::json!(["subject_kind:task"])); + let auxiliary = make_plugin("animus-subject-auxiliary", serde_json::json!([])); + std::fs::write( + config_dir.join("plugins.yaml"), + format!( + "plugins:\n animus-postgres:\n binary: {}\n animus-subject-auxiliary:\n binary: {}\n", + serving.display(), + auxiliary.display() + ), + ) + .expect("write plugins.yaml"); + + let project_root = temp.path().join("project"); + std::fs::create_dir_all(&project_root).expect("mkdir project"); + + let _env = isolate_discovery_env(&config_dir, &plugin_dir); + let reason = subject_router_degraded_reason(&project_root); + assert!( + reason.is_none(), + "a router with a serving consolidated backend must remain healthy, got: {reason:?}" + ); + } + /// Two `subject_backend` plugins that both declare `subject_kind:task` cause /// `SubjectRouter::from_lazy_specs` to fail with a duplicate-kind error. /// The probe must surface this as a degraded reason so the operator can From 9277cdf494e3a8e5f294743a7feb92add60e0266 Mon Sep 17 00:00:00 2001 From: "animus-launchapp-gitprovider[bot]" <4147602+animus-launchapp-gitprovider[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 04:11:21 +0000 Subject: [PATCH 13/13] =?UTF-8?q?Bug:=20daemon=5Fhealth=20stays=20degraded?= =?UTF-8?q?=20=E2=80=94=20rc.3=20last=5Ferror-clear=20fix=20missed=20the?= =?UTF-8?q?=20real=20source=20(boot=20subject-router=20reconcile)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/daemon_impl.rs | 20 ++++--------------- crates/orchestrator-plugin-host/src/host.rs | 5 +---- 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/crates/orchestrator-core/src/services/daemon_impl.rs b/crates/orchestrator-core/src/services/daemon_impl.rs index ba5319a5..374fc6b3 100644 --- a/crates/orchestrator-core/src/services/daemon_impl.rs +++ b/crates/orchestrator-core/src/services/daemon_impl.rs @@ -1246,10 +1246,7 @@ mod tests { let _env = isolate_discovery_env(&config_dir, &plugin_dir); let reason = subject_router_degraded_reason(&project_root); - assert!( - reason.is_none(), - "project-scoped consolidated subject backend must be routable, got: {reason:?}" - ); + assert!(reason.is_none(), "project-scoped consolidated subject backend must be routable, got: {reason:?}"); } /// A project with no subject_backend plugins at all must produce a degraded @@ -1302,10 +1299,7 @@ mod tests { let _env = isolate_discovery_env(&config_dir, &plugin_dir); let reason = subject_router_degraded_reason(&project_root); - assert!( - reason.is_some(), - "plugin discovery failure must produce a degraded reason, not false-healthy (None)" - ); + assert!(reason.is_some(), "plugin discovery failure must produce a degraded reason, not false-healthy (None)"); let reason = reason.unwrap(); assert!( reason.contains("plugins.yaml"), @@ -1499,10 +1493,7 @@ mod tests { let _env = isolate_discovery_env(&config_dir, &plugin_dir); let reason = subject_router_degraded_reason(&project_root); - assert!( - reason.is_none(), - "a router with a serving consolidated backend must remain healthy, got: {reason:?}" - ); + assert!(reason.is_none(), "a router with a serving consolidated backend must remain healthy, got: {reason:?}"); } /// Two `subject_backend` plugins that both declare `subject_kind:task` cause @@ -1557,10 +1548,7 @@ mod tests { let _env = isolate_discovery_env(&config_dir, &plugin_dir); let reason = subject_router_degraded_reason(&project_root); - assert!( - reason.is_some(), - "duplicate subject_kind:task claims must produce a degraded reason" - ); + assert!(reason.is_some(), "duplicate subject_kind:task claims must produce a degraded reason"); let r = reason.unwrap(); assert!( r.contains("subject_backend unroutable"), diff --git a/crates/orchestrator-plugin-host/src/host.rs b/crates/orchestrator-plugin-host/src/host.rs index 2f69e8ee..395a1cba 100644 --- a/crates/orchestrator-plugin-host/src/host.rs +++ b/crates/orchestrator-plugin-host/src/host.rs @@ -1638,10 +1638,7 @@ mod tests { assert_eq!(redact_stderr_line("using api key: sk-abc123 now"), "using api key ***"); assert_eq!(redact_stderr_line("Authorization: Bearer sk-xyz trailing"), "Authorization ***"); // Multi-token / PEM value cannot leak its tail. - assert_eq!( - redact_stderr_line("private_key=-----BEGIN KEY----- abcd -----END KEY-----"), - "private_key ***" - ); + assert_eq!(redact_stderr_line("private_key=-----BEGIN KEY----- abcd -----END KEY-----"), "private_key ***"); // Query-string credential in a URL without userinfo. assert_eq!( redact_stderr_line("cb https://h/x?client_id=a&client_secret=zzz"),