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 default_branch: String,
179 review_agent: String,
180 review_model: Option<String>,
181 review_task_id: String,
182 review_attempt_dir: std::path::PathBuf,
183 output_file: std::path::PathBuf,
184 invocation: runner::agent::AgentInvocation,
185 same_agent_fallback: bool,
186}
187
188impl ReviewContext {
189 fn review_model_str(&self) -> &str {
190 self.review_model.as_deref().unwrap_or("")
191 }
192}
193
194struct ReviewRun {
195 run_id: Option<i64>,
196}
197
198struct ParsedReview {
199 run_id: Option<i64>,
200 exit_code: i32,
201 stderr: String,
202 raw_output: String,
203 text_for_review: String,
204 token_usage: RunTokenUsage,
205 review_notes_for_comment: String,
206 decision: ReviewDecision,
207}
208
209fn build_review_disallowed_tools(worktree_path: &std::path::Path) -> Vec<String> {
210 let mut tools = vec![
211 "Bash(rm *)".to_string(),
212 "Bash(rm -*)".to_string(),
213 "Bash(git push*)".to_string(),
214 ];
215 let orch_yml = worktree_path.join(".orch.yml");
216 let orch_yml_str = orch_yml.to_string_lossy();
217 tools.extend([
218 format!("Write({orch_yml_str})"),
219 format!("Edit({orch_yml_str})"),
220 ]);
221 if let Ok(orch_home) = crate::home::orch_home() {
222 for path in [
223 orch_home.join("config.yml"),
224 orch_home.join("config.example.yml"),
225 ] {
226 let path_str = path.to_string_lossy();
227 tools.extend([
228 format!("Read({path_str})"),
229 format!("Write({path_str})"),
230 format!("Edit({path_str})"),
231 ]);
232 }
233 }
234 tools
235}
236
237async fn select_review_agent(
238 task_id: &str,
239 task_agent: &str,
240 store_id: Option<i64>,
241 router: &Arc<RwLock<Router>>,
242 store: &Arc<TaskStore>,
243) -> anyhow::Result<(String, Option<String>, bool)> {
244 let mut exclude_set: HashSet<String> = HashSet::new();
245 if !task_agent.is_empty() {
246 exclude_set.insert(task_agent.to_string());
247 }
248 if let Some(store_id) = store_id {
249 if let Ok(failed) = store.previous_review_agents(store_id).await {
250 for agent in failed {
251 exclude_set.insert(agent);
252 }
253 }
254 }
255
256 let mut r = router.write().await;
257 let mut tried_agents: HashSet<String> = HashSet::new();
258 let mut chosen_agent: Option<String> = None;
259 let mut chosen_model: Option<String> = None;
260 let available_count = r.available_agents.len().max(1);
261
262 loop {
263 let tmp_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
264 let agent = match r.next_round_robin_agent(&tmp_exclude_refs, "review") {
265 Some(a) => a,
266 None => break,
267 };
268
269 if tried_agents.contains(&agent) {
270 break;
271 }
272 tried_agents.insert(agent.clone());
273
274 let model = r.config.model_for_complexity(&agent, "review", task_id);
275 if model.is_some() {
276 chosen_agent = Some(agent);
277 chosen_model = model;
278 break;
279 }
280
281 exclude_set.insert(agent);
282 if exclude_set.len() >= available_count {
283 break;
284 }
285 }
286
287 if let Some(agent) = chosen_agent {
288 let same = !task_agent.is_empty() && agent == task_agent;
289 Ok((agent, chosen_model, same))
290 } else {
291 let final_exclude_refs: Vec<&str> = exclude_set.iter().map(|s| s.as_str()).collect();
292 let fallback_agent = r
293 .next_round_robin_agent(&final_exclude_refs, "review")
294 .unwrap_or_else(|| "claude".to_string());
295 let fallback_model = r
296 .config
297 .model_for_complexity(&fallback_agent, "review", task_id);
298 if fallback_model.is_none() {
299 anyhow::bail!(
300 "no review agent with a configured review model is available for task {}",
301 task_id
302 );
303 }
304 let same = !task_agent.is_empty() && fallback_agent == task_agent;
305 Ok((fallback_agent, fallback_model, same))
306 }
307}
308
309#[allow(clippy::too_many_arguments)]
310async fn build_review_context(
311 task: &ExternalTask,
312 repo: &str,
313 router: &Arc<RwLock<Router>>,
314 task_manager: &Arc<TaskManager>,
315 store: &Arc<TaskStore>,
316) -> anyhow::Result<ReviewPhase<ReviewContext>> {
317 let stored_task = store
318 .get_by_external_id(repo, &task.id.0)
319 .await
320 .with_context(|| format!("store lookup failed for task {}", task.id.0))?;
321
322 let store_id: Option<i64> = stored_task.as_ref().map(|t| t.id);
323 let had_prev_push_failure = stored_task
324 .as_ref()
325 .is_some_and(|t| t.last_error.contains("push failed"));
326 let review_attempt: u32 = match store_increment(
327 &Some(Arc::clone(store)),
328 repo,
329 &task.id.0,
330 "review_invocations",
331 )
332 .await
333 {
334 Ok(v) => v as u32,
335 Err(e) => {
336 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment review_invocations — using fallback attempt=1");
337 1
338 }
339 };
340
341 let no_code_review = stored_task.as_ref().is_some_and(|t| {
342 t.worktree.trim().is_empty() && t.branch.trim().is_empty() && t.pr_number.is_none()
343 });
344 if no_code_review {
345 if crate::engine::tasks::is_internal_id(&task.id.0) {
346 tracing::info!(
348 task_id = task.id.0,
349 "no worktree/branch/pr for internal needs_review task — skipping review and marking done"
350 );
351 if let Err(e) = task_manager
352 .update_task_status(&task.id, Status::Done)
353 .await
354 {
355 tracing::error!(
356 task_id = task.id.0,
357 err = %e,
358 "update_task_status(Done) failed for no-code review skip"
359 );
360 }
361 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Skipped));
362 }
363
364 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
367 .ok()
368 .and_then(|s| s.parse().ok())
369 .or_else(|| {
370 config::get("workflow.max_attempts")
371 .ok()
372 .and_then(|s| s.parse().ok())
373 })
374 .unwrap_or(3);
375
376 let reroutes = match store_increment(
377 &Some(Arc::clone(store)),
378 repo,
379 &task.id.0,
380 "no_code_reroutes",
381 )
382 .await
383 {
384 Ok(v) => v,
385 Err(e) => {
386 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
387 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
388 "transient store error: {e}"
389 ))));
390 }
391 };
392
393 if reroutes as u32 >= max_reroutes {
394 tracing::error!(
395 task_id = task.id.0,
396 reroutes,
397 max_reroutes,
398 "reached max reroute attempts for no-worktree/branch/pr result — blocking for human review"
399 );
400 let msg = format!(
401 "no worktree/branch/pr after {}/{} reroute attempts",
402 reroutes, max_reroutes
403 );
404 let fields = [
405 (
406 "block_reason",
407 serde_json::json!(format!(
408 "max reroute attempts ({}) reached — no worktree/branch/pr produced",
409 max_reroutes
410 )),
411 ),
412 ("last_error", serde_json::json!(msg)),
413 ("agent", serde_json::json!(null)),
414 ("model", serde_json::json!(null)),
415 ];
416 if let Err(e) = task_manager
417 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
418 .await
419 {
420 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
421 }
422 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(msg)));
423 }
424
425 tracing::warn!(
426 task_id = task.id.0,
427 reroutes,
428 max_reroutes,
429 "no worktree/branch/pr for external needs_review task — re-routing to a different agent"
430 );
431 if let Err(e) = store_set_result(
432 &Some(Arc::clone(store)),
433 repo,
434 &task.id.0,
435 &[
436 ("agent", serde_json::json!(null)),
437 ("model", serde_json::json!(null)),
438 (
439 "last_error",
440 serde_json::json!("no worktree/branch/pr produced"),
441 ),
442 ],
443 )
444 .await
445 {
446 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
447 } else if let Err(e) = task_manager.update_task_status(&task.id, Status::New).await {
448 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
449 }
450 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Rerouted));
451 }
452
453 let mut worktree_path = match stored_task.as_ref().map(|t| t.worktree.as_str()) {
454 Some(w) if !w.is_empty() => std::path::PathBuf::from(w),
455 _ => {
456 tracing::warn!(task_id = task.id.0, "no worktree found for review");
457 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
458 "no worktree found".to_string(),
459 )));
460 }
461 };
462
463 let mut branch_name = match stored_task.as_ref().map(|t| t.branch.as_str()) {
464 Some(b) if !b.is_empty() => b.to_string(),
465 _ => {
466 tracing::warn!(task_id = task.id.0, "no branch found for review");
467 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Failed(
468 "no branch found".to_string(),
469 )));
470 }
471 };
472
473 let mut missing_worktree_recovered = false;
474 if !worktree_path.exists() {
475 match recover_missing_review_worktree(task, repo, store).await {
476 Ok(recovered) => {
477 branch_name = recovered.branch;
478 worktree_path = recovered.work_dir;
479 missing_worktree_recovered = true;
480 }
481 Err(e) => {
482 let reason = format!("missing review worktree recovery failed: {e}");
483 tracing::error!(task_id = task.id.0, error = %reason, "cannot recover review worktree");
484 store_set(
485 &Some(Arc::clone(store)),
486 repo,
487 &task.id.0,
488 &[("last_error", serde_json::json!(reason.clone()))],
489 )
490 .await;
491 return Ok(ReviewPhase::EarlyReturn(ReviewDecision::Blocked(reason)));
492 }
493 }
494 }
495
496 if missing_worktree_recovered {
497 store_set(
498 &Some(Arc::clone(store)),
499 repo,
500 &task.id.0,
501 &[("last_error", serde_json::json!(""))],
502 )
503 .await;
504 }
505
506 let agent_summary = stored_task
507 .as_ref()
508 .map(|t| t.summary.clone())
509 .unwrap_or_default();
510 let stored_pr_number = stored_task
511 .as_ref()
512 .and_then(|t| t.pr_number)
513 .map(|n| n as u64)
514 .filter(|&n| n > 0);
515
516 let pr_number = match ensure_pr_exists(
517 task,
518 &branch_name,
519 &worktree_path,
520 repo,
521 &agent_summary,
522 store,
523 task_manager,
524 stored_pr_number,
525 )
526 .await?
527 {
528 EnsurePrResult::Ready(n) => n,
529 EnsurePrResult::EarlyReturn(decision) => return Ok(ReviewPhase::EarlyReturn(decision)),
530 };
531
532 {
536 let fetch_timeout = std::time::Duration::from_secs(60);
537 let fetch_future = async {
538 let mut cmd = Command::new("git");
539 cmd.args(["fetch", "origin", "--prune"])
540 .current_dir(&worktree_path);
541 cmd.kill_on_drop(true);
543 cmd.output_with_context().await
544 };
545
546 match tokio::time::timeout(fetch_timeout, fetch_future).await {
547 Ok(Ok(o)) if o.status.success() => {
548 }
550 Ok(Ok(o)) => {
551 tracing::warn!(
552 task_id = task.id.0,
553 exit_code = ?o.status.code(),
554 stdout = %String::from_utf8_lossy(&o.stdout),
555 stderr = %String::from_utf8_lossy(&o.stderr),
556 "review: git fetch failed (non-zero exit) — diff/log may use stale remote refs"
557 );
558 }
559 Ok(Err(e)) => {
560 tracing::warn!(
561 task_id = task.id.0,
562 error = %e,
563 "review: git fetch failed — diff/log may use stale remote refs"
564 );
565 }
566 Err(_) => {
567 tracing::warn!(
568 task_id = task.id.0,
569 "review: git fetch timed out after 60s — diff/log may use stale remote refs"
570 );
571 }
572 }
573 }
574
575 let default_branch = runner::worktree::detect_default_branch(&worktree_path).await;
576 let git_diff = runner::context::build_git_diff(&worktree_path, &default_branch).await;
577 let git_log = runner::context::build_git_log(&worktree_path, &default_branch).await;
578 let review_prompt = runner::agent::build_review_prompt(
579 task,
580 &agent_summary,
581 &git_diff,
582 &git_log,
583 &default_branch,
584 pr_number,
585 runner::agent::Shipcheck::detect(&worktree_path).await,
586 );
587
588 let task_agent = stored_task
589 .as_ref()
590 .and_then(|t| t.agent.clone())
591 .unwrap_or_default();
592 let (review_agent, review_model, same_agent_fallback) =
593 select_review_agent(&task.id.0, &task_agent, store_id, router, store).await?;
594
595 anyhow::ensure!(
596 review_model.is_some(),
597 "review model missing for task {} after agent selection — refusing to dispatch",
598 task.id.0
599 );
600
601 if same_agent_fallback {
602 tracing::warn!(
603 task_id = task.id.0,
604 agent = %review_agent,
605 "no other agent available for review — falling back to the agent that wrote the code"
606 );
607 store_log_activity(
608 &Some(Arc::clone(store)),
609 repo,
610 &task.id.0,
611 "review_agent_fallback",
612 None,
613 None,
614 Some(&review_agent),
615 review_model.as_deref(),
616 Some(&serde_json::json!({
617 "reason": "all available agents excluded (author + previous reviewers)",
618 })),
619 )
620 .await;
621 }
622
623 tracing::info!(
624 task_id = task.id.0,
625 agent = %review_agent,
626 model = ?review_model,
627 "spawning review agent"
628 );
629
630 let review_task_id = format!("{}-review", task.id.0);
631 let review_attempt_dir =
632 crate::home::task_attempt_dir_async(repo, &review_task_id, review_attempt).await?;
633 let output_file = review_attempt_dir.join("output.json");
634
635 let git_name =
636 crate::config::get("git.name").unwrap_or_else(|_| format!("{review_agent}[bot]"));
637 let git_email = crate::config::get("git.email")
638 .unwrap_or_else(|_| format!("{review_agent}[bot]@users.noreply.github.com"));
639 let review_timeout_secs: u64 = crate::config::get("workflow.review_timeout_seconds")
640 .ok()
641 .and_then(|s| s.parse().ok())
642 .unwrap_or_else(|| {
643 crate::config::get("workflow.timeout_seconds")
644 .ok()
645 .and_then(|s| s.parse().ok())
646 .unwrap_or(1800)
647 })
648 .max(1800);
649
650 let invocation = runner::agent::AgentInvocation {
651 agent: review_agent.clone(),
652 model: review_model.clone(),
653 work_dir: worktree_path.clone(),
654 system_prompt: runner::agent::review_system_prompt(),
655 agent_message: review_prompt,
656 task_id: review_task_id.clone(),
657 disallowed_tools: build_review_disallowed_tools(&worktree_path),
658 git_author_name: git_name,
659 git_author_email: git_email,
660 output_file: output_file.clone(),
661 timeout_seconds: review_timeout_secs,
662 repo: repo.to_string(),
663 attempt: review_attempt,
664 };
665
666 Ok(ReviewPhase::Ready(ReviewContext {
667 store_id,
668 had_prev_push_failure,
669 review_attempt,
670 worktree_path,
671 branch_name,
672 pr_number,
673 default_branch,
674 review_agent,
675 review_model,
676 review_task_id,
677 review_attempt_dir,
678 output_file,
679 invocation,
680 same_agent_fallback,
681 }))
682}
683
684#[allow(clippy::too_many_arguments)]
685async fn complete_review_run(
686 store: &Arc<TaskStore>,
687 run_id: Option<i64>,
688 exit_code: Option<i32>,
689 stdout: &str,
690 stderr: &str,
691 parsed: &str,
692 outcome: &str,
693 error: &str,
694 tokens: RunTokenUsage,
695) {
696 if let Some(run_id) = run_id {
697 if let Err(e) = store
698 .complete_run(&CompleteRun {
699 run_id,
700 exit_code,
701 stdout,
702 stderr,
703 parsed,
704 outcome,
705 error,
706 tokens,
707 })
708 .await
709 {
710 tracing::warn!(
711 run_id,
712 error = %e,
713 "failed to record review run completion in audit trail"
714 );
715 }
716 }
717}
718
719async fn invoke_review_agent(
720 task: &ExternalTask,
721 tmux: &Arc<TmuxManager>,
722 ctx: &ReviewContext,
723 repo: &str,
724 store: &Arc<TaskStore>,
725) -> ReviewPhase<ReviewRun> {
726 let run_id = if let Some(sid) = ctx.store_id {
727 match store
728 .start_run(&StartRun {
729 task_id: sid,
730 attempt: ctx.review_attempt as i32,
731 run_type: "review",
732 agent: &ctx.review_agent,
733 model: ctx.review_model.as_deref().unwrap_or(""),
734 command: "",
735 prompt: "",
736 })
737 .await
738 {
739 Ok(run_id) => Some(run_id),
740 Err(e) => {
741 tracing::warn!(
742 task_id = task.id.0,
743 error = %e,
744 "failed to record review run start in audit trail"
745 );
746 None
747 }
748 }
749 } else {
750 None
751 };
752
753 let session = match runner::agent::spawn_in_tmux(tmux, &ctx.invocation).await {
754 Ok(session) => session,
755 Err(e) => {
756 tracing::error!(task_id = task.id.0, error = %e, "failed to spawn review agent");
757 complete_review_run(
758 store,
759 run_id,
760 None,
761 "",
762 &e.to_string(),
763 "",
764 "failed",
765 &format!("spawn failed: {e}"),
766 RunTokenUsage::default(),
767 )
768 .await;
769 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("spawn failed: {e}")));
770 }
771 };
772
773 let start_comment = review_started_comment(&ctx.review_agent, ctx.review_model_str());
774 match GhHttp::new() {
775 Ok(gh) => {
776 if let Err(e) = gh
777 .add_comment(repo, &ctx.pr_number.to_string(), &start_comment)
778 .await
779 {
780 tracing::debug!(
781 task_id = task.id.0,
782 pr_number = ctx.pr_number,
783 error = %e,
784 "failed to post review-started comment"
785 );
786 }
787 }
788 Err(e) => {
789 tracing::debug!(
790 task_id = task.id.0,
791 pr_number = ctx.pr_number,
792 error = %e,
793 "failed to create GitHub client for review-started comment"
794 );
795 }
796 }
797
798 let poll_interval = std::time::Duration::from_secs(5);
799 let timeout_duration = std::time::Duration::from_secs(ctx.invocation.timeout_seconds);
800 let wait_result = tokio::time::timeout(
801 timeout_duration,
802 tmux.wait_for_completion(&session, poll_interval),
803 )
804 .await;
805
806 match wait_result {
807 Ok(Ok(_)) => {
808 tracing::info!(task_id = task.id.0, "review agent completed");
809 let _ = tmux.kill_session(&session).await;
810 ReviewPhase::Ready(ReviewRun { run_id })
811 }
812 Ok(Err(e)) => {
813 tracing::warn!(task_id = task.id.0, error = %e, "review agent process exited with error");
814 let _ = tmux.kill_session(&session).await;
815
816 let exit_code: i32 = tokio::task::spawn_blocking({
821 let review_attempt_dir = ctx.review_attempt_dir.clone();
822 move || {
823 std::fs::read_to_string(review_attempt_dir.join("exit.txt"))
824 .ok()
825 .and_then(|s| s.trim().parse().ok())
826 .unwrap_or(-1)
827 }
828 })
829 .await
830 .unwrap_or(-1);
831
832 if exit_code != 0 {
833 let raw_output =
838 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo)
839 .await;
840 let agent_result =
841 runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
842 if agent_result.as_ref().map(|r| !r.is_error).unwrap_or(false) {
844 tracing::info!(
845 task_id = task.id.0,
846 agent = %ctx.review_agent,
847 exit_code,
848 output_found = !raw_output.is_empty(),
849 "review agent succeeded despite non-zero exit — proceeding to parse output"
850 );
851 return ReviewPhase::Ready(ReviewRun { run_id });
852 }
853 }
854
855 tracing::error!(task_id = task.id.0, error = %e, "review agent failed");
856 complete_review_run(
857 store,
858 run_id,
859 Some(exit_code),
860 "",
861 &e.to_string(),
862 "",
863 "failed",
864 &format!("agent error: {e}"),
865 RunTokenUsage::default(),
866 )
867 .await;
868 ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!("agent error: {e}")))
869 }
870 Err(_) => {
871 tracing::error!(task_id = task.id.0, "review agent timed out");
872 let _ = tmux.kill_session(&session).await;
873 if let Some(model) = ctx.review_model.as_deref() {
874 crate::engine::cooldown::record_persistent_model_failure(&ctx.review_agent, model)
879 .await;
880 }
881 complete_review_run(
882 store,
883 run_id,
884 Some(-1),
885 "",
886 "timeout",
887 "",
888 "timeout",
889 "timeout",
890 RunTokenUsage::default(),
891 )
892 .await;
893 ReviewPhase::EarlyReturn(ReviewDecision::Failed("timeout".to_string()))
894 }
895 }
896}
897
898async fn record_review_agent_failure(
899 task_id: &str,
900 review_agent: &str,
901 review_model: Option<&str>,
902 err: &runner::agents::AgentError,
903) {
904 match err {
905 runner::agents::AgentError::RateLimit { message }
906 | runner::agents::AgentError::Auth { message } => {
907 tracing::warn!(
908 task_id,
909 agent = %review_agent,
910 "review agent hit error — adding to cooldown"
911 );
912 if let Some(reason) = crate::engine::cooldown::detect_credit_exhaustion(message) {
913 if reason == crate::engine::cooldown::CreditExhaustionReason::BillingCycleExhausted
914 {
915 if let Some(model) = review_model {
916 crate::engine::cooldown::record_persistent_model_failure(
918 review_agent,
919 model,
920 )
921 .await;
922 } else {
923 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason)
924 .await;
925 }
926 } else {
927 crate::engine::cooldown::record_credit_exhaustion(review_agent, reason).await;
928 }
929 } else {
930 runner::response::record_agent_failure_with_message(review_agent, &err.to_string())
931 .await;
932 }
933 }
934 runner::agents::AgentError::InvalidResponse { .. } => {
935 if let Some(model) = review_model {
940 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
941 }
942 }
943 runner::agents::AgentError::ModelUnavailable { .. } => {
944 if let Some(model) = review_model {
949 crate::engine::cooldown::record_persistent_model_failure(review_agent, model).await;
950 }
951 }
952 _ => {
953 if let Some(model) = review_model {
956 crate::engine::cooldown::record_model_failure(review_agent, model).await;
957 }
958 }
959 }
960}
961
962fn review_decision_from_response(
963 review_response: runner::response::ReviewResponse,
964) -> ReviewDecision {
965 match review_response.decision.as_str() {
966 "approve" => ReviewDecision::Approve,
967 "request_changes" => ReviewDecision::RequestChanges {
968 notes: review_response.notes,
969 issues: review_response.issues,
970 },
971 _ => ReviewDecision::Failed(format!("unknown decision: {}", review_response.decision)),
972 }
973}
974
975fn review_decision_name(decision: &ReviewDecision) -> &'static str {
976 match decision {
977 ReviewDecision::Approve => "approve",
978 ReviewDecision::RequestChanges { .. } => "request_changes",
979 ReviewDecision::Failed(_) => "failed",
980 ReviewDecision::Blocked(_) => "blocked",
981 ReviewDecision::Skipped => "skipped",
982 ReviewDecision::Rerouted => "rerouted",
983 }
984}
985
986async fn parse_review_output(
987 task: &ExternalTask,
988 repo: &str,
989 ctx: &ReviewContext,
990 run: ReviewRun,
991 store: &Arc<TaskStore>,
992) -> ReviewPhase<ParsedReview> {
993 let (file_exists, file_size, exit_code, stderr) = {
994 let output_file_clone = ctx.output_file.clone();
995 let review_attempt_dir_clone = ctx.review_attempt_dir.clone();
996 match tokio::task::spawn_blocking(move || {
997 let file_exists = output_file_clone.exists();
998 let file_size = std::fs::metadata(&output_file_clone)
999 .map(|m| m.len())
1000 .unwrap_or(0);
1001 let exit_code = std::fs::read_to_string(review_attempt_dir_clone.join("exit.txt"))
1002 .ok()
1003 .and_then(|s| s.trim().parse::<i32>().ok())
1004 .unwrap_or(-1);
1005 let stderr = std::fs::read_to_string(review_attempt_dir_clone.join("stderr.txt"))
1006 .unwrap_or_default();
1007 (file_exists, file_size, exit_code, stderr)
1008 })
1009 .await
1010 {
1011 Ok(tuple) => tuple,
1012 Err(e) => {
1013 tracing::error!(
1014 task_id = task.id.0,
1015 error = %e,
1016 "spawn_blocking panicked reading review output metadata"
1017 );
1018 (false, 0, -1, format!("spawn_blocking failed: {e}"))
1019 }
1020 }
1021 };
1022
1023 tracing::info!(
1024 task_id = task.id.0,
1025 path = %ctx.output_file.display(),
1026 file_exists,
1027 file_size,
1028 "reading review agent output"
1029 );
1030
1031 let raw_output =
1032 runner::response::read_output_file(&ctx.review_task_id, &ctx.output_file, repo).await;
1033 let agent_result = runner::agents::find_agent_result(&ctx.review_agent, &raw_output);
1034 let token_usage = RunTokenUsage {
1035 input_tokens: agent_result
1036 .as_ref()
1037 .and_then(|r| r.input_tokens)
1038 .unwrap_or(0),
1039 output_tokens: agent_result
1040 .as_ref()
1041 .and_then(|r| r.output_tokens)
1042 .unwrap_or(0),
1043 total_cost_usd: agent_result
1044 .as_ref()
1045 .and_then(|r| r.cost_usd)
1046 .unwrap_or(0.0),
1047 duration_secs: 0.0,
1048 };
1049
1050 let agent_result_is_error = agent_result.as_ref().map(|r| r.is_error).unwrap_or(false);
1051 let ndjson_completed_no_error = raw_output.contains("\"terminal_reason\":\"completed\"")
1052 && !raw_output.contains("\"is_error\":true");
1053 let is_hard_failure =
1055 !ndjson_completed_no_error && (raw_output.is_empty() || agent_result_is_error);
1056
1057 if is_hard_failure {
1058 tracing::warn!(
1059 task_id = task.id.0,
1060 exit_code,
1061 raw_output_len = raw_output.len(),
1062 stderr_len = stderr.len(),
1063 "review agent: entering error path"
1064 );
1065 let agent_runner = runner::agents::get_runner(&ctx.review_agent);
1066 let err = agent_runner.classify_error(exit_code, &raw_output, &stderr);
1067 record_review_agent_failure(
1068 &task.id.0,
1069 &ctx.review_agent,
1070 ctx.review_model.as_deref(),
1071 &err,
1072 )
1073 .await;
1074 tracing::error!(task_id = task.id.0, error = %err, "review agent failed");
1075 let stored_error = format_review_error(&ctx.review_agent, &err);
1076 complete_review_run(
1077 store,
1078 run.run_id,
1079 Some(exit_code),
1080 &raw_output,
1081 &stderr,
1082 "",
1083 outcome_for_agent_error(&err),
1084 &stored_error,
1085 token_usage,
1086 )
1087 .await;
1088 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1089 }
1090
1091 let text_for_review = agent_result
1092 .as_ref()
1093 .map(|r| r.result_text.clone())
1094 .filter(|text| !text.is_empty())
1095 .unwrap_or_else(|| {
1096 tracing::debug!(
1097 task_id = task.id.0,
1098 agent = %ctx.review_agent,
1099 "review agent: per-agent extractor returned no text, using raw output"
1100 );
1101 raw_output.clone()
1102 });
1103
1104 let review_response = match runner::response::parse_review_response(&text_for_review) {
1105 Ok(response) => response,
1106 Err(e) => {
1107 if let Some(response) = runner::response::infer_review_response(&text_for_review) {
1108 tracing::warn!(
1109 task_id = task.id.0,
1110 agent = %ctx.review_agent,
1111 "review response parsed via keyword fallback"
1112 );
1113 response
1114 } else {
1115 let ndjson_completed = ndjson_completed_no_error;
1121
1122 if ndjson_completed {
1127 let assistant_text = runner::agents::collect_assistant_messages_text(
1128 &ctx.review_agent,
1129 &raw_output,
1130 );
1131 if !assistant_text.is_empty() {
1132 if let Ok(response) =
1133 runner::response::parse_review_response(&assistant_text)
1134 {
1135 tracing::debug!(
1136 task_id = task.id.0,
1137 agent = %ctx.review_agent,
1138 "found review response JSON in assistant message (terminal_reason:completed)"
1139 );
1140 let review_notes_for_comment = response.notes.clone();
1141 let decision = review_decision_from_response(response);
1142 return ReviewPhase::Ready(ParsedReview {
1143 run_id: run.run_id,
1144 exit_code,
1145 stderr,
1146 raw_output,
1147 text_for_review: assistant_text,
1148 token_usage,
1149 review_notes_for_comment,
1150 decision,
1151 });
1152 }
1153 if let Some(response) =
1154 runner::response::infer_review_response(&assistant_text)
1155 {
1156 tracing::warn!(
1157 task_id = task.id.0,
1158 agent = %ctx.review_agent,
1159 "inferred review response from assistant message (terminal_reason:completed)"
1160 );
1161 let review_notes_for_comment = response.notes.clone();
1162 let decision = review_decision_from_response(response);
1163 return ReviewPhase::Ready(ParsedReview {
1164 run_id: run.run_id,
1165 exit_code,
1166 stderr,
1167 raw_output,
1168 text_for_review: assistant_text,
1169 token_usage,
1170 review_notes_for_comment,
1171 decision,
1172 });
1173 }
1174 }
1175 }
1176
1177 let scan_head = runner::agents::patterns::safe_head(
1184 &text_for_review,
1185 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
1186 );
1187 let scan_tail = runner::agents::patterns::safe_tail(
1188 &text_for_review,
1189 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
1190 );
1191
1192 if let Some(err) = (!ndjson_completed)
1193 .then(|| {
1194 runner::agents::patterns::detect_rate_limit(scan_head)
1195 .or_else(|| runner::agents::patterns::detect_rate_limit(scan_tail))
1196 })
1197 .flatten()
1198 {
1199 let runner::agents::AgentError::RateLimit { message } = &err else {
1200 unreachable!("detect_rate_limit only returns AgentError::RateLimit")
1201 };
1202 tracing::warn!(
1203 task_id = task.id.0,
1204 agent = %ctx.review_agent,
1205 "review agent hit rate limit — adding to cooldown"
1206 );
1207 let summary = runner::fallback::summarize_rate_limit_error(message);
1214 let stored_error = format!("{} rate limit: {summary}", ctx.review_agent);
1215 record_review_agent_failure(
1216 &task.id.0,
1217 &ctx.review_agent,
1218 ctx.review_model.as_deref(),
1219 &err,
1220 )
1221 .await;
1222 complete_review_run(
1223 store,
1224 run.run_id,
1225 Some(exit_code),
1226 &raw_output,
1227 &stderr,
1228 &text_for_review,
1229 outcome_for_agent_error(&err),
1230 &stored_error,
1231 token_usage,
1232 )
1233 .await;
1234 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1235 }
1236
1237 if let Some(err) = runner::agents::patterns::detect_auth_error(scan_tail) {
1238 let runner::agents::AgentError::Auth { message } = &err else {
1239 unreachable!("detect_auth_error only returns AgentError::Auth")
1240 };
1241 tracing::warn!(
1242 task_id = task.id.0,
1243 agent = %ctx.review_agent,
1244 "review agent hit auth error — adding to cooldown"
1245 );
1246 let stored_error = format!("auth error: {message}");
1247 record_review_agent_failure(
1248 &task.id.0,
1249 &ctx.review_agent,
1250 ctx.review_model.as_deref(),
1251 &err,
1252 )
1253 .await;
1254 complete_review_run(
1255 store,
1256 run.run_id,
1257 Some(exit_code),
1258 &raw_output,
1259 &stderr,
1260 &text_for_review,
1261 outcome_for_agent_error(&err),
1262 &stored_error,
1263 token_usage,
1264 )
1265 .await;
1266 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1267 }
1268
1269 if agent_result
1276 .as_ref()
1277 .map(|r| r.truncated_by_length)
1278 .unwrap_or(false)
1279 {
1280 tracing::warn!(
1281 task_id = task.id.0,
1282 agent = %ctx.review_agent,
1283 model = ?ctx.review_model,
1284 "review agent output truncated: step_finish reason=length \
1285 (token budget exceeded before completion)"
1286 );
1287 if let Some(model) = ctx.review_model.as_deref() {
1288 crate::engine::cooldown::record_persistent_model_failure(
1289 &ctx.review_agent,
1290 model,
1291 )
1292 .await;
1293 }
1294 let stored_error = "truncated: review output was cut off before completion \
1295 (output/reasoning token budget exceeded)"
1296 .to_string();
1297 complete_review_run(
1298 store,
1299 run.run_id,
1300 Some(exit_code),
1301 &raw_output,
1302 &stderr,
1303 &text_for_review,
1304 "truncated",
1305 &stored_error,
1306 token_usage,
1307 )
1308 .await;
1309 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(stored_error));
1310 }
1311
1312 tracing::error!(
1313 task_id = task.id.0,
1314 error = %e,
1315 agent = %ctx.review_agent,
1316 output = %text_for_review.chars().take(300).collect::<String>(),
1317 raw_output = %raw_output.chars().take(1000).collect::<String>(),
1318 "failed to parse review response"
1319 );
1320 if let Some(model) = ctx.review_model.as_deref() {
1321 crate::engine::cooldown::record_persistent_model_failure(
1327 &ctx.review_agent,
1328 model,
1329 )
1330 .await;
1331 }
1332 complete_review_run(
1333 store,
1334 run.run_id,
1335 Some(exit_code),
1336 &raw_output,
1337 &stderr,
1338 &text_for_review,
1339 "parse_error",
1340 &format!("parse error: {e}"),
1341 token_usage,
1342 )
1343 .await;
1344 return ReviewPhase::EarlyReturn(ReviewDecision::Failed(format!(
1345 "parse error: {e}"
1346 )));
1347 }
1348 }
1349 };
1350
1351 let review_notes_for_comment = review_response.notes.clone();
1352 let decision = review_decision_from_response(review_response);
1353
1354 tracing::info!(
1355 task_id = task.id.0,
1356 pr_number = ctx.pr_number,
1357 decision = ?decision,
1358 "review agent decision received"
1359 );
1360 store_log_activity(
1361 &Some(Arc::clone(store)),
1362 repo,
1363 &task.id.0,
1364 "review_decision",
1365 None,
1366 None,
1367 Some(&ctx.review_agent),
1368 ctx.review_model.as_deref(),
1369 Some(&serde_json::json!({
1370 "decision": review_decision_name(&decision),
1371 "pr_number": ctx.pr_number,
1372 })),
1373 )
1374 .await;
1375
1376 ReviewPhase::Ready(ParsedReview {
1377 run_id: run.run_id,
1378 exit_code,
1379 stderr,
1380 raw_output,
1381 text_for_review,
1382 token_usage,
1383 review_notes_for_comment,
1384 decision,
1385 })
1386}
1387
1388async fn finalize_review_run(
1389 task: &ExternalTask,
1390 ctx: &ReviewContext,
1391 parsed: &ParsedReview,
1392 store: &Arc<TaskStore>,
1393) {
1394 let outcome = match &parsed.decision {
1395 ReviewDecision::Approve
1396 | ReviewDecision::RequestChanges { .. }
1397 | ReviewDecision::Skipped
1398 | ReviewDecision::Rerouted => "success",
1399 ReviewDecision::Failed(_) | ReviewDecision::Blocked(_) => "failed",
1400 };
1401 let error = match &parsed.decision {
1402 ReviewDecision::Failed(error) | ReviewDecision::Blocked(error) => error.as_str(),
1403 _ => "",
1404 };
1405
1406 if outcome == "success" {
1407 crate::engine::cooldown::record_agent_success(&ctx.review_agent, ctx.review_model_str())
1410 .await;
1411 }
1412
1413 complete_review_run(
1414 store,
1415 parsed.run_id,
1416 Some(parsed.exit_code),
1417 &parsed.raw_output,
1418 &parsed.stderr,
1419 &parsed.text_for_review,
1420 outcome,
1421 error,
1422 parsed.token_usage,
1423 )
1424 .await;
1425
1426 if let Some(store_id) = ctx.store_id {
1427 if parsed.token_usage.input_tokens > 0 || parsed.token_usage.output_tokens > 0 {
1428 let model = if ctx.review_model_str().is_empty() {
1429 "unknown"
1430 } else {
1431 ctx.review_model_str()
1432 };
1433 if let Err(e) = store
1434 .store_tokens(
1435 store_id,
1436 parsed.token_usage.input_tokens,
1437 parsed.token_usage.output_tokens,
1438 model,
1439 )
1440 .await
1441 {
1442 tracing::warn!(
1443 task_id = task.id.0,
1444 ?e,
1445 "failed to store review agent token usage"
1446 );
1447 }
1448 }
1449 }
1450}
1451
1452async fn restore_review_config_if_needed(
1453 task_id: &str,
1454 worktree_path: &std::path::Path,
1455 default_branch: &str,
1456) {
1457 let _ = Command::new("git")
1458 .args(["checkout", "HEAD", "--", ".orch.yml"])
1459 .current_dir(worktree_path)
1460 .status()
1461 .await;
1462
1463 let merge_base = Command::new("git")
1464 .args(["merge-base", "HEAD", default_branch])
1465 .current_dir(worktree_path)
1466 .output()
1467 .await
1468 .ok()
1469 .filter(|output| output.status.success())
1470 .and_then(|output| String::from_utf8(output.stdout).ok())
1471 .map(|s| s.trim().to_string())
1472 .unwrap_or_default();
1473
1474 if merge_base.is_empty() {
1475 return;
1476 }
1477
1478 let orch_yml_changed = Command::new("git")
1479 .args([
1480 "diff",
1481 "--name-only",
1482 &merge_base,
1483 "HEAD",
1484 "--",
1485 ".orch.yml",
1486 ])
1487 .current_dir(worktree_path)
1488 .output()
1489 .await
1490 .ok()
1491 .filter(|output| output.status.success())
1492 .map(|output| !output.stdout.is_empty())
1493 .unwrap_or(false);
1494
1495 if !orch_yml_changed {
1496 return;
1497 }
1498
1499 tracing::warn!(
1500 task_id,
1501 "review agent modified .orch.yml (forbidden); restoring to merge-base version"
1502 );
1503 let restore_ok = Command::new("git")
1504 .args(["checkout", &merge_base, "--", ".orch.yml"])
1505 .current_dir(worktree_path)
1506 .status()
1507 .await
1508 .map(|status| status.success())
1509 .unwrap_or(false);
1510
1511 if !restore_ok {
1512 return;
1513 }
1514
1515 let staged = Command::new("git")
1516 .args(["diff", "--cached", "--name-only", "--", ".orch.yml"])
1517 .current_dir(worktree_path)
1518 .output()
1519 .await
1520 .ok()
1521 .filter(|output| output.status.success())
1522 .map(|output| !output.stdout.is_empty())
1523 .unwrap_or(false);
1524
1525 if staged {
1526 let _ = Command::new("git")
1527 .args([
1528 "-c",
1529 "commit.gpgsign=false",
1530 "commit",
1531 "-m",
1532 "revert: restore .orch.yml (review agent must not modify project config)",
1533 ])
1534 .current_dir(worktree_path)
1535 .status()
1536 .await;
1537 }
1538}
1539
1540fn is_missing_path_error_message(err: &str) -> bool {
1541 err.to_ascii_lowercase()
1542 .contains("no such file or directory")
1543}
1544
1545async fn push_review_branch(
1546 task: &ExternalTask,
1547 repo: &str,
1548 ctx: &ReviewContext,
1549 store: &Arc<TaskStore>,
1550) -> Result<(), String> {
1551 let mut worktree_path = ctx.worktree_path.clone();
1552 let mut branch_name = ctx.branch_name.clone();
1553 let mut retried_missing_worktree = false;
1554
1555 let push_result = loop {
1556 match runner::git_ops::push_branch(&worktree_path, &branch_name, &ctx.default_branch).await
1557 {
1558 Ok(ok) => break Ok(ok),
1559 Err(e)
1560 if !retried_missing_worktree
1561 && !worktree_path.exists()
1562 && is_missing_path_error_message(&e.to_string()) =>
1563 {
1564 retried_missing_worktree = true;
1565 tracing::warn!(
1566 task_id = task.id.0,
1567 worktree = %worktree_path.display(),
1568 branch = %branch_name,
1569 "review push failed because worktree disappeared; attempting one-time recovery"
1570 );
1571 match recover_missing_review_worktree(task, repo, store).await {
1572 Ok(recovered) => {
1573 worktree_path = recovered.work_dir;
1574 branch_name = recovered.branch;
1575 continue;
1576 }
1577 Err(recover_err) => {
1578 break Err(anyhow::anyhow!(
1579 "missing review worktree recovery failed after push error: {recover_err}"
1580 ));
1581 }
1582 }
1583 }
1584 Err(e) => break Err(e),
1585 }
1586 };
1587
1588 match push_result {
1589 Ok(_) => {
1590 store_log_activity(
1591 &Some(Arc::clone(store)),
1592 repo,
1593 &task.id.0,
1594 "push",
1595 None,
1596 None,
1597 Some(&ctx.review_agent),
1598 ctx.review_model.as_deref(),
1599 Some(&serde_json::json!({
1600 "status": "ok",
1601 "branch": branch_name.clone(),
1602 "default_branch": ctx.default_branch.clone(),
1603 "phase": "review",
1604 })),
1605 )
1606 .await;
1607 if ctx.had_prev_push_failure {
1608 if let Err(e) = store_set_result(
1609 &Some(Arc::clone(store)),
1610 repo,
1611 &task.id.0,
1612 &[
1613 ("last_error", serde_json::json!("")),
1614 ("push_failures", serde_json::json!(0)),
1615 ],
1616 )
1617 .await
1618 {
1619 tracing::warn!(task_id = task.id.0, err = %e, "failed to reset push_failures — counter may drift");
1620 }
1621 }
1622 Ok(())
1623 }
1624 Err(e) => {
1625 store_log_activity(
1626 &Some(Arc::clone(store)),
1627 repo,
1628 &task.id.0,
1629 "push",
1630 None,
1631 None,
1632 Some(&ctx.review_agent),
1633 ctx.review_model.as_deref(),
1634 Some(&serde_json::json!({
1635 "status": "error",
1636 "branch": branch_name.clone(),
1637 "default_branch": ctx.default_branch.clone(),
1638 "phase": "review",
1639 "error": e.to_string(),
1640 })),
1641 )
1642 .await;
1643 tracing::error!(
1644 task_id = task.id.0,
1645 branch = %ctx.branch_name,
1646 error = %e,
1647 "review push failed"
1648 );
1649 store_set(
1650 &Some(Arc::clone(store)),
1651 repo,
1652 &task.id.0,
1653 &[("last_error", serde_json::json!(format!("push failed: {e}")))],
1654 )
1655 .await;
1656 Err(e.to_string())
1657 }
1658 }
1659}
1660
1661fn build_pr_review_comment(decision: &ReviewDecision, review_notes_for_comment: &str) -> String {
1662 match decision {
1663 ReviewDecision::Approve => {
1664 format!(
1665 "## Automated Review \u{2014} Approve\n\n{}",
1666 review_notes_for_comment
1667 )
1668 }
1669 ReviewDecision::RequestChanges { notes, issues } => {
1670 let mut body = format!(
1671 "## Automated Review \u{2014} Changes Requested\n\n{}\n",
1672 notes
1673 );
1674 if !issues.is_empty() {
1675 body.push_str("\n**Issues Found:**\n");
1676 for issue in issues {
1677 body.push_str(&format!(
1678 "- `{}` line {}: {} [{}]\n",
1679 issue.file,
1680 issue
1681 .line
1682 .map(|line| line.to_string())
1683 .unwrap_or_else(|| "?".to_string()),
1684 issue.description,
1685 issue.severity
1686 ));
1687 }
1688 }
1689 body
1690 }
1691 _ => String::new(),
1692 }
1693}
1694
1695async fn post_review_comment(
1696 task: &ExternalTask,
1697 repo: &str,
1698 ctx: &ReviewContext,
1699 decision: &ReviewDecision,
1700 review_notes_for_comment: &str,
1701) -> anyhow::Result<()> {
1702 let gh = match GhHttp::new() {
1703 Ok(gh) => gh,
1704 Err(e) => {
1705 tracing::warn!(
1706 task_id = task.id.0,
1707 error = %e,
1708 "failed to create GH client for review comment"
1709 );
1710 return Ok(());
1711 }
1712 };
1713 let mut pr_comment = build_pr_review_comment(decision, review_notes_for_comment);
1714 if ctx.same_agent_fallback && !pr_comment.is_empty() {
1715 pr_comment.push_str(
1716 "\n> Note: no other agent was available, so this PR was reviewed by the same agent that wrote the code.\n",
1717 );
1718 }
1719
1720 if !pr_comment.is_empty() {
1721 let footer =
1722 attribution_footer("Reviewed", &ctx.review_agent, Some(ctx.review_model_str()));
1723 let pr_comment_with_footer = format!("{}{}", pr_comment, footer);
1724 if let Err(e) = gh
1725 .add_comment(repo, &ctx.pr_number.to_string(), &pr_comment_with_footer)
1726 .await
1727 {
1728 tracing::warn!(
1729 task_id = task.id.0,
1730 pr_number = ctx.pr_number,
1731 error = %e,
1732 "failed to post automated review comment on PR"
1733 );
1734 }
1735 }
1736
1737 Ok(())
1738}
1739
1740#[allow(clippy::too_many_arguments)]
1741async fn apply_review_decision(
1742 task: &ExternalTask,
1743 backend: &Arc<dyn ExternalBackend>,
1744 repo: &str,
1745 task_manager: &Arc<TaskManager>,
1746 store: &Arc<TaskStore>,
1747 ctx: &ReviewContext,
1748 parsed: &ParsedReview,
1749 router_config: &crate::engine::router::RouterConfig,
1750) -> anyhow::Result<ReviewDecision> {
1751 restore_review_config_if_needed(&task.id.0, &ctx.worktree_path, &ctx.default_branch).await;
1752
1753 if let Err(e) = push_review_branch(task, repo, ctx, store).await {
1754 return Ok(ReviewDecision::Failed(format!("push failed: {e}")));
1755 }
1756
1757 post_review_comment(
1758 task,
1759 repo,
1760 ctx,
1761 &parsed.decision,
1762 &parsed.review_notes_for_comment,
1763 )
1764 .await?;
1765
1766 match &parsed.decision {
1767 ReviewDecision::Approve => {
1768 if let Err(e) = auto_merge_pr(
1773 task,
1774 &ctx.branch_name,
1775 backend,
1776 repo,
1777 &ctx.review_agent,
1778 ctx.review_model_str(),
1779 task_manager,
1780 store,
1781 )
1782 .await
1783 {
1784 let error_msg = e.to_string();
1785 if error_msg.contains("not yet computed") {
1786 tracing::warn!(
1787 task_id = task.id.0,
1788 pr_number = ctx.pr_number,
1789 branch = %ctx.branch_name,
1790 error = %e,
1791 "auto-merge deferred — PR mergeability not yet computed"
1792 );
1793 } else {
1794 tracing::error!(
1795 task_id = task.id.0,
1796 pr_number = ctx.pr_number,
1797 branch = %ctx.branch_name,
1798 error = %e,
1799 "auto-merge failed"
1800 );
1801 }
1802 return Ok(ReviewDecision::Failed(format!("merge failed: {e}")));
1803 }
1804 Ok(ReviewDecision::Approve)
1805 }
1806 ReviewDecision::RequestChanges { notes, issues } => {
1807 handle_review_changes(
1808 task,
1809 notes,
1810 issues,
1811 backend,
1812 repo,
1813 ctx.pr_number,
1814 &ctx.review_agent,
1815 ctx.review_model_str(),
1816 task_manager,
1817 store,
1818 router_config,
1819 )
1820 .await?;
1821 Ok(parsed.decision.clone())
1822 }
1823 _ => Ok(parsed.decision.clone()),
1824 }
1825}
1826
1827async fn recover_missing_review_worktree(
1828 task: &ExternalTask,
1829 repo: &str,
1830 store: &Arc<TaskStore>,
1831) -> anyhow::Result<worktree::WorktreeSetup> {
1832 let project_dir = crate::config::get_projects_with_paths()?
1833 .into_iter()
1834 .find(|(candidate_repo, _)| candidate_repo == repo)
1835 .map(|(_, project_dir)| project_dir)
1836 .ok_or_else(|| anyhow::anyhow!("no project directory configured for repo {repo}"))?;
1837
1838 tracing::warn!(
1839 task_id = task.id.0,
1840 repo,
1841 project_dir = %project_dir.display(),
1842 "review worktree missing, recreating via normal setup"
1843 );
1844
1845 let store_ref = Some(Arc::clone(store));
1846 let wt = worktree::setup_worktree(&task.id.0, &task.title, &project_dir, &store_ref, repo)
1847 .await
1848 .context("recreating missing review worktree")?;
1849
1850 if !wt.work_dir.exists() {
1851 anyhow::bail!(
1852 "recreated review worktree at {} for task {} is still missing",
1853 wt.work_dir.display(),
1854 task.id.0
1855 );
1856 }
1857
1858 Ok(wt)
1859}
1860
1861async fn count_ahead_commits(
1867 worktree_path: &std::path::Path,
1868 default_branch: &str,
1869) -> Result<u64, String> {
1870 let worktree_str = worktree_path.to_str().ok_or_else(|| {
1871 format!(
1872 "worktree path contains non-UTF-8 characters: {:?}",
1873 worktree_path
1874 )
1875 })?;
1876
1877 let output = tokio::process::Command::new("git")
1878 .args([
1879 "-C",
1880 worktree_str,
1881 "rev-list",
1882 "--count",
1883 &format!("origin/{default_branch}..HEAD"),
1884 ])
1885 .output()
1886 .await
1887 .map_err(|e| format!("git rev-list command failed to start: {e}"))?;
1888
1889 if !output.status.success() {
1890 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1891 return Err(format!(
1892 "git rev-list exited with {:?}: {}",
1893 output.status.code(),
1894 if stderr.is_empty() {
1895 String::from_utf8_lossy(&output.stdout).trim().to_string()
1896 } else {
1897 stderr
1898 }
1899 ));
1900 }
1901
1902 String::from_utf8_lossy(&output.stdout)
1903 .trim()
1904 .parse::<u64>()
1905 .map_err(|e| format!("could not parse git rev-list output as u64: {e}"))
1906}
1907
1908#[allow(clippy::too_many_arguments)]
1915async fn ensure_pr_exists(
1916 task: &ExternalTask,
1917 branch_name: &str,
1918 worktree_path: &std::path::Path,
1919 repo: &str,
1920 agent_summary: &str,
1921 store: &Arc<TaskStore>,
1922 task_manager: &Arc<TaskManager>,
1923 stored_pr_number: Option<u64>,
1924) -> anyhow::Result<EnsurePrResult> {
1925 let gh_check = GhHttp::new()?;
1926
1927 if let Some(n) = stored_pr_number {
1928 tracing::info!(
1929 task_id = task.id.0,
1930 pr_number = n,
1931 branch = %branch_name,
1932 "open PR found in store, proceeding with review"
1933 );
1934 return Ok(EnsurePrResult::Ready(n));
1935 }
1936
1937 match gh_check.get_pr_number(repo, branch_name).await {
1938 Ok(Some(n)) => {
1939 tracing::info!(
1940 task_id = task.id.0,
1941 pr_number = n,
1942 branch = %branch_name,
1943 "open PR found, proceeding with review"
1944 );
1945 Ok(EnsurePrResult::Ready(n))
1946 }
1947 Ok(None) => {
1948 let default_branch = worktree::detect_default_branch(worktree_path).await;
1950 let has_commits = match count_ahead_commits(worktree_path, &default_branch).await {
1951 Ok(count) => count > 0,
1952 Err(e) => {
1953 tracing::error!(
1954 task_id = task.id.0,
1955 branch = %branch_name,
1956 default_branch = %default_branch,
1957 err = %e,
1958 "could not determine if branch has commits ahead of default"
1959 );
1960 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
1961 format!("git ancestry check failed: {e}"),
1962 )));
1963 }
1964 };
1965
1966 if has_commits {
1967 tracing::warn!(
1969 task_id = task.id.0,
1970 branch = %branch_name,
1971 "no open PR but branch has commits — attempting to create PR"
1972 );
1973 let worktree_str = worktree_path.to_str().ok_or_else(|| {
1974 anyhow::anyhow!(
1975 "worktree path contains non-UTF-8 characters: {:?}",
1976 worktree_path
1977 )
1978 })?;
1979 let auth_env = runner::git_ops::build_git_auth_env();
1981
1982 let push_timeout = std::time::Duration::from_secs(120);
1987 match tokio::time::timeout(
1988 push_timeout,
1989 tokio::process::Command::new("git")
1990 .args(["-C", worktree_str, "push", "-u", "origin", branch_name])
1991 .envs(auth_env.iter().map(|(k, v)| (k.as_str(), v.as_str())))
1992 .output(),
1993 )
1994 .await
1995 {
1996 Err(_elapsed) => {
1997 tracing::warn!(
1998 task_id = task.id.0,
1999 branch = %branch_name,
2000 timeout_secs = push_timeout.as_secs(),
2001 "review gate fallback push timed out"
2002 );
2003 }
2004 Ok(Ok(o)) => {
2005 if !o.status.success() {
2006 tracing::warn!(
2007 task_id = task.id.0,
2008 branch = %branch_name,
2009 exit_code = ?o.status.code(),
2010 stdout = %String::from_utf8_lossy(&o.stdout),
2011 stderr = %String::from_utf8_lossy(&o.stderr),
2012 "review gate fallback push failed"
2013 );
2014 } else {
2015 tracing::info!(task_id = task.id.0, branch = %branch_name, "review gate fallback push succeeded");
2016 }
2017 }
2018 Ok(Err(e)) => {
2019 tracing::error!(
2020 task_id = task.id.0,
2021 branch = %branch_name,
2022 error = %e,
2023 "review gate fallback push command failed to run"
2024 );
2025 }
2026 }
2027
2028 let task_ref = runner::git_ops::format_task_ref(&task.id.0);
2029 let pr_body = format!(
2030 "Resolves {task_ref}\n\nAuto-created by orch review gate (agent forgot to open PR)"
2031 );
2032 let gh = GhHttp::new()?;
2033 match gh
2034 .create_pr(repo, &task.title, &pr_body, branch_name, &default_branch)
2035 .await
2036 {
2037 Ok(url) => match parse_pr_number_from_url(&url) {
2038 Ok(pr_num) => {
2039 store_set_result(
2040 &Some(Arc::clone(store)),
2041 repo,
2042 &task.id.0,
2043 &[("pr_number", serde_json::json!(pr_num as i64))],
2044 )
2045 .await?;
2046 tracing::info!(
2047 task_id = task.id.0,
2048 branch = %branch_name,
2049 pr_url = %url,
2050 "created missing PR via GhHttp — continuing review"
2051 );
2052 Ok(EnsurePrResult::Ready(pr_num))
2053 }
2054 Err(e) => {
2055 tracing::warn!(
2056 task_id = task.id.0,
2057 branch = %branch_name,
2058 pr_url = %url,
2059 error = %e,
2060 "created missing PR via GhHttp, but failed to parse PR number"
2061 );
2062 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2063 format!("created missing PR, but could not parse PR number: {e}"),
2064 )))
2065 }
2066 },
2067 Err(e) => {
2068 let e_str = format!("{e}");
2069 if e_str.contains("already exists") {
2070 let backoff_delays = [2u64, 4u64];
2074 let mut found_pr: Option<u64> = None;
2075 for delay_secs in backoff_delays {
2076 tokio::time::sleep(tokio::time::Duration::from_secs(delay_secs))
2077 .await;
2078 if let Ok(Some(n)) = gh_check.get_pr_number(repo, branch_name).await
2079 {
2080 found_pr = Some(n);
2081 break;
2082 }
2083 }
2084 if let Some(n) = found_pr {
2085 tracing::info!(
2086 task_id = task.id.0,
2087 pr_number = n,
2088 branch = %branch_name,
2089 "found existing PR after create_pr 422 — retrying review"
2090 );
2091 store_set_result(
2092 &Some(Arc::clone(store)),
2093 repo,
2094 &task.id.0,
2095 &[("pr_number", serde_json::json!(n as i64))],
2096 )
2097 .await?;
2098 return Ok(EnsurePrResult::Ready(n));
2099 }
2100 }
2101 tracing::warn!(
2102 task_id = task.id.0,
2103 branch = %branch_name,
2104 error = %e,
2105 "create_pr failed via GhHttp, falling back to CLI"
2106 );
2107 let pr_timeout = std::time::Duration::from_secs(120);
2110 let mut gh_cmd = tokio::process::Command::new("gh");
2111 gh_cmd.args([
2112 "pr",
2113 "create",
2114 "--repo",
2115 repo,
2116 "--head",
2117 branch_name,
2118 "--title",
2119 &task.title,
2120 "--body",
2121 &pr_body,
2122 ]);
2123 gh_cmd.current_dir(worktree_path);
2124 gh_cmd.kill_on_drop(true);
2125
2126 let pr_result = tokio::time::timeout(pr_timeout, gh_cmd.output()).await;
2127 match pr_result {
2128 Ok(Ok(o)) if o.status.success() => {
2129 let stdout = String::from_utf8_lossy(&o.stdout);
2130 match parse_pr_number_from_url(stdout.trim()) {
2131 Ok(pr_num) => {
2132 store_set_result(
2133 &Some(Arc::clone(store)),
2134 repo,
2135 &task.id.0,
2136 &[("pr_number", serde_json::json!(pr_num as i64))],
2137 )
2138 .await?;
2139 tracing::info!(
2140 task_id = task.id.0,
2141 branch = %branch_name,
2142 "created missing PR via CLI — continuing review"
2143 );
2144 Ok(EnsurePrResult::Ready(pr_num))
2145 }
2146 Err(e) => {
2147 tracing::warn!(
2148 task_id = task.id.0,
2149 branch = %branch_name,
2150 stdout = %stdout,
2151 error = %e,
2152 "created missing PR via CLI, but failed to parse PR number"
2153 );
2154 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2155 format!("created missing PR via CLI, but could not parse PR number: {e}")
2156 )))
2157 }
2158 }
2159 }
2160 Ok(Ok(o)) => {
2161 let stderr = String::from_utf8_lossy(&o.stderr);
2162 if stderr.contains("already exists") {
2163 if let Some(pr_url) =
2164 stderr.lines().find(|l| l.trim().starts_with("https://"))
2165 {
2166 match parse_pr_number_from_url(pr_url.trim()) {
2167 Ok(pr_num) => {
2168 if let Err(e) = store_set_result(
2169 &Some(Arc::clone(store)),
2170 repo,
2171 &task.id.0,
2172 &[(
2173 "pr_number",
2174 serde_json::json!(pr_num as i64),
2175 )],
2176 )
2177 .await
2178 {
2179 tracing::warn!(
2180 task_id = task.id.0,
2181 pr_num,
2182 err = %e,
2183 "failed to persist pr_number from already-exists recovery — downstream may retry PR creation"
2184 );
2185 }
2186 tracing::info!(
2187 task_id = task.id.0,
2188 branch = %branch_name,
2189 pr_url = %pr_url,
2190 "PR already exists (from CLI stderr) — retrying review"
2191 );
2192 return Ok(EnsurePrResult::Ready(pr_num));
2193 }
2194 Err(e) => {
2195 tracing::warn!(
2196 task_id = task.id.0,
2197 branch = %branch_name,
2198 pr_url = %pr_url,
2199 error = %e,
2200 "found existing PR URL in CLI stderr, but failed to parse PR number"
2201 );
2202 }
2204 }
2205 }
2206 }
2207 tracing::error!(
2208 task_id = task.id.0,
2209 branch = %branch_name,
2210 stderr = %stderr,
2211 "failed to create missing PR — work may be stuck"
2212 );
2213
2214 if crate::engine::runner::git_ops::is_transient_github_api_error(
2221 &stderr,
2222 ) {
2223 tracing::warn!(
2224 task_id = task.id.0,
2225 branch = %branch_name,
2226 "transient GitHub error creating PR; will retry later without incrementing persistent failure counter"
2227 );
2228 return Ok(EnsurePrResult::EarlyReturn(
2229 ReviewDecision::Failed(format!(
2230 "transient github error creating PR: {stderr}"
2231 )),
2232 ));
2233 }
2234
2235 match store_increment(
2236 &Some(Arc::clone(store)),
2237 repo,
2238 &task.id.0,
2239 "pr_create_failures",
2240 )
2241 .await
2242 {
2243 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2244 return Ok(EnsurePrResult::EarlyReturn(
2245 ReviewDecision::Blocked(format!(
2246 "no PR, create failed {failures} times: {stderr}"
2247 )),
2248 ));
2249 }
2250 Ok(_) => {}
2251 Err(e) => {
2252 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2253 }
2254 }
2255 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2256 format!("no PR, create failed: {stderr}"),
2257 )))
2258 }
2259 Ok(Err(e)) => {
2260 tracing::error!(
2261 task_id = task.id.0,
2262 error = %e,
2263 "failed to run gh pr create"
2264 );
2265 let e_str = format!("{e}");
2266 if crate::engine::runner::git_ops::is_transient_github_api_error(
2267 &e_str,
2268 ) {
2269 tracing::warn!(
2270 task_id = task.id.0,
2271 branch = %branch_name,
2272 "transient GitHub error from gh CLI fallback; will retry later without incrementing persistent failure counter"
2273 );
2274 return Ok(EnsurePrResult::EarlyReturn(
2275 ReviewDecision::Failed(format!("transient gh error: {e}")),
2276 ));
2277 }
2278
2279 match store_increment(
2280 &Some(Arc::clone(store)),
2281 repo,
2282 &task.id.0,
2283 "pr_create_failures",
2284 )
2285 .await
2286 {
2287 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2288 return Ok(EnsurePrResult::EarlyReturn(
2289 ReviewDecision::Blocked(format!(
2290 "no PR, gh error {failures} times: {e}"
2291 )),
2292 ));
2293 }
2294 Ok(_) => {}
2295 Err(e) => {
2296 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2297 }
2298 }
2299 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2300 format!("no PR, gh error: {e}"),
2301 )))
2302 }
2303 Err(_elapsed) => {
2304 tracing::error!(
2306 task_id = task.id.0,
2307 timeout_secs = pr_timeout.as_secs(),
2308 "gh pr create CLI fallback timed out"
2309 );
2310 let e_str = format!(
2311 "gh pr create timed out after {}s",
2312 pr_timeout.as_secs()
2313 );
2314 if crate::engine::runner::git_ops::is_transient_github_api_error(
2315 &e_str,
2316 ) {
2317 tracing::warn!(
2318 task_id = task.id.0,
2319 branch = %branch_name,
2320 "transient GitHub timeout from gh CLI fallback; will retry later without incrementing persistent failure counter"
2321 );
2322 return Ok(EnsurePrResult::EarlyReturn(
2323 ReviewDecision::Failed(format!(
2324 "transient gh error: {e_str}"
2325 )),
2326 ));
2327 }
2328
2329 match store_increment(
2330 &Some(Arc::clone(store)),
2331 repo,
2332 &task.id.0,
2333 "pr_create_failures",
2334 )
2335 .await
2336 {
2337 Ok(failures) if failures >= MAX_PR_CREATE_FAILURES => {
2338 return Ok(EnsurePrResult::EarlyReturn(
2339 ReviewDecision::Blocked(format!(
2340 "no PR, gh timeout {failures} times: {e_str}"
2341 )),
2342 ));
2343 }
2344 Ok(_) => {}
2345 Err(e) => {
2346 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment pr_create_failures — skipping blocking decision this tick");
2347 }
2348 }
2349 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2350 format!("no PR, gh timeout: {e_str}"),
2351 )))
2352 }
2353 }
2354 }
2355 }
2356 } else {
2357 let merged = match gh_check.is_pr_merged(repo, branch_name).await {
2359 Ok(v) => v,
2360 Err(e) => {
2361 tracing::warn!(task_id = task.id.0, branch = %branch_name, err = %e, "merge check failed, skipping task this tick");
2362 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2363 format!("merge check failed: {e}"),
2364 )));
2365 }
2366 };
2367 if merged {
2368 tracing::info!(
2369 task_id = task.id.0,
2370 branch = %branch_name,
2371 "PR already merged, marking done"
2372 );
2373 if let Err(e) = task_manager
2374 .update_task_status(&task.id, Status::Done)
2375 .await
2376 {
2377 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2378 }
2379 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2380 }
2381
2382 let last_error = opt_store_get_task(&Some(Arc::clone(store)), repo, &task.id.0)
2383 .await
2384 .map(|t| t.last_error)
2385 .unwrap_or_default();
2386 let reason = if !agent_summary.is_empty() {
2387 agent_summary.to_string()
2388 } else {
2389 last_error.clone()
2390 };
2391
2392 if last_error.contains("exceeded max attempts") {
2393 tracing::warn!(
2394 task_id = task.id.0,
2395 branch = %branch_name,
2396 "no PR and no commits after max attempts — marking blocked to stop loop"
2397 );
2398 let fields = [
2402 (
2403 "block_reason",
2404 serde_json::json!("max attempts exceeded — no PR or commits produced"),
2405 ),
2406 ("last_error", serde_json::json!(last_error)),
2407 ];
2408 if let Err(e) = task_manager
2409 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2410 .await
2411 {
2412 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2413 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2414 format!("failed to write block_reason: {e}"),
2415 )));
2416 }
2417 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2418 }
2419
2420 if last_error.contains("422") && last_error.contains("head") {
2421 tracing::info!(
2422 task_id = task.id.0,
2423 branch = %branch_name,
2424 "no PR — 422/head-invalid means work already merged, marking done"
2425 );
2426 if let Err(e) = task_manager
2427 .update_task_status(&task.id, Status::Done)
2428 .await
2429 {
2430 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed");
2431 }
2432 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2433 }
2434
2435 if last_error.contains("no fallback agents")
2436 || last_error.contains("all agents exhausted")
2437 {
2438 tracing::warn!(
2439 task_id = task.id.0,
2440 branch = %branch_name,
2441 last_error = %last_error,
2442 "no PR and no commits after failover exhaustion — marking blocked to stop loop"
2443 );
2444 let fields = [
2448 (
2449 "block_reason",
2450 serde_json::json!("all agents exhausted — no PR or commits produced"),
2451 ),
2452 ("last_error", serde_json::json!(last_error)),
2453 ];
2454 if let Err(e) = task_manager
2455 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2456 .await
2457 {
2458 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2459 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2460 format!("failed to write block_reason: {e}"),
2461 )));
2462 }
2463 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped));
2464 }
2465
2466 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
2473 .ok()
2474 .and_then(|s| s.parse().ok())
2475 .or_else(|| {
2476 config::get("workflow.max_attempts")
2477 .ok()
2478 .and_then(|s| s.parse().ok())
2479 })
2480 .unwrap_or(3);
2481
2482 tracing::warn!(
2483 task_id = task.id.0,
2484 branch = %branch_name,
2485 reason = %reason,
2486 "no PR and no commits — re-routing for retry"
2487 );
2488
2489 if should_skip_no_code_reroute_increment(&last_error) {
2493 tracing::warn!(
2494 task_id = task.id.0,
2495 last_error = %last_error,
2496 "transient GitHub error recorded; skipping persistent no_pr_reroutes increment"
2497 );
2498 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2499 format!("transient github error: {last_error}"),
2500 )));
2501 }
2502
2503 let reroutes = match store_increment(
2505 &Some(Arc::clone(store)),
2506 repo,
2507 &task.id.0,
2508 "no_code_reroutes",
2509 )
2510 .await
2511 {
2512 Ok(v) => v,
2513 Err(e) => {
2514 tracing::warn!(task_id = task.id.0, err = %e, "failed to increment no_code_reroutes — skipping reroute/block decision this tick");
2515 return Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2517 format!("transient store error: {e}"),
2518 )));
2519 }
2520 };
2521
2522 if reroutes as u32 >= max_reroutes {
2523 tracing::error!(
2524 task_id = task.id.0,
2525 reroutes,
2526 max_reroutes,
2527 "reached max reroute attempts for no-pr-result — blocking for human review"
2528 );
2529 let msg = format!(
2530 "no PR or code changes after {}/{} reroute attempts",
2531 reroutes, max_reroutes
2532 );
2533 let fields = [
2537 (
2538 "block_reason",
2539 serde_json::json!(format!(
2540 "max reroute attempts ({}) reached — no PR or code changes produced",
2541 max_reroutes
2542 )),
2543 ),
2544 ("last_error", serde_json::json!(msg)),
2545 ("agent", serde_json::json!(null)),
2546 ("model", serde_json::json!(null)),
2547 ];
2548 if let Err(e) = task_manager
2549 .update_task_status_and_result(&task.id, Status::Blocked, &fields)
2550 .await
2551 {
2552 tracing::error!(task_id = task.id.0, err = %e, "update_task_status_and_result(Blocked) failed — skipping block to avoid silent auto-unblock loop");
2553 }
2554 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Blocked(msg)))
2555 } else if task.id.0.starts_with("internal:") {
2556 tracing::info!(
2563 task_id = task.id.0,
2564 "no PR and no commits for internal task — marking done"
2565 );
2566 if let Err(e) = task_manager
2567 .update_task_status(&task.id, Status::Done)
2568 .await
2569 {
2570 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(Done) failed — task may be stuck in InReview");
2571 }
2572 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Skipped))
2573 } else {
2574 if let Err(e) = store_set_result(
2579 &Some(Arc::clone(store)),
2580 repo,
2581 &task.id.0,
2582 &[
2583 ("agent", serde_json::json!(null)),
2584 ("model", serde_json::json!(null)),
2585 (
2586 "last_error",
2587 serde_json::json!("no PR or code changes produced"),
2588 ),
2589 ],
2590 )
2591 .await
2592 {
2593 tracing::error!(task_id = task.id.0, err = %e, "failed to clear agent/model for reroute — skipping status transition");
2594 } else if let Err(e) =
2596 task_manager.update_task_status(&task.id, Status::New).await
2597 {
2598 tracing::error!(task_id = task.id.0, err = %e, "update_task_status(New) failed — task may be stuck in InReview");
2599 }
2600 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Rerouted))
2601 }
2602 }
2603 }
2604 Err(e) => {
2605 tracing::warn!(
2606 task_id = task.id.0,
2607 branch = %branch_name,
2608 error = %e,
2609 "failed to check PR status"
2610 );
2611 Ok(EnsurePrResult::EarlyReturn(ReviewDecision::Failed(
2612 format!("PR check failed: {e}"),
2613 )))
2614 }
2615 }
2616}
2617
2618pub(crate) async fn review_and_merge(
2624 task: &ExternalTask,
2625 backend: &Arc<dyn ExternalBackend>,
2626 tmux: &Arc<TmuxManager>,
2627 repo: &str,
2628 router: &Arc<RwLock<Router>>,
2629 task_manager: &Arc<TaskManager>,
2630 store: &Arc<TaskStore>,
2631) -> anyhow::Result<ReviewDecision> {
2632 set_review_session_expected(store, repo, &task.id.0, true).await;
2633
2634 let ctx = match build_review_context(task, repo, router, task_manager, store).await? {
2635 ReviewPhase::Ready(ctx) => ctx,
2636 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2637 };
2638
2639 let run = match invoke_review_agent(task, tmux, &ctx, repo, store).await {
2640 ReviewPhase::Ready(run) => run,
2641 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2642 };
2643
2644 let parsed = match parse_review_output(task, repo, &ctx, run, store).await {
2645 ReviewPhase::Ready(parsed) => parsed,
2646 ReviewPhase::EarlyReturn(decision) => return Ok(decision),
2647 };
2648
2649 finalize_review_run(task, &ctx, &parsed, store).await;
2650
2651 let router_config = router.read().await.config.clone();
2652 apply_review_decision(
2653 task,
2654 backend,
2655 repo,
2656 task_manager,
2657 store,
2658 &ctx,
2659 &parsed,
2660 &router_config,
2661 )
2662 .await
2663}
2664
2665#[cfg(test)]
2666mod tests {
2667 use super::*;
2668
2669 use crate::backends::ExternalTask;
2670 use crate::engine::router::RouterConfig;
2671 use crate::github::types::{GitHubReview, GitHubReviewComment, GitHubUser, PullRequestReview};
2672 use crate::store::{TaskStatus, TaskStore};
2673 use tempfile::TempDir;
2674
2675 #[test]
2678 fn outcome_for_agent_error_kimi_billing_cycle_is_not_rate_limit() {
2679 let err = runner::agents::AgentError::RateLimit {
2683 message: "Failed to authenticate. API Error: 403 You've reached your usage limit \
2684 for this billing cycle. Your quota will be refreshed soon."
2685 .to_string(),
2686 };
2687 assert_eq!(outcome_for_agent_error(&err), "billing_cycle_exhausted");
2688 }
2689
2690 #[test]
2691 fn outcome_for_agent_error_generic_rate_limit_unaffected() {
2692 let err = runner::agents::AgentError::RateLimit {
2693 message: "429 Too Many Requests".to_string(),
2694 };
2695 assert_eq!(outcome_for_agent_error(&err), "rate_limit");
2696 }
2697
2698 #[test]
2699 fn outcome_for_agent_error_generic_auth_unaffected() {
2700 let err = runner::agents::AgentError::Auth {
2701 message: "401 Unauthorized".to_string(),
2702 };
2703 assert_eq!(outcome_for_agent_error(&err), "auth_error");
2704 }
2705
2706 #[test]
2709 fn parse_pr_number_standard_url() {
2710 assert_eq!(
2711 parse_pr_number_from_url("https://github.com/owner/repo/pull/42"),
2712 Ok(42)
2713 );
2714 }
2715
2716 #[test]
2717 fn parse_pr_number_trailing_slash() {
2718 assert_eq!(
2719 parse_pr_number_from_url("https://github.com/owner/repo/pull/42/"),
2720 Ok(42)
2721 );
2722 }
2723
2724 #[test]
2725 fn parse_pr_number_with_query_string() {
2726 assert_eq!(
2727 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?tab=files"),
2728 Ok(42)
2729 );
2730 }
2731
2732 #[test]
2733 fn parse_pr_number_with_hash_fragment() {
2734 assert_eq!(
2735 parse_pr_number_from_url("https://github.com/owner/repo/pull/42#issuecomment-123"),
2736 Ok(42)
2737 );
2738 }
2739
2740 #[test]
2741 fn parse_pr_number_with_query_and_hash() {
2742 assert_eq!(
2743 parse_pr_number_from_url("https://github.com/owner/repo/pull/42?quick_pull=1#top"),
2744 Ok(42)
2745 );
2746 }
2747
2748 #[test]
2749 fn parse_pr_number_with_leading_whitespace() {
2750 assert_eq!(
2751 parse_pr_number_from_url(" https://github.com/owner/repo/pull/42\n"),
2752 Ok(42)
2753 );
2754 }
2755
2756 #[test]
2757 fn parse_pr_number_wrong_domain() {
2758 let url = "https://gitlab.com/owner/repo/merge_requests/42";
2759 let result = parse_pr_number_from_url(url);
2760 assert!(result.is_err());
2761 assert!(result
2762 .unwrap_err()
2763 .contains("does not start with 'https://github.com/'"));
2764 }
2765
2766 #[test]
2767 fn parse_pr_number_missing_pull_segment() {
2768 let url = "https://github.com/owner/repo/issues/42";
2770 let result = parse_pr_number_from_url(url);
2771 assert!(result.is_err());
2772 assert!(result.unwrap_err().contains("missing '/pull/' segment"));
2773 }
2774
2775 #[test]
2776 fn parse_pr_number_zero_is_rejected() {
2777 let url = "https://github.com/owner/repo/pull/0";
2778 let result = parse_pr_number_from_url(url);
2779 assert!(result.is_err());
2780 assert!(result.unwrap_err().contains("cannot be zero"));
2781 }
2782
2783 #[test]
2784 fn parse_pr_number_non_numeric_segment() {
2785 let url = "https://github.com/owner/repo/pull/abc";
2786 let result = parse_pr_number_from_url(url);
2787 assert!(result.is_err());
2788 assert!(result.unwrap_err().contains("not a valid positive integer"));
2789 }
2790
2791 #[test]
2792 fn parse_pr_number_empty_string() {
2793 let result = parse_pr_number_from_url("");
2794 assert!(result.is_err());
2795 assert!(result.unwrap_err().contains("empty"));
2796 }
2797
2798 #[test]
2799 fn parse_pr_number_http_not_https() {
2800 let url = "http://github.com/owner/repo/pull/42";
2802 let result = parse_pr_number_from_url(url);
2803 assert!(result.is_err());
2804 assert!(result
2805 .unwrap_err()
2806 .contains("does not start with 'https://github.com/'"));
2807 }
2808
2809 #[test]
2817 fn format_review_error_summarises_rate_limit_fragment_with_api_retry() {
2818 let raw_fragment = "{\"attempt\":6,\"max_retries\":10,\"retry_delay_ms\":17266.789,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}\n\
2822 {\"type\":\"system\",\"subtype\":\"api_retry\",\"attempt\":7,\"max_retries\":10,\"retry_delay_ms\":35162.66,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"5c7148ee\"}";
2823 let err = runner::agents::AgentError::RateLimit {
2824 message: raw_fragment.to_string(),
2825 };
2826
2827 let stored = format_review_error("minimax", &err);
2828
2829 assert!(
2832 !stored.contains("{\"type\""),
2833 "stored error must not contain raw NDJSON envelope, got: {stored}"
2834 );
2835 assert!(
2836 !stored.contains("subtype"),
2837 "stored error must not contain raw api_retry fragment, got: {stored}"
2838 );
2839 assert!(
2840 !stored.contains("session_id"),
2841 "stored error must not contain session_id from provider payload, got: {stored}"
2842 );
2843 assert_eq!(
2844 stored,
2845 "minimax rate limit: status=429 after 7 attempts (last delay 35s)"
2846 );
2847 }
2848
2849 #[test]
2850 fn format_review_error_summarises_truncated_fragment_without_subtype_marker() {
2851 let raw_fragment = ",\"attempt\":5,\"max_retries\":10,\"retry_delay_ms\":9034.81,\"error_status\":429,\"error\":\"rate_limit\",\"session_id\":\"992e17d3\"";
2856 let err = runner::agents::AgentError::RateLimit {
2857 message: raw_fragment.to_string(),
2858 };
2859
2860 let stored = format_review_error("glm", &err);
2861
2862 assert!(stored.starts_with("glm rate limit:"));
2863 assert!(
2864 !stored.contains("session_id"),
2865 "stored error must not leak session_id from truncated fragment, got: {stored}"
2866 );
2867 assert!(
2870 stored.len() <= 200,
2871 "stored error should be compact (<=200 chars), got {} chars: {stored}",
2872 stored.len()
2873 );
2874 }
2875
2876 #[test]
2877 fn format_review_error_preserves_non_rate_limit_variants() {
2878 let err = runner::agents::AgentError::Auth {
2879 message: "invalid bearer token".to_string(),
2880 };
2881 assert_eq!(
2882 format_review_error("kimi", &err),
2883 "kimi auth error: invalid bearer token"
2884 );
2885
2886 let err = runner::agents::AgentError::Timeout {
2887 elapsed: std::time::Duration::from_secs(1801),
2888 };
2889 assert_eq!(
2890 format_review_error("kimi", &err),
2891 "kimi timed out after 1801s"
2892 );
2893 }
2894
2895 fn git(dir: &std::path::Path, args: &[&str]) {
2896 let mut cmd = std::process::Command::new("git");
2897 cmd.current_dir(dir);
2898 if args.first() == Some(&"commit") {
2900 cmd.args(["-c", "commit.gpgsign=false"]);
2901 }
2902 let status = cmd.args(args).status().unwrap();
2903 assert!(status.success(), "git {:?} failed", args);
2904 }
2905
2906 #[test]
2907 fn test_pull_request_review_requests_changes() {
2908 let review = PullRequestReview {
2909 review: GitHubReview {
2910 id: 1,
2911 user: GitHubUser {
2912 login: "reviewer".to_string(),
2913 },
2914 body: Some("Please fix".to_string()),
2915 state: "CHANGES_REQUESTED".to_string(),
2916 html_url: None,
2917 submitted_at: "2024-01-01T00:00:00Z".to_string(),
2918 commit_id: None,
2919 },
2920 comments: vec![],
2921 };
2922 assert!(review.requests_changes());
2923 }
2924
2925 #[test]
2926 fn test_pull_request_review_does_not_request_changes() {
2927 let review = PullRequestReview {
2928 review: GitHubReview {
2929 id: 1,
2930 user: GitHubUser {
2931 login: "reviewer".to_string(),
2932 },
2933 body: Some("LGTM".to_string()),
2934 state: "APPROVED".to_string(),
2935 html_url: None,
2936 submitted_at: "2024-01-01T00:00:00Z".to_string(),
2937 commit_id: None,
2938 },
2939 comments: vec![],
2940 };
2941 assert!(!review.requests_changes());
2942 }
2943
2944 #[test]
2945 fn test_pull_request_review_actionable_comments_filters_empty_and_replies() {
2946 let review = PullRequestReview {
2947 review: GitHubReview {
2948 id: 1,
2949 user: GitHubUser {
2950 login: "reviewer".to_string(),
2951 },
2952 body: None,
2953 state: "CHANGES_REQUESTED".to_string(),
2954 html_url: None,
2955 submitted_at: "2024-01-01T00:00:00Z".to_string(),
2956 commit_id: None,
2957 },
2958 comments: vec![
2959 GitHubReviewComment {
2960 id: 1,
2961 user: GitHubUser {
2962 login: "reviewer".to_string(),
2963 },
2964 body: "Fix this issue".to_string(),
2965 path: "src/main.rs".to_string(),
2966 line: Some(10),
2967 original_line: Some(10),
2968 commit_id: "abc".to_string(),
2969 original_commit_id: "abc".to_string(),
2970 html_url: "url".to_string(),
2971 created_at: "2024-01-01T00:00:00Z".to_string(),
2972 updated_at: "2024-01-01T00:00:00Z".to_string(),
2973 in_reply_to_id: None,
2974 diff_hunk: Some(
2975 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;".to_string(),
2976 ),
2977 },
2978 GitHubReviewComment {
2979 id: 2,
2980 user: GitHubUser {
2981 login: "reviewer".to_string(),
2982 },
2983 body: "".to_string(),
2984 path: "src/lib.rs".to_string(),
2985 line: Some(20),
2986 original_line: Some(20),
2987 commit_id: "abc".to_string(),
2988 original_commit_id: "abc".to_string(),
2989 html_url: "url".to_string(),
2990 created_at: "2024-01-01T00:00:00Z".to_string(),
2991 updated_at: "2024-01-01T00:00:00Z".to_string(),
2992 in_reply_to_id: None,
2993 diff_hunk: None,
2994 },
2995 GitHubReviewComment {
2996 id: 3,
2997 user: GitHubUser {
2998 login: "reviewer".to_string(),
2999 },
3000 body: "Reply to this".to_string(),
3001 path: "src/lib.rs".to_string(),
3002 line: Some(30),
3003 original_line: Some(30),
3004 commit_id: "abc".to_string(),
3005 original_commit_id: "abc".to_string(),
3006 html_url: "url".to_string(),
3007 created_at: "2024-01-01T00:00:00Z".to_string(),
3008 updated_at: "2024-01-01T00:00:00Z".to_string(),
3009 in_reply_to_id: Some(1),
3010 diff_hunk: None,
3011 },
3012 ],
3013 };
3014 let actionable = review.actionable_comments();
3015 assert_eq!(actionable.len(), 1);
3016 assert_eq!(actionable[0].id, 1);
3017 assert_eq!(actionable[0].body, "Fix this issue");
3018 assert_eq!(actionable[0].path, "src/main.rs");
3019 assert_eq!(
3020 actionable[0].diff_hunk.as_ref().unwrap(),
3021 "@@ -8,5 +8,5 @@ fn main() {\n- let x = 1;\n+ let x = 2;"
3022 );
3023 }
3024
3025 #[test]
3026 fn test_router_config_model_for_complexity_returns_none_without_config() {
3027 let cfg = RouterConfig::default();
3030 assert!(cfg.model_for_complexity("claude", "simple", "").is_none());
3031 assert!(cfg.model_for_complexity("opencode", "review", "").is_none());
3032 assert!(cfg
3033 .model_for_complexity("unknown_agent_xyz", "simple", "")
3034 .is_none());
3035 }
3036
3037 #[test]
3038 fn review_started_comment_includes_agent_and_model() {
3039 let expected_footer = attribution_footer("Review started", "kimi", Some("opus"));
3040 let expected = format!("🔍 Automated review started{}", expected_footer);
3041 assert_eq!(review_started_comment("kimi", "opus"), expected);
3042 }
3043
3044 fn no_code_review_task(id: &str) -> ExternalTask {
3047 ExternalTask {
3048 id: crate::backends::ExternalId(id.to_string()),
3049 title: "opencode discovery timeout".to_string(),
3050 body: "".to_string(),
3051 state: "open".to_string(),
3052 labels: vec![],
3053 author: "tester".to_string(),
3054 created_at: "2026-01-01T00:00:00Z".to_string(),
3055 updated_at: "2026-01-01T00:00:00Z".to_string(),
3056 url: "https://example.com/issues/1".to_string(),
3057 }
3058 }
3059
3060 #[tokio::test]
3061 async fn build_review_context_reroutes_external_task_with_no_code() {
3062 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3063 let repo = "owner/repo";
3064 let ext = no_code_review_task("3617");
3065 store.ensure_external_task(repo, &ext).await.unwrap();
3066
3067 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3068 let task_manager = Arc::new(TaskManager::with_store(
3069 backend,
3070 Arc::clone(&store),
3071 repo.to_string(),
3072 ));
3073 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3074
3075 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3076 .await
3077 .unwrap();
3078
3079 assert!(
3080 matches!(phase, ReviewPhase::EarlyReturn(ReviewDecision::Rerouted)),
3081 "external task with no worktree/branch/pr must reroute, not skip-to-done"
3082 );
3083
3084 let stored = store
3085 .get_by_external_id(repo, &ext.id.0)
3086 .await
3087 .unwrap()
3088 .expect("task must still exist");
3089 assert_eq!(stored.status, TaskStatus::New);
3090 assert!(stored.agent.is_none());
3091 assert!(stored.model.is_none());
3092 }
3093
3094 #[tokio::test]
3095 async fn build_review_context_blocks_external_task_after_max_reroutes() {
3096 let temp_home = TempDir::new().unwrap();
3099 let orch_dir = temp_home.path().join(".orch");
3100 std::fs::create_dir_all(&orch_dir).unwrap();
3101 let old_orch_home = std::env::var_os("ORCH_HOME");
3102 std::env::set_var("ORCH_HOME", &orch_dir);
3103
3104 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3105 let repo = "owner/repo";
3106 let ext = no_code_review_task("3618");
3107 store.ensure_external_task(repo, &ext).await.unwrap();
3108
3109 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3110 let task_manager = Arc::new(TaskManager::with_store(
3111 backend,
3112 Arc::clone(&store),
3113 repo.to_string(),
3114 ));
3115 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3116
3117 for _ in 0..3 {
3119 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3120 .await
3121 .unwrap();
3122 if let ReviewPhase::EarlyReturn(ReviewDecision::Blocked(_)) = phase {
3123 break;
3124 }
3125 }
3126
3127 let stored = store
3128 .get_by_external_id(repo, &ext.id.0)
3129 .await
3130 .unwrap()
3131 .expect("task must still exist");
3132 assert_eq!(stored.status, TaskStatus::Blocked);
3133
3134 if let Some(old) = old_orch_home {
3135 std::env::set_var("ORCH_HOME", old);
3136 } else {
3137 std::env::remove_var("ORCH_HOME");
3138 }
3139 }
3140
3141 #[tokio::test]
3142 async fn build_review_context_marks_internal_task_done_with_no_code() {
3143 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3144 let repo = "owner/repo";
3145 let internal_id = store
3146 .create_internal(repo, "Daily retrospective", "", "job", "job-1", None)
3147 .await
3148 .unwrap();
3149 let ext_id = format!("internal:{internal_id}");
3150 let ext = no_code_review_task(&ext_id);
3151
3152 let backend = Arc::new(crate::backends::test_helpers::NoopBackend);
3153 let task_manager = Arc::new(TaskManager::with_store(
3154 backend,
3155 Arc::clone(&store),
3156 repo.to_string(),
3157 ));
3158 let router = Arc::new(RwLock::new(Router::new(RouterConfig::default())));
3159
3160 let phase = build_review_context(&ext, repo, &router, &task_manager, &store)
3161 .await
3162 .unwrap();
3163
3164 assert!(matches!(
3165 phase,
3166 ReviewPhase::EarlyReturn(ReviewDecision::Skipped)
3167 ));
3168 let stored = store.get(internal_id).await.unwrap();
3169 assert_eq!(stored.status, TaskStatus::Done);
3170 }
3171
3172 #[test]
3173 fn no_code_reroute_skip_check_rejects_agent_stream_disconnects() {
3174 let last_error =
3175 "codex failed: Reconnecting... (stream disconnected before completion: Broken pipe)";
3176 assert!(!should_skip_no_code_reroute_increment(last_error));
3177 }
3178
3179 #[test]
3188 fn review_fallback_scan_ignores_rate_limit_word_buried_in_long_report() {
3189 let padding = "x".repeat(10_000);
3190 let text_for_review = format!(
3191 "{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}"
3192 );
3193
3194 let scan_head = runner::agents::patterns::safe_head(
3195 &text_for_review,
3196 runner::agents::patterns::RATE_LIMIT_SCAN_HEAD_BYTES,
3197 );
3198 let scan_tail = runner::agents::patterns::safe_tail(
3199 &text_for_review,
3200 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3201 );
3202
3203 assert!(runner::agents::patterns::detect_rate_limit(scan_head).is_none());
3204 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_none());
3205 }
3206
3207 #[test]
3208 fn review_fallback_scan_still_detects_real_rate_limit_at_tail() {
3209 let padding = "x".repeat(10_000);
3210 let text_for_review =
3211 format!("{padding}\nError: rate limit exceeded, please try again later");
3212
3213 let scan_tail = runner::agents::patterns::safe_tail(
3214 &text_for_review,
3215 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3216 );
3217
3218 assert!(runner::agents::patterns::detect_rate_limit(scan_tail).is_some());
3219 }
3220
3221 #[tokio::test]
3222 async fn recover_missing_review_worktree_recreates_worktree() {
3223 let temp_home = TempDir::new().unwrap();
3224 let orch_dir = temp_home.path().join(".orch");
3225 std::fs::create_dir_all(&orch_dir).unwrap();
3226 let old_orch_home = std::env::var_os("ORCH_HOME");
3227 std::env::set_var("ORCH_HOME", &orch_dir);
3228
3229 let project_dir = temp_home.path().join("project");
3230 std::fs::create_dir_all(&project_dir).unwrap();
3231 git(&project_dir, &["init", "-b", "main"]);
3232 git(&project_dir, &["config", "user.name", "Test User"]);
3233 git(&project_dir, &["config", "user.email", "test@example.com"]);
3234 std::fs::write(project_dir.join("README.md"), "hello").unwrap();
3235 git(&project_dir, &["add", "README.md"]);
3236 git(&project_dir, &["commit", "-m", "init"]);
3237
3238 std::fs::write(
3239 orch_dir.join("config.yml"),
3240 format!("projects:\n - {}\n", project_dir.display()),
3241 )
3242 .unwrap();
3243 std::fs::write(project_dir.join(".orch.yml"), "gh:\n repo: owner/repo\n").unwrap();
3244
3245 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3246 let repo = "owner/repo";
3247 store
3248 .create_internal(repo, "Fix review recovery", "", "review", "review-1", None)
3249 .await
3250 .unwrap();
3251 let ext = ExternalTask {
3252 id: crate::backends::ExternalId("1".to_string()),
3253 title: "Fix review recovery".to_string(),
3254 body: "".to_string(),
3255 state: "open".to_string(),
3256 labels: vec![],
3257 author: "tester".to_string(),
3258 created_at: "2026-01-01T00:00:00Z".to_string(),
3259 updated_at: "2026-01-01T00:00:00Z".to_string(),
3260 url: "https://example.com/issues/1".to_string(),
3261 };
3262 store.ensure_external_task(repo, &ext).await.unwrap();
3263
3264 let recovered = recover_missing_review_worktree(&ext, repo, &store)
3265 .await
3266 .unwrap();
3267
3268 assert!(recovered.work_dir.exists());
3269 assert!(recovered.branch.starts_with("gh-issue-1-"));
3270
3271 if let Some(old) = old_orch_home {
3272 std::env::set_var("ORCH_HOME", old);
3273 } else {
3274 std::env::remove_var("ORCH_HOME");
3275 }
3276 }
3277
3278 #[tokio::test]
3281 async fn count_ahead_commits_returns_zero_when_no_ahead_commits() {
3282 let temp = TempDir::new().unwrap();
3283 let dir = temp.path();
3284
3285 let bare = temp.path().join("bare.git");
3287 std::fs::create_dir_all(&bare).unwrap();
3288 std::process::Command::new("git")
3289 .args(["init", "--bare"])
3290 .current_dir(&bare)
3291 .output()
3292 .unwrap();
3293
3294 git(dir, &["init", "-b", "main"]);
3295 git(dir, &["config", "user.name", "Test"]);
3296 git(dir, &["config", "user.email", "test@test.com"]);
3297 std::fs::write(dir.join("f"), "a").unwrap();
3298 git(dir, &["add", "f"]);
3299 git(dir, &["commit", "-m", "init"]);
3300 std::process::Command::new("git")
3301 .args(["remote", "add", "origin", bare.to_str().unwrap()])
3302 .current_dir(dir)
3303 .output()
3304 .unwrap();
3305 std::process::Command::new("git")
3306 .args(["push", "origin", "main"])
3307 .current_dir(dir)
3308 .output()
3309 .unwrap();
3310
3311 let result = count_ahead_commits(dir, "main").await;
3313 assert!(
3314 result.is_ok(),
3315 "expected Ok when origin/main exists, got {result:?}"
3316 );
3317 assert_eq!(
3318 result.unwrap(),
3319 0,
3320 "expected 0 commits ahead when branch matches origin/main"
3321 );
3322 }
3323
3324 #[tokio::test]
3325 async fn count_ahead_commits_returns_error_when_remote_ref_missing() {
3326 let temp = TempDir::new().unwrap();
3327 let dir = temp.path();
3328 git(dir, &["init", "-b", "main"]);
3329 git(dir, &["config", "user.name", "Test"]);
3330 git(dir, &["config", "user.email", "test@test.com"]);
3331 std::fs::write(dir.join("f"), "a").unwrap();
3332 git(dir, &["add", "f"]);
3333 git(dir, &["commit", "-m", "init"]);
3334 let result = count_ahead_commits(dir, "main").await;
3336 assert!(
3337 result.is_err(),
3338 "expected Err when origin/main does not exist, got {result:?}"
3339 );
3340 let err = result.unwrap_err();
3341 assert!(
3342 err.contains("git rev-list exited"),
3343 "error should mention exit failure, got: {err}"
3344 );
3345 }
3346
3347 #[tokio::test]
3348 async fn count_ahead_commits_returns_error_when_git_cannot_start() {
3349 let temp = TempDir::new().unwrap();
3351 let dir = temp.path();
3352 let result = count_ahead_commits(dir, "main").await;
3354 assert!(
3355 result.is_err(),
3356 "expected Err when directory is not a git repo, got {result:?}"
3357 );
3358 }
3359
3360 #[serial_test::serial(cooldown_state)]
3370 #[tokio::test]
3371 async fn review_invalid_response_applies_persistent_model_cooldown() {
3372 crate::engine::cooldown::reset_global_state().await;
3373 let agent = "opencode-3314-review-invalid";
3374 let model = "opencode/north-mini-code-free";
3375 let key = format!("{agent}:{model}");
3376
3377 assert!(
3378 !crate::engine::cooldown::is_model_in_cooldown(agent, model),
3379 "model should not be in cooldown before record_review_agent_failure"
3380 );
3381
3382 let err = runner::agents::AgentError::InvalidResponse {
3383 raw: "empty review output".to_string(),
3384 };
3385 record_review_agent_failure("test-3314-a", agent, Some(model), &err).await;
3386
3387 let now = chrono::Utc::now().timestamp();
3388 let until = crate::engine::cooldown::cooldown_until(&key)
3389 .expect("review InvalidResponse should set model cooldown");
3390 let remaining = until.saturating_sub(now);
3391
3392 assert!(
3395 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3396 "review InvalidResponse must apply ~4h persistent cooldown (issue #3314), \
3397 got {remaining}s — may have used 5-min standard backoff instead"
3398 );
3399 }
3400
3401 #[serial_test::serial(cooldown_state)]
3409 #[tokio::test]
3410 async fn review_persistent_cooldown_survives_after_first_failure() {
3411 crate::engine::cooldown::reset_global_state().await;
3412 let agent = "opencode-3314-review-parse";
3413 let model = "opencode/north-mini-code-free";
3414 let key = format!("{agent}:{model}");
3415
3416 let err = runner::agents::AgentError::InvalidResponse {
3418 raw: "failed to parse review response".to_string(),
3419 };
3420 record_review_agent_failure("test-3314-b", agent, Some(model), &err).await;
3421
3422 let now = chrono::Utc::now().timestamp();
3423 let until1 = crate::engine::cooldown::cooldown_until(&key)
3424 .expect("first failure should set cooldown");
3425 let remaining1 = until1.saturating_sub(now);
3426
3427 crate::engine::cooldown::record_agent_success(agent, model).await;
3430
3431 assert!(
3437 remaining1 >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3438 "after success reset, first-failure cooldown should still be ~4h, got {remaining1}s"
3439 );
3440 }
3441
3442 #[serial_test::serial(cooldown_state)]
3448 #[tokio::test]
3449 async fn review_inline_scan_billing_cycle_message_gets_model_scoped_cooldown() {
3450 crate::engine::cooldown::reset_global_state().await;
3451 let agent = "kimi-3551-review-inline";
3452 let model = "opus";
3453 let key = format!("{agent}:{model}");
3454
3455 let text_for_review = "kimi rate limit: Failed to authenticate. API Error: 403 \
3457 You've reached your usage limit for this billing cycle. Your quota will be \
3458 refreshed next cycle.";
3459
3460 let scan_tail = runner::agents::patterns::safe_tail(
3461 text_for_review,
3462 runner::agents::patterns::RATE_LIMIT_SCAN_TAIL_BYTES,
3463 );
3464 let err = runner::agents::patterns::detect_rate_limit(scan_tail)
3465 .expect("billing-cycle message must still be caught by the rate-limit scan");
3466
3467 assert_eq!(
3468 outcome_for_agent_error(&err),
3469 "billing_cycle_exhausted",
3470 "inline-scanned billing-cycle message must not be stored as plain rate_limit"
3471 );
3472
3473 record_review_agent_failure("test-3551", agent, Some(model), &err).await;
3474
3475 let now = chrono::Utc::now().timestamp();
3476 let until = crate::engine::cooldown::cooldown_until(&key)
3477 .expect("billing-cycle exhaustion must set a model-scoped cooldown");
3478 let remaining = until.saturating_sub(now);
3479 assert!(
3484 remaining >= crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS - 30,
3485 "billing-cycle exhaustion for a known model must apply the ~4h persistent \
3486 model cooldown, got {remaining}s — may have used the generic short agent \
3487 cooldown instead"
3488 );
3489 }
3490
3491 fn test_review_task(id: &str) -> ExternalTask {
3494 ExternalTask {
3495 id: crate::backends::ExternalId(id.to_string()),
3496 title: "Test review task".to_string(),
3497 body: "".to_string(),
3498 state: "open".to_string(),
3499 labels: vec![],
3500 author: "tester".to_string(),
3501 created_at: "2026-01-01T00:00:00Z".to_string(),
3502 updated_at: "2026-01-01T00:00:00Z".to_string(),
3503 url: "https://example.com/issues/1".to_string(),
3504 }
3505 }
3506
3507 fn test_review_context(agent: &str, model: &str, work_dir: &std::path::Path) -> ReviewContext {
3508 ReviewContext {
3509 store_id: None,
3510 had_prev_push_failure: false,
3511 review_attempt: 1,
3512 worktree_path: work_dir.to_path_buf(),
3513 branch_name: "gh-issue-1-test".to_string(),
3514 pr_number: 1,
3515 default_branch: "main".to_string(),
3516 review_agent: agent.to_string(),
3517 review_model: Some(model.to_string()),
3518 review_task_id: "review-1".to_string(),
3519 review_attempt_dir: work_dir.to_path_buf(),
3520 output_file: work_dir.join("output.txt"),
3521 invocation: runner::agent::AgentInvocation {
3522 agent: agent.to_string(),
3523 model: Some(model.to_string()),
3524 work_dir: work_dir.to_path_buf(),
3525 system_prompt: String::new(),
3526 agent_message: String::new(),
3527 task_id: "review-1".to_string(),
3528 disallowed_tools: vec![],
3529 git_author_name: "Test".to_string(),
3530 git_author_email: "test@example.com".to_string(),
3531 output_file: work_dir.join("output.txt"),
3532 timeout_seconds: 0,
3533 repo: "owner/repo".to_string(),
3534 attempt: 1,
3535 },
3536 same_agent_fallback: false,
3537 }
3538 }
3539
3540 fn test_parsed_review(decision: ReviewDecision) -> ParsedReview {
3541 ParsedReview {
3542 run_id: None,
3543 exit_code: 0,
3544 stderr: String::new(),
3545 raw_output: String::new(),
3546 text_for_review: String::new(),
3547 token_usage: RunTokenUsage::default(),
3548 review_notes_for_comment: String::new(),
3549 decision,
3550 }
3551 }
3552
3553 #[serial_test::serial(cooldown_state)]
3559 #[tokio::test]
3560 async fn finalize_review_run_success_resets_generic_model_failure_count() {
3561 crate::engine::cooldown::reset_global_state().await;
3562 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3563 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
3564
3565 let agent = "test-3478-agent";
3566 let model = "test-3478-model";
3567 let failure_key = format!("failure_count:{agent}:{model}");
3568
3569 let err = runner::agents::AgentError::AgentFailed {
3573 message: "agent reported a generic failure".to_string(),
3574 };
3575 record_review_agent_failure("task-3478-a", agent, Some(model), &err).await;
3576 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3577 assert_eq!(
3578 store.kv_get(&failure_key).await.unwrap().as_deref(),
3579 Some("1"),
3580 "sanity check: failure should have incremented the persisted count"
3581 );
3582
3583 let work_dir = TempDir::new().unwrap();
3584 let ctx = test_review_context(agent, model, work_dir.path());
3585 let parsed = test_parsed_review(ReviewDecision::Approve);
3586 let task = test_review_task("1");
3587
3588 finalize_review_run(&task, &ctx, &parsed, &store).await;
3589 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3590
3591 assert_eq!(
3592 store.kv_get(&failure_key).await.unwrap().as_deref(),
3593 Some("0"),
3594 "successful review must reset failure_count:{{agent}}:{{model}} (issue #3478)"
3595 );
3596 }
3597
3598 #[serial_test::serial(cooldown_state)]
3604 #[tokio::test]
3605 async fn finalize_review_run_interleaved_success_keeps_generic_cooldown_at_base() {
3606 crate::engine::cooldown::reset_global_state().await;
3607 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3608 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
3609
3610 let agent = "test-3478-interleave-agent";
3611 let model = "test-3478-interleave-model";
3612 let cooldown_key = format!("{agent}:{model}");
3613
3614 let work_dir = TempDir::new().unwrap();
3615 let ctx = test_review_context(agent, model, work_dir.path());
3616 let task = test_review_task("2");
3617
3618 for i in 0..4 {
3619 let err = runner::agents::AgentError::AgentFailed {
3620 message: format!("failure {i}"),
3621 };
3622 record_review_agent_failure("task-3478-b", agent, Some(model), &err).await;
3623
3624 let now = chrono::Utc::now().timestamp();
3625 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
3626 .expect("failure should set cooldown");
3627 let remaining = until.saturating_sub(now);
3628
3629 assert!(
3630 remaining <= crate::engine::cooldown::BACKOFF_BASE_SECS + 60,
3631 "failure #{} must stay at base backoff since the prior success reset the \
3632 counter (issue #3478); got {remaining}s, base is {}s — an escalating value \
3633 here means review successes are not resetting failure_count",
3634 i + 1,
3635 crate::engine::cooldown::BACKOFF_BASE_SECS
3636 );
3637
3638 let parsed = test_parsed_review(ReviewDecision::Approve);
3642 finalize_review_run(&task, &ctx, &parsed, &store).await;
3643 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3644 }
3645 }
3646
3647 #[serial_test::serial(cooldown_state)]
3653 #[tokio::test]
3654 async fn finalize_review_run_success_does_not_reset_persistent_model_failure_count() {
3655 crate::engine::cooldown::reset_global_state().await;
3656 let store = Arc::new(TaskStore::open_memory().await.unwrap());
3657 crate::engine::cooldown::init_cooldown_store(store.clone()).await;
3658
3659 let agent = "test-3571-agent";
3660 let model = "test-3571-model";
3661 let cooldown_key = format!("{agent}:{model}");
3662
3663 let work_dir = TempDir::new().unwrap();
3664 let ctx = test_review_context(agent, model, work_dir.path());
3665 let task = test_review_task("3");
3666
3667 let err = runner::agents::AgentError::InvalidResponse {
3669 raw: "empty review output".to_string(),
3670 };
3671 record_review_agent_failure("task-3571-a", agent, Some(model), &err).await;
3672 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3673
3674 let parsed = test_parsed_review(ReviewDecision::Approve);
3677 finalize_review_run(&task, &ctx, &parsed, &store).await;
3678 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3679
3680 let err2 = runner::agents::AgentError::InvalidResponse {
3683 raw: "empty review output again".to_string(),
3684 };
3685 record_review_agent_failure("task-3571-b", agent, Some(model), &err2).await;
3686 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
3687
3688 let now = chrono::Utc::now().timestamp();
3689 let until = crate::engine::cooldown::cooldown_until(&cooldown_key)
3690 .expect("second failure should set cooldown");
3691 let remaining = until.saturating_sub(now);
3692
3693 assert!(
3694 remaining > crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS,
3695 "second persistent model failure should escalate past the 4h base despite an \
3696 intervening success (issue #3571); got {remaining}s, base is {}s",
3697 crate::engine::cooldown::PERSISTENT_MODEL_BACKOFF_BASE_SECS
3698 );
3699 }
3700}