car-server-core 0.24.1

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Durable repair learning for the native loop — the "gets better over time"
//! half.
//!
//! The native loop repairs across iterations but, on its own, forgets
//! everything the moment a session ends. This module wires `car-memgine`'s
//! skill store in so that *which repair approach worked for a given failure*
//! survives the session and can be recalled the next time the same failure
//! shows up.
//!
//! ## Shape
//!
//! A "repair skill" is a `car-memgine` skill whose trigger is keyed on a
//! **normalized failure signature** — the failing check's name plus a coarse
//! error class (e.g. `tests::test_failure`, `build::compile_error`). The
//! signature is stored both as a structured trigger (canonical, `kind =
//! "coder_repair"`) and echoed into `task_keywords` so the existing
//! keyword-based `find_skill` matcher can recall it (structured-trigger
//! dispatch is deferred in memgine — see `SkillTrigger` docs).
//!
//! ## Degradation
//!
//! Memgine is **never** a hard dependency of a coder session. When the daemon
//! runs standalone (`shared_memgine = None`), [`RepairMemory::disabled`] yields
//! a handle whose every method is a cheap no-op. The loop never blocks on the
//! memgine lock holding anything else, and a poisoned lock degrades to no-op
//! rather than propagating a panic into the loop.

use std::sync::Arc;

use tokio::sync::Mutex;

use car_memgine::graph::{SkillOutcome, SkillTrigger, StructuredTrigger};
use car_memgine::MemgineEngine;

use super::contract::CheckResult;

/// The structured-trigger discriminant for coder repair skills.
const REPAIR_KIND: &str = "coder_repair";
/// Persona under which repair skills are stored / recalled.
const REPAIR_PERSONA: &str = "car-coder";

/// A normalized fingerprint of a failing check: the check name plus a coarse
/// error class, so the *same kind* of failure recalls a prior fix even when the
/// exact output differs run to run.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FailureSignature {
    pub check: String,
    pub error_class: String,
}

impl FailureSignature {
    /// Derive a signature from a failed check result. The error class is a
    /// coarse bucket keyed off the exit code and a few stable substrings in the
    /// output tail — deliberately low-cardinality so recall generalizes.
    pub fn from_check(result: &CheckResult) -> Self {
        Self {
            check: normalize(&result.name),
            error_class: classify(result),
        }
    }

    /// The canonical signature string, e.g. `tests::compile_error`. Used as the
    /// skill name suffix and the structured-trigger signature.
    pub fn key(&self) -> String {
        format!("{}::{}", self.check, self.error_class)
    }
}

/// Lowercase, collapse non-alphanumerics to `_`, trim — so check names map to
/// stable signature tokens regardless of punctuation/case.
fn normalize(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut prev_us = false;
    for c in s.chars() {
        if c.is_ascii_alphanumeric() {
            out.push(c.to_ascii_lowercase());
            prev_us = false;
        } else if !prev_us {
            out.push('_');
            prev_us = true;
        }
    }
    out.trim_matches('_').to_string()
}

/// Coarse error class from exit code + output substrings. Order matters: the
/// most specific, stable signals win. Everything else collapses to
/// `exit_<code>` (or `nonzero` when the code is unknown) so cardinality stays
/// bounded.
fn classify(result: &CheckResult) -> String {
    let tail = result.output_tail.to_ascii_lowercase();
    // Compiler / type errors — the highest-signal, most actionable bucket.
    if tail.contains("error[e")
        || tail.contains("cannot find")
        || tail.contains("mismatched types")
        || tail.contains("no method named")
        || tail.contains("unresolved import")
        || tail.contains("syntaxerror")
        || tail.contains("compilation failed")
    {
        return "compile_error".to_string();
    }
    if tail.contains("test result: failed")
        || tail.contains("assertion")
        || tail.contains("panicked")
        || tail.contains("failures:")
    {
        return "test_failure".to_string();
    }
    if tail.contains("command not found") || tail.contains("no such file") {
        return "missing_command".to_string();
    }
    match result.exit_code {
        Some(code) => format!("exit_{code}"),
        None => "nonzero".to_string(),
    }
}

