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.
184/// An interrupted previous turn is already recorded in `api_messages` (its
185/// partial history + the interruption marker), so the prompt is appended
186/// verbatim — nothing is folded into it.
187pub(crate) fn commit_submission(
188    api_messages: &mut Vec<SharedMessage>,
189    steering: &mut VecDeque<String>,
190    prompt: &str,
191) {
192    for text in steering.drain(..) {
193        api_messages.push(Arc::new(serde_json::json!({"role": "user", "content": text})));
194    }
195    api_messages.push(Arc::new(serde_json::json!({"role": "user", "content": prompt})));
196}
197
198/// Build the PollRequest for the next poll.
199pub(crate) fn poll_request(
200    active: &DriverState,
201    outcome: Outcome,
202    error_kind: String,
203    session_cost_so_far: f64,
204) -> PollRequest {
205    PollRequest {
206        run_id: active.grant.run_id.clone(),
207        decision_id: uuid::Uuid::new_v4().to_string(),
208        outcome,
209        error_kind,
210        model: active.selection.model.clone(),
211        effort: active.selection.effort.clone(),
212        feedback: active.grant.feedback_enabled().then(|| {
213            if outcome == Outcome::Success {
214                active.completed_feedback
215            } else {
216                "unknown"
217            }
218            .into()
219        }),
220        session_id: Some(active.grant.session_id.clone()),
221        // (E-P7, §S3) let the plugin see the running spend.
222        session_cost_so_far: Some(session_cost_so_far),
223    }
224}
225
226/// Deadline remaining in ms, for wire events.
227pub(crate) fn deadline_ms(grant: &Grant) -> Option<u64> {
228    grant.deadline().map(|d| {
229        d.saturating_duration_since(Instant::now())
230            .as_millis()
231            .min(u64::MAX as u128) as u64
232    })
233}
234
235// ── P5: terminal classification ─────────────────────────────────────────────
236
237/// Classified end of a driver-owned turn. Only a final `Done` is `Success`.
238#[derive(Debug)]
239pub(crate) enum Terminal {
240    Success,
241    Failure(Outcome, String),
242    /// Cancel/unexpected-EOF: revoke WITHOUT producing an outcome for polling.
243    Canceled,
244}
245
246/// Capture the terminal before the stream handler consumes it. Called for
247/// `Done`, `Error`, or `None` (EOF / stream dropped).
248pub(crate) fn capture_terminal(
249    event: Option<&crate::StreamEvent>,
250    canceled: bool,
251) -> Option<Terminal> {
252    use crate::{SessionEvent, StreamEvent};
253    use crate::extensions::session_driver::{classify_turn_error, Outcome as O};
254    if canceled {
255        return Some(Terminal::Canceled);
256    }
257    match event {
258        Some(StreamEvent::Session(SessionEvent::Done)) => Some(Terminal::Success),
259        Some(StreamEvent::Session(SessionEvent::Error(error))) => {
260            if matches!(error.outcome, agent_core::TurnOutcome::Canceled) {
261                return Some(Terminal::Canceled);
262            }
263            let (outcome, kind) = classify_turn_error(error);
264            Some(Terminal::Failure(outcome, kind))
265        }
266        None => Some(Terminal::Failure(O::Blocked, "unknown".into())),
267        _ => None,
268    }
269}
270
271/// Apply the terminal to the driver state. Sets `outcome` for Success /
272/// classified failure. Revokes on Blocked, Canceled, or idle conflict.
273/// Returns `Some(reason)` if the caller must revoke.
274pub(crate) fn observe_terminal(
275    driver: &mut DriverState,
276    runtime: &crate::Runtime,
277    terminal: Terminal,
278) -> Option<String> {
279    if !driver.awaiting_terminal {
280        return None;
281    }
282    driver.awaiting_terminal = false;
283    match terminal {
284        Terminal::Canceled => {
285            return Some("canceled".into());
286        }
287        Terminal::Success => {
288            driver.completed_feedback = if driver.grant.feedback_enabled() {
289                driver.feedback.finish()
290            } else {
291                "unknown"
292            };
293            driver.outcome = Some((Outcome::Success, "none".into()));
294        }
295        Terminal::Failure(Outcome::TimeCheckpoint, kind)
296            if driver.grant.time_checkpoints_enabled()
297                && runtime.turn_budget().has_time_limit() =>
298        {
299            driver.completed_feedback = "unknown";
300            driver.feedback = feedback::Tracker::default();
301            driver.outcome = Some((Outcome::TimeCheckpoint, kind));
302        }
303        Terminal::Failure(Outcome::TimeCheckpoint, _) => {
304            return Some("blocked or unexpected end of stream; explicit user action required".into());
305        }
306        Terminal::Failure(outcome, kind) => {
307            if matches!(outcome, Outcome::Blocked) {
308                return Some("blocked or unexpected end of stream; explicit user action required".into());
309            }
310            driver.outcome = Some((outcome, kind));
311        }
312    }
313    None
314}
315
316// ── F11: pure-fn unit tests ────────────────────────────────────────────────
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use std::collections::VecDeque;
322
323    fn test_selection() -> Selection {
324        Selection {
325            model: "test/model".into(),
326            effort: "high".into(),
327        }
328    }
329
330    fn test_reply() -> Reply {
331        Reply::Start {
332            run_id: "test-run".into(),
333            models: vec![test_selection()],
334            prompt: "do the work".into(),
335            delay_ms: 2000,
336            max_duration_ms: Some(120_000),
337            feedback_version: Some(1),
338            time_checkpoint_version: Some(1),
339            context_mode: None,
340            notice: "test notice".into(),
341            max_cost_usd: None,
342        }
343    }
344
345    fn test_grant_and_state() -> (Grant, DriverState) {
346        let reply = test_reply();
347        let (grant, proposal) =
348            Grant::from_start("test-plugin", "test-session", reply).unwrap();
349        let state = DriverState {
350            selection: proposal.selection.clone(),
351            grant,
352            handler: std::sync::Arc::new(crate::extensions::runtime::tests::DummyHandler),
353            handler_generation: 0,
354            cancel: CancellationToken::new(),
355            workers: Arc::new(std::sync::Mutex::new(
356                crate::runtime::subagent::SubagentRegistry::new(),
357            )),
358            worker_epoch: 0,
359            deadline_task: None,
360            proposal: Some(Scheduled { proposal, due: Instant::now() + Duration::from_secs(2) }),
361            awaiting_terminal: false,
362            outcome: None,
363            feedback: feedback::Tracker::default(),
364            completed_feedback: "unknown",
365            steering: VecDeque::new(),
366            auto_wakes_blocked: false,
367            cost_at_arm: 0.0,
368        };
369        let grant_copy = Grant::from_start(
370            "test-plugin",
371            "test-session",
372            test_reply(),
373        )
374        .unwrap()
375        .0;
376        (grant_copy, state)
377    }
378
379    // ── schedule tests ──────────────────────────────────────────────────
380
381    #[test]
382    fn schedule_rejects_too_short_delay() {
383        let (_, mut state) = test_grant_and_state();
384        let proposal = crate::extensions::session_driver::Proposal {
385            selection: test_selection(),
386            prompt: "x".into(),
387            delay: Duration::from_millis(500),
388            notice: String::new(),
389        };
390        assert!(schedule(&mut state, proposal).is_err());
391    }
392
393    #[test]
394    fn schedule_rejects_past_deadline() {
395        let (_, mut state) = test_grant_and_state();
396        // Proposal delay exceeds the grant's max_duration_ms (120 s).
397        let proposal = crate::extensions::session_driver::Proposal {
398            selection: test_selection(),
399            prompt: "x".into(),
400            delay: Duration::from_secs(300),
401            notice: String::new(),
402        };
403        assert!(schedule(&mut state, proposal).is_err());
404    }
405
406    #[test]
407    fn schedule_accepts_valid_proposal() {
408        let (_, mut state) = test_grant_and_state();
409        let proposal = crate::extensions::session_driver::Proposal {
410            selection: Selection {
411                model: "other/model".into(),
412                effort: "low".into(),
413            },
414            prompt: "continue".into(),
415            delay: Duration::from_secs(2),
416            notice: "notice".into(),
417        };
418        assert!(schedule(&mut state, proposal).is_ok());
419        assert_eq!(state.selection.model, "other/model");
420        assert!(state.proposal.is_some());
421    }
422
423    // ── poll_request ────────────────────────────────────────────────────
424
425    #[test]
426    fn poll_request_includes_feedback_when_enabled() {
427        let (_, state) = test_grant_and_state();
428        let req = poll_request(
429            &state,
430            crate::extensions::session_driver::Outcome::Success,
431            "none".into(),
432            1.25,
433        );
434        assert!(req.feedback.is_some());
435        assert_eq!(req.run_id, "test-run");
436        assert!(req.session_id.is_some());
437        // (E-P7, §S3) running spend is reported to the plugin.
438        assert_eq!(req.session_cost_so_far, Some(1.25));
439    }
440
441    // ── same_lifecycle ──────────────────────────────────────────────────
442
443    #[test]
444    fn same_lifecycle_matches_same_generation() {
445        let handler: Arc<dyn crate::extensions::runtime::ExtensionHandler> =
446            Arc::new(crate::extensions::runtime::tests::DummyHandler);
447        let gen = live_generation(&handler).unwrap();
448        assert!(same_lifecycle(&handler, gen).is_ok());
449    }
450
451    #[test]
452    fn same_lifecycle_rejects_different_generation() {
453        let handler: Arc<dyn crate::extensions::runtime::ExtensionHandler> =
454            Arc::new(crate::extensions::runtime::tests::DummyHandler);
455        assert!(same_lifecycle(&handler, 999).is_err());
456    }
457
458    // ── history_with_steering ───────────────────────────────────────────
459
460    #[test]
461    fn history_with_steering_appends_user_messages() {
462        let base: Vec<SharedMessage> =
463            vec![Arc::new(serde_json::json!({"role":"assistant","content":"hi"}))];
464        let mut steering = VecDeque::new();
465        steering.push_back("one".into());
466        steering.push_back("two".into());
467        let result = history_with_steering(&base, &steering);
468        assert_eq!(result.len(), 3);
469        assert_eq!(result[1]["role"], "user");
470        assert_eq!(result[1]["content"], "one");
471        assert_eq!(result[2]["content"], "two");
472    }
473
474    // ── commit_submission ───────────────────────────────────────────────
475
476    #[test]
477    fn commit_submission_drains_steering_and_appends_prompt() {
478        let mut messages: Vec<SharedMessage> = Vec::new();
479        let mut steering = VecDeque::new();
480        steering.push_back("steer1".into());
481        steering.push_back("steer2".into());
482        commit_submission(&mut messages, &mut steering, "go");
483        assert!(steering.is_empty());
484        assert_eq!(messages.len(), 3);
485        assert_eq!(messages[0]["content"], "steer1");
486        assert_eq!(messages[1]["content"], "steer2");
487        assert_eq!(messages[2]["content"], "go");
488    }
489
490    // ── capture_terminal / observe_terminal ─────────────────────────────
491
492    #[test]
493    fn capture_terminal_done_is_success() {
494        let event = crate::StreamEvent::Session(crate::SessionEvent::Done);
495        match capture_terminal(Some(&event), false) {
496            Some(Terminal::Success) => {}
497            other => panic!("expected Success, got: {other:?}"),
498        }
499    }
500
501    #[test]
502    fn capture_terminal_canceled_flag() {
503        let event = crate::StreamEvent::Session(crate::SessionEvent::Done);
504        match capture_terminal(Some(&event), true) {
505            Some(Terminal::Canceled) => {}
506            other => panic!("expected Canceled, got: {other:?}"),
507        }
508    }
509
510    #[test]
511    fn capture_terminal_eof_is_blocked() {
512        match capture_terminal(None, false) {
513            Some(Terminal::Failure(Outcome::Blocked, _)) => {}
514            other => panic!("expected Blocked, got: {other:?}"),
515        }
516    }
517
518    #[test]
519    fn capture_terminal_error_with_canceled_outcome() {
520        let error = agent_core::TurnError {
521            message: "canceled".into(),
522            outcome: agent_core::TurnOutcome::Canceled,
523        };
524        let event = crate::StreamEvent::Session(crate::SessionEvent::Error(error));
525        match capture_terminal(Some(&event), false) {
526            Some(Terminal::Canceled) => {}
527            other => panic!("expected Canceled from TurnOutcome::Canceled, got: {other:?}"),
528        }
529    }
530
531    #[test]
532    fn capture_terminal_llm_event_is_none() {
533        let event = crate::StreamEvent::Llm(crate::LlmEvent::Text("hi".into()));
534        assert!(capture_terminal(Some(&event), false).is_none());
535    }
536
537    #[test]
538    fn observe_terminal_success_sets_outcome() {
539        let (_, mut state) = test_grant_and_state();
540        state.awaiting_terminal = true;
541        let runtime = tokio::runtime::Runtime::new().unwrap();
542        runtime.block_on(async {
543            let rt = crate::Runtime::new().await.unwrap();
544            let result = observe_terminal(&mut state, &rt, Terminal::Success);
545            assert!(result.is_none(), "success should not revoke");
546            assert!(state.outcome.is_some());
547            let (outcome, _) = state.outcome.clone().unwrap();
548            assert_eq!(outcome, Outcome::Success);
549        });
550    }
551
552    #[test]
553    fn observe_terminal_canceled_returns_revoke_reason() {
554        let (_, mut state) = test_grant_and_state();
555        state.awaiting_terminal = true;
556        let runtime = tokio::runtime::Runtime::new().unwrap();
557        runtime.block_on(async {
558            let rt = crate::Runtime::new().await.unwrap();
559            let result = observe_terminal(&mut state, &rt, Terminal::Canceled);
560            assert!(result.is_some());
561            assert!(result.unwrap().contains("canceled"));
562            assert!(state.outcome.is_none());
563        });
564    }
565
566    #[test]
567    fn observe_terminal_blocked_returns_revoke_reason() {
568        let (_, mut state) = test_grant_and_state();
569        state.awaiting_terminal = true;
570        let runtime = tokio::runtime::Runtime::new().unwrap();
571        runtime.block_on(async {
572            let rt = crate::Runtime::new().await.unwrap();
573            let result = observe_terminal(
574                &mut state,
575                &rt,
576                Terminal::Failure(Outcome::Blocked, "unknown".into()),
577            );
578            assert!(result.is_some());
579            assert!(result.unwrap().contains("blocked"));
580        });
581    }
582
583    #[test]
584    fn observe_terminal_provider_error_sets_outcome() {
585        let (_, mut state) = test_grant_and_state();
586        state.awaiting_terminal = true;
587        let runtime = tokio::runtime::Runtime::new().unwrap();
588        runtime.block_on(async {
589            let rt = crate::Runtime::new().await.unwrap();
590            let result = observe_terminal(
591                &mut state,
592                &rt,
593                Terminal::Failure(Outcome::ProviderError, "transient".into()),
594            );
595            assert!(result.is_none(), "provider error should not revoke");
596            let (outcome, kind) = state.outcome.clone().unwrap();
597            assert_eq!(outcome, Outcome::ProviderError);
598            assert_eq!(kind, "transient");
599        });
600    }
601
602    #[test]
603    fn observe_terminal_not_awaiting_is_noop() {
604        let (_, mut state) = test_grant_and_state();
605        state.awaiting_terminal = false;
606        let runtime = tokio::runtime::Runtime::new().unwrap();
607        runtime.block_on(async {
608            let rt = crate::Runtime::new().await.unwrap();
609            let result = observe_terminal(&mut state, &rt, Terminal::Success);
610            assert!(result.is_none());
611            assert!(state.outcome.is_none());
612        });
613    }
614
615    #[test]
616    fn observe_terminal_only_fires_once() {
617        let (_, mut state) = test_grant_and_state();
618        state.awaiting_terminal = true;
619        let runtime = tokio::runtime::Runtime::new().unwrap();
620        runtime.block_on(async {
621            let rt = crate::Runtime::new().await.unwrap();
622            let r1 = observe_terminal(&mut state, &rt, Terminal::Success);
623            assert!(r1.is_none());
624            assert!(state.outcome.is_some());
625            // Second call should be no-op (awaiting_terminal is false now).
626            state.outcome = None;
627            let r2 = observe_terminal(&mut state, &rt, Terminal::Success);
628            assert!(r2.is_none());
629            assert!(state.outcome.is_none());
630        });
631    }
632}