feat(engine): live progress status updates via event broadcast

Engine v2 now shows live progress in the CLI (and any channel):
- "Thinking..." when a step starts
- Tool name + success/error when actions execute
- "Processing results..." when a step completes

Implementation:
- ThreadManager holds a broadcast::Sender<ThreadEvent> (capacity 256)
- ExecutionLoop.emit_event() writes to thread.events AND broadcasts
- ThreadManager.subscribe_events() returns a receiver
- Router uses tokio::select! to listen for events while waiting for
  thread completion, forwarding them as StatusUpdate to the channel

This replaces the polling approach with zero-latency event streaming.
Agent.channels visibility widened to pub(crate) for bridge access.

102 tests passing, zero clippy warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-22 18:18:20 -07:00
co-authored by Claude Opus 4.6
parent b621cd503d
commit 7087964c22
4 changed files with 156 additions and 23 deletions
+1 -1
View File
@@ -175,7 +175,7 @@ pub struct AgentDeps {
pub struct Agent {
pub(super) config: AgentConfig,
pub(super) deps: AgentDeps,
pub(super) channels: Arc<ChannelManager>,
pub(crate) channels: Arc<ChannelManager>,
pub(super) context_manager: Arc<ContextManager>,
pub(super) scheduler: Arc<Scheduler>,
pub(super) router: Router,
+114 -13
View File
@@ -14,7 +14,7 @@ use crate::agent::Agent;
use crate::bridge::effect_adapter::EffectBridgeAdapter;
use crate::bridge::llm_adapter::LlmBridgeAdapter;
use crate::bridge::store_adapter::InMemoryStore;
use crate::channels::IncomingMessage;
use crate::channels::{IncomingMessage, StatusUpdate};
use crate::error::Error;
/// Check if the engine v2 is enabled via `ENGINE_V2=true` environment variable.
@@ -144,6 +144,16 @@ pub async fn handle_with_engine(
"engine v2: handling message"
);
// Send "Thinking..." status to the channel
let _ = agent
.channels
.send_status(
&message.channel,
StatusUpdate::Thinking("Processing...".into()),
&message.metadata,
)
.await;
// Get or create conversation for this channel+user
let conv_id = state
.conversation_manager
@@ -168,19 +178,43 @@ pub async fn handle_with_engine(
})
})?;
debug!(thread_id = %thread_id, "engine v2: thread active, waiting for completion");
debug!(thread_id = %thread_id, "engine v2: thread spawned");
// Wait for the thread to complete
let outcome = state
.thread_manager
.join_thread(thread_id)
.await
.map_err(|e| {
crate::error::Error::from(crate::error::JobError::ContextError {
id: uuid::Uuid::nil(),
reason: format!("engine v2 join error: {e}"),
})
})?;
// Subscribe to live events for progress updates
let mut event_rx = state.thread_manager.subscribe_events();
let channels = &agent.channels;
let channel_name = &message.channel;
let metadata = &message.metadata;
// Forward events to the channel while waiting for thread completion
loop {
tokio::select! {
// Check for events from the execution loop
event = event_rx.recv() => {
match event {
Ok(ref evt) if evt.thread_id == thread_id => {
forward_event_to_channel(evt, channels, channel_name, metadata).await;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
_ => {} // Event for a different thread, or lagged
}
}
// Also check if the thread has finished (in case we miss the events)
_ = tokio::time::sleep(std::time::Duration::from_millis(500)) => {
if !state.thread_manager.is_running(thread_id).await {
break;
}
}
}
}
// Join the thread to get the outcome
let outcome = state.thread_manager.join_thread(thread_id).await.map_err(|e| {
crate::error::Error::from(crate::error::JobError::ContextError {
id: uuid::Uuid::nil(),
reason: format!("engine v2 join error: {e}"),
})
})?;
// Record outcome in conversation
state
@@ -208,3 +242,70 @@ pub async fn handle_with_engine(
))),
}
}
/// Forward an engine ThreadEvent to the channel as a StatusUpdate.
async fn forward_event_to_channel(
event: &ironclaw_engine::ThreadEvent,
channels: &std::sync::Arc<crate::channels::ChannelManager>,
channel_name: &str,
metadata: &serde_json::Value,
) {
use ironclaw_engine::EventKind;
match &event.kind {
EventKind::StepStarted { .. } => {
let _ = channels
.send_status(
channel_name,
StatusUpdate::Thinking("Thinking...".into()),
metadata,
)
.await;
}
EventKind::ActionExecuted {
action_name,
..
} => {
let _ = channels
.send_status(
channel_name,
StatusUpdate::ToolCompleted {
name: action_name.clone(),
success: true,
error: None,
parameters: None,
},
metadata,
)
.await;
}
EventKind::ActionFailed {
action_name,
error,
..
} => {
let _ = channels
.send_status(
channel_name,
StatusUpdate::ToolCompleted {
name: action_name.clone(),
success: false,
error: Some(error.clone()),
parameters: None,
},
metadata,
)
.await;
}
EventKind::StepCompleted { .. } => {
let _ = channels
.send_status(
channel_name,
StatusUpdate::Thinking("Processing results...".into()),
metadata,
)
.await;
}
_ => {} // Other events don't need channel status
}
}