/// Optional handle onto the shared memgine, used to remember and recall repair
/// approaches. Cloning is cheap (`Arc`); `None` means learning is disabled and
/// every method is a no-op.
#[derive(Clone)]
pub struct RepairMemory {
    engine: Option<Arc<Mutex<MemgineEngine>>>,
}

impl RepairMemory {
    /// Wrap a shared memgine handle. `None` degrades to a no-op store.
    pub fn new(engine: Option<Arc<Mutex<MemgineEngine>>>) -> Self {
        Self { engine }
    }

    /// A store that does nothing — standalone daemon default and the simplest
    /// thing for tests that don't exercise learning.
    pub fn disabled() -> Self {
        Self { engine: None }
    }

    /// Whether learning is actually wired (memgine present).
    pub fn enabled(&self) -> bool {
        self.engine.is_some()
    }

    /// The stable skill name for a signature. One skill per signature, so
    /// repeated outcomes accumulate on the same node.
    fn skill_name(sig: &FailureSignature) -> String {
        format!("coder_repair::{}", sig.key())
    }

    /// Recall a previously-learned repair hint for this failure signature. Used
    /// to enrich the repair prompt with "last time this failed, this worked."
    /// Returns `None` when learning is disabled or no skill matches.
    pub async fn recall(&self, sig: &FailureSignature) -> Option<String> {
        let engine = self.engine.as_ref()?;
        let guard = engine.lock().await;
        let name = Self::skill_name(sig);
        // Keyword match on the signature key; the matcher is keyword-based, so
        // the signature lives in task_keywords (see module docs). Fall back to
        // an exact name lookup — skills are keyed by name — so recall is robust
        // even when the keyword matcher's ranking drops the entry.
        let meta = guard
            .find_skill(REPAIR_PERSONA, "", &sig.key(), 8)
            .into_iter()
            .map(|(m, _)| m)
            .find(|m| m.name == name)
            .or_else(|| guard.skill_meta(&name))?;
        if meta.code.trim().is_empty() {
            return None;
        }
        Some(meta.code)
    }

    /// Record that this signature's repair attempt FAILED this iteration. Only
    /// touches an existing skill (a fresh signature has nothing to penalize
    /// yet); ingestion happens on success.
    pub async fn record_failure(&self, sig: &FailureSignature) {
        let Some(engine) = self.engine.as_ref() else {
            return;
        };
        let mut guard = engine.lock().await;
        let name = Self::skill_name(sig);
        if skill_exists(&guard, &name) {
            let _ = guard.report_outcome(&name, SkillOutcome::Fail);
        }
    }

    /// Record that a repair WORKED: the contract went green after this
    /// signature had previously failed. If a skill for the signature exists,
    /// credit it with a success; otherwise ingest a new skill capturing the
    /// winning approach (`approach`) so the next occurrence can recall it.
    pub async fn record_success(&self, sig: &FailureSignature, approach: &str) {
        let Some(engine) = self.engine.as_ref() else {
            return;
        };
        let mut guard = engine.lock().await;
        let name = Self::skill_name(sig);
        if skill_exists(&guard, &name) {
            let _ = guard.report_outcome(&name, SkillOutcome::Success);
            return;
        }
        let trigger = SkillTrigger {
            persona: REPAIR_PERSONA.to_string(),
            url_pattern: String::new(),
            // The signature key is in task_keywords so the keyword matcher can
            // recall it; the structured payload is the canonical form.
            task_keywords: vec![sig.key(), sig.check.clone(), sig.error_class.clone()],
            structured: Some(StructuredTrigger {
                kind: REPAIR_KIND.to_string(),
                signature: serde_json::json!({
                    "check": sig.check,
                    "error_class": sig.error_class,
                }),
            }),
        };
        let description = format!(
            "Repair approach that resolved a '{}' failure of check '{}'.",
            sig.error_class, sig.check
        );
        guard.ingest_skill(
            &name,
            approach,
            "coder",
            trigger,
            &description,
            None,
            Vec::new(),
            Vec::new(),
        );
        let _ = guard.report_outcome(&name, SkillOutcome::Success);
    }
}

