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