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};
49const 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 #[serde(skip_serializing)]
64 pub session_id: String,
65 pub status: BgTaskStatus,
66 pub exit_code: Option<i32>,
67 pub command: String,
68 #[serde(default, skip_serializing_if = "String::is_empty")]
74 pub output_preview: String,
75 #[serde(default, skip_serializing_if = "is_false")]
80 pub output_truncated: bool,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub original_tokens: Option<u32>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub compressed_tokens: Option<u32>,
89 #[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 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 pub(crate) child_exit_observed: bool,
273 pub(crate) buffer: BgBuffer,
274 terminal_output_cache: Option<TerminalOutputCache>,
275 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if metadata.completion_delivered {
2513 return;
2514 }
2515
2516 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 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 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 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 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 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 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 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 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
3749fn read_file_tail_capped(path: &Path, max_bytes: usize) -> std::io::Result<Vec<u8>> {
3754 use std::io::{Read, Seek, SeekFrom};
3755 let mut file = std::fs::File::open(path)?;
3756 let len = file.metadata()?.len();
3757 let read_len = len.min(max_bytes as u64);
3758 if read_len > 0 && len > max_bytes as u64 {
3759 file.seek(SeekFrom::End(-(read_len as i64)))?;
3760 }
3761 let mut bytes = Vec::with_capacity(read_len as usize);
3762 file.read_to_end(&mut bytes)?;
3763 Ok(bytes)
3764}
3765
3766impl BgTask {
3767 fn snapshot(&self, preview_bytes: usize) -> BgTaskSnapshot {
3768 let state = self
3769 .state
3770 .lock()
3771 .unwrap_or_else(|poison| poison.into_inner());
3772 self.snapshot_locked(&state, preview_bytes)
3773 }
3774
3775 fn snapshot_locked(&self, state: &BgTaskState, preview_bytes: usize) -> BgTaskSnapshot {
3776 let metadata = &state.metadata;
3777 let duration_ms = metadata.duration_ms.or_else(|| {
3778 metadata
3779 .status
3780 .is_terminal()
3781 .then(|| self.started.elapsed().as_millis() as u64)
3782 });
3783 let (output_preview, output_truncated) = if metadata.mode == BgMode::Pty {
3784 (String::new(), false)
3785 } else if metadata.status.is_terminal() {
3786 state
3787 .terminal_output_cache
3788 .as_ref()
3789 .map(|cache| (cache.output_preview.clone(), cache.output_truncated))
3790 .unwrap_or_else(|| (String::new(), false))
3791 } else if preview_bytes == 0 {
3792 (String::new(), false)
3793 } else {
3794 state.buffer.read_tail(preview_bytes)
3795 };
3796 BgTaskSnapshot {
3797 info: BgTaskInfo {
3798 task_id: self.task_id.clone(),
3799 status: metadata.status.clone(),
3800 command: metadata.command.clone(),
3801 mode: metadata.mode.clone(),
3802 started_at: metadata.started_at,
3803 duration_ms,
3804 },
3805 exit_code: metadata.exit_code,
3806 child_pid: metadata.child_pid,
3807 workdir: metadata.workdir.display().to_string(),
3808 output_preview,
3809 output_truncated,
3810 output_path: state
3811 .buffer
3812 .output_path()
3813 .map(|path| path.display().to_string()),
3814 stderr_path: state
3815 .buffer
3816 .stderr_path()
3817 .map(|path| path.display().to_string()),
3818 pty_rows: (metadata.mode == BgMode::Pty).then_some(metadata.pty_rows.unwrap_or(24)),
3819 pty_cols: (metadata.mode == BgMode::Pty).then_some(metadata.pty_cols.unwrap_or(80)),
3820 pty_screen: None,
3821 }
3822 }
3823
3824 pub(crate) fn is_running(&self) -> bool {
3825 self.state
3826 .lock()
3827 .map(|state| {
3828 state.metadata.status == BgTaskStatus::Running
3829 || (state.metadata.mode == BgMode::Pty
3830 && state.metadata.status == BgTaskStatus::Killing)
3831 })
3832 .unwrap_or(false)
3833 }
3834
3835 fn is_terminal(&self) -> bool {
3836 self.state
3837 .lock()
3838 .map(|state| state.metadata.status.is_terminal())
3839 .unwrap_or(false)
3840 }
3841
3842 fn mark_terminal_now(&self) {
3843 if let Ok(mut terminal_at) = self.terminal_at.lock() {
3844 if terminal_at.is_none() {
3845 *terminal_at = Some(Instant::now());
3846 }
3847 }
3848 }
3849
3850 fn set_completion_delivered(
3851 &self,
3852 delivered: bool,
3853 registry: &BgTaskRegistry,
3854 ) -> Result<(), String> {
3855 let mut state = self
3856 .state
3857 .lock()
3858 .map_err(|_| "background task lock poisoned".to_string())?;
3859 let updated = registry
3860 .update_task_metadata(&self.paths, |metadata| {
3861 metadata.completion_delivered = delivered;
3862 })
3863 .map_err(|e| format!("failed to update completion delivery: {e}"))?;
3864 state.metadata = updated;
3865 Ok(())
3866 }
3867}
3868
3869#[cfg(unix)]
3890fn reap_piped_child(child_slot: &mut Option<Child>) {
3891 if let Some(mut child) = child_slot.take() {
3892 if matches!(child.try_wait(), Ok(None)) {
3893 let _ = child.wait();
3894 }
3895 }
3896}
3897
3898#[cfg(windows)]
3903fn reap_piped_child(child_slot: &mut Option<Child>) {
3904 *child_slot = None;
3905}
3906
3907fn terminal_metadata_from_marker(
3908 mut metadata: PersistedTask,
3909 marker: ExitMarker,
3910 reason: Option<String>,
3911) -> PersistedTask {
3912 match marker {
3913 ExitMarker::Code(code) => {
3914 let status = if code == 0 {
3915 BgTaskStatus::Completed
3916 } else {
3917 BgTaskStatus::Failed
3918 };
3919 metadata.mark_terminal(status, Some(code), reason);
3920 }
3921 ExitMarker::Killed => metadata.mark_terminal(
3922 BgTaskStatus::Killed,
3923 terminal_exit_code_for_status(&BgTaskStatus::Killed),
3924 reason,
3925 ),
3926 }
3927 metadata
3928}
3929
3930fn terminal_exit_code_for_status(status: &BgTaskStatus) -> Option<i32> {
3931 match status {
3932 BgTaskStatus::TimedOut => Some(124),
3933 BgTaskStatus::Killed => Some(137),
3934 _ => None,
3935 }
3936}
3937
3938#[cfg(unix)]
3939fn write_unix_command_script(command: &str, paths: &TaskPaths) -> Result<PathBuf, String> {
3940 let stem = paths
3941 .json
3942 .file_stem()
3943 .and_then(|stem| stem.to_str())
3944 .unwrap_or("wrapper");
3945 let script_path = paths.dir.join(format!("{stem}.sh"));
3946 fs::write(&script_path, command)
3947 .map_err(|e| format!("failed to write background bash command script: {e}"))?;
3948 Ok(script_path)
3949}
3950
3951#[cfg(unix)]
3952fn detached_shell_command(command_script: &Path, exit_path: &Path) -> Command {
3953 let shell = resolve_posix_shell();
3954 let mut cmd = crate::effective_path::new_command(&shell);
3955 cmd.arg("-c")
3961 .arg(r#""$0" "$1"; code=$?; printf "%s" "$code" > "$2.tmp.$$"; mv -f "$2.tmp.$$" "$2""#)
3962 .arg(&shell)
3963 .arg(command_script)
3964 .arg(exit_path);
3965 unsafe {
3966 cmd.pre_exec(|| {
3967 if libc::setsid() == -1 {
3968 return Err(std::io::Error::last_os_error());
3969 }
3970 Ok(())
3971 });
3972 }
3973 cmd
3974}
3975
3976#[cfg(unix)]
3977fn resolve_posix_shell() -> PathBuf {
3978 static POSIX_SHELL: OnceLock<PathBuf> = OnceLock::new();
3979 POSIX_SHELL
3980 .get_or_init(|| {
3981 std::env::var_os("BASH")
3982 .filter(|value| !value.is_empty())
3983 .map(PathBuf::from)
3984 .filter(|path| path.exists())
3985 .or_else(|| which::which("bash").ok())
3986 .or_else(|| which::which("zsh").ok())
3987 .unwrap_or_else(|| PathBuf::from("/bin/sh"))
3988 })
3989 .clone()
3990}
3991
3992#[cfg(windows)]
3993fn detached_shell_command_for(
3994 shell: crate::windows_shell::WindowsShell,
3995 command: &str,
3996 exit_path: &Path,
3997 paths: &TaskPaths,
3998 creation_flags: u32,
3999) -> Result<Command, String> {
4000 use crate::windows_shell::WindowsShell;
4001 let wrapper_body = shell.wrapper_script_bytes(command, exit_path);
4014 let wrapper_ext = match shell {
4015 WindowsShell::Pwsh | WindowsShell::Powershell => "ps1",
4016 WindowsShell::Cmd => "bat",
4017 WindowsShell::Posix(_) => "sh",
4021 };
4022 let wrapper_path = paths.dir.join(format!(
4023 "{}.{}",
4024 paths
4025 .json
4026 .file_stem()
4027 .and_then(|s| s.to_str())
4028 .unwrap_or("wrapper"),
4029 wrapper_ext
4030 ));
4031 fs::write(&wrapper_path, wrapper_body)
4032 .map_err(|e| format!("failed to write background bash wrapper script: {e}"))?;
4033
4034 let mut cmd = Command::new(shell.binary().as_ref());
4035 match shell {
4036 WindowsShell::Pwsh | WindowsShell::Powershell => {
4037 cmd.args([
4040 "-NoLogo",
4041 "-NoProfile",
4042 "-NonInteractive",
4043 "-ExecutionPolicy",
4044 "Bypass",
4045 "-File",
4046 ]);
4047 cmd.arg(&wrapper_path);
4048 }
4049 WindowsShell::Cmd => {
4050 cmd.args(["/D", "/C"]);
4057 cmd.arg(&wrapper_path);
4058 }
4059 WindowsShell::Posix(_) => {
4060 cmd.arg(&wrapper_path);
4065 }
4066 }
4067
4068 cmd.creation_flags(creation_flags);
4072 Ok(cmd)
4073}
4074
4075fn spawn_detached_child(
4091 command: &str,
4092 paths: &TaskPaths,
4093 workdir: &Path,
4094 env: &HashMap<String, String>,
4095) -> Result<std::process::Child, String> {
4096 #[cfg(not(windows))]
4097 {
4098 let stdout = create_capture_file(&paths.stdout)
4099 .map_err(|e| format!("failed to open stdout capture file: {e}"))?;
4100 let stderr = create_capture_file(&paths.stderr)
4101 .map_err(|e| format!("failed to open stderr capture file: {e}"))?;
4102 let command_script = write_unix_command_script(command, paths)?;
4103 detached_shell_command(&command_script, &paths.exit)
4104 .current_dir(workdir)
4105 .envs(env)
4106 .stdin(Stdio::null())
4107 .stdout(Stdio::from(stdout))
4108 .stderr(Stdio::from(stderr))
4109 .spawn()
4110 .map_err(|e| format!("failed to spawn background bash command: {e}"))
4111 }
4112 #[cfg(windows)]
4113 {
4114 use crate::windows_shell::shell_candidates;
4115 let candidates: Vec<crate::windows_shell::WindowsShell> = shell_candidates();
4126 const FLAG_CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
4147 const FLAG_CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000;
4148 const FLAG_CREATE_NO_WINDOW: u32 = 0x0800_0000;
4149 let with_breakaway =
4150 FLAG_CREATE_NO_WINDOW | FLAG_CREATE_NEW_PROCESS_GROUP | FLAG_CREATE_BREAKAWAY_FROM_JOB;
4151 let without_breakaway = FLAG_CREATE_NO_WINDOW | FLAG_CREATE_NEW_PROCESS_GROUP;
4152 let mut last_error: Option<String> = None;
4153 for (idx, shell) in candidates.iter().enumerate() {
4154 for &flags in &[with_breakaway, without_breakaway] {
4158 let stdout = create_capture_file(&paths.stdout)
4160 .map_err(|e| format!("failed to open stdout capture file: {e}"))?;
4161 let stderr = create_capture_file(&paths.stderr)
4162 .map_err(|e| format!("failed to open stderr capture file: {e}"))?;
4163 let mut cmd =
4164 detached_shell_command_for(shell.clone(), command, &paths.exit, paths, flags)?;
4165 cmd.current_dir(workdir)
4166 .envs(env)
4167 .stdin(Stdio::null())
4168 .stdout(Stdio::from(stdout))
4169 .stderr(Stdio::from(stderr));
4170 match cmd.spawn() {
4171 Ok(child) => {
4172 if idx > 0 {
4173 crate::slog_warn!("background bash spawn fell back to {} after {} earlier candidate(s) failed; \
4174 the cached PATH probe disagreed with runtime spawn — likely PATH \
4175 inheritance, antivirus / AppLocker / Defender ASR, or sandbox policy.",
4176 shell.binary(),
4177 idx);
4178 }
4179 if flags == without_breakaway {
4180 crate::slog_warn!(
4181 "background bash spawn: CREATE_BREAKAWAY_FROM_JOB rejected \
4182 (likely a restrictive Job Object — CI sandbox or MDM policy). \
4183 Spawned without breakaway; the bg task will be torn down if the \
4184 AFT process group is killed."
4185 );
4186 }
4187 return Ok(child);
4188 }
4189 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
4190 crate::slog_warn!("background bash spawn: {} returned NotFound at runtime — trying next candidate",
4191 shell.binary());
4192 last_error = Some(format!("{}: {e}", shell.binary()));
4193 break;
4196 }
4197 Err(e) if flags == with_breakaway && e.raw_os_error() == Some(5) => {
4198 crate::slog_warn!(
4200 "background bash spawn: CREATE_BREAKAWAY_FROM_JOB rejected with \
4201 Access Denied — retrying {} without breakaway",
4202 shell.binary()
4203 );
4204 last_error = Some(format!("{}: {e}", shell.binary()));
4205 continue;
4206 }
4207 Err(e) => {
4208 return Err(format!(
4209 "failed to spawn background bash command via {}: {e}",
4210 shell.binary()
4211 ));
4212 }
4213 }
4214 }
4215 }
4216 Err(format!(
4217 "failed to spawn background bash command: no Windows shell could be spawned. \
4218 Last error: {}. PATH-probed candidates: {:?}",
4219 last_error.unwrap_or_else(|| "no candidates were attempted".to_string()),
4220 candidates.iter().map(|s| s.binary()).collect::<Vec<_>>()
4221 ))
4222 }
4223}
4224
4225fn random_slug() -> String {
4226 let mut bytes = [0u8; 8];
4234 getrandom::fill(&mut bytes).unwrap_or_else(|_| {
4236 let t = SystemTime::now()
4238 .duration_since(UNIX_EPOCH)
4239 .map(|d| d.as_nanos() as u64)
4240 .unwrap_or(0);
4241 let p = u64::from(std::process::id());
4242 bytes.copy_from_slice(&(t ^ p.rotate_left(32)).to_le_bytes());
4243 });
4244 let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
4246 format!("bash-{hex}")
4247}
4248
4249#[cfg(test)]
4250mod tests {
4251 use std::collections::HashMap;
4252 use std::fs;
4253 use std::sync::atomic::{AtomicBool, AtomicUsize};
4254 use std::sync::{Arc, Mutex};
4255 use std::time::Duration;
4256 #[cfg(windows)]
4257 use std::time::Instant;
4258
4259 use super::*;
4260
4261 #[cfg(unix)]
4262 const QUICK_SUCCESS_COMMAND: &str = "true";
4263 #[cfg(windows)]
4264 const QUICK_SUCCESS_COMMAND: &str = "cmd /c exit 0";
4265
4266 #[cfg(unix)]
4267 const LONG_RUNNING_COMMAND: &str = "sleep 5";
4268 #[cfg(windows)]
4269 const LONG_RUNNING_COMMAND: &str = "cmd /c timeout /t 5 /nobreak > nul";
4270
4271 #[test]
4272 fn bash_memory_estimate_is_zero_when_empty_and_nonzero_for_completion_cache() {
4273 let registry = BgTaskRegistry::default();
4274 assert_eq!(registry.estimated_memory().estimated_bytes, Some(0));
4275 registry
4276 .inner
4277 .completions
4278 .lock()
4279 .unwrap()
4280 .push_back(BgCompletion {
4281 task_id: "bash-memory".to_string(),
4282 session_id: "session-memory".to_string(),
4283 status: BgTaskStatus::Completed,
4284 exit_code: Some(0),
4285 command: "printf memory".to_string(),
4286 output_preview: "resident completion output".to_string(),
4287 output_truncated: false,
4288 original_tokens: None,
4289 compressed_tokens: None,
4290 tokens_skipped: false,
4291 });
4292 let estimate = registry.estimated_memory();
4293 assert!(estimate.estimated_bytes.unwrap() > 0);
4294 assert_eq!(estimate.counts["completion_caches"], 1);
4295 assert_eq!(estimate.counts["sessions"], 1);
4296 }
4297
4298 #[test]
4299 fn gh_structured_detection_rejects_piped_commands() {
4300 assert!(is_gh_structured_command(
4301 "gh issue list --json number,title"
4302 ));
4303 assert!(is_gh_structured_command(
4304 "cd repo && gh issue list --json number,title"
4305 ));
4306
4307 assert!(!is_gh_structured_command(
4308 "gh issue list --json number,title | jq '.[]'"
4309 ));
4310 assert!(!is_gh_structured_command(
4311 "gh issue list --json number,title |"
4312 ));
4313 }
4314
4315 fn insert_terminal_piped_task(
4316 registry: &BgTaskRegistry,
4317 dir: &tempfile::TempDir,
4318 command: &str,
4319 stdout: &str,
4320 stderr: &str,
4321 compressed: bool,
4322 ) -> (String, Arc<BgTask>) {
4323 let task_id = format!("bash-test-{}", random_slug());
4324 let paths = task_paths(dir.path(), "session", &task_id);
4325 fs::create_dir_all(&paths.dir).unwrap();
4326 fs::write(&paths.stdout, stdout).unwrap();
4327 fs::write(&paths.stderr, stderr).unwrap();
4328 let mut metadata = PersistedTask::starting(
4329 task_id.clone(),
4330 "session".to_string(),
4331 command.to_string(),
4332 dir.path().to_path_buf(),
4333 Some(dir.path().to_path_buf()),
4334 Some(30_000),
4335 true,
4336 compressed,
4337 );
4338 metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4339 write_task(&paths.json, &metadata).unwrap();
4340 registry
4341 .insert_rehydrated_task(metadata, paths, true)
4342 .expect("insert terminal task");
4343 let task = registry.task_for_session(&task_id, "session").unwrap();
4344 (task_id, task)
4345 }
4346
4347 #[test]
4348 fn bash_zero_preview_running_status_skips_output_read_while_explicit_preview_reads() {
4349 let registry = BgTaskRegistry::default();
4350 let dir = tempfile::tempdir().unwrap();
4351 let task_id = format!("bash-test-{}", random_slug());
4352 let paths = task_paths(dir.path(), "session", &task_id);
4353 fs::create_dir_all(&paths.dir).unwrap();
4354 fs::write(&paths.stdout, "live output\n").unwrap();
4355 fs::write(&paths.stderr, "").unwrap();
4356 let stdout_path = paths.stdout.clone();
4357 let mut metadata = PersistedTask::starting(
4358 task_id.clone(),
4359 "session".to_string(),
4360 "sleep 60".to_string(),
4361 dir.path().to_path_buf(),
4362 Some(dir.path().to_path_buf()),
4363 Some(30_000),
4364 true,
4365 false,
4366 );
4367 metadata.status = BgTaskStatus::Running;
4368 write_task(&paths.json, &metadata).unwrap();
4369 registry
4370 .insert_rehydrated_task(metadata, paths, false)
4371 .expect("insert running task");
4372
4373 crate::bash_background::buffer::reset_tail_read_count(&stdout_path);
4374 for _ in 0..5 {
4375 let snapshot = registry
4376 .status(&task_id, "session", Some(dir.path()), Some(dir.path()), 0)
4377 .expect("running snapshot");
4378 assert_eq!(snapshot.info.status, BgTaskStatus::Running);
4379 assert!(snapshot.output_preview.is_empty());
4380 }
4381 assert_eq!(
4382 crate::bash_background::buffer::tail_read_count(&stdout_path),
4383 0
4384 );
4385
4386 let snapshot = registry
4387 .status(
4388 &task_id,
4389 "session",
4390 Some(dir.path()),
4391 Some(dir.path()),
4392 RUNNING_OUTPUT_PREVIEW_BYTES,
4393 )
4394 .expect("explicit running snapshot");
4395 assert_eq!(snapshot.output_preview, "live output\n");
4396 assert_eq!(
4397 crate::bash_background::buffer::tail_read_count(&stdout_path),
4398 1
4399 );
4400 }
4401
4402 #[test]
4403 fn artifact_read_capability_requires_exact_canonical_path_and_session() {
4404 let registry = BgTaskRegistry::default();
4405 let dir = tempfile::tempdir().unwrap();
4406 let (_task_id, task) = insert_terminal_piped_task(
4407 ®istry,
4408 &dir,
4409 "printf output",
4410 "stdout\n",
4411 "stderr\n",
4412 true,
4413 );
4414 fs::write(&task.paths.exit, "0\n").unwrap();
4415
4416 assert!(registry.is_session_owned_artifact_path("session", &task.paths.stdout));
4417 assert!(registry.is_session_owned_artifact_path("session", &task.paths.stderr));
4418 assert!(registry.is_session_owned_artifact_path("session", &task.paths.exit));
4419 assert!(!registry.is_session_owned_artifact_path("different-session", &task.paths.stdout));
4420 assert!(!registry.is_session_owned_artifact_path("session", &task.paths.json));
4421
4422 let unregistered = task.paths.dir.join("unregistered-output");
4423 fs::write(&unregistered, "not a task artifact\n").unwrap();
4424 assert!(!registry.is_session_owned_artifact_path("session", &unregistered));
4425 }
4426
4427 #[cfg(unix)]
4428 #[test]
4429 fn artifact_directory_symlink_does_not_create_a_prefix_exception() {
4430 let registry = BgTaskRegistry::default();
4431 let dir = tempfile::tempdir().unwrap();
4432 let project = dir.path().join("project");
4433 fs::create_dir_all(&project).unwrap();
4434 let (_task_id, task) =
4435 insert_terminal_piped_task(®istry, &dir, "printf output", "stdout\n", "", true);
4436 let link = project.join("task-artifacts");
4437 std::os::unix::fs::symlink(&task.paths.dir, &link).unwrap();
4438 let unregistered = task.paths.dir.join("unregistered-output");
4439 fs::write(&unregistered, "not registered\n").unwrap();
4440
4441 assert!(!registry.is_session_owned_artifact_path("session", &link));
4442 assert!(
4443 !registry.is_session_owned_artifact_path("session", &link.join("unregistered-output"))
4444 );
4445 assert!(registry.is_session_owned_artifact_path(
4446 "session",
4447 &link.join(task.paths.stdout.file_name().unwrap())
4448 ));
4449
4450 let outside = dir.path().join("outside-secret");
4451 fs::write(&outside, "must stay private\n").unwrap();
4452 fs::remove_file(&task.paths.stdout).unwrap();
4453 std::os::unix::fs::symlink(&outside, &task.paths.stdout).unwrap();
4454 assert!(!registry.is_session_owned_artifact_path("session", &task.paths.stdout));
4455 }
4456
4457 #[test]
4458 fn recovery_footer_uses_bash_status_when_artifact_is_not_registered() {
4459 let registry = BgTaskRegistry::default();
4460 let dir = tempfile::tempdir().unwrap();
4461 let task_id = "bash-unregistered-footer";
4462 let paths = task_paths(dir.path(), "session", task_id);
4463 fs::create_dir_all(&paths.dir).unwrap();
4464 fs::write(
4465 &paths.stdout,
4466 format!("{}tail\n", "output-line\n".repeat(2_000)),
4467 )
4468 .unwrap();
4469 fs::write(&paths.stderr, "").unwrap();
4470 let mut metadata = PersistedTask::starting(
4471 task_id.to_string(),
4472 "session".to_string(),
4473 "printf output".to_string(),
4474 dir.path().to_path_buf(),
4475 Some(dir.path().to_path_buf()),
4476 Some(30_000),
4477 true,
4478 true,
4479 );
4480 metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4481
4482 let cache = registry
4483 .render_terminal_output_from_paths(&metadata, &paths)
4484 .expect("terminal render");
4485
4486 assert!(cache
4487 .output_preview
4488 .contains("use bash_status({taskId: \"bash-unregistered-footer\"})"));
4489 assert!(!cache.output_preview.contains("full output: read "));
4490 }
4491
4492 fn insert_terminal_pty_task(
4493 registry: &BgTaskRegistry,
4494 dir: &tempfile::TempDir,
4495 pty_output: &str,
4496 ) -> (String, Arc<BgTask>) {
4497 let task_id = format!("bash-test-{}", random_slug());
4498 let paths = task_paths(dir.path(), "session", &task_id);
4499 fs::create_dir_all(&paths.dir).unwrap();
4500 fs::write(&paths.pty, pty_output).unwrap();
4501 let mut metadata = PersistedTask::starting(
4502 task_id.clone(),
4503 "session".to_string(),
4504 "python".to_string(),
4505 dir.path().to_path_buf(),
4506 Some(dir.path().to_path_buf()),
4507 Some(30_000),
4508 true,
4509 true,
4510 );
4511 metadata.mode = BgMode::Pty;
4512 metadata.mark_terminal(BgTaskStatus::Completed, Some(0), None);
4513 write_task(&paths.json, &metadata).unwrap();
4514 registry
4515 .insert_rehydrated_task(metadata, paths, true)
4516 .expect("insert terminal pty task");
4517 let task = registry.task_for_session(&task_id, "session").unwrap();
4518 (task_id, task)
4519 }
4520
4521 #[cfg(unix)]
4522 fn wait_for_terminal_snapshot(
4523 registry: &BgTaskRegistry,
4524 task_id: &str,
4525 session_id: &str,
4526 project: &Path,
4527 storage: &Path,
4528 ) -> BgTaskSnapshot {
4529 let started = Instant::now();
4530 loop {
4531 let snapshot = registry
4532 .status(task_id, session_id, Some(project), Some(storage), 4096)
4533 .expect("spawned task should be visible to status");
4534 if snapshot.info.status.is_terminal() {
4535 return snapshot;
4536 }
4537 assert!(
4538 started.elapsed() < Duration::from_secs(10),
4539 "timed out waiting for task {task_id} to finish; last status={:?}",
4540 snapshot.info.status
4541 );
4542 std::thread::sleep(Duration::from_millis(50));
4543 }
4544 }
4545
4546 fn write_running_project_task(storage: &Path, project: &Path, session: &str, task_id: &str) {
4547 let paths = task_paths(storage, session, task_id);
4548 let mut metadata = PersistedTask::starting(
4549 task_id.to_string(),
4550 session.to_string(),
4551 "sleep 60".to_string(),
4552 project.to_path_buf(),
4553 Some(project.to_path_buf()),
4554 Some(30_000),
4555 true,
4556 true,
4557 );
4558 metadata.status = BgTaskStatus::Running;
4559 write_task(&paths.json, &metadata).unwrap();
4560 fs::write(&paths.stdout, "still running\n").unwrap();
4561 fs::write(&paths.stderr, "").unwrap();
4562 }
4563
4564 #[test]
4565 fn status_replay_filters_same_session_by_project_root() {
4566 let project_a = tempfile::tempdir().unwrap();
4567 let project_b = tempfile::tempdir().unwrap();
4568 let storage = tempfile::tempdir().unwrap();
4569 let session = "shared-session";
4570 let task_id = "bash-project-a";
4571 write_running_project_task(storage.path(), project_a.path(), session, task_id);
4572
4573 let actor_b = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4574 assert!(actor_b
4575 .status(
4576 task_id,
4577 session,
4578 Some(project_b.path()),
4579 Some(storage.path()),
4580 1024,
4581 )
4582 .is_none());
4583 assert!(actor_b.task_for_session(task_id, session).is_none());
4584
4585 let actor_a = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4586 let snapshot = actor_a
4587 .status(
4588 task_id,
4589 session,
4590 Some(project_a.path()),
4591 Some(storage.path()),
4592 1024,
4593 )
4594 .expect("owning project should replay its task");
4595 assert_eq!(snapshot.info.status, BgTaskStatus::Running);
4596 }
4597
4598 #[cfg(unix)]
4599 #[test]
4600 fn multiline_pipeline_stdout_persists_all_lines_after_terminal_status() {
4601 let cases = [
4602 (
4603 "long-first",
4604 "sleep 0.5; printf 'one\\n' | cat\nprintf 'two\\n' | grep -c two\nprintf 'three\\n' | cat",
4605 vec!["one", "1", "three"],
4606 ),
4607 (
4608 "short-first",
4609 "printf 'one\\n' | cat\nsleep 0.2; printf 'two\\n' | grep -c two\nprintf 'three\\n' | cat",
4610 vec!["one", "1", "three"],
4611 ),
4612 (
4613 "failing-middle",
4614 "sleep 0.2; printf 'one\\n' | cat\nfalse; printf 'after-false\\n' | cat\nprintf 'three\\n' | cat",
4615 vec!["one", "after-false", "three"],
4616 ),
4617 ];
4618
4619 for (name, command, expected_lines) in cases {
4620 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
4621 let dir = tempfile::tempdir().unwrap();
4622 let session_id = format!("session-{name}");
4623 let task_id = registry
4624 .spawn(
4625 command,
4626 session_id.clone(),
4627 dir.path().to_path_buf(),
4628 HashMap::new(),
4629 Some(Duration::from_secs(30)),
4630 dir.path().to_path_buf(),
4631 10,
4632 true,
4633 true,
4634 Some(dir.path().to_path_buf()),
4635 )
4636 .unwrap();
4637
4638 let snapshot = wait_for_terminal_snapshot(
4639 ®istry,
4640 &task_id,
4641 &session_id,
4642 dir.path(),
4643 dir.path(),
4644 );
4645 assert_eq!(
4646 snapshot.info.status,
4647 BgTaskStatus::Completed,
4648 "{name}: task should complete; snapshot={snapshot:?}"
4649 );
4650 assert_eq!(
4651 snapshot.exit_code,
4652 Some(0),
4653 "{name}: script should use the final command's exit code"
4654 );
4655
4656 let stdout_path = task_paths(dir.path(), &session_id, &task_id).stdout;
4657 let stdout = fs::read_to_string(&stdout_path).unwrap_or_else(|error| {
4658 panic!(
4659 "{name}: failed to read raw stdout file {}: {error}",
4660 stdout_path.display()
4661 )
4662 });
4663 let lines: Vec<&str> = stdout.lines().collect();
4664 assert_eq!(
4665 lines,
4666 expected_lines,
4667 "{name}: raw stdout file must include every newline-separated command's output; stdout_path={}",
4668 stdout_path.display()
4669 );
4670 }
4671 }
4672
4673 #[test]
4674 fn recognizes_all_recovery_marker_forms() {
4675 assert!(is_recovery_marker(
4676 "[truncated output; full output: read \"/tmp/out\"]"
4677 ));
4678 assert!(is_recovery_marker(
4679 "[omitted output; see remaining: tail -n +42 \"/tmp/out\"]"
4680 ));
4681 assert!(is_recovery_marker(
4682 "[truncated output; full output unavailable]"
4683 ));
4684 assert!(is_recovery_marker(
4685 r#"[truncated 123 bytes from saved output prefix; retained output: read "/tmp/out"]"#
4686 ));
4687 }
4688
4689 #[test]
4690 fn recovery_marker_reports_disk_prefix_truncation_as_retained_output() {
4691 let recovery = RecoveryContext {
4692 dropped_by_class: BTreeMap::new(),
4693 had_inner_drop: false,
4694 offset_hint_eligible: false,
4695 offset_start_line: None,
4696 byte_truncated: false,
4697 disk_truncated_prefix_bytes: 4096,
4698 output_path: Some("/tmp/stdout".to_string()),
4699 stderr_path: None,
4700 include_stderr_path: false,
4701 artifact_access: ArtifactRecoveryAccess {
4702 task_id: "bash-test".to_string(),
4703 readable: true,
4704 },
4705 };
4706
4707 let marker = recovery_marker(&recovery).expect("disk truncation must emit marker");
4708
4709 assert!(marker.contains("truncated 4096 bytes from saved output prefix"));
4710 assert!(marker.contains(r#"retained output: read "/tmp/stdout""#));
4711 assert!(!marker.contains("full output: read"));
4712 }
4713
4714 #[test]
4715 fn killed_exit_marker_sets_nonzero_sentinel_exit_code() {
4716 let metadata = PersistedTask::starting(
4717 "task".to_string(),
4718 "session".to_string(),
4719 "cargo test".to_string(),
4720 PathBuf::from("/tmp"),
4721 None,
4722 None,
4723 true,
4724 true,
4725 );
4726
4727 let terminal = terminal_metadata_from_marker(metadata, ExitMarker::Killed, None);
4728
4729 assert_eq!(terminal.status, BgTaskStatus::Killed);
4730 assert_eq!(terminal.exit_code, Some(137));
4731 }
4732
4733 #[test]
4734 fn terminal_status_polls_use_cached_render_once_and_off_lock() {
4735 let registry = BgTaskRegistry::default();
4736 let dir = tempfile::tempdir().unwrap();
4737 let (_task_id, task) = insert_terminal_piped_task(
4738 ®istry,
4739 &dir,
4740 "custom-tool --verbose",
4741 &"stdout line\n".repeat(200_000),
4742 "",
4743 true,
4744 );
4745 let calls = Arc::new(AtomicUsize::new(0));
4746 let saw_unlocked_state = Arc::new(AtomicBool::new(false));
4747 let task_holder = Arc::new(Mutex::new(Some(Arc::clone(&task))));
4748 let calls_for_closure = Arc::clone(&calls);
4749 let unlocked_for_closure = Arc::clone(&saw_unlocked_state);
4750 let task_for_closure = Arc::clone(&task_holder);
4751 registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
4752 calls_for_closure.fetch_add(1, Ordering::SeqCst);
4753 if let Some(task) = task_for_closure.lock().unwrap().as_ref() {
4754 if task.state.try_lock().is_ok() {
4755 unlocked_for_closure.store(true, Ordering::SeqCst);
4756 }
4757 }
4758 CompressionResult::new(format!("compressed {} bytes", output.len()))
4759 });
4760
4761 let first = registry
4762 .status(
4763 &task.task_id,
4764 "session",
4765 None,
4766 Some(dir.path()),
4767 RUNNING_OUTPUT_PREVIEW_BYTES,
4768 )
4769 .unwrap();
4770 let second = registry
4771 .status(
4772 &task.task_id,
4773 "session",
4774 None,
4775 Some(dir.path()),
4776 RUNNING_OUTPUT_PREVIEW_BYTES,
4777 )
4778 .unwrap();
4779 let listed = registry.list(RUNNING_OUTPUT_PREVIEW_BYTES);
4780
4781 assert_eq!(
4782 calls.load(Ordering::SeqCst),
4783 1,
4784 "terminal render must be cached"
4785 );
4786 assert!(
4787 saw_unlocked_state.load(Ordering::SeqCst),
4788 "compressor must run after releasing the task state lock"
4789 );
4790 assert!(first.output_preview.starts_with("compressed "));
4791 assert_eq!(second.output_preview, first.output_preview);
4792 assert_eq!(listed[0].output_preview, first.output_preview);
4793 }
4794
4795 #[test]
4796 fn completion_preview_success_keeps_tail_only() {
4797 let registry = BgTaskRegistry::default();
4802 let dir = tempfile::tempdir().unwrap();
4803 let output = format!("HEAD-SIGNAL\n{}TAIL-SIGNAL\n", "middle\n".repeat(2_000));
4804 let (_task_id, task) =
4805 insert_terminal_piped_task(®istry, &dir, "cat big.log", &output, "", false);
4806
4807 registry.post_terminal_transition(&task, true).unwrap();
4808 let completions = registry.drain_completions_for_session(Some("session"));
4809 assert_eq!(completions.len(), 1);
4810 let preview = &completions[0].output_preview;
4811 assert!(preview.contains("TAIL-SIGNAL"), "preview was {preview:?}");
4812 assert!(!preview.contains("HEAD-SIGNAL"), "preview was {preview:?}");
4813 assert!(completions[0].output_truncated);
4814 }
4815
4816 #[test]
4817 fn completion_preview_failure_keeps_head_and_tail() {
4818 let registry = BgTaskRegistry::default();
4821 let dir = tempfile::tempdir().unwrap();
4822 let output = format!("HEAD-SIGNAL\n{}TAIL-SIGNAL\n", "middle\n".repeat(2_000));
4823 let task_id = format!("bash-test-{}", random_slug());
4824 let paths = task_paths(dir.path(), "session", &task_id);
4825 fs::create_dir_all(&paths.dir).unwrap();
4826 fs::write(&paths.stdout, &output).unwrap();
4827 fs::write(&paths.stderr, "").unwrap();
4828 let mut metadata = PersistedTask::starting(
4829 task_id.clone(),
4830 "session".to_string(),
4831 "cat big.log".to_string(),
4832 dir.path().to_path_buf(),
4833 Some(dir.path().to_path_buf()),
4834 Some(30_000),
4835 true,
4836 false,
4837 );
4838 metadata.mark_terminal(BgTaskStatus::Failed, Some(1), None);
4839 write_task(&paths.json, &metadata).unwrap();
4840 registry
4841 .insert_rehydrated_task(metadata, paths, true)
4842 .expect("insert terminal task");
4843 let task = registry.task_for_session(&task_id, "session").unwrap();
4844
4845 registry.post_terminal_transition(&task, true).unwrap();
4846 let completions = registry.drain_completions_for_session(Some("session"));
4847 assert_eq!(completions.len(), 1);
4848 let preview = &completions[0].output_preview;
4849 assert!(preview.contains("HEAD-SIGNAL"), "preview was {preview:?}");
4850 assert!(preview.contains("TAIL-SIGNAL"), "preview was {preview:?}");
4851 }
4852
4853 #[test]
4854 fn has_completions_for_session_matches_pending_delivery() {
4855 let registry = BgTaskRegistry::default();
4856 assert!(!registry.has_completions_for_session(Some("session")));
4857 assert!(!registry.has_completions_for_session(None));
4858
4859 let dir = tempfile::tempdir().unwrap();
4860 let (_task_id, task) =
4861 insert_terminal_piped_task(®istry, &dir, QUICK_SUCCESS_COMMAND, "done\n", "", false);
4862 registry.post_terminal_transition(&task, true).unwrap();
4863
4864 assert!(registry.has_completions_for_session(Some("session")));
4865 assert!(registry.has_completions_for_session(None));
4866 assert!(!registry.has_completions_for_session(Some("other-session")));
4867
4868 let completions = registry.drain_completions_for_session(Some("session"));
4869 assert_eq!(completions.len(), 1);
4870 assert_eq!(completions[0].task_id, task.task_id);
4871 }
4872
4873 #[test]
4874 fn structured_gh_json_survives_intact_and_ignores_stderr() {
4875 let registry = BgTaskRegistry::default();
4876 let dir = tempfile::tempdir().unwrap();
4877 let calls = Arc::new(AtomicUsize::new(0));
4878 let calls_for_closure = Arc::clone(&calls);
4879 registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
4880 calls_for_closure.fetch_add(1, Ordering::SeqCst);
4881 CompressionResult::new(output)
4882 });
4883 let (task_id, _task) = insert_terminal_piped_task(
4884 ®istry,
4885 &dir,
4886 "gh pr view 123 --json body",
4887 "{\"body\":\"hello\"}",
4888 "warning: stderr must not join json",
4889 true,
4890 );
4891
4892 let snapshot = registry
4893 .status(
4894 &task_id,
4895 "session",
4896 None,
4897 Some(dir.path()),
4898 RUNNING_OUTPUT_PREVIEW_BYTES,
4899 )
4900 .unwrap();
4901
4902 assert_eq!(snapshot.output_preview, "{\"body\":\"hello\"}");
4903 assert!(!snapshot.output_preview.contains("warning"));
4904 assert!(!snapshot.output_truncated);
4905 assert_eq!(
4906 calls.load(Ordering::SeqCst),
4907 0,
4908 "structured JSON bypasses compression"
4909 );
4910 }
4911
4912 #[test]
4913 fn registry_emits_single_recovery_marker_for_class_drops() {
4914 let registry = BgTaskRegistry::default();
4915 let dir = tempfile::tempdir().unwrap();
4916 registry.set_compressor_with_exit_code(move |_command, _output, _exit_code| {
4917 let mut dropped = BTreeMap::new();
4918 dropped.insert(DropClass::Error, 18);
4919 dropped.insert(DropClass::Warning, 6);
4920 CompressionResult::with_class_drops("kept diagnostic", dropped)
4921 });
4922 let (task_id, task) =
4923 insert_terminal_piped_task(®istry, &dir, "custom-tool", "raw", "", true);
4924
4925 let snapshot = registry
4926 .status(
4927 &task_id,
4928 "session",
4929 None,
4930 Some(dir.path()),
4931 RUNNING_OUTPUT_PREVIEW_BYTES,
4932 )
4933 .unwrap();
4934
4935 assert_eq!(snapshot.output_preview.matches("full output:").count(), 1);
4936 assert!(snapshot.output_preview.contains("+18 more errors"));
4937 assert!(snapshot.output_preview.contains("+6 more warnings"));
4938 assert!(snapshot
4939 .output_preview
4940 .contains(&format!("read \"{}\"", task.paths.stdout.display())));
4941 assert!(!snapshot.output_preview.contains("tail -n +"));
4942 assert!(snapshot.output_truncated);
4943 }
4944
4945 #[test]
4946 fn registry_marker_reports_semantic_and_byte_drops_once() {
4947 let registry = BgTaskRegistry::default();
4948 let dir = tempfile::tempdir().unwrap();
4949 registry.set_compressor_with_exit_code(move |_command, _output, _exit_code| {
4950 let mut dropped = BTreeMap::new();
4951 dropped.insert(DropClass::Error, 1);
4952 CompressionResult::with_class_drops(
4953 format!("HEAD-SIGNAL\n{}TAIL-SIGNAL", "middle\n".repeat(8_000)),
4954 dropped,
4955 )
4956 });
4957 let (task_id, _task) =
4958 insert_terminal_piped_task(®istry, &dir, "custom-tool", "raw", "", true);
4959
4960 let snapshot = registry
4961 .status(
4962 &task_id,
4963 "session",
4964 None,
4965 Some(dir.path()),
4966 RUNNING_OUTPUT_PREVIEW_BYTES,
4967 )
4968 .unwrap();
4969
4970 assert_eq!(snapshot.output_preview.matches("full output:").count(), 1);
4971 assert!(snapshot.output_preview.contains("+1 more error"));
4972 assert!(snapshot.output_preview.contains("truncated output"));
4973 assert!(snapshot.output_preview.contains("HEAD-SIGNAL"));
4974 assert!(snapshot.output_preview.contains("TAIL-SIGNAL"));
4975 assert!(!snapshot.output_preview.contains("...<truncated"));
4976 assert!(snapshot.output_truncated);
4977 }
4978
4979 #[test]
4980 fn cargo_stderr_class_drops_name_both_capture_paths() {
4981 let registry = BgTaskRegistry::default();
4982 let dir = tempfile::tempdir().unwrap();
4983 let filter_registry = crate::compress::toml_filter::FilterRegistry::default();
4984 registry.set_compressor_with_exit_code(move |command, output, exit_code| {
4985 crate::compress::compress_with_registry_exit_code(
4986 command,
4987 &output,
4988 exit_code,
4989 &filter_registry,
4990 )
4991 });
4992 let stderr = (0..22)
4993 .map(|index| {
4994 format!(
4995 "error: cargo failure {index}\n --> src/lib.rs:{}:1\n |\n{} | boom\n",
4996 index + 1,
4997 index + 1
4998 )
4999 })
5000 .collect::<Vec<_>>()
5001 .join("\n");
5002 let (task_id, task) = insert_terminal_piped_task(
5003 ®istry,
5004 &dir,
5005 "cargo check",
5006 "Finished dev [unoptimized] target(s) in 0.01s\n",
5007 &stderr,
5008 true,
5009 );
5010
5011 let snapshot = registry
5012 .status(
5013 &task_id,
5014 "session",
5015 None,
5016 Some(dir.path()),
5017 RUNNING_OUTPUT_PREVIEW_BYTES,
5018 )
5019 .unwrap();
5020
5021 assert!(snapshot.output_preview.contains("+2 more errors"));
5022 assert!(snapshot
5023 .output_preview
5024 .contains(&format!("read \"{}\"", task.paths.stdout.display())));
5025 assert!(snapshot
5026 .output_preview
5027 .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5028 assert!(!snapshot.output_preview.contains("tail -n +"));
5029 }
5030
5031 #[test]
5032 fn over_ceiling_structured_json_uses_pointer_not_partial_json() {
5033 let registry = BgTaskRegistry::default();
5034 let dir = tempfile::tempdir().unwrap();
5035 let body = format!("{{\"body\":\"{}\"}}", "x".repeat(60 * 1024));
5036 let (task_id, task) = insert_terminal_piped_task(
5037 ®istry,
5038 &dir,
5039 "cd /repo && gh pr view 123 --json body",
5040 &body,
5041 "",
5042 true,
5043 );
5044
5045 let snapshot = registry
5046 .status(
5047 &task_id,
5048 "session",
5049 None,
5050 Some(dir.path()),
5051 RUNNING_OUTPUT_PREVIEW_BYTES,
5052 )
5053 .unwrap();
5054
5055 assert!(snapshot.output_preview.starts_with("[JSON output "));
5056 assert!(snapshot
5057 .output_preview
5058 .contains(&task.paths.stdout.display().to_string()));
5059 assert!(!snapshot.output_preview.contains(&"x".repeat(1024)));
5060 assert!(snapshot.output_truncated);
5061 }
5062
5063 #[test]
5064 fn toml_strip_tail_cap_uses_full_output_hint_not_offset_hint() {
5065 let registry = BgTaskRegistry::default();
5066 let dir = tempfile::tempdir().unwrap();
5067 let filter_registry = crate::compress::toml_filter::build_registry(
5068 crate::compress::builtin_filters::ALL,
5069 None,
5070 None,
5071 );
5072 registry.set_compressor_with_exit_code(move |command, output, exit_code| {
5073 crate::compress::compress_with_registry_exit_code(
5074 command,
5075 &output,
5076 exit_code,
5077 &filter_registry,
5078 )
5079 });
5080 let stdout = format!(
5081 "make[1]: Entering directory `/tmp`\n{}",
5082 (0..100)
5083 .map(|index| format!("compile line {index}"))
5084 .collect::<Vec<_>>()
5085 .join("\n")
5086 );
5087 let (task_id, task) =
5088 insert_terminal_piped_task(®istry, &dir, "make all", &stdout, "", true);
5089
5090 let snapshot = registry
5091 .status(
5092 &task_id,
5093 "session",
5094 None,
5095 Some(dir.path()),
5096 RUNNING_OUTPUT_PREVIEW_BYTES,
5097 )
5098 .unwrap();
5099
5100 assert!(snapshot.output_preview.contains("compile line 99"));
5101 assert!(snapshot.output_preview.contains(&format!(
5102 "full output: read \"{}\"",
5103 task.paths.stdout.display()
5104 )));
5105 assert!(!snapshot
5106 .output_preview
5107 .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5108 assert!(!snapshot.output_preview.contains("tail -n +"));
5109 }
5110
5111 #[test]
5112 fn compressed_false_raw_passthrough_uses_wider_head_tail_cap() {
5113 let registry = BgTaskRegistry::default();
5114 let dir = tempfile::tempdir().unwrap();
5115 let output = format!("RAW-HEAD\n{}RAW-TAIL\n", "raw-middle\n".repeat(8_000));
5116 let (task_id, task) =
5117 insert_terminal_piped_task(®istry, &dir, "cat raw.log", &output, "RAW-ERR\n", false);
5118
5119 let snapshot = registry
5120 .status(
5121 &task_id,
5122 "session",
5123 None,
5124 Some(dir.path()),
5125 RUNNING_OUTPUT_PREVIEW_BYTES,
5126 )
5127 .unwrap();
5128
5129 assert!(snapshot.output_preview.contains("RAW-HEAD"));
5130 assert!(snapshot.output_preview.contains("RAW-TAIL"));
5131 assert!(snapshot.output_preview.contains("truncated output"));
5132 assert!(snapshot
5133 .output_preview
5134 .contains(&format!("read \"{}\"", task.paths.stdout.display())));
5135 assert!(snapshot
5136 .output_preview
5137 .contains(&format!("read \"{}\"", task.paths.stderr.display())));
5138 assert!(!snapshot.output_preview.contains("tail -n +"));
5139 assert!(snapshot.output_preview.len() > 16 * 1024);
5140 assert!(snapshot.output_truncated);
5141 }
5142
5143 #[test]
5144 fn pty_terminal_snapshot_bypasses_line_compression() {
5145 let registry = BgTaskRegistry::default();
5146 let dir = tempfile::tempdir().unwrap();
5147 let calls = Arc::new(AtomicUsize::new(0));
5148 let calls_for_closure = Arc::clone(&calls);
5149 registry.set_compressor_with_exit_code(move |_command, output, _exit_code| {
5150 calls_for_closure.fetch_add(1, Ordering::SeqCst);
5151 CompressionResult::new(output)
5152 });
5153 let (task_id, _task) = insert_terminal_pty_task(®istry, &dir, "raw\u{1b}[31m pty bytes");
5154
5155 let snapshot = registry
5156 .status(
5157 &task_id,
5158 "session",
5159 None,
5160 Some(dir.path()),
5161 RUNNING_OUTPUT_PREVIEW_BYTES,
5162 )
5163 .unwrap();
5164
5165 assert_eq!(snapshot.info.mode, BgMode::Pty);
5166 assert_eq!(snapshot.output_preview, "");
5167 assert_eq!(calls.load(Ordering::SeqCst), 0);
5168 }
5169
5170 #[test]
5171 fn pty_dimensions_are_persisted_and_returned_in_snapshot() {
5172 let registry = BgTaskRegistry::default();
5173 let dir = tempfile::tempdir().unwrap();
5174 let task_id = registry
5175 .spawn_pty(
5176 QUICK_SUCCESS_COMMAND,
5177 "session".to_string(),
5178 dir.path().to_path_buf(),
5179 HashMap::new(),
5180 Some(Duration::from_secs(30)),
5181 dir.path().to_path_buf(),
5182 10,
5183 true,
5184 false,
5185 Some(dir.path().to_path_buf()),
5186 50,
5187 120,
5188 )
5189 .unwrap();
5190
5191 let paths = task_paths(dir.path(), "session", &task_id);
5192 let metadata = read_task(&paths.json).unwrap();
5193 assert_eq!(
5194 metadata.schema_version,
5195 crate::bash_background::persistence::SCHEMA_VERSION
5196 );
5197 assert_eq!(metadata.mode, BgMode::Pty);
5198 assert_eq!(metadata.pty_rows, Some(50));
5199 assert_eq!(metadata.pty_cols, Some(120));
5200
5201 let snapshot = registry
5202 .status(&task_id, "session", None, Some(dir.path()), 1024)
5203 .unwrap();
5204 assert_eq!(snapshot.pty_rows, Some(50));
5205 assert_eq!(snapshot.pty_cols, Some(120));
5206 }
5207
5208 fn spawn_dead_child() -> std::process::Child {
5213 #[cfg(unix)]
5214 let mut cmd = std::process::Command::new("true");
5215 #[cfg(windows)]
5216 let mut cmd = {
5217 let mut c = std::process::Command::new("cmd");
5218 c.args(["/c", "exit", "0"]);
5219 c
5220 };
5221 cmd.stdin(std::process::Stdio::null());
5222 cmd.stdout(std::process::Stdio::null());
5223 cmd.stderr(std::process::Stdio::null());
5224 let mut child = cmd.spawn().expect("spawn replacement child for reap test");
5225 let started = Instant::now();
5234 loop {
5235 match child.try_wait() {
5236 Ok(Some(_)) => break,
5237 Ok(None) => {
5238 if started.elapsed() > Duration::from_secs(5) {
5239 panic!("dead-child stand-in did not exit within 5s");
5240 }
5241 std::thread::sleep(Duration::from_millis(10));
5242 }
5243 Err(error) => panic!("dead-child try_wait failed: {error}"),
5244 }
5245 }
5246 child
5247 }
5248
5249 #[test]
5250 fn ack_marks_delivered_even_when_completion_was_already_consumed_locally() {
5251 let registry = BgTaskRegistry::default();
5252 let dir = tempfile::tempdir().unwrap();
5253 let task_id = registry
5254 .spawn(
5255 LONG_RUNNING_COMMAND,
5256 "session".to_string(),
5257 dir.path().to_path_buf(),
5258 HashMap::new(),
5259 Some(Duration::from_secs(30)),
5260 dir.path().to_path_buf(),
5261 10,
5262 true,
5263 false,
5264 Some(dir.path().to_path_buf()),
5265 )
5266 .unwrap();
5267 registry
5268 .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5269 .unwrap();
5270 assert_eq!(
5271 registry
5272 .drain_completions_for_session(Some("session"))
5273 .len(),
5274 1
5275 );
5276
5277 registry.inner.completions.lock().unwrap().clear();
5280
5281 assert_eq!(
5282 registry.ack_completions_for_session(Some("session"), std::slice::from_ref(&task_id)),
5283 vec![task_id.clone()]
5284 );
5285 assert!(registry
5286 .drain_completions_for_session(Some("session"))
5287 .is_empty());
5288
5289 let paths = task_paths(dir.path(), "session", &task_id);
5290 let metadata = read_task(&paths.json).unwrap();
5291 assert!(metadata.completion_delivered);
5292
5293 let replayed = BgTaskRegistry::default();
5294 replayed
5295 .replay_session_inner(dir.path(), "session", None)
5296 .unwrap();
5297 assert!(replayed
5298 .drain_completions_for_session(Some("session"))
5299 .is_empty());
5300 }
5301
5302 #[test]
5303 fn register_watch_rejects_unknown_task() {
5304 let registry = BgTaskRegistry::default();
5305
5306 let result = registry.register_watch(
5307 "missing-task".to_string(),
5308 WatchPattern::Substring("READY".into()),
5309 true,
5310 );
5311
5312 assert_eq!(result, Err("task_not_found"));
5313 }
5314
5315 #[test]
5316 fn register_watch_on_terminal_task_scans_existing_output() {
5317 let frames = Arc::new(Mutex::new(Vec::new()));
5318 let captured = Arc::clone(&frames);
5319 let sender: crate::context::ProgressSender = Arc::new(Box::new(move |frame| {
5320 captured.lock().unwrap().push(frame);
5321 })
5322 as Box<dyn Fn(PushFrame) + Send + Sync>);
5323 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(Some(sender))));
5324 let dir = tempfile::tempdir().unwrap();
5325 let task_id = registry
5326 .spawn(
5327 LONG_RUNNING_COMMAND,
5328 "session".to_string(),
5329 dir.path().to_path_buf(),
5330 HashMap::new(),
5331 Some(Duration::from_secs(30)),
5332 dir.path().to_path_buf(),
5333 10,
5334 true,
5335 false,
5336 Some(dir.path().to_path_buf()),
5337 )
5338 .unwrap();
5339 registry
5340 .inner
5341 .shutdown
5342 .store(true, std::sync::atomic::Ordering::SeqCst);
5343 let task = registry.task_for_session(&task_id, "session").unwrap();
5344 std::fs::write(&task.paths.stdout, "READY\n").unwrap();
5345 registry
5346 .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5347 .unwrap();
5348 frames.lock().unwrap().clear();
5349 registry.inner.completions.lock().unwrap().clear();
5350
5351 registry
5352 .register_watch(
5353 task_id.clone(),
5354 WatchPattern::Substring("READY".into()),
5355 true,
5356 )
5357 .unwrap();
5358
5359 let frames = frames.lock().unwrap();
5360 let frame = frames
5361 .iter()
5362 .find_map(|frame| match frame {
5363 PushFrame::BashPatternMatch(frame) => Some(frame),
5364 _ => None,
5365 })
5366 .expect("terminal watch registration should emit pattern frame");
5367 assert_eq!(frame.reason, "pattern_match");
5368 assert_eq!(frame.task_id, task_id);
5369 assert_eq!(frame.session_id, "session");
5370 assert_eq!(frame.match_text, "READY");
5371 assert_eq!(frame.match_offset, 0);
5372 assert_eq!(registry.active_watch_count(&frame.task_id), 0);
5373 let metadata = read_task(&task.paths.json).unwrap();
5374 assert!(metadata.completion_delivered);
5375 }
5376
5377 #[test]
5378 fn cleanup_finished_removes_terminal_tasks_older_than_threshold() {
5379 let registry = BgTaskRegistry::default();
5380 let dir = tempfile::tempdir().unwrap();
5381 let task_id = registry
5382 .spawn(
5383 QUICK_SUCCESS_COMMAND,
5384 "session".to_string(),
5385 dir.path().to_path_buf(),
5386 HashMap::new(),
5387 Some(Duration::from_secs(30)),
5388 dir.path().to_path_buf(),
5389 10,
5390 true,
5391 false,
5392 Some(dir.path().to_path_buf()),
5393 )
5394 .unwrap();
5395 registry
5396 .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5397 .unwrap();
5398 let completions = registry.drain_completions_for_session(Some("session"));
5399 assert_eq!(completions.len(), 1);
5400 assert_eq!(
5401 registry.ack_completions_for_session(Some("session"), std::slice::from_ref(&task_id)),
5402 vec![task_id.clone()]
5403 );
5404
5405 registry.cleanup_finished(Duration::ZERO);
5406
5407 assert!(registry.inner.tasks.lock().unwrap().is_empty());
5408 }
5409
5410 #[test]
5411 fn cleanup_finished_retains_undelivered_terminals() {
5412 let registry = BgTaskRegistry::default();
5413 let dir = tempfile::tempdir().unwrap();
5414 let task_id = registry
5415 .spawn(
5416 QUICK_SUCCESS_COMMAND,
5417 "session".to_string(),
5418 dir.path().to_path_buf(),
5419 HashMap::new(),
5420 Some(Duration::from_secs(30)),
5421 dir.path().to_path_buf(),
5422 10,
5423 true,
5424 false,
5425 Some(dir.path().to_path_buf()),
5426 )
5427 .unwrap();
5428 registry
5429 .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5430 .unwrap();
5431
5432 registry.cleanup_finished(Duration::ZERO);
5433
5434 assert!(registry.inner.tasks.lock().unwrap().contains_key(&task_id));
5435 }
5436
5437 #[test]
5445 fn reap_child_marks_failed_when_child_exits_without_exit_marker() {
5446 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5447 let dir = tempfile::tempdir().unwrap();
5448 let task_id = registry
5449 .spawn(
5450 QUICK_SUCCESS_COMMAND,
5451 "session".to_string(),
5452 dir.path().to_path_buf(),
5453 HashMap::new(),
5454 Some(Duration::from_secs(30)),
5455 dir.path().to_path_buf(),
5456 10,
5457 true,
5458 false,
5459 Some(dir.path().to_path_buf()),
5460 )
5461 .unwrap();
5462
5463 let task = registry.task_for_session(&task_id, "session").unwrap();
5464
5465 let started = Instant::now();
5470 loop {
5471 let exited = {
5472 let mut state = task.state.lock().unwrap();
5473 match &mut state.runtime {
5474 TaskRuntime::Piped(Some(child)) => matches!(child.try_wait(), Ok(Some(_))),
5475 _ => true,
5476 }
5477 };
5478 if exited {
5479 break;
5480 }
5481 assert!(
5482 started.elapsed() < Duration::from_secs(5),
5483 "child should exit quickly"
5484 );
5485 std::thread::sleep(Duration::from_millis(20));
5486 }
5487
5488 registry
5496 .inner
5497 .shutdown
5498 .store(true, std::sync::atomic::Ordering::SeqCst);
5499 std::thread::sleep(Duration::from_millis(550));
5503
5504 let _ = std::fs::remove_file(&task.paths.exit);
5507
5508 {
5523 let mut state = task.state.lock().unwrap();
5524 state.metadata.status = BgTaskStatus::Running;
5525 state.metadata.status_reason = None;
5526 state.metadata.exit_code = None;
5527 state.metadata.finished_at = None;
5528 state.metadata.duration_ms = None;
5529 crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5532 .expect("persist reset Running metadata for reap_child test");
5533 if matches!(state.runtime, TaskRuntime::Piped(None)) {
5537 state.runtime = TaskRuntime::Piped(Some(spawn_dead_child()));
5538 }
5539 }
5540 *task.terminal_at.lock().unwrap() = None;
5543
5544 assert!(
5547 task.is_running(),
5548 "precondition: metadata.status == Running"
5549 );
5550 assert!(
5551 !task.paths.exit.exists(),
5552 "precondition: exit marker absent"
5553 );
5554
5555 registry.reap_child(&task);
5560
5561 {
5562 let state = task.state.lock().unwrap();
5563 assert_eq!(
5564 state.metadata.status,
5565 BgTaskStatus::Running,
5566 "first reap must leave status Running while waiting one pass for marker"
5567 );
5568 assert_eq!(
5569 state.metadata.status_reason, None,
5570 "first reap must not record a failure reason"
5571 );
5572 assert!(
5573 matches!(state.runtime, TaskRuntime::Piped(None)),
5574 "child handle must be released after first reap"
5575 );
5576 assert!(
5577 state.detached,
5578 "task must be marked detached after first reap"
5579 );
5580 }
5581
5582 registry.reap_child(&task);
5586
5587 let state = task.state.lock().unwrap();
5588 assert!(
5589 state.metadata.status.is_terminal(),
5590 "second reap must transition to terminal when PID dead and no marker. Got status={:?}",
5591 state.metadata.status
5592 );
5593 assert_eq!(
5594 state.metadata.status,
5595 BgTaskStatus::Failed,
5596 "must specifically be Failed (not Killed): status={:?}",
5597 state.metadata.status
5598 );
5599 assert_eq!(
5600 state.metadata.status_reason.as_deref(),
5601 Some("process exited without exit marker"),
5602 "reason must match replay path's wording: {:?}",
5603 state.metadata.status_reason
5604 );
5605 assert!(
5606 matches!(state.runtime, TaskRuntime::Piped(None)),
5607 "child handle must stay released after second reap"
5608 );
5609 assert!(
5610 state.detached,
5611 "task must remain detached after second reap"
5612 );
5613 }
5614
5615 #[test]
5620 fn reap_child_preserves_running_when_exit_marker_exists() {
5621 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5622 let dir = tempfile::tempdir().unwrap();
5623 let task_id = registry
5624 .spawn(
5625 QUICK_SUCCESS_COMMAND,
5626 "session".to_string(),
5627 dir.path().to_path_buf(),
5628 HashMap::new(),
5629 Some(Duration::from_secs(30)),
5630 dir.path().to_path_buf(),
5631 10,
5632 true,
5633 false,
5634 Some(dir.path().to_path_buf()),
5635 )
5636 .unwrap();
5637
5638 let task = registry.task_for_session(&task_id, "session").unwrap();
5639
5640 let started = Instant::now();
5643 loop {
5644 let exited = {
5645 let mut state = task.state.lock().unwrap();
5646 match &mut state.runtime {
5647 TaskRuntime::Piped(Some(child)) => matches!(child.try_wait(), Ok(Some(_))),
5648 _ => true,
5649 }
5650 };
5651 if exited && task.paths.exit.exists() {
5652 break;
5653 }
5654 assert!(
5655 started.elapsed() < Duration::from_secs(5),
5656 "child should exit and write marker quickly"
5657 );
5658 std::thread::sleep(Duration::from_millis(20));
5659 }
5660
5661 registry
5667 .inner
5668 .shutdown
5669 .store(true, std::sync::atomic::Ordering::SeqCst);
5670 std::thread::sleep(Duration::from_millis(550));
5671
5672 {
5678 let mut state = task.state.lock().unwrap();
5679 state.metadata.status = BgTaskStatus::Running;
5680 state.metadata.status_reason = None;
5681 if matches!(state.runtime, TaskRuntime::Piped(None)) {
5682 state.runtime = TaskRuntime::Piped(Some(spawn_dead_child()));
5683 }
5684 }
5685 *task.terminal_at.lock().unwrap() = None;
5686 if !task.paths.exit.exists() {
5689 std::fs::write(&task.paths.exit, "0").expect("write replacement exit marker");
5690 }
5691
5692 registry.reap_child(&task);
5696
5697 let state = task.state.lock().unwrap();
5698 assert!(
5699 matches!(state.runtime, TaskRuntime::Piped(None)),
5700 "child handle still released even when marker exists"
5701 );
5702 assert!(
5703 state.detached,
5704 "task still marked detached even when marker exists"
5705 );
5706 assert_eq!(
5711 state.metadata.status,
5712 BgTaskStatus::Running,
5713 "reap_child must defer to poll_task when marker exists"
5714 );
5715 }
5716
5717 #[cfg(unix)]
5721 fn pid_stat(pid: u32) -> Option<String> {
5722 let output = std::process::Command::new("ps")
5723 .args(["-o", "stat=", "-p", &pid.to_string()])
5724 .output()
5725 .ok()?;
5726 if !output.status.success() {
5727 return None;
5728 }
5729 let stat = String::from_utf8_lossy(&output.stdout).trim().to_string();
5730 if stat.is_empty() {
5731 None
5732 } else {
5733 Some(stat)
5734 }
5735 }
5736
5737 #[cfg(unix)]
5739 fn is_zombie(pid: u32) -> bool {
5740 pid_stat(pid).is_some_and(|stat| stat.starts_with('Z'))
5741 }
5742
5743 #[cfg(unix)]
5749 fn spawn_unreaped_zombie() -> std::process::Child {
5750 let child = std::process::Command::new("true")
5751 .stdin(std::process::Stdio::null())
5752 .stdout(std::process::Stdio::null())
5753 .stderr(std::process::Stdio::null())
5754 .spawn()
5755 .expect("spawn zombie stand-in");
5756 let pid = child.id();
5757 let started = Instant::now();
5758 while !is_zombie(pid) {
5759 assert!(
5760 started.elapsed() < Duration::from_secs(5),
5761 "stand-in child should become a zombie within 5s"
5762 );
5763 std::thread::sleep(Duration::from_millis(10));
5764 }
5765 child
5767 }
5768
5769 #[cfg(unix)]
5779 #[test]
5780 fn finalize_from_marker_reaps_child_no_zombie() {
5781 use std::sync::atomic::Ordering;
5782
5783 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5784 let dir = tempfile::tempdir().unwrap();
5785 let task_id = registry
5786 .spawn(
5787 QUICK_SUCCESS_COMMAND,
5788 "session".to_string(),
5789 dir.path().to_path_buf(),
5790 HashMap::new(),
5791 Some(Duration::from_secs(30)),
5792 dir.path().to_path_buf(),
5793 10,
5794 true,
5795 false,
5796 Some(dir.path().to_path_buf()),
5797 )
5798 .unwrap();
5799
5800 registry.inner.shutdown.store(true, Ordering::SeqCst);
5804 std::thread::sleep(Duration::from_millis(550));
5805
5806 let task = registry.task_for_session(&task_id, "session").unwrap();
5807
5808 let started = Instant::now();
5812 while !task.paths.exit.exists() {
5813 assert!(
5814 started.elapsed() < Duration::from_secs(5),
5815 "exit marker should land quickly for `true`"
5816 );
5817 std::thread::sleep(Duration::from_millis(20));
5818 }
5819
5820 let zombie_pid;
5826 {
5827 let mut state = task.state.lock().unwrap();
5828 state.metadata.status = BgTaskStatus::Running;
5829 state.metadata.status_reason = None;
5830 state.metadata.exit_code = None;
5831 state.metadata.finished_at = None;
5832 state.metadata.duration_ms = None;
5833 crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5834 .expect("persist reset Running metadata");
5835 let zombie = spawn_unreaped_zombie();
5836 zombie_pid = zombie.id();
5837 state.runtime = TaskRuntime::Piped(Some(zombie));
5838 }
5839 *task.terminal_at.lock().unwrap() = None;
5840
5841 assert!(
5843 is_zombie(zombie_pid),
5844 "precondition: stand-in child {zombie_pid} must be a zombie before finalize"
5845 );
5846
5847 registry.poll_task(&task).unwrap();
5850
5851 {
5852 let state = task.state.lock().unwrap();
5853 assert!(
5854 matches!(state.runtime, TaskRuntime::Piped(None)),
5855 "child handle must be released after marker finalize"
5856 );
5857 assert!(
5858 state.metadata.status.is_terminal(),
5859 "task must be terminal after marker finalize: {:?}",
5860 state.metadata.status
5861 );
5862 }
5863
5864 assert!(
5867 !is_zombie(zombie_pid),
5868 "issue #91 regression: child {zombie_pid} left as <defunct> zombie \
5869 after the exit-marker terminal transition"
5870 );
5871 }
5872
5873 #[cfg(unix)]
5877 #[test]
5878 fn kill_with_existing_marker_reaps_child_no_zombie() {
5879 use std::sync::atomic::Ordering;
5880
5881 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5882 let dir = tempfile::tempdir().unwrap();
5883 let task_id = registry
5884 .spawn(
5885 QUICK_SUCCESS_COMMAND,
5886 "session".to_string(),
5887 dir.path().to_path_buf(),
5888 HashMap::new(),
5889 Some(Duration::from_secs(30)),
5890 dir.path().to_path_buf(),
5891 10,
5892 true,
5893 false,
5894 Some(dir.path().to_path_buf()),
5895 )
5896 .unwrap();
5897
5898 registry.inner.shutdown.store(true, Ordering::SeqCst);
5899 std::thread::sleep(Duration::from_millis(550));
5900
5901 let task = registry.task_for_session(&task_id, "session").unwrap();
5902
5903 let started = Instant::now();
5904 while !task.paths.exit.exists() {
5905 assert!(
5906 started.elapsed() < Duration::from_secs(5),
5907 "exit marker should land quickly for `true`"
5908 );
5909 std::thread::sleep(Duration::from_millis(20));
5910 }
5911
5912 let zombie_pid;
5913 {
5914 let mut state = task.state.lock().unwrap();
5915 state.metadata.status = BgTaskStatus::Running;
5916 state.metadata.status_reason = None;
5917 state.metadata.exit_code = None;
5918 state.metadata.finished_at = None;
5919 state.metadata.duration_ms = None;
5920 crate::bash_background::persistence::write_task(&task.paths.json, &state.metadata)
5921 .expect("persist reset Running metadata");
5922 let zombie = spawn_unreaped_zombie();
5923 zombie_pid = zombie.id();
5924 state.runtime = TaskRuntime::Piped(Some(zombie));
5925 }
5926 *task.terminal_at.lock().unwrap() = None;
5927
5928 assert!(
5929 is_zombie(zombie_pid),
5930 "precondition: stand-in child {zombie_pid} must be a zombie before kill"
5931 );
5932
5933 registry
5935 .kill_with_status(&task_id, "session", BgTaskStatus::Killed)
5936 .expect("kill should succeed");
5937
5938 {
5939 let state = task.state.lock().unwrap();
5940 assert!(
5941 matches!(state.runtime, TaskRuntime::Piped(None)),
5942 "child handle must be released after marker-aware kill"
5943 );
5944 assert!(state.metadata.status.is_terminal());
5945 }
5946
5947 assert!(
5948 !is_zombie(zombie_pid),
5949 "issue #91 regression: child {zombie_pid} left as <defunct> zombie \
5950 after a marker-aware kill"
5951 );
5952 }
5953
5954 #[test]
5955 fn cleanup_finished_keeps_running_tasks() {
5956 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
5957 let dir = tempfile::tempdir().unwrap();
5958 let task_id = registry
5959 .spawn(
5960 LONG_RUNNING_COMMAND,
5961 "session".to_string(),
5962 dir.path().to_path_buf(),
5963 HashMap::new(),
5964 Some(Duration::from_secs(30)),
5965 dir.path().to_path_buf(),
5966 10,
5967 true,
5968 false,
5969 Some(dir.path().to_path_buf()),
5970 )
5971 .unwrap();
5972
5973 registry.cleanup_finished(Duration::ZERO);
5974
5975 assert!(registry.inner.tasks.lock().unwrap().contains_key(&task_id));
5976 let _ = registry.kill(&task_id, "session");
5977 }
5978
5979 #[cfg(windows)]
5980 fn wait_for_file(path: &Path) -> String {
5981 let started = Instant::now();
5982 loop {
5983 if path.exists() {
5984 return fs::read_to_string(path).expect("read file");
5985 }
5986 assert!(
5987 started.elapsed() < Duration::from_secs(30),
5988 "timed out waiting for {}",
5989 path.display()
5990 );
5991 std::thread::sleep(Duration::from_millis(100));
5992 }
5993 }
5994
5995 #[cfg(windows)]
5996 fn spawn_windows_registry_command(
5997 command: &str,
5998 ) -> (BgTaskRegistry, tempfile::TempDir, String) {
5999 let registry = BgTaskRegistry::new(Arc::new(Mutex::new(None)));
6000 let dir = tempfile::tempdir().unwrap();
6001 let task_id = registry
6002 .spawn(
6003 command,
6004 "session".to_string(),
6005 dir.path().to_path_buf(),
6006 HashMap::new(),
6007 Some(Duration::from_secs(30)),
6008 dir.path().to_path_buf(),
6009 10,
6010 false,
6011 false,
6012 Some(dir.path().to_path_buf()),
6013 )
6014 .unwrap();
6015 (registry, dir, task_id)
6016 }
6017
6018 #[cfg(windows)]
6019 #[test]
6020 fn windows_spawn_writes_exit_marker_for_zero_exit() {
6021 let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c exit 0");
6022 let exit_path = registry.task_exit_path(&task_id, "session").unwrap();
6023
6024 let content = wait_for_file(&exit_path);
6025
6026 assert_eq!(content.trim(), "0");
6027 }
6028
6029 #[cfg(windows)]
6030 #[test]
6031 fn windows_spawn_writes_exit_marker_for_nonzero_exit() {
6032 let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c exit 42");
6033 let exit_path = registry.task_exit_path(&task_id, "session").unwrap();
6034
6035 let content = wait_for_file(&exit_path);
6036
6037 assert_eq!(content.trim(), "42");
6038 }
6039
6040 #[cfg(windows)]
6041 #[test]
6042 fn windows_spawn_captures_stdout_to_disk() {
6043 let (registry, _dir, task_id) = spawn_windows_registry_command("cmd /c echo hello");
6044 let task = registry.task_for_session(&task_id, "session").unwrap();
6045 let stdout_path = task.paths.stdout.clone();
6046 let exit_path = task.paths.exit.clone();
6047
6048 let _ = wait_for_file(&exit_path);
6049 let stdout = fs::read_to_string(stdout_path).expect("read stdout");
6050
6051 assert!(stdout.contains("hello"), "stdout was {stdout:?}");
6052 }
6053
6054 #[cfg(windows)]
6055 #[test]
6056 fn windows_spawn_uses_pwsh_when_available() {
6057 let candidates = crate::windows_shell::shell_candidates_with(
6061 |binary| match binary {
6062 "pwsh.exe" => Some(std::path::PathBuf::from(r"C:\pwsh\pwsh.exe")),
6063 "powershell.exe" => Some(std::path::PathBuf::from(r"C:\ps\powershell.exe")),
6064 _ => None,
6065 },
6066 || None,
6067 );
6068 let shell = candidates.first().expect("at least one candidate").clone();
6069 assert_eq!(shell, crate::windows_shell::WindowsShell::Pwsh);
6070 assert_eq!(shell.binary().as_ref(), "pwsh.exe");
6071 }
6072
6073 #[cfg(windows)]
6080 #[test]
6081 fn windows_shell_cmd_wrapper_writes_exit_marker_with_move() {
6082 let exit_path = Path::new(r"C:\Temp\bash-test.exit");
6083 let script =
6084 crate::windows_shell::WindowsShell::Cmd.wrapper_script("cmd /c exit 42", exit_path);
6085
6086 assert!(
6090 script.contains("set CODE=%ERRORLEVEL%"),
6091 "wrapper must capture exit code into CODE: {script}"
6092 );
6093 assert!(
6094 script.contains("echo %CODE% >"),
6095 "wrapper must echo CODE to a temp marker file: {script}"
6096 );
6097 assert!(
6098 script.contains("move /Y"),
6099 "wrapper must use atomic move to write the marker: {script}"
6100 );
6101 assert!(
6104 script.contains("> nul"),
6105 "wrapper must redirect move output to nul: {script}"
6106 );
6107 assert!(
6109 script.contains("exit /B %CODE%"),
6110 "wrapper must propagate the captured exit code: {script}"
6111 );
6112 assert!(script.contains(r#""C:\Temp\bash-test.exit.tmp""#));
6113 assert!(script.contains(r#""C:\Temp\bash-test.exit""#));
6114 }
6115
6116 #[cfg(windows)]
6122 #[test]
6123 fn windows_shell_cmd_bg_command_uses_minimal_cmd_flags() {
6124 use crate::windows_shell::WindowsShell;
6125 let cmd = WindowsShell::Cmd.bg_command("echo wrapped");
6126 let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
6127 let args_strs: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
6128 assert_eq!(
6129 args_strs,
6130 vec!["/D", "/S", "/C", "echo wrapped"],
6131 "Cmd::bg_command must prepend /D /S /C"
6132 );
6133 }
6134
6135 #[cfg(windows)]
6138 #[test]
6139 fn windows_shell_pwsh_bg_command_uses_standard_args() {
6140 use crate::windows_shell::WindowsShell;
6141 let cmd = WindowsShell::Pwsh.bg_command("Get-Date");
6142 let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
6143 let args_strs: Vec<&str> = args.iter().filter_map(|a| a.to_str()).collect();
6144 assert!(
6145 args_strs.contains(&"-Command"),
6146 "Pwsh::bg_command must use -Command: {args_strs:?}"
6147 );
6148 assert!(
6149 args_strs.contains(&"Get-Date"),
6150 "Pwsh::bg_command must include the user command body"
6151 );
6152 }
6153}