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