1use std::collections::{BTreeMap, BTreeSet};
23use std::path::PathBuf;
24use std::sync::{Arc, LazyLock, Mutex};
25use std::time::Duration;
26
27use tokio::sync::{mpsc, oneshot};
28
29use crate::database::TurnReviewState;
30use crate::session_manager::{
31 ManagedSessionHandle, ManagedSessionView, ReviewDeliveryAdmission, ReviewerAction,
32 ReviewerOutcome, SessionManagerControl, new_command_id,
33};
34use mj_core::config::ReviewConfig;
35use mj_core::state::{MaterializedExecutionState, MaterializedSession};
36
37use mj_core::relay::{RelayCommand, RelayEvent, RelayObservation};
38
39use mj_core::review::lanes::{ReviewTier, UserMessage};
40use mj_core::review::verdict::ReviewVerdict;
41use mj_review::driver::{
42 INTENT_ROLE, PendingForward, Resolution, ReviewRequest, SUPERVISOR_ROLE, TurnReviewDriver,
43 TurnReviewPhase, TurnReviewSeed,
44};
45
46pub use mj_client::review::{RuntimeReviewView, VerdictKind, VerdictView, role_session_id};
47
48const ROLE_POLL_IDLE_INTERVAL: Duration = Duration::from_millis(200);
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct StartRefusal(pub String);
57
58impl std::fmt::Display for StartRefusal {
59 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 formatter.write_str(&self.0)
61 }
62}
63
64pub const PROMPT_HELD_MESSAGE: &str =
66 "a review of the last turn is open; forward, dismiss or cancel it first";
67
68static PROMPT_LOCK: LazyLock<Mutex<BTreeMap<String, PromptHold>>> = LazyLock::new(Mutex::default);
78
79#[derive(Debug, Default)]
80struct PromptHold {
81 delivery_epoch: Option<u64>,
82 delivery_command_id: Option<String>,
83}
84
85pub(crate) fn next_review_generation() -> Result<u64, String> {
90 let mut random = [0_u8; 8];
91 getrandom::fill(&mut random)
92 .map_err(|error| format!("generate reviewer generation: {error}"))?;
93 let generation = u64::from_le_bytes(random);
94 if generation == 0 {
95 return Err("generate reviewer generation: random nonce was zero".to_owned());
96 }
97 Ok(generation)
98}
99
100#[must_use]
102pub fn prompt_refusal(session_id: &str) -> Option<&'static str> {
103 PROMPT_LOCK
104 .lock()
105 .unwrap_or_else(std::sync::PoisonError::into_inner)
106 .contains_key(session_id)
107 .then_some(PROMPT_HELD_MESSAGE)
108}
109
110fn hold_prompts(session_id: &str) {
111 PROMPT_LOCK
112 .lock()
113 .unwrap_or_else(std::sync::PoisonError::into_inner)
114 .insert(session_id.to_owned(), PromptHold::default());
115}
116
117fn release_prompts(session_id: &str) {
118 PROMPT_LOCK
119 .lock()
120 .unwrap_or_else(std::sync::PoisonError::into_inner)
121 .remove(session_id);
122}
123
124fn admit_review_delivery(
128 session_id: &str,
129 epoch: u64,
130 command_id: &str,
131) -> Option<ReviewDeliveryAdmission> {
132 let mut locks = PROMPT_LOCK
133 .lock()
134 .unwrap_or_else(std::sync::PoisonError::into_inner);
135 let hold = locks.get_mut(session_id)?;
136 match (hold.delivery_epoch, hold.delivery_command_id.as_deref()) {
137 (Some(existing_epoch), Some(existing_command))
138 if existing_epoch != epoch || existing_command != command_id =>
139 {
140 None
141 }
142 _ => {
143 hold.delivery_epoch = Some(epoch);
144 hold.delivery_command_id = Some(command_id.to_owned());
145 Some(ReviewDeliveryAdmission::new(
146 session_id.to_owned(),
147 epoch,
148 command_id.to_owned(),
149 ))
150 }
151 }
152}
153
154pub(crate) fn review_delivery_admitted(
158 session_id: &str,
159 admission: &ReviewDeliveryAdmission,
160) -> bool {
161 let locks = PROMPT_LOCK
162 .lock()
163 .unwrap_or_else(std::sync::PoisonError::into_inner);
164 locks.get(session_id).is_some_and(|hold| {
165 admission.session_id() == session_id
166 && hold.delivery_epoch == Some(admission.epoch())
167 && hold.delivery_command_id.as_deref() == Some(admission.command_id())
168 })
169}
170
171pub type ReviewConfigSource = Arc<dyn Fn() -> ReviewConfig + Send + Sync>;
175
176pub trait ReviewEnvironment: Send + Sync {
183 fn check(&self, session_id: &str, profile: &str) -> Result<(), String>;
186
187 fn stage(
190 &self,
191 session_id: &str,
192 profile: &str,
193 generation: u64,
194 mcp_servers: &[mj_core::worker_launch::ReviewMcpServer],
195 dispatch_tool: bool,
196 ) -> Result<mj_core::worker_launch::ReviewerLaunchConfig, String>;
197
198 fn load_state(&self, session_id: &str) -> Result<TurnReviewState, String>;
201
202 fn save_state(&self, session_id: &str, state: &TurnReviewState) -> Result<(), String>;
206
207 fn clear_interrupted(&self) -> Result<Vec<String>, String>;
212}
213
214#[derive(Debug, Default)]
220pub struct ControllerEnvironment;
221
222impl ReviewEnvironment for ControllerEnvironment {
223 fn check(&self, session_id: &str, profile: &str) -> Result<(), String> {
224 let controller =
225 crate::controller::Controller::load().map_err(|error| format!("{error:#}"))?;
226 let Some(reviewer) = controller.config.profiles.get(profile) else {
227 return Err(format!(
228 "turn review needs a reviewer: [review] profile {profile:?} is not a profile in config.toml"
229 ));
230 };
231 if !reviewer.enabled {
232 return Err(format!(
233 "turn review needs an enabled reviewer: [review] profile {profile:?} is disabled"
234 ));
235 }
236 validate_reviewer_assignment(
237 session_id,
238 controller.state.sessions.get(session_id),
239 profile,
240 )
241 }
242
243 fn stage(
244 &self,
245 session_id: &str,
246 profile: &str,
247 generation: u64,
248 mcp_servers: &[mj_core::worker_launch::ReviewMcpServer],
249 dispatch_tool: bool,
250 ) -> Result<mj_core::worker_launch::ReviewerLaunchConfig, String> {
251 let controller =
252 crate::controller::Controller::load().map_err(|error| format!("{error:#}"))?;
253 controller
254 .stage_reviewer_profile_with_mcp(
255 session_id,
256 profile,
257 generation,
258 mcp_servers,
259 dispatch_tool,
260 )
261 .map_err(|error| format!("{error:#}"))
262 }
263
264 fn load_state(&self, session_id: &str) -> Result<TurnReviewState, String> {
265 crate::database::turn_review_state(session_id).map_err(|error| format!("{error:#}"))
266 }
267
268 fn save_state(&self, session_id: &str, state: &TurnReviewState) -> Result<(), String> {
269 crate::database::save_turn_review_state(session_id, state)
270 .map_err(|error| format!("{error:#}"))
271 }
272
273 fn clear_interrupted(&self) -> Result<Vec<String>, String> {
274 crate::database::clear_interrupted_turn_reviews().map_err(|error| format!("{error:#}"))
275 }
276}
277
278pub(crate) fn validate_reviewer_assignment(
279 session_id: &str,
280 session: Option<&mj_core::state::SessionRecord>,
281 profile: &str,
282) -> Result<(), String> {
283 let Some(session) = session else {
284 return Err(format!(
285 "session {session_id:?} is not in the controller store"
286 ));
287 };
288 if session.archived {
289 return Err("this session is archived".to_owned());
290 }
291 if session.last_profile == profile {
292 return Err(format!(
293 "turn review profile {profile:?} is also this session's primary profile; choose a different [review] profile"
294 ));
295 }
296 Ok(())
297}
298
299#[derive(Clone)]
301pub struct TurnReviewHost {
302 events: mpsc::UnboundedSender<HostEvent>,
303 shared: Arc<HostShared>,
304}
305
306struct HostShared {
308 views: Mutex<BTreeMap<String, RuntimeReviewView>>,
309 changed: Arc<dyn Fn() + Send + Sync>,
310 shutdown: tokio::sync::OnceCell<Result<(), String>>,
311}
312
313impl std::fmt::Debug for TurnReviewHost {
314 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 formatter.write_str("TurnReviewHost")
316 }
317}
318
319impl TurnReviewHost {
320 #[must_use]
322 pub fn spawn(control: SessionManagerControl, config: ReviewConfigSource) -> Self {
323 Self::spawn_notifying(control, config, Arc::new(|| {}))
324 }
325
326 #[must_use]
329 pub fn spawn_notifying(
330 control: SessionManagerControl,
331 config: ReviewConfigSource,
332 changed: Arc<dyn Fn() + Send + Sync>,
333 ) -> Self {
334 Self::spawn_in_notifying(control, config, Arc::new(ControllerEnvironment), changed)
335 }
336
337 #[must_use]
340 pub fn spawn_in(
341 control: SessionManagerControl,
342 config: ReviewConfigSource,
343 environment: Arc<dyn ReviewEnvironment>,
344 ) -> Self {
345 Self::spawn_in_notifying(control, config, environment, Arc::new(|| {}))
346 }
347
348 #[must_use]
349 fn spawn_in_notifying(
350 control: SessionManagerControl,
351 config: ReviewConfigSource,
352 environment: Arc<dyn ReviewEnvironment>,
353 changed: Arc<dyn Fn() + Send + Sync>,
354 ) -> Self {
355 let (events, receiver) = mpsc::unbounded_channel();
356 let (persistence, persistence_receiver) = mpsc::unbounded_channel();
357 let shared = Arc::new(HostShared {
358 views: Mutex::default(),
359 changed,
360 shutdown: tokio::sync::OnceCell::new(),
361 });
362 let host = Self {
363 events: events.clone(),
364 shared: shared.clone(),
365 };
366 let persistence_task = tokio::spawn(persistence_loop(
367 environment.clone(),
368 events.clone(),
369 persistence_receiver,
370 ));
371 persistence
375 .send(PersistenceRequest::SweepInterrupted)
376 .expect("new review persistence lane accepts its initial sweep");
377 tokio::spawn(host_loop(
378 HostState {
379 control,
380 config,
381 environment,
382 shared,
383 events,
384 persistence: Some(persistence),
385 persistence_task: Some(persistence_task),
386 reviews: BTreeMap::new(),
387 preparing: BTreeSet::new(),
388 pending_open: BTreeMap::new(),
389 closing: BTreeSet::new(),
390 awaiting_forward_persistence: BTreeMap::new(),
391 next_epoch: 0,
392 sessions: BTreeMap::new(),
393 missing_reviewer_reported: BTreeSet::new(),
394 recovery_candidates: BTreeSet::new(),
395 recovery_in_flight: BTreeSet::new(),
396 },
397 receiver,
398 ));
399 host
400 }
401
402 pub fn observe(&self, session_id: &str, view: &ManagedSessionView) {
404 let _ = self.events.send(HostEvent::View {
409 session_id: session_id.to_owned(),
410 snapshot: view
411 .snapshot
412 .as_ref()
413 .map(|snapshot| Box::new(snapshot.materialized.clone())),
414 prompt_driven: view
415 .snapshot
416 .as_ref()
417 .is_some_and(|snapshot| snapshot.operational.active_prompt.is_some()),
418 });
419 }
420
421 pub async fn start(&self, session_id: &str, manual: bool) -> Result<(), StartRefusal> {
423 let (reply, answer) = oneshot::channel();
424 self.events
425 .send(HostEvent::Start {
426 session_id: session_id.to_owned(),
427 manual,
428 reply: Some(reply),
429 })
430 .map_err(|_| StartRefusal("the review host stopped".to_owned()))?;
431 answer
432 .await
433 .map_err(|_| StartRefusal("the review host stopped".to_owned()))?
434 }
435
436 pub async fn resolve(&self, session_id: &str, resolution: Resolution) -> Result<(), String> {
438 let (reply, answer) = oneshot::channel();
439 self.events
440 .send(HostEvent::Resolve {
441 session_id: session_id.to_owned(),
442 resolution,
443 reply,
444 })
445 .map_err(|_| "the review host stopped".to_owned())?;
446 answer
447 .await
448 .map_err(|_| "the review host stopped".to_owned())?
449 }
450
451 pub async fn shutdown(&self) -> Result<(), String> {
455 self.shared
456 .shutdown
457 .get_or_init(|| async {
458 let (reply, answer) = oneshot::channel();
459 self.events
460 .send(HostEvent::Shutdown { reply })
461 .map_err(|_| "the review host stopped".to_owned())?;
462 answer
463 .await
464 .map_err(|_| "the review host stopped during shutdown".to_owned())?
465 })
466 .await
467 .clone()
468 }
469
470 #[must_use]
472 pub fn views(&self) -> Vec<RuntimeReviewView> {
473 self.shared
474 .views
475 .lock()
476 .unwrap_or_else(std::sync::PoisonError::into_inner)
477 .values()
478 .cloned()
479 .collect()
480 }
481
482 #[must_use]
484 pub fn view(&self, session_id: &str) -> Option<RuntimeReviewView> {
485 self.shared
486 .views
487 .lock()
488 .unwrap_or_else(std::sync::PoisonError::into_inner)
489 .get(session_id)
490 .cloned()
491 }
492
493 #[must_use]
495 pub fn refuses_prompt(&self, session_id: &str) -> bool {
496 prompt_refusal(session_id).is_some()
497 }
498}
499
500enum HostEvent {
502 View {
503 session_id: String,
504 snapshot: Option<Box<MaterializedSession>>,
505 prompt_driven: bool,
508 },
509 Start {
510 session_id: String,
511 manual: bool,
512 reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
513 },
514 Prepared {
515 session_id: String,
516 manual: bool,
517 reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
518 prepared: Result<Prepared, StartRefusal>,
519 },
520 RecoveryPrepared {
521 session_id: String,
522 prepared: Result<Option<Prepared>, String>,
523 },
524 StateSaved {
525 session_id: String,
526 completion: PersistenceCompletion,
527 result: Result<(), String>,
528 },
529 Step {
538 session_id: String,
539 epoch: u64,
540 step: ReviewStep,
541 },
542 Resolve {
543 session_id: String,
544 resolution: Resolution,
545 reply: oneshot::Sender<Result<(), String>>,
546 },
547 Interrupted { interrupted: Vec<String> },
550 Shutdown {
551 reply: oneshot::Sender<Result<(), String>>,
552 },
553}
554
555#[derive(Debug, Clone, Copy, PartialEq, Eq)]
556enum PersistenceCompletion {
557 Open,
558 Forward,
559 Close,
560}
561
562enum PersistenceRequest {
563 SweepInterrupted,
564 Save {
565 session_id: String,
566 state: Box<TurnReviewState>,
567 completion: Option<PersistenceCompletion>,
568 },
569 ClearActive {
570 reply: oneshot::Sender<Result<(), String>>,
571 },
572}
573
574enum ReviewStep {
576 Delta(Result<Vec<mj_core::relay::RepoDelta>, String>),
577 Analysis(Result<String, String>),
578 RoleStarted {
579 role: String,
580 result: Result<(), String>,
581 },
582 RolePrompted {
583 role: String,
584 result: Result<(), String>,
585 },
586 PrimaryPrompted(Result<(), String>),
587 RoleEvents {
588 role: String,
589 result: Result<Vec<RelayEvent>, String>,
590 },
591 Dispatches(Result<Vec<mj_core::review::lanes::ReviewSubagentRequest>, String>),
592}
593
594struct Prepared {
596 state: TurnReviewState,
597 reviewer: ReviewerIdentity,
598 tier: ReviewTier,
599 materialized: Box<MaterializedSession>,
602 resume_forward: Option<PendingForward>,
606}
607
608struct PendingOpen {
609 epoch: u64,
610 manual: bool,
611 reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
612 prepared: Prepared,
613}
614
615#[derive(Debug, Clone, PartialEq, Eq)]
617struct ReviewerIdentity {
618 profile: String,
619 model: Option<String>,
620 effort: Option<String>,
621}
622
623struct ReviewSlot {
625 epoch: u64,
628 driver: TurnReviewDriver,
629 roles: BTreeMap<String, RoleTranscript>,
632 reviewer: ReviewerIdentity,
633 state: TurnReviewState,
634 generation: u64,
638}
639
640#[derive(Default)]
642struct RoleTranscript {
643 session: Option<MaterializedSession>,
644 cursor_ordinal: u64,
645 cursor_digest: String,
646}
647
648impl RoleTranscript {
649 fn apply(&mut self, session_id: &str, events: &[RelayEvent]) {
650 let session = self
651 .session
652 .get_or_insert_with(|| MaterializedSession::empty(session_id));
653 for event in events {
654 let Ok(projected) = mj_transcript::projection::project_relay_event(session, event)
655 else {
656 continue;
657 };
658 if mj_transcript::projection::apply_committed_projection_event(
659 session,
660 event,
661 projected.mutation,
662 )
663 .is_err()
664 {
665 continue;
666 }
667 self.cursor_ordinal = event.ordinal;
668 self.cursor_digest.clone_from(&event.digest);
669 }
670 }
671
672 fn latest_answer(&self) -> Option<String> {
675 let session = self.session.as_ref()?;
676 session
677 .transcript
678 .iter()
679 .rev()
680 .find(|item| item.is_nonempty_agent_message())
681 .and_then(|item| {
682 let mj_core::state::TranscriptBody::Agent { chunks, .. } = &item.body else {
683 return None;
684 };
685 Some(mj_core::transcript::materialized_chunks_text(chunks))
686 })
687 .filter(|text| !text.trim().is_empty())
688 }
689}
690
691struct HostState {
692 control: SessionManagerControl,
693 config: ReviewConfigSource,
694 environment: Arc<dyn ReviewEnvironment>,
695 shared: Arc<HostShared>,
696 events: mpsc::UnboundedSender<HostEvent>,
697 persistence: Option<mpsc::UnboundedSender<PersistenceRequest>>,
698 persistence_task: Option<tokio::task::JoinHandle<()>>,
699 reviews: BTreeMap<String, ReviewSlot>,
700 preparing: BTreeSet<String>,
705 pending_open: BTreeMap<String, PendingOpen>,
708 closing: BTreeSet<String>,
711 awaiting_forward_persistence: BTreeMap<String, Vec<ReviewRequest>>,
715 next_epoch: u64,
719 sessions: BTreeMap<String, SessionWatch>,
722 missing_reviewer_reported: BTreeSet<String>,
725 recovery_candidates: BTreeSet<String>,
728 recovery_in_flight: BTreeSet<String>,
729}
730
731struct SessionWatch {
732 execution: MaterializedExecutionState,
733 prompt_driven: bool,
735 materialized: Option<Box<MaterializedSession>>,
736}
737
738async fn host_loop(mut state: HostState, mut events: mpsc::UnboundedReceiver<HostEvent>) {
739 while let Some(event) = events.recv().await {
740 if state.handle(event).await {
741 break;
742 }
743 }
744}
745
746async fn persistence_loop(
747 environment: Arc<dyn ReviewEnvironment>,
748 events: mpsc::UnboundedSender<HostEvent>,
749 mut requests: mpsc::UnboundedReceiver<PersistenceRequest>,
750) {
751 while let Some(request) = requests.recv().await {
752 match request {
753 PersistenceRequest::SweepInterrupted => {
754 let environment = environment.clone();
755 match tokio::task::spawn_blocking(move || environment.clear_interrupted()).await {
756 Ok(Ok(interrupted)) if !interrupted.is_empty() => {
757 let _ = events.send(HostEvent::Interrupted { interrupted });
758 }
759 Ok(Ok(_)) => {}
760 Ok(Err(error)) => {
761 tracing::warn!(%error, "could not clear interrupted reviews");
762 }
763 Err(error) => {
764 tracing::warn!(%error, "the interrupted-review sweep did not run");
765 }
766 }
767 }
768 PersistenceRequest::Save {
769 session_id,
770 state,
771 completion,
772 } => {
773 let environment = environment.clone();
774 let owner = session_id.clone();
775 let result =
776 tokio::task::spawn_blocking(move || environment.save_state(&owner, &state))
777 .await
778 .map_err(|error| format!("review state persistence task stopped: {error}"))
779 .and_then(|result| result);
780 if let Some(completion) = completion {
781 let _ = events.send(HostEvent::StateSaved {
782 session_id,
783 completion,
784 result,
785 });
786 } else if let Err(error) = result {
787 tracing::warn!(
788 session_id = %session_id,
789 %error,
790 "could not record how far this session has been reviewed"
791 );
792 }
793 }
794 PersistenceRequest::ClearActive { reply } => {
795 let environment = environment.clone();
796 let result = tokio::task::spawn_blocking(move || {
797 environment.clear_interrupted().map(|_| ())
798 })
799 .await
800 .map_err(|error| format!("review shutdown persistence task stopped: {error}"))
801 .and_then(|result| result);
802 let _ = reply.send(result);
803 }
804 }
805 }
806}
807
808impl HostState {
809 async fn handle(&mut self, event: HostEvent) -> bool {
812 match event {
813 HostEvent::View {
814 session_id,
815 snapshot,
816 prompt_driven,
817 } => self.observe(session_id, snapshot, prompt_driven).await,
818 HostEvent::Start {
819 session_id,
820 manual,
821 reply,
822 } => self.begin(session_id, manual, reply),
823 HostEvent::Prepared {
824 session_id,
825 manual,
826 reply,
827 prepared,
828 } => self.prepared(session_id, manual, reply, prepared),
829 HostEvent::RecoveryPrepared {
830 session_id,
831 prepared,
832 } => self.recovery_prepared(session_id, prepared),
833 HostEvent::StateSaved {
834 session_id,
835 completion,
836 result,
837 } => self.state_saved(session_id, completion, result),
838 HostEvent::Resolve {
839 session_id,
840 resolution,
841 reply,
842 } => {
843 let answer = self.resolve(&session_id, resolution);
844 let _ = reply.send(answer);
845 }
846 HostEvent::Step {
847 session_id,
848 epoch,
849 step,
850 } => self.step(session_id, epoch, step),
851 HostEvent::Interrupted { interrupted } => {
852 for session_id in interrupted {
853 self.recovery_candidates.insert(session_id.clone());
854 if self.sessions.get(&session_id).is_some_and(|watch| {
855 matches!(watch.execution, MaterializedExecutionState::Idle)
856 }) {
857 self.begin_recovery(&session_id);
858 }
859 }
860 }
861 HostEvent::Shutdown { reply } => {
862 let result = self.shutdown().await;
863 let _ = reply.send(result);
864 return true;
865 }
866 }
867 false
868 }
869
870 fn step(&mut self, session_id: String, epoch: u64, step: ReviewStep) {
872 if self.reviews.get(&session_id).map(|slot| slot.epoch) != Some(epoch) {
875 return;
876 }
877 match step {
878 ReviewStep::Delta(result) => {
879 let requests = match result {
880 Ok(deltas) => self
881 .reviews
882 .get_mut(&session_id)
883 .map(|slot| slot.driver.delta_captured(deltas))
884 .unwrap_or_default(),
885 Err(error) => {
886 self.fail(
887 &session_id,
888 format!("the change could not be captured: {error}"),
889 );
890 return;
891 }
892 };
893 self.run(&session_id, requests);
894 }
895 ReviewStep::Analysis(result) => {
896 let requests = self
897 .reviews
898 .get_mut(&session_id)
899 .map(|slot| slot.driver.analysis_completed(result))
900 .unwrap_or_default();
901 self.run(&session_id, requests);
902 }
903 ReviewStep::RoleStarted { role, result } => {
904 let requests = match self.reviews.get_mut(&session_id) {
905 Some(slot) => match result {
906 Ok(()) => slot.driver.role_started(&role),
907 Err(error) if mj_review::lanes::lane_by_id(&role).is_some() => {
911 slot.driver.lane_failed(&role, error)
912 }
913 Err(error)
914 if matches!(
915 slot.driver.phase(),
916 TurnReviewPhase::Forwarding { .. }
917 ) =>
918 {
919 tracing::debug!(
920 session_id = %session_id,
921 role = %role,
922 %error,
923 "ignoring a late reviewer start result during primary handoff"
924 );
925 return;
926 }
927 Err(error) => {
928 self.fail(&session_id, error);
929 return;
930 }
931 },
932 None => return,
933 };
934 self.run(&session_id, requests);
935 }
936 ReviewStep::RolePrompted { role, result } => {
937 if let Err(error) = result {
938 if self.reviews.get(&session_id).is_some_and(|slot| {
939 matches!(slot.driver.phase(), TurnReviewPhase::Forwarding { .. })
940 }) {
941 tracing::debug!(
945 session_id = %session_id,
946 role = %role,
947 %error,
948 "ignoring a late reviewer-role result during primary handoff"
949 );
950 return;
951 }
952 self.fail(
953 &session_id,
954 format!("reviewing role {role:?} could not be prompted: {error}"),
955 );
956 }
957 }
958 ReviewStep::PrimaryPrompted(result) => {
959 let requests = match result {
960 Ok(()) => self
961 .reviews
962 .get_mut(&session_id)
963 .map(|slot| slot.driver.forward_succeeded())
964 .unwrap_or_default(),
965 Err(error) => self
966 .reviews
967 .get_mut(&session_id)
968 .map(|slot| slot.driver.forward_failed(error))
969 .unwrap_or_default(),
970 };
971 self.run(&session_id, requests);
972 }
973 ReviewStep::RoleEvents { role, result } => self.role_events(session_id, role, result),
974 ReviewStep::Dispatches(result) => {
975 let requests = match result {
976 Ok(requests) => self
977 .reviews
978 .get_mut(&session_id)
979 .map(|slot| slot.driver.lanes_dispatched(requests))
980 .unwrap_or_default(),
981 Err(error) => {
985 if self.reviews.get(&session_id).is_some_and(|slot| {
986 matches!(slot.driver.phase(), TurnReviewPhase::Forwarding { .. })
987 }) {
988 tracing::debug!(
989 session_id = %session_id,
990 %error,
991 "ignoring a late lane dispatch result during primary handoff"
992 );
993 return;
994 }
995 self.fail(
996 &session_id,
997 format!(
998 "the review could not collect the supervisor's specialists: {error}"
999 ),
1000 );
1001 return;
1002 }
1003 };
1004 self.run(&session_id, requests);
1005 }
1006 }
1007 }
1008
1009 async fn observe(
1011 &mut self,
1012 session_id: String,
1013 snapshot: Option<Box<MaterializedSession>>,
1014 prompt_driven: bool,
1015 ) {
1016 let execution = snapshot
1017 .as_ref()
1018 .map_or(MaterializedExecutionState::Idle, |snapshot| {
1019 snapshot.execution
1020 });
1021 let previous = self.sessions.insert(
1022 session_id.clone(),
1023 SessionWatch {
1024 execution,
1025 prompt_driven,
1026 materialized: snapshot,
1027 },
1028 );
1029 if self.recovery_candidates.contains(&session_id)
1030 && matches!(execution, MaterializedExecutionState::Idle)
1031 {
1032 self.begin_recovery(&session_id);
1033 return;
1034 }
1035 let finished_turn = previous.as_ref().is_some_and(|watch| {
1039 watch.prompt_driven
1040 && matches!(watch.execution, MaterializedExecutionState::Running { .. })
1041 }) && matches!(execution, MaterializedExecutionState::Idle);
1042 if !finished_turn || !(self.config)().enabled {
1043 return;
1044 }
1045 self.begin(session_id, false, None);
1046 }
1047
1048 fn begin(
1054 &mut self,
1055 session_id: String,
1056 manual: bool,
1057 reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
1058 ) {
1059 if crate::controller::move_session::move_owns_session(&session_id) {
1060 answer(reply, Err(StartRefusal("session is moving".to_owned())));
1061 return;
1062 }
1063 if let Some(refusal) = self.refuse_start(&session_id) {
1064 answer(reply, Err(refusal));
1065 return;
1066 }
1067 if self.preparing.contains(&session_id) {
1068 answer(
1069 reply,
1070 Err(StartRefusal("a review is already starting".to_owned())),
1071 );
1072 return;
1073 }
1074 let config = (self.config)();
1075 let Some(profile) = config.reviewer_profile().map(str::to_owned) else {
1076 let refusal = StartRefusal(
1079 "turn review needs a reviewer: set [review] profile in config.toml".to_owned(),
1080 );
1081 if self.missing_reviewer_reported.insert(session_id.clone()) {
1082 self.record_notice(&session_id, refusal.0.clone());
1083 }
1084 answer(reply, Err(refusal));
1085 return;
1086 };
1087 let reviewer = ReviewerIdentity {
1088 profile,
1089 model: config.model.clone(),
1090 effort: config.effort.clone(),
1091 };
1092 let tier = config.tier;
1093 let control = self.control.clone();
1094 let events = self.events.clone();
1095 let prepare_session = session_id.clone();
1096 let environment = self.environment.clone();
1097 hold_prompts(&session_id);
1101 self.preparing.insert(session_id);
1102 tokio::spawn(async move {
1103 let prepared = prepare(&control, &environment, &prepare_session, &reviewer, tier).await;
1104 let _ = events.send(HostEvent::Prepared {
1105 session_id: prepare_session,
1106 manual,
1107 reply,
1108 prepared,
1109 });
1110 });
1111 }
1112
1113 fn begin_recovery(&mut self, session_id: &str) {
1118 if !self.recovery_candidates.contains(session_id)
1119 || self.recovery_in_flight.contains(session_id)
1120 || self.reviews.contains_key(session_id)
1121 || self.preparing.contains(session_id)
1122 {
1123 return;
1124 }
1125 let Some(watch) = self.sessions.get(session_id) else {
1126 return;
1127 };
1128 if watch
1129 .materialized
1130 .as_ref()
1131 .is_none_or(|snapshot| !snapshot.queued_prompts.is_empty())
1132 {
1133 return;
1134 }
1135 hold_prompts(session_id);
1136 self.recovery_in_flight.insert(session_id.to_owned());
1137 let control = self.control.clone();
1138 let environment = self.environment.clone();
1139 let events = self.events.clone();
1140 let session_id = session_id.to_owned();
1141 tokio::spawn(async move {
1142 let prepared = prepare_recovery(&control, &environment, &session_id).await;
1143 let _ = events.send(HostEvent::RecoveryPrepared {
1144 session_id,
1145 prepared,
1146 });
1147 });
1148 }
1149
1150 fn recovery_prepared(
1151 &mut self,
1152 session_id: String,
1153 prepared: Result<Option<Prepared>, String>,
1154 ) {
1155 self.recovery_in_flight.remove(&session_id);
1156 match prepared {
1157 Ok(Some(prepared)) => {
1158 self.recovery_candidates.remove(&session_id);
1159 self.preparing.insert(session_id.clone());
1160 self.prepared(session_id, false, None, Ok(prepared));
1161 }
1162 Ok(None) => {
1163 self.recovery_candidates.remove(&session_id);
1164 release_prompts(&session_id);
1165 self.record_notice(
1166 &session_id,
1167 "Turn review was cancelled when Mjolnir restarted; the next review covers the same changes".to_owned(),
1168 );
1169 }
1170 Err(error) => {
1171 tracing::warn!(session_id = %session_id, %error, "could not reconcile an interrupted review handoff");
1174 release_prompts(&session_id);
1175 }
1176 }
1177 }
1178
1179 fn refuse_start(&self, session_id: &str) -> Option<StartRefusal> {
1181 if self.reviews.contains_key(session_id) {
1182 return Some(StartRefusal("a review is already open".to_owned()));
1183 }
1184 if self.recovery_candidates.contains(session_id)
1185 || self.recovery_in_flight.contains(session_id)
1186 {
1187 return Some(StartRefusal(
1188 "an interrupted review handoff is being reconciled".to_owned(),
1189 ));
1190 }
1191 let Some(watch) = self.sessions.get(session_id) else {
1192 return Some(StartRefusal("this session is not connected".to_owned()));
1193 };
1194 if !matches!(watch.execution, MaterializedExecutionState::Idle) {
1195 return Some(StartRefusal(
1196 "a review runs between turns; this one is still working".to_owned(),
1197 ));
1198 }
1199 let queued = watch
1200 .materialized
1201 .as_ref()
1202 .is_some_and(|materialized| !materialized.queued_prompts.is_empty());
1203 if queued {
1204 return Some(StartRefusal(
1207 "prompts are queued; the review waits for them".to_owned(),
1208 ));
1209 }
1210 None
1211 }
1212
1213 fn prepared(
1214 &mut self,
1215 session_id: String,
1216 manual: bool,
1217 reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
1218 prepared: Result<Prepared, StartRefusal>,
1219 ) {
1220 let prepared = match prepared {
1221 Ok(prepared) => prepared,
1222 Err(refusal) => {
1223 self.preparing.remove(&session_id);
1224 release_prompts(&session_id);
1225 answer(reply, Err(refusal));
1226 return;
1227 }
1228 };
1229 if self.reviews.contains_key(&session_id) {
1230 self.preparing.remove(&session_id);
1231 release_prompts(&session_id);
1232 let refusal = StartRefusal("a review is already open".to_owned());
1233 answer(reply, Err(refusal));
1234 return;
1235 }
1236 self.next_epoch = self.next_epoch.saturating_add(1);
1237 let epoch = self.next_epoch;
1238 let mut prepared = prepared;
1239 prepared.state.active = Some(format!("review-{epoch}"));
1240 let state = prepared.state.clone();
1241 self.pending_open.insert(
1242 session_id.clone(),
1243 PendingOpen {
1244 epoch,
1245 manual,
1246 reply,
1247 prepared,
1248 },
1249 );
1250 if let Err(error) =
1251 self.persist(session_id.clone(), state, Some(PersistenceCompletion::Open))
1252 {
1253 let pending = self
1254 .pending_open
1255 .remove(&session_id)
1256 .expect("pending review was just inserted");
1257 let retry_recovery = pending.prepared.resume_forward.is_some();
1258 self.preparing.remove(&session_id);
1259 if retry_recovery {
1260 self.recovery_candidates.insert(session_id.clone());
1261 }
1262 release_prompts(&session_id);
1263 answer(
1264 pending.reply,
1265 Err(StartRefusal(format!(
1266 "could not record the active review: {error}"
1267 ))),
1268 );
1269 }
1270 }
1271
1272 fn state_saved(
1273 &mut self,
1274 session_id: String,
1275 completion: PersistenceCompletion,
1276 result: Result<(), String>,
1277 ) {
1278 match completion {
1279 PersistenceCompletion::Open => {
1280 let Some(pending) = self.pending_open.remove(&session_id) else {
1281 return;
1282 };
1283 self.preparing.remove(&session_id);
1284 if let Err(error) = result {
1285 if pending.prepared.resume_forward.is_some() {
1286 self.recovery_candidates.insert(session_id.clone());
1287 }
1288 release_prompts(&session_id);
1289 answer(
1290 pending.reply,
1291 Err(StartRefusal(format!(
1292 "could not record the active review: {error}"
1293 ))),
1294 );
1295 return;
1296 }
1297 let seed = seed_from_session(
1298 &pending.prepared.materialized,
1299 pending.prepared.tier,
1300 &pending.prepared.state,
1301 if pending.manual {
1302 "manual"
1303 } else {
1304 "automatic"
1305 },
1306 );
1307 let (driver, requests) =
1308 if let Some(pending) = pending.prepared.resume_forward.clone() {
1309 let command_id = pending.command_id.clone();
1310 let (mut driver, _) = TurnReviewDriver::resume_forward(seed, pending);
1311 let requests = driver.forward(command_id);
1312 (driver, requests)
1313 } else {
1314 TurnReviewDriver::start(seed)
1315 };
1316 self.reviews.insert(
1317 session_id.clone(),
1318 ReviewSlot {
1319 epoch: pending.epoch,
1320 driver,
1321 roles: BTreeMap::new(),
1322 reviewer: pending.prepared.reviewer,
1323 state: pending.prepared.state,
1324 generation: 0,
1328 },
1329 );
1330 answer(pending.reply, Ok(()));
1331 self.run(&session_id, requests);
1332 }
1333 PersistenceCompletion::Forward => {
1334 let Some(requests) = self.awaiting_forward_persistence.remove(&session_id) else {
1335 return;
1336 };
1337 if let Err(error) = result {
1338 if let Some(slot) = self.reviews.get_mut(&session_id) {
1339 slot.driver.forward_failed(format!(
1340 "the handoff could not be recorded durably: {error}"
1341 ));
1342 }
1343 self.publish(&session_id);
1344 return;
1345 }
1346 self.run(&session_id, requests);
1347 }
1348 PersistenceCompletion::Close => {
1349 self.closing.remove(&session_id);
1350 if let Err(error) = result {
1351 tracing::warn!(
1352 session_id = %session_id,
1353 %error,
1354 "could not clear the active review marker"
1355 );
1356 }
1357 let notice = self.reviews.get(&session_id).and_then(|slot| {
1358 resolution_notice(slot.driver.phase(), slot.driver.last_verdict())
1359 });
1360 self.reviews.remove(&session_id);
1361 release_prompts(&session_id);
1362 if let Some(notice) = notice {
1363 self.record_notice(&session_id, notice);
1364 }
1365 self.publish(&session_id);
1366 }
1367 }
1368 }
1369
1370 fn persist(
1371 &self,
1372 session_id: String,
1373 state: TurnReviewState,
1374 completion: Option<PersistenceCompletion>,
1375 ) -> Result<(), String> {
1376 self.persistence
1377 .as_ref()
1378 .ok_or_else(|| "the review persistence lane stopped".to_owned())?
1379 .send(PersistenceRequest::Save {
1380 session_id,
1381 state: Box::new(state),
1382 completion,
1383 })
1384 .map_err(|_| "the review persistence lane stopped".to_owned())
1385 }
1386
1387 fn resolve(&mut self, session_id: &str, resolution: Resolution) -> Result<(), String> {
1389 let (requests, pending_state) = {
1390 let Some(slot) = self.reviews.get_mut(session_id) else {
1391 return Err("no review is open for that session".to_owned());
1392 };
1393 let requests = match resolution {
1394 Resolution::Forwarded => {
1395 if !slot.driver.can_forward() {
1396 return Err("there are no findings to forward".to_owned());
1397 }
1398 slot.driver.forward(
1399 new_command_id("review-forward").map_err(|error| format!("{error:#}"))?,
1400 )
1401 }
1402 Resolution::Dismissed => {
1403 if slot.driver.verdict().is_none() {
1404 return Err("the review has not reached a verdict yet".to_owned());
1405 }
1406 slot.driver.dismiss()
1407 }
1408 Resolution::Cancelled => slot.driver.cancel(),
1409 Resolution::NothingToReview | Resolution::CoverageStarted => {
1410 return Err("that is not a resolution a surface can ask for".to_owned());
1411 }
1412 };
1413 if requests.is_empty() {
1414 return Err("the review could not be resolved that way".to_owned());
1415 }
1416 let pending_state = if resolution == Resolution::Forwarded {
1417 let Some(pending) = slot.driver.pending_forward() else {
1418 return Err("the review handoff has no durable findings".to_owned());
1419 };
1420 slot.state.pending_forward = Some(pending);
1421 Some(slot.state.clone())
1422 } else {
1423 None
1424 };
1425 (requests, pending_state)
1426 };
1427 if let Some(state) = pending_state {
1428 self.awaiting_forward_persistence
1429 .insert(session_id.to_owned(), requests);
1430 if let Err(error) = self.persist(
1431 session_id.to_owned(),
1432 state,
1433 Some(PersistenceCompletion::Forward),
1434 ) {
1435 self.awaiting_forward_persistence.remove(session_id);
1436 if let Some(slot) = self.reviews.get_mut(session_id) {
1437 slot.driver.forward_failed(format!(
1438 "the handoff could not be recorded durably: {error}"
1439 ));
1440 }
1441 self.publish(session_id);
1442 return Err(error);
1443 }
1444 self.publish(session_id);
1445 return Ok(());
1446 }
1447 self.run(session_id, requests);
1448 Ok(())
1449 }
1450
1451 fn fail(&mut self, session_id: &str, message: impl Into<String>) {
1455 let Some(slot) = self.reviews.get_mut(session_id) else {
1456 return;
1457 };
1458 slot.state.active = None;
1459 let state = slot.state.clone();
1460 let requests = slot.driver.request_failed(message);
1461 release_prompts(session_id);
1464 if let Err(error) = self.persist(session_id.to_owned(), state, None) {
1465 tracing::warn!(session_id, %error, "could not queue failed review persistence");
1466 }
1467 self.run(session_id, requests);
1468 }
1469
1470 fn run(&mut self, session_id: &str, requests: Vec<ReviewRequest>) {
1471 for request in requests {
1472 self.run_one(session_id, request);
1473 }
1474 self.publish(session_id);
1475 }
1476
1477 fn run_one(&mut self, session_id: &str, request: ReviewRequest) {
1478 match request {
1479 ReviewRequest::CaptureDelta { baselines } => {
1480 self.review_step(
1481 session_id,
1482 ReviewerAction::CaptureDelta { baselines },
1483 |outcome| {
1484 ReviewStep::Delta(match outcome {
1485 Ok(ReviewerOutcome::Delta { repositories }) => Ok(repositories),
1486 other => Err(unexpected(other)),
1487 })
1488 },
1489 );
1490 }
1491 ReviewRequest::AnalyzeDelta { repositories } => {
1492 self.review_step(
1493 session_id,
1494 ReviewerAction::AnalyzeDelta { repositories },
1495 |outcome| {
1496 ReviewStep::Analysis(match outcome {
1497 Ok(ReviewerOutcome::ChangedFunctions { packet }) => Ok(packet),
1498 other => Err(unexpected(other)),
1499 })
1500 },
1501 );
1502 }
1503 ReviewRequest::StartRole { role, fresh } => self.start_role(session_id, role, fresh),
1504 ReviewRequest::PromptRole {
1505 role,
1506 command_id,
1507 prompt,
1508 } => {
1509 self.prompt_role(session_id, &role, command_id, prompt);
1510 self.poll_role(session_id, &role, Duration::ZERO);
1511 }
1512 ReviewRequest::PromptPrimary { command_id, prompt } => {
1513 self.prompt_primary(session_id, command_id, prompt);
1517 }
1518 ReviewRequest::PauseRole { role } => {
1519 let session_id = session_id.to_owned();
1520 self.spawn_reviewer(
1521 session_id.clone(),
1522 Some(role),
1523 ReviewerAction::Pause,
1524 move |outcome| {
1525 if let Err(error) = outcome {
1526 tracing::debug!(
1527 session_id = %session_id,
1528 %error,
1529 "pausing a review role failed"
1530 );
1531 }
1532 None
1533 },
1534 );
1535 }
1536 ReviewRequest::AdvanceBaseline {
1537 trees,
1538 reviewed_through_ordinal,
1539 } => {
1540 if let Some(slot) = self.reviews.get_mut(session_id) {
1541 slot.state.baselines = trees.clone();
1542 slot.state.reviewed_through_ordinal = reviewed_through_ordinal;
1543 slot.state.pending_forward = None;
1548 let state = slot.state.clone();
1549 if let Err(error) = self.persist(session_id.to_owned(), state, None) {
1550 tracing::warn!(session_id, %error, "could not queue review baseline persistence");
1551 }
1552 }
1553 let session_id = session_id.to_owned();
1554 self.spawn_reviewer(
1555 session_id.clone(),
1556 None,
1557 ReviewerAction::AdvanceBaseline { trees },
1558 move |outcome| {
1559 if let Err(error) = outcome {
1560 tracing::debug!(
1564 session_id = %session_id,
1565 %error,
1566 "the review baseline ref could not be pinned"
1567 );
1568 }
1569 None
1570 },
1571 );
1572 }
1573 ReviewRequest::RecordPriorReview { prior } => {
1574 if let Some(slot) = self.reviews.get_mut(session_id) {
1575 slot.state.prior_review = Some(prior);
1576 }
1577 }
1578 ReviewRequest::ClearPriorReview => {
1579 if let Some(slot) = self.reviews.get_mut(session_id) {
1580 slot.state.prior_review = None;
1581 let state = slot.state.clone();
1582 if let Err(error) = self.persist(session_id.to_owned(), state, None) {
1583 tracing::warn!(session_id, %error, "could not queue prior review cleanup");
1584 }
1585 }
1586 }
1587 ReviewRequest::Close => {
1588 if self.closing.contains(session_id) {
1589 return;
1590 }
1591 if let Some(slot) = self.reviews.get_mut(session_id) {
1592 slot.state.active = None;
1593 if matches!(
1594 slot.driver.phase(),
1595 TurnReviewPhase::Resolved(Resolution::Cancelled)
1596 ) {
1597 slot.state.pending_forward = None;
1602 }
1603 let state = slot.state.clone();
1604 self.closing.insert(session_id.to_owned());
1605 release_prompts(session_id);
1610 if let Err(error) = self.persist(
1611 session_id.to_owned(),
1612 state,
1613 Some(PersistenceCompletion::Close),
1614 ) {
1615 tracing::warn!(session_id, %error, "could not queue review close persistence");
1616 self.closing.remove(session_id);
1617 self.reviews.remove(session_id);
1618 release_prompts(session_id);
1619 }
1620 }
1621 }
1622 }
1623 }
1624
1625 fn start_role(&mut self, session_id: &str, role: String, fresh: bool) {
1627 let fresh_generation = if fresh {
1628 match next_review_generation() {
1629 Ok(generation) => Some(generation),
1630 Err(error) => {
1631 self.fail(
1632 session_id,
1633 format!("the reviewer could not allocate a fresh conversation: {error}"),
1634 );
1635 return;
1636 }
1637 }
1638 } else {
1639 None
1640 };
1641 let Some(slot) = self.reviews.get_mut(session_id) else {
1642 return;
1643 };
1644 if let Some(generation) = fresh_generation {
1649 slot.generation = generation;
1650 }
1651 let generation = slot.generation;
1652 let epoch = slot.epoch;
1653 let reviewer = slot.reviewer.clone();
1654 let repositories = slot.driver.repository_roots();
1655 let control = self.control.clone();
1656 let environment = self.environment.clone();
1657 let events = self.events.clone();
1658 let session_id = session_id.to_owned();
1659 tokio::spawn(async move {
1660 let result = launch_role(
1661 &control,
1662 &environment,
1663 &session_id,
1664 &role,
1665 &reviewer,
1666 generation,
1667 &repositories,
1668 )
1669 .await;
1670 let _ = events.send(HostEvent::Step {
1671 session_id,
1672 epoch,
1673 step: ReviewStep::RoleStarted { role, result },
1674 });
1675 });
1676 }
1677
1678 fn prompt_role(&mut self, session_id: &str, role: &str, command_id: String, prompt: String) {
1679 let Some(epoch) = self.reviews.get(session_id).map(|slot| slot.epoch) else {
1680 return;
1681 };
1682 let owner = session_id.to_owned();
1683 let result_role = role.to_owned();
1684 self.spawn_reviewer(
1685 session_id.to_owned(),
1686 Some(role.to_owned()),
1687 ReviewerAction::Submit {
1688 command_id,
1689 command: prompt_command(prompt),
1690 },
1691 move |outcome| {
1692 let result = match outcome {
1693 Ok(ReviewerOutcome::Accepted { .. }) => Ok(()),
1694 other => Err(unexpected(other)),
1695 };
1696 Some(HostEvent::Step {
1697 session_id: owner,
1698 epoch,
1699 step: ReviewStep::RolePrompted {
1700 role: result_role,
1701 result,
1702 },
1703 })
1704 },
1705 );
1706 }
1707
1708 fn prompt_primary(&mut self, session_id: &str, command_id: String, prompt: String) {
1713 let Some(epoch) = self.reviews.get(session_id).map(|slot| slot.epoch) else {
1714 return;
1715 };
1716 let Some(admission) = admit_review_delivery(session_id, epoch, &command_id) else {
1717 self.step(
1718 session_id.to_owned(),
1719 epoch,
1720 ReviewStep::PrimaryPrompted(Err(
1721 "the review handoff admission is no longer valid".to_owned()
1722 )),
1723 );
1724 return;
1725 };
1726 let control = self.control.clone();
1727 let events = self.events.clone();
1728 let session_id = session_id.to_owned();
1729 tokio::spawn(async move {
1730 let submitted = async {
1731 let handle = control
1732 .session(session_id.clone())
1733 .await
1734 .map_err(|error| format!("{error:#}"))?;
1735 handle
1736 .submit_review_delivery(admission, prompt_command(prompt))
1737 .await
1738 .map(|_| ())
1739 .map_err(|error| format!("{error:#}"))
1740 }
1741 .await;
1742 let _ = events.send(HostEvent::Step {
1743 session_id,
1744 epoch,
1745 step: ReviewStep::PrimaryPrompted(submitted),
1746 });
1747 });
1748 }
1749
1750 fn poll_role(&mut self, session_id: &str, role: &str, delay: Duration) {
1752 let Some(slot) = self.reviews.get_mut(session_id) else {
1753 return;
1754 };
1755 let transcript = slot.roles.entry(role.to_owned()).or_default();
1756 let after_ordinal = transcript.cursor_ordinal;
1757 let after_digest = if transcript.cursor_digest.is_empty() {
1758 mj_core::relay::RELAY_EVENT_GENESIS_DIGEST.to_owned()
1759 } else {
1760 transcript.cursor_digest.clone()
1761 };
1762 let epoch = slot.epoch;
1763 let control = self.control.clone();
1764 let events = self.events.clone();
1765 let session_id = session_id.to_owned();
1766 let role = role.to_owned();
1767 tokio::spawn(async move {
1768 if !delay.is_zero() {
1769 tokio::time::sleep(delay).await;
1770 }
1771 let result = reviewer_action(
1772 &control,
1773 &session_id,
1774 Some(role.clone()),
1775 ReviewerAction::Attach {
1776 after_ordinal,
1777 after_digest,
1778 },
1779 )
1780 .await;
1781 let result = match result {
1782 Ok(ReviewerOutcome::Attached(attachment)) => Ok(attachment.events),
1783 other => Err(unexpected(other)),
1784 };
1785 let _ = events.send(HostEvent::Step {
1786 session_id,
1787 epoch,
1788 step: ReviewStep::RoleEvents { role, result },
1789 });
1790 });
1791 }
1792
1793 fn role_events(
1794 &mut self,
1795 session_id: String,
1796 role: String,
1797 result: Result<Vec<RelayEvent>, String>,
1798 ) {
1799 let events = match result {
1800 Ok(events) => events,
1801 Err(error) => {
1802 if self.reviews.get(&session_id).is_some_and(|slot| {
1803 matches!(slot.driver.phase(), TurnReviewPhase::Forwarding { .. })
1804 }) {
1805 tracing::debug!(
1806 session_id = %session_id,
1807 role = %role,
1808 %error,
1809 "ignoring a late reviewer-role poll during primary handoff"
1810 );
1811 return;
1812 }
1813 self.fail(&session_id, error);
1814 return;
1815 }
1816 };
1817 let Some(slot) = self.reviews.get_mut(&session_id) else {
1818 return;
1819 };
1820 let idle = events.is_empty();
1821 let relay_session = role_session_id(&session_id, &role);
1822 let transcript = slot.roles.entry(role.clone()).or_default();
1823 transcript.apply(&relay_session, &events);
1824 let awaited = slot
1829 .driver
1830 .awaited_commands()
1831 .into_iter()
1832 .find(|(awaited_role, _)| *awaited_role == role)
1833 .map(|(_, command_id)| command_id);
1834 let completed = awaited.as_ref().is_some_and(|awaited| {
1835 events.iter().any(|event| {
1836 matches!(
1837 &event.observation,
1838 RelayObservation::CommandCompleted { command_id, outcome }
1839 if command_id == awaited
1840 && matches!(
1841 outcome,
1842 mj_core::relay::RelayCommandOutcome::Prompt { .. }
1843 )
1844 )
1845 })
1846 });
1847 let requests = match (completed, awaited) {
1848 (true, Some(awaited)) => {
1849 let answer = slot
1850 .roles
1851 .get(&role)
1852 .and_then(RoleTranscript::latest_answer)
1853 .unwrap_or_default();
1854 let slot = self.reviews.get_mut(&session_id).expect("the slot is open");
1855 slot.driver.role_turn_completed(&awaited, &answer)
1856 }
1857 _ => Vec::new(),
1858 };
1859 self.run(&session_id, requests);
1860 let Some(slot) = self.reviews.get(&session_id) else {
1861 return;
1862 };
1863 if slot.driver.active_roles().contains(&role) {
1864 self.poll_role(
1865 &session_id,
1866 &role,
1867 if idle {
1868 ROLE_POLL_IDLE_INTERVAL
1869 } else {
1870 Duration::ZERO
1871 },
1872 );
1873 }
1874 if role == SUPERVISOR_ROLE
1875 && self
1876 .reviews
1877 .get(&session_id)
1878 .is_some_and(|slot| slot.driver.supervisor_running())
1879 {
1880 self.poll_dispatches(&session_id);
1881 }
1882 }
1883
1884 fn poll_dispatches(&mut self, session_id: &str) {
1888 self.review_step(session_id, ReviewerAction::TakeLaneDispatches, |outcome| {
1889 ReviewStep::Dispatches(match outcome {
1890 Ok(ReviewerOutcome::LaneDispatches { requests }) => Ok(requests),
1891 other => Err(unexpected(other)),
1892 })
1893 });
1894 }
1895
1896 fn record_notice(&self, session_id: &str, text: String) {
1900 let control = self.control.clone();
1901 let session_id = session_id.to_owned();
1902 tokio::spawn(async move {
1903 let recorded = async {
1904 let handle = control
1905 .session(session_id.clone())
1906 .await
1907 .map_err(|error| format!("{error:#}"))?;
1908 let command_id =
1909 new_command_id("turn-review-notice").map_err(|error| format!("{error:#}"))?;
1910 handle
1911 .submit(command_id, RelayCommand::RecordNotice { text })
1912 .await
1913 .map(|_| ())
1914 .map_err(|error| format!("{error:#}"))
1915 }
1916 .await;
1917 if let Err(error) = recorded {
1920 tracing::debug!(
1921 session_id = %session_id,
1922 %error,
1923 "could not record a review notice in the conversation"
1924 );
1925 }
1926 });
1927 }
1928
1929 fn review_step(
1932 &mut self,
1933 session_id: &str,
1934 action: ReviewerAction,
1935 into_step: impl FnOnce(Result<ReviewerOutcome, String>) -> ReviewStep + Send + 'static,
1936 ) {
1937 let Some(epoch) = self.reviews.get(session_id).map(|slot| slot.epoch) else {
1938 return;
1939 };
1940 let owner = session_id.to_owned();
1941 self.spawn_reviewer(session_id.to_owned(), None, action, move |outcome| {
1942 Some(HostEvent::Step {
1943 session_id: owner,
1944 epoch,
1945 step: into_step(outcome),
1946 })
1947 });
1948 }
1949
1950 fn spawn_reviewer(
1951 &self,
1952 session_id: String,
1953 role: Option<String>,
1954 action: ReviewerAction,
1955 into_event: impl FnOnce(Result<ReviewerOutcome, String>) -> Option<HostEvent> + Send + 'static,
1956 ) {
1957 let control = self.control.clone();
1958 let events = self.events.clone();
1959 tokio::spawn(async move {
1960 let outcome = reviewer_action(&control, &session_id, role, action).await;
1961 if let Some(event) = into_event(outcome) {
1962 let _ = events.send(event);
1963 }
1964 });
1965 }
1966
1967 fn publish(&self, session_id: &str) {
1970 let mut views = self
1971 .shared
1972 .views
1973 .lock()
1974 .unwrap_or_else(std::sync::PoisonError::into_inner);
1975 let changed = match self.reviews.get(session_id) {
1976 Some(slot) => {
1977 let next = slot.view(session_id);
1978 if views.get(session_id) == Some(&next) {
1979 false
1980 } else {
1981 views.insert(session_id.to_owned(), next);
1982 true
1983 }
1984 }
1985 None => views.remove(session_id).is_some(),
1986 };
1987 drop(views);
1988 if changed {
1989 (self.shared.changed)();
1990 }
1991 }
1992
1993 async fn shutdown(&mut self) -> Result<(), String> {
1994 let session_ids = self
1995 .preparing
1996 .iter()
1997 .chain(self.reviews.keys())
1998 .chain(self.recovery_in_flight.iter())
1999 .chain(self.recovery_candidates.iter())
2000 .cloned()
2001 .collect::<BTreeSet<_>>();
2002 for pending in std::mem::take(&mut self.pending_open).into_values() {
2003 answer(
2004 pending.reply,
2005 Err(StartRefusal("the daemon is shutting down".to_owned())),
2006 );
2007 }
2008
2009 let lane = self.persistence.take();
2013 for (session_id, slot) in &mut self.reviews {
2014 slot.state.active = None;
2015 let queued = lane
2016 .as_ref()
2017 .ok_or_else(|| "the review persistence lane stopped".to_owned())
2018 .and_then(|persistence| {
2019 persistence
2020 .send(PersistenceRequest::Save {
2021 session_id: session_id.clone(),
2022 state: Box::new(slot.state.clone()),
2023 completion: None,
2024 })
2025 .map_err(|_| "the review persistence lane stopped".to_owned())
2026 });
2027 if let Err(error) = queued {
2028 tracing::warn!(session_id, %error, "could not queue review shutdown persistence");
2029 }
2030 }
2031 self.preparing.clear();
2032 self.closing.clear();
2033 self.reviews.clear();
2034 for session_id in &session_ids {
2035 release_prompts(session_id);
2036 self.publish(session_id);
2037 }
2038
2039 let clear_result = match lane {
2040 Some(lane) => {
2041 let (reply, cleared) = oneshot::channel();
2042 let sent = lane
2043 .send(PersistenceRequest::ClearActive { reply })
2044 .map_err(|_| "the review persistence lane stopped during shutdown".to_owned());
2045 drop(lane);
2046 match sent {
2047 Ok(()) => cleared.await.map_err(|_| {
2048 "the review persistence lane stopped before cleanup".to_owned()
2049 })?,
2050 Err(error) => Err(error),
2051 }
2052 }
2053 None => Err("the review persistence lane already stopped".to_owned()),
2054 };
2055 let task_result = match self.persistence_task.take() {
2056 Some(task) => task
2057 .await
2058 .map_err(|error| format!("review persistence lane panicked: {error}")),
2059 None => Ok(()),
2060 };
2061 clear_result.and(task_result)
2062 }
2063}
2064
2065impl ReviewSlot {
2066 fn view(&self, session_id: &str) -> RuntimeReviewView {
2067 let verdict = match self.driver.phase() {
2068 TurnReviewPhase::Forwarding { synthesis, .. } => Some(VerdictView {
2069 kind: VerdictKind::Findings,
2070 text: synthesis.clone(),
2071 allowed: if matches!(
2072 self.driver.phase(),
2073 TurnReviewPhase::Forwarding { error: Some(_), .. }
2074 ) {
2075 vec![Resolution::Forwarded, Resolution::Cancelled]
2076 } else {
2077 Vec::new()
2078 },
2079 }),
2080 _ => self.driver.verdict().map(|verdict| match verdict {
2081 ReviewVerdict::Clean => VerdictView {
2082 kind: VerdictKind::Clean,
2083 text: String::new(),
2084 allowed: Vec::new(),
2085 },
2086 ReviewVerdict::Findings { synthesis, .. } => VerdictView {
2087 kind: VerdictKind::Findings,
2088 text: synthesis.clone(),
2089 allowed: vec![
2090 Resolution::Forwarded,
2091 Resolution::Dismissed,
2092 Resolution::Cancelled,
2093 ],
2094 },
2095 ReviewVerdict::Failed { reason } => VerdictView {
2096 kind: VerdictKind::Failed,
2097 text: reason.clone(),
2098 allowed: vec![Resolution::Dismissed, Resolution::Cancelled],
2102 },
2103 }),
2104 };
2105 RuntimeReviewView {
2106 session_id: session_id.to_owned(),
2107 tier: self.driver.tier(),
2108 phase: self.driver.phase().clone(),
2109 roles: self.driver.roles(),
2110 status: self.driver.status().to_owned(),
2111 verdict,
2112 }
2113 }
2114}
2115
2116fn answer(
2117 reply: Option<oneshot::Sender<Result<(), StartRefusal>>>,
2118 result: Result<(), StartRefusal>,
2119) {
2120 if let Some(reply) = reply {
2121 let _ = reply.send(result);
2122 }
2123}
2124
2125fn unexpected(outcome: Result<ReviewerOutcome, String>) -> String {
2126 match outcome {
2127 Ok(other) => format!("unexpected reviewer response {other:?}"),
2128 Err(error) => error,
2129 }
2130}
2131
2132fn prompt_command(prompt: String) -> RelayCommand {
2133 RelayCommand::Prompt {
2134 prompt: vec![agent_client_protocol::schema::v1::ContentBlock::Text(
2135 agent_client_protocol::schema::v1::TextContent::new(prompt),
2136 )],
2137 }
2138}
2139
2140async fn reviewer_action(
2141 control: &SessionManagerControl,
2142 session_id: &str,
2143 role: Option<String>,
2144 action: ReviewerAction,
2145) -> Result<ReviewerOutcome, String> {
2146 let handle: ManagedSessionHandle = control
2147 .session(session_id.to_owned())
2148 .await
2149 .map_err(|error| format!("{error:#}"))?;
2150 handle
2151 .reviewer_as(role, action)
2152 .await
2153 .map_err(|error| format!("{error:#}"))
2154}
2155
2156async fn launch_role(
2158 control: &SessionManagerControl,
2159 environment: &Arc<dyn ReviewEnvironment>,
2160 session_id: &str,
2161 role: &str,
2162 reviewer: &ReviewerIdentity,
2163 generation: u64,
2164 repositories: &[PathBuf],
2165) -> Result<(), String> {
2166 let lane = mj_review::lanes::lane_by_id(role).is_some();
2171 let mcp_servers = if role == INTENT_ROLE {
2172 Vec::new()
2173 } else {
2174 mj_review::bifrost::review_mcp_servers(
2175 repositories,
2176 if lane {
2177 mj_review::lanes::LANE_BIFROST_TOOLSET
2178 } else {
2179 mj_review::lanes::SUPERVISOR_BIFROST_TOOLSET
2180 },
2181 )
2182 };
2183 let dispatch_tool = role == SUPERVISOR_ROLE;
2185 let staged = {
2186 let session_id = session_id.to_owned();
2187 let profile = reviewer.profile.clone();
2188 let environment = environment.clone();
2189 tokio::task::spawn_blocking(move || {
2190 environment.stage(
2191 &session_id,
2192 &profile,
2193 generation,
2194 &mcp_servers,
2195 dispatch_tool,
2196 )
2197 })
2198 .await
2199 .map_err(|error| format!("staging the reviewer stopped: {error}"))??
2200 };
2201 let mut config = staged;
2202 config.model = reviewer.model.clone();
2203 config.effort = reviewer.effort.clone();
2204 match reviewer_action(
2205 control,
2206 session_id,
2207 Some(role.to_owned()),
2208 ReviewerAction::Start {
2209 config: Box::new(config),
2210 },
2211 )
2212 .await
2213 {
2214 Ok(ReviewerOutcome::Started(_)) => Ok(()),
2215 other => Err(unexpected(other)),
2216 }
2217}
2218
2219async fn prepare(
2221 control: &SessionManagerControl,
2222 environment: &Arc<dyn ReviewEnvironment>,
2223 session_id: &str,
2224 reviewer: &ReviewerIdentity,
2225 tier: ReviewTier,
2226) -> Result<Prepared, StartRefusal> {
2227 let profile = reviewer.profile.clone();
2228 let session = session_id.to_owned();
2229 let environment = environment.clone();
2230 let checked = tokio::task::spawn_blocking(move || -> Result<TurnReviewState, String> {
2233 environment.check(&session, &profile)?;
2234 environment.load_state(&session)
2235 })
2236 .await
2237 .map_err(|error| StartRefusal(format!("preparing the review stopped: {error}")))?;
2238 let state = checked.map_err(StartRefusal)?;
2239 let handle = control
2244 .session(session_id.to_owned())
2245 .await
2246 .map_err(|error| StartRefusal(format!("{error:#}")))?;
2247 match handle.reviewer(ReviewerAction::Status).await {
2248 Ok(ReviewerOutcome::Status(state)) if state.active_prompt.is_some() => {
2249 return Err(StartRefusal(
2250 "the reviewer is busy with a second opinion".to_owned(),
2251 ));
2252 }
2253 Ok(_) => {}
2254 Err(error) => return Err(StartRefusal(format!("{error:#}"))),
2255 }
2256 let view = handle.view();
2260 if !view.connected {
2261 return Err(StartRefusal("this session is not connected".to_owned()));
2262 }
2263 let Some(snapshot) = view.snapshot else {
2264 return Err(StartRefusal(
2265 "this session has no transcript yet".to_owned(),
2266 ));
2267 };
2268 if !matches!(
2269 snapshot.materialized.execution,
2270 MaterializedExecutionState::Idle
2271 ) {
2272 return Err(StartRefusal(
2273 "a review runs between turns; this one is still working".to_owned(),
2274 ));
2275 }
2276 if !snapshot.materialized.queued_prompts.is_empty() {
2277 return Err(StartRefusal(
2278 "prompts are queued; the review waits for them".to_owned(),
2279 ));
2280 }
2281 Ok(Prepared {
2282 state,
2283 reviewer: reviewer.clone(),
2284 tier,
2285 materialized: Box::new(snapshot.materialized),
2286 resume_forward: None,
2287 })
2288}
2289
2290async fn prepare_recovery(
2294 control: &SessionManagerControl,
2295 environment: &Arc<dyn ReviewEnvironment>,
2296 session_id: &str,
2297) -> Result<Option<Prepared>, String> {
2298 let session = session_id.to_owned();
2299 let environment = environment.clone();
2300 let state = tokio::task::spawn_blocking(move || environment.load_state(&session))
2301 .await
2302 .map_err(|error| format!("loading the pending review handoff stopped: {error}"))??;
2303 let Some(pending) = state.pending_forward.clone() else {
2304 return Ok(None);
2305 };
2306 let handle = control
2307 .session(session_id.to_owned())
2308 .await
2309 .map_err(|error| format!("{error:#}"))?;
2310 let view = handle.view();
2311 if !view.connected {
2312 return Err("the primary session is not connected".to_owned());
2313 }
2314 let Some(snapshot) = view.snapshot else {
2315 return Err("the primary session has no transcript yet".to_owned());
2316 };
2317 if !matches!(
2318 snapshot.materialized.execution,
2319 MaterializedExecutionState::Idle
2320 ) {
2321 return Err("the primary session is still working".to_owned());
2322 }
2323 if !snapshot.materialized.queued_prompts.is_empty() {
2324 return Err("prompts are queued; the pending handoff waits for them".to_owned());
2325 }
2326 Ok(Some(Prepared {
2327 state,
2328 reviewer: ReviewerIdentity {
2330 profile: String::new(),
2331 model: None,
2332 effort: None,
2333 },
2334 tier: ReviewTier::Quick,
2335 materialized: Box::new(snapshot.materialized),
2336 resume_forward: Some(pending),
2337 }))
2338}
2339
2340#[must_use]
2342pub fn resolution_notice(
2343 phase: &TurnReviewPhase,
2344 last_verdict: Option<&ReviewVerdict>,
2345) -> Option<String> {
2346 let TurnReviewPhase::Resolved(resolution) = phase else {
2347 return None;
2348 };
2349 Some(match resolution {
2350 Resolution::Forwarded => "Review findings sent to the agent".to_owned(),
2351 Resolution::Dismissed => match last_verdict {
2352 Some(ReviewVerdict::Clean) => "Review complete: no material findings".to_owned(),
2353 Some(ReviewVerdict::Failed { .. }) => {
2354 "Review failed; the change stays unreviewed".to_owned()
2355 }
2356 _ => "Review dismissed".to_owned(),
2357 },
2358 Resolution::Cancelled => match last_verdict {
2359 Some(ReviewVerdict::Failed { .. }) => {
2360 "Review failed; the change stays unreviewed".to_owned()
2361 }
2362 _ => "Review cancelled".to_owned(),
2363 },
2364 Resolution::NothingToReview => "Nothing to review: the turn changed no files".to_owned(),
2365 Resolution::CoverageStarted => {
2366 "Review coverage starts here; the next completed turn is reviewed".to_owned()
2367 }
2368 })
2369}
2370
2371fn seed_from_session(
2378 session: &MaterializedSession,
2379 tier: ReviewTier,
2380 state: &TurnReviewState,
2381 _trigger: &str,
2382) -> TurnReviewSeed {
2383 let reviewed_through = state.reviewed_through_ordinal;
2384 let mut task = String::new();
2385 let mut user_messages = Vec::new();
2386 let mut initial_result = String::new();
2387 let mut trajectory = Vec::new();
2388 for item in &session.transcript {
2389 match &item.body {
2390 mj_core::state::TranscriptBody::User { content } => {
2391 let text = mj_core::transcript::materialized_content_text(content);
2392 let text = text.trim();
2393 if text.is_empty() {
2394 continue;
2395 }
2396 if mj_core::second_opinion::is_control_origin_prompt(text) {
2397 continue;
2398 }
2399 task = text.to_owned();
2400 user_messages.push(UserMessage::prompt(text));
2405 if item.position > reviewed_through {
2406 trajectory.push(format!("user: {text}"));
2407 }
2408 }
2409 mj_core::state::TranscriptBody::Agent { chunks, .. } => {
2410 if !item.is_nonempty_agent_message() {
2411 continue;
2412 }
2413 let text = mj_core::transcript::materialized_chunks_text(chunks);
2414 let text = text.trim();
2415 if text.is_empty() {
2416 continue;
2417 }
2418 initial_result = text.to_owned();
2419 if item.position > reviewed_through {
2420 trajectory.push(format!("agent: {text}"));
2421 }
2422 }
2423 mj_core::state::TranscriptBody::Tool { call, .. } => {
2424 let title = call
2428 .get("title")
2429 .and_then(serde_json::Value::as_str)
2430 .unwrap_or_default()
2431 .trim();
2432 if item.position > reviewed_through && !title.is_empty() {
2433 trajectory.push(format!("tool: {title}"));
2434 }
2435 }
2436 _ => {}
2437 }
2438 }
2439 TurnReviewSeed {
2440 tier,
2441 task,
2442 user_messages,
2443 initial_result,
2444 trajectory: trajectory.join("\n"),
2445 baselines: state.baselines.clone(),
2446 through_ordinal: session.applied_event_ordinal,
2447 prior_review: state.prior_review.clone(),
2448 }
2449}
2450
2451#[cfg(test)]
2452mod tests {
2453 use super::*;
2454 use crate::session_manager::{
2455 RelaySessionTarget, RemoteSessionRequest, RemoteSessionRequests,
2456 spawn_remote_session_manager,
2457 };
2458 use mj_core::review::driver::{RoleState, RoleStatus};
2459 use mj_core::state::{ManagedSessionSnapshot, MaterializedSession};
2460
2461 use mj_core::relay::{
2462 RELAY_EVENT_FORMAT_V1, RelayCommandOutcome, RelayOperationalState, relay_event_digest,
2463 };
2464
2465 fn session_id(test: &str) -> String {
2469 format!("018f9dd2-a3b4-7c8d-9000-{test}")
2470 }
2471
2472 #[test]
2473 fn review_activity_follows_typed_transitions_without_reading_progress_prose() {
2474 let mut view = RuntimeReviewView {
2475 session_id: "activity".to_owned(),
2476 tier: ReviewTier::Quick,
2477 phase: TurnReviewPhase::LaunchingReviewer,
2478 roles: Vec::new(),
2479 status: "validating configuration".to_owned(),
2480 verdict: None,
2481 };
2482 assert_eq!(view.activity_label(), Some("Reviewing"));
2483 assert!(view.is_working());
2484 view.phase = TurnReviewPhase::Running {
2485 roles: vec![RoleStatus {
2486 role: mj_core::review::driver::VALIDATOR_ROLE.to_owned(),
2487 label: "Validator".to_owned(),
2488 state: RoleState::Running,
2489 }],
2490 };
2491 view.status = "checking source".to_owned();
2492 assert_eq!(view.activity_label(), Some("Validating"));
2493 assert!(view.is_working());
2494 view.phase = TurnReviewPhase::Verdict(ReviewVerdict::Findings {
2495 synthesis: "[P2] app.py:1 -- incorrect bounds".to_owned(),
2496 evidence: Default::default(),
2497 });
2498 assert_eq!(view.activity_label(), Some("Findings"));
2499 assert!(!view.is_working());
2500 view.phase = TurnReviewPhase::Verdict(ReviewVerdict::Failed {
2501 reason: "reviewer unavailable".to_owned(),
2502 });
2503 assert_eq!(view.activity_label(), Some("Review failed"));
2504 assert!(!view.is_working());
2505 view.phase = TurnReviewPhase::Forwarding {
2506 synthesis: "findings".into(),
2507 evidence: Default::default(),
2508 command_id: "forward".into(),
2509 error: None,
2510 };
2511 assert!(view.is_working());
2512 if let TurnReviewPhase::Forwarding { error, .. } = &mut view.phase {
2513 *error = Some("relay unavailable".into());
2514 }
2515 assert!(!view.is_working());
2516 view.phase = TurnReviewPhase::Resolved(Resolution::Cancelled);
2517 assert_eq!(view.activity_label(), None);
2518 assert!(!view.is_working());
2519 }
2520
2521 #[test]
2522 fn resolution_notices_keep_the_verdict_context_after_close() {
2523 let resolved_dismissed = TurnReviewPhase::Resolved(Resolution::Dismissed);
2524 assert_eq!(
2525 resolution_notice(&resolved_dismissed, Some(&ReviewVerdict::Clean)),
2526 Some("Review complete: no material findings".to_owned())
2527 );
2528 assert_eq!(
2529 resolution_notice(
2530 &TurnReviewPhase::Resolved(Resolution::Cancelled),
2531 Some(&ReviewVerdict::Failed {
2532 reason: "harness failed".to_owned(),
2533 }),
2534 ),
2535 Some("Review failed; the change stays unreviewed".to_owned())
2536 );
2537 assert_eq!(
2538 resolution_notice(
2539 &resolved_dismissed,
2540 Some(&ReviewVerdict::Findings {
2541 synthesis: "[P1] broken".to_owned(),
2542 evidence: Default::default(),
2543 }),
2544 ),
2545 Some("Review dismissed".to_owned())
2546 );
2547 }
2548
2549 fn user_prompt(position: u64, text: &str) -> Arc<mj_core::state::TranscriptItem> {
2550 Arc::new(mj_core::state::TranscriptItem {
2551 stable_id: format!("user:{position}"),
2552 position,
2553 latest_content_event_ordinal: None,
2554 created_at_ms: 0,
2555 last_changed_at_ms: 0,
2556 body: mj_core::state::TranscriptBody::User {
2557 content: vec![serde_json::json!({
2558 "type": "text",
2559 "text": text,
2560 })],
2561 },
2562 })
2563 }
2564
2565 #[test]
2566 fn seed_uses_the_latest_real_prompt_and_keeps_history_for_intent() {
2567 let mut session = MaterializedSession::empty("seed-prompts");
2568 session.applied_event_ordinal = 5;
2569 session.transcript = vec![
2570 user_prompt(1, "implement the old parser"),
2571 user_prompt(2, "support parse_range"),
2572 user_prompt(3, "[HARNESS NOTE: review the parser]"),
2573 user_prompt(4, "also finish the parser error path"),
2574 user_prompt(5, "[HARNESS NOTE: forwarded findings]"),
2575 ];
2576 let mut state = TurnReviewState {
2577 reviewed_through_ordinal: 1,
2578 ..TurnReviewState::default()
2579 };
2580
2581 let seed = seed_from_session(&session, ReviewTier::Extended, &state, "manual");
2582 assert_eq!(seed.task, "also finish the parser error path");
2583 assert_eq!(
2584 seed.user_messages
2585 .iter()
2586 .map(|message| message.text.as_str())
2587 .collect::<Vec<_>>(),
2588 vec![
2589 "implement the old parser",
2590 "support parse_range",
2591 "also finish the parser error path",
2592 ],
2593 "intent receives real prompts in chronological order"
2594 );
2595 assert!(!seed.trajectory.contains("HARNESS NOTE"));
2596
2597 state.reviewed_through_ordinal = 5;
2600 let corrective = seed_from_session(&session, ReviewTier::Extended, &state, "manual");
2601 assert_eq!(corrective.task, "also finish the parser error path");
2602 assert_eq!(corrective.user_messages.len(), 3);
2603 }
2604
2605 fn operational() -> RelayOperationalState {
2608 serde_json::from_value(serde_json::json!({
2609 "session_id": "reviewer",
2610 "execution": "idle",
2611 "latest_ordinal": 0,
2612 "latest_digest": mj_core::relay::RELAY_EVENT_GENESIS_DIGEST,
2613 "acknowledged_through": 0,
2614 "acknowledged_digest": mj_core::relay::RELAY_EVENT_GENESIS_DIGEST,
2615 "recovery_floor_ordinal": 0,
2616 "recovery_floor_digest": mj_core::relay::RELAY_EVENT_GENESIS_DIGEST,
2617 "native_session_id": null,
2618 "agent_capabilities": null,
2619 "agent_info": null,
2620 "config_options": [],
2621 "available_commands": [],
2622 "config": {},
2623 "active_prompt": null,
2624 "queued_prompts": [],
2625 "checkpoint_barrier": null,
2626 "checkpoint_ready": null,
2627 }))
2628 .expect("the operational state fixture matches its schema")
2629 }
2630
2631 struct FakeManager {
2638 session: String,
2639 control: SessionManagerControl,
2640 requests: RemoteSessionRequests,
2641 publisher: crate::session_manager::RemoteSessionPublisher,
2642 _shutdown: crate::session_manager::SessionManagerShutdown,
2643 _targets: tokio::sync::watch::Sender<Vec<RelaySessionTarget>>,
2644 }
2645
2646 impl FakeManager {
2647 async fn new(session: &str) -> Self {
2650 let channels = spawn_remote_session_manager().expect("remote manager");
2651 channels.targets.send_replace(vec![RelaySessionTarget {
2654 session_id: session.to_owned(),
2655 spec: crate::targets::CommandSpec::new("true", Vec::<String>::new()),
2656 worker_recovery: None,
2657 project_memory: None,
2658 }]);
2659 let manager = Self {
2660 session: session.to_owned(),
2661 control: channels.control,
2662 requests: channels.requests,
2663 publisher: channels.publisher,
2664 _shutdown: channels.shutdown,
2665 _targets: channels.targets,
2666 };
2667 manager
2671 .publisher
2672 .publish(
2673 session.to_owned(),
2674 view(session, mj_core::state::MaterializedExecutionState::Idle),
2675 )
2676 .await
2677 .expect("publish the first view");
2678 manager
2679 .control
2680 .wait_for_session(session, Duration::from_secs(5))
2681 .await
2682 .expect("the fake manager manages the session");
2683 manager
2684 }
2685
2686 async fn next(&mut self) -> RemoteSessionRequest {
2688 tokio::time::timeout(Duration::from_secs(5), self.requests.recv())
2689 .await
2690 .expect("the host makes a request")
2691 .expect("the manager is still running")
2692 }
2693
2694 async fn next_reviewer(
2697 &mut self,
2698 wanted: impl Fn(&Option<String>, &ReviewerAction) -> bool,
2699 ) -> (
2700 Option<String>,
2701 ReviewerAction,
2702 oneshot::Sender<Result<ReviewerOutcome, String>>,
2703 ) {
2704 loop {
2705 match self.next().await {
2706 RemoteSessionRequest::Reviewer {
2707 role,
2708 action,
2709 reply,
2710 ..
2711 } => {
2712 if wanted(&role, &action) {
2713 return (role, action, reply);
2714 }
2715 let _ = reply.send(answer_for(&action));
2718 }
2719 RemoteSessionRequest::Submit { reply, .. } => {
2720 let _ = reply.send(Ok(1));
2721 }
2722 other => panic!("unexpected request {}", other.session_id()),
2723 }
2724 }
2725 }
2726 }
2727
2728 fn answer_for(action: &ReviewerAction) -> Result<ReviewerOutcome, String> {
2731 match action {
2732 ReviewerAction::Status => Ok(ReviewerOutcome::Status(Box::new(operational()))),
2733 ReviewerAction::CaptureDelta { .. } => Ok(ReviewerOutcome::Delta {
2734 repositories: Vec::new(),
2735 }),
2736 ReviewerAction::AnalyzeDelta { .. } => Ok(ReviewerOutcome::ChangedFunctions {
2737 packet: "- edited retry()".to_owned(),
2738 }),
2739 ReviewerAction::AdvanceBaseline { .. } => Ok(ReviewerOutcome::BaselineAdvanced),
2740 ReviewerAction::TakeLaneDispatches => Ok(ReviewerOutcome::LaneDispatches {
2741 requests: Vec::new(),
2742 }),
2743 ReviewerAction::Attach { .. } => Ok(ReviewerOutcome::Attached(Box::new(
2744 crate::worker_client::RelayAttachment {
2745 state: operational(),
2746 events: Vec::new(),
2747 through_ordinal: 0,
2748 through_digest: mj_core::relay::RELAY_EVENT_GENESIS_DIGEST.to_owned(),
2749 },
2750 ))),
2751 ReviewerAction::Pause => Ok(ReviewerOutcome::Paused),
2752 ReviewerAction::Submit { .. } => Ok(ReviewerOutcome::Accepted { ordinal: 1 }),
2753 ReviewerAction::Start { .. } => Err("no harness in this test".to_owned()),
2754 ReviewerAction::RespondElicitation { .. } => Ok(ReviewerOutcome::ElicitationResolved),
2755 ReviewerAction::Acknowledge { .. } => {
2756 Ok(ReviewerOutcome::Acknowledged(mj_core::relay::RelayCursor {
2757 ordinal: 0,
2758 digest: mj_core::relay::RELAY_EVENT_GENESIS_DIGEST.to_owned(),
2759 }))
2760 }
2761 }
2762 }
2763
2764 fn view(
2767 session: &str,
2768 execution: mj_core::state::MaterializedExecutionState,
2769 ) -> ManagedSessionView {
2770 view_of(session, execution, true)
2771 }
2772
2773 fn view_of(
2776 session: &str,
2777 execution: mj_core::state::MaterializedExecutionState,
2778 prompt_driven: bool,
2779 ) -> ManagedSessionView {
2780 let mut materialized = MaterializedSession::empty(session);
2781 materialized.execution = execution;
2782 materialized.applied_event_ordinal = 12;
2783 let mut operational = operational();
2784 if prompt_driven
2785 && matches!(
2786 execution,
2787 mj_core::state::MaterializedExecutionState::Running { .. }
2788 )
2789 {
2790 operational.active_prompt = Some(mj_core::relay::ActiveRelayPrompt {
2791 command_id: "prompt-1".to_owned(),
2792 created_at_ms: 0,
2793 started_at_ms: 0,
2794 });
2795 }
2796 ManagedSessionView {
2797 snapshot: Some(ManagedSessionSnapshot {
2798 subagent_requests: Vec::new(),
2799 subagent_results: Vec::new(),
2800 window: mj_core::state::ProjectionWindow::of(&materialized),
2801 materialized,
2802 operational,
2803 latest_credential_sync_signal: None,
2804 worker_build: None,
2805 }),
2806 connected: true,
2807 error: None,
2808 }
2809 }
2810
2811 struct FakeEnvironment {
2815 staged: Mutex<Vec<(String, u64, bool)>>,
2816 state: Mutex<TurnReviewState>,
2819 writes: Mutex<Vec<(TurnReviewState, std::thread::ThreadId)>>,
2820 save_gate: Mutex<Option<Arc<SaveGate>>>,
2821 }
2822
2823 struct SaveGate {
2824 entered: tokio::sync::Notify,
2825 released: Mutex<bool>,
2826 released_changed: std::sync::Condvar,
2827 }
2828
2829 impl SaveGate {
2830 fn new() -> Arc<Self> {
2831 Arc::new(Self {
2832 entered: tokio::sync::Notify::new(),
2833 released: Mutex::new(false),
2834 released_changed: std::sync::Condvar::new(),
2835 })
2836 }
2837
2838 async fn entered(&self) {
2839 self.entered.notified().await;
2840 }
2841
2842 fn wait(&self) {
2843 self.entered.notify_one();
2844 let released = self
2845 .released
2846 .lock()
2847 .unwrap_or_else(std::sync::PoisonError::into_inner);
2848 drop(
2849 self.released_changed
2850 .wait_while(released, |released| !*released)
2851 .unwrap_or_else(std::sync::PoisonError::into_inner),
2852 );
2853 }
2854
2855 fn release(&self) {
2856 *self
2857 .released
2858 .lock()
2859 .unwrap_or_else(std::sync::PoisonError::into_inner) = true;
2860 self.released_changed.notify_all();
2861 }
2862 }
2863
2864 impl FakeEnvironment {
2865 fn new() -> Arc<Self> {
2866 Arc::new(Self {
2867 staged: Mutex::new(Vec::new()),
2868 state: Mutex::new(TurnReviewState::default()),
2869 writes: Mutex::new(Vec::new()),
2870 save_gate: Mutex::new(None),
2871 })
2872 }
2873
2874 fn state(&self) -> TurnReviewState {
2875 self.state
2876 .lock()
2877 .unwrap_or_else(std::sync::PoisonError::into_inner)
2878 .clone()
2879 }
2880
2881 fn staged_roles(&self) -> Vec<(String, u64, bool)> {
2882 self.staged
2883 .lock()
2884 .unwrap_or_else(std::sync::PoisonError::into_inner)
2885 .clone()
2886 }
2887
2888 fn writes(&self) -> Vec<(TurnReviewState, std::thread::ThreadId)> {
2889 self.writes
2890 .lock()
2891 .unwrap_or_else(std::sync::PoisonError::into_inner)
2892 .clone()
2893 }
2894
2895 fn block_saves(&self) -> Arc<SaveGate> {
2896 let gate = SaveGate::new();
2897 *self
2898 .save_gate
2899 .lock()
2900 .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(gate.clone());
2901 gate
2902 }
2903 }
2904
2905 impl ReviewEnvironment for FakeEnvironment {
2906 fn check(&self, _session_id: &str, _profile: &str) -> Result<(), String> {
2907 Ok(())
2908 }
2909
2910 fn stage(
2911 &self,
2912 _session_id: &str,
2913 profile: &str,
2914 generation: u64,
2915 mcp_servers: &[mj_core::worker_launch::ReviewMcpServer],
2916 dispatch_tool: bool,
2917 ) -> Result<mj_core::worker_launch::ReviewerLaunchConfig, String> {
2918 self.staged
2919 .lock()
2920 .unwrap_or_else(std::sync::PoisonError::into_inner)
2921 .push((profile.to_owned(), generation, dispatch_tool));
2922 Ok(mj_core::worker_launch::ReviewerLaunchConfig {
2923 profile_id: profile.to_owned(),
2924 harness: mj_core::config::HarnessKind::Claude,
2925 bridge_command: std::path::PathBuf::from("/bin/false"),
2926 bridge_args: Vec::new(),
2927 environment: Default::default(),
2928 execution_policy: mj_core::config::ExecutionPolicy::ConfiguredApprovals,
2929 model: None,
2930 effort: None,
2931 generation,
2932 mcp_servers: mcp_servers.to_vec(),
2933 })
2934 }
2935
2936 fn load_state(&self, _session_id: &str) -> Result<TurnReviewState, String> {
2937 Ok(self.state())
2938 }
2939
2940 fn save_state(&self, _session_id: &str, state: &TurnReviewState) -> Result<(), String> {
2941 let gate = self
2942 .save_gate
2943 .lock()
2944 .unwrap_or_else(std::sync::PoisonError::into_inner)
2945 .clone();
2946 if let Some(gate) = gate {
2947 gate.wait();
2948 }
2949 *self
2950 .state
2951 .lock()
2952 .unwrap_or_else(std::sync::PoisonError::into_inner) = state.clone();
2953 self.writes
2954 .lock()
2955 .unwrap_or_else(std::sync::PoisonError::into_inner)
2956 .push((state.clone(), std::thread::current().id()));
2957 Ok(())
2958 }
2959
2960 fn clear_interrupted(&self) -> Result<Vec<String>, String> {
2961 self.state
2962 .lock()
2963 .unwrap_or_else(std::sync::PoisonError::into_inner)
2964 .active = None;
2965 Ok(Vec::new())
2966 }
2967 }
2968
2969 fn armed(profile: Option<&str>) -> ReviewConfigSource {
2970 let profile = profile.map(str::to_owned);
2971 Arc::new(move || ReviewConfig {
2972 enabled: true,
2973 tier: ReviewTier::Quick,
2974 profile: profile.clone(),
2975 model: None,
2976 effort: None,
2977 })
2978 }
2979
2980 #[test]
2981 fn the_reviewer_profile_must_be_separate_from_the_primary_profile() {
2982 let session = mj_core::state::SessionRecord {
2983 mjolnir_subagents: None,
2984 create_managed_worktree: None,
2985 id: "session-1".to_owned(),
2986 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2987 title: "task".to_owned(),
2988 harness_kind: mj_core::config::HarnessKind::Codex,
2989 last_profile: "primary".to_owned(),
2990 bundle_id: "bundle".to_owned(),
2991 project_directory: None,
2992 managed_worktree: None,
2993 target_template_id: "local".to_owned(),
2994 resource_allocation: None,
2995 additional_mounts: Vec::new(),
2996 container_cpus: None,
2997 container_memory: None,
2998 state: mj_core::state::SessionState::Running,
2999 archived: false,
3000 target: None,
3001 native_session_id: None,
3002 acp_session_title: None,
3003 session_title_override: None,
3004 created_at: "2026-01-01T00:00:00Z".to_owned(),
3005 updated_at: "2026-01-01T00:00:00Z".to_owned(),
3006 viewed_through_event_ordinal: 0,
3007 draft_input: String::new(),
3008 last_error: None,
3009 last_checkpoint_error: None,
3010 checkpoint: None,
3011 };
3012
3013 let refusal = validate_reviewer_assignment("session-1", Some(&session), "primary")
3014 .expect_err("one harness profile cannot review its own output independently");
3015 assert!(refusal.contains("primary profile"), "{refusal}");
3016 validate_reviewer_assignment("session-1", Some(&session), "reviewer")
3017 .expect("a separate reviewer profile is accepted");
3018 }
3019
3020 #[test]
3021 fn delivery_admission_bypasses_only_the_matching_held_prompt() {
3022 let session = session_id("admission");
3023 hold_prompts(&session);
3024 let admission = admit_review_delivery(&session, 7, "forward-7")
3025 .expect("the live review hold grants its own corrective command");
3026 assert!(review_delivery_admitted(&session, &admission));
3027 assert!(!review_delivery_admitted(
3028 &session,
3029 &ReviewDeliveryAdmission::new(session.clone(), 7, "other-command".to_owned())
3030 ));
3031 assert!(!review_delivery_admitted(
3032 &session,
3033 &ReviewDeliveryAdmission::new(session.clone(), 8, "forward-7".to_owned())
3034 ));
3035 assert!(prompt_refusal(&session).is_some());
3036 release_prompts(&session);
3037 }
3038
3039 async fn finish_a_turn(manager: &FakeManager, host: &TurnReviewHost) {
3043 for execution in [
3044 mj_core::state::MaterializedExecutionState::Running { started_at_ms: 0 },
3045 mj_core::state::MaterializedExecutionState::Idle,
3046 ] {
3047 let published = view(&manager.session, execution);
3048 let _ = manager
3049 .publisher
3050 .publish(manager.session.clone(), published.clone())
3051 .await;
3052 host.observe(&manager.session, &published);
3053 }
3054 }
3055
3056 #[tokio::test]
3059 async fn an_unconfigured_reviewer_is_reported_once_per_session() {
3060 let session = session_id("unreviewable");
3061 let session = session.as_str();
3062 let mut manager = FakeManager::new(session).await;
3063 let environment = FakeEnvironment::new();
3064 let host =
3065 TurnReviewHost::spawn_in(manager.control.clone(), armed(None), environment.clone());
3066
3067 finish_a_turn(&manager, &host).await;
3068 let request = manager.next().await;
3069 let RemoteSessionRequest::Submit { command, reply, .. } = request else {
3070 panic!("the only thing an unreviewable turn does is say so");
3071 };
3072 let RelayCommand::RecordNotice { text } = command else {
3073 panic!("the notice is a controller-authored conversation line");
3074 };
3075 assert!(
3076 text.contains("[review] profile"),
3077 "the notice names the key that fixes it: {text}"
3078 );
3079 let _ = reply.send(Ok(1));
3080
3081 finish_a_turn(&manager, &host).await;
3083 assert!(
3084 tokio::time::timeout(Duration::from_millis(300), manager.requests.recv())
3085 .await
3086 .is_err(),
3087 "a second unreviewable turn is silent"
3088 );
3089 assert!(!host.refuses_prompt(session));
3090 }
3091
3092 #[tokio::test]
3096 async fn a_self_started_turn_does_not_arm_an_automatic_review() {
3097 let session = session_id("selfstarted0");
3098 let session = session.as_str();
3099 let mut manager = FakeManager::new(session).await;
3100 let environment = FakeEnvironment::new();
3101 let host =
3102 TurnReviewHost::spawn_in(manager.control.clone(), armed(None), environment.clone());
3103
3104 for execution in [
3105 MaterializedExecutionState::Running { started_at_ms: 0 },
3106 MaterializedExecutionState::Idle,
3107 ] {
3108 host.observe(session, &view_of(session, execution, false));
3109 }
3110 assert!(
3111 tokio::time::timeout(Duration::from_millis(300), manager.requests.recv())
3112 .await
3113 .is_err(),
3114 "a turn the harness started on its own arms nothing"
3115 );
3116
3117 finish_a_turn(&manager, &host).await;
3120 assert!(
3121 matches!(manager.next().await, RemoteSessionRequest::Submit { .. }),
3122 "a prompt-driven turn still reaches the automatic edge"
3123 );
3124 host.shutdown().await.expect("shutdown the host");
3125 }
3126
3127 #[tokio::test]
3132 async fn a_headless_turn_is_reviewed_and_resolves_itself() {
3133 let session = session_id("headless000");
3134 let session = session.as_str();
3135 let mut manager = FakeManager::new(session).await;
3136 let environment = FakeEnvironment::new();
3137 let host = TurnReviewHost::spawn_in(
3138 manager.control.clone(),
3139 armed(Some("reviewer")),
3140 environment.clone(),
3141 );
3142
3143 finish_a_turn(&manager, &host).await;
3144
3145 let (_, action, reply) = manager
3147 .next_reviewer(|_, action| matches!(action, ReviewerAction::Status))
3148 .await;
3149 assert!(matches!(action, ReviewerAction::Status));
3150 assert!(
3151 host.refuses_prompt(session),
3152 "admission holds prompts before preparation waits on the session actor"
3153 );
3154 let _ = reply.send(Ok(ReviewerOutcome::Status(Box::new(operational()))));
3155
3156 let (_, action, reply) = manager
3158 .next_reviewer(|_, action| matches!(action, ReviewerAction::CaptureDelta { .. }))
3159 .await;
3160 assert!(matches!(action, ReviewerAction::CaptureDelta { .. }));
3161 assert!(
3162 host.refuses_prompt(session),
3163 "the review holds the session's prompts from the moment it opens"
3164 );
3165 assert!(
3166 environment.state().active.is_some(),
3167 "the active marker is durable before review work starts"
3168 );
3169 let _ = reply.send(Ok(ReviewerOutcome::Delta {
3171 repositories: vec![mj_core::relay::RepoDelta {
3172 root: std::path::PathBuf::from("/workspace/app"),
3173 baseline_tree: None,
3174 current_tree: "first-tree".to_owned(),
3175 patch: String::new(),
3176 diffstat: "0 files changed".to_owned(),
3177 changed_lines: 0,
3178 }],
3179 }));
3180
3181 let (_, action, reply) = manager
3182 .next_reviewer(|_, action| matches!(action, ReviewerAction::AdvanceBaseline { .. }))
3183 .await;
3184 let ReviewerAction::AdvanceBaseline { trees } = action else {
3185 unreachable!("matched above");
3186 };
3187 assert_eq!(
3188 trees
3189 .get(std::path::Path::new("/workspace/app"))
3190 .map(String::as_str),
3191 Some("first-tree"),
3192 "the capture becomes the baseline the next review measures from"
3193 );
3194 let _ = reply.send(Ok(ReviewerOutcome::BaselineAdvanced));
3195
3196 tokio::time::timeout(Duration::from_secs(5), async {
3197 while host.refuses_prompt(session)
3198 || host.view(session).is_some()
3199 || environment.state().active.is_some()
3200 {
3201 tokio::task::yield_now().await;
3202 }
3203 })
3204 .await
3205 .expect("a resolved review releases prompts and drains its durable close");
3206 assert!(host.view(session).is_none(), "the review is over");
3207 assert_eq!(environment.state().active, None);
3208 }
3209
3210 #[tokio::test]
3214 async fn preparation_rechecks_the_live_actor_after_installing_the_prompt_hold() {
3215 let session = session_id("preparelive");
3216 let session = session.as_str();
3217 let mut manager = FakeManager::new(session).await;
3218 let environment = FakeEnvironment::new();
3219 let host = TurnReviewHost::spawn_in(
3220 manager.control.clone(),
3221 armed(Some("reviewer")),
3222 environment.clone(),
3223 );
3224
3225 finish_a_turn(&manager, &host).await;
3226 let (_, _, reply) = manager
3227 .next_reviewer(|_, action| matches!(action, ReviewerAction::Status))
3228 .await;
3229 assert!(host.refuses_prompt(session));
3230
3231 manager
3232 .publisher
3233 .publish(
3234 session.to_owned(),
3235 view(
3236 session,
3237 MaterializedExecutionState::Running { started_at_ms: 1 },
3238 ),
3239 )
3240 .await
3241 .expect("publish the command that won the admission race");
3242 let _ = reply.send(Ok(ReviewerOutcome::Status(Box::new(operational()))));
3243
3244 tokio::time::timeout(Duration::from_secs(5), async {
3245 while host.refuses_prompt(session) {
3246 tokio::task::yield_now().await;
3247 }
3248 })
3249 .await
3250 .expect("a refused preparation releases its prompt hold");
3251 assert!(host.view(session).is_none());
3252 assert_eq!(environment.state().active, None);
3253 assert!(
3254 tokio::time::timeout(Duration::from_millis(300), manager.requests.recv())
3255 .await
3256 .is_err(),
3257 "stale preparation never starts capture"
3258 );
3259 host.shutdown().await.expect("shutdown the host");
3260 }
3261
3262 #[tokio::test]
3266 async fn observation_bursts_do_not_drop_the_final_idle_edge() {
3267 let session = session_id("losslessobs");
3268 let session = session.as_str();
3269 let mut manager = FakeManager::new(session).await;
3270 let environment = FakeEnvironment::new();
3271 let config: ReviewConfigSource = Arc::new(|| ReviewConfig {
3272 enabled: false,
3273 tier: ReviewTier::Quick,
3274 profile: Some("reviewer".to_owned()),
3275 model: None,
3276 effort: None,
3277 });
3278 let host = TurnReviewHost::spawn_in(manager.control.clone(), config, environment);
3279
3280 let running = view(
3281 session,
3282 MaterializedExecutionState::Running { started_at_ms: 1 },
3283 );
3284 for _ in 0..256 {
3285 host.observe(session, &running);
3286 }
3287 host.observe(session, &view(session, MaterializedExecutionState::Idle));
3288
3289 let starting_host = host.clone();
3290 let session_owned = session.to_owned();
3291 let starting = tokio::spawn(async move { starting_host.start(&session_owned, true).await });
3292 let (_, _, reply) = manager
3293 .next_reviewer(|_, action| matches!(action, ReviewerAction::Status))
3294 .await;
3295 let _ = reply.send(Ok(ReviewerOutcome::Status(Box::new(operational()))));
3296 starting
3297 .await
3298 .expect("start task")
3299 .expect("the retained idle edge admits the review");
3300 assert!(host.refuses_prompt(session));
3301 host.shutdown().await.expect("shutdown the host");
3302 }
3303
3304 #[tokio::test]
3309 async fn persistence_is_nonblocking_ordered_and_drained_on_shutdown() {
3310 let session = session_id("persistlane");
3311 let session = session.as_str();
3312 let mut manager = FakeManager::new(session).await;
3313 let environment = FakeEnvironment::new();
3314 let host = TurnReviewHost::spawn_in(
3315 manager.control.clone(),
3316 armed(Some("reviewer")),
3317 environment.clone(),
3318 );
3319
3320 finish_a_turn(&manager, &host).await;
3321 let (_, _, reply) = manager
3322 .next_reviewer(|_, action| matches!(action, ReviewerAction::Status))
3323 .await;
3324 let open_gate = environment.block_saves();
3325 let _ = reply.send(Ok(ReviewerOutcome::Status(Box::new(operational()))));
3326 tokio::time::timeout(Duration::from_secs(5), open_gate.entered())
3327 .await
3328 .expect("the active write reaches the blocking lane");
3329
3330 let refusal = tokio::time::timeout(Duration::from_secs(1), host.start(session, true))
3331 .await
3332 .expect("the host loop remains responsive while persistence blocks")
3333 .expect_err("the same review is already starting");
3334 assert!(refusal.0.contains("already starting"), "{refusal}");
3335 assert!(host.view(session).is_none(), "open is not exposed early");
3336
3337 open_gate.release();
3338 let (_, _, _capture_reply) = manager
3339 .next_reviewer(|_, action| matches!(action, ReviewerAction::CaptureDelta { .. }))
3340 .await;
3341 assert!(environment.state().active.is_some());
3342 assert!(host.view(session).is_some());
3343
3344 let close_gate = environment.block_saves();
3345 let first_host = host.clone();
3346 let second_host = host.clone();
3347 let first = tokio::spawn(async move { first_host.shutdown().await });
3348 let second = tokio::spawn(async move { second_host.shutdown().await });
3349 tokio::time::timeout(Duration::from_secs(5), close_gate.entered())
3350 .await
3351 .expect("shutdown queues the final inactive state");
3352 assert!(!first.is_finished(), "shutdown drains the blocked write");
3353 assert!(
3354 !second.is_finished(),
3355 "concurrent shutdown joins the same drain"
3356 );
3357 assert!(
3358 !host.refuses_prompt(session),
3359 "logical shutdown releases prompts before persistence finishes"
3360 );
3361 close_gate.release();
3362 first
3363 .await
3364 .expect("first shutdown task")
3365 .expect("first drain");
3366 second
3367 .await
3368 .expect("second shutdown task")
3369 .expect("shared drain");
3370 host.shutdown().await.expect("shutdown stays idempotent");
3371
3372 assert_eq!(environment.state().active, None);
3373 assert!(host.view(session).is_none());
3374 let writes = environment.writes();
3375 assert!(
3376 writes
3377 .first()
3378 .is_some_and(|(state, _)| state.active.is_some())
3379 );
3380 assert!(
3381 writes
3382 .last()
3383 .is_some_and(|(state, _)| state.active.is_none())
3384 );
3385 let test_thread = std::thread::current().id();
3386 assert!(
3387 writes.iter().all(|(_, writer)| *writer != test_thread),
3388 "synchronous database writes run off the Tokio host thread"
3389 );
3390 }
3391
3392 #[tokio::test]
3396 async fn an_interrupted_handoff_retains_findings_until_acceptance_and_retries_the_same_id() {
3397 let session = session_id("handoff0000");
3398 let mut manager = FakeManager::new(&session).await;
3399 let environment = FakeEnvironment::new();
3400 let pending = PendingForward {
3401 synthesis: "[P2] src/lib.rs:1 -- incorrect boundary".to_owned(),
3402 evidence: Default::default(),
3403 command_id: "durable-forward-id".to_owned(),
3404 trees: BTreeMap::from([(PathBuf::from("/workspace/app"), "new".to_owned())]),
3405 reviewed_through_ordinal: 12,
3406 };
3407 {
3408 let mut state = environment.state.lock().unwrap();
3409 state
3410 .baselines
3411 .insert("/workspace/app".into(), "old".into());
3412 state.pending_forward = Some(pending.clone());
3413 }
3414 let host = TurnReviewHost::spawn_in(
3415 manager.control.clone(),
3416 armed(Some("reviewer")),
3417 environment.clone(),
3418 );
3419 host.observe(&session, &view(&session, MaterializedExecutionState::Idle));
3420 host.events
3421 .send(HostEvent::Interrupted {
3422 interrupted: vec![session.clone()],
3423 })
3424 .unwrap();
3425 let RemoteSessionRequest::Submit {
3426 command_id,
3427 admission,
3428 reply,
3429 ..
3430 } = manager.next().await
3431 else {
3432 panic!("recovery submits the pending handoff directly, without starting a reviewer");
3433 };
3434 assert_eq!(command_id, pending.command_id);
3435 assert!(review_delivery_admitted(&session, &admission.unwrap()));
3436 assert!(host.refuses_prompt(&session));
3437 assert_eq!(environment.state().pending_forward, Some(pending.clone()));
3438 assert_eq!(
3439 environment.state().baselines[&PathBuf::from("/workspace/app")],
3440 "old"
3441 );
3442 assert!(
3443 host.resolve(&session, Resolution::Forwarded).await.is_err(),
3444 "duplicate Forward is not another submission"
3445 );
3446 assert!(
3447 host.resolve(&session, Resolution::Cancelled).await.is_err(),
3448 "an unknown delivery cannot be undone"
3449 );
3450 reply
3451 .send(Err("primary temporarily unavailable".to_owned()))
3452 .unwrap();
3453 tokio::time::timeout(Duration::from_secs(2), async {
3454 while !host.view(&session).is_some_and(|view| {
3455 matches!(
3456 view.phase,
3457 TurnReviewPhase::Forwarding { error: Some(_), .. }
3458 )
3459 }) {
3460 tokio::task::yield_now().await;
3461 }
3462 })
3463 .await
3464 .expect("rejection remains actionable");
3465 assert_eq!(environment.state().pending_forward, Some(pending.clone()));
3466 host.resolve(&session, Resolution::Forwarded).await.unwrap();
3467 let RemoteSessionRequest::Submit {
3468 command_id,
3469 admission,
3470 reply,
3471 ..
3472 } = manager.next().await
3473 else {
3474 panic!("retry submits the same handoff");
3475 };
3476 assert_eq!(command_id, pending.command_id);
3477 let epoch = admission.unwrap().epoch();
3478 host.events
3479 .send(HostEvent::Step {
3480 session_id: session.clone(),
3481 epoch,
3482 step: ReviewStep::RoleEvents {
3483 role: "reviewer".to_owned(),
3484 result: Err("late reviewer disconnect".to_owned()),
3485 },
3486 })
3487 .unwrap();
3488 let gate = environment.block_saves();
3489 reply.send(Ok(42)).unwrap();
3490 tokio::time::timeout(Duration::from_secs(2), gate.entered())
3491 .await
3492 .unwrap();
3493 assert_eq!(
3494 environment.state().pending_forward,
3495 Some(pending),
3496 "the durable pending record remains until the complete accepted outcome is written"
3497 );
3498 gate.release();
3499 tokio::time::timeout(Duration::from_secs(2), async {
3500 while host.view(&session).is_some() {
3501 tokio::task::yield_now().await;
3502 }
3503 })
3504 .await
3505 .expect("accepted handoff closes");
3506 let state = environment.state();
3507 assert!(state.pending_forward.is_none());
3508 assert!(state.prior_review.is_some());
3509 assert_eq!(state.baselines[&PathBuf::from("/workspace/app")], "new");
3510 assert!(!host.refuses_prompt(&session));
3511 assert!(environment.staged_roles().is_empty());
3512 host.shutdown().await.unwrap();
3513 }
3514
3515 #[tokio::test]
3516 async fn queued_prompts_hold_a_review_back() {
3517 let session = session_id("queued00000");
3518 let session = session.as_str();
3519 let mut manager = FakeManager::new(session).await;
3520 let environment = FakeEnvironment::new();
3521 let host = TurnReviewHost::spawn_in(
3522 manager.control.clone(),
3523 armed(Some("reviewer")),
3524 environment.clone(),
3525 );
3526
3527 let mut queued = view(session, mj_core::state::MaterializedExecutionState::Idle);
3528 if let Some(snapshot) = queued.snapshot.as_mut() {
3529 snapshot.materialized.queued_prompts = vec![mj_core::state::MaterializedQueuedPrompt {
3530 accepted_ordinal: None,
3531 command_id: "queued-1".to_owned(),
3532 kind: mj_core::state::QueuedCommandKind::Prompt,
3533 content: vec![serde_json::json!({"type": "text", "text": "next"})],
3534 queued_at_ms: 0,
3535 }];
3536 }
3537 host.observe(
3538 session,
3539 &view(
3540 session,
3541 mj_core::state::MaterializedExecutionState::Running { started_at_ms: 0 },
3542 ),
3543 );
3544 host.observe(session, &queued);
3545
3546 assert!(
3547 tokio::time::timeout(Duration::from_millis(300), manager.requests.recv())
3548 .await
3549 .is_err(),
3550 "no review starts while prompts are queued"
3551 );
3552 let refusal = host
3553 .start(session, true)
3554 .await
3555 .expect_err("a manual review is refused for the same reason");
3556 assert!(refusal.0.contains("queued"), "{refusal}");
3557 }
3558
3559 #[tokio::test]
3563 async fn resolving_a_review_that_has_no_verdict_is_refused() {
3564 let session = session_id("resolution0");
3565 let session = session.as_str();
3566 let mut manager = FakeManager::new(session).await;
3567 let environment = FakeEnvironment::new();
3568 let host = TurnReviewHost::spawn_in(
3569 manager.control.clone(),
3570 armed(Some("reviewer")),
3571 environment.clone(),
3572 );
3573
3574 let error = host
3575 .resolve(session, Resolution::Forwarded)
3576 .await
3577 .expect_err("there is no review at all");
3578 assert!(error.contains("no review is open"), "{error}");
3579
3580 finish_a_turn(&manager, &host).await;
3581 let (_, _, reply) = manager
3582 .next_reviewer(|_, action| matches!(action, ReviewerAction::CaptureDelta { .. }))
3583 .await;
3584 let _ = reply.send(Ok(ReviewerOutcome::Delta {
3585 repositories: vec![mj_core::relay::RepoDelta {
3586 root: std::path::PathBuf::from("/workspace/app"),
3587 baseline_tree: Some("base".to_owned()),
3588 current_tree: "new".to_owned(),
3589 patch: "diff --git a/a b/a\n@@\n+one\n".to_owned(),
3590 diffstat: "1 file changed, 1 insertion(+)".to_owned(),
3591 changed_lines: 1,
3592 }],
3593 }));
3594
3595 tokio::time::timeout(Duration::from_secs(5), async {
3596 while host.view(session).is_none() {
3597 tokio::task::yield_now().await;
3598 }
3599 })
3600 .await
3601 .expect("the review is open");
3602
3603 let error = host
3604 .resolve(session, Resolution::Forwarded)
3605 .await
3606 .expect_err("nothing has been found yet");
3607 assert!(error.contains("no findings"), "{error}");
3608 let error = host
3609 .resolve(session, Resolution::Dismissed)
3610 .await
3611 .expect_err("nothing has been decided yet");
3612 assert!(error.contains("verdict"), "{error}");
3613 host.resolve(session, Resolution::Cancelled)
3616 .await
3617 .expect("cancel needs no verdict");
3618 tokio::time::timeout(Duration::from_secs(5), async {
3619 while host.refuses_prompt(session) {
3620 tokio::task::yield_now().await;
3621 }
3622 })
3623 .await
3624 .expect("cancelling releases the prompts");
3625 }
3626
3627 #[tokio::test]
3631 async fn a_failed_review_clears_durable_active_state_and_the_prompt_hold() {
3632 let session = session_id("failedrole0");
3633 let session = session.as_str();
3634 let mut manager = FakeManager::new(session).await;
3635 let environment = FakeEnvironment::new();
3636 let host = TurnReviewHost::spawn_in(
3637 manager.control.clone(),
3638 armed(Some("reviewer")),
3639 environment.clone(),
3640 );
3641 finish_a_turn(&manager, &host).await;
3642
3643 let (_, _, reply) = manager
3644 .next_reviewer(|_, action| matches!(action, ReviewerAction::CaptureDelta { .. }))
3645 .await;
3646 assert!(environment.state().active.is_some());
3647 let _ = reply.send(Ok(ReviewerOutcome::Delta {
3648 repositories: vec![mj_core::relay::RepoDelta {
3649 root: PathBuf::from("/workspace/app"),
3650 baseline_tree: Some("base".to_owned()),
3651 current_tree: "new".to_owned(),
3652 patch: "diff --git a/a b/a\n@@\n+one\n".to_owned(),
3653 diffstat: "1 file changed, 1 insertion(+)".to_owned(),
3654 changed_lines: 1,
3655 }],
3656 }));
3657 let (_, _, reply) = manager
3658 .next_reviewer(|_, action| matches!(action, ReviewerAction::Start { .. }))
3659 .await;
3660 let _ = reply.send(Err("review harness failed to launch".to_owned()));
3661
3662 tokio::time::timeout(Duration::from_secs(5), async {
3663 loop {
3664 let failed = host.view(session).is_some_and(|view| {
3665 matches!(
3666 view.verdict,
3667 Some(VerdictView {
3668 kind: VerdictKind::Failed,
3669 ..
3670 })
3671 )
3672 });
3673 if failed && !host.refuses_prompt(session) && environment.state().active.is_none() {
3674 break;
3675 }
3676 tokio::task::yield_now().await;
3677 }
3678 })
3679 .await
3680 .expect("failure releases and persists the turn");
3681
3682 host.resolve(session, Resolution::Dismissed)
3683 .await
3684 .expect("the visible failure can be dismissed");
3685 tokio::time::timeout(Duration::from_secs(5), async {
3686 while host.view(session).is_some() {
3687 tokio::task::yield_now().await;
3688 }
3689 })
3690 .await
3691 .expect("dismissal closes the failed review");
3692 host.shutdown().await.expect("shutdown the host");
3693 }
3694
3695 fn agent_event(ordinal: u64, previous_digest: &str, text: &str) -> RelayEvent {
3698 let mut event = RelayEvent {
3699 format: RELAY_EVENT_FORMAT_V1,
3700 ordinal,
3701 previous_digest: previous_digest.to_owned(),
3702 digest: String::new(),
3703 recorded_at_ms: i64::try_from(ordinal).unwrap_or_default() * 100,
3704 command_id: None,
3705 observation: RelayObservation::SessionUpdate {
3706 update: Box::new(
3707 agent_client_protocol::schema::v1::SessionUpdate::AgentMessageChunk(
3708 agent_client_protocol::schema::v1::ContentChunk::new(
3709 agent_client_protocol::schema::v1::ContentBlock::Text(
3710 agent_client_protocol::schema::v1::TextContent::new(text),
3711 ),
3712 ),
3713 ),
3714 ),
3715 },
3716 };
3717 event.digest = relay_event_digest(&event).expect("digest");
3718 event
3719 }
3720
3721 fn completion_event(ordinal: u64, previous_digest: &str, command_id: &str) -> RelayEvent {
3722 let mut event = RelayEvent {
3723 format: RELAY_EVENT_FORMAT_V1,
3724 ordinal,
3725 previous_digest: previous_digest.to_owned(),
3726 digest: String::new(),
3727 recorded_at_ms: i64::try_from(ordinal).unwrap_or_default() * 100,
3728 command_id: Some(command_id.to_owned()),
3729 observation: RelayObservation::CommandCompleted {
3730 command_id: command_id.to_owned(),
3731 outcome: RelayCommandOutcome::Prompt {
3732 diagnostic: None,
3733 stop_reason: "end_turn".to_owned(),
3734 usage: None,
3735 },
3736 },
3737 };
3738 event.digest = relay_event_digest(&event).expect("digest");
3739 event
3740 }
3741
3742 #[tokio::test]
3746 async fn a_clean_reviewer_report_resolves_the_review() {
3747 let session = session_id("cleanreport");
3748 let session = session.as_str();
3749 let mut manager = FakeManager::new(session).await;
3750 let environment = FakeEnvironment::new();
3751 let publications = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3752 let published = publications.clone();
3753 let host = TurnReviewHost::spawn_in_notifying(
3754 manager.control.clone(),
3755 armed(Some("reviewer")),
3756 environment.clone(),
3757 Arc::new(move || {
3758 published.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3759 }),
3760 );
3761 finish_a_turn(&manager, &host).await;
3762
3763 let (_, _, reply) = manager
3764 .next_reviewer(|_, action| matches!(action, ReviewerAction::CaptureDelta { .. }))
3765 .await;
3766 let after_add = publications.load(std::sync::atomic::Ordering::SeqCst);
3767 assert!(after_add > 0, "opening publishes and wakes surfaces");
3768 let _ = reply.send(Ok(ReviewerOutcome::Delta {
3769 repositories: vec![mj_core::relay::RepoDelta {
3770 root: std::path::PathBuf::from("/workspace/app"),
3771 baseline_tree: Some("base".to_owned()),
3772 current_tree: "new".to_owned(),
3773 patch: "diff --git a/a b/a\n@@\n+one\n".to_owned(),
3774 diffstat: "1 file changed, 1 insertion(+)".to_owned(),
3775 changed_lines: 1,
3776 }],
3777 }));
3778
3779 let (role, _, reply) = manager
3781 .next_reviewer(|_, action| matches!(action, ReviewerAction::Start { .. }))
3782 .await;
3783 let after_change = publications.load(std::sync::atomic::Ordering::SeqCst);
3784 assert!(
3785 after_change > after_add,
3786 "a projected review state change wakes surfaces"
3787 );
3788 assert_eq!(
3789 role.as_deref(),
3790 Some(mj_core::review::driver::REVIEWER_ROLE)
3791 );
3792 let _ = reply.send(Ok(ReviewerOutcome::Started(Box::new(
3793 crate::worker_client::StartedReviewer {
3794 native_session_id: None,
3795 config_options: Vec::new(),
3796 reused: false,
3797 state: operational(),
3798 },
3799 ))));
3800
3801 let (_, action, reply) = manager
3802 .next_reviewer(|role, action| {
3803 role.as_deref() == Some(mj_core::review::driver::REVIEWER_ROLE)
3804 && matches!(action, ReviewerAction::Submit { .. })
3805 })
3806 .await;
3807 let ReviewerAction::Submit {
3808 command_id,
3809 command,
3810 } = action
3811 else {
3812 unreachable!("matched above");
3813 };
3814 let RelayCommand::Prompt { prompt } = command else {
3815 panic!("a reviewing role is prompted");
3816 };
3817 assert!(
3818 format!("{prompt:?}").contains("+one"),
3819 "the reviewer is given the captured change"
3820 );
3821 let _ = reply.send(Ok(ReviewerOutcome::Accepted { ordinal: 1 }));
3822
3823 let (_, _, reply) = manager
3825 .next_reviewer(|role, action| {
3826 role.as_deref() == Some(mj_core::review::driver::REVIEWER_ROLE)
3827 && matches!(action, ReviewerAction::Attach { .. })
3828 })
3829 .await;
3830 assert!(
3831 tokio::time::timeout(Duration::from_millis(100), manager.requests.recv())
3832 .await
3833 .is_err(),
3834 "one role prompt has only one attachment poll in flight"
3835 );
3836 let before_identical = publications.load(std::sync::atomic::Ordering::SeqCst);
3837 let _ = reply.send(Ok(ReviewerOutcome::Attached(Box::new(
3838 crate::worker_client::RelayAttachment {
3839 state: operational(),
3840 events: Vec::new(),
3841 through_ordinal: 0,
3842 through_digest: mj_core::relay::RELAY_EVENT_GENESIS_DIGEST.to_owned(),
3843 },
3844 ))));
3845
3846 let (_, _, reply) = manager
3849 .next_reviewer(|role, action| {
3850 role.as_deref() == Some(mj_core::review::driver::REVIEWER_ROLE)
3851 && matches!(action, ReviewerAction::Attach { .. })
3852 })
3853 .await;
3854 assert_eq!(
3855 publications.load(std::sync::atomic::Ordering::SeqCst),
3856 before_identical,
3857 "an identical projection does not wake surfaces"
3858 );
3859 let answer = agent_event(
3860 1,
3861 mj_core::relay::RELAY_EVENT_GENESIS_DIGEST,
3862 "No findings.",
3863 );
3864 let completion = completion_event(2, &answer.digest, &command_id);
3865 let through_digest = completion.digest.clone();
3866 let _ = reply.send(Ok(ReviewerOutcome::Attached(Box::new(
3867 crate::worker_client::RelayAttachment {
3868 state: operational(),
3869 events: vec![answer, completion],
3870 through_ordinal: 2,
3871 through_digest,
3872 },
3873 ))));
3874
3875 tokio::time::timeout(Duration::from_secs(5), async {
3876 while host.refuses_prompt(session)
3877 || host.view(session).is_some()
3878 || environment.state().active.is_some()
3879 {
3880 tokio::task::yield_now().await;
3881 }
3882 })
3883 .await
3884 .expect("a clean review releases and durably closes the turn by itself");
3885 assert!(host.view(session).is_none());
3886 assert!(
3887 publications.load(std::sync::atomic::Ordering::SeqCst) > before_identical,
3888 "closing removes the view and wakes surfaces"
3889 );
3890
3891 let staged = environment.staged_roles();
3894 assert_eq!(staged.len(), 1);
3895 assert_eq!(staged[0].0, "reviewer");
3896 assert_ne!(staged[0].1, 0, "fresh reviewer generation");
3897 assert!(!staged[0].2);
3898 let recorded = environment.state();
3901 assert_eq!(
3902 recorded
3903 .baselines
3904 .get(std::path::Path::new("/workspace/app"))
3905 .map(String::as_str),
3906 Some("new")
3907 );
3908 assert_eq!(recorded.reviewed_through_ordinal, 12);
3909 assert_eq!(recorded.active, None);
3910 host.shutdown().await.expect("shutdown the host");
3911 }
3912}