1pub(crate) const MAX_REVIEW_AGENT_FAILURES: u64 = 3;
13
14const MAX_PR_CREATE_FAILURES: u64 = 3;
16
17fn outcome_for_agent_error(err: &runner::agents::AgentError) -> &'static str {
22 match err {
23 runner::agents::AgentError::Timeout { .. } => "timeout",
24 runner::agents::AgentError::Interrupted { .. } => "interrupted",
25 runner::agents::AgentError::RateLimit { message } => {
30 crate::engine::cooldown::detect_credit_exhaustion_outcome(message)
31 .unwrap_or("rate_limit")
32 }
33 runner::agents::AgentError::Auth { message } => {
34 crate::engine::cooldown::detect_credit_exhaustion_outcome(message)
35 .unwrap_or("auth_error")
36 }
37 runner::agents::AgentError::InvalidResponse { .. } => "parse_error",
38 _ => "failed",
39 }
40}
41
42fn format_review_error(agent: &str, err: &runner::agents::AgentError) -> String {
51 match err {
52 runner::agents::AgentError::RateLimit { message } => format!(
53 "{agent} rate limit: {}",
54 runner::fallback::summarize_rate_limit_error(message)
55 ),
56 runner::agents::AgentError::Auth { message } => format!("{agent} auth error: {message}"),
57 runner::agents::AgentError::Timeout { elapsed } => {
58 format!("{agent} timed out after {}s", elapsed.as_secs())
59 }
60 other => format!("{agent} agent error: {other}"),
61 }
62}
63
64pub(crate) fn parse_pr_number_from_url(url: &str) -> Result<u64, String> {
76 let url = url.trim();
77 if url.is_empty() {
78 return Err("URL is empty".to_string());
79 }
80 if !url.starts_with("https://github.com/") {
81 return Err(format!(
82 "URL does not start with 'https://github.com/': {url}"
83 ));
84 }
85 if !url.contains("/pull/") {
86 return Err(format!("URL missing '/pull/' segment: {url}"));
87 }
88 let last_segment = url
89 .trim_end_matches('/')
90 .rsplit('/')
91 .next()
92 .ok_or_else(|| format!("No segment after '/pull/' in URL: {url}"))?;
93 let number_part = last_segment
94 .split(['?', '#'])
95 .next()
96 .filter(|s| !s.is_empty())
97 .ok_or_else(|| format!("PR number segment is empty in URL: {url}"))?;
98 let pr_num: u64 = number_part.parse().map_err(|_| {
99 format!("PR number is not a valid positive integer: '{number_part}' in URL: {url}")
100 })?;
101 if pr_num == 0 {
102 return Err(format!("PR number cannot be zero in URL: {url}"));
103 }
104 Ok(pr_num)
105}
106
107fn review_started_comment(review_agent: &str, review_model: &str) -> String {
108 let footer = attribution_footer("Review started", review_agent, Some(review_model));
109 format!("🔍 Automated review started{}", footer)
110}
111
112fn should_skip_no_code_reroute_increment(last_error: &str) -> bool {
113 crate::engine::runner::git_ops::is_transient_github_api_error(last_error)
114}
115
116use crate::backends::{ExternalBackend, ExternalTask, Status};
117use crate::cmd::CommandErrorContext;
118use crate::config;
119use crate::engine::attribution_footer;
120use crate::engine::auto_merge::{auto_merge_pr, handle_review_changes};
121use crate::engine::runner;
122use crate::engine::runner::worktree;
123use crate::engine::tasks::TaskManager;
124use crate::github::http::GhHttp;
125use crate::store::store_log_activity;
126use crate::store::TaskStore;
127use crate::store::{opt_store_get_task, store_increment};
128use crate::store::{store_set, store_set_result};
129use crate::store::{CompleteRun, RunTokenUsage, StartRun};
130use crate::tmux::TmuxManager;
131use anyhow::Context;
132use std::collections::HashSet;
133use std::sync::Arc;
134use tokio::process::Command;
135use tokio::sync::RwLock;
136
137use super::router::Router;
138
139#[derive(Debug, Clone)]
141pub(crate) enum ReviewDecision {
142 Approve {
144 issues: Vec<crate::engine::runner::response::ReviewIssue>,
145 },
146 RequestChanges {
148 notes: String,
149 issues: Vec<crate::engine::runner::response::ReviewIssue>,
150 },
151 Failed(String),
153 Blocked(String),
155 Skipped,
157 Rerouted,
159}
160
161enum EnsurePrResult {
163 Ready(u64),
165 EarlyReturn(ReviewDecision),
167}
168
169enum ReviewPhase<T> {
170 Ready(T),
171 EarlyReturn(ReviewDecision),
172}
173
174struct ReviewContext {
175 store_id: Option<i64>,
176 had_prev_push_failure: bool,
177 review_attempt: u32,
178 worktree_path: std::path::PathBuf,
179 branch_name: String,
180 pr_number: u64,
181 head_sha: Option<String>,
183 default_branch: String,
184 review_agent: String,
185 review_model: Option<String>,
186 review_task_id: String,
187 review_attempt_dir: std::path::PathBuf,
188 output_file: std::path::PathBuf,
189 invocation: runner::agent::AgentInvocation,
190 same_agent_fallback: bool,
191}
192
193impl ReviewContext {
194 fn review_model_str(&self) -> &str {
195 self.review_model.as_deref().unwrap_or("")
196 }
197}
198
199struct ReviewRun {
200 run_id: Option<i64>,
201}
202
203struct ParsedReview {
204 run_id: Option<i64>,
205 exit_code: i32,
206 stderr: String,
207 raw_output: String,
208 text_for_review: String,
209 token_usage: RunTokenUsage,
210 review_notes_for_comment: String,
211 decision: ReviewDecision,
212}
213
214fn approved_sha_key(repo: &str, task_id: &str, pr_number: u64) -> String {
216 format!("review_approved_sha:{repo}#{task_id}#{pr_number}")
217}
218
219const REVIEW_TIMEOUT_DEFAULT_SECS: u64 = 900;
221const REVIEW_TIMEOUT_MIN_SECS: u64 = 300;
222
223fn review_timeout_secs(configured: Option<u64>) -> u64 {
225 configured
226 .unwrap_or(REVIEW_TIMEOUT_DEFAULT_SECS)
227 .max(REVIEW_TIMEOUT_MIN_SECS)
228}
229
230async fn worktree_head_sha(dir: &std::path::Path) -> Option<String> {
231 let out = tokio::process::Command::new("git")
232 .args(["rev-parse", "HEAD"])
233 .current_dir(dir)
234 .kill_on_drop(true)
235 .output()
236 .await
237 .ok()?;
238 if !out.status.success() {
239 return None;
240 }
241 let sha = String::from_utf8_lossy(&out.stdout).trim().to_string();
242 (!sha.is_empty()).then_some(sha)
243}
244
245async fn record_approved_sha(
248 store: &Arc<TaskStore>,
249 repo: &str,
250 task_id: &str,
251 pr_number: u64,
252 dir: &std::path::Path,
253) {
254 let Some(sha) = worktree_head_sha(dir).await else {
255 return;
256 };
257 if let Err(e) = store
258 .kv_set(&approved_sha_key(repo, task_id, pr_number), &sha)
259 .await
260 {
261 tracing::warn!(task_id, err = %e, "failed to record approved commit sha");
262 }
263}
264
265async fn clear_approved_sha(store: &Arc<TaskStore>, repo: &str, task_id: &str, pr_number: u64) {
267 if let Err(e) = store
268 .kv_delete(&approved_sha_key(repo, task_id, pr_number))
269 .await
270 {
271 tracing::warn!(task_id, err = %e, "failed to clear approved commit sha");
272 }
273}
274
275async fn record_carried_approval(
278 store: &Arc<TaskStore>,
279 repo: &str,
280 task_id: &str,
281 pr_number: u64,
282) {
283 store_log_activity(
284 &Some(Arc::clone(store)),
285 repo,
286 task_id,
287 "review_decision",
288 None,
289 None,
290 None::<&str>,
291 None::<&str>,
292 Some(&serde_json::json!({
293 "decision": "approve",
294 "pr_number": pr_number,
295 "reason": "head_already_approved",
296 })),
297 )
298 .await;
299}
300
301async fn pr_head_already_approved(
310 store: &Arc<TaskStore>,
311 repo: &str,
312 task_id: &str,
313 pr_number: u64,
314) -> bool {
315 let approved = match store
316 .kv_get(&approved_sha_key(repo, task_id, pr_number))
317 .await
318 {
319 Ok(sha) => sha,
320 Err(e) => {
321 tracing::warn!(task_id, err = %e, "failed to read approved commit sha");
322 return false;
323 }
324 };
325 if approved.is_none() {
326 return false;
327 }
328 let gh = match crate::github::http::GhHttp::new() {
329 Ok(gh) => gh,
330 Err(e) => {
331 tracing::warn!(task_id, err = %e, "cannot check PR head against recorded approval");
332 return false;
333 }
334 };
335 let pr_head = match gh.get_pr(repo, pr_number).await {
336 Ok(pr) => Some(pr.head.sha),
337 Err(e) => {
338 tracing::warn!(task_id, pr_number, err = %e, "failed to read PR head sha");
339 None
340 }
341 };
342 approval_covers_head(approved.as_deref(), pr_head.as_deref())
343}
344
345fn approval_covers_head(approved: Option<&str>, pr_head: Option<&str>) -> bool {
347 match (approved, pr_head) {
348 (Some(approved), Some(head)) => !approved.is_empty() && approved == head,
349 _ => false,
350 }
351}
352
353fn build_review_disallowed_tools(worktree_path: &std::path::Path) -> Vec<String> {
354 let mut tools = vec![
355 "Bash(rm *)".to_string(),
356 "Bash(rm -*)".to_string(),
357 "Bash(git push*)".to_string(),
358 "Bash(gh pr create*)".to_string(),
360 ];
361 let orch_yml = worktree_path.join(".orch.yml");
362 let orch_yml_str = orch_yml.to_string_lossy();
363 tools.extend([
364 format!("Write({orch_yml_str})"),
365 format!("Edit({orch_yml_str})"),
366 ]);
367 if let Ok(orch_home) = crate::home::orch_home() {
368 for path in [
369 orch_home.join("config.yml"),
370 orch_home.join("config.example.yml"),
371 ] {
372 let path_str = path.to_string_lossy();
373 tools.extend([
374 format!("Read({path_str})"),
375 format!("Write({path_str})"),
376 format!("Edit({path_str})"),
377 ]);
378 }
379 }
380 tools
381}
382
383async fn select_review_agent(
384 task_id: &str,
385 task_agent: &str,
386 store_id: Option<i64>,
387 router: &Arc<RwLock<Router>>,
388 store: &Arc<TaskStore>,
389) -> anyhow::Result<(String, Option<String>, bool)> {
390 let mut exclude_set: HashSet<String> = HashSet::new();
391 if !task_agent.is_empty() {
392 exclude_set.insert(task_agent.to_string());
393 }
394 if let Some(store_id) = store_id {
395 if let Ok(failed) = store.previous_review_agents(store_id).await {
396 for agent in failed {
397 exclude_set.insert(agent);
398 }
399 }
400 }
401
402 let mut r = router.write().await;
403 let mut tried_agents: HashSet<String> = HashSet::new();
404 let mut chosen_agent: Option<String> = None;
405 let mut chosen_model: Option<String> = None;
406 let available_count = r.available_agents.len().max(1);
407
408 loop {
409 let tmp_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
410 let agent = match r.next_round_robin_agent(&tmp_exclude_refs, "review") {
411 Some(a) => a,
412 None => break,
413 };
414
415 if tried_agents.contains(&agent) {
416 break;
417 }
418 tried_agents.insert(agent.clone());
419
420 let model = r.config.model_for_complexity(&agent, "review", task_id);
421 if model.is_some() {
422 chosen_agent = Some(agent);
423 chosen_model = model;
424 break;
425 }
426
427 exclude_set.insert(agent);
428 if exclude_set.len() >= available_count {
429 break;
430 }
431 }
432
433 if let Some(agent) = chosen_agent {
434 let same = !task_agent.is_empty() && agent == task_agent;
435 Ok((agent, chosen_model, same))
436 } else {
437 let final_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
438 let fallback_agent = r
439 .next_round_robin_agent(&final_exclude_refs, "review")
440 .unwrap_or_else(|| "claude".to_string());
441 let fallback_model = r
442 .config
443 .model_for_complexity(&fallback_agent, "review", task_id);
444 if fallback_model.is_none() {
445 anyhow::bail!(
446 "no review agent with a configured review model is available for task {}",
447 task_id
448 );
449 }
450 let same = !task_agent.is_empty() && fallback_agent == task_agent;
451 Ok((fallback_agent, fallback_model, same))
452 }
453}
454
455#[allow(clippy::too_many_arguments)]
456async fn build_review_context(
457 task: &ExternalTask,
458 repo: &str,
459 router: &Arc<RwLock<Router>>,
460 task_manager: &Arc<TaskManager>,
461 store: &Arc<TaskStore>,
462) -> anyhow::Result<ReviewPhase<ReviewContext>> {
463 let stored_task = store
464 .get_by_external_id(repo, &task.id.0)
465 .await
466 .with_context(|| format!("store lookup failed for task {}", task.id.0))?;
467
468 let store_id: Option<i64> = stored_task.as_ref().map(|t| t.id);
469
470 if let Some(pr_number) = stored_task.as_ref().and_then(|t| t.pr_number) {
473 let pr_number = pr_number as u64;
474 if pr_head_already_approved(store, repo, &task.id.0, pr_number).await {
475 tracing::info!(
476 task_id = task.id.0,
477 pr_number,
478 "PR head already approved — skipping review, merge retry continues"
479 );
480 record_carried_approval(store, repo, &task.id.0, pr_number).await;
481 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Approve {
482 issues: Vec::new(),
483 }));
484 }
485 }
486
487 let had_prev_push_failure = stored_task
488 .as_ref()
489 .is_some_and(|t| t.last_error.contains("push failed"));
490 let review_attempt: u32 = match store_increment(
491 &Some(Arc::clone(store)),
492 repo,
493 &task.id.0,
494 "review_invocations",
495 )
496 .await
497 {
498 Ok(v) => v as u32,
499 Err(e) => {
500 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment review_invocations — using fallback attempt=1");
501 1
502 }
503 };
504
505 let no_code_review = stored_task.as_ref().is_some_and(|t| {
506 t.worktree.trim().is_empty() && t.branch.trim().is_empty() && t.pr_number.is_none()
507 });
508 if no_code_review {
509 if crate::engine::tasks::is_internal_id(&task.id.0) {
510 tracing::info!(
512 task_id = task.id.0,
513 "no worktree/branch/pr for internal needs_review task — skipping review and marking done"
514 );
515 if let Err(e) = task_manager
516 .update_task_status(&task.id, Status::Done)
517 .await
518 {
519 tracing::error!(
520 task_id = task.id.0,
521 err = %e,
522 "update_task_status(Done) failed for no-code review skip"
523 );
524 }
525 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Skipped));
526 }
527
528 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
531 .ok()
532 .and_then(|s| s.parse().ok())
533 .or_else(|| {
534 config::get("workflow.max_attempts")
535 .ok()
536 .and_then(|s| s.parse().ok())
537 })
538 .unwrap_or(3);
539
540 let reroutes = match store_increment(
541 &Some(Arc::clone(store)),
542 repo,
543 &task.id.0,
544 "no_code_reroutes",
545 )
546 .await
547 {
548 Ok(v) => v,
549 Err(e) => {
550 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
551 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
552 "transient store error: {e}"
553 ))));
554 }
555 };
556
557 if reroutes as u32 >= max_reroutes {
558 tracing::error!(
559 task_id = task.id.0,
560 reroutes,
561 max_reroutes,
562 "reached max reroute attempts for no-worktree/branch/pr result — blocking for human review"
563 );
564 let msg = format!(
565 "no worktree/branch/pr after {}/{} reroute attempts",
566 reroutes, max_reroutes
567 );
568 let fields = [
569 (
570 "block_reason",
571 serde_json::json!(format!(
572 "max reroute attempts ({}) reached — no worktree/branch/pr produced",
573 max_reroutes
574 )),
575 ),
576 ("last_error", serde_json::json!(msg)),
577 ("agent", serde_json::json!(null)),
578 ("model", serde_json::json!(null)),
579 ];
580 if let Err(e) = task_manager
581 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
582 .await
583 {
584 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
585 }
586 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(msg)));
587 }
588
589 tracing::warn!(
590 task_id = task.id.0,
591 reroutes,
592 max_reroutes,
593 "no worktree/branch/pr for external needs_review task — re-routing to a different agent"
594 );
595 if let Err(e) = store_set_result(
596 &Some(Arc::clone(store)),
597 repo,
598 &task.id.0,
599 &[
600 ("agent", serde_json::json!(null)),
601 ("model", serde_json::json!(null)),
602 (
603 "last_error",
604 serde_json::json!("no worktree/branch/pr produced"),
605 ),
606 ],
607 )
608 .await
609 {
610 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
611 } else if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
612 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
613 }
614 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Rerouted));
615 }
616
617 let mut worktree_path = match stored_task.as_ref().map(|t| t.worktree.as_str()) {
618 Some(w) if !w.is_empty() => std::path::PathBuf::from(w),
619 _ => {
620 tracing::warn!(task_id = task.id.0, "no worktree found for review");
621 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
622 "no worktree found".to_string(),
623 )));
624 }
625 };
626
627 let mut branch_name = match stored_task.as_ref().map(|t| t.branch.as_str()) {
628 Some(b) if !b.is_empty() => b.to_string(),
629 _ => {
630 tracing::warn!(task_id = task.id.0, "no branch found for review");
631 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
632 "no branch found".to_string(),
633 )));
634 }
635 };
636
637 let mut missing_worktree_recovered = false;
638 if !worktree_path.exists() {
639 match recover_missing_review_worktree(task, repo, store).await {
640 Ok(recovered) => {
641 branch_name = recovered.branch;
642 worktree_path = recovered.work_dir;
643 missing_worktree_recovered = true;
644 }
645 Err(e) => {
646 let reason = format!("missing review worktree recovery failed: {e}");
647 tracing::error!(task_id = task.id.0, error = %reason, "cannot recover review worktree");
648 store_set(
649 &Some(Arc::clone(store)),
650 repo,
651 &task.id.0,
652 &[("last_error", serde_json::json!(reason.clone()))],
653 )
654 .await;
655 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(reason)));
656 }
657 }
658 }
659
660 if missing_worktree_recovered {
661 store_set(
662 &Some(Arc::clone(store)),
663 repo,
664 &task.id.0,
665 &[("last_error", serde_json::json!(""))],
666 )
667 .await;
668 }
669
670 let agent_summary = stored_task
671 .as_ref()
672 .map(|t| t.summary.clone())
673 .unwrap_or_default();
674 let stored_pr_number = stored_task
675 .as_ref()
676 .and_then(|t| t.pr_number)
677 .map(|n| n as u64)
678 .filter(|&n| n > 0);
679
680 let pr_number = match ensure_pr_exists(
681 task,
682 &branch_name,
683 &worktree_path,
684 repo,
685 &agent_summary,
686 store,
687 task_manager,
688 stored_pr_number,
689 )
690 .await?
691 {
692 EnsurePrResult::Ready(n) => n,
693 EnsurePrResult::EarlyReturn(decision) => return Ok(ReviewPhase::EarlyReturn(decision)),
694 };
695
696 {
700 let fetch_timeout = std::time::Duration::from_secs(60);
701 let fetch_future = async {
702 let mut cmd = Command::new("git");
703 cmd.args(["fetch", "origin", "--prune"])
704 .current_dir(&worktree_path);
705 cmd.kill_on_drop(true);
707 cmd.output_with_context().await
708 };
709
710 match tokio::time::timeout(fetch_timeout, fetch_future).await {
711 Ok(Ok(o)) if o.status.success() => {
712 }
714 Ok(Ok(o)) => {
715 tracing::warn!(
716 task_id = task.id.0,
717 exit_code = ?o.status.code(),
718 stdout = %String::from_utf8_lossy(&o.stdout),
719 stderr = %String::from_utf8_lossy(&o.stderr),
720 "review: git fetch failed (non-zero exit) — diff/log may use stale remote refs"
721 );
722 }
723 Ok(Err(e)) => {
724 tracing::warn!(
725 task_id = task.id.0,
726 error = %e,
727 "review: git fetch failed — diff/log may use stale remote refs"
728 );
729 }
730 Err(_) => {
731 tracing::warn!(
732 task_id = task.id.0,
733 "review: git fetch timed out after 60s — diff/log may use stale remote refs"
734 );
735 }
736 }
737 }
738
739 let default_branch = runner::worktree::detect_default_branch(&worktree_path).await;
740 let git_diff = runner::context::build_git_diff(&worktree_path, &default_branch).await;
741 let git_log = runner::context::build_git_log(&worktree_path, &default_branch).await;
742 let project_dir = crate::config::get_projects_with_paths()
745 .unwrap_or_default()
746 .into_iter()
747 .find(|(candidate, _)| candidate == repo)
748 .map(|(_, dir)| dir);
749 let sandboxed = match &project_dir {
750 Some(dir) => runner::sandbox::enabled(dir),
751 None => runner::sandbox::enabled_globally(),
752 };
753 let main_project_dir = project_dir.unwrap_or_else(|| worktree_path.clone());
754 let review_prompt = runner::agent::build_review_prompt(
755 task,
756 &agent_summary,
757 &git_diff,
758 &git_log,
759 &default_branch,
760 pr_number,
761 runner::agent::Shipcheck::detect(&worktree_path)
762 .await
763 .for_prompt(sandboxed),
764 );
765
766 let task_agent = stored_task
767 .as_ref()
768 .and_then(|t| t.agent.clone())
769 .unwrap_or_default();
770 let (review_agent, review_model, same_agent_fallback) =
771 select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
772
773 anyhow::ensure!(
774 review_model.is_some(),
775 "review model missing for task {} after agent selection — refusing to dispatch",
776 task.id.0
777 );
778
779 if same_agent_fallback {
780 tracing::warn!(
781 task_id = task.id.0,
782 agent = %review_agent,
783 "no other agent available for review — falling back to the agent that wrote the code"
784 );
785 store_log_activity(
786 &Some(Arc::clone(store)),
787 repo,
788 &task.id.0,
789 "review_agent_fallback",
790 None,
791 None,
792 Some(&review_agent),
793 review_model.as_deref(),
794 Some(&serde_json::json!({
795 "reason": "all available agents excluded (author + previous reviewers)",
796 })),
797 )
798 .await;
799 }
800
801 tracing::info!(
802 task_id = task.id.0,
803 agent = %review_agent,
804 model = ?review_model,
805 "spawning review agent"
806 );
807
808 let review_task_id = format!("{}-review", task.id.0);
809 let review_attempt_dir =
810 crate::home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
811 let output_file = review_attempt_dir.join("output.json");
812
813 let git_name =
814 crate::config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
815 let git_email = crate::config::get("git.email")
816 .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
817 let review_timeout_secs = review_timeout_seconds();
818
819 let invocation = runner::agent::AgentInvocation {
820 agent: review_agent.clone(),
821 model: review_model.clone(),
822 work_dir: worktree_path.clone(),
823 system_prompt: runner::agent::review_system_prompt(),
824 agent_message: review_prompt,
825 task_id: review_task_id.clone(),
826 disallowed_tools: build_review_disallowed_tools(&worktree_path),
827 git_author_name: git_name,
828 git_author_email: git_email,
829 output_file: output_file.clone(),
830 timeout_seconds: review_timeout_secs,
831 repo: repo.to_string(),
832 attempt: review_attempt,
833 sandbox: sandboxed,
834 main_project_dir,
835 branch: branch_name.clone(),
836 };
837
838 let head_sha = match GhHttp::new() {
839 Ok(gh) => gh.get_pr(repo, pr_number).await.ok().map(|pr| pr.head.sha),
840 Err(_) => None,
841 };
842
843 Ok(ReviewPhase::Ready(ReviewContext {
844 store_id,
845 had_prev_push_failure,
846 review_attempt,
847 worktree_path,
848 branch_name,
849 pr_number,
850 head_sha,
851 default_branch,
852 review_agent,
853 review_model,
854 review_task_id,
855 review_attempt_dir,
856 output_file,
857 invocation,
858 same_agent_fallback,
859 }))
860}
861
862#[allow(clippy::too_many_arguments)]
863async fn complete_review_run(
864 store: &Arc<TaskStore>,
865 run_id: Option<i64>,
866 exit_code: Option<i32>,
867 stdout: &str,
868 stderr: &str,
869 parsed: &str,
870 outcome: &str,
871 error: &str,
872 tokens: RunTokenUsage,
873) {
874 if let Some(run_id) = run_id {
875 if let Err(e) = store
876 .complete_run(&CompleteRun {
877 run_id,
878 exit_code,
879 stdout,
880 stderr,
881 parsed,
882 outcome,
883 error,
884 tokens,
885 })
886 .await
887 {
888 tracing::warn!(
889 run_id,
890 error = %e,
891 "failed to record review run completion in audit trail"
892 );
893 }
894 }
895}
896
897async fn invoke_review_agent(
898 task: &ExternalTask,
899 tmux: &Arc<TmuxManager>,
900 ctx: &ReviewContext,
901 repo: &str,
902 store: &Arc<TaskStore>,
903) -> ReviewPhase<ReviewRun> {
904 let run_id = if let Some(sid) = ctx.store_id {
905 match store
906 .start_run(&StartRun {
907 task_id: sid,
908 attempt: ctx.review_attempt as i32,
909 run_type: "review",
910 agent: &ctx.review_agent,
911 model: ctx.review_model.as_deref().unwrap_or(""),
912 command: "",
913 prompt: "",
914 })
915 .await
916 {
917 Ok(run_id) => Some(run_id),
918 Err(e) => {
919 tracing::warn!(
920 task_id = task.id.0,
921 error = %e,
922 "failed to record review run start in audit trail"
923 );
924 None
925 }
926 }
927 } else {
928 None
929 };
930
931 let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
932 Ok(session) => session,
933 Err(e) => {
934 tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
935 complete_review_run(
936 store,
937 run_id,
938 None,
939 "",
940 &e.to_string(),
941 "",
942 "failed",
943 &format!("spawn failed: {e}"),
944 RunTokenUsage::default(),
945 )
946 .await;
947 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
948 }
949 };
950
951 let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
952 match GhHttp::new() {
953 Ok(gh) => {
954 let app = review_app(repo).await;
955 post_review_started(app.as_deref(), &gh, repo, ctx.head_sha.as_deref()).await;
956 if let Err(e) = gh
957 .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
958 .await
959 {
960 tracing::debug!(
961 task_id = task.id.0,
962 pr_number = ctx.pr_number,
963 error = %e,
964 "failed to post review-started comment"
965 );
966 }
967 }
968 Err(e) => {
969 tracing::debug!(
970 task_id = task.id.0,
971 pr_number = ctx.pr_number,
972 error = %e,
973 "failed to create GitHub client for review-started comment"
974 );
975 }
976 }
977
978 let poll_interval = std::time::Duration::from_secs(5);
979 let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
980 let wait_result = tokio::time::timeout(
981 timeout_duration,
982 tmux.wait_for_completion(&session, poll_interval),
983 )
984 .await;
985
986 match wait_result {
987 Ok(Ok(_)) => {
988 tracing::info!(task_id = task.id.0, "review agent completed");
989 let _ = tmux.kill_session(&session).await;
990 ReviewPhase::Ready(ReviewRun { run_id })
991 }
992 Ok(Err(e)) => {
993 tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
994 let _ = tmux.kill_session(&session).await;
995
996 let exit_code: i32 = tokio::task::spawn_blocking({
1001 let review_attempt_dir = ctx.review_attempt_dir.clone();
1002 move || {
1003 std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
1004 .ok()
1005 .and_then(|s| s.trim().parse().ok())
1006 .unwrap_or(-1)
1007 }
1008 })
1009 .await
1010 .unwrap_or(-1);
1011
1012 if exit_code != 0 {
1013 let raw_output =
1018 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1019 .await;
1020 let agent_result =
1021 runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1022 if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
1024 tracing::info!(
1025 task_id = task.id.0,
1026 agent = %ctx.review_agent,
1027 exit_code,
1028 output_found = !raw_output.is_empty(),
1029 "review agent succeeded despite non-zero exit — proceeding to parse output"
1030 );
1031 return ReviewPhase::Ready(ReviewRun { run_id });
1032 }
1033 }
1034
1035 tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1036 complete_review_run(
1037 store,
1038 run_id,
1039 Some(exit_code),
1040 "",
1041 &e.to_string(),
1042 "",
1043 "failed",
1044 &format!("agent error: {e}"),
1045 RunTokenUsage::default(),
1046 )
1047 .await;
1048 ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1049 }
1050 Err(_) => {
1051 tracing::error!(task_id = task.id.0, "review agent timed out");
1052 let _ = tmux.kill_session(&session).await;
1053 complete_review_run(
1056 store,
1057 run_id,
1058 Some(-1),
1059 "",
1060 "timeout",
1061 "",
1062 "timeout",
1063 "timeout",
1064 RunTokenUsage::default(),
1065 )
1066 .await;
1067 ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1068 }
1069 }
1070}
1071
1072async fn record_review_agent_failure(
1073 task_id: &str,
1074 review_agent: &str,
1075 review_model: Option<&str>,
1076 err: &runner::agents::AgentError,
1077) {
1078 match err {
1079 runner::agents::AgentError::RateLimit { message }
1080 | runner::agents::AgentError::Auth { message } => {
1081 tracing::warn!(
1082 task_id,
1083 agent = %review_agent,
1084 "review agent hit error — adding to cooldown"
1085 );
1086 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
1087 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
1088 {
1089 if let Some(model) = review_model {
1090 crate::engine::cooldown::record_persistent_model_failure(
1092 review_agent,
1093 model,
1094 )
1095 .await;
1096 } else {
1097 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
1098 .await;
1099 }
1100 } else {
1101 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
1102 }
1103 } else {
1104 runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1105 .await;
1106 }
1107 }
1108 runner::agents::AgentError::Interrupted { .. } => {}
1110 runner::agents::AgentError::InvalidResponse { .. } => {
1111 if let Some(model) = review_model {
1113 crate::engine::cooldown::record_transient_model_failure(review_agent, model).await;
1114 }
1115 }
1116 runner::agents::AgentError::ModelUnavailable { .. } => {
1117 if let Some(model) = review_model {
1122 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1123 }
1124 }
1125 _ => {
1126 if let Some(model) = review_model {
1129 crate::engine::cooldown::record_model_failure(review_agent, model).await;
1130 }
1131 }
1132 }
1133}
1134
1135fn review_decision_from_response(
1136 review_response: runner::response::ReviewResponse,
1137) -> ReviewDecision {
1138 match review_response.decision.as_str() {
1139 "approve" => approve_from_evidence(review_response),
1140 "request_changes" => ReviewDecision::RequestChanges {
1141 notes: review_response.notes,
1142 issues: review_response.issues,
1143 },
1144 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1145 }
1146}
1147
1148fn approve_from_evidence(review_response: runner::response::ReviewResponse) -> ReviewDecision {
1150 let tests_failed = review_response
1151 .test_results
1152 .as_deref()
1153 .is_some_and(|s| s.trim().eq_ignore_ascii_case("fail"));
1154 let has_error = review_response
1155 .issues
1156 .iter()
1157 .any(|issue| issue.severity.trim().eq_ignore_ascii_case("error"));
1158 if tests_failed || has_error {
1159 let notes = if tests_failed {
1160 notes_for_failed_tests(review_response.notes)
1161 } else {
1162 review_response.notes
1163 };
1164 ReviewDecision::RequestChanges {
1165 notes,
1166 issues: review_response.issues,
1167 }
1168 } else {
1169 ReviewDecision::Approve {
1170 issues: review_response.issues,
1171 }
1172 }
1173}
1174
1175fn notes_for_failed_tests(notes: String) -> String {
1176 const MARK: &str = "Review reported test_results: fail";
1177 if notes.trim().is_empty() {
1178 MARK.to_string()
1179 } else if notes.contains("test_results: fail") {
1180 notes
1181 } else {
1182 format!("{notes}\n\n{MARK}")
1183 }
1184}
1185
1186fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1187 match decision {
1188 ReviewDecision::Approve { .. } => "approve",
1189 ReviewDecision::RequestChanges { .. } => "request_changes",
1190 ReviewDecision::Failed(_) => "failed",
1191 ReviewDecision::Blocked(_) => "blocked",
1192 ReviewDecision::Skipped => "skipped",
1193 ReviewDecision::Rerouted => "rerouted",
1194 }
1195}
1196
1197async fn parse_review_output(
1198 task: &ExternalTask,
1199 repo: &str,
1200 ctx: &ReviewContext,
1201 run: ReviewRun,
1202 store: &Arc<TaskStore>,
1203) -> ReviewPhase<ParsedReview> {
1204 let (file_exists, file_size, exit_code, stderr) = {
1205 let output_file_clone = ctx.output_file.clone();
1206 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1207 match tokio::task::spawn_blocking(move || {
1208 let file_exists = output_file_clone.exists();
1209 let file_size = std::fs::metadata(&output_file_clone)
1210 .map(|m| m.len())
1211 .unwrap_or(0);
1212 let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1213 .ok()
1214 .and_then(|s| s.trim().parse::<i32>().ok())
1215 .unwrap_or(-1);
1216 let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1217 .unwrap_or_default();
1218 (file_exists, file_size, exit_code, stderr)
1219 })
1220 .await
1221 {
1222 Ok(tuple) => tuple,
1223 Err(e) => {
1224 tracing::error!(
1225 task_id = task.id.0,
1226 error = %e,
1227 "spawn_blocking panicked reading review output metadata"
1228 );
1229 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1230 }
1231 }
1232 };
1233
1234 tracing::info!(
1235 task_id = task.id.0,
1236 path = %ctx.output_file.display(),
1237 file_exists,
1238 file_size,
1239 "reading review agent output"
1240 );
1241
1242 let raw_output =
1243 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1244 let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1245 let has_result_envelope =
1246 runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1247 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1248 let token_usage = RunTokenUsage {
1249 input_tokens: agent_result
1250 .as_ref()
1251 .and_then(|r| r.input_tokens)
1252 .unwrap_or(0),
1253 output_tokens: agent_result
1254 .as_ref()
1255 .and_then(|r| r.output_tokens)
1256 .unwrap_or(0),
1257 total_cost_usd: agent_result
1258 .as_ref()
1259 .and_then(|r| r.cost_usd)
1260 .unwrap_or(0.0),
1261 duration_secs: 0.0,
1262 };
1263
1264 let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1265 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1266 && !raw_output.contains("\"is_error\":true");
1267 let is_hard_failure =
1269 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1270
1271 if is_hard_failure {
1272 tracing::warn!(
1273 task_id = task.id.0,
1274 exit_code,
1275 raw_output_len = raw_output.len(),
1276 stderr_len = stderr.len(),
1277 "review agent: entering error path"
1278 );
1279 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1280 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1281 record_review_agent_failure(
1282 &task.id.0,
1283 &ctx.review_agent,
1284 ctx.review_model.as_deref(),
1285 &err,
1286 )
1287 .await;
1288 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1289 let stored_error = format_review_error(&ctx.review_agent, &err);
1290 complete_review_run(
1291 store,
1292 run.run_id,
1293 Some(exit_code),
1294 &raw_output,
1295 &stderr,
1296 "",
1297 outcome_for_agent_error(&err),
1298 &stored_error,
1299 token_usage,
1300 )
1301 .await;
1302 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1303 }
1304
1305 let text_for_review = agent_result
1306 .as_ref()
1307 .map(|r| r.result_text.clone())
1308 .filter(|text| !text.is_empty())
1309 .unwrap_or_else(|| {
1310 tracing::debug!(
1311 task_id = task.id.0,
1312 agent = %ctx.review_agent,
1313 "review agent: per-agent extractor returned no text, using raw output"
1314 );
1315 raw_output.clone()
1316 });
1317
1318 let review_response = match runner::response::parse_review_response(&text_for_review) {
1319 Ok(response) => response,
1320 Err(e) => {
1321 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1322 tracing::warn!(
1323 task_id = task.id.0,
1324 agent = %ctx.review_agent,
1325 "review response parsed via keyword fallback"
1326 );
1327 response
1328 } else {
1329 let ndjson_completed = ndjson_completed_no_error;
1335
1336 if ndjson_completed || !has_result_envelope {
1339 let assistant_text = runner::agents::collect_assistant_messages_text(
1340 &ctx.review_agent,
1341 &raw_output,
1342 );
1343 if !assistant_text.is_empty() {
1344 if let Ok(response) =
1345 runner::response::parse_review_response(&assistant_text)
1346 {
1347 tracing::debug!(
1348 task_id = task.id.0,
1349 agent = %ctx.review_agent,
1350 "found review response JSON in assistant message (terminal_reason:completed)"
1351 );
1352 let review_notes_for_comment = response.notes.clone();
1353 let decision = review_decision_from_response(response);
1354 return ReviewPhase::Ready(ParsedReview {
1355 run_id: run.run_id,
1356 exit_code,
1357 stderr,
1358 raw_output,
1359 text_for_review: assistant_text,
1360 token_usage,
1361 review_notes_for_comment,
1362 decision,
1363 });
1364 }
1365 if let Some(response) =
1366 runner::response::infer_review_response(&assistant_text)
1367 {
1368 tracing::warn!(
1369 task_id = task.id.0,
1370 agent = %ctx.review_agent,
1371 "inferred review response from assistant message (terminal_reason:completed)"
1372 );
1373 let review_notes_for_comment = response.notes.clone();
1374 let decision = review_decision_from_response(response);
1375 return ReviewPhase::Ready(ParsedReview {
1376 run_id: run.run_id,
1377 exit_code,
1378 stderr,
1379 raw_output,
1380 text_for_review: assistant_text,
1381 token_usage,
1382 review_notes_for_comment,
1383 decision,
1384 });
1385 }
1386 }
1387 }
1388
1389 let scan_head = runner::agents::patterns::safe_head(
1396 &text_for_review,
1397 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1398 );
1399 let scan_tail = runner::agents::patterns::safe_tail(
1400 &text_for_review,
1401 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1402 );
1403
1404 if let Some(err) = (!ndjson_completed)
1405 .then(|| {
1406 runner::agents::patterns::detect_rate_limit(scan_head)
1407 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1408 })
1409 .flatten()
1410 {
1411 let runner::agents::AgentError::RateLimit { message } = &err else {
1412 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1413 };
1414 tracing::warn!(
1415 task_id = task.id.0,
1416 agent = %ctx.review_agent,
1417 "review agent hit rate limit — adding to cooldown"
1418 );
1419 let summary = runner::fallback::summarize_rate_limit_error(message);
1426 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1427 record_review_agent_failure(
1428 &task.id.0,
1429 &ctx.review_agent,
1430 ctx.review_model.as_deref(),
1431 &err,
1432 )
1433 .await;
1434 complete_review_run(
1435 store,
1436 run.run_id,
1437 Some(exit_code),
1438 &raw_output,
1439 &stderr,
1440 &text_for_review,
1441 outcome_for_agent_error(&err),
1442 &stored_error,
1443 token_usage,
1444 )
1445 .await;
1446 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1447 }
1448
1449 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1450 let runner::agents::AgentError::Auth { message } = &err else {
1451 unreachable!("detect_auth_error only returns AgentError::Auth")
1452 };
1453 tracing::warn!(
1454 task_id = task.id.0,
1455 agent = %ctx.review_agent,
1456 "review agent hit auth error — adding to cooldown"
1457 );
1458 let stored_error = format!("auth error: {message}");
1459 record_review_agent_failure(
1460 &task.id.0,
1461 &ctx.review_agent,
1462 ctx.review_model.as_deref(),
1463 &err,
1464 )
1465 .await;
1466 complete_review_run(
1467 store,
1468 run.run_id,
1469 Some(exit_code),
1470 &raw_output,
1471 &stderr,
1472 &text_for_review,
1473 outcome_for_agent_error(&err),
1474 &stored_error,
1475 token_usage,
1476 )
1477 .await;
1478 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1479 }
1480
1481 if agent_result
1483 .as_ref()
1484 .map(|r| r.truncated_by_length)
1485 .unwrap_or(false)
1486 {
1487 tracing::warn!(
1488 task_id = task.id.0,
1489 agent = %ctx.review_agent,
1490 model = ?ctx.review_model,
1491 "review agent output truncated: step_finish reason=length \
1492 (token budget exceeded before completion)"
1493 );
1494 if let Some(model) = ctx.review_model.as_deref() {
1495 crate::engine::cooldown::record_transient_model_failure(
1496 &ctx.review_agent,
1497 model,
1498 )
1499 .await;
1500 }
1501 let stored_error = "truncated: review output was cut off before completion \
1502 (output/reasoning token budget exceeded)"
1503 .to_string();
1504 complete_review_run(
1505 store,
1506 run.run_id,
1507 Some(exit_code),
1508 &raw_output,
1509 &stderr,
1510 &text_for_review,
1511 "truncated",
1512 &stored_error,
1513 token_usage,
1514 )
1515 .await;
1516 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1517 }
1518
1519 if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1522 tracing::warn!(
1523 task_id = task.id.0,
1524 agent = %ctx.review_agent,
1525 model = ?ctx.review_model,
1526 "review session exited mid-thought with no result envelope \
1527 — storing as incomplete and retrying without persistent cooldown"
1528 );
1529 let stored_error =
1530 "incomplete: review session exited before producing a decision".to_string();
1531 complete_review_run(
1532 store,
1533 run.run_id,
1534 Some(exit_code),
1535 &raw_output,
1536 &stderr,
1537 &text_for_review,
1538 "incomplete",
1539 &stored_error,
1540 token_usage,
1541 )
1542 .await;
1543 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1544 }
1545
1546 tracing::error!(
1547 task_id = task.id.0,
1548 error = %e,
1549 agent = %ctx.review_agent,
1550 output = %text_for_review.chars().take(300).collect::<String>(),
1551 raw_output = %raw_output.chars().take(1000).collect::<String>(),
1552 "failed to parse review response"
1553 );
1554 if let Some(model) = ctx.review_model.as_deref() {
1555 crate::engine::cooldown::record_transient_model_failure(
1556 &ctx.review_agent,
1557 model,
1558 )
1559 .await;
1560 }
1561 complete_review_run(
1562 store,
1563 run.run_id,
1564 Some(exit_code),
1565 &raw_output,
1566 &stderr,
1567 &text_for_review,
1568 "parse_error",
1569 &format!("parse error: {e}"),
1570 token_usage,
1571 )
1572 .await;
1573 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1574 "parse error: {e}"
1575 )));
1576 }
1577 }
1578 };
1579
1580 let review_notes_for_comment = review_response.notes.clone();
1581 let decision = review_decision_from_response(review_response);
1582
1583 tracing::info!(
1584 task_id = task.id.0,
1585 pr_number = ctx.pr_number,
1586 decision = ?decision,
1587 "review agent decision received"
1588 );
1589 store_log_activity(
1590 &Some(Arc::clone(store)),
1591 repo,
1592 &task.id.0,
1593 "review_decision",
1594 None,
1595 None,
1596 Some(&ctx.review_agent),
1597 ctx.review_model.as_deref(),
1598 Some(&serde_json::json!({
1599 "decision": review_decision_name(&decision),
1600 "pr_number": ctx.pr_number,
1601 })),
1602 )
1603 .await;
1604
1605 ReviewPhase::Ready(ParsedReview {
1606 run_id: run.run_id,
1607 exit_code,
1608 stderr,
1609 raw_output,
1610 text_for_review,
1611 token_usage,
1612 review_notes_for_comment,
1613 decision,
1614 })
1615}
1616
1617async fn finalize_review_run(
1618 task: &ExternalTask,
1619 ctx: &ReviewContext,
1620 parsed: &ParsedReview,
1621 store: &Arc<TaskStore>,
1622) {
1623 let outcome = match &parsed.decision {
1624 ReviewDecision::Approve { .. }
1625 | ReviewDecision::RequestChanges { .. }
1626 | ReviewDecision::Skipped
1627 | ReviewDecision::Rerouted => "success",
1628 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
1629 };
1630 let error = match &parsed.decision {
1631 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
1632 _ => "",
1633 };
1634
1635 if outcome == "success" {
1636 crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
1639 .await;
1640 }
1641
1642 complete_review_run(
1643 store,
1644 parsed.run_id,
1645 Some(parsed.exit_code),
1646 &parsed.raw_output,
1647 &parsed.stderr,
1648 &parsed.text_for_review,
1649 outcome,
1650 error,
1651 parsed.token_usage,
1652 )
1653 .await;
1654
1655 if let Some(store_id) = ctx.store_id {
1656 if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
1657 let model = if ctx.review_model_str().is_empty() {
1658 "unknown"
1659 } else {
1660 ctx.review_model_str()
1661 };
1662 if let Err(e) = store
1663 .store_tokens(
1664 store_id,
1665 parsed.token_usage.input_tokens,
1666 parsed.token_usage.output_tokens,
1667 model,
1668 )
1669 .await
1670 {
1671 tracing::warn!(
1672 task_id = task.id.0,
1673 ?e,
1674 "failed to store review agent token usage"
1675 );
1676 }
1677 }
1678 }
1679}
1680
1681async fn restore_review_config_if_needed(
1682 task_id: &str,
1683 worktree_path: &std::path::Path,
1684 default_branch: &str,
1685) {
1686 let _ = Command::new("git")
1687 .args(["checkout", "HEAD", "--", ".orch.yml"])
1688 .current_dir(worktree_path)
1689 .status()
1690 .await;
1691
1692 let merge_base = Command::new("git")
1693 .args(["merge-base", "HEAD", default_branch])
1694 .current_dir(worktree_path)
1695 .output()
1696 .await
1697 .ok()
1698 .filter(|output| output.status.success())
1699 .and_then(|output| String::from_utf8(output.stdout).ok())
1700 .map(|s| s.trim().to_string())
1701 .unwrap_or_default();
1702
1703 if merge_base.is_empty() {
1704 return;
1705 }
1706
1707 let orch_yml_changed = Command::new("git")
1708 .args([
1709 "diff",
1710 "--name-only",
1711 &merge_base,
1712 "HEAD",
1713 "--",
1714 ".orch.yml",
1715 ])
1716 .current_dir(worktree_path)
1717 .output()
1718 .await
1719 .ok()
1720 .filter(|output| output.status.success())
1721 .map(|output| !output.stdout.is_empty())
1722 .unwrap_or(false);
1723
1724 if !orch_yml_changed {
1725 return;
1726 }
1727
1728 tracing::warn!(
1729 task_id,
1730 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
1731 );
1732 let restore_ok = Command::new("git")
1733 .args(["checkout", &merge_base, "--", ".orch.yml"])
1734 .current_dir(worktree_path)
1735 .status()
1736 .await
1737 .map(|status| status.success())
1738 .unwrap_or(false);
1739
1740 if !restore_ok {
1741 return;
1742 }
1743
1744 let staged = Command::new("git")
1745 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
1746 .current_dir(worktree_path)
1747 .output()
1748 .await
1749 .ok()
1750 .filter(|output| output.status.success())
1751 .map(|output| !output.stdout.is_empty())
1752 .unwrap_or(false);
1753
1754 if staged {
1755 let _ = Command::new("git")
1756 .args([
1757 "-c",
1758 "commit.gpgsign=false",
1759 "commit",
1760 "-m",
1761 "revert: restore .orch.yml (review agent must not modify project config)",
1762 ])
1763 .current_dir(worktree_path)
1764 .status()
1765 .await;
1766 }
1767}
1768
1769fn is_missing_path_error_message(err: &str) -> bool {
1770 err.to_ascii_lowercase()
1771 .contains("no such file or directory")
1772}
1773
1774async fn push_review_branch(
1775 task: &ExternalTask,
1776 repo: &str,
1777 ctx: &ReviewContext,
1778 store: &Arc<TaskStore>,
1779) -> Result<(), String> {
1780 let mut worktree_path = ctx.worktree_path.clone();
1781 let mut branch_name = ctx.branch_name.clone();
1782 let mut retried_missing_worktree = false;
1783
1784 let push_result = loop {
1785 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
1786 {
1787 Ok(ok) => break Ok(ok),
1788 Err(e)
1789 if !retried_missing_worktree
1790 && !worktree_path.exists()
1791 && is_missing_path_error_message(&e.to_string()) =>
1792 {
1793 retried_missing_worktree = true;
1794 tracing::warn!(
1795 task_id = task.id.0,
1796 worktree = %worktree_path.display(),
1797 branch = %branch_name,
1798 "review push failed because worktree disappeared; attempting one-time recovery"
1799 );
1800 match recover_missing_review_worktree(task, repo, store).await {
1801 Ok(recovered) => {
1802 worktree_path = recovered.work_dir;
1803 branch_name = recovered.branch;
1804 continue;
1805 }
1806 Err(recover_err) => {
1807 break Err(anyhow::anyhow!(
1808 "missing review worktree recovery failed after push error: {recover_err}"
1809 ));
1810 }
1811 }
1812 }
1813 Err(e) => break Err(e),
1814 }
1815 };
1816
1817 match push_result {
1818 Ok(_) => {
1819 store_log_activity(
1820 &Some(Arc::clone(store)),
1821 repo,
1822 &task.id.0,
1823 "push",
1824 None,
1825 None,
1826 Some(&ctx.review_agent),
1827 ctx.review_model.as_deref(),
1828 Some(&serde_json::json!({
1829 "status": "ok",
1830 "branch": branch_name.clone(),
1831 "default_branch": ctx.default_branch.clone(),
1832 "phase": "review",
1833 })),
1834 )
1835 .await;
1836 if ctx.had_prev_push_failure {
1837 if let Err(e) = store_set_result(
1838 &Some(Arc::clone(store)),
1839 repo,
1840 &task.id.0,
1841 &[
1842 ("last_error", serde_json::json!("")),
1843 ("push_failures", serde_json::json!(0)),
1844 ],
1845 )
1846 .await
1847 {
1848 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
1849 }
1850 }
1851 Ok(())
1852 }
1853 Err(e) => {
1854 store_log_activity(
1855 &Some(Arc::clone(store)),
1856 repo,
1857 &task.id.0,
1858 "push",
1859 None,
1860 None,
1861 Some(&ctx.review_agent),
1862 ctx.review_model.as_deref(),
1863 Some(&serde_json::json!({
1864 "status": "error",
1865 "branch": branch_name.clone(),
1866 "default_branch": ctx.default_branch.clone(),
1867 "phase": "review",
1868 "error": e.to_string(),
1869 })),
1870 )
1871 .await;
1872 tracing::error!(
1873 task_id = task.id.0,
1874 branch = %ctx.branch_name,
1875 error = %e,
1876 "review push failed"
1877 );
1878 store_set(
1879 &Some(Arc::clone(store)),
1880 repo,
1881 &task.id.0,
1882 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
1883 )
1884 .await;
1885 Err(e.to_string())
1886 }
1887 }
1888}
1889
1890fn append_review_issues(
1891 body: &mut String,
1892 issues: &[crate::engine::runner::response::ReviewIssue],
1893) {
1894 if issues.is_empty() {
1895 return;
1896 }
1897 body.push_str("\n**Issues Found:**\n");
1898 for issue in issues {
1899 body.push_str(&format!(
1900 "- `{}` line {}: {} [{}]\n",
1901 issue.file,
1902 issue
1903 .line
1904 .map(|line| line.to_string())
1905 .unwrap_or_else(|| "?".to_string()),
1906 issue.description,
1907 issue.severity
1908 ));
1909 }
1910}
1911
1912fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
1913 match decision {
1914 ReviewDecision::Approve { issues } => {
1915 let mut body = format!(
1916 "## Automated Review \u{2014} Approve\n\n{}",
1917 review_notes_for_comment
1918 );
1919 if !issues.is_empty() && !body.ends_with('\n') {
1920 body.push('\n');
1921 }
1922 append_review_issues(&mut body, issues);
1923 body
1924 }
1925 ReviewDecision::RequestChanges { notes, issues } => {
1926 let mut body = format!(
1927 "## Automated Review \u{2014} Changes Requested\n\n{}\n",
1928 notes
1929 );
1930 append_review_issues(&mut body, issues);
1931 body
1932 }
1933 _ => String::new(),
1934 }
1935}
1936
1937const REVIEW_STATUS_CONTEXT: &str = "orch/review";
1938
1939fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
1941 match decision {
1942 ReviewDecision::Approve { .. } => Some(("success", "Approved by automated review")),
1943 ReviewDecision::RequestChanges { .. } => {
1944 Some(("failure", "Automated review requested changes"))
1945 }
1946 _ => None,
1947 }
1948}
1949
1950async fn post_review_status(
1953 gh: &GhHttp,
1954 repo: &str,
1955 sha: Option<&str>,
1956 state: &str,
1957 description: &str,
1958) {
1959 let Some(sha) = sha else {
1960 return;
1961 };
1962 if let Err(e) = gh
1963 .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
1964 .await
1965 {
1966 tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
1967 }
1968}
1969
1970async fn post_review_comment(
1971 task: &ExternalTask,
1972 repo: &str,
1973 ctx: &ReviewContext,
1974 decision: &ReviewDecision,
1975 review_notes_for_comment: &str,
1976) -> anyhow::Result<()> {
1977 let gh = match GhHttp::new() {
1978 Ok(gh) => gh,
1979 Err(e) => {
1980 tracing::warn!(
1981 task_id = task.id.0,
1982 error = %e,
1983 "failed to create GH client for review comment"
1984 );
1985 return Ok(());
1986 }
1987 };
1988 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
1989 if ctx.same_agent_fallback && !pr_comment.is_empty() {
1990 pr_comment.push_str(
1991 "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
1992 );
1993 }
1994
1995 let head_sha = worktree_head_sha(&ctx.worktree_path)
1998 .await
1999 .or_else(|| ctx.head_sha.clone());
2000
2001 let app = review_app(repo).await;
2002 post_review_decision(
2003 app.as_deref(),
2004 &gh,
2005 repo,
2006 head_sha.as_deref(),
2007 decision,
2008 review_notes_for_comment,
2009 )
2010 .await;
2011
2012 if !pr_comment.is_empty() {
2013 let footer =
2014 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
2015 let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
2016 if let Err(e) = publish_review(
2017 app.as_deref(),
2018 &gh,
2019 repo,
2020 ctx.pr_number,
2021 head_sha.as_deref(),
2022 decision,
2023 &pr_comment_with_footer,
2024 )
2025 .await
2026 {
2027 tracing::warn!(
2028 task_id = task.id.0,
2029 pr_number = ctx.pr_number,
2030 error = %e,
2031 "failed to post automated review comment on PR"
2032 );
2033 }
2034 }
2035
2036 Ok(())
2037}
2038
2039async fn review_app(repo: &str) -> Option<Arc<crate::github::app::AppAuth>> {
2041 let mode = crate::review_mode::for_repo(repo);
2042 review_app_for(mode, crate::github::app::shared(), repo).await
2043}
2044
2045async fn review_app_for(
2046 mode: crate::review_mode::ReviewMode,
2047 app: Option<Arc<crate::github::app::AppAuth>>,
2048 repo: &str,
2049) -> Option<Arc<crate::github::app::AppAuth>> {
2050 if mode != crate::review_mode::ReviewMode::App {
2051 return None;
2052 }
2053
2054 let app = app?;
2055 match app.token_for_repo(repo).await {
2056 Ok(Some(_)) => Some(app),
2057 Ok(None) => {
2058 tracing::info!(
2059 repo,
2060 "review.mode is app but the App is not installed here, reviewing with a comment"
2061 );
2062 None
2063 }
2064 Err(e) => {
2065 tracing::warn!(repo, error = %e, "App installation lookup failed, reviewing with a comment");
2066 None
2067 }
2068 }
2069}
2070
2071async fn post_review_started(
2073 app: Option<&crate::github::app::AppAuth>,
2074 gh: &GhHttp,
2075 repo: &str,
2076 sha: Option<&str>,
2077) {
2078 use crate::github::app::CheckRunState;
2079 match (app, sha) {
2080 (Some(app), Some(sha)) => {
2081 if let Err(e) = app
2082 .post_check_run(repo, sha, CheckRunState::InProgress)
2083 .await
2084 {
2085 tracing::warn!(sha, error = %e, "failed to post the orch/review check run");
2086 }
2087 }
2088 _ => post_review_status(gh, repo, sha, "pending", "Automated review running").await,
2089 }
2090}
2091
2092async fn post_review_decision(
2093 app: Option<&crate::github::app::AppAuth>,
2094 gh: &GhHttp,
2095 repo: &str,
2096 sha: Option<&str>,
2097 decision: &ReviewDecision,
2098 notes: &str,
2099) {
2100 use crate::github::app::CheckRunState;
2101 let Some((state, description)) = review_status(decision) else {
2102 return;
2103 };
2104
2105 match (app, sha) {
2106 (Some(app), Some(sha)) => {
2107 let approved = state == "success";
2108 let completed = CheckRunState::Completed {
2109 approved,
2110 summary: if notes.is_empty() { description } else { notes },
2111 };
2112 if let Err(e) = app.post_check_run(repo, sha, completed).await {
2113 tracing::warn!(sha, error = %e, "failed to complete the orch/review check run");
2114 }
2115 }
2116 _ => post_review_status(gh, repo, sha, state, description).await,
2117 }
2118}
2119
2120async fn publish_review(
2124 app: Option<&crate::github::app::AppAuth>,
2125 gh: &GhHttp,
2126 repo: &str,
2127 pr_number: u64,
2128 head_sha: Option<&str>,
2129 decision: &ReviewDecision,
2130 comment: &str,
2131) -> anyhow::Result<()> {
2132 use crate::github::app::ReviewEvent;
2133 let event = match decision {
2134 ReviewDecision::Approve { .. } => Some(ReviewEvent::Approve),
2135 ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2136 _ => None,
2137 };
2138 if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2139 let inline = inline_comments(app, repo, pr_number, decision).await;
2140 if let Err(e) = app
2141 .submit_review(repo, pr_number, event, comment, sha, &inline)
2142 .await
2143 {
2144 tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, the comment stays the decision");
2145 }
2146 }
2147 gh.add_comment(repo, &pr_number.to_string(), comment).await
2148}
2149
2150async fn inline_comments(
2153 app: &crate::github::app::AppAuth,
2154 repo: &str,
2155 pr_number: u64,
2156 decision: &ReviewDecision,
2157) -> Vec<crate::github::app::InlineComment> {
2158 let ReviewDecision::RequestChanges { issues, .. } = decision else {
2159 return Vec::new();
2160 };
2161
2162 if issues.is_empty() {
2163 return Vec::new();
2164 }
2165
2166 match app.pull_request_hunks(repo, pr_number).await {
2167 Ok(hunks) => crate::github::app::split_inline(issues, &hunks).0,
2168 Err(e) => {
2169 tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2170 Vec::new()
2171 }
2172 }
2173}
2174
2175#[allow(clippy::too_many_arguments)]
2176async fn apply_review_decision(
2177 task: &ExternalTask,
2178 backend: &Arc<dyn ExternalBackend>,
2179 repo: &str,
2180 task_manager: &Arc<TaskManager>,
2181 store: &Arc<TaskStore>,
2182 ctx: &ReviewContext,
2183 parsed: &ParsedReview,
2184 router_config: &crate::engine::router::RouterConfig,
2185) -> anyhow::Result<ReviewDecision> {
2186 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2187
2188 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2189 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2190 }
2191
2192 post_review_comment(
2193 task,
2194 repo,
2195 ctx,
2196 &parsed.decision,
2197 &parsed.review_notes_for_comment,
2198 )
2199 .await?;
2200
2201 match &parsed.decision {
2202 ReviewDecision::Approve { .. } => {
2203 record_approved_sha(store, repo, &task.id.0, ctx.pr_number, &ctx.worktree_path).await;
2204 if let Err(e) = auto_merge_pr(
2209 task,
2210 &ctx.branch_name,
2211 backend,
2212 repo,
2213 &ctx.review_agent,
2214 ctx.review_model_str(),
2215 task_manager,
2216 store,
2217 )
2218 .await
2219 {
2220 let error_msg = e.to_string();
2221 if error_msg.contains("not yet computed") {
2222 tracing::warn!(
2223 task_id = task.id.0,
2224 pr_number = ctx.pr_number,
2225 branch = %ctx.branch_name,
2226 error = %e,
2227 "auto-merge deferred — PR mergeability not yet computed"
2228 );
2229 } else {
2230 tracing::error!(
2231 task_id = task.id.0,
2232 pr_number = ctx.pr_number,
2233 branch = %ctx.branch_name,
2234 error = %e,
2235 "auto-merge failed"
2236 );
2237 }
2238 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2239 }
2240 Ok(parsed.decision.clone())
2241 }
2242 ReviewDecision::RequestChanges { notes, issues } => {
2243 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2244 handle_review_changes(
2245 task,
2246 notes,
2247 issues,
2248 backend,
2249 repo,
2250 ctx.pr_number,
2251 &ctx.review_agent,
2252 ctx.review_model_str(),
2253 task_manager,
2254 store,
2255 router_config,
2256 )
2257 .await?;
2258 Ok(parsed.decision.clone())
2259 }
2260 _ => Ok(parsed.decision.clone()),
2261 }
2262}
2263
2264async fn recover_missing_review_worktree(
2265 task: &ExternalTask,
2266 repo: &str,
2267 store: &Arc<TaskStore>,
2268) -> anyhow::Result<worktree::WorktreeSetup> {
2269 let project_dir = crate::config::get_projects_with_paths()?
2270 .into_iter()
2271 .find(|(candidate_repo, _)| candidate_repo == repo)
2272 .map(|(_, project_dir)| project_dir)
2273 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2274
2275 tracing::warn!(
2276 task_id = task.id.0,
2277 repo,
2278 project_dir = %project_dir.display(),
2279 "review worktree missing, recreating via normal setup"
2280 );
2281
2282 let store_ref = Some(Arc::clone(store));
2283 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2284 .await
2285 .context("recreating missing review worktree")?;
2286
2287 if !wt.work_dir.exists() {
2288 anyhow::bail!(
2289 "recreated review worktree at {} for task {} is still missing",
2290 wt.work_dir.display(),
2291 task.id.0
2292 );
2293 }
2294
2295 Ok(wt)
2296}
2297
2298async fn count_ahead_commits(
2304 worktree_path: &std::path::Path,
2305 default_branch: &str,
2306) -> Result<u64, String> {
2307 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2308 format!(
2309 "worktree path contains non-UTF-8 characters: {:?}",
2310 worktree_path
2311 )
2312 })?;
2313
2314 let output = tokio::process::Command::new("git")
2315 .args([
2316 "-C",
2317 worktree_str,
2318 "rev-list",
2319 "--count",
2320 &format!("origin/{default_branch}..HEAD"),
2321 ])
2322 .output()
2323 .await
2324 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2325
2326 if !output.status.success() {
2327 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2328 return Err(format!(
2329 "git rev-list exited with {:?}: {}",
2330 output.status.code(),
2331 if stderr.is_empty() {
2332 String::from_utf8_lossy(&output.stdout).trim().to_string()
2333 } else {
2334 stderr
2335 }
2336 ));
2337 }
2338
2339 String::from_utf8_lossy(&output.stdout)
2340 .trim()
2341 .parse::<u64>()
2342 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2343}
2344
2345#[allow(clippy::too_many_arguments)]
2352async fn ensure_pr_exists(
2353 task: &ExternalTask,
2354 branch_name: &str,
2355 worktree_path: &std::path::Path,
2356 repo: &str,
2357 agent_summary: &str,
2358 store: &Arc<TaskStore>,
2359 task_manager: &Arc<TaskManager>,
2360 stored_pr_number: Option<u64>,
2361) -> anyhow::Result<EnsurePrResult> {
2362 let gh_check = GhHttp::new()?;
2363
2364 if let Some(n) = stored_pr_number {
2365 tracing::info!(
2366 task_id = task.id.0,
2367 pr_number = n,
2368 branch = %branch_name,
2369 "open PR found in store, proceeding with review"
2370 );
2371 return Ok(EnsurePrResult::Ready(n));
2372 }
2373
2374 match gh_check.get_pr_number(repo, branch_name).await {
2375 Ok(Some(n)) => {
2376 tracing::info!(
2377 task_id = task.id.0,
2378 pr_number = n,
2379 branch = %branch_name,
2380 "open PR found, proceeding with review"
2381 );
2382 Ok(EnsurePrResult::Ready(n))
2383 }
2384 Ok(None) => {
2385 let default_branch = worktree::detect_default_branch(worktree_path).await;
2387 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2388 Ok(count) => count > 0,
2389 Err(e) => {
2390 tracing::error!(
2391 task_id = task.id.0,
2392 branch = %branch_name,
2393 default_branch = %default_branch,
2394 err = %e,
2395 "could not determine if branch has commits ahead of default"
2396 );
2397 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2398 format!("git ancestry check failed: {e}"),
2399 )));
2400 }
2401 };
2402
2403 if has_commits {
2404 tracing::warn!(
2406 task_id = task.id.0,
2407 branch = %branch_name,
2408 "no open PR but branch has commits — attempting to create PR"
2409 );
2410 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2411 anyhow::anyhow!(
2412 "worktree path contains non-UTF-8 characters: {:?}",
2413 worktree_path
2414 )
2415 })?;
2416 let auth_env = runner::git_ops::build_git_auth_env().await;
2418
2419 let push_timeout = std::time::Duration::from_secs(120);
2424 match tokio::time::timeout(
2425 push_timeout,
2426 tokio::process::Command::new("git")
2427 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2428 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2429 .output(),
2430 )
2431 .await
2432 {
2433 Err(_elapsed) => {
2434 tracing::warn!(
2435 task_id = task.id.0,
2436 branch = %branch_name,
2437 timeout_secs = push_timeout.as_secs(),
2438 "review gate fallback push timed out"
2439 );
2440 }
2441 Ok(Ok(o)) => {
2442 if !o.status.success() {
2443 tracing::warn!(
2444 task_id = task.id.0,
2445 branch = %branch_name,
2446 exit_code = ?o.status.code(),
2447 stdout = %String::from_utf8_lossy(&o.stdout),
2448 stderr = %String::from_utf8_lossy(&o.stderr),
2449 "review gate fallback push failed"
2450 );
2451 } else {
2452 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2453 }
2454 }
2455 Ok(Err(e)) => {
2456 tracing::error!(
2457 task_id = task.id.0,
2458 branch = %branch_name,
2459 error = %e,
2460 "review gate fallback push command failed to run"
2461 );
2462 }
2463 }
2464
2465 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2466 let pr_body = format!(
2467 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2468 );
2469 let gh = GhHttp::new()?;
2470 match gh
2471 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2472 .await
2473 {
2474 Ok(url) => match parse_pr_number_from_url(&url) {
2475 Ok(pr_num) => {
2476 store_set_result(
2477 &Some(Arc::clone(store)),
2478 repo,
2479 &task.id.0,
2480 &[("pr_number", serde_json::json!(pr_num as i64))],
2481 )
2482 .await?;
2483 tracing::info!(
2484 task_id = task.id.0,
2485 branch = %branch_name,
2486 pr_url = %url,
2487 "created missing PR via GhHttp — continuing review"
2488 );
2489 Ok(EnsurePrResult::Ready(pr_num))
2490 }
2491 Err(e) => {
2492 tracing::warn!(
2493 task_id = task.id.0,
2494 branch = %branch_name,
2495 pr_url = %url,
2496 error = %e,
2497 "created missing PR via GhHttp, but failed to parse PR number"
2498 );
2499 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2500 format!("created missing PR, but could not parse PR number: {e}"),
2501 )))
2502 }
2503 },
2504 Err(e) => {
2505 let e_str = format!("{e}");
2506 if e_str.contains("already exists") {
2507 let backoff_delays = [2u64, 4u64];
2511 let mut found_pr: Option<u64> = None;
2512 for delay_secs in backoff_delays {
2513 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2514 .await;
2515 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2516 {
2517 found_pr = Some(n);
2518 break;
2519 }
2520 }
2521 if let Some(n) = found_pr {
2522 tracing::info!(
2523 task_id = task.id.0,
2524 pr_number = n,
2525 branch = %branch_name,
2526 "found existing PR after create_pr 422 — retrying review"
2527 );
2528 store_set_result(
2529 &Some(Arc::clone(store)),
2530 repo,
2531 &task.id.0,
2532 &[("pr_number", serde_json::json!(n as i64))],
2533 )
2534 .await?;
2535 return Ok(EnsurePrResult::Ready(n));
2536 }
2537 }
2538 tracing::warn!(
2539 task_id = task.id.0,
2540 branch = %branch_name,
2541 error = %e,
2542 "create_pr failed via GhHttp, falling back to CLI"
2543 );
2544 let pr_timeout = std::time::Duration::from_secs(120);
2547 let mut gh_cmd = tokio::process::Command::new("gh");
2548 gh_cmd.args([
2549 "pr",
2550 "create",
2551 "--repo",
2552 repo,
2553 "--head",
2554 branch_name,
2555 "--title",
2556 &task.title,
2557 "--body",
2558 &pr_body,
2559 ]);
2560 gh_cmd.current_dir(worktree_path);
2561 gh_cmd.kill_on_drop(true);
2562
2563 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2564 match pr_result {
2565 Ok(Ok(o)) if o.status.success() => {
2566 let stdout = String::from_utf8_lossy(&o.stdout);
2567 match parse_pr_number_from_url(stdout.trim()) {
2568 Ok(pr_num) => {
2569 store_set_result(
2570 &Some(Arc::clone(store)),
2571 repo,
2572 &task.id.0,
2573 &[("pr_number", serde_json::json!(pr_num as i64))],
2574 )
2575 .await?;
2576 tracing::info!(
2577 task_id = task.id.0,
2578 branch = %branch_name,
2579 "created missing PR via CLI — continuing review"
2580 );
2581 Ok(EnsurePrResult::Ready(pr_num))
2582 }
2583 Err(e) => {
2584 tracing::warn!(
2585 task_id = task.id.0,
2586 branch = %branch_name,
2587 stdout = %stdout,
2588 error = %e,
2589 "created missing PR via CLI, but failed to parse PR number"
2590 );
2591 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2592 format!("created missing PR via CLI, but could not parse PR number: {e}")
2593 )))
2594 }
2595 }
2596 }
2597 Ok(Ok(o)) => {
2598 let stderr = String::from_utf8_lossy(&o.stderr);
2599 if stderr.contains("already exists") {
2600 if let Some(pr_url) =
2601 stderr.lines().find(|l| l.trim().starts_with("https://"))
2602 {
2603 match parse_pr_number_from_url(pr_url.trim()) {
2604 Ok(pr_num) => {
2605 if let Err(e) = store_set_result(
2606 &Some(Arc::clone(store)),
2607 repo,
2608 &task.id.0,
2609 &[(
2610 "pr_number",
2611 serde_json::json!(pr_num as i64),
2612 )],
2613 )
2614 .await
2615 {
2616 tracing::warn!(
2617 task_id = task.id.0,
2618 pr_num,
2619 err = %e,
2620 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
2621 );
2622 }
2623 tracing::info!(
2624 task_id = task.id.0,
2625 branch = %branch_name,
2626 pr_url = %pr_url,
2627 "PR already exists (from CLI stderr) — retrying review"
2628 );
2629 return Ok(EnsurePrResult::Ready(pr_num));
2630 }
2631 Err(e) => {
2632 tracing::warn!(
2633 task_id = task.id.0,
2634 branch = %branch_name,
2635 pr_url = %pr_url,
2636 error = %e,
2637 "found existing PR URL in CLI stderr, but failed to parse PR number"
2638 );
2639 }
2641 }
2642 }
2643 }
2644 tracing::error!(
2645 task_id = task.id.0,
2646 branch = %branch_name,
2647 stderr = %stderr,
2648 "failed to create missing PR — work may be stuck"
2649 );
2650
2651 if crate::engine::runner::git_ops::is_transient_github_api_error(
2658 &stderr,
2659 ) {
2660 tracing::warn!(
2661 task_id = task.id.0,
2662 branch = %branch_name,
2663 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
2664 );
2665 return Ok(EnsurePrResult::EarlyReturn(
2666 ReviewDecision::Failed(format!(
2667 "transient github error creating PR: {stderr}"
2668 )),
2669 ));
2670 }
2671
2672 match store_increment(
2673 &Some(Arc::clone(store)),
2674 repo,
2675 &task.id.0,
2676 "pr_create_failures",
2677 )
2678 .await
2679 {
2680 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2681 return Ok(EnsurePrResult::EarlyReturn(
2682 ReviewDecision::Blocked(format!(
2683 "no PR, create failed {failures} times: {stderr}"
2684 )),
2685 ));
2686 }
2687 Ok(_) => {}
2688 Err(e) => {
2689 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2690 }
2691 }
2692 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2693 format!("no PR, create failed: {stderr}"),
2694 )))
2695 }
2696 Ok(Err(e)) => {
2697 tracing::error!(
2698 task_id = task.id.0,
2699 error = %e,
2700 "failed to run gh pr create"
2701 );
2702 let e_str = format!("{e}");
2703 if crate::engine::runner::git_ops::is_transient_github_api_error(
2704 &e_str,
2705 ) {
2706 tracing::warn!(
2707 task_id = task.id.0,
2708 branch = %branch_name,
2709 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
2710 );
2711 return Ok(EnsurePrResult::EarlyReturn(
2712 ReviewDecision::Failed(format!("transient gh error: {e}")),
2713 ));
2714 }
2715
2716 match store_increment(
2717 &Some(Arc::clone(store)),
2718 repo,
2719 &task.id.0,
2720 "pr_create_failures",
2721 )
2722 .await
2723 {
2724 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2725 return Ok(EnsurePrResult::EarlyReturn(
2726 ReviewDecision::Blocked(format!(
2727 "no PR, gh error {failures} times: {e}"
2728 )),
2729 ));
2730 }
2731 Ok(_) => {}
2732 Err(e) => {
2733 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2734 }
2735 }
2736 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2737 format!("no PR, gh error: {e}"),
2738 )))
2739 }
2740 Err(_elapsed) => {
2741 tracing::error!(
2743 task_id = task.id.0,
2744 timeout_secs = pr_timeout.as_secs(),
2745 "gh pr create CLI fallback timed out"
2746 );
2747 let e_str = format!(
2748 "gh pr create timed out after {}s",
2749 pr_timeout.as_secs()
2750 );
2751 if crate::engine::runner::git_ops::is_transient_github_api_error(
2752 &e_str,
2753 ) {
2754 tracing::warn!(
2755 task_id = task.id.0,
2756 branch = %branch_name,
2757 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
2758 );
2759 return Ok(EnsurePrResult::EarlyReturn(
2760 ReviewDecision::Failed(format!(
2761 "transient gh error: {e_str}"
2762 )),
2763 ));
2764 }
2765
2766 match store_increment(
2767 &Some(Arc::clone(store)),
2768 repo,
2769 &task.id.0,
2770 "pr_create_failures",
2771 )
2772 .await
2773 {
2774 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2775 return Ok(EnsurePrResult::EarlyReturn(
2776 ReviewDecision::Blocked(format!(
2777 "no PR, gh timeout {failures} times: {e_str}"
2778 )),
2779 ));
2780 }
2781 Ok(_) => {}
2782 Err(e) => {
2783 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2784 }
2785 }
2786 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2787 format!("no PR, gh timeout: {e_str}"),
2788 )))
2789 }
2790 }
2791 }
2792 }
2793 } else {
2794 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
2796 Ok(v) => v,
2797 Err(e) => {
2798 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
2799 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2800 format!("merge check failed: {e}"),
2801 )));
2802 }
2803 };
2804 if merged {
2805 tracing::info!(
2806 task_id = task.id.0,
2807 branch = %branch_name,
2808 "PR already merged, marking done"
2809 );
2810 if let Err(e) = task_manager
2811 .update_task_status(&task.id, Status::Done)
2812 .await
2813 {
2814 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2815 }
2816 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2817 }
2818
2819 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
2820 .await
2821 .map(|t| t.last_error)
2822 .unwrap_or_default();
2823 let reason = if !agent_summary.is_empty() {
2824 agent_summary.to_string()
2825 } else {
2826 last_error.clone()
2827 };
2828
2829 if last_error.contains("exceeded max attempts") {
2830 tracing::warn!(
2831 task_id = task.id.0,
2832 branch = %branch_name,
2833 "no PR and no commits after max attempts — marking blocked to stop loop"
2834 );
2835 let fields = [
2839 (
2840 "block_reason",
2841 serde_json::json!("max attempts exceeded — no PR or commits produced"),
2842 ),
2843 ("last_error", serde_json::json!(last_error)),
2844 ];
2845 if let Err(e) = task_manager
2846 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2847 .await
2848 {
2849 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2850 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2851 format!("failed to write block_reason: {e}"),
2852 )));
2853 }
2854 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2855 }
2856
2857 if last_error.contains("422") && last_error.contains("head") {
2858 tracing::info!(
2859 task_id = task.id.0,
2860 branch = %branch_name,
2861 "no PR — 422/head-invalid means work already merged, marking done"
2862 );
2863 if let Err(e) = task_manager
2864 .update_task_status(&task.id, Status::Done)
2865 .await
2866 {
2867 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
2868 }
2869 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2870 }
2871
2872 if last_error.contains("no fallback agents")
2873 || last_error.contains("all agents exhausted")
2874 {
2875 tracing::warn!(
2876 task_id = task.id.0,
2877 branch = %branch_name,
2878 last_error = %last_error,
2879 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
2880 );
2881 let fields = [
2885 (
2886 "block_reason",
2887 serde_json::json!("all agents exhausted — no PR or commits produced"),
2888 ),
2889 ("last_error", serde_json::json!(last_error)),
2890 ];
2891 if let Err(e) = task_manager
2892 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2893 .await
2894 {
2895 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2896 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2897 format!("failed to write block_reason: {e}"),
2898 )));
2899 }
2900 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2901 }
2902
2903 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
2910 .ok()
2911 .and_then(|s| s.parse().ok())
2912 .or_else(|| {
2913 config::get("workflow.max_attempts")
2914 .ok()
2915 .and_then(|s| s.parse().ok())
2916 })
2917 .unwrap_or(3);
2918
2919 tracing::warn!(
2920 task_id = task.id.0,
2921 branch = %branch_name,
2922 reason = %reason,
2923 "no PR and no commits — re-routing for retry"
2924 );
2925
2926 if should_skip_no_code_reroute_increment(&last_error) {
2930 tracing::warn!(
2931 task_id = task.id.0,
2932 last_error = %last_error,
2933 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
2934 );
2935 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2936 format!("transient github error: {last_error}"),
2937 )));
2938 }
2939
2940 let reroutes = match store_increment(
2942 &Some(Arc::clone(store)),
2943 repo,
2944 &task.id.0,
2945 "no_code_reroutes",
2946 )
2947 .await
2948 {
2949 Ok(v) => v,
2950 Err(e) => {
2951 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
2952 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2954 format!("transient store error: {e}"),
2955 )));
2956 }
2957 };
2958
2959 if reroutes as u32 >= max_reroutes {
2960 tracing::error!(
2961 task_id = task.id.0,
2962 reroutes,
2963 max_reroutes,
2964 "reached max reroute attempts for no-pr-result — blocking for human review"
2965 );
2966 let msg = format!(
2967 "no PR or code changes after {}/{} reroute attempts",
2968 reroutes, max_reroutes
2969 );
2970 let fields = [
2974 (
2975 "block_reason",
2976 serde_json::json!(format!(
2977 "max reroute attempts ({}) reached — no PR or code changes produced",
2978 max_reroutes
2979 )),
2980 ),
2981 ("last_error", serde_json::json!(msg)),
2982 ("agent", serde_json::json!(null)),
2983 ("model", serde_json::json!(null)),
2984 ];
2985 if let Err(e) = task_manager
2986 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2987 .await
2988 {
2989 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2990 }
2991 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
2992 } else if task.id.0.starts_with("internal:") {
2993 tracing::info!(
3000 task_id = task.id.0,
3001 "no PR and no commits for internal task — marking done"
3002 );
3003 if let Err(e) = task_manager
3004 .update_task_status(&task.id, Status::Done)
3005 .await
3006 {
3007 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3008 }
3009 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
3010 } else {
3011 if let Err(e) = store_set_result(
3016 &Some(Arc::clone(store)),
3017 repo,
3018 &task.id.0,
3019 &[
3020 ("agent", serde_json::json!(null)),
3021 ("model", serde_json::json!(null)),
3022 (
3023 "last_error",
3024 serde_json::json!("no PR or code changes produced"),
3025 ),
3026 ],
3027 )
3028 .await
3029 {
3030 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
3031 } else if let Err(e) =
3033 task_manager.update_task_status(&task.id, Status::New).await
3034 {
3035 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
3036 }
3037 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
3038 }
3039 }
3040 }
3041 Err(e) => {
3042 tracing::warn!(
3043 task_id = task.id.0,
3044 branch = %branch_name,
3045 error = %e,
3046 "failed to check PR status"
3047 );
3048 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3049 format!("PR check failed: {e}"),
3050 )))
3051 }
3052 }
3053}
3054
3055pub(crate) fn review_timeout_seconds() -> u64 {
3057 review_timeout_secs(
3058 crate::config::get("workflow.review_timeout_seconds")
3059 .ok()
3060 .and_then(|s| s.parse().ok()),
3061 )
3062}
3063
3064pub(crate) const REVIEW_RESULT_DELIVERY_SECS: u64 = 120;
3068
3069pub(crate) fn no_session_review_floor(configured: u64, review_timeout: u64) -> u64 {
3073 configured.max(review_timeout + REVIEW_RESULT_DELIVERY_SECS)
3074}
3075
3076pub(crate) async fn review_and_merge(
3082 task: &ExternalTask,
3083 backend: &Arc<dyn ExternalBackend>,
3084 tmux: &Arc<TmuxManager>,
3085 repo: &str,
3086 router: &Arc<RwLock<Router>>,
3087 task_manager: &Arc<TaskManager>,
3088 store: &Arc<TaskStore>,
3089) -> anyhow::Result<ReviewDecision> {
3090 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3091 ReviewPhase::Ready(ctx) => ctx,
3092 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3093 };
3094
3095 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3096 ReviewPhase::Ready(run) => run,
3097 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3098 };
3099
3100 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3101 ReviewPhase::Ready(parsed) => parsed,
3102 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3103 };
3104
3105 finalize_review_run(task, &ctx, &parsed, store).await;
3106
3107 let router_config = router.read().await.config.clone();
3108 apply_review_decision(
3109 task,
3110 backend,
3111 repo,
3112 task_manager,
3113 store,
3114 &ctx,
3115 &parsed,
3116 &router_config,
3117 )
3118 .await
3119}
3120
3121#[cfg(test)]
3122mod tests {
3123 use super::*;
3124
3125 #[test]
3126 fn a_review_gets_15_minutes_unless_configured_and_never_under_5() {
3127 assert_eq!(review_timeout_secs(None), 900);
3128 assert_eq!(review_timeout_secs(Some(600)), 600);
3129 assert_eq!(review_timeout_secs(Some(60)), 300);
3130 assert_eq!(review_timeout_secs(Some(3600)), 3600);
3131 }
3132
3133 use crate::backends::ExternalTask;
3134 use crate::engine::router::RouterConfig;
3135 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3136 use crate::store::{TaskStatus, TaskStore};
3137 use tempfile::TempDir;
3138
3139 #[test]
3144 fn no_session_review_floor_raises_a_limit_under_the_review_timeout() {
3145 assert_eq!(
3146 no_session_review_floor(600, 900),
3147 900 + REVIEW_RESULT_DELIVERY_SECS
3148 );
3149 }
3150
3151 #[test]
3152 fn no_session_review_floor_keeps_a_limit_over_the_review_timeout() {
3153 assert_eq!(no_session_review_floor(1800, 900), 1800);
3154 }
3155
3156 #[test]
3157 fn no_session_review_floor_clears_the_review_timeout_by_the_delivery_margin() {
3158 for (configured, review_timeout) in [(0, 0), (1800, 1800), (120, 7200)] {
3159 let floored = no_session_review_floor(configured, review_timeout);
3160 assert!(
3161 floored >= review_timeout + REVIEW_RESULT_DELIVERY_SECS,
3162 "{floored} must leave a finished review time to deliver its result"
3163 );
3164 }
3165 }
3166
3167 #[test]
3168 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3169 let err = runner::agents::AgentError::RateLimit {
3173 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3174 for this billing cycle. Your quota will be refreshed soon."
3175 .to_string(),
3176 };
3177 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3178 }
3179
3180 #[test]
3181 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3182 let err = runner::agents::AgentError::RateLimit {
3183 message: "429 Too Many Requests".to_string(),
3184 };
3185 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3186 }
3187
3188 #[test]
3189 fn outcome_for_agent_error_generic_auth_unaffected() {
3190 let err = runner::agents::AgentError::Auth {
3191 message: "401 Unauthorized".to_string(),
3192 };
3193 assert_eq!(outcome_for_agent_error(&err), "auth_error");
3194 }
3195
3196 #[test]
3199 fn parse_pr_number_standard_url() {
3200 assert_eq!(
3201 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3202 Ok(42)
3203 );
3204 }
3205
3206 #[test]
3207 fn parse_pr_number_trailing_slash() {
3208 assert_eq!(
3209 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3210 Ok(42)
3211 );
3212 }
3213
3214 #[test]
3215 fn parse_pr_number_with_query_string() {
3216 assert_eq!(
3217 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3218 Ok(42)
3219 );
3220 }
3221
3222 #[test]
3223 fn parse_pr_number_with_hash_fragment() {
3224 assert_eq!(
3225 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3226 Ok(42)
3227 );
3228 }
3229
3230 #[test]
3231 fn parse_pr_number_with_query_and_hash() {
3232 assert_eq!(
3233 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3234 Ok(42)
3235 );
3236 }
3237
3238 #[test]
3239 fn parse_pr_number_with_leading_whitespace() {
3240 assert_eq!(
3241 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
3242 Ok(42)
3243 );
3244 }
3245
3246 #[test]
3247 fn parse_pr_number_wrong_domain() {
3248 let url = "https://gitlab.com/owner/repo/merge_requests/42";
3249 let result = parse_pr_number_from_url(url);
3250 assert!(result.is_err());
3251 assert!(result
3252 .unwrap_err()
3253 .contains("does not start with 'https://github.com/'"));
3254 }
3255
3256 #[test]
3257 fn parse_pr_number_missing_pull_segment() {
3258 let url = "https://github.com/owner/repo/issues/42";
3260 let result = parse_pr_number_from_url(url);
3261 assert!(result.is_err());
3262 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3263 }
3264
3265 #[test]
3266 fn parse_pr_number_zero_is_rejected() {
3267 let url = "https://github.com/owner/repo/pull/0";
3268 let result = parse_pr_number_from_url(url);
3269 assert!(result.is_err());
3270 assert!(result.unwrap_err().contains("cannot be zero"));
3271 }
3272
3273 #[test]
3274 fn parse_pr_number_non_numeric_segment() {
3275 let url = "https://github.com/owner/repo/pull/abc";
3276 let result = parse_pr_number_from_url(url);
3277 assert!(result.is_err());
3278 assert!(result.unwrap_err().contains("not a valid positive integer"));
3279 }
3280
3281 #[test]
3282 fn parse_pr_number_empty_string() {
3283 let result = parse_pr_number_from_url("");
3284 assert!(result.is_err());
3285 assert!(result.unwrap_err().contains("empty"));
3286 }
3287
3288 #[test]
3289 fn parse_pr_number_http_not_https() {
3290 let url = "http://github.com/owner/repo/pull/42";
3292 let result = parse_pr_number_from_url(url);
3293 assert!(result.is_err());
3294 assert!(result
3295 .unwrap_err()
3296 .contains("does not start with 'https://github.com/'"));
3297 }
3298
3299 #[test]
3307 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3308 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3312 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3313 let err = runner::agents::AgentError::RateLimit {
3314 message: raw_fragment.to_string(),
3315 };
3316
3317 let stored = format_review_error("minimax", &err);
3318
3319 assert!(
3322 !stored.contains("{\"type\""),
3323 "stored error must not contain raw NDJSON envelope, got: {stored}"
3324 );
3325 assert!(
3326 !stored.contains("subtype"),
3327 "stored error must not contain raw api_retry fragment, got: {stored}"
3328 );
3329 assert!(
3330 !stored.contains("session_id"),
3331 "stored error must not contain session_id from provider payload, got: {stored}"
3332 );
3333 assert_eq!(
3334 stored,
3335 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3336 );
3337 }
3338
3339 #[test]
3340 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3341 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3346 let err = runner::agents::AgentError::RateLimit {
3347 message: raw_fragment.to_string(),
3348 };
3349
3350 let stored = format_review_error("glm", &err);
3351
3352 assert!(stored.starts_with("glm rate limit:"));
3353 assert!(
3354 !stored.contains("session_id"),
3355 "stored error must not leak session_id from truncated fragment, got: {stored}"
3356 );
3357 assert!(
3360 stored.len() <= 200,
3361 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3362 stored.len()
3363 );
3364 }
3365
3366 #[test]
3367 fn format_review_error_preserves_non_rate_limit_variants() {
3368 let err = runner::agents::AgentError::Auth {
3369 message: "invalid bearer token".to_string(),
3370 };
3371 assert_eq!(
3372 format_review_error("kimi", &err),
3373 "kimi auth error: invalid bearer token"
3374 );
3375
3376 let err = runner::agents::AgentError::Timeout {
3377 elapsed: std::time::Duration::from_secs(1801),
3378 };
3379 assert_eq!(
3380 format_review_error("kimi", &err),
3381 "kimi timed out after 1801s"
3382 );
3383 }
3384
3385 fn git(dir: &std::path::Path, args: &[&str]) {
3386 let mut cmd = std::process::Command::new("git");
3387 cmd.current_dir(dir);
3388 if args.first() == Some(&"commit") {
3390 cmd.args(["-c", "commit.gpgsign=false"]);
3391 }
3392 let status = cmd.args(args).status().unwrap();
3393 assert!(status.success(), "git {:?} failed", args);
3394 }
3395
3396 #[test]
3397 fn test_pull_request_review_requests_changes() {
3398 let review = PullRequestReview {
3399 review: GitHubReview {
3400 id: 1,
3401 user: GitHubUser {
3402 login: "reviewer".to_string(),
3403 },
3404 body: Some("Please fix".to_string()),
3405 state: "CHANGES_REQUESTED".to_string(),
3406 html_url: None,
3407 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3408 commit_id: None,
3409 },
3410 comments: vec![],
3411 };
3412 assert!(review.requests_changes());
3413 }
3414
3415 #[test]
3416 fn test_pull_request_review_does_not_request_changes() {
3417 let review = PullRequestReview {
3418 review: GitHubReview {
3419 id: 1,
3420 user: GitHubUser {
3421 login: "reviewer".to_string(),
3422 },
3423 body: Some("LGTM".to_string()),
3424 state: "APPROVED".to_string(),
3425 html_url: None,
3426 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3427 commit_id: None,
3428 },
3429 comments: vec![],
3430 };
3431 assert!(!review.requests_changes());
3432 }
3433
3434 #[test]
3435 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3436 let review = PullRequestReview {
3437 review: GitHubReview {
3438 id: 1,
3439 user: GitHubUser {
3440 login: "reviewer".to_string(),
3441 },
3442 body: None,
3443 state: "CHANGES_REQUESTED".to_string(),
3444 html_url: None,
3445 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3446 commit_id: None,
3447 },
3448 comments: vec![
3449 GitHubReviewComment {
3450 id: 1,
3451 user: GitHubUser {
3452 login: "reviewer".to_string(),
3453 },
3454 body: "Fix this issue".to_string(),
3455 path: "src/main.rs".to_string(),
3456 line: Some(10),
3457 original_line: Some(10),
3458 commit_id: "abc".to_string(),
3459 original_commit_id: "abc".to_string(),
3460 html_url: "url".to_string(),
3461 created_at: "2024-01-01T00:00:00Z".to_string(),
3462 updated_at: "2024-01-01T00:00:00Z".to_string(),
3463 in_reply_to_id: None,
3464 diff_hunk: Some(
3465 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3466 ),
3467 },
3468 GitHubReviewComment {
3469 id: 2,
3470 user: GitHubUser {
3471 login: "reviewer".to_string(),
3472 },
3473 body: "".to_string(),
3474 path: "src/lib.rs".to_string(),
3475 line: Some(20),
3476 original_line: Some(20),
3477 commit_id: "abc".to_string(),
3478 original_commit_id: "abc".to_string(),
3479 html_url: "url".to_string(),
3480 created_at: "2024-01-01T00:00:00Z".to_string(),
3481 updated_at: "2024-01-01T00:00:00Z".to_string(),
3482 in_reply_to_id: None,
3483 diff_hunk: None,
3484 },
3485 GitHubReviewComment {
3486 id: 3,
3487 user: GitHubUser {
3488 login: "reviewer".to_string(),
3489 },
3490 body: "Reply to this".to_string(),
3491 path: "src/lib.rs".to_string(),
3492 line: Some(30),
3493 original_line: Some(30),
3494 commit_id: "abc".to_string(),
3495 original_commit_id: "abc".to_string(),
3496 html_url: "url".to_string(),
3497 created_at: "2024-01-01T00:00:00Z".to_string(),
3498 updated_at: "2024-01-01T00:00:00Z".to_string(),
3499 in_reply_to_id: Some(1),
3500 diff_hunk: None,
3501 },
3502 ],
3503 };
3504 let actionable = review.actionable_comments();
3505 assert_eq!(actionable.len(), 1);
3506 assert_eq!(actionable[0].id, 1);
3507 assert_eq!(actionable[0].body, "Fix this issue");
3508 assert_eq!(actionable[0].path, "src/main.rs");
3509 assert_eq!(
3510 actionable[0].diff_hunk.as_ref().unwrap(),
3511 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3512 );
3513 }
3514
3515 #[test]
3516 fn test_router_config_model_for_complexity_returns_none_without_config() {
3517 let cfg = RouterConfig::default();
3520 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3521 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3522 assert!(cfg
3523 .model_for_complexity("unknown_agent_xyz", "simple", "")
3524 .is_none());
3525 }
3526
3527 #[test]
3528 fn review_status_maps_decisions() {
3529 assert_eq!(
3530 review_status(&ReviewDecision::Approve { issues: vec![] })
3531 .unwrap()
3532 .0,
3533 "success"
3534 );
3535 let changes = ReviewDecision::RequestChanges {
3536 notes: String::new(),
3537 issues: vec![],
3538 };
3539 assert_eq!(review_status(&changes).unwrap().0, "failure");
3540 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
3541 }
3542
3543 fn sample_review_issue(severity: &str) -> runner::response::ReviewIssue {
3544 runner::response::ReviewIssue {
3545 file: "src/engine/tick.rs".into(),
3546 line: Some(142),
3547 severity: severity.into(),
3548 description: "index out of bounds on the last element".into(),
3549 }
3550 }
3551
3552 fn sample_review_response(
3553 decision: &str,
3554 test_results: Option<&str>,
3555 issues: Vec<runner::response::ReviewIssue>,
3556 ) -> runner::response::ReviewResponse {
3557 runner::response::ReviewResponse {
3558 decision: decision.into(),
3559 notes: "looks fine".into(),
3560 test_results: test_results.map(str::to_string),
3561 issues,
3562 }
3563 }
3564
3565 #[test]
3566 fn approve_with_failing_tests_becomes_request_changes() {
3567 let decision =
3568 review_decision_from_response(sample_review_response("approve", Some("fail"), vec![]));
3569 match decision {
3570 ReviewDecision::RequestChanges { notes, issues } => {
3571 assert!(
3572 notes.contains("test_results: fail"),
3573 "notes must name the failing test run, got {notes:?}"
3574 );
3575 assert!(issues.is_empty());
3576 }
3577 other => panic!("expected RequestChanges, got {other:?}"),
3578 }
3579 }
3580
3581 #[test]
3582 fn approve_with_error_issue_becomes_request_changes() {
3583 let issue = sample_review_issue("error");
3584 let decision = review_decision_from_response(sample_review_response(
3585 "approve",
3586 Some("pass"),
3587 vec![issue.clone()],
3588 ));
3589 match decision {
3590 ReviewDecision::RequestChanges { issues, .. } => {
3591 assert_eq!(issues, vec![issue]);
3592 }
3593 other => panic!("expected RequestChanges, got {other:?}"),
3594 }
3595 }
3596
3597 #[test]
3598 fn approve_with_warning_issue_stays_approve_and_comment_lists_it() {
3599 let issue = sample_review_issue("warning");
3600 let decision = review_decision_from_response(sample_review_response(
3601 "approve",
3602 Some("pass"),
3603 vec![issue.clone()],
3604 ));
3605 match &decision {
3606 ReviewDecision::Approve { issues } => {
3607 assert_eq!(issues, &vec![issue.clone()]);
3608 }
3609 other => panic!("expected Approve, got {other:?}"),
3610 }
3611 let comment = build_pr_review_comment(&decision, "looks fine");
3612 assert!(
3613 comment.contains("Approve"),
3614 "approve header missing from {comment}"
3615 );
3616 assert!(
3617 comment.contains(&issue.description),
3618 "warning issue missing from {comment}"
3619 );
3620 assert!(
3621 comment.contains("warning"),
3622 "warning severity missing from {comment}"
3623 );
3624 }
3625
3626 #[test]
3627 fn review_started_comment_includes_agent_and_model() {
3628 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
3629 let expected = format!("🔍 Automated review started{}", expected_footer);
3630 assert_eq!(review_started_comment("kimi", "opus"), expected);
3631 }
3632
3633 fn no_code_review_task(id: &str) -> ExternalTask {
3636 ExternalTask {
3637 id: crate::backends::ExternalId(id.to_string()),
3638 title: "opencode discovery timeout".to_string(),
3639 body: "".to_string(),
3640 state: "open".to_string(),
3641 labels: vec![],
3642 author: "tester".to_string(),
3643 created_at: "2026-01-01T00:00:00Z".to_string(),
3644 updated_at: "2026-01-01T00:00:00Z".to_string(),
3645 url: "https://example.com/issues/1".to_string(),
3646 }
3647 }
3648
3649 #[tokio::test]
3653 async fn carried_approval_counts_as_this_rounds_decision() {
3654 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3655 let id = store
3656 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
3657 .await
3658 .unwrap();
3659 let task_id = format!("internal:{id}");
3660 store
3661 .update_status(id, crate::store::TaskStatus::InReview)
3662 .await
3663 .unwrap();
3664 assert_eq!(
3665 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3666 Some(false)
3667 );
3668
3669 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
3670
3671 assert_eq!(
3672 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3673 Some(true),
3674 "the stale in_review check must see this round as decided"
3675 );
3676 }
3677
3678 #[test]
3679 fn approval_covers_head_only_for_the_exact_commit() {
3680 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
3681 assert!(
3682 !approval_covers_head(Some("abc123"), Some("def456")),
3683 "a moved PR head must be reviewed"
3684 );
3685 assert!(
3686 !approval_covers_head(None, Some("abc123")),
3687 "no recorded approval means review"
3688 );
3689 assert!(
3690 !approval_covers_head(Some("abc123"), None),
3691 "an unreadable PR head must not skip the review"
3692 );
3693 assert!(!approval_covers_head(Some(""), Some("")));
3694 }
3695
3696 #[tokio::test]
3701 async fn approved_sha_round_trips_through_the_kv_store() {
3702 let temp = TempDir::new().unwrap();
3703 let wt = temp.path();
3704 git(wt, &["init", "-b", "main"]);
3705 git(wt, &["config", "user.name", "Test"]);
3706 git(wt, &["config", "user.email", "test@test.com"]);
3707 std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
3708 git(wt, &["add", "."]);
3709 git(wt, &["commit", "-m", "apply the fix"]);
3710
3711 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3712 let repo = "owner/repo";
3713 let task_id = "3783";
3714
3715 record_approved_sha(&store, repo, task_id, 77, wt).await;
3716 let recorded = store
3717 .kv_get(&approved_sha_key(repo, task_id, 77))
3718 .await
3719 .unwrap()
3720 .expect("approval must be recorded");
3721 assert_eq!(
3722 recorded,
3723 worktree_head_sha(wt).await.unwrap(),
3724 "the recorded sha must be the commit that was pushed to the PR"
3725 );
3726 assert!(
3727 store
3728 .kv_get(&approved_sha_key(repo, task_id, 78))
3729 .await
3730 .unwrap()
3731 .is_none(),
3732 "an approval must not leak to another PR"
3733 );
3734
3735 clear_approved_sha(&store, repo, task_id, 77).await;
3736 assert!(
3737 store
3738 .kv_get(&approved_sha_key(repo, task_id, 77))
3739 .await
3740 .unwrap()
3741 .is_none(),
3742 "requesting changes must drop the approval"
3743 );
3744 }
3745
3746 #[tokio::test]
3747 async fn build_review_context_reroutes_external_task_with_no_code() {
3748 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3749 let repo = "owner/repo";
3750 let ext = no_code_review_task("3617");
3751 store.ensure_external_task(repo, &ext).await.unwrap();
3752
3753 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3754 let task_manager = Arc::new(TaskManager::with_store(
3755 backend,
3756 Arc::clone(&store),
3757 repo.to_string(),
3758 ));
3759 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3760
3761 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3762 .await
3763 .unwrap();
3764
3765 assert!(
3766 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
3767 "external task with no worktree/branch/pr must reroute, not skip-to-done"
3768 );
3769
3770 let stored = store
3771 .get_by_external_id(repo, &ext.id.0)
3772 .await
3773 .unwrap()
3774 .expect("task must still exist");
3775 assert_eq!(stored.status, TaskStatus::New);
3776 assert!(stored.agent.is_none());
3777 assert!(stored.model.is_none());
3778 }
3779
3780 #[tokio::test]
3781 async fn build_review_context_blocks_external_task_after_max_reroutes() {
3782 let temp_home = TempDir::new().unwrap();
3785 let orch_dir = temp_home.path().join(".orch");
3786 std::fs::create_dir_all(&orch_dir).unwrap();
3787 let old_orch_home = std::env::var_os("ORCH_HOME");
3788 std::env::set_var("ORCH_HOME", &orch_dir);
3789
3790 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3791 let repo = "owner/repo";
3792 let ext = no_code_review_task("3618");
3793 store.ensure_external_task(repo, &ext).await.unwrap();
3794
3795 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3796 let task_manager = Arc::new(TaskManager::with_store(
3797 backend,
3798 Arc::clone(&store),
3799 repo.to_string(),
3800 ));
3801 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3802
3803 for _ in 0..3 {
3805 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3806 .await
3807 .unwrap();
3808 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
3809 break;
3810 }
3811 }
3812
3813 let stored = store
3814 .get_by_external_id(repo, &ext.id.0)
3815 .await
3816 .unwrap()
3817 .expect("task must still exist");
3818 assert_eq!(stored.status, TaskStatus::Blocked);
3819
3820 if let Some(old) = old_orch_home {
3821 std::env::set_var("ORCH_HOME", old);
3822 } else {
3823 std::env::remove_var("ORCH_HOME");
3824 }
3825 }
3826
3827 #[tokio::test]
3828 async fn build_review_context_marks_internal_task_done_with_no_code() {
3829 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3830 let repo = "owner/repo";
3831 let internal_id = store
3832 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
3833 .await
3834 .unwrap();
3835 let ext_id = format!("internal:{internal_id}");
3836 let ext = no_code_review_task(&ext_id);
3837
3838 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3839 let task_manager = Arc::new(TaskManager::with_store(
3840 backend,
3841 Arc::clone(&store),
3842 repo.to_string(),
3843 ));
3844 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3845
3846 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3847 .await
3848 .unwrap();
3849
3850 assert!(matches!(
3851 phase,
3852 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
3853 ));
3854 let stored = store.get(internal_id).await.unwrap();
3855 assert_eq!(stored.status, TaskStatus::Done);
3856 }
3857
3858 #[test]
3859 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
3860 let last_error =
3861 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
3862 assert!(!should_skip_no_code_reroute_increment(last_error));
3863 }
3864
3865 #[test]
3874 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
3875 let padding = "x".repeat(10_000);
3876 let text_for_review = format!(
3877 "{padding}\n+| codex | gpt-5.4 | failed | 1 |\n+| codex | gpt-5.5 | failed / aborted / (empty) | 1 each |\n+| kimi | opus | rate_limit | 1 |\n{padding}"
3878 );
3879
3880 let scan_head = runner::agents::patterns::safe_head(
3881 &text_for_review,
3882 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
3883 );
3884 let scan_tail = runner::agents::patterns::safe_tail(
3885 &text_for_review,
3886 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3887 );
3888
3889 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
3890 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
3891 }
3892
3893 #[test]
3894 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
3895 let padding = "x".repeat(10_000);
3896 let text_for_review =
3897 format!("{padding}\nError: rate limit exceeded, please try again later");
3898
3899 let scan_tail = runner::agents::patterns::safe_tail(
3900 &text_for_review,
3901 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3902 );
3903
3904 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
3905 }
3906
3907 #[tokio::test]
3908 async fn recover_missing_review_worktree_recreates_worktree() {
3909 let temp_home = TempDir::new().unwrap();
3910 let orch_dir = temp_home.path().join(".orch");
3911 std::fs::create_dir_all(&orch_dir).unwrap();
3912 let old_orch_home = std::env::var_os("ORCH_HOME");
3913 std::env::set_var("ORCH_HOME", &orch_dir);
3914
3915 let project_dir = temp_home.path().join("project");
3916 std::fs::create_dir_all(&project_dir).unwrap();
3917 git(&project_dir, &["init", "-b", "main"]);
3918 git(&project_dir, &["config", "user.name", "Test User"]);
3919 git(&project_dir, &["config", "user.email", "test@example.com"]);
3920 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
3921 git(&project_dir, &["add", "README.md"]);
3922 git(&project_dir, &["commit", "-m", "init"]);
3923
3924 std::fs::write(
3925 orch_dir.join("config.yml"),
3926 format!("projects:\n - {}\n", project_dir.display()),
3927 )
3928 .unwrap();
3929 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
3930
3931 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3932 let repo = "owner/repo";
3933 store
3934 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
3935 .await
3936 .unwrap();
3937 let ext = ExternalTask {
3938 id: crate::backends::ExternalId("1".to_string()),
3939 title: "Fix review recovery".to_string(),
3940 body: "".to_string(),
3941 state: "open".to_string(),
3942 labels: vec![],
3943 author: "tester".to_string(),
3944 created_at: "2026-01-01T00:00:00Z".to_string(),
3945 updated_at: "2026-01-01T00:00:00Z".to_string(),
3946 url: "https://example.com/issues/1".to_string(),
3947 };
3948 store.ensure_external_task(repo, &ext).await.unwrap();
3949
3950 let recovered = recover_missing_review_worktree(&ext, repo, &store)
3951 .await
3952 .unwrap();
3953
3954 assert!(recovered.work_dir.exists());
3955 assert!(recovered.branch.starts_with("gh-issue-1-"));
3956
3957 if let Some(old) = old_orch_home {
3958 std::env::set_var("ORCH_HOME", old);
3959 } else {
3960 std::env::remove_var("ORCH_HOME");
3961 }
3962 }
3963
3964 #[tokio::test]
3967 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
3968 let temp = TempDir::new().unwrap();
3969 let dir = temp.path();
3970
3971 let bare = temp.path().join("bare.git");
3973 std::fs::create_dir_all(&bare).unwrap();
3974 std::process::Command::new("git")
3975 .args(["init", "--bare"])
3976 .current_dir(&bare)
3977 .output()
3978 .unwrap();
3979
3980 git(dir, &["init", "-b", "main"]);
3981 git(dir, &["config", "user.name", "Test"]);
3982 git(dir, &["config", "user.email", "test@test.com"]);
3983 std::fs::write(dir.join("f"), "a").unwrap();
3984 git(dir, &["add", "f"]);
3985 git(dir, &["commit", "-m", "init"]);
3986 std::process::Command::new("git")
3987 .args(["remote", "add", "origin", bare.to_str().unwrap()])
3988 .current_dir(dir)
3989 .output()
3990 .unwrap();
3991 std::process::Command::new("git")
3992 .args(["push", "origin", "main"])
3993 .current_dir(dir)
3994 .output()
3995 .unwrap();
3996
3997 let result = count_ahead_commits(dir, "main").await;
3999 assert!(
4000 result.is_ok(),
4001 "expected Ok when origin/main exists, got {result:?}"
4002 );
4003 assert_eq!(
4004 result.unwrap(),
4005 0,
4006 "expected 0 commits ahead when branch matches origin/main"
4007 );
4008 }
4009
4010 #[tokio::test]
4011 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
4012 let temp = TempDir::new().unwrap();
4013 let dir = temp.path();
4014 git(dir, &["init", "-b", "main"]);
4015 git(dir, &["config", "user.name", "Test"]);
4016 git(dir, &["config", "user.email", "test@test.com"]);
4017 std::fs::write(dir.join("f"), "a").unwrap();
4018 git(dir, &["add", "f"]);
4019 git(dir, &["commit", "-m", "init"]);
4020 let result = count_ahead_commits(dir, "main").await;
4022 assert!(
4023 result.is_err(),
4024 "expected Err when origin/main does not exist, got {result:?}"
4025 );
4026 let err = result.unwrap_err();
4027 assert!(
4028 err.contains("git rev-list exited"),
4029 "error should mention exit failure, got: {err}"
4030 );
4031 }
4032
4033 #[tokio::test]
4034 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
4035 let temp = TempDir::new().unwrap();
4037 let dir = temp.path();
4038 let result = count_ahead_commits(dir, "main").await;
4040 assert!(
4041 result.is_err(),
4042 "expected Err when directory is not a git repo, got {result:?}"
4043 );
4044 }
4045
4046 #[serial_test::serial(cooldown_state)]
4050 #[tokio::test]
4051 async fn review_invalid_response_applies_short_model_cooldown() {
4052 crate::engine::cooldown::reset_global_state().await;
4053 let agent = "opencode-3314-review-invalid";
4054 let model = "opencode/north-mini-code-free";
4055 let key = format!("{agent}:{model}");
4056
4057 assert!(
4058 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4059 "model should not be in cooldown before record_review_agent_failure"
4060 );
4061
4062 let err = runner::agents::AgentError::InvalidResponse {
4063 raw: "empty review output".to_string(),
4064 };
4065 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
4066
4067 let now = chrono::Utc::now().timestamp();
4068 let until = crate::engine::cooldown::cooldown_until(&key)
4069 .expect("review InvalidResponse should set model cooldown");
4070 let remaining = until.saturating_sub(now);
4071 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4072 assert!(
4073 (base - 30..=base).contains(&remaining),
4074 "first unusable review output must cool the model for 5 minutes, got {remaining}s"
4075 );
4076 }
4077
4078 #[serial_test::serial(cooldown_state)]
4080 #[tokio::test]
4081 async fn review_output_cooldown_caps_at_fifteen_minutes() {
4082 crate::engine::cooldown::reset_global_state().await;
4083 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4084 crate::engine::cooldown::init_cooldown_store(store).await;
4085 let agent = "opencode-review-output-cap";
4086 let model = "opencode/north-mini-code-free";
4087 let key = format!("{agent}:{model}");
4088 let base = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS;
4089 let max = crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_MAX_SECS;
4090
4091 for (i, expected) in [base, max, max].into_iter().enumerate() {
4092 let err = runner::agents::AgentError::InvalidResponse {
4093 raw: format!("failed to parse review response {i}"),
4094 };
4095 record_review_agent_failure("test-review-output-cap", agent, Some(model), &err).await;
4096 let now = chrono::Utc::now().timestamp();
4097 let remaining = crate::engine::cooldown::cooldown_until(&key)
4098 .expect("unusable review output should set model cooldown")
4099 .saturating_sub(now);
4100 assert!(
4101 (expected - 30..=expected).contains(&remaining),
4102 "failure {} should cool the model for {expected}s, got {remaining}s",
4103 i + 1
4104 );
4105 }
4106 }
4107
4108 #[serial_test::serial(cooldown_state)]
4114 #[tokio::test]
4115 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
4116 crate::engine::cooldown::reset_global_state().await;
4117 let agent = "kimi-3551-review-inline";
4118 let model = "opus";
4119 let key = format!("{agent}:{model}");
4120
4121 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
4123 You've reached your usage limit for this billing cycle. Your quota will be \
4124 refreshed next cycle.";
4125
4126 let scan_tail = runner::agents::patterns::safe_tail(
4127 text_for_review,
4128 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4129 );
4130 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
4131 .expect("billing-cycle message must still be caught by the rate-limit scan");
4132
4133 assert_eq!(
4134 outcome_for_agent_error(&err),
4135 "billing_cycle_exhausted",
4136 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
4137 );
4138
4139 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
4140
4141 let now = chrono::Utc::now().timestamp();
4142 let until = crate::engine::cooldown::cooldown_until(&key)
4143 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
4144 let remaining = until.saturating_sub(now);
4145 assert!(
4150 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4151 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
4152 model cooldown, got {remaining}s — may have used the generic short agent \
4153 cooldown instead"
4154 );
4155 }
4156
4157 fn test_review_task(id: &str) -> ExternalTask {
4160 ExternalTask {
4161 id: crate::backends::ExternalId(id.to_string()),
4162 title: "Test review task".to_string(),
4163 body: "".to_string(),
4164 state: "open".to_string(),
4165 labels: vec![],
4166 author: "tester".to_string(),
4167 created_at: "2026-01-01T00:00:00Z".to_string(),
4168 updated_at: "2026-01-01T00:00:00Z".to_string(),
4169 url: "https://example.com/issues/1".to_string(),
4170 }
4171 }
4172
4173 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
4174 ReviewContext {
4175 store_id: None,
4176 had_prev_push_failure: false,
4177 review_attempt: 1,
4178 worktree_path: work_dir.to_path_buf(),
4179 branch_name: "gh-issue-1-test".to_string(),
4180 pr_number: 1,
4181 head_sha: None,
4182 default_branch: "main".to_string(),
4183 review_agent: agent.to_string(),
4184 review_model: Some(model.to_string()),
4185 review_task_id: "review-1".to_string(),
4186 review_attempt_dir: work_dir.to_path_buf(),
4187 output_file: work_dir.join("output.txt"),
4188 invocation: runner::agent::AgentInvocation {
4189 agent: agent.to_string(),
4190 model: Some(model.to_string()),
4191 work_dir: work_dir.to_path_buf(),
4192 system_prompt: String::new(),
4193 agent_message: String::new(),
4194 task_id: "review-1".to_string(),
4195 disallowed_tools: vec![],
4196 git_author_name: "Test".to_string(),
4197 git_author_email: "test@example.com".to_string(),
4198 output_file: work_dir.join("output.txt"),
4199 timeout_seconds: 0,
4200 repo: "owner/repo".to_string(),
4201 attempt: 1,
4202 sandbox: false,
4203 main_project_dir: work_dir.to_path_buf(),
4204 branch: String::new(),
4205 },
4206 same_agent_fallback: false,
4207 }
4208 }
4209
4210 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4211 ParsedReview {
4212 run_id: None,
4213 exit_code: 0,
4214 stderr: String::new(),
4215 raw_output: String::new(),
4216 text_for_review: String::new(),
4217 token_usage: RunTokenUsage::default(),
4218 review_notes_for_comment: String::new(),
4219 decision,
4220 }
4221 }
4222
4223 fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
4224 std::fs::write(dir.join("output.txt"), stdout).unwrap();
4225 std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4226 std::fs::write(dir.join("stderr.txt"), "").unwrap();
4227 }
4228
4229 #[serial_test::serial(cooldown_state)]
4233 #[tokio::test]
4234 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4235 crate::engine::cooldown::reset_global_state().await;
4236 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4237 let work_dir = TempDir::new().unwrap();
4238 let agent = "kimi";
4239 let model = "opus-3734-mid-thought";
4240 let ctx = test_review_context(agent, model, work_dir.path());
4241 let redraw = "^D\u{0008}".repeat(20);
4242 let ndjson = format!(
4243 "{}\n{}\n{}\n{}\n{}",
4244 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
4245 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"CI passes. Now verifying callers and the lease logic edge cases:"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4246 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4247 "\x1b[?25h",
4248 redraw,
4249 );
4250 write_review_output(work_dir.path(), &ndjson, 0);
4251
4252 let task = test_review_task("3734");
4253 let phase = parse_review_output(
4254 &task,
4255 "owner/repo",
4256 &ctx,
4257 ReviewRun { run_id: None },
4258 &store,
4259 )
4260 .await;
4261
4262 match phase {
4263 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4264 assert!(
4265 reason.contains("incomplete:"),
4266 "mid-thought review must be stored as incomplete, got {reason}"
4267 );
4268 }
4269 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4270 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4271 }
4272 assert!(
4273 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4274 "mid-thought incomplete must not apply persistent model cooldown"
4275 );
4276 }
4277
4278 #[serial_test::serial(cooldown_state)]
4281 #[tokio::test]
4282 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
4283 crate::engine::cooldown::reset_global_state().await;
4284 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4285 let work_dir = TempDir::new().unwrap();
4286 let agent = "kimi";
4287 let model = "opus-3734-tool-use";
4288 let ctx = test_review_context(agent, model, work_dir.path());
4289 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
4290 write_review_output(work_dir.path(), ndjson, 0);
4291
4292 let task = test_review_task("3734-tool");
4293 let phase = parse_review_output(
4294 &task,
4295 "owner/repo",
4296 &ctx,
4297 ReviewRun { run_id: None },
4298 &store,
4299 )
4300 .await;
4301
4302 match phase {
4303 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4304 assert!(
4305 reason.contains("incomplete:"),
4306 "tool_use without envelope must be incomplete, got {reason}"
4307 );
4308 }
4309 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4310 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4311 }
4312 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4313 }
4314
4315 #[serial_test::serial(cooldown_state)]
4318 #[tokio::test]
4319 async fn parse_review_output_unparseable_envelope_applies_short_cooldown() {
4320 crate::engine::cooldown::reset_global_state().await;
4321 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4322 let work_dir = TempDir::new().unwrap();
4323 let agent = "kimi";
4324 let model = "opus-3734-format";
4325 let ctx = test_review_context(agent, model, work_dir.path());
4326 let ndjson = concat!(
4327 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
4328 "\n",
4329 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
4330 );
4331 write_review_output(work_dir.path(), ndjson, 0);
4332
4333 let task = test_review_task("3734-format");
4334 let phase = parse_review_output(
4335 &task,
4336 "owner/repo",
4337 &ctx,
4338 ReviewRun { run_id: None },
4339 &store,
4340 )
4341 .await;
4342
4343 match phase {
4344 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4345 assert!(
4346 reason.contains("parse error"),
4347 "finished unparseable envelope must stay a parse error, got {reason}"
4348 );
4349 }
4350 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4351 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
4352 }
4353 let now = chrono::Utc::now().timestamp();
4354 let remaining = crate::engine::cooldown::cooldown_until(&format!("{agent}:{model}"))
4355 .expect("finished format failure must apply a model cooldown")
4356 .saturating_sub(now);
4357 assert!(
4358 remaining <= crate::engine::cooldown::TRANSIENT_MODEL_BACKOFF_BASE_SECS,
4359 "finished format failure must apply the 5 minute cooldown, got {remaining}s"
4360 );
4361 }
4362
4363 #[serial_test::serial(cooldown_state)]
4366 #[tokio::test]
4367 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
4368 crate::engine::cooldown::reset_global_state().await;
4369 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4370 let work_dir = TempDir::new().unwrap();
4371 let agent = "kimi";
4372 let model = "opus-3734-assistant";
4373 let ctx = test_review_context(agent, model, work_dir.path());
4374 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
4375 write_review_output(work_dir.path(), ndjson, 0);
4376
4377 let task = test_review_task("3734-asst");
4378 let phase = parse_review_output(
4379 &task,
4380 "owner/repo",
4381 &ctx,
4382 ReviewRun { run_id: None },
4383 &store,
4384 )
4385 .await;
4386
4387 match phase {
4388 ReviewPhase::Ready(parsed) => {
4389 assert!(matches!(parsed.decision, ReviewDecision::Approve { .. }));
4390 }
4391 ReviewPhase::EarlyReturn(_) => {
4392 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
4393 }
4394 }
4395 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4396 }
4397
4398 #[serial_test::serial(cooldown_state)]
4401 #[tokio::test]
4402 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
4403 crate::engine::cooldown::reset_global_state().await;
4404 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4405 let work_dir = TempDir::new().unwrap();
4406 let agent = "kimi";
4407 let model = "opus-3734-done";
4408 let ctx = test_review_context(agent, model, work_dir.path());
4409 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
4410 write_review_output(work_dir.path(), ndjson, 0);
4411
4412 let task = test_review_task("3734-done");
4413 let phase = parse_review_output(
4414 &task,
4415 "owner/repo",
4416 &ctx,
4417 ReviewRun { run_id: None },
4418 &store,
4419 )
4420 .await;
4421
4422 match phase {
4423 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4424 assert!(
4425 reason.contains("parse error"),
4426 "unfinished prose without a decision must not approve, got {reason}"
4427 );
4428 }
4429 ReviewPhase::Ready(_) => {
4430 panic!("task-path status=done synthesis must not become a review approval")
4431 }
4432 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4433 }
4434 }
4435
4436 #[serial_test::serial(cooldown_state)]
4442 #[tokio::test]
4443 async fn finalize_review_run_success_resets_generic_model_failure_count() {
4444 crate::engine::cooldown::reset_global_state().await;
4445 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4446 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4447
4448 let agent = "test-3478-agent";
4449 let model = "test-3478-model";
4450 let failure_key = format!("failure_count:{agent}:{model}");
4451
4452 let err = runner::agents::AgentError::AgentFailed {
4456 message: "agent reported a generic failure".to_string(),
4457 };
4458 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
4459 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4460 assert_eq!(
4461 store.kv_get(&failure_key).await.unwrap().as_deref(),
4462 Some("1"),
4463 "sanity check: failure should have incremented the persisted count"
4464 );
4465
4466 let work_dir = TempDir::new().unwrap();
4467 let ctx = test_review_context(agent, model, work_dir.path());
4468 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4469 let task = test_review_task("1");
4470
4471 finalize_review_run(&task, &ctx, &parsed, &store).await;
4472 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4473
4474 assert_eq!(
4475 store.kv_get(&failure_key).await.unwrap().as_deref(),
4476 Some("0"),
4477 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
4478 );
4479 }
4480
4481 #[serial_test::serial(cooldown_state)]
4487 #[tokio::test]
4488 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
4489 crate::engine::cooldown::reset_global_state().await;
4490 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4491 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4492
4493 let agent = "test-3478-interleave-agent";
4494 let model = "test-3478-interleave-model";
4495 let cooldown_key = format!("{agent}:{model}");
4496
4497 let work_dir = TempDir::new().unwrap();
4498 let ctx = test_review_context(agent, model, work_dir.path());
4499 let task = test_review_task("2");
4500
4501 for i in 0..4 {
4502 let err = runner::agents::AgentError::AgentFailed {
4503 message: format!("failure {i}"),
4504 };
4505 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
4506
4507 let now = chrono::Utc::now().timestamp();
4508 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4509 .expect("failure should set cooldown");
4510 let remaining = until.saturating_sub(now);
4511
4512 assert!(
4513 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
4514 "failure #{} must stay at base backoff since the prior success reset the \
4515 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
4516 here means review successes are not resetting failure_count",
4517 i + 1,
4518 crate::engine::cooldown::BACKOFF_BASE_SECS
4519 );
4520
4521 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4525 finalize_review_run(&task, &ctx, &parsed, &store).await;
4526 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4527 }
4528 }
4529
4530 #[serial_test::serial(cooldown_state)]
4536 #[tokio::test]
4537 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
4538 crate::engine::cooldown::reset_global_state().await;
4539 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4540 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4541
4542 let agent = "test-3571-agent";
4543 let model = "test-3571-model";
4544 let cooldown_key = format!("{agent}:{model}");
4545
4546 let work_dir = TempDir::new().unwrap();
4547 let ctx = test_review_context(agent, model, work_dir.path());
4548 let task = test_review_task("3");
4549
4550 let err = runner::agents::AgentError::ModelUnavailable {
4552 message: "model not found".to_string(),
4553 model: model.to_string(),
4554 };
4555 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
4556 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4557
4558 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4561 finalize_review_run(&task, &ctx, &parsed, &store).await;
4562 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4563
4564 let err2 = runner::agents::AgentError::ModelUnavailable {
4567 message: "model not found again".to_string(),
4568 model: model.to_string(),
4569 };
4570 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
4571 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4572
4573 let now = chrono::Utc::now().timestamp();
4574 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4575 .expect("second failure should set cooldown");
4576 let remaining = until.saturating_sub(now);
4577
4578 assert!(
4579 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
4580 "second persistent model failure should escalate past the 4h base despite an \
4581 intervening success (issue #3571); got {remaining}s, base is {}s",
4582 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
4583 );
4584 }
4585
4586 fn app_for_test(server: &wiremock::MockServer) -> Arc<crate::github::app::AppAuth> {
4587 Arc::new(crate::github::app::AppAuth::new(
4588 "12345".to_string(),
4589 crate::github::app::test_pem().to_string(),
4590 server.uri(),
4591 ))
4592 }
4593
4594 async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
4595 use wiremock::matchers::{method, path};
4596 use wiremock::{Mock, ResponseTemplate};
4597 Mock::given(method("GET"))
4598 .and(path(format!("/repos/{repo}/installation")))
4599 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4600 .mount(server)
4601 .await;
4602 Mock::given(method("POST"))
4603 .and(path("/app/installations/42/access_tokens"))
4604 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4605 "token": "inst-1",
4606 "expires_at": "2099-01-01T00:00:00Z",
4607 })))
4608 .mount(server)
4609 .await;
4610 }
4611
4612 #[tokio::test]
4613 async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
4614 use crate::review_mode::ReviewMode;
4615 use wiremock::matchers::{method, path};
4616 use wiremock::{Mock, MockServer, ResponseTemplate};
4617 let server = MockServer::start().await;
4618 Mock::given(method("GET"))
4619 .and(path("/repos/org/b/installation"))
4620 .respond_with(ResponseTemplate::new(404))
4621 .expect(1)
4622 .mount(&server)
4623 .await;
4624 let app = app_for_test(&server);
4625
4626 assert!(
4627 review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
4628 .await
4629 .is_none()
4630 );
4631 assert!(
4633 review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
4634 .await
4635 .is_none()
4636 );
4637 assert!(review_app_for(ReviewMode::App, None, "org/b")
4638 .await
4639 .is_none());
4640 }
4641
4642 #[tokio::test]
4643 async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
4644 use wiremock::matchers::{body_partial_json, method, path};
4645 use wiremock::{Mock, MockServer, ResponseTemplate};
4646 let server = MockServer::start().await;
4647 mount_installed(&server, "o/r").await;
4648 Mock::given(method("POST"))
4649 .and(path("/repos/o/r/check-runs"))
4650 .and(body_partial_json(serde_json::json!({
4651 "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
4652 })))
4653 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4654 .expect(1)
4655 .mount(&server)
4656 .await;
4657 Mock::given(method("POST"))
4658 .and(path("/repos/o/r/check-runs"))
4659 .and(body_partial_json(serde_json::json!({
4660 "name": "orch/review", "head_sha": "abc123", "status": "completed",
4661 "conclusion": "failure", "output": {"summary": "fix it"},
4662 })))
4663 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4664 .expect(1)
4665 .mount(&server)
4666 .await;
4667 let app = app_for_test(&server);
4668 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4669 let decision = ReviewDecision::RequestChanges {
4670 notes: "fix it".to_string(),
4671 issues: vec![],
4672 };
4673
4674 post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
4675 post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
4676 let requests = server.received_requests().await.expect("requests");
4677 assert!(requests
4678 .iter()
4679 .all(|req| !req.url.path().contains("/statuses/")));
4680 }
4681
4682 #[tokio::test]
4683 async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
4684 use crate::engine::runner::response::ReviewIssue;
4685 use wiremock::matchers::{method, path, query_param};
4686 use wiremock::{Mock, MockServer, ResponseTemplate};
4687 let server = MockServer::start().await;
4688 mount_installed(&server, "o/r").await;
4689 Mock::given(method("GET"))
4690 .and(path("/repos/o/r/pulls/9/files"))
4691 .and(query_param("page", "1"))
4692 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
4693 {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
4694 ])))
4695 .expect(1)
4696 .mount(&server)
4697 .await;
4698 Mock::given(method("POST"))
4699 .and(path("/repos/o/r/pulls/9/reviews"))
4700 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4701 .expect(1)
4702 .mount(&server)
4703 .await;
4704 Mock::given(method("POST"))
4705 .and(path("/repos/o/r/issues/9/comments"))
4706 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4707 .mount(&server)
4708 .await;
4709 let issue = |file: &str, line: Option<u32>| ReviewIssue {
4710 file: file.to_string(),
4711 line,
4712 severity: "error".to_string(),
4713 description: "wrong".to_string(),
4714 };
4715 let decision = ReviewDecision::RequestChanges {
4716 notes: "fix".to_string(),
4717 issues: vec![
4718 issue("src/a.rs", Some(11)),
4719 issue("src/a.rs", Some(50)),
4720 issue("src/b.rs", Some(3)),
4721 issue("src/a.rs", None),
4722 ],
4723 };
4724 let app = app_for_test(&server);
4725 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4726
4727 publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
4728 .await
4729 .expect("comment posted");
4730 let requests = server.received_requests().await.expect("requests");
4731 let review = requests
4732 .iter()
4733 .find(|req| req.url.path().ends_with("/reviews"))
4734 .expect("review submitted");
4735 let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
4736 assert_eq!(
4737 body["comments"],
4738 serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
4739 );
4740 }
4741
4742 #[allow(clippy::await_holding_lock)]
4743 #[tokio::test]
4744 async fn without_an_app_the_review_path_posts_only_the_comment() {
4745 use wiremock::matchers::{method, path};
4746 use wiremock::{Mock, MockServer, ResponseTemplate};
4747 let _env = crate::github::token::ENV_LOCK.lock().unwrap();
4748 let prev = std::env::var_os("GH_TOKEN");
4749 std::env::set_var("GH_TOKEN", "owner-token");
4750 let server = MockServer::start().await;
4751 Mock::given(method("POST"))
4752 .and(path("/repos/o/r/issues/9/comments"))
4753 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4754 .expect(1)
4755 .mount(&server)
4756 .await;
4757 let gh = GhHttp::for_test(server.uri(), None);
4758 let result = publish_review(
4759 None,
4760 &gh,
4761 "o/r",
4762 9,
4763 Some("abc123"),
4764 &ReviewDecision::Approve { issues: vec![] },
4765 "## Automated Review \u{2014} Approve\n\nok",
4766 )
4767 .await;
4768 match prev {
4769 Some(value) => std::env::set_var("GH_TOKEN", value),
4770 None => std::env::remove_var("GH_TOKEN"),
4771 }
4772 result.expect("comment posted");
4773 let requests = server.received_requests().await.expect("requests");
4774 assert!(requests
4775 .iter()
4776 .all(|req| !req.url.path().contains("/reviews")));
4777 }
4778
4779 #[tokio::test]
4780 async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
4781 use wiremock::matchers::{body_partial_json, method, path};
4782 use wiremock::{Mock, MockServer, ResponseTemplate};
4783 let server = MockServer::start().await;
4784 Mock::given(method("POST"))
4785 .and(path("/app/installations/42/access_tokens"))
4786 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4787 "token": "inst-1",
4788 "expires_at": "2099-01-01T00:00:00Z",
4789 })))
4790 .mount(&server)
4791 .await;
4792 Mock::given(method("POST"))
4793 .and(path("/repos/o/r/pulls/9/reviews"))
4794 .and(body_partial_json(serde_json::json!({
4795 "event": "REQUEST_CHANGES",
4796 "commit_id": "abc123",
4797 })))
4798 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4799 .expect(1)
4800 .mount(&server)
4801 .await;
4802 Mock::given(method("POST"))
4803 .and(path("/repos/o/r/issues/9/comments"))
4804 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4805 .expect(1)
4806 .mount(&server)
4807 .await;
4808 Mock::given(method("GET"))
4809 .and(path("/repos/o/r/installation"))
4810 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4811 .mount(&server)
4812 .await;
4813 let app = Arc::new(crate::github::app::AppAuth::new(
4814 "12345".to_string(),
4815 crate::github::app::test_pem().to_string(),
4816 server.uri(),
4817 ));
4818 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4819 let decision = ReviewDecision::RequestChanges {
4820 notes: "fix it".to_string(),
4821 issues: vec![],
4822 };
4823 publish_review(
4824 Some(&app),
4825 &gh,
4826 "o/r",
4827 9,
4828 Some("abc123"),
4829 &decision,
4830 "## Automated Review \u{2014} Changes Requested\n\nfix it",
4831 )
4832 .await
4833 .expect("comment posted");
4834 }
4835}