Skip to main content

lean_ctx/core/gotcha_tracker/
runtime.rs

1//! Runtime wiring: learn gotchas from real shell outcomes.
2//!
3//! `detect_error` / `try_resolve_pending` were fully implemented and unit-tested
4//! but never called on a live path — the engine that learns "error X was fixed
5//! by Y" sat dormant. This module hooks them into the shell layer at the exact
6//! point the #499 diagnostics store uses (`shell::exec`), so a failing
7//! build/test pushes a pending error and the next green run of the same command
8//! base correlates the fix into a persisted [`Gotcha`] — automatic, no agent
9//! action required.
10//!
11//! ## State model
12//! Pending errors are in-memory by design (`GotchaStore.pending_errors` is
13//! `#[serde(skip)]`). A process-global active store keeps them alive across
14//! calls, so in the long-lived daemon a fail→fix spanning two `ctx_shell` calls
15//! correlates. Durable gotchas / error logs / stats are persisted to
16//! `gotchas.json`, so the injection block and CLI see them across processes.
17//!
18//! [`Gotcha`]: super::Gotcha
19
20use std::sync::{Mutex, OnceLock};
21
22use super::GotchaStore;
23
24impl GotchaStore {
25    /// Learn from one finished command: push a pending error on failure, or
26    /// correlate a fix when a prior pending of the same command base now passes.
27    ///
28    /// Returns `true` when durable state (gotchas / error log / stats) changed
29    /// and the caller should persist. Pure — no globals, no I/O — so the
30    /// correlation logic is unit-testable in isolation.
31    pub fn learn_from_shell(
32        &mut self,
33        command: &str,
34        output: &str,
35        exit_code: i32,
36        files_touched: &[String],
37        session_id: &str,
38    ) -> bool {
39        if self.detect_error(output, command, exit_code, files_touched, session_id) {
40            // detect_error appended to error_log and bumped stats — both persist.
41            return true;
42        }
43        self.try_resolve_pending(command, files_touched, session_id)
44            .is_some()
45    }
46}
47
48/// Active per-project store, kept in memory so pending errors survive across
49/// shell calls within a process (the daemon's whole lifetime).
50struct Active {
51    project_hash: String,
52    store: GotchaStore,
53}
54
55static ACTIVE: OnceLock<Mutex<Option<Active>>> = OnceLock::new();
56
57fn active() -> &'static Mutex<Option<Active>> {
58    ACTIVE.get_or_init(|| Mutex::new(None))
59}
60
61/// Stable per-process session id. Each daemon run / CLI invocation counts as one
62/// "session" for cross-session correlation — semantically correct without
63/// coupling the shell layer to the MCP session store.
64fn process_session_id() -> &'static str {
65    static ID: OnceLock<String> = OnceLock::new();
66    ID.get_or_init(|| format!("proc-{}", std::process::id()))
67}
68
69/// Shell-layer hook: learn from a finished command's outcome.
70///
71/// Gated to build/test/run command families via
72/// [`is_correlatable_command`](super::detect::is_correlatable_command) so
73/// ordinary commands never touch disk. Resolves the project root and a stable
74/// session id internally, keeping the call site (a single line in `shell::exec`)
75/// minimal. Never panics and never blocks the shell on lock contention.
76pub fn record_shell_outcome(command: &str, output: &str, exit_code: i32) {
77    if !super::detect::is_correlatable_command(command) {
78        return;
79    }
80
81    let cwd = std::env::current_dir()
82        .map(|p| p.to_string_lossy().to_string())
83        .unwrap_or_default();
84    let project_root = crate::core::protocol::detect_project_root_or_cwd(&cwd);
85    let hash = crate::core::project_hash::hash_project_root(&project_root);
86
87    let Ok(mut guard) = active().try_lock() else {
88        return;
89    };
90
91    let needs_load = guard.as_ref().is_none_or(|a| a.project_hash != hash);
92    if needs_load {
93        *guard = Some(Active {
94            project_hash: hash,
95            store: GotchaStore::load(&project_root),
96        });
97    }
98
99    let Some(entry) = guard.as_mut() else {
100        return;
101    };
102    let changed =
103        entry
104            .store
105            .learn_from_shell(command, output, exit_code, &[], process_session_id());
106    if changed {
107        let _ = entry.store.save(&project_root);
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::core::gotcha_tracker::GotchaSource;
115
116    #[test]
117    fn learn_records_error_then_correlates_fix() {
118        let mut store = GotchaStore::new("h");
119
120        let changed = store.learn_from_shell(
121            "cargo build",
122            "error[E0507]: cannot move out of `self.name`",
123            1,
124            &[],
125            "s1",
126        );
127        assert!(changed, "failing build must record a pending error");
128        assert_eq!(store.pending_errors.len(), 1);
129        assert!(store.gotchas.is_empty(), "no gotcha until a fix correlates");
130
131        let fixed = store.learn_from_shell(
132            "cargo build --release",
133            "Finished `release` profile [optimized] target(s)",
134            0,
135            &["src/main.rs".into()],
136            "s1",
137        );
138        assert!(fixed, "green run of same base must correlate the fix");
139        assert_eq!(store.gotchas.len(), 1);
140        assert!(matches!(
141            store.gotchas[0].source,
142            GotchaSource::AutoDetected { .. }
143        ));
144        assert_eq!(store.pending_errors.len(), 0, "pending consumed by the fix");
145    }
146
147    #[test]
148    fn learn_ignores_clean_runs() {
149        let mut store = GotchaStore::new("h");
150        let changed = store.learn_from_shell("cargo build", "Finished `dev` profile", 0, &[], "s1");
151        assert!(
152            !changed,
153            "a green run with no pending error changes nothing"
154        );
155        assert!(store.gotchas.is_empty());
156        assert!(store.pending_errors.is_empty());
157    }
158
159    #[test]
160    fn record_shell_outcome_skips_non_correlatable() {
161        // `ls` is not a build/test family — must be a no-op, no panic, no state.
162        record_shell_outcome("ls -la", "file1\nfile2", 0);
163        record_shell_outcome("echo hello", "hello", 0);
164    }
165}