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