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