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