1#[cfg(windows)]
13use agentmux_common::win32::NoWindow;
14use std::collections::VecDeque;
15use std::sync::{Arc, Mutex, Weak};
16use std::time::Duration;
17
18use tokio::process::Command;
19
20use super::app_server::{AppServerExit, AppServerIncoming, AppServerLimits, AppServerProcess};
21use super::app_server_protocol::{
22 CodexAppServerEvent, CodexAppServerProtocolError, CodexAppServerSession, ThreadStartOptions,
23};
24use super::core;
25use super::health::{PassStats, TurnActivityTracker};
26use super::{
27 BlockControllerRuntimeStatus, BlockInputUnion, Controller, STATUS_DONE, STATUS_INIT,
28 STATUS_RUNNING,
29};
30use crate::backend::eventbus::EventBus;
31use crate::backend::storage::filestore::FileStore;
32use crate::backend::storage::store::Store;
33use crate::backend::mps;
34
35const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
36const OPERATION_TIMEOUT: Duration = Duration::from_secs(10);
37
38struct AppServerInner {
39 proc_status: String,
40 proc_exit_code: i32,
41 status_version: i32,
42 process: Option<Arc<AppServerProcess>>,
43 session: Option<Arc<CodexAppServerSession>>,
44 pending_messages: VecDeque<String>,
45}
46
47pub struct AppServerController {
49 block_id: String,
50 inner: Arc<Mutex<AppServerInner>>,
51 broker: Option<Arc<mps::Broker>>,
52 event_bus: Option<Arc<EventBus>>,
53 mstore: Option<Arc<Store>>,
54 filestore: Option<Arc<FileStore>>,
55 health_monitor: Arc<TurnActivityTracker>,
56 self_ref: Mutex<Option<Weak<Self>>>,
57}
58
59impl AppServerController {
60 pub fn new(
61 block_id: String,
62 broker: Option<Arc<mps::Broker>>,
63 event_bus: Option<Arc<EventBus>>,
64 mstore: Option<Arc<Store>>,
65 filestore: Option<Arc<FileStore>>,
66 ) -> Self {
67 let broker_for_turns = broker.clone();
68 Self {
69 block_id: block_id.clone(),
70 inner: Arc::new(Mutex::new(AppServerInner {
71 proc_status: STATUS_INIT.to_string(),
72 proc_exit_code: 0,
73 status_version: 0,
74 process: None,
75 session: None,
76 pending_messages: VecDeque::new(),
77 })),
78 broker,
79 event_bus,
80 mstore,
81 filestore,
82 health_monitor: Arc::new(TurnActivityTracker::for_block(block_id, broker_for_turns.as_ref())),
83 self_ref: Mutex::new(None),
84 }
85 }
86
87 pub fn set_self_ref(self: &Arc<Self>) {
88 *self.self_ref.lock().unwrap() = Some(Arc::downgrade(self));
89 }
90
91 fn set_status(&self, status: &str) {
92 {
100 let mut inner = self.inner.lock().unwrap();
101 inner.proc_status = status.to_string();
102 inner.status_version += 1;
103 }
104 self.publish_status();
105 }
106
107 fn publish_status(&self) {
108 if let Some(broker) = &self.broker {
109 super::publish_controller_status(broker, &self.get_runtime_status());
110 }
111 }
112
113 fn command_from_meta(
114 &self,
115 block_meta: &crate::backend::obj::MetaMapType,
116 ) -> Result<Command, String> {
117 let executable = crate::backend::obj::meta_get_string(block_meta, "cmd", "codex");
118 let args = match block_meta.get("cmd:args") {
119 Some(serde_json::Value::Array(values)) => values
120 .iter()
121 .filter_map(|value| value.as_str().map(str::to_string))
122 .collect::<Vec<_>>(),
123 _ => vec![
124 "app-server".to_string(),
125 "--listen".to_string(),
126 "stdio://".to_string(),
127 ],
128 };
129 if args.is_empty() {
130 return Err("App Server command must include app-server arguments".to_string());
131 }
132 let working_dir = crate::backend::obj::meta_get_string(block_meta, "cmd:cwd", "");
133 let mut env_vars = super::cmd_env_of(block_meta);
134 if let Some(mstore) = &self.mstore {
138 crate::server::agent_handlers::input::carry_block_identity_env(
139 mstore,
140 &self.block_id,
141 &mut env_vars,
142 );
143 }
144 crate::server::agent_handlers::input::carry_agent_workdir_env(&mut env_vars, block_meta);
146 crate::server::browser_uploads::record_launch_workspace(&self.block_id, &env_vars);
147 crate::backend::gh_guard::apply_gh_guard(&mut env_vars);
148 crate::backend::account_login_guard::strip_account_login(&mut env_vars);
149 crate::backend::identity_spawn::record_process_spawn(
151 &self.block_id,
152 crate::backend::identity_spawn::SpawnPath::AppServer,
153 &env_vars,
154 );
155 let mut command = crate::server::cli_handlers::make_cli_cmd(&executable);
156 command.args(args);
157 core::apply_working_dir(&mut command, &self.block_id, &working_dir, &env_vars);
158 #[cfg(windows)]
159 {
160 command.no_window();
161 }
162 Ok(command)
163 }
164
165 fn publish_protocol_frame(&self, frame: serde_json::Value, global_zone: Option<&str>) {
169 let line = format!("{}\n", frame);
170 if let Some(broker) = &self.broker {
171 broker.publish_event::<crate::backend::mps_events::AppServerOutput>(&crate::backend::mps_events::ProtocolFrame(frame.clone()), vec![format!("block:{}", self.block_id)], 1);
172 }
173 if let (Some(filestore), Some(broker)) = (&self.filestore, &self.broker) {
174 super::shell::handle_append_block_file(
179 broker,
180 &self.block_id,
181 "output",
182 line.as_bytes(),
183 Some(filestore),
184 global_zone,
185 );
186 }
187 }
188
189 fn spawn_event_loop(
190 &self,
191 process: Arc<AppServerProcess>,
192 session: Arc<CodexAppServerSession>,
193 ) {
194 let weak = self.self_ref.lock().unwrap().clone();
195 let global_zone = super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
198 tokio::spawn(async move {
199 let mut turn_usage = CodexTurnUsage::default();
202 while let Some(incoming) = process.transport.next_incoming().await {
203 if let AppServerIncoming::Notification { method, params } = &incoming {
204 turn_usage.note(method, params);
205 }
206 let frame = match &incoming {
217 AppServerIncoming::Notification { method, params } => {
218 serde_json::json!({"type":"codex_app_server","method":method,"params":params})
219 }
220 AppServerIncoming::Request {
221 id: _,
222 method,
223 params,
224 } => {
225 serde_json::json!({"type":"codex_app_server_request","method":method,"params":params})
226 }
227 };
228 if let Some(controller) = weak.as_ref().and_then(Weak::upgrade) {
229 controller.publish_protocol_frame(frame, global_zone.as_deref());
230 match incoming {
231 notification @ AppServerIncoming::Notification { .. } => {
232 if let Ok(event) = session.apply_incoming(notification) {
233 if matches!(event, CodexAppServerEvent::TurnCompleted { .. }) {
234 controller.health_monitor.end_pass(turn_usage.take());
235 controller.set_status(STATUS_RUNNING);
236 let next_message =
241 controller.inner.lock().unwrap().pending_messages.pop_front();
242 if let Some(next_message) = next_message {
243 controller.spawn_turn(session.clone(), next_message);
244 }
245 }
246 }
247 }
248 request @ AppServerIncoming::Request { .. } => {
249 if let Err(error) = session.accept_server_request(request) {
250 tracing::warn!(block_id = %controller.block_id, error = %error, "Codex App Server request rejected");
251 }
252 }
253 }
254 }
255 }
256 let exit_code = match process.wait_for_exit().await {
262 Ok(AppServerExit::Clean { code, .. })
263 | Ok(AppServerExit::BeforeInitialize { code, .. })
264 | Ok(AppServerExit::NonZero { code, .. }) => code.unwrap_or(1),
265 _ => 1,
266 };
267 if let Some(controller) = weak.as_ref().and_then(Weak::upgrade) {
268 let mut inner = controller.inner.lock().unwrap();
278 let is_still_ours = inner
279 .process
280 .as_ref()
281 .is_some_and(|current| Arc::ptr_eq(current, &process));
282 if is_still_ours {
283 inner.process = None;
284 inner.session = None;
285 inner.proc_exit_code = exit_code;
286 drop(inner);
287 controller.health_monitor.set_exited(exit_code);
288 controller.set_status(STATUS_DONE);
289 }
290 }
291 });
292 }
293
294 fn spawn_turn(&self, session: Arc<CodexAppServerSession>, message: String) {
295 let health = self.health_monitor.clone();
296 let weak = self.self_ref.lock().unwrap().clone();
297 health.set_active_turn(true);
298 self.set_status(STATUS_RUNNING);
299 let requeue_message = message.clone();
302 tokio::spawn(async move {
303 if let Err(error) = session.start_turn(message, OPERATION_TIMEOUT).await {
304 let turn_already_active =
311 matches!(error, CodexAppServerProtocolError::TurnAlreadyActive(_));
312 if !turn_already_active {
313 health.set_active_turn(false);
314 }
315 if let Some(controller) = weak.and_then(|weak| Weak::upgrade(&weak)) {
316 if turn_already_active {
317 controller
323 .inner
324 .lock()
325 .unwrap()
326 .pending_messages
327 .push_back(requeue_message);
328 } else {
329 controller.set_status(STATUS_DONE);
330 }
331 tracing::warn!(
332 block_id = %controller.block_id,
333 error = %error,
334 turn_already_active,
335 "Codex App Server turn failed to start"
336 );
337 }
338 }
339 });
340 }
341
342 pub fn send_message(&self, message: String) -> Result<(), String> {
343 let session = {
344 let mut inner = self.inner.lock().unwrap();
345 match inner.session.clone() {
346 Some(session) => Some(session),
347 None if inner.process.is_some() => {
348 inner.pending_messages.push_back(message.clone());
349 None
350 }
351 None => return Err("Codex App Server is not initialized".to_string()),
352 }
353 };
354 if let Some(session) = session {
355 self.spawn_turn(session, message);
356 }
357 Ok(())
358 }
359
360 fn clear_process_and_session(&self) {
368 let mut inner = self.inner.lock().unwrap();
369 inner.process = None;
370 inner.session = None;
371 }
372
373 fn stop_process(&self) {
374 let process = {
387 let mut inner = self.inner.lock().unwrap();
388 inner.session = None;
389 inner.process.take()
390 };
391 let weak = self.self_ref.lock().unwrap().clone();
392 if let Some(process) = process {
393 tokio::spawn(async move {
394 let exit = process.shutdown(Duration::from_secs(2)).await;
395 let exit_code = match exit {
396 Ok(AppServerExit::Clean { code, .. })
397 | Ok(AppServerExit::BeforeInitialize { code, .. })
398 | Ok(AppServerExit::NonZero { code, .. }) => code.unwrap_or(0),
399 _ => 0,
400 };
401 if let Some(controller) = weak.and_then(|weak| Weak::upgrade(&weak)) {
402 controller.health_monitor.set_exited(exit_code);
403 controller.inner.lock().unwrap().proc_exit_code = exit_code;
404 }
405 });
406 }
407 }
408}
409
410impl Controller for AppServerController {
411 fn start(
412 &self,
413 block_meta: crate::backend::obj::MetaMapType,
414 _rt_opts: Option<serde_json::Value>,
415 _force: bool,
416 ) -> Result<(), String> {
417 if self.inner.lock().unwrap().process.is_some() {
418 return Ok(());
419 }
420 let mut command = self.command_from_meta(&block_meta)?;
421 crate::backend::process_tracker::registry::place_spawn(&self.block_id, &mut command, true);
422 let process = Arc::new(
423 AppServerProcess::spawn(command, AppServerLimits::default())
424 .map_err(|error| error.to_string())?,
425 );
426 if let Some(pid) = process.pid() {
427 crate::backend::process_tracker::registry::track_spawned_agent(&self.block_id, pid);
428 }
429 let process_for_task = process.clone();
430 let weak = self.self_ref.lock().unwrap().clone();
431 self.inner.lock().unwrap().process = Some(process.clone());
432 self.set_status(STATUS_RUNNING);
433 tokio::spawn(async move {
434 let Some(controller) = weak.and_then(|weak| Weak::upgrade(&weak)) else {
435 return;
436 };
437 if let Err(error) = process_for_task
438 .transport
439 .initialize(INITIALIZE_TIMEOUT)
440 .await
441 {
442 tracing::warn!(block_id = %controller.block_id, error = %error, "Codex App Server initialize failed");
443 controller.set_status(STATUS_DONE);
444 controller.clear_process_and_session();
445 let _ = process_for_task.shutdown(Duration::from_secs(1)).await;
446 return;
447 }
448 let transport = process_for_task.transport.clone();
449 let session = Arc::new(CodexAppServerSession::new(transport));
450 let cwd = crate::backend::obj::meta_get_string(&block_meta, "cmd:cwd", "");
451 let model = crate::backend::obj::meta_get_string(&block_meta, "agent:model", "");
452 let stored_thread =
453 crate::backend::obj::meta_get_string(&block_meta, "agent:sessionid", "");
454 let thread = if stored_thread.is_empty() {
455 session
456 .start_thread(
457 ThreadStartOptions {
458 model: (!model.is_empty()).then_some(model),
459 cwd: (!cwd.is_empty()).then_some(cwd),
460 ..Default::default()
461 },
462 OPERATION_TIMEOUT,
463 )
464 .await
465 } else {
466 session
467 .resume_thread(
468 stored_thread,
469 (!model.is_empty()).then_some(model),
470 (!cwd.is_empty()).then_some(cwd),
471 OPERATION_TIMEOUT,
472 )
473 .await
474 };
475 let thread_id = match thread {
476 Ok(thread_id) => thread_id,
477 Err(error) => {
478 tracing::warn!(block_id = %controller.block_id, error = %error, "Codex App Server thread setup failed");
479 controller.set_status(STATUS_DONE);
480 controller.clear_process_and_session();
481 let _ = process_for_task.shutdown(Duration::from_secs(1)).await;
482 return;
483 }
484 };
485 if let (Some(store), Some(event_bus)) = (&controller.mstore, &controller.event_bus) {
486 core::persist_session_id(
487 &controller.block_id,
488 &thread_id,
489 &Some(store.clone()),
490 &Some(event_bus.clone()),
491 );
492 }
493 controller.inner.lock().unwrap().session = Some(session.clone());
494 controller.set_status(STATUS_RUNNING);
495 let first_pending_message = controller.inner.lock().unwrap().pending_messages.pop_front();
502 if let Some(first_pending_message) = first_pending_message {
503 controller.spawn_turn(session.clone(), first_pending_message);
504 }
505 controller.spawn_event_loop(process_for_task, session);
506 });
507 Ok(())
508 }
509
510 fn stop(&self, _graceful: bool, new_status: &str) -> Result<(), String> {
511 self.stop_process();
512 self.set_status(new_status);
513 Ok(())
514 }
515
516 fn get_runtime_status(&self) -> BlockControllerRuntimeStatus {
517 let inner = self.inner.lock().unwrap();
518 super::agent_runtime_status(
519 &self.block_id,
520 inner.status_version,
521 &inner.proc_status,
522 inner.proc_exit_code,
523 self.health_monitor.is_active_turn(),
524 )
525 }
526
527 fn send_input(&self, input: BlockInputUnion, _seq: Option<u64>) -> Result<(), String> {
528 if let Some(signal) = input.sig_name.as_deref() {
529 if signal == "SIGINT" || signal == "SIGTERM" {
530 self.stop_process();
531 self.set_status(STATUS_DONE);
540 return Ok(());
541 }
542 return Err(format!("unsupported App Server signal {signal}"));
543 }
544 if let Some(data) = input.input_data {
545 let message =
546 String::from_utf8(data).map_err(|error| format!("input is not UTF-8: {error}"))?;
547 return self.send_message(message);
548 }
549 Ok(())
550 }
551
552 fn turn_tracker(&self) -> Option<Arc<TurnActivityTracker>> {
553 Some(Arc::clone(&self.health_monitor))
554 }
555
556 fn controller_type(&self) -> &str {
557 super::BLOCK_CONTROLLER_APP_SERVER
558 }
559
560 fn block_id(&self) -> &str {
561 &self.block_id
562 }
563
564 fn as_any(&self) -> &dyn std::any::Any {
565 self
566 }
567}
568
569#[derive(Default)]
574struct CodexTurnUsage {
575 turn_id: Option<String>,
576 stats: PassStats,
577}
578
579impl CodexTurnUsage {
580 fn note(&mut self, method: &str, params: &serde_json::Value) {
581 if method != "thread/tokenUsage/updated" {
582 return;
583 }
584 let Some(output) = params.pointer("/tokenUsage/last/outputTokens").and_then(|v| v.as_u64()) else {
585 return;
586 };
587 let turn_id = params.get("turnId").and_then(|v| v.as_str()).map(str::to_string);
588 if turn_id != self.turn_id {
589 *self = CodexTurnUsage { turn_id, stats: PassStats::default() };
590 }
591 self.stats.output_tokens += output;
592 self.stats.steps += 1;
593 }
594
595 fn take(&mut self) -> Option<PassStats> {
597 let stats = std::mem::take(self).stats;
598 (stats.steps > 0).then_some(stats)
599 }
600}
601
602#[cfg(test)]
603mod tests {
604
605 #[test]
608 fn an_app_server_command_carries_the_rows_identity() {
609 use crate::backend::storage::agents::test_agent_def;
610 let store = Arc::new(Store::open_in_memory().unwrap());
611 let mut def = test_agent_def("uid-appsrv", "AppSrv", "codex", "agent", 1, "");
612 store.agent_def_insert(&mut def).unwrap();
613 let mut meta = crate::backend::obj::MetaMapType::new();
614 meta.insert("agentId".to_string(), serde_json::json!("uid-appsrv"));
615 let mut block = crate::backend::obj::Block {
616 oid: "block-appsrv".to_string(),
617 parentoref: String::new(),
618 version: 0,
619 runtimeopts: None,
620 stickers: None,
621 meta: meta.clone(),
622 subblockids: None,
623 };
624 store.insert(&mut block).unwrap();
625 let ctrl = AppServerController::new(
626 "block-appsrv".to_string(),
627 None,
628 None,
629 Some(store.clone()),
630 None,
631 );
632 let command = ctrl.command_from_meta(&meta).unwrap();
633 let env: std::collections::HashMap<String, String> = command
634 .as_std()
635 .get_envs()
636 .filter_map(|(k, v)| Some((k.to_str()?.to_string(), v?.to_str()?.to_string())))
637 .collect();
638 assert_eq!(
639 env.get("AGENTMUX_AGENT_UID").map(String::as_str),
640 Some("uid-appsrv")
641 );
642 assert_eq!(
643 env.get("AGENTMUX_AGENT_TOKEN"),
644 store.agent_token_load("uid-appsrv").unwrap().as_ref()
645 );
646 }
647
648 use super::*;
649 use crate::backend::obj::MetaMapType;
650
651 fn controller() -> AppServerController {
652 AppServerController::new(
653 "block-1".to_string(),
654 None,
655 None,
656 None,
657 None,
658 )
659 }
660
661 #[test]
662 fn starts_idle_and_reports_the_app_server_controller_type() {
663 let controller = controller();
664 let status = controller.get_runtime_status();
665 assert_eq!(status.shellprocstatus, STATUS_INIT);
666 assert_eq!(status.blockid, "block-1");
667 assert!(status.is_agent_pane);
668 assert_eq!(
669 controller.controller_type(),
670 super::super::BLOCK_CONTROLLER_APP_SERVER
671 );
672 }
673
674 #[test]
675 fn rejects_empty_app_server_arguments_before_spawning() {
676 let controller = controller();
677 let mut meta = MetaMapType::new();
678 meta.insert("cmd".to_string(), serde_json::json!("codex"));
679 meta.insert("cmd:args".to_string(), serde_json::json!([]));
680 let error = controller.command_from_meta(&meta).unwrap_err();
681 assert!(error.contains("must include app-server arguments"));
682 }
683
684 #[tokio::test]
685 async fn input_requires_a_completed_handshake() {
686 let controller = controller();
687 let error = controller
688 .send_input(BlockInputUnion::data(b"hello".to_vec()), None)
689 .unwrap_err();
690 assert!(error.contains("not initialized"));
691 }
692
693 fn controller_with_broker() -> (Arc<AppServerController>, Arc<mps::Broker>) {
706 let broker = Arc::new(mps::Broker::new());
707 let controller = Arc::new(AppServerController::new(
708 "block-1".to_string(),
709 Some(broker.clone()),
710 None,
711 None,
712 None,
713 ));
714 controller.set_self_ref();
715 (controller, broker)
716 }
717
718 #[test]
719 fn set_status_with_a_broker_does_not_deadlock_locking_its_own_mutex() {
720 let (controller, _broker) = controller_with_broker();
721 let for_thread = controller.clone();
722 let (tx, rx) = std::sync::mpsc::channel();
723 std::thread::spawn(move || {
724 for_thread.stop(false, STATUS_DONE).unwrap();
725 let _ = tx.send(());
726 });
727 rx.recv_timeout(Duration::from_secs(2)).expect(
728 "set_status must not deadlock re-locking its own mutex once a broker is configured \
729 — every production controller has one",
730 );
731 assert_eq!(controller.get_runtime_status().shellprocstatus, STATUS_DONE);
732 }
733
734 fn fake_server_binary() -> &'static std::path::PathBuf {
735 crate::test_support::fake_app_server_binary()
736 }
737
738 fn app_server_meta(mode: &str) -> MetaMapType {
739 let mut meta = MetaMapType::new();
740 meta.insert(
741 "cmd".to_string(),
742 serde_json::json!(fake_server_binary().to_string_lossy()),
743 );
744 meta.insert(
745 "cmd:env".to_string(),
746 serde_json::json!({"AGENTMUX_FAKE_APP_SERVER_MODE": mode}),
747 );
748 meta
749 }
750
751 async fn wait_until(predicate: impl Fn() -> bool, timeout: Duration) -> bool {
752 let deadline = tokio::time::Instant::now() + timeout;
753 loop {
754 if predicate() {
755 return true;
756 }
757 if tokio::time::Instant::now() >= deadline {
758 return false;
759 }
760 tokio::time::sleep(Duration::from_millis(20)).await;
761 }
762 }
763
764 fn session_turn_id(controller: &AppServerController) -> Option<String> {
773 controller
774 .inner
775 .lock()
776 .unwrap()
777 .session
778 .as_ref()
779 .and_then(|session| session.snapshot().turn_id)
780 }
781
782 #[tokio::test]
783 async fn spawn_event_loop_reaps_the_process_and_clears_stale_state_on_exit() {
784 let (controller, _broker) = controller_with_broker();
785 controller
786 .start(app_server_meta("exit-after-thread-start"), None, false)
787 .unwrap();
788
789 let reached_done = wait_until(
790 || controller.get_runtime_status().shellprocstatus == STATUS_DONE,
791 Duration::from_secs(5),
792 )
793 .await;
794 assert!(
795 reached_done,
796 "controller never reached STATUS_DONE after the fake server exited"
797 );
798
799 let status = controller.get_runtime_status();
800 assert_eq!(
801 status.shellprocexitcode, 42,
802 "must report the process's real exit code instead of a hardcoded stale one"
803 );
804 assert!(!status.turn_active);
805
806 let error = controller
807 .send_message("too late".to_string())
808 .unwrap_err();
809 assert!(
810 error.contains("not initialized"),
811 "process/session must be cleared on exit so a post-exit message is rejected \
812 instead of silently accepted by a session bound to a dead transport, got {error:?}",
813 );
814 }
815
816 #[tokio::test]
817 async fn a_second_message_while_a_turn_is_active_does_not_kill_the_controller() {
818 let (controller, _broker) = controller_with_broker();
819 controller
820 .start(app_server_meta("ready-for-turn"), None, false)
821 .unwrap();
822
823 let session_ready = wait_until(
827 || controller.inner.lock().unwrap().session.is_some(),
828 Duration::from_secs(5),
829 )
830 .await;
831 assert!(session_ready, "controller session was never established");
832
833 controller
834 .send_message("first message".to_string())
835 .unwrap();
836
837 let turn_id_set =
842 wait_until(|| session_turn_id(&controller).is_some(), Duration::from_secs(5)).await;
843 assert!(turn_id_set, "first turn's turn/start round-trip never completed");
844 assert!(controller.health_monitor.is_active_turn());
845 assert_eq!(controller.get_runtime_status().shellprocstatus, STATUS_RUNNING);
846
847 controller
851 .send_message("second message while the first turn is still running".to_string())
852 .unwrap();
853
854 tokio::time::sleep(Duration::from_millis(200)).await;
857
858 assert!(
859 controller.health_monitor.is_active_turn(),
860 "the ORIGINAL turn is still active; a rejected second turn must not clear that"
861 );
862 assert_eq!(
863 controller.get_runtime_status().shellprocstatus,
864 STATUS_RUNNING,
865 "a rejected second turn must not mark a live controller done"
866 );
867 assert_eq!(
868 controller.inner.lock().unwrap().pending_messages.len(),
869 1,
870 "the rejected message must be queued, not dropped (ReAgent P1, PR #3215 2nd review)"
871 );
872
873 controller.stop(true, STATUS_DONE).unwrap();
879 }
880
881 #[tokio::test]
885 async fn a_queued_message_is_sent_once_the_active_turn_completes() {
886 let (controller, _broker) = controller_with_broker();
887 controller
888 .start(app_server_meta("requeue-after-turn-completes"), None, false)
889 .unwrap();
890
891 let session_ready = wait_until(
892 || controller.inner.lock().unwrap().session.is_some(),
893 Duration::from_secs(5),
894 )
895 .await;
896 assert!(session_ready, "controller session was never established");
897
898 controller
899 .send_message("first message".to_string())
900 .unwrap();
901 let turn_id_set =
902 wait_until(|| session_turn_id(&controller).is_some(), Duration::from_secs(5)).await;
903 assert!(turn_id_set, "first turn's turn/start round-trip never completed");
904
905 controller
910 .send_message("queued while turn 1 is active".to_string())
911 .unwrap();
912 let queued = wait_until(
913 || controller.inner.lock().unwrap().pending_messages.len() == 1,
914 Duration::from_secs(1),
915 )
916 .await;
917 assert!(queued, "second message was not queued — test is racing the fixture's notification");
918
919 let requeued_message_was_sent = wait_until(
923 || controller.inner.lock().unwrap().pending_messages.is_empty(),
924 Duration::from_secs(5),
925 )
926 .await;
927 assert!(
928 requeued_message_was_sent,
929 "queued message was never drained after the active turn completed"
930 );
931 let second_turn_active = wait_until(
932 || controller.health_monitor.is_active_turn(),
933 Duration::from_secs(5),
934 )
935 .await;
936 assert!(
937 second_turn_active,
938 "the requeued message's turn never became active — it was drained but never actually sent"
939 );
940
941 controller.stop(true, STATUS_DONE).unwrap();
942 }
943
944 #[tokio::test]
950 async fn a_failed_handshake_clears_state_so_a_later_start_can_actually_respawn() {
951 let (controller, _broker) = controller_with_broker();
952 controller
953 .start(app_server_meta("exit-before-init"), None, false)
954 .unwrap();
955
956 let reached_done = wait_until(
957 || controller.get_runtime_status().shellprocstatus == STATUS_DONE,
958 Duration::from_secs(5),
959 )
960 .await;
961 assert!(reached_done, "controller never reached STATUS_DONE after the failed handshake");
962 assert!(
963 controller.inner.lock().unwrap().process.is_none(),
964 "a failed handshake must clear the dead process, not leave start()'s guard permanently tripped"
965 );
966 assert!(controller.inner.lock().unwrap().session.is_none());
967
968 controller
972 .start(app_server_meta("ready-for-turn"), None, false)
973 .unwrap();
974 let respawned_session_ready = wait_until(
975 || controller.inner.lock().unwrap().session.is_some(),
976 Duration::from_secs(5),
977 )
978 .await;
979 assert!(
980 respawned_session_ready,
981 "start() after a failed handshake never respawned — the block is permanently bricked"
982 );
983
984 controller.stop(true, STATUS_DONE).unwrap();
985 }
986
987 #[tokio::test]
999 async fn stop_clears_process_and_session_synchronously_so_an_immediate_restart_actually_respawns()
1000 {
1001 let (controller, _broker) = controller_with_broker();
1002 controller
1003 .start(app_server_meta("ready-for-turn"), None, false)
1004 .unwrap();
1005 let session_ready = wait_until(
1006 || controller.inner.lock().unwrap().session.is_some(),
1007 Duration::from_secs(5),
1008 )
1009 .await;
1010 assert!(session_ready, "controller session was never established");
1011
1012 controller.stop(true, STATUS_DONE).unwrap();
1013
1014 {
1017 let inner = controller.inner.lock().unwrap();
1018 assert!(
1019 inner.process.is_none(),
1020 "stop() must clear the process handle synchronously, not after the async shutdown completes"
1021 );
1022 assert!(inner.session.is_none());
1023 }
1024
1025 controller
1028 .start(app_server_meta("ready-for-turn"), None, false)
1029 .unwrap();
1030 let respawned = wait_until(
1031 || controller.inner.lock().unwrap().session.is_some(),
1032 Duration::from_secs(5),
1033 )
1034 .await;
1035 assert!(
1036 respawned,
1037 "start() called immediately after stop() never respawned -- the stale process.is_some() guard silently no-op'd"
1038 );
1039
1040 controller.stop(true, STATUS_DONE).unwrap();
1041 }
1042
1043 #[tokio::test]
1051 async fn send_input_with_a_kill_signal_updates_status_to_done() {
1052 let (controller, _broker) = controller_with_broker();
1053 controller
1054 .start(app_server_meta("ready-for-turn"), None, false)
1055 .unwrap();
1056 let session_ready = wait_until(
1057 || controller.inner.lock().unwrap().session.is_some(),
1058 Duration::from_secs(5),
1059 )
1060 .await;
1061 assert!(session_ready, "controller session was never established");
1062 assert_eq!(controller.get_runtime_status().shellprocstatus, STATUS_RUNNING);
1063
1064 controller
1065 .send_input(BlockInputUnion::signal("SIGTERM"), None)
1066 .unwrap();
1067
1068 assert_eq!(
1069 controller.get_runtime_status().shellprocstatus,
1070 STATUS_DONE,
1071 "a signal-triggered kill must update proc_status the same way stop() does -- \
1072 nothing else updates it for this path"
1073 );
1074 }
1075
1076 #[tokio::test]
1087 async fn a_stale_event_loop_does_not_clobber_a_respawned_process() {
1088 let (controller, _broker) = controller_with_broker();
1089 controller
1090 .start(app_server_meta("ready-for-turn"), None, false)
1091 .unwrap();
1092 let session_ready = wait_until(
1093 || controller.inner.lock().unwrap().session.is_some(),
1094 Duration::from_secs(5),
1095 )
1096 .await;
1097 assert!(session_ready, "controller session was never established");
1098 let old_process = controller.inner.lock().unwrap().process.clone().unwrap();
1099
1100 controller.stop_process();
1104
1105 controller
1108 .start(app_server_meta("ready-for-turn"), None, false)
1109 .unwrap();
1110 let respawned = wait_until(
1111 || controller.inner.lock().unwrap().session.is_some(),
1112 Duration::from_secs(5),
1113 )
1114 .await;
1115 assert!(
1116 respawned,
1117 "the respawn immediately after stop_process() never installed a new session"
1118 );
1119 let new_process_ptr =
1120 Arc::as_ptr(controller.inner.lock().unwrap().process.as_ref().unwrap());
1121
1122 let _ = old_process.wait_for_exit().await;
1128 for _ in 0..100 {
1129 tokio::task::yield_now().await;
1130 }
1131
1132 assert_ne!(
1133 controller.get_runtime_status().shellprocstatus,
1134 STATUS_DONE,
1135 "the stale old event loop must not stamp STATUS_DONE over the respawned process"
1136 );
1137 let inner = controller.inner.lock().unwrap();
1138 assert!(
1139 inner.session.is_some(),
1140 "the stale old event loop must not clear the respawned session"
1141 );
1142 assert_eq!(
1143 Arc::as_ptr(inner.process.as_ref().unwrap()),
1144 new_process_ptr,
1145 "inner.process must still be the respawned process, not cleared by the stale old event loop"
1146 );
1147 }
1148
1149 fn usage(turn: &str, output: u64) -> serde_json::Value {
1150 serde_json::json!({
1151 "threadId": "t",
1152 "turnId": turn,
1153 "tokenUsage": {
1154 "total": { "outputTokens": 9_999 },
1155 "last": { "outputTokens": output },
1156 "modelContextWindow": null,
1157 },
1158 })
1159 }
1160
1161 #[test]
1162 fn a_codex_turn_s_figures_sum_its_calls_and_start_over_per_turn() {
1163 let mut u = CodexTurnUsage::default();
1164 assert_eq!(u.take(), None, "no call reported usage");
1165 u.note("thread/tokenUsage/updated", &usage("turn-1", 120));
1166 u.note("item/completed", &usage("turn-1", 5_000));
1167 u.note("thread/tokenUsage/updated", &usage("turn-1", 30));
1168 let stats = u.take().unwrap();
1169 assert_eq!((stats.output_tokens, stats.steps), (150, 2), "each call's last, not the thread total");
1170 assert_eq!(u.take(), None, "taken once");
1171
1172 u.note("thread/tokenUsage/updated", &usage("turn-2", 70));
1174 u.note("thread/tokenUsage/updated", &usage("turn-3", 40));
1175 let stats = u.take().unwrap();
1176 assert_eq!((stats.output_tokens, stats.steps), (40, 1));
1177 assert_eq!(stats.cost_usd, 0.0);
1178 }
1179}