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