Skip to main content

roder_core/review/
run.rs

1//! Runs a review as a detached, read-only sub-turn.
2//!
3//! Roder has no one-shot sub-agent with a per-call system prompt, so a review
4//! is a child thread plus a single [`Runtime::start_turn`] whose
5//! [`InstructionBundle::system`] is replaced by the rubric. The thread is
6//! created with a read-only `tool_allowlist`, which is enforced both when tools
7//! are advertised and when a call is dispatched — the global policy mode is
8//! never touched, so a review can never widen what the parent thread may do.
9
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13use anyhow::Context;
14use roder_api::events::{
15    EventEnvelope, ReviewCompleted, ReviewFailed, ReviewStarted, RoderEvent, ThreadId, TurnId,
16};
17use roder_api::inference::{InferenceEvent, InstructionBundle};
18use roder_api::review::{ReviewId, ReviewOutput, ReviewRequest, ReviewTarget};
19use roder_api::transcript::{AssistantMessage, TranscriptItem, UserMessage};
20use roder_api::version_control::{
21    VcsProvider, VcsProviderResolution, VcsProviderResolver, VcsResolveRequest,
22};
23use time::OffsetDateTime;
24use tokio::sync::broadcast;
25
26use crate::review::REVIEW_RUBRIC;
27use crate::review::parse::parse_review_output;
28use crate::review::prompt::resolve_review_request;
29use crate::review::render::{
30    render_review_output_text, synthetic_user_action, synthetic_user_action_interrupted,
31};
32use crate::runtime::{CreateThreadRequest, FINAL_ANSWER_PHASE, Runtime, StartTurnRequest};
33
34/// The only tools a review thread may call.
35///
36/// `shell` is included because `git diff`/`git log` is how a reviewer sees a
37/// change; every tool that writes files, edits code, applies patches, or spawns
38/// agents is deliberately absent. Names are matched exactly against the tool
39/// registry (`allowlist_permits`), so they must stay in sync with
40/// `roder-tools`.
41pub const REVIEW_TOOL_ALLOWLIST: &[&str] = &["read_file", "list_files", "grep", "glob", "shell"];
42
43/// How many completed reviews stay resolvable by id for a later publish.
44const REVIEW_HISTORY_LIMIT: usize = 64;
45
46const REVIEW_INTERRUPTED_MESSAGE: &str =
47    "Review was interrupted. Re-run `/review` and wait for it to complete.";
48
49#[derive(Debug, Clone)]
50pub struct StartReviewRequest {
51    /// Thread the review was requested from; the review runs in a child of it.
52    pub thread_id: ThreadId,
53    pub target: ReviewTarget,
54    pub workspace: String,
55    pub provider_override: Option<String>,
56    pub model_override: Option<String>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ReviewHandle {
61    pub review_id: ReviewId,
62    pub review_thread_id: ThreadId,
63    pub turn_id: TurnId,
64}
65
66#[derive(Debug, Clone)]
67pub struct ReviewCompletion {
68    pub handle: ReviewHandle,
69    pub output: ReviewOutput,
70}
71
72/// Everything a publisher needs about a finished review, kept in memory so
73/// `review/publish` can act on a review id alone.
74#[derive(Debug, Clone)]
75pub struct ReviewRecord {
76    pub review_id: ReviewId,
77    pub parent_thread_id: ThreadId,
78    pub review_thread_id: ThreadId,
79    pub turn_id: TurnId,
80    pub request: ReviewRequest,
81    pub output: ReviewOutput,
82    pub workspace_root: PathBuf,
83}
84
85/// A review whose turn is running but whose findings have not been collected.
86struct StartedReview {
87    handle: ReviewHandle,
88    request: ReviewRequest,
89    parent_thread_id: ThreadId,
90    events: broadcast::Receiver<EventEnvelope>,
91}
92
93impl Runtime {
94    /// Starts a review and returns as soon as its turn is admitted. Findings
95    /// arrive as [`RoderEvent::ReviewCompleted`].
96    pub async fn start_review(
97        self: &Arc<Self>,
98        req: StartReviewRequest,
99    ) -> anyhow::Result<ReviewHandle> {
100        let started = self.begin_review(req).await?;
101        let handle = started.handle.clone();
102        let runtime = Arc::clone(self);
103        // `finish_review` reports its own failure as `ReviewFailed`, so the
104        // detached task has nothing left to do with the error.
105        tokio::spawn(async move {
106            let _ = runtime.finish_review(started).await;
107        });
108        Ok(handle)
109    }
110
111    /// Starts a review and awaits its findings.
112    pub async fn run_review(
113        self: &Arc<Self>,
114        req: StartReviewRequest,
115    ) -> anyhow::Result<ReviewCompletion> {
116        let started = self.begin_review(req).await?;
117        self.finish_review(started).await
118    }
119
120    /// Looks up a completed review by id.
121    pub async fn review_record(&self, review_id: &str) -> Option<ReviewRecord> {
122        self.reviews
123            .lock()
124            .await
125            .iter()
126            .rev()
127            .find(|record| record.review_id == review_id)
128            .cloned()
129    }
130
131    /// The most recently completed review, used when a publish request omits
132    /// the review id.
133    pub async fn latest_review_record(&self) -> Option<ReviewRecord> {
134        self.reviews.lock().await.last().cloned()
135    }
136
137    async fn begin_review(
138        self: &Arc<Self>,
139        req: StartReviewRequest,
140    ) -> anyhow::Result<StartedReview> {
141        if let Some(turn_id) = self.active_turn_for_thread(&req.thread_id).await {
142            anyhow::bail!(
143                "thread {} has an active turn ({turn_id}); wait for it to finish before starting a review",
144                req.thread_id
145            );
146        }
147
148        // `[review].model` reserves a stronger model for reviews without
149        // touching the model the requesting thread talks to.
150        let model_override = match req.model_override.clone() {
151            Some(model) => Some(model),
152            None => self.review_config().await.model,
153        };
154
155        let workspace_root = PathBuf::from(&req.workspace);
156        let vcs = self.review_vcs_provider(&workspace_root).await?;
157        let request = resolve_review_request(&req.target, &workspace_root, vcs.as_ref()).await?;
158
159        let review_thread = self
160            .create_thread_with(CreateThreadRequest {
161                title: Some(format!("review: {}", request.label)),
162                workspace: req.workspace.clone(),
163                workspace_id: None,
164                root_id: Some(req.thread_id.clone()),
165                provider: req.provider_override.clone(),
166                model: model_override.clone(),
167                selection_mode: None,
168                tool_allowlist: REVIEW_TOOL_ALLOWLIST
169                    .iter()
170                    .map(|name| (*name).to_string())
171                    .collect(),
172                service_tier: None,
173                developer_instructions: None,
174                external_tools: Vec::new(),
175                runner: None,
176            })
177            .await?;
178
179        // Subscribe before the turn starts, otherwise its first transcript
180        // items can land before the receiver exists.
181        let events = self.subscribe_events();
182
183        let turn_id = self
184            .start_turn(StartTurnRequest {
185                thread_id: review_thread.thread_id.clone(),
186                message: request.prompt.clone(),
187                images: Vec::new(),
188                provider_override: req.provider_override,
189                model_override,
190                reasoning_override: None,
191                workspace: req.workspace.clone(),
192                instructions: InstructionBundle {
193                    system: Some(REVIEW_RUBRIC.to_string()),
194                    developer: None,
195                    developer_context: None,
196                },
197                developer_context: None,
198                task_ledger_required: false,
199                service_tier_override: None,
200            })
201            .await?;
202
203        let handle = ReviewHandle {
204            review_id: uuid::Uuid::new_v4().to_string(),
205            review_thread_id: review_thread.thread_id,
206            turn_id,
207        };
208        self.emit(RoderEvent::ReviewStarted(ReviewStarted {
209            thread_id: req.thread_id.clone(),
210            turn_id: handle.turn_id.clone(),
211            review_id: handle.review_id.clone(),
212            review_thread_id: handle.review_thread_id.clone(),
213            target: req.target,
214            label: request.label.clone(),
215            timestamp: OffsetDateTime::now_utc(),
216        }))
217        .await;
218
219        Ok(StartedReview {
220            handle,
221            request,
222            parent_thread_id: req.thread_id,
223            events,
224        })
225    }
226
227    async fn finish_review(
228        self: &Arc<Self>,
229        started: StartedReview,
230    ) -> anyhow::Result<ReviewCompletion> {
231        let StartedReview {
232            handle,
233            request,
234            parent_thread_id,
235            mut events,
236        } = started;
237
238        let collected =
239            collect_final_answer(&mut events, &handle.review_thread_id, &handle.turn_id).await;
240
241        let text = match collected {
242            Ok(text) => text,
243            Err(error) => {
244                self.echo_review_into_parent(&parent_thread_id, &handle.turn_id, None)
245                    .await;
246                self.emit(RoderEvent::ReviewFailed(ReviewFailed {
247                    thread_id: parent_thread_id,
248                    turn_id: Some(handle.turn_id),
249                    review_id: handle.review_id,
250                    review_thread_id: Some(handle.review_thread_id),
251                    error: error.to_string(),
252                    timestamp: OffsetDateTime::now_utc(),
253                }))
254                .await;
255                return Err(error);
256            }
257        };
258
259        let output = parse_review_output(&text);
260        self.echo_review_into_parent(&parent_thread_id, &handle.turn_id, Some(&output))
261            .await;
262        self.remember_review(ReviewRecord {
263            review_id: handle.review_id.clone(),
264            parent_thread_id: parent_thread_id.clone(),
265            review_thread_id: handle.review_thread_id.clone(),
266            turn_id: handle.turn_id.clone(),
267            workspace_root: request.workspace_root.clone(),
268            request,
269            output: output.clone(),
270        })
271        .await;
272        self.emit(RoderEvent::ReviewCompleted(ReviewCompleted {
273            thread_id: parent_thread_id,
274            turn_id: handle.turn_id.clone(),
275            review_id: handle.review_id.clone(),
276            review_thread_id: handle.review_thread_id.clone(),
277            output: output.clone(),
278            timestamp: OffsetDateTime::now_utc(),
279        }))
280        .await;
281
282        Ok(ReviewCompletion { handle, output })
283    }
284
285    async fn review_vcs_provider(
286        &self,
287        workspace_root: &Path,
288    ) -> anyhow::Result<Arc<dyn VcsProvider>> {
289        let resolution = self
290            .registry
291            .version_control_resolver()
292            .resolve_provider(VcsResolveRequest {
293                workspace_root: workspace_root.to_path_buf(),
294                preferred_provider_id: None,
295            })
296            .await
297            .with_context(|| {
298                format!(
299                    "resolving a version control provider for {}",
300                    workspace_root.display()
301                )
302            })?;
303        match resolution {
304            VcsProviderResolution::Available { provider, .. } => Ok(provider),
305            VcsProviderResolution::Unavailable { workspace_root } => anyhow::bail!(
306                "no version control provider claims {}; a review needs a repository to diff",
307                workspace_root.display()
308            ),
309        }
310    }
311
312    /// Records the review in the requesting thread the way codex does: a
313    /// synthetic `<user_action>` turn plus the rendered summary, so the parent
314    /// agent can act on findings the user keeps.
315    async fn echo_review_into_parent(
316        &self,
317        thread_id: &ThreadId,
318        turn_id: &TurnId,
319        output: Option<&ReviewOutput>,
320    ) {
321        let (user_text, assistant_text) = match output {
322            Some(output) => (
323                synthetic_user_action(output),
324                render_review_output_text(output),
325            ),
326            None => (
327                synthetic_user_action_interrupted(),
328                REVIEW_INTERRUPTED_MESSAGE.to_string(),
329            ),
330        };
331        // Echoing is best effort: a persistence failure must not turn a
332        // successful review into a failed one.
333        let _ = self
334            .persist_turn_item(
335                thread_id,
336                turn_id,
337                &TranscriptItem::UserMessage(UserMessage::text(user_text)),
338            )
339            .await;
340        let _ = self
341            .persist_turn_item(
342                thread_id,
343                turn_id,
344                &TranscriptItem::AssistantMessage(AssistantMessage {
345                    text: assistant_text,
346                    phase: None,
347                }),
348            )
349            .await;
350    }
351
352    async fn remember_review(&self, record: ReviewRecord) {
353        let mut reviews = self.reviews.lock().await;
354        reviews.retain(|existing| existing.review_id != record.review_id);
355        reviews.push(record);
356        let overflow = reviews.len().saturating_sub(REVIEW_HISTORY_LIMIT);
357        if overflow > 0 {
358            reviews.drain(..overflow);
359        }
360    }
361}
362
363/// Drains the bus until the review turn ends, returning its final answer.
364///
365/// The persisted `final_answer` assistant item is authoritative; streamed
366/// message deltas are only a fallback for providers that end a turn without
367/// one.
368async fn collect_final_answer(
369    events: &mut broadcast::Receiver<EventEnvelope>,
370    thread_id: &ThreadId,
371    turn_id: &TurnId,
372) -> anyhow::Result<String> {
373    let mut final_answer: Option<String> = None;
374    let mut streamed = String::new();
375    loop {
376        let envelope = match events.recv().await {
377            Ok(envelope) => envelope,
378            Err(broadcast::error::RecvError::Lagged(_)) => continue,
379            Err(broadcast::error::RecvError::Closed) => {
380                anyhow::bail!("the runtime event bus closed before the review turn finished")
381            }
382        };
383        if envelope.thread_id.as_ref() != Some(thread_id)
384            || envelope.turn_id.as_ref() != Some(turn_id)
385        {
386            continue;
387        }
388        match &envelope.event {
389            RoderEvent::TranscriptItemAppended(event) => {
390                if let Some(TranscriptItem::AssistantMessage(message)) = &event.item
391                    && message.phase.as_deref() == Some(FINAL_ANSWER_PHASE)
392                {
393                    final_answer = Some(message.text.clone());
394                }
395            }
396            RoderEvent::InferenceEventReceived(event) => {
397                if let InferenceEvent::MessageDelta(delta) = &event.event {
398                    streamed.push_str(&delta.text);
399                }
400            }
401            RoderEvent::TurnCompleted(_) => break,
402            RoderEvent::TurnFailed(event) => {
403                anyhow::bail!("review turn failed: {}", event.error)
404            }
405            _ => {}
406        }
407    }
408    Ok(final_answer.unwrap_or(streamed))
409}
410
411#[cfg(test)]
412#[path = "run_tests.rs"]
413mod tests;