Files
huskies/server/src/agents/runtime/claude_code.rs
T

116 lines
4.2 KiB
Rust

//! Claude Code runtime — launches Claude Code CLI sessions as agent backends.
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
use crate::agent_log::AgentLogWriter;
use crate::io::watcher::WatcherEvent;
use crate::slog_warn;
use super::{AgentEvent, AgentRuntime, RuntimeContext, RuntimeResult, RuntimeStatus};
/// Agent runtime that spawns the `claude` CLI in a PTY and streams JSON events.
///
/// This is the default runtime (`runtime = "claude-code"` in project.toml).
/// It wraps the existing PTY-based execution logic, preserving all streaming,
/// token tracking, and inactivity timeout behaviour.
pub struct ClaudeCodeRuntime {
watcher_tx: broadcast::Sender<WatcherEvent>,
}
impl ClaudeCodeRuntime {
/// Create a new Claude Code runtime with a shared event channel.
pub fn new(watcher_tx: broadcast::Sender<WatcherEvent>) -> Self {
Self { watcher_tx }
}
}
impl AgentRuntime for ClaudeCodeRuntime {
async fn start(
&self,
ctx: RuntimeContext,
tx: broadcast::Sender<AgentEvent>,
event_log: Arc<Mutex<Vec<AgentEvent>>>,
log_writer: Option<Arc<Mutex<AgentLogWriter>>>,
) -> Result<RuntimeResult, String> {
let eager_record = ctx
.model
.as_ref()
.map(|m| (ctx.project_root.clone(), m.as_str().to_string()));
let pty_result = super::super::pty::run_agent_pty_streaming(
&ctx.story_id,
&ctx.agent_name,
&ctx.command,
&ctx.args,
&ctx.prompt,
&ctx.cwd,
&tx,
&event_log,
log_writer.clone(),
ctx.inactivity_timeout_secs,
self.watcher_tx.clone(),
ctx.session_id_to_resume.as_deref(),
eager_record.clone(),
)
.await;
match pty_result {
Ok(result) => Ok(RuntimeResult {
// Abort+no-session: CLI crashed (e.g. SIGABRT) before emitting its
// first "system" event. Detected by: non-zero exit AND no session.
aborted_signal: !result.exit_ok && result.session_id.is_none(),
exit_ok: result.exit_ok,
session_id: result.session_id,
token_usage: result.token_usage,
rate_limit_exit: result.rate_limit_exit,
rate_limit_reset_at: result.rate_limit_reset_at,
}),
Err(e) if ctx.session_id_to_resume.is_some() && ctx.fresh_prompt.is_some() => {
// Resume failed — fall back to a fresh session without --resume.
slog_warn!(
"[agents] Resume failed for {}:{}, retrying without --resume: {}",
ctx.story_id,
ctx.agent_name,
e
);
let fresh = ctx.fresh_prompt.unwrap();
let fallback_result = super::super::pty::run_agent_pty_streaming(
&ctx.story_id,
&ctx.agent_name,
&ctx.command,
&ctx.args,
&fresh,
&ctx.cwd,
&tx,
&event_log,
log_writer,
ctx.inactivity_timeout_secs,
self.watcher_tx.clone(),
None, // no --resume on fallback
eager_record,
)
.await?;
Ok(RuntimeResult {
aborted_signal: !fallback_result.exit_ok
&& fallback_result.session_id.is_none(),
exit_ok: fallback_result.exit_ok,
session_id: fallback_result.session_id,
token_usage: fallback_result.token_usage,
rate_limit_exit: fallback_result.rate_limit_exit,
rate_limit_reset_at: fallback_result.rate_limit_reset_at,
})
}
Err(e) => Err(e),
}
}
fn stop(&self) {
// Stopping is handled externally by the pool via kill_child_for_key().
}
fn get_status(&self) -> RuntimeStatus {
// Lifecycle status is tracked by the pool; the runtime itself is stateless.
RuntimeStatus::Idle
}
}