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;
5
6use std::future::Future;
7use std::path::PathBuf;
8use std::pin::Pin;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::time::{Duration, Instant};
12
13use serde::{Deserialize, Serialize};
14use tokio::sync::Mutex;
15
16use crate::errors::{Result, WinxError};
17use crate::state::bash_state::BashState;
18use crate::state::pty::SharedPtyShell;
19use crate::tools::bash_command::BashCommandResult;
20use crate::types::BashCommand;
21
22#[cfg(unix)]
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 generation_bound_actions: bool,
50    /// Runtime-owned file containing output dropped from the in-memory head.
51    /// The current result retains the tail; consumers should read this file only
52    /// when they need omitted history.
53    pub dropped_output_file: Option<PathBuf>,
54    /// Authoritative runtime truncation state. It is never inferred from
55    /// terminal text, which is controlled by the child process.
56    pub output_truncated: bool,
57}
58
59impl BashCommandRuntimeResult {
60    pub fn legacy(result: BashCommandResult) -> Self {
61        Self {
62            result,
63            compact_output: None,
64            command_generation: None,
65            execution_token: None,
66            generation_bound_actions: false,
67            dropped_output_file: None,
68            output_truncated: false,
69        }
70    }
71
72    pub(crate) fn with_dropped_output_file(mut self, path: Option<PathBuf>) -> Self {
73        self.dropped_output_file = path;
74        self
75    }
76}
77
78#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
79pub struct ShellExecutionToken {
80    pub guardian_epoch: String,
81    pub session_epoch: String,
82    pub generation: u64,
83}
84
85#[derive(Clone, Debug, Default, Serialize, Deserialize)]
86pub struct ShellActionOptions {
87    pub compact_output: bool,
88    pub expected_generation: Option<u64>,
89    /// Full execution identity used by protocol 1.5 peers. The legacy numeric
90    /// generation remains for source and protocol-1.4 compatibility only.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub expected_execution: Option<ShellExecutionToken>,
93    /// Adapter-to-control precondition. A control process must compare this
94    /// with the effective guardian immediately before relaying an action.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub expected_guardian_epoch: Option<String>,
97    /// Internal safety contract for an execution that is already represented
98    /// by an MCP Task and therefore cannot fall back to an unbound process.
99    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
100    pub require_generation_binding: bool,
101    /// Stable key for cancelling a launch that is queued inside a remote
102    /// guardian before an execution token exists.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub cancellation_key: Option<String>,
105    /// In-process launch gate. Skipped on the wire; control/guardian safety is
106    /// represented by the serializable preconditions above.
107    #[serde(skip)]
108    pub launch_cancelled: Option<Arc<AtomicBool>>,
109    /// Deterministic coverage for the automatic PTY-reset path. Never exists
110    /// in production builds or on the daemon wire.
111    #[cfg(test)]
112    #[serde(skip)]
113    pub(crate) force_clear_to_run_failure: bool,
114}
115
116impl ShellActionOptions {
117    pub(crate) fn is_default(&self) -> bool {
118        let is_default = !self.compact_output
119            && self.expected_generation.is_none()
120            && self.expected_execution.is_none()
121            && self.expected_guardian_epoch.is_none()
122            && !self.require_generation_binding
123            && self.cancellation_key.is_none();
124        #[cfg(test)]
125        {
126            is_default && !self.force_clear_to_run_failure
127        }
128        #[cfg(not(test))]
129        {
130            is_default
131        }
132    }
133
134    pub(crate) fn is_launch_cancelled(&self) -> bool {
135        self.launch_cancelled.as_ref().is_some_and(|flag| flag.load(Ordering::SeqCst))
136    }
137}
138
139impl PartialEq for ShellActionOptions {
140    fn eq(&self, other: &Self) -> bool {
141        let equal = self.compact_output == other.compact_output
142            && self.expected_generation == other.expected_generation
143            && self.expected_execution == other.expected_execution
144            && self.expected_guardian_epoch == other.expected_guardian_epoch
145            && self.require_generation_binding == other.require_generation_binding
146            && self.cancellation_key == other.cancellation_key;
147        #[cfg(test)]
148        {
149            equal && self.force_clear_to_run_failure == other.force_clear_to_run_failure
150        }
151        #[cfg(not(test))]
152        {
153            equal
154        }
155    }
156}
157
158impl Eq for ShellActionOptions {}
159
160#[derive(Clone, Debug, Default, Eq, PartialEq)]
161pub struct ShellSessionConfiguration {
162    pub attach_hint: Option<String>,
163    pub attached_existing: bool,
164}
165
166pub type ShellRuntimeConfigureFuture<'a> =
167    Pin<Box<dyn Future<Output = Result<ShellSessionConfiguration>> + Send + 'a>>;
168
169/// State transition requested by the Initialize tool.
170#[derive(Clone, Copy, Debug, Eq, PartialEq)]
171pub enum ShellSessionTransition {
172    FirstCall,
173    ModeChange,
174    Reset,
175    WorkspaceChange,
176}
177
178/// Narrow process-agnostic boundary for `BashCommand` execution.
179pub trait ShellRuntime: Send + Sync {
180    fn configure_session<'a>(
181        &'a self,
182        bash_state: &'a mut BashState,
183        transition: ShellSessionTransition,
184    ) -> ShellRuntimeConfigureFuture<'a>;
185
186    fn run_action<'a>(
187        &'a self,
188        bash_state: &'a Arc<Mutex<Option<BashState>>>,
189        command: BashCommand,
190    ) -> ShellRuntimeFuture<'a>;
191
192    /// Rich adapter path used by MCP orchestration. External runtime
193    /// implementations remain source-compatible through this default.
194    fn run_action_detailed<'a>(
195        &'a self,
196        bash_state: &'a Arc<Mutex<Option<BashState>>>,
197        command: BashCommand,
198        _options: ShellActionOptions,
199    ) -> ShellRuntimeDetailedFuture<'a> {
200        Box::pin(async move {
201            self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
202        })
203    }
204
205    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
206        Box::pin(async { Ok(false) })
207    }
208
209    /// Session-aware capability probe. Daemon-backed runtimes override this so
210    /// the answer comes from the guardian that owns this shell, not merely from
211    /// the stable control process in front of it.
212    fn supports_generation_bound_actions_for<'a>(
213        &'a self,
214        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
215    ) -> ShellRuntimeBoolFuture<'a> {
216        self.supports_generation_bound_actions()
217    }
218
219    fn interrupt<'a>(
220        &'a self,
221        bash_state: &'a Arc<Mutex<Option<BashState>>>,
222    ) -> ShellRuntimeUnitFuture<'a>;
223
224    /// Interrupt only the exact command generation owned by a Task. The
225    /// conservative default refuses generation-bound interruption.
226    fn interrupt_generation<'a>(
227        &'a self,
228        bash_state: &'a Arc<Mutex<Option<BashState>>>,
229        expected_generation: Option<u64>,
230    ) -> ShellRuntimeBoolFuture<'a> {
231        Box::pin(async move {
232            if expected_generation.is_some() {
233                return Ok(false);
234            }
235            self.interrupt(bash_state).await?;
236            Ok(true)
237        })
238    }
239
240    /// Interrupt only the complete execution identity. External runtimes keep
241    /// working through the generation-only conservative default.
242    fn interrupt_execution<'a>(
243        &'a self,
244        bash_state: &'a Arc<Mutex<Option<BashState>>>,
245        expected: Option<ShellExecutionToken>,
246    ) -> ShellRuntimeBoolFuture<'a> {
247        self.interrupt_generation(bash_state, expected.map(|token| token.generation))
248    }
249
250    /// Cancel a command that is reserved remotely but has not reached the PTY.
251    /// Embedded runtimes use the in-process cancellation flag and need no
252    /// additional reservation protocol.
253    fn cancel_pending_action<'a>(
254        &'a self,
255        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
256        _cancellation_key: &'a str,
257    ) -> ShellRuntimeBoolFuture<'a> {
258        Box::pin(async { Ok(false) })
259    }
260
261    /// Release the runtime-owned resources for one logical session.
262    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
263}
264
265/// Current in-process runtime. It intentionally delegates to the existing
266/// implementation so introducing the boundary does not change behavior.
267#[derive(Clone, Copy, Debug, Default)]
268pub struct EmbeddedShellRuntime;
269
270impl ShellRuntime for EmbeddedShellRuntime {
271    fn configure_session<'a>(
272        &'a self,
273        bash_state: &'a mut BashState,
274        transition: ShellSessionTransition,
275    ) -> ShellRuntimeConfigureFuture<'a> {
276        Box::pin(async move {
277            let operation_barrier =
278                lock_session_store().operation_barrier(&bash_state.current_thread_id);
279            let _operation = operation_barrier.write().await;
280            if matches!(
281                transition,
282                ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
283            ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
284            {
285                bash_state.init_pty_shell().await?;
286            }
287            let attach_hint = {
288                let shell = bash_state.pty_shell.lock().await;
289                shell.as_ref().and_then(|shell| shell.attach_hint.clone())
290            };
291            Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
292        })
293    }
294
295    fn run_action<'a>(
296        &'a self,
297        bash_state: &'a Arc<Mutex<Option<BashState>>>,
298        command: BashCommand,
299    ) -> ShellRuntimeFuture<'a> {
300        Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
301    }
302
303    fn run_action_detailed<'a>(
304        &'a self,
305        bash_state: &'a Arc<Mutex<Option<BashState>>>,
306        command: BashCommand,
307        options: ShellActionOptions,
308    ) -> ShellRuntimeDetailedFuture<'a> {
309        Box::pin(crate::tools::bash_command::handle_embedded_tool_call_detailed(
310            bash_state, command, options,
311        ))
312    }
313
314    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
315        Box::pin(async { Ok(true) })
316    }
317
318    fn interrupt<'a>(
319        &'a self,
320        bash_state: &'a Arc<Mutex<Option<BashState>>>,
321    ) -> ShellRuntimeUnitFuture<'a> {
322        Box::pin(interrupt_embedded(bash_state))
323    }
324
325    fn interrupt_generation<'a>(
326        &'a self,
327        bash_state: &'a Arc<Mutex<Option<BashState>>>,
328        expected_generation: Option<u64>,
329    ) -> ShellRuntimeBoolFuture<'a> {
330        Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
331    }
332
333    fn interrupt_execution<'a>(
334        &'a self,
335        bash_state: &'a Arc<Mutex<Option<BashState>>>,
336        expected: Option<ShellExecutionToken>,
337    ) -> ShellRuntimeBoolFuture<'a> {
338        Box::pin(interrupt_embedded_execution(bash_state, expected))
339    }
340
341    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
342        Box::pin(async move {
343            let operation_barrier = lock_session_store().operation_barrier(thread_id);
344            let _operation = operation_barrier.write().await;
345            let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
346            if let Some(shell) = shell {
347                *shell.lock().await = None;
348            }
349            Ok(())
350        })
351    }
352}
353
354async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
355    let _ = interrupt_embedded_generation(bash_state, None).await?;
356    Ok(())
357}
358
359const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
360const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
361const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
362const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
363
364/// Interrupt one exact foreground generation and wait until its prompt is
365/// visible. PTYs can occasionally deliver Ctrl+C while a short-lived child is
366/// handing terminal control back to its parent, so retry the signal while the
367/// same generation is still running. The caller retains the session operation
368/// barrier, which prevents any retry from reaching a newer command.
369pub(crate) async fn interrupt_pty_until_recovered(
370    shell: &SharedPtyShell,
371    expected_generation: Option<u64>,
372) -> Result<bool> {
373    let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
374    let mut next_interrupt = Instant::now();
375    let mut attempts = 0_u32;
376
377    loop {
378        let now = Instant::now();
379        let recovered = {
380            let mut guard = shell.lock().await;
381            let Some(pty) = guard.as_mut() else { return Ok(false) };
382            if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
383                return Ok(false);
384            }
385
386            if pty.poll_output_nonblocking() || !pty.command_running {
387                if attempts == 0 {
388                    return Ok(false);
389                }
390                true
391            } else {
392                if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
393                    pty.send_interrupt().map_err(|error| {
394                        WinxError::CommandExecutionError(format!(
395                            "failed to interrupt shell: {error}"
396                        ))
397                    })?;
398                    attempts += 1;
399                    next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
400                    if attempts > 1 {
401                        tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
402                    }
403                }
404                false
405            }
406        };
407
408        if recovered {
409            return Ok(true);
410        }
411        if Instant::now() >= deadline {
412            break;
413        }
414        tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
415    }
416
417    Err(WinxError::CommandExecutionError(
418        "interrupted shell did not return to a prompt within 3 seconds".to_string(),
419    ))
420}
421
422async fn interrupt_embedded_generation(
423    bash_state: &Arc<Mutex<Option<BashState>>>,
424    expected_generation: Option<u64>,
425) -> Result<bool> {
426    interrupt_embedded_guarded(bash_state, expected_generation, None).await
427}
428
429async fn interrupt_embedded_execution(
430    bash_state: &Arc<Mutex<Option<BashState>>>,
431    expected: Option<ShellExecutionToken>,
432) -> Result<bool> {
433    interrupt_embedded_guarded(
434        bash_state,
435        expected.as_ref().map(|token| token.generation),
436        expected.as_ref(),
437    )
438    .await
439}
440
441async fn interrupt_embedded_guarded(
442    bash_state: &Arc<Mutex<Option<BashState>>>,
443    expected_generation: Option<u64>,
444    expected_execution: Option<&ShellExecutionToken>,
445) -> Result<bool> {
446    // Match configure_session's BashState -> operation barrier lock order and
447    // retain the read side from incarnation capture through interruption.
448    let (shell, _operation) = {
449        let state = bash_state.lock().await;
450        let Some(state) = state.as_ref() else { return Ok(false) };
451        let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
452        let operation = operation_barrier.read_owned().await;
453        (state.pty_shell.clone(), operation)
454    };
455    {
456        let guard = shell.lock().await;
457        if let Some(pty) = guard.as_ref() {
458            if expected_execution.is_some_and(|expected| {
459                expected.guardian_epoch != "embedded"
460                    || expected.session_epoch != format!("{:016x}", pty.incarnation())
461            }) {
462                return Ok(false);
463            }
464        }
465    }
466    interrupt_pty_until_recovered(&shell, expected_generation).await
467}