Skip to main content

mj_controller/review_host/
prompts.rs

1use super::*;
2
3/// How long an idle reviewing role waits before reading its journal again. An
4/// attach answers immediately even when nothing has been journaled, so without
5/// this a review with several roles would spin on empty pages.
6pub(super) const ROLE_POLL_IDLE_INTERVAL: Duration = Duration::from_millis(200);
7
8/// Why a review could not start. Every variant is something a person can act
9/// on, which is why they carry their own sentences rather than a code.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct StartRefusal(pub String);
12
13impl std::fmt::Display for StartRefusal {
14    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        formatter.write_str(&self.0)
16    }
17}
18
19/// The message every surface gives for a prompt held by an open review.
20pub const PROMPT_HELD_MESSAGE: &str =
21    "a review of the last turn is open; forward, dismiss or cancel it first";
22
23/// Sessions whose prompts an unresolved review is holding. A hold may admit
24/// exactly one command for the matching review's corrective handoff; all
25/// ordinary prompts, including controller-authored notices, remain refused.
26///
27/// This is the authoritative lock, and it is in memory on purpose: the process
28/// that owns the review owns the lock, so a lock can never outlive the review
29/// that set it. The shipped design kept it in a database row written by the
30/// terminal, which is how a killed terminal could hold a session's prompts for
31/// ever.
32pub(super) static PROMPT_LOCK: LazyLock<Mutex<BTreeMap<String, PromptHold>>> =
33    LazyLock::new(Mutex::default);
34
35#[derive(Debug, Default)]
36pub(super) struct PromptHold {
37    pub(super) delivery_epoch: Option<u64>,
38    pub(super) delivery_command_id: Option<String>,
39}
40
41/// Fresh reviewer conversations need an identity that is unique across all
42/// roles, reviews, and controller restarts. A slot-local counter makes an
43/// extended review's next supervisor collide with a previous supervisor, so
44/// use a random nonce.
45pub(crate) fn next_review_generation() -> Result<u64, String> {
46    let mut random = [0_u8; 8];
47    getrandom::fill(&mut random)
48        .map_err(|error| format!("generate reviewer generation: {error}"))?;
49    let generation = u64::from_le_bytes(random);
50    if generation == 0 {
51        return Err("generate reviewer generation: random nonce was zero".to_owned());
52    }
53    Ok(generation)
54}
55
56/// Whether a prompt for `session_id` must be refused, and why.
57#[must_use]
58pub fn prompt_refusal(session_id: &str) -> Option<&'static str> {
59    PROMPT_LOCK
60        .lock()
61        .unwrap_or_else(std::sync::PoisonError::into_inner)
62        .contains_key(session_id)
63        .then_some(PROMPT_HELD_MESSAGE)
64}
65
66pub(super) fn hold_prompts(session_id: &str) {
67    PROMPT_LOCK
68        .lock()
69        .unwrap_or_else(std::sync::PoisonError::into_inner)
70        .insert(session_id.to_owned(), PromptHold::default());
71}
72
73pub(super) fn release_prompts(session_id: &str) {
74    PROMPT_LOCK
75        .lock()
76        .unwrap_or_else(std::sync::PoisonError::into_inner)
77        .remove(session_id);
78}
79
80/// Grants the actor one narrowly scoped exception to the prompt hold. The
81/// grant is tied to both the review epoch and command identity so a delayed
82/// request from an older review cannot enter a later one.
83pub(super) fn admit_review_delivery(
84    session_id: &str,
85    epoch: u64,
86    command_id: &str,
87) -> Option<ReviewDeliveryAdmission> {
88    let mut locks = PROMPT_LOCK
89        .lock()
90        .unwrap_or_else(std::sync::PoisonError::into_inner);
91    let hold = locks.get_mut(session_id)?;
92    match (hold.delivery_epoch, hold.delivery_command_id.as_deref()) {
93        (Some(existing_epoch), Some(existing_command))
94            if existing_epoch != epoch || existing_command != command_id =>
95        {
96            None
97        }
98        _ => {
99            hold.delivery_epoch = Some(epoch);
100            hold.delivery_command_id = Some(command_id.to_owned());
101            Some(ReviewDeliveryAdmission::new(
102                session_id.to_owned(),
103                epoch,
104                command_id.to_owned(),
105            ))
106        }
107    }
108}
109
110/// Called by the session actor before it bypasses the normal prompt refusal.
111/// This check is deliberately kept in the host-owned hold registry so an
112/// arbitrary caller cannot turn a generic prompt into an internal delivery.
113pub(crate) fn review_delivery_admitted(
114    session_id: &str,
115    admission: &ReviewDeliveryAdmission,
116) -> bool {
117    let locks = PROMPT_LOCK
118        .lock()
119        .unwrap_or_else(std::sync::PoisonError::into_inner);
120    locks.get(session_id).is_some_and(|hold| {
121        admission.session_id() == session_id
122            && hold.delivery_epoch == Some(admission.epoch())
123            && hold.delivery_command_id.as_deref() == Some(admission.command_id())
124    })
125}
126
127/// Where the host reads the arming configuration. The daemon reloads
128/// `config.toml` every 500 ms already, so this closure just reads whatever it
129/// last installed.
130pub type ReviewConfigSource = Arc<dyn Fn() -> ReviewConfig + Send + Sync>;