agentmux_srv/backend/blockcontroller/persistent/input.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! User-facing input surface: user messages, AskUserQuestion answers/denials,
5//! tool-permission decisions, raw stdin, and inbound control frames.
6
7use super::*;
8
9impl PersistentSubprocessController {
10 /// What a dead-air fallback needs to record its re-delivered line — see
11 /// [`RedeliveryPersister`].
12 pub(super) fn redelivery_persister(&self) -> RedeliveryPersister {
13 RedeliveryPersister {
14 broker: self.broker.clone(),
15 filestore: self.filestore.clone(),
16 mstore: self.mstore.clone(),
17 block_id: self.block_id.clone(),
18 }
19 }
20
21 /// Encode a message as the stream-json stdin line the CLI expects.
22 pub(super) fn encode_user_message(message: &str) -> String {
23 serde_json::json!({
24 "type": "user",
25 "message": {
26 "role": "user",
27 "content": message
28 }
29 })
30 .to_string()
31 }
32
33 /// Write an already-encoded stdin line, given a held `inner` guard.
34 ///
35 /// Callers must hold the lock across the must-wait check, the queue
36 /// update and this write, so no concurrent sender or watchdog tick can
37 /// slip between them.
38 ///
39 /// On success the line is also tracked into the current generation's
40 /// resume retry batch, so every write site (the send path and the
41 /// watchdog) gets it without having to remember to.
42 pub(super) fn try_write_stdin_locked(
43 inner: &mut PersistentInner,
44 line: &str,
45 ) -> Result<(), String> {
46 // reagentx P1 on PR #2360 (sixth review pass, round 7):
47 // `spawn_process` sets `stdin_tx` synchronously, well before the
48 // queued message that triggered the spawn is actually delivered by
49 // the background drain task (`drain_queue_after_successful_spawn`).
50 // Gating purely on `stdin_tx.is_some()` let a message land in that
51 // window and `try_send` straight to the live channel, jumping ahead
52 // of whatever was still queued. Checked in the SAME acquisition as
53 // `stdin_tx`, so nothing slips between two checks.
54 if inner.spawning_in_progress {
55 return Err("persistent process is still starting up — try again shortly".to_string());
56 }
57 // A committed deferred restart leaves `stdin_tx` live until the process
58 // actually exits, and a line written into a process about to be killed
59 // is lost, even though its caller was told it was delivered. The human
60 // path (`decide_send_action`) already refuses DeliverDirect here (codex
61 // P1 on PR #2858); this is the same rule for every write through this
62 // helper (codex P1 on #3562).
63 if inner.restart_pending {
64 return Err("persistent process is restarting for a config change — try again shortly".to_string());
65 }
66 // Likewise once a kill has been requested: the process is going down
67 // and a line written now dies with it (see `stop_pending`). Once it
68 // has exited, "not running" below is the accurate answer.
69 if inner.stop_pending && inner.stdin_tx.is_some() {
70 return Err("persistent process is stopping — try again shortly".to_string());
71 }
72 let tx = inner
73 .stdin_tx
74 .as_ref()
75 .ok_or("persistent process not running")?;
76 tx.try_send(line.to_string())
77 .map_err(|e| format!("stdin send failed: {e}"))?;
78 // codex P1 + reagent P1 on PR #3523, found independently by both:
79 // track this into the CURRENT generation's retry batch, exactly as
80 // `send_message`'s `DeliverDirect` branch does. That fix covered the
81 // typed-in-the-UI path and missed this one — the MuxBus/reactive
82 // injection path (`deliver_agent_message`).
83 //
84 // The gap matters most for eager resume, which spawns with an
85 // unconfirmed `--resume` and NO seed message, so the retry batch
86 // starts empty. An injected prompt written straight to stdin and never
87 // appended leaves it empty, so when the resumed sid turns out to be
88 // stale the retry fires with nothing to redeliver — and the prompt is
89 // gone, even though the caller was told it was delivered and the live
90 // blockfile append already rendered it to the operator.
91 //
92 // Read the generation and apply under the caller's SAME lock
93 // acquisition (already held for `try_send`) — a separate one risks a
94 // concurrent respawn bumping `spawn_generation` in between, making the
95 // event carry a stale generation that `update()`'s catch-all silently
96 // ignores. A no-op when no unconfirmed resume is in flight.
97 let generation = inner.spawn_generation;
98 let seq = inner.take_next_message_seq();
99 inner.apply_resume_event(persistent_resume::ResumeEvent::MessageAppendedToRetryBatch {
100 generation,
101 entry: persistent_resume::QueuedRetryEntry { seq, json: line.to_string() },
102 });
103 Ok(())
104 }
105
106 /// Deliver a user message to the **already-running** persistent process,
107 /// without a spawn config. Unlike `send_message`, this never spawns — it errors
108 /// if the process is not running. Used for controller-aware muxbus/reactive
109 /// delivery (`deliver_agent_message`), where the agent is live (busy or idle)
110 /// and we have no `PersistentSpawnConfig` to hand.
111 ///
112 /// A live process gets the message at once, whatever the agent is doing —
113 /// mid-turn included; the CLI reads it at its next inference step. It is
114 /// queued only while the process cannot take a write yet: a spawn in
115 /// flight, a committed restart, a requested stop, or another writer owning
116 /// stdin (see [`Self::deferred_must_wait_locked`]). Refusing it then used
117 /// to drop the message outright (`SPEC_NO_MIDTURN_DELIVERY_2026_09_23.md`
118 /// §3.2.1). Spec: `SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md` §2.1.
119 #[cfg(test)]
120 pub fn send_user_message(&self, message: String) -> Result<(), String> {
121 self.send_user_message_outcome(message).map(|_| ())
122 }
123
124 /// [`send_user_message`], reporting whether the message went to the agent
125 /// now or is queued until the process can take it (it is starting up,
126 /// restarting or stopping) — so an automated sender can tell its caller
127 /// the truth (MCP `SendMessage`).
128 #[cfg(test)]
129 pub fn send_user_message_outcome(&self, message: String) -> Result<SendOutcome, String> {
130 self.send_user_message_outcome_from(message, None)
131 }
132
133 /// [`Self::send_user_message_outcome`], saying who the input is from (turn
134 /// provenance, SPEC_AGENT_SELF_QUIT §6.3) — the Swarm broadcast, which is
135 /// the user's own message (SPEC_SWARM_BROADCAST_AS_USER_MESSAGE §6 q2).
136 /// The label is applied only when this message is the only one written: a
137 /// backlog written with it was queued by automated senders, and a message
138 /// left queued (process starting, restarting or stopping) is written later
139 /// by the drain with whatever else is queued. Both stay unlabelled, as
140 /// every automated delivery is, so the label can never vouch for input
141 /// that isn't the user's.
142 pub fn send_user_message_outcome_from(
143 &self,
144 message: String,
145 origin: Option<crate::backend::blockcontroller::health::TurnInput>,
146 ) -> Result<SendOutcome, String> {
147 // Pre-turn fence — see `send_message`.
148 self.fence_check()?;
149 let json_str = Self::encode_user_message(&message);
150
151 // ONE lock acquisition covers the must-wait check, the enqueue and the
152 // write. Because the watchdog takes this same lock, there is no window
153 // in which a message is enqueued against a queue a concurrent drain
154 // has already finished with.
155 //
156 // `publish_status`/`spawn_status_heartbeat` must NOT be called while
157 // this guard is held — `publish_status` takes `inner` itself and would
158 // deadlock. They run after the guard drops, below.
159 let (written, was_active, still_queued, failure) = {
160 let mut inner = self.inner.lock().unwrap();
161
162 if inner.deferred_deliveries.len() >= MAX_DEFERRED_DELIVERIES {
163 // An explicit error, never a false success. The caller treats
164 // this like any other transient delivery failure and retries.
165 return Err(format!(
166 "deferred-delivery queue is full ({MAX_DEFERRED_DELIVERIES} messages \
167 waiting for this agent's process to start up, restart or stop) — retry shortly"
168 ));
169 }
170 // Always enqueue first, then write the whole backlog in order.
171 // Writing this message directly while older ones are queued would
172 // reorder it ahead of them.
173 inner.deferred_deliveries.push_back(json_str);
174
175 // A spawn in flight counts as "wait" rather than as an error. This
176 // is the startup race that used to drop jekts outright
177 // (`SPEC_NO_MIDTURN_DELIVERY_2026_09_23.md` §3.2.1). Not every spawn
178 // ends in a turn — it can fail, or (eager resume) succeed with
179 // nothing to say — so anything left queued here is the watchdog's
180 // to finish, below.
181 let mut failure = None;
182 let mut written = Vec::new();
183 if !self.deferred_must_wait_locked(&inner) {
184 let (w, err) = Self::flush_deferred_locked(&mut inner, &self.block_id);
185 written = w;
186 if let Some(e) = err {
187 // This call's own message is the newest entry, so it was
188 // not written. Take it back: this call reports failure,
189 // so the caller owns the retry. Anything older stays
190 // queued — it was already accepted, and whoever queued it
191 // armed the watchdog, so it keeps its retry.
192 inner.deferred_deliveries.pop_back();
193 failure = Some(e);
194 }
195 }
196 // Marked under this same guard, so the turn state a concurrent
197 // caller or the turn-end handler reads is never behind the write.
198 // Only a fully successful write of this message alone: with a
199 // failure, the one entry written is an older queued one and this
200 // message was taken back (#4401).
201 let label = if failure.is_none() && written.len() == 1 { origin } else { None };
202 let was_active = (!written.is_empty()).then(|| self.mark_turn_active_locked_from(label));
203 (written, was_active, !inner.deferred_deliveries.is_empty(), failure)
204 };
205
206 // Anything still queued needs a guaranteed way out. Called after EVERY
207 // enqueue that leaves the queue non-empty, so it cannot miss a watchdog
208 // that exited just before.
209 if still_queued {
210 self.ensure_deferred_watchdog();
211 }
212
213 if let Some(was_active) = was_active {
214 // Everything below needs the `inner` lock released:
215 // `publish_status` takes it, and `spawn_status_heartbeat`'s task
216 // does too. The heartbeat is re-armed only when resuming from idle
217 // — a mid-turn send already has one running, and re-spawning per
218 // call would leak duplicate heartbeat tasks.
219 if !was_active {
220 self.spawn_status_heartbeat();
221 }
222 self.publish_status();
223 for line in &written {
224 self.append_delivered_message(line);
225 }
226 }
227 if let Some(e) = failure {
228 return Err(e);
229 }
230 if still_queued {
231 tracing::info!(
232 block_id = %self.block_id,
233 "delivery queued — the agent's process is starting up, restarting or stopping; \
234 it will be written once the process can take it"
235 );
236 return Ok(SendOutcome::Deferred);
237 }
238 Ok(SendOutcome::Sent)
239 }
240
241 /// Mark the turn active while the caller already holds `inner`, so the
242 /// flip is never behind the write that caused it. Lock order is `inner` →
243 /// `health_monitor`, matching the turn-end handler in `stdout_reader.rs`;
244 /// `health_monitor` never takes `inner`, so this cannot deadlock. Returns
245 /// the pre-call value.
246 fn mark_turn_active_locked(&self) -> bool {
247 self.health_monitor.mark_turn_active_returning_was_active()
248 }
249
250 /// [`Self::mark_turn_active_locked`] with the input's provenance; `None`
251 /// is exactly the unlabelled mark.
252 fn mark_turn_active_locked_from(
253 &self,
254 origin: Option<crate::backend::blockcontroller::health::TurnInput>,
255 ) -> bool {
256 self.health_monitor.mark_turn_active_from(origin)
257 }
258
259 /// Whether a queued message must keep waiting rather than be written now.
260 /// Only states in which the process cannot take a write: what the agent is
261 /// doing (mid-turn or idle) is not one of them
262 /// (`SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md` §2.1). Shared by the send
263 /// path and the watchdog, so the two can never disagree.
264 ///
265 /// - Another writer owns stdin — see
266 /// [`Self::stdin_owned_by_another_writer`]; this includes a spawn in
267 /// flight, whose write `try_write_stdin_locked` would refuse.
268 /// - A committed config restart (`restart_pending`), but only while the
269 /// process being restarted still holds `stdin_tx`: it is about to be
270 /// killed, so writes must wait for the replacement. Once it has exited,
271 /// the ordinary rules apply. The exit handler's respawn
272 /// (`respawn_after_config_restart`) holds the spawn claim, so the queue
273 /// keeps waiting for it (a spawn in flight, below), and if no spawn is in
274 /// flight, whoever was to start one declined or failed. Waiting on
275 /// `restart_pending` alone then held jekts forever, since only a
276 /// successful spawn clears it (Korp, 2026-09-29; ReAgent P1 on #3990: a
277 /// competing spawn claim that fails leaves it set). The watchdog's grace
278 /// window covers the moment between the exit and the respawn's claim.
279 /// - A requested kill (`stop_pending`), but only while the dying process
280 /// still holds `stdin_tx`: writes are refused then, and a refusal must
281 /// not reach an automated caller as an error (reagent P1 on #3562). Once
282 /// it exits, the ordinary no-process rules apply. A send fails into the
283 /// caller's respawn fallback, and the watchdog's grace window runs, so
284 /// nothing parks unreported behind a stop.
285 pub(super) fn deferred_must_wait_locked(&self, inner: &PersistentInner) -> bool {
286 (inner.restart_pending && inner.stdin_tx.is_some())
287 || (inner.stop_pending && inner.stdin_tx.is_some())
288 || Self::stdin_owned_by_another_writer(inner)
289 }
290
291 /// Another writer is feeding stdin, and messages it carries were accepted
292 /// earlier than anything in the deferred queue, so they must not be
293 /// overtaken:
294 ///
295 /// - a spawn is in flight — its seed message and drain go first;
296 /// - a stale-resume retry batch is being replayed (`drain_claim`), possibly
297 /// into this same live process by a background task writing to the same
298 /// channel;
299 /// - human messages are queued behind a spawn.
300 ///
301 /// Queued messages count only while a process exists to drain them. A
302 /// drain's "second stall" (`drain_queue_with_claim`) releases its claim
303 /// with leftovers queued and no process. Nothing owns that backlog, and
304 /// treating it as a writer made a send defer instead of failing into
305 /// the no-process fallback, and kept the watchdog resetting its orphan
306 /// timer forever (codex P1 on #3562).
307 ///
308 /// Also part of [`Self::deferred_must_wait_locked`], which every caller of
309 /// [`Self::flush_deferred_locked`] checks first under the same lock.
310 fn stdin_owned_by_another_writer(inner: &PersistentInner) -> bool {
311 inner.spawning_in_progress
312 || inner.drain_claim
313 || (!inner.pending_send_messages.is_empty() && inner.stdin_tx.is_some())
314 }
315
316 /// Write the queued messages to stdin, oldest first, as far as stdin takes
317 /// them. Returns the lines written, in order, and the error that stopped
318 /// it early, if any: the entry that failed stays at the head of the queue
319 /// with everything behind it, so a failed write never drops a message.
320 ///
321 /// The whole backlog at once, not one at a time: nothing is held for what
322 /// the agent is doing any more (`SPEC_JEKT_IMMEDIATE_DELIVERY_2026_09_28.md`
323 /// §2.1), only for whether the process can take a write.
324 ///
325 /// The caller holds `inner` across this, has checked
326 /// [`Self::deferred_must_wait_locked`] under that same guard, and marks the
327 /// turn active before dropping it if anything was written.
328 pub(super) fn flush_deferred_locked(
329 inner: &mut PersistentInner,
330 block_id: &str,
331 ) -> (Vec<String>, Option<String>) {
332 let mut written = Vec::new();
333 while let Some(head) = inner.deferred_deliveries.front().cloned() {
334 if let Err(e) = Self::try_write_stdin_locked(inner, &head) {
335 tracing::warn!(
336 block_id = %block_id,
337 error = %e,
338 queued = inner.deferred_deliveries.len(),
339 "deferred flush failed; leaving the queue for the watchdog to retry"
340 );
341 return (written, Some(e));
342 }
343 inner.deferred_deliveries.pop_front();
344 written.push(head);
345 }
346 (written, None)
347 }
348
349 /// The turn-boundary decision for a `result` frame, under `inner`: the turn
350 /// is over, and a deferred runtime-config restart may now apply. Returns
351 /// whether it does.
352 ///
353 /// Returns `None`, touching nothing, when `generation` is no longer the
354 /// current one. A fallback respawn can install a new process before the
355 /// old reader drains a buffered `result`. That `result` ends nothing the
356 /// new process is doing, so acting on it would mark the replacement's
357 /// running turn idle, or restart it (codex P1 on #3562).
358 ///
359 /// The deferred restart applies only when nothing is queued. Killing the
360 /// process with an entry still queued would strand it; the flag stays set,
361 /// and the next `result` (the watchdog's write starts a turn) applies it.
362 ///
363 /// `stats`: the `result` frame's figures. They go to the turn the pass
364 /// belonged to in the same step that idles it, so input arriving right
365 /// after (which may open a new turn) can never take them (#4492).
366 pub(super) fn turn_boundary_locked(
367 inner: &mut PersistentInner,
368 health: &TurnActivityTracker,
369 block_id: &str,
370 generation: u64,
371 stats: Option<crate::backend::blockcontroller::health::PassStats>,
372 ) -> Option<bool> {
373 if inner.spawn_generation != generation {
374 tracing::debug!(
375 block_id = %block_id,
376 reader_generation = generation,
377 current_generation = inner.spawn_generation,
378 "result from a replaced process — not a boundary for the current one"
379 );
380 return None;
381 }
382 let apply_deferred_restart = inner.deferred_deliveries.is_empty()
383 && std::mem::replace(&mut inner.restart_when_idle, false);
384 health.end_pass(stats);
385 if apply_deferred_restart {
386 inner.restart_pending = true;
387 // The restart token, set in the acquisition that commits the
388 // restart: nothing later may re-arm it after a Stop cleared it.
389 inner.config_restart_generation = Some(generation);
390 }
391 Some(apply_deferred_restart)
392 }
393
394 /// Make sure a watchdog task is looking after a non-empty deferred queue.
395 ///
396 /// Messages are queued only while the process cannot take a write (see
397 /// [`Self::deferred_must_wait_locked`]), and nothing else writes them once
398 /// it can, so this is how every queued message gets out:
399 ///
400 /// - a message queued behind a spawn — delivered once the process is up,
401 /// whether or not that spawn starts a turn (eager resume spawns with no
402 /// seed message);
403 /// - a message queued behind a committed restart or a requested stop —
404 /// delivered to the replacement process;
405 /// - a write that failed (e.g. the bounded stdin channel was momentarily
406 /// full) — retried;
407 /// - a message queued behind a spawn that then FAILED, or behind a process
408 /// that exited and was never replaced — reported stranded after the
409 /// grace window.
410 ///
411 /// Idempotent: at most one watchdog per controller. A no-op for an
412 /// instance without `set_self_ref` (only tests construct those) or outside
413 /// a Tokio runtime.
414 pub(super) fn ensure_deferred_watchdog(&self) {
415 let Some(ctrl) = self.self_ref.lock().unwrap().as_ref().and_then(|w| w.upgrade()) else {
416 return;
417 };
418 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
419 return;
420 };
421 {
422 let mut inner = self.inner.lock().unwrap();
423 if inner.deferred_watchdog_armed {
424 return;
425 }
426 inner.deferred_watchdog_armed = true;
427 }
428 let weak = Arc::downgrade(&ctrl);
429 drop(ctrl);
430 runtime.spawn(async move {
431 let mut orphaned_ticks = 0u32;
432 loop {
433 tokio::time::sleep(DEFERRED_WATCHDOG_TICK).await;
434 // Hold no strong reference across the sleep, so the watchdog
435 // never keeps a torn-down controller alive.
436 let Some(ctrl) = weak.upgrade() else { return };
437 if ctrl.sweep_deferred_once(&mut orphaned_ticks) == WatchdogStep::Exit {
438 return;
439 }
440 }
441 });
442 }
443
444 /// One watchdog tick. Split out of the task so tests can drive it
445 /// directly, without a real process or real time.
446 pub(super) fn sweep_deferred_once(&self, orphaned_ticks: &mut u32) -> WatchdogStep {
447 let (released, stranded) = {
448 let mut inner = self.inner.lock().unwrap();
449 if inner.deferred_deliveries.is_empty() {
450 // Disarm under the same lock that guards enqueue: any message
451 // pushed after this point is followed by its own
452 // `ensure_deferred_watchdog`, which will see "disarmed".
453 inner.deferred_watchdog_armed = false;
454 return WatchdogStep::Exit;
455 }
456 if self.deferred_must_wait_locked(&inner) {
457 // Something in flight will bring the process up (or fail into
458 // a state this watchdog sees on a later tick).
459 *orphaned_ticks = 0;
460 return WatchdogStep::Continue;
461 }
462 if inner.stdin_tx.is_none() {
463 // No process and nothing starting one. Give a respawn the
464 // grace window to begin, then stop pretending these will be
465 // delivered.
466 *orphaned_ticks += 1;
467 if *orphaned_ticks < DEFERRED_ORPHAN_GRACE_TICKS {
468 return WatchdogStep::Continue;
469 }
470 inner.deferred_watchdog_armed = false;
471 (None, inner.deferred_deliveries.drain(..).collect::<Vec<_>>())
472 } else {
473 *orphaned_ticks = 0;
474 // A failed write is transient; whatever is left is retried on
475 // the next tick.
476 let (written, _failed) = Self::flush_deferred_locked(&mut inner, &self.block_id);
477 if written.is_empty() {
478 return WatchdogStep::Continue;
479 }
480 // Flip inside the lock, exactly as the send path does.
481 (Some((written, self.mark_turn_active_locked())), Vec::new())
482 }
483 };
484
485 if !stranded.is_empty() {
486 Self::log_stranded_deferred(
487 &self.block_id,
488 "no process and no spawn in flight for the whole grace window",
489 &stranded,
490 );
491 return WatchdogStep::Exit;
492 }
493 if let Some((written, was_active)) = released {
494 tracing::info!(
495 block_id = %self.block_id,
496 count = written.len(),
497 "the agent's process can take input again — watchdog delivered the queued messages"
498 );
499 if !was_active {
500 self.spawn_status_heartbeat();
501 }
502 self.publish_status();
503 for line in &written {
504 self.append_delivered_message(line);
505 }
506 }
507 WatchdogStep::Continue
508 }
509
510 /// Report every stranded deferred entry, so a message this controller
511 /// accepted is never quietly discarded
512 /// (`SPEC_NO_MIDTURN_DELIVERY_2026_09_23.md` §4.5). Callers drain the
513 /// queue in the same `inner` acquisition that ends delivery (`stop`,
514 /// `shutdown`, the watchdog), so no flush can take an entry first.
515 ///
516 /// **Known gap:** reporting is currently a loud structured log, not a
517 /// failure routed back to the original sender. The queue stores encoded
518 /// stdin lines with no sender identity attached, so there is nothing to
519 /// address a reply to; carrying that identity through is Phase 3. Until
520 /// then a stranded message is visible in the logs rather than in the
521 /// sender's response — better than silence, short of the spec's intent.
522 ///
523 /// Takes `block_id` rather than `&self` so `shutdown`'s `'static` future,
524 /// which holds no controller reference, can report through it too.
525 pub(super) fn log_stranded_deferred(block_id: &str, reason: &str, stranded: &[String]) {
526 if stranded.is_empty() {
527 return;
528 }
529 tracing::warn!(
530 block_id = %block_id,
531 reason = %reason,
532 stranded = stranded.len(),
533 "deferred messages were accepted but never delivered"
534 );
535 for line in stranded {
536 tracing::warn!(block_id = %block_id, message = %line, "stranded deferred message");
537 }
538 }
539
540 /// Persist a delivered injection to the blockfile WITH a live event.
541 ///
542 /// Unlike `send_message` there is no `agent-message-accepted` pending echo
543 /// to pair with (nothing was typed in the UI), so without this the
544 /// injection is invisible to the human operator: a silent injection, which
545 /// SPEC_JEKT_SECURITY_AND_VISIBILITY §3.1/G1 forbids. The live append
546 /// renders it in the open pane; the persisted line lets `parseHistoryLines`
547 /// rebuild the node on reopen.
548 ///
549 /// Called at *delivery* time, not enqueue time, so the transcript shows the
550 /// message where the agent actually received it. A queued message is
551 /// therefore not yet visible — surfacing "N waiting" is a UI question
552 /// tracked as spec §8 Q3.
553 pub(super) fn append_delivered_message(&self, json_str: &str) {
554 let Some(ref broker) = self.broker else { return };
555 let global_zone =
556 super::super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
557 super::super::shell::append_output_line(
558 broker,
559 &self.block_id,
560 json_str,
561 self.filestore.as_ref(),
562 global_zone.as_deref(),
563 );
564 }
565
566 /// Answer a parked AskUserQuestion via the Agent SDK **control protocol**.
567 ///
568 /// The CLI asked us with a `can_use_tool` control_request (parked in
569 /// `pending_questions` by the stdout reader); we reply with a
570 /// `control_response` carrying `updatedInput.answers`. This is the ONLY
571 /// mechanism the CLI accepts — delivering a `tool_result` on stdin does NOT
572 /// work (the CLI auto-rejects AskUserQuestion within the turn). `answers` is
573 /// the JSON object mapping each question's text to the selected label(s) or
574 /// free-text. Process must already be running (agent is mid-turn, blocked on
575 /// this answer). Spec: docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md §2.3.
576 /// `pending_questions` is in-memory-only, scoped to THIS controller
577 /// instance — a fresh instance (pane reopen, or any process respawn)
578 /// starts with an empty map even though the persisted transcript can
579 /// still show the question as the tail node (deliberately preserved by
580 /// `scrubOrphanedInProgress` as "may still be answerable"). The frontend
581 /// (`useAgentQuestions.ts`'s `SAFE_TO_RETRY_VIA_FOLLOWUP` allowlist)
582 /// matches on this error's text (the "no pending AskUserQuestion" prefix)
583 /// to redeliver as a follow-up message instead of rolling back — keep
584 /// that exact prefix stable if this message ever changes. See
585 /// docs/reports/REPORT_WORKING_STATE_REGRESSION_AND_STUCK_QUESTION_PANEL_2026_07_27.md §2.7/§2.8.
586 pub fn answer_question(&self, tool_use_id: String, answers: serde_json::Value) -> Result<(), String> {
587 let resume_msg = build_answer_resume_message(&answers);
588 self.reply_to_pending_question(
589 tool_use_id,
590 "persistent process not running (cannot deliver answer)",
591 |questions, tool_use_id| {
592 serde_json::json!({
593 "behavior": "allow",
594 "updatedInput": { "questions": questions, "answers": answers },
595 "toolUseID": tool_use_id,
596 })
597 },
598 resume_msg,
599 &ANSWER_DEAD_AIR_LOGS,
600 )
601 }
602
603 /// Decline a parked AskUserQuestion via the Agent SDK **control protocol**
604 /// — the Cancel button / Escape in `AgentQuestionPanel.tsx`. Structurally a
605 /// mirror of `answer_question` (same `pending_questions` lookup/removal,
606 /// same dead-air safety net — the CLI can abandon a pending tool_use whose
607 /// turn already ended regardless of whether the response was an allow or a
608 /// deny), but sends `behavior: "deny"` with a `message` instead of
609 /// `behavior: "allow"` with `updatedInput`. This is a general, documented
610 /// Agent SDK mechanism (`PermissionResult` deny case for the `canUseTool`
611 /// callback) — AskUserQuestion goes through the exact same callback as
612 /// ordinary tool permission requests, confirmed against the official Agent
613 /// SDK docs (code.claude.com/docs/en/agent-sdk/user-input). Spec:
614 /// docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md.
615 ///
616 /// See `answer_question`'s doc comment for the `pending_questions`
617 /// in-memory-only caveat and the exact error-prefix stability requirement
618 /// (`useAgentQuestions.ts`'s `SAFE_TO_RETRY_VIA_FOLLOWUP` allowlist matches
619 /// on this method's error text too, since it shares the identical lookup).
620 pub fn deny_question(&self, tool_use_id: String, message: String) -> Result<(), String> {
621 let resume_msg = build_deny_resume_message(&message);
622 self.reply_to_pending_question(
623 tool_use_id,
624 "persistent process not running (cannot deliver decline)",
625 |_questions, tool_use_id| {
626 serde_json::json!({
627 "behavior": "deny",
628 "message": message,
629 "toolUseID": tool_use_id,
630 })
631 },
632 resume_msg,
633 &DENY_DEAD_AIR_LOGS,
634 )
635 }
636
637 /// Removes the parked AskUserQuestion `tool_use_id` and replies to it with
638 /// `response_body(questions, tool_use_id)` via
639 /// [`Self::send_control_response_with_fallback`]. The "no pending
640 /// AskUserQuestion" error prefix is matched by the frontend.
641 fn reply_to_pending_question(
642 &self,
643 tool_use_id: String,
644 not_running_error: &'static str,
645 response_body: impl FnOnce(serde_json::Value, &str) -> serde_json::Value,
646 resume_msg: String,
647 logs: &'static DeadAirLogs,
648 ) -> Result<(), String> {
649 let (request_id, questions, tx) = {
650 let mut inner = self.inner.lock().unwrap();
651 let (rid, qs) = inner
652 .pending_questions
653 .remove(&tool_use_id)
654 .ok_or_else(|| format!(
655 "no pending AskUserQuestion for tool_use_id {tool_use_id} — this controller \
656 instance never recorded it (process likely respawned since the question was \
657 asked, e.g. a pane close/reopen); the caller should redeliver as a follow-up message"
658 ))?;
659 let tx = inner
660 .stdin_tx
661 .as_ref()
662 .ok_or(not_running_error)?
663 .clone();
664 (rid, qs, tx)
665 };
666 let body = response_body(questions, &tool_use_id);
667 self.send_control_response_with_fallback(tx, request_id, body, tool_use_id, resume_msg, logs)
668 }
669
670 /// Sends the `control_response` for a parked `can_use_tool` request and
671 /// arms the dead-air fallback, which re-delivers `resume_msg` as a
672 /// follow-up user turn if no stdout activity follows the response.
673 fn send_control_response_with_fallback(
674 &self,
675 tx: mpsc::Sender<String>,
676 request_id: String,
677 response_body: serde_json::Value,
678 tool_use_id: String,
679 resume_msg: String,
680 logs: &'static DeadAirLogs,
681 ) -> Result<(), String> {
682 let control_response = serde_json::json!({
683 "type": "control_response",
684 "response": {
685 "subtype": "success",
686 "request_id": request_id,
687 "response": response_body,
688 }
689 });
690 // Snapshot stdout activity BEFORE sending, so a fast resume that emits
691 // between the send and the snapshot can't be mistaken for "no
692 // activity".
693 let stdout_seq = Arc::clone(&self.stdout_seq);
694 let before_seq = stdout_seq.load(Ordering::Relaxed);
695
696 tx.try_send(control_response.to_string())
697 .map_err(|e| format!("control_response send failed: {e}"))?;
698
699 // Dead-air safety net. The CLI *abandons* a pending tool_use if its
700 // turn already ended, silently dropping the control_response above
701 // (allow or deny), and the model stalls on an empty message
702 // (SPEC_ASK_USER_QUESTION_2026_06_15.md §9/§10.1). No stdout activity
703 // shortly after means the turn did not resume, so re-deliver the
704 // choice as a follow-up user turn. Gated on stdout activity (every
705 // frame, incl. control frames), so it never double-delivers.
706 let inner = Arc::clone(&self.inner);
707 let block_id = self.block_id.clone();
708 let persist = self.redelivery_persister();
709 tokio::spawn(async move {
710 tokio::time::sleep(std::time::Duration::from_millis(ANSWER_RESUME_FALLBACK_MS)).await;
711 // Any stdout frame since the snapshot means the turn resumed — nothing to do.
712 if stdout_seq.load(Ordering::Relaxed) != before_seq {
713 return;
714 }
715 let line = serde_json::json!({
716 "type": "user",
717 "message": { "role": "user", "content": resume_msg }
718 })
719 .to_string();
720 // The line is recorded in the transcript (spec §6.9) only if it was
721 // written, and not while a restart or stop is committed (the
722 // process is going down and may never read it; History must not
723 // claim a decision it never got). Recorded under the same `inner`
724 // acquisition as the send, so the fallbacks can't interleave with
725 // each other; ordering against a concurrent ordinary send is the
726 // same as between two ordinary sends (each records after its own
727 // send). `persist.write` never takes `inner`.
728 let guard = inner.lock().unwrap();
729 let stdin_tx = guard.stdin_tx.clone();
730 match stdin_tx {
731 Some(stdin_tx) if stdin_tx.try_send(line.clone()).is_ok() => {
732 if !guard.restart_pending && !guard.stop_pending {
733 persist.write(&line);
734 }
735 drop(guard);
736 tracing::warn!(
737 block_id = %block_id,
738 tool_use_id = %tool_use_id,
739 fallback_ms = ANSWER_RESUME_FALLBACK_MS,
740 "{}",
741 logs.redelivered
742 );
743 }
744 Some(_) => tracing::warn!(block_id = %block_id, "{}", logs.send_failed),
745 None => tracing::warn!(block_id = %block_id, "{}", logs.not_running),
746 }
747 });
748 Ok(())
749 }
750
751 /// Decide a parked ordinary tool-permission request via the Agent SDK
752 /// **control protocol** — the eventual `AgentDecisionPanel` Allow/Deny
753 /// buttons, once `should_route_to_decision_panel` is flipped on (Phase 2,
754 /// `SPEC_DECISION_PROMPT_2026_04_24.md` / `SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md`
755 /// §5). Mirrors `answer_question`/`deny_question` exactly — same
756 /// `pending_*` map lookup/removal, same dead-air safety net — combined
757 /// into one method because the frontend's `tool:decision` RPC already
758 /// carries `outcome` as a single field rather than two separate
759 /// commands.
760 ///
761 /// `outcome` must be `"allow"` or `"deny"` — validated by the caller
762 /// (`websocket.rs`'s `tooldecision` handler) before this is reached, the
763 /// same division of responsibility as that handler's existing `scope`
764 /// validation. An unrecognized value is treated as `"deny"` (fail
765 /// closed) rather than panicking or silently allowing.
766 ///
767 /// On allow, `updatedInput` echoes the ORIGINAL input verbatim — this
768 /// method does not support editing the call before approving it (no UI
769 /// for that exists; `SPEC_DECISION_PROMPT_2026_04_24.md` never scoped
770 /// one). On deny, `feedback` becomes the CLI-facing `message`
771 /// (`SPEC_DECISION_PROMPT`'s G6: "Denials carry user-typed feedback
772 /// verbatim to the agent"); a caller with no feedback gets a generic
773 /// default so the model still learns the call was refused.
774 ///
775 /// See `answer_question`'s doc comment for the `pending_permissions`
776 /// in-memory-only caveat (a fresh controller instance — pane reopen, any
777 /// process respawn — starts with an empty map) and for why the error
778 /// text names the tool_use_id and the likely cause.
779 pub fn decide_tool_permission(
780 &self,
781 tool_use_id: String,
782 outcome: &str,
783 feedback: Option<String>,
784 ) -> Result<(), String> {
785 let (request_id, tool_name, input, tx) = {
786 let mut inner = self.inner.lock().unwrap();
787 let (rid, tool_name, input) = inner
788 .pending_permissions
789 .remove(&tool_use_id)
790 .ok_or_else(|| format!(
791 "no pending tool-permission request for tool_use_id {tool_use_id} — this \
792 controller instance never recorded it (process likely respawned since the \
793 request was made, e.g. a pane close/reopen); the caller should redeliver \
794 as a follow-up message"
795 ))?;
796 let tx = inner
797 .stdin_tx
798 .as_ref()
799 .ok_or("persistent process not running (cannot deliver decision)")?
800 .clone();
801 (rid, tool_name, input, tx)
802 };
803
804 let allow = outcome == "allow";
805 let response_body = if allow {
806 serde_json::json!({
807 "behavior": "allow",
808 "updatedInput": input,
809 "toolUseID": tool_use_id,
810 })
811 } else {
812 serde_json::json!({
813 "behavior": "deny",
814 "message": feedback.clone().unwrap_or_else(|| "Denied by user.".to_string()),
815 "toolUseID": tool_use_id,
816 })
817 };
818 let resume_msg = build_tool_decision_resume_message(&tool_name, outcome, feedback.as_deref());
819 self.send_control_response_with_fallback(
820 tx,
821 request_id,
822 response_body,
823 tool_use_id,
824 resume_msg,
825 &TOOL_DECISION_DEAD_AIR_LOGS,
826 )
827 }
828
829 /// Push a raw NDJSON line to the live stdin (used to emit control_responses
830 /// from the stdout-reader task, which only holds an `Arc<Mutex<Inner>>`).
831 pub(super) fn push_stdin(inner: &Arc<Mutex<PersistentInner>>, line: String) {
832 let guard = inner.lock().unwrap();
833 if let Some(tx) = guard.stdin_tx.as_ref() {
834 let _ = tx.try_send(line);
835 }
836 }
837
838 /// Handle a control-protocol frame from the CLI's stdout. `control_request`
839 /// of subtype `can_use_tool`: AskUserQuestion is **parked** (the frontend
840 /// panel — rendered from the assistant stream — answers it via
841 /// `answer_question`); every other tool is routed to
842 /// `should_route_to_decision_panel`, which today always says no, so it is
843 /// **auto-allowed** to preserve the current bypass/yolo UX (Phase 1; see
844 /// that function's own doc comment for why Phase 2, #551, isn't simply
845 /// "flip it to true"). `control_response` frames (replies to requests we
846 /// initiate, none today) are logged and dropped. These frames are NOT
847 /// conversation output and never reach the blockfile.
848 /// Spec: docs/specs/SPEC_AGENT_CONTROL_PROTOCOL_2026_06_15.md §4.2.
849 pub(super) fn handle_control_frame(
850 kind: &str,
851 parsed: &serde_json::Value,
852 block_id: &str,
853 inner: &Arc<Mutex<PersistentInner>>,
854 ) {
855 if kind == "control_response" {
856 return;
857 }
858 // control_request
859 let req = match parsed.get("request") {
860 Some(r) => r,
861 None => return,
862 };
863 let subtype = req.get("subtype").and_then(|v| v.as_str()).unwrap_or("");
864 let request_id = parsed
865 .get("request_id")
866 .and_then(|v| v.as_str())
867 .unwrap_or("")
868 .to_string();
869
870 if subtype != "can_use_tool" {
871 tracing::info!(block_id = %block_id, subtype = %subtype, "persistent control_request: unhandled subtype, ignoring");
872 return;
873 }
874
875 let tool_name = req.get("tool_name").and_then(|v| v.as_str()).unwrap_or("");
876 let tool_use_id = req
877 .get("tool_use_id")
878 .and_then(|v| v.as_str())
879 .unwrap_or("")
880 .to_string();
881 let input = req.get("input").cloned().unwrap_or_else(|| serde_json::json!({}));
882
883 if tool_name == "AskUserQuestion" {
884 // Park; the frontend question panel will answer via answer_question().
885 let questions = input
886 .get("questions")
887 .cloned()
888 .unwrap_or_else(|| serde_json::json!([]));
889 {
890 let mut guard = inner.lock().unwrap();
891 guard
892 .pending_questions
893 .insert(tool_use_id.clone(), (request_id, questions));
894 }
895 tracing::info!(block_id = %block_id, tool_use_id = %tool_use_id, "AskUserQuestion parked; awaiting user answer");
896 } else if should_route_to_decision_panel(tool_name) {
897 // PHASE2-GATE: unreachable in production today (see that
898 // function's doc comment). Park exactly like AskUserQuestion
899 // above; the eventual AgentDecisionPanel Allow/Deny answers via
900 // `PersistentSubprocessController::decide_tool_permission`.
901 park_tool_permission_request(inner, tool_use_id.clone(), request_id, tool_name.to_string(), input);
902 tracing::info!(block_id = %block_id, tool_use_id = %tool_use_id, tool_name = %tool_name, "tool-permission request parked; awaiting user decision");
903 } else {
904 // Auto-allow every other tool (preserve today's bypass UX).
905 let resp = serde_json::json!({
906 "type": "control_response",
907 "response": {
908 "subtype": "success",
909 "request_id": request_id,
910 "response": { "behavior": "allow", "updatedInput": input }
911 }
912 });
913 Self::push_stdin(inner, resp.to_string());
914 }
915 }
916}
917
918/// The warn-level lines one dead-air fallback logs: re-delivered, stdin send
919/// failed, and skipped because the process is gone.
920struct DeadAirLogs {
921 redelivered: &'static str,
922 send_failed: &'static str,
923 not_running: &'static str,
924}
925
926const ANSWER_DEAD_AIR_LOGS: DeadAirLogs = DeadAirLogs {
927 redelivered: "AskUserQuestion answer did not resume the turn — re-delivered as a follow-up message (dead-air fallback)",
928 send_failed: "AskUserQuestion dead-air fallback: stdin send failed",
929 not_running: "AskUserQuestion dead-air fallback skipped: process not running",
930};
931
932const DENY_DEAD_AIR_LOGS: DeadAirLogs = DeadAirLogs {
933 redelivered: "AskUserQuestion decline did not resume the turn — re-delivered as a follow-up message (dead-air fallback)",
934 send_failed: "AskUserQuestion deny dead-air fallback: stdin send failed",
935 not_running: "AskUserQuestion deny dead-air fallback skipped: process not running",
936};
937
938const TOOL_DECISION_DEAD_AIR_LOGS: DeadAirLogs = DeadAirLogs {
939 redelivered: "tool-permission decision did not resume the turn — re-delivered as a follow-up message (dead-air fallback)",
940 send_failed: "tool-permission dead-air fallback: stdin send failed",
941 not_running: "tool-permission dead-air fallback skipped: process not running",
942};
943
944/// Records a dead-air re-delivery in the transcript. The three dead-air
945/// fallbacks (answer, decline, tool-permission decision) re-send the user's
946/// choice as a follow-up stdin line when the CLI abandoned the pending tool
947/// call; the CLI then writes no tool result, so without this the choice is
948/// gone on reload, absent from History, and lost when the live feed rolls the
949/// turn off (spec §6.9; Codex review of #3703). Written like every other
950/// stdin line (`persist_message_to_blockfile`), after the send succeeded.
951pub(super) struct RedeliveryPersister {
952 broker: Option<Arc<mps::Broker>>,
953 filestore: Option<Arc<FileStore>>,
954 mstore: Option<Arc<Store>>,
955 block_id: String,
956}
957
958impl RedeliveryPersister {
959 pub(super) fn write(&self, line: &str) {
960 let global_zone =
961 super::super::shell::resolve_global_output_zone(&self.mstore, &self.block_id);
962 let record = format!("{line}\n");
963 super::super::shell::persist_user_line(
964 self.broker.as_deref(),
965 &self.block_id,
966 record.as_bytes(),
967 self.filestore.as_ref(),
968 global_zone.as_deref(),
969 );
970 }
971}