use crate::broadcast::{LagLimits, SubscriberSink, fan_out_evicting};
use crate::context::{LoadedSkill, SkillMeta};
use crate::daemon::DaemonCommand;
use crate::db::{self, SessionRecord, write_session_retry, write_turn_retry};
use crate::providers::InferenceProvider;
use crate::requests::run_agent_loop;
use crate::tools::{ToolOutput, ToolRegistry};
use choreo_ai_protocols::model_reasoning_capability;
use choreo_keystore::ServiceCredential;
use choreo_proto::{
AssistantToolCallRecord, ContextConfig, DaemonMessage, DisplayedImageRecord, ReasoningArtifact,
ReasoningProducer, SessionEvent, SessionStatus, SessionSummary, TimestampMs, TokenUsage,
ToolResultRecord, Turn,
};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::io;
use std::path::PathBuf;
use std::sync::atomic::AtomicUsize;
use std::sync::{Arc, mpsc};
use tracing::{debug, error, info, trace, warn};
use unicode_segmentation::UnicodeSegmentation;
pub(crate) const CANCEL_ALL: u32 = 0;
pub(crate) const MAX_TITLE_CHARS: usize = 200;
pub(crate) const SESSION_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(5);
pub(crate) fn join_session_shutdown(handle: std::thread::JoinHandle<()>, session_id: u64) -> bool {
poll_join_with_grace(
handle,
session_id,
SESSION_SHUTDOWN_GRACE,
std::time::Instant::now,
std::thread::sleep,
)
}
fn poll_join_with_grace<F, S>(
handle: std::thread::JoinHandle<()>,
session_id: u64,
grace: std::time::Duration,
now: F,
sleep: S,
) -> bool
where
F: FnMut() -> std::time::Instant,
S: FnMut(std::time::Duration),
{
let handle = std::cell::RefCell::new(Some(handle));
shutdown_join_poll(
session_id,
grace,
|| handle.borrow().as_ref().is_some_and(|h| h.is_finished()),
|| {
if let Some(h) = handle.borrow_mut().take() {
let _ = h.join();
}
},
now,
sleep,
)
}
fn shutdown_join_poll(
session_id: u64,
grace: std::time::Duration,
mut finished: impl FnMut() -> bool,
mut reap: impl FnMut(),
mut now: impl FnMut() -> std::time::Instant,
mut sleep: impl FnMut(std::time::Duration),
) -> bool {
let deadline = now() + grace;
loop {
if finished() {
reap();
return true;
}
let remaining = deadline.saturating_duration_since(now());
if remaining.is_zero() {
tracing::warn!(
session_id,
grace_ms = grace.as_millis(),
"session thread did not exit within shutdown grace period; abandoning join \
(process exit will reap the thread; completed turns are already persisted)",
);
return false;
}
sleep(remaining.min(std::time::Duration::from_millis(50)));
}
}
#[cfg(feature = "test-utils")]
#[doc(hidden)]
pub fn join_session_shutdown_with_grace_for_test(
handle: std::thread::JoinHandle<()>,
session_id: u64,
grace: std::time::Duration,
) -> bool {
poll_join_with_grace(
handle,
session_id,
grace,
std::time::Instant::now,
std::thread::sleep,
)
}
pub enum SessionCommand {
RunInput {
request_id: u32,
input: Vec<u8>,
},
RunChildInput {
request_id: u32,
user_text: Option<String>,
reply: std::sync::mpsc::Sender<io::Result<ChildResult>>,
},
Cancel {
request_id: u32,
},
SetModel {
model: String,
},
StatusChanged(SessionStatus),
Attach {
client_id: u64,
tx: SubscriberSink,
},
Detach {
client_id: u64,
},
RemoveSubscriber {
client_id: u64,
},
GetSummary {
reply: std::sync::mpsc::Sender<SessionSummary>,
},
RequestFinished {
request_id: u32,
snapshot: SessionSnapshot,
},
Broadcast(DaemonMessage),
SyncAccumulatedUsage {
token_usage: TokenUsage,
last_prompt_tokens: Option<u32>,
},
SetTitle {
title: String,
},
SetWorkingDir {
path: PathBuf,
reply: mpsc::Sender<Result<String, String>>,
},
LoadTools {
groups: Vec<String>,
reply: mpsc::Sender<Result<String, String>>,
},
UnloadTools {
groups: Vec<String>,
reply: mpsc::Sender<Result<String, String>>,
},
SetAccount {
name: String,
},
DropProvider,
SetReasoningEffort {
effort: String,
},
GetReasoningEffort {
reply: mpsc::Sender<String>,
},
Undo,
Redo,
Shutdown,
}
#[derive(Clone)]
pub struct RequestContext {
pub cmd_tx: mpsc::Sender<SessionCommand>,
pub session_id: u64,
pub db: Arc<redb::Database>,
pub tool_registry: Arc<ToolRegistry>,
pub daemon_tx: mpsc::Sender<DaemonCommand>,
pub max_turns: u32,
pub lag_limits: LagLimits,
pub global_lag: Arc<AtomicUsize>,
pub substrate_credential: Option<ServiceCredential>,
}
pub struct ChildResult {
pub output: String,
pub is_error: bool,
}
#[derive(Debug, Clone)]
pub struct SessionMetadata {
pub title: Option<String>,
pub selected_model: Option<String>,
pub reasoning_effort: Option<String>,
pub parent_session_id: Option<u64>,
pub working_dir: Option<String>,
pub created_at: i64,
pub last_modified: i64,
pub turn_count: u32,
pub status: SessionStatus,
pub active_tool_groups: Vec<String>,
pub account_name: Option<String>,
pub accumulated_usage: TokenUsage,
pub context_window: Option<u32>,
pub last_prompt_tokens: Option<u32>,
}
impl From<SessionRecord> for SessionMetadata {
fn from(record: SessionRecord) -> Self {
let config = SessionConfig {
title: record.title,
selected_model: record.selected_model,
reasoning_effort: record.reasoning_effort,
parent_session_id: record.parent_session_id,
working_dir: record.working_dir.map(PathBuf::from),
created_at: record.created_at,
last_modified: record.last_modified,
status: SessionStatus::Sleeping,
active_tool_groups: record.active_tool_groups.into_iter().collect(),
context_config: record.context_config,
account_name: record.account_name,
accumulated_usage: TokenUsage::default(),
context_window: None,
last_prompt_tokens: None,
last_response_id: record.last_response_id,
last_response_id_producer: record.last_response_id_producer,
};
let mut meta = SessionMetadata::from(&config);
meta.turn_count = record.turn_count;
meta
}
}
impl From<SessionMetadata> for SessionRecord {
fn from(meta: SessionMetadata) -> Self {
SessionRecord {
title: meta.title,
selected_model: meta.selected_model,
reasoning_effort: meta.reasoning_effort,
parent_session_id: meta.parent_session_id,
working_dir: meta.working_dir,
turn_count: meta.turn_count,
created_at: meta.created_at,
last_modified: meta.last_modified,
active_tool_groups: meta.active_tool_groups,
context_config: ContextConfig::default(),
account_name: meta.account_name,
last_response_id: None,
last_response_id_producer: None,
}
}
}
impl From<&SessionState> for SessionMetadata {
fn from(state: &SessionState) -> Self {
let mut meta = SessionMetadata::from(&state.config);
meta.turn_count = state.turns.len() as u32;
meta
}
}
impl SessionMetadata {
pub fn to_summary(&self, session_id: u64) -> SessionSummary {
SessionSummary {
session_id,
title: self.title.clone(),
selected_model: self.selected_model.clone(),
reasoning_effort: self.reasoning_effort.clone(),
parent_session_id: self.parent_session_id,
working_dir: self.working_dir.clone(),
created_at: self.created_at,
last_modified: self.last_modified,
turn_count: self.turn_count,
status: self.status.clone(),
active_tool_groups: self.active_tool_groups.clone(),
account_name: self.account_name.clone(),
token_usage: Some(self.accumulated_usage),
context_window: self.context_window,
last_prompt_tokens: self.last_prompt_tokens,
}
}
}
#[derive(Clone, Debug)]
pub struct SessionConfig {
pub title: Option<String>,
pub selected_model: Option<String>,
pub reasoning_effort: Option<String>,
pub parent_session_id: Option<u64>,
pub working_dir: Option<PathBuf>,
pub created_at: i64,
pub last_modified: i64,
pub status: SessionStatus,
pub active_tool_groups: HashSet<String>,
pub context_config: ContextConfig,
pub account_name: Option<String>,
pub accumulated_usage: TokenUsage,
pub context_window: Option<u32>,
pub last_prompt_tokens: Option<u32>,
pub last_response_id: Option<String>,
pub last_response_id_producer: Option<ReasoningProducer>,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
title: None,
selected_model: None,
reasoning_effort: None,
parent_session_id: None,
working_dir: None,
created_at: 0,
last_modified: 0,
status: SessionStatus::Inactive,
active_tool_groups: HashSet::new(),
context_config: ContextConfig::default(),
account_name: None,
accumulated_usage: TokenUsage::default(),
context_window: None,
last_prompt_tokens: None,
last_response_id: None,
last_response_id_producer: None,
}
}
}
impl SessionConfig {
fn apply_worker_snapshot(&mut self, snapshot: &SessionConfig) {
self.accumulated_usage = snapshot.accumulated_usage;
self.context_window = snapshot.context_window;
self.last_prompt_tokens = snapshot.last_prompt_tokens;
self.last_response_id = snapshot.last_response_id.clone();
self.last_response_id_producer = snapshot.last_response_id_producer.clone();
}
}
impl From<&SessionConfig> for SessionMetadata {
fn from(config: &SessionConfig) -> Self {
SessionMetadata {
title: config.title.clone(),
selected_model: config.selected_model.clone(),
reasoning_effort: config.reasoning_effort.clone(),
parent_session_id: config.parent_session_id,
working_dir: config.working_dir.as_ref().map(|p| p.display().to_string()),
created_at: config.created_at,
last_modified: config.last_modified,
turn_count: 0,
status: config.status.clone(),
active_tool_groups: config.active_tool_groups.iter().cloned().collect(),
account_name: config.account_name.clone(),
accumulated_usage: config.accumulated_usage,
context_window: config.context_window,
last_prompt_tokens: config.last_prompt_tokens,
}
}
}
impl From<&SessionState> for SessionRecord {
fn from(state: &SessionState) -> Self {
let meta: SessionMetadata = state.into();
let mut record: SessionRecord = meta.into();
record.context_config = state.config.context_config.clone();
record.last_response_id = state.config.last_response_id.clone();
record.last_response_id_producer = state.config.last_response_id_producer.clone();
record
}
}
#[derive(Clone)]
pub struct SessionSnapshot {
pub config: SessionConfig,
pub turns: BTreeMap<u32, Turn>,
pub loaded_skill_bodies: Vec<LoadedSkill>,
pub context_cache: Option<(u64, Arc<String>)>,
pub discovered_skills: Option<Vec<SkillMeta>>,
}
pub(crate) struct ActiveRequest {
pub(crate) cancel_tx: crossbeam_channel::Sender<()>,
pub(crate) turn_id: u32,
}
pub struct ActiveSessionEntry {
pub cmd_tx: mpsc::Sender<SessionCommand>,
pub handle: std::thread::JoinHandle<()>,
}
pub struct SessionState {
pub config: SessionConfig,
pub next_turn_id: u32,
last_undo_turn_ids: Option<Vec<u32>>,
pub turns: BTreeMap<u32, Turn>,
subscribers: HashMap<u64, SubscriberSink>,
pub(crate) active_requests: BTreeMap<u32, ActiveRequest>,
pub provider: Option<InferenceProvider>,
pub registry: choreo_ai_protocols::SocketRegistry,
pub loaded_skill_bodies: Vec<LoadedSkill>,
pub context_cache: Option<(u64, Arc<String>)>,
pub discovered_skills: Option<Vec<SkillMeta>>,
}
#[derive(Debug, Clone, Default)]
pub struct AssistantResponse {
pub text: Option<String>,
pub reasoning: Option<String>,
pub tool_calls: Vec<AssistantToolCallRecord>,
pub token_usage: Option<TokenUsage>,
pub reasoning_artifact: Option<ReasoningArtifact>,
pub reasoning_producer: Option<ReasoningProducer>,
}
impl SessionState {
fn resolve_context_window_if_missing(&mut self, ctx: &RequestContext) {
if self.config.context_window.is_some() {
return;
}
let (Some(model), Some(provider)) = (&self.config.selected_model, &self.provider) else {
return;
};
if let Some(cw) = provider.resolve_context_window(model) {
debug!(
"session {}: re-resolved context_window={} for model={}",
ctx.session_id, cw, model
);
self.config.context_window = Some(cw);
broadcast(
&mut self.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ContextWindowResolved { context_window: cw },
},
);
}
}
fn snapshot(&self) -> SessionSnapshot {
SessionSnapshot {
config: self.config.clone(),
turns: self.turns.clone(),
loaded_skill_bodies: self.loaded_skill_bodies.clone(),
context_cache: self.context_cache.clone(),
discovered_skills: self.discovered_skills.clone(),
}
}
fn from_snapshot(snapshot: SessionSnapshot, subscribers: HashMap<u64, SubscriberSink>) -> Self {
let turn_count = snapshot.turns.len() as u32;
Self {
config: snapshot.config,
next_turn_id: turn_count,
last_undo_turn_ids: None,
turns: snapshot.turns,
subscribers,
active_requests: BTreeMap::new(),
provider: None,
registry: choreo_ai_protocols::SocketRegistry::default(),
loaded_skill_bodies: snapshot.loaded_skill_bodies,
context_cache: snapshot.context_cache,
discovered_skills: snapshot.discovered_skills,
}
}
pub(crate) fn session_state_message(&self, session_id: u64) -> DaemonMessage {
let reasoning_capability = self.config.selected_model.as_ref().and_then(|model| {
let slug = self.provider.as_ref()?.provider_slug();
Some(model_reasoning_capability(slug, model))
});
DaemonMessage::Session {
session_id: Some(session_id),
event: SessionEvent::SessionState {
title: self.config.title.clone(),
selected_model: self.config.selected_model.clone(),
parent_session_id: self.config.parent_session_id,
working_dir: self
.config
.working_dir
.as_ref()
.map(|p| p.display().to_string()),
turns: self
.turns
.iter()
.map(|(&turn_id, turn)| (turn_id, turn_for_client(turn)))
.collect(),
active_tool_groups: self.config.active_tool_groups.iter().cloned().collect(),
token_usage: Some(self.config.accumulated_usage),
context_window: self.config.context_window,
last_prompt_tokens: self.config.last_prompt_tokens,
status: self.config.status.clone(),
reasoning_effort: self.config.reasoning_effort.clone(),
reasoning_capability,
},
}
}
pub fn start_turn(&mut self, user_text: Option<String>) -> (u32, Turn) {
if user_text.is_some() {
self.last_undo_turn_ids = None;
}
let turn_id = self.next_turn_id;
self.next_turn_id += 1;
let turn = Turn {
created_at: TimestampMs::now(),
undone: false,
error: None,
user_text,
assistant_text: None,
assistant_reasoning: None,
tool_calls: Vec::new(),
token_usage: None,
tool_results: Vec::new(),
displayed_images: Vec::new(),
reasoning_artifact: None,
reasoning_producer: None,
};
self.turns.insert(turn_id, turn.clone());
(turn_id, turn)
}
pub fn set_assistant_response(&mut self, turn_id: u32, response: AssistantResponse) {
if let Some(turn) = self.turns.get_mut(&turn_id) {
turn.assistant_text = response.text;
turn.assistant_reasoning = response.reasoning;
turn.tool_calls = response.tool_calls;
turn.token_usage = response.token_usage;
turn.reasoning_artifact = response.reasoning_artifact;
turn.reasoning_producer = response.reasoning_producer;
}
}
pub fn seed_tool_results(
&mut self,
turn_id: u32,
tool_calls: &[AssistantToolCallRecord],
invocation_descriptions: &[String],
) {
if let Some(turn) = self.turns.get_mut(&turn_id) {
turn.tool_results = tool_calls
.iter()
.zip(invocation_descriptions.iter())
.map(|(tc, desc)| ToolResultRecord {
call_id: tc.call_id.clone(),
name: tc.name.clone(),
content: String::new(),
is_error: false,
invocation_description: desc.clone(),
image: None,
})
.collect();
}
}
pub fn update_tool_result(
&mut self,
turn_id: u32,
call_id: &str,
name: String,
output: &ToolOutput,
) {
if let Some(turn) = self.turns.get_mut(&turn_id)
&& let Some(record) = turn.tool_results.iter_mut().find(|r| r.call_id == call_id)
{
record.name = name;
record.content = output.content.clone();
record.is_error = output.is_error;
record.invocation_description = output.invocation_description.clone();
record.image = output.image_ref.clone();
}
}
pub fn mark_unexecuted_tool_results(&mut self, turn_id: u32, executed: &HashSet<String>) {
if let Some(turn) = self.turns.get_mut(&turn_id) {
for record in &mut turn.tool_results {
if !executed.contains(&record.call_id) {
record.content = "[cancelled — result not recorded]".to_string();
record.is_error = true;
}
}
}
}
pub fn add_displayed_image(&mut self, turn_id: u32, record: DisplayedImageRecord) {
if let Some(turn) = self.turns.get_mut(&turn_id) {
turn.displayed_images.push(record);
}
}
pub fn set_turn_error(&mut self, turn_id: u32, error: String) {
if let Some(turn) = self.turns.get_mut(&turn_id) {
turn.error = Some(error);
}
}
pub fn finalize_turn(
&mut self,
db: &redb::Database,
session_id: u64,
turn_id: u32,
) -> io::Result<()> {
if let Some(turn) = self.turns.get(&turn_id) {
write_turn_retry(db, session_id, turn_id, turn)
.map_err(|e| io::Error::other(format!("failed to persist turn {turn_id}: {e}")))?;
}
Ok(())
}
pub fn undo_turns(&mut self) -> Option<Vec<u32>> {
let target = self
.turns
.iter()
.rev()
.find(|(_, t)| !t.undone && t.user_text.is_some())
.map(|(&id, _)| id)?;
let to_undo: Vec<u32> = self.turns.range(target..).map(|(&id, _)| id).collect();
for &id in &to_undo {
if let Some(turn) = self.turns.get_mut(&id) {
turn.undone = true;
}
}
self.last_undo_turn_ids = Some(to_undo.clone());
Some(to_undo)
}
pub fn redo_turns(&mut self) -> Option<BTreeMap<u32, Turn>> {
let ids = self.last_undo_turn_ids.take()?;
let mut restored = BTreeMap::new();
for &id in &ids {
if let Some(turn) = self.turns.get_mut(&id) {
turn.undone = false;
restored.insert(id, turn.clone());
}
}
Some(restored)
}
pub fn empty() -> Self {
Self {
config: SessionConfig::default(),
next_turn_id: 0,
last_undo_turn_ids: None,
turns: BTreeMap::new(),
subscribers: HashMap::new(),
active_requests: BTreeMap::new(),
provider: None,
registry: choreo_ai_protocols::SocketRegistry::default(),
loaded_skill_bodies: Vec::new(),
context_cache: None,
discovered_skills: None,
}
}
pub(crate) fn resolve_provider(
&mut self,
ctx: &RequestContext,
) -> Result<InferenceProvider, String> {
if let Some(p) = &self.provider {
return Ok(p.clone());
}
let Some(name) = self.config.account_name.clone() else {
return Err(
"no account configured on this session — use /account <name> to set one"
.to_string(),
);
};
let (reply, rx) = crossbeam_channel::unbounded();
let _ = ctx.daemon_tx.send(DaemonCommand::ResolveAccountCmd {
account: name.clone(),
reply,
});
let Some((config, api_key)) = rx.recv().ok().flatten() else {
return Err(format!(
"no credential stored for account '{name}' — add one via the AI Providers page or /add-key"
));
};
let Some(api_key) = api_key else {
return Err(format!(
"no credential stored for account '{name}' — add one via the AI Providers page or /add-key"
));
};
let provider = InferenceProvider::from_account_config(
&config,
Some((*api_key).clone()),
&self.registry,
)
.map_err(|e| {
tracing::warn!(
session = ctx.session_id,
account = %name,
error = %e,
"failed to build provider client for session"
);
format!(
"no credential stored for account '{name}' — add one via the AI Providers page or /add-key"
)
})?;
debug!(session = ctx.session_id, account = %name, "resolved session provider lazily");
self.provider = Some(provider.clone());
Ok(provider)
}
}
pub(crate) fn turn_for_client(turn: &Turn) -> Turn {
let mut clone = turn.clone();
clone.reasoning_artifact = None;
clone.reasoning_producer = None;
for record in clone.tool_results.iter_mut() {
record.image = None;
}
clone
}
fn broadcast(
subscribers: &mut HashMap<u64, SubscriberSink>,
ctx: &RequestContext,
message: DaemonMessage,
) {
let _ = ctx.daemon_tx.send(DaemonCommand::BroadcastActivity {
session_id: Some(ctx.session_id),
msg: message.clone(),
});
let (evict_clients, evict_largest) = fan_out_evicting(
subscribers,
&message,
&ctx.lag_limits,
&ctx.global_lag,
|_| false, );
for client_id in evict_clients {
let _ = ctx.daemon_tx.send(DaemonCommand::EvictClient { client_id });
}
if evict_largest {
let _ = ctx.daemon_tx.send(DaemonCommand::EvictLargestLagging);
}
}
fn fail_request(
subscribers: &mut HashMap<u64, SubscriberSink>,
ctx: &RequestContext,
session_id: u64,
request_id: u32,
error: impl Into<String>,
) -> bool {
broadcast(
subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(session_id),
event: SessionEvent::Started {
request_id,
turn_id: 0,
estimated_prompt_tokens: 0,
},
},
);
broadcast(
subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(session_id),
event: SessionEvent::Failed {
request_id,
error: error.into(),
},
},
);
false
}
fn persist_session_metadata(state: &mut SessionState, ctx: &RequestContext, label: &str) {
let now = TimestampMs::now().as_millis();
state.config.last_modified = state.config.last_modified.max(now);
let _ = ctx.daemon_tx.send(DaemonCommand::UpdateMetadata {
session_id: ctx.session_id,
metadata: SessionMetadata::from(&*state),
});
let record = SessionRecord::from(&*state);
if let Err(e) = write_session_retry(&ctx.db, ctx.session_id, &record) {
warn!(error = %e, "failed to persist session record after {label}");
}
}
fn default_active_tool_groups() -> HashSet<String> {
#[cfg_attr(not(feature = "content"), allow(unused_mut))]
let mut groups = HashSet::from(["core".to_string(), "git".to_string(), "shell".to_string()]);
#[cfg(feature = "content")]
groups.insert("content".to_string());
#[cfg(target_os = "ios")]
groups.insert("ios".to_string());
groups
}
pub fn session_main(
rx: std::sync::mpsc::Receiver<SessionCommand>,
initial_provider: Option<InferenceProvider>,
registry: choreo_ai_protocols::SocketRegistry,
account_name: Option<String>,
init_record: Option<SessionRecord>,
ctx: RequestContext,
) {
let config = SessionConfig {
title: init_record.as_ref().and_then(|r| r.title.clone()),
selected_model: init_record.as_ref().and_then(|r| r.selected_model.clone()),
reasoning_effort: init_record
.as_ref()
.and_then(|r| r.reasoning_effort.clone()),
parent_session_id: init_record.as_ref().and_then(|r| r.parent_session_id),
working_dir: init_record
.as_ref()
.and_then(|r| r.working_dir.as_ref().map(PathBuf::from)),
created_at: init_record
.as_ref()
.map(|r| r.created_at)
.unwrap_or_else(|| TimestampMs::now().as_millis()),
last_modified: init_record
.as_ref()
.map(|r| r.last_modified)
.unwrap_or_else(|| TimestampMs::now().as_millis()),
status: SessionStatus::Inactive,
active_tool_groups: init_record
.as_ref()
.map(|r| r.active_tool_groups.iter().cloned().collect())
.filter(|cats: &HashSet<String>| !cats.is_empty())
.unwrap_or_else(default_active_tool_groups),
context_config: init_record
.as_ref()
.map(|r| r.context_config.clone())
.unwrap_or_default(),
account_name,
accumulated_usage: TokenUsage::default(),
context_window: None,
last_prompt_tokens: None,
last_response_id: init_record
.as_ref()
.and_then(|r| r.last_response_id.clone()),
last_response_id_producer: init_record
.as_ref()
.and_then(|r| r.last_response_id_producer.clone()),
};
let mut state = SessionState {
config,
provider: initial_provider,
registry,
..SessionState::empty()
};
state.resolve_context_window_if_missing(&ctx);
match db::read_turns(&ctx.db, ctx.session_id) {
Ok(turns) => {
for (turn_id, turn) in turns {
state.turns.insert(turn_id, turn);
state.next_turn_id = state.next_turn_id.max(turn_id + 1);
}
let mut accumulated_usage = TokenUsage::default();
let mut last_prompt_tokens = None;
for turn in state.turns.values() {
if let Some(u) = turn.token_usage {
accumulated_usage.input_tokens += u.input_tokens;
accumulated_usage.output_tokens += u.output_tokens;
accumulated_usage.total_tokens += u.total_tokens;
last_prompt_tokens = Some(u.input_tokens);
}
}
state.config.accumulated_usage = accumulated_usage;
state.config.last_prompt_tokens = last_prompt_tokens;
trace!(
last_prompt_tokens,
?accumulated_usage,
"reconstructed token state from turns after daemon restart"
);
}
Err(e) => warn!(ctx.session_id, error = %e, "failed to load turns from DB"),
}
let _ = ctx.daemon_tx.send(DaemonCommand::UpdateMetadata {
session_id: ctx.session_id,
metadata: SessionMetadata::from(&state),
});
info!("session {} started", ctx.session_id);
let mut shutdown_requested = false;
while let Ok(cmd) = rx.recv() {
if process_command(cmd, &mut state, &mut shutdown_requested, &ctx) {
break;
}
}
info!("session {} exiting", ctx.session_id);
persist_and_exit(&state, &ctx.db, ctx.session_id, &ctx.daemon_tx);
}
fn process_command(
cmd: SessionCommand,
state: &mut SessionState,
shutdown_requested: &mut bool,
ctx: &RequestContext,
) -> bool {
match cmd {
SessionCommand::RunInput { request_id, input } => {
handle_run_input(request_id, input, state, shutdown_requested, ctx)
}
SessionCommand::RunChildInput {
request_id,
user_text,
reply,
} => handle_run_child_input(request_id, user_text, reply, state, shutdown_requested, ctx),
SessionCommand::Cancel { request_id } => handle_cancel(request_id, state, ctx),
SessionCommand::SetModel { model } => handle_set_model(model, state, ctx),
SessionCommand::StatusChanged(new_status) => handle_status_changed(new_status, state, ctx),
SessionCommand::Attach { client_id, tx } => handle_attach(client_id, tx, state, ctx),
SessionCommand::Detach { client_id } => {
handle_detach(client_id, state, shutdown_requested, ctx)
}
SessionCommand::RemoveSubscriber { client_id } => {
handle_remove_subscriber(client_id, state, shutdown_requested, ctx)
}
SessionCommand::GetSummary { reply } => handle_get_summary(reply, state, ctx),
SessionCommand::RequestFinished {
request_id,
snapshot,
} => handle_request_finished(request_id, snapshot, state, shutdown_requested, ctx),
SessionCommand::Broadcast(message) => handle_broadcast(message, state, ctx),
SessionCommand::SyncAccumulatedUsage {
token_usage,
last_prompt_tokens,
} => handle_sync_accumulated_usage(token_usage, last_prompt_tokens, state, ctx),
SessionCommand::SetTitle { title } => handle_set_title(title, state, ctx),
SessionCommand::SetWorkingDir { path, reply } => {
handle_set_working_dir(path, reply, state, ctx)
}
SessionCommand::LoadTools { groups, reply } => handle_load_tools(groups, reply, state, ctx),
SessionCommand::UnloadTools { groups, reply } => {
handle_unload_tools(groups, reply, state, ctx)
}
SessionCommand::SetAccount { name } => handle_set_account(name, state, ctx),
SessionCommand::DropProvider => {
if state.provider.take().is_some() {
info!(
session = ctx.session_id,
"dropped cached provider client; it will be rebuilt on the next request"
);
}
false
}
SessionCommand::SetReasoningEffort { effort } => {
handle_set_reasoning_effort(effort, state, ctx)
}
SessionCommand::GetReasoningEffort { reply } => {
handle_get_reasoning_effort(reply, state, ctx)
}
SessionCommand::Undo => handle_undo(state, ctx),
SessionCommand::Redo => handle_redo(state, ctx),
SessionCommand::Shutdown => handle_shutdown(state, shutdown_requested, ctx),
}
}
fn handle_run_input(
request_id: u32,
input: Vec<u8>,
state: &mut SessionState,
shutdown_requested: &mut bool,
ctx: &RequestContext,
) -> bool {
debug!("session {}: RunInput id={}", ctx.session_id, request_id);
let text = String::from_utf8_lossy(&input).trim().to_string();
info!(
session_id = ctx.session_id,
input_len = text.len(),
input_preview = %text.chars().take(120).collect::<String>(),
"session received input",
);
if text.is_empty() {
return fail_request(
&mut state.subscribers,
ctx,
ctx.session_id,
request_id,
"empty input",
);
}
let provider = match state.resolve_provider(ctx) {
Ok(p) => p,
Err(msg) => {
return fail_request(&mut state.subscribers, ctx, ctx.session_id, request_id, msg);
}
};
state.resolve_context_window_if_missing(ctx);
let model = match &state.config.selected_model {
Some(m) => m.clone(),
None => {
return fail_request(
&mut state.subscribers,
ctx,
ctx.session_id,
request_id,
"no model selected",
);
}
};
if *shutdown_requested {
return fail_request(
&mut state.subscribers,
ctx,
ctx.session_id,
request_id,
"session is shutting down",
);
}
if !state.active_requests.is_empty() {
return fail_request(
&mut state.subscribers,
ctx,
ctx.session_id,
request_id,
"session already has an active request",
);
}
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::Started {
request_id,
turn_id: state.next_turn_id,
estimated_prompt_tokens: 0,
},
},
);
let (cancel_tx, cancel_rx) = crossbeam_channel::unbounded::<()>();
state.active_requests.insert(
request_id,
ActiveRequest {
cancel_tx,
turn_id: state.next_turn_id,
},
);
let mut worker_session = SessionState::from_snapshot(state.snapshot(), HashMap::new());
let ctx = ctx.clone();
let user_text = Some(text);
std::thread::spawn(move || {
let _ = run_request_worker(
request_id,
provider,
&mut worker_session,
model,
cancel_rx,
ctx,
None,
user_text,
);
});
false
}
fn handle_run_child_input(
request_id: u32,
user_text: Option<String>,
reply: std::sync::mpsc::Sender<io::Result<ChildResult>>,
state: &mut SessionState,
shutdown_requested: &mut bool,
ctx: &RequestContext,
) -> bool {
let provider = match state.resolve_provider(ctx) {
Ok(p) => p,
Err(_) => {
let _ = reply.send(Err(io::Error::other("daemon locked")));
return false;
}
};
let model = state.config.selected_model.clone().unwrap_or_default();
if *shutdown_requested {
let _ = reply.send(Err(io::Error::other("session is shutting down")));
return false;
}
if !state.active_requests.is_empty() {
let _ = reply.send(Err(io::Error::other(
"session already has an active request",
)));
return false;
}
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::Started {
request_id,
turn_id: state.next_turn_id,
estimated_prompt_tokens: 0,
},
},
);
let (cancel_tx, cancel_rx) = crossbeam_channel::unbounded::<()>();
state.active_requests.insert(
request_id,
ActiveRequest {
cancel_tx,
turn_id: state.next_turn_id,
},
);
let mut worker_session = SessionState::from_snapshot(state.snapshot(), HashMap::new());
let ctx = ctx.clone();
let provider = provider.clone();
std::thread::spawn(move || {
let result = run_request_worker(
request_id,
provider,
&mut worker_session,
model,
cancel_rx,
ctx,
Some(reply),
user_text,
);
let _ = result;
});
false
}
fn handle_cancel(request_id: u32, state: &mut SessionState, ctx: &RequestContext) -> bool {
let targets: Vec<u32> = if request_id == 0 {
state.active_requests.keys().copied().collect()
} else {
vec![request_id]
};
for rid in targets {
if let Some(active) = state.active_requests.get(&rid) {
let _ = active.cancel_tx.send(());
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::Cancelled { request_id: rid },
},
);
}
}
false
}
fn handle_set_model(model: String, state: &mut SessionState, ctx: &RequestContext) -> bool {
info!("session {}: SetModel model={}", ctx.session_id, model);
if let Err(msg) = validate_model_via_daemon(&model, ctx) {
warn!(
"session {}: model '{model}' rejected: {msg}",
ctx.session_id
);
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ModelSelectionFailed { model, error: msg },
},
);
return false;
}
state.config.selected_model = Some(model.clone());
let cw = state
.provider
.as_ref()
.and_then(|p| p.resolve_context_window(&model));
debug!(
"session {}: resolved context_window={:?} for model={}",
ctx.session_id, cw, model
);
state.config.context_window = cw;
if let Some(cw) = cw {
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ContextWindowResolved { context_window: cw },
},
);
}
let capability = state
.provider
.as_ref()
.map(|p| model_reasoning_capability(p.provider_slug(), &model));
if let Some(ref cap) = capability
&& let Some(ref effort) = state.config.reasoning_effort
&& effort != "off"
&& !cap.available_effort_levels.iter().any(|l| l == effort)
{
warn!(
session_id = ctx.session_id,
old_effort = %effort,
"reasoning effort not supported by new model, resetting to 'off'",
);
state.config.reasoning_effort = Some("off".to_string());
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ReasoningEffortSet {
effort: "off".to_string(),
},
},
);
}
debug!(
"session {}: broadcasting ModelSelected model={}",
ctx.session_id, model
);
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ModelSelected {
model: model.clone(),
reasoning_capability: capability,
},
},
);
persist_session_metadata(state, ctx, "SetModel");
false
}
fn validate_model_via_daemon(model: &str, ctx: &RequestContext) -> Result<(), String> {
let (reply, rx) = mpsc::channel();
if ctx
.daemon_tx
.send(DaemonCommand::ValidateModel {
session_id: ctx.session_id,
model: model.to_string(),
reply,
})
.is_err()
{
warn!(
"session {}: daemon disconnected during model validation for '{model}'",
ctx.session_id
);
return Ok(());
}
match rx.recv() {
Ok(Ok(())) => Ok(()),
Ok(Err(msg)) => Err(msg),
Err(_) => {
warn!(
"session {}: daemon disconnected while waiting for model validation \
of '{model}', allowing through",
ctx.session_id
);
Ok(())
}
}
}
fn handle_status_changed(
new_status: SessionStatus,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
state.config.status = new_status.clone();
let last_modified = state.config.last_modified;
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::SessionStatusChanged {
status: new_status.clone(),
last_modified,
},
},
);
let _ = ctx.daemon_tx.send(DaemonCommand::BroadcastSessionStatus {
session_id: ctx.session_id,
status: new_status,
});
false
}
fn handle_attach(
client_id: u64,
tx: SubscriberSink,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
info!("session {}: client {} attached", ctx.session_id, client_id);
state.subscribers.insert(client_id, tx);
let _ = ctx.daemon_tx.send(DaemonCommand::TrackSessionSubscription {
client_id,
session_id: ctx.session_id,
});
if !state.active_requests.is_empty()
&& let Some(tx) = state.subscribers.get(&client_id)
{
for (&request_id, active) in &state.active_requests {
tx.send_unchecked(
&DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::Started {
request_id,
turn_id: active.turn_id,
estimated_prompt_tokens: 0,
},
},
&ctx.global_lag,
);
}
}
let snapshot = state.session_state_message(ctx.session_id);
if let Some(tx) = state.subscribers.get(&client_id) {
tx.send_unchecked(&snapshot, &ctx.global_lag);
}
false
}
fn handle_detach(
client_id: u64,
state: &mut SessionState,
shutdown_requested: &bool,
ctx: &RequestContext,
) -> bool {
info!("session {}: client {} detached", ctx.session_id, client_id);
state.subscribers.remove(&client_id);
let _ = ctx
.daemon_tx
.send(DaemonCommand::UntrackSessionSubscription {
client_id,
session_id: ctx.session_id,
});
state.active_requests.is_empty() && (state.subscribers.is_empty() || *shutdown_requested)
}
fn handle_remove_subscriber(
client_id: u64,
state: &mut SessionState,
shutdown_requested: &bool,
ctx: &RequestContext,
) -> bool {
debug!(
"session {}: removing subscriber {}",
ctx.session_id, client_id
);
state.subscribers.remove(&client_id);
state.active_requests.is_empty() && (state.subscribers.is_empty() || *shutdown_requested)
}
fn handle_get_summary(
reply: std::sync::mpsc::Sender<SessionSummary>,
state: &SessionState,
ctx: &RequestContext,
) -> bool {
let _ = reply.send(SessionSummary {
session_id: ctx.session_id,
title: state.config.title.clone(),
selected_model: state.config.selected_model.clone(),
reasoning_effort: state.config.reasoning_effort.clone(),
parent_session_id: state.config.parent_session_id,
working_dir: state
.config
.working_dir
.as_ref()
.map(|p| p.display().to_string()),
created_at: state.config.created_at,
last_modified: state.config.last_modified,
turn_count: state.turns.len() as u32,
status: state.config.status.clone(),
active_tool_groups: state.config.active_tool_groups.iter().cloned().collect(),
account_name: state.config.account_name.clone(),
token_usage: Some(state.config.accumulated_usage),
context_window: state.config.context_window,
last_prompt_tokens: state.config.last_prompt_tokens,
});
false
}
fn handle_request_finished(
request_id: u32,
mut snapshot: SessionSnapshot,
state: &mut SessionState,
shutdown_requested: &bool,
ctx: &RequestContext,
) -> bool {
let undo_during_request = snapshot.turns.iter().any(|(&turn_id, snap_turn)| {
state
.turns
.get(&turn_id)
.is_some_and(|state_turn| state_turn.undone && !snap_turn.undone)
});
if undo_during_request {
debug!(
session_id = ctx.session_id,
request_id,
"undo landed while request was in flight; dropping stale response-id chain from worker snapshot",
);
snapshot.config.last_response_id = None;
snapshot.config.last_response_id_producer = None;
}
state.config.apply_worker_snapshot(&snapshot.config);
let last_modified = TimestampMs::now().as_millis();
state.config.last_modified = state.config.last_modified.max(last_modified);
let record = SessionRecord::from(&*state);
if let Err(e) = write_session_retry(&ctx.db, ctx.session_id, &record) {
warn!(error = %e, "failed to persist session config after request");
}
state.loaded_skill_bodies = snapshot.loaded_skill_bodies;
state.context_cache = snapshot.context_cache;
state.discovered_skills = snapshot.discovered_skills;
for (&turn_id, turn) in &snapshot.turns {
let is_new = !state.turns.contains_key(&turn_id);
if !is_new
&& let Some(state_turn) = state.turns.get(&turn_id)
&& state_turn.undone
&& !turn.undone
{
continue;
}
state.turns.insert(turn_id, turn.clone());
if is_new {
if let Err(e) = write_turn_retry(&ctx.db, ctx.session_id, turn_id, turn) {
tracing::warn!(turn_id, error = %e, "failed to persist turn");
}
} else if state.turns.get(&turn_id).is_some_and(|t| t != turn) {
if let Err(e) = write_turn_retry(&ctx.db, ctx.session_id, turn_id, turn) {
tracing::warn!(turn_id, error = %e, "failed to persist updated turn");
}
}
}
if let Some(max_id) = snapshot.turns.keys().max() {
state.next_turn_id = state.next_turn_id.max(max_id + 1);
}
state.active_requests.remove(&request_id);
state.config.status = SessionStatus::Inactive;
let _ = ctx.daemon_tx.send(DaemonCommand::UpdateMetadata {
session_id: ctx.session_id,
metadata: SessionMetadata::from(&*state),
});
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::SessionStatusChanged {
status: SessionStatus::Inactive,
last_modified,
},
},
);
let _ = ctx.daemon_tx.send(DaemonCommand::BroadcastSessionStatus {
session_id: ctx.session_id,
status: SessionStatus::Inactive,
});
state.active_requests.is_empty() && (state.subscribers.is_empty() || *shutdown_requested)
}
fn handle_broadcast(
message: DaemonMessage,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
broadcast(&mut state.subscribers, ctx, message);
false
}
fn handle_sync_accumulated_usage(
token_usage: TokenUsage,
last_prompt_tokens: Option<u32>,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
state.config.accumulated_usage.merge_max(token_usage);
if let Some(tokens) = last_prompt_tokens {
state.config.last_prompt_tokens = Some(tokens);
}
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::TokenUsageUpdate {
token_usage: state.config.accumulated_usage,
last_prompt_tokens: state.config.last_prompt_tokens,
},
},
);
let _ = ctx.daemon_tx.send(DaemonCommand::UpdateMetadata {
session_id: ctx.session_id,
metadata: SessionMetadata::from(&*state),
});
false
}
fn handle_set_title(title: String, state: &mut SessionState, ctx: &RequestContext) -> bool {
if title.graphemes(true).count() > MAX_TITLE_CHARS {
warn!(
session_id = ctx.session_id,
length = title.graphemes(true).count(),
max = MAX_TITLE_CHARS,
"rejecting SetTitle: title too long (defense-in-depth)",
);
return false;
}
info!(
session_id = ctx.session_id,
old_title = ?state.config.title,
new_title = %title,
"session title changed",
);
state.config.title = Some(title.clone());
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::SessionTitleSet {
title: title.clone(),
},
},
);
persist_session_metadata(state, ctx, "SetTitle");
false
}
fn handle_set_working_dir(
path: PathBuf,
reply: mpsc::Sender<Result<String, String>>,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
info!(
session_id = ctx.session_id,
old_path = ?state.config.working_dir,
new_path = %path.display(),
"session working directory changed",
);
state.config.working_dir = Some(path.clone());
state.discovered_skills = None;
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::SessionWorkingDirSet {
path: Some(path.to_string_lossy().into_owned()),
},
},
);
persist_session_metadata(state, ctx, "SetWorkingDir");
let _ = reply.send(Ok(path.to_string_lossy().into_owned()));
false
}
fn handle_load_tools(
groups: Vec<String>,
reply: mpsc::Sender<Result<String, String>>,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
info!(session_id = ctx.session_id, groups = ?groups, "session load_tools");
let known = ctx.tool_registry.known_group_names();
if let Some(unknown) = crate::tools::unknown_group_names(&groups, &known) {
let _ = reply.send(Err(format!(
"Unknown tool group(s): {}",
unknown.join(", ")
)));
return false;
}
let result =
crate::tools::load_tools::apply_load_tools(&mut state.config.active_tool_groups, &groups);
let session_state = state.session_state_message(ctx.session_id);
broadcast(&mut state.subscribers, ctx, session_state);
persist_session_metadata(state, ctx, "LoadTools");
let _ = reply.send(Ok(result));
false
}
fn handle_unload_tools(
groups: Vec<String>,
reply: mpsc::Sender<Result<String, String>>,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
info!(session_id = ctx.session_id, groups = ?groups, "session unload_tools");
let known = ctx.tool_registry.known_group_names();
if let Some(unknown) = crate::tools::unknown_group_names(&groups, &known) {
let _ = reply.send(Err(format!(
"Unknown tool group(s): {}",
unknown.join(", ")
)));
return false;
}
let result = crate::tools::unload_tools::apply_unload_tools(
&mut state.config.active_tool_groups,
&groups,
ctx.tool_registry.protected_groups(),
);
let session_state = state.session_state_message(ctx.session_id);
broadcast(&mut state.subscribers, ctx, session_state);
persist_session_metadata(state, ctx, "UnloadTools");
let _ = reply.send(Ok(result));
false
}
fn handle_set_account(name: String, state: &mut SessionState, ctx: &RequestContext) -> bool {
info!("session {}: SetAccount account={}", ctx.session_id, name);
let (reply, rx) = crossbeam_channel::unbounded();
let _ = ctx.daemon_tx.send(DaemonCommand::ResolveAccountCmd {
account: name.clone(),
reply,
});
if let Ok(Some((config, Some(api_key)))) = rx.recv()
&& let Ok(provider) = InferenceProvider::from_account_config(
&config,
Some((*api_key).clone()),
&state.registry,
)
{
if let Some(ref model) = state.config.selected_model {
let cw = provider.resolve_context_window(model);
debug!(
"session {}: re-resolved context_window={:?} after account change for model={}",
ctx.session_id, cw, model
);
state.config.context_window = cw;
if let Some(cw) = cw {
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ContextWindowResolved { context_window: cw },
},
);
}
}
state.provider = Some(provider);
}
state.config.account_name = Some(name.clone());
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::SessionAccountSet { account: name },
},
);
persist_session_metadata(state, ctx, "SetAccount");
false
}
fn handle_set_reasoning_effort(
effort: String,
state: &mut SessionState,
ctx: &RequestContext,
) -> bool {
if effort.len() > 64 {
let msg = format!("reasoning effort slug too long ({} bytes)", effort.len());
warn!(session_id = ctx.session_id, error = %msg, "reasoning effort rejected");
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ReasoningEffortSetFailed { effort, error: msg },
},
);
return false;
}
let capability = state.config.selected_model.as_ref().and_then(|model| {
let slug = state.provider.as_ref()?.provider_slug();
Some(model_reasoning_capability(slug, model))
});
let valid = effort == "off"
|| capability
.as_ref()
.map(|c| c.available_effort_levels.contains(&effort))
.unwrap_or(false)
|| capability.is_none();
if valid {
state.config.reasoning_effort = Some(effort.clone());
info!(
session_id = ctx.session_id,
effort = %effort,
model = ?state.config.selected_model,
"reasoning effort set",
);
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ReasoningEffortSet { effort },
},
);
return false;
} else {
let model = state.config.selected_model.as_deref().unwrap_or("(none)");
let msg = format!("model '{model}' does not support reasoning effort '{effort}'");
warn!(session_id = ctx.session_id, error = %msg, "reasoning effort rejected");
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::ReasoningEffortSetFailed { effort, error: msg },
},
);
}
false
}
fn handle_get_reasoning_effort(
reply: mpsc::Sender<String>,
state: &SessionState,
ctx: &RequestContext,
) -> bool {
let _ = ctx;
let current = state
.config
.reasoning_effort
.clone()
.unwrap_or_else(|| "off".to_string());
let _ = reply.send(current);
false
}
fn handle_undo(state: &mut SessionState, ctx: &RequestContext) -> bool {
let Some(turn_ids) = state.undo_turns() else {
debug!(
session_id = ctx.session_id,
"undo requested but no user turn to undo",
);
return false;
};
info!(
session_id = ctx.session_id,
turn_count = turn_ids.len(),
"undo: marked turns as undone",
);
if state.config.last_response_id.is_some() {
state.config.last_response_id = None;
state.config.last_response_id_producer = None;
let record = SessionRecord::from(&*state);
if let Err(e) = write_session_retry(&ctx.db, ctx.session_id, &record) {
tracing::warn!(error = %e, "failed to persist session record after Undo");
}
}
for &id in &turn_ids {
if let Some(turn) = state.turns.get(&id)
&& let Err(e) = write_turn_retry(&ctx.db, ctx.session_id, id, turn)
{
tracing::warn!(turn_id = id, error = %e, "failed to persist undone turn");
}
}
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::TurnsUndone { turn_ids },
},
);
false
}
fn handle_redo(state: &mut SessionState, ctx: &RequestContext) -> bool {
let Some(turns) = state.redo_turns() else {
debug!(
session_id = ctx.session_id,
"redo requested but nothing to redo (no prior undo, or new input after undo)",
);
return false;
};
info!(
session_id = ctx.session_id,
turn_count = turns.len(),
"redo: restored previously-undone turns",
);
for (&id, turn) in &turns {
if let Err(e) = write_turn_retry(&ctx.db, ctx.session_id, id, turn) {
tracing::warn!(turn_id = id, error = %e, "failed to persist redone turn");
}
}
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::TurnsRedone {
turns: turns
.iter()
.map(|(&turn_id, turn)| (turn_id, turn_for_client(turn)))
.collect(),
},
},
);
false
}
fn handle_shutdown(
state: &mut SessionState,
shutdown_requested: &mut bool,
ctx: &RequestContext,
) -> bool {
*shutdown_requested = true;
for (&request_id, active) in &state.active_requests {
let _ = active.cancel_tx.send(());
broadcast(
&mut state.subscribers,
ctx,
DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::Cancelled { request_id },
},
);
}
state.active_requests.is_empty()
}
#[expect(clippy::too_many_arguments)]
fn run_request_worker(
request_id: u32,
client: InferenceProvider,
session: &mut SessionState,
model: String,
cancel_rx: crossbeam_channel::Receiver<()>,
ctx: RequestContext,
child_reply: Option<mpsc::Sender<io::Result<ChildResult>>>,
user_text: Option<String>,
) -> io::Result<()> {
let request_start = std::time::Instant::now();
let initial_snapshot = session.snapshot();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
run_agent_loop(
&client, session, &model, request_id, &cancel_rx, &ctx, user_text,
)
}));
let (outcome, snapshot) = match result {
Ok(Ok(true)) => (RequestOutcome::Cancelled, session.snapshot()),
Ok(Ok(false)) => (RequestOutcome::Done, session.snapshot()),
Ok(Err(e)) => (RequestOutcome::Failed(e), session.snapshot()),
Err(_) => (
RequestOutcome::Failed(io::Error::other("request worker panicked")),
initial_snapshot,
),
};
let req_status = match &outcome {
RequestOutcome::Done => "done",
RequestOutcome::Failed(_) => "failed",
RequestOutcome::Cancelled => "cancelled",
};
crate::metrics::record_request_total(req_status);
crate::metrics::record_request_duration(req_status, request_start.elapsed().as_secs_f64());
match &outcome {
RequestOutcome::Done => {
info!(session_id = ctx.session_id, request_id, "request completed");
let usage = &session.config.accumulated_usage;
debug!(
session_id = ctx.session_id,
request_id,
input_tokens = usage.input_tokens,
output_tokens = usage.output_tokens,
total_tokens = usage.total_tokens,
"broadcasting Done with accumulated token usage"
);
let _ = ctx
.cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::Done {
request_id,
token_usage: Some(*usage),
last_prompt_tokens: session.config.last_prompt_tokens,
},
}));
}
RequestOutcome::Failed(error) => {
info!(session_id = ctx.session_id, request_id, error = %error, "request failed");
let _ = ctx
.cmd_tx
.send(SessionCommand::Broadcast(DaemonMessage::Session {
session_id: Some(ctx.session_id),
event: SessionEvent::Failed {
request_id,
error: error.to_string(),
},
}));
}
RequestOutcome::Cancelled => {
info!(session_id = ctx.session_id, request_id, "request cancelled");
}
}
if let Some(reply) = child_reply {
let child_result = match &outcome {
RequestOutcome::Done => {
let output = session
.turns
.values()
.filter_map(|t| t.assistant_text.clone())
.collect::<Vec<_>>()
.join("\n");
Ok(ChildResult {
output,
is_error: false,
})
}
RequestOutcome::Failed(error) => Ok(ChildResult {
output: error.to_string(),
is_error: true,
}),
RequestOutcome::Cancelled => Ok(ChildResult {
output: "request cancelled".to_string(),
is_error: true,
}),
};
let _ = reply.send(child_result);
}
let _ = ctx.cmd_tx.send(SessionCommand::RequestFinished {
request_id,
snapshot,
});
Ok(())
}
enum RequestOutcome {
Done,
Failed(io::Error),
Cancelled,
}
fn persist_and_exit(
state: &SessionState,
db: &redb::Database,
session_id: u64,
daemon_tx: &mpsc::Sender<DaemonCommand>,
) {
let record: SessionRecord = SessionRecord::from(state);
if let Err(e) = write_session_retry(db, session_id, &record) {
error!(
"persist_and_exit: failed to persist session {}: {e}",
session_id
);
}
let _ = daemon_tx.send(DaemonCommand::SessionExited { session_id });
}
#[cfg(test)]
mod tests;