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