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