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 {
3792            state.buffer.read_tail(preview_bytes)
3793        };
3794        BgTaskSnapshot {
3795            info: BgTaskInfo {
3796                task_id: self.task_id.clone(),
3797                status: metadata.status.clone(),
3798                command: metadata.command.clone(),
3799                mode: metadata.mode.clone(),
3800                started_at: metadata.started_at,
3801                duration_ms,
3802            },
3803            exit_code: metadata.exit_code,
3804            child_pid: metadata.child_pid,
3805            workdir: metadata.workdir.display().to_string(),
3806            output_preview,
3807            output_truncated,
3808            output_path: state
3809                .buffer
3810                .output_path()
3811                .map(|path| path.display().to_string()),
3812            stderr_path: state
3813                .buffer
3814                .stderr_path()
3815                .map(|path| path.display().to_string()),
3816            pty_rows: (metadata.mode == BgMode::Pty).then_some(metadata.pty_rows.unwrap_or(24)),
3817            pty_cols: (metadata.mode == BgMode::Pty).then_some(metadata.pty_cols.unwrap_or(80)),
3818            pty_screen: None,
3819        }
3820    }
3821
3822    pub(crate) fn is_running(&self) -> bool {
3823        self.state
3824            .lock()
3825            .map(|state| {
3826                state.metadata.status == BgTaskStatus::Running
3827                    || (state.metadata.mode == BgMode::Pty
3828                        && state.metadata.status == BgTaskStatus::Killing)
3829            })
3830            .unwrap_or(false)
3831    }
3832
3833    fn is_terminal(&self) -> bool {
3834        self.state
3835            .lock()
3836            .map(|state| state.metadata.status.is_terminal())
3837            .unwrap_or(false)
3838    }
3839
3840    fn mark_terminal_now(&self) {
3841        if let Ok(mut terminal_at) = self.terminal_at.lock() {
3842            if terminal_at.is_none() {
3843                *terminal_at = Some(Instant::now());
3844            }
3845        }
3846    }
3847
3848    fn set_completion_delivered(
3849        &self,
3850        delivered: bool,
3851        registry: &BgTaskRegistry,
3852    ) -> Result<(), String> {
3853        let mut state = self
3854            .state
3855            .lock()
3856            .map_err(|_| "background task lock poisoned".to_string())?;
3857        let updated = registry
3858            .update_task_metadata(&self.paths, |metadata| {
3859                metadata.completion_delivered = delivered;
3860            })
3861            .map_err(|e| format!("failed to update completion delivery: {e}"))?;
3862        state.metadata = updated;
3863        Ok(())
3864    }
3865}
3866
3867/// Reap an exited direct child handle, then clear the slot.
3868///
3869/// Dropping a [`std::process::Child`] does NOT `wait()` on the underlying OS
3870/// process. On Unix a finished-but-unreaped child lingers as a `<defunct>`
3871/// zombie until the AFT process itself exits (issue #91: `[mv] <defunct>`).
3872/// The terminal-transition paths that learn of completion from the
3873/// exit-marker file — rather than from [`BgTaskRegistry::reap_child`]'s
3874/// `try_wait()` — must therefore reap the handle explicitly instead of just
3875/// nulling it.
3876///
3877/// The exit marker is written by the wrapper's final statement (an atomic
3878/// `mv` rename), so by the time we observe the marker the direct child has
3879/// finished its work and is exiting; `wait()` returns essentially
3880/// immediately. We attempt a non-blocking `try_wait()` first so the common
3881/// case never blocks at all, falling back to a (bounded) `wait()` only to
3882/// cover the microsecond window between the rename and process teardown.
3883///
3884/// Callers hold the task state mutex, so this is serialized against
3885/// `reap_child` — there is no double-`wait()` hazard: whichever path acquires
3886/// the lock first reaps and clears the slot, and the other observes `None`.
3887#[cfg(unix)]
3888fn reap_piped_child(child_slot: &mut Option<Child>) {
3889    if let Some(mut child) = child_slot.take() {
3890        if matches!(child.try_wait(), Ok(None)) {
3891            let _ = child.wait();
3892        }
3893    }
3894}
3895
3896/// Windows has no zombie/`<defunct>` concept: dropping the [`Child`] closes
3897/// the process handle, which is the correct release. Preserve the historical
3898/// behavior of simply clearing the slot so the documented Windows PID-recycle
3899/// handling in `reap_child` is unaffected.
3900#[cfg(windows)]
3901fn reap_piped_child(child_slot: &mut Option<Child>) {
3902    *child_slot = None;
3903}
3904
3905fn terminal_metadata_from_marker(
3906    mut metadata: PersistedTask,
3907    marker: ExitMarker,
3908    reason: Option<String>,
3909) -> PersistedTask {
3910    match marker {
3911        ExitMarker::Code(code) => {
3912            let status = if code == 0 {
3913                BgTaskStatus::Completed
3914            } else {
3915                BgTaskStatus::Failed
3916            };
3917            metadata.mark_terminal(status, Some(code), reason);
3918        }
3919        ExitMarker::Killed => metadata.mark_terminal(
3920            BgTaskStatus::Killed,
3921            terminal_exit_code_for_status(&BgTaskStatus::Killed),
3922            reason,
3923        ),
3924    }
3925    metadata
3926}
3927
3928fn terminal_exit_code_for_status(status: &BgTaskStatus) -> Option<i32> {
3929    match status {
3930        BgTaskStatus::TimedOut => Some(124),
3931        BgTaskStatus::Killed => Some(137),
3932        _ => None,
3933    }
3934}
3935
3936#[cfg(unix)]
3937fn write_unix_command_script(command: &str, paths: &TaskPaths) -> Result<PathBuf, String> {
3938    let stem = paths
3939        .json
3940        .file_stem()
3941        .and_then(|stem| stem.to_str())
3942        .unwrap_or("wrapper");
3943    let script_path = paths.dir.join(format!("{stem}.sh"));
3944    fs::write(&script_path, command)
3945        .map_err(|e| format!("failed to write background bash command script: {e}"))?;
3946    Ok(script_path)
3947}
3948
3949#[cfg(unix)]
3950fn detached_shell_command(command_script: &Path, exit_path: &Path) -> Command {
3951    let shell = resolve_posix_shell();
3952    let mut cmd = crate::effective_path::new_command(&shell);
3953    // Keep the user-provided command body out of argv and shell `-c` parsing.
3954    // The direct child is still a tiny wrapper so it can write the authoritative
3955    // exit marker after the command script exits (including if that script calls
3956    // `exit`). Passing only file paths through `-c` avoids newline/quote/length
3957    // edge cases where a multi-command script can be mangled before execution.
3958    cmd.arg("-c")
3959        .arg(r#""$0" "$1"; code=$?; printf "%s" "$code" > "$2.tmp.$$"; mv -f "$2.tmp.$$" "$2""#)
3960        .arg(&shell)
3961        .arg(command_script)
3962        .arg(exit_path);
3963    unsafe {
3964        cmd.pre_exec(|| {
3965            if libc::setsid() == -1 {
3966                return Err(std::io::Error::last_os_error());
3967            }
3968            Ok(())
3969        });
3970    }
3971    cmd
3972}
3973
3974#[cfg(unix)]
3975fn resolve_posix_shell() -> PathBuf {
3976    static POSIX_SHELL: OnceLock<PathBuf> = OnceLock::new();
3977    POSIX_SHELL
3978        .get_or_init(|| {
3979            std::env::var_os("BASH")
3980                .filter(|value| !value.is_empty())
3981                .map(PathBuf::from)
3982                .filter(|path| path.exists())
3983                .or_else(|| which::which("bash").ok())
3984                .or_else(|| which::which("zsh").ok())
3985                .unwrap_or_else(|| PathBuf::from("/bin/sh"))
3986        })
3987        .clone()
3988}
3989
3990#[cfg(windows)]
3991fn detached_shell_command_for(
3992    shell: crate::windows_shell::WindowsShell,
3993    command: &str,
3994    exit_path: &Path,
3995    paths: &TaskPaths,
3996    creation_flags: u32,
3997) -> Result<Command, String> {
3998    use crate::windows_shell::WindowsShell;
3999    // Write the wrapper to a temp file alongside the other task files,
4000    // then invoke the shell with the file path as a single clean
4001    // argument. This sidesteps the entire Windows command-line quoting
4002    // mess (Rust std-lib quoting + cmd /C parser + PowerShell -Command
4003    // parser all interacting with embedded quotes in the wrapper).
4004    //
4005    // Path arguments don't need quoting in the same problematic way
4006    // because: (1) we use no-space task IDs (bash-XXXXXXXX) so the path
4007    // contains no characters that need shell escaping; (2) the wrapper
4008    // body's internal quotes never reach the shell command line — the
4009    // shell reads them from disk by file syntax rules, not command-line
4010    // parser rules.
4011    let wrapper_body = shell.wrapper_script_bytes(command, exit_path);
4012    let wrapper_ext = match shell {
4013        WindowsShell::Pwsh | WindowsShell::Powershell => "ps1",
4014        WindowsShell::Cmd => "bat",
4015        // POSIX shells (git-bash etc.) execute the wrapper through `-c`,
4016        // so the file extension is purely cosmetic; `.sh` matches what an
4017        // operator would expect when grepping the spill directory.
4018        WindowsShell::Posix(_) => "sh",
4019    };
4020    let wrapper_path = paths.dir.join(format!(
4021        "{}.{}",
4022        paths
4023            .json
4024            .file_stem()
4025            .and_then(|s| s.to_str())
4026            .unwrap_or("wrapper"),
4027        wrapper_ext
4028    ));
4029    fs::write(&wrapper_path, wrapper_body)
4030        .map_err(|e| format!("failed to write background bash wrapper script: {e}"))?;
4031
4032    let mut cmd = Command::new(shell.binary().as_ref());
4033    match shell {
4034        WindowsShell::Pwsh | WindowsShell::Powershell => {
4035            // -File runs the script with no quoting issues. `-NoLogo`,
4036            // `-NoProfile`, etc. apply to the host before the file runs.
4037            cmd.args([
4038                "-NoLogo",
4039                "-NoProfile",
4040                "-NonInteractive",
4041                "-ExecutionPolicy",
4042                "Bypass",
4043                "-File",
4044            ]);
4045            cmd.arg(&wrapper_path);
4046        }
4047        WindowsShell::Cmd => {
4048            // `cmd /D /C "<bat-file-path>"` — invoking a .bat
4049            // file via /C is well-defined; the file's contents are
4050            // read line-by-line by cmd's batch processor, NOT
4051            // re-interpreted by the /C parser. This avoids the
4052            // "filename syntax incorrect" errors that came from
4053            // having complex compound commands on the cmd line.
4054            cmd.args(["/D", "/C"]);
4055            cmd.arg(&wrapper_path);
4056        }
4057        WindowsShell::Posix(_) => {
4058            // git-bash and other POSIX shells run the wrapper script with
4059            // `<binary> <wrapper-path>` (the wrapper is just a shell
4060            // script). No special flags needed — the `trap` and atomic
4061            // exit-marker rename in `wrapper_script` are POSIX-standard.
4062            cmd.arg(&wrapper_path);
4063        }
4064    }
4065
4066    // Win32 process creation flags. Caller selects whether to include
4067    // CREATE_BREAKAWAY_FROM_JOB — see `detached_shell_command_for` callers
4068    // for the breakaway-fallback strategy.
4069    cmd.creation_flags(creation_flags);
4070    Ok(cmd)
4071}
4072
4073/// Spawn a detached background bash child process.
4074///
4075/// On Unix this is a single spawn against `/bin/sh`. On Windows it walks
4076/// `WindowsShell::shell_candidates()` (pwsh.exe → powershell.exe →
4077/// cmd.exe) and retries with the next candidate when the previous one
4078/// fails to spawn with `NotFound` — the same runtime safety net the
4079/// foreground bash path has, so issue #27 callers landing on cmd.exe
4080/// fallback can also use background bash. The wrapper script is
4081/// regenerated per attempt because PowerShell wrappers embed the shell
4082/// binary by name; the stdout/stderr capture handles are also reopened
4083/// per attempt because `Command::spawn()` consumes them.
4084///
4085/// Errors other than `NotFound` (PermissionDenied, OutOfMemory, etc.)
4086/// return immediately without retry — they indicate a problem with the
4087/// resolved shell that retrying with a different shell won't fix.
4088fn spawn_detached_child(
4089    command: &str,
4090    paths: &TaskPaths,
4091    workdir: &Path,
4092    env: &HashMap<String, String>,
4093) -> Result<std::process::Child, String> {
4094    #[cfg(not(windows))]
4095    {
4096        let stdout = create_capture_file(&paths.stdout)
4097            .map_err(|e| format!("failed to open stdout capture file: {e}"))?;
4098        let stderr = create_capture_file(&paths.stderr)
4099            .map_err(|e| format!("failed to open stderr capture file: {e}"))?;
4100        let command_script = write_unix_command_script(command, paths)?;
4101        detached_shell_command(&command_script, &paths.exit)
4102            .current_dir(workdir)
4103            .envs(env)
4104            .stdin(Stdio::null())
4105            .stdout(Stdio::from(stdout))
4106            .stderr(Stdio::from(stderr))
4107            .spawn()
4108            .map_err(|e| format!("failed to spawn background bash command: {e}"))
4109    }
4110    #[cfg(windows)]
4111    {
4112        use crate::windows_shell::shell_candidates;
4113        // Spawn priority: pwsh → powershell → git-bash → cmd. Same as the
4114        // legacy foreground bash spawn path. v0.20 routes ALL bash through
4115        // this background spawn helper, including foreground tool calls
4116        // where the model writes PowerShell-syntax (`$var = ...`,
4117        // `Start-Sleep`, `Add-Content`) — those fail outright under cmd.
4118        // The earlier v0.18-era cmd-first override worked around a
4119        // PowerShell detached-output bug; that bug is fixed at the
4120        // process-flag layer (CREATE_NO_WINDOW instead of DETACHED_PROCESS,
4121        // see flag block below), so we no longer need to misroute PS
4122        // commands through cmd.
4123        let candidates: Vec<crate::windows_shell::WindowsShell> = shell_candidates();
4124        // Win32 process creation flags. We try with CREATE_BREAKAWAY_FROM_JOB
4125        // first (so the bg child outlives the AFT process when AFT is killed),
4126        // then fall back without it for environments where the parent is in a
4127        // Job Object that doesn't grant `JOB_OBJECT_LIMIT_BREAKAWAY_OK`. CI
4128        // runners (GitHub Actions windows-2022) and some MDM-managed corp
4129        // environments hit this — `CreateProcess` returns Access Denied (5).
4130        // Without breakaway, the child still runs detached but will be torn
4131        // down with the parent if the parent process group is signaled.
4132        //
4133        // We use CREATE_NO_WINDOW (no visible console window, but the
4134        // child still has a hidden console) rather than DETACHED_PROCESS
4135        // (no console at all). PowerShell-based wrappers that perform
4136        // file I/O via [System.IO.File] need a console handle to flush
4137        // stdout/stderr correctly even when redirected — under
4138        // DETACHED_PROCESS, pwsh sometimes silently exits before
4139        // executing later script statements (the Move-Item that writes
4140        // the exit marker never runs), leaving the bg task forever
4141        // marked Failed: process exited without exit marker. cmd.exe
4142        // wrappers tolerate DETACHED_PROCESS, but switching to
4143        // CREATE_NO_WINDOW costs nothing for cmd and unblocks pwsh.
4144        const FLAG_CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
4145        const FLAG_CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
4146        const FLAG_CREATE_NO_WINDOW: u32 = 0x0800_0000;
4147        let with_breakaway =
4148            FLAG_CREATE_NO_WINDOW | FLAG_CREATE_NEW_PROCESS_GROUP | FLAG_CREATE_BREAKAWAY_FROM_JOB;
4149        let without_breakaway = FLAG_CREATE_NO_WINDOW | FLAG_CREATE_NEW_PROCESS_GROUP;
4150        let mut last_error: Option<String> = None;
4151        for (idx, shell) in candidates.iter().enumerate() {
4152            // Per-shell, try with breakaway first. If the process is in a
4153            // restrictive job, the breakaway flag triggers Access Denied
4154            // (os error 5). Retry once without breakaway.
4155            for &flags in &[with_breakaway, without_breakaway] {
4156                // Re-open capture handles per attempt; spawn() consumes them.
4157                let stdout = create_capture_file(&paths.stdout)
4158                    .map_err(|e| format!("failed to open stdout capture file: {e}"))?;
4159                let stderr = create_capture_file(&paths.stderr)
4160                    .map_err(|e| format!("failed to open stderr capture file: {e}"))?;
4161                let mut cmd =
4162                    detached_shell_command_for(shell.clone(), command, &paths.exit, paths, flags)?;
4163                cmd.current_dir(workdir)
4164                    .envs(env)
4165                    .stdin(Stdio::null())
4166                    .stdout(Stdio::from(stdout))
4167                    .stderr(Stdio::from(stderr));
4168                match cmd.spawn() {
4169                    Ok(child) => {
4170                        if idx > 0 {
4171                            crate::slog_warn!("background bash spawn fell back to {} after {} earlier candidate(s) failed; \
4172                             the cached PATH probe disagreed with runtime spawn — likely PATH \
4173                             inheritance, antivirus / AppLocker / Defender ASR, or sandbox policy.",
4174                            shell.binary(),
4175                            idx);
4176                        }
4177                        if flags == without_breakaway {
4178                            crate::slog_warn!(
4179                                "background bash spawn: CREATE_BREAKAWAY_FROM_JOB rejected \
4180                             (likely a restrictive Job Object — CI sandbox or MDM policy). \
4181                             Spawned without breakaway; the bg task will be torn down if the \
4182                             AFT process group is killed."
4183                            );
4184                        }
4185                        return Ok(child);
4186                    }
4187                    Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4188                        crate::slog_warn!("background bash spawn: {} returned NotFound at runtime — trying next candidate",
4189                        shell.binary());
4190                        last_error = Some(format!("{}: {e}", shell.binary()));
4191                        // Skip the without-breakaway retry for NotFound — the
4192                        // binary itself is missing, breakaway flag is irrelevant.
4193                        break;
4194                    }
4195                    Err(e) if flags == with_breakaway && e.raw_os_error() == Some(5) => {
4196                        // Access Denied during breakaway — retry without it.
4197                        crate::slog_warn!(
4198                            "background bash spawn: CREATE_BREAKAWAY_FROM_JOB rejected with \
4199                         Access Denied — retrying {} without breakaway",
4200                            shell.binary()
4201                        );
4202                        last_error = Some(format!("{}: {e}", shell.binary()));
4203                        continue;
4204                    }
4205                    Err(e) => {
4206                        return Err(format!(
4207                            "failed to spawn background bash command via {}: {e}",
4208                            shell.binary()
4209                        ));
4210                    }
4211                }
4212            }
4213        }
4214        Err(format!(
4215            "failed to spawn background bash command: no Windows shell could be spawned. \
4216             Last error: {}. PATH-probed candidates: {:?}",
4217            last_error.unwrap_or_else(|| "no candidates were attempted".to_string()),
4218            candidates.iter().map(|s| s.binary()).collect::<Vec<_>>()
4219        ))
4220    }
4221}
4222
4223fn random_slug() -> String {
4224    // 8 bytes = 64-bit entropy → `bash-{16hex}`, matching the documented contract
4225    // at `generate_unique_task_id`. The width is load-bearing for the subc
4226    // delivery dedup: a plugin can retain a delivered task id awaiting ack that
4227    // Rust has already dropped (a lost ack response), and Rust's uniqueness check
4228    // cannot see that plugin-side set — so id reuse must be made negligible by
4229    // entropy alone. 32-bit was reusable within a long session and could let a new
4230    // task collide with such a stale id and be silently skipped (audit R3 #3).
4231    let mut bytes = [0u8; 8];
4232    // getrandom is a transitive dependency; use it directly for OS entropy.
4233    getrandom::fill(&mut bytes).unwrap_or_else(|_| {
4234        // Extremely unlikely fallback: time + pid mix across all 8 bytes.
4235        let t = SystemTime::now()
4236            .duration_since(UNIX_EPOCH)
4237            .map(|d| d.as_nanos() as u64)
4238            .unwrap_or(0);
4239        let p = u64::from(std::process::id());
4240        bytes.copy_from_slice(&(t ^ p.rotate_left(32)).to_le_bytes());
4241    });
4242    // `bash-` + 16 lowercase hex chars — compact, OS-entropy backed.
4243    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
4244    format!("bash-{hex}")
4245}
4246
4247#[cfg(test)]
4248mod tests {
4249    use std::collections::HashMap;
4250    use std::fs;
4251    use std::sync::atomic::{AtomicBool, AtomicUsize};
4252    use std::sync::{Arc, Mutex};
4253    use std::time::Duration;
4254    #[cfg(windows)]
4255    use std::time::Instant;
4256
4257    use super::*;
4258
4259    #[cfg(unix)]
4260    const QUICK_SUCCESS_COMMAND: &str = "true";
4261    #[cfg(windows)]
4262    const QUICK_SUCCESS_COMMAND: &str = "cmd /c exit 0";
4263
4264    #[cfg(unix)]
4265    const LONG_RUNNING_COMMAND: &str = "sleep 5";
4266    #[cfg(windows)]
4267    const LONG_RUNNING_COMMAND: &str = "cmd /c timeout /t 5 /nobreak > nul";
4268
4269    #[test]
4270    fn bash_memory_estimate_is_zero_when_empty_and_nonzero_for_completion_cache() {
4271        let registry = BgTaskRegistry::default();
4272        assert_eq!(registry.estimated_memory().estimated_bytes, Some(0));
4273        registry
4274            .inner
4275            .completions
4276            .lock()
4277            .unwrap()
4278            .push_back(BgCompletion {
4279                task_id: "bash-memory".to_string(),
4280                session_id: "session-memory".to_string(),
4281                status: BgTaskStatus::Completed,
4282                exit_code: Some(0),
4283                command: "printf memory".to_string(),
4284                output_preview: "resident completion output".to_string(),
4285                output_truncated: false,
4286                original_tokens: None,
4287                compressed_tokens: None,
4288                tokens_skipped: false,
4289            });
4290        let estimate = registry.estimated_memory();
4291        assert!(estimate.estimated_bytes.unwrap() > 0);
4292        assert_eq!(estimate.counts["completion_caches"], 1);
4293        assert_eq!(estimate.counts["sessions"], 1);
4294    }
4295
4296    #[test]
4297    fn gh_structured_detection_rejects_piped_commands() {
4298        assert!(is_gh_structured_command(
4299            "gh issue list --json number,title"
4300        ));
4301        assert!(is_gh_structured_command(
4302            "cd repo && gh issue list --json number,title"
4303        ));
4304
4305        assert!(!is_gh_structured_command(
4306            "gh issue list --json number,title | jq '.[]'"
4307        ));
4308        assert!(!is_gh_structured_command(
4309            "gh issue list --json number,title |"
4310        ));
4311    }
4312
4313    fn insert_terminal_piped_task(
4314        registry: &BgTaskRegistry,
4315        dir: &tempfile::TempDir,
4316        command: &str,
4317        stdout: &str,
4318        stderr: &str,
4319        compressed: bool,
4320    ) -> (String, Arc<BgTask>) {
4321        let task_id = format!("bash-test-{}", random_slug());
4322        let paths = task_paths(dir.path(), "session", &task_id);
4323        fs::create_dir_all(&paths.dir).unwrap();
4324        fs::write(&paths.stdout, stdout).unwrap();
4325        fs::write(&paths.stderr, stderr).unwrap();
4326        let mut metadata = PersistedTask::starting(
4327            task_id.clone(),
4328            "session".to_string(),
4329            command.to_string(),
4330            dir.path().to_path_buf(),
4331            Some(dir.path().to_path_buf()),
4332            Some(30_000),
4333            true,
4334            compressed,
4335        );
4336        metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4337        write_task(&paths.json, &metadata).unwrap();
4338        registry
4339            .insert_rehydrated_task(metadata, paths, true)
4340            .expect("insert terminal task");
4341        let task = registry.task_for_session(&task_id, "session").unwrap();
4342        (task_id, task)
4343    }
4344
4345    #[test]
4346    fn artifact_read_capability_requires_exact_canonical_path_and_session() {
4347        let registry = BgTaskRegistry::default();
4348        let dir = tempfile::tempdir().unwrap();
4349        let (_task_id, task) = insert_terminal_piped_task(
4350            &registry,
4351            &dir,
4352            "printf output",
4353            "stdout\n",
4354            "stderr\n",
4355            true,
4356        );
4357        fs::write(&task.paths.exit, "0\n").unwrap();
4358
4359        assert!(registry.is_session_owned_artifact_path("session", &task.paths.stdout));
4360        assert!(registry.is_session_owned_artifact_path("session", &task.paths.stderr));
4361        assert!(registry.is_session_owned_artifact_path("session", &task.paths.exit));
4362        assert!(!registry.is_session_owned_artifact_path("different-session", &task.paths.stdout));
4363        assert!(!registry.is_session_owned_artifact_path("session", &task.paths.json));
4364
4365        let unregistered = task.paths.dir.join("unregistered-output");
4366        fs::write(&unregistered, "not a task artifact\n").unwrap();
4367        assert!(!registry.is_session_owned_artifact_path("session", &unregistered));
4368    }
4369
4370    #[cfg(unix)]
4371    #[test]
4372    fn artifact_directory_symlink_does_not_create_a_prefix_exception() {
4373        let registry = BgTaskRegistry::default();
4374        let dir = tempfile::tempdir().unwrap();
4375        let project = dir.path().join("project");
4376        fs::create_dir_all(&project).unwrap();
4377        let (_task_id, task) =
4378            insert_terminal_piped_task(&registry, &dir, "printf output", "stdout\n", "", true);
4379        let link = project.join("task-artifacts");
4380        std::os::unix::fs::symlink(&task.paths.dir, &link).unwrap();
4381        let unregistered = task.paths.dir.join("unregistered-output");
4382        fs::write(&unregistered, "not registered\n").unwrap();
4383
4384        assert!(!registry.is_session_owned_artifact_path("session", &link));
4385        assert!(
4386            !registry.is_session_owned_artifact_path("session", &link.join("unregistered-output"))
4387        );
4388        assert!(registry.is_session_owned_artifact_path(
4389            "session",
4390            &link.join(task.paths.stdout.file_name().unwrap())
4391        ));
4392
4393        let outside = dir.path().join("outside-secret");
4394        fs::write(&outside, "must stay private\n").unwrap();
4395        fs::remove_file(&task.paths.stdout).unwrap();
4396        std::os::unix::fs::symlink(&outside, &task.paths.stdout).unwrap();
4397        assert!(!registry.is_session_owned_artifact_path("session", &task.paths.stdout));
4398    }
4399
4400    #[test]
4401    fn recovery_footer_uses_bash_status_when_artifact_is_not_registered() {
4402        let registry = BgTaskRegistry::default();
4403        let dir = tempfile::tempdir().unwrap();
4404        let task_id = "bash-unregistered-footer";
4405        let paths = task_paths(dir.path(), "session", task_id);
4406        fs::create_dir_all(&paths.dir).unwrap();
4407        fs::write(
4408            &paths.stdout,
4409            format!("{}tail\n", "output-line\n".repeat(2_000)),
4410        )
4411        .unwrap();
4412        fs::write(&paths.stderr, "").unwrap();
4413        let mut metadata = PersistedTask::starting(
4414            task_id.to_string(),
4415            "session".to_string(),
4416            "printf output".to_string(),
4417            dir.path().to_path_buf(),
4418            Some(dir.path().to_path_buf()),
4419            Some(30_000),
4420            true,
4421            true,
4422        );
4423        metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4424
4425        let cache = registry
4426            .render_terminal_output_from_paths(&metadata, &paths)
4427            .expect("terminal render");
4428
4429        assert!(cache
4430            .output_preview
4431            .contains("use bash_status({taskId: \"bash-unregistered-footer\"})"));
4432        assert!(!cache.output_preview.contains("full output: read "));
4433    }
4434
4435    fn insert_terminal_pty_task(
4436        registry: &BgTaskRegistry,
4437        dir: &tempfile::TempDir,
4438        pty_output: &str,
4439    ) -> (String, Arc<BgTask>) {
4440        let task_id = format!("bash-test-{}", random_slug());
4441        let paths = task_paths(dir.path(), "session", &task_id);
4442        fs::create_dir_all(&paths.dir).unwrap();
4443        fs::write(&paths.pty, pty_output).unwrap();
4444        let mut metadata = PersistedTask::starting(
4445            task_id.clone(),
4446            "session".to_string(),
4447            "python".to_string(),
4448            dir.path().to_path_buf(),
4449            Some(dir.path().to_path_buf()),
4450            Some(30_000),
4451            true,
4452            true,
4453        );
4454        metadata.mode = BgMode::Pty;
4455        metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4456        write_task(&paths.json, &metadata).unwrap();
4457        registry
4458            .insert_rehydrated_task(metadata, paths, true)
4459            .expect("insert terminal pty task");
4460        let task = registry.task_for_session(&task_id, "session").unwrap();
4461        (task_id, task)
4462    }
4463
4464    #[cfg(unix)]
4465    fn wait_for_terminal_snapshot(
4466        registry: &BgTaskRegistry,
4467        task_id: &str,
4468        session_id: &str,
4469        project: &Path,
4470        storage: &Path,
4471    ) -> BgTaskSnapshot {
4472        let started = Instant::now();
4473        loop {
4474            let snapshot = registry
4475                .status(task_id, session_id, Some(project), Some(storage), 4096)
4476                .expect("spawned task should be visible to status");
4477            if snapshot.info.status.is_terminal() {
4478                return snapshot;
4479            }
4480            assert!(
4481                started.elapsed() < Duration::from_secs(10),
4482                "timed out waiting for task {task_id} to finish; last status={:?}",
4483                snapshot.info.status
4484            );
4485            std::thread::sleep(Duration::from_millis(50));
4486        }
4487    }
4488
4489    fn write_running_project_task(storage: &Path, project: &Path, session: &str, task_id: &str) {
4490        let paths = task_paths(storage, session, task_id);
4491        let mut metadata = PersistedTask::starting(
4492            task_id.to_string(),
4493            session.to_string(),
4494            "sleep 60".to_string(),
4495            project.to_path_buf(),
4496            Some(project.to_path_buf()),
4497            Some(30_000),
4498            true,
4499            true,
4500        );
4501        metadata.status = BgTaskStatus::Running;
4502        write_task(&paths.json, &metadata).unwrap();
4503        fs::write(&paths.stdout, "still running\n").unwrap();
4504        fs::write(&paths.stderr, "").unwrap();
4505    }
4506
4507    #[test]
4508    fn status_replay_filters_same_session_by_project_root() {
4509        let project_a = tempfile::tempdir().unwrap();
4510        let project_b = tempfile::tempdir().unwrap();
4511        let storage = tempfile::tempdir().unwrap();
4512        let session = "shared-session";
4513        let task_id = "bash-project-a";
4514        write_running_project_task(storage.path(), project_a.path(), session, task_id);
4515
4516        let actor_b = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4517        assert!(actor_b
4518            .status(
4519                task_id,
4520                session,
4521                Some(project_b.path()),
4522                Some(storage.path()),
4523                1024,
4524            )
4525            .is_none());
4526        assert!(actor_b.task_for_session(task_id, session).is_none());
4527
4528        let actor_a = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4529        let snapshot = actor_a
4530            .status(
4531                task_id,
4532                session,
4533                Some(project_a.path()),
4534                Some(storage.path()),
4535                1024,
4536            )
4537            .expect("owning project should replay its task");
4538        assert_eq!(snapshot.info.status, BgTaskStatus::Running);
4539    }
4540
4541    #[cfg(unix)]
4542    #[test]
4543    fn multiline_pipeline_stdout_persists_all_lines_after_terminal_status() {
4544        let cases = [
4545            (
4546                "long-first",
4547                "sleep 0.5; printf 'one\\n' | cat\nprintf 'two\\n' | grep -c two\nprintf 'three\\n' | cat",
4548                vec!["one", "1", "three"],
4549            ),
4550            (
4551                "short-first",
4552                "printf 'one\\n' | cat\nsleep 0.2; printf 'two\\n' | grep -c two\nprintf 'three\\n' | cat",
4553                vec!["one", "1", "three"],
4554            ),
4555            (
4556                "failing-middle",
4557                "sleep 0.2; printf 'one\\n' | cat\nfalse; printf 'after-false\\n' | cat\nprintf 'three\\n' | cat",
4558                vec!["one", "after-false", "three"],
4559            ),
4560        ];
4561
4562        for (name, command, expected_lines) in cases {
4563            let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4564            let dir = tempfile::tempdir().unwrap();
4565            let session_id = format!("session-{name}");
4566            let task_id = registry
4567                .spawn(
4568                    command,
4569                    session_id.clone(),
4570                    dir.path().to_path_buf(),
4571                    HashMap::new(),
4572                    Some(Duration::from_secs(30)),
4573                    dir.path().to_path_buf(),
4574                    10,
4575                    true,
4576                    true,
4577                    Some(dir.path().to_path_buf()),
4578                )
4579                .unwrap();
4580
4581            let snapshot = wait_for_terminal_snapshot(
4582                &registry,
4583                &task_id,
4584                &session_id,
4585                dir.path(),
4586                dir.path(),
4587            );
4588            assert_eq!(
4589                snapshot.info.status,
4590                BgTaskStatus::Completed,
4591                "{name}: task should complete; snapshot={snapshot:?}"
4592            );
4593            assert_eq!(
4594                snapshot.exit_code,
4595                Some(0),
4596                "{name}: script should use the final command's exit code"
4597            );
4598
4599            let stdout_path = task_paths(dir.path(), &session_id, &task_id).stdout;
4600            let stdout = fs::read_to_string(&stdout_path).unwrap_or_else(|error| {
4601                panic!(
4602                    "{name}: failed to read raw stdout file {}: {error}",
4603                    stdout_path.display()
4604                )
4605            });
4606            let lines: Vec<&str> = stdout.lines().collect();
4607            assert_eq!(
4608                lines,
4609                expected_lines,
4610                "{name}: raw stdout file must include every newline-separated command's output; stdout_path={}",
4611                stdout_path.display()
4612            );
4613        }
4614    }
4615
4616    #[test]
4617    fn recognizes_all_recovery_marker_forms() {
4618        assert!(is_recovery_marker(
4619            "[truncated output; full output: read \"/tmp/out\"]"
4620        ));
4621        assert!(is_recovery_marker(
4622            "[omitted output; see remaining: tail -n +42 \"/tmp/out\"]"
4623        ));
4624        assert!(is_recovery_marker(
4625            "[truncated output; full output unavailable]"
4626        ));
4627        assert!(is_recovery_marker(
4628            r#"[truncated 123 bytes from saved output prefix; retained output: read "/tmp/out"]"#
4629        ));
4630    }
4631
4632    #[test]
4633    fn recovery_marker_reports_disk_prefix_truncation_as_retained_output() {
4634        let recovery = RecoveryContext {
4635            dropped_by_class: BTreeMap::new(),
4636            had_inner_drop: false,
4637            offset_hint_eligible: false,
4638            offset_start_line: None,
4639            byte_truncated: false,
4640            disk_truncated_prefix_bytes: 4096,
4641            output_path: Some("/tmp/stdout".to_string()),
4642            stderr_path: None,
4643            include_stderr_path: false,
4644            artifact_access: ArtifactRecoveryAccess {
4645                task_id: "bash-test".to_string(),
4646                readable: true,
4647            },
4648        };
4649
4650        let marker = recovery_marker(&recovery).expect("disk truncation must emit marker");
4651
4652        assert!(marker.contains("truncated 4096 bytes from saved output prefix"));
4653        assert!(marker.contains(r#"retained output: read "/tmp/stdout""#));
4654        assert!(!marker.contains("full output: read"));
4655    }
4656
4657    #[test]
4658    fn killed_exit_marker_sets_nonzero_sentinel_exit_code() {
4659        let metadata = PersistedTask::starting(
4660            "task".to_string(),
4661            "session".to_string(),
4662            "cargo test".to_string(),
4663            PathBuf::from("/tmp"),
4664            None,
4665            None,
4666            true,
4667            true,
4668        );
4669
4670        let terminal = terminal_metadata_from_marker(metadata, ExitMarker::Killed, None);
4671
4672        assert_eq!(terminal.status, BgTaskStatus::Killed);
4673        assert_eq!(terminal.exit_code, Some(137));
4674    }
4675
4676    #[test]
4677    fn terminal_status_polls_use_cached_render_once_and_off_lock() {
4678        let registry = BgTaskRegistry::default();
4679        let dir = tempfile::tempdir().unwrap();
4680        let (_task_id, task) = insert_terminal_piped_task(
4681            &registry,
4682            &dir,
4683            "custom-tool --verbose",
4684            &"stdout line\n".repeat(200_000),
4685            "",
4686            true,
4687        );
4688        let calls = Arc::new(AtomicUsize::new(0));
4689        let saw_unlocked_state = Arc::new(AtomicBool::new(false));
4690        let task_holder = Arc::new(Mutex::new(Some(Arc::clone(&task))));
4691        let calls_for_closure = Arc::clone(&calls);
4692        let unlocked_for_closure = Arc::clone(&saw_unlocked_state);
4693        let task_for_closure = Arc::clone(&task_holder);
4694        registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
4695            calls_for_closure.fetch_add(1, Ordering::SeqCst);
4696            if let Some(task) = task_for_closure.lock().unwrap().as_ref() {
4697                if task.state.try_lock().is_ok() {
4698                    unlocked_for_closure.store(true, Ordering::SeqCst);
4699                }
4700            }
4701            CompressionResult::new(format!("compressed {} bytes", output.len()))
4702        });
4703
4704        let first = registry
4705            .status(
4706                &task.task_id,
4707                "session",
4708                None,
4709                Some(dir.path()),
4710                RUNNING_OUTPUT_PREVIEW_BYTES,
4711            )
4712            .unwrap();
4713        let second = registry
4714            .status(
4715                &task.task_id,
4716                "session",
4717                None,
4718                Some(dir.path()),
4719                RUNNING_OUTPUT_PREVIEW_BYTES,
4720            )
4721            .unwrap();
4722        let listed = registry.list(RUNNING_OUTPUT_PREVIEW_BYTES);
4723
4724        assert_eq!(
4725            calls.load(Ordering::SeqCst),
4726            1,
4727            "terminal render must be cached"
4728        );
4729        assert!(
4730            saw_unlocked_state.load(Ordering::SeqCst),
4731            "compressor must run after releasing the task state lock"
4732        );
4733        assert!(first.output_preview.starts_with("compressed "));
4734        assert_eq!(second.output_preview, first.output_preview);
4735        assert_eq!(listed[0].output_preview, first.output_preview);
4736    }
4737
4738    #[test]
4739    fn completion_preview_success_keeps_tail_only() {
4740        // Exit-aware completion previews: a SUCCESSFUL task's reminder keeps a
4741        // short tail only — head context is noise when the command worked
4742        // (regression: the uniform 4 KiB head+tail cap flooded reminders with
4743        // ~1K tokens of build noise per completed task).
4744        let registry = BgTaskRegistry::default();
4745        let dir = tempfile::tempdir().unwrap();
4746        let output = format!("HEAD-SIGNAL\n{}TAIL-SIGNAL\n", "middle\n".repeat(2_000));
4747        let (_task_id, task) =
4748            insert_terminal_piped_task(&registry, &dir, "cat big.log", &output, "", false);
4749
4750        registry.post_terminal_transition(&task, true).unwrap();
4751        let completions = registry.drain_completions_for_session(Some("session"));
4752        assert_eq!(completions.len(), 1);
4753        let preview = &completions[0].output_preview;
4754        assert!(preview.contains("TAIL-SIGNAL"), "preview was {preview:?}");
4755        assert!(!preview.contains("HEAD-SIGNAL"), "preview was {preview:?}");
4756        assert!(completions[0].output_truncated);
4757    }
4758
4759    #[test]
4760    fn completion_preview_failure_keeps_head_and_tail() {
4761        // A FAILED task keeps a small head (first error / command banner) plus
4762        // a larger tail (tracebacks and summaries land at the end).
4763        let registry = BgTaskRegistry::default();
4764        let dir = tempfile::tempdir().unwrap();
4765        let output = format!("HEAD-SIGNAL\n{}TAIL-SIGNAL\n", "middle\n".repeat(2_000));
4766        let task_id = format!("bash-test-{}", random_slug());
4767        let paths = task_paths(dir.path(), "session", &task_id);
4768        fs::create_dir_all(&paths.dir).unwrap();
4769        fs::write(&paths.stdout, &output).unwrap();
4770        fs::write(&paths.stderr, "").unwrap();
4771        let mut metadata = PersistedTask::starting(
4772            task_id.clone(),
4773            "session".to_string(),
4774            "cat big.log".to_string(),
4775            dir.path().to_path_buf(),
4776            Some(dir.path().to_path_buf()),
4777            Some(30_000),
4778            true,
4779            false,
4780        );
4781        metadata.mark_terminal(BgTaskStatus::Failed, Some(1), None);
4782        write_task(&paths.json, &metadata).unwrap();
4783        registry
4784            .insert_rehydrated_task(metadata, paths, true)
4785            .expect("insert terminal task");
4786        let task = registry.task_for_session(&task_id, "session").unwrap();
4787
4788        registry.post_terminal_transition(&task, true).unwrap();
4789        let completions = registry.drain_completions_for_session(Some("session"));
4790        assert_eq!(completions.len(), 1);
4791        let preview = &completions[0].output_preview;
4792        assert!(preview.contains("HEAD-SIGNAL"), "preview was {preview:?}");
4793        assert!(preview.contains("TAIL-SIGNAL"), "preview was {preview:?}");
4794    }
4795
4796    #[test]
4797    fn has_completions_for_session_matches_pending_delivery() {
4798        let registry = BgTaskRegistry::default();
4799        assert!(!registry.has_completions_for_session(Some("session")));
4800        assert!(!registry.has_completions_for_session(None));
4801
4802        let dir = tempfile::tempdir().unwrap();
4803        let (_task_id, task) =
4804            insert_terminal_piped_task(&registry, &dir, QUICK_SUCCESS_COMMAND, "done\n", "", false);
4805        registry.post_terminal_transition(&task, true).unwrap();
4806
4807        assert!(registry.has_completions_for_session(Some("session")));
4808        assert!(registry.has_completions_for_session(None));
4809        assert!(!registry.has_completions_for_session(Some("other-session")));
4810
4811        let completions = registry.drain_completions_for_session(Some("session"));
4812        assert_eq!(completions.len(), 1);
4813        assert_eq!(completions[0].task_id, task.task_id);
4814    }
4815
4816    #[test]
4817    fn structured_gh_json_survives_intact_and_ignores_stderr() {
4818        let registry = BgTaskRegistry::default();
4819        let dir = tempfile::tempdir().unwrap();
4820        let calls = Arc::new(AtomicUsize::new(0));
4821        let calls_for_closure = Arc::clone(&calls);
4822        registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
4823            calls_for_closure.fetch_add(1, Ordering::SeqCst);
4824            CompressionResult::new(output)
4825        });
4826        let (task_id, _task) = insert_terminal_piped_task(
4827            &registry,
4828            &dir,
4829            "gh pr view 123 --json body",
4830            "{\"body\":\"hello\"}",
4831            "warning: stderr must not join json",
4832            true,
4833        );
4834
4835        let snapshot = registry
4836            .status(
4837                &task_id,
4838                "session",
4839                None,
4840                Some(dir.path()),
4841                RUNNING_OUTPUT_PREVIEW_BYTES,
4842            )
4843            .unwrap();
4844
4845        assert_eq!(snapshot.output_preview, "{\"body\":\"hello\"}");
4846        assert!(!snapshot.output_preview.contains("warning"));
4847        assert!(!snapshot.output_truncated);
4848        assert_eq!(
4849            calls.load(Ordering::SeqCst),
4850            0,
4851            "structured JSON bypasses compression"
4852        );
4853    }
4854
4855    #[test]
4856    fn registry_emits_single_recovery_marker_for_class_drops() {
4857        let registry = BgTaskRegistry::default();
4858        let dir = tempfile::tempdir().unwrap();
4859        registry.set_compressor_with_exit_code(move |_command, _output, _exit_code| {
4860            let mut dropped = BTreeMap::new();
4861            dropped.insert(DropClass::Error, 18);
4862            dropped.insert(DropClass::Warning, 6);
4863            CompressionResult::with_class_drops("kept diagnostic", dropped)
4864        });
4865        let (task_id, task) =
4866            insert_terminal_piped_task(&registry, &dir, "custom-tool", "raw", "", true);
4867
4868        let snapshot = registry
4869            .status(
4870                &task_id,
4871                "session",
4872                None,
4873                Some(dir.path()),
4874                RUNNING_OUTPUT_PREVIEW_BYTES,
4875            )
4876            .unwrap();
4877
4878        assert_eq!(snapshot.output_preview.matches("full output:").count(), 1);
4879        assert!(snapshot.output_preview.contains("+18 more errors"));
4880        assert!(snapshot.output_preview.contains("+6 more warnings"));
4881        assert!(snapshot
4882            .output_preview
4883            .contains(&format!("read \"{}\"", task.paths.stdout.display())));
4884        assert!(!snapshot.output_preview.contains("tail -n +"));
4885        assert!(snapshot.output_truncated);
4886    }
4887
4888    #[test]
4889    fn registry_marker_reports_semantic_and_byte_drops_once() {
4890        let registry = BgTaskRegistry::default();
4891        let dir = tempfile::tempdir().unwrap();
4892        registry.set_compressor_with_exit_code(move |_command, _output, _exit_code| {
4893            let mut dropped = BTreeMap::new();
4894            dropped.insert(DropClass::Error, 1);
4895            CompressionResult::with_class_drops(
4896                format!("HEAD-SIGNAL\n{}TAIL-SIGNAL", "middle\n".repeat(8_000)),
4897                dropped,
4898            )
4899        });
4900        let (task_id, _task) =
4901            insert_terminal_piped_task(&registry, &dir, "custom-tool", "raw", "", true);
4902
4903        let snapshot = registry
4904            .status(
4905                &task_id,
4906                "session",
4907                None,
4908                Some(dir.path()),
4909                RUNNING_OUTPUT_PREVIEW_BYTES,
4910            )
4911            .unwrap();
4912
4913        assert_eq!(snapshot.output_preview.matches("full output:").count(), 1);
4914        assert!(snapshot.output_preview.contains("+1 more error"));
4915        assert!(snapshot.output_preview.contains("truncated output"));
4916        assert!(snapshot.output_preview.contains("HEAD-SIGNAL"));
4917        assert!(snapshot.output_preview.contains("TAIL-SIGNAL"));
4918        assert!(!snapshot.output_preview.contains("...<truncated"));
4919        assert!(snapshot.output_truncated);
4920    }
4921
4922    #[test]
4923    fn cargo_stderr_class_drops_name_both_capture_paths() {
4924        let registry = BgTaskRegistry::default();
4925        let dir = tempfile::tempdir().unwrap();
4926        let filter_registry = crate::compress::toml_filter::FilterRegistry::default();
4927        registry.set_compressor_with_exit_code(move |command, output, exit_code| {
4928            crate::compress::compress_with_registry_exit_code(
4929                command,
4930                &output,
4931                exit_code,
4932                &filter_registry,
4933            )
4934        });
4935        let stderr = (0..22)
4936            .map(|index| {
4937                format!(
4938                    "error: cargo failure {index}\n  --> src/lib.rs:{}:1\n   |\n{} | boom\n",
4939                    index + 1,
4940                    index + 1
4941                )
4942            })
4943            .collect::<Vec<_>>()
4944            .join("\n");
4945        let (task_id, task) = insert_terminal_piped_task(
4946            &registry,
4947            &dir,
4948            "cargo check",
4949            "Finished dev [unoptimized] target(s) in 0.01s\n",
4950            &stderr,
4951            true,
4952        );
4953
4954        let snapshot = registry
4955            .status(
4956                &task_id,
4957                "session",
4958                None,
4959                Some(dir.path()),
4960                RUNNING_OUTPUT_PREVIEW_BYTES,
4961            )
4962            .unwrap();
4963
4964        assert!(snapshot.output_preview.contains("+2 more errors"));
4965        assert!(snapshot
4966            .output_preview
4967            .contains(&format!("read \"{}\"", task.paths.stdout.display())));
4968        assert!(snapshot
4969            .output_preview
4970            .contains(&format!("read \"{}\"", task.paths.stderr.display())));
4971        assert!(!snapshot.output_preview.contains("tail -n +"));
4972    }
4973
4974    #[test]
4975    fn over_ceiling_structured_json_uses_pointer_not_partial_json() {
4976        let registry = BgTaskRegistry::default();
4977        let dir = tempfile::tempdir().unwrap();
4978        let body = format!("{{\"body\":\"{}\"}}", "x".repeat(60 * 1024));
4979        let (task_id, task) = insert_terminal_piped_task(
4980            &registry,
4981            &dir,
4982            "cd /repo && gh pr view 123 --json body",
4983            &body,
4984            "",
4985            true,
4986        );
4987
4988        let snapshot = registry
4989            .status(
4990                &task_id,
4991                "session",
4992                None,
4993                Some(dir.path()),
4994                RUNNING_OUTPUT_PREVIEW_BYTES,
4995            )
4996            .unwrap();
4997
4998        assert!(snapshot.output_preview.starts_with("[JSON output "));
4999        assert!(snapshot
5000            .output_preview
5001            .contains(&task.paths.stdout.display().to_string()));
5002        assert!(!snapshot.output_preview.contains(&"x".repeat(1024)));
5003        assert!(snapshot.output_truncated);
5004    }
5005
5006    #[test]
5007    fn toml_strip_tail_cap_uses_full_output_hint_not_offset_hint() {
5008        let registry = BgTaskRegistry::default();
5009        let dir = tempfile::tempdir().unwrap();
5010        let filter_registry = crate::compress::toml_filter::build_registry(
5011            crate::compress::builtin_filters::ALL,
5012            None,
5013            None,
5014        );
5015        registry.set_compressor_with_exit_code(move |command, output, exit_code| {
5016            crate::compress::compress_with_registry_exit_code(
5017                command,
5018                &output,
5019                exit_code,
5020                &filter_registry,
5021            )
5022        });
5023        let stdout = format!(
5024            "make[1]: Entering directory `/tmp`\n{}",
5025            (0..100)
5026                .map(|index| format!("compile line {index}"))
5027                .collect::<Vec<_>>()
5028                .join("\n")
5029        );
5030        let (task_id, task) =
5031            insert_terminal_piped_task(&registry, &dir, "make all", &stdout, "", true);
5032
5033        let snapshot = registry
5034            .status(
5035                &task_id,
5036                "session",
5037                None,
5038                Some(dir.path()),
5039                RUNNING_OUTPUT_PREVIEW_BYTES,
5040            )
5041            .unwrap();
5042
5043        assert!(snapshot.output_preview.contains("compile line 99"));
5044        assert!(snapshot.output_preview.contains(&format!(
5045            "full output: read \"{}\"",
5046            task.paths.stdout.display()
5047        )));
5048        assert!(!snapshot
5049            .output_preview
5050            .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5051        assert!(!snapshot.output_preview.contains("tail -n +"));
5052    }
5053
5054    #[test]
5055    fn compressed_false_raw_passthrough_uses_wider_head_tail_cap() {
5056        let registry = BgTaskRegistry::default();
5057        let dir = tempfile::tempdir().unwrap();
5058        let output = format!("RAW-HEAD\n{}RAW-TAIL\n", "raw-middle\n".repeat(8_000));
5059        let (task_id, task) =
5060            insert_terminal_piped_task(&registry, &dir, "cat raw.log", &output, "RAW-ERR\n", false);
5061
5062        let snapshot = registry
5063            .status(
5064                &task_id,
5065                "session",
5066                None,
5067                Some(dir.path()),
5068                RUNNING_OUTPUT_PREVIEW_BYTES,
5069            )
5070            .unwrap();
5071
5072        assert!(snapshot.output_preview.contains("RAW-HEAD"));
5073        assert!(snapshot.output_preview.contains("RAW-TAIL"));
5074        assert!(snapshot.output_preview.contains("truncated output"));
5075        assert!(snapshot
5076            .output_preview
5077            .contains(&format!("read \"{}\"", task.paths.stdout.display())));
5078        assert!(snapshot
5079            .output_preview
5080            .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5081        assert!(!snapshot.output_preview.contains("tail -n +"));
5082        assert!(snapshot.output_preview.len() > 16 * 1024);
5083        assert!(snapshot.output_truncated);
5084    }
5085
5086    #[test]
5087    fn pty_terminal_snapshot_bypasses_line_compression() {
5088        let registry = BgTaskRegistry::default();
5089        let dir = tempfile::tempdir().unwrap();
5090        let calls = Arc::new(AtomicUsize::new(0));
5091        let calls_for_closure = Arc::clone(&calls);
5092        registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
5093            calls_for_closure.fetch_add(1, Ordering::SeqCst);
5094            CompressionResult::new(output)
5095        });
5096        let (task_id, _task) = insert_terminal_pty_task(&registry, &dir, "raw\u{1b}[31m pty bytes");
5097
5098        let snapshot = registry
5099            .status(
5100                &task_id,
5101                "session",
5102                None,
5103                Some(dir.path()),
5104                RUNNING_OUTPUT_PREVIEW_BYTES,
5105            )
5106            .unwrap();
5107
5108        assert_eq!(snapshot.info.mode, BgMode::Pty);
5109        assert_eq!(snapshot.output_preview, "");
5110        assert_eq!(calls.load(Ordering::SeqCst), 0);
5111    }
5112
5113    #[test]
5114    fn pty_dimensions_are_persisted_and_returned_in_snapshot() {
5115        let registry = BgTaskRegistry::default();
5116        let dir = tempfile::tempdir().unwrap();
5117        let task_id = registry
5118            .spawn_pty(
5119                QUICK_SUCCESS_COMMAND,
5120                "session".to_string(),
5121                dir.path().to_path_buf(),
5122                HashMap::new(),
5123                Some(Duration::from_secs(30)),
5124                dir.path().to_path_buf(),
5125                10,
5126                true,
5127                false,
5128                Some(dir.path().to_path_buf()),
5129                50,
5130                120,
5131            )
5132            .unwrap();
5133
5134        let paths = task_paths(dir.path(), "session", &task_id);
5135        let metadata = read_task(&paths.json).unwrap();
5136        assert_eq!(
5137            metadata.schema_version,
5138            crate::bash_background::persistence::SCHEMA_VERSION
5139        );
5140        assert_eq!(metadata.mode, BgMode::Pty);
5141        assert_eq!(metadata.pty_rows, Some(50));
5142        assert_eq!(metadata.pty_cols, Some(120));
5143
5144        let snapshot = registry
5145            .status(&task_id, "session", None, Some(dir.path()), 1024)
5146            .unwrap();
5147        assert_eq!(snapshot.pty_rows, Some(50));
5148        assert_eq!(snapshot.pty_cols, Some(120));
5149    }
5150
5151    /// Spawn a child process that exits immediately and return it after
5152    /// it has terminated. Used by reap_child tests to simulate the
5153    /// "child exists and is dead" state when the watchdog has already
5154    /// nulled out the original child handle.
5155    fn spawn_dead_child() -> std::process::Child {
5156        #[cfg(unix)]
5157        let mut cmd = std::process::Command::new("true");
5158        #[cfg(windows)]
5159        let mut cmd = {
5160            let mut c = std::process::Command::new("cmd");
5161            c.args(["/c", "exit", "0"]);
5162            c
5163        };
5164        cmd.stdin(std::process::Stdio::null());
5165        cmd.stdout(std::process::Stdio::null());
5166        cmd.stderr(std::process::Stdio::null());
5167        let mut child = cmd.spawn().expect("spawn replacement child for reap test");
5168        // Poll try_wait() until the child actually exits, instead of calling
5169        // wait() which closes the OS handle. On Windows, after wait()
5170        // closes the handle, subsequent try_wait() calls (which reap_child
5171        // depends on) return Err — the test was inadvertently giving
5172        // reap_child an unusable child handle. Polling try_wait() keeps the
5173        // handle open and observes natural exit, matching the production
5174        // shape where the watchdog discovers an exited child for the first
5175        // time.
5176        let started = Instant::now();
5177        loop {
5178            match child.try_wait() {
5179                Ok(Some(_)) => break,
5180                Ok(None) => {
5181                    if started.elapsed() > Duration::from_secs(5) {
5182                        panic!("dead-child stand-in did not exit within 5s");
5183                    }
5184                    std::thread::sleep(Duration::from_millis(10));
5185                }
5186                Err(error) => panic!("dead-child try_wait failed: {error}"),
5187            }
5188        }
5189        child
5190    }
5191
5192    #[test]
5193    fn ack_marks_delivered_even_when_completion_was_already_consumed_locally() {
5194        let registry = BgTaskRegistry::default();
5195        let dir = tempfile::tempdir().unwrap();
5196        let task_id = registry
5197            .spawn(
5198                LONG_RUNNING_COMMAND,
5199                "session".to_string(),
5200                dir.path().to_path_buf(),
5201                HashMap::new(),
5202                Some(Duration::from_secs(30)),
5203                dir.path().to_path_buf(),
5204                10,
5205                true,
5206                false,
5207                Some(dir.path().to_path_buf()),
5208            )
5209            .unwrap();
5210        registry
5211            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5212            .unwrap();
5213        assert_eq!(
5214            registry
5215                .drain_completions_for_session(Some("session"))
5216                .len(),
5217            1
5218        );
5219
5220        // Simulate the plugin consuming a sync bash_watch({ exit:true }) result
5221        // locally before the Rust completion queue is drained/acked.
5222        registry.inner.completions.lock().unwrap().clear();
5223
5224        assert_eq!(
5225            registry.ack_completions_for_session(Some("session"), std::slice::from_ref(&task_id)),
5226            vec![task_id.clone()]
5227        );
5228        assert!(registry
5229            .drain_completions_for_session(Some("session"))
5230            .is_empty());
5231
5232        let paths = task_paths(dir.path(), "session", &task_id);
5233        let metadata = read_task(&paths.json).unwrap();
5234        assert!(metadata.completion_delivered);
5235
5236        let replayed = BgTaskRegistry::default();
5237        replayed
5238            .replay_session_inner(dir.path(), "session", None)
5239            .unwrap();
5240        assert!(replayed
5241            .drain_completions_for_session(Some("session"))
5242            .is_empty());
5243    }
5244
5245    #[test]
5246    fn register_watch_rejects_unknown_task() {
5247        let registry = BgTaskRegistry::default();
5248
5249        let result = registry.register_watch(
5250            "missing-task".to_string(),
5251            WatchPattern::Substring("READY".into()),
5252            true,
5253        );
5254
5255        assert_eq!(result, Err("task_not_found"));
5256    }
5257
5258    #[test]
5259    fn register_watch_on_terminal_task_scans_existing_output() {
5260        let frames = Arc::new(Mutex::new(Vec::new()));
5261        let captured = Arc::clone(&frames);
5262        let sender: crate::context::ProgressSender = Arc::new(Box::new(move |frame| {
5263            captured.lock().unwrap().push(frame);
5264        })
5265            as Box<dyn Fn(PushFrame) + Send + Sync>);
5266        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(Some(sender))));
5267        let dir = tempfile::tempdir().unwrap();
5268        let task_id = registry
5269            .spawn(
5270                LONG_RUNNING_COMMAND,
5271                "session".to_string(),
5272                dir.path().to_path_buf(),
5273                HashMap::new(),
5274                Some(Duration::from_secs(30)),
5275                dir.path().to_path_buf(),
5276                10,
5277                true,
5278                false,
5279                Some(dir.path().to_path_buf()),
5280            )
5281            .unwrap();
5282        registry
5283            .inner
5284            .shutdown
5285            .store(true, std::sync::atomic::Ordering::SeqCst);
5286        let task = registry.task_for_session(&task_id, "session").unwrap();
5287        std::fs::write(&task.paths.stdout, "READY\n").unwrap();
5288        registry
5289            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5290            .unwrap();
5291        frames.lock().unwrap().clear();
5292        registry.inner.completions.lock().unwrap().clear();
5293
5294        registry
5295            .register_watch(
5296                task_id.clone(),
5297                WatchPattern::Substring("READY".into()),
5298                true,
5299            )
5300            .unwrap();
5301
5302        let frames = frames.lock().unwrap();
5303        let frame = frames
5304            .iter()
5305            .find_map(|frame| match frame {
5306                PushFrame::BashPatternMatch(frame) => Some(frame),
5307                _ => None,
5308            })
5309            .expect("terminal watch registration should emit pattern frame");
5310        assert_eq!(frame.reason, "pattern_match");
5311        assert_eq!(frame.task_id, task_id);
5312        assert_eq!(frame.session_id, "session");
5313        assert_eq!(frame.match_text, "READY");
5314        assert_eq!(frame.match_offset, 0);
5315        assert_eq!(registry.active_watch_count(&frame.task_id), 0);
5316        let metadata = read_task(&task.paths.json).unwrap();
5317        assert!(metadata.completion_delivered);
5318    }
5319
5320    #[test]
5321    fn cleanup_finished_removes_terminal_tasks_older_than_threshold() {
5322        let registry = BgTaskRegistry::default();
5323        let dir = tempfile::tempdir().unwrap();
5324        let task_id = registry
5325            .spawn(
5326                QUICK_SUCCESS_COMMAND,
5327                "session".to_string(),
5328                dir.path().to_path_buf(),
5329                HashMap::new(),
5330                Some(Duration::from_secs(30)),
5331                dir.path().to_path_buf(),
5332                10,
5333                true,
5334                false,
5335                Some(dir.path().to_path_buf()),
5336            )
5337            .unwrap();
5338        registry
5339            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5340            .unwrap();
5341        let completions = registry.drain_completions_for_session(Some("session"));
5342        assert_eq!(completions.len(), 1);
5343        assert_eq!(
5344            registry.ack_completions_for_session(Some("session"), std::slice::from_ref(&task_id)),
5345            vec![task_id.clone()]
5346        );
5347
5348        registry.cleanup_finished(Duration::ZERO);
5349
5350        assert!(registry.inner.tasks.lock().unwrap().is_empty());
5351    }
5352
5353    #[test]
5354    fn cleanup_finished_retains_undelivered_terminals() {
5355        let registry = BgTaskRegistry::default();
5356        let dir = tempfile::tempdir().unwrap();
5357        let task_id = registry
5358            .spawn(
5359                QUICK_SUCCESS_COMMAND,
5360                "session".to_string(),
5361                dir.path().to_path_buf(),
5362                HashMap::new(),
5363                Some(Duration::from_secs(30)),
5364                dir.path().to_path_buf(),
5365                10,
5366                true,
5367                false,
5368                Some(dir.path().to_path_buf()),
5369            )
5370            .unwrap();
5371        registry
5372            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5373            .unwrap();
5374
5375        registry.cleanup_finished(Duration::ZERO);
5376
5377        assert!(registry.inner.tasks.lock().unwrap().contains_key(&task_id));
5378    }
5379
5380    /// Verify that the live watchdog path (reap_child) gives an exited
5381    /// child one watchdog pass for its exit marker to land, then marks the
5382    /// task Failed if the next pass still sees no marker.
5383    ///
5384    /// Cross-platform: uses a quick-exiting command that does NOT go
5385    /// through the wrapper script (we manually clear the exit marker
5386    /// after spawn to simulate the wrapper crashing before write).
5387    #[test]
5388    fn reap_child_marks_failed_when_child_exits_without_exit_marker() {
5389        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5390        let dir = tempfile::tempdir().unwrap();
5391        let task_id = registry
5392            .spawn(
5393                QUICK_SUCCESS_COMMAND,
5394                "session".to_string(),
5395                dir.path().to_path_buf(),
5396                HashMap::new(),
5397                Some(Duration::from_secs(30)),
5398                dir.path().to_path_buf(),
5399                10,
5400                true,
5401                false,
5402                Some(dir.path().to_path_buf()),
5403            )
5404            .unwrap();
5405
5406        let task = registry.task_for_session(&task_id, "session").unwrap();
5407
5408        // Wait for the child to actually exit and the wrapper to either
5409        // write the marker or fail. Then nuke the marker to simulate
5410        // wrapper crash before write. Poll up to 5s; this is plenty for a
5411        // `true`/`cmd /c exit 0` invocation.
5412        let started = Instant::now();
5413        loop {
5414            let exited = {
5415                let mut state = task.state.lock().unwrap();
5416                match &mut state.runtime {
5417                    TaskRuntime::Piped(Some(child)) => matches!(child.try_wait(), Ok(Some(_))),
5418                    _ => true,
5419                }
5420            };
5421            if exited {
5422                break;
5423            }
5424            assert!(
5425                started.elapsed() < Duration::from_secs(5),
5426                "child should exit quickly"
5427            );
5428            std::thread::sleep(Duration::from_millis(20));
5429        }
5430
5431        // Stop the watchdog so it doesn't race with our manual reap_child.
5432        // On fast Windows runners the watchdog ticks (every 500ms) can
5433        // observe the child exit and reap it before this test's assertion
5434        // fires, leaving us with state.child = None and an already-terminal
5435        // status. We specifically want to test reap_child's logic when
5436        // invoked manually on a Running-but-actually-dead task, so we need
5437        // exclusive control over the reap path here.
5438        registry
5439            .inner
5440            .shutdown
5441            .store(true, std::sync::atomic::Ordering::SeqCst);
5442        // Give the watchdog at most one tick (500ms) to notice shutdown
5443        // before we touch task state. Without this, an in-flight watchdog
5444        // iteration could still race with our state setup below.
5445        std::thread::sleep(Duration::from_millis(550));
5446
5447        // Wrapper likely wrote the marker by now; remove it to simulate
5448        // a wrapper crash that exited before persisting the exit code.
5449        let _ = std::fs::remove_file(&task.paths.exit);
5450
5451        // The watchdog may have already reaped the child handle and
5452        // marked the task terminal before we got here. Reset both so
5453        // reap_child has the "Running task whose child just exited"
5454        // shape it's designed to handle. If the original child handle is
5455        // gone, install a quick-exited stand-in so the first reap exercises
5456        // the same try_wait path as production.
5457        //
5458        // CRITICAL on Windows: the watchdog ticks fast enough that the
5459        // JSON on disk may already say `Completed`. `update_task` (called
5460        // by `reap_child`) reads from disk, applies the closure, but
5461        // ROLLS BACK if the original on-disk state was already terminal
5462        // (see persistence.rs::update_task). So we must reset BOTH
5463        // in-memory metadata AND the JSON on disk to a Running state to
5464        // give reap_child the fresh shape it expects to operate on.
5465        {
5466            let mut state = task.state.lock().unwrap();
5467            state.metadata.status = BgTaskStatus::Running;
5468            state.metadata.status_reason = None;
5469            state.metadata.exit_code = None;
5470            state.metadata.finished_at = None;
5471            state.metadata.duration_ms = None;
5472            // Persist the reset state to disk so update_task's terminal
5473            // rollback guard sees a non-terminal starting point.
5474            crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5475                .expect("persist reset Running metadata for reap_child test");
5476            // If the watchdog already nulled state.child, we need to
5477            // simulate "child exists and is dead" so reap_child's
5478            // try_wait path runs. Spawn a quick-exit child as a stand-in.
5479            if matches!(state.runtime, TaskRuntime::Piped(None)) {
5480                state.runtime = TaskRuntime::Piped(Some(spawn_dead_child()));
5481            }
5482        }
5483        // Clear the terminal_at marker too so mark_terminal_now() can fire
5484        // again inside reap_child.
5485        *task.terminal_at.lock().unwrap() = None;
5486
5487        // Sanity: task is still Running per metadata (replay/poll hasn't
5488        // observed the missing marker yet).
5489        assert!(
5490            task.is_running(),
5491            "precondition: metadata.status == Running"
5492        );
5493        assert!(
5494            !task.paths.exit.exists(),
5495            "precondition: exit marker absent"
5496        );
5497
5498        // First watchdog observation is intentionally insufficient to
5499        // declare failure. A missing marker may just mean the wrapper is
5500        // still completing its tmp-file-to-marker rename, so reap_child only
5501        // drops the child handle and switches to detached PID monitoring.
5502        registry.reap_child(&task);
5503
5504        {
5505            let state = task.state.lock().unwrap();
5506            assert_eq!(
5507                state.metadata.status,
5508                BgTaskStatus::Running,
5509                "first reap must leave status Running while waiting one pass for marker"
5510            );
5511            assert_eq!(
5512                state.metadata.status_reason, None,
5513                "first reap must not record a failure reason"
5514            );
5515            assert!(
5516                matches!(state.runtime, TaskRuntime::Piped(None)),
5517                "child handle must be released after first reap"
5518            );
5519            assert!(
5520                state.detached,
5521                "task must be marked detached after first reap"
5522            );
5523        }
5524
5525        // Second watchdog observation sees the detached PID is dead and the
5526        // marker is still absent. That is strong enough evidence that the
5527        // wrapper exited without persisting an exit code.
5528        registry.reap_child(&task);
5529
5530        let state = task.state.lock().unwrap();
5531        assert!(
5532            state.metadata.status.is_terminal(),
5533            "second reap must transition to terminal when PID dead and no marker. Got status={:?}",
5534            state.metadata.status
5535        );
5536        assert_eq!(
5537            state.metadata.status,
5538            BgTaskStatus::Failed,
5539            "must specifically be Failed (not Killed): status={:?}",
5540            state.metadata.status
5541        );
5542        assert_eq!(
5543            state.metadata.status_reason.as_deref(),
5544            Some("process exited without exit marker"),
5545            "reason must match replay path's wording: {:?}",
5546            state.metadata.status_reason
5547        );
5548        assert!(
5549            matches!(state.runtime, TaskRuntime::Piped(None)),
5550            "child handle must stay released after second reap"
5551        );
5552        assert!(
5553            state.detached,
5554            "task must remain detached after second reap"
5555        );
5556    }
5557
5558    /// Companion to the above: when the exit marker DOES exist on disk
5559    /// at reap_child time, reap_child must NOT mark the task Failed.
5560    /// Instead it leaves status=Running and lets the next poll_task()
5561    /// cycle finalize via the marker.
5562    #[test]
5563    fn reap_child_preserves_running_when_exit_marker_exists() {
5564        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5565        let dir = tempfile::tempdir().unwrap();
5566        let task_id = registry
5567            .spawn(
5568                QUICK_SUCCESS_COMMAND,
5569                "session".to_string(),
5570                dir.path().to_path_buf(),
5571                HashMap::new(),
5572                Some(Duration::from_secs(30)),
5573                dir.path().to_path_buf(),
5574                10,
5575                true,
5576                false,
5577                Some(dir.path().to_path_buf()),
5578            )
5579            .unwrap();
5580
5581        let task = registry.task_for_session(&task_id, "session").unwrap();
5582
5583        // Wait for child to exit AND for the marker to land. Both happen
5584        // shortly after the wrapper finishes — but we want both observed.
5585        let started = Instant::now();
5586        loop {
5587            let exited = {
5588                let mut state = task.state.lock().unwrap();
5589                match &mut state.runtime {
5590                    TaskRuntime::Piped(Some(child)) => matches!(child.try_wait(), Ok(Some(_))),
5591                    _ => true,
5592                }
5593            };
5594            if exited && task.paths.exit.exists() {
5595                break;
5596            }
5597            assert!(
5598                started.elapsed() < Duration::from_secs(5),
5599                "child should exit and write marker quickly"
5600            );
5601            std::thread::sleep(Duration::from_millis(20));
5602        }
5603
5604        // Stop the watchdog so it doesn't race with our manual reap_child.
5605        // On fast Windows runners the watchdog can call poll_task (which
5606        // finalizes via marker) before this test asserts the
5607        // "marker exists, status still Running" invariant. We want
5608        // exclusive control over the reap path.
5609        registry
5610            .inner
5611            .shutdown
5612            .store(true, std::sync::atomic::Ordering::SeqCst);
5613        std::thread::sleep(Duration::from_millis(550));
5614
5615        // If the watchdog already finalized the task before we stopped it,
5616        // restore the test setup: reset status to Running and ensure the
5617        // marker file is still on disk. We're testing reap_child's
5618        // behavior when called manually with both child-exited AND
5619        // marker-present, regardless of whether the watchdog beat us.
5620        {
5621            let mut state = task.state.lock().unwrap();
5622            state.metadata.status = BgTaskStatus::Running;
5623            state.metadata.status_reason = None;
5624            if matches!(state.runtime, TaskRuntime::Piped(None)) {
5625                state.runtime = TaskRuntime::Piped(Some(spawn_dead_child()));
5626            }
5627        }
5628        *task.terminal_at.lock().unwrap() = None;
5629        // Make sure the marker is still on disk (poll_task removes it on
5630        // finalization). Recreate it if needed.
5631        if !task.paths.exit.exists() {
5632            std::fs::write(&task.paths.exit, "0").expect("write replacement exit marker");
5633        }
5634
5635        // reap_child sees: child exited, marker exists. It should:
5636        //  - drop state.child / set state.detached = true
5637        //  - NOT change status (poll_task will finalize via marker next tick)
5638        registry.reap_child(&task);
5639
5640        let state = task.state.lock().unwrap();
5641        assert!(
5642            matches!(state.runtime, TaskRuntime::Piped(None)),
5643            "child handle still released even when marker exists"
5644        );
5645        assert!(
5646            state.detached,
5647            "task still marked detached even when marker exists"
5648        );
5649        // Status remains Running because reap_child defers to poll_task
5650        // when a marker exists. It would be wrong for reap to record the
5651        // marker outcome (poll_task does that with proper exit-code
5652        // parsing).
5653        assert_eq!(
5654            state.metadata.status,
5655            BgTaskStatus::Running,
5656            "reap_child must defer to poll_task when marker exists"
5657        );
5658    }
5659
5660    /// Read a process's `ps` state string ("Z", "S", "R", etc). Returns
5661    /// `None` once the PID has been fully reaped (no row), which is the
5662    /// post-reap state we want.
5663    #[cfg(unix)]
5664    fn pid_stat(pid: u32) -> Option<String> {
5665        let output = std::process::Command::new("ps")
5666            .args(["-o", "stat=", "-p", &pid.to_string()])
5667            .output()
5668            .ok()?;
5669        if !output.status.success() {
5670            return None;
5671        }
5672        let stat = String::from_utf8_lossy(&output.stdout).trim().to_string();
5673        if stat.is_empty() {
5674            None
5675        } else {
5676            Some(stat)
5677        }
5678    }
5679
5680    /// A `<defunct>` zombie carries `ps` state starting with 'Z'.
5681    #[cfg(unix)]
5682    fn is_zombie(pid: u32) -> bool {
5683        pid_stat(pid).is_some_and(|stat| stat.starts_with('Z'))
5684    }
5685
5686    /// Spawn a child that exits immediately and wait — via `ps`, NOT
5687    /// `try_wait()`/`wait()` — until it is observably a `<defunct>` zombie,
5688    /// then return the still-unreaped handle. This reproduces the exact
5689    /// state issue #91 leaves behind: an exited OS child whose parent has
5690    /// not reaped it.
5691    #[cfg(unix)]
5692    fn spawn_unreaped_zombie() -> std::process::Child {
5693        let child = std::process::Command::new("true")
5694            .stdin(std::process::Stdio::null())
5695            .stdout(std::process::Stdio::null())
5696            .stderr(std::process::Stdio::null())
5697            .spawn()
5698            .expect("spawn zombie stand-in");
5699        let pid = child.id();
5700        let started = Instant::now();
5701        while !is_zombie(pid) {
5702            assert!(
5703                started.elapsed() < Duration::from_secs(5),
5704                "stand-in child should become a zombie within 5s"
5705            );
5706            std::thread::sleep(Duration::from_millis(10));
5707        }
5708        // Return WITHOUT reaping — the handle still owns an unwaited zombie.
5709        child
5710    }
5711
5712    /// Regression test for issue #91: the exit-marker terminal path
5713    /// (`poll_task` -> `finalize_from_marker`) must REAP the direct child
5714    /// handle, not merely drop it. Dropping a `std::process::Child` does not
5715    /// `wait()` on Unix, so the exited child lingers as a `[mv] <defunct>`
5716    /// zombie until AFT exits.
5717    ///
5718    /// We install a known-unreaped zombie into the task's child slot and
5719    /// drive the marker finalize path, then assert the child is gone (reaped)
5720    /// rather than still `<defunct>`.
5721    #[cfg(unix)]
5722    #[test]
5723    fn finalize_from_marker_reaps_child_no_zombie() {
5724        use std::sync::atomic::Ordering;
5725
5726        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5727        let dir = tempfile::tempdir().unwrap();
5728        let task_id = registry
5729            .spawn(
5730                QUICK_SUCCESS_COMMAND,
5731                "session".to_string(),
5732                dir.path().to_path_buf(),
5733                HashMap::new(),
5734                Some(Duration::from_secs(30)),
5735                dir.path().to_path_buf(),
5736                10,
5737                true,
5738                false,
5739                Some(dir.path().to_path_buf()),
5740            )
5741            .unwrap();
5742
5743        // Stop the watchdog so the ONLY terminal-transition path under test
5744        // is the exit-marker finalize (not reap_child's try_wait, which would
5745        // reap the child for us and mask the bug).
5746        registry.inner.shutdown.store(true, Ordering::SeqCst);
5747        std::thread::sleep(Duration::from_millis(550));
5748
5749        let task = registry.task_for_session(&task_id, "session").unwrap();
5750
5751        // Wait for the wrapper's exit marker to land. We deliberately do NOT
5752        // call try_wait()/wait() on the real child here — doing so would reap
5753        // it and defeat the test.
5754        let started = Instant::now();
5755        while !task.paths.exit.exists() {
5756            assert!(
5757                started.elapsed() < Duration::from_secs(5),
5758                "exit marker should land quickly for `true`"
5759            );
5760            std::thread::sleep(Duration::from_millis(20));
5761        }
5762
5763        // Reset to a fresh Running shape and install a guaranteed-unreaped
5764        // zombie as the child handle, so the finalize path's reap behavior is
5765        // exercised deterministically regardless of how the real child was
5766        // handled. Persist Running so update_task's terminal-rollback guard
5767        // sees a non-terminal starting point.
5768        let zombie_pid;
5769        {
5770            let mut state = task.state.lock().unwrap();
5771            state.metadata.status = BgTaskStatus::Running;
5772            state.metadata.status_reason = None;
5773            state.metadata.exit_code = None;
5774            state.metadata.finished_at = None;
5775            state.metadata.duration_ms = None;
5776            crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5777                .expect("persist reset Running metadata");
5778            let zombie = spawn_unreaped_zombie();
5779            zombie_pid = zombie.id();
5780            state.runtime = TaskRuntime::Piped(Some(zombie));
5781        }
5782        *task.terminal_at.lock().unwrap() = None;
5783
5784        // Precondition: the installed child is genuinely a `<defunct>` zombie.
5785        assert!(
5786            is_zombie(zombie_pid),
5787            "precondition: stand-in child {zombie_pid} must be a zombie before finalize"
5788        );
5789
5790        // Drive the exit-marker terminal path. Before the fix this nulled the
5791        // Child handle without wait(), leaving the zombie behind.
5792        registry.poll_task(&task).unwrap();
5793
5794        {
5795            let state = task.state.lock().unwrap();
5796            assert!(
5797                matches!(state.runtime, TaskRuntime::Piped(None)),
5798                "child handle must be released after marker finalize"
5799            );
5800            assert!(
5801                state.metadata.status.is_terminal(),
5802                "task must be terminal after marker finalize: {:?}",
5803                state.metadata.status
5804            );
5805        }
5806
5807        // The core assertion: the child must have been REAPED, not just
5808        // dropped. A reaped PID has no `ps` row (or at minimum is not 'Z').
5809        assert!(
5810            !is_zombie(zombie_pid),
5811            "issue #91 regression: child {zombie_pid} left as <defunct> zombie \
5812             after the exit-marker terminal transition"
5813        );
5814    }
5815
5816    /// Companion to the above for the kill path: when a kill observes an
5817    /// already-present exit marker (the child finished on its own first), it
5818    /// must reap the child handle rather than dropping it.
5819    #[cfg(unix)]
5820    #[test]
5821    fn kill_with_existing_marker_reaps_child_no_zombie() {
5822        use std::sync::atomic::Ordering;
5823
5824        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5825        let dir = tempfile::tempdir().unwrap();
5826        let task_id = registry
5827            .spawn(
5828                QUICK_SUCCESS_COMMAND,
5829                "session".to_string(),
5830                dir.path().to_path_buf(),
5831                HashMap::new(),
5832                Some(Duration::from_secs(30)),
5833                dir.path().to_path_buf(),
5834                10,
5835                true,
5836                false,
5837                Some(dir.path().to_path_buf()),
5838            )
5839            .unwrap();
5840
5841        registry.inner.shutdown.store(true, Ordering::SeqCst);
5842        std::thread::sleep(Duration::from_millis(550));
5843
5844        let task = registry.task_for_session(&task_id, "session").unwrap();
5845
5846        let started = Instant::now();
5847        while !task.paths.exit.exists() {
5848            assert!(
5849                started.elapsed() < Duration::from_secs(5),
5850                "exit marker should land quickly for `true`"
5851            );
5852            std::thread::sleep(Duration::from_millis(20));
5853        }
5854
5855        let zombie_pid;
5856        {
5857            let mut state = task.state.lock().unwrap();
5858            state.metadata.status = BgTaskStatus::Running;
5859            state.metadata.status_reason = None;
5860            state.metadata.exit_code = None;
5861            state.metadata.finished_at = None;
5862            state.metadata.duration_ms = None;
5863            crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5864                .expect("persist reset Running metadata");
5865            let zombie = spawn_unreaped_zombie();
5866            zombie_pid = zombie.id();
5867            state.runtime = TaskRuntime::Piped(Some(zombie));
5868        }
5869        *task.terminal_at.lock().unwrap() = None;
5870
5871        assert!(
5872            is_zombie(zombie_pid),
5873            "precondition: stand-in child {zombie_pid} must be a zombie before kill"
5874        );
5875
5876        // Kill observes the existing marker and finalizes from it.
5877        registry
5878            .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5879            .expect("kill should succeed");
5880
5881        {
5882            let state = task.state.lock().unwrap();
5883            assert!(
5884                matches!(state.runtime, TaskRuntime::Piped(None)),
5885                "child handle must be released after marker-aware kill"
5886            );
5887            assert!(state.metadata.status.is_terminal());
5888        }
5889
5890        assert!(
5891            !is_zombie(zombie_pid),
5892            "issue #91 regression: child {zombie_pid} left as <defunct> zombie \
5893             after a marker-aware kill"
5894        );
5895    }
5896
5897    #[test]
5898    fn cleanup_finished_keeps_running_tasks() {
5899        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5900        let dir = tempfile::tempdir().unwrap();
5901        let task_id = registry
5902            .spawn(
5903                LONG_RUNNING_COMMAND,
5904                "session".to_string(),
5905                dir.path().to_path_buf(),
5906                HashMap::new(),
5907                Some(Duration::from_secs(30)),
5908                dir.path().to_path_buf(),
5909                10,
5910                true,
5911                false,
5912                Some(dir.path().to_path_buf()),
5913            )
5914            .unwrap();
5915
5916        registry.cleanup_finished(Duration::ZERO);
5917
5918        assert!(registry.inner.tasks.lock().unwrap().contains_key(&task_id));
5919        let _ = registry.kill(&task_id, "session");
5920    }
5921
5922    #[cfg(windows)]
5923    fn wait_for_file(path: &Path) -> String {
5924        let started = Instant::now();
5925        loop {
5926            if path.exists() {
5927                return fs::read_to_string(path).expect("read file");
5928            }
5929            assert!(
5930                started.elapsed() < Duration::from_secs(30),
5931                "timed out waiting for {}",
5932                path.display()
5933            );
5934            std::thread::sleep(Duration::from_millis(100));
5935        }
5936    }
5937
5938    #[cfg(windows)]
5939    fn spawn_windows_registry_command(
5940        command: &str,
5941    ) -> (BgTaskRegistry, tempfile::TempDir, String) {
5942        let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5943        let dir = tempfile::tempdir().unwrap();
5944        let task_id = registry
5945            .spawn(
5946                command,
5947                "session".to_string(),
5948                dir.path().to_path_buf(),
5949                HashMap::new(),
5950                Some(Duration::from_secs(30)),
5951                dir.path().to_path_buf(),
5952                10,
5953                false,
5954                false,
5955                Some(dir.path().to_path_buf()),
5956            )
5957            .unwrap();
5958        (registry, dir, task_id)
5959    }
5960
5961    #[cfg(windows)]
5962    #[test]
5963    fn windows_spawn_writes_exit_marker_for_zero_exit() {
5964        let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c exit 0");
5965        let exit_path = registry.task_exit_path(&task_id, "session").unwrap();
5966
5967        let content = wait_for_file(&exit_path);
5968
5969        assert_eq!(content.trim(), "0");
5970    }
5971
5972    #[cfg(windows)]
5973    #[test]
5974    fn windows_spawn_writes_exit_marker_for_nonzero_exit() {
5975        let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c exit 42");
5976        let exit_path = registry.task_exit_path(&task_id, "session").unwrap();
5977
5978        let content = wait_for_file(&exit_path);
5979
5980        assert_eq!(content.trim(), "42");
5981    }
5982
5983    #[cfg(windows)]
5984    #[test]
5985    fn windows_spawn_captures_stdout_to_disk() {
5986        let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c echo hello");
5987        let task = registry.task_for_session(&task_id, "session").unwrap();
5988        let stdout_path = task.paths.stdout.clone();
5989        let exit_path = task.paths.exit.clone();
5990
5991        let _ = wait_for_file(&exit_path);
5992        let stdout = fs::read_to_string(stdout_path).expect("read stdout");
5993
5994        assert!(stdout.contains("hello"), "stdout was {stdout:?}");
5995    }
5996
5997    #[cfg(windows)]
5998    #[test]
5999    fn windows_spawn_uses_pwsh_when_available() {
6000        // Without $SHELL set, $SHELL probe yields None and pwsh wins.
6001        // (We intentionally pass None for shell_env to keep this test
6002        // independent of the runner's actual env.)
6003        let candidates = crate::windows_shell::shell_candidates_with(
6004            |binary| match binary {
6005                "pwsh.exe" => Some(std::path::PathBuf::from(r"C:\pwsh\pwsh.exe")),
6006                "powershell.exe" => Some(std::path::PathBuf::from(r"C:\ps\powershell.exe")),
6007                _ => None,
6008            },
6009            || None,
6010        );
6011        let shell = candidates.first().expect("at least one candidate").clone();
6012        assert_eq!(shell, crate::windows_shell::WindowsShell::Pwsh);
6013        assert_eq!(shell.binary().as_ref(), "pwsh.exe");
6014    }
6015
6016    /// Issue #27 Oracle review P1, updated: cmd wrapper writes a `.bat` file
6017    /// that batch-evaluates `%ERRORLEVEL%` on its own line (line-by-line
6018    /// evaluation is the default for batch files; parse-time expansion only
6019    /// applies to compound `&`-chained inline commands). Capturing
6020    /// `%ERRORLEVEL%` into `set CODE=%ERRORLEVEL%` immediately after the user
6021    /// command runs records the real run-time exit code.
6022    #[cfg(windows)]
6023    #[test]
6024    fn windows_shell_cmd_wrapper_writes_exit_marker_with_move() {
6025        let exit_path = Path::new(r"C:\Temp\bash-test.exit");
6026        let script =
6027            crate::windows_shell::WindowsShell::Cmd.wrapper_script("cmd /c exit 42", exit_path);
6028
6029        // Batch wrapper: capture exit code into CODE on the line after the
6030        // user command, then write CODE to a temp marker file before
6031        // atomic-renaming it into place.
6032        assert!(
6033            script.contains("set CODE=%ERRORLEVEL%"),
6034            "wrapper must capture exit code into CODE: {script}"
6035        );
6036        assert!(
6037            script.contains("echo %CODE% >"),
6038            "wrapper must echo CODE to a temp marker file: {script}"
6039        );
6040        assert!(
6041            script.contains("move /Y"),
6042            "wrapper must use atomic move to write the marker: {script}"
6043        );
6044        // move output must be redirected to nul to avoid polluting the
6045        // user's captured stdout with "1 file(s) moved." lines.
6046        assert!(
6047            script.contains("> nul"),
6048            "wrapper must redirect move output to nul: {script}"
6049        );
6050        // exit /B %CODE% propagates the real exit code so wait() sees it.
6051        assert!(
6052            script.contains("exit /B %CODE%"),
6053            "wrapper must propagate the captured exit code: {script}"
6054        );
6055        assert!(script.contains(r#""C:\Temp\bash-test.exit.tmp""#));
6056        assert!(script.contains(r#""C:\Temp\bash-test.exit""#));
6057    }
6058
6059    /// `bg_command()` for Cmd no longer needs `/V:ON` — the wrapper is now
6060    /// written to a `.bat` file where batch-line evaluation captures
6061    /// `%ERRORLEVEL%` correctly without delayed expansion. We still need
6062    /// `/D` (skip AutoRun) and `/S` (simple quote-stripping for paths with
6063    /// internal `"`-quoting from `cmd_quote`).
6064    #[cfg(windows)]
6065    #[test]
6066    fn windows_shell_cmd_bg_command_uses_minimal_cmd_flags() {
6067        use crate::windows_shell::WindowsShell;
6068        let cmd = WindowsShell::Cmd.bg_command("echo wrapped");
6069        let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
6070        let args_strs: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
6071        assert_eq!(
6072            args_strs,
6073            vec!["/D", "/S", "/C", "echo wrapped"],
6074            "Cmd::bg_command must prepend /D /S /C"
6075        );
6076    }
6077
6078    /// PowerShell variants don't need `/V:ON`-style flags; their
6079    /// `bg_command()` args stay on the standard `-Command` path.
6080    #[cfg(windows)]
6081    #[test]
6082    fn windows_shell_pwsh_bg_command_uses_standard_args() {
6083        use crate::windows_shell::WindowsShell;
6084        let cmd = WindowsShell::Pwsh.bg_command("Get-Date");
6085        let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
6086        let args_strs: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
6087        assert!(
6088            args_strs.contains(&"-Command"),
6089            "Pwsh::bg_command must use -Command: {args_strs:?}"
6090        );
6091        assert!(
6092            args_strs.contains(&"Get-Date"),
6093            "Pwsh::bg_command must include the user command body"
6094        );
6095    }
6096}