1use std::collections::BTreeMap;
22use std::sync::atomic::{AtomicUsize, Ordering};
23
24use anyhow::Result;
25use uuid::Uuid;
26
27use agentos_sidecar_client::wire::{self, EventPayload, StreamChannel};
28
29use crate::agent_os::{AcpTerminalEntry, AgentOs, ShellEntry};
30use crate::error::ClientError;
31use crate::process::{install_output_callback, OutputCallback, ProcessStatus, StdinInput};
32use crate::stream::ByteStream;
33
34const SHELL_DATA_CHANNEL_CAPACITY: usize = 1024;
36
37const ACP_TERMINAL_LIMIT: usize = 1024;
39
40const DEFAULT_SHELL_COMMAND: &str = "sh";
43
44#[derive(Default)]
54pub struct OpenShellOptions {
55 pub command: Option<String>,
56 pub args: Vec<String>,
57 pub env: BTreeMap<String, String>,
58 pub cwd: Option<String>,
59 pub cols: Option<u16>,
60 pub rows: Option<u16>,
61 pub on_stderr: Option<OutputCallback>,
62}
63
64#[derive(Default)]
70pub struct ConnectTerminalOptions {
71 pub base: OpenShellOptions,
72 pub on_data: Option<OutputCallback>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ShellHandle {
78 pub shell_id: String,
79}
80
81fn rejected_to_error(rejected: wire::RejectedResponse) -> ClientError {
87 ClientError::Kernel {
88 code: rejected.code,
89 message: rejected.message,
90 }
91}
92
93fn stdin_chunk(data: StdinInput) -> Vec<u8> {
97 match data {
98 StdinInput::Text(text) => text.into_bytes(),
99 StdinInput::Bytes(bytes) => bytes,
100 }
101}
102
103fn try_reserve_counter(counter: &AtomicUsize, limit: usize) -> bool {
104 counter
105 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
106 (count < limit).then_some(count + 1)
107 })
108 .is_ok()
109}
110
111fn release_counter(counter: &AtomicUsize) {
112 let _ = counter.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |count| {
113 Some(count.saturating_sub(1))
114 });
115}
116
117struct AcpTerminalReservation<'a> {
118 agent: &'a AgentOs,
119 active: bool,
120}
121
122impl<'a> AcpTerminalReservation<'a> {
123 fn new(agent: &'a AgentOs) -> std::result::Result<Self, ClientError> {
124 if !try_reserve_counter(&agent.inner().acp_terminal_count, ACP_TERMINAL_LIMIT) {
125 return Err(ClientError::Sidecar(format!(
126 "acp terminal limit exceeded: at most {ACP_TERMINAL_LIMIT} terminals can be active per VM"
127 )));
128 }
129 Ok(Self {
130 agent,
131 active: true,
132 })
133 }
134
135 fn disarm(&mut self) {
136 self.active = false;
137 }
138}
139
140impl Drop for AcpTerminalReservation<'_> {
141 fn drop(&mut self) {
142 if self.active {
143 release_counter(&self.agent.inner().acp_terminal_count);
144 }
145 }
146}
147
148impl AgentOs {
149 fn vm_ownership(&self) -> wire::OwnershipScope {
151 wire::OwnershipScope::VmOwnership(wire::VmOwnership {
152 connection_id: self.connection_id().to_string(),
153 session_id: self.wire_session_id().to_string(),
154 vm_id: self.vm_id().to_string(),
155 })
156 }
157
158 pub(crate) fn finish_acp_terminal(&self, process_id: &str) {
159 if self.inner().acp_terminals.remove(process_id).is_some() {
160 release_counter(&self.inner().acp_terminal_count);
161 }
162 }
163
164 async fn start_acp_terminal(
165 &self,
166 execute: wire::ExecuteRequest,
167 ownership: wire::OwnershipScope,
168 pid_tx: tokio::sync::oneshot::Sender<std::result::Result<u32, ClientError>>,
169 process_id: &str,
170 ) -> Option<u32> {
171 {
172 let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
173 if self.inner().disposed.load(Ordering::SeqCst) {
174 let error = ClientError::Sidecar(
175 "cannot connect terminal after VM shutdown has started".to_string(),
176 );
177 let _ = pid_tx.send(Err(error));
178 self.finish_acp_terminal(process_id);
179 return None;
180 }
181 }
182
183 let result = match self
184 .transport()
185 .request_wire(ownership, wire::RequestPayload::ExecuteRequest(execute))
186 .await
187 {
188 Ok(wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
189 pid,
190 ..
191 })) => pid.ok_or_else(|| {
192 ClientError::Sidecar("connect_terminal: sidecar did not return a pid".to_string())
193 }),
194 Ok(wire::ResponsePayload::RejectedResponse(rejected)) => {
195 Err(rejected_to_error(rejected))
196 }
197 Ok(other) => Err(ClientError::Sidecar(format!(
198 "unexpected response to connect_terminal: {other:?}"
199 ))),
200 Err(error) => Err(error.into()),
201 };
202
203 match result {
204 Ok(pid) => {
205 let _ = pid_tx.send(Ok(pid));
206 Some(pid)
207 }
208 Err(error) => {
209 let _ = pid_tx.send(Err(error));
210 self.finish_acp_terminal(process_id);
211 None
212 }
213 }
214 }
215}
216
217impl AgentOs {
226 pub fn open_shell(&self, mut options: OpenShellOptions) -> Result<ShellHandle> {
237 let inner = self.inner();
238 let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
239 let shell_id = format!("shell-{counter}");
240 let process_id = format!("shell-{}", Uuid::new_v4());
242
243 let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
244 let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
245 let (spawned_tx, _) = tokio::sync::watch::channel(false);
247 let (exit_tx, _) = tokio::sync::watch::channel(None::<i32>);
249
250 if let Some(cb) = options.on_stderr.take() {
253 install_output_callback(stderr_tx.clone(), cb);
254 }
255
256 let entry = ShellEntry {
259 pid: 0,
260 data_tx: data_tx.clone(),
261 stderr_tx: stderr_tx.clone(),
262 process_id: process_id.clone(),
263 spawned_tx: spawned_tx.clone(),
264 exit_tx: exit_tx.clone(),
265 };
266 let _ = inner.shells.insert(shell_id.clone(), entry);
268
269 let command = options
270 .command
271 .clone()
272 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
273 options
274 .env
275 .insert(String::from("AGENTOS_EXEC_TTY"), String::from("1"));
276 if let Some(cols) = options.cols {
278 options
279 .env
280 .insert(String::from("COLUMNS"), cols.to_string());
281 }
282 if let Some(rows) = options.rows {
283 options.env.insert(String::from("LINES"), rows.to_string());
284 }
285 let execute = wire::ExecuteRequest {
286 process_id: process_id.clone(),
287 command: Some(command),
288 runtime: None,
289 entrypoint: None,
290 args: options.args.clone(),
291 env: options.env.clone().into_iter().collect(),
292 cwd: options.cwd.clone(),
293 wasm_permission_tier: None,
294 };
295
296 let agent = self.clone();
300 let ownership = self.vm_ownership();
301 let route_process_id = process_id.clone();
302 let exit_shell_id = shell_id.clone();
303 let exit_key = counter;
304 let handle = tokio::spawn(async move {
305 let mut events = agent.transport().subscribe_wire_events();
306
307 let response = match agent
308 .transport()
309 .request_wire(
310 ownership.clone(),
311 wire::RequestPayload::ExecuteRequest(execute),
312 )
313 .await
314 {
315 Ok(response) => response,
316 Err(error) => {
317 tracing::warn!(?error, shell_id = %exit_shell_id, "open_shell spawn failed");
318 agent.inner().shells.remove(&exit_shell_id);
320 agent.inner().pending_shell_exits.remove(&exit_key);
321 return;
322 }
323 };
324
325 if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
328 pid: Some(pid),
329 ..
330 }) = response
331 {
332 agent
333 .inner()
334 .shells
335 .update(&exit_shell_id, |_, existing| existing.pid = pid);
336 }
337 let _ = spawned_tx.send_replace(true);
343
344 loop {
345 let (_scope, payload) = match events.recv().await {
346 Ok(value) => value,
347 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
348 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
349 };
350 match payload {
351 EventPayload::ProcessOutputEvent(output) => {
352 if output.process_id != route_process_id {
353 continue;
354 }
355 match output.channel {
357 StreamChannel::Stdout => {
358 let _ = data_tx.send(output.chunk);
359 }
360 StreamChannel::Stderr => {
361 let _ = stderr_tx.send(output.chunk);
362 }
363 }
364 }
365 EventPayload::ProcessExitedEvent(exited) => {
366 if exited.process_id == route_process_id {
367 {
371 let mut retained = agent.inner().closed_shell_exit_codes.lock();
372 retained.push_back((exit_shell_id.clone(), exited.exit_code));
373 while retained.len() > crate::CLOSED_SHELL_EXIT_CODE_RETENTION_LIMIT
374 {
375 retained.pop_front();
376 }
377 }
378 let _ = exit_tx.send(Some(exited.exit_code));
379 break;
380 }
381 }
382 EventPayload::VmLifecycleEvent(_)
383 | EventPayload::StructuredEvent(_)
384 | EventPayload::ExtEnvelope(_) => {}
385 }
386 }
387
388 agent.inner().pending_shell_exits.remove(&exit_key);
391 agent.inner().shells.remove_if(&exit_shell_id, |existing| {
392 existing.process_id == route_process_id
393 });
394 });
396
397 let _ = inner.pending_shell_exits.insert(counter, handle);
398
399 Ok(ShellHandle { shell_id })
400 }
401
402 pub(crate) fn acp_open_terminal(
409 &self,
410 options: OpenShellOptions,
411 exit_tx: tokio::sync::watch::Sender<Option<i32>>,
412 on_output: impl Fn(&[u8]) + Send + Sync + 'static,
413 ) -> Result<ShellHandle> {
414 let inner = self.inner();
415 let counter = inner.shell_counter.fetch_add(1, Ordering::SeqCst) + 1;
416 let shell_id = format!("shell-{counter}");
417 let process_id = format!("shell-{}", Uuid::new_v4());
418
419 let (data_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
420 let (stderr_tx, _) = tokio::sync::broadcast::channel(SHELL_DATA_CHANNEL_CAPACITY);
421 let (spawned_tx, _) = tokio::sync::watch::channel(false);
422
423 let entry = ShellEntry {
424 pid: 0,
425 data_tx: data_tx.clone(),
426 stderr_tx: stderr_tx.clone(),
427 process_id: process_id.clone(),
428 spawned_tx: spawned_tx.clone(),
429 exit_tx: exit_tx.clone(),
431 };
432 let _ = inner.shells.insert(shell_id.clone(), entry);
433
434 let command = options
435 .command
436 .clone()
437 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
438 let execute = wire::ExecuteRequest {
439 process_id: process_id.clone(),
440 command: Some(command),
441 runtime: None,
442 entrypoint: None,
443 args: options.args.clone(),
444 env: options.env.clone().into_iter().collect(),
445 cwd: options.cwd.clone(),
446 wasm_permission_tier: None,
447 };
448
449 let agent = self.clone();
450 let ownership = self.vm_ownership();
451 let route_process_id = process_id.clone();
452 let exit_shell_id = shell_id.clone();
453 let exit_key = counter;
454 let on_output = std::sync::Arc::new(on_output);
455 let handle = tokio::spawn(async move {
456 let mut events = agent.transport().subscribe_wire_events();
457
458 let response = match agent
459 .transport()
460 .request_wire(
461 ownership.clone(),
462 wire::RequestPayload::ExecuteRequest(execute),
463 )
464 .await
465 {
466 Ok(response) => response,
467 Err(error) => {
468 tracing::warn!(?error, shell_id = %exit_shell_id, "acp_open_terminal spawn failed");
469 agent.inner().shells.remove(&exit_shell_id);
470 agent.inner().pending_shell_exits.remove(&exit_key);
471 let _ = exit_tx.send(Some(1));
472 return;
473 }
474 };
475
476 if let wire::ResponsePayload::ProcessStartedResponse(wire::ProcessStartedResponse {
477 pid: Some(pid),
478 ..
479 }) = response
480 {
481 agent
482 .inner()
483 .shells
484 .update(&exit_shell_id, |_, existing| existing.pid = pid);
485 }
486 let _ = spawned_tx.send_replace(true);
492
493 let mut exit_code: i32 = 0;
494 loop {
495 let (_scope, payload) = match events.recv().await {
496 Ok(value) => value,
497 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
498 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
499 };
500 match payload {
501 EventPayload::ProcessOutputEvent(output) => {
502 if output.process_id != route_process_id {
503 continue;
504 }
505 on_output(&output.chunk);
508 }
509 EventPayload::ProcessExitedEvent(exited) => {
510 if exited.process_id == route_process_id {
511 exit_code = exited.exit_code;
512 break;
513 }
514 }
515 EventPayload::VmLifecycleEvent(_)
516 | EventPayload::StructuredEvent(_)
517 | EventPayload::ExtEnvelope(_) => {}
518 }
519 }
520
521 agent.inner().pending_shell_exits.remove(&exit_key);
522 agent.inner().shells.remove_if(&exit_shell_id, |existing| {
523 existing.process_id == route_process_id
524 });
525 let _ = exit_tx.send(Some(exit_code));
526 });
527
528 let _ = inner.pending_shell_exits.insert(counter, handle);
532 Ok(ShellHandle { shell_id })
533 }
534
535 pub(crate) fn acp_kill_terminal_shell(
539 &self,
540 shell_id: &str,
541 ) -> std::result::Result<(), ClientError> {
542 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
543 let agent = self.clone();
544 let ownership = self.vm_ownership();
545 tokio::spawn(async move {
546 wait_for_spawn(spawned_rx).await;
547 let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
548 process_id,
549 signal: String::from("SIGTERM"),
550 });
551 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
552 tracing::warn!(?error, "acp_kill_terminal_shell failed");
553 }
554 });
555 Ok(())
556 }
557
558 pub async fn connect_terminal(&self, options: ConnectTerminalOptions) -> Result<u32> {
567 let ConnectTerminalOptions { base, on_data } = options;
568
569 let process_id = format!("terminal-{}", Uuid::new_v4());
570 let command = base
571 .command
572 .clone()
573 .unwrap_or_else(|| DEFAULT_SHELL_COMMAND.to_string());
574 let (data_tx, _) = tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
575 let (stderr_tx, _) =
576 tokio::sync::broadcast::channel::<Vec<u8>>(SHELL_DATA_CHANNEL_CAPACITY);
577
578 if let Some(cb) = on_data {
582 install_output_callback(data_tx.clone(), cb);
583 }
584 if let Some(cb) = base.on_stderr {
585 install_output_callback(stderr_tx.clone(), cb);
586 }
587
588 let execute = wire::ExecuteRequest {
589 process_id: process_id.clone(),
590 command: Some(command),
591 runtime: None,
592 entrypoint: None,
593 args: base.args.clone(),
594 env: base.env.clone().into_iter().collect(),
595 cwd: base.cwd.clone(),
596 wasm_permission_tier: None,
597 };
598
599 let events = self.transport().subscribe_wire_events();
601 let ownership = self.vm_ownership();
602 let (pid_tx, pid_rx) = tokio::sync::oneshot::channel();
603 let (start_tx, start_rx) = tokio::sync::oneshot::channel::<()>();
604 let agent = self.clone();
605 let route_process_id = process_id.clone();
606 let exit_task = tokio::spawn(async move {
607 if start_rx.await.is_err() {
608 return;
609 }
610 let terminal_pid = match agent
611 .start_acp_terminal(execute, ownership, pid_tx, &route_process_id)
612 .await
613 {
614 Some(pid) => pid,
615 None => return,
616 };
617 let mut events = events;
618 loop {
619 let (_scope, payload) = match events.recv().await {
620 Ok(value) => value,
621 Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
622 if terminal_process_finished(&agent, terminal_pid).await {
623 break;
624 }
625 continue;
626 }
627 Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
628 };
629 match payload {
630 EventPayload::ProcessOutputEvent(output) => {
631 if output.process_id != route_process_id {
632 continue;
633 }
634 match output.channel {
635 StreamChannel::Stdout => {
636 let _ = data_tx.send(output.chunk);
637 }
638 StreamChannel::Stderr => {
639 let _ = stderr_tx.send(output.chunk);
640 }
641 }
642 }
643 EventPayload::ProcessExitedEvent(exited) => {
644 if exited.process_id == route_process_id {
645 break;
646 }
647 }
648 EventPayload::VmLifecycleEvent(_)
649 | EventPayload::StructuredEvent(_)
650 | EventPayload::ExtEnvelope(_) => {}
651 }
652 }
653 agent.finish_acp_terminal(&route_process_id);
654 });
655
656 {
657 let _terminal_lifecycle_guard = self.inner().acp_terminal_lifecycle_lock.lock().await;
658 if self.inner().disposed.load(Ordering::SeqCst) {
659 exit_task.abort();
660 return Err(ClientError::Sidecar(
661 "cannot connect terminal after VM shutdown has started".to_string(),
662 )
663 .into());
664 }
665 let mut terminal_reservation = AcpTerminalReservation::new(self)?;
666 match self
667 .inner()
668 .acp_terminals
669 .insert(process_id.clone(), AcpTerminalEntry { exit_task })
670 {
671 Ok(()) => {}
672 Err((_, entry)) => {
673 entry.exit_task.abort();
674 return Err(ClientError::Sidecar(format!(
675 "terminal process id collision while tracking ACP terminal: {process_id}"
676 ))
677 .into());
678 }
679 }
680 terminal_reservation.disarm();
681 if start_tx.send(()).is_err() {
682 self.finish_acp_terminal(&process_id);
683 return Err(ClientError::Sidecar(
684 "terminal startup task ended before registration completed".to_string(),
685 )
686 .into());
687 }
688 }
689
690 pid_rx
691 .await
692 .map_err(|_| {
693 ClientError::Sidecar(
694 "terminal startup task ended before returning a pid".to_string(),
695 )
696 })?
697 .map_err(Into::into)
698 }
699
700 pub fn write_shell(
702 &self,
703 shell_id: &str,
704 data: StdinInput,
705 ) -> std::result::Result<(), ClientError> {
706 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
707 let chunk = stdin_chunk(data);
708
709 let agent = self.clone();
714 let ownership = self.vm_ownership();
715 tokio::spawn(async move {
716 wait_for_spawn(spawned_rx).await;
717 let payload = wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest {
718 process_id,
719 chunk,
720 });
721 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
722 tracing::warn!(?error, "write_shell failed");
723 }
724 });
725
726 Ok(())
727 }
728
729 pub async fn write_shell_awaited(
733 &self,
734 shell_id: &str,
735 data: StdinInput,
736 ) -> std::result::Result<(), ClientError> {
737 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
738 let chunk = stdin_chunk(data);
739 tracing::debug!(shell_id, "write_shell_awaited: waiting for spawn gate");
740 wait_for_spawn(spawned_rx).await;
741 tracing::debug!(shell_id, "write_shell_awaited: issuing wire write");
742 let payload =
743 wire::RequestPayload::WriteStdinRequest(wire::WriteStdinRequest { process_id, chunk });
744 let response = self
745 .transport()
746 .request_wire(self.vm_ownership(), payload)
747 .await?;
748 tracing::debug!(shell_id, "write_shell_awaited: wire write acked");
749 match response {
750 wire::ResponsePayload::RejectedResponse(rejected) => Err(rejected_to_error(rejected)),
751 _ => Ok(()),
752 }
753 }
754
755 pub fn on_shell_data(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
759 self.inner()
760 .shells
761 .read(shell_id, |_, entry| entry.data_tx.subscribe())
762 .map(ByteStream::new)
763 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
764 }
765
766 pub fn on_shell_stderr(&self, shell_id: &str) -> std::result::Result<ByteStream, ClientError> {
770 self.inner()
771 .shells
772 .read(shell_id, |_, entry| entry.stderr_tx.subscribe())
773 .map(ByteStream::new)
774 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
775 }
776
777 pub fn resize_shell(
781 &self,
782 shell_id: &str,
783 cols: u16,
784 rows: u16,
785 ) -> std::result::Result<(), ClientError> {
786 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
788
789 let agent = self.clone();
790 let ownership = self.vm_ownership();
791 tokio::spawn(async move {
792 wait_for_spawn(spawned_rx).await;
793 let payload = wire::RequestPayload::ResizePtyRequest(wire::ResizePtyRequest {
794 process_id,
795 cols,
796 rows,
797 });
798 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
799 tracing::warn!(?error, "resize_shell failed");
800 }
801 });
802
803 Ok(())
804 }
805
806 pub async fn wait_shell(&self, shell_id: &str) -> std::result::Result<i32, ClientError> {
810 let exit_rx = self
811 .inner()
812 .shells
813 .read(shell_id, |_, entry| entry.exit_tx.subscribe());
814 let Some(mut exit_rx) = exit_rx else {
815 let retained = self.inner().closed_shell_exit_codes.lock();
817 return retained
818 .iter()
819 .rev()
820 .find(|(id, _)| id == shell_id)
821 .map(|(_, code)| *code)
822 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
823 };
824 loop {
825 if let Some(code) = *exit_rx.borrow_and_update() {
826 return Ok(code);
827 }
828 if exit_rx.changed().await.is_err() {
829 let retained = self.inner().closed_shell_exit_codes.lock();
832 return retained
833 .iter()
834 .rev()
835 .find(|(id, _)| id == shell_id)
836 .map(|(_, code)| *code)
837 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()));
838 }
839 }
840 }
841
842 pub fn close_shell(&self, shell_id: &str) -> std::result::Result<(), ClientError> {
845 let (process_id, spawned_rx) = self.shell_wire_handle(shell_id)?;
846
847 self.inner().shells.remove(shell_id);
850
851 let agent = self.clone();
853 let ownership = self.vm_ownership();
854 tokio::spawn(async move {
855 wait_for_spawn(spawned_rx).await;
856 let payload = wire::RequestPayload::KillProcessRequest(wire::KillProcessRequest {
857 process_id,
858 signal: String::from("SIGTERM"),
859 });
860 if let Err(error) = agent.transport().request_wire(ownership, payload).await {
861 tracing::warn!(?error, "close_shell kill failed");
862 }
863 });
864
865 Ok(())
866 }
867
868 fn shell_wire_handle(
871 &self,
872 shell_id: &str,
873 ) -> std::result::Result<(String, tokio::sync::watch::Receiver<bool>), ClientError> {
874 self.inner()
875 .shells
876 .read(shell_id, |_, entry| {
877 (entry.process_id.clone(), entry.spawned_tx.subscribe())
878 })
879 .ok_or_else(|| ClientError::ShellNotFound(shell_id.to_string()))
880 }
881}
882
883async fn wait_for_spawn(mut spawned_rx: tokio::sync::watch::Receiver<bool>) {
886 if *spawned_rx.borrow() {
887 return;
888 }
889 while spawned_rx.changed().await.is_ok() {
890 if *spawned_rx.borrow() {
891 return;
892 }
893 }
894}
895
896async fn terminal_process_finished(agent: &AgentOs, pid: u32) -> bool {
897 match agent.all_processes().await {
898 Ok(processes) => match processes.into_iter().find(|process| process.pid == pid) {
899 Some(process) => process.status != ProcessStatus::Running,
900 None => true,
901 },
902 Err(error) => {
903 tracing::warn!(?error, pid, "terminal process snapshot failed");
904 false
905 }
906 }
907}
908
909#[cfg(test)]
910mod tests {
911 use super::*;
912
913 #[test]
914 fn reserve_counter_enforces_limit_and_release_reopens_slot() {
915 let counter = AtomicUsize::new(0);
916
917 assert!(try_reserve_counter(&counter, 2));
918 assert!(try_reserve_counter(&counter, 2));
919 assert!(!try_reserve_counter(&counter, 2));
920 release_counter(&counter);
921 assert!(try_reserve_counter(&counter, 2));
922 }
923}