Skip to main content

aft/bash_background/
mod.rs

1//! Background bash task management. Phase 0 stub; Phase 1 Track D fills in.
2
3pub mod buffer;
4pub mod output;
5pub mod persistence;
6pub mod process;
7pub mod pty_process;
8pub mod pty_runtime;
9pub mod registry;
10pub mod watchdog;
11pub mod watches;
12
13use crate::context::AppContext;
14use crate::protocol::Response;
15use persistence::BgMode;
16use serde::{Deserialize, Serialize};
17use serde_json::json;
18use std::collections::HashMap;
19use std::path::PathBuf;
20use std::time::Duration;
21
22pub use registry::{BgCompletion, BgTaskRegistry};
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BgTaskInfo {
26    pub task_id: String,
27    pub status: BgTaskStatus,
28    pub command: String,
29    pub mode: BgMode,
30    pub started_at: u64,
31    pub duration_ms: Option<u64>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum BgTaskStatus {
37    Starting,
38    Running,
39    Killing,
40    Completed,
41    Failed,
42    Killed,
43    TimedOut,
44}
45
46impl BgTaskStatus {
47    pub fn is_terminal(&self) -> bool {
48        matches!(
49            self,
50            BgTaskStatus::Completed
51                | BgTaskStatus::Failed
52                | BgTaskStatus::Killed
53                | BgTaskStatus::TimedOut
54        )
55    }
56}
57
58/// Spawn a bash command in the background. Returns a task_id immediately.
59#[allow(clippy::too_many_arguments)]
60pub fn spawn(
61    request_id: &str,
62    session_id: &str,
63    command: &str,
64    workdir: Option<PathBuf>,
65    env: Option<HashMap<String, String>>,
66    timeout_ms: Option<u64>,
67    ctx: &AppContext,
68    require_background_flag: bool,
69    notify_on_completion: bool,
70    compressed: bool,
71    pty: bool,
72    pty_rows: u16,
73    pty_cols: u16,
74) -> Response {
75    if require_background_flag && !ctx.config().experimental_bash_background {
76        return Response::error(
77            request_id,
78            "feature_disabled",
79            "background bash is disabled; set `experimental.bash.background: true` in aft.jsonc",
80        );
81    }
82
83    let workdir = workdir.unwrap_or_else(|| {
84        ctx.config().project_root.clone().unwrap_or_else(|| {
85            std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."))
86        })
87    });
88    let storage_dir = {
89        let config = ctx.config();
90        let root = storage_dir(config.storage_dir.as_deref());
91        config
92            .harness
93            .map(|harness| root.join(harness.as_str()))
94            .unwrap_or(root)
95    };
96    let max_running = ctx.config().max_background_bash_tasks;
97    let timeout = timeout_ms.map(Duration::from_millis);
98    let project_root = ctx
99        .config()
100        .project_root
101        .clone()
102        .or_else(|| std::env::current_dir().ok())
103        .and_then(|path| std::fs::canonicalize(&path).ok().or(Some(path)));
104
105    let env = env.unwrap_or_default();
106    let spawn_result = if pty {
107        ctx.bash_background().spawn_pty(
108            command,
109            session_id.to_string(),
110            workdir,
111            env,
112            timeout,
113            storage_dir,
114            max_running,
115            notify_on_completion,
116            compressed,
117            project_root,
118            pty_rows,
119            pty_cols,
120        )
121    } else {
122        ctx.bash_background().spawn(
123            command,
124            session_id.to_string(),
125            workdir,
126            env,
127            timeout,
128            storage_dir,
129            max_running,
130            notify_on_completion,
131            compressed,
132            project_root,
133        )
134    };
135
136    match spawn_result {
137        Ok(task_id) => Response::success(
138            request_id,
139            json!({
140                "task_id": task_id,
141                "status": BgTaskStatus::Running,
142                "mode": if pty { "pty" } else { "pipes" },
143            }),
144        ),
145        Err(message) if message.contains("limit exceeded") => {
146            Response::error(request_id, "background_task_limit_exceeded", message)
147        }
148        Err(message) => Response::error(request_id, "execution_failed", message),
149    }
150}
151
152pub fn storage_dir(configured: Option<&std::path::Path>) -> PathBuf {
153    if let Some(dir) = configured {
154        return dir.to_path_buf();
155    }
156    if let Some(dir) = std::env::var_os("AFT_CACHE_DIR") {
157        return PathBuf::from(dir).join("aft");
158    }
159    // Fallback to the user's home directory. On Unix this is `$HOME`; on
160    // Windows `HOME` is typically unset, so fall back to `USERPROFILE`
161    // (which is always set in interactive sessions and in the env that
162    // OpenCode/Pi pass through to plugin processes). If both are missing
163    // (rare — embedded contexts, broken shells), fall back to a temp
164    // directory rather than `"."` — a relative path makes bg-bash wrapper
165    // commands like `move /Y .\.cache\aft\... ...` fail with "system
166    // cannot find the path specified" once the working directory shifts.
167    let home = std::env::var_os("HOME")
168        .or_else(|| std::env::var_os("USERPROFILE"))
169        .map(PathBuf::from)
170        .unwrap_or_else(std::env::temp_dir);
171    home.join(".cache").join("aft")
172}
173
174pub fn repair_legacy_root_tasks(storage_root: &std::path::Path, harness: crate::harness::Harness) {
175    let root_tasks = storage_root.join("bash-tasks");
176    if !dir_has_entries(&root_tasks) {
177        return;
178    }
179
180    let harness_tasks = storage_root.join(harness.as_str()).join("bash-tasks");
181    if dir_has_entries(&harness_tasks) {
182        return;
183    }
184    if let Some(parent) = harness_tasks.parent() {
185        if let Err(error) = std::fs::create_dir_all(parent) {
186            crate::slog_warn!(
187                "failed to create harness bash task dir {}: {}",
188                parent.display(),
189                error
190            );
191            return;
192        }
193    }
194    if harness_tasks.exists() {
195        let _ = std::fs::remove_dir(&harness_tasks);
196    }
197
198    match std::fs::rename(&root_tasks, &harness_tasks) {
199        Ok(()) => crate::slog_info!(
200            "moved legacy root bash tasks into harness namespace: {}",
201            harness_tasks.display()
202        ),
203        Err(error) => {
204            crate::slog_warn!(
205                "failed to move legacy root bash tasks into {}: {}; trying child merge",
206                harness_tasks.display(),
207                error
208            );
209            if std::fs::create_dir_all(&harness_tasks).is_err() {
210                return;
211            }
212            if let Ok(entries) = std::fs::read_dir(&root_tasks) {
213                for entry in entries.flatten() {
214                    let source = entry.path();
215                    let target = harness_tasks.join(entry.file_name());
216                    if !target.exists() {
217                        let _ = std::fs::rename(source, target);
218                    }
219                }
220            }
221            let _ = std::fs::remove_dir(&root_tasks);
222        }
223    }
224}
225
226fn dir_has_entries(path: &std::path::Path) -> bool {
227    std::fs::read_dir(path)
228        .map(|mut entries| entries.next().is_some())
229        .unwrap_or(false)
230}