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}-{}", 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#[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}