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 is persisted under; `None` = not persisted
64    /// (no turn open, or this actor doesn't write drafts). `base_len` and
65    /// `text` are tracked for every turn: a cancel whose drain times out
66    /// keeps the streamed text (`SessionActor::keep_streamed_partial`).
67    open: Option<String>,
68    /// History length the in-flight response continues from.
69    base_len: usize,
70    /// Text streamed in the in-flight response so far.
71    text: String,
72    /// Tools the in-flight response has called (complete `ToolUse`s), which
73    /// may be running. Not persisted.
74    tools: Vec<String>,
75    /// Changed since the last flush.
76    dirty: bool,
77}
78
79/// What a started turn needs to open its stream (`start_turn`), held while
80/// its request waits for `on_session_start`.
81pub(crate) struct TurnStart {
82    cancel: CancellationToken,
83    steer_rx: mpsc::UnboundedReceiver<String>,
84    auto_approve: bool,
85    completion: crate::runtime::TurnCompletion,
86    /// Events started this turn (nobody typed it): its stream runs under the
87    /// event turn budget (#251). Decided from the turn's trigger at
88    /// `start_turn`, applied when the stream opens (`open_turn_stream`),
89    /// which may be after `on_session_start` has run.
90    event_turn: bool,
91}
92
93/// Why autonomous work may not start now
94/// ([`SessionActor::autonomous_work_blocked`]).
95#[derive(Clone, Copy, Debug, PartialEq, Eq)]
96pub(crate) enum WorkBlock {
97    /// A reload checkpointed this session: nothing may start work the reload
98    /// record does not carry until the exec, or until the daemon reports
99    /// that it failed (#255's reload fence).
100    Fenced,
101    /// This session's `on_session_start` has not run yet: a request sent
102    /// now would lack its injection.
103    SessionStartPending,
104}
105
106/// A source of autonomous work in the actor's loop: work nobody asked for
107/// with a command ([`SessionActor::holds`]).
108#[derive(Clone, Copy, Debug, PartialEq, Eq)]
109pub(crate) enum WorkSource {
110    /// Queued events (inbox, `synaps send`, worker completions).
111    QueueWake,
112    /// The park grace or a worker re-check.
113    ParkTimer,
114    /// A due prompt-cache keep-warm ping.
115    KeepWarmTimer,
116    /// The armed session driver's 200 ms tick.
117    DriverTick,
118}
119
120/// What `SessionActor::drain_cancelled_stream` recovered from a cancelled
121/// turn's stream.
122#[derive(Default)]
123pub(crate) struct CancelDrain {
124    /// The engine's final history for the turn (last `MessageHistory` seen).
125    pub(crate) history: Option<Vec<crate::SharedMessage>>,
126    /// The stream ended (`Done` / EOF) inside the drain budget.
127    pub(crate) closed: bool,
128}
129
130/// A field that is `None` exactly while the session is `Parked` (B3).
131/// Derefs to the value so the turn machine reads `self.runtime` / `self.conv`
132/// unchanged; every command that needs them goes through `ensure_live()`
133/// first, so the panic path is a programming error, not a runtime state.
134pub(crate) struct Live<T>(Option<T>);
135
136impl<T> Live<T> {
137    pub(crate) fn new(v: T) -> Self {
138        Self(Some(v))
139    }
140    pub(crate) fn is_live(&self) -> bool {
141        self.0.is_some()
142    }
143    pub(crate) fn park_take(&mut self) -> Option<T> {
144        self.0.take()
145    }
146    pub(crate) fn unpark_set(&mut self, v: T) {
147        self.0 = Some(v);
148    }
149}
150
151impl<T> std::ops::Deref for Live<T> {
152    type Target = T;
153    fn deref(&self) -> &T {
154        self.0.as_ref().expect("session is parked: runtime/conv unavailable")
155    }
156}
157
158impl<T> std::ops::DerefMut for Live<T> {
159    fn deref_mut(&mut self) -> &mut T {
160        self.0.as_mut().expect("session is parked: runtime/conv unavailable")
161    }
162}
163
164/// `SYNAPS_DAEMON_PARKED_EVICT_SECS`: how long a PARKED session stays in the
165/// daemon's map before it is evicted (`EndReason::Evicted`). Its state is on
166/// disk; `--continue <id>` rebuilds it exactly as after a daemon restart. The
167/// row only exists so `--attach <id>` / the adopt banner can find it quickly.
168/// Default 1 h; `never` → keep forever.
169pub fn parked_evict_after() -> Option<std::time::Duration> {
170    // Precedence: env > config (`daemon.parked_evict_secs`) > builtin default.
171    let cfg_secs = crate::config::load_config().daemon.parked_evict_secs;
172    parked_evict_after_from(
173        std::env::var("SYNAPS_DAEMON_PARKED_EVICT_SECS").ok().as_deref(),
174        cfg_secs,
175    )
176}
177
178fn parked_evict_after_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
179    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
180    match v.map(str::trim) {
181        Some("never" | "0" | "off") => None,
182        Some(n) => n
183            .parse::<u64>()
184            .ok()
185            .map(std::time::Duration::from_secs)
186            .or_else(from_cfg),
187        None => from_cfg(),
188    }
189}
190
191/// `SYNAPS_DAEMON_IDLE_END_GRACE_SECS`: how long a ZERO-turn session with no
192/// clients lingers before it ends (F18). Default 5 s — long enough for a
193/// client that disconnected mid-reconnect, short enough that open-and-close
194/// does not pin a Runtime for a minute.
195pub fn idle_end_grace() -> std::time::Duration {
196    std::env::var("SYNAPS_DAEMON_IDLE_END_GRACE_SECS")
197        .ok()
198        .and_then(|v| v.trim().parse::<u64>().ok())
199        .map(std::time::Duration::from_secs)
200        .unwrap_or(std::time::Duration::from_secs(5))
201}
202
203/// `SYNAPS_DAEMON_PARK_GRACE_SECS`: `never` → `None` (Parked disabled);
204/// `n` → n seconds after the last detach once idle; default 60.
205pub fn park_grace() -> Option<std::time::Duration> {
206    match std::env::var("SYNAPS_DAEMON_PARK_GRACE_SECS") {
207        Ok(v) if v.trim().eq_ignore_ascii_case("never") => None,
208        Ok(v) => v
209            .trim()
210            .parse::<u64>()
211            .ok()
212            .map(std::time::Duration::from_secs)
213            .or(Some(DEFAULT_PARK_GRACE)),
214        Err(_) => Some(DEFAULT_PARK_GRACE),
215    }
216}
217
218pub const DEFAULT_PARK_GRACE: std::time::Duration = std::time::Duration::from_secs(60);
219
220/// How often a detached idle session re-checks a park its workers hold
221/// (`can_park_ignoring_keepwarm`).
222pub const WORKER_PARK_RECHECK: std::time::Duration = std::time::Duration::from_secs(30);
223
224#[cfg(any(test, feature = "testing"))]
225static WORKER_PARK_RECHECK_OVERRIDE_MS: std::sync::atomic::AtomicU64 =
226    std::sync::atomic::AtomicU64::new(0);
227
228/// [`WORKER_PARK_RECHECK`], or the test override. Code-only, like
229/// `cache_keepwarm::interval_override`: no binary input can reach it.
230fn worker_park_recheck() -> std::time::Duration {
231    #[cfg(any(test, feature = "testing"))]
232    {
233        let ms = WORKER_PARK_RECHECK_OVERRIDE_MS.load(std::sync::atomic::Ordering::Relaxed);
234        if ms > 0 {
235            return std::time::Duration::from_millis(ms);
236        }
237    }
238    WORKER_PARK_RECHECK
239}
240
241/// Test-only controls (`cfg(test)` / `testing` feature).
242#[cfg(any(test, feature = "testing"))]
243pub mod testing {
244    use std::sync::atomic::Ordering;
245    use std::time::Duration;
246
247    /// Replaces the worker park re-check interval for every session in
248    /// this process while alive (tests that use it must be `#[serial]`).
249    #[must_use = "the override is cleared when the guard drops"]
250    pub struct WorkerParkRecheck(());
251
252    impl WorkerParkRecheck {
253        pub fn set(interval: Duration) -> Self {
254            let ms = u64::try_from(interval.as_millis()).unwrap_or(u64::MAX).max(1);
255            super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(ms, Ordering::Relaxed);
256            Self(())
257        }
258    }
259
260    impl Drop for WorkerParkRecheck {
261        fn drop(&mut self) {
262            super::WORKER_PARK_RECHECK_OVERRIDE_MS.store(0, Ordering::Relaxed);
263        }
264    }
265}
266
267/// `SYNAPS_DAEMON_PROMPT_ABANDON_SECS`: how long a pending host confirmation on
268/// a NON-driver session survives after the LAST client detaches before it is
269/// fail-closed answered `None` (deny) so `can_park()` can proceed. This bounds
270/// the resource pin from an ABANDONED prompt WITHOUT punishing a transient
271/// disconnect (SSH drop, sleep, wifi): a re-attach before expiry cancels it and
272/// the user answers the prompt normally. Default 1 h — generous enough that a
273/// human coming back from lunch still answers; `never`/`0`/`off` → `None` =
274/// disabled = pure pre-#112 behaviour (a prompt survives detach forever).
275///
276/// (The driver-armed case fail-closes immediately on last detach; this deadline
277/// is only for a plain interactive session — see `detach` / `rearm_prompt_abandon`.)
278pub fn prompt_abandon_timeout() -> Option<std::time::Duration> {
279    // Precedence: env > config (`daemon.prompt_abandon_secs`) > builtin default.
280    let cfg_secs = crate::config::load_config().daemon.prompt_abandon_secs;
281    prompt_abandon_timeout_from(
282        std::env::var("SYNAPS_DAEMON_PROMPT_ABANDON_SECS").ok().as_deref(),
283        cfg_secs,
284    )
285}
286
287fn prompt_abandon_timeout_from(v: Option<&str>, cfg_secs: u64) -> Option<std::time::Duration> {
288    let from_cfg = || (cfg_secs != 0).then(|| std::time::Duration::from_secs(cfg_secs));
289    match v.map(str::trim) {
290        Some("never" | "0" | "off") => None,
291        Some(n) => n
292            .parse::<u64>()
293            .ok()
294            .map(std::time::Duration::from_secs)
295            .or_else(from_cfg),
296        None => from_cfg(),
297    }
298}
299
300/// Non-persisted runtime knobs replayed after unpark (last-wins per
301/// variant). Model/reasoning live in the journal already; `/system` is
302/// runtime-only (never journaled) so it replays too (M7).
303fn replayable(setting: &SessionSetting) -> bool {
304    matches!(
305        setting,
306        SessionSetting::SystemPrompt { .. }
307            | SessionSetting::ContextWindow { .. }
308            | SessionSetting::CompactionModel { .. }
309            | SessionSetting::ApiRetries { .. }
310            | SessionSetting::SubagentTimeout { .. }
311            | SessionSetting::MaxToolOutput { .. }
312            | SessionSetting::BashTimeout { .. }
313            | SessionSetting::BashMaxTimeout { .. }
314            | SessionSetting::GrantWorkerModel { .. }
315    )
316}
317
318fn replay_setting(rt: &mut Runtime, setting: &SessionSetting) {
319    match setting {
320        SessionSetting::SystemPrompt { text } => rt.set_system_prompt(text.clone()),
321        SessionSetting::ContextWindow { tokens } => rt.set_context_window(*tokens),
322        SessionSetting::CompactionModel { model } => rt.set_compaction_model(model.clone()),
323        SessionSetting::ApiRetries { n } => rt.set_api_retries(*n),
324        SessionSetting::SubagentTimeout { secs } => rt.set_subagent_timeout(*secs),
325        SessionSetting::MaxToolOutput { bytes } => rt.set_max_tool_output(*bytes),
326        SessionSetting::BashTimeout { secs } => rt.set_bash_timeout(*secs),
327        SessionSetting::BashMaxTimeout { secs } => rt.set_bash_max_timeout(*secs),
328        SessionSetting::GrantWorkerModel { model } => {
329            let _ = rt.grant_worker_model(model);
330        }
331        _ => {}
332    }
333}
334
335/// One attached client: its meta + the mode it attached with (B1 ownership
336/// needs the mode to pick the next owner when the owner detaches).
337pub(crate) struct AttachedClient {
338    pub(crate) meta: ClientMeta,
339    pub(crate) mode: AttachMode,
340    /// Resolved at attach ([`resolve_client_name`]); unique in the session.
341    pub(crate) name: String,
342    /// Unix ms.
343    pub(crate) attached_at_ms: u64,
344    /// `SessionActor::input_seq` at this client's last input; 0 = none yet.
345    pub(crate) last_input: u64,
346}
347
348/// B2: a spawned `compact_conversation` (§2.5).
349pub(crate) struct CompactionJob {
350    pub(crate) task: tokio::task::JoinHandle<Result<crate::runtime::compaction::CompactionOutcome>>,
351    pub(crate) source: String,
352    #[allow(dead_code)]
353    pub(crate) started: tokio::time::Instant,
354}
355
356/// Commands that only the input owner may send (B1). Everything else
357/// (`Answer`, `Query`, `Save`, `Detach`, `Attach`, `Resync`, `HostEvent`)
358/// is open to every attached client — except that `answer` refuses an
359/// observer's answer to a Confirm prompt and accepts "Allow all" only from
360/// the owner (#148 C2). `Checkpoint` cancels the owner's turn
361/// and kills its PTYs, so it is owner-only from a client (M1); the daemon
362/// sends it `from: None`.
363fn is_input_command(cmd: &SessionCommand) -> bool {
364    matches!(
365        cmd,
366        SessionCommand::Checkpoint { .. }
367            | SessionCommand::Submit { .. }
368            | SessionCommand::SubmitPrepared { .. }
369            | SessionCommand::Steer { .. }
370            | SessionCommand::SubagentSteer { .. }
371            | SessionCommand::Cancel
372            | SessionCommand::Set { .. }
373            | SessionCommand::Compact { .. }
374            | SessionCommand::NewSession
375            | SessionCommand::EngineCommand { .. }
376            | SessionCommand::PluginCommand { .. }
377            | SessionCommand::Resume { .. }
378            | SessionCommand::KeepWarm { .. }
379            | SessionCommand::DriverStart { .. }
380            | SessionCommand::End {
381                reason: EndReason::ClientQuit
382            }
383    )
384}
385
386/// The MAIN-conversation note for a user steer of a worker (`SubagentSteer`):
387/// a low-severity event, formatted for the model like any drained event.
388fn subagent_steer_event(handle_id: &str, agent_name: &str, text: &str) -> crate::events::types::Event {
389    use crate::events::types::{Event, EventContent, EventSource, Severity};
390    Event {
391        id: uuid::Uuid::new_v4().to_string(),
392        timestamp: chrono::Utc::now(),
393        source: EventSource {
394            source_type: "user".to_string(),
395            name: "user".to_string(),
396            callback: None,
397        },
398        channel: None,
399        sender: None,
400        content: EventContent {
401            text: format!("User steered subagent '{agent_name}' ({handle_id}) directly: {text}"),
402            content_type: "subagent_steer".to_string(),
403            severity: Some(Severity::Low),
404            data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
405            disclosure: None,
406        },
407        expects_response: false,
408        reply_to: None,
409    }
410}
411
412/// The MAIN-conversation note for a worker the user cancelled with
413/// `/workers cancel`: its completion wake is withheld, so without this the
414/// main agent would wait for it (or dispatch the work again).
415pub(crate) fn subagent_user_cancel_event(
416    handle_id: &str,
417    agent_name: &str,
418) -> crate::events::types::Event {
419    use crate::events::types::{Event, EventContent, EventSource, Severity};
420    Event {
421        id: uuid::Uuid::new_v4().to_string(),
422        timestamp: chrono::Utc::now(),
423        source: EventSource {
424            source_type: "user".to_string(),
425            name: "user".to_string(),
426            callback: None,
427        },
428        channel: None,
429        sender: None,
430        content: EventContent {
431            text: format!(
432                "User cancelled subagent '{agent_name}' ({handle_id}) with /workers cancel. It stops at \
433                 its next step and sends no completion wake; do not wait for it. Its partial output is \
434                 readable with subagent_collect (handle_id \"{handle_id}\") only while this session \
435                 stays loaded: the handle does not survive the session being parked."
436            ),
437            content_type: "subagent_cancelled".to_string(),
438            severity: Some(Severity::Low),
439            data: Some(serde_json::json!({ "handle_id": handle_id, "agent_name": agent_name })),
440            disclosure: None,
441        },
442        expects_response: false,
443        reply_to: None,
444    }
445}
446
447/// The MAIN-conversation note for a worker `/close force` cancelled. The
448/// session ends right after, so this is for whoever continues it later
449/// (`--continue`): the worker never finished and no wake is coming.
450pub(crate) fn subagent_close_cancel_event(
451    handle_id: &str,
452    agent_name: &str,
453) -> crate::events::types::Event {
454    let mut event = subagent_user_cancel_event(handle_id, agent_name);
455    event.content.text = format!(
456        "User closed this session with /close force while subagent '{agent_name}' ({handle_id}) was \
457         running, so it was cancelled before it finished. It sends no completion wake; do not wait for \
458         it. If its work is still needed, check what it left behind and dispatch it again."
459    );
460    event
461}
462
463/// `KeepWarm{on: true}`'s notice (`/pin on`, `daemon pin`, `--keep-warm`).
464pub const PIN_ON_NOTICE: &str = "pinned: this session stays loaded (never parked or idle-ended, \
465     and the daemon will not idle-exit while it is pinned) and comes back after daemon restarts. \
466     /pin off to undo.";
467/// `KeepWarm{on: false}`'s notice.
468pub const PIN_OFF_NOTICE: &str = "unpinned: this session parks again when idle and won't come back after a restart";
469
470/// The engine command behind `/close` (`SessionActor::close_command`).
471pub(crate) const CLOSE_COMMAND: &str = "close";
472
473fn is_close_command(cmd: &SessionCommand) -> bool {
474    matches!(cmd, SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND)
475}
476
477/// The host-only engine command behind `synaps daemon rename`
478/// (`SessionActor::host_rename`; JSON args, `RenameArgs`). Clients rename
479/// with `saveas`.
480pub(crate) const RENAME_COMMAND: &str = "rename";
481
482fn is_rename_command(cmd: &SessionCommand) -> bool {
483    matches!(cmd, SessionCommand::EngineCommand { name, .. } if name == RENAME_COMMAND)
484}
485
486fn command_name(cmd: &SessionCommand) -> &'static str {
487    match cmd {
488        SessionCommand::Submit { .. } => "submit",
489        SessionCommand::SubmitPrepared { .. } => "submit_prepared",
490        SessionCommand::Steer { .. } => "steer",
491        SessionCommand::SubagentSteer { .. } => "subagent_steer",
492        SessionCommand::Cancel => "cancel",
493        SessionCommand::Answer { .. } => "answer",
494        SessionCommand::Set { .. } => "set",
495        SessionCommand::Compact { .. } => "compact",
496        SessionCommand::NewSession => "new_session",
497        SessionCommand::Save => "save",
498        SessionCommand::Query { .. } => "query",
499        SessionCommand::Attach { .. } => "attach",
500        SessionCommand::Detach { .. } => "detach",
501        SessionCommand::End { .. } => "end",
502        SessionCommand::Resync { .. } => "resync",
503        SessionCommand::EngineCommand { name, .. } if name == CLOSE_COMMAND => "close",
504        SessionCommand::EngineCommand { name, .. } if name == RENAME_COMMAND => "rename",
505        SessionCommand::EngineCommand { .. } => "engine_command",
506        SessionCommand::PluginCommand { .. } => "plugin_command",
507        SessionCommand::Resume { .. } => "resume",
508        SessionCommand::Checkpoint { .. } => "checkpoint",
509        SessionCommand::KeepWarm { .. } => "keep_warm",
510        SessionCommand::TakeInput => "take_input",
511        SessionCommand::Park => "park",
512        SessionCommand::AdoptWorkers { .. } => "adopt_workers",
513        SessionCommand::RestoreToolActivations { .. } => "restore_tool_activations",
514        SessionCommand::ReloadAborted => "reload_aborted",
515        SessionCommand::ArmPark => "arm_park",
516        SessionCommand::HostEvent(_) => "host_event",
517        SessionCommand::DriverStart { .. } => "driver_start",
518    }
519}
520
521pub struct SessionActor {
522    pub(crate) id: SessionId,
523    pub(crate) meta: SessionMeta,
524    pub(crate) config: SessionConfig,
525    /// THE runtime; `session_id` + `cwd` set by `create`. `None` ⇔ Parked.
526    pub(crate) runtime: Live<Runtime>,
527    /// `None` ⇔ Parked.
528    pub(crate) conv: Live<ConversationState>,
529    /// B3: unpark needs a fresh `foreground_runtime()`.
530    pub(crate) host: Arc<EngineHost>,
531    /// Kept across Park (`finish()` needs it without a Runtime).
532    pub(crate) hook_bus: Arc<crate::extensions::hooks::HookBus>,
533    /// Session-lifetime queue; `background` pushes into it, every Runtime
534    /// this actor builds is pointed at it (`Runtime::set_event_queue`).
535    pub(crate) event_queue: Arc<crate::events::EventQueue>,
536    /// Last-wins per variant; replayed after unpark (non-persisted knobs).
537    pub(crate) settings_replay: Vec<SessionSetting>,
538    pub(crate) keep_warm: bool,
539    /// Opt-in prompt-cache keep-warm pings while idle (task #444). Unrelated
540    /// to `keep_warm` above, which only stops the session from parking.
541    pub(crate) cache_keepwarm: super::cache_keepwarm::KeepWarmState,
542    /// Armed on last-detach-while-idle / idle-while-detached.
543    pub(crate) park_deadline: Option<tokio::time::Instant>,
544    /// `park_deadline` when it was armed as a worker re-check (workers held
545    /// the park), not as the reconnect grace: a re-check that finds the
546    /// session parkable arms the grace instead of parking.
547    pub(crate) park_recheck_at: Option<tokio::time::Instant>,
548    /// (P11) Armed when the last client detaches while a pending host prompt is
549    /// held on a NON-driver session; a re-attach cancels it, expiry denies the
550    /// prompt(s) so `can_park()` can proceed. `None` = no abandoned prompt (or
551    /// the deadline is disabled by config).
552    pub(crate) prompt_abandon_deadline: Option<tokio::time::Instant>,
553    // ── turn machine: the run() loop locals + App fields ──
554    pub(crate) stream: Option<ActiveStream>,
555    pub(crate) cancel: Option<CancellationToken>,
556    pub(crate) steer_tx: Option<mpsc::UnboundedSender<String>>,
557    /// An event-started turn's way onto the session budget: `steer` sends
558    /// the owner's message through it, and the turn's stream switches to the
559    /// session budget when it delivers that message. `None` for every other
560    /// turn (they already run on the session budget).
561    pub(crate) owner_steer_budget: Option<Arc<crate::runtime::budget::OwnerSteerBudget>>,
562    pub(crate) streaming: bool,
563    pub(crate) turn_baseline: usize,
564    /// Stamps each message appended to `conv.api_messages` with its time
565    /// (`_synaps_at`); what was loaded is settled and never stamped.
566    pub(crate) message_stamper: crate::runtime::message_time::Stamper,
567    pub(crate) consecutive_auto_turns: u32,
568    /// Formatted events `on_queue_wake` steered into this turn's stream
569    /// that the engine has not acknowledged (`SteeringDelivered`) yet. On
570    /// cancel they would die with the steering channel; `cancel_turn` moves
571    /// them into history after the interruption marker instead.
572    pub(crate) turn_steered_events: Vec<String>,
573    /// Set by the engine when the running turn reaches its normal end
574    /// (`TurnCompletion`); read when a cancel races that end.
575    pub(crate) turn_completion: Option<crate::runtime::TurnCompletion>,
576    /// A turn cut from outside `cancel_turn` (driver revocation cancels the
577    /// driver turn's token): the stream is given until this instant to
578    /// deliver its terminal event, then dropped and the turn finished as
579    /// interrupted (`finish_revoked_turn`).
580    pub(crate) revoked_turn_deadline: Option<tokio::time::Instant>,
581    /// In-flight turn draft for crash recovery (persisting sessions only).
582    pub(crate) turn_draft: TurnDraftState,
583    /// Session saves and turn-draft writes, off the turn machine
584    /// (`session::persister`).
585    pub(crate) persister: super::persister::Persister,
586    /// The snapshot last handed to the persister: an identical one is not
587    /// queued again (a turn's end asks several times for the same state).
588    pub(crate) last_queued_save: Option<super::persister::SnapshotKey>,
589    // ── prompts ──
590    pub(crate) secret_prompt_handle: SecretPromptHandle,
591    pub(crate) secret_prompt_rx: mpsc::UnboundedReceiver<SecretPromptRequest>,
592    /// Held across detach; replayed in `AttachSnapshot.pending_prompts`.
593    pub(crate) pending_prompts: VecDeque<(PromptRequest, oneshot::Sender<Option<String>>)>,
594    pub(crate) next_prompt_id: u64,
595    // ── clients ──
596    pub(crate) cmd_rx: mpsc::Receiver<Addressed>,
597    pub(crate) events: broadcast::Sender<Envelope>,
598    pub(crate) view: Arc<arc_swap::ArcSwap<RuntimeView>>,
599    pub(crate) attached: HashMap<ClientId, AttachedClient>,
600    /// B1: the one client whose input commands are honoured (`None` = any
601    /// host-originated sender only). With `client_input = shared` it is the
602    /// *active* client: the drive client that sent input last.
603    pub(crate) input_owner: Option<ClientId>,
604    /// Who may drive (config `client_input`, or the create override).
605    pub(crate) client_input: ClientInput,
606    /// Bumped on each input from a client ([`AttachedClient::last_input`]).
607    pub(crate) input_seq: u64,
608    pub(crate) next_client_id: u64,
609    pub(crate) seq: u64,
610    pub(crate) turn_replay: VecDeque<Envelope>,
611    /// Last Codex hosted-web_search state line shown (once per session per
612    /// model/state; see `codex_wire::session_web_search_notice`).
613    pub(crate) web_search_notice: Option<String>,
614    pub(crate) state: AttachState,
615    pub(crate) background: BackgroundTasks,
616    /// B2: spawned compaction in flight (`busy` while `Some`).
617    pub(crate) compact: Option<CompactionJob>,
618    /// `compact_inline` is running (it blocks the actor; listings still
619    /// see the session busy).
620    pub(crate) compacting_inline: bool,
621    /// `on_session_start` for this session. `await_extensions=false`: a
622    /// task waits for process-level discovery and its turn at the host's
623    /// gate (one hook at a time), then calls it, so boot never blocks on
624    /// it. A turn that comes first moves it ahead of background hooks and
625    /// waits for it without blocking the loop (`turn_awaiting_start`); a
626    /// keep-warm ping waits with its timer off (`holds`).
627    pub(crate) session_start: super::session_start::SessionStart,
628    /// A started turn whose request waits for `on_session_start`: its
629    /// stream opens when the hook has run. The turn is live meanwhile
630    /// (`streaming`), so Cancel, End and a driver revocation end it as any
631    /// turn, with nothing sent.
632    pub(crate) turn_awaiting_start: Option<TurnStart>,
633    /// 1 Hz `SubagentRows` cadence while a turn runs (`None` when idle).
634    pub(crate) subagent_tick: Option<tokio::time::Interval>,
635    /// Worker-registry generation of the current/last turn: a user
636    /// interrupt cancels only the workers this turn launched.
637    pub(crate) turn_worker_generation: u64,
638    /// Mirrors `handle.lifecycle()` (B3 writes Parking/Parked).
639    pub(crate) lifecycle: Arc<std::sync::atomic::AtomicU8>,
640    /// Mirrors `handle.journal_id()` (B2 stores the successor id).
641    pub(crate) journal_id: Arc<arc_swap::ArcSwap<String>>,
642    /// Mirrors `handle.presence()` (clients / owner / pending prompts).
643    pub(crate) presence: Arc<arc_swap::ArcSwap<super::handle::Presence>>,
644    /// Mirrors `handle.name()`; `sync_name` after any rename.
645    pub(crate) name: Arc<arc_swap::ArcSwap<Option<String>>>,
646    /// Mirrors `handle.pinned()`; written with `keep_warm` (create seeds it
647    /// from `meta.pinned`, `KeepWarm{on}` stores it).
648    pub(crate) keep_warm_cell: Arc<std::sync::atomic::AtomicBool>,
649    /// F10: per-session journal ownership lock. Held while Live, released on Park.
650    pub(crate) session_lock: Option<agent_core::session_lock::SessionLock>,
651    // ── E: session driver (actor-side, behind `session.drive` permission) ──
652    pub(crate) driver: Option<super::driver::DriverState>,
653    pub(crate) driver_pending: Option<super::driver::DriverPending>,
654    /// Generation counter for invalidating stale pending results.
655    pub(crate) driver_generation: u64,
656    /// Interrupted owner (for generic stop commands across revocation).
657    pub(crate) driver_interrupted_owner: Option<String>,
658    /// 200 ms cadence for `driver_tick()`, always present.
659    pub(crate) driver_tick_interval: tokio::time::Interval,
660    /// Set by a reload checkpoint until the `execv` (or until the daemon
661    /// reports it failed): see [`ReloadFence`].
662    pub(crate) reload_fence: Option<ReloadFence>,
663    /// Shared with the handle: `config.memory_mode`, readable by a reload
664    /// that gets no checkpoint reply.
665    pub(crate) memory_mode_slot: super::handle::MemoryModeSlot,
666    /// This runtime's memory has been seen active (a grant took effect).
667    /// Only then does "memory is off" mean consent was revoked; see
668    /// `effective_memory_mode`. Reset whenever the runtime is rebuilt.
669    pub(crate) memory_seen_active: bool,
670    /// While parked: the exact tool activations the dropped runtime held,
671    /// handed to the runtime the unpark builds (and to a reload record
672    /// written while parked). Empty while live.
673    pub(crate) parked_tool_activations: Vec<crate::tools::activation::CarriedActivation>,
674}
675
676/// A reload checkpoint wrote this session's reload record; until the
677/// `execv` replaces the process nothing may start new work the record does
678/// not know about. Queue wakes stay queued (the record carries the wakes it
679/// owes), and keep-warm pings, driver ticks and parking wait. Lifted by
680/// `ReloadAborted` (the exec failed) or, as a backstop, at `deadline`.
681pub(crate) struct ReloadFence {
682    pub(crate) deadline: tokio::time::Instant,
683    /// What the checkpoint recorded: re-announced to this image when the
684    /// exec does not happen.
685    pub(crate) workers: Vec<crate::runtime::subagent::CarriedWorker>,
686}
687
688/// A create that fails after taking the journal lock: a FRESH session never
689/// journaled, so its `.lock` would be litter next to no `.json` (F18). A
690/// continued session's lock file sits next to its journal and stays.
691fn release_fresh_lock(lock: Option<agent_core::session_lock::SessionLock>, continued: bool) {
692    if let (Some(lock), false) = (lock, continued) {
693        lock.release_and_remove();
694    }
695}
696
697impl SessionActor {
698    /// = today's `setup::boot()` per session (foreground_runtime →
699    /// resolve_session_and_prompt → journal lock → set_cwd → model override →
700    /// spawn_session_background → finish_session_setup) then
701    /// `on_session_start` (keyed injection).
702    pub(crate) async fn create(
703        host: &Arc<EngineHost>,
704        mut cfg: SessionConfig,
705    ) -> Result<(SessionHandle, SessionTask)> {
706        // cwd goes in BEFORE apply_config (inside foreground_runtime_for) so the
707        // one-shot memory binding scopes to the session's project (§4).
708        // A session that made its own `/memory` choice (carried by a reload)
709        // gets that, not the operator default.
710        let mut runtime = host
711            .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
712            .await?;
713        if let Some(mode) = cfg.memory_mode {
714            runtime.restore_session_memory_mode(mode);
715        }
716        let config: crate::SynapsConfig = (**host.config()).clone();
717        let client_input = cfg.client_input.unwrap_or(config.client_input);
718
719        let mut sb = crate::engine::setup::resolve_session_and_prompt(
720            &mut runtime,
721            &cfg.continue_session,
722            cfg.system.as_deref(),
723            cfg.prompt_manifest.as_deref(),
724        )?;
725        // F10: acquire the per-session journal ownership lock BEFORE anything
726        // publishes or writes for this id: the `--name` save, the registry
727        // card + per-session socket (`spawn_session_background`), the START
728        // index record and `on_session_start`. A refused `--continue` of a
729        // session live in another process used to publish its own card over
730        // the owner's first, then delete it on drop: the owner stayed live
731        // (socket up, journal locked) but `synaps send` and peers lost it.
732        let lock_kind = if cfg.await_extensions { "daemon" } else { "tui" };
733        let session_lock = if sb.continued {
734            // Continuing an existing session — the lock MUST succeed.
735            // If another process holds it, refuse with an actionable error.
736            let dir = agent_core::session_lock::sessions_dir();
737            let holder = agent_core::session_lock::LockHolder {
738                pid: std::process::id(),
739                kind: lock_kind.to_string(),
740            };
741            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
742                Ok(lock) => Some(lock),
743                Err(agent_core::session_lock::SessionLockError::Held { session_id, holder }) => {
744                    let msg = agent_core::session_lock::SessionLockError::Held { session_id, holder };
745                    return Err(crate::RuntimeError::Session(msg.to_string()));
746                }
747                Err(e) => {
748                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
749                    None
750                }
751            }
752        } else {
753            // Fresh session — best-effort lock.
754            let dir = agent_core::session_lock::sessions_dir();
755            let holder = agent_core::session_lock::LockHolder {
756                pid: std::process::id(),
757                kind: lock_kind.to_string(),
758            };
759            match agent_core::session_lock::SessionLock::try_acquire(&dir, &sb.session.id, holder) {
760                Ok(lock) => Some(lock),
761                Err(e) => {
762                    tracing::warn!(session = %sb.session.id, "session lock: {e}");
763                    None
764                }
765            }
766        };
767
768        // `--name` at create: apply BEFORE the registry entry is written so
769        // `synaps send --session <name>` and `--continue <name>` resolve
770        // from the first second — not only after a later `--continue`.
771        if let Some(name) = cfg.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) {
772            if let Err(e) = sb.session.set_name(name) {
773                release_fresh_lock(session_lock, sb.continued);
774                return Err(crate::RuntimeError::Session(format!("--name {name:?}: {e}")));
775            }
776            if cfg.persist {
777                // Persist the name now so disk resolution (chain/name → id)
778                // agrees with the live map even before the first turn.
779                if let Err(e) = sb.session.save().await {
780                    tracing::warn!(session = %sb.session.id, "failed to save named session at create: {e}");
781                }
782            }
783        }
784        runtime.set_cwd(cfg.cwd.clone());
785        // T5: rehydrate env from journal when the client sent none.
786        // Precedence: explicit Hello.env from the continuing client > journal > None.
787        // Never fall back to the daemon's own process env.
788        if cfg.env.is_none() {
789            if let Some(ref journal_env) = sb.session.env {
790                cfg.env = Some(journal_env.clone());
791            }
792            if cfg.env_stripped.is_empty() && !sb.session.env_stripped.is_empty() {
793                cfg.env_stripped = sb.session.env_stripped.clone();
794            }
795        }
796        // Always persist the latest env on the session so the next
797        // restart/continue sees it — a fresh client env supersedes
798        // whatever was journaled previously.
799        if cfg.env.is_some() {
800            sb.session.env = cfg.env.clone();
801            sb.session.env_stripped = cfg.env_stripped.clone();
802        }
803        runtime.set_env(cfg.env.clone());
804        runtime.set_env_stripped(cfg.env_stripped.clone());
805        // CLI `--model` overrides whatever was persisted (rpc.rs precedent).
806        if let Some(ref m) = cfg.model_override {
807            runtime.set_model(m.clone());
808        }
809        if let Some(clock) = &cfg.message_clock {
810            runtime.set_message_clock(clock.clone());
811        }
812
813        // Shared-inbox files are for whoever is at a keyboard: the gate stays
814        // shut until a client owns input (`publish_presence`), so a detached,
815        // parked or freshly rehydrated session is never woken by them.
816        let background = match crate::engine::setup::spawn_session_background_gated(
817            &runtime,
818            &sb.session,
819            Arc::new(std::sync::atomic::AtomicBool::new(false)),
820        ) {
821            Ok(background) => background,
822            Err(e) => {
823                release_fresh_lock(session_lock, sb.continued);
824                return Err(e);
825            }
826        };
827        crate::engine::setup::finish_session_setup(
828            &mut runtime,
829            &config,
830            &sb.session,
831            cfg.cwd.clone(),
832            crate::engine::setup::IndexRecord::Start,
833        );
834
835        // C2: per-session on_session_start (keyed injection) once the
836        // process-level discovery is known-finished. Never re-runs discovery.
837        // Every call goes through the host's session-start gate.
838        // `await_extensions=false` (TUI, a reload's restore): a spawned task
839        // runs it (`session_start` below), so boot never blocks on it.
840        // `startup.extensions_ready_timeout_secs` (config, default 30) bounds
841        // the wait on extension discovery only; the const remains the
842        // fallback. `run_at_create` then waits its turn at the gate, behind a
843        // running hook and older foreground ones (see `session_start`).
844        let ext_ready_timeout = extensions_ready_timeout(&config);
845        if cfg.await_extensions {
846            if tokio::time::timeout(ext_ready_timeout, host.extensions_ready())
847                .await
848                .is_err()
849            {
850                tracing::warn!(
851                    budget_secs = ext_ready_timeout.as_secs(),
852                    "extensions_ready timed out — on_session_start may miss late extensions"
853                );
854            }
855            super::session_start::run_at_create(host, runtime.hook_bus(), &sb.session.id).await;
856        }
857
858        let id = SessionId::from(sb.session.id.clone());
859
860        let meta = SessionMeta {
861            id: id.clone(),
862            name: sb.session.name.clone(),
863            model: runtime.model().to_string(),
864            cwd: cfg.cwd.clone(),
865            created_at: sb.session.created_at,
866            continued: sb.continued,
867            continue_info: sb.continue_info.as_ref().map(ContinueInfoWire::from),
868            host_pid: std::process::id(),
869            lifecycle: SessionLifecycle::Live,
870            clients: 0,
871            input_owner: None,
872            awaiting_input: 0,
873            roster: Vec::new(),
874            client_input: Some(client_input),
875            journal_id: sb.session.id.clone(),
876            locked_by: None,
877            pinned: cfg.keep_warm,
878            busy: false,
879            state_since: None,
880            park_due: None,
881            evict_due: None,
882            title: None,
883            cost_usd: 0.0,
884            messages: 0,
885        };
886        let mut conv = if sb.continued {
887            ConversationState::from_resumed(sb.session)
888        } else {
889            ConversationState::new(sb.session)
890        };
891        conv.api_messages = sb.api_messages;
892        conv.total_input_tokens = sb.total_input_tokens;
893        conv.total_output_tokens = sb.total_output_tokens;
894        conv.session_cost = sb.session_cost;
895        // Crash recovery (the last process died mid-turn): only the lock
896        // holder may fold the turn draft in, persist it and remove it.
897        if sb.continued && cfg.persist && session_lock.is_some() {
898            crate::engine::setup::recover_turn_draft(&mut conv).await;
899        }
900        // Loaded (and recovered) history keeps whatever times it carried.
901        let message_stamper = crate::runtime::message_time::Stamper::new(conv.api_messages.len());
902
903        let view = RuntimeView::from_runtime(&runtime).await;
904        let hook_bus = Arc::clone(runtime.hook_bus());
905        let event_queue = Arc::clone(runtime.event_queue());
906        let keep_warm = cfg.keep_warm;
907        let (
908            handle,
909            SessionEndpoints {
910                cmd_rx,
911                events,
912                view,
913                lifecycle,
914                journal_id,
915                presence,
916                name,
917                keep_warm: keep_warm_cell,
918                cache_keepwarm,
919                memory_mode,
920            },
921        ) = SessionHandle::new(meta.clone(), view);
922        // The mode is fixed for the session: seed the listing with it now.
923        // Otherwise only a presence publish (attach, detach, input) carries
924        // it, so a session restored at reload, with no client back yet,
925        // listed as `owner=-`. Not `publish_presence()`: that also sets the
926        // inbox gate, which stays as `SessionHandle::new` left it.
927        presence.store(Arc::new(super::handle::Presence {
928            client_input: Some(client_input),
929            ..Default::default()
930        }));
931        *memory_mode.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = cfg.memory_mode;
932        let (sp_tx, secret_prompt_rx) = mpsc::unbounded_channel();
933        let session_start = if cfg.await_extensions {
934            super::session_start::SessionStart::Done
935        } else if cfg.restore_parked {
936            // Parked right after create: the hook waits for the unpark.
937            super::session_start::SessionStart::Pending
938        } else {
939            super::session_start::SessionStart::queue(
940                host,
941                Arc::clone(&hook_bus),
942                Arc::clone(&journal_id),
943                ext_ready_timeout,
944            )
945        };
946
947        let mut actor = SessionActor {
948            id,
949            meta,
950            config: cfg,
951            runtime: Live::new(runtime),
952            conv: Live::new(conv),
953            host: Arc::clone(host),
954            hook_bus,
955            event_queue,
956            settings_replay: Vec::new(),
957            keep_warm,
958            cache_keepwarm: super::cache_keepwarm::KeepWarmState::new(
959                config.cache_keepwarm,
960                config.cache_keepwarm_auto_max_hours,
961                config.cache_keepwarm_headless,
962                cache_keepwarm,
963            ),
964            park_deadline: None,
965            park_recheck_at: None,
966            prompt_abandon_deadline: None,
967            stream: None,
968            cancel: None,
969            steer_tx: None,
970            owner_steer_budget: None,
971            streaming: false,
972            turn_baseline: 0,
973            message_stamper,
974            consecutive_auto_turns: 0,
975            turn_steered_events: Vec::new(),
976            turn_completion: None,
977            revoked_turn_deadline: None,
978            turn_draft: TurnDraftState::default(),
979            persister: super::persister::Persister::new(agent_core::session_lock::sessions_dir()),
980            last_queued_save: None,
981            secret_prompt_handle: SecretPromptHandle::new(sp_tx),
982            secret_prompt_rx,
983            pending_prompts: VecDeque::new(),
984            next_prompt_id: 1,
985            cmd_rx,
986            events,
987            view,
988            attached: HashMap::new(),
989            input_owner: None,
990            client_input,
991            input_seq: 0,
992            next_client_id: 1,
993            seq: 0,
994            turn_replay: VecDeque::new(),
995            web_search_notice: None,
996            state: AttachState::Detached { running: false },
997            background,
998            compact: None,
999            compacting_inline: false,
1000            session_start,
1001            turn_awaiting_start: None,
1002            subagent_tick: None,
1003            turn_worker_generation: 0,
1004            lifecycle,
1005            journal_id,
1006            presence,
1007            name,
1008            keep_warm_cell,
1009            session_lock,
1010            driver: None,
1011            driver_pending: None,
1012            driver_generation: 0,
1013            driver_interrupted_owner: None,
1014            driver_tick_interval: {
1015                let mut interval = tokio::time::interval(Duration::from_millis(200));
1016                interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
1017                interval
1018            },
1019            reload_fence: None,
1020            memory_mode_slot: memory_mode,
1021            memory_seen_active: false,
1022            parked_tool_activations: Vec::new(),
1023        };
1024        // Whether the build's grant (default or restored choice) took.
1025        actor.observe_memory();
1026        if actor.keep_warm {
1027            actor.note_pin();
1028        }
1029        Ok((handle, SessionTask(actor)))
1030    }
1031
1032    /// Publish `conv.session.name` to the handle (listings, `--continue
1033    /// <name>`) and rewrite this session's registry entry so `synaps send
1034    /// --session <name>` resolves the new name. Call after any rename.
1035    /// Write the pin as it stands now to the daemon's pin book (no-op
1036    /// in-process): the entry is added while pinned, dropped otherwise.
1037    pub(crate) fn note_pin(&self) {
1038        let Some(sink) = self.host.pin_sink() else { return };
1039        let journal = (**self.journal_id.load()).clone();
1040        if self.keep_warm {
1041            let kw = self.cache_keepwarm.override_mode.map(|m| m.to_string());
1042            sink.pinned(self.id.as_str(), &journal, self.conv.session.name.as_deref(), self.config.cwd.as_deref(), kw.as_deref());
1043        } else {
1044            sink.unpinned(self.id.as_str(), &journal);
1045        }
1046    }
1047
1048    /// Publish a new journal id; a pinned session's book entry follows it.
1049    pub(crate) fn set_journal_id(&mut self, new_id: String) {
1050        let old = self.journal_id.swap(Arc::new(new_id.clone()));
1051        if self.keep_warm && *old != new_id {
1052            if let Some(sink) = self.host.pin_sink() {
1053                sink.moved(&old, &new_id);
1054            }
1055        }
1056    }
1057
1058    pub(crate) fn sync_name(&mut self) {
1059        let name = self.conv.session.name.clone();
1060        self.mirror_name(name);
1061    }
1062
1063    /// Publish a name the session now has on disk (or in memory): handle
1064    /// and listing, the `run/<sid>.json` card, and a pinned session's book
1065    /// entry (verbatim, so a cleared name stops resolving).
1066    pub(crate) fn mirror_name(&mut self, name: Option<String>) {
1067        self.meta.name = name.clone();
1068        self.name.store(Arc::new(name.clone()));
1069        if self.keep_warm {
1070            if let Some(sink) = self.host.pin_sink() {
1071                let journal = (**self.journal_id.load()).clone();
1072                sink.renamed(&journal, name.as_deref());
1073            }
1074        }
1075        if let Err(e) = self.background.update_session_name(name.as_deref()) {
1076            tracing::warn!(session = %self.id, "registry name update failed: {e}");
1077        }
1078    }
1079
1080    // ── emit ─────────────────────────────────────────────────────────────
1081
1082    /// The ONLY seq++ site. Pushes to `turn_replay` while streaming, except
1083    /// prompt traffic (never replayed), per-client replies, and full-history
1084    /// envelopes (`MessageHistory`, `Conversation`): the engine publishes one
1085    /// per round, and an attaching client already gets the LATEST history in
1086    /// its snapshot's `conversation` — replaying older ones would ship the
1087    /// whole history once per round and roll its mirror back to a stale
1088    /// state. The ring's `TurnStarted` drops `user_text`: that prompt is
1089    /// already in the snapshot's history (a replayed copy would show it
1090    /// twice). Completed rounds leave the ring when their checkpoint is
1091    /// adopted (`trim_replay_to_checkpoint`).
1092    pub(crate) fn emit(&mut self, event: SessionEventWire) {
1093        let replay = self.streaming
1094            && !matches!(
1095                event,
1096                SessionEventWire::Prompt(_)
1097                    | SessionEventWire::PromptResolved { .. }
1098                    | SessionEventWire::Attached { .. }
1099                    | SessionEventWire::QueryResult { .. }
1100                    | SessionEventWire::Conversation(_)
1101                    | SessionEventWire::Stream(StreamEvent::Session(
1102                        SessionEvent::MessageHistory(_)
1103                    ))
1104            );
1105        let env = Envelope {
1106            session_id: self.id.clone(),
1107            seq: self.seq,
1108            ts: chrono::Utc::now(),
1109            event,
1110        };
1111        self.seq += 1;
1112        if replay {
1113            if self.turn_replay.len() >= TURN_REPLAY_CAP {
1114                self.turn_replay.pop_front();
1115            }
1116            let mut ring = env.clone();
1117            if let SessionEventWire::TurnStarted { user_text, .. } = &mut ring.event {
1118                *user_text = None;
1119            }
1120            self.turn_replay.push_back(ring);
1121        }
1122        // No receivers is not an error: streams are not tied to clients.
1123        let _ = self.events.send(env);
1124    }
1125
1126    /// A round checkpoint was adopted: every round in it is now in the
1127    /// attach snapshot's `conversation`, so the replay ring drops those
1128    /// rounds' display events — a mid-turn attach would otherwise render
1129    /// them twice (once from history, once from the ring). The engine
1130    /// publishes the checkpoint at a round boundary, so a tool call and its
1131    /// result are always on the same side of the cut. Kept: the turn's
1132    /// `TurnStarted`, notices, subagent progress, `Usage`.
1133    fn trim_replay_to_checkpoint(&mut self) {
1134        self.turn_replay.retain(|env| {
1135            !matches!(
1136                env.event,
1137                SessionEventWire::Stream(StreamEvent::Llm(_))
1138                    | SessionEventWire::Stream(StreamEvent::Agent(
1139                        AgentEvent::SteeringDelivered { .. }
1140                    ))
1141            )
1142        });
1143    }
1144
1145    pub(crate) fn emit_conversation(&mut self) {
1146        self.stamp_new_messages();
1147        let snap = self.conv.snapshot(self.consecutive_auto_turns);
1148        self.emit(SessionEventWire::Conversation(snap));
1149    }
1150
1151    /// Give every message appended since the last pass its time. Called
1152    /// wherever history leaves the actor (turn start, save, clients), so a
1153    /// message added outside a turn (events flushed at Done, notes,
1154    /// interruption markers) is stamped too. O(new messages).
1155    pub(crate) fn stamp_new_messages(&mut self) {
1156        if !self.conv.is_live() || !self.runtime.is_live() {
1157            return;
1158        }
1159        let clock = self.runtime.message_clock().clone();
1160        self.message_stamper.stamp(&mut self.conv.api_messages, &clock);
1161    }
1162
1163    pub(crate) async fn publish_view(&mut self) {
1164        let v = RuntimeView::from_runtime(&self.runtime).await;
1165        self.view.store(Arc::new(v));
1166    }
1167
1168    /// Queue a save of the current conversation. Never waits: the write
1169    /// runs on the actor's persister (latest wins), so a slow disk cannot
1170    /// stall the turn machine.
1171    pub(crate) fn request_save(&mut self) {
1172        if !self.config.persist || !self.conv.is_live() {
1173            return;
1174        }
1175        self.stamp_new_messages();
1176        let Some(session) = self.conv.prepare_save() else {
1177            return;
1178        };
1179        let key = super::persister::SnapshotKey::of(&session);
1180        // This exact state is already queued or on disk — unless the last
1181        // write failed, in which case it is queued again.
1182        if self.last_queued_save.as_ref() == Some(&key) && self.persister.saves_ok() {
1183            return;
1184        }
1185        self.last_queued_save = Some(key);
1186        self.persister.save(session);
1187    }
1188
1189    /// Wait, at most `SAVE_TIMEOUT`, until every queued save and draft
1190    /// operation has been applied. `false` on a failed save or the timeout
1191    /// (the queued writes still land, in order, when the disk catches up).
1192    pub(crate) async fn flush_saves(&mut self) -> bool {
1193        let ok = match tokio::time::timeout(budgets::SAVE_TIMEOUT, self.persister.flush()).await {
1194            Ok(ok) => ok,
1195            Err(_) => {
1196                tracing::warn!(session = %self.id, "session save still pending after the save budget");
1197                false
1198            }
1199        };
1200        if !ok {
1201            // Whatever failed is not known to be on disk: queue it again next
1202            // time instead of skipping it as a duplicate.
1203            self.last_queued_save = None;
1204        }
1205        ok
1206    }
1207
1208    /// Save now: queue the current conversation and wait for it (bounded,
1209    /// see `flush_saves`).
1210    pub(crate) async fn save(&mut self) -> bool {
1211        self.request_save();
1212        self.flush_saves().await
1213    }
1214
1215    /// A turn is over: announce `Idle` only once everything it queued is on
1216    /// disk (bounded, `flush_saves`). `Idle` has always meant "the session
1217    /// is saved": a client or script that reads the session, quits or
1218    /// hands off on `Idle` must never see the previous state. Per-round
1219    /// saves stay in the background; this waits once per turn.
1220    pub(crate) async fn announce_idle(&mut self) {
1221        // A turn may have revoked memory consent (the `memory_context`
1222        // tool): keep the handle's slot current for a reload that gets no
1223        // checkpoint reply.
1224        self.sync_memory_choice();
1225        self.flush_saves().await;
1226        // Before `Idle`, so a test that saw `Idle` knows the hook is in line.
1227        #[cfg(any(test, feature = "testing"))]
1228        if super::session_start::testing::take_requeue(&self.journal_id.load()) {
1229            self.queue_session_start();
1230        }
1231        self.emit(SessionEventWire::Idle);
1232        self.cache_keepwarm_on_idle().await;
1233    }
1234
1235    /// Wall 1 — actor-owned context-head checkpoint persistence.
1236    ///
1237    /// The receipt stays in-process (actor task → runtime stream task).
1238    /// It never crosses the wire — `SessionEventWire` maps this to `Done`.
1239    ///
1240    /// Park ordering: `can_park()` requires `!self.streaming`, and streaming
1241    /// is only cleared on stream completion/error. The checkpoint event
1242    /// arrives mid-stream, so park cannot race with a pending checkpoint.
1243    /// No explicit drain is needed.
1244    async fn handle_context_head_checkpoint(
1245        &mut self,
1246        session_id: String,
1247        messages: Vec<crate::SharedMessage>,
1248        receipt: agent_core::core::context_head::ContextHeadReceipt,
1249    ) {
1250        self.turn_draft_rolled_over();
1251        // Stale session id guard (compaction may have changed it).
1252        if session_id != self.conv.session.id {
1253            tracing::warn!(
1254                event_id = %session_id,
1255                actor_id = %self.conv.session.id,
1256                "context head checkpoint: stale session id — ignoring"
1257            );
1258            receipt.complete(Err(std::io::Error::new(
1259                std::io::ErrorKind::InvalidInput,
1260                "stale session id after compaction",
1261            )));
1262            return;
1263        }
1264
1265        // Non-persistent sessions (tests / --no-persist): complete without I/O.
1266        if !self.config.persist {
1267            receipt.complete(Ok(()));
1268            return;
1269        }
1270
1271        // A queued ordinary save must not land after (and over) the durable
1272        // head: drain the persister first.
1273        if !self.persister.flush().await {
1274            tracing::warn!(session = %self.id, "context head checkpoint: an earlier save failed");
1275        }
1276        let result = self.conv.persist_context_head(&session_id, messages).await;
1277        if let Err(ref e) = result {
1278            tracing::error!(session = %session_id, "context head checkpoint save failed: {e}");
1279        }
1280        let succeeded = result.is_ok();
1281        receipt.complete(result);
1282
1283        // P5: observe_checkpoint — revoke driver on failure or mismatch.
1284        if let Some(driver) = &self.driver {
1285            if !succeeded
1286                || driver.grant.session_id != session_id
1287                || session_id != self.conv.session.id
1288            {
1289                self.driver_revoke("context checkpoint failed or session replaced");
1290            }
1291        }
1292    }
1293
1294
1295    /// C3: the checkpoint reply payload (`SessionReloadRecord`).
1296    pub(crate) fn reload_record(&self) -> SessionReloadRecord {
1297        let view = self.view.load();
1298        SessionReloadRecord {
1299            // A consent revocation since the last record counts.
1300            config: SessionConfig {
1301                memory_mode: self.effective_memory_mode(),
1302                ..self.config.clone()
1303            },
1304            keep_warm: self.keep_warm,
1305            lifecycle: SessionLifecycle::from_u8(
1306                self.lifecycle.load(std::sync::atomic::Ordering::Acquire),
1307            ),
1308            settings_replay: self.settings_replay.clone(),
1309            cache_keepwarm: self.cache_keepwarm.override_mode.map(|m| m.to_string()),
1310            model: view.model.clone(),
1311            thinking_level: view.thinking_level.clone(),
1312            workers: Vec::new(),
1313            tool_activations: if self.runtime.is_live() {
1314                self.runtime.carried_tool_activations()
1315            } else {
1316                self.parked_tool_activations.clone()
1317            },
1318        }
1319    }
1320
1321    /// Also the one place the inbox gate follows `input_owner`: every owner
1322    /// change (attach, detach, takeover) publishes presence. `Observe`
1323    /// clients never own input, so a keep-warm observer keeps it shut.
1324    ///
1325    /// Also the `/sessions` state: busy, `state_since` (restamped whenever
1326    /// idle/busy/parked changes), the park / eviction deadline as wall
1327    /// clock, and the conversation's title / cost / message count (kept as
1328    /// last published while Parked). Every transition that moves them —
1329    /// turn and compaction start/end, attach/detach, `rearm_park`,
1330    /// park/unpark — ends in a publish; stream deltas do not.
1331    pub(crate) fn publish_presence(&self) {
1332        use super::handle::{Presence, PresenceState};
1333        self.background
1334            .set_inbox_accepting(self.input_owner.is_some());
1335        let prev = self.presence.load();
1336        let busy = self.busy() || self.compacting_inline;
1337        let parked = self.is_parked();
1338        let state = if parked {
1339            PresenceState::Parked
1340        } else if busy {
1341            PresenceState::Busy
1342        } else {
1343            PresenceState::Idle
1344        };
1345        let now = chrono::Utc::now();
1346        let state_since = match prev.state_since {
1347            Some(t) if prev.state == state => Some(t),
1348            _ => Some(now),
1349        };
1350        let wall = |at: tokio::time::Instant| {
1351            let left = at.saturating_duration_since(tokio::time::Instant::now());
1352            chrono::Duration::from_std(left)
1353                .ok()
1354                .and_then(|d| now.checked_add_signed(d))
1355        };
1356        // A worker re-check is not a park (it only looks again), nor is a
1357        // grace that workers now hold: its firing would only re-check. The
1358        // F18 idle end of a zero-turn session counts — it leaves Live too.
1359        let park_due = self
1360            .park_deadline
1361            .filter(|d| !parked && Some(*d) != self.park_recheck_at)
1362            .filter(|_| self.can_end_idle() || self.can_park())
1363            .and_then(wall);
1364        let evict_due = self.park_deadline.filter(|_| parked).and_then(wall);
1365        let (title, cost_usd, messages) = if self.conv.is_live() {
1366            (
1367                conversation_title(&self.conv),
1368                self.conv.session_cost,
1369                self.conv.api_messages.len(),
1370            )
1371        } else {
1372            (prev.title.clone(), prev.cost_usd, prev.messages)
1373        };
1374        self.presence.store(Arc::new(Presence {
1375            clients: self.attached.len(),
1376            input_owner: self.input_owner,
1377            awaiting_input: self.pending_prompts.len(),
1378            roster: self.roster(),
1379            client_input: Some(self.client_input),
1380            busy,
1381            state,
1382            state_since,
1383            park_due,
1384            evict_due,
1385            title,
1386            cost_usd,
1387            messages,
1388        }));
1389    }
1390
1391    /// Every attached client, by id.
1392    pub(crate) fn roster(&self) -> Vec<ClientInfo> {
1393        let mut roster: Vec<ClientInfo> = self
1394            .attached
1395            .iter()
1396            .map(|(id, a)| ClientInfo {
1397                id: *id,
1398                name: a.name.clone(),
1399                kind: a.meta.kind,
1400                mode: a.mode,
1401                attached_at_ms: a.attached_at_ms,
1402            })
1403            .collect();
1404        roster.sort_by_key(|c| c.id.0);
1405        roster
1406    }
1407
1408    /// `tui (#1)`, or `client #1` for an id that isn't attached.
1409    fn client_label(&self, id: ClientId) -> String {
1410        match self.attached.get(&id) {
1411            Some(a) => format!("{} (#{})", a.name, id.0),
1412            None => format!("client #{}", id.0),
1413        }
1414    }
1415
1416    /// Why client `c` may not send an input command, if it may not.
1417    /// `exclusive`: anyone but the owner. `shared`: observers (and clients
1418    /// that aren't attached); every drive client may.
1419    fn input_refusal(&self, c: ClientId) -> Option<String> {
1420        match self.client_input {
1421            ClientInput::Exclusive => (self.input_owner != Some(c)).then(|| match self.input_owner {
1422                Some(o) => format!("input owned by {}", self.client_label(o)),
1423                None => "input has no owner; attach with --takeover".to_string(),
1424            }),
1425            ClientInput::Shared => match self.attached.get(&c).map(|a| a.mode) {
1426                Some(AttachMode::Mirror | AttachMode::Takeover) => None,
1427                Some(AttachMode::Observe) => {
1428                    Some("this client is read-only (attached with --observe)".to_string())
1429                }
1430                None => Some("this client is not attached".to_string()),
1431            },
1432        }
1433    }
1434
1435    /// Client `c` sent input (or answered a prompt): remember when, and with
1436    /// `client_input = shared` make it the active client. Observers never
1437    /// become active.
1438    fn note_input(&mut self, c: ClientId) {
1439        let Some(a) = self.attached.get_mut(&c) else { return };
1440        if a.mode == AttachMode::Observe {
1441            return;
1442        }
1443        self.input_seq += 1;
1444        a.last_input = self.input_seq;
1445        if self.client_input == ClientInput::Shared && self.input_owner != Some(c) {
1446            let from = self.input_owner;
1447            self.input_owner = Some(c);
1448            self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason: OwnerChangeReason::Input });
1449            self.publish_presence();
1450        }
1451    }
1452
1453    /// Recomputes `state` from clients/streaming and (re)arms or disarms the
1454    /// park grace timer. A no-op while Parking/Parked.
1455    pub(crate) fn update_attach_state(&mut self) {
1456        self.publish_presence();
1457        if matches!(self.state, AttachState::Parking | AttachState::Parked) {
1458            return;
1459        }
1460        self.state = if self.attached.is_empty() {
1461            AttachState::Detached {
1462                running: self.streaming,
1463            }
1464        } else {
1465            AttachState::Attached(self.attached.len())
1466        };
1467        self.rearm_park();
1468        self.rearm_prompt_abandon();
1469    }
1470
1471    // ── Parked (B3) ──────────────────────────────────────────────────────
1472
1473    pub(crate) fn is_parked(&self) -> bool {
1474        matches!(self.state, AttachState::Parked)
1475    }
1476
1477    /// A session with nothing to save (`ConversationState::save` skips an
1478    /// empty `api_messages`, so no journal ever exists) never parks: it
1479    /// costs nothing warm and could not be unparked from disk (H2).
1480    pub(crate) fn can_park(&self) -> bool {
1481        // An active cache keep-warm period defers the idle auto-park (its
1482        // cost is bounded by the ski-rental ledger); when it ends,
1483        // `rearm_park`. An explicit `Park` ends the period first.
1484        self.can_park_ignoring_keepwarm() && !self.cache_keepwarm.active()
1485    }
1486
1487    /// `can_park` minus the keep-warm deferral.
1488    pub(crate) fn can_park_ignoring_keepwarm(&self) -> bool {
1489        self.can_park_but_for_workers() && !self.workers_hold_park()
1490    }
1491
1492    /// Whether parking would cost a worker (`SubagentRegistry::holds_park`):
1493    /// the park waits and re-checks every [`WORKER_PARK_RECHECK`].
1494    fn workers_hold_park(&self) -> bool {
1495        self.runtime.is_live()
1496            && self
1497                .runtime
1498                .subagent_registry()
1499                .lock()
1500                .unwrap_or_else(|p| p.into_inner())
1501                .holds_park()
1502    }
1503
1504    /// Every park condition except the session's workers.
1505    fn can_park_but_for_workers(&self) -> bool {
1506        self.attached.is_empty()
1507            && !self.streaming
1508            && self.compact.is_none()
1509            && self.pending_prompts.is_empty()
1510            && self.conv.is_live()
1511            && !self.conv.api_messages.is_empty()
1512            && self.conv.queued_message.is_none()
1513            && !self.keep_warm
1514            && self.config.persist
1515            && !self.is_parked()
1516            && self.driver.is_none()
1517    }
1518
1519    /// (E-P7, §S3) Return the first breached spend ceiling, if any, as
1520    /// `(scope, cost_observed, cap)`. Checks the host-owned per-session cap
1521    /// (`config.max_session_cost`) and, when a driver is armed, the effective
1522    /// per-run cap (`min(plugin grant, host)`) measured from `cost_at_arm`.
1523    /// `None` means every configured ceiling still has headroom.
1524    fn cost_cap_breach(&self) -> Option<(&'static str, f64, f64)> {
1525        let total = self.conv.session_cost;
1526        if let Some(cap) = self.config.max_session_cost {
1527            if cap.is_finite() && total >= cap {
1528                return Some(("session", total, cap));
1529            }
1530        }
1531        if let Some(driver) = &self.driver {
1532            // Host cap for the run is not yet a config field (S3 should-have
1533            // #4, daemon-level); pass `None` so the plugin's proposal stands
1534            // alone until the daemon cap lands.
1535            if let Some(cap) = driver.grant.effective_cost_cap(None) {
1536                let run_cost = total - driver.cost_at_arm;
1537                if cap.is_finite() && run_cost >= cap {
1538                    return Some(("run", run_cost, cap));
1539                }
1540            }
1541        }
1542        None
1543    }
1544
1545    /// `<sessions>/<id>.json` — written by both persistence modes.
1546    fn journal_exists(&self) -> bool {        let id = (**self.journal_id.load()).clone();
1547        crate::config::resolve_write_path("sessions")
1548            .join(format!("{id}.json"))
1549            .is_file()
1550    }
1551
1552    // ── E: driver arm/revoke ─────────────────────────────────────────────
1553
1554    /// Invalidate the driver: drop `DriverState`, cancel pending work, emit
1555    /// `DriverRevoked` with any undelivered steering, and release the host grant.
1556    pub(crate) fn driver_revoke(&mut self, reason: &str) {
1557        let undelivered = self
1558            .driver
1559            .as_mut()
1560            .map(|d| d.steering.drain(..).collect::<Vec<_>>())
1561            .unwrap_or_default();
1562        let was_active = self.driver.is_some() || self.driver_pending.is_some();
1563        // Dropping the driver below cancels the driver turn's token.
1564        if self.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
1565            self.note_cancel_cause(crate::engine::interrupt::InterruptReason::Driver);
1566        }
1567        if let Some(driver) = &self.driver {
1568            self.driver_interrupted_owner = Some(driver.grant.plugin_id.clone());
1569            self.host
1570                .release_driver(&driver.grant.plugin_id, &self.id);
1571        }
1572        self.driver_generation = self.driver_generation.wrapping_add(1);
1573        self.driver_pending = None;
1574        self.driver = None;
1575        if was_active {
1576            self.emit(SessionEventWire::DriverRevoked {
1577                reason: reason.to_string(),
1578                undelivered_steering: undelivered,
1579            });
1580        }
1581        // Dropping `DriverState` cancelled the driver turn's token (a child
1582        // of the driver's): that turn is over, but not through `cancel_turn`.
1583        // Unwind prompts now (as `cancel_turn` does), then give its stream
1584        // the drain budget to deliver its cancel-path history and terminal
1585        // event (`finish_revoked_turn`); a stream that never started (its
1586        // start task was just aborted) ends at the next loop turn. A caller
1587        // that cancels the turn itself right after (`finish`, `checkpoint`,
1588        // the cost cap) supersedes this: `cancel_turn` clears the deadline.
1589        if self.turn_cancelled() && self.revoked_turn_deadline.is_none() {
1590            self.resolve_pending_prompts();
1591            let wait = if self.stream.is_some() {
1592                budgets::CANCEL_DRAIN_TIMEOUT
1593            } else {
1594                std::time::Duration::ZERO
1595            };
1596            self.revoked_turn_deadline = Some(tokio::time::Instant::now() + wait);
1597        }
1598        self.rearm_park();
1599    }
1600
1601    /// Spawn the plugin's start command as an async task; the result is
1602    /// processed in `driver_tick()` (P4). Invokes the command via the
1603    /// extension manager, just like the TUI's `start_command`.
1604    pub(crate) fn driver_start(&mut self, plugin: String, command: String, arg: String) {
1605        // Revoke any existing driver first (TUI :369).
1606        if self.driver.is_some() || self.driver_pending.is_some() {
1607            self.driver_revoke("explicit command");
1608        }
1609
1610        let manager = self.host.ext_manager().clone();
1611        let session_id = self.id.0.clone();
1612
1613        // Resolve handler + check permissions synchronously.
1614        let (handler, timeout) = match manager.try_read() {
1615            Ok(mgr) => {
1616                let handler = match mgr.user_action_handler(&plugin) {
1617                    Ok(h) => h,
1618                    Err(error) => {
1619                        self.emit(SessionEventWire::SystemNotice(error));
1620                        return;
1621                    }
1622                };
1623                let timeout = if mgr.session_driver_handler(&plugin).is_ok() {
1624                    5
1625                } else {
1626                    self.emit(SessionEventWire::SystemNotice(
1627                        "session driver extension lacks validated session.drive permission".into(),
1628                    ));
1629                    return;
1630                };
1631                (handler, timeout)
1632            }
1633            Err(_) => {
1634                self.emit(SessionEventWire::SystemNotice(
1635                    "extensions are loading or busy — try again shortly".into(),
1636                ));
1637                return;
1638            }
1639        };
1640
1641        let handler_generation = super::driver::live_generation(&handler).ok();
1642
1643        let owner = plugin.clone();
1644        let cmd = command.clone();
1645        let args: Vec<String> = arg.split_whitespace().map(str::to_owned).collect();
1646
1647        let generation = self.driver_generation;
1648        let task_handler = handler.clone();
1649        self.driver_pending = Some(super::driver::DriverPending {
1650            generation,
1651            session_id: session_id.clone(),
1652            task: super::driver::Task(tokio::spawn(async move {
1653                let (sink, collector) =
1654                    invoke_event_channel(InvokeOutputBudget::default());
1655                let request_id = uuid::Uuid::new_v4().to_string();
1656                let (result, report) =
1657                    tokio::time::timeout(Duration::from_secs(timeout), async {
1658                        tokio::join!(
1659                            task_handler.invoke_command(&cmd, args, &request_id, sink),
1660                            collector.collect()
1661                        )
1662                    })
1663                    .await
1664                    .unwrap_or_else(|_| {
1665                        (
1666                            Err(format!("interactive command timed out ({timeout}s)")),
1667                            crate::extensions::invoke_output::InvokeOutputReport {
1668                                events: Vec::new(),
1669                                counters: Default::default(),
1670                            },
1671                        )
1672                    });
1673                super::driver::TaskResult::Command {
1674                    owner,
1675                    command,
1676                    handler: task_handler,
1677                    handler_generation,
1678                    result,
1679                    report,
1680                }
1681            })),
1682        });
1683    }
1684
1685    /// Arm the driver after a successful start command result. Mirrors
1686    /// the TUI's `arm()` (:538-602). Called from P4's `driver_tick()`.
1687    pub(crate) fn driver_arm(
1688        &mut self,
1689        owner: String,
1690        handler: Arc<dyn crate::extensions::runtime::ExtensionHandler>,
1691        handler_generation: u64,
1692        reply: Reply,
1693    ) -> std::result::Result<(), String> {
1694        let manager = self.host.ext_manager().clone();
1695        super::driver::same_handler(&manager, &owner, &handler, handler_generation)?;
1696
1697        if self.driver.is_some() {
1698            return Err("cannot replace an armed grant".into());
1699        }
1700
1701        let session_id = self.id.0.clone();
1702        let (grant, proposal) = Grant::from_start(&owner, &session_id, reply)?;
1703
1704        // Claim the host-level single-tenancy grant.
1705        self.host
1706            .claim_driver(&owner, &self.id)
1707            .map_err(|e| e.to_string())?;
1708
1709        let cancel = CancellationToken::new();
1710        let workers = self.runtime.subagent_registry().clone();
1711        let worker_epoch = workers
1712            .lock()
1713            .unwrap_or_else(|p| p.into_inner())
1714            .set_spawn_cancellation(Some(cancel.clone()));
1715        let deadline_task = grant.deadline().map(|deadline| {
1716            let c = cancel.clone();
1717            let w = workers.clone();
1718            super::driver::Task(tokio::spawn(async move {
1719                tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await;
1720                c.cancel();
1721                super::driver::cancel_workers(&w, worker_epoch);
1722            }))
1723        });
1724
1725        let models = grant.models().to_vec();
1726        let proposal_notice = proposal.notice.clone();
1727        let selection = proposal.selection.clone();
1728        let deadline_ms = super::driver::deadline_ms(&grant);
1729        let run_id = grant.run_id.clone();
1730        let cost_at_arm = self.conv.session_cost;
1731
1732        let mut state = super::driver::DriverState {
1733            selection: proposal.selection.clone(),
1734            grant,
1735            handler,
1736            handler_generation,
1737            cancel,
1738            workers,
1739            worker_epoch,
1740            deadline_task,
1741            proposal: None,
1742            awaiting_terminal: false,
1743            outcome: None,
1744            feedback: crate::extensions::feedback::Tracker::default(),
1745            completed_feedback: "unknown",
1746            steering: VecDeque::new(),
1747            auto_wakes_blocked: false,
1748            cost_at_arm,
1749        };
1750
1751        super::driver::schedule(&mut state, proposal)?;
1752
1753        // Apply context mode (session-only, like /context auto/off).
1754        let context_notice = state.grant.apply_context_mode(&self.runtime)?;
1755        if let Some(text) = context_notice {
1756            self.emit(SessionEventWire::SystemNotice(text));
1757        }
1758
1759        self.driver_interrupted_owner = None;
1760        self.driver = Some(state);
1761
1762        self.emit(SessionEventWire::DriverArmed {
1763            plugin_id: owner,
1764            run_id,
1765            models,
1766            selection,
1767            deadline_ms,
1768            notice: proposal_notice,
1769        });
1770
1771        Ok(())
1772    }
1773
1774    // ── autonomous work: the reload fence and on_session_start ───────────
1775
1776    /// Whether autonomous work may start now, and if not, why. Two states
1777    /// hold it back: a reload's fence (#255) until the exec or its abort, and
1778    /// this session's `on_session_start` until it has run. `Fenced` wins when
1779    /// both hold. Each source of autonomous work asks [`Self::holds`]; none
1780    /// reads the fence or the hook's state itself.
1781    ///
1782    /// | source          | `Fenced`  | `SessionStartPending`                                         |
1783    /// |-----------------|-----------|---------------------------------------------------------------|
1784    /// | queue wake      | waits     | runs: the turn starts, and its request waits for the hook      |
1785    /// | park timer      | waits     | runs: a park that happens takes a hook not called out of line  |
1786    /// | keep-warm timer | waits     | waits (timer off): a due ping goes out once the hook has run   |
1787    /// | driver tick     | waits     | runs: an armed driver is revoked (`driver_idle_conflict`)      |
1788    /// | driver start    | refused * | rejected in the tick (`driver_idle_conflict`)                  |
1789    ///
1790    /// \* `DriverStart` is a command: under the fence the command fence
1791    /// refuses it first ("daemon reloading"); the tick never sees it.
1792    ///
1793    /// The queue wake must not wait for the hook: a parked session's hook is
1794    /// out of line (`SessionStart::Pending`) and joins it only at the unpark,
1795    /// and the unpark is what the queue wake does. Waiting there would leave
1796    /// an event for a parked session queued for good. The turn's own wait
1797    /// (`open_turn_stream` after `session_start_ready`) holds its request
1798    /// instead, without blocking the loop.
1799    pub(crate) fn autonomous_work_blocked(&self) -> Option<WorkBlock> {
1800        if self.reload_fence.is_some() {
1801            Some(WorkBlock::Fenced)
1802        } else if !self.session_start.is_done() {
1803            Some(WorkBlock::SessionStartPending)
1804        } else {
1805            None
1806        }
1807    }
1808
1809    /// Whether `source` waits now (the table on
1810    /// [`Self::autonomous_work_blocked`]).
1811    pub(crate) fn holds(&self, source: WorkSource) -> bool {
1812        match self.autonomous_work_blocked() {
1813            None => false,
1814            Some(WorkBlock::Fenced) => true,
1815            Some(WorkBlock::SessionStartPending) => matches!(source, WorkSource::KeepWarmTimer),
1816        }
1817    }
1818
1819    // ── E-P4: driver_tick ────────────────────────────────────────────────
1820
1821    /// Actor-equivalent of the TUI's `idle_conflict()` (TUI :437-463).
1822    /// Returns `Some(reason)` when the driver must defer or revoke.
1823    fn driver_idle_conflict(&self) -> Option<&'static str> {
1824        if self.conv.queued_message.is_some() || !self.conv.pending_events.is_empty() {
1825            return Some("other queued work");
1826        }
1827        if self.compact.is_some() {
1828            return Some("compaction");
1829        }
1830        match self.autonomous_work_blocked() {
1831            Some(WorkBlock::Fenced) => return Some("daemon reloading"),
1832            Some(WorkBlock::SessionStartPending) => return Some("extensions loading or reloading"),
1833            None => {}
1834        }
1835        if self.conv.context_head.is_blocked(&self.conv.session) {
1836            return Some("unverified context head");
1837        }
1838        if self.driver_completion_blocked() {
1839            return Some("outstanding workers require collection/reconciliation");
1840        }
1841        None
1842    }
1843
1844    /// Actor-equivalent of the TUI's `completion_blocked()` (TUI :465-480).
1845    fn driver_completion_blocked(&self) -> bool {
1846        use agent_core::orchestration::CompletionGate;
1847        self.runtime.orchestration().is_some_and(|o| {
1848            !matches!(o.completion_gate(), CompletionGate::Allowed)
1849        }) || self
1850            .runtime
1851            .subagent_registry()
1852            .lock()
1853            .map(|r| {
1854                r.list_active().iter().any(|(_, _, status)| {
1855                    *status == crate::runtime::subagent::SubagentStatus::Running
1856                })
1857            })
1858            .unwrap_or(true)
1859    }
1860
1861    /// 200 ms timer arm — the core driver loop. Every invocation performs only
1862    /// bounded state transitions; all plugin and provider IO is in abort-on-drop
1863    /// spawned tasks. Mirrors the TUI's `tick()` (TUI :695-1027).
1864    pub(crate) async fn driver_tick(&mut self) {
1865        use crate::extensions::session_driver::Outcome;
1866        if self.holds(WorkSource::DriverTick) {
1867            return;
1868        }
1869
1870        // ── 1. Cleanup cancelled stream setup ────────────────────────────
1871        // (e.g. the grant's deadline cancelled the driver token before the
1872        // turn's stream started): an interrupted turn, ended like any other.
1873        if self.stream.is_none() && self.turn_cancelled() {
1874            self.finish_revoked_turn(false).await;
1875        }
1876
1877        // ── 2. Validate lifecycle ────────────────────────────────────────
1878        if let Some(driver) = &self.driver {
1879            let invalid = if driver.grant.session_id != self.id.0 {
1880                Some("session replaced".to_string())
1881            } else if driver.grant.expired() {
1882                Some("grant deadline reached".into())
1883            } else if driver.cancel.is_cancelled() {
1884                Some("canceled".into())
1885            } else {
1886                let manager = self.host.ext_manager().clone();
1887                super::driver::same_handler(
1888                    &manager,
1889                    &driver.grant.plugin_id,
1890                    &driver.handler,
1891                    driver.handler_generation,
1892                )
1893                .err()
1894            };
1895            if let Some(reason) = invalid {
1896                self.driver_revoke(&reason);
1897                return;
1898            }
1899        }
1900
1901        // ── 3. Idle conflict (only when driver is armed) ─────────────────
1902        if self.driver.is_some() {
1903            // (E-P7, §S1) No clients → no headless spend. `detach()` revokes on
1904            // the last-client transition; this is the belt-and-suspenders check
1905            // for any path that leaves a grant armed with zero clients.
1906            if self.attached.is_empty() {
1907                self.driver_revoke("no clients attached");
1908                return;
1909            }
1910            // F10/F14: use the full driver_idle_conflict() instead of an
1911            // inline subset that was missing `completion_blocked`.
1912            if let Some(reason) = self.driver_idle_conflict() {
1913                self.driver_revoke(reason);
1914                return;
1915            }
1916
1917            // Let the reactor classify queued events before deciding whether they
1918            // are competing work or steering within this owned turn.
1919            if !self.runtime.event_queue().is_empty() {
1920                return;
1921            }
1922        }
1923
1924        // ── 4. Process finished pending task ─────────────────────────────
1925        if self
1926            .driver_pending
1927            .as_ref()
1928            .is_some_and(|p| p.task.0.is_finished())
1929        {
1930            let mut pending = self.driver_pending.take().expect("checked pending");
1931            if pending.generation != self.driver_generation
1932                || pending.session_id != self.id.0
1933            {
1934                return;
1935            }
1936            let result = match (&mut pending.task.0).await {
1937                Ok(result) => result,
1938                Err(error) => {
1939                    self.driver_revoke("driver task failed");
1940                    self.emit(SessionEventWire::SystemNotice(format!(
1941                        "Session driver task failed: {error}"
1942                    )));
1943                    return;
1944                }
1945            };
1946            match result {
1947                super::driver::TaskResult::Command {
1948                    owner,
1949                    command: _,
1950                    handler,
1951                    handler_generation,
1952                    result,
1953                    report,
1954                } => {
1955                    let current = self
1956                        .host
1957                        .ext_manager()
1958                        .try_read()
1959                        .ok()
1960                        .and_then(|m| m.user_action_handler(&owner).ok());
1961                    if !current
1962                        .as_ref()
1963                        .is_some_and(|current| Arc::ptr_eq(current, &handler))
1964                    {
1965                        self.driver_revoke(
1966                            "command owner unloaded, replaced, or unavailable",
1967                        );
1968                        return;
1969                    }
1970                    let output_failed = report.is_limited()
1971                        || report.events.iter().any(|event| {
1972                            matches!(
1973                                event,
1974                                crate::extensions::runtime::InvokeCommandEvent::Output(
1975                                    crate::extensions::commands::CommandOutputEvent::Error { .. }
1976                                )
1977                            )
1978                        });
1979                    // F7 (shady should-fix): plugin command output is visible
1980                    // to the user, but BATCHED into one notice (cap 20 lines) —
1981                    // a chatty `/auto start` must not flood the client with one
1982                    // wire event per line.
1983                    let mut lines: Vec<String> = Vec::new();
1984                    for event in &report.events {
1985                        if let crate::extensions::runtime::InvokeCommandEvent::Output(out) = event {
1986                            let text = match out {
1987                                crate::extensions::commands::CommandOutputEvent::Text { content }
1988                                | crate::extensions::commands::CommandOutputEvent::System { content } => {
1989                                    Some(content.clone())
1990                                }
1991                                crate::extensions::commands::CommandOutputEvent::Error { content } => {
1992                                    Some(format!("Error: {content}"))
1993                                }
1994                                _ => None,
1995                            };
1996                            if let Some(text) = text {
1997                                lines.push(text);
1998                            }
1999                        }
2000                    }
2001                    if !lines.is_empty() {
2002                        const MAX_LINES: usize = 20;
2003                        let truncated = lines.len() > MAX_LINES;
2004                        if truncated {
2005                            lines.truncate(MAX_LINES);
2006                            lines.push("…output truncated".into());
2007                        }
2008                        self.emit(SessionEventWire::SystemNotice(lines.join("\n")));
2009                    }
2010                    if let Some(notice) = report.limit_notice() {
2011                        self.emit(SessionEventWire::SystemNotice(format!(
2012                            "command output truncated: {}",
2013                            notice.message,
2014                        )));
2015                    }
2016                    if let Ok(value) = result {
2017                        let result = match protocol::parse_reply(&value) {
2018                            Ok(Some(reply @ Reply::Start { .. })) => {
2019                                if output_failed {
2020                                    Err("command reported an error or exceeded its output budget"
2021                                        .into())
2022                                } else if self.streaming || self.stream.is_some() {
2023                                    Err("foreground turn still active".into())
2024                                } else if let Some(reason) = self.driver_idle_conflict() {
2025                                    Err(reason.into())
2026                                } else {
2027                                    match handler_generation {
2028                                        Some(generation) => {
2029                                            self.driver_arm(
2030                                                owner, handler, generation, reply,
2031                                            )
2032                                        }
2033                                        None => Err(
2034                                            "command owner did not expose a live lifecycle at dispatch"
2035                                                .into(),
2036                                        ),
2037                                    }
2038                                }
2039                            }
2040                            Ok(Some(
2041                                Reply::Stop { notice: text }
2042                                | Reply::Status { notice: text },
2043                            )) => {
2044                                self.emit(SessionEventWire::SystemNotice(text));
2045                                Ok(())
2046                            }
2047                            Ok(Some(Reply::Next { .. })) => Err(
2048                                "next requires an armed poll, not an interactive command"
2049                                    .into(),
2050                            ),
2051                            Ok(None) => Ok(()),
2052                            Err(error) => Err(error),
2053                        };
2054                        if let Err(error) = result {
2055                            self.emit(SessionEventWire::SystemNotice(format!(
2056                                "Session driver rejected: {error}"
2057                            )));
2058                        }
2059                    }
2060                }
2061                super::driver::TaskResult::Poll(result) => {
2062                    let Some(driver) = self.driver.as_mut() else {
2063                        return;
2064                    };
2065                    let stop_notice = match &result {
2066                        Ok(Reply::Stop { notice }) => Some(notice.clone()),
2067                        _ => None,
2068                    };
2069                    match result.and_then(|reply| driver.grant.accept(reply)) {
2070                        Ok(Some(proposal)) => {
2071                            let text = proposal.notice.clone();
2072                            if let Err(error) =
2073                                super::driver::schedule(driver, proposal)
2074                            {
2075                                self.driver_revoke(&error);
2076                            } else {
2077                                self.emit(SessionEventWire::SystemNotice(text));
2078                            }
2079                        }
2080                        Ok(None) => {
2081                            self.driver_revoke("owner requested stop");
2082                            if let Some(text) = stop_notice {
2083                                self.emit(SessionEventWire::SystemNotice(text));
2084                            }
2085                        }
2086                        Err(error) => {
2087                            self.driver_revoke(&format!("invalid poll response: {error}"))
2088                        }
2089                    }
2090                }
2091                super::driver::TaskResult::Prepared { proposal, result } => {
2092                    if self.streaming || self.stream.is_some() {
2093                        self.driver_revoke("foreground work took priority");
2094                        return;
2095                    }
2096                    // This includes a pending `on_session_start`: a driver
2097                    // turn opens its stream below, not through `start_turn`,
2098                    // so it never waits for the hook and must not run before it.
2099                    if let Some(reason) = self.driver_idle_conflict() {
2100                        self.driver_revoke(reason);
2101                        return;
2102                    }
2103                    match *result {
2104                        Err(protocol::PrepareError::Selection(error)) => {
2105                            self.emit(SessionEventWire::SystemNotice(format!(
2106                                "Session driver selection rejected (nothing sent): {error}"
2107                            )));
2108                            if let Some(driver) = self.driver.as_mut() {
2109                                driver.outcome = Some((
2110                                    Outcome::SelectionRejected,
2111                                    "unknown".into(),
2112                                ));
2113                            }
2114                        }
2115                        Err(protocol::PrepareError::Blocked(error)) => {
2116                            self.driver_revoke(&format!("preflight blocked: {error}"));
2117                        }
2118                        Ok(candidate) => {
2119                            let validated = proposal.clone();
2120                            let history = super::driver::history_with_steering(
2121                                &self.conv.api_messages,
2122                                &self
2123                                    .driver
2124                                    .as_ref()
2125                                    .map(|d| &d.steering)
2126                                    .cloned()
2127                                    .unwrap_or_default(),
2128                            );
2129                            if let Err(error) = protocol::validate_prepared(
2130                                &candidate, &validated, &history,
2131                            ) {
2132                                self.driver_revoke(&format!(
2133                                    "prepared selection changed before commit: {error}"
2134                                ));
2135                                return;
2136                            }
2137                            // F3: commit steering + prompt via the
2138                            // driver.rs helper (single call site).
2139                            // Destructure to split borrows across conv fields.
2140                            {
2141                                let driver = self.driver.as_mut().expect("driver present");
2142                                let conv = &mut *self.conv;
2143                                super::driver::commit_submission(
2144                                    &mut conv.api_messages,
2145                                    &mut driver.steering,
2146                                    &proposal.prompt,
2147                                );
2148                            }
2149                            // Apply the prepared runtime.
2150                            *self.runtime = candidate;
2151                            self.conv.session.model = self.runtime.model().to_owned();
2152                            self.conv.session.thinking_level =
2153                                self.runtime.thinking_level().to_owned();
2154                            self.consecutive_auto_turns = 0;
2155                            // Admission: the request gets its time now.
2156                            self.stamp_new_messages();
2157                            self.turn_baseline = self.conv.api_messages.len();
2158                            self.turn_steered_events.clear();
2159                            self.turn_replay.clear();
2160                            self.streaming = true;
2161                            self.open_turn_draft();
2162                            self.update_attach_state();
2163                            self.emit(SessionEventWire::TurnStarted {
2164                                turn_baseline: self.turn_baseline,
2165                                trigger: TurnTrigger::DriverAuto,
2166                                user_text: Some(proposal.prompt.clone()),
2167                            });
2168                            self.announce_web_search_state();
2169
2170                            // Set up the driver's awaiting_terminal.
2171                            if let Some(driver) = self.driver.as_mut() {
2172                                driver.awaiting_terminal = true;
2173                                driver.completed_feedback = "unknown";
2174                                if driver.grant.feedback_enabled() {
2175                                    driver.feedback.begin_turn(
2176                                        &driver.selection.model,
2177                                        &driver.selection.effort,
2178                                    );
2179                                }
2180                            }
2181
2182                            // Spawn stream start as a task (never block tick).
2183                            let handler = self
2184                                .driver
2185                                .as_ref()
2186                                .expect("driver")
2187                                .handler
2188                                .clone();
2189                            let handler_generation = self
2190                                .driver
2191                                .as_ref()
2192                                .expect("driver")
2193                                .handler_generation;
2194                            let ct = self
2195                                .driver
2196                                .as_ref()
2197                                .expect("driver")
2198                                .cancel
2199                                .child_token();
2200                            self.cancel = Some(ct.clone());
2201                            // Cancelled by anything that notes no cause (the
2202                            // grant's deadline task): the driver ending.
2203                            let completion = crate::runtime::TurnCompletion::with_default_cause(
2204                                crate::runtime::CancelCause::Driver,
2205                            );
2206                            self.turn_completion = Some(completion.clone());
2207                            let (tx, rx) = mpsc::unbounded_channel();
2208                            self.steer_tx = Some(tx);
2209                            self.owner_steer_budget = None;
2210                            let mut runtime = self.runtime.clone();
2211                            // F2: share the parent's TTL latches so the
2212                            // driver turn doesn't fire a redundant 1h→5m
2213                            // downgrade notice.
2214                            runtime.share_ttl_latches(&self.runtime);
2215                            let history = self.conv.api_messages.clone();
2216                            let secret = self.secret_prompt_handle.clone();
2217                            let session_id = self.id.0.clone();
2218                            let generation = self.driver_generation;
2219                            self.driver_pending = Some(super::driver::DriverPending {
2220                                generation,
2221                                session_id,
2222                                task: super::driver::Task(tokio::spawn(async move {
2223                                    if ct.is_cancelled() {
2224                                        return super::driver::TaskResult::Started(
2225                                            Err("canceled before stream setup".into()),
2226                                        );
2227                                    }
2228                                    if let Err(error) =
2229                                        super::driver::same_lifecycle(
2230                                            &handler,
2231                                            handler_generation,
2232                                        )
2233                                    {
2234                                        return super::driver::TaskResult::Started(
2235                                            Err(error),
2236                                        );
2237                                    }
2238                                    tokio::select! {
2239                                        biased;
2240                                        _ = ct.cancelled() => super::driver::TaskResult::Started(Err("canceled during stream setup".into())),
2241                                        started = runtime.run_stream_tracked(history, ct.clone(), Some(rx), Some(secret), false, completion) => super::driver::TaskResult::Started(Ok(started)),
2242                                    }
2243                                })),
2244                            });
2245
2246                            // Start subagent tick (same as normal start_turn).
2247                            let mut tick = tokio::time::interval(
2248                                std::time::Duration::from_secs(1),
2249                            );
2250                            tick.set_missed_tick_behavior(
2251                                tokio::time::MissedTickBehavior::Delay,
2252                            );
2253                            tick.reset();
2254                            self.subagent_tick = Some(tick);
2255                            self.request_save();
2256                        }
2257                    }
2258                }
2259                super::driver::TaskResult::Started(started) => match started {
2260                    Ok(started) if self.driver.is_some() => {
2261                        self.stream = Some(started);
2262                    }
2263                    Err(error) => {
2264                        self.driver_revoke(&error);
2265                    }
2266                    _ => {}
2267                },
2268            }
2269        }
2270
2271        // ── 5. Idle scheduling ───────────────────────────────────────────
2272        if self.streaming || self.stream.is_some() || self.driver_pending.is_some() {
2273            return;
2274        }
2275        if let Some(reason) = self.driver_idle_conflict() {
2276            self.driver_revoke(reason);
2277            return;
2278        }
2279        let session_cost = self.conv.session_cost;
2280        let Some(driver) = self.driver.as_mut() else {
2281            return;
2282        };
2283        if let Some((outcome, error_kind)) = driver.outcome.take() {
2284            let request = super::driver::poll_request(driver, outcome, error_kind, session_cost);
2285            let handler = driver.handler.clone();
2286            let session_id = self.id.0.clone();
2287            let generation = self.driver_generation;
2288            self.driver_pending = Some(super::driver::DriverPending {
2289                generation,
2290                session_id,
2291                task: super::driver::Task(tokio::spawn(async move {
2292                    // F4: poll must not hang forever — 30 s timeout like
2293                    // the prepare path's 5 s.
2294                    let result = tokio::time::timeout(
2295                        Duration::from_secs(30),
2296                        protocol::poll(handler, request),
2297                    )
2298                    .await
2299                    .unwrap_or_else(|_| Err("poll timed out (30s)".into()));
2300                    super::driver::TaskResult::Poll(result)
2301                })),
2302            });
2303        } else if driver
2304            .proposal
2305            .as_ref()
2306            .is_some_and(|p| Instant::now() >= p.due)
2307        {
2308            let proposal =
2309                driver.proposal.take().expect("checked proposal").proposal;
2310            // Clone is read-only (prepare never sends a request) and
2311            // discarded — no TTL latch sharing needed.
2312            let runtime = self.runtime.clone();
2313            let history = super::driver::history_with_steering(
2314                &self.conv.api_messages,
2315                &driver.steering,
2316            );
2317            let session_id = self.id.0.clone();
2318            let generation = self.driver_generation;
2319            self.driver_pending = Some(super::driver::DriverPending {
2320                generation,
2321                session_id,
2322                task: super::driver::Task(tokio::spawn(async move {
2323                    let result = tokio::time::timeout(
2324                        Duration::from_secs(5),
2325                        protocol::prepare(&runtime, &proposal, &history),
2326                    )
2327                    .await
2328                    .unwrap_or_else(|_| {
2329                        Err(protocol::PrepareError::Blocked(
2330                            "preparation timed out (5s)".into(),
2331                        ))
2332                    });
2333                    super::driver::TaskResult::Prepared {
2334                        proposal,
2335                        result: Box::new(result),
2336                    }
2337                })),
2338            });
2339        }
2340    }
2341
2342    /// F10: the session lock follows the conversation to `new_id` (a fresh
2343    /// id from NewSession or compaction). Best-effort: on failure the old
2344    /// lock is still released (the conversation left it) and the failure
2345    /// logged. The new lock is taken BEFORE the old one is dropped.
2346    pub(crate) fn reacquire_session_lock(&mut self, new_id: &str) {
2347        match Self::try_lock_session(new_id) {
2348            Ok(lock) => self.session_lock = Some(lock),
2349            Err(e) => {
2350                tracing::warn!(session = %new_id, "reacquire session lock: {e}");
2351                self.session_lock = None;
2352            }
2353        }
2354    }
2355
2356    /// Try to take the journal lock on `id` for this daemon process.
2357    pub(crate) fn try_lock_session(
2358        id: &str,
2359    ) -> std::result::Result<agent_core::session_lock::SessionLock, agent_core::session_lock::SessionLockError>
2360    {
2361        let dir = agent_core::session_lock::sessions_dir();
2362        let holder = agent_core::session_lock::LockHolder {
2363            pid: std::process::id(),
2364            kind: "daemon".to_string(),
2365        };
2366        agent_core::session_lock::SessionLock::try_acquire(&dir, id, holder)
2367    }
2368
2369    /// F18: a session with NO history can never park (nothing to journal),
2370    /// so with no clients it would stay Live — Runtime resident — forever.
2371    /// Same gates as `can_park` minus the history requirement: at the park
2372    /// deadline such a session ends instead.
2373    pub(crate) fn can_end_idle(&self) -> bool {
2374        self.attached.is_empty()
2375            && !self.streaming
2376            && self.compact.is_none()
2377            && self.pending_prompts.is_empty()
2378            && self.conv.is_live()
2379            && self.conv.api_messages.is_empty()
2380            && self.conv.queued_message.is_none()
2381            && !self.keep_warm
2382            && !self.is_parked()
2383            // An armed driver keeps the session alive between turns exactly
2384            // like an attached client would (shady P3/P4 F6: without this, a
2385            // zero-turn armed session is idle-ended 5 s after the last client
2386            // detaches, killing the run mid-arm). `can_park` already guards.
2387            && self.driver.is_none()
2388            && self.driver_pending.is_none()
2389    }
2390
2391    pub(crate) fn rearm_park(&mut self) {
2392        self.rearm_park_deadline();
2393        self.publish_presence();
2394    }
2395
2396    fn rearm_park_deadline(&mut self) {
2397        if self.is_parked() {
2398            // Eviction deadline is owned by `park()`; the attach path unparks
2399            // (which rebuilds the runtime and clears the deadline).
2400            return;
2401        }
2402        if self.can_end_idle() {
2403            // F18: nothing to keep warm — a zero-turn session with no
2404            // clients ends after a short grace, not the full park grace
2405            // (which exists to keep a runtime WITH history warm for a
2406            // quick reconnect).
2407            if self.park_deadline.is_none() {
2408                self.park_deadline =
2409                    Some(tokio::time::Instant::now() + idle_end_grace());
2410            }
2411            return;
2412        }
2413        if !self.can_park() {
2414            // Only workers in the way: look again later (a worker finishing
2415            // or being collected does not always pass through here).
2416            let workers_only = self.can_park_but_for_workers()
2417                && !self.cache_keepwarm.active()
2418                && park_grace().is_some();
2419            self.park_deadline = if workers_only {
2420                self.park_deadline.or_else(|| {
2421                    let at = tokio::time::Instant::now() + worker_park_recheck();
2422                    self.park_recheck_at = Some(at);
2423                    Some(at)
2424                })
2425            } else {
2426                None
2427            };
2428            return;
2429        }
2430        match park_grace() {
2431            Some(grace) => {
2432                // The earlier of a pending deadline and a fresh grace: a
2433                // grace armed at detach keeps counting, while a far-off
2434                // worker re-check gives way once the workers let go.
2435                let due = tokio::time::Instant::now() + grace;
2436                // A re-check due before the grace stays a re-check: when it
2437                // fires it arms the grace rather than parking early.
2438                self.park_deadline = Some(self.park_deadline.map_or(due, |d| d.min(due)));
2439            }
2440            None => self.park_deadline = None,
2441        }
2442    }
2443
2444    /// (P11) Bound the resource pin from an ABANDONED prompt. A pending host
2445    /// confirmation on a plain interactive session survives detach so the user
2446    /// reattaches and answers it — but if NObody ever comes back it pins the
2447    /// session forever (`can_park()` requires `pending_prompts` empty). Arm a
2448    /// deadline only in that abandoned state (zero clients, prompt pending, no
2449    /// driver); a re-attach clears it (prompt survives, user answers); expiry
2450    /// denies the prompt(s). The driver-armed case fail-closes immediately in
2451    /// `detach` and never reaches here. Idempotent: safe to call on every
2452    /// attach/detach/prompt transition.
2453    fn rearm_prompt_abandon(&mut self) {
2454        let armed_driver = self.driver.is_some() || self.driver_pending.is_some();
2455        let abandoned = self.attached.is_empty()
2456            && !self.pending_prompts.is_empty()
2457            && !armed_driver
2458            && !self.is_parked();
2459        if !abandoned {
2460            // Someone is attached, the prompt was answered, or a driver took
2461            // over the fail-closed path — never auto-deny while any of these.
2462            self.prompt_abandon_deadline = None;
2463            return;
2464        }
2465        if self.prompt_abandon_deadline.is_none() {
2466            // `None` config ⇒ deadline stays `None` ⇒ disabled (pre-#112).
2467            self.prompt_abandon_deadline =
2468                prompt_abandon_timeout().map(|d| tokio::time::Instant::now() + d);
2469        }
2470    }
2471
2472    /// (P11) The prompt-abandonment deadline expired with still zero clients:
2473    /// deny every pending prompt (send `None` + emit `PromptResolved`) so the
2474    /// session stops zombie-blocking and `can_park()` becomes true. A re-attach
2475    /// would have cleared the deadline first; re-check defensively.
2476    fn on_prompt_abandon_deadline(&mut self) {
2477        self.prompt_abandon_deadline = None;
2478        if !self.attached.is_empty() {
2479            return;
2480        }
2481        let had_prompts = !self.pending_prompts.is_empty();
2482        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
2483            let _ = tx.send(None);
2484            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
2485        }
2486        if had_prompts {
2487            tracing::info!(session = %self.id, "pending prompt abandoned (no clients before deadline) — denied");
2488            self.publish_presence();
2489            // `can_park()` may now be true — re-evaluate the park deadline.
2490            self.rearm_park();
2491        }
2492    }
2493
2494    fn set_lifecycle(&mut self, l: SessionLifecycle) {
2495        self.lifecycle
2496            .store(l as u8, std::sync::atomic::Ordering::Release);
2497        self.emit(SessionEventWire::Lifecycle(l));
2498    }
2499
2500    /// Save, close PTYs, drop `conv` THEN `runtime`. `background` (inbox
2501    /// watcher + per-session UDS + registry) stays: `synaps send` keeps
2502    /// resolving and its push into `event_queue` is the wake-up.
2503    pub(crate) async fn park(&mut self) -> std::ops::ControlFlow<EndReason> {
2504        let flow = self.park_now().await;
2505        self.publish_presence();
2506        // Restored to be parked but still live (it could not park): its
2507        // `on_session_start` runs now, as for any live session.
2508        if flow.is_continue()
2509            && !self.is_parked()
2510            && matches!(self.session_start, super::session_start::SessionStart::Pending)
2511        {
2512            self.queue_session_start();
2513        }
2514        flow
2515    }
2516
2517    async fn park_now(&mut self) -> std::ops::ControlFlow<EndReason> {
2518        // Fired by a worker re-check (not the grace, not an explicit Park)?
2519        let recheck = self.park_deadline.is_some() && self.park_deadline == self.park_recheck_at;
2520        self.park_recheck_at = None;
2521        self.park_deadline = None;
2522        if self.is_parked() {
2523            if self.attached.is_empty() {
2524                tracing::info!(session = %self.id, "parked past the eviction age — leaving the map (journal kept)");
2525                return std::ops::ControlFlow::Break(EndReason::Evicted);
2526            }
2527            return std::ops::ControlFlow::Continue(());
2528        }
2529        if self.can_end_idle() {
2530            tracing::info!(session = %self.id, "idle with no history — ending instead of parking (F18)");
2531            // No journal will ever exist for this id: take the lock file
2532            // with us instead of leaving an orphan `.lock` (RC soak F-NEW-1).
2533            if let Some(lock) = self.session_lock.take() {
2534                lock.release_and_remove();
2535            }
2536            return std::ops::ControlFlow::Break(EndReason::Idle);
2537        }
2538        if !self.can_park() {
2539            // Re-arms a re-check while only workers hold the park.
2540            self.rearm_park();
2541            return std::ops::ControlFlow::Continue(());
2542        }
2543        if recheck {
2544            // The workers let go: now the reconnect grace runs, from here.
2545            // A re-check is not the user's grace having elapsed.
2546            if let Some(grace) = park_grace() {
2547                self.park_deadline = Some(tokio::time::Instant::now() + grace);
2548                return std::ops::ControlFlow::Continue(());
2549            }
2550        }
2551        self.state = AttachState::Parking;
2552        self.set_lifecycle(SessionLifecycle::Parking);
2553        // Parking drops the conversation from memory: everything queued must
2554        // be on disk first, and a failed save keeps the session live.
2555        if !self.save().await {
2556            tracing::warn!(session = %self.id, "park: save failed or timed out — staying live");
2557            self.state = AttachState::Detached { running: false };
2558            self.set_lifecycle(SessionLifecycle::Live);
2559            return std::ops::ControlFlow::Continue(());
2560        }
2561        if !self.journal_exists() {
2562            // Never park what cannot be restored (H2).
2563            tracing::warn!(session = %self.id, "park: no journal on disk — staying live");
2564            self.state = AttachState::Detached { running: false };
2565            self.set_lifecycle(SessionLifecycle::Live);
2566            return std::ops::ControlFlow::Continue(());
2567        }
2568        if self.runtime.session_manager().active_count() > 0 {
2569            self.emit(SessionEventWire::SystemNotice(
2570                "parked: background shells closed".into(),
2571            ));
2572        }
2573        self.runtime.session_manager().shutdown_all();
2574        self.turn_replay.clear();
2575        // The unparked runtime is built from `config.memory_mode`: a
2576        // revocation made without `/memory` must be recorded first.
2577        self.sync_memory_choice();
2578        // The runtime's exact tool activations outlive it: the unparked
2579        // runtime restores them, so the tools array (the start of the cached
2580        // prompt prefix) and the model's tools are what they were.
2581        self.parked_tool_activations = self.runtime.carried_tool_activations();
2582        // Drop order: conv first, then runtime — a panic between them can
2583        // never leave a Runtime without state to save.
2584        // Last look at the conversation for listings (title/cost/messages).
2585        self.publish_presence();
2586        let conv = self.conv.park_take();
2587        drop(conv);
2588        let runtime = self.runtime.park_take();
2589        drop(runtime);
2590        // Hand the freed pages back: jemalloc otherwise keeps them as
2591        // dirty/muzzy for its decay window, and a parked session that
2592        // still shows in RssAnon buys nothing.
2593        crate::core::memstat::purge_arenas();
2594        // F10: release the journal lock so an in-process --continue can
2595        // pick up the parked session (the daemon will fail to re-acquire
2596        // on unpark and surface a clear error instead of a zombie).
2597        self.session_lock = None;
2598        // A hook not called yet waits for the unpark, out of line.
2599        self.session_start.withdraw_for_park();
2600        self.state = AttachState::Parked;
2601        self.set_lifecycle(SessionLifecycle::Parked);
2602        tracing::info!(session = %self.id, "session parked");
2603        // The park timer is reused as the eviction timer: a session nobody
2604        // re-attaches to within `parked_evict_after` leaves the map (its
2605        // journal stays; `--continue` brings it back).
2606        self.park_deadline = parked_evict_after().map(|d| tokio::time::Instant::now() + d);
2607        std::ops::ControlFlow::Continue(())
2608    }
2609
2610    /// Rebuild `runtime` + `conv` from the journal (`load_session_in_dir`
2611    /// via `resolve_session_and_prompt`), replay non-persisted settings,
2612    /// `finish_session_setup(IndexRecord::Skip)`. On error the session
2613    /// stays Parked (nothing is half-built).
2614    pub(crate) async fn unpark(&mut self) -> Result<()> {
2615        let started = std::time::Instant::now();
2616        // Somebody came back: the eviction deadline armed by `park()` is void.
2617        self.park_deadline = None;
2618        let journal_id = (**self.journal_id.load()).clone();
2619
2620        // F10: re-acquire the journal lock before rebuilding the runtime.
2621        // If an in-process --continue took over while we were parked, fail
2622        // loudly so the attach gets a clear error instead of a zombie.
2623        {
2624            let dir = agent_core::session_lock::sessions_dir();
2625            let holder = agent_core::session_lock::LockHolder {
2626                pid: std::process::id(),
2627                kind: "daemon".to_string(),
2628            };
2629            match agent_core::session_lock::SessionLock::try_acquire(&dir, &journal_id, holder) {
2630                Ok(lock) => self.session_lock = Some(lock),
2631                Err(agent_core::session_lock::SessionLockError::Held { .. }) => {
2632                    return Err(crate::RuntimeError::Session(format!(
2633                        "cannot unpark session {}: journal locked by another process",
2634                        journal_id
2635                    )));
2636                }
2637                Err(e) => {
2638                    tracing::warn!(session = %journal_id, "unpark: session lock: {e}");
2639                    // Non-fatal: proceed without the lock (e.g. read-only fs).
2640                }
2641            }
2642        }
2643
2644        let host = Arc::clone(&self.host);
2645        let cfg = self.config.clone();
2646        let queue = Arc::clone(&self.event_queue);
2647        let replay = self.settings_replay.clone();
2648        let journal_present = self.journal_exists();
2649        let view = self.view.load_full();
2650        let build = async move {
2651            let config: crate::SynapsConfig = (**host.config()).clone();
2652            // §4: cwd before apply_config — see `create`. The session's own
2653            // `/memory` choice, if it made one, instead of the default.
2654            let mut runtime = host
2655                .foreground_runtime_with(cfg.cwd.clone(), cfg.memory_mode.is_none())
2656                .await?;
2657            if let Some(mode) = cfg.memory_mode {
2658                runtime.restore_session_memory_mode(mode);
2659            }
2660            runtime.set_event_queue(queue);
2661            let mut sb = if journal_present {
2662                crate::engine::setup::resolve_session_and_prompt(
2663                    &mut runtime,
2664                    &Some(Some(journal_id)),
2665                    cfg.system.as_deref(),
2666                    cfg.prompt_manifest.as_deref(),
2667                )?
2668            } else {
2669                // The journal vanished under us (H2): rebuild an empty
2670                // conversation under the SAME id with the last-published
2671                // model/thinking rather than leaving a zombie Parked session.
2672                tracing::warn!(
2673                    session = %journal_id,
2674                    "unpark: journal missing — restoring as a fresh conversation"
2675                );
2676                let mut sb = crate::engine::setup::resolve_session_and_prompt(
2677                    &mut runtime,
2678                    &None,
2679                    cfg.system.as_deref(),
2680                    cfg.prompt_manifest.as_deref(),
2681                )?;
2682                sb.session.id = journal_id.clone();
2683                runtime.set_session_id(Some(journal_id));
2684                sb
2685            };
2686            runtime.set_cwd(cfg.cwd.clone());
2687            runtime.set_env(cfg.env.clone());
2688            runtime.set_env_stripped(cfg.env_stripped.clone());
2689            // The CURRENT model/thinking (the last published view), not
2690            // `cfg.model_override` frozen at create: `/model` survives park.
2691            runtime.set_model(view.model.clone());
2692            let _ = runtime.restore_session_reasoning(&view.thinking_level);
2693            if let Some(clock) = &cfg.message_clock {
2694                runtime.set_message_clock(clock.clone());
2695            }
2696            sb.session.model = view.model.clone();
2697            sb.session.thinking_level = runtime.thinking_level().to_string();
2698            crate::engine::setup::finish_session_setup(
2699                &mut runtime,
2700                &config,
2701                &sb.session,
2702                cfg.cwd.clone(),
2703                crate::engine::setup::IndexRecord::Skip,
2704            );
2705            for s in &replay {
2706                replay_setting(&mut runtime, s);
2707            }
2708            Ok::<_, crate::RuntimeError>((runtime, sb))
2709        };
2710        let (runtime, sb) = match tokio::time::timeout(budgets::UNPARK_TIMEOUT, build).await {
2711            Ok(Ok(v)) => v,
2712            Ok(Err(e)) => return Err(e),
2713            Err(_) => {
2714                return Err(crate::RuntimeError::Session(format!(
2715                    "unpark timed out after {}s",
2716                    budgets::UNPARK_TIMEOUT_SECS
2717                )))
2718            }
2719        };
2720        let mut conv = ConversationState::from_resumed(sb.session);
2721        conv.api_messages = sb.api_messages;
2722        conv.total_input_tokens = sb.total_input_tokens;
2723        conv.total_output_tokens = sb.total_output_tokens;
2724        conv.session_cost = sb.session_cost;
2725        if journal_present && self.config.persist && self.session_lock.is_some() {
2726            crate::engine::setup::recover_turn_draft(&mut conv).await;
2727        }
2728        self.message_stamper.settle(conv.api_messages.len());
2729        runtime.restore_tool_activations(std::mem::take(&mut self.parked_tool_activations));
2730        self.runtime.unpark_set(runtime);
2731        self.conv.unpark_set(conv);
2732        // The name comes from disk: a rename while parked wrote it there.
2733        if self.meta.name != self.conv.session.name {
2734            let name = self.conv.session.name.clone();
2735            self.mirror_name(name);
2736        }
2737        // A fresh runtime: whether its grant took effect is its own.
2738        self.memory_seen_active = false;
2739        self.observe_memory();
2740        self.publish_view().await;
2741        self.state = AttachState::Detached { running: false };
2742        self.set_lifecycle(SessionLifecycle::Live);
2743        self.update_attach_state();
2744        // A hook that waited for the unpark runs now (once per session).
2745        if matches!(self.session_start, super::session_start::SessionStart::Pending) {
2746            self.queue_session_start();
2747        }
2748        tracing::info!(session = %self.id, ms = started.elapsed().as_millis() as u64, "session unparked");
2749        Ok(())
2750    }
2751
2752    /// Unpark if Parked. `Err` = still Parked; the caller reports it
2753    /// (`Refused` to a client, `AttachRefused` to an attacher, a warning
2754    /// for host wakes).
2755    pub(crate) async fn ensure_live(&mut self) -> std::result::Result<(), String> {
2756        if !self.is_parked() {
2757            return Ok(());
2758        }
2759        let res = self
2760            .unpark()
2761            .await
2762            .map_err(|e| format!("session parked and could not be restored: {e}"));
2763        self.publish_presence();
2764        res
2765    }
2766
2767    /// `stream_handler.rs:264`: a turn OR a compaction blocks auto-turns.
2768    pub(crate) fn busy(&self) -> bool {
2769        self.streaming || self.compact.is_some()
2770    }
2771
2772    /// `runtime.subagent_registry().display_rows()` → `SubagentRows`, only
2773    /// when there is something to show (the TUI's reconcile is a no-op on
2774    /// an empty registry).
2775    ///
2776    /// The 1 Hz tick that drives this starts with a turn and outlives it
2777    /// while a background worker (`subagent_start`) is still running: those
2778    /// workers' progress events rode the ended turn's stream, so these rows
2779    /// (which carry each worker's step and tool count) are how clients keep
2780    /// up. The tick stops once no worker is running after the turn, having
2781    /// published the terminal rows.
2782    pub(crate) fn publish_subagent_rows(&mut self) {
2783        let rows = self
2784            .runtime
2785            .subagent_registry()
2786            .lock()
2787            .unwrap_or_else(|p| p.into_inner())
2788            .display_rows();
2789        let running = rows
2790            .iter()
2791            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running));
2792        if !rows.is_empty() {
2793            self.emit(SessionEventWire::SubagentRows(rows));
2794        }
2795        if !keep_subagent_tick(self.streaming, running) {
2796            self.subagent_tick = None;
2797            // The last worker finished: a park it held may be due now.
2798            self.rearm_park();
2799        }
2800    }
2801
2802    /// Whether any worker in the registry is still running.
2803    fn subagents_running(&self) -> bool {
2804        self.runtime
2805            .subagent_registry()
2806            .lock()
2807            .unwrap_or_else(|p| p.into_inner())
2808            .display_rows()
2809            .iter()
2810            .any(|r| matches!(r.status, crate::runtime::subagent::SubagentStatus::Running))
2811    }
2812
2813    /// Codex hosted web_search is read from this (engine/daemon) process's
2814    /// env, so say so in the session — once per session per model/state,
2815    /// inside the turn so its replay ring carries it to clients attaching
2816    /// mid-turn.
2817    fn announce_web_search_state(&mut self) {
2818        let notice = crate::runtime::openai::codex_wire::session_web_search_notice(
2819            self.runtime.model(),
2820            crate::runtime::openai::codex_wire::engine_env,
2821        );
2822        if notice.is_some() && notice != self.web_search_notice {
2823            self.web_search_notice = notice.clone();
2824            if let Some(text) = notice {
2825                self.emit(SessionEventWire::Stream(StreamEvent::Session(
2826                    SessionEvent::Notice(text),
2827                )));
2828            }
2829        }
2830    }
2831
2832    // ── turn start (dispatch.rs Submit tail / stream_handler.rs RunTurn) ──
2833
2834    /// This session's `on_session_start` task ended: the hook ran (or the
2835    /// task failed, which counts as done: fail-open like a hook timeout).
2836    /// Refresh the view, which shows the injection, then send what waited
2837    /// for it: a started turn's request. A due keep-warm ping waited with its
2838    /// timer off (`holds`); the loop's next pass arms it again.
2839    async fn session_start_finished(
2840        &mut self,
2841        ran: std::result::Result<bool, tokio::task::JoinError>,
2842    ) {
2843        use super::session_start::SessionStart;
2844        match ran {
2845            Ok(true) => {}
2846            // Withdrawn before it was called (a park): not run yet. Anything
2847            // waiting for it puts it back in line.
2848            Ok(false) => {
2849                self.session_start = SessionStart::Pending;
2850                if self.turn_awaiting_start.is_some() || self.cache_keepwarm.deadline().is_some() {
2851                    self.session_start_ready();
2852                }
2853                return;
2854            }
2855            Err(e) => tracing::warn!(session = %self.id, "on_session_start task failed: {e}"),
2856        }
2857        self.session_start = SessionStart::Done;
2858        if self.runtime.is_live() {
2859            self.publish_view().await;
2860        }
2861        if let Some(start) = self.turn_awaiting_start.take() {
2862            // A turn canceled from outside (a driver revocation) ends on its
2863            // own path (`finish_revoked_turn`) with nothing sent.
2864            if !start.cancel.is_cancelled() {
2865                self.open_turn_stream(start).await;
2866            }
2867        }
2868    }
2869
2870    /// No turn and no keep-warm ping goes out before `on_session_start` has
2871    /// run. Its injection is part of their system prompt, so a request sent
2872    /// before it would lack the extension context and the next request would
2873    /// change the cached system prompt. (A manual `/compact` does not wait:
2874    /// it builds its own prompt; a rolling summary reuses the request of the
2875    /// turn before it or builds its own prompt.) A session created with
2876    /// `await_extensions=false` (quick start, every session a reload
2877    /// restores) has a task in line at the host's gate for it, or, while
2878    /// parked, nothing in line until the unpark.
2879    ///
2880    /// `true` once the hook has run. Otherwise it puts the hook in line if
2881    /// it is not (after a park) and moves it ahead of background hooks; the
2882    /// caller waits for `session_start_finished` without blocking the loop.
2883    /// The hook itself always runs in that task, through the gate, so it is
2884    /// never called twice or beside another session's.
2885    pub(crate) fn session_start_ready(&mut self) -> bool {
2886        use super::session_start::SessionStart;
2887        if matches!(self.session_start, SessionStart::Pending) {
2888            self.queue_session_start();
2889        }
2890        match &self.session_start {
2891            SessionStart::Done => true,
2892            SessionStart::Queued { ticket, .. } => {
2893                ticket.prefer();
2894                false
2895            }
2896            SessionStart::Pending => false,
2897        }
2898    }
2899
2900    /// Put this session's `on_session_start` in line (in the background).
2901    fn queue_session_start(&mut self) {
2902        self.session_start = super::session_start::SessionStart::queue(
2903            &self.host,
2904            Arc::clone(&self.hook_bus),
2905            Arc::clone(&self.journal_id),
2906            extensions_ready_timeout(&self.host.config()),
2907        );
2908    }
2909
2910    /// Open the turn's stream: the request goes out.
2911    async fn open_turn_stream(&mut self, start: TurnStart) {
2912        // A turn started by events (nobody typed it) runs under the event
2913        // turn budget for this turn only; the stream copies the budget when
2914        // it starts, so the session budget is restored right after. Here and
2915        // not in `start_turn`: a turn whose request waited for
2916        // `on_session_start` opens its stream later, under its own budget.
2917        let session_budget = start.event_turn.then(|| {
2918            let event_budget = self
2919                .runtime
2920                .turn_budget()
2921                .for_event_turn(&self.host.config().turn_budgets);
2922            let session_budget = self.runtime.turn_budget().clone();
2923            self.runtime.set_turn_budget(event_budget);
2924            self.runtime
2925                .set_owner_steer_budget(self.owner_steer_budget.clone());
2926            session_budget
2927        });
2928        let stream = self
2929            .runtime
2930            .run_stream_tracked(
2931                self.conv.api_messages.clone(),
2932                start.cancel,
2933                Some(start.steer_rx),
2934                Some(self.secret_prompt_handle.clone()),
2935                start.auto_approve,
2936                start.completion,
2937            )
2938            .await;
2939        if let Some(session_budget) = session_budget {
2940            self.runtime.set_turn_budget(session_budget);
2941            self.runtime.set_owner_steer_budget(None);
2942        }
2943        self.stream = Some(stream);
2944    }
2945
2946    /// Start a turn. Its request goes out at once, or, while this session's
2947    /// `on_session_start` has not run, once it has (`session_start_ready`);
2948    /// the turn is live from here either way.
2949    pub(crate) async fn start_turn(&mut self, trigger: TurnTrigger, user_text: Option<String>) {
2950        self.cache_keepwarm_turn_started();
2951        let ct = CancellationToken::new();
2952        let (s_tx, s_rx) = mpsc::unbounded_channel::<String>();
2953        self.streaming = true;
2954        // Admission: the request (and anything queued with it) gets its
2955        // time now, before the stream copies the history.
2956        self.stamp_new_messages();
2957        self.turn_baseline = self.conv.api_messages.len();
2958        self.open_turn_draft();
2959        self.turn_steered_events.clear();
2960        self.turn_replay.clear();
2961        self.update_attach_state();
2962        self.emit(SessionEventWire::TurnStarted {
2963            turn_baseline: self.turn_baseline,
2964            trigger,
2965            user_text,
2966        });
2967        self.announce_web_search_state();
2968        // Blocks command processing during setup exactly like the TUI loop.
2969        // (E-P7, §S9) An armed driver MUST NOT auto-approve tool activation —
2970        // the session-driver contract keeps ordinary tool-approval gates in
2971        // force. Override the session config to false whenever a grant is
2972        // armed, regardless of what the client requested. (Driver-initiated
2973        // turns already pass `false` from the tick's Prepared arm; this guards
2974        // any foreground turn that starts while armed.)
2975        let auto_approve = self.config.auto_approve_confirms && self.driver.is_none();
2976        // Stamp this turn's worker launches before the stream can start any.
2977        // A grant made since the last turn (e.g. while the session was
2978        // being rebuilt) is seen before this turn can revoke it.
2979        self.observe_memory();
2980        self.turn_worker_generation = self
2981            .runtime
2982            .subagent_registry()
2983            .lock()
2984            .unwrap_or_else(|p| p.into_inner())
2985            .begin_turn();
2986        let completion = crate::runtime::TurnCompletion::new();
2987        self.turn_completion = Some(completion.clone());
2988        self.cancel = Some(ct.clone());
2989        self.steer_tx = Some(s_tx);
2990        let event_turn = trigger == TurnTrigger::EventAuto;
2991        self.owner_steer_budget = event_turn.then(Default::default);
2992        let mut tick = tokio::time::interval(std::time::Duration::from_secs(1));
2993        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
2994        tick.reset();
2995        self.subagent_tick = Some(tick);
2996        let start = TurnStart {
2997            cancel: ct,
2998            steer_rx: s_rx,
2999            auto_approve,
3000            completion,
3001            event_turn,
3002        };
3003        if self.session_start_ready() {
3004            self.open_turn_stream(start).await;
3005        } else {
3006            tracing::debug!(session = %self.id, "turn waits for on_session_start");
3007            self.turn_awaiting_start = Some(start);
3008            // Quick Start's first turn can wait here for extension discovery
3009            // (`startup.extensions_ready_timeout_secs`) and then the hook:
3010            // say so, or the silence looks like a hang.
3011            self.emit(SessionEventWire::SystemNotice(
3012                "waiting for extensions to start (on_session_start) before sending...".into(),
3013            ));
3014        }
3015    }
3016
3017    /// Every turn-end path (Done/Error/Cancel/stream EOF). `streaming=false`
3018    /// makes a `Cancel` racing a `Done` the idle no-op. Events steered into
3019    /// the stream that the engine never took (`turn_steered_events`) go back
3020    /// to `pending_events`, first: they are flushed into history like any
3021    /// event that arrived during the turn (the interrupted path takes them
3022    /// earlier, to place them after the marker). Queues the save of the
3023    /// turn's final history, then the draft removal (`close_turn_draft`).
3024    pub(crate) fn clear_stream(&mut self) {
3025        self.stream = None;
3026        self.turn_awaiting_start = None;
3027        self.cancel = None;
3028        self.steer_tx = None;
3029        self.owner_steer_budget = None;
3030        self.streaming = false;
3031        self.turn_completion = None;
3032        self.revoked_turn_deadline = None;
3033        // Background workers outlive the turn: keep publishing their rows
3034        // until the last one finishes (see `publish_subagent_rows`).
3035        if !keep_subagent_tick(false, self.subagents_running()) {
3036            self.subagent_tick = None;
3037        }
3038        if !self.turn_steered_events.is_empty() && self.conv.is_live() {
3039            let undelivered = std::mem::take(&mut self.turn_steered_events);
3040            self.conv.pending_events.splice(0..0, undelivered);
3041        }
3042        // The history that ends the turn — whether or not a draft is open
3043        // (no draft without the session lock) — then the draft removal.
3044        self.request_save();
3045        self.close_turn_draft();
3046        self.update_attach_state();
3047    }
3048
3049    // ── in-flight turn draft (crash recovery) ──────────────────────────
3050
3051    /// Turn start: the draft's existence is the "turn open" signal a
3052    /// loader uses to detect a process that died mid-turn. Only the session
3053    /// lock holder writes one: without the lock another process may own
3054    /// this session, and a draft is a claim that a turn is running HERE.
3055    fn open_turn_draft(&mut self) {
3056        let persist =
3057            self.config.persist && self.conv.is_live() && self.session_lock.is_some();
3058        self.turn_draft = TurnDraftState {
3059            open: persist.then(|| self.conv.session.id.clone()),
3060            base_len: self.conv.api_messages.len(),
3061            text: String::new(),
3062            tools: Vec::new(),
3063            dirty: true,
3064        };
3065        self.flush_turn_draft();
3066    }
3067
3068    fn turn_draft_text(&mut self, delta: &str) {
3069        let d = &mut self.turn_draft;
3070        if d.text.len() < agent_core::core::session_draft::TURN_DRAFT_MAX_TEXT_BYTES {
3071            d.text.push_str(delta);
3072            d.dirty = true;
3073        }
3074    }
3075
3076    fn turn_draft_reset_text(&mut self) {
3077        let d = &mut self.turn_draft;
3078        d.tools.clear();
3079        if !d.text.is_empty() {
3080            d.text.clear();
3081            d.dirty = true;
3082        }
3083    }
3084
3085    /// A round checkpoint was adopted and saved: later text continues from it.
3086    fn turn_draft_committed(&mut self) {
3087        self.turn_draft.base_len = self.conv.api_messages.len();
3088        self.turn_draft.text.clear();
3089        self.turn_draft.tools.clear();
3090        self.turn_draft.dirty = true;
3091        self.flush_turn_draft();
3092    }
3093
3094    /// A context rollover replaced the history head, and `base_len` no
3095    /// longer refers to it. Until the engine's next round checkpoint
3096    /// re-bases the draft, nothing streamed can be placed: an impossible
3097    /// length makes the cancel fallback (`keep_streamed_partial`) and crash
3098    /// recovery (`recover_crashed_turn`) both skip the text.
3099    fn turn_draft_rolled_over(&mut self) {
3100        self.turn_draft.base_len = usize::MAX;
3101        self.turn_draft.text.clear();
3102        self.turn_draft.tools.clear();
3103        self.turn_draft.dirty = true;
3104        self.flush_turn_draft();
3105    }
3106
3107    /// Follow the in-flight response for the draft: its text, a new
3108    /// response or retry starting over, the tools it calls. Shared by the
3109    /// stream handler and the cancel drain, so text the actor only receives
3110    /// while draining (already shown to clients) is tracked too.
3111    fn observe_turn_draft(&mut self, event: &crate::LlmEvent) {
3112        match event {
3113            crate::LlmEvent::Text(text) => self.turn_draft_text(text),
3114            crate::LlmEvent::ResponseStart | crate::LlmEvent::ResponseReset => {
3115                self.turn_draft_reset_text()
3116            }
3117            crate::LlmEvent::ToolUse { tool_name, .. } => {
3118                self.turn_draft.tools.push(tool_name.clone())
3119            }
3120            _ => {}
3121        }
3122    }
3123
3124    /// Write the draft if it changed (1 Hz turn tick; round checkpoints).
3125    /// Non-blocking and ordered (`persister`).
3126    pub(crate) fn flush_turn_draft(&mut self) {
3127        let Some(open) = self.turn_draft.open.clone() else {
3128            return;
3129        };
3130        if !self.turn_draft.dirty {
3131            return;
3132        }
3133        // Defensive: follow the conversation if its id changed mid-turn.
3134        let id = self.conv.session.id.clone();
3135        if open != id {
3136            self.persister.remove_draft(&open);
3137            self.turn_draft.open = Some(id.clone());
3138        }
3139        self.turn_draft.dirty = false;
3140        self.persister.write_draft(
3141            &id,
3142            agent_core::core::session_draft::TurnDraft {
3143                base_len: self.turn_draft.base_len,
3144                partial_text: self.turn_draft.text.clone(),
3145            },
3146        );
3147    }
3148
3149    /// Turn end (every path goes through `clear_stream`, which has just
3150    /// queued the save of the history that ends the turn): remove the draft.
3151    /// Queued after that save, and the persister never removes a draft while
3152    /// its session's latest save has failed, so the draft cannot disappear
3153    /// before the history that concludes its turn is on disk.
3154    fn close_turn_draft(&mut self) {
3155        if let Some(id) = self.turn_draft.open.take() {
3156            self.persister.remove_draft(&id);
3157        }
3158        self.turn_draft = TurnDraftState::default();
3159    }
3160
3161    /// dispatch.rs Submit (:1231-1288) minus presentation.
3162    pub(crate) async fn submit(
3163        &mut self,
3164        text: String,
3165        attachments: Vec<serde_json::Value>,
3166        from: Option<ClientId>,
3167    ) {
3168        // Wall 1 defense-in-depth: a latched (unverified) context head must
3169        // not accept new inference. The stream would refuse via
3170        // `durability_blocked` anyway, but that leaves the user message
3171        // orphaned in history; refuse here, before it is pushed.
3172        if self.conv.context_head.is_blocked(&self.conv.session) {
3173            let reason = "context head is unverified after a failed checkpoint; reload the \
3174                          session (`--continue`) or start a new one before continuing"
3175                .to_string();
3176            // Refused to the submitter (its editor text comes back and it
3177            // stops expecting this turn), a notice for everyone else.
3178            match from {
3179                Some(client) => self.emit(SessionEventWire::Refused {
3180                    client,
3181                    command: "submit".into(),
3182                    reason,
3183                }),
3184                None => self.emit(SessionEventWire::SystemNotice(reason)),
3185            }
3186            return;
3187        }
3188        if self.streaming {
3189            // A Submit while streaming is what the TUI calls StreamingInput.
3190            // Attachments during streaming are rejected by the client; if they
3191            // arrive anyway, ignore them (text-only steer).
3192            // P6: if the driver is armed and streaming, steer AND update
3193            // the driver's steering FIFO.
3194            if let Some(driver) = self.driver.as_mut() {
3195                let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3196                if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3197                    self.emit(SessionEventWire::SystemNotice(
3198                        "steering queue full (16 messages) — message dropped".into(),
3199                    ));
3200                    return;
3201                }
3202                if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3203                    self.emit(SessionEventWire::SystemNotice(
3204                        "steering queue full (256 KiB) — message dropped".into(),
3205                    ));
3206                    return;
3207                }
3208                driver.steering.push_back(text.clone());
3209            }
3210            self.steer(text);
3211            return;
3212        }
3213        // P6: while the driver is armed and idle, route to steering FIFO
3214        // instead of starting a normal turn.
3215        if self.driver.is_some() {
3216            let driver = self.driver.as_mut().unwrap();
3217            let byte_total: usize = driver.steering.iter().map(|s| s.len()).sum();
3218            if driver.steering.len() >= super::driver::STEERING_MAX_MESSAGES {
3219                self.emit(SessionEventWire::SystemNotice(
3220                    "steering queue full (16 messages) — message dropped".into(),
3221                ));
3222                return;
3223            }
3224            if byte_total + text.len() > super::driver::STEERING_MAX_BYTES {
3225                self.emit(SessionEventWire::SystemNotice(
3226                    "steering queue full (256 KiB) — message dropped".into(),
3227                ));
3228                return;
3229            }
3230            driver.steering.push_back(text.clone());
3231            // Clear auto_wakes_blocked on explicit user submit (user takeover).
3232            driver.auto_wakes_blocked = false;
3233            self.emit(SessionEventWire::Steered {
3234                text,
3235                delivered: false,
3236            });
3237            return;
3238        }
3239        if self.compact.is_some() {
3240            // dispatch.rs:1233-1237: queued until the compaction lands.
3241            self.emit(SessionEventWire::Steered {
3242                text: text.clone(),
3243                delivered: false,
3244            });
3245            self.conv.queued_message = Some(text);
3246            return;
3247        }
3248        // Real user send — reset auto-turn counter.
3249        self.consecutive_auto_turns = 0;
3250        // For every OTHER attached client: the submitter drew its own card.
3251        let user_text = text.clone();
3252        let api_content = text;
3253
3254        if attachments.is_empty() {
3255            // Text-only: existing path.
3256            self.conv.api_messages.push(std::sync::Arc::new(
3257                serde_json::json!({"role": "user", "content": api_content}),
3258            ));
3259        } else {
3260            // Build multipart content: text block (if non-empty) + attachment blocks.
3261            let mut blocks = Vec::with_capacity(attachments.len() + 1);
3262            if !api_content.is_empty() {
3263                blocks.push(serde_json::json!({"type": "text", "text": api_content}));
3264            }
3265            blocks.extend(attachments);
3266            let candidate = std::sync::Arc::new(
3267                serde_json::json!({"role": "user", "content": blocks}),
3268            );
3269            // Validate the complete history (including this message) for the
3270            // session's current model before accepting.
3271            let mut proposed = self.conv.api_messages.clone();
3272            proposed.push(candidate.clone());
3273            let model = self.runtime.model().to_string();
3274            if let Err(e) = crate::runtime::attachments::validate_history_for_request(&model, &proposed) {
3275                // Typed refusal: the submitting client gets its editor text
3276                // back (stream_handler restores `last_submitted` on `Refused`)
3277                // and keeps its attachment drafts; mirrors see a notice.
3278                match from {
3279                    Some(client) => self.emit(SessionEventWire::Refused {
3280                        client,
3281                        command: "submit".into(),
3282                        reason: format!("attachments rejected: {e}"),
3283                    }),
3284                    None => self.emit(SessionEventWire::SystemNotice(format!(
3285                        "attachments rejected: {e}"
3286                    ))),
3287                }
3288                return;
3289            }
3290            self.conv.api_messages.push(candidate);
3291        }
3292        self.start_turn(TurnTrigger::User, Some(user_text)).await;
3293    }
3294
3295    /// dispatch.rs StreamingInput plain-text branch (:1369-1378).
3296    ///
3297    /// Only the input owner steers (`is_input_command`). In an event-started
3298    /// turn, the owner's message also moves the turn onto the session budget
3299    /// (`self.runtime.turn_budget()`, which a `/budget` changes) once the
3300    /// stream delivers it. A steer that misses the turn (its stream ended)
3301    /// stays queued and starts a typed turn, on that budget anyway.
3302    pub(crate) fn steer(&mut self, text: String) {
3303        let send = |tx: &mpsc::UnboundedSender<String>| tx.send(text.clone()).is_ok();
3304        let delivered = match (self.steer_tx.as_ref(), self.owner_steer_budget.as_deref()) {
3305            (Some(tx), Some(lift)) => {
3306                lift.send_owner_steer(self.runtime.turn_budget().clone(), || send(tx))
3307            }
3308            (Some(tx), None) => send(tx),
3309            (None, _) => false,
3310        };
3311        self.emit(SessionEventWire::Steered {
3312            text: text.clone(),
3313            delivered,
3314        });
3315        self.conv.queued_message = Some(text);
3316    }
3317
3318    /// `SubagentSteer`: hand the user's text to one of this session's running
3319    /// workers, then note it in the MAIN conversation so the main agent knows
3320    /// the user redirected its worker. The note travels like a drained event
3321    /// (steered into a live turn, buffered while busy, injected while idle)
3322    /// but never starts a turn on its own; clients render it from `External`.
3323    pub(crate) fn steer_subagent(&mut self, from: Option<ClientId>, handle_id: &str, text: String) {
3324        let steered = {
3325            let registry = self
3326                .runtime
3327                .subagent_registry()
3328                .lock()
3329                .unwrap_or_else(|p| p.into_inner());
3330            match registry.get(handle_id) {
3331                None => Err(format!("no subagent {handle_id} in this session")),
3332                Some(h) => {
3333                    let running = h
3334                        .state
3335                        .read()
3336                        .unwrap_or_else(|p| p.into_inner())
3337                        .public_status()
3338                        == crate::runtime::subagent::SubagentStatus::Running;
3339                    if running {
3340                        h.steer(&text).map(|()| h.agent_name.clone())
3341                    } else {
3342                        Err(format!("{} ({handle_id}) is not running", h.agent_name))
3343                    }
3344                }
3345            }
3346        };
3347        let agent_name = match steered {
3348            Ok(name) => name,
3349            Err(reason) => {
3350                match from {
3351                    Some(c) => self.emit(SessionEventWire::Refused {
3352                        client: c,
3353                        command: "subagent_steer".to_string(),
3354                        reason,
3355                    }),
3356                    None => self.emit(SessionEventWire::SystemNotice(reason)),
3357                }
3358                return;
3359            }
3360        };
3361        self.note_in_main_conversation(subagent_steer_event(handle_id, &agent_name, &text));
3362    }
3363
3364    /// Tell the MAIN agent something the user did to its workers: the note
3365    /// travels like a drained event (steered into a live turn, buffered while
3366    /// busy, injected while idle) but never starts a turn on its own; clients
3367    /// render it from `External`.
3368    pub(crate) fn note_in_main_conversation(&mut self, event: crate::events::types::Event) {
3369        let formatted = crate::events::format_event_for_agent(&event);
3370        if self.busy() {
3371            let into_turn = self
3372                .steer_tx
3373                .as_ref()
3374                .is_some_and(|tx| tx.send(formatted.clone()).is_ok());
3375            if into_turn {
3376                self.turn_steered_events.push(formatted);
3377            } else {
3378                self.conv.pending_events.push(formatted);
3379            }
3380        } else {
3381            self.conv
3382                .api_messages
3383                .push(std::sync::Arc::new(serde_json::json!({
3384                    "role": "user",
3385                    "content": formatted
3386                })));
3387            self.request_save();
3388        }
3389        self.emit(SessionEventWire::External(event));
3390        self.emit_conversation();
3391    }
3392
3393    /// Every cancel path — `Cancel` (Esc), quit mid-turn (`finish`), the cost
3394    /// cap, a reload/host checkpoint — behind the TUI's `if streaming` guard
3395    /// (input.rs:350): a `Cancel` while idle is a no-op that only re-announces
3396    /// `Idle` — it must never touch history, save, or emit `Aborted`.
3397    ///
3398    /// The interrupted turn is recorded as REAL history, never as a recap:
3399    /// 1. the token is cancelled, then pending host prompts are answered
3400    ///    `None` (a tool or `before_tool_call` hook blocked on one must unwind);
3401    /// 2. the still-live stream is drained for a
3402    ///    bounded time (`drain_cancelled_stream`) so the engine's cancel-path
3403    ///    history — partial assistant message, completed tool rounds,
3404    ///    delivered steering, canceled `tool_result`s — plus its final Usage
3405    ///    and any in-flight context-head checkpoint reach the actor;
3406    /// 3. that history is adopted verbatim (else the last adopted history is
3407    ///    kept — every history the engine publishes is valid), then
3408    ///    `finish_interrupted_turn` APPENDS one interruption marker
3409    ///    (`engine::interrupt`). Nothing already sent is edited, so the
3410    ///    provider's cached prefix survives.
3411    ///
3412    /// A turn the engine had already finished when the cancel reached it
3413    /// (Esc a moment after the answer ended) is not interrupted: no marker.
3414    pub(crate) async fn cancel_turn(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3415        if !self.streaming {
3416            if self.compact.is_some() {
3417                self.abort_compaction();
3418                if let Some(q) = self.conv.queued_message.take() {
3419                    self.emit(SessionEventWire::Dequeued { text: q });
3420                }
3421                self.update_attach_state();
3422            }
3423            self.emit(SessionEventWire::Idle);
3424            return;
3425        }
3426        self.note_cancel_cause(reason);
3427        if let Some(ref ct) = self.cancel {
3428            ct.cancel();
3429        }
3430        // After the cancel, so a tool awaiting a prompt observes the cancel
3431        // (not a `None` it could read as "declined"); a `before_tool_call`
3432        // hook awaiting a confirm prompt is not cancel-aware and needs this
3433        // answer to unwind at all.
3434        self.resolve_pending_prompts();
3435        let drain = self.drain_cancelled_stream().await;
3436        match drain.history {
3437            Some(history) => self.conv.api_messages = history,
3438            None => {
3439                if !drain.closed {
3440                    tracing::warn!(
3441                        session = %self.id,
3442                        budget_ms = budgets::CANCEL_DRAIN_TIMEOUT_MS,
3443                        "cancelled turn did not publish its history within the drain budget; \
3444                         keeping the last adopted history plus the streamed partial reply"
3445                    );
3446                }
3447                self.keep_streamed_partial(reason);
3448            }
3449        }
3450        // Complete only if the drain consumed the whole stream: the final
3451        // history is published right after the completion is recorded.
3452        let completed = drain.closed && self.turn_completed();
3453        self.finish_interrupted_turn(reason, completed).await;
3454    }
3455
3456    /// An interrupted turn whose engine never published its post-cancel
3457    /// history (the drain budget ran out, or the stream ended without it):
3458    /// keep the reply text clients were already shown, as a real assistant
3459    /// message, so history matches the screen and the marker that follows
3460    /// doesn't merge into the next prompt. Same rule as crash recovery
3461    /// (`engine::interrupt::recover_crashed_turn`): only when the history is
3462    /// still exactly where that text began (`base_len`), never a stale one,
3463    /// and right after a user message. Text only, so it can't leave a
3464    /// `tool_use` without its result: an in-flight tool call and unsigned
3465    /// thinking are dropped. (Rebuilding the call isn't safe: with extended
3466    /// thinking a `tool_use` turn needs its signed thinking block.) A tool
3467    /// the response called instead gets a host-authored note after it
3468    /// (`engine::interrupt::tool_in_flight_note_message`), worded like the
3469    /// engine's canceled `tool_result`, so the next turn doesn't blindly
3470    /// repeat a non-idempotent action; clients get the same note. The
3471    /// engine's late publication is not applied: its stream is gone with the
3472    /// drain.
3473    fn keep_streamed_partial(&mut self, reason: crate::engine::interrupt::InterruptReason) {
3474        let d = &self.turn_draft;
3475        let follows_user = self.conv.api_messages.last().is_some_and(|m| m["role"] == "user");
3476        if d.base_len != self.conv.api_messages.len() || !follows_user {
3477            return;
3478        }
3479        let (text, tools) = (d.text.clone(), d.tools.clone());
3480        if !text.trim().is_empty() {
3481            self.conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3482                "role": "assistant",
3483                "content": [{"type": "text", "text": text}]
3484            })));
3485        }
3486        if !tools.is_empty() {
3487            let note = crate::engine::interrupt::tool_in_flight_note_message(reason, &tools);
3488            let text = note["content"].as_str().unwrap_or_default().to_owned();
3489            self.conv.api_messages.push(note);
3490            self.emit(SessionEventWire::SystemNotice(text));
3491        }
3492    }
3493
3494    /// The engine recorded the running turn's normal end (`TurnCompletion`).
3495    fn turn_completed(&self) -> bool {
3496        self.turn_completion.as_ref().is_some_and(|c| c.completed())
3497    }
3498
3499    /// Tell the engine why the running turn is being cancelled, BEFORE its
3500    /// token is: its canceled tool results say so ("Canceled by user" only
3501    /// for the user). First cause wins, so a teardown that revokes a driver
3502    /// and then cancels notes its own cause first.
3503    fn note_cancel_cause(&self, reason: crate::engine::interrupt::InterruptReason) {
3504        if !self.streaming {
3505            return;
3506        }
3507        if let (Some(turn), Some(cause)) = (&self.turn_completion, reason.cancel_cause()) {
3508            turn.note_cancel_cause(cause);
3509        }
3510    }
3511
3512    /// The ONE tail of every interrupted turn, once its stream has been
3513    /// drained or dropped (`cancel_turn`, a revoked driver turn): record the
3514    /// interruption in history, end the turn, tell clients. Never runs the
3515    /// post-turn machinery of a normal end (queued auto-send, event
3516    /// auto-turns, auto-compaction).
3517    ///
3518    /// `completed`: the engine had finished the turn normally before the
3519    /// cancel reached it — the history is a complete answer, so no marker is
3520    /// appended and clients get the `Done` they would have had.
3521    async fn finish_interrupted_turn(
3522        &mut self,
3523        reason: crate::engine::interrupt::InterruptReason,
3524        completed: bool,
3525    ) {
3526        // Defensive: only trailing invalid messages this turn appended.
3527        crate::engine::stream::repair_history_after_failure(
3528            &mut self.conv.api_messages,
3529            self.turn_baseline,
3530        );
3531        let kept_partial = self.conv.api_messages.len() > self.turn_baseline;
3532        if !completed {
3533            crate::engine::interrupt::append_marker(&mut self.conv.api_messages, reason);
3534        }
3535        // A user steer the engine never picked up was not delivered.
3536        if let Some(q) = self.conv.queued_message.take() {
3537            self.emit(SessionEventWire::Dequeued { text: q });
3538        }
3539        // Events that arrived during the turn go in after the marker: first
3540        // those steered into the stream but never drained by the engine
3541        // (otherwise lost with the channel), then those buffered.
3542        {
3543            let undelivered = std::mem::take(&mut self.turn_steered_events);
3544            let conv: &mut ConversationState = &mut self.conv;
3545            for formatted in undelivered.into_iter().chain(conv.pending_events.drain(..)) {
3546                conv.api_messages.push(std::sync::Arc::new(serde_json::json!({
3547                    "role": "user",
3548                    "content": formatted
3549                })));
3550            }
3551        }
3552        // Queues the save of this history, then the draft removal.
3553        self.clear_stream();
3554        // Cancel running reactive subagents; recover a poisoned guard rather
3555        // than skip cancellation. A user interrupt (Esc) stops the turn it
3556        // interrupted and the workers THAT turn launched; reactive workers
3557        // started by earlier turns are background work and keep running
3558        // (`/workers cancel` stops them). Every other reason — session
3559        // teardown, cost cap, host checkpoint, driver revocation — stops all.
3560        {
3561            use crate::runtime::subagent::WorkerCancelScope;
3562            let ending = self.lifecycle.load(std::sync::atomic::Ordering::Acquire)
3563                == SessionLifecycle::Ending as u8;
3564            let scope = worker_cancel_scope(reason, ending, self.turn_worker_generation);
3565            let mut registry = match self.runtime.subagent_registry().lock() {
3566                Ok(g) => g,
3567                Err(poisoned) => {
3568                    tracing::warn!(
3569                        "subagent registry mutex poisoned during abort; recovering to cancel running handles"
3570                    );
3571                    poisoned.into_inner()
3572                }
3573            };
3574            let cancelled = registry.cancel_running(scope);
3575            if !cancelled.is_empty() {
3576                tracing::info!(
3577                    ?scope,
3578                    workers = ?cancelled,
3579                    "turn interrupted: cancelled running workers"
3580                );
3581            }
3582            if let WorkerCancelScope::Turn(_) = scope {
3583                // Still running and staying so: not the ones just cancelled
3584                // (they read as running until their next step).
3585                let spared = registry
3586                    .display_rows()
3587                    .iter()
3588                    .filter(|r| {
3589                        r.status == crate::runtime::subagent::SubagentStatus::Running
3590                            && !r.cancel_requested
3591                    })
3592                    .count();
3593                drop(registry);
3594                if spared > 0 {
3595                    self.emit(SessionEventWire::Stream(StreamEvent::Session(
3596                        SessionEvent::Notice(format!(
3597                            "{spared} background worker{} from earlier turns still running — /workers to list, /workers cancel all to stop",
3598                            if spared == 1 { "" } else { "s" }
3599                        )),
3600                    )));
3601                }
3602            }
3603        }
3604        if completed {
3605            self.emit(SessionEventWire::Stream(StreamEvent::Session(SessionEvent::Done)));
3606        } else {
3607            // Typed event. `context_saved` (wire name kept for compatibility)
3608            // now means "the turn's partial work is kept in history".
3609            self.emit(SessionEventWire::Aborted {
3610                context_saved: kept_partial,
3611            });
3612        }
3613        self.emit_conversation();
3614        self.announce_idle().await;
3615    }
3616
3617    /// A turn whose token was cancelled from OUTSIDE `cancel_turn` — a
3618    /// driver revocation drops `DriverState`, cancelling the driver turn's
3619    /// child token (the grant's deadline task cancels that token too; the
3620    /// next `driver_tick` then revokes and arms the deadline) — ends here: on its `Done`, its stream's end, or when
3621    /// `revoked_turn_deadline` passes (a tool that ignores the cancel must
3622    /// not keep the session busy forever; the same budget as
3623    /// `drain_cancelled_stream`). What is left of the stream is dropped.
3624    async fn finish_revoked_turn(&mut self, stream_closed: bool) {
3625        if !self.streaming {
3626            return;
3627        }
3628        let completed = stream_closed && self.turn_completed();
3629        self.stream = None;
3630        if !completed {
3631            self.keep_streamed_partial(crate::engine::interrupt::InterruptReason::Driver);
3632        }
3633        self.finish_interrupted_turn(crate::engine::interrupt::InterruptReason::Driver, completed)
3634            .await;
3635    }
3636
3637    /// Whether the running turn's token has been cancelled (by `cancel_turn`
3638    /// or a driver revocation).
3639    fn turn_cancelled(&self) -> bool {
3640        self.streaming && self.cancel.as_ref().is_some_and(|ct| ct.is_cancelled())
3641    }
3642
3643    /// Answer every pending host prompt `None` (same as `checkpoint`/`finish`).
3644    fn resolve_pending_prompts(&mut self) {
3645        if self.pending_prompts.is_empty() {
3646            return;
3647        }
3648        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
3649            let _ = tx.send(None);
3650            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
3651        }
3652        self.publish_presence();
3653        self.rearm_prompt_abandon();
3654    }
3655
3656    /// Consume what a CANCELLED turn's stream still carries, for at most
3657    /// `budgets::CANCEL_DRAIN_TIMEOUT`, then drop it.
3658    ///
3659    /// Deliberately NOT `on_stream_event`: that handler would re-check the
3660    /// cost cap on the final `Usage` (recursive `cancel_turn`), run the
3661    /// failure path on the engine's typed `Canceled` error, and treat `Done`
3662    /// as a normal completion (queued auto-send, auto-compaction).
3663    ///
3664    /// Forwarded to clients: display events (partial text, tool results —
3665    /// the history being adopted contains them), `Usage`, agent events.
3666    /// Handled: `MessageHistory` (kept, last wins), `ContextHeadCheckpoint`
3667    /// (serviced — dropping its receipt would latch `durability_blocked`;
3668    /// the head it installs replaces any history kept before it),
3669    /// `SteeringDelivered`. Swallowed: `Done`, the `Canceled` error. A typed
3670    /// `InterruptedAfterSideEffect` becomes a notice (it is not a failure of
3671    /// the turn; the canceled `tool_result` already tells the model). A tool
3672    /// raising a host prompt before it observed the cancel is answered
3673    /// `None` at once, not at the deadline.
3674    async fn drain_cancelled_stream(&mut self) -> CancelDrain {
3675        let mut out = CancelDrain::default();
3676        let Some(mut stream) = self.stream.take() else {
3677            out.closed = true;
3678            return out;
3679        };
3680        let deadline = tokio::time::Instant::now() + budgets::CANCEL_DRAIN_TIMEOUT;
3681        loop {
3682            let event = tokio::select! {
3683                biased;
3684                Some(req) = self.secret_prompt_rx.recv() => {
3685                    let _ = req.response_tx.send(None);
3686                    continue;
3687                }
3688                next = tokio::time::timeout_at(deadline, stream.next()) => match next {
3689                    Err(_) => break,
3690                    Ok(None) => {
3691                        out.closed = true;
3692                        break;
3693                    }
3694                    Ok(Some(event)) => event,
3695                },
3696            };
3697            match event {
3698                StreamEvent::Session(SessionEvent::Done) => {
3699                    out.closed = true;
3700                    break;
3701                }
3702                StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3703                    out.history = Some(history);
3704                }
3705                StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
3706                    session_id,
3707                    messages,
3708                    receipt,
3709                }) => {
3710                    self.handle_context_head_checkpoint(session_id, messages, receipt)
3711                        .await;
3712                    // The adopted head is the actor's history now: a history
3713                    // kept from BEFORE it is stale and must not override it
3714                    // (a later `MessageHistory`, if any, still wins).
3715                    out.history = None;
3716                }
3717                StreamEvent::Session(SessionEvent::Error(err)) => match err.outcome {
3718                    crate::TurnOutcome::Canceled => {}
3719                    crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3720                        self.emit(SessionEventWire::SystemNotice(err.message));
3721                    }
3722                    _ => {
3723                        tracing::warn!(
3724                            session = %self.id,
3725                            category = err.category_label(),
3726                            "cancelled turn reported an error while draining"
3727                        );
3728                    }
3729                },
3730                StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3731                    self.note_steering_delivered(message);
3732                    self.emit(SessionEventWire::Stream(event));
3733                }
3734                StreamEvent::Session(SessionEvent::Usage { .. }) => {
3735                    self.record_usage(&event);
3736                    self.emit(SessionEventWire::Stream(event));
3737                }
3738                StreamEvent::Llm(ref llm) => {
3739                    self.observe_turn_draft(llm);
3740                    self.emit(SessionEventWire::Stream(event));
3741                }
3742                other => self.emit(SessionEventWire::Stream(other)),
3743            }
3744        }
3745        drop(stream);
3746        out
3747    }
3748
3749    /// `SteeringDelivered`: the engine injected `message` into history.
3750    fn note_steering_delivered(&mut self, message: &str) {
3751        if self.conv.queued_message.as_deref() == Some(message) {
3752            self.conv.queued_message = None;
3753            self.emit_conversation();
3754        }
3755        // P5/P6: pop the driver's steering FIFO on delivery ack.
3756        if let Some(driver) = self.driver.as_mut() {
3757            if driver.steering.front().map(String::as_str) == Some(message) {
3758                driver.steering.pop_front();
3759            }
3760        }
3761        if let Some(pos) = self.turn_steered_events.iter().position(|e| e == message) {
3762            self.turn_steered_events.remove(pos);
3763        }
3764    }
3765
3766    /// Accumulate one `SessionEvent::Usage` into the conversation totals.
3767    fn record_usage(&mut self, event: &StreamEvent) {
3768        if let StreamEvent::Session(SessionEvent::Usage {
3769            input_tokens,
3770            output_tokens,
3771            cache_read_input_tokens,
3772            cache_creation_input_tokens,
3773            cache_creation_5m,
3774            cache_creation_1h,
3775            model: usage_model,
3776        }) = event
3777        {
3778            let model_for_pricing = usage_model
3779                .as_deref()
3780                .unwrap_or(self.runtime.model())
3781                .to_string();
3782            self.cache_keepwarm_note_usage(crate::runtime::keepwarm::KeepWarmUsage {
3783                input_tokens: *input_tokens,
3784                output_tokens: *output_tokens,
3785                cache_read_tokens: *cache_read_input_tokens,
3786                cache_write_tokens: *cache_creation_input_tokens,
3787                cache_write_5m: *cache_creation_5m,
3788                cache_write_1h: *cache_creation_1h,
3789                model: usage_model.clone(),
3790            });
3791            self.conv.add_usage(
3792                *input_tokens,
3793                *output_tokens,
3794                *cache_read_input_tokens,
3795                *cache_creation_input_tokens,
3796                *cache_creation_5m,
3797                *cache_creation_1h,
3798                &model_for_pricing,
3799            );
3800        }
3801    }
3802
3803    // ── event-queue wake (stream_handler.rs handle_event_queue_arm) ──────
3804
3805    async fn on_queue_wake(&mut self) {
3806        if self.holds(WorkSource::QueueWake) {
3807            // Fenced for a reload: the record carries what is queued.
3808            return;
3809        }
3810        if self.is_parked() {
3811            if self.event_queue.is_empty() {
3812                return;
3813            }
3814            if let Err(e) = self.ensure_live().await {
3815                tracing::warn!(session = %self.id, error = %e, "event wake could not unpark; events stay queued");
3816                return;
3817            }
3818        }
3819        let busy = self.busy();
3820        let conv: &mut ConversationState = &mut self.conv;
3821        let drained = drain_event_queue(
3822            &self.event_queue,
3823            &mut conv.api_messages,
3824            &mut conv.pending_events,
3825            busy,
3826            self.steer_tx.as_ref(),
3827        );
3828        if drained.is_empty() {
3829            return;
3830        }
3831        for de in &drained {
3832            if de.disposition == EventDisposition::Steered {
3833                self.turn_steered_events.push(de.formatted.clone());
3834            }
3835            self.emit(SessionEventWire::External(de.event.clone()));
3836        }
3837        let injected = drained
3838            .iter()
3839            .any(|d| d.disposition == EventDisposition::Injected);
3840        if injected || busy {
3841            self.emit_conversation();
3842        }
3843
3844        // P5: observe_events — if non-steering events arrive during a
3845        // driver-OWNED turn, revoke (competing work). Coordinates with F8.
3846        if self.streaming
3847            && self
3848                .driver
3849                .as_ref()
3850                .is_some_and(|d| d.awaiting_terminal)
3851        {
3852            let competing = drained
3853                .iter()
3854                .any(|d| !matches!(d.disposition, EventDisposition::Steered | EventDisposition::DisplayOnly));
3855            if competing {
3856                self.driver_revoke("event-bus work took priority");
3857            }
3858        }
3859
3860        // `events.auto_turn = false` opts the session out of event-driven
3861        // turns (events are still injected/forwarded; the spend governor
3862        // for an ambient session). Read live from host config, like the
3863        // RPC/server hosts do — it used to be hardcoded `true` here.
3864        let auto_turn_enabled = self.host.config().events.auto_turn;
3865        let auto_turn_cap = self.host.config().events.auto_turn_cap;
3866        let action = wake_action_with_cap(
3867            &drained,
3868            &self.conv.api_messages,
3869            busy,
3870            auto_turn_enabled,
3871            self.consecutive_auto_turns,
3872            auto_turn_cap,
3873        );
3874        match action {
3875            WakeAction::RunTurn => {
3876                // F8: while a driver is armed, its tick owns turn scheduling.
3877                // Starting a competing turn here would race the driver.
3878                if self.driver.is_some() {
3879                    tracing::debug!("on_queue_wake: RunTurn inhibited — driver armed");
3880                } else if self.streaming || self.stream.is_some() {
3881                    tracing::warn!("handle_event_arm: RunTurn with active turn — skipping");
3882                } else {
3883                    self.consecutive_auto_turns += 1;
3884                    self.start_turn(TurnTrigger::EventAuto, None).await;
3885                }
3886            }
3887            WakeAction::Forward => {
3888                let hit_cap = injected
3889                    && !busy
3890                    && auto_turn_enabled
3891                    && auto_turn_cap_reached(self.consecutive_auto_turns, auto_turn_cap);
3892                if hit_cap {
3893                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
3894                }
3895            }
3896            WakeAction::Nothing => {}
3897        }
3898    }
3899
3900    // ── stream events (stream_handler.rs handle_stream_event + arm tail) ──
3901
3902    async fn on_stream_event(&mut self, event: StreamEvent) {
3903        // A turn cancelled from outside `cancel_turn` (a driver revocation):
3904        // its terminal events are not a normal end — the turn is finished as
3905        // interrupted, never through the post-turn machinery below.
3906        if self.turn_cancelled() {
3907            match event {
3908                StreamEvent::Session(SessionEvent::Done) => {
3909                    self.finish_revoked_turn(true).await;
3910                    return;
3911                }
3912                // Swallowed as in `drain_cancelled_stream`; `Done` follows.
3913                StreamEvent::Session(SessionEvent::Error(err)) => {
3914                    match err.outcome {
3915                        crate::TurnOutcome::Canceled => {}
3916                        crate::TurnOutcome::InterruptedAfterSideEffect { .. } => {
3917                            self.emit(SessionEventWire::SystemNotice(err.message));
3918                        }
3919                        _ => tracing::warn!(
3920                            session = %self.id,
3921                            category = err.category_label(),
3922                            "revoked turn reported an error"
3923                        ),
3924                    }
3925                    return;
3926                }
3927                _ => {}
3928            }
3929        }
3930        // Forward first: clients see the same order they see today. A
3931        // context-head checkpoint is actor-internal (its receipt is ours to
3932        // complete) and must not reach clients: the wire has no such event
3933        // and maps it to `Done`, which ended the turn on socket clients.
3934        if !matches!(
3935            event,
3936            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint { .. })
3937        ) {
3938            self.emit(SessionEventWire::Stream(event.clone()));
3939        }
3940
3941        // P5: observe_feedback — feed opted-in driver turns.
3942        if let Some(driver) = self.driver.as_mut() {
3943            if driver.awaiting_terminal && driver.grant.feedback_enabled() {
3944                driver.feedback.observe(&event);
3945            }
3946        }
3947
3948        // P5: capture_terminal BEFORE Done/Error handlers modify state.
3949        let is_canceled = self
3950            .cancel
3951            .as_ref()
3952            .is_some_and(|ct| ct.is_cancelled());
3953        let terminal = if self
3954            .driver
3955            .as_ref()
3956            .is_some_and(|d| d.awaiting_terminal)
3957        {
3958            super::driver::capture_terminal(Some(&event), is_canceled)
3959        } else {
3960            None
3961        };
3962
3963        enum After {
3964            Continue,
3965            AutoSendQueued(String),
3966            AutoTriggerEvents,
3967            Failed,
3968        }
3969        let mut after = After::Continue;
3970
3971        match event {
3972            // Text, a new provider response (or a retry's reset: the
3973            // in-flight text starts over), tool calls.
3974            StreamEvent::Llm(ref llm) => self.observe_turn_draft(llm),
3975            StreamEvent::Session(SessionEvent::MessageHistory(history)) => {
3976                // Published at every round boundary as well as at the end of
3977                // the turn (`runtime/stream.rs` ROUND CHECKPOINT): the session
3978                // on disk follows the turn as it progresses. Queued, never
3979                // awaited — the persister writes it (latest wins), so a slow
3980                // disk can never stall the turn machine.
3981                self.conv.api_messages = history;
3982                self.request_save();
3983                // The round is committed: the draft now continues from here.
3984                self.turn_draft_committed();
3985                self.trim_replay_to_checkpoint();
3986                self.emit_conversation();
3987            }
3988            StreamEvent::Agent(AgentEvent::SteeringDelivered { ref message }) => {
3989                self.note_steering_delivered(message);
3990            }
3991            StreamEvent::Agent(_) => {}
3992            StreamEvent::Session(SessionEvent::Usage { .. }) => {
3993                self.record_usage(&event);
3994                // (E-P7, §S3) Host-owned spend circuit breaker. Checked after
3995                // every Usage event — a breach cancels the in-flight turn,
3996                // revokes any armed driver (so it cannot re-poll and keep
3997                // spending), and tells clients why.
3998                if let Some((scope, cost, cap)) = self.cost_cap_breach() {
3999                    self.emit(SessionEventWire::CostCapReached {
4000                        scope: scope.to_string(),
4001                        cost,
4002                        cap,
4003                    });
4004                    self.note_cancel_cause(crate::engine::interrupt::InterruptReason::CostCap);
4005                    if self.driver.is_some() || self.driver_pending.is_some() {
4006                        self.driver_revoke(&format!("{scope} cost cap reached (${cost:.4} ≥ ${cap:.4})"));
4007                    }
4008                    self.cancel_turn(crate::engine::interrupt::InterruptReason::CostCap)
4009                        .await;
4010                    return;
4011                }
4012            }
4013            StreamEvent::Session(SessionEvent::Notice(_) | SessionEvent::Retry(_)) => {}
4014            StreamEvent::Session(SessionEvent::Done) => {
4015                self.clear_stream();
4016                self.publish_subagent_rows();
4017                // Flush events that arrived during streaming into api_messages
4018                let had_pending = !self.conv.pending_events.is_empty();
4019                {
4020                    let conv: &mut ConversationState = &mut self.conv;
4021                    for formatted in conv.pending_events.drain(..) {
4022                        conv.api_messages
4023                            .push(std::sync::Arc::new(serde_json::json!({
4024                                "role": "user",
4025                                "content": formatted
4026                            })));
4027                    }
4028                }
4029                if let Some(queued) = self.conv.queued_message.take() {
4030                    after = After::AutoSendQueued(queued);
4031                } else if had_pending {
4032                    self.request_save();
4033                    after = After::AutoTriggerEvents;
4034                }
4035                self.emit_conversation();
4036                if matches!(after, After::Continue) {
4037                    if self.config.auto_compact {
4038                        self.post_turn_chat().await;
4039                    }
4040                    // A spawned compaction emits Idle when it lands.
4041                    if self.compact.is_none() {
4042                        self.announce_idle().await;
4043                    }
4044                }
4045            }
4046            StreamEvent::Session(SessionEvent::Error(_)) => {
4047                // Remove only invalid messages appended by the ACTIVE turn —
4048                // before `clear_stream` queues the turn's final save.
4049                crate::engine::stream::repair_history_after_failure(
4050                    &mut self.conv.api_messages,
4051                    self.turn_baseline,
4052                );
4053                self.clear_stream();
4054                self.publish_subagent_rows();
4055                self.emit_conversation();
4056                after = After::Failed;
4057            }
4058            StreamEvent::Session(SessionEvent::ContextHeadCheckpoint {
4059                session_id,
4060                messages,
4061                receipt,
4062            }) => {
4063                self.handle_context_head_checkpoint(session_id, messages, receipt)
4064                    .await;
4065            }
4066        }
4067
4068        match after {
4069            After::Continue => {}
4070            After::Failed => {
4071                if self.config.auto_compact {
4072                    self.post_turn_chat().await;
4073                }
4074                if self.compact.is_none() {
4075                    self.announce_idle().await;
4076                }
4077            }
4078            After::AutoSendQueued(queued) => {
4079                if self.config.auto_compact {
4080                    self.post_turn_chat().await;
4081                }
4082                if self.compact.is_some() {
4083                    // Rides the compaction transition (queued_message
4084                    // restored last); the TUI reports it, never re-sends.
4085                    self.conv.queued_message = Some(queued);
4086                    return;
4087                }
4088                // Auto-send the queued message (user-authored — reset counter)
4089                self.consecutive_auto_turns = 0;
4090                let user_text = queued.clone();
4091                self.conv.api_messages.push(std::sync::Arc::new(
4092                    serde_json::json!({"role": "user", "content": queued}),
4093                ));
4094                self.start_turn(TurnTrigger::QueuedAuto, Some(user_text)).await;
4095            }
4096            After::AutoTriggerEvents => {
4097                if self.config.auto_compact {
4098                    self.post_turn_chat().await;
4099                }
4100                if self.compact.is_some() {
4101                    return;
4102                }
4103                // Central claim gate: allows turns while under the configured
4104                // cap (events.auto_turn_cap; 0 = unlimited), denies once reached.
4105                let auto_turn_cap = self.host.config().events.auto_turn_cap;
4106                if claim_auto_turn_with_cap(&mut self.consecutive_auto_turns, auto_turn_cap) {
4107                    self.start_turn(TurnTrigger::EventAuto, None).await;
4108                } else {
4109                    self.emit(SessionEventWire::AutoTurnCapReached { cap: auto_turn_cap });
4110                    self.announce_idle().await;
4111                }
4112            }
4113        }
4114
4115        // P5: observe_terminal — set driver outcome or revoke.
4116        if let Some(terminal) = terminal {
4117            let revoke_reason = if let Some(driver) = self.driver.as_mut() {
4118                super::driver::observe_terminal(driver, &self.runtime, terminal)
4119            } else {
4120                None
4121            };
4122            // Emit DriverTurnOutcome if an outcome was just set.
4123            if let Some(driver) = self.driver.as_ref() {
4124                if let Some((outcome, _)) = &driver.outcome {
4125                    self.emit(SessionEventWire::DriverTurnOutcome {
4126                        outcome: *outcome,
4127                        selection: driver.selection.clone(),
4128                        feedback: driver.grant.feedback_enabled().then(|| {
4129                            driver.completed_feedback.to_string()
4130                        }),
4131                    });
4132                }
4133            }
4134            if let Some(reason) = revoke_reason {
4135                self.driver_revoke(&reason);
4136            }
4137        }
4138    }
4139
4140    /// chat.rs post-turn block: save + engine-budget auto-compaction.
4141    async fn post_turn_chat(&mut self) {
4142        self.request_save();
4143        let assessment = self.runtime.assess_context(&self.conv.api_messages).await;
4144        if assessment.should_compact() {
4145            self.emit(SessionEventWire::SystemNotice(format!(
4146                "[auto-compacting ~{} tokens...]",
4147                assessment.used_tokens()
4148            )));
4149            self.compact(None, "auto").await;
4150        }
4151    }
4152
4153    /// `SYNAPS_SESSION_COMPACT_INLINE=1`: one-release kill-switch back to the
4154    /// #107 inline body (deleted in phase 4).
4155    fn compact_inline_env() -> bool {
4156        matches!(
4157            std::env::var("SYNAPS_SESSION_COMPACT_INLINE").as_deref(),
4158            Ok("1") | Ok("true")
4159        )
4160    }
4161
4162    /// `/compact` + auto-compaction entry (B2): spawns `compact_conversation`
4163    /// on a `Runtime::clone()` (the same clone the TUI makes at
4164    /// dispatch.rs:450 — the clone never runs turns) and returns; the
4165    /// `select!` arm `on_compaction_done` applies the outcome. While a job
4166    /// is in flight Attach/Detach/Cancel/Query are serviced, `Submit` is
4167    /// queued (`Steered{delivered:false}`), auto-turns see `busy`.
4168    pub(crate) async fn compact(&mut self, instructions: Option<String>, source: &str) {
4169        // Compaction replaces the history: the old cached prefix is dead.
4170        self.cache_keepwarm_compaction();
4171        if Self::compact_inline_env() {
4172            return self.compact_inline(instructions, source).await;
4173        }
4174        if self.compact.is_some() {
4175            self.emit(SessionEventWire::SystemNotice(
4176                "compaction already in progress".into(),
4177            ));
4178            return;
4179        }
4180        if self.streaming {
4181            self.emit(SessionEventWire::SystemNotice(
4182                "cannot compact while a turn is running".into(),
4183            ));
4184            return;
4185        }
4186        let disclosure =
4187            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line();
4188        self.emit(SessionEventWire::CompactionStarted {
4189            source: source.to_string(),
4190            disclosure,
4191        });
4192        let msgs = self.conv.api_messages.clone();
4193        let rt: Runtime = (*self.runtime).clone();
4194        let task = tokio::spawn(async move {
4195            compact_conversation(&msgs, &rt, instructions.as_deref()).await
4196        });
4197        self.compact = Some(CompactionJob {
4198            task,
4199            source: source.to_string(),
4200            started: tokio::time::Instant::now(),
4201        });
4202        self.update_attach_state();
4203    }
4204
4205    /// `select!` arm: the spawned job finished (or was aborted). Applies via
4206    /// the ONE engine transition with the configured policy; pending events
4207    /// and the queued message ride the transition (loop_arms.rs:761-836).
4208    async fn on_compaction_done(
4209        &mut self,
4210        res: std::result::Result<
4211            Result<crate::runtime::compaction::CompactionOutcome>,
4212            tokio::task::JoinError,
4213        >,
4214    ) {
4215        let Some(job) = self.compact.take() else {
4216            return;
4217        };
4218        let msg_count = self.conv.api_messages.len();
4219        match res {
4220            Err(join) => {
4221                if !join.is_cancelled() {
4222                    self.emit(SessionEventWire::CompactionFailed {
4223                        message: join.to_string(),
4224                        panicked: true,
4225                    });
4226                }
4227            }
4228            Ok(Err(e)) => self.emit(SessionEventWire::CompactionFailed {
4229                message: e.to_string(),
4230                panicked: false,
4231            }),
4232            Ok(Ok(outcome)) => {
4233                // The transition rewrites the predecessor on disk (its
4234                // `compacted_into` link): a queued save of it must land
4235                // first, never after.
4236                if !self.persister.flush().await {
4237                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4238                }
4239                let policy: CompactionPolicy = self.config.compaction_policy.into();
4240                let queued = self.conv.queued_message.clone();
4241                let applied = apply_compaction(
4242                    &self.runtime,
4243                    &self.conv.session,
4244                    &self.conv.api_messages,
4245                    &outcome,
4246                    CompactionTransition {
4247                        policy,
4248                        pending_events: self.conv.pending_events.clone(),
4249                        queued_message: queued.clone(),
4250                        hook_source: job.source.clone(),
4251                    },
4252                )
4253                .await;
4254                match applied {
4255                    Ok(applied) => {
4256                        let previous = applied.previous_session_id.clone();
4257                        self.conv.session = applied.session;
4258                        self.conv.api_messages = applied.api_messages;
4259                        self.conv.pending_events.clear();
4260                        self.conv.queued_message = None;
4261                        if policy == CompactionPolicy::LinkedSuccessor {
4262                            self.conv.total_input_tokens = 0;
4263                            self.conv.total_output_tokens = 0;
4264                            self.conv.session_cost = 0.0;
4265                            // The keep-warm subtotal follows the totals it
4266                            // is part of: input/output/cost restart, the
4267                            // cache totals (not reset here) keep their
4268                            // keep-warm share.
4269                            self.conv.keepwarm_cost = 0.0;
4270                            self.conv.keepwarm_tokens.input = 0;
4271                            self.conv.keepwarm_tokens.output = 0;
4272                        }
4273                        let new_id = self.conv.session.id.clone();
4274                        self.runtime.set_session_id(Some(new_id.clone()));
4275                        self.set_journal_id(new_id.clone());
4276                        // F10: lock follows the new journal id.
4277                        self.reacquire_session_lock(&new_id);
4278                        // Wall 1: reset continuation state for the new session id
4279                        // so stale epoch checks in persist_head() don't reject
4280                        // future checkpoints.
4281                        self.conv.context_head = crate::engine::session::ContextHeadPersistence::default();
4282                        self.runtime
4283                            .reset_context_continuation(&new_id, &self.conv.api_messages);
4284                        self.emit(SessionEventWire::CompactionApplied {
4285                            previous_session_id: previous,
4286                            session_id: new_id,
4287                            chains_advanced: applied.chains_advanced,
4288                            queued_restored: queued,
4289                            msg_count,
4290                        });
4291                    }
4292                    Err(e) => self.emit(SessionEventWire::CompactionFailed {
4293                        message: e.to_string(),
4294                        panicked: false,
4295                    }),
4296                }
4297            }
4298        }
4299        self.emit_conversation();
4300        self.update_attach_state();
4301        self.announce_idle().await;
4302    }
4303
4304    /// #107 inline body (kill-switch only).
4305    async fn compact_inline(&mut self, instructions: Option<String>, source: &str) {
4306        self.compacting_inline = true;
4307        self.publish_presence();
4308        self.compact_inline_inner(instructions, source).await;
4309        self.compacting_inline = false;
4310        self.publish_presence();
4311    }
4312
4313    async fn compact_inline_inner(&mut self, instructions: Option<String>, source: &str) {
4314        self.emit(SessionEventWire::SystemNotice(format!(
4315            "[{}]",
4316            preview_compaction_disclosure(&self.runtime, &self.conv.api_messages).render_line()
4317        )));
4318        let outcome =
4319            compact_conversation(&self.conv.api_messages, &self.runtime, instructions.as_deref())
4320                .await;
4321        let applied = match outcome {
4322            Ok(outcome) => {
4323                // Queued saves land before the transition rewrites the file.
4324                if !self.persister.flush().await {
4325                    tracing::warn!(session = %self.id, "compaction: an earlier save failed");
4326                }
4327                apply_compaction(
4328                    &self.runtime,
4329                    &self.conv.session,
4330                    &self.conv.api_messages,
4331                    &outcome,
4332                    CompactionTransition {
4333                        policy: CompactionPolicy::InPlace,
4334                        pending_events: Vec::new(),
4335                        queued_message: None,
4336                        hook_source: source.to_string(),
4337                    },
4338                )
4339                .await
4340            }
4341            Err(e) => Err(e),
4342        };
4343        match applied {
4344            Ok(applied) => {
4345                self.conv.session = applied.session;
4346                self.conv.api_messages = applied.api_messages;
4347                let after = self.runtime.assess_context(&self.conv.api_messages).await;
4348                self.emit(SessionEventWire::SystemNotice(format!(
4349                    "[compacted → ~{} tokens]",
4350                    after.used_tokens()
4351                )));
4352            }
4353            Err(e) => self.emit(SessionEventWire::SystemNotice(format!(
4354                "[compaction failed: {}]",
4355                e
4356            ))),
4357        }
4358        self.emit_conversation();
4359    }
4360
4361    // ── prompts ──────────────────────────────────────────────────────────
4362
4363    fn on_prompt_request(&mut self, req: SecretPromptRequest) {
4364        let id = self.next_prompt_id;
4365        self.next_prompt_id += 1;
4366        let pr = PromptRequest {
4367            id,
4368            kind: PromptKind::from_title(&req.title),
4369            title: req.title,
4370            prompt: req.prompt,
4371            raised_at: chrono::Utc::now(),
4372            confirm_scope: req.confirm_scope,
4373        };
4374        self.pending_prompts.push_back((pr.clone(), req.response_tx));
4375        self.publish_presence();
4376        self.emit(SessionEventWire::Prompt(pr));
4377        // A prompt can be raised while already detached (a turn running with no
4378        // client). Arm the abandonment deadline if this left us pinned.
4379        self.rearm_prompt_abandon();
4380    }
4381
4382    /// Why `from` may not give this answer to `prompt`, if it may not.
4383    /// Confirm prompts gate tool execution, so an `Observe` client cannot
4384    /// answer them at all, and only the input owner (or the host, `from:
4385    /// None`) can choose "Allow all", which arms a session-wide latch.
4386    fn confirm_answer_refusal(
4387        &self,
4388        from: Option<ClientId>,
4389        prompt: &PromptRequest,
4390        allow_all: bool,
4391    ) -> Option<&'static str> {
4392        let c = from?;
4393        if prompt.kind != PromptKind::Confirm {
4394            return None;
4395        }
4396        if self.attached.get(&c).map(|a| a.mode) == Some(AttachMode::Observe) {
4397            return Some("observe-mode clients cannot answer confirm prompts");
4398        }
4399        if allow_all && self.input_owner != Some(c) {
4400            return Some("only the input owner can choose Allow all");
4401        }
4402        None
4403    }
4404
4405    fn answer(&mut self, from: Option<ClientId>, prompt_id: u64, value: Option<String>) {
4406        let Some(pos) = self.pending_prompts.iter().position(|(p, _)| p.id == prompt_id) else {
4407            return; // unknown or already answered — dedup on id
4408        };
4409        let allow_all = value.as_deref().is_some_and(|v| {
4410            v.trim()
4411                .eq_ignore_ascii_case(crate::tools::CONFIRM_ANSWER_ALLOW_ALL)
4412        });
4413        // An answer is input too: with `client_input = shared` the answering
4414        // drive client becomes active first, so "Allow all" (owner only)
4415        // is its to choose.
4416        if let Some(c) = from {
4417            self.note_input(c);
4418        }
4419        if let Some(reason) = self.confirm_answer_refusal(from, &self.pending_prompts[pos].0, allow_all) {
4420            if let Some(c) = from {
4421                self.emit(SessionEventWire::Refused {
4422                    client: c,
4423                    command: "answer".to_string(),
4424                    reason: reason.to_string(),
4425                });
4426            }
4427            return; // the prompt stays pending for an eligible client
4428        }
4429        let approved = allow_all
4430            || value
4431                .as_deref()
4432                .map(str::trim)
4433                .is_some_and(|v| v.eq_ignore_ascii_case("y") || v.eq_ignore_ascii_case("yes"));
4434        let (prompt, tx) = self.pending_prompts.remove(pos).expect("position");
4435        let _ = tx.send(value);
4436        self.publish_presence();
4437        self.emit(SessionEventWire::PromptResolved { prompt_id });
4438        let by = match from {
4439            Some(c) => self.client_label(c),
4440            None => "the host".to_string(),
4441        };
4442        // prA N3: the tool card shows the model's original input; when the
4443        // approved call runs input rewritten by an extension, every client's
4444        // transcript records that (the prompt showed the final input).
4445        if approved && prompt.title == CONFIRM_PROMPT_TITLE {
4446            if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.rewritten_by.is_empty()) {
4447                self.emit(SessionEventWire::SystemNotice(format!(
4448                    "Approved by {by}: {} runs with input rewritten by {} (not the input shown \
4449                     on its tool card; the approval prompt showed what runs).",
4450                    scope.tool.as_deref().unwrap_or("the tool call"),
4451                    scope.rewritten_by.join(", ")
4452                )));
4453            }
4454        }
4455        // Arming the latch is announced to every attached client, not just
4456        // the one that pressed the key.
4457        if allow_all && prompt.title == CONFIRM_PROMPT_TITLE {
4458            if let Some(scope) = prompt.confirm_scope.as_ref().filter(|s| !s.extensions.is_empty()) {
4459                self.emit(SessionEventWire::SystemNotice(format!(
4460                    "Allow all armed by {by}: tool-call confirms from {} are auto-approved \
4461                     until /new. Other extensions still ask. /confirms reset revokes.",
4462                    scope.extensions_label()
4463                )));
4464            }
4465        }
4466        // The last pending prompt may have just cleared — drop the deadline.
4467        self.rearm_prompt_abandon();
4468    }
4469
4470    // ── settings / queries / engine commands ─────────────────────────────
4471
4472    pub(crate) async fn apply_setting(&mut self, id: u64, setting: SessionSetting) {
4473        if replayable(&setting) {
4474            let disc = std::mem::discriminant(&setting);
4475            self.settings_replay
4476                .retain(|s| std::mem::discriminant(s) != disc);
4477            self.settings_replay.push(setting.clone());
4478        }
4479        let name = match &setting {
4480            SessionSetting::Model { .. } => "model",
4481            SessionSetting::ReasoningLevel { .. } => "reasoning_level",
4482            SessionSetting::ContextWindow { .. } => "context_window",
4483            SessionSetting::CompactionModel { .. } => "compaction_model",
4484            SessionSetting::ApiRetries { .. } => "api_retries",
4485            SessionSetting::SubagentTimeout { .. } => "subagent_timeout",
4486            SessionSetting::MaxToolOutput { .. } => "max_tool_output",
4487            SessionSetting::BashTimeout { .. } => "bash_timeout",
4488            SessionSetting::BashMaxTimeout { .. } => "bash_max_timeout",
4489            SessionSetting::SystemPrompt { .. } => "system_prompt",
4490            SessionSetting::ReloadPrompt => "reload_prompt",
4491            SessionSetting::GrantWorkerModel { .. } => "grant_worker_model",
4492        };
4493        // Capability evidence must exist in THIS (possibly daemon) process
4494        // before the mutation is validated and a reasoning clamp decided.
4495        match &setting {
4496            SessionSetting::Model { model } => self.runtime.prepare_model_capabilities(model).await,
4497            SessionSetting::ReasoningLevel { .. } => {
4498                let model = self.runtime.model().to_string();
4499                self.runtime.prepare_model_capabilities(&model).await;
4500            }
4501            _ => {}
4502        }
4503        let rt = &mut self.runtime;
4504        let mut clamp_wire = None;
4505        let result: std::result::Result<Option<String>, String> = match setting {
4506            SessionSetting::Model { model } => rt.try_set_model(model).map(|clamp| {
4507                self.conv.session.model = rt.model().to_string();
4508                clamp.map(|c| {
4509                    self.conv.session.thinking_level = rt.thinking_level().to_string();
4510                    clamp_wire = Some(ReasoningClampWire {
4511                        from: c.from.as_str().to_string(),
4512                        to: c.to.as_str().to_string(),
4513                    });
4514                    format!(
4515                        "thinking → {} (clamped from {}: not supported by {})",
4516                        c.to.as_str(),
4517                        c.from.as_str(),
4518                        rt.model()
4519                    )
4520                })
4521            }),
4522            SessionSetting::ReasoningLevel { level } => {
4523                rt.set_reasoning_level_checked(level).map(|_| {
4524                    self.conv.session.thinking_level = rt.thinking_level().to_string();
4525                    None
4526                })
4527            }
4528            SessionSetting::ContextWindow { tokens } => {
4529                rt.set_context_window(tokens);
4530                Ok(None)
4531            }
4532            SessionSetting::CompactionModel { model } => {
4533                rt.set_compaction_model(model);
4534                Ok(None)
4535            }
4536            SessionSetting::ApiRetries { n } => {
4537                rt.set_api_retries(n);
4538                Ok(None)
4539            }
4540            SessionSetting::SubagentTimeout { secs } => {
4541                rt.set_subagent_timeout(secs);
4542                Ok(None)
4543            }
4544            SessionSetting::MaxToolOutput { bytes } => {
4545                rt.set_max_tool_output(bytes);
4546                Ok(None)
4547            }
4548            SessionSetting::BashTimeout { secs } => {
4549                rt.set_bash_timeout(secs);
4550                Ok(None)
4551            }
4552            SessionSetting::BashMaxTimeout { secs } => {
4553                rt.set_bash_max_timeout(secs);
4554                Ok(None)
4555            }
4556            SessionSetting::SystemPrompt { text } => {
4557                rt.set_system_prompt(text);
4558                Ok(None)
4559            }
4560            SessionSetting::ReloadPrompt => rt
4561                .reload_prompt()
4562                .map(|generation| Some(format!("prompt reloaded (generation {generation})")))
4563                .map_err(|e| e.to_string()),
4564            SessionSetting::GrantWorkerModel { model } => {
4565                rt.grant_worker_model(&model).map(|_| None)
4566            }
4567        };
4568        self.publish_view().await;
4569        let view = (**self.view.load()).clone();
4570        let (ok, message) = match result {
4571            Ok(m) => (true, m),
4572            Err(e) => (false, Some(e)),
4573        };
4574        self.emit(SessionEventWire::SettingChanged(SettingApplied {
4575            id,
4576            setting: name.to_string(),
4577            ok,
4578            message,
4579            view,
4580            clamp: clamp_wire,
4581        }));
4582    }
4583
4584    pub(crate) async fn query(&mut self, id: u64, query: SessionQuery) {
4585        // Status/View never wake a parked session (the idle probe must not).
4586        if self.is_parked() {
4587            let value = match query {
4588                SessionQuery::Status => serde_json::json!({
4589                    "session": (**self.journal_id.load()).clone(),
4590                    "model": self.view.load().model,
4591                    "lifecycle": SessionLifecycle::Parked,
4592                    "streaming": false,
4593                    "auto_turns": self.consecutive_auto_turns,
4594                    "attached": 0,
4595                    "pending_prompts": 0,
4596                    "pinned": self.keep_warm,
4597                }),
4598                SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4599                // A parked session has no live workers; a watch poll must not wake it.
4600                SessionQuery::SubagentWatch { handle_id, .. } => {
4601                    serde_json::json!({ "error": format!("no subagent {handle_id} in this session") })
4602                }
4603                _ => match self.ensure_live().await {
4604                    Ok(()) => return Box::pin(self.query(id, query)).await,
4605                    Err(e) => serde_json::json!({ "error": e }),
4606                },
4607            };
4608            self.emit(SessionEventWire::QueryResult { id, value });
4609            return;
4610        }
4611        let value = match query {
4612            SessionQuery::Status => {
4613                // Configured + resolved prompt-cache TTL (`auto` → 1h on OAuth).
4614                let cache_ttl = self.cache_ttl_status_json().await;
4615                serde_json::json!({
4616                    "session": self.conv.session.id,
4617                    "cache_ttl": cache_ttl,
4618                    "model": self.runtime.model(),
4619                    "lifecycle": SessionLifecycle::from_u8(self.lifecycle.load(std::sync::atomic::Ordering::Acquire)),
4620                    "tokens": { "input": self.conv.total_input_tokens, "output": self.conv.total_output_tokens },
4621                    "cost": self.conv.session_cost,
4622                    // Included in `cost`; labelled separately (task #444).
4623                    "keepwarm_cost": self.conv.keepwarm_cost,
4624                    "keepwarm": self.cache_keepwarm_status_json(None),
4625                    "messages": self.conv.api_messages.len(),
4626                    "streaming": self.streaming,
4627                    "auto_turns": self.consecutive_auto_turns,
4628                    "attached": self.attached.len(),
4629                    "pending_prompts": self.pending_prompts.len(),
4630                    // `/pin` status in the TUI.
4631                    "pinned": self.keep_warm,
4632                })
4633            }
4634            SessionQuery::Messages => {
4635                serde_json::to_value(&self.conv.api_messages).unwrap_or_default()
4636            }
4637            SessionQuery::DisplayTail { items } => {
4638                serde_json::to_value(super::display::display_tail(&self.conv.api_messages, items))
4639                    .unwrap_or_default()
4640            }
4641            SessionQuery::SubagentRows => {
4642                let rows = self
4643                    .runtime
4644                    .subagent_registry()
4645                    .lock()
4646                    .unwrap_or_else(|p| p.into_inner())
4647                    .display_rows();
4648                serde_json::Value::Array(
4649                    rows.iter()
4650                        .map(|r| {
4651                            serde_json::json!({
4652                                "subagent_id": r.subagent_id,
4653                                "agent_name": r.agent_name,
4654                                "status": format!("{:?}", r.status),
4655                                "cancel_requested": r.cancel_requested,
4656                                "elapsed_secs": r.elapsed_secs,
4657                            })
4658                        })
4659                        .collect(),
4660                )
4661            }
4662            SessionQuery::SubagentWatch { handle_id, since, tool_queue } => {
4663                let mut registry = self
4664                    .runtime
4665                    .subagent_registry()
4666                    .lock()
4667                    .unwrap_or_else(|p| p.into_inner());
4668                match registry.watch_page(&handle_id, since) {
4669                    // An older client did not ask for queue entries and
4670                    // could not decode a page that had them.
4671                    Some(page) if !tool_queue => {
4672                        serde_json::to_value(page.without_tool_queue()).unwrap_or_default()
4673                    }
4674                    Some(page) => serde_json::to_value(page).unwrap_or_default(),
4675                    None => serde_json::json!({
4676                        "error": format!("no subagent {handle_id} in this session")
4677                    }),
4678                }
4679            }
4680            SessionQuery::PromptInspection => {
4681                serde_json::to_value(self.runtime.prompt_inspection_json()).unwrap_or_default()
4682            }
4683            SessionQuery::ToolsSchema => serde_json::json!({ "unsupported": "tools_schema" }),
4684            SessionQuery::View => serde_json::to_value(&**self.view.load()).unwrap_or_default(),
4685            SessionQuery::ContextAssessment => {
4686                let a = self.runtime.assess_context(&self.conv.api_messages).await;
4687                serde_json::json!({
4688                    "used_tokens": a.used_tokens(),
4689                    "budget_tokens": a.budget_tokens(),
4690                    "provider_window": a.provider_window,
4691                    "should_compact": a.should_compact(),
4692                })
4693            }
4694            SessionQuery::ContextReport => {
4695                use crate::engine::commands::{context_command, CommandResult};
4696                match context_command(&self.runtime, Some(&self.conv.api_messages)) {
4697                    CommandResult::Output(text) => serde_json::json!({ "text": text }),
4698                    other => serde_json::json!({ "unsupported": format!("{other:?}") }),
4699                }
4700            }
4701        };
4702        self.emit(SessionEventWire::QueryResult { id, value });
4703    }
4704
4705    // ── attach / detach ──────────────────────────────────────────────────
4706
4707    pub(crate) fn snapshot(&self) -> AttachSnapshot {
4708        AttachSnapshot {
4709            meta: self.meta.clone(),
4710            view: (**self.view.load()).clone(),
4711            conversation: self.conv.snapshot(self.consecutive_auto_turns),
4712            streaming: self.streaming,
4713            replay: self.turn_replay.iter().cloned().collect(),
4714            pending_prompts: self.pending_prompts.iter().map(|(p, _)| p.clone()).collect(),
4715            clients: self.attached.iter().map(|(c, a)| (*c, a.meta.kind)).collect(),
4716            roster: self.roster(),
4717            client_input: self.client_input,
4718            input_owner: self.input_owner,
4719            display_tail: None,
4720        }
4721    }
4722
4723    /// `snapshot()` shaped for one client (phase 4 §2.3): `Digest` clients
4724    /// get an empty `api_messages` (`messages_len` kept), a daemon-projected
4725    /// `display_tail`, and a replay without the per-round `MessageHistory`
4726    /// envelopes (the trailing `Conversation` digest carries len/hash).
4727    pub(crate) fn snapshot_for(&self, client: &ClientMeta) -> AttachSnapshot {
4728        let mut snap = self.snapshot();
4729        if client.history == HistoryMode::Digest {
4730            snap.display_tail = Some(super::display::display_tail(
4731                &snap.conversation.api_messages,
4732                client.tail_items,
4733            ));
4734            snap.conversation.api_messages = Vec::new();
4735            snap.replay.retain(|e| {
4736                !matches!(
4737                    e.event,
4738                    SessionEventWire::Stream(StreamEvent::Session(SessionEvent::MessageHistory(_)))
4739                )
4740            });
4741        }
4742        snap
4743    }
4744
4745    /// B1 ownership: `Observe` never owns; `Mirror` owns iff nobody does
4746    /// (else read-only + notice); `Takeover` steals with
4747    /// `InputOwnerChanged{reason: Takeover}` to everyone (the old owner's
4748    /// client renders the toast).
4749    async fn attach(&mut self, client: ClientMeta, mode: AttachMode) {
4750        if let Err(e) = self.ensure_live().await {
4751            self.emit(SessionEventWire::AttachRefused { message: e });
4752            return;
4753        }
4754        let cid = ClientId(self.next_client_id);
4755        self.next_client_id += 1;
4756        let kind = client.kind;
4757        self.cache_keepwarm_note_attach(kind, mode);
4758        let snapshot_meta = client.clone();
4759        let name = resolve_client_name(
4760            client.name.as_deref(),
4761            kind,
4762            self.attached.values().map(|a| a.name.as_str()),
4763        );
4764        let attached_at_ms = chrono::Utc::now().timestamp_millis().max(0) as u64;
4765        self.attached.insert(
4766            cid,
4767            AttachedClient { meta: client, mode, name: name.clone(), attached_at_ms, last_input: 0 },
4768        );
4769        self.update_attach_state();
4770        let mut owner_change: Option<(Option<ClientId>, OwnerChangeReason)> = None;
4771        match mode {
4772            AttachMode::Observe => {}
4773            // Input owned elsewhere: the joiner learns it from its snapshot
4774            // (`AttachSnapshot::input_owned_elsewhere`), never from a notice
4775            // broadcast to every client.
4776            AttachMode::Mirror => {
4777                if self.input_owner.is_none() {
4778                    owner_change = Some((None, OwnerChangeReason::Attach));
4779                }
4780            }
4781            AttachMode::Takeover => {
4782                let reason = if self.input_owner.is_some() {
4783                    OwnerChangeReason::Takeover
4784                } else {
4785                    OwnerChangeReason::Attach
4786                };
4787                owner_change = Some((self.input_owner, reason));
4788            }
4789        }
4790        if let Some((from, reason)) = owner_change {
4791            self.input_owner = Some(cid);
4792            self.emit(SessionEventWire::InputOwnerChanged {
4793                from,
4794                to: Some(cid),
4795                reason,
4796            });
4797        }
4798        self.publish_presence();
4799        let snapshot = self.snapshot_for(&snapshot_meta);
4800        self.emit(SessionEventWire::Attached {
4801            client: cid,
4802            snapshot,
4803        });
4804        self.emit(SessionEventWire::ClientJoined { client: cid, kind, name, mode });
4805    }
4806
4807    /// B1 `/takeover`: the sending client takes input in place, as an
4808    /// `AttachMode::Takeover` attach would, without re-attaching. An
4809    /// `Observe` client is refused (it attached read-only on purpose); the
4810    /// owner, the host and unknown clients change nothing. Any turn keeps
4811    /// running: only who may drive the session changes.
4812    fn take_input(&mut self, from: Option<ClientId>) {
4813        let Some(c) = from else { return };
4814        let Some(mode) = self.attached.get(&c).map(|a| a.mode) else { return };
4815        if mode == AttachMode::Observe {
4816            self.emit(SessionEventWire::Refused {
4817                client: c,
4818                command: command_name(&SessionCommand::TakeInput).to_string(),
4819                reason: "this client attached with --observe and never owns input; \
4820                         attach without --observe to take it"
4821                    .to_string(),
4822            });
4823            return;
4824        }
4825        if self.input_owner == Some(c) {
4826            return;
4827        }
4828        let from = self.input_owner;
4829        let reason = if from.is_some() {
4830            OwnerChangeReason::Takeover
4831        } else {
4832            OwnerChangeReason::Attach
4833        };
4834        self.input_owner = Some(c);
4835        self.emit(SessionEventWire::InputOwnerChanged { from, to: Some(c), reason });
4836        self.publish_presence();
4837        self.note_input(c); // most recent, for promotion on detach
4838    }
4839
4840    /// Never touches `stream`/`cancel`: the turn keeps running and its
4841    /// events buffer in the broadcast (§8 detach-without-abort). The owner
4842    /// leaving passes input to the oldest attached non-`Observe` client.
4843    fn detach(&mut self, client: ClientId) {
4844        if self.attached.remove(&client).is_none() {
4845            return;
4846        }
4847        self.update_attach_state();
4848        self.emit(SessionEventWire::ClientLeft { client });
4849        if self.input_owner == Some(client) {
4850            // `shared`: back to whoever typed last (a script that sent one
4851            // prompt and left hands input back to the tab in use);
4852            // `exclusive`: the lowest id, as before. Ties: the lowest id.
4853            let shared = self.client_input == ClientInput::Shared;
4854            let next = self
4855                .attached
4856                .iter()
4857                .filter(|(_, a)| a.mode != AttachMode::Observe)
4858                .max_by_key(|(c, a)| (if shared { a.last_input } else { 0 }, std::cmp::Reverse(c.0)))
4859                .map(|(c, _)| *c);
4860            self.input_owner = next;
4861            self.emit(SessionEventWire::InputOwnerChanged {
4862                from: Some(client),
4863                to: next,
4864                reason: OwnerChangeReason::OwnerDetached,
4865            });
4866            self.publish_presence();
4867        }
4868        // (E-P7 §S1 / P11) The last client just left. Fail-closed gates apply
4869        // ONLY when a driver is (or was about to be) armed:
4870        //
4871        //  1. An armed driver is revoked: it must never run turns headless with
4872        //     nobody watching and no way to answer a confirmation.
4873        //  2. Its pending host confirmations are answered `None` (deny) — a
4874        //     headless autonomous run must not sit on an unanswerable prompt.
4875        //     `tools/discovery.rs` treats `None` as Unauthorized (deny).
4876        //
4877        // For a PLAIN interactive session (no driver), a pending prompt SURVIVES
4878        // detach: the user reattaches and answers it — the daemon's core
4879        // detach/reattach contract, and detach is often involuntary (SSH drop,
4880        // sleep, wifi). Denying on detach would let a transient disconnect
4881        // silently reject the user's action. The resource pin from an
4882        // *abandoned* prompt is bounded by the pending-prompt deadline instead
4883        // (see the actor select loop), not by punishing every detach.
4884        if self.attached.is_empty() && (self.driver.is_some() || self.driver_pending.is_some()) {
4885            self.driver_revoke("no clients attached");
4886            let had_prompts = !self.pending_prompts.is_empty();
4887            while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4888                let _ = tx.send(None);
4889                self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4890            }
4891            if had_prompts {
4892                self.publish_presence();
4893            }
4894        }
4895    }
4896
4897    /// B1 (used by C3 reload): checkpoint the session without ending it —
4898    /// cancel any turn (partial history kept + interruption marker), abort compaction, answer
4899    /// pending prompts `None`, save, close PTYs. Replies on
4900    /// `CHECKPOINT_QUERY_ID` so `reload.rs` can await it per session.
4901    pub(crate) async fn checkpoint(&mut self, reason: CheckpointReason) {
4902        let interrupt = match reason {
4903            CheckpointReason::Reload => crate::engine::interrupt::InterruptReason::Restart,
4904            CheckpointReason::HostRequest => crate::engine::interrupt::InterruptReason::Host,
4905        };
4906        // Before the revoke below cancels a driver turn.
4907        self.note_cancel_cause(interrupt);
4908        // E-P3: driver does NOT survive reload (§3 S2/S5).
4909        if self.driver.is_some() {
4910            self.driver_revoke("daemon reloaded");
4911        }
4912        // Workers cannot survive the exec either (they are threads of this
4913        // process). Flag + cancel them BEFORE `cancel_turn` (whose own
4914        // cancel would otherwise label them user-cancelled). This also
4915        // fences new launches: a worker the turn registers before its
4916        // cancel lands is stopped at birth as the reload's.
4917        let reload = matches!(reason, CheckpointReason::Reload);
4918        if reload {
4919            self.stop_workers_for_reload();
4920        }
4921        if self.streaming {
4922            self.cancel_turn(interrupt).await;
4923        }
4924        // Again after the turn cancel: every worker to await, including
4925        // any the cancelled turn registered meanwhile.
4926        let stopping = if reload { self.stop_workers_for_reload() } else { Vec::new() };
4927        let stopped_workers = await_stopped_workers(self.id.as_str(), stopping).await;
4928        self.abort_compaction();
4929        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
4930            let _ = tx.send(None);
4931            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
4932        }
4933        // One bounded wait covers the cancelled turn's save too (queued, not
4934        // awaited, by `cancel_turn`).
4935        if !self.save().await {
4936            tracing::warn!(session = %self.id, "checkpoint: save failed or timed out");
4937        }
4938        let notice = match reason {
4939            CheckpointReason::Reload => {
4940                "daemon reloading — background shells/PTYs will be closed; the turn was checkpointed"
4941            }
4942            CheckpointReason::HostRequest => {
4943                "checkpoint — background shells/PTYs closed; the turn was checkpointed"
4944            }
4945        };
4946        self.emit(SessionEventWire::SystemNotice(notice.to_string()));
4947        if !stopped_workers.is_empty() {
4948            self.emit(SessionEventWire::SystemNotice(format!(
4949                "daemon reloading — stopped worker{} {} (archived; resumable after the reload)",
4950                if stopped_workers.len() == 1 { "" } else { "s" },
4951                stopped_workers.join(", ")
4952            )));
4953        }
4954        self.runtime.session_manager().shutdown_all();
4955        let mut record = self.reload_record();
4956        if reload {
4957            record.workers = self.reload_carried_workers();
4958            // Recorded: from here to the exec, nothing may start work the
4959            // record does not carry (a queued wake must stay queued).
4960            self.reload_fence = Some(ReloadFence {
4961                deadline: tokio::time::Instant::now() + budgets::RELOAD_FENCE_TIMEOUT,
4962                workers: record.workers.clone(),
4963            });
4964        }
4965        let record = serde_json::to_value(record).unwrap_or_default();
4966        self.emit(SessionEventWire::QueryResult {
4967            id: super::wire::CHECKPOINT_QUERY_ID,
4968            value: serde_json::json!({ "ok": true, "record": record }),
4969        });
4970    }
4971
4972    /// The workers a reload carries for this session: every registry entry,
4973    /// plus whether a finished worker's completion wake is still queued
4974    /// (the exec discards the queue, so the next image re-posts it).
4975    pub(crate) fn reload_carried_workers(&self) -> Vec<crate::runtime::subagent::CarriedWorker> {
4976        let undelivered: std::collections::HashSet<String> = self
4977            .event_queue
4978            .snapshot()
4979            .iter()
4980            .filter(|e| e.content.content_type == "subagent_completion")
4981            .filter_map(|e| {
4982                e.content
4983                    .data
4984                    .as_ref()?
4985                    .get("handle_id")?
4986                    .as_str()
4987                    .map(str::to_string)
4988            })
4989            .collect();
4990        self.runtime
4991            .subagent_registry()
4992            .lock()
4993            .unwrap_or_else(|p| p.into_inner())
4994            .reload_carry(&undelivered)
4995    }
4996
4997    /// Host-only (`SessionCommand::RestoreToolActivations`, sent by
4998    /// reload's rehydrate): the activations this conversation held in the
4999    /// previous image. A live runtime takes them (restored at its first turn
5000    /// start); a parked session keeps them for the runtime its unpark
5001    /// builds, without unparking.
5002    pub(crate) fn restore_tool_activations(
5003        &mut self,
5004        activations: Vec<crate::tools::activation::CarriedActivation>,
5005    ) {
5006        if activations.is_empty() {
5007            return;
5008        }
5009        tracing::info!(
5010            session = %self.id,
5011            count = activations.len(),
5012            "reload: tool activations handed over from the previous image"
5013        );
5014        if self.runtime.is_live() {
5015            self.runtime.restore_tool_activations(activations);
5016        } else {
5017            for a in activations {
5018                if !self.parked_tool_activations.iter().any(|p| p.tool_id == a.tool_id) {
5019                    self.parked_tool_activations.push(a);
5020                }
5021            }
5022        }
5023    }
5024
5025    /// Host-only (`SessionCommand::AdoptWorkers`, sent by reload's
5026    /// rehydrate): the workers the previous daemon image stopped or still
5027    /// owed a wake. Their ids resolve to their archives again, and the
5028    /// parent is woken once per worker — it was waiting for completions the
5029    /// exec destroyed.
5030    pub(crate) fn adopt_workers(&mut self, workers: Vec<crate::runtime::subagent::CarriedWorker>) {
5031        self.adopt_carried_workers(workers, false);
5032    }
5033
5034    /// [`Self::adopt_workers`]; `failed_reload`: this image's own exec
5035    /// failed (`ReloadAborted`), so the notice must not say it reloaded.
5036    fn adopt_carried_workers(
5037        &mut self,
5038        workers: Vec<crate::runtime::subagent::CarriedWorker>,
5039        failed_reload: bool,
5040    ) {
5041        if workers.is_empty() {
5042            return;
5043        }
5044        {
5045            let mut registry = self
5046                .runtime
5047                .subagent_registry()
5048                .lock()
5049                .unwrap_or_else(|p| p.into_inner());
5050            for w in &workers {
5051                registry.adopt_carried(w);
5052                if w.needs_wake() {
5053                    // The next reload repeats this wake only while it is
5054                    // still queued.
5055                    registry.note_reload_wake(w);
5056                }
5057            }
5058        }
5059        let woken: Vec<&crate::runtime::subagent::CarriedWorker> =
5060            workers.iter().filter(|w| w.needs_wake()).collect();
5061        for w in &woken {
5062            let ev = crate::tools::build_reload_carry_event(w);
5063            if self.event_queue.push(ev.clone()).is_err() {
5064                self.event_queue.push_priority(ev);
5065            }
5066        }
5067        let stopped: Vec<&str> = woken
5068            .iter()
5069            .filter(|w| w.stopped_by_reload)
5070            .map(|w| w.id.as_str())
5071            .collect();
5072        tracing::info!(
5073            session = %self.id,
5074            carried = workers.len(),
5075            stopped = ?stopped,
5076            woken = woken.len(),
5077            "reload: adopted workers from the previous image"
5078        );
5079        if !stopped.is_empty() {
5080            self.emit(SessionEventWire::SystemNotice(format!(
5081                "{} — worker{} {} stopped by the reload; subagent_resume continues {} from {} archive",
5082                if failed_reload { "daemon reload failed" } else { "daemon reloaded" },
5083                if stopped.len() == 1 { "" } else { "s" },
5084                stopped.join(", "),
5085                if stopped.len() == 1 { "it" } else { "them" },
5086                if stopped.len() == 1 { "its" } else { "their" },
5087            )));
5088        }
5089    }
5090
5091    /// `stop_for_reload` on this session's registry (fences new launches).
5092    fn stop_workers_for_reload(
5093        &self,
5094    ) -> Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)> {
5095        self.runtime
5096            .subagent_registry()
5097            .lock()
5098            .unwrap_or_else(|p| p.into_inner())
5099            .stop_for_reload()
5100    }
5101
5102    /// Host-only (`SessionCommand::ReloadAborted`): the reload this session
5103    /// checkpointed for did not exec, so the old image keeps serving it.
5104    /// Lift the reload fence, hand the workers the checkpoint stopped back
5105    /// to the parent, and deliver what queued up meanwhile.
5106    pub(crate) async fn reload_aborted(&mut self) {
5107        self.lift_reload_fence(
5108            "daemon reload failed — this session continues on the running daemon",
5109        )
5110        .await;
5111    }
5112
5113    /// Lift the reload fence (see [`Self::reload_aborted`]), telling the
5114    /// user `notice` (the reload did not happen).
5115    async fn lift_reload_fence(&mut self, notice: &str) {
5116        let Some(fence) = self.reload_fence.take() else {
5117            return;
5118        };
5119        self.emit(SessionEventWire::SystemNotice(notice.to_string()));
5120        let workers = if self.runtime.is_live() {
5121            // From the LIVE registry, not only the checkpoint's snapshot: a
5122            // worker registered after it was stopped at birth by the
5123            // registry's reload fence and is owed its wake too. Snapshot
5124            // entries the registry no longer holds ride along.
5125            let mut carried = self.reload_carried_workers();
5126            carried.extend(
5127                fence
5128                    .workers
5129                    .into_iter()
5130                    .filter(|w| !carried.iter().any(|c| c.id == w.id))
5131                    .collect::<Vec<_>>(),
5132            );
5133            let mut registry = self
5134                .runtime
5135                .subagent_registry()
5136                .lock()
5137                .unwrap_or_else(|p| p.into_inner());
5138            registry.lift_reload_fence();
5139            // The queue the record assumed lost is still here: only the
5140            // reload's own stops owe a wake.
5141            registry.rewake_after_failed_exec(carried)
5142        } else {
5143            Vec::new()
5144        };
5145        tracing::info!(session = %self.id, "reload did not exec: fence lifted");
5146        self.adopt_carried_workers(workers, true);
5147        self.rearm_park();
5148        if !self.event_queue.is_empty() {
5149            self.on_queue_wake().await;
5150        }
5151    }
5152
5153    /// Backstop: no exec and no `ReloadAborted` within
5154    /// [`budgets::RELOAD_FENCE_TIMEOUT`] of the checkpoint.
5155    async fn reload_fence_expired(&mut self) {
5156        tracing::warn!(
5157            session = %self.id,
5158            "reload fence expired with no exec and no abort from the daemon; lifting it"
5159        );
5160        self.lift_reload_fence(
5161            "daemon reload never completed or reported back — this session continues on the running daemon",
5162        )
5163        .await;
5164    }
5165
5166    /// Abort the spawned job (`CompactionCancelled`); prior state intact.
5167    pub(crate) fn abort_compaction(&mut self) {
5168        if let Some(job) = self.compact.take() {
5169            job.task.abort();
5170            self.emit(SessionEventWire::CompactionCancelled);
5171            self.publish_presence();
5172        }
5173    }
5174
5175    // ── command dispatch ─────────────────────────────────────────────────
5176
5177    /// B1: a client-stamped input command from anyone but the owner is
5178    /// `Refused` with no side effect; `from: None` (host) bypasses.
5179    async fn handle(&mut self, addressed: Addressed) -> std::ops::ControlFlow<EndReason> {
5180        use std::ops::ControlFlow;
5181        let Addressed { from, cmd } = addressed;
5182        if let Some(c) = from {
5183            // `rename` is the daemon's control-connection rename; an
5184            // attached client has `/saveas` (owner only).
5185            if is_rename_command(&cmd) {
5186                self.emit(SessionEventWire::Refused {
5187                    client: c,
5188                    command: command_name(&cmd).to_string(),
5189                    reason: "rename is host-only; use /saveas".into(),
5190                });
5191                return ControlFlow::Continue(());
5192            }
5193            // Only the daemon's reload sends a reload checkpoint (it stops
5194            // every worker and fences the session for the exec after it).
5195            if matches!(cmd, SessionCommand::Checkpoint { reason: CheckpointReason::Reload }) {
5196                self.emit(SessionEventWire::Refused {
5197                    client: c,
5198                    command: command_name(&cmd).to_string(),
5199                    reason: "checkpoint(reload) is host-only; use `synaps daemon reload`".into(),
5200                });
5201                return ControlFlow::Continue(());
5202            }
5203            if let Some(reason) = is_input_command(&cmd).then(|| self.input_refusal(c)).flatten() {
5204                self.emit(SessionEventWire::Refused {
5205                    client: c,
5206                    command: command_name(&cmd).to_string(),
5207                    reason,
5208                });
5209                return ControlFlow::Continue(());
5210            }
5211        }
5212        // A reload checkpointed this session: nothing may start work or
5213        // change what the reload record captured until the exec (or until
5214        // the daemon reports it failed). The wake sources are fenced in the
5215        // select loop; commands are fenced here.
5216        if self.reload_fence.is_some() && cmd.refused_while_reloading() {
5217            // A host rename answers its caller only, never the whole room.
5218            if let SessionCommand::EngineCommand { id, name, .. } = &cmd {
5219                if name == RENAME_COMMAND {
5220                    self.emit(SessionEventWire::QueryResult {
5221                        id: *id,
5222                        value: serde_json::json!({
5223                            "kind": "error", "code": "busy",
5224                            "text": "daemon reloading; retry in a moment",
5225                        }),
5226                    });
5227                    return ControlFlow::Continue(());
5228                }
5229            }
5230            let reason = super::RELOADING_REFUSAL.to_string();
5231            match from {
5232                Some(c) => self.emit(SessionEventWire::Refused {
5233                    client: c,
5234                    command: command_name(&cmd).to_string(),
5235                    reason,
5236                }),
5237                None => self.emit(SessionEventWire::SystemNotice(reason)),
5238            }
5239            return ControlFlow::Continue(());
5240        }
5241        // Accepted input: with `client_input = shared` its sender becomes the
5242        // active client. `KeepWarm` doesn't count: `--keep-warm` sends it
5243        // on attach, not a person typing.
5244        if let Some(c) = from {
5245            if is_input_command(&cmd) && !matches!(cmd, SessionCommand::KeepWarm { .. }) {
5246                self.note_input(c);
5247            }
5248        }
5249        // A host rename (`synaps daemon rename`): handled here for live and
5250        // parked sessions alike. A parked one is renamed on disk and NOT
5251        // woken (`host_rename`), so this runs before the B3 wake below.
5252        if is_rename_command(&cmd) {
5253            if let SessionCommand::EngineCommand { id, arg, .. } = cmd {
5254                let value = self.host_rename(&arg).await;
5255                self.emit(SessionEventWire::QueryResult { id, value });
5256            }
5257            return ControlFlow::Continue(());
5258        }
5259        // B3: anything that needs the runtime wakes a parked session first.
5260        if self.is_parked() {
5261            let needs_runtime = !matches!(
5262                cmd,
5263                SessionCommand::Attach { .. }
5264                    | SessionCommand::Detach { .. }
5265                    | SessionCommand::End { .. }
5266                    | SessionCommand::Query { .. }
5267                    | SessionCommand::Answer { .. }
5268                    | SessionCommand::Save
5269                    | SessionCommand::Cancel
5270                    | SessionCommand::Checkpoint { .. }
5271                    | SessionCommand::Park
5272                    | SessionCommand::ReloadAborted
5273                    | SessionCommand::ArmPark
5274                    | SessionCommand::RestoreToolActivations { .. }
5275                    | SessionCommand::Resync { .. }
5276                    | SessionCommand::HostEvent(_)
5277                    | SessionCommand::TakeInput
5278            ) && !is_close_command(&cmd) // closing a parked session must not rebuild it
5279                // Unpinning a parked session only clears the flag; pinning
5280                // one wakes it (a pinned session is live by definition).
5281                && !matches!(cmd, SessionCommand::KeepWarm { on: false });
5282            if matches!(cmd, SessionCommand::HostEvent(_)) {
5283                return ControlFlow::Continue(()); // nobody attached, nothing to render
5284            }
5285            if matches!(
5286                cmd,
5287                SessionCommand::Save
5288                    | SessionCommand::Cancel
5289                    | SessionCommand::Park
5290                    | SessionCommand::ArmPark
5291                    | SessionCommand::Checkpoint { .. }
5292            ) {
5293                if let SessionCommand::Checkpoint { .. } = cmd {
5294                    let record = serde_json::to_value(self.reload_record()).unwrap_or_default();
5295                    self.emit(SessionEventWire::QueryResult {
5296                        id: super::wire::CHECKPOINT_QUERY_ID,
5297                        value: serde_json::json!({ "ok": true, "parked": true, "record": record }),
5298                    });
5299                }
5300                return ControlFlow::Continue(()); // already on disk / idle
5301            }
5302            if needs_runtime {
5303                if let Err(e) = self.ensure_live().await {
5304                    match from {
5305                        Some(c) => self.emit(SessionEventWire::Refused {
5306                            client: c,
5307                            command: command_name(&cmd).to_string(),
5308                            reason: e,
5309                        }),
5310                        None => self.emit(SessionEventWire::SystemNotice(e)),
5311                    }
5312                    return ControlFlow::Continue(());
5313                }
5314            }
5315        }
5316        match cmd {
5317            SessionCommand::Submit { text, attachments } => self.submit(text, attachments, from).await,
5318            SessionCommand::Steer { text } => {
5319                if self.streaming {
5320                    self.steer(text)
5321                } else {
5322                    self.submit(text, vec![], from).await
5323                }
5324            }
5325            SessionCommand::SubagentSteer { handle_id, text } => {
5326                self.steer_subagent(from, &handle_id, text)
5327            }
5328            SessionCommand::Cancel => {
5329                // The user's cancel, even of a driver's turn: noted before
5330                // the revoke below cancels that turn.
5331                self.note_cancel_cause(crate::engine::interrupt::InterruptReason::User);
5332                if self.driver.is_some() {
5333                    self.driver_revoke("canceled");
5334                }
5335                self.cancel_turn(crate::engine::interrupt::InterruptReason::User)
5336                    .await;
5337            }
5338            SessionCommand::Answer { prompt_id, value } => self.answer(from, prompt_id, value),
5339            SessionCommand::Set { id, setting } => self.apply_setting(id, setting).await,
5340            // `CompactionStarted` is the contract; no notice before it.
5341            SessionCommand::Compact { instructions } => {
5342                self.compact(instructions, "manual").await;
5343            }
5344            SessionCommand::NewSession => {
5345                if self.driver.is_some() {
5346                    self.driver_revoke("session replaced");
5347                }
5348                // A turn still waiting for `on_session_start` would open its
5349                // stream later on the cleared history, under the new journal
5350                // id: cancel it first, so it ends (and is saved) in the old
5351                // session with nothing sent.
5352                if self.turn_awaiting_start.is_some() {
5353                    self.cancel_turn(crate::engine::interrupt::InterruptReason::User).await;
5354                }
5355                // Before `clear` saves the old session: a ping in flight is
5356                // aborted and booked to it, not to the new one.
5357                self.cache_keepwarm_conversation_replaced();
5358                // `clear` saves the old session directly: queued saves of it
5359                // must land first, never after.
5360                self.persister.flush().await;
5361                self.conv.clear(&self.runtime).await;
5362                self.runtime
5363                    .begin_conversation(Some(self.conv.session.id.clone()));
5364                self.set_journal_id(self.conv.session.id.clone());
5365                // F10: lock follows the new session id.
5366                self.reacquire_session_lock(&self.conv.session.id.clone());
5367                self.emit(SessionEventWire::Cleared {
5368                    session_id: self.conv.session.id.clone(),
5369                });
5370                self.emit_conversation();
5371            }
5372            SessionCommand::Save => {
5373                self.save().await;
5374            }
5375            SessionCommand::Query { id, query } => self.query(id, query).await,
5376            // `/close` ends the session, which no other engine command can.
5377            SessionCommand::EngineCommand { id, name, arg } if name == CLOSE_COMMAND => {
5378                if let Some(reason) = self.close_command(id, &arg) {
5379                    return ControlFlow::Break(reason);
5380                }
5381            }
5382            SessionCommand::EngineCommand { id, name, arg } => {
5383                self.engine_command(id, name, arg).await
5384            }
5385            SessionCommand::Attach { client, mode } => self.attach(client, mode).await,
5386            SessionCommand::Detach { client } => {
5387                self.detach(client);
5388                // Nobody left to notice a stale period: confirm the prefix
5389                // now rather than at the next ping, so a session whose next
5390                // request already changed is free to park.
5391                if self.attached.is_empty() && self.cache_keepwarm.active() {
5392                    self.cache_keepwarm_recheck().await;
5393                }
5394            }
5395            SessionCommand::End { reason } => {
5396                // `finish` cancels a running turn for this reason; note it
5397                // before the revoke below cancels a driver turn first.
5398                self.note_cancel_cause(match reason {
5399                    EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5400                    _ => crate::engine::interrupt::InterruptReason::Host,
5401                });
5402                if self.driver.is_some() {
5403                    self.driver_revoke("session ending");
5404                }
5405                return ControlFlow::Break(reason);
5406            }
5407            SessionCommand::Resync { .. } => self.emit(SessionEventWire::SystemNotice(
5408                "resync not supported yet".into(),
5409            )),
5410            // A3 bodies live in actor_cmds.rs; B1 (Checkpoint), B3 (KeepWarm)
5411            // fill in the rest.
5412            SessionCommand::SubmitPrepared {
5413                messages,
5414                user_text,
5415            } => self.submit_prepared(messages, user_text).await,
5416            SessionCommand::PluginCommand {
5417                id,
5418                plugin,
5419                name,
5420                arg,
5421            } => self.plugin_command(id, plugin, name, arg).await,
5422            SessionCommand::Resume { id, query } => self.resume(id, query).await,
5423            SessionCommand::Checkpoint { reason } => self.checkpoint(reason).await,
5424            SessionCommand::AdoptWorkers { workers } => self.adopt_workers(workers),
5425            SessionCommand::RestoreToolActivations { activations } => {
5426                self.restore_tool_activations(activations)
5427            }
5428            SessionCommand::ReloadAborted => self.reload_aborted().await,
5429            // Parked sessions returned above (their evict deadline stands).
5430            SessionCommand::ArmPark => self.rearm_park(),
5431            SessionCommand::Park => {
5432                // Explicit request: end an active keep-warm period, then park.
5433                self.cache_keepwarm_park_requested();
5434                if let std::ops::ControlFlow::Break(reason) = self.park().await {
5435                    return ControlFlow::Break(reason);
5436                }
5437            }
5438            SessionCommand::TakeInput => self.take_input(from),
5439            SessionCommand::KeepWarm { on } => {
5440                self.keep_warm = on;
5441                self.meta.pinned = on; // the attach snapshot's copy
5442                self.keep_warm_cell.store(on, std::sync::atomic::Ordering::Release);
5443                self.note_pin();
5444                // Unpinned with nobody who ever typed: back to the old rule.
5445                if !on && !self.cache_keepwarm.interactive_seen && self.cache_keepwarm.active() {
5446                    self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::Disabled);
5447                }
5448                self.rearm_park();
5449                self.emit(SessionEventWire::SystemNotice(if on {
5450                    PIN_ON_NOTICE.to_string()
5451                } else {
5452                    PIN_OFF_NOTICE.to_string()
5453                }));
5454            }
5455            SessionCommand::HostEvent(ev) => match ev {
5456                HostEvent::ExtensionNotification {
5457                    extension_id,
5458                    method,
5459                    params,
5460                } => self.emit(SessionEventWire::ExtensionNotification {
5461                    extension_id,
5462                    method,
5463                    params,
5464                }),
5465                HostEvent::LoaderProgress(ev) => self.emit(SessionEventWire::LoaderProgress(ev)),
5466            },
5467            // E-P3: driver start handler.
5468            SessionCommand::DriverStart { plugin, command, arg } => {
5469                self.driver_start(plugin, command, arg);
5470            }
5471        }
5472        ControlFlow::Continue(())
5473    }
5474
5475    // ── teardown (tui/mod.rs:352-432 + chat.rs shutdown) ─────────────────
5476
5477    async fn finish(&mut self, reason: EndReason) {
5478        // Ended on purpose (`/close`, `daemon close`, a client's quit): the
5479        // pin goes with it. A daemon stop, reload or crash keeps it.
5480        if self.keep_warm && reason == EndReason::ClientQuit {
5481            self.keep_warm = false;
5482            self.note_pin();
5483        }
5484        self.cache_keepwarm_end(super::cache_keepwarm::PeriodEnd::SessionEnding);
5485        // Before on_session_end clears the keyed injection (STEP 2): a hook
5486        // not yet called leaves the line; one in flight finishes holding the
5487        // gate and drops what it injects (`SessionStart::end`).
5488        self.session_start.end();
5489        self.lifecycle.store(
5490            SessionLifecycle::Ending as u8,
5491            std::sync::atomic::Ordering::Release,
5492        );
5493        // Quitting the client mid-turn is the user's interruption; every
5494        // other end reason is the host's.
5495        let interrupt = match reason {
5496            EndReason::ClientQuit => crate::engine::interrupt::InterruptReason::User,
5497            _ => crate::engine::interrupt::InterruptReason::Host,
5498        };
5499        // Before the revoke below cancels a driver turn.
5500        self.note_cancel_cause(interrupt);
5501        // E-P3: clean up driver before teardown.
5502        if self.driver.is_some() {
5503            self.driver_revoke("session ending");
5504        }
5505        if self.streaming {
5506            self.cancel_turn(interrupt).await;
5507        }
5508        self.abort_compaction();
5509        // Outstanding prompts are cancelled (tool sees `None`).
5510        while let Some((pr, tx)) = self.pending_prompts.pop_front() {
5511            let _ = tx.send(None);
5512            self.emit(SessionEventWire::PromptResolved { prompt_id: pr.id });
5513        }
5514
5515        let parked = !self.conv.is_live();
5516        let session_id = (**self.journal_id.load()).clone();
5517        let api_messages = if parked {
5518            None
5519        } else {
5520            Some(self.conv.api_messages.clone())
5521        };
5522
5523        // STEP 1: save — own bounded budget, highest priority. A parked
5524        // session is already on disk: end record only. The wait covers
5525        // everything queued, including the cancelled turn's save.
5526        let persist = self.config.persist;
5527        if !parked {
5528            self.request_save();
5529        }
5530        let persister = &self.persister;
5531        let save_fut = async {
5532            if persist {
5533                if !persister.flush().await {
5534                    tracing::warn!(session = %session_id, "session end: save failed");
5535                }
5536                let mut index_record =
5537                    crate::core::session_index::SessionIndexRecord::end(&session_id);
5538                index_record.turns = api_messages.as_ref().map(|m| m.len());
5539                if let Err(err) = crate::core::session_index::append_record(&index_record) {
5540                    tracing::warn!("failed to append session end index record: {}", err);
5541                }
5542            }
5543        };
5544        if tokio::time::timeout(budgets::SAVE_TIMEOUT, save_fut)
5545            .await
5546            .is_err()
5547        {
5548            tracing::warn!(
5549                budget_secs = budgets::SAVE_TIMEOUT_SECS,
5550                "session save timed out — data may be incomplete"
5551            );
5552        }
5553
5554        // STEP 2 (C2): on_session_end — per session, concurrent, fail-open,
5555        // own budget; clears this session's keyed injection.
5556        let hook_bus = Arc::clone(&self.hook_bus);
5557        crate::extensions::loader::emit_session_end(
5558            &hook_bus,
5559            &session_id,
5560            api_messages,
5561            budgets::HOOKS_TIMEOUT,
5562        )
5563        .await;
5564
5565        // STEP 3: bounded observability flush (live only).
5566        if self.runtime.is_live() {
5567            if let Some(outcome) = self
5568                .runtime
5569                .shutdown_observability_async(
5570                    crate::runtime::telemetry::DEFAULT_SHUTDOWN_FLUSH_TIMEOUT,
5571                )
5572                .await
5573            {
5574                if !outcome.is_flushed() {
5575                    tracing::warn!(
5576                        stats = ?outcome.stats(),
5577                        "observability flush timed out — detached worker keeps draining"
5578                    );
5579                }
5580            }
5581        }
5582
5583        // Inbox watcher, per-session UDS, registry entry, keyed injection.
5584        self.background.shutdown();
5585        self.emit(SessionEventWire::Ended { reason });
5586    }
5587
5588    pub fn id(&self) -> &SessionId {
5589        &self.id
5590    }
5591}
5592
5593/// The tokio task. `SessionTask::run` is the reactor loop.
5594pub struct SessionTask(SessionActor);
5595
5596async fn next_stream_event(stream: &mut Option<ActiveStream>) -> Option<StreamEvent> {
5597    match stream {
5598        Some(s) => s.next().await,
5599        None => std::future::pending().await,
5600    }
5601}
5602
5603/// The conversation's title for listings: the saved title, else what
5604/// `Session::auto_title` will make of the first user message.
5605fn conversation_title(conv: &ConversationState) -> Option<String> {
5606    if !conv.session.title.is_empty() {
5607        return Some(conv.session.title.clone());
5608    }
5609    conv.api_messages
5610        .iter()
5611        .filter(|m| m["role"].as_str() == Some("user"))
5612        .find_map(|m| agent_core::core::session::user_content_for_display(&m["content"]))
5613        .map(|t| t.chars().take(80).collect())
5614}
5615
5616/// Whether the 1 Hz `SubagentRows` tick keeps running: always during a turn,
5617/// and after it while any worker is still running (a background worker's
5618/// progress events rode the ended turn's stream; the rows carry it now).
5619fn keep_subagent_tick(streaming: bool, any_running: bool) -> bool {
5620    streaming || any_running
5621}
5622
5623/// Which running workers an interrupted turn cancels. Only the user's own
5624/// interrupt of a live session is turn-scoped: Esc stops what that turn
5625/// started, not reactive workers inherited from earlier turns (which the
5626/// user may be waiting on). Quitting/teardown (`ending`), the cost cap, host
5627/// checkpoints/restarts and driver revocation stop every running worker.
5628fn worker_cancel_scope(
5629    reason: crate::engine::interrupt::InterruptReason,
5630    ending: bool,
5631    turn_generation: u64,
5632) -> crate::runtime::subagent::WorkerCancelScope {
5633    use crate::engine::interrupt::InterruptReason;
5634    use crate::runtime::subagent::WorkerCancelScope;
5635    match reason {
5636        InterruptReason::User if !ending => WorkerCancelScope::Turn(turn_generation),
5637        _ => WorkerCancelScope::All,
5638    }
5639}
5640
5641async fn next_tick(tick: &mut Option<tokio::time::Interval>) {
5642    match tick {
5643        Some(t) => {
5644            t.tick().await;
5645        }
5646        None => std::future::pending().await,
5647    }
5648}
5649
5650async fn poll_compaction(
5651    job: &mut Option<CompactionJob>,
5652) -> std::result::Result<Result<crate::runtime::compaction::CompactionOutcome>, tokio::task::JoinError> {
5653    match job {
5654        Some(j) => (&mut j.task).await,
5655        None => std::future::pending().await,
5656    }
5657}
5658
5659async fn park_timer(deadline: Option<tokio::time::Instant>) {
5660    match deadline {
5661        Some(t) => tokio::time::sleep_until(t).await,
5662        None => std::future::pending().await,
5663    }
5664}
5665
5666/// `revoked_turn_deadline`: same shape as `park_timer`.
5667async fn revoked_turn_timer(deadline: Option<tokio::time::Instant>) {
5668    match deadline {
5669        Some(t) => tokio::time::sleep_until(t).await,
5670        None => std::future::pending().await,
5671    }
5672}
5673
5674/// (P11) Prompt-abandonment deadline: same shape as `park_timer`. `None` =
5675/// disabled (no abandoned prompt, or the feature is off) → pends forever.
5676async fn prompt_abandon_timer(deadline: Option<tokio::time::Instant>) {
5677    match deadline {
5678        Some(t) => tokio::time::sleep_until(t).await,
5679        None => std::future::pending().await,
5680    }
5681}
5682
5683/// The reload fence's backstop deadline; pends forever when unfenced.
5684async fn reload_fence_timer(deadline: Option<tokio::time::Instant>) {
5685    match deadline {
5686        Some(t) => tokio::time::sleep_until(t).await,
5687        None => std::future::pending().await,
5688    }
5689}
5690
5691/// 200 ms cadence, gated on driver state — `None` driver+pending → pending forever.
5692async fn driver_tick_timer(
5693    has_driver: bool,
5694    has_pending: bool,
5695    interval: &mut tokio::time::Interval,
5696) {
5697    if has_driver || has_pending {
5698        interval.tick().await;
5699    } else {
5700        std::future::pending().await
5701    }
5702}
5703
5704/// How long a session waits for process-level extension discovery before
5705/// `on_session_start`: `startup.extensions_ready_timeout_secs`, or the
5706/// budget constant when that is 0.
5707fn extensions_ready_timeout(config: &crate::SynapsConfig) -> Duration {
5708    match config.startup.extensions_ready_timeout_secs {
5709        0 => budgets::EXTENSIONS_READY_TIMEOUT,
5710        secs => Duration::from_secs(secs),
5711    }
5712}
5713
5714/// This session's `on_session_start` task, while it is in line or running.
5715async fn session_start_task(
5716    start: &mut super::session_start::SessionStart,
5717) -> std::result::Result<bool, tokio::task::JoinError> {
5718    match start {
5719        super::session_start::SessionStart::Queued { task, .. } => task.await,
5720        _ => std::future::pending().await,
5721    }
5722}
5723
5724impl SessionTask {
5725    pub fn id(&self) -> &SessionId {
5726        self.0.id()
5727    }
5728
5729    /// Test seam: this session's subagent registry, to seed fake workers
5730    /// before `run()` (see `EngineHost::create_session_unspawned`).
5731    #[cfg(any(test, feature = "testing"))]
5732    pub fn subagent_registry(
5733        &self,
5734    ) -> Arc<std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>> {
5735        Arc::clone(self.0.runtime.subagent_registry())
5736    }
5737
5738    /// Test seam: a clone of this session's runtime. Clones share the
5739    /// memory-consent state, so a harness can revoke consent the way the
5740    /// `memory_context` tool's capability does (`memory_context_disable`).
5741    #[cfg(any(test, feature = "testing"))]
5742    pub fn runtime_for_harness(&self) -> crate::Runtime {
5743        (*self.0.runtime).clone()
5744    }
5745
5746    /// Test seam: this session's event queue (what a worker's finalizer
5747    /// pushes its completion wake into).
5748    #[cfg(any(test, feature = "testing"))]
5749    pub fn event_queue(&self) -> Arc<crate::events::EventQueue> {
5750        Arc::clone(&self.0.event_queue)
5751    }
5752
5753    /// Unbiased select (the TUI loop is unbiased too).
5754    pub async fn run(mut self) {
5755        let queue = Arc::clone(&self.0.event_queue);
5756        let reason = loop {
5757            let actor = &mut self.0;
5758            tokio::select! {
5759                cmd = actor.cmd_rx.recv() => match cmd {
5760                    Some(cmd) => {
5761                        if let std::ops::ControlFlow::Break(reason) = actor.handle(cmd).await {
5762                            break reason;
5763                        }
5764                    }
5765                    // Every handle dropped: nobody can ever reach us again.
5766                    None => break EndReason::HostShutdown,
5767                },
5768                Some(req) = actor.secret_prompt_rx.recv() => actor.on_prompt_request(req),
5769                // A held wake (and its permit) stays queued.
5770                _ = queue.notified(), if !actor.holds(WorkSource::QueueWake) => actor.on_queue_wake().await,
5771                _ = next_tick(&mut actor.subagent_tick) => {
5772                    actor.publish_subagent_rows();
5773                    actor.flush_turn_draft();
5774                }
5775                _ = park_timer(actor.park_deadline), if !actor.holds(WorkSource::ParkTimer) => {
5776                    if let std::ops::ControlFlow::Break(reason) = actor.park().await {
5777                        break reason;
5778                    }
5779                }
5780                _ = prompt_abandon_timer(actor.prompt_abandon_deadline) => {
5781                    actor.on_prompt_abandon_deadline();
5782                }
5783                _ = super::cache_keepwarm::keepwarm_timer(actor.cache_keepwarm.deadline()), if !actor.holds(WorkSource::KeepWarmTimer) => {
5784                    actor.on_cache_keepwarm_due().await;
5785                }
5786                res = super::cache_keepwarm::poll_keepwarm_ping(&mut actor.cache_keepwarm.task) => {
5787                    actor.on_cache_keepwarm_done(res);
5788                }
5789                res = poll_compaction(&mut actor.compact) => actor.on_compaction_done(res).await,
5790                ran = session_start_task(&mut actor.session_start) => {
5791                    actor.session_start_finished(ran).await;
5792                },
5793                _ = driver_tick_timer(actor.driver.is_some(), actor.driver_pending.is_some(), &mut actor.driver_tick_interval), if !actor.holds(WorkSource::DriverTick) => {
5794                    actor.driver_tick().await;
5795                }
5796                _ = reload_fence_timer(actor.reload_fence.as_ref().map(|f| f.deadline)) => {
5797                    actor.reload_fence_expired().await;
5798                }
5799                _ = revoked_turn_timer(actor.revoked_turn_deadline) => {
5800                    actor.finish_revoked_turn(false).await;
5801                }
5802                ev = next_stream_event(&mut actor.stream) => match ev {
5803                    Some(ev) => actor.on_stream_event(ev).await,
5804                    // A revoked turn's stream ending is its end.
5805                    None if actor.turn_cancelled() => actor.finish_revoked_turn(true).await,
5806                    None => {
5807                        // Stream ended without a terminal event: defensive reset.
5808                        // P5: capture_terminal(None) = EOF → revoke driver.
5809                        if actor.driver.as_ref().is_some_and(|d| d.awaiting_terminal) {
5810                            let terminal = super::driver::capture_terminal(None, false);
5811                            if let Some(terminal) = terminal {
5812                                if let Some(driver) = actor.driver.as_mut() {
5813                                    let reason = super::driver::observe_terminal(driver, &actor.runtime, terminal);
5814                                    // F-NEW-1 (shady): EOF is a Blocked terminal.
5815                                    // Emit a DriverTurnOutcome before revoking so a
5816                                    // client tracking outcomes sees the same
5817                                    // outcome+revoke pair the Error path produces —
5818                                    // not a turn that silently vanishes.
5819                                    let sel = driver.selection.clone();
5820                                    let fb = driver.grant.feedback_enabled()
5821                                        .then(|| driver.completed_feedback.to_string());
5822                                    actor.emit(SessionEventWire::DriverTurnOutcome {
5823                                        outcome: crate::extensions::session_driver::Outcome::Blocked,
5824                                        selection: sel,
5825                                        feedback: fb,
5826                                    });
5827                                    if let Some(reason) = reason {
5828                                        actor.driver_revoke(&reason);
5829                                    }
5830                                }
5831                            }
5832                        }
5833                        actor.clear_stream();
5834                        actor.emit_conversation();
5835                        actor.announce_idle().await;
5836                    }
5837                },
5838            }
5839        };
5840        self.0.finish(reason).await;
5841    }
5842}
5843
5844/// Wait (bounded by [`budgets::WORKER_RELOAD_STOP_TIMEOUT`]) until every worker
5845/// `stop_for_reload` returned has settled its finalizer: a stopped worker's
5846/// resume archive is on disk and its in-flight tool was dropped, which kills
5847/// that tool's process group (after the exec nothing could); a worker that
5848/// finished on its own has pushed its completion wake into the queue the
5849/// reload carries. Returns the ids the reload stopped.
5850async fn await_stopped_workers(
5851    session: &str,
5852    stopping: Vec<(String, Arc<std::sync::RwLock<crate::runtime::subagent::SubagentState>>)>,
5853) -> Vec<String> {
5854    if stopping.is_empty() {
5855        return Vec::new();
5856    }
5857    let ids: Vec<String> = stopping.iter().map(|(id, _)| id.clone()).collect();
5858    tracing::info!(session, workers = ?ids, "reload: stopping running workers");
5859    let deadline = tokio::time::Instant::now() + budgets::WORKER_RELOAD_STOP_TIMEOUT;
5860    loop {
5861        let pending: Vec<&str> = stopping
5862            .iter()
5863            .filter(|(_, s)| !s.read().unwrap_or_else(|p| p.into_inner()).completion_settled)
5864            .map(|(id, _)| id.as_str())
5865            .collect();
5866        if pending.is_empty() {
5867            break;
5868        }
5869        if tokio::time::Instant::now() >= deadline {
5870            tracing::warn!(
5871                session,
5872                workers = ?pending,
5873                "reload: workers did not stop in time; only their last archive survives the exec"
5874            );
5875            break;
5876        }
5877        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
5878    }
5879    stopping
5880        .iter()
5881        .filter(|(_, s)| s.read().unwrap_or_else(|p| p.into_inner()).stopped_by_reload)
5882        .map(|(id, _)| id.clone())
5883        .collect()
5884}
5885
5886#[cfg(test)]
5887mod worker_cancel_scope_tests {
5888    use super::worker_cancel_scope;
5889    use crate::engine::interrupt::InterruptReason;
5890    use crate::runtime::subagent::WorkerCancelScope;
5891
5892    #[test]
5893    fn only_a_live_user_interrupt_is_turn_scoped() {
5894        assert_eq!(
5895            worker_cancel_scope(InterruptReason::User, false, 7),
5896            WorkerCancelScope::Turn(7),
5897            "Esc stops the interrupted turn's workers only"
5898        );
5899        assert_eq!(
5900            worker_cancel_scope(InterruptReason::User, true, 7),
5901            WorkerCancelScope::All,
5902            "quitting the client mid-turn tears every worker down"
5903        );
5904        for reason in [
5905            InterruptReason::CostCap,
5906            InterruptReason::Restart,
5907            InterruptReason::Host,
5908            InterruptReason::Driver,
5909        ] {
5910            assert_eq!(
5911                worker_cancel_scope(reason, false, 7),
5912                WorkerCancelScope::All,
5913                "{reason:?} stops every running worker"
5914            );
5915        }
5916    }
5917}
5918
5919#[cfg(test)]
5920mod subagent_tick_tests {
5921    use super::keep_subagent_tick;
5922
5923    #[test]
5924    fn rows_keep_flowing_after_the_turn_while_a_worker_runs() {
5925        assert!(keep_subagent_tick(true, false), "during a turn, always");
5926        assert!(keep_subagent_tick(true, true));
5927        assert!(
5928            keep_subagent_tick(false, true),
5929            "after the turn, while a background worker runs"
5930        );
5931        assert!(
5932            !keep_subagent_tick(false, false),
5933            "stops once the turn is over and no worker runs"
5934        );
5935    }
5936}
5937
5938#[cfg(test)]
5939mod parked_evict_tests {
5940    use super::parked_evict_after_from;
5941    use super::prompt_abandon_timeout_from;
5942    use std::time::Duration;
5943
5944    #[test]
5945    fn parked_evict_defaults_to_one_hour_and_honours_never() {
5946        // cfg_secs = 3600 mirrors the DaemonConfig default.
5947        let d = 3600u64;
5948        assert_eq!(parked_evict_after_from(None, d), Some(Duration::from_secs(3600)));
5949        assert_eq!(parked_evict_after_from(Some("120"), d), Some(Duration::from_secs(120)));
5950        for never in ["never", "0", "off"] {
5951            assert_eq!(parked_evict_after_from(Some(never), d), None, "{never:?}");
5952        }
5953        assert_eq!(parked_evict_after_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5954        // env absent → config wins; config 0 disables; env still beats config.
5955        assert_eq!(parked_evict_after_from(None, 42), Some(Duration::from_secs(42)));
5956        assert_eq!(parked_evict_after_from(None, 0), None);
5957        assert_eq!(parked_evict_after_from(Some("7"), 42), Some(Duration::from_secs(7)));
5958    }
5959
5960    #[test]
5961    fn prompt_abandon_defaults_to_one_hour_and_honours_never() {
5962        // cfg_secs = 3600 mirrors the DaemonConfig default.
5963        let d = 3600u64;
5964        assert_eq!(prompt_abandon_timeout_from(None, d), Some(Duration::from_secs(3600)));
5965        assert_eq!(prompt_abandon_timeout_from(Some("30"), d), Some(Duration::from_secs(30)));
5966        // Disabled sentinels ⇒ None ⇒ pure pre-#112 (prompt survives forever).
5967        for never in ["never", "0", "off"] {
5968            assert_eq!(prompt_abandon_timeout_from(Some(never), d), None, "{never:?}");
5969        }
5970        // Garbage falls back to the safe default rather than disabling the guard.
5971        assert_eq!(prompt_abandon_timeout_from(Some("junk"), d), Some(Duration::from_secs(3600)));
5972        // env absent → config wins; config 0 disables; env still beats config.
5973        assert_eq!(prompt_abandon_timeout_from(None, 42), Some(Duration::from_secs(42)));
5974        assert_eq!(prompt_abandon_timeout_from(None, 0), None);
5975        assert_eq!(prompt_abandon_timeout_from(Some("7"), 42), Some(Duration::from_secs(7)));
5976    }
5977}