Skip to main content

agentmux_srv/backend/blockcontroller/
app_server_controller.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! First-class controller shell for a Codex App Server process.
5//!
6//! The protocol implementation lives in [`super::app_server`] and
7//! [`super::app_server_protocol`]. This module owns the AgentMux controller
8//! lifecycle: command construction, process/thread startup, turn dispatch,
9//! block output publication, and shutdown. Codex remains opt-in until the
10//! provider registry flips its controller type.
11
12#[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
47/// Controller for one isolated `codex app-server --listen stdio://` child.
48pub 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        // Scoped: `publish_status` -> `get_runtime_status` re-locks `self.inner`.
93        // Holding this guard across that call deadlocks (std::sync::Mutex isn't
94        // reentrant) — masked in the existing unit tests because they all
95        // construct the controller with `broker: None`, which short-circuits
96        // `publish_status` before it ever re-locks. Any real controller (always
97        // built with `Some(broker)`) would hang the instant `start()` calls
98        // `set_status(STATUS_RUNNING)`.
99        {
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        // Identity M4b-2 (spec §6.5.8): carry the block's row UID + token.
135        // Buys no attribution today — no provider maps to App Server, and
136        // codex is not given agentmux-mcp — but keeps the counters true.
137        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        // The workspace the "Your workspace" Operator Config entry points at.
145        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        // Identity M4a: record what this process is actually given.
150        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    /// `global_zone` is resolved once per process by `spawn_event_loop`, not
166    /// per frame: resolving reads the block from the store, and frames arrive
167    /// many times a second while a turn streams.
168    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            // Mirrored into the agent's global zone like every other agent's
175            // output (Phase 5a-3c): a pane reads the global zone when the
176            // block has an agentId, so frames written only to the block file
177            // were missing from its history.
178            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        // The agent's global transcript zone, resolved once for this process
196        // (as the host and container spawn paths do), not per frame.
197        let global_zone = super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
198        tokio::spawn(async move {
199            // The running turn's figures, for its ledger: summed over its model
200            // calls, reset when a call reports a different turn.
201            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                // KNOWN GAP (ReAgent P1, PR #3215, tracked for a follow-up PR, not
207                // fixed here): frontend/app/view/agent/providers/codex-translator.ts
208                // (selected for outputFormat "codex-json") only understands the
209                // dotted `codex exec --json` event shapes (e.g. `item.completed`) —
210                // it has no branch for either envelope type below, so this frame is
211                // persisted/broadcast but never rendered in the pane. Fixing this is
212                // a frontend rendering-pipeline change (needs its own dev-server
213                // verification per this repo's UI-change policy), not a mechanical
214                // backend patch, so it's deliberately left for a follow-up PR rather
215                // than rushed in alongside this one's already-large fix history.
216                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                                    // Send the next queued message (if any) now that
237                                    // the turn it collided with has finished — one at
238                                    // a time, since only one turn can be active. Any
239                                    // remainder stays queued for the NEXT completion.
240                                    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            // The transport closed (EOF/crash/shutdown). Reap the child for its
257            // real exit status instead of hardcoding one, and clear the process/
258            // session so a subsequent `start()` (resync) doesn't see a stale
259            // `process.is_some()` and wrongly no-op on a controller that is, from
260            // here on, actually done.
261            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                // Only report/clear state for OUR OWN process. stop_process()
269                // (from stop() or a signal-triggered kill) synchronously takes
270                // inner.process out and can be followed by a resync's start()
271                // installing a brand-new one, all before this event loop's own
272                // process notices EOF (which can take up to the shutdown grace
273                // period). Without this check, that stale EOF firing here would
274                // wipe out the new, live process/session and stamp STATUS_DONE
275                // with the OLD process's exit code over an actually-running
276                // block (ReAgent P0, PR #3215).
277                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        // Kept alongside `message` (which `start_turn` consumes) so a
300        // `TurnAlreadyActive` rejection can be requeued instead of lost.
301        let requeue_message = message.clone();
302        tokio::spawn(async move {
303            if let Err(error) = session.start_turn(message, OPERATION_TIMEOUT).await {
304                // `TurnAlreadyActive` means a DIFFERENT turn is still running —
305                // this attempt (queued via `send_message`, which already returned
306                // `Ok` to its caller) was rejected, but the original turn and the
307                // process behind it are still very much alive. Treating that the
308                // same as a real failure marked a live controller STATUS_DONE and
309                // health inactive out from under its own running turn.
310                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                        // ReAgent P1 on PR #3215's second review: don't just log and
318                        // drop it — `send_message` already told its caller `Ok`, so
319                        // this is the only chance to actually deliver it. Queue it
320                        // for `spawn_event_loop`'s `TurnCompleted` handler to send
321                        // once the turn that's currently active finishes.
322                        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    /// Clear a dead process/session so a later `start()` (resync) doesn't see
361    /// its own stale `process.is_some()` guard and wrongly no-op instead of
362    /// respawning. ReAgent P1 on PR #3215's third review: `start()`'s own
363    /// initialize/thread-setup failure branches set STATUS_DONE and shut the
364    /// process down without this, unlike the analogous EOF path in
365    /// `spawn_event_loop` — a handshake failure permanently bricked the block
366    /// with no recovery short of replacing the controller entirely.
367    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        // Cleared SYNCHRONOUSLY, before the async shutdown even starts —
375        // not after it completes. ReAgent P1, PR 3215 (3rd review): stop()
376        // sets the caller's new_status synchronously right after calling
377        // this, but the old version only cleared inner.process/session
378        // inside the spawned task, after up to the 2s shutdown grace
379        // period. A resync_controller call landing in that window saw the
380        // new terminal status, called start(), but start()'s
381        // process.is_some() guard was still true from the stale handle —
382        // silently no-op'd instead of respawning. Taking the process out
383        // of `inner` here (not just cloning it) means start()'s guard sees
384        // the truth immediately; the actual OS-level kill still happens in
385        // the background using this locally-owned handle.
386        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            // Only one turn can be active at a time — start just the first
496            // queued message now; the rest stay queued and are sent one at a
497            // time as each turn completes (spawn_event_loop's `TurnCompleted`
498            // handler). Draining and spawning all of them here concurrently
499            // meant every message but the first immediately hit
500            // `TurnAlreadyActive` and was silently lost (ReAgent P1, PR #3215).
501            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                // ReAgent P1, PR 3215 (3rd review): stop_process() alone
532                // never updates inner.proc_status -- its spawned task only
533                // clears inner.process/session once the kill actually
534                // completes. Without this, get_runtime_status kept
535                // reporting the pre-signal status (e.g. "running") forever
536                // after a signal-triggered kill, since nothing else updates
537                // it for this path (unlike Controller::stop(), which
538                // already sets a caller-provided status synchronously).
539                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/// A Codex turn's figures for the turn ledger (`PassStats`), from
570/// `thread/tokenUsage/updated`: one per model call, whose `tokenUsage.last`
571/// is that call's usage (`total` is the whole thread's). Codex reports no
572/// cost or API time, so those stay zero.
573#[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    /// The finished turn's figures, if any call reported usage; starts over.
596    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    /// Identity M4b-2 wiring (spec §6.5.8): with a store, the App Server
606    /// command for a row-backed block carries the row's UID + token.
607    #[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    // ── Regression coverage for ReAgent's PR #3215 review ────────────────────
694    // Four bugs: (1) `spawn_turn`'s error path couldn't tell "a second turn
695    // was rejected because one is already running" from "the process is
696    // actually dead", (2) `spawn_event_loop`'s EOF branch never reaped the
697    // child or cleared stale process/session state, (3) `deliver_agent_message`
698    // had no route to this controller at all (covered in blockcontroller/mod.rs's
699    // own tests), (4) `agent_open.rs`'s cli_args selection never looked at
700    // `provider.app_server` (covered in agent_open.rs's own tests). Plus one
701    // deadlock this investigation found independently: `set_status` re-locks
702    // its own mutex from inside the guard it's still holding, masked in every
703    // pre-existing test here because they all use `broker: None`.
704
705    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    /// The session's own `turn_id`, straight from `CodexAppServerSession`'s
765    /// state — unlike `health_monitor.is_active_turn()` (which flips to
766    /// `true` synchronously in `spawn_turn`, BEFORE the `turn/start` request
767    /// even goes out), this only becomes `Some` once the round-trip actually
768    /// completes. Tests that need to guarantee a SECOND `send_message` hits
769    /// `start_turn`'s local `TurnAlreadyActive` check (rather than racing it
770    /// and sending its own concurrent `turn/start`) must synchronize on this,
771    /// not on `is_active_turn()`.
772    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        // `start()` only completes the handshake/thread setup — it does not
824        // start a turn on its own. Wait for the session to actually exist
825        // before sending the first message.
826        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        // Wait for the ACTUAL turn/start round-trip to finish (not just
838        // `is_active_turn()`, which flips true before the request is even
839        // sent) so the second message below is guaranteed to hit the local
840        // `TurnAlreadyActive` check instead of racing it.
841        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        // `start_turn`'s `TurnAlreadyActive` check is local (state.turn_id is
848        // already Some from the first turn) — this never touches the network,
849        // so no fixture response is needed for it to fire.
850        controller
851            .send_message("second message while the first turn is still running".to_string())
852            .unwrap();
853
854        // Give the rejected turn's spawned task a moment to run (and, pre-fix,
855        // wrongly tear down the controller out from under the live first turn).
856        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        // Cleanup: this fixture mode idles forever rather than exiting on its
874        // own, and the event loop task holds its own Arc<AppServerProcess> for
875        // as long as it's reading — `kill_on_drop` never fires without an
876        // explicit stop, which would otherwise leak the child for the rest of
877        // this test binary's run.
878        controller.stop(true, STATUS_DONE).unwrap();
879    }
880
881    /// End-to-end version of the test above: the queued message must actually
882    /// get SENT once the turn it collided with completes, not just survive in
883    /// the queue forever. ReAgent P1 on PR #3215's second review.
884    #[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        // The fixture waits 300ms after responding to turn/start before
906        // sending turn/completed specifically so this has time to land while
907        // state.turn_id is still Some — i.e. the rejection-and-queue path,
908        // not a normal second turn started after the first already finished.
909        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        // Once turn/completed arrives, the event loop must drain the queue
920        // and start the second turn — the fixture only responds to a SECOND
921        // turn/start if the controller actually sends one.
922        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    /// ReAgent P1 on PR #3215's third review: a handshake failure (here,
945    /// the child dying before `initialize` completes) must not permanently
946    /// brick the block. `start()`'s own guard (`process.is_some()`) means a
947    /// stale process/session left behind by a failed handshake makes every
948    /// later resync a silent no-op instead of a real respawn.
949    #[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        // If the bug were still present, this would silently no-op (the old
969        // `process.is_some()` guard still passing) instead of actually
970        // spawning a new child — and the assertions below would time out.
971        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    /// ReAgent P1, PR 3215 (3rd review): stop() sets new_status
988    /// synchronously, but the process/session used to only get cleared
989    /// once stop_process()'s spawned shutdown task finished (up to a 2s
990    /// grace period later). A resync landing in that window saw the new
991    /// terminal status, called start(), but start()'s process.is_some()
992    /// guard was still true from the stale handle -- silently no-op'd
993    /// instead of respawning. This asserts the fix synchronously, with no
994    /// wait_until/sleep at all: process/session must already be cleared
995    /// the instant stop() returns, and a start() called immediately after
996    /// (not once some background task gets around to it) must actually
997    /// respawn.
998    #[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        // No wait_until here on purpose -- this must already be true the
1015        // instant stop() returns, not eventually.
1016        {
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        // Immediately, not after any delay: a resync landing right after
1026        // stop() must see a clean slate and actually respawn.
1027        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    /// ReAgent P2, PR #3215 (re-review): the send_input SIGINT/SIGTERM
1044    /// status-update fix (flagged in three prior review rounds) had no
1045    /// dedicated regression test, unlike every other fix in this file.
1046    /// stop_process() alone only clears inner.process/session once the
1047    /// spawned shutdown task completes; without the explicit
1048    /// set_status(STATUS_DONE) call, get_runtime_status kept reporting the
1049    /// pre-signal status forever after a signal-triggered kill.
1050    #[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    /// ReAgent P0, PR #3215 (re-review): spawn_event_loop's EOF/reap branch
1077    /// unconditionally overwrote inner.process/session/proc_exit_code and
1078    /// forced STATUS_DONE, without checking it was still reaping ITS OWN
1079    /// process. stop_process() (from stop()/a signal-triggered kill)
1080    /// synchronously takes inner.process out -- possibly followed by a
1081    /// resync's start() installing a brand-new one -- well before the OLD
1082    /// event loop notices its own process's EOF, which can take up to the
1083    /// shutdown grace period. When that stale EOF finally fired, it wiped
1084    /// out the new, live process/session and stamped STATUS_DONE with the
1085    /// OLD exit code over an actually-running block.
1086    #[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        // Simulates stop()/a signal-triggered kill: takes inner.process out
1101        // synchronously and starts the real (2s-timeout) shutdown in the
1102        // background -- exactly what stop_process() does.
1103        controller.stop_process();
1104
1105        // Respawn immediately, well before the old process's real OS-level
1106        // kill + EOF can complete. This is the race window.
1107        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        // Wait for the OLD process to actually finish exiting at the OS
1123        // level -- the old event loop awaits this same call internally, so
1124        // once this resolves its post-await reap logic is about to run (or
1125        // already has). A bounded number of scheduling turns afterward lets
1126        // it actually complete before asserting.
1127        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        // Usage left from a turn that never completed is not the next turn's.
1173        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}