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 {
1116 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1117 }
1118 }
1119 runner::agents::AgentError::ModelUnavailable { .. } => {
1120 if let Some(model) = review_model {
1125 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1126 }
1127 }
1128 _ => {
1129 if let Some(model) = review_model {
1132 crate::engine::cooldown::record_model_failure(review_agent, model).await;
1133 }
1134 }
1135 }
1136}
1137
1138fn review_decision_from_response(
1139 review_response: runner::response::ReviewResponse,
1140) -> ReviewDecision {
1141 match review_response.decision.as_str() {
1142 "approve" => approve_from_evidence(review_response),
1143 "request_changes" => ReviewDecision::RequestChanges {
1144 notes: review_response.notes,
1145 issues: review_response.issues,
1146 },
1147 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1148 }
1149}
1150
1151fn approve_from_evidence(review_response: runner::response::ReviewResponse) -> ReviewDecision {
1153 let tests_failed = review_response
1154 .test_results
1155 .as_deref()
1156 .is_some_and(|s| s.trim().eq_ignore_ascii_case("fail"));
1157 let has_error = review_response
1158 .issues
1159 .iter()
1160 .any(|issue| issue.severity.trim().eq_ignore_ascii_case("error"));
1161 if tests_failed || has_error {
1162 let notes = if tests_failed {
1163 notes_for_failed_tests(review_response.notes)
1164 } else {
1165 review_response.notes
1166 };
1167 ReviewDecision::RequestChanges {
1168 notes,
1169 issues: review_response.issues,
1170 }
1171 } else {
1172 ReviewDecision::Approve {
1173 issues: review_response.issues,
1174 }
1175 }
1176}
1177
1178fn notes_for_failed_tests(notes: String) -> String {
1179 const MARK: &str = "Review reported test_results: fail";
1180 if notes.trim().is_empty() {
1181 MARK.to_string()
1182 } else if notes.contains("test_results: fail") {
1183 notes
1184 } else {
1185 format!("{notes}\n\n{MARK}")
1186 }
1187}
1188
1189fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1190 match decision {
1191 ReviewDecision::Approve { .. } => "approve",
1192 ReviewDecision::RequestChanges { .. } => "request_changes",
1193 ReviewDecision::Failed(_) => "failed",
1194 ReviewDecision::Blocked(_) => "blocked",
1195 ReviewDecision::Skipped => "skipped",
1196 ReviewDecision::Rerouted => "rerouted",
1197 }
1198}
1199
1200async fn parse_review_output(
1201 task: &ExternalTask,
1202 repo: &str,
1203 ctx: &ReviewContext,
1204 run: ReviewRun,
1205 store: &Arc<TaskStore>,
1206) -> ReviewPhase<ParsedReview> {
1207 let (file_exists, file_size, exit_code, stderr) = {
1208 let output_file_clone = ctx.output_file.clone();
1209 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1210 match tokio::task::spawn_blocking(move || {
1211 let file_exists = output_file_clone.exists();
1212 let file_size = std::fs::metadata(&output_file_clone)
1213 .map(|m| m.len())
1214 .unwrap_or(0);
1215 let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1216 .ok()
1217 .and_then(|s| s.trim().parse::<i32>().ok())
1218 .unwrap_or(-1);
1219 let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1220 .unwrap_or_default();
1221 (file_exists, file_size, exit_code, stderr)
1222 })
1223 .await
1224 {
1225 Ok(tuple) => tuple,
1226 Err(e) => {
1227 tracing::error!(
1228 task_id = task.id.0,
1229 error = %e,
1230 "spawn_blocking panicked reading review output metadata"
1231 );
1232 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1233 }
1234 }
1235 };
1236
1237 tracing::info!(
1238 task_id = task.id.0,
1239 path = %ctx.output_file.display(),
1240 file_exists,
1241 file_size,
1242 "reading review agent output"
1243 );
1244
1245 let raw_output =
1246 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1247 let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1248 let has_result_envelope =
1249 runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1250 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1251 let token_usage = RunTokenUsage {
1252 input_tokens: agent_result
1253 .as_ref()
1254 .and_then(|r| r.input_tokens)
1255 .unwrap_or(0),
1256 output_tokens: agent_result
1257 .as_ref()
1258 .and_then(|r| r.output_tokens)
1259 .unwrap_or(0),
1260 total_cost_usd: agent_result
1261 .as_ref()
1262 .and_then(|r| r.cost_usd)
1263 .unwrap_or(0.0),
1264 duration_secs: 0.0,
1265 };
1266
1267 let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1268 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1269 && !raw_output.contains("\"is_error\":true");
1270 let is_hard_failure =
1272 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1273
1274 if is_hard_failure {
1275 tracing::warn!(
1276 task_id = task.id.0,
1277 exit_code,
1278 raw_output_len = raw_output.len(),
1279 stderr_len = stderr.len(),
1280 "review agent: entering error path"
1281 );
1282 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1283 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1284 record_review_agent_failure(
1285 &task.id.0,
1286 &ctx.review_agent,
1287 ctx.review_model.as_deref(),
1288 &err,
1289 )
1290 .await;
1291 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1292 let stored_error = format_review_error(&ctx.review_agent, &err);
1293 complete_review_run(
1294 store,
1295 run.run_id,
1296 Some(exit_code),
1297 &raw_output,
1298 &stderr,
1299 "",
1300 outcome_for_agent_error(&err),
1301 &stored_error,
1302 token_usage,
1303 )
1304 .await;
1305 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1306 }
1307
1308 let text_for_review = agent_result
1309 .as_ref()
1310 .map(|r| r.result_text.clone())
1311 .filter(|text| !text.is_empty())
1312 .unwrap_or_else(|| {
1313 tracing::debug!(
1314 task_id = task.id.0,
1315 agent = %ctx.review_agent,
1316 "review agent: per-agent extractor returned no text, using raw output"
1317 );
1318 raw_output.clone()
1319 });
1320
1321 let review_response = match runner::response::parse_review_response(&text_for_review) {
1322 Ok(response) => response,
1323 Err(e) => {
1324 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1325 tracing::warn!(
1326 task_id = task.id.0,
1327 agent = %ctx.review_agent,
1328 "review response parsed via keyword fallback"
1329 );
1330 response
1331 } else {
1332 let ndjson_completed = ndjson_completed_no_error;
1338
1339 if ndjson_completed || !has_result_envelope {
1342 let assistant_text = runner::agents::collect_assistant_messages_text(
1343 &ctx.review_agent,
1344 &raw_output,
1345 );
1346 if !assistant_text.is_empty() {
1347 if let Ok(response) =
1348 runner::response::parse_review_response(&assistant_text)
1349 {
1350 tracing::debug!(
1351 task_id = task.id.0,
1352 agent = %ctx.review_agent,
1353 "found review response JSON in assistant message (terminal_reason:completed)"
1354 );
1355 let review_notes_for_comment = response.notes.clone();
1356 let decision = review_decision_from_response(response);
1357 return ReviewPhase::Ready(ParsedReview {
1358 run_id: run.run_id,
1359 exit_code,
1360 stderr,
1361 raw_output,
1362 text_for_review: assistant_text,
1363 token_usage,
1364 review_notes_for_comment,
1365 decision,
1366 });
1367 }
1368 if let Some(response) =
1369 runner::response::infer_review_response(&assistant_text)
1370 {
1371 tracing::warn!(
1372 task_id = task.id.0,
1373 agent = %ctx.review_agent,
1374 "inferred review response from assistant message (terminal_reason:completed)"
1375 );
1376 let review_notes_for_comment = response.notes.clone();
1377 let decision = review_decision_from_response(response);
1378 return ReviewPhase::Ready(ParsedReview {
1379 run_id: run.run_id,
1380 exit_code,
1381 stderr,
1382 raw_output,
1383 text_for_review: assistant_text,
1384 token_usage,
1385 review_notes_for_comment,
1386 decision,
1387 });
1388 }
1389 }
1390 }
1391
1392 let scan_head = runner::agents::patterns::safe_head(
1399 &text_for_review,
1400 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1401 );
1402 let scan_tail = runner::agents::patterns::safe_tail(
1403 &text_for_review,
1404 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1405 );
1406
1407 if let Some(err) = (!ndjson_completed)
1408 .then(|| {
1409 runner::agents::patterns::detect_rate_limit(scan_head)
1410 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1411 })
1412 .flatten()
1413 {
1414 let runner::agents::AgentError::RateLimit { message } = &err else {
1415 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1416 };
1417 tracing::warn!(
1418 task_id = task.id.0,
1419 agent = %ctx.review_agent,
1420 "review agent hit rate limit — adding to cooldown"
1421 );
1422 let summary = runner::fallback::summarize_rate_limit_error(message);
1429 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1430 record_review_agent_failure(
1431 &task.id.0,
1432 &ctx.review_agent,
1433 ctx.review_model.as_deref(),
1434 &err,
1435 )
1436 .await;
1437 complete_review_run(
1438 store,
1439 run.run_id,
1440 Some(exit_code),
1441 &raw_output,
1442 &stderr,
1443 &text_for_review,
1444 outcome_for_agent_error(&err),
1445 &stored_error,
1446 token_usage,
1447 )
1448 .await;
1449 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1450 }
1451
1452 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1453 let runner::agents::AgentError::Auth { message } = &err else {
1454 unreachable!("detect_auth_error only returns AgentError::Auth")
1455 };
1456 tracing::warn!(
1457 task_id = task.id.0,
1458 agent = %ctx.review_agent,
1459 "review agent hit auth error — adding to cooldown"
1460 );
1461 let stored_error = format!("auth error: {message}");
1462 record_review_agent_failure(
1463 &task.id.0,
1464 &ctx.review_agent,
1465 ctx.review_model.as_deref(),
1466 &err,
1467 )
1468 .await;
1469 complete_review_run(
1470 store,
1471 run.run_id,
1472 Some(exit_code),
1473 &raw_output,
1474 &stderr,
1475 &text_for_review,
1476 outcome_for_agent_error(&err),
1477 &stored_error,
1478 token_usage,
1479 )
1480 .await;
1481 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1482 }
1483
1484 if agent_result
1491 .as_ref()
1492 .map(|r| r.truncated_by_length)
1493 .unwrap_or(false)
1494 {
1495 tracing::warn!(
1496 task_id = task.id.0,
1497 agent = %ctx.review_agent,
1498 model = ?ctx.review_model,
1499 "review agent output truncated: step_finish reason=length \
1500 (token budget exceeded before completion)"
1501 );
1502 if let Some(model) = ctx.review_model.as_deref() {
1503 crate::engine::cooldown::record_persistent_model_failure(
1504 &ctx.review_agent,
1505 model,
1506 )
1507 .await;
1508 }
1509 let stored_error = "truncated: review output was cut off before completion \
1510 (output/reasoning token budget exceeded)"
1511 .to_string();
1512 complete_review_run(
1513 store,
1514 run.run_id,
1515 Some(exit_code),
1516 &raw_output,
1517 &stderr,
1518 &text_for_review,
1519 "truncated",
1520 &stored_error,
1521 token_usage,
1522 )
1523 .await;
1524 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1525 }
1526
1527 if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1530 tracing::warn!(
1531 task_id = task.id.0,
1532 agent = %ctx.review_agent,
1533 model = ?ctx.review_model,
1534 "review session exited mid-thought with no result envelope \
1535 — storing as incomplete and retrying without persistent cooldown"
1536 );
1537 let stored_error =
1538 "incomplete: review session exited before producing a decision".to_string();
1539 complete_review_run(
1540 store,
1541 run.run_id,
1542 Some(exit_code),
1543 &raw_output,
1544 &stderr,
1545 &text_for_review,
1546 "incomplete",
1547 &stored_error,
1548 token_usage,
1549 )
1550 .await;
1551 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1552 }
1553
1554 tracing::error!(
1555 task_id = task.id.0,
1556 error = %e,
1557 agent = %ctx.review_agent,
1558 output = %text_for_review.chars().take(300).collect::<String>(),
1559 raw_output = %raw_output.chars().take(1000).collect::<String>(),
1560 "failed to parse review response"
1561 );
1562 if let Some(model) = ctx.review_model.as_deref() {
1563 crate::engine::cooldown::record_persistent_model_failure(
1566 &ctx.review_agent,
1567 model,
1568 )
1569 .await;
1570 }
1571 complete_review_run(
1572 store,
1573 run.run_id,
1574 Some(exit_code),
1575 &raw_output,
1576 &stderr,
1577 &text_for_review,
1578 "parse_error",
1579 &format!("parse error: {e}"),
1580 token_usage,
1581 )
1582 .await;
1583 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1584 "parse error: {e}"
1585 )));
1586 }
1587 }
1588 };
1589
1590 let review_notes_for_comment = review_response.notes.clone();
1591 let decision = review_decision_from_response(review_response);
1592
1593 tracing::info!(
1594 task_id = task.id.0,
1595 pr_number = ctx.pr_number,
1596 decision = ?decision,
1597 "review agent decision received"
1598 );
1599 store_log_activity(
1600 &Some(Arc::clone(store)),
1601 repo,
1602 &task.id.0,
1603 "review_decision",
1604 None,
1605 None,
1606 Some(&ctx.review_agent),
1607 ctx.review_model.as_deref(),
1608 Some(&serde_json::json!({
1609 "decision": review_decision_name(&decision),
1610 "pr_number": ctx.pr_number,
1611 })),
1612 )
1613 .await;
1614
1615 ReviewPhase::Ready(ParsedReview {
1616 run_id: run.run_id,
1617 exit_code,
1618 stderr,
1619 raw_output,
1620 text_for_review,
1621 token_usage,
1622 review_notes_for_comment,
1623 decision,
1624 })
1625}
1626
1627async fn finalize_review_run(
1628 task: &ExternalTask,
1629 ctx: &ReviewContext,
1630 parsed: &ParsedReview,
1631 store: &Arc<TaskStore>,
1632) {
1633 let outcome = match &parsed.decision {
1634 ReviewDecision::Approve { .. }
1635 | ReviewDecision::RequestChanges { .. }
1636 | ReviewDecision::Skipped
1637 | ReviewDecision::Rerouted => "success",
1638 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
1639 };
1640 let error = match &parsed.decision {
1641 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
1642 _ => "",
1643 };
1644
1645 if outcome == "success" {
1646 crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
1649 .await;
1650 }
1651
1652 complete_review_run(
1653 store,
1654 parsed.run_id,
1655 Some(parsed.exit_code),
1656 &parsed.raw_output,
1657 &parsed.stderr,
1658 &parsed.text_for_review,
1659 outcome,
1660 error,
1661 parsed.token_usage,
1662 )
1663 .await;
1664
1665 if let Some(store_id) = ctx.store_id {
1666 if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
1667 let model = if ctx.review_model_str().is_empty() {
1668 "unknown"
1669 } else {
1670 ctx.review_model_str()
1671 };
1672 if let Err(e) = store
1673 .store_tokens(
1674 store_id,
1675 parsed.token_usage.input_tokens,
1676 parsed.token_usage.output_tokens,
1677 model,
1678 )
1679 .await
1680 {
1681 tracing::warn!(
1682 task_id = task.id.0,
1683 ?e,
1684 "failed to store review agent token usage"
1685 );
1686 }
1687 }
1688 }
1689}
1690
1691async fn restore_review_config_if_needed(
1692 task_id: &str,
1693 worktree_path: &std::path::Path,
1694 default_branch: &str,
1695) {
1696 let _ = Command::new("git")
1697 .args(["checkout", "HEAD", "--", ".orch.yml"])
1698 .current_dir(worktree_path)
1699 .status()
1700 .await;
1701
1702 let merge_base = Command::new("git")
1703 .args(["merge-base", "HEAD", default_branch])
1704 .current_dir(worktree_path)
1705 .output()
1706 .await
1707 .ok()
1708 .filter(|output| output.status.success())
1709 .and_then(|output| String::from_utf8(output.stdout).ok())
1710 .map(|s| s.trim().to_string())
1711 .unwrap_or_default();
1712
1713 if merge_base.is_empty() {
1714 return;
1715 }
1716
1717 let orch_yml_changed = Command::new("git")
1718 .args([
1719 "diff",
1720 "--name-only",
1721 &merge_base,
1722 "HEAD",
1723 "--",
1724 ".orch.yml",
1725 ])
1726 .current_dir(worktree_path)
1727 .output()
1728 .await
1729 .ok()
1730 .filter(|output| output.status.success())
1731 .map(|output| !output.stdout.is_empty())
1732 .unwrap_or(false);
1733
1734 if !orch_yml_changed {
1735 return;
1736 }
1737
1738 tracing::warn!(
1739 task_id,
1740 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
1741 );
1742 let restore_ok = Command::new("git")
1743 .args(["checkout", &merge_base, "--", ".orch.yml"])
1744 .current_dir(worktree_path)
1745 .status()
1746 .await
1747 .map(|status| status.success())
1748 .unwrap_or(false);
1749
1750 if !restore_ok {
1751 return;
1752 }
1753
1754 let staged = Command::new("git")
1755 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
1756 .current_dir(worktree_path)
1757 .output()
1758 .await
1759 .ok()
1760 .filter(|output| output.status.success())
1761 .map(|output| !output.stdout.is_empty())
1762 .unwrap_or(false);
1763
1764 if staged {
1765 let _ = Command::new("git")
1766 .args([
1767 "-c",
1768 "commit.gpgsign=false",
1769 "commit",
1770 "-m",
1771 "revert: restore .orch.yml (review agent must not modify project config)",
1772 ])
1773 .current_dir(worktree_path)
1774 .status()
1775 .await;
1776 }
1777}
1778
1779fn is_missing_path_error_message(err: &str) -> bool {
1780 err.to_ascii_lowercase()
1781 .contains("no such file or directory")
1782}
1783
1784async fn push_review_branch(
1785 task: &ExternalTask,
1786 repo: &str,
1787 ctx: &ReviewContext,
1788 store: &Arc<TaskStore>,
1789) -> Result<(), String> {
1790 let mut worktree_path = ctx.worktree_path.clone();
1791 let mut branch_name = ctx.branch_name.clone();
1792 let mut retried_missing_worktree = false;
1793
1794 let push_result = loop {
1795 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
1796 {
1797 Ok(ok) => break Ok(ok),
1798 Err(e)
1799 if !retried_missing_worktree
1800 && !worktree_path.exists()
1801 && is_missing_path_error_message(&e.to_string()) =>
1802 {
1803 retried_missing_worktree = true;
1804 tracing::warn!(
1805 task_id = task.id.0,
1806 worktree = %worktree_path.display(),
1807 branch = %branch_name,
1808 "review push failed because worktree disappeared; attempting one-time recovery"
1809 );
1810 match recover_missing_review_worktree(task, repo, store).await {
1811 Ok(recovered) => {
1812 worktree_path = recovered.work_dir;
1813 branch_name = recovered.branch;
1814 continue;
1815 }
1816 Err(recover_err) => {
1817 break Err(anyhow::anyhow!(
1818 "missing review worktree recovery failed after push error: {recover_err}"
1819 ));
1820 }
1821 }
1822 }
1823 Err(e) => break Err(e),
1824 }
1825 };
1826
1827 match push_result {
1828 Ok(_) => {
1829 store_log_activity(
1830 &Some(Arc::clone(store)),
1831 repo,
1832 &task.id.0,
1833 "push",
1834 None,
1835 None,
1836 Some(&ctx.review_agent),
1837 ctx.review_model.as_deref(),
1838 Some(&serde_json::json!({
1839 "status": "ok",
1840 "branch": branch_name.clone(),
1841 "default_branch": ctx.default_branch.clone(),
1842 "phase": "review",
1843 })),
1844 )
1845 .await;
1846 if ctx.had_prev_push_failure {
1847 if let Err(e) = store_set_result(
1848 &Some(Arc::clone(store)),
1849 repo,
1850 &task.id.0,
1851 &[
1852 ("last_error", serde_json::json!("")),
1853 ("push_failures", serde_json::json!(0)),
1854 ],
1855 )
1856 .await
1857 {
1858 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
1859 }
1860 }
1861 Ok(())
1862 }
1863 Err(e) => {
1864 store_log_activity(
1865 &Some(Arc::clone(store)),
1866 repo,
1867 &task.id.0,
1868 "push",
1869 None,
1870 None,
1871 Some(&ctx.review_agent),
1872 ctx.review_model.as_deref(),
1873 Some(&serde_json::json!({
1874 "status": "error",
1875 "branch": branch_name.clone(),
1876 "default_branch": ctx.default_branch.clone(),
1877 "phase": "review",
1878 "error": e.to_string(),
1879 })),
1880 )
1881 .await;
1882 tracing::error!(
1883 task_id = task.id.0,
1884 branch = %ctx.branch_name,
1885 error = %e,
1886 "review push failed"
1887 );
1888 store_set(
1889 &Some(Arc::clone(store)),
1890 repo,
1891 &task.id.0,
1892 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
1893 )
1894 .await;
1895 Err(e.to_string())
1896 }
1897 }
1898}
1899
1900fn append_review_issues(
1901 body: &mut String,
1902 issues: &[crate::engine::runner::response::ReviewIssue],
1903) {
1904 if issues.is_empty() {
1905 return;
1906 }
1907 body.push_str("\n**Issues Found:**\n");
1908 for issue in issues {
1909 body.push_str(&format!(
1910 "- `{}` line {}: {} [{}]\n",
1911 issue.file,
1912 issue
1913 .line
1914 .map(|line| line.to_string())
1915 .unwrap_or_else(|| "?".to_string()),
1916 issue.description,
1917 issue.severity
1918 ));
1919 }
1920}
1921
1922fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
1923 match decision {
1924 ReviewDecision::Approve { issues } => {
1925 let mut body = format!(
1926 "## Automated Review \u{2014} Approve\n\n{}",
1927 review_notes_for_comment
1928 );
1929 if !issues.is_empty() && !body.ends_with('\n') {
1930 body.push('\n');
1931 }
1932 append_review_issues(&mut body, issues);
1933 body
1934 }
1935 ReviewDecision::RequestChanges { notes, issues } => {
1936 let mut body = format!(
1937 "## Automated Review \u{2014} Changes Requested\n\n{}\n",
1938 notes
1939 );
1940 append_review_issues(&mut body, issues);
1941 body
1942 }
1943 _ => String::new(),
1944 }
1945}
1946
1947const REVIEW_STATUS_CONTEXT: &str = "orch/review";
1948
1949fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
1951 match decision {
1952 ReviewDecision::Approve { .. } => Some(("success", "Approved by automated review")),
1953 ReviewDecision::RequestChanges { .. } => {
1954 Some(("failure", "Automated review requested changes"))
1955 }
1956 _ => None,
1957 }
1958}
1959
1960async fn post_review_status(
1963 gh: &GhHttp,
1964 repo: &str,
1965 sha: Option<&str>,
1966 state: &str,
1967 description: &str,
1968) {
1969 let Some(sha) = sha else {
1970 return;
1971 };
1972 if let Err(e) = gh
1973 .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
1974 .await
1975 {
1976 tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
1977 }
1978}
1979
1980async fn post_review_comment(
1981 task: &ExternalTask,
1982 repo: &str,
1983 ctx: &ReviewContext,
1984 decision: &ReviewDecision,
1985 review_notes_for_comment: &str,
1986) -> anyhow::Result<()> {
1987 let gh = match GhHttp::new() {
1988 Ok(gh) => gh,
1989 Err(e) => {
1990 tracing::warn!(
1991 task_id = task.id.0,
1992 error = %e,
1993 "failed to create GH client for review comment"
1994 );
1995 return Ok(());
1996 }
1997 };
1998 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
1999 if ctx.same_agent_fallback && !pr_comment.is_empty() {
2000 pr_comment.push_str(
2001 "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
2002 );
2003 }
2004
2005 let head_sha = worktree_head_sha(&ctx.worktree_path)
2008 .await
2009 .or_else(|| ctx.head_sha.clone());
2010
2011 let app = review_app(repo).await;
2012 post_review_decision(
2013 app.as_deref(),
2014 &gh,
2015 repo,
2016 head_sha.as_deref(),
2017 decision,
2018 review_notes_for_comment,
2019 )
2020 .await;
2021
2022 if !pr_comment.is_empty() {
2023 let footer =
2024 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
2025 let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
2026 if let Err(e) = publish_review(
2027 app.as_deref(),
2028 &gh,
2029 repo,
2030 ctx.pr_number,
2031 head_sha.as_deref(),
2032 decision,
2033 &pr_comment_with_footer,
2034 )
2035 .await
2036 {
2037 tracing::warn!(
2038 task_id = task.id.0,
2039 pr_number = ctx.pr_number,
2040 error = %e,
2041 "failed to post automated review comment on PR"
2042 );
2043 }
2044 }
2045
2046 Ok(())
2047}
2048
2049async fn review_app(repo: &str) -> Option<Arc<crate::github::app::AppAuth>> {
2051 let mode = crate::review_mode::for_repo(repo);
2052 review_app_for(mode, crate::github::app::shared(), repo).await
2053}
2054
2055async fn review_app_for(
2056 mode: crate::review_mode::ReviewMode,
2057 app: Option<Arc<crate::github::app::AppAuth>>,
2058 repo: &str,
2059) -> Option<Arc<crate::github::app::AppAuth>> {
2060 if mode != crate::review_mode::ReviewMode::App {
2061 return None;
2062 }
2063
2064 let app = app?;
2065 match app.token_for_repo(repo).await {
2066 Ok(Some(_)) => Some(app),
2067 Ok(None) => {
2068 tracing::info!(
2069 repo,
2070 "review.mode is app but the App is not installed here, reviewing with a comment"
2071 );
2072 None
2073 }
2074 Err(e) => {
2075 tracing::warn!(repo, error = %e, "App installation lookup failed, reviewing with a comment");
2076 None
2077 }
2078 }
2079}
2080
2081async fn post_review_started(
2083 app: Option<&crate::github::app::AppAuth>,
2084 gh: &GhHttp,
2085 repo: &str,
2086 sha: Option<&str>,
2087) {
2088 use crate::github::app::CheckRunState;
2089 match (app, sha) {
2090 (Some(app), Some(sha)) => {
2091 if let Err(e) = app
2092 .post_check_run(repo, sha, CheckRunState::InProgress)
2093 .await
2094 {
2095 tracing::warn!(sha, error = %e, "failed to post the orch/review check run");
2096 }
2097 }
2098 _ => post_review_status(gh, repo, sha, "pending", "Automated review running").await,
2099 }
2100}
2101
2102async fn post_review_decision(
2103 app: Option<&crate::github::app::AppAuth>,
2104 gh: &GhHttp,
2105 repo: &str,
2106 sha: Option<&str>,
2107 decision: &ReviewDecision,
2108 notes: &str,
2109) {
2110 use crate::github::app::CheckRunState;
2111 let Some((state, description)) = review_status(decision) else {
2112 return;
2113 };
2114
2115 match (app, sha) {
2116 (Some(app), Some(sha)) => {
2117 let approved = state == "success";
2118 let completed = CheckRunState::Completed {
2119 approved,
2120 summary: if notes.is_empty() { description } else { notes },
2121 };
2122 if let Err(e) = app.post_check_run(repo, sha, completed).await {
2123 tracing::warn!(sha, error = %e, "failed to complete the orch/review check run");
2124 }
2125 }
2126 _ => post_review_status(gh, repo, sha, state, description).await,
2127 }
2128}
2129
2130async fn publish_review(
2134 app: Option<&crate::github::app::AppAuth>,
2135 gh: &GhHttp,
2136 repo: &str,
2137 pr_number: u64,
2138 head_sha: Option<&str>,
2139 decision: &ReviewDecision,
2140 comment: &str,
2141) -> anyhow::Result<()> {
2142 use crate::github::app::ReviewEvent;
2143 let event = match decision {
2144 ReviewDecision::Approve { .. } => Some(ReviewEvent::Approve),
2145 ReviewDecision::RequestChanges { .. } => Some(ReviewEvent::RequestChanges),
2146 _ => None,
2147 };
2148 if let (Some(app), Some(sha), Some(event)) = (app, head_sha, event) {
2149 let inline = inline_comments(app, repo, pr_number, decision).await;
2150 if let Err(e) = app
2151 .submit_review(repo, pr_number, event, comment, sha, &inline)
2152 .await
2153 {
2154 tracing::warn!(pr_number, sha, error = %e, "failed to submit App review, the comment stays the decision");
2155 }
2156 }
2157 gh.add_comment(repo, &pr_number.to_string(), comment).await
2158}
2159
2160async fn inline_comments(
2163 app: &crate::github::app::AppAuth,
2164 repo: &str,
2165 pr_number: u64,
2166 decision: &ReviewDecision,
2167) -> Vec<crate::github::app::InlineComment> {
2168 let ReviewDecision::RequestChanges { issues, .. } = decision else {
2169 return Vec::new();
2170 };
2171
2172 if issues.is_empty() {
2173 return Vec::new();
2174 }
2175
2176 match app.pull_request_hunks(repo, pr_number).await {
2177 Ok(hunks) => crate::github::app::split_inline(issues, &hunks).0,
2178 Err(e) => {
2179 tracing::warn!(pr_number, error = %e, "could not read the PR diff, issues stay in the review body");
2180 Vec::new()
2181 }
2182 }
2183}
2184
2185#[allow(clippy::too_many_arguments)]
2186async fn apply_review_decision(
2187 task: &ExternalTask,
2188 backend: &Arc<dyn ExternalBackend>,
2189 repo: &str,
2190 task_manager: &Arc<TaskManager>,
2191 store: &Arc<TaskStore>,
2192 ctx: &ReviewContext,
2193 parsed: &ParsedReview,
2194 router_config: &crate::engine::router::RouterConfig,
2195) -> anyhow::Result<ReviewDecision> {
2196 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
2197
2198 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
2199 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
2200 }
2201
2202 post_review_comment(
2203 task,
2204 repo,
2205 ctx,
2206 &parsed.decision,
2207 &parsed.review_notes_for_comment,
2208 )
2209 .await?;
2210
2211 match &parsed.decision {
2212 ReviewDecision::Approve { .. } => {
2213 record_approved_sha(store, repo, &task.id.0, ctx.pr_number, &ctx.worktree_path).await;
2214 if let Err(e) = auto_merge_pr(
2219 task,
2220 &ctx.branch_name,
2221 backend,
2222 repo,
2223 &ctx.review_agent,
2224 ctx.review_model_str(),
2225 task_manager,
2226 store,
2227 )
2228 .await
2229 {
2230 let error_msg = e.to_string();
2231 if error_msg.contains("not yet computed") {
2232 tracing::warn!(
2233 task_id = task.id.0,
2234 pr_number = ctx.pr_number,
2235 branch = %ctx.branch_name,
2236 error = %e,
2237 "auto-merge deferred — PR mergeability not yet computed"
2238 );
2239 } else {
2240 tracing::error!(
2241 task_id = task.id.0,
2242 pr_number = ctx.pr_number,
2243 branch = %ctx.branch_name,
2244 error = %e,
2245 "auto-merge failed"
2246 );
2247 }
2248 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2249 }
2250 Ok(parsed.decision.clone())
2251 }
2252 ReviewDecision::RequestChanges { notes, issues } => {
2253 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2254 handle_review_changes(
2255 task,
2256 notes,
2257 issues,
2258 backend,
2259 repo,
2260 ctx.pr_number,
2261 &ctx.review_agent,
2262 ctx.review_model_str(),
2263 task_manager,
2264 store,
2265 router_config,
2266 )
2267 .await?;
2268 Ok(parsed.decision.clone())
2269 }
2270 _ => Ok(parsed.decision.clone()),
2271 }
2272}
2273
2274async fn recover_missing_review_worktree(
2275 task: &ExternalTask,
2276 repo: &str,
2277 store: &Arc<TaskStore>,
2278) -> anyhow::Result<worktree::WorktreeSetup> {
2279 let project_dir = crate::config::get_projects_with_paths()?
2280 .into_iter()
2281 .find(|(candidate_repo, _)| candidate_repo == repo)
2282 .map(|(_, project_dir)| project_dir)
2283 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2284
2285 tracing::warn!(
2286 task_id = task.id.0,
2287 repo,
2288 project_dir = %project_dir.display(),
2289 "review worktree missing, recreating via normal setup"
2290 );
2291
2292 let store_ref = Some(Arc::clone(store));
2293 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2294 .await
2295 .context("recreating missing review worktree")?;
2296
2297 if !wt.work_dir.exists() {
2298 anyhow::bail!(
2299 "recreated review worktree at {} for task {} is still missing",
2300 wt.work_dir.display(),
2301 task.id.0
2302 );
2303 }
2304
2305 Ok(wt)
2306}
2307
2308async fn count_ahead_commits(
2314 worktree_path: &std::path::Path,
2315 default_branch: &str,
2316) -> Result<u64, String> {
2317 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2318 format!(
2319 "worktree path contains non-UTF-8 characters: {:?}",
2320 worktree_path
2321 )
2322 })?;
2323
2324 let output = tokio::process::Command::new("git")
2325 .args([
2326 "-C",
2327 worktree_str,
2328 "rev-list",
2329 "--count",
2330 &format!("origin/{default_branch}..HEAD"),
2331 ])
2332 .output()
2333 .await
2334 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2335
2336 if !output.status.success() {
2337 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2338 return Err(format!(
2339 "git rev-list exited with {:?}: {}",
2340 output.status.code(),
2341 if stderr.is_empty() {
2342 String::from_utf8_lossy(&output.stdout).trim().to_string()
2343 } else {
2344 stderr
2345 }
2346 ));
2347 }
2348
2349 String::from_utf8_lossy(&output.stdout)
2350 .trim()
2351 .parse::<u64>()
2352 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2353}
2354
2355#[allow(clippy::too_many_arguments)]
2362async fn ensure_pr_exists(
2363 task: &ExternalTask,
2364 branch_name: &str,
2365 worktree_path: &std::path::Path,
2366 repo: &str,
2367 agent_summary: &str,
2368 store: &Arc<TaskStore>,
2369 task_manager: &Arc<TaskManager>,
2370 stored_pr_number: Option<u64>,
2371) -> anyhow::Result<EnsurePrResult> {
2372 let gh_check = GhHttp::new()?;
2373
2374 if let Some(n) = stored_pr_number {
2375 tracing::info!(
2376 task_id = task.id.0,
2377 pr_number = n,
2378 branch = %branch_name,
2379 "open PR found in store, proceeding with review"
2380 );
2381 return Ok(EnsurePrResult::Ready(n));
2382 }
2383
2384 match gh_check.get_pr_number(repo, branch_name).await {
2385 Ok(Some(n)) => {
2386 tracing::info!(
2387 task_id = task.id.0,
2388 pr_number = n,
2389 branch = %branch_name,
2390 "open PR found, proceeding with review"
2391 );
2392 Ok(EnsurePrResult::Ready(n))
2393 }
2394 Ok(None) => {
2395 let default_branch = worktree::detect_default_branch(worktree_path).await;
2397 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2398 Ok(count) => count > 0,
2399 Err(e) => {
2400 tracing::error!(
2401 task_id = task.id.0,
2402 branch = %branch_name,
2403 default_branch = %default_branch,
2404 err = %e,
2405 "could not determine if branch has commits ahead of default"
2406 );
2407 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2408 format!("git ancestry check failed: {e}"),
2409 )));
2410 }
2411 };
2412
2413 if has_commits {
2414 tracing::warn!(
2416 task_id = task.id.0,
2417 branch = %branch_name,
2418 "no open PR but branch has commits — attempting to create PR"
2419 );
2420 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2421 anyhow::anyhow!(
2422 "worktree path contains non-UTF-8 characters: {:?}",
2423 worktree_path
2424 )
2425 })?;
2426 let auth_env = runner::git_ops::build_git_auth_env().await;
2428
2429 let push_timeout = std::time::Duration::from_secs(120);
2434 match tokio::time::timeout(
2435 push_timeout,
2436 tokio::process::Command::new("git")
2437 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2438 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2439 .output(),
2440 )
2441 .await
2442 {
2443 Err(_elapsed) => {
2444 tracing::warn!(
2445 task_id = task.id.0,
2446 branch = %branch_name,
2447 timeout_secs = push_timeout.as_secs(),
2448 "review gate fallback push timed out"
2449 );
2450 }
2451 Ok(Ok(o)) => {
2452 if !o.status.success() {
2453 tracing::warn!(
2454 task_id = task.id.0,
2455 branch = %branch_name,
2456 exit_code = ?o.status.code(),
2457 stdout = %String::from_utf8_lossy(&o.stdout),
2458 stderr = %String::from_utf8_lossy(&o.stderr),
2459 "review gate fallback push failed"
2460 );
2461 } else {
2462 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2463 }
2464 }
2465 Ok(Err(e)) => {
2466 tracing::error!(
2467 task_id = task.id.0,
2468 branch = %branch_name,
2469 error = %e,
2470 "review gate fallback push command failed to run"
2471 );
2472 }
2473 }
2474
2475 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2476 let pr_body = format!(
2477 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2478 );
2479 let gh = GhHttp::new()?;
2480 match gh
2481 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2482 .await
2483 {
2484 Ok(url) => match parse_pr_number_from_url(&url) {
2485 Ok(pr_num) => {
2486 store_set_result(
2487 &Some(Arc::clone(store)),
2488 repo,
2489 &task.id.0,
2490 &[("pr_number", serde_json::json!(pr_num as i64))],
2491 )
2492 .await?;
2493 tracing::info!(
2494 task_id = task.id.0,
2495 branch = %branch_name,
2496 pr_url = %url,
2497 "created missing PR via GhHttp — continuing review"
2498 );
2499 Ok(EnsurePrResult::Ready(pr_num))
2500 }
2501 Err(e) => {
2502 tracing::warn!(
2503 task_id = task.id.0,
2504 branch = %branch_name,
2505 pr_url = %url,
2506 error = %e,
2507 "created missing PR via GhHttp, but failed to parse PR number"
2508 );
2509 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2510 format!("created missing PR, but could not parse PR number: {e}"),
2511 )))
2512 }
2513 },
2514 Err(e) => {
2515 let e_str = format!("{e}");
2516 if e_str.contains("already exists") {
2517 let backoff_delays = [2u64, 4u64];
2521 let mut found_pr: Option<u64> = None;
2522 for delay_secs in backoff_delays {
2523 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2524 .await;
2525 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2526 {
2527 found_pr = Some(n);
2528 break;
2529 }
2530 }
2531 if let Some(n) = found_pr {
2532 tracing::info!(
2533 task_id = task.id.0,
2534 pr_number = n,
2535 branch = %branch_name,
2536 "found existing PR after create_pr 422 — retrying review"
2537 );
2538 store_set_result(
2539 &Some(Arc::clone(store)),
2540 repo,
2541 &task.id.0,
2542 &[("pr_number", serde_json::json!(n as i64))],
2543 )
2544 .await?;
2545 return Ok(EnsurePrResult::Ready(n));
2546 }
2547 }
2548 tracing::warn!(
2549 task_id = task.id.0,
2550 branch = %branch_name,
2551 error = %e,
2552 "create_pr failed via GhHttp, falling back to CLI"
2553 );
2554 let pr_timeout = std::time::Duration::from_secs(120);
2557 let mut gh_cmd = tokio::process::Command::new("gh");
2558 gh_cmd.args([
2559 "pr",
2560 "create",
2561 "--repo",
2562 repo,
2563 "--head",
2564 branch_name,
2565 "--title",
2566 &task.title,
2567 "--body",
2568 &pr_body,
2569 ]);
2570 gh_cmd.current_dir(worktree_path);
2571 gh_cmd.kill_on_drop(true);
2572
2573 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2574 match pr_result {
2575 Ok(Ok(o)) if o.status.success() => {
2576 let stdout = String::from_utf8_lossy(&o.stdout);
2577 match parse_pr_number_from_url(stdout.trim()) {
2578 Ok(pr_num) => {
2579 store_set_result(
2580 &Some(Arc::clone(store)),
2581 repo,
2582 &task.id.0,
2583 &[("pr_number", serde_json::json!(pr_num as i64))],
2584 )
2585 .await?;
2586 tracing::info!(
2587 task_id = task.id.0,
2588 branch = %branch_name,
2589 "created missing PR via CLI — continuing review"
2590 );
2591 Ok(EnsurePrResult::Ready(pr_num))
2592 }
2593 Err(e) => {
2594 tracing::warn!(
2595 task_id = task.id.0,
2596 branch = %branch_name,
2597 stdout = %stdout,
2598 error = %e,
2599 "created missing PR via CLI, but failed to parse PR number"
2600 );
2601 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2602 format!("created missing PR via CLI, but could not parse PR number: {e}")
2603 )))
2604 }
2605 }
2606 }
2607 Ok(Ok(o)) => {
2608 let stderr = String::from_utf8_lossy(&o.stderr);
2609 if stderr.contains("already exists") {
2610 if let Some(pr_url) =
2611 stderr.lines().find(|l| l.trim().starts_with("https://"))
2612 {
2613 match parse_pr_number_from_url(pr_url.trim()) {
2614 Ok(pr_num) => {
2615 if let Err(e) = store_set_result(
2616 &Some(Arc::clone(store)),
2617 repo,
2618 &task.id.0,
2619 &[(
2620 "pr_number",
2621 serde_json::json!(pr_num as i64),
2622 )],
2623 )
2624 .await
2625 {
2626 tracing::warn!(
2627 task_id = task.id.0,
2628 pr_num,
2629 err = %e,
2630 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
2631 );
2632 }
2633 tracing::info!(
2634 task_id = task.id.0,
2635 branch = %branch_name,
2636 pr_url = %pr_url,
2637 "PR already exists (from CLI stderr) — retrying review"
2638 );
2639 return Ok(EnsurePrResult::Ready(pr_num));
2640 }
2641 Err(e) => {
2642 tracing::warn!(
2643 task_id = task.id.0,
2644 branch = %branch_name,
2645 pr_url = %pr_url,
2646 error = %e,
2647 "found existing PR URL in CLI stderr, but failed to parse PR number"
2648 );
2649 }
2651 }
2652 }
2653 }
2654 tracing::error!(
2655 task_id = task.id.0,
2656 branch = %branch_name,
2657 stderr = %stderr,
2658 "failed to create missing PR — work may be stuck"
2659 );
2660
2661 if crate::engine::runner::git_ops::is_transient_github_api_error(
2668 &stderr,
2669 ) {
2670 tracing::warn!(
2671 task_id = task.id.0,
2672 branch = %branch_name,
2673 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
2674 );
2675 return Ok(EnsurePrResult::EarlyReturn(
2676 ReviewDecision::Failed(format!(
2677 "transient github error creating PR: {stderr}"
2678 )),
2679 ));
2680 }
2681
2682 match store_increment(
2683 &Some(Arc::clone(store)),
2684 repo,
2685 &task.id.0,
2686 "pr_create_failures",
2687 )
2688 .await
2689 {
2690 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2691 return Ok(EnsurePrResult::EarlyReturn(
2692 ReviewDecision::Blocked(format!(
2693 "no PR, create failed {failures} times: {stderr}"
2694 )),
2695 ));
2696 }
2697 Ok(_) => {}
2698 Err(e) => {
2699 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2700 }
2701 }
2702 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2703 format!("no PR, create failed: {stderr}"),
2704 )))
2705 }
2706 Ok(Err(e)) => {
2707 tracing::error!(
2708 task_id = task.id.0,
2709 error = %e,
2710 "failed to run gh pr create"
2711 );
2712 let e_str = format!("{e}");
2713 if crate::engine::runner::git_ops::is_transient_github_api_error(
2714 &e_str,
2715 ) {
2716 tracing::warn!(
2717 task_id = task.id.0,
2718 branch = %branch_name,
2719 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
2720 );
2721 return Ok(EnsurePrResult::EarlyReturn(
2722 ReviewDecision::Failed(format!("transient gh error: {e}")),
2723 ));
2724 }
2725
2726 match store_increment(
2727 &Some(Arc::clone(store)),
2728 repo,
2729 &task.id.0,
2730 "pr_create_failures",
2731 )
2732 .await
2733 {
2734 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2735 return Ok(EnsurePrResult::EarlyReturn(
2736 ReviewDecision::Blocked(format!(
2737 "no PR, gh error {failures} times: {e}"
2738 )),
2739 ));
2740 }
2741 Ok(_) => {}
2742 Err(e) => {
2743 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2744 }
2745 }
2746 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2747 format!("no PR, gh error: {e}"),
2748 )))
2749 }
2750 Err(_elapsed) => {
2751 tracing::error!(
2753 task_id = task.id.0,
2754 timeout_secs = pr_timeout.as_secs(),
2755 "gh pr create CLI fallback timed out"
2756 );
2757 let e_str = format!(
2758 "gh pr create timed out after {}s",
2759 pr_timeout.as_secs()
2760 );
2761 if crate::engine::runner::git_ops::is_transient_github_api_error(
2762 &e_str,
2763 ) {
2764 tracing::warn!(
2765 task_id = task.id.0,
2766 branch = %branch_name,
2767 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
2768 );
2769 return Ok(EnsurePrResult::EarlyReturn(
2770 ReviewDecision::Failed(format!(
2771 "transient gh error: {e_str}"
2772 )),
2773 ));
2774 }
2775
2776 match store_increment(
2777 &Some(Arc::clone(store)),
2778 repo,
2779 &task.id.0,
2780 "pr_create_failures",
2781 )
2782 .await
2783 {
2784 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2785 return Ok(EnsurePrResult::EarlyReturn(
2786 ReviewDecision::Blocked(format!(
2787 "no PR, gh timeout {failures} times: {e_str}"
2788 )),
2789 ));
2790 }
2791 Ok(_) => {}
2792 Err(e) => {
2793 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2794 }
2795 }
2796 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2797 format!("no PR, gh timeout: {e_str}"),
2798 )))
2799 }
2800 }
2801 }
2802 }
2803 } else {
2804 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
2806 Ok(v) => v,
2807 Err(e) => {
2808 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
2809 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2810 format!("merge check failed: {e}"),
2811 )));
2812 }
2813 };
2814 if merged {
2815 tracing::info!(
2816 task_id = task.id.0,
2817 branch = %branch_name,
2818 "PR already merged, marking done"
2819 );
2820 if let Err(e) = task_manager
2821 .update_task_status(&task.id, Status::Done)
2822 .await
2823 {
2824 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2825 }
2826 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2827 }
2828
2829 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
2830 .await
2831 .map(|t| t.last_error)
2832 .unwrap_or_default();
2833 let reason = if !agent_summary.is_empty() {
2834 agent_summary.to_string()
2835 } else {
2836 last_error.clone()
2837 };
2838
2839 if last_error.contains("exceeded max attempts") {
2840 tracing::warn!(
2841 task_id = task.id.0,
2842 branch = %branch_name,
2843 "no PR and no commits after max attempts — marking blocked to stop loop"
2844 );
2845 let fields = [
2849 (
2850 "block_reason",
2851 serde_json::json!("max attempts exceeded — no PR or commits produced"),
2852 ),
2853 ("last_error", serde_json::json!(last_error)),
2854 ];
2855 if let Err(e) = task_manager
2856 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2857 .await
2858 {
2859 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2860 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2861 format!("failed to write block_reason: {e}"),
2862 )));
2863 }
2864 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2865 }
2866
2867 if last_error.contains("422") && last_error.contains("head") {
2868 tracing::info!(
2869 task_id = task.id.0,
2870 branch = %branch_name,
2871 "no PR — 422/head-invalid means work already merged, marking done"
2872 );
2873 if let Err(e) = task_manager
2874 .update_task_status(&task.id, Status::Done)
2875 .await
2876 {
2877 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
2878 }
2879 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2880 }
2881
2882 if last_error.contains("no fallback agents")
2883 || last_error.contains("all agents exhausted")
2884 {
2885 tracing::warn!(
2886 task_id = task.id.0,
2887 branch = %branch_name,
2888 last_error = %last_error,
2889 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
2890 );
2891 let fields = [
2895 (
2896 "block_reason",
2897 serde_json::json!("all agents exhausted — no PR or commits produced"),
2898 ),
2899 ("last_error", serde_json::json!(last_error)),
2900 ];
2901 if let Err(e) = task_manager
2902 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2903 .await
2904 {
2905 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2906 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2907 format!("failed to write block_reason: {e}"),
2908 )));
2909 }
2910 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2911 }
2912
2913 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
2920 .ok()
2921 .and_then(|s| s.parse().ok())
2922 .or_else(|| {
2923 config::get("workflow.max_attempts")
2924 .ok()
2925 .and_then(|s| s.parse().ok())
2926 })
2927 .unwrap_or(3);
2928
2929 tracing::warn!(
2930 task_id = task.id.0,
2931 branch = %branch_name,
2932 reason = %reason,
2933 "no PR and no commits — re-routing for retry"
2934 );
2935
2936 if should_skip_no_code_reroute_increment(&last_error) {
2940 tracing::warn!(
2941 task_id = task.id.0,
2942 last_error = %last_error,
2943 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
2944 );
2945 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2946 format!("transient github error: {last_error}"),
2947 )));
2948 }
2949
2950 let reroutes = match store_increment(
2952 &Some(Arc::clone(store)),
2953 repo,
2954 &task.id.0,
2955 "no_code_reroutes",
2956 )
2957 .await
2958 {
2959 Ok(v) => v,
2960 Err(e) => {
2961 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
2962 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2964 format!("transient store error: {e}"),
2965 )));
2966 }
2967 };
2968
2969 if reroutes as u32 >= max_reroutes {
2970 tracing::error!(
2971 task_id = task.id.0,
2972 reroutes,
2973 max_reroutes,
2974 "reached max reroute attempts for no-pr-result — blocking for human review"
2975 );
2976 let msg = format!(
2977 "no PR or code changes after {}/{} reroute attempts",
2978 reroutes, max_reroutes
2979 );
2980 let fields = [
2984 (
2985 "block_reason",
2986 serde_json::json!(format!(
2987 "max reroute attempts ({}) reached — no PR or code changes produced",
2988 max_reroutes
2989 )),
2990 ),
2991 ("last_error", serde_json::json!(msg)),
2992 ("agent", serde_json::json!(null)),
2993 ("model", serde_json::json!(null)),
2994 ];
2995 if let Err(e) = task_manager
2996 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2997 .await
2998 {
2999 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
3000 }
3001 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
3002 } else if task.id.0.starts_with("internal:") {
3003 tracing::info!(
3010 task_id = task.id.0,
3011 "no PR and no commits for internal task — marking done"
3012 );
3013 if let Err(e) = task_manager
3014 .update_task_status(&task.id, Status::Done)
3015 .await
3016 {
3017 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
3018 }
3019 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
3020 } else {
3021 if let Err(e) = store_set_result(
3026 &Some(Arc::clone(store)),
3027 repo,
3028 &task.id.0,
3029 &[
3030 ("agent", serde_json::json!(null)),
3031 ("model", serde_json::json!(null)),
3032 (
3033 "last_error",
3034 serde_json::json!("no PR or code changes produced"),
3035 ),
3036 ],
3037 )
3038 .await
3039 {
3040 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
3041 } else if let Err(e) =
3043 task_manager.update_task_status(&task.id, Status::New).await
3044 {
3045 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
3046 }
3047 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
3048 }
3049 }
3050 }
3051 Err(e) => {
3052 tracing::warn!(
3053 task_id = task.id.0,
3054 branch = %branch_name,
3055 error = %e,
3056 "failed to check PR status"
3057 );
3058 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
3059 format!("PR check failed: {e}"),
3060 )))
3061 }
3062 }
3063}
3064
3065pub(crate) fn review_timeout_seconds() -> u64 {
3067 review_timeout_secs(
3068 crate::config::get("workflow.review_timeout_seconds")
3069 .ok()
3070 .and_then(|s| s.parse().ok()),
3071 )
3072}
3073
3074pub(crate) const REVIEW_RESULT_DELIVERY_SECS: u64 = 120;
3078
3079pub(crate) fn no_session_review_floor(configured: u64, review_timeout: u64) -> u64 {
3083 configured.max(review_timeout + REVIEW_RESULT_DELIVERY_SECS)
3084}
3085
3086pub(crate) async fn review_and_merge(
3092 task: &ExternalTask,
3093 backend: &Arc<dyn ExternalBackend>,
3094 tmux: &Arc<TmuxManager>,
3095 repo: &str,
3096 router: &Arc<RwLock<Router>>,
3097 task_manager: &Arc<TaskManager>,
3098 store: &Arc<TaskStore>,
3099) -> anyhow::Result<ReviewDecision> {
3100 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
3101 ReviewPhase::Ready(ctx) => ctx,
3102 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3103 };
3104
3105 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
3106 ReviewPhase::Ready(run) => run,
3107 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3108 };
3109
3110 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
3111 ReviewPhase::Ready(parsed) => parsed,
3112 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
3113 };
3114
3115 finalize_review_run(task, &ctx, &parsed, store).await;
3116
3117 let router_config = router.read().await.config.clone();
3118 apply_review_decision(
3119 task,
3120 backend,
3121 repo,
3122 task_manager,
3123 store,
3124 &ctx,
3125 &parsed,
3126 &router_config,
3127 )
3128 .await
3129}
3130
3131#[cfg(test)]
3132mod tests {
3133 use super::*;
3134
3135 #[test]
3136 fn a_review_gets_15_minutes_unless_configured_and_never_under_5() {
3137 assert_eq!(review_timeout_secs(None), 900);
3138 assert_eq!(review_timeout_secs(Some(600)), 600);
3139 assert_eq!(review_timeout_secs(Some(60)), 300);
3140 assert_eq!(review_timeout_secs(Some(3600)), 3600);
3141 }
3142
3143 use crate::backends::ExternalTask;
3144 use crate::engine::router::RouterConfig;
3145 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
3146 use crate::store::{TaskStatus, TaskStore};
3147 use tempfile::TempDir;
3148
3149 #[test]
3154 fn no_session_review_floor_raises_a_limit_under_the_review_timeout() {
3155 assert_eq!(
3156 no_session_review_floor(600, 900),
3157 900 + REVIEW_RESULT_DELIVERY_SECS
3158 );
3159 }
3160
3161 #[test]
3162 fn no_session_review_floor_keeps_a_limit_over_the_review_timeout() {
3163 assert_eq!(no_session_review_floor(1800, 900), 1800);
3164 }
3165
3166 #[test]
3167 fn no_session_review_floor_clears_the_review_timeout_by_the_delivery_margin() {
3168 for (configured, review_timeout) in [(0, 0), (1800, 1800), (120, 7200)] {
3169 let floored = no_session_review_floor(configured, review_timeout);
3170 assert!(
3171 floored >= review_timeout + REVIEW_RESULT_DELIVERY_SECS,
3172 "{floored} must leave a finished review time to deliver its result"
3173 );
3174 }
3175 }
3176
3177 #[test]
3178 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
3179 let err = runner::agents::AgentError::RateLimit {
3183 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
3184 for this billing cycle. Your quota will be refreshed soon."
3185 .to_string(),
3186 };
3187 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
3188 }
3189
3190 #[test]
3191 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
3192 let err = runner::agents::AgentError::RateLimit {
3193 message: "429 Too Many Requests".to_string(),
3194 };
3195 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
3196 }
3197
3198 #[test]
3199 fn outcome_for_agent_error_generic_auth_unaffected() {
3200 let err = runner::agents::AgentError::Auth {
3201 message: "401 Unauthorized".to_string(),
3202 };
3203 assert_eq!(outcome_for_agent_error(&err), "auth_error");
3204 }
3205
3206 #[test]
3209 fn parse_pr_number_standard_url() {
3210 assert_eq!(
3211 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
3212 Ok(42)
3213 );
3214 }
3215
3216 #[test]
3217 fn parse_pr_number_trailing_slash() {
3218 assert_eq!(
3219 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
3220 Ok(42)
3221 );
3222 }
3223
3224 #[test]
3225 fn parse_pr_number_with_query_string() {
3226 assert_eq!(
3227 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
3228 Ok(42)
3229 );
3230 }
3231
3232 #[test]
3233 fn parse_pr_number_with_hash_fragment() {
3234 assert_eq!(
3235 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
3236 Ok(42)
3237 );
3238 }
3239
3240 #[test]
3241 fn parse_pr_number_with_query_and_hash() {
3242 assert_eq!(
3243 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
3244 Ok(42)
3245 );
3246 }
3247
3248 #[test]
3249 fn parse_pr_number_with_leading_whitespace() {
3250 assert_eq!(
3251 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
3252 Ok(42)
3253 );
3254 }
3255
3256 #[test]
3257 fn parse_pr_number_wrong_domain() {
3258 let url = "https://gitlab.com/owner/repo/merge_requests/42";
3259 let result = parse_pr_number_from_url(url);
3260 assert!(result.is_err());
3261 assert!(result
3262 .unwrap_err()
3263 .contains("does not start with 'https://github.com/'"));
3264 }
3265
3266 #[test]
3267 fn parse_pr_number_missing_pull_segment() {
3268 let url = "https://github.com/owner/repo/issues/42";
3270 let result = parse_pr_number_from_url(url);
3271 assert!(result.is_err());
3272 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3273 }
3274
3275 #[test]
3276 fn parse_pr_number_zero_is_rejected() {
3277 let url = "https://github.com/owner/repo/pull/0";
3278 let result = parse_pr_number_from_url(url);
3279 assert!(result.is_err());
3280 assert!(result.unwrap_err().contains("cannot be zero"));
3281 }
3282
3283 #[test]
3284 fn parse_pr_number_non_numeric_segment() {
3285 let url = "https://github.com/owner/repo/pull/abc";
3286 let result = parse_pr_number_from_url(url);
3287 assert!(result.is_err());
3288 assert!(result.unwrap_err().contains("not a valid positive integer"));
3289 }
3290
3291 #[test]
3292 fn parse_pr_number_empty_string() {
3293 let result = parse_pr_number_from_url("");
3294 assert!(result.is_err());
3295 assert!(result.unwrap_err().contains("empty"));
3296 }
3297
3298 #[test]
3299 fn parse_pr_number_http_not_https() {
3300 let url = "http://github.com/owner/repo/pull/42";
3302 let result = parse_pr_number_from_url(url);
3303 assert!(result.is_err());
3304 assert!(result
3305 .unwrap_err()
3306 .contains("does not start with 'https://github.com/'"));
3307 }
3308
3309 #[test]
3317 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3318 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3322 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3323 let err = runner::agents::AgentError::RateLimit {
3324 message: raw_fragment.to_string(),
3325 };
3326
3327 let stored = format_review_error("minimax", &err);
3328
3329 assert!(
3332 !stored.contains("{\"type\""),
3333 "stored error must not contain raw NDJSON envelope, got: {stored}"
3334 );
3335 assert!(
3336 !stored.contains("subtype"),
3337 "stored error must not contain raw api_retry fragment, got: {stored}"
3338 );
3339 assert!(
3340 !stored.contains("session_id"),
3341 "stored error must not contain session_id from provider payload, got: {stored}"
3342 );
3343 assert_eq!(
3344 stored,
3345 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3346 );
3347 }
3348
3349 #[test]
3350 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3351 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3356 let err = runner::agents::AgentError::RateLimit {
3357 message: raw_fragment.to_string(),
3358 };
3359
3360 let stored = format_review_error("glm", &err);
3361
3362 assert!(stored.starts_with("glm rate limit:"));
3363 assert!(
3364 !stored.contains("session_id"),
3365 "stored error must not leak session_id from truncated fragment, got: {stored}"
3366 );
3367 assert!(
3370 stored.len() <= 200,
3371 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3372 stored.len()
3373 );
3374 }
3375
3376 #[test]
3377 fn format_review_error_preserves_non_rate_limit_variants() {
3378 let err = runner::agents::AgentError::Auth {
3379 message: "invalid bearer token".to_string(),
3380 };
3381 assert_eq!(
3382 format_review_error("kimi", &err),
3383 "kimi auth error: invalid bearer token"
3384 );
3385
3386 let err = runner::agents::AgentError::Timeout {
3387 elapsed: std::time::Duration::from_secs(1801),
3388 };
3389 assert_eq!(
3390 format_review_error("kimi", &err),
3391 "kimi timed out after 1801s"
3392 );
3393 }
3394
3395 fn git(dir: &std::path::Path, args: &[&str]) {
3396 let mut cmd = std::process::Command::new("git");
3397 cmd.current_dir(dir);
3398 if args.first() == Some(&"commit") {
3400 cmd.args(["-c", "commit.gpgsign=false"]);
3401 }
3402 let status = cmd.args(args).status().unwrap();
3403 assert!(status.success(), "git {:?} failed", args);
3404 }
3405
3406 #[test]
3407 fn test_pull_request_review_requests_changes() {
3408 let review = PullRequestReview {
3409 review: GitHubReview {
3410 id: 1,
3411 user: GitHubUser {
3412 login: "reviewer".to_string(),
3413 },
3414 body: Some("Please fix".to_string()),
3415 state: "CHANGES_REQUESTED".to_string(),
3416 html_url: None,
3417 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3418 commit_id: None,
3419 },
3420 comments: vec![],
3421 };
3422 assert!(review.requests_changes());
3423 }
3424
3425 #[test]
3426 fn test_pull_request_review_does_not_request_changes() {
3427 let review = PullRequestReview {
3428 review: GitHubReview {
3429 id: 1,
3430 user: GitHubUser {
3431 login: "reviewer".to_string(),
3432 },
3433 body: Some("LGTM".to_string()),
3434 state: "APPROVED".to_string(),
3435 html_url: None,
3436 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3437 commit_id: None,
3438 },
3439 comments: vec![],
3440 };
3441 assert!(!review.requests_changes());
3442 }
3443
3444 #[test]
3445 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3446 let review = PullRequestReview {
3447 review: GitHubReview {
3448 id: 1,
3449 user: GitHubUser {
3450 login: "reviewer".to_string(),
3451 },
3452 body: None,
3453 state: "CHANGES_REQUESTED".to_string(),
3454 html_url: None,
3455 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3456 commit_id: None,
3457 },
3458 comments: vec![
3459 GitHubReviewComment {
3460 id: 1,
3461 user: GitHubUser {
3462 login: "reviewer".to_string(),
3463 },
3464 body: "Fix this issue".to_string(),
3465 path: "src/main.rs".to_string(),
3466 line: Some(10),
3467 original_line: Some(10),
3468 commit_id: "abc".to_string(),
3469 original_commit_id: "abc".to_string(),
3470 html_url: "url".to_string(),
3471 created_at: "2024-01-01T00:00:00Z".to_string(),
3472 updated_at: "2024-01-01T00:00:00Z".to_string(),
3473 in_reply_to_id: None,
3474 diff_hunk: Some(
3475 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3476 ),
3477 },
3478 GitHubReviewComment {
3479 id: 2,
3480 user: GitHubUser {
3481 login: "reviewer".to_string(),
3482 },
3483 body: "".to_string(),
3484 path: "src/lib.rs".to_string(),
3485 line: Some(20),
3486 original_line: Some(20),
3487 commit_id: "abc".to_string(),
3488 original_commit_id: "abc".to_string(),
3489 html_url: "url".to_string(),
3490 created_at: "2024-01-01T00:00:00Z".to_string(),
3491 updated_at: "2024-01-01T00:00:00Z".to_string(),
3492 in_reply_to_id: None,
3493 diff_hunk: None,
3494 },
3495 GitHubReviewComment {
3496 id: 3,
3497 user: GitHubUser {
3498 login: "reviewer".to_string(),
3499 },
3500 body: "Reply to this".to_string(),
3501 path: "src/lib.rs".to_string(),
3502 line: Some(30),
3503 original_line: Some(30),
3504 commit_id: "abc".to_string(),
3505 original_commit_id: "abc".to_string(),
3506 html_url: "url".to_string(),
3507 created_at: "2024-01-01T00:00:00Z".to_string(),
3508 updated_at: "2024-01-01T00:00:00Z".to_string(),
3509 in_reply_to_id: Some(1),
3510 diff_hunk: None,
3511 },
3512 ],
3513 };
3514 let actionable = review.actionable_comments();
3515 assert_eq!(actionable.len(), 1);
3516 assert_eq!(actionable[0].id, 1);
3517 assert_eq!(actionable[0].body, "Fix this issue");
3518 assert_eq!(actionable[0].path, "src/main.rs");
3519 assert_eq!(
3520 actionable[0].diff_hunk.as_ref().unwrap(),
3521 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3522 );
3523 }
3524
3525 #[test]
3526 fn test_router_config_model_for_complexity_returns_none_without_config() {
3527 let cfg = RouterConfig::default();
3530 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3531 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3532 assert!(cfg
3533 .model_for_complexity("unknown_agent_xyz", "simple", "")
3534 .is_none());
3535 }
3536
3537 #[test]
3538 fn review_status_maps_decisions() {
3539 assert_eq!(
3540 review_status(&ReviewDecision::Approve { issues: vec![] })
3541 .unwrap()
3542 .0,
3543 "success"
3544 );
3545 let changes = ReviewDecision::RequestChanges {
3546 notes: String::new(),
3547 issues: vec![],
3548 };
3549 assert_eq!(review_status(&changes).unwrap().0, "failure");
3550 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
3551 }
3552
3553 fn sample_review_issue(severity: &str) -> runner::response::ReviewIssue {
3554 runner::response::ReviewIssue {
3555 file: "src/engine/tick.rs".into(),
3556 line: Some(142),
3557 severity: severity.into(),
3558 description: "index out of bounds on the last element".into(),
3559 }
3560 }
3561
3562 fn sample_review_response(
3563 decision: &str,
3564 test_results: Option<&str>,
3565 issues: Vec<runner::response::ReviewIssue>,
3566 ) -> runner::response::ReviewResponse {
3567 runner::response::ReviewResponse {
3568 decision: decision.into(),
3569 notes: "looks fine".into(),
3570 test_results: test_results.map(str::to_string),
3571 issues,
3572 }
3573 }
3574
3575 #[test]
3576 fn approve_with_failing_tests_becomes_request_changes() {
3577 let decision =
3578 review_decision_from_response(sample_review_response("approve", Some("fail"), vec![]));
3579 match decision {
3580 ReviewDecision::RequestChanges { notes, issues } => {
3581 assert!(
3582 notes.contains("test_results: fail"),
3583 "notes must name the failing test run, got {notes:?}"
3584 );
3585 assert!(issues.is_empty());
3586 }
3587 other => panic!("expected RequestChanges, got {other:?}"),
3588 }
3589 }
3590
3591 #[test]
3592 fn approve_with_error_issue_becomes_request_changes() {
3593 let issue = sample_review_issue("error");
3594 let decision = review_decision_from_response(sample_review_response(
3595 "approve",
3596 Some("pass"),
3597 vec![issue.clone()],
3598 ));
3599 match decision {
3600 ReviewDecision::RequestChanges { issues, .. } => {
3601 assert_eq!(issues, vec![issue]);
3602 }
3603 other => panic!("expected RequestChanges, got {other:?}"),
3604 }
3605 }
3606
3607 #[test]
3608 fn approve_with_warning_issue_stays_approve_and_comment_lists_it() {
3609 let issue = sample_review_issue("warning");
3610 let decision = review_decision_from_response(sample_review_response(
3611 "approve",
3612 Some("pass"),
3613 vec![issue.clone()],
3614 ));
3615 match &decision {
3616 ReviewDecision::Approve { issues } => {
3617 assert_eq!(issues, &vec![issue.clone()]);
3618 }
3619 other => panic!("expected Approve, got {other:?}"),
3620 }
3621 let comment = build_pr_review_comment(&decision, "looks fine");
3622 assert!(
3623 comment.contains("Approve"),
3624 "approve header missing from {comment}"
3625 );
3626 assert!(
3627 comment.contains(&issue.description),
3628 "warning issue missing from {comment}"
3629 );
3630 assert!(
3631 comment.contains("warning"),
3632 "warning severity missing from {comment}"
3633 );
3634 }
3635
3636 #[test]
3637 fn review_started_comment_includes_agent_and_model() {
3638 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
3639 let expected = format!("🔍 Automated review started{}", expected_footer);
3640 assert_eq!(review_started_comment("kimi", "opus"), expected);
3641 }
3642
3643 fn no_code_review_task(id: &str) -> ExternalTask {
3646 ExternalTask {
3647 id: crate::backends::ExternalId(id.to_string()),
3648 title: "opencode discovery timeout".to_string(),
3649 body: "".to_string(),
3650 state: "open".to_string(),
3651 labels: vec![],
3652 author: "tester".to_string(),
3653 created_at: "2026-01-01T00:00:00Z".to_string(),
3654 updated_at: "2026-01-01T00:00:00Z".to_string(),
3655 url: "https://example.com/issues/1".to_string(),
3656 }
3657 }
3658
3659 #[tokio::test]
3663 async fn carried_approval_counts_as_this_rounds_decision() {
3664 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3665 let id = store
3666 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
3667 .await
3668 .unwrap();
3669 let task_id = format!("internal:{id}");
3670 store
3671 .update_status(id, crate::store::TaskStatus::InReview)
3672 .await
3673 .unwrap();
3674 assert_eq!(
3675 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3676 Some(false)
3677 );
3678
3679 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
3680
3681 assert_eq!(
3682 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3683 Some(true),
3684 "the stale in_review check must see this round as decided"
3685 );
3686 }
3687
3688 #[test]
3689 fn approval_covers_head_only_for_the_exact_commit() {
3690 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
3691 assert!(
3692 !approval_covers_head(Some("abc123"), Some("def456")),
3693 "a moved PR head must be reviewed"
3694 );
3695 assert!(
3696 !approval_covers_head(None, Some("abc123")),
3697 "no recorded approval means review"
3698 );
3699 assert!(
3700 !approval_covers_head(Some("abc123"), None),
3701 "an unreadable PR head must not skip the review"
3702 );
3703 assert!(!approval_covers_head(Some(""), Some("")));
3704 }
3705
3706 #[tokio::test]
3711 async fn approved_sha_round_trips_through_the_kv_store() {
3712 let temp = TempDir::new().unwrap();
3713 let wt = temp.path();
3714 git(wt, &["init", "-b", "main"]);
3715 git(wt, &["config", "user.name", "Test"]);
3716 git(wt, &["config", "user.email", "test@test.com"]);
3717 std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
3718 git(wt, &["add", "."]);
3719 git(wt, &["commit", "-m", "apply the fix"]);
3720
3721 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3722 let repo = "owner/repo";
3723 let task_id = "3783";
3724
3725 record_approved_sha(&store, repo, task_id, 77, wt).await;
3726 let recorded = store
3727 .kv_get(&approved_sha_key(repo, task_id, 77))
3728 .await
3729 .unwrap()
3730 .expect("approval must be recorded");
3731 assert_eq!(
3732 recorded,
3733 worktree_head_sha(wt).await.unwrap(),
3734 "the recorded sha must be the commit that was pushed to the PR"
3735 );
3736 assert!(
3737 store
3738 .kv_get(&approved_sha_key(repo, task_id, 78))
3739 .await
3740 .unwrap()
3741 .is_none(),
3742 "an approval must not leak to another PR"
3743 );
3744
3745 clear_approved_sha(&store, repo, task_id, 77).await;
3746 assert!(
3747 store
3748 .kv_get(&approved_sha_key(repo, task_id, 77))
3749 .await
3750 .unwrap()
3751 .is_none(),
3752 "requesting changes must drop the approval"
3753 );
3754 }
3755
3756 #[tokio::test]
3757 async fn build_review_context_reroutes_external_task_with_no_code() {
3758 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3759 let repo = "owner/repo";
3760 let ext = no_code_review_task("3617");
3761 store.ensure_external_task(repo, &ext).await.unwrap();
3762
3763 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3764 let task_manager = Arc::new(TaskManager::with_store(
3765 backend,
3766 Arc::clone(&store),
3767 repo.to_string(),
3768 ));
3769 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3770
3771 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3772 .await
3773 .unwrap();
3774
3775 assert!(
3776 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
3777 "external task with no worktree/branch/pr must reroute, not skip-to-done"
3778 );
3779
3780 let stored = store
3781 .get_by_external_id(repo, &ext.id.0)
3782 .await
3783 .unwrap()
3784 .expect("task must still exist");
3785 assert_eq!(stored.status, TaskStatus::New);
3786 assert!(stored.agent.is_none());
3787 assert!(stored.model.is_none());
3788 }
3789
3790 #[tokio::test]
3791 async fn build_review_context_blocks_external_task_after_max_reroutes() {
3792 let temp_home = TempDir::new().unwrap();
3795 let orch_dir = temp_home.path().join(".orch");
3796 std::fs::create_dir_all(&orch_dir).unwrap();
3797 let old_orch_home = std::env::var_os("ORCH_HOME");
3798 std::env::set_var("ORCH_HOME", &orch_dir);
3799
3800 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3801 let repo = "owner/repo";
3802 let ext = no_code_review_task("3618");
3803 store.ensure_external_task(repo, &ext).await.unwrap();
3804
3805 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3806 let task_manager = Arc::new(TaskManager::with_store(
3807 backend,
3808 Arc::clone(&store),
3809 repo.to_string(),
3810 ));
3811 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3812
3813 for _ in 0..3 {
3815 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3816 .await
3817 .unwrap();
3818 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
3819 break;
3820 }
3821 }
3822
3823 let stored = store
3824 .get_by_external_id(repo, &ext.id.0)
3825 .await
3826 .unwrap()
3827 .expect("task must still exist");
3828 assert_eq!(stored.status, TaskStatus::Blocked);
3829
3830 if let Some(old) = old_orch_home {
3831 std::env::set_var("ORCH_HOME", old);
3832 } else {
3833 std::env::remove_var("ORCH_HOME");
3834 }
3835 }
3836
3837 #[tokio::test]
3838 async fn build_review_context_marks_internal_task_done_with_no_code() {
3839 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3840 let repo = "owner/repo";
3841 let internal_id = store
3842 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
3843 .await
3844 .unwrap();
3845 let ext_id = format!("internal:{internal_id}");
3846 let ext = no_code_review_task(&ext_id);
3847
3848 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3849 let task_manager = Arc::new(TaskManager::with_store(
3850 backend,
3851 Arc::clone(&store),
3852 repo.to_string(),
3853 ));
3854 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3855
3856 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3857 .await
3858 .unwrap();
3859
3860 assert!(matches!(
3861 phase,
3862 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
3863 ));
3864 let stored = store.get(internal_id).await.unwrap();
3865 assert_eq!(stored.status, TaskStatus::Done);
3866 }
3867
3868 #[test]
3869 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
3870 let last_error =
3871 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
3872 assert!(!should_skip_no_code_reroute_increment(last_error));
3873 }
3874
3875 #[test]
3884 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
3885 let padding = "x".repeat(10_000);
3886 let text_for_review = format!(
3887 "{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}"
3888 );
3889
3890 let scan_head = runner::agents::patterns::safe_head(
3891 &text_for_review,
3892 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
3893 );
3894 let scan_tail = runner::agents::patterns::safe_tail(
3895 &text_for_review,
3896 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3897 );
3898
3899 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
3900 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
3901 }
3902
3903 #[test]
3904 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
3905 let padding = "x".repeat(10_000);
3906 let text_for_review =
3907 format!("{padding}\nError: rate limit exceeded, please try again later");
3908
3909 let scan_tail = runner::agents::patterns::safe_tail(
3910 &text_for_review,
3911 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3912 );
3913
3914 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
3915 }
3916
3917 #[tokio::test]
3918 async fn recover_missing_review_worktree_recreates_worktree() {
3919 let temp_home = TempDir::new().unwrap();
3920 let orch_dir = temp_home.path().join(".orch");
3921 std::fs::create_dir_all(&orch_dir).unwrap();
3922 let old_orch_home = std::env::var_os("ORCH_HOME");
3923 std::env::set_var("ORCH_HOME", &orch_dir);
3924
3925 let project_dir = temp_home.path().join("project");
3926 std::fs::create_dir_all(&project_dir).unwrap();
3927 git(&project_dir, &["init", "-b", "main"]);
3928 git(&project_dir, &["config", "user.name", "Test User"]);
3929 git(&project_dir, &["config", "user.email", "test@example.com"]);
3930 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
3931 git(&project_dir, &["add", "README.md"]);
3932 git(&project_dir, &["commit", "-m", "init"]);
3933
3934 std::fs::write(
3935 orch_dir.join("config.yml"),
3936 format!("projects:\n - {}\n", project_dir.display()),
3937 )
3938 .unwrap();
3939 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
3940
3941 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3942 let repo = "owner/repo";
3943 store
3944 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
3945 .await
3946 .unwrap();
3947 let ext = ExternalTask {
3948 id: crate::backends::ExternalId("1".to_string()),
3949 title: "Fix review recovery".to_string(),
3950 body: "".to_string(),
3951 state: "open".to_string(),
3952 labels: vec![],
3953 author: "tester".to_string(),
3954 created_at: "2026-01-01T00:00:00Z".to_string(),
3955 updated_at: "2026-01-01T00:00:00Z".to_string(),
3956 url: "https://example.com/issues/1".to_string(),
3957 };
3958 store.ensure_external_task(repo, &ext).await.unwrap();
3959
3960 let recovered = recover_missing_review_worktree(&ext, repo, &store)
3961 .await
3962 .unwrap();
3963
3964 assert!(recovered.work_dir.exists());
3965 assert!(recovered.branch.starts_with("gh-issue-1-"));
3966
3967 if let Some(old) = old_orch_home {
3968 std::env::set_var("ORCH_HOME", old);
3969 } else {
3970 std::env::remove_var("ORCH_HOME");
3971 }
3972 }
3973
3974 #[tokio::test]
3977 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
3978 let temp = TempDir::new().unwrap();
3979 let dir = temp.path();
3980
3981 let bare = temp.path().join("bare.git");
3983 std::fs::create_dir_all(&bare).unwrap();
3984 std::process::Command::new("git")
3985 .args(["init", "--bare"])
3986 .current_dir(&bare)
3987 .output()
3988 .unwrap();
3989
3990 git(dir, &["init", "-b", "main"]);
3991 git(dir, &["config", "user.name", "Test"]);
3992 git(dir, &["config", "user.email", "test@test.com"]);
3993 std::fs::write(dir.join("f"), "a").unwrap();
3994 git(dir, &["add", "f"]);
3995 git(dir, &["commit", "-m", "init"]);
3996 std::process::Command::new("git")
3997 .args(["remote", "add", "origin", bare.to_str().unwrap()])
3998 .current_dir(dir)
3999 .output()
4000 .unwrap();
4001 std::process::Command::new("git")
4002 .args(["push", "origin", "main"])
4003 .current_dir(dir)
4004 .output()
4005 .unwrap();
4006
4007 let result = count_ahead_commits(dir, "main").await;
4009 assert!(
4010 result.is_ok(),
4011 "expected Ok when origin/main exists, got {result:?}"
4012 );
4013 assert_eq!(
4014 result.unwrap(),
4015 0,
4016 "expected 0 commits ahead when branch matches origin/main"
4017 );
4018 }
4019
4020 #[tokio::test]
4021 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
4022 let temp = TempDir::new().unwrap();
4023 let dir = temp.path();
4024 git(dir, &["init", "-b", "main"]);
4025 git(dir, &["config", "user.name", "Test"]);
4026 git(dir, &["config", "user.email", "test@test.com"]);
4027 std::fs::write(dir.join("f"), "a").unwrap();
4028 git(dir, &["add", "f"]);
4029 git(dir, &["commit", "-m", "init"]);
4030 let result = count_ahead_commits(dir, "main").await;
4032 assert!(
4033 result.is_err(),
4034 "expected Err when origin/main does not exist, got {result:?}"
4035 );
4036 let err = result.unwrap_err();
4037 assert!(
4038 err.contains("git rev-list exited"),
4039 "error should mention exit failure, got: {err}"
4040 );
4041 }
4042
4043 #[tokio::test]
4044 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
4045 let temp = TempDir::new().unwrap();
4047 let dir = temp.path();
4048 let result = count_ahead_commits(dir, "main").await;
4050 assert!(
4051 result.is_err(),
4052 "expected Err when directory is not a git repo, got {result:?}"
4053 );
4054 }
4055
4056 #[serial_test::serial(cooldown_state)]
4066 #[tokio::test]
4067 async fn review_invalid_response_applies_persistent_model_cooldown() {
4068 crate::engine::cooldown::reset_global_state().await;
4069 let agent = "opencode-3314-review-invalid";
4070 let model = "opencode/north-mini-code-free";
4071 let key = format!("{agent}:{model}");
4072
4073 assert!(
4074 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4075 "model should not be in cooldown before record_review_agent_failure"
4076 );
4077
4078 let err = runner::agents::AgentError::InvalidResponse {
4079 raw: "empty review output".to_string(),
4080 };
4081 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
4082
4083 let now = chrono::Utc::now().timestamp();
4084 let until = crate::engine::cooldown::cooldown_until(&key)
4085 .expect("review InvalidResponse should set model cooldown");
4086 let remaining = until.saturating_sub(now);
4087
4088 assert!(
4091 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4092 "review InvalidResponse must apply ~4h persistent cooldown (issue #3314), \
4093 got {remaining}s — may have used 5-min standard backoff instead"
4094 );
4095 }
4096
4097 #[serial_test::serial(cooldown_state)]
4105 #[tokio::test]
4106 async fn review_persistent_cooldown_survives_after_first_failure() {
4107 crate::engine::cooldown::reset_global_state().await;
4108 let agent = "opencode-3314-review-parse";
4109 let model = "opencode/north-mini-code-free";
4110 let key = format!("{agent}:{model}");
4111
4112 let err = runner::agents::AgentError::InvalidResponse {
4114 raw: "failed to parse review response".to_string(),
4115 };
4116 record_review_agent_failure("test-3314-b", agent, Some(model), &err).await;
4117
4118 let now = chrono::Utc::now().timestamp();
4119 let until1 = crate::engine::cooldown::cooldown_until(&key)
4120 .expect("first failure should set cooldown");
4121 let remaining1 = until1.saturating_sub(now);
4122
4123 crate::engine::cooldown::record_agent_success(agent, model).await;
4126
4127 assert!(
4133 remaining1 >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4134 "after success reset, first-failure cooldown should still be ~4h, got {remaining1}s"
4135 );
4136 }
4137
4138 #[serial_test::serial(cooldown_state)]
4144 #[tokio::test]
4145 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
4146 crate::engine::cooldown::reset_global_state().await;
4147 let agent = "kimi-3551-review-inline";
4148 let model = "opus";
4149 let key = format!("{agent}:{model}");
4150
4151 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
4153 You've reached your usage limit for this billing cycle. Your quota will be \
4154 refreshed next cycle.";
4155
4156 let scan_tail = runner::agents::patterns::safe_tail(
4157 text_for_review,
4158 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
4159 );
4160 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
4161 .expect("billing-cycle message must still be caught by the rate-limit scan");
4162
4163 assert_eq!(
4164 outcome_for_agent_error(&err),
4165 "billing_cycle_exhausted",
4166 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
4167 );
4168
4169 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
4170
4171 let now = chrono::Utc::now().timestamp();
4172 let until = crate::engine::cooldown::cooldown_until(&key)
4173 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
4174 let remaining = until.saturating_sub(now);
4175 assert!(
4180 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
4181 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
4182 model cooldown, got {remaining}s — may have used the generic short agent \
4183 cooldown instead"
4184 );
4185 }
4186
4187 fn test_review_task(id: &str) -> ExternalTask {
4190 ExternalTask {
4191 id: crate::backends::ExternalId(id.to_string()),
4192 title: "Test review task".to_string(),
4193 body: "".to_string(),
4194 state: "open".to_string(),
4195 labels: vec![],
4196 author: "tester".to_string(),
4197 created_at: "2026-01-01T00:00:00Z".to_string(),
4198 updated_at: "2026-01-01T00:00:00Z".to_string(),
4199 url: "https://example.com/issues/1".to_string(),
4200 }
4201 }
4202
4203 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
4204 ReviewContext {
4205 store_id: None,
4206 had_prev_push_failure: false,
4207 review_attempt: 1,
4208 worktree_path: work_dir.to_path_buf(),
4209 branch_name: "gh-issue-1-test".to_string(),
4210 pr_number: 1,
4211 head_sha: None,
4212 default_branch: "main".to_string(),
4213 review_agent: agent.to_string(),
4214 review_model: Some(model.to_string()),
4215 review_task_id: "review-1".to_string(),
4216 review_attempt_dir: work_dir.to_path_buf(),
4217 output_file: work_dir.join("output.txt"),
4218 invocation: runner::agent::AgentInvocation {
4219 agent: agent.to_string(),
4220 model: Some(model.to_string()),
4221 work_dir: work_dir.to_path_buf(),
4222 system_prompt: String::new(),
4223 agent_message: String::new(),
4224 task_id: "review-1".to_string(),
4225 disallowed_tools: vec![],
4226 git_author_name: "Test".to_string(),
4227 git_author_email: "test@example.com".to_string(),
4228 output_file: work_dir.join("output.txt"),
4229 timeout_seconds: 0,
4230 repo: "owner/repo".to_string(),
4231 attempt: 1,
4232 sandbox: false,
4233 main_project_dir: work_dir.to_path_buf(),
4234 branch: String::new(),
4235 },
4236 same_agent_fallback: false,
4237 }
4238 }
4239
4240 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
4241 ParsedReview {
4242 run_id: None,
4243 exit_code: 0,
4244 stderr: String::new(),
4245 raw_output: String::new(),
4246 text_for_review: String::new(),
4247 token_usage: RunTokenUsage::default(),
4248 review_notes_for_comment: String::new(),
4249 decision,
4250 }
4251 }
4252
4253 fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
4254 std::fs::write(dir.join("output.txt"), stdout).unwrap();
4255 std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
4256 std::fs::write(dir.join("stderr.txt"), "").unwrap();
4257 }
4258
4259 #[serial_test::serial(cooldown_state)]
4263 #[tokio::test]
4264 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
4265 crate::engine::cooldown::reset_global_state().await;
4266 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4267 let work_dir = TempDir::new().unwrap();
4268 let agent = "kimi";
4269 let model = "opus-3734-mid-thought";
4270 let ctx = test_review_context(agent, model, work_dir.path());
4271 let redraw = "^D\u{0008}".repeat(20);
4272 let ndjson = format!(
4273 "{}\n{}\n{}\n{}\n{}",
4274 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
4275 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}}}"#,
4276 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
4277 "\x1b[?25h",
4278 redraw,
4279 );
4280 write_review_output(work_dir.path(), &ndjson, 0);
4281
4282 let task = test_review_task("3734");
4283 let phase = parse_review_output(
4284 &task,
4285 "owner/repo",
4286 &ctx,
4287 ReviewRun { run_id: None },
4288 &store,
4289 )
4290 .await;
4291
4292 match phase {
4293 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4294 assert!(
4295 reason.contains("incomplete:"),
4296 "mid-thought review must be stored as incomplete, got {reason}"
4297 );
4298 }
4299 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4300 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4301 }
4302 assert!(
4303 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
4304 "mid-thought incomplete must not apply persistent model cooldown"
4305 );
4306 }
4307
4308 #[serial_test::serial(cooldown_state)]
4311 #[tokio::test]
4312 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
4313 crate::engine::cooldown::reset_global_state().await;
4314 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4315 let work_dir = TempDir::new().unwrap();
4316 let agent = "kimi";
4317 let model = "opus-3734-tool-use";
4318 let ctx = test_review_context(agent, model, work_dir.path());
4319 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
4320 write_review_output(work_dir.path(), ndjson, 0);
4321
4322 let task = test_review_task("3734-tool");
4323 let phase = parse_review_output(
4324 &task,
4325 "owner/repo",
4326 &ctx,
4327 ReviewRun { run_id: None },
4328 &store,
4329 )
4330 .await;
4331
4332 match phase {
4333 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4334 assert!(
4335 reason.contains("incomplete:"),
4336 "tool_use without envelope must be incomplete, got {reason}"
4337 );
4338 }
4339 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
4340 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
4341 }
4342 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4343 }
4344
4345 #[serial_test::serial(cooldown_state)]
4348 #[tokio::test]
4349 async fn parse_review_output_unparseable_envelope_applies_persistent_cooldown() {
4350 crate::engine::cooldown::reset_global_state().await;
4351 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4352 let work_dir = TempDir::new().unwrap();
4353 let agent = "kimi";
4354 let model = "opus-3734-format";
4355 let ctx = test_review_context(agent, model, work_dir.path());
4356 let ndjson = concat!(
4357 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
4358 "\n",
4359 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
4360 );
4361 write_review_output(work_dir.path(), ndjson, 0);
4362
4363 let task = test_review_task("3734-format");
4364 let phase = parse_review_output(
4365 &task,
4366 "owner/repo",
4367 &ctx,
4368 ReviewRun { run_id: None },
4369 &store,
4370 )
4371 .await;
4372
4373 match phase {
4374 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4375 assert!(
4376 reason.contains("parse error"),
4377 "finished unparseable envelope must stay a parse error, got {reason}"
4378 );
4379 }
4380 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4381 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
4382 }
4383 assert!(
4384 crate::engine::cooldown::is_model_in_cooldown(agent, model),
4385 "finished format failure must apply persistent model cooldown"
4386 );
4387 }
4388
4389 #[serial_test::serial(cooldown_state)]
4392 #[tokio::test]
4393 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
4394 crate::engine::cooldown::reset_global_state().await;
4395 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4396 let work_dir = TempDir::new().unwrap();
4397 let agent = "kimi";
4398 let model = "opus-3734-assistant";
4399 let ctx = test_review_context(agent, model, work_dir.path());
4400 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
4401 write_review_output(work_dir.path(), ndjson, 0);
4402
4403 let task = test_review_task("3734-asst");
4404 let phase = parse_review_output(
4405 &task,
4406 "owner/repo",
4407 &ctx,
4408 ReviewRun { run_id: None },
4409 &store,
4410 )
4411 .await;
4412
4413 match phase {
4414 ReviewPhase::Ready(parsed) => {
4415 assert!(matches!(parsed.decision, ReviewDecision::Approve { .. }));
4416 }
4417 ReviewPhase::EarlyReturn(_) => {
4418 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
4419 }
4420 }
4421 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4422 }
4423
4424 #[serial_test::serial(cooldown_state)]
4427 #[tokio::test]
4428 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
4429 crate::engine::cooldown::reset_global_state().await;
4430 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4431 let work_dir = TempDir::new().unwrap();
4432 let agent = "kimi";
4433 let model = "opus-3734-done";
4434 let ctx = test_review_context(agent, model, work_dir.path());
4435 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
4436 write_review_output(work_dir.path(), ndjson, 0);
4437
4438 let task = test_review_task("3734-done");
4439 let phase = parse_review_output(
4440 &task,
4441 "owner/repo",
4442 &ctx,
4443 ReviewRun { run_id: None },
4444 &store,
4445 )
4446 .await;
4447
4448 match phase {
4449 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4450 assert!(
4451 reason.contains("parse error"),
4452 "unfinished prose without a decision must not approve, got {reason}"
4453 );
4454 }
4455 ReviewPhase::Ready(_) => {
4456 panic!("task-path status=done synthesis must not become a review approval")
4457 }
4458 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4459 }
4460 }
4461
4462 #[serial_test::serial(cooldown_state)]
4468 #[tokio::test]
4469 async fn finalize_review_run_success_resets_generic_model_failure_count() {
4470 crate::engine::cooldown::reset_global_state().await;
4471 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4472 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4473
4474 let agent = "test-3478-agent";
4475 let model = "test-3478-model";
4476 let failure_key = format!("failure_count:{agent}:{model}");
4477
4478 let err = runner::agents::AgentError::AgentFailed {
4482 message: "agent reported a generic failure".to_string(),
4483 };
4484 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
4485 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4486 assert_eq!(
4487 store.kv_get(&failure_key).await.unwrap().as_deref(),
4488 Some("1"),
4489 "sanity check: failure should have incremented the persisted count"
4490 );
4491
4492 let work_dir = TempDir::new().unwrap();
4493 let ctx = test_review_context(agent, model, work_dir.path());
4494 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4495 let task = test_review_task("1");
4496
4497 finalize_review_run(&task, &ctx, &parsed, &store).await;
4498 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4499
4500 assert_eq!(
4501 store.kv_get(&failure_key).await.unwrap().as_deref(),
4502 Some("0"),
4503 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
4504 );
4505 }
4506
4507 #[serial_test::serial(cooldown_state)]
4513 #[tokio::test]
4514 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
4515 crate::engine::cooldown::reset_global_state().await;
4516 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4517 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4518
4519 let agent = "test-3478-interleave-agent";
4520 let model = "test-3478-interleave-model";
4521 let cooldown_key = format!("{agent}:{model}");
4522
4523 let work_dir = TempDir::new().unwrap();
4524 let ctx = test_review_context(agent, model, work_dir.path());
4525 let task = test_review_task("2");
4526
4527 for i in 0..4 {
4528 let err = runner::agents::AgentError::AgentFailed {
4529 message: format!("failure {i}"),
4530 };
4531 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
4532
4533 let now = chrono::Utc::now().timestamp();
4534 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4535 .expect("failure should set cooldown");
4536 let remaining = until.saturating_sub(now);
4537
4538 assert!(
4539 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
4540 "failure #{} must stay at base backoff since the prior success reset the \
4541 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
4542 here means review successes are not resetting failure_count",
4543 i + 1,
4544 crate::engine::cooldown::BACKOFF_BASE_SECS
4545 );
4546
4547 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4551 finalize_review_run(&task, &ctx, &parsed, &store).await;
4552 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4553 }
4554 }
4555
4556 #[serial_test::serial(cooldown_state)]
4562 #[tokio::test]
4563 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
4564 crate::engine::cooldown::reset_global_state().await;
4565 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4566 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4567
4568 let agent = "test-3571-agent";
4569 let model = "test-3571-model";
4570 let cooldown_key = format!("{agent}:{model}");
4571
4572 let work_dir = TempDir::new().unwrap();
4573 let ctx = test_review_context(agent, model, work_dir.path());
4574 let task = test_review_task("3");
4575
4576 let err = runner::agents::AgentError::InvalidResponse {
4578 raw: "empty review output".to_string(),
4579 };
4580 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
4581 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4582
4583 let parsed = test_parsed_review(ReviewDecision::Approve { issues: vec![] });
4586 finalize_review_run(&task, &ctx, &parsed, &store).await;
4587 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4588
4589 let err2 = runner::agents::AgentError::InvalidResponse {
4592 raw: "empty review output again".to_string(),
4593 };
4594 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
4595 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4596
4597 let now = chrono::Utc::now().timestamp();
4598 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4599 .expect("second failure should set cooldown");
4600 let remaining = until.saturating_sub(now);
4601
4602 assert!(
4603 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
4604 "second persistent model failure should escalate past the 4h base despite an \
4605 intervening success (issue #3571); got {remaining}s, base is {}s",
4606 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
4607 );
4608 }
4609
4610 fn app_for_test(server: &wiremock::MockServer) -> Arc<crate::github::app::AppAuth> {
4611 Arc::new(crate::github::app::AppAuth::new(
4612 "12345".to_string(),
4613 crate::github::app::test_pem().to_string(),
4614 server.uri(),
4615 ))
4616 }
4617
4618 async fn mount_installed(server: &wiremock::MockServer, repo: &str) {
4619 use wiremock::matchers::{method, path};
4620 use wiremock::{Mock, ResponseTemplate};
4621 Mock::given(method("GET"))
4622 .and(path(format!("/repos/{repo}/installation")))
4623 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4624 .mount(server)
4625 .await;
4626 Mock::given(method("POST"))
4627 .and(path("/app/installations/42/access_tokens"))
4628 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4629 "token": "inst-1",
4630 "expires_at": "2099-01-01T00:00:00Z",
4631 })))
4632 .mount(server)
4633 .await;
4634 }
4635
4636 #[tokio::test]
4637 async fn app_mode_falls_back_to_comments_where_the_app_is_not_installed() {
4638 use crate::review_mode::ReviewMode;
4639 use wiremock::matchers::{method, path};
4640 use wiremock::{Mock, MockServer, ResponseTemplate};
4641 let server = MockServer::start().await;
4642 Mock::given(method("GET"))
4643 .and(path("/repos/org/b/installation"))
4644 .respond_with(ResponseTemplate::new(404))
4645 .expect(1)
4646 .mount(&server)
4647 .await;
4648 let app = app_for_test(&server);
4649
4650 assert!(
4651 review_app_for(ReviewMode::App, Some(Arc::clone(&app)), "org/b")
4652 .await
4653 .is_none()
4654 );
4655 assert!(
4657 review_app_for(ReviewMode::Comment, Some(Arc::clone(&app)), "org/b")
4658 .await
4659 .is_none()
4660 );
4661 assert!(review_app_for(ReviewMode::App, None, "org/b")
4662 .await
4663 .is_none());
4664 }
4665
4666 #[tokio::test]
4667 async fn the_check_run_starts_and_completes_on_the_reviewed_sha() {
4668 use wiremock::matchers::{body_partial_json, method, path};
4669 use wiremock::{Mock, MockServer, ResponseTemplate};
4670 let server = MockServer::start().await;
4671 mount_installed(&server, "o/r").await;
4672 Mock::given(method("POST"))
4673 .and(path("/repos/o/r/check-runs"))
4674 .and(body_partial_json(serde_json::json!({
4675 "name": "orch/review", "head_sha": "abc123", "status": "in_progress",
4676 })))
4677 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4678 .expect(1)
4679 .mount(&server)
4680 .await;
4681 Mock::given(method("POST"))
4682 .and(path("/repos/o/r/check-runs"))
4683 .and(body_partial_json(serde_json::json!({
4684 "name": "orch/review", "head_sha": "abc123", "status": "completed",
4685 "conclusion": "failure", "output": {"summary": "fix it"},
4686 })))
4687 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4688 .expect(1)
4689 .mount(&server)
4690 .await;
4691 let app = app_for_test(&server);
4692 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4693 let decision = ReviewDecision::RequestChanges {
4694 notes: "fix it".to_string(),
4695 issues: vec![],
4696 };
4697
4698 post_review_started(Some(&app), &gh, "o/r", Some("abc123")).await;
4699 post_review_decision(Some(&app), &gh, "o/r", Some("abc123"), &decision, "fix it").await;
4700 let requests = server.received_requests().await.expect("requests");
4701 assert!(requests
4702 .iter()
4703 .all(|req| !req.url.path().contains("/statuses/")));
4704 }
4705
4706 #[tokio::test]
4707 async fn issues_on_diff_lines_go_inline_and_the_rest_stay_in_the_body() {
4708 use crate::engine::runner::response::ReviewIssue;
4709 use wiremock::matchers::{method, path, query_param};
4710 use wiremock::{Mock, MockServer, ResponseTemplate};
4711 let server = MockServer::start().await;
4712 mount_installed(&server, "o/r").await;
4713 Mock::given(method("GET"))
4714 .and(path("/repos/o/r/pulls/9/files"))
4715 .and(query_param("page", "1"))
4716 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
4717 {"filename": "src/a.rs", "patch": "@@ -1,2 +10,3 @@\n ctx\n+new\n+new"},
4718 ])))
4719 .expect(1)
4720 .mount(&server)
4721 .await;
4722 Mock::given(method("POST"))
4723 .and(path("/repos/o/r/pulls/9/reviews"))
4724 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4725 .expect(1)
4726 .mount(&server)
4727 .await;
4728 Mock::given(method("POST"))
4729 .and(path("/repos/o/r/issues/9/comments"))
4730 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4731 .mount(&server)
4732 .await;
4733 let issue = |file: &str, line: Option<u32>| ReviewIssue {
4734 file: file.to_string(),
4735 line,
4736 severity: "error".to_string(),
4737 description: "wrong".to_string(),
4738 };
4739 let decision = ReviewDecision::RequestChanges {
4740 notes: "fix".to_string(),
4741 issues: vec![
4742 issue("src/a.rs", Some(11)),
4743 issue("src/a.rs", Some(50)),
4744 issue("src/b.rs", Some(3)),
4745 issue("src/a.rs", None),
4746 ],
4747 };
4748 let app = app_for_test(&server);
4749 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4750
4751 publish_review(Some(&app), &gh, "o/r", 9, Some("abc123"), &decision, "body")
4752 .await
4753 .expect("comment posted");
4754 let requests = server.received_requests().await.expect("requests");
4755 let review = requests
4756 .iter()
4757 .find(|req| req.url.path().ends_with("/reviews"))
4758 .expect("review submitted");
4759 let body: serde_json::Value = serde_json::from_slice(&review.body).unwrap();
4760 assert_eq!(
4761 body["comments"],
4762 serde_json::json!([{"path": "src/a.rs", "line": 11, "side": "RIGHT", "body": "**error:** wrong"}])
4763 );
4764 }
4765
4766 #[allow(clippy::await_holding_lock)]
4767 #[tokio::test]
4768 async fn without_an_app_the_review_path_posts_only_the_comment() {
4769 use wiremock::matchers::{method, path};
4770 use wiremock::{Mock, MockServer, ResponseTemplate};
4771 let _env = crate::github::token::ENV_LOCK.lock().unwrap();
4772 let prev = std::env::var_os("GH_TOKEN");
4773 std::env::set_var("GH_TOKEN", "owner-token");
4774 let server = MockServer::start().await;
4775 Mock::given(method("POST"))
4776 .and(path("/repos/o/r/issues/9/comments"))
4777 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 1})))
4778 .expect(1)
4779 .mount(&server)
4780 .await;
4781 let gh = GhHttp::for_test(server.uri(), None);
4782 let result = publish_review(
4783 None,
4784 &gh,
4785 "o/r",
4786 9,
4787 Some("abc123"),
4788 &ReviewDecision::Approve { issues: vec![] },
4789 "## Automated Review \u{2014} Approve\n\nok",
4790 )
4791 .await;
4792 match prev {
4793 Some(value) => std::env::set_var("GH_TOKEN", value),
4794 None => std::env::remove_var("GH_TOKEN"),
4795 }
4796 result.expect("comment posted");
4797 let requests = server.received_requests().await.expect("requests");
4798 assert!(requests
4799 .iter()
4800 .all(|req| !req.url.path().contains("/reviews")));
4801 }
4802
4803 #[tokio::test]
4804 async fn with_an_app_the_review_is_submitted_on_the_reviewed_sha() {
4805 use wiremock::matchers::{body_partial_json, method, path};
4806 use wiremock::{Mock, MockServer, ResponseTemplate};
4807 let server = MockServer::start().await;
4808 Mock::given(method("POST"))
4809 .and(path("/app/installations/42/access_tokens"))
4810 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
4811 "token": "inst-1",
4812 "expires_at": "2099-01-01T00:00:00Z",
4813 })))
4814 .mount(&server)
4815 .await;
4816 Mock::given(method("POST"))
4817 .and(path("/repos/o/r/pulls/9/reviews"))
4818 .and(body_partial_json(serde_json::json!({
4819 "event": "REQUEST_CHANGES",
4820 "commit_id": "abc123",
4821 })))
4822 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
4823 .expect(1)
4824 .mount(&server)
4825 .await;
4826 Mock::given(method("POST"))
4827 .and(path("/repos/o/r/issues/9/comments"))
4828 .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 2})))
4829 .expect(1)
4830 .mount(&server)
4831 .await;
4832 Mock::given(method("GET"))
4833 .and(path("/repos/o/r/installation"))
4834 .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
4835 .mount(&server)
4836 .await;
4837 let app = Arc::new(crate::github::app::AppAuth::new(
4838 "12345".to_string(),
4839 crate::github::app::test_pem().to_string(),
4840 server.uri(),
4841 ));
4842 let gh = GhHttp::for_test(server.uri(), Some(Arc::clone(&app)));
4843 let decision = ReviewDecision::RequestChanges {
4844 notes: "fix it".to_string(),
4845 issues: vec![],
4846 };
4847 publish_review(
4848 Some(&app),
4849 &gh,
4850 "o/r",
4851 9,
4852 Some("abc123"),
4853 &decision,
4854 "## Automated Review \u{2014} Changes Requested\n\nfix it",
4855 )
4856 .await
4857 .expect("comment posted");
4858 }
4859}