Skip to main content

mj_controller/review_host/
prompts.rs

1use super::*;
2
3/// How long the reviewer waits before reading its journal again when idle. An
4/// attach answers immediately even when nothing has been journaled, so without
5/// this the review 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 unique across reviews and
42/// controller restarts, so use a random nonce.
43pub(crate) fn next_review_generation() -> Result<u64, String> {
44    let mut random = [0_u8; 8];
45    getrandom::fill(&mut random)
46        .map_err(|error| format!("generate reviewer generation: {error}"))?;
47    let generation = u64::from_le_bytes(random);
48    if generation == 0 {
49        return Err("generate reviewer generation: random nonce was zero".to_owned());
50    }
51    Ok(generation)
52}
53
54/// Whether a prompt for `session_id` must be refused, and why.
55#[must_use]
56pub fn prompt_refusal(session_id: &str) -> Option<&'static str> {
57    PROMPT_LOCK
58        .lock()
59        .unwrap_or_else(std::sync::PoisonError::into_inner)
60        .contains_key(session_id)
61        .then_some(PROMPT_HELD_MESSAGE)
62}
63
64pub(super) fn hold_prompts(session_id: &str) {
65    PROMPT_LOCK
66        .lock()
67        .unwrap_or_else(std::sync::PoisonError::into_inner)
68        .insert(session_id.to_owned(), PromptHold::default());
69}
70
71pub(super) fn release_prompts(session_id: &str) {
72    PROMPT_LOCK
73        .lock()
74        .unwrap_or_else(std::sync::PoisonError::into_inner)
75        .remove(session_id);
76}
77
78/// Grants the actor one narrowly scoped exception to the prompt hold. The
79/// grant is tied to both the review epoch and command identity so a delayed
80/// request from an older review cannot enter a later one.
81pub(super) fn admit_review_delivery(
82    session_id: &str,
83    epoch: u64,
84    command_id: &str,
85) -> Option<ReviewDeliveryAdmission> {
86    let mut locks = PROMPT_LOCK
87        .lock()
88        .unwrap_or_else(std::sync::PoisonError::into_inner);
89    let hold = locks.get_mut(session_id)?;
90    match (hold.delivery_epoch, hold.delivery_command_id.as_deref()) {
91        (Some(existing_epoch), Some(existing_command))
92            if existing_epoch != epoch || existing_command != command_id =>
93        {
94            None
95        }
96        _ => {
97            hold.delivery_epoch = Some(epoch);
98            hold.delivery_command_id = Some(command_id.to_owned());
99            Some(ReviewDeliveryAdmission::new(
100                session_id.to_owned(),
101                epoch,
102                command_id.to_owned(),
103            ))
104        }
105    }
106}
107
108/// Called by the session actor before it bypasses the normal prompt refusal.
109/// This check is deliberately kept in the host-owned hold registry so an
110/// arbitrary caller cannot turn a generic prompt into an internal delivery.
111pub(crate) fn review_delivery_admitted(
112    session_id: &str,
113    admission: &ReviewDeliveryAdmission,
114) -> bool {
115    let locks = PROMPT_LOCK
116        .lock()
117        .unwrap_or_else(std::sync::PoisonError::into_inner);
118    locks.get(session_id).is_some_and(|hold| {
119        admission.session_id() == session_id
120            && hold.delivery_epoch == Some(admission.epoch())
121            && hold.delivery_command_id.as_deref() == Some(admission.command_id())
122    })
123}
124
125/// Where the host reads the arming configuration for one session: `[review]`
126/// with that session's own choice applied. The daemon reloads `config.toml`
127/// every 500 ms already, so this closure just reads whatever it last
128/// installed. It must not block, because the host calls it on its loop.
129pub type ReviewConfigSource = Arc<dyn Fn(&str) -> ReviewConfig + Send + Sync>;