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;
720 if err_str.contains("422") {
721 if err_str.contains("No commits between") || err_str.contains("head") {
722 tracing::info!(
723 task_id = ctx.task_id,
724 "PR creation returned 422/no-commits/head-invalid — agent made no code changes or branch merged, marking done"
725 );
726 has_pushed = false;
727 } else if git_ops::is_invalid_base_error(&err_str) {
728 tracing::error!(
729 task_id = ctx.task_id,
730 "PR creation failed with invalid base branch — blocking task"
731 );
732 pr_base_invalid = true;
733 }
734 }
735 (false, has_pushed, pr_base_invalid)
736 }
737 }
738}
739
740async fn run_git_ops(
742 ctx: &StoreCtx<'_>,
743 wt: &worktree::WorktreeSetup,
744 task_title: &str,
745 resp: &AgentResponse,
746 agent_name: &str,
747 model_name: Option<&str>,
748 new_attempts: u32,
749) -> GitOpsResult {
750 auto_commit_changes(ctx, &wt.work_dir, task_title, agent_name, new_attempts).await;
751
752 let mut has_commits =
753 check_commits_and_clear_stale_errors(ctx, &wt.work_dir, &wt.default_branch).await;
754
755 let push_result = if !has_commits {
759 store::store_log_activity(
760 ctx.store,
761 ctx.repo,
762 ctx.task_id,
763 "push",
764 None,
765 None,
766 Some(agent_name),
767 model_name,
768 Some(&serde_json::json!({
769 "status": "skipped",
770 "reason": "no_commits_ahead",
771 "branch": wt.branch,
772 "default_branch": wt.default_branch,
773 })),
774 )
775 .await;
776 PushResult::NoCommits
777 } else {
778 push_branch_with_log(
779 ctx,
780 &wt.work_dir,
781 &wt.branch,
782 &wt.default_branch,
783 agent_name,
784 model_name,
785 )
786 .await
787 };
788 let push_result = if matches!(push_result, PushResult::WorktreeMissing) {
789 match recover_missing_worktree_for_push(ctx, wt, task_title, agent_name, model_name).await {
790 Ok(recovered) => {
791 let wt_for_ops = &recovered;
792 has_commits = check_commits_and_clear_stale_errors(
793 ctx,
794 &wt_for_ops.work_dir,
795 &wt_for_ops.default_branch,
796 )
797 .await;
798 if !has_commits {
799 PushResult::NoCommits
800 } else {
801 push_branch_with_log(
802 ctx,
803 &wt_for_ops.work_dir,
804 &wt_for_ops.branch,
805 &wt_for_ops.default_branch,
806 agent_name,
807 model_name,
808 )
809 .await
810 }
811 }
812 Err(e) => {
813 tracing::error!(
814 task_id = ctx.task_id,
815 err = %e,
816 "worktree recovery before push failed"
817 );
818 ctx.append_activity(
819 "push_recover_worktree",
820 Some(agent_name),
821 model_name,
822 Some(&serde_json::json!({
823 "status": "error",
824 "worktree": wt.work_dir.display().to_string(),
825 "branch": wt.branch,
826 "error": e.to_string(),
827 })),
828 )
829 .await;
830 ctx.set(&[(
831 "last_error",
832 serde_json::json!(format!(
833 "push failed: worktree disappeared and recovery failed: {e}"
834 )),
835 )])
836 .await;
837 PushResult::Failed
838 }
839 }
840 } else {
841 push_result
842 };
843
844 let mut has_pushed = matches!(push_result, PushResult::Success);
845 let mut has_pr = false;
846
847 if has_commits && matches!(push_result, PushResult::Failed) {
849 tracing::warn!(
850 task_id = ctx.task_id,
851 "skipping PR creation due to push failure"
852 );
853 } else if matches!(push_result, PushResult::Rebased) {
854 tracing::info!(
857 task_id = ctx.task_id,
858 "skipping PR creation after successful rebase — task will be re-routed"
859 );
860 } else if matches!(push_result, PushResult::NoCommits) {
861 tracing::info!(
862 task_id = ctx.task_id,
863 "no commits ahead of default branch, skipping push + PR"
864 );
865 } else if !has_pushed {
866 } else if ctx.repo.is_empty() {
868 tracing::warn!(
869 task_id = ctx.task_id,
870 "skipping PR creation — repo is empty (internal task?)"
871 );
872 } else {
873 let (pr, updated_pushed, pr_base_invalid) = create_pr_with_log(
874 ctx, wt, task_title, resp, agent_name, model_name, has_pushed,
875 )
876 .await;
877 has_pr = pr;
878 has_pushed = updated_pushed;
879 return GitOpsResult {
880 has_pr,
881 has_pushed,
882 has_commits,
883 pr_base_invalid,
884 };
885 }
886
887 GitOpsResult {
888 has_pr,
889 has_pushed,
890 has_commits,
891 pr_base_invalid: false,
892 }
893}
894
895async fn detect_push_failure_state(
899 ctx: &StoreCtx<'_>,
900 has_commits: bool,
901 has_pushed: bool,
902) -> PushFailureState {
903 let stored_last_error = ctx
905 .get_task()
906 .await
907 .map(|t| t.last_error)
908 .unwrap_or_default();
909
910 let push_failed = !has_pushed && has_commits && stored_last_error.contains("push failed");
911
912 let is_workflow_scope_failure = push_failed
915 && (stored_last_error.contains("workflow` scope")
916 || stored_last_error.contains("workflow' scope")
917 || (stored_last_error.contains("refusing to allow")
918 && stored_last_error.contains("workflow")));
919
920 let is_rebased_recovery = push_failed && stored_last_error.contains("rebased successfully");
924 let is_rebase_conflict_failure =
925 push_failed && stored_last_error.contains("push failed and rebase failed:");
926
927 let push_failures = if push_failed
933 && !is_workflow_scope_failure
934 && !is_rebased_recovery
935 && !is_rebase_conflict_failure
936 {
937 match ctx.increment("push_failures").await {
938 Ok(v) => v,
939 Err(e) => {
940 tracing::warn!(
941 task_id = ctx.task_id,
942 err = %e,
943 "failed to increment push_failures — reading current value from store"
944 );
945 ctx.get_task()
947 .await
948 .map(|t| (t.push_failures.max(0) as u64) + 1)
949 .unwrap_or(1) }
951 }
952 } else {
953 0
954 };
955
956 PushFailureState {
957 push_failed,
958 is_workflow_scope_failure,
959 is_rebase_conflict_failure,
960 push_failures,
961 stored_last_error,
962 }
963}
964
965async fn detect_no_code_reroutes(
968 ctx: &StoreCtx<'_>,
969 is_no_code_reroute: bool,
970 new_attempts: u32,
971 max_reroutes: u32,
972) -> u64 {
973 if !is_no_code_reroute {
974 return 0;
975 }
976 tracing::warn!(
977 task_id = ctx.task_id,
978 attempts = new_attempts,
979 max_reroutes,
980 "agent reported done but produced no code changes on external task requiring PR"
981 );
982 match ctx.increment("no_code_reroutes").await {
983 Ok(v) => v,
984 Err(e) => {
985 tracing::warn!(
986 task_id = ctx.task_id,
987 err = %e,
988 "failed to increment no_code_reroutes — reading current value from store"
989 );
990 ctx.get_task()
992 .await
993 .map(|t| (t.no_code_reroutes.max(0) as u64) + 1)
994 .unwrap_or(1) }
996 }
997}
998
999fn agent_blocked_reason(resp: &crate::parser::AgentResponse) -> String {
1006 let summary_opt = if !resp.summary.is_empty() {
1007 Some(resp.summary.as_str())
1008 } else {
1009 None
1010 };
1011 let base = resp
1012 .error
1013 .as_deref()
1014 .filter(|s| !s.is_empty())
1015 .or(summary_opt)
1016 .unwrap_or("agent returned blocked status without a reason");
1017 if resp.remaining.is_empty() {
1018 base.to_string()
1019 } else {
1020 format!("{base}. Remaining: {}", resp.remaining.join("; "))
1021 }
1022}
1023
1024fn is_retryable_blocked_reason(reason: &str) -> bool {
1030 let r = reason.to_lowercase();
1031
1032 let permanent_patterns = [
1033 "not found",
1034 "does not exist",
1035 "invalid",
1036 "malformed",
1037 "syntax error",
1038 "unsupported",
1039 "unimplemented",
1040 "manual intervention required",
1041 "human intervention required",
1042 ];
1043 if permanent_patterns.iter().any(|p| r.contains(p)) {
1044 return false;
1045 }
1046
1047 let transient_patterns = [
1048 "unavailable",
1049 "temporarily unavailable",
1050 "timeout",
1051 "timed out",
1052 "connection reset",
1053 "connection refused",
1054 "network error",
1055 "rate limit",
1056 "try again",
1057 "service down",
1058 "permission denied",
1059 "worktree lock",
1060 "git lock",
1061 "lockfile permission",
1062 "lock permission",
1063 "resource busy",
1064 "could not resolve host",
1065 "gpg decryption fails",
1068 "gpg decryption failed",
1069 "gpg agent",
1070 "no passphrase available",
1071 "passphrase unavailable",
1072 "pass store",
1073 "passwordstoreprovider",
1074 "agentvaultprovider",
1075 "onepasswordprovider",
1076 "decryption failed in this agent session",
1077 ];
1078 transient_patterns.iter().any(|p| r.contains(p))
1079}
1080
1081#[allow(clippy::too_many_arguments)]
1083async fn apply_post_decision_effects(
1084 ctx: &StoreCtx<'_>,
1085 final_status: &str,
1086 resp_status: &str,
1087 push_state: &PushFailureState,
1088 is_no_code_reroute: bool,
1089 has_pr: bool,
1090 has_pushed: bool,
1091 is_pr_base_invalid: bool,
1092 has_delegations: bool,
1093 is_external: bool,
1094 no_code_reroutes: u64,
1095 max_reroutes: u32,
1096 agent_name: &str,
1097) {
1098 if push_state.is_workflow_scope_failure {
1099 tracing::error!(
1101 task_id = ctx.task_id,
1102 "push failed: token lacks `workflow` OAuth scope — blocking immediately \
1103 (rerouting would not help)"
1104 );
1105 ctx.set(&[(
1106 "last_error",
1107 serde_json::json!(format!(
1108 "push failed: GitHub token lacks `workflow` OAuth scope. \
1109 The agent modified .github/workflows/ files but the token cannot push them. \
1110 Fix: add `workflow` scope to your GitHub token, or use a GitHub App for auth. \
1111 Original error: {}",
1112 push_state.stored_last_error
1113 )),
1114 )])
1115 .await;
1116 } else if push_state.is_rebase_conflict_failure {
1117 tracing::error!(
1118 task_id = ctx.task_id,
1119 "push failed and automatic rebase recovery failed — blocking for human intervention"
1120 );
1121 ctx.set(&[
1122 ("agent", serde_json::json!(null)),
1123 ("model", serde_json::json!(null)),
1124 ])
1125 .await;
1126 } else if push_state.push_failed {
1127 let is_rebased_recovery = push_state
1129 .stored_last_error
1130 .contains("rebased successfully");
1131 ctx.set(&[
1133 ("agent", serde_json::json!(null)),
1134 ("model", serde_json::json!(null)),
1135 ])
1136 .await;
1137 let push_failures = push_state.push_failures;
1138 if is_rebased_recovery {
1139 tracing::info!(
1141 task_id = ctx.task_id,
1142 "push failed but rebase succeeded — rerouting to different agent with up-to-date history"
1143 );
1144 } else if push_failures >= 3 {
1145 tracing::error!(
1146 task_id = ctx.task_id,
1147 push_failures,
1148 "push failed {push_failures} times — blocking for human intervention"
1149 );
1150 } else {
1151 tracing::warn!(
1152 task_id = ctx.task_id,
1153 push_failures,
1154 "agent done but push failed ({push_failures}/3) — rerouting to different agent"
1155 );
1156 }
1157 } else if resp_status == "done" && !has_pr && has_delegations {
1158 tracing::info!(
1159 task_id = ctx.task_id,
1160 "agent reported done with delegations but no PR — setting blocked"
1161 );
1162 } else if (resp_status == "done" || resp_status == "completed")
1163 && !has_pr
1164 && has_pushed
1165 && is_pr_base_invalid
1166 {
1167 tracing::error!(
1168 task_id = ctx.task_id,
1169 "PR creation failed with invalid base branch — blocking for human intervention"
1170 );
1171 ctx.set(&[(
1172 "last_error",
1173 serde_json::json!(
1174 "PR creation failed: the base branch is invalid. The agent's commits were pushed successfully, \
1175 but GitHub rejected the PR because the base branch does not exist in the repository. \
1176 Verify the repository has a valid default branch and manually create the PR if needed."
1177 ),
1178 )])
1179 .await;
1180 } else if resp_status == "done" && !has_pr && has_pushed {
1181 tracing::warn!(
1183 task_id = ctx.task_id,
1184 "agent done, commits pushed, but PR creation failed — re-dispatching as routed"
1185 );
1186 } else if is_no_code_reroute {
1187 if final_status == "blocked" {
1192 let prev_no_code_agent = ctx
1196 .get_task()
1197 .await
1198 .map(|t| t.no_code_last_agent.clone())
1199 .unwrap_or_default();
1200 let is_same_agent = !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name;
1201
1202 let msg = if is_same_agent {
1203 tracing::error!(
1204 task_id = ctx.task_id,
1205 agent = %agent_name,
1206 "blocking same-agent no-code loop: agent {} would be selected again",
1207 agent_name
1208 );
1209 format!(
1210 "agent {} completed without code changes twice — same-agent loop detected, blocking for human review",
1211 agent_name
1212 )
1213 } else {
1214 tracing::error!(
1215 task_id = ctx.task_id,
1216 no_code_reroutes,
1217 max_reroutes,
1218 "blocking no-code reroute: reached max reroute attempts ({}/{}) for no-code-result",
1219 no_code_reroutes, max_reroutes
1220 );
1221 format!(
1222 "agent completed without code changes after {}/{} reroute attempts on external task requiring PR",
1223 no_code_reroutes, max_reroutes
1224 )
1225 };
1226 ctx.set(&[("last_error", serde_json::json!(msg))]).await;
1227 } else {
1228 ctx.set(&[(
1230 "last_error",
1231 serde_json::json!(
1232 "agent completed without code changes on external task requiring PR"
1233 ),
1234 )])
1235 .await;
1236 }
1237
1238 ctx.set(&[
1243 ("no_code_last_agent", serde_json::json!(agent_name)),
1244 ("agent", serde_json::json!(null)),
1245 ("model", serde_json::json!(null)),
1246 ])
1247 .await;
1248 } else if resp_status == "done" && !has_pr && is_external {
1249 tracing::info!(
1252 task_id = ctx.task_id,
1253 "external task with non-code labels reported done — marking done without PR"
1254 );
1255 } else if resp_status == "done" && !has_pr {
1256 tracing::info!(
1257 task_id = ctx.task_id,
1258 "internal task reported done with no PR — marking done"
1259 );
1260 }
1261}
1262
1263async fn store_token_usage(
1265 ctx: &StoreCtx<'_>,
1266 input_tokens: Option<u64>,
1267 output_tokens: Option<u64>,
1268 model_name: Option<&str>,
1269) {
1270 let (Some(input), Some(output)) = (input_tokens, output_tokens) else {
1271 return;
1272 };
1273 let model = model_name.unwrap_or("haiku");
1274 if let Some(ref st) = ctx.store {
1275 if let Some(store_id) = ctx.store_id_opt {
1276 if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1277 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1278 }
1279 } else if let Ok(Some(store_id)) = st.resolve_task_id(ctx.repo, ctx.task_id).await {
1280 if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1281 tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1282 }
1283 }
1284 }
1285}
1286
1287#[allow(clippy::too_many_arguments)]
1293pub async fn handle_success(
1294 task_id: &str,
1295 parsed: agents::ParsedResponse,
1296 wt: &worktree::WorktreeSetup,
1297 task_title: &str,
1298 agent_name: &str,
1299 model_name: Option<&str>,
1300 new_attempts: u32,
1301 repo: &str,
1302 store: &Option<Arc<TaskStore>>,
1303 raw_stdout: &str,
1304) -> anyhow::Result<(String, bool)> {
1305 let input_tokens = parsed.input_tokens;
1307 let output_tokens = parsed.output_tokens;
1308 let resp = parsed.response;
1309
1310 tracing::info!(
1311 task_id,
1312 status = resp.status,
1313 summary = resp.summary,
1314 "agent completed successfully"
1315 );
1316
1317 let store_id_opt = store::resolve_store_id(store, repo, task_id).await;
1320 let ctx = StoreCtx {
1321 store,
1322 store_id_opt,
1323 repo,
1324 task_id,
1325 };
1326
1327 ctx.set(&[("network_retries", serde_json::json!(0))]).await;
1328
1329 let git = if resp.status == "done"
1331 || resp.status == "completed"
1332 || resp.status == "in_progress"
1333 || resp.status == "needs_review"
1334 {
1335 run_git_ops(
1336 &ctx,
1337 wt,
1338 task_title,
1339 &resp,
1340 agent_name,
1341 model_name,
1342 new_attempts,
1343 )
1344 .await
1345 } else {
1346 GitOpsResult::default()
1347 };
1348
1349 if !resp.delegations.is_empty() {
1351 ctx.set(&[("delegations", serde_json::json!(resp.delegations))])
1352 .await;
1353 }
1354
1355 let has_delegations = !resp.delegations.is_empty();
1362
1363 let push_state = detect_push_failure_state(&ctx, git.has_commits, git.has_pushed).await;
1365
1366 let is_external = !task_id.starts_with("internal:");
1374 let requires_pr = is_external && !git.has_pr && !resp.status.starts_with("needs_review");
1375
1376 let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
1379 .ok()
1380 .and_then(|s| s.parse().ok())
1381 .or_else(|| {
1382 config::get("workflow.max_attempts")
1383 .ok()
1384 .and_then(|s| s.parse().ok())
1385 })
1386 .unwrap_or(3);
1387
1388 let is_no_code_reroute = !push_state.is_workflow_scope_failure
1391 && !push_state.push_failed
1392 && (resp.status == "done" || resp.status == "completed")
1393 && !has_delegations
1394 && !git.has_pushed
1395 && requires_pr;
1396
1397 let no_code_reroutes =
1398 detect_no_code_reroutes(&ctx, is_no_code_reroute, new_attempts, max_reroutes).await;
1399
1400 let blocked_reason = if resp.status == "blocked" {
1401 Some(agent_blocked_reason(&resp))
1402 } else {
1403 None
1404 };
1405 let is_retryable_blocked = blocked_reason
1406 .as_deref()
1407 .map(is_retryable_blocked_reason)
1408 .unwrap_or(false);
1409
1410 let is_same_agent = if is_no_code_reroute {
1415 let prev_no_code_agent = ctx
1416 .get_task()
1417 .await
1418 .map(|t| t.no_code_last_agent.clone())
1419 .unwrap_or_default();
1420 !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name
1421 } else {
1422 false
1423 };
1424
1425 let final_status_owned = classify_final_status(&DecisionInput {
1428 agent_status: &resp.status,
1429 is_workflow_scope_failure: push_state.is_workflow_scope_failure,
1430 is_rebase_conflict_failure: push_state.is_rebase_conflict_failure,
1431 push_failed: push_state.push_failed,
1432 push_failures: push_state.push_failures,
1433 has_pr: git.has_pr,
1434 has_delegations,
1435 has_pushed: git.has_pushed,
1436 is_pr_base_invalid: git.pr_base_invalid,
1437 requires_pr,
1438 no_code_reroutes,
1439 max_reroutes,
1440 is_same_agent,
1441 is_completed_status: resp.status == "completed",
1442 is_retryable_blocked,
1443 });
1444 let final_status = final_status_owned.as_str();
1445
1446 apply_post_decision_effects(
1449 &ctx,
1450 final_status,
1451 &resp.status,
1452 &push_state,
1453 is_no_code_reroute,
1454 git.has_pr,
1455 git.has_pushed,
1456 git.pr_base_invalid,
1457 has_delegations,
1458 is_external,
1459 no_code_reroutes,
1460 max_reroutes,
1461 agent_name,
1462 )
1463 .await;
1464
1465 if resp.status == "blocked" {
1471 let reason = blocked_reason.unwrap_or_else(|| agent_blocked_reason(&resp));
1472 if final_status == "new" {
1473 crate::engine::cooldown::record_agent_failure_with_message(agent_name, &reason).await;
1476 if let Some(m) = model_name {
1477 crate::engine::cooldown::record_model_failure(agent_name, m).await;
1478 }
1479 ctx.set(&[
1480 (
1481 "last_error",
1482 serde_json::json!(format!(
1483 "transient blocker (auto-retry scheduled): {reason}"
1484 )),
1485 ),
1486 ("agent", serde_json::json!(null)),
1487 ("model", serde_json::json!(null)),
1488 ])
1489 .await;
1490 tracing::warn!(
1491 task_id = ctx.task_id,
1492 reason = %reason,
1493 "agent returned retryable blocked status — rerouting as new with cooldown"
1494 );
1495 } else if final_status == "blocked" {
1496 ctx.set(&[("last_error", serde_json::json!(reason))]).await;
1497 }
1498 }
1499
1500 ctx.set(&[("summary", serde_json::json!(resp.summary))])
1501 .await;
1502
1503 store_token_usage(
1505 &ctx,
1506 input_tokens.or(resp.input_tokens),
1507 output_tokens.or(resp.output_tokens),
1508 model_name,
1509 )
1510 .await;
1511
1512 response::store_learnings_from_response(
1514 task_id,
1515 new_attempts,
1516 agent_name,
1517 model_name,
1518 &resp,
1519 resp.error.as_deref(),
1520 store,
1521 repo,
1522 raw_stdout,
1523 )
1524 .await;
1525
1526 Ok((final_status.to_string(), push_state.push_failed))
1529}
1530
1531#[cfg(test)]
1533mod tests {
1534 use super::*;
1535
1536 #[test]
1537 fn missing_path_error_detects_io_not_found() {
1538 let err = anyhow::Error::from(std::io::Error::new(
1539 std::io::ErrorKind::NotFound,
1540 "simulated missing path",
1541 ));
1542 assert!(is_missing_path_error(&err));
1543 }
1544
1545 #[test]
1546 fn missing_path_error_detects_message_fallback() {
1547 let err = anyhow::anyhow!("push failed: No such file or directory (os error 2)");
1548 assert!(is_missing_path_error(&err));
1549 }
1550
1551 #[test]
1555 fn classify_workflow_scope_failure_blocks() {
1556 let status = classify_final_status(&DecisionInput {
1557 is_workflow_scope_failure: true,
1558 push_failed: true,
1559 push_failures: 1,
1560 ..Default::default()
1561 });
1562 assert_eq!(status, "blocked");
1563 }
1564
1565 #[test]
1567 fn classify_rebase_conflict_failure_blocks() {
1568 let status = classify_final_status(&DecisionInput {
1569 is_rebase_conflict_failure: true,
1570 push_failed: true,
1571 ..Default::default()
1572 });
1573 assert_eq!(status, "blocked");
1574 }
1575
1576 #[test]
1578 fn classify_push_failed_under_threshold_reroutes() {
1579 let status = classify_final_status(&DecisionInput {
1580 push_failed: true,
1581 push_failures: 2, ..Default::default()
1583 });
1584 assert_eq!(status, "new");
1585 }
1586
1587 #[test]
1589 fn classify_push_failed_at_threshold_blocks() {
1590 let status = classify_final_status(&DecisionInput {
1591 push_failed: true,
1592 push_failures: 3, ..Default::default()
1594 });
1595 assert_eq!(status, "blocked");
1596 }
1597
1598 #[test]
1600 fn classify_done_with_pr_goes_to_needs_review() {
1601 let status = classify_final_status(&DecisionInput {
1602 agent_status: "done",
1603 has_pr: true,
1604 ..Default::default()
1605 });
1606 assert_eq!(status, "needs_review");
1607 }
1608
1609 #[test]
1611 fn classify_done_with_delegations_no_pr_blocks() {
1612 let status = classify_final_status(&DecisionInput {
1613 agent_status: "done",
1614 has_delegations: true,
1615 ..Default::default()
1616 });
1617 assert_eq!(status, "blocked");
1618 }
1619
1620 #[test]
1622 fn classify_done_pushed_no_pr_reroutes_for_pr_creation() {
1623 let status = classify_final_status(&DecisionInput {
1624 agent_status: "done",
1625 has_pushed: true,
1626 ..Default::default()
1627 });
1628 assert_eq!(status, "routed");
1629 }
1630
1631 #[test]
1633 fn classify_done_pushed_pr_base_invalid_blocks() {
1634 let status = classify_final_status(&DecisionInput {
1635 agent_status: "done",
1636 has_pushed: true,
1637 is_pr_base_invalid: true,
1638 ..Default::default()
1639 });
1640 assert_eq!(status, "blocked");
1641 }
1642
1643 #[test]
1645 fn classify_completed_pushed_pr_base_invalid_blocks() {
1646 let status = classify_final_status(&DecisionInput {
1647 agent_status: "completed",
1648 is_completed_status: true,
1649 has_pushed: true,
1650 is_pr_base_invalid: true,
1651 ..Default::default()
1652 });
1653 assert_eq!(status, "blocked");
1654 }
1655
1656 #[test]
1658 fn classify_done_requires_pr_under_max_reroutes() {
1659 let status = classify_final_status(&DecisionInput {
1660 agent_status: "done",
1661 requires_pr: true,
1662 no_code_reroutes: 1,
1663 max_reroutes: 3,
1664 is_same_agent: false,
1665 ..Default::default()
1666 });
1667 assert_eq!(status, "new");
1668 }
1669
1670 #[test]
1672 fn classify_done_requires_pr_same_agent_blocks_immediately() {
1673 let status = classify_final_status(&DecisionInput {
1674 agent_status: "done",
1675 requires_pr: true,
1676 no_code_reroutes: 1,
1677 max_reroutes: 3,
1678 is_same_agent: true, ..Default::default()
1680 });
1681 assert_eq!(status, "blocked");
1682 }
1683
1684 #[test]
1686 fn classify_done_requires_pr_at_max_reroutes_blocks() {
1687 let status = classify_final_status(&DecisionInput {
1688 agent_status: "done",
1689 requires_pr: true,
1690 no_code_reroutes: 3,
1691 max_reroutes: 3,
1692 is_same_agent: false,
1693 ..Default::default()
1694 });
1695 assert_eq!(status, "blocked");
1696 }
1697
1698 #[test]
1700 fn classify_done_requires_pr_exceeds_max_reroutes_blocks() {
1701 let status = classify_final_status(&DecisionInput {
1702 agent_status: "done",
1703 requires_pr: true,
1704 no_code_reroutes: 5,
1705 max_reroutes: 3,
1706 is_same_agent: false,
1707 ..Default::default()
1708 });
1709 assert_eq!(status, "blocked");
1710 }
1711
1712 #[test]
1714 fn classify_done_external_no_pushed_same_agent_blocks_immediately() {
1715 let status = classify_final_status(&DecisionInput {
1716 agent_status: "done",
1717 requires_pr: true, no_code_reroutes: 0,
1719 max_reroutes: 3,
1720 is_same_agent: true,
1721 ..Default::default()
1722 });
1723 assert_eq!(status, "blocked");
1724 }
1725
1726 #[test]
1734 fn classify_done_external_no_pushed_commits_reroutes() {
1735 let status = classify_final_status(&DecisionInput {
1736 agent_status: "done",
1737 has_pushed: false,
1738 requires_pr: true, no_code_reroutes: 0,
1740 max_reroutes: 3,
1741 is_same_agent: false,
1742 ..Default::default()
1743 });
1744 assert_eq!(status, "new");
1745 }
1746
1747 #[test]
1750 fn classify_done_internal_task_no_pr_is_done() {
1751 let status = classify_final_status(&DecisionInput {
1752 agent_status: "done",
1753 has_pushed: false,
1754 requires_pr: false,
1755 ..Default::default()
1756 });
1757 assert_eq!(status, "done");
1758 }
1759
1760 #[test]
1762 fn classify_non_done_status_passes_through() {
1763 for status_str in &["in_progress", "needs_review", "blocked"] {
1764 let result = classify_final_status(&DecisionInput {
1765 agent_status: status_str,
1766 ..Default::default()
1767 });
1768 assert_eq!(
1769 result, *status_str,
1770 "status '{status_str}' should pass through unchanged"
1771 );
1772 }
1773 }
1774
1775 #[test]
1777 fn classify_descriptive_completion_status_normalizes_to_done() {
1778 let status = classify_final_status(&DecisionInput {
1779 agent_status: "Trading scan complete. Nothing to trade.",
1780 ..Default::default()
1781 });
1782 assert_eq!(status, "done");
1783 }
1784
1785 #[test]
1787 fn classify_descriptive_completion_with_failure_cues_does_not_normalize() {
1788 let status = classify_final_status(&DecisionInput {
1789 agent_status: "completed but failed to push branch due to error",
1790 ..Default::default()
1791 });
1792 assert_eq!(status, "completed but failed to push branch due to error");
1793 }
1794
1795 #[test]
1797 fn classify_push_failed_takes_precedence_over_has_pr() {
1798 let status = classify_final_status(&DecisionInput {
1799 agent_status: "done",
1800 has_pr: true,
1801 push_failed: true,
1802 push_failures: 1,
1803 ..Default::default()
1804 });
1805 assert_eq!(status, "new");
1807 }
1808
1809 #[test]
1811 fn classify_workflow_scope_failure_takes_precedence_over_push_reroute() {
1812 let status = classify_final_status(&DecisionInput {
1813 is_workflow_scope_failure: true,
1814 push_failed: true,
1815 push_failures: 1, ..Default::default()
1817 });
1818 assert_eq!(status, "blocked");
1819 }
1820
1821 #[test]
1825 fn classify_completed_with_pr_goes_to_needs_review() {
1826 let status = classify_final_status(&DecisionInput {
1827 agent_status: "completed",
1828 has_pr: true,
1829 is_completed_status: true,
1830 ..Default::default()
1831 });
1832 assert_eq!(status, "needs_review");
1833 }
1834
1835 #[test]
1837 fn classify_completed_with_delegations_no_pr_blocks() {
1838 let status = classify_final_status(&DecisionInput {
1839 agent_status: "completed",
1840 has_delegations: true,
1841 is_completed_status: true,
1842 ..Default::default()
1843 });
1844 assert_eq!(status, "blocked");
1845 }
1846
1847 #[test]
1849 fn classify_completed_pushed_no_pr_reroutes_for_pr_creation() {
1850 let status = classify_final_status(&DecisionInput {
1851 agent_status: "completed",
1852 has_pushed: true,
1853 is_completed_status: true,
1854 ..Default::default()
1855 });
1856 assert_eq!(status, "routed");
1857 }
1858
1859 #[test]
1861 fn classify_completed_requires_pr_under_max_reroutes() {
1862 let status = classify_final_status(&DecisionInput {
1863 agent_status: "completed",
1864 requires_pr: true,
1865 no_code_reroutes: 1,
1866 max_reroutes: 3,
1867 is_completed_status: true,
1868 is_same_agent: false,
1869 ..Default::default()
1870 });
1871 assert_eq!(status, "new");
1872 }
1873
1874 #[test]
1876 fn classify_completed_requires_pr_at_max_reroutes_blocks() {
1877 let status = classify_final_status(&DecisionInput {
1878 agent_status: "completed",
1879 requires_pr: true,
1880 no_code_reroutes: 3,
1881 max_reroutes: 3,
1882 is_completed_status: true,
1883 is_same_agent: false,
1884 ..Default::default()
1885 });
1886 assert_eq!(status, "blocked");
1887 }
1888
1889 #[test]
1891 fn classify_completed_same_agent_blocks_immediately() {
1892 let status = classify_final_status(&DecisionInput {
1893 agent_status: "completed",
1894 requires_pr: true,
1895 no_code_reroutes: 1,
1896 max_reroutes: 3,
1897 is_completed_status: true,
1898 is_same_agent: true,
1899 ..Default::default()
1900 });
1901 assert_eq!(status, "blocked");
1902 }
1903
1904 #[test]
1906 fn classify_completed_internal_task_is_done() {
1907 let status = classify_final_status(&DecisionInput {
1908 agent_status: "completed",
1909 requires_pr: false,
1910 is_completed_status: true,
1911 ..Default::default()
1912 });
1913 assert_eq!(status, "done");
1914 }
1915
1916 #[test]
1920 fn agent_blocked_reason_prefers_error_field() {
1921 let resp = crate::parser::AgentResponse {
1922 status: "blocked".to_string(),
1923 error: Some("CDP endpoint not reachable".to_string()),
1924 summary: "Could not complete research".to_string(),
1925 remaining: vec!["Run x-twitter-brave with reachable endpoint".to_string()],
1926 ..Default::default()
1927 };
1928 let reason = agent_blocked_reason(&resp);
1929 assert!(
1930 reason.contains("CDP endpoint not reachable"),
1931 "reason: {reason}"
1932 );
1933 assert!(reason.contains("Remaining:"), "reason: {reason}");
1934 assert!(reason.contains("x-twitter-brave"), "reason: {reason}");
1935 }
1936
1937 #[test]
1939 fn agent_blocked_reason_falls_back_to_summary() {
1940 let resp = crate::parser::AgentResponse {
1941 status: "blocked".to_string(),
1942 error: None,
1943 summary: "Could not access required data sources in sandboxed environment".to_string(),
1944 remaining: vec![],
1945 ..Default::default()
1946 };
1947 let reason = agent_blocked_reason(&resp);
1948 assert_eq!(
1949 reason,
1950 "Could not access required data sources in sandboxed environment"
1951 );
1952 }
1953
1954 #[test]
1956 fn agent_blocked_reason_generic_fallback() {
1957 let resp = crate::parser::AgentResponse {
1958 status: "blocked".to_string(),
1959 ..Default::default()
1960 };
1961 let reason = agent_blocked_reason(&resp);
1962 assert_eq!(reason, "agent returned blocked status without a reason");
1963 }
1964
1965 #[test]
1967 fn classify_agent_blocked_passes_through() {
1968 let status = classify_final_status(&DecisionInput {
1969 agent_status: "blocked",
1970 ..Default::default()
1971 });
1972 assert_eq!(status, "blocked");
1973 }
1974
1975 #[test]
1976 fn classify_retryable_agent_blocked_reroutes() {
1977 let status = classify_final_status(&DecisionInput {
1978 agent_status: "blocked",
1979 is_retryable_blocked: true,
1980 ..Default::default()
1981 });
1982 assert_eq!(status, "new");
1983 }
1984
1985 #[test]
1986 fn retryable_blocked_reason_detects_transient_patterns() {
1987 assert!(is_retryable_blocked_reason(
1988 "Twitter access unavailable: service timed out"
1989 ));
1990 assert!(is_retryable_blocked_reason(
1991 "final commit blocked by git worktree lock permission error"
1992 ));
1993 assert!(is_retryable_blocked_reason(
1994 "could not commit due a worktree git lock permission error"
1995 ));
1996 assert!(is_retryable_blocked_reason(
1997 "git lockfile permission restrictions in this environment"
1998 ));
1999 assert!(is_retryable_blocked_reason(
2000 "API rate limit hit, try again later"
2001 ));
2002 assert!(is_retryable_blocked_reason(
2003 "Credential bean/hyperliquid-address exists in the pass store but GPG decryption fails in this agent session — no passphrase available."
2004 ));
2005 assert!(is_retryable_blocked_reason(
2006 "credential resolution failed via PasswordStoreProvider; AgentVaultProvider unavailable"
2007 ));
2008 }
2009
2010 #[test]
2011 fn retryable_blocked_reason_rejects_permanent_patterns() {
2012 assert!(!is_retryable_blocked_reason(
2013 "invalid request payload: malformed JSON"
2014 ));
2015 assert!(!is_retryable_blocked_reason(
2016 "resource not found in repository"
2017 ));
2018 }
2019}