Skip to main content

winx_code_agent/daemon/
server.rs

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