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}
125
126fn classify_final_status(input: &DecisionInput<'_>) -> String {
133 if input.is_workflow_scope_failure || input.is_rebase_conflict_failure {
134 "blocked".to_string()
135 } else if input.push_failed {
136 if input.push_failures >= 3 {
137 "blocked".to_string()
138 } else {
139 "new".to_string()
140 }
141 } else if input.agent_status == "done" && input.has_pr {
142 "needs_review".to_string()
143 } else if input.agent_status == "done" && !input.has_pr && input.has_delegations {
144 "blocked".to_string()
145 } else if input.agent_status == "done"
146 && !input.has_pr
147 && input.has_pushed
148 && input.is_pr_base_invalid
149 {
150 "blocked".to_string()
154 } else if input.agent_status == "done" && !input.has_pr && input.has_pushed {
155 "routed".to_string()
156 } else if input.agent_status == "done" && input.requires_pr {
157 if input.is_same_agent || input.no_code_reroutes >= input.max_reroutes as u64 {
161 "blocked".to_string()
162 } else {
163 "new".to_string()
164 }
165 } else if input.agent_status == "done" && !input.has_pushed {
166 "done".to_string()
171 } else if input.is_completed_status && input.has_pr {
172 "needs_review".to_string()
174 } else if input.is_completed_status && !input.has_pr && input.has_delegations {
175 "blocked".to_string()
177 } else if input.is_completed_status
178 && !input.has_pr
179 && input.has_pushed
180 && input.is_pr_base_invalid
181 {
182 "blocked".to_string()
183 } else if input.is_completed_status && !input.has_pr && input.has_pushed {
184 "routed".to_string()
186 } else if input.is_completed_status && input.requires_pr {
187 if input.is_same_agent || input.no_code_reroutes >= input.max_reroutes as u64 {
190 "blocked".to_string()
191 } else {
192 "new".to_string()
193 }
194 } else if input.is_completed_status {
195 "done".to_string()
199 } else if input.agent_status == "blocked" && input.is_retryable_blocked {
200 "new".to_string()
201 } else if crate::status_heuristics::status_looks_like_descriptive_completion(input.agent_status)
202 {
203 "done".to_string()
204 } else {
205 input.agent_status.to_string()
206 }
207}
208
209struct StoreCtx<'a> {
213 store: &'a Option<Arc<TaskStore>>,
214 store_id_opt: Option<i64>,
215 repo: &'a str,
216 task_id: &'a str,
217}
218
219impl<'a> StoreCtx<'a> {
220 async fn set(&self, fields: &[(&str, serde_json::Value)]) {
222 if let Some(store_id) = self.store_id_opt {
223 let _ = store::store_set_by_id(&self.store.as_ref(), store_id, fields).await;
224 } else {
225 store::store_set(self.store, self.repo, self.task_id, fields).await;
226 }
227 }
228
229 async fn get_task(&self) -> Option<crate::store::Task> {
231 if let Some(store_id) = self.store_id_opt {
232 store::opt_store_get_task_by_id(self.store, store_id).await
233 } else {
234 store::opt_store_get_task(self.store, self.repo, self.task_id).await
235 }
236 }
237
238 async fn increment(&self, field: &str) -> anyhow::Result<u64> {
240 if let Some(store_id) = self.store_id_opt {
241 store::store_increment_by_id(self.store, store_id, field).await
242 } else {
243 store::store_increment(self.store, self.repo, self.task_id, field).await
244 }
245 }
246
247 async fn append_activity(
249 &self,
250 event_type: &str,
251 agent: Option<&str>,
252 model: Option<&str>,
253 details: Option<&serde_json::Value>,
254 ) {
255 if let Some(store_id) = self.store_id_opt {
256 if let Some(ref s) = self.store {
257 if let Err(e) = s
258 .append_activity(store_id, event_type, None, None, agent, model, details)
259 .await
260 {
261 tracing::warn!(
262 task_id = self.task_id,
263 event_type,
264 error = %e,
265 "store append_activity failed"
266 );
267 }
268 }
269 } else {
270 store::store_log_activity(
271 self.store,
272 self.repo,
273 self.task_id,
274 event_type,
275 None,
276 None,
277 agent,
278 model,
279 details,
280 )
281 .await;
282 }
283 }
284}
285
286#[derive(Default)]
290struct GitOpsResult {
291 has_pr: bool,
292 has_pushed: bool,
293 has_commits: bool,
294 pr_base_invalid: bool,
298}
299
300struct PushFailureState {
302 push_failed: bool,
303 is_workflow_scope_failure: bool,
304 is_rebase_conflict_failure: bool,
305 push_failures: u64,
307 stored_last_error: String,
309}
310
311async fn auto_commit_changes(
315 ctx: &StoreCtx<'_>,
316 work_dir: &Path,
317 task_title: &str,
318 agent_name: &str,
319 new_attempts: u32,
320) {
321 if let Err(e) =
322 git_ops::auto_commit(work_dir, ctx.task_id, task_title, agent_name, new_attempts).await
323 {
324 tracing::error!(task_id = ctx.task_id, error = ?e, "auto commit failed");
325 ctx.set(&[(
326 "last_error",
327 serde_json::json!(format!("auto commit failed: {e}")),
328 )])
329 .await;
330 }
331}
332
333async fn check_commits_and_clear_stale_errors(
338 ctx: &StoreCtx<'_>,
339 work_dir: &Path,
340 default_branch: &str,
341) -> bool {
342 let has_commits = git_ops::has_commits_ahead(work_dir, default_branch).await;
343 if !has_commits {
344 tracing::info!(
345 task_id = ctx.task_id,
346 "no commits ahead of default branch, skipping push + PR"
347 );
348 let last_err = ctx
350 .get_task()
351 .await
352 .map(|t| t.last_error)
353 .unwrap_or_default();
354 if last_err.contains("push failed") {
355 ctx.set(&[("last_error", serde_json::json!(""))]).await;
356 }
357 }
358 has_commits
359}
360
361enum PushResult {
373 Success,
374 Rebased,
375 Failed,
376 NoCommits, WorktreeMissing, }
379
380fn is_missing_path_error(err: &anyhow::Error) -> bool {
381 err.chain().any(|cause| {
382 cause
383 .downcast_ref::<std::io::Error>()
384 .is_some_and(|ioe| ioe.kind() == std::io::ErrorKind::NotFound)
385 }) || err
386 .to_string()
387 .to_ascii_lowercase()
388 .contains("no such file or directory")
389}
390
391async fn recover_missing_worktree_for_push(
392 ctx: &StoreCtx<'_>,
393 wt: &worktree::WorktreeSetup,
394 task_title: &str,
395 agent_name: &str,
396 model_name: Option<&str>,
397) -> anyhow::Result<worktree::WorktreeSetup> {
398 tracing::warn!(
399 task_id = ctx.task_id,
400 worktree = %wt.work_dir.display(),
401 branch = wt.branch,
402 "worktree missing during push; attempting one-time recovery"
403 );
404 ctx.append_activity(
405 "push_recover_worktree",
406 Some(agent_name),
407 model_name,
408 Some(&serde_json::json!({
409 "status": "start",
410 "worktree": wt.work_dir.display().to_string(),
411 "branch": wt.branch,
412 })),
413 )
414 .await;
415
416 let recovered = worktree::setup_worktree(
417 ctx.task_id,
418 task_title,
419 &wt.main_project_dir,
420 ctx.store,
421 ctx.repo,
422 )
423 .await?;
424
425 if !recovered.work_dir.exists() {
426 anyhow::bail!(
427 "recovered worktree path is still missing: {}",
428 recovered.work_dir.display()
429 );
430 }
431
432 ctx.append_activity(
433 "push_recover_worktree",
434 Some(agent_name),
435 model_name,
436 Some(&serde_json::json!({
437 "status": "ok",
438 "worktree": recovered.work_dir.display().to_string(),
439 "branch": recovered.branch,
440 })),
441 )
442 .await;
443
444 Ok(recovered)
445}
446
447async fn push_branch_with_log(
448 ctx: &StoreCtx<'_>,
449 work_dir: &Path,
450 branch: &str,
451 default_branch: &str,
452 agent_name: &str,
453 model_name: Option<&str>,
454) -> PushResult {
455 match git_ops::push_branch(work_dir, branch, default_branch).await {
456 Ok(_) => {
457 ctx.append_activity(
458 "push",
459 Some(agent_name),
460 model_name,
461 Some(&serde_json::json!({
462 "status": "ok",
463 "branch": branch,
464 "default_branch": default_branch,
465 })),
466 )
467 .await;
468 ctx.set(&[
471 ("last_error", serde_json::json!("")),
472 ("push_failures", serde_json::json!(0)),
473 ])
474 .await;
475 PushResult::Success
476 }
477 Err(e) => {
478 let err_str = e.to_string();
479 tracing::error!(task_id = ctx.task_id, error = ?e, "push failed");
480
481 if is_missing_path_error(&e) && !work_dir.exists() {
482 tracing::warn!(
483 task_id = ctx.task_id,
484 worktree = %work_dir.display(),
485 "push failed because worktree path disappeared; will attempt recovery"
486 );
487 ctx.append_activity(
488 "push",
489 Some(agent_name),
490 model_name,
491 Some(&serde_json::json!({
492 "status": "retry",
493 "branch": branch,
494 "default_branch": default_branch,
495 "reason": "worktree_missing",
496 "error": err_str,
497 })),
498 )
499 .await;
500 return PushResult::WorktreeMissing;
501 }
502
503 let is_diverged = err_str.contains("non-fast-forward")
505 || err_str.contains("rejected")
506 || err_str.contains("fetch first")
507 || err_str.contains("behind");
508
509 if is_diverged {
510 tracing::info!(
511 task_id = ctx.task_id,
512 branch = branch,
513 "attempting fetch and rebase to recover from diverged branch"
514 );
515
516 match git_ops::rebase_on_branch(work_dir, branch).await {
518 Ok(true) => {
519 tracing::info!(
521 task_id = ctx.task_id,
522 branch = branch,
523 "rebase succeeded — task will be re-routed to new agent"
524 );
525 ctx.append_activity(
526 "push_rebased",
527 Some(agent_name),
528 model_name,
529 Some(&serde_json::json!({
530 "status": "rebased",
531 "branch": branch,
532 "reason": "diverged_from_remote",
533 })),
534 )
535 .await;
536 ctx.set(&[(
537 "last_error",
538 serde_json::json!(
539 "push failed: branch diverged from remote — rebased successfully, will retry with new agent"
540 ),
541 )])
542 .await;
543 return PushResult::Rebased;
544 }
545 Ok(false) => {
546 tracing::info!(
548 task_id = ctx.task_id,
549 branch = branch,
550 "no local commits after fetch — marking as success"
551 );
552 ctx.append_activity(
553 "push",
554 Some(agent_name),
555 model_name,
556 Some(&serde_json::json!({
557 "status": "ok",
558 "branch": branch,
559 "reason": "no_commits_after_fetch",
560 })),
561 )
562 .await;
563 ctx.set(&[
564 ("last_error", serde_json::json!("")),
565 ("push_failures", serde_json::json!(0)),
566 ])
567 .await;
568 return PushResult::Success;
569 }
570 Err(rebase_err) => {
571 tracing::error!(
573 task_id = ctx.task_id,
574 branch = branch,
575 error = ?rebase_err,
576 "rebase failed — blocking task for human intervention"
577 );
578 ctx.append_activity(
579 "push_rebased",
580 Some(agent_name),
581 model_name,
582 Some(&serde_json::json!({
583 "status": "error",
584 "branch": branch,
585 "error": rebase_err.to_string(),
586 })),
587 )
588 .await;
589 ctx.set(&[(
590 "last_error",
591 serde_json::json!(format!(
592 "push failed and rebase failed: {rebase_err} — manual resolution required"
593 )),
594 )])
595 .await;
596 return PushResult::Failed;
597 }
598 }
599 }
600
601 ctx.append_activity(
603 "push",
604 Some(agent_name),
605 model_name,
606 Some(&serde_json::json!({
607 "status": "error",
608 "branch": branch,
609 "default_branch": default_branch,
610 "error": err_str,
611 })),
612 )
613 .await;
614 ctx.set(&[("last_error", serde_json::json!(format!("push failed: {e}")))])
615 .await;
616 PushResult::Failed
617 }
618 }
619}
620
621async fn create_pr_with_log(
628 ctx: &StoreCtx<'_>,
629 wt: &worktree::WorktreeSetup,
630 task_title: &str,
631 resp: &AgentResponse,
632 agent_name: &str,
633 model_name: Option<&str>,
634 mut has_pushed: bool,
635) -> (bool, bool, bool) {
636 match git_ops::create_pr_if_needed(
637 &wt.work_dir,
638 &wt.branch,
639 task_title,
640 &resp.summary,
641 &resp.accomplished,
642 &resp.remaining,
643 &resp.files,
644 ctx.task_id,
645 agent_name,
646 model_name,
647 ctx.repo,
648 &wt.default_branch,
649 )
650 .await
651 {
652 Ok(ref url) => {
653 match crate::engine::review::parse_pr_number_from_url(url) {
657 Ok(pr_num) => {
658 if let Err(e) = store::store_set_result(
659 ctx.store,
660 ctx.repo,
661 ctx.task_id,
662 &[("pr_number", serde_json::json!(pr_num as i64))],
663 )
664 .await
665 {
666 tracing::error!(
667 task_id = ctx.task_id,
668 pr_url = %url,
669 err = %e,
670 "CRITICAL: failed to save pr_number to store — downstream review gate may trigger duplicate gh pr create"
671 );
672 }
673 }
674 Err(e) => {
675 tracing::warn!(
676 task_id = ctx.task_id,
677 pr_url = %url,
678 error = %e,
679 "PR created but failed to parse PR number from URL"
680 );
681 }
682 }
683 ctx.append_activity(
684 "pr_create",
685 Some(agent_name),
686 model_name,
687 Some(&serde_json::json!({"status": "created", "url": url})),
688 )
689 .await;
690 (true, has_pushed, false)
691 }
692 Err(e) => {
693 let err_str = format!("{e}");
694 tracing::error!(task_id = ctx.task_id, error = ?e, "create PR failed");
695 ctx.append_activity(
696 "pr_create",
697 Some(agent_name),
698 model_name,
699 Some(&serde_json::json!({
700 "status": "error",
701 "branch": wt.branch,
702 "error": err_str.clone(),
703 })),
704 )
705 .await;
706 ctx.set(&[(
707 "last_error",
708 serde_json::json!(format!("create PR failed: {e}")),
709 )])
710 .await;
711 let mut pr_base_invalid = false;
721 if agents::patterns::detect_auth_error(&err_str).is_some() {
722 tracing::error!(
723 task_id = ctx.task_id,
724 "PR creation failed with GitHub auth error — blocking task"
725 );
726 pr_base_invalid = true;
727 } else if err_str.contains("422") {
728 if err_str.contains("No commits between") || err_str.contains("head") {
729 tracing::info!(
730 task_id = ctx.task_id,
731 "PR creation returned 422/no-commits/head-invalid — agent made no code changes or branch merged, marking done"
732 );
733 has_pushed = false;
734 } else if git_ops::is_invalid_base_error(&err_str) {
735 tracing::error!(
736 task_id = ctx.task_id,
737 "PR creation failed with invalid base branch — blocking task"
738 );
739 pr_base_invalid = true;
740 }
741 }
742 (false, has_pushed, pr_base_invalid)
743 }
744 }
745}
746
747async fn run_git_ops(
749 ctx: &StoreCtx<'_>,
750 wt: &worktree::WorktreeSetup,
751 task_title: &str,
752 resp: &AgentResponse,
753 agent_name: &str,
754 model_name: Option<&str>,
755 new_attempts: u32,
756) -> GitOpsResult {
757 auto_commit_changes(ctx, &wt.work_dir, task_title, agent_name, new_attempts).await;
758
759 let mut has_commits =
760 check_commits_and_clear_stale_errors(ctx, &wt.work_dir, &wt.default_branch).await;
761
762 let push_result = if !has_commits {
766 store::store_log_activity(
767 ctx.store,
768 ctx.repo,
769 ctx.task_id,
770 "push",
771 None,
772 None,
773 Some(agent_name),
774 model_name,
775 Some(&serde_json::json!({
776 "status": "skipped",
777 "reason": "no_commits_ahead",
778 "branch": wt.branch,
779 "default_branch": wt.default_branch,
780 })),
781 )
782 .await;
783 PushResult::NoCommits
784 } else {
785 push_branch_with_log(
786 ctx,
787 &wt.work_dir,
788 &wt.branch,
789 &wt.default_branch,
790 agent_name,
791 model_name,
792 )
793 .await
794 };
795 let push_result = if matches!(push_result, PushResult::WorktreeMissing) {
796 match recover_missing_worktree_for_push(ctx, wt, task_title, agent_name, model_name).await {
797 Ok(recovered) => {
798 let wt_for_ops = &recovered;
799 has_commits = check_commits_and_clear_stale_errors(
800 ctx,
801 &wt_for_ops.work_dir,
802 &wt_for_ops.default_branch,
803 )
804 .await;
805 if !has_commits {
806 PushResult::NoCommits
807 } else {
808 push_branch_with_log(
809 ctx,
810 &wt_for_ops.work_dir,
811 &wt_for_ops.branch,
812 &wt_for_ops.default_branch,
813 agent_name,
814 model_name,
815 )
816 .await
817 }
818 }
819 Err(e) => {
820 tracing::error!(
821 task_id = ctx.task_id,
822 err = %e,
823 "worktree recovery before push failed"
824 );
825 ctx.append_activity(
826 "push_recover_worktree",
827 Some(agent_name),
828 model_name,
829 Some(&serde_json::json!({
830 "status": "error",
831 "worktree": wt.work_dir.display().to_string(),
832 "branch": wt.branch,
833 "error": e.to_string(),
834 })),
835 )
836 .await;
837 ctx.set(&[(
838 "last_error",
839 serde_json::json!(format!(
840 "push failed: worktree disappeared and recovery failed: {e}"
841 )),
842 )])
843 .await;
844 PushResult::Failed
845 }
846 }
847 } else {
848 push_result
849 };
850
851 let mut has_pushed = matches!(push_result, PushResult::Success);
852 let mut has_pr = false;
853
854 if has_commits && matches!(push_result, PushResult::Failed) {
856 tracing::warn!(
857 task_id = ctx.task_id,
858 "skipping PR creation due to push failure"
859 );
860 } else if matches!(push_result, PushResult::Rebased) {
861 tracing::info!(
864 task_id = ctx.task_id,
865 "skipping PR creation after successful rebase — task will be re-routed"
866 );
867 } else if matches!(push_result, PushResult::NoCommits) {
868 tracing::info!(
869 task_id = ctx.task_id,
870 "no commits ahead of default branch, skipping push + PR"
871 );
872 } else if !has_pushed {
873 } else if ctx.repo.is_empty() {
875 tracing::warn!(
876 task_id = ctx.task_id,
877 "skipping PR creation — repo is empty (internal task?)"
878 );
879 } else {
880 let (pr, updated_pushed, pr_base_invalid) = create_pr_with_log(
881 ctx, wt, task_title, resp, agent_name, model_name, has_pushed,
882 )
883 .await;
884 has_pr = pr;
885 has_pushed = updated_pushed;
886 return GitOpsResult {
887 has_pr,
888 has_pushed,
889 has_commits,
890 pr_base_invalid,
891 };
892 }
893
894 GitOpsResult {
895 has_pr,
896 has_pushed,
897 has_commits,
898 pr_base_invalid: false,
899 }
900}
901
902async fn detect_push_failure_state(
906 ctx: &StoreCtx<'_>,
907 has_commits: bool,
908 has_pushed: bool,
909) -> PushFailureState {
910 let stored_last_error = ctx
912 .get_task()
913 .await
914 .map(|t| t.last_error)
915 .unwrap_or_default();
916
917 let push_failed = !has_pushed && has_commits && stored_last_error.contains("push failed");
918
919 let is_workflow_scope_failure = push_failed
922 && (stored_last_error.contains("workflow` scope")
923 || stored_last_error.contains("workflow' scope")
924 || (stored_last_error.contains("refusing to allow")
925 && stored_last_error.contains("workflow")));
926
927 let is_rebased_recovery = push_failed && stored_last_error.contains("rebased successfully");
931 let is_rebase_conflict_failure =
932 push_failed && stored_last_error.contains("push failed and rebase failed:");
933
934 let push_failures = if push_failed
940 && !is_workflow_scope_failure
941 && !is_rebased_recovery
942 && !is_rebase_conflict_failure
943 {
944 match ctx.increment("push_failures").await {
945 Ok(v) => v,
946 Err(e) => {
947 tracing::warn!(
948 task_id = ctx.task_id,
949 err = %e,
950 "failed to increment push_failures — reading current value from store"
951 );
952 ctx.get_task()
954 .await
955 .map(|t| (t.push_failures.max(0) as u64) + 1)
956 .unwrap_or(1) }
958 }
959 } else {
960 0
961 };
962
963 PushFailureState {
964 push_failed,
965 is_workflow_scope_failure,
966 is_rebase_conflict_failure,
967 push_failures,
968 stored_last_error,
969 }
970}
971
972async fn detect_no_code_reroutes(
975 ctx: &StoreCtx<'_>,
976 is_no_code_reroute: bool,
977 new_attempts: u32,
978 max_reroutes: u32,
979) -> u64 {
980 if !is_no_code_reroute {
981 return 0;
982 }
983 tracing::warn!(
984 task_id = ctx.task_id,
985 attempts = new_attempts,
986 max_reroutes,
987 "agent reported done but produced no code changes on external task requiring PR"
988 );
989 match ctx.increment("no_code_reroutes").await {
990 Ok(v) => v,
991 Err(e) => {
992 tracing::warn!(
993 task_id = ctx.task_id,
994 err = %e,
995 "failed to increment no_code_reroutes — reading current value from store"
996 );
997 ctx.get_task()
999 .await
1000 .map(|t| (t.no_code_reroutes.max(0) as u64) + 1)
1001 .unwrap_or(1) }
1003 }
1004}
1005
1006fn agent_blocked_reason(resp: &crate::parser::AgentResponse) -> String {
1013 let summary_opt = if !resp.summary.is_empty() {
1014 Some(resp.summary.as_str())
1015 } else {
1016 None
1017 };
1018 let base = resp
1019 .error
1020 .as_deref()
1021 .filter(|s| !s.is_empty())
1022 .or(summary_opt)
1023 .unwrap_or("agent returned blocked status without a reason");
1024 if resp.remaining.is_empty() {
1025 base.to_string()
1026 } else {
1027 format!("{base}. Remaining: {}", resp.remaining.join("; "))
1028 }
1029}
1030
1031fn is_retryable_blocked_reason(reason: &str) -> bool {
1037 let r = reason.to_lowercase();
1038
1039 let permanent_patterns = [
1040 "not found",
1041 "does not exist",
1042 "invalid",
1043 "malformed",
1044 "syntax error",
1045 "unsupported",
1046 "unimplemented",
1047 "manual intervention required",
1048 "human intervention required",
1049 ];
1050 if permanent_patterns.iter().any(|p| r.contains(p)) {
1051 return false;
1052 }
1053
1054 let transient_patterns = [
1055 "unavailable",
1056 "temporarily unavailable",
1057 "timeout",
1058 "timed out",
1059 "connection reset",
1060 "connection refused",
1061 "network error",
1062 "rate limit",
1063 "try again",
1064 "service down",
1065 "permission denied",
1066 "worktree lock",
1067 "git lock",
1068 "lockfile permission",
1069 "lock permission",
1070 "resource busy",
1071 "could not resolve host",
1072 "gpg decryption fails",
1075 "gpg decryption failed",
1076 "gpg agent",
1077 "no passphrase available",
1078 "passphrase unavailable",
1079 "pass store",
1080 "passwordstoreprovider",
1081 "agentvaultprovider",
1082 "onepasswordprovider",
1083 "decryption failed in this agent session",
1084 ];
1085 transient_patterns.iter().any(|p| r.contains(p))
1086}
1087
1088#[allow(clippy::too_many_arguments)]
1090async fn apply_post_decision_effects(
1091 ctx: &StoreCtx<'_>,
1092 final_status: &str,
1093 resp_status: &str,
1094 push_state: &PushFailureState,
1095 is_no_code_reroute: bool,
1096 has_pr: bool,
1097 has_pushed: bool,
1098 is_pr_base_invalid: bool,
1099 has_delegations: bool,
1100 is_external: bool,
1101 no_code_reroutes: u64,
1102 max_reroutes: u32,
1103 agent_name: &str,
1104) {
1105 if push_state.is_workflow_scope_failure {
1106 tracing::error!(
1108 task_id = ctx.task_id,
1109 "push failed: token lacks `workflow` OAuth scope — blocking immediately \
1110 (rerouting would not help)"
1111 );
1112 ctx.set(&[(
1113 "last_error",
1114 serde_json::json!(format!(
1115 "push failed: GitHub token lacks `workflow` OAuth scope. \
1116 The agent modified .github/workflows/ files but the token cannot push them. \
1117 Fix: add `workflow` scope to your GitHub token, or use a GitHub App for auth. \
1118 Original error: {}",
1119 push_state.stored_last_error
1120 )),
1121 )])
1122 .await;
1123 } else if push_state.is_rebase_conflict_failure {
1124 tracing::error!(
1125 task_id = ctx.task_id,
1126 "push failed and automatic rebase recovery failed — blocking for human intervention"
1127 );
1128 ctx.set(&[
1129 ("agent", serde_json::json!(null)),
1130 ("model", serde_json::json!(null)),
1131 ])
1132 .await;
1133 } else if push_state.push_failed {
1134 let is_rebased_recovery = push_state
1136 .stored_last_error
1137 .contains("rebased successfully");
1138 ctx.set(&[
1140 ("agent", serde_json::json!(null)),
1141 ("model", serde_json::json!(null)),
1142 ])
1143 .await;
1144 let push_failures = push_state.push_failures;
1145 if is_rebased_recovery {
1146 tracing::info!(
1148 task_id = ctx.task_id,
1149 "push failed but rebase succeeded — rerouting to different agent with up-to-date history"
1150 );
1151 } else if push_failures >= 3 {
1152 tracing::error!(
1153 task_id = ctx.task_id,
1154 push_failures,
1155 "push failed {push_failures} times — blocking for human intervention"
1156 );
1157 } else {
1158 tracing::warn!(
1159 task_id = ctx.task_id,
1160 push_failures,
1161 "agent done but push failed ({push_failures}/3) — rerouting to different agent"
1162 );
1163 }
1164 } else if resp_status == "done" && !has_pr && has_delegations {
1165 tracing::info!(
1166 task_id = ctx.task_id,
1167 "agent reported done with delegations but no PR — setting blocked"
1168 );
1169 } else if (resp_status == "done" || resp_status == "completed")
1170 && !has_pr
1171 && has_pushed
1172 && is_pr_base_invalid
1173 {
1174 tracing::error!(
1175 task_id = ctx.task_id,
1176 "PR creation failed with invalid base branch — blocking for human intervention"
1177 );
1178 ctx.set(&[(
1179 "last_error",
1180 serde_json::json!(
1181 "PR creation failed terminally (invalid base branch or GitHub auth error). The agent's commits were pushed successfully. \
1182 Check the repository default branch and the GitHub token, then create the PR manually or unblock the task."
1183 ),
1184 )])
1185 .await;
1186 } else if resp_status == "done" && !has_pr && has_pushed {
1187 tracing::warn!(
1189 task_id = ctx.task_id,
1190 "agent done, commits pushed, but PR creation failed — re-dispatching as routed"
1191 );
1192 } else if is_no_code_reroute {
1193 if final_status == "blocked" {
1198 let prev_no_code_agent = ctx
1202 .get_task()
1203 .await
1204 .map(|t| t.no_code_last_agent.clone())
1205 .unwrap_or_default();
1206 let is_same_agent = !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name;
1207
1208 let msg = if is_same_agent {
1209 tracing::error!(
1210 task_id = ctx.task_id,
1211 agent = %agent_name,
1212 "blocking same-agent no-code loop: agent {} would be selected again",
1213 agent_name
1214 );
1215 format!(
1216 "agent {} completed without code changes twice — same-agent loop detected, blocking for human review",
1217 agent_name
1218 )
1219 } else {
1220 tracing::error!(
1221 task_id = ctx.task_id,
1222 no_code_reroutes,
1223 max_reroutes,
1224 "blocking no-code reroute: reached max reroute attempts ({}/{}) for no-code-result",
1225 no_code_reroutes, max_reroutes
1226 );
1227 format!(
1228 "agent completed without code changes after {}/{} reroute attempts on external task requiring PR",
1229 no_code_reroutes, max_reroutes
1230 )
1231 };
1232 ctx.set(&[("last_error", serde_json::json!(msg))]).await;
1233 } else {
1234 ctx.set(&[(
1236 "last_error",
1237 serde_json::json!(
1238 "agent completed without code changes on external task requiring PR"
1239 ),
1240 )])
1241 .await;
1242 }
1243
1244 ctx.set(&[
1249 ("no_code_last_agent", serde_json::json!(agent_name)),
1250 ("agent", serde_json::json!(null)),
1251 ("model", serde_json::json!(null)),
1252 ])
1253 .await;
1254 } else if resp_status == "done" && !has_pr && is_external {
1255 tracing::info!(
1258 task_id = ctx.task_id,
1259 "external task with non-code labels reported done — marking done without PR"
1260 );
1261 } else if resp_status == "done" && !has_pr {
1262 tracing::info!(
1263 task_id = ctx.task_id,
1264 "internal task reported done with no PR — marking done"
1265 );
1266 }
1267}
1268
1269async fn store_token_usage(
1271 ctx: &StoreCtx<'_>,
1272 input_tokens: Option<u64>,
1273 output_tokens: Option<u64>,
1274 model_name: Option<&str>,
1275) {
1276 let (Some(input), Some(output)) = (input_tokens, output_tokens) else {
1277 return;
1278 };
1279 let model = model_name.unwrap_or("haiku");
1280 if let Some(ref st) = ctx.store {
1281 if let Some(store_id) = ctx.store_id_opt {
1282 if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1283 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1284 }
1285 } else if let Ok(Some(store_id)) = st.resolve_task_id(ctx.repo, ctx.task_id).await {
1286 if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1287 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1288 }
1289 }
1290 }
1291}
1292
1293#[allow(clippy::too_many_arguments)]
1299pub async fn handle_success(
1300 task_id: &str,
1301 parsed: agents::ParsedResponse,
1302 wt: &worktree::WorktreeSetup,
1303 task_title: &str,
1304 agent_name: &str,
1305 model_name: Option<&str>,
1306 new_attempts: u32,
1307 repo: &str,
1308 store: &Option<Arc<TaskStore>>,
1309 raw_stdout: &str,
1310) -> anyhow::Result<(String, bool)> {
1311 let input_tokens = parsed.input_tokens;
1313 let output_tokens = parsed.output_tokens;
1314 let resp = parsed.response;
1315
1316 tracing::info!(
1317 task_id,
1318 status = resp.status,
1319 summary = resp.summary,
1320 "agent completed successfully"
1321 );
1322
1323 let store_id_opt = store::resolve_store_id(store, repo, task_id).await;
1326 let ctx = StoreCtx {
1327 store,
1328 store_id_opt,
1329 repo,
1330 task_id,
1331 };
1332
1333 ctx.set(&[("network_retries", serde_json::json!(0))]).await;
1334
1335 let git = if resp.status == "done"
1337 || resp.status == "completed"
1338 || resp.status == "in_progress"
1339 || resp.status == "needs_review"
1340 {
1341 run_git_ops(
1342 &ctx,
1343 wt,
1344 task_title,
1345 &resp,
1346 agent_name,
1347 model_name,
1348 new_attempts,
1349 )
1350 .await
1351 } else {
1352 GitOpsResult::default()
1353 };
1354
1355 if !resp.delegations.is_empty() {
1357 ctx.set(&[("delegations", serde_json::json!(resp.delegations))])
1358 .await;
1359 }
1360
1361 let has_delegations = !resp.delegations.is_empty();
1368
1369 let push_state = detect_push_failure_state(&ctx, git.has_commits, git.has_pushed).await;
1371
1372 let is_external = !task_id.starts_with("internal:");
1380 let requires_pr = is_external && !git.has_pr && !resp.status.starts_with("needs_review");
1381
1382 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
1385 .ok()
1386 .and_then(|s| s.parse().ok())
1387 .or_else(|| {
1388 config::get("workflow.max_attempts")
1389 .ok()
1390 .and_then(|s| s.parse().ok())
1391 })
1392 .unwrap_or(3);
1393
1394 let is_no_code_reroute = !push_state.is_workflow_scope_failure
1397 && !push_state.push_failed
1398 && (resp.status == "done" || resp.status == "completed")
1399 && !has_delegations
1400 && !git.has_pushed
1401 && requires_pr;
1402
1403 let no_code_reroutes =
1404 detect_no_code_reroutes(&ctx, is_no_code_reroute, new_attempts, max_reroutes).await;
1405
1406 let blocked_reason = if resp.status == "blocked" {
1407 Some(agent_blocked_reason(&resp))
1408 } else {
1409 None
1410 };
1411 let is_retryable_blocked = blocked_reason
1412 .as_deref()
1413 .map(is_retryable_blocked_reason)
1414 .unwrap_or(false);
1415
1416 let is_same_agent = if is_no_code_reroute {
1421 let prev_no_code_agent = ctx
1422 .get_task()
1423 .await
1424 .map(|t| t.no_code_last_agent.clone())
1425 .unwrap_or_default();
1426 !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name
1427 } else {
1428 false
1429 };
1430
1431 let final_status_owned = classify_final_status(&DecisionInput {
1434 agent_status: &resp.status,
1435 is_workflow_scope_failure: push_state.is_workflow_scope_failure,
1436 is_rebase_conflict_failure: push_state.is_rebase_conflict_failure,
1437 push_failed: push_state.push_failed,
1438 push_failures: push_state.push_failures,
1439 has_pr: git.has_pr,
1440 has_delegations,
1441 has_pushed: git.has_pushed,
1442 is_pr_base_invalid: git.pr_base_invalid,
1443 requires_pr,
1444 no_code_reroutes,
1445 max_reroutes,
1446 is_same_agent,
1447 is_completed_status: resp.status == "completed",
1448 is_retryable_blocked,
1449 });
1450 let final_status = final_status_owned.as_str();
1451
1452 apply_post_decision_effects(
1455 &ctx,
1456 final_status,
1457 &resp.status,
1458 &push_state,
1459 is_no_code_reroute,
1460 git.has_pr,
1461 git.has_pushed,
1462 git.pr_base_invalid,
1463 has_delegations,
1464 is_external,
1465 no_code_reroutes,
1466 max_reroutes,
1467 agent_name,
1468 )
1469 .await;
1470
1471 if resp.status == "blocked" {
1477 let reason = blocked_reason.unwrap_or_else(|| agent_blocked_reason(&resp));
1478 if final_status == "new" {
1479 crate::engine::cooldown::record_agent_failure_with_message(agent_name, &reason).await;
1482 if let Some(m) = model_name {
1483 crate::engine::cooldown::record_model_failure(agent_name, m).await;
1484 }
1485 ctx.set(&[
1486 (
1487 "last_error",
1488 serde_json::json!(format!(
1489 "transient blocker (auto-retry scheduled): {reason}"
1490 )),
1491 ),
1492 ("agent", serde_json::json!(null)),
1493 ("model", serde_json::json!(null)),
1494 ])
1495 .await;
1496 tracing::warn!(
1497 task_id = ctx.task_id,
1498 reason = %reason,
1499 "agent returned retryable blocked status — rerouting as new with cooldown"
1500 );
1501 } else if final_status == "blocked" {
1502 ctx.set(&[("last_error", serde_json::json!(reason))]).await;
1503 }
1504 }
1505
1506 ctx.set(&[("summary", serde_json::json!(resp.summary))])
1507 .await;
1508
1509 store_token_usage(
1511 &ctx,
1512 input_tokens.or(resp.input_tokens),
1513 output_tokens.or(resp.output_tokens),
1514 model_name,
1515 )
1516 .await;
1517
1518 response::store_learnings_from_response(
1520 task_id,
1521 new_attempts,
1522 agent_name,
1523 model_name,
1524 &resp,
1525 resp.error.as_deref(),
1526 store,
1527 repo,
1528 raw_stdout,
1529 )
1530 .await;
1531
1532 Ok((final_status.to_string(), push_state.push_failed))
1535}
1536
1537#[cfg(test)]
1539mod tests {
1540 use super::*;
1541
1542 #[test]
1543 fn missing_path_error_detects_io_not_found() {
1544 let err = anyhow::Error::from(std::io::Error::new(
1545 std::io::ErrorKind::NotFound,
1546 "simulated missing path",
1547 ));
1548 assert!(is_missing_path_error(&err));
1549 }
1550
1551 #[test]
1552 fn missing_path_error_detects_message_fallback() {
1553 let err = anyhow::anyhow!("push failed: No such file or directory (os error 2)");
1554 assert!(is_missing_path_error(&err));
1555 }
1556
1557 #[test]
1561 fn classify_workflow_scope_failure_blocks() {
1562 let status = classify_final_status(&DecisionInput {
1563 is_workflow_scope_failure: true,
1564 push_failed: true,
1565 push_failures: 1,
1566 ..Default::default()
1567 });
1568 assert_eq!(status, "blocked");
1569 }
1570
1571 #[test]
1573 fn classify_rebase_conflict_failure_blocks() {
1574 let status = classify_final_status(&DecisionInput {
1575 is_rebase_conflict_failure: true,
1576 push_failed: true,
1577 ..Default::default()
1578 });
1579 assert_eq!(status, "blocked");
1580 }
1581
1582 #[test]
1584 fn classify_push_failed_under_threshold_reroutes() {
1585 let status = classify_final_status(&DecisionInput {
1586 push_failed: true,
1587 push_failures: 2, ..Default::default()
1589 });
1590 assert_eq!(status, "new");
1591 }
1592
1593 #[test]
1595 fn classify_push_failed_at_threshold_blocks() {
1596 let status = classify_final_status(&DecisionInput {
1597 push_failed: true,
1598 push_failures: 3, ..Default::default()
1600 });
1601 assert_eq!(status, "blocked");
1602 }
1603
1604 #[test]
1606 fn classify_done_with_pr_goes_to_needs_review() {
1607 let status = classify_final_status(&DecisionInput {
1608 agent_status: "done",
1609 has_pr: true,
1610 ..Default::default()
1611 });
1612 assert_eq!(status, "needs_review");
1613 }
1614
1615 #[test]
1617 fn classify_done_with_delegations_no_pr_blocks() {
1618 let status = classify_final_status(&DecisionInput {
1619 agent_status: "done",
1620 has_delegations: true,
1621 ..Default::default()
1622 });
1623 assert_eq!(status, "blocked");
1624 }
1625
1626 #[test]
1628 fn classify_done_pushed_no_pr_reroutes_for_pr_creation() {
1629 let status = classify_final_status(&DecisionInput {
1630 agent_status: "done",
1631 has_pushed: true,
1632 ..Default::default()
1633 });
1634 assert_eq!(status, "routed");
1635 }
1636
1637 #[test]
1639 fn classify_done_pushed_pr_base_invalid_blocks() {
1640 let status = classify_final_status(&DecisionInput {
1641 agent_status: "done",
1642 has_pushed: true,
1643 is_pr_base_invalid: true,
1644 ..Default::default()
1645 });
1646 assert_eq!(status, "blocked");
1647 }
1648
1649 #[test]
1651 fn classify_completed_pushed_pr_base_invalid_blocks() {
1652 let status = classify_final_status(&DecisionInput {
1653 agent_status: "completed",
1654 is_completed_status: true,
1655 has_pushed: true,
1656 is_pr_base_invalid: true,
1657 ..Default::default()
1658 });
1659 assert_eq!(status, "blocked");
1660 }
1661
1662 #[test]
1664 fn classify_done_requires_pr_under_max_reroutes() {
1665 let status = classify_final_status(&DecisionInput {
1666 agent_status: "done",
1667 requires_pr: true,
1668 no_code_reroutes: 1,
1669 max_reroutes: 3,
1670 is_same_agent: false,
1671 ..Default::default()
1672 });
1673 assert_eq!(status, "new");
1674 }
1675
1676 #[test]
1678 fn classify_done_requires_pr_same_agent_blocks_immediately() {
1679 let status = classify_final_status(&DecisionInput {
1680 agent_status: "done",
1681 requires_pr: true,
1682 no_code_reroutes: 1,
1683 max_reroutes: 3,
1684 is_same_agent: true, ..Default::default()
1686 });
1687 assert_eq!(status, "blocked");
1688 }
1689
1690 #[test]
1692 fn classify_done_requires_pr_at_max_reroutes_blocks() {
1693 let status = classify_final_status(&DecisionInput {
1694 agent_status: "done",
1695 requires_pr: true,
1696 no_code_reroutes: 3,
1697 max_reroutes: 3,
1698 is_same_agent: false,
1699 ..Default::default()
1700 });
1701 assert_eq!(status, "blocked");
1702 }
1703
1704 #[test]
1706 fn classify_done_requires_pr_exceeds_max_reroutes_blocks() {
1707 let status = classify_final_status(&DecisionInput {
1708 agent_status: "done",
1709 requires_pr: true,
1710 no_code_reroutes: 5,
1711 max_reroutes: 3,
1712 is_same_agent: false,
1713 ..Default::default()
1714 });
1715 assert_eq!(status, "blocked");
1716 }
1717
1718 #[test]
1720 fn classify_done_external_no_pushed_same_agent_blocks_immediately() {
1721 let status = classify_final_status(&DecisionInput {
1722 agent_status: "done",
1723 requires_pr: true, no_code_reroutes: 0,
1725 max_reroutes: 3,
1726 is_same_agent: true,
1727 ..Default::default()
1728 });
1729 assert_eq!(status, "blocked");
1730 }
1731
1732 #[test]
1740 fn classify_done_external_no_pushed_commits_reroutes() {
1741 let status = classify_final_status(&DecisionInput {
1742 agent_status: "done",
1743 has_pushed: false,
1744 requires_pr: true, no_code_reroutes: 0,
1746 max_reroutes: 3,
1747 is_same_agent: false,
1748 ..Default::default()
1749 });
1750 assert_eq!(status, "new");
1751 }
1752
1753 #[test]
1756 fn classify_done_internal_task_no_pr_is_done() {
1757 let status = classify_final_status(&DecisionInput {
1758 agent_status: "done",
1759 has_pushed: false,
1760 requires_pr: false,
1761 ..Default::default()
1762 });
1763 assert_eq!(status, "done");
1764 }
1765
1766 #[test]
1768 fn classify_non_done_status_passes_through() {
1769 for status_str in &["in_progress", "needs_review", "blocked"] {
1770 let result = classify_final_status(&DecisionInput {
1771 agent_status: status_str,
1772 ..Default::default()
1773 });
1774 assert_eq!(
1775 result, *status_str,
1776 "status '{status_str}' should pass through unchanged"
1777 );
1778 }
1779 }
1780
1781 #[test]
1783 fn classify_descriptive_completion_status_normalizes_to_done() {
1784 let status = classify_final_status(&DecisionInput {
1785 agent_status: "Trading scan complete. Nothing to trade.",
1786 ..Default::default()
1787 });
1788 assert_eq!(status, "done");
1789 }
1790
1791 #[test]
1793 fn classify_descriptive_completion_with_failure_cues_does_not_normalize() {
1794 let status = classify_final_status(&DecisionInput {
1795 agent_status: "completed but failed to push branch due to error",
1796 ..Default::default()
1797 });
1798 assert_eq!(status, "completed but failed to push branch due to error");
1799 }
1800
1801 #[test]
1803 fn classify_push_failed_takes_precedence_over_has_pr() {
1804 let status = classify_final_status(&DecisionInput {
1805 agent_status: "done",
1806 has_pr: true,
1807 push_failed: true,
1808 push_failures: 1,
1809 ..Default::default()
1810 });
1811 assert_eq!(status, "new");
1813 }
1814
1815 #[test]
1817 fn classify_workflow_scope_failure_takes_precedence_over_push_reroute() {
1818 let status = classify_final_status(&DecisionInput {
1819 is_workflow_scope_failure: true,
1820 push_failed: true,
1821 push_failures: 1, ..Default::default()
1823 });
1824 assert_eq!(status, "blocked");
1825 }
1826
1827 #[test]
1831 fn classify_completed_with_pr_goes_to_needs_review() {
1832 let status = classify_final_status(&DecisionInput {
1833 agent_status: "completed",
1834 has_pr: true,
1835 is_completed_status: true,
1836 ..Default::default()
1837 });
1838 assert_eq!(status, "needs_review");
1839 }
1840
1841 #[test]
1843 fn classify_completed_with_delegations_no_pr_blocks() {
1844 let status = classify_final_status(&DecisionInput {
1845 agent_status: "completed",
1846 has_delegations: true,
1847 is_completed_status: true,
1848 ..Default::default()
1849 });
1850 assert_eq!(status, "blocked");
1851 }
1852
1853 #[test]
1855 fn classify_completed_pushed_no_pr_reroutes_for_pr_creation() {
1856 let status = classify_final_status(&DecisionInput {
1857 agent_status: "completed",
1858 has_pushed: true,
1859 is_completed_status: true,
1860 ..Default::default()
1861 });
1862 assert_eq!(status, "routed");
1863 }
1864
1865 #[test]
1867 fn classify_completed_requires_pr_under_max_reroutes() {
1868 let status = classify_final_status(&DecisionInput {
1869 agent_status: "completed",
1870 requires_pr: true,
1871 no_code_reroutes: 1,
1872 max_reroutes: 3,
1873 is_completed_status: true,
1874 is_same_agent: false,
1875 ..Default::default()
1876 });
1877 assert_eq!(status, "new");
1878 }
1879
1880 #[test]
1882 fn classify_completed_requires_pr_at_max_reroutes_blocks() {
1883 let status = classify_final_status(&DecisionInput {
1884 agent_status: "completed",
1885 requires_pr: true,
1886 no_code_reroutes: 3,
1887 max_reroutes: 3,
1888 is_completed_status: true,
1889 is_same_agent: false,
1890 ..Default::default()
1891 });
1892 assert_eq!(status, "blocked");
1893 }
1894
1895 #[test]
1897 fn classify_completed_same_agent_blocks_immediately() {
1898 let status = classify_final_status(&DecisionInput {
1899 agent_status: "completed",
1900 requires_pr: true,
1901 no_code_reroutes: 1,
1902 max_reroutes: 3,
1903 is_completed_status: true,
1904 is_same_agent: true,
1905 ..Default::default()
1906 });
1907 assert_eq!(status, "blocked");
1908 }
1909
1910 #[test]
1912 fn classify_completed_internal_task_is_done() {
1913 let status = classify_final_status(&DecisionInput {
1914 agent_status: "completed",
1915 requires_pr: false,
1916 is_completed_status: true,
1917 ..Default::default()
1918 });
1919 assert_eq!(status, "done");
1920 }
1921
1922 #[test]
1926 fn agent_blocked_reason_prefers_error_field() {
1927 let resp = crate::parser::AgentResponse {
1928 status: "blocked".to_string(),
1929 error: Some("CDP endpoint not reachable".to_string()),
1930 summary: "Could not complete research".to_string(),
1931 remaining: vec!["Run x-twitter-brave with reachable endpoint".to_string()],
1932 ..Default::default()
1933 };
1934 let reason = agent_blocked_reason(&resp);
1935 assert!(
1936 reason.contains("CDP endpoint not reachable"),
1937 "reason: {reason}"
1938 );
1939 assert!(reason.contains("Remaining:"), "reason: {reason}");
1940 assert!(reason.contains("x-twitter-brave"), "reason: {reason}");
1941 }
1942
1943 #[test]
1945 fn agent_blocked_reason_falls_back_to_summary() {
1946 let resp = crate::parser::AgentResponse {
1947 status: "blocked".to_string(),
1948 error: None,
1949 summary: "Could not access required data sources in sandboxed environment".to_string(),
1950 remaining: vec![],
1951 ..Default::default()
1952 };
1953 let reason = agent_blocked_reason(&resp);
1954 assert_eq!(
1955 reason,
1956 "Could not access required data sources in sandboxed environment"
1957 );
1958 }
1959
1960 #[test]
1962 fn agent_blocked_reason_generic_fallback() {
1963 let resp = crate::parser::AgentResponse {
1964 status: "blocked".to_string(),
1965 ..Default::default()
1966 };
1967 let reason = agent_blocked_reason(&resp);
1968 assert_eq!(reason, "agent returned blocked status without a reason");
1969 }
1970
1971 #[test]
1973 fn classify_agent_blocked_passes_through() {
1974 let status = classify_final_status(&DecisionInput {
1975 agent_status: "blocked",
1976 ..Default::default()
1977 });
1978 assert_eq!(status, "blocked");
1979 }
1980
1981 #[test]
1982 fn classify_retryable_agent_blocked_reroutes() {
1983 let status = classify_final_status(&DecisionInput {
1984 agent_status: "blocked",
1985 is_retryable_blocked: true,
1986 ..Default::default()
1987 });
1988 assert_eq!(status, "new");
1989 }
1990
1991 #[test]
1992 fn retryable_blocked_reason_detects_transient_patterns() {
1993 assert!(is_retryable_blocked_reason(
1994 "Twitter access unavailable: service timed out"
1995 ));
1996 assert!(is_retryable_blocked_reason(
1997 "final commit blocked by git worktree lock permission error"
1998 ));
1999 assert!(is_retryable_blocked_reason(
2000 "could not commit due a worktree git lock permission error"
2001 ));
2002 assert!(is_retryable_blocked_reason(
2003 "git lockfile permission restrictions in this environment"
2004 ));
2005 assert!(is_retryable_blocked_reason(
2006 "API rate limit hit, try again later"
2007 ));
2008 assert!(is_retryable_blocked_reason(
2009 "Credential bean/hyperliquid-address exists in the pass store but GPG decryption fails in this agent session — no passphrase available."
2010 ));
2011 assert!(is_retryable_blocked_reason(
2012 "credential resolution failed via PasswordStoreProvider; AgentVaultProvider unavailable"
2013 ));
2014 }
2015
2016 #[test]
2017 fn retryable_blocked_reason_rejects_permanent_patterns() {
2018 assert!(!is_retryable_blocked_reason(
2019 "invalid request payload: malformed JSON"
2020 ));
2021 assert!(!is_retryable_blocked_reason(
2022 "resource not found in repository"
2023 ));
2024 }
2025}