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