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        || crate::core::protocol::request_project_unresolved()
79    {
80        return;
81    }
82
83    let cwd = std::env::current_dir()
84        .map(|p| p.to_string_lossy().to_string())
85        .unwrap_or_default();
86    let project_root = crate::core::protocol::detect_project_root_or_cwd(&cwd);
87    let hash = crate::core::project_hash::hash_project_root(&project_root);
88
89    let Ok(mut guard) = active().try_lock() else {
90        return;
91    };
92
93    let needs_load = guard.as_ref().is_none_or(|a| a.project_hash != hash);
94    if needs_load {
95        *guard = Some(Active {
96            project_hash: hash,
97            store: GotchaStore::load(&project_root),
98        });
99    }
100
101    let Some(entry) = guard.as_mut() else {
102        return;
103    };
104    let changed =
105        entry
106            .store
107            .learn_from_shell(command, output, exit_code, &[], process_session_id());
108    if changed {
109        let _ = entry.store.save(&project_root);
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::core::gotcha_tracker::GotchaSource;
117
118    #[test]
119    fn learn_records_error_then_correlates_fix() {
120        let mut store = GotchaStore::new("h");
121
122        let changed = store.learn_from_shell(
123            "cargo build",
124            "error[E0507]: cannot move out of `self.name`",
125            1,
126            &[],
127            "s1",
128        );
129        assert!(changed, "failing build must record a pending error");
130        assert_eq!(store.pending_errors.len(), 1);
131        assert!(store.gotchas.is_empty(), "no gotcha until a fix correlates");
132
133        let fixed = store.learn_from_shell(
134            "cargo build --release",
135            "Finished `release` profile [optimized] target(s)",
136            0,
137            &["src/main.rs".into()],
138            "s1",
139        );
140        assert!(fixed, "green run of same base must correlate the fix");
141        assert_eq!(store.gotchas.len(), 1);
142        assert!(matches!(
143            store.gotchas[0].source,
144            GotchaSource::AutoDetected { .. }
145        ));
146        assert_eq!(store.pending_errors.len(), 0, "pending consumed by the fix");
147    }
148
149    #[test]
150    fn learn_ignores_clean_runs() {
151        let mut store = GotchaStore::new("h");
152        let changed = store.learn_from_shell("cargo build", "Finished `dev` profile", 0, &[], "s1");
153        assert!(
154            !changed,
155            "a green run with no pending error changes nothing"
156        );
157        assert!(store.gotchas.is_empty());
158        assert!(store.pending_errors.is_empty());
159    }
160
161    #[test]
162    fn record_shell_outcome_skips_non_correlatable() {
163        // `ls` is not a build/test family — must be a no-op, no panic, no state.
164        record_shell_outcome("ls -la", "file1\nfile2", 0);
165        record_shell_outcome("echo hello", "hello", 0);
166    }
167}