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