Skip to main content

kaish_kernel/scheduler/
job.rs

1//! Background job management for kaish.
2//!
3//! Provides the `JobManager` for tracking background jobs started with `&`.
4
5use std::collections::HashMap;
6use std::io;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::future::Future;
10use std::sync::Arc;
11use std::time::SystemTime;
12
13use tokio::sync::{oneshot, Mutex};
14use tokio::task::JoinHandle;
15
16use crate::interpreter::ExecResult;
17use crate::scheduler::stream::BoundedStream;
18
19// Data types re-exported from kaish-types.
20pub use kaish_types::{JobId, JobInfo, JobStatus};
21
22/// One job's live output streams.
23///
24/// Handed out by [`JobManager::streams`] so a producer (the drain task behind
25/// an external command) can write into a job that outlives the command, and so
26/// an embedder can tail a running job with
27/// [`BoundedStream::changed_since`] instead of a poll loop.
28#[derive(Clone)]
29pub struct JobStreams {
30    /// The job's stdout, written as the bytes arrive.
31    ///
32    /// Fed two ways, and never both for the same bytes:
33    ///
34    /// * **Live**, per 8 KiB chunk, by the drain task behind an external
35    ///   command running for this job — but only from the stage whose stdout
36    ///   *is* the job's stdout (`Only` or `Last` in the pipeline), so
37    ///   `a | b` streams `b` and not `a`'s bytes on their way into `b`.
38    /// * **At completion**, from the job's captured `ExecResult`, and only
39    ///   when nothing was streamed live. That covers a builtin-only job
40    ///   (`echo hi &`): a builtin returns its output as a value when it
41    ///   finishes, so there is no byte stream to tee.
42    ///
43    /// Whichever fed it, the stream is closed once the job's result is in
44    /// ([`JobManager::finalize_streams`]), so a reader can tell "no more
45    /// coming" from "nothing yet".
46    pub stdout: Arc<BoundedStream>,
47    /// The job's stderr. Same two feeds as [`Self::stdout`], except the live
48    /// one takes **every** stage's stderr, not just the last — stderr is not
49    /// piped between stages. The consequence, stated rather than papered
50    /// over: in a job mixing builtins and externals, once any external has
51    /// written stderr the completion write is skipped, so a builtin stage's
52    /// stderr stays in the job's `ExecResult` and does not reach this stream.
53    pub stderr: Arc<BoundedStream>,
54}
55
56/// A background job.
57pub struct Job {
58    /// Job ID.
59    pub id: JobId,
60    /// Owning manager's session ID — disambiguates output file paths between
61    /// JobManager instances that share the process (and thus the same job ID
62    /// space, since IDs restart at 1 per manager).
63    session_id: u64,
64    /// Command description.
65    pub command: String,
66    /// Task handle (None if already awaited).
67    handle: Option<JoinHandle<ExecResult>>,
68    /// Channel to receive result (alternative to handle).
69    result_rx: Option<oneshot::Receiver<ExecResult>>,
70    /// Cached result after completion.
71    result: Option<ExecResult>,
72    /// Path to output file (captures stdout/stderr after completion).
73    output_file: Option<PathBuf>,
74    /// Whether to persist completed output to a host temp file. Disabled for
75    /// hermetic / read-only kernels (custom backend, NoLocal) whose output must
76    /// never reach the real filesystem outside the VFS — see
77    /// [`JobManager::set_persist_output_files`]. Stamped from the manager when
78    /// the job is registered. A hermetic kernel still reads the job's output
79    /// from its live streams (`/v/jobs/{id}/stdout`, or
80    /// [`JobManager::read_stdout`]) — the host file is a convenience, not the
81    /// only copy.
82    persist_output: bool,
83    /// Live stdout of this job. Handed out as [`JobStreams::stdout`], which
84    /// documents exactly which bytes reach it.
85    stdout_stream: Arc<BoundedStream>,
86    /// Live stderr of this job. See [`JobStreams::stderr`].
87    stderr_stream: Arc<BoundedStream>,
88    /// OS process ID (for stopped jobs).
89    pid: Option<u32>,
90    /// OS process group ID (for stopped jobs).
91    pgid: Option<u32>,
92    /// Whether this job is stopped (SIGTSTP).
93    stopped: bool,
94    /// Whether a terminating kill was dispatched at this job (`kill %N`, or
95    /// an embedder's cancel). Once the job unwinds, this turns its terminal
96    /// status into `Killed` instead of `Failed` — "someone killed it" and
97    /// "it errored on its own" stay distinguishable after the fact (GH #244).
98    /// A job that manages to finish successfully before the cascade lands
99    /// still reports `Done`: the result is the truth, the flag only colors
100    /// a non-ok exit.
101    killed: bool,
102    /// Cancellation token of the background fork running this job. Cancelling
103    /// it stops the job whether it is an in-process builtin future or wraps
104    /// external children (the cancellation cascade SIGTERM→SIGKILLs their
105    /// process groups). This is how `kill %N` reaches a job that has no OS
106    /// process group of its own (e.g. `sleep &`, a kaish builtin).
107    cancel: Option<tokio_util::sync::CancellationToken>,
108    /// Process groups of external children spawned while running this job.
109    /// Lets `kill -<sig> %N` deliver an arbitrary signal (STOP/CONT/USR1/…)
110    /// straight to the real processes via `killpg`, not just terminate. Empty
111    /// for a pure-builtin job (nothing with a PGID ran).
112    pgids: Vec<u32>,
113    /// Wall-clock time this job started running, stamped at construction.
114    /// Acquired via `kaish_types::clock::system_now` (not `SystemTime::now()`
115    /// directly) so this stays valid on `wasm32-unknown-unknown`. Surfaced on
116    /// `JobInfo.started_at` (GH #243).
117    started_at: SystemTime,
118    /// Wall-clock time this job's result became available, stamped once by
119    /// `try_poll` the moment `self.result` transitions from `None` to `Some`.
120    /// Surfaced on `JobInfo.finished_at` (GH #243).
121    finished_at: Option<SystemTime>,
122}
123
124impl Job {
125    /// Create a new job from a task handle.
126    pub fn new(id: JobId, session_id: u64, command: String, handle: JoinHandle<ExecResult>) -> Self {
127        Self {
128            id,
129            session_id,
130            command,
131            handle: Some(handle),
132            result_rx: None,
133            result: None,
134            output_file: None,
135            persist_output: true,
136            stdout_stream: Arc::new(BoundedStream::default_size()),
137            stderr_stream: Arc::new(BoundedStream::default_size()),
138            pid: None,
139            pgid: None,
140            stopped: false,
141            killed: false,
142            cancel: None,
143            pgids: Vec::new(),
144            started_at: kaish_types::clock::system_now(),
145            finished_at: None,
146        }
147    }
148
149    /// Create a new job from a result channel.
150    pub fn from_channel(id: JobId, session_id: u64, command: String, rx: oneshot::Receiver<ExecResult>) -> Self {
151        Self {
152            id,
153            session_id,
154            command,
155            handle: None,
156            result_rx: Some(rx),
157            result: None,
158            output_file: None,
159            persist_output: true,
160            stdout_stream: Arc::new(BoundedStream::default_size()),
161            stderr_stream: Arc::new(BoundedStream::default_size()),
162            pid: None,
163            pgid: None,
164            stopped: false,
165            killed: false,
166            cancel: None,
167            pgids: Vec::new(),
168            started_at: kaish_types::clock::system_now(),
169            finished_at: None,
170        }
171    }
172
173    /// Create a stopped job (from Ctrl-Z on a foreground process).
174    pub fn stopped(id: JobId, session_id: u64, command: String, pid: u32, pgid: u32) -> Self {
175        Self {
176            id,
177            session_id,
178            command,
179            handle: None,
180            result_rx: None,
181            result: None,
182            output_file: None,
183            persist_output: true,
184            stdout_stream: Arc::new(BoundedStream::default_size()),
185            stderr_stream: Arc::new(BoundedStream::default_size()),
186            pid: Some(pid),
187            pgid: Some(pgid),
188            stopped: true,
189            killed: false,
190            cancel: None,
191            pgids: Vec::new(),
192            // The foreground process actually started earlier (before Ctrl-Z
193            // stopped it into job tracking), but kaish had no job entry for it
194            // until now — "now" is the best available approximation, and a
195            // strict improvement over having no timestamp at all.
196            started_at: kaish_types::clock::system_now(),
197            finished_at: None,
198        }
199    }
200
201    /// Get the output file path (if available).
202    pub fn output_file(&self) -> Option<&PathBuf> {
203        self.output_file.as_ref()
204    }
205
206    /// This job's live output streams (see [`JobStreams`]).
207    pub fn streams(&self) -> JobStreams {
208        JobStreams {
209            stdout: self.stdout_stream.clone(),
210            stderr: self.stderr_stream.clone(),
211        }
212    }
213
214    /// Check if the job has completed.
215    ///
216    /// Stopped jobs are not considered done.
217    pub fn is_done(&mut self) -> bool {
218        if self.stopped {
219            return false;
220        }
221        self.try_poll();
222        self.result.is_some()
223    }
224
225    /// Get the job's status.
226    pub fn status(&mut self) -> JobStatus {
227        if self.stopped {
228            return JobStatus::Stopped;
229        }
230        self.try_poll();
231        match &self.result {
232            Some(r) if r.ok() => JobStatus::Done,
233            Some(_) if self.killed => JobStatus::Killed,
234            Some(_) => JobStatus::Failed,
235            None => JobStatus::Running,
236        }
237    }
238
239    /// Get the job's status as a string suitable for /v/jobs/{id}/status.
240    ///
241    /// Returns:
242    /// - `"running"` if the job is still running
243    /// - `"stopped"` if the job is stopped (Ctrl-Z / SIGTSTP)
244    /// - `"done:0"` if the job completed successfully
245    /// - `"killed:{code}"` if the job was terminated by `kill %N`
246    /// - `"failed:{code}"` if the job failed with an exit code
247    ///
248    /// The vocabulary must stay in step with [`Self::status`] — GH #252 was
249    /// exactly this pair drifting: `status()` learned the `stopped` check and
250    /// this string twin didn't, so `/v/jobs/N/status` reported a Ctrl-Z'd job
251    /// as `"running"` forever (a stopped job has no result channel, so
252    /// `try_poll` can never produce a result).
253    pub fn status_string(&mut self) -> String {
254        if self.stopped {
255            return "stopped".to_string();
256        }
257        self.try_poll();
258        match &self.result {
259            Some(r) if r.ok() => "done:0".to_string(),
260            Some(r) if self.killed => format!("killed:{}", r.code),
261            Some(r) => format!("failed:{}", r.code),
262            None => "running".to_string(),
263        }
264    }
265
266    /// Write job output to a temp file.
267    fn write_output_file(&self, result: &ExecResult) -> Option<PathBuf> {
268        // This is a human-readable text log; a binary stdout is noted, not
269        // dumped (lossy-decoding it would corrupt; raw bytes would garble the
270        // log). Only its size is recorded.
271        let is_bytes = result.is_bytes();
272        let text = if is_bytes {
273            std::borrow::Cow::Borrowed("")
274        } else {
275            result.text_out()
276        };
277        if !is_bytes && text.is_empty() && result.err.is_empty() {
278            return None;
279        }
280
281        let tmp_dir = std::env::temp_dir().join("kaish").join("jobs");
282        if std::fs::create_dir_all(&tmp_dir).is_err() {
283            tracing::warn!("Failed to create job output directory");
284            return None;
285        }
286
287        // Include the OS pid: `session_id` is only unique *within* a process
288        // (it's a process-local atomic that restarts at 0), so two kaish
289        // processes on one host — or two `cargo test` binaries — would
290        // otherwise both write `session_0_job_1.txt` into this shared dir and
291        // clobber each other (a real cross-process collision, and the source
292        // of the `test_cleanup_removes_temp_files` flake). pid + session_id +
293        // job id is unique across processes. Mirrors `output_limit`'s spill
294        // filename convention.
295        let filename = format!(
296            "session_{}_job_{}.{}.txt",
297            self.session_id,
298            self.id.0,
299            std::process::id()
300        );
301        let path = tmp_dir.join(filename);
302
303        let mut content = String::new();
304        content.push_str(&format!("# Job {}: {}\n", self.id, self.command));
305        // Same terminal-status words as `status()` — a killed job's persisted
306        // log must not claim it "Failed" on its own (GH #244 review finding).
307        let status = if result.ok() {
308            "Done"
309        } else if self.killed {
310            "Killed"
311        } else {
312            "Failed"
313        };
314        content.push_str(&format!("# Status: {status}\n\n"));
315
316        if is_bytes {
317            let n = result.out_bytes().map(|b| b.len()).unwrap_or(0);
318            content.push_str(&format!(
319                "## STDOUT\n[binary output: {n} bytes — omitted from this text log]\n"
320            ));
321        } else if !text.is_empty() {
322            content.push_str("## STDOUT\n");
323            content.push_str(&text);
324            if !text.ends_with('\n') {
325                content.push('\n');
326            }
327        }
328
329        if !result.err.is_empty() {
330            content.push_str("\n## STDERR\n");
331            content.push_str(&result.err);
332            if !result.err.ends_with('\n') {
333                content.push('\n');
334            }
335        }
336
337        match std::fs::write(&path, content) {
338            Ok(()) => Some(path),
339            Err(e) => {
340                tracing::warn!("Failed to write job output file: {}", e);
341                None
342            }
343        }
344    }
345
346    /// Remove any temp files associated with this job.
347    pub fn cleanup_files(&mut self) {
348        if let Some(path) = self.output_file.take() {
349            if let Err(e) = std::fs::remove_file(&path) {
350                // Ignore "not found" — file may not have been written
351                if e.kind() != io::ErrorKind::NotFound {
352                    tracing::warn!("Failed to clean up job output file {}: {}", path.display(), e);
353                }
354            }
355        }
356    }
357
358    /// Get the result if completed, without waiting.
359    pub fn try_result(&self) -> Option<&ExecResult> {
360        self.result.as_ref()
361    }
362
363    /// Try to poll the result channel and update status.
364    ///
365    /// This is a non-blocking check that updates `self.result` if the
366    /// job has completed. Returns true if the job is now done.
367    pub fn try_poll(&mut self) -> bool {
368        if self.result.is_some() {
369            return true;
370        }
371
372        // Try to poll the oneshot channel
373        if let Some(rx) = self.result_rx.as_mut() {
374            match rx.try_recv() {
375                Ok(result) => {
376                    self.result = Some(result);
377                    self.result_rx = None;
378                    self.finished_at = Some(kaish_types::clock::system_now());
379                    return true;
380                }
381                Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
382                    // Still running
383                    return false;
384                }
385                Err(tokio::sync::oneshot::error::TryRecvError::Closed) => {
386                    // The sender dropped without sending a result — the
387                    // spawned task's future panicked and unwound before
388                    // `tx.send(result)` ran (GH #247). `execute_background`
389                    // uses this oneshot-channel path exclusively for every
390                    // `&` job, so this is the ONLY place a background-job
391                    // panic surfaces; a wording indistinguishable from an
392                    // ordinary `exit 1` ("job channel closed", previously)
393                    // hid a kernel bug behind what read as a normal command
394                    // failure, and the case went to `tracing::error!` for
395                    // the first time here — it was not logged at all before.
396                    tracing::error!(
397                        job_id = %self.id,
398                        command = %self.command,
399                        "background job task ended without producing a result — \
400                         its future likely panicked"
401                    );
402                    self.result = Some(ExecResult::failure(
403                        1,
404                        format!(
405                            "job {}: task ended without a result (likely a kernel panic, \
406                             not the command's own exit) — see the kernel's logs",
407                            self.id
408                        ),
409                    ));
410                    self.result_rx = None;
411                    self.finished_at = Some(kaish_types::clock::system_now());
412                    return true;
413                }
414            }
415        }
416
417        // Check if handle is finished
418        if let Some(handle) = self.handle.as_mut()
419            && handle.is_finished() {
420                // Take the handle and wait for it (should be instant)
421                let Some(mut handle) = self.handle.take() else {
422                    return false;
423                };
424                // Poll directly with a noop waker — safe because is_finished() was true
425                let waker = std::task::Waker::noop();
426                let mut cx = std::task::Context::from_waker(waker);
427                let result = match std::pin::Pin::new(&mut handle).poll(&mut cx) {
428                    std::task::Poll::Ready(Ok(r)) => r,
429                    std::task::Poll::Ready(Err(e)) => {
430                        ExecResult::failure(1, format!("job panicked: {}", e))
431                    }
432                    std::task::Poll::Pending => {
433                        // is_finished() promised Ready, but if the runtime
434                        // ever says Pending anyway, dropping the taken handle
435                        // would strand the job as "Running" forever with its
436                        // result silently lost. Put it back and retry on a
437                        // later poll.
438                        self.handle = Some(handle);
439                        return false;
440                    }
441                };
442                self.result = Some(result);
443                self.finished_at = Some(kaish_types::clock::system_now());
444                return true;
445            }
446
447        false
448    }
449
450    /// The process groups recorded for this job, combining `pgids` (from
451    /// externals spawned while running) with the legacy single `pgid`
452    /// recorded for a *stopped* foreground job — the single accessor `list`/
453    /// `get`/`reap_finished` use to fill `JobInfo.pgids` (GH #243), and what
454    /// `JobManager::job_pgids` delegates to so the combine logic exists once.
455    fn pgids_combined(&self) -> Vec<u32> {
456        let mut v = self.pgids.clone();
457        if let Some(pg) = self.pgid
458            && !v.contains(&pg)
459        {
460            v.push(pg);
461        }
462        v
463    }
464
465    /// Build the full `JobInfo` snapshot for this job. `status` is taken as a
466    /// parameter rather than recomputed here because computing it requires
467    /// `&mut self` (it polls) — callers (`list`/`get`/`reap_finished`)
468    /// already did that poll before calling this. The
469    /// single chokepoint that populates every `JobInfo` field (GH #243), so
470    /// the call sites can't drift on which fields they remember to set.
471    fn to_info(&self, status: JobStatus) -> JobInfo {
472        let exit_code = self.result.as_ref().map(|r| r.code);
473        JobInfo::new(self.id, self.command.clone(), status)
474            .with_output_file(self.output_file.clone())
475            .with_pid(self.pid)
476            .with_exit_code(exit_code)
477            .with_started_at(self.started_at)
478            .with_finished_at(self.finished_at)
479            .with_pgids(self.pgids_combined())
480    }
481}
482
483/// Process-wide counter handing each JobManager a distinct session ID. Job IDs
484/// restart at 1 per manager, so the session ID is what keeps output file paths
485/// from colliding between managers sharing a process (concurrent tests, forks).
486/// It is process-LOCAL (restarts at 0 per process), so output filenames also
487/// mix in the OS pid to stay unique across processes — see `write_output_file`.
488static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(0);
489
490/// Remove job output files in `/tmp/kaish/jobs/` that were written by processes
491/// which are no longer running. Run once per process (guarded by a `Once` in
492/// [`JobManager::new`]).
493///
494/// Strategy: filenames follow `session_S_job_J.PID.txt`. We parse the PID
495/// component and skip files whose PID matches the current process (those
496/// belong to live sessions in this very process). For other PIDs we check
497/// `/proc/{pid}` on Linux; on non-Linux platforms we skip the prune entirely
498/// since there is no cheap cross-platform liveness check.
499///
500/// All errors are intentionally ignored — this is opportunistic cleanup only.
501fn prune_orphaned_job_files() {
502    // Only prune on Linux where /proc/{pid} is a reliable liveness check.
503    #[cfg(target_os = "linux")]
504    {
505        let jobs_dir = std::env::temp_dir().join("kaish").join("jobs");
506        let Ok(entries) = std::fs::read_dir(&jobs_dir) else {
507            return; // directory doesn't exist yet — nothing to prune
508        };
509        let current_pid = std::process::id();
510        for entry in entries.flatten() {
511            let name = entry.file_name();
512            let name_str = name.to_string_lossy();
513            // Expected format: session_S_job_J.PID.txt
514            // The PID sits between the last '.' before ".txt" and the preceding '.'.
515            let file_pid: Option<u32> = name_str
516                .strip_suffix(".txt")
517                .and_then(|s| s.rsplit_once('.'))
518                .and_then(|(_, pid_str)| pid_str.parse().ok());
519            let Some(pid) = file_pid else {
520                continue; // not a job output file — skip
521            };
522            if pid == current_pid {
523                continue; // belongs to the current process — leave it alone
524            }
525            // Check if the owning process is still alive via /proc.
526            if std::path::Path::new(&format!("/proc/{}", pid)).exists() {
527                continue; // process is still running — leave it alone
528            }
529            // Process is gone: remove the stale file. Error intentionally ignored.
530            let _ = std::fs::remove_file(entry.path());
531        }
532    }
533}
534
535/// Manager for background jobs.
536pub struct JobManager {
537    /// Process-unique ID for this manager, mixed into job output file paths.
538    session_id: u64,
539    /// Counter for generating unique job IDs.
540    next_id: AtomicU64,
541    /// Map of job ID to job.
542    jobs: Arc<Mutex<HashMap<JobId, Job>>>,
543    /// Whether completed jobs persist their output to a host temp file. On by
544    /// default; a hermetic / read-only kernel disables it so output never
545    /// bypasses the VFS onto the real filesystem (see
546    /// [`set_persist_output_files`](Self::set_persist_output_files)). Stamped
547    /// onto each [`Job`] at registration.
548    persist_output_files: std::sync::atomic::AtomicBool,
549    /// SIGTERM→SIGKILL grace of the cancellation cascade, in milliseconds —
550    /// mirrored from `KernelConfig::kill_grace` at kernel construction so the
551    /// `kill` builtin can bound its wait-for-death on the same number the
552    /// cascade actually uses (GH #244). Milliseconds in an atomic rather than
553    /// a locked `Duration` because readers are on the kill path and never
554    /// need sub-millisecond precision.
555    kill_grace_ms: AtomicU64,
556    /// How many finished jobs stay tracked before the oldest are reaped to
557    /// make room (GH #244: nothing auto-reaped, so a long-lived embedder
558    /// accumulated results, streams, and temp files without bound). Enforced
559    /// at registration time — see [`Self::enforce_retention_locked`] for what
560    /// "finished" excludes (gated and stopped jobs are never evicted).
561    finished_retention: AtomicU64,
562}
563
564/// Default for [`JobManager::set_finished_retention`]: keep the last 100
565/// finished jobs. Interactive sessions never notice (the REPL reaps every
566/// prompt); an embedder that never reaps stays bounded.
567pub const DEFAULT_FINISHED_RETENTION: u64 = 100;
568
569impl JobManager {
570    /// Create a new job manager.
571    ///
572    /// On construction, best-effort prunes stale job output files left by
573    /// previously crashed kaish processes. All errors are intentionally ignored
574    /// — startup cleanup is opportunistic and must never prevent the manager
575    /// from being created (silent-fallback rule: the only case where silent is
576    /// correct is read-only / cleanup-only paths with no data loss risk).
577    ///
578    /// # Scoping decision
579    /// All sessions share a single `/tmp/kaish/jobs/` directory. Filenames embed
580    /// the OS PID that wrote them (`session_S_job_J.PID.txt`). Files from the
581    /// current process are never touched here — only files whose embedded PID
582    /// refers to a dead process are removed. On Linux we check `/proc/{pid}` for
583    /// existence; on other platforms we skip the prune rather than guess.
584    pub fn new() -> Self {
585        // Orphans from dead sessions only need pruning once per process, not on
586        // every JobManager (kernels + every fork build one). The `Once` keeps
587        // the dir scan / `/proc` checks off the hot path of background jobs,
588        // scatter workers, and pipeline stages.
589        static PRUNE_ONCE: std::sync::Once = std::sync::Once::new();
590        PRUNE_ONCE.call_once(prune_orphaned_job_files);
591        Self {
592            session_id: NEXT_SESSION_ID.fetch_add(1, Ordering::SeqCst),
593            next_id: AtomicU64::new(1),
594            jobs: Arc::new(Mutex::new(HashMap::new())),
595            persist_output_files: std::sync::atomic::AtomicBool::new(true),
596            kill_grace_ms: AtomicU64::new(2_000),
597            finished_retention: AtomicU64::new(DEFAULT_FINISHED_RETENTION),
598        }
599    }
600
601    /// Mirror `KernelConfig::kill_grace` onto the manager (see the field doc).
602    pub fn set_kill_grace(&self, grace: std::time::Duration) {
603        self.kill_grace_ms
604            .store(grace.as_millis().min(u128::from(u64::MAX)) as u64, Ordering::Relaxed);
605    }
606
607    /// The cancellation cascade's SIGTERM→SIGKILL grace (see [`Self::set_kill_grace`]).
608    pub fn kill_grace(&self) -> std::time::Duration {
609        std::time::Duration::from_millis(self.kill_grace_ms.load(Ordering::Relaxed))
610    }
611
612    /// Set how many finished jobs stay tracked (default 100,
613    /// `DEFAULT_FINISHED_RETENTION`). `0` keeps no finished jobs beyond the
614    /// gate-safety rule — gated and stopped jobs are never evicted regardless.
615    pub fn set_finished_retention(&self, keep: u64) {
616        self.finished_retention.store(keep, Ordering::Relaxed);
617    }
618
619    /// Toggle whether completed jobs persist their output to a host temp file.
620    ///
621    /// Disable this for a hermetic / read-only kernel: the host write in
622    /// `Job::write_output_file` uses `std::fs` directly and so bypasses the
623    /// VFS (and any read-only mount). Turning it off costs a hermetic kernel
624    /// nothing it cannot get elsewhere — the job's output is in its live
625    /// streams (`/v/jobs/{id}/stdout`, or [`JobManager::read_stdout`]), bounded by a
626    /// 10 MB ring. Redirect to a VFS path (`cmd > /tmp/out &`) when a job
627    /// outruns that ring.
628    ///
629    /// Must be set before jobs are spawned — the flag is stamped onto each job
630    /// at registration time, not consulted at completion.
631    pub fn set_persist_output_files(&self, on: bool) {
632        self.persist_output_files.store(on, Ordering::Relaxed);
633    }
634
635    /// Whether completed jobs persist their output to a host temp file.
636    pub fn persist_output_files(&self) -> bool {
637        self.persist_output_files.load(Ordering::Relaxed)
638    }
639
640    /// Spawn a new background job from a future.
641    ///
642    /// The job is inserted into the map synchronously before returning,
643    /// guaranteeing it's immediately queryable via `exists()` or `get()`.
644    pub async fn spawn<F>(&self, command: String, future: F) -> JobId
645    where
646        F: std::future::Future<Output = ExecResult> + Send + 'static,
647    {
648        let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
649        // Propagate the embedder's trace context across the spawn boundary so
650        // background-job spans stay in the same trace (see telemetry module).
651        let handle = tokio::spawn(crate::telemetry::bind_current_context(future));
652        let mut job = Job::new(id, self.session_id, command, handle);
653        job.persist_output = self.persist_output_files();
654
655        // Insert under an async lock — NOT a busy-spin on try_lock. The old
656        // sync spin could livelock the executor: on a current-thread runtime it
657        // blocks the only worker thread, so a task holding the lock across an
658        // await can never make progress to release it. `lock().await` yields
659        // instead. The insert still completes before we return, so the job is
660        // immediately queryable via `exists()`/`get()`.
661        let mut jobs = self.jobs.lock().await;
662        jobs.insert(id, job);
663        self.enforce_retention_locked(&mut jobs);
664
665        id
666    }
667
668    /// Spawn a job that's already running and communicate via channel.
669    pub async fn register(&self, command: String, rx: oneshot::Receiver<ExecResult>) -> JobId {
670        let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
671        let mut job = Job::from_channel(id, self.session_id, command, rx);
672        job.persist_output = self.persist_output_files();
673
674        let mut jobs = self.jobs.lock().await;
675        jobs.insert(id, job);
676        self.enforce_retention_locked(&mut jobs);
677
678        id
679    }
680
681    /// A job's live output streams, or `None` if there is no such job.
682    ///
683    /// The producer side: `Kernel::try_execute_external` takes these for the
684    /// job it is running under and tees the child's pipes into them as the
685    /// bytes arrive.
686    pub async fn streams(&self, id: JobId) -> Option<JobStreams> {
687        let jobs = self.jobs.lock().await;
688        jobs.get(&id).map(|job| job.streams())
689    }
690
691    /// Snapshot a job's stdout so far, or `None` if there is no such job.
692    ///
693    /// **Readable while the job runs** — that is the point. `None` and
694    /// `Some(vec![])` are different answers: no such job, versus a job that
695    /// has not written anything yet.
696    pub async fn read_stdout(&self, id: JobId) -> Option<Vec<u8>> {
697        let stream = self.streams(id).await?.stdout;
698        Some(stream.read().await)
699    }
700
701    /// Snapshot a job's stderr so far, or `None` if there is no such job.
702    /// See [`Self::read_stdout`].
703    pub async fn read_stderr(&self, id: JobId) -> Option<Vec<u8>> {
704        let stream = self.streams(id).await?.stderr;
705        Some(stream.read().await)
706    }
707
708    /// Close out a finished job's streams: write the captured result into a
709    /// stream that received nothing live, then close both.
710    ///
711    /// The conditional is the no-double-write rule. A stream with live bytes
712    /// in it already holds exactly what the child emitted; writing
713    /// `result.text_out()` on top would repeat all of it. A stream with no
714    /// live bytes belongs to a job with nothing to tee — a builtin returns
715    /// its output as a value, not as a pipe — and would otherwise read empty
716    /// forever.
717    ///
718    /// Called by the background task that owns the job, before it hands the
719    /// result over, so a reader that sees a terminal `status` also sees a
720    /// closed, complete stream.
721    pub async fn finalize_streams(&self, id: JobId, result: &ExecResult) {
722        let Some(streams) = self.streams(id).await else {
723            return;
724        };
725
726        if streams.stdout.stats().await.total_written == 0 {
727            // Raw bytes when the payload is binary; `text_out` would decode it
728            // lossily and corrupt what a caller reads back out of the node.
729            match result.out_bytes() {
730                Some(bytes) => streams.stdout.write(bytes).await,
731                None => streams.stdout.write(result.text_out().as_bytes()).await,
732            }
733        }
734        if streams.stderr.stats().await.total_written == 0 {
735            streams.stderr.write(result.err.as_bytes()).await;
736        }
737
738        streams.stdout.close().await;
739        streams.stderr.close().await;
740    }
741
742    /// Wait for a specific job to complete.
743    ///
744    /// Returns `None` when the job does not exist **or is stopped** — a stopped
745    /// job can never finish, so waiting on one would hang forever. Callers that
746    /// need to tell the two apart check [`JobManager::get`] after a `None`.
747    ///
748    /// The job's pending awaitable (its task handle or result channel) is taken
749    /// out of the map under the lock, then the lock is **released before**
750    /// awaiting completion. Holding the `jobs` mutex across the await would
751    /// block every other job operation (spawn/register/list/status/kill) for the
752    /// whole duration of the job — so a nested `&` started under a parked
753    /// `wait %N` would deadlock. The lock is re-acquired only to finalize
754    /// (persist output, cache the result).
755    pub async fn wait(&self, id: JobId) -> Option<ExecResult> {
756        // Poll for completion WITHOUT removing the job's `JoinHandle` from the
757        // map: `Job::try_poll` (via `is_done`) consumes the handle only once it
758        // has finished. That matters two ways:
759        //   * Drop-safe: a waiter dropped mid-wait (e.g. `timeout N wait %1`)
760        //     never carries the handle off and orphans the result, so a later
761        //     `wait %1` still completes instead of hanging.
762        //   * No lock-across-await: we sleep between polls rather than holding
763        //     the `jobs` mutex over the wait (which would block every other job
764        //     op — the deadlock this method exists to avoid) or busy-spinning.
765        // Cost is up to one poll interval of latency on completion — imperceptible
766        // for a job wait, and the sleep keeps idle CPU at zero.
767        loop {
768            {
769                let mut jobs = self.jobs.lock().await;
770                let job = jobs.get_mut(&id)?;
771                // A stopped job can never finish: `is_done()` returns `false`
772                // for as long as it is stopped, so polling would spin forever —
773                // the same hang `wait_all`'s stopped-skip closes, reachable
774                // here directly (`wait %N` on a Ctrl-Z'd job) and by a job
775                // stopping *after* `wait_all` took its snapshot (the bg reaper
776                // observes a SIGSTOP and flips the flag mid-wait). Bail loud;
777                // the caller resumes with `bg`/`fg` and waits again.
778                if job.stopped {
779                    return None;
780                }
781                if job.is_done() {
782                    let result = job
783                        .result
784                        .clone()
785                        .unwrap_or_else(|| ExecResult::failure(1, "no result"));
786                    // Finalize once: persist output (idempotent on output_file).
787                    if job.persist_output
788                        && job.output_file.is_none()
789                        && let Some(path) = job.write_output_file(&result)
790                    {
791                        job.output_file = Some(path);
792                    }
793                    // A completion was just observed — enforce retention here
794                    // too, not only at registration (an embedder that stops
795                    // registering must still stay bounded). The result is
796                    // already cloned, so evicting this very job (cap 0) is
797                    // safe.
798                    self.enforce_retention_locked(&mut jobs);
799                    return Some(result);
800                }
801            }
802            // Lock released between polls — other job ops run freely.
803            tokio::time::sleep(std::time::Duration::from_millis(10)).await;
804        }
805    }
806
807    /// Wait for all jobs that can still finish, returning results in completion
808    /// order.
809    ///
810    /// **Stopped jobs are skipped, and that is load-bearing.** A Ctrl-Z'd job is
811    /// registered by [`JobManager::register_stopped`] with no `JoinHandle` and no
812    /// result channel, and [`Job::is_done`] returns `false` for as long as it is
813    /// stopped — so nothing can ever make it done. Waiting on one here spun the
814    /// 10ms poll loop forever, and since [`crate::Kernel::shutdown`] calls this, a single
815    /// Ctrl-Z hung shutdown with no timeout and no escape. Skip them: `wait_all`
816    /// means "wait for everything that will finish", not "wait for everything".
817    ///
818    /// The filter below is a snapshot; a job that stops *after* it (the bg
819    /// reaper observing a SIGSTOP) is caught by [`JobManager::wait`]'s own
820    /// stopped guard, which returns `None` instead of re-creating the hang.
821    ///
822    /// A caller that wants a stopped job to finish must resume it first (`bg`/`fg`).
823    pub async fn wait_all(&self) -> Vec<(JobId, ExecResult)> {
824        let mut results = Vec::new();
825
826        let ids: Vec<JobId> = {
827            let jobs = self.jobs.lock().await;
828            jobs.iter()
829                .filter(|(_, job)| !job.stopped)
830                .map(|(id, _)| *id)
831                .collect()
832        };
833
834        for id in ids {
835            if let Some(result) = self.wait(id).await {
836                results.push((id, result));
837            }
838        }
839
840        results
841    }
842
843    /// List all jobs with their status.
844    ///
845    /// Listing polls every job, so this is also a completion-observation
846    /// point: retention is enforced here (after the snapshot is taken — the
847    /// returned list is complete even for entries evicted by it).
848    ///
849    /// Sorted by [`JobId`] (GH #247) — the backing map is a `HashMap`, whose
850    /// iteration order is arbitrary and was leaking straight through to
851    /// `jobs`, `/v/jobs`, and `--json`: two jobs could list as `[2, 1]`. An
852    /// MCP caller handed that order, or a snapshot test pinned against it,
853    /// saw a flake with no code change — sorting makes the order a stated
854    /// contract instead of whatever the hasher happened to do.
855    pub async fn list(&self) -> Vec<JobInfo> {
856        let mut jobs = self.jobs.lock().await;
857        let mut infos: Vec<JobInfo> = jobs
858            .values_mut()
859            .map(|job| {
860                let status = job.status();
861                job.to_info(status)
862            })
863            .collect();
864        infos.sort_by_key(|info| info.id);
865        self.enforce_retention_locked(&mut jobs);
866        infos
867    }
868
869    /// Get the number of running jobs.
870    pub async fn running_count(&self) -> usize {
871        let mut jobs = self.jobs.lock().await;
872        let mut count = 0;
873        for job in jobs.values_mut() {
874            if !job.is_done() {
875                count += 1;
876            }
877        }
878        count
879    }
880
881    /// Remove completed jobs from tracking and clean up their temp files,
882    /// returning info for each job removed.
883    ///
884    /// Shared by `jobs --cleanup` (which only needs a count) and the REPL's
885    /// pre-prompt notification (GH #131, which needs the id/command/status of
886    /// each job so it can print `[N]+ Done ...` before reaping it) — one rule
887    /// for "is this job safe to reap", not two copies that could drift.
888    pub async fn reap_finished(&self) -> Vec<JobInfo> {
889        let mut jobs = self.jobs.lock().await;
890        let done_ids: Vec<JobId> = jobs
891            .iter_mut()
892            .filter_map(|(id, job)| job.is_done().then_some(*id))
893            .collect();
894
895        let mut removed = Vec::with_capacity(done_ids.len());
896        for id in done_ids {
897            let Some(mut job) = jobs.remove(&id) else {
898                continue;
899            };
900            let status = job.status();
901            let info = job.to_info(status);
902            job.cleanup_files();
903            removed.push(info);
904        }
905        removed
906    }
907
908    /// Remove completed jobs from tracking and clean up their temp files.
909    ///
910    /// The count-only form of [`reap_finished`](Self::reap_finished) that
911    /// `jobs --cleanup` reports.
912    pub async fn cleanup(&self) {
913        self.reap_finished().await;
914    }
915
916    /// Evict the oldest finished jobs beyond the retention cap
917    /// ([`Self::set_finished_retention`]). Called with the jobs lock held at
918    /// registration time (the moment the tracked-job count grows) **and** at
919    /// the completion-observation points (`list`, `wait`'s finalize) — so an
920    /// embedder that stops registering but keeps observing stays bounded
921    /// without a background sweeper (GH #244). A session that registers jobs
922    /// and then never calls anything at all holds what it registered; there
923    /// is no sweeper task by design. "Finished" follows `reap_finished`'s
924    /// rule: stopped jobs are not finished. Eviction
925    /// is oldest `finished_at` first, so the survivors are the newest N.
926    fn enforce_retention_locked(&self, jobs: &mut HashMap<JobId, Job>) {
927        let keep = self.finished_retention.load(Ordering::Relaxed) as usize;
928        let mut finished: Vec<(JobId, SystemTime)> = jobs
929            .iter_mut()
930            .filter_map(|(id, job)| {
931                job.is_done()
932                    .then(|| (*id, job.finished_at.unwrap_or(job.started_at)))
933            })
934            .collect();
935        if finished.len() <= keep {
936            return;
937        }
938        // Job IDs tie-break equal timestamps (they grow monotonically), so
939        // eviction order is deterministic even under a coarse clock.
940        finished.sort_by_key(|&(id, finished_at)| (finished_at, id.0));
941        let evict = finished.len() - keep;
942        for (id, _) in finished.into_iter().take(evict) {
943            if let Some(mut job) = jobs.remove(&id) {
944                job.cleanup_files();
945            }
946        }
947    }
948
949    /// Check if a specific job exists.
950    pub async fn exists(&self, id: JobId) -> bool {
951        let jobs = self.jobs.lock().await;
952        jobs.contains_key(&id)
953    }
954
955    /// Get info for a specific job.
956    pub async fn get(&self, id: JobId) -> Option<JobInfo> {
957        let mut jobs = self.jobs.lock().await;
958        jobs.get_mut(&id).map(|job| {
959            let status = job.status();
960            job.to_info(status)
961        })
962    }
963
964    /// Get the command string for a job.
965    pub async fn get_command(&self, id: JobId) -> Option<String> {
966        let jobs = self.jobs.lock().await;
967        jobs.get(&id).map(|job| job.command.clone())
968    }
969
970    /// Get the status string for a job (for /v/jobs/{id}/status).
971    pub async fn get_status_string(&self, id: JobId) -> Option<String> {
972        let mut jobs = self.jobs.lock().await;
973        jobs.get_mut(&id).map(|job| job.status_string())
974    }
975
976    /// List all job IDs, sorted (GH #247 — see [`Self::list`]'s doc for why
977    /// the backing `HashMap`'s iteration order is not good enough here: this
978    /// backs the `/v/jobs` directory listing via [`crate::vfs::JobFs`]).
979    pub async fn list_ids(&self) -> Vec<JobId> {
980        let jobs = self.jobs.lock().await;
981        let mut ids: Vec<JobId> = jobs.keys().copied().collect();
982        ids.sort();
983        ids
984    }
985
986    /// Register a stopped job (from Ctrl-Z on a foreground process).
987    pub async fn register_stopped(&self, command: String, pid: u32, pgid: u32) -> JobId {
988        let id = JobId(self.next_id.fetch_add(1, Ordering::SeqCst));
989        let job = Job::stopped(id, self.session_id, command, pid, pgid);
990        let mut jobs = self.jobs.lock().await;
991        jobs.insert(id, job);
992        self.enforce_retention_locked(&mut jobs);
993        id
994    }
995
996    /// Mark a job as stopped with its process info.
997    pub async fn stop_job(&self, id: JobId, pid: u32, pgid: u32) {
998        let mut jobs = self.jobs.lock().await;
999        if let Some(job) = jobs.get_mut(&id) {
1000            job.stopped = true;
1001            job.pid = Some(pid);
1002            job.pgid = Some(pgid);
1003        }
1004    }
1005
1006    /// Mark a stopped job as resumed.
1007    pub async fn resume_job(&self, id: JobId) {
1008        let mut jobs = self.jobs.lock().await;
1009        if let Some(job) = jobs.get_mut(&id) {
1010            job.stopped = false;
1011        }
1012    }
1013
1014    /// Get the most recently stopped job.
1015    pub async fn last_stopped(&self) -> Option<JobId> {
1016        let mut jobs = self.jobs.lock().await;
1017        // Find the highest-numbered stopped job
1018        let mut best: Option<JobId> = None;
1019        for job in jobs.values_mut() {
1020            if job.stopped {
1021                match best {
1022                    None => best = Some(job.id),
1023                    Some(b) if job.id.0 > b.0 => best = Some(job.id),
1024                    _ => {}
1025                }
1026            }
1027        }
1028        best
1029    }
1030
1031    /// Get process info (pid, pgid) for a job.
1032    pub async fn get_process_info(&self, id: JobId) -> Option<(u32, u32)> {
1033        let jobs = self.jobs.lock().await;
1034        jobs.get(&id).and_then(|job| {
1035            match (job.pid, job.pgid) {
1036                (Some(pid), Some(pgid)) => Some((pid, pgid)),
1037                _ => None,
1038            }
1039        })
1040    }
1041
1042    /// Record the cancellation token of the fork running a background job, so
1043    /// `kill %N` can stop the job even when it has no OS process group of its
1044    /// own (e.g. a pure builtin like `sleep &`).
1045    pub async fn set_cancel_token(&self, id: JobId, token: tokio_util::sync::CancellationToken) {
1046        let mut jobs = self.jobs.lock().await;
1047        if let Some(job) = jobs.get_mut(&id) {
1048            job.cancel = Some(token);
1049        }
1050    }
1051
1052    /// Flag a terminating kill and trip the job's cancellation token, as one
1053    /// operation under the jobs lock. Returns `false` — and leaves the job
1054    /// **unflagged** — when there is no lever to kill with: no cancellation
1055    /// token recorded and no OS signal already delivered (`delivered`). The
1056    /// flag turns the job's terminal status into `Killed`, so setting it
1057    /// without a working delivery would misclassify a later *organic* failure
1058    /// as a kill (found in review: `JobManager::spawn`/`register` jobs have
1059    /// no token unless the kernel records one).
1060    ///
1061    /// The flag is set *before* the token trips (the job can unwind the
1062    /// instant it does; a flag set after races the status read), and the
1063    /// token is cancelled after the lock drops — `CancellationToken::cancel`
1064    /// is synchronous, but waking waiters under the jobs lock buys nothing.
1065    pub async fn mark_killed_and_cancel(&self, id: JobId, delivered: bool) -> bool {
1066        let token = {
1067            let mut jobs = self.jobs.lock().await;
1068            let Some(job) = jobs.get_mut(&id) else {
1069                return false;
1070            };
1071            let token = job.cancel.clone();
1072            if token.is_none() && !delivered {
1073                return false;
1074            }
1075            job.killed = true;
1076            token
1077        };
1078        if let Some(token) = token {
1079            token.cancel();
1080        }
1081        true
1082    }
1083
1084    /// Cancel a job by its token. Returns `true` if a token was recorded and
1085    /// cancelled. The cancellation cascade stops in-process builtin futures and
1086    /// SIGTERM→SIGKILLs any external children's process groups.
1087    pub async fn cancel(&self, id: JobId) -> bool {
1088        let jobs = self.jobs.lock().await;
1089        match jobs.get(&id).and_then(|job| job.cancel.clone()) {
1090            Some(token) => {
1091                token.cancel();
1092                true
1093            }
1094            None => false,
1095        }
1096    }
1097
1098    /// Record a process group spawned while running a background job. Lets
1099    /// `kill -<sig> %N` deliver an arbitrary signal directly to the real
1100    /// processes. Deduplicated (a job may spawn several externals).
1101    pub async fn add_pgid(&self, id: JobId, pgid: u32) {
1102        let mut jobs = self.jobs.lock().await;
1103        if let Some(job) = jobs.get_mut(&id) {
1104            if !job.pgids.contains(&pgid) {
1105                job.pgids.push(pgid);
1106            }
1107        }
1108    }
1109
1110    /// The process groups recorded for a job (empty for a pure-builtin job).
1111    /// Includes the legacy single `pgid` recorded for *stopped* jobs (Ctrl-Z),
1112    /// so `kill %N` signals a stopped foreground job's group too.
1113    pub async fn job_pgids(&self, id: JobId) -> Vec<u32> {
1114        let jobs = self.jobs.lock().await;
1115        jobs.get(&id).map(Job::pgids_combined).unwrap_or_default()
1116    }
1117
1118    /// Non-blocking accessor for a finished job's result — `None` while the
1119    /// job is still `Running`/`Stopped`, or if `id` doesn't exist. Unlike
1120    /// [`Self::wait`], this never parks: it polls once and returns whatever is
1121    /// (or isn't) already available. GH #243: previously the only ways to
1122    /// read a job's `ExecResult` were `wait` (blocks until done) or
1123    /// string-parsing `failed:{code}` off `/v/jobs/N/status`.
1124    pub async fn try_result(&self, id: JobId) -> Option<ExecResult> {
1125        let mut jobs = self.jobs.lock().await;
1126        let job = jobs.get_mut(&id)?;
1127        job.try_poll();
1128        job.try_result().cloned()
1129    }
1130
1131    /// Remove a job from tracking.
1132    pub async fn remove(&self, id: JobId) {
1133        let mut jobs = self.jobs.lock().await;
1134        if let Some(mut job) = jobs.remove(&id) {
1135            job.cleanup_files();
1136        }
1137    }
1138}
1139
1140impl Default for JobManager {
1141    fn default() -> Self {
1142        Self::new()
1143    }
1144}
1145
1146#[cfg(test)]
1147mod tests {
1148    use super::*;
1149    use std::time::Duration;
1150
1151    #[tokio::test]
1152    async fn test_no_host_output_file_when_persistence_disabled() {
1153        // A hermetic / read-only kernel (custom backend, or NoLocal mode)
1154        // disables host output-file persistence so a background job's output
1155        // never lands on the real filesystem via `std::fs`, bypassing the VFS.
1156        let manager = JobManager::new();
1157        assert!(manager.persist_output_files(), "default is to persist");
1158        manager.set_persist_output_files(false);
1159        assert!(!manager.persist_output_files());
1160
1161        let id = manager.spawn("leaky".to_string(), async {
1162            ExecResult::success("output that must not hit host disk")
1163        }).await;
1164        tokio::time::sleep(Duration::from_millis(10)).await;
1165        let result = manager.wait(id).await;
1166        assert!(result.is_some());
1167
1168        // No temp file should have been written to the host filesystem.
1169        let output_file = {
1170            let jobs = manager.jobs.lock().await;
1171            jobs.get(&id).and_then(|j| j.output_file().cloned())
1172        };
1173        assert!(
1174            output_file.is_none(),
1175            "no host output file should be written when persistence is disabled, got {output_file:?}"
1176        );
1177    }
1178
1179    #[tokio::test]
1180    async fn test_spawn_and_wait() {
1181        let manager = JobManager::new();
1182
1183        let id = manager.spawn("test".to_string(), async {
1184            tokio::time::sleep(Duration::from_millis(10)).await;
1185            ExecResult::success("done")
1186        }).await;
1187
1188        // Wait a bit for the job to be registered
1189        tokio::time::sleep(Duration::from_millis(5)).await;
1190
1191        let result = manager.wait(id).await;
1192        assert!(result.is_some());
1193        let result = result.unwrap();
1194        assert!(result.ok());
1195        assert_eq!(&*result.text_out(), "done");
1196    }
1197
1198    #[tokio::test]
1199    async fn test_wait_all() {
1200        let manager = JobManager::new();
1201
1202        manager.spawn("job1".to_string(), async {
1203            tokio::time::sleep(Duration::from_millis(10)).await;
1204            ExecResult::success("one")
1205        }).await;
1206
1207        manager.spawn("job2".to_string(), async {
1208            tokio::time::sleep(Duration::from_millis(5)).await;
1209            ExecResult::success("two")
1210        }).await;
1211
1212        // Wait for jobs to register
1213        tokio::time::sleep(Duration::from_millis(5)).await;
1214
1215        let results = manager.wait_all().await;
1216        assert_eq!(results.len(), 2);
1217    }
1218
1219    #[tokio::test]
1220    async fn test_list_jobs() {
1221        let manager = JobManager::new();
1222
1223        manager.spawn("test job".to_string(), async {
1224            tokio::time::sleep(Duration::from_millis(50)).await;
1225            ExecResult::success("")
1226        }).await;
1227
1228        // Wait for job to register
1229        tokio::time::sleep(Duration::from_millis(5)).await;
1230
1231        let jobs = manager.list().await;
1232        assert_eq!(jobs.len(), 1);
1233        assert_eq!(jobs[0].command, "test job");
1234        assert_eq!(jobs[0].status, JobStatus::Running);
1235    }
1236
1237    #[tokio::test]
1238    async fn test_job_status_after_completion() {
1239        let manager = JobManager::new();
1240
1241        let id = manager.spawn("quick".to_string(), async {
1242            ExecResult::success("")
1243        }).await;
1244
1245        // Wait for job to complete
1246        tokio::time::sleep(Duration::from_millis(10)).await;
1247        let _ = manager.wait(id).await;
1248
1249        let info = manager.get(id).await;
1250        assert!(info.is_some());
1251        assert_eq!(info.unwrap().status, JobStatus::Done);
1252    }
1253
1254    #[tokio::test]
1255    async fn test_job_info_carries_exit_code_on_failure() {
1256        // GH #243(a): a job that exited 42 must surface the code on
1257        // JobInfo.exit_code, not just JobStatus::Failed — the audit verified
1258        // `jobs --json` lost it entirely.
1259        let manager = JobManager::new();
1260
1261        let id = manager
1262            .spawn("sh -c 'exit 42'".to_string(), async {
1263                ExecResult::failure(42, "")
1264            })
1265            .await;
1266
1267        tokio::time::sleep(Duration::from_millis(10)).await;
1268
1269        let info = manager.get(id).await.expect("job exists");
1270        assert_eq!(info.status, JobStatus::Failed);
1271        assert_eq!(info.exit_code, Some(42), "exit code must survive onto JobInfo");
1272    }
1273
1274    #[tokio::test]
1275    async fn test_job_info_exit_code_none_while_running() {
1276        let manager = JobManager::new();
1277        let (_tx, rx) = oneshot::channel();
1278        let id = manager.register("still going".to_string(), rx).await;
1279
1280        let info = manager.get(id).await.expect("job exists");
1281        assert_eq!(info.status, JobStatus::Running);
1282        assert!(info.exit_code.is_none(), "a running job has no exit code yet");
1283    }
1284
1285    #[tokio::test]
1286    async fn test_job_info_started_at_and_finished_at() {
1287        // GH #243(b): timestamps must be present so an embedder can compute
1288        // "running for Ns" or sort by recency.
1289        let manager = JobManager::new();
1290        let before_spawn = kaish_types::clock::system_now();
1291
1292        let id = manager
1293            .spawn("quick".to_string(), async {
1294                tokio::time::sleep(Duration::from_millis(10)).await;
1295                ExecResult::success("")
1296            })
1297            .await;
1298
1299        // Immediately after spawn, started_at is set but finished_at is not.
1300        let info = manager.get(id).await.expect("job exists");
1301        assert!(
1302            info.started_at >= before_spawn,
1303            "started_at must be stamped at (or after) spawn time"
1304        );
1305        assert!(info.finished_at.is_none(), "not finished yet");
1306
1307        let _ = manager.wait(id).await;
1308
1309        let info = manager.get(id).await.expect("job exists");
1310        let finished_at = info.finished_at.expect("finished_at must be set once done");
1311        assert!(
1312            finished_at >= info.started_at,
1313            "finished_at must be at or after started_at"
1314        );
1315    }
1316
1317    #[tokio::test]
1318    async fn test_job_info_surfaces_pgids() {
1319        // GH #243(c): pgids (real OS process groups an embedder actually
1320        // creates) must be surfaced on JobInfo — pid almost never is
1321        // (TTY-only, Ctrl-Z path).
1322        let manager = JobManager::new();
1323        let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1324
1325        manager.add_pgid(id, 4242).await;
1326        manager.add_pgid(id, 4243).await;
1327
1328        let info = manager.get(id).await.expect("job exists");
1329        assert_eq!(info.pgids, vec![4242, 4243]);
1330        assert!(info.pid.is_none(), "pid stays None for a non-stopped job");
1331    }
1332
1333    #[tokio::test]
1334    async fn test_try_result_is_non_blocking_and_none_while_running() {
1335        // GH #243: previously the only way to read a finished job's ExecResult
1336        // was the blocking `wait`; `Job::try_result` was `pub` but the `jobs`
1337        // map was private, so nothing on JobManager could reach it.
1338        let manager = JobManager::new();
1339        let (_tx, rx) = oneshot::channel::<ExecResult>();
1340        let id = manager.register("still going".to_string(), rx).await;
1341
1342        // Must return immediately (no sleep here) with None — the job never
1343        // got a result.
1344        assert!(manager.try_result(id).await.is_none());
1345
1346        // Unknown id -> None too, no panic.
1347        assert!(manager.try_result(JobId(999)).await.is_none());
1348    }
1349
1350    #[tokio::test]
1351    async fn test_try_result_returns_result_once_finished() {
1352        let manager = JobManager::new();
1353        let id = manager
1354            .spawn("quick".to_string(), async { ExecResult::success("hi") })
1355            .await;
1356
1357        tokio::time::sleep(Duration::from_millis(10)).await;
1358
1359        let result = manager.try_result(id).await.expect("job finished");
1360        assert!(result.ok());
1361        assert_eq!(&*result.text_out(), "hi");
1362
1363        // The job is still tracked (try_result doesn't reap) — a second call
1364        // (and wait()) still sees it.
1365        assert!(manager.try_result(id).await.is_some());
1366        assert!(manager.wait(id).await.is_some());
1367    }
1368
1369    #[tokio::test]
1370    async fn test_cleanup() {
1371        let manager = JobManager::new();
1372
1373        let id = manager.spawn("done".to_string(), async {
1374            ExecResult::success("")
1375        }).await;
1376
1377        // Wait for completion
1378        tokio::time::sleep(Duration::from_millis(10)).await;
1379        let _ = manager.wait(id).await;
1380
1381        // Should have 1 job
1382        assert_eq!(manager.list().await.len(), 1);
1383
1384        // Cleanup
1385        manager.cleanup().await;
1386
1387        // Should have 0 jobs
1388        assert_eq!(manager.list().await.len(), 0);
1389    }
1390
1391    #[tokio::test]
1392    async fn test_cleanup_removes_temp_files() {
1393        // Bug K: cleanup should remove temp files
1394        let manager = JobManager::new();
1395
1396        let id = manager.spawn("output job".to_string(), async {
1397            ExecResult::success("some output that gets written to a temp file")
1398        }).await;
1399
1400        // Wait for completion (triggers output file creation)
1401        tokio::time::sleep(Duration::from_millis(10)).await;
1402        let result = manager.wait(id).await;
1403        assert!(result.is_some());
1404
1405        // Get the output file path before cleanup. The job produced output, so
1406        // a temp file must have been written — otherwise this test would pass
1407        // vacuously.
1408        let output_file = {
1409            let jobs = manager.jobs.lock().await;
1410            jobs.get(&id).and_then(|j| j.output_file().cloned())
1411        };
1412        let path = output_file.expect("job with output should have written a temp file");
1413        assert!(path.exists(), "temp file should exist before cleanup: {}", path.display());
1414
1415        // Cleanup should remove the job and its files.
1416        manager.cleanup().await;
1417
1418        assert!(
1419            !path.exists(),
1420            "temp file should be removed after cleanup: {}",
1421            path.display()
1422        );
1423    }
1424
1425    #[tokio::test]
1426    async fn test_reap_finished_returns_removed_job_info() {
1427        // GH #131: the REPL's pre-prompt notification needs the id/command/
1428        // status of each reaped job, not just a count.
1429        let manager = JobManager::new();
1430        manager.set_persist_output_files(false);
1431
1432        let id = manager
1433            .spawn("sleep 0.1".to_string(), async { ExecResult::success("") })
1434            .await;
1435        tokio::time::sleep(Duration::from_millis(10)).await;
1436        let _ = manager.wait(id).await;
1437
1438        let removed = manager.reap_finished().await;
1439        assert_eq!(removed.len(), 1);
1440        assert_eq!(removed[0].id, id);
1441        assert_eq!(removed[0].command, "sleep 0.1");
1442        assert_eq!(removed[0].status, JobStatus::Done);
1443
1444        // And it's actually gone from tracking.
1445        assert!(manager.list().await.is_empty());
1446    }
1447
1448    #[tokio::test]
1449    async fn test_register_with_channel() {
1450        let manager = JobManager::new();
1451        let (tx, rx) = oneshot::channel();
1452
1453        let id = manager.register("channel job".to_string(), rx).await;
1454
1455        // Send result
1456        tx.send(ExecResult::success("from channel")).unwrap();
1457
1458        let result = manager.wait(id).await;
1459        assert!(result.is_some());
1460        assert_eq!(&*result.unwrap().text_out(), "from channel");
1461    }
1462
1463    /// GH #247: `execute_background` uses the oneshot-channel path
1464    /// exclusively, so a panic inside the spawned task drops the sender
1465    /// without a result — the exact shape reproduced here by dropping `tx`
1466    /// directly rather than triggering a real panic. Before the fix this
1467    /// reported `failed:1` with the generic text "job channel closed",
1468    /// indistinguishable from a command that legitimately exited 1 and never
1469    /// logged. The result must now name what actually happened (a task that
1470    /// ended without producing a result) instead of reading like an ordinary
1471    /// command failure.
1472    #[tokio::test]
1473    async fn test_dropped_sender_reports_as_a_kernel_fault_not_exit_1() {
1474        let manager = JobManager::new();
1475        let (tx, rx) = oneshot::channel::<ExecResult>();
1476
1477        let id = manager.register("will panic".to_string(), rx).await;
1478        drop(tx); // simulates the spawned task's future panicking mid-flight
1479
1480        let result = manager.wait(id).await.expect("job must resolve, not hang");
1481        assert_eq!(result.code, 1);
1482        assert!(
1483            !result.err.contains("job channel closed"),
1484            "message must not use the old generic wording: {}",
1485            result.err
1486        );
1487        assert!(
1488            result.err.contains("panic") || result.err.contains("task ended without a result"),
1489            "message must name a kernel fault, not read like an ordinary exit 1: {}",
1490            result.err
1491        );
1492    }
1493
1494    #[tokio::test]
1495    async fn test_spawn_immediately_available() {
1496        // Bug J: job should be queryable immediately after spawn()
1497        let manager = JobManager::new();
1498
1499        let id = manager.spawn("instant".to_string(), async {
1500            tokio::time::sleep(Duration::from_millis(100)).await;
1501            ExecResult::success("done")
1502        }).await;
1503
1504        // Should be immediately visible without any sleep
1505        let exists = manager.exists(id).await;
1506        assert!(exists, "job should be immediately available after spawn()");
1507
1508        let info = manager.get(id).await;
1509        assert!(info.is_some(), "job info should be available immediately");
1510    }
1511
1512    #[tokio::test]
1513    async fn test_nonexistent_job() {
1514        let manager = JobManager::new();
1515        let result = manager.wait(JobId(999)).await;
1516        assert!(result.is_none());
1517    }
1518
1519    /// GH #247: `list`/`list_ids` iterated the backing `HashMap` directly, so
1520    /// two jobs could come back as `[2, 1]` — arbitrary, and a flake source
1521    /// for any MCP caller or snapshot test that depended on the order. Job
1522    /// ids are minted strictly increasing (`next_id`), so ascending-by-id is
1523    /// the one order that is both stable and meaningful (spawn order).
1524    #[tokio::test]
1525    async fn test_list_and_list_ids_are_sorted_by_id() {
1526        let manager = JobManager::new();
1527        let mut ids = Vec::new();
1528        for n in 0..8 {
1529            let (_tx, rx) = oneshot::channel::<ExecResult>();
1530            ids.push(manager.register(format!("job-{n}"), rx).await);
1531        }
1532
1533        let listed_ids = manager.list_ids().await;
1534        assert_eq!(listed_ids, ids, "list_ids must come back in ascending JobId order");
1535
1536        let infos = manager.list().await;
1537        let info_ids: Vec<JobId> = infos.iter().map(|i| i.id).collect();
1538        assert_eq!(info_ids, ids, "list must come back in ascending JobId order");
1539    }
1540
1541    #[tokio::test]
1542    async fn test_cancel_token_fires() {
1543        // A recorded cancel token can be tripped by id — this is how `kill %N`
1544        // stops a pure-builtin job that has no OS process group.
1545        let manager = JobManager::new();
1546        let token = tokio_util::sync::CancellationToken::new();
1547        let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1548        manager.set_cancel_token(id, token.clone()).await;
1549
1550        assert!(!token.is_cancelled());
1551        assert!(manager.cancel(id).await, "cancel should report success");
1552        assert!(token.is_cancelled(), "the job's token must be tripped");
1553    }
1554
1555    #[tokio::test]
1556    async fn test_cancel_without_token_returns_false() {
1557        let manager = JobManager::new();
1558        let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1559        // No token recorded → nothing to cancel.
1560        assert!(!manager.cancel(id).await);
1561        // Unknown id → also false.
1562        assert!(!manager.cancel(JobId(999)).await);
1563    }
1564
1565    #[tokio::test]
1566    async fn test_pgids_recorded_and_deduped() {
1567        let manager = JobManager::new();
1568        let id = manager.spawn("bg".to_string(), async { ExecResult::success("") }).await;
1569        assert!(manager.job_pgids(id).await.is_empty());
1570
1571        manager.add_pgid(id, 4242).await;
1572        manager.add_pgid(id, 4243).await;
1573        manager.add_pgid(id, 4242).await; // duplicate ignored
1574        assert_eq!(manager.job_pgids(id).await, vec![4242, 4243]);
1575
1576        // Unknown id → empty, no panic.
1577        assert!(manager.job_pgids(JobId(999)).await.is_empty());
1578    }
1579
1580    #[tokio::test]
1581    async fn wait_does_not_block_other_job_ops() {
1582        // Regression: `wait(id)` must NOT hold the jobs mutex across the job's
1583        // completion. The buggy version did, so while a `wait %N` was parked,
1584        // every other job op (list/spawn/status) blocked until the job finished
1585        // — a nested `&` under `wait %N` deadlocked. (Also covers the old
1586        // `spawn` try_lock busy-spin, which on a current-thread runtime livelocked
1587        // the executor when the lock was held.)
1588        let manager = Arc::new(JobManager::new());
1589        manager.set_persist_output_files(false);
1590
1591        // A job that blocks until we release it.
1592        let (tx, rx) = oneshot::channel::<()>();
1593        let id = manager
1594            .spawn("blocker".to_string(), async move {
1595                let _ = rx.await;
1596                ExecResult::success("done")
1597            })
1598            .await;
1599
1600        // Park a waiter on it (in the buggy version, holds the lock for the
1601        // job's whole lifetime).
1602        let waiter = {
1603            let m = manager.clone();
1604            tokio::spawn(async move { m.wait(id).await })
1605        };
1606        // Let the waiter acquire the lock and park on the job's completion.
1607        tokio::time::sleep(Duration::from_millis(50)).await;
1608
1609        // Other job ops must stay responsive while the waiter is parked.
1610        let listed = tokio::time::timeout(Duration::from_secs(2), manager.list()).await;
1611        assert!(
1612            listed.is_ok(),
1613            "list() blocked while wait() was parked — jobs lock held across await"
1614        );
1615        let second = tokio::time::timeout(
1616            Duration::from_secs(2),
1617            manager.spawn("second".to_string(), async { ExecResult::success("2") }),
1618        )
1619        .await;
1620        assert!(
1621            second.is_ok(),
1622            "spawn() blocked/spun while wait() was parked"
1623        );
1624
1625        // Release the job; the parked waiter must observe the result.
1626        let _ = tx.send(());
1627        let result = tokio::time::timeout(Duration::from_secs(2), waiter)
1628            .await
1629            .expect("waiter join timed out")
1630            .expect("waiter task panicked");
1631        assert_eq!(result.map(|r| r.code), Some(0), "waiter should see exit 0");
1632    }
1633
1634    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1635    async fn wait_survives_a_dropped_waiter() {
1636        // Regression (Gemini review): a waiter dropped mid-wait (e.g.
1637        // `timeout N wait %1`) must NOT orphan the job's result. The buggy
1638        // version took the JoinHandle out to await it, so dropping that waiter
1639        // detached the task and lost its result, and a SECOND `wait %1` then
1640        // hung forever (busy-spinning in the AlreadyWaiting branch). `wait` must
1641        // never take the handle until it's finished.
1642        let manager = Arc::new(JobManager::new());
1643        manager.set_persist_output_files(false);
1644
1645        let (tx, rx) = oneshot::channel::<()>();
1646        let id = manager
1647            .spawn("blocker".to_string(), async move {
1648                let _ = rx.await;
1649                ExecResult::success("done")
1650            })
1651            .await;
1652
1653        // Waiter A parks on the job, then is aborted (dropped) before it finishes.
1654        {
1655            let m = manager.clone();
1656            let a = tokio::spawn(async move { m.wait(id).await });
1657            tokio::time::sleep(Duration::from_millis(20)).await;
1658            a.abort();
1659            let _ = a.await;
1660        }
1661
1662        // The job completes after A is gone.
1663        let _ = tx.send(());
1664
1665        // Waiter B must still observe the result, not hang.
1666        let res = tokio::time::timeout(Duration::from_secs(2), manager.wait(id))
1667            .await
1668            .expect("wait must not hang after a prior waiter was dropped");
1669        assert_eq!(res.map(|r| r.code), Some(0), "B should see the completed job");
1670    }
1671
1672    /// A Ctrl-Z'd job has no `JoinHandle` and no result channel, and `is_done()`
1673    /// returns `false` while `stopped` — so nothing can ever make it done.
1674    /// `wait_all` used to poll it forever at 10ms, and since `Kernel::shutdown`
1675    /// calls `wait_all`, one Ctrl-Z hung shutdown with no timeout and no escape.
1676    #[tokio::test]
1677    async fn wait_all_skips_a_stopped_job_instead_of_hanging_forever() {
1678        let manager = JobManager::new();
1679
1680        // No real process needed: `wait_all` decides on the `stopped` flag, and
1681        // the hang was never about the pid.
1682        let stopped = manager
1683            .register_stopped("sleep 5".to_string(), 4242, 4242)
1684            .await;
1685
1686        // A job that does finish, so this also proves we did not fix the hang by
1687        // making `wait_all` skip everything.
1688        let finisher = manager
1689            .spawn("finisher".to_string(), async { ExecResult::success("ok") })
1690            .await;
1691
1692        let results = tokio::time::timeout(Duration::from_secs(2), manager.wait_all())
1693            .await
1694            .expect("wait_all must not hang on a stopped job");
1695
1696        let ids: Vec<JobId> = results.iter().map(|(id, _)| *id).collect();
1697        assert!(
1698            !ids.contains(&stopped),
1699            "a stopped job can never complete, so wait_all must skip it"
1700        );
1701        assert!(
1702            ids.contains(&finisher),
1703            "wait_all must still collect jobs that can finish"
1704        );
1705    }
1706
1707    /// `wait` on an already-stopped job returns `None` immediately instead of
1708    /// polling a job that can never become done.
1709    #[tokio::test]
1710    async fn wait_returns_none_on_a_stopped_job() {
1711        let manager = JobManager::new();
1712        let id = manager
1713            .register_stopped("sleep 5".to_string(), 4242, 4242)
1714            .await;
1715
1716        let res = tokio::time::timeout(Duration::from_secs(2), manager.wait(id))
1717            .await
1718            .expect("wait on a stopped job must return, not hang");
1719        assert!(res.is_none(), "a stopped job has no result to wait for");
1720    }
1721
1722    /// The `wait_all` stopped-skip is a snapshot: a job that stops *after* the
1723    /// filter (the bg reaper observing a SIGSTOP) used to leave the inner
1724    /// `wait` polling `is_done()` forever — the same shutdown hang, reached
1725    /// through a sub-200ms window instead of always. The stopped guard inside
1726    /// `wait`'s loop closes it.
1727    #[tokio::test]
1728    async fn wait_bails_when_the_job_stops_mid_wait() {
1729        let manager = Arc::new(JobManager::new());
1730        manager.set_persist_output_files(false);
1731
1732        // A job that never finishes on its own: the sender side is kept alive
1733        // so the future stays parked until the test ends.
1734        let (_tx, rx) = oneshot::channel::<()>();
1735        let id = manager
1736            .spawn("blocker".to_string(), async move {
1737                let _ = rx.await;
1738                ExecResult::success("done")
1739            })
1740            .await;
1741
1742        let m = manager.clone();
1743        let waiter = tokio::spawn(async move { m.wait(id).await });
1744
1745        // Let the waiter enter its poll loop, then stop the job under it.
1746        tokio::time::sleep(Duration::from_millis(30)).await;
1747        manager.stop_job(id, 4242, 4242).await;
1748
1749        let res = tokio::time::timeout(Duration::from_secs(2), waiter)
1750            .await
1751            .expect("wait must return once the job stops, not poll forever")
1752            .expect("waiter task must not panic");
1753        assert!(res.is_none(), "a job that stopped mid-wait has no result");
1754    }
1755
1756    /// Same race through `wait_all`: the job passes the not-stopped snapshot,
1757    /// then stops while the inner `wait` polls it.
1758    #[tokio::test]
1759    async fn wait_all_returns_when_a_job_stops_after_the_snapshot() {
1760        let manager = Arc::new(JobManager::new());
1761        manager.set_persist_output_files(false);
1762
1763        let (_tx, rx) = oneshot::channel::<()>();
1764        let id = manager
1765            .spawn("blocker".to_string(), async move {
1766                let _ = rx.await;
1767                ExecResult::success("done")
1768            })
1769            .await;
1770
1771        let m = manager.clone();
1772        let all = tokio::spawn(async move { m.wait_all().await });
1773
1774        tokio::time::sleep(Duration::from_millis(30)).await;
1775        manager.stop_job(id, 4242, 4242).await;
1776
1777        let results = tokio::time::timeout(Duration::from_secs(2), all)
1778            .await
1779            .expect("wait_all must return once the job stops, not poll forever")
1780            .expect("wait_all task must not panic");
1781        assert!(
1782            !results.iter().any(|(rid, _)| *rid == id),
1783            "a job that stopped mid-wait_all yields no result"
1784        );
1785    }
1786
1787    /// GH #252: the status *string* (backing `/v/jobs/N/status`) must agree
1788    /// with `status()` about a stopped job. A stopped job has no result
1789    /// channel, so `try_poll` can never resolve it — without the explicit
1790    /// check it read `running` forever while `status()` said `Stopped`.
1791    #[tokio::test]
1792    async fn status_string_reports_stopped() {
1793        let manager = JobManager::new();
1794        let id = manager.register_stopped("vi".to_string(), 4242, 4242).await;
1795        assert_eq!(manager.get_status_string(id).await.as_deref(), Some("stopped"));
1796        assert_eq!(
1797            manager.get(id).await.map(|info| info.status),
1798            Some(JobStatus::Stopped),
1799            "status() and status_string() must agree"
1800        );
1801    }
1802
1803    /// GH #244: a killed job's terminal status is `Killed`/`killed:{code}`,
1804    /// not `Failed` — the flag is set by `mark_killed_and_cancel` before the
1805    /// cancel trips, and only colors a non-ok exit (a job that finished ok
1806    /// anyway still reads `Done`).
1807    #[tokio::test]
1808    async fn mark_killed_colors_the_terminal_status() {
1809        let manager = JobManager::new();
1810        manager.set_persist_output_files(false);
1811        let (tx, rx) = oneshot::channel::<()>();
1812        let id = manager
1813            .spawn("victim".to_string(), async move {
1814                let _ = rx.await;
1815                ExecResult::failure(130, "cancelled")
1816            })
1817            .await;
1818        // delivered=true stands in for a real killpg delivery — spawn()'d
1819        // jobs record no cancellation token.
1820        assert!(manager.mark_killed_and_cancel(id, true).await);
1821        drop(tx); // unblock the future — it returns the 130 result
1822        let result = manager.wait(id).await.expect("job finishes");
1823        assert_eq!(result.code, 130);
1824        assert_eq!(
1825            manager.get(id).await.map(|info| info.status),
1826            Some(JobStatus::Killed)
1827        );
1828        assert_eq!(manager.get_status_string(id).await.as_deref(), Some("killed:130"));
1829
1830        // A successful exit is never re-colored: the result is the truth.
1831        let id2 = manager
1832            .spawn("survivor".to_string(), async { ExecResult::success("done") })
1833            .await;
1834        assert!(manager.mark_killed_and_cancel(id2, true).await);
1835        let result = manager.wait(id2).await.expect("job finishes");
1836        assert!(result.ok());
1837        assert_eq!(
1838            manager.get(id2).await.map(|info| info.status),
1839            Some(JobStatus::Done),
1840            "a job that finished ok before the kill landed reports Done"
1841        );
1842    }
1843
1844    /// Review finding (GH #244): with no cancellation token and nothing
1845    /// delivered, `mark_killed_and_cancel` must refuse AND leave the flag
1846    /// unset — otherwise a later organic failure reads as a kill that never
1847    /// happened.
1848    #[tokio::test]
1849    async fn no_lever_kill_does_not_color_a_later_organic_failure() {
1850        let manager = JobManager::new();
1851        manager.set_persist_output_files(false);
1852        let (tx, rx) = oneshot::channel::<()>();
1853        let id = manager
1854            .spawn("doomed anyway".to_string(), async move {
1855                let _ = rx.await;
1856                ExecResult::failure(7, "organic failure")
1857            })
1858            .await;
1859        assert!(
1860            !manager.mark_killed_and_cancel(id, false).await,
1861            "no token + nothing delivered must refuse"
1862        );
1863        drop(tx);
1864        let result = manager.wait(id).await.expect("job finishes");
1865        assert_eq!(result.code, 7);
1866        assert_eq!(
1867            manager.get(id).await.map(|info| info.status),
1868            Some(JobStatus::Failed),
1869            "the failed kill attempt must not have colored the status"
1870        );
1871        assert_eq!(manager.get_status_string(id).await.as_deref(), Some("failed:7"));
1872    }
1873
1874    /// Review finding (GH #244): retention must also hold when jobs finish
1875    /// AFTER registration stopped — `list()` observes completions and
1876    /// enforces. (The registration-time test above releases each job before
1877    /// the next spawn, which masked this.)
1878    #[tokio::test]
1879    async fn retention_enforced_when_completion_is_observed_by_list() {
1880        let manager = JobManager::new();
1881        manager.set_persist_output_files(false);
1882        manager.set_finished_retention(2);
1883
1884        let mut releases = Vec::new();
1885        let mut ids = Vec::new();
1886        for n in 0..5 {
1887            let (tx, rx) = oneshot::channel::<()>();
1888            releases.push(tx);
1889            ids.push(
1890                manager
1891                    .spawn(format!("held {n}"), async move {
1892                        let _ = rx.await;
1893                        ExecResult::success("")
1894                    })
1895                    .await,
1896            );
1897        }
1898        // All five registered while RUNNING — registration-time enforcement
1899        // had nothing to evict. Now they all finish with no registration
1900        // following. (Not waited one-by-one: wait() itself now evicts at
1901        // each observed completion, so a sequential wait on the oldest ids
1902        // finds them already gone — which is the feature, not the fixture.)
1903        for tx in releases {
1904            drop(tx);
1905        }
1906        let deadline = std::time::Instant::now() + Duration::from_secs(5);
1907        loop {
1908            let infos = manager.list().await;
1909            if infos.iter().all(|info| info.status != JobStatus::Running) {
1910                break;
1911            }
1912            assert!(
1913                std::time::Instant::now() < deadline,
1914                "jobs did not finish in time"
1915            );
1916            tokio::time::sleep(Duration::from_millis(10)).await;
1917        }
1918        let mut tracked = 0;
1919        for id in &ids {
1920            if manager.exists(*id).await {
1921                tracked += 1;
1922            }
1923        }
1924        assert!(
1925            tracked <= 2,
1926            "finished jobs beyond the cap must be evicted once observed, still tracked: {tracked}"
1927        );
1928    }
1929
1930    /// GH #244: registration evicts the oldest finished jobs beyond the
1931    /// retention cap, so a never-reaping embedder stays bounded. Running jobs
1932    /// are never evicted.
1933    #[tokio::test]
1934    async fn finished_retention_evicts_oldest_at_registration() {
1935        let manager = JobManager::new();
1936        manager.set_persist_output_files(false);
1937        manager.set_finished_retention(2);
1938
1939        let mut finished_ids = Vec::new();
1940        for n in 0..4 {
1941            let id = manager
1942                .spawn(format!("quick {n}"), async { ExecResult::success("") })
1943                .await;
1944            manager.wait(id).await.expect("job finishes");
1945            finished_ids.push(id);
1946        }
1947        // A still-running job to prove eviction only touches finished ones.
1948        let (_tx, rx) = oneshot::channel::<()>();
1949        let running = manager
1950            .spawn("blocker".to_string(), async move {
1951                let _ = rx.await;
1952                ExecResult::success("")
1953            })
1954            .await;
1955
1956        assert!(manager.exists(running).await, "running job is never evicted");
1957        let tracked_finished: Vec<bool> = {
1958            let mut v = Vec::new();
1959            for id in &finished_ids {
1960                v.push(manager.exists(*id).await);
1961            }
1962            v
1963        };
1964        // Registering the 3rd and 4th quick jobs (and the blocker) evicted the
1965        // oldest finished entries down to the cap of 2.
1966        assert_eq!(
1967            tracked_finished,
1968            vec![false, false, true, true],
1969            "oldest finished jobs evicted first: {finished_ids:?}"
1970        );
1971    }
1972}