Skip to main content

mj_controller/
review_host.rs

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