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