Skip to main content

mj_client/
session.rs

1//! The session operations used by interactive control surfaces.
2
3use std::fmt;
4use std::future::Future;
5use std::pin::Pin;
6use std::sync::Arc;
7use std::time::Duration;
8
9use agent_client_protocol::schema::v1::SessionConfigOption;
10use anyhow::{Context, Result, ensure};
11use mj_core::config::Config;
12use mj_core::elicitation::ElicitationResponse;
13use mj_core::state::{ManagedSessionSnapshot, SessionRecord};
14
15use mj_core::relay::{
16    AnalyzeDeltaRepository, RelayCommand, RelayCursor, RelayEvent, RelayOperationalState, RepoDelta,
17};
18use mj_core::worker_launch::ReviewerLaunchConfig;
19
20pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
21
22#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(tag = "kind", content = "detail", rename_all = "snake_case")]
24pub enum ViewError {
25    Unreachable(String),
26    TargetMissing(String),
27    ProjectionIntegrity(String),
28}
29
30impl ViewError {
31    pub fn detail(&self) -> &str {
32        match self {
33            Self::Unreachable(detail)
34            | Self::TargetMissing(detail)
35            | Self::ProjectionIntegrity(detail) => detail,
36        }
37    }
38}
39
40#[derive(Debug, Clone, PartialEq, Default)]
41pub struct ManagedSessionView {
42    pub snapshot: Option<ManagedSessionSnapshot>,
43    pub connected: bool,
44    pub error: Option<ViewError>,
45}
46
47#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
48pub struct RelayAttachment {
49    pub state: RelayOperationalState,
50    pub events: Vec<RelayEvent>,
51    pub through_ordinal: u64,
52    pub through_digest: String,
53}
54
55#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
56pub struct StartedReviewer {
57    pub native_session_id: Option<String>,
58    pub config_options: Vec<SessionConfigOption>,
59    pub reused: bool,
60    pub state: RelayOperationalState,
61}
62
63#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ReviewerAction {
66    Start {
67        config: Box<ReviewerLaunchConfig>,
68    },
69    Submit {
70        command_id: String,
71        command: RelayCommand,
72    },
73    Attach {
74        after_ordinal: u64,
75        after_digest: String,
76    },
77    Acknowledge {
78        through_ordinal: u64,
79        through_digest: String,
80    },
81    Status,
82    RespondElicitation {
83        elicitation_id: String,
84        response: ElicitationResponse,
85    },
86    Pause,
87    CaptureDelta {
88        baselines: std::collections::BTreeMap<std::path::PathBuf, String>,
89    },
90    AdvanceBaseline {
91        trees: std::collections::BTreeMap<std::path::PathBuf, String>,
92    },
93    AnalyzeDelta {
94        repositories: Vec<AnalyzeDeltaRepository>,
95    },
96    TakeLaneDispatches,
97}
98
99impl ReviewerAction {
100    pub const fn operation_name(&self) -> &'static str {
101        match self {
102            Self::Start { .. } => "reviewer_start",
103            Self::Submit { .. } => "reviewer_submit",
104            Self::Attach { .. } => "reviewer_attach",
105            Self::Acknowledge { .. } => "reviewer_acknowledge",
106            Self::Status => "reviewer_status",
107            Self::RespondElicitation { .. } => "reviewer_respond_elicitation",
108            Self::Pause => "reviewer_pause",
109            Self::CaptureDelta { .. } => "reviewer_capture_delta",
110            Self::AdvanceBaseline { .. } => "reviewer_advance_baseline",
111            Self::AnalyzeDelta { .. } => "reviewer_analyze_delta",
112            Self::TakeLaneDispatches => "reviewer_take_lane_dispatches",
113        }
114    }
115}
116
117#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
118#[serde(rename_all = "snake_case")]
119pub enum ReviewerOutcome {
120    Started(Box<StartedReviewer>),
121    Accepted {
122        ordinal: u64,
123    },
124    Attached(Box<RelayAttachment>),
125    Acknowledged(RelayCursor),
126    Status(Box<RelayOperationalState>),
127    ElicitationResolved,
128    Paused,
129    Delta {
130        repositories: Vec<RepoDelta>,
131    },
132    BaselineAdvanced,
133    ChangedFunctions {
134        packet: String,
135    },
136    LaneDispatches {
137        requests: Vec<mj_core::review::lanes::ReviewSubagentRequest>,
138    },
139}
140
141pub struct PendingRelaySubmit {
142    completion: BoxFuture<'static, Result<u64>>,
143}
144
145impl PendingRelaySubmit {
146    pub fn new(completion: BoxFuture<'static, Result<u64>>) -> Self {
147        Self { completion }
148    }
149
150    pub async fn wait(self) -> Result<u64> {
151        self.completion.await
152    }
153}
154
155pub struct PendingRelaySync {
156    completion: BoxFuture<'static, Result<()>>,
157}
158
159impl PendingRelaySync {
160    pub fn new(completion: BoxFuture<'static, Result<()>>) -> Self {
161        Self { completion }
162    }
163
164    pub async fn wait(self) -> Result<()> {
165        self.completion.await
166    }
167}
168
169#[derive(Debug, Default)]
170pub struct ReviewState {
171    pub review: Option<mj_core::storage::StoredReview>,
172    pub defaults: mj_core::second_opinion::ReviewerDefaults,
173}
174
175pub trait SessionHandleBackend: Send + Sync {
176    fn search_prompts(
177        &self,
178        bundle_id: String,
179        scope: mj_core::storage::HistoryScope,
180        query: String,
181    ) -> BoxFuture<'_, Result<Vec<mj_core::storage::PromptHistoryEntry>>>;
182    fn review_state(&self) -> BoxFuture<'_, Result<ReviewState>>;
183
184    fn config_result(&self, command_id: String) -> BoxFuture<'_, Result<Option<Option<String>>>>;
185
186    fn clone_box(&self) -> Box<dyn SessionHandleBackend>;
187    fn session_id(&self) -> &str;
188    fn view(&self) -> ManagedSessionView;
189    fn is_stopped(&self) -> bool;
190    fn has_changed(&self) -> Result<bool>;
191    fn changed(&mut self) -> BoxFuture<'_, Result<ManagedSessionView>>;
192    fn enqueue_submit(
193        &self,
194        command_id: String,
195        command: RelayCommand,
196    ) -> BoxFuture<'_, Result<PendingRelaySubmit>>;
197    fn enqueue_sync(&self) -> BoxFuture<'_, Result<PendingRelaySync>>;
198    fn respond_elicitation(
199        &self,
200        elicitation_id: String,
201        response: ElicitationResponse,
202    ) -> BoxFuture<'_, Result<()>>;
203    fn stop_background_task(&self, background_task_id: String) -> BoxFuture<'_, Result<()>>;
204    fn reviewer(
205        &self,
206        role: Option<String>,
207        action: ReviewerAction,
208    ) -> BoxFuture<'_, Result<ReviewerOutcome>>;
209}
210
211pub struct SessionHandle {
212    backend: Box<dyn SessionHandleBackend>,
213}
214
215impl SessionHandle {
216    pub async fn search_prompts(
217        &self,
218        bundle_id: String,
219        scope: mj_core::storage::HistoryScope,
220        query: String,
221    ) -> Result<Vec<mj_core::storage::PromptHistoryEntry>> {
222        self.backend.search_prompts(bundle_id, scope, query).await
223    }
224    pub async fn review_state(&self) -> Result<ReviewState> {
225        self.backend.review_state().await
226    }
227
228    pub fn new(backend: impl SessionHandleBackend + 'static) -> Self {
229        Self {
230            backend: Box::new(backend),
231        }
232    }
233
234    pub fn session_id(&self) -> &str {
235        self.backend.session_id()
236    }
237
238    pub fn view(&self) -> ManagedSessionView {
239        self.backend.view()
240    }
241
242    pub fn is_stopped(&self) -> bool {
243        self.backend.is_stopped()
244    }
245
246    pub fn has_changed(&self) -> Result<bool> {
247        self.backend.has_changed()
248    }
249
250    pub async fn changed(&mut self) -> Result<ManagedSessionView> {
251        self.backend.changed().await
252    }
253
254    pub async fn submit(&self, command_id: String, command: RelayCommand) -> Result<u64> {
255        self.enqueue_submit(command_id, command).await?.wait().await
256    }
257
258    /// Apply a setting and wait for its durable success or rejection.
259    pub async fn set_config(&self, key: String, value: String) -> Result<()> {
260        let command_id = new_command_id("set-config")?;
261        self.submit(command_id.clone(), RelayCommand::SetConfig { key, value })
262            .await?;
263        tokio::time::timeout(Duration::from_secs(60), async {
264            loop {
265                if let Some(error) = self.backend.config_result(command_id.clone()).await? {
266                    if let Some(error) = error {
267                        anyhow::bail!("{error}");
268                    }
269                    self.sync_now().await?;
270                    return Ok(());
271                }
272                ensure!(
273                    !self.is_stopped(),
274                    "session stopped while applying configuration"
275                );
276                if let Some(error) = self.view().error {
277                    anyhow::bail!("configuration connection failed: {}", error.detail());
278                }
279                tokio::time::sleep(Duration::from_millis(50)).await;
280            }
281        })
282        .await
283        .context("configuration command did not complete within 60 seconds")?
284    }
285
286    pub async fn enqueue_submit(
287        &self,
288        command_id: String,
289        command: RelayCommand,
290    ) -> Result<PendingRelaySubmit> {
291        self.backend.enqueue_submit(command_id, command).await
292    }
293
294    pub async fn sync_now(&self) -> Result<()> {
295        self.enqueue_sync().await?.wait().await
296    }
297
298    pub async fn enqueue_sync(&self) -> Result<PendingRelaySync> {
299        self.backend.enqueue_sync().await
300    }
301
302    pub async fn respond_elicitation(
303        &self,
304        elicitation_id: String,
305        response: ElicitationResponse,
306    ) -> Result<()> {
307        self.backend
308            .respond_elicitation(elicitation_id, response)
309            .await
310    }
311
312    pub async fn stop_background_task(&self, background_task_id: String) -> Result<()> {
313        self.backend.stop_background_task(background_task_id).await
314    }
315
316    pub async fn reviewer(&self, action: ReviewerAction) -> Result<ReviewerOutcome> {
317        self.reviewer_as(None, action).await
318    }
319
320    pub async fn reviewer_as(
321        &self,
322        role: Option<String>,
323        action: ReviewerAction,
324    ) -> Result<ReviewerOutcome> {
325        self.backend.reviewer(role, action).await
326    }
327}
328
329impl Clone for SessionHandle {
330    fn clone(&self) -> Self {
331        Self {
332            backend: self.backend.clone_box(),
333        }
334    }
335}
336
337impl fmt::Debug for SessionHandle {
338    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
339        formatter
340            .debug_struct("SessionHandle")
341            .field("session_id", &self.session_id())
342            .finish_non_exhaustive()
343    }
344}
345
346pub trait SessionControlBackend: Send + Sync {
347    fn session(&self, session_id: String) -> BoxFuture<'_, Result<SessionHandle>>;
348}
349
350#[derive(Clone)]
351pub struct SessionControl {
352    backend: Arc<dyn SessionControlBackend>,
353}
354
355impl SessionControl {
356    pub fn new(backend: impl SessionControlBackend + 'static) -> Self {
357        Self {
358            backend: Arc::new(backend),
359        }
360    }
361
362    pub async fn session(&self, session_id: impl Into<String>) -> Result<SessionHandle> {
363        self.backend.session(session_id.into()).await
364    }
365
366    pub async fn wait_for_session(
367        &self,
368        session_id: &str,
369        timeout: Duration,
370    ) -> Result<SessionHandle> {
371        tokio::time::timeout(timeout, async {
372            loop {
373                match self.session(session_id.to_owned()).await {
374                    Ok(handle) => return Ok(handle),
375                    Err(error) => {
376                        tracing::trace!(session_id, "waiting for session actor: {error:#}");
377                        tokio::time::sleep(Duration::from_millis(25)).await;
378                    }
379                }
380            }
381        })
382        .await
383        .with_context(|| {
384            format!(
385                "session {session_id} did not become available within {} seconds",
386                timeout.as_secs()
387            )
388        })?
389    }
390}
391
392impl fmt::Debug for SessionControl {
393    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
394        formatter.write_str("SessionControl(..)")
395    }
396}
397
398pub trait ReviewerStagerBackend: Send + Sync {
399    fn stage(
400        &self,
401        config: Config,
402        session: SessionRecord,
403        profile_id: String,
404        generation: u64,
405    ) -> Result<ReviewerLaunchConfig>;
406}
407
408#[derive(Clone)]
409pub struct ReviewerStager {
410    backend: Arc<dyn ReviewerStagerBackend>,
411}
412
413impl ReviewerStager {
414    pub fn new(backend: impl ReviewerStagerBackend + 'static) -> Self {
415        Self {
416            backend: Arc::new(backend),
417        }
418    }
419
420    pub fn stage(
421        &self,
422        config: Config,
423        session: SessionRecord,
424        profile_id: String,
425        generation: u64,
426    ) -> Result<ReviewerLaunchConfig> {
427        self.backend.stage(config, session, profile_id, generation)
428    }
429
430    #[doc(hidden)]
431    pub fn unavailable(message: impl Into<String>) -> Self {
432        Self::new(UnavailableReviewerStager(message.into()))
433    }
434}
435
436impl fmt::Debug for ReviewerStager {
437    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
438        formatter.write_str("ReviewerStager(..)")
439    }
440}
441
442struct UnavailableReviewerStager(String);
443
444impl ReviewerStagerBackend for UnavailableReviewerStager {
445    fn stage(
446        &self,
447        _config: Config,
448        _session: SessionRecord,
449        _profile_id: String,
450        _generation: u64,
451    ) -> Result<ReviewerLaunchConfig> {
452        anyhow::bail!(self.0.clone())
453    }
454}
455
456pub fn new_command_id(prefix: &str) -> Result<String> {
457    ensure!(!prefix.trim().is_empty(), "command ID prefix is required");
458    let mut random = [0_u8; 16];
459    getrandom::fill(&mut random)
460        .map_err(|error| anyhow::anyhow!("generate command ID: {error}"))?;
461    Ok(format!("{prefix}-{}", hex(&random)))
462}
463
464fn hex(bytes: &[u8]) -> String {
465    const DIGITS: &[u8; 16] = b"0123456789abcdef";
466    let mut output = String::with_capacity(bytes.len() * 2);
467    for byte in bytes {
468        output.push(char::from(DIGITS[usize::from(byte >> 4)]));
469        output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
470    }
471    output
472}
473
474/// A stopped session and a manager that resolves its live replacement.
475///
476/// Chat's cross-crate tests use this hand-written client fake to verify actor
477/// replacement without depending on the controller implementation crate.
478#[doc(hidden)]
479pub struct ReplacementSessionTestFixture {
480    pub stopped: SessionHandle,
481    pub control: SessionControl,
482    pub submitted: tokio::sync::mpsc::UnboundedReceiver<RelayCommand>,
483}
484
485#[derive(Clone)]
486struct ReplacementTestSession {
487    #[cfg(test)]
488    history: Option<tokio::sync::mpsc::UnboundedSender<HistoryTestRequest>>,
489    session_id: String,
490    stopped: bool,
491    accepted_ordinal: u64,
492    submitted: Option<tokio::sync::mpsc::UnboundedSender<RelayCommand>>,
493    view: tokio::sync::watch::Receiver<ManagedSessionView>,
494    _view_guard: Option<Arc<tokio::sync::watch::Sender<ManagedSessionView>>>,
495}
496
497impl SessionHandleBackend for ReplacementTestSession {
498    fn search_prompts(
499        &self,
500        _bundle_id: String,
501        _scope: mj_core::storage::HistoryScope,
502        _query: String,
503    ) -> BoxFuture<'_, Result<Vec<mj_core::storage::PromptHistoryEntry>>> {
504        #[cfg(test)]
505        if let Some(history) = &self.history {
506            let (response, result) = tokio::sync::oneshot::channel();
507            let sent = history.send(HistoryTestRequest {
508                bundle_id: _bundle_id,
509                scope: _scope,
510                query: _query,
511                response,
512            });
513            return Box::pin(async move {
514                sent.map_err(|_| anyhow::anyhow!("history backend closed"))?;
515                result
516                    .await
517                    .map_err(|_| anyhow::anyhow!("history response dropped"))?
518            });
519        }
520        Box::pin(async { Ok(Vec::new()) })
521    }
522    fn review_state(&self) -> BoxFuture<'_, Result<ReviewState>> {
523        Box::pin(async { Ok(ReviewState::default()) })
524    }
525
526    fn config_result(&self, _command_id: String) -> BoxFuture<'_, Result<Option<Option<String>>>> {
527        Box::pin(async { Ok(None) })
528    }
529    fn clone_box(&self) -> Box<dyn SessionHandleBackend> {
530        Box::new(self.clone())
531    }
532
533    fn session_id(&self) -> &str {
534        &self.session_id
535    }
536
537    fn view(&self) -> ManagedSessionView {
538        self.view.borrow().clone()
539    }
540
541    fn is_stopped(&self) -> bool {
542        self.stopped
543    }
544
545    fn has_changed(&self) -> Result<bool> {
546        self.view.has_changed().context("session manager stopped")
547    }
548
549    fn changed(&mut self) -> BoxFuture<'_, Result<ManagedSessionView>> {
550        Box::pin(async move {
551            self.view
552                .changed()
553                .await
554                .context("session manager stopped")?;
555            Ok(self.view())
556        })
557    }
558
559    fn enqueue_submit(
560        &self,
561        _command_id: String,
562        command: RelayCommand,
563    ) -> BoxFuture<'_, Result<PendingRelaySubmit>> {
564        let submitted = self.submitted.clone();
565        let stopped = self.stopped;
566        let accepted_ordinal = self.accepted_ordinal;
567        Box::pin(async move {
568            ensure!(!stopped, "session manager stopped");
569            let submitted = submitted.context("unsupported test operation")?;
570            submitted
571                .send(command)
572                .context("test submit observer stopped")?;
573            Ok(PendingRelaySubmit::new(Box::pin(async move {
574                Ok(accepted_ordinal)
575            })))
576        })
577    }
578
579    fn enqueue_sync(&self) -> BoxFuture<'_, Result<PendingRelaySync>> {
580        let stopped = self.stopped;
581        Box::pin(async move {
582            ensure!(!stopped, "session manager stopped");
583            Ok(PendingRelaySync::new(Box::pin(async { Ok(()) })))
584        })
585    }
586
587    fn respond_elicitation(
588        &self,
589        _elicitation_id: String,
590        _response: ElicitationResponse,
591    ) -> BoxFuture<'_, Result<()>> {
592        Box::pin(async { anyhow::bail!("unsupported test operation") })
593    }
594
595    fn stop_background_task(&self, _background_task_id: String) -> BoxFuture<'_, Result<()>> {
596        Box::pin(async { anyhow::bail!("unsupported test operation") })
597    }
598
599    fn reviewer(
600        &self,
601        _role: Option<String>,
602        _action: ReviewerAction,
603    ) -> BoxFuture<'_, Result<ReviewerOutcome>> {
604        Box::pin(async { anyhow::bail!("unsupported test operation") })
605    }
606}
607
608struct ReplacementTestControl {
609    session_id: String,
610    replacement: SessionHandle,
611}
612
613impl SessionControlBackend for ReplacementTestControl {
614    fn session(&self, session_id: String) -> BoxFuture<'_, Result<SessionHandle>> {
615        Box::pin(async move {
616            ensure!(
617                session_id == self.session_id,
618                "session {session_id} is not managed"
619            );
620            Ok(self.replacement.clone())
621        })
622    }
623}
624
625#[doc(hidden)]
626pub fn replacement_session_test_fixture(
627    session_id: &str,
628    accepted_ordinal: u64,
629) -> ReplacementSessionTestFixture {
630    let (stopped_view_tx, stopped_view) =
631        tokio::sync::watch::channel(ManagedSessionView::default());
632    drop(stopped_view_tx);
633    let stopped = SessionHandle::new(ReplacementTestSession {
634        #[cfg(test)]
635        history: None,
636        session_id: session_id.to_owned(),
637        stopped: true,
638        accepted_ordinal,
639        submitted: None,
640        view: stopped_view,
641        _view_guard: None,
642    });
643
644    let (view_tx, view) = tokio::sync::watch::channel(ManagedSessionView::default());
645    let (submitted_tx, submitted) = tokio::sync::mpsc::unbounded_channel();
646    let replacement = SessionHandle::new(ReplacementTestSession {
647        #[cfg(test)]
648        history: None,
649        session_id: session_id.to_owned(),
650        stopped: false,
651        accepted_ordinal,
652        submitted: Some(submitted_tx),
653        view,
654        _view_guard: Some(Arc::new(view_tx)),
655    });
656    let control = SessionControl::new(ReplacementTestControl {
657        session_id: session_id.to_owned(),
658        replacement,
659    });
660    ReplacementSessionTestFixture {
661        stopped,
662        control,
663        submitted,
664    }
665}
666
667#[cfg(test)]
668struct HistoryTestRequest {
669    bundle_id: String,
670    scope: mj_core::storage::HistoryScope,
671    query: String,
672    response: tokio::sync::oneshot::Sender<Result<Vec<mj_core::storage::PromptHistoryEntry>>>,
673}
674
675#[cfg(test)]
676mod storage_tests {
677    use super::*;
678    use mj_core::storage::{HistoryScope, PromptHistoryEntry};
679
680    #[tokio::test]
681    async fn history_search_yields_until_backend_responds_and_propagates_failures() {
682        let (history, mut requests) = tokio::sync::mpsc::unbounded_channel();
683        let (view_guard, view) = tokio::sync::watch::channel(ManagedSessionView::default());
684        let session = SessionHandle::new(ReplacementTestSession {
685            history: Some(history),
686            session_id: "session".into(),
687            stopped: false,
688            accepted_ordinal: 0,
689            submitted: None,
690            view,
691            _view_guard: Some(Arc::new(view_guard)),
692        });
693        let search =
694            session.search_prompts("bundle".into(), HistoryScope::Project, "needle".into());
695        tokio::pin!(search);
696        let request = tokio::select! {
697            biased;
698            result = &mut search => panic!("search completed before storage replied: {result:?}"),
699            request = requests.recv() => request.unwrap(),
700        };
701        assert_eq!(request.bundle_id, "bundle");
702        assert_eq!(request.scope, HistoryScope::Project);
703        assert_eq!(request.query, "needle");
704        request
705            .response
706            .send(Err(anyhow::anyhow!("storage unavailable")))
707            .unwrap();
708        assert!(
709            search
710                .await
711                .unwrap_err()
712                .to_string()
713                .contains("storage unavailable")
714        );
715
716        let search = session.search_prompts("bundle".into(), HistoryScope::Project, "retry".into());
717        tokio::pin!(search);
718        let request = tokio::select! {
719            biased;
720            result = &mut search => panic!("retry completed before storage replied: {result:?}"),
721            request = requests.recv() => request.unwrap(),
722        };
723        request
724            .response
725            .send(Ok(vec![PromptHistoryEntry {
726                id: 1,
727                session_id: "session".into(),
728                text: "retry works".into(),
729            }]))
730            .unwrap();
731        assert_eq!(search.await.unwrap()[0].text, "retry works");
732    }
733}