Skip to main content

agentmux_srv/bootstrap/
delivery.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Split out of the single-file bootstrap module unchanged (SPEC_LARGE_FILE_MODULE_ANALYSIS_2026_09_30.md §4.3).
5
6use super::*;
7
8/// Deliver cron fires in process, through the server's shared inject path
9/// (`server::reactive::deliver`) on the instance-key tier — identity M4c-3
10/// (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md §6.5.9). The fire
11/// keeps every tier the route has (this instance, cross-instance,
12/// cross-channel, LAN, cloud) and audits the job's creator UID here only.
13///
14/// Must run AFTER `build_app_state` and BEFORE `cron_scheduler.start()`, so
15/// no fire takes the HTTP fallback.
16pub fn install_cron_delivery(state: &AppState) {
17    let st = state.clone();
18    state.cron_scheduler.install_delivery(Arc::new(move |req, creator_uid| {
19        let st = st.clone();
20        Box::pin(async move {
21            crate::server::reactive::deliver(
22                &st,
23                crate::server::ReactiveAuthVia::FullAuthKey,
24                &creator_uid,
25                req,
26            )
27            .await
28        })
29    }));
30}
31
32/// Where a message for an agent block goes.
33pub(crate) enum AgentRoute {
34    /// Taken on the controller's own channel — persistent stdin, ACP
35    /// `session/prompt`, App Server — either now (`Delivered`) or queued for when
36    /// the process can take it (`Deferred`). `Pty`: the controller is PTY-based,
37    /// and the caller decides what that means for it.
38    Sent(reactive::SenderDelivery),
39    /// No structured channel takes it now: the caller starts a turn
40    /// (`run_agent_turn`). `subprocess` is true for a `SubprocessController`,
41    /// false for a persistent controller that is registered but not yet spawned.
42    StartTurn { subprocess: bool },
43}
44
45/// The routing half of the reactive sender below, shared with the Swarm broadcast
46/// (`server::app_api::fleet`) so a broadcast reaches ACP, App Server, running
47/// persistent and not-yet-spawned agents exactly as an inter-agent message does.
48///
49/// Persistent + ACP + App Server keep their structured delivery; genuine PTY
50/// controllers report `Pty`; a `SubprocessController` has no PTY and rejects raw
51/// input, so it gets a turn instead (docs/reports/
52/// REPORT_JEKT_DELIVERY_DROPS_SUBPROCESS_AGENTS_2026_09_02.md). An `Err` is a
53/// structured controller that failed to take the message; it must not be retried
54/// as keystrokes.
55pub(crate) fn route_agent_message(block_id: &str, message: &str) -> Result<AgentRoute, String> {
56    route_agent_message_from(block_id, message, None)
57}
58
59/// [`route_agent_message`], saying who the input is from: the Swarm broadcast
60/// labels its delivery to a running persistent agent as the user's
61/// (`deliver_agent_message_from`). `None` is exactly `route_agent_message`.
62pub(crate) fn route_agent_message_from(
63    block_id: &str,
64    message: &str,
65    origin: Option<backend::blockcontroller::health::TurnInput>,
66) -> Result<AgentRoute, String> {
67    let Some(ctrl) = backend::blockcontroller::get_controller(block_id) else {
68        return Err(format!("no controller for block {block_id}"));
69    };
70    let is_subprocess = ctrl
71        .as_any()
72        .downcast_ref::<backend::blockcontroller::subprocess::SubprocessController>()
73        .is_some();
74    if is_subprocess {
75        return Ok(AgentRoute::StartTurn { subprocess: true });
76    }
77    match backend::blockcontroller::deliver_agent_message_from(block_id, message, origin) {
78        Ok(backend::blockcontroller::AgentDelivery::Structured) => {
79            Ok(AgentRoute::Sent(reactive::SenderDelivery::Delivered))
80        }
81        Ok(backend::blockcontroller::AgentDelivery::StructuredDeferred) => {
82            Ok(AgentRoute::Sent(reactive::SenderDelivery::Deferred))
83        }
84        Ok(backend::blockcontroller::AgentDelivery::Pty) => Ok(AgentRoute::Sent(reactive::SenderDelivery::Pty)),
85        Err(e) => {
86            // A persistent controller that is REGISTERED BUT NOT YET SPAWNED can't
87            // be steered — `deliver_agent_message` writes to a live stdin and there
88            // isn't one — but it can be STARTED. Controllers register lazily, so
89            // after any srv restart every persistent agent sits in this state until
90            // something is sent to it; without this, first contact fails with
91            // "persistent process not running"
92            // (docs/reports/REPORT_JEKT_DELIVERY_DROPS_UNSPAWNED_PERSISTENT_AGENTS_2026_09_03.md).
93            //
94            // Narrow on purpose: `needs_spawn()` is false while a spawn is already
95            // in flight (that surfaces as the retryable "still starting up"), and
96            // false for a live process whose delivery failed for another reason;
97            // both keep returning the original error rather than start a second
98            // turn.
99            // An ACP agent whose process exited likewise: the turn path
100            // starts it again (`AcpController::ensure_started`).
101            let recoverable = ctrl
102                .as_any()
103                .downcast_ref::<backend::blockcontroller::persistent::PersistentSubprocessController>()
104                .is_some_and(|p| p.needs_spawn())
105                || ctrl
106                    .as_any()
107                    .downcast_ref::<backend::blockcontroller::acp::AcpController>()
108                    .is_some_and(|a| !a.is_running());
109            if !recoverable {
110                return Err(e);
111            }
112            // Nothing was persisted or written, so falling through cannot
113            // double-deliver.
114            tracing::info!(
115                block_id = %block_id,
116                error = %e,
117                "reactive delivery: persistent controller not yet spawned — starting a turn instead"
118            );
119            Ok(AgentRoute::StartTurn { subprocess: false })
120        }
121    }
122}
123
124/// Give the reactive handler a delivery route to `SubprocessController` agents.
125///
126/// `spawn_background_subsystems` installs a message sender that can only reach
127/// the two controller kinds `deliver_agent_message` knows about — persistent
128/// (stream-json stdin) and ACP (`session/prompt`). Everything else it reports as
129/// [`AgentDelivery::Pty`], so the caller falls back to keystroke injection.
130///
131/// That fallback is wrong for a `SubprocessController`, which has no PTY and
132/// rejects raw input outright ("subprocess controller does not accept raw input;
133/// use AgentInputCommand"). The result was that EVERY inter-agent message to
134/// such an agent was dropped: 6 of 10 providers as host agents (codex, gemini,
135/// qwen, kimi, muxcode, antigravity) and every container agent of any provider,
136/// since `agent_open.rs` forces `controller_type = "subprocess"` for container
137/// mode. The swarm pane still listed them as present, because registration and
138/// delivery are separate paths. See
139/// `docs/reports/REPORT_JEKT_DELIVERY_DROPS_SUBPROCESS_AGENTS_2026_09_02.md`.
140///
141/// This re-installs the sender with the missing branch: a subprocess target gets
142/// a real turn via `run_agent_turn` — the same thing `AgentInputCommand` does
143/// when the operator types into the pane — instead of keystrokes it will refuse.
144///
145/// Must run AFTER `build_app_state`: unlike the early sender, the turn needs
146/// `AppState` (stores, identity, broker, container manager) and is async.
147pub fn install_agent_turn_delivery(state: &AppState) {
148    let deps = crate::server::agent_handlers::AgentTurnDeps::from_state(state);
149    state
150        .reactive_handler
151        .set_message_sender(Arc::new(move |block_id: &str, message: &str| {
152            // Which controller kinds take a structured message and which need a turn
153            // started is `route_agent_message`, shared with the Swarm broadcast so
154            // the two cannot drift.
155            let is_subprocess = match route_agent_message(block_id, message)? {
156                AgentRoute::Sent(outcome) => return Ok(outcome),
157                AgentRoute::StartTurn { subprocess } => subprocess,
158            };
159
160            // `MessageSender` is synchronous but starting a turn is not, so this
161            // waits for the turn to START and reports what actually happened.
162            //
163            // It must NOT spawn-and-return-`Ok(true)` optimistically (codex P1 on
164            // PR #2930). `success` is not advisory: `cloud_subscriber` treats a
165            // successful delivery as final and releases the claim back to pending
166            // for retry ONLY when `!delivery.success`
167            // (`muxbus/cloud_subscriber.rs`). Reporting success before the
168            // fallible work ran would therefore turn every transient failure —
169            // stale block meta, an oauth spawn-gate refusal, Docker down,
170            // `ensure_running` failing — into a WAN message that is marked
171            // delivered, never retried, and permanently lost. An honest `Err` here
172            // is what lets that message come back.
173            //
174            // `block_in_place` yields this worker thread back to the runtime for
175            // the duration, so other tasks keep running. The cost is real and
176            // worth naming: `inject_message_inner` calls this while holding the
177            // reactive `Handler` mutex, so a slow start (first-time container
178            // image pull is the pathological case — everything else is a block
179            // read plus an already-running `docker exec`) serializes other
180            // injections behind it. Bounded stalling beats silent loss.
181            //
182            // `run_agent_turn` returns once the turn has been STARTED, not once
183            // the agent has answered, so this is not waiting on model latency.
184            //
185            // `TurnRegistration::Skip` is LOAD-BEARING, not a tidy-up. We are
186            // running under that same non-reentrant `Mutex<Handler>`, so the
187            // registration tail's `get_global_handler().register_agent(...)`
188            // would try to re-lock a mutex this very thread already holds and
189            // wedge the reactive handler process-wide (reagent P0 on PR #2930).
190            // It is also simply redundant here: this block was resolved BY a
191            // `resolve_target` lookup in that handler's registry, so the agent
192            // is registered by construction. Do not "simplify" this to Register.
193            let Ok(handle) = tokio::runtime::Handle::try_current() else {
194                return Err(
195                    "no tokio runtime available to start an agent turn".to_string(),
196                );
197            };
198            // `block_in_place` panics on a current-thread runtime. Production is
199            // multi-thread (`#[tokio::main]`); refusing loudly on the other flavor
200            // is correct, since the alternative is the optimistic lie above.
201            if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::CurrentThread {
202                return Err(
203                    "cannot start an agent turn from a current-thread runtime"
204                        .to_string(),
205                );
206            }
207            let deps = deps.clone();
208            let block_id_owned = block_id.to_string();
209            let message = message.to_string();
210            tracing::info!(
211                block_id = %block_id_owned,
212                kind = if is_subprocess { "subprocess" } else { "persistent (not yet spawned)" },
213                "reactive delivery: starting agent turn (no PTY fallback)"
214            );
215            let started = tokio::task::block_in_place(|| {
216                handle.block_on(crate::server::agent_handlers::run_agent_turn(
217                    &deps,
218                    block_id_owned.clone(),
219                    message,
220                    None,
221                    crate::server::agent_handlers::TurnRegistration::Skip,
222                    // Reactive delivery: a jekt, cron, nudge, broadcast, loop.
223                    crate::backend::blockcontroller::health::TurnOrigin::Automated,
224                    Vec::new(),
225                ))
226            });
227            match started {
228                Ok(()) => Ok(reactive::SenderDelivery::Delivered),
229                Err(e) => {
230                    tracing::error!(
231                        block_id = %block_id_owned,
232                        error = %e,
233                        "reactive delivery: agent turn failed to start"
234                    );
235                    Err(e)
236                }
237            }
238        }));
239}
240
241/// Wires `blockcontroller::close_on_exit` (see that function's own doc
242/// comment) up to the real close action, now that `AppState` exists.
243/// Mirrors `install_agent_turn_delivery` just above — same "backend code
244/// needs a capability only available once `AppState` is built" shape,
245/// solved the same way (a process-wide callback set once at boot).
246///
247/// The callback itself is deliberately synchronous (`Fn(String, String)`,
248/// not `async fn`) — `shell/lifecycle.rs`'s wait/cleanup task calls it from
249/// a plain (non-async) decision point after its own cleanup, and trait
250/// objects can't hold an `async fn` without extra boxing machinery this
251/// doesn't need. `tokio::spawn` here is what actually runs the (necessarily
252/// async) `sagas::delete_block::run` call; the callback returns
253/// immediately, fire-and-forget, matching `close_on_exit`'s own contract.
254///
255/// See `docs/specs/SPEC_TERM_EXIT_RESPAWN_LOOP_2026_09_15.md` §10 for why
256/// this exists: a shell pane's controller has no code path today that
257/// closes it when the shell process exits, which (combined with §7-9's
258/// fix for the OTHER half of that bug) leaves an exited pane sitting inert
259/// — technically correct, but indistinguishable from "hung" to a user.
260pub fn install_close_on_exit_handler(state: &AppState) {
261    let state = state.clone();
262    backend::blockcontroller::set_close_on_exit_handler(Arc::new(move |tab_id: String, block_id: String| {
263        let state = state.clone();
264        tokio::spawn(async move {
265            if let Err(e) = crate::sagas::delete_block::run(&state, tab_id.clone(), block_id.clone()).await {
266                // Best-effort, same posture as every other close-on-exit
267                // failure mode: the shell already exited and its PTY is
268                // already gone either way, so there is nothing left to roll
269                // back — a failed close just leaves the pane sitting inert,
270                // the same as if close-on-exit weren't enabled for it at
271                // all, not a lost or corrupted state.
272                tracing::warn!(
273                    tab_id = %tab_id,
274                    block_id = %block_id,
275                    error = %e,
276                    "close-on-exit: delete_block saga failed"
277                );
278            }
279        });
280    }));
281}
282
283#[cfg(test)]
284mod route_agent_message_tests {
285    use super::*;
286    use backend::blockcontroller::{delete_controller, register_controller};
287
288    #[test]
289    fn a_block_with_no_controller_is_an_error() {
290        let Err(e) = route_agent_message("route-test-no-such-block", "hi") else {
291            panic!("an unregistered block has nowhere to go");
292        };
293        assert!(e.contains("no controller for block"), "{e}");
294    }
295
296    #[test]
297    fn a_subprocess_agent_gets_a_turn_not_keystrokes() {
298        let block_id = "route-test-subprocess";
299        register_controller(
300            block_id,
301            Arc::new(backend::blockcontroller::subprocess::SubprocessController::new(
302                "tab-route".to_string(),
303                block_id.to_string(),
304                None,
305                None,
306                None,
307                None,
308                None,
309                Arc::from("test-boot"),
310            )),
311        );
312        assert!(matches!(
313            route_agent_message(block_id, "hi"),
314            Ok(AgentRoute::StartTurn { subprocess: true })
315        ));
316        delete_controller(block_id);
317    }
318
319    #[test]
320    fn an_app_server_agent_is_routed_to_its_own_channel_never_to_a_turn_or_keystrokes() {
321        let block_id = "route-test-app-server";
322        register_controller(
323            block_id,
324            Arc::new(backend::blockcontroller::app_server_controller::AppServerController::new(
325                block_id.to_string(),
326                None,
327                None,
328                None,
329                None,
330            )),
331        );
332        // No process yet, so its own `send_message` refuses: reaching that refusal
333        // is the proof the structured route was taken (not StartTurn, not Pty).
334        let Err(e) = route_agent_message(block_id, "hi") else {
335            panic!("an App Server controller with no process cannot take a message");
336        };
337        assert!(e.contains("not initialized"), "{e}");
338        delete_controller(block_id);
339    }
340
341    /// A message for an ACP agent whose process has exited is a turn to
342    /// start (which starts the agent again), not a refusal that leaves the
343    /// message stashed with nothing to run it.
344    #[test]
345    fn a_stopped_acp_agent_is_routed_to_a_turn_that_restarts_it() {
346        let block_id = "route-test-acp-stopped";
347        register_controller(
348            block_id,
349            Arc::new(backend::blockcontroller::acp::AcpController::new(
350                "tab-route".to_string(),
351                block_id.to_string(),
352                None,
353                None,
354                None,
355                None,
356            )),
357        );
358        assert!(matches!(
359            route_agent_message(block_id, "hi"),
360            Ok(AgentRoute::StartTurn { subprocess: false })
361        ));
362        delete_controller(block_id);
363    }
364}