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    /// Reattach an adapter to its existing session. External runtimes retain
187    /// their historical mode-refresh behavior; native runtimes override this
188    /// with a non-mutating fast path.
189    fn attach_session<'a>(
190        &'a self,
191        bash_state: &'a mut BashState,
192    ) -> ShellRuntimeConfigureFuture<'a> {
193        self.configure_session(bash_state, ShellSessionTransition::ModeChange)
194    }
195
196    fn run_action<'a>(
197        &'a self,
198        bash_state: &'a Arc<Mutex<Option<BashState>>>,
199        command: BashCommand,
200    ) -> ShellRuntimeFuture<'a>;
201
202    /// Rich adapter path used by MCP orchestration. External runtime
203    /// implementations remain source-compatible through this default.
204    fn run_action_detailed<'a>(
205        &'a self,
206        bash_state: &'a Arc<Mutex<Option<BashState>>>,
207        command: BashCommand,
208        _options: ShellActionOptions,
209    ) -> ShellRuntimeDetailedFuture<'a> {
210        Box::pin(async move {
211            self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
212        })
213    }
214
215    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
216        Box::pin(async { Ok(false) })
217    }
218
219    /// Session-aware capability probe. Daemon-backed runtimes override this so
220    /// the answer comes from the guardian that owns this shell, not merely from
221    /// the stable control process in front of it.
222    fn supports_generation_bound_actions_for<'a>(
223        &'a self,
224        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
225    ) -> ShellRuntimeBoolFuture<'a> {
226        self.supports_generation_bound_actions()
227    }
228
229    fn interrupt<'a>(
230        &'a self,
231        bash_state: &'a Arc<Mutex<Option<BashState>>>,
232    ) -> ShellRuntimeUnitFuture<'a>;
233
234    /// Interrupt only the exact command generation owned by a Task. The
235    /// conservative default refuses generation-bound interruption.
236    fn interrupt_generation<'a>(
237        &'a self,
238        bash_state: &'a Arc<Mutex<Option<BashState>>>,
239        expected_generation: Option<u64>,
240    ) -> ShellRuntimeBoolFuture<'a> {
241        Box::pin(async move {
242            if expected_generation.is_some() {
243                return Ok(false);
244            }
245            self.interrupt(bash_state).await?;
246            Ok(true)
247        })
248    }
249
250    /// Interrupt only the complete execution identity. External runtimes keep
251    /// working through the generation-only conservative default.
252    fn interrupt_execution<'a>(
253        &'a self,
254        bash_state: &'a Arc<Mutex<Option<BashState>>>,
255        expected: Option<ShellExecutionToken>,
256    ) -> ShellRuntimeBoolFuture<'a> {
257        self.interrupt_generation(bash_state, expected.map(|token| token.generation))
258    }
259
260    /// Cancel a command that is reserved remotely but has not reached the PTY.
261    /// Embedded runtimes use the in-process cancellation flag and need no
262    /// additional reservation protocol.
263    fn cancel_pending_action<'a>(
264        &'a self,
265        _bash_state: &'a Arc<Mutex<Option<BashState>>>,
266        _cancellation_key: &'a str,
267    ) -> ShellRuntimeBoolFuture<'a> {
268        Box::pin(async { Ok(false) })
269    }
270
271    /// Release the runtime-owned resources for one logical session.
272    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
273}
274
275/// Current in-process runtime. It intentionally delegates to the existing
276/// implementation so introducing the boundary does not change behavior.
277#[derive(Clone, Copy, Debug, Default)]
278pub struct EmbeddedShellRuntime;
279
280impl ShellRuntime for EmbeddedShellRuntime {
281    fn configure_session<'a>(
282        &'a self,
283        bash_state: &'a mut BashState,
284        transition: ShellSessionTransition,
285    ) -> ShellRuntimeConfigureFuture<'a> {
286        Box::pin(async move {
287            let operation_barrier =
288                lock_session_store().operation_barrier(&bash_state.current_thread_id);
289            let _operation = operation_barrier.write().await;
290            if matches!(
291                transition,
292                ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
293            ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
294            {
295                bash_state.init_pty_shell().await?;
296            }
297            let attach_hint = {
298                let shell = bash_state.pty_shell.lock().await;
299                shell.as_ref().and_then(|shell| shell.attach_hint.clone())
300            };
301            Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
302        })
303    }
304
305    fn attach_session<'a>(
306        &'a self,
307        _bash_state: &'a mut BashState,
308    ) -> ShellRuntimeConfigureFuture<'a> {
309        Box::pin(async {
310            Ok(ShellSessionConfiguration { attach_hint: None, attached_existing: true })
311        })
312    }
313
314    fn run_action<'a>(
315        &'a self,
316        bash_state: &'a Arc<Mutex<Option<BashState>>>,
317        command: BashCommand,
318    ) -> ShellRuntimeFuture<'a> {
319        Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
320    }
321
322    fn run_action_detailed<'a>(
323        &'a self,
324        bash_state: &'a Arc<Mutex<Option<BashState>>>,
325        command: BashCommand,
326        options: ShellActionOptions,
327    ) -> ShellRuntimeDetailedFuture<'a> {
328        Box::pin(crate::tools::bash_command::handle_embedded_tool_call_detailed(
329            bash_state, command, options,
330        ))
331    }
332
333    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
334        Box::pin(async { Ok(true) })
335    }
336
337    fn interrupt<'a>(
338        &'a self,
339        bash_state: &'a Arc<Mutex<Option<BashState>>>,
340    ) -> ShellRuntimeUnitFuture<'a> {
341        Box::pin(interrupt_embedded(bash_state))
342    }
343
344    fn interrupt_generation<'a>(
345        &'a self,
346        bash_state: &'a Arc<Mutex<Option<BashState>>>,
347        expected_generation: Option<u64>,
348    ) -> ShellRuntimeBoolFuture<'a> {
349        Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
350    }
351
352    fn interrupt_execution<'a>(
353        &'a self,
354        bash_state: &'a Arc<Mutex<Option<BashState>>>,
355        expected: Option<ShellExecutionToken>,
356    ) -> ShellRuntimeBoolFuture<'a> {
357        Box::pin(interrupt_embedded_execution(bash_state, expected))
358    }
359
360    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
361        Box::pin(async move {
362            let operation_barrier = lock_session_store().operation_barrier(thread_id);
363            let _operation = operation_barrier.write().await;
364            let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
365            if let Some(shell) = shell {
366                *shell.lock().await = None;
367            }
368            Ok(())
369        })
370    }
371}
372
373async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
374    let _ = interrupt_embedded_generation(bash_state, None).await?;
375    Ok(())
376}
377
378const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
379const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
380const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
381const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
382
383/// Interrupt one exact foreground generation and wait until its prompt is
384/// visible. PTYs can occasionally deliver Ctrl+C while a short-lived child is
385/// handing terminal control back to its parent, so retry the signal while the
386/// same generation is still running. The caller retains the session operation
387/// barrier, which prevents any retry from reaching a newer command.
388pub(crate) async fn interrupt_pty_until_recovered(
389    shell: &SharedPtyShell,
390    expected_generation: Option<u64>,
391) -> Result<bool> {
392    let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
393    let mut next_interrupt = Instant::now();
394    let mut attempts = 0_u32;
395
396    loop {
397        let now = Instant::now();
398        let recovered = {
399            let mut guard = shell.lock().await;
400            let Some(pty) = guard.as_mut() else { return Ok(false) };
401            if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
402                return Ok(false);
403            }
404
405            if pty.poll_output_nonblocking() || !pty.command_running {
406                if attempts == 0 {
407                    return Ok(false);
408                }
409                true
410            } else {
411                if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
412                    pty.send_interrupt().map_err(|error| {
413                        WinxError::CommandExecutionError(format!(
414                            "failed to interrupt shell: {error}"
415                        ))
416                    })?;
417                    attempts += 1;
418                    next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
419                    if attempts > 1 {
420                        tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
421                    }
422                }
423                false
424            }
425        };
426
427        if recovered {
428            return Ok(true);
429        }
430        if Instant::now() >= deadline {
431            break;
432        }
433        tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
434    }
435
436    Err(WinxError::CommandExecutionError(
437        "interrupted shell did not return to a prompt within 3 seconds".to_string(),
438    ))
439}
440
441async fn interrupt_embedded_generation(
442    bash_state: &Arc<Mutex<Option<BashState>>>,
443    expected_generation: Option<u64>,
444) -> Result<bool> {
445    interrupt_embedded_guarded(bash_state, expected_generation, None).await
446}
447
448async fn interrupt_embedded_execution(
449    bash_state: &Arc<Mutex<Option<BashState>>>,
450    expected: Option<ShellExecutionToken>,
451) -> Result<bool> {
452    interrupt_embedded_guarded(
453        bash_state,
454        expected.as_ref().map(|token| token.generation),
455        expected.as_ref(),
456    )
457    .await
458}
459
460async fn interrupt_embedded_guarded(
461    bash_state: &Arc<Mutex<Option<BashState>>>,
462    expected_generation: Option<u64>,
463    expected_execution: Option<&ShellExecutionToken>,
464) -> Result<bool> {
465    // Match configure_session's BashState -> operation barrier lock order and
466    // retain the read side from incarnation capture through interruption.
467    let (shell, _operation) = {
468        let state = bash_state.lock().await;
469        let Some(state) = state.as_ref() else { return Ok(false) };
470        let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
471        let operation = operation_barrier.read_owned().await;
472        (state.pty_shell.clone(), operation)
473    };
474    {
475        let guard = shell.lock().await;
476        if let Some(pty) = guard.as_ref() {
477            if expected_execution.is_some_and(|expected| {
478                expected.guardian_epoch != "embedded"
479                    || expected.session_epoch != format!("{:016x}", pty.incarnation())
480            }) {
481                return Ok(false);
482            }
483        }
484    }
485    interrupt_pty_until_recovered(&shell, expected_generation).await
486}