Skip to main content

mj_controller/
hel_review_host.rs

1//! Where turn review actually runs.
2//!
3//! The review driver is a pure state machine (`hel::hel_review::driver`); this is the
4//! process that feeds it. It lives in the controller daemon, which is the one
5//! process that owns every session whether or not anyone is watching: it pumps
6//! each session's relay every 150 ms, it is the only SQLite writer, and it
7//! hosts the phone server. That is why review lives here and not in a UI. A
8//! review started from the terminal survives the terminal closing; a session
9//! driven only from a phone is reviewed on the same terms; a session nobody is
10//! attached to is reviewed too.
11//!
12//! Every surface is a projection: the terminal and the phone both render
13//! [`RuntimeReviewView`] and both resolve a review by asking the host. Neither
14//! owns any part of the review.
15//!
16//! Shape: one task owns all review state and processes [`HostEvent`]s in
17//! order. Everything slow -- capturing a delta, staging a reviewer profile,
18//! reading a role's journal -- happens in a spawned task that sends its result
19//! back as another event. Nothing here holds a lock across an await, and no
20//! two reviews can interleave their state.
21
22use 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
47/// How long an idle reviewing role waits before reading its journal again. An
48/// attach answers immediately even when nothing has been journaled, so without
49/// this a review with several roles would spin on empty pages.
50const ROLE_POLL_IDLE_INTERVAL: Duration = Duration::from_millis(200);
51
52/// Why a review could not start. Every variant is something a person can act
53/// on, which is why they carry their own sentences rather than a code.
54#[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
63/// The message every surface gives for a prompt held by an open review.
64pub const PROMPT_HELD_MESSAGE: &str =
65    "a review of the last turn is open; forward, dismiss or cancel it first";
66
67/// Sessions whose prompts an unresolved review is holding. A hold may admit
68/// exactly one command for the matching review's corrective handoff; all
69/// ordinary prompts, including controller-authored notices, remain refused.
70///
71/// This is the authoritative lock, and it is in memory on purpose: the process
72/// that owns the review owns the lock, so a lock can never outlive the review
73/// that set it. The shipped design kept it in a database row written by the
74/// terminal, which is how a killed terminal could hold a session's prompts for
75/// ever.
76static 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
84/// Fresh reviewer conversations need an identity that is unique across all
85/// roles, reviews, and controller restarts. A slot-local counter makes an
86/// extended review's next supervisor collide with a previous supervisor, so
87/// use a random nonce.
88pub(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/// Whether a prompt for `session_id` must be refused, and why.
100#[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
123/// Grants the actor one narrowly scoped exception to the prompt hold. The
124/// grant is tied to both the review epoch and command identity so a delayed
125/// request from an older review cannot enter a later one.
126fn 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
153/// Called by the session actor before it bypasses the normal prompt refusal.
154/// This check is deliberately kept in the host-owned hold registry so an
155/// arbitrary caller cannot turn a generic prompt into an internal delivery.
156pub(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
170/// Where the host reads the arming configuration. The daemon reloads
171/// `config.toml` every 500 ms already, so this closure just reads whatever it
172/// last installed.
173pub type ReviewConfigSource = Arc<dyn Fn() -> ReviewConfig + Send + Sync>;
174
175/// Everything a review needs from the controller: whether it can review this
176/// session at all, and a staged reviewer profile to launch a role from.
177///
178/// It is a trait so the host's own tests can drive a whole review without a
179/// container, a harness, or the developer's own `config.toml`. The daemon
180/// installs [`ControllerEnvironment`], which loads the real controller.
181pub trait ReviewEnvironment: Send + Sync {
182    /// Refuses, with a sentence for a person, when this session cannot be
183    /// reviewed under `profile`.
184    fn check(&self, session_id: &str, profile: &str) -> Result<(), String>;
185
186    /// Stages the reviewer profile for one role and describes how to launch
187    /// it. Blocking: it copies a profile onto the session's target.
188    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    /// How far this session has been reviewed. Blocking: it reads the
198    /// controller's database.
199    fn load_state(&self, session_id: &str) -> Result<TurnReviewState, String>;
200
201    /// Records how far this session has been reviewed. Blocking: the host
202    /// routes it through its ordered persistence lane rather than calling it
203    /// on the Tokio task that owns review state.
204    fn save_state(&self, session_id: &str, state: &TurnReviewState) -> Result<(), String>;
205
206    /// Clears the in-flight flag of every review a restart interrupted, and
207    /// reports whose they were. Baselines are deliberately left alone: the
208    /// interrupted review never advanced one, so the next review covers the
209    /// same change and nothing is lost.
210    fn clear_interrupted(&self) -> Result<Vec<String>, String>;
211}
212
213/// The production environment: the controller as it is on disk right now.
214///
215/// It is reloaded per call rather than held, because a review is rare and the
216/// answer must reflect the config as it stands when the review starts -- the
217/// daemon reloads config.toml every 500 ms for the same reason.
218#[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/// A handle on the review host. Cheap to clone; every method is a message.
299#[derive(Clone)]
300pub struct TurnReviewHost {
301    events: mpsc::UnboundedSender<HostEvent>,
302    shared: Arc<HostShared>,
303}
304
305/// What surfaces read without waiting for the host's task.
306struct 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    /// Starts the host's task, reviewing through the real controller.
320    #[must_use]
321    pub fn spawn(control: SessionManagerControl, config: ReviewConfigSource) -> Self {
322        Self::spawn_notifying(control, config, Arc::new(|| {}))
323    }
324
325    /// Starts the production host and calls `changed` whenever a surface view
326    /// is added, changed, or removed.
327    #[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    /// The same, against a caller-supplied environment. `config` is read at
337    /// each trigger decision.
338    #[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        // The restart sweep is the first operation in the same FIFO lane that
371        // records new active reviews, so it cannot clear a review that opened
372        // while the sweep was still running.
373        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    /// Reports one session's latest view. This is the trigger's only input.
402    pub fn observe(&self, session_id: &str, view: &ManagedSessionView) {
403        // Running -> Idle is an edge, not a level: the session manager
404        // suppresses unchanged views, so dropping one here can lose an
405        // automatic review permanently. An unbounded hand-off keeps the
406        // daemon's update loop nonblocking without dropping that edge.
407        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    /// Reviews the turn that just finished, on request.
421    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    /// Forwards, dismisses, or cancels the open review.
436    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    /// Stops accepting review work, releases every prompt hold, and drains the
451    /// ordered persistence lane before the daemon shuts its database writer
452    /// down.
453    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    /// Every open review, for a snapshot a surface renders.
470    #[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    /// One session's review, if it has one.
482    #[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    /// Whether an unresolved review is holding this session's prompts.
493    #[must_use]
494    pub fn refuses_prompt(&self, session_id: &str) -> bool {
495        prompt_refusal(session_id).is_some()
496    }
497}
498
499/// What the host's task processes, in order.
500enum HostEvent {
501    View {
502        session_id: String,
503        snapshot: Option<Box<MaterializedSession>>,
504        /// Whether this view had a prompt of ours in flight. Only a turn that
505        /// answered a prompt arms an automatic review.
506        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    /// One asynchronous step of a review that was open when it started.
529    ///
530    /// `epoch` is which review asked. mjolnir's orchestrator tags every review
531    /// outcome with one and drops the ones that no longer match
532    /// (`mj-core/src/orchestrator.rs`, `review_outcome_rx`), because a result
533    /// arriving after its review was cancelled would otherwise be applied to
534    /// whatever review is open now. Session id alone is not enough: a session
535    /// can start its next review immediately.
536    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    /// Reviews a daemon restart interrupted, so each session's conversation
547    /// says what happened to it.
548    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
573/// One asynchronous step's result, belonging to exactly one review.
574enum 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
593/// Everything one blocking preparation gathered before a review can start.
594struct Prepared {
595    state: TurnReviewState,
596    reviewer: ReviewerIdentity,
597    tier: ReviewTier,
598    /// Read from the live actor after the admission hold is installed and a
599    /// reviewer status command drains every actor command ahead of it.
600    materialized: Box<MaterializedSession>,
601    /// Present only while startup reconciles an interrupted corrective
602    /// handoff. Such a review skips reviewer processes and retries the exact
603    /// primary command id.
604    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/// Which harness reviews, and how it is configured. Read from `[review]`.
615#[derive(Debug, Clone, PartialEq, Eq)]
616struct ReviewerIdentity {
617    profile: String,
618    model: Option<String>,
619    effort: Option<String>,
620}
621
622/// One open review and its execution context.
623struct ReviewSlot {
624    /// Which review this is. Asynchronous results name it, and results that
625    /// name another are dropped.
626    epoch: u64,
627    driver: TurnReviewDriver,
628    /// One transcript projection per reviewing role, which is how the host
629    /// reads a role's answer out of its own relay journal.
630    roles: BTreeMap<String, RoleTranscript>,
631    reviewer: ReviewerIdentity,
632    state: TurnReviewState,
633    /// The sidecar reads a new generation as "this is a different reviewer".
634    /// Fresh role launches receive a random nonce, so a later review cannot
635    /// reuse the native conversation left by an earlier one.
636    generation: u64,
637}
638
639/// One role's journal, folded far enough to read its final answer.
640#[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    /// The role's latest complete answer, which is what the driver reads. Tool
671    /// logs and reasoning are deliberately not part of it.
672    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    /// Sessions whose review is being prepared. Preparation is asynchronous,
699    /// so without this an automatic trigger and a manual `/review` racing each
700    /// other would both create a review and the second would overwrite the
701    /// first.
702    preparing: BTreeSet<String>,
703    /// Reviews whose durable active marker is being written. They are not
704    /// visible and start no agents until that write succeeds.
705    pending_open: BTreeMap<String, PendingOpen>,
706    /// Reviews whose durable active marker is being cleared. Their resolved
707    /// view and prompt hold remain until the ordered write completes.
708    closing: BTreeSet<String>,
709    /// Primary handoff requests wait here until their durable pending record
710    /// has been written. This prevents an accepted relay command from racing
711    /// a failed SQLite write.
712    awaiting_forward_persistence: BTreeMap<String, Vec<ReviewRequest>>,
713    /// Distinguishes reviews. Every asynchronous step carries the epoch of the
714    /// review that asked for it, so a late result cannot land on its
715    /// successor.
716    next_epoch: u64,
717    /// The last view seen per session: its execution state, for the
718    /// Running→Idle edge, and its materialized transcript, for the seed.
719    sessions: BTreeMap<String, SessionWatch>,
720    /// Sessions already told that no reviewer is configured. One notice per
721    /// session, not one per turn.
722    missing_reviewer_reported: BTreeSet<String>,
723    /// Sessions whose durable handoff survived a restart and still needs the
724    /// primary relay's idempotent acknowledgement reconciled.
725    recovery_candidates: BTreeSet<String>,
726    recovery_in_flight: BTreeSet<String>,
727}
728
729struct SessionWatch {
730    execution: MaterializedExecutionState,
731    /// Whether the view had a prompt of ours in flight.
732    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    /// Returns true once shutdown has drained persistence and the host loop may
808    /// stop.
809    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    /// Applies one asynchronous result to the review that asked for it.
869    fn step(&mut self, session_id: String, epoch: u64, step: ReviewStep) {
870        // A result from a review that has since been cancelled, resolved, or
871        // replaced is not this review's business.
872        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                        // A lane that cannot start is a coverage gap the
906                        // supervisor is told about; any other role failing to
907                        // start fails the review.
908                        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                        // Role cleanup can report after the primary handoff
940                        // has begun. It is unrelated to that handoff and must
941                        // not release its prompt hold or replace its findings.
942                        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                    // A dropped dispatch would leave the supervisor waiting for
980                    // lanes that never run, so it fails the review rather than
981                    // stalling it.
982                    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    /// Watches one session for the edge that arms an automatic review.
1008    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        // A turn the harness starts on its own also runs and then goes idle.
1034        // Reviewing that is a separate decision, so the edge that arms a
1035        // review is the end of a turn that answered a prompt.
1036        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    /// Decides whether a review can start, and prepares one if it can.
1047    ///
1048    /// The cheap gates are answered here; the ones that need the database or
1049    /// the worker are answered in the preparation task, so this never blocks
1050    /// the host's loop.
1051    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            // Configuration is the only place this can be fixed, so the
1075            // message names the key. A session hears it once, not once a turn.
1076            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        // Admission and prompt refusal are one transition. Any prompt already
1096        // ahead of the preparation's reviewer-status command is drained before
1097        // `prepare` reads the actor view; every later prompt sees this hold.
1098        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    /// Reconciles a durable corrective handoff left by a previous daemon.
1112    /// This path does not require a reviewer profile: the review already has
1113    /// findings, and only the primary relay's idempotent command needs to be
1114    /// observed again.
1115    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                // Keep the candidate so a later connected/idle observation can
1170                // retry. No success notice is emitted for an unknown outcome.
1171                tracing::warn!(session_id = %session_id, %error, "could not reconcile an interrupted review handoff");
1172                release_prompts(&session_id);
1173            }
1174        }
1175    }
1176
1177    /// The gates that need nothing but the host's own state.
1178    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            // Reviewing now would hold prompts the user has already sent. The
1203            // review after the queue drains covers the whole batch instead.
1204            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                        // `start_role` assigns a process-wide generation before
1323                        // every fresh role. Zero remains the explicit
1324                        // generation for a role that resumes in place.
1325                        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    /// Forwards, dismisses, or cancels an open review on a surface's request.
1386    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    /// Ends a review that cannot continue. Every failure path is the same: a
1450    /// verdict the user dismisses, and a baseline that stays where it was, so
1451    /// the change is reviewed again rather than silently skipped.
1452    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        // A failed review remains visible so the person can dismiss it, but it
1460        // no longer owns the turn or has work capable of progressing.
1461        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                // The review's own corrective prompt must not be held by the
1512                // review's own lock, and by this point the review has
1513                // resolved, so the lock is already released below.
1514                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                    // Clear an accepted handoff in the same durable state
1542                    // write as its prior-review record and baseline. Until
1543                    // this point shutdown/restart must retain it for command
1544                    // id reconciliation.
1545                    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                            // The controller's copy is what the next capture is
1559                            // taken against; the worker-side ref is only a gc
1560                            // pin, so a failure here costs nothing but the pin.
1561                            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                        // A user explicitly cancelled a rejected handoff, so
1596                        // discard its retry record along with the held pane.
1597                        // An in-flight or accepted handoff never reaches this
1598                        // branch before its durable reconciliation sequence.
1599                        slot.state.pending_forward = None;
1600                    }
1601                    let state = slot.state.clone();
1602                    self.closing.insert(session_id.to_owned());
1603                    // The primary handoff has already returned a durable
1604                    // acceptance before Close can be requested. Releasing now
1605                    // lets later user prompts queue behind that accepted
1606                    // corrective turn; no held prompt can overtake it.
1607                    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    /// Stages the configured reviewer profile and starts one role under it.
1624    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        // A fresh role must not reuse the running harness session: the
1643        // validator judges the reviewer's claims against source, so it must
1644        // not inherit them. Bumping the generation is what the sidecar reads
1645        // as "this is a different reviewer".
1646        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    /// Sends the review's corrective prompt to the primary agent. The actor
1707    /// receives a scoped admission that is valid only for this review and
1708    /// command id; the hold remains until the resulting event acknowledges a
1709    /// durable relay acceptance.
1710    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    /// Reads one role's journal from where the host left off.
1749    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        // The newest agent message is not enough on its own: after the
1823        // validator starts, the reviewer's own findings are still the newest
1824        // message in that role's journal. The relay's completion record for
1825        // the exact command the driver submitted is what settles it.
1826        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    /// Collects the specialist lanes the supervisor asked for through its MCP
1883    /// tool. The tool answers the supervisor at once and leaves the request in
1884    /// the worker; this is where the host picks it up and launches them.
1885    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    /// Puts one controller-authored line into the session's conversation, so a
1895    /// resolution is visible on every surface rather than in one UI's notice
1896    /// bar.
1897    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            // The conversation line is a courtesy; a relay that refuses it has
1916            // not damaged the review.
1917            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    /// Runs one reviewer action for the default role and feeds its outcome
1928    /// back to the review that asked for it.
1929    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    /// Republishes what surfaces read. Called after every state change, so a
1966    /// snapshot poll and a phone request see the same review.
1967    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        // Take the lane once and use that single sender for every final write.
2008        // No sender may survive the join below or the receiver can never
2009        // observe EOF.
2010        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                    // A failed review has nothing to forward, and dismissing
2097                    // it does not advance the baseline: the change stays
2098                    // unreviewed either way.
2099                    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
2154/// Stages the configured reviewer profile and starts one role under it.
2155async 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    // A specialist lane's analyzers are its identity, so it gets the `slopcop`
2165    // set as well as navigation; every other role navigates and reads rather
2166    // than running analyzers. The intent analyst gets no tools at all: it
2167    // reads the user's messages, not the code.
2168    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    // Only the supervisor may launch specialists.
2182    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
2217/// Everything a review needs that only the database and the worker can answer.
2218async 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    // Both answers come from the controller and its database, so they are
2229    // asked together, once, off the host's loop.
2230    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    // Mutual exclusion with a plan-review second opinion: they share the
2238    // default reviewer role, and the running one keeps the slot. Checked
2239    // against the worker rather than against any UI's state, because the
2240    // worker is the only place that knows.
2241    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    // Reviewer actions and primary prompt submissions are serialized by the
2255    // same session actor. Anything accepted before the admission hold is
2256    // reflected here; anything after it was refused by the actor.
2257    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
2288/// Loads the durable handoff before touching the live actor. A missing
2289/// pending record means the accepted result had already been reconciled; the
2290/// interrupted review can then stay cancelled without fabricating a notice.
2291async 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        // No reviewer process is started for a handoff-only recovery.
2327        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/// The transcript line a resolution leaves behind, on every surface.
2339#[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
2369/// Builds the review's seed from the session's own projection.
2370///
2371/// This is the daemon-side twin of what the chat used to read out of its view
2372/// state: the latest user prompt is the task, all chronological user messages
2373/// are the intent context, the agent's closing message is the result,
2374/// and a compact trajectory says what it did.
2375fn 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                // The intent analyst needs the complete chronological user
2399                // history to distinguish a current steering prompt from an
2400                // earlier requirement. `task` separately identifies the
2401                // latest outer prompt.
2402                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                // The tool's own title, straight out of the stored ACP call:
2423                // the trajectory says what the agent did, and the captured
2424                // patch already carries what it changed.
2425                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    /// One session id per test. The prompt lock is process-wide -- there is
2463    /// one daemon per machine and one host in it -- so tests that shared a
2464    /// session id would release each other's locks.
2465    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        // A corrective-only pass still has no new user prompt, but retains the
2595        // latest real prompt as its current outer task.
2596        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    /// An idle reviewer's operational state. Built through serde because the
2603    /// struct's own constructor belongs to the relay.
2604    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    /// A session manager whose requests the test answers itself.
2629    ///
2630    /// This is the production remote-manager plumbing with the daemon end
2631    /// replaced by the test: `control` is exactly what the daemon hands the
2632    /// host, and every reviewer action the host makes arrives here as a
2633    /// request to answer, so the host is exercised through its real interface.
2634    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        /// Builds the manager and waits until it is managing the session, so
2645        /// the host's first request cannot race the actor's creation.
2646        async fn new(session: &str) -> Self {
2647            let channels = spawn_remote_session_manager().expect("remote manager");
2648            // The target is never dialled: this manager forwards every
2649            // request to the test instead of to a worker.
2650            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            // The remote manager creates an actor for a session once a view
2665            // has been published for it, which is what the daemon does with
2666            // every session it owns.
2667            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        /// The next request the host makes, or a failure if it makes none.
2684        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        /// Answers reviewer actions until one matches `wanted`, which is then
2692        /// returned unanswered for the test to answer itself.
2693        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                        // Anything else the host asks for on the way is
2713                        // answered plausibly so the review keeps moving.
2714                        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    /// A plausible answer to any reviewer action, for the steps a test is not
2726    /// asserting on.
2727    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    /// A view of a turn that is answering a prompt, which is the kind an
2762    /// automatic review is armed by.
2763    fn view(
2764        session: &str,
2765        execution: hel::hel_state::MaterializedExecutionState,
2766    ) -> ManagedSessionView {
2767        view_of(session, execution, true)
2768    }
2769
2770    /// `prompt_driven` false is a turn the harness started on its own: it runs
2771    /// and goes idle with no prompt of ours in flight.
2772    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    /// A controller that says yes: the profile exists and the session is
2807    /// reviewable. Staging answers with a launch config rather than copying a
2808    /// profile onto a target, so a whole review runs without one.
2809    struct FakeEnvironment {
2810        staged: Mutex<Vec<(String, u64, bool)>>,
2811        /// The review bookkeeping, in memory rather than in the developer's
2812        /// own database.
2813        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    /// Drives a session from running to idle, which is the edge that arms an
3033    /// automatic review. The daemon observes each view as it publishes it, so
3034    /// the fake does both too.
3035    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    /// A turn finishing with no reviewer configured says so in the
3050    /// conversation -- once, not once a turn -- and reviews nothing.
3051    #[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        // A second turn says nothing: one notice per session, not one a turn.
3075        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    /// A turn the harness starts on its own also runs and then goes idle.
3086    /// Reviewing those is a separate decision, so the automatic edge ignores
3087    /// one and still arms on the next turn that answers a prompt.
3088    #[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        // The very next prompt-driven turn still arms one, which for an
3111        // unconfigured reviewer is the notice that says so.
3112        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    /// A session nobody is attached to is reviewed: the daemon sees the turn
3121    /// finish, captures, finds nothing changed, records its baseline, and
3122    /// releases the lock. This is the headless case the terminal-hosted
3123    /// review could never do.
3124    #[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        // The reviewer role is checked for a running second opinion first.
3139        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        // Then the capture that defines what is under review.
3150        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        // Nothing changed, so the review records its baseline and resolves.
3163        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    /// A prompt that landed before the admission hold is reflected by the
3204    /// live actor recheck, so preparation refuses and gives the prompt lock
3205    /// back instead of reviewing a stale idle snapshot.
3206    #[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    /// Session observations are an edge stream. A burst larger than the old
3256    /// bounded hand-off must retain its final idle edge, or manual admission
3257    /// sees a stale running session and automatic review can be lost too.
3258    #[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    /// Durable state uses one blocking FIFO lane: a blocked write cannot stop
3298    /// the host actor, opening is not exposed before `active` is stored, and
3299    /// shutdown waits for the final clear. The public shutdown is safe for
3300    /// concurrent daemon cleanup callers and subsequent idempotent calls.
3301    #[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    /// Queued prompts hold the review back: reviewing now would hold work the
3386    /// user has already sent, and the review after the queue drains covers the
3387    /// whole batch anyway.
3388    #[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    /// Resolutions are gated on the verdict the review actually reached, in
3552    /// the host rather than in any surface, so every surface gets the same
3553    /// answer.
3554    #[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        // Cancel is always available, which is what keeps a surface from ever
3606        // being stuck with an open review it cannot end.
3607        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    /// A reviewer launch failure is a durable failed verdict, but it no longer
3620    /// owns the primary turn: the active marker and admission hold are both
3621    /// cleared before the user dismisses the visible failure.
3622    #[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    /// A relay event carrying one agent message, for the answer a role's
3688    /// journal reports.
3689    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    /// A role's answer is read from its own journal, and it is the completion
3733    /// record for the exact command the driver submitted that says the answer
3734    /// is final -- not merely the newest message in the journal.
3735    #[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        // The reviewer's harness starts, and the host prompts it.
3770        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        // Its journal reports a clean answer, ending the command it was given.
3814        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        // An empty journal page runs the host's publish path but leaves the
3837        // projection identical. It schedules another poll without a wakeup.
3838        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        // The reviewer ran under the configured profile, and only the
3882        // supervisor is ever given the tool that launches specialists.
3883        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        // A resolved review records what it reviewed through, so the next one
3889        // measures from here.
3890        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}