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}