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}
105
106#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub(crate) enum WorkSource {
110 QueueWake,
112 ParkTimer,
114 KeepWarmTimer,
116 DriverTick,
118}
119
120#[derive(Default)]
123pub(crate) struct CancelDrain {
124 pub(crate) history: Option<Vec<crate::SharedMessage>>,
126 pub(crate) closed: bool,
128}
129
130pub(crate) struct Live<T>(Option<T>);
135
136impl<T> Live<T> {
137 pub(crate) fn new(v: T) -> Self {
138 Self(Some(v))
139 }
140 pub(crate) fn is_live(&self) -> bool {
141 self.0.is_some()
142 }
143 pub(crate) fn park_take(&mut self) -> Option<T> {
144 self.0.take()
145 }
146 pub(crate) fn unpark_set(&mut self, v: T) {
147 self.0 = Some(v);
148 }
149}
150
151impl<T> std::ops::Deref for Live<T> {
152 type Target = T;
153 fn deref(&self) -> &T {
154 self.0.as_ref().expect("session is parked: runtime/conv unavailable")
155 }
156}
157
158impl<T> std::ops::DerefMut for Live<T> {
159 fn deref_mut(&mut self) -> &mut T {
160 self.0.as_mut().expect("session is parked: runtime/conv unavailable")
161 }
162}
163
164pub fn parked_evict_after() -> Option<std::time::Duration> {
170 let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
172 parked_evict_after_from(
173 std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
174 cfg_secs,
175 )
176}
177
178fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
179 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
180 match v.map(str::trim) {
181 Some("never" | "0" | "off") => None,
182 Some(n) => n
183 .parse::<u64>()
184 .ok()
185 .map(std::time::Duration::from_secs)
186 .or_else(from_cfg),
187 None => from_cfg(),
188 }
189}
190
191pub fn idle_end_grace() -> std::time::Duration {
196 std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
197 .ok()
198 .and_then(|v| v.trim().parse::<u64>().ok())
199 .map(std::time::Duration::from_secs)
200 .unwrap_or(std::time::Duration::from_secs(5))
201}
202
203pub fn park_grace() -> Option<std::time::Duration> {
206 match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
207 Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
208 Ok(v) => v
209 .trim()
210 .parse::<u64>()
211 .ok()
212 .map(std::time::Duration::from_secs)
213 .or(Some(DEFAULT_PARK_GRACE)),
214 Err(_) => Some(DEFAULT_PARK_GRACE),
215 }
216}
217
218pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
219
220pub const WORKER_PARK_RECHECK: std::time::Duration = std::time::Duration::from_secs(30);
223
224#[cfg(any(test, feature = "testing"))]
225static WORKER_PARK_RECHECK_OVERRIDE_MS: std::sync::atomic::AtomicU64 =
226 std::sync::atomic::AtomicU64::new(0);
227
228fn worker_park_recheck() -> std::time::Duration {
231 #[cfg(any(test, feature = "testing"))]
232 {
233 let ms = WORKER_PARK_RECHECK_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
234 if ms > 0 {
235 return std::time::Duration::from_millis(ms);
236 }
237 }
238 WORKER_PARK_RECHECK
239}
240
241#[cfg(any(test, feature = "testing"))]
243pub mod testing {
244 use std::sync::atomic::Ordering;
245 use std::time::Duration;
246
247 #[must_use = "the override is cleared when the guard drops"]
250 pub struct WorkerParkRecheck(());
251
252 impl WorkerParkRecheck {
253 pub fn set(interval: Duration) -> Self {
254 let ms = u64::try_from(interval.as_millis()).unwrap_or(u64::MAX).max(1);
255 super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(ms, Ordering::Relaxed);
256 Self(())
257 }
258 }
259
260 impl Drop for WorkerParkRecheck {
261 fn drop(&mut self) {
262 super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(0, Ordering::Relaxed);
263 }
264 }
265}
266
267pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
279 let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
281 prompt_abandon_timeout_from(
282 std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
283 cfg_secs,
284 )
285}
286
287fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
288 let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
289 match v.map(str::trim) {
290 Some("never" | "0" | "off") => None,
291 Some(n) => n
292 .parse::<u64>()
293 .ok()
294 .map(std::time::Duration::from_secs)
295 .or_else(from_cfg),
296 None => from_cfg(),
297 }
298}
299
300fn replayable(setting: &SessionSetting) -> bool {
304 matches!(
305 setting,
306 SessionSetting::SystemPrompt { .. }
307 | SessionSetting::ContextWindow { .. }
308 | SessionSetting::CompactionModel { .. }
309 | SessionSetting::ApiRetries { .. }
310 | SessionSetting::SubagentTimeout { .. }
311 | SessionSetting::MaxToolOutput { .. }
312 | SessionSetting::BashTimeout { .. }
313 | SessionSetting::BashMaxTimeout { .. }
314 | SessionSetting::GrantWorkerModel { .. }
315 )
316}
317
318fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
319 match setting {
320 SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
321 SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
322 SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
323 SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
324 SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
325 SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
326 SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
327 SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
328 SessionSetting::GrantWorkerModel { model } => {
329 let _ = rt.grant_worker_model(model);
330 }
331 _ => {}
332 }
333}
334
335pub(crate) struct AttachedClient {
338 pub(crate) meta: ClientMeta,
339 pub(crate) mode: AttachMode,
340 pub(crate) name: String,
342 pub(crate) attached_at_ms: u64,
344 pub(crate) last_input: u64,
346}
347
348pub(crate) struct CompactionJob {
350 pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
351 pub(crate) source: String,
352 #[allow(dead_code)]
353 pub(crate) started: tokio::time::Instant,
354}
355
356fn is_input_command(cmd: &SessionCommand) -> bool {
364 matches!(
365 cmd,
366 SessionCommand::Checkpoint { .. }
367 | SessionCommand::Submit { .. }
368 | SessionCommand::SubmitPrepared { .. }
369 | SessionCommand::Steer { .. }
370 | SessionCommand::SubagentSteer { .. }
371 | SessionCommand::Cancel
372 | SessionCommand::Set { .. }
373 | SessionCommand::Compact { .. }
374 | SessionCommand::NewSession
375 | SessionCommand::EngineCommand { .. }
376 | SessionCommand::PluginCommand { .. }
377 | SessionCommand::Resume { .. }
378 | SessionCommand::KeepWarm { .. }
379 | SessionCommand::DriverStart { .. }
380 | SessionCommand::End {
381 reason: EndReason::ClientQuit
382 }
383 )
384}
385
386fn subagent_steer_event(handle_id: &str, agent_name: &str, text: &str) -> crate::events::types::Event {
389 use crate::events::types::{Event, EventContent, EventSource, Severity};
390 Event {
391 id: uuid::Uuid::new_v4().to_string(),
392 timestamp: chrono::Utc::now(),
393 source: EventSource {
394 source_type: "user".to_string(),
395 name: "user".to_string(),
396 callback: None,
397 },
398 channel: None,
399 sender: None,
400 content: EventContent {
401 text: format!("User steered subagent '{agent_name}' ({handle_id}) directly: {text}"),
402 content_type: "subagent_steer".to_string(),
403 severity: Some(Severity::Low),
404 data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
405 disclosure: None,
406 },
407 expects_response: false,
408 reply_to: None,
409 }
410}
411
412pub(crate) fn subagent_user_cancel_event(
416 handle_id: &str,
417 agent_name: &str,
418) -> crate::events::types::Event {
419 use crate::events::types::{Event, EventContent, EventSource, Severity};
420 Event {
421 id: uuid::Uuid::new_v4().to_string(),
422 timestamp: chrono::Utc::now(),
423 source: EventSource {
424 source_type: "user".to_string(),
425 name: "user".to_string(),
426 callback: None,
427 },
428 channel: None,
429 sender: None,
430 content: EventContent {
431 text: format!(
432 "User cancelled subagent '{agent_name}' ({handle_id}) with /workers cancel. It stops at \
433 its next step and sends no completion wake; do not wait for it. Its partial output is \
434 readable with subagent_collect (handle_id \"{handle_id}\") only while this session \
435 stays loaded: the handle does not survive the session being parked."
436 ),
437 content_type: "subagent_cancelled".to_string(),
438 severity: Some(Severity::Low),
439 data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
440 disclosure: None,
441 },
442 expects_response: false,
443 reply_to: None,
444 }
445}
446
447pub(crate) fn subagent_close_cancel_event(
451 handle_id: &str,
452 agent_name: &str,
453) -> crate::events::types::Event {
454 let mut event = subagent_user_cancel_event(handle_id, agent_name);
455 event.content.text = format!(
456 "User closed this session with /close force while subagent '{agent_name}' ({handle_id}) was \
457 running, so it was cancelled before it finished. It sends no completion wake; do not wait for \
458 it. If its work is still needed, check what it left behind and dispatch it again."
459 );
460 event
461}
462
463pub const PIN_ON_NOTICE: &str = "pinned: this session stays loaded (never parked or idle-ended, \
465 and the daemon will not idle-exit while it is pinned) and comes back after daemon restarts. \
466 /pin off to undo.";
467pub const PIN_OFF_NOTICE: &str = "unpinned: this session parks again when idle and won't come back after a restart";
469
470pub(crate) const CLOSE_COMMAND: &str = "close";
472
473fn is_close_command(cmd: &SessionCommand) -> bool {
474 matches!(cmd, SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND)
475}
476
477fn command_name(cmd: &SessionCommand) -> &'static str {
478 match cmd {
479 SessionCommand::Submit { .. } => "submit",
480 SessionCommand::SubmitPrepared { .. } => "submit_prepared",
481 SessionCommand::Steer { .. } => "steer",
482 SessionCommand::SubagentSteer { .. } => "subagent_steer",
483 SessionCommand::Cancel => "cancel",
484 SessionCommand::Answer { .. } => "answer",
485 SessionCommand::Set { .. } => "set",
486 SessionCommand::Compact { .. } => "compact",
487 SessionCommand::NewSession => "new_session",
488 SessionCommand::Save => "save",
489 SessionCommand::Query { .. } => "query",
490 SessionCommand::Attach { .. } => "attach",
491 SessionCommand::Detach { .. } => "detach",
492 SessionCommand::End { .. } => "end",
493 SessionCommand::Resync { .. } => "resync",
494 SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND => "close",
495 SessionCommand::EngineCommand { .. } => "engine_command",
496 SessionCommand::PluginCommand { .. } => "plugin_command",
497 SessionCommand::Resume { .. } => "resume",
498 SessionCommand::Checkpoint { .. } => "checkpoint",
499 SessionCommand::KeepWarm { .. } => "keep_warm",
500 SessionCommand::TakeInput => "take_input",
501 SessionCommand::Park => "park",
502 SessionCommand::AdoptWorkers { .. } => "adopt_workers",
503 SessionCommand::RestoreToolActivations { .. } => "restore_tool_activations",
504 SessionCommand::ReloadAborted => "reload_aborted",
505 SessionCommand::ArmPark => "arm_park",
506 SessionCommand::HostEvent(_) => "host_event",
507 SessionCommand::DriverStart { .. } => "driver_start",
508 }
509}
510
511pub struct SessionActor {
512 pub(crate) id: SessionId,
513 pub(crate) meta: SessionMeta,
514 pub(crate) config: SessionConfig,
515 pub(crate) runtime: Live<Runtime>,
517 pub(crate) conv: Live<ConversationState>,
519 pub(crate) host: Arc<EngineHost>,
521 pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
523 pub(crate) event_queue: Arc<crate::events::EventQueue>,
526 pub(crate) settings_replay: Vec<SessionSetting>,
528 pub(crate) keep_warm: bool,
529 pub(crate) cache_keepwarm: super::cache_keepwarm::KeepWarmState,
532 pub(crate) park_deadline: Option<tokio::time::Instant>,
534 pub(crate) park_recheck_at: Option<tokio::time::Instant>,
538 pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
543 pub(crate) stream: Option<ActiveStream>,
545 pub(crate) cancel: Option<CancellationToken>,
546 pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
547 pub(crate) owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
552 pub(crate) streaming: bool,
553 pub(crate) turn_baseline: usize,
554 pub(crate) consecutive_auto_turns: u32,
555 pub(crate) turn_steered_events: Vec<String>,
560 pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
563 pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
568 pub(crate) turn_draft: TurnDraftState,
570 pub(crate) persister: super::persister::Persister,
573 pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
576 pub(crate) secret_prompt_handle: SecretPromptHandle,
578 pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
579 pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
581 pub(crate) next_prompt_id: u64,
582 pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
584 pub(crate) events: broadcast::Sender<Envelope>,
585 pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
586 pub(crate) attached: HashMap<ClientId, AttachedClient>,
587 pub(crate) input_owner: Option<ClientId>,
591 pub(crate) client_input: ClientInput,
593 pub(crate) input_seq: u64,
595 pub(crate) next_client_id: u64,
596 pub(crate) seq: u64,
597 pub(crate) turn_replay: VecDeque<Envelope>,
598 pub(crate) web_search_notice: Option<String>,
601 pub(crate) state: AttachState,
602 pub(crate) background: BackgroundTasks,
603 pub(crate) compact: Option<CompactionJob>,
605 pub(crate) compacting_inline: bool,
608 pub(crate) session_start: super::session_start::SessionStart,
615 pub(crate) turn_awaiting_start: Option<TurnStart>,
620 pub(crate) subagent_tick: Option<tokio::time::Interval>,
622 pub(crate) turn_worker_generation: u64,
625 pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
627 pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
629 pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
631 pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
633 pub(crate) keep_warm_cell: Arc<std::sync::atomic::AtomicBool>,
636 pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
638 pub(crate) driver: Option<super::driver::DriverState>,
640 pub(crate) driver_pending: Option<super::driver::DriverPending>,
641 pub(crate) driver_generation: u64,
643 pub(crate) driver_interrupted_owner: Option<String>,
645 pub(crate) driver_tick_interval: tokio::time::Interval,
647 pub(crate) reload_fence: Option<ReloadFence>,
650 pub(crate) memory_mode_slot: super::handle::MemoryModeSlot,
653 pub(crate) memory_seen_active: bool,
657 pub(crate) parked_tool_activations: Vec<crate::tools::activation::CarriedActivation>,
661}
662
663pub(crate) struct ReloadFence {
669 pub(crate) deadline: tokio::time::Instant,
670 pub(crate) workers: Vec<crate::runtime::subagent::CarriedWorker>,
673}
674
675fn release_fresh_lock(lock: Option<agent_core::session_lock::SessionLock>, continued: bool) {
679 if let (Some(lock), false) = (lock, continued) {
680 lock.release_and_remove();
681 }
682}
683
684impl SessionActor {
685 pub(crate) async fn create(
690 host: &Arc<EngineHost>,
691 mut cfg: SessionConfig,
692 ) -> Result<(SessionHandle, SessionTask)> {
693 let mut runtime = host
698 .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
699 .await?;
700 if let Some(mode) = cfg.memory_mode {
701 runtime.restore_session_memory_mode(mode);
702 }
703 let config: crate::SynapsConfig = (**host.config()).clone();
704 let client_input = cfg.client_input.unwrap_or(config.client_input);
705
706 let mut sb = crate::engine::setup::resolve_session_and_prompt(
707 &mut runtime,
708 &cfg.continue_session,
709 cfg.system.as_deref(),
710 cfg.prompt_manifest.as_deref(),
711 )?;
712 let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
720 let session_lock = if sb.continued {
721 let dir = agent_core::session_lock::sessions_dir();
724 let holder = agent_core::session_lock::LockHolder {
725 pid: std::process::id(),
726 kind: lock_kind.to_string(),
727 };
728 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
729 Ok(lock) => Some(lock),
730 Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
731 let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
732 return Err(crate::RuntimeError::Session(msg.to_string()));
733 }
734 Err(e) => {
735 tracing::warn!(session = %sb.session.id, "session lock: {e}");
736 None
737 }
738 }
739 } else {
740 let dir = agent_core::session_lock::sessions_dir();
742 let holder = agent_core::session_lock::LockHolder {
743 pid: std::process::id(),
744 kind: lock_kind.to_string(),
745 };
746 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
747 Ok(lock) => Some(lock),
748 Err(e) => {
749 tracing::warn!(session = %sb.session.id, "session lock: {e}");
750 None
751 }
752 }
753 };
754
755 if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
759 if let Err(e) = sb.session.set_name(name) {
760 release_fresh_lock(session_lock, sb.continued);
761 return Err(crate::RuntimeError::Session(format!("--name {name:?}: {e}")));
762 }
763 if cfg.persist {
764 if let Err(e) = sb.session.save().await {
767 tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
768 }
769 }
770 }
771 runtime.set_cwd(cfg.cwd.clone());
772 if cfg.env.is_none() {
776 if let Some(ref journal_env) = sb.session.env {
777 cfg.env = Some(journal_env.clone());
778 }
779 if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
780 cfg.env_stripped = sb.session.env_stripped.clone();
781 }
782 }
783 if cfg.env.is_some() {
787 sb.session.env = cfg.env.clone();
788 sb.session.env_stripped = cfg.env_stripped.clone();
789 }
790 runtime.set_env(cfg.env.clone());
791 runtime.set_env_stripped(cfg.env_stripped.clone());
792 if let Some(ref m) = cfg.model_override {
794 runtime.set_model(m.clone());
795 }
796
797 let background = match crate::engine::setup::spawn_session_background_gated(
801 &runtime,
802 &sb.session,
803 Arc::new(std::sync::atomic::AtomicBool::new(false)),
804 ) {
805 Ok(background) => background,
806 Err(e) => {
807 release_fresh_lock(session_lock, sb.continued);
808 return Err(e);
809 }
810 };
811 crate::engine::setup::finish_session_setup(
812 &mut runtime,
813 &config,
814 &sb.session,
815 cfg.cwd.clone(),
816 crate::engine::setup::IndexRecord::Start,
817 );
818
819 let ext_ready_timeout = extensions_ready_timeout(&config);
829 if cfg.await_extensions {
830 if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
831 .await
832 .is_err()
833 {
834 tracing::warn!(
835 budget_secs = ext_ready_timeout.as_secs(),
836 "extensions_ready timed out — on_session_start may miss late extensions"
837 );
838 }
839 super::session_start::run_at_create(host, runtime.hook_bus(), &sb.session.id).await;
840 }
841
842 let id = SessionId::from(sb.session.id.clone());
843
844 let meta = SessionMeta {
845 id: id.clone(),
846 name: sb.session.name.clone(),
847 model: runtime.model().to_string(),
848 cwd: cfg.cwd.clone(),
849 created_at: sb.session.created_at,
850 continued: sb.continued,
851 continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
852 host_pid: std::process::id(),
853 lifecycle: SessionLifecycle::Live,
854 clients: 0,
855 input_owner: None,
856 awaiting_input: 0,
857 roster: Vec::new(),
858 client_input: Some(client_input),
859 journal_id: sb.session.id.clone(),
860 locked_by: None,
861 pinned: cfg.keep_warm,
862 busy: false,
863 state_since: None,
864 park_due: None,
865 evict_due: None,
866 title: None,
867 cost_usd: 0.0,
868 messages: 0,
869 };
870 let mut conv = if sb.continued {
871 ConversationState::from_resumed(sb.session)
872 } else {
873 ConversationState::new(sb.session)
874 };
875 conv.api_messages = sb.api_messages;
876 conv.total_input_tokens = sb.total_input_tokens;
877 conv.total_output_tokens = sb.total_output_tokens;
878 conv.session_cost = sb.session_cost;
879 if sb.continued && cfg.persist && session_lock.is_some() {
882 crate::engine::setup::recover_turn_draft(&mut conv).await;
883 }
884
885 let view = RuntimeView::from_runtime(&runtime).await;
886 let hook_bus = Arc::clone(runtime.hook_bus());
887 let event_queue = Arc::clone(runtime.event_queue());
888 let keep_warm = cfg.keep_warm;
889 let (
890 handle,
891 SessionEndpoints {
892 cmd_rx,
893 events,
894 view,
895 lifecycle,
896 journal_id,
897 presence,
898 name,
899 keep_warm: keep_warm_cell,
900 cache_keepwarm,
901 memory_mode,
902 },
903 ) = SessionHandle::new(meta.clone(), view);
904 presence.store(Arc::new(super::handle::Presence {
910 client_input: Some(client_input),
911 ..Default::default()
912 }));
913 *memory_mode.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = cfg.memory_mode;
914 let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
915 let session_start = if cfg.await_extensions {
916 super::session_start::SessionStart::Done
917 } else if cfg.restore_parked {
918 super::session_start::SessionStart::Pending
920 } else {
921 super::session_start::SessionStart::queue(
922 host,
923 Arc::clone(&hook_bus),
924 Arc::clone(&journal_id),
925 ext_ready_timeout,
926 )
927 };
928
929 let mut actor = SessionActor {
930 id,
931 meta,
932 config: cfg,
933 runtime: Live::new(runtime),
934 conv: Live::new(conv),
935 host: Arc::clone(host),
936 hook_bus,
937 event_queue,
938 settings_replay: Vec::new(),
939 keep_warm,
940 cache_keepwarm: super::cache_keepwarm::KeepWarmState::new(
941 config.cache_keepwarm,
942 config.cache_keepwarm_auto_max_hours,
943 cache_keepwarm,
944 ),
945 park_deadline: None,
946 park_recheck_at: None,
947 prompt_abandon_deadline: None,
948 stream: None,
949 cancel: None,
950 steer_tx: None,
951 owner_steer_budget: None,
952 streaming: false,
953 turn_baseline: 0,
954 consecutive_auto_turns: 0,
955 turn_steered_events: Vec::new(),
956 turn_completion: None,
957 revoked_turn_deadline: None,
958 turn_draft: TurnDraftState::default(),
959 persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
960 last_queued_save: None,
961 secret_prompt_handle: SecretPromptHandle::new(sp_tx),
962 secret_prompt_rx,
963 pending_prompts: VecDeque::new(),
964 next_prompt_id: 1,
965 cmd_rx,
966 events,
967 view,
968 attached: HashMap::new(),
969 input_owner: None,
970 client_input,
971 input_seq: 0,
972 next_client_id: 1,
973 seq: 0,
974 turn_replay: VecDeque::new(),
975 web_search_notice: None,
976 state: AttachState::Detached { running: false },
977 background,
978 compact: None,
979 compacting_inline: false,
980 session_start,
981 turn_awaiting_start: None,
982 subagent_tick: None,
983 turn_worker_generation: 0,
984 lifecycle,
985 journal_id,
986 presence,
987 name,
988 keep_warm_cell,
989 session_lock,
990 driver: None,
991 driver_pending: None,
992 driver_generation: 0,
993 driver_interrupted_owner: None,
994 driver_tick_interval: {
995 let mut interval = tokio::time::interval(Duration::from_millis(200));
996 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
997 interval
998 },
999 reload_fence: None,
1000 memory_mode_slot: memory_mode,
1001 memory_seen_active: false,
1002 parked_tool_activations: Vec::new(),
1003 };
1004 actor.observe_memory();
1006 if actor.keep_warm {
1007 actor.note_pin();
1008 }
1009 Ok((handle, SessionTask(actor)))
1010 }
1011
1012 pub(crate) fn note_pin(&self) {
1018 let Some(sink) = self.host.pin_sink() else { return };
1019 let journal = (**self.journal_id.load()).clone();
1020 if self.keep_warm {
1021 let kw = self.cache_keepwarm.override_mode.map(|m| m.to_string());
1022 sink.pinned(self.id.as_str(), &journal, self.conv.session.name.as_deref(), self.config.cwd.as_deref(), kw.as_deref());
1023 } else {
1024 sink.unpinned(self.id.as_str(), &journal);
1025 }
1026 }
1027
1028 pub(crate) fn set_journal_id(&mut self, new_id: String) {
1030 let old = self.journal_id.swap(Arc::new(new_id.clone()));
1031 if self.keep_warm && *old != new_id {
1032 if let Some(sink) = self.host.pin_sink() {
1033 sink.moved(&old, &new_id);
1034 }
1035 }
1036 }
1037
1038 pub(crate) fn sync_name(&mut self) {
1039 let name = self.conv.session.name.clone();
1040 self.meta.name = name.clone();
1041 self.name.store(Arc::new(name.clone()));
1042 if self.keep_warm {
1043 self.note_pin();
1044 }
1045 if let Err(e) = self.background.update_session_name(name.as_deref()) {
1046 tracing::warn!(session = %self.id, "registry name update failed: {e}");
1047 }
1048 }
1049
1050 pub(crate) fn emit(&mut self, event: SessionEventWire) {
1063 let replay = self.streaming
1064 && !matches!(
1065 event,
1066 SessionEventWire::Prompt(_)
1067 | SessionEventWire::PromptResolved { .. }
1068 | SessionEventWire::Attached { .. }
1069 | SessionEventWire::QueryResult { .. }
1070 | SessionEventWire::Conversation(_)
1071 | SessionEventWire::Stream(StreamEvent::Session(
1072 SessionEvent::MessageHistory(_)
1073 ))
1074 );
1075 let env = Envelope {
1076 session_id: self.id.clone(),
1077 seq: self.seq,
1078 ts: chrono::Utc::now(),
1079 event,
1080 };
1081 self.seq += 1;
1082 if replay {
1083 if self.turn_replay.len() >= TURN_REPLAY_CAP {
1084 self.turn_replay.pop_front();
1085 }
1086 let mut ring = env.clone();
1087 if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
1088 *user_text = None;
1089 }
1090 self.turn_replay.push_back(ring);
1091 }
1092 let _ = self.events.send(env);
1094 }
1095
1096 fn trim_replay_to_checkpoint(&mut self) {
1104 self.turn_replay.retain(|env| {
1105 !matches!(
1106 env.event,
1107 SessionEventWire::Stream(StreamEvent::Llm(_))
1108 | SessionEventWire::Stream(StreamEvent::Agent(
1109 AgentEvent::SteeringDelivered { .. }
1110 ))
1111 )
1112 });
1113 }
1114
1115 pub(crate) fn emit_conversation(&mut self) {
1116 let snap = self.conv.snapshot(self.consecutive_auto_turns);
1117 self.emit(SessionEventWire::Conversation(snap));
1118 }
1119
1120 pub(crate) async fn publish_view(&mut self) {
1121 let v = RuntimeView::from_runtime(&self.runtime).await;
1122 self.view.store(Arc::new(v));
1123 }
1124
1125 pub(crate) fn request_save(&mut self) {
1129 if !self.config.persist || !self.conv.is_live() {
1130 return;
1131 }
1132 let Some(session) = self.conv.prepare_save() else {
1133 return;
1134 };
1135 let key = super::persister::SnapshotKey::of(&session);
1136 if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
1139 return;
1140 }
1141 self.last_queued_save = Some(key);
1142 self.persister.save(session);
1143 }
1144
1145 pub(crate) async fn flush_saves(&mut self) -> bool {
1149 let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
1150 Ok(ok) => ok,
1151 Err(_) => {
1152 tracing::warn!(session = %self.id, "session save still pending after the save budget");
1153 false
1154 }
1155 };
1156 if !ok {
1157 self.last_queued_save = None;
1160 }
1161 ok
1162 }
1163
1164 pub(crate) async fn save(&mut self) -> bool {
1167 self.request_save();
1168 self.flush_saves().await
1169 }
1170
1171 pub(crate) async fn announce_idle(&mut self) {
1177 self.sync_memory_choice();
1181 self.flush_saves().await;
1182 #[cfg(any(test, feature = "testing"))]
1184 if super::session_start::testing::take_requeue(&self.journal_id.load()) {
1185 self.queue_session_start();
1186 }
1187 self.emit(SessionEventWire::Idle);
1188 self.cache_keepwarm_on_idle().await;
1189 }
1190
1191 async fn handle_context_head_checkpoint(
1201 &mut self,
1202 session_id: String,
1203 messages: Vec<crate::SharedMessage>,
1204 receipt: agent_core::core::context_head::ContextHeadReceipt,
1205 ) {
1206 self.turn_draft_rolled_over();
1207 if session_id != self.conv.session.id {
1209 tracing::warn!(
1210 event_id = %session_id,
1211 actor_id = %self.conv.session.id,
1212 "context head checkpoint: stale session id — ignoring"
1213 );
1214 receipt.complete(Err(std::io::Error::new(
1215 std::io::ErrorKind::InvalidInput,
1216 "stale session id after compaction",
1217 )));
1218 return;
1219 }
1220
1221 if !self.config.persist {
1223 receipt.complete(Ok(()));
1224 return;
1225 }
1226
1227 if !self.persister.flush().await {
1230 tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
1231 }
1232 let result = self.conv.persist_context_head(&session_id, messages).await;
1233 if let Err(ref e) = result {
1234 tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
1235 }
1236 let succeeded = result.is_ok();
1237 receipt.complete(result);
1238
1239 if let Some(driver) = &self.driver {
1241 if !succeeded
1242 || driver.grant.session_id != session_id
1243 || session_id != self.conv.session.id
1244 {
1245 self.driver_revoke("context checkpoint failed or session replaced");
1246 }
1247 }
1248 }
1249
1250
1251 pub(crate) fn reload_record(&self) -> SessionReloadRecord {
1253 let view = self.view.load();
1254 SessionReloadRecord {
1255 config: SessionConfig {
1257 memory_mode: self.effective_memory_mode(),
1258 ..self.config.clone()
1259 },
1260 keep_warm: self.keep_warm,
1261 lifecycle: SessionLifecycle::from_u8(
1262 self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
1263 ),
1264 settings_replay: self.settings_replay.clone(),
1265 cache_keepwarm: self.cache_keepwarm.override_mode.map(|m| m.to_string()),
1266 model: view.model.clone(),
1267 thinking_level: view.thinking_level.clone(),
1268 workers: Vec::new(),
1269 tool_activations: if self.runtime.is_live() {
1270 self.runtime.carried_tool_activations()
1271 } else {
1272 self.parked_tool_activations.clone()
1273 },
1274 }
1275 }
1276
1277 pub(crate) fn publish_presence(&self) {
1288 use super::handle::{Presence, PresenceState};
1289 self.background
1290 .set_inbox_accepting(self.input_owner.is_some());
1291 let prev = self.presence.load();
1292 let busy = self.busy() || self.compacting_inline;
1293 let parked = self.is_parked();
1294 let state = if parked {
1295 PresenceState::Parked
1296 } else if busy {
1297 PresenceState::Busy
1298 } else {
1299 PresenceState::Idle
1300 };
1301 let now = chrono::Utc::now();
1302 let state_since = match prev.state_since {
1303 Some(t) if prev.state == state => Some(t),
1304 _ => Some(now),
1305 };
1306 let wall = |at: tokio::time::Instant| {
1307 let left = at.saturating_duration_since(tokio::time::Instant::now());
1308 chrono::Duration::from_std(left)
1309 .ok()
1310 .and_then(|d| now.checked_add_signed(d))
1311 };
1312 let park_due = self
1316 .park_deadline
1317 .filter(|d| !parked && Some(*d) != self.park_recheck_at)
1318 .filter(|_| self.can_end_idle() || self.can_park())
1319 .and_then(wall);
1320 let evict_due = self.park_deadline.filter(|_| parked).and_then(wall);
1321 let (title, cost_usd, messages) = if self.conv.is_live() {
1322 (
1323 conversation_title(&self.conv),
1324 self.conv.session_cost,
1325 self.conv.api_messages.len(),
1326 )
1327 } else {
1328 (prev.title.clone(), prev.cost_usd, prev.messages)
1329 };
1330 self.presence.store(Arc::new(Presence {
1331 clients: self.attached.len(),
1332 input_owner: self.input_owner,
1333 awaiting_input: self.pending_prompts.len(),
1334 roster: self.roster(),
1335 client_input: Some(self.client_input),
1336 busy,
1337 state,
1338 state_since,
1339 park_due,
1340 evict_due,
1341 title,
1342 cost_usd,
1343 messages,
1344 }));
1345 }
1346
1347 pub(crate) fn roster(&self) -> Vec<ClientInfo> {
1349 let mut roster: Vec<ClientInfo> = self
1350 .attached
1351 .iter()
1352 .map(|(id, a)| ClientInfo {
1353 id: *id,
1354 name: a.name.clone(),
1355 kind: a.meta.kind,
1356 mode: a.mode,
1357 attached_at_ms: a.attached_at_ms,
1358 })
1359 .collect();
1360 roster.sort_by_key(|c| c.id.0);
1361 roster
1362 }
1363
1364 fn client_label(&self, id: ClientId) -> String {
1366 match self.attached.get(&id) {
1367 Some(a) => format!("{} (#{})", a.name, id.0),
1368 None => format!("client #{}", id.0),
1369 }
1370 }
1371
1372 fn input_refusal(&self, c: ClientId) -> Option<String> {
1376 match self.client_input {
1377 ClientInput::Exclusive => (self.input_owner != Some(c)).then(|| match self.input_owner {
1378 Some(o) => format!("input owned by {}", self.client_label(o)),
1379 None => "input has no owner; attach with --takeover".to_string(),
1380 }),
1381 ClientInput::Shared => match self.attached.get(&c).map(|a| a.mode) {
1382 Some(AttachMode::Mirror | AttachMode::Takeover) => None,
1383 Some(AttachMode::Observe) => {
1384 Some("this client is read-only (attached with --observe)".to_string())
1385 }
1386 None => Some("this client is not attached".to_string()),
1387 },
1388 }
1389 }
1390
1391 fn note_input(&mut self, c: ClientId) {
1395 let Some(a) = self.attached.get_mut(&c) else { return };
1396 if a.mode == AttachMode::Observe {
1397 return;
1398 }
1399 self.input_seq += 1;
1400 a.last_input = self.input_seq;
1401 if self.client_input == ClientInput::Shared && self.input_owner != Some(c) {
1402 let from = self.input_owner;
1403 self.input_owner = Some(c);
1404 self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason: OwnerChangeReason::Input });
1405 self.publish_presence();
1406 }
1407 }
1408
1409 pub(crate) fn update_attach_state(&mut self) {
1412 self.publish_presence();
1413 if matches!(self.state, AttachState::Parking | AttachState::Parked) {
1414 return;
1415 }
1416 self.state = if self.attached.is_empty() {
1417 AttachState::Detached {
1418 running: self.streaming,
1419 }
1420 } else {
1421 AttachState::Attached(self.attached.len())
1422 };
1423 self.rearm_park();
1424 self.rearm_prompt_abandon();
1425 }
1426
1427 pub(crate) fn is_parked(&self) -> bool {
1430 matches!(self.state, AttachState::Parked)
1431 }
1432
1433 pub(crate) fn can_park(&self) -> bool {
1437 self.can_park_ignoring_keepwarm() && !self.cache_keepwarm.active()
1441 }
1442
1443 pub(crate) fn can_park_ignoring_keepwarm(&self) -> bool {
1445 self.can_park_but_for_workers() && !self.workers_hold_park()
1446 }
1447
1448 fn workers_hold_park(&self) -> bool {
1451 self.runtime.is_live()
1452 && self
1453 .runtime
1454 .subagent_registry()
1455 .lock()
1456 .unwrap_or_else(|p| p.into_inner())
1457 .holds_park()
1458 }
1459
1460 fn can_park_but_for_workers(&self) -> bool {
1462 self.attached.is_empty()
1463 && !self.streaming
1464 && self.compact.is_none()
1465 && self.pending_prompts.is_empty()
1466 && self.conv.is_live()
1467 && !self.conv.api_messages.is_empty()
1468 && self.conv.queued_message.is_none()
1469 && !self.keep_warm
1470 && self.config.persist
1471 && !self.is_parked()
1472 && self.driver.is_none()
1473 }
1474
1475 fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
1481 let total = self.conv.session_cost;
1482 if let Some(cap) = self.config.max_session_cost {
1483 if cap.is_finite() && total >= cap {
1484 return Some(("session", total, cap));
1485 }
1486 }
1487 if let Some(driver) = &self.driver {
1488 if let Some(cap) = driver.grant.effective_cost_cap(None) {
1492 let run_cost = total - driver.cost_at_arm;
1493 if cap.is_finite() && run_cost >= cap {
1494 return Some(("run", run_cost, cap));
1495 }
1496 }
1497 }
1498 None
1499 }
1500
1501 fn journal_exists(&self) -> bool { let id = (**self.journal_id.load()).clone();
1503 crate::config::resolve_write_path("sessions")
1504 .join(format!("{id}.json"))
1505 .is_file()
1506 }
1507
1508 pub(crate) fn driver_revoke(&mut self, reason: &str) {
1513 let undelivered = self
1514 .driver
1515 .as_mut()
1516 .map(|d| d.steering.drain(..).collect::<Vec<_>>())
1517 .unwrap_or_default();
1518 let was_active = self.driver.is_some() || self.driver_pending.is_some();
1519 if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
1521 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
1522 }
1523 if let Some(driver) = &self.driver {
1524 self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
1525 self.host
1526 .release_driver(&driver.grant.plugin_id, &self.id);
1527 }
1528 self.driver_generation = self.driver_generation.wrapping_add(1);
1529 self.driver_pending = None;
1530 self.driver = None;
1531 if was_active {
1532 self.emit(SessionEventWire::DriverRevoked {
1533 reason: reason.to_string(),
1534 undelivered_steering: undelivered,
1535 });
1536 }
1537 if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1546 self.resolve_pending_prompts();
1547 let wait = if self.stream.is_some() {
1548 budgets::CANCEL_DRAIN_TIMEOUT
1549 } else {
1550 std::time::Duration::ZERO
1551 };
1552 self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1553 }
1554 self.rearm_park();
1555 }
1556
1557 pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1561 if self.driver.is_some() || self.driver_pending.is_some() {
1563 self.driver_revoke("explicit command");
1564 }
1565
1566 let manager = self.host.ext_manager().clone();
1567 let session_id = self.id.0.clone();
1568
1569 let (handler, timeout) = match manager.try_read() {
1571 Ok(mgr) => {
1572 let handler = match mgr.user_action_handler(&plugin) {
1573 Ok(h) => h,
1574 Err(error) => {
1575 self.emit(SessionEventWire::SystemNotice(error));
1576 return;
1577 }
1578 };
1579 let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1580 5
1581 } else {
1582 self.emit(SessionEventWire::SystemNotice(
1583 "session driver extension lacks validated session.drive permission".into(),
1584 ));
1585 return;
1586 };
1587 (handler, timeout)
1588 }
1589 Err(_) => {
1590 self.emit(SessionEventWire::SystemNotice(
1591 "extensions are loading or busy — try again shortly".into(),
1592 ));
1593 return;
1594 }
1595 };
1596
1597 let handler_generation = super::driver::live_generation(&handler).ok();
1598
1599 let owner = plugin.clone();
1600 let cmd = command.clone();
1601 let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1602
1603 let generation = self.driver_generation;
1604 let task_handler = handler.clone();
1605 self.driver_pending = Some(super::driver::DriverPending {
1606 generation,
1607 session_id: session_id.clone(),
1608 task: super::driver::Task(tokio::spawn(async move {
1609 let (sink, collector) =
1610 invoke_event_channel(InvokeOutputBudget::default());
1611 let request_id = uuid::Uuid::new_v4().to_string();
1612 let (result, report) =
1613 tokio::time::timeout(Duration::from_secs(timeout), async {
1614 tokio::join!(
1615 task_handler.invoke_command(&cmd, args, &request_id, sink),
1616 collector.collect()
1617 )
1618 })
1619 .await
1620 .unwrap_or_else(|_| {
1621 (
1622 Err(format!("interactive command timed out ({timeout}s)")),
1623 crate::extensions::invoke_output::InvokeOutputReport {
1624 events: Vec::new(),
1625 counters: Default::default(),
1626 },
1627 )
1628 });
1629 super::driver::TaskResult::Command {
1630 owner,
1631 command,
1632 handler: task_handler,
1633 handler_generation,
1634 result,
1635 report,
1636 }
1637 })),
1638 });
1639 }
1640
1641 pub(crate) fn driver_arm(
1644 &mut self,
1645 owner: String,
1646 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1647 handler_generation: u64,
1648 reply: Reply,
1649 ) -> std::result::Result<(), String> {
1650 let manager = self.host.ext_manager().clone();
1651 super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1652
1653 if self.driver.is_some() {
1654 return Err("cannot replace an armed grant".into());
1655 }
1656
1657 let session_id = self.id.0.clone();
1658 let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1659
1660 self.host
1662 .claim_driver(&owner, &self.id)
1663 .map_err(|e| e.to_string())?;
1664
1665 let cancel = CancellationToken::new();
1666 let workers = self.runtime.subagent_registry().clone();
1667 let worker_epoch = workers
1668 .lock()
1669 .unwrap_or_else(|p| p.into_inner())
1670 .set_spawn_cancellation(Some(cancel.clone()));
1671 let deadline_task = grant.deadline().map(|deadline| {
1672 let c = cancel.clone();
1673 let w = workers.clone();
1674 super::driver::Task(tokio::spawn(async move {
1675 tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1676 c.cancel();
1677 super::driver::cancel_workers(&w, worker_epoch);
1678 }))
1679 });
1680
1681 let models = grant.models().to_vec();
1682 let proposal_notice = proposal.notice.clone();
1683 let selection = proposal.selection.clone();
1684 let deadline_ms = super::driver::deadline_ms(&grant);
1685 let run_id = grant.run_id.clone();
1686 let cost_at_arm = self.conv.session_cost;
1687
1688 let mut state = super::driver::DriverState {
1689 selection: proposal.selection.clone(),
1690 grant,
1691 handler,
1692 handler_generation,
1693 cancel,
1694 workers,
1695 worker_epoch,
1696 deadline_task,
1697 proposal: None,
1698 awaiting_terminal: false,
1699 outcome: None,
1700 feedback: crate::extensions::feedback::Tracker::default(),
1701 completed_feedback: "unknown",
1702 steering: VecDeque::new(),
1703 auto_wakes_blocked: false,
1704 cost_at_arm,
1705 };
1706
1707 super::driver::schedule(&mut state, proposal)?;
1708
1709 let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1711 if let Some(text) = context_notice {
1712 self.emit(SessionEventWire::SystemNotice(text));
1713 }
1714
1715 self.driver_interrupted_owner = None;
1716 self.driver = Some(state);
1717
1718 self.emit(SessionEventWire::DriverArmed {
1719 plugin_id: owner,
1720 run_id,
1721 models,
1722 selection,
1723 deadline_ms,
1724 notice: proposal_notice,
1725 });
1726
1727 Ok(())
1728 }
1729
1730 pub(crate) fn autonomous_work_blocked(&self) -> Option<WorkBlock> {
1756 if self.reload_fence.is_some() {
1757 Some(WorkBlock::Fenced)
1758 } else if !self.session_start.is_done() {
1759 Some(WorkBlock::SessionStartPending)
1760 } else {
1761 None
1762 }
1763 }
1764
1765 pub(crate) fn holds(&self, source: WorkSource) -> bool {
1768 match self.autonomous_work_blocked() {
1769 None => false,
1770 Some(WorkBlock::Fenced) => true,
1771 Some(WorkBlock::SessionStartPending) => matches!(source, WorkSource::KeepWarmTimer),
1772 }
1773 }
1774
1775 fn driver_idle_conflict(&self) -> Option<&'static str> {
1780 if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1781 return Some("other queued work");
1782 }
1783 if self.compact.is_some() {
1784 return Some("compaction");
1785 }
1786 match self.autonomous_work_blocked() {
1787 Some(WorkBlock::Fenced) => return Some("daemon reloading"),
1788 Some(WorkBlock::SessionStartPending) => return Some("extensions loading or reloading"),
1789 None => {}
1790 }
1791 if self.conv.context_head.is_blocked(&self.conv.session) {
1792 return Some("unverified context head");
1793 }
1794 if self.driver_completion_blocked() {
1795 return Some("outstanding workers require collection/reconciliation");
1796 }
1797 None
1798 }
1799
1800 fn driver_completion_blocked(&self) -> bool {
1802 use agent_core::orchestration::CompletionGate;
1803 self.runtime.orchestration().is_some_and(|o| {
1804 !matches!(o.completion_gate(), CompletionGate::Allowed)
1805 }) || self
1806 .runtime
1807 .subagent_registry()
1808 .lock()
1809 .map(|r| {
1810 r.list_active().iter().any(|(_, _, status)| {
1811 *status == crate::runtime::subagent::SubagentStatus::Running
1812 })
1813 })
1814 .unwrap_or(true)
1815 }
1816
1817 pub(crate) async fn driver_tick(&mut self) {
1821 use crate::extensions::session_driver::Outcome;
1822 if self.holds(WorkSource::DriverTick) {
1823 return;
1824 }
1825
1826 if self.stream.is_none() && self.turn_cancelled() {
1830 self.finish_revoked_turn(false).await;
1831 }
1832
1833 if let Some(driver) = &self.driver {
1835 let invalid = if driver.grant.session_id != self.id.0 {
1836 Some("session replaced".to_string())
1837 } else if driver.grant.expired() {
1838 Some("grant deadline reached".into())
1839 } else if driver.cancel.is_cancelled() {
1840 Some("canceled".into())
1841 } else {
1842 let manager = self.host.ext_manager().clone();
1843 super::driver::same_handler(
1844 &manager,
1845 &driver.grant.plugin_id,
1846 &driver.handler,
1847 driver.handler_generation,
1848 )
1849 .err()
1850 };
1851 if let Some(reason) = invalid {
1852 self.driver_revoke(&reason);
1853 return;
1854 }
1855 }
1856
1857 if self.driver.is_some() {
1859 if self.attached.is_empty() {
1863 self.driver_revoke("no clients attached");
1864 return;
1865 }
1866 if let Some(reason) = self.driver_idle_conflict() {
1869 self.driver_revoke(reason);
1870 return;
1871 }
1872
1873 if !self.runtime.event_queue().is_empty() {
1876 return;
1877 }
1878 }
1879
1880 if self
1882 .driver_pending
1883 .as_ref()
1884 .is_some_and(|p| p.task.0.is_finished())
1885 {
1886 let mut pending = self.driver_pending.take().expect("checked pending");
1887 if pending.generation != self.driver_generation
1888 || pending.session_id != self.id.0
1889 {
1890 return;
1891 }
1892 let result = match (&mut pending.task.0).await {
1893 Ok(result) => result,
1894 Err(error) => {
1895 self.driver_revoke("driver task failed");
1896 self.emit(SessionEventWire::SystemNotice(format!(
1897 "Session driver task failed: {error}"
1898 )));
1899 return;
1900 }
1901 };
1902 match result {
1903 super::driver::TaskResult::Command {
1904 owner,
1905 command: _,
1906 handler,
1907 handler_generation,
1908 result,
1909 report,
1910 } => {
1911 let current = self
1912 .host
1913 .ext_manager()
1914 .try_read()
1915 .ok()
1916 .and_then(|m| m.user_action_handler(&owner).ok());
1917 if !current
1918 .as_ref()
1919 .is_some_and(|current| Arc::ptr_eq(current, &handler))
1920 {
1921 self.driver_revoke(
1922 "command owner unloaded, replaced, or unavailable",
1923 );
1924 return;
1925 }
1926 let output_failed = report.is_limited()
1927 || report.events.iter().any(|event| {
1928 matches!(
1929 event,
1930 crate::extensions::runtime::InvokeCommandEvent::Output(
1931 crate::extensions::commands::CommandOutputEvent::Error { .. }
1932 )
1933 )
1934 });
1935 let mut lines: Vec<String> = Vec::new();
1940 for event in &report.events {
1941 if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1942 let text = match out {
1943 crate::extensions::commands::CommandOutputEvent::Text { content }
1944 | crate::extensions::commands::CommandOutputEvent::System { content } => {
1945 Some(content.clone())
1946 }
1947 crate::extensions::commands::CommandOutputEvent::Error { content } => {
1948 Some(format!("Error: {content}"))
1949 }
1950 _ => None,
1951 };
1952 if let Some(text) = text {
1953 lines.push(text);
1954 }
1955 }
1956 }
1957 if !lines.is_empty() {
1958 const MAX_LINES: usize = 20;
1959 let truncated = lines.len() > MAX_LINES;
1960 if truncated {
1961 lines.truncate(MAX_LINES);
1962 lines.push("…output truncated".into());
1963 }
1964 self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
1965 }
1966 if let Some(notice) = report.limit_notice() {
1967 self.emit(SessionEventWire::SystemNotice(format!(
1968 "command output truncated: {}",
1969 notice.message,
1970 )));
1971 }
1972 if let Ok(value) = result {
1973 let result = match protocol::parse_reply(&value) {
1974 Ok(Some(reply @ Reply::Start { .. })) => {
1975 if output_failed {
1976 Err("command reported an error or exceeded its output budget"
1977 .into())
1978 } else if self.streaming || self.stream.is_some() {
1979 Err("foreground turn still active".into())
1980 } else if let Some(reason) = self.driver_idle_conflict() {
1981 Err(reason.into())
1982 } else {
1983 match handler_generation {
1984 Some(generation) => {
1985 self.driver_arm(
1986 owner, handler, generation, reply,
1987 )
1988 }
1989 None => Err(
1990 "command owner did not expose a live lifecycle at dispatch"
1991 .into(),
1992 ),
1993 }
1994 }
1995 }
1996 Ok(Some(
1997 Reply::Stop { notice: text }
1998 | Reply::Status { notice: text },
1999 )) => {
2000 self.emit(SessionEventWire::SystemNotice(text));
2001 Ok(())
2002 }
2003 Ok(Some(Reply::Next { .. })) => Err(
2004 "next requires an armed poll, not an interactive command"
2005 .into(),
2006 ),
2007 Ok(None) => Ok(()),
2008 Err(error) => Err(error),
2009 };
2010 if let Err(error) = result {
2011 self.emit(SessionEventWire::SystemNotice(format!(
2012 "Session driver rejected: {error}"
2013 )));
2014 }
2015 }
2016 }
2017 super::driver::TaskResult::Poll(result) => {
2018 let Some(driver) = self.driver.as_mut() else {
2019 return;
2020 };
2021 let stop_notice = match &result {
2022 Ok(Reply::Stop { notice }) => Some(notice.clone()),
2023 _ => None,
2024 };
2025 match result.and_then(|reply| driver.grant.accept(reply)) {
2026 Ok(Some(proposal)) => {
2027 let text = proposal.notice.clone();
2028 if let Err(error) =
2029 super::driver::schedule(driver, proposal)
2030 {
2031 self.driver_revoke(&error);
2032 } else {
2033 self.emit(SessionEventWire::SystemNotice(text));
2034 }
2035 }
2036 Ok(None) => {
2037 self.driver_revoke("owner requested stop");
2038 if let Some(text) = stop_notice {
2039 self.emit(SessionEventWire::SystemNotice(text));
2040 }
2041 }
2042 Err(error) => {
2043 self.driver_revoke(&format!("invalid poll response: {error}"))
2044 }
2045 }
2046 }
2047 super::driver::TaskResult::Prepared { proposal, result } => {
2048 if self.streaming || self.stream.is_some() {
2049 self.driver_revoke("foreground work took priority");
2050 return;
2051 }
2052 if let Some(reason) = self.driver_idle_conflict() {
2056 self.driver_revoke(reason);
2057 return;
2058 }
2059 match *result {
2060 Err(protocol::PrepareError::Selection(error)) => {
2061 self.emit(SessionEventWire::SystemNotice(format!(
2062 "Session driver selection rejected (nothing sent): {error}"
2063 )));
2064 if let Some(driver) = self.driver.as_mut() {
2065 driver.outcome = Some((
2066 Outcome::SelectionRejected,
2067 "unknown".into(),
2068 ));
2069 }
2070 }
2071 Err(protocol::PrepareError::Blocked(error)) => {
2072 self.driver_revoke(&format!("preflight blocked: {error}"));
2073 }
2074 Ok(candidate) => {
2075 let validated = proposal.clone();
2076 let history = super::driver::history_with_steering(
2077 &self.conv.api_messages,
2078 &self
2079 .driver
2080 .as_ref()
2081 .map(|d| &d.steering)
2082 .cloned()
2083 .unwrap_or_default(),
2084 );
2085 if let Err(error) = protocol::validate_prepared(
2086 &candidate, &validated, &history,
2087 ) {
2088 self.driver_revoke(&format!(
2089 "prepared selection changed before commit: {error}"
2090 ));
2091 return;
2092 }
2093 {
2097 let driver = self.driver.as_mut().expect("driver present");
2098 let conv = &mut *self.conv;
2099 super::driver::commit_submission(
2100 &mut conv.api_messages,
2101 &mut driver.steering,
2102 &proposal.prompt,
2103 );
2104 }
2105 *self.runtime = candidate;
2107 self.conv.session.model = self.runtime.model().to_owned();
2108 self.conv.session.thinking_level =
2109 self.runtime.thinking_level().to_owned();
2110 self.consecutive_auto_turns = 0;
2111 self.turn_baseline = self.conv.api_messages.len();
2112 self.turn_steered_events.clear();
2113 self.turn_replay.clear();
2114 self.streaming = true;
2115 self.open_turn_draft();
2116 self.update_attach_state();
2117 self.emit(SessionEventWire::TurnStarted {
2118 turn_baseline: self.turn_baseline,
2119 trigger: TurnTrigger::DriverAuto,
2120 user_text: Some(proposal.prompt.clone()),
2121 });
2122 self.announce_web_search_state();
2123
2124 if let Some(driver) = self.driver.as_mut() {
2126 driver.awaiting_terminal = true;
2127 driver.completed_feedback = "unknown";
2128 if driver.grant.feedback_enabled() {
2129 driver.feedback.begin_turn(
2130 &driver.selection.model,
2131 &driver.selection.effort,
2132 );
2133 }
2134 }
2135
2136 let handler = self
2138 .driver
2139 .as_ref()
2140 .expect("driver")
2141 .handler
2142 .clone();
2143 let handler_generation = self
2144 .driver
2145 .as_ref()
2146 .expect("driver")
2147 .handler_generation;
2148 let ct = self
2149 .driver
2150 .as_ref()
2151 .expect("driver")
2152 .cancel
2153 .child_token();
2154 self.cancel = Some(ct.clone());
2155 let completion = crate::runtime::TurnCompletion::with_default_cause(
2158 crate::runtime::CancelCause::Driver,
2159 );
2160 self.turn_completion = Some(completion.clone());
2161 let (tx, rx) = mpsc::unbounded_channel();
2162 self.steer_tx = Some(tx);
2163 self.owner_steer_budget = None;
2164 let mut runtime = self.runtime.clone();
2165 runtime.share_ttl_latches(&self.runtime);
2169 let history = self.conv.api_messages.clone();
2170 let secret = self.secret_prompt_handle.clone();
2171 let session_id = self.id.0.clone();
2172 let generation = self.driver_generation;
2173 self.driver_pending = Some(super::driver::DriverPending {
2174 generation,
2175 session_id,
2176 task: super::driver::Task(tokio::spawn(async move {
2177 if ct.is_cancelled() {
2178 return super::driver::TaskResult::Started(
2179 Err("canceled before stream setup".into()),
2180 );
2181 }
2182 if let Err(error) =
2183 super::driver::same_lifecycle(
2184 &handler,
2185 handler_generation,
2186 )
2187 {
2188 return super::driver::TaskResult::Started(
2189 Err(error),
2190 );
2191 }
2192 tokio::select! {
2193 biased;
2194 _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
2195 started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
2196 }
2197 })),
2198 });
2199
2200 let mut tick = tokio::time::interval(
2202 std::time::Duration::from_secs(1),
2203 );
2204 tick.set_missed_tick_behavior(
2205 tokio::time::MissedTickBehavior::Delay,
2206 );
2207 tick.reset();
2208 self.subagent_tick = Some(tick);
2209 self.request_save();
2210 }
2211 }
2212 }
2213 super::driver::TaskResult::Started(started) => match started {
2214 Ok(started) if self.driver.is_some() => {
2215 self.stream = Some(started);
2216 }
2217 Err(error) => {
2218 self.driver_revoke(&error);
2219 }
2220 _ => {}
2221 },
2222 }
2223 }
2224
2225 if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
2227 return;
2228 }
2229 if let Some(reason) = self.driver_idle_conflict() {
2230 self.driver_revoke(reason);
2231 return;
2232 }
2233 let session_cost = self.conv.session_cost;
2234 let Some(driver) = self.driver.as_mut() else {
2235 return;
2236 };
2237 if let Some((outcome, error_kind)) = driver.outcome.take() {
2238 let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
2239 let handler = driver.handler.clone();
2240 let session_id = self.id.0.clone();
2241 let generation = self.driver_generation;
2242 self.driver_pending = Some(super::driver::DriverPending {
2243 generation,
2244 session_id,
2245 task: super::driver::Task(tokio::spawn(async move {
2246 let result = tokio::time::timeout(
2249 Duration::from_secs(30),
2250 protocol::poll(handler, request),
2251 )
2252 .await
2253 .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
2254 super::driver::TaskResult::Poll(result)
2255 })),
2256 });
2257 } else if driver
2258 .proposal
2259 .as_ref()
2260 .is_some_and(|p| Instant::now() >= p.due)
2261 {
2262 let proposal =
2263 driver.proposal.take().expect("checked proposal").proposal;
2264 let runtime = self.runtime.clone();
2267 let history = super::driver::history_with_steering(
2268 &self.conv.api_messages,
2269 &driver.steering,
2270 );
2271 let session_id = self.id.0.clone();
2272 let generation = self.driver_generation;
2273 self.driver_pending = Some(super::driver::DriverPending {
2274 generation,
2275 session_id,
2276 task: super::driver::Task(tokio::spawn(async move {
2277 let result = tokio::time::timeout(
2278 Duration::from_secs(5),
2279 protocol::prepare(&runtime, &proposal, &history),
2280 )
2281 .await
2282 .unwrap_or_else(|_| {
2283 Err(protocol::PrepareError::Blocked(
2284 "preparation timed out (5s)".into(),
2285 ))
2286 });
2287 super::driver::TaskResult::Prepared {
2288 proposal,
2289 result: Box::new(result),
2290 }
2291 })),
2292 });
2293 }
2294 }
2295
2296 pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
2301 match Self::try_lock_session(new_id) {
2302 Ok(lock) => self.session_lock = Some(lock),
2303 Err(e) => {
2304 tracing::warn!(session = %new_id, "reacquire session lock: {e}");
2305 self.session_lock = None;
2306 }
2307 }
2308 }
2309
2310 pub(crate) fn try_lock_session(
2312 id: &str,
2313 ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
2314 {
2315 let dir = agent_core::session_lock::sessions_dir();
2316 let holder = agent_core::session_lock::LockHolder {
2317 pid: std::process::id(),
2318 kind: "daemon".to_string(),
2319 };
2320 agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
2321 }
2322
2323 pub(crate) fn can_end_idle(&self) -> bool {
2328 self.attached.is_empty()
2329 && !self.streaming
2330 && self.compact.is_none()
2331 && self.pending_prompts.is_empty()
2332 && self.conv.is_live()
2333 && self.conv.api_messages.is_empty()
2334 && self.conv.queued_message.is_none()
2335 && !self.keep_warm
2336 && !self.is_parked()
2337 && self.driver.is_none()
2342 && self.driver_pending.is_none()
2343 }
2344
2345 pub(crate) fn rearm_park(&mut self) {
2346 self.rearm_park_deadline();
2347 self.publish_presence();
2348 }
2349
2350 fn rearm_park_deadline(&mut self) {
2351 if self.is_parked() {
2352 return;
2355 }
2356 if self.can_end_idle() {
2357 if self.park_deadline.is_none() {
2362 self.park_deadline =
2363 Some(tokio::time::Instant::now() + idle_end_grace());
2364 }
2365 return;
2366 }
2367 if !self.can_park() {
2368 let workers_only = self.can_park_but_for_workers()
2371 && !self.cache_keepwarm.active()
2372 && park_grace().is_some();
2373 self.park_deadline = if workers_only {
2374 self.park_deadline.or_else(|| {
2375 let at = tokio::time::Instant::now() + worker_park_recheck();
2376 self.park_recheck_at = Some(at);
2377 Some(at)
2378 })
2379 } else {
2380 None
2381 };
2382 return;
2383 }
2384 match park_grace() {
2385 Some(grace) => {
2386 let due = tokio::time::Instant::now() + grace;
2390 self.park_deadline = Some(self.park_deadline.map_or(due, |d| d.min(due)));
2393 }
2394 None => self.park_deadline = None,
2395 }
2396 }
2397
2398 fn rearm_prompt_abandon(&mut self) {
2408 let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
2409 let abandoned = self.attached.is_empty()
2410 && !self.pending_prompts.is_empty()
2411 && !armed_driver
2412 && !self.is_parked();
2413 if !abandoned {
2414 self.prompt_abandon_deadline = None;
2417 return;
2418 }
2419 if self.prompt_abandon_deadline.is_none() {
2420 self.prompt_abandon_deadline =
2422 prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
2423 }
2424 }
2425
2426 fn on_prompt_abandon_deadline(&mut self) {
2431 self.prompt_abandon_deadline = None;
2432 if !self.attached.is_empty() {
2433 return;
2434 }
2435 let had_prompts = !self.pending_prompts.is_empty();
2436 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2437 let _ = tx.send(None);
2438 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2439 }
2440 if had_prompts {
2441 tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
2442 self.publish_presence();
2443 self.rearm_park();
2445 }
2446 }
2447
2448 fn set_lifecycle(&mut self, l: SessionLifecycle) {
2449 self.lifecycle
2450 .store(l as u8, std::sync::atomic::Ordering::Release);
2451 self.emit(SessionEventWire::Lifecycle(l));
2452 }
2453
2454 pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
2458 let flow = self.park_now().await;
2459 self.publish_presence();
2460 if flow.is_continue()
2463 && !self.is_parked()
2464 && matches!(self.session_start, super::session_start::SessionStart::Pending)
2465 {
2466 self.queue_session_start();
2467 }
2468 flow
2469 }
2470
2471 async fn park_now(&mut self) -> std::ops::ControlFlow<EndReason> {
2472 let recheck = self.park_deadline.is_some() && self.park_deadline == self.park_recheck_at;
2474 self.park_recheck_at = None;
2475 self.park_deadline = None;
2476 if self.is_parked() {
2477 if self.attached.is_empty() {
2478 tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
2479 return std::ops::ControlFlow::Break(EndReason::Evicted);
2480 }
2481 return std::ops::ControlFlow::Continue(());
2482 }
2483 if self.can_end_idle() {
2484 tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
2485 if let Some(lock) = self.session_lock.take() {
2488 lock.release_and_remove();
2489 }
2490 return std::ops::ControlFlow::Break(EndReason::Idle);
2491 }
2492 if !self.can_park() {
2493 self.rearm_park();
2495 return std::ops::ControlFlow::Continue(());
2496 }
2497 if recheck {
2498 if let Some(grace) = park_grace() {
2501 self.park_deadline = Some(tokio::time::Instant::now() + grace);
2502 return std::ops::ControlFlow::Continue(());
2503 }
2504 }
2505 self.state = AttachState::Parking;
2506 self.set_lifecycle(SessionLifecycle::Parking);
2507 if !self.save().await {
2510 tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
2511 self.state = AttachState::Detached { running: false };
2512 self.set_lifecycle(SessionLifecycle::Live);
2513 return std::ops::ControlFlow::Continue(());
2514 }
2515 if !self.journal_exists() {
2516 tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
2518 self.state = AttachState::Detached { running: false };
2519 self.set_lifecycle(SessionLifecycle::Live);
2520 return std::ops::ControlFlow::Continue(());
2521 }
2522 if self.runtime.session_manager().active_count() > 0 {
2523 self.emit(SessionEventWire::SystemNotice(
2524 "parked: background shells closed".into(),
2525 ));
2526 }
2527 self.runtime.session_manager().shutdown_all();
2528 self.turn_replay.clear();
2529 self.sync_memory_choice();
2532 self.parked_tool_activations = self.runtime.carried_tool_activations();
2536 self.publish_presence();
2540 let conv = self.conv.park_take();
2541 drop(conv);
2542 let runtime = self.runtime.park_take();
2543 drop(runtime);
2544 crate::core::memstat::purge_arenas();
2548 self.session_lock = None;
2552 self.session_start.withdraw_for_park();
2554 self.state = AttachState::Parked;
2555 self.set_lifecycle(SessionLifecycle::Parked);
2556 tracing::info!(session = %self.id, "session parked");
2557 self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
2561 std::ops::ControlFlow::Continue(())
2562 }
2563
2564 pub(crate) async fn unpark(&mut self) -> Result<()> {
2569 let started = std::time::Instant::now();
2570 self.park_deadline = None;
2572 let journal_id = (**self.journal_id.load()).clone();
2573
2574 {
2578 let dir = agent_core::session_lock::sessions_dir();
2579 let holder = agent_core::session_lock::LockHolder {
2580 pid: std::process::id(),
2581 kind: "daemon".to_string(),
2582 };
2583 match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
2584 Ok(lock) => self.session_lock = Some(lock),
2585 Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
2586 return Err(crate::RuntimeError::Session(format!(
2587 "cannot unpark session {}: journal locked by another process",
2588 journal_id
2589 )));
2590 }
2591 Err(e) => {
2592 tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
2593 }
2595 }
2596 }
2597
2598 let host = Arc::clone(&self.host);
2599 let cfg = self.config.clone();
2600 let queue = Arc::clone(&self.event_queue);
2601 let replay = self.settings_replay.clone();
2602 let journal_present = self.journal_exists();
2603 let view = self.view.load_full();
2604 let build = async move {
2605 let config: crate::SynapsConfig = (**host.config()).clone();
2606 let mut runtime = host
2609 .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
2610 .await?;
2611 if let Some(mode) = cfg.memory_mode {
2612 runtime.restore_session_memory_mode(mode);
2613 }
2614 runtime.set_event_queue(queue);
2615 let mut sb = if journal_present {
2616 crate::engine::setup::resolve_session_and_prompt(
2617 &mut runtime,
2618 &Some(Some(journal_id)),
2619 cfg.system.as_deref(),
2620 cfg.prompt_manifest.as_deref(),
2621 )?
2622 } else {
2623 tracing::warn!(
2627 session = %journal_id,
2628 "unpark: journal missing — restoring as a fresh conversation"
2629 );
2630 let mut sb = crate::engine::setup::resolve_session_and_prompt(
2631 &mut runtime,
2632 &None,
2633 cfg.system.as_deref(),
2634 cfg.prompt_manifest.as_deref(),
2635 )?;
2636 sb.session.id = journal_id.clone();
2637 runtime.set_session_id(Some(journal_id));
2638 sb
2639 };
2640 runtime.set_cwd(cfg.cwd.clone());
2641 runtime.set_env(cfg.env.clone());
2642 runtime.set_env_stripped(cfg.env_stripped.clone());
2643 runtime.set_model(view.model.clone());
2646 let _ = runtime.restore_session_reasoning(&view.thinking_level);
2647 sb.session.model = view.model.clone();
2648 sb.session.thinking_level = runtime.thinking_level().to_string();
2649 crate::engine::setup::finish_session_setup(
2650 &mut runtime,
2651 &config,
2652 &sb.session,
2653 cfg.cwd.clone(),
2654 crate::engine::setup::IndexRecord::Skip,
2655 );
2656 for s in &replay {
2657 replay_setting(&mut runtime, s);
2658 }
2659 Ok::<_, crate::RuntimeError>((runtime, sb))
2660 };
2661 let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
2662 Ok(Ok(v)) => v,
2663 Ok(Err(e)) => return Err(e),
2664 Err(_) => {
2665 return Err(crate::RuntimeError::Session(format!(
2666 "unpark timed out after {}s",
2667 budgets::UNPARK_TIMEOUT_SECS
2668 )))
2669 }
2670 };
2671 let mut conv = ConversationState::from_resumed(sb.session);
2672 conv.api_messages = sb.api_messages;
2673 conv.total_input_tokens = sb.total_input_tokens;
2674 conv.total_output_tokens = sb.total_output_tokens;
2675 conv.session_cost = sb.session_cost;
2676 if journal_present && self.config.persist && self.session_lock.is_some() {
2677 crate::engine::setup::recover_turn_draft(&mut conv).await;
2678 }
2679 runtime.restore_tool_activations(std::mem::take(&mut self.parked_tool_activations));
2680 self.runtime.unpark_set(runtime);
2681 self.conv.unpark_set(conv);
2682 self.memory_seen_active = false;
2684 self.observe_memory();
2685 self.publish_view().await;
2686 self.state = AttachState::Detached { running: false };
2687 self.set_lifecycle(SessionLifecycle::Live);
2688 self.update_attach_state();
2689 if matches!(self.session_start, super::session_start::SessionStart::Pending) {
2691 self.queue_session_start();
2692 }
2693 tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2694 Ok(())
2695 }
2696
2697 pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2701 if !self.is_parked() {
2702 return Ok(());
2703 }
2704 let res = self
2705 .unpark()
2706 .await
2707 .map_err(|e| format!("session parked and could not be restored: {e}"));
2708 self.publish_presence();
2709 res
2710 }
2711
2712 pub(crate) fn busy(&self) -> bool {
2714 self.streaming || self.compact.is_some()
2715 }
2716
2717 pub(crate) fn publish_subagent_rows(&mut self) {
2728 let rows = self
2729 .runtime
2730 .subagent_registry()
2731 .lock()
2732 .unwrap_or_else(|p| p.into_inner())
2733 .display_rows();
2734 let running = rows
2735 .iter()
2736 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2737 if !rows.is_empty() {
2738 self.emit(SessionEventWire::SubagentRows(rows));
2739 }
2740 if !keep_subagent_tick(self.streaming, running) {
2741 self.subagent_tick = None;
2742 self.rearm_park();
2744 }
2745 }
2746
2747 fn subagents_running(&self) -> bool {
2749 self.runtime
2750 .subagent_registry()
2751 .lock()
2752 .unwrap_or_else(|p| p.into_inner())
2753 .display_rows()
2754 .iter()
2755 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2756 }
2757
2758 fn announce_web_search_state(&mut self) {
2763 let notice = crate::runtime::openai::codex_wire::session_web_search_notice(
2764 self.runtime.model(),
2765 crate::runtime::openai::codex_wire::engine_env,
2766 );
2767 if notice.is_some() && notice != self.web_search_notice {
2768 self.web_search_notice = notice.clone();
2769 if let Some(text) = notice {
2770 self.emit(SessionEventWire::Stream(StreamEvent::Session(
2771 SessionEvent::Notice(text),
2772 )));
2773 }
2774 }
2775 }
2776
2777 async fn session_start_finished(
2785 &mut self,
2786 ran: std::result::Result<bool, tokio::task::JoinError>,
2787 ) {
2788 use super::session_start::SessionStart;
2789 match ran {
2790 Ok(true) => {}
2791 Ok(false) => {
2794 self.session_start = SessionStart::Pending;
2795 if self.turn_awaiting_start.is_some() || self.cache_keepwarm.deadline().is_some() {
2796 self.session_start_ready();
2797 }
2798 return;
2799 }
2800 Err(e) => tracing::warn!(session = %self.id, "on_session_start task failed: {e}"),
2801 }
2802 self.session_start = SessionStart::Done;
2803 if self.runtime.is_live() {
2804 self.publish_view().await;
2805 }
2806 if let Some(start) = self.turn_awaiting_start.take() {
2807 if !start.cancel.is_cancelled() {
2810 self.open_turn_stream(start).await;
2811 }
2812 }
2813 }
2814
2815 pub(crate) fn session_start_ready(&mut self) -> bool {
2831 use super::session_start::SessionStart;
2832 if matches!(self.session_start, SessionStart::Pending) {
2833 self.queue_session_start();
2834 }
2835 match &self.session_start {
2836 SessionStart::Done => true,
2837 SessionStart::Queued { ticket, .. } => {
2838 ticket.prefer();
2839 false
2840 }
2841 SessionStart::Pending => false,
2842 }
2843 }
2844
2845 fn queue_session_start(&mut self) {
2847 self.session_start = super::session_start::SessionStart::queue(
2848 &self.host,
2849 Arc::clone(&self.hook_bus),
2850 Arc::clone(&self.journal_id),
2851 extensions_ready_timeout(&self.host.config()),
2852 );
2853 }
2854
2855 async fn open_turn_stream(&mut self, start: TurnStart) {
2857 let session_budget = start.event_turn.then(|| {
2863 let event_budget = self
2864 .runtime
2865 .turn_budget()
2866 .for_event_turn(&self.host.config().turn_budgets);
2867 let session_budget = self.runtime.turn_budget().clone();
2868 self.runtime.set_turn_budget(event_budget);
2869 self.runtime
2870 .set_owner_steer_budget(self.owner_steer_budget.clone());
2871 session_budget
2872 });
2873 let stream = self
2874 .runtime
2875 .run_stream_tracked(
2876 self.conv.api_messages.clone(),
2877 start.cancel,
2878 Some(start.steer_rx),
2879 Some(self.secret_prompt_handle.clone()),
2880 start.auto_approve,
2881 start.completion,
2882 )
2883 .await;
2884 if let Some(session_budget) = session_budget {
2885 self.runtime.set_turn_budget(session_budget);
2886 self.runtime.set_owner_steer_budget(None);
2887 }
2888 self.stream = Some(stream);
2889 }
2890
2891 pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2895 self.cache_keepwarm_turn_started();
2896 let ct = CancellationToken::new();
2897 let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2898 self.streaming = true;
2899 self.turn_baseline = self.conv.api_messages.len();
2900 self.open_turn_draft();
2901 self.turn_steered_events.clear();
2902 self.turn_replay.clear();
2903 self.update_attach_state();
2904 self.emit(SessionEventWire::TurnStarted {
2905 turn_baseline: self.turn_baseline,
2906 trigger,
2907 user_text,
2908 });
2909 self.announce_web_search_state();
2910 let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2918 self.observe_memory();
2922 self.turn_worker_generation = self
2923 .runtime
2924 .subagent_registry()
2925 .lock()
2926 .unwrap_or_else(|p| p.into_inner())
2927 .begin_turn();
2928 let completion = crate::runtime::TurnCompletion::new();
2929 self.turn_completion = Some(completion.clone());
2930 self.cancel = Some(ct.clone());
2931 self.steer_tx = Some(s_tx);
2932 let event_turn = trigger == TurnTrigger::EventAuto;
2933 self.owner_steer_budget = event_turn.then(Default::default);
2934 let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2935 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2936 tick.reset();
2937 self.subagent_tick = Some(tick);
2938 let start = TurnStart {
2939 cancel: ct,
2940 steer_rx: s_rx,
2941 auto_approve,
2942 completion,
2943 event_turn,
2944 };
2945 if self.session_start_ready() {
2946 self.open_turn_stream(start).await;
2947 } else {
2948 tracing::debug!(session = %self.id, "turn waits for on_session_start");
2949 self.turn_awaiting_start = Some(start);
2950 self.emit(SessionEventWire::SystemNotice(
2954 "waiting for extensions to start (on_session_start) before sending...".into(),
2955 ));
2956 }
2957 }
2958
2959 pub(crate) fn clear_stream(&mut self) {
2967 self.stream = None;
2968 self.turn_awaiting_start = None;
2969 self.cancel = None;
2970 self.steer_tx = None;
2971 self.owner_steer_budget = None;
2972 self.streaming = false;
2973 self.turn_completion = None;
2974 self.revoked_turn_deadline = None;
2975 if !keep_subagent_tick(false, self.subagents_running()) {
2978 self.subagent_tick = None;
2979 }
2980 if !self.turn_steered_events.is_empty() && self.conv.is_live() {
2981 let undelivered = std::mem::take(&mut self.turn_steered_events);
2982 self.conv.pending_events.splice(0..0, undelivered);
2983 }
2984 self.request_save();
2987 self.close_turn_draft();
2988 self.update_attach_state();
2989 }
2990
2991 fn open_turn_draft(&mut self) {
2998 let persist =
2999 self.config.persist && self.conv.is_live() && self.session_lock.is_some();
3000 self.turn_draft = TurnDraftState {
3001 open: persist.then(|| self.conv.session.id.clone()),
3002 base_len: self.conv.api_messages.len(),
3003 text: String::new(),
3004 tools: Vec::new(),
3005 dirty: true,
3006 };
3007 self.flush_turn_draft();
3008 }
3009
3010 fn turn_draft_text(&mut self, delta: &str) {
3011 let d = &mut self.turn_draft;
3012 if d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES {
3013 d.text.push_str(delta);
3014 d.dirty = true;
3015 }
3016 }
3017
3018 fn turn_draft_reset_text(&mut self) {
3019 let d = &mut self.turn_draft;
3020 d.tools.clear();
3021 if !d.text.is_empty() {
3022 d.text.clear();
3023 d.dirty = true;
3024 }
3025 }
3026
3027 fn turn_draft_committed(&mut self) {
3029 self.turn_draft.base_len = self.conv.api_messages.len();
3030 self.turn_draft.text.clear();
3031 self.turn_draft.tools.clear();
3032 self.turn_draft.dirty = true;
3033 self.flush_turn_draft();
3034 }
3035
3036 fn turn_draft_rolled_over(&mut self) {
3042 self.turn_draft.base_len = usize::MAX;
3043 self.turn_draft.text.clear();
3044 self.turn_draft.tools.clear();
3045 self.turn_draft.dirty = true;
3046 self.flush_turn_draft();
3047 }
3048
3049 fn observe_turn_draft(&mut self, event: &crate::LlmEvent) {
3054 match event {
3055 crate::LlmEvent::Text(text) => self.turn_draft_text(text),
3056 crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset => {
3057 self.turn_draft_reset_text()
3058 }
3059 crate::LlmEvent::ToolUse { tool_name, .. } => {
3060 self.turn_draft.tools.push(tool_name.clone())
3061 }
3062 _ => {}
3063 }
3064 }
3065
3066 pub(crate) fn flush_turn_draft(&mut self) {
3069 let Some(open) = self.turn_draft.open.clone() else {
3070 return;
3071 };
3072 if !self.turn_draft.dirty {
3073 return;
3074 }
3075 let id = self.conv.session.id.clone();
3077 if open != id {
3078 self.persister.remove_draft(&open);
3079 self.turn_draft.open = Some(id.clone());
3080 }
3081 self.turn_draft.dirty = false;
3082 self.persister.write_draft(
3083 &id,
3084 agent_core::core::session_draft::TurnDraft {
3085 base_len: self.turn_draft.base_len,
3086 partial_text: self.turn_draft.text.clone(),
3087 },
3088 );
3089 }
3090
3091 fn close_turn_draft(&mut self) {
3097 if let Some(id) = self.turn_draft.open.take() {
3098 self.persister.remove_draft(&id);
3099 }
3100 self.turn_draft = TurnDraftState::default();
3101 }
3102
3103 pub(crate) async fn submit(
3105 &mut self,
3106 text: String,
3107 attachments: Vec<serde_json::Value>,
3108 from: Option<ClientId>,
3109 ) {
3110 if self.conv.context_head.is_blocked(&self.conv.session) {
3115 let reason = "context head is unverified after a failed checkpoint; reload the \
3116 session (`--continue`) or start a new one before continuing"
3117 .to_string();
3118 match from {
3121 Some(client) => self.emit(SessionEventWire::Refused {
3122 client,
3123 command: "submit".into(),
3124 reason,
3125 }),
3126 None => self.emit(SessionEventWire::SystemNotice(reason)),
3127 }
3128 return;
3129 }
3130 if self.streaming {
3131 if let Some(driver) = self.driver.as_mut() {
3137 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3138 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3139 self.emit(SessionEventWire::SystemNotice(
3140 "steering queue full (16 messages) — message dropped".into(),
3141 ));
3142 return;
3143 }
3144 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3145 self.emit(SessionEventWire::SystemNotice(
3146 "steering queue full (256 KiB) — message dropped".into(),
3147 ));
3148 return;
3149 }
3150 driver.steering.push_back(text.clone());
3151 }
3152 self.steer(text);
3153 return;
3154 }
3155 if self.driver.is_some() {
3158 let driver = self.driver.as_mut().unwrap();
3159 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3160 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3161 self.emit(SessionEventWire::SystemNotice(
3162 "steering queue full (16 messages) — message dropped".into(),
3163 ));
3164 return;
3165 }
3166 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3167 self.emit(SessionEventWire::SystemNotice(
3168 "steering queue full (256 KiB) — message dropped".into(),
3169 ));
3170 return;
3171 }
3172 driver.steering.push_back(text.clone());
3173 driver.auto_wakes_blocked = false;
3175 self.emit(SessionEventWire::Steered {
3176 text,
3177 delivered: false,
3178 });
3179 return;
3180 }
3181 if self.compact.is_some() {
3182 self.emit(SessionEventWire::Steered {
3184 text: text.clone(),
3185 delivered: false,
3186 });
3187 self.conv.queued_message = Some(text);
3188 return;
3189 }
3190 self.consecutive_auto_turns = 0;
3192 let user_text = text.clone();
3194 let api_content = text;
3195
3196 if attachments.is_empty() {
3197 self.conv.api_messages.push(std::sync::Arc::new(
3199 serde_json::json!({"role": "user", "content": api_content}),
3200 ));
3201 } else {
3202 let mut blocks = Vec::with_capacity(attachments.len() + 1);
3204 if !api_content.is_empty() {
3205 blocks.push(serde_json::json!({"type": "text", "text": api_content}));
3206 }
3207 blocks.extend(attachments);
3208 let candidate = std::sync::Arc::new(
3209 serde_json::json!({"role": "user", "content": blocks}),
3210 );
3211 let mut proposed = self.conv.api_messages.clone();
3214 proposed.push(candidate.clone());
3215 let model = self.runtime.model().to_string();
3216 if let Err(e) = crate::runtime::attachments::validate_history_for_request(&model, &proposed) {
3217 match from {
3221 Some(client) => self.emit(SessionEventWire::Refused {
3222 client,
3223 command: "submit".into(),
3224 reason: format!("attachments rejected: {e}"),
3225 }),
3226 None => self.emit(SessionEventWire::SystemNotice(format!(
3227 "attachments rejected: {e}"
3228 ))),
3229 }
3230 return;
3231 }
3232 self.conv.api_messages.push(candidate);
3233 }
3234 self.start_turn(TurnTrigger::User, Some(user_text)).await;
3235 }
3236
3237 pub(crate) fn steer(&mut self, text: String) {
3245 let send = |tx: &mpsc::UnboundedSender<String>| tx.send(text.clone()).is_ok();
3246 let delivered = match (self.steer_tx.as_ref(), self.owner_steer_budget.as_deref()) {
3247 (Some(tx), Some(lift)) => {
3248 lift.send_owner_steer(self.runtime.turn_budget().clone(), || send(tx))
3249 }
3250 (Some(tx), None) => send(tx),
3251 (None, _) => false,
3252 };
3253 self.emit(SessionEventWire::Steered {
3254 text: text.clone(),
3255 delivered,
3256 });
3257 self.conv.queued_message = Some(text);
3258 }
3259
3260 pub(crate) fn steer_subagent(&mut self, from: Option<ClientId>, handle_id: &str, text: String) {
3266 let steered = {
3267 let registry = self
3268 .runtime
3269 .subagent_registry()
3270 .lock()
3271 .unwrap_or_else(|p| p.into_inner());
3272 match registry.get(handle_id) {
3273 None => Err(format!("no subagent {handle_id} in this session")),
3274 Some(h) => {
3275 let running = h
3276 .state
3277 .read()
3278 .unwrap_or_else(|p| p.into_inner())
3279 .public_status()
3280 == crate::runtime::subagent::SubagentStatus::Running;
3281 if running {
3282 h.steer(&text).map(|()| h.agent_name.clone())
3283 } else {
3284 Err(format!("{} ({handle_id}) is not running", h.agent_name))
3285 }
3286 }
3287 }
3288 };
3289 let agent_name = match steered {
3290 Ok(name) => name,
3291 Err(reason) => {
3292 match from {
3293 Some(c) => self.emit(SessionEventWire::Refused {
3294 client: c,
3295 command: "subagent_steer".to_string(),
3296 reason,
3297 }),
3298 None => self.emit(SessionEventWire::SystemNotice(reason)),
3299 }
3300 return;
3301 }
3302 };
3303 self.note_in_main_conversation(subagent_steer_event(handle_id, &agent_name, &text));
3304 }
3305
3306 pub(crate) fn note_in_main_conversation(&mut self, event: crate::events::types::Event) {
3311 let formatted = crate::events::format_event_for_agent(&event);
3312 if self.busy() {
3313 let into_turn = self
3314 .steer_tx
3315 .as_ref()
3316 .is_some_and(|tx| tx.send(formatted.clone()).is_ok());
3317 if into_turn {
3318 self.turn_steered_events.push(formatted);
3319 } else {
3320 self.conv.pending_events.push(formatted);
3321 }
3322 } else {
3323 self.conv
3324 .api_messages
3325 .push(std::sync::Arc::new(serde_json::json!({
3326 "role": "user",
3327 "content": formatted
3328 })));
3329 self.request_save();
3330 }
3331 self.emit(SessionEventWire::External(event));
3332 self.emit_conversation();
3333 }
3334
3335 pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3357 if !self.streaming {
3358 if self.compact.is_some() {
3359 self.abort_compaction();
3360 if let Some(q) = self.conv.queued_message.take() {
3361 self.emit(SessionEventWire::Dequeued { text: q });
3362 }
3363 self.update_attach_state();
3364 }
3365 self.emit(SessionEventWire::Idle);
3366 return;
3367 }
3368 self.note_cancel_cause(reason);
3369 if let Some(ref ct) = self.cancel {
3370 ct.cancel();
3371 }
3372 self.resolve_pending_prompts();
3377 let drain = self.drain_cancelled_stream().await;
3378 match drain.history {
3379 Some(history) => self.conv.api_messages = history,
3380 None => {
3381 if !drain.closed {
3382 tracing::warn!(
3383 session = %self.id,
3384 budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
3385 "cancelled turn did not publish its history within the drain budget; \
3386 keeping the last adopted history plus the streamed partial reply"
3387 );
3388 }
3389 self.keep_streamed_partial(reason);
3390 }
3391 }
3392 let completed = drain.closed && self.turn_completed();
3395 self.finish_interrupted_turn(reason, completed).await;
3396 }
3397
3398 fn keep_streamed_partial(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3416 let d = &self.turn_draft;
3417 let follows_user = self.conv.api_messages.last().is_some_and(|m| m["role"] == "user");
3418 if d.base_len != self.conv.api_messages.len() || !follows_user {
3419 return;
3420 }
3421 let (text, tools) = (d.text.clone(), d.tools.clone());
3422 if !text.trim().is_empty() {
3423 self.conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3424 "role": "assistant",
3425 "content": [{"type": "text", "text": text}]
3426 })));
3427 }
3428 if !tools.is_empty() {
3429 let note = crate::engine::interrupt::tool_in_flight_note_message(reason, &tools);
3430 let text = note["content"].as_str().unwrap_or_default().to_owned();
3431 self.conv.api_messages.push(note);
3432 self.emit(SessionEventWire::SystemNotice(text));
3433 }
3434 }
3435
3436 fn turn_completed(&self) -> bool {
3438 self.turn_completion.as_ref().is_some_and(|c| c.completed())
3439 }
3440
3441 fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
3446 if !self.streaming {
3447 return;
3448 }
3449 if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
3450 turn.note_cancel_cause(cause);
3451 }
3452 }
3453
3454 async fn finish_interrupted_turn(
3464 &mut self,
3465 reason: crate::engine::interrupt::InterruptReason,
3466 completed: bool,
3467 ) {
3468 crate::engine::stream::repair_history_after_failure(
3470 &mut self.conv.api_messages,
3471 self.turn_baseline,
3472 );
3473 let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
3474 if !completed {
3475 crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
3476 }
3477 if let Some(q) = self.conv.queued_message.take() {
3479 self.emit(SessionEventWire::Dequeued { text: q });
3480 }
3481 {
3485 let undelivered = std::mem::take(&mut self.turn_steered_events);
3486 let conv: &mut ConversationState = &mut self.conv;
3487 for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
3488 conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3489 "role": "user",
3490 "content": formatted
3491 })));
3492 }
3493 }
3494 self.clear_stream();
3496 {
3503 use crate::runtime::subagent::WorkerCancelScope;
3504 let ending = self.lifecycle.load(std::sync::atomic::Ordering::Acquire)
3505 == SessionLifecycle::Ending as u8;
3506 let scope = worker_cancel_scope(reason, ending, self.turn_worker_generation);
3507 let mut registry = match self.runtime.subagent_registry().lock() {
3508 Ok(g) => g,
3509 Err(poisoned) => {
3510 tracing::warn!(
3511 "subagent registry mutex poisoned during abort; recovering to cancel running handles"
3512 );
3513 poisoned.into_inner()
3514 }
3515 };
3516 let cancelled = registry.cancel_running(scope);
3517 if !cancelled.is_empty() {
3518 tracing::info!(
3519 ?scope,
3520 workers = ?cancelled,
3521 "turn interrupted: cancelled running workers"
3522 );
3523 }
3524 if let WorkerCancelScope::Turn(_) = scope {
3525 let spared = registry
3528 .display_rows()
3529 .iter()
3530 .filter(|r| {
3531 r.status == crate::runtime::subagent::SubagentStatus::Running
3532 && !r.cancel_requested
3533 })
3534 .count();
3535 drop(registry);
3536 if spared > 0 {
3537 self.emit(SessionEventWire::Stream(StreamEvent::Session(
3538 SessionEvent::Notice(format!(
3539 "{spared} background worker{} from earlier turns still running — /workers to list, /workers cancel all to stop",
3540 if spared == 1 { "" } else { "s" }
3541 )),
3542 )));
3543 }
3544 }
3545 }
3546 if completed {
3547 self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
3548 } else {
3549 self.emit(SessionEventWire::Aborted {
3552 context_saved: kept_partial,
3553 });
3554 }
3555 self.emit_conversation();
3556 self.announce_idle().await;
3557 }
3558
3559 async fn finish_revoked_turn(&mut self, stream_closed: bool) {
3567 if !self.streaming {
3568 return;
3569 }
3570 let completed = stream_closed && self.turn_completed();
3571 self.stream = None;
3572 if !completed {
3573 self.keep_streamed_partial(crate::engine::interrupt::InterruptReason::Driver);
3574 }
3575 self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
3576 .await;
3577 }
3578
3579 fn turn_cancelled(&self) -> bool {
3582 self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
3583 }
3584
3585 fn resolve_pending_prompts(&mut self) {
3587 if self.pending_prompts.is_empty() {
3588 return;
3589 }
3590 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3591 let _ = tx.send(None);
3592 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3593 }
3594 self.publish_presence();
3595 self.rearm_prompt_abandon();
3596 }
3597
3598 async fn drain_cancelled_stream(&mut self) -> CancelDrain {
3617 let mut out = CancelDrain::default();
3618 let Some(mut stream) = self.stream.take() else {
3619 out.closed = true;
3620 return out;
3621 };
3622 let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
3623 loop {
3624 let event = tokio::select! {
3625 biased;
3626 Some(req) = self.secret_prompt_rx.recv() => {
3627 let _ = req.response_tx.send(None);
3628 continue;
3629 }
3630 next = tokio::time::timeout_at(deadline, stream.next()) => match next {
3631 Err(_) => break,
3632 Ok(None) => {
3633 out.closed = true;
3634 break;
3635 }
3636 Ok(Some(event)) => event,
3637 },
3638 };
3639 match event {
3640 StreamEvent::Session(SessionEvent::Done) => {
3641 out.closed = true;
3642 break;
3643 }
3644 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3645 out.history = Some(history);
3646 }
3647 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
3648 session_id,
3649 messages,
3650 receipt,
3651 }) => {
3652 self.handle_context_head_checkpoint(session_id, messages, receipt)
3653 .await;
3654 out.history = None;
3658 }
3659 StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
3660 crate::TurnOutcome::Canceled => {}
3661 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3662 self.emit(SessionEventWire::SystemNotice(err.message));
3663 }
3664 _ => {
3665 tracing::warn!(
3666 session = %self.id,
3667 category = err.category_label(),
3668 "cancelled turn reported an error while draining"
3669 );
3670 }
3671 },
3672 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3673 self.note_steering_delivered(message);
3674 self.emit(SessionEventWire::Stream(event));
3675 }
3676 StreamEvent::Session(SessionEvent::Usage { .. }) => {
3677 self.record_usage(&event);
3678 self.emit(SessionEventWire::Stream(event));
3679 }
3680 StreamEvent::Llm(ref llm) => {
3681 self.observe_turn_draft(llm);
3682 self.emit(SessionEventWire::Stream(event));
3683 }
3684 other => self.emit(SessionEventWire::Stream(other)),
3685 }
3686 }
3687 drop(stream);
3688 out
3689 }
3690
3691 fn note_steering_delivered(&mut self, message: &str) {
3693 if self.conv.queued_message.as_deref() == Some(message) {
3694 self.conv.queued_message = None;
3695 self.emit_conversation();
3696 }
3697 if let Some(driver) = self.driver.as_mut() {
3699 if driver.steering.front().map(String::as_str) == Some(message) {
3700 driver.steering.pop_front();
3701 }
3702 }
3703 if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
3704 self.turn_steered_events.remove(pos);
3705 }
3706 }
3707
3708 fn record_usage(&mut self, event: &StreamEvent) {
3710 if let StreamEvent::Session(SessionEvent::Usage {
3711 input_tokens,
3712 output_tokens,
3713 cache_read_input_tokens,
3714 cache_creation_input_tokens,
3715 cache_creation_5m,
3716 cache_creation_1h,
3717 model: usage_model,
3718 }) = event
3719 {
3720 let model_for_pricing = usage_model
3721 .as_deref()
3722 .unwrap_or(self.runtime.model())
3723 .to_string();
3724 self.cache_keepwarm_note_usage(crate::runtime::keepwarm::KeepWarmUsage {
3725 input_tokens: *input_tokens,
3726 output_tokens: *output_tokens,
3727 cache_read_tokens: *cache_read_input_tokens,
3728 cache_write_tokens: *cache_creation_input_tokens,
3729 cache_write_5m: *cache_creation_5m,
3730 cache_write_1h: *cache_creation_1h,
3731 model: usage_model.clone(),
3732 });
3733 self.conv.add_usage(
3734 *input_tokens,
3735 *output_tokens,
3736 *cache_read_input_tokens,
3737 *cache_creation_input_tokens,
3738 *cache_creation_5m,
3739 *cache_creation_1h,
3740 &model_for_pricing,
3741 );
3742 }
3743 }
3744
3745 async fn on_queue_wake(&mut self) {
3748 if self.holds(WorkSource::QueueWake) {
3749 return;
3751 }
3752 if self.is_parked() {
3753 if self.event_queue.is_empty() {
3754 return;
3755 }
3756 if let Err(e) = self.ensure_live().await {
3757 tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
3758 return;
3759 }
3760 }
3761 let busy = self.busy();
3762 let conv: &mut ConversationState = &mut self.conv;
3763 let drained = drain_event_queue(
3764 &self.event_queue,
3765 &mut conv.api_messages,
3766 &mut conv.pending_events,
3767 busy,
3768 self.steer_tx.as_ref(),
3769 );
3770 if drained.is_empty() {
3771 return;
3772 }
3773 for de in &drained {
3774 if de.disposition == EventDisposition::Steered {
3775 self.turn_steered_events.push(de.formatted.clone());
3776 }
3777 self.emit(SessionEventWire::External(de.event.clone()));
3778 }
3779 let injected = drained
3780 .iter()
3781 .any(|d| d.disposition == EventDisposition::Injected);
3782 if injected || busy {
3783 self.emit_conversation();
3784 }
3785
3786 if self.streaming
3789 && self
3790 .driver
3791 .as_ref()
3792 .is_some_and(|d| d.awaiting_terminal)
3793 {
3794 let competing = drained
3795 .iter()
3796 .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
3797 if competing {
3798 self.driver_revoke("event-bus work took priority");
3799 }
3800 }
3801
3802 let auto_turn_enabled = self.host.config().events.auto_turn;
3807 let auto_turn_cap = self.host.config().events.auto_turn_cap;
3808 let action = wake_action_with_cap(
3809 &drained,
3810 &self.conv.api_messages,
3811 busy,
3812 auto_turn_enabled,
3813 self.consecutive_auto_turns,
3814 auto_turn_cap,
3815 );
3816 match action {
3817 WakeAction::RunTurn => {
3818 if self.driver.is_some() {
3821 tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
3822 } else if self.streaming || self.stream.is_some() {
3823 tracing::warn!("handle_event_arm: RunTurn with active turn — skipping");
3824 } else {
3825 self.consecutive_auto_turns += 1;
3826 self.start_turn(TurnTrigger::EventAuto, None).await;
3827 }
3828 }
3829 WakeAction::Forward => {
3830 let hit_cap = injected
3831 && !busy
3832 && auto_turn_enabled
3833 && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
3834 if hit_cap {
3835 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3836 }
3837 }
3838 WakeAction::Nothing => {}
3839 }
3840 }
3841
3842 async fn on_stream_event(&mut self, event: StreamEvent) {
3845 if self.turn_cancelled() {
3849 match event {
3850 StreamEvent::Session(SessionEvent::Done) => {
3851 self.finish_revoked_turn(true).await;
3852 return;
3853 }
3854 StreamEvent::Session(SessionEvent::Error(err)) => {
3856 match err.outcome {
3857 crate::TurnOutcome::Canceled => {}
3858 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3859 self.emit(SessionEventWire::SystemNotice(err.message));
3860 }
3861 _ => tracing::warn!(
3862 session = %self.id,
3863 category = err.category_label(),
3864 "revoked turn reported an error"
3865 ),
3866 }
3867 return;
3868 }
3869 _ => {}
3870 }
3871 }
3872 if !matches!(
3877 event,
3878 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
3879 ) {
3880 self.emit(SessionEventWire::Stream(event.clone()));
3881 }
3882
3883 if let Some(driver) = self.driver.as_mut() {
3885 if driver.awaiting_terminal && driver.grant.feedback_enabled() {
3886 driver.feedback.observe(&event);
3887 }
3888 }
3889
3890 let is_canceled = self
3892 .cancel
3893 .as_ref()
3894 .is_some_and(|ct| ct.is_cancelled());
3895 let terminal = if self
3896 .driver
3897 .as_ref()
3898 .is_some_and(|d| d.awaiting_terminal)
3899 {
3900 super::driver::capture_terminal(Some(&event), is_canceled)
3901 } else {
3902 None
3903 };
3904
3905 enum After {
3906 Continue,
3907 AutoSendQueued(String),
3908 AutoTriggerEvents,
3909 Failed,
3910 }
3911 let mut after = After::Continue;
3912
3913 match event {
3914 StreamEvent::Llm(ref llm) => self.observe_turn_draft(llm),
3917 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3918 self.conv.api_messages = history;
3924 self.request_save();
3925 self.turn_draft_committed();
3927 self.trim_replay_to_checkpoint();
3928 self.emit_conversation();
3929 }
3930 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3931 self.note_steering_delivered(message);
3932 }
3933 StreamEvent::Agent(_) => {}
3934 StreamEvent::Session(SessionEvent::Usage { .. }) => {
3935 self.record_usage(&event);
3936 if let Some((scope, cost, cap)) = self.cost_cap_breach() {
3941 self.emit(SessionEventWire::CostCapReached {
3942 scope: scope.to_string(),
3943 cost,
3944 cap,
3945 });
3946 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
3947 if self.driver.is_some() || self.driver_pending.is_some() {
3948 self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
3949 }
3950 self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
3951 .await;
3952 return;
3953 }
3954 }
3955 StreamEvent::Session(SessionEvent::Notice(_) | SessionEvent::Retry(_)) => {}
3956 StreamEvent::Session(SessionEvent::Done) => {
3957 self.clear_stream();
3958 self.publish_subagent_rows();
3959 let had_pending = !self.conv.pending_events.is_empty();
3961 {
3962 let conv: &mut ConversationState = &mut self.conv;
3963 for formatted in conv.pending_events.drain(..) {
3964 conv.api_messages
3965 .push(std::sync::Arc::new(serde_json::json!({
3966 "role": "user",
3967 "content": formatted
3968 })));
3969 }
3970 }
3971 if let Some(queued) = self.conv.queued_message.take() {
3972 after = After::AutoSendQueued(queued);
3973 } else if had_pending {
3974 self.request_save();
3975 after = After::AutoTriggerEvents;
3976 }
3977 self.emit_conversation();
3978 if matches!(after, After::Continue) {
3979 if self.config.auto_compact {
3980 self.post_turn_chat().await;
3981 }
3982 if self.compact.is_none() {
3984 self.announce_idle().await;
3985 }
3986 }
3987 }
3988 StreamEvent::Session(SessionEvent::Error(_)) => {
3989 crate::engine::stream::repair_history_after_failure(
3992 &mut self.conv.api_messages,
3993 self.turn_baseline,
3994 );
3995 self.clear_stream();
3996 self.publish_subagent_rows();
3997 self.emit_conversation();
3998 after = After::Failed;
3999 }
4000 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
4001 session_id,
4002 messages,
4003 receipt,
4004 }) => {
4005 self.handle_context_head_checkpoint(session_id, messages, receipt)
4006 .await;
4007 }
4008 }
4009
4010 match after {
4011 After::Continue => {}
4012 After::Failed => {
4013 if self.config.auto_compact {
4014 self.post_turn_chat().await;
4015 }
4016 if self.compact.is_none() {
4017 self.announce_idle().await;
4018 }
4019 }
4020 After::AutoSendQueued(queued) => {
4021 if self.config.auto_compact {
4022 self.post_turn_chat().await;
4023 }
4024 if self.compact.is_some() {
4025 self.conv.queued_message = Some(queued);
4028 return;
4029 }
4030 self.consecutive_auto_turns = 0;
4032 let user_text = queued.clone();
4033 self.conv.api_messages.push(std::sync::Arc::new(
4034 serde_json::json!({"role": "user", "content": queued}),
4035 ));
4036 self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
4037 }
4038 After::AutoTriggerEvents => {
4039 if self.config.auto_compact {
4040 self.post_turn_chat().await;
4041 }
4042 if self.compact.is_some() {
4043 return;
4044 }
4045 let auto_turn_cap = self.host.config().events.auto_turn_cap;
4048 if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
4049 self.start_turn(TurnTrigger::EventAuto, None).await;
4050 } else {
4051 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
4052 self.announce_idle().await;
4053 }
4054 }
4055 }
4056
4057 if let Some(terminal) = terminal {
4059 let revoke_reason = if let Some(driver) = self.driver.as_mut() {
4060 super::driver::observe_terminal(driver, &self.runtime, terminal)
4061 } else {
4062 None
4063 };
4064 if let Some(driver) = self.driver.as_ref() {
4066 if let Some((outcome, _)) = &driver.outcome {
4067 self.emit(SessionEventWire::DriverTurnOutcome {
4068 outcome: *outcome,
4069 selection: driver.selection.clone(),
4070 feedback: driver.grant.feedback_enabled().then(|| {
4071 driver.completed_feedback.to_string()
4072 }),
4073 });
4074 }
4075 }
4076 if let Some(reason) = revoke_reason {
4077 self.driver_revoke(&reason);
4078 }
4079 }
4080 }
4081
4082 async fn post_turn_chat(&mut self) {
4084 self.request_save();
4085 let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
4086 if assessment.should_compact() {
4087 self.emit(SessionEventWire::SystemNotice(format!(
4088 "[auto-compacting ~{} tokens...]",
4089 assessment.used_tokens()
4090 )));
4091 self.compact(None, "auto").await;
4092 }
4093 }
4094
4095 fn compact_inline_env() -> bool {
4098 matches!(
4099 std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
4100 Ok("1") | Ok("true")
4101 )
4102 }
4103
4104 pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
4111 self.cache_keepwarm_compaction();
4113 if Self::compact_inline_env() {
4114 return self.compact_inline(instructions, source).await;
4115 }
4116 if self.compact.is_some() {
4117 self.emit(SessionEventWire::SystemNotice(
4118 "compaction already in progress".into(),
4119 ));
4120 return;
4121 }
4122 if self.streaming {
4123 self.emit(SessionEventWire::SystemNotice(
4124 "cannot compact while a turn is running".into(),
4125 ));
4126 return;
4127 }
4128 let disclosure =
4129 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
4130 self.emit(SessionEventWire::CompactionStarted {
4131 source: source.to_string(),
4132 disclosure,
4133 });
4134 let msgs = self.conv.api_messages.clone();
4135 let rt: Runtime = (*self.runtime).clone();
4136 let task = tokio::spawn(async move {
4137 compact_conversation(&msgs, &rt, instructions.as_deref()).await
4138 });
4139 self.compact = Some(CompactionJob {
4140 task,
4141 source: source.to_string(),
4142 started: tokio::time::Instant::now(),
4143 });
4144 self.update_attach_state();
4145 }
4146
4147 async fn on_compaction_done(
4151 &mut self,
4152 res: std::result::Result<
4153 Result<crate::runtime::compaction::CompactionOutcome>,
4154 tokio::task::JoinError,
4155 >,
4156 ) {
4157 let Some(job) = self.compact.take() else {
4158 return;
4159 };
4160 let msg_count = self.conv.api_messages.len();
4161 match res {
4162 Err(join) => {
4163 if !join.is_cancelled() {
4164 self.emit(SessionEventWire::CompactionFailed {
4165 message: join.to_string(),
4166 panicked: true,
4167 });
4168 }
4169 }
4170 Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
4171 message: e.to_string(),
4172 panicked: false,
4173 }),
4174 Ok(Ok(outcome)) => {
4175 if !self.persister.flush().await {
4179 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4180 }
4181 let policy: CompactionPolicy = self.config.compaction_policy.into();
4182 let queued = self.conv.queued_message.clone();
4183 let applied = apply_compaction(
4184 &self.runtime,
4185 &self.conv.session,
4186 &self.conv.api_messages,
4187 &outcome,
4188 CompactionTransition {
4189 policy,
4190 pending_events: self.conv.pending_events.clone(),
4191 queued_message: queued.clone(),
4192 hook_source: job.source.clone(),
4193 },
4194 )
4195 .await;
4196 match applied {
4197 Ok(applied) => {
4198 let previous = applied.previous_session_id.clone();
4199 self.conv.session = applied.session;
4200 self.conv.api_messages = applied.api_messages;
4201 self.conv.pending_events.clear();
4202 self.conv.queued_message = None;
4203 if policy == CompactionPolicy::LinkedSuccessor {
4204 self.conv.total_input_tokens = 0;
4205 self.conv.total_output_tokens = 0;
4206 self.conv.session_cost = 0.0;
4207 self.conv.keepwarm_cost = 0.0;
4212 self.conv.keepwarm_tokens.input = 0;
4213 self.conv.keepwarm_tokens.output = 0;
4214 }
4215 let new_id = self.conv.session.id.clone();
4216 self.runtime.set_session_id(Some(new_id.clone()));
4217 self.set_journal_id(new_id.clone());
4218 self.reacquire_session_lock(&new_id);
4220 self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
4224 self.runtime
4225 .reset_context_continuation(&new_id, &self.conv.api_messages);
4226 self.emit(SessionEventWire::CompactionApplied {
4227 previous_session_id: previous,
4228 session_id: new_id,
4229 chains_advanced: applied.chains_advanced,
4230 queued_restored: queued,
4231 msg_count,
4232 });
4233 }
4234 Err(e) => self.emit(SessionEventWire::CompactionFailed {
4235 message: e.to_string(),
4236 panicked: false,
4237 }),
4238 }
4239 }
4240 }
4241 self.emit_conversation();
4242 self.update_attach_state();
4243 self.announce_idle().await;
4244 }
4245
4246 async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
4248 self.compacting_inline = true;
4249 self.publish_presence();
4250 self.compact_inline_inner(instructions, source).await;
4251 self.compacting_inline = false;
4252 self.publish_presence();
4253 }
4254
4255 async fn compact_inline_inner(&mut self, instructions: Option<String>, source: &str) {
4256 self.emit(SessionEventWire::SystemNotice(format!(
4257 "[{}]",
4258 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
4259 )));
4260 let outcome =
4261 compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
4262 .await;
4263 let applied = match outcome {
4264 Ok(outcome) => {
4265 if !self.persister.flush().await {
4267 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4268 }
4269 apply_compaction(
4270 &self.runtime,
4271 &self.conv.session,
4272 &self.conv.api_messages,
4273 &outcome,
4274 CompactionTransition {
4275 policy: CompactionPolicy::InPlace,
4276 pending_events: Vec::new(),
4277 queued_message: None,
4278 hook_source: source.to_string(),
4279 },
4280 )
4281 .await
4282 }
4283 Err(e) => Err(e),
4284 };
4285 match applied {
4286 Ok(applied) => {
4287 self.conv.session = applied.session;
4288 self.conv.api_messages = applied.api_messages;
4289 let after = self.runtime.assess_context(&self.conv.api_messages).await;
4290 self.emit(SessionEventWire::SystemNotice(format!(
4291 "[compacted → ~{} tokens]",
4292 after.used_tokens()
4293 )));
4294 }
4295 Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
4296 "[compaction failed: {}]",
4297 e
4298 ))),
4299 }
4300 self.emit_conversation();
4301 }
4302
4303 fn on_prompt_request(&mut self, req: SecretPromptRequest) {
4306 let id = self.next_prompt_id;
4307 self.next_prompt_id += 1;
4308 let pr = PromptRequest {
4309 id,
4310 kind: PromptKind::from_title(&req.title),
4311 title: req.title,
4312 prompt: req.prompt,
4313 raised_at: chrono::Utc::now(),
4314 confirm_scope: req.confirm_scope,
4315 };
4316 self.pending_prompts.push_back((pr.clone(), req.response_tx));
4317 self.publish_presence();
4318 self.emit(SessionEventWire::Prompt(pr));
4319 self.rearm_prompt_abandon();
4322 }
4323
4324 fn confirm_answer_refusal(
4329 &self,
4330 from: Option<ClientId>,
4331 prompt: &PromptRequest,
4332 allow_all: bool,
4333 ) -> Option<&'static str> {
4334 let c = from?;
4335 if prompt.kind != PromptKind::Confirm {
4336 return None;
4337 }
4338 if self.attached.get(&c).map(|a| a.mode) == Some(AttachMode::Observe) {
4339 return Some("observe-mode clients cannot answer confirm prompts");
4340 }
4341 if allow_all && self.input_owner != Some(c) {
4342 return Some("only the input owner can choose Allow all");
4343 }
4344 None
4345 }
4346
4347 fn answer(&mut self, from: Option<ClientId>, prompt_id: u64, value: Option<String>) {
4348 let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
4349 return; };
4351 let allow_all = value.as_deref().is_some_and(|v| {
4352 v.trim()
4353 .eq_ignore_ascii_case(crate::tools::CONFIRM_ANSWER_ALLOW_ALL)
4354 });
4355 if let Some(c) = from {
4359 self.note_input(c);
4360 }
4361 if let Some(reason) = self.confirm_answer_refusal(from, &self.pending_prompts[pos].0, allow_all) {
4362 if let Some(c) = from {
4363 self.emit(SessionEventWire::Refused {
4364 client: c,
4365 command: "answer".to_string(),
4366 reason: reason.to_string(),
4367 });
4368 }
4369 return; }
4371 let approved = allow_all
4372 || value
4373 .as_deref()
4374 .map(str::trim)
4375 .is_some_and(|v| v.eq_ignore_ascii_case("y") || v.eq_ignore_ascii_case("yes"));
4376 let (prompt, tx) = self.pending_prompts.remove(pos).expect("position");
4377 let _ = tx.send(value);
4378 self.publish_presence();
4379 self.emit(SessionEventWire::PromptResolved { prompt_id });
4380 let by = match from {
4381 Some(c) => self.client_label(c),
4382 None => "the host".to_string(),
4383 };
4384 if approved && prompt.title == CONFIRM_PROMPT_TITLE {
4388 if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.rewritten_by.is_empty()) {
4389 self.emit(SessionEventWire::SystemNotice(format!(
4390 "Approved by {by}: {} runs with input rewritten by {} (not the input shown \
4391 on its tool card; the approval prompt showed what runs).",
4392 scope.tool.as_deref().unwrap_or("the tool call"),
4393 scope.rewritten_by.join(", ")
4394 )));
4395 }
4396 }
4397 if allow_all && prompt.title == CONFIRM_PROMPT_TITLE {
4400 if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.extensions.is_empty()) {
4401 self.emit(SessionEventWire::SystemNotice(format!(
4402 "Allow all armed by {by}: tool-call confirms from {} are auto-approved \
4403 until /new. Other extensions still ask. /confirms reset revokes.",
4404 scope.extensions_label()
4405 )));
4406 }
4407 }
4408 self.rearm_prompt_abandon();
4410 }
4411
4412 pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
4415 if replayable(&setting) {
4416 let disc = std::mem::discriminant(&setting);
4417 self.settings_replay
4418 .retain(|s| std::mem::discriminant(s) != disc);
4419 self.settings_replay.push(setting.clone());
4420 }
4421 let name = match &setting {
4422 SessionSetting::Model { .. } => "model",
4423 SessionSetting::ReasoningLevel { .. } => "reasoning_level",
4424 SessionSetting::ContextWindow { .. } => "context_window",
4425 SessionSetting::CompactionModel { .. } => "compaction_model",
4426 SessionSetting::ApiRetries { .. } => "api_retries",
4427 SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
4428 SessionSetting::MaxToolOutput { .. } => "max_tool_output",
4429 SessionSetting::BashTimeout { .. } => "bash_timeout",
4430 SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
4431 SessionSetting::SystemPrompt { .. } => "system_prompt",
4432 SessionSetting::ReloadPrompt => "reload_prompt",
4433 SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
4434 };
4435 match &setting {
4438 SessionSetting::Model { model } => self.runtime.prepare_model_capabilities(model).await,
4439 SessionSetting::ReasoningLevel { .. } => {
4440 let model = self.runtime.model().to_string();
4441 self.runtime.prepare_model_capabilities(&model).await;
4442 }
4443 _ => {}
4444 }
4445 let rt = &mut self.runtime;
4446 let mut clamp_wire = None;
4447 let result: std::result::Result<Option<String>, String> = match setting {
4448 SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
4449 self.conv.session.model = rt.model().to_string();
4450 clamp.map(|c| {
4451 self.conv.session.thinking_level = rt.thinking_level().to_string();
4452 clamp_wire = Some(ReasoningClampWire {
4453 from: c.from.as_str().to_string(),
4454 to: c.to.as_str().to_string(),
4455 });
4456 format!(
4457 "thinking → {} (clamped from {}: not supported by {})",
4458 c.to.as_str(),
4459 c.from.as_str(),
4460 rt.model()
4461 )
4462 })
4463 }),
4464 SessionSetting::ReasoningLevel { level } => {
4465 rt.set_reasoning_level_checked(level).map(|_| {
4466 self.conv.session.thinking_level = rt.thinking_level().to_string();
4467 None
4468 })
4469 }
4470 SessionSetting::ContextWindow { tokens } => {
4471 rt.set_context_window(tokens);
4472 Ok(None)
4473 }
4474 SessionSetting::CompactionModel { model } => {
4475 rt.set_compaction_model(model);
4476 Ok(None)
4477 }
4478 SessionSetting::ApiRetries { n } => {
4479 rt.set_api_retries(n);
4480 Ok(None)
4481 }
4482 SessionSetting::SubagentTimeout { secs } => {
4483 rt.set_subagent_timeout(secs);
4484 Ok(None)
4485 }
4486 SessionSetting::MaxToolOutput { bytes } => {
4487 rt.set_max_tool_output(bytes);
4488 Ok(None)
4489 }
4490 SessionSetting::BashTimeout { secs } => {
4491 rt.set_bash_timeout(secs);
4492 Ok(None)
4493 }
4494 SessionSetting::BashMaxTimeout { secs } => {
4495 rt.set_bash_max_timeout(secs);
4496 Ok(None)
4497 }
4498 SessionSetting::SystemPrompt { text } => {
4499 rt.set_system_prompt(text);
4500 Ok(None)
4501 }
4502 SessionSetting::ReloadPrompt => rt
4503 .reload_prompt()
4504 .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
4505 .map_err(|e| e.to_string()),
4506 SessionSetting::GrantWorkerModel { model } => {
4507 rt.grant_worker_model(&model).map(|_| None)
4508 }
4509 };
4510 self.publish_view().await;
4511 let view = (**self.view.load()).clone();
4512 let (ok, message) = match result {
4513 Ok(m) => (true, m),
4514 Err(e) => (false, Some(e)),
4515 };
4516 self.emit(SessionEventWire::SettingChanged(SettingApplied {
4517 id,
4518 setting: name.to_string(),
4519 ok,
4520 message,
4521 view,
4522 clamp: clamp_wire,
4523 }));
4524 }
4525
4526 pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
4527 if self.is_parked() {
4529 let value = match query {
4530 SessionQuery::Status => serde_json::json!({
4531 "session": (**self.journal_id.load()).clone(),
4532 "model": self.view.load().model,
4533 "lifecycle": SessionLifecycle::Parked,
4534 "streaming": false,
4535 "auto_turns": self.consecutive_auto_turns,
4536 "attached": 0,
4537 "pending_prompts": 0,
4538 "pinned": self.keep_warm,
4539 }),
4540 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4541 SessionQuery::SubagentWatch { handle_id, .. } => {
4543 serde_json::json!({ "error": format!("no subagent {handle_id} in this session") })
4544 }
4545 _ => match self.ensure_live().await {
4546 Ok(()) => return Box::pin(self.query(id, query)).await,
4547 Err(e) => serde_json::json!({ "error": e }),
4548 },
4549 };
4550 self.emit(SessionEventWire::QueryResult { id, value });
4551 return;
4552 }
4553 let value = match query {
4554 SessionQuery::Status => serde_json::json!({
4555 "session": self.conv.session.id,
4556 "model": self.runtime.model(),
4557 "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
4558 "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
4559 "cost": self.conv.session_cost,
4560 "keepwarm_cost": self.conv.keepwarm_cost,
4562 "keepwarm": self.cache_keepwarm_status_json(None),
4563 "messages": self.conv.api_messages.len(),
4564 "streaming": self.streaming,
4565 "auto_turns": self.consecutive_auto_turns,
4566 "attached": self.attached.len(),
4567 "pending_prompts": self.pending_prompts.len(),
4568 "pinned": self.keep_warm,
4570 }),
4571 SessionQuery::Messages => {
4572 serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
4573 }
4574 SessionQuery::DisplayTail { items } => {
4575 serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
4576 .unwrap_or_default()
4577 }
4578 SessionQuery::SubagentRows => {
4579 let rows = self
4580 .runtime
4581 .subagent_registry()
4582 .lock()
4583 .unwrap_or_else(|p| p.into_inner())
4584 .display_rows();
4585 serde_json::Value::Array(
4586 rows.iter()
4587 .map(|r| {
4588 serde_json::json!({
4589 "subagent_id": r.subagent_id,
4590 "agent_name": r.agent_name,
4591 "status": format!("{:?}", r.status),
4592 "cancel_requested": r.cancel_requested,
4593 "elapsed_secs": r.elapsed_secs,
4594 })
4595 })
4596 .collect(),
4597 )
4598 }
4599 SessionQuery::SubagentWatch { handle_id, since, tool_queue } => {
4600 let mut registry = self
4601 .runtime
4602 .subagent_registry()
4603 .lock()
4604 .unwrap_or_else(|p| p.into_inner());
4605 match registry.watch_page(&handle_id, since) {
4606 Some(page) if !tool_queue => {
4609 serde_json::to_value(page.without_tool_queue()).unwrap_or_default()
4610 }
4611 Some(page) => serde_json::to_value(page).unwrap_or_default(),
4612 None => serde_json::json!({
4613 "error": format!("no subagent {handle_id} in this session")
4614 }),
4615 }
4616 }
4617 SessionQuery::PromptInspection => {
4618 serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
4619 }
4620 SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
4621 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4622 SessionQuery::ContextAssessment => {
4623 let a = self.runtime.assess_context(&self.conv.api_messages).await;
4624 serde_json::json!({
4625 "used_tokens": a.used_tokens(),
4626 "budget_tokens": a.budget_tokens(),
4627 "provider_window": a.provider_window,
4628 "should_compact": a.should_compact(),
4629 })
4630 }
4631 SessionQuery::ContextReport => {
4632 use crate::engine::commands::{context_command, CommandResult};
4633 match context_command(&self.runtime, Some(&self.conv.api_messages)) {
4634 CommandResult::Output(text) => serde_json::json!({ "text": text }),
4635 other => serde_json::json!({ "unsupported": format!("{other:?}") }),
4636 }
4637 }
4638 };
4639 self.emit(SessionEventWire::QueryResult { id, value });
4640 }
4641
4642 pub(crate) fn snapshot(&self) -> AttachSnapshot {
4645 AttachSnapshot {
4646 meta: self.meta.clone(),
4647 view: (**self.view.load()).clone(),
4648 conversation: self.conv.snapshot(self.consecutive_auto_turns),
4649 streaming: self.streaming,
4650 replay: self.turn_replay.iter().cloned().collect(),
4651 pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
4652 clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
4653 roster: self.roster(),
4654 client_input: self.client_input,
4655 input_owner: self.input_owner,
4656 display_tail: None,
4657 }
4658 }
4659
4660 pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
4665 let mut snap = self.snapshot();
4666 if client.history == HistoryMode::Digest {
4667 snap.display_tail = Some(super::display::display_tail(
4668 &snap.conversation.api_messages,
4669 client.tail_items,
4670 ));
4671 snap.conversation.api_messages = Vec::new();
4672 snap.replay.retain(|e| {
4673 !matches!(
4674 e.event,
4675 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
4676 )
4677 });
4678 }
4679 snap
4680 }
4681
4682 async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
4687 if let Err(e) = self.ensure_live().await {
4688 self.emit(SessionEventWire::AttachRefused { message: e });
4689 return;
4690 }
4691 let cid = ClientId(self.next_client_id);
4692 self.next_client_id += 1;
4693 let kind = client.kind;
4694 self.cache_keepwarm_note_attach(kind, mode);
4695 let snapshot_meta = client.clone();
4696 let name = resolve_client_name(
4697 client.name.as_deref(),
4698 kind,
4699 self.attached.values().map(|a| a.name.as_str()),
4700 );
4701 let attached_at_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
4702 self.attached.insert(
4703 cid,
4704 AttachedClient { meta: client, mode, name: name.clone(), attached_at_ms, last_input: 0 },
4705 );
4706 self.update_attach_state();
4707 let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
4708 match mode {
4709 AttachMode::Observe => {}
4710 AttachMode::Mirror => {
4714 if self.input_owner.is_none() {
4715 owner_change = Some((None, OwnerChangeReason::Attach));
4716 }
4717 }
4718 AttachMode::Takeover => {
4719 let reason = if self.input_owner.is_some() {
4720 OwnerChangeReason::Takeover
4721 } else {
4722 OwnerChangeReason::Attach
4723 };
4724 owner_change = Some((self.input_owner, reason));
4725 }
4726 }
4727 if let Some((from, reason)) = owner_change {
4728 self.input_owner = Some(cid);
4729 self.emit(SessionEventWire::InputOwnerChanged {
4730 from,
4731 to: Some(cid),
4732 reason,
4733 });
4734 }
4735 self.publish_presence();
4736 let snapshot = self.snapshot_for(&snapshot_meta);
4737 self.emit(SessionEventWire::Attached {
4738 client: cid,
4739 snapshot,
4740 });
4741 self.emit(SessionEventWire::ClientJoined { client: cid, kind, name, mode });
4742 }
4743
4744 fn take_input(&mut self, from: Option<ClientId>) {
4750 let Some(c) = from else { return };
4751 let Some(mode) = self.attached.get(&c).map(|a| a.mode) else { return };
4752 if mode == AttachMode::Observe {
4753 self.emit(SessionEventWire::Refused {
4754 client: c,
4755 command: command_name(&SessionCommand::TakeInput).to_string(),
4756 reason: "this client attached with --observe and never owns input; \
4757 attach without --observe to take it"
4758 .to_string(),
4759 });
4760 return;
4761 }
4762 if self.input_owner == Some(c) {
4763 return;
4764 }
4765 let from = self.input_owner;
4766 let reason = if from.is_some() {
4767 OwnerChangeReason::Takeover
4768 } else {
4769 OwnerChangeReason::Attach
4770 };
4771 self.input_owner = Some(c);
4772 self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason });
4773 self.publish_presence();
4774 self.note_input(c); }
4776
4777 fn detach(&mut self, client: ClientId) {
4781 if self.attached.remove(&client).is_none() {
4782 return;
4783 }
4784 self.update_attach_state();
4785 self.emit(SessionEventWire::ClientLeft { client });
4786 if self.input_owner == Some(client) {
4787 let shared = self.client_input == ClientInput::Shared;
4791 let next = self
4792 .attached
4793 .iter()
4794 .filter(|(_, a)| a.mode != AttachMode::Observe)
4795 .max_by_key(|(c, a)| (if shared { a.last_input } else { 0 }, std::cmp::Reverse(c.0)))
4796 .map(|(c, _)| *c);
4797 self.input_owner = next;
4798 self.emit(SessionEventWire::InputOwnerChanged {
4799 from: Some(client),
4800 to: next,
4801 reason: OwnerChangeReason::OwnerDetached,
4802 });
4803 self.publish_presence();
4804 }
4805 if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
4822 self.driver_revoke("no clients attached");
4823 let had_prompts = !self.pending_prompts.is_empty();
4824 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4825 let _ = tx.send(None);
4826 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4827 }
4828 if had_prompts {
4829 self.publish_presence();
4830 }
4831 }
4832 }
4833
4834 pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
4839 let interrupt = match reason {
4840 CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
4841 CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
4842 };
4843 self.note_cancel_cause(interrupt);
4845 if self.driver.is_some() {
4847 self.driver_revoke("daemon reloaded");
4848 }
4849 let reload = matches!(reason, CheckpointReason::Reload);
4855 if reload {
4856 self.stop_workers_for_reload();
4857 }
4858 if self.streaming {
4859 self.cancel_turn(interrupt).await;
4860 }
4861 let stopping = if reload { self.stop_workers_for_reload() } else { Vec::new() };
4864 let stopped_workers = await_stopped_workers(self.id.as_str(), stopping).await;
4865 self.abort_compaction();
4866 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4867 let _ = tx.send(None);
4868 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4869 }
4870 if !self.save().await {
4873 tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
4874 }
4875 let notice = match reason {
4876 CheckpointReason::Reload => {
4877 "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
4878 }
4879 CheckpointReason::HostRequest => {
4880 "checkpoint — background shells/PTYs closed; the turn was checkpointed"
4881 }
4882 };
4883 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
4884 if !stopped_workers.is_empty() {
4885 self.emit(SessionEventWire::SystemNotice(format!(
4886 "daemon reloading — stopped worker{} {} (archived; resumable after the reload)",
4887 if stopped_workers.len() == 1 { "" } else { "s" },
4888 stopped_workers.join(", ")
4889 )));
4890 }
4891 self.runtime.session_manager().shutdown_all();
4892 let mut record = self.reload_record();
4893 if reload {
4894 record.workers = self.reload_carried_workers();
4895 self.reload_fence = Some(ReloadFence {
4898 deadline: tokio::time::Instant::now() + budgets::RELOAD_FENCE_TIMEOUT,
4899 workers: record.workers.clone(),
4900 });
4901 }
4902 let record = serde_json::to_value(record).unwrap_or_default();
4903 self.emit(SessionEventWire::QueryResult {
4904 id: super::wire::CHECKPOINT_QUERY_ID,
4905 value: serde_json::json!({ "ok": true, "record": record }),
4906 });
4907 }
4908
4909 pub(crate) fn reload_carried_workers(&self) -> Vec<crate::runtime::subagent::CarriedWorker> {
4913 let undelivered: std::collections::HashSet<String> = self
4914 .event_queue
4915 .snapshot()
4916 .iter()
4917 .filter(|e| e.content.content_type == "subagent_completion")
4918 .filter_map(|e| {
4919 e.content
4920 .data
4921 .as_ref()?
4922 .get("handle_id")?
4923 .as_str()
4924 .map(str::to_string)
4925 })
4926 .collect();
4927 self.runtime
4928 .subagent_registry()
4929 .lock()
4930 .unwrap_or_else(|p| p.into_inner())
4931 .reload_carry(&undelivered)
4932 }
4933
4934 pub(crate) fn restore_tool_activations(
4940 &mut self,
4941 activations: Vec<crate::tools::activation::CarriedActivation>,
4942 ) {
4943 if activations.is_empty() {
4944 return;
4945 }
4946 tracing::info!(
4947 session = %self.id,
4948 count = activations.len(),
4949 "reload: tool activations handed over from the previous image"
4950 );
4951 if self.runtime.is_live() {
4952 self.runtime.restore_tool_activations(activations);
4953 } else {
4954 for a in activations {
4955 if !self.parked_tool_activations.iter().any(|p| p.tool_id == a.tool_id) {
4956 self.parked_tool_activations.push(a);
4957 }
4958 }
4959 }
4960 }
4961
4962 pub(crate) fn adopt_workers(&mut self, workers: Vec<crate::runtime::subagent::CarriedWorker>) {
4968 self.adopt_carried_workers(workers, false);
4969 }
4970
4971 fn adopt_carried_workers(
4974 &mut self,
4975 workers: Vec<crate::runtime::subagent::CarriedWorker>,
4976 failed_reload: bool,
4977 ) {
4978 if workers.is_empty() {
4979 return;
4980 }
4981 {
4982 let mut registry = self
4983 .runtime
4984 .subagent_registry()
4985 .lock()
4986 .unwrap_or_else(|p| p.into_inner());
4987 for w in &workers {
4988 registry.adopt_carried(w);
4989 if w.needs_wake() {
4990 registry.note_reload_wake(w);
4993 }
4994 }
4995 }
4996 let woken: Vec<&crate::runtime::subagent::CarriedWorker> =
4997 workers.iter().filter(|w| w.needs_wake()).collect();
4998 for w in &woken {
4999 let ev = crate::tools::build_reload_carry_event(w);
5000 if self.event_queue.push(ev.clone()).is_err() {
5001 self.event_queue.push_priority(ev);
5002 }
5003 }
5004 let stopped: Vec<&str> = woken
5005 .iter()
5006 .filter(|w| w.stopped_by_reload)
5007 .map(|w| w.id.as_str())
5008 .collect();
5009 tracing::info!(
5010 session = %self.id,
5011 carried = workers.len(),
5012 stopped = ?stopped,
5013 woken = woken.len(),
5014 "reload: adopted workers from the previous image"
5015 );
5016 if !stopped.is_empty() {
5017 self.emit(SessionEventWire::SystemNotice(format!(
5018 "{} — worker{} {} stopped by the reload; subagent_resume continues {} from {} archive",
5019 if failed_reload { "daemon reload failed" } else { "daemon reloaded" },
5020 if stopped.len() == 1 { "" } else { "s" },
5021 stopped.join(", "),
5022 if stopped.len() == 1 { "it" } else { "them" },
5023 if stopped.len() == 1 { "its" } else { "their" },
5024 )));
5025 }
5026 }
5027
5028 fn stop_workers_for_reload(
5030 &self,
5031 ) -> Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)> {
5032 self.runtime
5033 .subagent_registry()
5034 .lock()
5035 .unwrap_or_else(|p| p.into_inner())
5036 .stop_for_reload()
5037 }
5038
5039 pub(crate) async fn reload_aborted(&mut self) {
5044 self.lift_reload_fence(
5045 "daemon reload failed — this session continues on the running daemon",
5046 )
5047 .await;
5048 }
5049
5050 async fn lift_reload_fence(&mut self, notice: &str) {
5053 let Some(fence) = self.reload_fence.take() else {
5054 return;
5055 };
5056 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
5057 let workers = if self.runtime.is_live() {
5058 let mut carried = self.reload_carried_workers();
5063 carried.extend(
5064 fence
5065 .workers
5066 .into_iter()
5067 .filter(|w| !carried.iter().any(|c| c.id == w.id))
5068 .collect::<Vec<_>>(),
5069 );
5070 let mut registry = self
5071 .runtime
5072 .subagent_registry()
5073 .lock()
5074 .unwrap_or_else(|p| p.into_inner());
5075 registry.lift_reload_fence();
5076 registry.rewake_after_failed_exec(carried)
5079 } else {
5080 Vec::new()
5081 };
5082 tracing::info!(session = %self.id, "reload did not exec: fence lifted");
5083 self.adopt_carried_workers(workers, true);
5084 self.rearm_park();
5085 if !self.event_queue.is_empty() {
5086 self.on_queue_wake().await;
5087 }
5088 }
5089
5090 async fn reload_fence_expired(&mut self) {
5093 tracing::warn!(
5094 session = %self.id,
5095 "reload fence expired with no exec and no abort from the daemon; lifting it"
5096 );
5097 self.lift_reload_fence(
5098 "daemon reload never completed or reported back — this session continues on the running daemon",
5099 )
5100 .await;
5101 }
5102
5103 pub(crate) fn abort_compaction(&mut self) {
5105 if let Some(job) = self.compact.take() {
5106 job.task.abort();
5107 self.emit(SessionEventWire::CompactionCancelled);
5108 self.publish_presence();
5109 }
5110 }
5111
5112 async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
5117 use std::ops::ControlFlow;
5118 let Addressed { from, cmd } = addressed;
5119 if let Some(c) = from {
5120 if matches!(cmd, SessionCommand::Checkpoint { reason: CheckpointReason::Reload }) {
5123 self.emit(SessionEventWire::Refused {
5124 client: c,
5125 command: command_name(&cmd).to_string(),
5126 reason: "checkpoint(reload) is host-only; use `synaps daemon reload`".into(),
5127 });
5128 return ControlFlow::Continue(());
5129 }
5130 if let Some(reason) = is_input_command(&cmd).then(|| self.input_refusal(c)).flatten() {
5131 self.emit(SessionEventWire::Refused {
5132 client: c,
5133 command: command_name(&cmd).to_string(),
5134 reason,
5135 });
5136 return ControlFlow::Continue(());
5137 }
5138 }
5139 if self.reload_fence.is_some() && cmd.refused_while_reloading() {
5144 let reason = super::RELOADING_REFUSAL.to_string();
5145 match from {
5146 Some(c) => self.emit(SessionEventWire::Refused {
5147 client: c,
5148 command: command_name(&cmd).to_string(),
5149 reason,
5150 }),
5151 None => self.emit(SessionEventWire::SystemNotice(reason)),
5152 }
5153 return ControlFlow::Continue(());
5154 }
5155 if let Some(c) = from {
5159 if is_input_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { .. }) {
5160 self.note_input(c);
5161 }
5162 }
5163 if self.is_parked() {
5165 let needs_runtime = !matches!(
5166 cmd,
5167 SessionCommand::Attach { .. }
5168 | SessionCommand::Detach { .. }
5169 | SessionCommand::End { .. }
5170 | SessionCommand::Query { .. }
5171 | SessionCommand::Answer { .. }
5172 | SessionCommand::Save
5173 | SessionCommand::Cancel
5174 | SessionCommand::Checkpoint { .. }
5175 | SessionCommand::Park
5176 | SessionCommand::ReloadAborted
5177 | SessionCommand::ArmPark
5178 | SessionCommand::RestoreToolActivations { .. }
5179 | SessionCommand::Resync { .. }
5180 | SessionCommand::HostEvent(_)
5181 | SessionCommand::TakeInput
5182 ) && !is_close_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { on: false });
5186 if matches!(cmd, SessionCommand::HostEvent(_)) {
5187 return ControlFlow::Continue(()); }
5189 if matches!(
5190 cmd,
5191 SessionCommand::Save
5192 | SessionCommand::Cancel
5193 | SessionCommand::Park
5194 | SessionCommand::ArmPark
5195 | SessionCommand::Checkpoint { .. }
5196 ) {
5197 if let SessionCommand::Checkpoint { .. } = cmd {
5198 let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
5199 self.emit(SessionEventWire::QueryResult {
5200 id: super::wire::CHECKPOINT_QUERY_ID,
5201 value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
5202 });
5203 }
5204 return ControlFlow::Continue(()); }
5206 if needs_runtime {
5207 if let Err(e) = self.ensure_live().await {
5208 match from {
5209 Some(c) => self.emit(SessionEventWire::Refused {
5210 client: c,
5211 command: command_name(&cmd).to_string(),
5212 reason: e,
5213 }),
5214 None => self.emit(SessionEventWire::SystemNotice(e)),
5215 }
5216 return ControlFlow::Continue(());
5217 }
5218 }
5219 }
5220 match cmd {
5221 SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
5222 SessionCommand::Steer { text } => {
5223 if self.streaming {
5224 self.steer(text)
5225 } else {
5226 self.submit(text, vec![], from).await
5227 }
5228 }
5229 SessionCommand::SubagentSteer { handle_id, text } => {
5230 self.steer_subagent(from, &handle_id, text)
5231 }
5232 SessionCommand::Cancel => {
5233 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
5236 if self.driver.is_some() {
5237 self.driver_revoke("canceled");
5238 }
5239 self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
5240 .await;
5241 }
5242 SessionCommand::Answer { prompt_id, value } => self.answer(from, prompt_id, value),
5243 SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
5244 SessionCommand::Compact { instructions } => {
5246 self.compact(instructions, "manual").await;
5247 }
5248 SessionCommand::NewSession => {
5249 if self.driver.is_some() {
5250 self.driver_revoke("session replaced");
5251 }
5252 if self.turn_awaiting_start.is_some() {
5257 self.cancel_turn(crate::engine::interrupt::InterruptReason::User).await;
5258 }
5259 self.cache_keepwarm_conversation_replaced();
5262 self.persister.flush().await;
5265 self.conv.clear(&self.runtime).await;
5266 self.runtime
5267 .begin_conversation(Some(self.conv.session.id.clone()));
5268 self.set_journal_id(self.conv.session.id.clone());
5269 self.reacquire_session_lock(&self.conv.session.id.clone());
5271 self.emit(SessionEventWire::Cleared {
5272 session_id: self.conv.session.id.clone(),
5273 });
5274 self.emit_conversation();
5275 }
5276 SessionCommand::Save => {
5277 self.save().await;
5278 }
5279 SessionCommand::Query { id, query } => self.query(id, query).await,
5280 SessionCommand::EngineCommand { id, name, arg } if name == CLOSE_COMMAND => {
5282 if let Some(reason) = self.close_command(id, &arg) {
5283 return ControlFlow::Break(reason);
5284 }
5285 }
5286 SessionCommand::EngineCommand { id, name, arg } => {
5287 self.engine_command(id, name, arg).await
5288 }
5289 SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
5290 SessionCommand::Detach { client } => {
5291 self.detach(client);
5292 if self.attached.is_empty() && self.cache_keepwarm.active() {
5296 self.cache_keepwarm_recheck().await;
5297 }
5298 }
5299 SessionCommand::End { reason } => {
5300 self.note_cancel_cause(match reason {
5303 EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5304 _ => crate::engine::interrupt::InterruptReason::Host,
5305 });
5306 if self.driver.is_some() {
5307 self.driver_revoke("session ending");
5308 }
5309 return ControlFlow::Break(reason);
5310 }
5311 SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
5312 "resync not supported yet".into(),
5313 )),
5314 SessionCommand::SubmitPrepared {
5317 messages,
5318 user_text,
5319 } => self.submit_prepared(messages, user_text).await,
5320 SessionCommand::PluginCommand {
5321 id,
5322 plugin,
5323 name,
5324 arg,
5325 } => self.plugin_command(id, plugin, name, arg).await,
5326 SessionCommand::Resume { id, query } => self.resume(id, query).await,
5327 SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
5328 SessionCommand::AdoptWorkers { workers } => self.adopt_workers(workers),
5329 SessionCommand::RestoreToolActivations { activations } => {
5330 self.restore_tool_activations(activations)
5331 }
5332 SessionCommand::ReloadAborted => self.reload_aborted().await,
5333 SessionCommand::ArmPark => self.rearm_park(),
5335 SessionCommand::Park => {
5336 self.cache_keepwarm_park_requested();
5338 if let std::ops::ControlFlow::Break(reason) = self.park().await {
5339 return ControlFlow::Break(reason);
5340 }
5341 }
5342 SessionCommand::TakeInput => self.take_input(from),
5343 SessionCommand::KeepWarm { on } => {
5344 self.keep_warm = on;
5345 self.meta.pinned = on; self.keep_warm_cell.store(on, std::sync::atomic::Ordering::Release);
5347 self.note_pin();
5348 if !on && !self.cache_keepwarm.interactive_seen && self.cache_keepwarm.active() {
5350 self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::Disabled);
5351 }
5352 self.rearm_park();
5353 self.emit(SessionEventWire::SystemNotice(if on {
5354 PIN_ON_NOTICE.to_string()
5355 } else {
5356 PIN_OFF_NOTICE.to_string()
5357 }));
5358 }
5359 SessionCommand::HostEvent(ev) => match ev {
5360 HostEvent::ExtensionNotification {
5361 extension_id,
5362 method,
5363 params,
5364 } => self.emit(SessionEventWire::ExtensionNotification {
5365 extension_id,
5366 method,
5367 params,
5368 }),
5369 HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
5370 },
5371 SessionCommand::DriverStart { plugin, command, arg } => {
5373 self.driver_start(plugin, command, arg);
5374 }
5375 }
5376 ControlFlow::Continue(())
5377 }
5378
5379 async fn finish(&mut self, reason: EndReason) {
5382 if self.keep_warm && reason == EndReason::ClientQuit {
5385 self.keep_warm = false;
5386 self.note_pin();
5387 }
5388 self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::SessionEnding);
5389 self.session_start.end();
5393 self.lifecycle.store(
5394 SessionLifecycle::Ending as u8,
5395 std::sync::atomic::Ordering::Release,
5396 );
5397 let interrupt = match reason {
5400 EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5401 _ => crate::engine::interrupt::InterruptReason::Host,
5402 };
5403 self.note_cancel_cause(interrupt);
5405 if self.driver.is_some() {
5407 self.driver_revoke("session ending");
5408 }
5409 if self.streaming {
5410 self.cancel_turn(interrupt).await;
5411 }
5412 self.abort_compaction();
5413 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
5415 let _ = tx.send(None);
5416 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
5417 }
5418
5419 let parked = !self.conv.is_live();
5420 let session_id = (**self.journal_id.load()).clone();
5421 let api_messages = if parked {
5422 None
5423 } else {
5424 Some(self.conv.api_messages.clone())
5425 };
5426
5427 let persist = self.config.persist;
5431 if !parked {
5432 self.request_save();
5433 }
5434 let persister = &self.persister;
5435 let save_fut = async {
5436 if persist {
5437 if !persister.flush().await {
5438 tracing::warn!(session = %session_id, "session end: save failed");
5439 }
5440 let mut index_record =
5441 crate::core::session_index::SessionIndexRecord::end(&session_id);
5442 index_record.turns = api_messages.as_ref().map(|m| m.len());
5443 if let Err(err) = crate::core::session_index::append_record(&index_record) {
5444 tracing::warn!("failed to append session end index record: {}", err);
5445 }
5446 }
5447 };
5448 if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
5449 .await
5450 .is_err()
5451 {
5452 tracing::warn!(
5453 budget_secs = budgets::SAVE_TIMEOUT_SECS,
5454 "session save timed out — data may be incomplete"
5455 );
5456 }
5457
5458 let hook_bus = Arc::clone(&self.hook_bus);
5461 crate::extensions::loader::emit_session_end(
5462 &hook_bus,
5463 &session_id,
5464 api_messages,
5465 budgets::HOOKS_TIMEOUT,
5466 )
5467 .await;
5468
5469 if self.runtime.is_live() {
5471 if let Some(outcome) = self
5472 .runtime
5473 .shutdown_observability_async(
5474 crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
5475 )
5476 .await
5477 {
5478 if !outcome.is_flushed() {
5479 tracing::warn!(
5480 stats = ?outcome.stats(),
5481 "observability flush timed out — detached worker keeps draining"
5482 );
5483 }
5484 }
5485 }
5486
5487 self.background.shutdown();
5489 self.emit(SessionEventWire::Ended { reason });
5490 }
5491
5492 pub fn id(&self) -> &SessionId {
5493 &self.id
5494 }
5495}
5496
5497pub struct SessionTask(SessionActor);
5499
5500async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
5501 match stream {
5502 Some(s) => s.next().await,
5503 None => std::future::pending().await,
5504 }
5505}
5506
5507fn conversation_title(conv: &ConversationState) -> Option<String> {
5510 if !conv.session.title.is_empty() {
5511 return Some(conv.session.title.clone());
5512 }
5513 conv.api_messages
5514 .iter()
5515 .filter(|m| m["role"].as_str() == Some("user"))
5516 .find_map(|m| agent_core::core::session::user_content_for_display(&m["content"]))
5517 .map(|t| t.chars().take(80).collect())
5518}
5519
5520fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
5524 streaming || any_running
5525}
5526
5527fn worker_cancel_scope(
5533 reason: crate::engine::interrupt::InterruptReason,
5534 ending: bool,
5535 turn_generation: u64,
5536) -> crate::runtime::subagent::WorkerCancelScope {
5537 use crate::engine::interrupt::InterruptReason;
5538 use crate::runtime::subagent::WorkerCancelScope;
5539 match reason {
5540 InterruptReason::User if !ending => WorkerCancelScope::Turn(turn_generation),
5541 _ => WorkerCancelScope::All,
5542 }
5543}
5544
5545async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
5546 match tick {
5547 Some(t) => {
5548 t.tick().await;
5549 }
5550 None => std::future::pending().await,
5551 }
5552}
5553
5554async fn poll_compaction(
5555 job: &mut Option<CompactionJob>,
5556) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
5557 match job {
5558 Some(j) => (&mut j.task).await,
5559 None => std::future::pending().await,
5560 }
5561}
5562
5563async fn park_timer(deadline: Option<tokio::time::Instant>) {
5564 match deadline {
5565 Some(t) => tokio::time::sleep_until(t).await,
5566 None => std::future::pending().await,
5567 }
5568}
5569
5570async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
5572 match deadline {
5573 Some(t) => tokio::time::sleep_until(t).await,
5574 None => std::future::pending().await,
5575 }
5576}
5577
5578async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
5581 match deadline {
5582 Some(t) => tokio::time::sleep_until(t).await,
5583 None => std::future::pending().await,
5584 }
5585}
5586
5587async fn reload_fence_timer(deadline: Option<tokio::time::Instant>) {
5589 match deadline {
5590 Some(t) => tokio::time::sleep_until(t).await,
5591 None => std::future::pending().await,
5592 }
5593}
5594
5595async fn driver_tick_timer(
5597 has_driver: bool,
5598 has_pending: bool,
5599 interval: &mut tokio::time::Interval,
5600) {
5601 if has_driver || has_pending {
5602 interval.tick().await;
5603 } else {
5604 std::future::pending().await
5605 }
5606}
5607
5608fn extensions_ready_timeout(config: &crate::SynapsConfig) -> Duration {
5612 match config.startup.extensions_ready_timeout_secs {
5613 0 => budgets::EXTENSIONS_READY_TIMEOUT,
5614 secs => Duration::from_secs(secs),
5615 }
5616}
5617
5618async fn session_start_task(
5620 start: &mut super::session_start::SessionStart,
5621) -> std::result::Result<bool, tokio::task::JoinError> {
5622 match start {
5623 super::session_start::SessionStart::Queued { task, .. } => task.await,
5624 _ => std::future::pending().await,
5625 }
5626}
5627
5628impl SessionTask {
5629 pub fn id(&self) -> &SessionId {
5630 self.0.id()
5631 }
5632
5633 #[cfg(any(test, feature = "testing"))]
5636 pub fn subagent_registry(
5637 &self,
5638 ) -> Arc<std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>> {
5639 Arc::clone(self.0.runtime.subagent_registry())
5640 }
5641
5642 #[cfg(any(test, feature = "testing"))]
5646 pub fn runtime_for_harness(&self) -> crate::Runtime {
5647 (*self.0.runtime).clone()
5648 }
5649
5650 #[cfg(any(test, feature = "testing"))]
5653 pub fn event_queue(&self) -> Arc<crate::events::EventQueue> {
5654 Arc::clone(&self.0.event_queue)
5655 }
5656
5657 pub async fn run(mut self) {
5659 let queue = Arc::clone(&self.0.event_queue);
5660 let reason = loop {
5661 let actor = &mut self.0;
5662 tokio::select! {
5663 cmd = actor.cmd_rx.recv() => match cmd {
5664 Some(cmd) => {
5665 if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
5666 break reason;
5667 }
5668 }
5669 None => break EndReason::HostShutdown,
5671 },
5672 Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
5673 _ = queue.notified(), if !actor.holds(WorkSource::QueueWake) => actor.on_queue_wake().await,
5675 _ = next_tick(&mut actor.subagent_tick) => {
5676 actor.publish_subagent_rows();
5677 actor.flush_turn_draft();
5678 }
5679 _ = park_timer(actor.park_deadline), if !actor.holds(WorkSource::ParkTimer) => {
5680 if let std::ops::ControlFlow::Break(reason) = actor.park().await {
5681 break reason;
5682 }
5683 }
5684 _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
5685 actor.on_prompt_abandon_deadline();
5686 }
5687 _ = super::cache_keepwarm::keepwarm_timer(actor.cache_keepwarm.deadline()), if !actor.holds(WorkSource::KeepWarmTimer) => {
5688 actor.on_cache_keepwarm_due().await;
5689 }
5690 res = super::cache_keepwarm::poll_keepwarm_ping(&mut actor.cache_keepwarm.task) => {
5691 actor.on_cache_keepwarm_done(res);
5692 }
5693 res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
5694 ran = session_start_task(&mut actor.session_start) => {
5695 actor.session_start_finished(ran).await;
5696 },
5697 _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval), if !actor.holds(WorkSource::DriverTick) => {
5698 actor.driver_tick().await;
5699 }
5700 _ = reload_fence_timer(actor.reload_fence.as_ref().map(|f| f.deadline)) => {
5701 actor.reload_fence_expired().await;
5702 }
5703 _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
5704 actor.finish_revoked_turn(false).await;
5705 }
5706 ev = next_stream_event(&mut actor.stream) => match ev {
5707 Some(ev) => actor.on_stream_event(ev).await,
5708 None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
5710 None => {
5711 if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
5714 let terminal = super::driver::capture_terminal(None, false);
5715 if let Some(terminal) = terminal {
5716 if let Some(driver) = actor.driver.as_mut() {
5717 let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
5718 let sel = driver.selection.clone();
5724 let fb = driver.grant.feedback_enabled()
5725 .then(|| driver.completed_feedback.to_string());
5726 actor.emit(SessionEventWire::DriverTurnOutcome {
5727 outcome: crate::extensions::session_driver::Outcome::Blocked,
5728 selection: sel,
5729 feedback: fb,
5730 });
5731 if let Some(reason) = reason {
5732 actor.driver_revoke(&reason);
5733 }
5734 }
5735 }
5736 }
5737 actor.clear_stream();
5738 actor.emit_conversation();
5739 actor.announce_idle().await;
5740 }
5741 },
5742 }
5743 };
5744 self.0.finish(reason).await;
5745 }
5746}
5747
5748async fn await_stopped_workers(
5755 session: &str,
5756 stopping: Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)>,
5757) -> Vec<String> {
5758 if stopping.is_empty() {
5759 return Vec::new();
5760 }
5761 let ids: Vec<String> = stopping.iter().map(|(id, _)| id.clone()).collect();
5762 tracing::info!(session, workers = ?ids, "reload: stopping running workers");
5763 let deadline = tokio::time::Instant::now() + budgets::WORKER_RELOAD_STOP_TIMEOUT;
5764 loop {
5765 let pending: Vec<&str> = stopping
5766 .iter()
5767 .filter(|(_, s)| !s.read().unwrap_or_else(|p| p.into_inner()).completion_settled)
5768 .map(|(id, _)| id.as_str())
5769 .collect();
5770 if pending.is_empty() {
5771 break;
5772 }
5773 if tokio::time::Instant::now() >= deadline {
5774 tracing::warn!(
5775 session,
5776 workers = ?pending,
5777 "reload: workers did not stop in time; only their last archive survives the exec"
5778 );
5779 break;
5780 }
5781 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
5782 }
5783 stopping
5784 .iter()
5785 .filter(|(_, s)| s.read().unwrap_or_else(|p| p.into_inner()).stopped_by_reload)
5786 .map(|(id, _)| id.clone())
5787 .collect()
5788}
5789
5790#[cfg(test)]
5791mod worker_cancel_scope_tests {
5792 use super::worker_cancel_scope;
5793 use crate::engine::interrupt::InterruptReason;
5794 use crate::runtime::subagent::WorkerCancelScope;
5795
5796 #[test]
5797 fn only_a_live_user_interrupt_is_turn_scoped() {
5798 assert_eq!(
5799 worker_cancel_scope(InterruptReason::User, false, 7),
5800 WorkerCancelScope::Turn(7),
5801 "Esc stops the interrupted turn's workers only"
5802 );
5803 assert_eq!(
5804 worker_cancel_scope(InterruptReason::User, true, 7),
5805 WorkerCancelScope::All,
5806 "quitting the client mid-turn tears every worker down"
5807 );
5808 for reason in [
5809 InterruptReason::CostCap,
5810 InterruptReason::Restart,
5811 InterruptReason::Host,
5812 InterruptReason::Driver,
5813 ] {
5814 assert_eq!(
5815 worker_cancel_scope(reason, false, 7),
5816 WorkerCancelScope::All,
5817 "{reason:?} stops every running worker"
5818 );
5819 }
5820 }
5821}
5822
5823#[cfg(test)]
5824mod subagent_tick_tests {
5825 use super::keep_subagent_tick;
5826
5827 #[test]
5828 fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
5829 assert!(keep_subagent_tick(true, false), "during a turn, always");
5830 assert!(keep_subagent_tick(true, true));
5831 assert!(
5832 keep_subagent_tick(false, true),
5833 "after the turn, while a background worker runs"
5834 );
5835 assert!(
5836 !keep_subagent_tick(false, false),
5837 "stops once the turn is over and no worker runs"
5838 );
5839 }
5840}
5841
5842#[cfg(test)]
5843mod parked_evict_tests {
5844 use super::parked_evict_after_from;
5845 use super::prompt_abandon_timeout_from;
5846 use std::time::Duration;
5847
5848 #[test]
5849 fn parked_evict_defaults_to_one_hour_and_honours_never() {
5850 let d = 3600u64;
5852 assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
5853 assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
5854 for never in ["never", "0", "off"] {
5855 assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
5856 }
5857 assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5858 assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
5860 assert_eq!(parked_evict_after_from(None, 0), None);
5861 assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
5862 }
5863
5864 #[test]
5865 fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
5866 let d = 3600u64;
5868 assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
5869 assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
5870 for never in ["never", "0", "off"] {
5872 assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
5873 }
5874 assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5876 assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
5878 assert_eq!(prompt_abandon_timeout_from(None, 0), None);
5879 assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
5880 }
5881}