1use std::collections::{HashMap, VecDeque};
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use futures::StreamExt;
27use tokio::sync::{broadcast, mpsc, oneshot};
28
29use crate::engine::reactor::{
30 auto_turn_cap_reached, claim_auto_turn_with_cap, drain_event_queue, wake_action_with_cap,
31 EventDisposition, WakeAction,
32};
33use crate::engine::session::ConversationState;
34use crate::engine::setup::BackgroundTasks;
35use crate::extensions::invoke_output::{invoke_event_channel, InvokeOutputBudget};
36use crate::extensions::session_driver::{self as protocol, Grant, Reply};
37use crate::runtime::compaction::{
38 apply_compaction, compact_conversation, preview_compaction_disclosure, CompactionPolicy,
39 CompactionTransition,
40};
41use crate::tools::{SecretPromptHandle, SecretPromptRequest};
42use crate::{
43 AgentEvent, CancellationToken, EngineHost, LlmEvent, Result, Runtime, SessionEvent,
44 StreamEvent,
45};
46
47use super::budgets;
48use super::handle::{SessionEndpoints, SessionHandle};
49use super::types::*;
50use super::view::RuntimeView;
51
52pub type ActiveStream = std::pin::Pin<Box<dyn futures::Stream<Item = StreamEvent> + Send>>;
54
55const TURN_REPLAY_CAP: usize = 4096;
57
58#[derive(Default)]
74pub(crate) struct TurnLog {
75 parts: Vec<TurnPart>,
76}
77
78pub(crate) enum TurnPart {
79 Thinking(String),
80 Text(String),
81 ToolUse { name: String, input: String },
82 ToolResult { tool_id: String, content: String },
83}
84
85impl TurnLog {
86 fn clear(&mut self) {
87 self.parts.clear();
88 }
89
90 fn tool_result_mut(&mut self, tool_id: &str) -> Option<&mut String> {
91 self.parts.iter_mut().rev().find_map(|p| match p {
92 TurnPart::ToolResult { tool_id: id, content } if id == tool_id => Some(content),
93 _ => None,
94 })
95 }
96
97 fn tool_result_delta(&mut self, tool_id: String, delta: String) {
100 match self.tool_result_mut(&tool_id) {
101 Some(c) => c.push_str(&delta),
102 None => self.parts.push(TurnPart::ToolResult {
103 tool_id,
104 content: delta,
105 }),
106 }
107 }
108
109 fn tool_result(&mut self, tool_id: String, result: String) {
112 match self.tool_result_mut(&tool_id) {
113 Some(c) => *c = result,
114 None => self.parts.push(TurnPart::ToolResult {
115 tool_id,
116 content: result,
117 }),
118 }
119 }
120
121 fn text(&mut self, t: &str) {
122 if let Some(TurnPart::Text(s)) = self.parts.last_mut() {
123 s.push_str(t);
124 } else {
125 self.parts.push(TurnPart::Text(t.to_string()));
126 }
127 }
128
129 fn thinking(&mut self, t: &str) {
130 if let Some(TurnPart::Thinking(s)) = self.parts.last_mut() {
131 s.push_str(t);
132 } else {
133 self.parts.push(TurnPart::Thinking(t.to_string()));
134 }
135 }
136
137 fn abort_context(&self) -> Option<String> {
147 let mut parts: Vec<String> = Vec::new();
148 for p in &self.parts {
149 match p {
150 TurnPart::Thinking(t) if !t.is_empty() => {
151 let preview: String = t.chars().take(500).collect();
152 parts.push(format!("- your reasoning so far: {}", preview.trim()));
153 }
154 TurnPart::Text(t) if !t.is_empty() => {
155 parts.push(format!("- you had started writing: {}", t.trim()));
156 }
157 TurnPart::ToolUse { name, input } => {
158 let input_preview: String = input.chars().take(200).collect();
159 parts.push(format!(
160 "- you invoked the {} tool with input: {}",
161 name,
162 input_preview.trim()
163 ));
164 }
165 TurnPart::ToolResult { content, .. } if !content.is_empty() => {
166 let preview: String = content.chars().take(300).collect();
167 parts.push(format!("- that tool returned: {}", preview.trim()));
168 }
169 _ => {}
170 }
171 }
172 if parts.is_empty() {
173 return None;
174 }
175 Some(format!(
176 "(System note — ABORT CONTEXT: your previous response was interrupted \
177 before it finished. This is a factual recap of your OWN partial work, \
178 not new instructions. Continue naturally from here or adjust for the \
179 user's next message; do not re-run completed tool calls.)\n{}",
180 parts.join("\n")
181 ))
182 }
183}
184
185pub(crate) struct Live<T>(Option<T>);
190
191impl<T> Live<T> {
192 pub(crate) fn new(v: T) -> Self {
193 Self(Some(v))
194 }
195 pub(crate) fn is_live(&self) -> bool {
196 self.0.is_some()
197 }
198 pub(crate) fn park_take(&mut self) -> Option<T> {
199 self.0.take()
200 }
201 pub(crate) fn unpark_set(&mut self, v: T) {
202 self.0 = Some(v);
203 }
204}
205
206impl<T> std::ops::Deref for Live<T> {
207 type Target = T;
208 fn deref(&self) -> &T {
209 self.0.as_ref().expect("session is parked: runtime/conv unavailable")
210 }
211}
212
213impl<T> std::ops::DerefMut for Live<T> {
214 fn deref_mut(&mut self) -> &mut T {
215 self.0.as_mut().expect("session is parked: runtime/conv unavailable")
216 }
217}
218
219pub fn parked_evict_after() -> Option<std::time::Duration> {
225 let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
227 parked_evict_after_from(
228 std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
229 cfg_secs,
230 )
231}
232
233fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
234 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
235 match v.map(str::trim) {
236 Some("never" | "0" | "off") => None,
237 Some(n) => n
238 .parse::<u64>()
239 .ok()
240 .map(std::time::Duration::from_secs)
241 .or_else(from_cfg),
242 None => from_cfg(),
243 }
244}
245
246pub fn idle_end_grace() -> std::time::Duration {
251 std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
252 .ok()
253 .and_then(|v| v.trim().parse::<u64>().ok())
254 .map(std::time::Duration::from_secs)
255 .unwrap_or(std::time::Duration::from_secs(5))
256}
257
258pub fn park_grace() -> Option<std::time::Duration> {
261 match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
262 Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
263 Ok(v) => v
264 .trim()
265 .parse::<u64>()
266 .ok()
267 .map(std::time::Duration::from_secs)
268 .or(Some(DEFAULT_PARK_GRACE)),
269 Err(_) => Some(DEFAULT_PARK_GRACE),
270 }
271}
272
273pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
274
275pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
287 let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
289 prompt_abandon_timeout_from(
290 std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
291 cfg_secs,
292 )
293}
294
295fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
296 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
297 match v.map(str::trim) {
298 Some("never" | "0" | "off") => None,
299 Some(n) => n
300 .parse::<u64>()
301 .ok()
302 .map(std::time::Duration::from_secs)
303 .or_else(from_cfg),
304 None => from_cfg(),
305 }
306}
307
308fn replayable(setting: &SessionSetting) -> bool {
312 matches!(
313 setting,
314 SessionSetting::SystemPrompt { .. }
315 | SessionSetting::ContextWindow { .. }
316 | SessionSetting::CompactionModel { .. }
317 | SessionSetting::ApiRetries { .. }
318 | SessionSetting::SubagentTimeout { .. }
319 | SessionSetting::MaxToolOutput { .. }
320 | SessionSetting::BashTimeout { .. }
321 | SessionSetting::BashMaxTimeout { .. }
322 | SessionSetting::GrantWorkerModel { .. }
323 )
324}
325
326fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
327 match setting {
328 SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
329 SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
330 SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
331 SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
332 SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
333 SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
334 SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
335 SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
336 SessionSetting::GrantWorkerModel { model } => {
337 let _ = rt.grant_worker_model(model);
338 }
339 _ => {}
340 }
341}
342
343pub(crate) struct AttachedClient {
346 pub(crate) meta: ClientMeta,
347 pub(crate) mode: AttachMode,
348}
349
350pub(crate) struct CompactionJob {
352 pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
353 pub(crate) source: String,
354 #[allow(dead_code)]
355 pub(crate) started: tokio::time::Instant,
356}
357
358fn is_input_command(cmd: &SessionCommand) -> bool {
364 matches!(
365 cmd,
366 SessionCommand::Checkpoint { .. }
367 | SessionCommand::Submit { .. }
368 | SessionCommand::SubmitPrepared { .. }
369 | SessionCommand::Steer { .. }
370 | SessionCommand::Cancel
371 | SessionCommand::Set { .. }
372 | SessionCommand::Compact { .. }
373 | SessionCommand::NewSession
374 | SessionCommand::EngineCommand { .. }
375 | SessionCommand::PluginCommand { .. }
376 | SessionCommand::Resume { .. }
377 | SessionCommand::KeepWarm { .. }
378 | SessionCommand::DriverStart { .. }
379 | SessionCommand::End {
380 reason: EndReason::ClientQuit
381 }
382 )
383}
384
385fn command_name(cmd: &SessionCommand) -> &'static str {
386 match cmd {
387 SessionCommand::Submit { .. } => "submit",
388 SessionCommand::SubmitPrepared { .. } => "submit_prepared",
389 SessionCommand::Steer { .. } => "steer",
390 SessionCommand::Cancel => "cancel",
391 SessionCommand::Answer { .. } => "answer",
392 SessionCommand::Set { .. } => "set",
393 SessionCommand::Compact { .. } => "compact",
394 SessionCommand::NewSession => "new_session",
395 SessionCommand::Save => "save",
396 SessionCommand::Query { .. } => "query",
397 SessionCommand::Attach { .. } => "attach",
398 SessionCommand::Detach { .. } => "detach",
399 SessionCommand::End { .. } => "end",
400 SessionCommand::Resync { .. } => "resync",
401 SessionCommand::EngineCommand { .. } => "engine_command",
402 SessionCommand::PluginCommand { .. } => "plugin_command",
403 SessionCommand::Resume { .. } => "resume",
404 SessionCommand::Checkpoint { .. } => "checkpoint",
405 SessionCommand::KeepWarm { .. } => "keep_warm",
406 SessionCommand::Park => "park",
407 SessionCommand::HostEvent(_) => "host_event",
408 SessionCommand::DriverStart { .. } => "driver_start",
409 }
410}
411
412pub struct SessionActor {
413 pub(crate) id: SessionId,
414 pub(crate) meta: SessionMeta,
415 pub(crate) config: SessionConfig,
416 pub(crate) runtime: Live<Runtime>,
418 pub(crate) conv: Live<ConversationState>,
420 pub(crate) host: Arc<EngineHost>,
422 pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
424 pub(crate) event_queue: Arc<crate::events::EventQueue>,
427 pub(crate) settings_replay: Vec<SessionSetting>,
429 pub(crate) keep_warm: bool,
430 pub(crate) park_deadline: Option<tokio::time::Instant>,
432 pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
437 pub(crate) stream: Option<ActiveStream>,
439 pub(crate) cancel: Option<CancellationToken>,
440 pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
441 pub(crate) streaming: bool,
442 pub(crate) turn_baseline: usize,
443 pub(crate) consecutive_auto_turns: u32,
444 pub(crate) turn_log: TurnLog,
445 pub(crate) secret_prompt_handle: SecretPromptHandle,
447 pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
448 pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
450 pub(crate) next_prompt_id: u64,
451 pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
453 pub(crate) events: broadcast::Sender<Envelope>,
454 pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
455 pub(crate) attached: HashMap<ClientId, AttachedClient>,
456 pub(crate) input_owner: Option<ClientId>,
459 pub(crate) next_client_id: u64,
460 pub(crate) seq: u64,
461 pub(crate) turn_replay: VecDeque<Envelope>,
462 pub(crate) state: AttachState,
463 pub(crate) background: BackgroundTasks,
464 pub(crate) compact: Option<CompactionJob>,
466 pub(crate) ext_ready: Option<oneshot::Receiver<()>>,
469 pub(crate) subagent_tick: Option<tokio::time::Interval>,
471 pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
473 pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
475 pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
477 pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
479 pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
481 pub(crate) driver: Option<super::driver::DriverState>,
483 pub(crate) driver_pending: Option<super::driver::DriverPending>,
484 pub(crate) driver_generation: u64,
486 pub(crate) driver_interrupted_owner: Option<String>,
488 pub(crate) driver_tick_interval: tokio::time::Interval,
490}
491
492impl SessionActor {
493 pub(crate) async fn create(
498 host: &Arc<EngineHost>,
499 mut cfg: SessionConfig,
500 ) -> Result<(SessionHandle, SessionTask)> {
501 let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
504 let config: crate::SynapsConfig = (**host.config()).clone();
505
506 let mut sb = crate::engine::setup::resolve_session_and_prompt(
507 &mut runtime,
508 &cfg.continue_session,
509 cfg.system.as_deref(),
510 cfg.prompt_manifest.as_deref(),
511 )?;
512 if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
516 sb.session
517 .set_name(name)
518 .map_err(|e| crate::RuntimeError::Session(format!("--name {name:?}: {e}")))?;
519 if cfg.persist {
520 if let Err(e) = sb.session.save().await {
523 tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
524 }
525 }
526 }
527 runtime.set_cwd(cfg.cwd.clone());
528 if cfg.env.is_none() {
532 if let Some(ref journal_env) = sb.session.env {
533 cfg.env = Some(journal_env.clone());
534 }
535 if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
536 cfg.env_stripped = sb.session.env_stripped.clone();
537 }
538 }
539 if cfg.env.is_some() {
543 sb.session.env = cfg.env.clone();
544 sb.session.env_stripped = cfg.env_stripped.clone();
545 }
546 runtime.set_env(cfg.env.clone());
547 runtime.set_env_stripped(cfg.env_stripped.clone());
548 if let Some(ref m) = cfg.model_override {
550 runtime.set_model(m.clone());
551 }
552
553 let background =
554 crate::engine::setup::spawn_session_background(&runtime, &sb.session)?;
555 crate::engine::setup::finish_session_setup(
556 &mut runtime,
557 &config,
558 &sb.session,
559 cfg.cwd.clone(),
560 crate::engine::setup::IndexRecord::Start,
561 );
562
563 let ext_ready_timeout = {
570 let secs = config.startup.extensions_ready_timeout_secs;
571 if secs == 0 {
572 budgets::EXTENSIONS_READY_TIMEOUT
573 } else {
574 std::time::Duration::from_secs(secs)
575 }
576 };
577 let mut ext_ready = None;
578 if cfg.await_extensions {
579 if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
580 .await
581 .is_err()
582 {
583 tracing::warn!(
584 budget_secs = ext_ready_timeout.as_secs(),
585 "extensions_ready timed out — on_session_start may miss late extensions"
586 );
587 }
588 crate::extensions::loader::emit_session_start(runtime.hook_bus(), &sb.session.id)
589 .await;
590 } else {
591 let (tx, rx) = oneshot::channel();
592 let waiter_host = Arc::clone(host);
593 tokio::spawn(async move {
594 let _ = tokio::time::timeout(ext_ready_timeout, waiter_host.extensions_ready())
595 .await;
596 let _ = tx.send(());
597 });
598 ext_ready = Some(rx);
599 }
600
601 let id = SessionId::from(sb.session.id.clone());
602
603 let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
605 let session_lock = if sb.continued {
606 let dir = agent_core::session_lock::sessions_dir();
609 let holder = agent_core::session_lock::LockHolder {
610 pid: std::process::id(),
611 kind: lock_kind.to_string(),
612 };
613 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
614 Ok(lock) => Some(lock),
615 Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
616 let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
617 return Err(crate::RuntimeError::Session(msg.to_string()));
618 }
619 Err(e) => {
620 tracing::warn!(session = %sb.session.id, "session lock: {e}");
621 None
622 }
623 }
624 } else {
625 let dir = agent_core::session_lock::sessions_dir();
627 let holder = agent_core::session_lock::LockHolder {
628 pid: std::process::id(),
629 kind: lock_kind.to_string(),
630 };
631 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
632 Ok(lock) => Some(lock),
633 Err(e) => {
634 tracing::warn!(session = %sb.session.id, "session lock: {e}");
635 None
636 }
637 }
638 };
639
640 let meta = SessionMeta {
641 id: id.clone(),
642 name: sb.session.name.clone(),
643 model: runtime.model().to_string(),
644 cwd: cfg.cwd.clone(),
645 created_at: sb.session.created_at,
646 continued: sb.continued,
647 continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
648 host_pid: std::process::id(),
649 lifecycle: SessionLifecycle::Live,
650 clients: 0,
651 input_owner: None,
652 awaiting_input: 0,
653 journal_id: sb.session.id.clone(),
654 locked_by: None,
655 };
656 let mut conv = if sb.continued {
657 ConversationState::from_resumed(sb.session)
658 } else {
659 ConversationState::new(sb.session)
660 };
661 conv.api_messages = sb.api_messages;
662 conv.total_input_tokens = sb.total_input_tokens;
663 conv.total_output_tokens = sb.total_output_tokens;
664 conv.session_cost = sb.session_cost;
665 conv.abort_context = sb.abort_context;
666
667 let view = RuntimeView::from_runtime(&runtime).await;
668 let hook_bus = Arc::clone(runtime.hook_bus());
669 let event_queue = Arc::clone(runtime.event_queue());
670 let keep_warm = cfg.keep_warm;
671 let (
672 handle,
673 SessionEndpoints {
674 cmd_rx,
675 events,
676 view,
677 lifecycle,
678 journal_id,
679 presence,
680 name,
681 },
682 ) = SessionHandle::new(meta.clone(), view);
683 let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
684
685 let actor = SessionActor {
686 id,
687 meta,
688 config: cfg,
689 runtime: Live::new(runtime),
690 conv: Live::new(conv),
691 host: Arc::clone(host),
692 hook_bus,
693 event_queue,
694 settings_replay: Vec::new(),
695 keep_warm,
696 park_deadline: None,
697 prompt_abandon_deadline: None,
698 stream: None,
699 cancel: None,
700 steer_tx: None,
701 streaming: false,
702 turn_baseline: 0,
703 consecutive_auto_turns: 0,
704 turn_log: TurnLog::default(),
705 secret_prompt_handle: SecretPromptHandle::new(sp_tx),
706 secret_prompt_rx,
707 pending_prompts: VecDeque::new(),
708 next_prompt_id: 1,
709 cmd_rx,
710 events,
711 view,
712 attached: HashMap::new(),
713 input_owner: None,
714 next_client_id: 1,
715 seq: 0,
716 turn_replay: VecDeque::new(),
717 state: AttachState::Detached { running: false },
718 background,
719 compact: None,
720 ext_ready,
721 subagent_tick: None,
722 lifecycle,
723 journal_id,
724 presence,
725 name,
726 session_lock,
727 driver: None,
728 driver_pending: None,
729 driver_generation: 0,
730 driver_interrupted_owner: None,
731 driver_tick_interval: {
732 let mut interval = tokio::time::interval(Duration::from_millis(200));
733 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
734 interval
735 },
736 };
737 Ok((handle, SessionTask(actor)))
738 }
739
740 pub(crate) fn sync_name(&mut self) {
744 let name = self.conv.session.name.clone();
745 self.meta.name = name.clone();
746 self.name.store(Arc::new(name.clone()));
747 if let Err(e) = crate::events::registry::update_session_name(self.id.as_str(), name.as_deref()) {
748 tracing::warn!(session = %self.id, "registry name update failed: {e}");
749 }
750 }
751
752 pub(crate) fn emit(&mut self, event: SessionEventWire) {
757 let replay = self.streaming
758 && !matches!(
759 event,
760 SessionEventWire::Prompt(_)
761 | SessionEventWire::PromptResolved { .. }
762 | SessionEventWire::Attached { .. }
763 | SessionEventWire::QueryResult { .. }
764 );
765 let env = Envelope {
766 session_id: self.id.clone(),
767 seq: self.seq,
768 ts: chrono::Utc::now(),
769 event,
770 };
771 self.seq += 1;
772 if replay {
773 if self.turn_replay.len() >= TURN_REPLAY_CAP {
774 self.turn_replay.pop_front();
775 }
776 self.turn_replay.push_back(env.clone());
777 }
778 let _ = self.events.send(env);
780 }
781
782 pub(crate) fn emit_conversation(&mut self) {
783 let snap = self.conv.snapshot(self.consecutive_auto_turns);
784 self.emit(SessionEventWire::Conversation(snap));
785 }
786
787 pub(crate) async fn publish_view(&mut self) {
788 let v = RuntimeView::from_runtime(&self.runtime).await;
789 self.view.store(Arc::new(v));
790 }
791
792 pub(crate) async fn save(&mut self) {
793 if self.config.persist && self.conv.is_live() {
794 self.conv.save().await;
795 }
796 }
797
798 async fn handle_context_head_checkpoint(
808 &mut self,
809 session_id: String,
810 messages: Vec<crate::SharedMessage>,
811 receipt: agent_core::core::context_head::ContextHeadReceipt,
812 ) {
813 if session_id != self.conv.session.id {
815 tracing::warn!(
816 event_id = %session_id,
817 actor_id = %self.conv.session.id,
818 "context head checkpoint: stale session id — ignoring"
819 );
820 receipt.complete(Err(std::io::Error::new(
821 std::io::ErrorKind::InvalidInput,
822 "stale session id after compaction",
823 )));
824 return;
825 }
826
827 if !self.config.persist {
829 receipt.complete(Ok(()));
830 return;
831 }
832
833 let result = self.conv.persist_context_head(&session_id, messages).await;
834 if let Err(ref e) = result {
835 tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
836 }
837 let succeeded = result.is_ok();
838 receipt.complete(result);
839
840 if let Some(driver) = &self.driver {
842 if !succeeded
843 || driver.grant.session_id != session_id
844 || session_id != self.conv.session.id
845 {
846 self.driver_revoke("context checkpoint failed or session replaced");
847 }
848 }
849 }
850
851
852 pub(crate) fn reload_record(&self) -> SessionReloadRecord {
854 let view = self.view.load();
855 SessionReloadRecord {
856 config: self.config.clone(),
857 keep_warm: self.keep_warm,
858 lifecycle: SessionLifecycle::from_u8(
859 self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
860 ),
861 settings_replay: self.settings_replay.clone(),
862 model: view.model.clone(),
863 thinking_level: view.thinking_level.clone(),
864 }
865 }
866
867 pub(crate) fn publish_presence(&self) {
868 self.presence.store(Arc::new(super::handle::Presence {
869 clients: self.attached.len(),
870 input_owner: self.input_owner,
871 awaiting_input: self.pending_prompts.len(),
872 }));
873 }
874
875 pub(crate) fn update_attach_state(&mut self) {
878 self.publish_presence();
879 if matches!(self.state, AttachState::Parking | AttachState::Parked) {
880 return;
881 }
882 self.state = if self.attached.is_empty() {
883 AttachState::Detached {
884 running: self.streaming,
885 }
886 } else {
887 AttachState::Attached(self.attached.len())
888 };
889 self.rearm_park();
890 self.rearm_prompt_abandon();
891 }
892
893 pub(crate) fn is_parked(&self) -> bool {
896 matches!(self.state, AttachState::Parked)
897 }
898
899 pub(crate) fn can_park(&self) -> bool {
903 self.attached.is_empty()
904 && !self.streaming
905 && self.compact.is_none()
906 && self.pending_prompts.is_empty()
907 && self.conv.is_live()
908 && !self.conv.api_messages.is_empty()
909 && self.conv.queued_message.is_none()
910 && !self.keep_warm
911 && self.config.persist
912 && !self.is_parked()
913 && self.driver.is_none()
914 }
915
916 fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
922 let total = self.conv.session_cost;
923 if let Some(cap) = self.config.max_session_cost {
924 if cap.is_finite() && total >= cap {
925 return Some(("session", total, cap));
926 }
927 }
928 if let Some(driver) = &self.driver {
929 if let Some(cap) = driver.grant.effective_cost_cap(None) {
933 let run_cost = total - driver.cost_at_arm;
934 if cap.is_finite() && run_cost >= cap {
935 return Some(("run", run_cost, cap));
936 }
937 }
938 }
939 None
940 }
941
942 fn journal_exists(&self) -> bool { let id = (**self.journal_id.load()).clone();
944 crate::config::resolve_write_path("sessions")
945 .join(format!("{id}.json"))
946 .is_file()
947 }
948
949 pub(crate) fn driver_revoke(&mut self, reason: &str) {
954 let undelivered = self
955 .driver
956 .as_mut()
957 .map(|d| d.steering.drain(..).collect::<Vec<_>>())
958 .unwrap_or_default();
959 let was_active = self.driver.is_some() || self.driver_pending.is_some();
960 if let Some(driver) = &self.driver {
961 self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
962 self.host
963 .release_driver(&driver.grant.plugin_id, &self.id);
964 }
965 self.driver_generation = self.driver_generation.wrapping_add(1);
966 self.driver_pending = None;
967 self.driver = None;
968 if was_active {
969 self.emit(SessionEventWire::DriverRevoked {
970 reason: reason.to_string(),
971 undelivered_steering: undelivered,
972 });
973 }
974 self.rearm_park();
975 }
976
977 pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
981 if self.driver.is_some() || self.driver_pending.is_some() {
983 self.driver_revoke("explicit command");
984 }
985
986 let manager = self.host.ext_manager().clone();
987 let session_id = self.id.0.clone();
988
989 let (handler, timeout) = match manager.try_read() {
991 Ok(mgr) => {
992 let handler = match mgr.user_action_handler(&plugin) {
993 Ok(h) => h,
994 Err(error) => {
995 self.emit(SessionEventWire::SystemNotice(error));
996 return;
997 }
998 };
999 let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1000 5
1001 } else {
1002 self.emit(SessionEventWire::SystemNotice(
1003 "session driver extension lacks validated session.drive permission".into(),
1004 ));
1005 return;
1006 };
1007 (handler, timeout)
1008 }
1009 Err(_) => {
1010 self.emit(SessionEventWire::SystemNotice(
1011 "extensions are loading or busy — try again shortly".into(),
1012 ));
1013 return;
1014 }
1015 };
1016
1017 let handler_generation = super::driver::live_generation(&handler).ok();
1018
1019 let owner = plugin.clone();
1020 let cmd = command.clone();
1021 let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1022
1023 let generation = self.driver_generation;
1024 let task_handler = handler.clone();
1025 self.driver_pending = Some(super::driver::DriverPending {
1026 generation,
1027 session_id: session_id.clone(),
1028 task: super::driver::Task(tokio::spawn(async move {
1029 let (sink, collector) =
1030 invoke_event_channel(InvokeOutputBudget::default());
1031 let request_id = uuid::Uuid::new_v4().to_string();
1032 let (result, report) =
1033 tokio::time::timeout(Duration::from_secs(timeout), async {
1034 tokio::join!(
1035 task_handler.invoke_command(&cmd, args, &request_id, sink),
1036 collector.collect()
1037 )
1038 })
1039 .await
1040 .unwrap_or_else(|_| {
1041 (
1042 Err(format!("interactive command timed out ({timeout}s)")),
1043 crate::extensions::invoke_output::InvokeOutputReport {
1044 events: Vec::new(),
1045 counters: Default::default(),
1046 },
1047 )
1048 });
1049 super::driver::TaskResult::Command {
1050 owner,
1051 command,
1052 handler: task_handler,
1053 handler_generation,
1054 result,
1055 report,
1056 }
1057 })),
1058 });
1059 }
1060
1061 pub(crate) fn driver_arm(
1064 &mut self,
1065 owner: String,
1066 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1067 handler_generation: u64,
1068 reply: Reply,
1069 ) -> std::result::Result<(), String> {
1070 let manager = self.host.ext_manager().clone();
1071 super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1072
1073 if self.driver.is_some() {
1074 return Err("cannot replace an armed grant".into());
1075 }
1076
1077 let session_id = self.id.0.clone();
1078 let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1079
1080 self.host
1082 .claim_driver(&owner, &self.id)
1083 .map_err(|e| e.to_string())?;
1084
1085 let cancel = CancellationToken::new();
1086 let workers = self.runtime.subagent_registry().clone();
1087 let worker_epoch = workers
1088 .lock()
1089 .unwrap_or_else(|p| p.into_inner())
1090 .set_spawn_cancellation(Some(cancel.clone()));
1091 let deadline_task = grant.deadline().map(|deadline| {
1092 let c = cancel.clone();
1093 let w = workers.clone();
1094 super::driver::Task(tokio::spawn(async move {
1095 tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1096 c.cancel();
1097 super::driver::cancel_workers(&w, worker_epoch);
1098 }))
1099 });
1100
1101 let models = grant.models().to_vec();
1102 let proposal_notice = proposal.notice.clone();
1103 let selection = proposal.selection.clone();
1104 let deadline_ms = super::driver::deadline_ms(&grant);
1105 let run_id = grant.run_id.clone();
1106 let cost_at_arm = self.conv.session_cost;
1107
1108 let mut state = super::driver::DriverState {
1109 selection: proposal.selection.clone(),
1110 grant,
1111 handler,
1112 handler_generation,
1113 cancel,
1114 workers,
1115 worker_epoch,
1116 deadline_task,
1117 proposal: None,
1118 awaiting_terminal: false,
1119 outcome: None,
1120 feedback: crate::extensions::feedback::Tracker::default(),
1121 completed_feedback: "unknown",
1122 steering: VecDeque::new(),
1123 auto_wakes_blocked: false,
1124 cost_at_arm,
1125 };
1126
1127 super::driver::schedule(&mut state, proposal)?;
1128
1129 let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1131 if let Some(text) = context_notice {
1132 self.emit(SessionEventWire::SystemNotice(text));
1133 }
1134
1135 self.driver_interrupted_owner = None;
1136 self.driver = Some(state);
1137
1138 self.emit(SessionEventWire::DriverArmed {
1139 plugin_id: owner,
1140 run_id,
1141 models,
1142 selection,
1143 deadline_ms,
1144 notice: proposal_notice,
1145 });
1146
1147 Ok(())
1148 }
1149
1150 fn driver_idle_conflict(&self) -> Option<&'static str> {
1155 if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1156 return Some("other queued work");
1157 }
1158 if self.compact.is_some() {
1159 return Some("compaction");
1160 }
1161 if self.ext_ready.is_some() {
1162 return Some("extensions loading or reloading");
1163 }
1164 if self.conv.context_head.is_blocked(&self.conv.session) {
1165 return Some("unverified context head");
1166 }
1167 if self.driver_completion_blocked() {
1168 return Some("outstanding workers require collection/reconciliation");
1169 }
1170 None
1171 }
1172
1173 fn driver_completion_blocked(&self) -> bool {
1175 use agent_core::orchestration::CompletionGate;
1176 self.runtime.orchestration().is_some_and(|o| {
1177 !matches!(o.completion_gate(), CompletionGate::Allowed)
1178 }) || self
1179 .runtime
1180 .subagent_registry()
1181 .lock()
1182 .map(|r| {
1183 r.list_active().iter().any(|(_, _, status)| {
1184 *status == crate::runtime::subagent::SubagentStatus::Running
1185 })
1186 })
1187 .unwrap_or(true)
1188 }
1189
1190 pub(crate) async fn driver_tick(&mut self) {
1194 use crate::extensions::session_driver::Outcome;
1195
1196 if self.stream.is_none()
1198 && self
1199 .cancel
1200 .as_ref()
1201 .is_some_and(|ct| ct.is_cancelled())
1202 {
1203 self.clear_stream();
1204 self.emit(SessionEventWire::Idle);
1205 }
1206
1207 if let Some(driver) = &self.driver {
1209 let invalid = if driver.grant.session_id != self.id.0 {
1210 Some("session replaced".to_string())
1211 } else if driver.grant.expired() {
1212 Some("grant deadline reached".into())
1213 } else if driver.cancel.is_cancelled() {
1214 Some("canceled".into())
1215 } else {
1216 let manager = self.host.ext_manager().clone();
1217 super::driver::same_handler(
1218 &manager,
1219 &driver.grant.plugin_id,
1220 &driver.handler,
1221 driver.handler_generation,
1222 )
1223 .err()
1224 };
1225 if let Some(reason) = invalid {
1226 self.driver_revoke(&reason);
1227 return;
1228 }
1229 }
1230
1231 if self.driver.is_some() {
1233 if self.attached.is_empty() {
1237 self.driver_revoke("no clients attached");
1238 return;
1239 }
1240 if let Some(reason) = self.driver_idle_conflict() {
1243 self.driver_revoke(reason);
1244 return;
1245 }
1246
1247 if !self.runtime.event_queue().is_empty() {
1250 return;
1251 }
1252 }
1253
1254 if self
1256 .driver_pending
1257 .as_ref()
1258 .is_some_and(|p| p.task.0.is_finished())
1259 {
1260 let mut pending = self.driver_pending.take().expect("checked pending");
1261 if pending.generation != self.driver_generation
1262 || pending.session_id != self.id.0
1263 {
1264 return;
1265 }
1266 let result = match (&mut pending.task.0).await {
1267 Ok(result) => result,
1268 Err(error) => {
1269 self.driver_revoke("driver task failed");
1270 self.emit(SessionEventWire::SystemNotice(format!(
1271 "Session driver task failed: {error}"
1272 )));
1273 return;
1274 }
1275 };
1276 match result {
1277 super::driver::TaskResult::Command {
1278 owner,
1279 command: _,
1280 handler,
1281 handler_generation,
1282 result,
1283 report,
1284 } => {
1285 let current = self
1286 .host
1287 .ext_manager()
1288 .try_read()
1289 .ok()
1290 .and_then(|m| m.user_action_handler(&owner).ok());
1291 if !current
1292 .as_ref()
1293 .is_some_and(|current| Arc::ptr_eq(current, &handler))
1294 {
1295 self.driver_revoke(
1296 "command owner unloaded, replaced, or unavailable",
1297 );
1298 return;
1299 }
1300 let output_failed = report.is_limited()
1301 || report.events.iter().any(|event| {
1302 matches!(
1303 event,
1304 crate::extensions::runtime::InvokeCommandEvent::Output(
1305 crate::extensions::commands::CommandOutputEvent::Error { .. }
1306 )
1307 )
1308 });
1309 let mut lines: Vec<String> = Vec::new();
1314 for event in &report.events {
1315 if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1316 let text = match out {
1317 crate::extensions::commands::CommandOutputEvent::Text { content }
1318 | crate::extensions::commands::CommandOutputEvent::System { content } => {
1319 Some(content.clone())
1320 }
1321 crate::extensions::commands::CommandOutputEvent::Error { content } => {
1322 Some(format!("Error: {content}"))
1323 }
1324 _ => None,
1325 };
1326 if let Some(text) = text {
1327 lines.push(text);
1328 }
1329 }
1330 }
1331 if !lines.is_empty() {
1332 const MAX_LINES: usize = 20;
1333 let truncated = lines.len() > MAX_LINES;
1334 if truncated {
1335 lines.truncate(MAX_LINES);
1336 lines.push("…output truncated".into());
1337 }
1338 self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
1339 }
1340 if let Some(notice) = report.limit_notice() {
1341 self.emit(SessionEventWire::SystemNotice(format!(
1342 "command output truncated: {}",
1343 notice.message,
1344 )));
1345 }
1346 if let Ok(value) = result {
1347 let result = match protocol::parse_reply(&value) {
1348 Ok(Some(reply @ Reply::Start { .. })) => {
1349 if output_failed {
1350 Err("command reported an error or exceeded its output budget"
1351 .into())
1352 } else if self.streaming || self.stream.is_some() {
1353 Err("foreground turn still active".into())
1354 } else if let Some(reason) = self.driver_idle_conflict() {
1355 Err(reason.into())
1356 } else {
1357 match handler_generation {
1358 Some(generation) => {
1359 self.driver_arm(
1360 owner, handler, generation, reply,
1361 )
1362 }
1363 None => Err(
1364 "command owner did not expose a live lifecycle at dispatch"
1365 .into(),
1366 ),
1367 }
1368 }
1369 }
1370 Ok(Some(
1371 Reply::Stop { notice: text }
1372 | Reply::Status { notice: text },
1373 )) => {
1374 self.emit(SessionEventWire::SystemNotice(text));
1375 Ok(())
1376 }
1377 Ok(Some(Reply::Next { .. })) => Err(
1378 "next requires an armed poll, not an interactive command"
1379 .into(),
1380 ),
1381 Ok(None) => Ok(()),
1382 Err(error) => Err(error),
1383 };
1384 if let Err(error) = result {
1385 self.emit(SessionEventWire::SystemNotice(format!(
1386 "Session driver rejected: {error}"
1387 )));
1388 }
1389 }
1390 }
1391 super::driver::TaskResult::Poll(result) => {
1392 let Some(driver) = self.driver.as_mut() else {
1393 return;
1394 };
1395 let stop_notice = match &result {
1396 Ok(Reply::Stop { notice }) => Some(notice.clone()),
1397 _ => None,
1398 };
1399 match result.and_then(|reply| driver.grant.accept(reply)) {
1400 Ok(Some(proposal)) => {
1401 let text = proposal.notice.clone();
1402 if let Err(error) =
1403 super::driver::schedule(driver, proposal)
1404 {
1405 self.driver_revoke(&error);
1406 } else {
1407 self.emit(SessionEventWire::SystemNotice(text));
1408 }
1409 }
1410 Ok(None) => {
1411 self.driver_revoke("owner requested stop");
1412 if let Some(text) = stop_notice {
1413 self.emit(SessionEventWire::SystemNotice(text));
1414 }
1415 }
1416 Err(error) => {
1417 self.driver_revoke(&format!("invalid poll response: {error}"))
1418 }
1419 }
1420 }
1421 super::driver::TaskResult::Prepared { proposal, result } => {
1422 if self.streaming || self.stream.is_some() {
1423 self.driver_revoke("foreground work took priority");
1424 return;
1425 }
1426 if let Some(reason) = self.driver_idle_conflict() {
1427 self.driver_revoke(reason);
1428 return;
1429 }
1430 match *result {
1431 Err(protocol::PrepareError::Selection(error)) => {
1432 self.emit(SessionEventWire::SystemNotice(format!(
1433 "Session driver selection rejected (nothing sent): {error}"
1434 )));
1435 if let Some(driver) = self.driver.as_mut() {
1436 driver.outcome = Some((
1437 Outcome::SelectionRejected,
1438 "unknown".into(),
1439 ));
1440 }
1441 }
1442 Err(protocol::PrepareError::Blocked(error)) => {
1443 self.driver_revoke(&format!("preflight blocked: {error}"));
1444 }
1445 Ok(candidate) => {
1446 let mut validated = proposal.clone();
1447 if let Some(context) = &self.conv.abort_context {
1448 validated.prompt =
1449 format!("{context}\n\n{}", proposal.prompt);
1450 }
1451 let history = super::driver::history_with_steering(
1452 &self.conv.api_messages,
1453 &self
1454 .driver
1455 .as_ref()
1456 .map(|d| &d.steering)
1457 .cloned()
1458 .unwrap_or_default(),
1459 );
1460 if let Err(error) = protocol::validate_prepared(
1461 &candidate, &validated, &history,
1462 ) {
1463 self.driver_revoke(&format!(
1464 "prepared selection changed before commit: {error}"
1465 ));
1466 return;
1467 }
1468 {
1472 let driver = self.driver.as_mut().expect("driver present");
1473 let conv = &mut *self.conv;
1474 super::driver::commit_submission(
1475 &mut conv.api_messages,
1476 &mut driver.steering,
1477 &proposal.prompt,
1478 &mut conv.abort_context,
1479 );
1480 }
1481 *self.runtime = candidate;
1483 self.conv.session.model = self.runtime.model().to_owned();
1484 self.conv.session.thinking_level =
1485 self.runtime.thinking_level().to_owned();
1486 self.consecutive_auto_turns = 0;
1487 self.turn_baseline = self.conv.api_messages.len();
1488 self.turn_log.clear();
1489 self.turn_replay.clear();
1490 self.streaming = true;
1491 self.update_attach_state();
1492 self.emit(SessionEventWire::TurnStarted {
1493 turn_baseline: self.turn_baseline,
1494 trigger: TurnTrigger::DriverAuto,
1495 user_text: Some(proposal.prompt.clone()),
1496 });
1497
1498 if let Some(driver) = self.driver.as_mut() {
1500 driver.awaiting_terminal = true;
1501 driver.completed_feedback = "unknown";
1502 if driver.grant.feedback_enabled() {
1503 driver.feedback.begin_turn(
1504 &driver.selection.model,
1505 &driver.selection.effort,
1506 );
1507 }
1508 }
1509
1510 let handler = self
1512 .driver
1513 .as_ref()
1514 .expect("driver")
1515 .handler
1516 .clone();
1517 let handler_generation = self
1518 .driver
1519 .as_ref()
1520 .expect("driver")
1521 .handler_generation;
1522 let ct = self
1523 .driver
1524 .as_ref()
1525 .expect("driver")
1526 .cancel
1527 .child_token();
1528 self.cancel = Some(ct.clone());
1529 let (tx, rx) = mpsc::unbounded_channel();
1530 self.steer_tx = Some(tx);
1531 let mut runtime = self.runtime.clone();
1532 runtime.share_ttl_latches(&self.runtime);
1536 let history = self.conv.api_messages.clone();
1537 let secret = self.secret_prompt_handle.clone();
1538 let session_id = self.id.0.clone();
1539 let generation = self.driver_generation;
1540 self.driver_pending = Some(super::driver::DriverPending {
1541 generation,
1542 session_id,
1543 task: super::driver::Task(tokio::spawn(async move {
1544 if ct.is_cancelled() {
1545 return super::driver::TaskResult::Started(
1546 Err("canceled before stream setup".into()),
1547 );
1548 }
1549 if let Err(error) =
1550 super::driver::same_lifecycle(
1551 &handler,
1552 handler_generation,
1553 )
1554 {
1555 return super::driver::TaskResult::Started(
1556 Err(error),
1557 );
1558 }
1559 tokio::select! {
1560 biased;
1561 _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
1562 started = runtime.run_stream_with_messages(history, ct.clone(), Some(rx), Some(secret), false) => super::driver::TaskResult::Started(Ok(started)),
1563 }
1564 })),
1565 });
1566
1567 let mut tick = tokio::time::interval(
1569 std::time::Duration::from_secs(1),
1570 );
1571 tick.set_missed_tick_behavior(
1572 tokio::time::MissedTickBehavior::Delay,
1573 );
1574 tick.reset();
1575 self.subagent_tick = Some(tick);
1576 self.save().await;
1577 }
1578 }
1579 }
1580 super::driver::TaskResult::Started(started) => match started {
1581 Ok(started) if self.driver.is_some() => {
1582 self.stream = Some(started);
1583 }
1584 Err(error) => {
1585 self.driver_revoke(&error);
1586 }
1587 _ => {}
1588 },
1589 }
1590 }
1591
1592 if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
1594 return;
1595 }
1596 if let Some(reason) = self.driver_idle_conflict() {
1597 self.driver_revoke(reason);
1598 return;
1599 }
1600 let session_cost = self.conv.session_cost;
1601 let Some(driver) = self.driver.as_mut() else {
1602 return;
1603 };
1604 if let Some((outcome, error_kind)) = driver.outcome.take() {
1605 let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
1606 let handler = driver.handler.clone();
1607 let session_id = self.id.0.clone();
1608 let generation = self.driver_generation;
1609 self.driver_pending = Some(super::driver::DriverPending {
1610 generation,
1611 session_id,
1612 task: super::driver::Task(tokio::spawn(async move {
1613 let result = tokio::time::timeout(
1616 Duration::from_secs(30),
1617 protocol::poll(handler, request),
1618 )
1619 .await
1620 .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
1621 super::driver::TaskResult::Poll(result)
1622 })),
1623 });
1624 } else if driver
1625 .proposal
1626 .as_ref()
1627 .is_some_and(|p| Instant::now() >= p.due)
1628 {
1629 let mut proposal =
1630 driver.proposal.take().expect("checked proposal").proposal;
1631 let original_prompt = proposal.prompt.clone();
1632 if let Some(context) = &self.conv.abort_context {
1633 proposal.prompt = format!("{context}\n\n{}", proposal.prompt);
1634 }
1635 let runtime = self.runtime.clone();
1638 let history = super::driver::history_with_steering(
1639 &self.conv.api_messages,
1640 &driver.steering,
1641 );
1642 let session_id = self.id.0.clone();
1643 let generation = self.driver_generation;
1644 self.driver_pending = Some(super::driver::DriverPending {
1645 generation,
1646 session_id,
1647 task: super::driver::Task(tokio::spawn(async move {
1648 let result = tokio::time::timeout(
1649 Duration::from_secs(5),
1650 protocol::prepare(&runtime, &proposal, &history),
1651 )
1652 .await
1653 .unwrap_or_else(|_| {
1654 Err(protocol::PrepareError::Blocked(
1655 "preparation timed out (5s)".into(),
1656 ))
1657 });
1658 proposal.prompt = original_prompt;
1659 super::driver::TaskResult::Prepared {
1660 proposal,
1661 result: Box::new(result),
1662 }
1663 })),
1664 });
1665 }
1666 }
1667
1668 fn reacquire_session_lock(&mut self, new_id: &str) {
1670 self.session_lock = None;
1672 let dir = agent_core::session_lock::sessions_dir();
1673 let holder = agent_core::session_lock::LockHolder {
1674 pid: std::process::id(),
1675 kind: "daemon".to_string(),
1676 };
1677 match agent_core::session_lock::SessionLock::try_acquire(&dir, new_id, holder) {
1678 Ok(lock) => self.session_lock = Some(lock),
1679 Err(e) => {
1680 tracing::warn!(session = %new_id, "reacquire session lock: {e}");
1681 }
1682 }
1683 }
1684
1685 pub(crate) fn can_end_idle(&self) -> bool {
1690 self.attached.is_empty()
1691 && !self.streaming
1692 && self.compact.is_none()
1693 && self.pending_prompts.is_empty()
1694 && self.conv.is_live()
1695 && self.conv.api_messages.is_empty()
1696 && self.conv.queued_message.is_none()
1697 && !self.keep_warm
1698 && !self.is_parked()
1699 && self.driver.is_none()
1704 && self.driver_pending.is_none()
1705 }
1706
1707 fn rearm_park(&mut self) {
1708 if self.is_parked() {
1709 return;
1712 }
1713 if self.can_end_idle() {
1714 if self.park_deadline.is_none() {
1719 self.park_deadline =
1720 Some(tokio::time::Instant::now() + idle_end_grace());
1721 }
1722 return;
1723 }
1724 if !self.can_park() {
1725 self.park_deadline = None;
1726 return;
1727 }
1728 match park_grace() {
1729 Some(grace) => {
1730 if self.park_deadline.is_none() {
1731 self.park_deadline = Some(tokio::time::Instant::now() + grace);
1732 }
1733 }
1734 None => self.park_deadline = None,
1735 }
1736 }
1737
1738 fn rearm_prompt_abandon(&mut self) {
1748 let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
1749 let abandoned = self.attached.is_empty()
1750 && !self.pending_prompts.is_empty()
1751 && !armed_driver
1752 && !self.is_parked();
1753 if !abandoned {
1754 self.prompt_abandon_deadline = None;
1757 return;
1758 }
1759 if self.prompt_abandon_deadline.is_none() {
1760 self.prompt_abandon_deadline =
1762 prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
1763 }
1764 }
1765
1766 fn on_prompt_abandon_deadline(&mut self) {
1771 self.prompt_abandon_deadline = None;
1772 if !self.attached.is_empty() {
1773 return;
1774 }
1775 let had_prompts = !self.pending_prompts.is_empty();
1776 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
1777 let _ = tx.send(None);
1778 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
1779 }
1780 if had_prompts {
1781 tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
1782 self.publish_presence();
1783 self.rearm_park();
1785 }
1786 }
1787
1788 fn set_lifecycle(&mut self, l: SessionLifecycle) {
1789 self.lifecycle
1790 .store(l as u8, std::sync::atomic::Ordering::Release);
1791 self.emit(SessionEventWire::Lifecycle(l));
1792 }
1793
1794 pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
1798 self.park_deadline = None;
1799 if self.is_parked() {
1800 if self.attached.is_empty() {
1801 tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
1802 return std::ops::ControlFlow::Break(EndReason::Evicted);
1803 }
1804 return std::ops::ControlFlow::Continue(());
1805 }
1806 if self.can_end_idle() {
1807 tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
1808 if let Some(lock) = self.session_lock.take() {
1811 lock.release_and_remove();
1812 }
1813 return std::ops::ControlFlow::Break(EndReason::Idle);
1814 }
1815 if !self.can_park() {
1816 return std::ops::ControlFlow::Continue(());
1817 }
1818 self.state = AttachState::Parking;
1819 self.set_lifecycle(SessionLifecycle::Parking);
1820 if tokio::time::timeout(budgets::SAVE_TIMEOUT, self.save())
1821 .await
1822 .is_err()
1823 {
1824 tracing::warn!(session = %self.id, "park: save timed out — staying live");
1825 self.state = AttachState::Detached { running: false };
1826 self.set_lifecycle(SessionLifecycle::Live);
1827 return std::ops::ControlFlow::Continue(());
1828 }
1829 if !self.journal_exists() {
1830 tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
1832 self.state = AttachState::Detached { running: false };
1833 self.set_lifecycle(SessionLifecycle::Live);
1834 return std::ops::ControlFlow::Continue(());
1835 }
1836 if self.runtime.session_manager().active_count() > 0 {
1837 self.emit(SessionEventWire::SystemNotice(
1838 "parked: background shells closed".into(),
1839 ));
1840 }
1841 self.runtime.session_manager().shutdown_all();
1842 self.turn_replay.clear();
1843 let conv = self.conv.park_take();
1846 drop(conv);
1847 let runtime = self.runtime.park_take();
1848 drop(runtime);
1849 crate::core::memstat::purge_arenas();
1853 self.session_lock = None;
1857 self.state = AttachState::Parked;
1858 self.set_lifecycle(SessionLifecycle::Parked);
1859 tracing::info!(session = %self.id, "session parked");
1860 self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
1864 std::ops::ControlFlow::Continue(())
1865 }
1866
1867 pub(crate) async fn unpark(&mut self) -> Result<()> {
1872 let started = std::time::Instant::now();
1873 self.park_deadline = None;
1875 let journal_id = (**self.journal_id.load()).clone();
1876
1877 {
1881 let dir = agent_core::session_lock::sessions_dir();
1882 let holder = agent_core::session_lock::LockHolder {
1883 pid: std::process::id(),
1884 kind: "daemon".to_string(),
1885 };
1886 match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
1887 Ok(lock) => self.session_lock = Some(lock),
1888 Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
1889 return Err(crate::RuntimeError::Session(format!(
1890 "cannot unpark session {}: journal locked by another process",
1891 journal_id
1892 )));
1893 }
1894 Err(e) => {
1895 tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
1896 }
1898 }
1899 }
1900
1901 let host = Arc::clone(&self.host);
1902 let cfg = self.config.clone();
1903 let queue = Arc::clone(&self.event_queue);
1904 let replay = self.settings_replay.clone();
1905 let journal_present = self.journal_exists();
1906 let view = self.view.load_full();
1907 let build = async move {
1908 let config: crate::SynapsConfig = (**host.config()).clone();
1909 let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
1911 runtime.set_event_queue(queue);
1912 let mut sb = if journal_present {
1913 crate::engine::setup::resolve_session_and_prompt(
1914 &mut runtime,
1915 &Some(Some(journal_id)),
1916 cfg.system.as_deref(),
1917 cfg.prompt_manifest.as_deref(),
1918 )?
1919 } else {
1920 tracing::warn!(
1924 session = %journal_id,
1925 "unpark: journal missing — restoring as a fresh conversation"
1926 );
1927 let mut sb = crate::engine::setup::resolve_session_and_prompt(
1928 &mut runtime,
1929 &None,
1930 cfg.system.as_deref(),
1931 cfg.prompt_manifest.as_deref(),
1932 )?;
1933 sb.session.id = journal_id.clone();
1934 runtime.set_session_id(Some(journal_id));
1935 sb
1936 };
1937 runtime.set_cwd(cfg.cwd.clone());
1938 runtime.set_env(cfg.env.clone());
1939 runtime.set_env_stripped(cfg.env_stripped.clone());
1940 runtime.set_model(view.model.clone());
1943 let _ = runtime.restore_session_reasoning(&view.thinking_level);
1944 sb.session.model = view.model.clone();
1945 sb.session.thinking_level = runtime.thinking_level().to_string();
1946 crate::engine::setup::finish_session_setup(
1947 &mut runtime,
1948 &config,
1949 &sb.session,
1950 cfg.cwd.clone(),
1951 crate::engine::setup::IndexRecord::Skip,
1952 );
1953 for s in &replay {
1954 replay_setting(&mut runtime, s);
1955 }
1956 Ok::<_, crate::RuntimeError>((runtime, sb))
1957 };
1958 let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
1959 Ok(Ok(v)) => v,
1960 Ok(Err(e)) => return Err(e),
1961 Err(_) => {
1962 return Err(crate::RuntimeError::Session(format!(
1963 "unpark timed out after {}s",
1964 budgets::UNPARK_TIMEOUT_SECS
1965 )))
1966 }
1967 };
1968 let mut conv = ConversationState::from_resumed(sb.session);
1969 conv.api_messages = sb.api_messages;
1970 conv.total_input_tokens = sb.total_input_tokens;
1971 conv.total_output_tokens = sb.total_output_tokens;
1972 conv.session_cost = sb.session_cost;
1973 conv.abort_context = sb.abort_context;
1974 self.runtime.unpark_set(runtime);
1975 self.conv.unpark_set(conv);
1976 self.publish_view().await;
1977 self.state = AttachState::Detached { running: false };
1978 self.set_lifecycle(SessionLifecycle::Live);
1979 self.update_attach_state();
1980 tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
1981 Ok(())
1982 }
1983
1984 pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
1988 if !self.is_parked() {
1989 return Ok(());
1990 }
1991 self.unpark()
1992 .await
1993 .map_err(|e| format!("session parked and could not be restored: {e}"))
1994 }
1995
1996 pub(crate) fn busy(&self) -> bool {
1998 self.streaming || self.compact.is_some()
1999 }
2000
2001 pub(crate) fn publish_subagent_rows(&mut self) {
2005 let rows = self
2006 .runtime
2007 .subagent_registry()
2008 .lock()
2009 .unwrap_or_else(|p| p.into_inner())
2010 .display_rows();
2011 if !rows.is_empty() {
2012 self.emit(SessionEventWire::SubagentRows(rows));
2013 }
2014 }
2015
2016 pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2019 let ct = CancellationToken::new();
2020 let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2021 self.streaming = true;
2022 self.turn_baseline = self.conv.api_messages.len();
2023 self.turn_log.clear();
2024 self.turn_replay.clear();
2025 self.update_attach_state();
2026 self.emit(SessionEventWire::TurnStarted {
2027 turn_baseline: self.turn_baseline,
2028 trigger,
2029 user_text,
2030 });
2031 let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2039 let stream = self
2040 .runtime
2041 .run_stream_with_messages(
2042 self.conv.api_messages.clone(),
2043 ct.clone(),
2044 Some(s_rx),
2045 Some(self.secret_prompt_handle.clone()),
2046 auto_approve,
2047 )
2048 .await;
2049 self.stream = Some(stream);
2050 self.cancel = Some(ct);
2051 self.steer_tx = Some(s_tx);
2052 let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2053 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2054 tick.reset();
2055 self.subagent_tick = Some(tick);
2056 }
2057
2058 pub(crate) fn clear_stream(&mut self) {
2062 self.stream = None;
2063 self.cancel = None;
2064 self.steer_tx = None;
2065 self.streaming = false;
2066 self.subagent_tick = None;
2067 self.turn_log.clear();
2068 self.update_attach_state();
2069 }
2070
2071 pub(crate) async fn submit(
2073 &mut self,
2074 text: String,
2075 attachments: Vec<serde_json::Value>,
2076 from: Option<ClientId>,
2077 ) {
2078 if self.conv.context_head.is_blocked(&self.conv.session) {
2083 self.emit(SessionEventWire::SystemNotice(
2084 "context head is unverified after a failed checkpoint; reload the session (`--continue`) or start a new one before continuing"
2085 .into(),
2086 ));
2087 return;
2088 }
2089 if self.streaming {
2090 if let Some(driver) = self.driver.as_mut() {
2096 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2097 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2098 self.emit(SessionEventWire::SystemNotice(
2099 "steering queue full (16 messages) — message dropped".into(),
2100 ));
2101 return;
2102 }
2103 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2104 self.emit(SessionEventWire::SystemNotice(
2105 "steering queue full (256 KiB) — message dropped".into(),
2106 ));
2107 return;
2108 }
2109 driver.steering.push_back(text.clone());
2110 }
2111 self.steer(text);
2112 return;
2113 }
2114 if self.driver.is_some() {
2117 let driver = self.driver.as_mut().unwrap();
2118 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2119 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2120 self.emit(SessionEventWire::SystemNotice(
2121 "steering queue full (16 messages) — message dropped".into(),
2122 ));
2123 return;
2124 }
2125 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2126 self.emit(SessionEventWire::SystemNotice(
2127 "steering queue full (256 KiB) — message dropped".into(),
2128 ));
2129 return;
2130 }
2131 driver.steering.push_back(text.clone());
2132 driver.auto_wakes_blocked = false;
2134 self.emit(SessionEventWire::Steered {
2135 text,
2136 delivered: false,
2137 });
2138 return;
2139 }
2140 if self.compact.is_some() {
2141 self.emit(SessionEventWire::Steered {
2143 text: text.clone(),
2144 delivered: false,
2145 });
2146 self.conv.queued_message = Some(text);
2147 return;
2148 }
2149 self.consecutive_auto_turns = 0;
2151 let api_content = if let Some(ref ctx) = self.conv.abort_context {
2153 let combined = format!("{}\n\n{}", ctx, text);
2154 self.conv.abort_context = None;
2155 combined
2156 } else {
2157 text
2158 };
2159
2160 if attachments.is_empty() {
2161 self.conv.api_messages.push(std::sync::Arc::new(
2163 serde_json::json!({"role": "user", "content": api_content}),
2164 ));
2165 } else {
2166 let mut blocks = Vec::with_capacity(attachments.len() + 1);
2168 if !api_content.is_empty() {
2169 blocks.push(serde_json::json!({"type": "text", "text": api_content}));
2170 }
2171 blocks.extend(attachments);
2172 let candidate = std::sync::Arc::new(
2173 serde_json::json!({"role": "user", "content": blocks}),
2174 );
2175 let mut proposed = self.conv.api_messages.clone();
2178 proposed.push(candidate.clone());
2179 let model = self.runtime.model().to_string();
2180 if let Err(e) = crate::runtime::attachments::validate_messages(&model, &proposed) {
2181 match from {
2185 Some(client) => self.emit(SessionEventWire::Refused {
2186 client,
2187 command: "submit".into(),
2188 reason: format!("attachments rejected: {e}"),
2189 }),
2190 None => self.emit(SessionEventWire::SystemNotice(format!(
2191 "attachments rejected: {e}"
2192 ))),
2193 }
2194 return;
2195 }
2196 self.conv.api_messages.push(candidate);
2197 }
2198 self.start_turn(TurnTrigger::User, None).await;
2199 }
2200
2201 pub(crate) fn steer(&mut self, text: String) {
2203 let delivered = self
2204 .steer_tx
2205 .as_ref()
2206 .map(|tx| tx.send(text.clone()).is_ok())
2207 .unwrap_or(false);
2208 self.emit(SessionEventWire::Steered {
2209 text: text.clone(),
2210 delivered,
2211 });
2212 self.conv.queued_message = Some(text);
2213 }
2214
2215 pub(crate) async fn cancel_turn(&mut self) {
2220 if !self.streaming {
2221 if self.compact.is_some() {
2222 self.abort_compaction();
2223 if let Some(q) = self.conv.queued_message.take() {
2224 self.emit(SessionEventWire::Dequeued { text: q });
2225 }
2226 self.update_attach_state();
2227 }
2228 self.emit(SessionEventWire::Idle);
2229 return;
2230 }
2231 if let Some(ref ct) = self.cancel {
2232 ct.cancel();
2233 }
2234 self.conv.abort_context = self.turn_log.abort_context();
2235 if let Some(q) = self.conv.queued_message.take() {
2236 self.emit(SessionEventWire::Dequeued { text: q });
2237 }
2238 {
2240 let conv: &mut ConversationState = &mut self.conv;
2241 for formatted in conv.pending_events.drain(..) {
2242 conv.api_messages
2243 .push(std::sync::Arc::new(serde_json::json!({
2244 "role": "user",
2245 "content": formatted
2246 })));
2247 }
2248 }
2249 self.clear_stream();
2250 {
2253 let mut registry = match self.runtime.subagent_registry().lock() {
2254 Ok(g) => g,
2255 Err(poisoned) => {
2256 tracing::warn!(
2257 "subagent registry mutex poisoned during abort; recovering to cancel running handles"
2258 );
2259 poisoned.into_inner()
2260 }
2261 };
2262 for handle in registry.iter_mut_handles() {
2263 if handle.status() == crate::runtime::subagent::SubagentStatus::Running {
2264 handle.cancel();
2265 }
2266 }
2267 }
2268 self.emit(SessionEventWire::Aborted {
2270 context_saved: self.conv.abort_context.is_some(),
2271 });
2272 self.save().await;
2273 self.emit_conversation();
2274 self.emit(SessionEventWire::Idle);
2275 }
2276
2277 async fn on_queue_wake(&mut self) {
2280 if self.is_parked() {
2281 if self.event_queue.is_empty() {
2282 return;
2283 }
2284 if let Err(e) = self.ensure_live().await {
2285 tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
2286 return;
2287 }
2288 }
2289 let busy = self.busy();
2290 let conv: &mut ConversationState = &mut self.conv;
2291 let drained = drain_event_queue(
2292 &self.event_queue,
2293 &mut conv.api_messages,
2294 &mut conv.pending_events,
2295 busy,
2296 self.steer_tx.as_ref(),
2297 );
2298 if drained.is_empty() {
2299 return;
2300 }
2301 for de in &drained {
2302 self.emit(SessionEventWire::External(de.event.clone()));
2303 }
2304 let injected = drained
2305 .iter()
2306 .any(|d| d.disposition == EventDisposition::Injected);
2307 if injected || busy {
2308 self.emit_conversation();
2309 }
2310
2311 if self.streaming
2314 && self
2315 .driver
2316 .as_ref()
2317 .is_some_and(|d| d.awaiting_terminal)
2318 {
2319 let competing = drained
2320 .iter()
2321 .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
2322 if competing {
2323 self.driver_revoke("event-bus work took priority");
2324 }
2325 }
2326
2327 let auto_turn_enabled = self.host.config().events.auto_turn;
2332 let auto_turn_cap = self.host.config().events.auto_turn_cap;
2333 let action = wake_action_with_cap(
2334 &drained,
2335 &self.conv.api_messages,
2336 busy,
2337 auto_turn_enabled,
2338 self.consecutive_auto_turns,
2339 auto_turn_cap,
2340 );
2341 match action {
2342 WakeAction::RunTurn => {
2343 if self.driver.is_some() {
2346 tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
2347 } else if self.stream.is_some() {
2348 tracing::warn!("handle_event_arm: RunTurn with active stream — skipping");
2349 } else {
2350 self.consecutive_auto_turns += 1;
2351 self.start_turn(TurnTrigger::EventAuto, None).await;
2352 }
2353 }
2354 WakeAction::Forward => {
2355 let hit_cap = injected
2356 && !busy
2357 && auto_turn_enabled
2358 && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
2359 if hit_cap {
2360 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
2361 }
2362 }
2363 WakeAction::Nothing => {}
2364 }
2365 }
2366
2367 async fn on_stream_event(&mut self, event: StreamEvent) {
2370 self.emit(SessionEventWire::Stream(event.clone()));
2372
2373 if let Some(driver) = self.driver.as_mut() {
2375 if driver.awaiting_terminal && driver.grant.feedback_enabled() {
2376 driver.feedback.observe(&event);
2377 }
2378 }
2379
2380 let is_canceled = self
2382 .cancel
2383 .as_ref()
2384 .is_some_and(|ct| ct.is_cancelled());
2385 let terminal = if self
2386 .driver
2387 .as_ref()
2388 .is_some_and(|d| d.awaiting_terminal)
2389 {
2390 super::driver::capture_terminal(Some(&event), is_canceled)
2391 } else {
2392 None
2393 };
2394
2395 enum After {
2396 Continue,
2397 AutoSendQueued(String),
2398 AutoTriggerEvents,
2399 Failed,
2400 }
2401 let mut after = After::Continue;
2402
2403 match event {
2404 StreamEvent::Llm(LlmEvent::Thinking(text)) => self.turn_log.thinking(&text),
2405 StreamEvent::Llm(LlmEvent::Text(text)) => self.turn_log.text(&text),
2406 StreamEvent::Llm(LlmEvent::ToolUse {
2407 tool_name, input, ..
2408 }) => {
2409 let input_str = serde_json::to_string(&input).unwrap_or_default();
2410 self.turn_log.parts.push(TurnPart::ToolUse {
2411 name: tool_name,
2412 input: input_str,
2413 });
2414 }
2415 StreamEvent::Llm(LlmEvent::ToolResultDelta { tool_id, delta }) => {
2416 self.turn_log.tool_result_delta(tool_id, delta);
2417 }
2418 StreamEvent::Llm(LlmEvent::ToolResult { tool_id, result }) => {
2419 self.turn_log.tool_result(tool_id, result);
2420 }
2421 StreamEvent::Llm(_) => {}
2422 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
2423 self.conv.api_messages = history;
2424 self.save().await;
2425 self.emit_conversation();
2426 }
2427 StreamEvent::Agent(AgentEvent::SteeringDelivered { message }) => {
2428 if self.conv.queued_message.as_ref() == Some(&message) {
2429 self.conv.queued_message = None;
2430 self.emit_conversation();
2431 }
2432 if let Some(driver) = self.driver.as_mut() {
2434 if driver.steering.front() == Some(&message) {
2435 driver.steering.pop_front();
2436 }
2437 }
2438 }
2439 StreamEvent::Agent(_) => {}
2440 StreamEvent::Session(SessionEvent::Usage {
2441 input_tokens,
2442 output_tokens,
2443 cache_read_input_tokens,
2444 cache_creation_input_tokens,
2445 cache_creation_5m,
2446 cache_creation_1h,
2447 model: usage_model,
2448 }) => {
2449 let model_for_pricing = usage_model
2450 .as_deref()
2451 .unwrap_or(self.runtime.model())
2452 .to_string();
2453 self.conv.add_usage(
2454 input_tokens,
2455 output_tokens,
2456 cache_read_input_tokens,
2457 cache_creation_input_tokens,
2458 cache_creation_5m,
2459 cache_creation_1h,
2460 &model_for_pricing,
2461 );
2462 if let Some((scope, cost, cap)) = self.cost_cap_breach() {
2467 self.emit(SessionEventWire::CostCapReached {
2468 scope: scope.to_string(),
2469 cost,
2470 cap,
2471 });
2472 if self.driver.is_some() || self.driver_pending.is_some() {
2473 self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
2474 }
2475 self.cancel_turn().await;
2476 return;
2477 }
2478 }
2479 StreamEvent::Session(SessionEvent::Notice(_)) => {}
2480 StreamEvent::Session(SessionEvent::Done) => {
2481 self.clear_stream();
2482 self.publish_subagent_rows();
2483 let had_pending = !self.conv.pending_events.is_empty();
2485 {
2486 let conv: &mut ConversationState = &mut self.conv;
2487 for formatted in conv.pending_events.drain(..) {
2488 conv.api_messages
2489 .push(std::sync::Arc::new(serde_json::json!({
2490 "role": "user",
2491 "content": formatted
2492 })));
2493 }
2494 }
2495 if let Some(queued) = self.conv.queued_message.take() {
2496 after = After::AutoSendQueued(queued);
2497 } else if had_pending {
2498 self.save().await;
2499 after = After::AutoTriggerEvents;
2500 }
2501 self.emit_conversation();
2502 if matches!(after, After::Continue) {
2503 if self.config.auto_compact {
2504 self.post_turn_chat().await;
2505 }
2506 if self.compact.is_none() {
2508 self.emit(SessionEventWire::Idle);
2509 }
2510 }
2511 }
2512 StreamEvent::Session(SessionEvent::Error(_)) => {
2513 self.clear_stream();
2514 self.publish_subagent_rows();
2515 crate::engine::stream::repair_history_after_failure(
2517 &mut self.conv.api_messages,
2518 self.turn_baseline,
2519 );
2520 self.emit_conversation();
2521 after = After::Failed;
2522 }
2523 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
2524 session_id,
2525 messages,
2526 receipt,
2527 }) => {
2528 self.handle_context_head_checkpoint(session_id, messages, receipt)
2529 .await;
2530 }
2531 }
2532
2533 match after {
2534 After::Continue => {}
2535 After::Failed => {
2536 if self.config.auto_compact {
2537 self.post_turn_chat().await;
2538 }
2539 if self.compact.is_none() {
2540 self.emit(SessionEventWire::Idle);
2541 }
2542 }
2543 After::AutoSendQueued(queued) => {
2544 if self.config.auto_compact {
2545 self.post_turn_chat().await;
2546 }
2547 if self.compact.is_some() {
2548 self.conv.queued_message = Some(queued);
2551 return;
2552 }
2553 self.consecutive_auto_turns = 0;
2555 let user_text = queued.clone();
2556 let api_content = if let Some(ref ctx) = self.conv.abort_context {
2557 let combined = format!("{}\n\n{}", ctx, queued);
2558 self.conv.abort_context = None;
2559 combined
2560 } else {
2561 queued
2562 };
2563 self.conv.api_messages.push(std::sync::Arc::new(
2564 serde_json::json!({"role": "user", "content": api_content}),
2565 ));
2566 self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
2567 }
2568 After::AutoTriggerEvents => {
2569 if self.config.auto_compact {
2570 self.post_turn_chat().await;
2571 }
2572 if self.compact.is_some() {
2573 return;
2574 }
2575 let auto_turn_cap = self.host.config().events.auto_turn_cap;
2578 if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
2579 self.start_turn(TurnTrigger::EventAuto, None).await;
2580 } else {
2581 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
2582 self.emit(SessionEventWire::Idle);
2583 }
2584 }
2585 }
2586
2587 if let Some(terminal) = terminal {
2589 let revoke_reason = if let Some(driver) = self.driver.as_mut() {
2590 super::driver::observe_terminal(driver, &self.runtime, terminal)
2591 } else {
2592 None
2593 };
2594 if let Some(driver) = self.driver.as_ref() {
2596 if let Some((outcome, _)) = &driver.outcome {
2597 self.emit(SessionEventWire::DriverTurnOutcome {
2598 outcome: *outcome,
2599 selection: driver.selection.clone(),
2600 feedback: driver.grant.feedback_enabled().then(|| {
2601 driver.completed_feedback.to_string()
2602 }),
2603 });
2604 }
2605 }
2606 if let Some(reason) = revoke_reason {
2607 self.driver_revoke(&reason);
2608 }
2609 }
2610 }
2611
2612 async fn post_turn_chat(&mut self) {
2614 self.save().await;
2615 let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
2616 if assessment.should_compact() {
2617 self.emit(SessionEventWire::SystemNotice(format!(
2618 "[auto-compacting ~{} tokens...]",
2619 assessment.used_tokens()
2620 )));
2621 self.compact(None, "auto").await;
2622 }
2623 }
2624
2625 fn compact_inline_env() -> bool {
2628 matches!(
2629 std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
2630 Ok("1") | Ok("true")
2631 )
2632 }
2633
2634 pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
2641 if Self::compact_inline_env() {
2642 return self.compact_inline(instructions, source).await;
2643 }
2644 if self.compact.is_some() {
2645 self.emit(SessionEventWire::SystemNotice(
2646 "compaction already in progress".into(),
2647 ));
2648 return;
2649 }
2650 if self.streaming {
2651 self.emit(SessionEventWire::SystemNotice(
2652 "cannot compact while a turn is running".into(),
2653 ));
2654 return;
2655 }
2656 let disclosure =
2657 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
2658 self.emit(SessionEventWire::CompactionStarted {
2659 source: source.to_string(),
2660 disclosure,
2661 });
2662 let msgs = self.conv.api_messages.clone();
2663 let rt: Runtime = (*self.runtime).clone();
2664 let task = tokio::spawn(async move {
2665 compact_conversation(&msgs, &rt, instructions.as_deref()).await
2666 });
2667 self.compact = Some(CompactionJob {
2668 task,
2669 source: source.to_string(),
2670 started: tokio::time::Instant::now(),
2671 });
2672 self.update_attach_state();
2673 }
2674
2675 async fn on_compaction_done(
2679 &mut self,
2680 res: std::result::Result<
2681 Result<crate::runtime::compaction::CompactionOutcome>,
2682 tokio::task::JoinError,
2683 >,
2684 ) {
2685 let Some(job) = self.compact.take() else {
2686 return;
2687 };
2688 let msg_count = self.conv.api_messages.len();
2689 match res {
2690 Err(join) => {
2691 if !join.is_cancelled() {
2692 self.emit(SessionEventWire::CompactionFailed {
2693 message: join.to_string(),
2694 panicked: true,
2695 });
2696 }
2697 }
2698 Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
2699 message: e.to_string(),
2700 panicked: false,
2701 }),
2702 Ok(Ok(outcome)) => {
2703 let policy: CompactionPolicy = self.config.compaction_policy.into();
2704 let queued = self.conv.queued_message.clone();
2705 let applied = apply_compaction(
2706 &self.runtime,
2707 &self.conv.session,
2708 &self.conv.api_messages,
2709 &outcome,
2710 CompactionTransition {
2711 policy,
2712 pending_events: self.conv.pending_events.clone(),
2713 queued_message: queued.clone(),
2714 hook_source: job.source.clone(),
2715 },
2716 )
2717 .await;
2718 match applied {
2719 Ok(applied) => {
2720 let previous = applied.previous_session_id.clone();
2721 self.conv.session = applied.session;
2722 self.conv.api_messages = applied.api_messages;
2723 self.conv.pending_events.clear();
2724 self.conv.queued_message = None;
2725 if policy == CompactionPolicy::LinkedSuccessor {
2726 self.conv.total_input_tokens = 0;
2727 self.conv.total_output_tokens = 0;
2728 self.conv.session_cost = 0.0;
2729 }
2730 let new_id = self.conv.session.id.clone();
2731 self.runtime.set_session_id(Some(new_id.clone()));
2732 self.journal_id.store(Arc::new(new_id.clone()));
2733 self.reacquire_session_lock(&new_id);
2735 self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
2739 self.runtime
2740 .reset_context_continuation(&new_id, &self.conv.api_messages);
2741 self.emit(SessionEventWire::CompactionApplied {
2742 previous_session_id: previous,
2743 session_id: new_id,
2744 chains_advanced: applied.chains_advanced,
2745 queued_restored: queued,
2746 msg_count,
2747 });
2748 }
2749 Err(e) => self.emit(SessionEventWire::CompactionFailed {
2750 message: e.to_string(),
2751 panicked: false,
2752 }),
2753 }
2754 }
2755 }
2756 self.emit_conversation();
2757 self.update_attach_state();
2758 self.emit(SessionEventWire::Idle);
2759 }
2760
2761 async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
2763 self.emit(SessionEventWire::SystemNotice(format!(
2764 "[{}]",
2765 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
2766 )));
2767 let outcome =
2768 compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
2769 .await;
2770 let applied = match outcome {
2771 Ok(outcome) => {
2772 apply_compaction(
2773 &self.runtime,
2774 &self.conv.session,
2775 &self.conv.api_messages,
2776 &outcome,
2777 CompactionTransition {
2778 policy: CompactionPolicy::InPlace,
2779 pending_events: Vec::new(),
2780 queued_message: None,
2781 hook_source: source.to_string(),
2782 },
2783 )
2784 .await
2785 }
2786 Err(e) => Err(e),
2787 };
2788 match applied {
2789 Ok(applied) => {
2790 self.conv.session = applied.session;
2791 self.conv.api_messages = applied.api_messages;
2792 let after = self.runtime.assess_context(&self.conv.api_messages).await;
2793 self.emit(SessionEventWire::SystemNotice(format!(
2794 "[compacted → ~{} tokens]",
2795 after.used_tokens()
2796 )));
2797 }
2798 Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
2799 "[compaction failed: {}]",
2800 e
2801 ))),
2802 }
2803 self.emit_conversation();
2804 }
2805
2806 fn on_prompt_request(&mut self, req: SecretPromptRequest) {
2809 let id = self.next_prompt_id;
2810 self.next_prompt_id += 1;
2811 let pr = PromptRequest {
2812 id,
2813 kind: PromptKind::from_title(&req.title),
2814 title: req.title,
2815 prompt: req.prompt,
2816 raised_at: chrono::Utc::now(),
2817 };
2818 self.pending_prompts.push_back((pr.clone(), req.response_tx));
2819 self.publish_presence();
2820 self.emit(SessionEventWire::Prompt(pr));
2821 self.rearm_prompt_abandon();
2824 }
2825
2826 fn answer(&mut self, prompt_id: u64, value: Option<String>) {
2827 let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
2828 return; };
2830 let (_, tx) = self.pending_prompts.remove(pos).expect("position");
2831 let _ = tx.send(value);
2832 self.publish_presence();
2833 self.emit(SessionEventWire::PromptResolved { prompt_id });
2834 self.rearm_prompt_abandon();
2836 }
2837
2838 pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
2841 if replayable(&setting) {
2842 let disc = std::mem::discriminant(&setting);
2843 self.settings_replay
2844 .retain(|s| std::mem::discriminant(s) != disc);
2845 self.settings_replay.push(setting.clone());
2846 }
2847 let name = match &setting {
2848 SessionSetting::Model { .. } => "model",
2849 SessionSetting::ReasoningLevel { .. } => "reasoning_level",
2850 SessionSetting::ContextWindow { .. } => "context_window",
2851 SessionSetting::CompactionModel { .. } => "compaction_model",
2852 SessionSetting::ApiRetries { .. } => "api_retries",
2853 SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
2854 SessionSetting::MaxToolOutput { .. } => "max_tool_output",
2855 SessionSetting::BashTimeout { .. } => "bash_timeout",
2856 SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
2857 SessionSetting::SystemPrompt { .. } => "system_prompt",
2858 SessionSetting::ReloadPrompt => "reload_prompt",
2859 SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
2860 };
2861 let rt = &mut self.runtime;
2862 let mut clamp_wire = None;
2863 let result: std::result::Result<Option<String>, String> = match setting {
2864 SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
2865 self.conv.session.model = rt.model().to_string();
2866 clamp.map(|c| {
2867 self.conv.session.thinking_level = rt.thinking_level().to_string();
2868 clamp_wire = Some(ReasoningClampWire {
2869 from: c.from.as_str().to_string(),
2870 to: c.to.as_str().to_string(),
2871 });
2872 format!(
2873 "thinking → {} (clamped from {}: not supported by {})",
2874 c.to.as_str(),
2875 c.from.as_str(),
2876 rt.model()
2877 )
2878 })
2879 }),
2880 SessionSetting::ReasoningLevel { level } => {
2881 rt.set_reasoning_level_checked(level).map(|_| {
2882 self.conv.session.thinking_level = rt.thinking_level().to_string();
2883 None
2884 })
2885 }
2886 SessionSetting::ContextWindow { tokens } => {
2887 rt.set_context_window(tokens);
2888 Ok(None)
2889 }
2890 SessionSetting::CompactionModel { model } => {
2891 rt.set_compaction_model(model);
2892 Ok(None)
2893 }
2894 SessionSetting::ApiRetries { n } => {
2895 rt.set_api_retries(n);
2896 Ok(None)
2897 }
2898 SessionSetting::SubagentTimeout { secs } => {
2899 rt.set_subagent_timeout(secs);
2900 Ok(None)
2901 }
2902 SessionSetting::MaxToolOutput { bytes } => {
2903 rt.set_max_tool_output(bytes);
2904 Ok(None)
2905 }
2906 SessionSetting::BashTimeout { secs } => {
2907 rt.set_bash_timeout(secs);
2908 Ok(None)
2909 }
2910 SessionSetting::BashMaxTimeout { secs } => {
2911 rt.set_bash_max_timeout(secs);
2912 Ok(None)
2913 }
2914 SessionSetting::SystemPrompt { text } => {
2915 rt.set_system_prompt(text);
2916 Ok(None)
2917 }
2918 SessionSetting::ReloadPrompt => rt
2919 .reload_prompt()
2920 .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
2921 .map_err(|e| e.to_string()),
2922 SessionSetting::GrantWorkerModel { model } => {
2923 rt.grant_worker_model(&model).map(|_| None)
2924 }
2925 };
2926 self.publish_view().await;
2927 let view = (**self.view.load()).clone();
2928 let (ok, message) = match result {
2929 Ok(m) => (true, m),
2930 Err(e) => (false, Some(e)),
2931 };
2932 self.emit(SessionEventWire::SettingChanged(SettingApplied {
2933 id,
2934 setting: name.to_string(),
2935 ok,
2936 message,
2937 view,
2938 clamp: clamp_wire,
2939 }));
2940 }
2941
2942 pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
2943 if self.is_parked() {
2945 let value = match query {
2946 SessionQuery::Status => serde_json::json!({
2947 "session": (**self.journal_id.load()).clone(),
2948 "model": self.view.load().model,
2949 "lifecycle": SessionLifecycle::Parked,
2950 "streaming": false,
2951 "auto_turns": self.consecutive_auto_turns,
2952 "attached": 0,
2953 "pending_prompts": 0,
2954 }),
2955 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
2956 _ => match self.ensure_live().await {
2957 Ok(()) => return Box::pin(self.query(id, query)).await,
2958 Err(e) => serde_json::json!({ "error": e }),
2959 },
2960 };
2961 self.emit(SessionEventWire::QueryResult { id, value });
2962 return;
2963 }
2964 let value = match query {
2965 SessionQuery::Status => serde_json::json!({
2966 "session": self.conv.session.id,
2967 "model": self.runtime.model(),
2968 "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
2969 "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
2970 "cost": self.conv.session_cost,
2971 "messages": self.conv.api_messages.len(),
2972 "streaming": self.streaming,
2973 "auto_turns": self.consecutive_auto_turns,
2974 "attached": self.attached.len(),
2975 "pending_prompts": self.pending_prompts.len(),
2976 }),
2977 SessionQuery::Messages => {
2978 serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
2979 }
2980 SessionQuery::DisplayTail { items } => {
2981 serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
2982 .unwrap_or_default()
2983 }
2984 SessionQuery::SubagentRows => {
2985 let rows = self
2986 .runtime
2987 .subagent_registry()
2988 .lock()
2989 .unwrap_or_else(|p| p.into_inner())
2990 .display_rows();
2991 serde_json::Value::Array(
2992 rows.iter()
2993 .map(|r| {
2994 serde_json::json!({
2995 "subagent_id": r.subagent_id,
2996 "agent_name": r.agent_name,
2997 "status": format!("{:?}", r.status),
2998 "cancel_requested": r.cancel_requested,
2999 "elapsed_secs": r.elapsed_secs,
3000 })
3001 })
3002 .collect(),
3003 )
3004 }
3005 SessionQuery::PromptInspection => {
3006 serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
3007 }
3008 SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
3009 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
3010 SessionQuery::ContextAssessment => {
3011 let a = self.runtime.assess_context(&self.conv.api_messages).await;
3012 serde_json::json!({
3013 "used_tokens": a.used_tokens(),
3014 "budget_tokens": a.budget_tokens(),
3015 "provider_window": a.provider_window,
3016 "should_compact": a.should_compact(),
3017 })
3018 }
3019 SessionQuery::ContextReport => {
3020 use crate::engine::commands::{context_command, CommandResult};
3021 match context_command(&self.runtime, Some(&self.conv.api_messages)) {
3022 CommandResult::Output(text) => serde_json::json!({ "text": text }),
3023 other => serde_json::json!({ "unsupported": format!("{other:?}") }),
3024 }
3025 }
3026 };
3027 self.emit(SessionEventWire::QueryResult { id, value });
3028 }
3029
3030 pub(crate) fn snapshot(&self) -> AttachSnapshot {
3033 AttachSnapshot {
3034 meta: self.meta.clone(),
3035 view: (**self.view.load()).clone(),
3036 conversation: self.conv.snapshot(self.consecutive_auto_turns),
3037 streaming: self.streaming,
3038 replay: self.turn_replay.iter().cloned().collect(),
3039 pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
3040 clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
3041 input_owner: self.input_owner,
3042 display_tail: None,
3043 }
3044 }
3045
3046 pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
3051 let mut snap = self.snapshot();
3052 if client.history == HistoryMode::Digest {
3053 snap.display_tail = Some(super::display::display_tail(
3054 &snap.conversation.api_messages,
3055 client.tail_items,
3056 ));
3057 snap.conversation.api_messages = Vec::new();
3058 snap.replay.retain(|e| {
3059 !matches!(
3060 e.event,
3061 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
3062 )
3063 });
3064 }
3065 snap
3066 }
3067
3068 async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
3073 if let Err(e) = self.ensure_live().await {
3074 self.emit(SessionEventWire::AttachRefused { message: e });
3075 return;
3076 }
3077 let cid = ClientId(self.next_client_id);
3078 self.next_client_id += 1;
3079 let kind = client.kind;
3080 let snapshot_meta = client.clone();
3081 self.attached.insert(cid, AttachedClient { meta: client, mode });
3082 self.update_attach_state();
3083 let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
3084 let mut notice: Option<String> = None;
3085 match mode {
3086 AttachMode::Observe => {}
3087 AttachMode::Mirror => match self.input_owner {
3088 None => owner_change = Some((None, OwnerChangeReason::Attach)),
3089 Some(owner) => {
3090 let owner_kind = self
3091 .attached
3092 .get(&owner)
3093 .map(|a| format!("{:?}", a.meta.kind).to_lowercase())
3094 .unwrap_or_else(|| "?".into());
3095 notice = Some(format!(
3096 "input is owned by client #{} ({}); attach with --takeover to steal it",
3097 owner.0, owner_kind
3098 ));
3099 }
3100 },
3101 AttachMode::Takeover => {
3102 let reason = if self.input_owner.is_some() {
3103 OwnerChangeReason::Takeover
3104 } else {
3105 OwnerChangeReason::Attach
3106 };
3107 owner_change = Some((self.input_owner, reason));
3108 }
3109 }
3110 if let Some((from, reason)) = owner_change {
3111 self.input_owner = Some(cid);
3112 self.emit(SessionEventWire::InputOwnerChanged {
3113 from,
3114 to: Some(cid),
3115 reason,
3116 });
3117 }
3118 self.publish_presence();
3119 let snapshot = self.snapshot_for(&snapshot_meta);
3120 self.emit(SessionEventWire::Attached {
3121 client: cid,
3122 snapshot,
3123 });
3124 self.emit(SessionEventWire::ClientJoined { client: cid, kind });
3125 if let Some(n) = notice {
3126 self.emit(SessionEventWire::SystemNotice(n));
3127 }
3128 }
3129
3130 fn detach(&mut self, client: ClientId) {
3134 if self.attached.remove(&client).is_none() {
3135 return;
3136 }
3137 self.update_attach_state();
3138 self.emit(SessionEventWire::ClientLeft { client });
3139 if self.input_owner == Some(client) {
3140 let next = self
3141 .attached
3142 .iter()
3143 .filter(|(_, a)| a.mode != AttachMode::Observe)
3144 .map(|(c, _)| *c)
3145 .min_by_key(|c| c.0);
3146 self.input_owner = next;
3147 self.emit(SessionEventWire::InputOwnerChanged {
3148 from: Some(client),
3149 to: next,
3150 reason: OwnerChangeReason::OwnerDetached,
3151 });
3152 self.publish_presence();
3153 }
3154 if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
3171 self.driver_revoke("no clients attached");
3172 let had_prompts = !self.pending_prompts.is_empty();
3173 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3174 let _ = tx.send(None);
3175 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3176 }
3177 if had_prompts {
3178 self.publish_presence();
3179 }
3180 }
3181 }
3182
3183 pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
3188 if self.driver.is_some() {
3190 self.driver_revoke("daemon reloaded");
3191 }
3192 if self.streaming {
3193 self.cancel_turn().await;
3194 }
3195 self.abort_compaction();
3196 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3197 let _ = tx.send(None);
3198 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3199 }
3200 if tokio::time::timeout(budgets::SAVE_TIMEOUT, self.save())
3201 .await
3202 .is_err()
3203 {
3204 tracing::warn!("checkpoint: save timed out");
3205 }
3206 let notice = match reason {
3207 CheckpointReason::Reload => {
3208 "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
3209 }
3210 CheckpointReason::HostRequest => {
3211 "checkpoint — background shells/PTYs closed; the turn was checkpointed"
3212 }
3213 };
3214 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
3215 self.runtime.session_manager().shutdown_all();
3216 let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3217 self.emit(SessionEventWire::QueryResult {
3218 id: super::wire::CHECKPOINT_QUERY_ID,
3219 value: serde_json::json!({ "ok": true, "record": record }),
3220 });
3221 }
3222
3223 pub(crate) fn abort_compaction(&mut self) {
3225 if let Some(job) = self.compact.take() {
3226 job.task.abort();
3227 self.emit(SessionEventWire::CompactionCancelled);
3228 }
3229 }
3230
3231 async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
3236 use std::ops::ControlFlow;
3237 let Addressed { from, cmd } = addressed;
3238 if let Some(c) = from {
3239 if is_input_command(&cmd) && self.input_owner != Some(c) {
3240 let reason = match self.input_owner {
3241 Some(o) => format!("input owned by client #{}", o.0),
3242 None => "input has no owner; attach with --takeover".to_string(),
3243 };
3244 self.emit(SessionEventWire::Refused {
3245 client: c,
3246 command: command_name(&cmd).to_string(),
3247 reason,
3248 });
3249 return ControlFlow::Continue(());
3250 }
3251 }
3252 if self.is_parked() {
3254 let needs_runtime = !matches!(
3255 cmd,
3256 SessionCommand::Attach { .. }
3257 | SessionCommand::Detach { .. }
3258 | SessionCommand::End { .. }
3259 | SessionCommand::Query { .. }
3260 | SessionCommand::Answer { .. }
3261 | SessionCommand::Save
3262 | SessionCommand::Cancel
3263 | SessionCommand::Checkpoint { .. }
3264 | SessionCommand::Park
3265 | SessionCommand::Resync { .. }
3266 | SessionCommand::HostEvent(_)
3267 );
3268 if matches!(cmd, SessionCommand::HostEvent(_)) {
3269 return ControlFlow::Continue(()); }
3271 if matches!(
3272 cmd,
3273 SessionCommand::Save
3274 | SessionCommand::Cancel
3275 | SessionCommand::Park
3276 | SessionCommand::Checkpoint { .. }
3277 ) {
3278 if let SessionCommand::Checkpoint { .. } = cmd {
3279 let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3280 self.emit(SessionEventWire::QueryResult {
3281 id: super::wire::CHECKPOINT_QUERY_ID,
3282 value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
3283 });
3284 }
3285 return ControlFlow::Continue(()); }
3287 if needs_runtime {
3288 if let Err(e) = self.ensure_live().await {
3289 match from {
3290 Some(c) => self.emit(SessionEventWire::Refused {
3291 client: c,
3292 command: command_name(&cmd).to_string(),
3293 reason: e,
3294 }),
3295 None => self.emit(SessionEventWire::SystemNotice(e)),
3296 }
3297 return ControlFlow::Continue(());
3298 }
3299 }
3300 }
3301 match cmd {
3302 SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
3303 SessionCommand::Steer { text } => {
3304 if self.streaming {
3305 self.steer(text)
3306 } else {
3307 self.submit(text, vec![], from).await
3308 }
3309 }
3310 SessionCommand::Cancel => {
3311 if self.driver.is_some() {
3312 self.driver_revoke("canceled");
3313 }
3314 self.cancel_turn().await;
3315 }
3316 SessionCommand::Answer { prompt_id, value } => self.answer(prompt_id, value),
3317 SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
3318 SessionCommand::Compact { instructions } => {
3320 self.compact(instructions, "manual").await;
3321 }
3322 SessionCommand::NewSession => {
3323 if self.driver.is_some() {
3324 self.driver_revoke("session replaced");
3325 }
3326 self.conv.clear(&self.runtime).await;
3327 self.runtime
3328 .set_session_id(Some(self.conv.session.id.clone()));
3329 self.journal_id.store(Arc::new(self.conv.session.id.clone()));
3330 self.reacquire_session_lock(&self.conv.session.id.clone());
3332 self.emit(SessionEventWire::Cleared {
3333 session_id: self.conv.session.id.clone(),
3334 });
3335 self.emit_conversation();
3336 }
3337 SessionCommand::Save => self.save().await,
3338 SessionCommand::Query { id, query } => self.query(id, query).await,
3339 SessionCommand::EngineCommand { id, name, arg } => {
3340 self.engine_command(id, name, arg).await
3341 }
3342 SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
3343 SessionCommand::Detach { client } => self.detach(client),
3344 SessionCommand::End { reason } => {
3345 if self.driver.is_some() {
3346 self.driver_revoke("session ending");
3347 }
3348 return ControlFlow::Break(reason);
3349 }
3350 SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
3351 "resync not supported yet".into(),
3352 )),
3353 SessionCommand::SubmitPrepared {
3356 messages,
3357 user_text,
3358 } => self.submit_prepared(messages, user_text).await,
3359 SessionCommand::PluginCommand {
3360 id,
3361 plugin,
3362 name,
3363 arg,
3364 } => self.plugin_command(id, plugin, name, arg).await,
3365 SessionCommand::Resume { id, query } => self.resume(id, query).await,
3366 SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
3367 SessionCommand::Park => {
3368 if let std::ops::ControlFlow::Break(reason) = self.park().await {
3369 return ControlFlow::Break(reason);
3370 }
3371 }
3372 SessionCommand::KeepWarm { on } => {
3373 self.keep_warm = on;
3374 self.rearm_park();
3375 self.emit(SessionEventWire::SystemNotice(format!(
3376 "keep-warm {}",
3377 if on { "on: this session will not be parked" } else { "off" }
3378 )));
3379 }
3380 SessionCommand::HostEvent(ev) => match ev {
3381 HostEvent::ExtensionNotification {
3382 extension_id,
3383 method,
3384 params,
3385 } => self.emit(SessionEventWire::ExtensionNotification {
3386 extension_id,
3387 method,
3388 params,
3389 }),
3390 HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
3391 },
3392 SessionCommand::DriverStart { plugin, command, arg } => {
3394 self.driver_start(plugin, command, arg);
3395 }
3396 }
3397 ControlFlow::Continue(())
3398 }
3399
3400 async fn finish(&mut self, reason: EndReason) {
3403 self.lifecycle.store(
3404 SessionLifecycle::Ending as u8,
3405 std::sync::atomic::Ordering::Release,
3406 );
3407 if self.driver.is_some() {
3409 self.driver_revoke("session ending");
3410 }
3411 if self.streaming {
3412 self.cancel_turn().await;
3413 }
3414 self.abort_compaction();
3415 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3417 let _ = tx.send(None);
3418 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3419 }
3420
3421 let parked = !self.conv.is_live();
3422 let session_id = (**self.journal_id.load()).clone();
3423 let api_messages = if parked {
3424 None
3425 } else {
3426 Some(self.conv.api_messages.clone())
3427 };
3428
3429 let persist = self.config.persist;
3432 let save_fut = async {
3433 if persist {
3434 if !parked {
3435 self.conv.save().await;
3436 }
3437 let mut index_record =
3438 crate::core::session_index::SessionIndexRecord::end(&session_id);
3439 index_record.turns = api_messages.as_ref().map(|m| m.len());
3440 if let Err(err) = crate::core::session_index::append_record(&index_record) {
3441 tracing::warn!("failed to append session end index record: {}", err);
3442 }
3443 }
3444 };
3445 if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
3446 .await
3447 .is_err()
3448 {
3449 tracing::warn!(
3450 budget_secs = budgets::SAVE_TIMEOUT_SECS,
3451 "session save timed out — data may be incomplete"
3452 );
3453 }
3454
3455 let hook_bus = Arc::clone(&self.hook_bus);
3458 crate::extensions::loader::emit_session_end(
3459 &hook_bus,
3460 &session_id,
3461 api_messages,
3462 budgets::HOOKS_TIMEOUT,
3463 )
3464 .await;
3465
3466 if self.runtime.is_live() {
3468 if let Some(outcome) = self
3469 .runtime
3470 .shutdown_observability_async(
3471 crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
3472 )
3473 .await
3474 {
3475 if !outcome.is_flushed() {
3476 tracing::warn!(
3477 stats = ?outcome.stats(),
3478 "observability flush timed out — detached worker keeps draining"
3479 );
3480 }
3481 }
3482 }
3483
3484 self.background.shutdown();
3486 self.emit(SessionEventWire::Ended { reason });
3487 }
3488
3489 pub fn id(&self) -> &SessionId {
3490 &self.id
3491 }
3492}
3493
3494pub struct SessionTask(SessionActor);
3496
3497async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
3498 match stream {
3499 Some(s) => s.next().await,
3500 None => std::future::pending().await,
3501 }
3502}
3503
3504async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
3505 match tick {
3506 Some(t) => {
3507 t.tick().await;
3508 }
3509 None => std::future::pending().await,
3510 }
3511}
3512
3513async fn poll_compaction(
3514 job: &mut Option<CompactionJob>,
3515) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
3516 match job {
3517 Some(j) => (&mut j.task).await,
3518 None => std::future::pending().await,
3519 }
3520}
3521
3522async fn park_timer(deadline: Option<tokio::time::Instant>) {
3523 match deadline {
3524 Some(t) => tokio::time::sleep_until(t).await,
3525 None => std::future::pending().await,
3526 }
3527}
3528
3529async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
3532 match deadline {
3533 Some(t) => tokio::time::sleep_until(t).await,
3534 None => std::future::pending().await,
3535 }
3536}
3537
3538async fn driver_tick_timer(
3540 has_driver: bool,
3541 has_pending: bool,
3542 interval: &mut tokio::time::Interval,
3543) {
3544 if has_driver || has_pending {
3545 interval.tick().await;
3546 } else {
3547 std::future::pending().await
3548 }
3549}
3550
3551async fn ext_ready(rx: &mut Option<oneshot::Receiver<()>>) {
3552 match rx {
3553 Some(r) => {
3554 let _ = r.await;
3555 }
3556 None => std::future::pending().await,
3557 }
3558}
3559
3560impl SessionTask {
3561 pub fn id(&self) -> &SessionId {
3562 self.0.id()
3563 }
3564
3565 pub async fn run(mut self) {
3567 let queue = Arc::clone(&self.0.event_queue);
3568 let reason = loop {
3569 let actor = &mut self.0;
3570 tokio::select! {
3571 cmd = actor.cmd_rx.recv() => match cmd {
3572 Some(cmd) => {
3573 if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
3574 break reason;
3575 }
3576 }
3577 None => break EndReason::HostShutdown,
3579 },
3580 Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
3581 _ = queue.notified() => actor.on_queue_wake().await,
3582 _ = next_tick(&mut actor.subagent_tick) => actor.publish_subagent_rows(),
3583 _ = park_timer(actor.park_deadline) => {
3584 if let std::ops::ControlFlow::Break(reason) = actor.park().await {
3585 break reason;
3586 }
3587 }
3588 _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
3589 actor.on_prompt_abandon_deadline();
3590 }
3591 res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
3592 _ = ext_ready(&mut actor.ext_ready) => {
3593 actor.ext_ready = None;
3594 let id = (**actor.journal_id.load()).clone();
3595 let bus = Arc::clone(&actor.hook_bus);
3596 crate::extensions::loader::emit_session_start(&bus, &id).await;
3597 if actor.runtime.is_live() {
3598 actor.publish_view().await;
3599 }
3600 },
3601 _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval) => {
3602 actor.driver_tick().await;
3603 }
3604 ev = next_stream_event(&mut actor.stream) => match ev {
3605 Some(ev) => actor.on_stream_event(ev).await,
3606 None => {
3607 if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
3610 let terminal = super::driver::capture_terminal(None, false);
3611 if let Some(terminal) = terminal {
3612 if let Some(driver) = actor.driver.as_mut() {
3613 let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
3614 let sel = driver.selection.clone();
3620 let fb = driver.grant.feedback_enabled()
3621 .then(|| driver.completed_feedback.to_string());
3622 actor.emit(SessionEventWire::DriverTurnOutcome {
3623 outcome: crate::extensions::session_driver::Outcome::Blocked,
3624 selection: sel,
3625 feedback: fb,
3626 });
3627 if let Some(reason) = reason {
3628 actor.driver_revoke(&reason);
3629 }
3630 }
3631 }
3632 }
3633 actor.clear_stream();
3634 actor.emit_conversation();
3635 actor.emit(SessionEventWire::Idle);
3636 }
3637 },
3638 }
3639 };
3640 self.0.finish(reason).await;
3641 }
3642}
3643
3644#[cfg(test)]
3645mod abort_context_tests {
3646 use super::{TurnLog, TurnPart};
3647
3648 fn log(parts: Vec<TurnPart>) -> TurnLog {
3649 TurnLog { parts }
3650 }
3651
3652 #[test]
3655 fn abort_context_uses_prose_not_protocol_tags() {
3656 let ctx = log(vec![
3657 TurnPart::Thinking("weighing options".into()),
3658 TurnPart::Text("Here is the".into()),
3659 TurnPart::ToolUse { name: "bash".into(), input: "{\"command\":\"ls\"}".into() },
3660 TurnPart::ToolResult { tool_id: "t1".into(), content: "a b c".into() },
3661 ])
3662 .abort_context()
3663 .expect("non-empty log yields context");
3664
3665 for forbidden in ["[tool_use]", "[tool_result]", "[response]", "[thinking]"] {
3666 assert!(!ctx.contains(forbidden), "protocol tag leaked: {forbidden} in {ctx}");
3667 }
3668 assert!(ctx.contains("ABORT CONTEXT"));
3670 assert!(ctx.contains("System note"));
3671 assert!(ctx.contains("not new instructions"));
3672 assert!(ctx.contains("bash"));
3674 assert!(ctx.contains("a b c"));
3675 }
3676
3677 #[test]
3678 fn empty_log_yields_no_context() {
3679 assert!(log(vec![]).abort_context().is_none());
3680 assert!(log(vec![TurnPart::Text(String::new())]).abort_context().is_none());
3681 }
3682}
3683
3684#[cfg(test)]
3685mod parked_evict_tests {
3686 use super::parked_evict_after_from;
3687 use super::prompt_abandon_timeout_from;
3688 use std::time::Duration;
3689
3690 #[test]
3691 fn parked_evict_defaults_to_one_hour_and_honours_never() {
3692 let d = 3600u64;
3694 assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
3695 assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
3696 for never in ["never", "0", "off"] {
3697 assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
3698 }
3699 assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
3700 assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
3702 assert_eq!(parked_evict_after_from(None, 0), None);
3703 assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
3704 }
3705
3706 #[test]
3707 fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
3708 let d = 3600u64;
3710 assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
3711 assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
3712 for never in ["never", "0", "off"] {
3714 assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
3715 }
3716 assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
3718 assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
3720 assert_eq!(prompt_abandon_timeout_from(None, 0), None);
3721 assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
3722 }
3723}