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#[allow(clippy::too_many_arguments)]
18pub async fn write_result_json(
19 attempt_dir: &Path,
20 task_id: &str,
21 agent_name: &str,
22 model_name: Option<&str>,
23 exit_code: i32,
24 new_attempts: u32,
25 parse_result: &Result<agents::ParsedResponse, agents::AgentError>,
26 raw_stdout: &str,
27 raw_stderr: &str,
28) {
29 let result_json = match parse_result {
30 Ok(parsed) => {
31 serde_json::json!({
32 "outcome": "success",
33 "agent": agent_name,
34 "model": model_name.unwrap_or("default"),
35 "exit_code": exit_code,
36 "attempt": new_attempts,
37 "status": parsed.response.status,
38 "summary": parsed.response.summary,
39 "input_tokens": parsed.input_tokens,
40 "output_tokens": parsed.output_tokens,
41 "duration_ms": parsed.duration_ms,
42 "files": parsed.response.files,
43 "accomplished": parsed.response.accomplished,
44 "remaining": parsed.response.remaining,
45 "error": parsed.response.error,
46 "learnings": parsed.response.learnings,
47 "delegations": parsed.response.delegations.iter()
48 .map(|d| serde_json::json!({"title": d.title, "body": d.body}))
49 .collect::<Vec<_>>(),
50 })
51 }
52 Err(agent_err) => {
53 serde_json::json!({
54 "outcome": "error",
55 "agent": agent_name,
56 "model": model_name.unwrap_or("default"),
57 "exit_code": exit_code,
58 "attempt": new_attempts,
59 "error_class": agents::error_class_name(agent_err),
60 "error_message": agent_err.to_string(),
61 "stderr_tail": agents::patterns::safe_tail(raw_stderr, 2000),
62 "stdout_tail": agents::patterns::safe_tail(raw_stdout, 2000),
63 })
64 }
65 };
66
67 if let Err(e) = tokio::fs::write(
68 attempt_dir.join("result.json"),
69 serde_json::to_string_pretty(&result_json).unwrap_or_default(),
70 )
71 .await
72 {
73 tracing::debug!(task_id, ?e, "failed to write result.json");
74 }
75}
76
77#[derive(Default)]
83struct DecisionInput<'a> {
84 agent_status: &'a str,
86 is_workflow_scope_failure: bool,
89 is_rebase_conflict_failure: bool,
92 push_failed: bool,
94 push_failures: u64,
97 has_pr: bool,
99 has_delegations: bool,
101 has_pushed: bool,
103 is_pr_base_invalid: bool,
107 requires_pr: bool,
110 no_code_reroutes: u64,
112 max_reroutes: u32,
114 is_same_agent: bool,
117 is_completed_status: bool,
122 is_retryable_blocked: bool,
124 is_superseded_conflict: bool,
128}
129
130fn classify_final_status(input: &DecisionInput<'_>) -> String {
137 if input.is_workflow_scope_failure || input.is_rebase_conflict_failure {
138 "blocked".to_string()
139 } else if input.push_failed {
140 if input.push_failures >= 3 {
141 "blocked".to_string()
142 } else {
143 "new".to_string()
144 }
145 } else if input.agent_status == "done" && input.has_pr {
146 "needs_review".to_string()
147 } else if input.agent_status == "done" && !input.has_pr && input.has_delegations {
148 "blocked".to_string()
149 } else if input.agent_status == "done"
150 && !input.has_pr
151 && input.has_pushed
152 && input.is_pr_base_invalid
153 {
154 "blocked".to_string()
158 } else if input.agent_status == "done" && !input.has_pr && input.has_pushed {
159 "routed".to_string()
160 } else if input.is_superseded_conflict {
161 "done".to_string()
162 } else if input.agent_status == "done" && input.requires_pr {
163 if input.is_same_agent || input.no_code_reroutes >= input.max_reroutes as u64 {
167 "blocked".to_string()
168 } else {
169 "new".to_string()
170 }
171 } else if input.agent_status == "done" && !input.has_pushed {
172 "done".to_string()
177 } else if input.is_completed_status && input.has_pr {
178 "needs_review".to_string()
180 } else if input.is_completed_status && !input.has_pr && input.has_delegations {
181 "blocked".to_string()
183 } else if input.is_completed_status
184 && !input.has_pr
185 && input.has_pushed
186 && input.is_pr_base_invalid
187 {
188 "blocked".to_string()
189 } else if input.is_completed_status && !input.has_pr && input.has_pushed {
190 "routed".to_string()
192 } else if input.is_completed_status && input.requires_pr {
193 if input.is_same_agent || input.no_code_reroutes >= input.max_reroutes as u64 {
196 "blocked".to_string()
197 } else {
198 "new".to_string()
199 }
200 } else if input.is_completed_status {
201 "done".to_string()
205 } else if input.agent_status == "blocked" && input.is_retryable_blocked {
206 "new".to_string()
207 } else if crate::status_heuristics::status_looks_like_descriptive_completion(input.agent_status)
208 {
209 "done".to_string()
210 } else {
211 input.agent_status.to_string()
212 }
213}
214
215struct StoreCtx<'a> {
219 store: &'a Option<Arc<TaskStore>>,
220 store_id_opt: Option<i64>,
221 repo: &'a str,
222 task_id: &'a str,
223}
224
225impl<'a> StoreCtx<'a> {
226 async fn set(&self, fields: &[(&str, serde_json::Value)]) {
228 if let Some(store_id) = self.store_id_opt {
229 let _ = store::store_set_by_id(&self.store.as_ref(), store_id, fields).await;
230 } else {
231 store::store_set(self.store, self.repo, self.task_id, fields).await;
232 }
233 }
234
235 async fn get_task(&self) -> Option<crate::store::Task> {
237 if let Some(store_id) = self.store_id_opt {
238 store::opt_store_get_task_by_id(self.store, store_id).await
239 } else {
240 store::opt_store_get_task(self.store, self.repo, self.task_id).await
241 }
242 }
243
244 async fn increment(&self, field: &str) -> anyhow::Result<u64> {
246 if let Some(store_id) = self.store_id_opt {
247 store::store_increment_by_id(self.store, store_id, field).await
248 } else {
249 store::store_increment(self.store, self.repo, self.task_id, field).await
250 }
251 }
252
253 async fn append_activity(
255 &self,
256 event_type: &str,
257 agent: Option<&str>,
258 model: Option<&str>,
259 details: Option<&serde_json::Value>,
260 ) {
261 if let Some(store_id) = self.store_id_opt {
262 if let Some(ref s) = self.store {
263 if let Err(e) = s
264 .append_activity(store_id, event_type, None, None, agent, model, details)
265 .await
266 {
267 tracing::warn!(
268 task_id = self.task_id,
269 event_type,
270 error = %e,
271 "store append_activity failed"
272 );
273 }
274 }
275 } else {
276 store::store_log_activity(
277 self.store,
278 self.repo,
279 self.task_id,
280 event_type,
281 None,
282 None,
283 agent,
284 model,
285 details,
286 )
287 .await;
288 }
289 }
290}
291
292#[derive(Default)]
296struct GitOpsResult {
297 has_pr: bool,
298 has_pushed: bool,
299 has_commits: bool,
300 pr_base_invalid: bool,
304}
305
306struct PushFailureState {
308 push_failed: bool,
309 is_workflow_scope_failure: bool,
310 is_rebase_conflict_failure: bool,
311 push_failures: u64,
313 stored_last_error: String,
315}
316
317async fn auto_commit_changes(
321 ctx: &StoreCtx<'_>,
322 work_dir: &Path,
323 task_title: &str,
324 agent_name: &str,
325 new_attempts: u32,
326) {
327 if let Err(e) =
328 git_ops::auto_commit(work_dir, ctx.task_id, task_title, agent_name, new_attempts).await
329 {
330 tracing::error!(task_id = ctx.task_id, error = ?e, "auto commit failed");
331 ctx.set(&[(
332 "last_error",
333 serde_json::json!(format!("auto commit failed: {e}")),
334 )])
335 .await;
336 }
337}
338
339async fn check_commits_and_clear_stale_errors(
344 ctx: &StoreCtx<'_>,
345 work_dir: &Path,
346 default_branch: &str,
347) -> bool {
348 let has_commits = git_ops::has_commits_ahead(work_dir, default_branch).await;
349 if !has_commits {
350 tracing::info!(
351 task_id = ctx.task_id,
352 "no commits ahead of default branch, skipping push + PR"
353 );
354 let last_err = ctx
356 .get_task()
357 .await
358 .map(|t| t.last_error)
359 .unwrap_or_default();
360 if last_err.contains("push failed") {
361 ctx.set(&[("last_error", serde_json::json!(""))]).await;
362 }
363 }
364 has_commits
365}
366
367enum PushResult {
379 Success,
380 Rebased,
381 Failed,
382 NoCommits, WorktreeMissing, }
385
386fn is_missing_path_error(err: &anyhow::Error) -> bool {
387 err.chain().any(|cause| {
388 cause
389 .downcast_ref::<std::io::Error>()
390 .is_some_and(|ioe| ioe.kind() == std::io::ErrorKind::NotFound)
391 }) || err
392 .to_string()
393 .to_ascii_lowercase()
394 .contains("no such file or directory")
395}
396
397async fn recover_missing_worktree_for_push(
398 ctx: &StoreCtx<'_>,
399 wt: &worktree::WorktreeSetup,
400 task_title: &str,
401 agent_name: &str,
402 model_name: Option<&str>,
403) -> anyhow::Result<worktree::WorktreeSetup> {
404 tracing::warn!(
405 task_id = ctx.task_id,
406 worktree = %wt.work_dir.display(),
407 branch = wt.branch,
408 "worktree missing during push; attempting one-time recovery"
409 );
410 ctx.append_activity(
411 "push_recover_worktree",
412 Some(agent_name),
413 model_name,
414 Some(&serde_json::json!({
415 "status": "start",
416 "worktree": wt.work_dir.display().to_string(),
417 "branch": wt.branch,
418 })),
419 )
420 .await;
421
422 let recovered = worktree::setup_worktree(
423 ctx.task_id,
424 task_title,
425 &wt.main_project_dir,
426 ctx.store,
427 ctx.repo,
428 )
429 .await?;
430
431 if !recovered.work_dir.exists() {
432 anyhow::bail!(
433 "recovered worktree path is still missing: {}",
434 recovered.work_dir.display()
435 );
436 }
437
438 ctx.append_activity(
439 "push_recover_worktree",
440 Some(agent_name),
441 model_name,
442 Some(&serde_json::json!({
443 "status": "ok",
444 "worktree": recovered.work_dir.display().to_string(),
445 "branch": recovered.branch,
446 })),
447 )
448 .await;
449
450 Ok(recovered)
451}
452
453async fn push_branch_with_log(
454 ctx: &StoreCtx<'_>,
455 work_dir: &Path,
456 branch: &str,
457 default_branch: &str,
458 agent_name: &str,
459 model_name: Option<&str>,
460) -> PushResult {
461 match git_ops::push_branch(work_dir, branch, default_branch).await {
462 Ok(_) => {
463 ctx.append_activity(
464 "push",
465 Some(agent_name),
466 model_name,
467 Some(&serde_json::json!({
468 "status": "ok",
469 "branch": branch,
470 "default_branch": default_branch,
471 })),
472 )
473 .await;
474 ctx.set(&[
477 ("last_error", serde_json::json!("")),
478 ("push_failures", serde_json::json!(0)),
479 ])
480 .await;
481 PushResult::Success
482 }
483 Err(e) => {
484 let err_str = e.to_string();
485 tracing::error!(task_id = ctx.task_id, error = ?e, "push failed");
486
487 if is_missing_path_error(&e) && !work_dir.exists() {
488 tracing::warn!(
489 task_id = ctx.task_id,
490 worktree = %work_dir.display(),
491 "push failed because worktree path disappeared; will attempt recovery"
492 );
493 ctx.append_activity(
494 "push",
495 Some(agent_name),
496 model_name,
497 Some(&serde_json::json!({
498 "status": "retry",
499 "branch": branch,
500 "default_branch": default_branch,
501 "reason": "worktree_missing",
502 "error": err_str,
503 })),
504 )
505 .await;
506 return PushResult::WorktreeMissing;
507 }
508
509 let is_diverged = err_str.contains("non-fast-forward")
511 || err_str.contains("rejected")
512 || err_str.contains("fetch first")
513 || err_str.contains("behind");
514
515 if is_diverged {
516 tracing::info!(
517 task_id = ctx.task_id,
518 branch = branch,
519 "attempting fetch and rebase to recover from diverged branch"
520 );
521
522 match git_ops::rebase_on_branch(work_dir, branch).await {
524 Ok(true) => {
525 tracing::info!(
527 task_id = ctx.task_id,
528 branch = branch,
529 "rebase succeeded — task will be re-routed to new agent"
530 );
531 ctx.append_activity(
532 "push_rebased",
533 Some(agent_name),
534 model_name,
535 Some(&serde_json::json!({
536 "status": "rebased",
537 "branch": branch,
538 "reason": "diverged_from_remote",
539 })),
540 )
541 .await;
542 ctx.set(&[(
543 "last_error",
544 serde_json::json!(
545 "push failed: branch diverged from remote — rebased successfully, will retry with new agent"
546 ),
547 )])
548 .await;
549 return PushResult::Rebased;
550 }
551 Ok(false) => {
552 tracing::info!(
554 task_id = ctx.task_id,
555 branch = branch,
556 "no local commits after fetch — marking as success"
557 );
558 ctx.append_activity(
559 "push",
560 Some(agent_name),
561 model_name,
562 Some(&serde_json::json!({
563 "status": "ok",
564 "branch": branch,
565 "reason": "no_commits_after_fetch",
566 })),
567 )
568 .await;
569 ctx.set(&[
570 ("last_error", serde_json::json!("")),
571 ("push_failures", serde_json::json!(0)),
572 ])
573 .await;
574 return PushResult::Success;
575 }
576 Err(rebase_err) => {
577 tracing::error!(
579 task_id = ctx.task_id,
580 branch = branch,
581 error = ?rebase_err,
582 "rebase failed — blocking task for human intervention"
583 );
584 ctx.append_activity(
585 "push_rebased",
586 Some(agent_name),
587 model_name,
588 Some(&serde_json::json!({
589 "status": "error",
590 "branch": branch,
591 "error": rebase_err.to_string(),
592 })),
593 )
594 .await;
595 ctx.set(&[(
596 "last_error",
597 serde_json::json!(format!(
598 "push failed and rebase failed: {rebase_err} — manual resolution required"
599 )),
600 )])
601 .await;
602 return PushResult::Failed;
603 }
604 }
605 }
606
607 ctx.append_activity(
609 "push",
610 Some(agent_name),
611 model_name,
612 Some(&serde_json::json!({
613 "status": "error",
614 "branch": branch,
615 "default_branch": default_branch,
616 "error": err_str,
617 })),
618 )
619 .await;
620 ctx.set(&[("last_error", serde_json::json!(format!("push failed: {e}")))])
621 .await;
622 PushResult::Failed
623 }
624 }
625}
626
627async fn create_pr_with_log(
634 ctx: &StoreCtx<'_>,
635 wt: &worktree::WorktreeSetup,
636 task_title: &str,
637 resp: &AgentResponse,
638 agent_name: &str,
639 model_name: Option<&str>,
640 mut has_pushed: bool,
641) -> (bool, bool, bool) {
642 match git_ops::create_pr_if_needed(
643 &wt.work_dir,
644 &wt.branch,
645 task_title,
646 &resp.summary,
647 &resp.accomplished,
648 &resp.remaining,
649 &resp.files,
650 ctx.task_id,
651 agent_name,
652 model_name,
653 ctx.repo,
654 &wt.default_branch,
655 )
656 .await
657 {
658 Ok(ref url) => {
659 match crate::engine::review::parse_pr_number_from_url(url) {
663 Ok(pr_num) => {
664 if let Err(e) = store::store_set_result(
665 ctx.store,
666 ctx.repo,
667 ctx.task_id,
668 &[("pr_number", serde_json::json!(pr_num as i64))],
669 )
670 .await
671 {
672 tracing::error!(
673 task_id = ctx.task_id,
674 pr_url = %url,
675 err = %e,
676 "CRITICAL: failed to save pr_number to store — downstream review gate may trigger duplicate gh pr create"
677 );
678 }
679 }
680 Err(e) => {
681 tracing::warn!(
682 task_id = ctx.task_id,
683 pr_url = %url,
684 error = %e,
685 "PR created but failed to parse PR number from URL"
686 );
687 }
688 }
689 ctx.append_activity(
690 "pr_create",
691 Some(agent_name),
692 model_name,
693 Some(&serde_json::json!({"status": "created", "url": url})),
694 )
695 .await;
696 (true, has_pushed, false)
697 }
698 Err(e) => {
699 let err_str = format!("{e}");
700 tracing::error!(task_id = ctx.task_id, error = ?e, "create PR failed");
701 ctx.append_activity(
702 "pr_create",
703 Some(agent_name),
704 model_name,
705 Some(&serde_json::json!({
706 "status": "error",
707 "branch": wt.branch,
708 "error": err_str.clone(),
709 })),
710 )
711 .await;
712 ctx.set(&[(
713 "last_error",
714 serde_json::json!(format!("create PR failed: {e}")),
715 )])
716 .await;
717 let mut pr_base_invalid = false;
727 if agents::patterns::detect_auth_error(&err_str).is_some() {
728 tracing::error!(
729 task_id = ctx.task_id,
730 "PR creation failed with GitHub auth error — blocking task"
731 );
732 pr_base_invalid = true;
733 } else if err_str.contains("422") {
734 if err_str.contains("No commits between") || err_str.contains("head") {
735 tracing::info!(
736 task_id = ctx.task_id,
737 "PR creation returned 422/no-commits/head-invalid — agent made no code changes or branch merged, marking done"
738 );
739 has_pushed = false;
740 } else if git_ops::is_invalid_base_error(&err_str) {
741 tracing::error!(
742 task_id = ctx.task_id,
743 "PR creation failed with invalid base branch — blocking task"
744 );
745 pr_base_invalid = true;
746 }
747 }
748 (false, has_pushed, pr_base_invalid)
749 }
750 }
751}
752
753async fn run_git_ops(
755 ctx: &StoreCtx<'_>,
756 wt: &worktree::WorktreeSetup,
757 task_title: &str,
758 resp: &AgentResponse,
759 agent_name: &str,
760 model_name: Option<&str>,
761 new_attempts: u32,
762) -> GitOpsResult {
763 auto_commit_changes(ctx, &wt.work_dir, task_title, agent_name, new_attempts).await;
764
765 let mut has_commits =
766 check_commits_and_clear_stale_errors(ctx, &wt.work_dir, &wt.default_branch).await;
767
768 let push_result = if !has_commits {
772 store::store_log_activity(
773 ctx.store,
774 ctx.repo,
775 ctx.task_id,
776 "push",
777 None,
778 None,
779 Some(agent_name),
780 model_name,
781 Some(&serde_json::json!({
782 "status": "skipped",
783 "reason": "no_commits_ahead",
784 "branch": wt.branch,
785 "default_branch": wt.default_branch,
786 })),
787 )
788 .await;
789 PushResult::NoCommits
790 } else {
791 push_branch_with_log(
792 ctx,
793 &wt.work_dir,
794 &wt.branch,
795 &wt.default_branch,
796 agent_name,
797 model_name,
798 )
799 .await
800 };
801 let push_result = if matches!(push_result, PushResult::WorktreeMissing) {
802 match recover_missing_worktree_for_push(ctx, wt, task_title, agent_name, model_name).await {
803 Ok(recovered) => {
804 let wt_for_ops = &recovered;
805 has_commits = check_commits_and_clear_stale_errors(
806 ctx,
807 &wt_for_ops.work_dir,
808 &wt_for_ops.default_branch,
809 )
810 .await;
811 if !has_commits {
812 PushResult::NoCommits
813 } else {
814 push_branch_with_log(
815 ctx,
816 &wt_for_ops.work_dir,
817 &wt_for_ops.branch,
818 &wt_for_ops.default_branch,
819 agent_name,
820 model_name,
821 )
822 .await
823 }
824 }
825 Err(e) => {
826 tracing::error!(
827 task_id = ctx.task_id,
828 err = %e,
829 "worktree recovery before push failed"
830 );
831 ctx.append_activity(
832 "push_recover_worktree",
833 Some(agent_name),
834 model_name,
835 Some(&serde_json::json!({
836 "status": "error",
837 "worktree": wt.work_dir.display().to_string(),
838 "branch": wt.branch,
839 "error": e.to_string(),
840 })),
841 )
842 .await;
843 ctx.set(&[(
844 "last_error",
845 serde_json::json!(format!(
846 "push failed: worktree disappeared and recovery failed: {e}"
847 )),
848 )])
849 .await;
850 PushResult::Failed
851 }
852 }
853 } else {
854 push_result
855 };
856
857 let mut has_pushed = matches!(push_result, PushResult::Success);
858 let mut has_pr = false;
859
860 if has_commits && matches!(push_result, PushResult::Failed) {
862 tracing::warn!(
863 task_id = ctx.task_id,
864 "skipping PR creation due to push failure"
865 );
866 } else if matches!(push_result, PushResult::Rebased) {
867 tracing::info!(
870 task_id = ctx.task_id,
871 "skipping PR creation after successful rebase — task will be re-routed"
872 );
873 } else if matches!(push_result, PushResult::NoCommits) {
874 tracing::info!(
875 task_id = ctx.task_id,
876 "no commits ahead of default branch, skipping push + PR"
877 );
878 } else if !has_pushed {
879 } else if ctx.repo.is_empty() {
881 tracing::warn!(
882 task_id = ctx.task_id,
883 "skipping PR creation — repo is empty (internal task?)"
884 );
885 } else {
886 let (pr, updated_pushed, pr_base_invalid) = create_pr_with_log(
887 ctx, wt, task_title, resp, agent_name, model_name, has_pushed,
888 )
889 .await;
890 has_pr = pr;
891 has_pushed = updated_pushed;
892 return GitOpsResult {
893 has_pr,
894 has_pushed,
895 has_commits,
896 pr_base_invalid,
897 };
898 }
899
900 GitOpsResult {
901 has_pr,
902 has_pushed,
903 has_commits,
904 pr_base_invalid: false,
905 }
906}
907
908async fn close_superseded_pr(ctx: &StoreCtx<'_>, pr_number: u64) {
912 if ctx.repo.is_empty() {
913 return;
914 }
915 let gh = match crate::github::http::GhHttp::new() {
916 Ok(gh) => gh,
917 Err(e) => {
918 tracing::warn!(task_id = ctx.task_id, err = %e, "cannot close superseded PR");
919 return;
920 }
921 };
922 let note = "Closing as superseded: the change is already on the default branch, so this branch has no commits ahead of it.";
923 if let Err(e) = gh.add_comment(ctx.repo, &pr_number.to_string(), note).await {
924 tracing::warn!(task_id = ctx.task_id, pr_number, err = %e, "failed to comment on superseded PR");
925 }
926 if let Err(e) = gh.close_pr(ctx.repo, pr_number).await {
927 tracing::warn!(task_id = ctx.task_id, pr_number, err = %e, "failed to close superseded PR");
928 }
929 ctx.set(&[(
930 "last_error",
931 serde_json::json!("PR closed as superseded: the fix already landed on the default branch"),
932 )])
933 .await;
934}
935
936fn superseded_conflict_pr(
942 merge_conflict_retries: i32,
943 pr_number: Option<i32>,
944 has_commits: bool,
945) -> Option<u64> {
946 if has_commits || merge_conflict_retries <= 0 {
947 return None;
948 }
949 pr_number.filter(|n| *n > 0).map(|n| n as u64)
950}
951
952async fn detect_push_failure_state(
954 ctx: &StoreCtx<'_>,
955 has_commits: bool,
956 has_pushed: bool,
957) -> PushFailureState {
958 let stored_last_error = ctx
960 .get_task()
961 .await
962 .map(|t| t.last_error)
963 .unwrap_or_default();
964
965 let push_failed = !has_pushed && has_commits && stored_last_error.contains("push failed");
966
967 let is_workflow_scope_failure = push_failed
970 && (stored_last_error.contains("workflow` scope")
971 || stored_last_error.contains("workflow' scope")
972 || (stored_last_error.contains("refusing to allow")
973 && stored_last_error.contains("workflow")));
974
975 let is_rebased_recovery = push_failed && stored_last_error.contains("rebased successfully");
979 let is_rebase_conflict_failure =
980 push_failed && stored_last_error.contains("push failed and rebase failed:");
981
982 let push_failures = if push_failed
988 && !is_workflow_scope_failure
989 && !is_rebased_recovery
990 && !is_rebase_conflict_failure
991 {
992 match ctx.increment("push_failures").await {
993 Ok(v) => v,
994 Err(e) => {
995 tracing::warn!(
996 task_id = ctx.task_id,
997 err = %e,
998 "failed to increment push_failures — reading current value from store"
999 );
1000 ctx.get_task()
1002 .await
1003 .map(|t| (t.push_failures.max(0) as u64) + 1)
1004 .unwrap_or(1) }
1006 }
1007 } else {
1008 0
1009 };
1010
1011 PushFailureState {
1012 push_failed,
1013 is_workflow_scope_failure,
1014 is_rebase_conflict_failure,
1015 push_failures,
1016 stored_last_error,
1017 }
1018}
1019
1020async fn detect_no_code_reroutes(
1023 ctx: &StoreCtx<'_>,
1024 is_no_code_reroute: bool,
1025 new_attempts: u32,
1026 max_reroutes: u32,
1027) -> u64 {
1028 if !is_no_code_reroute {
1029 return 0;
1030 }
1031 tracing::warn!(
1032 task_id = ctx.task_id,
1033 attempts = new_attempts,
1034 max_reroutes,
1035 "agent reported done but produced no code changes on external task requiring PR"
1036 );
1037 match ctx.increment("no_code_reroutes").await {
1038 Ok(v) => v,
1039 Err(e) => {
1040 tracing::warn!(
1041 task_id = ctx.task_id,
1042 err = %e,
1043 "failed to increment no_code_reroutes — reading current value from store"
1044 );
1045 ctx.get_task()
1047 .await
1048 .map(|t| (t.no_code_reroutes.max(0) as u64) + 1)
1049 .unwrap_or(1) }
1051 }
1052}
1053
1054fn agent_blocked_reason(resp: &crate::parser::AgentResponse) -> String {
1061 let summary_opt = if !resp.summary.is_empty() {
1062 Some(resp.summary.as_str())
1063 } else {
1064 None
1065 };
1066 let base = resp
1067 .error
1068 .as_deref()
1069 .filter(|s| !s.is_empty())
1070 .or(summary_opt)
1071 .unwrap_or("agent returned blocked status without a reason");
1072 if resp.remaining.is_empty() {
1073 base.to_string()
1074 } else {
1075 format!("{base}. Remaining: {}", resp.remaining.join("; "))
1076 }
1077}
1078
1079fn is_retryable_blocked_reason(reason: &str) -> bool {
1085 let r = reason.to_lowercase();
1086
1087 let permanent_patterns = [
1088 "not found",
1089 "does not exist",
1090 "invalid",
1091 "malformed",
1092 "syntax error",
1093 "unsupported",
1094 "unimplemented",
1095 "manual intervention required",
1096 "human intervention required",
1097 ];
1098 if permanent_patterns.iter().any(|p| r.contains(p)) {
1099 return false;
1100 }
1101
1102 let transient_patterns = [
1103 "unavailable",
1104 "temporarily unavailable",
1105 "timeout",
1106 "timed out",
1107 "connection reset",
1108 "connection refused",
1109 "network error",
1110 "rate limit",
1111 "try again",
1112 "service down",
1113 "permission denied",
1114 "worktree lock",
1115 "git lock",
1116 "lockfile permission",
1117 "lock permission",
1118 "resource busy",
1119 "could not resolve host",
1120 "gpg decryption fails",
1123 "gpg decryption failed",
1124 "gpg agent",
1125 "no passphrase available",
1126 "passphrase unavailable",
1127 "pass store",
1128 "passwordstoreprovider",
1129 "agentvaultprovider",
1130 "onepasswordprovider",
1131 "decryption failed in this agent session",
1132 ];
1133 transient_patterns.iter().any(|p| r.contains(p))
1134}
1135
1136#[allow(clippy::too_many_arguments)]
1138async fn apply_post_decision_effects(
1139 ctx: &StoreCtx<'_>,
1140 final_status: &str,
1141 resp_status: &str,
1142 push_state: &PushFailureState,
1143 is_no_code_reroute: bool,
1144 has_pr: bool,
1145 has_pushed: bool,
1146 is_pr_base_invalid: bool,
1147 has_delegations: bool,
1148 is_external: bool,
1149 no_code_reroutes: u64,
1150 max_reroutes: u32,
1151 agent_name: &str,
1152) {
1153 if push_state.is_workflow_scope_failure {
1154 tracing::error!(
1156 task_id = ctx.task_id,
1157 "push failed: token lacks `workflow` OAuth scope — blocking immediately \
1158 (rerouting would not help)"
1159 );
1160 ctx.set(&[(
1161 "last_error",
1162 serde_json::json!(format!(
1163 "push failed: GitHub token lacks `workflow` OAuth scope. \
1164 The agent modified .github/workflows/ files but the token cannot push them. \
1165 Fix: add `workflow` scope to your GitHub token, or use a GitHub App for auth. \
1166 Original error: {}",
1167 push_state.stored_last_error
1168 )),
1169 )])
1170 .await;
1171 } else if push_state.is_rebase_conflict_failure {
1172 tracing::error!(
1173 task_id = ctx.task_id,
1174 "push failed and automatic rebase recovery failed — blocking for human intervention"
1175 );
1176 ctx.set(&[
1177 ("agent", serde_json::json!(null)),
1178 ("model", serde_json::json!(null)),
1179 ])
1180 .await;
1181 } else if push_state.push_failed {
1182 let is_rebased_recovery = push_state
1184 .stored_last_error
1185 .contains("rebased successfully");
1186 ctx.set(&[
1188 ("agent", serde_json::json!(null)),
1189 ("model", serde_json::json!(null)),
1190 ])
1191 .await;
1192 let push_failures = push_state.push_failures;
1193 if is_rebased_recovery {
1194 tracing::info!(
1196 task_id = ctx.task_id,
1197 "push failed but rebase succeeded — rerouting to different agent with up-to-date history"
1198 );
1199 } else if push_failures >= 3 {
1200 tracing::error!(
1201 task_id = ctx.task_id,
1202 push_failures,
1203 "push failed {push_failures} times — blocking for human intervention"
1204 );
1205 } else {
1206 tracing::warn!(
1207 task_id = ctx.task_id,
1208 push_failures,
1209 "agent done but push failed ({push_failures}/3) — rerouting to different agent"
1210 );
1211 }
1212 } else if resp_status == "done" && !has_pr && has_delegations {
1213 tracing::info!(
1214 task_id = ctx.task_id,
1215 "agent reported done with delegations but no PR — setting blocked"
1216 );
1217 } else if (resp_status == "done" || resp_status == "completed")
1218 && !has_pr
1219 && has_pushed
1220 && is_pr_base_invalid
1221 {
1222 tracing::error!(
1223 task_id = ctx.task_id,
1224 "PR creation failed with invalid base branch — blocking for human intervention"
1225 );
1226 ctx.set(&[(
1227 "last_error",
1228 serde_json::json!(
1229 "PR creation failed terminally (invalid base branch or GitHub auth error). The agent's commits were pushed successfully. \
1230 Check the repository default branch and the GitHub token, then create the PR manually or unblock the task."
1231 ),
1232 )])
1233 .await;
1234 } else if resp_status == "done" && !has_pr && has_pushed {
1235 tracing::warn!(
1237 task_id = ctx.task_id,
1238 "agent done, commits pushed, but PR creation failed — re-dispatching as routed"
1239 );
1240 } else if is_no_code_reroute {
1241 if final_status == "blocked" {
1246 let prev_no_code_agent = ctx
1250 .get_task()
1251 .await
1252 .map(|t| t.no_code_last_agent.clone())
1253 .unwrap_or_default();
1254 let is_same_agent = !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name;
1255
1256 let msg = if is_same_agent {
1257 tracing::error!(
1258 task_id = ctx.task_id,
1259 agent = %agent_name,
1260 "blocking same-agent no-code loop: agent {} would be selected again",
1261 agent_name
1262 );
1263 format!(
1264 "agent {} completed without code changes twice — same-agent loop detected, blocking for human review",
1265 agent_name
1266 )
1267 } else {
1268 tracing::error!(
1269 task_id = ctx.task_id,
1270 no_code_reroutes,
1271 max_reroutes,
1272 "blocking no-code reroute: reached max reroute attempts ({}/{}) for no-code-result",
1273 no_code_reroutes, max_reroutes
1274 );
1275 format!(
1276 "agent completed without code changes after {}/{} reroute attempts on external task requiring PR",
1277 no_code_reroutes, max_reroutes
1278 )
1279 };
1280 ctx.set(&[("last_error", serde_json::json!(msg))]).await;
1281 } else {
1282 ctx.set(&[(
1284 "last_error",
1285 serde_json::json!(
1286 "agent completed without code changes on external task requiring PR"
1287 ),
1288 )])
1289 .await;
1290 }
1291
1292 ctx.set(&[
1297 ("no_code_last_agent", serde_json::json!(agent_name)),
1298 ("agent", serde_json::json!(null)),
1299 ("model", serde_json::json!(null)),
1300 ])
1301 .await;
1302 } else if resp_status == "done" && !has_pr && is_external {
1303 tracing::info!(
1306 task_id = ctx.task_id,
1307 "external task with non-code labels reported done — marking done without PR"
1308 );
1309 } else if resp_status == "done" && !has_pr {
1310 tracing::info!(
1311 task_id = ctx.task_id,
1312 "internal task reported done with no PR — marking done"
1313 );
1314 }
1315}
1316
1317async fn store_token_usage(
1319 ctx: &StoreCtx<'_>,
1320 input_tokens: Option<u64>,
1321 output_tokens: Option<u64>,
1322 model_name: Option<&str>,
1323) {
1324 let (Some(input), Some(output)) = (input_tokens, output_tokens) else {
1325 return;
1326 };
1327 let model = model_name.unwrap_or("haiku");
1328 if let Some(ref st) = ctx.store {
1329 if let Some(store_id) = ctx.store_id_opt {
1330 if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1331 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1332 }
1333 } else if let Ok(Some(store_id)) = st.resolve_task_id(ctx.repo, ctx.task_id).await {
1334 if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1335 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1336 }
1337 }
1338 }
1339}
1340
1341#[allow(clippy::too_many_arguments)]
1347pub async fn handle_success(
1348 task_id: &str,
1349 parsed: agents::ParsedResponse,
1350 wt: &worktree::WorktreeSetup,
1351 task_title: &str,
1352 agent_name: &str,
1353 model_name: Option<&str>,
1354 new_attempts: u32,
1355 repo: &str,
1356 store: &Option<Arc<TaskStore>>,
1357 raw_stdout: &str,
1358) -> anyhow::Result<(String, bool)> {
1359 let input_tokens = parsed.input_tokens;
1361 let output_tokens = parsed.output_tokens;
1362 let resp = parsed.response;
1363
1364 tracing::info!(
1365 task_id,
1366 status = resp.status,
1367 summary = resp.summary,
1368 "agent completed successfully"
1369 );
1370
1371 let store_id_opt = store::resolve_store_id(store, repo, task_id).await;
1374 let ctx = StoreCtx {
1375 store,
1376 store_id_opt,
1377 repo,
1378 task_id,
1379 };
1380
1381 ctx.set(&[("network_retries", serde_json::json!(0))]).await;
1382
1383 let git = if resp.status == "done"
1385 || resp.status == "completed"
1386 || resp.status == "in_progress"
1387 || resp.status == "needs_review"
1388 {
1389 run_git_ops(
1390 &ctx,
1391 wt,
1392 task_title,
1393 &resp,
1394 agent_name,
1395 model_name,
1396 new_attempts,
1397 )
1398 .await
1399 } else {
1400 GitOpsResult::default()
1401 };
1402
1403 if !resp.delegations.is_empty() {
1405 ctx.set(&[("delegations", serde_json::json!(resp.delegations))])
1406 .await;
1407 }
1408
1409 let has_delegations = !resp.delegations.is_empty();
1416
1417 let push_state = detect_push_failure_state(&ctx, git.has_commits, git.has_pushed).await;
1419
1420 let is_external = !task_id.starts_with("internal:");
1428 let requires_pr = is_external && !git.has_pr && !resp.status.starts_with("needs_review");
1429
1430 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
1433 .ok()
1434 .and_then(|s| s.parse().ok())
1435 .or_else(|| {
1436 config::get("workflow.max_attempts")
1437 .ok()
1438 .and_then(|s| s.parse().ok())
1439 })
1440 .unwrap_or(3);
1441
1442 let is_no_code_reroute = !push_state.is_workflow_scope_failure
1445 && !push_state.push_failed
1446 && (resp.status == "done" || resp.status == "completed")
1447 && !has_delegations
1448 && !git.has_pushed
1449 && requires_pr;
1450
1451 let superseded_pr = match (is_no_code_reroute, ctx.get_task().await) {
1454 (true, Some(task)) => {
1455 superseded_conflict_pr(task.merge_conflict_retries, task.pr_number, git.has_commits)
1456 }
1457 _ => None,
1458 };
1459 let is_no_code_reroute = is_no_code_reroute && superseded_pr.is_none();
1460
1461 let no_code_reroutes =
1462 detect_no_code_reroutes(&ctx, is_no_code_reroute, new_attempts, max_reroutes).await;
1463
1464 let blocked_reason = if resp.status == "blocked" {
1465 Some(agent_blocked_reason(&resp))
1466 } else {
1467 None
1468 };
1469 let is_retryable_blocked = blocked_reason
1470 .as_deref()
1471 .map(is_retryable_blocked_reason)
1472 .unwrap_or(false);
1473
1474 let is_same_agent = if is_no_code_reroute {
1479 let prev_no_code_agent = ctx
1480 .get_task()
1481 .await
1482 .map(|t| t.no_code_last_agent.clone())
1483 .unwrap_or_default();
1484 !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name
1485 } else {
1486 false
1487 };
1488
1489 let final_status_owned = classify_final_status(&DecisionInput {
1492 agent_status: &resp.status,
1493 is_workflow_scope_failure: push_state.is_workflow_scope_failure,
1494 is_rebase_conflict_failure: push_state.is_rebase_conflict_failure,
1495 push_failed: push_state.push_failed,
1496 push_failures: push_state.push_failures,
1497 has_pr: git.has_pr,
1498 has_delegations,
1499 has_pushed: git.has_pushed,
1500 is_pr_base_invalid: git.pr_base_invalid,
1501 requires_pr,
1502 no_code_reroutes,
1503 max_reroutes,
1504 is_same_agent,
1505 is_completed_status: resp.status == "completed",
1506 is_retryable_blocked,
1507 is_superseded_conflict: superseded_pr.is_some(),
1508 });
1509 let final_status = final_status_owned.as_str();
1510
1511 apply_post_decision_effects(
1514 &ctx,
1515 final_status,
1516 &resp.status,
1517 &push_state,
1518 is_no_code_reroute,
1519 git.has_pr,
1520 git.has_pushed,
1521 git.pr_base_invalid,
1522 has_delegations,
1523 is_external,
1524 no_code_reroutes,
1525 max_reroutes,
1526 agent_name,
1527 )
1528 .await;
1529
1530 if resp.status == "blocked" {
1536 let reason = blocked_reason.unwrap_or_else(|| agent_blocked_reason(&resp));
1537 if final_status == "new" {
1538 crate::engine::cooldown::record_agent_failure_with_message(agent_name, &reason).await;
1541 if let Some(m) = model_name {
1542 crate::engine::cooldown::record_model_failure(agent_name, m).await;
1543 }
1544 ctx.set(&[
1545 (
1546 "last_error",
1547 serde_json::json!(format!(
1548 "transient blocker (auto-retry scheduled): {reason}"
1549 )),
1550 ),
1551 ("agent", serde_json::json!(null)),
1552 ("model", serde_json::json!(null)),
1553 ])
1554 .await;
1555 tracing::warn!(
1556 task_id = ctx.task_id,
1557 reason = %reason,
1558 "agent returned retryable blocked status — rerouting as new with cooldown"
1559 );
1560 } else if final_status == "blocked" {
1561 ctx.set(&[("last_error", serde_json::json!(reason))]).await;
1562 }
1563 }
1564
1565 if let Some(pr_number) = superseded_pr {
1566 tracing::info!(
1567 task_id = ctx.task_id,
1568 pr_number,
1569 "conflict reroute found the fix already on the default branch — closing superseded PR"
1570 );
1571 close_superseded_pr(&ctx, pr_number).await;
1572 }
1573
1574 ctx.set(&[("summary", serde_json::json!(resp.summary))])
1575 .await;
1576
1577 store_token_usage(
1579 &ctx,
1580 input_tokens.or(resp.input_tokens),
1581 output_tokens.or(resp.output_tokens),
1582 model_name,
1583 )
1584 .await;
1585
1586 response::store_learnings_from_response(
1588 task_id,
1589 new_attempts,
1590 agent_name,
1591 model_name,
1592 &resp,
1593 resp.error.as_deref(),
1594 store,
1595 repo,
1596 raw_stdout,
1597 )
1598 .await;
1599
1600 Ok((final_status.to_string(), push_state.push_failed))
1603}
1604
1605#[cfg(test)]
1607mod tests {
1608 use super::*;
1609
1610 #[test]
1611 fn missing_path_error_detects_io_not_found() {
1612 let err = anyhow::Error::from(std::io::Error::new(
1613 std::io::ErrorKind::NotFound,
1614 "simulated missing path",
1615 ));
1616 assert!(is_missing_path_error(&err));
1617 }
1618
1619 #[test]
1620 fn missing_path_error_detects_message_fallback() {
1621 let err = anyhow::anyhow!("push failed: No such file or directory (os error 2)");
1622 assert!(is_missing_path_error(&err));
1623 }
1624
1625 #[test]
1626 fn superseded_conflict_pr_requires_conflict_reroute_and_no_commits() {
1627 assert_eq!(superseded_conflict_pr(1, Some(42), false), Some(42));
1629 assert_eq!(superseded_conflict_pr(1, Some(42), true), None);
1631 assert_eq!(superseded_conflict_pr(0, Some(42), false), None);
1633 assert_eq!(superseded_conflict_pr(1, None, false), None);
1635 }
1636
1637 #[tokio::test]
1641 async fn conflict_reroute_with_no_commits_closes_pr_and_marks_done() {
1642 fn git(dir: &std::path::Path, args: &[&str]) {
1643 let ok = std::process::Command::new("git")
1644 .args(args)
1645 .current_dir(dir)
1646 .env("GIT_AUTHOR_NAME", "t")
1647 .env("GIT_AUTHOR_EMAIL", "t@example.com")
1648 .env("GIT_COMMITTER_NAME", "t")
1649 .env("GIT_COMMITTER_EMAIL", "t@example.com")
1650 .output()
1651 .expect("git runs")
1652 .status
1653 .success();
1654 assert!(ok, "git {args:?} failed");
1655 }
1656
1657 let dir = tempfile::tempdir().unwrap();
1660 let origin = dir.path().join("origin.git");
1661 std::fs::create_dir(&origin).unwrap();
1662 git(&origin, &["init", "--bare", "-b", "main"]);
1663 let work_dir = dir.path().join("work");
1664 std::fs::create_dir(&work_dir).unwrap();
1665 git(&work_dir, &["init", "-b", "main"]);
1666 git(
1667 &work_dir,
1668 &["remote", "add", "origin", &origin.display().to_string()],
1669 );
1670 std::fs::write(work_dir.join("a.txt"), "a").unwrap();
1671 git(&work_dir, &["add", "-A"]);
1672 git(&work_dir, &["commit", "-m", "init"]);
1673 git(&work_dir, &["push", "-u", "origin", "main"]);
1674 git(&work_dir, &["checkout", "-b", "fix-branch"]);
1675
1676 let wt = worktree::WorktreeSetup {
1677 work_dir: work_dir.clone(),
1678 branch: "fix-branch".to_string(),
1679 default_branch: "main".to_string(),
1680 main_project_dir: work_dir.clone(),
1681 };
1682
1683 let store = Arc::new(TaskStore::open_memory().await.unwrap());
1684 let store_id = store
1685 .create(&crate::store::NewTask {
1686 external_id: Some("3772".to_string()),
1687 repo: "owner/repo".to_string(),
1688 origin: "github".to_string(),
1689 title: "Fix the thing".to_string(),
1690 body: String::new(),
1691 source: "manual".to_string(),
1692 source_id: String::new(),
1693 author: "bot".to_string(),
1694 url: String::new(),
1695 labels: vec![],
1696 parent_id: None,
1697 })
1698 .await
1699 .unwrap();
1700 store
1701 .set_fields(store_id, &[("pr_number", serde_json::json!(42))])
1702 .await
1703 .unwrap();
1704 store
1706 .increment(store_id, "merge_conflict_retries")
1707 .await
1708 .unwrap();
1709 store
1712 .set_fields(
1713 store_id,
1714 &[("no_code_last_agent", serde_json::json!("claude"))],
1715 )
1716 .await
1717 .unwrap();
1718
1719 let parsed = agents::ParsedResponse {
1720 response: AgentResponse {
1721 status: "done".to_string(),
1722 summary: "the fix is already on main".to_string(),
1723 ..Default::default()
1724 },
1725 input_tokens: None,
1726 output_tokens: None,
1727 duration_ms: None,
1728 };
1729
1730 let (final_status, push_failed) = handle_success(
1731 "3772",
1732 parsed,
1733 &wt,
1734 "Fix the thing",
1735 "claude",
1736 Some("sonnet"),
1737 1,
1738 "owner/repo",
1739 &Some(Arc::clone(&store)),
1740 "",
1741 )
1742 .await
1743 .unwrap();
1744
1745 assert_eq!(
1746 final_status, "done",
1747 "a superseded conflict reroute must end done, not blocked"
1748 );
1749 assert!(!push_failed);
1750
1751 let task = store.get(store_id).await.unwrap();
1752 assert!(
1753 task.last_error.contains("superseded"),
1754 "the close attempt must be recorded on the task: {}",
1755 task.last_error
1756 );
1757 }
1758
1759 #[test]
1762 fn classify_superseded_conflict_marks_done() {
1763 let status = classify_final_status(&DecisionInput {
1764 agent_status: "done",
1765 requires_pr: true,
1766 is_same_agent: true,
1767 is_superseded_conflict: true,
1768 ..Default::default()
1769 });
1770 assert_eq!(status, "done");
1771 }
1772
1773 #[test]
1777 fn classify_workflow_scope_failure_blocks() {
1778 let status = classify_final_status(&DecisionInput {
1779 is_workflow_scope_failure: true,
1780 push_failed: true,
1781 push_failures: 1,
1782 ..Default::default()
1783 });
1784 assert_eq!(status, "blocked");
1785 }
1786
1787 #[test]
1789 fn classify_rebase_conflict_failure_blocks() {
1790 let status = classify_final_status(&DecisionInput {
1791 is_rebase_conflict_failure: true,
1792 push_failed: true,
1793 ..Default::default()
1794 });
1795 assert_eq!(status, "blocked");
1796 }
1797
1798 #[test]
1800 fn classify_push_failed_under_threshold_reroutes() {
1801 let status = classify_final_status(&DecisionInput {
1802 push_failed: true,
1803 push_failures: 2, ..Default::default()
1805 });
1806 assert_eq!(status, "new");
1807 }
1808
1809 #[test]
1811 fn classify_push_failed_at_threshold_blocks() {
1812 let status = classify_final_status(&DecisionInput {
1813 push_failed: true,
1814 push_failures: 3, ..Default::default()
1816 });
1817 assert_eq!(status, "blocked");
1818 }
1819
1820 #[test]
1822 fn classify_done_with_pr_goes_to_needs_review() {
1823 let status = classify_final_status(&DecisionInput {
1824 agent_status: "done",
1825 has_pr: true,
1826 ..Default::default()
1827 });
1828 assert_eq!(status, "needs_review");
1829 }
1830
1831 #[test]
1833 fn classify_done_with_delegations_no_pr_blocks() {
1834 let status = classify_final_status(&DecisionInput {
1835 agent_status: "done",
1836 has_delegations: true,
1837 ..Default::default()
1838 });
1839 assert_eq!(status, "blocked");
1840 }
1841
1842 #[test]
1844 fn classify_done_pushed_no_pr_reroutes_for_pr_creation() {
1845 let status = classify_final_status(&DecisionInput {
1846 agent_status: "done",
1847 has_pushed: true,
1848 ..Default::default()
1849 });
1850 assert_eq!(status, "routed");
1851 }
1852
1853 #[test]
1855 fn classify_done_pushed_pr_base_invalid_blocks() {
1856 let status = classify_final_status(&DecisionInput {
1857 agent_status: "done",
1858 has_pushed: true,
1859 is_pr_base_invalid: true,
1860 ..Default::default()
1861 });
1862 assert_eq!(status, "blocked");
1863 }
1864
1865 #[test]
1867 fn classify_completed_pushed_pr_base_invalid_blocks() {
1868 let status = classify_final_status(&DecisionInput {
1869 agent_status: "completed",
1870 is_completed_status: true,
1871 has_pushed: true,
1872 is_pr_base_invalid: true,
1873 ..Default::default()
1874 });
1875 assert_eq!(status, "blocked");
1876 }
1877
1878 #[test]
1880 fn classify_done_requires_pr_under_max_reroutes() {
1881 let status = classify_final_status(&DecisionInput {
1882 agent_status: "done",
1883 requires_pr: true,
1884 no_code_reroutes: 1,
1885 max_reroutes: 3,
1886 is_same_agent: false,
1887 ..Default::default()
1888 });
1889 assert_eq!(status, "new");
1890 }
1891
1892 #[test]
1894 fn classify_done_requires_pr_same_agent_blocks_immediately() {
1895 let status = classify_final_status(&DecisionInput {
1896 agent_status: "done",
1897 requires_pr: true,
1898 no_code_reroutes: 1,
1899 max_reroutes: 3,
1900 is_same_agent: true, ..Default::default()
1902 });
1903 assert_eq!(status, "blocked");
1904 }
1905
1906 #[test]
1908 fn classify_done_requires_pr_at_max_reroutes_blocks() {
1909 let status = classify_final_status(&DecisionInput {
1910 agent_status: "done",
1911 requires_pr: true,
1912 no_code_reroutes: 3,
1913 max_reroutes: 3,
1914 is_same_agent: false,
1915 ..Default::default()
1916 });
1917 assert_eq!(status, "blocked");
1918 }
1919
1920 #[test]
1922 fn classify_done_requires_pr_exceeds_max_reroutes_blocks() {
1923 let status = classify_final_status(&DecisionInput {
1924 agent_status: "done",
1925 requires_pr: true,
1926 no_code_reroutes: 5,
1927 max_reroutes: 3,
1928 is_same_agent: false,
1929 ..Default::default()
1930 });
1931 assert_eq!(status, "blocked");
1932 }
1933
1934 #[test]
1936 fn classify_done_external_no_pushed_same_agent_blocks_immediately() {
1937 let status = classify_final_status(&DecisionInput {
1938 agent_status: "done",
1939 requires_pr: true, no_code_reroutes: 0,
1941 max_reroutes: 3,
1942 is_same_agent: true,
1943 ..Default::default()
1944 });
1945 assert_eq!(status, "blocked");
1946 }
1947
1948 #[test]
1956 fn classify_done_external_no_pushed_commits_reroutes() {
1957 let status = classify_final_status(&DecisionInput {
1958 agent_status: "done",
1959 has_pushed: false,
1960 requires_pr: true, no_code_reroutes: 0,
1962 max_reroutes: 3,
1963 is_same_agent: false,
1964 ..Default::default()
1965 });
1966 assert_eq!(status, "new");
1967 }
1968
1969 #[test]
1972 fn classify_done_internal_task_no_pr_is_done() {
1973 let status = classify_final_status(&DecisionInput {
1974 agent_status: "done",
1975 has_pushed: false,
1976 requires_pr: false,
1977 ..Default::default()
1978 });
1979 assert_eq!(status, "done");
1980 }
1981
1982 #[test]
1984 fn classify_non_done_status_passes_through() {
1985 for status_str in &["in_progress", "needs_review", "blocked"] {
1986 let result = classify_final_status(&DecisionInput {
1987 agent_status: status_str,
1988 ..Default::default()
1989 });
1990 assert_eq!(
1991 result, *status_str,
1992 "status '{status_str}' should pass through unchanged"
1993 );
1994 }
1995 }
1996
1997 #[test]
1999 fn classify_descriptive_completion_status_normalizes_to_done() {
2000 let status = classify_final_status(&DecisionInput {
2001 agent_status: "Trading scan complete. Nothing to trade.",
2002 ..Default::default()
2003 });
2004 assert_eq!(status, "done");
2005 }
2006
2007 #[test]
2009 fn classify_descriptive_completion_with_failure_cues_does_not_normalize() {
2010 let status = classify_final_status(&DecisionInput {
2011 agent_status: "completed but failed to push branch due to error",
2012 ..Default::default()
2013 });
2014 assert_eq!(status, "completed but failed to push branch due to error");
2015 }
2016
2017 #[test]
2019 fn classify_push_failed_takes_precedence_over_has_pr() {
2020 let status = classify_final_status(&DecisionInput {
2021 agent_status: "done",
2022 has_pr: true,
2023 push_failed: true,
2024 push_failures: 1,
2025 ..Default::default()
2026 });
2027 assert_eq!(status, "new");
2029 }
2030
2031 #[test]
2033 fn classify_workflow_scope_failure_takes_precedence_over_push_reroute() {
2034 let status = classify_final_status(&DecisionInput {
2035 is_workflow_scope_failure: true,
2036 push_failed: true,
2037 push_failures: 1, ..Default::default()
2039 });
2040 assert_eq!(status, "blocked");
2041 }
2042
2043 #[test]
2047 fn classify_completed_with_pr_goes_to_needs_review() {
2048 let status = classify_final_status(&DecisionInput {
2049 agent_status: "completed",
2050 has_pr: true,
2051 is_completed_status: true,
2052 ..Default::default()
2053 });
2054 assert_eq!(status, "needs_review");
2055 }
2056
2057 #[test]
2059 fn classify_completed_with_delegations_no_pr_blocks() {
2060 let status = classify_final_status(&DecisionInput {
2061 agent_status: "completed",
2062 has_delegations: true,
2063 is_completed_status: true,
2064 ..Default::default()
2065 });
2066 assert_eq!(status, "blocked");
2067 }
2068
2069 #[test]
2071 fn classify_completed_pushed_no_pr_reroutes_for_pr_creation() {
2072 let status = classify_final_status(&DecisionInput {
2073 agent_status: "completed",
2074 has_pushed: true,
2075 is_completed_status: true,
2076 ..Default::default()
2077 });
2078 assert_eq!(status, "routed");
2079 }
2080
2081 #[test]
2083 fn classify_completed_requires_pr_under_max_reroutes() {
2084 let status = classify_final_status(&DecisionInput {
2085 agent_status: "completed",
2086 requires_pr: true,
2087 no_code_reroutes: 1,
2088 max_reroutes: 3,
2089 is_completed_status: true,
2090 is_same_agent: false,
2091 ..Default::default()
2092 });
2093 assert_eq!(status, "new");
2094 }
2095
2096 #[test]
2098 fn classify_completed_requires_pr_at_max_reroutes_blocks() {
2099 let status = classify_final_status(&DecisionInput {
2100 agent_status: "completed",
2101 requires_pr: true,
2102 no_code_reroutes: 3,
2103 max_reroutes: 3,
2104 is_completed_status: true,
2105 is_same_agent: false,
2106 ..Default::default()
2107 });
2108 assert_eq!(status, "blocked");
2109 }
2110
2111 #[test]
2113 fn classify_completed_same_agent_blocks_immediately() {
2114 let status = classify_final_status(&DecisionInput {
2115 agent_status: "completed",
2116 requires_pr: true,
2117 no_code_reroutes: 1,
2118 max_reroutes: 3,
2119 is_completed_status: true,
2120 is_same_agent: true,
2121 ..Default::default()
2122 });
2123 assert_eq!(status, "blocked");
2124 }
2125
2126 #[test]
2128 fn classify_completed_internal_task_is_done() {
2129 let status = classify_final_status(&DecisionInput {
2130 agent_status: "completed",
2131 requires_pr: false,
2132 is_completed_status: true,
2133 ..Default::default()
2134 });
2135 assert_eq!(status, "done");
2136 }
2137
2138 #[test]
2142 fn agent_blocked_reason_prefers_error_field() {
2143 let resp = crate::parser::AgentResponse {
2144 status: "blocked".to_string(),
2145 error: Some("CDP endpoint not reachable".to_string()),
2146 summary: "Could not complete research".to_string(),
2147 remaining: vec!["Run x-twitter-brave with reachable endpoint".to_string()],
2148 ..Default::default()
2149 };
2150 let reason = agent_blocked_reason(&resp);
2151 assert!(
2152 reason.contains("CDP endpoint not reachable"),
2153 "reason: {reason}"
2154 );
2155 assert!(reason.contains("Remaining:"), "reason: {reason}");
2156 assert!(reason.contains("x-twitter-brave"), "reason: {reason}");
2157 }
2158
2159 #[test]
2161 fn agent_blocked_reason_falls_back_to_summary() {
2162 let resp = crate::parser::AgentResponse {
2163 status: "blocked".to_string(),
2164 error: None,
2165 summary: "Could not access required data sources in sandboxed environment".to_string(),
2166 remaining: vec![],
2167 ..Default::default()
2168 };
2169 let reason = agent_blocked_reason(&resp);
2170 assert_eq!(
2171 reason,
2172 "Could not access required data sources in sandboxed environment"
2173 );
2174 }
2175
2176 #[test]
2178 fn agent_blocked_reason_generic_fallback() {
2179 let resp = crate::parser::AgentResponse {
2180 status: "blocked".to_string(),
2181 ..Default::default()
2182 };
2183 let reason = agent_blocked_reason(&resp);
2184 assert_eq!(reason, "agent returned blocked status without a reason");
2185 }
2186
2187 #[test]
2189 fn classify_agent_blocked_passes_through() {
2190 let status = classify_final_status(&DecisionInput {
2191 agent_status: "blocked",
2192 ..Default::default()
2193 });
2194 assert_eq!(status, "blocked");
2195 }
2196
2197 #[test]
2198 fn classify_retryable_agent_blocked_reroutes() {
2199 let status = classify_final_status(&DecisionInput {
2200 agent_status: "blocked",
2201 is_retryable_blocked: true,
2202 ..Default::default()
2203 });
2204 assert_eq!(status, "new");
2205 }
2206
2207 #[test]
2208 fn retryable_blocked_reason_detects_transient_patterns() {
2209 assert!(is_retryable_blocked_reason(
2210 "Twitter access unavailable: service timed out"
2211 ));
2212 assert!(is_retryable_blocked_reason(
2213 "final commit blocked by git worktree lock permission error"
2214 ));
2215 assert!(is_retryable_blocked_reason(
2216 "could not commit due a worktree git lock permission error"
2217 ));
2218 assert!(is_retryable_blocked_reason(
2219 "git lockfile permission restrictions in this environment"
2220 ));
2221 assert!(is_retryable_blocked_reason(
2222 "API rate limit hit, try again later"
2223 ));
2224 assert!(is_retryable_blocked_reason(
2225 "Credential bean/hyperliquid-address exists in the pass store but GPG decryption fails in this agent session — no passphrase available."
2226 ));
2227 assert!(is_retryable_blocked_reason(
2228 "credential resolution failed via PasswordStoreProvider; AgentVaultProvider unavailable"
2229 ));
2230 }
2231
2232 #[test]
2233 fn retryable_blocked_reason_rejects_permanent_patterns() {
2234 assert!(!is_retryable_blocked_reason(
2235 "invalid request payload: malformed JSON"
2236 ));
2237 assert!(!is_retryable_blocked_reason(
2238 "resource not found in repository"
2239 ));
2240 }
2241}