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>,
68 base_len: usize,
70 text: String,
72 tools: Vec<String>,
75 dirty: bool,
77}
78
79pub(crate) struct TurnStart {
82 cancel: CancellationToken,
83 steer_rx: mpsc::UnboundedReceiver<String>,
84 auto_approve: bool,
85 completion: crate::runtime::TurnCompletion,
86 event_turn: bool,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub(crate) enum WorkBlock {
97 Fenced,
101 SessionStartPending,
104 AwaitingOwner,
107}
108
109#[derive(Clone, Copy, Debug, PartialEq, Eq)]
112pub(crate) enum WorkSource {
113 QueueWake,
115 ParkTimer,
117 KeepWarmTimer,
119 DriverTick,
121}
122
123#[derive(Default)]
126pub(crate) struct CancelDrain {
127 pub(crate) history: Option<Vec<crate::SharedMessage>>,
129 pub(crate) closed: bool,
131}
132
133pub(crate) struct Live<T>(Option<T>);
138
139impl<T> Live<T> {
140 pub(crate) fn new(v: T) -> Self {
141 Self(Some(v))
142 }
143 pub(crate) fn is_live(&self) -> bool {
144 self.0.is_some()
145 }
146 pub(crate) fn park_take(&mut self) -> Option<T> {
147 self.0.take()
148 }
149 pub(crate) fn unpark_set(&mut self, v: T) {
150 self.0 = Some(v);
151 }
152}
153
154impl<T> std::ops::Deref for Live<T> {
155 type Target = T;
156 fn deref(&self) -> &T {
157 self.0.as_ref().expect("session is parked: runtime/conv unavailable")
158 }
159}
160
161impl<T> std::ops::DerefMut for Live<T> {
162 fn deref_mut(&mut self) -> &mut T {
163 self.0.as_mut().expect("session is parked: runtime/conv unavailable")
164 }
165}
166
167pub fn parked_evict_after() -> Option<std::time::Duration> {
173 let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
175 parked_evict_after_from(
176 std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
177 cfg_secs,
178 )
179}
180
181fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
182 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
183 match v.map(str::trim) {
184 Some("never" | "0" | "off") => None,
185 Some(n) => n
186 .parse::<u64>()
187 .ok()
188 .map(std::time::Duration::from_secs)
189 .or_else(from_cfg),
190 None => from_cfg(),
191 }
192}
193
194pub fn idle_end_grace() -> std::time::Duration {
199 std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
200 .ok()
201 .and_then(|v| v.trim().parse::<u64>().ok())
202 .map(std::time::Duration::from_secs)
203 .unwrap_or(std::time::Duration::from_secs(5))
204}
205
206pub fn park_grace() -> Option<std::time::Duration> {
209 match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
210 Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
211 Ok(v) => v
212 .trim()
213 .parse::<u64>()
214 .ok()
215 .map(std::time::Duration::from_secs)
216 .or(Some(DEFAULT_PARK_GRACE)),
217 Err(_) => Some(DEFAULT_PARK_GRACE),
218 }
219}
220
221pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
222
223pub const WORKER_PARK_RECHECK: std::time::Duration = std::time::Duration::from_secs(30);
226
227#[cfg(any(test, feature = "testing"))]
228static WORKER_PARK_RECHECK_OVERRIDE_MS: std::sync::atomic::AtomicU64 =
229 std::sync::atomic::AtomicU64::new(0);
230
231fn worker_park_recheck() -> std::time::Duration {
234 #[cfg(any(test, feature = "testing"))]
235 {
236 let ms = WORKER_PARK_RECHECK_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
237 if ms > 0 {
238 return std::time::Duration::from_millis(ms);
239 }
240 }
241 WORKER_PARK_RECHECK
242}
243
244#[cfg(any(test, feature = "testing"))]
246pub mod testing {
247 use std::sync::atomic::Ordering;
248 use std::time::Duration;
249
250 #[must_use = "the override is cleared when the guard drops"]
253 pub struct WorkerParkRecheck(());
254
255 impl WorkerParkRecheck {
256 pub fn set(interval: Duration) -> Self {
257 let ms = u64::try_from(interval.as_millis()).unwrap_or(u64::MAX).max(1);
258 super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(ms, Ordering::Relaxed);
259 Self(())
260 }
261 }
262
263 impl Drop for WorkerParkRecheck {
264 fn drop(&mut self) {
265 super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(0, Ordering::Relaxed);
266 }
267 }
268}
269
270pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
282 let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
284 prompt_abandon_timeout_from(
285 std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
286 cfg_secs,
287 )
288}
289
290fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
291 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
292 match v.map(str::trim) {
293 Some("never" | "0" | "off") => None,
294 Some(n) => n
295 .parse::<u64>()
296 .ok()
297 .map(std::time::Duration::from_secs)
298 .or_else(from_cfg),
299 None => from_cfg(),
300 }
301}
302
303fn replayable(setting: &SessionSetting) -> bool {
307 matches!(
308 setting,
309 SessionSetting::SystemPrompt { .. }
310 | SessionSetting::ContextWindow { .. }
311 | SessionSetting::CompactionModel { .. }
312 | SessionSetting::ApiRetries { .. }
313 | SessionSetting::SubagentTimeout { .. }
314 | SessionSetting::MaxToolOutput { .. }
315 | SessionSetting::BashTimeout { .. }
316 | SessionSetting::BashMaxTimeout { .. }
317 | SessionSetting::GrantWorkerModel { .. }
318 )
319}
320
321fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
322 match setting {
323 SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
324 SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
325 SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
326 SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
327 SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
328 SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
329 SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
330 SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
331 SessionSetting::GrantWorkerModel { model } => {
332 let _ = rt.grant_worker_model(model);
333 }
334 _ => {}
335 }
336}
337
338pub(crate) struct AttachedClient {
341 pub(crate) meta: ClientMeta,
342 pub(crate) mode: AttachMode,
343 pub(crate) name: String,
345 pub(crate) attached_at_ms: u64,
347 pub(crate) last_input: u64,
349}
350
351pub(crate) struct CompactionJob {
353 pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
354 pub(crate) source: String,
355 #[allow(dead_code)]
356 pub(crate) started: tokio::time::Instant,
357}
358
359fn is_input_command(cmd: &SessionCommand) -> bool {
367 matches!(
368 cmd,
369 SessionCommand::Checkpoint { .. }
370 | SessionCommand::Submit { .. }
371 | SessionCommand::SubmitPrepared { .. }
372 | SessionCommand::Steer { .. }
373 | SessionCommand::SubagentSteer { .. }
374 | SessionCommand::Cancel
375 | SessionCommand::Set { .. }
376 | SessionCommand::Compact { .. }
377 | SessionCommand::NewSession
378 | SessionCommand::EngineCommand { .. }
379 | SessionCommand::PluginCommand { .. }
380 | SessionCommand::Resume { .. }
381 | SessionCommand::KeepWarm { .. }
382 | SessionCommand::DriverStart { .. }
383 | SessionCommand::End {
384 reason: EndReason::ClientQuit
385 }
386 )
387}
388
389fn subagent_steer_event(handle_id: &str, agent_name: &str, text: &str) -> crate::events::types::Event {
392 use crate::events::types::{Event, EventContent, EventSource, Severity};
393 Event {
394 id: uuid::Uuid::new_v4().to_string(),
395 timestamp: chrono::Utc::now(),
396 source: EventSource {
397 source_type: "user".to_string(),
398 name: "user".to_string(),
399 callback: None,
400 },
401 channel: None,
402 sender: None,
403 content: EventContent {
404 text: format!("User steered subagent '{agent_name}' ({handle_id}) directly: {text}"),
405 content_type: "subagent_steer".to_string(),
406 severity: Some(Severity::Low),
407 data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
408 disclosure: None,
409 },
410 expects_response: false,
411 reply_to: None,
412 }
413}
414
415pub(crate) fn subagent_user_cancel_event(
419 handle_id: &str,
420 agent_name: &str,
421) -> crate::events::types::Event {
422 use crate::events::types::{Event, EventContent, EventSource, Severity};
423 Event {
424 id: uuid::Uuid::new_v4().to_string(),
425 timestamp: chrono::Utc::now(),
426 source: EventSource {
427 source_type: "user".to_string(),
428 name: "user".to_string(),
429 callback: None,
430 },
431 channel: None,
432 sender: None,
433 content: EventContent {
434 text: format!(
435 "User cancelled subagent '{agent_name}' ({handle_id}) with /workers cancel. It stops at \
436 its next step and sends no completion wake; do not wait for it. Its partial output is \
437 readable with subagent_collect (handle_id \"{handle_id}\") only while this session \
438 stays loaded: the handle does not survive the session being parked."
439 ),
440 content_type: "subagent_cancelled".to_string(),
441 severity: Some(Severity::Low),
442 data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
443 disclosure: None,
444 },
445 expects_response: false,
446 reply_to: None,
447 }
448}
449
450pub(crate) fn subagent_close_cancel_event(
454 handle_id: &str,
455 agent_name: &str,
456) -> crate::events::types::Event {
457 let mut event = subagent_user_cancel_event(handle_id, agent_name);
458 event.content.text = format!(
459 "User closed this session with /close force while subagent '{agent_name}' ({handle_id}) was \
460 running, so it was cancelled before it finished. It sends no completion wake; do not wait for \
461 it. If its work is still needed, check what it left behind and dispatch it again."
462 );
463 event
464}
465
466pub const PIN_ON_NOTICE: &str = "pinned: this session stays loaded (never parked or idle-ended, \
468 and the daemon will not idle-exit while it is pinned) and comes back after daemon restarts. \
469 /pin off to undo.";
470pub const PIN_OFF_NOTICE: &str = "unpinned: this session parks again when idle and won't come back after a restart";
472
473pub(crate) const CLOSE_COMMAND: &str = "close";
475pub(crate) const CLOSE_UNATTACHED_ARG: &str = "unattached";
480
481fn is_close_command(cmd: &SessionCommand) -> bool {
482 matches!(cmd, SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND)
483}
484
485pub(crate) const RENAME_COMMAND: &str = "rename";
489
490fn is_rename_command(cmd: &SessionCommand) -> bool {
491 matches!(cmd, SessionCommand::EngineCommand { name, .. } if name == RENAME_COMMAND)
492}
493
494pub(crate) fn command_name(cmd: &SessionCommand) -> &'static str {
495 match cmd {
496 SessionCommand::Submit { .. } => "submit",
497 SessionCommand::SubmitPrepared { .. } => "submit_prepared",
498 SessionCommand::Steer { .. } => "steer",
499 SessionCommand::SubagentSteer { .. } => "subagent_steer",
500 SessionCommand::Cancel => "cancel",
501 SessionCommand::Answer { .. } => "answer",
502 SessionCommand::Set { .. } => "set",
503 SessionCommand::Compact { .. } => "compact",
504 SessionCommand::NewSession => "new_session",
505 SessionCommand::Save => "save",
506 SessionCommand::Query { .. } => "query",
507 SessionCommand::Attach { .. } => "attach",
508 SessionCommand::Detach { .. } => "detach",
509 SessionCommand::End { .. } => "end",
510 SessionCommand::Resync { .. } => "resync",
511 SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND => "close",
512 SessionCommand::EngineCommand { name, .. } if name == RENAME_COMMAND => "rename",
513 SessionCommand::EngineCommand { .. } => "engine_command",
514 SessionCommand::PluginCommand { .. } => "plugin_command",
515 SessionCommand::Resume { .. } => "resume",
516 SessionCommand::Checkpoint { .. } => "checkpoint",
517 SessionCommand::KeepWarm { .. } => "keep_warm",
518 SessionCommand::TakeInput => "take_input",
519 SessionCommand::Park => "park",
520 SessionCommand::AdoptWorkers { .. } => "adopt_workers",
521 SessionCommand::ArmOwnerRebind => "arm_owner_rebind",
522 SessionCommand::RestoreToolActivations { .. } => "restore_tool_activations",
523 SessionCommand::ReloadAborted => "reload_aborted",
524 SessionCommand::ArmPark => "arm_park",
525 SessionCommand::HostEvent(_) => "host_event",
526 SessionCommand::DriverStart { .. } => "driver_start",
527 }
528}
529
530pub struct SessionActor {
531 pub(crate) id: SessionId,
532 pub(crate) meta: SessionMeta,
533 pub(crate) config: SessionConfig,
534 pub(crate) runtime: Live<Runtime>,
536 pub(crate) conv: Live<ConversationState>,
538 pub(crate) host: Arc<EngineHost>,
540 pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
542 pub(crate) event_queue: Arc<crate::events::EventQueue>,
545 pub(crate) settings_replay: Vec<SessionSetting>,
547 pub(crate) keep_warm: bool,
548 pub(crate) cache_keepwarm: super::cache_keepwarm::KeepWarmState,
551 pub(crate) park_deadline: Option<tokio::time::Instant>,
553 pub(crate) park_recheck_at: Option<tokio::time::Instant>,
557 pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
562 pub(crate) stream: Option<ActiveStream>,
564 pub(crate) cancel: Option<CancellationToken>,
565 pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
566 pub(crate) owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
571 pub(crate) streaming: bool,
572 pub(crate) turn_baseline: usize,
573 pub(crate) message_stamper: crate::runtime::message_time::Stamper,
576 pub(crate) consecutive_auto_turns: u32,
577 pub(crate) turn_steered_events: Vec<String>,
582 pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
585 pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
590 pub(crate) turn_draft: TurnDraftState,
592 pub(crate) persister: super::persister::Persister,
595 pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
598 pub(crate) secret_prompt_handle: SecretPromptHandle,
600 pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
601 pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
603 pub(crate) next_prompt_id: u64,
604 pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
606 pub(crate) events: broadcast::Sender<Envelope>,
607 pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
608 pub(crate) attached: HashMap<ClientId, AttachedClient>,
609 pub(crate) input_owner: Option<ClientId>,
613 pub(crate) client_input: ClientInput,
615 pub(crate) input_seq: u64,
617 pub(crate) next_client_id: u64,
618 pub(crate) seq: u64,
619 pub(crate) turn_replay: VecDeque<Envelope>,
620 pub(crate) web_search_notice: Option<String>,
623 pub(crate) state: AttachState,
624 pub(crate) background: BackgroundTasks,
625 pub(crate) compact: Option<CompactionJob>,
627 pub(crate) compacting_inline: bool,
630 pub(crate) session_start: super::session_start::SessionStart,
637 pub(crate) turn_awaiting_start: Option<TurnStart>,
642 pub(crate) subagent_tick: Option<tokio::time::Interval>,
644 pub(crate) turn_worker_generation: u64,
647 pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
649 pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
651 pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
653 pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
655 pub(crate) keep_warm_cell: Arc<std::sync::atomic::AtomicBool>,
658 pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
660 pub(crate) driver: Option<super::driver::DriverState>,
662 pub(crate) driver_pending: Option<super::driver::DriverPending>,
663 pub(crate) driver_generation: u64,
665 pub(crate) driver_interrupted_owner: Option<String>,
667 pub(crate) driver_tick_interval: tokio::time::Interval,
669 pub(crate) reload_fence: Option<ReloadFence>,
672 pub(crate) owned: Option<super::owned::OwnedState>,
674 pub(crate) parked_tool_activations: Vec<crate::tools::activation::CarriedActivation>,
678}
679
680pub(crate) struct ReloadFence {
686 pub(crate) deadline: tokio::time::Instant,
687 pub(crate) workers: Vec<crate::runtime::subagent::CarriedWorker>,
690}
691
692fn release_fresh_lock(lock: Option<agent_core::session_lock::SessionLock>, continued: bool) {
696 if let (Some(lock), false) = (lock, continued) {
697 lock.release_and_remove();
698 }
699}
700
701fn live_folder(cwd: Option<std::path::PathBuf>, chat: &str) -> Option<std::path::PathBuf> {
705 crate::daemon::reload::restorable_folder(cwd, chat)
706}
707
708impl SessionActor {
709 pub(crate) async fn create(
714 host: &Arc<EngineHost>,
715 mut cfg: SessionConfig,
716 ) -> Result<(SessionHandle, SessionTask)> {
717 let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
720 let config: crate::SynapsConfig = (**host.config()).clone();
721 let client_input = cfg.client_input.unwrap_or(config.client_input);
722
723 let mut sb = crate::engine::setup::resolve_session_and_prompt(
724 &mut runtime,
725 &cfg.continue_session,
726 cfg.system.as_deref(),
727 cfg.prompt_manifest.as_deref(),
728 )?;
729 let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
737 let session_lock = if sb.continued {
738 let dir = agent_core::session_lock::sessions_dir();
741 let holder = agent_core::session_lock::LockHolder {
742 pid: std::process::id(),
743 kind: lock_kind.to_string(),
744 };
745 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
746 Ok(lock) => Some(lock),
747 Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
748 let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
749 return Err(crate::RuntimeError::Session(msg.to_string()));
750 }
751 Err(e) => {
752 tracing::warn!(session = %sb.session.id, "session lock: {e}");
753 None
754 }
755 }
756 } else {
757 let dir = agent_core::session_lock::sessions_dir();
759 let holder = agent_core::session_lock::LockHolder {
760 pid: std::process::id(),
761 kind: lock_kind.to_string(),
762 };
763 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
764 Ok(lock) => Some(lock),
765 Err(e) => {
766 tracing::warn!(session = %sb.session.id, "session lock: {e}");
767 None
768 }
769 }
770 };
771
772 if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
776 if let Err(e) = sb.session.set_name(name) {
777 release_fresh_lock(session_lock, sb.continued);
778 return Err(crate::RuntimeError::Session(format!("--name {name:?}: {e}")));
779 }
780 if cfg.persist {
781 if let Err(e) = sb.session.save().await {
784 tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
785 }
786 }
787 }
788 runtime.set_cwd(cfg.cwd.clone());
789 if cfg.env.is_none() {
793 if let Some(ref journal_env) = sb.session.env {
794 cfg.env = Some(journal_env.clone());
795 }
796 if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
797 cfg.env_stripped = sb.session.env_stripped.clone();
798 }
799 }
800 if cfg.env.is_some() {
804 sb.session.env = cfg.env.clone();
805 sb.session.env_stripped = cfg.env_stripped.clone();
806 }
807 runtime.set_env(cfg.env.clone());
808 runtime.set_env_stripped(cfg.env_stripped.clone());
809 if let Some(ref m) = cfg.model_override {
811 runtime.set_model(m.clone());
812 }
813 if let Some(clock) = &cfg.message_clock {
814 runtime.set_message_clock(clock.clone());
815 }
816
817 let background = match crate::engine::setup::spawn_session_background_gated(
821 &runtime,
822 &sb.session,
823 Arc::new(std::sync::atomic::AtomicBool::new(false)),
824 ) {
825 Ok(background) => background,
826 Err(e) => {
827 release_fresh_lock(session_lock, sb.continued);
828 return Err(e);
829 }
830 };
831 crate::engine::setup::finish_session_setup(
832 &mut runtime,
833 &config,
834 &sb.session,
835 cfg.cwd.clone(),
836 crate::engine::setup::IndexRecord::Start,
837 );
838
839 let ext_ready_timeout = extensions_ready_timeout(&config);
849 if cfg.await_extensions {
850 if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
851 .await
852 .is_err()
853 {
854 tracing::warn!(
855 budget_secs = ext_ready_timeout.as_secs(),
856 "extensions_ready timed out — on_session_start may miss late extensions"
857 );
858 }
859 super::session_start::run_at_create(
860 host,
861 runtime.hook_bus(),
862 &sb.session.id,
863 cfg.cwd.as_deref(),
864 )
865 .await;
866 }
867
868 let id = SessionId::from(sb.session.id.clone());
869
870 let meta = SessionMeta {
871 id: id.clone(),
872 name: sb.session.name.clone(),
873 model: runtime.model().to_string(),
874 cwd: cfg.cwd.clone(),
875 created_at: sb.session.created_at,
876 continued: sb.continued,
877 continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
878 host_pid: std::process::id(),
879 lifecycle: SessionLifecycle::Live,
880 clients: 0,
881 input_owner: None,
882 awaiting_input: 0,
883 roster: Vec::new(),
884 client_input: Some(client_input),
885 journal_id: sb.session.id.clone(),
886 locked_by: None,
887 pinned: cfg.keep_warm,
888 busy: false,
889 state_since: None,
890 park_due: None,
891 evict_due: None,
892 title: None,
893 cost_usd: 0.0,
894 messages: 0,
895 };
896 let mut conv = if sb.continued {
897 ConversationState::from_resumed(sb.session)
898 } else {
899 ConversationState::new(sb.session)
900 };
901 conv.api_messages = sb.api_messages;
902 conv.total_input_tokens = sb.total_input_tokens;
903 conv.total_output_tokens = sb.total_output_tokens;
904 conv.session_cost = sb.session_cost;
905 if sb.continued && cfg.persist && session_lock.is_some() {
908 crate::engine::setup::recover_turn_draft(&mut conv).await;
909 }
910 let message_stamper = crate::runtime::message_time::Stamper::new(conv.api_messages.len());
912
913 let view = RuntimeView::from_runtime(&runtime).await;
914 let hook_bus = Arc::clone(runtime.hook_bus());
915 let event_queue = Arc::clone(runtime.event_queue());
916 let keep_warm = cfg.keep_warm;
917 let (
918 handle,
919 SessionEndpoints {
920 cmd_rx,
921 events,
922 view,
923 lifecycle,
924 journal_id,
925 presence,
926 name,
927 keep_warm: keep_warm_cell,
928 cache_keepwarm,
929 },
930 ) = SessionHandle::new(meta.clone(), view);
931 let handle = handle.with_owner_token(cfg.owned.as_ref().map(|o| o.token.clone()));
932 let owned = cfg.owned.clone().map(super::owned::OwnedState::new);
933 presence.store(Arc::new(super::handle::Presence {
939 client_input: Some(client_input),
940 ..Default::default()
941 }));
942 let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
943 let session_start = if cfg.await_extensions {
944 super::session_start::SessionStart::Done
945 } else if cfg.restore_parked {
946 super::session_start::SessionStart::Pending
948 } else {
949 super::session_start::SessionStart::queue(
950 host,
951 Arc::clone(&hook_bus),
952 Arc::clone(&journal_id),
953 cfg.cwd.clone(),
954 ext_ready_timeout,
955 )
956 };
957
958 let actor = SessionActor {
959 id,
960 meta,
961 config: cfg,
962 runtime: Live::new(runtime),
963 conv: Live::new(conv),
964 host: Arc::clone(host),
965 hook_bus,
966 event_queue,
967 settings_replay: Vec::new(),
968 keep_warm,
969 cache_keepwarm: super::cache_keepwarm::KeepWarmState::new(
970 config.cache_keepwarm,
971 config.cache_keepwarm_auto_max_hours,
972 config.cache_keepwarm_headless,
973 cache_keepwarm,
974 owned.is_some(),
975 ),
976 park_deadline: None,
977 park_recheck_at: None,
978 prompt_abandon_deadline: None,
979 stream: None,
980 cancel: None,
981 steer_tx: None,
982 owner_steer_budget: None,
983 streaming: false,
984 turn_baseline: 0,
985 message_stamper,
986 consecutive_auto_turns: 0,
987 turn_steered_events: Vec::new(),
988 turn_completion: None,
989 revoked_turn_deadline: None,
990 turn_draft: TurnDraftState::default(),
991 persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
992 last_queued_save: None,
993 secret_prompt_handle: SecretPromptHandle::new(sp_tx),
994 secret_prompt_rx,
995 pending_prompts: VecDeque::new(),
996 next_prompt_id: 1,
997 cmd_rx,
998 events,
999 view,
1000 attached: HashMap::new(),
1001 input_owner: None,
1002 client_input,
1003 input_seq: 0,
1004 next_client_id: 1,
1005 seq: 0,
1006 turn_replay: VecDeque::new(),
1007 web_search_notice: None,
1008 state: AttachState::Detached { running: false },
1009 background,
1010 compact: None,
1011 compacting_inline: false,
1012 session_start,
1013 turn_awaiting_start: None,
1014 subagent_tick: None,
1015 turn_worker_generation: 0,
1016 lifecycle,
1017 journal_id,
1018 presence,
1019 name,
1020 keep_warm_cell,
1021 session_lock,
1022 driver: None,
1023 driver_pending: None,
1024 driver_generation: 0,
1025 driver_interrupted_owner: None,
1026 driver_tick_interval: {
1027 let mut interval = tokio::time::interval(Duration::from_millis(200));
1028 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1029 interval
1030 },
1031 reload_fence: None,
1032 owned,
1033 parked_tool_activations: Vec::new(),
1034 };
1035 if actor.keep_warm {
1036 actor.note_pin();
1037 }
1038 Ok((handle, SessionTask(actor)))
1039 }
1040
1041 pub(crate) fn note_pin(&self) {
1047 let Some(sink) = self.host.pin_sink() else { return };
1048 let journal = (**self.journal_id.load()).clone();
1049 if self.keep_warm {
1050 let kw = self.cache_keepwarm.override_mode.map(|m| m.to_string());
1051 sink.pinned(self.id.as_str(), &journal, self.conv.session.name.as_deref(), self.config.cwd.as_deref(), kw.as_deref());
1052 } else {
1053 sink.unpinned(self.id.as_str(), &journal);
1054 }
1055 }
1056
1057 pub(crate) fn set_journal_id(&mut self, new_id: String) {
1059 let old = self.journal_id.swap(Arc::new(new_id.clone()));
1060 if self.keep_warm && *old != new_id {
1061 if let Some(sink) = self.host.pin_sink() {
1062 sink.moved(&old, &new_id);
1063 }
1064 }
1065 }
1066
1067 pub(crate) fn sync_name(&mut self) {
1068 let name = self.conv.session.name.clone();
1069 self.mirror_name(name);
1070 }
1071
1072 pub(crate) fn mirror_name(&mut self, name: Option<String>) {
1076 self.meta.name = name.clone();
1077 self.name.store(Arc::new(name.clone()));
1078 if self.keep_warm {
1079 if let Some(sink) = self.host.pin_sink() {
1080 let journal = (**self.journal_id.load()).clone();
1081 sink.renamed(&journal, name.as_deref());
1082 }
1083 }
1084 if let Err(e) = self.background.update_session_name(name.as_deref()) {
1085 tracing::warn!(session = %self.id, "registry name update failed: {e}");
1086 }
1087 }
1088
1089 pub(crate) fn emit(&mut self, event: SessionEventWire) {
1102 let replay = self.streaming
1103 && !matches!(
1104 event,
1105 SessionEventWire::Prompt(_)
1106 | SessionEventWire::PromptResolved { .. }
1107 | SessionEventWire::Attached { .. }
1108 | SessionEventWire::QueryResult { .. }
1109 | SessionEventWire::Conversation(_)
1110 | SessionEventWire::Stream(StreamEvent::Session(
1111 SessionEvent::MessageHistory(_)
1112 ))
1113 );
1114 let env = Envelope {
1115 session_id: self.id.clone(),
1116 seq: self.seq,
1117 ts: chrono::Utc::now(),
1118 event,
1119 };
1120 self.seq += 1;
1121 if replay {
1122 if self.turn_replay.len() >= TURN_REPLAY_CAP {
1123 self.turn_replay.pop_front();
1124 }
1125 let mut ring = env.clone();
1126 if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
1127 *user_text = None;
1128 }
1129 self.turn_replay.push_back(ring);
1130 }
1131 let _ = self.events.send(env);
1133 }
1134
1135 fn trim_replay_to_checkpoint(&mut self) {
1143 self.turn_replay.retain(|env| {
1144 !matches!(
1145 env.event,
1146 SessionEventWire::Stream(StreamEvent::Llm(_))
1147 | SessionEventWire::Stream(StreamEvent::Agent(
1148 AgentEvent::SteeringDelivered { .. }
1149 ))
1150 )
1151 });
1152 }
1153
1154 pub(crate) fn emit_conversation(&mut self) {
1155 self.stamp_new_messages();
1156 let snap = self.conv.snapshot(self.consecutive_auto_turns);
1157 self.emit(SessionEventWire::Conversation(snap));
1158 }
1159
1160 pub(crate) fn stamp_new_messages(&mut self) {
1165 if !self.conv.is_live() || !self.runtime.is_live() {
1166 return;
1167 }
1168 let clock = self.runtime.message_clock().clone();
1169 self.message_stamper.stamp(&mut self.conv.api_messages, &clock);
1170 }
1171
1172 pub(crate) async fn publish_view(&mut self) {
1173 let v = RuntimeView::from_runtime(&self.runtime).await;
1174 self.view.store(Arc::new(v));
1175 }
1176
1177 pub(crate) fn request_save(&mut self) {
1181 if !self.config.persist || !self.conv.is_live() {
1182 return;
1183 }
1184 self.stamp_new_messages();
1185 let Some(session) = self.conv.prepare_save() else {
1186 return;
1187 };
1188 let key = super::persister::SnapshotKey::of(&session);
1189 if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
1192 return;
1193 }
1194 self.last_queued_save = Some(key);
1195 self.persister.save(session);
1196 }
1197
1198 pub(crate) async fn flush_saves(&mut self) -> bool {
1202 let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
1203 Ok(ok) => ok,
1204 Err(_) => {
1205 tracing::warn!(session = %self.id, "session save still pending after the save budget");
1206 false
1207 }
1208 };
1209 if !ok {
1210 self.last_queued_save = None;
1213 }
1214 ok
1215 }
1216
1217 pub(crate) async fn save(&mut self) -> bool {
1220 self.request_save();
1221 self.flush_saves().await
1222 }
1223
1224 pub(crate) async fn announce_idle(&mut self) {
1230 self.flush_saves().await;
1231 #[cfg(any(test, feature = "testing"))]
1233 if super::session_start::testing::take_requeue(&self.journal_id.load()) {
1234 self.queue_session_start();
1235 }
1236 self.emit(SessionEventWire::Idle);
1237 self.cache_keepwarm_on_idle().await;
1238 }
1239
1240 async fn handle_context_head_checkpoint(
1250 &mut self,
1251 session_id: String,
1252 messages: Vec<crate::SharedMessage>,
1253 receipt: agent_core::core::context_head::ContextHeadReceipt,
1254 ) {
1255 self.turn_draft_rolled_over();
1256 if session_id != self.conv.session.id {
1258 tracing::warn!(
1259 event_id = %session_id,
1260 actor_id = %self.conv.session.id,
1261 "context head checkpoint: stale session id — ignoring"
1262 );
1263 receipt.complete(Err(std::io::Error::new(
1264 std::io::ErrorKind::InvalidInput,
1265 "stale session id after compaction",
1266 )));
1267 return;
1268 }
1269
1270 if !self.config.persist {
1272 receipt.complete(Ok(()));
1273 return;
1274 }
1275
1276 if !self.persister.flush().await {
1279 tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
1280 }
1281 let result = self.conv.persist_context_head(&session_id, messages).await;
1282 if let Err(ref e) = result {
1283 tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
1284 }
1285 let succeeded = result.is_ok();
1286 receipt.complete(result);
1287
1288 if let Some(driver) = &self.driver {
1290 if !succeeded
1291 || driver.grant.session_id != session_id
1292 || session_id != self.conv.session.id
1293 {
1294 self.driver_revoke("context checkpoint failed or session replaced");
1295 }
1296 }
1297 }
1298
1299
1300 pub(crate) fn reload_record(&self) -> SessionReloadRecord {
1302 let view = self.view.load();
1303 SessionReloadRecord {
1304 config: self.config.clone(),
1305 keep_warm: self.keep_warm,
1306 lifecycle: SessionLifecycle::from_u8(
1307 self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
1308 ),
1309 settings_replay: self.settings_replay.clone(),
1310 cache_keepwarm: self.cache_keepwarm.override_mode.map(|m| m.to_string()),
1311 model: view.model.clone(),
1312 thinking_level: view.thinking_level.clone(),
1313 workers: Vec::new(),
1314 tool_activations: if self.runtime.is_live() {
1315 self.runtime.carried_tool_activations()
1316 } else {
1317 self.parked_tool_activations.clone()
1318 },
1319 }
1320 }
1321
1322 pub(crate) fn publish_presence(&self) {
1333 use super::handle::{Presence, PresenceState};
1334 self.background
1335 .set_inbox_accepting(self.input_owner.is_some());
1336 let prev = self.presence.load();
1337 let busy = self.busy() || self.compacting_inline;
1338 let parked = self.is_parked();
1339 let state = if parked {
1340 PresenceState::Parked
1341 } else if busy {
1342 PresenceState::Busy
1343 } else {
1344 PresenceState::Idle
1345 };
1346 let now = chrono::Utc::now();
1347 let state_since = match prev.state_since {
1348 Some(t) if prev.state == state => Some(t),
1349 _ => Some(now),
1350 };
1351 let wall = |at: tokio::time::Instant| {
1352 let left = at.saturating_duration_since(tokio::time::Instant::now());
1353 chrono::Duration::from_std(left)
1354 .ok()
1355 .and_then(|d| now.checked_add_signed(d))
1356 };
1357 let park_due = self
1361 .park_deadline
1362 .filter(|d| !parked && Some(*d) != self.park_recheck_at)
1363 .filter(|_| self.can_end_idle() || self.can_park())
1364 .and_then(wall);
1365 let evict_due = self.park_deadline.filter(|_| parked).and_then(wall);
1366 let (title, cost_usd, messages) = if self.conv.is_live() {
1367 (
1368 conversation_title(&self.conv),
1369 self.conv.session_cost,
1370 self.conv.api_messages.len(),
1371 )
1372 } else {
1373 (prev.title.clone(), prev.cost_usd, prev.messages)
1374 };
1375 self.presence.store(Arc::new(Presence {
1376 clients: self.attached.len(),
1377 input_owner: self.input_owner,
1378 awaiting_input: self.pending_prompts.len(),
1379 roster: self.roster(),
1380 client_input: Some(self.client_input),
1381 busy,
1382 state,
1383 state_since,
1384 park_due,
1385 evict_due,
1386 title,
1387 cost_usd,
1388 messages,
1389 owning_client: self.owned.as_ref().and_then(|o| o.client),
1390 }));
1391 }
1392
1393 pub(crate) fn roster(&self) -> Vec<ClientInfo> {
1395 let mut roster: Vec<ClientInfo> = self
1396 .attached
1397 .iter()
1398 .map(|(id, a)| ClientInfo {
1399 id: *id,
1400 name: a.name.clone(),
1401 kind: a.meta.kind,
1402 mode: a.mode,
1403 attached_at_ms: a.attached_at_ms,
1404 })
1405 .collect();
1406 roster.sort_by_key(|c| c.id.0);
1407 roster
1408 }
1409
1410 fn client_label(&self, id: ClientId) -> String {
1412 match self.attached.get(&id) {
1413 Some(a) => format!("{} (#{})", a.name, id.0),
1414 None => format!("client #{}", id.0),
1415 }
1416 }
1417
1418 fn input_refusal(&self, c: ClientId) -> Option<String> {
1422 match self.client_input {
1423 ClientInput::Exclusive => (self.input_owner != Some(c)).then(|| match self.input_owner {
1424 Some(o) => format!("input owned by {}", self.client_label(o)),
1425 None => "input has no owner; attach with --takeover".to_string(),
1426 }),
1427 ClientInput::Shared => match self.attached.get(&c).map(|a| a.mode) {
1428 Some(AttachMode::Mirror | AttachMode::Takeover) => None,
1429 Some(AttachMode::Observe) => {
1430 Some("this client is read-only (attached with --observe)".to_string())
1431 }
1432 None => Some("this client is not attached".to_string()),
1433 },
1434 }
1435 }
1436
1437 fn note_input(&mut self, c: ClientId) {
1441 let Some(a) = self.attached.get_mut(&c) else { return };
1442 if a.mode == AttachMode::Observe {
1443 return;
1444 }
1445 self.input_seq += 1;
1446 a.last_input = self.input_seq;
1447 if self.client_input == ClientInput::Shared && self.input_owner != Some(c) {
1448 let from = self.input_owner;
1449 self.input_owner = Some(c);
1450 self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason: OwnerChangeReason::Input });
1451 self.publish_presence();
1452 }
1453 }
1454
1455 pub(crate) fn update_attach_state(&mut self) {
1458 self.publish_presence();
1459 if matches!(self.state, AttachState::Parking | AttachState::Parked) {
1460 return;
1461 }
1462 self.state = if self.attached.is_empty() {
1463 AttachState::Detached {
1464 running: self.streaming,
1465 }
1466 } else {
1467 AttachState::Attached(self.attached.len())
1468 };
1469 self.rearm_park();
1470 self.rearm_prompt_abandon();
1471 }
1472
1473 pub(crate) fn is_parked(&self) -> bool {
1476 matches!(self.state, AttachState::Parked)
1477 }
1478
1479 pub(crate) fn can_park(&self) -> bool {
1483 self.can_park_ignoring_keepwarm() && !self.cache_keepwarm.active()
1487 }
1488
1489 pub(crate) fn can_park_ignoring_keepwarm(&self) -> bool {
1491 self.can_park_but_for_workers() && !self.workers_hold_park()
1492 }
1493
1494 fn workers_hold_park(&self) -> bool {
1497 self.runtime.is_live()
1498 && self
1499 .runtime
1500 .subagent_registry()
1501 .lock()
1502 .unwrap_or_else(|p| p.into_inner())
1503 .holds_park()
1504 }
1505
1506 fn can_park_but_for_workers(&self) -> bool {
1508 self.attached.is_empty()
1509 && !self.streaming
1510 && self.compact.is_none()
1511 && self.pending_prompts.is_empty()
1512 && self.conv.is_live()
1513 && !self.conv.api_messages.is_empty()
1514 && self.conv.queued_message.is_none()
1515 && !self.keep_warm
1516 && self.config.persist
1517 && !self.is_parked()
1518 && self.owned.is_none()
1520 && self.driver.is_none()
1521 }
1522
1523 fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
1529 let total = self.conv.session_cost;
1530 if let Some(cap) = self.config.max_session_cost {
1531 if cap.is_finite() && total >= cap {
1532 return Some(("session", total, cap));
1533 }
1534 }
1535 if let Some(driver) = &self.driver {
1536 if let Some(cap) = driver.grant.effective_cost_cap(None) {
1540 let run_cost = total - driver.cost_at_arm;
1541 if cap.is_finite() && run_cost >= cap {
1542 return Some(("run", run_cost, cap));
1543 }
1544 }
1545 }
1546 None
1547 }
1548
1549 fn journal_exists(&self) -> bool { let id = (**self.journal_id.load()).clone();
1551 crate::config::resolve_write_path("sessions")
1552 .join(format!("{id}.json"))
1553 .is_file()
1554 }
1555
1556 pub(crate) fn driver_revoke(&mut self, reason: &str) {
1561 let undelivered = self
1562 .driver
1563 .as_mut()
1564 .map(|d| d.steering.drain(..).collect::<Vec<_>>())
1565 .unwrap_or_default();
1566 let was_active = self.driver.is_some() || self.driver_pending.is_some();
1567 if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
1569 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
1570 }
1571 if let Some(driver) = &self.driver {
1572 self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
1573 self.host
1574 .release_driver(&driver.grant.plugin_id, &self.id);
1575 }
1576 self.driver_generation = self.driver_generation.wrapping_add(1);
1577 self.driver_pending = None;
1578 self.driver = None;
1579 if was_active {
1580 self.emit(SessionEventWire::DriverRevoked {
1581 reason: reason.to_string(),
1582 undelivered_steering: undelivered,
1583 });
1584 }
1585 if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1594 self.resolve_pending_prompts();
1595 let wait = if self.stream.is_some() {
1596 budgets::CANCEL_DRAIN_TIMEOUT
1597 } else {
1598 std::time::Duration::ZERO
1599 };
1600 self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1601 }
1602 self.rearm_park();
1603 }
1604
1605 pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1609 if self.driver.is_some() || self.driver_pending.is_some() {
1611 self.driver_revoke("explicit command");
1612 }
1613
1614 let manager = self.host.ext_manager().clone();
1615 let session_id = self.id.0.clone();
1616
1617 let (handler, timeout) = match manager.try_read() {
1619 Ok(mgr) => {
1620 let handler = match mgr.user_action_handler(&plugin) {
1621 Ok(h) => h,
1622 Err(error) => {
1623 self.emit(SessionEventWire::SystemNotice(error));
1624 return;
1625 }
1626 };
1627 let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1628 5
1629 } else {
1630 self.emit(SessionEventWire::SystemNotice(
1631 "session driver extension lacks validated session.drive permission".into(),
1632 ));
1633 return;
1634 };
1635 (handler, timeout)
1636 }
1637 Err(_) => {
1638 self.emit(SessionEventWire::SystemNotice(
1639 "extensions are loading or busy — try again shortly".into(),
1640 ));
1641 return;
1642 }
1643 };
1644
1645 let handler_generation = super::driver::live_generation(&handler).ok();
1646
1647 let owner = plugin.clone();
1648 let cmd = command.clone();
1649 let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1650
1651 let generation = self.driver_generation;
1652 let task_handler = handler.clone();
1653 self.driver_pending = Some(super::driver::DriverPending {
1654 generation,
1655 session_id: session_id.clone(),
1656 task: super::driver::Task(tokio::spawn(async move {
1657 let (sink, collector) =
1658 invoke_event_channel(InvokeOutputBudget::default());
1659 let request_id = uuid::Uuid::new_v4().to_string();
1660 let (result, report) =
1661 tokio::time::timeout(Duration::from_secs(timeout), async {
1662 tokio::join!(
1663 task_handler.invoke_command(&cmd, args, &request_id, sink),
1664 collector.collect()
1665 )
1666 })
1667 .await
1668 .unwrap_or_else(|_| {
1669 (
1670 Err(format!("interactive command timed out ({timeout}s)")),
1671 crate::extensions::invoke_output::InvokeOutputReport {
1672 events: Vec::new(),
1673 counters: Default::default(),
1674 },
1675 )
1676 });
1677 super::driver::TaskResult::Command {
1678 owner,
1679 command,
1680 handler: task_handler,
1681 handler_generation,
1682 result,
1683 report,
1684 }
1685 })),
1686 });
1687 }
1688
1689 pub(crate) fn driver_arm(
1692 &mut self,
1693 owner: String,
1694 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1695 handler_generation: u64,
1696 reply: Reply,
1697 ) -> std::result::Result<(), String> {
1698 let manager = self.host.ext_manager().clone();
1699 super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1700
1701 if self.driver.is_some() {
1702 return Err("cannot replace an armed grant".into());
1703 }
1704
1705 let session_id = self.id.0.clone();
1706 let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1707
1708 self.host
1710 .claim_driver(&owner, &self.id)
1711 .map_err(|e| e.to_string())?;
1712
1713 let cancel = CancellationToken::new();
1714 let workers = self.runtime.subagent_registry().clone();
1715 let worker_epoch = workers
1716 .lock()
1717 .unwrap_or_else(|p| p.into_inner())
1718 .set_spawn_cancellation(Some(cancel.clone()));
1719 let deadline_task = grant.deadline().map(|deadline| {
1720 let c = cancel.clone();
1721 let w = workers.clone();
1722 super::driver::Task(tokio::spawn(async move {
1723 tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1724 c.cancel();
1725 super::driver::cancel_workers(&w, worker_epoch);
1726 }))
1727 });
1728
1729 let models = grant.models().to_vec();
1730 let proposal_notice = proposal.notice.clone();
1731 let selection = proposal.selection.clone();
1732 let deadline_ms = super::driver::deadline_ms(&grant);
1733 let run_id = grant.run_id.clone();
1734 let cost_at_arm = self.conv.session_cost;
1735
1736 let mut state = super::driver::DriverState {
1737 selection: proposal.selection.clone(),
1738 grant,
1739 handler,
1740 handler_generation,
1741 cancel,
1742 workers,
1743 worker_epoch,
1744 deadline_task,
1745 proposal: None,
1746 awaiting_terminal: false,
1747 outcome: None,
1748 feedback: crate::extensions::feedback::Tracker::default(),
1749 completed_feedback: "unknown",
1750 steering: VecDeque::new(),
1751 auto_wakes_blocked: false,
1752 cost_at_arm,
1753 };
1754
1755 super::driver::schedule(&mut state, proposal)?;
1756
1757 let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1759 if let Some(text) = context_notice {
1760 self.emit(SessionEventWire::SystemNotice(text));
1761 }
1762
1763 self.driver_interrupted_owner = None;
1764 self.driver = Some(state);
1765
1766 self.emit(SessionEventWire::DriverArmed {
1767 plugin_id: owner,
1768 run_id,
1769 models,
1770 selection,
1771 deadline_ms,
1772 notice: proposal_notice,
1773 });
1774
1775 Ok(())
1776 }
1777
1778 pub(crate) fn autonomous_work_blocked(&self) -> Option<WorkBlock> {
1804 if self.reload_fence.is_some() {
1805 Some(WorkBlock::Fenced)
1806 } else if self.owned.as_ref().is_some_and(|o| o.awaiting_owner) {
1807 Some(WorkBlock::AwaitingOwner)
1808 } else if !self.session_start.is_done() {
1809 Some(WorkBlock::SessionStartPending)
1810 } else {
1811 None
1812 }
1813 }
1814
1815 pub(crate) fn holds(&self, source: WorkSource) -> bool {
1818 match self.autonomous_work_blocked() {
1819 None => false,
1820 Some(WorkBlock::Fenced | WorkBlock::AwaitingOwner) => true,
1821 Some(WorkBlock::SessionStartPending) => matches!(source, WorkSource::KeepWarmTimer),
1822 }
1823 }
1824
1825 fn driver_idle_conflict(&self) -> Option<&'static str> {
1830 if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1831 return Some("other queued work");
1832 }
1833 if self.compact.is_some() {
1834 return Some("compaction");
1835 }
1836 match self.autonomous_work_blocked() {
1837 Some(WorkBlock::Fenced) => return Some("daemon reloading"),
1838 Some(WorkBlock::AwaitingOwner) => return Some("waiting for the session's owner to reconnect"),
1839 Some(WorkBlock::SessionStartPending) => return Some("extensions loading or reloading"),
1840 None => {}
1841 }
1842 if self.conv.context_head.is_blocked(&self.conv.session) {
1843 return Some("unverified context head");
1844 }
1845 if self.driver_completion_blocked() {
1846 return Some("outstanding workers require collection/reconciliation");
1847 }
1848 None
1849 }
1850
1851 fn driver_completion_blocked(&self) -> bool {
1853 use agent_core::orchestration::CompletionGate;
1854 self.runtime.orchestration().is_some_and(|o| {
1855 !matches!(o.completion_gate(), CompletionGate::Allowed)
1856 }) || self
1857 .runtime
1858 .subagent_registry()
1859 .lock()
1860 .map(|r| {
1861 r.list_active().iter().any(|(_, _, status)| {
1862 *status == crate::runtime::subagent::SubagentStatus::Running
1863 })
1864 })
1865 .unwrap_or(true)
1866 }
1867
1868 pub(crate) async fn driver_tick(&mut self) {
1872 use crate::extensions::session_driver::Outcome;
1873 if self.holds(WorkSource::DriverTick) {
1874 return;
1875 }
1876
1877 if self.stream.is_none() && self.turn_cancelled() {
1881 self.finish_revoked_turn(false).await;
1882 }
1883
1884 if let Some(driver) = &self.driver {
1886 let invalid = if driver.grant.session_id != self.id.0 {
1887 Some("session replaced".to_string())
1888 } else if driver.grant.expired() {
1889 Some("grant deadline reached".into())
1890 } else if driver.cancel.is_cancelled() {
1891 Some("canceled".into())
1892 } else {
1893 let manager = self.host.ext_manager().clone();
1894 super::driver::same_handler(
1895 &manager,
1896 &driver.grant.plugin_id,
1897 &driver.handler,
1898 driver.handler_generation,
1899 )
1900 .err()
1901 };
1902 if let Some(reason) = invalid {
1903 self.driver_revoke(&reason);
1904 return;
1905 }
1906 }
1907
1908 if self.driver.is_some() {
1910 if self.attached.is_empty() {
1914 self.driver_revoke("no clients attached");
1915 return;
1916 }
1917 if let Some(reason) = self.driver_idle_conflict() {
1920 self.driver_revoke(reason);
1921 return;
1922 }
1923
1924 if !self.runtime.event_queue().is_empty() {
1927 return;
1928 }
1929 }
1930
1931 if self
1933 .driver_pending
1934 .as_ref()
1935 .is_some_and(|p| p.task.0.is_finished())
1936 {
1937 let mut pending = self.driver_pending.take().expect("checked pending");
1938 if pending.generation != self.driver_generation
1939 || pending.session_id != self.id.0
1940 {
1941 return;
1942 }
1943 let result = match (&mut pending.task.0).await {
1944 Ok(result) => result,
1945 Err(error) => {
1946 self.driver_revoke("driver task failed");
1947 self.emit(SessionEventWire::SystemNotice(format!(
1948 "Session driver task failed: {error}"
1949 )));
1950 return;
1951 }
1952 };
1953 match result {
1954 super::driver::TaskResult::Command {
1955 owner,
1956 command: _,
1957 handler,
1958 handler_generation,
1959 result,
1960 report,
1961 } => {
1962 let current = self
1963 .host
1964 .ext_manager()
1965 .try_read()
1966 .ok()
1967 .and_then(|m| m.user_action_handler(&owner).ok());
1968 if !current
1969 .as_ref()
1970 .is_some_and(|current| Arc::ptr_eq(current, &handler))
1971 {
1972 self.driver_revoke(
1973 "command owner unloaded, replaced, or unavailable",
1974 );
1975 return;
1976 }
1977 let output_failed = report.is_limited()
1978 || report.events.iter().any(|event| {
1979 matches!(
1980 event,
1981 crate::extensions::runtime::InvokeCommandEvent::Output(
1982 crate::extensions::commands::CommandOutputEvent::Error { .. }
1983 )
1984 )
1985 });
1986 let mut lines: Vec<String> = Vec::new();
1991 for event in &report.events {
1992 if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1993 let text = match out {
1994 crate::extensions::commands::CommandOutputEvent::Text { content }
1995 | crate::extensions::commands::CommandOutputEvent::System { content } => {
1996 Some(content.clone())
1997 }
1998 crate::extensions::commands::CommandOutputEvent::Error { content } => {
1999 Some(format!("Error: {content}"))
2000 }
2001 _ => None,
2002 };
2003 if let Some(text) = text {
2004 lines.push(text);
2005 }
2006 }
2007 }
2008 if !lines.is_empty() {
2009 const MAX_LINES: usize = 20;
2010 let truncated = lines.len() > MAX_LINES;
2011 if truncated {
2012 lines.truncate(MAX_LINES);
2013 lines.push("…output truncated".into());
2014 }
2015 self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
2016 }
2017 if let Some(notice) = report.limit_notice() {
2018 self.emit(SessionEventWire::SystemNotice(format!(
2019 "command output truncated: {}",
2020 notice.message,
2021 )));
2022 }
2023 if let Ok(value) = result {
2024 let result = match protocol::parse_reply(&value) {
2025 Ok(Some(reply @ Reply::Start { .. })) => {
2026 if output_failed {
2027 Err("command reported an error or exceeded its output budget"
2028 .into())
2029 } else if self.streaming || self.stream.is_some() {
2030 Err("foreground turn still active".into())
2031 } else if let Some(reason) = self.driver_idle_conflict() {
2032 Err(reason.into())
2033 } else {
2034 match handler_generation {
2035 Some(generation) => {
2036 self.driver_arm(
2037 owner, handler, generation, reply,
2038 )
2039 }
2040 None => Err(
2041 "command owner did not expose a live lifecycle at dispatch"
2042 .into(),
2043 ),
2044 }
2045 }
2046 }
2047 Ok(Some(
2048 Reply::Stop { notice: text }
2049 | Reply::Status { notice: text },
2050 )) => {
2051 self.emit(SessionEventWire::SystemNotice(text));
2052 Ok(())
2053 }
2054 Ok(Some(Reply::Next { .. })) => Err(
2055 "next requires an armed poll, not an interactive command"
2056 .into(),
2057 ),
2058 Ok(None) => Ok(()),
2059 Err(error) => Err(error),
2060 };
2061 if let Err(error) = result {
2062 self.emit(SessionEventWire::SystemNotice(format!(
2063 "Session driver rejected: {error}"
2064 )));
2065 }
2066 }
2067 }
2068 super::driver::TaskResult::Poll(result) => {
2069 let Some(driver) = self.driver.as_mut() else {
2070 return;
2071 };
2072 let stop_notice = match &result {
2073 Ok(Reply::Stop { notice }) => Some(notice.clone()),
2074 _ => None,
2075 };
2076 match result.and_then(|reply| driver.grant.accept(reply)) {
2077 Ok(Some(proposal)) => {
2078 let text = proposal.notice.clone();
2079 if let Err(error) =
2080 super::driver::schedule(driver, proposal)
2081 {
2082 self.driver_revoke(&error);
2083 } else {
2084 self.emit(SessionEventWire::SystemNotice(text));
2085 }
2086 }
2087 Ok(None) => {
2088 self.driver_revoke("owner requested stop");
2089 if let Some(text) = stop_notice {
2090 self.emit(SessionEventWire::SystemNotice(text));
2091 }
2092 }
2093 Err(error) => {
2094 self.driver_revoke(&format!("invalid poll response: {error}"))
2095 }
2096 }
2097 }
2098 super::driver::TaskResult::Prepared { proposal, result } => {
2099 if self.streaming || self.stream.is_some() {
2100 self.driver_revoke("foreground work took priority");
2101 return;
2102 }
2103 if let Some(reason) = self.driver_idle_conflict() {
2107 self.driver_revoke(reason);
2108 return;
2109 }
2110 match *result {
2111 Err(protocol::PrepareError::Selection(error)) => {
2112 self.emit(SessionEventWire::SystemNotice(format!(
2113 "Session driver selection rejected (nothing sent): {error}"
2114 )));
2115 if let Some(driver) = self.driver.as_mut() {
2116 driver.outcome = Some((
2117 Outcome::SelectionRejected,
2118 "unknown".into(),
2119 ));
2120 }
2121 }
2122 Err(protocol::PrepareError::Blocked(error)) => {
2123 self.driver_revoke(&format!("preflight blocked: {error}"));
2124 }
2125 Ok(candidate) => {
2126 let validated = proposal.clone();
2127 let history = super::driver::history_with_steering(
2128 &self.conv.api_messages,
2129 &self
2130 .driver
2131 .as_ref()
2132 .map(|d| &d.steering)
2133 .cloned()
2134 .unwrap_or_default(),
2135 );
2136 if let Err(error) = protocol::validate_prepared(
2137 &candidate, &validated, &history,
2138 ) {
2139 self.driver_revoke(&format!(
2140 "prepared selection changed before commit: {error}"
2141 ));
2142 return;
2143 }
2144 {
2148 let driver = self.driver.as_mut().expect("driver present");
2149 let conv = &mut *self.conv;
2150 super::driver::commit_submission(
2151 &mut conv.api_messages,
2152 &mut driver.steering,
2153 &proposal.prompt,
2154 );
2155 }
2156 *self.runtime = candidate;
2158 self.conv.session.model = self.runtime.model().to_owned();
2159 self.conv.session.thinking_level =
2160 self.runtime.thinking_level().to_owned();
2161 self.consecutive_auto_turns = 0;
2162 self.stamp_new_messages();
2164 self.turn_baseline = self.conv.api_messages.len();
2165 self.turn_steered_events.clear();
2166 self.turn_replay.clear();
2167 self.streaming = true;
2168 self.open_turn_draft();
2169 self.update_attach_state();
2170 self.emit(SessionEventWire::TurnStarted {
2171 turn_baseline: self.turn_baseline,
2172 trigger: TurnTrigger::DriverAuto,
2173 user_text: Some(proposal.prompt.clone()),
2174 });
2175 self.announce_web_search_state();
2176
2177 if let Some(driver) = self.driver.as_mut() {
2179 driver.awaiting_terminal = true;
2180 driver.completed_feedback = "unknown";
2181 if driver.grant.feedback_enabled() {
2182 driver.feedback.begin_turn(
2183 &driver.selection.model,
2184 &driver.selection.effort,
2185 );
2186 }
2187 }
2188
2189 let handler = self
2191 .driver
2192 .as_ref()
2193 .expect("driver")
2194 .handler
2195 .clone();
2196 let handler_generation = self
2197 .driver
2198 .as_ref()
2199 .expect("driver")
2200 .handler_generation;
2201 let ct = self
2202 .driver
2203 .as_ref()
2204 .expect("driver")
2205 .cancel
2206 .child_token();
2207 self.cancel = Some(ct.clone());
2208 let completion = crate::runtime::TurnCompletion::with_default_cause(
2211 crate::runtime::CancelCause::Driver,
2212 );
2213 self.turn_completion = Some(completion.clone());
2214 let (tx, rx) = mpsc::unbounded_channel();
2215 self.steer_tx = Some(tx);
2216 self.owner_steer_budget = None;
2217 let mut runtime = self.runtime.clone();
2218 runtime.share_ttl_latches(&self.runtime);
2222 let history = self.conv.api_messages.clone();
2223 let secret = self.secret_prompt_handle.clone();
2224 let session_id = self.id.0.clone();
2225 let generation = self.driver_generation;
2226 self.driver_pending = Some(super::driver::DriverPending {
2227 generation,
2228 session_id,
2229 task: super::driver::Task(tokio::spawn(async move {
2230 if ct.is_cancelled() {
2231 return super::driver::TaskResult::Started(
2232 Err("canceled before stream setup".into()),
2233 );
2234 }
2235 if let Err(error) =
2236 super::driver::same_lifecycle(
2237 &handler,
2238 handler_generation,
2239 )
2240 {
2241 return super::driver::TaskResult::Started(
2242 Err(error),
2243 );
2244 }
2245 tokio::select! {
2246 biased;
2247 _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
2248 started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
2249 }
2250 })),
2251 });
2252
2253 let mut tick = tokio::time::interval(
2255 std::time::Duration::from_secs(1),
2256 );
2257 tick.set_missed_tick_behavior(
2258 tokio::time::MissedTickBehavior::Delay,
2259 );
2260 tick.reset();
2261 self.subagent_tick = Some(tick);
2262 self.request_save();
2263 }
2264 }
2265 }
2266 super::driver::TaskResult::Started(started) => match started {
2267 Ok(started) if self.driver.is_some() => {
2268 self.stream = Some(started);
2269 }
2270 Err(error) => {
2271 self.driver_revoke(&error);
2272 }
2273 _ => {}
2274 },
2275 }
2276 }
2277
2278 if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
2280 return;
2281 }
2282 if let Some(reason) = self.driver_idle_conflict() {
2283 self.driver_revoke(reason);
2284 return;
2285 }
2286 let session_cost = self.conv.session_cost;
2287 let Some(driver) = self.driver.as_mut() else {
2288 return;
2289 };
2290 if let Some((outcome, error_kind)) = driver.outcome.take() {
2291 let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
2292 let handler = driver.handler.clone();
2293 let session_id = self.id.0.clone();
2294 let generation = self.driver_generation;
2295 self.driver_pending = Some(super::driver::DriverPending {
2296 generation,
2297 session_id,
2298 task: super::driver::Task(tokio::spawn(async move {
2299 let result = tokio::time::timeout(
2302 Duration::from_secs(30),
2303 protocol::poll(handler, request),
2304 )
2305 .await
2306 .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
2307 super::driver::TaskResult::Poll(result)
2308 })),
2309 });
2310 } else if driver
2311 .proposal
2312 .as_ref()
2313 .is_some_and(|p| Instant::now() >= p.due)
2314 {
2315 let proposal =
2316 driver.proposal.take().expect("checked proposal").proposal;
2317 let runtime = self.runtime.clone();
2320 let history = super::driver::history_with_steering(
2321 &self.conv.api_messages,
2322 &driver.steering,
2323 );
2324 let session_id = self.id.0.clone();
2325 let generation = self.driver_generation;
2326 self.driver_pending = Some(super::driver::DriverPending {
2327 generation,
2328 session_id,
2329 task: super::driver::Task(tokio::spawn(async move {
2330 let result = tokio::time::timeout(
2331 Duration::from_secs(5),
2332 protocol::prepare(&runtime, &proposal, &history),
2333 )
2334 .await
2335 .unwrap_or_else(|_| {
2336 Err(protocol::PrepareError::Blocked(
2337 "preparation timed out (5s)".into(),
2338 ))
2339 });
2340 super::driver::TaskResult::Prepared {
2341 proposal,
2342 result: Box::new(result),
2343 }
2344 })),
2345 });
2346 }
2347 }
2348
2349 pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
2354 match Self::try_lock_session(new_id) {
2355 Ok(lock) => self.session_lock = Some(lock),
2356 Err(e) => {
2357 tracing::warn!(session = %new_id, "reacquire session lock: {e}");
2358 self.session_lock = None;
2359 }
2360 }
2361 }
2362
2363 pub(crate) fn try_lock_session(
2365 id: &str,
2366 ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
2367 {
2368 let dir = agent_core::session_lock::sessions_dir();
2369 let holder = agent_core::session_lock::LockHolder {
2370 pid: std::process::id(),
2371 kind: "daemon".to_string(),
2372 };
2373 agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
2374 }
2375
2376 pub(crate) fn can_end_idle(&self) -> bool {
2381 self.attached.is_empty()
2382 && !self.streaming
2383 && self.compact.is_none()
2384 && self.pending_prompts.is_empty()
2385 && self.conv.is_live()
2386 && self.conv.api_messages.is_empty()
2387 && self.conv.queued_message.is_none()
2388 && !self.keep_warm
2389 && !self.is_parked()
2390 && self.driver.is_none()
2395 && self.driver_pending.is_none()
2396 && self.owned.is_none()
2398 }
2399
2400 pub(crate) fn rearm_park(&mut self) {
2401 self.rearm_park_deadline();
2402 self.publish_presence();
2403 }
2404
2405 fn rearm_park_deadline(&mut self) {
2406 if self.is_parked() {
2407 return;
2410 }
2411 if self.can_end_idle() {
2412 if self.park_deadline.is_none() {
2417 self.park_deadline =
2418 Some(tokio::time::Instant::now() + idle_end_grace());
2419 }
2420 return;
2421 }
2422 if !self.can_park() {
2423 let workers_only = self.can_park_but_for_workers()
2426 && !self.cache_keepwarm.active()
2427 && park_grace().is_some();
2428 self.park_deadline = if workers_only {
2429 self.park_deadline.or_else(|| {
2430 let at = tokio::time::Instant::now() + worker_park_recheck();
2431 self.park_recheck_at = Some(at);
2432 Some(at)
2433 })
2434 } else {
2435 None
2436 };
2437 return;
2438 }
2439 match park_grace() {
2440 Some(grace) => {
2441 let due = tokio::time::Instant::now() + grace;
2445 self.park_deadline = Some(self.park_deadline.map_or(due, |d| d.min(due)));
2448 }
2449 None => self.park_deadline = None,
2450 }
2451 }
2452
2453 fn rearm_prompt_abandon(&mut self) {
2463 let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
2464 let abandoned = self.attached.is_empty()
2465 && !self.pending_prompts.is_empty()
2466 && !armed_driver
2467 && !self.is_parked();
2468 if !abandoned {
2469 self.prompt_abandon_deadline = None;
2472 return;
2473 }
2474 if self.prompt_abandon_deadline.is_none() {
2475 self.prompt_abandon_deadline =
2477 prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
2478 }
2479 }
2480
2481 fn on_prompt_abandon_deadline(&mut self) {
2486 self.prompt_abandon_deadline = None;
2487 if !self.attached.is_empty() {
2488 return;
2489 }
2490 let had_prompts = !self.pending_prompts.is_empty();
2491 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2492 let _ = tx.send(None);
2493 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2494 }
2495 if had_prompts {
2496 tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
2497 self.publish_presence();
2498 self.rearm_park();
2500 }
2501 }
2502
2503 fn set_lifecycle(&mut self, l: SessionLifecycle) {
2504 self.lifecycle
2505 .store(l as u8, std::sync::atomic::Ordering::Release);
2506 self.emit(SessionEventWire::Lifecycle(l));
2507 }
2508
2509 pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
2513 let flow = self.park_now().await;
2514 self.publish_presence();
2515 if flow.is_continue()
2518 && !self.is_parked()
2519 && matches!(self.session_start, super::session_start::SessionStart::Pending)
2520 {
2521 self.queue_session_start();
2522 }
2523 flow
2524 }
2525
2526 async fn park_now(&mut self) -> std::ops::ControlFlow<EndReason> {
2527 let recheck = self.park_deadline.is_some() && self.park_deadline == self.park_recheck_at;
2529 self.park_recheck_at = None;
2530 self.park_deadline = None;
2531 if self.is_parked() {
2532 if self.attached.is_empty() {
2533 tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
2534 return std::ops::ControlFlow::Break(EndReason::Evicted);
2535 }
2536 return std::ops::ControlFlow::Continue(());
2537 }
2538 if self.can_end_idle() {
2539 tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
2540 if let Some(lock) = self.session_lock.take() {
2543 lock.release_and_remove();
2544 }
2545 return std::ops::ControlFlow::Break(EndReason::Idle);
2546 }
2547 if !self.can_park() {
2548 self.rearm_park();
2550 return std::ops::ControlFlow::Continue(());
2551 }
2552 if recheck {
2553 if let Some(grace) = park_grace() {
2556 self.park_deadline = Some(tokio::time::Instant::now() + grace);
2557 return std::ops::ControlFlow::Continue(());
2558 }
2559 }
2560 self.state = AttachState::Parking;
2561 self.set_lifecycle(SessionLifecycle::Parking);
2562 if !self.save().await {
2565 tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
2566 self.state = AttachState::Detached { running: false };
2567 self.set_lifecycle(SessionLifecycle::Live);
2568 return std::ops::ControlFlow::Continue(());
2569 }
2570 if !self.journal_exists() {
2571 tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
2573 self.state = AttachState::Detached { running: false };
2574 self.set_lifecycle(SessionLifecycle::Live);
2575 return std::ops::ControlFlow::Continue(());
2576 }
2577 if self.runtime.session_manager().active_count() > 0 {
2578 self.emit(SessionEventWire::SystemNotice(
2579 "parked: background shells closed".into(),
2580 ));
2581 }
2582 self.runtime.session_manager().shutdown_all();
2583 self.turn_replay.clear();
2584 self.parked_tool_activations = self.runtime.carried_tool_activations();
2588 self.publish_presence();
2592 let conv = self.conv.park_take();
2593 drop(conv);
2594 let runtime = self.runtime.park_take();
2595 drop(runtime);
2596 crate::core::memstat::purge_arenas();
2600 self.session_lock = None;
2604 self.session_start.withdraw_for_park();
2606 self.state = AttachState::Parked;
2607 self.set_lifecycle(SessionLifecycle::Parked);
2608 tracing::info!(session = %self.id, "session parked");
2609 self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
2613 std::ops::ControlFlow::Continue(())
2614 }
2615
2616 pub(crate) async fn unpark(&mut self) -> Result<()> {
2621 let started = std::time::Instant::now();
2622 self.park_deadline = None;
2624 let journal_id = (**self.journal_id.load()).clone();
2625
2626 {
2630 let dir = agent_core::session_lock::sessions_dir();
2631 let holder = agent_core::session_lock::LockHolder {
2632 pid: std::process::id(),
2633 kind: "daemon".to_string(),
2634 };
2635 match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
2636 Ok(lock) => self.session_lock = Some(lock),
2637 Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
2638 return Err(crate::RuntimeError::Session(format!(
2639 "cannot unpark session {}: journal locked by another process",
2640 journal_id
2641 )));
2642 }
2643 Err(e) => {
2644 tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
2645 }
2647 }
2648 }
2649
2650 self.config.cwd = live_folder(self.config.cwd.take(), &journal_id);
2654 let host = Arc::clone(&self.host);
2655 let cfg = self.config.clone();
2656 let queue = Arc::clone(&self.event_queue);
2657 let replay = self.settings_replay.clone();
2658 let journal_present = self.journal_exists();
2659 let view = self.view.load_full();
2660 let build = async move {
2661 let config: crate::SynapsConfig = (**host.config()).clone();
2662 let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
2664 runtime.set_event_queue(queue);
2665 let mut sb = if journal_present {
2666 crate::engine::setup::resolve_session_and_prompt(
2667 &mut runtime,
2668 &Some(Some(journal_id)),
2669 cfg.system.as_deref(),
2670 cfg.prompt_manifest.as_deref(),
2671 )?
2672 } else {
2673 tracing::warn!(
2677 session = %journal_id,
2678 "unpark: journal missing — restoring as a fresh conversation"
2679 );
2680 let mut sb = crate::engine::setup::resolve_session_and_prompt(
2681 &mut runtime,
2682 &None,
2683 cfg.system.as_deref(),
2684 cfg.prompt_manifest.as_deref(),
2685 )?;
2686 sb.session.id = journal_id.clone();
2687 runtime.set_session_id(Some(journal_id));
2688 sb
2689 };
2690 runtime.set_cwd(cfg.cwd.clone());
2691 runtime.set_env(cfg.env.clone());
2692 runtime.set_env_stripped(cfg.env_stripped.clone());
2693 runtime.set_model(view.model.clone());
2696 let _ = runtime.restore_session_reasoning(&view.thinking_level);
2697 if let Some(clock) = &cfg.message_clock {
2698 runtime.set_message_clock(clock.clone());
2699 }
2700 sb.session.model = view.model.clone();
2701 sb.session.thinking_level = runtime.thinking_level().to_string();
2702 crate::engine::setup::finish_session_setup(
2703 &mut runtime,
2704 &config,
2705 &sb.session,
2706 cfg.cwd.clone(),
2707 crate::engine::setup::IndexRecord::Skip,
2708 );
2709 for s in &replay {
2710 replay_setting(&mut runtime, s);
2711 }
2712 Ok::<_, crate::RuntimeError>((runtime, sb))
2713 };
2714 let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
2715 Ok(Ok(v)) => v,
2716 Ok(Err(e)) => return Err(e),
2717 Err(_) => {
2718 return Err(crate::RuntimeError::Session(format!(
2719 "unpark timed out after {}s",
2720 budgets::UNPARK_TIMEOUT_SECS
2721 )))
2722 }
2723 };
2724 let mut conv = ConversationState::from_resumed(sb.session);
2725 conv.api_messages = sb.api_messages;
2726 conv.total_input_tokens = sb.total_input_tokens;
2727 conv.total_output_tokens = sb.total_output_tokens;
2728 conv.session_cost = sb.session_cost;
2729 if journal_present && self.config.persist && self.session_lock.is_some() {
2730 crate::engine::setup::recover_turn_draft(&mut conv).await;
2731 }
2732 self.message_stamper.settle(conv.api_messages.len());
2733 runtime.restore_tool_activations(std::mem::take(&mut self.parked_tool_activations));
2734 self.runtime.unpark_set(runtime);
2735 self.conv.unpark_set(conv);
2736 if self.meta.name != self.conv.session.name {
2738 let name = self.conv.session.name.clone();
2739 self.mirror_name(name);
2740 }
2741 self.publish_view().await;
2742 self.state = AttachState::Detached { running: false };
2743 self.set_lifecycle(SessionLifecycle::Live);
2744 self.update_attach_state();
2745 if matches!(self.session_start, super::session_start::SessionStart::Pending) {
2747 self.queue_session_start();
2748 }
2749 tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2750 Ok(())
2751 }
2752
2753 pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2757 if !self.is_parked() {
2758 return Ok(());
2759 }
2760 let res = self
2761 .unpark()
2762 .await
2763 .map_err(|e| format!("session parked and could not be restored: {e}"));
2764 self.publish_presence();
2765 res
2766 }
2767
2768 pub(crate) fn busy(&self) -> bool {
2770 self.streaming || self.compact.is_some()
2771 }
2772
2773 pub(crate) fn publish_subagent_rows(&mut self) {
2784 let rows = self
2785 .runtime
2786 .subagent_registry()
2787 .lock()
2788 .unwrap_or_else(|p| p.into_inner())
2789 .display_rows();
2790 let running = rows
2791 .iter()
2792 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2793 if !rows.is_empty() {
2794 self.emit(SessionEventWire::SubagentRows(rows));
2795 }
2796 if !keep_subagent_tick(self.streaming, running) {
2797 self.subagent_tick = None;
2798 self.rearm_park();
2800 }
2801 }
2802
2803 fn subagents_running(&self) -> bool {
2805 self.runtime
2806 .subagent_registry()
2807 .lock()
2808 .unwrap_or_else(|p| p.into_inner())
2809 .display_rows()
2810 .iter()
2811 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2812 }
2813
2814 fn announce_web_search_state(&mut self) {
2819 let notice = crate::runtime::openai::codex_wire::session_web_search_notice(
2820 self.runtime.model(),
2821 crate::runtime::openai::codex_wire::engine_env,
2822 );
2823 if notice.is_some() && notice != self.web_search_notice {
2824 self.web_search_notice = notice.clone();
2825 if let Some(text) = notice {
2826 self.emit(SessionEventWire::Stream(StreamEvent::Session(
2827 SessionEvent::Notice(text),
2828 )));
2829 }
2830 }
2831 }
2832
2833 async fn session_start_finished(
2841 &mut self,
2842 ran: std::result::Result<bool, tokio::task::JoinError>,
2843 ) {
2844 use super::session_start::SessionStart;
2845 match ran {
2846 Ok(true) => {}
2847 Ok(false) => {
2850 self.session_start = SessionStart::Pending;
2851 if self.turn_awaiting_start.is_some() || self.cache_keepwarm.deadline().is_some() {
2852 self.session_start_ready();
2853 }
2854 return;
2855 }
2856 Err(e) => tracing::warn!(session = %self.id, "on_session_start task failed: {e}"),
2857 }
2858 self.session_start = SessionStart::Done;
2859 if self.runtime.is_live() {
2860 self.publish_view().await;
2861 }
2862 if let Some(start) = self.turn_awaiting_start.take() {
2863 if !start.cancel.is_cancelled() {
2866 self.open_turn_stream(start).await;
2867 }
2868 }
2869 }
2870
2871 pub(crate) fn session_start_ready(&mut self) -> bool {
2887 use super::session_start::SessionStart;
2888 if matches!(self.session_start, SessionStart::Pending) {
2889 self.queue_session_start();
2890 }
2891 match &self.session_start {
2892 SessionStart::Done => true,
2893 SessionStart::Queued { ticket, .. } => {
2894 ticket.prefer();
2895 false
2896 }
2897 SessionStart::Pending => false,
2898 }
2899 }
2900
2901 fn queue_session_start(&mut self) {
2903 self.session_start = super::session_start::SessionStart::queue(
2904 &self.host,
2905 Arc::clone(&self.hook_bus),
2906 Arc::clone(&self.journal_id),
2907 self.config.cwd.clone(),
2908 extensions_ready_timeout(&self.host.config()),
2909 );
2910 }
2911
2912 async fn open_turn_stream(&mut self, start: TurnStart) {
2914 let session_budget = start.event_turn.then(|| {
2920 let event_budget = self
2921 .runtime
2922 .turn_budget()
2923 .for_event_turn(&self.host.config().turn_budgets);
2924 let session_budget = self.runtime.turn_budget().clone();
2925 self.runtime.set_turn_budget(event_budget);
2926 self.runtime
2927 .set_owner_steer_budget(self.owner_steer_budget.clone());
2928 session_budget
2929 });
2930 let stream = self
2931 .runtime
2932 .run_stream_tracked(
2933 self.conv.api_messages.clone(),
2934 start.cancel,
2935 Some(start.steer_rx),
2936 Some(self.secret_prompt_handle.clone()),
2937 start.auto_approve,
2938 start.completion,
2939 )
2940 .await;
2941 if let Some(session_budget) = session_budget {
2942 self.runtime.set_turn_budget(session_budget);
2943 self.runtime.set_owner_steer_budget(None);
2944 }
2945 self.stream = Some(stream);
2946 }
2947
2948 pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2952 self.cache_keepwarm_turn_started();
2953 let ct = CancellationToken::new();
2954 let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2955 self.streaming = true;
2956 self.stamp_new_messages();
2959 self.turn_baseline = self.conv.api_messages.len();
2960 self.open_turn_draft();
2961 self.turn_steered_events.clear();
2962 self.turn_replay.clear();
2963 self.update_attach_state();
2964 self.emit(SessionEventWire::TurnStarted {
2965 turn_baseline: self.turn_baseline,
2966 trigger,
2967 user_text,
2968 });
2969 self.announce_web_search_state();
2970 let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2978 self.turn_worker_generation = self
2980 .runtime
2981 .subagent_registry()
2982 .lock()
2983 .unwrap_or_else(|p| p.into_inner())
2984 .begin_turn();
2985 let completion = crate::runtime::TurnCompletion::new();
2986 self.turn_completion = Some(completion.clone());
2987 self.cancel = Some(ct.clone());
2988 self.steer_tx = Some(s_tx);
2989 let event_turn = trigger == TurnTrigger::EventAuto;
2990 self.owner_steer_budget = event_turn.then(Default::default);
2991 let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2992 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2993 tick.reset();
2994 self.subagent_tick = Some(tick);
2995 let start = TurnStart {
2996 cancel: ct,
2997 steer_rx: s_rx,
2998 auto_approve,
2999 completion,
3000 event_turn,
3001 };
3002 if self.session_start_ready() {
3003 self.open_turn_stream(start).await;
3004 } else {
3005 tracing::debug!(session = %self.id, "turn waits for on_session_start");
3006 self.turn_awaiting_start = Some(start);
3007 self.emit(SessionEventWire::SystemNotice(
3011 "waiting for extensions to start (on_session_start) before sending...".into(),
3012 ));
3013 }
3014 }
3015
3016 pub(crate) fn clear_stream(&mut self) {
3024 self.stream = None;
3025 self.turn_awaiting_start = None;
3026 self.cancel = None;
3027 self.steer_tx = None;
3028 self.owner_steer_budget = None;
3029 self.streaming = false;
3030 self.turn_completion = None;
3031 self.revoked_turn_deadline = None;
3032 if !keep_subagent_tick(false, self.subagents_running()) {
3035 self.subagent_tick = None;
3036 }
3037 if !self.turn_steered_events.is_empty() && self.conv.is_live() {
3038 let undelivered = std::mem::take(&mut self.turn_steered_events);
3039 self.conv.pending_events.splice(0..0, undelivered);
3040 }
3041 self.request_save();
3044 self.close_turn_draft();
3045 self.update_attach_state();
3046 }
3047
3048 fn open_turn_draft(&mut self) {
3055 let persist =
3056 self.config.persist && self.conv.is_live() && self.session_lock.is_some();
3057 self.turn_draft = TurnDraftState {
3058 open: persist.then(|| self.conv.session.id.clone()),
3059 base_len: self.conv.api_messages.len(),
3060 text: String::new(),
3061 tools: Vec::new(),
3062 dirty: true,
3063 };
3064 self.flush_turn_draft();
3065 }
3066
3067 fn turn_draft_text(&mut self, delta: &str) {
3068 let d = &mut self.turn_draft;
3069 if d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES {
3070 d.text.push_str(delta);
3071 d.dirty = true;
3072 }
3073 }
3074
3075 fn turn_draft_reset_text(&mut self) {
3076 let d = &mut self.turn_draft;
3077 d.tools.clear();
3078 if !d.text.is_empty() {
3079 d.text.clear();
3080 d.dirty = true;
3081 }
3082 }
3083
3084 fn turn_draft_committed(&mut self) {
3086 self.turn_draft.base_len = self.conv.api_messages.len();
3087 self.turn_draft.text.clear();
3088 self.turn_draft.tools.clear();
3089 self.turn_draft.dirty = true;
3090 self.flush_turn_draft();
3091 }
3092
3093 fn turn_draft_rolled_over(&mut self) {
3099 self.turn_draft.base_len = usize::MAX;
3100 self.turn_draft.text.clear();
3101 self.turn_draft.tools.clear();
3102 self.turn_draft.dirty = true;
3103 self.flush_turn_draft();
3104 }
3105
3106 fn observe_turn_draft(&mut self, event: &crate::LlmEvent) {
3111 match event {
3112 crate::LlmEvent::Text(text) => self.turn_draft_text(text),
3113 crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset => {
3114 self.turn_draft_reset_text()
3115 }
3116 crate::LlmEvent::ToolUse { tool_name, .. } => {
3117 self.turn_draft.tools.push(tool_name.clone())
3118 }
3119 _ => {}
3120 }
3121 }
3122
3123 pub(crate) fn flush_turn_draft(&mut self) {
3126 let Some(open) = self.turn_draft.open.clone() else {
3127 return;
3128 };
3129 if !self.turn_draft.dirty {
3130 return;
3131 }
3132 let id = self.conv.session.id.clone();
3134 if open != id {
3135 self.persister.remove_draft(&open);
3136 self.turn_draft.open = Some(id.clone());
3137 }
3138 self.turn_draft.dirty = false;
3139 self.persister.write_draft(
3140 &id,
3141 agent_core::core::session_draft::TurnDraft {
3142 base_len: self.turn_draft.base_len,
3143 partial_text: self.turn_draft.text.clone(),
3144 },
3145 );
3146 }
3147
3148 fn close_turn_draft(&mut self) {
3154 if let Some(id) = self.turn_draft.open.take() {
3155 self.persister.remove_draft(&id);
3156 }
3157 self.turn_draft = TurnDraftState::default();
3158 }
3159
3160 pub(crate) async fn submit(
3162 &mut self,
3163 text: String,
3164 attachments: Vec<serde_json::Value>,
3165 from: Option<ClientId>,
3166 ) {
3167 if self.conv.context_head.is_blocked(&self.conv.session) {
3172 let reason = "context head is unverified after a failed checkpoint; reload the \
3173 session (`--continue`) or start a new one before continuing"
3174 .to_string();
3175 match from {
3178 Some(client) => self.emit(SessionEventWire::Refused {
3179 client,
3180 command: "submit".into(),
3181 reason,
3182 }),
3183 None => self.emit(SessionEventWire::SystemNotice(reason)),
3184 }
3185 return;
3186 }
3187 if self.streaming {
3188 if let Some(driver) = self.driver.as_mut() {
3194 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3195 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3196 self.emit(SessionEventWire::SystemNotice(
3197 "steering queue full (16 messages) — message dropped".into(),
3198 ));
3199 return;
3200 }
3201 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3202 self.emit(SessionEventWire::SystemNotice(
3203 "steering queue full (256 KiB) — message dropped".into(),
3204 ));
3205 return;
3206 }
3207 driver.steering.push_back(text.clone());
3208 }
3209 self.steer(text);
3210 return;
3211 }
3212 if self.driver.is_some() {
3215 let driver = self.driver.as_mut().unwrap();
3216 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3217 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3218 self.emit(SessionEventWire::SystemNotice(
3219 "steering queue full (16 messages) — message dropped".into(),
3220 ));
3221 return;
3222 }
3223 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3224 self.emit(SessionEventWire::SystemNotice(
3225 "steering queue full (256 KiB) — message dropped".into(),
3226 ));
3227 return;
3228 }
3229 driver.steering.push_back(text.clone());
3230 driver.auto_wakes_blocked = false;
3232 self.emit(SessionEventWire::Steered {
3233 text,
3234 delivered: false,
3235 });
3236 return;
3237 }
3238 if self.compact.is_some() {
3239 self.emit(SessionEventWire::Steered {
3241 text: text.clone(),
3242 delivered: false,
3243 });
3244 self.conv.queued_message = Some(text);
3245 return;
3246 }
3247 self.consecutive_auto_turns = 0;
3249 let user_text = text.clone();
3251 let api_content = text;
3252
3253 if attachments.is_empty() {
3254 self.conv.api_messages.push(std::sync::Arc::new(
3256 serde_json::json!({"role": "user", "content": api_content}),
3257 ));
3258 } else {
3259 let mut blocks = Vec::with_capacity(attachments.len() + 1);
3261 if !api_content.is_empty() {
3262 blocks.push(serde_json::json!({"type": "text", "text": api_content}));
3263 }
3264 blocks.extend(attachments);
3265 let candidate = std::sync::Arc::new(
3266 serde_json::json!({"role": "user", "content": blocks}),
3267 );
3268 let mut proposed = self.conv.api_messages.clone();
3271 proposed.push(candidate.clone());
3272 let model = self.runtime.model().to_string();
3273 if let Err(e) = crate::runtime::attachments::validate_history_for_request(&model, &proposed) {
3274 match from {
3278 Some(client) => self.emit(SessionEventWire::Refused {
3279 client,
3280 command: "submit".into(),
3281 reason: format!("attachments rejected: {e}"),
3282 }),
3283 None => self.emit(SessionEventWire::SystemNotice(format!(
3284 "attachments rejected: {e}"
3285 ))),
3286 }
3287 return;
3288 }
3289 self.conv.api_messages.push(candidate);
3290 }
3291 self.start_turn(TurnTrigger::User, Some(user_text)).await;
3292 }
3293
3294 pub(crate) fn steer(&mut self, text: String) {
3302 let send = |tx: &mpsc::UnboundedSender<String>| tx.send(text.clone()).is_ok();
3303 let delivered = match (self.steer_tx.as_ref(), self.owner_steer_budget.as_deref()) {
3304 (Some(tx), Some(lift)) => {
3305 lift.send_owner_steer(self.runtime.turn_budget().clone(), || send(tx))
3306 }
3307 (Some(tx), None) => send(tx),
3308 (None, _) => false,
3309 };
3310 self.emit(SessionEventWire::Steered {
3311 text: text.clone(),
3312 delivered,
3313 });
3314 self.conv.queued_message = Some(text);
3315 }
3316
3317 pub(crate) fn steer_subagent(&mut self, from: Option<ClientId>, handle_id: &str, text: String) {
3323 let steered = {
3324 let registry = self
3325 .runtime
3326 .subagent_registry()
3327 .lock()
3328 .unwrap_or_else(|p| p.into_inner());
3329 match registry.get(handle_id) {
3330 None => Err(format!("no subagent {handle_id} in this session")),
3331 Some(h) => {
3332 let running = h
3333 .state
3334 .read()
3335 .unwrap_or_else(|p| p.into_inner())
3336 .public_status()
3337 == crate::runtime::subagent::SubagentStatus::Running;
3338 if running {
3339 h.steer(&text).map(|()| h.agent_name.clone())
3340 } else {
3341 Err(format!("{} ({handle_id}) is not running", h.agent_name))
3342 }
3343 }
3344 }
3345 };
3346 let agent_name = match steered {
3347 Ok(name) => name,
3348 Err(reason) => {
3349 match from {
3350 Some(c) => self.emit(SessionEventWire::Refused {
3351 client: c,
3352 command: "subagent_steer".to_string(),
3353 reason,
3354 }),
3355 None => self.emit(SessionEventWire::SystemNotice(reason)),
3356 }
3357 return;
3358 }
3359 };
3360 self.note_in_main_conversation(subagent_steer_event(handle_id, &agent_name, &text));
3361 }
3362
3363 pub(crate) fn note_in_main_conversation(&mut self, event: crate::events::types::Event) {
3368 let formatted = crate::events::format_event_for_agent(&event);
3369 if self.busy() {
3370 let into_turn = self
3371 .steer_tx
3372 .as_ref()
3373 .is_some_and(|tx| tx.send(formatted.clone()).is_ok());
3374 if into_turn {
3375 self.turn_steered_events.push(formatted);
3376 } else {
3377 self.conv.pending_events.push(formatted);
3378 }
3379 } else {
3380 self.conv
3381 .api_messages
3382 .push(std::sync::Arc::new(serde_json::json!({
3383 "role": "user",
3384 "content": formatted
3385 })));
3386 self.request_save();
3387 }
3388 self.emit(SessionEventWire::External(event));
3389 self.emit_conversation();
3390 }
3391
3392 pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3414 if !self.streaming {
3415 if self.compact.is_some() {
3416 self.abort_compaction();
3417 if let Some(q) = self.conv.queued_message.take() {
3418 self.emit(SessionEventWire::Dequeued { text: q });
3419 }
3420 self.update_attach_state();
3421 }
3422 self.emit(SessionEventWire::Idle);
3423 return;
3424 }
3425 self.note_cancel_cause(reason);
3426 if let Some(ref ct) = self.cancel {
3427 ct.cancel();
3428 }
3429 self.resolve_pending_prompts();
3434 let drain = self.drain_cancelled_stream().await;
3435 match drain.history {
3436 Some(history) => self.conv.api_messages = history,
3437 None => {
3438 if !drain.closed {
3439 tracing::warn!(
3440 session = %self.id,
3441 budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
3442 "cancelled turn did not publish its history within the drain budget; \
3443 keeping the last adopted history plus the streamed partial reply"
3444 );
3445 }
3446 self.keep_streamed_partial(reason);
3447 }
3448 }
3449 let completed = drain.closed && self.turn_completed();
3452 self.finish_interrupted_turn(reason, completed).await;
3453 }
3454
3455 fn keep_streamed_partial(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3473 let d = &self.turn_draft;
3474 let follows_user = self.conv.api_messages.last().is_some_and(|m| m["role"] == "user");
3475 if d.base_len != self.conv.api_messages.len() || !follows_user {
3476 return;
3477 }
3478 let (text, tools) = (d.text.clone(), d.tools.clone());
3479 if !text.trim().is_empty() {
3480 self.conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3481 "role": "assistant",
3482 "content": [{"type": "text", "text": text}]
3483 })));
3484 }
3485 if !tools.is_empty() {
3486 let note = crate::engine::interrupt::tool_in_flight_note_message(reason, &tools);
3487 let text = note["content"].as_str().unwrap_or_default().to_owned();
3488 self.conv.api_messages.push(note);
3489 self.emit(SessionEventWire::SystemNotice(text));
3490 }
3491 }
3492
3493 fn turn_completed(&self) -> bool {
3495 self.turn_completion.as_ref().is_some_and(|c| c.completed())
3496 }
3497
3498 pub(crate) fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
3503 if !self.streaming {
3504 return;
3505 }
3506 if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
3507 turn.note_cancel_cause(cause);
3508 }
3509 }
3510
3511 async fn finish_interrupted_turn(
3521 &mut self,
3522 reason: crate::engine::interrupt::InterruptReason,
3523 completed: bool,
3524 ) {
3525 crate::engine::stream::repair_history_after_failure(
3527 &mut self.conv.api_messages,
3528 self.turn_baseline,
3529 );
3530 let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
3531 if !completed {
3532 crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
3533 }
3534 if let Some(q) = self.conv.queued_message.take() {
3536 self.emit(SessionEventWire::Dequeued { text: q });
3537 }
3538 {
3542 let undelivered = std::mem::take(&mut self.turn_steered_events);
3543 let conv: &mut ConversationState = &mut self.conv;
3544 for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
3545 conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3546 "role": "user",
3547 "content": formatted
3548 })));
3549 }
3550 }
3551 self.clear_stream();
3553 {
3560 use crate::runtime::subagent::WorkerCancelScope;
3561 let ending = self.lifecycle.load(std::sync::atomic::Ordering::Acquire)
3562 == SessionLifecycle::Ending as u8;
3563 let scope = worker_cancel_scope(reason, ending, self.turn_worker_generation);
3564 let mut registry = match self.runtime.subagent_registry().lock() {
3565 Ok(g) => g,
3566 Err(poisoned) => {
3567 tracing::warn!(
3568 "subagent registry mutex poisoned during abort; recovering to cancel running handles"
3569 );
3570 poisoned.into_inner()
3571 }
3572 };
3573 let cancelled = registry.cancel_running(scope);
3574 if !cancelled.is_empty() {
3575 tracing::info!(
3576 ?scope,
3577 workers = ?cancelled,
3578 "turn interrupted: cancelled running workers"
3579 );
3580 }
3581 if let WorkerCancelScope::Turn(_) = scope {
3582 let spared = registry
3585 .display_rows()
3586 .iter()
3587 .filter(|r| {
3588 r.status == crate::runtime::subagent::SubagentStatus::Running
3589 && !r.cancel_requested
3590 })
3591 .count();
3592 drop(registry);
3593 if spared > 0 {
3594 self.emit(SessionEventWire::Stream(StreamEvent::Session(
3595 SessionEvent::Notice(format!(
3596 "{spared} background worker{} from earlier turns still running — /workers to list, /workers cancel all to stop",
3597 if spared == 1 { "" } else { "s" }
3598 )),
3599 )));
3600 }
3601 }
3602 }
3603 if completed {
3604 self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
3605 } else {
3606 self.emit(SessionEventWire::Aborted {
3609 context_saved: kept_partial,
3610 });
3611 }
3612 self.emit_conversation();
3613 self.announce_idle().await;
3614 }
3615
3616 async fn finish_revoked_turn(&mut self, stream_closed: bool) {
3624 if !self.streaming {
3625 return;
3626 }
3627 let completed = stream_closed && self.turn_completed();
3628 self.stream = None;
3629 if !completed {
3630 self.keep_streamed_partial(crate::engine::interrupt::InterruptReason::Driver);
3631 }
3632 self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
3633 .await;
3634 }
3635
3636 fn turn_cancelled(&self) -> bool {
3639 self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
3640 }
3641
3642 fn resolve_pending_prompts(&mut self) {
3644 if self.pending_prompts.is_empty() {
3645 return;
3646 }
3647 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3648 let _ = tx.send(None);
3649 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3650 }
3651 self.publish_presence();
3652 self.rearm_prompt_abandon();
3653 }
3654
3655 async fn drain_cancelled_stream(&mut self) -> CancelDrain {
3674 let mut out = CancelDrain::default();
3675 let Some(mut stream) = self.stream.take() else {
3676 out.closed = true;
3677 return out;
3678 };
3679 let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
3680 loop {
3681 let event = tokio::select! {
3682 biased;
3683 Some(req) = self.secret_prompt_rx.recv() => {
3684 let _ = req.response_tx.send(None);
3685 continue;
3686 }
3687 next = tokio::time::timeout_at(deadline, stream.next()) => match next {
3688 Err(_) => break,
3689 Ok(None) => {
3690 out.closed = true;
3691 break;
3692 }
3693 Ok(Some(event)) => event,
3694 },
3695 };
3696 match event {
3697 StreamEvent::Session(SessionEvent::Done) => {
3698 out.closed = true;
3699 break;
3700 }
3701 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3702 out.history = Some(history);
3703 }
3704 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
3705 session_id,
3706 messages,
3707 receipt,
3708 }) => {
3709 self.handle_context_head_checkpoint(session_id, messages, receipt)
3710 .await;
3711 out.history = None;
3715 }
3716 StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
3717 crate::TurnOutcome::Canceled => {}
3718 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3719 self.emit(SessionEventWire::SystemNotice(err.message));
3720 }
3721 _ => {
3722 tracing::warn!(
3723 session = %self.id,
3724 category = err.category_label(),
3725 "cancelled turn reported an error while draining"
3726 );
3727 }
3728 },
3729 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3730 self.note_steering_delivered(message);
3731 self.emit(SessionEventWire::Stream(event));
3732 }
3733 StreamEvent::Session(SessionEvent::Usage { .. }) => {
3734 self.record_usage(&event);
3735 self.emit(SessionEventWire::Stream(event));
3736 }
3737 StreamEvent::Llm(ref llm) => {
3738 self.observe_turn_draft(llm);
3739 self.emit(SessionEventWire::Stream(event));
3740 }
3741 other => self.emit(SessionEventWire::Stream(other)),
3742 }
3743 }
3744 drop(stream);
3745 out
3746 }
3747
3748 fn note_steering_delivered(&mut self, message: &str) {
3750 if self.conv.queued_message.as_deref() == Some(message) {
3751 self.conv.queued_message = None;
3752 self.emit_conversation();
3753 }
3754 if let Some(driver) = self.driver.as_mut() {
3756 if driver.steering.front().map(String::as_str) == Some(message) {
3757 driver.steering.pop_front();
3758 }
3759 }
3760 if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
3761 self.turn_steered_events.remove(pos);
3762 }
3763 }
3764
3765 fn record_usage(&mut self, event: &StreamEvent) {
3767 if let StreamEvent::Session(SessionEvent::Usage {
3768 input_tokens,
3769 output_tokens,
3770 cache_read_input_tokens,
3771 cache_creation_input_tokens,
3772 cache_creation_5m,
3773 cache_creation_1h,
3774 model: usage_model,
3775 }) = event
3776 {
3777 let model_for_pricing = usage_model
3778 .as_deref()
3779 .unwrap_or(self.runtime.model())
3780 .to_string();
3781 self.cache_keepwarm_note_usage(crate::runtime::keepwarm::KeepWarmUsage {
3782 input_tokens: *input_tokens,
3783 output_tokens: *output_tokens,
3784 cache_read_tokens: *cache_read_input_tokens,
3785 cache_write_tokens: *cache_creation_input_tokens,
3786 cache_write_5m: *cache_creation_5m,
3787 cache_write_1h: *cache_creation_1h,
3788 model: usage_model.clone(),
3789 });
3790 self.conv.add_usage(
3791 *input_tokens,
3792 *output_tokens,
3793 *cache_read_input_tokens,
3794 *cache_creation_input_tokens,
3795 *cache_creation_5m,
3796 *cache_creation_1h,
3797 &model_for_pricing,
3798 );
3799 }
3800 }
3801
3802 async fn on_queue_wake(&mut self) {
3805 if self.holds(WorkSource::QueueWake) {
3806 return;
3808 }
3809 if self.is_parked() {
3810 if self.event_queue.is_empty() {
3811 return;
3812 }
3813 if let Err(e) = self.ensure_live().await {
3814 tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
3815 return;
3816 }
3817 }
3818 let busy = self.busy();
3819 let conv: &mut ConversationState = &mut self.conv;
3820 let drained = drain_event_queue(
3821 &self.event_queue,
3822 &mut conv.api_messages,
3823 &mut conv.pending_events,
3824 busy,
3825 self.steer_tx.as_ref(),
3826 );
3827 if drained.is_empty() {
3828 return;
3829 }
3830 for de in &drained {
3831 if de.disposition == EventDisposition::Steered {
3832 self.turn_steered_events.push(de.formatted.clone());
3833 }
3834 self.emit(SessionEventWire::External(de.event.clone()));
3835 }
3836 let injected = drained
3837 .iter()
3838 .any(|d| d.disposition == EventDisposition::Injected);
3839 if injected || busy {
3840 self.emit_conversation();
3841 }
3842
3843 if self.streaming
3846 && self
3847 .driver
3848 .as_ref()
3849 .is_some_and(|d| d.awaiting_terminal)
3850 {
3851 let competing = drained
3852 .iter()
3853 .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
3854 if competing {
3855 self.driver_revoke("event-bus work took priority");
3856 }
3857 }
3858
3859 let auto_turn_enabled = self.host.config().events.auto_turn;
3864 let auto_turn_cap = self.host.config().events.auto_turn_cap;
3865 let action = wake_action_with_cap(
3866 &drained,
3867 &self.conv.api_messages,
3868 busy,
3869 auto_turn_enabled,
3870 self.consecutive_auto_turns,
3871 auto_turn_cap,
3872 );
3873 match action {
3874 WakeAction::RunTurn => {
3875 if self.driver.is_some() {
3878 tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
3879 } else if self.streaming || self.stream.is_some() {
3880 tracing::warn!("handle_event_arm: RunTurn with active turn — skipping");
3881 } else {
3882 self.consecutive_auto_turns += 1;
3883 self.start_turn(TurnTrigger::EventAuto, None).await;
3884 }
3885 }
3886 WakeAction::Forward => {
3887 let hit_cap = injected
3888 && !busy
3889 && auto_turn_enabled
3890 && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
3891 if hit_cap {
3892 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3893 }
3894 }
3895 WakeAction::Nothing => {}
3896 }
3897 }
3898
3899 async fn on_stream_event(&mut self, event: StreamEvent) {
3902 if self.turn_cancelled() {
3906 match event {
3907 StreamEvent::Session(SessionEvent::Done) => {
3908 self.finish_revoked_turn(true).await;
3909 return;
3910 }
3911 StreamEvent::Session(SessionEvent::Error(err)) => {
3913 match err.outcome {
3914 crate::TurnOutcome::Canceled => {}
3915 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3916 self.emit(SessionEventWire::SystemNotice(err.message));
3917 }
3918 _ => tracing::warn!(
3919 session = %self.id,
3920 category = err.category_label(),
3921 "revoked turn reported an error"
3922 ),
3923 }
3924 return;
3925 }
3926 _ => {}
3927 }
3928 }
3929 if !matches!(
3934 event,
3935 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
3936 ) {
3937 self.emit(SessionEventWire::Stream(event.clone()));
3938 }
3939
3940 if let Some(driver) = self.driver.as_mut() {
3942 if driver.awaiting_terminal && driver.grant.feedback_enabled() {
3943 driver.feedback.observe(&event);
3944 }
3945 }
3946
3947 let is_canceled = self
3949 .cancel
3950 .as_ref()
3951 .is_some_and(|ct| ct.is_cancelled());
3952 let terminal = if self
3953 .driver
3954 .as_ref()
3955 .is_some_and(|d| d.awaiting_terminal)
3956 {
3957 super::driver::capture_terminal(Some(&event), is_canceled)
3958 } else {
3959 None
3960 };
3961
3962 enum After {
3963 Continue,
3964 AutoSendQueued(String),
3965 AutoTriggerEvents,
3966 Failed,
3967 }
3968 let mut after = After::Continue;
3969
3970 match event {
3971 StreamEvent::Llm(ref llm) => self.observe_turn_draft(llm),
3974 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3975 self.conv.api_messages = history;
3981 self.request_save();
3982 self.turn_draft_committed();
3984 self.trim_replay_to_checkpoint();
3985 self.emit_conversation();
3986 }
3987 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3988 self.note_steering_delivered(message);
3989 }
3990 StreamEvent::Agent(_) => {}
3991 StreamEvent::Session(SessionEvent::Usage { .. }) => {
3992 self.record_usage(&event);
3993 if let Some((scope, cost, cap)) = self.cost_cap_breach() {
3998 self.emit(SessionEventWire::CostCapReached {
3999 scope: scope.to_string(),
4000 cost,
4001 cap,
4002 });
4003 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
4004 if self.driver.is_some() || self.driver_pending.is_some() {
4005 self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
4006 }
4007 self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
4008 .await;
4009 return;
4010 }
4011 }
4012 StreamEvent::Session(SessionEvent::Notice(_) | SessionEvent::Retry(_)) => {}
4013 StreamEvent::Session(SessionEvent::Done) => {
4014 self.clear_stream();
4015 self.publish_subagent_rows();
4016 let had_pending = !self.conv.pending_events.is_empty();
4018 {
4019 let conv: &mut ConversationState = &mut self.conv;
4020 for formatted in conv.pending_events.drain(..) {
4021 conv.api_messages
4022 .push(std::sync::Arc::new(serde_json::json!({
4023 "role": "user",
4024 "content": formatted
4025 })));
4026 }
4027 }
4028 if let Some(queued) = self.conv.queued_message.take() {
4029 after = After::AutoSendQueued(queued);
4030 } else if had_pending {
4031 self.request_save();
4032 after = After::AutoTriggerEvents;
4033 }
4034 self.emit_conversation();
4035 if matches!(after, After::Continue) {
4036 if self.config.auto_compact {
4037 self.post_turn_chat().await;
4038 }
4039 if self.compact.is_none() {
4041 self.announce_idle().await;
4042 }
4043 }
4044 }
4045 StreamEvent::Session(SessionEvent::Error(_)) => {
4046 crate::engine::stream::repair_history_after_failure(
4049 &mut self.conv.api_messages,
4050 self.turn_baseline,
4051 );
4052 self.clear_stream();
4053 self.publish_subagent_rows();
4054 self.emit_conversation();
4055 after = After::Failed;
4056 }
4057 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
4058 session_id,
4059 messages,
4060 receipt,
4061 }) => {
4062 self.handle_context_head_checkpoint(session_id, messages, receipt)
4063 .await;
4064 }
4065 }
4066
4067 match after {
4068 After::Continue => {}
4069 After::Failed => {
4070 if self.config.auto_compact {
4071 self.post_turn_chat().await;
4072 }
4073 if self.compact.is_none() {
4074 self.announce_idle().await;
4075 }
4076 }
4077 After::AutoSendQueued(queued) => {
4078 if self.config.auto_compact {
4079 self.post_turn_chat().await;
4080 }
4081 if self.compact.is_some() {
4082 self.conv.queued_message = Some(queued);
4085 return;
4086 }
4087 self.consecutive_auto_turns = 0;
4089 let user_text = queued.clone();
4090 self.conv.api_messages.push(std::sync::Arc::new(
4091 serde_json::json!({"role": "user", "content": queued}),
4092 ));
4093 self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
4094 }
4095 After::AutoTriggerEvents => {
4096 if self.config.auto_compact {
4097 self.post_turn_chat().await;
4098 }
4099 if self.compact.is_some() {
4100 return;
4101 }
4102 let auto_turn_cap = self.host.config().events.auto_turn_cap;
4105 if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
4106 self.start_turn(TurnTrigger::EventAuto, None).await;
4107 } else {
4108 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
4109 self.announce_idle().await;
4110 }
4111 }
4112 }
4113
4114 if let Some(terminal) = terminal {
4116 let revoke_reason = if let Some(driver) = self.driver.as_mut() {
4117 super::driver::observe_terminal(driver, &self.runtime, terminal)
4118 } else {
4119 None
4120 };
4121 if let Some(driver) = self.driver.as_ref() {
4123 if let Some((outcome, _)) = &driver.outcome {
4124 self.emit(SessionEventWire::DriverTurnOutcome {
4125 outcome: *outcome,
4126 selection: driver.selection.clone(),
4127 feedback: driver.grant.feedback_enabled().then(|| {
4128 driver.completed_feedback.to_string()
4129 }),
4130 });
4131 }
4132 }
4133 if let Some(reason) = revoke_reason {
4134 self.driver_revoke(&reason);
4135 }
4136 }
4137 }
4138
4139 async fn post_turn_chat(&mut self) {
4141 self.request_save();
4142 let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
4143 if assessment.should_compact() {
4144 self.emit(SessionEventWire::SystemNotice(format!(
4145 "[auto-compacting ~{} tokens...]",
4146 assessment.used_tokens()
4147 )));
4148 self.compact(None, "auto").await;
4149 }
4150 }
4151
4152 fn compact_inline_env() -> bool {
4155 matches!(
4156 std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
4157 Ok("1") | Ok("true")
4158 )
4159 }
4160
4161 pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
4168 self.cache_keepwarm_compaction();
4170 if Self::compact_inline_env() {
4171 return self.compact_inline(instructions, source).await;
4172 }
4173 if self.compact.is_some() {
4174 self.emit(SessionEventWire::SystemNotice(
4175 "compaction already in progress".into(),
4176 ));
4177 return;
4178 }
4179 if self.streaming {
4180 self.emit(SessionEventWire::SystemNotice(
4181 "cannot compact while a turn is running".into(),
4182 ));
4183 return;
4184 }
4185 let disclosure =
4186 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
4187 self.emit(SessionEventWire::CompactionStarted {
4188 source: source.to_string(),
4189 disclosure,
4190 });
4191 let msgs = self.conv.api_messages.clone();
4192 let rt: Runtime = (*self.runtime).clone();
4193 let task = tokio::spawn(async move {
4194 compact_conversation(&msgs, &rt, instructions.as_deref()).await
4195 });
4196 self.compact = Some(CompactionJob {
4197 task,
4198 source: source.to_string(),
4199 started: tokio::time::Instant::now(),
4200 });
4201 self.update_attach_state();
4202 }
4203
4204 async fn on_compaction_done(
4208 &mut self,
4209 res: std::result::Result<
4210 Result<crate::runtime::compaction::CompactionOutcome>,
4211 tokio::task::JoinError,
4212 >,
4213 ) {
4214 let Some(job) = self.compact.take() else {
4215 return;
4216 };
4217 let msg_count = self.conv.api_messages.len();
4218 match res {
4219 Err(join) => {
4220 if !join.is_cancelled() {
4221 self.emit(SessionEventWire::CompactionFailed {
4222 message: join.to_string(),
4223 panicked: true,
4224 });
4225 }
4226 }
4227 Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
4228 message: e.to_string(),
4229 panicked: false,
4230 }),
4231 Ok(Ok(outcome)) => {
4232 if !self.persister.flush().await {
4236 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4237 }
4238 let policy: CompactionPolicy = self.config.compaction_policy.into();
4239 let queued = self.conv.queued_message.clone();
4240 let applied = apply_compaction(
4241 &self.runtime,
4242 &self.conv.session,
4243 &self.conv.api_messages,
4244 &outcome,
4245 CompactionTransition {
4246 policy,
4247 pending_events: self.conv.pending_events.clone(),
4248 queued_message: queued.clone(),
4249 hook_source: job.source.clone(),
4250 },
4251 )
4252 .await;
4253 match applied {
4254 Ok(applied) => {
4255 let previous = applied.previous_session_id.clone();
4256 self.conv.session = applied.session;
4257 self.conv.api_messages = applied.api_messages;
4258 self.conv.pending_events.clear();
4259 self.conv.queued_message = None;
4260 if policy == CompactionPolicy::LinkedSuccessor {
4261 self.conv.total_input_tokens = 0;
4262 self.conv.total_output_tokens = 0;
4263 self.conv.session_cost = 0.0;
4264 self.conv.keepwarm_cost = 0.0;
4269 self.conv.keepwarm_tokens.input = 0;
4270 self.conv.keepwarm_tokens.output = 0;
4271 }
4272 let new_id = self.conv.session.id.clone();
4273 self.runtime.set_session_id(Some(new_id.clone()));
4274 self.set_journal_id(new_id.clone());
4275 self.reacquire_session_lock(&new_id);
4277 self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
4281 self.runtime
4282 .reset_context_continuation(&new_id, &self.conv.api_messages);
4283 self.emit(SessionEventWire::CompactionApplied {
4284 previous_session_id: previous,
4285 session_id: new_id,
4286 chains_advanced: applied.chains_advanced,
4287 queued_restored: queued,
4288 msg_count,
4289 });
4290 }
4291 Err(e) => self.emit(SessionEventWire::CompactionFailed {
4292 message: e.to_string(),
4293 panicked: false,
4294 }),
4295 }
4296 }
4297 }
4298 self.emit_conversation();
4299 self.update_attach_state();
4300 self.announce_idle().await;
4301 }
4302
4303 async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
4305 self.compacting_inline = true;
4306 self.publish_presence();
4307 self.compact_inline_inner(instructions, source).await;
4308 self.compacting_inline = false;
4309 self.publish_presence();
4310 }
4311
4312 async fn compact_inline_inner(&mut self, instructions: Option<String>, source: &str) {
4313 self.emit(SessionEventWire::SystemNotice(format!(
4314 "[{}]",
4315 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
4316 )));
4317 let outcome =
4318 compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
4319 .await;
4320 let applied = match outcome {
4321 Ok(outcome) => {
4322 if !self.persister.flush().await {
4324 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4325 }
4326 apply_compaction(
4327 &self.runtime,
4328 &self.conv.session,
4329 &self.conv.api_messages,
4330 &outcome,
4331 CompactionTransition {
4332 policy: CompactionPolicy::InPlace,
4333 pending_events: Vec::new(),
4334 queued_message: None,
4335 hook_source: source.to_string(),
4336 },
4337 )
4338 .await
4339 }
4340 Err(e) => Err(e),
4341 };
4342 match applied {
4343 Ok(applied) => {
4344 self.conv.session = applied.session;
4345 self.conv.api_messages = applied.api_messages;
4346 let after = self.runtime.assess_context(&self.conv.api_messages).await;
4347 self.emit(SessionEventWire::SystemNotice(format!(
4348 "[compacted → ~{} tokens]",
4349 after.used_tokens()
4350 )));
4351 }
4352 Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
4353 "[compaction failed: {}]",
4354 e
4355 ))),
4356 }
4357 self.emit_conversation();
4358 }
4359
4360 fn on_prompt_request(&mut self, req: SecretPromptRequest) {
4363 let id = self.next_prompt_id;
4364 self.next_prompt_id += 1;
4365 let pr = PromptRequest {
4366 id,
4367 kind: PromptKind::from_title(&req.title),
4368 title: req.title,
4369 prompt: req.prompt,
4370 raised_at: chrono::Utc::now(),
4371 confirm_scope: req.confirm_scope,
4372 };
4373 self.pending_prompts.push_back((pr.clone(), req.response_tx));
4374 self.publish_presence();
4375 self.emit(SessionEventWire::Prompt(pr));
4376 self.rearm_prompt_abandon();
4379 }
4380
4381 fn confirm_answer_refusal(
4386 &self,
4387 from: Option<ClientId>,
4388 prompt: &PromptRequest,
4389 allow_all: bool,
4390 ) -> Option<&'static str> {
4391 let c = from?;
4392 if prompt.kind != PromptKind::Confirm {
4393 return None;
4394 }
4395 if self.attached.get(&c).map(|a| a.mode) == Some(AttachMode::Observe) {
4396 return Some("observe-mode clients cannot answer confirm prompts");
4397 }
4398 if allow_all && self.input_owner != Some(c) {
4399 return Some("only the input owner can choose Allow all");
4400 }
4401 None
4402 }
4403
4404 fn answer(&mut self, from: Option<ClientId>, prompt_id: u64, value: Option<String>) {
4405 let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
4406 return; };
4408 let allow_all = value.as_deref().is_some_and(|v| {
4409 v.trim()
4410 .eq_ignore_ascii_case(crate::tools::CONFIRM_ANSWER_ALLOW_ALL)
4411 });
4412 if let Some(c) = from {
4416 self.note_input(c);
4417 }
4418 if let Some(reason) = self.confirm_answer_refusal(from, &self.pending_prompts[pos].0, allow_all) {
4419 if let Some(c) = from {
4420 self.emit(SessionEventWire::Refused {
4421 client: c,
4422 command: "answer".to_string(),
4423 reason: reason.to_string(),
4424 });
4425 }
4426 return; }
4428 let approved = allow_all
4429 || value
4430 .as_deref()
4431 .map(str::trim)
4432 .is_some_and(|v| v.eq_ignore_ascii_case("y") || v.eq_ignore_ascii_case("yes"));
4433 let (prompt, tx) = self.pending_prompts.remove(pos).expect("position");
4434 let _ = tx.send(value);
4435 self.publish_presence();
4436 self.emit(SessionEventWire::PromptResolved { prompt_id });
4437 let by = match from {
4438 Some(c) => self.client_label(c),
4439 None => "the host".to_string(),
4440 };
4441 if approved && prompt.title == CONFIRM_PROMPT_TITLE {
4445 if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.rewritten_by.is_empty()) {
4446 self.emit(SessionEventWire::SystemNotice(format!(
4447 "Approved by {by}: {} runs with input rewritten by {} (not the input shown \
4448 on its tool card; the approval prompt showed what runs).",
4449 scope.tool.as_deref().unwrap_or("the tool call"),
4450 scope.rewritten_by.join(", ")
4451 )));
4452 }
4453 }
4454 if allow_all && prompt.title == CONFIRM_PROMPT_TITLE {
4457 if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.extensions.is_empty()) {
4458 self.emit(SessionEventWire::SystemNotice(format!(
4459 "Allow all armed by {by}: tool-call confirms from {} are auto-approved \
4460 until /new. Other extensions still ask. /confirms reset revokes.",
4461 scope.extensions_label()
4462 )));
4463 }
4464 }
4465 self.rearm_prompt_abandon();
4467 }
4468
4469 pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
4472 if replayable(&setting) {
4473 let disc = std::mem::discriminant(&setting);
4474 self.settings_replay
4475 .retain(|s| std::mem::discriminant(s) != disc);
4476 self.settings_replay.push(setting.clone());
4477 }
4478 let name = match &setting {
4479 SessionSetting::Model { .. } => "model",
4480 SessionSetting::ReasoningLevel { .. } => "reasoning_level",
4481 SessionSetting::ContextWindow { .. } => "context_window",
4482 SessionSetting::CompactionModel { .. } => "compaction_model",
4483 SessionSetting::ApiRetries { .. } => "api_retries",
4484 SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
4485 SessionSetting::MaxToolOutput { .. } => "max_tool_output",
4486 SessionSetting::BashTimeout { .. } => "bash_timeout",
4487 SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
4488 SessionSetting::SystemPrompt { .. } => "system_prompt",
4489 SessionSetting::ReloadPrompt => "reload_prompt",
4490 SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
4491 };
4492 match &setting {
4495 SessionSetting::Model { model } => self.runtime.prepare_model_capabilities(model).await,
4496 SessionSetting::ReasoningLevel { .. } => {
4497 let model = self.runtime.model().to_string();
4498 self.runtime.prepare_model_capabilities(&model).await;
4499 }
4500 _ => {}
4501 }
4502 let rt = &mut self.runtime;
4503 let mut clamp_wire = None;
4504 let result: std::result::Result<Option<String>, String> = match setting {
4505 SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
4506 self.conv.session.model = rt.model().to_string();
4507 clamp.map(|c| {
4508 self.conv.session.thinking_level = rt.thinking_level().to_string();
4509 clamp_wire = Some(ReasoningClampWire {
4510 from: c.from.as_str().to_string(),
4511 to: c.to.as_str().to_string(),
4512 });
4513 format!(
4514 "thinking → {} (clamped from {}: not supported by {})",
4515 c.to.as_str(),
4516 c.from.as_str(),
4517 rt.model()
4518 )
4519 })
4520 }),
4521 SessionSetting::ReasoningLevel { level } => {
4522 rt.set_reasoning_level_checked(level).map(|_| {
4523 self.conv.session.thinking_level = rt.thinking_level().to_string();
4524 None
4525 })
4526 }
4527 SessionSetting::ContextWindow { tokens } => {
4528 rt.set_context_window(tokens);
4529 Ok(None)
4530 }
4531 SessionSetting::CompactionModel { model } => {
4532 rt.set_compaction_model(model);
4533 Ok(None)
4534 }
4535 SessionSetting::ApiRetries { n } => {
4536 rt.set_api_retries(n);
4537 Ok(None)
4538 }
4539 SessionSetting::SubagentTimeout { secs } => {
4540 rt.set_subagent_timeout(secs);
4541 Ok(None)
4542 }
4543 SessionSetting::MaxToolOutput { bytes } => {
4544 rt.set_max_tool_output(bytes);
4545 Ok(None)
4546 }
4547 SessionSetting::BashTimeout { secs } => {
4548 rt.set_bash_timeout(secs);
4549 Ok(None)
4550 }
4551 SessionSetting::BashMaxTimeout { secs } => {
4552 rt.set_bash_max_timeout(secs);
4553 Ok(None)
4554 }
4555 SessionSetting::SystemPrompt { text } => {
4556 rt.set_system_prompt(text);
4557 Ok(None)
4558 }
4559 SessionSetting::ReloadPrompt => rt
4560 .reload_prompt()
4561 .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
4562 .map_err(|e| e.to_string()),
4563 SessionSetting::GrantWorkerModel { model } => {
4564 rt.grant_worker_model(&model).map(|_| None)
4565 }
4566 };
4567 self.publish_view().await;
4568 let view = (**self.view.load()).clone();
4569 let (ok, message) = match result {
4570 Ok(m) => (true, m),
4571 Err(e) => (false, Some(e)),
4572 };
4573 self.emit(SessionEventWire::SettingChanged(SettingApplied {
4574 id,
4575 setting: name.to_string(),
4576 ok,
4577 message,
4578 view,
4579 clamp: clamp_wire,
4580 }));
4581 }
4582
4583 pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
4584 if self.is_parked() {
4586 let value = match query {
4587 SessionQuery::Status => serde_json::json!({
4588 "session": (**self.journal_id.load()).clone(),
4589 "model": self.view.load().model,
4590 "lifecycle": SessionLifecycle::Parked,
4591 "streaming": false,
4592 "auto_turns": self.consecutive_auto_turns,
4593 "attached": 0,
4594 "pending_prompts": 0,
4595 "pinned": self.keep_warm,
4596 }),
4597 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4598 SessionQuery::SubagentWatch { handle_id, .. } => {
4600 serde_json::json!({ "error": format!("no subagent {handle_id} in this session") })
4601 }
4602 _ => match self.ensure_live().await {
4603 Ok(()) => return Box::pin(self.query(id, query)).await,
4604 Err(e) => serde_json::json!({ "error": e }),
4605 },
4606 };
4607 self.emit(SessionEventWire::QueryResult { id, value });
4608 return;
4609 }
4610 let value = match query {
4611 SessionQuery::Status => {
4612 let cache_ttl = self.cache_ttl_status_json().await;
4614 serde_json::json!({
4615 "session": self.conv.session.id,
4616 "cache_ttl": cache_ttl,
4617 "model": self.runtime.model(),
4618 "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
4619 "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
4620 "cost": self.conv.session_cost,
4621 "keepwarm_cost": self.conv.keepwarm_cost,
4623 "keepwarm": self.cache_keepwarm_status_json(None),
4624 "messages": self.conv.api_messages.len(),
4625 "streaming": self.streaming,
4626 "auto_turns": self.consecutive_auto_turns,
4627 "attached": self.attached.len(),
4628 "pending_prompts": self.pending_prompts.len(),
4629 "pinned": self.keep_warm,
4631 })
4632 }
4633 SessionQuery::Messages => {
4634 serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
4635 }
4636 SessionQuery::DisplayTail { items } => {
4637 serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
4638 .unwrap_or_default()
4639 }
4640 SessionQuery::SubagentRows => {
4641 let rows = self
4642 .runtime
4643 .subagent_registry()
4644 .lock()
4645 .unwrap_or_else(|p| p.into_inner())
4646 .display_rows();
4647 serde_json::Value::Array(
4648 rows.iter()
4649 .map(|r| {
4650 serde_json::json!({
4651 "subagent_id": r.subagent_id,
4652 "agent_name": r.agent_name,
4653 "status": format!("{:?}", r.status),
4654 "cancel_requested": r.cancel_requested,
4655 "elapsed_secs": r.elapsed_secs,
4656 })
4657 })
4658 .collect(),
4659 )
4660 }
4661 SessionQuery::SubagentWatch { handle_id, since, tool_queue } => {
4662 let mut registry = self
4663 .runtime
4664 .subagent_registry()
4665 .lock()
4666 .unwrap_or_else(|p| p.into_inner());
4667 match registry.watch_page(&handle_id, since) {
4668 Some(page) if !tool_queue => {
4671 serde_json::to_value(page.without_tool_queue()).unwrap_or_default()
4672 }
4673 Some(page) => serde_json::to_value(page).unwrap_or_default(),
4674 None => serde_json::json!({
4675 "error": format!("no subagent {handle_id} in this session")
4676 }),
4677 }
4678 }
4679 SessionQuery::PromptInspection => {
4680 serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
4681 }
4682 SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
4683 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4684 SessionQuery::ContextAssessment => {
4685 let a = self.runtime.assess_context(&self.conv.api_messages).await;
4686 serde_json::json!({
4687 "used_tokens": a.used_tokens(),
4688 "budget_tokens": a.budget_tokens(),
4689 "provider_window": a.provider_window,
4690 "should_compact": a.should_compact(),
4691 })
4692 }
4693 SessionQuery::ContextReport => {
4694 use crate::engine::commands::{context_command, CommandResult};
4695 match context_command(&self.runtime, Some(&self.conv.api_messages)) {
4696 CommandResult::Output(text) => serde_json::json!({ "text": text }),
4697 other => serde_json::json!({ "unsupported": format!("{other:?}") }),
4698 }
4699 }
4700 };
4701 self.emit(SessionEventWire::QueryResult { id, value });
4702 }
4703
4704 pub(crate) fn snapshot(&self) -> AttachSnapshot {
4707 AttachSnapshot {
4708 meta: self.meta.clone(),
4709 view: (**self.view.load()).clone(),
4710 conversation: self.conv.snapshot(self.consecutive_auto_turns),
4711 streaming: self.streaming,
4712 replay: self.turn_replay.iter().cloned().collect(),
4713 pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
4714 clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
4715 roster: self.roster(),
4716 client_input: self.client_input,
4717 input_owner: self.input_owner,
4718 display_tail: None,
4719 }
4720 }
4721
4722 pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
4727 let mut snap = self.snapshot();
4728 if client.history == HistoryMode::Digest {
4729 snap.display_tail = Some(super::display::display_tail(
4730 &snap.conversation.api_messages,
4731 client.tail_items,
4732 ));
4733 snap.conversation.api_messages = Vec::new();
4734 snap.replay.retain(|e| {
4735 !matches!(
4736 e.event,
4737 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
4738 )
4739 });
4740 }
4741 snap
4742 }
4743
4744 async fn attach(&mut self, client: ClientMeta, mode: AttachMode, owner: Option<super::types::OwnerBind>) {
4749 if owner.as_ref().is_some_and(|b| self.drop_dead_claim(b)) {
4752 return;
4753 }
4754 if let Err(e) = self.ensure_live().await {
4755 self.emit(SessionEventWire::AttachRefused { message: e });
4756 return;
4757 }
4758 let cid = ClientId(self.next_client_id);
4759 self.next_client_id += 1;
4760 let kind = client.kind;
4761 self.cache_keepwarm_note_attach(kind, mode);
4762 let snapshot_meta = client.clone();
4763 let name = resolve_client_name(
4764 client.name.as_deref(),
4765 kind,
4766 self.attached.values().map(|a| a.name.as_str()),
4767 );
4768 let attached_at_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
4769 self.attached.insert(
4770 cid,
4771 AttachedClient { meta: client, mode, name: name.clone(), attached_at_ms, last_input: 0 },
4772 );
4773 let awaiting_owner = self.owned.as_ref().is_some_and(|o| o.awaiting_owner);
4776 if let Some(bind) = owner {
4777 self.bind_owner(cid, bind);
4778 }
4779 let resumed = awaiting_owner && self.owned.as_ref().is_some_and(|o| !o.awaiting_owner);
4781 self.update_attach_state();
4782 let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
4783 match mode {
4784 AttachMode::Observe => {}
4785 AttachMode::Mirror => {
4789 if self.input_owner.is_none() {
4790 owner_change = Some((None, OwnerChangeReason::Attach));
4791 }
4792 }
4793 AttachMode::Takeover => {
4794 let reason = if self.input_owner.is_some() {
4795 OwnerChangeReason::Takeover
4796 } else {
4797 OwnerChangeReason::Attach
4798 };
4799 owner_change = Some((self.input_owner, reason));
4800 }
4801 }
4802 if self.watches_owned(cid) {
4805 owner_change = None;
4806 }
4807 if let Some((from, reason)) = owner_change {
4808 self.input_owner = Some(cid);
4809 self.emit(SessionEventWire::InputOwnerChanged {
4810 from,
4811 to: Some(cid),
4812 reason,
4813 });
4814 }
4815 self.publish_presence();
4816 let snapshot = self.snapshot_for(&snapshot_meta);
4817 self.emit(SessionEventWire::Attached {
4818 client: cid,
4819 snapshot,
4820 });
4821 self.emit(SessionEventWire::ClientJoined { client: cid, kind, name, mode });
4822 if resumed && !self.event_queue.is_empty() {
4823 self.on_queue_wake().await;
4824 }
4825 }
4826
4827 fn take_input(&mut self, from: Option<ClientId>) {
4833 let Some(c) = from else { return };
4834 let Some(mode) = self.attached.get(&c).map(|a| a.mode) else { return };
4835 if mode == AttachMode::Observe {
4836 self.emit(SessionEventWire::Refused {
4837 client: c,
4838 command: command_name(&SessionCommand::TakeInput).to_string(),
4839 reason: "this client attached with --observe and never owns input; \
4840 attach without --observe to take it"
4841 .to_string(),
4842 });
4843 return;
4844 }
4845 if self.input_owner == Some(c) {
4846 return;
4847 }
4848 let from = self.input_owner;
4849 let reason = if from.is_some() {
4850 OwnerChangeReason::Takeover
4851 } else {
4852 OwnerChangeReason::Attach
4853 };
4854 self.input_owner = Some(c);
4855 self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason });
4856 self.publish_presence();
4857 self.note_input(c); }
4859
4860 pub(crate) fn detach(&mut self, client: ClientId) {
4864 if self.attached.remove(&client).is_none() {
4865 return;
4866 }
4867 self.update_attach_state();
4868 self.emit(SessionEventWire::ClientLeft { client });
4869 if self.input_owner == Some(client) {
4870 let shared = self.client_input == ClientInput::Shared;
4875 let next = self
4876 .attached
4877 .iter()
4878 .filter(|(_, a)| a.mode != AttachMode::Observe && self.owned.is_none())
4879 .max_by_key(|(c, a)| (if shared { a.last_input } else { 0 }, std::cmp::Reverse(c.0)))
4880 .map(|(c, _)| *c);
4881 self.input_owner = next;
4882 self.emit(SessionEventWire::InputOwnerChanged {
4883 from: Some(client),
4884 to: next,
4885 reason: OwnerChangeReason::OwnerDetached,
4886 });
4887 self.publish_presence();
4888 }
4889 if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
4906 self.driver_revoke("no clients attached");
4907 let had_prompts = !self.pending_prompts.is_empty();
4908 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4909 let _ = tx.send(None);
4910 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4911 }
4912 if had_prompts {
4913 self.publish_presence();
4914 }
4915 }
4916 }
4917
4918 pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
4923 let interrupt = match reason {
4924 CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
4925 CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
4926 };
4927 self.note_cancel_cause(interrupt);
4929 if self.driver.is_some() {
4931 self.driver_revoke("daemon reloaded");
4932 }
4933 let reload = matches!(reason, CheckpointReason::Reload);
4939 if reload {
4940 self.stop_workers_for_reload();
4941 }
4942 if self.streaming {
4943 self.cancel_turn(interrupt).await;
4944 }
4945 let stopping = if reload { self.stop_workers_for_reload() } else { Vec::new() };
4948 let stopped_workers = await_stopped_workers(self.id.as_str(), stopping).await;
4949 self.abort_compaction();
4950 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4951 let _ = tx.send(None);
4952 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4953 }
4954 if !self.save().await {
4957 tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
4958 }
4959 let notice = match reason {
4960 CheckpointReason::Reload => {
4961 "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
4962 }
4963 CheckpointReason::HostRequest => {
4964 "checkpoint — background shells/PTYs closed; the turn was checkpointed"
4965 }
4966 };
4967 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
4968 if !stopped_workers.is_empty() {
4969 self.emit(SessionEventWire::SystemNotice(format!(
4970 "daemon reloading — stopped worker{} {} (archived; resumable after the reload)",
4971 if stopped_workers.len() == 1 { "" } else { "s" },
4972 stopped_workers.join(", ")
4973 )));
4974 }
4975 self.runtime.session_manager().shutdown_all();
4976 let mut record = self.reload_record();
4977 if reload {
4978 record.workers = self.reload_carried_workers();
4979 self.reload_fence = Some(ReloadFence {
4982 deadline: tokio::time::Instant::now() + budgets::RELOAD_FENCE_TIMEOUT,
4983 workers: record.workers.clone(),
4984 });
4985 }
4986 let record = serde_json::to_value(record).unwrap_or_default();
4987 self.emit(SessionEventWire::QueryResult {
4988 id: super::wire::CHECKPOINT_QUERY_ID,
4989 value: serde_json::json!({ "ok": true, "record": record }),
4990 });
4991 }
4992
4993 pub(crate) fn reload_carried_workers(&self) -> Vec<crate::runtime::subagent::CarriedWorker> {
4997 let undelivered: std::collections::HashSet<String> = self
4998 .event_queue
4999 .snapshot()
5000 .iter()
5001 .filter(|e| e.content.content_type == "subagent_completion")
5002 .filter_map(|e| {
5003 e.content
5004 .data
5005 .as_ref()?
5006 .get("handle_id")?
5007 .as_str()
5008 .map(str::to_string)
5009 })
5010 .collect();
5011 self.runtime
5012 .subagent_registry()
5013 .lock()
5014 .unwrap_or_else(|p| p.into_inner())
5015 .reload_carry(&undelivered)
5016 }
5017
5018 pub(crate) fn restore_tool_activations(
5024 &mut self,
5025 activations: Vec<crate::tools::activation::CarriedActivation>,
5026 ) {
5027 if activations.is_empty() {
5028 return;
5029 }
5030 tracing::info!(
5031 session = %self.id,
5032 count = activations.len(),
5033 "reload: tool activations handed over from the previous image"
5034 );
5035 if self.runtime.is_live() {
5036 self.runtime.restore_tool_activations(activations);
5037 } else {
5038 for a in activations {
5039 if !self.parked_tool_activations.iter().any(|p| p.tool_id == a.tool_id) {
5040 self.parked_tool_activations.push(a);
5041 }
5042 }
5043 }
5044 }
5045
5046 pub(crate) fn adopt_workers(&mut self, workers: Vec<crate::runtime::subagent::CarriedWorker>) {
5052 self.adopt_carried_workers(workers, false);
5053 }
5054
5055 fn adopt_carried_workers(
5058 &mut self,
5059 workers: Vec<crate::runtime::subagent::CarriedWorker>,
5060 failed_reload: bool,
5061 ) {
5062 if workers.is_empty() {
5063 return;
5064 }
5065 {
5066 let mut registry = self
5067 .runtime
5068 .subagent_registry()
5069 .lock()
5070 .unwrap_or_else(|p| p.into_inner());
5071 for w in &workers {
5072 registry.adopt_carried(w);
5073 if w.needs_wake() {
5074 registry.note_reload_wake(w);
5077 }
5078 }
5079 }
5080 let woken: Vec<&crate::runtime::subagent::CarriedWorker> =
5081 workers.iter().filter(|w| w.needs_wake()).collect();
5082 for w in &woken {
5083 let ev = crate::tools::build_reload_carry_event(w);
5084 if self.event_queue.push(ev.clone()).is_err() {
5085 self.event_queue.push_priority(ev);
5086 }
5087 }
5088 let stopped: Vec<&str> = woken
5089 .iter()
5090 .filter(|w| w.stopped_by_reload)
5091 .map(|w| w.id.as_str())
5092 .collect();
5093 tracing::info!(
5094 session = %self.id,
5095 carried = workers.len(),
5096 stopped = ?stopped,
5097 woken = woken.len(),
5098 "reload: adopted workers from the previous image"
5099 );
5100 if !stopped.is_empty() {
5101 self.emit(SessionEventWire::SystemNotice(format!(
5102 "{} — worker{} {} stopped by the reload; subagent_resume continues {} from {} archive",
5103 if failed_reload { "daemon reload failed" } else { "daemon reloaded" },
5104 if stopped.len() == 1 { "" } else { "s" },
5105 stopped.join(", "),
5106 if stopped.len() == 1 { "it" } else { "them" },
5107 if stopped.len() == 1 { "its" } else { "their" },
5108 )));
5109 }
5110 }
5111
5112 fn stop_workers_for_reload(
5114 &self,
5115 ) -> Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)> {
5116 self.runtime
5117 .subagent_registry()
5118 .lock()
5119 .unwrap_or_else(|p| p.into_inner())
5120 .stop_for_reload()
5121 }
5122
5123 pub(crate) async fn reload_aborted(&mut self) {
5128 self.lift_reload_fence(
5129 "daemon reload failed — this session continues on the running daemon",
5130 )
5131 .await;
5132 }
5133
5134 async fn lift_reload_fence(&mut self, notice: &str) {
5137 let Some(fence) = self.reload_fence.take() else {
5138 return;
5139 };
5140 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
5141 let workers = if self.runtime.is_live() {
5142 let mut carried = self.reload_carried_workers();
5147 carried.extend(
5148 fence
5149 .workers
5150 .into_iter()
5151 .filter(|w| !carried.iter().any(|c| c.id == w.id))
5152 .collect::<Vec<_>>(),
5153 );
5154 let mut registry = self
5155 .runtime
5156 .subagent_registry()
5157 .lock()
5158 .unwrap_or_else(|p| p.into_inner());
5159 registry.lift_reload_fence();
5160 registry.rewake_after_failed_exec(carried)
5163 } else {
5164 Vec::new()
5165 };
5166 tracing::info!(session = %self.id, "reload did not exec: fence lifted");
5167 self.adopt_carried_workers(workers, true);
5168 self.rearm_park();
5169 if !self.event_queue.is_empty() && !self.holds(WorkSource::QueueWake) {
5172 self.on_queue_wake().await;
5173 }
5174 }
5175
5176 async fn reload_fence_expired(&mut self) {
5179 tracing::warn!(
5180 session = %self.id,
5181 "reload fence expired with no exec and no abort from the daemon; lifting it"
5182 );
5183 self.lift_reload_fence(
5184 "daemon reload never completed or reported back — this session continues on the running daemon",
5185 )
5186 .await;
5187 }
5188
5189 pub(crate) fn abort_compaction(&mut self) {
5191 if let Some(job) = self.compact.take() {
5192 job.task.abort();
5193 self.emit(SessionEventWire::CompactionCancelled);
5194 self.publish_presence();
5195 }
5196 }
5197
5198 async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
5203 use std::ops::ControlFlow;
5204 let Addressed { from, cmd } = addressed;
5205 if let Some(c) = from {
5206 if is_rename_command(&cmd) {
5209 self.emit(SessionEventWire::Refused {
5210 client: c,
5211 command: command_name(&cmd).to_string(),
5212 reason: "rename is host-only; use /saveas".into(),
5213 });
5214 return ControlFlow::Continue(());
5215 }
5216 if matches!(cmd, SessionCommand::Checkpoint { reason: CheckpointReason::Reload }) {
5219 self.emit(SessionEventWire::Refused {
5220 client: c,
5221 command: command_name(&cmd).to_string(),
5222 reason: "checkpoint(reload) is host-only; use `synaps daemon reload`".into(),
5223 });
5224 return ControlFlow::Continue(());
5225 }
5226 if let Some(reason) = self.owned_refusal(c, &cmd) {
5228 self.emit(SessionEventWire::Refused {
5229 client: c,
5230 command: command_name(&cmd).to_string(),
5231 reason,
5232 });
5233 return ControlFlow::Continue(());
5234 }
5235 if let Some(reason) = is_input_command(&cmd).then(|| self.input_refusal(c)).flatten() {
5236 self.emit(SessionEventWire::Refused {
5237 client: c,
5238 command: command_name(&cmd).to_string(),
5239 reason,
5240 });
5241 return ControlFlow::Continue(());
5242 }
5243 }
5244 if self.reload_fence.is_some() && cmd.refused_while_reloading() {
5249 if let SessionCommand::EngineCommand { id, name, .. } = &cmd {
5251 if name == RENAME_COMMAND {
5252 self.emit(SessionEventWire::QueryResult {
5253 id: *id,
5254 value: serde_json::json!({
5255 "kind": "error", "code": "busy",
5256 "text": "daemon reloading; retry in a moment",
5257 }),
5258 });
5259 return ControlFlow::Continue(());
5260 }
5261 }
5262 let reason = super::RELOADING_REFUSAL.to_string();
5263 match from {
5264 Some(c) => self.emit(SessionEventWire::Refused {
5265 client: c,
5266 command: command_name(&cmd).to_string(),
5267 reason,
5268 }),
5269 None => self.emit(SessionEventWire::SystemNotice(reason)),
5270 }
5271 return ControlFlow::Continue(());
5272 }
5273 if let Some(c) = from {
5277 if is_input_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { .. }) {
5278 self.note_input(c);
5279 }
5280 }
5281 if is_rename_command(&cmd) {
5285 if let SessionCommand::EngineCommand { id, arg, .. } = cmd {
5286 let value = self.host_rename(&arg).await;
5287 self.emit(SessionEventWire::QueryResult { id, value });
5288 }
5289 return ControlFlow::Continue(());
5290 }
5291 if self.is_parked() {
5293 let needs_runtime = !matches!(
5294 cmd,
5295 SessionCommand::Attach { .. }
5296 | SessionCommand::Detach { .. }
5297 | SessionCommand::End { .. }
5298 | SessionCommand::Query { .. }
5299 | SessionCommand::Answer { .. }
5300 | SessionCommand::Save
5301 | SessionCommand::Cancel
5302 | SessionCommand::Checkpoint { .. }
5303 | SessionCommand::Park
5304 | SessionCommand::ReloadAborted
5305 | SessionCommand::ArmPark
5306 | SessionCommand::RestoreToolActivations { .. }
5307 | SessionCommand::Resync { .. }
5308 | SessionCommand::HostEvent(_)
5309 | SessionCommand::TakeInput
5310 ) && !is_close_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { on: false });
5314 if matches!(cmd, SessionCommand::HostEvent(_)) {
5315 return ControlFlow::Continue(()); }
5317 if matches!(
5318 cmd,
5319 SessionCommand::Save
5320 | SessionCommand::Cancel
5321 | SessionCommand::Park
5322 | SessionCommand::ArmPark
5323 | SessionCommand::Checkpoint { .. }
5324 ) {
5325 if let SessionCommand::Checkpoint { .. } = cmd {
5326 let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
5327 self.emit(SessionEventWire::QueryResult {
5328 id: super::wire::CHECKPOINT_QUERY_ID,
5329 value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
5330 });
5331 }
5332 return ControlFlow::Continue(()); }
5334 if needs_runtime {
5335 if let Err(e) = self.ensure_live().await {
5336 match from {
5337 Some(c) => self.emit(SessionEventWire::Refused {
5338 client: c,
5339 command: command_name(&cmd).to_string(),
5340 reason: e,
5341 }),
5342 None => self.emit(SessionEventWire::SystemNotice(e)),
5343 }
5344 return ControlFlow::Continue(());
5345 }
5346 }
5347 }
5348 match cmd {
5349 SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
5350 SessionCommand::Steer { text } => {
5351 if self.streaming {
5352 self.steer(text)
5353 } else {
5354 self.submit(text, vec![], from).await
5355 }
5356 }
5357 SessionCommand::SubagentSteer { handle_id, text } => {
5358 self.steer_subagent(from, &handle_id, text)
5359 }
5360 SessionCommand::Cancel => {
5361 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
5364 if self.driver.is_some() {
5365 self.driver_revoke("canceled");
5366 }
5367 self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
5368 .await;
5369 }
5370 SessionCommand::Answer { prompt_id, value } => self.answer(from, prompt_id, value),
5371 SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
5372 SessionCommand::Compact { instructions } => {
5374 self.compact(instructions, "manual").await;
5375 }
5376 SessionCommand::NewSession => {
5377 if self.driver.is_some() {
5378 self.driver_revoke("session replaced");
5379 }
5380 if self.turn_awaiting_start.is_some() {
5385 self.cancel_turn(crate::engine::interrupt::InterruptReason::User).await;
5386 }
5387 self.cache_keepwarm_conversation_replaced();
5390 self.persister.flush().await;
5393 self.conv.clear(&self.runtime).await;
5394 self.runtime
5395 .begin_conversation(Some(self.conv.session.id.clone()));
5396 self.set_journal_id(self.conv.session.id.clone());
5397 self.reacquire_session_lock(&self.conv.session.id.clone());
5399 self.emit(SessionEventWire::Cleared {
5400 session_id: self.conv.session.id.clone(),
5401 });
5402 self.emit_conversation();
5403 }
5404 SessionCommand::Save => {
5405 self.save().await;
5406 }
5407 SessionCommand::Query { id, query } => self.query(id, query).await,
5408 SessionCommand::EngineCommand { id, name, arg } if name == CLOSE_COMMAND => {
5410 if let Some(reason) = self.close_command(id, &arg, from.is_none()) {
5411 return ControlFlow::Break(reason);
5412 }
5413 }
5414 SessionCommand::EngineCommand { id, name, arg } => {
5415 self.engine_command(id, name, arg).await
5416 }
5417 SessionCommand::Attach { client, mode, owner } => self.attach(client, mode, owner).await,
5418 SessionCommand::Detach { client } => {
5419 if let Some(reason) = self.detach_client(client) {
5421 return ControlFlow::Break(reason);
5422 }
5423 if self.attached.is_empty() && self.cache_keepwarm.active() {
5427 self.cache_keepwarm_recheck().await;
5428 }
5429 }
5430 SessionCommand::End { reason } => {
5431 self.note_cancel_cause(match reason {
5434 EndReason::ClientQuit | EndReason::OwnerGone => crate::engine::interrupt::InterruptReason::User,
5435 _ => crate::engine::interrupt::InterruptReason::Host,
5436 });
5437 if self.driver.is_some() {
5438 self.driver_revoke("session ending");
5439 }
5440 return ControlFlow::Break(reason);
5441 }
5442 SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
5443 "resync not supported yet".into(),
5444 )),
5445 SessionCommand::SubmitPrepared {
5448 messages,
5449 user_text,
5450 } => self.submit_prepared(messages, user_text).await,
5451 SessionCommand::PluginCommand {
5452 id,
5453 plugin,
5454 name,
5455 arg,
5456 } => self.plugin_command(id, plugin, name, arg).await,
5457 SessionCommand::Resume { id, query } => self.resume(id, query).await,
5458 SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
5459 SessionCommand::AdoptWorkers { workers } => self.adopt_workers(workers),
5460 SessionCommand::RestoreToolActivations { activations } => {
5461 self.restore_tool_activations(activations)
5462 }
5463 SessionCommand::ReloadAborted => self.reload_aborted().await,
5464 SessionCommand::ArmPark => self.rearm_park(),
5466 SessionCommand::Park => {
5467 self.cache_keepwarm_park_requested();
5469 if let std::ops::ControlFlow::Break(reason) = self.park().await {
5470 return ControlFlow::Break(reason);
5471 }
5472 }
5473 SessionCommand::TakeInput => self.take_input(from),
5474 SessionCommand::KeepWarm { on: true } if self.owned.is_some() => self.refuse_owned_pin(from),
5475 SessionCommand::ArmOwnerRebind => self.arm_owner_rebind(),
5476 SessionCommand::KeepWarm { on } => {
5477 self.keep_warm = on;
5478 self.meta.pinned = on; self.keep_warm_cell.store(on, std::sync::atomic::Ordering::Release);
5480 self.note_pin();
5481 if !on && !self.cache_keepwarm.interactive_seen && self.cache_keepwarm.active() {
5483 self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::Disabled);
5484 }
5485 self.rearm_park();
5486 self.emit(SessionEventWire::SystemNotice(if on {
5487 PIN_ON_NOTICE.to_string()
5488 } else {
5489 PIN_OFF_NOTICE.to_string()
5490 }));
5491 }
5492 SessionCommand::HostEvent(ev) => match ev {
5493 HostEvent::ExtensionNotification {
5494 extension_id,
5495 method,
5496 params,
5497 } => self.emit(SessionEventWire::ExtensionNotification {
5498 extension_id,
5499 method,
5500 params,
5501 }),
5502 HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
5503 },
5504 SessionCommand::DriverStart { plugin, command, arg } => {
5506 self.driver_start(plugin, command, arg);
5507 }
5508 }
5509 ControlFlow::Continue(())
5510 }
5511
5512 async fn finish(&mut self, reason: EndReason) {
5515 if self.keep_warm && reason == EndReason::ClientQuit {
5518 self.keep_warm = false;
5519 self.note_pin();
5520 }
5521 self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::SessionEnding);
5522 self.session_start.end();
5526 self.lifecycle.store(
5527 SessionLifecycle::Ending as u8,
5528 std::sync::atomic::Ordering::Release,
5529 );
5530 let interrupt = match reason {
5534 EndReason::ClientQuit | EndReason::OwnerGone => crate::engine::interrupt::InterruptReason::User,
5535 _ => crate::engine::interrupt::InterruptReason::Host,
5536 };
5537 self.note_cancel_cause(interrupt);
5539 if self.driver.is_some() {
5541 self.driver_revoke("session ending");
5542 }
5543 if self.streaming {
5544 self.cancel_turn(interrupt).await;
5545 }
5546 self.abort_compaction();
5547 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
5549 let _ = tx.send(None);
5550 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
5551 }
5552
5553 let parked = !self.conv.is_live();
5554 let session_id = (**self.journal_id.load()).clone();
5555 let api_messages = if parked {
5556 None
5557 } else {
5558 Some(self.conv.api_messages.clone())
5559 };
5560
5561 let persist = self.config.persist;
5565 if !parked {
5566 self.request_save();
5567 }
5568 let persister = &self.persister;
5569 let save_fut = async {
5570 if persist {
5571 if !persister.flush().await {
5572 tracing::warn!(session = %session_id, "session end: save failed");
5573 }
5574 let mut index_record =
5575 crate::core::session_index::SessionIndexRecord::end(&session_id);
5576 index_record.turns = api_messages.as_ref().map(|m| m.len());
5577 if let Err(err) = crate::core::session_index::append_record(&index_record) {
5578 tracing::warn!("failed to append session end index record: {}", err);
5579 }
5580 }
5581 };
5582 if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
5583 .await
5584 .is_err()
5585 {
5586 tracing::warn!(
5587 budget_secs = budgets::SAVE_TIMEOUT_SECS,
5588 "session save timed out — data may be incomplete"
5589 );
5590 }
5591
5592 let hook_bus = Arc::clone(&self.hook_bus);
5595 crate::extensions::loader::emit_session_end(
5596 &hook_bus,
5597 &session_id,
5598 self.config.cwd.as_deref(),
5599 api_messages,
5600 budgets::HOOKS_TIMEOUT,
5601 )
5602 .await;
5603
5604 if self.runtime.is_live() {
5606 if let Some(outcome) = self
5607 .runtime
5608 .shutdown_observability_async(
5609 crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
5610 )
5611 .await
5612 {
5613 if !outcome.is_flushed() {
5614 tracing::warn!(
5615 stats = ?outcome.stats(),
5616 "observability flush timed out — detached worker keeps draining"
5617 );
5618 }
5619 }
5620 }
5621
5622 self.background.shutdown();
5624 self.session_lock = None;
5629 self.emit(SessionEventWire::Ended { reason });
5630 }
5631
5632 pub fn id(&self) -> &SessionId {
5633 &self.id
5634 }
5635}
5636
5637pub struct SessionTask(SessionActor);
5639
5640async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
5641 match stream {
5642 Some(s) => s.next().await,
5643 None => std::future::pending().await,
5644 }
5645}
5646
5647fn conversation_title(conv: &ConversationState) -> Option<String> {
5650 if !conv.session.title.is_empty() {
5651 return Some(conv.session.title.clone());
5652 }
5653 conv.api_messages
5654 .iter()
5655 .filter(|m| m["role"].as_str() == Some("user"))
5656 .find_map(|m| agent_core::core::session::user_content_for_display(&m["content"]))
5657 .map(|t| t.chars().take(80).collect())
5658}
5659
5660fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
5664 streaming || any_running
5665}
5666
5667fn worker_cancel_scope(
5673 reason: crate::engine::interrupt::InterruptReason,
5674 ending: bool,
5675 turn_generation: u64,
5676) -> crate::runtime::subagent::WorkerCancelScope {
5677 use crate::engine::interrupt::InterruptReason;
5678 use crate::runtime::subagent::WorkerCancelScope;
5679 match reason {
5680 InterruptReason::User if !ending => WorkerCancelScope::Turn(turn_generation),
5681 _ => WorkerCancelScope::All,
5682 }
5683}
5684
5685async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
5686 match tick {
5687 Some(t) => {
5688 t.tick().await;
5689 }
5690 None => std::future::pending().await,
5691 }
5692}
5693
5694async fn poll_compaction(
5695 job: &mut Option<CompactionJob>,
5696) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
5697 match job {
5698 Some(j) => (&mut j.task).await,
5699 None => std::future::pending().await,
5700 }
5701}
5702
5703async fn park_timer(deadline: Option<tokio::time::Instant>) {
5704 match deadline {
5705 Some(t) => tokio::time::sleep_until(t).await,
5706 None => std::future::pending().await,
5707 }
5708}
5709
5710async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
5712 match deadline {
5713 Some(t) => tokio::time::sleep_until(t).await,
5714 None => std::future::pending().await,
5715 }
5716}
5717
5718async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
5721 match deadline {
5722 Some(t) => tokio::time::sleep_until(t).await,
5723 None => std::future::pending().await,
5724 }
5725}
5726
5727async fn reload_fence_timer(deadline: Option<tokio::time::Instant>) {
5729 match deadline {
5730 Some(t) => tokio::time::sleep_until(t).await,
5731 None => std::future::pending().await,
5732 }
5733}
5734
5735async fn driver_tick_timer(
5737 has_driver: bool,
5738 has_pending: bool,
5739 interval: &mut tokio::time::Interval,
5740) {
5741 if has_driver || has_pending {
5742 interval.tick().await;
5743 } else {
5744 std::future::pending().await
5745 }
5746}
5747
5748fn extensions_ready_timeout(config: &crate::SynapsConfig) -> Duration {
5752 match config.startup.extensions_ready_timeout_secs {
5753 0 => budgets::EXTENSIONS_READY_TIMEOUT,
5754 secs => Duration::from_secs(secs),
5755 }
5756}
5757
5758async fn session_start_task(
5760 start: &mut super::session_start::SessionStart,
5761) -> std::result::Result<bool, tokio::task::JoinError> {
5762 match start {
5763 super::session_start::SessionStart::Queued { task, .. } => task.await,
5764 _ => std::future::pending().await,
5765 }
5766}
5767
5768impl SessionTask {
5769 pub fn id(&self) -> &SessionId {
5770 self.0.id()
5771 }
5772
5773 #[cfg(any(test, feature = "testing"))]
5776 pub fn subagent_registry(
5777 &self,
5778 ) -> Arc<std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>> {
5779 Arc::clone(self.0.runtime.subagent_registry())
5780 }
5781
5782 #[cfg(any(test, feature = "testing"))]
5786 pub fn runtime_for_harness(&self) -> crate::Runtime {
5787 (*self.0.runtime).clone()
5788 }
5789
5790 #[cfg(any(test, feature = "testing"))]
5793 pub fn event_queue(&self) -> Arc<crate::events::EventQueue> {
5794 Arc::clone(&self.0.event_queue)
5795 }
5796
5797 pub async fn run(mut self) {
5799 let queue = Arc::clone(&self.0.event_queue);
5800 let reason = loop {
5801 let actor = &mut self.0;
5802 tokio::select! {
5803 cmd = actor.cmd_rx.recv() => match cmd {
5804 Some(cmd) => {
5805 if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
5806 break reason;
5807 }
5808 }
5809 None => break EndReason::HostShutdown,
5811 },
5812 Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
5813 _ = queue.notified(), if !actor.holds(WorkSource::QueueWake) => actor.on_queue_wake().await,
5815 _ = next_tick(&mut actor.subagent_tick) => {
5816 actor.publish_subagent_rows();
5817 actor.flush_turn_draft();
5818 }
5819 _ = park_timer(actor.park_deadline), if !actor.holds(WorkSource::ParkTimer) => {
5820 if let std::ops::ControlFlow::Break(reason) = actor.park().await {
5821 break reason;
5822 }
5823 }
5824 _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
5825 actor.on_prompt_abandon_deadline();
5826 }
5827 _ = super::cache_keepwarm::keepwarm_timer(actor.cache_keepwarm.deadline()), if !actor.holds(WorkSource::KeepWarmTimer) => {
5828 actor.on_cache_keepwarm_due().await;
5829 }
5830 res = super::cache_keepwarm::poll_keepwarm_ping(&mut actor.cache_keepwarm.task) => {
5831 actor.on_cache_keepwarm_done(res);
5832 }
5833 res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
5834 ran = session_start_task(&mut actor.session_start) => {
5835 actor.session_start_finished(ran).await;
5836 },
5837 _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval), if !actor.holds(WorkSource::DriverTick) => {
5838 actor.driver_tick().await;
5839 }
5840 _ = reload_fence_timer(actor.reload_fence.as_ref().map(|f| f.deadline)) => {
5841 actor.reload_fence_expired().await;
5842 }
5843 _ = super::owned::rebind_timer(actor.owned.as_ref().and_then(|o| o.rebind_deadline)) => {
5844 if let Some(reason) = actor.owner_rebind_expired() {
5845 break reason;
5846 }
5847 }
5848 _ = super::owned::lease_closed(actor.owned.as_ref().and_then(|o| o.lease())) => {
5849 if let Some(reason) = actor.owner_lease_closed() {
5850 break reason;
5851 }
5852 }
5853 _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
5854 actor.finish_revoked_turn(false).await;
5855 }
5856 ev = next_stream_event(&mut actor.stream) => match ev {
5857 Some(ev) => actor.on_stream_event(ev).await,
5858 None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
5860 None => {
5861 if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
5864 let terminal = super::driver::capture_terminal(None, false);
5865 if let Some(terminal) = terminal {
5866 if let Some(driver) = actor.driver.as_mut() {
5867 let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
5868 let sel = driver.selection.clone();
5874 let fb = driver.grant.feedback_enabled()
5875 .then(|| driver.completed_feedback.to_string());
5876 actor.emit(SessionEventWire::DriverTurnOutcome {
5877 outcome: crate::extensions::session_driver::Outcome::Blocked,
5878 selection: sel,
5879 feedback: fb,
5880 });
5881 if let Some(reason) = reason {
5882 actor.driver_revoke(&reason);
5883 }
5884 }
5885 }
5886 }
5887 actor.clear_stream();
5888 actor.emit_conversation();
5889 actor.announce_idle().await;
5890 }
5891 },
5892 }
5893 };
5894 self.0.finish(reason).await;
5895 }
5896}
5897
5898async fn await_stopped_workers(
5905 session: &str,
5906 stopping: Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)>,
5907) -> Vec<String> {
5908 if stopping.is_empty() {
5909 return Vec::new();
5910 }
5911 let ids: Vec<String> = stopping.iter().map(|(id, _)| id.clone()).collect();
5912 tracing::info!(session, workers = ?ids, "reload: stopping running workers");
5913 let deadline = tokio::time::Instant::now() + budgets::WORKER_RELOAD_STOP_TIMEOUT;
5914 loop {
5915 let pending: Vec<&str> = stopping
5916 .iter()
5917 .filter(|(_, s)| !s.read().unwrap_or_else(|p| p.into_inner()).completion_settled)
5918 .map(|(id, _)| id.as_str())
5919 .collect();
5920 if pending.is_empty() {
5921 break;
5922 }
5923 if tokio::time::Instant::now() >= deadline {
5924 tracing::warn!(
5925 session,
5926 workers = ?pending,
5927 "reload: workers did not stop in time; only their last archive survives the exec"
5928 );
5929 break;
5930 }
5931 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
5932 }
5933 stopping
5934 .iter()
5935 .filter(|(_, s)| s.read().unwrap_or_else(|p| p.into_inner()).stopped_by_reload)
5936 .map(|(id, _)| id.clone())
5937 .collect()
5938}
5939
5940#[cfg(test)]
5941mod live_folder_tests {
5942 use super::live_folder;
5943
5944 #[test]
5945 fn only_a_missing_folder_or_a_file_is_dropped() {
5946 let tmp = tempfile::tempdir().unwrap();
5947 let folder = tmp.path().join("chat");
5948 std::fs::create_dir(&folder).unwrap();
5949 let file = tmp.path().join("file");
5950 std::fs::write(&file, "").unwrap();
5951 assert_eq!(live_folder(None, "chat"), None);
5952 assert_eq!(live_folder(Some(folder.clone()), "chat"), Some(folder.clone()));
5953 assert_eq!(live_folder(Some(file.clone()), "chat"), None, "not a folder");
5954 assert_eq!(live_folder(Some(file.join("below")), "chat"), None, "below a file");
5955 assert_eq!(live_folder(Some(tmp.path().join("gone")), "chat"), None, "absent");
5956 }
5957
5958 #[cfg(unix)]
5961 #[test]
5962 fn a_folder_that_cannot_be_checked_is_kept() {
5963 use std::os::unix::fs::PermissionsExt;
5964 let tmp = tempfile::tempdir().unwrap();
5965 let locked = tmp.path().join("locked");
5966 let folder = locked.join("chat");
5967 std::fs::create_dir_all(&folder).unwrap();
5968 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
5969 let denied = std::fs::metadata(&folder).is_err();
5970 let kept = live_folder(Some(folder.clone()), "chat");
5971 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o700)).unwrap();
5972 if denied {
5973 assert_eq!(kept, Some(folder));
5974 }
5975 }
5976}
5977
5978#[cfg(test)]
5979mod worker_cancel_scope_tests {
5980 use super::worker_cancel_scope;
5981 use crate::engine::interrupt::InterruptReason;
5982 use crate::runtime::subagent::WorkerCancelScope;
5983
5984 #[test]
5985 fn only_a_live_user_interrupt_is_turn_scoped() {
5986 assert_eq!(
5987 worker_cancel_scope(InterruptReason::User, false, 7),
5988 WorkerCancelScope::Turn(7),
5989 "Esc stops the interrupted turn's workers only"
5990 );
5991 assert_eq!(
5992 worker_cancel_scope(InterruptReason::User, true, 7),
5993 WorkerCancelScope::All,
5994 "quitting the client mid-turn tears every worker down"
5995 );
5996 for reason in [
5997 InterruptReason::CostCap,
5998 InterruptReason::Restart,
5999 InterruptReason::Host,
6000 InterruptReason::Driver,
6001 ] {
6002 assert_eq!(
6003 worker_cancel_scope(reason, false, 7),
6004 WorkerCancelScope::All,
6005 "{reason:?} stops every running worker"
6006 );
6007 }
6008 }
6009}
6010
6011#[cfg(test)]
6012mod subagent_tick_tests {
6013 use super::keep_subagent_tick;
6014
6015 #[test]
6016 fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
6017 assert!(keep_subagent_tick(true, false), "during a turn, always");
6018 assert!(keep_subagent_tick(true, true));
6019 assert!(
6020 keep_subagent_tick(false, true),
6021 "after the turn, while a background worker runs"
6022 );
6023 assert!(
6024 !keep_subagent_tick(false, false),
6025 "stops once the turn is over and no worker runs"
6026 );
6027 }
6028}
6029
6030#[cfg(test)]
6031mod parked_evict_tests {
6032 use super::parked_evict_after_from;
6033 use super::prompt_abandon_timeout_from;
6034 use std::time::Duration;
6035
6036 #[test]
6037 fn parked_evict_defaults_to_one_hour_and_honours_never() {
6038 let d = 3600u64;
6040 assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
6041 assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
6042 for never in ["never", "0", "off"] {
6043 assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
6044 }
6045 assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
6046 assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
6048 assert_eq!(parked_evict_after_from(None, 0), None);
6049 assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
6050 }
6051
6052 #[test]
6053 fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
6054 let d = 3600u64;
6056 assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
6057 assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
6058 for never in ["never", "0", "off"] {
6060 assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
6061 }
6062 assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
6064 assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
6066 assert_eq!(prompt_abandon_timeout_from(None, 0), None);
6067 assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
6068 }
6069}