Skip to main content

agent_engine/session/
actor.rs

1//! `SessionActor` — owns THE `Runtime` + `ConversationState` for one
2//! conversation and runs its turn machine (PLAN-phase2 §2.5).
3//!
4//! Every method body is moved from an existing reactor site (TUI
5//! `dispatch.rs` Abort/Submit/StreamingInput, `stream_handler.rs`
6//! event-queue + stream arms, `tui/mod.rs` teardown, `cmd/chat.rs`
7//! post-turn); the presentation halves stay client-side and are fed by
8//! the envelopes this actor emits. Each `StreamEvent` is forwarded to
9//! clients BEFORE the actor acts on it, so a client sees the same order it
10//! sees today.
11//!
12//! Invariants:
13//! - `Runtime::clone()` resets TTL latches. The actor clones it only for
14//!   driver stream-start (Prepared→Started) and compaction. The stream-start
15//!   clone shares the original's TTL atomics via `share_ttl_latches` so a
16//!   redundant 1h→5m downgrade notice per driver turn is avoided. The
17//!   compaction clone is read-only+discarded and needs no sharing.
18//! - `emit` is the ONLY `seq` increment site.
19//! - `Detach` never touches `stream`/`cancel`; only `End` (and `Cancel`)
20//!   stop a turn.
21
22use 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
52/// The in-flight response stream (tui/stream_handler.rs `ActiveStream`).
53pub type ActiveStream = std::pin::Pin<Box<dyn futures::Stream<Item = StreamEvent> + Send>>;
54
55/// `turn_replay` cap (envelopes). §6 #9: the 2 MiB text bound is day 3.
56const TURN_REPLAY_CAP: usize = 4096;
57
58/// The in-flight turn draft (`sessions/<id>.turn`, see
59/// `agent_core::core::session_draft`): what the actor last knew about the
60/// response being streamed, flushed at most once per 1 Hz turn tick.
61#[derive(Default)]
62pub(crate) struct TurnDraftState {
63    /// Session id the open draft lives under; `None` = no turn open.
64    open: Option<String>,
65    /// History length the in-flight response continues from.
66    base_len: usize,
67    /// Text streamed in the in-flight response so far.
68    text: String,
69    /// Changed since the last flush.
70    dirty: bool,
71}
72
73/// What `SessionActor::drain_cancelled_stream` recovered from a cancelled
74/// turn's stream.
75#[derive(Default)]
76pub(crate) struct CancelDrain {
77    /// The engine's final history for the turn (last `MessageHistory` seen).
78    pub(crate) history: Option<Vec<crate::SharedMessage>>,
79    /// The stream ended (`Done` / EOF) inside the drain budget.
80    pub(crate) closed: bool,
81}
82
83/// A field that is `None` exactly while the session is `Parked` (B3).
84/// Derefs to the value so the turn machine reads `self.runtime` / `self.conv`
85/// unchanged; every command that needs them goes through `ensure_live()`
86/// first, so the panic path is a programming error, not a runtime state.
87pub(crate) struct Live<T>(Option<T>);
88
89impl<T> Live<T> {
90    pub(crate) fn new(v: T) -> Self {
91        Self(Some(v))
92    }
93    pub(crate) fn is_live(&self) -> bool {
94        self.0.is_some()
95    }
96    pub(crate) fn park_take(&mut self) -> Option<T> {
97        self.0.take()
98    }
99    pub(crate) fn unpark_set(&mut self, v: T) {
100        self.0 = Some(v);
101    }
102}
103
104impl<T> std::ops::Deref for Live<T> {
105    type Target = T;
106    fn deref(&self) -> &T {
107        self.0.as_ref().expect("session is parked: runtime/conv unavailable")
108    }
109}
110
111impl<T> std::ops::DerefMut for Live<T> {
112    fn deref_mut(&mut self) -> &mut T {
113        self.0.as_mut().expect("session is parked: runtime/conv unavailable")
114    }
115}
116
117/// `SYNAPS_DAEMON_PARKED_EVICT_SECS`: how long a PARKED session stays in the
118/// daemon's map before it is evicted (`EndReason::Evicted`). Its state is on
119/// disk; `--continue <id>` rebuilds it exactly as after a daemon restart. The
120/// row only exists so `--attach <id>` / the adopt banner can find it quickly.
121/// Default 1 h; `never` → keep forever.
122pub fn parked_evict_after() -> Option<std::time::Duration> {
123    // Precedence: env > config (`daemon.parked_evict_secs`) > builtin default.
124    let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
125    parked_evict_after_from(
126        std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
127        cfg_secs,
128    )
129}
130
131fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
132    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
133    match v.map(str::trim) {
134        Some("never" | "0" | "off") => None,
135        Some(n) => n
136            .parse::<u64>()
137            .ok()
138            .map(std::time::Duration::from_secs)
139            .or_else(from_cfg),
140        None => from_cfg(),
141    }
142}
143
144/// `SYNAPS_DAEMON_IDLE_END_GRACE_SECS`: how long a ZERO-turn session with no
145/// clients lingers before it ends (F18). Default 5 s — long enough for a
146/// client that disconnected mid-reconnect, short enough that open-and-close
147/// does not pin a Runtime for a minute.
148pub fn idle_end_grace() -> std::time::Duration {
149    std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
150        .ok()
151        .and_then(|v| v.trim().parse::<u64>().ok())
152        .map(std::time::Duration::from_secs)
153        .unwrap_or(std::time::Duration::from_secs(5))
154}
155
156/// `SYNAPS_DAEMON_PARK_GRACE_SECS`: `never` → `None` (Parked disabled);
157/// `n` → n seconds after the last detach once idle; default 60.
158pub fn park_grace() -> Option<std::time::Duration> {
159    match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
160        Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
161        Ok(v) => v
162            .trim()
163            .parse::<u64>()
164            .ok()
165            .map(std::time::Duration::from_secs)
166            .or(Some(DEFAULT_PARK_GRACE)),
167        Err(_) => Some(DEFAULT_PARK_GRACE),
168    }
169}
170
171pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
172
173/// `SYNAPS_DAEMON_PROMPT_ABANDON_SECS`: how long a pending host confirmation on
174/// a NON-driver session survives after the LAST client detaches before it is
175/// fail-closed answered `None` (deny) so `can_park()` can proceed. This bounds
176/// the resource pin from an ABANDONED prompt WITHOUT punishing a transient
177/// disconnect (SSH drop, sleep, wifi): a re-attach before expiry cancels it and
178/// the user answers the prompt normally. Default 1 h — generous enough that a
179/// human coming back from lunch still answers; `never`/`0`/`off` → `None` =
180/// disabled = pure pre-#112 behaviour (a prompt survives detach forever).
181///
182/// (The driver-armed case fail-closes immediately on last detach; this deadline
183/// is only for a plain interactive session — see `detach` / `rearm_prompt_abandon`.)
184pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
185    // Precedence: env > config (`daemon.prompt_abandon_secs`) > builtin default.
186    let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
187    prompt_abandon_timeout_from(
188        std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
189        cfg_secs,
190    )
191}
192
193fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
194    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
195    match v.map(str::trim) {
196        Some("never" | "0" | "off") => None,
197        Some(n) => n
198            .parse::<u64>()
199            .ok()
200            .map(std::time::Duration::from_secs)
201            .or_else(from_cfg),
202        None => from_cfg(),
203    }
204}
205
206/// Non-persisted runtime knobs replayed after unpark (last-wins per
207/// variant). Model/reasoning live in the journal already; `/system` is
208/// runtime-only (never journaled) so it replays too (M7).
209fn replayable(setting: &SessionSetting) -> bool {
210    matches!(
211        setting,
212        SessionSetting::SystemPrompt { .. }
213            | SessionSetting::ContextWindow { .. }
214            | SessionSetting::CompactionModel { .. }
215            | SessionSetting::ApiRetries { .. }
216            | SessionSetting::SubagentTimeout { .. }
217            | SessionSetting::MaxToolOutput { .. }
218            | SessionSetting::BashTimeout { .. }
219            | SessionSetting::BashMaxTimeout { .. }
220            | SessionSetting::GrantWorkerModel { .. }
221    )
222}
223
224fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
225    match setting {
226        SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
227        SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
228        SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
229        SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
230        SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
231        SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
232        SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
233        SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
234        SessionSetting::GrantWorkerModel { model } => {
235            let _ = rt.grant_worker_model(model);
236        }
237        _ => {}
238    }
239}
240
241/// One attached client: its meta + the mode it attached with (B1 ownership
242/// needs the mode to pick the next owner when the owner detaches).
243pub(crate) struct AttachedClient {
244    pub(crate) meta: ClientMeta,
245    pub(crate) mode: AttachMode,
246}
247
248/// B2: a spawned `compact_conversation` (§2.5).
249pub(crate) struct CompactionJob {
250    pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
251    pub(crate) source: String,
252    #[allow(dead_code)]
253    pub(crate) started: tokio::time::Instant,
254}
255
256/// Commands that only the input owner may send (B1). Everything else
257/// (`Answer`, `Query`, `Save`, `Detach`, `Attach`, `Resync`, `HostEvent`)
258/// is open to every attached client. `Checkpoint` cancels the owner's turn
259/// and kills its PTYs, so it is owner-only from a client (M1); the daemon
260/// sends it `from: None`.
261fn is_input_command(cmd: &SessionCommand) -> bool {
262    matches!(
263        cmd,
264        SessionCommand::Checkpoint { .. }
265            | SessionCommand::Submit { .. }
266            | SessionCommand::SubmitPrepared { .. }
267            | SessionCommand::Steer { .. }
268            | SessionCommand::Cancel
269            | SessionCommand::Set { .. }
270            | SessionCommand::Compact { .. }
271            | SessionCommand::NewSession
272            | SessionCommand::EngineCommand { .. }
273            | SessionCommand::PluginCommand { .. }
274            | SessionCommand::Resume { .. }
275            | SessionCommand::KeepWarm { .. }
276            | SessionCommand::DriverStart { .. }
277            | SessionCommand::End {
278                reason: EndReason::ClientQuit
279            }
280    )
281}
282
283fn command_name(cmd: &SessionCommand) -> &'static str {
284    match cmd {
285        SessionCommand::Submit { .. } => "submit",
286        SessionCommand::SubmitPrepared { .. } => "submit_prepared",
287        SessionCommand::Steer { .. } => "steer",
288        SessionCommand::Cancel => "cancel",
289        SessionCommand::Answer { .. } => "answer",
290        SessionCommand::Set { .. } => "set",
291        SessionCommand::Compact { .. } => "compact",
292        SessionCommand::NewSession => "new_session",
293        SessionCommand::Save => "save",
294        SessionCommand::Query { .. } => "query",
295        SessionCommand::Attach { .. } => "attach",
296        SessionCommand::Detach { .. } => "detach",
297        SessionCommand::End { .. } => "end",
298        SessionCommand::Resync { .. } => "resync",
299        SessionCommand::EngineCommand { .. } => "engine_command",
300        SessionCommand::PluginCommand { .. } => "plugin_command",
301        SessionCommand::Resume { .. } => "resume",
302        SessionCommand::Checkpoint { .. } => "checkpoint",
303        SessionCommand::KeepWarm { .. } => "keep_warm",
304        SessionCommand::Park => "park",
305        SessionCommand::HostEvent(_) => "host_event",
306        SessionCommand::DriverStart { .. } => "driver_start",
307    }
308}
309
310pub struct SessionActor {
311    pub(crate) id: SessionId,
312    pub(crate) meta: SessionMeta,
313    pub(crate) config: SessionConfig,
314    /// THE runtime; `session_id` + `cwd` set by `create`. `None` ⇔ Parked.
315    pub(crate) runtime: Live<Runtime>,
316    /// `None` ⇔ Parked.
317    pub(crate) conv: Live<ConversationState>,
318    /// B3: unpark needs a fresh `foreground_runtime()`.
319    pub(crate) host: Arc<EngineHost>,
320    /// Kept across Park (`finish()` needs it without a Runtime).
321    pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
322    /// Session-lifetime queue; `background` pushes into it, every Runtime
323    /// this actor builds is pointed at it (`Runtime::set_event_queue`).
324    pub(crate) event_queue: Arc<crate::events::EventQueue>,
325    /// Last-wins per variant; replayed after unpark (non-persisted knobs).
326    pub(crate) settings_replay: Vec<SessionSetting>,
327    pub(crate) keep_warm: bool,
328    /// Opt-in prompt-cache keep-warm pings while idle (task #444). Unrelated
329    /// to `keep_warm` above, which only stops the session from parking.
330    pub(crate) cache_keepwarm: super::cache_keepwarm::KeepWarmState,
331    /// Armed on last-detach-while-idle / idle-while-detached.
332    pub(crate) park_deadline: Option<tokio::time::Instant>,
333    /// (P11) Armed when the last client detaches while a pending host prompt is
334    /// held on a NON-driver session; a re-attach cancels it, expiry denies the
335    /// prompt(s) so `can_park()` can proceed. `None` = no abandoned prompt (or
336    /// the deadline is disabled by config).
337    pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
338    // ── turn machine: the run() loop locals + App fields ──
339    pub(crate) stream: Option<ActiveStream>,
340    pub(crate) cancel: Option<CancellationToken>,
341    pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
342    pub(crate) streaming: bool,
343    pub(crate) turn_baseline: usize,
344    pub(crate) consecutive_auto_turns: u32,
345    /// Formatted events `on_queue_wake` steered into this turn's stream
346    /// that the engine has not acknowledged (`SteeringDelivered`) yet. On
347    /// cancel they would die with the steering channel; `cancel_turn` moves
348    /// them into history after the interruption marker instead.
349    pub(crate) turn_steered_events: Vec<String>,
350    /// Set by the engine when the running turn reaches its normal end
351    /// (`TurnCompletion`); read when a cancel races that end.
352    pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
353    /// A turn cut from outside `cancel_turn` (driver revocation cancels the
354    /// driver turn's token): the stream is given until this instant to
355    /// deliver its terminal event, then dropped and the turn finished as
356    /// interrupted (`finish_revoked_turn`).
357    pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
358    /// In-flight turn draft for crash recovery (persisting sessions only).
359    pub(crate) turn_draft: TurnDraftState,
360    /// Session saves and turn-draft writes, off the turn machine
361    /// (`session::persister`).
362    pub(crate) persister: super::persister::Persister,
363    /// The snapshot last handed to the persister: an identical one is not
364    /// queued again (a turn's end asks several times for the same state).
365    pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
366    // ── prompts ──
367    pub(crate) secret_prompt_handle: SecretPromptHandle,
368    pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
369    /// Held across detach; replayed in `AttachSnapshot.pending_prompts`.
370    pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
371    pub(crate) next_prompt_id: u64,
372    // ── clients ──
373    pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
374    pub(crate) events: broadcast::Sender<Envelope>,
375    pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
376    pub(crate) attached: HashMap<ClientId, AttachedClient>,
377    /// B1: the one client whose input commands are honoured (`None` = any
378    /// host-originated sender only).
379    pub(crate) input_owner: Option<ClientId>,
380    pub(crate) next_client_id: u64,
381    pub(crate) seq: u64,
382    pub(crate) turn_replay: VecDeque<Envelope>,
383    pub(crate) state: AttachState,
384    pub(crate) background: BackgroundTasks,
385    /// B2: spawned compaction in flight (`busy` while `Some`).
386    pub(crate) compact: Option<CompactionJob>,
387    /// `await_extensions=false`: fires when process-level discovery is done;
388    /// the actor then emits `on_session_start` without having blocked boot.
389    pub(crate) ext_ready: Option<oneshot::Receiver<()>>,
390    /// 1 Hz `SubagentRows` cadence while a turn runs (`None` when idle).
391    pub(crate) subagent_tick: Option<tokio::time::Interval>,
392    /// Mirrors `handle.lifecycle()` (B3 writes Parking/Parked).
393    pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
394    /// Mirrors `handle.journal_id()` (B2 stores the successor id).
395    pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
396    /// Mirrors `handle.presence()` (clients / owner / pending prompts).
397    pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
398    /// Mirrors `handle.name()`; `sync_name` after any rename.
399    pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
400    /// F10: per-session journal ownership lock. Held while Live, released on Park.
401    pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
402    // ── E: session driver (actor-side, behind `session.drive` permission) ──
403    pub(crate) driver: Option<super::driver::DriverState>,
404    pub(crate) driver_pending: Option<super::driver::DriverPending>,
405    /// Generation counter for invalidating stale pending results.
406    pub(crate) driver_generation: u64,
407    /// Interrupted owner (for generic stop commands across revocation).
408    pub(crate) driver_interrupted_owner: Option<String>,
409    /// 200 ms cadence for `driver_tick()`, always present.
410    pub(crate) driver_tick_interval: tokio::time::Interval,
411}
412
413impl SessionActor {
414    /// = today's `setup::boot()` per session (foreground_runtime →
415    /// resolve_session_and_prompt → set_cwd → model override →
416    /// spawn_session_background → finish_session_setup) then
417    /// `on_session_start` (keyed injection).
418    pub(crate) async fn create(
419        host: &Arc<EngineHost>,
420        mut cfg: SessionConfig,
421    ) -> Result<(SessionHandle, SessionTask)> {
422        // cwd goes in BEFORE apply_config (inside foreground_runtime_for) so the
423        // one-shot memory binding scopes to the session's project (§4).
424        let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
425        let config: crate::SynapsConfig = (**host.config()).clone();
426
427        let mut sb = crate::engine::setup::resolve_session_and_prompt(
428            &mut runtime,
429            &cfg.continue_session,
430            cfg.system.as_deref(),
431            cfg.prompt_manifest.as_deref(),
432        )?;
433        // `--name` at create: apply BEFORE the registry entry is written so
434        // `synaps send --session <name>` and `--continue <name>` resolve
435        // from the first second — not only after a later `--continue`.
436        if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
437            sb.session
438                .set_name(name)
439                .map_err(|e| crate::RuntimeError::Session(format!("--name {name:?}: {e}")))?;
440            if cfg.persist {
441                // Persist the name now so disk resolution (chain/name → id)
442                // agrees with the live map even before the first turn.
443                if let Err(e) = sb.session.save().await {
444                    tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
445                }
446            }
447        }
448        runtime.set_cwd(cfg.cwd.clone());
449        // T5: rehydrate env from journal when the client sent none.
450        // Precedence: explicit Hello.env from the continuing client > journal > None.
451        // Never fall back to the daemon's own process env.
452        if cfg.env.is_none() {
453            if let Some(ref journal_env) = sb.session.env {
454                cfg.env = Some(journal_env.clone());
455            }
456            if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
457                cfg.env_stripped = sb.session.env_stripped.clone();
458            }
459        }
460        // Always persist the latest env on the session so the next
461        // restart/continue sees it — a fresh client env supersedes
462        // whatever was journaled previously.
463        if cfg.env.is_some() {
464            sb.session.env = cfg.env.clone();
465            sb.session.env_stripped = cfg.env_stripped.clone();
466        }
467        runtime.set_env(cfg.env.clone());
468        runtime.set_env_stripped(cfg.env_stripped.clone());
469        // CLI `--model` overrides whatever was persisted (rpc.rs precedent).
470        if let Some(ref m) = cfg.model_override {
471            runtime.set_model(m.clone());
472        }
473
474        let background =
475            crate::engine::setup::spawn_session_background(&runtime, &sb.session)?;
476        crate::engine::setup::finish_session_setup(
477            &mut runtime,
478            &config,
479            &sb.session,
480            cfg.cwd.clone(),
481            crate::engine::setup::IndexRecord::Start,
482        );
483
484        // C2: per-session on_session_start (keyed injection) once the
485        // process-level discovery is known-finished. Never re-runs discovery.
486        // `await_extensions=false` (TUI): a spawned waiter fires the
487        // `ext_ready` arm instead, so boot never blocks on discovery.
488        // `startup.extensions_ready_timeout_secs` (config, default 30) bounds
489        // the wait on extension discovery; the const remains the fallback.
490        let ext_ready_timeout = {
491            let secs = config.startup.extensions_ready_timeout_secs;
492            if secs == 0 {
493                budgets::EXTENSIONS_READY_TIMEOUT
494            } else {
495                std::time::Duration::from_secs(secs)
496            }
497        };
498        let mut ext_ready = None;
499        if cfg.await_extensions {
500            if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
501                .await
502                .is_err()
503            {
504                tracing::warn!(
505                    budget_secs = ext_ready_timeout.as_secs(),
506                    "extensions_ready timed out — on_session_start may miss late extensions"
507                );
508            }
509            crate::extensions::loader::emit_session_start(runtime.hook_bus(), &sb.session.id)
510                .await;
511        } else {
512            let (tx, rx) = oneshot::channel();
513            let waiter_host = Arc::clone(host);
514            tokio::spawn(async move {
515                let _ = tokio::time::timeout(ext_ready_timeout, waiter_host.extensions_ready())
516                    .await;
517                let _ = tx.send(());
518            });
519            ext_ready = Some(rx);
520        }
521
522        let id = SessionId::from(sb.session.id.clone());
523
524        // F10: acquire the per-session journal ownership lock.
525        let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
526        let session_lock = if sb.continued {
527            // Continuing an existing session — the lock MUST succeed.
528            // If another process holds it, refuse with an actionable error.
529            let dir = agent_core::session_lock::sessions_dir();
530            let holder = agent_core::session_lock::LockHolder {
531                pid: std::process::id(),
532                kind: lock_kind.to_string(),
533            };
534            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
535                Ok(lock) => Some(lock),
536                Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
537                    let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
538                    return Err(crate::RuntimeError::Session(msg.to_string()));
539                }
540                Err(e) => {
541                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
542                    None
543                }
544            }
545        } else {
546            // Fresh session — best-effort lock.
547            let dir = agent_core::session_lock::sessions_dir();
548            let holder = agent_core::session_lock::LockHolder {
549                pid: std::process::id(),
550                kind: lock_kind.to_string(),
551            };
552            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
553                Ok(lock) => Some(lock),
554                Err(e) => {
555                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
556                    None
557                }
558            }
559        };
560
561        let meta = SessionMeta {
562            id: id.clone(),
563            name: sb.session.name.clone(),
564            model: runtime.model().to_string(),
565            cwd: cfg.cwd.clone(),
566            created_at: sb.session.created_at,
567            continued: sb.continued,
568            continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
569            host_pid: std::process::id(),
570            lifecycle: SessionLifecycle::Live,
571            clients: 0,
572            input_owner: None,
573            awaiting_input: 0,
574            journal_id: sb.session.id.clone(),
575            locked_by: None,
576        };
577        let mut conv = if sb.continued {
578            ConversationState::from_resumed(sb.session)
579        } else {
580            ConversationState::new(sb.session)
581        };
582        conv.api_messages = sb.api_messages;
583        conv.total_input_tokens = sb.total_input_tokens;
584        conv.total_output_tokens = sb.total_output_tokens;
585        conv.session_cost = sb.session_cost;
586        // Crash recovery (the last process died mid-turn): only the lock
587        // holder may fold the turn draft in, persist it and remove it.
588        if sb.continued && cfg.persist && session_lock.is_some() {
589            crate::engine::setup::recover_turn_draft(&mut conv).await;
590        }
591
592        let view = RuntimeView::from_runtime(&runtime).await;
593        let hook_bus = Arc::clone(runtime.hook_bus());
594        let event_queue = Arc::clone(runtime.event_queue());
595        let keep_warm = cfg.keep_warm;
596        let (
597            handle,
598            SessionEndpoints {
599                cmd_rx,
600                events,
601                view,
602                lifecycle,
603                journal_id,
604                presence,
605                name,
606                cache_keepwarm,
607            },
608        ) = SessionHandle::new(meta.clone(), view);
609        let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
610
611        let actor = SessionActor {
612            id,
613            meta,
614            config: cfg,
615            runtime: Live::new(runtime),
616            conv: Live::new(conv),
617            host: Arc::clone(host),
618            hook_bus,
619            event_queue,
620            settings_replay: Vec::new(),
621            keep_warm,
622            cache_keepwarm: super::cache_keepwarm::KeepWarmState::new(
623                config.cache_keepwarm,
624                config.cache_keepwarm_auto_max_hours,
625                cache_keepwarm,
626            ),
627            park_deadline: None,
628            prompt_abandon_deadline: None,
629            stream: None,
630            cancel: None,
631            steer_tx: None,
632            streaming: false,
633            turn_baseline: 0,
634            consecutive_auto_turns: 0,
635            turn_steered_events: Vec::new(),
636            turn_completion: None,
637            revoked_turn_deadline: None,
638            turn_draft: TurnDraftState::default(),
639            persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
640            last_queued_save: None,
641            secret_prompt_handle: SecretPromptHandle::new(sp_tx),
642            secret_prompt_rx,
643            pending_prompts: VecDeque::new(),
644            next_prompt_id: 1,
645            cmd_rx,
646            events,
647            view,
648            attached: HashMap::new(),
649            input_owner: None,
650            next_client_id: 1,
651            seq: 0,
652            turn_replay: VecDeque::new(),
653            state: AttachState::Detached { running: false },
654            background,
655            compact: None,
656            ext_ready,
657            subagent_tick: None,
658            lifecycle,
659            journal_id,
660            presence,
661            name,
662            session_lock,
663            driver: None,
664            driver_pending: None,
665            driver_generation: 0,
666            driver_interrupted_owner: None,
667            driver_tick_interval: {
668                let mut interval = tokio::time::interval(Duration::from_millis(200));
669                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
670                interval
671            },
672        };
673        Ok((handle, SessionTask(actor)))
674    }
675
676    /// Publish `conv.session.name` to the handle (listings, `--continue
677    /// <name>`) and rewrite this session's registry entry so `synaps send
678    /// --session <name>` resolves the new name. Call after any rename.
679    pub(crate) fn sync_name(&mut self) {
680        let name = self.conv.session.name.clone();
681        self.meta.name = name.clone();
682        self.name.store(Arc::new(name.clone()));
683        if let Err(e) = self.background.update_session_name(name.as_deref()) {
684            tracing::warn!(session = %self.id, "registry name update failed: {e}");
685        }
686    }
687
688    // ── emit ─────────────────────────────────────────────────────────────
689
690    /// The ONLY seq++ site. Pushes to `turn_replay` while streaming, except
691    /// prompt traffic (never replayed), per-client replies, and full-history
692    /// envelopes (`MessageHistory`, `Conversation`): the engine publishes one
693    /// per round, and an attaching client already gets the LATEST history in
694    /// its snapshot's `conversation` — replaying older ones would ship the
695    /// whole history once per round and roll its mirror back to a stale
696    /// state. The ring's `TurnStarted` drops `user_text`: that prompt is
697    /// already in the snapshot's history (a replayed copy would show it
698    /// twice). Completed rounds leave the ring when their checkpoint is
699    /// adopted (`trim_replay_to_checkpoint`).
700    pub(crate) fn emit(&mut self, event: SessionEventWire) {
701        let replay = self.streaming
702            && !matches!(
703                event,
704                SessionEventWire::Prompt(_)
705                    | SessionEventWire::PromptResolved { .. }
706                    | SessionEventWire::Attached { .. }
707                    | SessionEventWire::QueryResult { .. }
708                    | SessionEventWire::Conversation(_)
709                    | SessionEventWire::Stream(StreamEvent::Session(
710                        SessionEvent::MessageHistory(_)
711                    ))
712            );
713        let env = Envelope {
714            session_id: self.id.clone(),
715            seq: self.seq,
716            ts: chrono::Utc::now(),
717            event,
718        };
719        self.seq += 1;
720        if replay {
721            if self.turn_replay.len() >= TURN_REPLAY_CAP {
722                self.turn_replay.pop_front();
723            }
724            let mut ring = env.clone();
725            if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
726                *user_text = None;
727            }
728            self.turn_replay.push_back(ring);
729        }
730        // No receivers is not an error: streams are not tied to clients.
731        let _ = self.events.send(env);
732    }
733
734    /// A round checkpoint was adopted: every round in it is now in the
735    /// attach snapshot's `conversation`, so the replay ring drops those
736    /// rounds' display events — a mid-turn attach would otherwise render
737    /// them twice (once from history, once from the ring). The engine
738    /// publishes the checkpoint at a round boundary, so a tool call and its
739    /// result are always on the same side of the cut. Kept: the turn's
740    /// `TurnStarted`, notices, subagent progress, `Usage`.
741    fn trim_replay_to_checkpoint(&mut self) {
742        self.turn_replay.retain(|env| {
743            !matches!(
744                env.event,
745                SessionEventWire::Stream(StreamEvent::Llm(_))
746                    | SessionEventWire::Stream(StreamEvent::Agent(
747                        AgentEvent::SteeringDelivered { .. }
748                    ))
749            )
750        });
751    }
752
753    pub(crate) fn emit_conversation(&mut self) {
754        let snap = self.conv.snapshot(self.consecutive_auto_turns);
755        self.emit(SessionEventWire::Conversation(snap));
756    }
757
758    pub(crate) async fn publish_view(&mut self) {
759        let v = RuntimeView::from_runtime(&self.runtime).await;
760        self.view.store(Arc::new(v));
761    }
762
763    /// Queue a save of the current conversation. Never waits: the write
764    /// runs on the actor's persister (latest wins), so a slow disk cannot
765    /// stall the turn machine.
766    pub(crate) fn request_save(&mut self) {
767        if !self.config.persist || !self.conv.is_live() {
768            return;
769        }
770        let Some(session) = self.conv.prepare_save() else {
771            return;
772        };
773        let key = super::persister::SnapshotKey::of(&session);
774        // This exact state is already queued or on disk — unless the last
775        // write failed, in which case it is queued again.
776        if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
777            return;
778        }
779        self.last_queued_save = Some(key);
780        self.persister.save(session);
781    }
782
783    /// Wait, at most `SAVE_TIMEOUT`, until every queued save and draft
784    /// operation has been applied. `false` on a failed save or the timeout
785    /// (the queued writes still land, in order, when the disk catches up).
786    pub(crate) async fn flush_saves(&mut self) -> bool {
787        let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
788            Ok(ok) => ok,
789            Err(_) => {
790                tracing::warn!(session = %self.id, "session save still pending after the save budget");
791                false
792            }
793        };
794        if !ok {
795            // Whatever failed is not known to be on disk: queue it again next
796            // time instead of skipping it as a duplicate.
797            self.last_queued_save = None;
798        }
799        ok
800    }
801
802    /// Save now: queue the current conversation and wait for it (bounded,
803    /// see `flush_saves`).
804    pub(crate) async fn save(&mut self) -> bool {
805        self.request_save();
806        self.flush_saves().await
807    }
808
809    /// A turn is over: announce `Idle` only once everything it queued is on
810    /// disk (bounded, `flush_saves`). `Idle` has always meant "the session
811    /// is saved": a client or script that reads the session, quits or
812    /// hands off on `Idle` must never see the previous state. Per-round
813    /// saves stay in the background; this waits once per turn.
814    pub(crate) async fn announce_idle(&mut self) {
815        self.flush_saves().await;
816        self.emit(SessionEventWire::Idle);
817        self.cache_keepwarm_on_idle().await;
818    }
819
820    /// Wall 1 — actor-owned context-head checkpoint persistence.
821    ///
822    /// The receipt stays in-process (actor task → runtime stream task).
823    /// It never crosses the wire — `SessionEventWire` maps this to `Done`.
824    ///
825    /// Park ordering: `can_park()` requires `!self.streaming`, and streaming
826    /// is only cleared on stream completion/error. The checkpoint event
827    /// arrives mid-stream, so park cannot race with a pending checkpoint.
828    /// No explicit drain is needed.
829    async fn handle_context_head_checkpoint(
830        &mut self,
831        session_id: String,
832        messages: Vec<crate::SharedMessage>,
833        receipt: agent_core::core::context_head::ContextHeadReceipt,
834    ) {
835        // Stale session id guard (compaction may have changed it).
836        if session_id != self.conv.session.id {
837            tracing::warn!(
838                event_id = %session_id,
839                actor_id = %self.conv.session.id,
840                "context head checkpoint: stale session id — ignoring"
841            );
842            receipt.complete(Err(std::io::Error::new(
843                std::io::ErrorKind::InvalidInput,
844                "stale session id after compaction",
845            )));
846            return;
847        }
848
849        // Non-persistent sessions (tests / --no-persist): complete without I/O.
850        if !self.config.persist {
851            receipt.complete(Ok(()));
852            return;
853        }
854
855        // A queued ordinary save must not land after (and over) the durable
856        // head: drain the persister first.
857        if !self.persister.flush().await {
858            tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
859        }
860        let result = self.conv.persist_context_head(&session_id, messages).await;
861        if let Err(ref e) = result {
862            tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
863        }
864        let succeeded = result.is_ok();
865        receipt.complete(result);
866
867        // P5: observe_checkpoint — revoke driver on failure or mismatch.
868        if let Some(driver) = &self.driver {
869            if !succeeded
870                || driver.grant.session_id != session_id
871                || session_id != self.conv.session.id
872            {
873                self.driver_revoke("context checkpoint failed or session replaced");
874            }
875        }
876    }
877
878
879    /// C3: the checkpoint reply payload (`SessionReloadRecord`).
880    pub(crate) fn reload_record(&self) -> SessionReloadRecord {
881        let view = self.view.load();
882        SessionReloadRecord {
883            config: self.config.clone(),
884            keep_warm: self.keep_warm,
885            lifecycle: SessionLifecycle::from_u8(
886                self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
887            ),
888            settings_replay: self.settings_replay.clone(),
889            cache_keepwarm: self.cache_keepwarm.override_mode.map(|m| m.to_string()),
890            model: view.model.clone(),
891            thinking_level: view.thinking_level.clone(),
892        }
893    }
894
895    pub(crate) fn publish_presence(&self) {
896        self.presence.store(Arc::new(super::handle::Presence {
897            clients: self.attached.len(),
898            input_owner: self.input_owner,
899            awaiting_input: self.pending_prompts.len(),
900        }));
901    }
902
903    /// Recomputes `state` from clients/streaming and (re)arms or disarms the
904    /// park grace timer. A no-op while Parking/Parked.
905    pub(crate) fn update_attach_state(&mut self) {
906        self.publish_presence();
907        if matches!(self.state, AttachState::Parking | AttachState::Parked) {
908            return;
909        }
910        self.state = if self.attached.is_empty() {
911            AttachState::Detached {
912                running: self.streaming,
913            }
914        } else {
915            AttachState::Attached(self.attached.len())
916        };
917        self.rearm_park();
918        self.rearm_prompt_abandon();
919    }
920
921    // ── Parked (B3) ──────────────────────────────────────────────────────
922
923    pub(crate) fn is_parked(&self) -> bool {
924        matches!(self.state, AttachState::Parked)
925    }
926
927    /// A session with nothing to save (`ConversationState::save` skips an
928    /// empty `api_messages`, so no journal ever exists) never parks: it
929    /// costs nothing warm and could not be unparked from disk (H2).
930    pub(crate) fn can_park(&self) -> bool {
931        // An active cache keep-warm period defers the idle auto-park (its
932        // cost is bounded by the ski-rental ledger); when it ends,
933        // `rearm_park`. An explicit `Park` ends the period first.
934        self.can_park_ignoring_keepwarm() && !self.cache_keepwarm.active()
935    }
936
937    /// `can_park` minus the keep-warm deferral.
938    pub(crate) fn can_park_ignoring_keepwarm(&self) -> bool {
939        self.attached.is_empty()
940            && !self.streaming
941            && self.compact.is_none()
942            && self.pending_prompts.is_empty()
943            && self.conv.is_live()
944            && !self.conv.api_messages.is_empty()
945            && self.conv.queued_message.is_none()
946            && !self.keep_warm
947            && self.config.persist
948            && !self.is_parked()
949            && self.driver.is_none()
950    }
951
952    /// (E-P7, §S3) Return the first breached spend ceiling, if any, as
953    /// `(scope, cost_observed, cap)`. Checks the host-owned per-session cap
954    /// (`config.max_session_cost`) and, when a driver is armed, the effective
955    /// per-run cap (`min(plugin grant, host)`) measured from `cost_at_arm`.
956    /// `None` means every configured ceiling still has headroom.
957    fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
958        let total = self.conv.session_cost;
959        if let Some(cap) = self.config.max_session_cost {
960            if cap.is_finite() && total >= cap {
961                return Some(("session", total, cap));
962            }
963        }
964        if let Some(driver) = &self.driver {
965            // Host cap for the run is not yet a config field (S3 should-have
966            // #4, daemon-level); pass `None` so the plugin's proposal stands
967            // alone until the daemon cap lands.
968            if let Some(cap) = driver.grant.effective_cost_cap(None) {
969                let run_cost = total - driver.cost_at_arm;
970                if cap.is_finite() && run_cost >= cap {
971                    return Some(("run", run_cost, cap));
972                }
973            }
974        }
975        None
976    }
977
978    /// `<sessions>/<id>.json` — written by both persistence modes.
979    fn journal_exists(&self) -> bool {        let id = (**self.journal_id.load()).clone();
980        crate::config::resolve_write_path("sessions")
981            .join(format!("{id}.json"))
982            .is_file()
983    }
984
985    // ── E: driver arm/revoke ─────────────────────────────────────────────
986
987    /// Invalidate the driver: drop `DriverState`, cancel pending work, emit
988    /// `DriverRevoked` with any undelivered steering, and release the host grant.
989    pub(crate) fn driver_revoke(&mut self, reason: &str) {
990        let undelivered = self
991            .driver
992            .as_mut()
993            .map(|d| d.steering.drain(..).collect::<Vec<_>>())
994            .unwrap_or_default();
995        let was_active = self.driver.is_some() || self.driver_pending.is_some();
996        // Dropping the driver below cancels the driver turn's token.
997        if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
998            self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
999        }
1000        if let Some(driver) = &self.driver {
1001            self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
1002            self.host
1003                .release_driver(&driver.grant.plugin_id, &self.id);
1004        }
1005        self.driver_generation = self.driver_generation.wrapping_add(1);
1006        self.driver_pending = None;
1007        self.driver = None;
1008        if was_active {
1009            self.emit(SessionEventWire::DriverRevoked {
1010                reason: reason.to_string(),
1011                undelivered_steering: undelivered,
1012            });
1013        }
1014        // Dropping `DriverState` cancelled the driver turn's token (a child
1015        // of the driver's): that turn is over, but not through `cancel_turn`.
1016        // Unwind prompts now (as `cancel_turn` does), then give its stream
1017        // the drain budget to deliver its cancel-path history and terminal
1018        // event (`finish_revoked_turn`); a stream that never started (its
1019        // start task was just aborted) ends at the next loop turn. A caller
1020        // that cancels the turn itself right after (`finish`, `checkpoint`,
1021        // the cost cap) supersedes this: `cancel_turn` clears the deadline.
1022        if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1023            self.resolve_pending_prompts();
1024            let wait = if self.stream.is_some() {
1025                budgets::CANCEL_DRAIN_TIMEOUT
1026            } else {
1027                std::time::Duration::ZERO
1028            };
1029            self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1030        }
1031        self.rearm_park();
1032    }
1033
1034    /// Spawn the plugin's start command as an async task; the result is
1035    /// processed in `driver_tick()` (P4). Invokes the command via the
1036    /// extension manager, just like the TUI's `start_command`.
1037    pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1038        // Revoke any existing driver first (TUI :369).
1039        if self.driver.is_some() || self.driver_pending.is_some() {
1040            self.driver_revoke("explicit command");
1041        }
1042
1043        let manager = self.host.ext_manager().clone();
1044        let session_id = self.id.0.clone();
1045
1046        // Resolve handler + check permissions synchronously.
1047        let (handler, timeout) = match manager.try_read() {
1048            Ok(mgr) => {
1049                let handler = match mgr.user_action_handler(&plugin) {
1050                    Ok(h) => h,
1051                    Err(error) => {
1052                        self.emit(SessionEventWire::SystemNotice(error));
1053                        return;
1054                    }
1055                };
1056                let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1057                    5
1058                } else {
1059                    self.emit(SessionEventWire::SystemNotice(
1060                        "session driver extension lacks validated session.drive permission".into(),
1061                    ));
1062                    return;
1063                };
1064                (handler, timeout)
1065            }
1066            Err(_) => {
1067                self.emit(SessionEventWire::SystemNotice(
1068                    "extensions are loading or busy — try again shortly".into(),
1069                ));
1070                return;
1071            }
1072        };
1073
1074        let handler_generation = super::driver::live_generation(&handler).ok();
1075
1076        let owner = plugin.clone();
1077        let cmd = command.clone();
1078        let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1079
1080        let generation = self.driver_generation;
1081        let task_handler = handler.clone();
1082        self.driver_pending = Some(super::driver::DriverPending {
1083            generation,
1084            session_id: session_id.clone(),
1085            task: super::driver::Task(tokio::spawn(async move {
1086                let (sink, collector) =
1087                    invoke_event_channel(InvokeOutputBudget::default());
1088                let request_id = uuid::Uuid::new_v4().to_string();
1089                let (result, report) =
1090                    tokio::time::timeout(Duration::from_secs(timeout), async {
1091                        tokio::join!(
1092                            task_handler.invoke_command(&cmd, args, &request_id, sink),
1093                            collector.collect()
1094                        )
1095                    })
1096                    .await
1097                    .unwrap_or_else(|_| {
1098                        (
1099                            Err(format!("interactive command timed out ({timeout}s)")),
1100                            crate::extensions::invoke_output::InvokeOutputReport {
1101                                events: Vec::new(),
1102                                counters: Default::default(),
1103                            },
1104                        )
1105                    });
1106                super::driver::TaskResult::Command {
1107                    owner,
1108                    command,
1109                    handler: task_handler,
1110                    handler_generation,
1111                    result,
1112                    report,
1113                }
1114            })),
1115        });
1116    }
1117
1118    /// Arm the driver after a successful start command result. Mirrors
1119    /// the TUI's `arm()` (:538-602). Called from P4's `driver_tick()`.
1120    pub(crate) fn driver_arm(
1121        &mut self,
1122        owner: String,
1123        handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1124        handler_generation: u64,
1125        reply: Reply,
1126    ) -> std::result::Result<(), String> {
1127        let manager = self.host.ext_manager().clone();
1128        super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1129
1130        if self.driver.is_some() {
1131            return Err("cannot replace an armed grant".into());
1132        }
1133
1134        let session_id = self.id.0.clone();
1135        let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1136
1137        // Claim the host-level single-tenancy grant.
1138        self.host
1139            .claim_driver(&owner, &self.id)
1140            .map_err(|e| e.to_string())?;
1141
1142        let cancel = CancellationToken::new();
1143        let workers = self.runtime.subagent_registry().clone();
1144        let worker_epoch = workers
1145            .lock()
1146            .unwrap_or_else(|p| p.into_inner())
1147            .set_spawn_cancellation(Some(cancel.clone()));
1148        let deadline_task = grant.deadline().map(|deadline| {
1149            let c = cancel.clone();
1150            let w = workers.clone();
1151            super::driver::Task(tokio::spawn(async move {
1152                tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1153                c.cancel();
1154                super::driver::cancel_workers(&w, worker_epoch);
1155            }))
1156        });
1157
1158        let models = grant.models().to_vec();
1159        let proposal_notice = proposal.notice.clone();
1160        let selection = proposal.selection.clone();
1161        let deadline_ms = super::driver::deadline_ms(&grant);
1162        let run_id = grant.run_id.clone();
1163        let cost_at_arm = self.conv.session_cost;
1164
1165        let mut state = super::driver::DriverState {
1166            selection: proposal.selection.clone(),
1167            grant,
1168            handler,
1169            handler_generation,
1170            cancel,
1171            workers,
1172            worker_epoch,
1173            deadline_task,
1174            proposal: None,
1175            awaiting_terminal: false,
1176            outcome: None,
1177            feedback: crate::extensions::feedback::Tracker::default(),
1178            completed_feedback: "unknown",
1179            steering: VecDeque::new(),
1180            auto_wakes_blocked: false,
1181            cost_at_arm,
1182        };
1183
1184        super::driver::schedule(&mut state, proposal)?;
1185
1186        // Apply context mode (session-only, like /context auto/off).
1187        let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1188        if let Some(text) = context_notice {
1189            self.emit(SessionEventWire::SystemNotice(text));
1190        }
1191
1192        self.driver_interrupted_owner = None;
1193        self.driver = Some(state);
1194
1195        self.emit(SessionEventWire::DriverArmed {
1196            plugin_id: owner,
1197            run_id,
1198            models,
1199            selection,
1200            deadline_ms,
1201            notice: proposal_notice,
1202        });
1203
1204        Ok(())
1205    }
1206
1207    // ── E-P4: driver_tick ────────────────────────────────────────────────
1208
1209    /// Actor-equivalent of the TUI's `idle_conflict()` (TUI :437-463).
1210    /// Returns `Some(reason)` when the driver must defer or revoke.
1211    fn driver_idle_conflict(&self) -> Option<&'static str> {
1212        if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1213            return Some("other queued work");
1214        }
1215        if self.compact.is_some() {
1216            return Some("compaction");
1217        }
1218        if self.ext_ready.is_some() {
1219            return Some("extensions loading or reloading");
1220        }
1221        if self.conv.context_head.is_blocked(&self.conv.session) {
1222            return Some("unverified context head");
1223        }
1224        if self.driver_completion_blocked() {
1225            return Some("outstanding workers require collection/reconciliation");
1226        }
1227        None
1228    }
1229
1230    /// Actor-equivalent of the TUI's `completion_blocked()` (TUI :465-480).
1231    fn driver_completion_blocked(&self) -> bool {
1232        use agent_core::orchestration::CompletionGate;
1233        self.runtime.orchestration().is_some_and(|o| {
1234            !matches!(o.completion_gate(), CompletionGate::Allowed)
1235        }) || self
1236            .runtime
1237            .subagent_registry()
1238            .lock()
1239            .map(|r| {
1240                r.list_active().iter().any(|(_, _, status)| {
1241                    *status == crate::runtime::subagent::SubagentStatus::Running
1242                })
1243            })
1244            .unwrap_or(true)
1245    }
1246
1247    /// 200 ms timer arm — the core driver loop. Every invocation performs only
1248    /// bounded state transitions; all plugin and provider IO is in abort-on-drop
1249    /// spawned tasks. Mirrors the TUI's `tick()` (TUI :695-1027).
1250    pub(crate) async fn driver_tick(&mut self) {
1251        use crate::extensions::session_driver::Outcome;
1252
1253        // ── 1. Cleanup cancelled stream setup ────────────────────────────
1254        // (e.g. the grant's deadline cancelled the driver token before the
1255        // turn's stream started): an interrupted turn, ended like any other.
1256        if self.stream.is_none() && self.turn_cancelled() {
1257            self.finish_revoked_turn(false).await;
1258        }
1259
1260        // ── 2. Validate lifecycle ────────────────────────────────────────
1261        if let Some(driver) = &self.driver {
1262            let invalid = if driver.grant.session_id != self.id.0 {
1263                Some("session replaced".to_string())
1264            } else if driver.grant.expired() {
1265                Some("grant deadline reached".into())
1266            } else if driver.cancel.is_cancelled() {
1267                Some("canceled".into())
1268            } else {
1269                let manager = self.host.ext_manager().clone();
1270                super::driver::same_handler(
1271                    &manager,
1272                    &driver.grant.plugin_id,
1273                    &driver.handler,
1274                    driver.handler_generation,
1275                )
1276                .err()
1277            };
1278            if let Some(reason) = invalid {
1279                self.driver_revoke(&reason);
1280                return;
1281            }
1282        }
1283
1284        // ── 3. Idle conflict (only when driver is armed) ─────────────────
1285        if self.driver.is_some() {
1286            // (E-P7, §S1) No clients → no headless spend. `detach()` revokes on
1287            // the last-client transition; this is the belt-and-suspenders check
1288            // for any path that leaves a grant armed with zero clients.
1289            if self.attached.is_empty() {
1290                self.driver_revoke("no clients attached");
1291                return;
1292            }
1293            // F10/F14: use the full driver_idle_conflict() instead of an
1294            // inline subset that was missing `completion_blocked`.
1295            if let Some(reason) = self.driver_idle_conflict() {
1296                self.driver_revoke(reason);
1297                return;
1298            }
1299
1300            // Let the reactor classify queued events before deciding whether they
1301            // are competing work or steering within this owned turn.
1302            if !self.runtime.event_queue().is_empty() {
1303                return;
1304            }
1305        }
1306
1307        // ── 4. Process finished pending task ─────────────────────────────
1308        if self
1309            .driver_pending
1310            .as_ref()
1311            .is_some_and(|p| p.task.0.is_finished())
1312        {
1313            let mut pending = self.driver_pending.take().expect("checked pending");
1314            if pending.generation != self.driver_generation
1315                || pending.session_id != self.id.0
1316            {
1317                return;
1318            }
1319            let result = match (&mut pending.task.0).await {
1320                Ok(result) => result,
1321                Err(error) => {
1322                    self.driver_revoke("driver task failed");
1323                    self.emit(SessionEventWire::SystemNotice(format!(
1324                        "Session driver task failed: {error}"
1325                    )));
1326                    return;
1327                }
1328            };
1329            match result {
1330                super::driver::TaskResult::Command {
1331                    owner,
1332                    command: _,
1333                    handler,
1334                    handler_generation,
1335                    result,
1336                    report,
1337                } => {
1338                    let current = self
1339                        .host
1340                        .ext_manager()
1341                        .try_read()
1342                        .ok()
1343                        .and_then(|m| m.user_action_handler(&owner).ok());
1344                    if !current
1345                        .as_ref()
1346                        .is_some_and(|current| Arc::ptr_eq(current, &handler))
1347                    {
1348                        self.driver_revoke(
1349                            "command owner unloaded, replaced, or unavailable",
1350                        );
1351                        return;
1352                    }
1353                    let output_failed = report.is_limited()
1354                        || report.events.iter().any(|event| {
1355                            matches!(
1356                                event,
1357                                crate::extensions::runtime::InvokeCommandEvent::Output(
1358                                    crate::extensions::commands::CommandOutputEvent::Error { .. }
1359                                )
1360                            )
1361                        });
1362                    // F7 (shady should-fix): plugin command output is visible
1363                    // to the user, but BATCHED into one notice (cap 20 lines) —
1364                    // a chatty `/auto start` must not flood the client with one
1365                    // wire event per line.
1366                    let mut lines: Vec<String> = Vec::new();
1367                    for event in &report.events {
1368                        if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1369                            let text = match out {
1370                                crate::extensions::commands::CommandOutputEvent::Text { content }
1371                                | crate::extensions::commands::CommandOutputEvent::System { content } => {
1372                                    Some(content.clone())
1373                                }
1374                                crate::extensions::commands::CommandOutputEvent::Error { content } => {
1375                                    Some(format!("Error: {content}"))
1376                                }
1377                                _ => None,
1378                            };
1379                            if let Some(text) = text {
1380                                lines.push(text);
1381                            }
1382                        }
1383                    }
1384                    if !lines.is_empty() {
1385                        const MAX_LINES: usize = 20;
1386                        let truncated = lines.len() > MAX_LINES;
1387                        if truncated {
1388                            lines.truncate(MAX_LINES);
1389                            lines.push("…output truncated".into());
1390                        }
1391                        self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
1392                    }
1393                    if let Some(notice) = report.limit_notice() {
1394                        self.emit(SessionEventWire::SystemNotice(format!(
1395                            "command output truncated: {}",
1396                            notice.message,
1397                        )));
1398                    }
1399                    if let Ok(value) = result {
1400                        let result = match protocol::parse_reply(&value) {
1401                            Ok(Some(reply @ Reply::Start { .. })) => {
1402                                if output_failed {
1403                                    Err("command reported an error or exceeded its output budget"
1404                                        .into())
1405                                } else if self.streaming || self.stream.is_some() {
1406                                    Err("foreground turn still active".into())
1407                                } else if let Some(reason) = self.driver_idle_conflict() {
1408                                    Err(reason.into())
1409                                } else {
1410                                    match handler_generation {
1411                                        Some(generation) => {
1412                                            self.driver_arm(
1413                                                owner, handler, generation, reply,
1414                                            )
1415                                        }
1416                                        None => Err(
1417                                            "command owner did not expose a live lifecycle at dispatch"
1418                                                .into(),
1419                                        ),
1420                                    }
1421                                }
1422                            }
1423                            Ok(Some(
1424                                Reply::Stop { notice: text }
1425                                | Reply::Status { notice: text },
1426                            )) => {
1427                                self.emit(SessionEventWire::SystemNotice(text));
1428                                Ok(())
1429                            }
1430                            Ok(Some(Reply::Next { .. })) => Err(
1431                                "next requires an armed poll, not an interactive command"
1432                                    .into(),
1433                            ),
1434                            Ok(None) => Ok(()),
1435                            Err(error) => Err(error),
1436                        };
1437                        if let Err(error) = result {
1438                            self.emit(SessionEventWire::SystemNotice(format!(
1439                                "Session driver rejected: {error}"
1440                            )));
1441                        }
1442                    }
1443                }
1444                super::driver::TaskResult::Poll(result) => {
1445                    let Some(driver) = self.driver.as_mut() else {
1446                        return;
1447                    };
1448                    let stop_notice = match &result {
1449                        Ok(Reply::Stop { notice }) => Some(notice.clone()),
1450                        _ => None,
1451                    };
1452                    match result.and_then(|reply| driver.grant.accept(reply)) {
1453                        Ok(Some(proposal)) => {
1454                            let text = proposal.notice.clone();
1455                            if let Err(error) =
1456                                super::driver::schedule(driver, proposal)
1457                            {
1458                                self.driver_revoke(&error);
1459                            } else {
1460                                self.emit(SessionEventWire::SystemNotice(text));
1461                            }
1462                        }
1463                        Ok(None) => {
1464                            self.driver_revoke("owner requested stop");
1465                            if let Some(text) = stop_notice {
1466                                self.emit(SessionEventWire::SystemNotice(text));
1467                            }
1468                        }
1469                        Err(error) => {
1470                            self.driver_revoke(&format!("invalid poll response: {error}"))
1471                        }
1472                    }
1473                }
1474                super::driver::TaskResult::Prepared { proposal, result } => {
1475                    if self.streaming || self.stream.is_some() {
1476                        self.driver_revoke("foreground work took priority");
1477                        return;
1478                    }
1479                    if let Some(reason) = self.driver_idle_conflict() {
1480                        self.driver_revoke(reason);
1481                        return;
1482                    }
1483                    match *result {
1484                        Err(protocol::PrepareError::Selection(error)) => {
1485                            self.emit(SessionEventWire::SystemNotice(format!(
1486                                "Session driver selection rejected (nothing sent): {error}"
1487                            )));
1488                            if let Some(driver) = self.driver.as_mut() {
1489                                driver.outcome = Some((
1490                                    Outcome::SelectionRejected,
1491                                    "unknown".into(),
1492                                ));
1493                            }
1494                        }
1495                        Err(protocol::PrepareError::Blocked(error)) => {
1496                            self.driver_revoke(&format!("preflight blocked: {error}"));
1497                        }
1498                        Ok(candidate) => {
1499                            let validated = proposal.clone();
1500                            let history = super::driver::history_with_steering(
1501                                &self.conv.api_messages,
1502                                &self
1503                                    .driver
1504                                    .as_ref()
1505                                    .map(|d| &d.steering)
1506                                    .cloned()
1507                                    .unwrap_or_default(),
1508                            );
1509                            if let Err(error) = protocol::validate_prepared(
1510                                &candidate, &validated, &history,
1511                            ) {
1512                                self.driver_revoke(&format!(
1513                                    "prepared selection changed before commit: {error}"
1514                                ));
1515                                return;
1516                            }
1517                            // F3: commit steering + prompt via the
1518                            // driver.rs helper (single call site).
1519                            // Destructure to split borrows across conv fields.
1520                            {
1521                                let driver = self.driver.as_mut().expect("driver present");
1522                                let conv = &mut *self.conv;
1523                                super::driver::commit_submission(
1524                                    &mut conv.api_messages,
1525                                    &mut driver.steering,
1526                                    &proposal.prompt,
1527                                );
1528                            }
1529                            // Apply the prepared runtime.
1530                            *self.runtime = candidate;
1531                            self.conv.session.model = self.runtime.model().to_owned();
1532                            self.conv.session.thinking_level =
1533                                self.runtime.thinking_level().to_owned();
1534                            self.consecutive_auto_turns = 0;
1535                            self.turn_baseline = self.conv.api_messages.len();
1536                            self.turn_steered_events.clear();
1537                            self.turn_replay.clear();
1538                            self.streaming = true;
1539                            self.open_turn_draft();
1540                            self.update_attach_state();
1541                            self.emit(SessionEventWire::TurnStarted {
1542                                turn_baseline: self.turn_baseline,
1543                                trigger: TurnTrigger::DriverAuto,
1544                                user_text: Some(proposal.prompt.clone()),
1545                            });
1546
1547                            // Set up the driver's awaiting_terminal.
1548                            if let Some(driver) = self.driver.as_mut() {
1549                                driver.awaiting_terminal = true;
1550                                driver.completed_feedback = "unknown";
1551                                if driver.grant.feedback_enabled() {
1552                                    driver.feedback.begin_turn(
1553                                        &driver.selection.model,
1554                                        &driver.selection.effort,
1555                                    );
1556                                }
1557                            }
1558
1559                            // Spawn stream start as a task (never block tick).
1560                            let handler = self
1561                                .driver
1562                                .as_ref()
1563                                .expect("driver")
1564                                .handler
1565                                .clone();
1566                            let handler_generation = self
1567                                .driver
1568                                .as_ref()
1569                                .expect("driver")
1570                                .handler_generation;
1571                            let ct = self
1572                                .driver
1573                                .as_ref()
1574                                .expect("driver")
1575                                .cancel
1576                                .child_token();
1577                            self.cancel = Some(ct.clone());
1578                            // Cancelled by anything that notes no cause (the
1579                            // grant's deadline task): the driver ending.
1580                            let completion = crate::runtime::TurnCompletion::with_default_cause(
1581                                crate::runtime::CancelCause::Driver,
1582                            );
1583                            self.turn_completion = Some(completion.clone());
1584                            let (tx, rx) = mpsc::unbounded_channel();
1585                            self.steer_tx = Some(tx);
1586                            let mut runtime = self.runtime.clone();
1587                            // F2: share the parent's TTL latches so the
1588                            // driver turn doesn't fire a redundant 1h→5m
1589                            // downgrade notice.
1590                            runtime.share_ttl_latches(&self.runtime);
1591                            let history = self.conv.api_messages.clone();
1592                            let secret = self.secret_prompt_handle.clone();
1593                            let session_id = self.id.0.clone();
1594                            let generation = self.driver_generation;
1595                            self.driver_pending = Some(super::driver::DriverPending {
1596                                generation,
1597                                session_id,
1598                                task: super::driver::Task(tokio::spawn(async move {
1599                                    if ct.is_cancelled() {
1600                                        return super::driver::TaskResult::Started(
1601                                            Err("canceled before stream setup".into()),
1602                                        );
1603                                    }
1604                                    if let Err(error) =
1605                                        super::driver::same_lifecycle(
1606                                            &handler,
1607                                            handler_generation,
1608                                        )
1609                                    {
1610                                        return super::driver::TaskResult::Started(
1611                                            Err(error),
1612                                        );
1613                                    }
1614                                    tokio::select! {
1615                                        biased;
1616                                        _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
1617                                        started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
1618                                    }
1619                                })),
1620                            });
1621
1622                            // Start subagent tick (same as normal start_turn).
1623                            let mut tick = tokio::time::interval(
1624                                std::time::Duration::from_secs(1),
1625                            );
1626                            tick.set_missed_tick_behavior(
1627                                tokio::time::MissedTickBehavior::Delay,
1628                            );
1629                            tick.reset();
1630                            self.subagent_tick = Some(tick);
1631                            self.request_save();
1632                        }
1633                    }
1634                }
1635                super::driver::TaskResult::Started(started) => match started {
1636                    Ok(started) if self.driver.is_some() => {
1637                        self.stream = Some(started);
1638                    }
1639                    Err(error) => {
1640                        self.driver_revoke(&error);
1641                    }
1642                    _ => {}
1643                },
1644            }
1645        }
1646
1647        // ── 5. Idle scheduling ───────────────────────────────────────────
1648        if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
1649            return;
1650        }
1651        if let Some(reason) = self.driver_idle_conflict() {
1652            self.driver_revoke(reason);
1653            return;
1654        }
1655        let session_cost = self.conv.session_cost;
1656        let Some(driver) = self.driver.as_mut() else {
1657            return;
1658        };
1659        if let Some((outcome, error_kind)) = driver.outcome.take() {
1660            let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
1661            let handler = driver.handler.clone();
1662            let session_id = self.id.0.clone();
1663            let generation = self.driver_generation;
1664            self.driver_pending = Some(super::driver::DriverPending {
1665                generation,
1666                session_id,
1667                task: super::driver::Task(tokio::spawn(async move {
1668                    // F4: poll must not hang forever — 30 s timeout like
1669                    // the prepare path's 5 s.
1670                    let result = tokio::time::timeout(
1671                        Duration::from_secs(30),
1672                        protocol::poll(handler, request),
1673                    )
1674                    .await
1675                    .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
1676                    super::driver::TaskResult::Poll(result)
1677                })),
1678            });
1679        } else if driver
1680            .proposal
1681            .as_ref()
1682            .is_some_and(|p| Instant::now() >= p.due)
1683        {
1684            let proposal =
1685                driver.proposal.take().expect("checked proposal").proposal;
1686            // Clone is read-only (prepare never sends a request) and
1687            // discarded — no TTL latch sharing needed.
1688            let runtime = self.runtime.clone();
1689            let history = super::driver::history_with_steering(
1690                &self.conv.api_messages,
1691                &driver.steering,
1692            );
1693            let session_id = self.id.0.clone();
1694            let generation = self.driver_generation;
1695            self.driver_pending = Some(super::driver::DriverPending {
1696                generation,
1697                session_id,
1698                task: super::driver::Task(tokio::spawn(async move {
1699                    let result = tokio::time::timeout(
1700                        Duration::from_secs(5),
1701                        protocol::prepare(&runtime, &proposal, &history),
1702                    )
1703                    .await
1704                    .unwrap_or_else(|_| {
1705                        Err(protocol::PrepareError::Blocked(
1706                            "preparation timed out (5s)".into(),
1707                        ))
1708                    });
1709                    super::driver::TaskResult::Prepared {
1710                        proposal,
1711                        result: Box::new(result),
1712                    }
1713                })),
1714            });
1715        }
1716    }
1717
1718    /// F10: the session lock follows the conversation to `new_id` (a fresh
1719    /// id from NewSession or compaction). Best-effort: on failure the old
1720    /// lock is still released (the conversation left it) and the failure
1721    /// logged. The new lock is taken BEFORE the old one is dropped.
1722    pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
1723        match Self::try_lock_session(new_id) {
1724            Ok(lock) => self.session_lock = Some(lock),
1725            Err(e) => {
1726                tracing::warn!(session = %new_id, "reacquire session lock: {e}");
1727                self.session_lock = None;
1728            }
1729        }
1730    }
1731
1732    /// Try to take the journal lock on `id` for this daemon process.
1733    pub(crate) fn try_lock_session(
1734        id: &str,
1735    ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
1736    {
1737        let dir = agent_core::session_lock::sessions_dir();
1738        let holder = agent_core::session_lock::LockHolder {
1739            pid: std::process::id(),
1740            kind: "daemon".to_string(),
1741        };
1742        agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
1743    }
1744
1745    /// F18: a session with NO history can never park (nothing to journal),
1746    /// so with no clients it would stay Live — Runtime resident — forever.
1747    /// Same gates as `can_park` minus the history requirement: at the park
1748    /// deadline such a session ends instead.
1749    pub(crate) fn can_end_idle(&self) -> bool {
1750        self.attached.is_empty()
1751            && !self.streaming
1752            && self.compact.is_none()
1753            && self.pending_prompts.is_empty()
1754            && self.conv.is_live()
1755            && self.conv.api_messages.is_empty()
1756            && self.conv.queued_message.is_none()
1757            && !self.keep_warm
1758            && !self.is_parked()
1759            // An armed driver keeps the session alive between turns exactly
1760            // like an attached client would (shady P3/P4 F6: without this, a
1761            // zero-turn armed session is idle-ended 5 s after the last client
1762            // detaches, killing the run mid-arm). `can_park` already guards.
1763            && self.driver.is_none()
1764            && self.driver_pending.is_none()
1765    }
1766
1767    pub(crate) fn rearm_park(&mut self) {
1768        if self.is_parked() {
1769            // Eviction deadline is owned by `park()`; the attach path unparks
1770            // (which rebuilds the runtime and clears the deadline).
1771            return;
1772        }
1773        if self.can_end_idle() {
1774            // F18: nothing to keep warm — a zero-turn session with no
1775            // clients ends after a short grace, not the full park grace
1776            // (which exists to keep a runtime WITH history warm for a
1777            // quick reconnect).
1778            if self.park_deadline.is_none() {
1779                self.park_deadline =
1780                    Some(tokio::time::Instant::now() + idle_end_grace());
1781            }
1782            return;
1783        }
1784        if !self.can_park() {
1785            self.park_deadline = None;
1786            return;
1787        }
1788        match park_grace() {
1789            Some(grace) => {
1790                if self.park_deadline.is_none() {
1791                    self.park_deadline = Some(tokio::time::Instant::now() + grace);
1792                }
1793            }
1794            None => self.park_deadline = None,
1795        }
1796    }
1797
1798    /// (P11) Bound the resource pin from an ABANDONED prompt. A pending host
1799    /// confirmation on a plain interactive session survives detach so the user
1800    /// reattaches and answers it — but if NObody ever comes back it pins the
1801    /// session forever (`can_park()` requires `pending_prompts` empty). Arm a
1802    /// deadline only in that abandoned state (zero clients, prompt pending, no
1803    /// driver); a re-attach clears it (prompt survives, user answers); expiry
1804    /// denies the prompt(s). The driver-armed case fail-closes immediately in
1805    /// `detach` and never reaches here. Idempotent: safe to call on every
1806    /// attach/detach/prompt transition.
1807    fn rearm_prompt_abandon(&mut self) {
1808        let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
1809        let abandoned = self.attached.is_empty()
1810            && !self.pending_prompts.is_empty()
1811            && !armed_driver
1812            && !self.is_parked();
1813        if !abandoned {
1814            // Someone is attached, the prompt was answered, or a driver took
1815            // over the fail-closed path — never auto-deny while any of these.
1816            self.prompt_abandon_deadline = None;
1817            return;
1818        }
1819        if self.prompt_abandon_deadline.is_none() {
1820            // `None` config ⇒ deadline stays `None` ⇒ disabled (pre-#112).
1821            self.prompt_abandon_deadline =
1822                prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
1823        }
1824    }
1825
1826    /// (P11) The prompt-abandonment deadline expired with still zero clients:
1827    /// deny every pending prompt (send `None` + emit `PromptResolved`) so the
1828    /// session stops zombie-blocking and `can_park()` becomes true. A re-attach
1829    /// would have cleared the deadline first; re-check defensively.
1830    fn on_prompt_abandon_deadline(&mut self) {
1831        self.prompt_abandon_deadline = None;
1832        if !self.attached.is_empty() {
1833            return;
1834        }
1835        let had_prompts = !self.pending_prompts.is_empty();
1836        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
1837            let _ = tx.send(None);
1838            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
1839        }
1840        if had_prompts {
1841            tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
1842            self.publish_presence();
1843            // `can_park()` may now be true — re-evaluate the park deadline.
1844            self.rearm_park();
1845        }
1846    }
1847
1848    fn set_lifecycle(&mut self, l: SessionLifecycle) {
1849        self.lifecycle
1850            .store(l as u8, std::sync::atomic::Ordering::Release);
1851        self.emit(SessionEventWire::Lifecycle(l));
1852    }
1853
1854    /// Save, close PTYs, drop `conv` THEN `runtime`. `background` (inbox
1855    /// watcher + per-session UDS + registry) stays: `synaps send` keeps
1856    /// resolving and its push into `event_queue` is the wake-up.
1857    pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
1858        self.park_deadline = None;
1859        if self.is_parked() {
1860            if self.attached.is_empty() {
1861                tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
1862                return std::ops::ControlFlow::Break(EndReason::Evicted);
1863            }
1864            return std::ops::ControlFlow::Continue(());
1865        }
1866        if self.can_end_idle() {
1867            tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
1868            // No journal will ever exist for this id: take the lock file
1869            // with us instead of leaving an orphan `.lock` (RC soak F-NEW-1).
1870            if let Some(lock) = self.session_lock.take() {
1871                lock.release_and_remove();
1872            }
1873            return std::ops::ControlFlow::Break(EndReason::Idle);
1874        }
1875        if !self.can_park() {
1876            return std::ops::ControlFlow::Continue(());
1877        }
1878        self.state = AttachState::Parking;
1879        self.set_lifecycle(SessionLifecycle::Parking);
1880        // Parking drops the conversation from memory: everything queued must
1881        // be on disk first, and a failed save keeps the session live.
1882        if !self.save().await {
1883            tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
1884            self.state = AttachState::Detached { running: false };
1885            self.set_lifecycle(SessionLifecycle::Live);
1886            return std::ops::ControlFlow::Continue(());
1887        }
1888        if !self.journal_exists() {
1889            // Never park what cannot be restored (H2).
1890            tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
1891            self.state = AttachState::Detached { running: false };
1892            self.set_lifecycle(SessionLifecycle::Live);
1893            return std::ops::ControlFlow::Continue(());
1894        }
1895        if self.runtime.session_manager().active_count() > 0 {
1896            self.emit(SessionEventWire::SystemNotice(
1897                "parked: background shells closed".into(),
1898            ));
1899        }
1900        self.runtime.session_manager().shutdown_all();
1901        self.turn_replay.clear();
1902        // Drop order: conv first, then runtime — a panic between them can
1903        // never leave a Runtime without state to save.
1904        let conv = self.conv.park_take();
1905        drop(conv);
1906        let runtime = self.runtime.park_take();
1907        drop(runtime);
1908        // Hand the freed pages back: jemalloc otherwise keeps them as
1909        // dirty/muzzy for its decay window, and a parked session that
1910        // still shows in RssAnon buys nothing.
1911        crate::core::memstat::purge_arenas();
1912        // F10: release the journal lock so an in-process --continue can
1913        // pick up the parked session (the daemon will fail to re-acquire
1914        // on unpark and surface a clear error instead of a zombie).
1915        self.session_lock = None;
1916        self.state = AttachState::Parked;
1917        self.set_lifecycle(SessionLifecycle::Parked);
1918        tracing::info!(session = %self.id, "session parked");
1919        // The park timer is reused as the eviction timer: a session nobody
1920        // re-attaches to within `parked_evict_after` leaves the map (its
1921        // journal stays; `--continue` brings it back).
1922        self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
1923        std::ops::ControlFlow::Continue(())
1924    }
1925
1926    /// Rebuild `runtime` + `conv` from the journal (`load_session_in_dir`
1927    /// via `resolve_session_and_prompt`), replay non-persisted settings,
1928    /// `finish_session_setup(IndexRecord::Skip)`. On error the session
1929    /// stays Parked (nothing is half-built).
1930    pub(crate) async fn unpark(&mut self) -> Result<()> {
1931        let started = std::time::Instant::now();
1932        // Somebody came back: the eviction deadline armed by `park()` is void.
1933        self.park_deadline = None;
1934        let journal_id = (**self.journal_id.load()).clone();
1935
1936        // F10: re-acquire the journal lock before rebuilding the runtime.
1937        // If an in-process --continue took over while we were parked, fail
1938        // loudly so the attach gets a clear error instead of a zombie.
1939        {
1940            let dir = agent_core::session_lock::sessions_dir();
1941            let holder = agent_core::session_lock::LockHolder {
1942                pid: std::process::id(),
1943                kind: "daemon".to_string(),
1944            };
1945            match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
1946                Ok(lock) => self.session_lock = Some(lock),
1947                Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
1948                    return Err(crate::RuntimeError::Session(format!(
1949                        "cannot unpark session {}: journal locked by another process",
1950                        journal_id
1951                    )));
1952                }
1953                Err(e) => {
1954                    tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
1955                    // Non-fatal: proceed without the lock (e.g. read-only fs).
1956                }
1957            }
1958        }
1959
1960        let host = Arc::clone(&self.host);
1961        let cfg = self.config.clone();
1962        let queue = Arc::clone(&self.event_queue);
1963        let replay = self.settings_replay.clone();
1964        let journal_present = self.journal_exists();
1965        let view = self.view.load_full();
1966        let build = async move {
1967            let config: crate::SynapsConfig = (**host.config()).clone();
1968            // §4: cwd before apply_config — see `create`.
1969            let mut runtime = host.foreground_runtime_for(cfg.cwd.clone()).await?;
1970            runtime.set_event_queue(queue);
1971            let mut sb = if journal_present {
1972                crate::engine::setup::resolve_session_and_prompt(
1973                    &mut runtime,
1974                    &Some(Some(journal_id)),
1975                    cfg.system.as_deref(),
1976                    cfg.prompt_manifest.as_deref(),
1977                )?
1978            } else {
1979                // The journal vanished under us (H2): rebuild an empty
1980                // conversation under the SAME id with the last-published
1981                // model/thinking rather than leaving a zombie Parked session.
1982                tracing::warn!(
1983                    session = %journal_id,
1984                    "unpark: journal missing — restoring as a fresh conversation"
1985                );
1986                let mut sb = crate::engine::setup::resolve_session_and_prompt(
1987                    &mut runtime,
1988                    &None,
1989                    cfg.system.as_deref(),
1990                    cfg.prompt_manifest.as_deref(),
1991                )?;
1992                sb.session.id = journal_id.clone();
1993                runtime.set_session_id(Some(journal_id));
1994                sb
1995            };
1996            runtime.set_cwd(cfg.cwd.clone());
1997            runtime.set_env(cfg.env.clone());
1998            runtime.set_env_stripped(cfg.env_stripped.clone());
1999            // The CURRENT model/thinking (the last published view), not
2000            // `cfg.model_override` frozen at create: `/model` survives park.
2001            runtime.set_model(view.model.clone());
2002            let _ = runtime.restore_session_reasoning(&view.thinking_level);
2003            sb.session.model = view.model.clone();
2004            sb.session.thinking_level = runtime.thinking_level().to_string();
2005            crate::engine::setup::finish_session_setup(
2006                &mut runtime,
2007                &config,
2008                &sb.session,
2009                cfg.cwd.clone(),
2010                crate::engine::setup::IndexRecord::Skip,
2011            );
2012            for s in &replay {
2013                replay_setting(&mut runtime, s);
2014            }
2015            Ok::<_, crate::RuntimeError>((runtime, sb))
2016        };
2017        let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
2018            Ok(Ok(v)) => v,
2019            Ok(Err(e)) => return Err(e),
2020            Err(_) => {
2021                return Err(crate::RuntimeError::Session(format!(
2022                    "unpark timed out after {}s",
2023                    budgets::UNPARK_TIMEOUT_SECS
2024                )))
2025            }
2026        };
2027        let mut conv = ConversationState::from_resumed(sb.session);
2028        conv.api_messages = sb.api_messages;
2029        conv.total_input_tokens = sb.total_input_tokens;
2030        conv.total_output_tokens = sb.total_output_tokens;
2031        conv.session_cost = sb.session_cost;
2032        if journal_present && self.config.persist && self.session_lock.is_some() {
2033            crate::engine::setup::recover_turn_draft(&mut conv).await;
2034        }
2035        self.runtime.unpark_set(runtime);
2036        self.conv.unpark_set(conv);
2037        self.publish_view().await;
2038        self.state = AttachState::Detached { running: false };
2039        self.set_lifecycle(SessionLifecycle::Live);
2040        self.update_attach_state();
2041        tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2042        Ok(())
2043    }
2044
2045    /// Unpark if Parked. `Err` = still Parked; the caller reports it
2046    /// (`Refused` to a client, `AttachRefused` to an attacher, a warning
2047    /// for host wakes).
2048    pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2049        if !self.is_parked() {
2050            return Ok(());
2051        }
2052        self.unpark()
2053            .await
2054            .map_err(|e| format!("session parked and could not be restored: {e}"))
2055    }
2056
2057    /// `stream_handler.rs:264`: a turn OR a compaction blocks auto-turns.
2058    pub(crate) fn busy(&self) -> bool {
2059        self.streaming || self.compact.is_some()
2060    }
2061
2062    /// `runtime.subagent_registry().display_rows()` → `SubagentRows`, only
2063    /// when there is something to show (the TUI's reconcile is a no-op on
2064    /// an empty registry).
2065    ///
2066    /// The 1 Hz tick that drives this starts with a turn and outlives it
2067    /// while a background worker (`subagent_start`) is still running: those
2068    /// workers' progress events rode the ended turn's stream, so these rows
2069    /// (which carry each worker's step and tool count) are how clients keep
2070    /// up. The tick stops once no worker is running after the turn, having
2071    /// published the terminal rows.
2072    pub(crate) fn publish_subagent_rows(&mut self) {
2073        let rows = self
2074            .runtime
2075            .subagent_registry()
2076            .lock()
2077            .unwrap_or_else(|p| p.into_inner())
2078            .display_rows();
2079        let running = rows
2080            .iter()
2081            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2082        if !rows.is_empty() {
2083            self.emit(SessionEventWire::SubagentRows(rows));
2084        }
2085        if !keep_subagent_tick(self.streaming, running) {
2086            self.subagent_tick = None;
2087        }
2088    }
2089
2090    /// Whether any worker in the registry is still running.
2091    fn subagents_running(&self) -> bool {
2092        self.runtime
2093            .subagent_registry()
2094            .lock()
2095            .unwrap_or_else(|p| p.into_inner())
2096            .display_rows()
2097            .iter()
2098            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2099    }
2100
2101    // ── turn start (dispatch.rs Submit tail / stream_handler.rs RunTurn) ──
2102
2103    pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2104        self.cache_keepwarm_turn_started();
2105        let ct = CancellationToken::new();
2106        let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2107        self.streaming = true;
2108        self.turn_baseline = self.conv.api_messages.len();
2109        self.open_turn_draft();
2110        self.turn_steered_events.clear();
2111        self.turn_replay.clear();
2112        self.update_attach_state();
2113        self.emit(SessionEventWire::TurnStarted {
2114            turn_baseline: self.turn_baseline,
2115            trigger,
2116            user_text,
2117        });
2118        // Blocks command processing during setup exactly like the TUI loop.
2119        // (E-P7, §S9) An armed driver MUST NOT auto-approve tool activation —
2120        // the session-driver contract keeps ordinary tool-approval gates in
2121        // force. Override the session config to false whenever a grant is
2122        // armed, regardless of what the client requested. (Driver-initiated
2123        // turns already pass `false` from the tick's Prepared arm; this guards
2124        // any foreground turn that starts while armed.)
2125        let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2126        let completion = crate::runtime::TurnCompletion::new();
2127        self.turn_completion = Some(completion.clone());
2128        let stream = self
2129            .runtime
2130            .run_stream_tracked(
2131                self.conv.api_messages.clone(),
2132                ct.clone(),
2133                Some(s_rx),
2134                Some(self.secret_prompt_handle.clone()),
2135                auto_approve,
2136                completion,
2137            )
2138            .await;
2139        self.stream = Some(stream);
2140        self.cancel = Some(ct);
2141        self.steer_tx = Some(s_tx);
2142        let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2143        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2144        tick.reset();
2145        self.subagent_tick = Some(tick);
2146    }
2147
2148    /// Every turn-end path (Done/Error/Cancel/stream EOF). `streaming=false`
2149    /// makes a `Cancel` racing a `Done` the idle no-op. Events steered into
2150    /// the stream that the engine never took (`turn_steered_events`) go back
2151    /// to `pending_events`, first: they are flushed into history like any
2152    /// event that arrived during the turn (the interrupted path takes them
2153    /// earlier, to place them after the marker). Queues the save of the
2154    /// turn's final history, then the draft removal (`close_turn_draft`).
2155    pub(crate) fn clear_stream(&mut self) {
2156        self.stream = None;
2157        self.cancel = None;
2158        self.steer_tx = None;
2159        self.streaming = false;
2160        self.turn_completion = None;
2161        self.revoked_turn_deadline = None;
2162        // Background workers outlive the turn: keep publishing their rows
2163        // until the last one finishes (see `publish_subagent_rows`).
2164        if !keep_subagent_tick(false, self.subagents_running()) {
2165            self.subagent_tick = None;
2166        }
2167        if !self.turn_steered_events.is_empty() && self.conv.is_live() {
2168            let undelivered = std::mem::take(&mut self.turn_steered_events);
2169            self.conv.pending_events.splice(0..0, undelivered);
2170        }
2171        // The history that ends the turn — whether or not a draft is open
2172        // (no draft without the session lock) — then the draft removal.
2173        self.request_save();
2174        self.close_turn_draft();
2175        self.update_attach_state();
2176    }
2177
2178    // ── in-flight turn draft (crash recovery) ──────────────────────────
2179
2180    /// Turn start: the draft's existence is the "turn open" signal a
2181    /// loader uses to detect a process that died mid-turn. Only the session
2182    /// lock holder writes one: without the lock another process may own
2183    /// this session, and a draft is a claim that a turn is running HERE.
2184    fn open_turn_draft(&mut self) {
2185        if !self.config.persist || !self.conv.is_live() || self.session_lock.is_none() {
2186            return;
2187        }
2188        self.turn_draft = TurnDraftState {
2189            open: Some(self.conv.session.id.clone()),
2190            base_len: self.conv.api_messages.len(),
2191            text: String::new(),
2192            dirty: true,
2193        };
2194        self.flush_turn_draft();
2195    }
2196
2197    fn turn_draft_text(&mut self, delta: &str) {
2198        let d = &mut self.turn_draft;
2199        if d.open.is_some()
2200            && d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES
2201        {
2202            d.text.push_str(delta);
2203            d.dirty = true;
2204        }
2205    }
2206
2207    fn turn_draft_reset_text(&mut self) {
2208        let d = &mut self.turn_draft;
2209        if d.open.is_some() && !d.text.is_empty() {
2210            d.text.clear();
2211            d.dirty = true;
2212        }
2213    }
2214
2215    /// A round checkpoint was adopted and saved: later text continues from it.
2216    fn turn_draft_committed(&mut self) {
2217        if self.turn_draft.open.is_none() {
2218            return;
2219        }
2220        self.turn_draft.base_len = self.conv.api_messages.len();
2221        self.turn_draft.text.clear();
2222        self.turn_draft.dirty = true;
2223        self.flush_turn_draft();
2224    }
2225
2226    /// Write the draft if it changed (1 Hz turn tick; round checkpoints).
2227    /// Non-blocking and ordered (`persister`).
2228    pub(crate) fn flush_turn_draft(&mut self) {
2229        let Some(open) = self.turn_draft.open.clone() else {
2230            return;
2231        };
2232        if !self.turn_draft.dirty {
2233            return;
2234        }
2235        // Defensive: follow the conversation if its id changed mid-turn.
2236        let id = self.conv.session.id.clone();
2237        if open != id {
2238            self.persister.remove_draft(&open);
2239            self.turn_draft.open = Some(id.clone());
2240        }
2241        self.turn_draft.dirty = false;
2242        self.persister.write_draft(
2243            &id,
2244            agent_core::core::session_draft::TurnDraft {
2245                base_len: self.turn_draft.base_len,
2246                partial_text: self.turn_draft.text.clone(),
2247            },
2248        );
2249    }
2250
2251    /// Turn end (every path goes through `clear_stream`, which has just
2252    /// queued the save of the history that ends the turn): remove the draft.
2253    /// Queued after that save, and the persister never removes a draft while
2254    /// its session's latest save has failed, so the draft cannot disappear
2255    /// before the history that concludes its turn is on disk.
2256    fn close_turn_draft(&mut self) {
2257        if let Some(id) = self.turn_draft.open.take() {
2258            self.persister.remove_draft(&id);
2259        }
2260        self.turn_draft = TurnDraftState::default();
2261    }
2262
2263    /// dispatch.rs Submit (:1231-1288) minus presentation.
2264    pub(crate) async fn submit(
2265        &mut self,
2266        text: String,
2267        attachments: Vec<serde_json::Value>,
2268        from: Option<ClientId>,
2269    ) {
2270        // Wall 1 defense-in-depth: a latched (unverified) context head must
2271        // not accept new inference. The stream would refuse via
2272        // `durability_blocked` anyway, but that leaves the user message
2273        // orphaned in history; refuse here, before it is pushed.
2274        if self.conv.context_head.is_blocked(&self.conv.session) {
2275            let reason = "context head is unverified after a failed checkpoint; reload the \
2276                          session (`--continue`) or start a new one before continuing"
2277                .to_string();
2278            // Refused to the submitter (its editor text comes back and it
2279            // stops expecting this turn), a notice for everyone else.
2280            match from {
2281                Some(client) => self.emit(SessionEventWire::Refused {
2282                    client,
2283                    command: "submit".into(),
2284                    reason,
2285                }),
2286                None => self.emit(SessionEventWire::SystemNotice(reason)),
2287            }
2288            return;
2289        }
2290        if self.streaming {
2291            // A Submit while streaming is what the TUI calls StreamingInput.
2292            // Attachments during streaming are rejected by the client; if they
2293            // arrive anyway, ignore them (text-only steer).
2294            // P6: if the driver is armed and streaming, steer AND update
2295            // the driver's steering FIFO.
2296            if let Some(driver) = self.driver.as_mut() {
2297                let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2298                if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2299                    self.emit(SessionEventWire::SystemNotice(
2300                        "steering queue full (16 messages) — message dropped".into(),
2301                    ));
2302                    return;
2303                }
2304                if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2305                    self.emit(SessionEventWire::SystemNotice(
2306                        "steering queue full (256 KiB) — message dropped".into(),
2307                    ));
2308                    return;
2309                }
2310                driver.steering.push_back(text.clone());
2311            }
2312            self.steer(text);
2313            return;
2314        }
2315        // P6: while the driver is armed and idle, route to steering FIFO
2316        // instead of starting a normal turn.
2317        if self.driver.is_some() {
2318            let driver = self.driver.as_mut().unwrap();
2319            let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
2320            if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
2321                self.emit(SessionEventWire::SystemNotice(
2322                    "steering queue full (16 messages) — message dropped".into(),
2323                ));
2324                return;
2325            }
2326            if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
2327                self.emit(SessionEventWire::SystemNotice(
2328                    "steering queue full (256 KiB) — message dropped".into(),
2329                ));
2330                return;
2331            }
2332            driver.steering.push_back(text.clone());
2333            // Clear auto_wakes_blocked on explicit user submit (user takeover).
2334            driver.auto_wakes_blocked = false;
2335            self.emit(SessionEventWire::Steered {
2336                text,
2337                delivered: false,
2338            });
2339            return;
2340        }
2341        if self.compact.is_some() {
2342            // dispatch.rs:1233-1237: queued until the compaction lands.
2343            self.emit(SessionEventWire::Steered {
2344                text: text.clone(),
2345                delivered: false,
2346            });
2347            self.conv.queued_message = Some(text);
2348            return;
2349        }
2350        // Real user send — reset auto-turn counter.
2351        self.consecutive_auto_turns = 0;
2352        // For every OTHER attached client: the submitter drew its own card.
2353        let user_text = text.clone();
2354        let api_content = text;
2355
2356        if attachments.is_empty() {
2357            // Text-only: existing path.
2358            self.conv.api_messages.push(std::sync::Arc::new(
2359                serde_json::json!({"role": "user", "content": api_content}),
2360            ));
2361        } else {
2362            // Build multipart content: text block (if non-empty) + attachment blocks.
2363            let mut blocks = Vec::with_capacity(attachments.len() + 1);
2364            if !api_content.is_empty() {
2365                blocks.push(serde_json::json!({"type": "text", "text": api_content}));
2366            }
2367            blocks.extend(attachments);
2368            let candidate = std::sync::Arc::new(
2369                serde_json::json!({"role": "user", "content": blocks}),
2370            );
2371            // Validate the complete history (including this message) for the
2372            // session's current model before accepting.
2373            let mut proposed = self.conv.api_messages.clone();
2374            proposed.push(candidate.clone());
2375            let model = self.runtime.model().to_string();
2376            if let Err(e) = crate::runtime::attachments::validate_history_for_request(&model, &proposed) {
2377                // Typed refusal: the submitting client gets its editor text
2378                // back (stream_handler restores `last_submitted` on `Refused`)
2379                // and keeps its attachment drafts; mirrors see a notice.
2380                match from {
2381                    Some(client) => self.emit(SessionEventWire::Refused {
2382                        client,
2383                        command: "submit".into(),
2384                        reason: format!("attachments rejected: {e}"),
2385                    }),
2386                    None => self.emit(SessionEventWire::SystemNotice(format!(
2387                        "attachments rejected: {e}"
2388                    ))),
2389                }
2390                return;
2391            }
2392            self.conv.api_messages.push(candidate);
2393        }
2394        self.start_turn(TurnTrigger::User, Some(user_text)).await;
2395    }
2396
2397    /// dispatch.rs StreamingInput plain-text branch (:1369-1378).
2398    pub(crate) fn steer(&mut self, text: String) {
2399        let delivered = self
2400            .steer_tx
2401            .as_ref()
2402            .map(|tx| tx.send(text.clone()).is_ok())
2403            .unwrap_or(false);
2404        self.emit(SessionEventWire::Steered {
2405            text: text.clone(),
2406            delivered,
2407        });
2408        self.conv.queued_message = Some(text);
2409    }
2410
2411    /// Every cancel path — `Cancel` (Esc), quit mid-turn (`finish`), the cost
2412    /// cap, a reload/host checkpoint — behind the TUI's `if streaming` guard
2413    /// (input.rs:350): a `Cancel` while idle is a no-op that only re-announces
2414    /// `Idle` — it must never touch history, save, or emit `Aborted`.
2415    ///
2416    /// The interrupted turn is recorded as REAL history, never as a recap:
2417    /// 1. the token is cancelled, then pending host prompts are answered
2418    ///    `None` (a tool or `before_tool_call` hook blocked on one must unwind);
2419    /// 2. the still-live stream is drained for a
2420    ///    bounded time (`drain_cancelled_stream`) so the engine's cancel-path
2421    ///    history — partial assistant message, completed tool rounds,
2422    ///    delivered steering, canceled `tool_result`s — plus its final Usage
2423    ///    and any in-flight context-head checkpoint reach the actor;
2424    /// 3. that history is adopted verbatim (else the last adopted history is
2425    ///    kept — every history the engine publishes is valid), then
2426    ///    `finish_interrupted_turn` APPENDS one interruption marker
2427    ///    (`engine::interrupt`). Nothing already sent is edited, so the
2428    ///    provider's cached prefix survives.
2429    ///
2430    /// A turn the engine had already finished when the cancel reached it
2431    /// (Esc a moment after the answer ended) is not interrupted: no marker.
2432    pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
2433        if !self.streaming {
2434            if self.compact.is_some() {
2435                self.abort_compaction();
2436                if let Some(q) = self.conv.queued_message.take() {
2437                    self.emit(SessionEventWire::Dequeued { text: q });
2438                }
2439                self.update_attach_state();
2440            }
2441            self.emit(SessionEventWire::Idle);
2442            return;
2443        }
2444        self.note_cancel_cause(reason);
2445        if let Some(ref ct) = self.cancel {
2446            ct.cancel();
2447        }
2448        // After the cancel, so a tool awaiting a prompt observes the cancel
2449        // (not a `None` it could read as "declined"); a `before_tool_call`
2450        // hook awaiting a confirm prompt is not cancel-aware and needs this
2451        // answer to unwind at all.
2452        self.resolve_pending_prompts();
2453        let drain = self.drain_cancelled_stream().await;
2454        match drain.history {
2455            Some(history) => self.conv.api_messages = history,
2456            None if !drain.closed => tracing::warn!(
2457                session = %self.id,
2458                budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
2459                "cancelled turn did not publish its history within the drain budget; \
2460                 keeping the last adopted history"
2461            ),
2462            None => {}
2463        }
2464        // Complete only if the drain consumed the whole stream: the final
2465        // history is published right after the completion is recorded.
2466        let completed = drain.closed && self.turn_completed();
2467        self.finish_interrupted_turn(reason, completed).await;
2468    }
2469
2470    /// The engine recorded the running turn's normal end (`TurnCompletion`).
2471    fn turn_completed(&self) -> bool {
2472        self.turn_completion.as_ref().is_some_and(|c| c.completed())
2473    }
2474
2475    /// Tell the engine why the running turn is being cancelled, BEFORE its
2476    /// token is: its canceled tool results say so ("Canceled by user" only
2477    /// for the user). First cause wins, so a teardown that revokes a driver
2478    /// and then cancels notes its own cause first.
2479    fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
2480        if !self.streaming {
2481            return;
2482        }
2483        if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
2484            turn.note_cancel_cause(cause);
2485        }
2486    }
2487
2488    /// The ONE tail of every interrupted turn, once its stream has been
2489    /// drained or dropped (`cancel_turn`, a revoked driver turn): record the
2490    /// interruption in history, end the turn, tell clients. Never runs the
2491    /// post-turn machinery of a normal end (queued auto-send, event
2492    /// auto-turns, auto-compaction).
2493    ///
2494    /// `completed`: the engine had finished the turn normally before the
2495    /// cancel reached it — the history is a complete answer, so no marker is
2496    /// appended and clients get the `Done` they would have had.
2497    async fn finish_interrupted_turn(
2498        &mut self,
2499        reason: crate::engine::interrupt::InterruptReason,
2500        completed: bool,
2501    ) {
2502        // Defensive: only trailing invalid messages this turn appended.
2503        crate::engine::stream::repair_history_after_failure(
2504            &mut self.conv.api_messages,
2505            self.turn_baseline,
2506        );
2507        let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
2508        if !completed {
2509            crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
2510        }
2511        // A user steer the engine never picked up was not delivered.
2512        if let Some(q) = self.conv.queued_message.take() {
2513            self.emit(SessionEventWire::Dequeued { text: q });
2514        }
2515        // Events that arrived during the turn go in after the marker: first
2516        // those steered into the stream but never drained by the engine
2517        // (otherwise lost with the channel), then those buffered.
2518        {
2519            let undelivered = std::mem::take(&mut self.turn_steered_events);
2520            let conv: &mut ConversationState = &mut self.conv;
2521            for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
2522                conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
2523                    "role": "user",
2524                    "content": formatted
2525                })));
2526            }
2527        }
2528        // Queues the save of this history, then the draft removal.
2529        self.clear_stream();
2530        // Cancel all running reactive subagents; recover a poisoned guard
2531        // rather than skip cancellation.
2532        {
2533            let mut registry = match self.runtime.subagent_registry().lock() {
2534                Ok(g) => g,
2535                Err(poisoned) => {
2536                    tracing::warn!(
2537                        "subagent registry mutex poisoned during abort; recovering to cancel running handles"
2538                    );
2539                    poisoned.into_inner()
2540                }
2541            };
2542            for handle in registry.iter_mut_handles() {
2543                if handle.status() == crate::runtime::subagent::SubagentStatus::Running {
2544                    handle.cancel();
2545                }
2546            }
2547        }
2548        if completed {
2549            self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
2550        } else {
2551            // Typed event. `context_saved` (wire name kept for compatibility)
2552            // now means "the turn's partial work is kept in history".
2553            self.emit(SessionEventWire::Aborted {
2554                context_saved: kept_partial,
2555            });
2556        }
2557        self.emit_conversation();
2558        self.announce_idle().await;
2559    }
2560
2561    /// A turn whose token was cancelled from OUTSIDE `cancel_turn` — a
2562    /// driver revocation drops `DriverState`, cancelling the driver turn's
2563    /// child token (the grant's deadline task cancels that token too; the
2564    /// next `driver_tick` then revokes and arms the deadline) — ends here: on its `Done`, its stream's end, or when
2565    /// `revoked_turn_deadline` passes (a tool that ignores the cancel must
2566    /// not keep the session busy forever; the same budget as
2567    /// `drain_cancelled_stream`). What is left of the stream is dropped.
2568    async fn finish_revoked_turn(&mut self, stream_closed: bool) {
2569        if !self.streaming {
2570            return;
2571        }
2572        let completed = stream_closed && self.turn_completed();
2573        self.stream = None;
2574        self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
2575            .await;
2576    }
2577
2578    /// Whether the running turn's token has been cancelled (by `cancel_turn`
2579    /// or a driver revocation).
2580    fn turn_cancelled(&self) -> bool {
2581        self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
2582    }
2583
2584    /// Answer every pending host prompt `None` (same as `checkpoint`/`finish`).
2585    fn resolve_pending_prompts(&mut self) {
2586        if self.pending_prompts.is_empty() {
2587            return;
2588        }
2589        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2590            let _ = tx.send(None);
2591            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2592        }
2593        self.publish_presence();
2594        self.rearm_prompt_abandon();
2595    }
2596
2597    /// Consume what a CANCELLED turn's stream still carries, for at most
2598    /// `budgets::CANCEL_DRAIN_TIMEOUT`, then drop it.
2599    ///
2600    /// Deliberately NOT `on_stream_event`: that handler would re-check the
2601    /// cost cap on the final `Usage` (recursive `cancel_turn`), run the
2602    /// failure path on the engine's typed `Canceled` error, and treat `Done`
2603    /// as a normal completion (queued auto-send, auto-compaction).
2604    ///
2605    /// Forwarded to clients: display events (partial text, tool results —
2606    /// the history being adopted contains them), `Usage`, agent events.
2607    /// Handled: `MessageHistory` (kept, last wins), `ContextHeadCheckpoint`
2608    /// (serviced — dropping its receipt would latch `durability_blocked`;
2609    /// the head it installs replaces any history kept before it),
2610    /// `SteeringDelivered`. Swallowed: `Done`, the `Canceled` error. A typed
2611    /// `InterruptedAfterSideEffect` becomes a notice (it is not a failure of
2612    /// the turn; the canceled `tool_result` already tells the model). A tool
2613    /// raising a host prompt before it observed the cancel is answered
2614    /// `None` at once, not at the deadline.
2615    async fn drain_cancelled_stream(&mut self) -> CancelDrain {
2616        let mut out = CancelDrain::default();
2617        let Some(mut stream) = self.stream.take() else {
2618            out.closed = true;
2619            return out;
2620        };
2621        let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
2622        loop {
2623            let event = tokio::select! {
2624                biased;
2625                Some(req) = self.secret_prompt_rx.recv() => {
2626                    let _ = req.response_tx.send(None);
2627                    continue;
2628                }
2629                next = tokio::time::timeout_at(deadline, stream.next()) => match next {
2630                    Err(_) => break,
2631                    Ok(None) => {
2632                        out.closed = true;
2633                        break;
2634                    }
2635                    Ok(Some(event)) => event,
2636                },
2637            };
2638            match event {
2639                StreamEvent::Session(SessionEvent::Done) => {
2640                    out.closed = true;
2641                    break;
2642                }
2643                StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
2644                    out.history = Some(history);
2645                }
2646                StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
2647                    session_id,
2648                    messages,
2649                    receipt,
2650                }) => {
2651                    self.handle_context_head_checkpoint(session_id, messages, receipt)
2652                        .await;
2653                    // The adopted head is the actor's history now: a history
2654                    // kept from BEFORE it is stale and must not override it
2655                    // (a later `MessageHistory`, if any, still wins).
2656                    out.history = None;
2657                }
2658                StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
2659                    crate::TurnOutcome::Canceled => {}
2660                    crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
2661                        self.emit(SessionEventWire::SystemNotice(err.message));
2662                    }
2663                    _ => {
2664                        tracing::warn!(
2665                            session = %self.id,
2666                            category = err.category_label(),
2667                            "cancelled turn reported an error while draining"
2668                        );
2669                    }
2670                },
2671                StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
2672                    self.note_steering_delivered(message);
2673                    self.emit(SessionEventWire::Stream(event));
2674                }
2675                StreamEvent::Session(SessionEvent::Usage { .. }) => {
2676                    self.record_usage(&event);
2677                    self.emit(SessionEventWire::Stream(event));
2678                }
2679                other => self.emit(SessionEventWire::Stream(other)),
2680            }
2681        }
2682        drop(stream);
2683        out
2684    }
2685
2686    /// `SteeringDelivered`: the engine injected `message` into history.
2687    fn note_steering_delivered(&mut self, message: &str) {
2688        if self.conv.queued_message.as_deref() == Some(message) {
2689            self.conv.queued_message = None;
2690            self.emit_conversation();
2691        }
2692        // P5/P6: pop the driver's steering FIFO on delivery ack.
2693        if let Some(driver) = self.driver.as_mut() {
2694            if driver.steering.front().map(String::as_str) == Some(message) {
2695                driver.steering.pop_front();
2696            }
2697        }
2698        if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
2699            self.turn_steered_events.remove(pos);
2700        }
2701    }
2702
2703    /// Accumulate one `SessionEvent::Usage` into the conversation totals.
2704    fn record_usage(&mut self, event: &StreamEvent) {
2705        if let StreamEvent::Session(SessionEvent::Usage {
2706            input_tokens,
2707            output_tokens,
2708            cache_read_input_tokens,
2709            cache_creation_input_tokens,
2710            cache_creation_5m,
2711            cache_creation_1h,
2712            model: usage_model,
2713        }) = event
2714        {
2715            let model_for_pricing = usage_model
2716                .as_deref()
2717                .unwrap_or(self.runtime.model())
2718                .to_string();
2719            self.cache_keepwarm_note_usage(crate::runtime::keepwarm::KeepWarmUsage {
2720                input_tokens: *input_tokens,
2721                output_tokens: *output_tokens,
2722                cache_read_tokens: *cache_read_input_tokens,
2723                cache_write_tokens: *cache_creation_input_tokens,
2724                cache_write_5m: *cache_creation_5m,
2725                cache_write_1h: *cache_creation_1h,
2726                model: usage_model.clone(),
2727            });
2728            self.conv.add_usage(
2729                *input_tokens,
2730                *output_tokens,
2731                *cache_read_input_tokens,
2732                *cache_creation_input_tokens,
2733                *cache_creation_5m,
2734                *cache_creation_1h,
2735                &model_for_pricing,
2736            );
2737        }
2738    }
2739
2740    // ── event-queue wake (stream_handler.rs handle_event_queue_arm) ──────
2741
2742    async fn on_queue_wake(&mut self) {
2743        if self.is_parked() {
2744            if self.event_queue.is_empty() {
2745                return;
2746            }
2747            if let Err(e) = self.ensure_live().await {
2748                tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
2749                return;
2750            }
2751        }
2752        let busy = self.busy();
2753        let conv: &mut ConversationState = &mut self.conv;
2754        let drained = drain_event_queue(
2755            &self.event_queue,
2756            &mut conv.api_messages,
2757            &mut conv.pending_events,
2758            busy,
2759            self.steer_tx.as_ref(),
2760        );
2761        if drained.is_empty() {
2762            return;
2763        }
2764        for de in &drained {
2765            if de.disposition == EventDisposition::Steered {
2766                self.turn_steered_events.push(de.formatted.clone());
2767            }
2768            self.emit(SessionEventWire::External(de.event.clone()));
2769        }
2770        let injected = drained
2771            .iter()
2772            .any(|d| d.disposition == EventDisposition::Injected);
2773        if injected || busy {
2774            self.emit_conversation();
2775        }
2776
2777        // P5: observe_events — if non-steering events arrive during a
2778        // driver-OWNED turn, revoke (competing work). Coordinates with F8.
2779        if self.streaming
2780            && self
2781                .driver
2782                .as_ref()
2783                .is_some_and(|d| d.awaiting_terminal)
2784        {
2785            let competing = drained
2786                .iter()
2787                .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
2788            if competing {
2789                self.driver_revoke("event-bus work took priority");
2790            }
2791        }
2792
2793        // `events.auto_turn = false` opts the session out of event-driven
2794        // turns (events are still injected/forwarded; the spend governor
2795        // for an ambient session). Read live from host config, like the
2796        // RPC/server hosts do — it used to be hardcoded `true` here.
2797        let auto_turn_enabled = self.host.config().events.auto_turn;
2798        let auto_turn_cap = self.host.config().events.auto_turn_cap;
2799        let action = wake_action_with_cap(
2800            &drained,
2801            &self.conv.api_messages,
2802            busy,
2803            auto_turn_enabled,
2804            self.consecutive_auto_turns,
2805            auto_turn_cap,
2806        );
2807        match action {
2808            WakeAction::RunTurn => {
2809                // F8: while a driver is armed, its tick owns turn scheduling.
2810                // Starting a competing turn here would race the driver.
2811                if self.driver.is_some() {
2812                    tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
2813                } else if self.stream.is_some() {
2814                    tracing::warn!("handle_event_arm: RunTurn with active stream — skipping");
2815                } else {
2816                    self.consecutive_auto_turns += 1;
2817                    self.start_turn(TurnTrigger::EventAuto, None).await;
2818                }
2819            }
2820            WakeAction::Forward => {
2821                let hit_cap = injected
2822                    && !busy
2823                    && auto_turn_enabled
2824                    && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
2825                if hit_cap {
2826                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
2827                }
2828            }
2829            WakeAction::Nothing => {}
2830        }
2831    }
2832
2833    // ── stream events (stream_handler.rs handle_stream_event + arm tail) ──
2834
2835    async fn on_stream_event(&mut self, event: StreamEvent) {
2836        // A turn cancelled from outside `cancel_turn` (a driver revocation):
2837        // its terminal events are not a normal end — the turn is finished as
2838        // interrupted, never through the post-turn machinery below.
2839        if self.turn_cancelled() {
2840            match event {
2841                StreamEvent::Session(SessionEvent::Done) => {
2842                    self.finish_revoked_turn(true).await;
2843                    return;
2844                }
2845                // Swallowed as in `drain_cancelled_stream`; `Done` follows.
2846                StreamEvent::Session(SessionEvent::Error(err)) => {
2847                    match err.outcome {
2848                        crate::TurnOutcome::Canceled => {}
2849                        crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
2850                            self.emit(SessionEventWire::SystemNotice(err.message));
2851                        }
2852                        _ => tracing::warn!(
2853                            session = %self.id,
2854                            category = err.category_label(),
2855                            "revoked turn reported an error"
2856                        ),
2857                    }
2858                    return;
2859                }
2860                _ => {}
2861            }
2862        }
2863        // Forward first: clients see the same order they see today. A
2864        // context-head checkpoint is actor-internal (its receipt is ours to
2865        // complete) and must not reach clients: the wire has no such event
2866        // and maps it to `Done`, which ended the turn on socket clients.
2867        if !matches!(
2868            event,
2869            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
2870        ) {
2871            self.emit(SessionEventWire::Stream(event.clone()));
2872        }
2873
2874        // P5: observe_feedback — feed opted-in driver turns.
2875        if let Some(driver) = self.driver.as_mut() {
2876            if driver.awaiting_terminal && driver.grant.feedback_enabled() {
2877                driver.feedback.observe(&event);
2878            }
2879        }
2880
2881        // P5: capture_terminal BEFORE Done/Error handlers modify state.
2882        let is_canceled = self
2883            .cancel
2884            .as_ref()
2885            .is_some_and(|ct| ct.is_cancelled());
2886        let terminal = if self
2887            .driver
2888            .as_ref()
2889            .is_some_and(|d| d.awaiting_terminal)
2890        {
2891            super::driver::capture_terminal(Some(&event), is_canceled)
2892        } else {
2893            None
2894        };
2895
2896        enum After {
2897            Continue,
2898            AutoSendQueued(String),
2899            AutoTriggerEvents,
2900            Failed,
2901        }
2902        let mut after = After::Continue;
2903
2904        match event {
2905            StreamEvent::Llm(crate::LlmEvent::Text(text)) => self.turn_draft_text(&text),
2906            // A new provider response (or a retry's reset): the in-flight
2907            // text starts over.
2908            StreamEvent::Llm(crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset) => {
2909                self.turn_draft_reset_text()
2910            }
2911            StreamEvent::Llm(_) => {}
2912            StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
2913                // Published at every round boundary as well as at the end of
2914                // the turn (`runtime/stream.rs` ROUND CHECKPOINT): the session
2915                // on disk follows the turn as it progresses. Queued, never
2916                // awaited — the persister writes it (latest wins), so a slow
2917                // disk can never stall the turn machine.
2918                self.conv.api_messages = history;
2919                self.request_save();
2920                // The round is committed: the draft now continues from here.
2921                self.turn_draft_committed();
2922                self.trim_replay_to_checkpoint();
2923                self.emit_conversation();
2924            }
2925            StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
2926                self.note_steering_delivered(message);
2927            }
2928            StreamEvent::Agent(_) => {}
2929            StreamEvent::Session(SessionEvent::Usage { .. }) => {
2930                self.record_usage(&event);
2931                // (E-P7, §S3) Host-owned spend circuit breaker. Checked after
2932                // every Usage event — a breach cancels the in-flight turn,
2933                // revokes any armed driver (so it cannot re-poll and keep
2934                // spending), and tells clients why.
2935                if let Some((scope, cost, cap)) = self.cost_cap_breach() {
2936                    self.emit(SessionEventWire::CostCapReached {
2937                        scope: scope.to_string(),
2938                        cost,
2939                        cap,
2940                    });
2941                    self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
2942                    if self.driver.is_some() || self.driver_pending.is_some() {
2943                        self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
2944                    }
2945                    self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
2946                        .await;
2947                    return;
2948                }
2949            }
2950            StreamEvent::Session(SessionEvent::Notice(_)) => {}
2951            StreamEvent::Session(SessionEvent::Done) => {
2952                self.clear_stream();
2953                self.publish_subagent_rows();
2954                // Flush events that arrived during streaming into api_messages
2955                let had_pending = !self.conv.pending_events.is_empty();
2956                {
2957                    let conv: &mut ConversationState = &mut self.conv;
2958                    for formatted in conv.pending_events.drain(..) {
2959                        conv.api_messages
2960                            .push(std::sync::Arc::new(serde_json::json!({
2961                                "role": "user",
2962                                "content": formatted
2963                            })));
2964                    }
2965                }
2966                if let Some(queued) = self.conv.queued_message.take() {
2967                    after = After::AutoSendQueued(queued);
2968                } else if had_pending {
2969                    self.request_save();
2970                    after = After::AutoTriggerEvents;
2971                }
2972                self.emit_conversation();
2973                if matches!(after, After::Continue) {
2974                    if self.config.auto_compact {
2975                        self.post_turn_chat().await;
2976                    }
2977                    // A spawned compaction emits Idle when it lands.
2978                    if self.compact.is_none() {
2979                        self.announce_idle().await;
2980                    }
2981                }
2982            }
2983            StreamEvent::Session(SessionEvent::Error(_)) => {
2984                // Remove only invalid messages appended by the ACTIVE turn —
2985                // before `clear_stream` queues the turn's final save.
2986                crate::engine::stream::repair_history_after_failure(
2987                    &mut self.conv.api_messages,
2988                    self.turn_baseline,
2989                );
2990                self.clear_stream();
2991                self.publish_subagent_rows();
2992                self.emit_conversation();
2993                after = After::Failed;
2994            }
2995            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
2996                session_id,
2997                messages,
2998                receipt,
2999            }) => {
3000                self.handle_context_head_checkpoint(session_id, messages, receipt)
3001                    .await;
3002            }
3003        }
3004
3005        match after {
3006            After::Continue => {}
3007            After::Failed => {
3008                if self.config.auto_compact {
3009                    self.post_turn_chat().await;
3010                }
3011                if self.compact.is_none() {
3012                    self.announce_idle().await;
3013                }
3014            }
3015            After::AutoSendQueued(queued) => {
3016                if self.config.auto_compact {
3017                    self.post_turn_chat().await;
3018                }
3019                if self.compact.is_some() {
3020                    // Rides the compaction transition (queued_message
3021                    // restored last); the TUI reports it, never re-sends.
3022                    self.conv.queued_message = Some(queued);
3023                    return;
3024                }
3025                // Auto-send the queued message (user-authored — reset counter)
3026                self.consecutive_auto_turns = 0;
3027                let user_text = queued.clone();
3028                self.conv.api_messages.push(std::sync::Arc::new(
3029                    serde_json::json!({"role": "user", "content": queued}),
3030                ));
3031                self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
3032            }
3033            After::AutoTriggerEvents => {
3034                if self.config.auto_compact {
3035                    self.post_turn_chat().await;
3036                }
3037                if self.compact.is_some() {
3038                    return;
3039                }
3040                // Central claim gate: allows turns while under the configured
3041                // cap (events.auto_turn_cap; 0 = unlimited), denies once reached.
3042                let auto_turn_cap = self.host.config().events.auto_turn_cap;
3043                if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
3044                    self.start_turn(TurnTrigger::EventAuto, None).await;
3045                } else {
3046                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3047                    self.announce_idle().await;
3048                }
3049            }
3050        }
3051
3052        // P5: observe_terminal — set driver outcome or revoke.
3053        if let Some(terminal) = terminal {
3054            let revoke_reason = if let Some(driver) = self.driver.as_mut() {
3055                super::driver::observe_terminal(driver, &self.runtime, terminal)
3056            } else {
3057                None
3058            };
3059            // Emit DriverTurnOutcome if an outcome was just set.
3060            if let Some(driver) = self.driver.as_ref() {
3061                if let Some((outcome, _)) = &driver.outcome {
3062                    self.emit(SessionEventWire::DriverTurnOutcome {
3063                        outcome: *outcome,
3064                        selection: driver.selection.clone(),
3065                        feedback: driver.grant.feedback_enabled().then(|| {
3066                            driver.completed_feedback.to_string()
3067                        }),
3068                    });
3069                }
3070            }
3071            if let Some(reason) = revoke_reason {
3072                self.driver_revoke(&reason);
3073            }
3074        }
3075    }
3076
3077    /// chat.rs post-turn block: save + engine-budget auto-compaction.
3078    async fn post_turn_chat(&mut self) {
3079        self.request_save();
3080        let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
3081        if assessment.should_compact() {
3082            self.emit(SessionEventWire::SystemNotice(format!(
3083                "[auto-compacting ~{} tokens...]",
3084                assessment.used_tokens()
3085            )));
3086            self.compact(None, "auto").await;
3087        }
3088    }
3089
3090    /// `SYNAPS_SESSION_COMPACT_INLINE=1`: one-release kill-switch back to the
3091    /// #107 inline body (deleted in phase 4).
3092    fn compact_inline_env() -> bool {
3093        matches!(
3094            std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
3095            Ok("1") | Ok("true")
3096        )
3097    }
3098
3099    /// `/compact` + auto-compaction entry (B2): spawns `compact_conversation`
3100    /// on a `Runtime::clone()` (the same clone the TUI makes at
3101    /// dispatch.rs:450 — the clone never runs turns) and returns; the
3102    /// `select!` arm `on_compaction_done` applies the outcome. While a job
3103    /// is in flight Attach/Detach/Cancel/Query are serviced, `Submit` is
3104    /// queued (`Steered{delivered:false}`), auto-turns see `busy`.
3105    pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
3106        // Compaction replaces the history: the old cached prefix is dead.
3107        self.cache_keepwarm_compaction();
3108        if Self::compact_inline_env() {
3109            return self.compact_inline(instructions, source).await;
3110        }
3111        if self.compact.is_some() {
3112            self.emit(SessionEventWire::SystemNotice(
3113                "compaction already in progress".into(),
3114            ));
3115            return;
3116        }
3117        if self.streaming {
3118            self.emit(SessionEventWire::SystemNotice(
3119                "cannot compact while a turn is running".into(),
3120            ));
3121            return;
3122        }
3123        let disclosure =
3124            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
3125        self.emit(SessionEventWire::CompactionStarted {
3126            source: source.to_string(),
3127            disclosure,
3128        });
3129        let msgs = self.conv.api_messages.clone();
3130        let rt: Runtime = (*self.runtime).clone();
3131        let task = tokio::spawn(async move {
3132            compact_conversation(&msgs, &rt, instructions.as_deref()).await
3133        });
3134        self.compact = Some(CompactionJob {
3135            task,
3136            source: source.to_string(),
3137            started: tokio::time::Instant::now(),
3138        });
3139        self.update_attach_state();
3140    }
3141
3142    /// `select!` arm: the spawned job finished (or was aborted). Applies via
3143    /// the ONE engine transition with the configured policy; pending events
3144    /// and the queued message ride the transition (loop_arms.rs:761-836).
3145    async fn on_compaction_done(
3146        &mut self,
3147        res: std::result::Result<
3148            Result<crate::runtime::compaction::CompactionOutcome>,
3149            tokio::task::JoinError,
3150        >,
3151    ) {
3152        let Some(job) = self.compact.take() else {
3153            return;
3154        };
3155        let msg_count = self.conv.api_messages.len();
3156        match res {
3157            Err(join) => {
3158                if !join.is_cancelled() {
3159                    self.emit(SessionEventWire::CompactionFailed {
3160                        message: join.to_string(),
3161                        panicked: true,
3162                    });
3163                }
3164            }
3165            Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
3166                message: e.to_string(),
3167                panicked: false,
3168            }),
3169            Ok(Ok(outcome)) => {
3170                // The transition rewrites the predecessor on disk (its
3171                // `compacted_into` link): a queued save of it must land
3172                // first, never after.
3173                if !self.persister.flush().await {
3174                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
3175                }
3176                let policy: CompactionPolicy = self.config.compaction_policy.into();
3177                let queued = self.conv.queued_message.clone();
3178                let applied = apply_compaction(
3179                    &self.runtime,
3180                    &self.conv.session,
3181                    &self.conv.api_messages,
3182                    &outcome,
3183                    CompactionTransition {
3184                        policy,
3185                        pending_events: self.conv.pending_events.clone(),
3186                        queued_message: queued.clone(),
3187                        hook_source: job.source.clone(),
3188                    },
3189                )
3190                .await;
3191                match applied {
3192                    Ok(applied) => {
3193                        let previous = applied.previous_session_id.clone();
3194                        self.conv.session = applied.session;
3195                        self.conv.api_messages = applied.api_messages;
3196                        self.conv.pending_events.clear();
3197                        self.conv.queued_message = None;
3198                        if policy == CompactionPolicy::LinkedSuccessor {
3199                            self.conv.total_input_tokens = 0;
3200                            self.conv.total_output_tokens = 0;
3201                            self.conv.session_cost = 0.0;
3202                            // The keep-warm subtotal follows the totals it
3203                            // is part of: input/output/cost restart, the
3204                            // cache totals (not reset here) keep their
3205                            // keep-warm share.
3206                            self.conv.keepwarm_cost = 0.0;
3207                            self.conv.keepwarm_tokens.input = 0;
3208                            self.conv.keepwarm_tokens.output = 0;
3209                        }
3210                        let new_id = self.conv.session.id.clone();
3211                        self.runtime.set_session_id(Some(new_id.clone()));
3212                        self.journal_id.store(Arc::new(new_id.clone()));
3213                        // F10: lock follows the new journal id.
3214                        self.reacquire_session_lock(&new_id);
3215                        // Wall 1: reset continuation state for the new session id
3216                        // so stale epoch checks in persist_head() don't reject
3217                        // future checkpoints.
3218                        self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
3219                        self.runtime
3220                            .reset_context_continuation(&new_id, &self.conv.api_messages);
3221                        self.emit(SessionEventWire::CompactionApplied {
3222                            previous_session_id: previous,
3223                            session_id: new_id,
3224                            chains_advanced: applied.chains_advanced,
3225                            queued_restored: queued,
3226                            msg_count,
3227                        });
3228                    }
3229                    Err(e) => self.emit(SessionEventWire::CompactionFailed {
3230                        message: e.to_string(),
3231                        panicked: false,
3232                    }),
3233                }
3234            }
3235        }
3236        self.emit_conversation();
3237        self.update_attach_state();
3238        self.announce_idle().await;
3239    }
3240
3241    /// #107 inline body (kill-switch only).
3242    async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
3243        self.emit(SessionEventWire::SystemNotice(format!(
3244            "[{}]",
3245            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
3246        )));
3247        let outcome =
3248            compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
3249                .await;
3250        let applied = match outcome {
3251            Ok(outcome) => {
3252                // Queued saves land before the transition rewrites the file.
3253                if !self.persister.flush().await {
3254                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
3255                }
3256                apply_compaction(
3257                    &self.runtime,
3258                    &self.conv.session,
3259                    &self.conv.api_messages,
3260                    &outcome,
3261                    CompactionTransition {
3262                        policy: CompactionPolicy::InPlace,
3263                        pending_events: Vec::new(),
3264                        queued_message: None,
3265                        hook_source: source.to_string(),
3266                    },
3267                )
3268                .await
3269            }
3270            Err(e) => Err(e),
3271        };
3272        match applied {
3273            Ok(applied) => {
3274                self.conv.session = applied.session;
3275                self.conv.api_messages = applied.api_messages;
3276                let after = self.runtime.assess_context(&self.conv.api_messages).await;
3277                self.emit(SessionEventWire::SystemNotice(format!(
3278                    "[compacted → ~{} tokens]",
3279                    after.used_tokens()
3280                )));
3281            }
3282            Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
3283                "[compaction failed: {}]",
3284                e
3285            ))),
3286        }
3287        self.emit_conversation();
3288    }
3289
3290    // ── prompts ──────────────────────────────────────────────────────────
3291
3292    fn on_prompt_request(&mut self, req: SecretPromptRequest) {
3293        let id = self.next_prompt_id;
3294        self.next_prompt_id += 1;
3295        let pr = PromptRequest {
3296            id,
3297            kind: PromptKind::from_title(&req.title),
3298            title: req.title,
3299            prompt: req.prompt,
3300            raised_at: chrono::Utc::now(),
3301        };
3302        self.pending_prompts.push_back((pr.clone(), req.response_tx));
3303        self.publish_presence();
3304        self.emit(SessionEventWire::Prompt(pr));
3305        // A prompt can be raised while already detached (a turn running with no
3306        // client). Arm the abandonment deadline if this left us pinned.
3307        self.rearm_prompt_abandon();
3308    }
3309
3310    fn answer(&mut self, prompt_id: u64, value: Option<String>) {
3311        let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
3312            return; // unknown or already answered — dedup on id
3313        };
3314        let (_, tx) = self.pending_prompts.remove(pos).expect("position");
3315        let _ = tx.send(value);
3316        self.publish_presence();
3317        self.emit(SessionEventWire::PromptResolved { prompt_id });
3318        // The last pending prompt may have just cleared — drop the deadline.
3319        self.rearm_prompt_abandon();
3320    }
3321
3322    // ── settings / queries / engine commands ─────────────────────────────
3323
3324    pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
3325        if replayable(&setting) {
3326            let disc = std::mem::discriminant(&setting);
3327            self.settings_replay
3328                .retain(|s| std::mem::discriminant(s) != disc);
3329            self.settings_replay.push(setting.clone());
3330        }
3331        let name = match &setting {
3332            SessionSetting::Model { .. } => "model",
3333            SessionSetting::ReasoningLevel { .. } => "reasoning_level",
3334            SessionSetting::ContextWindow { .. } => "context_window",
3335            SessionSetting::CompactionModel { .. } => "compaction_model",
3336            SessionSetting::ApiRetries { .. } => "api_retries",
3337            SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
3338            SessionSetting::MaxToolOutput { .. } => "max_tool_output",
3339            SessionSetting::BashTimeout { .. } => "bash_timeout",
3340            SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
3341            SessionSetting::SystemPrompt { .. } => "system_prompt",
3342            SessionSetting::ReloadPrompt => "reload_prompt",
3343            SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
3344        };
3345        let rt = &mut self.runtime;
3346        let mut clamp_wire = None;
3347        let result: std::result::Result<Option<String>, String> = match setting {
3348            SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
3349                self.conv.session.model = rt.model().to_string();
3350                clamp.map(|c| {
3351                    self.conv.session.thinking_level = rt.thinking_level().to_string();
3352                    clamp_wire = Some(ReasoningClampWire {
3353                        from: c.from.as_str().to_string(),
3354                        to: c.to.as_str().to_string(),
3355                    });
3356                    format!(
3357                        "thinking → {} (clamped from {}: not supported by {})",
3358                        c.to.as_str(),
3359                        c.from.as_str(),
3360                        rt.model()
3361                    )
3362                })
3363            }),
3364            SessionSetting::ReasoningLevel { level } => {
3365                rt.set_reasoning_level_checked(level).map(|_| {
3366                    self.conv.session.thinking_level = rt.thinking_level().to_string();
3367                    None
3368                })
3369            }
3370            SessionSetting::ContextWindow { tokens } => {
3371                rt.set_context_window(tokens);
3372                Ok(None)
3373            }
3374            SessionSetting::CompactionModel { model } => {
3375                rt.set_compaction_model(model);
3376                Ok(None)
3377            }
3378            SessionSetting::ApiRetries { n } => {
3379                rt.set_api_retries(n);
3380                Ok(None)
3381            }
3382            SessionSetting::SubagentTimeout { secs } => {
3383                rt.set_subagent_timeout(secs);
3384                Ok(None)
3385            }
3386            SessionSetting::MaxToolOutput { bytes } => {
3387                rt.set_max_tool_output(bytes);
3388                Ok(None)
3389            }
3390            SessionSetting::BashTimeout { secs } => {
3391                rt.set_bash_timeout(secs);
3392                Ok(None)
3393            }
3394            SessionSetting::BashMaxTimeout { secs } => {
3395                rt.set_bash_max_timeout(secs);
3396                Ok(None)
3397            }
3398            SessionSetting::SystemPrompt { text } => {
3399                rt.set_system_prompt(text);
3400                Ok(None)
3401            }
3402            SessionSetting::ReloadPrompt => rt
3403                .reload_prompt()
3404                .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
3405                .map_err(|e| e.to_string()),
3406            SessionSetting::GrantWorkerModel { model } => {
3407                rt.grant_worker_model(&model).map(|_| None)
3408            }
3409        };
3410        self.publish_view().await;
3411        let view = (**self.view.load()).clone();
3412        let (ok, message) = match result {
3413            Ok(m) => (true, m),
3414            Err(e) => (false, Some(e)),
3415        };
3416        self.emit(SessionEventWire::SettingChanged(SettingApplied {
3417            id,
3418            setting: name.to_string(),
3419            ok,
3420            message,
3421            view,
3422            clamp: clamp_wire,
3423        }));
3424    }
3425
3426    pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
3427        // Status/View never wake a parked session (the idle probe must not).
3428        if self.is_parked() {
3429            let value = match query {
3430                SessionQuery::Status => serde_json::json!({
3431                    "session": (**self.journal_id.load()).clone(),
3432                    "model": self.view.load().model,
3433                    "lifecycle": SessionLifecycle::Parked,
3434                    "streaming": false,
3435                    "auto_turns": self.consecutive_auto_turns,
3436                    "attached": 0,
3437                    "pending_prompts": 0,
3438                }),
3439                SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
3440                _ => match self.ensure_live().await {
3441                    Ok(()) => return Box::pin(self.query(id, query)).await,
3442                    Err(e) => serde_json::json!({ "error": e }),
3443                },
3444            };
3445            self.emit(SessionEventWire::QueryResult { id, value });
3446            return;
3447        }
3448        let value = match query {
3449            SessionQuery::Status => serde_json::json!({
3450                "session": self.conv.session.id,
3451                "model": self.runtime.model(),
3452                "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
3453                "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
3454                "cost": self.conv.session_cost,
3455                // Included in `cost`; labelled separately (task #444).
3456                "keepwarm_cost": self.conv.keepwarm_cost,
3457                "keepwarm": self.cache_keepwarm_status_json(None),
3458                "messages": self.conv.api_messages.len(),
3459                "streaming": self.streaming,
3460                "auto_turns": self.consecutive_auto_turns,
3461                "attached": self.attached.len(),
3462                "pending_prompts": self.pending_prompts.len(),
3463            }),
3464            SessionQuery::Messages => {
3465                serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
3466            }
3467            SessionQuery::DisplayTail { items } => {
3468                serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
3469                    .unwrap_or_default()
3470            }
3471            SessionQuery::SubagentRows => {
3472                let rows = self
3473                    .runtime
3474                    .subagent_registry()
3475                    .lock()
3476                    .unwrap_or_else(|p| p.into_inner())
3477                    .display_rows();
3478                serde_json::Value::Array(
3479                    rows.iter()
3480                        .map(|r| {
3481                            serde_json::json!({
3482                                "subagent_id": r.subagent_id,
3483                                "agent_name": r.agent_name,
3484                                "status": format!("{:?}", r.status),
3485                                "cancel_requested": r.cancel_requested,
3486                                "elapsed_secs": r.elapsed_secs,
3487                            })
3488                        })
3489                        .collect(),
3490                )
3491            }
3492            SessionQuery::PromptInspection => {
3493                serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
3494            }
3495            SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
3496            SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
3497            SessionQuery::ContextAssessment => {
3498                let a = self.runtime.assess_context(&self.conv.api_messages).await;
3499                serde_json::json!({
3500                    "used_tokens": a.used_tokens(),
3501                    "budget_tokens": a.budget_tokens(),
3502                    "provider_window": a.provider_window,
3503                    "should_compact": a.should_compact(),
3504                })
3505            }
3506            SessionQuery::ContextReport => {
3507                use crate::engine::commands::{context_command, CommandResult};
3508                match context_command(&self.runtime, Some(&self.conv.api_messages)) {
3509                    CommandResult::Output(text) => serde_json::json!({ "text": text }),
3510                    other => serde_json::json!({ "unsupported": format!("{other:?}") }),
3511                }
3512            }
3513        };
3514        self.emit(SessionEventWire::QueryResult { id, value });
3515    }
3516
3517    // ── attach / detach ──────────────────────────────────────────────────
3518
3519    pub(crate) fn snapshot(&self) -> AttachSnapshot {
3520        AttachSnapshot {
3521            meta: self.meta.clone(),
3522            view: (**self.view.load()).clone(),
3523            conversation: self.conv.snapshot(self.consecutive_auto_turns),
3524            streaming: self.streaming,
3525            replay: self.turn_replay.iter().cloned().collect(),
3526            pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
3527            clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
3528            input_owner: self.input_owner,
3529            display_tail: None,
3530        }
3531    }
3532
3533    /// `snapshot()` shaped for one client (phase 4 §2.3): `Digest` clients
3534    /// get an empty `api_messages` (`messages_len` kept), a daemon-projected
3535    /// `display_tail`, and a replay without the per-round `MessageHistory`
3536    /// envelopes (the trailing `Conversation` digest carries len/hash).
3537    pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
3538        let mut snap = self.snapshot();
3539        if client.history == HistoryMode::Digest {
3540            snap.display_tail = Some(super::display::display_tail(
3541                &snap.conversation.api_messages,
3542                client.tail_items,
3543            ));
3544            snap.conversation.api_messages = Vec::new();
3545            snap.replay.retain(|e| {
3546                !matches!(
3547                    e.event,
3548                    SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
3549                )
3550            });
3551        }
3552        snap
3553    }
3554
3555    /// B1 ownership: `Observe` never owns; `Mirror` owns iff nobody does
3556    /// (else read-only + notice); `Takeover` steals with
3557    /// `InputOwnerChanged{reason: Takeover}` to everyone (the old owner's
3558    /// client renders the toast).
3559    async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
3560        if let Err(e) = self.ensure_live().await {
3561            self.emit(SessionEventWire::AttachRefused { message: e });
3562            return;
3563        }
3564        let cid = ClientId(self.next_client_id);
3565        self.next_client_id += 1;
3566        let kind = client.kind;
3567        self.cache_keepwarm_note_attach(kind);
3568        let snapshot_meta = client.clone();
3569        self.attached.insert(cid, AttachedClient { meta: client, mode });
3570        self.update_attach_state();
3571        let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
3572        match mode {
3573            AttachMode::Observe => {}
3574            // Input owned elsewhere: the joiner learns it from its snapshot
3575            // (`AttachSnapshot::input_owned_elsewhere`), never from a notice
3576            // broadcast to every client.
3577            AttachMode::Mirror => {
3578                if self.input_owner.is_none() {
3579                    owner_change = Some((None, OwnerChangeReason::Attach));
3580                }
3581            }
3582            AttachMode::Takeover => {
3583                let reason = if self.input_owner.is_some() {
3584                    OwnerChangeReason::Takeover
3585                } else {
3586                    OwnerChangeReason::Attach
3587                };
3588                owner_change = Some((self.input_owner, reason));
3589            }
3590        }
3591        if let Some((from, reason)) = owner_change {
3592            self.input_owner = Some(cid);
3593            self.emit(SessionEventWire::InputOwnerChanged {
3594                from,
3595                to: Some(cid),
3596                reason,
3597            });
3598        }
3599        self.publish_presence();
3600        let snapshot = self.snapshot_for(&snapshot_meta);
3601        self.emit(SessionEventWire::Attached {
3602            client: cid,
3603            snapshot,
3604        });
3605        self.emit(SessionEventWire::ClientJoined { client: cid, kind });
3606    }
3607
3608    /// Never touches `stream`/`cancel`: the turn keeps running and its
3609    /// events buffer in the broadcast (§8 detach-without-abort). The owner
3610    /// leaving passes input to the oldest attached non-`Observe` client.
3611    fn detach(&mut self, client: ClientId) {
3612        if self.attached.remove(&client).is_none() {
3613            return;
3614        }
3615        self.update_attach_state();
3616        self.emit(SessionEventWire::ClientLeft { client });
3617        if self.input_owner == Some(client) {
3618            let next = self
3619                .attached
3620                .iter()
3621                .filter(|(_, a)| a.mode != AttachMode::Observe)
3622                .map(|(c, _)| *c)
3623                .min_by_key(|c| c.0);
3624            self.input_owner = next;
3625            self.emit(SessionEventWire::InputOwnerChanged {
3626                from: Some(client),
3627                to: next,
3628                reason: OwnerChangeReason::OwnerDetached,
3629            });
3630            self.publish_presence();
3631        }
3632        // (E-P7 §S1 / P11) The last client just left. Fail-closed gates apply
3633        // ONLY when a driver is (or was about to be) armed:
3634        //
3635        //  1. An armed driver is revoked: it must never run turns headless with
3636        //     nobody watching and no way to answer a confirmation.
3637        //  2. Its pending host confirmations are answered `None` (deny) — a
3638        //     headless autonomous run must not sit on an unanswerable prompt.
3639        //     `tools/discovery.rs` treats `None` as Unauthorized (deny).
3640        //
3641        // For a PLAIN interactive session (no driver), a pending prompt SURVIVES
3642        // detach: the user reattaches and answers it — the daemon's core
3643        // detach/reattach contract, and detach is often involuntary (SSH drop,
3644        // sleep, wifi). Denying on detach would let a transient disconnect
3645        // silently reject the user's action. The resource pin from an
3646        // *abandoned* prompt is bounded by the pending-prompt deadline instead
3647        // (see the actor select loop), not by punishing every detach.
3648        if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
3649            self.driver_revoke("no clients attached");
3650            let had_prompts = !self.pending_prompts.is_empty();
3651            while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3652                let _ = tx.send(None);
3653                self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3654            }
3655            if had_prompts {
3656                self.publish_presence();
3657            }
3658        }
3659    }
3660
3661    /// B1 (used by C3 reload): checkpoint the session without ending it —
3662    /// cancel any turn (partial history kept + interruption marker), abort compaction, answer
3663    /// pending prompts `None`, save, close PTYs. Replies on
3664    /// `CHECKPOINT_QUERY_ID` so `reload.rs` can await it per session.
3665    pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
3666        let interrupt = match reason {
3667            CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
3668            CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
3669        };
3670        // Before the revoke below cancels a driver turn.
3671        self.note_cancel_cause(interrupt);
3672        // E-P3: driver does NOT survive reload (§3 S2/S5).
3673        if self.driver.is_some() {
3674            self.driver_revoke("daemon reloaded");
3675        }
3676        if self.streaming {
3677            self.cancel_turn(interrupt).await;
3678        }
3679        self.abort_compaction();
3680        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3681            let _ = tx.send(None);
3682            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3683        }
3684        // One bounded wait covers the cancelled turn's save too (queued, not
3685        // awaited, by `cancel_turn`).
3686        if !self.save().await {
3687            tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
3688        }
3689        let notice = match reason {
3690            CheckpointReason::Reload => {
3691                "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
3692            }
3693            CheckpointReason::HostRequest => {
3694                "checkpoint — background shells/PTYs closed; the turn was checkpointed"
3695            }
3696        };
3697        self.emit(SessionEventWire::SystemNotice(notice.to_string()));
3698        self.runtime.session_manager().shutdown_all();
3699        let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3700        self.emit(SessionEventWire::QueryResult {
3701            id: super::wire::CHECKPOINT_QUERY_ID,
3702            value: serde_json::json!({ "ok": true, "record": record }),
3703        });
3704    }
3705
3706    /// Abort the spawned job (`CompactionCancelled`); prior state intact.
3707    pub(crate) fn abort_compaction(&mut self) {
3708        if let Some(job) = self.compact.take() {
3709            job.task.abort();
3710            self.emit(SessionEventWire::CompactionCancelled);
3711        }
3712    }
3713
3714    // ── command dispatch ─────────────────────────────────────────────────
3715
3716    /// B1: a client-stamped input command from anyone but the owner is
3717    /// `Refused` with no side effect; `from: None` (host) bypasses.
3718    async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
3719        use std::ops::ControlFlow;
3720        let Addressed { from, cmd } = addressed;
3721        if let Some(c) = from {
3722            if is_input_command(&cmd) && self.input_owner != Some(c) {
3723                let reason = match self.input_owner {
3724                    Some(o) => format!("input owned by client #{}", o.0),
3725                    None => "input has no owner; attach with --takeover".to_string(),
3726                };
3727                self.emit(SessionEventWire::Refused {
3728                    client: c,
3729                    command: command_name(&cmd).to_string(),
3730                    reason,
3731                });
3732                return ControlFlow::Continue(());
3733            }
3734        }
3735        // B3: anything that needs the runtime wakes a parked session first.
3736        if self.is_parked() {
3737            let needs_runtime = !matches!(
3738                cmd,
3739                SessionCommand::Attach { .. }
3740                    | SessionCommand::Detach { .. }
3741                    | SessionCommand::End { .. }
3742                    | SessionCommand::Query { .. }
3743                    | SessionCommand::Answer { .. }
3744                    | SessionCommand::Save
3745                    | SessionCommand::Cancel
3746                    | SessionCommand::Checkpoint { .. }
3747                    | SessionCommand::Park
3748                    | SessionCommand::Resync { .. }
3749                    | SessionCommand::HostEvent(_)
3750            );
3751            if matches!(cmd, SessionCommand::HostEvent(_)) {
3752                return ControlFlow::Continue(()); // nobody attached, nothing to render
3753            }
3754            if matches!(
3755                cmd,
3756                SessionCommand::Save
3757                    | SessionCommand::Cancel
3758                    | SessionCommand::Park
3759                    | SessionCommand::Checkpoint { .. }
3760            ) {
3761                if let SessionCommand::Checkpoint { .. } = cmd {
3762                    let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
3763                    self.emit(SessionEventWire::QueryResult {
3764                        id: super::wire::CHECKPOINT_QUERY_ID,
3765                        value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
3766                    });
3767                }
3768                return ControlFlow::Continue(()); // already on disk / idle
3769            }
3770            if needs_runtime {
3771                if let Err(e) = self.ensure_live().await {
3772                    match from {
3773                        Some(c) => self.emit(SessionEventWire::Refused {
3774                            client: c,
3775                            command: command_name(&cmd).to_string(),
3776                            reason: e,
3777                        }),
3778                        None => self.emit(SessionEventWire::SystemNotice(e)),
3779                    }
3780                    return ControlFlow::Continue(());
3781                }
3782            }
3783        }
3784        match cmd {
3785            SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
3786            SessionCommand::Steer { text } => {
3787                if self.streaming {
3788                    self.steer(text)
3789                } else {
3790                    self.submit(text, vec![], from).await
3791                }
3792            }
3793            SessionCommand::Cancel => {
3794                // The user's cancel, even of a driver's turn: noted before
3795                // the revoke below cancels that turn.
3796                self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
3797                if self.driver.is_some() {
3798                    self.driver_revoke("canceled");
3799                }
3800                self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
3801                    .await;
3802            }
3803            SessionCommand::Answer { prompt_id, value } => self.answer(prompt_id, value),
3804            SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
3805            // `CompactionStarted` is the contract; no notice before it.
3806            SessionCommand::Compact { instructions } => {
3807                self.compact(instructions, "manual").await;
3808            }
3809            SessionCommand::NewSession => {
3810                if self.driver.is_some() {
3811                    self.driver_revoke("session replaced");
3812                }
3813                // Before `clear` saves the old session: a ping in flight is
3814                // aborted and booked to it, not to the new one.
3815                self.cache_keepwarm_conversation_replaced();
3816                // `clear` saves the old session directly: queued saves of it
3817                // must land first, never after.
3818                self.persister.flush().await;
3819                self.conv.clear(&self.runtime).await;
3820                self.runtime
3821                    .set_session_id(Some(self.conv.session.id.clone()));
3822                self.journal_id.store(Arc::new(self.conv.session.id.clone()));
3823                // F10: lock follows the new session id.
3824                self.reacquire_session_lock(&self.conv.session.id.clone());
3825                self.emit(SessionEventWire::Cleared {
3826                    session_id: self.conv.session.id.clone(),
3827                });
3828                self.emit_conversation();
3829            }
3830            SessionCommand::Save => {
3831                self.save().await;
3832            }
3833            SessionCommand::Query { id, query } => self.query(id, query).await,
3834            SessionCommand::EngineCommand { id, name, arg } => {
3835                self.engine_command(id, name, arg).await
3836            }
3837            SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
3838            SessionCommand::Detach { client } => {
3839                self.detach(client);
3840                // Nobody left to notice a stale period: confirm the prefix
3841                // now rather than at the next ping, so a session whose next
3842                // request already changed is free to park.
3843                if self.attached.is_empty() && self.cache_keepwarm.active() {
3844                    self.cache_keepwarm_recheck().await;
3845                }
3846            }
3847            SessionCommand::End { reason } => {
3848                // `finish` cancels a running turn for this reason; note it
3849                // before the revoke below cancels a driver turn first.
3850                self.note_cancel_cause(match reason {
3851                    EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
3852                    _ => crate::engine::interrupt::InterruptReason::Host,
3853                });
3854                if self.driver.is_some() {
3855                    self.driver_revoke("session ending");
3856                }
3857                return ControlFlow::Break(reason);
3858            }
3859            SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
3860                "resync not supported yet".into(),
3861            )),
3862            // A3 bodies live in actor_cmds.rs; B1 (Checkpoint), B3 (KeepWarm)
3863            // fill in the rest.
3864            SessionCommand::SubmitPrepared {
3865                messages,
3866                user_text,
3867            } => self.submit_prepared(messages, user_text).await,
3868            SessionCommand::PluginCommand {
3869                id,
3870                plugin,
3871                name,
3872                arg,
3873            } => self.plugin_command(id, plugin, name, arg).await,
3874            SessionCommand::Resume { id, query } => self.resume(id, query).await,
3875            SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
3876            SessionCommand::Park => {
3877                // Explicit request: end an active keep-warm period, then park.
3878                self.cache_keepwarm_park_requested();
3879                if let std::ops::ControlFlow::Break(reason) = self.park().await {
3880                    return ControlFlow::Break(reason);
3881                }
3882            }
3883            SessionCommand::KeepWarm { on } => {
3884                self.keep_warm = on;
3885                self.rearm_park();
3886                self.emit(SessionEventWire::SystemNotice(format!(
3887                    "keep-warm {}",
3888                    if on { "on: this session will not be parked" } else { "off" }
3889                )));
3890            }
3891            SessionCommand::HostEvent(ev) => match ev {
3892                HostEvent::ExtensionNotification {
3893                    extension_id,
3894                    method,
3895                    params,
3896                } => self.emit(SessionEventWire::ExtensionNotification {
3897                    extension_id,
3898                    method,
3899                    params,
3900                }),
3901                HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
3902            },
3903            // E-P3: driver start handler.
3904            SessionCommand::DriverStart { plugin, command, arg } => {
3905                self.driver_start(plugin, command, arg);
3906            }
3907        }
3908        ControlFlow::Continue(())
3909    }
3910
3911    // ── teardown (tui/mod.rs:352-432 + chat.rs shutdown) ─────────────────
3912
3913    async fn finish(&mut self, reason: EndReason) {
3914        self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::SessionEnding);
3915        self.lifecycle.store(
3916            SessionLifecycle::Ending as u8,
3917            std::sync::atomic::Ordering::Release,
3918        );
3919        // Quitting the client mid-turn is the user's interruption; every
3920        // other end reason is the host's.
3921        let interrupt = match reason {
3922            EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
3923            _ => crate::engine::interrupt::InterruptReason::Host,
3924        };
3925        // Before the revoke below cancels a driver turn.
3926        self.note_cancel_cause(interrupt);
3927        // E-P3: clean up driver before teardown.
3928        if self.driver.is_some() {
3929            self.driver_revoke("session ending");
3930        }
3931        if self.streaming {
3932            self.cancel_turn(interrupt).await;
3933        }
3934        self.abort_compaction();
3935        // Outstanding prompts are cancelled (tool sees `None`).
3936        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3937            let _ = tx.send(None);
3938            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3939        }
3940
3941        let parked = !self.conv.is_live();
3942        let session_id = (**self.journal_id.load()).clone();
3943        let api_messages = if parked {
3944            None
3945        } else {
3946            Some(self.conv.api_messages.clone())
3947        };
3948
3949        // STEP 1: save — own bounded budget, highest priority. A parked
3950        // session is already on disk: end record only. The wait covers
3951        // everything queued, including the cancelled turn's save.
3952        let persist = self.config.persist;
3953        if !parked {
3954            self.request_save();
3955        }
3956        let persister = &self.persister;
3957        let save_fut = async {
3958            if persist {
3959                if !persister.flush().await {
3960                    tracing::warn!(session = %session_id, "session end: save failed");
3961                }
3962                let mut index_record =
3963                    crate::core::session_index::SessionIndexRecord::end(&session_id);
3964                index_record.turns = api_messages.as_ref().map(|m| m.len());
3965                if let Err(err) = crate::core::session_index::append_record(&index_record) {
3966                    tracing::warn!("failed to append session end index record: {}", err);
3967                }
3968            }
3969        };
3970        if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
3971            .await
3972            .is_err()
3973        {
3974            tracing::warn!(
3975                budget_secs = budgets::SAVE_TIMEOUT_SECS,
3976                "session save timed out — data may be incomplete"
3977            );
3978        }
3979
3980        // STEP 2 (C2): on_session_end — per session, concurrent, fail-open,
3981        // own budget; clears this session's keyed injection.
3982        let hook_bus = Arc::clone(&self.hook_bus);
3983        crate::extensions::loader::emit_session_end(
3984            &hook_bus,
3985            &session_id,
3986            api_messages,
3987            budgets::HOOKS_TIMEOUT,
3988        )
3989        .await;
3990
3991        // STEP 3: bounded observability flush (live only).
3992        if self.runtime.is_live() {
3993            if let Some(outcome) = self
3994                .runtime
3995                .shutdown_observability_async(
3996                    crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
3997                )
3998                .await
3999            {
4000                if !outcome.is_flushed() {
4001                    tracing::warn!(
4002                        stats = ?outcome.stats(),
4003                        "observability flush timed out — detached worker keeps draining"
4004                    );
4005                }
4006            }
4007        }
4008
4009        // Inbox watcher, per-session UDS, registry entry, keyed injection.
4010        self.background.shutdown();
4011        self.emit(SessionEventWire::Ended { reason });
4012    }
4013
4014    pub fn id(&self) -> &SessionId {
4015        &self.id
4016    }
4017}
4018
4019/// The tokio task. `SessionTask::run` is the reactor loop.
4020pub struct SessionTask(SessionActor);
4021
4022async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
4023    match stream {
4024        Some(s) => s.next().await,
4025        None => std::future::pending().await,
4026    }
4027}
4028
4029/// Whether the 1 Hz `SubagentRows` tick keeps running: always during a turn,
4030/// and after it while any worker is still running (a background worker's
4031/// progress events rode the ended turn's stream; the rows carry it now).
4032fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
4033    streaming || any_running
4034}
4035
4036async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
4037    match tick {
4038        Some(t) => {
4039            t.tick().await;
4040        }
4041        None => std::future::pending().await,
4042    }
4043}
4044
4045async fn poll_compaction(
4046    job: &mut Option<CompactionJob>,
4047) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
4048    match job {
4049        Some(j) => (&mut j.task).await,
4050        None => std::future::pending().await,
4051    }
4052}
4053
4054async fn park_timer(deadline: Option<tokio::time::Instant>) {
4055    match deadline {
4056        Some(t) => tokio::time::sleep_until(t).await,
4057        None => std::future::pending().await,
4058    }
4059}
4060
4061/// `revoked_turn_deadline`: same shape as `park_timer`.
4062async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
4063    match deadline {
4064        Some(t) => tokio::time::sleep_until(t).await,
4065        None => std::future::pending().await,
4066    }
4067}
4068
4069/// (P11) Prompt-abandonment deadline: same shape as `park_timer`. `None` =
4070/// disabled (no abandoned prompt, or the feature is off) → pends forever.
4071async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
4072    match deadline {
4073        Some(t) => tokio::time::sleep_until(t).await,
4074        None => std::future::pending().await,
4075    }
4076}
4077
4078/// 200 ms cadence, gated on driver state — `None` driver+pending → pending forever.
4079async fn driver_tick_timer(
4080    has_driver: bool,
4081    has_pending: bool,
4082    interval: &mut tokio::time::Interval,
4083) {
4084    if has_driver || has_pending {
4085        interval.tick().await;
4086    } else {
4087        std::future::pending().await
4088    }
4089}
4090
4091async fn ext_ready(rx: &mut Option<oneshot::Receiver<()>>) {
4092    match rx {
4093        Some(r) => {
4094            let _ = r.await;
4095        }
4096        None => std::future::pending().await,
4097    }
4098}
4099
4100impl SessionTask {
4101    pub fn id(&self) -> &SessionId {
4102        self.0.id()
4103    }
4104
4105    /// Unbiased select (the TUI loop is unbiased too).
4106    pub async fn run(mut self) {
4107        let queue = Arc::clone(&self.0.event_queue);
4108        let reason = loop {
4109            let actor = &mut self.0;
4110            tokio::select! {
4111                cmd = actor.cmd_rx.recv() => match cmd {
4112                    Some(cmd) => {
4113                        if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
4114                            break reason;
4115                        }
4116                    }
4117                    // Every handle dropped: nobody can ever reach us again.
4118                    None => break EndReason::HostShutdown,
4119                },
4120                Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
4121                _ = queue.notified() => actor.on_queue_wake().await,
4122                _ = next_tick(&mut actor.subagent_tick) => {
4123                    actor.publish_subagent_rows();
4124                    actor.flush_turn_draft();
4125                }
4126                _ = park_timer(actor.park_deadline) => {
4127                    if let std::ops::ControlFlow::Break(reason) = actor.park().await {
4128                        break reason;
4129                    }
4130                }
4131                _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
4132                    actor.on_prompt_abandon_deadline();
4133                }
4134                _ = super::cache_keepwarm::keepwarm_timer(actor.cache_keepwarm.deadline()) => {
4135                    actor.on_cache_keepwarm_due().await;
4136                }
4137                res = super::cache_keepwarm::poll_keepwarm_ping(&mut actor.cache_keepwarm.task) => {
4138                    actor.on_cache_keepwarm_done(res);
4139                }
4140                res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
4141                _ = ext_ready(&mut actor.ext_ready) => {
4142                    actor.ext_ready = None;
4143                    let id = (**actor.journal_id.load()).clone();
4144                    let bus = Arc::clone(&actor.hook_bus);
4145                    crate::extensions::loader::emit_session_start(&bus, &id).await;
4146                    if actor.runtime.is_live() {
4147                        actor.publish_view().await;
4148                    }
4149                },
4150                _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval) => {
4151                    actor.driver_tick().await;
4152                }
4153                _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
4154                    actor.finish_revoked_turn(false).await;
4155                }
4156                ev = next_stream_event(&mut actor.stream) => match ev {
4157                    Some(ev) => actor.on_stream_event(ev).await,
4158                    // A revoked turn's stream ending is its end.
4159                    None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
4160                    None => {
4161                        // Stream ended without a terminal event: defensive reset.
4162                        // P5: capture_terminal(None) = EOF → revoke driver.
4163                        if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
4164                            let terminal = super::driver::capture_terminal(None, false);
4165                            if let Some(terminal) = terminal {
4166                                if let Some(driver) = actor.driver.as_mut() {
4167                                    let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
4168                                    // F-NEW-1 (shady): EOF is a Blocked terminal.
4169                                    // Emit a DriverTurnOutcome before revoking so a
4170                                    // client tracking outcomes sees the same
4171                                    // outcome+revoke pair the Error path produces —
4172                                    // not a turn that silently vanishes.
4173                                    let sel = driver.selection.clone();
4174                                    let fb = driver.grant.feedback_enabled()
4175                                        .then(|| driver.completed_feedback.to_string());
4176                                    actor.emit(SessionEventWire::DriverTurnOutcome {
4177                                        outcome: crate::extensions::session_driver::Outcome::Blocked,
4178                                        selection: sel,
4179                                        feedback: fb,
4180                                    });
4181                                    if let Some(reason) = reason {
4182                                        actor.driver_revoke(&reason);
4183                                    }
4184                                }
4185                            }
4186                        }
4187                        actor.clear_stream();
4188                        actor.emit_conversation();
4189                        actor.announce_idle().await;
4190                    }
4191                },
4192            }
4193        };
4194        self.0.finish(reason).await;
4195    }
4196}
4197
4198#[cfg(test)]
4199mod subagent_tick_tests {
4200    use super::keep_subagent_tick;
4201
4202    #[test]
4203    fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
4204        assert!(keep_subagent_tick(true, false), "during a turn, always");
4205        assert!(keep_subagent_tick(true, true));
4206        assert!(
4207            keep_subagent_tick(false, true),
4208            "after the turn, while a background worker runs"
4209        );
4210        assert!(
4211            !keep_subagent_tick(false, false),
4212            "stops once the turn is over and no worker runs"
4213        );
4214    }
4215}
4216
4217#[cfg(test)]
4218mod parked_evict_tests {
4219    use super::parked_evict_after_from;
4220    use super::prompt_abandon_timeout_from;
4221    use std::time::Duration;
4222
4223    #[test]
4224    fn parked_evict_defaults_to_one_hour_and_honours_never() {
4225        // cfg_secs = 3600 mirrors the DaemonConfig default.
4226        let d = 3600u64;
4227        assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
4228        assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
4229        for never in ["never", "0", "off"] {
4230            assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
4231        }
4232        assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
4233        // env absent → config wins; config 0 disables; env still beats config.
4234        assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
4235        assert_eq!(parked_evict_after_from(None, 0), None);
4236        assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
4237    }
4238
4239    #[test]
4240    fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
4241        // cfg_secs = 3600 mirrors the DaemonConfig default.
4242        let d = 3600u64;
4243        assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
4244        assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
4245        // Disabled sentinels ⇒ None ⇒ pure pre-#112 (prompt survives forever).
4246        for never in ["never", "0", "off"] {
4247            assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
4248        }
4249        // Garbage falls back to the safe default rather than disabling the guard.
4250        assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
4251        // env absent → config wins; config 0 disables; env still beats config.
4252        assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
4253        assert_eq!(prompt_abandon_timeout_from(None, 0), None);
4254        assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
4255    }
4256}