Skip to main content

aft/bash_background/
mod.rs

1//! Background bash task management: spawning detached tasks, the watchdog that
2//! reaps them, output buffering/compression, and on-disk persistence so tasks
3//! survive a bridge restart.
4
5pub mod buffer;
6pub mod output;
7pub mod persistence;
8pub mod process;
9pub mod pty_process;
10pub mod pty_runtime;
11pub mod registry;
12pub mod watchdog;
13pub mod watches;
14
15use crate::context::AppContext;
16use crate::protocol::Response;
17use persistence::BgMode;
18use serde::{Deserialize, Serialize};
19use serde_json::json;
20use std::collections::HashMap;
21use std::path::PathBuf;
22use std::time::Duration;
23
24pub use registry::{BgCompletion, BgTaskHealthCounts, BgTaskRegistry};
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct BgTaskInfo {
28    pub task_id: String,
29    pub status: BgTaskStatus,
30    pub command: String,
31    pub mode: BgMode,
32    pub started_at: u64,
33    pub duration_ms: Option<u64>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "snake_case")]
38pub enum BgTaskStatus {
39    Starting,
40    Running,
41    Killing,
42    Completed,
43    Failed,
44    Killed,
45    TimedOut,
46}
47
48impl BgTaskStatus {
49    pub fn is_terminal(&self) -> bool {
50        matches!(
51            self,
52            BgTaskStatus::Completed
53                | BgTaskStatus::Failed
54                | BgTaskStatus::Killed
55                | BgTaskStatus::TimedOut
56        )
57    }
58}
59
60/// Spawn a bash command in the background. Returns a task_id immediately.
61#[allow(clippy::too_many_arguments)]
62pub fn spawn(
63    request_id: &str,
64    session_id: &str,
65    command: &str,
66    workdir: Option<PathBuf>,
67    env: Option<HashMap<String, String>>,
68    timeout_ms: Option<u64>,
69    ctx: &AppContext,
70    require_background_flag: bool,
71    notify_on_completion: bool,
72    compressed: bool,
73    pty: bool,
74    pty_rows: u16,
75    pty_cols: u16,
76) -> Response {
77    if require_background_flag && !ctx.config().experimental_bash_background {
78        return Response::error(
79            request_id,
80            "feature_disabled",
81            "background bash is disabled; set `bash: { background: true }` (or `bash: true`) in aft.jsonc",
82        );
83    }
84
85    let workdir = workdir.unwrap_or_else(|| {
86        ctx.config().project_root.clone().unwrap_or_else(|| {
87            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
88        })
89    });
90    let storage_dir = {
91        let config = ctx.config();
92        let root = storage_dir(config.storage_dir.as_deref());
93        config
94            .harness
95            .as_ref()
96            .map(|harness| root.join(harness.storage_segment()))
97            .unwrap_or(root)
98    };
99    let max_running = ctx.config().max_background_bash_tasks;
100    let timeout = timeout_ms.map(Duration::from_millis);
101    let project_root = ctx
102        .config()
103        .project_root
104        .clone()
105        .or_else(|| std::env::current_dir().ok())
106        .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
107
108    let env = env.unwrap_or_default();
109    let spawn_result = if pty {
110        ctx.bash_background().spawn_pty(
111            command,
112            session_id.to_string(),
113            workdir,
114            env,
115            timeout,
116            storage_dir,
117            max_running,
118            notify_on_completion,
119            compressed,
120            project_root,
121            pty_rows,
122            pty_cols,
123        )
124    } else {
125        ctx.bash_background().spawn(
126            command,
127            session_id.to_string(),
128            workdir,
129            env,
130            timeout,
131            storage_dir,
132            max_running,
133            notify_on_completion,
134            compressed,
135            project_root,
136        )
137    };
138
139    match spawn_result {
140        Ok(task_id) => Response::success(
141            request_id,
142            json!({
143                "task_id": task_id,
144                "status": BgTaskStatus::Running,
145                "mode": if pty { "pty" } else { "pipes" },
146            }),
147        ),
148        Err(message) if message.contains("limit exceeded") => {
149            Response::error(request_id, "background_task_limit_exceeded", message)
150        }
151        Err(message) => Response::error(request_id, "execution_failed", message),
152    }
153}
154
155pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
156    if let Some(dir) = configured {
157        return dir.to_path_buf();
158    }
159    if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
160        return PathBuf::from(dir).join("aft");
161    }
162    // Default to the CortexKit shared data root — the SAME location the
163    // plugins inject as `storage_dir` on every configure. Before this, the
164    // fallback was the pre-migration `~/.cache/aft`, which only plugin-less
165    // invocations ever hit; once the daemon-supervised module became such an
166    // invocation it built a parallel storage universe there (duplicate
167    // trigram/semantic/callgraph caches AND a separate artifact-owner
168    // manifest, so cross-front leases could not see each other). Keep this
169    // in sync with resolveCortexKitStorageRoot in the TS packages.
170    cortexkit_data_root().join("cortexkit").join("aft")
171}
172
173fn cortexkit_data_root() -> PathBuf {
174    if let Some(dir) = std::env::var_os("XDG_DATA_HOME") {
175        if !dir.is_empty() {
176            return PathBuf::from(dir);
177        }
178    }
179    let home = std::env::var_os("HOME")
180        .or_else(|| std::env::var_os("USERPROFILE"))
181        .map(PathBuf::from)
182        .unwrap_or_else(std::env::temp_dir);
183    if cfg!(windows) {
184        return std::env::var_os("LOCALAPPDATA")
185            .or_else(|| std::env::var_os("APPDATA"))
186            .map(PathBuf::from)
187            .unwrap_or_else(|| home.join("AppData").join("Local"));
188    }
189    home.join(".local").join("share")
190}
191
192pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
193    let root_tasks = storage_root.join("bash-tasks");
194    if !dir_has_entries(&root_tasks) {
195        return;
196    }
197
198    let harness_tasks = storage_root
199        .join(harness.storage_segment())
200        .join("bash-tasks");
201    if dir_has_entries(&harness_tasks) {
202        return;
203    }
204    if let Some(parent) = harness_tasks.parent() {
205        if let Err(error) = std::fs::create_dir_all(parent) {
206            crate::slog_warn!(
207                "failed to create harness bash task dir {}: {}",
208                parent.display(),
209                error
210            );
211            return;
212        }
213    }
214    if harness_tasks.exists() {
215        let _ = std::fs::remove_dir(&harness_tasks);
216    }
217
218    match std::fs::rename(&root_tasks, &harness_tasks) {
219        Ok(()) => crate::slog_info!(
220            "moved legacy root bash tasks into harness namespace: {}",
221            harness_tasks.display()
222        ),
223        Err(error) => {
224            crate::slog_warn!(
225                "failed to move legacy root bash tasks into {}: {}; trying child merge",
226                harness_tasks.display(),
227                error
228            );
229            if std::fs::create_dir_all(&harness_tasks).is_err() {
230                return;
231            }
232            if let Ok(entries) = std::fs::read_dir(&root_tasks) {
233                for entry in entries.flatten() {
234                    let source = entry.path();
235                    let target = harness_tasks.join(entry.file_name());
236                    if !target.exists() {
237                        let _ = std::fs::rename(source, target);
238                    }
239                }
240            }
241            let _ = std::fs::remove_dir(&root_tasks);
242        }
243    }
244}
245
246fn dir_has_entries(path: &std::path::Path) -> bool {
247    std::fs::read_dir(path)
248        .map(|mut entries| entries.next().is_some())
249        .unwrap_or(false)
250}
251
252#[cfg(test)]
253mod storage_root_tests {
254    use std::path::PathBuf;
255
256    // The plugins inject the CortexKit data root as `storage_dir` on every
257    // configure; plugin-less invocations (daemon-supervised module, bare CLI,
258    // warmup) hit the Rust fallback below instead. If the two ever diverge
259    // again, every front pays duplicate cold indexes AND artifact-owner
260    // manifests split across universes so cross-front ReadOnly leasing goes
261    // blind (the v0.45.x daemon-module regression: the fallback still pointed
262    // at pre-migration ~/.cache/aft). This locks all Rust fallback sites to
263    // the same resolution the TS packages use (resolveCortexKitStorageRoot:
264    // XDG_DATA_HOME || platform data dir, + cortexkit/aft).
265    #[test]
266    fn plugin_less_fallback_matches_plugin_injected_cortexkit_root() {
267        let _guard = crate::test_env::process_env_lock();
268        let data_home = std::env::var_os("XDG_DATA_HOME")
269            .filter(|value| !value.is_empty())
270            .map(PathBuf::from)
271            .unwrap_or_else(|| {
272                let home = std::env::var_os("HOME")
273                    .or_else(|| std::env::var_os("USERPROFILE"))
274                    .map(PathBuf::from)
275                    .expect("test environment provides a home directory");
276                if cfg!(windows) {
277                    std::env::var_os("LOCALAPPDATA")
278                        .or_else(|| std::env::var_os("APPDATA"))
279                        .map(PathBuf::from)
280                        .unwrap_or_else(|| home.join("AppData").join("Local"))
281                } else {
282                    home.join(".local").join("share")
283                }
284            });
285        let expected_plugin_injected_root = data_home.join("cortexkit").join("aft");
286
287        let cache_dir_override_absent = std::env::var_os("AFT_CACHE_DIR").is_none();
288        assert!(
289            cache_dir_override_absent,
290            "test requires AFT_CACHE_DIR unset to exercise the real fallback"
291        );
292
293        assert_eq!(
294            super::storage_dir(None),
295            expected_plugin_injected_root,
296            "bash_background::storage_dir fallback diverged from the plugin-injected root"
297        );
298
299        let temp_project = tempfile::tempdir().expect("temp project");
300        let resolved = crate::search_index::resolve_cache_dir(temp_project.path(), None);
301        assert!(
302            resolved.starts_with(&expected_plugin_injected_root),
303            "search_index::resolve_cache_dir fallback diverged: {}",
304            resolved.display()
305        );
306    }
307}