Skip to main content

pi/modes/
run.rs

1//! Mode dispatch: route a resolved [`AppMode`] to the right runner, with
2//! signal handling and guaranteed `dispose`.
3//!
4//! The concrete RPC server (`run_rpc_mode`), interactive TUI, and the live
5//! print-mode event wiring are owned by sibling slices and are still landing.
6//! To stay compilable and unit-testable today, [`run_mode_default`] dispatches
7//! through an injected [`ModeDispatch`] trait: each method corresponds to one
8//! application mode and returns an exit code. The integrator (Main) wires the
9//! real implementations once they land; tests inject fakes.
10//!
11//! # Signal handling
12//!
13//! [`run_mode_with_codes`] races the dispatched mode future against SIGINT /
14//! SIGTERM / SIGHUP handlers. A shared first-wins sender ensures exactly one
15//! signal's exit code reaches the dispatcher; the others are dropped. When a
16//! signal arrives first, the mode future is dropped (cancelled) and the
17//! canonical signal exit code is returned.
18//!
19//! # Dispose guarantee
20//!
21//! `runtime.dispose()` runs on every exit path (mode completion, signal,
22//! error) because `run_mode_with_codes` awaits it explicitly before
23//! returning. No `Drop`-based `block_on`: the caller's tokio runtime owns the
24//! lifecycle.
25//!
26//! # Signal exit codes (match `modes/print-mode.ts` + `modes/rpc/rpc-mode.ts`)
27//!
28//! | signal / condition      | exit | notes                       |
29//! |-------------------------|------|-----------------------------|
30//! | stdin EOF (RPC)         | 0    | clean shutdown (mode path)  |
31//! | SIGINT / SIGTERM        | 143  | 128 + 15                    |
32//! | SIGHUP (unix)           | 129  | 128 + 1                     |
33//! | normal completion       | mode-determined                 |
34
35use std::process::ExitCode;
36use std::sync::{Arc, Mutex};
37
38use futures::FutureExt;
39use futures::future::BoxFuture;
40use tokio::sync::oneshot;
41
42use crate::cli::bootstrap::{AppMode, Dispatched};
43use crate::core::agent_session_runtime::AgentSessionRuntime;
44use crate::core::output_guard;
45
46/// Dispatcher injected by the integrator.
47///
48/// Each method receives the resolved [`Dispatched`] (mode, runtime handle,
49/// initial message, follow-ups, migrations) and returns the process exit code
50/// for that mode. Implementations are expected to bind extensions, subscribe
51/// session events, drive the mode loop, and return the final exit code.
52///
53/// All methods take `&self` so a single dispatcher can be reused across
54/// `/reload` cycles (each reload rebuilds the runtime but not the dispatcher).
55pub trait ModeDispatch: Send + Sync {
56    /// Run the interactive TUI mode.
57    ///
58    /// # Errors
59    /// Implementation-defined; the error is surfaced on stderr and the
60    /// dispatcher falls back to exit 1.
61    fn run_interactive(
62        &self,
63        dispatched: Dispatched,
64        runtime: Arc<AgentSessionRuntime>,
65    ) -> BoxFuture<'_, Result<u8, String>>;
66
67    /// Run the text or JSON print mode.
68    ///
69    /// `mode` is guaranteed to be [`AppMode::Print`] or [`AppMode::Json`].
70    ///
71    /// # Errors
72    /// Implementation-defined.
73    fn run_print(
74        &self,
75        dispatched: Dispatched,
76        runtime: Arc<AgentSessionRuntime>,
77    ) -> BoxFuture<'_, Result<u8, String>>;
78
79    /// Run the headless RPC server. Returns when stdin EOF arrives or a
80    /// signal cancels the run.
81    ///
82    /// # Errors
83    /// Implementation-defined.
84    fn run_rpc(
85        &self,
86        dispatched: Dispatched,
87        runtime: Arc<AgentSessionRuntime>,
88    ) -> BoxFuture<'_, Result<u8, String>>;
89}
90
91/// Run the resolved mode to completion with canonical signal handling.
92///
93/// Installs signal handlers, dispatches to the right [`ModeDispatch`] method,
94/// guarantees `dispose`, and returns a process [`ExitCode`]. The dispatch
95/// future is raced against the signal handlers; a signal cancels the mode and
96/// returns its exit code.
97pub async fn run_mode_default(dispatched: Dispatched, handler: &dyn ModeDispatch) -> ExitCode {
98    run_mode_with_codes(dispatched, handler, SignalCodes::default()).await
99}
100
101/// Exit codes for each signal condition.
102#[derive(Clone, Copy, Debug)]
103pub struct SignalCodes {
104    /// SIGINT (Ctrl+C) / SIGTERM.
105    pub sigterm: u8,
106    /// SIGHUP (Unix only; ignored on Windows).
107    pub sighup: Option<u8>,
108}
109
110impl Default for SignalCodes {
111    fn default() -> Self {
112        Self {
113            sigterm: defaults::SIGTERM,
114            #[cfg(unix)]
115            sighup: Some(defaults::SIGHUP),
116            #[cfg(not(unix))]
117            sighup: None,
118        }
119    }
120}
121
122/// Canonical exit codes matching the TypeScript reference.
123pub mod defaults {
124    /// Exit code for RPC stdin EOF.
125    pub const STDIN_EOF: u8 = 0;
126    /// Exit code for SIGTERM / SIGINT.
127    pub const SIGTERM: u8 = 143;
128    /// Exit code for SIGHUP on unix.
129    pub const SIGHUP: u8 = 129;
130}
131
132/// Run with explicit signal codes. Exposed for tests that want to observe
133/// the signal-race machinery.
134pub async fn run_mode_with_codes(
135    dispatched: Dispatched,
136    handler: &dyn ModeDispatch,
137    codes: SignalCodes,
138) -> ExitCode {
139    let mode = dispatched.mode;
140    let runtime = dispatched.handle.runtime.clone();
141
142    let signal = SignalRelay::install(codes);
143    let signal_rx = signal.take_rx();
144
145    let mode_fut = match mode {
146        AppMode::Interactive => handler
147            .run_interactive(dispatched, Arc::clone(&runtime))
148            .boxed(),
149        AppMode::Print | AppMode::Json => {
150            handler.run_print(dispatched, Arc::clone(&runtime)).boxed()
151        }
152        AppMode::Rpc => handler.run_rpc(dispatched, Arc::clone(&runtime)).boxed(),
153    };
154
155    let result = tokio::select! {
156        biased;
157        code = async {
158            match signal_rx {
159                Some(rx) => rx.await.unwrap_or(defaults::STDIN_EOF),
160                None => defaults::STDIN_EOF,
161            }
162        } => Ok(code),
163        outcome = mode_fut => outcome,
164    };
165
166    signal.cancel().await;
167
168    // Restore stdout (print/rpc took it over).
169    if !mode.is_interactive() {
170        output_guard::restore_stdout();
171    }
172
173    // Dispose before returning the exit code.
174    runtime.dispose().await;
175
176    let exit_code = match result {
177        Ok(code) => code,
178        Err(message) => {
179            output_guard::ProductOutput::writeln(&format!("Error: {message}"));
180            1
181        }
182    };
183    ExitCode::from(exit_code)
184}
185
186/// One-shot relay shared across all signal handlers. The first handler to
187/// call [`SignalRelayHandle::fire`] wins; subsequent calls are dropped.
188struct SignalRelay {
189    /// `Some` until the first signal fires; `None` afterwards.
190    sender: Mutex<Option<oneshot::Sender<u8>>>,
191    /// Cancellation token for the background handler tasks.
192    cancel: tokio_util::sync::CancellationToken,
193    /// Receiver. Wrapped in a Mutex so `rx()` can take it exactly once.
194    receiver: Mutex<Option<oneshot::Receiver<u8>>>,
195}
196
197/// Handle returned to the caller so it can await the signal and later cancel
198/// the handlers.
199pub struct SignalRelayHandle {
200    relay: Arc<SignalRelay>,
201}
202
203impl SignalRelayHandle {
204    /// Take the receiver out of the handle so it can be awaited independently.
205    /// Returns `None` if already taken.
206    fn take_rx(&self) -> Option<oneshot::Receiver<u8>> {
207        self.relay
208            .receiver
209            .lock()
210            .unwrap_or_else(std::sync::PoisonError::into_inner)
211            .take()
212    }
213
214    /// Cancel the background handler tasks (best-effort).
215    async fn cancel(&self) {
216        self.relay.cancel.cancel();
217        // Give the handler tasks a chance to observe cancellation.
218        tokio::task::yield_now().await;
219    }
220}
221
222impl SignalRelay {
223    /// Install the signal handlers and return a handle.
224    fn install(codes: SignalCodes) -> SignalRelayHandle {
225        let (tx, rx) = oneshot::channel::<u8>();
226        let relay = Arc::new(SignalRelay {
227            sender: Mutex::new(Some(tx)),
228            cancel: tokio_util::sync::CancellationToken::new(),
229            receiver: Mutex::new(Some(rx)),
230        });
231
232        // SIGINT (Ctrl+C) — treated like SIGTERM. Always installed (cross-platform).
233        let int_relay = Arc::clone(&relay);
234        let int_cancel = relay.cancel.clone();
235        tokio::spawn(async move {
236            tokio::select! {
237                biased;
238                () = int_cancel.cancelled() => {}
239                res = tokio::signal::ctrl_c() => {
240                    if let Ok(()) = res {
241                        fire(&int_relay, codes.sigterm);
242                    }
243                }
244            }
245        });
246
247        #[cfg(unix)]
248        {
249            use tokio::signal::unix::{SignalKind, signal};
250            // SIGTERM.
251            if let Ok(mut stream) = signal(SignalKind::terminate()) {
252                let term_relay = Arc::clone(&relay);
253                let term_cancel = relay.cancel.clone();
254                let term_code = codes.sigterm;
255                tokio::spawn(async move {
256                    tokio::select! {
257                        biased;
258                        () = term_cancel.cancelled() => {}
259                        _ = stream.recv() => {
260                            fire(&term_relay, term_code);
261                        }
262                    }
263                });
264            }
265            // SIGHUP.
266            if let Some(hup_code) = codes.sighup
267                && let Ok(mut stream) = signal(SignalKind::hangup())
268            {
269                let hup_relay = Arc::clone(&relay);
270                let hup_cancel = relay.cancel.clone();
271                tokio::spawn(async move {
272                    tokio::select! {
273                        biased;
274                        () = hup_cancel.cancelled() => {}
275                        _ = stream.recv() => {
276                            fire(&hup_relay, hup_code);
277                        }
278                    }
279                });
280            }
281        }
282
283        #[cfg(not(unix))]
284        {
285            let _ = codes;
286        }
287
288        SignalRelayHandle { relay }
289    }
290}
291
292/// Fire the relay with `code` if no signal has fired yet. First-wins.
293fn fire(relay: &Arc<SignalRelay>, code: u8) {
294    let sender = {
295        let mut guard = relay
296            .sender
297            .lock()
298            .unwrap_or_else(std::sync::PoisonError::into_inner);
299        guard.take()
300    };
301    if let Some(tx) = sender {
302        let _ = tx.send(code);
303    }
304}
305
306// ---------------------------------------------------------------------------
307// Concrete print-mode binding (live AgentSessionRuntime)
308// ---------------------------------------------------------------------------
309
310use crate::core::agent_session::AgentSessionEvent;
311use crate::core::agent_session::prompt::PromptOptions;
312use crate::modes::print::{OutputGuardSink, PrintModeOptions, PrintOutput, run_print_mode};
313use tokio::sync::mpsc;
314
315/// Run print mode (text or JSON) against a live [`AgentSession`].
316///
317/// This is the concrete binding that connects the bootstrap's [`Dispatched`]
318/// (runtime + initial message + follow-ups) to the generic
319/// [`run_print_mode`] renderer:
320///
321/// 1. Subscribes to `AgentSessionEvent` via an unbounded channel bridge.
322/// 2. Reads the session header from the session manager (for JSON mode).
323/// 3. Drives prompts with `PromptOptions { source: "print"|"json" }`.
324/// 4. Calls `run_print_mode` with `OutputGuardSink`.
325/// 5. Returns the exit code (0 on success, 1 on error stop reason).
326///
327/// # Errors
328///
329/// Returns an error when prompting the session or rendering print output fails.
330pub async fn run_print_session(
331    dispatched: Dispatched,
332    runtime: Arc<AgentSessionRuntime>,
333) -> Result<u8, String> {
334    let print_output = if dispatched.mode.is_json() {
335        PrintOutput::Json
336    } else {
337        PrintOutput::Text
338    };
339    let session = runtime.session();
340
341    // Bind extensions before subscribing/prompting: emits the stored
342    // session_start{startup} and runs bind-time resource discovery
343    // (upstream print-mode parity). Bind errors are non-fatal.
344    let _ = session
345        .bind_extensions(crate::core::agent_session::ExtensionBindings {
346            mode: Some(if print_output.is_json() {
347                crate::core::agent_session::ExtensionMode::Json
348            } else {
349                crate::core::agent_session::ExtensionMode::Print
350            }),
351            ..Default::default()
352        })
353        .await;
354
355    // Session header (JSON mode only).
356    let header = if print_output.is_json() {
357        let sm = session.session_manager();
358        let sm_guard = sm.lock().await;
359        let sm = sm_guard;
360        sm.get_header().cloned()
361    } else {
362        None
363    };
364
365    // Subscribe to events via channel bridge.
366    let (event_tx, event_rx) = mpsc::unbounded_channel::<AgentSessionEvent>();
367    let unsubscribe = session.subscribe(move |event: &AgentSessionEvent| {
368        let _ = event_tx.send(event.clone());
369    });
370
371    let source_str = if print_output.is_json() {
372        "json"
373    } else {
374        "print"
375    };
376    let source_string = source_str.to_owned();
377    let initial_images = dispatched.initial_images.clone();
378    let initial_message = dispatched.initial_message.clone();
379    let remaining_messages = dispatched.remaining_messages.clone();
380    let session_for_prompts = Arc::clone(&session);
381
382    let prompt_driver = move || async move {
383        if let Some(initial) = initial_message.as_deref() {
384            let opts = PromptOptions {
385                images: initial_images.clone(),
386                source: Some(source_string.clone()),
387                ..PromptOptions::default()
388            };
389            if let Err(err) = session_for_prompts.prompt(initial, opts).await {
390                return Err(std::io::Error::other(format!("{err}")));
391            }
392        }
393        for msg in &remaining_messages {
394            let opts = PromptOptions {
395                source: Some(source_string.clone()),
396                ..PromptOptions::default()
397            };
398            if let Err(err) = session_for_prompts.prompt(msg, opts).await {
399                return Err(std::io::Error::other(format!("{err}")));
400            }
401        }
402        Ok(())
403    };
404
405    let options = PrintModeOptions {
406        mode: print_output,
407        messages: Vec::new(),
408        initial_message: dispatched.initial_message.clone(),
409        initial_images: dispatched.initial_images.clone(),
410    };
411
412    // Box::pin the unfold stream to satisfy the `Unpin` bound on
413    // `run_print_mode`'s `S` type parameter.
414    let event_stream = Box::pin(futures::stream::unfold(event_rx, |mut rx| async move {
415        rx.recv().await.map(|event| (event, rx))
416    }));
417
418    let exit_code = run_print_mode(
419        &options,
420        header.as_ref(),
421        event_stream,
422        prompt_driver,
423        unsubscribe,
424        &OutputGuardSink,
425    )
426    .await
427    .map_err(|e| format!("{e}"))?;
428
429    Ok(u8::try_from(exit_code).unwrap_or(1))
430}
431
432/// Runner for an injected application mode.
433pub type ModeRunner = dyn Fn(Dispatched, Arc<AgentSessionRuntime>) -> BoxFuture<'static, Result<u8, String>>
434    + Send
435    + Sync;
436
437/// Default dispatcher: concrete print/json mode, injectable RPC/interactive.
438///
439/// Print and JSON modes are wired directly to [`run_print_session`]. RPC and
440/// interactive modes accept closures that the integrator provides — these are
441/// real dependency-injection points, not stubs.
442pub struct DefaultDispatcher {
443    /// RPC mode runner.
444    pub rpc: Option<Arc<ModeRunner>>,
445    /// Interactive mode runner.
446    pub interactive: Option<Arc<ModeRunner>>,
447}
448
449impl DefaultDispatcher {
450    /// Create with print/json wired; RPC and interactive injected.
451    #[must_use]
452    pub fn new() -> Self {
453        Self {
454            rpc: None,
455            interactive: None,
456        }
457    }
458
459    /// Set the RPC mode runner.
460    #[must_use]
461    pub fn with_rpc<F>(mut self, f: F) -> Self
462    where
463        F: Fn(Dispatched, Arc<AgentSessionRuntime>) -> BoxFuture<'static, Result<u8, String>>
464            + Send
465            + Sync
466            + 'static,
467    {
468        self.rpc = Some(Arc::new(f));
469        self
470    }
471
472    /// Set the interactive mode runner.
473    #[must_use]
474    pub fn with_interactive<F>(mut self, f: F) -> Self
475    where
476        F: Fn(Dispatched, Arc<AgentSessionRuntime>) -> BoxFuture<'static, Result<u8, String>>
477            + Send
478            + Sync
479            + 'static,
480    {
481        self.interactive = Some(Arc::new(f));
482        self
483    }
484}
485
486impl Default for DefaultDispatcher {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492impl ModeDispatch for DefaultDispatcher {
493    fn run_interactive(
494        &self,
495        dispatched: Dispatched,
496        runtime: Arc<AgentSessionRuntime>,
497    ) -> BoxFuture<'_, Result<u8, String>> {
498        match &self.interactive {
499            Some(runner) => runner(dispatched, runtime),
500            None => Box::pin(async move {
501                // Interactive TUI requires a terminal; in headless contexts
502                // we cannot launch it. Surface the condition explicitly.
503                Err("interactive mode requires a TTY terminal".to_owned())
504            }),
505        }
506    }
507
508    fn run_print(
509        &self,
510        dispatched: Dispatched,
511        runtime: Arc<AgentSessionRuntime>,
512    ) -> BoxFuture<'_, Result<u8, String>> {
513        Box::pin(async move { run_print_session(dispatched, runtime).await })
514    }
515
516    fn run_rpc(
517        &self,
518        dispatched: Dispatched,
519        runtime: Arc<AgentSessionRuntime>,
520    ) -> BoxFuture<'_, Result<u8, String>> {
521        match &self.rpc {
522            Some(runner) => runner(dispatched, runtime),
523            None => {
524                Box::pin(async move { Err("rpc mode requires the RPC server runner".to_owned()) })
525            }
526        }
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::sync::Mutex as StdMutex;
534
535    /// Fake dispatcher recording which mode ran.
536    #[derive(Default)]
537    struct FakeDispatcher {
538        print_code: StdMutex<Option<u8>>,
539        calls: StdMutex<Vec<&'static str>>,
540    }
541
542    impl ModeDispatch for Arc<FakeDispatcher> {
543        fn run_interactive(
544            &self,
545            _dispatched: Dispatched,
546            _runtime: Arc<AgentSessionRuntime>,
547        ) -> BoxFuture<'_, Result<u8, String>> {
548            let result = self
549                .calls
550                .lock()
551                .map_err(|error| format!("record interactive call: {error}"))
552                .map(|mut calls| calls.push("interactive"));
553            Box::pin(async move {
554                result?;
555                Ok(0)
556            })
557        }
558        fn run_print(
559            &self,
560            _dispatched: Dispatched,
561            _runtime: Arc<AgentSessionRuntime>,
562        ) -> BoxFuture<'_, Result<u8, String>> {
563            let result = self
564                .calls
565                .lock()
566                .map_err(|error| format!("record print call: {error}"))
567                .and_then(|mut calls| {
568                    calls.push("print");
569                    self.print_code
570                        .lock()
571                        .map_err(|error| format!("read print exit code: {error}"))
572                        .map(|code| code.unwrap_or(0))
573                });
574            Box::pin(async move { result })
575        }
576        fn run_rpc(
577            &self,
578            _dispatched: Dispatched,
579            _runtime: Arc<AgentSessionRuntime>,
580        ) -> BoxFuture<'_, Result<u8, String>> {
581            let result = self
582                .calls
583                .lock()
584                .map_err(|error| format!("record RPC call: {error}"))
585                .map(|mut calls| calls.push("rpc"));
586            Box::pin(async move {
587                result?;
588                Ok(0)
589            })
590        }
591    }
592
593    #[test]
594    fn defaults_match_reference() {
595        assert_eq!(defaults::STDIN_EOF, 0);
596        assert_eq!(defaults::SIGTERM, 143);
597        assert_eq!(defaults::SIGHUP, 129);
598    }
599
600    #[test]
601    fn signal_codes_default() {
602        let codes = SignalCodes::default();
603        assert_eq!(codes.sigterm, 143);
604        #[cfg(unix)]
605        assert_eq!(codes.sighup, Some(129));
606    }
607
608    #[test]
609    fn fire_is_first_wins() -> Result<(), String> {
610        let (tx, _rx) = oneshot::channel::<u8>();
611        let relay = Arc::new(SignalRelay {
612            sender: Mutex::new(Some(tx)),
613            cancel: tokio_util::sync::CancellationToken::new(),
614            receiver: Mutex::new(None),
615        });
616        fire(&relay, 143);
617        // Sender is now taken.
618        assert!(
619            relay
620                .sender
621                .lock()
622                .map_err(|error| format!("inspect signal sender: {error}"))?
623                .is_none()
624        );
625        // Second fire is a no-op.
626        fire(&relay, 129);
627        assert!(
628            relay
629                .sender
630                .lock()
631                .map_err(|error| format!("inspect signal sender after second fire: {error}"))?
632                .is_none()
633        );
634        Ok(())
635    }
636}