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