Skip to main content

winx_code_agent/daemon/
client.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::Arc;
5use std::time::{Duration, Instant};
6
7use crate::daemon::transport::DaemonStream;
8use tokio::sync::Mutex;
9
10use super::protocol::{
11    read_json_frame, write_json_frame, CancelActionParams, ConfigureSessionParams,
12    ConfigureSessionResult, ConfigureSessionTransition, HelloResult, JournalRead,
13    JournalReadParams, PruneParams, PruneResult, RpcRequest, RpcResponse, RunActionParams,
14    RunActionResult, SessionInfo, SessionParams, WireShellError, ATTACH_OR_CREATE_CAPABILITY,
15    CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY, COMPACT_ACTION_OUTPUT_CAPABILITY,
16    GENERATION_BOUND_ACTIONS_CAPABILITY, PROTOCOL_MAJOR, TYPED_ACTION_RESULT_CAPABILITY,
17};
18use crate::errors::{Result, WinxError};
19use crate::runtime::{
20    BashCommandRuntimeResult, ShellActionOptions, ShellExecutionToken, ShellRuntime,
21    ShellRuntimeBoolFuture, ShellRuntimeConfigureFuture, ShellRuntimeDetailedFuture,
22    ShellRuntimeFuture, ShellRuntimeUnitFuture, ShellSessionConfiguration, ShellSessionTransition,
23};
24use crate::state::bash_state::BashState;
25use crate::tools::bash_command::BashCommandResult;
26use crate::types::BashCommand;
27
28/// Shell runtime backed by a `winxd` Unix-domain socket.
29#[derive(Clone, Debug)]
30pub struct DaemonShellRuntime {
31    socket_path: PathBuf,
32    consumer_id: String,
33    negotiations: Arc<Mutex<NegotiationCache>>,
34}
35
36#[derive(Debug, Default)]
37struct NegotiationCache {
38    sessions: HashMap<String, CachedNegotiation>,
39    gates: HashMap<String, NegotiationGate>,
40    local_revisions: HashMap<String, LocalRevision>,
41}
42
43#[derive(Debug)]
44struct CachedNegotiation {
45    session: Arc<Mutex<NegotiatedSession>>,
46    last_seen: Instant,
47}
48
49#[derive(Debug)]
50struct NegotiationGate {
51    gate: Arc<Mutex<()>>,
52    last_seen: Instant,
53}
54
55#[derive(Debug)]
56struct LocalRevision {
57    revision: Arc<AtomicU64>,
58    last_seen: Instant,
59}
60
61const MAX_NEGOTIATED_SESSIONS: usize = 64;
62const NEGOTIATION_TTL: Duration = Duration::from_secs(5 * 60);
63const DAEMON_CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
64const SESSION_CONTROL_RPC_TIMEOUT: Duration = Duration::from_secs(10);
65
66#[derive(Debug)]
67struct NegotiatedSession {
68    control_epoch: String,
69    guardian: HelloResult,
70    /// Idle negotiated connection used only to detect a control restart. Tool
71    /// RPCs use disposable connections so cancelled framing is never reused.
72    health_stream: DaemonStream,
73}
74
75#[derive(Clone, Copy, Debug, Eq, PartialEq)]
76enum RemoteSessionTransition {
77    FirstCall,
78    Attach,
79    ModeChange,
80    Reset,
81    WorkspaceChange,
82}
83
84impl From<ShellSessionTransition> for RemoteSessionTransition {
85    fn from(transition: ShellSessionTransition) -> Self {
86        match transition {
87            ShellSessionTransition::FirstCall => Self::FirstCall,
88            ShellSessionTransition::ModeChange => Self::ModeChange,
89            ShellSessionTransition::Reset => Self::Reset,
90            ShellSessionTransition::WorkspaceChange => Self::WorkspaceChange,
91        }
92    }
93}
94
95/// Control-plane client used by the CLI and reconnection tests.
96#[derive(Clone, Debug)]
97pub struct DaemonClient {
98    socket_path: PathBuf,
99}
100
101impl DaemonClient {
102    pub fn new(socket_path: impl AsRef<Path>) -> Self {
103        Self { socket_path: socket_path.as_ref().to_path_buf() }
104    }
105
106    async fn connected_with_hello(&self) -> Result<(DaemonStream, HelloResult)> {
107        let mut stream = self.connect_raw().await?;
108        let hello: HelloResult = tokio::time::timeout(
109            SESSION_CONTROL_RPC_TIMEOUT,
110            DaemonShellRuntime::request(
111                &mut stream,
112                rand::random::<u64>(),
113                "winx.hello",
114                serde_json::json!({ "protocol_major": PROTOCOL_MAJOR }),
115            ),
116        )
117        .await
118        .map_err(|_| WinxError::CommandTimeout {
119            command: "winxd handshake".to_string(),
120            timeout_seconds: SESSION_CONTROL_RPC_TIMEOUT.as_secs(),
121        })??;
122        if hello.protocol_major != PROTOCOL_MAJOR {
123            return Err(WinxError::ConfigurationError(format!(
124                "winxd protocol major {} is incompatible with client {}",
125                hello.protocol_major, PROTOCOL_MAJOR
126            )));
127        }
128        Ok((stream, hello))
129    }
130
131    async fn connect_raw(&self) -> Result<DaemonStream> {
132        tokio::time::timeout(
133            DAEMON_CONNECT_TIMEOUT,
134            crate::daemon::transport::connect(&self.socket_path),
135        )
136        .await
137        .map_err(|_| WinxError::CommandTimeout {
138            command: format!("connect to winxd at {}", self.socket_path.display()),
139            timeout_seconds: DAEMON_CONNECT_TIMEOUT.as_secs(),
140        })?
141        .map_err(|error| {
142            WinxError::ShellInitializationError(format!(
143                "cannot connect to winxd at {}: {error}",
144                self.socket_path.display()
145            ))
146        })
147    }
148
149    async fn connected(&self) -> Result<DaemonStream> {
150        self.connected_with_hello().await.map(|(stream, _)| stream)
151    }
152
153    pub async fn hello(&self) -> Result<HelloResult> {
154        self.connected_with_hello().await.map(|(_, hello)| hello)
155    }
156
157    pub async fn list_sessions(&self) -> Result<Vec<SessionInfo>> {
158        let mut stream = self.connected().await?;
159        DaemonShellRuntime::request(
160            &mut stream,
161            rand::random::<u64>(),
162            "session.list",
163            serde_json::json!({}),
164        )
165        .await
166    }
167
168    pub async fn session_info(&self, thread_id: &str) -> Result<SessionInfo> {
169        let mut stream = self.connected().await?;
170        DaemonShellRuntime::request(
171            &mut stream,
172            rand::random::<u64>(),
173            "session.info",
174            serde_json::to_value(SessionParams {
175                thread_id: thread_id.to_string(),
176                expected_generation: None,
177                expected_execution: None,
178                expected_guardian_epoch: None,
179            })
180            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
181        )
182        .await
183    }
184
185    pub async fn read_output(&self, thread_id: &str, consumer_id: &str) -> Result<JournalRead> {
186        let mut stream = self.connected().await?;
187        DaemonShellRuntime::request(
188            &mut stream,
189            rand::random::<u64>(),
190            "session.read_output",
191            serde_json::to_value(JournalReadParams {
192                thread_id: thread_id.to_string(),
193                consumer_id: consumer_id.to_string(),
194            })
195            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
196        )
197        .await
198    }
199
200    /// Ask the process behind this socket to exit after answering. Only
201    /// daemons advertising `process_shutdown` honor it.
202    pub async fn shutdown(&self) -> Result<()> {
203        let mut stream = self.connected().await?;
204        let _: serde_json::Value = DaemonShellRuntime::request(
205            &mut stream,
206            rand::random::<u64>(),
207            "winx.shutdown",
208            serde_json::json!({}),
209        )
210        .await?;
211        Ok(())
212    }
213
214    pub async fn kill_session(&self, thread_id: &str) -> Result<bool> {
215        let mut stream = self.connected().await?;
216        DaemonShellRuntime::request(
217            &mut stream,
218            rand::random::<u64>(),
219            "session.kill",
220            serde_json::to_value(SessionParams {
221                thread_id: thread_id.to_string(),
222                expected_generation: None,
223                expected_execution: None,
224                expected_guardian_epoch: None,
225            })
226            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
227        )
228        .await
229    }
230
231    pub async fn prune_sessions(&self, idle_seconds: Option<u64>) -> Result<PruneResult> {
232        let mut stream = self.connected().await?;
233        DaemonShellRuntime::request(
234            &mut stream,
235            rand::random::<u64>(),
236            "session.prune",
237            serde_json::to_value(PruneParams { idle_seconds })
238                .map_err(|error| WinxError::SerializationError(error.to_string()))?,
239        )
240        .await
241    }
242
243    pub async fn interrupt_session(&self, thread_id: &str) -> Result<()> {
244        let _ = self.interrupt_session_generation(thread_id, None).await?;
245        Ok(())
246    }
247
248    pub async fn interrupt_session_generation(
249        &self,
250        thread_id: &str,
251        expected_generation: Option<u64>,
252    ) -> Result<bool> {
253        self.interrupt_session_execution(
254            thread_id,
255            expected_generation.map(|generation| ShellExecutionToken {
256                guardian_epoch: String::new(),
257                session_epoch: String::new(),
258                generation,
259            }),
260        )
261        .await
262    }
263
264    async fn interrupt_session_execution(
265        &self,
266        thread_id: &str,
267        expected_execution: Option<ShellExecutionToken>,
268    ) -> Result<bool> {
269        // Cancellation deliberately uses its own connection. A long-running
270        // action or status request must never delay it.
271        let (mut stream, _) = self.connected_with_hello().await?;
272        let hello: HelloResult = DaemonShellRuntime::request(
273            &mut stream,
274            rand::random::<u64>(),
275            "session.negotiate",
276            serde_json::to_value(SessionParams {
277                thread_id: thread_id.to_string(),
278                expected_generation: None,
279                expected_execution: None,
280                expected_guardian_epoch: None,
281            })
282            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
283        )
284        .await?;
285        if expected_execution.is_some()
286            && !hello
287                .capabilities
288                .iter()
289                .any(|capability| capability == GENERATION_BOUND_ACTIONS_CAPABILITY)
290        {
291            return Ok(false);
292        }
293        if let Some(expected) = expected_execution.as_ref() {
294            if !expected.guardian_epoch.is_empty() && expected.guardian_epoch != hello.daemon_epoch
295            {
296                return Ok(false);
297            }
298        }
299        let expected_generation = expected_execution.as_ref().map(|token| token.generation);
300        DaemonShellRuntime::request(
301            &mut stream,
302            rand::random::<u64>(),
303            "session.interrupt",
304            serde_json::to_value(SessionParams {
305                thread_id: thread_id.to_string(),
306                expected_generation,
307                expected_execution: expected_execution.filter(|token| {
308                    !token.guardian_epoch.is_empty() && !token.session_epoch.is_empty()
309                }),
310                expected_guardian_epoch: Some(hello.daemon_epoch),
311            })
312            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
313        )
314        .await
315    }
316
317    async fn cancel_pending_action(&self, thread_id: &str, cancellation_key: &str) -> Result<bool> {
318        // Like interruption, prelaunch cancellation must not share the action
319        // channel that may itself be queued behind another foreground command.
320        let (mut stream, _) = self.connected_with_hello().await?;
321        let hello: HelloResult = DaemonShellRuntime::request(
322            &mut stream,
323            rand::random::<u64>(),
324            "session.negotiate",
325            serde_json::to_value(SessionParams {
326                thread_id: thread_id.to_string(),
327                expected_generation: None,
328                expected_execution: None,
329                expected_guardian_epoch: None,
330            })
331            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
332        )
333        .await?;
334        if !hello
335            .capabilities
336            .iter()
337            .any(|capability| capability == CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY)
338        {
339            return Ok(false);
340        }
341        DaemonShellRuntime::request(
342            &mut stream,
343            rand::random::<u64>(),
344            "shell.cancel_action",
345            serde_json::to_value(CancelActionParams {
346                thread_id: thread_id.to_string(),
347                cancellation_key: cancellation_key.to_string(),
348                expected_guardian_epoch: Some(hello.daemon_epoch),
349            })
350            .map_err(|error| WinxError::SerializationError(error.to_string()))?,
351        )
352        .await
353    }
354}
355
356impl DaemonShellRuntime {
357    pub fn new(socket_path: impl AsRef<Path>) -> Self {
358        Self::with_consumer_id(socket_path, format!("adapter-{:016x}", rand::random::<u64>()))
359    }
360
361    pub fn with_consumer_id(socket_path: impl AsRef<Path>, consumer_id: impl Into<String>) -> Self {
362        Self {
363            socket_path: socket_path.as_ref().to_path_buf(),
364            consumer_id: consumer_id.into(),
365            negotiations: Arc::new(Mutex::new(NegotiationCache::default())),
366        }
367    }
368
369    pub fn socket_path(&self) -> &Path {
370        &self.socket_path
371    }
372
373    async fn negotiated_session(&self, thread_id: &str) -> Result<Arc<Mutex<NegotiatedSession>>> {
374        {
375            let now = Instant::now();
376            let mut cache = self.negotiations.lock().await;
377            prune_negotiations(&mut cache, now);
378            if let Some(entry) = cache.sessions.get_mut(thread_id) {
379                entry.last_seen = now;
380                return Ok(Arc::clone(&entry.session));
381            }
382        }
383
384        let negotiation_gate = self.negotiation_gate(thread_id).await?;
385        let _single_flight = negotiation_gate.lock().await;
386        {
387            let now = Instant::now();
388            let mut cache = self.negotiations.lock().await;
389            prune_negotiations(&mut cache, now);
390            if let Some(entry) = cache.sessions.get_mut(thread_id) {
391                entry.last_seen = now;
392                return Ok(Arc::clone(&entry.session));
393            }
394        }
395
396        // Do not hold the global cache mutex across socket I/O. The published
397        // connection is an idle liveness probe; tool RPCs never share it.
398        let client = DaemonClient::new(&self.socket_path);
399        let (mut stream, control) = client.connected_with_hello().await?;
400        let hello: HelloResult = tokio::time::timeout(
401            SESSION_CONTROL_RPC_TIMEOUT,
402            Self::request(
403                &mut stream,
404                rand::random::<u64>(),
405                "session.negotiate",
406                serde_json::to_value(SessionParams {
407                    thread_id: thread_id.to_string(),
408                    expected_generation: None,
409                    expected_execution: None,
410                    expected_guardian_epoch: None,
411                })
412                .map_err(|error| WinxError::SerializationError(error.to_string()))?,
413            ),
414        )
415        .await
416        .map_err(|_| WinxError::CommandTimeout {
417            command: "negotiate a guardian session".to_string(),
418            timeout_seconds: SESSION_CONTROL_RPC_TIMEOUT.as_secs(),
419        })??;
420        if hello.protocol_major != PROTOCOL_MAJOR {
421            return Err(WinxError::ConfigurationError(format!(
422                "guardian protocol major {} is incompatible with adapter {}",
423                hello.protocol_major, PROTOCOL_MAJOR
424            )));
425        }
426        let session = Arc::new(Mutex::new(NegotiatedSession {
427            control_epoch: control.daemon_epoch,
428            guardian: hello,
429            health_stream: stream,
430        }));
431        let now = Instant::now();
432        let mut cache = self.negotiations.lock().await;
433        prune_negotiations(&mut cache, now);
434        if let Some(existing) = cache.sessions.get_mut(thread_id) {
435            existing.last_seen = now;
436            return Ok(Arc::clone(&existing.session));
437        }
438        cache_negotiation(&mut cache, thread_id, Arc::clone(&session), now)?;
439        Ok(session)
440    }
441
442    async fn local_revision(&self, thread_id: &str) -> Result<Arc<AtomicU64>> {
443        let now = Instant::now();
444        let mut cache = self.negotiations.lock().await;
445        prune_negotiations(&mut cache, now);
446        if let Some(entry) = cache.local_revisions.get_mut(thread_id) {
447            entry.last_seen = now;
448            return Ok(Arc::clone(&entry.revision));
449        }
450        if cache.local_revisions.len() >= MAX_NEGOTIATED_SESSIONS {
451            let oldest = cache
452                .local_revisions
453                .iter()
454                .filter(|(_, entry)| Arc::strong_count(&entry.revision) == 1)
455                .min_by_key(|(_, entry)| entry.last_seen)
456                .map(|(thread_id, _)| thread_id.clone());
457            if let Some(oldest) = oldest {
458                cache.local_revisions.remove(&oldest);
459            }
460        }
461        if cache.local_revisions.len() >= MAX_NEGOTIATED_SESSIONS {
462            return Err(WinxError::ResourceAllocationError {
463                message: format!(
464                    "all {MAX_NEGOTIATED_SESSIONS} daemon local session revisions are active"
465                ),
466            });
467        }
468        let revision = Arc::new(AtomicU64::new(0));
469        cache.local_revisions.insert(
470            thread_id.to_string(),
471            LocalRevision { revision: Arc::clone(&revision), last_seen: now },
472        );
473        Ok(revision)
474    }
475
476    async fn local_revision_is_current(
477        &self,
478        thread_id: &str,
479        expected: &Arc<AtomicU64>,
480        value: u64,
481    ) -> bool {
482        let mut cache = self.negotiations.lock().await;
483        let Some(current) = cache.local_revisions.get_mut(thread_id) else { return false };
484        current.last_seen = Instant::now();
485        Arc::ptr_eq(&current.revision, expected) && current.revision.load(Ordering::SeqCst) == value
486    }
487
488    async fn apply_snapshot_if_current(
489        &self,
490        bash_state: &Arc<Mutex<Option<BashState>>>,
491        thread_id: &str,
492        expected_revision: &Arc<AtomicU64>,
493        revision_value: u64,
494        snapshot: &crate::state::persistence::BashStateSnapshot,
495    ) -> bool {
496        let mut state = bash_state.lock().await;
497        let Some(state) = state.as_mut() else { return false };
498        if !self.local_revision_is_current(thread_id, expected_revision, revision_value).await {
499            return false;
500        }
501        state.apply_snapshot(snapshot);
502        true
503    }
504
505    async fn negotiation_gate(&self, thread_id: &str) -> Result<Arc<Mutex<()>>> {
506        let now = Instant::now();
507        let mut cache = self.negotiations.lock().await;
508        prune_negotiations(&mut cache, now);
509        if let Some(entry) = cache.gates.get_mut(thread_id) {
510            entry.last_seen = now;
511            return Ok(Arc::clone(&entry.gate));
512        }
513        if cache.gates.len() >= MAX_NEGOTIATED_SESSIONS {
514            let oldest = cache
515                .gates
516                .iter()
517                .filter(|(_, entry)| Arc::strong_count(&entry.gate) == 1)
518                .min_by_key(|(_, entry)| entry.last_seen)
519                .map(|(key, _)| key.clone());
520            if let Some(oldest) = oldest {
521                cache.gates.remove(&oldest);
522            }
523        }
524        if cache.gates.len() >= MAX_NEGOTIATED_SESSIONS {
525            return Err(WinxError::ResourceAllocationError {
526                message: format!(
527                    "all {MAX_NEGOTIATED_SESSIONS} daemon negotiation slots are active"
528                ),
529            });
530        }
531        let gate = Arc::new(Mutex::new(()));
532        cache.gates.insert(
533            thread_id.to_string(),
534            NegotiationGate { gate: Arc::clone(&gate), last_seen: now },
535        );
536        Ok(gate)
537    }
538
539    async fn live_negotiated_session(
540        &self,
541        thread_id: &str,
542    ) -> Result<Arc<Mutex<NegotiatedSession>>> {
543        loop {
544            let session = self.negotiated_session(thread_id).await?;
545            let (alive, control_epoch) = {
546                let session_guard = session.lock().await;
547                let mut probe = [0_u8; 1];
548                match session_guard.health_stream.try_read(&mut probe) {
549                    Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
550                        (true, session_guard.control_epoch.clone())
551                    }
552                    Ok(_) | Err(_) => (false, session_guard.control_epoch.clone()),
553                }
554            };
555            if alive {
556                return Ok(session);
557            }
558            tracing::debug!(%control_epoch, %thread_id, "invalidating closed winxd negotiation");
559            self.invalidate_negotiation(thread_id, &session).await;
560        }
561    }
562
563    async fn negotiated_guardian(&self, thread_id: &str) -> Result<HelloResult> {
564        let session = self.live_negotiated_session(thread_id).await?;
565        let hello = session.lock().await.guardian.clone();
566        Ok(hello)
567    }
568
569    async fn invalidate_negotiation(
570        &self,
571        thread_id: &str,
572        expected: &Arc<Mutex<NegotiatedSession>>,
573    ) {
574        let mut cache = self.negotiations.lock().await;
575        if cache
576            .sessions
577            .get(thread_id)
578            .is_some_and(|current| Arc::ptr_eq(&current.session, expected))
579        {
580            cache.sessions.remove(thread_id);
581        }
582    }
583
584    async fn negotiated_request<T: serde::de::DeserializeOwned>(
585        &self,
586        id: u64,
587        method: &str,
588        params: serde_json::Value,
589    ) -> Result<T> {
590        match tokio::time::timeout(
591            SESSION_CONTROL_RPC_TIMEOUT,
592            self.isolated_request(id, method, params),
593        )
594        .await
595        {
596            Ok(result) => result,
597            Err(_) => Err(WinxError::CommandTimeout {
598                command: format!("winxd {method} RPC"),
599                timeout_seconds: SESSION_CONTROL_RPC_TIMEOUT.as_secs(),
600            }),
601        }
602    }
603
604    async fn negotiated_action_request<T: serde::de::DeserializeOwned>(
605        &self,
606        id: u64,
607        params: serde_json::Value,
608        options: &ShellActionOptions,
609    ) -> Result<T> {
610        if options.is_launch_cancelled() {
611            return Err(WinxError::CommandExecutionError(
612                "task was cancelled before the shell action launch gate".to_string(),
613            ));
614        }
615        self.isolated_request(id, "shell.run_action", params).await
616    }
617
618    /// Run one framed RPC on a disposable connection. Tokio's framed
619    /// `read_exact`/`write_all` operations are not cancellation safe; never
620    /// expose a partially consumed transport to the next tool call.
621    async fn isolated_request<T: serde::de::DeserializeOwned>(
622        &self,
623        id: u64,
624        method: &str,
625        params: serde_json::Value,
626    ) -> Result<T> {
627        let mut stream = DaemonClient::new(&self.socket_path).connect_raw().await?;
628        Self::request(&mut stream, id, method, params).await
629    }
630
631    async fn request<T: serde::de::DeserializeOwned>(
632        stream: &mut DaemonStream,
633        id: u64,
634        method: &str,
635        params: serde_json::Value,
636    ) -> Result<T> {
637        write_json_frame(
638            stream,
639            &RpcRequest { jsonrpc: "2.0".to_string(), id, method: method.to_string(), params },
640        )
641        .await?;
642        let response: RpcResponse = read_json_frame(stream).await?;
643        if response.id != id || response.jsonrpc != "2.0" {
644            return Err(WinxError::ParseError("mismatched JSON-RPC response".to_string()));
645        }
646        if let Some(error) = response.error {
647            return Err(WinxError::CommandExecutionError(format!(
648                "winxd JSON-RPC error {}: {}",
649                error.code, error.message
650            )));
651        }
652        serde_json::from_value(
653            response.result.ok_or_else(|| {
654                WinxError::ParseError("JSON-RPC response omitted result".to_string())
655            })?,
656        )
657        .map_err(|error| WinxError::DeserializationError(error.to_string()))
658    }
659
660    async fn run_remote_detailed(
661        &self,
662        bash_state: &std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
663        command: BashCommand,
664        requested_options: ShellActionOptions,
665    ) -> Result<BashCommandRuntimeResult> {
666        let thread_id = command.thread_id.clone();
667        // Capture state and its adapter-local configuration revision under the
668        // BashState lock. configure_remote uses the same lock order, so a
669        // concurrent Initialize/Reset/Mode/Workspace transition cannot land
670        // between these two values.
671        let (snapshot, local_revision, captured_revision) = {
672            let state = bash_state.lock().await;
673            let state = state.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
674            let revision = self.local_revision(&thread_id).await?;
675            let value = revision.load(Ordering::SeqCst);
676            (state.snapshot(), revision, value)
677        };
678        let mut session = self.live_negotiated_session(&thread_id).await?;
679        let hello = session.lock().await.guardian.clone();
680        let (options, mut generation_bound_actions) =
681            action_options_for_guardian(&hello, requested_options.clone())?;
682        let request_key = format!("{:016x}", rand::random::<u64>());
683        let id = rand::random::<u64>();
684        let encode_params = |options| {
685            serde_json::to_value(RunActionParams {
686                snapshot: snapshot.clone(),
687                command: command.clone(),
688                request_key: request_key.clone(),
689                consumer_id: self.consumer_id.clone(),
690                options,
691            })
692            .map_err(|error| WinxError::SerializationError(error.to_string()))
693        };
694        let params = encode_params(options.clone())?;
695        let result: RunActionResult = match self
696            .negotiated_action_request(id, params, &options)
697            .await
698        {
699            Ok(result) => result,
700            Err(first_error) => {
701                // A failed disposable request may mean the control or guardian
702                // epoch changed. Re-negotiate before retrying, then enforce the
703                // effective guardian's capabilities. The stable request key
704                // makes a lost response safe without executing twice.
705                self.invalidate_negotiation(&thread_id, &session).await;
706                session = self.live_negotiated_session(&thread_id).await?;
707                let retry_hello = session.lock().await.guardian.clone();
708                if retry_hello.daemon_epoch != hello.daemon_epoch {
709                    return Err(WinxError::CommandExecutionError(format!(
710                        "{first_error}; guardian epoch changed from {} to {}, so Winx will not retry an ambiguously delivered shell action",
711                        hello.daemon_epoch, retry_hello.daemon_epoch
712                    )));
713                }
714                let (retry_options, retry_generation_bound) =
715                    action_options_for_guardian(&retry_hello, requested_options.clone())?;
716                generation_bound_actions = retry_generation_bound;
717                self.negotiated_action_request(
718                    id,
719                    encode_params(retry_options)?,
720                    &requested_options,
721                )
722                .await
723                .map_err(|retry_error| {
724                    WinxError::CommandExecutionError(format!(
725                        "{first_error}; retry after guardian negotiation failed: {retry_error}"
726                    ))
727                })?
728            }
729        };
730
731        self.apply_snapshot_if_current(
732            bash_state,
733            &thread_id,
734            &local_revision,
735            captured_revision,
736            &result.snapshot,
737        )
738        .await;
739        runtime_result_from_wire(result, generation_bound_actions)
740    }
741
742    async fn configure_remote(
743        &self,
744        bash_state: &mut BashState,
745        transition: RemoteSessionTransition,
746    ) -> Result<ShellSessionConfiguration> {
747        let thread_id = bash_state.current_thread_id.clone();
748        // The caller owns BashState for this entire future. Bump before any
749        // remote I/O so every older action response is stale even when this
750        // transition ultimately returns an error.
751        self.local_revision(&thread_id).await?.fetch_add(1, Ordering::SeqCst);
752        let snapshot = bash_state.snapshot();
753        let encode_params = |wire_transition| {
754            serde_json::to_value(ConfigureSessionParams {
755                snapshot: snapshot.clone(),
756                transition: wire_transition,
757            })
758            .map_err(|error| WinxError::SerializationError(error.to_string()))
759        };
760        let mut session = self.live_negotiated_session(&thread_id).await?;
761        let hello = session.lock().await.guardian.clone();
762        let wire_transition = configure_transition_for_guardian(transition, &hello);
763        let result: ConfigureSessionResult = match self
764            .negotiated_request(
765                rand::random::<u64>(),
766                "session.configure",
767                encode_params(wire_transition)?,
768            )
769            .await
770        {
771            Ok(result) => result,
772            Err(first_error)
773                if matches!(
774                    transition,
775                    RemoteSessionTransition::FirstCall | RemoteSessionTransition::Attach
776                ) =>
777            {
778                // Attach-or-create is idempotent. If delivery or the reply was
779                // lost, renegotiate and retry without resetting the PTY.
780                self.invalidate_negotiation(&thread_id, &session).await;
781                session = self.live_negotiated_session(&thread_id).await?;
782                let retry_hello = session.lock().await.guardian.clone();
783                let retry_transition = configure_transition_for_guardian(transition, &retry_hello);
784                self.negotiated_request(
785                    rand::random::<u64>(),
786                    "session.configure",
787                    encode_params(retry_transition)?,
788                )
789                .await
790                .map_err(|retry_error| {
791                    WinxError::CommandExecutionError(format!(
792                        "{first_error}; retry after guardian negotiation failed: {retry_error}"
793                    ))
794                })?
795            }
796            Err(error) => {
797                self.invalidate_negotiation(&thread_id, &session).await;
798                return Err(error);
799            }
800        };
801        if let Some(snapshot) = result.snapshot {
802            bash_state.apply_snapshot(&snapshot);
803        }
804        Ok(ShellSessionConfiguration {
805            attach_hint: result.attach_hint,
806            attached_existing: result.attached_existing,
807        })
808    }
809}
810
811fn configure_transition_for_guardian(
812    transition: RemoteSessionTransition,
813    guardian: &HelloResult,
814) -> ConfigureSessionTransition {
815    match transition {
816        RemoteSessionTransition::FirstCall => ConfigureSessionTransition::FirstCall,
817        RemoteSessionTransition::Attach
818            if guardian
819                .capabilities
820                .iter()
821                .any(|capability| capability == ATTACH_OR_CREATE_CAPABILITY) =>
822        {
823            ConfigureSessionTransition::FirstCall
824        }
825        // Guardians predating attach-or-create preserve the live PTY when the
826        // adapter reapplies the current mode snapshot.
827        RemoteSessionTransition::Attach | RemoteSessionTransition::ModeChange => {
828            ConfigureSessionTransition::ModeChange
829        }
830        RemoteSessionTransition::Reset => ConfigureSessionTransition::Reset,
831        RemoteSessionTransition::WorkspaceChange => ConfigureSessionTransition::WorkspaceChange,
832    }
833}
834
835fn prune_negotiations(cache: &mut NegotiationCache, now: Instant) {
836    cache.sessions.retain(|_, entry| {
837        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
838            || Arc::strong_count(&entry.session) > 1
839    });
840    cache.gates.retain(|_, entry| {
841        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL || Arc::strong_count(&entry.gate) > 1
842    });
843    cache.local_revisions.retain(|_, entry| {
844        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
845            || Arc::strong_count(&entry.revision) > 1
846    });
847}
848
849fn cache_negotiation(
850    cache: &mut NegotiationCache,
851    thread_id: &str,
852    session: Arc<Mutex<NegotiatedSession>>,
853    now: Instant,
854) -> Result<()> {
855    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
856        if let Some(oldest) = cache
857            .sessions
858            .iter()
859            .filter(|(_, entry)| Arc::strong_count(&entry.session) == 1)
860            .min_by_key(|(_, entry)| entry.last_seen)
861            .map(|(thread_id, _)| thread_id.clone())
862        {
863            cache.sessions.remove(&oldest);
864        }
865    }
866    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
867        return Err(WinxError::ResourceAllocationError {
868            message: format!("all {MAX_NEGOTIATED_SESSIONS} daemon sessions are active"),
869        });
870    }
871    cache.sessions.insert(thread_id.to_string(), CachedNegotiation { session, last_seen: now });
872    Ok(())
873}
874
875impl ShellRuntime for DaemonShellRuntime {
876    fn configure_session<'a>(
877        &'a self,
878        bash_state: &'a mut BashState,
879        transition: ShellSessionTransition,
880    ) -> ShellRuntimeConfigureFuture<'a> {
881        Box::pin(self.configure_remote(bash_state, transition.into()))
882    }
883
884    fn attach_session<'a>(
885        &'a self,
886        bash_state: &'a mut BashState,
887    ) -> ShellRuntimeConfigureFuture<'a> {
888        Box::pin(self.configure_remote(bash_state, RemoteSessionTransition::Attach))
889    }
890
891    fn run_action<'a>(
892        &'a self,
893        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
894        command: BashCommand,
895    ) -> ShellRuntimeFuture<'a> {
896        Box::pin(async move {
897            Ok(self
898                .run_remote_detailed(bash_state, command, ShellActionOptions::default())
899                .await?
900                .result)
901        })
902    }
903
904    fn run_action_detailed<'a>(
905        &'a self,
906        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
907        command: BashCommand,
908        options: ShellActionOptions,
909    ) -> ShellRuntimeDetailedFuture<'a> {
910        Box::pin(self.run_remote_detailed(bash_state, command, options))
911    }
912
913    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
914        // The stable control daemon cannot answer this without a session. Keep
915        // the legacy context-free probe conservative.
916        Box::pin(async { Ok(false) })
917    }
918
919    fn supports_generation_bound_actions_for<'a>(
920        &'a self,
921        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
922    ) -> ShellRuntimeBoolFuture<'a> {
923        Box::pin(async move {
924            let thread_id = bash_state
925                .lock()
926                .await
927                .as_ref()
928                .ok_or(WinxError::BashStateNotInitialized)?
929                .current_thread_id
930                .clone();
931            let hello = self.negotiated_guardian(&thread_id).await?;
932            Ok([GENERATION_BOUND_ACTIONS_CAPABILITY, CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY]
933                .iter()
934                .all(|required| hello.capabilities.iter().any(|capability| capability == required)))
935        })
936    }
937
938    fn interrupt<'a>(
939        &'a self,
940        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
941    ) -> ShellRuntimeUnitFuture<'a> {
942        Box::pin(async move {
943            let thread_id = bash_state
944                .lock()
945                .await
946                .as_ref()
947                .ok_or(WinxError::BashStateNotInitialized)?
948                .current_thread_id
949                .clone();
950            DaemonClient::new(&self.socket_path).interrupt_session(&thread_id).await
951        })
952    }
953
954    fn interrupt_generation<'a>(
955        &'a self,
956        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
957        expected_generation: Option<u64>,
958    ) -> ShellRuntimeBoolFuture<'a> {
959        Box::pin(async move {
960            let thread_id = bash_state
961                .lock()
962                .await
963                .as_ref()
964                .ok_or(WinxError::BashStateNotInitialized)?
965                .current_thread_id
966                .clone();
967            DaemonClient::new(&self.socket_path)
968                .interrupt_session_generation(&thread_id, expected_generation)
969                .await
970        })
971    }
972
973    fn interrupt_execution<'a>(
974        &'a self,
975        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
976        expected: Option<ShellExecutionToken>,
977    ) -> ShellRuntimeBoolFuture<'a> {
978        Box::pin(async move {
979            let thread_id = bash_state
980                .lock()
981                .await
982                .as_ref()
983                .ok_or(WinxError::BashStateNotInitialized)?
984                .current_thread_id
985                .clone();
986            DaemonClient::new(&self.socket_path)
987                .interrupt_session_execution(&thread_id, expected)
988                .await
989        })
990    }
991
992    fn cancel_pending_action<'a>(
993        &'a self,
994        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
995        cancellation_key: &'a str,
996    ) -> ShellRuntimeBoolFuture<'a> {
997        Box::pin(async move {
998            let thread_id = bash_state
999                .lock()
1000                .await
1001                .as_ref()
1002                .ok_or(WinxError::BashStateNotInitialized)?
1003                .current_thread_id
1004                .clone();
1005            DaemonClient::new(&self.socket_path)
1006                .cancel_pending_action(&thread_id, cancellation_key)
1007                .await
1008        })
1009    }
1010
1011    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
1012        Box::pin(async move {
1013            let _ = DaemonClient::new(&self.socket_path).kill_session(thread_id).await?;
1014            self.negotiations.lock().await.sessions.remove(thread_id);
1015            Ok(())
1016        })
1017    }
1018}
1019
1020fn action_options_for_guardian(
1021    hello: &HelloResult,
1022    mut options: ShellActionOptions,
1023) -> Result<(ShellActionOptions, bool)> {
1024    let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
1025    if !has(TYPED_ACTION_RESULT_CAPABILITY) {
1026        return Err(WinxError::ConfigurationError(format!(
1027            "the running guardian does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before executing BashCommand"
1028        )));
1029    }
1030    let generation_bound_actions = has(GENERATION_BOUND_ACTIONS_CAPABILITY);
1031    if (options.expected_generation.is_some() || options.require_generation_binding)
1032        && !generation_bound_actions
1033    {
1034        return Err(WinxError::InvalidInput(
1035            "the running guardian does not support generation-bound shell actions".to_string(),
1036        ));
1037    }
1038    if let Some(expected) = options.expected_execution.as_ref() {
1039        if expected.guardian_epoch != hello.daemon_epoch {
1040            return Err(WinxError::InvalidInput(
1041                "execution token belongs to a different guardian epoch".to_string(),
1042            ));
1043        }
1044        options.expected_generation = Some(expected.generation);
1045    }
1046    if options.expected_generation.is_some() || options.require_generation_binding {
1047        options.expected_guardian_epoch = Some(hello.daemon_epoch.clone());
1048    }
1049    if options.cancellation_key.is_some() && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
1050        return Err(WinxError::InvalidInput(
1051            "the running guardian does not support cancellable action reservations".to_string(),
1052        ));
1053    }
1054    if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
1055        options.compact_output = false;
1056    }
1057    Ok((options, generation_bound_actions))
1058}
1059
1060fn runtime_result_from_wire(
1061    result: RunActionResult,
1062    generation_bound_actions: bool,
1063) -> Result<BashCommandRuntimeResult> {
1064    match (
1065        result.output,
1066        result.compact_output,
1067        result.state,
1068        result.error,
1069        result.dropped_output_file,
1070    ) {
1071        (output, compact_output, Some(state), None, dropped_output_file)
1072            if output.is_some() || compact_output.is_some() => {
1073            let (output, compact_output) = match (output, compact_output) {
1074                (Some(output), compact) => (output, compact),
1075                (None, Some(compact)) => (String::new(), Some(compact)),
1076                (None, None) => unreachable!(),
1077            };
1078            Ok(BashCommandRuntimeResult {
1079                result: BashCommandResult { output, state },
1080                compact_output,
1081                command_generation: result.command_generation,
1082                execution_token: result.execution_token,
1083                generation_bound_actions,
1084                dropped_output_file,
1085                output_truncated: result.output_truncated,
1086            })
1087        }
1088        (_, _, _, Some(error), _) => Err(from_wire_error(error)),
1089        (Some(_), _, None, None, _) => Err(WinxError::ConfigurationError(
1090            "the session guardian predates typed BashCommand results; terminate this durable session, then initialize it again so the current guardian binary is created. Winx will not reconstruct orchestration state from terminal text."
1091                .to_string(),
1092        )),
1093        _ => Err(WinxError::ParseError(
1094            "winxd action response had neither a typed result nor an error".to_string(),
1095        )),
1096    }
1097}
1098
1099fn from_wire_error(error: WireShellError) -> WinxError {
1100    match error {
1101        WireShellError::BashStateNotInitialized => WinxError::BashStateNotInitialized,
1102        WireShellError::ShellInitialization(message) => {
1103            WinxError::ShellInitializationError(message)
1104        }
1105        WireShellError::CommandExecution(message) | WireShellError::Other(message) => {
1106            WinxError::CommandExecutionError(message)
1107        }
1108        WireShellError::NoActiveCommand(message) => WinxError::NoActiveCommand(message),
1109        WireShellError::BackgroundSessionNotFound(message) => {
1110            WinxError::BackgroundSessionNotFound(message)
1111        }
1112        WireShellError::EmptyInteractiveInput(action) => {
1113            WinxError::EmptyInteractiveInput { action }
1114        }
1115        WireShellError::InteractiveTargetNotRunning(message) => {
1116            WinxError::InteractiveTargetNotRunning(message)
1117        }
1118        WireShellError::CommandAlreadyRunning { current_command, duration_seconds } => {
1119            WinxError::CommandAlreadyRunning { current_command, duration_seconds }
1120        }
1121        WireShellError::CommandNotAllowed(message) => WinxError::CommandNotAllowed(message),
1122        WireShellError::ThreadIdMismatch(message) => WinxError::ThreadIdMismatch(message),
1123        WireShellError::InvalidInput(message) => WinxError::InvalidInput(message),
1124    }
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129    #![allow(clippy::expect_used)]
1130
1131    #[cfg(unix)]
1132    use std::sync::atomic::AtomicUsize;
1133    use std::sync::atomic::Ordering;
1134
1135    use super::*;
1136    #[cfg(unix)]
1137    use crate::daemon::protocol::{RpcResponse, TYPED_ACTION_RESULT_CAPABILITY};
1138    #[cfg(unix)]
1139    use tokio::net::UnixStream;
1140
1141    #[cfg(unix)] // mocks the control plane with a raw UnixListener/pair
1142    #[tokio::test]
1143    async fn cancelled_rpc_cannot_poison_the_next_tool_channel() {
1144        let temp = tempfile::tempdir().expect("temporary directory");
1145        let socket = temp.path().join("cancel-safe.sock");
1146        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1147        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel();
1148        let server = tokio::spawn(async move {
1149            let (mut first_stream, _) = listener.accept().await.expect("accept first RPC");
1150            let first_handler = tokio::spawn(async move {
1151                let first: RpcRequest =
1152                    read_json_frame(&mut first_stream).await.expect("read first RPC");
1153                assert_eq!(first.method, "session.configure");
1154                let _ = first_seen_tx.send(());
1155                let closed = read_json_frame::<_, RpcRequest>(&mut first_stream).await;
1156                assert!(
1157                    closed
1158                        .as_ref()
1159                        .is_err_and(|error| error.kind() == std::io::ErrorKind::UnexpectedEof),
1160                    "cancelled disposable connection must close cleanly: {closed:?}"
1161                );
1162            });
1163
1164            let (mut second_stream, _) = listener.accept().await.expect("accept second RPC");
1165            let second: RpcRequest =
1166                read_json_frame(&mut second_stream).await.expect("read second RPC");
1167            assert_eq!(second.method, "session.configure");
1168            write_json_frame(
1169                &mut second_stream,
1170                &RpcResponse {
1171                    jsonrpc: "2.0".to_string(),
1172                    id: second.id,
1173                    result: Some(serde_json::json!(42)),
1174                    error: None,
1175                },
1176            )
1177            .await
1178            .expect("write second response");
1179            first_handler.await.expect("first connection handler");
1180        });
1181
1182        let runtime = DaemonShellRuntime::new(&socket);
1183        let first_runtime = runtime.clone();
1184        let first = tokio::spawn(async move {
1185            first_runtime
1186                .isolated_request::<u64>(1, "session.configure", serde_json::json!({}))
1187                .await
1188        });
1189        first_seen_rx.await.expect("first request reached server");
1190        first.abort();
1191        assert!(first.await.expect_err("first request was cancelled").is_cancelled());
1192
1193        let second = tokio::time::timeout(
1194            Duration::from_secs(1),
1195            runtime.isolated_request::<u64>(2, "session.configure", serde_json::json!({})),
1196        )
1197        .await
1198        .expect("next RPC must not inherit cancelled framing")
1199        .expect("second RPC succeeds");
1200        assert_eq!(second, 42);
1201        server.await.expect("mock server task");
1202    }
1203
1204    #[cfg(unix)] // mocks the control plane with a raw UnixListener
1205    #[tokio::test]
1206    #[allow(clippy::too_many_lines)] // full control/guardian wire exchange is intentional
1207    async fn generation_capability_comes_from_effective_guardian_and_is_cached() {
1208        let temp = tempfile::tempdir().expect("temporary directory");
1209        let socket = temp.path().join("control.sock");
1210        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1211        let connections = Arc::new(AtomicUsize::new(0));
1212        let observed = Arc::clone(&connections);
1213        let server = tokio::spawn(async move {
1214            let (mut stream, _) = listener.accept().await.expect("accept adapter");
1215            observed.fetch_add(1, Ordering::SeqCst);
1216            let hello: RpcRequest = read_json_frame(&mut stream).await.expect("control hello");
1217            assert_eq!(hello.method, "winx.hello");
1218            write_json_frame(
1219                &mut stream,
1220                &RpcResponse {
1221                    jsonrpc: "2.0".to_string(),
1222                    id: hello.id,
1223                    result: Some(
1224                        serde_json::to_value(HelloResult {
1225                            protocol_major: PROTOCOL_MAJOR,
1226                            protocol_minor: 5,
1227                            capabilities: vec![GENERATION_BOUND_ACTIONS_CAPABILITY.to_string()],
1228                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1229                            daemon_epoch: "control-15".to_string(),
1230                            daemon_pid: 15,
1231                            process_role: None,
1232                            build: None,
1233                        })
1234                        .expect("control hello value"),
1235                    ),
1236                    error: None,
1237                },
1238            )
1239            .await
1240            .expect("write control hello");
1241
1242            let negotiation: RpcRequest =
1243                read_json_frame(&mut stream).await.expect("session negotiation");
1244            assert_eq!(negotiation.method, "session.negotiate");
1245            write_json_frame(
1246                &mut stream,
1247                &RpcResponse {
1248                    jsonrpc: "2.0".to_string(),
1249                    id: negotiation.id,
1250                    result: Some(
1251                        serde_json::to_value(HelloResult {
1252                            protocol_major: PROTOCOL_MAJOR,
1253                            protocol_minor: 4,
1254                            capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1255                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1256                            daemon_epoch: "guardian-14".to_string(),
1257                            daemon_pid: 14,
1258                            process_role: None,
1259                            build: None,
1260                        })
1261                        .expect("guardian hello value"),
1262                    ),
1263                    error: None,
1264                },
1265            )
1266            .await
1267            .expect("write guardian hello");
1268
1269            let (mut action_stream, _) = listener.accept().await.expect("accept isolated action");
1270            observed.fetch_add(1, Ordering::SeqCst);
1271            let action: RpcRequest =
1272                read_json_frame(&mut action_stream).await.expect("isolated action request");
1273            assert_eq!(action.method, "shell.run_action", "hot path must not send winx.hello");
1274            assert!(
1275                action.params.get("options").is_none(),
1276                "guardian 1.4 must receive default protocol-1.4 options"
1277            );
1278            let params: RunActionParams =
1279                serde_json::from_value(action.params).expect("run action params");
1280            let cwd = params.snapshot.cwd.clone();
1281            write_json_frame(
1282                &mut action_stream,
1283                &RpcResponse {
1284                    jsonrpc: "2.0".to_string(),
1285                    id: action.id,
1286                    result: Some(
1287                        serde_json::to_value(RunActionResult {
1288                            output: Some("guardian-14-output".to_string()),
1289                            compact_output: None,
1290                            command_generation: None,
1291                            execution_token: None,
1292                            dropped_output_file: None,
1293                            output_truncated: false,
1294                            state: Some(crate::tools::bash_command::BashCommandState {
1295                                process_status:
1296                                    crate::tools::bash_command::BashProcessStatus::Exited,
1297                                background_id: None,
1298                                running_for_seconds: None,
1299                                exit_code: Some(0),
1300                                cwd: cwd.into(),
1301                                turn_state: None,
1302                            }),
1303                            snapshot: params.snapshot,
1304                            error: None,
1305                        })
1306                        .expect("action result value"),
1307                    ),
1308                    error: None,
1309                },
1310            )
1311            .await
1312            .expect("write action result");
1313        });
1314
1315        let runtime = DaemonShellRuntime::new(&socket);
1316        let mut state = BashState::new();
1317        state.current_thread_id = "legacy-guardian".to_string();
1318        let state = Arc::new(Mutex::new(Some(state)));
1319        let (first_probe, concurrent_probe) = tokio::join!(
1320            runtime.supports_generation_bound_actions_for(&state),
1321            runtime.supports_generation_bound_actions_for(&state)
1322        );
1323        assert!(!first_probe.expect("first capability probe"));
1324        assert!(!concurrent_probe.expect("concurrent capability probe"));
1325        assert!(!tokio::time::timeout(
1326            std::time::Duration::from_millis(100),
1327            runtime.supports_generation_bound_actions_for(&state),
1328        )
1329        .await
1330        .expect("cached probe must not reconnect")
1331        .expect("cached capability"));
1332
1333        let outcome = runtime
1334            .run_remote_detailed(
1335                &state,
1336                BashCommand {
1337                    action_json: crate::types::BashCommandAction::Command {
1338                        command: "printf guardian-14-output".to_string(),
1339                        is_background: false,
1340                        allow_multi: false,
1341                    },
1342                    wait_for_seconds: Some(0.0),
1343                    thread_id: "legacy-guardian".to_string(),
1344                },
1345                ShellActionOptions { compact_output: true, ..ShellActionOptions::default() },
1346            )
1347            .await
1348            .expect("guardian 1.4 action");
1349        assert_eq!(outcome.result.output, "guardian-14-output");
1350        assert_eq!(outcome.compact_output, None);
1351        server.await.expect("mock server task");
1352        assert_eq!(connections.load(Ordering::SeqCst), 2);
1353    }
1354
1355    #[cfg(unix)] // mocks the control plane with a raw UnixListener
1356    #[tokio::test]
1357    #[allow(clippy::too_many_lines)] // two complete negotiated epochs are intentional
1358    async fn ambiguous_action_is_not_retried_after_guardian_epoch_changes() {
1359        let temp = tempfile::tempdir().expect("temporary directory");
1360        let socket = temp.path().join("control-restart.sock");
1361        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1362        let action_count = Arc::new(AtomicUsize::new(0));
1363        let observed = Arc::clone(&action_count);
1364        let server = tokio::spawn(async move {
1365            for (control_epoch, guardian_epoch) in
1366                [("control-one", "guardian-one"), ("control-two", "guardian-two")]
1367            {
1368                let (mut stream, _) = listener.accept().await.expect("accept adapter");
1369                let control_request: RpcRequest =
1370                    read_json_frame(&mut stream).await.expect("control hello");
1371                assert_eq!(control_request.method, "winx.hello");
1372                write_json_frame(
1373                    &mut stream,
1374                    &RpcResponse {
1375                        jsonrpc: "2.0".to_string(),
1376                        id: control_request.id,
1377                        result: Some(
1378                            serde_json::to_value(HelloResult {
1379                                protocol_major: PROTOCOL_MAJOR,
1380                                protocol_minor: 5,
1381                                capabilities: vec![],
1382                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1383                                daemon_epoch: control_epoch.to_string(),
1384                                daemon_pid: 15,
1385                                process_role: None,
1386                                build: None,
1387                            })
1388                            .expect("control hello value"),
1389                        ),
1390                        error: None,
1391                    },
1392                )
1393                .await
1394                .expect("control hello response");
1395                let negotiation: RpcRequest =
1396                    read_json_frame(&mut stream).await.expect("session negotiation");
1397                write_json_frame(
1398                    &mut stream,
1399                    &RpcResponse {
1400                        jsonrpc: "2.0".to_string(),
1401                        id: negotiation.id,
1402                        result: Some(
1403                            serde_json::to_value(HelloResult {
1404                                protocol_major: PROTOCOL_MAJOR,
1405                                protocol_minor: 5,
1406                                capabilities: vec![
1407                                    TYPED_ACTION_RESULT_CAPABILITY.to_string(),
1408                                    GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
1409                                ],
1410                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1411                                daemon_epoch: guardian_epoch.to_string(),
1412                                daemon_pid: 16,
1413                                process_role: None,
1414                                build: None,
1415                            })
1416                            .expect("guardian hello value"),
1417                        ),
1418                        error: None,
1419                    },
1420                )
1421                .await
1422                .expect("guardian hello response");
1423
1424                if guardian_epoch == "guardian-one" {
1425                    let (mut action_stream, _) =
1426                        listener.accept().await.expect("accept isolated first action");
1427                    let action: RpcRequest =
1428                        read_json_frame(&mut action_stream).await.expect("first action");
1429                    assert_eq!(action.method, "shell.run_action");
1430                    observed.fetch_add(1, Ordering::SeqCst);
1431                    // Dropping without a response makes delivery ambiguous.
1432                } else {
1433                    let repeated = tokio::time::timeout(
1434                        std::time::Duration::from_millis(100),
1435                        listener.accept(),
1436                    )
1437                    .await;
1438                    assert!(repeated.is_err(), "action was resent to a different guardian epoch");
1439                }
1440            }
1441        });
1442
1443        let runtime = DaemonShellRuntime::new(&socket);
1444        let mut state = BashState::new();
1445        state.current_thread_id = "ambiguous-action".to_string();
1446        let state = Arc::new(Mutex::new(Some(state)));
1447        let result = runtime
1448            .run_remote_detailed(
1449                &state,
1450                BashCommand {
1451                    action_json: crate::types::BashCommandAction::Command {
1452                        command: "printf must-run-once".to_string(),
1453                        is_background: false,
1454                        allow_multi: false,
1455                    },
1456                    wait_for_seconds: Some(0.0),
1457                    thread_id: "ambiguous-action".to_string(),
1458                },
1459                ShellActionOptions::default(),
1460            )
1461            .await;
1462        assert!(
1463            matches!(result, Err(WinxError::CommandExecutionError(ref message)) if message.contains("will not retry")),
1464            "{result:?}"
1465        );
1466        server.await.expect("mock server task");
1467        assert_eq!(action_count.load(Ordering::SeqCst), 1);
1468    }
1469
1470    #[cfg(unix)] // mocks the control plane with a raw UnixListener/pair
1471    #[tokio::test]
1472    async fn adapter_negotiation_cache_is_bounded_under_session_churn() {
1473        let mut cache = NegotiationCache::default();
1474        for index in 0..(MAX_NEGOTIATED_SESSIONS + 50) {
1475            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1476            drop(peer);
1477            let session = Arc::new(Mutex::new(NegotiatedSession {
1478                control_epoch: "control".to_string(),
1479                guardian: HelloResult {
1480                    protocol_major: PROTOCOL_MAJOR,
1481                    protocol_minor: 5,
1482                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1483                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1484                    daemon_epoch: format!("guardian-{index}"),
1485                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1486                    process_role: None,
1487                    build: None,
1488                },
1489                health_stream: stream,
1490            }));
1491            cache_negotiation(&mut cache, &format!("thread-{index}"), session, Instant::now())
1492                .expect("inactive cache entry should be evictable");
1493        }
1494        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1495    }
1496
1497    #[cfg(unix)] // mocks the control plane with a raw UnixListener/pair
1498    #[tokio::test]
1499    async fn adapter_negotiation_cache_never_evicts_active_sessions() {
1500        let mut cache = NegotiationCache::default();
1501        let mut active = Vec::new();
1502        for index in 0..MAX_NEGOTIATED_SESSIONS {
1503            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1504            drop(peer);
1505            let session = Arc::new(Mutex::new(NegotiatedSession {
1506                control_epoch: "control".to_string(),
1507                guardian: HelloResult {
1508                    protocol_major: PROTOCOL_MAJOR,
1509                    protocol_minor: 5,
1510                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1511                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1512                    daemon_epoch: format!("active-{index}"),
1513                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1514                    process_role: None,
1515                    build: None,
1516                },
1517                health_stream: stream,
1518            }));
1519            cache_negotiation(
1520                &mut cache,
1521                &format!("active-{index}"),
1522                Arc::clone(&session),
1523                Instant::now(),
1524            )
1525            .expect("active slot");
1526            active.push(session);
1527        }
1528        let (stream, peer) = UnixStream::pair().expect("overflow pair");
1529        drop(peer);
1530        let overflow = Arc::new(Mutex::new(NegotiatedSession {
1531            control_epoch: "control".to_string(),
1532            guardian: HelloResult {
1533                protocol_major: PROTOCOL_MAJOR,
1534                protocol_minor: 5,
1535                capabilities: vec![],
1536                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1537                daemon_epoch: "overflow".to_string(),
1538                daemon_pid: 999,
1539                process_role: None,
1540                build: None,
1541            },
1542            health_stream: stream,
1543        }));
1544        let error =
1545            cache_negotiation(&mut cache, "overflow", Arc::clone(&overflow), Instant::now())
1546                .expect_err("all active sessions must apply backpressure");
1547        assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1548        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1549
1550        drop(active.pop());
1551        cache_negotiation(&mut cache, "overflow", overflow, Instant::now())
1552            .expect("an inactive session can be evicted");
1553        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1554    }
1555
1556    #[tokio::test]
1557    async fn stale_daemon_action_snapshot_cannot_overwrite_local_reconfiguration() {
1558        let runtime = DaemonShellRuntime::new("unused.sock");
1559        let mut initial = BashState::new();
1560        initial.current_thread_id = "local-revision".to_string();
1561        initial.cwd = PathBuf::from("/old-action-cwd");
1562        let stale_snapshot = initial.snapshot();
1563        let state = Arc::new(Mutex::new(Some(initial)));
1564
1565        let revision = runtime.local_revision("local-revision").await.expect("revision");
1566        let captured = revision.load(Ordering::SeqCst);
1567        let mut configure_guard = state.lock().await;
1568        let apply_runtime = runtime.clone();
1569        let apply_state = Arc::clone(&state);
1570        let apply_revision = Arc::clone(&revision);
1571        let stale_apply = tokio::spawn(async move {
1572            apply_runtime
1573                .apply_snapshot_if_current(
1574                    &apply_state,
1575                    "local-revision",
1576                    &apply_revision,
1577                    captured,
1578                    &stale_snapshot,
1579                )
1580                .await
1581        });
1582        tokio::task::yield_now().await;
1583
1584        // configure_remote performs this bump while its caller holds the same
1585        // BashState lock. The stale action writeback is already concurrent and
1586        // waiting for that lock, so it must revalidate after configuration.
1587        revision.fetch_add(1, Ordering::SeqCst);
1588        configure_guard.as_mut().expect("state").cwd = PathBuf::from("/new-config-cwd");
1589        drop(configure_guard);
1590
1591        assert!(!stale_apply.await.expect("stale writeback task"));
1592        assert_eq!(
1593            state.lock().await.as_ref().expect("state").cwd,
1594            PathBuf::from("/new-config-cwd")
1595        );
1596    }
1597}