From 339ddf9a99cecc85c670b61f8a67f2e9fbbfc774 Mon Sep 17 00:00:00 2001 From: yishuiliunian Date: Wed, 1 Apr 2026 13:58:21 +0800 Subject: [PATCH 1/2] fix: ESC interrupt lost after agent state machine refactor, background agent results not delivered to root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs introduced in c29b548 (agent state machine refactor): 1. ESC interrupt silently ignored after first use: `emit_interrupted()` sets status to WaitingForInput, then the loop's `transition(WaitingForInput)` emits a duplicate AwaitingInput event. The second event resets TUI's `agent_idle` to true even while the agent is executing tools, making all subsequent ESC presses no-ops. Fix: make `transition()` idempotent — skip emission when already in the target status. 2. Background sub-agent results never reach root agent: `start_agent_io` (root agent registration) called `register_connection` without a `completion_tx`. When background children finish, Hub's `prepare_parent_delivery` finds no channel and silently drops the result. Root agent blocks in `recv_input()` forever. Fix: create completion_tx + bridge in `start_agent_io`, matching the sub-agent path in `register_agent_connection`. Also adds diagnostic tracing to the interrupt signal chain (ESC → TUI → SessionController → Hub → forward_loop) for future debugging. --- crates/loopal-agent-hub/src/agent_io.rs | 15 ++++++++++++--- .../src/dispatch/dispatch_handlers.rs | 8 +++++++- crates/loopal-agent-hub/src/spawn_manager.rs | 2 +- crates/loopal-agent-server/src/session_forward.rs | 1 + crates/loopal-runtime/src/agent_loop/runner.rs | 8 ++++++++ crates/loopal-session/src/controller.rs | 5 +++-- crates/loopal-session/src/controller_ops.rs | 3 +++ crates/loopal-tui/src/input/navigation.rs | 6 +++++- 8 files changed, 40 insertions(+), 8 deletions(-) diff --git a/crates/loopal-agent-hub/src/agent_io.rs b/crates/loopal-agent-hub/src/agent_io.rs index a5a9828e..ff3895fe 100644 --- a/crates/loopal-agent-hub/src/agent_io.rs +++ b/crates/loopal-agent-hub/src/agent_io.rs @@ -8,7 +8,7 @@ use tracing::{info, warn}; use loopal_ipc::connection::{Connection, Incoming}; use loopal_ipc::protocol::methods; -use loopal_protocol::AgentEvent; +use loopal_protocol::{AgentEvent, Envelope}; use crate::dispatch::dispatch_hub_request; use crate::hub::Hub; @@ -125,25 +125,34 @@ fn spawn_wait_agent( } /// Register agent Connection in Hub and spawn background IO loop. +/// +/// Sets up a completion channel + bridge so background sub-agent results +/// are forwarded to this agent via IPC `agent/message` notifications. pub fn start_agent_io( hub: Arc>, name: &str, conn: Arc, rx: tokio::sync::mpsc::Receiver, ) { - // Registration + IO loop in one background task (used by hub_server for incoming clients) let hub2 = hub.clone(); let n = name.to_string(); let n2 = name.to_string(); let conn2 = conn.clone(); + let conn3 = conn.clone(); tokio::spawn(async move { + // Completion channel: delivers background sub-agent results to this agent. + let (completion_tx, completion_rx) = tokio::sync::mpsc::channel::(32); { let mut h = hub.lock().await; - if let Err(e) = h.registry.register_connection(&n, conn2) { + if let Err(e) = + h.registry + .register_connection_with_parent(&n, conn2, None, None, Some(completion_tx)) + { tracing::warn!(agent = %n, error = %e, "registration failed"); return; } } + crate::spawn_manager::spawn_completion_bridge(&n, conn3, completion_rx); info!(agent = %n, "agent registered in Hub"); let output = agent_io_loop(hub2, conn, rx, n.clone()).await; let pending = { diff --git a/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs b/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs index 998462bf..110389eb 100644 --- a/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs +++ b/crates/loopal-agent-hub/src/dispatch/dispatch_handlers.rs @@ -48,15 +48,21 @@ pub async fn handle_control(hub: &Arc>, params: Value) -> Result>, params: Value) -> Result { let target = params["target"].as_str().ok_or("missing 'target' field")?; + tracing::info!(target, "handle_interrupt: looking up agent connection"); let conn = { let h = hub.lock().await; h.registry .get_agent_connection(target) .ok_or_else(|| format!("no agent: '{target}'"))? }; - let _ = conn + let result = conn .send_notification(methods::AGENT_INTERRUPT.name, json!({})) .await; + match &result { + Ok(()) => tracing::info!(target, "handle_interrupt: notification sent"), + Err(e) => tracing::warn!(target, error = %e, "handle_interrupt: send failed"), + } + let _ = result; Ok(json!({"ok": true})) } diff --git a/crates/loopal-agent-hub/src/spawn_manager.rs b/crates/loopal-agent-hub/src/spawn_manager.rs index c377f4f2..c23ca8b0 100644 --- a/crates/loopal-agent-hub/src/spawn_manager.rs +++ b/crates/loopal-agent-hub/src/spawn_manager.rs @@ -133,7 +133,7 @@ pub async fn register_agent_connection( } /// Bridge: reads from Hub-internal channel, forwards to agent via IPC notification. -fn spawn_completion_bridge(name: &str, conn: Arc, mut rx: mpsc::Receiver) { +pub fn spawn_completion_bridge(name: &str, conn: Arc, mut rx: mpsc::Receiver) { let n = name.to_string(); tokio::spawn(async move { while let Some(envelope) = rx.recv().await { diff --git a/crates/loopal-agent-server/src/session_forward.rs b/crates/loopal-agent-server/src/session_forward.rs index c1975234..b0c5f111 100644 --- a/crates/loopal-agent-server/src/session_forward.rs +++ b/crates/loopal-agent-server/src/session_forward.rs @@ -47,6 +47,7 @@ pub(crate) async fn forward_loop( } Incoming::Notification { method, params } => { if method == methods::AGENT_INTERRUPT.name { + tracing::info!("forward_loop: received agent/interrupt, signaling"); session.interrupt.signal(); session.interrupt_tx.send_modify(|v| *v = v.wrapping_add(1)); } else if method == methods::AGENT_MESSAGE.name { diff --git a/crates/loopal-runtime/src/agent_loop/runner.rs b/crates/loopal-runtime/src/agent_loop/runner.rs index 90b36db6..c2aafda9 100644 --- a/crates/loopal-runtime/src/agent_loop/runner.rs +++ b/crates/loopal-runtime/src/agent_loop/runner.rs @@ -110,7 +110,15 @@ impl AgentLoopRunner { /// /// **This is the ONLY way to change agent status.** Every status change /// goes through this method, ensuring SSOT and deterministic event emission. + /// + /// Skips emission when already in the target status (idempotent) to prevent + /// duplicate idle events — e.g. `emit_interrupted()` already transitions to + /// WaitingForInput, so the subsequent `transition(WaitingForInput)` at the + /// top of the loop must NOT emit a second AwaitingInput. pub(super) async fn transition(&mut self, new_status: AgentStatus) -> Result<()> { + if self.status == new_status { + return Ok(()); + } self.status = new_status; match new_status { AgentStatus::Starting => Ok(()), diff --git a/crates/loopal-session/src/controller.rs b/crates/loopal-session/src/controller.rs index 8ee486d3..fdddad46 100644 --- a/crates/loopal-session/src/controller.rs +++ b/crates/loopal-session/src/controller.rs @@ -76,8 +76,9 @@ impl SessionController { /// Interrupt the currently viewed agent. pub fn interrupt(&self) { - tracing::debug!("session: interrupt signaled"); - self.backend.interrupt_target(&self.active_target()); + let target = self.active_target(); + tracing::info!(target = %target, "session: interrupt signaled"); + self.backend.interrupt_target(&target); } /// Interrupt a specific named agent (e.g., to terminate from agent panel). diff --git a/crates/loopal-session/src/controller_ops.rs b/crates/loopal-session/src/controller_ops.rs index aefbc460..9043ba34 100644 --- a/crates/loopal-session/src/controller_ops.rs +++ b/crates/loopal-session/src/controller_ops.rs @@ -21,14 +21,17 @@ impl ControlBackend { pub(crate) fn interrupt_target(&self, target: &str) { match self { Self::Local(ch) => { + tracing::info!(target, "interrupt_target: local signal"); ch.interrupt.signal(); ch.interrupt_tx.send_modify(|v| *v = v.wrapping_add(1)); } Self::Hub(client) => { + tracing::info!(target, "interrupt_target: hub IPC"); let client = client.clone(); let target = target.to_string(); tokio::spawn(async move { client.interrupt_target(&target).await; + tracing::info!(target = %target, "interrupt_target: hub IPC sent"); }); } } diff --git a/crates/loopal-tui/src/input/navigation.rs b/crates/loopal-tui/src/input/navigation.rs index 275f7fb5..efee72bf 100644 --- a/crates/loopal-tui/src/input/navigation.rs +++ b/crates/loopal-tui/src/input/navigation.rs @@ -80,13 +80,17 @@ pub(super) fn handle_down(app: &mut App) -> InputAction { pub(super) fn handle_esc(app: &mut App) -> InputAction { // Priority 1: exit agent view - if app.session.lock().active_view != loopal_session::ROOT_AGENT { + let active_view = app.session.lock().active_view.clone(); + if active_view != loopal_session::ROOT_AGENT { + tracing::info!(view = %active_view, "ESC: exit agent view (not root)"); return InputAction::ExitAgentView; } let is_idle = app.session.lock().active_conversation().agent_idle; if !is_idle { + tracing::info!("ESC: agent busy, sending interrupt"); return InputAction::Interrupt; } + tracing::debug!("ESC: agent idle, no interrupt"); let now = Instant::now(); let is_empty = app.input.is_empty(); if is_empty { From 9624b0b80cceff1e017e4687f0e54e088c2a75e3 Mon Sep 17 00:00:00 2001 From: yishuiliunian Date: Wed, 1 Apr 2026 14:05:42 +0800 Subject: [PATCH 2/2] style: fix rustfmt formatting --- crates/loopal-agent-hub/src/agent_io.rs | 11 +++++++---- crates/loopal-agent-hub/src/spawn_manager.rs | 6 +++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/loopal-agent-hub/src/agent_io.rs b/crates/loopal-agent-hub/src/agent_io.rs index ff3895fe..aae6f40b 100644 --- a/crates/loopal-agent-hub/src/agent_io.rs +++ b/crates/loopal-agent-hub/src/agent_io.rs @@ -144,10 +144,13 @@ pub fn start_agent_io( let (completion_tx, completion_rx) = tokio::sync::mpsc::channel::(32); { let mut h = hub.lock().await; - if let Err(e) = - h.registry - .register_connection_with_parent(&n, conn2, None, None, Some(completion_tx)) - { + if let Err(e) = h.registry.register_connection_with_parent( + &n, + conn2, + None, + None, + Some(completion_tx), + ) { tracing::warn!(agent = %n, error = %e, "registration failed"); return; } diff --git a/crates/loopal-agent-hub/src/spawn_manager.rs b/crates/loopal-agent-hub/src/spawn_manager.rs index c23ca8b0..617237e1 100644 --- a/crates/loopal-agent-hub/src/spawn_manager.rs +++ b/crates/loopal-agent-hub/src/spawn_manager.rs @@ -133,7 +133,11 @@ pub async fn register_agent_connection( } /// Bridge: reads from Hub-internal channel, forwards to agent via IPC notification. -pub fn spawn_completion_bridge(name: &str, conn: Arc, mut rx: mpsc::Receiver) { +pub fn spawn_completion_bridge( + name: &str, + conn: Arc, + mut rx: mpsc::Receiver, +) { let n = name.to_string(); tokio::spawn(async move { while let Some(envelope) = rx.recv().await {