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, 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)]
62pub(crate) struct TurnDraftState {
63 open: Option<String>,
65 base_len: usize,
67 text: String,
69 dirty: bool,
71}
72
73#[derive(Default)]
76pub(crate) struct CancelDrain {
77 pub(crate) history: Option<Vec<crate::SharedMessage>>,
79 pub(crate) closed: bool,
81}
82
83pub(crate) struct Live<T>(Option<T>);
88
89impl<T> Live<T> {
90 pub(crate) fn new(v: T) -> Self {
91 Self(Some(v))
92 }
93 pub(crate) fn is_live(&self) -> bool {
94 self.0.is_some()
95 }
96 pub(crate) fn park_take(&mut self) -> Option<T> {
97 self.0.take()
98 }
99 pub(crate) fn unpark_set(&mut self, v: T) {
100 self.0 = Some(v);
101 }
102}
103
104impl<T> std::ops::Deref for Live<T> {
105 type Target = T;
106 fn deref(&self) -> &T {
107 self.0.as_ref().expect("session is parked: runtime/conv unavailable")
108 }
109}
110
111impl<T> std::ops::DerefMut for Live<T> {
112 fn deref_mut(&mut self) -> &mut T {
113 self.0.as_mut().expect("session is parked: runtime/conv unavailable")
114 }
115}
116
117pub fn parked_evict_after() -> Option<std::time::Duration> {
123 let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
125 parked_evict_after_from(
126 std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
127 cfg_secs,
128 )
129}
130
131fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
132 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
133 match v.map(str::trim) {
134 Some("never" | "0" | "off") => None,
135 Some(n) => n
136 .parse::<u64>()
137 .ok()
138 .map(std::time::Duration::from_secs)
139 .or_else(from_cfg),
140 None => from_cfg(),
141 }
142}
143
144pub fn idle_end_grace() -> std::time::Duration {
149 std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
150 .ok()
151 .and_then(|v| v.trim().parse::<u64>().ok())
152 .map(std::time::Duration::from_secs)
153 .unwrap_or(std::time::Duration::from_secs(5))
154}
155
156pub fn park_grace() -> Option<std::time::Duration> {
159 match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
160 Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
161 Ok(v) => v
162 .trim()
163 .parse::<u64>()
164 .ok()
165 .map(std::time::Duration::from_secs)
166 .or(Some(DEFAULT_PARK_GRACE)),
167 Err(_) => Some(DEFAULT_PARK_GRACE),
168 }
169}
170
171pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
172
173pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
185 let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
187 prompt_abandon_timeout_from(
188 std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
189 cfg_secs,
190 )
191}
192
193fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
194 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
195 match v.map(str::trim) {
196 Some("never" | "0" | "off") => None,
197 Some(n) => n
198 .parse::<u64>()
199 .ok()
200 .map(std::time::Duration::from_secs)
201 .or_else(from_cfg),
202 None => from_cfg(),
203 }
204}
205
206fn replayable(setting: &SessionSetting) -> bool {
210 matches!(
211 setting,
212 SessionSetting::SystemPrompt { .. }
213 | SessionSetting::ContextWindow { .. }
214 | SessionSetting::CompactionModel { .. }
215 | SessionSetting::ApiRetries { .. }
216 | SessionSetting::SubagentTimeout { .. }
217 | SessionSetting::MaxToolOutput { .. }
218 | SessionSetting::BashTimeout { .. }
219 | SessionSetting::BashMaxTimeout { .. }
220 | SessionSetting::GrantWorkerModel { .. }
221 )
222}
223
224fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
225 match setting {
226 SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
227 SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
228 SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
229 SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
230 SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
231 SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
232 SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
233 SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
234 SessionSetting::GrantWorkerModel { model } => {
235 let _ = rt.grant_worker_model(model);
236 }
237 _ => {}
238 }
239}
240
241pub(crate) struct AttachedClient {
244 pub(crate) meta: ClientMeta,
245 pub(crate) mode: AttachMode,
246}
247
248pub(crate) struct CompactionJob {
250 pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
251 pub(crate) source: String,
252 #[allow(dead_code)]
253 pub(crate) started: tokio::time::Instant,
254}
255
256fn is_input_command(cmd: &SessionCommand) -> bool {
262 matches!(
263 cmd,
264 SessionCommand::Checkpoint { .. }
265 | SessionCommand::Submit { .. }
266 | SessionCommand::SubmitPrepared { .. }
267 | SessionCommand::Steer { .. }
268 | SessionCommand::Cancel
269 | SessionCommand::Set { .. }
270 | SessionCommand::Compact { .. }
271 | SessionCommand::NewSession
272 | SessionCommand::EngineCommand { .. }
273 | SessionCommand::PluginCommand { .. }
274 | SessionCommand::Resume { .. }
275 | SessionCommand::KeepWarm { .. }
276 | SessionCommand::DriverStart { .. }
277 | SessionCommand::End {
278 reason: EndReason::ClientQuit
279 }
280 )
281}
282
283fn command_name(cmd: &SessionCommand) -> &'static str {
284 match cmd {
285 SessionCommand::Submit { .. } => "submit",
286 SessionCommand::SubmitPrepared { .. } => "submit_prepared",
287 SessionCommand::Steer { .. } => "steer",
288 SessionCommand::Cancel => "cancel",
289 SessionCommand::Answer { .. } => "answer",
290 SessionCommand::Set { .. } => "set",
291 SessionCommand::Compact { .. } => "compact",
292 SessionCommand::NewSession => "new_session",
293 SessionCommand::Save => "save",
294 SessionCommand::Query { .. } => "query",
295 SessionCommand::Attach { .. } => "attach",
296 SessionCommand::Detach { .. } => "detach",
297 SessionCommand::End { .. } => "end",
298 SessionCommand::Resync { .. } => "resync",
299 SessionCommand::EngineCommand { .. } => "engine_command",
300 SessionCommand::PluginCommand { .. } => "plugin_command",
301 SessionCommand::Resume { .. } => "resume",
302 SessionCommand::Checkpoint { .. } => "checkpoint",
303 SessionCommand::KeepWarm { .. } => "keep_warm",
304 SessionCommand::Park => "park",
305 SessionCommand::HostEvent(_) => "host_event",
306 SessionCommand::DriverStart { .. } => "driver_start",
307 }
308}
309
310pub struct SessionActor {
311 pub(crate) id: SessionId,
312 pub(crate) meta: SessionMeta,
313 pub(crate) config: SessionConfig,
314 pub(crate) runtime: Live<Runtime>,
316 pub(crate) conv: Live<ConversationState>,
318 pub(crate) host: Arc<EngineHost>,
320 pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
322 pub(crate) event_queue: Arc<crate::events::EventQueue>,
325 pub(crate) settings_replay: Vec<SessionSetting>,
327 pub(crate) keep_warm: bool,
328 pub(crate) park_deadline: Option<tokio::time::Instant>,
330 pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
335 pub(crate) stream: Option<ActiveStream>,
337 pub(crate) cancel: Option<CancellationToken>,
338 pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
339 pub(crate) streaming: bool,
340 pub(crate) turn_baseline: usize,
341 pub(crate) consecutive_auto_turns: u32,
342 pub(crate) turn_steered_events: Vec<String>,
347 pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
350 pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
355 pub(crate) turn_draft: TurnDraftState,
357 pub(crate) persister: super::persister::Persister,
360 pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
363 pub(crate) secret_prompt_handle: SecretPromptHandle,
365 pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
366 pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
368 pub(crate) next_prompt_id: u64,
369 pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
371 pub(crate) events: broadcast::Sender<Envelope>,
372 pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
373 pub(crate) attached: HashMap<ClientId, AttachedClient>,
374 pub(crate) input_owner: Option<ClientId>,
377 pub(crate) next_client_id: u64,
378 pub(crate) seq: u64,
379 pub(crate) turn_replay: VecDeque<Envelope>,
380 pub(crate) state: AttachState,
381 pub(crate) background: BackgroundTasks,
382 pub(crate) compact: Option<CompactionJob>,
384 pub(crate) ext_ready: Option<oneshot::Receiver<()>>,
387 pub(crate) subagent_tick: Option<tokio::time::Interval>,
389 pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
391 pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
393 pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
395 pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
397 pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
399 pub(crate) driver: Option<super::driver::DriverState>,
401 pub(crate) driver_pending: Option<super::driver::DriverPending>,
402 pub(crate) driver_generation: u64,
404 pub(crate) driver_interrupted_owner: Option<String>,
406 pub(crate) driver_tick_interval: tokio::time::Interval,
408}
409
410impl SessionActor {
411 pub(crate) async fn create(
416 host: &Arc<EngineHost>,
417 mut cfg: SessionConfig,
418 ) -> Result<(SessionHandle, SessionTask)> {
419 let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
422 let config: crate::SynapsConfig = (**host.config()).clone();
423
424 let mut sb = crate::engine::setup::resolve_session_and_prompt(
425 &mut runtime,
426 &cfg.continue_session,
427 cfg.system.as_deref(),
428 cfg.prompt_manifest.as_deref(),
429 )?;
430 if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
434 sb.session
435 .set_name(name)
436 .map_err(|e| crate::RuntimeError::Session(format!("--name {name:?}: {e}")))?;
437 if cfg.persist {
438 if let Err(e) = sb.session.save().await {
441 tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
442 }
443 }
444 }
445 runtime.set_cwd(cfg.cwd.clone());
446 if cfg.env.is_none() {
450 if let Some(ref journal_env) = sb.session.env {
451 cfg.env = Some(journal_env.clone());
452 }
453 if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
454 cfg.env_stripped = sb.session.env_stripped.clone();
455 }
456 }
457 if cfg.env.is_some() {
461 sb.session.env = cfg.env.clone();
462 sb.session.env_stripped = cfg.env_stripped.clone();
463 }
464 runtime.set_env(cfg.env.clone());
465 runtime.set_env_stripped(cfg.env_stripped.clone());
466 if let Some(ref m) = cfg.model_override {
468 runtime.set_model(m.clone());
469 }
470
471 let background =
472 crate::engine::setup::spawn_session_background(&runtime, &sb.session)?;
473 crate::engine::setup::finish_session_setup(
474 &mut runtime,
475 &config,
476 &sb.session,
477 cfg.cwd.clone(),
478 crate::engine::setup::IndexRecord::Start,
479 );
480
481 let ext_ready_timeout = {
488 let secs = config.startup.extensions_ready_timeout_secs;
489 if secs == 0 {
490 budgets::EXTENSIONS_READY_TIMEOUT
491 } else {
492 std::time::Duration::from_secs(secs)
493 }
494 };
495 let mut ext_ready = None;
496 if cfg.await_extensions {
497 if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
498 .await
499 .is_err()
500 {
501 tracing::warn!(
502 budget_secs = ext_ready_timeout.as_secs(),
503 "extensions_ready timed out — on_session_start may miss late extensions"
504 );
505 }
506 crate::extensions::loader::emit_session_start(runtime.hook_bus(), &sb.session.id)
507 .await;
508 } else {
509 let (tx, rx) = oneshot::channel();
510 let waiter_host = Arc::clone(host);
511 tokio::spawn(async move {
512 let _ = tokio::time::timeout(ext_ready_timeout, waiter_host.extensions_ready())
513 .await;
514 let _ = tx.send(());
515 });
516 ext_ready = Some(rx);
517 }
518
519 let id = SessionId::from(sb.session.id.clone());
520
521 let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
523 let session_lock = if sb.continued {
524 let dir = agent_core::session_lock::sessions_dir();
527 let holder = agent_core::session_lock::LockHolder {
528 pid: std::process::id(),
529 kind: lock_kind.to_string(),
530 };
531 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
532 Ok(lock) => Some(lock),
533 Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
534 let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
535 return Err(crate::RuntimeError::Session(msg.to_string()));
536 }
537 Err(e) => {
538 tracing::warn!(session = %sb.session.id, "session lock: {e}");
539 None
540 }
541 }
542 } else {
543 let dir = agent_core::session_lock::sessions_dir();
545 let holder = agent_core::session_lock::LockHolder {
546 pid: std::process::id(),
547 kind: lock_kind.to_string(),
548 };
549 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
550 Ok(lock) => Some(lock),
551 Err(e) => {
552 tracing::warn!(session = %sb.session.id, "session lock: {e}");
553 None
554 }
555 }
556 };
557
558 let meta = SessionMeta {
559 id: id.clone(),
560 name: sb.session.name.clone(),
561 model: runtime.model().to_string(),
562 cwd: cfg.cwd.clone(),
563 created_at: sb.session.created_at,
564 continued: sb.continued,
565 continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
566 host_pid: std::process::id(),
567 lifecycle: SessionLifecycle::Live,
568 clients: 0,
569 input_owner: None,
570 awaiting_input: 0,
571 journal_id: sb.session.id.clone(),
572 locked_by: None,
573 };
574 let mut conv = if sb.continued {
575 ConversationState::from_resumed(sb.session)
576 } else {
577 ConversationState::new(sb.session)
578 };
579 conv.api_messages = sb.api_messages;
580 conv.total_input_tokens = sb.total_input_tokens;
581 conv.total_output_tokens = sb.total_output_tokens;
582 conv.session_cost = sb.session_cost;
583 if sb.continued && cfg.persist && session_lock.is_some() {
586 crate::engine::setup::recover_turn_draft(&mut conv).await;
587 }
588
589 let view = RuntimeView::from_runtime(&runtime).await;
590 let hook_bus = Arc::clone(runtime.hook_bus());
591 let event_queue = Arc::clone(runtime.event_queue());
592 let keep_warm = cfg.keep_warm;
593 let (
594 handle,
595 SessionEndpoints {
596 cmd_rx,
597 events,
598 view,
599 lifecycle,
600 journal_id,
601 presence,
602 name,
603 },
604 ) = SessionHandle::new(meta.clone(), view);
605 let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
606
607 let actor = SessionActor {
608 id,
609 meta,
610 config: cfg,
611 runtime: Live::new(runtime),
612 conv: Live::new(conv),
613 host: Arc::clone(host),
614 hook_bus,
615 event_queue,
616 settings_replay: Vec::new(),
617 keep_warm,
618 park_deadline: None,
619 prompt_abandon_deadline: None,
620 stream: None,
621 cancel: None,
622 steer_tx: None,
623 streaming: false,
624 turn_baseline: 0,
625 consecutive_auto_turns: 0,
626 turn_steered_events: Vec::new(),
627 turn_completion: None,
628 revoked_turn_deadline: None,
629 turn_draft: TurnDraftState::default(),
630 persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
631 last_queued_save: None,
632 secret_prompt_handle: SecretPromptHandle::new(sp_tx),
633 secret_prompt_rx,
634 pending_prompts: VecDeque::new(),
635 next_prompt_id: 1,
636 cmd_rx,
637 events,
638 view,
639 attached: HashMap::new(),
640 input_owner: None,
641 next_client_id: 1,
642 seq: 0,
643 turn_replay: VecDeque::new(),
644 state: AttachState::Detached { running: false },
645 background,
646 compact: None,
647 ext_ready,
648 subagent_tick: None,
649 lifecycle,
650 journal_id,
651 presence,
652 name,
653 session_lock,
654 driver: None,
655 driver_pending: None,
656 driver_generation: 0,
657 driver_interrupted_owner: None,
658 driver_tick_interval: {
659 let mut interval = tokio::time::interval(Duration::from_millis(200));
660 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
661 interval
662 },
663 };
664 Ok((handle, SessionTask(actor)))
665 }
666
667 pub(crate) fn sync_name(&mut self) {
671 let name = self.conv.session.name.clone();
672 self.meta.name = name.clone();
673 self.name.store(Arc::new(name.clone()));
674 if let Err(e) = crate::events::registry::update_session_name(self.id.as_str(), name.as_deref()) {
675 tracing::warn!(session = %self.id, "registry name update failed: {e}");
676 }
677 }
678
679 pub(crate) fn emit(&mut self, event: SessionEventWire) {
692 let replay = self.streaming
693 && !matches!(
694 event,
695 SessionEventWire::Prompt(_)
696 | SessionEventWire::PromptResolved { .. }
697 | SessionEventWire::Attached { .. }
698 | SessionEventWire::QueryResult { .. }
699 | SessionEventWire::Conversation(_)
700 | SessionEventWire::Stream(StreamEvent::Session(
701 SessionEvent::MessageHistory(_)
702 ))
703 );
704 let env = Envelope {
705 session_id: self.id.clone(),
706 seq: self.seq,
707 ts: chrono::Utc::now(),
708 event,
709 };
710 self.seq += 1;
711 if replay {
712 if self.turn_replay.len() >= TURN_REPLAY_CAP {
713 self.turn_replay.pop_front();
714 }
715 let mut ring = env.clone();
716 if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
717 *user_text = None;
718 }
719 self.turn_replay.push_back(ring);
720 }
721 let _ = self.events.send(env);
723 }
724
725 fn trim_replay_to_checkpoint(&mut self) {
733 self.turn_replay.retain(|env| {
734 !matches!(
735 env.event,
736 SessionEventWire::Stream(StreamEvent::Llm(_))
737 | SessionEventWire::Stream(StreamEvent::Agent(
738 AgentEvent::SteeringDelivered { .. }
739 ))
740 )
741 });
742 }
743
744 pub(crate) fn emit_conversation(&mut self) {
745 let snap = self.conv.snapshot(self.consecutive_auto_turns);
746 self.emit(SessionEventWire::Conversation(snap));
747 }
748
749 pub(crate) async fn publish_view(&mut self) {
750 let v = RuntimeView::from_runtime(&self.runtime).await;
751 self.view.store(Arc::new(v));
752 }
753
754 pub(crate) fn request_save(&mut self) {
758 if !self.config.persist || !self.conv.is_live() {
759 return;
760 }
761 let Some(session) = self.conv.prepare_save() else {
762 return;
763 };
764 let key = super::persister::SnapshotKey::of(&session);
765 if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
768 return;
769 }
770 self.last_queued_save = Some(key);
771 self.persister.save(session);
772 }
773
774 pub(crate) async fn flush_saves(&mut self) -> bool {
778 let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
779 Ok(ok) => ok,
780 Err(_) => {
781 tracing::warn!(session = %self.id, "session save still pending after the save budget");
782 false
783 }
784 };
785 if !ok {
786 self.last_queued_save = None;
789 }
790 ok
791 }
792
793 pub(crate) async fn save(&mut self) -> bool {
796 self.request_save();
797 self.flush_saves().await
798 }
799
800 pub(crate) async fn announce_idle(&mut self) {
806 self.flush_saves().await;
807 self.emit(SessionEventWire::Idle);
808 }
809
810 async fn handle_context_head_checkpoint(
820 &mut self,
821 session_id: String,
822 messages: Vec<crate::SharedMessage>,
823 receipt: agent_core::core::context_head::ContextHeadReceipt,
824 ) {
825 if session_id != self.conv.session.id {
827 tracing::warn!(
828 event_id = %session_id,
829 actor_id = %self.conv.session.id,
830 "context head checkpoint: stale session id — ignoring"
831 );
832 receipt.complete(Err(std::io::Error::new(
833 std::io::ErrorKind::InvalidInput,
834 "stale session id after compaction",
835 )));
836 return;
837 }
838
839 if !self.config.persist {
841 receipt.complete(Ok(()));
842 return;
843 }
844
845 if !self.persister.flush().await {
848 tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
849 }
850 let result = self.conv.persist_context_head(&session_id, messages).await;
851 if let Err(ref e) = result {
852 tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
853 }
854 let succeeded = result.is_ok();
855 receipt.complete(result);
856
857 if let Some(driver) = &self.driver {
859 if !succeeded
860 || driver.grant.session_id != session_id
861 || session_id != self.conv.session.id
862 {
863 self.driver_revoke("context checkpoint failed or session replaced");
864 }
865 }
866 }
867
868
869 pub(crate) fn reload_record(&self) -> SessionReloadRecord {
871 let view = self.view.load();
872 SessionReloadRecord {
873 config: self.config.clone(),
874 keep_warm: self.keep_warm,
875 lifecycle: SessionLifecycle::from_u8(
876 self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
877 ),
878 settings_replay: self.settings_replay.clone(),
879 model: view.model.clone(),
880 thinking_level: view.thinking_level.clone(),
881 }
882 }
883
884 pub(crate) fn publish_presence(&self) {
885 self.presence.store(Arc::new(super::handle::Presence {
886 clients: self.attached.len(),
887 input_owner: self.input_owner,
888 awaiting_input: self.pending_prompts.len(),
889 }));
890 }
891
892 pub(crate) fn update_attach_state(&mut self) {
895 self.publish_presence();
896 if matches!(self.state, AttachState::Parking | AttachState::Parked) {
897 return;
898 }
899 self.state = if self.attached.is_empty() {
900 AttachState::Detached {
901 running: self.streaming,
902 }
903 } else {
904 AttachState::Attached(self.attached.len())
905 };
906 self.rearm_park();
907 self.rearm_prompt_abandon();
908 }
909
910 pub(crate) fn is_parked(&self) -> bool {
913 matches!(self.state, AttachState::Parked)
914 }
915
916 pub(crate) fn can_park(&self) -> bool {
920 self.attached.is_empty()
921 && !self.streaming
922 && self.compact.is_none()
923 && self.pending_prompts.is_empty()
924 && self.conv.is_live()
925 && !self.conv.api_messages.is_empty()
926 && self.conv.queued_message.is_none()
927 && !self.keep_warm
928 && self.config.persist
929 && !self.is_parked()
930 && self.driver.is_none()
931 }
932
933 fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
939 let total = self.conv.session_cost;
940 if let Some(cap) = self.config.max_session_cost {
941 if cap.is_finite() && total >= cap {
942 return Some(("session", total, cap));
943 }
944 }
945 if let Some(driver) = &self.driver {
946 if let Some(cap) = driver.grant.effective_cost_cap(None) {
950 let run_cost = total - driver.cost_at_arm;
951 if cap.is_finite() && run_cost >= cap {
952 return Some(("run", run_cost, cap));
953 }
954 }
955 }
956 None
957 }
958
959 fn journal_exists(&self) -> bool { let id = (**self.journal_id.load()).clone();
961 crate::config::resolve_write_path("sessions")
962 .join(format!("{id}.json"))
963 .is_file()
964 }
965
966 pub(crate) fn driver_revoke(&mut self, reason: &str) {
971 let undelivered = self
972 .driver
973 .as_mut()
974 .map(|d| d.steering.drain(..).collect::<Vec<_>>())
975 .unwrap_or_default();
976 let was_active = self.driver.is_some() || self.driver_pending.is_some();
977 if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
979 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
980 }
981 if let Some(driver) = &self.driver {
982 self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
983 self.host
984 .release_driver(&driver.grant.plugin_id, &self.id);
985 }
986 self.driver_generation = self.driver_generation.wrapping_add(1);
987 self.driver_pending = None;
988 self.driver = None;
989 if was_active {
990 self.emit(SessionEventWire::DriverRevoked {
991 reason: reason.to_string(),
992 undelivered_steering: undelivered,
993 });
994 }
995 if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1004 self.resolve_pending_prompts();
1005 let wait = if self.stream.is_some() {
1006 budgets::CANCEL_DRAIN_TIMEOUT
1007 } else {
1008 std::time::Duration::ZERO
1009 };
1010 self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1011 }
1012 self.rearm_park();
1013 }
1014
1015 pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1019 if self.driver.is_some() || self.driver_pending.is_some() {
1021 self.driver_revoke("explicit command");
1022 }
1023
1024 let manager = self.host.ext_manager().clone();
1025 let session_id = self.id.0.clone();
1026
1027 let (handler, timeout) = match manager.try_read() {
1029 Ok(mgr) => {
1030 let handler = match mgr.user_action_handler(&plugin) {
1031 Ok(h) => h,
1032 Err(error) => {
1033 self.emit(SessionEventWire::SystemNotice(error));
1034 return;
1035 }
1036 };
1037 let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1038 5
1039 } else {
1040 self.emit(SessionEventWire::SystemNotice(
1041 "session driver extension lacks validated session.drive permission".into(),
1042 ));
1043 return;
1044 };
1045 (handler, timeout)
1046 }
1047 Err(_) => {
1048 self.emit(SessionEventWire::SystemNotice(
1049 "extensions are loading or busy — try again shortly".into(),
1050 ));
1051 return;
1052 }
1053 };
1054
1055 let handler_generation = super::driver::live_generation(&handler).ok();
1056
1057 let owner = plugin.clone();
1058 let cmd = command.clone();
1059 let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1060
1061 let generation = self.driver_generation;
1062 let task_handler = handler.clone();
1063 self.driver_pending = Some(super::driver::DriverPending {
1064 generation,
1065 session_id: session_id.clone(),
1066 task: super::driver::Task(tokio::spawn(async move {
1067 let (sink, collector) =
1068 invoke_event_channel(InvokeOutputBudget::default());
1069 let request_id = uuid::Uuid::new_v4().to_string();
1070 let (result, report) =
1071 tokio::time::timeout(Duration::from_secs(timeout), async {
1072 tokio::join!(
1073 task_handler.invoke_command(&cmd, args, &request_id, sink),
1074 collector.collect()
1075 )
1076 })
1077 .await
1078 .unwrap_or_else(|_| {
1079 (
1080 Err(format!("interactive command timed out ({timeout}s)")),
1081 crate::extensions::invoke_output::InvokeOutputReport {
1082 events: Vec::new(),
1083 counters: Default::default(),
1084 },
1085 )
1086 });
1087 super::driver::TaskResult::Command {
1088 owner,
1089 command,
1090 handler: task_handler,
1091 handler_generation,
1092 result,
1093 report,
1094 }
1095 })),
1096 });
1097 }
1098
1099 pub(crate) fn driver_arm(
1102 &mut self,
1103 owner: String,
1104 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1105 handler_generation: u64,
1106 reply: Reply,
1107 ) -> std::result::Result<(), String> {
1108 let manager = self.host.ext_manager().clone();
1109 super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1110
1111 if self.driver.is_some() {
1112 return Err("cannot replace an armed grant".into());
1113 }
1114
1115 let session_id = self.id.0.clone();
1116 let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1117
1118 self.host
1120 .claim_driver(&owner, &self.id)
1121 .map_err(|e| e.to_string())?;
1122
1123 let cancel = CancellationToken::new();
1124 let workers = self.runtime.subagent_registry().clone();
1125 let worker_epoch = workers
1126 .lock()
1127 .unwrap_or_else(|p| p.into_inner())
1128 .set_spawn_cancellation(Some(cancel.clone()));
1129 let deadline_task = grant.deadline().map(|deadline| {
1130 let c = cancel.clone();
1131 let w = workers.clone();
1132 super::driver::Task(tokio::spawn(async move {
1133 tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1134 c.cancel();
1135 super::driver::cancel_workers(&w, worker_epoch);
1136 }))
1137 });
1138
1139 let models = grant.models().to_vec();
1140 let proposal_notice = proposal.notice.clone();
1141 let selection = proposal.selection.clone();
1142 let deadline_ms = super::driver::deadline_ms(&grant);
1143 let run_id = grant.run_id.clone();
1144 let cost_at_arm = self.conv.session_cost;
1145
1146 let mut state = super::driver::DriverState {
1147 selection: proposal.selection.clone(),
1148 grant,
1149 handler,
1150 handler_generation,
1151 cancel,
1152 workers,
1153 worker_epoch,
1154 deadline_task,
1155 proposal: None,
1156 awaiting_terminal: false,
1157 outcome: None,
1158 feedback: crate::extensions::feedback::Tracker::default(),
1159 completed_feedback: "unknown",
1160 steering: VecDeque::new(),
1161 auto_wakes_blocked: false,
1162 cost_at_arm,
1163 };
1164
1165 super::driver::schedule(&mut state, proposal)?;
1166
1167 let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1169 if let Some(text) = context_notice {
1170 self.emit(SessionEventWire::SystemNotice(text));
1171 }
1172
1173 self.driver_interrupted_owner = None;
1174 self.driver = Some(state);
1175
1176 self.emit(SessionEventWire::DriverArmed {
1177 plugin_id: owner,
1178 run_id,
1179 models,
1180 selection,
1181 deadline_ms,
1182 notice: proposal_notice,
1183 });
1184
1185 Ok(())
1186 }
1187
1188 fn driver_idle_conflict(&self) -> Option<&'static str> {
1193 if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1194 return Some("other queued work");
1195 }
1196 if self.compact.is_some() {
1197 return Some("compaction");
1198 }
1199 if self.ext_ready.is_some() {
1200 return Some("extensions loading or reloading");
1201 }
1202 if self.conv.context_head.is_blocked(&self.conv.session) {
1203 return Some("unverified context head");
1204 }
1205 if self.driver_completion_blocked() {
1206 return Some("outstanding workers require collection/reconciliation");
1207 }
1208 None
1209 }
1210
1211 fn driver_completion_blocked(&self) -> bool {
1213 use agent_core::orchestration::CompletionGate;
1214 self.runtime.orchestration().is_some_and(|o| {
1215 !matches!(o.completion_gate(), CompletionGate::Allowed)
1216 }) || self
1217 .runtime
1218 .subagent_registry()
1219 .lock()
1220 .map(|r| {
1221 r.list_active().iter().any(|(_, _, status)| {
1222 *status == crate::runtime::subagent::SubagentStatus::Running
1223 })
1224 })
1225 .unwrap_or(true)
1226 }
1227
1228 pub(crate) async fn driver_tick(&mut self) {
1232 use crate::extensions::session_driver::Outcome;
1233
1234 if self.stream.is_none() && self.turn_cancelled() {
1238 self.finish_revoked_turn(false).await;
1239 }
1240
1241 if let Some(driver) = &self.driver {
1243 let invalid = if driver.grant.session_id != self.id.0 {
1244 Some("session replaced".to_string())
1245 } else if driver.grant.expired() {
1246 Some("grant deadline reached".into())
1247 } else if driver.cancel.is_cancelled() {
1248 Some("canceled".into())
1249 } else {
1250 let manager = self.host.ext_manager().clone();
1251 super::driver::same_handler(
1252 &manager,
1253 &driver.grant.plugin_id,
1254 &driver.handler,
1255 driver.handler_generation,
1256 )
1257 .err()
1258 };
1259 if let Some(reason) = invalid {
1260 self.driver_revoke(&reason);
1261 return;
1262 }
1263 }
1264
1265 if self.driver.is_some() {
1267 if self.attached.is_empty() {
1271 self.driver_revoke("no clients attached");
1272 return;
1273 }
1274 if let Some(reason) = self.driver_idle_conflict() {
1277 self.driver_revoke(reason);
1278 return;
1279 }
1280
1281 if !self.runtime.event_queue().is_empty() {
1284 return;
1285 }
1286 }
1287
1288 if self
1290 .driver_pending
1291 .as_ref()
1292 .is_some_and(|p| p.task.0.is_finished())
1293 {
1294 let mut pending = self.driver_pending.take().expect("checked pending");
1295 if pending.generation != self.driver_generation
1296 || pending.session_id != self.id.0
1297 {
1298 return;
1299 }
1300 let result = match (&mut pending.task.0).await {
1301 Ok(result) => result,
1302 Err(error) => {
1303 self.driver_revoke("driver task failed");
1304 self.emit(SessionEventWire::SystemNotice(format!(
1305 "Session driver task failed: {error}"
1306 )));
1307 return;
1308 }
1309 };
1310 match result {
1311 super::driver::TaskResult::Command {
1312 owner,
1313 command: _,
1314 handler,
1315 handler_generation,
1316 result,
1317 report,
1318 } => {
1319 let current = self
1320 .host
1321 .ext_manager()
1322 .try_read()
1323 .ok()
1324 .and_then(|m| m.user_action_handler(&owner).ok());
1325 if !current
1326 .as_ref()
1327 .is_some_and(|current| Arc::ptr_eq(current, &handler))
1328 {
1329 self.driver_revoke(
1330 "command owner unloaded, replaced, or unavailable",
1331 );
1332 return;
1333 }
1334 let output_failed = report.is_limited()
1335 || report.events.iter().any(|event| {
1336 matches!(
1337 event,
1338 crate::extensions::runtime::InvokeCommandEvent::Output(
1339 crate::extensions::commands::CommandOutputEvent::Error { .. }
1340 )
1341 )
1342 });
1343 let mut lines: Vec<String> = Vec::new();
1348 for event in &report.events {
1349 if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1350 let text = match out {
1351 crate::extensions::commands::CommandOutputEvent::Text { content }
1352 | crate::extensions::commands::CommandOutputEvent::System { content } => {
1353 Some(content.clone())
1354 }
1355 crate::extensions::commands::CommandOutputEvent::Error { content } => {
1356 Some(format!("Error: {content}"))
1357 }
1358 _ => None,
1359 };
1360 if let Some(text) = text {
1361 lines.push(text);
1362 }
1363 }
1364 }
1365 if !lines.is_empty() {
1366 const MAX_LINES: usize = 20;
1367 let truncated = lines.len() > MAX_LINES;
1368 if truncated {
1369 lines.truncate(MAX_LINES);
1370 lines.push("…output truncated".into());
1371 }
1372 self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
1373 }
1374 if let Some(notice) = report.limit_notice() {
1375 self.emit(SessionEventWire::SystemNotice(format!(
1376 "command output truncated: {}",
1377 notice.message,
1378 )));
1379 }
1380 if let Ok(value) = result {
1381 let result = match protocol::parse_reply(&value) {
1382 Ok(Some(reply @ Reply::Start { .. })) => {
1383 if output_failed {
1384 Err("command reported an error or exceeded its output budget"
1385 .into())
1386 } else if self.streaming || self.stream.is_some() {
1387 Err("foreground turn still active".into())
1388 } else if let Some(reason) = self.driver_idle_conflict() {
1389 Err(reason.into())
1390 } else {
1391 match handler_generation {
1392 Some(generation) => {
1393 self.driver_arm(
1394 owner, handler, generation, reply,
1395 )
1396 }
1397 None => Err(
1398 "command owner did not expose a live lifecycle at dispatch"
1399 .into(),
1400 ),
1401 }
1402 }
1403 }
1404 Ok(Some(
1405 Reply::Stop { notice: text }
1406 | Reply::Status { notice: text },
1407 )) => {
1408 self.emit(SessionEventWire::SystemNotice(text));
1409 Ok(())
1410 }
1411 Ok(Some(Reply::Next { .. })) => Err(
1412 "next requires an armed poll, not an interactive command"
1413 .into(),
1414 ),
1415 Ok(None) => Ok(()),
1416 Err(error) => Err(error),
1417 };
1418 if let Err(error) = result {
1419 self.emit(SessionEventWire::SystemNotice(format!(
1420 "Session driver rejected: {error}"
1421 )));
1422 }
1423 }
1424 }
1425 super::driver::TaskResult::Poll(result) => {
1426 let Some(driver) = self.driver.as_mut() else {
1427 return;
1428 };
1429 let stop_notice = match &result {
1430 Ok(Reply::Stop { notice }) => Some(notice.clone()),
1431 _ => None,
1432 };
1433 match result.and_then(|reply| driver.grant.accept(reply)) {
1434 Ok(Some(proposal)) => {
1435 let text = proposal.notice.clone();
1436 if let Err(error) =
1437 super::driver::schedule(driver, proposal)
1438 {
1439 self.driver_revoke(&error);
1440 } else {
1441 self.emit(SessionEventWire::SystemNotice(text));
1442 }
1443 }
1444 Ok(None) => {
1445 self.driver_revoke("owner requested stop");
1446 if let Some(text) = stop_notice {
1447 self.emit(SessionEventWire::SystemNotice(text));
1448 }
1449 }
1450 Err(error) => {
1451 self.driver_revoke(&format!("invalid poll response: {error}"))
1452 }
1453 }
1454 }
1455 super::driver::TaskResult::Prepared { proposal, result } => {
1456 if self.streaming || self.stream.is_some() {
1457 self.driver_revoke("foreground work took priority");
1458 return;
1459 }
1460 if let Some(reason) = self.driver_idle_conflict() {
1461 self.driver_revoke(reason);
1462 return;
1463 }
1464 match *result {
1465 Err(protocol::PrepareError::Selection(error)) => {
1466 self.emit(SessionEventWire::SystemNotice(format!(
1467 "Session driver selection rejected (nothing sent): {error}"
1468 )));
1469 if let Some(driver) = self.driver.as_mut() {
1470 driver.outcome = Some((
1471 Outcome::SelectionRejected,
1472 "unknown".into(),
1473 ));
1474 }
1475 }
1476 Err(protocol::PrepareError::Blocked(error)) => {
1477 self.driver_revoke(&format!("preflight blocked: {error}"));
1478 }
1479 Ok(candidate) => {
1480 let validated = proposal.clone();
1481 let history = super::driver::history_with_steering(
1482 &self.conv.api_messages,
1483 &self
1484 .driver
1485 .as_ref()
1486 .map(|d| &d.steering)
1487 .cloned()
1488 .unwrap_or_default(),
1489 );
1490 if let Err(error) = protocol::validate_prepared(
1491 &candidate, &validated, &history,
1492 ) {
1493 self.driver_revoke(&format!(
1494 "prepared selection changed before commit: {error}"
1495 ));
1496 return;
1497 }
1498 {
1502 let driver = self.driver.as_mut().expect("driver present");
1503 let conv = &mut *self.conv;
1504 super::driver::commit_submission(
1505 &mut conv.api_messages,
1506 &mut driver.steering,
1507 &proposal.prompt,
1508 );
1509 }
1510 *self.runtime = candidate;
1512 self.conv.session.model = self.runtime.model().to_owned();
1513 self.conv.session.thinking_level =
1514 self.runtime.thinking_level().to_owned();
1515 self.consecutive_auto_turns = 0;
1516 self.turn_baseline = self.conv.api_messages.len();
1517 self.turn_steered_events.clear();
1518 self.turn_replay.clear();
1519 self.streaming = true;
1520 self.open_turn_draft();
1521 self.update_attach_state();
1522 self.emit(SessionEventWire::TurnStarted {
1523 turn_baseline: self.turn_baseline,
1524 trigger: TurnTrigger::DriverAuto,
1525 user_text: Some(proposal.prompt.clone()),
1526 });
1527
1528 if let Some(driver) = self.driver.as_mut() {
1530 driver.awaiting_terminal = true;
1531 driver.completed_feedback = "unknown";
1532 if driver.grant.feedback_enabled() {
1533 driver.feedback.begin_turn(
1534 &driver.selection.model,
1535 &driver.selection.effort,
1536 );
1537 }
1538 }
1539
1540 let handler = self
1542 .driver
1543 .as_ref()
1544 .expect("driver")
1545 .handler
1546 .clone();
1547 let handler_generation = self
1548 .driver
1549 .as_ref()
1550 .expect("driver")
1551 .handler_generation;
1552 let ct = self
1553 .driver
1554 .as_ref()
1555 .expect("driver")
1556 .cancel
1557 .child_token();
1558 self.cancel = Some(ct.clone());
1559 let completion = crate::runtime::TurnCompletion::with_default_cause(
1562 crate::runtime::CancelCause::Driver,
1563 );
1564 self.turn_completion = Some(completion.clone());
1565 let (tx, rx) = mpsc::unbounded_channel();
1566 self.steer_tx = Some(tx);
1567 let mut runtime = self.runtime.clone();
1568 runtime.share_ttl_latches(&self.runtime);
1572 let history = self.conv.api_messages.clone();
1573 let secret = self.secret_prompt_handle.clone();
1574 let session_id = self.id.0.clone();
1575 let generation = self.driver_generation;
1576 self.driver_pending = Some(super::driver::DriverPending {
1577 generation,
1578 session_id,
1579 task: super::driver::Task(tokio::spawn(async move {
1580 if ct.is_cancelled() {
1581 return super::driver::TaskResult::Started(
1582 Err("canceled before stream setup".into()),
1583 );
1584 }
1585 if let Err(error) =
1586 super::driver::same_lifecycle(
1587 &handler,
1588 handler_generation,
1589 )
1590 {
1591 return super::driver::TaskResult::Started(
1592 Err(error),
1593 );
1594 }
1595 tokio::select! {
1596 biased;
1597 _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
1598 started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
1599 }
1600 })),
1601 });
1602
1603 let mut tick = tokio::time::interval(
1605 std::time::Duration::from_secs(1),
1606 );
1607 tick.set_missed_tick_behavior(
1608 tokio::time::MissedTickBehavior::Delay,
1609 );
1610 tick.reset();
1611 self.subagent_tick = Some(tick);
1612 self.request_save();
1613 }
1614 }
1615 }
1616 super::driver::TaskResult::Started(started) => match started {
1617 Ok(started) if self.driver.is_some() => {
1618 self.stream = Some(started);
1619 }
1620 Err(error) => {
1621 self.driver_revoke(&error);
1622 }
1623 _ => {}
1624 },
1625 }
1626 }
1627
1628 if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
1630 return;
1631 }
1632 if let Some(reason) = self.driver_idle_conflict() {
1633 self.driver_revoke(reason);
1634 return;
1635 }
1636 let session_cost = self.conv.session_cost;
1637 let Some(driver) = self.driver.as_mut() else {
1638 return;
1639 };
1640 if let Some((outcome, error_kind)) = driver.outcome.take() {
1641 let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
1642 let handler = driver.handler.clone();
1643 let session_id = self.id.0.clone();
1644 let generation = self.driver_generation;
1645 self.driver_pending = Some(super::driver::DriverPending {
1646 generation,
1647 session_id,
1648 task: super::driver::Task(tokio::spawn(async move {
1649 let result = tokio::time::timeout(
1652 Duration::from_secs(30),
1653 protocol::poll(handler, request),
1654 )
1655 .await
1656 .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
1657 super::driver::TaskResult::Poll(result)
1658 })),
1659 });
1660 } else if driver
1661 .proposal
1662 .as_ref()
1663 .is_some_and(|p| Instant::now() >= p.due)
1664 {
1665 let proposal =
1666 driver.proposal.take().expect("checked proposal").proposal;
1667 let runtime = self.runtime.clone();
1670 let history = super::driver::history_with_steering(
1671 &self.conv.api_messages,
1672 &driver.steering,
1673 );
1674 let session_id = self.id.0.clone();
1675 let generation = self.driver_generation;
1676 self.driver_pending = Some(super::driver::DriverPending {
1677 generation,
1678 session_id,
1679 task: super::driver::Task(tokio::spawn(async move {
1680 let result = tokio::time::timeout(
1681 Duration::from_secs(5),
1682 protocol::prepare(&runtime, &proposal, &history),
1683 )
1684 .await
1685 .unwrap_or_else(|_| {
1686 Err(protocol::PrepareError::Blocked(
1687 "preparation timed out (5s)".into(),
1688 ))
1689 });
1690 super::driver::TaskResult::Prepared {
1691 proposal,
1692 result: Box::new(result),
1693 }
1694 })),
1695 });
1696 }
1697 }
1698
1699 pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
1704 match Self::try_lock_session(new_id) {
1705 Ok(lock) => self.session_lock = Some(lock),
1706 Err(e) => {
1707 tracing::warn!(session = %new_id, "reacquire session lock: {e}");
1708 self.session_lock = None;
1709 }
1710 }
1711 }
1712
1713 pub(crate) fn try_lock_session(
1715 id: &str,
1716 ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
1717 {
1718 let dir = agent_core::session_lock::sessions_dir();
1719 let holder = agent_core::session_lock::LockHolder {
1720 pid: std::process::id(),
1721 kind: "daemon".to_string(),
1722 };
1723 agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
1724 }
1725
1726 pub(crate) fn can_end_idle(&self) -> bool {
1731 self.attached.is_empty()
1732 && !self.streaming
1733 && self.compact.is_none()
1734 && self.pending_prompts.is_empty()
1735 && self.conv.is_live()
1736 && self.conv.api_messages.is_empty()
1737 && self.conv.queued_message.is_none()
1738 && !self.keep_warm
1739 && !self.is_parked()
1740 && self.driver.is_none()
1745 && self.driver_pending.is_none()
1746 }
1747
1748 fn rearm_park(&mut self) {
1749 if self.is_parked() {
1750 return;
1753 }
1754 if self.can_end_idle() {
1755 if self.park_deadline.is_none() {
1760 self.park_deadline =
1761 Some(tokio::time::Instant::now() + idle_end_grace());
1762 }
1763 return;
1764 }
1765 if !self.can_park() {
1766 self.park_deadline = None;
1767 return;
1768 }
1769 match park_grace() {
1770 Some(grace) => {
1771 if self.park_deadline.is_none() {
1772 self.park_deadline = Some(tokio::time::Instant::now() + grace);
1773 }
1774 }
1775 None => self.park_deadline = None,
1776 }
1777 }
1778
1779 fn rearm_prompt_abandon(&mut self) {
1789 let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
1790 let abandoned = self.attached.is_empty()
1791 && !self.pending_prompts.is_empty()
1792 && !armed_driver
1793 && !self.is_parked();
1794 if !abandoned {
1795 self.prompt_abandon_deadline = None;
1798 return;
1799 }
1800 if self.prompt_abandon_deadline.is_none() {
1801 self.prompt_abandon_deadline =
1803 prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
1804 }
1805 }
1806
1807 fn on_prompt_abandon_deadline(&mut self) {
1812 self.prompt_abandon_deadline = None;
1813 if !self.attached.is_empty() {
1814 return;
1815 }
1816 let had_prompts = !self.pending_prompts.is_empty();
1817 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
1818 let _ = tx.send(None);
1819 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
1820 }
1821 if had_prompts {
1822 tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
1823 self.publish_presence();
1824 self.rearm_park();
1826 }
1827 }
1828
1829 fn set_lifecycle(&mut self, l: SessionLifecycle) {
1830 self.lifecycle
1831 .store(l as u8, std::sync::atomic::Ordering::Release);
1832 self.emit(SessionEventWire::Lifecycle(l));
1833 }
1834
1835 pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
1839 self.park_deadline = None;
1840 if self.is_parked() {
1841 if self.attached.is_empty() {
1842 tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
1843 return std::ops::ControlFlow::Break(EndReason::Evicted);
1844 }
1845 return std::ops::ControlFlow::Continue(());
1846 }
1847 if self.can_end_idle() {
1848 tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
1849 if let Some(lock) = self.session_lock.take() {
1852 lock.release_and_remove();
1853 }
1854 return std::ops::ControlFlow::Break(EndReason::Idle);
1855 }
1856 if !self.can_park() {
1857 return std::ops::ControlFlow::Continue(());
1858 }
1859 self.state = AttachState::Parking;
1860 self.set_lifecycle(SessionLifecycle::Parking);
1861 if !self.save().await {
1864 tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
1865 self.state = AttachState::Detached { running: false };
1866 self.set_lifecycle(SessionLifecycle::Live);
1867 return std::ops::ControlFlow::Continue(());
1868 }
1869 if !self.journal_exists() {
1870 tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
1872 self.state = AttachState::Detached { running: false };
1873 self.set_lifecycle(SessionLifecycle::Live);
1874 return std::ops::ControlFlow::Continue(());
1875 }
1876 if self.runtime.session_manager().active_count() > 0 {
1877 self.emit(SessionEventWire::SystemNotice(
1878 "parked: background shells closed".into(),
1879 ));
1880 }
1881 self.runtime.session_manager().shutdown_all();
1882 self.turn_replay.clear();
1883 let conv = self.conv.park_take();
1886 drop(conv);
1887 let runtime = self.runtime.park_take();
1888 drop(runtime);
1889 crate::core::memstat::purge_arenas();
1893 self.session_lock = None;
1897 self.state = AttachState::Parked;
1898 self.set_lifecycle(SessionLifecycle::Parked);
1899 tracing::info!(session = %self.id, "session parked");
1900 self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
1904 std::ops::ControlFlow::Continue(())
1905 }
1906
1907 pub(crate) async fn unpark(&mut self) -> Result<()> {
1912 let started = std::time::Instant::now();
1913 self.park_deadline = None;
1915 let journal_id = (**self.journal_id.load()).clone();
1916
1917 {
1921 let dir = agent_core::session_lock::sessions_dir();
1922 let holder = agent_core::session_lock::LockHolder {
1923 pid: std::process::id(),
1924 kind: "daemon".to_string(),
1925 };
1926 match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
1927 Ok(lock) => self.session_lock = Some(lock),
1928 Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
1929 return Err(crate::RuntimeError::Session(format!(
1930 "cannot unpark session {}: journal locked by another process",
1931 journal_id
1932 )));
1933 }
1934 Err(e) => {
1935 tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
1936 }
1938 }
1939 }
1940
1941 let host = Arc::clone(&self.host);
1942 let cfg = self.config.clone();
1943 let queue = Arc::clone(&self.event_queue);
1944 let replay = self.settings_replay.clone();
1945 let journal_present = self.journal_exists();
1946 let view = self.view.load_full();
1947 let build = async move {
1948 let config: crate::SynapsConfig = (**host.config()).clone();
1949 let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
1951 runtime.set_event_queue(queue);
1952 let mut sb = if journal_present {
1953 crate::engine::setup::resolve_session_and_prompt(
1954 &mut runtime,
1955 &Some(Some(journal_id)),
1956 cfg.system.as_deref(),
1957 cfg.prompt_manifest.as_deref(),
1958 )?
1959 } else {
1960 tracing::warn!(
1964 session = %journal_id,
1965 "unpark: journal missing — restoring as a fresh conversation"
1966 );
1967 let mut sb = crate::engine::setup::resolve_session_and_prompt(
1968 &mut runtime,
1969 &None,
1970 cfg.system.as_deref(),
1971 cfg.prompt_manifest.as_deref(),
1972 )?;
1973 sb.session.id = journal_id.clone();
1974 runtime.set_session_id(Some(journal_id));
1975 sb
1976 };
1977 runtime.set_cwd(cfg.cwd.clone());
1978 runtime.set_env(cfg.env.clone());
1979 runtime.set_env_stripped(cfg.env_stripped.clone());
1980 runtime.set_model(view.model.clone());
1983 let _ = runtime.restore_session_reasoning(&view.thinking_level);
1984 sb.session.model = view.model.clone();
1985 sb.session.thinking_level = runtime.thinking_level().to_string();
1986 crate::engine::setup::finish_session_setup(
1987 &mut runtime,
1988 &config,
1989 &sb.session,
1990 cfg.cwd.clone(),
1991 crate::engine::setup::IndexRecord::Skip,
1992 );
1993 for s in &replay {
1994 replay_setting(&mut runtime, s);
1995 }
1996 Ok::<_, crate::RuntimeError>((runtime, sb))
1997 };
1998 let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
1999 Ok(Ok(v)) => v,
2000 Ok(Err(e)) => return Err(e),
2001 Err(_) => {
2002 return Err(crate::RuntimeError::Session(format!(
2003 "unpark timed out after {}s",
2004 budgets::UNPARK_TIMEOUT_SECS
2005 )))
2006 }
2007 };
2008 let mut conv = ConversationState::from_resumed(sb.session);
2009 conv.api_messages = sb.api_messages;
2010 conv.total_input_tokens = sb.total_input_tokens;
2011 conv.total_output_tokens = sb.total_output_tokens;
2012 conv.session_cost = sb.session_cost;
2013 if journal_present && self.config.persist && self.session_lock.is_some() {
2014 crate::engine::setup::recover_turn_draft(&mut conv).await;
2015 }
2016 self.runtime.unpark_set(runtime);
2017 self.conv.unpark_set(conv);
2018 self.publish_view().await;
2019 self.state = AttachState::Detached { running: false };
2020 self.set_lifecycle(SessionLifecycle::Live);
2021 self.update_attach_state();
2022 tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2023 Ok(())
2024 }
2025
2026 pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2030 if !self.is_parked() {
2031 return Ok(());
2032 }
2033 self.unpark()
2034 .await
2035 .map_err(|e| format!("session parked and could not be restored: {e}"))
2036 }
2037
2038 pub(crate) fn busy(&self) -> bool {
2040 self.streaming || self.compact.is_some()
2041 }
2042
2043 pub(crate) fn publish_subagent_rows(&mut self) {
2054 let rows = self
2055 .runtime
2056 .subagent_registry()
2057 .lock()
2058 .unwrap_or_else(|p| p.into_inner())
2059 .display_rows();
2060 let running = rows
2061 .iter()
2062 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2063 if !rows.is_empty() {
2064 self.emit(SessionEventWire::SubagentRows(rows));
2065 }
2066 if !keep_subagent_tick(self.streaming, running) {
2067 self.subagent_tick = None;
2068 }
2069 }
2070
2071 fn subagents_running(&self) -> bool {
2073 self.runtime
2074 .subagent_registry()
2075 .lock()
2076 .unwrap_or_else(|p| p.into_inner())
2077 .display_rows()
2078 .iter()
2079 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2080 }
2081
2082 pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2085 let ct = CancellationToken::new();
2086 let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2087 self.streaming = true;
2088 self.turn_baseline = self.conv.api_messages.len();
2089 self.open_turn_draft();
2090 self.turn_steered_events.clear();
2091 self.turn_replay.clear();
2092 self.update_attach_state();
2093 self.emit(SessionEventWire::TurnStarted {
2094 turn_baseline: self.turn_baseline,
2095 trigger,
2096 user_text,
2097 });
2098 let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2106 let completion = crate::runtime::TurnCompletion::new();
2107 self.turn_completion = Some(completion.clone());
2108 let stream = self
2109 .runtime
2110 .run_stream_tracked(
2111 self.conv.api_messages.clone(),
2112 ct.clone(),
2113 Some(s_rx),
2114 Some(self.secret_prompt_handle.clone()),
2115 auto_approve,
2116 completion,
2117 )
2118 .await;
2119 self.stream = Some(stream);
2120 self.cancel = Some(ct);
2121 self.steer_tx = Some(s_tx);
2122 let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2123 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2124 tick.reset();
2125 self.subagent_tick = Some(tick);
2126 }
2127
2128 pub(crate) fn clear_stream(&mut self) {
2136 self.stream = None;
2137 self.cancel = None;
2138 self.steer_tx = None;
2139 self.streaming = false;
2140 self.turn_completion = None;
2141 self.revoked_turn_deadline = None;
2142 if !keep_subagent_tick(false, self.subagents_running()) {
2145 self.subagent_tick = None;
2146 }
2147 if !self.turn_steered_events.is_empty() && self.conv.is_live() {
2148 let undelivered = std::mem::take(&mut self.turn_steered_events);
2149 self.conv.pending_events.splice(0..0, undelivered);
2150 }
2151 self.request_save();
2154 self.close_turn_draft();
2155 self.update_attach_state();
2156 }
2157
2158 fn open_turn_draft(&mut self) {
2165 if !self.config.persist || !self.conv.is_live() || self.session_lock.is_none() {
2166 return;
2167 }
2168 self.turn_draft = TurnDraftState {
2169 open: Some(self.conv.session.id.clone()),
2170 base_len: self.conv.api_messages.len(),
2171 text: String::new(),
2172 dirty: true,
2173 };
2174 self.flush_turn_draft();
2175 }
2176
2177 fn turn_draft_text(&mut self, delta: &str) {
2178 let d = &mut self.turn_draft;
2179 if d.open.is_some()
2180 && d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES
2181 {
2182 d.text.push_str(delta);
2183 d.dirty = true;
2184 }
2185 }
2186
2187 fn turn_draft_reset_text(&mut self) {
2188 let d = &mut self.turn_draft;
2189 if d.open.is_some() && !d.text.is_empty() {
2190 d.text.clear();
2191 d.dirty = true;
2192 }
2193 }
2194
2195 fn turn_draft_committed(&mut self) {
2197 if self.turn_draft.open.is_none() {
2198 return;
2199 }
2200 self.turn_draft.base_len = self.conv.api_messages.len();
2201 self.turn_draft.text.clear();
2202 self.turn_draft.dirty = true;
2203 self.flush_turn_draft();
2204 }
2205
2206 pub(crate) fn flush_turn_draft(&mut self) {
2209 let Some(open) = self.turn_draft.open.clone() else {
2210 return;
2211 };
2212 if !self.turn_draft.dirty {
2213 return;
2214 }
2215 let id = self.conv.session.id.clone();
2217 if open != id {
2218 self.persister.remove_draft(&open);
2219 self.turn_draft.open = Some(id.clone());
2220 }
2221 self.turn_draft.dirty = false;
2222 self.persister.write_draft(
2223 &id,
2224 agent_core::core::session_draft::TurnDraft {
2225 base_len: self.turn_draft.base_len,
2226 partial_text: self.turn_draft.text.clone(),
2227 },
2228 );
2229 }
2230
2231 fn close_turn_draft(&mut self) {
2237 if let Some(id) = self.turn_draft.open.take() {
2238 self.persister.remove_draft(&id);
2239 }
2240 self.turn_draft = TurnDraftState::default();
2241 }
2242
2243 pub(crate) async fn submit(
2245 &mut self,
2246 text: String,
2247 attachments: Vec<serde_json::Value>,
2248 from: Option<ClientId>,
2249 ) {
2250 if self.conv.context_head.is_blocked(&self.conv.session) {
2255 let reason = "context head is unverified after a failed checkpoint; reload the \
2256 session (`--continue`) or start a new one before continuing"
2257 .to_string();
2258 match from {
2261 Some(client) => self.emit(SessionEventWire::Refused {
2262 client,
2263 command: "submit".into(),
2264 reason,
2265 }),
2266 None => self.emit(SessionEventWire::SystemNotice(reason)),
2267 }
2268 return;
2269 }
2270 if self.streaming {
2271 if let Some(driver) = self.driver.as_mut() {
2277 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2278 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2279 self.emit(SessionEventWire::SystemNotice(
2280 "steering queue full (16 messages) — message dropped".into(),
2281 ));
2282 return;
2283 }
2284 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2285 self.emit(SessionEventWire::SystemNotice(
2286 "steering queue full (256 KiB) — message dropped".into(),
2287 ));
2288 return;
2289 }
2290 driver.steering.push_back(text.clone());
2291 }
2292 self.steer(text);
2293 return;
2294 }
2295 if self.driver.is_some() {
2298 let driver = self.driver.as_mut().unwrap();
2299 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2300 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2301 self.emit(SessionEventWire::SystemNotice(
2302 "steering queue full (16 messages) — message dropped".into(),
2303 ));
2304 return;
2305 }
2306 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2307 self.emit(SessionEventWire::SystemNotice(
2308 "steering queue full (256 KiB) — message dropped".into(),
2309 ));
2310 return;
2311 }
2312 driver.steering.push_back(text.clone());
2313 driver.auto_wakes_blocked = false;
2315 self.emit(SessionEventWire::Steered {
2316 text,
2317 delivered: false,
2318 });
2319 return;
2320 }
2321 if self.compact.is_some() {
2322 self.emit(SessionEventWire::Steered {
2324 text: text.clone(),
2325 delivered: false,
2326 });
2327 self.conv.queued_message = Some(text);
2328 return;
2329 }
2330 self.consecutive_auto_turns = 0;
2332 let user_text = text.clone();
2334 let api_content = text;
2335
2336 if attachments.is_empty() {
2337 self.conv.api_messages.push(std::sync::Arc::new(
2339 serde_json::json!({"role": "user", "content": api_content}),
2340 ));
2341 } else {
2342 let mut blocks = Vec::with_capacity(attachments.len() + 1);
2344 if !api_content.is_empty() {
2345 blocks.push(serde_json::json!({"type": "text", "text": api_content}));
2346 }
2347 blocks.extend(attachments);
2348 let candidate = std::sync::Arc::new(
2349 serde_json::json!({"role": "user", "content": blocks}),
2350 );
2351 let mut proposed = self.conv.api_messages.clone();
2354 proposed.push(candidate.clone());
2355 let model = self.runtime.model().to_string();
2356 if let Err(e) = crate::runtime::attachments::validate_messages(&model, &proposed) {
2357 match from {
2361 Some(client) => self.emit(SessionEventWire::Refused {
2362 client,
2363 command: "submit".into(),
2364 reason: format!("attachments rejected: {e}"),
2365 }),
2366 None => self.emit(SessionEventWire::SystemNotice(format!(
2367 "attachments rejected: {e}"
2368 ))),
2369 }
2370 return;
2371 }
2372 self.conv.api_messages.push(candidate);
2373 }
2374 self.start_turn(TurnTrigger::User, Some(user_text)).await;
2375 }
2376
2377 pub(crate) fn steer(&mut self, text: String) {
2379 let delivered = self
2380 .steer_tx
2381 .as_ref()
2382 .map(|tx| tx.send(text.clone()).is_ok())
2383 .unwrap_or(false);
2384 self.emit(SessionEventWire::Steered {
2385 text: text.clone(),
2386 delivered,
2387 });
2388 self.conv.queued_message = Some(text);
2389 }
2390
2391 pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
2413 if !self.streaming {
2414 if self.compact.is_some() {
2415 self.abort_compaction();
2416 if let Some(q) = self.conv.queued_message.take() {
2417 self.emit(SessionEventWire::Dequeued { text: q });
2418 }
2419 self.update_attach_state();
2420 }
2421 self.emit(SessionEventWire::Idle);
2422 return;
2423 }
2424 self.note_cancel_cause(reason);
2425 if let Some(ref ct) = self.cancel {
2426 ct.cancel();
2427 }
2428 self.resolve_pending_prompts();
2433 let drain = self.drain_cancelled_stream().await;
2434 match drain.history {
2435 Some(history) => self.conv.api_messages = history,
2436 None if !drain.closed => tracing::warn!(
2437 session = %self.id,
2438 budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
2439 "cancelled turn did not publish its history within the drain budget; \
2440 keeping the last adopted history"
2441 ),
2442 None => {}
2443 }
2444 let completed = drain.closed && self.turn_completed();
2447 self.finish_interrupted_turn(reason, completed).await;
2448 }
2449
2450 fn turn_completed(&self) -> bool {
2452 self.turn_completion.as_ref().is_some_and(|c| c.completed())
2453 }
2454
2455 fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
2460 if !self.streaming {
2461 return;
2462 }
2463 if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
2464 turn.note_cancel_cause(cause);
2465 }
2466 }
2467
2468 async fn finish_interrupted_turn(
2478 &mut self,
2479 reason: crate::engine::interrupt::InterruptReason,
2480 completed: bool,
2481 ) {
2482 crate::engine::stream::repair_history_after_failure(
2484 &mut self.conv.api_messages,
2485 self.turn_baseline,
2486 );
2487 let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
2488 if !completed {
2489 crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
2490 }
2491 if let Some(q) = self.conv.queued_message.take() {
2493 self.emit(SessionEventWire::Dequeued { text: q });
2494 }
2495 {
2499 let undelivered = std::mem::take(&mut self.turn_steered_events);
2500 let conv: &mut ConversationState = &mut self.conv;
2501 for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
2502 conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
2503 "role": "user",
2504 "content": formatted
2505 })));
2506 }
2507 }
2508 self.clear_stream();
2510 {
2513 let mut registry = match self.runtime.subagent_registry().lock() {
2514 Ok(g) => g,
2515 Err(poisoned) => {
2516 tracing::warn!(
2517 "subagent registry mutex poisoned during abort; recovering to cancel running handles"
2518 );
2519 poisoned.into_inner()
2520 }
2521 };
2522 for handle in registry.iter_mut_handles() {
2523 if handle.status() == crate::runtime::subagent::SubagentStatus::Running {
2524 handle.cancel();
2525 }
2526 }
2527 }
2528 if completed {
2529 self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
2530 } else {
2531 self.emit(SessionEventWire::Aborted {
2534 context_saved: kept_partial,
2535 });
2536 }
2537 self.emit_conversation();
2538 self.announce_idle().await;
2539 }
2540
2541 async fn finish_revoked_turn(&mut self, stream_closed: bool) {
2549 if !self.streaming {
2550 return;
2551 }
2552 let completed = stream_closed && self.turn_completed();
2553 self.stream = None;
2554 self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
2555 .await;
2556 }
2557
2558 fn turn_cancelled(&self) -> bool {
2561 self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
2562 }
2563
2564 fn resolve_pending_prompts(&mut self) {
2566 if self.pending_prompts.is_empty() {
2567 return;
2568 }
2569 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2570 let _ = tx.send(None);
2571 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2572 }
2573 self.publish_presence();
2574 self.rearm_prompt_abandon();
2575 }
2576
2577 async fn drain_cancelled_stream(&mut self) -> CancelDrain {
2596 let mut out = CancelDrain::default();
2597 let Some(mut stream) = self.stream.take() else {
2598 out.closed = true;
2599 return out;
2600 };
2601 let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
2602 loop {
2603 let event = tokio::select! {
2604 biased;
2605 Some(req) = self.secret_prompt_rx.recv() => {
2606 let _ = req.response_tx.send(None);
2607 continue;
2608 }
2609 next = tokio::time::timeout_at(deadline, stream.next()) => match next {
2610 Err(_) => break,
2611 Ok(None) => {
2612 out.closed = true;
2613 break;
2614 }
2615 Ok(Some(event)) => event,
2616 },
2617 };
2618 match event {
2619 StreamEvent::Session(SessionEvent::Done) => {
2620 out.closed = true;
2621 break;
2622 }
2623 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
2624 out.history = Some(history);
2625 }
2626 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
2627 session_id,
2628 messages,
2629 receipt,
2630 }) => {
2631 self.handle_context_head_checkpoint(session_id, messages, receipt)
2632 .await;
2633 out.history = None;
2637 }
2638 StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
2639 crate::TurnOutcome::Canceled => {}
2640 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
2641 self.emit(SessionEventWire::SystemNotice(err.message));
2642 }
2643 _ => {
2644 tracing::warn!(
2645 session = %self.id,
2646 category = err.category_label(),
2647 "cancelled turn reported an error while draining"
2648 );
2649 }
2650 },
2651 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
2652 self.note_steering_delivered(message);
2653 self.emit(SessionEventWire::Stream(event));
2654 }
2655 StreamEvent::Session(SessionEvent::Usage { .. }) => {
2656 self.record_usage(&event);
2657 self.emit(SessionEventWire::Stream(event));
2658 }
2659 other => self.emit(SessionEventWire::Stream(other)),
2660 }
2661 }
2662 drop(stream);
2663 out
2664 }
2665
2666 fn note_steering_delivered(&mut self, message: &str) {
2668 if self.conv.queued_message.as_deref() == Some(message) {
2669 self.conv.queued_message = None;
2670 self.emit_conversation();
2671 }
2672 if let Some(driver) = self.driver.as_mut() {
2674 if driver.steering.front().map(String::as_str) == Some(message) {
2675 driver.steering.pop_front();
2676 }
2677 }
2678 if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
2679 self.turn_steered_events.remove(pos);
2680 }
2681 }
2682
2683 fn record_usage(&mut self, event: &StreamEvent) {
2685 if let StreamEvent::Session(SessionEvent::Usage {
2686 input_tokens,
2687 output_tokens,
2688 cache_read_input_tokens,
2689 cache_creation_input_tokens,
2690 cache_creation_5m,
2691 cache_creation_1h,
2692 model: usage_model,
2693 }) = event
2694 {
2695 let model_for_pricing = usage_model
2696 .as_deref()
2697 .unwrap_or(self.runtime.model())
2698 .to_string();
2699 self.conv.add_usage(
2700 *input_tokens,
2701 *output_tokens,
2702 *cache_read_input_tokens,
2703 *cache_creation_input_tokens,
2704 *cache_creation_5m,
2705 *cache_creation_1h,
2706 &model_for_pricing,
2707 );
2708 }
2709 }
2710
2711 async fn on_queue_wake(&mut self) {
2714 if self.is_parked() {
2715 if self.event_queue.is_empty() {
2716 return;
2717 }
2718 if let Err(e) = self.ensure_live().await {
2719 tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
2720 return;
2721 }
2722 }
2723 let busy = self.busy();
2724 let conv: &mut ConversationState = &mut self.conv;
2725 let drained = drain_event_queue(
2726 &self.event_queue,
2727 &mut conv.api_messages,
2728 &mut conv.pending_events,
2729 busy,
2730 self.steer_tx.as_ref(),
2731 );
2732 if drained.is_empty() {
2733 return;
2734 }
2735 for de in &drained {
2736 if de.disposition == EventDisposition::Steered {
2737 self.turn_steered_events.push(de.formatted.clone());
2738 }
2739 self.emit(SessionEventWire::External(de.event.clone()));
2740 }
2741 let injected = drained
2742 .iter()
2743 .any(|d| d.disposition == EventDisposition::Injected);
2744 if injected || busy {
2745 self.emit_conversation();
2746 }
2747
2748 if self.streaming
2751 && self
2752 .driver
2753 .as_ref()
2754 .is_some_and(|d| d.awaiting_terminal)
2755 {
2756 let competing = drained
2757 .iter()
2758 .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
2759 if competing {
2760 self.driver_revoke("event-bus work took priority");
2761 }
2762 }
2763
2764 let auto_turn_enabled = self.host.config().events.auto_turn;
2769 let auto_turn_cap = self.host.config().events.auto_turn_cap;
2770 let action = wake_action_with_cap(
2771 &drained,
2772 &self.conv.api_messages,
2773 busy,
2774 auto_turn_enabled,
2775 self.consecutive_auto_turns,
2776 auto_turn_cap,
2777 );
2778 match action {
2779 WakeAction::RunTurn => {
2780 if self.driver.is_some() {
2783 tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
2784 } else if self.stream.is_some() {
2785 tracing::warn!("handle_event_arm: RunTurn with active stream — skipping");
2786 } else {
2787 self.consecutive_auto_turns += 1;
2788 self.start_turn(TurnTrigger::EventAuto, None).await;
2789 }
2790 }
2791 WakeAction::Forward => {
2792 let hit_cap = injected
2793 && !busy
2794 && auto_turn_enabled
2795 && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
2796 if hit_cap {
2797 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
2798 }
2799 }
2800 WakeAction::Nothing => {}
2801 }
2802 }
2803
2804 async fn on_stream_event(&mut self, event: StreamEvent) {
2807 if self.turn_cancelled() {
2811 match event {
2812 StreamEvent::Session(SessionEvent::Done) => {
2813 self.finish_revoked_turn(true).await;
2814 return;
2815 }
2816 StreamEvent::Session(SessionEvent::Error(err)) => {
2818 match err.outcome {
2819 crate::TurnOutcome::Canceled => {}
2820 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
2821 self.emit(SessionEventWire::SystemNotice(err.message));
2822 }
2823 _ => tracing::warn!(
2824 session = %self.id,
2825 category = err.category_label(),
2826 "revoked turn reported an error"
2827 ),
2828 }
2829 return;
2830 }
2831 _ => {}
2832 }
2833 }
2834 if !matches!(
2839 event,
2840 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
2841 ) {
2842 self.emit(SessionEventWire::Stream(event.clone()));
2843 }
2844
2845 if let Some(driver) = self.driver.as_mut() {
2847 if driver.awaiting_terminal && driver.grant.feedback_enabled() {
2848 driver.feedback.observe(&event);
2849 }
2850 }
2851
2852 let is_canceled = self
2854 .cancel
2855 .as_ref()
2856 .is_some_and(|ct| ct.is_cancelled());
2857 let terminal = if self
2858 .driver
2859 .as_ref()
2860 .is_some_and(|d| d.awaiting_terminal)
2861 {
2862 super::driver::capture_terminal(Some(&event), is_canceled)
2863 } else {
2864 None
2865 };
2866
2867 enum After {
2868 Continue,
2869 AutoSendQueued(String),
2870 AutoTriggerEvents,
2871 Failed,
2872 }
2873 let mut after = After::Continue;
2874
2875 match event {
2876 StreamEvent::Llm(crate::LlmEvent::Text(text)) => self.turn_draft_text(&text),
2877 StreamEvent::Llm(crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset) => {
2880 self.turn_draft_reset_text()
2881 }
2882 StreamEvent::Llm(_) => {}
2883 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
2884 self.conv.api_messages = history;
2890 self.request_save();
2891 self.turn_draft_committed();
2893 self.trim_replay_to_checkpoint();
2894 self.emit_conversation();
2895 }
2896 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
2897 self.note_steering_delivered(message);
2898 }
2899 StreamEvent::Agent(_) => {}
2900 StreamEvent::Session(SessionEvent::Usage { .. }) => {
2901 self.record_usage(&event);
2902 if let Some((scope, cost, cap)) = self.cost_cap_breach() {
2907 self.emit(SessionEventWire::CostCapReached {
2908 scope: scope.to_string(),
2909 cost,
2910 cap,
2911 });
2912 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
2913 if self.driver.is_some() || self.driver_pending.is_some() {
2914 self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
2915 }
2916 self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
2917 .await;
2918 return;
2919 }
2920 }
2921 StreamEvent::Session(SessionEvent::Notice(_)) => {}
2922 StreamEvent::Session(SessionEvent::Done) => {
2923 self.clear_stream();
2924 self.publish_subagent_rows();
2925 let had_pending = !self.conv.pending_events.is_empty();
2927 {
2928 let conv: &mut ConversationState = &mut self.conv;
2929 for formatted in conv.pending_events.drain(..) {
2930 conv.api_messages
2931 .push(std::sync::Arc::new(serde_json::json!({
2932 "role": "user",
2933 "content": formatted
2934 })));
2935 }
2936 }
2937 if let Some(queued) = self.conv.queued_message.take() {
2938 after = After::AutoSendQueued(queued);
2939 } else if had_pending {
2940 self.request_save();
2941 after = After::AutoTriggerEvents;
2942 }
2943 self.emit_conversation();
2944 if matches!(after, After::Continue) {
2945 if self.config.auto_compact {
2946 self.post_turn_chat().await;
2947 }
2948 if self.compact.is_none() {
2950 self.announce_idle().await;
2951 }
2952 }
2953 }
2954 StreamEvent::Session(SessionEvent::Error(_)) => {
2955 crate::engine::stream::repair_history_after_failure(
2958 &mut self.conv.api_messages,
2959 self.turn_baseline,
2960 );
2961 self.clear_stream();
2962 self.publish_subagent_rows();
2963 self.emit_conversation();
2964 after = After::Failed;
2965 }
2966 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
2967 session_id,
2968 messages,
2969 receipt,
2970 }) => {
2971 self.handle_context_head_checkpoint(session_id, messages, receipt)
2972 .await;
2973 }
2974 }
2975
2976 match after {
2977 After::Continue => {}
2978 After::Failed => {
2979 if self.config.auto_compact {
2980 self.post_turn_chat().await;
2981 }
2982 if self.compact.is_none() {
2983 self.announce_idle().await;
2984 }
2985 }
2986 After::AutoSendQueued(queued) => {
2987 if self.config.auto_compact {
2988 self.post_turn_chat().await;
2989 }
2990 if self.compact.is_some() {
2991 self.conv.queued_message = Some(queued);
2994 return;
2995 }
2996 self.consecutive_auto_turns = 0;
2998 let user_text = queued.clone();
2999 self.conv.api_messages.push(std::sync::Arc::new(
3000 serde_json::json!({"role": "user", "content": queued}),
3001 ));
3002 self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
3003 }
3004 After::AutoTriggerEvents => {
3005 if self.config.auto_compact {
3006 self.post_turn_chat().await;
3007 }
3008 if self.compact.is_some() {
3009 return;
3010 }
3011 let auto_turn_cap = self.host.config().events.auto_turn_cap;
3014 if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
3015 self.start_turn(TurnTrigger::EventAuto, None).await;
3016 } else {
3017 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3018 self.announce_idle().await;
3019 }
3020 }
3021 }
3022
3023 if let Some(terminal) = terminal {
3025 let revoke_reason = if let Some(driver) = self.driver.as_mut() {
3026 super::driver::observe_terminal(driver, &self.runtime, terminal)
3027 } else {
3028 None
3029 };
3030 if let Some(driver) = self.driver.as_ref() {
3032 if let Some((outcome, _)) = &driver.outcome {
3033 self.emit(SessionEventWire::DriverTurnOutcome {
3034 outcome: *outcome,
3035 selection: driver.selection.clone(),
3036 feedback: driver.grant.feedback_enabled().then(|| {
3037 driver.completed_feedback.to_string()
3038 }),
3039 });
3040 }
3041 }
3042 if let Some(reason) = revoke_reason {
3043 self.driver_revoke(&reason);
3044 }
3045 }
3046 }
3047
3048 async fn post_turn_chat(&mut self) {
3050 self.request_save();
3051 let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
3052 if assessment.should_compact() {
3053 self.emit(SessionEventWire::SystemNotice(format!(
3054 "[auto-compacting ~{} tokens...]",
3055 assessment.used_tokens()
3056 )));
3057 self.compact(None, "auto").await;
3058 }
3059 }
3060
3061 fn compact_inline_env() -> bool {
3064 matches!(
3065 std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
3066 Ok("1") | Ok("true")
3067 )
3068 }
3069
3070 pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
3077 if Self::compact_inline_env() {
3078 return self.compact_inline(instructions, source).await;
3079 }
3080 if self.compact.is_some() {
3081 self.emit(SessionEventWire::SystemNotice(
3082 "compaction already in progress".into(),
3083 ));
3084 return;
3085 }
3086 if self.streaming {
3087 self.emit(SessionEventWire::SystemNotice(
3088 "cannot compact while a turn is running".into(),
3089 ));
3090 return;
3091 }
3092 let disclosure =
3093 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
3094 self.emit(SessionEventWire::CompactionStarted {
3095 source: source.to_string(),
3096 disclosure,
3097 });
3098 let msgs = self.conv.api_messages.clone();
3099 let rt: Runtime = (*self.runtime).clone();
3100 let task = tokio::spawn(async move {
3101 compact_conversation(&msgs, &rt, instructions.as_deref()).await
3102 });
3103 self.compact = Some(CompactionJob {
3104 task,
3105 source: source.to_string(),
3106 started: tokio::time::Instant::now(),
3107 });
3108 self.update_attach_state();
3109 }
3110
3111 async fn on_compaction_done(
3115 &mut self,
3116 res: std::result::Result<
3117 Result<crate::runtime::compaction::CompactionOutcome>,
3118 tokio::task::JoinError,
3119 >,
3120 ) {
3121 let Some(job) = self.compact.take() else {
3122 return;
3123 };
3124 let msg_count = self.conv.api_messages.len();
3125 match res {
3126 Err(join) => {
3127 if !join.is_cancelled() {
3128 self.emit(SessionEventWire::CompactionFailed {
3129 message: join.to_string(),
3130 panicked: true,
3131 });
3132 }
3133 }
3134 Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
3135 message: e.to_string(),
3136 panicked: false,
3137 }),
3138 Ok(Ok(outcome)) => {
3139 if !self.persister.flush().await {
3143 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
3144 }
3145 let policy: CompactionPolicy = self.config.compaction_policy.into();
3146 let queued = self.conv.queued_message.clone();
3147 let applied = apply_compaction(
3148 &self.runtime,
3149 &self.conv.session,
3150 &self.conv.api_messages,
3151 &outcome,
3152 CompactionTransition {
3153 policy,
3154 pending_events: self.conv.pending_events.clone(),
3155 queued_message: queued.clone(),
3156 hook_source: job.source.clone(),
3157 },
3158 )
3159 .await;
3160 match applied {
3161 Ok(applied) => {
3162 let previous = applied.previous_session_id.clone();
3163 self.conv.session = applied.session;
3164 self.conv.api_messages = applied.api_messages;
3165 self.conv.pending_events.clear();
3166 self.conv.queued_message = None;
3167 if policy == CompactionPolicy::LinkedSuccessor {
3168 self.conv.total_input_tokens = 0;
3169 self.conv.total_output_tokens = 0;
3170 self.conv.session_cost = 0.0;
3171 }
3172 let new_id = self.conv.session.id.clone();
3173 self.runtime.set_session_id(Some(new_id.clone()));
3174 self.journal_id.store(Arc::new(new_id.clone()));
3175 self.reacquire_session_lock(&new_id);
3177 self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
3181 self.runtime
3182 .reset_context_continuation(&new_id, &self.conv.api_messages);
3183 self.emit(SessionEventWire::CompactionApplied {
3184 previous_session_id: previous,
3185 session_id: new_id,
3186 chains_advanced: applied.chains_advanced,
3187 queued_restored: queued,
3188 msg_count,
3189 });
3190 }
3191 Err(e) => self.emit(SessionEventWire::CompactionFailed {
3192 message: e.to_string(),
3193 panicked: false,
3194 }),
3195 }
3196 }
3197 }
3198 self.emit_conversation();
3199 self.update_attach_state();
3200 self.announce_idle().await;
3201 }
3202
3203 async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
3205 self.emit(SessionEventWire::SystemNotice(format!(
3206 "[{}]",
3207 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
3208 )));
3209 let outcome =
3210 compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
3211 .await;
3212 let applied = match outcome {
3213 Ok(outcome) => {
3214 if !self.persister.flush().await {
3216 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
3217 }
3218 apply_compaction(
3219 &self.runtime,
3220 &self.conv.session,
3221 &self.conv.api_messages,
3222 &outcome,
3223 CompactionTransition {
3224 policy: CompactionPolicy::InPlace,
3225 pending_events: Vec::new(),
3226 queued_message: None,
3227 hook_source: source.to_string(),
3228 },
3229 )
3230 .await
3231 }
3232 Err(e) => Err(e),
3233 };
3234 match applied {
3235 Ok(applied) => {
3236 self.conv.session = applied.session;
3237 self.conv.api_messages = applied.api_messages;
3238 let after = self.runtime.assess_context(&self.conv.api_messages).await;
3239 self.emit(SessionEventWire::SystemNotice(format!(
3240 "[compacted → ~{} tokens]",
3241 after.used_tokens()
3242 )));
3243 }
3244 Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
3245 "[compaction failed: {}]",
3246 e
3247 ))),
3248 }
3249 self.emit_conversation();
3250 }
3251
3252 fn on_prompt_request(&mut self, req: SecretPromptRequest) {
3255 let id = self.next_prompt_id;
3256 self.next_prompt_id += 1;
3257 let pr = PromptRequest {
3258 id,
3259 kind: PromptKind::from_title(&req.title),
3260 title: req.title,
3261 prompt: req.prompt,
3262 raised_at: chrono::Utc::now(),
3263 };
3264 self.pending_prompts.push_back((pr.clone(), req.response_tx));
3265 self.publish_presence();
3266 self.emit(SessionEventWire::Prompt(pr));
3267 self.rearm_prompt_abandon();
3270 }
3271
3272 fn answer(&mut self, prompt_id: u64, value: Option<String>) {
3273 let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
3274 return; };
3276 let (_, tx) = self.pending_prompts.remove(pos).expect("position");
3277 let _ = tx.send(value);
3278 self.publish_presence();
3279 self.emit(SessionEventWire::PromptResolved { prompt_id });
3280 self.rearm_prompt_abandon();
3282 }
3283
3284 pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
3287 if replayable(&setting) {
3288 let disc = std::mem::discriminant(&setting);
3289 self.settings_replay
3290 .retain(|s| std::mem::discriminant(s) != disc);
3291 self.settings_replay.push(setting.clone());
3292 }
3293 let name = match &setting {
3294 SessionSetting::Model { .. } => "model",
3295 SessionSetting::ReasoningLevel { .. } => "reasoning_level",
3296 SessionSetting::ContextWindow { .. } => "context_window",
3297 SessionSetting::CompactionModel { .. } => "compaction_model",
3298 SessionSetting::ApiRetries { .. } => "api_retries",
3299 SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
3300 SessionSetting::MaxToolOutput { .. } => "max_tool_output",
3301 SessionSetting::BashTimeout { .. } => "bash_timeout",
3302 SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
3303 SessionSetting::SystemPrompt { .. } => "system_prompt",
3304 SessionSetting::ReloadPrompt => "reload_prompt",
3305 SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
3306 };
3307 let rt = &mut self.runtime;
3308 let mut clamp_wire = None;
3309 let result: std::result::Result<Option<String>, String> = match setting {
3310 SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
3311 self.conv.session.model = rt.model().to_string();
3312 clamp.map(|c| {
3313 self.conv.session.thinking_level = rt.thinking_level().to_string();
3314 clamp_wire = Some(ReasoningClampWire {
3315 from: c.from.as_str().to_string(),
3316 to: c.to.as_str().to_string(),
3317 });
3318 format!(
3319 "thinking → {} (clamped from {}: not supported by {})",
3320 c.to.as_str(),
3321 c.from.as_str(),
3322 rt.model()
3323 )
3324 })
3325 }),
3326 SessionSetting::ReasoningLevel { level } => {
3327 rt.set_reasoning_level_checked(level).map(|_| {
3328 self.conv.session.thinking_level = rt.thinking_level().to_string();
3329 None
3330 })
3331 }
3332 SessionSetting::ContextWindow { tokens } => {
3333 rt.set_context_window(tokens);
3334 Ok(None)
3335 }
3336 SessionSetting::CompactionModel { model } => {
3337 rt.set_compaction_model(model);
3338 Ok(None)
3339 }
3340 SessionSetting::ApiRetries { n } => {
3341 rt.set_api_retries(n);
3342 Ok(None)
3343 }
3344 SessionSetting::SubagentTimeout { secs } => {
3345 rt.set_subagent_timeout(secs);
3346 Ok(None)
3347 }
3348 SessionSetting::MaxToolOutput { bytes } => {
3349 rt.set_max_tool_output(bytes);
3350 Ok(None)
3351 }
3352 SessionSetting::BashTimeout { secs } => {
3353 rt.set_bash_timeout(secs);
3354 Ok(None)
3355 }
3356 SessionSetting::BashMaxTimeout { secs } => {
3357 rt.set_bash_max_timeout(secs);
3358 Ok(None)
3359 }
3360 SessionSetting::SystemPrompt { text } => {
3361 rt.set_system_prompt(text);
3362 Ok(None)
3363 }
3364 SessionSetting::ReloadPrompt => rt
3365 .reload_prompt()
3366 .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
3367 .map_err(|e| e.to_string()),
3368 SessionSetting::GrantWorkerModel { model } => {
3369 rt.grant_worker_model(&model).map(|_| None)
3370 }
3371 };
3372 self.publish_view().await;
3373 let view = (**self.view.load()).clone();
3374 let (ok, message) = match result {
3375 Ok(m) => (true, m),
3376 Err(e) => (false, Some(e)),
3377 };
3378 self.emit(SessionEventWire::SettingChanged(SettingApplied {
3379 id,
3380 setting: name.to_string(),
3381 ok,
3382 message,
3383 view,
3384 clamp: clamp_wire,
3385 }));
3386 }
3387
3388 pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
3389 if self.is_parked() {
3391 let value = match query {
3392 SessionQuery::Status => serde_json::json!({
3393 "session": (**self.journal_id.load()).clone(),
3394 "model": self.view.load().model,
3395 "lifecycle": SessionLifecycle::Parked,
3396 "streaming": false,
3397 "auto_turns": self.consecutive_auto_turns,
3398 "attached": 0,
3399 "pending_prompts": 0,
3400 }),
3401 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
3402 _ => match self.ensure_live().await {
3403 Ok(()) => return Box::pin(self.query(id, query)).await,
3404 Err(e) => serde_json::json!({ "error": e }),
3405 },
3406 };
3407 self.emit(SessionEventWire::QueryResult { id, value });
3408 return;
3409 }
3410 let value = match query {
3411 SessionQuery::Status => serde_json::json!({
3412 "session": self.conv.session.id,
3413 "model": self.runtime.model(),
3414 "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
3415 "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
3416 "cost": self.conv.session_cost,
3417 "messages": self.conv.api_messages.len(),
3418 "streaming": self.streaming,
3419 "auto_turns": self.consecutive_auto_turns,
3420 "attached": self.attached.len(),
3421 "pending_prompts": self.pending_prompts.len(),
3422 }),
3423 SessionQuery::Messages => {
3424 serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
3425 }
3426 SessionQuery::DisplayTail { items } => {
3427 serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
3428 .unwrap_or_default()
3429 }
3430 SessionQuery::SubagentRows => {
3431 let rows = self
3432 .runtime
3433 .subagent_registry()
3434 .lock()
3435 .unwrap_or_else(|p| p.into_inner())
3436 .display_rows();
3437 serde_json::Value::Array(
3438 rows.iter()
3439 .map(|r| {
3440 serde_json::json!({
3441 "subagent_id": r.subagent_id,
3442 "agent_name": r.agent_name,
3443 "status": format!("{:?}", r.status),
3444 "cancel_requested": r.cancel_requested,
3445 "elapsed_secs": r.elapsed_secs,
3446 })
3447 })
3448 .collect(),
3449 )
3450 }
3451 SessionQuery::PromptInspection => {
3452 serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
3453 }
3454 SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
3455 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
3456 SessionQuery::ContextAssessment => {
3457 let a = self.runtime.assess_context(&self.conv.api_messages).await;
3458 serde_json::json!({
3459 "used_tokens": a.used_tokens(),
3460 "budget_tokens": a.budget_tokens(),
3461 "provider_window": a.provider_window,
3462 "should_compact": a.should_compact(),
3463 })
3464 }
3465 SessionQuery::ContextReport => {
3466 use crate::engine::commands::{context_command, CommandResult};
3467 match context_command(&self.runtime, Some(&self.conv.api_messages)) {
3468 CommandResult::Output(text) => serde_json::json!({ "text": text }),
3469 other => serde_json::json!({ "unsupported": format!("{other:?}") }),
3470 }
3471 }
3472 };
3473 self.emit(SessionEventWire::QueryResult { id, value });
3474 }
3475
3476 pub(crate) fn snapshot(&self) -> AttachSnapshot {
3479 AttachSnapshot {
3480 meta: self.meta.clone(),
3481 view: (**self.view.load()).clone(),
3482 conversation: self.conv.snapshot(self.consecutive_auto_turns),
3483 streaming: self.streaming,
3484 replay: self.turn_replay.iter().cloned().collect(),
3485 pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
3486 clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
3487 input_owner: self.input_owner,
3488 display_tail: None,
3489 }
3490 }
3491
3492 pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
3497 let mut snap = self.snapshot();
3498 if client.history == HistoryMode::Digest {
3499 snap.display_tail = Some(super::display::display_tail(
3500 &snap.conversation.api_messages,
3501 client.tail_items,
3502 ));
3503 snap.conversation.api_messages = Vec::new();
3504 snap.replay.retain(|e| {
3505 !matches!(
3506 e.event,
3507 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
3508 )
3509 });
3510 }
3511 snap
3512 }
3513
3514 async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
3519 if let Err(e) = self.ensure_live().await {
3520 self.emit(SessionEventWire::AttachRefused { message: e });
3521 return;
3522 }
3523 let cid = ClientId(self.next_client_id);
3524 self.next_client_id += 1;
3525 let kind = client.kind;
3526 let snapshot_meta = client.clone();
3527 self.attached.insert(cid, AttachedClient { meta: client, mode });
3528 self.update_attach_state();
3529 let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
3530 match mode {
3531 AttachMode::Observe => {}
3532 AttachMode::Mirror => {
3536 if self.input_owner.is_none() {
3537 owner_change = Some((None, OwnerChangeReason::Attach));
3538 }
3539 }
3540 AttachMode::Takeover => {
3541 let reason = if self.input_owner.is_some() {
3542 OwnerChangeReason::Takeover
3543 } else {
3544 OwnerChangeReason::Attach
3545 };
3546 owner_change = Some((self.input_owner, reason));
3547 }
3548 }
3549 if let Some((from, reason)) = owner_change {
3550 self.input_owner = Some(cid);
3551 self.emit(SessionEventWire::InputOwnerChanged {
3552 from,
3553 to: Some(cid),
3554 reason,
3555 });
3556 }
3557 self.publish_presence();
3558 let snapshot = self.snapshot_for(&snapshot_meta);
3559 self.emit(SessionEventWire::Attached {
3560 client: cid,
3561 snapshot,
3562 });
3563 self.emit(SessionEventWire::ClientJoined { client: cid, kind });
3564 }
3565
3566 fn detach(&mut self, client: ClientId) {
3570 if self.attached.remove(&client).is_none() {
3571 return;
3572 }
3573 self.update_attach_state();
3574 self.emit(SessionEventWire::ClientLeft { client });
3575 if self.input_owner == Some(client) {
3576 let next = self
3577 .attached
3578 .iter()
3579 .filter(|(_, a)| a.mode != AttachMode::Observe)
3580 .map(|(c, _)| *c)
3581 .min_by_key(|c| c.0);
3582 self.input_owner = next;
3583 self.emit(SessionEventWire::InputOwnerChanged {
3584 from: Some(client),
3585 to: next,
3586 reason: OwnerChangeReason::OwnerDetached,
3587 });
3588 self.publish_presence();
3589 }
3590 if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
3607 self.driver_revoke("no clients attached");
3608 let had_prompts = !self.pending_prompts.is_empty();
3609 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3610 let _ = tx.send(None);
3611 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3612 }
3613 if had_prompts {
3614 self.publish_presence();
3615 }
3616 }
3617 }
3618
3619 pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
3624 let interrupt = match reason {
3625 CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
3626 CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
3627 };
3628 self.note_cancel_cause(interrupt);
3630 if self.driver.is_some() {
3632 self.driver_revoke("daemon reloaded");
3633 }
3634 if self.streaming {
3635 self.cancel_turn(interrupt).await;
3636 }
3637 self.abort_compaction();
3638 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3639 let _ = tx.send(None);
3640 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3641 }
3642 if !self.save().await {
3645 tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
3646 }
3647 let notice = match reason {
3648 CheckpointReason::Reload => {
3649 "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
3650 }
3651 CheckpointReason::HostRequest => {
3652 "checkpoint — background shells/PTYs closed; the turn was checkpointed"
3653 }
3654 };
3655 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
3656 self.runtime.session_manager().shutdown_all();
3657 let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3658 self.emit(SessionEventWire::QueryResult {
3659 id: super::wire::CHECKPOINT_QUERY_ID,
3660 value: serde_json::json!({ "ok": true, "record": record }),
3661 });
3662 }
3663
3664 pub(crate) fn abort_compaction(&mut self) {
3666 if let Some(job) = self.compact.take() {
3667 job.task.abort();
3668 self.emit(SessionEventWire::CompactionCancelled);
3669 }
3670 }
3671
3672 async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
3677 use std::ops::ControlFlow;
3678 let Addressed { from, cmd } = addressed;
3679 if let Some(c) = from {
3680 if is_input_command(&cmd) && self.input_owner != Some(c) {
3681 let reason = match self.input_owner {
3682 Some(o) => format!("input owned by client #{}", o.0),
3683 None => "input has no owner; attach with --takeover".to_string(),
3684 };
3685 self.emit(SessionEventWire::Refused {
3686 client: c,
3687 command: command_name(&cmd).to_string(),
3688 reason,
3689 });
3690 return ControlFlow::Continue(());
3691 }
3692 }
3693 if self.is_parked() {
3695 let needs_runtime = !matches!(
3696 cmd,
3697 SessionCommand::Attach { .. }
3698 | SessionCommand::Detach { .. }
3699 | SessionCommand::End { .. }
3700 | SessionCommand::Query { .. }
3701 | SessionCommand::Answer { .. }
3702 | SessionCommand::Save
3703 | SessionCommand::Cancel
3704 | SessionCommand::Checkpoint { .. }
3705 | SessionCommand::Park
3706 | SessionCommand::Resync { .. }
3707 | SessionCommand::HostEvent(_)
3708 );
3709 if matches!(cmd, SessionCommand::HostEvent(_)) {
3710 return ControlFlow::Continue(()); }
3712 if matches!(
3713 cmd,
3714 SessionCommand::Save
3715 | SessionCommand::Cancel
3716 | SessionCommand::Park
3717 | SessionCommand::Checkpoint { .. }
3718 ) {
3719 if let SessionCommand::Checkpoint { .. } = cmd {
3720 let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3721 self.emit(SessionEventWire::QueryResult {
3722 id: super::wire::CHECKPOINT_QUERY_ID,
3723 value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
3724 });
3725 }
3726 return ControlFlow::Continue(()); }
3728 if needs_runtime {
3729 if let Err(e) = self.ensure_live().await {
3730 match from {
3731 Some(c) => self.emit(SessionEventWire::Refused {
3732 client: c,
3733 command: command_name(&cmd).to_string(),
3734 reason: e,
3735 }),
3736 None => self.emit(SessionEventWire::SystemNotice(e)),
3737 }
3738 return ControlFlow::Continue(());
3739 }
3740 }
3741 }
3742 match cmd {
3743 SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
3744 SessionCommand::Steer { text } => {
3745 if self.streaming {
3746 self.steer(text)
3747 } else {
3748 self.submit(text, vec![], from).await
3749 }
3750 }
3751 SessionCommand::Cancel => {
3752 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
3755 if self.driver.is_some() {
3756 self.driver_revoke("canceled");
3757 }
3758 self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
3759 .await;
3760 }
3761 SessionCommand::Answer { prompt_id, value } => self.answer(prompt_id, value),
3762 SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
3763 SessionCommand::Compact { instructions } => {
3765 self.compact(instructions, "manual").await;
3766 }
3767 SessionCommand::NewSession => {
3768 if self.driver.is_some() {
3769 self.driver_revoke("session replaced");
3770 }
3771 self.persister.flush().await;
3774 self.conv.clear(&self.runtime).await;
3775 self.runtime
3776 .set_session_id(Some(self.conv.session.id.clone()));
3777 self.journal_id.store(Arc::new(self.conv.session.id.clone()));
3778 self.reacquire_session_lock(&self.conv.session.id.clone());
3780 self.emit(SessionEventWire::Cleared {
3781 session_id: self.conv.session.id.clone(),
3782 });
3783 self.emit_conversation();
3784 }
3785 SessionCommand::Save => {
3786 self.save().await;
3787 }
3788 SessionCommand::Query { id, query } => self.query(id, query).await,
3789 SessionCommand::EngineCommand { id, name, arg } => {
3790 self.engine_command(id, name, arg).await
3791 }
3792 SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
3793 SessionCommand::Detach { client } => self.detach(client),
3794 SessionCommand::End { reason } => {
3795 self.note_cancel_cause(match reason {
3798 EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
3799 _ => crate::engine::interrupt::InterruptReason::Host,
3800 });
3801 if self.driver.is_some() {
3802 self.driver_revoke("session ending");
3803 }
3804 return ControlFlow::Break(reason);
3805 }
3806 SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
3807 "resync not supported yet".into(),
3808 )),
3809 SessionCommand::SubmitPrepared {
3812 messages,
3813 user_text,
3814 } => self.submit_prepared(messages, user_text).await,
3815 SessionCommand::PluginCommand {
3816 id,
3817 plugin,
3818 name,
3819 arg,
3820 } => self.plugin_command(id, plugin, name, arg).await,
3821 SessionCommand::Resume { id, query } => self.resume(id, query).await,
3822 SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
3823 SessionCommand::Park => {
3824 if let std::ops::ControlFlow::Break(reason) = self.park().await {
3825 return ControlFlow::Break(reason);
3826 }
3827 }
3828 SessionCommand::KeepWarm { on } => {
3829 self.keep_warm = on;
3830 self.rearm_park();
3831 self.emit(SessionEventWire::SystemNotice(format!(
3832 "keep-warm {}",
3833 if on { "on: this session will not be parked" } else { "off" }
3834 )));
3835 }
3836 SessionCommand::HostEvent(ev) => match ev {
3837 HostEvent::ExtensionNotification {
3838 extension_id,
3839 method,
3840 params,
3841 } => self.emit(SessionEventWire::ExtensionNotification {
3842 extension_id,
3843 method,
3844 params,
3845 }),
3846 HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
3847 },
3848 SessionCommand::DriverStart { plugin, command, arg } => {
3850 self.driver_start(plugin, command, arg);
3851 }
3852 }
3853 ControlFlow::Continue(())
3854 }
3855
3856 async fn finish(&mut self, reason: EndReason) {
3859 self.lifecycle.store(
3860 SessionLifecycle::Ending as u8,
3861 std::sync::atomic::Ordering::Release,
3862 );
3863 let interrupt = match reason {
3866 EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
3867 _ => crate::engine::interrupt::InterruptReason::Host,
3868 };
3869 self.note_cancel_cause(interrupt);
3871 if self.driver.is_some() {
3873 self.driver_revoke("session ending");
3874 }
3875 if self.streaming {
3876 self.cancel_turn(interrupt).await;
3877 }
3878 self.abort_compaction();
3879 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3881 let _ = tx.send(None);
3882 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3883 }
3884
3885 let parked = !self.conv.is_live();
3886 let session_id = (**self.journal_id.load()).clone();
3887 let api_messages = if parked {
3888 None
3889 } else {
3890 Some(self.conv.api_messages.clone())
3891 };
3892
3893 let persist = self.config.persist;
3897 if !parked {
3898 self.request_save();
3899 }
3900 let persister = &self.persister;
3901 let save_fut = async {
3902 if persist {
3903 if !persister.flush().await {
3904 tracing::warn!(session = %session_id, "session end: save failed");
3905 }
3906 let mut index_record =
3907 crate::core::session_index::SessionIndexRecord::end(&session_id);
3908 index_record.turns = api_messages.as_ref().map(|m| m.len());
3909 if let Err(err) = crate::core::session_index::append_record(&index_record) {
3910 tracing::warn!("failed to append session end index record: {}", err);
3911 }
3912 }
3913 };
3914 if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
3915 .await
3916 .is_err()
3917 {
3918 tracing::warn!(
3919 budget_secs = budgets::SAVE_TIMEOUT_SECS,
3920 "session save timed out — data may be incomplete"
3921 );
3922 }
3923
3924 let hook_bus = Arc::clone(&self.hook_bus);
3927 crate::extensions::loader::emit_session_end(
3928 &hook_bus,
3929 &session_id,
3930 api_messages,
3931 budgets::HOOKS_TIMEOUT,
3932 )
3933 .await;
3934
3935 if self.runtime.is_live() {
3937 if let Some(outcome) = self
3938 .runtime
3939 .shutdown_observability_async(
3940 crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
3941 )
3942 .await
3943 {
3944 if !outcome.is_flushed() {
3945 tracing::warn!(
3946 stats = ?outcome.stats(),
3947 "observability flush timed out — detached worker keeps draining"
3948 );
3949 }
3950 }
3951 }
3952
3953 self.background.shutdown();
3955 self.emit(SessionEventWire::Ended { reason });
3956 }
3957
3958 pub fn id(&self) -> &SessionId {
3959 &self.id
3960 }
3961}
3962
3963pub struct SessionTask(SessionActor);
3965
3966async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
3967 match stream {
3968 Some(s) => s.next().await,
3969 None => std::future::pending().await,
3970 }
3971}
3972
3973fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
3977 streaming || any_running
3978}
3979
3980async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
3981 match tick {
3982 Some(t) => {
3983 t.tick().await;
3984 }
3985 None => std::future::pending().await,
3986 }
3987}
3988
3989async fn poll_compaction(
3990 job: &mut Option<CompactionJob>,
3991) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
3992 match job {
3993 Some(j) => (&mut j.task).await,
3994 None => std::future::pending().await,
3995 }
3996}
3997
3998async fn park_timer(deadline: Option<tokio::time::Instant>) {
3999 match deadline {
4000 Some(t) => tokio::time::sleep_until(t).await,
4001 None => std::future::pending().await,
4002 }
4003}
4004
4005async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
4007 match deadline {
4008 Some(t) => tokio::time::sleep_until(t).await,
4009 None => std::future::pending().await,
4010 }
4011}
4012
4013async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
4016 match deadline {
4017 Some(t) => tokio::time::sleep_until(t).await,
4018 None => std::future::pending().await,
4019 }
4020}
4021
4022async fn driver_tick_timer(
4024 has_driver: bool,
4025 has_pending: bool,
4026 interval: &mut tokio::time::Interval,
4027) {
4028 if has_driver || has_pending {
4029 interval.tick().await;
4030 } else {
4031 std::future::pending().await
4032 }
4033}
4034
4035async fn ext_ready(rx: &mut Option<oneshot::Receiver<()>>) {
4036 match rx {
4037 Some(r) => {
4038 let _ = r.await;
4039 }
4040 None => std::future::pending().await,
4041 }
4042}
4043
4044impl SessionTask {
4045 pub fn id(&self) -> &SessionId {
4046 self.0.id()
4047 }
4048
4049 pub async fn run(mut self) {
4051 let queue = Arc::clone(&self.0.event_queue);
4052 let reason = loop {
4053 let actor = &mut self.0;
4054 tokio::select! {
4055 cmd = actor.cmd_rx.recv() => match cmd {
4056 Some(cmd) => {
4057 if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
4058 break reason;
4059 }
4060 }
4061 None => break EndReason::HostShutdown,
4063 },
4064 Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
4065 _ = queue.notified() => actor.on_queue_wake().await,
4066 _ = next_tick(&mut actor.subagent_tick) => {
4067 actor.publish_subagent_rows();
4068 actor.flush_turn_draft();
4069 }
4070 _ = park_timer(actor.park_deadline) => {
4071 if let std::ops::ControlFlow::Break(reason) = actor.park().await {
4072 break reason;
4073 }
4074 }
4075 _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
4076 actor.on_prompt_abandon_deadline();
4077 }
4078 res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
4079 _ = ext_ready(&mut actor.ext_ready) => {
4080 actor.ext_ready = None;
4081 let id = (**actor.journal_id.load()).clone();
4082 let bus = Arc::clone(&actor.hook_bus);
4083 crate::extensions::loader::emit_session_start(&bus, &id).await;
4084 if actor.runtime.is_live() {
4085 actor.publish_view().await;
4086 }
4087 },
4088 _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval) => {
4089 actor.driver_tick().await;
4090 }
4091 _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
4092 actor.finish_revoked_turn(false).await;
4093 }
4094 ev = next_stream_event(&mut actor.stream) => match ev {
4095 Some(ev) => actor.on_stream_event(ev).await,
4096 None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
4098 None => {
4099 if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
4102 let terminal = super::driver::capture_terminal(None, false);
4103 if let Some(terminal) = terminal {
4104 if let Some(driver) = actor.driver.as_mut() {
4105 let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
4106 let sel = driver.selection.clone();
4112 let fb = driver.grant.feedback_enabled()
4113 .then(|| driver.completed_feedback.to_string());
4114 actor.emit(SessionEventWire::DriverTurnOutcome {
4115 outcome: crate::extensions::session_driver::Outcome::Blocked,
4116 selection: sel,
4117 feedback: fb,
4118 });
4119 if let Some(reason) = reason {
4120 actor.driver_revoke(&reason);
4121 }
4122 }
4123 }
4124 }
4125 actor.clear_stream();
4126 actor.emit_conversation();
4127 actor.announce_idle().await;
4128 }
4129 },
4130 }
4131 };
4132 self.0.finish(reason).await;
4133 }
4134}
4135
4136#[cfg(test)]
4137mod subagent_tick_tests {
4138 use super::keep_subagent_tick;
4139
4140 #[test]
4141 fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
4142 assert!(keep_subagent_tick(true, false), "during a turn, always");
4143 assert!(keep_subagent_tick(true, true));
4144 assert!(
4145 keep_subagent_tick(false, true),
4146 "after the turn, while a background worker runs"
4147 );
4148 assert!(
4149 !keep_subagent_tick(false, false),
4150 "stops once the turn is over and no worker runs"
4151 );
4152 }
4153}
4154
4155#[cfg(test)]
4156mod parked_evict_tests {
4157 use super::parked_evict_after_from;
4158 use super::prompt_abandon_timeout_from;
4159 use std::time::Duration;
4160
4161 #[test]
4162 fn parked_evict_defaults_to_one_hour_and_honours_never() {
4163 let d = 3600u64;
4165 assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
4166 assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
4167 for never in ["never", "0", "off"] {
4168 assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
4169 }
4170 assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
4171 assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
4173 assert_eq!(parked_evict_after_from(None, 0), None);
4174 assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
4175 }
4176
4177 #[test]
4178 fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
4179 let d = 3600u64;
4181 assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
4182 assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
4183 for never in ["never", "0", "off"] {
4185 assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
4186 }
4187 assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
4189 assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
4191 assert_eq!(prompt_abandon_timeout_from(None, 0), None);
4192 assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
4193 }
4194}