1use crate::config;
8use crate::parser::AgentResponse;
9use crate::store;
10use crate::store::TaskStore;
11use std::io;
12use std::io::ErrorKind;
13use std::path::Path;
14use std::sync::Arc;
15
16use tokio::fs;
17
18use super::{agents, git_ops, response, worktree};
19use crate::engine::cooldown;
20use crate::engine::review;
21use crate::github::http::GhHttp;
22use crate::status_heuristics;
23use crate::store::Task;
24
25#[expect(
27 clippy::too_many_arguments,
28 reason = "each field of result.json is a separate argument"
29)]
30pub async fn write_result_json(
31 attempt_dir: &Path,
32 task_id: &str,
33 agent_name: &str,
34 model_name: Option<&str>,
35 exit_code: i32,
36 new_attempts: u32,
37 parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
38 raw_stdout: &str,
39 raw_stderr: &str,
40) {
41 let result_json = match parse_result {
42 Ok(parsed) => {
43 serde_json::json!({
44 "outcome": "success",
45 "agent": agent_name,
46 "model": model_name.unwrap_or("default"),
47 "exit_code": exit_code,
48 "attempt": new_attempts,
49 "status": parsed.response.status,
50 "summary": parsed.response.summary,
51 "input_tokens": parsed.input_tokens,
52 "output_tokens": parsed.output_tokens,
53 "duration_ms": parsed.duration_ms,
54 "files": parsed.response.files,
55 "accomplished": parsed.response.accomplished,
56 "remaining": parsed.response.remaining,
57 "error": parsed.response.error,
58 "learnings": parsed.response.learnings,
59 "delegations": parsed.response.delegations.iter()
60 .map(|d| serde_json::json!({"title": d.title, "body": d.body}))
61 .collect::<Vec<_>>(),
62 })
63 }
64 Err(agent_err) => {
65 serde_json::json!({
66 "outcome": "error",
67 "agent": agent_name,
68 "model": model_name.unwrap_or("default"),
69 "exit_code": exit_code,
70 "attempt": new_attempts,
71 "error_class": agents::error_class_name(agent_err),
72 "error_message": agent_err.to_string(),
73 "stderr_tail": agents::patterns::safe_tail(raw_stderr, 2000),
74 "stdout_tail": agents::patterns::safe_tail(raw_stdout, 2000),
75 })
76 }
77 };
78
79 if let Err(e) = fs::write(
80 attempt_dir.join("result.json"),
81 serde_json::to_string_pretty(&result_json).unwrap_or_default(),
82 )
83 .await
84 {
85 tracing::debug!(task_id, ?e, "failed to write result.json");
86 }
87}
88
89#[derive(Default)]
95#[expect(
96 clippy::struct_excessive_bools,
97 reason = "each flag is an independent input to the status decision"
98)]
99struct DecisionInput<'a> {
100 agent_status: &'a str,
102 is_workflow_scope_failure: bool,
105 is_rebase_conflict_failure: bool,
108 push_failed: bool,
110 push_failures: u64,
113 has_pr: bool,
115 has_delegations: bool,
117 has_pushed: bool,
119 is_pr_base_invalid: bool,
123 requires_pr: bool,
126 no_code_reroutes: u64,
128 max_reroutes: u32,
130 is_same_agent: bool,
133 is_completed_status: bool,
138 is_retryable_blocked: bool,
140 is_superseded_conflict: bool,
144}
145
146fn classify_final_status(input: &DecisionInput<'_>) -> String {
153 if input.is_workflow_scope_failure || input.is_rebase_conflict_failure {
154 "blocked".to_string()
155 } else if input.push_failed {
156 if input.push_failures >= 3 {
157 "blocked".to_string()
158 } else {
159 "new".to_string()
160 }
161 } else if input.agent_status == "done" && input.has_pr {
162 "needs_review".to_string()
163 } else if input.agent_status == "done" && !input.has_pr && input.has_delegations {
164 "blocked".to_string()
165 } else if input.agent_status == "done"
166 && !input.has_pr
167 && input.has_pushed
168 && input.is_pr_base_invalid
169 {
170 "blocked".to_string()
174 } else if input.agent_status == "done" && !input.has_pr && input.has_pushed {
175 "routed".to_string()
176 } else if input.is_superseded_conflict {
177 "done".to_string()
178 } else if input.agent_status == "done" && input.requires_pr {
179 if input.is_same_agent || input.no_code_reroutes >= u64::from(input.max_reroutes) {
183 "blocked".to_string()
184 } else {
185 "new".to_string()
186 }
187 } else if input.agent_status == "done" && !input.has_pushed {
188 "done".to_string()
193 } else if input.is_completed_status && input.has_pr {
194 "needs_review".to_string()
196 } else if input.is_completed_status && !input.has_pr && input.has_delegations {
197 "blocked".to_string()
199 } else if input.is_completed_status
200 && !input.has_pr
201 && input.has_pushed
202 && input.is_pr_base_invalid
203 {
204 "blocked".to_string()
205 } else if input.is_completed_status && !input.has_pr && input.has_pushed {
206 "routed".to_string()
208 } else if input.is_completed_status && input.requires_pr {
209 if input.is_same_agent || input.no_code_reroutes >= u64::from(input.max_reroutes) {
212 "blocked".to_string()
213 } else {
214 "new".to_string()
215 }
216 } else if input.is_completed_status {
217 "done".to_string()
221 } else if input.agent_status == "blocked" && input.is_retryable_blocked {
222 "new".to_string()
223 } else if status_heuristics::status_looks_like_descriptive_completion(input.agent_status) {
224 "done".to_string()
225 } else {
226 input.agent_status.to_string()
227 }
228}
229
230struct StoreCtx<'a> {
234 store: &'a Option<Arc<TaskStore>>,
235 store_id_opt: Option<i64>,
236 repo: &'a str,
237 task_id: &'a str,
238}
239
240impl StoreCtx<'_> {
241 async fn set(&self, fields: &[(&str, serde_json::Value)]) {
243 if let Some(store_id) = self.store_id_opt {
244 let _ = store::store_set_by_id(self.store.as_ref(), store_id, fields).await;
245 } else {
246 store::store_set(self.store, self.repo, self.task_id, fields).await;
247 }
248 }
249
250 async fn get_task(&self) -> Option<Task> {
252 if let Some(store_id) = self.store_id_opt {
253 store::opt_store_get_task_by_id(self.store, store_id).await
254 } else {
255 store::opt_store_get_task(self.store, self.repo, self.task_id).await
256 }
257 }
258
259 async fn increment(&self, field: &str) -> anyhow::Result<u64> {
261 if let Some(store_id) = self.store_id_opt {
262 store::store_increment_by_id(self.store, store_id, field).await
263 } else {
264 store::store_increment(self.store, self.repo, self.task_id, field).await
265 }
266 }
267
268 async fn append_activity(
270 &self,
271 event_type: &str,
272 agent: Option<&str>,
273 model: Option<&str>,
274 details: Option<&serde_json::Value>,
275 ) {
276 if let Some(store_id) = self.store_id_opt {
277 if let Some(s) = self.store
278 && let Err(e) = s
279 .append_activity(store_id, event_type, None, None, agent, model, details)
280 .await
281 {
282 tracing::warn!(
283 task_id = self.task_id,
284 event_type,
285 error = %e,
286 "store append_activity failed"
287 );
288 }
289 } else {
290 store::store_log_activity(
291 self.store,
292 self.repo,
293 self.task_id,
294 event_type,
295 None,
296 None,
297 agent,
298 model,
299 details,
300 )
301 .await;
302 }
303 }
304}
305
306#[derive(Default)]
310#[expect(
311 clippy::struct_excessive_bools,
312 reason = "each flag is an independent outcome of the git pipeline"
313)]
314struct GitOpsResult {
315 has_pr: bool,
316 has_pushed: bool,
317 has_commits: bool,
318 pr_base_invalid: bool,
322}
323
324struct PushFailureState {
326 push_failed: bool,
327 is_workflow_scope_failure: bool,
328 is_rebase_conflict_failure: bool,
329 push_failures: u64,
331 stored_last_error: String,
333}
334
335async fn auto_commit_changes(
339 ctx: &StoreCtx<'_>,
340 work_dir: &Path,
341 task_title: &str,
342 agent_name: &str,
343 new_attempts: u32,
344) {
345 if let Err(e) = git_ops::auto_commit(
346 work_dir,
347 ctx.task_id,
348 task_title,
349 agent_name,
350 new_attempts,
351 super::commit::When::Always,
352 )
353 .await
354 {
355 tracing::error!(task_id = ctx.task_id, error = ?e, "auto commit failed");
356 ctx.set(&[(
357 "last_error",
358 serde_json::json!(format!("auto commit failed: {e}")),
359 )])
360 .await;
361 }
362}
363
364async fn check_commits_and_clear_stale_errors(
369 ctx: &StoreCtx<'_>,
370 work_dir: &Path,
371 default_branch: &str,
372) -> bool {
373 let has_commits = git_ops::has_commits_ahead(work_dir, default_branch).await;
374 if !has_commits {
375 tracing::info!(
376 task_id = ctx.task_id,
377 "no commits ahead of default branch, skipping push + PR"
378 );
379 let last_err = ctx
381 .get_task()
382 .await
383 .map(|t| t.last_error)
384 .unwrap_or_default();
385 if last_err.contains("push failed") {
386 ctx.set(&[("last_error", serde_json::json!(""))]).await;
387 }
388 }
389 has_commits
390}
391
392enum PushResult {
402 Success,
403 Failed,
404 NoCommits, WorktreeMissing, }
407
408fn is_missing_path_error(err: &anyhow::Error) -> bool {
409 err.chain().any(|cause| {
410 cause
411 .downcast_ref::<io::Error>()
412 .is_some_and(|ioe| ioe.kind() == ErrorKind::NotFound)
413 }) || err
414 .to_string()
415 .to_ascii_lowercase()
416 .contains("no such file or directory")
417}
418
419async fn recover_missing_worktree_for_push(
420 ctx: &StoreCtx<'_>,
421 wt: &worktree::WorktreeSetup,
422 task_title: &str,
423 agent_name: &str,
424 model_name: Option<&str>,
425) -> anyhow::Result<worktree::WorktreeSetup> {
426 tracing::warn!(
427 task_id = ctx.task_id,
428 worktree = %wt.work_dir.display(),
429 branch = wt.branch,
430 "worktree missing during push; attempting one-time recovery"
431 );
432 ctx.append_activity(
433 "push_recover_worktree",
434 Some(agent_name),
435 model_name,
436 Some(&serde_json::json!({
437 "status": "start",
438 "worktree": wt.work_dir.display().to_string(),
439 "branch": wt.branch,
440 })),
441 )
442 .await;
443
444 let recovered = worktree::setup_worktree(
445 ctx.task_id,
446 task_title,
447 &wt.main_project_dir,
448 ctx.store,
449 ctx.repo,
450 )
451 .await?;
452
453 if !recovered.work_dir.exists() {
454 anyhow::bail!(
455 "recovered worktree path is still missing: {}",
456 recovered.work_dir.display()
457 );
458 }
459
460 ctx.append_activity(
461 "push_recover_worktree",
462 Some(agent_name),
463 model_name,
464 Some(&serde_json::json!({
465 "status": "ok",
466 "worktree": recovered.work_dir.display().to_string(),
467 "branch": recovered.branch,
468 })),
469 )
470 .await;
471
472 Ok(recovered)
473}
474
475async fn push_after_rebase(
480 ctx: &StoreCtx<'_>,
481 work_dir: &Path,
482 branch: &str,
483 default_branch: &str,
484 agent_name: &str,
485 model_name: Option<&str>,
486) -> PushResult {
487 match git_ops::push_branch(work_dir, branch, default_branch).await {
488 Ok(_) => {
489 ctx.append_activity(
490 "push",
491 Some(agent_name),
492 model_name,
493 Some(&serde_json::json!({
494 "status": "ok",
495 "branch": branch,
496 "default_branch": default_branch,
497 "reason": "pushed_after_rebase",
498 })),
499 )
500 .await;
501 ctx.set(&[
502 ("last_error", serde_json::json!("")),
503 ("push_failures", serde_json::json!(0)),
504 ])
505 .await;
506 PushResult::Success
507 }
508 Err(e) => {
509 tracing::error!(
510 task_id = ctx.task_id,
511 branch = branch,
512 error = ?e,
513 "push failed again after rebasing on the remote branch"
514 );
515 ctx.append_activity(
516 "push",
517 Some(agent_name),
518 model_name,
519 Some(&serde_json::json!({
520 "status": "error",
521 "branch": branch,
522 "default_branch": default_branch,
523 "reason": "pushed_after_rebase",
524 "error": e.to_string(),
525 })),
526 )
527 .await;
528 ctx.set(&[(
529 "last_error",
530 serde_json::json!(format!("push failed after rebase on remote branch: {e}")),
531 )])
532 .await;
533 PushResult::Failed
534 }
535 }
536}
537
538async fn push_branch_with_log(
539 ctx: &StoreCtx<'_>,
540 work_dir: &Path,
541 branch: &str,
542 default_branch: &str,
543 agent_name: &str,
544 model_name: Option<&str>,
545) -> PushResult {
546 match git_ops::push_branch(work_dir, branch, default_branch).await {
547 Ok(_) => {
548 ctx.append_activity(
549 "push",
550 Some(agent_name),
551 model_name,
552 Some(&serde_json::json!({
553 "status": "ok",
554 "branch": branch,
555 "default_branch": default_branch,
556 })),
557 )
558 .await;
559 ctx.set(&[
562 ("last_error", serde_json::json!("")),
563 ("push_failures", serde_json::json!(0)),
564 ])
565 .await;
566 PushResult::Success
567 }
568 Err(e) => {
569 let err_str = e.to_string();
570 tracing::error!(task_id = ctx.task_id, error = ?e, "push failed");
571
572 if is_missing_path_error(&e) && !work_dir.exists() {
573 tracing::warn!(
574 task_id = ctx.task_id,
575 worktree = %work_dir.display(),
576 "push failed because worktree path disappeared; will attempt recovery"
577 );
578 ctx.append_activity(
579 "push",
580 Some(agent_name),
581 model_name,
582 Some(&serde_json::json!({
583 "status": "retry",
584 "branch": branch,
585 "default_branch": default_branch,
586 "reason": "worktree_missing",
587 "error": err_str,
588 })),
589 )
590 .await;
591 return PushResult::WorktreeMissing;
592 }
593
594 let is_diverged = err_str.contains("non-fast-forward")
596 || err_str.contains("rejected")
597 || err_str.contains("fetch first")
598 || err_str.contains("behind");
599
600 if is_diverged {
601 tracing::info!(
602 task_id = ctx.task_id,
603 branch = branch,
604 "attempting fetch and rebase to recover from diverged branch"
605 );
606
607 match git_ops::rebase_on_branch(work_dir, branch).await {
609 Ok(true) => {
610 tracing::info!(
614 task_id = ctx.task_id,
615 branch = branch,
616 "rebase succeeded — retrying the push"
617 );
618 ctx.append_activity(
619 "push_rebased",
620 Some(agent_name),
621 model_name,
622 Some(&serde_json::json!({
623 "status": "rebased",
624 "branch": branch,
625 "reason": "diverged_from_remote",
626 })),
627 )
628 .await;
629 return push_after_rebase(
630 ctx,
631 work_dir,
632 branch,
633 default_branch,
634 agent_name,
635 model_name,
636 )
637 .await;
638 }
639 Ok(false) => {
640 tracing::info!(
642 task_id = ctx.task_id,
643 branch = branch,
644 "no local commits after fetch — marking as success"
645 );
646 ctx.append_activity(
647 "push",
648 Some(agent_name),
649 model_name,
650 Some(&serde_json::json!({
651 "status": "ok",
652 "branch": branch,
653 "reason": "no_commits_after_fetch",
654 })),
655 )
656 .await;
657 ctx.set(&[
658 ("last_error", serde_json::json!("")),
659 ("push_failures", serde_json::json!(0)),
660 ])
661 .await;
662 return PushResult::Success;
663 }
664 Err(rebase_err) => {
665 tracing::error!(
667 task_id = ctx.task_id,
668 branch = branch,
669 error = ?rebase_err,
670 "rebase failed — blocking task for human intervention"
671 );
672 ctx.append_activity(
673 "push_rebased",
674 Some(agent_name),
675 model_name,
676 Some(&serde_json::json!({
677 "status": "error",
678 "branch": branch,
679 "error": rebase_err.to_string(),
680 })),
681 )
682 .await;
683 ctx.set(&[(
684 "last_error",
685 serde_json::json!(format!(
686 "push failed and rebase failed: {rebase_err} — manual resolution required"
687 )),
688 )])
689 .await;
690 return PushResult::Failed;
691 }
692 }
693 }
694
695 ctx.append_activity(
697 "push",
698 Some(agent_name),
699 model_name,
700 Some(&serde_json::json!({
701 "status": "error",
702 "branch": branch,
703 "default_branch": default_branch,
704 "error": err_str,
705 })),
706 )
707 .await;
708 ctx.set(&[("last_error", serde_json::json!(format!("push failed: {e}")))])
709 .await;
710 PushResult::Failed
711 }
712 }
713}
714
715fn is_local_rate_limit_skip(err_str: &str) -> bool {
717 err_str.contains("rate-limited, skipping call")
718}
719
720async fn create_pr_with_log(
727 ctx: &StoreCtx<'_>,
728 wt: &worktree::WorktreeSetup,
729 task_title: &str,
730 resp: &AgentResponse,
731 agent_name: &str,
732 model_name: Option<&str>,
733 mut has_pushed: bool,
734) -> (bool, bool, bool) {
735 match git_ops::create_pr_if_needed(
736 &wt.work_dir,
737 &wt.branch,
738 task_title,
739 &resp.summary,
740 &resp.accomplished,
741 &resp.remaining,
742 &resp.files,
743 ctx.task_id,
744 agent_name,
745 model_name,
746 ctx.repo,
747 &wt.default_branch,
748 )
749 .await
750 {
751 Ok(ref url) => {
752 match review::parse_pr_number_from_url(url) {
756 Ok(pr_num) => {
757 if let Err(e) = store::store_set_result(
758 ctx.store,
759 ctx.repo,
760 ctx.task_id,
761 &[("pr_number", serde_json::json!(pr_num))],
762 )
763 .await
764 {
765 tracing::error!(
766 task_id = ctx.task_id,
767 pr_url = %url,
768 err = %e,
769 "CRITICAL: failed to save pr_number to store — downstream review gate may trigger duplicate gh pr create"
770 );
771 }
772 }
773 Err(e) => {
774 tracing::warn!(
775 task_id = ctx.task_id,
776 pr_url = %url,
777 error = %e,
778 "PR created but failed to parse PR number from URL"
779 );
780 }
781 }
782 ctx.append_activity(
783 "pr_create",
784 Some(agent_name),
785 model_name,
786 Some(&serde_json::json!({"status": "created", "url": url})),
787 )
788 .await;
789 (true, has_pushed, false)
790 }
791 Err(e) => {
792 let err_str = format!("{e}");
793 tracing::error!(task_id = ctx.task_id, error = ?e, "create PR failed");
794 ctx.append_activity(
795 "pr_create",
796 Some(agent_name),
797 model_name,
798 Some(&serde_json::json!({
799 "status": "error",
800 "branch": wt.branch,
801 "error": err_str.clone(),
802 })),
803 )
804 .await;
805 ctx.set(&[(
806 "last_error",
807 serde_json::json!(format!("create PR failed: {e}")),
808 )])
809 .await;
810 let mut pr_base_invalid = false;
820 if is_local_rate_limit_skip(&err_str) {
821 tracing::warn!(
824 task_id = ctx.task_id,
825 "PR creation skipped by local GitHub rate limit — review gate will create the PR"
826 );
827 return (true, has_pushed, false);
828 }
829 if agents::patterns::detect_auth_error(&err_str).is_some() {
830 tracing::error!(
831 task_id = ctx.task_id,
832 "PR creation failed with GitHub auth error — blocking task"
833 );
834 pr_base_invalid = true;
835 } else if err_str.contains("422") {
836 if err_str.contains("No commits between") || err_str.contains("head") {
837 tracing::info!(
838 task_id = ctx.task_id,
839 "PR creation returned 422/no-commits/head-invalid — agent made no code changes or branch merged, marking done"
840 );
841 has_pushed = false;
842 } else if git_ops::is_invalid_base_error(&err_str) {
843 tracing::error!(
844 task_id = ctx.task_id,
845 "PR creation failed with invalid base branch — blocking task"
846 );
847 pr_base_invalid = true;
848 }
849 }
850 (false, has_pushed, pr_base_invalid)
851 }
852 }
853}
854
855async fn run_git_ops(
857 ctx: &StoreCtx<'_>,
858 wt: &worktree::WorktreeSetup,
859 task_title: &str,
860 resp: &AgentResponse,
861 agent_name: &str,
862 model_name: Option<&str>,
863 new_attempts: u32,
864) -> GitOpsResult {
865 auto_commit_changes(ctx, &wt.work_dir, task_title, agent_name, new_attempts).await;
866
867 let mut has_commits =
868 check_commits_and_clear_stale_errors(ctx, &wt.work_dir, &wt.default_branch).await;
869
870 let push_result = if has_commits {
874 push_branch_with_log(
875 ctx,
876 &wt.work_dir,
877 &wt.branch,
878 &wt.default_branch,
879 agent_name,
880 model_name,
881 )
882 .await
883 } else {
884 store::store_log_activity(
885 ctx.store,
886 ctx.repo,
887 ctx.task_id,
888 "push",
889 None,
890 None,
891 Some(agent_name),
892 model_name,
893 Some(&serde_json::json!({
894 "status": "skipped",
895 "reason": "no_commits_ahead",
896 "branch": wt.branch,
897 "default_branch": wt.default_branch,
898 })),
899 )
900 .await;
901 PushResult::NoCommits
902 };
903 let push_result = if matches!(push_result, PushResult::WorktreeMissing) {
904 match recover_missing_worktree_for_push(ctx, wt, task_title, agent_name, model_name).await {
905 Ok(recovered) => {
906 let wt_for_ops = &recovered;
907 has_commits = check_commits_and_clear_stale_errors(
908 ctx,
909 &wt_for_ops.work_dir,
910 &wt_for_ops.default_branch,
911 )
912 .await;
913 if has_commits {
914 push_branch_with_log(
915 ctx,
916 &wt_for_ops.work_dir,
917 &wt_for_ops.branch,
918 &wt_for_ops.default_branch,
919 agent_name,
920 model_name,
921 )
922 .await
923 } else {
924 PushResult::NoCommits
925 }
926 }
927 Err(e) => {
928 tracing::error!(
929 task_id = ctx.task_id,
930 err = %e,
931 "worktree recovery before push failed"
932 );
933 ctx.append_activity(
934 "push_recover_worktree",
935 Some(agent_name),
936 model_name,
937 Some(&serde_json::json!({
938 "status": "error",
939 "worktree": wt.work_dir.display().to_string(),
940 "branch": wt.branch,
941 "error": e.to_string(),
942 })),
943 )
944 .await;
945 ctx.set(&[(
946 "last_error",
947 serde_json::json!(format!(
948 "push failed: worktree disappeared and recovery failed: {e}"
949 )),
950 )])
951 .await;
952 PushResult::Failed
953 }
954 }
955 } else {
956 push_result
957 };
958
959 let mut has_pushed = matches!(push_result, PushResult::Success);
960 let mut has_pr = false;
961
962 if has_commits && matches!(push_result, PushResult::Failed) {
964 tracing::warn!(
965 task_id = ctx.task_id,
966 "skipping PR creation due to push failure"
967 );
968 } else if matches!(push_result, PushResult::NoCommits) {
969 tracing::info!(
970 task_id = ctx.task_id,
971 "no commits ahead of default branch, skipping push + PR"
972 );
973 } else if !has_pushed {
974 } else if ctx.repo.is_empty() {
976 tracing::warn!(
977 task_id = ctx.task_id,
978 "skipping PR creation — repo is empty (internal task?)"
979 );
980 } else {
981 let (pr, updated_pushed, pr_base_invalid) = create_pr_with_log(
982 ctx, wt, task_title, resp, agent_name, model_name, has_pushed,
983 )
984 .await;
985 has_pr = pr;
986 has_pushed = updated_pushed;
987 return GitOpsResult {
988 has_pr,
989 has_pushed,
990 has_commits,
991 pr_base_invalid,
992 };
993 }
994
995 GitOpsResult {
996 has_pr,
997 has_pushed,
998 has_commits,
999 pr_base_invalid: false,
1000 }
1001}
1002
1003async fn close_superseded_pr(ctx: &StoreCtx<'_>, pr_number: u64) {
1007 if ctx.repo.is_empty() {
1008 return;
1009 }
1010 match GhHttp::new() {
1011 Ok(gh) => {
1012 let note = "Closing as superseded: the change is already on the default branch, so this branch has no commits ahead of it.";
1013 if let Err(e) = gh.add_comment(ctx.repo, &pr_number.to_string(), note).await {
1014 tracing::warn!(task_id = ctx.task_id, pr_number, err = %e, "failed to comment on superseded PR");
1015 }
1016 if let Err(e) = gh.close_pr(ctx.repo, pr_number).await {
1017 tracing::warn!(task_id = ctx.task_id, pr_number, err = %e, "failed to close superseded PR");
1018 }
1019 }
1020 Err(e) => {
1021 tracing::warn!(task_id = ctx.task_id, err = %e, "cannot close superseded PR");
1022 }
1023 }
1024 ctx.set(&[
1026 (
1027 "last_error",
1028 serde_json::json!(
1029 "PR closed as superseded: the fix already landed on the default branch"
1030 ),
1031 ),
1032 ("pr_number", serde_json::json!(null)),
1033 ])
1034 .await;
1035}
1036
1037fn superseded_conflict_pr(
1043 merge_conflict_retries: i32,
1044 pr_number: Option<i32>,
1045 has_commits: bool,
1046) -> Option<u64> {
1047 if has_commits || merge_conflict_retries <= 0 {
1048 return None;
1049 }
1050 pr_number
1051 .filter(|n| *n > 0)
1052 .and_then(|n| u64::try_from(n).ok())
1053}
1054
1055async fn detect_push_failure_state(
1057 ctx: &StoreCtx<'_>,
1058 has_commits: bool,
1059 has_pushed: bool,
1060) -> PushFailureState {
1061 let stored_last_error = ctx
1063 .get_task()
1064 .await
1065 .map(|t| t.last_error)
1066 .unwrap_or_default();
1067
1068 let push_failed = !has_pushed && has_commits && stored_last_error.contains("push failed");
1069
1070 let is_workflow_scope_failure = push_failed
1073 && (stored_last_error.contains("workflow` scope")
1074 || stored_last_error.contains("workflow' scope")
1075 || (stored_last_error.contains("refusing to allow")
1076 && stored_last_error.contains("workflow")));
1077
1078 let is_rebase_conflict_failure =
1079 push_failed && stored_last_error.contains("push failed and rebase failed:");
1080
1081 let push_failures = if push_failed && !is_workflow_scope_failure && !is_rebase_conflict_failure
1086 {
1087 match ctx.increment("push_failures").await {
1088 Ok(v) => v,
1089 Err(e) => {
1090 tracing::warn!(
1091 task_id = ctx.task_id,
1092 err = %e,
1093 "failed to increment push_failures — reading current value from store"
1094 );
1095 ctx.get_task()
1097 .await
1098 .map_or(1, |t| u64::try_from(t.push_failures).unwrap_or(0) + 1)
1099 }
1101 }
1102 } else {
1103 0
1104 };
1105
1106 PushFailureState {
1107 push_failed,
1108 is_workflow_scope_failure,
1109 is_rebase_conflict_failure,
1110 push_failures,
1111 stored_last_error,
1112 }
1113}
1114
1115async fn detect_no_code_reroutes(
1118 ctx: &StoreCtx<'_>,
1119 is_no_code_reroute: bool,
1120 new_attempts: u32,
1121 max_reroutes: u32,
1122) -> u64 {
1123 if !is_no_code_reroute {
1124 return 0;
1125 }
1126 tracing::warn!(
1127 task_id = ctx.task_id,
1128 attempts = new_attempts,
1129 max_reroutes,
1130 "agent reported done but produced no code changes on external task requiring PR"
1131 );
1132 match ctx.increment("no_code_reroutes").await {
1133 Ok(v) => v,
1134 Err(e) => {
1135 tracing::warn!(
1136 task_id = ctx.task_id,
1137 err = %e,
1138 "failed to increment no_code_reroutes — reading current value from store"
1139 );
1140 ctx.get_task()
1142 .await
1143 .map_or(1, |t| u64::try_from(t.no_code_reroutes).unwrap_or(0) + 1)
1144 }
1146 }
1147}
1148
1149fn agent_blocked_reason(resp: &AgentResponse) -> String {
1156 let summary_opt = if resp.summary.is_empty() {
1157 None
1158 } else {
1159 Some(resp.summary.as_str())
1160 };
1161 let base = resp
1162 .error
1163 .as_deref()
1164 .filter(|s| !s.is_empty())
1165 .or(summary_opt)
1166 .unwrap_or("agent returned blocked status without a reason");
1167 if resp.remaining.is_empty() {
1168 base.to_string()
1169 } else {
1170 format!("{base}. Remaining: {}", resp.remaining.join("; "))
1171 }
1172}
1173
1174fn is_denied_push_block(resp: &AgentResponse) -> bool {
1176 let r = agent_blocked_reason(resp).to_lowercase();
1177 r.contains("push")
1178 && [
1179 "denied",
1180 "not allowed",
1181 "permission",
1182 "disallowed",
1183 "approval",
1184 ]
1185 .iter()
1186 .any(|p| r.contains(p))
1187}
1188
1189fn is_retryable_blocked_reason(reason: &str) -> bool {
1195 let r = reason.to_lowercase();
1196
1197 let permanent_patterns = [
1198 "not found",
1199 "does not exist",
1200 "invalid",
1201 "malformed",
1202 "syntax error",
1203 "unsupported",
1204 "unimplemented",
1205 "manual intervention required",
1206 "human intervention required",
1207 ];
1208 if permanent_patterns.iter().any(|p| r.contains(p)) {
1209 return false;
1210 }
1211
1212 let transient_patterns = [
1213 "unavailable",
1214 "temporarily unavailable",
1215 "timeout",
1216 "timed out",
1217 "connection reset",
1218 "connection refused",
1219 "network error",
1220 "rate limit",
1221 "try again",
1222 "service down",
1223 "permission denied",
1224 "worktree lock",
1225 "git lock",
1226 "lockfile permission",
1227 "lock permission",
1228 "resource busy",
1229 "could not resolve host",
1230 "gpg decryption fails",
1233 "gpg decryption failed",
1234 "gpg agent",
1235 "no passphrase available",
1236 "passphrase unavailable",
1237 "pass store",
1238 "passwordstoreprovider",
1239 "agentvaultprovider",
1240 "onepasswordprovider",
1241 "decryption failed in this agent session",
1242 ];
1243 transient_patterns.iter().any(|p| r.contains(p))
1244}
1245
1246#[expect(
1248 clippy::too_many_arguments,
1249 reason = "the decision and its context arrive as separate values"
1250)]
1251async fn apply_post_decision_effects(
1252 ctx: &StoreCtx<'_>,
1253 final_status: &str,
1254 resp_status: &str,
1255 push_state: &PushFailureState,
1256 is_no_code_reroute: bool,
1257 has_pr: bool,
1258 has_pushed: bool,
1259 is_pr_base_invalid: bool,
1260 has_delegations: bool,
1261 is_external: bool,
1262 no_code_reroutes: u64,
1263 max_reroutes: u32,
1264 agent_name: &str,
1265) {
1266 if push_state.is_workflow_scope_failure {
1267 tracing::error!(
1269 task_id = ctx.task_id,
1270 "push failed: token lacks `workflow` OAuth scope — blocking immediately \
1271 (rerouting would not help)"
1272 );
1273 ctx.set(&[(
1274 "last_error",
1275 serde_json::json!(format!(
1276 "push failed: GitHub token lacks `workflow` OAuth scope. \
1277 The agent modified .github/workflows/ files but the token cannot push them. \
1278 Fix: add `workflow` scope to your GitHub token, or use a GitHub App for auth. \
1279 Original error: {}",
1280 push_state.stored_last_error
1281 )),
1282 )])
1283 .await;
1284 } else if push_state.is_rebase_conflict_failure {
1285 tracing::error!(
1286 task_id = ctx.task_id,
1287 "push failed and automatic rebase recovery failed — blocking for human intervention"
1288 );
1289 ctx.set(&[
1290 ("agent", serde_json::json!(null)),
1291 ("model", serde_json::json!(null)),
1292 ])
1293 .await;
1294 } else if push_state.push_failed {
1295 ctx.set(&[
1297 ("agent", serde_json::json!(null)),
1298 ("model", serde_json::json!(null)),
1299 ])
1300 .await;
1301 let push_failures = push_state.push_failures;
1302 if push_failures >= 3 {
1303 tracing::error!(
1304 task_id = ctx.task_id,
1305 push_failures,
1306 "push failed {push_failures} times — blocking for human intervention"
1307 );
1308 } else {
1309 tracing::warn!(
1310 task_id = ctx.task_id,
1311 push_failures,
1312 "agent done but push failed ({push_failures}/3) — rerouting to different agent"
1313 );
1314 }
1315 } else if resp_status == "done" && !has_pr && has_delegations {
1316 tracing::info!(
1317 task_id = ctx.task_id,
1318 "agent reported done with delegations but no PR — setting blocked"
1319 );
1320 } else if (resp_status == "done" || resp_status == "completed")
1321 && !has_pr
1322 && has_pushed
1323 && is_pr_base_invalid
1324 {
1325 tracing::error!(
1326 task_id = ctx.task_id,
1327 "PR creation failed with invalid base branch — blocking for human intervention"
1328 );
1329 ctx.set(&[(
1330 "last_error",
1331 serde_json::json!(
1332 "PR creation failed terminally (invalid base branch or GitHub auth error). The agent's commits were pushed successfully. \
1333 Check the repository default branch and the GitHub token, then create the PR manually or unblock the task."
1334 ),
1335 )])
1336 .await;
1337 } else if resp_status == "done" && !has_pr && has_pushed {
1338 tracing::warn!(
1340 task_id = ctx.task_id,
1341 "agent done, commits pushed, but PR creation failed — re-dispatching as routed"
1342 );
1343 } else if is_no_code_reroute {
1344 if final_status == "blocked" {
1349 let prev_no_code_agent = ctx
1353 .get_task()
1354 .await
1355 .map(|t| t.no_code_last_agent)
1356 .unwrap_or_default();
1357 let is_same_agent = !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name;
1358
1359 let msg = if is_same_agent {
1360 tracing::error!(
1361 task_id = ctx.task_id,
1362 agent = %agent_name,
1363 "blocking same-agent no-code loop: agent {} would be selected again",
1364 agent_name
1365 );
1366 format!(
1367 "agent {agent_name} completed without code changes twice — same-agent loop detected, blocking for human review"
1368 )
1369 } else {
1370 tracing::error!(
1371 task_id = ctx.task_id,
1372 no_code_reroutes,
1373 max_reroutes,
1374 "blocking no-code reroute: reached max reroute attempts ({}/{}) for no-code-result",
1375 no_code_reroutes,
1376 max_reroutes
1377 );
1378 format!(
1379 "agent completed without code changes after {no_code_reroutes}/{max_reroutes} reroute attempts on external task requiring PR"
1380 )
1381 };
1382 ctx.set(&[("last_error", serde_json::json!(msg))]).await;
1383 } else {
1384 ctx.set(&[(
1386 "last_error",
1387 serde_json::json!(
1388 "agent completed without code changes on external task requiring PR"
1389 ),
1390 )])
1391 .await;
1392 }
1393
1394 ctx.set(&[
1399 ("no_code_last_agent", serde_json::json!(agent_name)),
1400 ("agent", serde_json::json!(null)),
1401 ("model", serde_json::json!(null)),
1402 ])
1403 .await;
1404 } else if resp_status == "done" && !has_pr && is_external {
1405 tracing::info!(
1408 task_id = ctx.task_id,
1409 "external task with non-code labels reported done — marking done without PR"
1410 );
1411 } else if resp_status == "done" && !has_pr {
1412 tracing::info!(
1413 task_id = ctx.task_id,
1414 "internal task reported done with no PR — marking done"
1415 );
1416 }
1417}
1418
1419async fn store_token_usage(
1422 ctx: &StoreCtx<'_>,
1423 input_tokens: Option<u64>,
1424 output_tokens: Option<u64>,
1425 cost_usd: Option<f64>,
1426) {
1427 let (Some(input), Some(output)) = (input_tokens, output_tokens) else {
1428 return;
1429 };
1430 if let Some(st) = ctx.store {
1431 if let Some(store_id) = ctx.store_id_opt {
1432 if let Err(e) = st.store_tokens(store_id, input, output, cost_usd).await {
1433 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1434 }
1435 } else if let Ok(Some(store_id)) = st.resolve_task_id(ctx.repo, ctx.task_id).await
1436 && let Err(e) = st.store_tokens(store_id, input, output, cost_usd).await
1437 {
1438 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1439 }
1440 }
1441}
1442
1443#[expect(
1449 clippy::too_many_arguments,
1450 reason = "the runner passes each part of the run result"
1451)]
1452pub async fn handle_success(
1453 task_id: &str,
1454 parsed: agents::ParsedResponse,
1455 wt: &worktree::WorktreeSetup,
1456 task_title: &str,
1457 agent_name: &str,
1458 model_name: Option<&str>,
1459 new_attempts: u32,
1460 repo: &str,
1461 store: &Option<Arc<TaskStore>>,
1462 raw_stdout: &str,
1463) -> anyhow::Result<(String, bool)> {
1464 let input_tokens = parsed.input_tokens;
1466 let output_tokens = parsed.output_tokens;
1467 let cost_usd = parsed.cost_usd;
1468 let mut resp = parsed.response;
1469
1470 tracing::info!(
1471 task_id,
1472 status = resp.status,
1473 summary = resp.summary,
1474 "agent completed successfully"
1475 );
1476
1477 let push_denied = resp.status == "blocked" && is_denied_push_block(&resp);
1478
1479 let store_id_opt = store::resolve_store_id(store, repo, task_id).await;
1482 let ctx = StoreCtx {
1483 store,
1484 store_id_opt,
1485 repo,
1486 task_id,
1487 };
1488
1489 ctx.set(&[("network_retries", serde_json::json!(0))]).await;
1490
1491 let git = if resp.status == "done"
1493 || resp.status == "completed"
1494 || resp.status == "in_progress"
1495 || resp.status == "needs_review"
1496 || push_denied
1497 {
1498 run_git_ops(
1499 &ctx,
1500 wt,
1501 task_title,
1502 &resp,
1503 agent_name,
1504 model_name,
1505 new_attempts,
1506 )
1507 .await
1508 } else {
1509 GitOpsResult::default()
1510 };
1511
1512 if push_denied && (git.has_pr || git.has_pushed) {
1515 tracing::info!(
1516 task_id,
1517 "agent blocked on denied push, orch pushed the branch"
1518 );
1519 resp.status = "done".to_string();
1520 }
1521
1522 if !resp.delegations.is_empty() {
1524 ctx.set(&[("delegations", serde_json::json!(resp.delegations))])
1525 .await;
1526 }
1527
1528 let has_delegations = !resp.delegations.is_empty();
1535
1536 let push_state = detect_push_failure_state(&ctx, git.has_commits, git.has_pushed).await;
1538
1539 let is_external = !task_id.starts_with("internal:");
1547 let requires_pr = is_external && !git.has_pr && !resp.status.starts_with("needs_review");
1548
1549 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
1552 .ok()
1553 .and_then(|s| s.parse().ok())
1554 .or_else(|| {
1555 config::get("workflow.max_attempts")
1556 .ok()
1557 .and_then(|s| s.parse().ok())
1558 })
1559 .unwrap_or(3);
1560
1561 let is_no_code_reroute = !push_state.is_workflow_scope_failure
1564 && !push_state.push_failed
1565 && (resp.status == "done" || resp.status == "completed")
1566 && !has_delegations
1567 && !git.has_pushed
1568 && requires_pr;
1569
1570 let superseded_pr = match (is_no_code_reroute, ctx.get_task().await) {
1573 (true, Some(task)) => {
1574 superseded_conflict_pr(task.merge_conflict_retries, task.pr_number, git.has_commits)
1575 }
1576 _ => None,
1577 };
1578 let is_no_code_reroute = is_no_code_reroute && superseded_pr.is_none();
1579
1580 let no_code_reroutes =
1581 detect_no_code_reroutes(&ctx, is_no_code_reroute, new_attempts, max_reroutes).await;
1582
1583 let blocked_reason = if resp.status == "blocked" {
1584 Some(agent_blocked_reason(&resp))
1585 } else {
1586 None
1587 };
1588 let is_retryable_blocked = blocked_reason
1589 .as_deref()
1590 .is_some_and(is_retryable_blocked_reason);
1591
1592 let is_same_agent = if is_no_code_reroute {
1597 let prev_no_code_agent = ctx
1598 .get_task()
1599 .await
1600 .map(|t| t.no_code_last_agent)
1601 .unwrap_or_default();
1602 !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name
1603 } else {
1604 false
1605 };
1606
1607 let final_status_owned = classify_final_status(&DecisionInput {
1610 agent_status: &resp.status,
1611 is_workflow_scope_failure: push_state.is_workflow_scope_failure,
1612 is_rebase_conflict_failure: push_state.is_rebase_conflict_failure,
1613 push_failed: push_state.push_failed,
1614 push_failures: push_state.push_failures,
1615 has_pr: git.has_pr,
1616 has_delegations,
1617 has_pushed: git.has_pushed,
1618 is_pr_base_invalid: git.pr_base_invalid,
1619 requires_pr,
1620 no_code_reroutes,
1621 max_reroutes,
1622 is_same_agent,
1623 is_completed_status: resp.status == "completed",
1624 is_retryable_blocked,
1625 is_superseded_conflict: superseded_pr.is_some(),
1626 });
1627 let final_status = final_status_owned.as_str();
1628
1629 apply_post_decision_effects(
1632 &ctx,
1633 final_status,
1634 &resp.status,
1635 &push_state,
1636 is_no_code_reroute,
1637 git.has_pr,
1638 git.has_pushed,
1639 git.pr_base_invalid,
1640 has_delegations,
1641 is_external,
1642 no_code_reroutes,
1643 max_reroutes,
1644 agent_name,
1645 )
1646 .await;
1647
1648 if resp.status == "blocked" {
1654 let reason = blocked_reason.unwrap_or_else(|| agent_blocked_reason(&resp));
1655 if final_status == "new" {
1656 cooldown::record_agent_failure_with_message(agent_name, &reason).await;
1659 if let Some(m) = model_name {
1660 cooldown::record_model_failure(agent_name, m).await;
1661 }
1662 ctx.set(&[
1663 (
1664 "last_error",
1665 serde_json::json!(format!(
1666 "transient blocker (auto-retry scheduled): {reason}"
1667 )),
1668 ),
1669 ("agent", serde_json::json!(null)),
1670 ("model", serde_json::json!(null)),
1671 ])
1672 .await;
1673 tracing::warn!(
1674 task_id = ctx.task_id,
1675 reason = %reason,
1676 "agent returned retryable blocked status — rerouting as new with cooldown"
1677 );
1678 } else if final_status == "blocked" {
1679 ctx.set(&[("last_error", serde_json::json!(reason))]).await;
1680 }
1681 }
1682
1683 if let Some(pr_number) = superseded_pr {
1684 tracing::info!(
1685 task_id = ctx.task_id,
1686 pr_number,
1687 "conflict reroute found the fix already on the default branch — closing superseded PR"
1688 );
1689 close_superseded_pr(&ctx, pr_number).await;
1690 }
1691
1692 ctx.set(&[("summary", serde_json::json!(resp.summary))])
1693 .await;
1694
1695 store_token_usage(
1697 &ctx,
1698 input_tokens.or(resp.input_tokens),
1699 output_tokens.or(resp.output_tokens),
1700 cost_usd,
1701 )
1702 .await;
1703
1704 response::store_learnings_from_response(
1706 task_id,
1707 new_attempts,
1708 agent_name,
1709 model_name,
1710 &resp,
1711 resp.error.as_deref(),
1712 store,
1713 repo,
1714 raw_stdout,
1715 )
1716 .await;
1717
1718 Ok((final_status.to_string(), push_state.push_failed))
1721}
1722
1723#[cfg(test)]
1725mod tests {
1726 use std::fs as std_fs;
1727 use std::process::Command;
1728
1729 use super::*;
1730 use crate::store::NewTask;
1731
1732 #[test]
1733 fn local_rate_limit_skip_is_detected() {
1734 assert!(is_local_rate_limit_skip(
1735 "GitHub API error: GitHub REST API rate-limited, skipping call for 236s"
1736 ));
1737 assert!(!is_local_rate_limit_skip(
1738 "GitHub API POST failed (502): Bad Gateway"
1739 ));
1740 }
1741
1742 fn git(dir: &Path, args: &[&str]) {
1744 let out = Command::new("git")
1745 .current_dir(dir)
1746 .args(args)
1747 .output()
1748 .unwrap_or_else(|e| panic!("git {args:?} failed to start: {e}"));
1749 assert!(
1750 out.status.success(),
1751 "git {args:?} failed:\nstdout: {}\nstderr: {}",
1752 String::from_utf8_lossy(&out.stdout),
1753 String::from_utf8_lossy(&out.stderr)
1754 );
1755 }
1756
1757 fn init_clone(remote: &Path, dir: &Path) {
1758 git(dir, &["clone", remote.to_str().unwrap(), "."]);
1759 git(dir, &["config", "user.email", "t@t.com"]);
1760 git(dir, &["config", "user.name", "T"]);
1761 git(dir, &["config", "commit.gpgsign", "false"]);
1762 }
1763
1764 #[tokio::test]
1771 async fn push_rejected_after_remote_rebase_is_pushed_not_rerouted() {
1772 let remote = tempfile::tempdir().unwrap();
1773 git(remote.path(), &["init", "--bare", "-b", "main"]);
1774
1775 let work = tempfile::tempdir().unwrap();
1777 init_clone(remote.path(), work.path());
1778 std_fs::write(work.path().join("README.md"), "base").unwrap();
1779 git(work.path(), &["add", "."]);
1780 git(work.path(), &["commit", "-m", "init"]);
1781 git(work.path(), &["push", "origin", "HEAD:main"]);
1782 git(work.path(), &["checkout", "-b", "feat"]);
1783 std_fs::write(work.path().join("fix.txt"), "the fix").unwrap();
1784 git(work.path(), &["add", "."]);
1785 git(work.path(), &["commit", "-m", "apply the fix"]);
1786 git(work.path(), &["push", "-u", "origin", "feat"]);
1787
1788 let other = tempfile::tempdir().unwrap();
1790 init_clone(remote.path(), other.path());
1791 git(other.path(), &["checkout", "feat"]);
1792 git(
1793 other.path(),
1794 &["commit", "--amend", "-m", "apply the fix (rebased)"],
1795 );
1796 git(other.path(), &["push", "--force", "origin", "feat"]);
1797
1798 std_fs::write(work.path().join("more.txt"), "follow-up").unwrap();
1800 git(work.path(), &["add", "."]);
1801 git(work.path(), &["commit", "-m", "follow-up"]);
1802
1803 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1804 let store_id = store
1805 .create_internal("owner/repo", "Push recovery", "", "task", "3783", None)
1806 .await
1807 .unwrap();
1808 let task_id = format!("internal:{store_id}");
1809 let store_opt = Some(Arc::clone(&store));
1810 let ctx = StoreCtx {
1811 store: &store_opt,
1812 store_id_opt: Some(store_id),
1813 repo: "owner/repo",
1814 task_id: &task_id,
1815 };
1816
1817 let result =
1818 push_branch_with_log(&ctx, work.path(), "feat", "main", "claude", Some("opus")).await;
1819
1820 assert!(
1821 matches!(result, PushResult::Success),
1822 "orch must land the push itself after adopting the moved remote branch"
1823 );
1824
1825 let task = store.get(store_id).await.unwrap();
1826 assert!(
1827 task.last_error.is_empty(),
1828 "a recovered push must leave no error behind, got: {}",
1829 task.last_error
1830 );
1831 assert_eq!(task.push_failures, 0, "recovery is not a push failure");
1832
1833 let log = Command::new("git")
1836 .current_dir(work.path())
1837 .args(["log", "--oneline", "origin/feat"])
1838 .output()
1839 .unwrap();
1840 let log = String::from_utf8_lossy(&log.stdout);
1841 assert!(
1842 log.contains("follow-up"),
1843 "remote must carry the task's commit: {log}"
1844 );
1845 assert!(
1846 log.contains("apply the fix (rebased)"),
1847 "orch must adopt the rebased remote history, not re-push its own: {log}"
1848 );
1849 assert!(
1850 !log.contains("apply the fix\n"),
1851 "the superseded commit must not come back: {log}"
1852 );
1853 }
1854
1855 #[test]
1856 fn missing_path_error_detects_io_not_found() {
1857 let err = anyhow::Error::from(io::Error::new(
1858 ErrorKind::NotFound,
1859 "simulated missing path",
1860 ));
1861 assert!(is_missing_path_error(&err));
1862 }
1863
1864 #[test]
1865 fn missing_path_error_detects_message_fallback() {
1866 let err = anyhow::anyhow!("push failed: No such file or directory (os error 2)");
1867 assert!(is_missing_path_error(&err));
1868 }
1869
1870 #[test]
1871 fn superseded_conflict_pr_requires_conflict_reroute_and_no_commits() {
1872 assert_eq!(superseded_conflict_pr(1, Some(42), false), Some(42));
1874 assert_eq!(superseded_conflict_pr(1, Some(42), true), None);
1876 assert_eq!(superseded_conflict_pr(0, Some(42), false), None);
1878 assert_eq!(superseded_conflict_pr(1, None, false), None);
1880 }
1881
1882 #[tokio::test]
1886 async fn conflict_reroute_with_no_commits_closes_pr_and_marks_done() {
1887 fn git(dir: &Path, args: &[&str]) {
1888 let ok = Command::new("git")
1889 .args(args)
1890 .current_dir(dir)
1891 .env("GIT_AUTHOR_NAME", "t")
1892 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1893 .env("GIT_COMMITTER_NAME", "t")
1894 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1895 .output()
1896 .expect("git runs")
1897 .status
1898 .success();
1899 assert!(ok, "git {args:?} failed");
1900 }
1901
1902 let dir = tempfile::tempdir().unwrap();
1905 let origin = dir.path().join("origin.git");
1906 std_fs::create_dir(&origin).unwrap();
1907 git(&origin, &["init", "--bare", "-b", "main"]);
1908 let work_dir = dir.path().join("work");
1909 std_fs::create_dir(&work_dir).unwrap();
1910 git(&work_dir, &["init", "-b", "main"]);
1911 git(
1912 &work_dir,
1913 &["remote", "add", "origin", &origin.display().to_string()],
1914 );
1915 std_fs::write(work_dir.join("a.txt"), "a").unwrap();
1916 git(&work_dir, &["add", "-A"]);
1917 git(&work_dir, &["commit", "-m", "init"]);
1918 git(&work_dir, &["push", "-u", "origin", "main"]);
1919 git(&work_dir, &["checkout", "-b", "fix-branch"]);
1920
1921 let wt = worktree::WorktreeSetup {
1922 work_dir: work_dir.clone(),
1923 branch: "fix-branch".to_string(),
1924 default_branch: "main".to_string(),
1925 main_project_dir: work_dir.clone(),
1926 };
1927
1928 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1929 let store_id = store
1930 .create(&NewTask {
1931 external_id: Some("3772".to_string()),
1932 repo: "owner/repo".to_string(),
1933 origin: "github".to_string(),
1934 title: "Fix the thing".to_string(),
1935 body: String::new(),
1936 source: "manual".to_string(),
1937 source_id: String::new(),
1938 author: "bot".to_string(),
1939 url: String::new(),
1940 labels: vec![],
1941 parent_id: None,
1942 })
1943 .await
1944 .unwrap();
1945 store
1946 .set_fields(store_id, &[("pr_number", serde_json::json!(42))])
1947 .await
1948 .unwrap();
1949 store
1951 .increment(store_id, "merge_conflict_retries")
1952 .await
1953 .unwrap();
1954 store
1957 .set_fields(
1958 store_id,
1959 &[("no_code_last_agent", serde_json::json!("claude"))],
1960 )
1961 .await
1962 .unwrap();
1963
1964 let parsed = agents::ParsedResponse {
1965 response: AgentResponse {
1966 status: "done".to_string(),
1967 summary: "the fix is already on main".to_string(),
1968 ..Default::default()
1969 },
1970 input_tokens: None,
1971 output_tokens: None,
1972 cost_usd: None,
1973 duration_ms: None,
1974 };
1975
1976 let (final_status, push_failed) = handle_success(
1977 "3772",
1978 parsed,
1979 &wt,
1980 "Fix the thing",
1981 "claude",
1982 Some("sonnet"),
1983 1,
1984 "owner/repo",
1985 &Some(Arc::clone(&store)),
1986 "",
1987 )
1988 .await
1989 .unwrap();
1990
1991 assert_eq!(
1992 final_status, "done",
1993 "a superseded conflict reroute must end done, not blocked"
1994 );
1995 assert!(!push_failed);
1996
1997 let task = store.get(store_id).await.unwrap();
1998 assert!(
1999 task.last_error.contains("superseded"),
2000 "the close attempt must be recorded on the task: {}",
2001 task.last_error
2002 );
2003 assert_eq!(
2004 task.pr_number, None,
2005 "a superseded PR must not stay on the task, the blocker refresh would read it as closed unmerged"
2006 );
2007 }
2008
2009 #[test]
2012 fn classify_superseded_conflict_marks_done() {
2013 let status = classify_final_status(&DecisionInput {
2014 agent_status: "done",
2015 requires_pr: true,
2016 is_same_agent: true,
2017 is_superseded_conflict: true,
2018 ..Default::default()
2019 });
2020 assert_eq!(status, "done");
2021 }
2022
2023 #[test]
2027 fn classify_workflow_scope_failure_blocks() {
2028 let status = classify_final_status(&DecisionInput {
2029 is_workflow_scope_failure: true,
2030 push_failed: true,
2031 push_failures: 1,
2032 ..Default::default()
2033 });
2034 assert_eq!(status, "blocked");
2035 }
2036
2037 #[test]
2039 fn classify_rebase_conflict_failure_blocks() {
2040 let status = classify_final_status(&DecisionInput {
2041 is_rebase_conflict_failure: true,
2042 push_failed: true,
2043 ..Default::default()
2044 });
2045 assert_eq!(status, "blocked");
2046 }
2047
2048 #[test]
2050 fn classify_push_failed_under_threshold_reroutes() {
2051 let status = classify_final_status(&DecisionInput {
2052 push_failed: true,
2053 push_failures: 2, ..Default::default()
2055 });
2056 assert_eq!(status, "new");
2057 }
2058
2059 #[test]
2060 fn denied_push_block_detected() {
2061 let resp = |s: &str| AgentResponse {
2062 status: "blocked".to_string(),
2063 summary: s.to_string(),
2064 ..Default::default()
2065 };
2066 assert!(is_denied_push_block(&resp(
2067 "Work committed, but `git push -u origin HEAD` was denied"
2068 )));
2069 assert!(is_denied_push_block(&resp(
2070 "It is not pushed: `git push --force-with-lease` needs approval that this session could not get."
2071 )));
2072 assert!(is_denied_push_block(&resp(
2073 "`git push --force-with-lease` was denied because this session cannot get approval"
2074 )));
2075 assert!(is_denied_push_block(&resp("The push requires approval")));
2076 assert!(!is_denied_push_block(&resp("Missing API key")));
2077 }
2078
2079 #[test]
2081 fn classify_push_failed_at_threshold_blocks() {
2082 let status = classify_final_status(&DecisionInput {
2083 push_failed: true,
2084 push_failures: 3, ..Default::default()
2086 });
2087 assert_eq!(status, "blocked");
2088 }
2089
2090 #[test]
2092 fn classify_done_with_pr_goes_to_needs_review() {
2093 let status = classify_final_status(&DecisionInput {
2094 agent_status: "done",
2095 has_pr: true,
2096 ..Default::default()
2097 });
2098 assert_eq!(status, "needs_review");
2099 }
2100
2101 #[test]
2103 fn classify_done_with_delegations_no_pr_blocks() {
2104 let status = classify_final_status(&DecisionInput {
2105 agent_status: "done",
2106 has_delegations: true,
2107 ..Default::default()
2108 });
2109 assert_eq!(status, "blocked");
2110 }
2111
2112 #[test]
2114 fn classify_done_pushed_no_pr_reroutes_for_pr_creation() {
2115 let status = classify_final_status(&DecisionInput {
2116 agent_status: "done",
2117 has_pushed: true,
2118 ..Default::default()
2119 });
2120 assert_eq!(status, "routed");
2121 }
2122
2123 #[test]
2125 fn classify_done_pushed_pr_base_invalid_blocks() {
2126 let status = classify_final_status(&DecisionInput {
2127 agent_status: "done",
2128 has_pushed: true,
2129 is_pr_base_invalid: true,
2130 ..Default::default()
2131 });
2132 assert_eq!(status, "blocked");
2133 }
2134
2135 #[test]
2137 fn classify_completed_pushed_pr_base_invalid_blocks() {
2138 let status = classify_final_status(&DecisionInput {
2139 agent_status: "completed",
2140 is_completed_status: true,
2141 has_pushed: true,
2142 is_pr_base_invalid: true,
2143 ..Default::default()
2144 });
2145 assert_eq!(status, "blocked");
2146 }
2147
2148 #[test]
2150 fn classify_done_requires_pr_under_max_reroutes() {
2151 let status = classify_final_status(&DecisionInput {
2152 agent_status: "done",
2153 requires_pr: true,
2154 no_code_reroutes: 1,
2155 max_reroutes: 3,
2156 is_same_agent: false,
2157 ..Default::default()
2158 });
2159 assert_eq!(status, "new");
2160 }
2161
2162 #[test]
2164 fn classify_done_requires_pr_same_agent_blocks_immediately() {
2165 let status = classify_final_status(&DecisionInput {
2166 agent_status: "done",
2167 requires_pr: true,
2168 no_code_reroutes: 1,
2169 max_reroutes: 3,
2170 is_same_agent: true, ..Default::default()
2172 });
2173 assert_eq!(status, "blocked");
2174 }
2175
2176 #[test]
2178 fn classify_done_requires_pr_at_max_reroutes_blocks() {
2179 let status = classify_final_status(&DecisionInput {
2180 agent_status: "done",
2181 requires_pr: true,
2182 no_code_reroutes: 3,
2183 max_reroutes: 3,
2184 is_same_agent: false,
2185 ..Default::default()
2186 });
2187 assert_eq!(status, "blocked");
2188 }
2189
2190 #[test]
2192 fn classify_done_requires_pr_exceeds_max_reroutes_blocks() {
2193 let status = classify_final_status(&DecisionInput {
2194 agent_status: "done",
2195 requires_pr: true,
2196 no_code_reroutes: 5,
2197 max_reroutes: 3,
2198 is_same_agent: false,
2199 ..Default::default()
2200 });
2201 assert_eq!(status, "blocked");
2202 }
2203
2204 #[test]
2206 fn classify_done_external_no_pushed_same_agent_blocks_immediately() {
2207 let status = classify_final_status(&DecisionInput {
2208 agent_status: "done",
2209 requires_pr: true, no_code_reroutes: 0,
2211 max_reroutes: 3,
2212 is_same_agent: true,
2213 ..Default::default()
2214 });
2215 assert_eq!(status, "blocked");
2216 }
2217
2218 #[test]
2226 fn classify_done_external_no_pushed_commits_reroutes() {
2227 let status = classify_final_status(&DecisionInput {
2228 agent_status: "done",
2229 has_pushed: false,
2230 requires_pr: true, no_code_reroutes: 0,
2232 max_reroutes: 3,
2233 is_same_agent: false,
2234 ..Default::default()
2235 });
2236 assert_eq!(status, "new");
2237 }
2238
2239 #[test]
2242 fn classify_done_internal_task_no_pr_is_done() {
2243 let status = classify_final_status(&DecisionInput {
2244 agent_status: "done",
2245 has_pushed: false,
2246 requires_pr: false,
2247 ..Default::default()
2248 });
2249 assert_eq!(status, "done");
2250 }
2251
2252 #[test]
2254 fn classify_non_done_status_passes_through() {
2255 for status_str in &["in_progress", "needs_review", "blocked"] {
2256 let result = classify_final_status(&DecisionInput {
2257 agent_status: status_str,
2258 ..Default::default()
2259 });
2260 assert_eq!(
2261 result, *status_str,
2262 "status '{status_str}' should pass through unchanged"
2263 );
2264 }
2265 }
2266
2267 #[test]
2269 fn classify_descriptive_completion_status_normalizes_to_done() {
2270 let status = classify_final_status(&DecisionInput {
2271 agent_status: "Trading scan complete. Nothing to trade.",
2272 ..Default::default()
2273 });
2274 assert_eq!(status, "done");
2275 }
2276
2277 #[test]
2279 fn classify_descriptive_completion_with_failure_cues_does_not_normalize() {
2280 let status = classify_final_status(&DecisionInput {
2281 agent_status: "completed but failed to push branch due to error",
2282 ..Default::default()
2283 });
2284 assert_eq!(status, "completed but failed to push branch due to error");
2285 }
2286
2287 #[test]
2289 fn classify_push_failed_takes_precedence_over_has_pr() {
2290 let status = classify_final_status(&DecisionInput {
2291 agent_status: "done",
2292 has_pr: true,
2293 push_failed: true,
2294 push_failures: 1,
2295 ..Default::default()
2296 });
2297 assert_eq!(status, "new");
2299 }
2300
2301 #[test]
2303 fn classify_workflow_scope_failure_takes_precedence_over_push_reroute() {
2304 let status = classify_final_status(&DecisionInput {
2305 is_workflow_scope_failure: true,
2306 push_failed: true,
2307 push_failures: 1, ..Default::default()
2309 });
2310 assert_eq!(status, "blocked");
2311 }
2312
2313 #[test]
2317 fn classify_completed_with_pr_goes_to_needs_review() {
2318 let status = classify_final_status(&DecisionInput {
2319 agent_status: "completed",
2320 has_pr: true,
2321 is_completed_status: true,
2322 ..Default::default()
2323 });
2324 assert_eq!(status, "needs_review");
2325 }
2326
2327 #[test]
2329 fn classify_completed_with_delegations_no_pr_blocks() {
2330 let status = classify_final_status(&DecisionInput {
2331 agent_status: "completed",
2332 has_delegations: true,
2333 is_completed_status: true,
2334 ..Default::default()
2335 });
2336 assert_eq!(status, "blocked");
2337 }
2338
2339 #[test]
2341 fn classify_completed_pushed_no_pr_reroutes_for_pr_creation() {
2342 let status = classify_final_status(&DecisionInput {
2343 agent_status: "completed",
2344 has_pushed: true,
2345 is_completed_status: true,
2346 ..Default::default()
2347 });
2348 assert_eq!(status, "routed");
2349 }
2350
2351 #[test]
2353 fn classify_completed_requires_pr_under_max_reroutes() {
2354 let status = classify_final_status(&DecisionInput {
2355 agent_status: "completed",
2356 requires_pr: true,
2357 no_code_reroutes: 1,
2358 max_reroutes: 3,
2359 is_completed_status: true,
2360 is_same_agent: false,
2361 ..Default::default()
2362 });
2363 assert_eq!(status, "new");
2364 }
2365
2366 #[test]
2368 fn classify_completed_requires_pr_at_max_reroutes_blocks() {
2369 let status = classify_final_status(&DecisionInput {
2370 agent_status: "completed",
2371 requires_pr: true,
2372 no_code_reroutes: 3,
2373 max_reroutes: 3,
2374 is_completed_status: true,
2375 is_same_agent: false,
2376 ..Default::default()
2377 });
2378 assert_eq!(status, "blocked");
2379 }
2380
2381 #[test]
2383 fn classify_completed_same_agent_blocks_immediately() {
2384 let status = classify_final_status(&DecisionInput {
2385 agent_status: "completed",
2386 requires_pr: true,
2387 no_code_reroutes: 1,
2388 max_reroutes: 3,
2389 is_completed_status: true,
2390 is_same_agent: true,
2391 ..Default::default()
2392 });
2393 assert_eq!(status, "blocked");
2394 }
2395
2396 #[test]
2398 fn classify_completed_internal_task_is_done() {
2399 let status = classify_final_status(&DecisionInput {
2400 agent_status: "completed",
2401 requires_pr: false,
2402 is_completed_status: true,
2403 ..Default::default()
2404 });
2405 assert_eq!(status, "done");
2406 }
2407
2408 #[test]
2412 fn agent_blocked_reason_prefers_error_field() {
2413 let resp = AgentResponse {
2414 status: "blocked".to_string(),
2415 error: Some("CDP endpoint not reachable".to_string()),
2416 summary: "Could not complete research".to_string(),
2417 remaining: vec!["Run x-twitter-brave with reachable endpoint".to_string()],
2418 ..Default::default()
2419 };
2420 let reason = agent_blocked_reason(&resp);
2421 assert!(
2422 reason.contains("CDP endpoint not reachable"),
2423 "reason: {reason}"
2424 );
2425 assert!(reason.contains("Remaining:"), "reason: {reason}");
2426 assert!(reason.contains("x-twitter-brave"), "reason: {reason}");
2427 }
2428
2429 #[test]
2431 fn agent_blocked_reason_falls_back_to_summary() {
2432 let resp = AgentResponse {
2433 status: "blocked".to_string(),
2434 error: None,
2435 summary: "Could not access required data sources in sandboxed environment".to_string(),
2436 remaining: vec![],
2437 ..Default::default()
2438 };
2439 let reason = agent_blocked_reason(&resp);
2440 assert_eq!(
2441 reason,
2442 "Could not access required data sources in sandboxed environment"
2443 );
2444 }
2445
2446 #[test]
2448 fn agent_blocked_reason_generic_fallback() {
2449 let resp = AgentResponse {
2450 status: "blocked".to_string(),
2451 ..Default::default()
2452 };
2453 let reason = agent_blocked_reason(&resp);
2454 assert_eq!(reason, "agent returned blocked status without a reason");
2455 }
2456
2457 #[test]
2459 fn classify_agent_blocked_passes_through() {
2460 let status = classify_final_status(&DecisionInput {
2461 agent_status: "blocked",
2462 ..Default::default()
2463 });
2464 assert_eq!(status, "blocked");
2465 }
2466
2467 #[test]
2468 fn classify_retryable_agent_blocked_reroutes() {
2469 let status = classify_final_status(&DecisionInput {
2470 agent_status: "blocked",
2471 is_retryable_blocked: true,
2472 ..Default::default()
2473 });
2474 assert_eq!(status, "new");
2475 }
2476
2477 #[test]
2478 fn retryable_blocked_reason_detects_transient_patterns() {
2479 assert!(is_retryable_blocked_reason(
2480 "Twitter access unavailable: service timed out"
2481 ));
2482 assert!(is_retryable_blocked_reason(
2483 "final commit blocked by git worktree lock permission error"
2484 ));
2485 assert!(is_retryable_blocked_reason(
2486 "could not commit due a worktree git lock permission error"
2487 ));
2488 assert!(is_retryable_blocked_reason(
2489 "git lockfile permission restrictions in this environment"
2490 ));
2491 assert!(is_retryable_blocked_reason(
2492 "API rate limit hit, try again later"
2493 ));
2494 assert!(is_retryable_blocked_reason(
2495 "Credential bean/hyperliquid-address exists in the pass store but GPG decryption fails in this agent session — no passphrase available."
2496 ));
2497 assert!(is_retryable_blocked_reason(
2498 "credential resolution failed via PasswordStoreProvider; AgentVaultProvider unavailable"
2499 ));
2500 }
2501
2502 #[test]
2503 fn retryable_blocked_reason_rejects_permanent_patterns() {
2504 assert!(!is_retryable_blocked_reason(
2505 "invalid request payload: malformed JSON"
2506 ));
2507 assert!(!is_retryable_blocked_reason(
2508 "resource not found in repository"
2509 ));
2510 }
2511}