Skip to main content

winx_code_agent/runtime/
mod.rs

1//! Shell execution boundary shared by embedded and daemon-backed runtimes.
2
3mod selection;
4mod session_store;
5pub mod workflow;
6
7use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use serde::{Deserialize, Serialize};
15use tokio::sync::Mutex;
16
17use crate::errors::{Result, WinxError};
18use crate::state::bash_state::BashState;
19use crate::state::pty::SharedPtyShell;
20use crate::tools::bash_command::BashCommandResult;
21use crate::types::BashCommand;
22
23pub use selection::{
24    configured_daemon_binary, ensure_control_daemon_at, ensure_daemon_at, restart_control_daemon_at,
25};
26pub use selection::{
27    configured_runtime_mode, configured_shell_runtime, select_runtime_mode, RuntimeMode,
28};
29pub(crate) use session_store::lock_session_store;
30pub use session_store::{SessionStore, ShellTarget};
31
32/// Boxed future returned by [`ShellRuntime`] implementations.
33pub type ShellRuntimeFuture<'a> =
34    Pin<Box<dyn Future<Output = Result<BashCommandResult>> + Send + 'a>>;
35pub type ShellRuntimeUnitFuture<'a> = Pin<Box<dyn Future<Output = Result<()>> + Send + 'a>>;
36pub type ShellRuntimeBoolFuture<'a> = Pin<Box<dyn Future<Output = Result<bool>> + Send + 'a>>;
37pub type ShellRuntimeDetailedFuture<'a> =
38    Pin<Box<dyn Future<Output = Result<BashCommandRuntimeResult>> + Send + 'a>>;
39
40/// Internal orchestration details layered around the stable public
41/// `BashCommandResult`. Adding a separate type avoids adding required fields to
42/// the existing public result struct.
43#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct BashCommandRuntimeResult {
45    pub result: BashCommandResult,
46    pub compact_output: Option<String>,
47    pub command_generation: Option<u64>,
48    pub execution_token: Option<ShellExecutionToken>,
49    pub action_context: Option<ShellActionContext>,
50    pub generation_bound_actions: bool,
51    /// Runtime-owned file containing output dropped from the in-memory head.
52    /// The current result retains the tail; consumers should read this file only
53    /// when they need omitted history.
54    pub dropped_output_file: Option<PathBuf>,
55    /// Authoritative runtime truncation state. It is never inferred from
56    /// terminal text, which is controlled by the child process.
57    pub output_truncated: bool,
58    pub workflow: Option<workflow::WorkflowObservation>,
59}
60
61impl BashCommandRuntimeResult {
62    pub fn legacy(result: BashCommandResult) -> Self {
63        Self {
64            result,
65            compact_output: None,
66            command_generation: None,
67            execution_token: None,
68            action_context: None,
69            generation_bound_actions: false,
70            dropped_output_file: None,
71            output_truncated: false,
72            workflow: None,
73        }
74    }
75
76    pub(crate) fn with_dropped_output_file(mut self, path: Option<PathBuf>) -> Self {
77        self.dropped_output_file = path;
78        self
79    }
80}
81
82/// Immutable context captured when the runtime admits this action.
83/// Later observations may run in another workspace; only launches create history.
84#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct ShellActionContext {
86    pub thread_id: String,
87    pub workspace_root: PathBuf,
88}
89
90#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
91pub struct ShellExecutionToken {
92    pub guardian_epoch: String,
93    pub session_epoch: String,
94    pub generation: u64,
95}
96
97#[derive(Clone, Debug, Default, Serialize, Deserialize)]
98pub struct ShellActionOptions {
99    pub compact_output: bool,
100    pub expected_generation: Option<u64>,
101    /// Full execution identity used by protocol 1.5 peers. The legacy numeric
102    /// generation remains for source and protocol-1.4 compatibility only.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub expected_execution: Option<ShellExecutionToken>,
105    /// Adapter-to-control precondition. A control process must compare this
106    /// with the effective guardian immediately before relaying an action.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub expected_guardian_epoch: Option<String>,
109    /// Internal safety contract for an execution that is already represented
110    /// by an MCP Task and therefore cannot fall back to an unbound process.
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub require_generation_binding: bool,
113    /// Stable key for cancelling a launch that is queued inside a remote
114    /// guardian before an execution token exists.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub cancellation_key: Option<String>,
117    /// In-process launch gate. Skipped on the wire; control/guardian safety is
118    /// represented by the serializable preconditions above.
119    #[serde(skip)]
120    pub launch_cancelled: Option<Arc<AtomicBool>>,
121    /// Deterministic coverage for the automatic PTY-reset path. Never exists
122    /// in production builds or on the daemon wire.
123    #[cfg(test)]
124    #[serde(skip)]
125    pub(crate) force_clear_to_run_failure: bool,
126}
127
128impl ShellActionOptions {
129    pub(crate) fn is_default(&self) -> bool {
130        let is_default = !self.compact_output
131            && self.expected_generation.is_none()
132            && self.expected_execution.is_none()
133            && self.expected_guardian_epoch.is_none()
134            && !self.require_generation_binding
135            && self.cancellation_key.is_none();
136        #[cfg(test)]
137        {
138            is_default && !self.force_clear_to_run_failure
139        }
140        #[cfg(not(test))]
141        {
142            is_default
143        }
144    }
145
146    pub(crate) fn is_launch_cancelled(&self) -> bool {
147        self.launch_cancelled.as_ref().is_some_and(|flag| flag.load(Ordering::SeqCst))
148    }
149}
150
151impl PartialEq for ShellActionOptions {
152    fn eq(&self, other: &Self) -> bool {
153        let equal = self.compact_output == other.compact_output
154            && self.expected_generation == other.expected_generation
155            && self.expected_execution == other.expected_execution
156            && self.expected_guardian_epoch == other.expected_guardian_epoch
157            && self.require_generation_binding == other.require_generation_binding
158            && self.cancellation_key == other.cancellation_key;
159        #[cfg(test)]
160        {
161            equal && self.force_clear_to_run_failure == other.force_clear_to_run_failure
162        }
163        #[cfg(not(test))]
164        {
165            equal
166        }
167    }
168}
169
170impl Eq for ShellActionOptions {}
171
172#[derive(Clone, Debug, Default, Eq, PartialEq)]
173pub struct ShellSessionConfiguration {
174    pub attach_hint: Option<String>,
175    pub attached_existing: bool,
176}
177
178pub type ShellRuntimeConfigureFuture<'a> =
179    Pin<Box<dyn Future<Output = Result<ShellSessionConfiguration>> + Send + 'a>>;
180
181/// Observational snapshot of one shell session, answered by
182/// `Initialize(type = "status")` without touching the PTY.
183#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
184pub struct ShellSessionStatus {
185    /// `daemon` or `embedded`.
186    pub runtime: &'static str,
187    /// Guardian process behind this session (daemon runtime only).
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub guardian_pid: Option<u32>,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub guardian_epoch: Option<String>,
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub guardian_build: Option<String>,
194    pub command_running: bool,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub running_command_id: Option<String>,
197    pub background_command_ids: Vec<String>,
198    #[serde(skip_serializing_if = "Option::is_none")]
199    pub last_activity_unix_ms: Option<u64>,
200}
201
202pub type ShellRuntimeStatusFuture<'a> =
203    Pin<Box<dyn Future<Output = Result<ShellSessionStatus>> + Send + 'a>>;
204
205pub type ShellRuntimeTemporaryReleaseFuture<'a> = Pin<
206    Box<
207        dyn Future<Output = Result<crate::utils::agent_temp::release::TemporaryReleaseResult>>
208            + Send
209            + 'a,
210    >,
211>;
212
213/// State transition requested by the Initialize tool.
214#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub enum ShellSessionTransition {
216    FirstCall,
217    ModeChange,
218    Reset,
219    WorkspaceChange,
220}
221
222/// Narrow process-agnostic boundary for `BashCommand` execution.
223pub trait ShellRuntime: Send + Sync {
224    /// Execute cleanup at the authoritative runtime owner, never after a detached idle probe.
225    fn release_temporary<'a>(
226        &'a self,
227        _state: &'a Arc<Mutex<Option<BashState>>>,
228        _request: crate::utils::agent_temp::release::TemporaryReleaseRequest,
229    ) -> ShellRuntimeTemporaryReleaseFuture<'a> {
230        Box::pin(async {
231            Err(WinxError::InvalidInput(
232                "unsupported: safe temporary release requires a capable durable guardian on Linux"
233                    .into(),
234            ))
235        })
236    }
237
238    fn configure_session<'a>(
239        &'a self,
240        bash_state: &'a mut BashState,
241        transition: ShellSessionTransition,
242    ) -> ShellRuntimeConfigureFuture<'a>;
243
244    /// Reattach an adapter to its existing session. External runtimes retain
245    /// their historical mode-refresh behavior; native runtimes override this
246    /// with a non-mutating fast path.
247    fn attach_session<'a>(
248        &'a self,
249        bash_state: &'a mut BashState,
250    ) -> ShellRuntimeConfigureFuture<'a> {
251        self.configure_session(bash_state, ShellSessionTransition::ModeChange)
252    }
253
254    fn run_action<'a>(
255        &'a self,
256        bash_state: &'a Arc<Mutex<Option<BashState>>>,
257        command: BashCommand,
258    ) -> ShellRuntimeFuture<'a>;
259
260    /// Rich adapter path used by MCP orchestration. External runtime
261    /// implementations remain source-compatible through this default.
262    fn run_action_detailed<'a>(
263        &'a self,
264        bash_state: &'a Arc<Mutex<Option<BashState>>>,
265        command: BashCommand,
266        _options: ShellActionOptions,
267    ) -> ShellRuntimeDetailedFuture<'a> {
268        Box::pin(async move {
269            self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
270        })
271    }
272
273    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
274        Box::pin(async { Ok(false) })
275    }
276
277    /// Session-aware capability probe. Daemon-backed runtimes override this so
278    /// the answer comes from the guardian that owns this shell, not merely from
279    /// the stable control process in front of it.
280    fn supports_generation_bound_actions_for<'a>(
281        &'a self,
282        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
283    ) -> ShellRuntimeBoolFuture<'a> {
284        self.supports_generation_bound_actions()
285    }
286    /// Whether this exact session's guardian supports native observations and prompts.
287    fn supports_agent_workflows_for<'a>(
288        &'a self,
289        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
290    ) -> ShellRuntimeBoolFuture<'a> {
291        Box::pin(async { Ok(false) })
292    }
293
294    /// Describe the session without mutating it. The default inspects the
295    /// in-process PTY; daemon-backed runtimes ask the guardian that owns it.
296    fn describe_session<'a>(
297        &'a self,
298        bash_state: &'a Arc<Mutex<Option<BashState>>>,
299    ) -> ShellRuntimeStatusFuture<'a> {
300        Box::pin(async move {
301            let guard = bash_state.lock().await;
302            let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
303            let (command_running, running_command_id) = {
304                let shell = state.pty_shell.lock().await;
305                shell.as_ref().map_or((false, None), |shell| {
306                    (
307                        shell.command_running,
308                        shell.command_running.then(|| shell.last_command.clone()),
309                    )
310                })
311            };
312            let background_command_ids =
313                lock_session_store().background_ids_for(&state.current_thread_id);
314            Ok(ShellSessionStatus {
315                runtime: "embedded",
316                command_running,
317                running_command_id,
318                background_command_ids,
319                ..ShellSessionStatus::default()
320            })
321        })
322    }
323
324    fn interrupt<'a>(
325        &'a self,
326        bash_state: &'a Arc<Mutex<Option<BashState>>>,
327    ) -> ShellRuntimeUnitFuture<'a>;
328
329    /// Interrupt only the exact command generation owned by a Task. The
330    /// conservative default refuses generation-bound interruption.
331    fn interrupt_generation<'a>(
332        &'a self,
333        bash_state: &'a Arc<Mutex<Option<BashState>>>,
334        expected_generation: Option<u64>,
335    ) -> ShellRuntimeBoolFuture<'a> {
336        Box::pin(async move {
337            if expected_generation.is_some() {
338                return Ok(false);
339            }
340            self.interrupt(bash_state).await?;
341            Ok(true)
342        })
343    }
344
345    /// Interrupt only the complete execution identity. External runtimes keep
346    /// working through the generation-only conservative default.
347    fn interrupt_execution<'a>(
348        &'a self,
349        bash_state: &'a Arc<Mutex<Option<BashState>>>,
350        expected: Option<ShellExecutionToken>,
351    ) -> ShellRuntimeBoolFuture<'a> {
352        self.interrupt_generation(bash_state, expected.map(|token| token.generation))
353    }
354
355    /// Cancel a command that is reserved remotely but has not reached the PTY.
356    /// Embedded runtimes use the in-process cancellation flag and need no
357    /// additional reservation protocol.
358    fn cancel_pending_action<'a>(
359        &'a self,
360        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
361        _cancellation_key: &'a str,
362    ) -> ShellRuntimeBoolFuture<'a> {
363        Box::pin(async { Ok(false) })
364    }
365
366    /// Release the runtime-owned resources for one logical session.
367    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
368}
369
370/// Current in-process runtime. It intentionally delegates to the existing
371/// implementation so introducing the boundary does not change behavior.
372#[derive(Clone, Copy, Debug, Default)]
373pub struct EmbeddedShellRuntime;
374
375impl ShellRuntime for EmbeddedShellRuntime {
376    fn configure_session<'a>(
377        &'a self,
378        bash_state: &'a mut BashState,
379        transition: ShellSessionTransition,
380    ) -> ShellRuntimeConfigureFuture<'a> {
381        Box::pin(async move {
382            let operation_barrier =
383                lock_session_store().operation_barrier(&bash_state.current_thread_id);
384            let _operation = operation_barrier.write().await;
385            if matches!(
386                transition,
387                ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
388            ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
389            {
390                bash_state.init_pty_shell().await?;
391            }
392            let attach_hint = {
393                let shell = bash_state.pty_shell.lock().await;
394                shell.as_ref().and_then(|shell| shell.attach_hint.clone())
395            };
396            Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
397        })
398    }
399
400    fn attach_session<'a>(
401        &'a self,
402        _bash_state: &'a mut BashState,
403    ) -> ShellRuntimeConfigureFuture<'a> {
404        Box::pin(async {
405            Ok(ShellSessionConfiguration { attach_hint: None, attached_existing: true })
406        })
407    }
408
409    fn run_action<'a>(
410        &'a self,
411        bash_state: &'a Arc<Mutex<Option<BashState>>>,
412        command: BashCommand,
413    ) -> ShellRuntimeFuture<'a> {
414        Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
415    }
416
417    fn run_action_detailed<'a>(
418        &'a self,
419        bash_state: &'a Arc<Mutex<Option<BashState>>>,
420        command: BashCommand,
421        options: ShellActionOptions,
422    ) -> ShellRuntimeDetailedFuture<'a> {
423        Box::pin(async move {
424            if options
425                .expected_execution
426                .as_ref()
427                .is_some_and(|expected| expected.guardian_epoch != "embedded")
428                || options
429                    .expected_guardian_epoch
430                    .as_deref()
431                    .is_some_and(|epoch| epoch != "embedded")
432            {
433                return Err(WinxError::InvalidInput(
434                    "execution precondition belongs to a different runtime guardian".to_string(),
435                ));
436            }
437            crate::tools::bash_command::handle_embedded_tool_call_detailed(
438                bash_state, command, options,
439            )
440            .await
441        })
442    }
443
444    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
445        Box::pin(async { Ok(true) })
446    }
447
448    fn supports_agent_workflows_for<'a>(
449        &'a self,
450        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
451    ) -> ShellRuntimeBoolFuture<'a> {
452        Box::pin(async { Ok(true) })
453    }
454
455    fn interrupt<'a>(
456        &'a self,
457        bash_state: &'a Arc<Mutex<Option<BashState>>>,
458    ) -> ShellRuntimeUnitFuture<'a> {
459        Box::pin(interrupt_embedded(bash_state))
460    }
461
462    fn interrupt_generation<'a>(
463        &'a self,
464        bash_state: &'a Arc<Mutex<Option<BashState>>>,
465        expected_generation: Option<u64>,
466    ) -> ShellRuntimeBoolFuture<'a> {
467        Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
468    }
469
470    fn interrupt_execution<'a>(
471        &'a self,
472        bash_state: &'a Arc<Mutex<Option<BashState>>>,
473        expected: Option<ShellExecutionToken>,
474    ) -> ShellRuntimeBoolFuture<'a> {
475        Box::pin(interrupt_embedded_execution(bash_state, expected))
476    }
477
478    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
479        Box::pin(async move {
480            let operation_barrier = lock_session_store().operation_barrier(thread_id);
481            let _operation = operation_barrier.write().await;
482            let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
483            if let Some(shell) = shell {
484                *shell.lock().await = None;
485            }
486            Ok(())
487        })
488    }
489}
490
491async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
492    let _ = interrupt_embedded_generation(bash_state, None).await?;
493    Ok(())
494}
495
496const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
497const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
498const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
499const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
500
501/// Interrupt one exact foreground generation and wait until its prompt is
502/// visible. PTYs can occasionally deliver Ctrl+C while a short-lived child is
503/// handing terminal control back to its parent, so retry the signal while the
504/// same generation is still running. The caller retains the session operation
505/// barrier, which prevents any retry from reaching a newer command.
506pub(crate) async fn interrupt_pty_until_recovered(
507    shell: &SharedPtyShell,
508    expected_generation: Option<u64>,
509) -> Result<bool> {
510    let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
511    let mut next_interrupt = Instant::now();
512    let mut attempts = 0_u32;
513
514    loop {
515        let now = Instant::now();
516        let recovered = {
517            let mut guard = shell.lock().await;
518            let Some(pty) = guard.as_mut() else { return Ok(false) };
519            if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
520                return Ok(false);
521            }
522
523            if pty.poll_output_nonblocking() || !pty.command_running {
524                if attempts == 0 {
525                    return Ok(false);
526                }
527                true
528            } else {
529                if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
530                    pty.send_interrupt().map_err(|error| {
531                        WinxError::CommandExecutionError(format!(
532                            "failed to interrupt shell: {error}"
533                        ))
534                    })?;
535                    attempts += 1;
536                    next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
537                    if attempts > 1 {
538                        tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
539                    }
540                }
541                false
542            }
543        };
544
545        if recovered {
546            return Ok(true);
547        }
548        if Instant::now() >= deadline {
549            break;
550        }
551        tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
552    }
553
554    Err(WinxError::CommandExecutionError(
555        "interrupted shell did not return to a prompt within 3 seconds".to_string(),
556    ))
557}
558
559async fn interrupt_embedded_generation(
560    bash_state: &Arc<Mutex<Option<BashState>>>,
561    expected_generation: Option<u64>,
562) -> Result<bool> {
563    interrupt_embedded_guarded(bash_state, expected_generation, None).await
564}
565
566async fn interrupt_embedded_execution(
567    bash_state: &Arc<Mutex<Option<BashState>>>,
568    expected: Option<ShellExecutionToken>,
569) -> Result<bool> {
570    interrupt_embedded_guarded(
571        bash_state,
572        expected.as_ref().map(|token| token.generation),
573        expected.as_ref(),
574    )
575    .await
576}
577
578async fn interrupt_embedded_guarded(
579    bash_state: &Arc<Mutex<Option<BashState>>>,
580    expected_generation: Option<u64>,
581    expected_execution: Option<&ShellExecutionToken>,
582) -> Result<bool> {
583    // Match configure_session's BashState -> operation barrier lock order and
584    // retain the read side from incarnation capture through interruption.
585    let (shell, _operation) = {
586        let state = bash_state.lock().await;
587        let Some(state) = state.as_ref() else { return Ok(false) };
588        let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
589        let operation = operation_barrier.read_owned().await;
590        (state.pty_shell.clone(), operation)
591    };
592    {
593        let guard = shell.lock().await;
594        if let Some(pty) = guard.as_ref() {
595            if expected_execution.is_some_and(|expected| {
596                expected.guardian_epoch != "embedded"
597                    || expected.session_epoch != format!("{:016x}", pty.incarnation())
598            }) {
599                return Ok(false);
600            }
601        }
602    }
603    interrupt_pty_until_recovered(&shell, expected_generation).await
604}
605
606/// The existing operation barrier excludes native command/prompt starts. Busy,
607/// unavailable, or uninitialized owners fail closed; a foreground wait timeout
608/// does not clear `command_running`. The blocking worker owns the barrier even
609/// if its awaiting RPC disappears.
610pub(crate) async fn release_temporary_at_owner(
611    slot: &Arc<Mutex<Option<BashState>>>,
612    request: crate::utils::agent_temp::release::TemporaryReleaseRequest,
613) -> Result<crate::utils::agent_temp::release::TemporaryReleaseResult> {
614    let state = slot.lock().await;
615    let state = state.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
616    if state.current_thread_id != request.thread_id
617        || state.workspace_root != request.workspace_root
618    {
619        return Err(WinxError::InvalidInput(
620            "temporary release scope differs from initialized runtime".into(),
621        ));
622    }
623    for file in &request.files {
624        if !state.is_file_edit_allowed(&file.target.file_path) {
625            return Err(WinxError::FileOperationDenied {
626                path: file.target.file_path.clone().into(),
627                message: "temporary release is not allowed in the current runtime mode".into(),
628            });
629        }
630    }
631    let barrier = lock_session_store().operation_barrier(&request.thread_id);
632    let operation = barrier
633        .try_write_owned()
634        .map_err(|_| WinxError::InvalidInput("in_use: a runtime operation is active".into()))?;
635    {
636        let shell = state.pty_shell.lock().await;
637        let shell = shell.as_ref().ok_or_else(|| {
638            WinxError::InvalidInput("unresolved_reference: runtime shell is unavailable".into())
639        })?;
640        if shell.command_running {
641            return Err(WinxError::InvalidInput("in_use: foreground execution is active".into()));
642        }
643    }
644    let backgrounds = lock_session_store().background_ids_for(&request.thread_id);
645    for id in backgrounds {
646        let shell = lock_session_store().resolve(&request.thread_id, &ShellTarget::Background(id));
647        let Some(shell) = shell else {
648            return Err(WinxError::InvalidInput(
649                "unresolved_reference: background execution state is unavailable".into(),
650            ));
651        };
652        let shell = shell.lock().await;
653        if shell.as_ref().is_none_or(|shell| shell.command_running) {
654            return Err(WinxError::InvalidInput(
655                "in_use: background execution is active or unresolved".into(),
656            ));
657        }
658    }
659    tokio::task::spawn_blocking(move || {
660        let _operation = operation;
661        crate::utils::agent_temp::release::release(&request)
662    })
663    .await
664    .map_err(|error| {
665        WinxError::CommandExecutionError(format!("temporary release worker failed: {error}"))
666    })?
667}
668
669#[cfg(all(test, target_os = "linux"))]
670mod temporary_release_runtime_tests {
671    #![allow(clippy::unwrap_used, clippy::expect_used)]
672    use super::*;
673    use crate::utils::agent_temp::release::{prepare, read_managed, TemporaryReleaseTarget};
674
675    #[tokio::test]
676    async fn live_background_execution_blocks_release_after_inline_return() -> anyhow::Result<()> {
677        let workspace = tempfile::tempdir()?;
678        let root = workspace.path().canonicalize()?;
679        let thread = crate::types::normalize_thread_id(&format!(
680            "release-runtime-{:032x}",
681            rand::random::<u128>()
682        ));
683        let helper =
684            crate::utils::agent_temp::session_info(&root, &thread).directory.join("helper");
685        std::fs::create_dir_all(helper.parent().unwrap())?;
686        std::fs::write(&helper, "keep")?;
687        let (text, metadata) = read_managed(&root, &thread, &helper)?
688            .ok_or_else(|| anyhow::anyhow!("generation missing"))?;
689        let target = TemporaryReleaseTarget {
690            file_path: helper.to_string_lossy().into_owned(),
691            expected_revision: format!(
692                "sha256:{}",
693                crate::tools::file_write_or_edit::hash_content(&text)
694            ),
695            artifact_generation: metadata.artifact_generation,
696        };
697        let request = prepare(&root, &thread, &[target])?;
698        let mut state = BashState::new();
699        state.current_thread_id = thread.clone();
700        state.workspace_root = root.clone();
701        state.cwd = root;
702        state.mode = crate::types::Modes::Wcgw;
703        state.init_pty_shell().await?;
704        let slot = Arc::new(Mutex::new(Some(state)));
705        let command: BashCommand = serde_json::from_value(serde_json::json!({
706            "thread_id": thread, "wait_for_seconds": 0.01,
707            "action_json": { "type": "command", "command": "sleep 30", "is_background": true, "allow_multi": false }
708        }))?;
709        EmbeddedShellRuntime
710            .run_action_detailed(&slot, command, ShellActionOptions::default())
711            .await?;
712        let outcome = release_temporary_at_owner(&slot, request).await;
713        // Clean up only the test-owned background process even if the assertion fails.
714        let ids = lock_session_store().background_ids_for(&thread);
715        for id in ids {
716            lock_session_store().remove_shell(&id);
717        }
718        assert!(outcome.is_err(), "a returned inline wait does not end execution ownership");
719        assert_eq!(std::fs::read_to_string(helper)?, "keep");
720        Ok(())
721    }
722}