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        if matches!(
768            &command.action_json,
769            crate::types::BashCommandAction::Inspect { .. }
770                | crate::types::BashCommandAction::Prompt { .. }
771        ) && !hello
772            .capabilities
773            .iter()
774            .any(|capability| capability == super::protocol::AGENT_WORKFLOWS_CAPABILITY)
775        {
776            return Err(WinxError::InvalidInput(
777                "the effective guardian does not support native agent workflows; no input was sent"
778                    .to_string(),
779            ));
780        }
781        let (options, mut generation_bound_actions) =
782            action_options_for_guardian(&hello, requested_options.clone())?;
783        let request_key = format!("{:016x}", rand::random::<u64>());
784        let id = rand::random::<u64>();
785        let encode_params = |options| {
786            serde_json::to_value(RunActionParams {
787                snapshot: snapshot.clone(),
788                command: command.clone(),
789                request_key: request_key.clone(),
790                consumer_id: self.consumer_id.clone(),
791                options,
792            })
793            .map_err(|error| WinxError::SerializationError(error.to_string()))
794        };
795        let params = encode_params(options.clone())?;
796        let result: RunActionResult = match self
797            .negotiated_action_request(id, params, &options)
798            .await
799        {
800            Ok(result) => result,
801            Err(first_error) => {
802                // A failed disposable request may mean the control or guardian
803                // epoch changed. Re-negotiate before retrying, then enforce the
804                // effective guardian's capabilities. The stable request key
805                // makes a lost response safe without executing twice.
806                self.invalidate_negotiation(&thread_id, &session).await;
807                session = self.live_negotiated_session(&thread_id).await?;
808                let retry_hello = session.lock().await.guardian.clone();
809                if retry_hello.daemon_epoch != hello.daemon_epoch {
810                    return Err(WinxError::CommandExecutionError(format!(
811                        "{first_error}; guardian epoch changed from {} to {}, so Winx will not retry an ambiguously delivered shell action",
812                        hello.daemon_epoch, retry_hello.daemon_epoch
813                    )));
814                }
815                if matches!(
816                    &command.action_json,
817                    crate::types::BashCommandAction::Inspect { .. }
818                        | crate::types::BashCommandAction::Prompt { .. }
819                ) && !retry_hello
820                    .capabilities
821                    .iter()
822                    .any(|capability| capability == super::protocol::AGENT_WORKFLOWS_CAPABILITY)
823                {
824                    return Err(WinxError::InvalidInput("the effective guardian does not support native agent workflows; no input was sent".to_string()));
825                }
826                let (retry_options, retry_generation_bound) =
827                    action_options_for_guardian(&retry_hello, requested_options.clone())?;
828                generation_bound_actions = retry_generation_bound;
829                self.negotiated_action_request(
830                    id,
831                    encode_params(retry_options)?,
832                    &requested_options,
833                )
834                .await
835                .map_err(|retry_error| {
836                    WinxError::CommandExecutionError(format!(
837                        "{first_error}; retry after guardian negotiation failed: {retry_error}"
838                    ))
839                })?
840            }
841        };
842
843        self.sync_action_cwd_if_current(
844            bash_state,
845            &thread_id,
846            &local_revision,
847            captured_revision,
848            &result.snapshot,
849        )
850        .await;
851        runtime_result_from_wire(result, generation_bound_actions)
852    }
853
854    async fn configure_remote(
855        &self,
856        bash_state: &mut BashState,
857        transition: RemoteSessionTransition,
858    ) -> Result<ShellSessionConfiguration> {
859        let thread_id = bash_state.current_thread_id.clone();
860        // The caller owns BashState for this entire future. Bump before any
861        // remote I/O so every older action response is stale even when this
862        // transition ultimately returns an error.
863        self.local_revision(&thread_id).await?.fetch_add(1, Ordering::SeqCst);
864        let snapshot = bash_state.snapshot();
865        let encode_params = |wire_transition| {
866            serde_json::to_value(ConfigureSessionParams {
867                snapshot: snapshot.clone(),
868                transition: wire_transition,
869            })
870            .map_err(|error| WinxError::SerializationError(error.to_string()))
871        };
872        let mut session = self.live_negotiated_session(&thread_id).await?;
873        let hello = session.lock().await.guardian.clone();
874        let wire_transition = configure_transition_for_guardian(transition, &hello);
875        let result: ConfigureSessionResult = match self
876            .negotiated_request(
877                rand::random::<u64>(),
878                "session.configure",
879                encode_params(wire_transition)?,
880            )
881            .await
882        {
883            Ok(result) => result,
884            Err(first_error)
885                if matches!(
886                    transition,
887                    RemoteSessionTransition::FirstCall | RemoteSessionTransition::Attach
888                ) =>
889            {
890                // Attach-or-create is idempotent. If delivery or the reply was
891                // lost, renegotiate and retry without resetting the PTY.
892                self.invalidate_negotiation(&thread_id, &session).await;
893                session = self.live_negotiated_session(&thread_id).await?;
894                let retry_hello = session.lock().await.guardian.clone();
895                let retry_transition = configure_transition_for_guardian(transition, &retry_hello);
896                self.negotiated_request(
897                    rand::random::<u64>(),
898                    "session.configure",
899                    encode_params(retry_transition)?,
900                )
901                .await
902                .map_err(|retry_error| {
903                    WinxError::CommandExecutionError(format!(
904                        "{first_error}; retry after guardian negotiation failed: {retry_error}"
905                    ))
906                })?
907            }
908            Err(error) => {
909                self.invalidate_negotiation(&thread_id, &session).await;
910                return Err(error);
911            }
912        };
913        if let Some(mut restored) = result.snapshot {
914            if result.attached_existing
915                && matches!(
916                    transition,
917                    RemoteSessionTransition::FirstCall | RemoteSessionTransition::Attach
918                )
919            {
920                preserve_completed_verifications(&mut restored, &snapshot);
921                // A fresh Initialize has no local receipts yet, but the previous
922                // adapter may have persisted completion after its last shell RPC.
923                match load_bash_state(&thread_id) {
924                    Ok(Some(durable)) => preserve_completed_verifications(&mut restored, &durable),
925                    Ok(None) => {}
926                    Err(error) => tracing::warn!(
927                        %thread_id, %error,
928                        "could not recover completed verification receipts during reattach"
929                    ),
930                }
931            }
932            bash_state.apply_snapshot(&restored);
933        }
934        Ok(ShellSessionConfiguration {
935            attach_hint: result.attach_hint,
936            attached_existing: result.attached_existing,
937        })
938    }
939}
940
941// The daemon sees the receipt before the adapter records verification completion.
942// Restore that terminal result only for the same retained commit and session.
943fn preserve_completed_verifications(snapshot: &mut BashStateSnapshot, source: &BashStateSnapshot) {
944    if snapshot.chat_id != source.chat_id || snapshot.workspace_root != source.workspace_root {
945        return;
946    }
947    for receipt in &mut snapshot.edit_mutation_receipts {
948        let completed = source.edit_mutation_receipts.iter().any(|candidate| {
949            candidate.persisted
950                && candidate.verification_state == Some(EditVerificationState::Passed)
951                && candidate.verification_id.is_some()
952                && candidate.schema_version == receipt.schema_version
953                && candidate.tool == receipt.tool
954                && candidate.fingerprint == receipt.fingerprint
955                && candidate.receipt_id == receipt.receipt_id
956                && candidate.committed_at_unix_ms == receipt.committed_at_unix_ms
957                && candidate.verification_id == receipt.verification_id
958                && candidate.legacy_verification_id == receipt.legacy_verification_id
959                && candidate.postconditions == receipt.postconditions
960                && candidate.uncommitted_paths == receipt.uncommitted_paths
961        });
962        if completed {
963            receipt.status = "completed".to_string();
964            receipt.verification_pending = false;
965            receipt.verification_state = Some(EditVerificationState::Passed);
966            receipt.verification_execution = None;
967        }
968    }
969}
970
971fn configure_transition_for_guardian(
972    transition: RemoteSessionTransition,
973    guardian: &HelloResult,
974) -> ConfigureSessionTransition {
975    match transition {
976        RemoteSessionTransition::FirstCall => ConfigureSessionTransition::FirstCall,
977        RemoteSessionTransition::Attach
978            if guardian
979                .capabilities
980                .iter()
981                .any(|capability| capability == ATTACH_OR_CREATE_CAPABILITY) =>
982        {
983            ConfigureSessionTransition::FirstCall
984        }
985        // Guardians predating attach-or-create preserve the live PTY when the
986        // adapter reapplies the current mode snapshot.
987        RemoteSessionTransition::Attach | RemoteSessionTransition::ModeChange => {
988            ConfigureSessionTransition::ModeChange
989        }
990        RemoteSessionTransition::Reset => ConfigureSessionTransition::Reset,
991        RemoteSessionTransition::WorkspaceChange => ConfigureSessionTransition::WorkspaceChange,
992    }
993}
994
995fn prune_negotiations(cache: &mut NegotiationCache, now: Instant) {
996    cache.sessions.retain(|_, entry| {
997        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
998            || Arc::strong_count(&entry.session) > 1
999    });
1000    cache.gates.retain(|_, entry| {
1001        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL || Arc::strong_count(&entry.gate) > 1
1002    });
1003    cache.local_revisions.retain(|_, entry| {
1004        now.duration_since(entry.last_seen) <= NEGOTIATION_TTL
1005            || Arc::strong_count(&entry.revision) > 1
1006    });
1007}
1008
1009fn cache_negotiation(
1010    cache: &mut NegotiationCache,
1011    thread_id: &str,
1012    session: Arc<Mutex<NegotiatedSession>>,
1013    now: Instant,
1014) -> Result<()> {
1015    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
1016        if let Some(oldest) = cache
1017            .sessions
1018            .iter()
1019            .filter(|(_, entry)| Arc::strong_count(&entry.session) == 1)
1020            .min_by_key(|(_, entry)| entry.last_seen)
1021            .map(|(thread_id, _)| thread_id.clone())
1022        {
1023            cache.sessions.remove(&oldest);
1024        }
1025    }
1026    if cache.sessions.len() >= MAX_NEGOTIATED_SESSIONS {
1027        return Err(WinxError::ResourceAllocationError {
1028            message: format!("all {MAX_NEGOTIATED_SESSIONS} daemon sessions are active"),
1029        });
1030    }
1031    cache.sessions.insert(thread_id.to_string(), CachedNegotiation { session, last_seen: now });
1032    Ok(())
1033}
1034
1035impl ShellRuntime for DaemonShellRuntime {
1036    fn release_temporary<'a>(
1037        &'a self,
1038        _state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1039        mut request: crate::utils::agent_temp::release::TemporaryReleaseRequest,
1040    ) -> crate::runtime::ShellRuntimeTemporaryReleaseFuture<'a> {
1041        Box::pin(async move {
1042            let hello = self.negotiated_guardian(&request.thread_id).await?;
1043            if !hello
1044                .capabilities
1045                .iter()
1046                .any(|item| item == super::protocol::TEMPORARY_RELEASE_CAPABILITY)
1047            {
1048                return Err(WinxError::InvalidInput("the effective guardian does not support safe temporary release; no files were removed".into()));
1049            }
1050            request.expected_guardian_epoch = Some(hello.daemon_epoch);
1051            // One disposable request bounds connect, write and read together.
1052            // Its failure cannot establish whether the owner already removed files.
1053            self.negotiated_request(
1054                rand::random::<u64>(),
1055                "session.release_temporary",
1056                serde_json::to_value(request)
1057                    .map_err(|error| WinxError::SerializationError(error.to_string()))?,
1058            )
1059            .await
1060            .map_err(|error| WinxError::CommandExecutionError(format!(
1061                "temporary release outcome unknown: {error}; the client will not retry automatically; inspect each requested target before retrying."
1062            )))
1063        })
1064    }
1065
1066    fn configure_session<'a>(
1067        &'a self,
1068        bash_state: &'a mut BashState,
1069        transition: ShellSessionTransition,
1070    ) -> ShellRuntimeConfigureFuture<'a> {
1071        Box::pin(self.configure_remote(bash_state, transition.into()))
1072    }
1073
1074    fn attach_session<'a>(
1075        &'a self,
1076        bash_state: &'a mut BashState,
1077    ) -> ShellRuntimeConfigureFuture<'a> {
1078        Box::pin(self.configure_remote(bash_state, RemoteSessionTransition::Attach))
1079    }
1080
1081    fn run_action<'a>(
1082        &'a self,
1083        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1084        command: BashCommand,
1085    ) -> ShellRuntimeFuture<'a> {
1086        Box::pin(async move {
1087            Ok(self
1088                .run_remote_detailed(bash_state, command, ShellActionOptions::default())
1089                .await?
1090                .result)
1091        })
1092    }
1093
1094    fn run_action_detailed<'a>(
1095        &'a self,
1096        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1097        command: BashCommand,
1098        options: ShellActionOptions,
1099    ) -> ShellRuntimeDetailedFuture<'a> {
1100        Box::pin(self.run_remote_detailed(bash_state, command, options))
1101    }
1102
1103    fn supports_generation_bound_actions(&self) -> ShellRuntimeBoolFuture<'_> {
1104        // The stable control daemon cannot answer this without a session. Keep
1105        // the legacy context-free probe conservative.
1106        Box::pin(async { Ok(false) })
1107    }
1108
1109    fn describe_session<'a>(
1110        &'a self,
1111        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1112    ) -> ShellRuntimeStatusFuture<'a> {
1113        Box::pin(async move {
1114            let thread_id = bash_state
1115                .lock()
1116                .await
1117                .as_ref()
1118                .ok_or(WinxError::BashStateNotInitialized)?
1119                .current_thread_id
1120                .clone();
1121            // A session that never ran a command has no guardian yet; the
1122            // control daemon answers "session not found" and the probe reports
1123            // an idle daemon-backed session instead of failing.
1124            let info = match DaemonClient::new(&self.socket_path).session_info(&thread_id).await {
1125                Ok(info) => info,
1126                Err(WinxError::CommandExecutionError(message))
1127                    if message.contains("session not found") =>
1128                {
1129                    return Ok(ShellSessionStatus {
1130                        runtime: "daemon",
1131                        ..ShellSessionStatus::default()
1132                    });
1133                }
1134                Err(error) => return Err(error),
1135            };
1136            // The control plane attaches the guardian hello to `session.list`
1137            // entries; a direct guardian answer omits it, so negotiate the
1138            // identity from the socket itself in that case.
1139            let guardian = match info.runtime.clone() {
1140                Some(hello) => Some(hello),
1141                None => self.negotiated_guardian(&thread_id).await.ok(),
1142            };
1143            let guardian = guardian.as_ref();
1144            Ok(ShellSessionStatus {
1145                runtime: "daemon",
1146                guardian_pid: guardian.map(|hello| hello.daemon_pid),
1147                guardian_epoch: guardian.map(|hello| hello.daemon_epoch.clone()),
1148                guardian_build: guardian
1149                    .and_then(|hello| hello.build.as_ref())
1150                    .map(|build| build.display.clone()),
1151                command_running: info.running,
1152                running_command_id: info.command_id,
1153                background_command_ids: info.background_command_ids,
1154                last_activity_unix_ms: info.last_activity_unix_ms,
1155            })
1156        })
1157    }
1158
1159    fn supports_generation_bound_actions_for<'a>(
1160        &'a self,
1161        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
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            let hello = self.negotiated_guardian(&thread_id).await?;
1172            Ok([GENERATION_BOUND_ACTIONS_CAPABILITY, CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY]
1173                .iter()
1174                .all(|required| hello.capabilities.iter().any(|capability| capability == required)))
1175        })
1176    }
1177
1178    fn supports_agent_workflows_for<'a>(
1179        &'a self,
1180        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
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            let hello = self.negotiated_guardian(&thread_id).await?;
1191            Ok(hello
1192                .capabilities
1193                .iter()
1194                .any(|capability| capability == super::protocol::AGENT_WORKFLOWS_CAPABILITY))
1195        })
1196    }
1197
1198    fn interrupt<'a>(
1199        &'a self,
1200        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1201    ) -> ShellRuntimeUnitFuture<'a> {
1202        Box::pin(async move {
1203            let thread_id = bash_state
1204                .lock()
1205                .await
1206                .as_ref()
1207                .ok_or(WinxError::BashStateNotInitialized)?
1208                .current_thread_id
1209                .clone();
1210            DaemonClient::new(&self.socket_path).interrupt_session(&thread_id).await
1211        })
1212    }
1213
1214    fn interrupt_generation<'a>(
1215        &'a self,
1216        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1217        expected_generation: Option<u64>,
1218    ) -> ShellRuntimeBoolFuture<'a> {
1219        Box::pin(async move {
1220            let thread_id = bash_state
1221                .lock()
1222                .await
1223                .as_ref()
1224                .ok_or(WinxError::BashStateNotInitialized)?
1225                .current_thread_id
1226                .clone();
1227            DaemonClient::new(&self.socket_path)
1228                .interrupt_session_generation(&thread_id, expected_generation)
1229                .await
1230        })
1231    }
1232
1233    fn interrupt_execution<'a>(
1234        &'a self,
1235        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1236        expected: Option<ShellExecutionToken>,
1237    ) -> ShellRuntimeBoolFuture<'a> {
1238        Box::pin(async move {
1239            let thread_id = bash_state
1240                .lock()
1241                .await
1242                .as_ref()
1243                .ok_or(WinxError::BashStateNotInitialized)?
1244                .current_thread_id
1245                .clone();
1246            DaemonClient::new(&self.socket_path)
1247                .interrupt_session_execution(&thread_id, expected)
1248                .await
1249        })
1250    }
1251
1252    fn cancel_pending_action<'a>(
1253        &'a self,
1254        bash_state: &'a std::sync::Arc<tokio::sync::Mutex<Option<BashState>>>,
1255        cancellation_key: &'a str,
1256    ) -> ShellRuntimeBoolFuture<'a> {
1257        Box::pin(async move {
1258            let thread_id = bash_state
1259                .lock()
1260                .await
1261                .as_ref()
1262                .ok_or(WinxError::BashStateNotInitialized)?
1263                .current_thread_id
1264                .clone();
1265            DaemonClient::new(&self.socket_path)
1266                .cancel_pending_action(&thread_id, cancellation_key)
1267                .await
1268        })
1269    }
1270
1271    fn terminate_session<'a>(&'a self, thread_id: &'a str) -> ShellRuntimeUnitFuture<'a> {
1272        Box::pin(async move {
1273            let _ = DaemonClient::new(&self.socket_path).kill_session(thread_id).await?;
1274            self.negotiations.lock().await.sessions.remove(thread_id);
1275            Ok(())
1276        })
1277    }
1278}
1279
1280fn action_options_for_guardian(
1281    hello: &HelloResult,
1282    mut options: ShellActionOptions,
1283) -> Result<(ShellActionOptions, bool)> {
1284    let has = |capability: &str| hello.capabilities.iter().any(|candidate| candidate == capability);
1285    if !has(TYPED_ACTION_RESULT_CAPABILITY) {
1286        return Err(WinxError::ConfigurationError(format!(
1287            "the running guardian does not advertise {TYPED_ACTION_RESULT_CAPABILITY}; terminate this durable session and initialize it again before executing BashCommand"
1288        )));
1289    }
1290    let generation_bound_actions = has(GENERATION_BOUND_ACTIONS_CAPABILITY);
1291    if (options.expected_generation.is_some() || options.require_generation_binding)
1292        && !generation_bound_actions
1293    {
1294        return Err(WinxError::InvalidInput(
1295            "the running guardian does not support generation-bound shell actions".to_string(),
1296        ));
1297    }
1298    if let Some(expected) = options.expected_execution.as_ref() {
1299        if expected.guardian_epoch != hello.daemon_epoch {
1300            return Err(WinxError::InvalidInput(
1301                "execution token belongs to a different guardian epoch".to_string(),
1302            ));
1303        }
1304        options.expected_generation = Some(expected.generation);
1305    }
1306    if options.expected_generation.is_some() || options.require_generation_binding {
1307        options.expected_guardian_epoch = Some(hello.daemon_epoch.clone());
1308    }
1309    if options.cancellation_key.is_some() && !has(CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY) {
1310        return Err(WinxError::InvalidInput(
1311            "the running guardian does not support cancellable action reservations".to_string(),
1312        ));
1313    }
1314    if !has(COMPACT_ACTION_OUTPUT_CAPABILITY) {
1315        options.compact_output = false;
1316    }
1317    Ok((options, generation_bound_actions))
1318}
1319
1320fn runtime_result_from_wire(
1321    result: RunActionResult,
1322    generation_bound_actions: bool,
1323) -> Result<BashCommandRuntimeResult> {
1324    match (
1325        result.output,
1326        result.compact_output,
1327        result.state,
1328        result.error,
1329        result.dropped_output_file,
1330    ) {
1331        (output, compact_output, Some(state), None, dropped_output_file)
1332            if output.is_some() || compact_output.is_some() => {
1333            let (output, compact_output) = match (output, compact_output) {
1334                (Some(output), compact) => (output, compact),
1335                (None, Some(compact)) => (String::new(), Some(compact)),
1336                (None, None) => unreachable!(),
1337            };
1338            Ok(BashCommandRuntimeResult {
1339                result: BashCommandResult { output, state },
1340                compact_output,
1341                command_generation: result.command_generation,
1342                execution_token: result.execution_token,
1343                action_context: result.action_context,
1344                generation_bound_actions,
1345                dropped_output_file,
1346                output_truncated: result.output_truncated,
1347                workflow: result.workflow,
1348            })
1349        }
1350        (_, _, _, Some(error), _) => Err(from_wire_error(error)),
1351        (Some(_), _, None, None, _) => Err(WinxError::ConfigurationError(
1352            "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."
1353                .to_string(),
1354        )),
1355        _ => Err(WinxError::ParseError(
1356            "winxd action response had neither a typed result nor an error".to_string(),
1357        )),
1358    }
1359}
1360
1361fn from_wire_error(error: WireShellError) -> WinxError {
1362    match error {
1363        WireShellError::BashStateNotInitialized => WinxError::BashStateNotInitialized,
1364        WireShellError::ShellInitialization(message) => {
1365            WinxError::ShellInitializationError(message)
1366        }
1367        WireShellError::CommandExecution(message) | WireShellError::Other(message) => {
1368            WinxError::CommandExecutionError(message)
1369        }
1370        WireShellError::NoActiveCommand(message) => WinxError::NoActiveCommand(message),
1371        WireShellError::BackgroundSessionNotFound(message) => {
1372            WinxError::BackgroundSessionNotFound(message)
1373        }
1374        WireShellError::EmptyInteractiveInput(action) => {
1375            WinxError::EmptyInteractiveInput { action }
1376        }
1377        WireShellError::InteractiveTargetNotRunning(message) => {
1378            WinxError::InteractiveTargetNotRunning(message)
1379        }
1380        WireShellError::CommandAlreadyRunning { current_command, duration_seconds } => {
1381            WinxError::CommandAlreadyRunning { current_command, duration_seconds }
1382        }
1383        WireShellError::CommandNotAllowed(message) => WinxError::CommandNotAllowed(message),
1384        WireShellError::ThreadIdMismatch(message) => WinxError::ThreadIdMismatch(message),
1385        WireShellError::InvalidInput(message) => WinxError::InvalidInput(message),
1386    }
1387}
1388
1389#[cfg(test)]
1390mod tests {
1391    #![allow(clippy::expect_used)]
1392
1393    #[cfg(unix)]
1394    use std::sync::atomic::AtomicUsize;
1395    use std::sync::atomic::Ordering;
1396
1397    use super::*;
1398    #[cfg(unix)]
1399    use crate::daemon::protocol::{RpcResponse, TYPED_ACTION_RESULT_CAPABILITY};
1400    #[cfg(unix)]
1401    use tokio::net::UnixStream;
1402
1403    #[cfg(unix)] // mocks a guardian that answers hello and then stalls
1404    #[tokio::test(start_paused = true)]
1405    async fn control_rpcs_time_out_when_a_guardian_stalls_after_hello() {
1406        let temp = tempfile::tempdir().expect("temporary directory");
1407        let socket = temp.path().join("stalled.sock");
1408        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock guardian");
1409        let server = tokio::spawn(async move {
1410            let (mut stream, _) = listener.accept().await.expect("accept");
1411            let hello: RpcRequest = read_json_frame(&mut stream).await.expect("read hello");
1412            assert_eq!(hello.method, "winx.hello");
1413            let result = HelloResult::current(
1414                crate::daemon::protocol::DaemonProcessRole::Guardian,
1415                vec![],
1416                "stalled",
1417            );
1418            write_json_frame(
1419                &mut stream,
1420                &RpcResponse {
1421                    jsonrpc: "2.0".to_string(),
1422                    id: hello.id,
1423                    result: Some(serde_json::to_value(result).expect("hello json")),
1424                    error: None,
1425                },
1426            )
1427            .await
1428            .expect("write hello");
1429            // Read the session.list request, then never answer it.
1430            let _list: RpcRequest = read_json_frame(&mut stream).await.expect("read list");
1431            std::future::pending::<()>().await;
1432        });
1433
1434        let error = DaemonClient::new(&socket)
1435            .list_sessions()
1436            .await
1437            .expect_err("a stalled guardian must not park the caller forever");
1438        assert!(
1439            matches!(error, WinxError::CommandTimeout { .. }),
1440            "expected a bounded timeout, got {error}"
1441        );
1442        server.abort();
1443    }
1444
1445    #[cfg(unix)] // mocks the control plane with a raw UnixListener/pair
1446    #[tokio::test]
1447    async fn cancelled_rpc_cannot_poison_the_next_tool_channel() {
1448        let temp = tempfile::tempdir().expect("temporary directory");
1449        let socket = temp.path().join("cancel-safe.sock");
1450        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1451        let (first_seen_tx, first_seen_rx) = tokio::sync::oneshot::channel();
1452        let server = tokio::spawn(async move {
1453            let (mut first_stream, _) = listener.accept().await.expect("accept first RPC");
1454            let first_handler = tokio::spawn(async move {
1455                let first: RpcRequest =
1456                    read_json_frame(&mut first_stream).await.expect("read first RPC");
1457                assert_eq!(first.method, "session.configure");
1458                let _ = first_seen_tx.send(());
1459                let closed = read_json_frame::<_, RpcRequest>(&mut first_stream).await;
1460                assert!(
1461                    closed
1462                        .as_ref()
1463                        .is_err_and(|error| error.kind() == std::io::ErrorKind::UnexpectedEof),
1464                    "cancelled disposable connection must close cleanly: {closed:?}"
1465                );
1466            });
1467
1468            let (mut second_stream, _) = listener.accept().await.expect("accept second RPC");
1469            let second: RpcRequest =
1470                read_json_frame(&mut second_stream).await.expect("read second RPC");
1471            assert_eq!(second.method, "session.configure");
1472            write_json_frame(
1473                &mut second_stream,
1474                &RpcResponse {
1475                    jsonrpc: "2.0".to_string(),
1476                    id: second.id,
1477                    result: Some(serde_json::json!(42)),
1478                    error: None,
1479                },
1480            )
1481            .await
1482            .expect("write second response");
1483            first_handler.await.expect("first connection handler");
1484        });
1485
1486        let runtime = DaemonShellRuntime::new(&socket);
1487        let first_runtime = runtime.clone();
1488        let first = tokio::spawn(async move {
1489            first_runtime
1490                .isolated_request::<u64>(1, "session.configure", serde_json::json!({}))
1491                .await
1492        });
1493        first_seen_rx.await.expect("first request reached server");
1494        first.abort();
1495        assert!(first.await.expect_err("first request was cancelled").is_cancelled());
1496
1497        let second = tokio::time::timeout(
1498            Duration::from_secs(1),
1499            runtime.isolated_request::<u64>(2, "session.configure", serde_json::json!({})),
1500        )
1501        .await
1502        .expect("next RPC must not inherit cancelled framing")
1503        .expect("second RPC succeeds");
1504        assert_eq!(second, 42);
1505        server.await.expect("mock server task");
1506    }
1507
1508    #[cfg(unix)] // mocks the control plane with a raw UnixListener
1509    #[tokio::test]
1510    #[allow(clippy::too_many_lines)] // full control/guardian wire exchange is intentional
1511    async fn generation_capability_comes_from_effective_guardian_and_is_cached() {
1512        let temp = tempfile::tempdir().expect("temporary directory");
1513        let socket = temp.path().join("control.sock");
1514        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1515        let connections = Arc::new(AtomicUsize::new(0));
1516        let observed = Arc::clone(&connections);
1517        let server = tokio::spawn(async move {
1518            let (mut stream, _) = listener.accept().await.expect("accept adapter");
1519            observed.fetch_add(1, Ordering::SeqCst);
1520            let hello: RpcRequest = read_json_frame(&mut stream).await.expect("control hello");
1521            assert_eq!(hello.method, "winx.hello");
1522            write_json_frame(
1523                &mut stream,
1524                &RpcResponse {
1525                    jsonrpc: "2.0".to_string(),
1526                    id: hello.id,
1527                    result: Some(
1528                        serde_json::to_value(HelloResult {
1529                            protocol_major: PROTOCOL_MAJOR,
1530                            protocol_minor: 5,
1531                            capabilities: vec![GENERATION_BOUND_ACTIONS_CAPABILITY.to_string()],
1532                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1533                            daemon_epoch: "control-15".to_string(),
1534                            daemon_pid: 15,
1535                            process_role: None,
1536                            build: None,
1537                        })
1538                        .expect("control hello value"),
1539                    ),
1540                    error: None,
1541                },
1542            )
1543            .await
1544            .expect("write control hello");
1545
1546            let negotiation: RpcRequest =
1547                read_json_frame(&mut stream).await.expect("session negotiation");
1548            assert_eq!(negotiation.method, "session.negotiate");
1549            write_json_frame(
1550                &mut stream,
1551                &RpcResponse {
1552                    jsonrpc: "2.0".to_string(),
1553                    id: negotiation.id,
1554                    result: Some(
1555                        serde_json::to_value(HelloResult {
1556                            protocol_major: PROTOCOL_MAJOR,
1557                            protocol_minor: 4,
1558                            capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1559                            max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1560                            daemon_epoch: "guardian-14".to_string(),
1561                            daemon_pid: 14,
1562                            process_role: None,
1563                            build: None,
1564                        })
1565                        .expect("guardian hello value"),
1566                    ),
1567                    error: None,
1568                },
1569            )
1570            .await
1571            .expect("write guardian hello");
1572
1573            let (mut action_stream, _) = listener.accept().await.expect("accept isolated action");
1574            observed.fetch_add(1, Ordering::SeqCst);
1575            let action: RpcRequest =
1576                read_json_frame(&mut action_stream).await.expect("isolated action request");
1577            assert_eq!(action.method, "shell.run_action", "hot path must not send winx.hello");
1578            assert!(
1579                action.params.get("options").is_none(),
1580                "guardian 1.4 must receive default protocol-1.4 options"
1581            );
1582            let params: RunActionParams =
1583                serde_json::from_value(action.params).expect("run action params");
1584            let cwd = params.snapshot.cwd.clone();
1585            write_json_frame(
1586                &mut action_stream,
1587                &RpcResponse {
1588                    jsonrpc: "2.0".to_string(),
1589                    id: action.id,
1590                    result: Some(
1591                        serde_json::to_value(RunActionResult {
1592                            output: Some("guardian-14-output".to_string()),
1593                            compact_output: None,
1594                            command_generation: None,
1595                            execution_token: None,
1596                            action_context: None,
1597                            dropped_output_file: None,
1598                            output_truncated: false,
1599                            workflow: None,
1600                            state: Some(crate::tools::bash_command::BashCommandState {
1601                                process_status:
1602                                    crate::tools::bash_command::BashProcessStatus::Exited,
1603                                background_id: None,
1604                                running_for_seconds: None,
1605                                exit_code: Some(0),
1606                                cwd: cwd.into(),
1607                                turn_state: None,
1608                            }),
1609                            snapshot: params.snapshot,
1610                            error: None,
1611                        })
1612                        .expect("action result value"),
1613                    ),
1614                    error: None,
1615                },
1616            )
1617            .await
1618            .expect("write action result");
1619        });
1620
1621        let runtime = DaemonShellRuntime::new(&socket);
1622        let mut state = BashState::new();
1623        state.current_thread_id = "legacy-guardian".to_string();
1624        let state = Arc::new(Mutex::new(Some(state)));
1625        let (first_probe, concurrent_probe) = tokio::join!(
1626            runtime.supports_generation_bound_actions_for(&state),
1627            runtime.supports_generation_bound_actions_for(&state)
1628        );
1629        assert!(!first_probe.expect("first capability probe"));
1630        assert!(!concurrent_probe.expect("concurrent capability probe"));
1631        assert!(!tokio::time::timeout(
1632            std::time::Duration::from_millis(100),
1633            runtime.supports_generation_bound_actions_for(&state),
1634        )
1635        .await
1636        .expect("cached probe must not reconnect")
1637        .expect("cached capability"));
1638
1639        let outcome = runtime
1640            .run_remote_detailed(
1641                &state,
1642                BashCommand {
1643                    action_json: crate::types::BashCommandAction::Command {
1644                        command: "printf guardian-14-output".to_string(),
1645                        is_background: false,
1646                        allow_multi: false,
1647                    },
1648                    wait_for_seconds: Some(0.0),
1649                    thread_id: "legacy-guardian".to_string(),
1650                },
1651                ShellActionOptions { compact_output: true, ..ShellActionOptions::default() },
1652            )
1653            .await
1654            .expect("guardian 1.4 action");
1655        assert_eq!(outcome.result.output, "guardian-14-output");
1656        assert_eq!(outcome.compact_output, None);
1657        server.await.expect("mock server task");
1658        assert_eq!(connections.load(Ordering::SeqCst), 2);
1659    }
1660
1661    #[cfg(unix)] // mocks the control plane with a raw UnixListener
1662    #[tokio::test]
1663    #[allow(clippy::too_many_lines)] // two complete negotiated epochs are intentional
1664    async fn ambiguous_action_is_not_retried_after_guardian_epoch_changes() {
1665        let temp = tempfile::tempdir().expect("temporary directory");
1666        let socket = temp.path().join("control-restart.sock");
1667        let listener = tokio::net::UnixListener::bind(&socket).expect("bind mock control");
1668        let action_count = Arc::new(AtomicUsize::new(0));
1669        let observed = Arc::clone(&action_count);
1670        let server = tokio::spawn(async move {
1671            for (control_epoch, guardian_epoch) in
1672                [("control-one", "guardian-one"), ("control-two", "guardian-two")]
1673            {
1674                let (mut stream, _) = listener.accept().await.expect("accept adapter");
1675                let control_request: RpcRequest =
1676                    read_json_frame(&mut stream).await.expect("control hello");
1677                assert_eq!(control_request.method, "winx.hello");
1678                write_json_frame(
1679                    &mut stream,
1680                    &RpcResponse {
1681                        jsonrpc: "2.0".to_string(),
1682                        id: control_request.id,
1683                        result: Some(
1684                            serde_json::to_value(HelloResult {
1685                                protocol_major: PROTOCOL_MAJOR,
1686                                protocol_minor: 5,
1687                                capabilities: vec![],
1688                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1689                                daemon_epoch: control_epoch.to_string(),
1690                                daemon_pid: 15,
1691                                process_role: None,
1692                                build: None,
1693                            })
1694                            .expect("control hello value"),
1695                        ),
1696                        error: None,
1697                    },
1698                )
1699                .await
1700                .expect("control hello response");
1701                let negotiation: RpcRequest =
1702                    read_json_frame(&mut stream).await.expect("session negotiation");
1703                write_json_frame(
1704                    &mut stream,
1705                    &RpcResponse {
1706                        jsonrpc: "2.0".to_string(),
1707                        id: negotiation.id,
1708                        result: Some(
1709                            serde_json::to_value(HelloResult {
1710                                protocol_major: PROTOCOL_MAJOR,
1711                                protocol_minor: 5,
1712                                capabilities: vec![
1713                                    TYPED_ACTION_RESULT_CAPABILITY.to_string(),
1714                                    GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
1715                                ],
1716                                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1717                                daemon_epoch: guardian_epoch.to_string(),
1718                                daemon_pid: 16,
1719                                process_role: None,
1720                                build: None,
1721                            })
1722                            .expect("guardian hello value"),
1723                        ),
1724                        error: None,
1725                    },
1726                )
1727                .await
1728                .expect("guardian hello response");
1729
1730                if guardian_epoch == "guardian-one" {
1731                    let (mut action_stream, _) =
1732                        listener.accept().await.expect("accept isolated first action");
1733                    let action: RpcRequest =
1734                        read_json_frame(&mut action_stream).await.expect("first action");
1735                    assert_eq!(action.method, "shell.run_action");
1736                    observed.fetch_add(1, Ordering::SeqCst);
1737                    // Dropping without a response makes delivery ambiguous.
1738                } else {
1739                    let repeated = tokio::time::timeout(
1740                        std::time::Duration::from_millis(100),
1741                        listener.accept(),
1742                    )
1743                    .await;
1744                    assert!(repeated.is_err(), "action was resent to a different guardian epoch");
1745                }
1746            }
1747        });
1748
1749        let runtime = DaemonShellRuntime::new(&socket);
1750        let mut state = BashState::new();
1751        state.current_thread_id = "ambiguous-action".to_string();
1752        let state = Arc::new(Mutex::new(Some(state)));
1753        let result = runtime
1754            .run_remote_detailed(
1755                &state,
1756                BashCommand {
1757                    action_json: crate::types::BashCommandAction::Command {
1758                        command: "printf must-run-once".to_string(),
1759                        is_background: false,
1760                        allow_multi: false,
1761                    },
1762                    wait_for_seconds: Some(0.0),
1763                    thread_id: "ambiguous-action".to_string(),
1764                },
1765                ShellActionOptions::default(),
1766            )
1767            .await;
1768        assert!(
1769            matches!(result, Err(WinxError::CommandExecutionError(ref message)) if message.contains("will not retry")),
1770            "{result:?}"
1771        );
1772        server.await.expect("mock server task");
1773        assert_eq!(action_count.load(Ordering::SeqCst), 1);
1774    }
1775
1776    #[cfg(unix)] // mocks the control plane with a raw UnixListener/pair
1777    #[tokio::test]
1778    async fn adapter_negotiation_cache_is_bounded_under_session_churn() {
1779        let mut cache = NegotiationCache::default();
1780        for index in 0..(MAX_NEGOTIATED_SESSIONS + 50) {
1781            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1782            drop(peer);
1783            let session = Arc::new(Mutex::new(NegotiatedSession {
1784                control_epoch: "control".to_string(),
1785                guardian: HelloResult {
1786                    protocol_major: PROTOCOL_MAJOR,
1787                    protocol_minor: 5,
1788                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1789                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1790                    daemon_epoch: format!("guardian-{index}"),
1791                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1792                    process_role: None,
1793                    build: None,
1794                },
1795                health_stream: stream,
1796            }));
1797            cache_negotiation(&mut cache, &format!("thread-{index}"), session, Instant::now())
1798                .expect("inactive cache entry should be evictable");
1799        }
1800        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1801    }
1802
1803    #[cfg(unix)] // mocks the control plane with a raw UnixListener/pair
1804    #[tokio::test]
1805    async fn adapter_negotiation_cache_never_evicts_active_sessions() {
1806        let mut cache = NegotiationCache::default();
1807        let mut active = Vec::new();
1808        for index in 0..MAX_NEGOTIATED_SESSIONS {
1809            let (stream, peer) = UnixStream::pair().expect("unix stream pair");
1810            drop(peer);
1811            let session = Arc::new(Mutex::new(NegotiatedSession {
1812                control_epoch: "control".to_string(),
1813                guardian: HelloResult {
1814                    protocol_major: PROTOCOL_MAJOR,
1815                    protocol_minor: 5,
1816                    capabilities: vec![TYPED_ACTION_RESULT_CAPABILITY.to_string()],
1817                    max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1818                    daemon_epoch: format!("active-{index}"),
1819                    daemon_pid: u32::try_from(index).unwrap_or(u32::MAX),
1820                    process_role: None,
1821                    build: None,
1822                },
1823                health_stream: stream,
1824            }));
1825            cache_negotiation(
1826                &mut cache,
1827                &format!("active-{index}"),
1828                Arc::clone(&session),
1829                Instant::now(),
1830            )
1831            .expect("active slot");
1832            active.push(session);
1833        }
1834        let (stream, peer) = UnixStream::pair().expect("overflow pair");
1835        drop(peer);
1836        let overflow = Arc::new(Mutex::new(NegotiatedSession {
1837            control_epoch: "control".to_string(),
1838            guardian: HelloResult {
1839                protocol_major: PROTOCOL_MAJOR,
1840                protocol_minor: 5,
1841                capabilities: vec![],
1842                max_frame_bytes: super::super::protocol::MAX_FRAME_BYTES,
1843                daemon_epoch: "overflow".to_string(),
1844                daemon_pid: 999,
1845                process_role: None,
1846                build: None,
1847            },
1848            health_stream: stream,
1849        }));
1850        let error =
1851            cache_negotiation(&mut cache, "overflow", Arc::clone(&overflow), Instant::now())
1852                .expect_err("all active sessions must apply backpressure");
1853        assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1854        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1855
1856        drop(active.pop());
1857        cache_negotiation(&mut cache, "overflow", overflow, Instant::now())
1858            .expect("an inactive session can be evicted");
1859        assert_eq!(cache.sessions.len(), MAX_NEGOTIATED_SESSIONS);
1860    }
1861
1862    fn verification_snapshot(verification_state: EditVerificationState) -> BashStateSnapshot {
1863        use crate::state::bash_state::{EditMutationPostcondition, EditMutationReceipt};
1864
1865        let mut state = BashState::new();
1866        state.current_thread_id = "receipt-session".to_string();
1867        state.workspace_root = PathBuf::from("/workspace");
1868        state.cwd = PathBuf::from("/workspace/daemon-cwd");
1869        let mut snapshot = state.snapshot();
1870        let passed = verification_state == EditVerificationState::Passed;
1871        snapshot.edit_mutation_receipts.push(EditMutationReceipt {
1872            temporary_release: None,
1873            schema_version: 1,
1874            fingerprint: "fingerprint".to_string(),
1875            receipt_id: "edit_same_request".to_string(),
1876            tool: "EditFiles".to_string(),
1877            status: if passed { "completed" } else { "completed_with_issues" }.to_string(),
1878            committed_at_unix_ms: 1234,
1879            postconditions: vec![EditMutationPostcondition {
1880                removed_generation: None,
1881                path: "/workspace/target".to_string(),
1882                sha256: "written-content".to_string(),
1883            }],
1884            persisted: true,
1885            verification_pending: !passed,
1886            verification_id: Some("verify_same_command".to_string()),
1887            legacy_verification_id: None,
1888            verification_state: Some(verification_state),
1889            verification_execution: None,
1890            uncommitted_paths: Vec::new(),
1891            next_undo_id: Some("live-only".to_string()),
1892            undo_ids: vec![Some("live-only".to_string())],
1893        });
1894        snapshot
1895    }
1896
1897    #[test]
1898    fn reattach_reconciliation_preserves_session_authority_and_undo_boundary() {
1899        let mut remote = verification_snapshot(EditVerificationState::Interrupted);
1900        let original_mode = remote.mode.clone();
1901        let mut durable = verification_snapshot(EditVerificationState::Passed);
1902        durable.cwd = "/workspace/old-adapter-cwd".to_string();
1903        durable.mode = "architect".to_string();
1904        let stale = remote.clone();
1905
1906        preserve_completed_verifications(&mut remote, &durable);
1907        preserve_completed_verifications(&mut remote, &stale);
1908        assert_eq!(remote.cwd, "/workspace/daemon-cwd");
1909        assert_eq!(remote.mode, original_mode);
1910        assert_eq!(remote.edit_mutation_receipts.len(), 1);
1911        let receipt = &remote.edit_mutation_receipts[0];
1912        assert_eq!(receipt.status, "completed");
1913        assert!(!receipt.verification_pending);
1914        assert_eq!(receipt.verification_state, Some(EditVerificationState::Passed));
1915        assert_eq!(receipt.committed_at_unix_ms, 1234);
1916
1917        let mut restored = BashState::new();
1918        restored.apply_snapshot(&remote);
1919        let restored = restored.snapshot();
1920        assert_eq!(
1921            restored.edit_mutation_receipts[0].verification_state,
1922            Some(EditVerificationState::Passed)
1923        );
1924        assert_eq!(restored.edit_mutation_receipts[0].undo_ids, Vec::<Option<String>>::new());
1925        assert!(restored.edit_mutation_receipts[0].next_undo_id.is_none());
1926    }
1927
1928    #[test]
1929    fn reattach_reconciliation_rejects_other_commits_and_verification_variants() {
1930        for mismatch in [
1931            "thread",
1932            "workspace",
1933            "commit",
1934            "contents",
1935            "verification",
1936            "legacy",
1937            "volatile",
1938            "pending",
1939        ] {
1940            let mut remote = verification_snapshot(EditVerificationState::Interrupted);
1941            let before = remote.edit_mutation_receipts.clone();
1942            let mut durable = verification_snapshot(EditVerificationState::Passed);
1943            let receipt = &mut durable.edit_mutation_receipts[0];
1944            match mismatch {
1945                "thread" => durable.chat_id = "another-session".to_string(),
1946                "workspace" => durable.workspace_root = "/another-workspace".to_string(),
1947                "commit" => receipt.committed_at_unix_ms -= 1,
1948                "contents" => receipt.postconditions[0].sha256 = "other-content".to_string(),
1949                "verification" => receipt.verification_id = Some("verify_other".to_string()),
1950                "legacy" => receipt.legacy_verification_id = Some("legacy_other".to_string()),
1951                "volatile" => receipt.persisted = false,
1952                "pending" => receipt.verification_state = Some(EditVerificationState::Pending),
1953                _ => unreachable!(),
1954            }
1955            preserve_completed_verifications(&mut remote, &durable);
1956            assert_eq!(remote.edit_mutation_receipts, before, "{mismatch}");
1957        }
1958
1959        let mut remote = verification_snapshot(EditVerificationState::Interrupted);
1960        remote.edit_mutation_receipts.clear();
1961        let durable = verification_snapshot(EditVerificationState::Passed);
1962        preserve_completed_verifications(&mut remote, &durable);
1963        assert!(remote.edit_mutation_receipts.is_empty(), "evicted receipts stay absent");
1964    }
1965
1966    #[tokio::test]
1967    async fn stale_daemon_action_snapshot_cannot_overwrite_local_reconfiguration() {
1968        let runtime = DaemonShellRuntime::new("unused.sock");
1969        let mut initial = BashState::new();
1970        initial.current_thread_id = "local-revision".to_string();
1971        initial.cwd = PathBuf::from("/old-action-cwd");
1972        let stale_snapshot = initial.snapshot();
1973        let state = Arc::new(Mutex::new(Some(initial)));
1974
1975        let revision = runtime.local_revision("local-revision").await.expect("revision");
1976        let captured = revision.load(Ordering::SeqCst);
1977        let mut configure_guard = state.lock().await;
1978        let apply_runtime = runtime.clone();
1979        let apply_state = Arc::clone(&state);
1980        let apply_revision = Arc::clone(&revision);
1981        let stale_apply = tokio::spawn(async move {
1982            apply_runtime
1983                .sync_action_cwd_if_current(
1984                    &apply_state,
1985                    "local-revision",
1986                    &apply_revision,
1987                    captured,
1988                    &stale_snapshot,
1989                )
1990                .await
1991        });
1992        tokio::task::yield_now().await;
1993
1994        // configure_remote performs this bump while its caller holds the same
1995        // BashState lock. The stale action writeback is already concurrent and
1996        // waiting for that lock, so it must revalidate after configuration.
1997        revision.fetch_add(1, Ordering::SeqCst);
1998        configure_guard.as_mut().expect("state").cwd = PathBuf::from("/new-config-cwd");
1999        drop(configure_guard);
2000
2001        assert!(!stale_apply.await.expect("stale writeback task"));
2002        assert_eq!(
2003            state.lock().await.as_ref().expect("state").cwd,
2004            PathBuf::from("/new-config-cwd")
2005        );
2006    }
2007
2008    #[cfg(unix)]
2009    async fn temporary_release_rpc_fixture(
2010    ) -> anyhow::Result<(tempfile::TempDir, DaemonShellRuntime, UnixStream, tokio::net::UnixListener)>
2011    {
2012        let temp = tempfile::tempdir()?;
2013        let socket = temp.path().join("release.sock");
2014        let listener = tokio::net::UnixListener::bind(&socket)?;
2015        let runtime = DaemonShellRuntime::new(&socket);
2016        let (health_stream, health_peer) = UnixStream::pair()?;
2017        let guardian = HelloResult::current(
2018            super::super::protocol::DaemonProcessRole::Guardian,
2019            vec![super::super::protocol::TEMPORARY_RELEASE_CAPABILITY.to_string()],
2020            "release-owner",
2021        );
2022        cache_negotiation(
2023            &mut *runtime.negotiations.lock().await,
2024            "release-request",
2025            Arc::new(Mutex::new(NegotiatedSession {
2026                control_epoch: "release-control".to_string(),
2027                guardian,
2028                health_stream,
2029            })),
2030            Instant::now(),
2031        )?;
2032        Ok((temp, runtime, health_peer, listener))
2033    }
2034
2035    #[cfg(unix)]
2036    async fn temporary_release_rpc_peer(
2037        listener: tokio::net::UnixListener,
2038        observed: Arc<AtomicUsize>,
2039        close_response: bool,
2040    ) -> anyhow::Result<()> {
2041        let (mut stream, _) = listener.accept().await?;
2042        let mut request: RpcRequest = read_json_frame(&mut stream).await?;
2043        // The baseline opens with hello; disposable negotiated requests already
2044        // have a live, capable owner and send the operation on this connection.
2045        if request.method == "winx.hello" {
2046            let hello = HelloResult::current(
2047                super::super::protocol::DaemonProcessRole::Guardian,
2048                vec![super::super::protocol::TEMPORARY_RELEASE_CAPABILITY.to_string()],
2049                "release-owner",
2050            );
2051            write_json_frame(
2052                &mut stream,
2053                &RpcResponse {
2054                    jsonrpc: "2.0".to_string(),
2055                    id: request.id,
2056                    result: Some(serde_json::to_value(hello)?),
2057                    error: None,
2058                },
2059            )
2060            .await?;
2061            request = read_json_frame(&mut stream).await?;
2062        }
2063        assert_eq!(request.method, "session.release_temporary");
2064        assert_eq!(request.params["expected_guardian_epoch"], "release-owner");
2065        assert_eq!(request.params["thread_id"], "release-request");
2066        observed.fetch_add(1, Ordering::SeqCst);
2067        if !close_response {
2068            let closed = read_json_frame::<_, RpcRequest>(&mut stream).await;
2069            assert!(closed.is_err(), "timed-out disposable release connection must close");
2070        }
2071        drop(stream);
2072        assert!(
2073            tokio::time::timeout(Duration::from_millis(10), listener.accept()).await.is_err(),
2074            "an uncertain release must never be resent"
2075        );
2076        Ok(())
2077    }
2078
2079    #[cfg(unix)]
2080    fn temporary_release_rpc_request() -> crate::utils::agent_temp::release::TemporaryReleaseRequest
2081    {
2082        crate::utils::agent_temp::release::TemporaryReleaseRequest {
2083            thread_id: "release-request".to_string(),
2084            workspace_root: PathBuf::from("/workspace"),
2085            files: Vec::new(),
2086            expected_guardian_epoch: Some("untrusted-old-owner".to_string()),
2087        }
2088    }
2089
2090    #[cfg(unix)]
2091    #[tokio::test(start_paused = true)]
2092    async fn temporary_release_rpc_stall_is_bounded_unknown_and_never_resent() -> anyhow::Result<()>
2093    {
2094        let (_temp, runtime, _health, listener) = temporary_release_rpc_fixture().await?;
2095        let observed = Arc::new(AtomicUsize::new(0));
2096        let server = tokio::spawn(temporary_release_rpc_peer(listener, observed.clone(), false));
2097        let state = Arc::new(Mutex::new(None));
2098        let local_admission = tokio::sync::RwLock::new(());
2099        let started = tokio::time::Instant::now();
2100        let result =
2101            tokio::time::timeout(SESSION_CONTROL_RPC_TIMEOUT + Duration::from_secs(1), async {
2102                let _call_guard = local_admission.read().await;
2103                runtime.release_temporary(&state, temporary_release_rpc_request()).await
2104            })
2105            .await;
2106        let elapsed = started.elapsed();
2107        server.await??;
2108        assert_eq!(observed.load(Ordering::SeqCst), 1);
2109        let result = result
2110            .map_err(|_| anyhow::anyhow!("release RPC exceeded its bounded control deadline"))?;
2111        let error = result.expect_err("a stalled release reply has an unknown outcome").to_string();
2112        assert!(error.contains("release outcome unknown"), "{error}");
2113        assert!(error.contains("inspect"), "{error}");
2114        assert!(error.contains("not retry"), "{error}");
2115        assert!(elapsed <= SESSION_CONTROL_RPC_TIMEOUT + Duration::from_millis(100));
2116        assert!(
2117            local_admission.try_write().is_ok(),
2118            "the completed RPC must release local admission"
2119        );
2120        Ok(())
2121    }
2122
2123    #[cfg(unix)]
2124    #[tokio::test(start_paused = true)]
2125    async fn temporary_release_rpc_transport_loss_is_unknown_and_never_resent() -> anyhow::Result<()>
2126    {
2127        let (_temp, runtime, _health, listener) = temporary_release_rpc_fixture().await?;
2128        let observed = Arc::new(AtomicUsize::new(0));
2129        let server = tokio::spawn(temporary_release_rpc_peer(listener, observed.clone(), true));
2130        let state = Arc::new(Mutex::new(None));
2131        let result = runtime.release_temporary(&state, temporary_release_rpc_request()).await;
2132        server.await??;
2133        assert_eq!(observed.load(Ordering::SeqCst), 1);
2134        let error = result.expect_err("lost release response has an unknown outcome").to_string();
2135        assert!(error.contains("release outcome unknown"), "{error}");
2136        assert!(error.contains("inspect"), "{error}");
2137        assert!(error.contains("not retry"), "{error}");
2138        Ok(())
2139    }
2140}