feat(engine): Phase 2 — execution loop, capability system, thread runtime

Add the core execution engine to ironclaw_engine crate:

- CapabilityRegistry: register/get/list capabilities and actions
- LeaseManager: async lease lifecycle (grant, check, consume, revoke, expire)
- PolicyEngine: deterministic effect-level allow/deny/approve
- ThreadTree: parent-child relationship tracking
- ThreadSignal/ThreadOutcome: inter-thread messaging via mpsc
- ThreadManager: spawn threads as tokio tasks, stop, inject messages, join
- ExecutionLoop: core loop replacing run_agentic_loop() with signals,
  context building, LLM calls, action execution, and event recording
- Structured executor (Tier 0): lease lookup → policy check → effect execution
- Tool intent nudge detection
- MemoryStore + RetrievalEngine stubs for Phase 4
- Full 8-phase architecture plan in docs/plans/
- CLAUDE.md spec for the engine crate

74 tests passing, zero clippy warnings.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-21 00:16:41 -07:00
co-authored by Claude Opus 4.6
parent 8be19a4128
commit bf7dfb8c49
19 changed files with 2497 additions and 10 deletions
@@ -0,0 +1,422 @@
//! Thread manager — top-level orchestrator for thread lifecycle.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{debug, error};
use crate::capability::lease::LeaseManager;
use crate::capability::policy::PolicyEngine;
use crate::capability::registry::CapabilityRegistry;
use crate::executor::ExecutionLoop;
use crate::runtime::messaging::{self, SignalSender, ThreadOutcome, ThreadSignal};
use crate::runtime::tree::ThreadTree;
use crate::traits::effect::EffectExecutor;
use crate::traits::llm::LlmBackend;
use crate::traits::store::Store;
use crate::types::error::EngineError;
use crate::types::message::ThreadMessage;
use crate::types::project::ProjectId;
use crate::types::thread::{Thread, ThreadConfig, ThreadId, ThreadType};
/// Handle to a running thread for checking results.
struct RunningThread {
signal_tx: SignalSender,
handle: tokio::task::JoinHandle<Result<ThreadOutcome, EngineError>>,
}
/// Top-level orchestrator for thread lifecycle.
///
/// Manages thread spawning, supervision, signaling, and tree relationships.
pub struct ThreadManager {
llm: Arc<dyn LlmBackend>,
effects: Arc<dyn EffectExecutor>,
store: Arc<dyn Store>,
pub capabilities: Arc<CapabilityRegistry>,
pub leases: Arc<LeaseManager>,
pub policy: Arc<PolicyEngine>,
tree: RwLock<ThreadTree>,
running: RwLock<HashMap<ThreadId, RunningThread>>,
}
impl ThreadManager {
pub fn new(
llm: Arc<dyn LlmBackend>,
effects: Arc<dyn EffectExecutor>,
store: Arc<dyn Store>,
capabilities: Arc<CapabilityRegistry>,
leases: Arc<LeaseManager>,
policy: Arc<PolicyEngine>,
) -> Self {
Self {
llm,
effects,
store,
capabilities,
leases,
policy,
tree: RwLock::new(ThreadTree::new()),
running: RwLock::new(HashMap::new()),
}
}
/// Spawn a new thread and start executing it.
///
/// Grants default capability leases for all registered capabilities.
/// Returns the thread ID immediately; the thread runs in a background task.
pub async fn spawn_thread(
&self,
goal: impl Into<String>,
thread_type: ThreadType,
project_id: ProjectId,
config: ThreadConfig,
parent_id: Option<ThreadId>,
user_id: impl Into<String>,
) -> Result<ThreadId, EngineError> {
let mut thread = Thread::new(goal, thread_type, project_id, config);
if let Some(pid) = parent_id {
thread = thread.with_parent(pid);
}
let thread_id = thread.id;
let user_id = user_id.into();
// Register in tree
if let Some(pid) = parent_id {
self.tree.write().await.add_child(pid, thread_id);
}
// Grant leases for all registered capabilities
for cap in self.capabilities.list() {
let lease = self
.leases
.grant(thread_id, &cap.name, vec![], None, None)
.await;
thread.capability_leases.push(lease.id);
}
// Persist
self.store.save_thread(&thread).await?;
// Create signal channel
let (tx, rx) = messaging::signal_channel(32);
// Build execution loop
let llm = Arc::clone(&self.llm);
let effects = Arc::clone(&self.effects);
let leases = Arc::clone(&self.leases);
let policy = Arc::clone(&self.policy);
let exec_loop = ExecutionLoop::new(thread, llm, effects, leases, policy, rx, user_id);
// Spawn background task
let handle = tokio::spawn(async move {
let mut exec = exec_loop;
let result = exec.run().await;
debug!(thread_id = %thread_id, "thread execution finished");
result
});
self.running.write().await.insert(
thread_id,
RunningThread {
signal_tx: tx,
handle,
},
);
Ok(thread_id)
}
/// Send a stop signal to a running thread.
pub async fn stop_thread(&self, thread_id: ThreadId) -> Result<(), EngineError> {
let running = self.running.read().await;
if let Some(rt) = running.get(&thread_id) {
let _ = rt.signal_tx.send(ThreadSignal::Stop).await;
Ok(())
} else {
Err(EngineError::ThreadNotFound(thread_id))
}
}
/// Inject a user message into a running thread.
pub async fn inject_message(
&self,
thread_id: ThreadId,
message: ThreadMessage,
) -> Result<(), EngineError> {
let running = self.running.read().await;
if let Some(rt) = running.get(&thread_id) {
let _ = rt
.signal_tx
.send(ThreadSignal::InjectMessage(message))
.await;
Ok(())
} else {
Err(EngineError::ThreadNotFound(thread_id))
}
}
/// Check if a thread is still running.
pub async fn is_running(&self, thread_id: ThreadId) -> bool {
let running = self.running.read().await;
running
.get(&thread_id)
.is_some_and(|rt| !rt.handle.is_finished())
}
/// Wait for a thread to finish and return its outcome.
/// Removes the thread from the running set.
pub async fn join_thread(
&self,
thread_id: ThreadId,
) -> Result<ThreadOutcome, EngineError> {
let rt = {
let mut running = self.running.write().await;
running.remove(&thread_id)
};
match rt {
Some(rt) => match rt.handle.await {
Ok(result) => result,
Err(e) => {
error!(thread_id = %thread_id, "thread task panicked: {e}");
Ok(ThreadOutcome::Failed {
error: format!("thread task panicked: {e}"),
})
}
},
None => Err(EngineError::ThreadNotFound(thread_id)),
}
}
/// Get children of a thread.
pub async fn children_of(&self, thread_id: ThreadId) -> Vec<ThreadId> {
let tree = self.tree.read().await;
tree.children_of(thread_id).to_vec()
}
/// Get the parent of a thread.
pub async fn parent_of(&self, thread_id: ThreadId) -> Option<ThreadId> {
let tree = self.tree.read().await;
tree.parent_of(thread_id)
}
/// Clean up finished threads from the running set.
pub async fn cleanup_finished(&self) -> Vec<ThreadId> {
let mut running = self.running.write().await;
let finished: Vec<ThreadId> = running
.iter()
.filter(|(_, rt)| rt.handle.is_finished())
.map(|(id, _)| *id)
.collect();
for id in &finished {
running.remove(id);
}
finished
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::capability::{ActionDef, Capability, CapabilityLease, EffectType};
use crate::types::event::ThreadEvent;
use crate::types::memory::{DocId, MemoryDoc};
use crate::types::project::Project;
use crate::types::step::{ActionResult, LlmResponse, Step, TokenUsage};
use crate::types::thread::ThreadState;
use crate::traits::llm::{LlmCallConfig, LlmOutput};
use std::sync::Mutex;
use std::time::Duration;
// ── Mocks ───────────────────────────────────────────────
struct MockLlm {
responses: Mutex<Vec<LlmOutput>>,
}
impl MockLlm {
fn text(msg: &str) -> Arc<Self> {
Arc::new(Self {
responses: Mutex::new(vec![LlmOutput {
response: LlmResponse::Text(msg.into()),
usage: TokenUsage::default(),
}]),
})
}
}
#[async_trait::async_trait]
impl LlmBackend for MockLlm {
async fn complete(
&self,
_: &[crate::types::message::ThreadMessage],
_: &[ActionDef],
_: &LlmCallConfig,
) -> Result<LlmOutput, EngineError> {
let mut r = self.responses.lock().unwrap();
if r.is_empty() {
Ok(LlmOutput {
response: LlmResponse::Text("done".into()),
usage: TokenUsage::default(),
})
} else {
Ok(r.remove(0))
}
}
fn model_name(&self) -> &str {
"mock"
}
}
struct MockEffects;
#[async_trait::async_trait]
impl EffectExecutor for MockEffects {
async fn execute_action(
&self,
_: &str,
_: serde_json::Value,
_: &CapabilityLease,
_: &crate::traits::effect::ThreadExecutionContext,
) -> Result<ActionResult, EngineError> {
Ok(ActionResult {
call_id: String::new(),
action_name: String::new(),
output: serde_json::json!({}),
is_error: false,
duration: Duration::from_millis(1),
})
}
async fn available_actions(
&self,
_: &[CapabilityLease],
) -> Result<Vec<ActionDef>, EngineError> {
Ok(vec![])
}
}
struct MockStore;
#[async_trait::async_trait]
impl Store for MockStore {
async fn save_thread(&self, _: &Thread) -> Result<(), EngineError> { Ok(()) }
async fn load_thread(&self, _: ThreadId) -> Result<Option<Thread>, EngineError> { Ok(None) }
async fn list_threads(&self, _: ProjectId) -> Result<Vec<Thread>, EngineError> { Ok(vec![]) }
async fn update_thread_state(&self, _: ThreadId, _: ThreadState) -> Result<(), EngineError> { Ok(()) }
async fn save_step(&self, _: &Step) -> Result<(), EngineError> { Ok(()) }
async fn load_steps(&self, _: ThreadId) -> Result<Vec<Step>, EngineError> { Ok(vec![]) }
async fn append_events(&self, _: &[ThreadEvent]) -> Result<(), EngineError> { Ok(()) }
async fn load_events(&self, _: ThreadId) -> Result<Vec<ThreadEvent>, EngineError> { Ok(vec![]) }
async fn save_project(&self, _: &Project) -> Result<(), EngineError> { Ok(()) }
async fn load_project(&self, _: ProjectId) -> Result<Option<Project>, EngineError> { Ok(None) }
async fn save_memory_doc(&self, _: &MemoryDoc) -> Result<(), EngineError> { Ok(()) }
async fn load_memory_doc(&self, _: DocId) -> Result<Option<MemoryDoc>, EngineError> { Ok(None) }
async fn list_memory_docs(&self, _: ProjectId) -> Result<Vec<MemoryDoc>, EngineError> { Ok(vec![]) }
async fn save_lease(&self, _: &CapabilityLease) -> Result<(), EngineError> { Ok(()) }
async fn load_active_leases(&self, _: ThreadId) -> Result<Vec<CapabilityLease>, EngineError> { Ok(vec![]) }
async fn revoke_lease(&self, _: crate::types::capability::LeaseId, _: &str) -> Result<(), EngineError> { Ok(()) }
}
fn make_manager(llm: Arc<dyn LlmBackend>) -> ThreadManager {
let mut caps = CapabilityRegistry::new();
caps.register(Capability {
name: "test".into(),
description: "Test capability".into(),
actions: vec![ActionDef {
name: "test_tool".into(),
description: "Test".into(),
parameters_schema: serde_json::json!({}),
effects: vec![EffectType::ReadLocal],
requires_approval: false,
}],
knowledge: vec![],
policies: vec![],
});
ThreadManager::new(
llm,
Arc::new(MockEffects),
Arc::new(MockStore),
Arc::new(caps),
Arc::new(LeaseManager::new()),
Arc::new(PolicyEngine::new()),
)
}
// ── Tests ───────────────────────────────────────────────
#[tokio::test]
async fn spawn_and_join() {
let mgr = make_manager(MockLlm::text("Hello!"));
let project = ProjectId::new();
let tid = mgr
.spawn_thread("test", ThreadType::Foreground, project, ThreadConfig::default(), None, "user")
.await
.unwrap();
let outcome = mgr.join_thread(tid).await.unwrap();
assert!(matches!(outcome, ThreadOutcome::Completed { response: Some(r) } if r == "Hello!"));
}
#[tokio::test]
async fn stop_thread_works() {
// LLM that returns many action responses
let responses: Vec<LlmOutput> = (0..100)
.map(|i| LlmOutput {
response: LlmResponse::ActionCalls {
calls: vec![crate::types::step::ActionCall {
id: format!("c{i}"),
action_name: "test_tool".into(),
parameters: serde_json::json!({}),
}],
content: None,
},
usage: TokenUsage::default(),
})
.collect();
let mgr = make_manager(Arc::new(MockLlm {
responses: Mutex::new(responses),
}));
let project = ProjectId::new();
let tid = mgr
.spawn_thread("test", ThreadType::Foreground, project, ThreadConfig::default(), None, "user")
.await
.unwrap();
// Give it a moment to start, then stop
tokio::time::sleep(Duration::from_millis(10)).await;
mgr.stop_thread(tid).await.unwrap();
let outcome = mgr.join_thread(tid).await.unwrap();
assert!(matches!(
outcome,
ThreadOutcome::Stopped | ThreadOutcome::Completed { .. } | ThreadOutcome::MaxIterations
));
}
#[tokio::test]
async fn parent_child_tree() {
let mgr = make_manager(MockLlm::text("parent done"));
let project = ProjectId::new();
let parent = mgr
.spawn_thread("parent", ThreadType::Foreground, project, ThreadConfig::default(), None, "user")
.await
.unwrap();
let child = mgr
.spawn_thread("child", ThreadType::Research, project, ThreadConfig::default(), Some(parent), "user")
.await
.unwrap();
assert_eq!(mgr.parent_of(child).await, Some(parent));
assert_eq!(mgr.children_of(parent).await, vec![child]);
}
}
@@ -0,0 +1,53 @@
//! Thread-to-thread messaging via channels.
use crate::types::message::ThreadMessage;
use crate::types::thread::ThreadId;
/// Signal sent to a running thread via its mailbox.
#[derive(Debug)]
pub enum ThreadSignal {
/// Stop the thread gracefully.
Stop,
/// Pause execution (can be resumed later).
Suspend,
/// Resume a suspended thread.
Resume,
/// Inject a user message into the thread's context.
InjectMessage(ThreadMessage),
/// Notification that a child thread completed.
ChildCompleted {
child_id: ThreadId,
outcome: ThreadOutcome,
},
}
/// Final outcome of a thread's execution.
#[derive(Debug, Clone)]
pub enum ThreadOutcome {
/// Completed with an optional text response.
Completed { response: Option<String> },
/// Thread was stopped by a signal.
Stopped,
/// Max iterations reached without completing.
MaxIterations,
/// Terminal failure.
Failed { error: String },
/// A capability action requires user approval before continuing.
NeedApproval {
action_name: String,
call_id: String,
parameters: serde_json::Value,
},
}
/// A mailbox for sending signals to a running thread.
///
/// Each thread gets a `(sender, receiver)` pair. The `ThreadManager` holds
/// the sender; the `ExecutionLoop` holds the receiver.
pub type SignalSender = tokio::sync::mpsc::Sender<ThreadSignal>;
pub type SignalReceiver = tokio::sync::mpsc::Receiver<ThreadSignal>;
/// Create a new signal channel with the given buffer size.
pub fn signal_channel(buffer: usize) -> (SignalSender, SignalReceiver) {
tokio::sync::mpsc::channel(buffer)
}
+11 -2
View File
@@ -1,4 +1,13 @@
//! Thread lifecycle management.
//!
//! ThreadManager, thread tree, and inter-thread messaging.
//! Implemented in Phase 2.
//! - [`ThreadManager`] — top-level orchestrator for spawning and supervising threads
//! - [`ThreadTree`] — parent-child relationship tracking
//! - [`messaging`] — inter-thread signal channel
pub mod manager;
pub mod messaging;
pub mod tree;
pub use manager::ThreadManager;
pub use messaging::ThreadOutcome;
pub use tree::ThreadTree;
+129
View File
@@ -0,0 +1,129 @@
//! Thread tree — parent-child relationship tracking.
use std::collections::HashMap;
use crate::types::thread::ThreadId;
/// Manages parent-child thread relationships.
///
/// Simple in-memory tree. Threads form a forest (multiple roots).
#[derive(Debug, Default)]
pub struct ThreadTree {
/// child → parent
parents: HashMap<ThreadId, ThreadId>,
/// parent → children (ordered by insertion)
children: HashMap<ThreadId, Vec<ThreadId>>,
}
impl ThreadTree {
pub fn new() -> Self {
Self::default()
}
/// Register a parent-child relationship.
pub fn add_child(&mut self, parent_id: ThreadId, child_id: ThreadId) {
self.parents.insert(child_id, parent_id);
self.children.entry(parent_id).or_default().push(child_id);
}
/// Get the parent of a thread, if any.
pub fn parent_of(&self, thread_id: ThreadId) -> Option<ThreadId> {
self.parents.get(&thread_id).copied()
}
/// Get the children of a thread.
pub fn children_of(&self, thread_id: ThreadId) -> &[ThreadId] {
self.children
.get(&thread_id)
.map(Vec::as_slice)
.unwrap_or(&[])
}
/// Walk up the tree to collect all ancestors (parent, grandparent, ...).
pub fn ancestors(&self, thread_id: ThreadId) -> Vec<ThreadId> {
let mut result = Vec::new();
let mut current = thread_id;
while let Some(parent) = self.parents.get(&current) {
result.push(*parent);
current = *parent;
}
result
}
/// Remove a thread from the tree. Does not remove its children.
pub fn remove(&mut self, thread_id: ThreadId) {
if let Some(parent) = self.parents.remove(&thread_id)
&& let Some(siblings) = self.children.get_mut(&parent)
{
siblings.retain(|id| *id != thread_id);
}
// Orphan any children (their parent_id entries become stale)
self.children.remove(&thread_id);
}
/// Check if a thread is a root (no parent).
pub fn is_root(&self, thread_id: ThreadId) -> bool {
!self.parents.contains_key(&thread_id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn add_and_query() {
let mut tree = ThreadTree::new();
let parent = ThreadId::new();
let child1 = ThreadId::new();
let child2 = ThreadId::new();
tree.add_child(parent, child1);
tree.add_child(parent, child2);
assert_eq!(tree.parent_of(child1), Some(parent));
assert_eq!(tree.parent_of(child2), Some(parent));
assert_eq!(tree.children_of(parent).len(), 2);
assert!(tree.is_root(parent));
assert!(!tree.is_root(child1));
}
#[test]
fn ancestors_walk_up() {
let mut tree = ThreadTree::new();
let root = ThreadId::new();
let mid = ThreadId::new();
let leaf = ThreadId::new();
tree.add_child(root, mid);
tree.add_child(mid, leaf);
let ancestors = tree.ancestors(leaf);
assert_eq!(ancestors, vec![mid, root]);
}
#[test]
fn remove_detaches_from_parent() {
let mut tree = ThreadTree::new();
let parent = ThreadId::new();
let child = ThreadId::new();
tree.add_child(parent, child);
tree.remove(child);
assert_eq!(tree.parent_of(child), None);
assert!(tree.children_of(parent).is_empty());
}
#[test]
fn children_of_unknown_returns_empty() {
let tree = ThreadTree::new();
assert!(tree.children_of(ThreadId::new()).is_empty());
}
#[test]
fn ancestors_of_root_is_empty() {
let tree = ThreadTree::new();
assert!(tree.ancestors(ThreadId::new()).is_empty());
}
}