1mod selection;
4mod session_store;
5pub mod workflow;
6
7use std::future::Future;
8use std::path::PathBuf;
9use std::pin::Pin;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::time::{Duration, Instant};
13
14use serde::{Deserialize, Serialize};
15use tokio::sync::Mutex;
16
17use crate::errors::{Result, WinxError};
18use crate::state::bash_state::BashState;
19use crate::state::pty::SharedPtyShell;
20use crate::tools::bash_command::BashCommandResult;
21use crate::types::BashCommand;
22
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
32pub 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#[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 action_context: Option<ShellActionContext>,
50 pub generation_bound_actions: bool,
51 pub dropped_output_file: Option<PathBuf>,
55 pub output_truncated: bool,
58 pub workflow: Option<workflow::WorkflowObservation>,
59}
60
61impl BashCommandRuntimeResult {
62 pub fn legacy(result: BashCommandResult) -> Self {
63 Self {
64 result,
65 compact_output: None,
66 command_generation: None,
67 execution_token: None,
68 action_context: None,
69 generation_bound_actions: false,
70 dropped_output_file: None,
71 output_truncated: false,
72 workflow: None,
73 }
74 }
75
76 pub(crate) fn with_dropped_output_file(mut self, path: Option<PathBuf>) -> Self {
77 self.dropped_output_file = path;
78 self
79 }
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
85pub struct ShellActionContext {
86 pub thread_id: String,
87 pub workspace_root: PathBuf,
88}
89
90#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
91pub struct ShellExecutionToken {
92 pub guardian_epoch: String,
93 pub session_epoch: String,
94 pub generation: u64,
95}
96
97#[derive(Clone, Debug, Default, Serialize, Deserialize)]
98pub struct ShellActionOptions {
99 pub compact_output: bool,
100 pub expected_generation: Option<u64>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub expected_execution: Option<ShellExecutionToken>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub expected_guardian_epoch: Option<String>,
109 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112 pub require_generation_binding: bool,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub cancellation_key: Option<String>,
117 #[serde(skip)]
120 pub launch_cancelled: Option<Arc<AtomicBool>>,
121 #[cfg(test)]
124 #[serde(skip)]
125 pub(crate) force_clear_to_run_failure: bool,
126}
127
128impl ShellActionOptions {
129 pub(crate) fn is_default(&self) -> bool {
130 let is_default = !self.compact_output
131 && self.expected_generation.is_none()
132 && self.expected_execution.is_none()
133 && self.expected_guardian_epoch.is_none()
134 && !self.require_generation_binding
135 && self.cancellation_key.is_none();
136 #[cfg(test)]
137 {
138 is_default && !self.force_clear_to_run_failure
139 }
140 #[cfg(not(test))]
141 {
142 is_default
143 }
144 }
145
146 pub(crate) fn is_launch_cancelled(&self) -> bool {
147 self.launch_cancelled.as_ref().is_some_and(|flag| flag.load(Ordering::SeqCst))
148 }
149}
150
151impl PartialEq for ShellActionOptions {
152 fn eq(&self, other: &Self) -> bool {
153 let equal = self.compact_output == other.compact_output
154 && self.expected_generation == other.expected_generation
155 && self.expected_execution == other.expected_execution
156 && self.expected_guardian_epoch == other.expected_guardian_epoch
157 && self.require_generation_binding == other.require_generation_binding
158 && self.cancellation_key == other.cancellation_key;
159 #[cfg(test)]
160 {
161 equal && self.force_clear_to_run_failure == other.force_clear_to_run_failure
162 }
163 #[cfg(not(test))]
164 {
165 equal
166 }
167 }
168}
169
170impl Eq for ShellActionOptions {}
171
172#[derive(Clone, Debug, Default, Eq, PartialEq)]
173pub struct ShellSessionConfiguration {
174 pub attach_hint: Option<String>,
175 pub attached_existing: bool,
176}
177
178pub type ShellRuntimeConfigureFuture<'a> =
179 Pin<Box<dyn Future<Output = Result<ShellSessionConfiguration>> + Send + 'a>>;
180
181#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize)]
184pub struct ShellSessionStatus {
185 pub runtime: &'static str,
187 #[serde(skip_serializing_if = "Option::is_none")]
189 pub guardian_pid: Option<u32>,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub guardian_epoch: Option<String>,
192 #[serde(skip_serializing_if = "Option::is_none")]
193 pub guardian_build: Option<String>,
194 pub command_running: bool,
195 #[serde(skip_serializing_if = "Option::is_none")]
196 pub running_command_id: Option<String>,
197 pub background_command_ids: Vec<String>,
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub last_activity_unix_ms: Option<u64>,
200}
201
202pub type ShellRuntimeStatusFuture<'a> =
203 Pin<Box<dyn Future<Output = Result<ShellSessionStatus>> + Send + 'a>>;
204
205pub type ShellRuntimeTemporaryReleaseFuture<'a> = Pin<
206 Box<
207 dyn Future<Output = Result<crate::utils::agent_temp::release::TemporaryReleaseResult>>
208 + Send
209 + 'a,
210 >,
211>;
212
213#[derive(Clone, Copy, Debug, Eq, PartialEq)]
215pub enum ShellSessionTransition {
216 FirstCall,
217 ModeChange,
218 Reset,
219 WorkspaceChange,
220}
221
222pub trait ShellRuntime: Send + Sync {
224 fn release_temporary<'a>(
226 &'a self,
227 _state: &'a Arc<Mutex<Option<BashState>>>,
228 _request: crate::utils::agent_temp::release::TemporaryReleaseRequest,
229 ) -> ShellRuntimeTemporaryReleaseFuture<'a> {
230 Box::pin(async {
231 Err(WinxError::InvalidInput(
232 "unsupported: safe temporary release requires a capable durable guardian on Linux"
233 .into(),
234 ))
235 })
236 }
237
238 fn configure_session<'a>(
239 &'a self,
240 bash_state: &'a mut BashState,
241 transition: ShellSessionTransition,
242 ) -> ShellRuntimeConfigureFuture<'a>;
243
244 fn attach_session<'a>(
248 &'a self,
249 bash_state: &'a mut BashState,
250 ) -> ShellRuntimeConfigureFuture<'a> {
251 self.configure_session(bash_state, ShellSessionTransition::ModeChange)
252 }
253
254 fn run_action<'a>(
255 &'a self,
256 bash_state: &'a Arc<Mutex<Option<BashState>>>,
257 command: BashCommand,
258 ) -> ShellRuntimeFuture<'a>;
259
260 fn run_action_detailed<'a>(
263 &'a self,
264 bash_state: &'a Arc<Mutex<Option<BashState>>>,
265 command: BashCommand,
266 _options: ShellActionOptions,
267 ) -> ShellRuntimeDetailedFuture<'a> {
268 Box::pin(async move {
269 self.run_action(bash_state, command).await.map(BashCommandRuntimeResult::legacy)
270 })
271 }
272
273 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
274 Box::pin(async { Ok(false) })
275 }
276
277 fn supports_generation_bound_actions_for<'a>(
281 &'a self,
282 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
283 ) -> ShellRuntimeBoolFuture<'a> {
284 self.supports_generation_bound_actions()
285 }
286 fn supports_agent_workflows_for<'a>(
288 &'a self,
289 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
290 ) -> ShellRuntimeBoolFuture<'a> {
291 Box::pin(async { Ok(false) })
292 }
293
294 fn describe_session<'a>(
297 &'a self,
298 bash_state: &'a Arc<Mutex<Option<BashState>>>,
299 ) -> ShellRuntimeStatusFuture<'a> {
300 Box::pin(async move {
301 let guard = bash_state.lock().await;
302 let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
303 let (command_running, running_command_id) = {
304 let shell = state.pty_shell.lock().await;
305 shell.as_ref().map_or((false, None), |shell| {
306 (
307 shell.command_running,
308 shell.command_running.then(|| shell.last_command.clone()),
309 )
310 })
311 };
312 let background_command_ids =
313 lock_session_store().background_ids_for(&state.current_thread_id);
314 Ok(ShellSessionStatus {
315 runtime: "embedded",
316 command_running,
317 running_command_id,
318 background_command_ids,
319 ..ShellSessionStatus::default()
320 })
321 })
322 }
323
324 fn interrupt<'a>(
325 &'a self,
326 bash_state: &'a Arc<Mutex<Option<BashState>>>,
327 ) -> ShellRuntimeUnitFuture<'a>;
328
329 fn interrupt_generation<'a>(
332 &'a self,
333 bash_state: &'a Arc<Mutex<Option<BashState>>>,
334 expected_generation: Option<u64>,
335 ) -> ShellRuntimeBoolFuture<'a> {
336 Box::pin(async move {
337 if expected_generation.is_some() {
338 return Ok(false);
339 }
340 self.interrupt(bash_state).await?;
341 Ok(true)
342 })
343 }
344
345 fn interrupt_execution<'a>(
348 &'a self,
349 bash_state: &'a Arc<Mutex<Option<BashState>>>,
350 expected: Option<ShellExecutionToken>,
351 ) -> ShellRuntimeBoolFuture<'a> {
352 self.interrupt_generation(bash_state, expected.map(|token| token.generation))
353 }
354
355 fn cancel_pending_action<'a>(
359 &'a self,
360 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
361 _cancellation_key: &'a str,
362 ) -> ShellRuntimeBoolFuture<'a> {
363 Box::pin(async { Ok(false) })
364 }
365
366 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a>;
368}
369
370#[derive(Clone, Copy, Debug, Default)]
373pub struct EmbeddedShellRuntime;
374
375impl ShellRuntime for EmbeddedShellRuntime {
376 fn configure_session<'a>(
377 &'a self,
378 bash_state: &'a mut BashState,
379 transition: ShellSessionTransition,
380 ) -> ShellRuntimeConfigureFuture<'a> {
381 Box::pin(async move {
382 let operation_barrier =
383 lock_session_store().operation_barrier(&bash_state.current_thread_id);
384 let _operation = operation_barrier.write().await;
385 if matches!(
386 transition,
387 ShellSessionTransition::FirstCall | ShellSessionTransition::Reset
388 ) && (transition == ShellSessionTransition::Reset || bash_state.cwd.exists())
389 {
390 bash_state.init_pty_shell().await?;
391 }
392 let attach_hint = {
393 let shell = bash_state.pty_shell.lock().await;
394 shell.as_ref().and_then(|shell| shell.attach_hint.clone())
395 };
396 Ok(ShellSessionConfiguration { attach_hint, attached_existing: false })
397 })
398 }
399
400 fn attach_session<'a>(
401 &'a self,
402 _bash_state: &'a mut BashState,
403 ) -> ShellRuntimeConfigureFuture<'a> {
404 Box::pin(async {
405 Ok(ShellSessionConfiguration { attach_hint: None, attached_existing: true })
406 })
407 }
408
409 fn run_action<'a>(
410 &'a self,
411 bash_state: &'a Arc<Mutex<Option<BashState>>>,
412 command: BashCommand,
413 ) -> ShellRuntimeFuture<'a> {
414 Box::pin(crate::tools::bash_command::handle_embedded_tool_call(bash_state, command))
415 }
416
417 fn run_action_detailed<'a>(
418 &'a self,
419 bash_state: &'a Arc<Mutex<Option<BashState>>>,
420 command: BashCommand,
421 options: ShellActionOptions,
422 ) -> ShellRuntimeDetailedFuture<'a> {
423 Box::pin(async move {
424 if options
425 .expected_execution
426 .as_ref()
427 .is_some_and(|expected| expected.guardian_epoch != "embedded")
428 || options
429 .expected_guardian_epoch
430 .as_deref()
431 .is_some_and(|epoch| epoch != "embedded")
432 {
433 return Err(WinxError::InvalidInput(
434 "execution precondition belongs to a different runtime guardian".to_string(),
435 ));
436 }
437 crate::tools::bash_command::handle_embedded_tool_call_detailed(
438 bash_state, command, options,
439 )
440 .await
441 })
442 }
443
444 fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
445 Box::pin(async { Ok(true) })
446 }
447
448 fn supports_agent_workflows_for<'a>(
449 &'a self,
450 _bash_state: &'a Arc<Mutex<Option<BashState>>>,
451 ) -> ShellRuntimeBoolFuture<'a> {
452 Box::pin(async { Ok(true) })
453 }
454
455 fn interrupt<'a>(
456 &'a self,
457 bash_state: &'a Arc<Mutex<Option<BashState>>>,
458 ) -> ShellRuntimeUnitFuture<'a> {
459 Box::pin(interrupt_embedded(bash_state))
460 }
461
462 fn interrupt_generation<'a>(
463 &'a self,
464 bash_state: &'a Arc<Mutex<Option<BashState>>>,
465 expected_generation: Option<u64>,
466 ) -> ShellRuntimeBoolFuture<'a> {
467 Box::pin(interrupt_embedded_generation(bash_state, expected_generation))
468 }
469
470 fn interrupt_execution<'a>(
471 &'a self,
472 bash_state: &'a Arc<Mutex<Option<BashState>>>,
473 expected: Option<ShellExecutionToken>,
474 ) -> ShellRuntimeBoolFuture<'a> {
475 Box::pin(interrupt_embedded_execution(bash_state, expected))
476 }
477
478 fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
479 Box::pin(async move {
480 let operation_barrier = lock_session_store().operation_barrier(thread_id);
481 let _operation = operation_barrier.write().await;
482 let shell = lock_session_store().resolve(thread_id, &ShellTarget::Main);
483 if let Some(shell) = shell {
484 *shell.lock().await = None;
485 }
486 Ok(())
487 })
488 }
489}
490
491async fn interrupt_embedded(bash_state: &Arc<Mutex<Option<BashState>>>) -> Result<()> {
492 let _ = interrupt_embedded_generation(bash_state, None).await?;
493 Ok(())
494}
495
496const INTERRUPT_RECOVERY_TIMEOUT: Duration = Duration::from_secs(3);
497const INTERRUPT_RETRY_INTERVAL: Duration = Duration::from_millis(250);
498const INTERRUPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
499const MAX_INTERRUPT_ATTEMPTS: u32 = 3;
500
501pub(crate) async fn interrupt_pty_until_recovered(
507 shell: &SharedPtyShell,
508 expected_generation: Option<u64>,
509) -> Result<bool> {
510 let deadline = Instant::now() + INTERRUPT_RECOVERY_TIMEOUT;
511 let mut next_interrupt = Instant::now();
512 let mut attempts = 0_u32;
513
514 loop {
515 let now = Instant::now();
516 let recovered = {
517 let mut guard = shell.lock().await;
518 let Some(pty) = guard.as_mut() else { return Ok(false) };
519 if expected_generation.is_some_and(|expected| pty.command_generation() != expected) {
520 return Ok(false);
521 }
522
523 if pty.poll_output_nonblocking() || !pty.command_running {
524 if attempts == 0 {
525 return Ok(false);
526 }
527 true
528 } else {
529 if attempts < MAX_INTERRUPT_ATTEMPTS && now >= next_interrupt {
530 pty.send_interrupt().map_err(|error| {
531 WinxError::CommandExecutionError(format!(
532 "failed to interrupt shell: {error}"
533 ))
534 })?;
535 attempts += 1;
536 next_interrupt = Instant::now() + INTERRUPT_RETRY_INTERVAL;
537 if attempts > 1 {
538 tracing::debug!(attempts, "retrying Ctrl+C for active PTY generation");
539 }
540 }
541 false
542 }
543 };
544
545 if recovered {
546 return Ok(true);
547 }
548 if Instant::now() >= deadline {
549 break;
550 }
551 tokio::time::sleep(INTERRUPT_POLL_INTERVAL).await;
552 }
553
554 Err(WinxError::CommandExecutionError(
555 "interrupted shell did not return to a prompt within 3 seconds".to_string(),
556 ))
557}
558
559async fn interrupt_embedded_generation(
560 bash_state: &Arc<Mutex<Option<BashState>>>,
561 expected_generation: Option<u64>,
562) -> Result<bool> {
563 interrupt_embedded_guarded(bash_state, expected_generation, None).await
564}
565
566async fn interrupt_embedded_execution(
567 bash_state: &Arc<Mutex<Option<BashState>>>,
568 expected: Option<ShellExecutionToken>,
569) -> Result<bool> {
570 interrupt_embedded_guarded(
571 bash_state,
572 expected.as_ref().map(|token| token.generation),
573 expected.as_ref(),
574 )
575 .await
576}
577
578async fn interrupt_embedded_guarded(
579 bash_state: &Arc<Mutex<Option<BashState>>>,
580 expected_generation: Option<u64>,
581 expected_execution: Option<&ShellExecutionToken>,
582) -> Result<bool> {
583 let (shell, _operation) = {
586 let state = bash_state.lock().await;
587 let Some(state) = state.as_ref() else { return Ok(false) };
588 let operation_barrier = lock_session_store().operation_barrier(&state.current_thread_id);
589 let operation = operation_barrier.read_owned().await;
590 (state.pty_shell.clone(), operation)
591 };
592 {
593 let guard = shell.lock().await;
594 if let Some(pty) = guard.as_ref() {
595 if expected_execution.is_some_and(|expected| {
596 expected.guardian_epoch != "embedded"
597 || expected.session_epoch != format!("{:016x}", pty.incarnation())
598 }) {
599 return Ok(false);
600 }
601 }
602 }
603 interrupt_pty_until_recovered(&shell, expected_generation).await
604}
605
606pub(crate) async fn release_temporary_at_owner(
611 slot: &Arc<Mutex<Option<BashState>>>,
612 request: crate::utils::agent_temp::release::TemporaryReleaseRequest,
613) -> Result<crate::utils::agent_temp::release::TemporaryReleaseResult> {
614 let state = slot.lock().await;
615 let state = state.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
616 if state.current_thread_id != request.thread_id
617 || state.workspace_root != request.workspace_root
618 {
619 return Err(WinxError::InvalidInput(
620 "temporary release scope differs from initialized runtime".into(),
621 ));
622 }
623 for file in &request.files {
624 if !state.is_file_edit_allowed(&file.target.file_path) {
625 return Err(WinxError::FileOperationDenied {
626 path: file.target.file_path.clone().into(),
627 message: "temporary release is not allowed in the current runtime mode".into(),
628 });
629 }
630 }
631 let barrier = lock_session_store().operation_barrier(&request.thread_id);
632 let operation = barrier
633 .try_write_owned()
634 .map_err(|_| WinxError::InvalidInput("in_use: a runtime operation is active".into()))?;
635 {
636 let shell = state.pty_shell.lock().await;
637 let shell = shell.as_ref().ok_or_else(|| {
638 WinxError::InvalidInput("unresolved_reference: runtime shell is unavailable".into())
639 })?;
640 if shell.command_running {
641 return Err(WinxError::InvalidInput("in_use: foreground execution is active".into()));
642 }
643 }
644 let backgrounds = lock_session_store().background_ids_for(&request.thread_id);
645 for id in backgrounds {
646 let shell = lock_session_store().resolve(&request.thread_id, &ShellTarget::Background(id));
647 let Some(shell) = shell else {
648 return Err(WinxError::InvalidInput(
649 "unresolved_reference: background execution state is unavailable".into(),
650 ));
651 };
652 let shell = shell.lock().await;
653 if shell.as_ref().is_none_or(|shell| shell.command_running) {
654 return Err(WinxError::InvalidInput(
655 "in_use: background execution is active or unresolved".into(),
656 ));
657 }
658 }
659 tokio::task::spawn_blocking(move || {
660 let _operation = operation;
661 crate::utils::agent_temp::release::release(&request)
662 })
663 .await
664 .map_err(|error| {
665 WinxError::CommandExecutionError(format!("temporary release worker failed: {error}"))
666 })?
667}
668
669#[cfg(all(test, target_os = "linux"))]
670mod temporary_release_runtime_tests {
671 #![allow(clippy::unwrap_used, clippy::expect_used)]
672 use super::*;
673 use crate::utils::agent_temp::release::{prepare, read_managed, TemporaryReleaseTarget};
674
675 #[tokio::test]
676 async fn live_background_execution_blocks_release_after_inline_return() -> anyhow::Result<()> {
677 let workspace = tempfile::tempdir()?;
678 let root = workspace.path().canonicalize()?;
679 let thread = crate::types::normalize_thread_id(&format!(
680 "release-runtime-{:032x}",
681 rand::random::<u128>()
682 ));
683 let helper =
684 crate::utils::agent_temp::session_info(&root, &thread).directory.join("helper");
685 std::fs::create_dir_all(helper.parent().unwrap())?;
686 std::fs::write(&helper, "keep")?;
687 let (text, metadata) = read_managed(&root, &thread, &helper)?
688 .ok_or_else(|| anyhow::anyhow!("generation missing"))?;
689 let target = TemporaryReleaseTarget {
690 file_path: helper.to_string_lossy().into_owned(),
691 expected_revision: format!(
692 "sha256:{}",
693 crate::tools::file_write_or_edit::hash_content(&text)
694 ),
695 artifact_generation: metadata.artifact_generation,
696 };
697 let request = prepare(&root, &thread, &[target])?;
698 let mut state = BashState::new();
699 state.current_thread_id = thread.clone();
700 state.workspace_root = root.clone();
701 state.cwd = root;
702 state.mode = crate::types::Modes::Wcgw;
703 state.init_pty_shell().await?;
704 let slot = Arc::new(Mutex::new(Some(state)));
705 let command: BashCommand = serde_json::from_value(serde_json::json!({
706 "thread_id": thread, "wait_for_seconds": 0.01,
707 "action_json": { "type": "command", "command": "sleep 30", "is_background": true, "allow_multi": false }
708 }))?;
709 EmbeddedShellRuntime
710 .run_action_detailed(&slot, command, ShellActionOptions::default())
711 .await?;
712 let outcome = release_temporary_at_owner(&slot, request).await;
713 let ids = lock_session_store().background_ids_for(&thread);
715 for id in ids {
716 lock_session_store().remove_shell(&id);
717 }
718 assert!(outcome.is_err(), "a returned inline wait does not end execution ownership");
719 assert_eq!(std::fs::read_to_string(helper)?, "keep");
720 Ok(())
721 }
722}