1use 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 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}-{}", mj_core::hex::lower_hex(random)))
462}
463
464#[doc(hidden)]
469pub struct ReplacementSessionTestFixture {
470 pub stopped: SessionHandle,
471 pub control: SessionControl,
472 pub submitted: tokio::sync::mpsc::UnboundedReceiver<RelayCommand>,
473}
474
475#[derive(Clone)]
476struct ReplacementTestSession {
477 #[cfg(test)]
478 history: Option<tokio::sync::mpsc::UnboundedSender<HistoryTestRequest>>,
479 session_id: String,
480 stopped: bool,
481 accepted_ordinal: u64,
482 submitted: Option<tokio::sync::mpsc::UnboundedSender<RelayCommand>>,
483 view: tokio::sync::watch::Receiver<ManagedSessionView>,
484 _view_guard: Option<Arc<tokio::sync::watch::Sender<ManagedSessionView>>>,
485}
486
487impl SessionHandleBackend for ReplacementTestSession {
488 fn search_prompts(
489 &self,
490 _bundle_id: String,
491 _scope: mj_core::storage::HistoryScope,
492 _query: String,
493 ) -> BoxFuture<'_, Result<Vec<mj_core::storage::PromptHistoryEntry>>> {
494 #[cfg(test)]
495 if let Some(history) = &self.history {
496 let (response, result) = tokio::sync::oneshot::channel();
497 let sent = history.send(HistoryTestRequest {
498 bundle_id: _bundle_id,
499 scope: _scope,
500 query: _query,
501 response,
502 });
503 return Box::pin(async move {
504 sent.map_err(|_| anyhow::anyhow!("history backend closed"))?;
505 result
506 .await
507 .map_err(|_| anyhow::anyhow!("history response dropped"))?
508 });
509 }
510 Box::pin(async { Ok(Vec::new()) })
511 }
512 fn review_state(&self) -> BoxFuture<'_, Result<ReviewState>> {
513 Box::pin(async { Ok(ReviewState::default()) })
514 }
515
516 fn config_result(&self, _command_id: String) -> BoxFuture<'_, Result<Option<Option<String>>>> {
517 Box::pin(async { Ok(None) })
518 }
519 fn clone_box(&self) -> Box<dyn SessionHandleBackend> {
520 Box::new(self.clone())
521 }
522
523 fn session_id(&self) -> &str {
524 &self.session_id
525 }
526
527 fn view(&self) -> ManagedSessionView {
528 self.view.borrow().clone()
529 }
530
531 fn is_stopped(&self) -> bool {
532 self.stopped
533 }
534
535 fn has_changed(&self) -> Result<bool> {
536 self.view.has_changed().context("session manager stopped")
537 }
538
539 fn changed(&mut self) -> BoxFuture<'_, Result<ManagedSessionView>> {
540 Box::pin(async move {
541 self.view
542 .changed()
543 .await
544 .context("session manager stopped")?;
545 Ok(self.view())
546 })
547 }
548
549 fn enqueue_submit(
550 &self,
551 _command_id: String,
552 command: RelayCommand,
553 ) -> BoxFuture<'_, Result<PendingRelaySubmit>> {
554 let submitted = self.submitted.clone();
555 let stopped = self.stopped;
556 let accepted_ordinal = self.accepted_ordinal;
557 Box::pin(async move {
558 ensure!(!stopped, "session manager stopped");
559 let submitted = submitted.context("unsupported test operation")?;
560 submitted
561 .send(command)
562 .context("test submit observer stopped")?;
563 Ok(PendingRelaySubmit::new(Box::pin(async move {
564 Ok(accepted_ordinal)
565 })))
566 })
567 }
568
569 fn enqueue_sync(&self) -> BoxFuture<'_, Result<PendingRelaySync>> {
570 let stopped = self.stopped;
571 Box::pin(async move {
572 ensure!(!stopped, "session manager stopped");
573 Ok(PendingRelaySync::new(Box::pin(async { Ok(()) })))
574 })
575 }
576
577 fn respond_elicitation(
578 &self,
579 _elicitation_id: String,
580 _response: ElicitationResponse,
581 ) -> BoxFuture<'_, Result<()>> {
582 Box::pin(async { anyhow::bail!("unsupported test operation") })
583 }
584
585 fn stop_background_task(&self, _background_task_id: String) -> BoxFuture<'_, Result<()>> {
586 Box::pin(async { anyhow::bail!("unsupported test operation") })
587 }
588
589 fn reviewer(
590 &self,
591 _role: Option<String>,
592 _action: ReviewerAction,
593 ) -> BoxFuture<'_, Result<ReviewerOutcome>> {
594 Box::pin(async { anyhow::bail!("unsupported test operation") })
595 }
596}
597
598struct ReplacementTestControl {
599 session_id: String,
600 replacement: SessionHandle,
601}
602
603impl SessionControlBackend for ReplacementTestControl {
604 fn session(&self, session_id: String) -> BoxFuture<'_, Result<SessionHandle>> {
605 Box::pin(async move {
606 ensure!(
607 session_id == self.session_id,
608 "session {session_id} is not managed"
609 );
610 Ok(self.replacement.clone())
611 })
612 }
613}
614
615#[doc(hidden)]
616pub fn replacement_session_test_fixture(
617 session_id: &str,
618 accepted_ordinal: u64,
619) -> ReplacementSessionTestFixture {
620 let (stopped_view_tx, stopped_view) =
621 tokio::sync::watch::channel(ManagedSessionView::default());
622 drop(stopped_view_tx);
623 let stopped = SessionHandle::new(ReplacementTestSession {
624 #[cfg(test)]
625 history: None,
626 session_id: session_id.to_owned(),
627 stopped: true,
628 accepted_ordinal,
629 submitted: None,
630 view: stopped_view,
631 _view_guard: None,
632 });
633
634 let (view_tx, view) = tokio::sync::watch::channel(ManagedSessionView::default());
635 let (submitted_tx, submitted) = tokio::sync::mpsc::unbounded_channel();
636 let replacement = SessionHandle::new(ReplacementTestSession {
637 #[cfg(test)]
638 history: None,
639 session_id: session_id.to_owned(),
640 stopped: false,
641 accepted_ordinal,
642 submitted: Some(submitted_tx),
643 view,
644 _view_guard: Some(Arc::new(view_tx)),
645 });
646 let control = SessionControl::new(ReplacementTestControl {
647 session_id: session_id.to_owned(),
648 replacement,
649 });
650 ReplacementSessionTestFixture {
651 stopped,
652 control,
653 submitted,
654 }
655}
656
657#[cfg(test)]
658struct HistoryTestRequest {
659 bundle_id: String,
660 scope: mj_core::storage::HistoryScope,
661 query: String,
662 response: tokio::sync::oneshot::Sender<Result<Vec<mj_core::storage::PromptHistoryEntry>>>,
663}
664
665#[cfg(test)]
666mod storage_tests {
667 use super::*;
668 use mj_core::storage::{HistoryScope, PromptHistoryEntry};
669
670 #[tokio::test]
671 async fn history_search_yields_until_backend_responds_and_propagates_failures() {
672 let (history, mut requests) = tokio::sync::mpsc::unbounded_channel();
673 let (view_guard, view) = tokio::sync::watch::channel(ManagedSessionView::default());
674 let session = SessionHandle::new(ReplacementTestSession {
675 history: Some(history),
676 session_id: "session".into(),
677 stopped: false,
678 accepted_ordinal: 0,
679 submitted: None,
680 view,
681 _view_guard: Some(Arc::new(view_guard)),
682 });
683 let search =
684 session.search_prompts("bundle".into(), HistoryScope::Project, "needle".into());
685 tokio::pin!(search);
686 let request = tokio::select! {
687 biased;
688 result = &mut search => panic!("search completed before storage replied: {result:?}"),
689 request = requests.recv() => request.unwrap(),
690 };
691 assert_eq!(request.bundle_id, "bundle");
692 assert_eq!(request.scope, HistoryScope::Project);
693 assert_eq!(request.query, "needle");
694 request
695 .response
696 .send(Err(anyhow::anyhow!("storage unavailable")))
697 .unwrap();
698 assert!(
699 search
700 .await
701 .unwrap_err()
702 .to_string()
703 .contains("storage unavailable")
704 );
705
706 let search = session.search_prompts("bundle".into(), HistoryScope::Project, "retry".into());
707 tokio::pin!(search);
708 let request = tokio::select! {
709 biased;
710 result = &mut search => panic!("retry completed before storage replied: {result:?}"),
711 request = requests.recv() => request.unwrap(),
712 };
713 request
714 .response
715 .send(Ok(vec![PromptHistoryEntry {
716 id: 1,
717 session_id: "session".into(),
718 text: "retry works".into(),
719 }]))
720 .unwrap();
721 assert_eq!(search.await.unwrap()[0].text, "retry works");
722 }
723}