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
477pub(crate) const RENAME_COMMAND: &str = "rename";
481
482fn is_rename_command(cmd: &SessionCommand) -> bool {
483 matches!(cmd, SessionCommand::EngineCommand { name, .. } if name == RENAME_COMMAND)
484}
485
486fn command_name(cmd: &SessionCommand) -> &'static str {
487 match cmd {
488 SessionCommand::Submit { .. } => "submit",
489 SessionCommand::SubmitPrepared { .. } => "submit_prepared",
490 SessionCommand::Steer { .. } => "steer",
491 SessionCommand::SubagentSteer { .. } => "subagent_steer",
492 SessionCommand::Cancel => "cancel",
493 SessionCommand::Answer { .. } => "answer",
494 SessionCommand::Set { .. } => "set",
495 SessionCommand::Compact { .. } => "compact",
496 SessionCommand::NewSession => "new_session",
497 SessionCommand::Save => "save",
498 SessionCommand::Query { .. } => "query",
499 SessionCommand::Attach { .. } => "attach",
500 SessionCommand::Detach { .. } => "detach",
501 SessionCommand::End { .. } => "end",
502 SessionCommand::Resync { .. } => "resync",
503 SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND => "close",
504 SessionCommand::EngineCommand { name, .. } if name == RENAME_COMMAND => "rename",
505 SessionCommand::EngineCommand { .. } => "engine_command",
506 SessionCommand::PluginCommand { .. } => "plugin_command",
507 SessionCommand::Resume { .. } => "resume",
508 SessionCommand::Checkpoint { .. } => "checkpoint",
509 SessionCommand::KeepWarm { .. } => "keep_warm",
510 SessionCommand::TakeInput => "take_input",
511 SessionCommand::Park => "park",
512 SessionCommand::AdoptWorkers { .. } => "adopt_workers",
513 SessionCommand::RestoreToolActivations { .. } => "restore_tool_activations",
514 SessionCommand::ReloadAborted => "reload_aborted",
515 SessionCommand::ArmPark => "arm_park",
516 SessionCommand::HostEvent(_) => "host_event",
517 SessionCommand::DriverStart { .. } => "driver_start",
518 }
519}
520
521pub struct SessionActor {
522 pub(crate) id: SessionId,
523 pub(crate) meta: SessionMeta,
524 pub(crate) config: SessionConfig,
525 pub(crate) runtime: Live<Runtime>,
527 pub(crate) conv: Live<ConversationState>,
529 pub(crate) host: Arc<EngineHost>,
531 pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
533 pub(crate) event_queue: Arc<crate::events::EventQueue>,
536 pub(crate) settings_replay: Vec<SessionSetting>,
538 pub(crate) keep_warm: bool,
539 pub(crate) cache_keepwarm: super::cache_keepwarm::KeepWarmState,
542 pub(crate) park_deadline: Option<tokio::time::Instant>,
544 pub(crate) park_recheck_at: Option<tokio::time::Instant>,
548 pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
553 pub(crate) stream: Option<ActiveStream>,
555 pub(crate) cancel: Option<CancellationToken>,
556 pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
557 pub(crate) owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
562 pub(crate) streaming: bool,
563 pub(crate) turn_baseline: usize,
564 pub(crate) message_stamper: crate::runtime::message_time::Stamper,
567 pub(crate) consecutive_auto_turns: u32,
568 pub(crate) turn_steered_events: Vec<String>,
573 pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
576 pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
581 pub(crate) turn_draft: TurnDraftState,
583 pub(crate) persister: super::persister::Persister,
586 pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
589 pub(crate) secret_prompt_handle: SecretPromptHandle,
591 pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
592 pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
594 pub(crate) next_prompt_id: u64,
595 pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
597 pub(crate) events: broadcast::Sender<Envelope>,
598 pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
599 pub(crate) attached: HashMap<ClientId, AttachedClient>,
600 pub(crate) input_owner: Option<ClientId>,
604 pub(crate) client_input: ClientInput,
606 pub(crate) input_seq: u64,
608 pub(crate) next_client_id: u64,
609 pub(crate) seq: u64,
610 pub(crate) turn_replay: VecDeque<Envelope>,
611 pub(crate) web_search_notice: Option<String>,
614 pub(crate) state: AttachState,
615 pub(crate) background: BackgroundTasks,
616 pub(crate) compact: Option<CompactionJob>,
618 pub(crate) compacting_inline: bool,
621 pub(crate) session_start: super::session_start::SessionStart,
628 pub(crate) turn_awaiting_start: Option<TurnStart>,
633 pub(crate) subagent_tick: Option<tokio::time::Interval>,
635 pub(crate) turn_worker_generation: u64,
638 pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
640 pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
642 pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
644 pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
646 pub(crate) keep_warm_cell: Arc<std::sync::atomic::AtomicBool>,
649 pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
651 pub(crate) driver: Option<super::driver::DriverState>,
653 pub(crate) driver_pending: Option<super::driver::DriverPending>,
654 pub(crate) driver_generation: u64,
656 pub(crate) driver_interrupted_owner: Option<String>,
658 pub(crate) driver_tick_interval: tokio::time::Interval,
660 pub(crate) reload_fence: Option<ReloadFence>,
663 pub(crate) memory_mode_slot: super::handle::MemoryModeSlot,
666 pub(crate) memory_seen_active: bool,
670 pub(crate) parked_tool_activations: Vec<crate::tools::activation::CarriedActivation>,
674}
675
676pub(crate) struct ReloadFence {
682 pub(crate) deadline: tokio::time::Instant,
683 pub(crate) workers: Vec<crate::runtime::subagent::CarriedWorker>,
686}
687
688fn release_fresh_lock(lock: Option<agent_core::session_lock::SessionLock>, continued: bool) {
692 if let (Some(lock), false) = (lock, continued) {
693 lock.release_and_remove();
694 }
695}
696
697impl SessionActor {
698 pub(crate) async fn create(
703 host: &Arc<EngineHost>,
704 mut cfg: SessionConfig,
705 ) -> Result<(SessionHandle, SessionTask)> {
706 let mut runtime = host
711 .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
712 .await?;
713 if let Some(mode) = cfg.memory_mode {
714 runtime.restore_session_memory_mode(mode);
715 }
716 let config: crate::SynapsConfig = (**host.config()).clone();
717 let client_input = cfg.client_input.unwrap_or(config.client_input);
718
719 let mut sb = crate::engine::setup::resolve_session_and_prompt(
720 &mut runtime,
721 &cfg.continue_session,
722 cfg.system.as_deref(),
723 cfg.prompt_manifest.as_deref(),
724 )?;
725 let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
733 let session_lock = if sb.continued {
734 let dir = agent_core::session_lock::sessions_dir();
737 let holder = agent_core::session_lock::LockHolder {
738 pid: std::process::id(),
739 kind: lock_kind.to_string(),
740 };
741 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
742 Ok(lock) => Some(lock),
743 Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
744 let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
745 return Err(crate::RuntimeError::Session(msg.to_string()));
746 }
747 Err(e) => {
748 tracing::warn!(session = %sb.session.id, "session lock: {e}");
749 None
750 }
751 }
752 } else {
753 let dir = agent_core::session_lock::sessions_dir();
755 let holder = agent_core::session_lock::LockHolder {
756 pid: std::process::id(),
757 kind: lock_kind.to_string(),
758 };
759 match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
760 Ok(lock) => Some(lock),
761 Err(e) => {
762 tracing::warn!(session = %sb.session.id, "session lock: {e}");
763 None
764 }
765 }
766 };
767
768 if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
772 if let Err(e) = sb.session.set_name(name) {
773 release_fresh_lock(session_lock, sb.continued);
774 return Err(crate::RuntimeError::Session(format!("--name {name:?}: {e}")));
775 }
776 if cfg.persist {
777 if let Err(e) = sb.session.save().await {
780 tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
781 }
782 }
783 }
784 runtime.set_cwd(cfg.cwd.clone());
785 if cfg.env.is_none() {
789 if let Some(ref journal_env) = sb.session.env {
790 cfg.env = Some(journal_env.clone());
791 }
792 if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
793 cfg.env_stripped = sb.session.env_stripped.clone();
794 }
795 }
796 if cfg.env.is_some() {
800 sb.session.env = cfg.env.clone();
801 sb.session.env_stripped = cfg.env_stripped.clone();
802 }
803 runtime.set_env(cfg.env.clone());
804 runtime.set_env_stripped(cfg.env_stripped.clone());
805 if let Some(ref m) = cfg.model_override {
807 runtime.set_model(m.clone());
808 }
809 if let Some(clock) = &cfg.message_clock {
810 runtime.set_message_clock(clock.clone());
811 }
812
813 let background = match crate::engine::setup::spawn_session_background_gated(
817 &runtime,
818 &sb.session,
819 Arc::new(std::sync::atomic::AtomicBool::new(false)),
820 ) {
821 Ok(background) => background,
822 Err(e) => {
823 release_fresh_lock(session_lock, sb.continued);
824 return Err(e);
825 }
826 };
827 crate::engine::setup::finish_session_setup(
828 &mut runtime,
829 &config,
830 &sb.session,
831 cfg.cwd.clone(),
832 crate::engine::setup::IndexRecord::Start,
833 );
834
835 let ext_ready_timeout = extensions_ready_timeout(&config);
845 if cfg.await_extensions {
846 if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
847 .await
848 .is_err()
849 {
850 tracing::warn!(
851 budget_secs = ext_ready_timeout.as_secs(),
852 "extensions_ready timed out — on_session_start may miss late extensions"
853 );
854 }
855 super::session_start::run_at_create(host, runtime.hook_bus(), &sb.session.id).await;
856 }
857
858 let id = SessionId::from(sb.session.id.clone());
859
860 let meta = SessionMeta {
861 id: id.clone(),
862 name: sb.session.name.clone(),
863 model: runtime.model().to_string(),
864 cwd: cfg.cwd.clone(),
865 created_at: sb.session.created_at,
866 continued: sb.continued,
867 continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
868 host_pid: std::process::id(),
869 lifecycle: SessionLifecycle::Live,
870 clients: 0,
871 input_owner: None,
872 awaiting_input: 0,
873 roster: Vec::new(),
874 client_input: Some(client_input),
875 journal_id: sb.session.id.clone(),
876 locked_by: None,
877 pinned: cfg.keep_warm,
878 busy: false,
879 state_since: None,
880 park_due: None,
881 evict_due: None,
882 title: None,
883 cost_usd: 0.0,
884 messages: 0,
885 };
886 let mut conv = if sb.continued {
887 ConversationState::from_resumed(sb.session)
888 } else {
889 ConversationState::new(sb.session)
890 };
891 conv.api_messages = sb.api_messages;
892 conv.total_input_tokens = sb.total_input_tokens;
893 conv.total_output_tokens = sb.total_output_tokens;
894 conv.session_cost = sb.session_cost;
895 if sb.continued && cfg.persist && session_lock.is_some() {
898 crate::engine::setup::recover_turn_draft(&mut conv).await;
899 }
900 let message_stamper = crate::runtime::message_time::Stamper::new(conv.api_messages.len());
902
903 let view = RuntimeView::from_runtime(&runtime).await;
904 let hook_bus = Arc::clone(runtime.hook_bus());
905 let event_queue = Arc::clone(runtime.event_queue());
906 let keep_warm = cfg.keep_warm;
907 let (
908 handle,
909 SessionEndpoints {
910 cmd_rx,
911 events,
912 view,
913 lifecycle,
914 journal_id,
915 presence,
916 name,
917 keep_warm: keep_warm_cell,
918 cache_keepwarm,
919 memory_mode,
920 },
921 ) = SessionHandle::new(meta.clone(), view);
922 presence.store(Arc::new(super::handle::Presence {
928 client_input: Some(client_input),
929 ..Default::default()
930 }));
931 *memory_mode.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = cfg.memory_mode;
932 let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
933 let session_start = if cfg.await_extensions {
934 super::session_start::SessionStart::Done
935 } else if cfg.restore_parked {
936 super::session_start::SessionStart::Pending
938 } else {
939 super::session_start::SessionStart::queue(
940 host,
941 Arc::clone(&hook_bus),
942 Arc::clone(&journal_id),
943 ext_ready_timeout,
944 )
945 };
946
947 let mut actor = SessionActor {
948 id,
949 meta,
950 config: cfg,
951 runtime: Live::new(runtime),
952 conv: Live::new(conv),
953 host: Arc::clone(host),
954 hook_bus,
955 event_queue,
956 settings_replay: Vec::new(),
957 keep_warm,
958 cache_keepwarm: super::cache_keepwarm::KeepWarmState::new(
959 config.cache_keepwarm,
960 config.cache_keepwarm_auto_max_hours,
961 config.cache_keepwarm_headless,
962 cache_keepwarm,
963 ),
964 park_deadline: None,
965 park_recheck_at: None,
966 prompt_abandon_deadline: None,
967 stream: None,
968 cancel: None,
969 steer_tx: None,
970 owner_steer_budget: None,
971 streaming: false,
972 turn_baseline: 0,
973 message_stamper,
974 consecutive_auto_turns: 0,
975 turn_steered_events: Vec::new(),
976 turn_completion: None,
977 revoked_turn_deadline: None,
978 turn_draft: TurnDraftState::default(),
979 persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
980 last_queued_save: None,
981 secret_prompt_handle: SecretPromptHandle::new(sp_tx),
982 secret_prompt_rx,
983 pending_prompts: VecDeque::new(),
984 next_prompt_id: 1,
985 cmd_rx,
986 events,
987 view,
988 attached: HashMap::new(),
989 input_owner: None,
990 client_input,
991 input_seq: 0,
992 next_client_id: 1,
993 seq: 0,
994 turn_replay: VecDeque::new(),
995 web_search_notice: None,
996 state: AttachState::Detached { running: false },
997 background,
998 compact: None,
999 compacting_inline: false,
1000 session_start,
1001 turn_awaiting_start: None,
1002 subagent_tick: None,
1003 turn_worker_generation: 0,
1004 lifecycle,
1005 journal_id,
1006 presence,
1007 name,
1008 keep_warm_cell,
1009 session_lock,
1010 driver: None,
1011 driver_pending: None,
1012 driver_generation: 0,
1013 driver_interrupted_owner: None,
1014 driver_tick_interval: {
1015 let mut interval = tokio::time::interval(Duration::from_millis(200));
1016 interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1017 interval
1018 },
1019 reload_fence: None,
1020 memory_mode_slot: memory_mode,
1021 memory_seen_active: false,
1022 parked_tool_activations: Vec::new(),
1023 };
1024 actor.observe_memory();
1026 if actor.keep_warm {
1027 actor.note_pin();
1028 }
1029 Ok((handle, SessionTask(actor)))
1030 }
1031
1032 pub(crate) fn note_pin(&self) {
1038 let Some(sink) = self.host.pin_sink() else { return };
1039 let journal = (**self.journal_id.load()).clone();
1040 if self.keep_warm {
1041 let kw = self.cache_keepwarm.override_mode.map(|m| m.to_string());
1042 sink.pinned(self.id.as_str(), &journal, self.conv.session.name.as_deref(), self.config.cwd.as_deref(), kw.as_deref());
1043 } else {
1044 sink.unpinned(self.id.as_str(), &journal);
1045 }
1046 }
1047
1048 pub(crate) fn set_journal_id(&mut self, new_id: String) {
1050 let old = self.journal_id.swap(Arc::new(new_id.clone()));
1051 if self.keep_warm && *old != new_id {
1052 if let Some(sink) = self.host.pin_sink() {
1053 sink.moved(&old, &new_id);
1054 }
1055 }
1056 }
1057
1058 pub(crate) fn sync_name(&mut self) {
1059 let name = self.conv.session.name.clone();
1060 self.mirror_name(name);
1061 }
1062
1063 pub(crate) fn mirror_name(&mut self, name: Option<String>) {
1067 self.meta.name = name.clone();
1068 self.name.store(Arc::new(name.clone()));
1069 if self.keep_warm {
1070 if let Some(sink) = self.host.pin_sink() {
1071 let journal = (**self.journal_id.load()).clone();
1072 sink.renamed(&journal, name.as_deref());
1073 }
1074 }
1075 if let Err(e) = self.background.update_session_name(name.as_deref()) {
1076 tracing::warn!(session = %self.id, "registry name update failed: {e}");
1077 }
1078 }
1079
1080 pub(crate) fn emit(&mut self, event: SessionEventWire) {
1093 let replay = self.streaming
1094 && !matches!(
1095 event,
1096 SessionEventWire::Prompt(_)
1097 | SessionEventWire::PromptResolved { .. }
1098 | SessionEventWire::Attached { .. }
1099 | SessionEventWire::QueryResult { .. }
1100 | SessionEventWire::Conversation(_)
1101 | SessionEventWire::Stream(StreamEvent::Session(
1102 SessionEvent::MessageHistory(_)
1103 ))
1104 );
1105 let env = Envelope {
1106 session_id: self.id.clone(),
1107 seq: self.seq,
1108 ts: chrono::Utc::now(),
1109 event,
1110 };
1111 self.seq += 1;
1112 if replay {
1113 if self.turn_replay.len() >= TURN_REPLAY_CAP {
1114 self.turn_replay.pop_front();
1115 }
1116 let mut ring = env.clone();
1117 if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
1118 *user_text = None;
1119 }
1120 self.turn_replay.push_back(ring);
1121 }
1122 let _ = self.events.send(env);
1124 }
1125
1126 fn trim_replay_to_checkpoint(&mut self) {
1134 self.turn_replay.retain(|env| {
1135 !matches!(
1136 env.event,
1137 SessionEventWire::Stream(StreamEvent::Llm(_))
1138 | SessionEventWire::Stream(StreamEvent::Agent(
1139 AgentEvent::SteeringDelivered { .. }
1140 ))
1141 )
1142 });
1143 }
1144
1145 pub(crate) fn emit_conversation(&mut self) {
1146 self.stamp_new_messages();
1147 let snap = self.conv.snapshot(self.consecutive_auto_turns);
1148 self.emit(SessionEventWire::Conversation(snap));
1149 }
1150
1151 pub(crate) fn stamp_new_messages(&mut self) {
1156 if !self.conv.is_live() || !self.runtime.is_live() {
1157 return;
1158 }
1159 let clock = self.runtime.message_clock().clone();
1160 self.message_stamper.stamp(&mut self.conv.api_messages, &clock);
1161 }
1162
1163 pub(crate) async fn publish_view(&mut self) {
1164 let v = RuntimeView::from_runtime(&self.runtime).await;
1165 self.view.store(Arc::new(v));
1166 }
1167
1168 pub(crate) fn request_save(&mut self) {
1172 if !self.config.persist || !self.conv.is_live() {
1173 return;
1174 }
1175 self.stamp_new_messages();
1176 let Some(session) = self.conv.prepare_save() else {
1177 return;
1178 };
1179 let key = super::persister::SnapshotKey::of(&session);
1180 if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
1183 return;
1184 }
1185 self.last_queued_save = Some(key);
1186 self.persister.save(session);
1187 }
1188
1189 pub(crate) async fn flush_saves(&mut self) -> bool {
1193 let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
1194 Ok(ok) => ok,
1195 Err(_) => {
1196 tracing::warn!(session = %self.id, "session save still pending after the save budget");
1197 false
1198 }
1199 };
1200 if !ok {
1201 self.last_queued_save = None;
1204 }
1205 ok
1206 }
1207
1208 pub(crate) async fn save(&mut self) -> bool {
1211 self.request_save();
1212 self.flush_saves().await
1213 }
1214
1215 pub(crate) async fn announce_idle(&mut self) {
1221 self.sync_memory_choice();
1225 self.flush_saves().await;
1226 #[cfg(any(test, feature = "testing"))]
1228 if super::session_start::testing::take_requeue(&self.journal_id.load()) {
1229 self.queue_session_start();
1230 }
1231 self.emit(SessionEventWire::Idle);
1232 self.cache_keepwarm_on_idle().await;
1233 }
1234
1235 async fn handle_context_head_checkpoint(
1245 &mut self,
1246 session_id: String,
1247 messages: Vec<crate::SharedMessage>,
1248 receipt: agent_core::core::context_head::ContextHeadReceipt,
1249 ) {
1250 self.turn_draft_rolled_over();
1251 if session_id != self.conv.session.id {
1253 tracing::warn!(
1254 event_id = %session_id,
1255 actor_id = %self.conv.session.id,
1256 "context head checkpoint: stale session id — ignoring"
1257 );
1258 receipt.complete(Err(std::io::Error::new(
1259 std::io::ErrorKind::InvalidInput,
1260 "stale session id after compaction",
1261 )));
1262 return;
1263 }
1264
1265 if !self.config.persist {
1267 receipt.complete(Ok(()));
1268 return;
1269 }
1270
1271 if !self.persister.flush().await {
1274 tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
1275 }
1276 let result = self.conv.persist_context_head(&session_id, messages).await;
1277 if let Err(ref e) = result {
1278 tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
1279 }
1280 let succeeded = result.is_ok();
1281 receipt.complete(result);
1282
1283 if let Some(driver) = &self.driver {
1285 if !succeeded
1286 || driver.grant.session_id != session_id
1287 || session_id != self.conv.session.id
1288 {
1289 self.driver_revoke("context checkpoint failed or session replaced");
1290 }
1291 }
1292 }
1293
1294
1295 pub(crate) fn reload_record(&self) -> SessionReloadRecord {
1297 let view = self.view.load();
1298 SessionReloadRecord {
1299 config: SessionConfig {
1301 memory_mode: self.effective_memory_mode(),
1302 ..self.config.clone()
1303 },
1304 keep_warm: self.keep_warm,
1305 lifecycle: SessionLifecycle::from_u8(
1306 self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
1307 ),
1308 settings_replay: self.settings_replay.clone(),
1309 cache_keepwarm: self.cache_keepwarm.override_mode.map(|m| m.to_string()),
1310 model: view.model.clone(),
1311 thinking_level: view.thinking_level.clone(),
1312 workers: Vec::new(),
1313 tool_activations: if self.runtime.is_live() {
1314 self.runtime.carried_tool_activations()
1315 } else {
1316 self.parked_tool_activations.clone()
1317 },
1318 }
1319 }
1320
1321 pub(crate) fn publish_presence(&self) {
1332 use super::handle::{Presence, PresenceState};
1333 self.background
1334 .set_inbox_accepting(self.input_owner.is_some());
1335 let prev = self.presence.load();
1336 let busy = self.busy() || self.compacting_inline;
1337 let parked = self.is_parked();
1338 let state = if parked {
1339 PresenceState::Parked
1340 } else if busy {
1341 PresenceState::Busy
1342 } else {
1343 PresenceState::Idle
1344 };
1345 let now = chrono::Utc::now();
1346 let state_since = match prev.state_since {
1347 Some(t) if prev.state == state => Some(t),
1348 _ => Some(now),
1349 };
1350 let wall = |at: tokio::time::Instant| {
1351 let left = at.saturating_duration_since(tokio::time::Instant::now());
1352 chrono::Duration::from_std(left)
1353 .ok()
1354 .and_then(|d| now.checked_add_signed(d))
1355 };
1356 let park_due = self
1360 .park_deadline
1361 .filter(|d| !parked && Some(*d) != self.park_recheck_at)
1362 .filter(|_| self.can_end_idle() || self.can_park())
1363 .and_then(wall);
1364 let evict_due = self.park_deadline.filter(|_| parked).and_then(wall);
1365 let (title, cost_usd, messages) = if self.conv.is_live() {
1366 (
1367 conversation_title(&self.conv),
1368 self.conv.session_cost,
1369 self.conv.api_messages.len(),
1370 )
1371 } else {
1372 (prev.title.clone(), prev.cost_usd, prev.messages)
1373 };
1374 self.presence.store(Arc::new(Presence {
1375 clients: self.attached.len(),
1376 input_owner: self.input_owner,
1377 awaiting_input: self.pending_prompts.len(),
1378 roster: self.roster(),
1379 client_input: Some(self.client_input),
1380 busy,
1381 state,
1382 state_since,
1383 park_due,
1384 evict_due,
1385 title,
1386 cost_usd,
1387 messages,
1388 }));
1389 }
1390
1391 pub(crate) fn roster(&self) -> Vec<ClientInfo> {
1393 let mut roster: Vec<ClientInfo> = self
1394 .attached
1395 .iter()
1396 .map(|(id, a)| ClientInfo {
1397 id: *id,
1398 name: a.name.clone(),
1399 kind: a.meta.kind,
1400 mode: a.mode,
1401 attached_at_ms: a.attached_at_ms,
1402 })
1403 .collect();
1404 roster.sort_by_key(|c| c.id.0);
1405 roster
1406 }
1407
1408 fn client_label(&self, id: ClientId) -> String {
1410 match self.attached.get(&id) {
1411 Some(a) => format!("{} (#{})", a.name, id.0),
1412 None => format!("client #{}", id.0),
1413 }
1414 }
1415
1416 fn input_refusal(&self, c: ClientId) -> Option<String> {
1420 match self.client_input {
1421 ClientInput::Exclusive => (self.input_owner != Some(c)).then(|| match self.input_owner {
1422 Some(o) => format!("input owned by {}", self.client_label(o)),
1423 None => "input has no owner; attach with --takeover".to_string(),
1424 }),
1425 ClientInput::Shared => match self.attached.get(&c).map(|a| a.mode) {
1426 Some(AttachMode::Mirror | AttachMode::Takeover) => None,
1427 Some(AttachMode::Observe) => {
1428 Some("this client is read-only (attached with --observe)".to_string())
1429 }
1430 None => Some("this client is not attached".to_string()),
1431 },
1432 }
1433 }
1434
1435 fn note_input(&mut self, c: ClientId) {
1439 let Some(a) = self.attached.get_mut(&c) else { return };
1440 if a.mode == AttachMode::Observe {
1441 return;
1442 }
1443 self.input_seq += 1;
1444 a.last_input = self.input_seq;
1445 if self.client_input == ClientInput::Shared && self.input_owner != Some(c) {
1446 let from = self.input_owner;
1447 self.input_owner = Some(c);
1448 self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason: OwnerChangeReason::Input });
1449 self.publish_presence();
1450 }
1451 }
1452
1453 pub(crate) fn update_attach_state(&mut self) {
1456 self.publish_presence();
1457 if matches!(self.state, AttachState::Parking | AttachState::Parked) {
1458 return;
1459 }
1460 self.state = if self.attached.is_empty() {
1461 AttachState::Detached {
1462 running: self.streaming,
1463 }
1464 } else {
1465 AttachState::Attached(self.attached.len())
1466 };
1467 self.rearm_park();
1468 self.rearm_prompt_abandon();
1469 }
1470
1471 pub(crate) fn is_parked(&self) -> bool {
1474 matches!(self.state, AttachState::Parked)
1475 }
1476
1477 pub(crate) fn can_park(&self) -> bool {
1481 self.can_park_ignoring_keepwarm() && !self.cache_keepwarm.active()
1485 }
1486
1487 pub(crate) fn can_park_ignoring_keepwarm(&self) -> bool {
1489 self.can_park_but_for_workers() && !self.workers_hold_park()
1490 }
1491
1492 fn workers_hold_park(&self) -> bool {
1495 self.runtime.is_live()
1496 && self
1497 .runtime
1498 .subagent_registry()
1499 .lock()
1500 .unwrap_or_else(|p| p.into_inner())
1501 .holds_park()
1502 }
1503
1504 fn can_park_but_for_workers(&self) -> bool {
1506 self.attached.is_empty()
1507 && !self.streaming
1508 && self.compact.is_none()
1509 && self.pending_prompts.is_empty()
1510 && self.conv.is_live()
1511 && !self.conv.api_messages.is_empty()
1512 && self.conv.queued_message.is_none()
1513 && !self.keep_warm
1514 && self.config.persist
1515 && !self.is_parked()
1516 && self.driver.is_none()
1517 }
1518
1519 fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
1525 let total = self.conv.session_cost;
1526 if let Some(cap) = self.config.max_session_cost {
1527 if cap.is_finite() && total >= cap {
1528 return Some(("session", total, cap));
1529 }
1530 }
1531 if let Some(driver) = &self.driver {
1532 if let Some(cap) = driver.grant.effective_cost_cap(None) {
1536 let run_cost = total - driver.cost_at_arm;
1537 if cap.is_finite() && run_cost >= cap {
1538 return Some(("run", run_cost, cap));
1539 }
1540 }
1541 }
1542 None
1543 }
1544
1545 fn journal_exists(&self) -> bool { let id = (**self.journal_id.load()).clone();
1547 crate::config::resolve_write_path("sessions")
1548 .join(format!("{id}.json"))
1549 .is_file()
1550 }
1551
1552 pub(crate) fn driver_revoke(&mut self, reason: &str) {
1557 let undelivered = self
1558 .driver
1559 .as_mut()
1560 .map(|d| d.steering.drain(..).collect::<Vec<_>>())
1561 .unwrap_or_default();
1562 let was_active = self.driver.is_some() || self.driver_pending.is_some();
1563 if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
1565 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
1566 }
1567 if let Some(driver) = &self.driver {
1568 self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
1569 self.host
1570 .release_driver(&driver.grant.plugin_id, &self.id);
1571 }
1572 self.driver_generation = self.driver_generation.wrapping_add(1);
1573 self.driver_pending = None;
1574 self.driver = None;
1575 if was_active {
1576 self.emit(SessionEventWire::DriverRevoked {
1577 reason: reason.to_string(),
1578 undelivered_steering: undelivered,
1579 });
1580 }
1581 if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1590 self.resolve_pending_prompts();
1591 let wait = if self.stream.is_some() {
1592 budgets::CANCEL_DRAIN_TIMEOUT
1593 } else {
1594 std::time::Duration::ZERO
1595 };
1596 self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1597 }
1598 self.rearm_park();
1599 }
1600
1601 pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1605 if self.driver.is_some() || self.driver_pending.is_some() {
1607 self.driver_revoke("explicit command");
1608 }
1609
1610 let manager = self.host.ext_manager().clone();
1611 let session_id = self.id.0.clone();
1612
1613 let (handler, timeout) = match manager.try_read() {
1615 Ok(mgr) => {
1616 let handler = match mgr.user_action_handler(&plugin) {
1617 Ok(h) => h,
1618 Err(error) => {
1619 self.emit(SessionEventWire::SystemNotice(error));
1620 return;
1621 }
1622 };
1623 let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1624 5
1625 } else {
1626 self.emit(SessionEventWire::SystemNotice(
1627 "session driver extension lacks validated session.drive permission".into(),
1628 ));
1629 return;
1630 };
1631 (handler, timeout)
1632 }
1633 Err(_) => {
1634 self.emit(SessionEventWire::SystemNotice(
1635 "extensions are loading or busy — try again shortly".into(),
1636 ));
1637 return;
1638 }
1639 };
1640
1641 let handler_generation = super::driver::live_generation(&handler).ok();
1642
1643 let owner = plugin.clone();
1644 let cmd = command.clone();
1645 let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1646
1647 let generation = self.driver_generation;
1648 let task_handler = handler.clone();
1649 self.driver_pending = Some(super::driver::DriverPending {
1650 generation,
1651 session_id: session_id.clone(),
1652 task: super::driver::Task(tokio::spawn(async move {
1653 let (sink, collector) =
1654 invoke_event_channel(InvokeOutputBudget::default());
1655 let request_id = uuid::Uuid::new_v4().to_string();
1656 let (result, report) =
1657 tokio::time::timeout(Duration::from_secs(timeout), async {
1658 tokio::join!(
1659 task_handler.invoke_command(&cmd, args, &request_id, sink),
1660 collector.collect()
1661 )
1662 })
1663 .await
1664 .unwrap_or_else(|_| {
1665 (
1666 Err(format!("interactive command timed out ({timeout}s)")),
1667 crate::extensions::invoke_output::InvokeOutputReport {
1668 events: Vec::new(),
1669 counters: Default::default(),
1670 },
1671 )
1672 });
1673 super::driver::TaskResult::Command {
1674 owner,
1675 command,
1676 handler: task_handler,
1677 handler_generation,
1678 result,
1679 report,
1680 }
1681 })),
1682 });
1683 }
1684
1685 pub(crate) fn driver_arm(
1688 &mut self,
1689 owner: String,
1690 handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1691 handler_generation: u64,
1692 reply: Reply,
1693 ) -> std::result::Result<(), String> {
1694 let manager = self.host.ext_manager().clone();
1695 super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1696
1697 if self.driver.is_some() {
1698 return Err("cannot replace an armed grant".into());
1699 }
1700
1701 let session_id = self.id.0.clone();
1702 let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1703
1704 self.host
1706 .claim_driver(&owner, &self.id)
1707 .map_err(|e| e.to_string())?;
1708
1709 let cancel = CancellationToken::new();
1710 let workers = self.runtime.subagent_registry().clone();
1711 let worker_epoch = workers
1712 .lock()
1713 .unwrap_or_else(|p| p.into_inner())
1714 .set_spawn_cancellation(Some(cancel.clone()));
1715 let deadline_task = grant.deadline().map(|deadline| {
1716 let c = cancel.clone();
1717 let w = workers.clone();
1718 super::driver::Task(tokio::spawn(async move {
1719 tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1720 c.cancel();
1721 super::driver::cancel_workers(&w, worker_epoch);
1722 }))
1723 });
1724
1725 let models = grant.models().to_vec();
1726 let proposal_notice = proposal.notice.clone();
1727 let selection = proposal.selection.clone();
1728 let deadline_ms = super::driver::deadline_ms(&grant);
1729 let run_id = grant.run_id.clone();
1730 let cost_at_arm = self.conv.session_cost;
1731
1732 let mut state = super::driver::DriverState {
1733 selection: proposal.selection.clone(),
1734 grant,
1735 handler,
1736 handler_generation,
1737 cancel,
1738 workers,
1739 worker_epoch,
1740 deadline_task,
1741 proposal: None,
1742 awaiting_terminal: false,
1743 outcome: None,
1744 feedback: crate::extensions::feedback::Tracker::default(),
1745 completed_feedback: "unknown",
1746 steering: VecDeque::new(),
1747 auto_wakes_blocked: false,
1748 cost_at_arm,
1749 };
1750
1751 super::driver::schedule(&mut state, proposal)?;
1752
1753 let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1755 if let Some(text) = context_notice {
1756 self.emit(SessionEventWire::SystemNotice(text));
1757 }
1758
1759 self.driver_interrupted_owner = None;
1760 self.driver = Some(state);
1761
1762 self.emit(SessionEventWire::DriverArmed {
1763 plugin_id: owner,
1764 run_id,
1765 models,
1766 selection,
1767 deadline_ms,
1768 notice: proposal_notice,
1769 });
1770
1771 Ok(())
1772 }
1773
1774 pub(crate) fn autonomous_work_blocked(&self) -> Option<WorkBlock> {
1800 if self.reload_fence.is_some() {
1801 Some(WorkBlock::Fenced)
1802 } else if !self.session_start.is_done() {
1803 Some(WorkBlock::SessionStartPending)
1804 } else {
1805 None
1806 }
1807 }
1808
1809 pub(crate) fn holds(&self, source: WorkSource) -> bool {
1812 match self.autonomous_work_blocked() {
1813 None => false,
1814 Some(WorkBlock::Fenced) => true,
1815 Some(WorkBlock::SessionStartPending) => matches!(source, WorkSource::KeepWarmTimer),
1816 }
1817 }
1818
1819 fn driver_idle_conflict(&self) -> Option<&'static str> {
1824 if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1825 return Some("other queued work");
1826 }
1827 if self.compact.is_some() {
1828 return Some("compaction");
1829 }
1830 match self.autonomous_work_blocked() {
1831 Some(WorkBlock::Fenced) => return Some("daemon reloading"),
1832 Some(WorkBlock::SessionStartPending) => return Some("extensions loading or reloading"),
1833 None => {}
1834 }
1835 if self.conv.context_head.is_blocked(&self.conv.session) {
1836 return Some("unverified context head");
1837 }
1838 if self.driver_completion_blocked() {
1839 return Some("outstanding workers require collection/reconciliation");
1840 }
1841 None
1842 }
1843
1844 fn driver_completion_blocked(&self) -> bool {
1846 use agent_core::orchestration::CompletionGate;
1847 self.runtime.orchestration().is_some_and(|o| {
1848 !matches!(o.completion_gate(), CompletionGate::Allowed)
1849 }) || self
1850 .runtime
1851 .subagent_registry()
1852 .lock()
1853 .map(|r| {
1854 r.list_active().iter().any(|(_, _, status)| {
1855 *status == crate::runtime::subagent::SubagentStatus::Running
1856 })
1857 })
1858 .unwrap_or(true)
1859 }
1860
1861 pub(crate) async fn driver_tick(&mut self) {
1865 use crate::extensions::session_driver::Outcome;
1866 if self.holds(WorkSource::DriverTick) {
1867 return;
1868 }
1869
1870 if self.stream.is_none() && self.turn_cancelled() {
1874 self.finish_revoked_turn(false).await;
1875 }
1876
1877 if let Some(driver) = &self.driver {
1879 let invalid = if driver.grant.session_id != self.id.0 {
1880 Some("session replaced".to_string())
1881 } else if driver.grant.expired() {
1882 Some("grant deadline reached".into())
1883 } else if driver.cancel.is_cancelled() {
1884 Some("canceled".into())
1885 } else {
1886 let manager = self.host.ext_manager().clone();
1887 super::driver::same_handler(
1888 &manager,
1889 &driver.grant.plugin_id,
1890 &driver.handler,
1891 driver.handler_generation,
1892 )
1893 .err()
1894 };
1895 if let Some(reason) = invalid {
1896 self.driver_revoke(&reason);
1897 return;
1898 }
1899 }
1900
1901 if self.driver.is_some() {
1903 if self.attached.is_empty() {
1907 self.driver_revoke("no clients attached");
1908 return;
1909 }
1910 if let Some(reason) = self.driver_idle_conflict() {
1913 self.driver_revoke(reason);
1914 return;
1915 }
1916
1917 if !self.runtime.event_queue().is_empty() {
1920 return;
1921 }
1922 }
1923
1924 if self
1926 .driver_pending
1927 .as_ref()
1928 .is_some_and(|p| p.task.0.is_finished())
1929 {
1930 let mut pending = self.driver_pending.take().expect("checked pending");
1931 if pending.generation != self.driver_generation
1932 || pending.session_id != self.id.0
1933 {
1934 return;
1935 }
1936 let result = match (&mut pending.task.0).await {
1937 Ok(result) => result,
1938 Err(error) => {
1939 self.driver_revoke("driver task failed");
1940 self.emit(SessionEventWire::SystemNotice(format!(
1941 "Session driver task failed: {error}"
1942 )));
1943 return;
1944 }
1945 };
1946 match result {
1947 super::driver::TaskResult::Command {
1948 owner,
1949 command: _,
1950 handler,
1951 handler_generation,
1952 result,
1953 report,
1954 } => {
1955 let current = self
1956 .host
1957 .ext_manager()
1958 .try_read()
1959 .ok()
1960 .and_then(|m| m.user_action_handler(&owner).ok());
1961 if !current
1962 .as_ref()
1963 .is_some_and(|current| Arc::ptr_eq(current, &handler))
1964 {
1965 self.driver_revoke(
1966 "command owner unloaded, replaced, or unavailable",
1967 );
1968 return;
1969 }
1970 let output_failed = report.is_limited()
1971 || report.events.iter().any(|event| {
1972 matches!(
1973 event,
1974 crate::extensions::runtime::InvokeCommandEvent::Output(
1975 crate::extensions::commands::CommandOutputEvent::Error { .. }
1976 )
1977 )
1978 });
1979 let mut lines: Vec<String> = Vec::new();
1984 for event in &report.events {
1985 if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1986 let text = match out {
1987 crate::extensions::commands::CommandOutputEvent::Text { content }
1988 | crate::extensions::commands::CommandOutputEvent::System { content } => {
1989 Some(content.clone())
1990 }
1991 crate::extensions::commands::CommandOutputEvent::Error { content } => {
1992 Some(format!("Error: {content}"))
1993 }
1994 _ => None,
1995 };
1996 if let Some(text) = text {
1997 lines.push(text);
1998 }
1999 }
2000 }
2001 if !lines.is_empty() {
2002 const MAX_LINES: usize = 20;
2003 let truncated = lines.len() > MAX_LINES;
2004 if truncated {
2005 lines.truncate(MAX_LINES);
2006 lines.push("…output truncated".into());
2007 }
2008 self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
2009 }
2010 if let Some(notice) = report.limit_notice() {
2011 self.emit(SessionEventWire::SystemNotice(format!(
2012 "command output truncated: {}",
2013 notice.message,
2014 )));
2015 }
2016 if let Ok(value) = result {
2017 let result = match protocol::parse_reply(&value) {
2018 Ok(Some(reply @ Reply::Start { .. })) => {
2019 if output_failed {
2020 Err("command reported an error or exceeded its output budget"
2021 .into())
2022 } else if self.streaming || self.stream.is_some() {
2023 Err("foreground turn still active".into())
2024 } else if let Some(reason) = self.driver_idle_conflict() {
2025 Err(reason.into())
2026 } else {
2027 match handler_generation {
2028 Some(generation) => {
2029 self.driver_arm(
2030 owner, handler, generation, reply,
2031 )
2032 }
2033 None => Err(
2034 "command owner did not expose a live lifecycle at dispatch"
2035 .into(),
2036 ),
2037 }
2038 }
2039 }
2040 Ok(Some(
2041 Reply::Stop { notice: text }
2042 | Reply::Status { notice: text },
2043 )) => {
2044 self.emit(SessionEventWire::SystemNotice(text));
2045 Ok(())
2046 }
2047 Ok(Some(Reply::Next { .. })) => Err(
2048 "next requires an armed poll, not an interactive command"
2049 .into(),
2050 ),
2051 Ok(None) => Ok(()),
2052 Err(error) => Err(error),
2053 };
2054 if let Err(error) = result {
2055 self.emit(SessionEventWire::SystemNotice(format!(
2056 "Session driver rejected: {error}"
2057 )));
2058 }
2059 }
2060 }
2061 super::driver::TaskResult::Poll(result) => {
2062 let Some(driver) = self.driver.as_mut() else {
2063 return;
2064 };
2065 let stop_notice = match &result {
2066 Ok(Reply::Stop { notice }) => Some(notice.clone()),
2067 _ => None,
2068 };
2069 match result.and_then(|reply| driver.grant.accept(reply)) {
2070 Ok(Some(proposal)) => {
2071 let text = proposal.notice.clone();
2072 if let Err(error) =
2073 super::driver::schedule(driver, proposal)
2074 {
2075 self.driver_revoke(&error);
2076 } else {
2077 self.emit(SessionEventWire::SystemNotice(text));
2078 }
2079 }
2080 Ok(None) => {
2081 self.driver_revoke("owner requested stop");
2082 if let Some(text) = stop_notice {
2083 self.emit(SessionEventWire::SystemNotice(text));
2084 }
2085 }
2086 Err(error) => {
2087 self.driver_revoke(&format!("invalid poll response: {error}"))
2088 }
2089 }
2090 }
2091 super::driver::TaskResult::Prepared { proposal, result } => {
2092 if self.streaming || self.stream.is_some() {
2093 self.driver_revoke("foreground work took priority");
2094 return;
2095 }
2096 if let Some(reason) = self.driver_idle_conflict() {
2100 self.driver_revoke(reason);
2101 return;
2102 }
2103 match *result {
2104 Err(protocol::PrepareError::Selection(error)) => {
2105 self.emit(SessionEventWire::SystemNotice(format!(
2106 "Session driver selection rejected (nothing sent): {error}"
2107 )));
2108 if let Some(driver) = self.driver.as_mut() {
2109 driver.outcome = Some((
2110 Outcome::SelectionRejected,
2111 "unknown".into(),
2112 ));
2113 }
2114 }
2115 Err(protocol::PrepareError::Blocked(error)) => {
2116 self.driver_revoke(&format!("preflight blocked: {error}"));
2117 }
2118 Ok(candidate) => {
2119 let validated = proposal.clone();
2120 let history = super::driver::history_with_steering(
2121 &self.conv.api_messages,
2122 &self
2123 .driver
2124 .as_ref()
2125 .map(|d| &d.steering)
2126 .cloned()
2127 .unwrap_or_default(),
2128 );
2129 if let Err(error) = protocol::validate_prepared(
2130 &candidate, &validated, &history,
2131 ) {
2132 self.driver_revoke(&format!(
2133 "prepared selection changed before commit: {error}"
2134 ));
2135 return;
2136 }
2137 {
2141 let driver = self.driver.as_mut().expect("driver present");
2142 let conv = &mut *self.conv;
2143 super::driver::commit_submission(
2144 &mut conv.api_messages,
2145 &mut driver.steering,
2146 &proposal.prompt,
2147 );
2148 }
2149 *self.runtime = candidate;
2151 self.conv.session.model = self.runtime.model().to_owned();
2152 self.conv.session.thinking_level =
2153 self.runtime.thinking_level().to_owned();
2154 self.consecutive_auto_turns = 0;
2155 self.stamp_new_messages();
2157 self.turn_baseline = self.conv.api_messages.len();
2158 self.turn_steered_events.clear();
2159 self.turn_replay.clear();
2160 self.streaming = true;
2161 self.open_turn_draft();
2162 self.update_attach_state();
2163 self.emit(SessionEventWire::TurnStarted {
2164 turn_baseline: self.turn_baseline,
2165 trigger: TurnTrigger::DriverAuto,
2166 user_text: Some(proposal.prompt.clone()),
2167 });
2168 self.announce_web_search_state();
2169
2170 if let Some(driver) = self.driver.as_mut() {
2172 driver.awaiting_terminal = true;
2173 driver.completed_feedback = "unknown";
2174 if driver.grant.feedback_enabled() {
2175 driver.feedback.begin_turn(
2176 &driver.selection.model,
2177 &driver.selection.effort,
2178 );
2179 }
2180 }
2181
2182 let handler = self
2184 .driver
2185 .as_ref()
2186 .expect("driver")
2187 .handler
2188 .clone();
2189 let handler_generation = self
2190 .driver
2191 .as_ref()
2192 .expect("driver")
2193 .handler_generation;
2194 let ct = self
2195 .driver
2196 .as_ref()
2197 .expect("driver")
2198 .cancel
2199 .child_token();
2200 self.cancel = Some(ct.clone());
2201 let completion = crate::runtime::TurnCompletion::with_default_cause(
2204 crate::runtime::CancelCause::Driver,
2205 );
2206 self.turn_completion = Some(completion.clone());
2207 let (tx, rx) = mpsc::unbounded_channel();
2208 self.steer_tx = Some(tx);
2209 self.owner_steer_budget = None;
2210 let mut runtime = self.runtime.clone();
2211 runtime.share_ttl_latches(&self.runtime);
2215 let history = self.conv.api_messages.clone();
2216 let secret = self.secret_prompt_handle.clone();
2217 let session_id = self.id.0.clone();
2218 let generation = self.driver_generation;
2219 self.driver_pending = Some(super::driver::DriverPending {
2220 generation,
2221 session_id,
2222 task: super::driver::Task(tokio::spawn(async move {
2223 if ct.is_cancelled() {
2224 return super::driver::TaskResult::Started(
2225 Err("canceled before stream setup".into()),
2226 );
2227 }
2228 if let Err(error) =
2229 super::driver::same_lifecycle(
2230 &handler,
2231 handler_generation,
2232 )
2233 {
2234 return super::driver::TaskResult::Started(
2235 Err(error),
2236 );
2237 }
2238 tokio::select! {
2239 biased;
2240 _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
2241 started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
2242 }
2243 })),
2244 });
2245
2246 let mut tick = tokio::time::interval(
2248 std::time::Duration::from_secs(1),
2249 );
2250 tick.set_missed_tick_behavior(
2251 tokio::time::MissedTickBehavior::Delay,
2252 );
2253 tick.reset();
2254 self.subagent_tick = Some(tick);
2255 self.request_save();
2256 }
2257 }
2258 }
2259 super::driver::TaskResult::Started(started) => match started {
2260 Ok(started) if self.driver.is_some() => {
2261 self.stream = Some(started);
2262 }
2263 Err(error) => {
2264 self.driver_revoke(&error);
2265 }
2266 _ => {}
2267 },
2268 }
2269 }
2270
2271 if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
2273 return;
2274 }
2275 if let Some(reason) = self.driver_idle_conflict() {
2276 self.driver_revoke(reason);
2277 return;
2278 }
2279 let session_cost = self.conv.session_cost;
2280 let Some(driver) = self.driver.as_mut() else {
2281 return;
2282 };
2283 if let Some((outcome, error_kind)) = driver.outcome.take() {
2284 let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
2285 let handler = driver.handler.clone();
2286 let session_id = self.id.0.clone();
2287 let generation = self.driver_generation;
2288 self.driver_pending = Some(super::driver::DriverPending {
2289 generation,
2290 session_id,
2291 task: super::driver::Task(tokio::spawn(async move {
2292 let result = tokio::time::timeout(
2295 Duration::from_secs(30),
2296 protocol::poll(handler, request),
2297 )
2298 .await
2299 .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
2300 super::driver::TaskResult::Poll(result)
2301 })),
2302 });
2303 } else if driver
2304 .proposal
2305 .as_ref()
2306 .is_some_and(|p| Instant::now() >= p.due)
2307 {
2308 let proposal =
2309 driver.proposal.take().expect("checked proposal").proposal;
2310 let runtime = self.runtime.clone();
2313 let history = super::driver::history_with_steering(
2314 &self.conv.api_messages,
2315 &driver.steering,
2316 );
2317 let session_id = self.id.0.clone();
2318 let generation = self.driver_generation;
2319 self.driver_pending = Some(super::driver::DriverPending {
2320 generation,
2321 session_id,
2322 task: super::driver::Task(tokio::spawn(async move {
2323 let result = tokio::time::timeout(
2324 Duration::from_secs(5),
2325 protocol::prepare(&runtime, &proposal, &history),
2326 )
2327 .await
2328 .unwrap_or_else(|_| {
2329 Err(protocol::PrepareError::Blocked(
2330 "preparation timed out (5s)".into(),
2331 ))
2332 });
2333 super::driver::TaskResult::Prepared {
2334 proposal,
2335 result: Box::new(result),
2336 }
2337 })),
2338 });
2339 }
2340 }
2341
2342 pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
2347 match Self::try_lock_session(new_id) {
2348 Ok(lock) => self.session_lock = Some(lock),
2349 Err(e) => {
2350 tracing::warn!(session = %new_id, "reacquire session lock: {e}");
2351 self.session_lock = None;
2352 }
2353 }
2354 }
2355
2356 pub(crate) fn try_lock_session(
2358 id: &str,
2359 ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
2360 {
2361 let dir = agent_core::session_lock::sessions_dir();
2362 let holder = agent_core::session_lock::LockHolder {
2363 pid: std::process::id(),
2364 kind: "daemon".to_string(),
2365 };
2366 agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
2367 }
2368
2369 pub(crate) fn can_end_idle(&self) -> bool {
2374 self.attached.is_empty()
2375 && !self.streaming
2376 && self.compact.is_none()
2377 && self.pending_prompts.is_empty()
2378 && self.conv.is_live()
2379 && self.conv.api_messages.is_empty()
2380 && self.conv.queued_message.is_none()
2381 && !self.keep_warm
2382 && !self.is_parked()
2383 && self.driver.is_none()
2388 && self.driver_pending.is_none()
2389 }
2390
2391 pub(crate) fn rearm_park(&mut self) {
2392 self.rearm_park_deadline();
2393 self.publish_presence();
2394 }
2395
2396 fn rearm_park_deadline(&mut self) {
2397 if self.is_parked() {
2398 return;
2401 }
2402 if self.can_end_idle() {
2403 if self.park_deadline.is_none() {
2408 self.park_deadline =
2409 Some(tokio::time::Instant::now() + idle_end_grace());
2410 }
2411 return;
2412 }
2413 if !self.can_park() {
2414 let workers_only = self.can_park_but_for_workers()
2417 && !self.cache_keepwarm.active()
2418 && park_grace().is_some();
2419 self.park_deadline = if workers_only {
2420 self.park_deadline.or_else(|| {
2421 let at = tokio::time::Instant::now() + worker_park_recheck();
2422 self.park_recheck_at = Some(at);
2423 Some(at)
2424 })
2425 } else {
2426 None
2427 };
2428 return;
2429 }
2430 match park_grace() {
2431 Some(grace) => {
2432 let due = tokio::time::Instant::now() + grace;
2436 self.park_deadline = Some(self.park_deadline.map_or(due, |d| d.min(due)));
2439 }
2440 None => self.park_deadline = None,
2441 }
2442 }
2443
2444 fn rearm_prompt_abandon(&mut self) {
2454 let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
2455 let abandoned = self.attached.is_empty()
2456 && !self.pending_prompts.is_empty()
2457 && !armed_driver
2458 && !self.is_parked();
2459 if !abandoned {
2460 self.prompt_abandon_deadline = None;
2463 return;
2464 }
2465 if self.prompt_abandon_deadline.is_none() {
2466 self.prompt_abandon_deadline =
2468 prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
2469 }
2470 }
2471
2472 fn on_prompt_abandon_deadline(&mut self) {
2477 self.prompt_abandon_deadline = None;
2478 if !self.attached.is_empty() {
2479 return;
2480 }
2481 let had_prompts = !self.pending_prompts.is_empty();
2482 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2483 let _ = tx.send(None);
2484 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2485 }
2486 if had_prompts {
2487 tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
2488 self.publish_presence();
2489 self.rearm_park();
2491 }
2492 }
2493
2494 fn set_lifecycle(&mut self, l: SessionLifecycle) {
2495 self.lifecycle
2496 .store(l as u8, std::sync::atomic::Ordering::Release);
2497 self.emit(SessionEventWire::Lifecycle(l));
2498 }
2499
2500 pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
2504 let flow = self.park_now().await;
2505 self.publish_presence();
2506 if flow.is_continue()
2509 && !self.is_parked()
2510 && matches!(self.session_start, super::session_start::SessionStart::Pending)
2511 {
2512 self.queue_session_start();
2513 }
2514 flow
2515 }
2516
2517 async fn park_now(&mut self) -> std::ops::ControlFlow<EndReason> {
2518 let recheck = self.park_deadline.is_some() && self.park_deadline == self.park_recheck_at;
2520 self.park_recheck_at = None;
2521 self.park_deadline = None;
2522 if self.is_parked() {
2523 if self.attached.is_empty() {
2524 tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
2525 return std::ops::ControlFlow::Break(EndReason::Evicted);
2526 }
2527 return std::ops::ControlFlow::Continue(());
2528 }
2529 if self.can_end_idle() {
2530 tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
2531 if let Some(lock) = self.session_lock.take() {
2534 lock.release_and_remove();
2535 }
2536 return std::ops::ControlFlow::Break(EndReason::Idle);
2537 }
2538 if !self.can_park() {
2539 self.rearm_park();
2541 return std::ops::ControlFlow::Continue(());
2542 }
2543 if recheck {
2544 if let Some(grace) = park_grace() {
2547 self.park_deadline = Some(tokio::time::Instant::now() + grace);
2548 return std::ops::ControlFlow::Continue(());
2549 }
2550 }
2551 self.state = AttachState::Parking;
2552 self.set_lifecycle(SessionLifecycle::Parking);
2553 if !self.save().await {
2556 tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
2557 self.state = AttachState::Detached { running: false };
2558 self.set_lifecycle(SessionLifecycle::Live);
2559 return std::ops::ControlFlow::Continue(());
2560 }
2561 if !self.journal_exists() {
2562 tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
2564 self.state = AttachState::Detached { running: false };
2565 self.set_lifecycle(SessionLifecycle::Live);
2566 return std::ops::ControlFlow::Continue(());
2567 }
2568 if self.runtime.session_manager().active_count() > 0 {
2569 self.emit(SessionEventWire::SystemNotice(
2570 "parked: background shells closed".into(),
2571 ));
2572 }
2573 self.runtime.session_manager().shutdown_all();
2574 self.turn_replay.clear();
2575 self.sync_memory_choice();
2578 self.parked_tool_activations = self.runtime.carried_tool_activations();
2582 self.publish_presence();
2586 let conv = self.conv.park_take();
2587 drop(conv);
2588 let runtime = self.runtime.park_take();
2589 drop(runtime);
2590 crate::core::memstat::purge_arenas();
2594 self.session_lock = None;
2598 self.session_start.withdraw_for_park();
2600 self.state = AttachState::Parked;
2601 self.set_lifecycle(SessionLifecycle::Parked);
2602 tracing::info!(session = %self.id, "session parked");
2603 self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
2607 std::ops::ControlFlow::Continue(())
2608 }
2609
2610 pub(crate) async fn unpark(&mut self) -> Result<()> {
2615 let started = std::time::Instant::now();
2616 self.park_deadline = None;
2618 let journal_id = (**self.journal_id.load()).clone();
2619
2620 {
2624 let dir = agent_core::session_lock::sessions_dir();
2625 let holder = agent_core::session_lock::LockHolder {
2626 pid: std::process::id(),
2627 kind: "daemon".to_string(),
2628 };
2629 match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
2630 Ok(lock) => self.session_lock = Some(lock),
2631 Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
2632 return Err(crate::RuntimeError::Session(format!(
2633 "cannot unpark session {}: journal locked by another process",
2634 journal_id
2635 )));
2636 }
2637 Err(e) => {
2638 tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
2639 }
2641 }
2642 }
2643
2644 let host = Arc::clone(&self.host);
2645 let cfg = self.config.clone();
2646 let queue = Arc::clone(&self.event_queue);
2647 let replay = self.settings_replay.clone();
2648 let journal_present = self.journal_exists();
2649 let view = self.view.load_full();
2650 let build = async move {
2651 let config: crate::SynapsConfig = (**host.config()).clone();
2652 let mut runtime = host
2655 .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
2656 .await?;
2657 if let Some(mode) = cfg.memory_mode {
2658 runtime.restore_session_memory_mode(mode);
2659 }
2660 runtime.set_event_queue(queue);
2661 let mut sb = if journal_present {
2662 crate::engine::setup::resolve_session_and_prompt(
2663 &mut runtime,
2664 &Some(Some(journal_id)),
2665 cfg.system.as_deref(),
2666 cfg.prompt_manifest.as_deref(),
2667 )?
2668 } else {
2669 tracing::warn!(
2673 session = %journal_id,
2674 "unpark: journal missing — restoring as a fresh conversation"
2675 );
2676 let mut sb = crate::engine::setup::resolve_session_and_prompt(
2677 &mut runtime,
2678 &None,
2679 cfg.system.as_deref(),
2680 cfg.prompt_manifest.as_deref(),
2681 )?;
2682 sb.session.id = journal_id.clone();
2683 runtime.set_session_id(Some(journal_id));
2684 sb
2685 };
2686 runtime.set_cwd(cfg.cwd.clone());
2687 runtime.set_env(cfg.env.clone());
2688 runtime.set_env_stripped(cfg.env_stripped.clone());
2689 runtime.set_model(view.model.clone());
2692 let _ = runtime.restore_session_reasoning(&view.thinking_level);
2693 if let Some(clock) = &cfg.message_clock {
2694 runtime.set_message_clock(clock.clone());
2695 }
2696 sb.session.model = view.model.clone();
2697 sb.session.thinking_level = runtime.thinking_level().to_string();
2698 crate::engine::setup::finish_session_setup(
2699 &mut runtime,
2700 &config,
2701 &sb.session,
2702 cfg.cwd.clone(),
2703 crate::engine::setup::IndexRecord::Skip,
2704 );
2705 for s in &replay {
2706 replay_setting(&mut runtime, s);
2707 }
2708 Ok::<_, crate::RuntimeError>((runtime, sb))
2709 };
2710 let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
2711 Ok(Ok(v)) => v,
2712 Ok(Err(e)) => return Err(e),
2713 Err(_) => {
2714 return Err(crate::RuntimeError::Session(format!(
2715 "unpark timed out after {}s",
2716 budgets::UNPARK_TIMEOUT_SECS
2717 )))
2718 }
2719 };
2720 let mut conv = ConversationState::from_resumed(sb.session);
2721 conv.api_messages = sb.api_messages;
2722 conv.total_input_tokens = sb.total_input_tokens;
2723 conv.total_output_tokens = sb.total_output_tokens;
2724 conv.session_cost = sb.session_cost;
2725 if journal_present && self.config.persist && self.session_lock.is_some() {
2726 crate::engine::setup::recover_turn_draft(&mut conv).await;
2727 }
2728 self.message_stamper.settle(conv.api_messages.len());
2729 runtime.restore_tool_activations(std::mem::take(&mut self.parked_tool_activations));
2730 self.runtime.unpark_set(runtime);
2731 self.conv.unpark_set(conv);
2732 if self.meta.name != self.conv.session.name {
2734 let name = self.conv.session.name.clone();
2735 self.mirror_name(name);
2736 }
2737 self.memory_seen_active = false;
2739 self.observe_memory();
2740 self.publish_view().await;
2741 self.state = AttachState::Detached { running: false };
2742 self.set_lifecycle(SessionLifecycle::Live);
2743 self.update_attach_state();
2744 if matches!(self.session_start, super::session_start::SessionStart::Pending) {
2746 self.queue_session_start();
2747 }
2748 tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2749 Ok(())
2750 }
2751
2752 pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2756 if !self.is_parked() {
2757 return Ok(());
2758 }
2759 let res = self
2760 .unpark()
2761 .await
2762 .map_err(|e| format!("session parked and could not be restored: {e}"));
2763 self.publish_presence();
2764 res
2765 }
2766
2767 pub(crate) fn busy(&self) -> bool {
2769 self.streaming || self.compact.is_some()
2770 }
2771
2772 pub(crate) fn publish_subagent_rows(&mut self) {
2783 let rows = self
2784 .runtime
2785 .subagent_registry()
2786 .lock()
2787 .unwrap_or_else(|p| p.into_inner())
2788 .display_rows();
2789 let running = rows
2790 .iter()
2791 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2792 if !rows.is_empty() {
2793 self.emit(SessionEventWire::SubagentRows(rows));
2794 }
2795 if !keep_subagent_tick(self.streaming, running) {
2796 self.subagent_tick = None;
2797 self.rearm_park();
2799 }
2800 }
2801
2802 fn subagents_running(&self) -> bool {
2804 self.runtime
2805 .subagent_registry()
2806 .lock()
2807 .unwrap_or_else(|p| p.into_inner())
2808 .display_rows()
2809 .iter()
2810 .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2811 }
2812
2813 fn announce_web_search_state(&mut self) {
2818 let notice = crate::runtime::openai::codex_wire::session_web_search_notice(
2819 self.runtime.model(),
2820 crate::runtime::openai::codex_wire::engine_env,
2821 );
2822 if notice.is_some() && notice != self.web_search_notice {
2823 self.web_search_notice = notice.clone();
2824 if let Some(text) = notice {
2825 self.emit(SessionEventWire::Stream(StreamEvent::Session(
2826 SessionEvent::Notice(text),
2827 )));
2828 }
2829 }
2830 }
2831
2832 async fn session_start_finished(
2840 &mut self,
2841 ran: std::result::Result<bool, tokio::task::JoinError>,
2842 ) {
2843 use super::session_start::SessionStart;
2844 match ran {
2845 Ok(true) => {}
2846 Ok(false) => {
2849 self.session_start = SessionStart::Pending;
2850 if self.turn_awaiting_start.is_some() || self.cache_keepwarm.deadline().is_some() {
2851 self.session_start_ready();
2852 }
2853 return;
2854 }
2855 Err(e) => tracing::warn!(session = %self.id, "on_session_start task failed: {e}"),
2856 }
2857 self.session_start = SessionStart::Done;
2858 if self.runtime.is_live() {
2859 self.publish_view().await;
2860 }
2861 if let Some(start) = self.turn_awaiting_start.take() {
2862 if !start.cancel.is_cancelled() {
2865 self.open_turn_stream(start).await;
2866 }
2867 }
2868 }
2869
2870 pub(crate) fn session_start_ready(&mut self) -> bool {
2886 use super::session_start::SessionStart;
2887 if matches!(self.session_start, SessionStart::Pending) {
2888 self.queue_session_start();
2889 }
2890 match &self.session_start {
2891 SessionStart::Done => true,
2892 SessionStart::Queued { ticket, .. } => {
2893 ticket.prefer();
2894 false
2895 }
2896 SessionStart::Pending => false,
2897 }
2898 }
2899
2900 fn queue_session_start(&mut self) {
2902 self.session_start = super::session_start::SessionStart::queue(
2903 &self.host,
2904 Arc::clone(&self.hook_bus),
2905 Arc::clone(&self.journal_id),
2906 extensions_ready_timeout(&self.host.config()),
2907 );
2908 }
2909
2910 async fn open_turn_stream(&mut self, start: TurnStart) {
2912 let session_budget = start.event_turn.then(|| {
2918 let event_budget = self
2919 .runtime
2920 .turn_budget()
2921 .for_event_turn(&self.host.config().turn_budgets);
2922 let session_budget = self.runtime.turn_budget().clone();
2923 self.runtime.set_turn_budget(event_budget);
2924 self.runtime
2925 .set_owner_steer_budget(self.owner_steer_budget.clone());
2926 session_budget
2927 });
2928 let stream = self
2929 .runtime
2930 .run_stream_tracked(
2931 self.conv.api_messages.clone(),
2932 start.cancel,
2933 Some(start.steer_rx),
2934 Some(self.secret_prompt_handle.clone()),
2935 start.auto_approve,
2936 start.completion,
2937 )
2938 .await;
2939 if let Some(session_budget) = session_budget {
2940 self.runtime.set_turn_budget(session_budget);
2941 self.runtime.set_owner_steer_budget(None);
2942 }
2943 self.stream = Some(stream);
2944 }
2945
2946 pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2950 self.cache_keepwarm_turn_started();
2951 let ct = CancellationToken::new();
2952 let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2953 self.streaming = true;
2954 self.stamp_new_messages();
2957 self.turn_baseline = self.conv.api_messages.len();
2958 self.open_turn_draft();
2959 self.turn_steered_events.clear();
2960 self.turn_replay.clear();
2961 self.update_attach_state();
2962 self.emit(SessionEventWire::TurnStarted {
2963 turn_baseline: self.turn_baseline,
2964 trigger,
2965 user_text,
2966 });
2967 self.announce_web_search_state();
2968 let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2976 self.observe_memory();
2980 self.turn_worker_generation = self
2981 .runtime
2982 .subagent_registry()
2983 .lock()
2984 .unwrap_or_else(|p| p.into_inner())
2985 .begin_turn();
2986 let completion = crate::runtime::TurnCompletion::new();
2987 self.turn_completion = Some(completion.clone());
2988 self.cancel = Some(ct.clone());
2989 self.steer_tx = Some(s_tx);
2990 let event_turn = trigger == TurnTrigger::EventAuto;
2991 self.owner_steer_budget = event_turn.then(Default::default);
2992 let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2993 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2994 tick.reset();
2995 self.subagent_tick = Some(tick);
2996 let start = TurnStart {
2997 cancel: ct,
2998 steer_rx: s_rx,
2999 auto_approve,
3000 completion,
3001 event_turn,
3002 };
3003 if self.session_start_ready() {
3004 self.open_turn_stream(start).await;
3005 } else {
3006 tracing::debug!(session = %self.id, "turn waits for on_session_start");
3007 self.turn_awaiting_start = Some(start);
3008 self.emit(SessionEventWire::SystemNotice(
3012 "waiting for extensions to start (on_session_start) before sending...".into(),
3013 ));
3014 }
3015 }
3016
3017 pub(crate) fn clear_stream(&mut self) {
3025 self.stream = None;
3026 self.turn_awaiting_start = None;
3027 self.cancel = None;
3028 self.steer_tx = None;
3029 self.owner_steer_budget = None;
3030 self.streaming = false;
3031 self.turn_completion = None;
3032 self.revoked_turn_deadline = None;
3033 if !keep_subagent_tick(false, self.subagents_running()) {
3036 self.subagent_tick = None;
3037 }
3038 if !self.turn_steered_events.is_empty() && self.conv.is_live() {
3039 let undelivered = std::mem::take(&mut self.turn_steered_events);
3040 self.conv.pending_events.splice(0..0, undelivered);
3041 }
3042 self.request_save();
3045 self.close_turn_draft();
3046 self.update_attach_state();
3047 }
3048
3049 fn open_turn_draft(&mut self) {
3056 let persist =
3057 self.config.persist && self.conv.is_live() && self.session_lock.is_some();
3058 self.turn_draft = TurnDraftState {
3059 open: persist.then(|| self.conv.session.id.clone()),
3060 base_len: self.conv.api_messages.len(),
3061 text: String::new(),
3062 tools: Vec::new(),
3063 dirty: true,
3064 };
3065 self.flush_turn_draft();
3066 }
3067
3068 fn turn_draft_text(&mut self, delta: &str) {
3069 let d = &mut self.turn_draft;
3070 if d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES {
3071 d.text.push_str(delta);
3072 d.dirty = true;
3073 }
3074 }
3075
3076 fn turn_draft_reset_text(&mut self) {
3077 let d = &mut self.turn_draft;
3078 d.tools.clear();
3079 if !d.text.is_empty() {
3080 d.text.clear();
3081 d.dirty = true;
3082 }
3083 }
3084
3085 fn turn_draft_committed(&mut self) {
3087 self.turn_draft.base_len = self.conv.api_messages.len();
3088 self.turn_draft.text.clear();
3089 self.turn_draft.tools.clear();
3090 self.turn_draft.dirty = true;
3091 self.flush_turn_draft();
3092 }
3093
3094 fn turn_draft_rolled_over(&mut self) {
3100 self.turn_draft.base_len = usize::MAX;
3101 self.turn_draft.text.clear();
3102 self.turn_draft.tools.clear();
3103 self.turn_draft.dirty = true;
3104 self.flush_turn_draft();
3105 }
3106
3107 fn observe_turn_draft(&mut self, event: &crate::LlmEvent) {
3112 match event {
3113 crate::LlmEvent::Text(text) => self.turn_draft_text(text),
3114 crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset => {
3115 self.turn_draft_reset_text()
3116 }
3117 crate::LlmEvent::ToolUse { tool_name, .. } => {
3118 self.turn_draft.tools.push(tool_name.clone())
3119 }
3120 _ => {}
3121 }
3122 }
3123
3124 pub(crate) fn flush_turn_draft(&mut self) {
3127 let Some(open) = self.turn_draft.open.clone() else {
3128 return;
3129 };
3130 if !self.turn_draft.dirty {
3131 return;
3132 }
3133 let id = self.conv.session.id.clone();
3135 if open != id {
3136 self.persister.remove_draft(&open);
3137 self.turn_draft.open = Some(id.clone());
3138 }
3139 self.turn_draft.dirty = false;
3140 self.persister.write_draft(
3141 &id,
3142 agent_core::core::session_draft::TurnDraft {
3143 base_len: self.turn_draft.base_len,
3144 partial_text: self.turn_draft.text.clone(),
3145 },
3146 );
3147 }
3148
3149 fn close_turn_draft(&mut self) {
3155 if let Some(id) = self.turn_draft.open.take() {
3156 self.persister.remove_draft(&id);
3157 }
3158 self.turn_draft = TurnDraftState::default();
3159 }
3160
3161 pub(crate) async fn submit(
3163 &mut self,
3164 text: String,
3165 attachments: Vec<serde_json::Value>,
3166 from: Option<ClientId>,
3167 ) {
3168 if self.conv.context_head.is_blocked(&self.conv.session) {
3173 let reason = "context head is unverified after a failed checkpoint; reload the \
3174 session (`--continue`) or start a new one before continuing"
3175 .to_string();
3176 match from {
3179 Some(client) => self.emit(SessionEventWire::Refused {
3180 client,
3181 command: "submit".into(),
3182 reason,
3183 }),
3184 None => self.emit(SessionEventWire::SystemNotice(reason)),
3185 }
3186 return;
3187 }
3188 if self.streaming {
3189 if let Some(driver) = self.driver.as_mut() {
3195 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3196 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3197 self.emit(SessionEventWire::SystemNotice(
3198 "steering queue full (16 messages) — message dropped".into(),
3199 ));
3200 return;
3201 }
3202 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3203 self.emit(SessionEventWire::SystemNotice(
3204 "steering queue full (256 KiB) — message dropped".into(),
3205 ));
3206 return;
3207 }
3208 driver.steering.push_back(text.clone());
3209 }
3210 self.steer(text);
3211 return;
3212 }
3213 if self.driver.is_some() {
3216 let driver = self.driver.as_mut().unwrap();
3217 let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3218 if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3219 self.emit(SessionEventWire::SystemNotice(
3220 "steering queue full (16 messages) — message dropped".into(),
3221 ));
3222 return;
3223 }
3224 if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3225 self.emit(SessionEventWire::SystemNotice(
3226 "steering queue full (256 KiB) — message dropped".into(),
3227 ));
3228 return;
3229 }
3230 driver.steering.push_back(text.clone());
3231 driver.auto_wakes_blocked = false;
3233 self.emit(SessionEventWire::Steered {
3234 text,
3235 delivered: false,
3236 });
3237 return;
3238 }
3239 if self.compact.is_some() {
3240 self.emit(SessionEventWire::Steered {
3242 text: text.clone(),
3243 delivered: false,
3244 });
3245 self.conv.queued_message = Some(text);
3246 return;
3247 }
3248 self.consecutive_auto_turns = 0;
3250 let user_text = text.clone();
3252 let api_content = text;
3253
3254 if attachments.is_empty() {
3255 self.conv.api_messages.push(std::sync::Arc::new(
3257 serde_json::json!({"role": "user", "content": api_content}),
3258 ));
3259 } else {
3260 let mut blocks = Vec::with_capacity(attachments.len() + 1);
3262 if !api_content.is_empty() {
3263 blocks.push(serde_json::json!({"type": "text", "text": api_content}));
3264 }
3265 blocks.extend(attachments);
3266 let candidate = std::sync::Arc::new(
3267 serde_json::json!({"role": "user", "content": blocks}),
3268 );
3269 let mut proposed = self.conv.api_messages.clone();
3272 proposed.push(candidate.clone());
3273 let model = self.runtime.model().to_string();
3274 if let Err(e) = crate::runtime::attachments::validate_history_for_request(&model, &proposed) {
3275 match from {
3279 Some(client) => self.emit(SessionEventWire::Refused {
3280 client,
3281 command: "submit".into(),
3282 reason: format!("attachments rejected: {e}"),
3283 }),
3284 None => self.emit(SessionEventWire::SystemNotice(format!(
3285 "attachments rejected: {e}"
3286 ))),
3287 }
3288 return;
3289 }
3290 self.conv.api_messages.push(candidate);
3291 }
3292 self.start_turn(TurnTrigger::User, Some(user_text)).await;
3293 }
3294
3295 pub(crate) fn steer(&mut self, text: String) {
3303 let send = |tx: &mpsc::UnboundedSender<String>| tx.send(text.clone()).is_ok();
3304 let delivered = match (self.steer_tx.as_ref(), self.owner_steer_budget.as_deref()) {
3305 (Some(tx), Some(lift)) => {
3306 lift.send_owner_steer(self.runtime.turn_budget().clone(), || send(tx))
3307 }
3308 (Some(tx), None) => send(tx),
3309 (None, _) => false,
3310 };
3311 self.emit(SessionEventWire::Steered {
3312 text: text.clone(),
3313 delivered,
3314 });
3315 self.conv.queued_message = Some(text);
3316 }
3317
3318 pub(crate) fn steer_subagent(&mut self, from: Option<ClientId>, handle_id: &str, text: String) {
3324 let steered = {
3325 let registry = self
3326 .runtime
3327 .subagent_registry()
3328 .lock()
3329 .unwrap_or_else(|p| p.into_inner());
3330 match registry.get(handle_id) {
3331 None => Err(format!("no subagent {handle_id} in this session")),
3332 Some(h) => {
3333 let running = h
3334 .state
3335 .read()
3336 .unwrap_or_else(|p| p.into_inner())
3337 .public_status()
3338 == crate::runtime::subagent::SubagentStatus::Running;
3339 if running {
3340 h.steer(&text).map(|()| h.agent_name.clone())
3341 } else {
3342 Err(format!("{} ({handle_id}) is not running", h.agent_name))
3343 }
3344 }
3345 }
3346 };
3347 let agent_name = match steered {
3348 Ok(name) => name,
3349 Err(reason) => {
3350 match from {
3351 Some(c) => self.emit(SessionEventWire::Refused {
3352 client: c,
3353 command: "subagent_steer".to_string(),
3354 reason,
3355 }),
3356 None => self.emit(SessionEventWire::SystemNotice(reason)),
3357 }
3358 return;
3359 }
3360 };
3361 self.note_in_main_conversation(subagent_steer_event(handle_id, &agent_name, &text));
3362 }
3363
3364 pub(crate) fn note_in_main_conversation(&mut self, event: crate::events::types::Event) {
3369 let formatted = crate::events::format_event_for_agent(&event);
3370 if self.busy() {
3371 let into_turn = self
3372 .steer_tx
3373 .as_ref()
3374 .is_some_and(|tx| tx.send(formatted.clone()).is_ok());
3375 if into_turn {
3376 self.turn_steered_events.push(formatted);
3377 } else {
3378 self.conv.pending_events.push(formatted);
3379 }
3380 } else {
3381 self.conv
3382 .api_messages
3383 .push(std::sync::Arc::new(serde_json::json!({
3384 "role": "user",
3385 "content": formatted
3386 })));
3387 self.request_save();
3388 }
3389 self.emit(SessionEventWire::External(event));
3390 self.emit_conversation();
3391 }
3392
3393 pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3415 if !self.streaming {
3416 if self.compact.is_some() {
3417 self.abort_compaction();
3418 if let Some(q) = self.conv.queued_message.take() {
3419 self.emit(SessionEventWire::Dequeued { text: q });
3420 }
3421 self.update_attach_state();
3422 }
3423 self.emit(SessionEventWire::Idle);
3424 return;
3425 }
3426 self.note_cancel_cause(reason);
3427 if let Some(ref ct) = self.cancel {
3428 ct.cancel();
3429 }
3430 self.resolve_pending_prompts();
3435 let drain = self.drain_cancelled_stream().await;
3436 match drain.history {
3437 Some(history) => self.conv.api_messages = history,
3438 None => {
3439 if !drain.closed {
3440 tracing::warn!(
3441 session = %self.id,
3442 budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
3443 "cancelled turn did not publish its history within the drain budget; \
3444 keeping the last adopted history plus the streamed partial reply"
3445 );
3446 }
3447 self.keep_streamed_partial(reason);
3448 }
3449 }
3450 let completed = drain.closed && self.turn_completed();
3453 self.finish_interrupted_turn(reason, completed).await;
3454 }
3455
3456 fn keep_streamed_partial(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3474 let d = &self.turn_draft;
3475 let follows_user = self.conv.api_messages.last().is_some_and(|m| m["role"] == "user");
3476 if d.base_len != self.conv.api_messages.len() || !follows_user {
3477 return;
3478 }
3479 let (text, tools) = (d.text.clone(), d.tools.clone());
3480 if !text.trim().is_empty() {
3481 self.conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3482 "role": "assistant",
3483 "content": [{"type": "text", "text": text}]
3484 })));
3485 }
3486 if !tools.is_empty() {
3487 let note = crate::engine::interrupt::tool_in_flight_note_message(reason, &tools);
3488 let text = note["content"].as_str().unwrap_or_default().to_owned();
3489 self.conv.api_messages.push(note);
3490 self.emit(SessionEventWire::SystemNotice(text));
3491 }
3492 }
3493
3494 fn turn_completed(&self) -> bool {
3496 self.turn_completion.as_ref().is_some_and(|c| c.completed())
3497 }
3498
3499 fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
3504 if !self.streaming {
3505 return;
3506 }
3507 if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
3508 turn.note_cancel_cause(cause);
3509 }
3510 }
3511
3512 async fn finish_interrupted_turn(
3522 &mut self,
3523 reason: crate::engine::interrupt::InterruptReason,
3524 completed: bool,
3525 ) {
3526 crate::engine::stream::repair_history_after_failure(
3528 &mut self.conv.api_messages,
3529 self.turn_baseline,
3530 );
3531 let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
3532 if !completed {
3533 crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
3534 }
3535 if let Some(q) = self.conv.queued_message.take() {
3537 self.emit(SessionEventWire::Dequeued { text: q });
3538 }
3539 {
3543 let undelivered = std::mem::take(&mut self.turn_steered_events);
3544 let conv: &mut ConversationState = &mut self.conv;
3545 for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
3546 conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3547 "role": "user",
3548 "content": formatted
3549 })));
3550 }
3551 }
3552 self.clear_stream();
3554 {
3561 use crate::runtime::subagent::WorkerCancelScope;
3562 let ending = self.lifecycle.load(std::sync::atomic::Ordering::Acquire)
3563 == SessionLifecycle::Ending as u8;
3564 let scope = worker_cancel_scope(reason, ending, self.turn_worker_generation);
3565 let mut registry = match self.runtime.subagent_registry().lock() {
3566 Ok(g) => g,
3567 Err(poisoned) => {
3568 tracing::warn!(
3569 "subagent registry mutex poisoned during abort; recovering to cancel running handles"
3570 );
3571 poisoned.into_inner()
3572 }
3573 };
3574 let cancelled = registry.cancel_running(scope);
3575 if !cancelled.is_empty() {
3576 tracing::info!(
3577 ?scope,
3578 workers = ?cancelled,
3579 "turn interrupted: cancelled running workers"
3580 );
3581 }
3582 if let WorkerCancelScope::Turn(_) = scope {
3583 let spared = registry
3586 .display_rows()
3587 .iter()
3588 .filter(|r| {
3589 r.status == crate::runtime::subagent::SubagentStatus::Running
3590 && !r.cancel_requested
3591 })
3592 .count();
3593 drop(registry);
3594 if spared > 0 {
3595 self.emit(SessionEventWire::Stream(StreamEvent::Session(
3596 SessionEvent::Notice(format!(
3597 "{spared} background worker{} from earlier turns still running — /workers to list, /workers cancel all to stop",
3598 if spared == 1 { "" } else { "s" }
3599 )),
3600 )));
3601 }
3602 }
3603 }
3604 if completed {
3605 self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
3606 } else {
3607 self.emit(SessionEventWire::Aborted {
3610 context_saved: kept_partial,
3611 });
3612 }
3613 self.emit_conversation();
3614 self.announce_idle().await;
3615 }
3616
3617 async fn finish_revoked_turn(&mut self, stream_closed: bool) {
3625 if !self.streaming {
3626 return;
3627 }
3628 let completed = stream_closed && self.turn_completed();
3629 self.stream = None;
3630 if !completed {
3631 self.keep_streamed_partial(crate::engine::interrupt::InterruptReason::Driver);
3632 }
3633 self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
3634 .await;
3635 }
3636
3637 fn turn_cancelled(&self) -> bool {
3640 self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
3641 }
3642
3643 fn resolve_pending_prompts(&mut self) {
3645 if self.pending_prompts.is_empty() {
3646 return;
3647 }
3648 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3649 let _ = tx.send(None);
3650 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3651 }
3652 self.publish_presence();
3653 self.rearm_prompt_abandon();
3654 }
3655
3656 async fn drain_cancelled_stream(&mut self) -> CancelDrain {
3675 let mut out = CancelDrain::default();
3676 let Some(mut stream) = self.stream.take() else {
3677 out.closed = true;
3678 return out;
3679 };
3680 let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
3681 loop {
3682 let event = tokio::select! {
3683 biased;
3684 Some(req) = self.secret_prompt_rx.recv() => {
3685 let _ = req.response_tx.send(None);
3686 continue;
3687 }
3688 next = tokio::time::timeout_at(deadline, stream.next()) => match next {
3689 Err(_) => break,
3690 Ok(None) => {
3691 out.closed = true;
3692 break;
3693 }
3694 Ok(Some(event)) => event,
3695 },
3696 };
3697 match event {
3698 StreamEvent::Session(SessionEvent::Done) => {
3699 out.closed = true;
3700 break;
3701 }
3702 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3703 out.history = Some(history);
3704 }
3705 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
3706 session_id,
3707 messages,
3708 receipt,
3709 }) => {
3710 self.handle_context_head_checkpoint(session_id, messages, receipt)
3711 .await;
3712 out.history = None;
3716 }
3717 StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
3718 crate::TurnOutcome::Canceled => {}
3719 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3720 self.emit(SessionEventWire::SystemNotice(err.message));
3721 }
3722 _ => {
3723 tracing::warn!(
3724 session = %self.id,
3725 category = err.category_label(),
3726 "cancelled turn reported an error while draining"
3727 );
3728 }
3729 },
3730 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3731 self.note_steering_delivered(message);
3732 self.emit(SessionEventWire::Stream(event));
3733 }
3734 StreamEvent::Session(SessionEvent::Usage { .. }) => {
3735 self.record_usage(&event);
3736 self.emit(SessionEventWire::Stream(event));
3737 }
3738 StreamEvent::Llm(ref llm) => {
3739 self.observe_turn_draft(llm);
3740 self.emit(SessionEventWire::Stream(event));
3741 }
3742 other => self.emit(SessionEventWire::Stream(other)),
3743 }
3744 }
3745 drop(stream);
3746 out
3747 }
3748
3749 fn note_steering_delivered(&mut self, message: &str) {
3751 if self.conv.queued_message.as_deref() == Some(message) {
3752 self.conv.queued_message = None;
3753 self.emit_conversation();
3754 }
3755 if let Some(driver) = self.driver.as_mut() {
3757 if driver.steering.front().map(String::as_str) == Some(message) {
3758 driver.steering.pop_front();
3759 }
3760 }
3761 if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
3762 self.turn_steered_events.remove(pos);
3763 }
3764 }
3765
3766 fn record_usage(&mut self, event: &StreamEvent) {
3768 if let StreamEvent::Session(SessionEvent::Usage {
3769 input_tokens,
3770 output_tokens,
3771 cache_read_input_tokens,
3772 cache_creation_input_tokens,
3773 cache_creation_5m,
3774 cache_creation_1h,
3775 model: usage_model,
3776 }) = event
3777 {
3778 let model_for_pricing = usage_model
3779 .as_deref()
3780 .unwrap_or(self.runtime.model())
3781 .to_string();
3782 self.cache_keepwarm_note_usage(crate::runtime::keepwarm::KeepWarmUsage {
3783 input_tokens: *input_tokens,
3784 output_tokens: *output_tokens,
3785 cache_read_tokens: *cache_read_input_tokens,
3786 cache_write_tokens: *cache_creation_input_tokens,
3787 cache_write_5m: *cache_creation_5m,
3788 cache_write_1h: *cache_creation_1h,
3789 model: usage_model.clone(),
3790 });
3791 self.conv.add_usage(
3792 *input_tokens,
3793 *output_tokens,
3794 *cache_read_input_tokens,
3795 *cache_creation_input_tokens,
3796 *cache_creation_5m,
3797 *cache_creation_1h,
3798 &model_for_pricing,
3799 );
3800 }
3801 }
3802
3803 async fn on_queue_wake(&mut self) {
3806 if self.holds(WorkSource::QueueWake) {
3807 return;
3809 }
3810 if self.is_parked() {
3811 if self.event_queue.is_empty() {
3812 return;
3813 }
3814 if let Err(e) = self.ensure_live().await {
3815 tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
3816 return;
3817 }
3818 }
3819 let busy = self.busy();
3820 let conv: &mut ConversationState = &mut self.conv;
3821 let drained = drain_event_queue(
3822 &self.event_queue,
3823 &mut conv.api_messages,
3824 &mut conv.pending_events,
3825 busy,
3826 self.steer_tx.as_ref(),
3827 );
3828 if drained.is_empty() {
3829 return;
3830 }
3831 for de in &drained {
3832 if de.disposition == EventDisposition::Steered {
3833 self.turn_steered_events.push(de.formatted.clone());
3834 }
3835 self.emit(SessionEventWire::External(de.event.clone()));
3836 }
3837 let injected = drained
3838 .iter()
3839 .any(|d| d.disposition == EventDisposition::Injected);
3840 if injected || busy {
3841 self.emit_conversation();
3842 }
3843
3844 if self.streaming
3847 && self
3848 .driver
3849 .as_ref()
3850 .is_some_and(|d| d.awaiting_terminal)
3851 {
3852 let competing = drained
3853 .iter()
3854 .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
3855 if competing {
3856 self.driver_revoke("event-bus work took priority");
3857 }
3858 }
3859
3860 let auto_turn_enabled = self.host.config().events.auto_turn;
3865 let auto_turn_cap = self.host.config().events.auto_turn_cap;
3866 let action = wake_action_with_cap(
3867 &drained,
3868 &self.conv.api_messages,
3869 busy,
3870 auto_turn_enabled,
3871 self.consecutive_auto_turns,
3872 auto_turn_cap,
3873 );
3874 match action {
3875 WakeAction::RunTurn => {
3876 if self.driver.is_some() {
3879 tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
3880 } else if self.streaming || self.stream.is_some() {
3881 tracing::warn!("handle_event_arm: RunTurn with active turn — skipping");
3882 } else {
3883 self.consecutive_auto_turns += 1;
3884 self.start_turn(TurnTrigger::EventAuto, None).await;
3885 }
3886 }
3887 WakeAction::Forward => {
3888 let hit_cap = injected
3889 && !busy
3890 && auto_turn_enabled
3891 && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
3892 if hit_cap {
3893 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3894 }
3895 }
3896 WakeAction::Nothing => {}
3897 }
3898 }
3899
3900 async fn on_stream_event(&mut self, event: StreamEvent) {
3903 if self.turn_cancelled() {
3907 match event {
3908 StreamEvent::Session(SessionEvent::Done) => {
3909 self.finish_revoked_turn(true).await;
3910 return;
3911 }
3912 StreamEvent::Session(SessionEvent::Error(err)) => {
3914 match err.outcome {
3915 crate::TurnOutcome::Canceled => {}
3916 crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3917 self.emit(SessionEventWire::SystemNotice(err.message));
3918 }
3919 _ => tracing::warn!(
3920 session = %self.id,
3921 category = err.category_label(),
3922 "revoked turn reported an error"
3923 ),
3924 }
3925 return;
3926 }
3927 _ => {}
3928 }
3929 }
3930 if !matches!(
3935 event,
3936 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
3937 ) {
3938 self.emit(SessionEventWire::Stream(event.clone()));
3939 }
3940
3941 if let Some(driver) = self.driver.as_mut() {
3943 if driver.awaiting_terminal && driver.grant.feedback_enabled() {
3944 driver.feedback.observe(&event);
3945 }
3946 }
3947
3948 let is_canceled = self
3950 .cancel
3951 .as_ref()
3952 .is_some_and(|ct| ct.is_cancelled());
3953 let terminal = if self
3954 .driver
3955 .as_ref()
3956 .is_some_and(|d| d.awaiting_terminal)
3957 {
3958 super::driver::capture_terminal(Some(&event), is_canceled)
3959 } else {
3960 None
3961 };
3962
3963 enum After {
3964 Continue,
3965 AutoSendQueued(String),
3966 AutoTriggerEvents,
3967 Failed,
3968 }
3969 let mut after = After::Continue;
3970
3971 match event {
3972 StreamEvent::Llm(ref llm) => self.observe_turn_draft(llm),
3975 StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3976 self.conv.api_messages = history;
3982 self.request_save();
3983 self.turn_draft_committed();
3985 self.trim_replay_to_checkpoint();
3986 self.emit_conversation();
3987 }
3988 StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3989 self.note_steering_delivered(message);
3990 }
3991 StreamEvent::Agent(_) => {}
3992 StreamEvent::Session(SessionEvent::Usage { .. }) => {
3993 self.record_usage(&event);
3994 if let Some((scope, cost, cap)) = self.cost_cap_breach() {
3999 self.emit(SessionEventWire::CostCapReached {
4000 scope: scope.to_string(),
4001 cost,
4002 cap,
4003 });
4004 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
4005 if self.driver.is_some() || self.driver_pending.is_some() {
4006 self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
4007 }
4008 self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
4009 .await;
4010 return;
4011 }
4012 }
4013 StreamEvent::Session(SessionEvent::Notice(_) | SessionEvent::Retry(_)) => {}
4014 StreamEvent::Session(SessionEvent::Done) => {
4015 self.clear_stream();
4016 self.publish_subagent_rows();
4017 let had_pending = !self.conv.pending_events.is_empty();
4019 {
4020 let conv: &mut ConversationState = &mut self.conv;
4021 for formatted in conv.pending_events.drain(..) {
4022 conv.api_messages
4023 .push(std::sync::Arc::new(serde_json::json!({
4024 "role": "user",
4025 "content": formatted
4026 })));
4027 }
4028 }
4029 if let Some(queued) = self.conv.queued_message.take() {
4030 after = After::AutoSendQueued(queued);
4031 } else if had_pending {
4032 self.request_save();
4033 after = After::AutoTriggerEvents;
4034 }
4035 self.emit_conversation();
4036 if matches!(after, After::Continue) {
4037 if self.config.auto_compact {
4038 self.post_turn_chat().await;
4039 }
4040 if self.compact.is_none() {
4042 self.announce_idle().await;
4043 }
4044 }
4045 }
4046 StreamEvent::Session(SessionEvent::Error(_)) => {
4047 crate::engine::stream::repair_history_after_failure(
4050 &mut self.conv.api_messages,
4051 self.turn_baseline,
4052 );
4053 self.clear_stream();
4054 self.publish_subagent_rows();
4055 self.emit_conversation();
4056 after = After::Failed;
4057 }
4058 StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
4059 session_id,
4060 messages,
4061 receipt,
4062 }) => {
4063 self.handle_context_head_checkpoint(session_id, messages, receipt)
4064 .await;
4065 }
4066 }
4067
4068 match after {
4069 After::Continue => {}
4070 After::Failed => {
4071 if self.config.auto_compact {
4072 self.post_turn_chat().await;
4073 }
4074 if self.compact.is_none() {
4075 self.announce_idle().await;
4076 }
4077 }
4078 After::AutoSendQueued(queued) => {
4079 if self.config.auto_compact {
4080 self.post_turn_chat().await;
4081 }
4082 if self.compact.is_some() {
4083 self.conv.queued_message = Some(queued);
4086 return;
4087 }
4088 self.consecutive_auto_turns = 0;
4090 let user_text = queued.clone();
4091 self.conv.api_messages.push(std::sync::Arc::new(
4092 serde_json::json!({"role": "user", "content": queued}),
4093 ));
4094 self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
4095 }
4096 After::AutoTriggerEvents => {
4097 if self.config.auto_compact {
4098 self.post_turn_chat().await;
4099 }
4100 if self.compact.is_some() {
4101 return;
4102 }
4103 let auto_turn_cap = self.host.config().events.auto_turn_cap;
4106 if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
4107 self.start_turn(TurnTrigger::EventAuto, None).await;
4108 } else {
4109 self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
4110 self.announce_idle().await;
4111 }
4112 }
4113 }
4114
4115 if let Some(terminal) = terminal {
4117 let revoke_reason = if let Some(driver) = self.driver.as_mut() {
4118 super::driver::observe_terminal(driver, &self.runtime, terminal)
4119 } else {
4120 None
4121 };
4122 if let Some(driver) = self.driver.as_ref() {
4124 if let Some((outcome, _)) = &driver.outcome {
4125 self.emit(SessionEventWire::DriverTurnOutcome {
4126 outcome: *outcome,
4127 selection: driver.selection.clone(),
4128 feedback: driver.grant.feedback_enabled().then(|| {
4129 driver.completed_feedback.to_string()
4130 }),
4131 });
4132 }
4133 }
4134 if let Some(reason) = revoke_reason {
4135 self.driver_revoke(&reason);
4136 }
4137 }
4138 }
4139
4140 async fn post_turn_chat(&mut self) {
4142 self.request_save();
4143 let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
4144 if assessment.should_compact() {
4145 self.emit(SessionEventWire::SystemNotice(format!(
4146 "[auto-compacting ~{} tokens...]",
4147 assessment.used_tokens()
4148 )));
4149 self.compact(None, "auto").await;
4150 }
4151 }
4152
4153 fn compact_inline_env() -> bool {
4156 matches!(
4157 std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
4158 Ok("1") | Ok("true")
4159 )
4160 }
4161
4162 pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
4169 self.cache_keepwarm_compaction();
4171 if Self::compact_inline_env() {
4172 return self.compact_inline(instructions, source).await;
4173 }
4174 if self.compact.is_some() {
4175 self.emit(SessionEventWire::SystemNotice(
4176 "compaction already in progress".into(),
4177 ));
4178 return;
4179 }
4180 if self.streaming {
4181 self.emit(SessionEventWire::SystemNotice(
4182 "cannot compact while a turn is running".into(),
4183 ));
4184 return;
4185 }
4186 let disclosure =
4187 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
4188 self.emit(SessionEventWire::CompactionStarted {
4189 source: source.to_string(),
4190 disclosure,
4191 });
4192 let msgs = self.conv.api_messages.clone();
4193 let rt: Runtime = (*self.runtime).clone();
4194 let task = tokio::spawn(async move {
4195 compact_conversation(&msgs, &rt, instructions.as_deref()).await
4196 });
4197 self.compact = Some(CompactionJob {
4198 task,
4199 source: source.to_string(),
4200 started: tokio::time::Instant::now(),
4201 });
4202 self.update_attach_state();
4203 }
4204
4205 async fn on_compaction_done(
4209 &mut self,
4210 res: std::result::Result<
4211 Result<crate::runtime::compaction::CompactionOutcome>,
4212 tokio::task::JoinError,
4213 >,
4214 ) {
4215 let Some(job) = self.compact.take() else {
4216 return;
4217 };
4218 let msg_count = self.conv.api_messages.len();
4219 match res {
4220 Err(join) => {
4221 if !join.is_cancelled() {
4222 self.emit(SessionEventWire::CompactionFailed {
4223 message: join.to_string(),
4224 panicked: true,
4225 });
4226 }
4227 }
4228 Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
4229 message: e.to_string(),
4230 panicked: false,
4231 }),
4232 Ok(Ok(outcome)) => {
4233 if !self.persister.flush().await {
4237 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4238 }
4239 let policy: CompactionPolicy = self.config.compaction_policy.into();
4240 let queued = self.conv.queued_message.clone();
4241 let applied = apply_compaction(
4242 &self.runtime,
4243 &self.conv.session,
4244 &self.conv.api_messages,
4245 &outcome,
4246 CompactionTransition {
4247 policy,
4248 pending_events: self.conv.pending_events.clone(),
4249 queued_message: queued.clone(),
4250 hook_source: job.source.clone(),
4251 },
4252 )
4253 .await;
4254 match applied {
4255 Ok(applied) => {
4256 let previous = applied.previous_session_id.clone();
4257 self.conv.session = applied.session;
4258 self.conv.api_messages = applied.api_messages;
4259 self.conv.pending_events.clear();
4260 self.conv.queued_message = None;
4261 if policy == CompactionPolicy::LinkedSuccessor {
4262 self.conv.total_input_tokens = 0;
4263 self.conv.total_output_tokens = 0;
4264 self.conv.session_cost = 0.0;
4265 self.conv.keepwarm_cost = 0.0;
4270 self.conv.keepwarm_tokens.input = 0;
4271 self.conv.keepwarm_tokens.output = 0;
4272 }
4273 let new_id = self.conv.session.id.clone();
4274 self.runtime.set_session_id(Some(new_id.clone()));
4275 self.set_journal_id(new_id.clone());
4276 self.reacquire_session_lock(&new_id);
4278 self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
4282 self.runtime
4283 .reset_context_continuation(&new_id, &self.conv.api_messages);
4284 self.emit(SessionEventWire::CompactionApplied {
4285 previous_session_id: previous,
4286 session_id: new_id,
4287 chains_advanced: applied.chains_advanced,
4288 queued_restored: queued,
4289 msg_count,
4290 });
4291 }
4292 Err(e) => self.emit(SessionEventWire::CompactionFailed {
4293 message: e.to_string(),
4294 panicked: false,
4295 }),
4296 }
4297 }
4298 }
4299 self.emit_conversation();
4300 self.update_attach_state();
4301 self.announce_idle().await;
4302 }
4303
4304 async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
4306 self.compacting_inline = true;
4307 self.publish_presence();
4308 self.compact_inline_inner(instructions, source).await;
4309 self.compacting_inline = false;
4310 self.publish_presence();
4311 }
4312
4313 async fn compact_inline_inner(&mut self, instructions: Option<String>, source: &str) {
4314 self.emit(SessionEventWire::SystemNotice(format!(
4315 "[{}]",
4316 preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
4317 )));
4318 let outcome =
4319 compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
4320 .await;
4321 let applied = match outcome {
4322 Ok(outcome) => {
4323 if !self.persister.flush().await {
4325 tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4326 }
4327 apply_compaction(
4328 &self.runtime,
4329 &self.conv.session,
4330 &self.conv.api_messages,
4331 &outcome,
4332 CompactionTransition {
4333 policy: CompactionPolicy::InPlace,
4334 pending_events: Vec::new(),
4335 queued_message: None,
4336 hook_source: source.to_string(),
4337 },
4338 )
4339 .await
4340 }
4341 Err(e) => Err(e),
4342 };
4343 match applied {
4344 Ok(applied) => {
4345 self.conv.session = applied.session;
4346 self.conv.api_messages = applied.api_messages;
4347 let after = self.runtime.assess_context(&self.conv.api_messages).await;
4348 self.emit(SessionEventWire::SystemNotice(format!(
4349 "[compacted → ~{} tokens]",
4350 after.used_tokens()
4351 )));
4352 }
4353 Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
4354 "[compaction failed: {}]",
4355 e
4356 ))),
4357 }
4358 self.emit_conversation();
4359 }
4360
4361 fn on_prompt_request(&mut self, req: SecretPromptRequest) {
4364 let id = self.next_prompt_id;
4365 self.next_prompt_id += 1;
4366 let pr = PromptRequest {
4367 id,
4368 kind: PromptKind::from_title(&req.title),
4369 title: req.title,
4370 prompt: req.prompt,
4371 raised_at: chrono::Utc::now(),
4372 confirm_scope: req.confirm_scope,
4373 };
4374 self.pending_prompts.push_back((pr.clone(), req.response_tx));
4375 self.publish_presence();
4376 self.emit(SessionEventWire::Prompt(pr));
4377 self.rearm_prompt_abandon();
4380 }
4381
4382 fn confirm_answer_refusal(
4387 &self,
4388 from: Option<ClientId>,
4389 prompt: &PromptRequest,
4390 allow_all: bool,
4391 ) -> Option<&'static str> {
4392 let c = from?;
4393 if prompt.kind != PromptKind::Confirm {
4394 return None;
4395 }
4396 if self.attached.get(&c).map(|a| a.mode) == Some(AttachMode::Observe) {
4397 return Some("observe-mode clients cannot answer confirm prompts");
4398 }
4399 if allow_all && self.input_owner != Some(c) {
4400 return Some("only the input owner can choose Allow all");
4401 }
4402 None
4403 }
4404
4405 fn answer(&mut self, from: Option<ClientId>, prompt_id: u64, value: Option<String>) {
4406 let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
4407 return; };
4409 let allow_all = value.as_deref().is_some_and(|v| {
4410 v.trim()
4411 .eq_ignore_ascii_case(crate::tools::CONFIRM_ANSWER_ALLOW_ALL)
4412 });
4413 if let Some(c) = from {
4417 self.note_input(c);
4418 }
4419 if let Some(reason) = self.confirm_answer_refusal(from, &self.pending_prompts[pos].0, allow_all) {
4420 if let Some(c) = from {
4421 self.emit(SessionEventWire::Refused {
4422 client: c,
4423 command: "answer".to_string(),
4424 reason: reason.to_string(),
4425 });
4426 }
4427 return; }
4429 let approved = allow_all
4430 || value
4431 .as_deref()
4432 .map(str::trim)
4433 .is_some_and(|v| v.eq_ignore_ascii_case("y") || v.eq_ignore_ascii_case("yes"));
4434 let (prompt, tx) = self.pending_prompts.remove(pos).expect("position");
4435 let _ = tx.send(value);
4436 self.publish_presence();
4437 self.emit(SessionEventWire::PromptResolved { prompt_id });
4438 let by = match from {
4439 Some(c) => self.client_label(c),
4440 None => "the host".to_string(),
4441 };
4442 if approved && prompt.title == CONFIRM_PROMPT_TITLE {
4446 if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.rewritten_by.is_empty()) {
4447 self.emit(SessionEventWire::SystemNotice(format!(
4448 "Approved by {by}: {} runs with input rewritten by {} (not the input shown \
4449 on its tool card; the approval prompt showed what runs).",
4450 scope.tool.as_deref().unwrap_or("the tool call"),
4451 scope.rewritten_by.join(", ")
4452 )));
4453 }
4454 }
4455 if allow_all && prompt.title == CONFIRM_PROMPT_TITLE {
4458 if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.extensions.is_empty()) {
4459 self.emit(SessionEventWire::SystemNotice(format!(
4460 "Allow all armed by {by}: tool-call confirms from {} are auto-approved \
4461 until /new. Other extensions still ask. /confirms reset revokes.",
4462 scope.extensions_label()
4463 )));
4464 }
4465 }
4466 self.rearm_prompt_abandon();
4468 }
4469
4470 pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
4473 if replayable(&setting) {
4474 let disc = std::mem::discriminant(&setting);
4475 self.settings_replay
4476 .retain(|s| std::mem::discriminant(s) != disc);
4477 self.settings_replay.push(setting.clone());
4478 }
4479 let name = match &setting {
4480 SessionSetting::Model { .. } => "model",
4481 SessionSetting::ReasoningLevel { .. } => "reasoning_level",
4482 SessionSetting::ContextWindow { .. } => "context_window",
4483 SessionSetting::CompactionModel { .. } => "compaction_model",
4484 SessionSetting::ApiRetries { .. } => "api_retries",
4485 SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
4486 SessionSetting::MaxToolOutput { .. } => "max_tool_output",
4487 SessionSetting::BashTimeout { .. } => "bash_timeout",
4488 SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
4489 SessionSetting::SystemPrompt { .. } => "system_prompt",
4490 SessionSetting::ReloadPrompt => "reload_prompt",
4491 SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
4492 };
4493 match &setting {
4496 SessionSetting::Model { model } => self.runtime.prepare_model_capabilities(model).await,
4497 SessionSetting::ReasoningLevel { .. } => {
4498 let model = self.runtime.model().to_string();
4499 self.runtime.prepare_model_capabilities(&model).await;
4500 }
4501 _ => {}
4502 }
4503 let rt = &mut self.runtime;
4504 let mut clamp_wire = None;
4505 let result: std::result::Result<Option<String>, String> = match setting {
4506 SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
4507 self.conv.session.model = rt.model().to_string();
4508 clamp.map(|c| {
4509 self.conv.session.thinking_level = rt.thinking_level().to_string();
4510 clamp_wire = Some(ReasoningClampWire {
4511 from: c.from.as_str().to_string(),
4512 to: c.to.as_str().to_string(),
4513 });
4514 format!(
4515 "thinking → {} (clamped from {}: not supported by {})",
4516 c.to.as_str(),
4517 c.from.as_str(),
4518 rt.model()
4519 )
4520 })
4521 }),
4522 SessionSetting::ReasoningLevel { level } => {
4523 rt.set_reasoning_level_checked(level).map(|_| {
4524 self.conv.session.thinking_level = rt.thinking_level().to_string();
4525 None
4526 })
4527 }
4528 SessionSetting::ContextWindow { tokens } => {
4529 rt.set_context_window(tokens);
4530 Ok(None)
4531 }
4532 SessionSetting::CompactionModel { model } => {
4533 rt.set_compaction_model(model);
4534 Ok(None)
4535 }
4536 SessionSetting::ApiRetries { n } => {
4537 rt.set_api_retries(n);
4538 Ok(None)
4539 }
4540 SessionSetting::SubagentTimeout { secs } => {
4541 rt.set_subagent_timeout(secs);
4542 Ok(None)
4543 }
4544 SessionSetting::MaxToolOutput { bytes } => {
4545 rt.set_max_tool_output(bytes);
4546 Ok(None)
4547 }
4548 SessionSetting::BashTimeout { secs } => {
4549 rt.set_bash_timeout(secs);
4550 Ok(None)
4551 }
4552 SessionSetting::BashMaxTimeout { secs } => {
4553 rt.set_bash_max_timeout(secs);
4554 Ok(None)
4555 }
4556 SessionSetting::SystemPrompt { text } => {
4557 rt.set_system_prompt(text);
4558 Ok(None)
4559 }
4560 SessionSetting::ReloadPrompt => rt
4561 .reload_prompt()
4562 .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
4563 .map_err(|e| e.to_string()),
4564 SessionSetting::GrantWorkerModel { model } => {
4565 rt.grant_worker_model(&model).map(|_| None)
4566 }
4567 };
4568 self.publish_view().await;
4569 let view = (**self.view.load()).clone();
4570 let (ok, message) = match result {
4571 Ok(m) => (true, m),
4572 Err(e) => (false, Some(e)),
4573 };
4574 self.emit(SessionEventWire::SettingChanged(SettingApplied {
4575 id,
4576 setting: name.to_string(),
4577 ok,
4578 message,
4579 view,
4580 clamp: clamp_wire,
4581 }));
4582 }
4583
4584 pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
4585 if self.is_parked() {
4587 let value = match query {
4588 SessionQuery::Status => serde_json::json!({
4589 "session": (**self.journal_id.load()).clone(),
4590 "model": self.view.load().model,
4591 "lifecycle": SessionLifecycle::Parked,
4592 "streaming": false,
4593 "auto_turns": self.consecutive_auto_turns,
4594 "attached": 0,
4595 "pending_prompts": 0,
4596 "pinned": self.keep_warm,
4597 }),
4598 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4599 SessionQuery::SubagentWatch { handle_id, .. } => {
4601 serde_json::json!({ "error": format!("no subagent {handle_id} in this session") })
4602 }
4603 _ => match self.ensure_live().await {
4604 Ok(()) => return Box::pin(self.query(id, query)).await,
4605 Err(e) => serde_json::json!({ "error": e }),
4606 },
4607 };
4608 self.emit(SessionEventWire::QueryResult { id, value });
4609 return;
4610 }
4611 let value = match query {
4612 SessionQuery::Status => {
4613 let cache_ttl = self.cache_ttl_status_json().await;
4615 serde_json::json!({
4616 "session": self.conv.session.id,
4617 "cache_ttl": cache_ttl,
4618 "model": self.runtime.model(),
4619 "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
4620 "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
4621 "cost": self.conv.session_cost,
4622 "keepwarm_cost": self.conv.keepwarm_cost,
4624 "keepwarm": self.cache_keepwarm_status_json(None),
4625 "messages": self.conv.api_messages.len(),
4626 "streaming": self.streaming,
4627 "auto_turns": self.consecutive_auto_turns,
4628 "attached": self.attached.len(),
4629 "pending_prompts": self.pending_prompts.len(),
4630 "pinned": self.keep_warm,
4632 })
4633 }
4634 SessionQuery::Messages => {
4635 serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
4636 }
4637 SessionQuery::DisplayTail { items } => {
4638 serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
4639 .unwrap_or_default()
4640 }
4641 SessionQuery::SubagentRows => {
4642 let rows = self
4643 .runtime
4644 .subagent_registry()
4645 .lock()
4646 .unwrap_or_else(|p| p.into_inner())
4647 .display_rows();
4648 serde_json::Value::Array(
4649 rows.iter()
4650 .map(|r| {
4651 serde_json::json!({
4652 "subagent_id": r.subagent_id,
4653 "agent_name": r.agent_name,
4654 "status": format!("{:?}", r.status),
4655 "cancel_requested": r.cancel_requested,
4656 "elapsed_secs": r.elapsed_secs,
4657 })
4658 })
4659 .collect(),
4660 )
4661 }
4662 SessionQuery::SubagentWatch { handle_id, since, tool_queue } => {
4663 let mut registry = self
4664 .runtime
4665 .subagent_registry()
4666 .lock()
4667 .unwrap_or_else(|p| p.into_inner());
4668 match registry.watch_page(&handle_id, since) {
4669 Some(page) if !tool_queue => {
4672 serde_json::to_value(page.without_tool_queue()).unwrap_or_default()
4673 }
4674 Some(page) => serde_json::to_value(page).unwrap_or_default(),
4675 None => serde_json::json!({
4676 "error": format!("no subagent {handle_id} in this session")
4677 }),
4678 }
4679 }
4680 SessionQuery::PromptInspection => {
4681 serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
4682 }
4683 SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
4684 SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4685 SessionQuery::ContextAssessment => {
4686 let a = self.runtime.assess_context(&self.conv.api_messages).await;
4687 serde_json::json!({
4688 "used_tokens": a.used_tokens(),
4689 "budget_tokens": a.budget_tokens(),
4690 "provider_window": a.provider_window,
4691 "should_compact": a.should_compact(),
4692 })
4693 }
4694 SessionQuery::ContextReport => {
4695 use crate::engine::commands::{context_command, CommandResult};
4696 match context_command(&self.runtime, Some(&self.conv.api_messages)) {
4697 CommandResult::Output(text) => serde_json::json!({ "text": text }),
4698 other => serde_json::json!({ "unsupported": format!("{other:?}") }),
4699 }
4700 }
4701 };
4702 self.emit(SessionEventWire::QueryResult { id, value });
4703 }
4704
4705 pub(crate) fn snapshot(&self) -> AttachSnapshot {
4708 AttachSnapshot {
4709 meta: self.meta.clone(),
4710 view: (**self.view.load()).clone(),
4711 conversation: self.conv.snapshot(self.consecutive_auto_turns),
4712 streaming: self.streaming,
4713 replay: self.turn_replay.iter().cloned().collect(),
4714 pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
4715 clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
4716 roster: self.roster(),
4717 client_input: self.client_input,
4718 input_owner: self.input_owner,
4719 display_tail: None,
4720 }
4721 }
4722
4723 pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
4728 let mut snap = self.snapshot();
4729 if client.history == HistoryMode::Digest {
4730 snap.display_tail = Some(super::display::display_tail(
4731 &snap.conversation.api_messages,
4732 client.tail_items,
4733 ));
4734 snap.conversation.api_messages = Vec::new();
4735 snap.replay.retain(|e| {
4736 !matches!(
4737 e.event,
4738 SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
4739 )
4740 });
4741 }
4742 snap
4743 }
4744
4745 async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
4750 if let Err(e) = self.ensure_live().await {
4751 self.emit(SessionEventWire::AttachRefused { message: e });
4752 return;
4753 }
4754 let cid = ClientId(self.next_client_id);
4755 self.next_client_id += 1;
4756 let kind = client.kind;
4757 self.cache_keepwarm_note_attach(kind, mode);
4758 let snapshot_meta = client.clone();
4759 let name = resolve_client_name(
4760 client.name.as_deref(),
4761 kind,
4762 self.attached.values().map(|a| a.name.as_str()),
4763 );
4764 let attached_at_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
4765 self.attached.insert(
4766 cid,
4767 AttachedClient { meta: client, mode, name: name.clone(), attached_at_ms, last_input: 0 },
4768 );
4769 self.update_attach_state();
4770 let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
4771 match mode {
4772 AttachMode::Observe => {}
4773 AttachMode::Mirror => {
4777 if self.input_owner.is_none() {
4778 owner_change = Some((None, OwnerChangeReason::Attach));
4779 }
4780 }
4781 AttachMode::Takeover => {
4782 let reason = if self.input_owner.is_some() {
4783 OwnerChangeReason::Takeover
4784 } else {
4785 OwnerChangeReason::Attach
4786 };
4787 owner_change = Some((self.input_owner, reason));
4788 }
4789 }
4790 if let Some((from, reason)) = owner_change {
4791 self.input_owner = Some(cid);
4792 self.emit(SessionEventWire::InputOwnerChanged {
4793 from,
4794 to: Some(cid),
4795 reason,
4796 });
4797 }
4798 self.publish_presence();
4799 let snapshot = self.snapshot_for(&snapshot_meta);
4800 self.emit(SessionEventWire::Attached {
4801 client: cid,
4802 snapshot,
4803 });
4804 self.emit(SessionEventWire::ClientJoined { client: cid, kind, name, mode });
4805 }
4806
4807 fn take_input(&mut self, from: Option<ClientId>) {
4813 let Some(c) = from else { return };
4814 let Some(mode) = self.attached.get(&c).map(|a| a.mode) else { return };
4815 if mode == AttachMode::Observe {
4816 self.emit(SessionEventWire::Refused {
4817 client: c,
4818 command: command_name(&SessionCommand::TakeInput).to_string(),
4819 reason: "this client attached with --observe and never owns input; \
4820 attach without --observe to take it"
4821 .to_string(),
4822 });
4823 return;
4824 }
4825 if self.input_owner == Some(c) {
4826 return;
4827 }
4828 let from = self.input_owner;
4829 let reason = if from.is_some() {
4830 OwnerChangeReason::Takeover
4831 } else {
4832 OwnerChangeReason::Attach
4833 };
4834 self.input_owner = Some(c);
4835 self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason });
4836 self.publish_presence();
4837 self.note_input(c); }
4839
4840 fn detach(&mut self, client: ClientId) {
4844 if self.attached.remove(&client).is_none() {
4845 return;
4846 }
4847 self.update_attach_state();
4848 self.emit(SessionEventWire::ClientLeft { client });
4849 if self.input_owner == Some(client) {
4850 let shared = self.client_input == ClientInput::Shared;
4854 let next = self
4855 .attached
4856 .iter()
4857 .filter(|(_, a)| a.mode != AttachMode::Observe)
4858 .max_by_key(|(c, a)| (if shared { a.last_input } else { 0 }, std::cmp::Reverse(c.0)))
4859 .map(|(c, _)| *c);
4860 self.input_owner = next;
4861 self.emit(SessionEventWire::InputOwnerChanged {
4862 from: Some(client),
4863 to: next,
4864 reason: OwnerChangeReason::OwnerDetached,
4865 });
4866 self.publish_presence();
4867 }
4868 if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
4885 self.driver_revoke("no clients attached");
4886 let had_prompts = !self.pending_prompts.is_empty();
4887 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4888 let _ = tx.send(None);
4889 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4890 }
4891 if had_prompts {
4892 self.publish_presence();
4893 }
4894 }
4895 }
4896
4897 pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
4902 let interrupt = match reason {
4903 CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
4904 CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
4905 };
4906 self.note_cancel_cause(interrupt);
4908 if self.driver.is_some() {
4910 self.driver_revoke("daemon reloaded");
4911 }
4912 let reload = matches!(reason, CheckpointReason::Reload);
4918 if reload {
4919 self.stop_workers_for_reload();
4920 }
4921 if self.streaming {
4922 self.cancel_turn(interrupt).await;
4923 }
4924 let stopping = if reload { self.stop_workers_for_reload() } else { Vec::new() };
4927 let stopped_workers = await_stopped_workers(self.id.as_str(), stopping).await;
4928 self.abort_compaction();
4929 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4930 let _ = tx.send(None);
4931 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4932 }
4933 if !self.save().await {
4936 tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
4937 }
4938 let notice = match reason {
4939 CheckpointReason::Reload => {
4940 "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
4941 }
4942 CheckpointReason::HostRequest => {
4943 "checkpoint — background shells/PTYs closed; the turn was checkpointed"
4944 }
4945 };
4946 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
4947 if !stopped_workers.is_empty() {
4948 self.emit(SessionEventWire::SystemNotice(format!(
4949 "daemon reloading — stopped worker{} {} (archived; resumable after the reload)",
4950 if stopped_workers.len() == 1 { "" } else { "s" },
4951 stopped_workers.join(", ")
4952 )));
4953 }
4954 self.runtime.session_manager().shutdown_all();
4955 let mut record = self.reload_record();
4956 if reload {
4957 record.workers = self.reload_carried_workers();
4958 self.reload_fence = Some(ReloadFence {
4961 deadline: tokio::time::Instant::now() + budgets::RELOAD_FENCE_TIMEOUT,
4962 workers: record.workers.clone(),
4963 });
4964 }
4965 let record = serde_json::to_value(record).unwrap_or_default();
4966 self.emit(SessionEventWire::QueryResult {
4967 id: super::wire::CHECKPOINT_QUERY_ID,
4968 value: serde_json::json!({ "ok": true, "record": record }),
4969 });
4970 }
4971
4972 pub(crate) fn reload_carried_workers(&self) -> Vec<crate::runtime::subagent::CarriedWorker> {
4976 let undelivered: std::collections::HashSet<String> = self
4977 .event_queue
4978 .snapshot()
4979 .iter()
4980 .filter(|e| e.content.content_type == "subagent_completion")
4981 .filter_map(|e| {
4982 e.content
4983 .data
4984 .as_ref()?
4985 .get("handle_id")?
4986 .as_str()
4987 .map(str::to_string)
4988 })
4989 .collect();
4990 self.runtime
4991 .subagent_registry()
4992 .lock()
4993 .unwrap_or_else(|p| p.into_inner())
4994 .reload_carry(&undelivered)
4995 }
4996
4997 pub(crate) fn restore_tool_activations(
5003 &mut self,
5004 activations: Vec<crate::tools::activation::CarriedActivation>,
5005 ) {
5006 if activations.is_empty() {
5007 return;
5008 }
5009 tracing::info!(
5010 session = %self.id,
5011 count = activations.len(),
5012 "reload: tool activations handed over from the previous image"
5013 );
5014 if self.runtime.is_live() {
5015 self.runtime.restore_tool_activations(activations);
5016 } else {
5017 for a in activations {
5018 if !self.parked_tool_activations.iter().any(|p| p.tool_id == a.tool_id) {
5019 self.parked_tool_activations.push(a);
5020 }
5021 }
5022 }
5023 }
5024
5025 pub(crate) fn adopt_workers(&mut self, workers: Vec<crate::runtime::subagent::CarriedWorker>) {
5031 self.adopt_carried_workers(workers, false);
5032 }
5033
5034 fn adopt_carried_workers(
5037 &mut self,
5038 workers: Vec<crate::runtime::subagent::CarriedWorker>,
5039 failed_reload: bool,
5040 ) {
5041 if workers.is_empty() {
5042 return;
5043 }
5044 {
5045 let mut registry = self
5046 .runtime
5047 .subagent_registry()
5048 .lock()
5049 .unwrap_or_else(|p| p.into_inner());
5050 for w in &workers {
5051 registry.adopt_carried(w);
5052 if w.needs_wake() {
5053 registry.note_reload_wake(w);
5056 }
5057 }
5058 }
5059 let woken: Vec<&crate::runtime::subagent::CarriedWorker> =
5060 workers.iter().filter(|w| w.needs_wake()).collect();
5061 for w in &woken {
5062 let ev = crate::tools::build_reload_carry_event(w);
5063 if self.event_queue.push(ev.clone()).is_err() {
5064 self.event_queue.push_priority(ev);
5065 }
5066 }
5067 let stopped: Vec<&str> = woken
5068 .iter()
5069 .filter(|w| w.stopped_by_reload)
5070 .map(|w| w.id.as_str())
5071 .collect();
5072 tracing::info!(
5073 session = %self.id,
5074 carried = workers.len(),
5075 stopped = ?stopped,
5076 woken = woken.len(),
5077 "reload: adopted workers from the previous image"
5078 );
5079 if !stopped.is_empty() {
5080 self.emit(SessionEventWire::SystemNotice(format!(
5081 "{} — worker{} {} stopped by the reload; subagent_resume continues {} from {} archive",
5082 if failed_reload { "daemon reload failed" } else { "daemon reloaded" },
5083 if stopped.len() == 1 { "" } else { "s" },
5084 stopped.join(", "),
5085 if stopped.len() == 1 { "it" } else { "them" },
5086 if stopped.len() == 1 { "its" } else { "their" },
5087 )));
5088 }
5089 }
5090
5091 fn stop_workers_for_reload(
5093 &self,
5094 ) -> Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)> {
5095 self.runtime
5096 .subagent_registry()
5097 .lock()
5098 .unwrap_or_else(|p| p.into_inner())
5099 .stop_for_reload()
5100 }
5101
5102 pub(crate) async fn reload_aborted(&mut self) {
5107 self.lift_reload_fence(
5108 "daemon reload failed — this session continues on the running daemon",
5109 )
5110 .await;
5111 }
5112
5113 async fn lift_reload_fence(&mut self, notice: &str) {
5116 let Some(fence) = self.reload_fence.take() else {
5117 return;
5118 };
5119 self.emit(SessionEventWire::SystemNotice(notice.to_string()));
5120 let workers = if self.runtime.is_live() {
5121 let mut carried = self.reload_carried_workers();
5126 carried.extend(
5127 fence
5128 .workers
5129 .into_iter()
5130 .filter(|w| !carried.iter().any(|c| c.id == w.id))
5131 .collect::<Vec<_>>(),
5132 );
5133 let mut registry = self
5134 .runtime
5135 .subagent_registry()
5136 .lock()
5137 .unwrap_or_else(|p| p.into_inner());
5138 registry.lift_reload_fence();
5139 registry.rewake_after_failed_exec(carried)
5142 } else {
5143 Vec::new()
5144 };
5145 tracing::info!(session = %self.id, "reload did not exec: fence lifted");
5146 self.adopt_carried_workers(workers, true);
5147 self.rearm_park();
5148 if !self.event_queue.is_empty() {
5149 self.on_queue_wake().await;
5150 }
5151 }
5152
5153 async fn reload_fence_expired(&mut self) {
5156 tracing::warn!(
5157 session = %self.id,
5158 "reload fence expired with no exec and no abort from the daemon; lifting it"
5159 );
5160 self.lift_reload_fence(
5161 "daemon reload never completed or reported back — this session continues on the running daemon",
5162 )
5163 .await;
5164 }
5165
5166 pub(crate) fn abort_compaction(&mut self) {
5168 if let Some(job) = self.compact.take() {
5169 job.task.abort();
5170 self.emit(SessionEventWire::CompactionCancelled);
5171 self.publish_presence();
5172 }
5173 }
5174
5175 async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
5180 use std::ops::ControlFlow;
5181 let Addressed { from, cmd } = addressed;
5182 if let Some(c) = from {
5183 if is_rename_command(&cmd) {
5186 self.emit(SessionEventWire::Refused {
5187 client: c,
5188 command: command_name(&cmd).to_string(),
5189 reason: "rename is host-only; use /saveas".into(),
5190 });
5191 return ControlFlow::Continue(());
5192 }
5193 if matches!(cmd, SessionCommand::Checkpoint { reason: CheckpointReason::Reload }) {
5196 self.emit(SessionEventWire::Refused {
5197 client: c,
5198 command: command_name(&cmd).to_string(),
5199 reason: "checkpoint(reload) is host-only; use `synaps daemon reload`".into(),
5200 });
5201 return ControlFlow::Continue(());
5202 }
5203 if let Some(reason) = is_input_command(&cmd).then(|| self.input_refusal(c)).flatten() {
5204 self.emit(SessionEventWire::Refused {
5205 client: c,
5206 command: command_name(&cmd).to_string(),
5207 reason,
5208 });
5209 return ControlFlow::Continue(());
5210 }
5211 }
5212 if self.reload_fence.is_some() && cmd.refused_while_reloading() {
5217 if let SessionCommand::EngineCommand { id, name, .. } = &cmd {
5219 if name == RENAME_COMMAND {
5220 self.emit(SessionEventWire::QueryResult {
5221 id: *id,
5222 value: serde_json::json!({
5223 "kind": "error", "code": "busy",
5224 "text": "daemon reloading; retry in a moment",
5225 }),
5226 });
5227 return ControlFlow::Continue(());
5228 }
5229 }
5230 let reason = super::RELOADING_REFUSAL.to_string();
5231 match from {
5232 Some(c) => self.emit(SessionEventWire::Refused {
5233 client: c,
5234 command: command_name(&cmd).to_string(),
5235 reason,
5236 }),
5237 None => self.emit(SessionEventWire::SystemNotice(reason)),
5238 }
5239 return ControlFlow::Continue(());
5240 }
5241 if let Some(c) = from {
5245 if is_input_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { .. }) {
5246 self.note_input(c);
5247 }
5248 }
5249 if is_rename_command(&cmd) {
5253 if let SessionCommand::EngineCommand { id, arg, .. } = cmd {
5254 let value = self.host_rename(&arg).await;
5255 self.emit(SessionEventWire::QueryResult { id, value });
5256 }
5257 return ControlFlow::Continue(());
5258 }
5259 if self.is_parked() {
5261 let needs_runtime = !matches!(
5262 cmd,
5263 SessionCommand::Attach { .. }
5264 | SessionCommand::Detach { .. }
5265 | SessionCommand::End { .. }
5266 | SessionCommand::Query { .. }
5267 | SessionCommand::Answer { .. }
5268 | SessionCommand::Save
5269 | SessionCommand::Cancel
5270 | SessionCommand::Checkpoint { .. }
5271 | SessionCommand::Park
5272 | SessionCommand::ReloadAborted
5273 | SessionCommand::ArmPark
5274 | SessionCommand::RestoreToolActivations { .. }
5275 | SessionCommand::Resync { .. }
5276 | SessionCommand::HostEvent(_)
5277 | SessionCommand::TakeInput
5278 ) && !is_close_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { on: false });
5282 if matches!(cmd, SessionCommand::HostEvent(_)) {
5283 return ControlFlow::Continue(()); }
5285 if matches!(
5286 cmd,
5287 SessionCommand::Save
5288 | SessionCommand::Cancel
5289 | SessionCommand::Park
5290 | SessionCommand::ArmPark
5291 | SessionCommand::Checkpoint { .. }
5292 ) {
5293 if let SessionCommand::Checkpoint { .. } = cmd {
5294 let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
5295 self.emit(SessionEventWire::QueryResult {
5296 id: super::wire::CHECKPOINT_QUERY_ID,
5297 value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
5298 });
5299 }
5300 return ControlFlow::Continue(()); }
5302 if needs_runtime {
5303 if let Err(e) = self.ensure_live().await {
5304 match from {
5305 Some(c) => self.emit(SessionEventWire::Refused {
5306 client: c,
5307 command: command_name(&cmd).to_string(),
5308 reason: e,
5309 }),
5310 None => self.emit(SessionEventWire::SystemNotice(e)),
5311 }
5312 return ControlFlow::Continue(());
5313 }
5314 }
5315 }
5316 match cmd {
5317 SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
5318 SessionCommand::Steer { text } => {
5319 if self.streaming {
5320 self.steer(text)
5321 } else {
5322 self.submit(text, vec![], from).await
5323 }
5324 }
5325 SessionCommand::SubagentSteer { handle_id, text } => {
5326 self.steer_subagent(from, &handle_id, text)
5327 }
5328 SessionCommand::Cancel => {
5329 self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
5332 if self.driver.is_some() {
5333 self.driver_revoke("canceled");
5334 }
5335 self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
5336 .await;
5337 }
5338 SessionCommand::Answer { prompt_id, value } => self.answer(from, prompt_id, value),
5339 SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
5340 SessionCommand::Compact { instructions } => {
5342 self.compact(instructions, "manual").await;
5343 }
5344 SessionCommand::NewSession => {
5345 if self.driver.is_some() {
5346 self.driver_revoke("session replaced");
5347 }
5348 if self.turn_awaiting_start.is_some() {
5353 self.cancel_turn(crate::engine::interrupt::InterruptReason::User).await;
5354 }
5355 self.cache_keepwarm_conversation_replaced();
5358 self.persister.flush().await;
5361 self.conv.clear(&self.runtime).await;
5362 self.runtime
5363 .begin_conversation(Some(self.conv.session.id.clone()));
5364 self.set_journal_id(self.conv.session.id.clone());
5365 self.reacquire_session_lock(&self.conv.session.id.clone());
5367 self.emit(SessionEventWire::Cleared {
5368 session_id: self.conv.session.id.clone(),
5369 });
5370 self.emit_conversation();
5371 }
5372 SessionCommand::Save => {
5373 self.save().await;
5374 }
5375 SessionCommand::Query { id, query } => self.query(id, query).await,
5376 SessionCommand::EngineCommand { id, name, arg } if name == CLOSE_COMMAND => {
5378 if let Some(reason) = self.close_command(id, &arg) {
5379 return ControlFlow::Break(reason);
5380 }
5381 }
5382 SessionCommand::EngineCommand { id, name, arg } => {
5383 self.engine_command(id, name, arg).await
5384 }
5385 SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
5386 SessionCommand::Detach { client } => {
5387 self.detach(client);
5388 if self.attached.is_empty() && self.cache_keepwarm.active() {
5392 self.cache_keepwarm_recheck().await;
5393 }
5394 }
5395 SessionCommand::End { reason } => {
5396 self.note_cancel_cause(match reason {
5399 EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5400 _ => crate::engine::interrupt::InterruptReason::Host,
5401 });
5402 if self.driver.is_some() {
5403 self.driver_revoke("session ending");
5404 }
5405 return ControlFlow::Break(reason);
5406 }
5407 SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
5408 "resync not supported yet".into(),
5409 )),
5410 SessionCommand::SubmitPrepared {
5413 messages,
5414 user_text,
5415 } => self.submit_prepared(messages, user_text).await,
5416 SessionCommand::PluginCommand {
5417 id,
5418 plugin,
5419 name,
5420 arg,
5421 } => self.plugin_command(id, plugin, name, arg).await,
5422 SessionCommand::Resume { id, query } => self.resume(id, query).await,
5423 SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
5424 SessionCommand::AdoptWorkers { workers } => self.adopt_workers(workers),
5425 SessionCommand::RestoreToolActivations { activations } => {
5426 self.restore_tool_activations(activations)
5427 }
5428 SessionCommand::ReloadAborted => self.reload_aborted().await,
5429 SessionCommand::ArmPark => self.rearm_park(),
5431 SessionCommand::Park => {
5432 self.cache_keepwarm_park_requested();
5434 if let std::ops::ControlFlow::Break(reason) = self.park().await {
5435 return ControlFlow::Break(reason);
5436 }
5437 }
5438 SessionCommand::TakeInput => self.take_input(from),
5439 SessionCommand::KeepWarm { on } => {
5440 self.keep_warm = on;
5441 self.meta.pinned = on; self.keep_warm_cell.store(on, std::sync::atomic::Ordering::Release);
5443 self.note_pin();
5444 if !on && !self.cache_keepwarm.interactive_seen && self.cache_keepwarm.active() {
5446 self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::Disabled);
5447 }
5448 self.rearm_park();
5449 self.emit(SessionEventWire::SystemNotice(if on {
5450 PIN_ON_NOTICE.to_string()
5451 } else {
5452 PIN_OFF_NOTICE.to_string()
5453 }));
5454 }
5455 SessionCommand::HostEvent(ev) => match ev {
5456 HostEvent::ExtensionNotification {
5457 extension_id,
5458 method,
5459 params,
5460 } => self.emit(SessionEventWire::ExtensionNotification {
5461 extension_id,
5462 method,
5463 params,
5464 }),
5465 HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
5466 },
5467 SessionCommand::DriverStart { plugin, command, arg } => {
5469 self.driver_start(plugin, command, arg);
5470 }
5471 }
5472 ControlFlow::Continue(())
5473 }
5474
5475 async fn finish(&mut self, reason: EndReason) {
5478 if self.keep_warm && reason == EndReason::ClientQuit {
5481 self.keep_warm = false;
5482 self.note_pin();
5483 }
5484 self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::SessionEnding);
5485 self.session_start.end();
5489 self.lifecycle.store(
5490 SessionLifecycle::Ending as u8,
5491 std::sync::atomic::Ordering::Release,
5492 );
5493 let interrupt = match reason {
5496 EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5497 _ => crate::engine::interrupt::InterruptReason::Host,
5498 };
5499 self.note_cancel_cause(interrupt);
5501 if self.driver.is_some() {
5503 self.driver_revoke("session ending");
5504 }
5505 if self.streaming {
5506 self.cancel_turn(interrupt).await;
5507 }
5508 self.abort_compaction();
5509 while let Some((pr, tx)) = self.pending_prompts.pop_front() {
5511 let _ = tx.send(None);
5512 self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
5513 }
5514
5515 let parked = !self.conv.is_live();
5516 let session_id = (**self.journal_id.load()).clone();
5517 let api_messages = if parked {
5518 None
5519 } else {
5520 Some(self.conv.api_messages.clone())
5521 };
5522
5523 let persist = self.config.persist;
5527 if !parked {
5528 self.request_save();
5529 }
5530 let persister = &self.persister;
5531 let save_fut = async {
5532 if persist {
5533 if !persister.flush().await {
5534 tracing::warn!(session = %session_id, "session end: save failed");
5535 }
5536 let mut index_record =
5537 crate::core::session_index::SessionIndexRecord::end(&session_id);
5538 index_record.turns = api_messages.as_ref().map(|m| m.len());
5539 if let Err(err) = crate::core::session_index::append_record(&index_record) {
5540 tracing::warn!("failed to append session end index record: {}", err);
5541 }
5542 }
5543 };
5544 if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
5545 .await
5546 .is_err()
5547 {
5548 tracing::warn!(
5549 budget_secs = budgets::SAVE_TIMEOUT_SECS,
5550 "session save timed out — data may be incomplete"
5551 );
5552 }
5553
5554 let hook_bus = Arc::clone(&self.hook_bus);
5557 crate::extensions::loader::emit_session_end(
5558 &hook_bus,
5559 &session_id,
5560 api_messages,
5561 budgets::HOOKS_TIMEOUT,
5562 )
5563 .await;
5564
5565 if self.runtime.is_live() {
5567 if let Some(outcome) = self
5568 .runtime
5569 .shutdown_observability_async(
5570 crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
5571 )
5572 .await
5573 {
5574 if !outcome.is_flushed() {
5575 tracing::warn!(
5576 stats = ?outcome.stats(),
5577 "observability flush timed out — detached worker keeps draining"
5578 );
5579 }
5580 }
5581 }
5582
5583 self.background.shutdown();
5585 self.emit(SessionEventWire::Ended { reason });
5586 }
5587
5588 pub fn id(&self) -> &SessionId {
5589 &self.id
5590 }
5591}
5592
5593pub struct SessionTask(SessionActor);
5595
5596async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
5597 match stream {
5598 Some(s) => s.next().await,
5599 None => std::future::pending().await,
5600 }
5601}
5602
5603fn conversation_title(conv: &ConversationState) -> Option<String> {
5606 if !conv.session.title.is_empty() {
5607 return Some(conv.session.title.clone());
5608 }
5609 conv.api_messages
5610 .iter()
5611 .filter(|m| m["role"].as_str() == Some("user"))
5612 .find_map(|m| agent_core::core::session::user_content_for_display(&m["content"]))
5613 .map(|t| t.chars().take(80).collect())
5614}
5615
5616fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
5620 streaming || any_running
5621}
5622
5623fn worker_cancel_scope(
5629 reason: crate::engine::interrupt::InterruptReason,
5630 ending: bool,
5631 turn_generation: u64,
5632) -> crate::runtime::subagent::WorkerCancelScope {
5633 use crate::engine::interrupt::InterruptReason;
5634 use crate::runtime::subagent::WorkerCancelScope;
5635 match reason {
5636 InterruptReason::User if !ending => WorkerCancelScope::Turn(turn_generation),
5637 _ => WorkerCancelScope::All,
5638 }
5639}
5640
5641async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
5642 match tick {
5643 Some(t) => {
5644 t.tick().await;
5645 }
5646 None => std::future::pending().await,
5647 }
5648}
5649
5650async fn poll_compaction(
5651 job: &mut Option<CompactionJob>,
5652) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
5653 match job {
5654 Some(j) => (&mut j.task).await,
5655 None => std::future::pending().await,
5656 }
5657}
5658
5659async fn park_timer(deadline: Option<tokio::time::Instant>) {
5660 match deadline {
5661 Some(t) => tokio::time::sleep_until(t).await,
5662 None => std::future::pending().await,
5663 }
5664}
5665
5666async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
5668 match deadline {
5669 Some(t) => tokio::time::sleep_until(t).await,
5670 None => std::future::pending().await,
5671 }
5672}
5673
5674async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
5677 match deadline {
5678 Some(t) => tokio::time::sleep_until(t).await,
5679 None => std::future::pending().await,
5680 }
5681}
5682
5683async fn reload_fence_timer(deadline: Option<tokio::time::Instant>) {
5685 match deadline {
5686 Some(t) => tokio::time::sleep_until(t).await,
5687 None => std::future::pending().await,
5688 }
5689}
5690
5691async fn driver_tick_timer(
5693 has_driver: bool,
5694 has_pending: bool,
5695 interval: &mut tokio::time::Interval,
5696) {
5697 if has_driver || has_pending {
5698 interval.tick().await;
5699 } else {
5700 std::future::pending().await
5701 }
5702}
5703
5704fn extensions_ready_timeout(config: &crate::SynapsConfig) -> Duration {
5708 match config.startup.extensions_ready_timeout_secs {
5709 0 => budgets::EXTENSIONS_READY_TIMEOUT,
5710 secs => Duration::from_secs(secs),
5711 }
5712}
5713
5714async fn session_start_task(
5716 start: &mut super::session_start::SessionStart,
5717) -> std::result::Result<bool, tokio::task::JoinError> {
5718 match start {
5719 super::session_start::SessionStart::Queued { task, .. } => task.await,
5720 _ => std::future::pending().await,
5721 }
5722}
5723
5724impl SessionTask {
5725 pub fn id(&self) -> &SessionId {
5726 self.0.id()
5727 }
5728
5729 #[cfg(any(test, feature = "testing"))]
5732 pub fn subagent_registry(
5733 &self,
5734 ) -> Arc<std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>> {
5735 Arc::clone(self.0.runtime.subagent_registry())
5736 }
5737
5738 #[cfg(any(test, feature = "testing"))]
5742 pub fn runtime_for_harness(&self) -> crate::Runtime {
5743 (*self.0.runtime).clone()
5744 }
5745
5746 #[cfg(any(test, feature = "testing"))]
5749 pub fn event_queue(&self) -> Arc<crate::events::EventQueue> {
5750 Arc::clone(&self.0.event_queue)
5751 }
5752
5753 pub async fn run(mut self) {
5755 let queue = Arc::clone(&self.0.event_queue);
5756 let reason = loop {
5757 let actor = &mut self.0;
5758 tokio::select! {
5759 cmd = actor.cmd_rx.recv() => match cmd {
5760 Some(cmd) => {
5761 if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
5762 break reason;
5763 }
5764 }
5765 None => break EndReason::HostShutdown,
5767 },
5768 Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
5769 _ = queue.notified(), if !actor.holds(WorkSource::QueueWake) => actor.on_queue_wake().await,
5771 _ = next_tick(&mut actor.subagent_tick) => {
5772 actor.publish_subagent_rows();
5773 actor.flush_turn_draft();
5774 }
5775 _ = park_timer(actor.park_deadline), if !actor.holds(WorkSource::ParkTimer) => {
5776 if let std::ops::ControlFlow::Break(reason) = actor.park().await {
5777 break reason;
5778 }
5779 }
5780 _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
5781 actor.on_prompt_abandon_deadline();
5782 }
5783 _ = super::cache_keepwarm::keepwarm_timer(actor.cache_keepwarm.deadline()), if !actor.holds(WorkSource::KeepWarmTimer) => {
5784 actor.on_cache_keepwarm_due().await;
5785 }
5786 res = super::cache_keepwarm::poll_keepwarm_ping(&mut actor.cache_keepwarm.task) => {
5787 actor.on_cache_keepwarm_done(res);
5788 }
5789 res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
5790 ran = session_start_task(&mut actor.session_start) => {
5791 actor.session_start_finished(ran).await;
5792 },
5793 _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval), if !actor.holds(WorkSource::DriverTick) => {
5794 actor.driver_tick().await;
5795 }
5796 _ = reload_fence_timer(actor.reload_fence.as_ref().map(|f| f.deadline)) => {
5797 actor.reload_fence_expired().await;
5798 }
5799 _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
5800 actor.finish_revoked_turn(false).await;
5801 }
5802 ev = next_stream_event(&mut actor.stream) => match ev {
5803 Some(ev) => actor.on_stream_event(ev).await,
5804 None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
5806 None => {
5807 if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
5810 let terminal = super::driver::capture_terminal(None, false);
5811 if let Some(terminal) = terminal {
5812 if let Some(driver) = actor.driver.as_mut() {
5813 let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
5814 let sel = driver.selection.clone();
5820 let fb = driver.grant.feedback_enabled()
5821 .then(|| driver.completed_feedback.to_string());
5822 actor.emit(SessionEventWire::DriverTurnOutcome {
5823 outcome: crate::extensions::session_driver::Outcome::Blocked,
5824 selection: sel,
5825 feedback: fb,
5826 });
5827 if let Some(reason) = reason {
5828 actor.driver_revoke(&reason);
5829 }
5830 }
5831 }
5832 }
5833 actor.clear_stream();
5834 actor.emit_conversation();
5835 actor.announce_idle().await;
5836 }
5837 },
5838 }
5839 };
5840 self.0.finish(reason).await;
5841 }
5842}
5843
5844async fn await_stopped_workers(
5851 session: &str,
5852 stopping: Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)>,
5853) -> Vec<String> {
5854 if stopping.is_empty() {
5855 return Vec::new();
5856 }
5857 let ids: Vec<String> = stopping.iter().map(|(id, _)| id.clone()).collect();
5858 tracing::info!(session, workers = ?ids, "reload: stopping running workers");
5859 let deadline = tokio::time::Instant::now() + budgets::WORKER_RELOAD_STOP_TIMEOUT;
5860 loop {
5861 let pending: Vec<&str> = stopping
5862 .iter()
5863 .filter(|(_, s)| !s.read().unwrap_or_else(|p| p.into_inner()).completion_settled)
5864 .map(|(id, _)| id.as_str())
5865 .collect();
5866 if pending.is_empty() {
5867 break;
5868 }
5869 if tokio::time::Instant::now() >= deadline {
5870 tracing::warn!(
5871 session,
5872 workers = ?pending,
5873 "reload: workers did not stop in time; only their last archive survives the exec"
5874 );
5875 break;
5876 }
5877 tokio::time::sleep(std::time::Duration::from_millis(25)).await;
5878 }
5879 stopping
5880 .iter()
5881 .filter(|(_, s)| s.read().unwrap_or_else(|p| p.into_inner()).stopped_by_reload)
5882 .map(|(id, _)| id.clone())
5883 .collect()
5884}
5885
5886#[cfg(test)]
5887mod worker_cancel_scope_tests {
5888 use super::worker_cancel_scope;
5889 use crate::engine::interrupt::InterruptReason;
5890 use crate::runtime::subagent::WorkerCancelScope;
5891
5892 #[test]
5893 fn only_a_live_user_interrupt_is_turn_scoped() {
5894 assert_eq!(
5895 worker_cancel_scope(InterruptReason::User, false, 7),
5896 WorkerCancelScope::Turn(7),
5897 "Esc stops the interrupted turn's workers only"
5898 );
5899 assert_eq!(
5900 worker_cancel_scope(InterruptReason::User, true, 7),
5901 WorkerCancelScope::All,
5902 "quitting the client mid-turn tears every worker down"
5903 );
5904 for reason in [
5905 InterruptReason::CostCap,
5906 InterruptReason::Restart,
5907 InterruptReason::Host,
5908 InterruptReason::Driver,
5909 ] {
5910 assert_eq!(
5911 worker_cancel_scope(reason, false, 7),
5912 WorkerCancelScope::All,
5913 "{reason:?} stops every running worker"
5914 );
5915 }
5916 }
5917}
5918
5919#[cfg(test)]
5920mod subagent_tick_tests {
5921 use super::keep_subagent_tick;
5922
5923 #[test]
5924 fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
5925 assert!(keep_subagent_tick(true, false), "during a turn, always");
5926 assert!(keep_subagent_tick(true, true));
5927 assert!(
5928 keep_subagent_tick(false, true),
5929 "after the turn, while a background worker runs"
5930 );
5931 assert!(
5932 !keep_subagent_tick(false, false),
5933 "stops once the turn is over and no worker runs"
5934 );
5935 }
5936}
5937
5938#[cfg(test)]
5939mod parked_evict_tests {
5940 use super::parked_evict_after_from;
5941 use super::prompt_abandon_timeout_from;
5942 use std::time::Duration;
5943
5944 #[test]
5945 fn parked_evict_defaults_to_one_hour_and_honours_never() {
5946 let d = 3600u64;
5948 assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
5949 assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
5950 for never in ["never", "0", "off"] {
5951 assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
5952 }
5953 assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5954 assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
5956 assert_eq!(parked_evict_after_from(None, 0), None);
5957 assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
5958 }
5959
5960 #[test]
5961 fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
5962 let d = 3600u64;
5964 assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
5965 assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
5966 for never in ["never", "0", "off"] {
5968 assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
5969 }
5970 assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5972 assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
5974 assert_eq!(prompt_abandon_timeout_from(None, 0), None);
5975 assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
5976 }
5977}