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