Skip to main content

agent_engine/session/
driver.rs

1//! Actor-side session driver state machine (E §2).
2//!
3//! `DriverState` lives on `SessionActor.driver`. It observes `StreamEvent`s the
4//! actor already receives and issues the actor's own `start_turn`/`steer`/`cancel`.
5//! Nothing here touches a client, a terminal, or `App`. Clients see only
6//! `SessionEventWire::Driver*` events.
7
8use std::collections::VecDeque;
9use std::sync::Arc;
10use std::time::{Duration, Instant};
11
12use crate::extensions::feedback;
13use crate::extensions::runtime::{ExtensionHandler, ExtensionHealth};
14use crate::extensions::session_driver::{
15    Grant, Outcome, PollRequest, PrepareError, Proposal, Reply, Selection,
16};
17use crate::{CancellationToken, Runtime, SharedMessage};
18
19type Workers = Arc<std::sync::Mutex<crate::runtime::subagent::SubagentRegistry>>;
20type Manager = Arc<tokio::sync::RwLock<crate::extensions::manager::ExtensionManager>>;
21
22pub(crate) fn cancel_workers(workers: &Workers, epoch: u64) {
23    workers
24        .lock()
25        .unwrap_or_else(|p| p.into_inner())
26        .cancel_spawn_epoch(epoch);
27}
28
29// ── Abort-on-drop task wrapper ───────────────────────────────────────────────
30
31pub(crate) struct Task<T>(pub tokio::task::JoinHandle<T>);
32impl<T> Drop for Task<T> {
33    fn drop(&mut self) {
34        self.0.abort();
35    }
36}
37
38// ── Scheduled proposal ──────────────────────────────────────────────────────
39
40pub(crate) struct Scheduled {
41    pub proposal: Proposal,
42    pub due: Instant,
43}
44
45// ── Pending async work ──────────────────────────────────────────────────────
46
47pub(crate) struct DriverPending {
48    pub generation: u64,
49    pub session_id: String,
50    pub task: Task<TaskResult>,
51}
52
53pub(crate) enum TaskResult {
54    Command {
55        owner: String,
56        #[allow(dead_code)] // stored for diagnostics, matched as `_`
57        command: String,
58        handler: Arc<dyn ExtensionHandler>,
59        handler_generation: Option<u64>,
60        result: Result<serde_json::Value, String>,
61        report: crate::extensions::invoke_output::InvokeOutputReport,
62    },
63    Poll(Result<Reply, String>),
64    Prepared {
65        proposal: Proposal,
66        result: Box<Result<Runtime, PrepareError>>,
67    },
68    Started(Result<super::actor::ActiveStream, String>),
69}
70
71// ── Core state ──────────────────────────────────────────────────────────────
72
73/// The driver state lives on the actor; dropping it cancels all owned work.
74pub(crate) struct DriverState {
75    pub grant: Grant,
76    pub handler: Arc<dyn ExtensionHandler>,
77    pub handler_generation: u64,
78    pub cancel: CancellationToken,
79    pub workers: Workers,
80    pub worker_epoch: u64,
81    #[allow(dead_code)] // consumed by P7 deadline enforcement
82    pub deadline_task: Option<Task<()>>,
83    pub proposal: Option<Scheduled>,
84    pub selection: Selection,
85    pub awaiting_terminal: bool,
86    pub outcome: Option<(Outcome, String)>,
87    pub feedback: feedback::Tracker,
88    pub completed_feedback: &'static str,
89    /// Only explicit human submissions, never draft text. Entries remain until
90    /// delivery acknowledgement or an atomic next-turn commit. Bounded: 16 msgs
91    /// / 256 KiB total UTF-8 bytes (§2).
92    pub steering: VecDeque<String>,
93    pub auto_wakes_blocked: bool,
94    #[allow(dead_code)] // consumed by P7 cost gate
95    pub cost_at_arm: f64,
96}
97
98impl Drop for DriverState {
99    fn drop(&mut self) {
100        self.cancel.cancel();
101        cancel_workers(&self.workers, self.worker_epoch);
102    }
103}
104
105// ── Steering bounds ─────────────────────────────────────────────────────────
106
107pub(crate) const STEERING_MAX_MESSAGES: usize = 16;
108pub(crate) const STEERING_MAX_BYTES: usize = 256 * 1024;
109
110// ── Lifecycle helpers ───────────────────────────────────────────────────────
111
112pub(crate) fn live_generation(handler: &Arc<dyn ExtensionHandler>) -> Result<u64, String> {
113    handler
114        .lifecycle_snapshot()
115        .filter(|s| {
116            matches!(
117                s.health,
118                ExtensionHealth::Running | ExtensionHealth::Degraded
119            )
120        })
121        .map(|s| s.generation)
122        .ok_or_else(|| "owning extension is not live or its lifecycle is unavailable".into())
123}
124
125pub(crate) fn same_lifecycle(
126    handler: &Arc<dyn ExtensionHandler>,
127    generation: u64,
128) -> Result<(), String> {
129    if live_generation(handler)? == generation {
130        Ok(())
131    } else {
132        Err("owning extension process restarted or transport was lost".into())
133    }
134}
135
136pub(crate) fn same_handler(
137    manager: &Manager,
138    owner: &str,
139    handler: &Arc<dyn ExtensionHandler>,
140    generation: u64,
141) -> Result<(), String> {
142    let current = manager
143        .try_read()
144        .map_err(|_| "extension lifecycle busy".to_string())?
145        .session_driver_handler(owner)?;
146    if Arc::ptr_eq(&current, handler) {
147        same_lifecycle(handler, generation)
148    } else {
149        Err("owning extension was replaced".into())
150    }
151}
152
153pub(crate) fn schedule(active: &mut DriverState, proposal: Proposal) -> Result<(), String> {
154    if proposal.delay < Duration::from_secs(1) || proposal.delay > Duration::from_secs(300) {
155        return Err("invalid proposal delay".into());
156    }
157    let due = Instant::now() + proposal.delay;
158    if active
159        .grant
160        .deadline()
161        .is_some_and(|deadline| due >= deadline)
162    {
163        return Err("proposal would run past the grant deadline".into());
164    }
165    active.selection = proposal.selection.clone();
166    active.proposal = Some(Scheduled { proposal, due });
167    Ok(())
168}
169
170/// Build the pending-turn history by appending undelivered steering to
171/// the current api_messages snapshot — read-only, never drains the queue.
172pub(crate) fn history_with_steering(
173    api_messages: &[SharedMessage],
174    steering: &VecDeque<String>,
175) -> Vec<SharedMessage> {
176    let mut history = api_messages.to_vec();
177    for text in steering {
178        history.push(Arc::new(serde_json::json!({"role": "user", "content": text})));
179    }
180    history
181}
182
183/// Drain queued steering into api_messages before a driver turn starts.
184pub(crate) fn commit_submission(
185    api_messages: &mut Vec<SharedMessage>,
186    steering: &mut VecDeque<String>,
187    prompt: &str,
188    abort_context: &mut Option<String>,
189) {
190    for text in steering.drain(..) {
191        api_messages.push(Arc::new(serde_json::json!({"role": "user", "content": text})));
192    }
193    let text = if let Some(context) = abort_context.take() {
194        format!("{context}\n\n{prompt}")
195    } else {
196        prompt.to_string()
197    };
198    api_messages.push(Arc::new(serde_json::json!({"role": "user", "content": text})));
199}
200
201/// Build the PollRequest for the next poll.
202pub(crate) fn poll_request(
203    active: &DriverState,
204    outcome: Outcome,
205    error_kind: String,
206    session_cost_so_far: f64,
207) -> PollRequest {
208    PollRequest {
209        run_id: active.grant.run_id.clone(),
210        decision_id: uuid::Uuid::new_v4().to_string(),
211        outcome,
212        error_kind,
213        model: active.selection.model.clone(),
214        effort: active.selection.effort.clone(),
215        feedback: active.grant.feedback_enabled().then(|| {
216            if outcome == Outcome::Success {
217                active.completed_feedback
218            } else {
219                "unknown"
220            }
221            .into()
222        }),
223        session_id: Some(active.grant.session_id.clone()),
224        // (E-P7, §S3) let the plugin see the running spend.
225        session_cost_so_far: Some(session_cost_so_far),
226    }
227}
228
229/// Deadline remaining in ms, for wire events.
230pub(crate) fn deadline_ms(grant: &Grant) -> Option<u64> {
231    grant.deadline().map(|d| {
232        d.saturating_duration_since(Instant::now())
233            .as_millis()
234            .min(u64::MAX as u128) as u64
235    })
236}
237
238// ── P5: terminal classification ─────────────────────────────────────────────
239
240/// Classified end of a driver-owned turn. Only a final `Done` is `Success`.
241#[derive(Debug)]
242pub(crate) enum Terminal {
243    Success,
244    Failure(Outcome, String),
245    /// Cancel/unexpected-EOF: revoke WITHOUT producing an outcome for polling.
246    Canceled,
247}
248
249/// Capture the terminal before the stream handler consumes it. Called for
250/// `Done`, `Error`, or `None` (EOF / stream dropped).
251pub(crate) fn capture_terminal(
252    event: Option<&crate::StreamEvent>,
253    canceled: bool,
254) -> Option<Terminal> {
255    use crate::{SessionEvent, StreamEvent};
256    use crate::extensions::session_driver::{classify_turn_error, Outcome as O};
257    if canceled {
258        return Some(Terminal::Canceled);
259    }
260    match event {
261        Some(StreamEvent::Session(SessionEvent::Done)) => Some(Terminal::Success),
262        Some(StreamEvent::Session(SessionEvent::Error(error))) => {
263            if matches!(error.outcome, agent_core::TurnOutcome::Canceled) {
264                return Some(Terminal::Canceled);
265            }
266            let (outcome, kind) = classify_turn_error(error);
267            Some(Terminal::Failure(outcome, kind))
268        }
269        None => Some(Terminal::Failure(O::Blocked, "unknown".into())),
270        _ => None,
271    }
272}
273
274/// Apply the terminal to the driver state. Sets `outcome` for Success /
275/// classified failure. Revokes on Blocked, Canceled, or idle conflict.
276/// Returns `Some(reason)` if the caller must revoke.
277pub(crate) fn observe_terminal(
278    driver: &mut DriverState,
279    runtime: &crate::Runtime,
280    terminal: Terminal,
281) -> Option<String> {
282    if !driver.awaiting_terminal {
283        return None;
284    }
285    driver.awaiting_terminal = false;
286    match terminal {
287        Terminal::Canceled => {
288            return Some("canceled".into());
289        }
290        Terminal::Success => {
291            driver.completed_feedback = if driver.grant.feedback_enabled() {
292                driver.feedback.finish()
293            } else {
294                "unknown"
295            };
296            driver.outcome = Some((Outcome::Success, "none".into()));
297        }
298        Terminal::Failure(Outcome::TimeCheckpoint, kind)
299            if driver.grant.time_checkpoints_enabled()
300                && !runtime.turn_budget().max_elapsed.is_zero() =>
301        {
302            driver.completed_feedback = "unknown";
303            driver.feedback = feedback::Tracker::default();
304            driver.outcome = Some((Outcome::TimeCheckpoint, kind));
305        }
306        Terminal::Failure(Outcome::TimeCheckpoint, _) => {
307            return Some("blocked or unexpected end of stream; explicit user action required".into());
308        }
309        Terminal::Failure(outcome, kind) => {
310            if matches!(outcome, Outcome::Blocked) {
311                return Some("blocked or unexpected end of stream; explicit user action required".into());
312            }
313            driver.outcome = Some((outcome, kind));
314        }
315    }
316    None
317}
318
319// ── F11: pure-fn unit tests ────────────────────────────────────────────────
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use std::collections::VecDeque;
325
326    fn test_selection() -> Selection {
327        Selection {
328            model: "test/model".into(),
329            effort: "high".into(),
330        }
331    }
332
333    fn test_reply() -> Reply {
334        Reply::Start {
335            run_id: "test-run".into(),
336            models: vec![test_selection()],
337            prompt: "do the work".into(),
338            delay_ms: 2000,
339            max_duration_ms: Some(120_000),
340            feedback_version: Some(1),
341            time_checkpoint_version: Some(1),
342            context_mode: None,
343            notice: "test notice".into(),
344            max_cost_usd: None,
345        }
346    }
347
348    fn test_grant_and_state() -> (Grant, DriverState) {
349        let reply = test_reply();
350        let (grant, proposal) =
351            Grant::from_start("test-plugin", "test-session", reply).unwrap();
352        let state = DriverState {
353            selection: proposal.selection.clone(),
354            grant,
355            handler: std::sync::Arc::new(crate::extensions::runtime::tests::DummyHandler),
356            handler_generation: 0,
357            cancel: CancellationToken::new(),
358            workers: Arc::new(std::sync::Mutex::new(
359                crate::runtime::subagent::SubagentRegistry::new(),
360            )),
361            worker_epoch: 0,
362            deadline_task: None,
363            proposal: Some(Scheduled { proposal, due: Instant::now() + Duration::from_secs(2) }),
364            awaiting_terminal: false,
365            outcome: None,
366            feedback: feedback::Tracker::default(),
367            completed_feedback: "unknown",
368            steering: VecDeque::new(),
369            auto_wakes_blocked: false,
370            cost_at_arm: 0.0,
371        };
372        let grant_copy = Grant::from_start(
373            "test-plugin",
374            "test-session",
375            test_reply(),
376        )
377        .unwrap()
378        .0;
379        (grant_copy, state)
380    }
381
382    // ── schedule tests ──────────────────────────────────────────────────
383
384    #[test]
385    fn schedule_rejects_too_short_delay() {
386        let (_, mut state) = test_grant_and_state();
387        let proposal = crate::extensions::session_driver::Proposal {
388            selection: test_selection(),
389            prompt: "x".into(),
390            delay: Duration::from_millis(500),
391            notice: String::new(),
392        };
393        assert!(schedule(&mut state, proposal).is_err());
394    }
395
396    #[test]
397    fn schedule_rejects_past_deadline() {
398        let (_, mut state) = test_grant_and_state();
399        // Proposal delay exceeds the grant's max_duration_ms (120 s).
400        let proposal = crate::extensions::session_driver::Proposal {
401            selection: test_selection(),
402            prompt: "x".into(),
403            delay: Duration::from_secs(300),
404            notice: String::new(),
405        };
406        assert!(schedule(&mut state, proposal).is_err());
407    }
408
409    #[test]
410    fn schedule_accepts_valid_proposal() {
411        let (_, mut state) = test_grant_and_state();
412        let proposal = crate::extensions::session_driver::Proposal {
413            selection: Selection {
414                model: "other/model".into(),
415                effort: "low".into(),
416            },
417            prompt: "continue".into(),
418            delay: Duration::from_secs(2),
419            notice: "notice".into(),
420        };
421        assert!(schedule(&mut state, proposal).is_ok());
422        assert_eq!(state.selection.model, "other/model");
423        assert!(state.proposal.is_some());
424    }
425
426    // ── poll_request ────────────────────────────────────────────────────
427
428    #[test]
429    fn poll_request_includes_feedback_when_enabled() {
430        let (_, state) = test_grant_and_state();
431        let req = poll_request(
432            &state,
433            crate::extensions::session_driver::Outcome::Success,
434            "none".into(),
435            1.25,
436        );
437        assert!(req.feedback.is_some());
438        assert_eq!(req.run_id, "test-run");
439        assert!(req.session_id.is_some());
440        // (E-P7, §S3) running spend is reported to the plugin.
441        assert_eq!(req.session_cost_so_far, Some(1.25));
442    }
443
444    // ── same_lifecycle ──────────────────────────────────────────────────
445
446    #[test]
447    fn same_lifecycle_matches_same_generation() {
448        let handler: Arc<dyn crate::extensions::runtime::ExtensionHandler> =
449            Arc::new(crate::extensions::runtime::tests::DummyHandler);
450        let gen = live_generation(&handler).unwrap();
451        assert!(same_lifecycle(&handler, gen).is_ok());
452    }
453
454    #[test]
455    fn same_lifecycle_rejects_different_generation() {
456        let handler: Arc<dyn crate::extensions::runtime::ExtensionHandler> =
457            Arc::new(crate::extensions::runtime::tests::DummyHandler);
458        assert!(same_lifecycle(&handler, 999).is_err());
459    }
460
461    // ── history_with_steering ───────────────────────────────────────────
462
463    #[test]
464    fn history_with_steering_appends_user_messages() {
465        let base: Vec<SharedMessage> =
466            vec![Arc::new(serde_json::json!({"role":"assistant","content":"hi"}))];
467        let mut steering = VecDeque::new();
468        steering.push_back("one".into());
469        steering.push_back("two".into());
470        let result = history_with_steering(&base, &steering);
471        assert_eq!(result.len(), 3);
472        assert_eq!(result[1]["role"], "user");
473        assert_eq!(result[1]["content"], "one");
474        assert_eq!(result[2]["content"], "two");
475    }
476
477    // ── commit_submission ───────────────────────────────────────────────
478
479    #[test]
480    fn commit_submission_drains_steering_and_appends_prompt() {
481        let mut messages: Vec<SharedMessage> = Vec::new();
482        let mut steering = VecDeque::new();
483        steering.push_back("steer1".into());
484        steering.push_back("steer2".into());
485        let mut abort = None;
486        commit_submission(&mut messages, &mut steering, "go", &mut abort);
487        assert!(steering.is_empty());
488        assert_eq!(messages.len(), 3);
489        assert_eq!(messages[0]["content"], "steer1");
490        assert_eq!(messages[1]["content"], "steer2");
491        assert_eq!(messages[2]["content"], "go");
492    }
493
494    #[test]
495    fn commit_submission_prepends_abort_context() {
496        let mut messages: Vec<SharedMessage> = Vec::new();
497        let mut steering = VecDeque::new();
498        let mut abort = Some("aborted context".into());
499        commit_submission(&mut messages, &mut steering, "go", &mut abort);
500        assert!(abort.is_none());
501        assert_eq!(messages.len(), 1);
502        assert!(messages[0]["content"].as_str().unwrap().contains("aborted context"));
503        assert!(messages[0]["content"].as_str().unwrap().contains("go"));
504    }
505
506    // ── capture_terminal / observe_terminal ─────────────────────────────
507
508    #[test]
509    fn capture_terminal_done_is_success() {
510        let event = crate::StreamEvent::Session(crate::SessionEvent::Done);
511        match capture_terminal(Some(&event), false) {
512            Some(Terminal::Success) => {}
513            other => panic!("expected Success, got: {other:?}"),
514        }
515    }
516
517    #[test]
518    fn capture_terminal_canceled_flag() {
519        let event = crate::StreamEvent::Session(crate::SessionEvent::Done);
520        match capture_terminal(Some(&event), true) {
521            Some(Terminal::Canceled) => {}
522            other => panic!("expected Canceled, got: {other:?}"),
523        }
524    }
525
526    #[test]
527    fn capture_terminal_eof_is_blocked() {
528        match capture_terminal(None, false) {
529            Some(Terminal::Failure(Outcome::Blocked, _)) => {}
530            other => panic!("expected Blocked, got: {other:?}"),
531        }
532    }
533
534    #[test]
535    fn capture_terminal_error_with_canceled_outcome() {
536        let error = agent_core::TurnError {
537            message: "canceled".into(),
538            outcome: agent_core::TurnOutcome::Canceled,
539        };
540        let event = crate::StreamEvent::Session(crate::SessionEvent::Error(error));
541        match capture_terminal(Some(&event), false) {
542            Some(Terminal::Canceled) => {}
543            other => panic!("expected Canceled from TurnOutcome::Canceled, got: {other:?}"),
544        }
545    }
546
547    #[test]
548    fn capture_terminal_llm_event_is_none() {
549        let event = crate::StreamEvent::Llm(crate::LlmEvent::Text("hi".into()));
550        assert!(capture_terminal(Some(&event), false).is_none());
551    }
552
553    #[test]
554    fn observe_terminal_success_sets_outcome() {
555        let (_, mut state) = test_grant_and_state();
556        state.awaiting_terminal = true;
557        let runtime = tokio::runtime::Runtime::new().unwrap();
558        runtime.block_on(async {
559            let rt = crate::Runtime::new().await.unwrap();
560            let result = observe_terminal(&mut state, &rt, Terminal::Success);
561            assert!(result.is_none(), "success should not revoke");
562            assert!(state.outcome.is_some());
563            let (outcome, _) = state.outcome.clone().unwrap();
564            assert_eq!(outcome, Outcome::Success);
565        });
566    }
567
568    #[test]
569    fn observe_terminal_canceled_returns_revoke_reason() {
570        let (_, mut state) = test_grant_and_state();
571        state.awaiting_terminal = true;
572        let runtime = tokio::runtime::Runtime::new().unwrap();
573        runtime.block_on(async {
574            let rt = crate::Runtime::new().await.unwrap();
575            let result = observe_terminal(&mut state, &rt, Terminal::Canceled);
576            assert!(result.is_some());
577            assert!(result.unwrap().contains("canceled"));
578            assert!(state.outcome.is_none());
579        });
580    }
581
582    #[test]
583    fn observe_terminal_blocked_returns_revoke_reason() {
584        let (_, mut state) = test_grant_and_state();
585        state.awaiting_terminal = true;
586        let runtime = tokio::runtime::Runtime::new().unwrap();
587        runtime.block_on(async {
588            let rt = crate::Runtime::new().await.unwrap();
589            let result = observe_terminal(
590                &mut state,
591                &rt,
592                Terminal::Failure(Outcome::Blocked, "unknown".into()),
593            );
594            assert!(result.is_some());
595            assert!(result.unwrap().contains("blocked"));
596        });
597    }
598
599    #[test]
600    fn observe_terminal_provider_error_sets_outcome() {
601        let (_, mut state) = test_grant_and_state();
602        state.awaiting_terminal = true;
603        let runtime = tokio::runtime::Runtime::new().unwrap();
604        runtime.block_on(async {
605            let rt = crate::Runtime::new().await.unwrap();
606            let result = observe_terminal(
607                &mut state,
608                &rt,
609                Terminal::Failure(Outcome::ProviderError, "transient".into()),
610            );
611            assert!(result.is_none(), "provider error should not revoke");
612            let (outcome, kind) = state.outcome.clone().unwrap();
613            assert_eq!(outcome, Outcome::ProviderError);
614            assert_eq!(kind, "transient");
615        });
616    }
617
618    #[test]
619    fn observe_terminal_not_awaiting_is_noop() {
620        let (_, mut state) = test_grant_and_state();
621        state.awaiting_terminal = false;
622        let runtime = tokio::runtime::Runtime::new().unwrap();
623        runtime.block_on(async {
624            let rt = crate::Runtime::new().await.unwrap();
625            let result = observe_terminal(&mut state, &rt, Terminal::Success);
626            assert!(result.is_none());
627            assert!(state.outcome.is_none());
628        });
629    }
630
631    #[test]
632    fn observe_terminal_only_fires_once() {
633        let (_, mut state) = test_grant_and_state();
634        state.awaiting_terminal = true;
635        let runtime = tokio::runtime::Runtime::new().unwrap();
636        runtime.block_on(async {
637            let rt = crate::Runtime::new().await.unwrap();
638            let r1 = observe_terminal(&mut state, &rt, Terminal::Success);
639            assert!(r1.is_none());
640            assert!(state.outcome.is_some());
641            // Second call should be no-op (awaiting_terminal is false now).
642            state.outcome = None;
643            let r2 = observe_terminal(&mut state, &rt, Terminal::Success);
644            assert!(r2.is_none());
645            assert!(state.outcome.is_none());
646        });
647    }
648}