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