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