Skip to main content

lean_ctx_server/server/
background_shell.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Explicit, in-process background jobs for `ctx_shell`.
3//!
4//! The MCP request may complete immediately, but the child keeps the exact
5//! timeout, allow-list, path-jail and process-group policy of foreground shell
6//! execution. Jobs intentionally live in the daemon: restarting it invalidates
7//! outstanding jobs rather than silently orphaning subprocesses.
8
9use std::collections::HashMap;
10use std::path::Path;
11use std::sync::{
12    Arc, LazyLock, Mutex,
13    atomic::{AtomicBool, AtomicU64, Ordering},
14    mpsc,
15};
16use std::time::{Duration, Instant};
17
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub enum JobState {
20    Running { output: String },
21    Completed { output: String, exit_code: i32 },
22    Cancelled { output: String },
23}
24
25const MAX_RETAINED_COMPLETED_JOBS: usize = 64;
26const MAX_RETAINED_COMPLETED_BYTES: usize = 16 * 1024 * 1024;
27const MAX_FOREGROUND_OUTPUT_RECEIVERS: usize = 64;
28const COMPLETED_JOB_TTL: Duration = Duration::from_mins(5);
29
30struct Job {
31    cancel: Arc<AtomicBool>,
32    state: JobState,
33    finished_at: Option<Instant>,
34    // Terminal text is storage-admitted only. Running jobs keep no output.
35    generation: u64,
36    actual_exit_code: i32,
37    completeness: lean_ctx::core::context_admission::OutputCompleteness,
38    output_withheld: bool,
39    // Q13: the call's fixed, content-free notice. Kept beside the retained
40    // text, never in it: storage admission, scrubbing and replacement do not
41    // touch it, and every delivery puts it in front once.
42    notice: Option<&'static str>,
43    // One-shot raw results go only to foreground receivers that are still alive;
44    // they are never included in JobState or a pollable snapshot.
45    foreground_senders: Vec<mpsc::SyncSender<Arc<crate::server::execute::ShellExecution>>>,
46    // #1876: identical launches coalesce onto one entry, so several foreground
47    // calls can wait on it at once. A detached/background owner keeps it pollable.
48    foreground_waiters: usize,
49    background_owned: bool,
50    telemetry_recorded: bool,
51}
52
53static JOBS: LazyLock<Mutex<HashMap<String, Job>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
54static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1);
55
56/// How often a foreground run reports progress to the MCP client (#1173).
57const TICK: Duration = Duration::from_secs(5);
58
59fn prune_finished_jobs(jobs: &mut HashMap<String, Job>, now: Instant) {
60    prune_finished_jobs_with_limits(
61        jobs,
62        now,
63        MAX_RETAINED_COMPLETED_JOBS,
64        MAX_RETAINED_COMPLETED_BYTES,
65    );
66}
67
68fn prune_finished_jobs_with_limits(
69    jobs: &mut HashMap<String, Job>,
70    now: Instant,
71    max_completed_jobs: usize,
72    max_completed_bytes: usize,
73) {
74    jobs.retain(|_, job| {
75        job.foreground_waiters > 0
76            || job
77                .finished_at
78                .is_none_or(|finished_at| now.duration_since(finished_at) < COMPLETED_JOB_TTL)
79    });
80
81    let mut completed: Vec<_> = jobs
82        .iter()
83        .filter(|(_, job)| job.foreground_waiters == 0)
84        .filter_map(|(id, job)| {
85            let finished_at = job.finished_at?;
86            let output_bytes = match &job.state {
87                JobState::Completed { output, .. } | JobState::Cancelled { output } => output.len(),
88                JobState::Running { .. } => 0,
89            };
90            Some((finished_at, id.clone(), output_bytes))
91        })
92        .collect();
93    completed.sort_unstable_by_key(|(finished_at, _, _)| *finished_at);
94
95    let mut retained_bytes = completed.iter().map(|(_, _, bytes)| bytes).sum::<usize>();
96    let mut retained_jobs = completed.len();
97    for (_, id, output_bytes) in completed {
98        if retained_jobs <= max_completed_jobs && retained_bytes <= max_completed_bytes {
99            break;
100        }
101        if retained_jobs == 1 {
102            break;
103        }
104        jobs.remove(&id);
105        retained_jobs -= 1;
106        retained_bytes = retained_bytes.saturating_sub(output_bytes);
107    }
108}
109
110pub fn start(
111    command: String,
112    cwd: String,
113    extra_env: std::collections::HashMap<String, String>,
114    timeout_ms: Option<u64>,
115) -> String {
116    start_inner(command, cwd, extra_env, timeout_ms, false)
117}
118
119fn start_inner(
120    command: String,
121    cwd: String,
122    extra_env: std::collections::HashMap<String, String>,
123    timeout_ms: Option<u64>,
124    foreground: bool,
125) -> String {
126    start_inner_with_sender(command, cwd, extra_env, timeout_ms, foreground, None)
127}
128
129fn start_inner_with_sender(
130    command: String,
131    cwd: String,
132    extra_env: std::collections::HashMap<String, String>,
133    timeout_ms: Option<u64>,
134    foreground: bool,
135    output_sender: Option<mpsc::SyncSender<Arc<crate::server::execute::ShellExecution>>>,
136) -> String {
137    // IDs are content-addressed so tool responses stay deterministic (#498).
138    // An identical in-flight launch coalesces onto the same job instead of
139    // creating duplicate expensive builds/tests.
140    let mut env_entries: Vec<_> = extra_env.iter().collect();
141    env_entries.sort_unstable_by(|a, b| a.0.cmp(b.0));
142    let env_key = env_entries
143        .into_iter()
144        .map(|(key, value)| format!("{key}={value}"))
145        .collect::<Vec<_>>()
146        .join("\0");
147    let material = format!(
148        "{command}\0{cwd}\0{}\0{env_key}",
149        timeout_ms.unwrap_or_default()
150    );
151    let id = format!(
152        "shell_{}",
153        &blake3::hash(material.as_bytes()).to_hex()[..16]
154    );
155    let cancel = Arc::new(AtomicBool::new(false));
156    let worker_cancel = Arc::clone(&cancel);
157    {
158        let mut jobs = JOBS
159            .lock()
160            .unwrap_or_else(std::sync::PoisonError::into_inner);
161        prune_finished_jobs(&mut jobs, Instant::now());
162        // A finished entry that a foreground waiter has not read yet is joined
163        // too (#1876): replacing it would hand that waiter's release to the new
164        // job, which then disappears under its own waiter.
165        if let Some(job) = jobs.get_mut(&id)
166            && (matches!(job.state, JobState::Running { .. }) || job.foreground_waiters > 0)
167        {
168            if foreground {
169                job.foreground_waiters += 1;
170                if matches!(job.state, JobState::Running { .. })
171                    && job.foreground_senders.len() < MAX_FOREGROUND_OUTPUT_RECEIVERS
172                    && let Some(sender) = output_sender
173                {
174                    job.foreground_senders.push(sender);
175                }
176            } else {
177                job.background_owned = true;
178            }
179            return id;
180        }
181        jobs.insert(
182            id.clone(),
183            Job {
184                cancel,
185                state: JobState::Running {
186                    output: String::new(),
187                },
188                finished_at: None,
189                generation: NEXT_GENERATION.fetch_add(1, Ordering::Relaxed),
190                actual_exit_code: 0,
191                completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
192                output_withheld: false,
193                notice: None,
194                foreground_senders: output_sender.into_iter().collect(),
195                foreground_waiters: usize::from(foreground),
196                background_owned: !foreground,
197                telemetry_recorded: false,
198            },
199        );
200    }
201
202    let worker_id = id.clone();
203    let project_root = lean_ctx::core::policy::runtime::detached_project_root(Path::new(&cwd)).ok();
204    std::thread::spawn(move || {
205        let execution = crate::server::execute::execute_command_with_env_cancellable_captured(
206            &command,
207            &cwd,
208            &extra_env,
209            timeout_ms,
210            Some(&worker_cancel),
211            // #1113/#1173: bounded by output, not wall clock — a monitor loop
212            // emitting a line every 45s must survive. See `idle_keyed`.
213            true,
214        );
215        let store = lean_ctx::core::context_admission::stores::StoreAdmission::current();
216        let stored_output = project_root.as_deref().and_then(|root| {
217            let root = root.to_str()?;
218            lean_ctx::core::policy::runtime::with_project_source_view(root, || {
219                // Background output is project-policy filtered before it is
220                // retained. Preserve the existing audit of the original text
221                // at this boundary: later tool-pipeline auditing sees only the
222                // admitted text (or a content-free withheld marker).
223                let policy_outcome = lean_ctx::core::policy::runtime::active().map(|policy| {
224                    lean_ctx::core::policy::content::evaluate_text(&execution.output, &policy)
225                });
226                // #2164: a pipe still held by a process the command left
227                // running does not cost the job its output.
228                let stored =
229                    store.admit_background_output(&execution.output, execution.completeness);
230                if let Some(outcome) = policy_outcome
231                    && !outcome.audit.is_empty()
232                    && (outcome.blocked
233                        || outcome.text != execution.output
234                        || stored.as_deref() != Some(execution.output.as_str()))
235                {
236                    crate::server::policy_guard::audit_filter(
237                        "ctx_shell",
238                        &outcome.audit,
239                        outcome.blocked,
240                    );
241                }
242                stored
243            })
244            .ok()
245            .flatten()
246        });
247        let mut jobs = JOBS
248            .lock()
249            .unwrap_or_else(std::sync::PoisonError::into_inner);
250        let Some(job) = jobs.get_mut(&worker_id) else {
251            return;
252        };
253        // Check epoch while the job insertion lock is held, so a policy change
254        // while waiting for JOBS cannot repopulate terminal state.
255        let stored_output = stored_output.filter(|_| store.is_current());
256        let cancelled = worker_cancel.load(Ordering::Acquire);
257        let foreground_result = Arc::new(execution);
258        for sender in job.foreground_senders.drain(..) {
259            let _ = sender.send(Arc::clone(&foreground_result));
260        }
261        let output_withheld = stored_output.is_none();
262        let output = stored_output.unwrap_or_default();
263        let exit_code = foreground_result.exit_code;
264        job.actual_exit_code = exit_code;
265        job.completeness = foreground_result.completeness;
266        job.output_withheld = output_withheld;
267        job.notice = foreground_result.notice;
268        job.state = if cancelled {
269            JobState::Cancelled { output }
270        } else {
271            JobState::Completed { output, exit_code }
272        };
273        job.finished_at = Some(Instant::now());
274        // Only a background-owned job is counted here, so a failure nobody
275        // polls still reaches telemetry. A foreground run is classified once by
276        // the call pipeline, which knows that exit 1 with output (grep, diff,
277        // test) is not an error; counting it here as well filed every failing
278        // inline command twice, the second time as a LeanCTX-internal error.
279        if !cancelled && exit_code != 0 && job.background_owned {
280            let category = if exit_code == 124 {
281                lean_ctx::core::telemetry_v2::ErrorCategory::Timeout
282            } else {
283                lean_ctx::core::telemetry_v2::ErrorCategory::Command
284            };
285            if lean_ctx::core::telemetry_aggregate::record_error_category(category).is_ok() {
286                job.telemetry_recorded = true;
287            }
288        }
289        prune_finished_jobs(&mut jobs, Instant::now());
290    });
291    id
292}
293
294/// Outcome of a foreground run that is allowed to detach on a soft cap.
295pub enum ForegroundResult {
296    /// The command finished within the soft cap; output is returned inline.
297    /// Other coalesced foreground/background callers retain their result.
298    Finished { output: String, exit_code: i32 },
299    /// The command was still running at the soft cap and was left running as a
300    /// pollable background job (#1106).
301    Detached { job_id: String },
302}
303
304/// Legacy tuple-shaped foreground result for existing callers.
305pub fn run_foreground_or_detach(
306    command: String,
307    cwd: String,
308    extra_env: std::collections::HashMap<String, String>,
309    timeout_ms: Option<u64>,
310    soft_cap: Duration,
311    on_tick: Option<&dyn Fn(Duration)>,
312    cancel: Option<&tokio_util::sync::CancellationToken>,
313) -> ForegroundResult {
314    match run_foreground_or_detach_captured(
315        command, cwd, extra_env, timeout_ms, soft_cap, on_tick, cancel,
316    ) {
317        CapturedForegroundResult::Finished {
318            output,
319            exit_code,
320            notice,
321            ..
322        } => ForegroundResult::Finished {
323            // Nothing shortens the text on this path: the notice goes in
324            // front here.
325            output: crate::server::execute::with_notice(notice, output),
326            exit_code,
327        },
328        CapturedForegroundResult::Detached { job_id } => ForegroundResult::Detached { job_id },
329    }
330}
331
332/// Process output returned only while the original foreground call remains alive.
333pub(crate) enum CapturedForegroundResult {
334    Finished {
335        output: String,
336        exit_code: i32,
337        completeness: lean_ctx::core::context_admission::OutputCompleteness,
338        retained_withheld: bool,
339        /// Not part of `output`; see `ShellExecution::notice`.
340        notice: Option<&'static str>,
341    },
342    Detached {
343        job_id: String,
344    },
345}
346
347/// Run a managed job with a one-shot channel for live foreground delivery.
348/// Detached callers drop that receiver; later polls can see only stored text.
349pub(crate) fn run_foreground_or_detach_captured(
350    command: String,
351    cwd: String,
352    extra_env: std::collections::HashMap<String, String>,
353    timeout_ms: Option<u64>,
354    soft_cap: Duration,
355    on_tick: Option<&dyn Fn(Duration)>,
356    cancel: Option<&tokio_util::sync::CancellationToken>,
357) -> CapturedForegroundResult {
358    let (sender, receiver) = mpsc::sync_channel(1);
359    let id = start_inner_with_sender(command, cwd, extra_env, timeout_ms, true, Some(sender));
360    let started = Instant::now();
361    let deadline = started + soft_cap;
362    let mut next_tick = started + TICK;
363    loop {
364        if let Ok(execution) = receiver.try_recv() {
365            release_foreground(&id, false);
366            return CapturedForegroundResult::Finished {
367                output: execution.output.clone(),
368                exit_code: execution.exit_code,
369                completeness: execution.completeness,
370                retained_withheld: false,
371                notice: execution.notice,
372            };
373        }
374        if let Some(snapshot) = snapshot(&id) {
375            match snapshot.state {
376                JobState::Completed { output, exit_code } => {
377                    release_foreground(&id, false);
378                    return CapturedForegroundResult::Finished {
379                        output,
380                        exit_code,
381                        completeness: snapshot.completeness,
382                        retained_withheld: snapshot.output_withheld,
383                        notice: snapshot.notice,
384                    };
385                }
386                JobState::Cancelled { output } => {
387                    release_foreground(&id, false);
388                    return CapturedForegroundResult::Finished {
389                        output,
390                        exit_code: snapshot.actual_exit_code,
391                        completeness: snapshot.completeness,
392                        retained_withheld: snapshot.output_withheld,
393                        notice: snapshot.notice,
394                    };
395                }
396                JobState::Running { .. } => {}
397            }
398        }
399        if cancel.is_some_and(tokio_util::sync::CancellationToken::is_cancelled) {
400            release_foreground(&id, true);
401            return CapturedForegroundResult::Detached { job_id: id };
402        }
403        let now = Instant::now();
404        if now >= deadline {
405            release_foreground(&id, true);
406            return CapturedForegroundResult::Detached { job_id: id };
407        }
408        if let Some(tick) = on_tick
409            && now >= next_tick
410        {
411            tick(started.elapsed());
412            next_tick = now + TICK;
413        }
414        std::thread::sleep(Duration::from_millis(50));
415    }
416}
417
418/// One foreground waiter is done with `id` (#1876). A detached waiter hands the
419/// id to its caller, so the entry becomes background-owned and stays pollable.
420/// Otherwise the entry is removed once no waiter and no background caller is
421/// left; a waiter that still polls it must not find it gone.
422fn release_foreground(id: &str, detached: bool) {
423    let mut jobs = JOBS
424        .lock()
425        .unwrap_or_else(std::sync::PoisonError::into_inner);
426    let Some(job) = jobs.get_mut(id) else {
427        return;
428    };
429    job.foreground_waiters = job.foreground_waiters.saturating_sub(1);
430    if detached {
431        job.background_owned = true;
432    } else if job.foreground_waiters == 0 && !job.background_owned {
433        jobs.remove(id);
434    }
435}
436
437#[cfg(test)]
438pub(crate) fn remove_for_test(id: &str) {
439    JOBS.lock()
440        .unwrap_or_else(std::sync::PoisonError::into_inner)
441        .remove(id);
442}
443
444/// Jobs whose command is still running. An idle MCP server with one of these
445/// is not idle: it holds the child and the output the agent will poll for.
446pub fn running_count() -> usize {
447    JOBS.lock()
448        .unwrap_or_else(std::sync::PoisonError::into_inner)
449        .values()
450        .filter(|job| matches!(job.state, JobState::Running { .. }))
451        .count()
452}
453
454#[derive(Debug, Clone)]
455pub(crate) struct JobSnapshot {
456    pub(crate) state: JobState,
457    pub(crate) generation: u64,
458    pub(crate) completeness: lean_ctx::core::context_admission::OutputCompleteness,
459    pub(crate) output_withheld: bool,
460    pub(crate) actual_exit_code: i32,
461    /// The job's fixed notice, if it has one; not part of the state's text.
462    pub(crate) notice: Option<&'static str>,
463}
464
465/// A running job exposes state only; terminal text was storage-admitted before
466/// it entered the pollable job record.
467pub(crate) fn snapshot(id: &str) -> Option<JobSnapshot> {
468    let jobs = JOBS
469        .lock()
470        .unwrap_or_else(std::sync::PoisonError::into_inner);
471    let job = jobs.get(id)?;
472    Some(JobSnapshot {
473        state: job.state.clone(),
474        generation: job.generation,
475        completeness: job.completeness,
476        output_withheld: job.output_withheld,
477        actual_exit_code: job.actual_exit_code,
478        notice: job.notice,
479    })
480}
481
482pub fn status(id: &str) -> Option<JobState> {
483    snapshot(id).map(|snapshot| snapshot.state)
484}
485
486/// Replace retained terminal text only if the caller still holds the exact
487/// generation and text it inspected. A policy change can therefore scrub a job
488/// without racing a reused content-addressed id or a newer poll.
489pub(crate) fn replace_retained_output_if_generation(
490    id: &str,
491    generation: u64,
492    expected: &str,
493    expected_epoch: u64,
494    replacement: Option<&str>,
495) -> bool {
496    let mut jobs = JOBS
497        .lock()
498        .unwrap_or_else(std::sync::PoisonError::into_inner);
499    let Some(job) = jobs.get_mut(id) else {
500        return false;
501    };
502    if job.generation != generation
503        || (replacement.is_some()
504            && lean_ctx::core::context_admission::stores::current_epoch() != expected_epoch)
505    {
506        return false;
507    }
508    let current = match &job.state {
509        JobState::Completed { output, .. } | JobState::Cancelled { output } => output,
510        JobState::Running { .. } => return false,
511    };
512    if current != expected {
513        return false;
514    }
515    match &mut job.state {
516        JobState::Completed { output, .. } | JobState::Cancelled { output } => {
517            output.clear();
518            if let Some(replacement) = replacement {
519                output.push_str(replacement);
520            }
521        }
522        JobState::Running { .. } => return false,
523    }
524    job.output_withheld = replacement.is_none();
525    true
526}
527
528/// Persist one aggregate for a terminal failed job, then mark that concrete
529/// retained job instance. A later execution may reuse the content-addressed ID,
530/// but replaces the `Job` and therefore starts with a fresh marker.
531pub fn record_error_telemetry_once(
532    id: &str,
533    category: lean_ctx::core::telemetry_v2::ErrorCategory,
534) -> Result<bool, String> {
535    let mut jobs = JOBS
536        .lock()
537        .unwrap_or_else(std::sync::PoisonError::into_inner);
538    let Some(job) = jobs.get_mut(id) else {
539        return Ok(false);
540    };
541    if job.telemetry_recorded
542        || !matches!(job.state, JobState::Completed { exit_code, .. } if exit_code != 0)
543    {
544        return Ok(false);
545    }
546    lean_ctx::core::telemetry_aggregate::record_error_category(category)?;
547    job.telemetry_recorded = true;
548    Ok(true)
549}
550
551pub(crate) fn cancel_snapshot(id: &str) -> Option<JobSnapshot> {
552    {
553        let mut jobs = JOBS
554            .lock()
555            .unwrap_or_else(std::sync::PoisonError::into_inner);
556        let job = jobs.get_mut(id)?;
557        if matches!(job.state, JobState::Running { .. }) {
558            job.cancel.store(true, Ordering::Release);
559        }
560    }
561    snapshot(id)
562}
563
564pub fn cancel(id: &str) -> Option<JobState> {
565    cancel_snapshot(id).map(|snapshot| snapshot.state)
566}
567
568#[cfg(test)]
569mod tests {
570    use super::{
571        ForegroundResult, JobState, TICK, cancel, run_foreground_or_detach, start, status,
572    };
573    use std::time::Duration;
574
575    /// A finished job's text is storage-admitted, on the job's own thread,
576    /// under the process's gateway configuration and project policy, and is
577    /// dropped when that configuration changes meanwhile. A test that reads
578    /// the retained text holds both still: its own data directory (which
579    /// serializes every test that changes the environment) and no project
580    /// policy — environment first, policy second, as everywhere.
581    fn retained_text_held_still() -> (
582        lean_ctx::core::data_dir::IsolatedDataDir,
583        lean_ctx::core::policy::runtime::TestPolicyOverride,
584    ) {
585        let data = lean_ctx::core::data_dir::isolated_data_dir();
586        let community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
587        (data, community)
588    }
589
590    fn completed_job(exit_code: i32) -> super::Job {
591        super::Job {
592            cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
593            state: JobState::Completed {
594                output: "opaque output".into(),
595                exit_code,
596            },
597            finished_at: Some(std::time::Instant::now()),
598            generation: 0,
599            actual_exit_code: 0,
600            completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
601            output_withheld: false,
602            notice: None,
603            foreground_senders: Vec::new(),
604            foreground_waiters: 0,
605            background_owned: true,
606            telemetry_recorded: false,
607        }
608    }
609
610    #[test]
611    fn retained_output_replacement_checks_epoch_under_the_job_lock() {
612        let id = "shell_epoch_replacement_test";
613        let mut job = completed_job(0);
614        job.generation = 41;
615        super::JOBS
616            .lock()
617            .unwrap_or_else(std::sync::PoisonError::into_inner)
618            .insert(id.into(), job);
619        let epoch = lean_ctx::core::context_admission::stores::current_epoch();
620
621        assert!(
622            !super::replace_retained_output_if_generation(
623                id,
624                41,
625                "opaque output",
626                epoch.wrapping_add(1),
627                Some("stale output"),
628            ),
629            "an old policy snapshot cannot repopulate a terminal job"
630        );
631        assert!(matches!(
632            super::status(id),
633            Some(JobState::Completed { output, .. }) if output == "opaque output"
634        ));
635
636        assert!(
637            super::replace_retained_output_if_generation(
638                id,
639                41,
640                "opaque output",
641                epoch.wrapping_add(1),
642                None,
643            ),
644            "scrubbing remains allowed after an epoch change"
645        );
646        assert!(matches!(
647            super::status(id),
648            Some(JobState::Completed { output, .. }) if output.is_empty()
649        ));
650        super::remove_for_test(id);
651    }
652
653    #[test]
654    #[serial_test::serial]
655    fn failed_job_telemetry_is_once_per_execution_even_when_id_is_reused() {
656        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
657        let id = "shell_same_content";
658        super::JOBS
659            .lock()
660            .unwrap_or_else(std::sync::PoisonError::into_inner)
661            .insert(id.into(), completed_job(2));
662
663        assert!(
664            super::record_error_telemetry_once(
665                id,
666                lean_ctx::core::telemetry_v2::ErrorCategory::Internal
667            )
668            .expect("record first execution")
669        );
670        assert!(
671            !super::record_error_telemetry_once(
672                id,
673                lean_ctx::core::telemetry_v2::ErrorCategory::Internal
674            )
675            .expect("deduplicate repeated poll")
676        );
677
678        super::JOBS
679            .lock()
680            .unwrap_or_else(std::sync::PoisonError::into_inner)
681            .insert(id.into(), completed_job(2));
682        assert!(
683            super::record_error_telemetry_once(
684                id,
685                lean_ctx::core::telemetry_v2::ErrorCategory::Internal
686            )
687            .expect("record replacement execution")
688        );
689
690        let batch = lean_ctx::core::telemetry_aggregate::preview_daily_batch().expect("preview");
691        let count = batch
692            .events
693            .iter()
694            .find_map(|envelope| match &envelope.event {
695                lean_ctx::core::telemetry_v2::TelemetryEventV2::ErrorCategoryAggregate(metrics)
696                    if metrics.category
697                        == lean_ctx::core::telemetry_v2::ErrorCategory::Internal =>
698                {
699                    Some(metrics.count)
700                }
701                _ => None,
702            });
703        assert_eq!(count, Some(2));
704        super::remove_for_test(id);
705    }
706
707    fn queued_error_count(category: lean_ctx::core::telemetry_v2::ErrorCategory) -> Option<u64> {
708        // A preview fails fast while another test's send holds the state lock;
709        // that is transient, so wait it out instead of failing this test.
710        let mut attempt = 0;
711        let batch = loop {
712            match lean_ctx::core::telemetry_aggregate::preview_daily_batch() {
713                Ok(batch) => break batch,
714                Err(_) if attempt < 100 => {
715                    attempt += 1;
716                    std::thread::sleep(Duration::from_millis(20));
717                }
718                Err(error) => panic!("preview: {error}"),
719            }
720        };
721        batch
722            .events
723            .iter()
724            .find_map(|envelope| match &envelope.event {
725                lean_ctx::core::telemetry_v2::TelemetryEventV2::ErrorCategoryAggregate(metrics)
726                    if metrics.category == category =>
727                {
728                    Some(metrics.count)
729                }
730                _ => None,
731            })
732    }
733
734    /// A failing inline command is classified once, by the call pipeline; the
735    /// worker counts only background-owned jobs, and as a failed command, never
736    /// as a LeanCTX-internal error.
737    #[test]
738    #[serial_test::serial]
739    #[cfg_attr(windows, ignore)]
740    fn worker_counts_only_background_failures_and_as_commands() {
741        use lean_ctx::core::telemetry_v2::ErrorCategory;
742        // The daily error counters are process-global: any parallel test whose
743        // failing command or detached job lands while this one holds the
744        // isolated data dir is counted here too (CI saw 2 instead of 1). Run
745        // the exact scenario alone in a fresh test process so the counts are
746        // this worker's own, like the MCP cancellation regression does.
747        if std::env::var_os("LEAN_CTX_BACKGROUND_TELEMETRY_SUBPROCESS").is_none() {
748            let environment = tempfile::tempdir().unwrap();
749            let mut command = std::process::Command::new(std::env::current_exe().unwrap());
750            command
751                .args([
752                    "--exact",
753                    "server::background_shell::tests::worker_counts_only_background_failures_and_as_commands",
754                    "--nocapture",
755                    "--test-threads=1",
756                ])
757                .env("LEAN_CTX_BACKGROUND_TELEMETRY_SUBPROCESS", "1");
758            for name in [
759                "LEAN_CTX_DATA_DIR",
760                "LEAN_CTX_CONFIG_DIR",
761                "LEAN_CTX_STATE_DIR",
762                "LEAN_CTX_CACHE_DIR",
763            ] {
764                command.env(name, environment.path());
765            }
766            let output = command.output().unwrap();
767            let stdout = String::from_utf8_lossy(&output.stdout);
768            assert!(
769                output.status.success() && stdout.contains("1 passed"),
770                "{stdout}\n{}",
771                String::from_utf8_lossy(&output.stderr)
772            );
773            return;
774        }
775        let _iso = lean_ctx::core::data_dir::isolated_data_dir();
776
777        let result = run_foreground_or_detach(
778            "exit 3".to_string(),
779            ".".to_string(),
780            std::collections::HashMap::default(),
781            Some(10_000),
782            Duration::from_secs(10),
783            None,
784            None,
785        );
786        assert!(matches!(
787            result,
788            ForegroundResult::Finished { exit_code: 3, .. }
789        ));
790        assert_eq!(queued_error_count(ErrorCategory::Command), None);
791        assert_eq!(queued_error_count(ErrorCategory::Internal), None);
792
793        let id = start(
794            "exit 4".to_string(),
795            ".".to_string(),
796            std::collections::HashMap::default(),
797            Some(10_000),
798        );
799        for _ in 0..200 {
800            if matches!(status(&id), Some(JobState::Completed { .. })) {
801                break;
802            }
803            std::thread::sleep(Duration::from_millis(25));
804        }
805        assert!(matches!(
806            status(&id),
807            Some(JobState::Completed { exit_code: 4, .. })
808        ));
809        assert_eq!(queued_error_count(ErrorCategory::Command), Some(1));
810        assert_eq!(queued_error_count(ErrorCategory::Internal), None);
811        super::remove_for_test(&id);
812    }
813
814    #[test]
815    fn completed_job_retention_is_bounded() {
816        let now = std::time::Instant::now();
817        let mut jobs = std::collections::HashMap::new();
818        for index in 0..=super::MAX_RETAINED_COMPLETED_JOBS {
819            jobs.insert(
820                format!("job_{index}"),
821                super::Job {
822                    cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
823                    state: JobState::Completed {
824                        output: "x".repeat(1024),
825                        exit_code: 0,
826                    },
827                    finished_at: Some(
828                        now.checked_sub(Duration::from_secs((index + 1) as u64))
829                            .unwrap(),
830                    ),
831                    generation: 0,
832                    actual_exit_code: 0,
833                    completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
834                    output_withheld: false,
835                    notice: None,
836                    foreground_senders: Vec::new(),
837                    foreground_waiters: 0,
838                    background_owned: true,
839                    telemetry_recorded: false,
840                },
841            );
842        }
843        jobs.insert(
844            "expired".to_string(),
845            super::Job {
846                cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
847                state: JobState::Completed {
848                    output: "expired".to_string(),
849                    exit_code: 0,
850                },
851                finished_at: Some(
852                    now.checked_sub(super::COMPLETED_JOB_TTL + Duration::from_secs(1))
853                        .unwrap(),
854                ),
855                generation: 0,
856                actual_exit_code: 0,
857                completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
858                output_withheld: false,
859                notice: None,
860                foreground_senders: Vec::new(),
861                foreground_waiters: 0,
862                background_owned: true,
863                telemetry_recorded: false,
864            },
865        );
866
867        super::prune_finished_jobs(&mut jobs, now);
868
869        assert_eq!(jobs.len(), super::MAX_RETAINED_COMPLETED_JOBS);
870        assert!(!jobs.contains_key("expired"));
871        assert!(!jobs.contains_key(&format!("job_{}", super::MAX_RETAINED_COMPLETED_JOBS)));
872    }
873
874    #[test]
875    fn completed_job_output_bytes_are_bounded() {
876        let now = std::time::Instant::now();
877        let mut jobs = std::collections::HashMap::new();
878        for index in 0..3 {
879            jobs.insert(
880                format!("job_{index}"),
881                super::Job {
882                    cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
883                    state: JobState::Completed {
884                        output: "x".repeat(8),
885                        exit_code: 0,
886                    },
887                    finished_at: Some(
888                        now.checked_sub(Duration::from_secs((3 - index) as u64))
889                            .unwrap(),
890                    ),
891                    generation: 0,
892                    actual_exit_code: 0,
893                    completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
894                    output_withheld: false,
895                    notice: None,
896                    foreground_senders: Vec::new(),
897                    foreground_waiters: 0,
898                    background_owned: true,
899                    telemetry_recorded: false,
900                },
901            );
902        }
903
904        super::prune_finished_jobs_with_limits(&mut jobs, now, 10, 16);
905
906        assert_eq!(jobs.len(), 2);
907        assert!(!jobs.contains_key("job_0"));
908    }
909
910    #[test]
911    #[cfg_attr(windows, ignore)]
912    fn foreground_run_finishing_within_cap_returns_inline() {
913        let (_data, _community) = retained_text_held_still();
914        let result = run_foreground_or_detach(
915            "printf FG_OK".to_string(),
916            ".".to_string(),
917            std::collections::HashMap::default(),
918            Some(10_000),
919            Duration::from_secs(10),
920            None,
921            None,
922        );
923        match result {
924            ForegroundResult::Finished { output, exit_code } => {
925                assert_eq!(exit_code, 0);
926                assert!(output.contains("FG_OK"), "got: {output}");
927            }
928            ForegroundResult::Detached { .. } => panic!("fast command should not detach"),
929        }
930    }
931
932    #[test]
933    #[cfg_attr(windows, ignore)]
934    fn foreground_run_exceeding_cap_detaches_to_pollable_job() {
935        let result = run_foreground_or_detach(
936            "sleep 5; printf SLOW_OK".to_string(),
937            ".".to_string(),
938            std::collections::HashMap::default(),
939            Some(10_000),
940            Duration::from_millis(100),
941            None,
942            None,
943        );
944        let ForegroundResult::Detached { job_id } = result else {
945            panic!("slow command should detach");
946        };
947        assert!(job_id.starts_with("shell_"));
948        // The returned id must resolve via status — the core #1106 guarantee.
949        assert!(status(&job_id).is_some());
950        cancel(&job_id);
951    }
952
953    /// #1173: a `timeout_ms` far beyond the soft cap must NOT keep the command
954    /// in the foreground. Raising the cap past the MCP host's ~120s abort is
955    /// what stranded results behind an unresolvable task id; the caller must
956    /// still get a real `shell_*` job id at the cap.
957    #[test]
958    #[cfg_attr(windows, ignore)]
959    fn large_timeout_ms_still_detaches_at_the_soft_cap() {
960        let result = run_foreground_or_detach(
961            "sleep 5; printf NEVER_INLINE".to_string(),
962            ".".to_string(),
963            std::collections::HashMap::default(),
964            Some(600_000),
965            Duration::from_millis(100),
966            None,
967            None,
968        );
969        let ForegroundResult::Detached { job_id } = result else {
970            panic!("timeout_ms must not extend the foreground wait");
971        };
972        assert!(status(&job_id).is_some());
973        cancel(&job_id);
974    }
975
976    /// #1173: a foreground run reports progress while it waits, so a slow
977    /// command is distinguishable from a hang. Deliberately slower than the
978    /// other tests here — it has to outlive one real [`TICK`].
979    #[test]
980    #[cfg_attr(windows, ignore)]
981    fn foreground_run_reports_progress_while_waiting() {
982        let ticks = std::sync::atomic::AtomicUsize::new(0);
983        let tick = |elapsed: Duration| {
984            assert!(elapsed >= TICK, "tick must report real elapsed time");
985            ticks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
986        };
987        let result = run_foreground_or_detach(
988            "sleep 30".to_string(),
989            ".".to_string(),
990            std::collections::HashMap::default(),
991            Some(60_000),
992            TICK + Duration::from_millis(500),
993            Some(&tick),
994            None,
995        );
996        let ForegroundResult::Detached { job_id } = result else {
997            panic!("slow command should detach");
998        };
999        cancel(&job_id);
1000        assert!(
1001            ticks.load(std::sync::atomic::Ordering::Relaxed) >= 1,
1002            "no progress reported during a {}s+ foreground wait",
1003            TICK.as_secs()
1004        );
1005    }
1006
1007    /// #1781: a client-side cancellation must free the waiting thread long
1008    /// before the soft cap. The assertion is on *elapsed time*, not just on the
1009    /// `Detached` shape — detaching at the cap returns the same variant, so the
1010    /// shape alone would pass even with the check removed.
1011    #[test]
1012    #[cfg_attr(windows, ignore)]
1013    fn cancelled_token_detaches_without_waiting_for_the_soft_cap() {
1014        let token = tokio_util::sync::CancellationToken::new();
1015        token.cancel();
1016        let started = std::time::Instant::now();
1017        let result = run_foreground_or_detach(
1018            // Distinct from the progress test's `sleep 30`: identical commands
1019            // coalesce onto one job (#1876), so a shared one would be cancelled
1020            // under the other test's feet.
1021            "sleep 31".to_string(),
1022            ".".to_string(),
1023            std::collections::HashMap::default(),
1024            Some(60_000),
1025            Duration::from_mins(1),
1026            None,
1027            Some(&token),
1028        );
1029        let elapsed = started.elapsed();
1030        let ForegroundResult::Detached { job_id } = result else {
1031            panic!("a cancelled request must detach instead of waiting it out");
1032        };
1033        cancel(&job_id);
1034        assert!(
1035            elapsed < Duration::from_secs(5),
1036            "cancelled wait took {elapsed:?} against a one-minute cap — token not observed"
1037        );
1038    }
1039
1040    #[test]
1041    #[cfg_attr(windows, ignore)]
1042    fn background_job_runs_past_request_and_can_be_observed() {
1043        let (_data, _community) = retained_text_held_still();
1044        let id = start(
1045            "sleep 0.1; printf BG_JOB_OK".to_string(),
1046            ".".to_string(),
1047            std::collections::HashMap::default(),
1048            Some(10_000),
1049        );
1050        assert!(matches!(status(&id), Some(JobState::Running { .. })));
1051        for _ in 0..40 {
1052            if let Some(JobState::Completed { output, exit_code }) = status(&id) {
1053                assert_eq!(exit_code, 0);
1054                assert!(output.contains("BG_JOB_OK"), "got: {output}");
1055                return;
1056            }
1057            std::thread::sleep(Duration::from_millis(25));
1058        }
1059        panic!("background job did not complete");
1060    }
1061
1062    #[test]
1063    #[cfg_attr(windows, ignore)]
1064    fn cancelling_background_job_returns_cancelled_state() {
1065        let id = start(
1066            "sleep 5".to_string(),
1067            ".".to_string(),
1068            std::collections::HashMap::default(),
1069            Some(10_000),
1070        );
1071        assert!(matches!(cancel(&id), Some(JobState::Running { .. })));
1072        for _ in 0..40 {
1073            if let Some(JobState::Cancelled { output }) = status(&id) {
1074                assert!(
1075                    !output.contains("[cancelled: command stopped on request]"),
1076                    "cancellation lifecycle belongs to the structured state, not retained output"
1077                );
1078                return;
1079            }
1080            std::thread::sleep(Duration::from_millis(25));
1081        }
1082        panic!("background job was not cancelled");
1083    }
1084
1085    /// Running jobs must never retain or expose raw output; only terminal
1086    /// StoreAdmission-approved text can appear in a later poll.
1087    #[test]
1088    #[cfg_attr(windows, ignore)]
1089    fn running_background_job_status_never_exposes_live_output() {
1090        let id = start(
1091            "printf EARLY_LINE; sleep 3".to_string(),
1092            ".".to_string(),
1093            std::collections::HashMap::default(),
1094            Some(10_000),
1095        );
1096        let mut saw_running = false;
1097        for _ in 0..20 {
1098            if let Some(JobState::Running { output }) = status(&id) {
1099                saw_running = true;
1100                assert!(output.is_empty(), "running state retained: {output:?}");
1101                break;
1102            }
1103            std::thread::sleep(Duration::from_millis(10));
1104        }
1105        cancel(&id);
1106        assert!(
1107            saw_running,
1108            "job completed before running state was observed"
1109        );
1110    }
1111
1112    /// #1876: identical foreground calls coalesce onto one job. The first to
1113    /// see it finish used to remove the shared entry, so every other caller
1114    /// polled a missing id until the host timed out. Each must get the output.
1115    #[test]
1116    #[cfg_attr(windows, ignore)]
1117    fn identical_concurrent_foreground_calls_all_observe_completion() {
1118        let (_data, _community) = retained_text_held_still();
1119        const CALLERS: usize = 3;
1120        let barrier = std::sync::Arc::new(std::sync::Barrier::new(CALLERS));
1121        let handles: Vec<_> = (0..CALLERS)
1122            .map(|_| {
1123                let barrier = std::sync::Arc::clone(&barrier);
1124                std::thread::spawn(move || {
1125                    barrier.wait();
1126                    run_foreground_or_detach(
1127                        "sleep 0.2; printf GH1876_ALL_OK".to_string(),
1128                        ".".to_string(),
1129                        std::collections::HashMap::default(),
1130                        Some(10_000),
1131                        Duration::from_secs(10),
1132                        None,
1133                        None,
1134                    )
1135                })
1136            })
1137            .collect();
1138        for handle in handles {
1139            match handle.join().expect("foreground caller panicked") {
1140                ForegroundResult::Finished { output, exit_code } => {
1141                    assert_eq!(exit_code, 0);
1142                    assert!(output.contains("GH1876_ALL_OK"), "got: {output}");
1143                }
1144                ForegroundResult::Detached { job_id } => {
1145                    cancel(&job_id);
1146                    panic!("a coalesced caller lost the shared job and detached");
1147                }
1148            }
1149        }
1150    }
1151
1152    /// #1876: a foreground call that joins an explicit background job must
1153    /// not remove the entry the background caller still polls by id.
1154    #[test]
1155    #[cfg_attr(windows, ignore)]
1156    fn foreground_join_keeps_a_background_job_pollable() {
1157        let (_data, _community) = retained_text_held_still();
1158        let command = "sleep 0.2; printf GH1876_BG_OK".to_string();
1159        let id = start(
1160            command.clone(),
1161            ".".to_string(),
1162            std::collections::HashMap::default(),
1163            Some(10_000),
1164        );
1165        let result = run_foreground_or_detach(
1166            command,
1167            ".".to_string(),
1168            std::collections::HashMap::default(),
1169            Some(10_000),
1170            Duration::from_secs(10),
1171            None,
1172            None,
1173        );
1174        assert!(matches!(result, ForegroundResult::Finished { .. }));
1175        match status(&id) {
1176            Some(JobState::Completed { output, .. }) => assert!(output.contains("GH1876_BG_OK")),
1177            other => panic!("background job vanished after a foreground join: {other:?}"),
1178        }
1179        super::remove_for_test(&id);
1180    }
1181
1182    /// #1876: a call that arrives after the job finished but before its waiter
1183    /// read the result must join that entry. Replacing it let the first
1184    /// waiter's release delete the second caller's job.
1185    #[test]
1186    #[cfg_attr(windows, ignore)]
1187    fn a_caller_joins_a_finished_entry_its_waiter_has_not_read() {
1188        // The retained text is storage-admitted under the process's gateway
1189        // configuration and dropped when that configuration changes while the
1190        // job finishes (`StoreAdmission::is_current`). This test reads the
1191        // retained text, so it holds the configuration still: its own data
1192        // directory (which serializes every test that changes one) and no
1193        // project policy.
1194        let _data = lean_ctx::core::data_dir::isolated_data_dir();
1195        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
1196        let launch = || {
1197            super::start_inner(
1198                "printf GH1876_WINDOW".to_string(),
1199                ".".to_string(),
1200                std::collections::HashMap::default(),
1201                Some(10_000),
1202                true,
1203            )
1204        };
1205        let id = launch();
1206        let deadline = std::time::Instant::now() + Duration::from_secs(10);
1207        while !matches!(status(&id), Some(JobState::Completed { .. })) {
1208            assert!(std::time::Instant::now() < deadline, "job never completed");
1209            std::thread::sleep(Duration::from_millis(10));
1210        }
1211
1212        assert_eq!(launch(), id);
1213        super::release_foreground(&id, false);
1214        match status(&id) {
1215            Some(JobState::Completed { output, .. }) => assert!(output.contains("GH1876_WINDOW")),
1216            other => panic!("the joined caller lost its result: {other:?}"),
1217        }
1218        super::release_foreground(&id, false);
1219        assert!(status(&id).is_none(), "the last waiter removes the entry");
1220    }
1221
1222    /// #1876: fast identical commands finish inside one poll interval, which
1223    /// is where callers used to replace an unread entry.
1224    #[test]
1225    #[cfg_attr(windows, ignore)]
1226    fn fast_identical_concurrent_foreground_calls_all_return() {
1227        let (_data, _community) = retained_text_held_still();
1228        const CALLERS: usize = 8;
1229        for _ in 0..5 {
1230            let barrier = std::sync::Arc::new(std::sync::Barrier::new(CALLERS));
1231            let handles: Vec<_> = (0..CALLERS)
1232                .map(|_| {
1233                    let barrier = std::sync::Arc::clone(&barrier);
1234                    std::thread::spawn(move || {
1235                        barrier.wait();
1236                        run_foreground_or_detach(
1237                            "printf GH1876_FAST".to_string(),
1238                            ".".to_string(),
1239                            std::collections::HashMap::default(),
1240                            Some(10_000),
1241                            Duration::from_secs(5),
1242                            None,
1243                            None,
1244                        )
1245                    })
1246                })
1247                .collect();
1248            for handle in handles {
1249                match handle.join().expect("foreground caller panicked") {
1250                    ForegroundResult::Finished { output, .. } => {
1251                        assert!(output.contains("GH1876_FAST"), "got: {output}");
1252                    }
1253                    ForegroundResult::Detached { job_id } => {
1254                        cancel(&job_id);
1255                        panic!("a fast identical caller lost its job and detached");
1256                    }
1257                }
1258            }
1259        }
1260    }
1261
1262    #[test]
1263    fn pruning_keeps_finished_jobs_that_a_waiter_has_not_read() {
1264        let now = std::time::Instant::now();
1265        let mut jobs = std::collections::HashMap::new();
1266        jobs.insert(
1267            "waited".to_string(),
1268            super::Job {
1269                cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
1270                state: JobState::Completed {
1271                    output: "x".repeat(64),
1272                    exit_code: 0,
1273                },
1274                finished_at: Some(
1275                    now.checked_sub(super::COMPLETED_JOB_TTL + Duration::from_secs(1))
1276                        .unwrap(),
1277                ),
1278                generation: 0,
1279                actual_exit_code: 0,
1280                completeness: lean_ctx::core::context_admission::OutputCompleteness::complete(),
1281                output_withheld: false,
1282                notice: None,
1283                foreground_senders: Vec::new(),
1284                foreground_waiters: 1,
1285                background_owned: false,
1286                telemetry_recorded: false,
1287            },
1288        );
1289
1290        super::prune_finished_jobs_with_limits(&mut jobs, now, 0, 0);
1291
1292        assert!(jobs.contains_key("waited"));
1293    }
1294}