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 review_prompt = runner::agent::build_review_prompt(
728 task,
729 &agent_summary,
730 &git_diff,
731 &git_log,
732 &default_branch,
733 pr_number,
734 runner::agent::Shipcheck::detect(&worktree_path).await,
735 );
736
737 let task_agent = stored_task
738 .as_ref()
739 .and_then(|t| t.agent.clone())
740 .unwrap_or_default();
741 let (review_agent, review_model, same_agent_fallback) =
742 select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
743
744 anyhow::ensure!(
745 review_model.is_some(),
746 "review model missing for task {} after agent selection — refusing to dispatch",
747 task.id.0
748 );
749
750 if same_agent_fallback {
751 tracing::warn!(
752 task_id = task.id.0,
753 agent = %review_agent,
754 "no other agent available for review — falling back to the agent that wrote the code"
755 );
756 store_log_activity(
757 &Some(Arc::clone(store)),
758 repo,
759 &task.id.0,
760 "review_agent_fallback",
761 None,
762 None,
763 Some(&review_agent),
764 review_model.as_deref(),
765 Some(&serde_json::json!({
766 "reason": "all available agents excluded (author + previous reviewers)",
767 })),
768 )
769 .await;
770 }
771
772 tracing::info!(
773 task_id = task.id.0,
774 agent = %review_agent,
775 model = ?review_model,
776 "spawning review agent"
777 );
778
779 let review_task_id = format!("{}-review", task.id.0);
780 let review_attempt_dir =
781 crate::home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
782 let output_file = review_attempt_dir.join("output.json");
783
784 let git_name =
785 crate::config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
786 let git_email = crate::config::get("git.email")
787 .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
788 let review_timeout_secs: u64 = crate::config::get("workflow.review_timeout_seconds")
789 .ok()
790 .and_then(|s| s.parse().ok())
791 .unwrap_or_else(|| {
792 crate::config::get("workflow.timeout_seconds")
793 .ok()
794 .and_then(|s| s.parse().ok())
795 .unwrap_or(1800)
796 })
797 .max(1800);
798
799 let invocation = runner::agent::AgentInvocation {
800 agent: review_agent.clone(),
801 model: review_model.clone(),
802 work_dir: worktree_path.clone(),
803 system_prompt: runner::agent::review_system_prompt(),
804 agent_message: review_prompt,
805 task_id: review_task_id.clone(),
806 disallowed_tools: build_review_disallowed_tools(&worktree_path),
807 git_author_name: git_name,
808 git_author_email: git_email,
809 output_file: output_file.clone(),
810 timeout_seconds: review_timeout_secs,
811 repo: repo.to_string(),
812 attempt: review_attempt,
813 };
814
815 let head_sha = match GhHttp::new() {
816 Ok(gh) => gh.get_pr(repo, pr_number).await.ok().map(|pr| pr.head.sha),
817 Err(_) => None,
818 };
819
820 Ok(ReviewPhase::Ready(ReviewContext {
821 store_id,
822 had_prev_push_failure,
823 review_attempt,
824 worktree_path,
825 branch_name,
826 pr_number,
827 head_sha,
828 default_branch,
829 review_agent,
830 review_model,
831 review_task_id,
832 review_attempt_dir,
833 output_file,
834 invocation,
835 same_agent_fallback,
836 }))
837}
838
839#[allow(clippy::too_many_arguments)]
840async fn complete_review_run(
841 store: &Arc<TaskStore>,
842 run_id: Option<i64>,
843 exit_code: Option<i32>,
844 stdout: &str,
845 stderr: &str,
846 parsed: &str,
847 outcome: &str,
848 error: &str,
849 tokens: RunTokenUsage,
850) {
851 if let Some(run_id) = run_id {
852 if let Err(e) = store
853 .complete_run(&CompleteRun {
854 run_id,
855 exit_code,
856 stdout,
857 stderr,
858 parsed,
859 outcome,
860 error,
861 tokens,
862 })
863 .await
864 {
865 tracing::warn!(
866 run_id,
867 error = %e,
868 "failed to record review run completion in audit trail"
869 );
870 }
871 }
872}
873
874async fn invoke_review_agent(
875 task: &ExternalTask,
876 tmux: &Arc<TmuxManager>,
877 ctx: &ReviewContext,
878 repo: &str,
879 store: &Arc<TaskStore>,
880) -> ReviewPhase<ReviewRun> {
881 let run_id = if let Some(sid) = ctx.store_id {
882 match store
883 .start_run(&StartRun {
884 task_id: sid,
885 attempt: ctx.review_attempt as i32,
886 run_type: "review",
887 agent: &ctx.review_agent,
888 model: ctx.review_model.as_deref().unwrap_or(""),
889 command: "",
890 prompt: "",
891 })
892 .await
893 {
894 Ok(run_id) => Some(run_id),
895 Err(e) => {
896 tracing::warn!(
897 task_id = task.id.0,
898 error = %e,
899 "failed to record review run start in audit trail"
900 );
901 None
902 }
903 }
904 } else {
905 None
906 };
907
908 let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
909 Ok(session) => session,
910 Err(e) => {
911 tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
912 complete_review_run(
913 store,
914 run_id,
915 None,
916 "",
917 &e.to_string(),
918 "",
919 "failed",
920 &format!("spawn failed: {e}"),
921 RunTokenUsage::default(),
922 )
923 .await;
924 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
925 }
926 };
927
928 let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
929 match GhHttp::new() {
930 Ok(gh) => {
931 post_review_status(
932 &gh,
933 repo,
934 ctx.head_sha.as_deref(),
935 "pending",
936 "Automated review running",
937 )
938 .await;
939 if let Err(e) = gh
940 .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
941 .await
942 {
943 tracing::debug!(
944 task_id = task.id.0,
945 pr_number = ctx.pr_number,
946 error = %e,
947 "failed to post review-started comment"
948 );
949 }
950 }
951 Err(e) => {
952 tracing::debug!(
953 task_id = task.id.0,
954 pr_number = ctx.pr_number,
955 error = %e,
956 "failed to create GitHub client for review-started comment"
957 );
958 }
959 }
960
961 let poll_interval = std::time::Duration::from_secs(5);
962 let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
963 let wait_result = tokio::time::timeout(
964 timeout_duration,
965 tmux.wait_for_completion(&session, poll_interval),
966 )
967 .await;
968
969 match wait_result {
970 Ok(Ok(_)) => {
971 tracing::info!(task_id = task.id.0, "review agent completed");
972 let _ = tmux.kill_session(&session).await;
973 ReviewPhase::Ready(ReviewRun { run_id })
974 }
975 Ok(Err(e)) => {
976 tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
977 let _ = tmux.kill_session(&session).await;
978
979 let exit_code: i32 = tokio::task::spawn_blocking({
984 let review_attempt_dir = ctx.review_attempt_dir.clone();
985 move || {
986 std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
987 .ok()
988 .and_then(|s| s.trim().parse().ok())
989 .unwrap_or(-1)
990 }
991 })
992 .await
993 .unwrap_or(-1);
994
995 if exit_code != 0 {
996 let raw_output =
1001 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
1002 .await;
1003 let agent_result =
1004 runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1005 if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
1007 tracing::info!(
1008 task_id = task.id.0,
1009 agent = %ctx.review_agent,
1010 exit_code,
1011 output_found = !raw_output.is_empty(),
1012 "review agent succeeded despite non-zero exit — proceeding to parse output"
1013 );
1014 return ReviewPhase::Ready(ReviewRun { run_id });
1015 }
1016 }
1017
1018 tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
1019 complete_review_run(
1020 store,
1021 run_id,
1022 Some(exit_code),
1023 "",
1024 &e.to_string(),
1025 "",
1026 "failed",
1027 &format!("agent error: {e}"),
1028 RunTokenUsage::default(),
1029 )
1030 .await;
1031 ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
1032 }
1033 Err(_) => {
1034 tracing::error!(task_id = task.id.0, "review agent timed out");
1035 let _ = tmux.kill_session(&session).await;
1036 if let Some(model) = ctx.review_model.as_deref() {
1037 crate::engine::cooldown::record_persistent_model_failure(&ctx.review_agent, model)
1042 .await;
1043 }
1044 complete_review_run(
1045 store,
1046 run_id,
1047 Some(-1),
1048 "",
1049 "timeout",
1050 "",
1051 "timeout",
1052 "timeout",
1053 RunTokenUsage::default(),
1054 )
1055 .await;
1056 ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
1057 }
1058 }
1059}
1060
1061async fn record_review_agent_failure(
1062 task_id: &str,
1063 review_agent: &str,
1064 review_model: Option<&str>,
1065 err: &runner::agents::AgentError,
1066) {
1067 match err {
1068 runner::agents::AgentError::RateLimit { message }
1069 | runner::agents::AgentError::Auth { message } => {
1070 tracing::warn!(
1071 task_id,
1072 agent = %review_agent,
1073 "review agent hit error — adding to cooldown"
1074 );
1075 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
1076 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
1077 {
1078 if let Some(model) = review_model {
1079 crate::engine::cooldown::record_persistent_model_failure(
1081 review_agent,
1082 model,
1083 )
1084 .await;
1085 } else {
1086 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
1087 .await;
1088 }
1089 } else {
1090 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
1091 }
1092 } else {
1093 runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
1094 .await;
1095 }
1096 }
1097 runner::agents::AgentError::InvalidResponse { .. } => {
1098 if let Some(model) = review_model {
1103 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1104 }
1105 }
1106 runner::agents::AgentError::ModelUnavailable { .. } => {
1107 if let Some(model) = review_model {
1112 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
1113 }
1114 }
1115 _ => {
1116 if let Some(model) = review_model {
1119 crate::engine::cooldown::record_model_failure(review_agent, model).await;
1120 }
1121 }
1122 }
1123}
1124
1125fn review_decision_from_response(
1126 review_response: runner::response::ReviewResponse,
1127) -> ReviewDecision {
1128 match review_response.decision.as_str() {
1129 "approve" => ReviewDecision::Approve,
1130 "request_changes" => ReviewDecision::RequestChanges {
1131 notes: review_response.notes,
1132 issues: review_response.issues,
1133 },
1134 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
1135 }
1136}
1137
1138fn review_decision_name(decision: &ReviewDecision) -> &'static str {
1139 match decision {
1140 ReviewDecision::Approve => "approve",
1141 ReviewDecision::RequestChanges { .. } => "request_changes",
1142 ReviewDecision::Failed(_) => "failed",
1143 ReviewDecision::Blocked(_) => "blocked",
1144 ReviewDecision::Skipped => "skipped",
1145 ReviewDecision::Rerouted => "rerouted",
1146 }
1147}
1148
1149async fn parse_review_output(
1150 task: &ExternalTask,
1151 repo: &str,
1152 ctx: &ReviewContext,
1153 run: ReviewRun,
1154 store: &Arc<TaskStore>,
1155) -> ReviewPhase<ParsedReview> {
1156 let (file_exists, file_size, exit_code, stderr) = {
1157 let output_file_clone = ctx.output_file.clone();
1158 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
1159 match tokio::task::spawn_blocking(move || {
1160 let file_exists = output_file_clone.exists();
1161 let file_size = std::fs::metadata(&output_file_clone)
1162 .map(|m| m.len())
1163 .unwrap_or(0);
1164 let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1165 .ok()
1166 .and_then(|s| s.trim().parse::<i32>().ok())
1167 .unwrap_or(-1);
1168 let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1169 .unwrap_or_default();
1170 (file_exists, file_size, exit_code, stderr)
1171 })
1172 .await
1173 {
1174 Ok(tuple) => tuple,
1175 Err(e) => {
1176 tracing::error!(
1177 task_id = task.id.0,
1178 error = %e,
1179 "spawn_blocking panicked reading review output metadata"
1180 );
1181 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1182 }
1183 }
1184 };
1185
1186 tracing::info!(
1187 task_id = task.id.0,
1188 path = %ctx.output_file.display(),
1189 file_exists,
1190 file_size,
1191 "reading review agent output"
1192 );
1193
1194 let raw_output =
1195 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1196 let raw_output = runner::agents::claude::strip_tty_control_lines(&raw_output);
1197 let has_result_envelope =
1198 runner::agents::claude::find_ndjson_result_line(&raw_output).is_some();
1199 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1200 let token_usage = RunTokenUsage {
1201 input_tokens: agent_result
1202 .as_ref()
1203 .and_then(|r| r.input_tokens)
1204 .unwrap_or(0),
1205 output_tokens: agent_result
1206 .as_ref()
1207 .and_then(|r| r.output_tokens)
1208 .unwrap_or(0),
1209 total_cost_usd: agent_result
1210 .as_ref()
1211 .and_then(|r| r.cost_usd)
1212 .unwrap_or(0.0),
1213 duration_secs: 0.0,
1214 };
1215
1216 let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1217 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1218 && !raw_output.contains("\"is_error\":true");
1219 let is_hard_failure =
1221 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1222
1223 if is_hard_failure {
1224 tracing::warn!(
1225 task_id = task.id.0,
1226 exit_code,
1227 raw_output_len = raw_output.len(),
1228 stderr_len = stderr.len(),
1229 "review agent: entering error path"
1230 );
1231 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1232 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1233 record_review_agent_failure(
1234 &task.id.0,
1235 &ctx.review_agent,
1236 ctx.review_model.as_deref(),
1237 &err,
1238 )
1239 .await;
1240 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1241 let stored_error = format_review_error(&ctx.review_agent, &err);
1242 complete_review_run(
1243 store,
1244 run.run_id,
1245 Some(exit_code),
1246 &raw_output,
1247 &stderr,
1248 "",
1249 outcome_for_agent_error(&err),
1250 &stored_error,
1251 token_usage,
1252 )
1253 .await;
1254 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1255 }
1256
1257 let text_for_review = agent_result
1258 .as_ref()
1259 .map(|r| r.result_text.clone())
1260 .filter(|text| !text.is_empty())
1261 .unwrap_or_else(|| {
1262 tracing::debug!(
1263 task_id = task.id.0,
1264 agent = %ctx.review_agent,
1265 "review agent: per-agent extractor returned no text, using raw output"
1266 );
1267 raw_output.clone()
1268 });
1269
1270 let review_response = match runner::response::parse_review_response(&text_for_review) {
1271 Ok(response) => response,
1272 Err(e) => {
1273 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1274 tracing::warn!(
1275 task_id = task.id.0,
1276 agent = %ctx.review_agent,
1277 "review response parsed via keyword fallback"
1278 );
1279 response
1280 } else {
1281 let ndjson_completed = ndjson_completed_no_error;
1287
1288 if ndjson_completed || !has_result_envelope {
1291 let assistant_text = runner::agents::collect_assistant_messages_text(
1292 &ctx.review_agent,
1293 &raw_output,
1294 );
1295 if !assistant_text.is_empty() {
1296 if let Ok(response) =
1297 runner::response::parse_review_response(&assistant_text)
1298 {
1299 tracing::debug!(
1300 task_id = task.id.0,
1301 agent = %ctx.review_agent,
1302 "found review response JSON in assistant message (terminal_reason:completed)"
1303 );
1304 let review_notes_for_comment = response.notes.clone();
1305 let decision = review_decision_from_response(response);
1306 return ReviewPhase::Ready(ParsedReview {
1307 run_id: run.run_id,
1308 exit_code,
1309 stderr,
1310 raw_output,
1311 text_for_review: assistant_text,
1312 token_usage,
1313 review_notes_for_comment,
1314 decision,
1315 });
1316 }
1317 if let Some(response) =
1318 runner::response::infer_review_response(&assistant_text)
1319 {
1320 tracing::warn!(
1321 task_id = task.id.0,
1322 agent = %ctx.review_agent,
1323 "inferred review response from assistant message (terminal_reason:completed)"
1324 );
1325 let review_notes_for_comment = response.notes.clone();
1326 let decision = review_decision_from_response(response);
1327 return ReviewPhase::Ready(ParsedReview {
1328 run_id: run.run_id,
1329 exit_code,
1330 stderr,
1331 raw_output,
1332 text_for_review: assistant_text,
1333 token_usage,
1334 review_notes_for_comment,
1335 decision,
1336 });
1337 }
1338 }
1339 }
1340
1341 let scan_head = runner::agents::patterns::safe_head(
1348 &text_for_review,
1349 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1350 );
1351 let scan_tail = runner::agents::patterns::safe_tail(
1352 &text_for_review,
1353 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1354 );
1355
1356 if let Some(err) = (!ndjson_completed)
1357 .then(|| {
1358 runner::agents::patterns::detect_rate_limit(scan_head)
1359 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1360 })
1361 .flatten()
1362 {
1363 let runner::agents::AgentError::RateLimit { message } = &err else {
1364 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1365 };
1366 tracing::warn!(
1367 task_id = task.id.0,
1368 agent = %ctx.review_agent,
1369 "review agent hit rate limit — adding to cooldown"
1370 );
1371 let summary = runner::fallback::summarize_rate_limit_error(message);
1378 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1379 record_review_agent_failure(
1380 &task.id.0,
1381 &ctx.review_agent,
1382 ctx.review_model.as_deref(),
1383 &err,
1384 )
1385 .await;
1386 complete_review_run(
1387 store,
1388 run.run_id,
1389 Some(exit_code),
1390 &raw_output,
1391 &stderr,
1392 &text_for_review,
1393 outcome_for_agent_error(&err),
1394 &stored_error,
1395 token_usage,
1396 )
1397 .await;
1398 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1399 }
1400
1401 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1402 let runner::agents::AgentError::Auth { message } = &err else {
1403 unreachable!("detect_auth_error only returns AgentError::Auth")
1404 };
1405 tracing::warn!(
1406 task_id = task.id.0,
1407 agent = %ctx.review_agent,
1408 "review agent hit auth error — adding to cooldown"
1409 );
1410 let stored_error = format!("auth error: {message}");
1411 record_review_agent_failure(
1412 &task.id.0,
1413 &ctx.review_agent,
1414 ctx.review_model.as_deref(),
1415 &err,
1416 )
1417 .await;
1418 complete_review_run(
1419 store,
1420 run.run_id,
1421 Some(exit_code),
1422 &raw_output,
1423 &stderr,
1424 &text_for_review,
1425 outcome_for_agent_error(&err),
1426 &stored_error,
1427 token_usage,
1428 )
1429 .await;
1430 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1431 }
1432
1433 if agent_result
1440 .as_ref()
1441 .map(|r| r.truncated_by_length)
1442 .unwrap_or(false)
1443 {
1444 tracing::warn!(
1445 task_id = task.id.0,
1446 agent = %ctx.review_agent,
1447 model = ?ctx.review_model,
1448 "review agent output truncated: step_finish reason=length \
1449 (token budget exceeded before completion)"
1450 );
1451 if let Some(model) = ctx.review_model.as_deref() {
1452 crate::engine::cooldown::record_persistent_model_failure(
1453 &ctx.review_agent,
1454 model,
1455 )
1456 .await;
1457 }
1458 let stored_error = "truncated: review output was cut off before completion \
1459 (output/reasoning token budget exceeded)"
1460 .to_string();
1461 complete_review_run(
1462 store,
1463 run.run_id,
1464 Some(exit_code),
1465 &raw_output,
1466 &stderr,
1467 &text_for_review,
1468 "truncated",
1469 &stored_error,
1470 token_usage,
1471 )
1472 .await;
1473 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1474 }
1475
1476 if runner::agents::claude::review_session_exited_mid_thought(&raw_output) {
1479 tracing::warn!(
1480 task_id = task.id.0,
1481 agent = %ctx.review_agent,
1482 model = ?ctx.review_model,
1483 "review session exited mid-thought with no result envelope \
1484 — storing as incomplete and retrying without persistent cooldown"
1485 );
1486 let stored_error =
1487 "incomplete: review session exited before producing a decision".to_string();
1488 complete_review_run(
1489 store,
1490 run.run_id,
1491 Some(exit_code),
1492 &raw_output,
1493 &stderr,
1494 &text_for_review,
1495 "incomplete",
1496 &stored_error,
1497 token_usage,
1498 )
1499 .await;
1500 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1501 }
1502
1503 tracing::error!(
1504 task_id = task.id.0,
1505 error = %e,
1506 agent = %ctx.review_agent,
1507 output = %text_for_review.chars().take(300).collect::<String>(),
1508 raw_output = %raw_output.chars().take(1000).collect::<String>(),
1509 "failed to parse review response"
1510 );
1511 if let Some(model) = ctx.review_model.as_deref() {
1512 crate::engine::cooldown::record_persistent_model_failure(
1515 &ctx.review_agent,
1516 model,
1517 )
1518 .await;
1519 }
1520 complete_review_run(
1521 store,
1522 run.run_id,
1523 Some(exit_code),
1524 &raw_output,
1525 &stderr,
1526 &text_for_review,
1527 "parse_error",
1528 &format!("parse error: {e}"),
1529 token_usage,
1530 )
1531 .await;
1532 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1533 "parse error: {e}"
1534 )));
1535 }
1536 }
1537 };
1538
1539 let review_notes_for_comment = review_response.notes.clone();
1540 let decision = review_decision_from_response(review_response);
1541
1542 tracing::info!(
1543 task_id = task.id.0,
1544 pr_number = ctx.pr_number,
1545 decision = ?decision,
1546 "review agent decision received"
1547 );
1548 store_log_activity(
1549 &Some(Arc::clone(store)),
1550 repo,
1551 &task.id.0,
1552 "review_decision",
1553 None,
1554 None,
1555 Some(&ctx.review_agent),
1556 ctx.review_model.as_deref(),
1557 Some(&serde_json::json!({
1558 "decision": review_decision_name(&decision),
1559 "pr_number": ctx.pr_number,
1560 })),
1561 )
1562 .await;
1563
1564 ReviewPhase::Ready(ParsedReview {
1565 run_id: run.run_id,
1566 exit_code,
1567 stderr,
1568 raw_output,
1569 text_for_review,
1570 token_usage,
1571 review_notes_for_comment,
1572 decision,
1573 })
1574}
1575
1576async fn finalize_review_run(
1577 task: &ExternalTask,
1578 ctx: &ReviewContext,
1579 parsed: &ParsedReview,
1580 store: &Arc<TaskStore>,
1581) {
1582 let outcome = match &parsed.decision {
1583 ReviewDecision::Approve
1584 | ReviewDecision::RequestChanges { .. }
1585 | ReviewDecision::Skipped
1586 | ReviewDecision::Rerouted => "success",
1587 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
1588 };
1589 let error = match &parsed.decision {
1590 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
1591 _ => "",
1592 };
1593
1594 if outcome == "success" {
1595 crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
1598 .await;
1599 }
1600
1601 complete_review_run(
1602 store,
1603 parsed.run_id,
1604 Some(parsed.exit_code),
1605 &parsed.raw_output,
1606 &parsed.stderr,
1607 &parsed.text_for_review,
1608 outcome,
1609 error,
1610 parsed.token_usage,
1611 )
1612 .await;
1613
1614 if let Some(store_id) = ctx.store_id {
1615 if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
1616 let model = if ctx.review_model_str().is_empty() {
1617 "unknown"
1618 } else {
1619 ctx.review_model_str()
1620 };
1621 if let Err(e) = store
1622 .store_tokens(
1623 store_id,
1624 parsed.token_usage.input_tokens,
1625 parsed.token_usage.output_tokens,
1626 model,
1627 )
1628 .await
1629 {
1630 tracing::warn!(
1631 task_id = task.id.0,
1632 ?e,
1633 "failed to store review agent token usage"
1634 );
1635 }
1636 }
1637 }
1638}
1639
1640async fn restore_review_config_if_needed(
1641 task_id: &str,
1642 worktree_path: &std::path::Path,
1643 default_branch: &str,
1644) {
1645 let _ = Command::new("git")
1646 .args(["checkout", "HEAD", "--", ".orch.yml"])
1647 .current_dir(worktree_path)
1648 .status()
1649 .await;
1650
1651 let merge_base = Command::new("git")
1652 .args(["merge-base", "HEAD", default_branch])
1653 .current_dir(worktree_path)
1654 .output()
1655 .await
1656 .ok()
1657 .filter(|output| output.status.success())
1658 .and_then(|output| String::from_utf8(output.stdout).ok())
1659 .map(|s| s.trim().to_string())
1660 .unwrap_or_default();
1661
1662 if merge_base.is_empty() {
1663 return;
1664 }
1665
1666 let orch_yml_changed = Command::new("git")
1667 .args([
1668 "diff",
1669 "--name-only",
1670 &merge_base,
1671 "HEAD",
1672 "--",
1673 ".orch.yml",
1674 ])
1675 .current_dir(worktree_path)
1676 .output()
1677 .await
1678 .ok()
1679 .filter(|output| output.status.success())
1680 .map(|output| !output.stdout.is_empty())
1681 .unwrap_or(false);
1682
1683 if !orch_yml_changed {
1684 return;
1685 }
1686
1687 tracing::warn!(
1688 task_id,
1689 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
1690 );
1691 let restore_ok = Command::new("git")
1692 .args(["checkout", &merge_base, "--", ".orch.yml"])
1693 .current_dir(worktree_path)
1694 .status()
1695 .await
1696 .map(|status| status.success())
1697 .unwrap_or(false);
1698
1699 if !restore_ok {
1700 return;
1701 }
1702
1703 let staged = Command::new("git")
1704 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
1705 .current_dir(worktree_path)
1706 .output()
1707 .await
1708 .ok()
1709 .filter(|output| output.status.success())
1710 .map(|output| !output.stdout.is_empty())
1711 .unwrap_or(false);
1712
1713 if staged {
1714 let _ = Command::new("git")
1715 .args([
1716 "-c",
1717 "commit.gpgsign=false",
1718 "commit",
1719 "-m",
1720 "revert: restore .orch.yml (review agent must not modify project config)",
1721 ])
1722 .current_dir(worktree_path)
1723 .status()
1724 .await;
1725 }
1726}
1727
1728fn is_missing_path_error_message(err: &str) -> bool {
1729 err.to_ascii_lowercase()
1730 .contains("no such file or directory")
1731}
1732
1733async fn push_review_branch(
1734 task: &ExternalTask,
1735 repo: &str,
1736 ctx: &ReviewContext,
1737 store: &Arc<TaskStore>,
1738) -> Result<(), String> {
1739 let mut worktree_path = ctx.worktree_path.clone();
1740 let mut branch_name = ctx.branch_name.clone();
1741 let mut retried_missing_worktree = false;
1742
1743 let push_result = loop {
1744 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
1745 {
1746 Ok(ok) => break Ok(ok),
1747 Err(e)
1748 if !retried_missing_worktree
1749 && !worktree_path.exists()
1750 && is_missing_path_error_message(&e.to_string()) =>
1751 {
1752 retried_missing_worktree = true;
1753 tracing::warn!(
1754 task_id = task.id.0,
1755 worktree = %worktree_path.display(),
1756 branch = %branch_name,
1757 "review push failed because worktree disappeared; attempting one-time recovery"
1758 );
1759 match recover_missing_review_worktree(task, repo, store).await {
1760 Ok(recovered) => {
1761 worktree_path = recovered.work_dir;
1762 branch_name = recovered.branch;
1763 continue;
1764 }
1765 Err(recover_err) => {
1766 break Err(anyhow::anyhow!(
1767 "missing review worktree recovery failed after push error: {recover_err}"
1768 ));
1769 }
1770 }
1771 }
1772 Err(e) => break Err(e),
1773 }
1774 };
1775
1776 match push_result {
1777 Ok(_) => {
1778 store_log_activity(
1779 &Some(Arc::clone(store)),
1780 repo,
1781 &task.id.0,
1782 "push",
1783 None,
1784 None,
1785 Some(&ctx.review_agent),
1786 ctx.review_model.as_deref(),
1787 Some(&serde_json::json!({
1788 "status": "ok",
1789 "branch": branch_name.clone(),
1790 "default_branch": ctx.default_branch.clone(),
1791 "phase": "review",
1792 })),
1793 )
1794 .await;
1795 if ctx.had_prev_push_failure {
1796 if let Err(e) = store_set_result(
1797 &Some(Arc::clone(store)),
1798 repo,
1799 &task.id.0,
1800 &[
1801 ("last_error", serde_json::json!("")),
1802 ("push_failures", serde_json::json!(0)),
1803 ],
1804 )
1805 .await
1806 {
1807 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
1808 }
1809 }
1810 Ok(())
1811 }
1812 Err(e) => {
1813 store_log_activity(
1814 &Some(Arc::clone(store)),
1815 repo,
1816 &task.id.0,
1817 "push",
1818 None,
1819 None,
1820 Some(&ctx.review_agent),
1821 ctx.review_model.as_deref(),
1822 Some(&serde_json::json!({
1823 "status": "error",
1824 "branch": branch_name.clone(),
1825 "default_branch": ctx.default_branch.clone(),
1826 "phase": "review",
1827 "error": e.to_string(),
1828 })),
1829 )
1830 .await;
1831 tracing::error!(
1832 task_id = task.id.0,
1833 branch = %ctx.branch_name,
1834 error = %e,
1835 "review push failed"
1836 );
1837 store_set(
1838 &Some(Arc::clone(store)),
1839 repo,
1840 &task.id.0,
1841 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
1842 )
1843 .await;
1844 Err(e.to_string())
1845 }
1846 }
1847}
1848
1849fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
1850 match decision {
1851 ReviewDecision::Approve => {
1852 format!(
1853 "## Automated Review \u{2014} Approve\n\n{}",
1854 review_notes_for_comment
1855 )
1856 }
1857 ReviewDecision::RequestChanges { notes, issues } => {
1858 let mut body = format!(
1859 "## Automated Review \u{2014} Changes Requested\n\n{}\n",
1860 notes
1861 );
1862 if !issues.is_empty() {
1863 body.push_str("\n**Issues Found:**\n");
1864 for issue in issues {
1865 body.push_str(&format!(
1866 "- `{}` line {}: {} [{}]\n",
1867 issue.file,
1868 issue
1869 .line
1870 .map(|line| line.to_string())
1871 .unwrap_or_else(|| "?".to_string()),
1872 issue.description,
1873 issue.severity
1874 ));
1875 }
1876 }
1877 body
1878 }
1879 _ => String::new(),
1880 }
1881}
1882
1883const REVIEW_STATUS_CONTEXT: &str = "orch/review";
1884
1885fn review_status(decision: &ReviewDecision) -> Option<(&'static str, &'static str)> {
1887 match decision {
1888 ReviewDecision::Approve => Some(("success", "Approved by automated review")),
1889 ReviewDecision::RequestChanges { .. } => {
1890 Some(("failure", "Automated review requested changes"))
1891 }
1892 _ => None,
1893 }
1894}
1895
1896async fn post_review_status(
1899 gh: &GhHttp,
1900 repo: &str,
1901 sha: Option<&str>,
1902 state: &str,
1903 description: &str,
1904) {
1905 let Some(sha) = sha else {
1906 return;
1907 };
1908 if let Err(e) = gh
1909 .post_commit_status(repo, sha, state, REVIEW_STATUS_CONTEXT, description)
1910 .await
1911 {
1912 tracing::warn!(sha, error = %e, "failed to post orch/review commit status");
1913 }
1914}
1915
1916async fn post_review_comment(
1917 task: &ExternalTask,
1918 repo: &str,
1919 ctx: &ReviewContext,
1920 decision: &ReviewDecision,
1921 review_notes_for_comment: &str,
1922) -> anyhow::Result<()> {
1923 let gh = match GhHttp::new() {
1924 Ok(gh) => gh,
1925 Err(e) => {
1926 tracing::warn!(
1927 task_id = task.id.0,
1928 error = %e,
1929 "failed to create GH client for review comment"
1930 );
1931 return Ok(());
1932 }
1933 };
1934 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
1935 if ctx.same_agent_fallback && !pr_comment.is_empty() {
1936 pr_comment.push_str(
1937 "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
1938 );
1939 }
1940
1941 if let Some((state, description)) = review_status(decision) {
1942 post_review_status(&gh, repo, ctx.head_sha.as_deref(), state, description).await;
1943 }
1944
1945 if !pr_comment.is_empty() {
1946 let footer =
1947 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
1948 let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
1949 if let Err(e) = gh
1950 .add_comment(repo, &ctx.pr_number.to_string(), &pr_comment_with_footer)
1951 .await
1952 {
1953 tracing::warn!(
1954 task_id = task.id.0,
1955 pr_number = ctx.pr_number,
1956 error = %e,
1957 "failed to post automated review comment on PR"
1958 );
1959 }
1960 }
1961
1962 Ok(())
1963}
1964
1965#[allow(clippy::too_many_arguments)]
1966async fn apply_review_decision(
1967 task: &ExternalTask,
1968 backend: &Arc<dyn ExternalBackend>,
1969 repo: &str,
1970 task_manager: &Arc<TaskManager>,
1971 store: &Arc<TaskStore>,
1972 ctx: &ReviewContext,
1973 parsed: &ParsedReview,
1974 router_config: &crate::engine::router::RouterConfig,
1975) -> anyhow::Result<ReviewDecision> {
1976 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
1977
1978 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
1979 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
1980 }
1981
1982 post_review_comment(
1983 task,
1984 repo,
1985 ctx,
1986 &parsed.decision,
1987 &parsed.review_notes_for_comment,
1988 )
1989 .await?;
1990
1991 match &parsed.decision {
1992 ReviewDecision::Approve => {
1993 record_approved_sha(store, repo, &task.id.0, ctx.pr_number, &ctx.worktree_path).await;
1994 if let Err(e) = auto_merge_pr(
1999 task,
2000 &ctx.branch_name,
2001 backend,
2002 repo,
2003 &ctx.review_agent,
2004 ctx.review_model_str(),
2005 task_manager,
2006 store,
2007 )
2008 .await
2009 {
2010 let error_msg = e.to_string();
2011 if error_msg.contains("not yet computed") {
2012 tracing::warn!(
2013 task_id = task.id.0,
2014 pr_number = ctx.pr_number,
2015 branch = %ctx.branch_name,
2016 error = %e,
2017 "auto-merge deferred — PR mergeability not yet computed"
2018 );
2019 } else {
2020 tracing::error!(
2021 task_id = task.id.0,
2022 pr_number = ctx.pr_number,
2023 branch = %ctx.branch_name,
2024 error = %e,
2025 "auto-merge failed"
2026 );
2027 }
2028 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
2029 }
2030 Ok(ReviewDecision::Approve)
2031 }
2032 ReviewDecision::RequestChanges { notes, issues } => {
2033 clear_approved_sha(store, repo, &task.id.0, ctx.pr_number).await;
2034 handle_review_changes(
2035 task,
2036 notes,
2037 issues,
2038 backend,
2039 repo,
2040 ctx.pr_number,
2041 &ctx.review_agent,
2042 ctx.review_model_str(),
2043 task_manager,
2044 store,
2045 router_config,
2046 )
2047 .await?;
2048 Ok(parsed.decision.clone())
2049 }
2050 _ => Ok(parsed.decision.clone()),
2051 }
2052}
2053
2054async fn recover_missing_review_worktree(
2055 task: &ExternalTask,
2056 repo: &str,
2057 store: &Arc<TaskStore>,
2058) -> anyhow::Result<worktree::WorktreeSetup> {
2059 let project_dir = crate::config::get_projects_with_paths()?
2060 .into_iter()
2061 .find(|(candidate_repo, _)| candidate_repo == repo)
2062 .map(|(_, project_dir)| project_dir)
2063 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
2064
2065 tracing::warn!(
2066 task_id = task.id.0,
2067 repo,
2068 project_dir = %project_dir.display(),
2069 "review worktree missing, recreating via normal setup"
2070 );
2071
2072 let store_ref = Some(Arc::clone(store));
2073 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
2074 .await
2075 .context("recreating missing review worktree")?;
2076
2077 if !wt.work_dir.exists() {
2078 anyhow::bail!(
2079 "recreated review worktree at {} for task {} is still missing",
2080 wt.work_dir.display(),
2081 task.id.0
2082 );
2083 }
2084
2085 Ok(wt)
2086}
2087
2088async fn count_ahead_commits(
2094 worktree_path: &std::path::Path,
2095 default_branch: &str,
2096) -> Result<u64, String> {
2097 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2098 format!(
2099 "worktree path contains non-UTF-8 characters: {:?}",
2100 worktree_path
2101 )
2102 })?;
2103
2104 let output = tokio::process::Command::new("git")
2105 .args([
2106 "-C",
2107 worktree_str,
2108 "rev-list",
2109 "--count",
2110 &format!("origin/{default_branch}..HEAD"),
2111 ])
2112 .output()
2113 .await
2114 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
2115
2116 if !output.status.success() {
2117 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
2118 return Err(format!(
2119 "git rev-list exited with {:?}: {}",
2120 output.status.code(),
2121 if stderr.is_empty() {
2122 String::from_utf8_lossy(&output.stdout).trim().to_string()
2123 } else {
2124 stderr
2125 }
2126 ));
2127 }
2128
2129 String::from_utf8_lossy(&output.stdout)
2130 .trim()
2131 .parse::<u64>()
2132 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
2133}
2134
2135#[allow(clippy::too_many_arguments)]
2142async fn ensure_pr_exists(
2143 task: &ExternalTask,
2144 branch_name: &str,
2145 worktree_path: &std::path::Path,
2146 repo: &str,
2147 agent_summary: &str,
2148 store: &Arc<TaskStore>,
2149 task_manager: &Arc<TaskManager>,
2150 stored_pr_number: Option<u64>,
2151) -> anyhow::Result<EnsurePrResult> {
2152 let gh_check = GhHttp::new()?;
2153
2154 if let Some(n) = stored_pr_number {
2155 tracing::info!(
2156 task_id = task.id.0,
2157 pr_number = n,
2158 branch = %branch_name,
2159 "open PR found in store, proceeding with review"
2160 );
2161 return Ok(EnsurePrResult::Ready(n));
2162 }
2163
2164 match gh_check.get_pr_number(repo, branch_name).await {
2165 Ok(Some(n)) => {
2166 tracing::info!(
2167 task_id = task.id.0,
2168 pr_number = n,
2169 branch = %branch_name,
2170 "open PR found, proceeding with review"
2171 );
2172 Ok(EnsurePrResult::Ready(n))
2173 }
2174 Ok(None) => {
2175 let default_branch = worktree::detect_default_branch(worktree_path).await;
2177 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
2178 Ok(count) => count > 0,
2179 Err(e) => {
2180 tracing::error!(
2181 task_id = task.id.0,
2182 branch = %branch_name,
2183 default_branch = %default_branch,
2184 err = %e,
2185 "could not determine if branch has commits ahead of default"
2186 );
2187 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2188 format!("git ancestry check failed: {e}"),
2189 )));
2190 }
2191 };
2192
2193 if has_commits {
2194 tracing::warn!(
2196 task_id = task.id.0,
2197 branch = %branch_name,
2198 "no open PR but branch has commits — attempting to create PR"
2199 );
2200 let worktree_str = worktree_path.to_str().ok_or_else(|| {
2201 anyhow::anyhow!(
2202 "worktree path contains non-UTF-8 characters: {:?}",
2203 worktree_path
2204 )
2205 })?;
2206 let auth_env = runner::git_ops::build_git_auth_env();
2208
2209 let push_timeout = std::time::Duration::from_secs(120);
2214 match tokio::time::timeout(
2215 push_timeout,
2216 tokio::process::Command::new("git")
2217 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
2218 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
2219 .output(),
2220 )
2221 .await
2222 {
2223 Err(_elapsed) => {
2224 tracing::warn!(
2225 task_id = task.id.0,
2226 branch = %branch_name,
2227 timeout_secs = push_timeout.as_secs(),
2228 "review gate fallback push timed out"
2229 );
2230 }
2231 Ok(Ok(o)) => {
2232 if !o.status.success() {
2233 tracing::warn!(
2234 task_id = task.id.0,
2235 branch = %branch_name,
2236 exit_code = ?o.status.code(),
2237 stdout = %String::from_utf8_lossy(&o.stdout),
2238 stderr = %String::from_utf8_lossy(&o.stderr),
2239 "review gate fallback push failed"
2240 );
2241 } else {
2242 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2243 }
2244 }
2245 Ok(Err(e)) => {
2246 tracing::error!(
2247 task_id = task.id.0,
2248 branch = %branch_name,
2249 error = %e,
2250 "review gate fallback push command failed to run"
2251 );
2252 }
2253 }
2254
2255 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2256 let pr_body = format!(
2257 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2258 );
2259 let gh = GhHttp::new()?;
2260 match gh
2261 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2262 .await
2263 {
2264 Ok(url) => match parse_pr_number_from_url(&url) {
2265 Ok(pr_num) => {
2266 store_set_result(
2267 &Some(Arc::clone(store)),
2268 repo,
2269 &task.id.0,
2270 &[("pr_number", serde_json::json!(pr_num as i64))],
2271 )
2272 .await?;
2273 tracing::info!(
2274 task_id = task.id.0,
2275 branch = %branch_name,
2276 pr_url = %url,
2277 "created missing PR via GhHttp — continuing review"
2278 );
2279 Ok(EnsurePrResult::Ready(pr_num))
2280 }
2281 Err(e) => {
2282 tracing::warn!(
2283 task_id = task.id.0,
2284 branch = %branch_name,
2285 pr_url = %url,
2286 error = %e,
2287 "created missing PR via GhHttp, but failed to parse PR number"
2288 );
2289 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2290 format!("created missing PR, but could not parse PR number: {e}"),
2291 )))
2292 }
2293 },
2294 Err(e) => {
2295 let e_str = format!("{e}");
2296 if e_str.contains("already exists") {
2297 let backoff_delays = [2u64, 4u64];
2301 let mut found_pr: Option<u64> = None;
2302 for delay_secs in backoff_delays {
2303 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2304 .await;
2305 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2306 {
2307 found_pr = Some(n);
2308 break;
2309 }
2310 }
2311 if let Some(n) = found_pr {
2312 tracing::info!(
2313 task_id = task.id.0,
2314 pr_number = n,
2315 branch = %branch_name,
2316 "found existing PR after create_pr 422 — retrying review"
2317 );
2318 store_set_result(
2319 &Some(Arc::clone(store)),
2320 repo,
2321 &task.id.0,
2322 &[("pr_number", serde_json::json!(n as i64))],
2323 )
2324 .await?;
2325 return Ok(EnsurePrResult::Ready(n));
2326 }
2327 }
2328 tracing::warn!(
2329 task_id = task.id.0,
2330 branch = %branch_name,
2331 error = %e,
2332 "create_pr failed via GhHttp, falling back to CLI"
2333 );
2334 let pr_timeout = std::time::Duration::from_secs(120);
2337 let mut gh_cmd = tokio::process::Command::new("gh");
2338 gh_cmd.args([
2339 "pr",
2340 "create",
2341 "--repo",
2342 repo,
2343 "--head",
2344 branch_name,
2345 "--title",
2346 &task.title,
2347 "--body",
2348 &pr_body,
2349 ]);
2350 gh_cmd.current_dir(worktree_path);
2351 gh_cmd.kill_on_drop(true);
2352
2353 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2354 match pr_result {
2355 Ok(Ok(o)) if o.status.success() => {
2356 let stdout = String::from_utf8_lossy(&o.stdout);
2357 match parse_pr_number_from_url(stdout.trim()) {
2358 Ok(pr_num) => {
2359 store_set_result(
2360 &Some(Arc::clone(store)),
2361 repo,
2362 &task.id.0,
2363 &[("pr_number", serde_json::json!(pr_num as i64))],
2364 )
2365 .await?;
2366 tracing::info!(
2367 task_id = task.id.0,
2368 branch = %branch_name,
2369 "created missing PR via CLI — continuing review"
2370 );
2371 Ok(EnsurePrResult::Ready(pr_num))
2372 }
2373 Err(e) => {
2374 tracing::warn!(
2375 task_id = task.id.0,
2376 branch = %branch_name,
2377 stdout = %stdout,
2378 error = %e,
2379 "created missing PR via CLI, but failed to parse PR number"
2380 );
2381 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2382 format!("created missing PR via CLI, but could not parse PR number: {e}")
2383 )))
2384 }
2385 }
2386 }
2387 Ok(Ok(o)) => {
2388 let stderr = String::from_utf8_lossy(&o.stderr);
2389 if stderr.contains("already exists") {
2390 if let Some(pr_url) =
2391 stderr.lines().find(|l| l.trim().starts_with("https://"))
2392 {
2393 match parse_pr_number_from_url(pr_url.trim()) {
2394 Ok(pr_num) => {
2395 if let Err(e) = store_set_result(
2396 &Some(Arc::clone(store)),
2397 repo,
2398 &task.id.0,
2399 &[(
2400 "pr_number",
2401 serde_json::json!(pr_num as i64),
2402 )],
2403 )
2404 .await
2405 {
2406 tracing::warn!(
2407 task_id = task.id.0,
2408 pr_num,
2409 err = %e,
2410 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
2411 );
2412 }
2413 tracing::info!(
2414 task_id = task.id.0,
2415 branch = %branch_name,
2416 pr_url = %pr_url,
2417 "PR already exists (from CLI stderr) — retrying review"
2418 );
2419 return Ok(EnsurePrResult::Ready(pr_num));
2420 }
2421 Err(e) => {
2422 tracing::warn!(
2423 task_id = task.id.0,
2424 branch = %branch_name,
2425 pr_url = %pr_url,
2426 error = %e,
2427 "found existing PR URL in CLI stderr, but failed to parse PR number"
2428 );
2429 }
2431 }
2432 }
2433 }
2434 tracing::error!(
2435 task_id = task.id.0,
2436 branch = %branch_name,
2437 stderr = %stderr,
2438 "failed to create missing PR — work may be stuck"
2439 );
2440
2441 if crate::engine::runner::git_ops::is_transient_github_api_error(
2448 &stderr,
2449 ) {
2450 tracing::warn!(
2451 task_id = task.id.0,
2452 branch = %branch_name,
2453 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
2454 );
2455 return Ok(EnsurePrResult::EarlyReturn(
2456 ReviewDecision::Failed(format!(
2457 "transient github error creating PR: {stderr}"
2458 )),
2459 ));
2460 }
2461
2462 match store_increment(
2463 &Some(Arc::clone(store)),
2464 repo,
2465 &task.id.0,
2466 "pr_create_failures",
2467 )
2468 .await
2469 {
2470 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2471 return Ok(EnsurePrResult::EarlyReturn(
2472 ReviewDecision::Blocked(format!(
2473 "no PR, create failed {failures} times: {stderr}"
2474 )),
2475 ));
2476 }
2477 Ok(_) => {}
2478 Err(e) => {
2479 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2480 }
2481 }
2482 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2483 format!("no PR, create failed: {stderr}"),
2484 )))
2485 }
2486 Ok(Err(e)) => {
2487 tracing::error!(
2488 task_id = task.id.0,
2489 error = %e,
2490 "failed to run gh pr create"
2491 );
2492 let e_str = format!("{e}");
2493 if crate::engine::runner::git_ops::is_transient_github_api_error(
2494 &e_str,
2495 ) {
2496 tracing::warn!(
2497 task_id = task.id.0,
2498 branch = %branch_name,
2499 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
2500 );
2501 return Ok(EnsurePrResult::EarlyReturn(
2502 ReviewDecision::Failed(format!("transient gh error: {e}")),
2503 ));
2504 }
2505
2506 match store_increment(
2507 &Some(Arc::clone(store)),
2508 repo,
2509 &task.id.0,
2510 "pr_create_failures",
2511 )
2512 .await
2513 {
2514 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2515 return Ok(EnsurePrResult::EarlyReturn(
2516 ReviewDecision::Blocked(format!(
2517 "no PR, gh error {failures} times: {e}"
2518 )),
2519 ));
2520 }
2521 Ok(_) => {}
2522 Err(e) => {
2523 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2524 }
2525 }
2526 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2527 format!("no PR, gh error: {e}"),
2528 )))
2529 }
2530 Err(_elapsed) => {
2531 tracing::error!(
2533 task_id = task.id.0,
2534 timeout_secs = pr_timeout.as_secs(),
2535 "gh pr create CLI fallback timed out"
2536 );
2537 let e_str = format!(
2538 "gh pr create timed out after {}s",
2539 pr_timeout.as_secs()
2540 );
2541 if crate::engine::runner::git_ops::is_transient_github_api_error(
2542 &e_str,
2543 ) {
2544 tracing::warn!(
2545 task_id = task.id.0,
2546 branch = %branch_name,
2547 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
2548 );
2549 return Ok(EnsurePrResult::EarlyReturn(
2550 ReviewDecision::Failed(format!(
2551 "transient gh error: {e_str}"
2552 )),
2553 ));
2554 }
2555
2556 match store_increment(
2557 &Some(Arc::clone(store)),
2558 repo,
2559 &task.id.0,
2560 "pr_create_failures",
2561 )
2562 .await
2563 {
2564 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2565 return Ok(EnsurePrResult::EarlyReturn(
2566 ReviewDecision::Blocked(format!(
2567 "no PR, gh timeout {failures} times: {e_str}"
2568 )),
2569 ));
2570 }
2571 Ok(_) => {}
2572 Err(e) => {
2573 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2574 }
2575 }
2576 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2577 format!("no PR, gh timeout: {e_str}"),
2578 )))
2579 }
2580 }
2581 }
2582 }
2583 } else {
2584 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
2586 Ok(v) => v,
2587 Err(e) => {
2588 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
2589 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2590 format!("merge check failed: {e}"),
2591 )));
2592 }
2593 };
2594 if merged {
2595 tracing::info!(
2596 task_id = task.id.0,
2597 branch = %branch_name,
2598 "PR already merged, marking done"
2599 );
2600 if let Err(e) = task_manager
2601 .update_task_status(&task.id, Status::Done)
2602 .await
2603 {
2604 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2605 }
2606 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2607 }
2608
2609 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
2610 .await
2611 .map(|t| t.last_error)
2612 .unwrap_or_default();
2613 let reason = if !agent_summary.is_empty() {
2614 agent_summary.to_string()
2615 } else {
2616 last_error.clone()
2617 };
2618
2619 if last_error.contains("exceeded max attempts") {
2620 tracing::warn!(
2621 task_id = task.id.0,
2622 branch = %branch_name,
2623 "no PR and no commits after max attempts — marking blocked to stop loop"
2624 );
2625 let fields = [
2629 (
2630 "block_reason",
2631 serde_json::json!("max attempts exceeded — no PR or commits produced"),
2632 ),
2633 ("last_error", serde_json::json!(last_error)),
2634 ];
2635 if let Err(e) = task_manager
2636 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2637 .await
2638 {
2639 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2640 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2641 format!("failed to write block_reason: {e}"),
2642 )));
2643 }
2644 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2645 }
2646
2647 if last_error.contains("422") && last_error.contains("head") {
2648 tracing::info!(
2649 task_id = task.id.0,
2650 branch = %branch_name,
2651 "no PR — 422/head-invalid means work already merged, marking done"
2652 );
2653 if let Err(e) = task_manager
2654 .update_task_status(&task.id, Status::Done)
2655 .await
2656 {
2657 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
2658 }
2659 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2660 }
2661
2662 if last_error.contains("no fallback agents")
2663 || last_error.contains("all agents exhausted")
2664 {
2665 tracing::warn!(
2666 task_id = task.id.0,
2667 branch = %branch_name,
2668 last_error = %last_error,
2669 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
2670 );
2671 let fields = [
2675 (
2676 "block_reason",
2677 serde_json::json!("all agents exhausted — no PR or commits produced"),
2678 ),
2679 ("last_error", serde_json::json!(last_error)),
2680 ];
2681 if let Err(e) = task_manager
2682 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2683 .await
2684 {
2685 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2686 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2687 format!("failed to write block_reason: {e}"),
2688 )));
2689 }
2690 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2691 }
2692
2693 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
2700 .ok()
2701 .and_then(|s| s.parse().ok())
2702 .or_else(|| {
2703 config::get("workflow.max_attempts")
2704 .ok()
2705 .and_then(|s| s.parse().ok())
2706 })
2707 .unwrap_or(3);
2708
2709 tracing::warn!(
2710 task_id = task.id.0,
2711 branch = %branch_name,
2712 reason = %reason,
2713 "no PR and no commits — re-routing for retry"
2714 );
2715
2716 if should_skip_no_code_reroute_increment(&last_error) {
2720 tracing::warn!(
2721 task_id = task.id.0,
2722 last_error = %last_error,
2723 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
2724 );
2725 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2726 format!("transient github error: {last_error}"),
2727 )));
2728 }
2729
2730 let reroutes = match store_increment(
2732 &Some(Arc::clone(store)),
2733 repo,
2734 &task.id.0,
2735 "no_code_reroutes",
2736 )
2737 .await
2738 {
2739 Ok(v) => v,
2740 Err(e) => {
2741 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
2742 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2744 format!("transient store error: {e}"),
2745 )));
2746 }
2747 };
2748
2749 if reroutes as u32 >= max_reroutes {
2750 tracing::error!(
2751 task_id = task.id.0,
2752 reroutes,
2753 max_reroutes,
2754 "reached max reroute attempts for no-pr-result — blocking for human review"
2755 );
2756 let msg = format!(
2757 "no PR or code changes after {}/{} reroute attempts",
2758 reroutes, max_reroutes
2759 );
2760 let fields = [
2764 (
2765 "block_reason",
2766 serde_json::json!(format!(
2767 "max reroute attempts ({}) reached — no PR or code changes produced",
2768 max_reroutes
2769 )),
2770 ),
2771 ("last_error", serde_json::json!(msg)),
2772 ("agent", serde_json::json!(null)),
2773 ("model", serde_json::json!(null)),
2774 ];
2775 if let Err(e) = task_manager
2776 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2777 .await
2778 {
2779 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2780 }
2781 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
2782 } else if task.id.0.starts_with("internal:") {
2783 tracing::info!(
2790 task_id = task.id.0,
2791 "no PR and no commits for internal task — marking done"
2792 );
2793 if let Err(e) = task_manager
2794 .update_task_status(&task.id, Status::Done)
2795 .await
2796 {
2797 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2798 }
2799 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
2800 } else {
2801 if let Err(e) = store_set_result(
2806 &Some(Arc::clone(store)),
2807 repo,
2808 &task.id.0,
2809 &[
2810 ("agent", serde_json::json!(null)),
2811 ("model", serde_json::json!(null)),
2812 (
2813 "last_error",
2814 serde_json::json!("no PR or code changes produced"),
2815 ),
2816 ],
2817 )
2818 .await
2819 {
2820 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
2821 } else if let Err(e) =
2823 task_manager.update_task_status(&task.id, Status::New).await
2824 {
2825 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
2826 }
2827 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
2828 }
2829 }
2830 }
2831 Err(e) => {
2832 tracing::warn!(
2833 task_id = task.id.0,
2834 branch = %branch_name,
2835 error = %e,
2836 "failed to check PR status"
2837 );
2838 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2839 format!("PR check failed: {e}"),
2840 )))
2841 }
2842 }
2843}
2844
2845pub(crate) async fn review_and_merge(
2851 task: &ExternalTask,
2852 backend: &Arc<dyn ExternalBackend>,
2853 tmux: &Arc<TmuxManager>,
2854 repo: &str,
2855 router: &Arc<RwLock<Router>>,
2856 task_manager: &Arc<TaskManager>,
2857 store: &Arc<TaskStore>,
2858) -> anyhow::Result<ReviewDecision> {
2859 set_review_session_expected(store, repo, &task.id.0, true).await;
2860
2861 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
2862 ReviewPhase::Ready(ctx) => ctx,
2863 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2864 };
2865
2866 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
2867 ReviewPhase::Ready(run) => run,
2868 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2869 };
2870
2871 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
2872 ReviewPhase::Ready(parsed) => parsed,
2873 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2874 };
2875
2876 finalize_review_run(task, &ctx, &parsed, store).await;
2877
2878 let router_config = router.read().await.config.clone();
2879 apply_review_decision(
2880 task,
2881 backend,
2882 repo,
2883 task_manager,
2884 store,
2885 &ctx,
2886 &parsed,
2887 &router_config,
2888 )
2889 .await
2890}
2891
2892#[cfg(test)]
2893mod tests {
2894 use super::*;
2895
2896 use crate::backends::ExternalTask;
2897 use crate::engine::router::RouterConfig;
2898 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
2899 use crate::store::{TaskStatus, TaskStore};
2900 use tempfile::TempDir;
2901
2902 #[test]
2905 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
2906 let err = runner::agents::AgentError::RateLimit {
2910 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
2911 for this billing cycle. Your quota will be refreshed soon."
2912 .to_string(),
2913 };
2914 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
2915 }
2916
2917 #[test]
2918 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
2919 let err = runner::agents::AgentError::RateLimit {
2920 message: "429 Too Many Requests".to_string(),
2921 };
2922 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
2923 }
2924
2925 #[test]
2926 fn outcome_for_agent_error_generic_auth_unaffected() {
2927 let err = runner::agents::AgentError::Auth {
2928 message: "401 Unauthorized".to_string(),
2929 };
2930 assert_eq!(outcome_for_agent_error(&err), "auth_error");
2931 }
2932
2933 #[test]
2936 fn parse_pr_number_standard_url() {
2937 assert_eq!(
2938 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
2939 Ok(42)
2940 );
2941 }
2942
2943 #[test]
2944 fn parse_pr_number_trailing_slash() {
2945 assert_eq!(
2946 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
2947 Ok(42)
2948 );
2949 }
2950
2951 #[test]
2952 fn parse_pr_number_with_query_string() {
2953 assert_eq!(
2954 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
2955 Ok(42)
2956 );
2957 }
2958
2959 #[test]
2960 fn parse_pr_number_with_hash_fragment() {
2961 assert_eq!(
2962 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
2963 Ok(42)
2964 );
2965 }
2966
2967 #[test]
2968 fn parse_pr_number_with_query_and_hash() {
2969 assert_eq!(
2970 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
2971 Ok(42)
2972 );
2973 }
2974
2975 #[test]
2976 fn parse_pr_number_with_leading_whitespace() {
2977 assert_eq!(
2978 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
2979 Ok(42)
2980 );
2981 }
2982
2983 #[test]
2984 fn parse_pr_number_wrong_domain() {
2985 let url = "https://gitlab.com/owner/repo/merge_requests/42";
2986 let result = parse_pr_number_from_url(url);
2987 assert!(result.is_err());
2988 assert!(result
2989 .unwrap_err()
2990 .contains("does not start with 'https://github.com/'"));
2991 }
2992
2993 #[test]
2994 fn parse_pr_number_missing_pull_segment() {
2995 let url = "https://github.com/owner/repo/issues/42";
2997 let result = parse_pr_number_from_url(url);
2998 assert!(result.is_err());
2999 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
3000 }
3001
3002 #[test]
3003 fn parse_pr_number_zero_is_rejected() {
3004 let url = "https://github.com/owner/repo/pull/0";
3005 let result = parse_pr_number_from_url(url);
3006 assert!(result.is_err());
3007 assert!(result.unwrap_err().contains("cannot be zero"));
3008 }
3009
3010 #[test]
3011 fn parse_pr_number_non_numeric_segment() {
3012 let url = "https://github.com/owner/repo/pull/abc";
3013 let result = parse_pr_number_from_url(url);
3014 assert!(result.is_err());
3015 assert!(result.unwrap_err().contains("not a valid positive integer"));
3016 }
3017
3018 #[test]
3019 fn parse_pr_number_empty_string() {
3020 let result = parse_pr_number_from_url("");
3021 assert!(result.is_err());
3022 assert!(result.unwrap_err().contains("empty"));
3023 }
3024
3025 #[test]
3026 fn parse_pr_number_http_not_https() {
3027 let url = "http://github.com/owner/repo/pull/42";
3029 let result = parse_pr_number_from_url(url);
3030 assert!(result.is_err());
3031 assert!(result
3032 .unwrap_err()
3033 .contains("does not start with 'https://github.com/'"));
3034 }
3035
3036 #[test]
3044 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
3045 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
3049 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
3050 let err = runner::agents::AgentError::RateLimit {
3051 message: raw_fragment.to_string(),
3052 };
3053
3054 let stored = format_review_error("minimax", &err);
3055
3056 assert!(
3059 !stored.contains("{\"type\""),
3060 "stored error must not contain raw NDJSON envelope, got: {stored}"
3061 );
3062 assert!(
3063 !stored.contains("subtype"),
3064 "stored error must not contain raw api_retry fragment, got: {stored}"
3065 );
3066 assert!(
3067 !stored.contains("session_id"),
3068 "stored error must not contain session_id from provider payload, got: {stored}"
3069 );
3070 assert_eq!(
3071 stored,
3072 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
3073 );
3074 }
3075
3076 #[test]
3077 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
3078 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
3083 let err = runner::agents::AgentError::RateLimit {
3084 message: raw_fragment.to_string(),
3085 };
3086
3087 let stored = format_review_error("glm", &err);
3088
3089 assert!(stored.starts_with("glm rate limit:"));
3090 assert!(
3091 !stored.contains("session_id"),
3092 "stored error must not leak session_id from truncated fragment, got: {stored}"
3093 );
3094 assert!(
3097 stored.len() <= 200,
3098 "stored error should be compact (<=200 chars), got {} chars: {stored}",
3099 stored.len()
3100 );
3101 }
3102
3103 #[test]
3104 fn format_review_error_preserves_non_rate_limit_variants() {
3105 let err = runner::agents::AgentError::Auth {
3106 message: "invalid bearer token".to_string(),
3107 };
3108 assert_eq!(
3109 format_review_error("kimi", &err),
3110 "kimi auth error: invalid bearer token"
3111 );
3112
3113 let err = runner::agents::AgentError::Timeout {
3114 elapsed: std::time::Duration::from_secs(1801),
3115 };
3116 assert_eq!(
3117 format_review_error("kimi", &err),
3118 "kimi timed out after 1801s"
3119 );
3120 }
3121
3122 fn git(dir: &std::path::Path, args: &[&str]) {
3123 let mut cmd = std::process::Command::new("git");
3124 cmd.current_dir(dir);
3125 if args.first() == Some(&"commit") {
3127 cmd.args(["-c", "commit.gpgsign=false"]);
3128 }
3129 let status = cmd.args(args).status().unwrap();
3130 assert!(status.success(), "git {:?} failed", args);
3131 }
3132
3133 #[test]
3134 fn test_pull_request_review_requests_changes() {
3135 let review = PullRequestReview {
3136 review: GitHubReview {
3137 id: 1,
3138 user: GitHubUser {
3139 login: "reviewer".to_string(),
3140 },
3141 body: Some("Please fix".to_string()),
3142 state: "CHANGES_REQUESTED".to_string(),
3143 html_url: None,
3144 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3145 commit_id: None,
3146 },
3147 comments: vec![],
3148 };
3149 assert!(review.requests_changes());
3150 }
3151
3152 #[test]
3153 fn test_pull_request_review_does_not_request_changes() {
3154 let review = PullRequestReview {
3155 review: GitHubReview {
3156 id: 1,
3157 user: GitHubUser {
3158 login: "reviewer".to_string(),
3159 },
3160 body: Some("LGTM".to_string()),
3161 state: "APPROVED".to_string(),
3162 html_url: None,
3163 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3164 commit_id: None,
3165 },
3166 comments: vec![],
3167 };
3168 assert!(!review.requests_changes());
3169 }
3170
3171 #[test]
3172 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
3173 let review = PullRequestReview {
3174 review: GitHubReview {
3175 id: 1,
3176 user: GitHubUser {
3177 login: "reviewer".to_string(),
3178 },
3179 body: None,
3180 state: "CHANGES_REQUESTED".to_string(),
3181 html_url: None,
3182 submitted_at: "2024-01-01T00:00:00Z".to_string(),
3183 commit_id: None,
3184 },
3185 comments: vec![
3186 GitHubReviewComment {
3187 id: 1,
3188 user: GitHubUser {
3189 login: "reviewer".to_string(),
3190 },
3191 body: "Fix this issue".to_string(),
3192 path: "src/main.rs".to_string(),
3193 line: Some(10),
3194 original_line: Some(10),
3195 commit_id: "abc".to_string(),
3196 original_commit_id: "abc".to_string(),
3197 html_url: "url".to_string(),
3198 created_at: "2024-01-01T00:00:00Z".to_string(),
3199 updated_at: "2024-01-01T00:00:00Z".to_string(),
3200 in_reply_to_id: None,
3201 diff_hunk: Some(
3202 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
3203 ),
3204 },
3205 GitHubReviewComment {
3206 id: 2,
3207 user: GitHubUser {
3208 login: "reviewer".to_string(),
3209 },
3210 body: "".to_string(),
3211 path: "src/lib.rs".to_string(),
3212 line: Some(20),
3213 original_line: Some(20),
3214 commit_id: "abc".to_string(),
3215 original_commit_id: "abc".to_string(),
3216 html_url: "url".to_string(),
3217 created_at: "2024-01-01T00:00:00Z".to_string(),
3218 updated_at: "2024-01-01T00:00:00Z".to_string(),
3219 in_reply_to_id: None,
3220 diff_hunk: None,
3221 },
3222 GitHubReviewComment {
3223 id: 3,
3224 user: GitHubUser {
3225 login: "reviewer".to_string(),
3226 },
3227 body: "Reply to this".to_string(),
3228 path: "src/lib.rs".to_string(),
3229 line: Some(30),
3230 original_line: Some(30),
3231 commit_id: "abc".to_string(),
3232 original_commit_id: "abc".to_string(),
3233 html_url: "url".to_string(),
3234 created_at: "2024-01-01T00:00:00Z".to_string(),
3235 updated_at: "2024-01-01T00:00:00Z".to_string(),
3236 in_reply_to_id: Some(1),
3237 diff_hunk: None,
3238 },
3239 ],
3240 };
3241 let actionable = review.actionable_comments();
3242 assert_eq!(actionable.len(), 1);
3243 assert_eq!(actionable[0].id, 1);
3244 assert_eq!(actionable[0].body, "Fix this issue");
3245 assert_eq!(actionable[0].path, "src/main.rs");
3246 assert_eq!(
3247 actionable[0].diff_hunk.as_ref().unwrap(),
3248 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3249 );
3250 }
3251
3252 #[test]
3253 fn test_router_config_model_for_complexity_returns_none_without_config() {
3254 let cfg = RouterConfig::default();
3257 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3258 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3259 assert!(cfg
3260 .model_for_complexity("unknown_agent_xyz", "simple", "")
3261 .is_none());
3262 }
3263
3264 #[test]
3265 fn review_status_maps_decisions() {
3266 assert_eq!(
3267 review_status(&ReviewDecision::Approve).unwrap().0,
3268 "success"
3269 );
3270 let changes = ReviewDecision::RequestChanges {
3271 notes: String::new(),
3272 issues: vec![],
3273 };
3274 assert_eq!(review_status(&changes).unwrap().0, "failure");
3275 assert!(review_status(&ReviewDecision::Failed("x".into())).is_none());
3276 }
3277
3278 #[test]
3279 fn review_started_comment_includes_agent_and_model() {
3280 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
3281 let expected = format!("🔍 Automated review started{}", expected_footer);
3282 assert_eq!(review_started_comment("kimi", "opus"), expected);
3283 }
3284
3285 fn no_code_review_task(id: &str) -> ExternalTask {
3288 ExternalTask {
3289 id: crate::backends::ExternalId(id.to_string()),
3290 title: "opencode discovery timeout".to_string(),
3291 body: "".to_string(),
3292 state: "open".to_string(),
3293 labels: vec![],
3294 author: "tester".to_string(),
3295 created_at: "2026-01-01T00:00:00Z".to_string(),
3296 updated_at: "2026-01-01T00:00:00Z".to_string(),
3297 url: "https://example.com/issues/1".to_string(),
3298 }
3299 }
3300
3301 #[tokio::test]
3305 async fn carried_approval_counts_as_this_rounds_decision() {
3306 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3307 let id = store
3308 .create_internal("owner/repo", "Approved head", "", "test", "carried", None)
3309 .await
3310 .unwrap();
3311 let task_id = format!("internal:{id}");
3312 store
3313 .update_status(id, crate::store::TaskStatus::InReview)
3314 .await
3315 .unwrap();
3316 assert_eq!(
3317 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3318 Some(false)
3319 );
3320
3321 record_carried_approval(&store, "owner/repo", &task_id, 7).await;
3322
3323 assert_eq!(
3324 store.in_review_entry(id).await.unwrap().map(|e| e.1),
3325 Some(true),
3326 "the stale in_review check must see this round as decided"
3327 );
3328 }
3329
3330 #[test]
3331 fn approval_covers_head_only_for_the_exact_commit() {
3332 assert!(approval_covers_head(Some("abc123"), Some("abc123")));
3333 assert!(
3334 !approval_covers_head(Some("abc123"), Some("def456")),
3335 "a moved PR head must be reviewed"
3336 );
3337 assert!(
3338 !approval_covers_head(None, Some("abc123")),
3339 "no recorded approval means review"
3340 );
3341 assert!(
3342 !approval_covers_head(Some("abc123"), None),
3343 "an unreadable PR head must not skip the review"
3344 );
3345 assert!(!approval_covers_head(Some(""), Some("")));
3346 }
3347
3348 #[tokio::test]
3353 async fn approved_sha_round_trips_through_the_kv_store() {
3354 let temp = TempDir::new().unwrap();
3355 let wt = temp.path();
3356 git(wt, &["init", "-b", "main"]);
3357 git(wt, &["config", "user.name", "Test"]);
3358 git(wt, &["config", "user.email", "test@test.com"]);
3359 std::fs::write(wt.join("fix.txt"), "the fix").unwrap();
3360 git(wt, &["add", "."]);
3361 git(wt, &["commit", "-m", "apply the fix"]);
3362
3363 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3364 let repo = "owner/repo";
3365 let task_id = "3783";
3366
3367 record_approved_sha(&store, repo, task_id, 77, wt).await;
3368 let recorded = store
3369 .kv_get(&approved_sha_key(repo, task_id, 77))
3370 .await
3371 .unwrap()
3372 .expect("approval must be recorded");
3373 assert_eq!(
3374 recorded,
3375 worktree_head_sha(wt).await.unwrap(),
3376 "the recorded sha must be the commit that was pushed to the PR"
3377 );
3378 assert!(
3379 store
3380 .kv_get(&approved_sha_key(repo, task_id, 78))
3381 .await
3382 .unwrap()
3383 .is_none(),
3384 "an approval must not leak to another PR"
3385 );
3386
3387 clear_approved_sha(&store, repo, task_id, 77).await;
3388 assert!(
3389 store
3390 .kv_get(&approved_sha_key(repo, task_id, 77))
3391 .await
3392 .unwrap()
3393 .is_none(),
3394 "requesting changes must drop the approval"
3395 );
3396 }
3397
3398 #[tokio::test]
3399 async fn build_review_context_reroutes_external_task_with_no_code() {
3400 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3401 let repo = "owner/repo";
3402 let ext = no_code_review_task("3617");
3403 store.ensure_external_task(repo, &ext).await.unwrap();
3404
3405 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3406 let task_manager = Arc::new(TaskManager::with_store(
3407 backend,
3408 Arc::clone(&store),
3409 repo.to_string(),
3410 ));
3411 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3412
3413 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3414 .await
3415 .unwrap();
3416
3417 assert!(
3418 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
3419 "external task with no worktree/branch/pr must reroute, not skip-to-done"
3420 );
3421
3422 let stored = store
3423 .get_by_external_id(repo, &ext.id.0)
3424 .await
3425 .unwrap()
3426 .expect("task must still exist");
3427 assert_eq!(stored.status, TaskStatus::New);
3428 assert!(stored.agent.is_none());
3429 assert!(stored.model.is_none());
3430 }
3431
3432 #[tokio::test]
3433 async fn build_review_context_blocks_external_task_after_max_reroutes() {
3434 let temp_home = TempDir::new().unwrap();
3437 let orch_dir = temp_home.path().join(".orch");
3438 std::fs::create_dir_all(&orch_dir).unwrap();
3439 let old_orch_home = std::env::var_os("ORCH_HOME");
3440 std::env::set_var("ORCH_HOME", &orch_dir);
3441
3442 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3443 let repo = "owner/repo";
3444 let ext = no_code_review_task("3618");
3445 store.ensure_external_task(repo, &ext).await.unwrap();
3446
3447 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3448 let task_manager = Arc::new(TaskManager::with_store(
3449 backend,
3450 Arc::clone(&store),
3451 repo.to_string(),
3452 ));
3453 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3454
3455 for _ in 0..3 {
3457 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3458 .await
3459 .unwrap();
3460 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
3461 break;
3462 }
3463 }
3464
3465 let stored = store
3466 .get_by_external_id(repo, &ext.id.0)
3467 .await
3468 .unwrap()
3469 .expect("task must still exist");
3470 assert_eq!(stored.status, TaskStatus::Blocked);
3471
3472 if let Some(old) = old_orch_home {
3473 std::env::set_var("ORCH_HOME", old);
3474 } else {
3475 std::env::remove_var("ORCH_HOME");
3476 }
3477 }
3478
3479 #[tokio::test]
3480 async fn build_review_context_marks_internal_task_done_with_no_code() {
3481 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3482 let repo = "owner/repo";
3483 let internal_id = store
3484 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
3485 .await
3486 .unwrap();
3487 let ext_id = format!("internal:{internal_id}");
3488 let ext = no_code_review_task(&ext_id);
3489
3490 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3491 let task_manager = Arc::new(TaskManager::with_store(
3492 backend,
3493 Arc::clone(&store),
3494 repo.to_string(),
3495 ));
3496 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3497
3498 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3499 .await
3500 .unwrap();
3501
3502 assert!(matches!(
3503 phase,
3504 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
3505 ));
3506 let stored = store.get(internal_id).await.unwrap();
3507 assert_eq!(stored.status, TaskStatus::Done);
3508 }
3509
3510 #[test]
3511 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
3512 let last_error =
3513 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
3514 assert!(!should_skip_no_code_reroute_increment(last_error));
3515 }
3516
3517 #[test]
3526 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
3527 let padding = "x".repeat(10_000);
3528 let text_for_review = format!(
3529 "{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}"
3530 );
3531
3532 let scan_head = runner::agents::patterns::safe_head(
3533 &text_for_review,
3534 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
3535 );
3536 let scan_tail = runner::agents::patterns::safe_tail(
3537 &text_for_review,
3538 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3539 );
3540
3541 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
3542 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
3543 }
3544
3545 #[test]
3546 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
3547 let padding = "x".repeat(10_000);
3548 let text_for_review =
3549 format!("{padding}\nError: rate limit exceeded, please try again later");
3550
3551 let scan_tail = runner::agents::patterns::safe_tail(
3552 &text_for_review,
3553 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3554 );
3555
3556 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
3557 }
3558
3559 #[tokio::test]
3560 async fn recover_missing_review_worktree_recreates_worktree() {
3561 let temp_home = TempDir::new().unwrap();
3562 let orch_dir = temp_home.path().join(".orch");
3563 std::fs::create_dir_all(&orch_dir).unwrap();
3564 let old_orch_home = std::env::var_os("ORCH_HOME");
3565 std::env::set_var("ORCH_HOME", &orch_dir);
3566
3567 let project_dir = temp_home.path().join("project");
3568 std::fs::create_dir_all(&project_dir).unwrap();
3569 git(&project_dir, &["init", "-b", "main"]);
3570 git(&project_dir, &["config", "user.name", "Test User"]);
3571 git(&project_dir, &["config", "user.email", "test@example.com"]);
3572 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
3573 git(&project_dir, &["add", "README.md"]);
3574 git(&project_dir, &["commit", "-m", "init"]);
3575
3576 std::fs::write(
3577 orch_dir.join("config.yml"),
3578 format!("projects:\n - {}\n", project_dir.display()),
3579 )
3580 .unwrap();
3581 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
3582
3583 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3584 let repo = "owner/repo";
3585 store
3586 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
3587 .await
3588 .unwrap();
3589 let ext = ExternalTask {
3590 id: crate::backends::ExternalId("1".to_string()),
3591 title: "Fix review recovery".to_string(),
3592 body: "".to_string(),
3593 state: "open".to_string(),
3594 labels: vec![],
3595 author: "tester".to_string(),
3596 created_at: "2026-01-01T00:00:00Z".to_string(),
3597 updated_at: "2026-01-01T00:00:00Z".to_string(),
3598 url: "https://example.com/issues/1".to_string(),
3599 };
3600 store.ensure_external_task(repo, &ext).await.unwrap();
3601
3602 let recovered = recover_missing_review_worktree(&ext, repo, &store)
3603 .await
3604 .unwrap();
3605
3606 assert!(recovered.work_dir.exists());
3607 assert!(recovered.branch.starts_with("gh-issue-1-"));
3608
3609 if let Some(old) = old_orch_home {
3610 std::env::set_var("ORCH_HOME", old);
3611 } else {
3612 std::env::remove_var("ORCH_HOME");
3613 }
3614 }
3615
3616 #[tokio::test]
3619 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
3620 let temp = TempDir::new().unwrap();
3621 let dir = temp.path();
3622
3623 let bare = temp.path().join("bare.git");
3625 std::fs::create_dir_all(&bare).unwrap();
3626 std::process::Command::new("git")
3627 .args(["init", "--bare"])
3628 .current_dir(&bare)
3629 .output()
3630 .unwrap();
3631
3632 git(dir, &["init", "-b", "main"]);
3633 git(dir, &["config", "user.name", "Test"]);
3634 git(dir, &["config", "user.email", "test@test.com"]);
3635 std::fs::write(dir.join("f"), "a").unwrap();
3636 git(dir, &["add", "f"]);
3637 git(dir, &["commit", "-m", "init"]);
3638 std::process::Command::new("git")
3639 .args(["remote", "add", "origin", bare.to_str().unwrap()])
3640 .current_dir(dir)
3641 .output()
3642 .unwrap();
3643 std::process::Command::new("git")
3644 .args(["push", "origin", "main"])
3645 .current_dir(dir)
3646 .output()
3647 .unwrap();
3648
3649 let result = count_ahead_commits(dir, "main").await;
3651 assert!(
3652 result.is_ok(),
3653 "expected Ok when origin/main exists, got {result:?}"
3654 );
3655 assert_eq!(
3656 result.unwrap(),
3657 0,
3658 "expected 0 commits ahead when branch matches origin/main"
3659 );
3660 }
3661
3662 #[tokio::test]
3663 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
3664 let temp = TempDir::new().unwrap();
3665 let dir = temp.path();
3666 git(dir, &["init", "-b", "main"]);
3667 git(dir, &["config", "user.name", "Test"]);
3668 git(dir, &["config", "user.email", "test@test.com"]);
3669 std::fs::write(dir.join("f"), "a").unwrap();
3670 git(dir, &["add", "f"]);
3671 git(dir, &["commit", "-m", "init"]);
3672 let result = count_ahead_commits(dir, "main").await;
3674 assert!(
3675 result.is_err(),
3676 "expected Err when origin/main does not exist, got {result:?}"
3677 );
3678 let err = result.unwrap_err();
3679 assert!(
3680 err.contains("git rev-list exited"),
3681 "error should mention exit failure, got: {err}"
3682 );
3683 }
3684
3685 #[tokio::test]
3686 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
3687 let temp = TempDir::new().unwrap();
3689 let dir = temp.path();
3690 let result = count_ahead_commits(dir, "main").await;
3692 assert!(
3693 result.is_err(),
3694 "expected Err when directory is not a git repo, got {result:?}"
3695 );
3696 }
3697
3698 #[serial_test::serial(cooldown_state)]
3708 #[tokio::test]
3709 async fn review_invalid_response_applies_persistent_model_cooldown() {
3710 crate::engine::cooldown::reset_global_state().await;
3711 let agent = "opencode-3314-review-invalid";
3712 let model = "opencode/north-mini-code-free";
3713 let key = format!("{agent}:{model}");
3714
3715 assert!(
3716 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
3717 "model should not be in cooldown before record_review_agent_failure"
3718 );
3719
3720 let err = runner::agents::AgentError::InvalidResponse {
3721 raw: "empty review output".to_string(),
3722 };
3723 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
3724
3725 let now = chrono::Utc::now().timestamp();
3726 let until = crate::engine::cooldown::cooldown_until(&key)
3727 .expect("review InvalidResponse should set model cooldown");
3728 let remaining = until.saturating_sub(now);
3729
3730 assert!(
3733 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3734 "review InvalidResponse must apply ~4h persistent cooldown (issue #3314), \
3735 got {remaining}s — may have used 5-min standard backoff instead"
3736 );
3737 }
3738
3739 #[serial_test::serial(cooldown_state)]
3747 #[tokio::test]
3748 async fn review_persistent_cooldown_survives_after_first_failure() {
3749 crate::engine::cooldown::reset_global_state().await;
3750 let agent = "opencode-3314-review-parse";
3751 let model = "opencode/north-mini-code-free";
3752 let key = format!("{agent}:{model}");
3753
3754 let err = runner::agents::AgentError::InvalidResponse {
3756 raw: "failed to parse review response".to_string(),
3757 };
3758 record_review_agent_failure("test-3314-b", agent, Some(model), &err).await;
3759
3760 let now = chrono::Utc::now().timestamp();
3761 let until1 = crate::engine::cooldown::cooldown_until(&key)
3762 .expect("first failure should set cooldown");
3763 let remaining1 = until1.saturating_sub(now);
3764
3765 crate::engine::cooldown::record_agent_success(agent, model).await;
3768
3769 assert!(
3775 remaining1 >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3776 "after success reset, first-failure cooldown should still be ~4h, got {remaining1}s"
3777 );
3778 }
3779
3780 #[serial_test::serial(cooldown_state)]
3786 #[tokio::test]
3787 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
3788 crate::engine::cooldown::reset_global_state().await;
3789 let agent = "kimi-3551-review-inline";
3790 let model = "opus";
3791 let key = format!("{agent}:{model}");
3792
3793 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
3795 You've reached your usage limit for this billing cycle. Your quota will be \
3796 refreshed next cycle.";
3797
3798 let scan_tail = runner::agents::patterns::safe_tail(
3799 text_for_review,
3800 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3801 );
3802 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
3803 .expect("billing-cycle message must still be caught by the rate-limit scan");
3804
3805 assert_eq!(
3806 outcome_for_agent_error(&err),
3807 "billing_cycle_exhausted",
3808 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
3809 );
3810
3811 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
3812
3813 let now = chrono::Utc::now().timestamp();
3814 let until = crate::engine::cooldown::cooldown_until(&key)
3815 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
3816 let remaining = until.saturating_sub(now);
3817 assert!(
3822 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3823 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
3824 model cooldown, got {remaining}s — may have used the generic short agent \
3825 cooldown instead"
3826 );
3827 }
3828
3829 fn test_review_task(id: &str) -> ExternalTask {
3832 ExternalTask {
3833 id: crate::backends::ExternalId(id.to_string()),
3834 title: "Test review task".to_string(),
3835 body: "".to_string(),
3836 state: "open".to_string(),
3837 labels: vec![],
3838 author: "tester".to_string(),
3839 created_at: "2026-01-01T00:00:00Z".to_string(),
3840 updated_at: "2026-01-01T00:00:00Z".to_string(),
3841 url: "https://example.com/issues/1".to_string(),
3842 }
3843 }
3844
3845 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
3846 ReviewContext {
3847 store_id: None,
3848 had_prev_push_failure: false,
3849 review_attempt: 1,
3850 worktree_path: work_dir.to_path_buf(),
3851 branch_name: "gh-issue-1-test".to_string(),
3852 pr_number: 1,
3853 head_sha: None,
3854 default_branch: "main".to_string(),
3855 review_agent: agent.to_string(),
3856 review_model: Some(model.to_string()),
3857 review_task_id: "review-1".to_string(),
3858 review_attempt_dir: work_dir.to_path_buf(),
3859 output_file: work_dir.join("output.txt"),
3860 invocation: runner::agent::AgentInvocation {
3861 agent: agent.to_string(),
3862 model: Some(model.to_string()),
3863 work_dir: work_dir.to_path_buf(),
3864 system_prompt: String::new(),
3865 agent_message: String::new(),
3866 task_id: "review-1".to_string(),
3867 disallowed_tools: vec![],
3868 git_author_name: "Test".to_string(),
3869 git_author_email: "test@example.com".to_string(),
3870 output_file: work_dir.join("output.txt"),
3871 timeout_seconds: 0,
3872 repo: "owner/repo".to_string(),
3873 attempt: 1,
3874 },
3875 same_agent_fallback: false,
3876 }
3877 }
3878
3879 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
3880 ParsedReview {
3881 run_id: None,
3882 exit_code: 0,
3883 stderr: String::new(),
3884 raw_output: String::new(),
3885 text_for_review: String::new(),
3886 token_usage: RunTokenUsage::default(),
3887 review_notes_for_comment: String::new(),
3888 decision,
3889 }
3890 }
3891
3892 fn write_review_output(dir: &std::path::Path, stdout: &str, exit_code: i32) {
3893 std::fs::write(dir.join("output.txt"), stdout).unwrap();
3894 std::fs::write(dir.join("exit.txt"), exit_code.to_string()).unwrap();
3895 std::fs::write(dir.join("stderr.txt"), "").unwrap();
3896 }
3897
3898 #[serial_test::serial(cooldown_state)]
3902 #[tokio::test]
3903 async fn parse_review_output_mid_thought_is_incomplete_without_persistent_cooldown() {
3904 crate::engine::cooldown::reset_global_state().await;
3905 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3906 let work_dir = TempDir::new().unwrap();
3907 let agent = "kimi";
3908 let model = "opus-3734-mid-thought";
3909 let ctx = test_review_context(agent, model, work_dir.path());
3910 let redraw = "^D\u{0008}".repeat(20);
3911 let ndjson = format!(
3912 "{}\n{}\n{}\n{}\n{}",
3913 r#"{"type":"system","subtype":"thinking_tokens","tokens":1945}"#,
3914 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}}}"#,
3915 r#"{"type":"assistant","message":{"content":[{"type":"thinking","thinking":"checking lease edge cases"}],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#,
3916 "\x1b[?25h",
3917 redraw,
3918 );
3919 write_review_output(work_dir.path(), &ndjson, 0);
3920
3921 let task = test_review_task("3734");
3922 let phase = parse_review_output(
3923 &task,
3924 "owner/repo",
3925 &ctx,
3926 ReviewRun { run_id: None },
3927 &store,
3928 )
3929 .await;
3930
3931 match phase {
3932 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
3933 assert!(
3934 reason.contains("incomplete:"),
3935 "mid-thought review must be stored as incomplete, got {reason}"
3936 );
3937 }
3938 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
3939 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
3940 }
3941 assert!(
3942 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
3943 "mid-thought incomplete must not apply persistent model cooldown"
3944 );
3945 }
3946
3947 #[serial_test::serial(cooldown_state)]
3950 #[tokio::test]
3951 async fn parse_review_output_tool_use_without_envelope_is_incomplete() {
3952 crate::engine::cooldown::reset_global_state().await;
3953 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3954 let work_dir = TempDir::new().unwrap();
3955 let agent = "kimi";
3956 let model = "opus-3734-tool-use";
3957 let ctx = test_review_context(agent, model, work_dir.path());
3958 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs"}}]}}"#;
3959 write_review_output(work_dir.path(), ndjson, 0);
3960
3961 let task = test_review_task("3734-tool");
3962 let phase = parse_review_output(
3963 &task,
3964 "owner/repo",
3965 &ctx,
3966 ReviewRun { run_id: None },
3967 &store,
3968 )
3969 .await;
3970
3971 match phase {
3972 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
3973 assert!(
3974 reason.contains("incomplete:"),
3975 "tool_use without envelope must be incomplete, got {reason}"
3976 );
3977 }
3978 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(incomplete)"),
3979 ReviewPhase::Ready(_) => panic!("expected Failed(incomplete), got Ready"),
3980 }
3981 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
3982 }
3983
3984 #[serial_test::serial(cooldown_state)]
3987 #[tokio::test]
3988 async fn parse_review_output_unparseable_envelope_applies_persistent_cooldown() {
3989 crate::engine::cooldown::reset_global_state().await;
3990 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3991 let work_dir = TempDir::new().unwrap();
3992 let agent = "kimi";
3993 let model = "opus-3734-format";
3994 let ctx = test_review_context(agent, model, work_dir.path());
3995 let ndjson = concat!(
3996 r#"{"type":"assistant","message":{"content":[{"type":"text","text":"I have finished looking at the code."}]}}"#,
3997 "\n",
3998 r#"{"type":"result","subtype":"success","is_error":false,"result":"I have finished looking at the code."}"#,
3999 );
4000 write_review_output(work_dir.path(), ndjson, 0);
4001
4002 let task = test_review_task("3734-format");
4003 let phase = parse_review_output(
4004 &task,
4005 "owner/repo",
4006 &ctx,
4007 ReviewRun { run_id: None },
4008 &store,
4009 )
4010 .await;
4011
4012 match phase {
4013 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4014 assert!(
4015 reason.contains("parse error"),
4016 "finished unparseable envelope must stay a parse error, got {reason}"
4017 );
4018 }
4019 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4020 ReviewPhase::Ready(_) => panic!("expected Failed(parse error), got Ready"),
4021 }
4022 assert!(
4023 crate::engine::cooldown::is_model_in_cooldown(agent, model),
4024 "finished format failure must apply persistent model cooldown"
4025 );
4026 }
4027
4028 #[serial_test::serial(cooldown_state)]
4031 #[tokio::test]
4032 async fn parse_review_output_missing_envelope_uses_assistant_decision() {
4033 crate::engine::cooldown::reset_global_state().await;
4034 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4035 let work_dir = TempDir::new().unwrap();
4036 let agent = "kimi";
4037 let model = "opus-3734-assistant";
4038 let ctx = test_review_context(agent, model, work_dir.path());
4039 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"{\"decision\":\"approve\",\"notes\":\"LGTM\",\"issues\":[]}"}]}}"#;
4040 write_review_output(work_dir.path(), ndjson, 0);
4041
4042 let task = test_review_task("3734-asst");
4043 let phase = parse_review_output(
4044 &task,
4045 "owner/repo",
4046 &ctx,
4047 ReviewRun { run_id: None },
4048 &store,
4049 )
4050 .await;
4051
4052 match phase {
4053 ReviewPhase::Ready(parsed) => {
4054 assert!(matches!(parsed.decision, ReviewDecision::Approve));
4055 }
4056 ReviewPhase::EarlyReturn(_) => {
4057 panic!("expected Ready(Approve) from assistant text, got EarlyReturn")
4058 }
4059 }
4060 assert!(!crate::engine::cooldown::is_model_in_cooldown(agent, model));
4061 }
4062
4063 #[serial_test::serial(cooldown_state)]
4066 #[tokio::test]
4067 async fn parse_review_output_does_not_treat_task_done_synthesis_as_approval() {
4068 crate::engine::cooldown::reset_global_state().await;
4069 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4070 let work_dir = TempDir::new().unwrap();
4071 let agent = "kimi";
4072 let model = "opus-3734-done";
4073 let ctx = test_review_context(agent, model, work_dir.path());
4074 let ndjson = r#"{"type":"assistant","message":{"content":[{"type":"text","text":"analyzing the diff"}]}}"#;
4075 write_review_output(work_dir.path(), ndjson, 0);
4076
4077 let task = test_review_task("3734-done");
4078 let phase = parse_review_output(
4079 &task,
4080 "owner/repo",
4081 &ctx,
4082 ReviewRun { run_id: None },
4083 &store,
4084 )
4085 .await;
4086
4087 match phase {
4088 ReviewPhase::EarlyReturn(ReviewDecision::Failed(reason)) => {
4089 assert!(
4090 reason.contains("parse error"),
4091 "unfinished prose without a decision must not approve, got {reason}"
4092 );
4093 }
4094 ReviewPhase::Ready(_) => {
4095 panic!("task-path status=done synthesis must not become a review approval")
4096 }
4097 ReviewPhase::EarlyReturn(_) => panic!("expected Failed(parse error)"),
4098 }
4099 }
4100
4101 #[serial_test::serial(cooldown_state)]
4107 #[tokio::test]
4108 async fn finalize_review_run_success_resets_generic_model_failure_count() {
4109 crate::engine::cooldown::reset_global_state().await;
4110 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4111 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4112
4113 let agent = "test-3478-agent";
4114 let model = "test-3478-model";
4115 let failure_key = format!("failure_count:{agent}:{model}");
4116
4117 let err = runner::agents::AgentError::AgentFailed {
4121 message: "agent reported a generic failure".to_string(),
4122 };
4123 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
4124 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4125 assert_eq!(
4126 store.kv_get(&failure_key).await.unwrap().as_deref(),
4127 Some("1"),
4128 "sanity check: failure should have incremented the persisted count"
4129 );
4130
4131 let work_dir = TempDir::new().unwrap();
4132 let ctx = test_review_context(agent, model, work_dir.path());
4133 let parsed = test_parsed_review(ReviewDecision::Approve);
4134 let task = test_review_task("1");
4135
4136 finalize_review_run(&task, &ctx, &parsed, &store).await;
4137 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4138
4139 assert_eq!(
4140 store.kv_get(&failure_key).await.unwrap().as_deref(),
4141 Some("0"),
4142 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
4143 );
4144 }
4145
4146 #[serial_test::serial(cooldown_state)]
4152 #[tokio::test]
4153 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
4154 crate::engine::cooldown::reset_global_state().await;
4155 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4156 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4157
4158 let agent = "test-3478-interleave-agent";
4159 let model = "test-3478-interleave-model";
4160 let cooldown_key = format!("{agent}:{model}");
4161
4162 let work_dir = TempDir::new().unwrap();
4163 let ctx = test_review_context(agent, model, work_dir.path());
4164 let task = test_review_task("2");
4165
4166 for i in 0..4 {
4167 let err = runner::agents::AgentError::AgentFailed {
4168 message: format!("failure {i}"),
4169 };
4170 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
4171
4172 let now = chrono::Utc::now().timestamp();
4173 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4174 .expect("failure should set cooldown");
4175 let remaining = until.saturating_sub(now);
4176
4177 assert!(
4178 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
4179 "failure #{} must stay at base backoff since the prior success reset the \
4180 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
4181 here means review successes are not resetting failure_count",
4182 i + 1,
4183 crate::engine::cooldown::BACKOFF_BASE_SECS
4184 );
4185
4186 let parsed = test_parsed_review(ReviewDecision::Approve);
4190 finalize_review_run(&task, &ctx, &parsed, &store).await;
4191 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4192 }
4193 }
4194
4195 #[serial_test::serial(cooldown_state)]
4201 #[tokio::test]
4202 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
4203 crate::engine::cooldown::reset_global_state().await;
4204 let store = Arc::new(TaskStore::open_memory().await.unwrap());
4205 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
4206
4207 let agent = "test-3571-agent";
4208 let model = "test-3571-model";
4209 let cooldown_key = format!("{agent}:{model}");
4210
4211 let work_dir = TempDir::new().unwrap();
4212 let ctx = test_review_context(agent, model, work_dir.path());
4213 let task = test_review_task("3");
4214
4215 let err = runner::agents::AgentError::InvalidResponse {
4217 raw: "empty review output".to_string(),
4218 };
4219 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
4220 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4221
4222 let parsed = test_parsed_review(ReviewDecision::Approve);
4225 finalize_review_run(&task, &ctx, &parsed, &store).await;
4226 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4227
4228 let err2 = runner::agents::AgentError::InvalidResponse {
4231 raw: "empty review output again".to_string(),
4232 };
4233 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
4234 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
4235
4236 let now = chrono::Utc::now().timestamp();
4237 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
4238 .expect("second failure should set cooldown");
4239 let remaining = until.saturating_sub(now);
4240
4241 assert!(
4242 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
4243 "second persistent model failure should escalate past the 4h base despite an \
4244 intervening success (issue #3571); got {remaining}s, base is {}s",
4245 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
4246 );
4247 }
4248}