Skip to main content

aft/bash_background/
registry.rs

1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::fs;
3use std::io::{Read, Write};
4use std::path::{Path, PathBuf};
5use std::process::{Child, Command, Stdio};
6use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
7#[cfg(unix)]
8use std::sync::OnceLock;
9use std::sync::{Arc, Mutex, RwLock};
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12use rusqlite::Connection;
13use serde::Serialize;
14
15use crate::compress::caps::DropClass;
16use crate::compress::CompressionResult;
17use crate::context::SharedProgressSender;
18use crate::harness::Harness;
19use crate::protocol::{BashCompletedFrame, BashLongRunningFrame, BashPatternMatchFrame, PushFrame};
20
21#[cfg(unix)]
22use std::os::unix::process::CommandExt;
23#[cfg(windows)]
24use std::os::windows::process::CommandExt;
25
26use super::buffer::{combine_streams, BgBuffer, DiskTruncation, StreamKind, TokenCountInput};
27use super::output::{
28    cap_completion_output, cap_completion_output_with_marker, cap_final_output,
29    cap_final_output_with_marker, completion_preview_threshold, json_output_pointer, quote_path,
30    retained_json_output_pointer, COMPRESS_INPUT_CAP_BYTES, COMPRESS_INPUT_HEAD_BYTES,
31    COMPRESS_INPUT_TAIL_BYTES, FINAL_OUTPUT_CAP_BYTES, RAW_PASSTHROUGH_CAP_BYTES,
32    RAW_PASSTHROUGH_HEAD_BYTES, RAW_PASSTHROUGH_TAIL_BYTES, RUNNING_OUTPUT_PREVIEW_BYTES,
33    STRUCTURED_OUTPUT_CAP_BYTES,
34};
35use super::persistence::{
36    create_capture_file, delete_task_bundle, read_exit_marker, read_task, session_tasks_dir,
37    task_paths, unix_millis, update_task, write_kill_marker_if_absent, write_task, BgMode,
38    ExitMarker, PersistedTask, TaskPaths,
39};
40use super::process::is_process_alive;
41#[cfg(unix)]
42use super::process::terminate_pgid;
43#[cfg(windows)]
44use super::process::terminate_pid;
45use super::pty_process::spawn_pty_for_command;
46use super::pty_runtime::PtyRuntime;
47use super::watches::{PatternMatch, WatchPattern, WatchRegistry};
48use super::{BgTaskInfo, BgTaskStatus};
49/// Default timeout for background bash tasks: 30 minutes.
50/// Agents can override per-call via the `timeout` parameter (in ms).
51const DEFAULT_BG_TIMEOUT: Duration = Duration::from_secs(30 * 60);
52const STALE_RUNNING_AFTER: Duration = Duration::from_secs(24 * 60 * 60);
53const PERSISTED_GC_GRACE: Duration = Duration::from_secs(24 * 60 * 60);
54const QUARANTINE_GC_GRACE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
55
56const TOKENIZE_CAP_BYTES_PER_STREAM: usize = 128 * 1024;
57
58#[derive(Debug, Clone, Serialize)]
59pub struct BgCompletion {
60    pub task_id: String,
61    /// Intentionally omitted from serialized completion payloads: push frames
62    /// carry `session_id` at the BashCompletedFrame envelope level for routing.
63    #[serde(skip_serializing)]
64    pub session_id: String,
65    pub status: BgTaskStatus,
66    pub exit_code: Option<i32>,
67    pub command: String,
68    /// Small head+tail preview of the cached terminal render at completion time,
69    /// cached so push-frame consumers and `bash_drain_completions` callers see
70    /// the same preview without racing against later output rotation. Empty
71    /// when not captured (e.g., persisted task seen on startup before buffer
72    /// reattachment).
73    #[serde(default, skip_serializing_if = "String::is_empty")]
74    pub output_preview: String,
75    /// True when the captured tail is shorter than the actual output (because
76    /// rotation occurred or the output exceeds the preview cap). Plugins use
77    /// this to render a `…` prefix and signal that `bash_status` would return
78    /// more.
79    #[serde(default, skip_serializing_if = "is_false")]
80    pub output_truncated: bool,
81    /// Token count for raw stdout+stderr before compression. Omitted when any
82    /// stream exceeds the 128 KiB tokenization cap.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub original_tokens: Option<u32>,
85    /// Token count for the compressed output generated from the same capped
86    /// raw payload. Omitted when raw tokenization is skipped.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub compressed_tokens: Option<u32>,
89    /// True when a stream exceeded the tokenization cap and counts are absent.
90    #[serde(default, skip_serializing_if = "is_false")]
91    pub tokens_skipped: bool,
92}
93
94fn is_false(v: &bool) -> bool {
95    !*v
96}
97
98#[derive(Debug, Clone, Serialize)]
99pub struct BgTaskSnapshot {
100    #[serde(flatten)]
101    pub info: BgTaskInfo,
102    pub exit_code: Option<i32>,
103    pub child_pid: Option<u32>,
104    pub workdir: String,
105    pub output_preview: String,
106    pub output_truncated: bool,
107    pub output_path: Option<String>,
108    pub stderr_path: Option<String>,
109    #[serde(skip_serializing_if = "Option::is_none")]
110    pub pty_rows: Option<u16>,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub pty_cols: Option<u16>,
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub pty_screen: Option<String>,
115}
116
117#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
118pub struct BgTaskHealthCounts {
119    pub running: usize,
120    pub pending_completions: usize,
121}
122
123#[derive(Debug, Clone, Copy, PartialEq, Eq)]
124enum TerminalOutputKind {
125    Compressed,
126    Raw,
127    Structured,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131struct TerminalOutputCache {
132    output_preview: String,
133    output_truncated: bool,
134    kind: TerminalOutputKind,
135    output_path: Option<String>,
136    stderr_path: Option<String>,
137    artifact_access: ArtifactRecoveryAccess,
138    recovery: Option<RecoveryContext>,
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142struct ArtifactRecoveryAccess {
143    task_id: String,
144    readable: bool,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148struct RecoveryContext {
149    dropped_by_class: BTreeMap<DropClass, usize>,
150    had_inner_drop: bool,
151    offset_hint_eligible: bool,
152    offset_start_line: Option<usize>,
153    byte_truncated: bool,
154    disk_truncated_prefix_bytes: u64,
155    output_path: Option<String>,
156    stderr_path: Option<String>,
157    include_stderr_path: bool,
158    artifact_access: ArtifactRecoveryAccess,
159}
160
161fn optional_string_bytes(value: Option<&String>) -> u64 {
162    value
163        .map(|value| crate::memory::usize_to_u64(value.len()))
164        .unwrap_or(0)
165}
166
167fn terminal_output_cache_estimated_bytes(cache: &TerminalOutputCache) -> u64 {
168    let recovery_bytes = cache
169        .recovery
170        .as_ref()
171        .map(|recovery| {
172            crate::memory::usize_to_u64(recovery.dropped_by_class.len())
173                .saturating_mul(
174                    (std::mem::size_of::<DropClass>() + std::mem::size_of::<usize>()) as u64,
175                )
176                .saturating_add(optional_string_bytes(recovery.output_path.as_ref()))
177                .saturating_add(optional_string_bytes(recovery.stderr_path.as_ref()))
178                .saturating_add(crate::memory::usize_to_u64(
179                    recovery.artifact_access.task_id.len(),
180                ))
181        })
182        .unwrap_or(0);
183    (std::mem::size_of::<TerminalOutputCache>() as u64)
184        .saturating_add(crate::memory::usize_to_u64(cache.output_preview.len()))
185        .saturating_add(optional_string_bytes(cache.output_path.as_ref()))
186        .saturating_add(optional_string_bytes(cache.stderr_path.as_ref()))
187        .saturating_add(crate::memory::usize_to_u64(
188            cache.artifact_access.task_id.len(),
189        ))
190        .saturating_add(recovery_bytes)
191}
192
193fn completion_estimated_bytes(completion: &BgCompletion) -> u64 {
194    (std::mem::size_of::<BgCompletion>() as u64)
195        .saturating_add(crate::memory::usize_to_u64(completion.task_id.len()))
196        .saturating_add(crate::memory::usize_to_u64(completion.session_id.len()))
197        .saturating_add(crate::memory::usize_to_u64(completion.command.len()))
198        .saturating_add(crate::memory::usize_to_u64(completion.output_preview.len()))
199}
200
201impl RecoveryContext {
202    fn has_visible_drop(&self) -> bool {
203        self.byte_truncated
204            || self.disk_truncated_prefix_bytes > 0
205            || self.had_inner_drop
206            || !self.dropped_by_class.is_empty()
207    }
208}
209
210#[derive(Clone)]
211pub struct BgTaskRegistry {
212    pub(crate) inner: Arc<RegistryInner>,
213}
214
215pub(crate) struct RegistryInner {
216    pub(crate) tasks: Mutex<HashMap<String, Arc<BgTask>>>,
217    pub(crate) completions: Mutex<VecDeque<BgCompletion>>,
218    pub(crate) progress_sender: SharedProgressSender,
219    watchdog_started: AtomicBool,
220    pub(crate) shutdown: AtomicBool,
221    pub(crate) long_running_reminder_enabled: AtomicBool,
222    pub(crate) long_running_reminder_interval_ms: AtomicU64,
223    persisted_gc_started: AtomicBool,
224    #[cfg(test)]
225    persisted_gc_runs: AtomicU64,
226    /// Output compression callback. Set by `AppContext` after construction.
227    /// Takes (command, raw_output, exit_code) and returns compressed text. Called from
228    /// the watchdog thread when a task reaches a terminal state and from
229    /// `bash_status`/`list` snapshot reads. When `None`, output is returned
230    /// uncompressed.
231    pub(crate) compressor:
232        Mutex<Option<Box<dyn Fn(&str, String, Option<i32>) -> CompressionResult + Send + Sync>>>,
233    pub(crate) db_pool: RwLock<Option<Arc<Mutex<Connection>>>>,
234    pub(crate) db_harness: RwLock<Option<String>>,
235    pub(crate) wake_tx: crossbeam_channel::Sender<()>,
236    pub(crate) wake_rx: crossbeam_channel::Receiver<()>,
237    pub(crate) watch_registry: Mutex<WatchRegistry>,
238    wait_detach_sessions: Mutex<HashSet<String>>,
239    active_wait_sessions: Mutex<HashMap<String, usize>>,
240}
241
242pub(crate) struct BgTask {
243    pub(crate) task_id: String,
244    pub(crate) session_id: String,
245    pub(crate) paths: TaskPaths,
246    artifact_root: PathBuf,
247    pub(crate) started: Instant,
248    pub(crate) last_reminder_at: Mutex<Option<Instant>>,
249    pub(crate) terminal_at: Mutex<Option<Instant>>,
250    pub(crate) state: Mutex<BgTaskState>,
251}
252
253pub(crate) enum TaskRuntime {
254    Piped(Option<Child>),
255    Pty(Option<PtyRuntime>),
256}
257
258pub(crate) struct BgTaskState {
259    pub(crate) metadata: PersistedTask,
260    pub(crate) runtime: TaskRuntime,
261    pub(crate) detached: bool,
262    /// True once `reap_child` has observed the direct child handle's exit
263    /// via `try_wait()`. Used by the two-pass watchdog to skip the racy
264    /// `is_process_alive(child_pid)` probe on the second pass — we already
265    /// have authoritative evidence that the child is dead, no need to
266    /// re-verify via PID liveness which is unreliable on Windows where
267    /// PIDs can be recycled within seconds.
268    ///
269    /// Remains `false` on replay-restored tasks (those have a `child_pid`
270    /// but never observed exit via this process's `try_wait()`), so those
271    /// continue to fall through to the `is_process_alive` probe path.
272    pub(crate) child_exit_observed: bool,
273    pub(crate) buffer: BgBuffer,
274    terminal_output_cache: Option<TerminalOutputCache>,
275    /// PTY-only: set for timeout kill intent before signaling the child.
276    pub(crate) pending_terminal_override: Option<BgTaskStatus>,
277}
278
279fn completion_matches_session(completion: &BgCompletion, session_id: Option<&str>) -> bool {
280    session_id
281        .map(|session_id| completion.session_id == session_id)
282        .unwrap_or(true)
283}
284
285impl BgTaskRegistry {
286    pub fn new(progress_sender: SharedProgressSender) -> Self {
287        let (wake_tx, wake_rx) = crossbeam_channel::bounded(1);
288        Self {
289            inner: Arc::new(RegistryInner {
290                tasks: Mutex::new(HashMap::new()),
291                completions: Mutex::new(VecDeque::new()),
292                progress_sender,
293                watchdog_started: AtomicBool::new(false),
294                shutdown: AtomicBool::new(false),
295                long_running_reminder_enabled: AtomicBool::new(true),
296                long_running_reminder_interval_ms: AtomicU64::new(600_000),
297                persisted_gc_started: AtomicBool::new(false),
298                #[cfg(test)]
299                persisted_gc_runs: AtomicU64::new(0),
300                compressor: Mutex::new(None),
301                db_pool: RwLock::new(None),
302                db_harness: RwLock::new(None),
303                wake_tx,
304                wake_rx,
305                watch_registry: Mutex::new(WatchRegistry::default()),
306                wait_detach_sessions: Mutex::new(HashSet::new()),
307                active_wait_sessions: Mutex::new(HashMap::new()),
308            }),
309        }
310    }
311
312    /// Return whether `path` is an exact artifact registered to `session_id`.
313    ///
314    /// The requested path is canonicalized and compared with exact artifact
315    /// names under the task directory identity captured at registration time.
316    /// Deliberately do not grant access by `bash-tasks` directory prefix: a
317    /// prefix exception would expose unrelated files and could be widened
318    /// through symlinks or directory replacement.
319    pub fn is_session_owned_artifact_path(&self, session_id: &str, path: &Path) -> bool {
320        let Ok(requested) = fs::canonicalize(path) else {
321            return false;
322        };
323        let Ok(tasks) = self.inner.tasks.lock() else {
324            return false;
325        };
326
327        tasks.values().any(|task| {
328            task.session_id == session_id
329                && [
330                    &task.paths.stdout,
331                    &task.paths.stderr,
332                    &task.paths.exit,
333                    &task.paths.pty,
334                ]
335                .into_iter()
336                .filter_map(|known| known.file_name())
337                .any(|name| task.artifact_root.join(name) == requested)
338        })
339    }
340
341    pub fn set_harness(&self, harness: Harness) {
342        if let Ok(mut slot) = self.inner.db_harness.write() {
343            *slot = Some(harness.storage_segment());
344        }
345    }
346
347    pub fn set_db_pool(&self, conn: Arc<Mutex<Connection>>) {
348        if let Ok(mut slot) = self.inner.db_pool.write() {
349            *slot = Some(conn);
350        }
351    }
352
353    pub fn clear_db_pool(&self) {
354        if let Ok(mut slot) = self.inner.db_pool.write() {
355            *slot = None;
356        }
357    }
358
359    pub fn begin_wait_mode_session(&self, session_id: &str) {
360        if let Ok(mut active) = self.inner.active_wait_sessions.lock() {
361            *active.entry(session_id.to_string()).or_insert(0) += 1;
362        }
363        if let Ok(mut detach) = self.inner.wait_detach_sessions.lock() {
364            detach.remove(session_id);
365        }
366    }
367
368    pub fn end_wait_mode_session(&self, session_id: &str) {
369        if let Ok(mut active) = self.inner.active_wait_sessions.lock() {
370            match active.get_mut(session_id) {
371                Some(count) if *count > 1 => *count -= 1,
372                Some(_) => {
373                    active.remove(session_id);
374                }
375                None => {}
376            }
377        }
378        if let Ok(mut detach) = self.inner.wait_detach_sessions.lock() {
379            detach.remove(session_id);
380        }
381    }
382
383    pub fn signal_wait_mode_detach(&self, session_id: &str) -> bool {
384        let is_waiting = self
385            .inner
386            .active_wait_sessions
387            .lock()
388            .map(|active| active.get(session_id).copied().unwrap_or(0) > 0)
389            .unwrap_or(false);
390        if !is_waiting {
391            return false;
392        }
393        self.inner
394            .wait_detach_sessions
395            .lock()
396            .map(|mut detach| detach.insert(session_id.to_string()))
397            .unwrap_or(false)
398    }
399
400    /// Number of sessions currently blocked in a `wait: true` foreground bash.
401    /// Diagnostic only (the detach-signal trace log).
402    pub fn active_wait_session_count(&self) -> usize {
403        self.inner
404            .active_wait_sessions
405            .lock()
406            .map(|active| active.len())
407            .unwrap_or(0)
408    }
409
410    pub fn take_wait_mode_detach(&self, session_id: &str) -> bool {
411        self.inner
412            .wait_detach_sessions
413            .lock()
414            .map(|mut detach| detach.remove(session_id))
415            .unwrap_or(false)
416    }
417
418    /// Install the output-compression callback. Called by `main.rs` after
419    /// `AppContext` is constructed so that snapshot/completion paths can
420    /// invoke `compress::compress_with_registry` without holding a context
421    /// reference. When called multiple times, the latest installation wins.
422    pub fn set_compressor<F>(&self, compressor: F)
423    where
424        F: Fn(&str, String) -> CompressionResult + Send + Sync + 'static,
425    {
426        self.set_compressor_with_exit_code(move |command, output, _exit_code| {
427            compressor(command, output)
428        });
429    }
430
431    pub fn set_compressor_with_exit_code<F>(&self, compressor: F)
432    where
433        F: Fn(&str, String, Option<i32>) -> CompressionResult + Send + Sync + 'static,
434    {
435        if let Ok(mut slot) = self.inner.compressor.lock() {
436            *slot = Some(Box::new(compressor));
437        }
438    }
439
440    /// Apply the installed compressor (if any) to `output`. Returns `output`
441    /// untouched when no compressor is installed.
442    pub(crate) fn compress_output(
443        &self,
444        command: &str,
445        output: String,
446        exit_code: Option<i32>,
447    ) -> CompressionResult {
448        let Ok(slot) = self.inner.compressor.lock() else {
449            return CompressionResult::new(output);
450        };
451        match slot.as_ref() {
452            Some(compressor) => compressor(command, output, exit_code),
453            None => CompressionResult::new(output),
454        }
455    }
456
457    fn ensure_terminal_output_cache(&self, task: &Arc<BgTask>) -> Option<TerminalOutputCache> {
458        let (metadata, buffer) = {
459            let state = task.state.lock().ok()?;
460            if !state.metadata.status.is_terminal() || state.metadata.mode == BgMode::Pty {
461                return None;
462            }
463            if let Some(cache) = state.terminal_output_cache.clone() {
464                return Some(cache);
465            }
466            (state.metadata.clone(), state.buffer.clone())
467        };
468
469        let mut cap_buffer = buffer.clone();
470        let disk_truncation = cap_buffer.enforce_terminal_cap();
471        let cache = self.render_terminal_output(&metadata, &cap_buffer, disk_truncation);
472        let mut state = task.state.lock().ok()?;
473        if !state.metadata.status.is_terminal() || state.metadata.mode == BgMode::Pty {
474            return None;
475        }
476        if let Some(existing) = state.terminal_output_cache.clone() {
477            return Some(existing);
478        }
479        state.terminal_output_cache = Some(cache.clone());
480        Some(cache)
481    }
482
483    fn render_terminal_output(
484        &self,
485        metadata: &PersistedTask,
486        buffer: &BgBuffer,
487        disk_truncation: DiskTruncation,
488    ) -> TerminalOutputCache {
489        let output_readable = buffer
490            .output_path()
491            .is_some_and(|path| self.is_session_owned_artifact_path(&metadata.session_id, &path));
492        let stderr_readable = buffer
493            .stderr_path()
494            .map(|path| self.is_session_owned_artifact_path(&metadata.session_id, path))
495            .unwrap_or(true);
496        let artifact_access = ArtifactRecoveryAccess {
497            task_id: metadata.task_id.clone(),
498            readable: output_readable && stderr_readable,
499        };
500
501        if metadata.mode == BgMode::Pty {
502            return TerminalOutputCache {
503                output_preview: String::new(),
504                output_truncated: false,
505                kind: TerminalOutputKind::Raw,
506                output_path: buffer.output_path().map(|path| path.display().to_string()),
507                stderr_path: buffer.stderr_path().map(|path| path.display().to_string()),
508                artifact_access,
509                recovery: None,
510            };
511        }
512
513        if let Some(structured) = render_structured_output(
514            &metadata.command,
515            buffer,
516            disk_truncation,
517            artifact_access.clone(),
518        ) {
519            return structured;
520        }
521
522        if !metadata.compressed {
523            return render_raw_passthrough(buffer, disk_truncation, artifact_access);
524        }
525
526        let raw = buffer.read_combined_head_tail(
527            COMPRESS_INPUT_CAP_BYTES,
528            COMPRESS_INPUT_HEAD_BYTES,
529            COMPRESS_INPUT_TAIL_BYTES,
530        );
531        let compressed = self.compress_output(&metadata.command, raw.text, metadata.exit_code);
532        render_compressed_with_recovery(
533            buffer,
534            compressed,
535            raw.truncated,
536            disk_truncation,
537            artifact_access,
538        )
539    }
540
541    fn snapshot_with_terminal_cache(
542        &self,
543        task: &Arc<BgTask>,
544        preview_bytes: usize,
545    ) -> BgTaskSnapshot {
546        let mut snapshot = task.snapshot(preview_bytes);
547        self.maybe_compress_snapshot(task, &mut snapshot);
548        snapshot
549    }
550
551    fn post_terminal_transition(&self, task: &Arc<BgTask>, emit_frame: bool) -> Result<(), String> {
552        let (metadata, buffer) = {
553            let state = task
554                .state
555                .lock()
556                .map_err(|_| "background task lock poisoned".to_string())?;
557            if !state.metadata.status.is_terminal() {
558                return Ok(());
559            }
560            (state.metadata.clone(), state.buffer.clone())
561        };
562
563        let cache = self.ensure_terminal_output_cache(task);
564        self.enqueue_completion_from_parts(
565            &metadata,
566            Some(&buffer),
567            None,
568            emit_frame,
569            cache.as_ref(),
570        );
571        Ok(())
572    }
573
574    fn persist_task(&self, paths: &TaskPaths, metadata: &PersistedTask) -> std::io::Result<()> {
575        write_task(&paths.json, metadata)?;
576        self.dual_write_task(paths, metadata);
577        Ok(())
578    }
579
580    fn update_task_metadata<F>(
581        &self,
582        paths: &TaskPaths,
583        update: F,
584    ) -> std::io::Result<PersistedTask>
585    where
586        F: FnOnce(&mut PersistedTask),
587    {
588        let metadata = update_task(&paths.json, update)?;
589        self.dual_write_task(paths, &metadata);
590        Ok(metadata)
591    }
592
593    fn dual_write_task(&self, paths: &TaskPaths, metadata: &PersistedTask) {
594        let pool = self.inner.db_pool.read().ok().and_then(|slot| slot.clone());
595        let Some(pool) = pool else {
596            return;
597        };
598        let harness = self
599            .inner
600            .db_harness
601            .read()
602            .ok()
603            .and_then(|slot| slot.clone());
604        let Some(harness) = harness else {
605            crate::slog_warn!(
606                "dual-write bash_task to DB skipped for {}: harness not configured",
607                metadata.task_id
608            );
609            return;
610        };
611        let row = match metadata.to_bash_task_row(&harness, paths) {
612            Ok(row) => row,
613            Err(error) => {
614                crate::slog_warn!(
615                    "dual-write bash_task to DB failed for {}: {}",
616                    metadata.task_id,
617                    error
618                );
619                return;
620            }
621        };
622        let conn = match pool.lock() {
623            Ok(conn) => conn,
624            Err(_) => {
625                crate::slog_warn!(
626                    "dual-write bash_task to DB failed for {}: db mutex poisoned",
627                    metadata.task_id
628                );
629                return;
630            }
631        };
632        if let Err(error) = crate::db::bash_tasks::upsert_bash_task(&conn, &row) {
633            crate::slog_warn!(
634                "dual-write bash_task to DB failed for {}: {}",
635                metadata.task_id,
636                error
637            );
638        }
639    }
640
641    pub fn configure_long_running_reminders(&self, enabled: bool, interval_ms: u64) {
642        self.inner
643            .long_running_reminder_enabled
644            .store(enabled, Ordering::SeqCst);
645        self.inner
646            .long_running_reminder_interval_ms
647            .store(interval_ms, Ordering::SeqCst);
648    }
649
650    #[cfg(unix)]
651    #[allow(clippy::too_many_arguments)]
652    pub fn spawn(
653        &self,
654        command: &str,
655        session_id: String,
656        workdir: PathBuf,
657        env: HashMap<String, String>,
658        timeout: Option<Duration>,
659        storage_dir: PathBuf,
660        max_running: usize,
661        notify_on_completion: bool,
662        compressed: bool,
663        project_root: Option<PathBuf>,
664    ) -> Result<String, String> {
665        self.start_watchdog();
666
667        let running = self.running_count();
668        if running >= max_running {
669            return Err(format!(
670                "background bash task limit exceeded: {running} running (max {max_running})"
671            ));
672        }
673
674        let timeout = timeout.or(Some(DEFAULT_BG_TIMEOUT));
675        let timeout_ms = timeout.map(|timeout| timeout.as_millis() as u64);
676        let task_id = self.generate_unique_task_id()?;
677        let paths = task_paths(&storage_dir, &session_id, &task_id);
678        fs::create_dir_all(&paths.dir)
679            .map_err(|e| format!("failed to create background task dir: {e}"))?;
680
681        let mut metadata = PersistedTask::starting(
682            task_id.clone(),
683            session_id.clone(),
684            command.to_string(),
685            workdir.clone(),
686            project_root,
687            timeout_ms,
688            notify_on_completion,
689            compressed,
690        );
691        self.persist_task(&paths, &metadata)
692            .map_err(|e| format!("failed to persist background task metadata: {e}"))?;
693
694        // Pre-create capture files so the watchdog/buffer can always
695        // open them for reading. The spawn helper opens its own handles
696        // per attempt because each `Command::spawn()` consumes them.
697        create_capture_file(&paths.stdout)
698            .map_err(|e| format!("failed to create stdout capture file: {e}"))?;
699        create_capture_file(&paths.stderr)
700            .map_err(|e| format!("failed to create stderr capture file: {e}"))?;
701
702        let child = match spawn_detached_child(command, &paths, &workdir, &env) {
703            Ok(child) => child,
704            Err(error) => {
705                crate::slog_warn!("failed to spawn background bash task {task_id}; deleting partial bundle: {error}");
706                let _ = delete_task_bundle(&paths);
707                return Err(error);
708            }
709        };
710
711        let child_pid = child.id();
712        metadata.mark_running(child_pid, child_pid as i32);
713        self.persist_task(&paths, &metadata)
714            .map_err(|e| format!("failed to persist running background task metadata: {e}"))?;
715
716        let task = Arc::new(BgTask {
717            task_id: task_id.clone(),
718            session_id,
719            paths: paths.clone(),
720            artifact_root: canonical_artifact_root(&paths),
721            started: Instant::now(),
722            last_reminder_at: Mutex::new(None),
723            terminal_at: Mutex::new(None),
724            state: Mutex::new(BgTaskState {
725                metadata,
726                runtime: TaskRuntime::Piped(Some(child)),
727                detached: false,
728                child_exit_observed: false,
729                buffer: BgBuffer::new(paths.stdout.clone(), paths.stderr.clone()),
730                terminal_output_cache: None,
731                pending_terminal_override: None,
732            }),
733        });
734
735        self.inner
736            .tasks
737            .lock()
738            .map_err(|_| "background task registry lock poisoned".to_string())?
739            .insert(task_id.clone(), task);
740
741        Ok(task_id)
742    }
743
744    #[allow(clippy::too_many_arguments)]
745    pub fn spawn_pty(
746        &self,
747        command: &str,
748        session_id: String,
749        workdir: PathBuf,
750        env: HashMap<String, String>,
751        timeout: Option<Duration>,
752        storage_dir: PathBuf,
753        max_running: usize,
754        notify_on_completion: bool,
755        compressed: bool,
756        project_root: Option<PathBuf>,
757        rows: u16,
758        cols: u16,
759    ) -> Result<String, String> {
760        self.start_watchdog();
761
762        let running = self.running_count();
763        if running >= max_running {
764            return Err(format!(
765                "background bash task limit exceeded: {running} running (max {max_running})"
766            ));
767        }
768
769        let timeout = timeout.or(Some(DEFAULT_BG_TIMEOUT));
770        let timeout_ms = timeout.map(|timeout| timeout.as_millis() as u64);
771        let task_id = self.generate_unique_task_id()?;
772        let paths = task_paths(&storage_dir, &session_id, &task_id);
773        fs::create_dir_all(&paths.dir)
774            .map_err(|e| format!("failed to create background task dir: {e}"))?;
775
776        let mut metadata = PersistedTask::starting(
777            task_id.clone(),
778            session_id.clone(),
779            command.to_string(),
780            workdir.clone(),
781            project_root,
782            timeout_ms,
783            notify_on_completion,
784            compressed,
785        );
786        metadata.mode = BgMode::Pty;
787        metadata.pty_rows = Some(rows);
788        metadata.pty_cols = Some(cols);
789        self.persist_task(&paths, &metadata)
790            .map_err(|e| format!("failed to persist background task metadata: {e}"))?;
791        create_capture_file(&paths.pty)
792            .map_err(|e| format!("failed to create PTY capture file: {e}"))?;
793
794        let runtime = match spawn_pty_for_command(
795            &task_id,
796            &session_id,
797            command,
798            &paths,
799            &workdir,
800            &env,
801            rows,
802            cols,
803            self.inner.wake_tx.clone(),
804        ) {
805            Ok(runtime) => runtime,
806            Err(error) => {
807                crate::slog_warn!(
808                    "failed to spawn PTY background bash task {task_id}; deleting partial bundle: {error}"
809                );
810                let _ = delete_task_bundle(&paths);
811                return Err(error);
812            }
813        };
814
815        if let Some(child_pid) = runtime.child_pid {
816            metadata.mark_running(child_pid, child_pid as i32);
817        } else {
818            metadata.status = BgTaskStatus::Running;
819            metadata.pgid = None;
820        }
821        self.persist_task(&paths, &metadata)
822            .map_err(|e| format!("failed to persist running background task metadata: {e}"))?;
823
824        let task = Arc::new(BgTask {
825            task_id: task_id.clone(),
826            session_id,
827            paths: paths.clone(),
828            artifact_root: canonical_artifact_root(&paths),
829            started: Instant::now(),
830            last_reminder_at: Mutex::new(None),
831            terminal_at: Mutex::new(None),
832            state: Mutex::new(BgTaskState {
833                metadata,
834                runtime: TaskRuntime::Pty(Some(runtime)),
835                detached: false,
836                child_exit_observed: false,
837                buffer: BgBuffer::pty(paths.pty.clone()),
838                terminal_output_cache: None,
839                pending_terminal_override: None,
840            }),
841        });
842
843        self.inner
844            .tasks
845            .lock()
846            .map_err(|_| "background task registry lock poisoned".to_string())?
847            .insert(task_id.clone(), task);
848
849        Ok(task_id)
850    }
851
852    #[cfg(windows)]
853    #[allow(clippy::too_many_arguments)]
854    pub fn spawn(
855        &self,
856        command: &str,
857        session_id: String,
858        workdir: PathBuf,
859        env: HashMap<String, String>,
860        timeout: Option<Duration>,
861        storage_dir: PathBuf,
862        max_running: usize,
863        notify_on_completion: bool,
864        compressed: bool,
865        project_root: Option<PathBuf>,
866    ) -> Result<String, String> {
867        self.start_watchdog();
868
869        let running = self.running_count();
870        if running >= max_running {
871            return Err(format!(
872                "background bash task limit exceeded: {running} running (max {max_running})"
873            ));
874        }
875
876        let timeout = timeout.or(Some(DEFAULT_BG_TIMEOUT));
877        let timeout_ms = timeout.map(|timeout| timeout.as_millis() as u64);
878        let task_id = self.generate_unique_task_id()?;
879        let paths = task_paths(&storage_dir, &session_id, &task_id);
880        fs::create_dir_all(&paths.dir)
881            .map_err(|e| format!("failed to create background task dir: {e}"))?;
882
883        let mut metadata = PersistedTask::starting(
884            task_id.clone(),
885            session_id.clone(),
886            command.to_string(),
887            workdir.clone(),
888            project_root,
889            timeout_ms,
890            notify_on_completion,
891            compressed,
892        );
893        self.persist_task(&paths, &metadata)
894            .map_err(|e| format!("failed to persist background task metadata: {e}"))?;
895
896        // Capture files are pre-created so the watchdog/buffer can always
897        // open them for reading even if the child hasn't written anything
898        // yet. The spawn helper opens its own handles per attempt because
899        // each `Command::spawn()` consumes them, and on Windows we may
900        // retry across multiple shell candidates if the first one fails.
901        create_capture_file(&paths.stdout)
902            .map_err(|e| format!("failed to create stdout capture file: {e}"))?;
903        create_capture_file(&paths.stderr)
904            .map_err(|e| format!("failed to create stderr capture file: {e}"))?;
905
906        let child = match spawn_detached_child(command, &paths, &workdir, &env) {
907            Ok(child) => child,
908            Err(error) => {
909                crate::slog_warn!("failed to spawn background bash task {task_id}; deleting partial bundle: {error}");
910                let _ = delete_task_bundle(&paths);
911                return Err(error);
912            }
913        };
914
915        let child_pid = child.id();
916        metadata.status = BgTaskStatus::Running;
917        metadata.child_pid = Some(child_pid);
918        metadata.pgid = None;
919        self.persist_task(&paths, &metadata)
920            .map_err(|e| format!("failed to persist running background task metadata: {e}"))?;
921
922        let task = Arc::new(BgTask {
923            task_id: task_id.clone(),
924            session_id,
925            paths: paths.clone(),
926            artifact_root: canonical_artifact_root(&paths),
927            started: Instant::now(),
928            last_reminder_at: Mutex::new(None),
929            terminal_at: Mutex::new(None),
930            state: Mutex::new(BgTaskState {
931                metadata,
932                runtime: TaskRuntime::Piped(Some(child)),
933                detached: false,
934                child_exit_observed: false,
935                buffer: BgBuffer::new(paths.stdout.clone(), paths.stderr.clone()),
936                terminal_output_cache: None,
937                pending_terminal_override: None,
938            }),
939        });
940
941        self.inner
942            .tasks
943            .lock()
944            .map_err(|_| "background task registry lock poisoned".to_string())?
945            .insert(task_id.clone(), task);
946
947        Ok(task_id)
948    }
949
950    pub fn write_pty(
951        &self,
952        task_id: &str,
953        session_id: &str,
954        input: &[u8],
955    ) -> Result<usize, String> {
956        let task = self
957            .task_for_session(task_id, session_id)
958            .ok_or_else(|| "task_not_found".to_string())?;
959
960        let writer = {
961            let state = task
962                .state
963                .lock()
964                .map_err(|_| "background task lock poisoned".to_string())?;
965            if state.metadata.mode != BgMode::Pty {
966                return Err("task_not_pty".to_string());
967            }
968            if state.metadata.status.is_terminal() {
969                return Err("task_exited".to_string());
970            }
971            match &state.runtime {
972                TaskRuntime::Pty(Some(runtime)) => Arc::clone(&runtime.writer),
973                TaskRuntime::Pty(None) => return Err("task_exited".to_string()),
974                TaskRuntime::Piped(_) => return Err("task_not_pty".to_string()),
975            }
976        };
977
978        let mut writer = writer
979            .lock()
980            .map_err(|_| "PTY writer lock poisoned".to_string())?;
981        writer
982            .write_all(input)
983            .map_err(|error| format!("failed to write to PTY: {error}"))?;
984        writer
985            .flush()
986            .map_err(|error| format!("failed to flush PTY writer: {error}"))?;
987        Ok(input.len())
988    }
989
990    pub fn replay_session(&self, storage_dir: &Path, session_id: &str) -> Result<(), String> {
991        self.replay_session_inner(storage_dir, session_id, None)
992    }
993
994    pub fn replay_session_for_project(
995        &self,
996        storage_dir: &Path,
997        session_id: &str,
998        project_root: &Path,
999    ) -> Result<(), String> {
1000        self.replay_session_inner(storage_dir, session_id, Some(project_root))
1001    }
1002
1003    fn replay_session_inner(
1004        &self,
1005        storage_dir: &Path,
1006        session_id: &str,
1007        project_root: Option<&Path>,
1008    ) -> Result<(), String> {
1009        self.start_watchdog();
1010        if !self.inner.persisted_gc_started.swap(true, Ordering::SeqCst) {
1011            if let Err(error) = self.maybe_gc_persisted(storage_dir) {
1012                crate::slog_warn!("failed to GC persisted background bash tasks: {error}");
1013            }
1014        }
1015
1016        let canonical_project = project_root.map(canonicalized_path);
1017        // Replay strategy: DB is the post-v0.27 source of truth. Disk
1018        // fallback handles pre-v0.27 tasks that haven't been migrated and
1019        // the cold-start `__default__` namespace (configure runs before any
1020        // user session exists, so plugin-init triggers a session-less DB
1021        // lookup that will be empty until a real session writes a task).
1022        //
1023        // We deliberately keep the empty-DB / empty-disk path silent — it's
1024        // the normal startup case and would otherwise fire on every configure
1025        // (see GitHub user report against v0.27.0). INFO-level logs only when
1026        // disk actually returned tasks (real migration signal); WARN when the
1027        // DB lookup itself errored.
1028        let tasks = match self.replay_session_from_db(session_id) {
1029            Some(Ok(tasks)) if !tasks.is_empty() => tasks,
1030            Some(Ok(_)) => {
1031                let disk_tasks = self.replay_session_from_disk(storage_dir, session_id)?;
1032                if !disk_tasks.is_empty() {
1033                    crate::slog_info!(
1034                        "bash task replay: 0 in DB for session {}, {} from disk fallback",
1035                        session_id,
1036                        disk_tasks.len()
1037                    );
1038                }
1039                disk_tasks
1040            }
1041            Some(Err(error)) => {
1042                crate::slog_warn!(
1043                    "bash task replay DB lookup failed for session {}; falling back to disk: {}",
1044                    session_id,
1045                    error
1046                );
1047                self.replay_session_from_disk(storage_dir, session_id)?
1048            }
1049            None => {
1050                // DB pool unconfigured — common in tests + before harness is set.
1051                self.replay_session_from_disk(storage_dir, session_id)?
1052            }
1053        };
1054
1055        for mut metadata in tasks {
1056            if metadata.session_id != session_id {
1057                continue;
1058            }
1059            if let Some(canonical_project) = canonical_project.as_deref() {
1060                let metadata_project = metadata.project_root.as_deref().map(canonicalized_path);
1061                if metadata_project.as_deref() != Some(canonical_project) {
1062                    continue;
1063                }
1064            }
1065
1066            let paths = task_paths(storage_dir, session_id, &metadata.task_id);
1067            match metadata.status {
1068                BgTaskStatus::Starting => {
1069                    let completion_was_delivered = metadata.completion_delivered;
1070                    metadata.mark_terminal(
1071                        BgTaskStatus::Failed,
1072                        None,
1073                        Some("spawn aborted".to_string()),
1074                    );
1075                    metadata.completion_delivered |= completion_was_delivered;
1076                    let _ = self.persist_task(&paths, &metadata);
1077                    self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1078                    self.insert_rehydrated_task(metadata, paths, true)?;
1079                }
1080                BgTaskStatus::Running | BgTaskStatus::Killing => {
1081                    if metadata.mode == BgMode::Pty {
1082                        if let Ok(Some(marker)) = read_exit_marker(&paths.exit) {
1083                            let completion_was_delivered = metadata.completion_delivered;
1084                            metadata = terminal_metadata_from_marker(metadata, marker, None);
1085                            metadata.completion_delivered |= completion_was_delivered;
1086                            let _ = self.persist_task(&paths, &metadata);
1087                            self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1088                            self.insert_rehydrated_task(metadata, paths, true)?;
1089                        } else if metadata.status.is_terminal() {
1090                            self.insert_rehydrated_task(metadata, paths, true)?;
1091                        } else {
1092                            let completion_was_delivered = metadata.completion_delivered;
1093                            metadata.mark_terminal(
1094                                BgTaskStatus::Killed,
1095                                None,
1096                                Some("pty_lost_on_bridge_restart".to_string()),
1097                            );
1098                            metadata.completion_delivered |= completion_was_delivered;
1099                            let _ = self.persist_task(&paths, &metadata);
1100                            self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1101                            self.insert_rehydrated_task(metadata, paths, true)?;
1102                        }
1103                    } else if self.running_metadata_is_stale(&metadata) {
1104                        let completion_was_delivered = metadata.completion_delivered;
1105                        metadata.mark_terminal(
1106                            BgTaskStatus::Killed,
1107                            None,
1108                            Some("orphaned (>24h)".to_string()),
1109                        );
1110                        metadata.completion_delivered |= completion_was_delivered;
1111                        if !paths.exit.exists() {
1112                            let _ = write_kill_marker_if_absent(&paths.exit);
1113                        }
1114                        let _ = self.persist_task(&paths, &metadata);
1115                        self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1116                        self.insert_rehydrated_task(metadata, paths, true)?;
1117                    } else if let Ok(Some(marker)) = read_exit_marker(&paths.exit) {
1118                        let reason = (metadata.status == BgTaskStatus::Killing).then(|| {
1119                            "recovered from inconsistent killing state on replay".to_string()
1120                        });
1121                        if reason.is_some() {
1122                            crate::slog_warn!("background task {} had killing state with exit marker; preferring marker",
1123                            metadata.task_id);
1124                        }
1125                        let completion_was_delivered = metadata.completion_delivered;
1126                        metadata = terminal_metadata_from_marker(metadata, marker, reason);
1127                        metadata.completion_delivered |= completion_was_delivered;
1128                        let _ = self.persist_task(&paths, &metadata);
1129                        self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1130                        self.insert_rehydrated_task(metadata, paths, true)?;
1131                    } else if metadata.status == BgTaskStatus::Killing {
1132                        if !paths.exit.exists() {
1133                            let _ = write_kill_marker_if_absent(&paths.exit);
1134                        }
1135                        let completion_was_delivered = metadata.completion_delivered;
1136                        metadata.mark_terminal(
1137                            BgTaskStatus::Killed,
1138                            None,
1139                            Some("recovered from inconsistent killing state on replay".to_string()),
1140                        );
1141                        metadata.completion_delivered |= completion_was_delivered;
1142                        let _ = self.persist_task(&paths, &metadata);
1143                        self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1144                        self.insert_rehydrated_task(metadata, paths, true)?;
1145                    } else if metadata.child_pid.is_some_and(|pid| !is_process_alive(pid)) {
1146                        let completion_was_delivered = metadata.completion_delivered;
1147                        metadata.mark_terminal(
1148                            BgTaskStatus::Failed,
1149                            None,
1150                            Some("process exited without exit marker".to_string()),
1151                        );
1152                        metadata.completion_delivered |= completion_was_delivered;
1153                        let _ = self.persist_task(&paths, &metadata);
1154                        self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1155                        self.insert_rehydrated_task(metadata, paths, true)?;
1156                    } else {
1157                        self.insert_rehydrated_task(metadata, paths, true)?;
1158                    }
1159                }
1160                _ if metadata.status.is_terminal() => {
1161                    // Borrow `paths` for the completion enqueue BEFORE
1162                    // `insert_rehydrated_task` consumes it. The completion
1163                    // helper only reads from `paths` (stdout/stderr/exit) to
1164                    // reconstruct a tail preview, so it must see the same
1165                    // paths the rehydrated task will own.
1166                    self.enqueue_completion_if_needed(&metadata, Some(&paths), false);
1167                    self.insert_rehydrated_task(metadata, paths, true)?;
1168                }
1169                _ => {}
1170            }
1171        }
1172
1173        Ok(())
1174    }
1175
1176    fn replay_session_from_db(
1177        &self,
1178        session_id: &str,
1179    ) -> Option<Result<Vec<PersistedTask>, String>> {
1180        let pool = self
1181            .inner
1182            .db_pool
1183            .read()
1184            .ok()
1185            .and_then(|slot| slot.clone())?;
1186        let harness = self
1187            .inner
1188            .db_harness
1189            .read()
1190            .ok()
1191            .and_then(|slot| slot.clone())?;
1192        let conn = match pool.lock() {
1193            Ok(conn) => conn,
1194            Err(_) => return Some(Err("db mutex poisoned".to_string())),
1195        };
1196        Some(
1197            crate::db::bash_tasks::list_bash_tasks_for_session(&conn, &harness, session_id)
1198                .map(|rows| rows.into_iter().map(PersistedTask::from).collect())
1199                .map_err(|error| error.to_string()),
1200        )
1201    }
1202
1203    fn replay_session_from_disk(
1204        &self,
1205        storage_dir: &Path,
1206        session_id: &str,
1207    ) -> Result<Vec<PersistedTask>, String> {
1208        let dir = session_tasks_dir(storage_dir, session_id);
1209        if !dir.exists() {
1210            return Ok(Vec::new());
1211        }
1212
1213        let entries = fs::read_dir(&dir)
1214            .map_err(|e| format!("failed to read background task dir {}: {e}", dir.display()))?;
1215        let mut tasks = Vec::new();
1216        for entry in entries.flatten() {
1217            let path = entry.path();
1218            if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
1219                continue;
1220            }
1221            match read_task(&path) {
1222                Ok(metadata) => tasks.push(metadata),
1223                Err(error) => {
1224                    crate::slog_warn!(
1225                        "quarantining invalid background task metadata {} during replay: {error}",
1226                        path.display()
1227                    );
1228                    if let Err(quarantine_error) =
1229                        quarantine_task_json(storage_dir, &dir, &path, QuarantineKind::Invalid)
1230                    {
1231                        crate::slog_warn!(
1232                            "failed to quarantine invalid background task metadata {}: {quarantine_error}",
1233                            path.display()
1234                        );
1235                    }
1236                }
1237            }
1238        }
1239        Ok(tasks)
1240    }
1241
1242    pub fn register_watch(
1243        &self,
1244        task_id: String,
1245        pattern: WatchPattern,
1246        once: bool,
1247    ) -> Result<String, &'static str> {
1248        let task = self.task(&task_id).ok_or("task_not_found")?;
1249        let (mode, terminal_at_registration, stdout, stderr, pty) = task
1250            .state
1251            .lock()
1252            .map(|state| {
1253                (
1254                    state.metadata.mode.clone(),
1255                    state.metadata.status.is_terminal(),
1256                    task.paths.stdout.clone(),
1257                    task.paths.stderr.clone(),
1258                    task.paths.pty.clone(),
1259                )
1260            })
1261            .map_err(|_| "background_task_lock_poisoned")?;
1262
1263        let mut terminal_matches = Vec::new();
1264        let scanned_terminal = terminal_at_registration;
1265        let watch_id = {
1266            let mut registry = self
1267                .inner
1268                .watch_registry
1269                .lock()
1270                .map_err(|_| "watch_registry_poisoned")?;
1271            let watch_id = registry.register(task_id.clone(), pattern, once)?;
1272            match &mode {
1273                BgMode::Pipes => {
1274                    let stdout_key = format!("{task_id}:stdout");
1275                    let stderr_key = format!("{task_id}:stderr");
1276                    if terminal_at_registration {
1277                        registry.set_file_cursor(&stdout_key, 0);
1278                        registry.set_file_cursor(&stderr_key, 0);
1279                        terminal_matches.extend(registry.scan_file_new_bytes(
1280                            &stdout_key,
1281                            &task_id,
1282                            &stdout,
1283                        ));
1284                        terminal_matches.extend(registry.scan_file_new_bytes(
1285                            &stderr_key,
1286                            &task_id,
1287                            &stderr,
1288                        ));
1289                    } else {
1290                        registry.prime_file_cursor(&stdout_key, &stdout);
1291                        registry.prime_file_cursor(&stderr_key, &stderr);
1292                    }
1293                }
1294                BgMode::Pty => {
1295                    let pty_key = format!("{task_id}:pty");
1296                    if terminal_at_registration {
1297                        registry.set_file_cursor(&pty_key, 0);
1298                        terminal_matches
1299                            .extend(registry.scan_file_new_bytes(&pty_key, &task_id, &pty));
1300                    } else {
1301                        registry.prime_file_cursor(&pty_key, &pty);
1302                    }
1303                }
1304            }
1305            watch_id
1306        };
1307
1308        if task.is_terminal() {
1309            if !scanned_terminal {
1310                terminal_matches = {
1311                    let mut registry = self
1312                        .inner
1313                        .watch_registry
1314                        .lock()
1315                        .map_err(|_| "watch_registry_poisoned")?;
1316                    match &mode {
1317                        BgMode::Pipes => {
1318                            let stdout_key = format!("{task_id}:stdout");
1319                            let stderr_key = format!("{task_id}:stderr");
1320                            registry.set_file_cursor(&stdout_key, 0);
1321                            registry.set_file_cursor(&stderr_key, 0);
1322                            let mut matches =
1323                                registry.scan_file_new_bytes(&stdout_key, &task_id, &stdout);
1324                            matches.extend(registry.scan_file_new_bytes(
1325                                &stderr_key,
1326                                &task_id,
1327                                &stderr,
1328                            ));
1329                            matches
1330                        }
1331                        BgMode::Pty => {
1332                            let pty_key = format!("{task_id}:pty");
1333                            registry.set_file_cursor(&pty_key, 0);
1334                            registry.scan_file_new_bytes(&pty_key, &task_id, &pty)
1335                        }
1336                    }
1337                };
1338            }
1339
1340            let (watch_controlled, watch_matched) = self.task_watch_state(&task_id);
1341            if terminal_matches.is_empty() && (!watch_controlled || watch_matched) {
1342                if watch_matched {
1343                    let _ = task.set_completion_delivered(true, self);
1344                    self.clear_task_watch_state(&task_id);
1345                }
1346                return Ok(watch_id);
1347            }
1348
1349            let completion = self
1350                .remove_pending_completion(&task_id)
1351                .or_else(|| self.completion_snapshot_for_task(&task));
1352            if terminal_matches.is_empty() {
1353                if let Some(completion) = completion.as_ref() {
1354                    self.emit_bash_watch_exit(completion);
1355                }
1356            } else {
1357                for pattern_match in terminal_matches {
1358                    self.emit_bash_pattern_match(&task.session_id, pattern_match);
1359                }
1360            }
1361            let _ = task.set_completion_delivered(true, self);
1362            self.clear_task_watch_state(&task_id);
1363        }
1364
1365        Ok(watch_id)
1366    }
1367
1368    pub fn unregister_watch(&self, task_id: &str, watch_id: &str) {
1369        if let Ok(mut registry) = self.inner.watch_registry.lock() {
1370            registry.unregister(task_id, watch_id);
1371        }
1372    }
1373
1374    pub fn active_watch_count(&self, task_id: &str) -> usize {
1375        self.inner
1376            .watch_registry
1377            .lock()
1378            .map(|registry| registry.active_count(task_id))
1379            .unwrap_or(0)
1380    }
1381
1382    fn task_watch_state(&self, task_id: &str) -> (bool, bool) {
1383        self.inner
1384            .watch_registry
1385            .lock()
1386            .map(|registry| {
1387                (
1388                    registry.has_controlled_task(task_id),
1389                    registry.has_matched_task(task_id),
1390                )
1391            })
1392            .unwrap_or((false, false))
1393    }
1394
1395    fn task_has_watch_control(&self, task_id: &str) -> bool {
1396        self.inner
1397            .watch_registry
1398            .lock()
1399            .map(|registry| registry.has_controlled_task(task_id))
1400            .unwrap_or(false)
1401    }
1402
1403    fn clear_task_watch_state(&self, task_id: &str) {
1404        if let Ok(mut registry) = self.inner.watch_registry.lock() {
1405            registry.clear_task(task_id);
1406        }
1407    }
1408
1409    pub(crate) fn scan_task_watch_output(&self, task: &Arc<BgTask>) {
1410        let (mode, stdout, stderr, pty) = match task.state.lock() {
1411            Ok(state) => (
1412                state.metadata.mode.clone(),
1413                task.paths.stdout.clone(),
1414                task.paths.stderr.clone(),
1415                task.paths.pty.clone(),
1416            ),
1417            Err(_) => return,
1418        };
1419        let mut matches = Vec::new();
1420        if let Ok(mut registry) = self.inner.watch_registry.lock() {
1421            match mode {
1422                BgMode::Pipes => {
1423                    let stdout_key = format!("{}:stdout", task.task_id);
1424                    let stderr_key = format!("{}:stderr", task.task_id);
1425                    matches.extend(registry.scan_file_new_bytes(
1426                        &stdout_key,
1427                        &task.task_id,
1428                        &stdout,
1429                    ));
1430                    matches.extend(registry.scan_file_new_bytes(
1431                        &stderr_key,
1432                        &task.task_id,
1433                        &stderr,
1434                    ));
1435                }
1436                BgMode::Pty => {
1437                    let pty_key = format!("{}:pty", task.task_id);
1438                    matches.extend(registry.scan_file_new_bytes(&pty_key, &task.task_id, &pty));
1439                }
1440            }
1441        }
1442        for pattern_match in matches {
1443            self.emit_bash_pattern_match(&task.session_id, pattern_match);
1444        }
1445    }
1446
1447    pub fn status(
1448        &self,
1449        task_id: &str,
1450        session_id: &str,
1451        project_root: Option<&Path>,
1452        storage_dir: Option<&Path>,
1453        preview_bytes: usize,
1454    ) -> Option<BgTaskSnapshot> {
1455        let mut task = self.task_for_session(task_id, session_id);
1456        if task.is_none() {
1457            if let Some(storage_dir) = storage_dir {
1458                let _ = if let Some(project_root) = project_root {
1459                    self.replay_session_for_project(storage_dir, session_id, project_root)
1460                } else {
1461                    self.replay_session(storage_dir, session_id)
1462                };
1463                task = self.task_for_session(task_id, session_id);
1464            }
1465        }
1466        let Some(task) = task else {
1467            return self.status_relaxed(
1468                task_id,
1469                session_id,
1470                project_root?,
1471                storage_dir?,
1472                preview_bytes,
1473            );
1474        };
1475        let _ = self.poll_task(&task);
1476        Some(self.snapshot_with_terminal_cache(&task, preview_bytes))
1477    }
1478
1479    fn status_relaxed_task(
1480        &self,
1481        task_id: &str,
1482        project_root: &Path,
1483        storage_dir: &Path,
1484    ) -> Option<Arc<BgTask>> {
1485        let canonical_project = canonicalized_path(project_root);
1486        match self.lookup_relaxed_task_from_db(task_id, project_root) {
1487            Some(Ok(Some(metadata))) => {
1488                if let Some(task) = self.task(task_id) {
1489                    let matches_project = task
1490                        .state
1491                        .lock()
1492                        .map(|state| {
1493                            state
1494                                .metadata
1495                                .project_root
1496                                .as_deref()
1497                                .map(canonicalized_path)
1498                                .as_deref()
1499                                == Some(canonical_project.as_path())
1500                        })
1501                        .unwrap_or(false);
1502                    return matches_project.then_some(task);
1503                }
1504                let paths = task_paths(storage_dir, &metadata.session_id, &metadata.task_id);
1505                if self.insert_rehydrated_task(metadata, paths, true).is_err() {
1506                    return None;
1507                }
1508                return self.task(task_id);
1509            }
1510            Some(Ok(None)) => {
1511                crate::slog_info!(
1512                    "bash task relaxed DB miss for {}; falling back to disk",
1513                    task_id
1514                );
1515            }
1516            Some(Err(error)) => {
1517                crate::slog_warn!(
1518                    "bash task relaxed DB lookup failed for {}; falling back to disk: {}",
1519                    task_id,
1520                    error
1521                );
1522            }
1523            None => {
1524                crate::slog_info!(
1525                    "bash task relaxed DB unavailable for {}; falling back to disk",
1526                    task_id
1527                );
1528            }
1529        }
1530        let root = storage_dir.join("bash-tasks");
1531        let entries = fs::read_dir(&root).ok()?;
1532        for entry in entries.flatten() {
1533            let dir = entry.path();
1534            if !dir.is_dir() {
1535                continue;
1536            }
1537            let path = dir.join(format!("{task_id}.json"));
1538            if !path.exists() {
1539                continue;
1540            }
1541            let metadata = match read_task(&path) {
1542                Ok(metadata) => metadata,
1543                Err(error) => {
1544                    crate::slog_warn!(
1545                        "quarantining invalid background task metadata {} during relaxed lookup: {error}",
1546                        path.display()
1547                    );
1548                    if let Err(quarantine_error) =
1549                        quarantine_task_json(storage_dir, &dir, &path, QuarantineKind::Invalid)
1550                    {
1551                        crate::slog_warn!(
1552                            "failed to quarantine invalid background task metadata {}: {quarantine_error}",
1553                            path.display()
1554                        );
1555                    }
1556                    continue;
1557                }
1558            };
1559            let metadata_project = metadata.project_root.as_deref().map(canonicalized_path);
1560            if metadata_project.as_deref() != Some(canonical_project.as_path()) {
1561                continue;
1562            }
1563            if let Some(task) = self.task(task_id) {
1564                let matches_project = task
1565                    .state
1566                    .lock()
1567                    .map(|state| {
1568                        state
1569                            .metadata
1570                            .project_root
1571                            .as_deref()
1572                            .map(canonicalized_path)
1573                            .as_deref()
1574                            == Some(canonical_project.as_path())
1575                    })
1576                    .unwrap_or(false);
1577                return matches_project.then_some(task);
1578            }
1579            let paths = task_paths(storage_dir, &metadata.session_id, &metadata.task_id);
1580            if self.insert_rehydrated_task(metadata, paths, true).is_err() {
1581                return None;
1582            }
1583            return self.task(task_id);
1584        }
1585        None
1586    }
1587
1588    fn lookup_relaxed_task_from_db(
1589        &self,
1590        task_id: &str,
1591        project_root: &Path,
1592    ) -> Option<Result<Option<PersistedTask>, String>> {
1593        let pool = self
1594            .inner
1595            .db_pool
1596            .read()
1597            .ok()
1598            .and_then(|slot| slot.clone())?;
1599        let harness = self
1600            .inner
1601            .db_harness
1602            .read()
1603            .ok()
1604            .and_then(|slot| slot.clone())?;
1605        let conn = match pool.lock() {
1606            Ok(conn) => conn,
1607            Err(_) => return Some(Err("db mutex poisoned".to_string())),
1608        };
1609        let project_key = crate::path_identity::project_scope_key(project_root);
1610        Some(
1611            crate::db::bash_tasks::find_bash_task_for_project(
1612                &conn,
1613                &harness,
1614                &project_key,
1615                task_id,
1616            )
1617            .map(|row| row.map(PersistedTask::from))
1618            .map_err(|error| error.to_string()),
1619        )
1620    }
1621
1622    pub(super) fn status_relaxed(
1623        &self,
1624        task_id: &str,
1625        _session_id: &str,
1626        project_root: &Path,
1627        storage_dir: &Path,
1628        preview_bytes: usize,
1629    ) -> Option<BgTaskSnapshot> {
1630        let task = self.status_relaxed_task(task_id, project_root, storage_dir)?;
1631        let _ = self.poll_task(&task);
1632        Some(self.snapshot_with_terminal_cache(&task, preview_bytes))
1633    }
1634
1635    pub fn kill_relaxed(
1636        &self,
1637        task_id: &str,
1638        project_root: &Path,
1639        storage_dir: &Path,
1640    ) -> Result<BgTaskSnapshot, String> {
1641        let task = self
1642            .status_relaxed_task(task_id, project_root, storage_dir)
1643            .ok_or_else(|| format!("background task not found: {task_id}"))?;
1644        self.kill_with_status(task_id, &task.session_id, BgTaskStatus::Killed)
1645    }
1646
1647    pub fn maybe_gc_persisted(&self, storage_dir: &Path) -> Result<usize, String> {
1648        #[cfg(test)]
1649        self.inner.persisted_gc_runs.fetch_add(1, Ordering::SeqCst);
1650
1651        let mut deleted = 0usize;
1652
1653        let root = storage_dir.join("bash-tasks");
1654        if root.exists() {
1655            let session_dirs = fs::read_dir(&root).map_err(|e| {
1656                format!(
1657                    "failed to read background task root {}: {e}",
1658                    root.display()
1659                )
1660            })?;
1661            for session_entry in session_dirs.flatten() {
1662                let session_dir = session_entry.path();
1663                if !session_dir.is_dir() {
1664                    continue;
1665                }
1666                let task_entries = match fs::read_dir(&session_dir) {
1667                    Ok(entries) => entries,
1668                    Err(error) => {
1669                        crate::slog_warn!(
1670                            "failed to read background task session dir {}: {error}",
1671                            session_dir.display()
1672                        );
1673                        continue;
1674                    }
1675                };
1676                for task_entry in task_entries.flatten() {
1677                    let json_path = task_entry.path();
1678                    if json_path
1679                        .extension()
1680                        .and_then(|extension| extension.to_str())
1681                        != Some("json")
1682                    {
1683                        continue;
1684                    }
1685                    if modified_within(&json_path, PERSISTED_GC_GRACE) {
1686                        continue;
1687                    }
1688                    let metadata = match read_task(&json_path) {
1689                        Ok(metadata) => metadata,
1690                        Err(error) => {
1691                            crate::slog_warn!(
1692                                "quarantining corrupt background task metadata {}: {error}",
1693                                json_path.display()
1694                            );
1695                            quarantine_task_json(
1696                                storage_dir,
1697                                &session_dir,
1698                                &json_path,
1699                                QuarantineKind::Corrupt,
1700                            )?;
1701                            continue;
1702                        }
1703                    };
1704                    if !(metadata.status.is_terminal() && metadata.completion_delivered) {
1705                        continue;
1706                    }
1707                    let paths = task_paths(storage_dir, &metadata.session_id, &metadata.task_id);
1708                    match delete_task_bundle(&paths) {
1709                        Ok(()) => {
1710                            deleted += 1;
1711                            log::debug!(
1712                                "deleted persisted background task bundle {}",
1713                                metadata.task_id
1714                            );
1715                        }
1716                        Err(error) => {
1717                            crate::slog_warn!(
1718                                "failed to delete background task bundle {}: {error}",
1719                                metadata.task_id
1720                            );
1721                            continue;
1722                        }
1723                    }
1724                }
1725            }
1726        }
1727        gc_quarantine(storage_dir);
1728        Ok(deleted)
1729    }
1730
1731    pub fn list(&self, preview_bytes: usize) -> Vec<BgTaskSnapshot> {
1732        let tasks = self
1733            .inner
1734            .tasks
1735            .lock()
1736            .map(|tasks| tasks.values().cloned().collect::<Vec<_>>())
1737            .unwrap_or_default();
1738        tasks
1739            .into_iter()
1740            .map(|task| {
1741                let _ = self.poll_task(&task);
1742                self.snapshot_with_terminal_cache(&task, preview_bytes)
1743            })
1744            .collect()
1745    }
1746
1747    /// Replace terminal pipe snapshots with the task's cached rendered output.
1748    /// Running tasks stay raw (tail-only) so agents debugging a live process see
1749    /// exactly what it emitted. PTY tasks are explicitly excluded: their raw
1750    /// terminal bytes are rendered by the plugin's PTY path, not the line
1751    /// compressor.
1752    fn maybe_compress_snapshot(&self, task: &Arc<BgTask>, snapshot: &mut BgTaskSnapshot) {
1753        if !snapshot.info.status.is_terminal() || snapshot.info.mode == BgMode::Pty {
1754            return;
1755        }
1756        if let Some(cache) = self.ensure_terminal_output_cache(task) {
1757            snapshot.output_preview = cache.output_preview;
1758            snapshot.output_truncated = cache.output_truncated;
1759        }
1760    }
1761
1762    pub fn kill(&self, task_id: &str, session_id: &str) -> Result<BgTaskSnapshot, String> {
1763        self.kill_with_status(task_id, session_id, BgTaskStatus::Killed)
1764    }
1765
1766    pub fn promote(&self, task_id: &str, session_id: &str) -> Result<bool, String> {
1767        let task = self
1768            .task_for_session(task_id, session_id)
1769            .ok_or_else(|| format!("background task not found: {task_id}"))?;
1770        let terminal_after_promote = {
1771            let mut state = task
1772                .state
1773                .lock()
1774                .map_err(|_| "background task lock poisoned".to_string())?;
1775            let updated = self
1776                .update_task_metadata(&task.paths, |metadata| {
1777                    metadata.notify_on_completion = true;
1778                    metadata.completion_delivered = false;
1779                })
1780                .map_err(|e| format!("failed to promote background task: {e}"))?;
1781            state.metadata = updated;
1782            state.metadata.status.is_terminal()
1783        };
1784        if terminal_after_promote {
1785            self.post_terminal_transition(&task, true)?;
1786        }
1787        Ok(true)
1788    }
1789
1790    pub(crate) fn kill_for_timeout(&self, task_id: &str, session_id: &str) -> Result<(), String> {
1791        self.kill_with_status(task_id, session_id, BgTaskStatus::TimedOut)
1792            .map(|_| ())
1793    }
1794
1795    pub fn cleanup_finished(&self, older_than: Duration) {
1796        let cutoff = Instant::now().checked_sub(older_than);
1797        let removable_paths: Vec<(String, TaskPaths)> =
1798            if let Ok(mut tasks) = self.inner.tasks.lock() {
1799                let removable = tasks
1800                    .iter()
1801                    .filter_map(|(task_id, task)| {
1802                        let delivered_terminal = task
1803                            .state
1804                            .lock()
1805                            .map(|state| {
1806                                state.metadata.status.is_terminal()
1807                                    && state.metadata.completion_delivered
1808                            })
1809                            .unwrap_or(false);
1810                        if !delivered_terminal {
1811                            return None;
1812                        }
1813
1814                        let terminal_at = task.terminal_at.lock().ok().and_then(|at| *at);
1815                        let expired = match (terminal_at, cutoff) {
1816                            (Some(terminal_at), Some(cutoff)) => terminal_at <= cutoff,
1817                            (Some(_), None) => true,
1818                            (None, _) => false,
1819                        };
1820                        expired.then(|| task_id.clone())
1821                    })
1822                    .collect::<Vec<_>>();
1823
1824                removable
1825                    .into_iter()
1826                    .filter_map(|task_id| {
1827                        tasks
1828                            .remove(&task_id)
1829                            .map(|task| (task_id, task.paths.clone()))
1830                    })
1831                    .collect()
1832            } else {
1833                Vec::new()
1834            };
1835
1836        for (task_id, paths) in removable_paths {
1837            match delete_task_bundle(&paths) {
1838                Ok(()) => log::debug!("deleted persisted background task bundle {task_id}"),
1839                Err(error) => crate::slog_warn!(
1840                    "failed to delete persisted background task bundle {task_id}: {error}"
1841                ),
1842            }
1843        }
1844    }
1845
1846    pub fn drain_completions(&self) -> Vec<BgCompletion> {
1847        self.drain_completions_for_session(None)
1848    }
1849
1850    pub fn drain_completions_for_session(&self, session_id: Option<&str>) -> Vec<BgCompletion> {
1851        let completions = match self.inner.completions.lock() {
1852            Ok(completions) => completions,
1853            Err(_) => return Vec::new(),
1854        };
1855
1856        completions
1857            .iter()
1858            .filter(|completion| completion_matches_session(completion, session_id))
1859            .cloned()
1860            .collect()
1861    }
1862
1863    pub fn has_completions_for_session(&self, session_id: Option<&str>) -> bool {
1864        match self.inner.completions.lock() {
1865            Ok(completions) => completions
1866                .iter()
1867                .any(|completion| completion_matches_session(completion, session_id)),
1868            // Bias to safety: if the queue state cannot be inspected cheaply,
1869            // let callers take the existing drain path rather than risk
1870            // suppressing a pending completion.
1871            Err(_) => true,
1872        }
1873    }
1874
1875    pub fn ack_completions_for_session(
1876        &self,
1877        session_id: Option<&str>,
1878        task_ids: &[String],
1879    ) -> Vec<String> {
1880        if task_ids.is_empty() {
1881            return Vec::new();
1882        }
1883        let requested_task_ids = task_ids.iter().map(String::as_str).collect::<HashSet<_>>();
1884        let mut completion_sessions = HashMap::new();
1885        if let Ok(mut completions) = self.inner.completions.lock() {
1886            completions.retain(|completion| {
1887                let session_matches = session_id
1888                    .map(|session_id| completion.session_id == session_id)
1889                    .unwrap_or(true);
1890                if session_matches && requested_task_ids.contains(completion.task_id.as_str()) {
1891                    completion_sessions
1892                        .insert(completion.task_id.clone(), completion.session_id.clone());
1893                    false
1894                } else {
1895                    true
1896                }
1897            });
1898        }
1899
1900        let mut delivered = Vec::new();
1901        for task_id in task_ids {
1902            let task = if let Some(session_id) = session_id {
1903                self.task_for_session(task_id, session_id)
1904            } else if let Some(completion_session_id) = completion_sessions.get(task_id) {
1905                self.task_for_session(task_id, completion_session_id)
1906            } else {
1907                self.task(task_id)
1908            };
1909            if let Some(task) = task {
1910                if task.set_completion_delivered(true, self).is_ok() {
1911                    delivered.push(task_id.clone());
1912                }
1913            }
1914        }
1915
1916        delivered
1917    }
1918
1919    pub fn pending_completions_for_session(&self, session_id: &str) -> Vec<BgCompletion> {
1920        self.inner
1921            .completions
1922            .lock()
1923            .map(|completions| {
1924                completions
1925                    .iter()
1926                    .filter(|completion| completion.session_id == session_id)
1927                    .cloned()
1928                    .collect()
1929            })
1930            .unwrap_or_default()
1931    }
1932
1933    fn remove_pending_completion(&self, task_id: &str) -> Option<BgCompletion> {
1934        let mut completions = self.inner.completions.lock().ok()?;
1935        let idx = completions
1936            .iter()
1937            .position(|completion| completion.task_id == task_id)?;
1938        completions.remove(idx)
1939    }
1940
1941    fn completion_snapshot_for_task(&self, task: &Arc<BgTask>) -> Option<BgCompletion> {
1942        let snapshot = self.snapshot_with_terminal_cache(task, RUNNING_OUTPUT_PREVIEW_BYTES);
1943        if !snapshot.info.status.is_terminal() {
1944            return None;
1945        }
1946        let (output_preview, output_truncated) = if snapshot.info.mode == BgMode::Pty {
1947            (String::new(), false)
1948        } else {
1949            self.ensure_terminal_output_cache(task)
1950                .map(|cache| completion_preview_for_cache(&cache, snapshot.exit_code))
1951                .unwrap_or_else(|| (String::new(), false))
1952        };
1953        Some(BgCompletion {
1954            task_id: snapshot.info.task_id,
1955            session_id: task.session_id.clone(),
1956            status: snapshot.info.status,
1957            exit_code: snapshot.exit_code,
1958            command: snapshot.info.command,
1959            output_preview,
1960            output_truncated,
1961            original_tokens: None,
1962            compressed_tokens: None,
1963            tokens_skipped: false,
1964        })
1965    }
1966
1967    pub fn detach(&self) {
1968        self.inner.shutdown.store(true, Ordering::SeqCst);
1969        if let Ok(mut tasks) = self.inner.tasks.lock() {
1970            for task in tasks.values() {
1971                if let Ok(mut state) = task.state.lock() {
1972                    match &mut state.runtime {
1973                        TaskRuntime::Piped(child) => *child = None,
1974                        TaskRuntime::Pty(runtime) => *runtime = None,
1975                    }
1976                    state.detached = true;
1977                }
1978            }
1979            tasks.clear();
1980        }
1981    }
1982
1983    pub fn shutdown(&self) {
1984        let tasks = self
1985            .inner
1986            .tasks
1987            .lock()
1988            .map(|tasks| {
1989                tasks
1990                    .values()
1991                    .map(|task| (task.task_id.clone(), task.session_id.clone()))
1992                    .collect::<Vec<_>>()
1993            })
1994            .unwrap_or_default();
1995        for (task_id, session_id) in tasks {
1996            let _ = self.kill(&task_id, &session_id);
1997        }
1998    }
1999
2000    pub(crate) fn poll_task(&self, task: &Arc<BgTask>) -> Result<(), String> {
2001        if let Ok(state) = task.state.lock() {
2002            if let TaskRuntime::Pty(Some(pty)) = &state.runtime {
2003                // On Windows ConPTY, the reader may not observe EOF while the
2004                // master handle is still held in `PtyRuntime`. The waiter writes
2005                // the authoritative exit marker before setting `exit_observed`,
2006                // so once exit is observed we can finalize from that marker and
2007                // drop the runtime, which lets the reader finish. Waiting for
2008                // `reader_done && exit_observed` wedges completed PTY tasks on
2009                // Windows.
2010                if !pty.exit_observed.load(Ordering::SeqCst) {
2011                    return Ok(());
2012                }
2013            }
2014        }
2015        let marker = match read_exit_marker(&task.paths.exit) {
2016            Ok(Some(marker)) => marker,
2017            Ok(None) => return Ok(()),
2018            Err(error) => return Err(format!("failed to read exit marker: {error}")),
2019        };
2020        self.finalize_from_marker(task, marker, None)
2021    }
2022
2023    pub(crate) fn reap_child(&self, task: &Arc<BgTask>) {
2024        let mut needs_completion = false;
2025        {
2026            let Ok(mut state) = task.state.lock() else {
2027                return;
2028            };
2029            match &mut state.runtime {
2030                TaskRuntime::Piped(child_slot) => {
2031                    if let Some(child) = child_slot.as_mut() {
2032                        if matches!(child.try_wait(), Ok(Some(_))) {
2033                            *child_slot = None;
2034                            state.detached = true;
2035                            state.child_exit_observed = true;
2036                        }
2037                    } else if state.detached {
2038                        let child_known_dead = state.child_exit_observed
2039                            || state
2040                                .metadata
2041                                .child_pid
2042                                .is_some_and(|pid| !is_process_alive(pid));
2043                        if child_known_dead {
2044                            needs_completion =
2045                                self.fail_without_exit_marker_if_needed(task, &mut state);
2046                        }
2047                    }
2048                }
2049                TaskRuntime::Pty(Some(pty)) => {
2050                    if pty.exit_observed.load(Ordering::SeqCst) {
2051                        drop(state);
2052                        let _ = self.poll_task(task);
2053                        return;
2054                    }
2055                }
2056                TaskRuntime::Pty(None) => {}
2057            }
2058        }
2059        if needs_completion {
2060            let _ = self.post_terminal_transition(task, true);
2061        }
2062    }
2063
2064    fn fail_without_exit_marker_if_needed(
2065        &self,
2066        task: &Arc<BgTask>,
2067        state: &mut BgTaskState,
2068    ) -> bool {
2069        if state.metadata.status.is_terminal() {
2070            return false;
2071        }
2072        if matches!(read_exit_marker(&task.paths.exit), Ok(Some(_))) {
2073            return false;
2074        }
2075        let watch_controlled = self.task_has_watch_control(&task.task_id);
2076        let updated = self.update_task_metadata(&task.paths, |metadata| {
2077            metadata.mark_terminal(
2078                BgTaskStatus::Failed,
2079                None,
2080                Some("process exited without exit marker".to_string()),
2081            );
2082            if watch_controlled {
2083                metadata.completion_delivered = true;
2084            }
2085        });
2086        if let Ok(metadata) = updated {
2087            state.pending_terminal_override = None;
2088            state.metadata = metadata;
2089            task.mark_terminal_now();
2090            return true;
2091        }
2092        false
2093    }
2094
2095    pub(crate) fn running_tasks(&self) -> Vec<Arc<BgTask>> {
2096        self.inner
2097            .tasks
2098            .lock()
2099            .map(|tasks| {
2100                tasks
2101                    .values()
2102                    .filter(|task| task.is_running())
2103                    .cloned()
2104                    .collect()
2105            })
2106            .unwrap_or_default()
2107    }
2108
2109    fn insert_rehydrated_task(
2110        &self,
2111        metadata: PersistedTask,
2112        paths: TaskPaths,
2113        detached: bool,
2114    ) -> Result<(), String> {
2115        let task_id = metadata.task_id.clone();
2116        let session_id = metadata.session_id.clone();
2117        let started = started_instant_from_unix_millis(metadata.started_at);
2118        let suppress_replayed_running_reminder = metadata.status == BgTaskStatus::Running;
2119        let mode = metadata.mode.clone();
2120        let task = Arc::new(BgTask {
2121            task_id: task_id.clone(),
2122            session_id,
2123            paths: paths.clone(),
2124            artifact_root: canonical_artifact_root(&paths),
2125            started,
2126            last_reminder_at: Mutex::new(suppress_replayed_running_reminder.then(Instant::now)),
2127            terminal_at: Mutex::new(metadata.status.is_terminal().then(Instant::now)),
2128            state: Mutex::new(BgTaskState {
2129                metadata,
2130                runtime: if mode == BgMode::Pty {
2131                    TaskRuntime::Pty(None)
2132                } else {
2133                    TaskRuntime::Piped(None)
2134                },
2135                detached,
2136                // Replay path: we never observed the child handle's exit
2137                // in this process (the previous AFT process did, but its
2138                // observation didn't survive restart). Leave this false so
2139                // the second-pass reap falls through to the
2140                // `is_process_alive(child_pid)` probe rather than declaring
2141                // failure based on stale evidence.
2142                child_exit_observed: false,
2143                buffer: if mode == BgMode::Pty {
2144                    BgBuffer::pty(paths.pty.clone())
2145                } else {
2146                    BgBuffer::new(paths.stdout.clone(), paths.stderr.clone())
2147                },
2148                terminal_output_cache: None,
2149                pending_terminal_override: None,
2150            }),
2151        });
2152        self.inner
2153            .tasks
2154            .lock()
2155            .map_err(|_| "background task registry lock poisoned".to_string())?
2156            .insert(task_id, task);
2157        Ok(())
2158    }
2159
2160    fn kill_with_status(
2161        &self,
2162        task_id: &str,
2163        session_id: &str,
2164        terminal_status: BgTaskStatus,
2165    ) -> Result<BgTaskSnapshot, String> {
2166        let task = self
2167            .task_for_session(task_id, session_id)
2168            .ok_or_else(|| format!("background task not found: {task_id}"))?;
2169        let mut terminalized = false;
2170
2171        {
2172            let mut state = task
2173                .state
2174                .lock()
2175                .map_err(|_| "background task lock poisoned".to_string())?;
2176            if state.metadata.status.is_terminal() {
2177                state.pending_terminal_override = None;
2178            } else if let Ok(Some(marker)) = read_exit_marker(&task.paths.exit) {
2179                state.metadata =
2180                    terminal_metadata_from_marker(state.metadata.clone(), marker, None);
2181                if self.task_has_watch_control(&task.task_id) {
2182                    state.metadata.completion_delivered = true;
2183                }
2184                state.pending_terminal_override = None;
2185                task.mark_terminal_now();
2186                match &mut state.runtime {
2187                    // Exit marker already present: the child finished on its
2188                    // own before this kill observed it. Reap it rather than
2189                    // dropping the handle so it doesn't become a zombie
2190                    // (issue #91). The active-kill branch below already
2191                    // `wait()`s after signaling, so this is the only kill
2192                    // path that needed the explicit reap.
2193                    TaskRuntime::Piped(child_slot) => reap_piped_child(child_slot),
2194                    TaskRuntime::Pty(runtime) => *runtime = None,
2195                }
2196                state.detached = true;
2197                self.persist_task(&task.paths, &state.metadata)
2198                    .map_err(|e| format!("failed to persist terminal state: {e}"))?;
2199                terminalized = true;
2200            } else {
2201                let was_already_killing = state.metadata.status == BgTaskStatus::Killing;
2202                if !was_already_killing {
2203                    state.metadata.status = BgTaskStatus::Killing;
2204                    self.persist_task(&task.paths, &state.metadata)
2205                        .map_err(|e| format!("failed to persist killing state: {e}"))?;
2206                }
2207
2208                #[cfg(unix)]
2209                let pgid = state.metadata.pgid;
2210                #[cfg(windows)]
2211                let child_pid = state.metadata.child_pid;
2212                if !was_already_killing
2213                    && state.metadata.mode == BgMode::Pty
2214                    && terminal_status == BgTaskStatus::TimedOut
2215                {
2216                    state.pending_terminal_override = Some(BgTaskStatus::TimedOut);
2217                }
2218
2219                #[cfg(windows)]
2220                let mut pty_forced_terminal_status: Option<BgTaskStatus> = None;
2221
2222                match &mut state.runtime {
2223                    TaskRuntime::Piped(child_slot) => {
2224                        #[cfg(unix)]
2225                        if let Some(pgid) = pgid {
2226                            terminate_pgid(pgid, child_slot.as_mut());
2227                        }
2228                        #[cfg(windows)]
2229                        if let Some(child) = child_slot.as_mut() {
2230                            super::process::terminate_process(child);
2231                        } else if let Some(pid) = child_pid {
2232                            terminate_pid(pid);
2233                        }
2234                        if let Some(child) = child_slot.as_mut() {
2235                            let _ = child.wait();
2236                        }
2237                        *child_slot = None;
2238                        state.detached = true;
2239
2240                        if !task.paths.exit.exists() {
2241                            write_kill_marker_if_absent(&task.paths.exit)
2242                                .map_err(|e| format!("failed to write kill marker: {e}"))?;
2243                        }
2244
2245                        let exit_code = terminal_exit_code_for_status(&terminal_status);
2246                        state
2247                            .metadata
2248                            .mark_terminal(terminal_status, exit_code, None);
2249                        if self.task_has_watch_control(&task.task_id) {
2250                            state.metadata.completion_delivered = true;
2251                        }
2252                        state.pending_terminal_override = None;
2253                        task.mark_terminal_now();
2254                        self.persist_task(&task.paths, &state.metadata)
2255                            .map_err(|e| format!("failed to persist killed state: {e}"))?;
2256                        terminalized = true;
2257                    }
2258                    TaskRuntime::Pty(Some(pty)) => {
2259                        pty.was_killed.store(true, Ordering::SeqCst);
2260                        if let Err(error) = pty.killer.kill() {
2261                            crate::slog_warn!(
2262                                "[pty-kill] {task_id} ChildKiller::kill failed: {error}"
2263                            );
2264                        }
2265                        if let Some(pid) = pty.child_pid {
2266                            #[cfg(unix)]
2267                            terminate_pgid(pid as i32, None);
2268                            #[cfg(windows)]
2269                            terminate_pid(pid);
2270                        }
2271                        drop(pty.master.take());
2272
2273                        #[cfg(windows)]
2274                        {
2275                            let default_status = if terminal_status == BgTaskStatus::TimedOut {
2276                                BgTaskStatus::TimedOut
2277                            } else {
2278                                BgTaskStatus::Killed
2279                            };
2280                            pty_forced_terminal_status = Some(
2281                                state
2282                                    .pending_terminal_override
2283                                    .take()
2284                                    .unwrap_or(default_status),
2285                            );
2286                        }
2287                    }
2288                    TaskRuntime::Pty(None) => {}
2289                }
2290
2291                #[cfg(windows)]
2292                if let Some(target_status) = pty_forced_terminal_status {
2293                    if !task.paths.exit.exists() {
2294                        write_kill_marker_if_absent(&task.paths.exit)
2295                            .map_err(|e| format!("failed to write kill marker: {e}"))?;
2296                    }
2297
2298                    let exit_code = terminal_exit_code_for_status(&target_status);
2299                    state.metadata.mark_terminal(target_status, exit_code, None);
2300                    if self.task_has_watch_control(&task.task_id) {
2301                        state.metadata.completion_delivered = true;
2302                    }
2303                    state.pending_terminal_override = None;
2304                    task.mark_terminal_now();
2305                    if let TaskRuntime::Pty(runtime) = &mut state.runtime {
2306                        *runtime = None;
2307                    }
2308                    state.detached = true;
2309                    self.persist_task(&task.paths, &state.metadata)
2310                        .map_err(|e| format!("failed to persist killed PTY state: {e}"))?;
2311                    terminalized = true;
2312                }
2313            }
2314        }
2315
2316        if terminalized {
2317            self.post_terminal_transition(&task, true)?;
2318        }
2319        Ok(self.snapshot_with_terminal_cache(&task, RUNNING_OUTPUT_PREVIEW_BYTES))
2320    }
2321
2322    fn finalize_from_marker(
2323        &self,
2324        task: &Arc<BgTask>,
2325        marker: ExitMarker,
2326        reason: Option<String>,
2327    ) -> Result<(), String> {
2328        let watch_controlled = self.task_has_watch_control(&task.task_id);
2329        let mut pty_reader_done = None;
2330        {
2331            let mut state = task
2332                .state
2333                .lock()
2334                .map_err(|_| "background task lock poisoned".to_string())?;
2335            if state.metadata.status.is_terminal() {
2336                state.pending_terminal_override = None;
2337                return Ok(());
2338            }
2339
2340            let pending_override = state.pending_terminal_override.take();
2341            let is_pty = state.metadata.mode == BgMode::Pty;
2342            let updated = self
2343                .update_task_metadata(&task.paths, |metadata| {
2344                    let mut new_metadata = if is_pty && marker == ExitMarker::Killed {
2345                        let mut metadata = metadata.clone();
2346                        let target_status = pending_override.unwrap_or(BgTaskStatus::Killed);
2347                        let exit_code = terminal_exit_code_for_status(&target_status);
2348                        metadata.mark_terminal(target_status, exit_code, reason);
2349                        metadata
2350                    } else {
2351                        terminal_metadata_from_marker(metadata.clone(), marker, reason)
2352                    };
2353                    if watch_controlled {
2354                        new_metadata.completion_delivered = true;
2355                    }
2356                    *metadata = new_metadata;
2357                })
2358                .map_err(|e| format!("failed to persist terminal state: {e}"))?;
2359            state.metadata = updated;
2360            task.mark_terminal_now();
2361            match &mut state.runtime {
2362                // Reap the exited direct child instead of dropping it, so it
2363                // does not linger as a `<defunct>` zombie (issue #91). The
2364                // wrapper writes the exit marker as its final act, so the
2365                // child is already exiting and `wait()` returns immediately.
2366                TaskRuntime::Piped(child_slot) => reap_piped_child(child_slot),
2367                TaskRuntime::Pty(runtime) => {
2368                    pty_reader_done = runtime
2369                        .as_ref()
2370                        .map(|runtime| Arc::clone(&runtime.reader_done));
2371                    *runtime = None;
2372                }
2373            }
2374            state.detached = true;
2375        }
2376
2377        if let Some(reader_done) = pty_reader_done {
2378            let deadline = Instant::now() + Duration::from_millis(200);
2379            while !reader_done.load(Ordering::SeqCst) && Instant::now() < deadline {
2380                std::thread::sleep(Duration::from_millis(10));
2381            }
2382        }
2383
2384        // One final scan runs before terminal notification routing so bytes
2385        // printed immediately before exit can win over the exit safety net.
2386        self.scan_task_watch_output(task);
2387
2388        self.post_terminal_transition(task, true)
2389    }
2390
2391    fn enqueue_completion_if_needed(
2392        &self,
2393        metadata: &PersistedTask,
2394        paths: Option<&TaskPaths>,
2395        emit_frame: bool,
2396    ) {
2397        if metadata.status.is_terminal() && !metadata.completion_delivered {
2398            let cache =
2399                paths.and_then(|paths| self.render_terminal_output_from_paths(metadata, paths));
2400            self.enqueue_completion_from_parts(metadata, None, paths, emit_frame, cache.as_ref());
2401        }
2402    }
2403
2404    fn render_terminal_output_from_paths(
2405        &self,
2406        metadata: &PersistedTask,
2407        paths: &TaskPaths,
2408    ) -> Option<TerminalOutputCache> {
2409        if metadata.mode == BgMode::Pty {
2410            return None;
2411        }
2412        let mut buffer = BgBuffer::new(paths.stdout.clone(), paths.stderr.clone());
2413        let disk_truncation = buffer.enforce_terminal_cap();
2414        Some(self.render_terminal_output(metadata, &buffer, disk_truncation))
2415    }
2416
2417    fn enqueue_completion_from_parts(
2418        &self,
2419        metadata: &PersistedTask,
2420        buffer: Option<&BgBuffer>,
2421        paths: Option<&TaskPaths>,
2422        emit_frame: bool,
2423        terminal_render: Option<&TerminalOutputCache>,
2424    ) {
2425        // Only the terminal-state guard prevents double-recording here. The
2426        // `completion_delivered` flag is NOT used to gate compression-event
2427        // recording, because `mark_terminal` flips `completion_delivered=true`
2428        // immediately for tasks with `notify_on_completion=false` (foreground
2429        // bash polled via `bash_status`, which is the common case). Pre-emptive
2430        // delivery flagging is correct for the push-frame queue (suppresses
2431        // duplicate user-visible notifications) but would silently skip the
2432        // database insert below. Compression event recording is idempotent at
2433        // the DB layer (unique on harness+session+task_id), so re-entry is
2434        // safe; the dedupe-by-queue check stays for the push frame side.
2435        if !metadata.status.is_terminal() {
2436            return;
2437        }
2438
2439        let owned_buffer = if buffer.is_none() && metadata.mode != BgMode::Pty {
2440            paths.map(|paths| BgBuffer::new(paths.stdout.clone(), paths.stderr.clone()))
2441        } else {
2442            None
2443        };
2444        let render_buffer = buffer.or(owned_buffer.as_ref());
2445        let owned_render = if terminal_render.is_none() {
2446            render_buffer.map(|buffer| {
2447                let mut capped_buffer = buffer.clone();
2448                let disk_truncation = capped_buffer.enforce_terminal_cap();
2449                self.render_terminal_output(metadata, &capped_buffer, disk_truncation)
2450            })
2451        } else {
2452            None
2453        };
2454        let render = terminal_render.or(owned_render.as_ref());
2455
2456        // Completion reminders use the already-rendered terminal output and a
2457        // smaller, exit-aware head+tail cap. They never invoke the compressor
2458        // themselves.
2459        let (output_preview, output_truncated) = render
2460            .map(|cache| completion_preview_for_cache(cache, metadata.exit_code))
2461            .unwrap_or_else(|| (String::new(), false));
2462
2463        let token_counts = self.completion_token_counts(
2464            metadata,
2465            buffer,
2466            paths,
2467            render.map(|render| render.output_preview.as_str()),
2468        );
2469        let completion = BgCompletion {
2470            task_id: metadata.task_id.clone(),
2471            session_id: metadata.session_id.clone(),
2472            status: metadata.status.clone(),
2473            exit_code: metadata.exit_code,
2474            command: metadata.command.clone(),
2475            output_preview,
2476            output_truncated,
2477            original_tokens: token_counts.original_tokens,
2478            compressed_tokens: token_counts.compressed_tokens,
2479            tokens_skipped: token_counts.tokens_skipped,
2480        };
2481
2482        // Record the compression event BEFORE the push-frame dedupe. Event
2483        // recording has its own idempotency at the DB layer (unique key on
2484        // harness+session+task_id), so it's safe to attempt for every
2485        // terminal-state finalize. Critically, this path runs even when
2486        // `completion_delivered=true` was pre-set by `mark_terminal` for
2487        // foreground bash (`notify_on_completion=false`) — which is the common
2488        // case for OpenCode/Pi `bash` tool calls. Previously this code lived
2489        // after the dedupe guard and never fired for foreground tasks, which
2490        // meant compression accounting was effectively dead for >99% of
2491        // real-world bash usage.
2492        self.record_compression_event_if_applicable(metadata, &token_counts);
2493
2494        let (watch_controlled, watch_matched) = self.task_watch_state(&metadata.task_id);
2495        if watch_controlled {
2496            if emit_frame && !watch_matched {
2497                self.emit_bash_watch_exit(&completion);
2498            }
2499            self.clear_task_watch_state(&metadata.task_id);
2500            return;
2501        }
2502
2503        // Push-frame queue is gated on `completion_delivered` so foreground
2504        // bash with `notify_on_completion=false` does not leak a user-visible
2505        // completion notification. `mark_terminal` pre-sets
2506        // `completion_delivered=true` for those tasks; honoring it here keeps
2507        // the suppression invariant the test
2508        // `no_notify_foreground_poll_completion_does_not_enqueue_completion`
2509        // asserts. The compression-event recording above intentionally runs
2510        // before this gate so foreground bash still contributes to the
2511        // session/project aggregates.
2512        if metadata.completion_delivered {
2513            return;
2514        }
2515
2516        // Push-frame queue dedupe stays per-task to prevent duplicate
2517        // user-visible completion notifications.
2518        let pushed = if let Ok(mut completions) = self.inner.completions.lock() {
2519            if completions
2520                .iter()
2521                .any(|existing| existing.task_id == metadata.task_id)
2522            {
2523                false
2524            } else {
2525                completions.push_back(completion.clone());
2526                true
2527            }
2528        } else {
2529            false
2530        };
2531
2532        if pushed && emit_frame {
2533            self.emit_bash_completed(completion);
2534        }
2535    }
2536
2537    fn record_compression_event_if_applicable(
2538        &self,
2539        metadata: &PersistedTask,
2540        token_counts: &CompletionTokenCounts,
2541    ) {
2542        if metadata.mode == BgMode::Pty {
2543            return;
2544        }
2545
2546        let (original_tokens, compressed_tokens, original_bytes, compressed_bytes) = match (
2547            token_counts.original_tokens,
2548            token_counts.compressed_tokens,
2549            token_counts.original_bytes,
2550            token_counts.compressed_bytes,
2551        ) {
2552            (
2553                Some(original_tokens),
2554                Some(compressed_tokens),
2555                Some(original_bytes),
2556                Some(compressed_bytes),
2557            ) => (
2558                original_tokens,
2559                compressed_tokens,
2560                original_bytes,
2561                compressed_bytes,
2562            ),
2563            _ => {
2564                crate::slog_warn!(
2565                    "compression event skipped for {}: token counts unavailable (likely spill file missing or unreadable)",
2566                    metadata.task_id
2567                );
2568                return;
2569            }
2570        };
2571
2572        let pool = self.inner.db_pool.read().ok().and_then(|slot| slot.clone());
2573        let Some(pool) = pool else {
2574            crate::slog_warn!(
2575                "compression event skipped for {}: db_pool not initialized — was configure run?",
2576                metadata.task_id
2577            );
2578            return;
2579        };
2580        let harness = self
2581            .inner
2582            .db_harness
2583            .read()
2584            .ok()
2585            .and_then(|slot| slot.clone());
2586        let Some(harness) = harness else {
2587            crate::slog_warn!(
2588                "compression event insert skipped for {}: harness not configured",
2589                metadata.task_id
2590            );
2591            return;
2592        };
2593
2594        let project_root = metadata
2595            .project_root
2596            .as_deref()
2597            .unwrap_or(&metadata.workdir);
2598        let project_key = crate::path_identity::project_scope_key(project_root);
2599        let row = crate::db::compression_events::CompressionEventRow {
2600            harness: &harness,
2601            session_id: Some(&metadata.session_id),
2602            project_key: &project_key,
2603            tool: "bash",
2604            task_id: Some(&metadata.task_id),
2605            command: Some(&metadata.command),
2606            compressor: if metadata.compressed {
2607                "registry"
2608            } else {
2609                "none"
2610            },
2611            original_bytes,
2612            compressed_bytes,
2613            original_tokens,
2614            compressed_tokens,
2615            created_at: unix_millis() as i64,
2616        };
2617
2618        let conn = match pool.lock() {
2619            Ok(conn) => conn,
2620            Err(_) => {
2621                crate::slog_warn!(
2622                    "compression event insert failed for {}: db mutex poisoned",
2623                    metadata.task_id
2624                );
2625                return;
2626            }
2627        };
2628        match crate::db::compression_events::insert_compression_event(&conn, &row) {
2629            Ok(_) => {
2630                // DEBUG-level: each foreground bash call records one of these,
2631                // which clutters info-level logs without adding diagnostic value.
2632                // Aggregate totals are visible via the status RPC / TUI sidebar.
2633                crate::slog_debug!(
2634                    "compression event recorded for {} (project={}, session={}, {} → {} tokens)",
2635                    metadata.task_id,
2636                    project_key,
2637                    metadata.session_id,
2638                    original_tokens,
2639                    compressed_tokens
2640                );
2641            }
2642            Err(error) => {
2643                crate::slog_warn!(
2644                    "compression event insert failed for {}: {}",
2645                    metadata.task_id,
2646                    error
2647                );
2648            }
2649        }
2650    }
2651
2652    fn emit_bash_pattern_match(&self, session_id: &str, pattern_match: PatternMatch) {
2653        let Ok(progress_sender) = self
2654            .inner
2655            .progress_sender
2656            .lock()
2657            .map(|sender| sender.clone())
2658        else {
2659            return;
2660        };
2661        if let Some(sender) = progress_sender.as_ref() {
2662            sender(PushFrame::BashPatternMatch(BashPatternMatchFrame::new(
2663                pattern_match.task_id,
2664                session_id.to_string(),
2665                pattern_match.watch_id,
2666                pattern_match.match_text,
2667                pattern_match.match_offset,
2668                pattern_match.context,
2669                pattern_match.once,
2670            )));
2671        }
2672    }
2673
2674    fn emit_bash_watch_exit(&self, completion: &BgCompletion) {
2675        let Ok(progress_sender) = self
2676            .inner
2677            .progress_sender
2678            .lock()
2679            .map(|sender| sender.clone())
2680        else {
2681            return;
2682        };
2683        let Some(sender) = progress_sender.as_ref() else {
2684            return;
2685        };
2686        let status = completion_status_text(&completion.status, completion.exit_code);
2687        let preview = completion.output_preview.trim_end();
2688        let context = if preview.is_empty() {
2689            format!("task {} exited ({status})", completion.task_id)
2690        } else {
2691            format!(
2692                "task {} exited ({status})
2693{preview}",
2694                completion.task_id
2695            )
2696        };
2697        sender(PushFrame::BashPatternMatch(
2698            BashPatternMatchFrame::task_exit(
2699                completion.task_id.clone(),
2700                completion.session_id.clone(),
2701                format!("exited ({status})"),
2702                context,
2703            ),
2704        ));
2705    }
2706
2707    fn emit_bash_completed(&self, completion: BgCompletion) {
2708        let Ok(progress_sender) = self
2709            .inner
2710            .progress_sender
2711            .lock()
2712            .map(|sender| sender.clone())
2713        else {
2714            return;
2715        };
2716        let Some(sender) = progress_sender.as_ref() else {
2717            return;
2718        };
2719        // Clone the callback out of the registry mutex before writing to stdout;
2720        // otherwise a blocked push-frame write could pin the mutex and starve
2721        // unrelated progress-sender updates.
2722        // Bg task transitions are discovered by the watchdog thread, so the
2723        // sender is shared behind a Mutex. It still uses the same stdout writer
2724        // closure as foreground progress frames, preserving the existing lock/
2725        // flush behavior in main.rs.
2726        sender(PushFrame::BashCompleted(BashCompletedFrame::new(
2727            completion.task_id,
2728            completion.session_id,
2729            completion.status,
2730            completion.exit_code,
2731            completion.command,
2732            completion.output_preview,
2733            completion.output_truncated,
2734            completion.original_tokens,
2735            completion.compressed_tokens,
2736            completion.tokens_skipped,
2737        )));
2738    }
2739
2740    fn completion_token_counts(
2741        &self,
2742        metadata: &PersistedTask,
2743        buffer: Option<&BgBuffer>,
2744        paths: Option<&TaskPaths>,
2745        rendered_output: Option<&str>,
2746    ) -> CompletionTokenCounts {
2747        if metadata.mode == BgMode::Pty {
2748            return CompletionTokenCounts::skipped();
2749        }
2750
2751        let raw = match buffer {
2752            Some(buffer) => buffer.read_for_token_count(TOKENIZE_CAP_BYTES_PER_STREAM),
2753            None => paths
2754                .map(|paths| {
2755                    read_for_token_count_from_disk(metadata, paths, TOKENIZE_CAP_BYTES_PER_STREAM)
2756                })
2757                .unwrap_or(TokenCountInput::Skipped),
2758        };
2759
2760        let TokenCountInput::Text(raw_output) = raw else {
2761            return CompletionTokenCounts::skipped();
2762        };
2763
2764        let original_tokens = token_count_u32(&raw_output);
2765        let original_bytes = raw_output.len() as i64;
2766        let compressed_output = rendered_output.unwrap_or(&raw_output);
2767        let compressed_tokens = token_count_u32(compressed_output);
2768        let compressed_bytes = compressed_output.len() as i64;
2769        CompletionTokenCounts {
2770            original_tokens: Some(original_tokens),
2771            compressed_tokens: Some(compressed_tokens),
2772            original_bytes: Some(original_bytes),
2773            compressed_bytes: Some(compressed_bytes),
2774            tokens_skipped: false,
2775        }
2776    }
2777
2778    pub(crate) fn maybe_emit_long_running_reminder(&self, task: &Arc<BgTask>) {
2779        if !self
2780            .inner
2781            .long_running_reminder_enabled
2782            .load(Ordering::SeqCst)
2783        {
2784            return;
2785        }
2786        let interval_ms = self
2787            .inner
2788            .long_running_reminder_interval_ms
2789            .load(Ordering::SeqCst);
2790        if interval_ms == 0 {
2791            return;
2792        }
2793        let interval = Duration::from_millis(interval_ms);
2794        let now = Instant::now();
2795        let Ok(mut last_reminder_at) = task.last_reminder_at.lock() else {
2796            return;
2797        };
2798        let since = last_reminder_at.unwrap_or(task.started);
2799        if now.duration_since(since) < interval {
2800            return;
2801        }
2802        let command = task
2803            .state
2804            .lock()
2805            .map(|state| state.metadata.command.clone())
2806            .unwrap_or_default();
2807        *last_reminder_at = Some(now);
2808        self.emit_bash_long_running(BashLongRunningFrame::new(
2809            task.task_id.clone(),
2810            task.session_id.clone(),
2811            command,
2812            task.started.elapsed().as_millis() as u64,
2813        ));
2814    }
2815
2816    fn emit_bash_long_running(&self, frame: BashLongRunningFrame) {
2817        let Ok(progress_sender) = self
2818            .inner
2819            .progress_sender
2820            .lock()
2821            .map(|sender| sender.clone())
2822        else {
2823            return;
2824        };
2825        if let Some(sender) = progress_sender.as_ref() {
2826            sender(PushFrame::BashLongRunning(frame));
2827        }
2828    }
2829
2830    fn task(&self, task_id: &str) -> Option<Arc<BgTask>> {
2831        self.inner
2832            .tasks
2833            .lock()
2834            .ok()
2835            .and_then(|tasks| tasks.get(task_id).cloned())
2836    }
2837
2838    fn task_for_session(&self, task_id: &str, session_id: &str) -> Option<Arc<BgTask>> {
2839        self.task(task_id)
2840            .filter(|task| task.session_id == session_id)
2841    }
2842
2843    pub fn try_health_counts(&self) -> Option<BgTaskHealthCounts> {
2844        let running = self
2845            .inner
2846            .tasks
2847            .try_lock()
2848            .ok()
2849            .map(|tasks| tasks.values().filter(|task| task.is_running()).count())?;
2850        let pending_completions = self.inner.completions.try_lock().ok().map(|q| q.len())?;
2851        Some(BgTaskHealthCounts {
2852            running,
2853            pending_completions,
2854        })
2855    }
2856
2857    /// Estimate resident bash output caches without reading disk-backed task
2858    /// streams. Spill files are deliberately excluded because they do not
2859    /// occupy the daemon heap.
2860    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
2861        let tasks = match self.inner.tasks.try_lock() {
2862            Ok(tasks) => tasks.values().cloned().collect::<Vec<_>>(),
2863            Err(_) => return crate::memory::MemoryEstimate::busy(),
2864        };
2865        let mut bytes = 0u64;
2866        let mut terminal_output_caches = 0usize;
2867        let mut sessions = HashSet::new();
2868        for task in &tasks {
2869            sessions.insert(task.session_id.clone());
2870            let state = match task.state.try_lock() {
2871                Ok(state) => state,
2872                Err(_) => return crate::memory::MemoryEstimate::busy(),
2873            };
2874            if let Some(cache) = state.terminal_output_cache.as_ref() {
2875                terminal_output_caches = terminal_output_caches.saturating_add(1);
2876                bytes = bytes.saturating_add(terminal_output_cache_estimated_bytes(cache));
2877            }
2878        }
2879        let completion_count = match self.inner.completions.try_lock() {
2880            Ok(completions) => {
2881                for completion in completions.iter() {
2882                    sessions.insert(completion.session_id.clone());
2883                    bytes = bytes.saturating_add(completion_estimated_bytes(completion));
2884                }
2885                completions.len()
2886            }
2887            Err(_) => return crate::memory::MemoryEstimate::busy(),
2888        };
2889
2890        crate::memory::MemoryEstimate::estimated(bytes)
2891            .count("tasks", tasks.len())
2892            .count("sessions", sessions.len())
2893            .count("terminal_output_caches", terminal_output_caches)
2894            .count("completion_caches", completion_count)
2895            .count_u64("output_ring_bytes", 0)
2896    }
2897
2898    fn running_count(&self) -> usize {
2899        self.inner
2900            .tasks
2901            .lock()
2902            .map(|tasks| tasks.values().filter(|task| task.is_running()).count())
2903            .unwrap_or(0)
2904    }
2905
2906    fn start_watchdog(&self) {
2907        if !self.inner.watchdog_started.swap(true, Ordering::SeqCst) {
2908            super::watchdog::start(self.clone());
2909        }
2910    }
2911
2912    fn running_metadata_is_stale(&self, metadata: &PersistedTask) -> bool {
2913        unix_millis().saturating_sub(metadata.started_at) > STALE_RUNNING_AFTER.as_millis() as u64
2914    }
2915
2916    #[cfg(test)]
2917    pub fn task_json_path(&self, task_id: &str, session_id: &str) -> Option<PathBuf> {
2918        self.task_for_session(task_id, session_id)
2919            .map(|task| task.paths.json.clone())
2920    }
2921
2922    #[cfg(test)]
2923    pub fn task_exit_path(&self, task_id: &str, session_id: &str) -> Option<PathBuf> {
2924        self.task_for_session(task_id, session_id)
2925            .map(|task| task.paths.exit.clone())
2926    }
2927
2928    /// Generate a `bash-{16hex}` slug that is unique against live tasks and queued completions.
2929    fn generate_unique_task_id(&self) -> Result<String, String> {
2930        for _ in 0..32 {
2931            let candidate = random_slug();
2932            let tasks = self
2933                .inner
2934                .tasks
2935                .lock()
2936                .map_err(|_| "background task registry lock poisoned".to_string())?;
2937            if tasks.contains_key(&candidate) {
2938                continue;
2939            }
2940            let completions = self
2941                .inner
2942                .completions
2943                .lock()
2944                .map_err(|_| "background completions lock poisoned".to_string())?;
2945            if completions
2946                .iter()
2947                .any(|completion| completion.task_id == candidate)
2948            {
2949                continue;
2950            }
2951            return Ok(candidate);
2952        }
2953        Err("failed to allocate unique background task id after 32 attempts".to_string())
2954    }
2955}
2956
2957fn canonical_artifact_root(paths: &TaskPaths) -> PathBuf {
2958    fs::canonicalize(&paths.dir).unwrap_or_else(|_| paths.dir.clone())
2959}
2960
2961fn render_compressed_with_recovery(
2962    buffer: &BgBuffer,
2963    mut compressed: CompressionResult,
2964    input_truncated: bool,
2965    disk_truncation: DiskTruncation,
2966    artifact_access: ArtifactRecoveryAccess,
2967) -> TerminalOutputCache {
2968    // Preserve a single canonical trailing newline. A bare `.trim_end()` strips
2969    // the legitimate final newline that `echo` and most commands emit, so
2970    // agent-facing output diverged from native bash ("hello" vs "hello\n") and
2971    // broke the no-JSON-envelope contract. Collapse excess trailing blank lines
2972    // to one, but keep that one when the content had a trailing newline. NOTE:
2973    // the check must read the ORIGINAL text — strip_plain_truncation_marker_lines
2974    // rebuilds via `.lines().join("\n")`, which itself drops the trailing newline.
2975    let had_trailing_newline = compressed.text.ends_with('\n');
2976    let mut text = strip_plain_truncation_marker_lines(&compressed.text)
2977        .trim_end()
2978        .to_string();
2979    if had_trailing_newline && !text.is_empty() {
2980        text.push('\n');
2981    }
2982    compressed.text = text;
2983
2984    let output_path = buffer.output_path().map(|path| path.display().to_string());
2985    let stderr_path = buffer.stderr_path().map(|path| path.display().to_string());
2986    let include_stderr_path = buffer.stream_len(StreamKind::Stderr) > 0;
2987    let mut recovery = RecoveryContext {
2988        dropped_by_class: compressed.dropped_by_class,
2989        had_inner_drop: compressed.had_inner_drop,
2990        offset_hint_eligible: compressed.offset_hint_eligible,
2991        offset_start_line: compressed.offset_start_line,
2992        byte_truncated: input_truncated,
2993        disk_truncated_prefix_bytes: disk_truncation.total_prefix_bytes(),
2994        output_path: output_path.clone(),
2995        stderr_path: stderr_path.clone(),
2996        include_stderr_path,
2997        artifact_access: artifact_access.clone(),
2998    };
2999
3000    let (output_preview, output_truncated) =
3001        render_body_with_recovery_marker(&compressed.text, &mut recovery);
3002    TerminalOutputCache {
3003        output_preview,
3004        output_truncated,
3005        kind: TerminalOutputKind::Compressed,
3006        output_path,
3007        stderr_path,
3008        artifact_access,
3009        recovery: Some(recovery),
3010    }
3011}
3012
3013fn render_body_with_recovery_marker(body: &str, recovery: &mut RecoveryContext) -> (String, bool) {
3014    render_body_with_recovery_marker_at_cap(
3015        body,
3016        recovery,
3017        FINAL_OUTPUT_CAP_BYTES,
3018        cap_final_output,
3019        cap_final_output_with_marker,
3020    )
3021}
3022
3023fn render_raw_body_with_recovery_marker(
3024    body: &str,
3025    recovery: &mut RecoveryContext,
3026) -> (String, bool) {
3027    render_body_with_recovery_marker_at_cap(
3028        body,
3029        recovery,
3030        RAW_PASSTHROUGH_CAP_BYTES,
3031        |input| {
3032            super::output::cap_head_tail(
3033                input,
3034                RAW_PASSTHROUGH_CAP_BYTES,
3035                RAW_PASSTHROUGH_HEAD_BYTES,
3036                RAW_PASSTHROUGH_TAIL_BYTES,
3037            )
3038        },
3039        |input, marker| {
3040            super::output::cap_head_tail_with_marker(
3041                input,
3042                RAW_PASSTHROUGH_CAP_BYTES,
3043                RAW_PASSTHROUGH_HEAD_BYTES,
3044                RAW_PASSTHROUGH_TAIL_BYTES,
3045                marker,
3046            )
3047        },
3048    )
3049}
3050
3051fn render_body_with_recovery_marker_at_cap<F, G>(
3052    body: &str,
3053    recovery: &mut RecoveryContext,
3054    cap_bytes: usize,
3055    cap_plain: F,
3056    cap_with_marker: G,
3057) -> (String, bool)
3058where
3059    F: Fn(&str) -> super::output::CappedText,
3060    G: Fn(&str, &str) -> super::output::CappedText,
3061{
3062    let needs_marker = recovery.has_visible_drop();
3063    if body.len() > cap_bytes {
3064        recovery.byte_truncated = true;
3065        if let Some(marker) = recovery_marker(recovery) {
3066            let capped = cap_with_marker(body, &marker);
3067            return (capped.text, true);
3068        }
3069        let capped = cap_plain(body);
3070        return (capped.text, capped.truncated || needs_marker);
3071    }
3072
3073    if !needs_marker {
3074        return (body.to_string(), false);
3075    }
3076
3077    let Some(marker) = recovery_marker(recovery) else {
3078        return (body.to_string(), true);
3079    };
3080    let with_marker = append_recovery_marker(body, &marker);
3081    if with_marker.len() <= cap_bytes {
3082        return (with_marker, true);
3083    }
3084
3085    recovery.byte_truncated = true;
3086    let marker = recovery_marker(recovery).unwrap_or(marker);
3087    let capped = cap_with_marker(body, &marker);
3088    (capped.text, true)
3089}
3090
3091fn append_recovery_marker(body: &str, marker: &str) -> String {
3092    if body.is_empty() {
3093        return marker.to_string();
3094    }
3095    let mut output = body.trim_end().to_string();
3096    output.push('\n');
3097    output.push_str(marker);
3098    output
3099}
3100
3101fn recovery_marker(recovery: &RecoveryContext) -> Option<String> {
3102    let mut parts = Vec::new();
3103    for (class, count) in &recovery.dropped_by_class {
3104        let label = if *count == 1 {
3105            class.singular()
3106        } else {
3107            class.plural()
3108        };
3109        parts.push(format!("+{count} more {label}"));
3110    }
3111    if recovery.byte_truncated {
3112        parts.push("truncated output".to_string());
3113    }
3114    let disk_truncated_prefix_bytes = recovery.disk_truncated_prefix_bytes;
3115    if disk_truncated_prefix_bytes > 0 {
3116        parts.push(format!(
3117            "truncated {disk_truncated_prefix_bytes} bytes from saved output prefix"
3118        ));
3119    } else if recovery.had_inner_drop && parts.is_empty() {
3120        parts.push("omitted output".to_string());
3121    }
3122
3123    if parts.is_empty() {
3124        return None;
3125    }
3126
3127    let hint = recovery_hint(recovery);
3128    Some(format!("[{}; {hint}]", parts.join(", ")))
3129}
3130
3131fn bash_status_recovery_hint(access: &ArtifactRecoveryAccess) -> String {
3132    let task_id = serde_json::to_string(&access.task_id)
3133        .unwrap_or_else(|_| format!("\"{}\"", access.task_id));
3134    format!("use bash_status({{taskId: {task_id}}})")
3135}
3136
3137fn recovery_hint(recovery: &RecoveryContext) -> String {
3138    if !recovery.artifact_access.readable {
3139        return bash_status_recovery_hint(&recovery.artifact_access);
3140    }
3141
3142    // AFT stores stdout/stderr separately and combines them in memory. Class caps,
3143    // middle truncation, and mixed stdout/stderr renders are not line-offset
3144    // portable. Only a single-file contiguous-prefix drop may use `tail -n +N`.
3145    if recovery.offset_hint_eligible
3146        && !recovery.byte_truncated
3147        && recovery.dropped_by_class.is_empty()
3148        && !recovery.include_stderr_path
3149    {
3150        if let (Some(path), Some(line)) =
3151            (recovery.output_path.as_deref(), recovery.offset_start_line)
3152        {
3153            return format!("see remaining: tail -n +{line} {}", quote_path(path));
3154        }
3155    }
3156
3157    let mut paths = Vec::new();
3158    if let Some(path) = recovery.output_path.as_deref() {
3159        paths.push(path);
3160    }
3161    if recovery.include_stderr_path {
3162        if let Some(path) = recovery.stderr_path.as_deref() {
3163            if !paths.contains(&path) {
3164                paths.push(path);
3165            }
3166        }
3167    }
3168
3169    if paths.is_empty() {
3170        return "full output unavailable".to_string();
3171    }
3172
3173    let reads = paths
3174        .into_iter()
3175        .map(|path| format!("read {}", quote_path(path)))
3176        .collect::<Vec<_>>()
3177        .join(" and ");
3178    if recovery.disk_truncated_prefix_bytes > 0 {
3179        format!("retained output: {reads}")
3180    } else {
3181        format!("full output: {reads}")
3182    }
3183}
3184
3185fn strip_plain_truncation_marker_lines(input: &str) -> String {
3186    input
3187        .lines()
3188        .filter(|line| !is_plain_truncation_marker(line.trim()))
3189        .collect::<Vec<_>>()
3190        .join("\n")
3191}
3192
3193fn strip_recovery_marker_lines(input: &str) -> String {
3194    input
3195        .lines()
3196        .filter(|line| !is_recovery_marker(line.trim()))
3197        .collect::<Vec<_>>()
3198        .join("\n")
3199}
3200
3201fn is_plain_truncation_marker(line: &str) -> bool {
3202    let Some(rest) = line.strip_prefix("...<truncated ") else {
3203        return false;
3204    };
3205    let Some(bytes) = rest.strip_suffix(" bytes>...") else {
3206        return false;
3207    };
3208    !bytes.is_empty() && bytes.chars().all(|ch| ch.is_ascii_digit())
3209}
3210
3211fn is_recovery_marker(line: &str) -> bool {
3212    line.starts_with('[')
3213        && line.ends_with(']')
3214        && (line.contains("full output: read ")
3215            || line.contains("retained output: read ")
3216            || line.contains("see remaining: tail -n +")
3217            || line.contains("use bash_status({taskId:")
3218            || line.contains("full output unavailable"))
3219}
3220
3221fn structured_output_pointer(
3222    total_bytes: u64,
3223    output_path: &str,
3224    truncated_prefix_bytes: u64,
3225    artifact_access: &ArtifactRecoveryAccess,
3226) -> String {
3227    if artifact_access.readable {
3228        return if truncated_prefix_bytes > 0 {
3229            retained_json_output_pointer(total_bytes, output_path, truncated_prefix_bytes)
3230        } else {
3231            json_output_pointer(total_bytes, output_path)
3232        };
3233    }
3234
3235    let kb = total_bytes.div_ceil(1024);
3236    let hint = bash_status_recovery_hint(artifact_access);
3237    if truncated_prefix_bytes > 0 {
3238        format!(
3239            "[JSON output {kb} KB; truncated {truncated_prefix_bytes} bytes from saved output prefix; retained output: {hint}]"
3240        )
3241    } else {
3242        format!("[JSON output {kb} KB; full output: {hint}]")
3243    }
3244}
3245
3246fn render_structured_output(
3247    command: &str,
3248    buffer: &BgBuffer,
3249    disk_truncation: DiskTruncation,
3250    artifact_access: ArtifactRecoveryAccess,
3251) -> Option<TerminalOutputCache> {
3252    if !is_gh_structured_command(command) {
3253        return None;
3254    }
3255
3256    let output_path = buffer
3257        .output_path()
3258        .map(|path| path.display().to_string())?;
3259    let stdout_bytes = buffer.stream_len(StreamKind::Stdout);
3260    if stdout_bytes == 0 {
3261        return None;
3262    }
3263
3264    if stdout_bytes > STRUCTURED_OUTPUT_CAP_BYTES as u64 {
3265        if !stream_starts_like_json(buffer, StreamKind::Stdout) {
3266            return None;
3267        }
3268        let output_preview = structured_output_pointer(
3269            stdout_bytes,
3270            &output_path,
3271            disk_truncation.total_prefix_bytes(),
3272            &artifact_access,
3273        );
3274        return Some(TerminalOutputCache {
3275            output_preview,
3276            output_truncated: true,
3277            kind: TerminalOutputKind::Structured,
3278            output_path: Some(output_path),
3279            stderr_path: buffer.stderr_path().map(|path| path.display().to_string()),
3280            artifact_access,
3281            recovery: None,
3282        });
3283    }
3284
3285    let stdout = buffer.read_stream_bounded(StreamKind::Stdout, STRUCTURED_OUTPUT_CAP_BYTES);
3286    if stdout.truncated || !is_structured_body(&stdout.text) {
3287        return None;
3288    }
3289
3290    Some(TerminalOutputCache {
3291        output_preview: stdout.text,
3292        output_truncated: false,
3293        kind: TerminalOutputKind::Structured,
3294        output_path: Some(output_path),
3295        stderr_path: buffer.stderr_path().map(|path| path.display().to_string()),
3296        artifact_access,
3297        recovery: None,
3298    })
3299}
3300
3301fn render_raw_passthrough(
3302    buffer: &BgBuffer,
3303    disk_truncation: DiskTruncation,
3304    artifact_access: ArtifactRecoveryAccess,
3305) -> TerminalOutputCache {
3306    let raw = buffer.read_combined_head_tail(
3307        RAW_PASSTHROUGH_CAP_BYTES,
3308        RAW_PASSTHROUGH_HEAD_BYTES,
3309        RAW_PASSTHROUGH_TAIL_BYTES,
3310    );
3311    let output_path = buffer.output_path().map(|path| path.display().to_string());
3312    let stderr_path = buffer.stderr_path().map(|path| path.display().to_string());
3313    if !raw.truncated && disk_truncation.total_prefix_bytes() == 0 {
3314        return TerminalOutputCache {
3315            output_preview: raw.text,
3316            output_truncated: false,
3317            kind: TerminalOutputKind::Raw,
3318            output_path,
3319            stderr_path,
3320            artifact_access,
3321            recovery: None,
3322        };
3323    }
3324
3325    let include_stderr_path = buffer.stream_len(StreamKind::Stderr) > 0;
3326    let mut recovery = RecoveryContext {
3327        dropped_by_class: BTreeMap::new(),
3328        had_inner_drop: false,
3329        offset_hint_eligible: false,
3330        offset_start_line: None,
3331        byte_truncated: raw.truncated,
3332        disk_truncated_prefix_bytes: disk_truncation.total_prefix_bytes(),
3333        output_path: output_path.clone(),
3334        stderr_path: stderr_path.clone(),
3335        include_stderr_path,
3336        artifact_access: artifact_access.clone(),
3337    };
3338    let (output_preview, output_truncated) =
3339        render_raw_body_with_recovery_marker(&raw.text, &mut recovery);
3340    TerminalOutputCache {
3341        output_preview,
3342        output_truncated,
3343        kind: TerminalOutputKind::Raw,
3344        output_path,
3345        stderr_path,
3346        artifact_access,
3347        recovery: Some(recovery),
3348    }
3349}
3350
3351fn completion_preview_for_cache(
3352    cache: &TerminalOutputCache,
3353    exit_code: Option<i32>,
3354) -> (String, bool) {
3355    // Reminder previews are sized by exit status: success gets a short tail,
3356    // failure keeps head+tail context (see output.rs completion caps).
3357    let exit_ok = exit_code == Some(0);
3358    let threshold = completion_preview_threshold(exit_ok);
3359    if cache.kind == TerminalOutputKind::Structured && cache.output_preview.len() > threshold {
3360        if let Some(path) = cache.output_path.as_deref() {
3361            return (
3362                structured_output_pointer(
3363                    cache.output_preview.len() as u64,
3364                    path,
3365                    0,
3366                    &cache.artifact_access,
3367                ),
3368                true,
3369            );
3370        }
3371        return (cache.output_preview.clone(), cache.output_truncated);
3372    }
3373
3374    if let Some(recovery) = cache.recovery.as_ref() {
3375        if cache.output_preview.len() <= threshold {
3376            return (cache.output_preview.clone(), cache.output_truncated);
3377        }
3378        let body = strip_recovery_marker_lines(&cache.output_preview);
3379        let mut completion_recovery = recovery.clone();
3380        completion_recovery.byte_truncated = true;
3381        if let Some(marker) = recovery_marker(&completion_recovery) {
3382            let capped = cap_completion_output_with_marker(&body, &marker, exit_ok);
3383            return (capped.text, true);
3384        }
3385    }
3386
3387    let capped = cap_completion_output(&cache.output_preview, exit_ok);
3388    (capped.text, cache.output_truncated || capped.truncated)
3389}
3390
3391fn is_gh_structured_command(command: &str) -> bool {
3392    let Some(normalized) = crate::compress::plain_command_for_structured_output(command) else {
3393        return false;
3394    };
3395    let tokens = shell_words_for_flags(&normalized);
3396    let Some(head) = tokens.first() else {
3397        return false;
3398    };
3399    let head_name = Path::new(head)
3400        .file_name()
3401        .and_then(|name| name.to_str())
3402        .unwrap_or(head);
3403    if !(head_name == "gh" || head_name.eq_ignore_ascii_case("gh.exe")) {
3404        return false;
3405    }
3406    tokens.iter().any(|token| {
3407        matches!(token.as_str(), "--json" | "--jq" | "--template")
3408            || token.starts_with("--json=")
3409            || token.starts_with("--jq=")
3410            || token.starts_with("--template=")
3411    })
3412}
3413
3414fn shell_words_for_flags(command: &str) -> Vec<String> {
3415    let mut words = Vec::new();
3416    let mut current = String::new();
3417    let mut in_single = false;
3418    let mut in_double = false;
3419    let mut escaped = false;
3420
3421    for ch in command.chars() {
3422        if escaped {
3423            current.push(ch);
3424            escaped = false;
3425            continue;
3426        }
3427        if ch == '\\' && !in_single {
3428            escaped = true;
3429            continue;
3430        }
3431        if ch == '\'' && !in_double {
3432            in_single = !in_single;
3433            continue;
3434        }
3435        if ch == '"' && !in_single {
3436            in_double = !in_double;
3437            continue;
3438        }
3439        if ch.is_whitespace() && !in_single && !in_double {
3440            if !current.is_empty() {
3441                words.push(std::mem::take(&mut current));
3442            }
3443            continue;
3444        }
3445        if matches!(ch, ';' | '&' | '|') && !in_single && !in_double {
3446            if !current.is_empty() {
3447                words.push(std::mem::take(&mut current));
3448            }
3449            continue;
3450        }
3451        current.push(ch);
3452    }
3453    if !current.is_empty() {
3454        words.push(current);
3455    }
3456    words
3457}
3458
3459fn is_structured_body(body: &str) -> bool {
3460    let trimmed = body.trim();
3461    if trimmed.is_empty() {
3462        return false;
3463    }
3464    if serde_json::from_str::<serde_json::Value>(trimmed).is_ok() {
3465        return true;
3466    }
3467
3468    let mut saw_line = false;
3469    for line in trimmed
3470        .lines()
3471        .map(str::trim)
3472        .filter(|line| !line.is_empty())
3473    {
3474        saw_line = true;
3475        if serde_json::from_str::<serde_json::Value>(line).is_err() {
3476            return false;
3477        }
3478    }
3479    saw_line
3480}
3481
3482fn stream_starts_like_json(buffer: &BgBuffer, stream: StreamKind) -> bool {
3483    let path = match (buffer, stream) {
3484        (BgBuffer::Pipes { stdout_path, .. }, StreamKind::Stdout) => Some(stdout_path),
3485        (BgBuffer::Pipes { stderr_path, .. }, StreamKind::Stderr) => Some(stderr_path),
3486        (BgBuffer::Pty { combined_path }, _) => Some(combined_path),
3487    };
3488    let Some(path) = path else {
3489        return false;
3490    };
3491    let Ok(file) = std::fs::File::open(path) else {
3492        return false;
3493    };
3494    let mut limited = file.take(512);
3495    let mut bytes = Vec::new();
3496    if limited.read_to_end(&mut bytes).is_err() {
3497        return false;
3498    }
3499    String::from_utf8_lossy(&bytes)
3500        .chars()
3501        .find(|ch| !ch.is_whitespace())
3502        .is_some_and(|ch| matches!(ch, '{' | '[' | '"' | '-' | '0'..='9' | 't' | 'f' | 'n'))
3503}
3504
3505struct CompletionTokenCounts {
3506    original_tokens: Option<u32>,
3507    compressed_tokens: Option<u32>,
3508    original_bytes: Option<i64>,
3509    compressed_bytes: Option<i64>,
3510    tokens_skipped: bool,
3511}
3512
3513impl CompletionTokenCounts {
3514    fn skipped() -> Self {
3515        Self {
3516            original_tokens: None,
3517            compressed_tokens: None,
3518            original_bytes: None,
3519            compressed_bytes: None,
3520            tokens_skipped: true,
3521        }
3522    }
3523}
3524
3525fn completion_status_text(status: &BgTaskStatus, exit_code: Option<i32>) -> String {
3526    match status {
3527        BgTaskStatus::TimedOut => "timed out".to_string(),
3528        BgTaskStatus::Killed => "killed".to_string(),
3529        _ => exit_code
3530            .map(|code| format!("exit {code}"))
3531            .unwrap_or_else(|| format!("{status:?}").to_lowercase()),
3532    }
3533}
3534
3535fn token_count_u32(text: &str) -> u32 {
3536    aft_tokenizer::count_tokens(text)
3537        .try_into()
3538        .unwrap_or(u32::MAX)
3539}
3540
3541impl Default for BgTaskRegistry {
3542    fn default() -> Self {
3543        Self::new(Arc::new(Mutex::new(None)))
3544    }
3545}
3546
3547fn modified_within(path: &Path, grace: Duration) -> bool {
3548    fs::metadata(path)
3549        .and_then(|metadata| metadata.modified())
3550        .ok()
3551        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
3552        .map(|age| age < grace)
3553        .unwrap_or(false)
3554}
3555
3556fn canonicalized_path(path: &Path) -> PathBuf {
3557    fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
3558}
3559
3560fn started_instant_from_unix_millis(started_at: u64) -> Instant {
3561    let now_ms = SystemTime::now()
3562        .duration_since(UNIX_EPOCH)
3563        .ok()
3564        .map(|duration| duration.as_millis() as u64)
3565        .unwrap_or(started_at);
3566    let elapsed_ms = now_ms.saturating_sub(started_at);
3567    Instant::now()
3568        .checked_sub(Duration::from_millis(elapsed_ms))
3569        .unwrap_or_else(Instant::now)
3570}
3571
3572fn gc_quarantine(storage_dir: &Path) {
3573    let quarantine_root = storage_dir.join("bash-tasks-quarantine");
3574    let Ok(session_dirs) = fs::read_dir(&quarantine_root) else {
3575        return;
3576    };
3577    for session_entry in session_dirs.flatten() {
3578        let session_quarantine_dir = session_entry.path();
3579        if !session_quarantine_dir.is_dir() {
3580            continue;
3581        }
3582        let entries = match fs::read_dir(&session_quarantine_dir) {
3583            Ok(entries) => entries,
3584            Err(error) => {
3585                crate::slog_warn!(
3586                    "failed to read background task quarantine dir {}: {error}",
3587                    session_quarantine_dir.display()
3588                );
3589                continue;
3590            }
3591        };
3592        for entry in entries.flatten() {
3593            let path = entry.path();
3594            if modified_within(&path, QUARANTINE_GC_GRACE) {
3595                continue;
3596            }
3597            let result = if path.is_dir() {
3598                fs::remove_dir_all(&path)
3599            } else {
3600                fs::remove_file(&path)
3601            };
3602            match result {
3603                Ok(()) => log::debug!(
3604                    "deleted old background task quarantine entry {}",
3605                    path.display()
3606                ),
3607                Err(error) => crate::slog_warn!(
3608                    "failed to delete old background task quarantine entry {}: {error}",
3609                    path.display()
3610                ),
3611            }
3612        }
3613        let _ = fs::remove_dir(&session_quarantine_dir);
3614    }
3615    let _ = fs::remove_dir(&quarantine_root);
3616}
3617
3618enum QuarantineKind {
3619    Corrupt,
3620    Invalid,
3621}
3622
3623fn quarantine_task_json(
3624    storage_dir: &Path,
3625    session_dir: &Path,
3626    json_path: &Path,
3627    kind: QuarantineKind,
3628) -> Result<(), String> {
3629    let session_hash = session_dir
3630        .file_name()
3631        .and_then(|name| name.to_str())
3632        .ok_or_else(|| {
3633            format!(
3634                "invalid background task session dir: {}",
3635                session_dir.display()
3636            )
3637        })?;
3638    let task_name = json_path
3639        .file_name()
3640        .and_then(|name| name.to_str())
3641        .ok_or_else(|| format!("invalid background task json path: {}", json_path.display()))?;
3642    let unix_ts = SystemTime::now()
3643        .duration_since(UNIX_EPOCH)
3644        .map(|duration| duration.as_secs())
3645        .unwrap_or(0);
3646    let quarantine_dir = storage_dir.join("bash-tasks-quarantine").join(session_hash);
3647    fs::create_dir_all(&quarantine_dir).map_err(|e| {
3648        format!(
3649            "failed to create background task quarantine dir {}: {e}",
3650            quarantine_dir.display()
3651        )
3652    })?;
3653    let target_name = quarantine_name(task_name, unix_ts, &kind);
3654    let target = quarantine_dir.join(target_name);
3655    fs::rename(json_path, &target).map_err(|e| {
3656        format!(
3657            "failed to quarantine background task metadata {} to {}: {e}",
3658            json_path.display(),
3659            target.display()
3660        )
3661    })?;
3662
3663    for sibling in task_sibling_paths(json_path) {
3664        if !sibling.exists() {
3665            continue;
3666        }
3667        let Some(sibling_name) = sibling.file_name().and_then(|name| name.to_str()) else {
3668            crate::slog_warn!(
3669                "skipping background task sibling with invalid name during quarantine: {}",
3670                sibling.display()
3671            );
3672            continue;
3673        };
3674        let sibling_target = quarantine_dir.join(quarantine_name(sibling_name, unix_ts, &kind));
3675        if let Err(error) = fs::rename(&sibling, &sibling_target) {
3676            crate::slog_warn!(
3677                "failed to quarantine background task sibling {} to {}: {error}",
3678                sibling.display(),
3679                sibling_target.display()
3680            );
3681        }
3682    }
3683
3684    let _ = fs::remove_dir(session_dir);
3685    Ok(())
3686}
3687
3688fn quarantine_name(file_name: &str, unix_ts: u64, kind: &QuarantineKind) -> String {
3689    match kind {
3690        QuarantineKind::Corrupt => format!("{file_name}.corrupt-{unix_ts}"),
3691        QuarantineKind::Invalid => {
3692            let path = Path::new(file_name);
3693            let stem = path.file_stem().and_then(|stem| stem.to_str());
3694            let extension = path.extension().and_then(|extension| extension.to_str());
3695            match (stem, extension) {
3696                (Some(stem), Some(extension)) => format!("{stem}.invalid.{unix_ts}.{extension}"),
3697                _ => format!("{file_name}.invalid.{unix_ts}"),
3698            }
3699        }
3700    }
3701}
3702
3703fn task_sibling_paths(json_path: &Path) -> Vec<PathBuf> {
3704    let Some(parent) = json_path.parent() else {
3705        return Vec::new();
3706    };
3707    let Some(stem) = json_path.file_stem().and_then(|stem| stem.to_str()) else {
3708        return Vec::new();
3709    };
3710    ["stdout", "stderr", "exit", "pty", "ps1", "bat", "sh"]
3711        .into_iter()
3712        .map(|extension| parent.join(format!("{stem}.{extension}")))
3713        .collect()
3714}
3715
3716fn read_for_token_count_from_disk(
3717    metadata: &PersistedTask,
3718    paths: &TaskPaths,
3719    max_bytes_per_stream: usize,
3720) -> TokenCountInput {
3721    if metadata.mode == BgMode::Pty {
3722        return TokenCountInput::Skipped;
3723    }
3724    // Read up to `max_bytes_per_stream` bytes per stream rather than
3725    // refusing to tokenize anything when the file exceeds the cap.
3726    // Mirror the in-memory `BgBuffer::read_for_token_count` policy
3727    // (see comment there) — large outputs are exactly the tasks that
3728    // benefit most from compression accounting, so silent-skipping
3729    // them defeats the purpose of token tracking.
3730    let stdout = read_file_tail_capped(&paths.stdout, max_bytes_per_stream);
3731    let stderr = read_file_tail_capped(&paths.stderr, max_bytes_per_stream);
3732    match (stdout, stderr) {
3733        (Ok(stdout), Ok(stderr)) => TokenCountInput::Text(combine_streams(
3734            String::from_utf8_lossy(&stdout).as_ref(),
3735            String::from_utf8_lossy(&stderr).as_ref(),
3736        )),
3737        (Ok(stdout), Err(_)) => TokenCountInput::Text(combine_streams(
3738            String::from_utf8_lossy(&stdout).as_ref(),
3739            "",
3740        )),
3741        (Err(_), Ok(stderr)) => TokenCountInput::Text(combine_streams(
3742            "",
3743            String::from_utf8_lossy(&stderr).as_ref(),
3744        )),
3745        (Err(_), Err(_)) => TokenCountInput::Skipped,
3746    }
3747}
3748
3749/// Read at most `max_bytes` bytes from the END of `path`. Used for
3750/// tokenization where the most recent output is more representative than
3751/// an arbitrarily-capped beginning. Returns `Err` if the file cannot be
3752/// opened (genuinely missing or permissions error).
3753fn read_file_tail_capped(path: &Path, max_bytes: usize) -> std::io::Result<Vec<u8>> {
3754    use std::io::{Read, Seek, SeekFrom};
3755    let mut file = std::fs::File::open(path)?;
3756    let len = file.metadata()?.len();
3757    let read_len = len.min(max_bytes as u64);
3758    if read_len > 0 && len > max_bytes as u64 {
3759        file.seek(SeekFrom::End(-(read_len as i64)))?;
3760    }
3761    let mut bytes = Vec::with_capacity(read_len as usize);
3762    file.read_to_end(&mut bytes)?;
3763    Ok(bytes)
3764}
3765
3766impl BgTask {
3767    fn snapshot(&self, preview_bytes: usize) -> BgTaskSnapshot {
3768        let state = self
3769            .state
3770            .lock()
3771            .unwrap_or_else(|poison| poison.into_inner());
3772        self.snapshot_locked(&state, preview_bytes)
3773    }
3774
3775    fn snapshot_locked(&self, state: &BgTaskState, preview_bytes: usize) -> BgTaskSnapshot {
3776        let metadata = &state.metadata;
3777        let duration_ms = metadata.duration_ms.or_else(|| {
3778            metadata
3779                .status
3780                .is_terminal()
3781                .then(|| self.started.elapsed().as_millis() as u64)
3782        });
3783        let (output_preview, output_truncated) = if metadata.mode == BgMode::Pty {
3784            (String::new(), false)
3785        } else if metadata.status.is_terminal() {
3786            state
3787                .terminal_output_cache
3788                .as_ref()
3789                .map(|cache| (cache.output_preview.clone(), cache.output_truncated))
3790                .unwrap_or_else(|| (String::new(), false))
3791        } else if preview_bytes == 0 {
3792            (String::new(), false)
3793        } else {
3794            state.buffer.read_tail(preview_bytes)
3795        };
3796        BgTaskSnapshot {
3797            info: BgTaskInfo {
3798                task_id: self.task_id.clone(),
3799                status: metadata.status.clone(),
3800                command: metadata.command.clone(),
3801                mode: metadata.mode.clone(),
3802                started_at: metadata.started_at,
3803                duration_ms,
3804            },
3805            exit_code: metadata.exit_code,
3806            child_pid: metadata.child_pid,
3807            workdir: metadata.workdir.display().to_string(),
3808            output_preview,
3809            output_truncated,
3810            output_path: state
3811                .buffer
3812                .output_path()
3813                .map(|path| path.display().to_string()),
3814            stderr_path: state
3815                .buffer
3816                .stderr_path()
3817                .map(|path| path.display().to_string()),
3818            pty_rows: (metadata.mode == BgMode::Pty).then_some(metadata.pty_rows.unwrap_or(24)),
3819            pty_cols: (metadata.mode == BgMode::Pty).then_some(metadata.pty_cols.unwrap_or(80)),
3820            pty_screen: None,
3821        }
3822    }
3823
3824    pub(crate) fn is_running(&self) -> bool {
3825        self.state
3826            .lock()
3827            .map(|state| {
3828                state.metadata.status == BgTaskStatus::Running
3829                    || (state.metadata.mode == BgMode::Pty
3830                        && state.metadata.status == BgTaskStatus::Killing)
3831            })
3832            .unwrap_or(false)
3833    }
3834
3835    fn is_terminal(&self) -> bool {
3836        self.state
3837            .lock()
3838            .map(|state| state.metadata.status.is_terminal())
3839            .unwrap_or(false)
3840    }
3841
3842    fn mark_terminal_now(&self) {
3843        if let Ok(mut terminal_at) = self.terminal_at.lock() {
3844            if terminal_at.is_none() {
3845                *terminal_at = Some(Instant::now());
3846            }
3847        }
3848    }
3849
3850    fn set_completion_delivered(
3851        &self,
3852        delivered: bool,
3853        registry: &BgTaskRegistry,
3854    ) -> Result<(), String> {
3855        let mut state = self
3856            .state
3857            .lock()
3858            .map_err(|_| "background task lock poisoned".to_string())?;
3859        let updated = registry
3860            .update_task_metadata(&self.paths, |metadata| {
3861                metadata.completion_delivered = delivered;
3862            })
3863            .map_err(|e| format!("failed to update completion delivery: {e}"))?;
3864        state.metadata = updated;
3865        Ok(())
3866    }
3867}
3868
3869/// Reap an exited direct child handle, then clear the slot.
3870///
3871/// Dropping a [`std::process::Child`] does NOT `wait()` on the underlying OS
3872/// process. On Unix a finished-but-unreaped child lingers as a `<defunct>`
3873/// zombie until the AFT process itself exits (issue #91: `[mv] <defunct>`).
3874/// The terminal-transition paths that learn of completion from the
3875/// exit-marker file — rather than from [`BgTaskRegistry::reap_child`]'s
3876/// `try_wait()` — must therefore reap the handle explicitly instead of just
3877/// nulling it.
3878///
3879/// The exit marker is written by the wrapper's final statement (an atomic
3880/// `mv` rename), so by the time we observe the marker the direct child has
3881/// finished its work and is exiting; `wait()` returns essentially
3882/// immediately. We attempt a non-blocking `try_wait()` first so the common
3883/// case never blocks at all, falling back to a (bounded) `wait()` only to
3884/// cover the microsecond window between the rename and process teardown.
3885///
3886/// Callers hold the task state mutex, so this is serialized against
3887/// `reap_child` — there is no double-`wait()` hazard: whichever path acquires
3888/// the lock first reaps and clears the slot, and the other observes `None`.
3889#[cfg(unix)]
3890fn reap_piped_child(child_slot: &mut Option<Child>) {
3891    if let Some(mut child) = child_slot.take() {
3892        if matches!(child.try_wait(), Ok(None)) {
3893            let _ = child.wait();
3894        }
3895    }
3896}
3897
3898/// Windows has no zombie/`<defunct>` concept: dropping the [`Child`] closes
3899/// the process handle, which is the correct release. Preserve the historical
3900/// behavior of simply clearing the slot so the documented Windows PID-recycle
3901/// handling in `reap_child` is unaffected.
3902#[cfg(windows)]
3903fn reap_piped_child(child_slot: &mut Option<Child>) {
3904    *child_slot = None;
3905}
3906
3907fn terminal_metadata_from_marker(
3908    mut metadata: PersistedTask,
3909    marker: ExitMarker,
3910    reason: Option<String>,
3911) -> PersistedTask {
3912    match marker {
3913        ExitMarker::Code(code) => {
3914            let status = if code == 0 {
3915                BgTaskStatus::Completed
3916            } else {
3917                BgTaskStatus::Failed
3918            };
3919            metadata.mark_terminal(status, Some(code), reason);
3920        }
3921        ExitMarker::Killed => metadata.mark_terminal(
3922            BgTaskStatus::Killed,
3923            terminal_exit_code_for_status(&BgTaskStatus::Killed),
3924            reason,
3925        ),
3926    }
3927    metadata
3928}
3929
3930fn terminal_exit_code_for_status(status: &BgTaskStatus) -> Option<i32> {
3931    match status {
3932        BgTaskStatus::TimedOut => Some(124),
3933        BgTaskStatus::Killed => Some(137),
3934        _ => None,
3935    }
3936}
3937
3938#[cfg(unix)]
3939fn write_unix_command_script(command: &str, paths: &TaskPaths) -> Result<PathBuf, String> {
3940    let stem = paths
3941        .json
3942        .file_stem()
3943        .and_then(|stem| stem.to_str())
3944        .unwrap_or("wrapper");
3945    let script_path = paths.dir.join(format!("{stem}.sh"));
3946    fs::write(&script_path, command)
3947        .map_err(|e| format!("failed to write background bash command script: {e}"))?;
3948    Ok(script_path)
3949}
3950
3951#[cfg(unix)]
3952fn detached_shell_command(command_script: &Path, exit_path: &Path) -> Command {
3953    let shell = resolve_posix_shell();
3954    let mut cmd = crate::effective_path::new_command(&shell);
3955    // Keep the user-provided command body out of argv and shell `-c` parsing.
3956    // The direct child is still a tiny wrapper so it can write the authoritative
3957    // exit marker after the command script exits (including if that script calls
3958    // `exit`). Passing only file paths through `-c` avoids newline/quote/length
3959    // edge cases where a multi-command script can be mangled before execution.
3960    cmd.arg("-c")
3961        .arg(r#""$0" "$1"; code=$?; printf "%s" "$code" > "$2.tmp.$$"; mv -f "$2.tmp.$$" "$2""#)
3962        .arg(&shell)
3963        .arg(command_script)
3964        .arg(exit_path);
3965    unsafe {
3966        cmd.pre_exec(|| {
3967            if libc::setsid() == -1 {
3968                return Err(std::io::Error::last_os_error());
3969            }
3970            Ok(())
3971        });
3972    }
3973    cmd
3974}
3975
3976#[cfg(unix)]
3977fn resolve_posix_shell() -> PathBuf {
3978    static POSIX_SHELL: OnceLock<PathBuf> = OnceLock::new();
3979    POSIX_SHELL
3980        .get_or_init(|| {
3981            std::env::var_os("BASH")
3982                .filter(|value| !value.is_empty())
3983                .map(PathBuf::from)
3984                .filter(|path| path.exists())
3985                .or_else(|| which::which("bash").ok())
3986                .or_else(|| which::which("zsh").ok())
3987                .unwrap_or_else(|| PathBuf::from("/bin/sh"))
3988        })
3989        .clone()
3990}
3991
3992#[cfg(windows)]
3993fn detached_shell_command_for(
3994    shell: crate::windows_shell::WindowsShell,
3995    command: &str,
3996    exit_path: &Path,
3997    paths: &TaskPaths,
3998    creation_flags: u32,
3999) -> Result<Command, String> {
4000    use crate::windows_shell::WindowsShell;
4001    // Write the wrapper to a temp file alongside the other task files,
4002    // then invoke the shell with the file path as a single clean
4003    // argument. This sidesteps the entire Windows command-line quoting
4004    // mess (Rust std-lib quoting + cmd /C parser + PowerShell -Command
4005    // parser all interacting with embedded quotes in the wrapper).
4006    //
4007    // Path arguments don't need quoting in the same problematic way
4008    // because: (1) we use no-space task IDs (bash-XXXXXXXX) so the path
4009    // contains no characters that need shell escaping; (2) the wrapper
4010    // body's internal quotes never reach the shell command line — the
4011    // shell reads them from disk by file syntax rules, not command-line
4012    // parser rules.
4013    let wrapper_body = shell.wrapper_script_bytes(command, exit_path);
4014    let wrapper_ext = match shell {
4015        WindowsShell::Pwsh | WindowsShell::Powershell => "ps1",
4016        WindowsShell::Cmd => "bat",
4017        // POSIX shells (git-bash etc.) execute the wrapper through `-c`,
4018        // so the file extension is purely cosmetic; `.sh` matches what an
4019        // operator would expect when grepping the spill directory.
4020        WindowsShell::Posix(_) => "sh",
4021    };
4022    let wrapper_path = paths.dir.join(format!(
4023        "{}.{}",
4024        paths
4025            .json
4026            .file_stem()
4027            .and_then(|s| s.to_str())
4028            .unwrap_or("wrapper"),
4029        wrapper_ext
4030    ));
4031    fs::write(&wrapper_path, wrapper_body)
4032        .map_err(|e| format!("failed to write background bash wrapper script: {e}"))?;
4033
4034    let mut cmd = Command::new(shell.binary().as_ref());
4035    match shell {
4036        WindowsShell::Pwsh | WindowsShell::Powershell => {
4037            // -File runs the script with no quoting issues. `-NoLogo`,
4038            // `-NoProfile`, etc. apply to the host before the file runs.
4039            cmd.args([
4040                "-NoLogo",
4041                "-NoProfile",
4042                "-NonInteractive",
4043                "-ExecutionPolicy",
4044                "Bypass",
4045                "-File",
4046            ]);
4047            cmd.arg(&wrapper_path);
4048        }
4049        WindowsShell::Cmd => {
4050            // `cmd /D /C "<bat-file-path>"` — invoking a .bat
4051            // file via /C is well-defined; the file's contents are
4052            // read line-by-line by cmd's batch processor, NOT
4053            // re-interpreted by the /C parser. This avoids the
4054            // "filename syntax incorrect" errors that came from
4055            // having complex compound commands on the cmd line.
4056            cmd.args(["/D", "/C"]);
4057            cmd.arg(&wrapper_path);
4058        }
4059        WindowsShell::Posix(_) => {
4060            // git-bash and other POSIX shells run the wrapper script with
4061            // `<binary> <wrapper-path>` (the wrapper is just a shell
4062            // script). No special flags needed — the `trap` and atomic
4063            // exit-marker rename in `wrapper_script` are POSIX-standard.
4064            cmd.arg(&wrapper_path);
4065        }
4066    }
4067
4068    // Win32 process creation flags. Caller selects whether to include
4069    // CREATE_BREAKAWAY_FROM_JOB — see `detached_shell_command_for` callers
4070    // for the breakaway-fallback strategy.
4071    cmd.creation_flags(creation_flags);
4072    Ok(cmd)
4073}
4074
4075/// Spawn a detached background bash child process.
4076///
4077/// On Unix this is a single spawn against `/bin/sh`. On Windows it walks
4078/// `WindowsShell::shell_candidates()` (pwsh.exe → powershell.exe →
4079/// cmd.exe) and retries with the next candidate when the previous one
4080/// fails to spawn with `NotFound` — the same runtime safety net the
4081/// foreground bash path has, so issue #27 callers landing on cmd.exe
4082/// fallback can also use background bash. The wrapper script is
4083/// regenerated per attempt because PowerShell wrappers embed the shell
4084/// binary by name; the stdout/stderr capture handles are also reopened
4085/// per attempt because `Command::spawn()` consumes them.
4086///
4087/// Errors other than `NotFound` (PermissionDenied, OutOfMemory, etc.)
4088/// return immediately without retry — they indicate a problem with the
4089/// resolved shell that retrying with a different shell won't fix.
4090fn spawn_detached_child(
4091    command: &str,
4092    paths: &TaskPaths,
4093    workdir: &Path,
4094    env: &HashMap<String, String>,
4095) -> Result<std::process::Child, String> {
4096    #[cfg(not(windows))]
4097    {
4098        let stdout = create_capture_file(&paths.stdout)
4099            .map_err(|e| format!("failed to open stdout capture file: {e}"))?;
4100        let stderr = create_capture_file(&paths.stderr)
4101            .map_err(|e| format!("failed to open stderr capture file: {e}"))?;
4102        let command_script = write_unix_command_script(command, paths)?;
4103        detached_shell_command(&command_script, &paths.exit)
4104            .current_dir(workdir)
4105            .envs(env)
4106            .stdin(Stdio::null())
4107            .stdout(Stdio::from(stdout))
4108            .stderr(Stdio::from(stderr))
4109            .spawn()
4110            .map_err(|e| format!("failed to spawn background bash command: {e}"))
4111    }
4112    #[cfg(windows)]
4113    {
4114        use crate::windows_shell::shell_candidates;
4115        // Spawn priority: pwsh → powershell → git-bash → cmd. Same as the
4116        // legacy foreground bash spawn path. v0.20 routes ALL bash through
4117        // this background spawn helper, including foreground tool calls
4118        // where the model writes PowerShell-syntax (`$var = ...`,
4119        // `Start-Sleep`, `Add-Content`) — those fail outright under cmd.
4120        // The earlier v0.18-era cmd-first override worked around a
4121        // PowerShell detached-output bug; that bug is fixed at the
4122        // process-flag layer (CREATE_NO_WINDOW instead of DETACHED_PROCESS,
4123        // see flag block below), so we no longer need to misroute PS
4124        // commands through cmd.
4125        let candidates: Vec<crate::windows_shell::WindowsShell> = shell_candidates();
4126        // Win32 process creation flags. We try with CREATE_BREAKAWAY_FROM_JOB
4127        // first (so the bg child outlives the AFT process when AFT is killed),
4128        // then fall back without it for environments where the parent is in a
4129        // Job Object that doesn't grant `JOB_OBJECT_LIMIT_BREAKAWAY_OK`. CI
4130        // runners (GitHub Actions windows-2022) and some MDM-managed corp
4131        // environments hit this — `CreateProcess` returns Access Denied (5).
4132        // Without breakaway, the child still runs detached but will be torn
4133        // down with the parent if the parent process group is signaled.
4134        //
4135        // We use CREATE_NO_WINDOW (no visible console window, but the
4136        // child still has a hidden console) rather than DETACHED_PROCESS
4137        // (no console at all). PowerShell-based wrappers that perform
4138        // file I/O via [System.IO.File] need a console handle to flush
4139        // stdout/stderr correctly even when redirected — under
4140        // DETACHED_PROCESS, pwsh sometimes silently exits before
4141        // executing later script statements (the Move-Item that writes
4142        // the exit marker never runs), leaving the bg task forever
4143        // marked Failed: process exited without exit marker. cmd.exe
4144        // wrappers tolerate DETACHED_PROCESS, but switching to
4145        // CREATE_NO_WINDOW costs nothing for cmd and unblocks pwsh.
4146        const FLAG_CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
4147        const FLAG_CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
4148        const FLAG_CREATE_NO_WINDOW: u32 = 0x0800_0000;
4149        let with_breakaway =
4150            FLAG_CREATE_NO_WINDOW | FLAG_CREATE_NEW_PROCESS_GROUP | FLAG_CREATE_BREAKAWAY_FROM_JOB;
4151        let without_breakaway = FLAG_CREATE_NO_WINDOW | FLAG_CREATE_NEW_PROCESS_GROUP;
4152        let mut last_error: Option<String> = None;
4153        for (idx, shell) in candidates.iter().enumerate() {
4154            // Per-shell, try with breakaway first. If the process is in a
4155            // restrictive job, the breakaway flag triggers Access Denied
4156            // (os error 5). Retry once without breakaway.
4157            for &flags in &[with_breakaway, without_breakaway] {
4158                // Re-open capture handles per attempt; spawn() consumes them.
4159                let stdout = create_capture_file(&paths.stdout)
4160                    .map_err(|e| format!("failed to open stdout capture file: {e}"))?;
4161                let stderr = create_capture_file(&paths.stderr)
4162                    .map_err(|e| format!("failed to open stderr capture file: {e}"))?;
4163                let mut cmd =
4164                    detached_shell_command_for(shell.clone(), command, &paths.exit, paths, flags)?;
4165                cmd.current_dir(workdir)
4166                    .envs(env)
4167                    .stdin(Stdio::null())
4168                    .stdout(Stdio::from(stdout))
4169                    .stderr(Stdio::from(stderr));
4170                match cmd.spawn() {
4171                    Ok(child) => {
4172                        if idx > 0 {
4173                            crate::slog_warn!("background bash spawn fell back to {} after {} earlier candidate(s) failed; \
4174                             the cached PATH probe disagreed with runtime spawn — likely PATH \
4175                             inheritance, antivirus / AppLocker / Defender ASR, or sandbox policy.",
4176                            shell.binary(),
4177                            idx);
4178                        }
4179                        if flags == without_breakaway {
4180                            crate::slog_warn!(
4181                                "background bash spawn: CREATE_BREAKAWAY_FROM_JOB rejected \
4182                             (likely a restrictive Job Object — CI sandbox or MDM policy). \
4183                             Spawned without breakaway; the bg task will be torn down if the \
4184                             AFT process group is killed."
4185                            );
4186                        }
4187                        return Ok(child);
4188                    }
4189                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4190                        crate::slog_warn!("background bash spawn: {} returned NotFound at runtime — trying next candidate",
4191                        shell.binary());
4192                        last_error = Some(format!("{}: {e}", shell.binary()));
4193                        // Skip the without-breakaway retry for NotFound — the
4194                        // binary itself is missing, breakaway flag is irrelevant.
4195                        break;
4196                    }
4197                    Err(e) if flags == with_breakaway && e.raw_os_error() == Some(5) => {
4198                        // Access Denied during breakaway — retry without it.
4199                        crate::slog_warn!(
4200                            "background bash spawn: CREATE_BREAKAWAY_FROM_JOB rejected with \
4201                         Access Denied — retrying {} without breakaway",
4202                            shell.binary()
4203                        );
4204                        last_error = Some(format!("{}: {e}", shell.binary()));
4205                        continue;
4206                    }
4207                    Err(e) => {
4208                        return Err(format!(
4209                            "failed to spawn background bash command via {}: {e}",
4210                            shell.binary()
4211                        ));
4212                    }
4213                }
4214            }
4215        }
4216        Err(format!(
4217            "failed to spawn background bash command: no Windows shell could be spawned. \
4218             Last error: {}. PATH-probed candidates: {:?}",
4219            last_error.unwrap_or_else(|| "no candidates were attempted".to_string()),
4220            candidates.iter().map(|s| s.binary()).collect::<Vec<_>>()
4221        ))
4222    }
4223}
4224
4225fn random_slug() -> String {
4226    // 8 bytes = 64-bit entropy → `bash-{16hex}`, matching the documented contract
4227    // at `generate_unique_task_id`. The width is load-bearing for the subc
4228    // delivery dedup: a plugin can retain a delivered task id awaiting ack that
4229    // Rust has already dropped (a lost ack response), and Rust's uniqueness check
4230    // cannot see that plugin-side set — so id reuse must be made negligible by
4231    // entropy alone. 32-bit was reusable within a long session and could let a new
4232    // task collide with such a stale id and be silently skipped (audit R3 #3).
4233    let mut bytes = [0u8; 8];
4234    // getrandom is a transitive dependency; use it directly for OS entropy.
4235    getrandom::fill(&mut bytes).unwrap_or_else(|_| {
4236        // Extremely unlikely fallback: time + pid mix across all 8 bytes.
4237        let t = SystemTime::now()
4238            .duration_since(UNIX_EPOCH)
4239            .map(|d| d.as_nanos() as u64)
4240            .unwrap_or(0);
4241        let p = u64::from(std::process::id());
4242        bytes.copy_from_slice(&(t ^ p.rotate_left(32)).to_le_bytes());
4243    });
4244    // `bash-` + 16 lowercase hex chars — compact, OS-entropy backed.
4245    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
4246    format!("bash-{hex}")
4247}
4248
4249#[cfg(test)]
4250mod tests {
4251    use std::collections::HashMap;
4252    use std::fs;
4253    use std::sync::atomic::{AtomicBool, AtomicUsize};
4254    use std::sync::{Arc, Mutex};
4255    use std::time::Duration;
4256    #[cfg(windows)]
4257    use std::time::Instant;
4258
4259    use super::*;
4260
4261    #[cfg(unix)]
4262    const QUICK_SUCCESS_COMMAND: &str = "true";
4263    #[cfg(windows)]
4264    const QUICK_SUCCESS_COMMAND: &str = "cmd /c exit 0";
4265
4266    #[cfg(unix)]
4267    const LONG_RUNNING_COMMAND: &str = "sleep 5";
4268    #[cfg(windows)]
4269    const LONG_RUNNING_COMMAND: &str = "cmd /c timeout /t 5 /nobreak > nul";
4270
4271    #[test]
4272    fn bash_memory_estimate_is_zero_when_empty_and_nonzero_for_completion_cache() {
4273        let registry = BgTaskRegistry::default();
4274        assert_eq!(registry.estimated_memory().estimated_bytes, Some(0));
4275        registry
4276            .inner
4277            .completions
4278            .lock()
4279            .unwrap()
4280            .push_back(BgCompletion {
4281                task_id: "bash-memory".to_string(),
4282                session_id: "session-memory".to_string(),
4283                status: BgTaskStatus::Completed,
4284                exit_code: Some(0),
4285                command: "printf memory".to_string(),
4286                output_preview: "resident completion output".to_string(),
4287                output_truncated: false,
4288                original_tokens: None,
4289                compressed_tokens: None,
4290                tokens_skipped: false,
4291            });
4292        let estimate = registry.estimated_memory();
4293        assert!(estimate.estimated_bytes.unwrap() > 0);
4294        assert_eq!(estimate.counts["completion_caches"], 1);
4295        assert_eq!(estimate.counts["sessions"], 1);
4296    }
4297
4298    #[test]
4299    fn gh_structured_detection_rejects_piped_commands() {
4300        assert!(is_gh_structured_command(
4301            "gh issue list --json number,title"
4302        ));
4303        assert!(is_gh_structured_command(
4304            "cd repo && gh issue list --json number,title"
4305        ));
4306
4307        assert!(!is_gh_structured_command(
4308            "gh issue list --json number,title | jq '.[]'"
4309        ));
4310        assert!(!is_gh_structured_command(
4311            "gh issue list --json number,title |"
4312        ));
4313    }
4314
4315    fn insert_terminal_piped_task(
4316        registry: &BgTaskRegistry,
4317        dir: &tempfile::TempDir,
4318        command: &str,
4319        stdout: &str,
4320        stderr: &str,
4321        compressed: bool,
4322    ) -> (String, Arc<BgTask>) {
4323        let task_id = format!("bash-test-{}", random_slug());
4324        let paths = task_paths(dir.path(), "session", &task_id);
4325        fs::create_dir_all(&paths.dir).unwrap();
4326        fs::write(&paths.stdout, stdout).unwrap();
4327        fs::write(&paths.stderr, stderr).unwrap();
4328        let mut metadata = PersistedTask::starting(
4329            task_id.clone(),
4330            "session".to_string(),
4331            command.to_string(),
4332            dir.path().to_path_buf(),
4333            Some(dir.path().to_path_buf()),
4334            Some(30_000),
4335            true,
4336            compressed,
4337        );
4338        metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4339        write_task(&paths.json, &metadata).unwrap();
4340        registry
4341            .insert_rehydrated_task(metadata, paths, true)
4342            .expect("insert terminal task");
4343        let task = registry.task_for_session(&task_id, "session").unwrap();
4344        (task_id, task)
4345    }
4346
4347    #[test]
4348    fn bash_zero_preview_running_status_skips_output_read_while_explicit_preview_reads() {
4349        let registry = BgTaskRegistry::default();
4350        let dir = tempfile::tempdir().unwrap();
4351        let task_id = format!("bash-test-{}", random_slug());
4352        let paths = task_paths(dir.path(), "session", &task_id);
4353        fs::create_dir_all(&paths.dir).unwrap();
4354        fs::write(&paths.stdout, "live output\n").unwrap();
4355        fs::write(&paths.stderr, "").unwrap();
4356        let stdout_path = paths.stdout.clone();
4357        let mut metadata = PersistedTask::starting(
4358            task_id.clone(),
4359            "session".to_string(),
4360            "sleep 60".to_string(),
4361            dir.path().to_path_buf(),
4362            Some(dir.path().to_path_buf()),
4363            Some(30_000),
4364            true,
4365            false,
4366        );
4367        metadata.status = BgTaskStatus::Running;
4368        write_task(&paths.json, &metadata).unwrap();
4369        registry
4370            .insert_rehydrated_task(metadata, paths, false)
4371            .expect("insert running task");
4372
4373        crate::bash_background::buffer::reset_tail_read_count(&stdout_path);
4374        for _ in 0..5 {
4375            let snapshot = registry
4376                .status(&task_id, "session", Some(dir.path()), Some(dir.path()), 0)
4377                .expect("running snapshot");
4378            assert_eq!(snapshot.info.status, BgTaskStatus::Running);
4379            assert!(snapshot.output_preview.is_empty());
4380        }
4381        assert_eq!(
4382            crate::bash_background::buffer::tail_read_count(&stdout_path),
4383            0
4384        );
4385
4386        let snapshot = registry
4387            .status(
4388                &task_id,
4389                "session",
4390                Some(dir.path()),
4391                Some(dir.path()),
4392                RUNNING_OUTPUT_PREVIEW_BYTES,
4393            )
4394            .expect("explicit running snapshot");
4395        assert_eq!(snapshot.output_preview, "live output\n");
4396        assert_eq!(
4397            crate::bash_background::buffer::tail_read_count(&stdout_path),
4398            1
4399        );
4400    }
4401
4402    #[test]
4403    fn artifact_read_capability_requires_exact_canonical_path_and_session() {
4404        let registry = BgTaskRegistry::default();
4405        let dir = tempfile::tempdir().unwrap();
4406        let (_task_id, task) = insert_terminal_piped_task(
4407            &registry,
4408            &dir,
4409            "printf output",
4410            "stdout\n",
4411            "stderr\n",
4412            true,
4413        );
4414        fs::write(&task.paths.exit, "0\n").unwrap();
4415
4416        assert!(registry.is_session_owned_artifact_path("session", &task.paths.stdout));
4417        assert!(registry.is_session_owned_artifact_path("session", &task.paths.stderr));
4418        assert!(registry.is_session_owned_artifact_path("session", &task.paths.exit));
4419        assert!(!registry.is_session_owned_artifact_path("different-session", &task.paths.stdout));
4420        assert!(!registry.is_session_owned_artifact_path("session", &task.paths.json));
4421
4422        let unregistered = task.paths.dir.join("unregistered-output");
4423        fs::write(&unregistered, "not a task artifact\n").unwrap();
4424        assert!(!registry.is_session_owned_artifact_path("session", &unregistered));
4425    }
4426
4427    #[cfg(unix)]
4428    #[test]
4429    fn artifact_directory_symlink_does_not_create_a_prefix_exception() {
4430        let registry = BgTaskRegistry::default();
4431        let dir = tempfile::tempdir().unwrap();
4432        let project = dir.path().join("project");
4433        fs::create_dir_all(&project).unwrap();
4434        let (_task_id, task) =
4435            insert_terminal_piped_task(&registry, &dir, "printf output", "stdout\n", "", true);
4436        let link = project.join("task-artifacts");
4437        std::os::unix::fs::symlink(&task.paths.dir, &link).unwrap();
4438        let unregistered = task.paths.dir.join("unregistered-output");
4439        fs::write(&unregistered, "not registered\n").unwrap();
4440
4441        assert!(!registry.is_session_owned_artifact_path("session", &link));
4442        assert!(
4443            !registry.is_session_owned_artifact_path("session", &link.join("unregistered-output"))
4444        );
4445        assert!(registry.is_session_owned_artifact_path(
4446            "session",
4447            &link.join(task.paths.stdout.file_name().unwrap())
4448        ));
4449
4450        let outside = dir.path().join("outside-secret");
4451        fs::write(&outside, "must stay private\n").unwrap();
4452        fs::remove_file(&task.paths.stdout).unwrap();
4453        std::os::unix::fs::symlink(&outside, &task.paths.stdout).unwrap();
4454        assert!(!registry.is_session_owned_artifact_path("session", &task.paths.stdout));
4455    }
4456
4457    #[test]
4458    fn recovery_footer_uses_bash_status_when_artifact_is_not_registered() {
4459        let registry = BgTaskRegistry::default();
4460        let dir = tempfile::tempdir().unwrap();
4461        let task_id = "bash-unregistered-footer";
4462        let paths = task_paths(dir.path(), "session", task_id);
4463        fs::create_dir_all(&paths.dir).unwrap();
4464        fs::write(
4465            &paths.stdout,
4466            format!("{}tail\n", "output-line\n".repeat(2_000)),
4467        )
4468        .unwrap();
4469        fs::write(&paths.stderr, "").unwrap();
4470        let mut metadata = PersistedTask::starting(
4471            task_id.to_string(),
4472            "session".to_string(),
4473            "printf output".to_string(),
4474            dir.path().to_path_buf(),
4475            Some(dir.path().to_path_buf()),
4476            Some(30_000),
4477            true,
4478            true,
4479        );
4480        metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4481
4482        let cache = registry
4483            .render_terminal_output_from_paths(&metadata, &paths)
4484            .expect("terminal render");
4485
4486        assert!(cache
4487            .output_preview
4488            .contains("use bash_status({taskId: \"bash-unregistered-footer\"})"));
4489        assert!(!cache.output_preview.contains("full output: read "));
4490    }
4491
4492    fn insert_terminal_pty_task(
4493        registry: &BgTaskRegistry,
4494        dir: &tempfile::TempDir,
4495        pty_output: &str,
4496    ) -> (String, Arc<BgTask>) {
4497        let task_id = format!("bash-test-{}", random_slug());
4498        let paths = task_paths(dir.path(), "session", &task_id);
4499        fs::create_dir_all(&paths.dir).unwrap();
4500        fs::write(&paths.pty, pty_output).unwrap();
4501        let mut metadata = PersistedTask::starting(
4502            task_id.clone(),
4503            "session".to_string(),
4504            "python".to_string(),
4505            dir.path().to_path_buf(),
4506            Some(dir.path().to_path_buf()),
4507            Some(30_000),
4508            true,
4509            true,
4510        );
4511        metadata.mode = BgMode::Pty;
4512        metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4513        write_task(&paths.json, &metadata).unwrap();
4514        registry
4515            .insert_rehydrated_task(metadata, paths, true)
4516            .expect("insert terminal pty task");
4517        let task = registry.task_for_session(&task_id, "session").unwrap();
4518        (task_id, task)
4519    }
4520
4521    #[cfg(unix)]
4522    fn wait_for_terminal_snapshot(
4523        registry: &BgTaskRegistry,
4524        task_id: &str,
4525        session_id: &str,
4526        project: &Path,
4527        storage: &Path,
4528    ) -> BgTaskSnapshot {
4529        let started = Instant::now();
4530        loop {
4531            let snapshot = registry
4532                .status(task_id, session_id, Some(project), Some(storage), 4096)
4533                .expect("spawned task should be visible to status");
4534            if snapshot.info.status.is_terminal() {
4535                return snapshot;
4536            }
4537            assert!(
4538                started.elapsed() < Duration::from_secs(10),
4539                "timed out waiting for task {task_id} to finish; last status={:?}",
4540                snapshot.info.status
4541            );
4542            std::thread::sleep(Duration::from_millis(50));
4543        }
4544    }
4545
4546    fn write_running_project_task(storage: &Path, project: &Path, session: &str, task_id: &str) {
4547        let paths = task_paths(storage, session, task_id);
4548        let mut metadata = PersistedTask::starting(
4549            task_id.to_string(),
4550            session.to_string(),
4551            "sleep 60".to_string(),
4552            project.to_path_buf(),
4553            Some(project.to_path_buf()),
4554            Some(30_000),
4555            true,
4556            true,
4557        );
4558        metadata.status = BgTaskStatus::Running;
4559        write_task(&paths.json, &metadata).unwrap();
4560        fs::write(&paths.stdout, "still running\n").unwrap();
4561        fs::write(&paths.stderr, "").unwrap();
4562    }
4563
4564    #[test]
4565    fn status_replay_filters_same_session_by_project_root() {
4566        let project_a = tempfile::tempdir().unwrap();
4567        let project_b = tempfile::tempdir().unwrap();
4568        let storage = tempfile::tempdir().unwrap();
4569        let session = "shared-session";
4570        let task_id = "bash-project-a";
4571        write_running_project_task(storage.path(), project_a.path(), session, task_id);
4572
4573        let actor_b = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4574        assert!(actor_b
4575            .status(
4576                task_id,
4577                session,
4578                Some(project_b.path()),
4579                Some(storage.path()),
4580                1024,
4581            )
4582            .is_none());
4583        assert!(actor_b.task_for_session(task_id, session).is_none());
4584
4585        let actor_a = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4586        let snapshot = actor_a
4587            .status(
4588                task_id,
4589                session,
4590                Some(project_a.path()),
4591                Some(storage.path()),
4592                1024,
4593            )
4594            .expect("owning project should replay its task");
4595        assert_eq!(snapshot.info.status, BgTaskStatus::Running);
4596    }
4597
4598    #[cfg(unix)]
4599    #[test]
4600    fn multiline_pipeline_stdout_persists_all_lines_after_terminal_status() {
4601        let cases = [
4602            (
4603                "long-first",
4604                "sleep 0.5; printf 'one\\n' | cat\nprintf 'two\\n' | grep -c two\nprintf 'three\\n' | cat",
4605                vec!["one", "1", "three"],
4606            ),
4607            (
4608                "short-first",
4609                "printf 'one\\n' | cat\nsleep 0.2; printf 'two\\n' | grep -c two\nprintf 'three\\n' | cat",
4610                vec!["one", "1", "three"],
4611            ),
4612            (
4613                "failing-middle",
4614                "sleep 0.2; printf 'one\\n' | cat\nfalse; printf 'after-false\\n' | cat\nprintf 'three\\n' | cat",
4615                vec!["one", "after-false", "three"],
4616            ),
4617        ];
4618
4619        for (name, command, expected_lines) in cases {
4620            let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4621            let dir = tempfile::tempdir().unwrap();
4622            let session_id = format!("session-{name}");
4623            let task_id = registry
4624                .spawn(
4625                    command,
4626                    session_id.clone(),
4627                    dir.path().to_path_buf(),
4628                    HashMap::new(),
4629                    Some(Duration::from_secs(30)),
4630                    dir.path().to_path_buf(),
4631                    10,
4632                    true,
4633                    true,
4634                    Some(dir.path().to_path_buf()),
4635                )
4636                .unwrap();
4637
4638            let snapshot = wait_for_terminal_snapshot(
4639                &registry,
4640                &task_id,
4641                &session_id,
4642                dir.path(),
4643                dir.path(),
4644            );
4645            assert_eq!(
4646                snapshot.info.status,
4647                BgTaskStatus::Completed,
4648                "{name}: task should complete; snapshot={snapshot:?}"
4649            );
4650            assert_eq!(
4651                snapshot.exit_code,
4652                Some(0),
4653                "{name}: script should use the final command's exit code"
4654            );
4655
4656            let stdout_path = task_paths(dir.path(), &session_id, &task_id).stdout;
4657            let stdout = fs::read_to_string(&stdout_path).unwrap_or_else(|error| {
4658                panic!(
4659                    "{name}: failed to read raw stdout file {}: {error}",
4660                    stdout_path.display()
4661                )
4662            });
4663            let lines: Vec<&str> = stdout.lines().collect();
4664            assert_eq!(
4665                lines,
4666                expected_lines,
4667                "{name}: raw stdout file must include every newline-separated command's output; stdout_path={}",
4668                stdout_path.display()
4669            );
4670        }
4671    }
4672
4673    #[test]
4674    fn recognizes_all_recovery_marker_forms() {
4675        assert!(is_recovery_marker(
4676            "[truncated output; full output: read \"/tmp/out\"]"
4677        ));
4678        assert!(is_recovery_marker(
4679            "[omitted output; see remaining: tail -n +42 \"/tmp/out\"]"
4680        ));
4681        assert!(is_recovery_marker(
4682            "[truncated output; full output unavailable]"
4683        ));
4684        assert!(is_recovery_marker(
4685            r#"[truncated 123 bytes from saved output prefix; retained output: read "/tmp/out"]"#
4686        ));
4687    }
4688
4689    #[test]
4690    fn recovery_marker_reports_disk_prefix_truncation_as_retained_output() {
4691        let recovery = RecoveryContext {
4692            dropped_by_class: BTreeMap::new(),
4693            had_inner_drop: false,
4694            offset_hint_eligible: false,
4695            offset_start_line: None,
4696            byte_truncated: false,
4697            disk_truncated_prefix_bytes: 4096,
4698            output_path: Some("/tmp/stdout".to_string()),
4699            stderr_path: None,
4700            include_stderr_path: false,
4701            artifact_access: ArtifactRecoveryAccess {
4702                task_id: "bash-test".to_string(),
4703                readable: true,
4704            },
4705        };
4706
4707        let marker = recovery_marker(&recovery).expect("disk truncation must emit marker");
4708
4709        assert!(marker.contains("truncated 4096 bytes from saved output prefix"));
4710        assert!(marker.contains(r#"retained output: read "/tmp/stdout""#));
4711        assert!(!marker.contains("full output: read"));
4712    }
4713
4714    #[test]
4715    fn killed_exit_marker_sets_nonzero_sentinel_exit_code() {
4716        let metadata = PersistedTask::starting(
4717            "task".to_string(),
4718            "session".to_string(),
4719            "cargo test".to_string(),
4720            PathBuf::from("/tmp"),
4721            None,
4722            None,
4723            true,
4724            true,
4725        );
4726
4727        let terminal = terminal_metadata_from_marker(metadata, ExitMarker::Killed, None);
4728
4729        assert_eq!(terminal.status, BgTaskStatus::Killed);
4730        assert_eq!(terminal.exit_code, Some(137));
4731    }
4732
4733    #[test]
4734    fn terminal_status_polls_use_cached_render_once_and_off_lock() {
4735        let registry = BgTaskRegistry::default();
4736        let dir = tempfile::tempdir().unwrap();
4737        let (_task_id, task) = insert_terminal_piped_task(
4738            &registry,
4739            &dir,
4740            "custom-tool --verbose",
4741            &"stdout line\n".repeat(200_000),
4742            "",
4743            true,
4744        );
4745        let calls = Arc::new(AtomicUsize::new(0));
4746        let saw_unlocked_state = Arc::new(AtomicBool::new(false));
4747        let task_holder = Arc::new(Mutex::new(Some(Arc::clone(&task))));
4748        let calls_for_closure = Arc::clone(&calls);
4749        let unlocked_for_closure = Arc::clone(&saw_unlocked_state);
4750        let task_for_closure = Arc::clone(&task_holder);
4751        registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
4752            calls_for_closure.fetch_add(1, Ordering::SeqCst);
4753            if let Some(task) = task_for_closure.lock().unwrap().as_ref() {
4754                if task.state.try_lock().is_ok() {
4755                    unlocked_for_closure.store(true, Ordering::SeqCst);
4756                }
4757            }
4758            CompressionResult::new(format!("compressed {} bytes", output.len()))
4759        });
4760
4761        let first = registry
4762            .status(
4763                &task.task_id,
4764                "session",
4765                None,
4766                Some(dir.path()),
4767                RUNNING_OUTPUT_PREVIEW_BYTES,
4768            )
4769            .unwrap();
4770        let second = registry
4771            .status(
4772                &task.task_id,
4773                "session",
4774                None,
4775                Some(dir.path()),
4776                RUNNING_OUTPUT_PREVIEW_BYTES,
4777            )
4778            .unwrap();
4779        let listed = registry.list(RUNNING_OUTPUT_PREVIEW_BYTES);
4780
4781        assert_eq!(
4782            calls.load(Ordering::SeqCst),
4783            1,
4784            "terminal render must be cached"
4785        );
4786        assert!(
4787            saw_unlocked_state.load(Ordering::SeqCst),
4788            "compressor must run after releasing the task state lock"
4789        );
4790        assert!(first.output_preview.starts_with("compressed "));
4791        assert_eq!(second.output_preview, first.output_preview);
4792        assert_eq!(listed[0].output_preview, first.output_preview);
4793    }
4794
4795    #[test]
4796    fn completion_preview_success_keeps_tail_only() {
4797        // Exit-aware completion previews: a SUCCESSFUL task's reminder keeps a
4798        // short tail only — head context is noise when the command worked
4799        // (regression: the uniform 4 KiB head+tail cap flooded reminders with
4800        // ~1K tokens of build noise per completed task).
4801        let registry = BgTaskRegistry::default();
4802        let dir = tempfile::tempdir().unwrap();
4803        let output = format!("HEAD-SIGNAL\n{}TAIL-SIGNAL\n", "middle\n".repeat(2_000));
4804        let (_task_id, task) =
4805            insert_terminal_piped_task(&registry, &dir, "cat big.log", &output, "", false);
4806
4807        registry.post_terminal_transition(&task, true).unwrap();
4808        let completions = registry.drain_completions_for_session(Some("session"));
4809        assert_eq!(completions.len(), 1);
4810        let preview = &completions[0].output_preview;
4811        assert!(preview.contains("TAIL-SIGNAL"), "preview was {preview:?}");
4812        assert!(!preview.contains("HEAD-SIGNAL"), "preview was {preview:?}");
4813        assert!(completions[0].output_truncated);
4814    }
4815
4816    #[test]
4817    fn completion_preview_failure_keeps_head_and_tail() {
4818        // A FAILED task keeps a small head (first error / command banner) plus
4819        // a larger tail (tracebacks and summaries land at the end).
4820        let registry = BgTaskRegistry::default();
4821        let dir = tempfile::tempdir().unwrap();
4822        let output = format!("HEAD-SIGNAL\n{}TAIL-SIGNAL\n", "middle\n".repeat(2_000));
4823        let task_id = format!("bash-test-{}", random_slug());
4824        let paths = task_paths(dir.path(), "session", &task_id);
4825        fs::create_dir_all(&paths.dir).unwrap();
4826        fs::write(&paths.stdout, &output).unwrap();
4827        fs::write(&paths.stderr, "").unwrap();
4828        let mut metadata = PersistedTask::starting(
4829            task_id.clone(),
4830            "session".to_string(),
4831            "cat big.log".to_string(),
4832            dir.path().to_path_buf(),
4833            Some(dir.path().to_path_buf()),
4834            Some(30_000),
4835            true,
4836            false,
4837        );
4838        metadata.mark_terminal(BgTaskStatus::Failed, Some(1), None);
4839        write_task(&paths.json, &metadata).unwrap();
4840        registry
4841            .insert_rehydrated_task(metadata, paths, true)
4842            .expect("insert terminal task");
4843        let task = registry.task_for_session(&task_id, "session").unwrap();
4844
4845        registry.post_terminal_transition(&task, true).unwrap();
4846        let completions = registry.drain_completions_for_session(Some("session"));
4847        assert_eq!(completions.len(), 1);
4848        let preview = &completions[0].output_preview;
4849        assert!(preview.contains("HEAD-SIGNAL"), "preview was {preview:?}");
4850        assert!(preview.contains("TAIL-SIGNAL"), "preview was {preview:?}");
4851    }
4852
4853    #[test]
4854    fn has_completions_for_session_matches_pending_delivery() {
4855        let registry = BgTaskRegistry::default();
4856        assert!(!registry.has_completions_for_session(Some("session")));
4857        assert!(!registry.has_completions_for_session(None));
4858
4859        let dir = tempfile::tempdir().unwrap();
4860        let (_task_id, task) =
4861            insert_terminal_piped_task(&registry, &dir, QUICK_SUCCESS_COMMAND, "done\n", "", false);
4862        registry.post_terminal_transition(&task, true).unwrap();
4863
4864        assert!(registry.has_completions_for_session(Some("session")));
4865        assert!(registry.has_completions_for_session(None));
4866        assert!(!registry.has_completions_for_session(Some("other-session")));
4867
4868        let completions = registry.drain_completions_for_session(Some("session"));
4869        assert_eq!(completions.len(), 1);
4870        assert_eq!(completions[0].task_id, task.task_id);
4871    }
4872
4873    #[test]
4874    fn structured_gh_json_survives_intact_and_ignores_stderr() {
4875        let registry = BgTaskRegistry::default();
4876        let dir = tempfile::tempdir().unwrap();
4877        let calls = Arc::new(AtomicUsize::new(0));
4878        let calls_for_closure = Arc::clone(&calls);
4879        registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
4880            calls_for_closure.fetch_add(1, Ordering::SeqCst);
4881            CompressionResult::new(output)
4882        });
4883        let (task_id, _task) = insert_terminal_piped_task(
4884            &registry,
4885            &dir,
4886            "gh pr view 123 --json body",
4887            "{\"body\":\"hello\"}",
4888            "warning: stderr must not join json",
4889            true,
4890        );
4891
4892        let snapshot = registry
4893            .status(
4894                &task_id,
4895                "session",
4896                None,
4897                Some(dir.path()),
4898                RUNNING_OUTPUT_PREVIEW_BYTES,
4899            )
4900            .unwrap();
4901
4902        assert_eq!(snapshot.output_preview, "{\"body\":\"hello\"}");
4903        assert!(!snapshot.output_preview.contains("warning"));
4904        assert!(!snapshot.output_truncated);
4905        assert_eq!(
4906            calls.load(Ordering::SeqCst),
4907            0,
4908            "structured JSON bypasses compression"
4909        );
4910    }
4911
4912    #[test]
4913    fn registry_emits_single_recovery_marker_for_class_drops() {
4914        let registry = BgTaskRegistry::default();
4915        let dir = tempfile::tempdir().unwrap();
4916        registry.set_compressor_with_exit_code(move |_command, _output, _exit_code| {
4917            let mut dropped = BTreeMap::new();
4918            dropped.insert(DropClass::Error, 18);
4919            dropped.insert(DropClass::Warning, 6);
4920            CompressionResult::with_class_drops("kept diagnostic", dropped)
4921        });
4922        let (task_id, task) =
4923            insert_terminal_piped_task(&registry, &dir, "custom-tool", "raw", "", true);
4924
4925        let snapshot = registry
4926            .status(
4927                &task_id,
4928                "session",
4929                None,
4930                Some(dir.path()),
4931                RUNNING_OUTPUT_PREVIEW_BYTES,
4932            )
4933            .unwrap();
4934
4935        assert_eq!(snapshot.output_preview.matches("full output:").count(), 1);
4936        assert!(snapshot.output_preview.contains("+18 more errors"));
4937        assert!(snapshot.output_preview.contains("+6 more warnings"));
4938        assert!(snapshot
4939            .output_preview
4940            .contains(&format!("read \"{}\"", task.paths.stdout.display())));
4941        assert!(!snapshot.output_preview.contains("tail -n +"));
4942        assert!(snapshot.output_truncated);
4943    }
4944
4945    #[test]
4946    fn registry_marker_reports_semantic_and_byte_drops_once() {
4947        let registry = BgTaskRegistry::default();
4948        let dir = tempfile::tempdir().unwrap();
4949        registry.set_compressor_with_exit_code(move |_command, _output, _exit_code| {
4950            let mut dropped = BTreeMap::new();
4951            dropped.insert(DropClass::Error, 1);
4952            CompressionResult::with_class_drops(
4953                format!("HEAD-SIGNAL\n{}TAIL-SIGNAL", "middle\n".repeat(8_000)),
4954                dropped,
4955            )
4956        });
4957        let (task_id, _task) =
4958            insert_terminal_piped_task(&registry, &dir, "custom-tool", "raw", "", true);
4959
4960        let snapshot = registry
4961            .status(
4962                &task_id,
4963                "session",
4964                None,
4965                Some(dir.path()),
4966                RUNNING_OUTPUT_PREVIEW_BYTES,
4967            )
4968            .unwrap();
4969
4970        assert_eq!(snapshot.output_preview.matches("full output:").count(), 1);
4971        assert!(snapshot.output_preview.contains("+1 more error"));
4972        assert!(snapshot.output_preview.contains("truncated output"));
4973        assert!(snapshot.output_preview.contains("HEAD-SIGNAL"));
4974        assert!(snapshot.output_preview.contains("TAIL-SIGNAL"));
4975        assert!(!snapshot.output_preview.contains("...<truncated"));
4976        assert!(snapshot.output_truncated);
4977    }
4978
4979    #[test]
4980    fn cargo_stderr_class_drops_name_both_capture_paths() {
4981        let registry = BgTaskRegistry::default();
4982        let dir = tempfile::tempdir().unwrap();
4983        let filter_registry = crate::compress::toml_filter::FilterRegistry::default();
4984        registry.set_compressor_with_exit_code(move |command, output, exit_code| {
4985            crate::compress::compress_with_registry_exit_code(
4986                command,
4987                &output,
4988                exit_code,
4989                &filter_registry,
4990            )
4991        });
4992        let stderr = (0..22)
4993            .map(|index| {
4994                format!(
4995                    "error: cargo failure {index}\n  --> src/lib.rs:{}:1\n   |\n{} | boom\n",
4996                    index + 1,
4997                    index + 1
4998                )
4999            })
5000            .collect::<Vec<_>>()
5001            .join("\n");
5002        let (task_id, task) = insert_terminal_piped_task(
5003            &registry,
5004            &dir,
5005            "cargo check",
5006            "Finished dev [unoptimized] target(s) in 0.01s\n",
5007            &stderr,
5008            true,
5009        );
5010
5011        let snapshot = registry
5012            .status(
5013                &task_id,
5014                "session",
5015                None,
5016                Some(dir.path()),
5017                RUNNING_OUTPUT_PREVIEW_BYTES,
5018            )
5019            .unwrap();
5020
5021        assert!(snapshot.output_preview.contains("+2 more errors"));
5022        assert!(snapshot
5023            .output_preview
5024            .contains(&format!("read \"{}\"", task.paths.stdout.display())));
5025        assert!(snapshot
5026            .output_preview
5027            .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5028        assert!(!snapshot.output_preview.contains("tail -n +"));
5029    }
5030
5031    #[test]
5032    fn over_ceiling_structured_json_uses_pointer_not_partial_json() {
5033        let registry = BgTaskRegistry::default();
5034        let dir = tempfile::tempdir().unwrap();
5035        let body = format!("{{\"body\":\"{}\"}}", "x".repeat(60 * 1024));
5036        let (task_id, task) = insert_terminal_piped_task(
5037            &registry,
5038            &dir,
5039            "cd /repo && gh pr view 123 --json body",
5040            &body,
5041            "",
5042            true,
5043        );
5044
5045        let snapshot = registry
5046            .status(
5047                &task_id,
5048                "session",
5049                None,
5050                Some(dir.path()),
5051                RUNNING_OUTPUT_PREVIEW_BYTES,
5052            )
5053            .unwrap();
5054
5055        assert!(snapshot.output_preview.starts_with("[JSON output "));
5056        assert!(snapshot
5057            .output_preview
5058            .contains(&task.paths.stdout.display().to_string()));
5059        assert!(!snapshot.output_preview.contains(&"x".repeat(1024)));
5060        assert!(snapshot.output_truncated);
5061    }
5062
5063    #[test]
5064    fn toml_strip_tail_cap_uses_full_output_hint_not_offset_hint() {
5065        let registry = BgTaskRegistry::default();
5066        let dir = tempfile::tempdir().unwrap();
5067        let filter_registry = crate::compress::toml_filter::build_registry(
5068            crate::compress::builtin_filters::ALL,
5069            None,
5070            None,
5071        );
5072        registry.set_compressor_with_exit_code(move |command, output, exit_code| {
5073            crate::compress::compress_with_registry_exit_code(
5074                command,
5075                &output,
5076                exit_code,
5077                &filter_registry,
5078            )
5079        });
5080        let stdout = format!(
5081            "make[1]: Entering directory `/tmp`\n{}",
5082            (0..100)
5083                .map(|index| format!("compile line {index}"))
5084                .collect::<Vec<_>>()
5085                .join("\n")
5086        );
5087        let (task_id, task) =
5088            insert_terminal_piped_task(&registry, &dir, "make all", &stdout, "", true);
5089
5090        let snapshot = registry
5091            .status(
5092                &task_id,
5093                "session",
5094                None,
5095                Some(dir.path()),
5096                RUNNING_OUTPUT_PREVIEW_BYTES,
5097            )
5098            .unwrap();
5099
5100        assert!(snapshot.output_preview.contains("compile line 99"));
5101        assert!(snapshot.output_preview.contains(&format!(
5102            "full output: read \"{}\"",
5103            task.paths.stdout.display()
5104        )));
5105        assert!(!snapshot
5106            .output_preview
5107            .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5108        assert!(!snapshot.output_preview.contains("tail -n +"));
5109    }
5110
5111    #[test]
5112    fn compressed_false_raw_passthrough_uses_wider_head_tail_cap() {
5113        let registry = BgTaskRegistry::default();
5114        let dir = tempfile::tempdir().unwrap();
5115        let output = format!("RAW-HEAD\n{}RAW-TAIL\n", "raw-middle\n".repeat(8_000));
5116        let (task_id, task) =
5117            insert_terminal_piped_task(&registry, &dir, "cat raw.log", &output, "RAW-ERR\n", false);
5118
5119        let snapshot = registry
5120            .status(
5121                &task_id,
5122                "session",
5123                None,
5124                Some(dir.path()),
5125                RUNNING_OUTPUT_PREVIEW_BYTES,
5126            )
5127            .unwrap();
5128
5129        assert!(snapshot.output_preview.contains("RAW-HEAD"));
5130        assert!(snapshot.output_preview.contains("RAW-TAIL"));
5131        assert!(snapshot.output_preview.contains("truncated output"));
5132        assert!(snapshot
5133            .output_preview
5134            .contains(&format!("read \"{}\"", task.paths.stdout.display())));
5135        assert!(snapshot
5136            .output_preview
5137            .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5138        assert!(!snapshot.output_preview.contains("tail -n +"));
5139        assert!(snapshot.output_preview.len() > 16 * 1024);
5140        assert!(snapshot.output_truncated);
5141    }
5142
5143    #[test]
5144    fn pty_terminal_snapshot_bypasses_line_compression() {
5145        let registry = BgTaskRegistry::default();
5146        let dir = tempfile::tempdir().unwrap();
5147        let calls = Arc::new(AtomicUsize::new(0));
5148        let calls_for_closure = Arc::clone(&calls);
5149        registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
5150            calls_for_closure.fetch_add(1, Ordering::SeqCst);
5151            CompressionResult::new(output)
5152        });
5153        let (task_id, _task) = insert_terminal_pty_task(&registry, &dir, "raw\u{1b}[31m pty bytes");
5154
5155        let snapshot = registry
5156            .status(
5157                &task_id,
5158                "session",
5159                None,
5160                Some(dir.path()),
5161                RUNNING_OUTPUT_PREVIEW_BYTES,
5162            )
5163            .unwrap();
5164
5165        assert_eq!(snapshot.info.mode, BgMode::Pty);
5166        assert_eq!(snapshot.output_preview, "");
5167        assert_eq!(calls.load(Ordering::SeqCst), 0);
5168    }
5169
5170    #[test]
5171    fn pty_dimensions_are_persisted_and_returned_in_snapshot() {
5172        let registry = BgTaskRegistry::default();
5173        let dir = tempfile::tempdir().unwrap();
5174        let task_id = registry
5175            .spawn_pty(
5176                QUICK_SUCCESS_COMMAND,
5177                "session".to_string(),
5178                dir.path().to_path_buf(),
5179                HashMap::new(),
5180                Some(Duration::from_secs(30)),
5181                dir.path().to_path_buf(),
5182                10,
5183                true,
5184                false,
5185                Some(dir.path().to_path_buf()),
5186                50,
5187                120,
5188            )
5189            .unwrap();
5190
5191        let paths = task_paths(dir.path(), "session", &task_id);
5192        let metadata = read_task(&paths.json).unwrap();
5193        assert_eq!(
5194            metadata.schema_version,
5195            crate::bash_background::persistence::SCHEMA_VERSION
5196        );
5197        assert_eq!(metadata.mode, BgMode::Pty);
5198        assert_eq!(metadata.pty_rows, Some(50));
5199        assert_eq!(metadata.pty_cols, Some(120));
5200
5201        let snapshot = registry
5202            .status(&task_id, "session", None, Some(dir.path()), 1024)
5203            .unwrap();
5204        assert_eq!(snapshot.pty_rows, Some(50));
5205        assert_eq!(snapshot.pty_cols, Some(120));
5206    }
5207
5208    /// Spawn a child process that exits immediately and return it after
5209    /// it has terminated. Used by reap_child tests to simulate the
5210    /// "child exists and is dead" state when the watchdog has already
5211    /// nulled out the original child handle.
5212    fn spawn_dead_child() -> std::process::Child {
5213        #[cfg(unix)]
5214        let mut cmd = std::process::Command::new("true");
5215        #[cfg(windows)]
5216        let mut cmd = {
5217            let mut c = std::process::Command::new("cmd");
5218            c.args(["/c", "exit", "0"]);
5219            c
5220        };
5221        cmd.stdin(std::process::Stdio::null());
5222        cmd.stdout(std::process::Stdio::null());
5223        cmd.stderr(std::process::Stdio::null());
5224        let mut child = cmd.spawn().expect("spawn replacement child for reap test");
5225        // Poll try_wait() until the child actually exits, instead of calling
5226        // wait() which closes the OS handle. On Windows, after wait()
5227        // closes the handle, subsequent try_wait() calls (which reap_child
5228        // depends on) return Err — the test was inadvertently giving
5229        // reap_child an unusable child handle. Polling try_wait() keeps the
5230        // handle open and observes natural exit, matching the production
5231        // shape where the watchdog discovers an exited child for the first
5232        // time.
5233        let started = Instant::now();
5234        loop {
5235            match child.try_wait() {
5236                Ok(Some(_)) => break,
5237                Ok(None) => {
5238                    if started.elapsed() > Duration::from_secs(5) {
5239                        panic!("dead-child stand-in did not exit within 5s");
5240                    }
5241                    std::thread::sleep(Duration::from_millis(10));
5242                }
5243                Err(error) => panic!("dead-child try_wait failed: {error}"),
5244            }
5245        }
5246        child
5247    }
5248
5249    #[test]
5250    fn ack_marks_delivered_even_when_completion_was_already_consumed_locally() {
5251        let registry = BgTaskRegistry::default();
5252        let dir = tempfile::tempdir().unwrap();
5253        let task_id = registry
5254            .spawn(
5255                LONG_RUNNING_COMMAND,
5256                "session".to_string(),
5257                dir.path().to_path_buf(),
5258                HashMap::new(),
5259                Some(Duration::from_secs(30)),
5260                dir.path().to_path_buf(),
5261                10,
5262                true,
5263                false,
5264                Some(dir.path().to_path_buf()),
5265            )
5266            .unwrap();
5267        registry
5268            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5269            .unwrap();
5270        assert_eq!(
5271            registry
5272                .drain_completions_for_session(Some("session"))
5273                .len(),
5274            1
5275        );
5276
5277        // Simulate the plugin consuming a sync bash_watch({ exit:true }) result
5278        // locally before the Rust completion queue is drained/acked.
5279        registry.inner.completions.lock().unwrap().clear();
5280
5281        assert_eq!(
5282            registry.ack_completions_for_session(Some("session"), std::slice::from_ref(&task_id)),
5283            vec![task_id.clone()]
5284        );
5285        assert!(registry
5286            .drain_completions_for_session(Some("session"))
5287            .is_empty());
5288
5289        let paths = task_paths(dir.path(), "session", &task_id);
5290        let metadata = read_task(&paths.json).unwrap();
5291        assert!(metadata.completion_delivered);
5292
5293        let replayed = BgTaskRegistry::default();
5294        replayed
5295            .replay_session_inner(dir.path(), "session", None)
5296            .unwrap();
5297        assert!(replayed
5298            .drain_completions_for_session(Some("session"))
5299            .is_empty());
5300    }
5301
5302    #[test]
5303    fn register_watch_rejects_unknown_task() {
5304        let registry = BgTaskRegistry::default();
5305
5306        let result = registry.register_watch(
5307            "missing-task".to_string(),
5308            WatchPattern::Substring("READY".into()),
5309            true,
5310        );
5311
5312        assert_eq!(result, Err("task_not_found"));
5313    }
5314
5315    #[test]
5316    fn register_watch_on_terminal_task_scans_existing_output() {
5317        let frames = Arc::new(Mutex::new(Vec::new()));
5318        let captured = Arc::clone(&frames);
5319        let sender: crate::context::ProgressSender = Arc::new(Box::new(move |frame| {
5320            captured.lock().unwrap().push(frame);
5321        })
5322            as Box<dyn Fn(PushFrame) + Send + Sync>);
5323        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(Some(sender))));
5324        let dir = tempfile::tempdir().unwrap();
5325        let task_id = registry
5326            .spawn(
5327                LONG_RUNNING_COMMAND,
5328                "session".to_string(),
5329                dir.path().to_path_buf(),
5330                HashMap::new(),
5331                Some(Duration::from_secs(30)),
5332                dir.path().to_path_buf(),
5333                10,
5334                true,
5335                false,
5336                Some(dir.path().to_path_buf()),
5337            )
5338            .unwrap();
5339        registry
5340            .inner
5341            .shutdown
5342            .store(true, std::sync::atomic::Ordering::SeqCst);
5343        let task = registry.task_for_session(&task_id, "session").unwrap();
5344        std::fs::write(&task.paths.stdout, "READY\n").unwrap();
5345        registry
5346            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5347            .unwrap();
5348        frames.lock().unwrap().clear();
5349        registry.inner.completions.lock().unwrap().clear();
5350
5351        registry
5352            .register_watch(
5353                task_id.clone(),
5354                WatchPattern::Substring("READY".into()),
5355                true,
5356            )
5357            .unwrap();
5358
5359        let frames = frames.lock().unwrap();
5360        let frame = frames
5361            .iter()
5362            .find_map(|frame| match frame {
5363                PushFrame::BashPatternMatch(frame) => Some(frame),
5364                _ => None,
5365            })
5366            .expect("terminal watch registration should emit pattern frame");
5367        assert_eq!(frame.reason, "pattern_match");
5368        assert_eq!(frame.task_id, task_id);
5369        assert_eq!(frame.session_id, "session");
5370        assert_eq!(frame.match_text, "READY");
5371        assert_eq!(frame.match_offset, 0);
5372        assert_eq!(registry.active_watch_count(&frame.task_id), 0);
5373        let metadata = read_task(&task.paths.json).unwrap();
5374        assert!(metadata.completion_delivered);
5375    }
5376
5377    #[test]
5378    fn cleanup_finished_removes_terminal_tasks_older_than_threshold() {
5379        let registry = BgTaskRegistry::default();
5380        let dir = tempfile::tempdir().unwrap();
5381        let task_id = registry
5382            .spawn(
5383                QUICK_SUCCESS_COMMAND,
5384                "session".to_string(),
5385                dir.path().to_path_buf(),
5386                HashMap::new(),
5387                Some(Duration::from_secs(30)),
5388                dir.path().to_path_buf(),
5389                10,
5390                true,
5391                false,
5392                Some(dir.path().to_path_buf()),
5393            )
5394            .unwrap();
5395        registry
5396            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5397            .unwrap();
5398        let completions = registry.drain_completions_for_session(Some("session"));
5399        assert_eq!(completions.len(), 1);
5400        assert_eq!(
5401            registry.ack_completions_for_session(Some("session"), std::slice::from_ref(&task_id)),
5402            vec![task_id.clone()]
5403        );
5404
5405        registry.cleanup_finished(Duration::ZERO);
5406
5407        assert!(registry.inner.tasks.lock().unwrap().is_empty());
5408    }
5409
5410    #[test]
5411    fn cleanup_finished_retains_undelivered_terminals() {
5412        let registry = BgTaskRegistry::default();
5413        let dir = tempfile::tempdir().unwrap();
5414        let task_id = registry
5415            .spawn(
5416                QUICK_SUCCESS_COMMAND,
5417                "session".to_string(),
5418                dir.path().to_path_buf(),
5419                HashMap::new(),
5420                Some(Duration::from_secs(30)),
5421                dir.path().to_path_buf(),
5422                10,
5423                true,
5424                false,
5425                Some(dir.path().to_path_buf()),
5426            )
5427            .unwrap();
5428        registry
5429            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5430            .unwrap();
5431
5432        registry.cleanup_finished(Duration::ZERO);
5433
5434        assert!(registry.inner.tasks.lock().unwrap().contains_key(&task_id));
5435    }
5436
5437    /// Verify that the live watchdog path (reap_child) gives an exited
5438    /// child one watchdog pass for its exit marker to land, then marks the
5439    /// task Failed if the next pass still sees no marker.
5440    ///
5441    /// Cross-platform: uses a quick-exiting command that does NOT go
5442    /// through the wrapper script (we manually clear the exit marker
5443    /// after spawn to simulate the wrapper crashing before write).
5444    #[test]
5445    fn reap_child_marks_failed_when_child_exits_without_exit_marker() {
5446        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5447        let dir = tempfile::tempdir().unwrap();
5448        let task_id = registry
5449            .spawn(
5450                QUICK_SUCCESS_COMMAND,
5451                "session".to_string(),
5452                dir.path().to_path_buf(),
5453                HashMap::new(),
5454                Some(Duration::from_secs(30)),
5455                dir.path().to_path_buf(),
5456                10,
5457                true,
5458                false,
5459                Some(dir.path().to_path_buf()),
5460            )
5461            .unwrap();
5462
5463        let task = registry.task_for_session(&task_id, "session").unwrap();
5464
5465        // Wait for the child to actually exit and the wrapper to either
5466        // write the marker or fail. Then nuke the marker to simulate
5467        // wrapper crash before write. Poll up to 5s; this is plenty for a
5468        // `true`/`cmd /c exit 0` invocation.
5469        let started = Instant::now();
5470        loop {
5471            let exited = {
5472                let mut state = task.state.lock().unwrap();
5473                match &mut state.runtime {
5474                    TaskRuntime::Piped(Some(child)) => matches!(child.try_wait(), Ok(Some(_))),
5475                    _ => true,
5476                }
5477            };
5478            if exited {
5479                break;
5480            }
5481            assert!(
5482                started.elapsed() < Duration::from_secs(5),
5483                "child should exit quickly"
5484            );
5485            std::thread::sleep(Duration::from_millis(20));
5486        }
5487
5488        // Stop the watchdog so it doesn't race with our manual reap_child.
5489        // On fast Windows runners the watchdog ticks (every 500ms) can
5490        // observe the child exit and reap it before this test's assertion
5491        // fires, leaving us with state.child = None and an already-terminal
5492        // status. We specifically want to test reap_child's logic when
5493        // invoked manually on a Running-but-actually-dead task, so we need
5494        // exclusive control over the reap path here.
5495        registry
5496            .inner
5497            .shutdown
5498            .store(true, std::sync::atomic::Ordering::SeqCst);
5499        // Give the watchdog at most one tick (500ms) to notice shutdown
5500        // before we touch task state. Without this, an in-flight watchdog
5501        // iteration could still race with our state setup below.
5502        std::thread::sleep(Duration::from_millis(550));
5503
5504        // Wrapper likely wrote the marker by now; remove it to simulate
5505        // a wrapper crash that exited before persisting the exit code.
5506        let _ = std::fs::remove_file(&task.paths.exit);
5507
5508        // The watchdog may have already reaped the child handle and
5509        // marked the task terminal before we got here. Reset both so
5510        // reap_child has the "Running task whose child just exited"
5511        // shape it's designed to handle. If the original child handle is
5512        // gone, install a quick-exited stand-in so the first reap exercises
5513        // the same try_wait path as production.
5514        //
5515        // CRITICAL on Windows: the watchdog ticks fast enough that the
5516        // JSON on disk may already say `Completed`. `update_task` (called
5517        // by `reap_child`) reads from disk, applies the closure, but
5518        // ROLLS BACK if the original on-disk state was already terminal
5519        // (see persistence.rs::update_task). So we must reset BOTH
5520        // in-memory metadata AND the JSON on disk to a Running state to
5521        // give reap_child the fresh shape it expects to operate on.
5522        {
5523            let mut state = task.state.lock().unwrap();
5524            state.metadata.status = BgTaskStatus::Running;
5525            state.metadata.status_reason = None;
5526            state.metadata.exit_code = None;
5527            state.metadata.finished_at = None;
5528            state.metadata.duration_ms = None;
5529            // Persist the reset state to disk so update_task's terminal
5530            // rollback guard sees a non-terminal starting point.
5531            crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5532                .expect("persist reset Running metadata for reap_child test");
5533            // If the watchdog already nulled state.child, we need to
5534            // simulate "child exists and is dead" so reap_child's
5535            // try_wait path runs. Spawn a quick-exit child as a stand-in.
5536            if matches!(state.runtime, TaskRuntime::Piped(None)) {
5537                state.runtime = TaskRuntime::Piped(Some(spawn_dead_child()));
5538            }
5539        }
5540        // Clear the terminal_at marker too so mark_terminal_now() can fire
5541        // again inside reap_child.
5542        *task.terminal_at.lock().unwrap() = None;
5543
5544        // Sanity: task is still Running per metadata (replay/poll hasn't
5545        // observed the missing marker yet).
5546        assert!(
5547            task.is_running(),
5548            "precondition: metadata.status == Running"
5549        );
5550        assert!(
5551            !task.paths.exit.exists(),
5552            "precondition: exit marker absent"
5553        );
5554
5555        // First watchdog observation is intentionally insufficient to
5556        // declare failure. A missing marker may just mean the wrapper is
5557        // still completing its tmp-file-to-marker rename, so reap_child only
5558        // drops the child handle and switches to detached PID monitoring.
5559        registry.reap_child(&task);
5560
5561        {
5562            let state = task.state.lock().unwrap();
5563            assert_eq!(
5564                state.metadata.status,
5565                BgTaskStatus::Running,
5566                "first reap must leave status Running while waiting one pass for marker"
5567            );
5568            assert_eq!(
5569                state.metadata.status_reason, None,
5570                "first reap must not record a failure reason"
5571            );
5572            assert!(
5573                matches!(state.runtime, TaskRuntime::Piped(None)),
5574                "child handle must be released after first reap"
5575            );
5576            assert!(
5577                state.detached,
5578                "task must be marked detached after first reap"
5579            );
5580        }
5581
5582        // Second watchdog observation sees the detached PID is dead and the
5583        // marker is still absent. That is strong enough evidence that the
5584        // wrapper exited without persisting an exit code.
5585        registry.reap_child(&task);
5586
5587        let state = task.state.lock().unwrap();
5588        assert!(
5589            state.metadata.status.is_terminal(),
5590            "second reap must transition to terminal when PID dead and no marker. Got status={:?}",
5591            state.metadata.status
5592        );
5593        assert_eq!(
5594            state.metadata.status,
5595            BgTaskStatus::Failed,
5596            "must specifically be Failed (not Killed): status={:?}",
5597            state.metadata.status
5598        );
5599        assert_eq!(
5600            state.metadata.status_reason.as_deref(),
5601            Some("process exited without exit marker"),
5602            "reason must match replay path's wording: {:?}",
5603            state.metadata.status_reason
5604        );
5605        assert!(
5606            matches!(state.runtime, TaskRuntime::Piped(None)),
5607            "child handle must stay released after second reap"
5608        );
5609        assert!(
5610            state.detached,
5611            "task must remain detached after second reap"
5612        );
5613    }
5614
5615    /// Companion to the above: when the exit marker DOES exist on disk
5616    /// at reap_child time, reap_child must NOT mark the task Failed.
5617    /// Instead it leaves status=Running and lets the next poll_task()
5618    /// cycle finalize via the marker.
5619    #[test]
5620    fn reap_child_preserves_running_when_exit_marker_exists() {
5621        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5622        let dir = tempfile::tempdir().unwrap();
5623        let task_id = registry
5624            .spawn(
5625                QUICK_SUCCESS_COMMAND,
5626                "session".to_string(),
5627                dir.path().to_path_buf(),
5628                HashMap::new(),
5629                Some(Duration::from_secs(30)),
5630                dir.path().to_path_buf(),
5631                10,
5632                true,
5633                false,
5634                Some(dir.path().to_path_buf()),
5635            )
5636            .unwrap();
5637
5638        let task = registry.task_for_session(&task_id, "session").unwrap();
5639
5640        // Wait for child to exit AND for the marker to land. Both happen
5641        // shortly after the wrapper finishes — but we want both observed.
5642        let started = Instant::now();
5643        loop {
5644            let exited = {
5645                let mut state = task.state.lock().unwrap();
5646                match &mut state.runtime {
5647                    TaskRuntime::Piped(Some(child)) => matches!(child.try_wait(), Ok(Some(_))),
5648                    _ => true,
5649                }
5650            };
5651            if exited && task.paths.exit.exists() {
5652                break;
5653            }
5654            assert!(
5655                started.elapsed() < Duration::from_secs(5),
5656                "child should exit and write marker quickly"
5657            );
5658            std::thread::sleep(Duration::from_millis(20));
5659        }
5660
5661        // Stop the watchdog so it doesn't race with our manual reap_child.
5662        // On fast Windows runners the watchdog can call poll_task (which
5663        // finalizes via marker) before this test asserts the
5664        // "marker exists, status still Running" invariant. We want
5665        // exclusive control over the reap path.
5666        registry
5667            .inner
5668            .shutdown
5669            .store(true, std::sync::atomic::Ordering::SeqCst);
5670        std::thread::sleep(Duration::from_millis(550));
5671
5672        // If the watchdog already finalized the task before we stopped it,
5673        // restore the test setup: reset status to Running and ensure the
5674        // marker file is still on disk. We're testing reap_child's
5675        // behavior when called manually with both child-exited AND
5676        // marker-present, regardless of whether the watchdog beat us.
5677        {
5678            let mut state = task.state.lock().unwrap();
5679            state.metadata.status = BgTaskStatus::Running;
5680            state.metadata.status_reason = None;
5681            if matches!(state.runtime, TaskRuntime::Piped(None)) {
5682                state.runtime = TaskRuntime::Piped(Some(spawn_dead_child()));
5683            }
5684        }
5685        *task.terminal_at.lock().unwrap() = None;
5686        // Make sure the marker is still on disk (poll_task removes it on
5687        // finalization). Recreate it if needed.
5688        if !task.paths.exit.exists() {
5689            std::fs::write(&task.paths.exit, "0").expect("write replacement exit marker");
5690        }
5691
5692        // reap_child sees: child exited, marker exists. It should:
5693        //  - drop state.child / set state.detached = true
5694        //  - NOT change status (poll_task will finalize via marker next tick)
5695        registry.reap_child(&task);
5696
5697        let state = task.state.lock().unwrap();
5698        assert!(
5699            matches!(state.runtime, TaskRuntime::Piped(None)),
5700            "child handle still released even when marker exists"
5701        );
5702        assert!(
5703            state.detached,
5704            "task still marked detached even when marker exists"
5705        );
5706        // Status remains Running because reap_child defers to poll_task
5707        // when a marker exists. It would be wrong for reap to record the
5708        // marker outcome (poll_task does that with proper exit-code
5709        // parsing).
5710        assert_eq!(
5711            state.metadata.status,
5712            BgTaskStatus::Running,
5713            "reap_child must defer to poll_task when marker exists"
5714        );
5715    }
5716
5717    /// Read a process's `ps` state string ("Z", "S", "R", etc). Returns
5718    /// `None` once the PID has been fully reaped (no row), which is the
5719    /// post-reap state we want.
5720    #[cfg(unix)]
5721    fn pid_stat(pid: u32) -> Option<String> {
5722        let output = std::process::Command::new("ps")
5723            .args(["-o", "stat=", "-p", &pid.to_string()])
5724            .output()
5725            .ok()?;
5726        if !output.status.success() {
5727            return None;
5728        }
5729        let stat = String::from_utf8_lossy(&output.stdout).trim().to_string();
5730        if stat.is_empty() {
5731            None
5732        } else {
5733            Some(stat)
5734        }
5735    }
5736
5737    /// A `<defunct>` zombie carries `ps` state starting with 'Z'.
5738    #[cfg(unix)]
5739    fn is_zombie(pid: u32) -> bool {
5740        pid_stat(pid).is_some_and(|stat| stat.starts_with('Z'))
5741    }
5742
5743    /// Spawn a child that exits immediately and wait — via `ps`, NOT
5744    /// `try_wait()`/`wait()` — until it is observably a `<defunct>` zombie,
5745    /// then return the still-unreaped handle. This reproduces the exact
5746    /// state issue #91 leaves behind: an exited OS child whose parent has
5747    /// not reaped it.
5748    #[cfg(unix)]
5749    fn spawn_unreaped_zombie() -> std::process::Child {
5750        let child = std::process::Command::new("true")
5751            .stdin(std::process::Stdio::null())
5752            .stdout(std::process::Stdio::null())
5753            .stderr(std::process::Stdio::null())
5754            .spawn()
5755            .expect("spawn zombie stand-in");
5756        let pid = child.id();
5757        let started = Instant::now();
5758        while !is_zombie(pid) {
5759            assert!(
5760                started.elapsed() < Duration::from_secs(5),
5761                "stand-in child should become a zombie within 5s"
5762            );
5763            std::thread::sleep(Duration::from_millis(10));
5764        }
5765        // Return WITHOUT reaping — the handle still owns an unwaited zombie.
5766        child
5767    }
5768
5769    /// Regression test for issue #91: the exit-marker terminal path
5770    /// (`poll_task` -> `finalize_from_marker`) must REAP the direct child
5771    /// handle, not merely drop it. Dropping a `std::process::Child` does not
5772    /// `wait()` on Unix, so the exited child lingers as a `[mv] <defunct>`
5773    /// zombie until AFT exits.
5774    ///
5775    /// We install a known-unreaped zombie into the task's child slot and
5776    /// drive the marker finalize path, then assert the child is gone (reaped)
5777    /// rather than still `<defunct>`.
5778    #[cfg(unix)]
5779    #[test]
5780    fn finalize_from_marker_reaps_child_no_zombie() {
5781        use std::sync::atomic::Ordering;
5782
5783        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5784        let dir = tempfile::tempdir().unwrap();
5785        let task_id = registry
5786            .spawn(
5787                QUICK_SUCCESS_COMMAND,
5788                "session".to_string(),
5789                dir.path().to_path_buf(),
5790                HashMap::new(),
5791                Some(Duration::from_secs(30)),
5792                dir.path().to_path_buf(),
5793                10,
5794                true,
5795                false,
5796                Some(dir.path().to_path_buf()),
5797            )
5798            .unwrap();
5799
5800        // Stop the watchdog so the ONLY terminal-transition path under test
5801        // is the exit-marker finalize (not reap_child's try_wait, which would
5802        // reap the child for us and mask the bug).
5803        registry.inner.shutdown.store(true, Ordering::SeqCst);
5804        std::thread::sleep(Duration::from_millis(550));
5805
5806        let task = registry.task_for_session(&task_id, "session").unwrap();
5807
5808        // Wait for the wrapper's exit marker to land. We deliberately do NOT
5809        // call try_wait()/wait() on the real child here — doing so would reap
5810        // it and defeat the test.
5811        let started = Instant::now();
5812        while !task.paths.exit.exists() {
5813            assert!(
5814                started.elapsed() < Duration::from_secs(5),
5815                "exit marker should land quickly for `true`"
5816            );
5817            std::thread::sleep(Duration::from_millis(20));
5818        }
5819
5820        // Reset to a fresh Running shape and install a guaranteed-unreaped
5821        // zombie as the child handle, so the finalize path's reap behavior is
5822        // exercised deterministically regardless of how the real child was
5823        // handled. Persist Running so update_task's terminal-rollback guard
5824        // sees a non-terminal starting point.
5825        let zombie_pid;
5826        {
5827            let mut state = task.state.lock().unwrap();
5828            state.metadata.status = BgTaskStatus::Running;
5829            state.metadata.status_reason = None;
5830            state.metadata.exit_code = None;
5831            state.metadata.finished_at = None;
5832            state.metadata.duration_ms = None;
5833            crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5834                .expect("persist reset Running metadata");
5835            let zombie = spawn_unreaped_zombie();
5836            zombie_pid = zombie.id();
5837            state.runtime = TaskRuntime::Piped(Some(zombie));
5838        }
5839        *task.terminal_at.lock().unwrap() = None;
5840
5841        // Precondition: the installed child is genuinely a `<defunct>` zombie.
5842        assert!(
5843            is_zombie(zombie_pid),
5844            "precondition: stand-in child {zombie_pid} must be a zombie before finalize"
5845        );
5846
5847        // Drive the exit-marker terminal path. Before the fix this nulled the
5848        // Child handle without wait(), leaving the zombie behind.
5849        registry.poll_task(&task).unwrap();
5850
5851        {
5852            let state = task.state.lock().unwrap();
5853            assert!(
5854                matches!(state.runtime, TaskRuntime::Piped(None)),
5855                "child handle must be released after marker finalize"
5856            );
5857            assert!(
5858                state.metadata.status.is_terminal(),
5859                "task must be terminal after marker finalize: {:?}",
5860                state.metadata.status
5861            );
5862        }
5863
5864        // The core assertion: the child must have been REAPED, not just
5865        // dropped. A reaped PID has no `ps` row (or at minimum is not 'Z').
5866        assert!(
5867            !is_zombie(zombie_pid),
5868            "issue #91 regression: child {zombie_pid} left as <defunct> zombie \
5869             after the exit-marker terminal transition"
5870        );
5871    }
5872
5873    /// Companion to the above for the kill path: when a kill observes an
5874    /// already-present exit marker (the child finished on its own first), it
5875    /// must reap the child handle rather than dropping it.
5876    #[cfg(unix)]
5877    #[test]
5878    fn kill_with_existing_marker_reaps_child_no_zombie() {
5879        use std::sync::atomic::Ordering;
5880
5881        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5882        let dir = tempfile::tempdir().unwrap();
5883        let task_id = registry
5884            .spawn(
5885                QUICK_SUCCESS_COMMAND,
5886                "session".to_string(),
5887                dir.path().to_path_buf(),
5888                HashMap::new(),
5889                Some(Duration::from_secs(30)),
5890                dir.path().to_path_buf(),
5891                10,
5892                true,
5893                false,
5894                Some(dir.path().to_path_buf()),
5895            )
5896            .unwrap();
5897
5898        registry.inner.shutdown.store(true, Ordering::SeqCst);
5899        std::thread::sleep(Duration::from_millis(550));
5900
5901        let task = registry.task_for_session(&task_id, "session").unwrap();
5902
5903        let started = Instant::now();
5904        while !task.paths.exit.exists() {
5905            assert!(
5906                started.elapsed() < Duration::from_secs(5),
5907                "exit marker should land quickly for `true`"
5908            );
5909            std::thread::sleep(Duration::from_millis(20));
5910        }
5911
5912        let zombie_pid;
5913        {
5914            let mut state = task.state.lock().unwrap();
5915            state.metadata.status = BgTaskStatus::Running;
5916            state.metadata.status_reason = None;
5917            state.metadata.exit_code = None;
5918            state.metadata.finished_at = None;
5919            state.metadata.duration_ms = None;
5920            crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5921                .expect("persist reset Running metadata");
5922            let zombie = spawn_unreaped_zombie();
5923            zombie_pid = zombie.id();
5924            state.runtime = TaskRuntime::Piped(Some(zombie));
5925        }
5926        *task.terminal_at.lock().unwrap() = None;
5927
5928        assert!(
5929            is_zombie(zombie_pid),
5930            "precondition: stand-in child {zombie_pid} must be a zombie before kill"
5931        );
5932
5933        // Kill observes the existing marker and finalizes from it.
5934        registry
5935            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5936            .expect("kill should succeed");
5937
5938        {
5939            let state = task.state.lock().unwrap();
5940            assert!(
5941                matches!(state.runtime, TaskRuntime::Piped(None)),
5942                "child handle must be released after marker-aware kill"
5943            );
5944            assert!(state.metadata.status.is_terminal());
5945        }
5946
5947        assert!(
5948            !is_zombie(zombie_pid),
5949            "issue #91 regression: child {zombie_pid} left as <defunct> zombie \
5950             after a marker-aware kill"
5951        );
5952    }
5953
5954    #[test]
5955    fn cleanup_finished_keeps_running_tasks() {
5956        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5957        let dir = tempfile::tempdir().unwrap();
5958        let task_id = registry
5959            .spawn(
5960                LONG_RUNNING_COMMAND,
5961                "session".to_string(),
5962                dir.path().to_path_buf(),
5963                HashMap::new(),
5964                Some(Duration::from_secs(30)),
5965                dir.path().to_path_buf(),
5966                10,
5967                true,
5968                false,
5969                Some(dir.path().to_path_buf()),
5970            )
5971            .unwrap();
5972
5973        registry.cleanup_finished(Duration::ZERO);
5974
5975        assert!(registry.inner.tasks.lock().unwrap().contains_key(&task_id));
5976        let _ = registry.kill(&task_id, "session");
5977    }
5978
5979    #[cfg(windows)]
5980    fn wait_for_file(path: &Path) -> String {
5981        let started = Instant::now();
5982        loop {
5983            if path.exists() {
5984                return fs::read_to_string(path).expect("read file");
5985            }
5986            assert!(
5987                started.elapsed() < Duration::from_secs(30),
5988                "timed out waiting for {}",
5989                path.display()
5990            );
5991            std::thread::sleep(Duration::from_millis(100));
5992        }
5993    }
5994
5995    #[cfg(windows)]
5996    fn spawn_windows_registry_command(
5997        command: &str,
5998    ) -> (BgTaskRegistry, tempfile::TempDir, String) {
5999        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
6000        let dir = tempfile::tempdir().unwrap();
6001        let task_id = registry
6002            .spawn(
6003                command,
6004                "session".to_string(),
6005                dir.path().to_path_buf(),
6006                HashMap::new(),
6007                Some(Duration::from_secs(30)),
6008                dir.path().to_path_buf(),
6009                10,
6010                false,
6011                false,
6012                Some(dir.path().to_path_buf()),
6013            )
6014            .unwrap();
6015        (registry, dir, task_id)
6016    }
6017
6018    #[cfg(windows)]
6019    #[test]
6020    fn windows_spawn_writes_exit_marker_for_zero_exit() {
6021        let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c exit 0");
6022        let exit_path = registry.task_exit_path(&task_id, "session").unwrap();
6023
6024        let content = wait_for_file(&exit_path);
6025
6026        assert_eq!(content.trim(), "0");
6027    }
6028
6029    #[cfg(windows)]
6030    #[test]
6031    fn windows_spawn_writes_exit_marker_for_nonzero_exit() {
6032        let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c exit 42");
6033        let exit_path = registry.task_exit_path(&task_id, "session").unwrap();
6034
6035        let content = wait_for_file(&exit_path);
6036
6037        assert_eq!(content.trim(), "42");
6038    }
6039
6040    #[cfg(windows)]
6041    #[test]
6042    fn windows_spawn_captures_stdout_to_disk() {
6043        let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c echo hello");
6044        let task = registry.task_for_session(&task_id, "session").unwrap();
6045        let stdout_path = task.paths.stdout.clone();
6046        let exit_path = task.paths.exit.clone();
6047
6048        let _ = wait_for_file(&exit_path);
6049        let stdout = fs::read_to_string(stdout_path).expect("read stdout");
6050
6051        assert!(stdout.contains("hello"), "stdout was {stdout:?}");
6052    }
6053
6054    #[cfg(windows)]
6055    #[test]
6056    fn windows_spawn_uses_pwsh_when_available() {
6057        // Without $SHELL set, $SHELL probe yields None and pwsh wins.
6058        // (We intentionally pass None for shell_env to keep this test
6059        // independent of the runner's actual env.)
6060        let candidates = crate::windows_shell::shell_candidates_with(
6061            |binary| match binary {
6062                "pwsh.exe" => Some(std::path::PathBuf::from(r"C:\pwsh\pwsh.exe")),
6063                "powershell.exe" => Some(std::path::PathBuf::from(r"C:\ps\powershell.exe")),
6064                _ => None,
6065            },
6066            || None,
6067        );
6068        let shell = candidates.first().expect("at least one candidate").clone();
6069        assert_eq!(shell, crate::windows_shell::WindowsShell::Pwsh);
6070        assert_eq!(shell.binary().as_ref(), "pwsh.exe");
6071    }
6072
6073    /// Issue #27 Oracle review P1, updated: cmd wrapper writes a `.bat` file
6074    /// that batch-evaluates `%ERRORLEVEL%` on its own line (line-by-line
6075    /// evaluation is the default for batch files; parse-time expansion only
6076    /// applies to compound `&`-chained inline commands). Capturing
6077    /// `%ERRORLEVEL%` into `set CODE=%ERRORLEVEL%` immediately after the user
6078    /// command runs records the real run-time exit code.
6079    #[cfg(windows)]
6080    #[test]
6081    fn windows_shell_cmd_wrapper_writes_exit_marker_with_move() {
6082        let exit_path = Path::new(r"C:\Temp\bash-test.exit");
6083        let script =
6084            crate::windows_shell::WindowsShell::Cmd.wrapper_script("cmd /c exit 42", exit_path);
6085
6086        // Batch wrapper: capture exit code into CODE on the line after the
6087        // user command, then write CODE to a temp marker file before
6088        // atomic-renaming it into place.
6089        assert!(
6090            script.contains("set CODE=%ERRORLEVEL%"),
6091            "wrapper must capture exit code into CODE: {script}"
6092        );
6093        assert!(
6094            script.contains("echo %CODE% >"),
6095            "wrapper must echo CODE to a temp marker file: {script}"
6096        );
6097        assert!(
6098            script.contains("move /Y"),
6099            "wrapper must use atomic move to write the marker: {script}"
6100        );
6101        // move output must be redirected to nul to avoid polluting the
6102        // user's captured stdout with "1 file(s) moved." lines.
6103        assert!(
6104            script.contains("> nul"),
6105            "wrapper must redirect move output to nul: {script}"
6106        );
6107        // exit /B %CODE% propagates the real exit code so wait() sees it.
6108        assert!(
6109            script.contains("exit /B %CODE%"),
6110            "wrapper must propagate the captured exit code: {script}"
6111        );
6112        assert!(script.contains(r#""C:\Temp\bash-test.exit.tmp""#));
6113        assert!(script.contains(r#""C:\Temp\bash-test.exit""#));
6114    }
6115
6116    /// `bg_command()` for Cmd no longer needs `/V:ON` — the wrapper is now
6117    /// written to a `.bat` file where batch-line evaluation captures
6118    /// `%ERRORLEVEL%` correctly without delayed expansion. We still need
6119    /// `/D` (skip AutoRun) and `/S` (simple quote-stripping for paths with
6120    /// internal `"`-quoting from `cmd_quote`).
6121    #[cfg(windows)]
6122    #[test]
6123    fn windows_shell_cmd_bg_command_uses_minimal_cmd_flags() {
6124        use crate::windows_shell::WindowsShell;
6125        let cmd = WindowsShell::Cmd.bg_command("echo wrapped");
6126        let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
6127        let args_strs: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
6128        assert_eq!(
6129            args_strs,
6130            vec!["/D", "/S", "/C", "echo wrapped"],
6131            "Cmd::bg_command must prepend /D /S /C"
6132        );
6133    }
6134
6135    /// PowerShell variants don't need `/V:ON`-style flags; their
6136    /// `bg_command()` args stay on the standard `-Command` path.
6137    #[cfg(windows)]
6138    #[test]
6139    fn windows_shell_pwsh_bg_command_uses_standard_args() {
6140        use crate::windows_shell::WindowsShell;
6141        let cmd = WindowsShell::Pwsh.bg_command("Get-Date");
6142        let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
6143        let args_strs: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
6144        assert!(
6145            args_strs.contains(&"-Command"),
6146            "Pwsh::bg_command must use -Command: {args_strs:?}"
6147        );
6148        assert!(
6149            args_strs.contains(&"Get-Date"),
6150            "Pwsh::bg_command must include the user command body"
6151        );
6152    }
6153}