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