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