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