Skip to main content

winx_code_agent/daemon/
server.rs

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