Skip to main content

winx_code_agent/daemon/
server.rs

1use std::collections::{HashMap, HashSet, VecDeque};
2use std::io::ErrorKind;
3use std::path::{Path, PathBuf};
4use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::sync::Arc;
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use tokio::sync::{watch, Mutex, Notify, RwLock};
9
10use super::protocol::{
11    read_json_frame, write_json_frame, CancelActionParams, ConfigureSessionParams,
12    ConfigureSessionResult, ConfigureSessionTransition, DaemonProcessRole, HelloResult,
13    JournalRead, JournalReadParams, RpcError, RpcRequest, RpcResponse, RunActionParams,
14    RunActionResult, SessionInfo, SessionParams, WireShellError, ATTACH_OR_CREATE_CAPABILITY,
15    CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY, COMPACT_ACTION_OUTPUT_CAPABILITY,
16    GENERATION_BOUND_ACTIONS_CAPABILITY, TYPED_ACTION_RESULT_CAPABILITY,
17};
18use crate::errors::{Result, WinxError};
19use crate::runtime::{lock_session_store, ShellExecutionToken, ShellTarget};
20use crate::state::bash_state::BashState;
21use crate::state::persistence::BashStateSnapshot;
22use crate::state::pty::SharedPtyShell;
23use crate::tools::bash_command::{guardian_foreground_shell_needs_reset, ShellDeliveryCursor};
24use crate::types::normalize_thread_id;
25
26type SharedState = Arc<Mutex<Option<BashState>>>;
27
28struct TimedEntry<T> {
29    value: T,
30    last_seen: Instant,
31}
32
33type CachedActionResult = std::result::Result<RunActionResult, String>;
34
35#[derive(Clone)]
36enum ActionCompletion {
37    InFlight(Arc<watch::Sender<Option<CachedActionResult>>>),
38    Complete(Box<CachedActionResult>),
39}
40
41enum ActionReservation {
42    Owner(Arc<watch::Sender<Option<CachedActionResult>>>),
43    Waiter(Arc<watch::Sender<Option<CachedActionResult>>>),
44    Cached(Box<CachedActionResult>),
45    Rejected { error: WinxError, preserve_cancellation: bool },
46}
47
48struct ActionOwnerGuard {
49    completion: Arc<watch::Sender<Option<CachedActionResult>>>,
50    armed: bool,
51}
52
53impl ActionOwnerGuard {
54    fn new(completion: Arc<watch::Sender<Option<CachedActionResult>>>) -> Self {
55        Self { completion, armed: true }
56    }
57
58    fn disarm(&mut self) {
59        self.armed = false;
60    }
61}
62
63impl Drop for ActionOwnerGuard {
64    fn drop(&mut self) {
65        if self.armed {
66            self.completion.send_replace(Some(Err(
67                "single-flight shell action owner disappeared before completion".to_string(),
68            )));
69        }
70    }
71}
72
73struct ActionEntry {
74    fingerprint: Vec<u8>,
75    cancellation_key: Option<String>,
76    completion: ActionCompletion,
77    last_seen: Instant,
78}
79
80struct DaemonSession {
81    state: SharedState,
82    /// Reset/configure takes the writer; actions, snapshots, cursors, and
83    /// interrupts take readers. This binds execution-token validation to the
84    /// exact shell incarnation on which the operation is performed.
85    operation_barrier: Arc<RwLock<()>>,
86    completed: Mutex<HashMap<String, ActionEntry>>,
87    journal: Mutex<OutputJournal>,
88    observed: Mutex<HashMap<String, (u64, String)>>,
89    background_ids: Mutex<HashSet<String>>,
90    command_id: Mutex<Option<String>>,
91    delivery_cursors: Mutex<HashMap<String, TimedEntry<Arc<Mutex<ShellDeliveryCursor>>>>>,
92    launch_reservations: Mutex<HashMap<String, TimedEntry<Arc<AtomicBool>>>>,
93    created_at_unix_ms: u64,
94    last_activity_unix_ms: AtomicU64,
95    last_command_at_unix_ms: AtomicU64,
96    ever_ran_command: AtomicBool,
97    drainer_started: AtomicBool,
98    activity: Notify,
99    session_epoch: Arc<Mutex<String>>,
100}
101
102impl DaemonSession {
103    fn new() -> Self {
104        let now = unix_ms();
105        Self {
106            state: Arc::new(Mutex::new(None)),
107            operation_barrier: Arc::new(RwLock::new(())),
108            completed: Mutex::new(HashMap::new()),
109            journal: Mutex::new(OutputJournal::default()),
110            observed: Mutex::new(HashMap::new()),
111            background_ids: Mutex::new(HashSet::new()),
112            command_id: Mutex::new(None),
113            delivery_cursors: Mutex::new(HashMap::new()),
114            launch_reservations: Mutex::new(HashMap::new()),
115            created_at_unix_ms: now,
116            last_activity_unix_ms: AtomicU64::new(now),
117            last_command_at_unix_ms: AtomicU64::new(0),
118            ever_ran_command: AtomicBool::new(false),
119            drainer_started: AtomicBool::new(false),
120            activity: Notify::new(),
121            session_epoch: Arc::new(Mutex::new(new_epoch())),
122        }
123    }
124
125    fn note_activity(&self, command: bool) {
126        let now = unix_ms();
127        self.last_activity_unix_ms.store(now, Ordering::Release);
128        if command {
129            self.last_command_at_unix_ms.store(now, Ordering::Release);
130            self.ever_ran_command.store(true, Ordering::Release);
131        }
132        self.activity.notify_one();
133    }
134}
135
136fn new_epoch() -> String {
137    format!("{:016x}", rand::random::<u64>())
138}
139
140const MAX_JOURNAL_BYTES: usize = 4 * 1024 * 1024;
141const MAX_SESSION_CACHE_ENTRIES: usize = 256;
142const MAX_IDENTIFIER_BYTES: usize = 256;
143const SESSION_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
144const ACTION_OWNER_TIMEOUT: Duration = Duration::from_secs(11 * 60);
145const ATTACH_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(2);
146const CONFIGURE_BARRIER_TIMEOUT: Duration = Duration::from_secs(5);
147const FOREGROUND_ADMISSION_TIMEOUT: Duration = Duration::from_millis(100);
148const DRAIN_CHANGED_INTERVAL: Duration = Duration::from_millis(20);
149const DRAIN_ACTIVE_INTERVAL: Duration = Duration::from_millis(100);
150const DRAIN_IDLE_INTERVAL: Duration = Duration::from_secs(1);
151
152#[derive(Clone, Copy, Default)]
153struct CaptureState {
154    changed: bool,
155    active: bool,
156}
157
158impl CaptureState {
159    fn merge(&mut self, other: Self) {
160        self.changed |= other.changed;
161        self.active |= other.active;
162    }
163
164    fn next_delay(self) -> Duration {
165        if self.changed {
166            DRAIN_CHANGED_INTERVAL
167        } else if self.active {
168            DRAIN_ACTIVE_INTERVAL
169        } else {
170            DRAIN_IDLE_INTERVAL
171        }
172    }
173}
174
175#[derive(Default)]
176struct OutputJournal {
177    chunks: VecDeque<(u64, String)>,
178    cursors: HashMap<String, TimedEntry<u64>>,
179    next_seq: u64,
180    bytes: usize,
181}
182
183fn prune_timed_entries<T>(entries: &mut HashMap<String, TimedEntry<T>>, now: Instant) {
184    entries.retain(|_, entry| now.duration_since(entry.last_seen) <= SESSION_CACHE_TTL);
185}
186
187fn evict_oldest_if_full<T>(entries: &mut HashMap<String, TimedEntry<T>>, incoming: &str) {
188    if entries.contains_key(incoming) || entries.len() < MAX_SESSION_CACHE_ENTRIES {
189        return;
190    }
191    if let Some(oldest) =
192        entries.iter().min_by_key(|(_, entry)| entry.last_seen).map(|(key, _)| key.clone())
193    {
194        entries.remove(&oldest);
195    }
196}
197
198async fn launch_cancellation_flag(
199    session: &DaemonSession,
200    cancellation_key: &str,
201) -> Result<Arc<AtomicBool>> {
202    let now = Instant::now();
203    let mut reservations = session.launch_reservations.lock().await;
204    reservations.retain(|_, entry| {
205        now.duration_since(entry.last_seen) <= SESSION_CACHE_TTL
206            || Arc::strong_count(&entry.value) > 1
207    });
208    if !reservations.contains_key(cancellation_key)
209        && reservations.len() >= MAX_SESSION_CACHE_ENTRIES
210    {
211        let oldest = reservations
212            .iter()
213            .filter(|(_, entry)| {
214                Arc::strong_count(&entry.value) == 1 && !entry.value.load(Ordering::Acquire)
215            })
216            .min_by_key(|(_, entry)| entry.last_seen)
217            .map(|(key, _)| key.clone());
218        if let Some(oldest) = oldest {
219            reservations.remove(&oldest);
220        }
221    }
222    if !reservations.contains_key(cancellation_key)
223        && reservations.len() >= MAX_SESSION_CACHE_ENTRIES
224    {
225        return Err(WinxError::ResourceAllocationError {
226            message: format!(
227                "all {MAX_SESSION_CACHE_ENTRIES} action cancellation reservations are active"
228            ),
229        });
230    }
231    let entry = reservations
232        .entry(cancellation_key.to_string())
233        .or_insert_with(|| TimedEntry { value: Arc::new(AtomicBool::new(false)), last_seen: now });
234    entry.last_seen = now;
235    Ok(Arc::clone(&entry.value))
236}
237
238async fn consume_launch_cancellation_flag(
239    session: &DaemonSession,
240    cancellation_key: &str,
241    expected: &Arc<AtomicBool>,
242) {
243    let mut reservations = session.launch_reservations.lock().await;
244    if reservations.get(cancellation_key).is_some_and(|entry| Arc::ptr_eq(&entry.value, expected)) {
245        reservations.remove(cancellation_key);
246    }
247}
248
249async fn consume_unowned_launch_cancellation_flag(
250    session: &DaemonSession,
251    cancellation_key: Option<&str>,
252) {
253    let Some(cancellation_key) = cancellation_key else {
254        return;
255    };
256
257    // Owners publish their ActionEntry before acquiring the flag. Keep that
258    // registry locked through the cleanup decision so a rejected/replayed
259    // attempt cannot erase a cancellation reserved for an in-flight owner.
260    let completed = session.completed.lock().await;
261    let owned = completed.values().any(|entry| {
262        entry.cancellation_key.as_deref() == Some(cancellation_key)
263            && matches!(entry.completion, ActionCompletion::InFlight(_))
264            && !action_entry_is_terminal(entry)
265    });
266    if owned {
267        return;
268    }
269    let mut reservations = session.launch_reservations.lock().await;
270    if reservations.get(cancellation_key).is_some_and(|entry| Arc::strong_count(&entry.value) == 1)
271    {
272        reservations.remove(cancellation_key);
273    }
274}
275
276fn validate_identifier(label: &str, value: &str) -> Result<()> {
277    if value.len() > MAX_IDENTIFIER_BYTES {
278        return Err(WinxError::InvalidInput(format!(
279            "{label} exceeds the {MAX_IDENTIFIER_BYTES}-byte limit"
280        )));
281    }
282    Ok(())
283}
284
285impl OutputJournal {
286    fn append(&mut self, output: String) {
287        if output.is_empty() {
288            return;
289        }
290        self.next_seq = self.next_seq.saturating_add(1);
291        self.bytes = self.bytes.saturating_add(output.len());
292        self.chunks.push_back((self.next_seq, output));
293        while self.bytes > MAX_JOURNAL_BYTES && self.chunks.len() > 1 {
294            if let Some((_, removed)) = self.chunks.pop_front() {
295                self.bytes = self.bytes.saturating_sub(removed.len());
296            }
297        }
298    }
299
300    fn read(&mut self, consumer_id: &str) -> JournalRead {
301        let now = Instant::now();
302        prune_timed_entries(&mut self.cursors, now);
303        evict_oldest_if_full(&mut self.cursors, consumer_id);
304
305        let oldest = self.chunks.front().map_or(self.next_seq.saturating_add(1), |(seq, _)| *seq);
306        let cursor = self.cursors.get(consumer_id).map_or(0, |entry| entry.value);
307        let gap = cursor.saturating_add(1) < oldest;
308        let mut output = String::new();
309        let mut next_seq = cursor;
310        for (seq, chunk) in self.chunks.iter().filter(|(seq, _)| *seq > cursor) {
311            output.push_str(chunk);
312            next_seq = *seq;
313        }
314        self.cursors
315            .insert(consumer_id.to_string(), TimedEntry { value: next_seq, last_seen: now });
316        JournalRead { output, next_seq, gap }
317    }
318}
319
320/// Long-lived JSON-RPC shell owner listening on the daemon transport.
321pub struct DaemonServer {
322    listener: crate::daemon::transport::DaemonListener,
323    socket_path: PathBuf,
324    sessions: Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
325    epoch: String,
326}
327
328impl DaemonServer {
329    pub async fn bind(socket_path: impl AsRef<Path>) -> Result<Self> {
330        let socket_path = socket_path.as_ref().to_path_buf();
331        let parent = socket_path.parent().ok_or_else(|| {
332            WinxError::ConfigurationError("daemon socket must have a parent directory".to_string())
333        })?;
334        tokio::fs::create_dir_all(parent).await?;
335        crate::daemon::transport::restrict_to_owner(parent, 0o700).await?;
336
337        if tokio::fs::try_exists(&socket_path).await? {
338            if crate::daemon::transport::connect(&socket_path).await.is_ok() {
339                return Err(WinxError::ConfigurationError(format!(
340                    "a winxd instance is already listening at {}",
341                    socket_path.display()
342                )));
343            }
344            tokio::fs::remove_file(&socket_path).await?;
345        }
346
347        let listener = crate::daemon::transport::DaemonListener::bind(&socket_path)?;
348        crate::daemon::transport::restrict_to_owner(&socket_path, 0o600).await?;
349        let epoch = format!("{:016x}", rand::random::<u64>());
350        Ok(Self { listener, socket_path, sessions: Arc::new(Mutex::new(HashMap::new())), epoch })
351    }
352
353    pub async fn serve(mut self) -> Result<()> {
354        loop {
355            let stream = self.listener.accept().await?;
356            if !crate::daemon::transport::peer_is_same_user(&stream)? {
357                tracing::warn!("Rejected winxd connection from a different uid");
358                continue;
359            }
360            let sessions = self.sessions.clone();
361            let epoch = self.epoch.clone();
362            tokio::spawn(async move {
363                if let Err(error) = serve_connection(stream, sessions, epoch).await {
364                    tracing::debug!("winxd client disconnected: {error}");
365                }
366            });
367        }
368    }
369}
370
371impl Drop for DaemonServer {
372    fn drop(&mut self) {
373        let _ = std::fs::remove_file(&self.socket_path);
374    }
375}
376
377async fn serve_connection(
378    mut stream: crate::daemon::transport::DaemonStream,
379    sessions: Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
380    epoch: String,
381) -> Result<()> {
382    loop {
383        let request: RpcRequest = match read_json_frame(&mut stream).await {
384            Ok(request) => request,
385            Err(error) if error.kind() == ErrorKind::UnexpectedEof => return Ok(()),
386            Err(error) => return Err(error.into()),
387        };
388        let response = dispatch(request, &sessions, &epoch).await;
389        write_json_frame(&mut stream, &response).await?;
390    }
391}
392
393#[allow(clippy::too_many_lines)]
394async fn dispatch(
395    request: RpcRequest,
396    sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
397    epoch: &str,
398) -> RpcResponse {
399    if request.jsonrpc != "2.0" {
400        return rpc_error(request.id, -32600, "JSON-RPC version must be 2.0");
401    }
402
403    match request.method.as_str() {
404        "winx.hello" | "session.negotiate" => rpc_result(
405            request.id,
406            &HelloResult::current(
407                DaemonProcessRole::Guardian,
408                vec![
409                    "session.configure".to_string(),
410                    "session.negotiate".to_string(),
411                    "shell.run_action".to_string(),
412                    TYPED_ACTION_RESULT_CAPABILITY.to_string(),
413                    COMPACT_ACTION_OUTPUT_CAPABILITY.to_string(),
414                    GENERATION_BOUND_ACTIONS_CAPABILITY.to_string(),
415                    CANCELLABLE_ACTION_RESERVATIONS_CAPABILITY.to_string(),
416                    "session.list".to_string(),
417                    "session.read_output".to_string(),
418                    "session.kill".to_string(),
419                    "multi_consumer_cursors".to_string(),
420                    "idempotency".to_string(),
421                    "session_activity_timestamps".to_string(),
422                    ATTACH_OR_CREATE_CAPABILITY.to_string(),
423                    crate::daemon::protocol::PROCESS_SHUTDOWN_CAPABILITY.to_string(),
424                ],
425                epoch,
426            ),
427        ),
428        "winx.shutdown" => {
429            schedule_process_exit("winx-guardian");
430            rpc_result(request.id, &serde_json::json!({ "shutting_down": true }))
431        }
432        "session.configure" => {
433            let params: ConfigureSessionParams = match serde_json::from_value(request.params) {
434                Ok(params) => params,
435                Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
436            };
437            match configure_session(sessions, params).await {
438                Ok(result) => rpc_result(request.id, &result),
439                Err(error) => rpc_error(request.id, -32603, &error.to_string()),
440            }
441        }
442        "shell.run_action" => {
443            let params: RunActionParams = match serde_json::from_value(request.params) {
444                Ok(params) => params,
445                Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
446            };
447            match run_action(sessions, params, epoch).await {
448                Ok(result) => rpc_result(request.id, &result),
449                Err(error) => rpc_error(request.id, -32603, &error.to_string()),
450            }
451        }
452        "shell.cancel_action" => {
453            let params: CancelActionParams = match serde_json::from_value(request.params) {
454                Ok(params) => params,
455                Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
456            };
457            if params.expected_guardian_epoch.as_deref().is_some_and(|expected| expected != epoch) {
458                return rpc_result(request.id, &false);
459            }
460            if let Err(error) = validate_identifier("cancellation_key", &params.cancellation_key) {
461                return rpc_error(request.id, -32602, &error.to_string());
462            }
463            let session =
464                sessions.lock().await.get(&normalize_thread_id(&params.thread_id)).cloned();
465            match session {
466                Some(session) => {
467                    match launch_cancellation_flag(&session, &params.cancellation_key).await {
468                        Ok(flag) => {
469                            flag.store(true, Ordering::SeqCst);
470                            rpc_result(request.id, &true)
471                        }
472                        Err(error) => rpc_error(request.id, -32603, &error.to_string()),
473                    }
474                }
475                None => rpc_result(request.id, &false),
476            }
477        }
478        "session.list" => {
479            let entries = sessions.lock().await.values().cloned().collect::<Vec<_>>();
480            let mut infos = Vec::with_capacity(entries.len());
481            for session in entries {
482                if let Some(info) = session_info(&session).await {
483                    infos.push(info);
484                }
485            }
486            infos.sort_by(|left, right| left.thread_id.cmp(&right.thread_id));
487            rpc_result(request.id, &infos)
488        }
489        "session.info" => {
490            let params: SessionParams = match serde_json::from_value(request.params) {
491                Ok(params) => params,
492                Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
493            };
494            let session =
495                sessions.lock().await.get(&normalize_thread_id(&params.thread_id)).cloned();
496            match session {
497                Some(session) => match session_info(&session).await {
498                    Some(info) => rpc_result(request.id, &info),
499                    None => rpc_error(request.id, -32004, "session not initialized"),
500                },
501                None => rpc_error(request.id, -32004, "session not found"),
502            }
503        }
504        "session.read_output" => {
505            let params: JournalReadParams = match serde_json::from_value(request.params) {
506                Ok(params) => params,
507                Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
508            };
509            if let Err(error) = validate_identifier("consumer_id", &params.consumer_id) {
510                return rpc_error(request.id, -32602, &error.to_string());
511            }
512            let thread_id = normalize_thread_id(&params.thread_id);
513            let session = sessions.lock().await.get(&thread_id).cloned();
514            match session {
515                Some(session) => {
516                    session.note_activity(false);
517                    capture_session_outputs(&session).await;
518                    let read = session.journal.lock().await.read(&params.consumer_id);
519                    rpc_result(request.id, &read)
520                }
521                None => rpc_error(request.id, -32004, "session not found"),
522            }
523        }
524        "session.kill" => {
525            let params: SessionParams = match serde_json::from_value(request.params) {
526                Ok(params) => params,
527                Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
528            };
529            let session = sessions.lock().await.remove(&normalize_thread_id(&params.thread_id));
530            match session {
531                Some(session) => {
532                    let _operation = session.operation_barrier.write().await;
533                    let background_ids = session.background_ids.lock().await.clone();
534                    {
535                        let mut store = lock_session_store();
536                        for id in background_ids {
537                            store.remove_shell(&id);
538                        }
539                    }
540                    *session.state.lock().await = None;
541                    rpc_result(request.id, &true)
542                }
543                None => rpc_result(request.id, &false),
544            }
545        }
546        "session.interrupt" => {
547            let params: SessionParams = match serde_json::from_value(request.params) {
548                Ok(params) => params,
549                Err(error) => return rpc_error(request.id, -32602, &error.to_string()),
550            };
551            let session =
552                sessions.lock().await.get(&normalize_thread_id(&params.thread_id)).cloned();
553            match session {
554                Some(session) => match interrupt_session(&session, &params, epoch).await {
555                    Ok(interrupted) => rpc_result(request.id, &interrupted),
556                    Err(error) => rpc_error(request.id, -32005, &error.to_string()),
557                },
558                None => rpc_error(request.id, -32004, "session not found"),
559            }
560        }
561        _ => rpc_error(request.id, -32601, "method not found"),
562    }
563}
564
565/// Take the authoritative snapshot for an observational reattach without the
566/// operation barrier held by a long-running shell action.
567async fn attach_existing_session(
568    session: &Arc<DaemonSession>,
569) -> Result<Option<ConfigureSessionResult>> {
570    let snapshot = tokio::time::timeout(ATTACH_SNAPSHOT_TIMEOUT, session.state.lock())
571        .await
572        .map_err(|_| WinxError::CommandTimeout {
573            command: "attach to the durable shell session".to_string(),
574            timeout_seconds: ATTACH_SNAPSHOT_TIMEOUT.as_secs(),
575        })?
576        .as_ref()
577        .map(BashState::snapshot);
578    Ok(snapshot.map(|snapshot| ConfigureSessionResult {
579        attach_hint: None,
580        snapshot: Some(snapshot),
581        attached_existing: true,
582    }))
583}
584
585async fn configure_session(
586    sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
587    params: ConfigureSessionParams,
588) -> Result<ConfigureSessionResult> {
589    let thread_id = normalize_thread_id(&params.snapshot.chat_id);
590    if thread_id.is_empty() {
591        return Err(WinxError::ThreadIdMismatch(
592            "daemon session configuration requires an explicit thread_id".to_string(),
593        ));
594    }
595
596    let (session, entry_created) = {
597        let mut sessions = sessions.lock().await;
598        match params.transition {
599            ConfigureSessionTransition::FirstCall => match sessions.entry(thread_id) {
600                std::collections::hash_map::Entry::Occupied(entry) => (entry.get().clone(), false),
601                std::collections::hash_map::Entry::Vacant(entry) => {
602                    let session = Arc::new(DaemonSession::new());
603                    entry.insert(session.clone());
604                    (session, true)
605                }
606            },
607            _ => (
608                sessions.get(&thread_id).cloned().ok_or(WinxError::BashStateNotInitialized)?,
609                false,
610            ),
611        }
612    };
613    ensure_drainer(&session);
614    session.note_activity(false);
615
616    // Reattaching is observational: it must not queue behind the operation
617    // writer held by a reset or behind the read guard held for a long-running
618    // shell action. The durable state is authoritative, and ordinary Bash
619    // actions hold this small state mutex only while taking or applying a snapshot.
620    if matches!(params.transition, ConfigureSessionTransition::FirstCall) && !entry_created {
621        if let Some(attached) = attach_existing_session(&session).await? {
622            session.activity.notify_one();
623            return Ok(attached);
624        }
625    }
626
627    let _operation =
628        tokio::time::timeout(CONFIGURE_BARRIER_TIMEOUT, session.operation_barrier.write())
629            .await
630            .map_err(|_| WinxError::CommandTimeout {
631                command: "configure the shell while another operation is active".to_string(),
632                timeout_seconds: CONFIGURE_BARRIER_TIMEOUT.as_secs(),
633            })?;
634
635    let (attach_hint, snapshot, attached_existing) = {
636        let mut guard = session.state.lock().await;
637        let mut attached_existing = false;
638        match params.transition {
639            ConfigureSessionTransition::FirstCall => {
640                if entry_created || guard.is_none() {
641                    let mut state = BashState::new();
642                    state.apply_snapshot(&params.snapshot);
643                    if state.cwd.exists() {
644                        state.init_pty_shell().await?;
645                    }
646                    *guard = Some(state);
647                } else {
648                    // Attach-or-create: preserve the guardian-owned PTY, cwd, mode,
649                    // output journal, and command state across adapter reconnects.
650                    attached_existing = true;
651                }
652            }
653            ConfigureSessionTransition::ModeChange => {
654                let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
655                let daemon_cwd = state.cwd.clone();
656                state.apply_snapshot(&params.snapshot);
657                state.cwd = daemon_cwd;
658            }
659            ConfigureSessionTransition::Reset => {
660                let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
661                let daemon_cwd = state.cwd.clone();
662                state.apply_snapshot(&params.snapshot);
663                state.cwd = daemon_cwd;
664                state.init_pty_shell().await?;
665                *session.session_epoch.lock().await = new_epoch();
666            }
667            ConfigureSessionTransition::WorkspaceChange => {
668                let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
669                state.apply_snapshot(&params.snapshot);
670            }
671        }
672        let state = guard.as_ref().ok_or(WinxError::BashStateNotInitialized)?;
673        let attach_hint = {
674            let shell = state.pty_shell.lock().await;
675            shell.as_ref().and_then(|shell| shell.attach_hint.clone())
676        };
677        (attach_hint, state.snapshot(), attached_existing)
678    };
679
680    if matches!(params.transition, ConfigureSessionTransition::Reset)
681        || (matches!(params.transition, ConfigureSessionTransition::FirstCall)
682            && !attached_existing)
683    {
684        *session.command_id.lock().await = None;
685        // Idempotency entries intentionally survive reset. A response-lost
686        // owner or waiter must still observe the original operation result;
687        // its execution token identifies the old incarnation unambiguously.
688        session.delivery_cursors.lock().await.clear();
689        session.observed.lock().await.remove("main");
690    }
691    capture_session_outputs_locked(&session).await;
692    session.activity.notify_one();
693    Ok(ConfigureSessionResult { attach_hint, snapshot: Some(snapshot), attached_existing })
694}
695
696#[allow(clippy::too_many_lines)] // session setup, execution, capture, and idempotent caching
697async fn run_action(
698    sessions: &Arc<Mutex<HashMap<String, Arc<DaemonSession>>>>,
699    mut params: RunActionParams,
700    guardian_epoch: &str,
701) -> Result<RunActionResult> {
702    validate_identifier("request_key", &params.request_key)?;
703    validate_identifier("consumer_id", &params.consumer_id)?;
704    if let Some(key) = params.options.cancellation_key.as_deref() {
705        validate_identifier("cancellation_key", key)?;
706    }
707    let thread_id = normalize_thread_id(&params.command.thread_id);
708    if thread_id.is_empty() {
709        return Err(WinxError::ThreadIdMismatch(
710            "daemon requests require an explicit thread_id".to_string(),
711        ));
712    }
713
714    let session = {
715        let mut sessions = sessions.lock().await;
716        sessions.entry(thread_id.clone()).or_insert_with(|| Arc::new(DaemonSession::new())).clone()
717    };
718    let cancellation_key = params.options.cancellation_key.clone();
719    let fingerprint = canonical_action_fingerprint(&params)?;
720
721    let request_key = params.request_key.clone();
722    let reservation = {
723        let mut completed = session.completed.lock().await;
724        let now = Instant::now();
725        prune_action_entries(&mut completed, now);
726        if let Some(entry) = completed.get_mut(&request_key) {
727            if entry.fingerprint == fingerprint {
728                entry.last_seen = now;
729                match &entry.completion {
730                    ActionCompletion::Complete(result) => {
731                        ActionReservation::Cached(Box::new((**result).clone()))
732                    }
733                    ActionCompletion::InFlight(sender) => {
734                        ActionReservation::Waiter(Arc::clone(sender))
735                    }
736                }
737            } else {
738                let preserve_cancellation = entry.cancellation_key == cancellation_key
739                    && matches!(entry.completion, ActionCompletion::InFlight(_))
740                    && !action_entry_is_terminal(entry);
741                ActionReservation::Rejected {
742                    error: WinxError::InvalidInput(
743                        "request_key was reused with different shell action parameters".to_string(),
744                    ),
745                    preserve_cancellation,
746                }
747            }
748        } else if evict_oldest_action_if_full(&mut completed) {
749            let completion = Arc::new(watch::channel(None).0);
750            completed.insert(
751                request_key.clone(),
752                ActionEntry {
753                    fingerprint,
754                    cancellation_key: cancellation_key.clone(),
755                    completion: ActionCompletion::InFlight(Arc::clone(&completion)),
756                    last_seen: now,
757                },
758            );
759            ActionReservation::Owner(completion)
760        } else {
761            ActionReservation::Rejected {
762                error: WinxError::ResourceAllocationError {
763                    message: format!(
764                        "all {MAX_SESSION_CACHE_ENTRIES} shell action reservations are in flight; retry after one completes"
765                    ),
766                },
767                preserve_cancellation: false,
768            }
769        }
770    };
771
772    match reservation {
773        ActionReservation::Cached(result) => {
774            consume_unowned_launch_cancellation_flag(&session, cancellation_key.as_deref()).await;
775            cached_action_result(*result)
776        }
777        ActionReservation::Rejected { error, preserve_cancellation } => {
778            if !preserve_cancellation {
779                consume_unowned_launch_cancellation_flag(&session, cancellation_key.as_deref())
780                    .await;
781            }
782            Err(error)
783        }
784        ActionReservation::Waiter(completion) => {
785            wait_for_action_completion(completion.subscribe()).await
786        }
787        ActionReservation::Owner(completion) => {
788            let launch_reservation = if let Some(key) = cancellation_key {
789                match launch_cancellation_flag(&session, &key).await {
790                    Ok(flag) => {
791                        params.options.launch_cancelled = Some(Arc::clone(&flag));
792                        Some((key, flag))
793                    }
794                    Err(error) => {
795                        complete_action(
796                            &session,
797                            &request_key,
798                            Err(error.to_string()),
799                            &completion,
800                        )
801                        .await;
802                        return Err(error);
803                    }
804                }
805            } else {
806                None
807            };
808            let owner_session = Arc::clone(&session);
809            let owner_epoch = guardian_epoch.to_string();
810            let owner_completion = Arc::clone(&completion);
811            tokio::spawn(async move {
812                let mut owner_guard = ActionOwnerGuard::new(Arc::clone(&owner_completion));
813                let outcome = match tokio::time::timeout(
814                    ACTION_OWNER_TIMEOUT,
815                    execute_action(&owner_session, params, &owner_epoch),
816                )
817                .await
818                {
819                    Ok(outcome) => outcome,
820                    Err(_) => Err(WinxError::CommandTimeout {
821                        command: "guardian shell action".to_string(),
822                        timeout_seconds: ACTION_OWNER_TIMEOUT.as_secs(),
823                    }),
824                };
825                if let Some((key, flag)) = launch_reservation {
826                    consume_launch_cancellation_flag(&owner_session, &key, &flag).await;
827                }
828                let cached = outcome.map_err(|error| error.to_string());
829                complete_action(&owner_session, &request_key, cached, &owner_completion).await;
830                owner_guard.disarm();
831            });
832            wait_for_action_completion(completion.subscribe()).await
833        }
834    }
835}
836
837fn cached_action_result(cached: CachedActionResult) -> Result<RunActionResult> {
838    cached.map_err(WinxError::CommandExecutionError)
839}
840
841async fn wait_for_action_completion(
842    mut completion: watch::Receiver<Option<CachedActionResult>>,
843) -> Result<RunActionResult> {
844    loop {
845        if let Some(result) = completion.borrow().clone() {
846            return cached_action_result(result);
847        }
848        if completion.changed().await.is_err() {
849            return Err(WinxError::CommandExecutionError(
850                "single-flight shell action owner disappeared".to_string(),
851            ));
852        }
853    }
854}
855
856async fn complete_action(
857    session: &DaemonSession,
858    request_key: &str,
859    cached: CachedActionResult,
860    completion: &watch::Sender<Option<CachedActionResult>>,
861) {
862    {
863        let mut completed = session.completed.lock().await;
864        if let Some(entry) = completed.get_mut(request_key) {
865            entry.completion = ActionCompletion::Complete(Box::new(cached.clone()));
866            entry.last_seen = Instant::now();
867        }
868    }
869    completion.send_replace(Some(cached));
870}
871
872fn canonical_action_fingerprint(params: &RunActionParams) -> Result<Vec<u8>> {
873    let value = serde_json::to_value((
874        &params.snapshot,
875        &params.command,
876        &params.consumer_id,
877        &params.options,
878    ))
879    .map_err(|error| WinxError::SerializationError(error.to_string()))?;
880    serde_json::to_vec(&canonical_json(value))
881        .map_err(|error| WinxError::SerializationError(error.to_string()))
882}
883
884fn canonical_json(value: serde_json::Value) -> serde_json::Value {
885    match value {
886        serde_json::Value::Array(values) => {
887            serde_json::Value::Array(values.into_iter().map(canonical_json).collect())
888        }
889        serde_json::Value::Object(values) => {
890            let mut entries = values.into_iter().collect::<Vec<_>>();
891            entries.sort_by(|(left, _), (right, _)| left.cmp(right));
892            serde_json::Value::Object(
893                entries.into_iter().map(|(key, value)| (key, canonical_json(value))).collect(),
894            )
895        }
896        scalar => scalar,
897    }
898}
899
900#[allow(clippy::too_many_lines)]
901async fn execute_action(
902    session: &Arc<DaemonSession>,
903    params: RunActionParams,
904    guardian_epoch: &str,
905) -> Result<RunActionResult> {
906    let foreground_command = matches!(
907        &params.command.action_json,
908        crate::types::BashCommandAction::Command { is_background: false, .. }
909    );
910    // Foreground preflight takes the writer before state, cursor, or command
911    // gates. Other actions take a reader. Never leave a second foreground
912    // command queued behind a long-running chat: after a tiny admission window
913    // return the typed running result so the caller can follow status_check.
914    // This also prevents a queued writer from head-of-line blocking observers.
915    let (write_operation, read_operation) = if foreground_command {
916        match tokio::time::timeout(
917            FOREGROUND_ADMISSION_TIMEOUT,
918            Arc::clone(&session.operation_barrier).write_owned(),
919        )
920        .await
921        {
922            Ok(operation) => (Some(operation), None),
923            Err(_) => {
924                return foreground_admission_busy_result(session, params.snapshot.clone()).await;
925            }
926        }
927    } else {
928        (None, Some(Arc::clone(&session.operation_barrier).read_owned().await))
929    };
930    if params
931        .options
932        .expected_guardian_epoch
933        .as_deref()
934        .is_some_and(|expected| expected != guardian_epoch)
935        || params
936            .options
937            .expected_execution
938            .as_ref()
939            .is_some_and(|expected| expected.guardian_epoch != guardian_epoch)
940    {
941        return Err(WinxError::InvalidInput(
942            "execution precondition refers to a different guardian epoch".to_string(),
943        ));
944    }
945    let session_epoch = session.session_epoch.lock().await.clone();
946    if params.options.expected_execution.as_ref().is_some_and(|expected| {
947        expected.session_epoch != session_epoch
948            || params
949                .options
950                .expected_generation
951                .is_some_and(|generation| generation != expected.generation)
952    }) {
953        return Err(WinxError::InvalidInput(
954            "execution precondition refers to a different session incarnation".to_string(),
955        ));
956    }
957    let is_command =
958        matches!(&params.command.action_json, crate::types::BashCommandAction::Command { .. });
959    ensure_drainer(session);
960    session.note_activity(is_command);
961
962    let preflight_reset = {
963        let mut guard = session.state.lock().await;
964        let state = if let Some(state) = guard.as_mut() {
965            let daemon_cwd = state.cwd.clone();
966            state.apply_snapshot(&params.snapshot);
967            state.cwd = daemon_cwd;
968            state
969        } else {
970            let mut state = BashState::new();
971            state.apply_snapshot(&params.snapshot);
972            guard.insert(state)
973        };
974        let needs_shell = state.pty_shell.lock().await.is_none();
975        if needs_shell && state.cwd.exists() {
976            state.init_pty_shell().await?;
977        }
978        if foreground_command && !params.options.is_launch_cancelled() {
979            guardian_foreground_shell_needs_reset(state, &params.options).await?
980        } else {
981            false
982        }
983    };
984    if preflight_reset {
985        let mut guard = session.state.lock().await;
986        let state = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
987        *session.session_epoch.lock().await = new_epoch();
988        if let Err(error) = state.init_pty_shell().await {
989            tracing::warn!(%error, "failed to reset guardian PTY during exclusive preflight");
990        }
991    }
992    let _operation = if let Some(operation) = write_operation {
993        Some(operation.downgrade())
994    } else {
995        read_operation
996    };
997
998    if is_command {
999        *session.command_id.lock().await = Some(params.request_key.clone());
1000    }
1001    let consumer_id = if params.consumer_id.is_empty() {
1002        "legacy-adapter".to_string()
1003    } else {
1004        params.consumer_id
1005    };
1006    let cursor_key = delivery_cursor_key(&consumer_id, &params.command.action_json);
1007    let cursor = {
1008        let now = Instant::now();
1009        let mut cursors = session.delivery_cursors.lock().await;
1010        prune_timed_entries(&mut cursors, now);
1011        evict_oldest_if_full(&mut cursors, &cursor_key);
1012        let entry = cursors.entry(cursor_key).or_insert_with(|| TimedEntry {
1013            value: Arc::new(Mutex::new(ShellDeliveryCursor::default())),
1014            last_seen: now,
1015        });
1016        entry.last_seen = now;
1017        entry.value.clone()
1018    };
1019    let outcome = crate::tools::bash_command::handle_embedded_tool_call_with_cursor_detailed(
1020        &session.state,
1021        params.command,
1022        &cursor,
1023        params.options,
1024        foreground_command,
1025    )
1026    .await;
1027    let session_epoch = session.session_epoch.lock().await.clone();
1028    if let Ok(result) = &outcome {
1029        if let Some(id) = result.result.state.background_id.as_ref() {
1030            session.background_ids.lock().await.insert(id.clone());
1031        }
1032    }
1033    capture_session_outputs_locked(session).await;
1034    let snapshot =
1035        session.state.lock().await.as_ref().ok_or(WinxError::BashStateNotInitialized)?.snapshot();
1036    let result = match outcome {
1037        Ok(result) => {
1038            let command_generation = result.command_generation;
1039            let execution_token = command_generation.map(|generation| ShellExecutionToken {
1040                guardian_epoch: guardian_epoch.to_string(),
1041                session_epoch,
1042                generation,
1043            });
1044            // Compact and legacy are mutually exclusive on the wire. Compact
1045            // rendering leaves the stable legacy string empty at runtime.
1046            let (output, compact_output) = match result.compact_output {
1047                Some(compact) => (None, Some(compact)),
1048                None => (Some(result.result.output), None),
1049            };
1050            RunActionResult {
1051                output,
1052                compact_output,
1053                command_generation,
1054                execution_token,
1055                dropped_output_file: result.dropped_output_file,
1056                output_truncated: result.output_truncated,
1057                state: Some(result.result.state),
1058                snapshot,
1059                error: None,
1060            }
1061        }
1062        Err(error) => RunActionResult {
1063            output: None,
1064            compact_output: None,
1065            command_generation: None,
1066            execution_token: None,
1067            dropped_output_file: None,
1068            output_truncated: false,
1069            state: None,
1070            snapshot,
1071            error: Some(to_wire_error(error)),
1072        },
1073    };
1074
1075    session.activity.notify_one();
1076    Ok(result)
1077}
1078
1079async fn foreground_admission_busy_result(
1080    session: &Arc<DaemonSession>,
1081    fallback_snapshot: BashStateSnapshot,
1082) -> Result<RunActionResult> {
1083    let state = tokio::time::timeout(FOREGROUND_ADMISSION_TIMEOUT, async {
1084        session.state.lock().await.as_ref().map(|state| (state.snapshot(), state.pty_shell.clone()))
1085    })
1086    .await
1087    .ok()
1088    .flatten();
1089    let (snapshot, shell) =
1090        state.map_or((fallback_snapshot, None), |(snapshot, shell)| (snapshot, Some(shell)));
1091    let active = if let Some(shell) = shell {
1092        tokio::time::timeout(FOREGROUND_ADMISSION_TIMEOUT, shell.lock()).await.ok().and_then(
1093            |guard| {
1094                guard.as_ref().filter(|shell| shell.command_running).map(|shell| {
1095                    (
1096                        shell.last_command.clone(),
1097                        shell.command_elapsed().map_or(0.0, |elapsed| elapsed.as_secs_f64()),
1098                    )
1099                })
1100            },
1101        )
1102    } else {
1103        None
1104    };
1105    let (current_command, duration_seconds) = active
1106        .unwrap_or_else(|| ("another operation in this shared workspace session".to_string(), 0.0));
1107    session.note_activity(false);
1108    Ok(RunActionResult {
1109        output: None,
1110        compact_output: None,
1111        command_generation: None,
1112        execution_token: None,
1113        dropped_output_file: None,
1114        output_truncated: false,
1115        state: None,
1116        snapshot,
1117        error: Some(WireShellError::CommandAlreadyRunning { current_command, duration_seconds }),
1118    })
1119}
1120
1121fn prune_action_entries(entries: &mut HashMap<String, ActionEntry>, now: Instant) {
1122    entries.retain(|_, entry| {
1123        !action_entry_is_terminal(entry) || now.duration_since(entry.last_seen) <= SESSION_CACHE_TTL
1124    });
1125}
1126
1127fn action_entry_is_terminal(entry: &ActionEntry) -> bool {
1128    match &entry.completion {
1129        ActionCompletion::Complete(_) => true,
1130        ActionCompletion::InFlight(completion) => completion.borrow().is_some(),
1131    }
1132}
1133
1134fn evict_oldest_action_if_full(entries: &mut HashMap<String, ActionEntry>) -> bool {
1135    if entries.len() < MAX_SESSION_CACHE_ENTRIES {
1136        return true;
1137    }
1138    if let Some(oldest) = entries
1139        .iter()
1140        .filter(|(_, entry)| action_entry_is_terminal(entry))
1141        .min_by_key(|(_, entry)| entry.last_seen)
1142        .map(|(key, _)| key.clone())
1143    {
1144        entries.remove(&oldest);
1145    }
1146    entries.len() < MAX_SESSION_CACHE_ENTRIES
1147}
1148
1149fn delivery_cursor_key(consumer_id: &str, action: &crate::types::BashCommandAction) -> String {
1150    let background_id = match action {
1151        crate::types::BashCommandAction::StatusCheck { bg_command_id, .. }
1152        | crate::types::BashCommandAction::SendText { bg_command_id, .. }
1153        | crate::types::BashCommandAction::SendSpecials { bg_command_id, .. }
1154        | crate::types::BashCommandAction::SendAscii { bg_command_id, .. }
1155        | crate::types::BashCommandAction::Screen { bg_command_id, .. }
1156        | crate::types::BashCommandAction::WaitForTurn { bg_command_id, .. } => {
1157            bg_command_id.as_deref()
1158        }
1159        crate::types::BashCommandAction::Command { .. } => None,
1160    };
1161    match background_id {
1162        Some(id) => format!("{consumer_id}:background:{id}"),
1163        None => format!("{consumer_id}:main"),
1164    }
1165}
1166
1167fn ensure_drainer(session: &Arc<DaemonSession>) {
1168    if session.drainer_started.swap(true, Ordering::AcqRel) {
1169        session.activity.notify_one();
1170        return;
1171    }
1172    let session = session.clone();
1173    tokio::spawn(async move {
1174        loop {
1175            if Arc::strong_count(&session) == 1 {
1176                return;
1177            }
1178            let capture = capture_session_outputs(&session).await;
1179            tokio::select! {
1180                () = tokio::time::sleep(capture.next_delay()) => {}
1181                () = session.activity.notified() => {}
1182            }
1183        }
1184    });
1185}
1186
1187async fn capture_session_outputs(session: &Arc<DaemonSession>) -> CaptureState {
1188    let _operation = session.operation_barrier.read().await;
1189    capture_session_outputs_locked(session).await
1190}
1191
1192async fn capture_session_outputs_locked(session: &Arc<DaemonSession>) -> CaptureState {
1193    let (thread_id, main) = {
1194        let state = session.state.lock().await;
1195        let Some(state) = state.as_ref() else { return CaptureState::default() };
1196        (state.current_thread_id.clone(), state.pty_shell.clone())
1197    };
1198    let mut capture = capture_shell_output(session, "main", &main).await;
1199
1200    let background_ids = session.background_ids.lock().await.clone();
1201    for id in background_ids {
1202        let shell = {
1203            let store = lock_session_store();
1204            store.resolve(&thread_id, &ShellTarget::Background(id.clone()))
1205        };
1206        if let Some(shell) = shell {
1207            capture.merge(capture_shell_output(session, &id, &shell).await);
1208        }
1209    }
1210    capture
1211}
1212
1213async fn capture_shell_output(
1214    session: &Arc<DaemonSession>,
1215    terminal_id: &str,
1216    shell: &SharedPtyShell,
1217) -> CaptureState {
1218    let (generation, snapshot, active) = {
1219        let mut guard = shell.lock().await;
1220        let Some(shell) = guard.as_mut() else { return CaptureState::default() };
1221        shell.poll_output_nonblocking();
1222        (shell.command_generation(), shell.output_snapshot(), shell.command_running)
1223    };
1224    let delta = {
1225        let mut observed = session.observed.lock().await;
1226        let (previous_generation, previous) =
1227            observed.entry(terminal_id.to_string()).or_insert_with(|| (generation, String::new()));
1228        if *previous_generation != generation {
1229            *previous_generation = generation;
1230            previous.clear();
1231        }
1232        let delta = snapshot
1233            .strip_prefix(previous.as_str())
1234            .map_or_else(|| snapshot.clone(), str::to_string);
1235        previous.clone_from(&snapshot);
1236        delta
1237    };
1238    let changed = !delta.is_empty();
1239    if changed {
1240        session.last_activity_unix_ms.store(unix_ms(), Ordering::Release);
1241        session.journal.lock().await.append(delta);
1242    }
1243    CaptureState { changed, active }
1244}
1245
1246async fn session_info(session: &Arc<DaemonSession>) -> Option<SessionInfo> {
1247    let _operation = session.operation_barrier.read().await;
1248    let (thread_id, cwd, main) = {
1249        let state = session.state.lock().await;
1250        let state = state.as_ref()?;
1251        (
1252            state.current_thread_id.clone(),
1253            state.cwd.to_string_lossy().into_owned(),
1254            state.pty_shell.clone(),
1255        )
1256    };
1257    let (shell_pid, running) = {
1258        let guard = main.lock().await;
1259        guard.as_ref().map_or((None, false), |shell| (shell.process_id(), shell.command_running))
1260    };
1261    let background_command_ids = active_background_command_ids(session, &thread_id).await;
1262    let last_command_at_unix_ms = session.last_command_at_unix_ms.load(Ordering::Acquire);
1263    Some(SessionInfo {
1264        thread_id,
1265        cwd,
1266        shell_pid,
1267        command_id: session.command_id.lock().await.clone(),
1268        running,
1269        background_command_ids,
1270        created_at_unix_ms: Some(session.created_at_unix_ms),
1271        last_activity_unix_ms: Some(session.last_activity_unix_ms.load(Ordering::Acquire)),
1272        last_command_at_unix_ms: (last_command_at_unix_ms > 0).then_some(last_command_at_unix_ms),
1273        ever_ran_command: session.ever_ran_command.load(Ordering::Acquire),
1274        runtime: None,
1275    })
1276}
1277
1278async fn active_background_command_ids(
1279    session: &Arc<DaemonSession>,
1280    thread_id: &str,
1281) -> Vec<String> {
1282    let candidates = session.background_ids.lock().await.clone();
1283    let mut active = Vec::new();
1284    for id in candidates {
1285        let shell = {
1286            let store = lock_session_store();
1287            store.resolve(thread_id, &ShellTarget::Background(id.clone()))
1288        };
1289        let Some(shell) = shell else { continue };
1290        // Capture the final queued bytes before deciding that a completed shell is
1291        // no longer active. Otherwise pruning the id here could make the journal
1292        // drainer stop one poll too early.
1293        capture_shell_output(session, &id, &shell).await;
1294        let running = {
1295            let guard = shell.lock().await;
1296            guard.as_ref().is_some_and(|shell| shell.command_running)
1297        };
1298        if running {
1299            active.push(id);
1300        }
1301    }
1302
1303    let active_set = active.iter().cloned().collect::<HashSet<_>>();
1304    session.background_ids.lock().await.retain(|id| active_set.contains(id));
1305    active.sort();
1306    active
1307}
1308
1309async fn interrupt_session(
1310    session: &Arc<DaemonSession>,
1311    params: &SessionParams,
1312    guardian_epoch: &str,
1313) -> Result<bool> {
1314    session.note_activity(false);
1315    let _operation = session.operation_barrier.read().await;
1316    if params.expected_guardian_epoch.as_deref().is_some_and(|expected| expected != guardian_epoch)
1317        || params
1318            .expected_execution
1319            .as_ref()
1320            .is_some_and(|expected| expected.guardian_epoch != guardian_epoch)
1321    {
1322        return Ok(false);
1323    }
1324    let session_epoch = session.session_epoch.lock().await;
1325    if params.expected_execution.as_ref().is_some_and(|expected| {
1326        expected.session_epoch != *session_epoch
1327            || params
1328                .expected_generation
1329                .is_some_and(|generation| generation != expected.generation)
1330    }) {
1331        return Ok(false);
1332    }
1333    drop(session_epoch);
1334    let shell = {
1335        let state = session.state.lock().await;
1336        state.as_ref().map(|state| state.pty_shell.clone())
1337    };
1338    let Some(shell) = shell else { return Ok(false) };
1339    let interrupted =
1340        crate::runtime::interrupt_pty_until_recovered(&shell, params.expected_generation).await?;
1341    if interrupted {
1342        capture_session_outputs_locked(session).await;
1343    }
1344    Ok(interrupted)
1345}
1346
1347fn unix_ms() -> u64 {
1348    SystemTime::now()
1349        .duration_since(UNIX_EPOCH)
1350        .ok()
1351        .and_then(|duration| u64::try_from(duration.as_millis()).ok())
1352        .unwrap_or(0)
1353}
1354
1355fn to_wire_error(error: WinxError) -> WireShellError {
1356    match error {
1357        WinxError::BashStateNotInitialized => WireShellError::BashStateNotInitialized,
1358        WinxError::ShellInitializationError(message) => {
1359            WireShellError::ShellInitialization(message)
1360        }
1361        WinxError::CommandExecutionError(message) => WireShellError::CommandExecution(message),
1362        WinxError::NoActiveCommand(message) => WireShellError::NoActiveCommand(message),
1363        WinxError::BackgroundSessionNotFound(message) => {
1364            WireShellError::BackgroundSessionNotFound(message)
1365        }
1366        WinxError::EmptyInteractiveInput { action } => {
1367            WireShellError::EmptyInteractiveInput(action)
1368        }
1369        WinxError::InteractiveTargetNotRunning(message) => {
1370            WireShellError::InteractiveTargetNotRunning(message)
1371        }
1372        WinxError::CommandAlreadyRunning { current_command, duration_seconds } => {
1373            WireShellError::CommandAlreadyRunning { current_command, duration_seconds }
1374        }
1375        WinxError::CommandNotAllowed(message) => WireShellError::CommandNotAllowed(message),
1376        WinxError::ThreadIdMismatch(message) => WireShellError::ThreadIdMismatch(message),
1377        WinxError::InvalidInput(message) | WinxError::ArgumentParseError(message) => {
1378            WireShellError::InvalidInput(message)
1379        }
1380        other => WireShellError::Other(other.to_string()),
1381    }
1382}
1383
1384fn rpc_result<T: serde::Serialize>(id: u64, result: &T) -> RpcResponse {
1385    match serde_json::to_value(result) {
1386        Ok(result) => {
1387            RpcResponse { jsonrpc: "2.0".to_string(), id, result: Some(result), error: None }
1388        }
1389        Err(error) => rpc_error(id, -32603, &error.to_string()),
1390    }
1391}
1392
1393fn rpc_error(id: u64, code: i32, message: &str) -> RpcResponse {
1394    RpcResponse {
1395        jsonrpc: "2.0".to_string(),
1396        id,
1397        result: None,
1398        error: Some(RpcError { code, message: message.to_string() }),
1399    }
1400}
1401
1402/// Answer the in-flight request, then exit. Guardians own PTYs that die with
1403/// them, so this is only requested after `session.kill` drained the session.
1404pub(crate) fn schedule_process_exit(role: &'static str) {
1405    tokio::spawn(async move {
1406        tokio::time::sleep(std::time::Duration::from_millis(150)).await;
1407        tracing::info!("{role} exiting on winx.shutdown request");
1408        std::process::exit(0);
1409    });
1410}
1411
1412#[cfg(test)]
1413mod tests {
1414    #![allow(clippy::expect_used)]
1415    use std::collections::HashMap;
1416    use std::sync::atomic::Ordering;
1417    use std::sync::Arc;
1418    use std::time::{Duration, Instant};
1419
1420    use tokio::sync::{watch, Mutex};
1421
1422    use super::{
1423        canonical_action_fingerprint, configure_session, consume_launch_cancellation_flag,
1424        interrupt_session, launch_cancellation_flag, run_action, validate_identifier,
1425        ActionCompletion, ActionEntry, ActionOwnerGuard, CaptureState, DaemonSession,
1426        OutputJournal, MAX_SESSION_CACHE_ENTRIES,
1427    };
1428    use crate::daemon::protocol::{
1429        ConfigureSessionParams, ConfigureSessionTransition, RunActionParams, SessionParams,
1430        WireShellError,
1431    };
1432    use crate::errors::WinxError;
1433    use crate::runtime::ShellActionOptions;
1434    use crate::state::bash_state::BashState;
1435    use crate::types::BashCommand;
1436
1437    /// Spell a fixture path for the shell under test. Git for Windows bash
1438    /// treats `\` in an unquoted word as an escape, so Windows paths are
1439    /// handed over with forward slashes, which every MSYS tool accepts.
1440    fn shell_path(path: &std::path::Path) -> String {
1441        crate::utils::path::display_relative(path)
1442    }
1443
1444    fn action_params(state: &BashState, command: &str, request_key: &str) -> RunActionParams {
1445        let command: BashCommand = serde_json::from_value(serde_json::json!({
1446            "action_json": {
1447                "type": "command",
1448                "command": command,
1449                "is_background": false,
1450                "allow_multi": true
1451            },
1452            "thread_id": state.current_thread_id,
1453            "wait_for_seconds": 2.0
1454        }))
1455        .expect("valid command");
1456        RunActionParams {
1457            snapshot: state.snapshot(),
1458            command,
1459            request_key: request_key.to_string(),
1460            consumer_id: "single-flight-test".to_string(),
1461            options: ShellActionOptions::default(),
1462        }
1463    }
1464
1465    #[tokio::test]
1466    async fn existing_session_attach_bypasses_busy_operation_barrier() {
1467        let thread_id = "busy_attach";
1468        let session = Arc::new(DaemonSession::new());
1469        let mut state = BashState::new();
1470        state.current_thread_id = thread_id.to_string();
1471        state.initialized = true;
1472        let snapshot = state.snapshot();
1473        *session.state.lock().await = Some(state);
1474
1475        let sessions =
1476            Arc::new(Mutex::new(HashMap::from([(thread_id.to_string(), Arc::clone(&session))])));
1477        let _active_operation = session.operation_barrier.write().await;
1478        let attached = tokio::time::timeout(
1479            Duration::from_millis(250),
1480            configure_session(
1481                &sessions,
1482                ConfigureSessionParams {
1483                    snapshot,
1484                    transition: ConfigureSessionTransition::FirstCall,
1485                },
1486            ),
1487        )
1488        .await
1489        .expect("attach must not queue behind the active shell operation")
1490        .expect("attach existing session");
1491
1492        assert!(attached.attached_existing);
1493        assert_eq!(attached.snapshot.expect("authoritative daemon snapshot").chat_id, thread_id);
1494    }
1495
1496    #[test]
1497    fn late_consumer_is_told_when_journal_head_was_dropped() {
1498        let mut journal = OutputJournal::default();
1499        journal.append("a".repeat(3 * 1024 * 1024));
1500        journal.append("b".repeat(3 * 1024 * 1024));
1501
1502        let read = journal.read("late");
1503        assert!(read.gap);
1504        assert!(read.output.starts_with('b'));
1505    }
1506
1507    #[test]
1508    fn journal_consumer_cursors_are_lru_bounded() {
1509        let mut journal = OutputJournal::default();
1510        journal.append("output".to_string());
1511        for index in 0..(MAX_SESSION_CACHE_ENTRIES + 100) {
1512            let _ = journal.read(&format!("consumer-{index}"));
1513        }
1514        assert_eq!(journal.cursors.len(), MAX_SESSION_CACHE_ENTRIES);
1515    }
1516
1517    #[test]
1518    fn daemon_identifiers_have_a_small_memory_bound() {
1519        assert!(validate_identifier("consumer_id", &"x".repeat(256)).is_ok());
1520        assert!(validate_identifier("consumer_id", &"x".repeat(257)).is_err());
1521    }
1522
1523    #[test]
1524    fn drainer_backoff_tracks_output_and_command_activity() {
1525        assert!(
1526            CaptureState { changed: true, active: false }.next_delay()
1527                < CaptureState { changed: false, active: true }.next_delay()
1528        );
1529        assert!(
1530            CaptureState { changed: false, active: true }.next_delay()
1531                < CaptureState { changed: false, active: false }.next_delay()
1532        );
1533    }
1534
1535    // Real Git Bash cold-start latency on shared Windows CI runners makes this
1536    // timing-sensitive real-shell test flaky; it is verified on real Windows hosts
1537    // and runs in the Unix CI matrix.
1538    #[cfg_attr(windows, ignore = "flaky under Git Bash cold start on shared Windows CI runners")]
1539    #[tokio::test]
1540    async fn request_key_is_single_flight_and_replays_lost_response() {
1541        let temp = tempfile::tempdir().expect("temporary directory");
1542        let marker = temp.path().join("single-flight.marker");
1543        let mut state = BashState::new();
1544        state.cwd = temp.path().to_path_buf();
1545        state.workspace_root = temp.path().to_path_buf();
1546        state.current_thread_id = "single_flight".to_string();
1547        let command = format!("printf x >> '{}'; sleep 0.1", shell_path(&marker));
1548        let params = action_params(&state, &command, "same-request");
1549        let sessions = Arc::new(Mutex::new(HashMap::<String, Arc<DaemonSession>>::new()));
1550
1551        let (first, concurrent) = tokio::join!(
1552            run_action(&sessions, params.clone(), "guardian-a"),
1553            run_action(&sessions, params.clone(), "guardian-a")
1554        );
1555        assert!(first.expect("owner result").error.is_none());
1556        assert!(concurrent.expect("shared result").error.is_none());
1557        let replay = run_action(&sessions, params, "guardian-a").await.expect("cached replay");
1558        assert!(replay.error.is_none());
1559        assert_eq!(std::fs::read_to_string(marker).expect("marker"), "x");
1560    }
1561
1562    #[tokio::test]
1563    async fn request_key_collision_rejects_different_parameters() {
1564        let temp = tempfile::tempdir().expect("temporary directory");
1565        let mut state = BashState::new();
1566        state.cwd = temp.path().to_path_buf();
1567        state.workspace_root = temp.path().to_path_buf();
1568        state.current_thread_id = "request_collision".to_string();
1569        let sessions = Arc::new(Mutex::new(HashMap::<String, Arc<DaemonSession>>::new()));
1570        run_action(&sessions, action_params(&state, "printf first", "collision"), "guardian-a")
1571            .await
1572            .expect("first action");
1573
1574        let error = run_action(
1575            &sessions,
1576            action_params(&state, "printf second", "collision"),
1577            "guardian-a",
1578        )
1579        .await
1580        .expect_err("collision must fail");
1581        assert!(error.to_string().contains("different shell action parameters"));
1582    }
1583
1584    #[tokio::test]
1585    async fn non_launching_action_paths_consume_unowned_cancellation_tombstones() {
1586        let temp = tempfile::tempdir().expect("temporary directory");
1587        let mut state = BashState::new();
1588        state.cwd = temp.path().to_path_buf();
1589        state.workspace_root = temp.path().to_path_buf();
1590        state.current_thread_id = "early_cancellation_cleanup".to_string();
1591        let session = Arc::new(DaemonSession::new());
1592        let sessions = Arc::new(Mutex::new(HashMap::from([(
1593            state.current_thread_id.clone(),
1594            Arc::clone(&session),
1595        )])));
1596
1597        let mut replay = action_params(&state, "printf replay", "replay-request");
1598        replay.options.cancellation_key = Some("replay-cancel".to_string());
1599        run_action(&sessions, replay.clone(), "guardian-a").await.expect("initial replay action");
1600        let replay_tombstone =
1601            launch_cancellation_flag(&session, "replay-cancel").await.expect("replay tombstone");
1602        replay_tombstone.store(true, Ordering::Release);
1603        drop(replay_tombstone);
1604        run_action(&sessions, replay, "guardian-a").await.expect("cached replay");
1605        assert!(!session.launch_reservations.lock().await.contains_key("replay-cancel"));
1606
1607        let mut first_collision =
1608            action_params(&state, "printf collision-one", "collision-request");
1609        first_collision.options.cancellation_key = Some("collision-cancel".to_string());
1610        run_action(&sessions, first_collision, "guardian-a")
1611            .await
1612            .expect("initial collision action");
1613        let collision_tombstone = launch_cancellation_flag(&session, "collision-cancel")
1614            .await
1615            .expect("collision tombstone");
1616        collision_tombstone.store(true, Ordering::Release);
1617        drop(collision_tombstone);
1618        let mut collision = action_params(&state, "printf collision-two", "collision-request");
1619        collision.options.cancellation_key = Some("collision-cancel".to_string());
1620        assert!(run_action(&sessions, collision, "guardian-a").await.is_err());
1621        assert!(!session.launch_reservations.lock().await.contains_key("collision-cancel"));
1622
1623        {
1624            let mut completed = session.completed.lock().await;
1625            completed.clear();
1626            for index in 0..MAX_SESSION_CACHE_ENTRIES {
1627                completed.insert(
1628                    format!("working-{index}"),
1629                    ActionEntry {
1630                        fingerprint: vec![u8::try_from(index % 255).unwrap_or_default()],
1631                        cancellation_key: None,
1632                        completion: ActionCompletion::InFlight(Arc::new(watch::channel(None).0)),
1633                        last_seen: Instant::now(),
1634                    },
1635                );
1636            }
1637        }
1638        let saturation_tombstone = launch_cancellation_flag(&session, "saturation-cancel")
1639            .await
1640            .expect("saturation tombstone");
1641        saturation_tombstone.store(true, Ordering::Release);
1642        drop(saturation_tombstone);
1643        let mut saturated = action_params(&state, "printf blocked", "overflow-request");
1644        saturated.options.cancellation_key = Some("saturation-cancel".to_string());
1645        assert!(matches!(
1646            run_action(&sessions, saturated, "guardian-a").await,
1647            Err(WinxError::ResourceAllocationError { .. })
1648        ));
1649        assert!(!session.launch_reservations.lock().await.contains_key("saturation-cancel"));
1650    }
1651
1652    // Real Git Bash cold-start latency on shared Windows CI runners makes this
1653    // timing-sensitive real-shell test flaky; it is verified on real Windows hosts
1654    // and runs in the Unix CI matrix.
1655    #[cfg_attr(windows, ignore = "flaky under Git Bash cold start on shared Windows CI runners")]
1656    #[tokio::test]
1657    async fn action_waiter_does_not_consume_the_owners_cancellation_reservation() {
1658        let temp = tempfile::tempdir().expect("temporary directory");
1659        let started = temp.path().join("single-flight-started.marker");
1660        let release = temp.path().join("single-flight-release.marker");
1661        let mut state = BashState::new();
1662        state.cwd = temp.path().to_path_buf();
1663        state.workspace_root = temp.path().to_path_buf();
1664        state.current_thread_id = "waiter_cancellation_owner".to_string();
1665        let session = Arc::new(DaemonSession::new());
1666        let sessions = Arc::new(Mutex::new(HashMap::from([(
1667            state.current_thread_id.clone(),
1668            Arc::clone(&session),
1669        )])));
1670        let mut params = action_params(
1671            &state,
1672            &format!(
1673                "sh -c 'touch \"{}\"; while [ ! -e \"{}\" ]; do sleep 0.01; done'",
1674                shell_path(&started),
1675                shell_path(&release)
1676            ),
1677            "shared-cancel-request",
1678        );
1679        params.options.cancellation_key = Some("shared-cancel-key".to_string());
1680        // The owner consumes its reservation as soon as `execute_action`
1681        // returns, which also happens when the inline wait expires with the
1682        // command still running. The default 2s window coincides with the
1683        // startup timeout below, so a cold CI runner could expire it right
1684        // after the marker appeared and the mid-flight assertion would find
1685        // the reservation already released. The command only ends when this
1686        // test writes the release marker, so a long inline wait is safe.
1687        params.command.wait_for_seconds = Some(30.0);
1688
1689        let owner_sessions = Arc::clone(&sessions);
1690        let owner_params = params.clone();
1691        let owner =
1692            tokio::spawn(
1693                async move { run_action(&owner_sessions, owner_params, "guardian-a").await },
1694            );
1695        tokio::time::timeout(Duration::from_secs(10), async {
1696            while !started.exists() {
1697                tokio::time::sleep(Duration::from_millis(5)).await;
1698            }
1699        })
1700        .await
1701        .expect("owner command never started");
1702
1703        let waiter_sessions = Arc::clone(&sessions);
1704        let waiter =
1705            tokio::spawn(async move { run_action(&waiter_sessions, params, "guardian-a").await });
1706        tokio::time::sleep(Duration::from_millis(25)).await;
1707        assert!(
1708            session.launch_reservations.lock().await.contains_key("shared-cancel-key"),
1709            "a waiter consumed the owner's launch reservation"
1710        );
1711
1712        std::fs::write(release, b"release").expect("release owner command");
1713        owner.await.expect("owner join").expect("owner result");
1714        waiter.await.expect("waiter join").expect("waiter result");
1715        assert!(!session.launch_reservations.lock().await.contains_key("shared-cancel-key"));
1716    }
1717
1718    #[test]
1719    fn action_fingerprint_is_canonical_across_map_insertion_order() {
1720        let mut first = BashState::new();
1721        first.current_thread_id = "canonical".to_string();
1722        first.whitelist_for_overwrite.insert(
1723            "z".to_string(),
1724            crate::state::bash_state::FileWhitelistData::new("z".into(), vec![(1, 1)], 1),
1725        );
1726        first.whitelist_for_overwrite.insert(
1727            "a".to_string(),
1728            crate::state::bash_state::FileWhitelistData::new("a".into(), vec![(1, 1)], 1),
1729        );
1730        let mut second = BashState::new();
1731        second.current_thread_id = "canonical".to_string();
1732        second.whitelist_for_overwrite.insert(
1733            "a".to_string(),
1734            crate::state::bash_state::FileWhitelistData::new("a".into(), vec![(1, 1)], 1),
1735        );
1736        second.whitelist_for_overwrite.insert(
1737            "z".to_string(),
1738            crate::state::bash_state::FileWhitelistData::new("z".into(), vec![(1, 1)], 1),
1739        );
1740
1741        assert_eq!(
1742            canonical_action_fingerprint(&action_params(&first, "printf stable", "key"))
1743                .expect("first fingerprint"),
1744            canonical_action_fingerprint(&action_params(&second, "printf stable", "key"))
1745                .expect("second fingerprint")
1746        );
1747    }
1748
1749    #[tokio::test]
1750    async fn action_cache_applies_backpressure_when_every_entry_is_in_flight() {
1751        let state = BashState::new();
1752        let session = Arc::new(DaemonSession::new());
1753        {
1754            let mut completed = session.completed.lock().await;
1755            for index in 0..MAX_SESSION_CACHE_ENTRIES {
1756                completed.insert(
1757                    format!("working-{index}"),
1758                    ActionEntry {
1759                        fingerprint: vec![u8::try_from(index % 255).unwrap_or_default()],
1760                        cancellation_key: None,
1761                        completion: ActionCompletion::InFlight(Arc::new(watch::channel(None).0)),
1762                        last_seen: Instant::now(),
1763                    },
1764                );
1765            }
1766        }
1767        let sessions =
1768            Arc::new(Mutex::new(HashMap::from([(state.current_thread_id.clone(), session)])));
1769        let error = run_action(
1770            &sessions,
1771            action_params(&state, "printf blocked", "overflow"),
1772            "guardian-a",
1773        )
1774        .await
1775        .expect_err("all-in-flight cache must reject new reservations");
1776        assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1777    }
1778
1779    #[tokio::test]
1780    async fn cancellation_reservations_never_evict_active_flags() {
1781        let session = DaemonSession::new();
1782        let mut active = Vec::new();
1783        for index in 0..MAX_SESSION_CACHE_ENTRIES {
1784            active.push(
1785                launch_cancellation_flag(&session, &format!("active-{index}"))
1786                    .await
1787                    .expect("active cancellation reservation"),
1788            );
1789        }
1790        let error = launch_cancellation_flag(&session, "overflow")
1791            .await
1792            .expect_err("all active cancellation flags must apply backpressure");
1793        assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1794        assert_eq!(session.launch_reservations.lock().await.len(), MAX_SESSION_CACHE_ENTRIES);
1795
1796        drop(active.pop());
1797        launch_cancellation_flag(&session, "overflow")
1798            .await
1799            .expect("inactive cancellation flag should be evictable");
1800        assert_eq!(session.launch_reservations.lock().await.len(), MAX_SESSION_CACHE_ENTRIES);
1801    }
1802
1803    #[tokio::test]
1804    async fn cancelled_tombstones_apply_backpressure_until_consumed() {
1805        let session = DaemonSession::new();
1806        for index in 0..MAX_SESSION_CACHE_ENTRIES {
1807            let flag = launch_cancellation_flag(&session, &format!("cancelled-{index}"))
1808                .await
1809                .expect("cancel tombstone");
1810            flag.store(true, Ordering::Release);
1811        }
1812
1813        let error = launch_cancellation_flag(&session, "overflow")
1814            .await
1815            .expect_err("cancel tombstones must not be evicted for capacity");
1816        assert!(matches!(error, WinxError::ResourceAllocationError { .. }));
1817
1818        let consumed = session
1819            .launch_reservations
1820            .lock()
1821            .await
1822            .get("cancelled-0")
1823            .expect("preserved tombstone")
1824            .value
1825            .clone();
1826        consume_launch_cancellation_flag(&session, "cancelled-0", &consumed).await;
1827        launch_cancellation_flag(&session, "replacement")
1828            .await
1829            .expect("consumed tombstone releases capacity");
1830    }
1831
1832    #[tokio::test]
1833    async fn cancel_before_action_survives_capacity_churn_and_prevents_launch() {
1834        let temp = tempfile::tempdir().expect("temporary directory");
1835        let marker = temp.path().join("cancelled-before-action.marker");
1836        let mut state = BashState::new();
1837        state.cwd = temp.path().to_path_buf();
1838        state.workspace_root = temp.path().to_path_buf();
1839        state.current_thread_id = "cancel_tombstone_churn".to_string();
1840        let session = Arc::new(DaemonSession::new());
1841        let sessions = Arc::new(Mutex::new(HashMap::from([(
1842            state.current_thread_id.clone(),
1843            Arc::clone(&session),
1844        )])));
1845
1846        let cancelled = launch_cancellation_flag(&session, "cancel-before-action")
1847            .await
1848            .expect("cancel reservation");
1849        cancelled.store(true, Ordering::Release);
1850        drop(cancelled);
1851        for index in 0..(MAX_SESSION_CACHE_ENTRIES * 2) {
1852            drop(
1853                launch_cancellation_flag(&session, &format!("churn-{index}"))
1854                    .await
1855                    .expect("inactive churn reservation"),
1856            );
1857        }
1858        assert!(
1859            session
1860                .launch_reservations
1861                .lock()
1862                .await
1863                .get("cancel-before-action")
1864                .is_some_and(|entry| entry.value.load(Ordering::Acquire)),
1865            "capacity churn evicted the cancellation tombstone"
1866        );
1867
1868        let mut params = action_params(
1869            &state,
1870            &format!("printf forbidden > '{}'", shell_path(&marker)),
1871            "cancelled-action-request",
1872        );
1873        params.options.cancellation_key = Some("cancel-before-action".to_string());
1874        let result =
1875            run_action(&sessions, params, "guardian-a").await.expect("cancelled action result");
1876        assert!(matches!(
1877            result.error,
1878            Some(WireShellError::CommandExecution(ref message))
1879                if message.contains("cancelled before command launch")
1880        ));
1881        assert!(!marker.exists(), "cancelled action reached the PTY");
1882        assert!(
1883            !session.launch_reservations.lock().await.contains_key("cancel-before-action"),
1884            "the action did not consume its cancellation tombstone"
1885        );
1886    }
1887
1888    // Real Git Bash cold-start latency on shared Windows CI runners makes this
1889    // timing-sensitive real-shell test flaky; it is verified on real Windows hosts
1890    // and runs in the Unix CI matrix.
1891    #[cfg_attr(windows, ignore = "flaky under Git Bash cold start on shared Windows CI runners")]
1892    #[tokio::test]
1893    async fn automatic_pty_reset_rotates_before_stale_operations_reach_the_new_pty() {
1894        let temp = tempfile::tempdir().expect("temporary directory");
1895        let marker = temp.path().join("new-incarnation-running.marker");
1896        let completed_marker = temp.path().join("new-incarnation-completed.marker");
1897        let mut state = BashState::new();
1898        state.cwd = temp.path().to_path_buf();
1899        state.workspace_root = temp.path().to_path_buf();
1900        state.current_thread_id = "automatic_pty_reset".to_string();
1901        let sessions = Arc::new(Mutex::new(HashMap::new()));
1902
1903        let first = run_action(
1904            &sessions,
1905            action_params(&state, "printf first-incarnation", "before-auto-reset"),
1906            "guardian-a",
1907        )
1908        .await
1909        .expect("first action");
1910        let prior_token = first.execution_token.expect("first execution token");
1911        let session =
1912            sessions.lock().await.get("automatic_pty_reset").expect("daemon session").clone();
1913
1914        let mut forced = action_params(
1915            &state,
1916            &format!(
1917                "sh -c 'touch \"{}\"; sleep 0.4; printf completed > \"{}\"'",
1918                shell_path(&marker),
1919                shell_path(&completed_marker)
1920            ),
1921            "forced-auto-reset",
1922        );
1923        forced.options.force_clear_to_run_failure = true;
1924        let reset_sessions = Arc::clone(&sessions);
1925        let reset =
1926            tokio::spawn(async move { run_action(&reset_sessions, forced, "guardian-a").await });
1927        tokio::time::timeout(Duration::from_secs(2), async {
1928            while !marker.exists() {
1929                tokio::time::sleep(Duration::from_millis(5)).await;
1930            }
1931        })
1932        .await
1933        .expect("replacement PTY command never started");
1934        assert_ne!(
1935            *session.session_epoch.lock().await,
1936            prior_token.session_epoch,
1937            "epoch was not rotated before the replacement command launched"
1938        );
1939        assert!(!reset.is_finished(), "reset action returned before the interleaving probe");
1940
1941        let mut stale_status = action_params(&state, "printf unused", "stale-after-auto-reset");
1942        stale_status.command = serde_json::from_value(serde_json::json!({
1943            "action_json": {"type": "status_check", "status_check": true},
1944            "thread_id": "automatic_pty_reset",
1945            "wait_for_seconds": 0.0
1946        }))
1947        .expect("status command");
1948        stale_status.options.expected_generation = Some(prior_token.generation);
1949        stale_status.options.expected_execution = Some(prior_token.clone());
1950        assert!(run_action(&sessions, stale_status, "guardian-a").await.is_err());
1951
1952        assert!(!interrupt_session(
1953            &session,
1954            &SessionParams {
1955                thread_id: "automatic_pty_reset".to_string(),
1956                expected_generation: Some(prior_token.generation),
1957                expected_execution: Some(prior_token.clone()),
1958                expected_guardian_epoch: Some("guardian-a".to_string()),
1959            },
1960            "guardian-a",
1961        )
1962        .await
1963        .expect("stale interrupt result"));
1964
1965        let reset = reset.await.expect("reset action join").expect("reset action result");
1966        let current = reset.execution_token.clone().expect("reset execution token");
1967        assert_eq!(prior_token.generation, current.generation);
1968        assert_ne!(prior_token.session_epoch, current.session_epoch);
1969        assert_eq!(
1970            std::fs::read_to_string(completed_marker).expect("replacement completion marker"),
1971            "completed",
1972            "a stale operation disturbed the replacement command: {reset:?}"
1973        );
1974    }
1975
1976    #[tokio::test]
1977    async fn abandoned_action_owner_notifies_waiters() {
1978        let completion = Arc::new(watch::channel(None).0);
1979        let receiver = completion.subscribe();
1980        drop(ActionOwnerGuard::new(completion));
1981        let error = super::wait_for_action_completion(receiver)
1982            .await
1983            .expect_err("abandoned owner must fail its waiters");
1984        assert!(error.to_string().contains("owner disappeared"));
1985    }
1986
1987    #[tokio::test]
1988    #[allow(clippy::too_many_lines)] // One deterministic reset/action/interrupt schedule.
1989    async fn reset_is_atomic_with_action_status_and_interrupt_across_generation_collision() {
1990        let temp = tempfile::tempdir().expect("temporary directory");
1991        let mut state = BashState::new();
1992        state.cwd = temp.path().to_path_buf();
1993        state.workspace_root = temp.path().to_path_buf();
1994        state.current_thread_id = "atomic_reset".to_string();
1995        let session = Arc::new(DaemonSession::new());
1996        let sessions = Arc::new(Mutex::new(HashMap::from([(
1997            state.current_thread_id.clone(),
1998            Arc::clone(&session),
1999        )])));
2000        let held = session.operation_barrier.write().await;
2001        let owner_sessions = Arc::clone(&sessions);
2002        let first_params =
2003            action_params(&state, "sh -c 'sleep 0.25; printf old-incarnation'", "old-action");
2004        let first =
2005            tokio::spawn(
2006                async move { run_action(&owner_sessions, first_params, "guardian-a").await },
2007            );
2008
2009        while !session.completed.lock().await.contains_key("old-action") {
2010            tokio::task::yield_now().await;
2011        }
2012        // Let the owner reach the barrier while the test-held writer makes the
2013        // queue ordering deterministic, then enqueue reset behind it.
2014        tokio::time::sleep(Duration::from_millis(10)).await;
2015        let reset_sessions = Arc::clone(&sessions);
2016        let reset_snapshot = state.snapshot();
2017        let mut reset = tokio::spawn(async move {
2018            configure_session(
2019                &reset_sessions,
2020                ConfigureSessionParams {
2021                    snapshot: reset_snapshot,
2022                    transition: ConfigureSessionTransition::Reset,
2023                },
2024            )
2025            .await
2026        });
2027        drop(held);
2028        assert!(
2029            tokio::time::timeout(Duration::from_millis(50), &mut reset).await.is_err(),
2030            "reset crossed an in-flight action's session barrier"
2031        );
2032
2033        let first = tokio::time::timeout(Duration::from_secs(5), first)
2034            .await
2035            .expect("first action timeout")
2036            .expect("first join")
2037            .expect("first result");
2038        let stale_execution = first.execution_token.expect("old execution token");
2039        tokio::time::timeout(Duration::from_secs(5), reset)
2040            .await
2041            .expect("reset timeout")
2042            .expect("reset join")
2043            .expect("reset result");
2044
2045        let mut new_action =
2046            action_params(&state, "sh -c 'sleep 0.15; printf new-incarnation'", "new-action");
2047        new_action.command.wait_for_seconds = Some(0.001);
2048        let current = tokio::time::timeout(
2049            Duration::from_secs(5),
2050            run_action(&sessions, new_action, "guardian-a"),
2051        )
2052        .await
2053        .expect("new action timeout")
2054        .expect("new action");
2055        let current_token = current.execution_token.expect("current execution token");
2056        assert_eq!(stale_execution.generation, current_token.generation);
2057        assert_ne!(stale_execution.session_epoch, current_token.session_epoch);
2058
2059        let mut stale_status = action_params(&state, "printf unused", "stale-status");
2060        stale_status.command = serde_json::from_value(serde_json::json!({
2061            "action_json": {"type": "status_check", "status_check": true},
2062            "thread_id": "atomic_reset",
2063            "wait_for_seconds": 0.01
2064        }))
2065        .expect("status command");
2066        stale_status.options.expected_generation = Some(stale_execution.generation);
2067        stale_status.options.expected_execution = Some(stale_execution.clone());
2068        assert!(tokio::time::timeout(
2069            Duration::from_secs(5),
2070            run_action(&sessions, stale_status, "guardian-a"),
2071        )
2072        .await
2073        .expect("stale status timeout")
2074        .is_err());
2075
2076        assert!(!interrupt_session(
2077            &session,
2078            &SessionParams {
2079                thread_id: "atomic_reset".to_string(),
2080                expected_generation: Some(stale_execution.generation),
2081                expected_execution: Some(stale_execution),
2082                expected_guardian_epoch: Some("guardian-a".to_string()),
2083            },
2084            "guardian-a",
2085        )
2086        .await
2087        .expect("stale interrupt"));
2088
2089        let mut current_status = action_params(&state, "printf unused", "current-status");
2090        current_status.command = serde_json::from_value(serde_json::json!({
2091            "action_json": {"type": "status_check", "status_check": true},
2092            "thread_id": "atomic_reset",
2093            "wait_for_seconds": 1.0
2094        }))
2095        .expect("current status command");
2096        current_status.options.expected_generation = Some(current_token.generation);
2097        current_status.options.expected_execution = Some(current_token);
2098        let drained = tokio::time::timeout(
2099            Duration::from_secs(5),
2100            run_action(&sessions, current_status, "guardian-a"),
2101        )
2102        .await
2103        .expect("current status timeout")
2104        .expect("current status");
2105        assert!(
2106            drained.output.as_deref().is_some_and(|output| output.contains("new-incarnation")),
2107            "stale operations consumed or interrupted the new incarnation: {drained:?}"
2108        );
2109    }
2110}