/// Does a skill with this exact name already live in the graph? Skill nodes
/// are keyed by name, so an exact `skill_meta` lookup is the cheap check.
fn skill_exists(engine: &MemgineEngine, name: &str) -> bool {
    engine.skill_meta(name).is_some()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn failed(name: &str, exit: Option<i64>, tail: &str) -> CheckResult {
        CheckResult {
            name: name.into(),
            passed: false,
            exit_code: exit,
            output_tail: tail.into(),
            duration_ms: 1,
        }
    }

    fn mem() -> RepairMemory {
        RepairMemory::new(Some(Arc::new(Mutex::new(MemgineEngine::new(None)))))
    }

    #[test]
    fn signature_normalizes_and_classifies() {
        let sig = FailureSignature::from_check(&failed(
            "Cargo Tests",
            Some(101),
            "error[E0433]: cannot find crate",
        ));
        assert_eq!(sig.check, "cargo_tests");
        assert_eq!(sig.error_class, "compile_error");
        assert_eq!(sig.key(), "cargo_tests::compile_error");
    }

    #[test]
    fn classify_buckets_are_coarse_and_stable() {
        assert_eq!(
            FailureSignature::from_check(&failed("t", Some(1), "test result: FAILED. 1 failed"))
                .error_class,
            "test_failure"
        );
        assert_eq!(
            FailureSignature::from_check(&failed("t", Some(127), "bash: foo: command not found"))
                .error_class,
            "missing_command"
        );
        // Unrecognized output collapses to the exit bucket.
        assert_eq!(
            FailureSignature::from_check(&failed("t", Some(2), "something opaque")).error_class,
            "exit_2"
        );
    }

    #[tokio::test]
    async fn disabled_memory_is_a_total_noop() {
        let m = RepairMemory::disabled();
        assert!(!m.enabled());
        let sig = FailureSignature::from_check(&failed("t", Some(1), "boom"));
        // None of these panic or do anything observable.
        m.record_failure(&sig).await;
        m.record_success(&sig, "fix it").await;
        assert_eq!(m.recall(&sig).await, None);
    }

    #[tokio::test]
    async fn success_ingests_then_recalls_the_approach() {
        let m = mem();
        let sig = FailureSignature::from_check(&failed("build", Some(101), "mismatched types"));
        assert_eq!(m.recall(&sig).await, None, "nothing learned yet");

        m.record_success(&sig, "cargo fix --allow-dirty then re-add the import")
            .await;
        let recalled = m.recall(&sig).await.expect("approach should be recalled");
        assert!(recalled.contains("cargo fix"));
    }

    #[tokio::test]
    async fn second_success_credits_the_same_skill_not_a_duplicate() {
        let m = mem();
        let sig = FailureSignature::from_check(&failed("tests", Some(101), "assertion failed"));
        m.record_success(&sig, "first approach").await;
        // A second success on the same signature must NOT overwrite the
        // recorded approach nor create a second skill.
        m.record_success(&sig, "different text").await;

        let engine = m.engine.as_ref().unwrap().lock().await;
        let skill = engine
            .skill_meta(&RepairMemory::skill_name(&sig))
            .expect("exactly one skill per signature");
        assert_eq!(skill.code, "first approach", "approach preserved");
        // success_count: 1 from ingest + 1 from the second success.
        assert_eq!(skill.stats.success_count, 2);
    }

    #[tokio::test]
    async fn failure_penalizes_an_existing_skill_only() {
        let m = mem();
        let sig = FailureSignature::from_check(&failed("tests", Some(101), "panicked"));
        // No skill yet → failure is a quiet no-op (nothing to penalize).
        m.record_failure(&sig).await;
        assert_eq!(m.recall(&sig).await, None);

        // After a success ingests the skill, a failure increments fail_count.
        m.record_success(&sig, "the fix").await;
        m.record_failure(&sig).await;
        let engine = m.engine.as_ref().unwrap().lock().await;
        let skill = engine
            .skill_meta(&RepairMemory::skill_name(&sig))
            .unwrap();
        assert_eq!(skill.stats.fail_count, 1);
        assert_eq!(skill.stats.success_count, 1);
    }
}