Skip to main content

orch/engine/runner/
response_handler.rs

1//! Success-path response handler — commit, push, PR, token storage.
2//!
3//! Extracted from `runner/mod.rs`. Handles the `Ok(parsed)` arm of the parse
4//! result, including git operations and delegation storage. Also owns
5//! `write_result_json`.
6
7use 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/// Write a structured `result.json` to the attempt directory for debugging.
17#[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/// Input to the pure status-decision function [`classify_final_status`].
78///
79/// The caller is responsible for pre-computing counter values (via store
80/// increments) and populating them here.  No I/O is performed inside the
81/// decision function itself, making it trivially unit-testable.
82#[derive(Default)]
83struct DecisionInput<'a> {
84    /// Agent-reported status (e.g. `"done"`, `"in_progress"`).
85    agent_status: &'a str,
86    /// Push was attempted and commits existed, but the push error contained a
87    /// `workflow` scope complaint — non-retryable.
88    is_workflow_scope_failure: bool,
89    /// Push failure recovery attempted a rebase but failed (for example due to
90    /// conflicts). This is non-retryable without human intervention.
91    is_rebase_conflict_failure: bool,
92    /// Push was attempted (commits existed) but failed.
93    push_failed: bool,
94    /// Persistent push-failure counter *after* this run's increment.
95    /// Only meaningful when `push_failed` is `true`.
96    push_failures: u64,
97    /// A PR already exists (or was just created) for this task.
98    has_pr: bool,
99    /// The agent created sub-task delegations.
100    has_delegations: bool,
101    /// Commits were pushed to the remote branch successfully.
102    has_pushed: bool,
103    /// PR creation failed because the base branch was invalid (even after
104    /// GitHub API fallback). This is a terminal error — the task should be
105    /// blocked rather than re-dispatched.
106    is_pr_base_invalid: bool,
107    /// The task is external and requires a PR to be marked done.
108    /// Always false for internal tasks.
109    requires_pr: bool,
110    /// Persistent no-code-reroute counter *after* this run's increment.
111    no_code_reroutes: u64,
112    /// Maximum no-code reroutes before blocking (from config).
113    max_reroutes: u32,
114    /// True if the agent that just ran is the same as the one that produced
115    /// the previous no-code result. Same-agent loops should be blocked immediately.
116    is_same_agent: bool,
117    /// True when agent_status is "completed". Used to distinguish internal
118    /// completed tasks (mark done) from "done" tasks where the git ops pipeline
119    /// would have detected no-code reroutes. Pre-computed from agent_status
120    /// by the caller so this function stays pure.
121    is_completed_status: bool,
122    /// Agent explicitly returned blocked, but reason appears transient and retryable.
123    is_retryable_blocked: bool,
124    /// The task was re-dispatched to resolve a merge conflict and came back with
125    /// nothing ahead of the default branch, meaning the fix already landed and
126    /// the open PR is superseded. Mark done instead of counting a no-code run.
127    is_superseded_conflict: bool,
128}
129
130/// Determine the final task status from pre-computed state.
131///
132/// This is a pure function — it performs no I/O.  All store increments must be
133/// done by the caller **before** calling this function (so counters reflect the
134/// current run), and all store side-effects (clearing agent/model, writing
135/// last_error) must be applied **after** based on the returned status.
136fn classify_final_status(input: &DecisionInput<'_>) -> String {
137    if input.is_workflow_scope_failure || input.is_rebase_conflict_failure {
138        "blocked".to_string()
139    } else if input.push_failed {
140        if input.push_failures >= 3 {
141            "blocked".to_string()
142        } else {
143            "new".to_string()
144        }
145    } else if input.agent_status == "done" && input.has_pr {
146        "needs_review".to_string()
147    } else if input.agent_status == "done" && !input.has_pr && input.has_delegations {
148        "blocked".to_string()
149    } else if input.agent_status == "done"
150        && !input.has_pr
151        && input.has_pushed
152        && input.is_pr_base_invalid
153    {
154        // PR creation failed with an invalid base branch (even after GitHub API
155        // fallback).  This is a terminal configuration error — re-dispatching
156        // would just burn tokens on the same failure.
157        "blocked".to_string()
158    } else if input.agent_status == "done" && !input.has_pr && input.has_pushed {
159        "routed".to_string()
160    } else if input.is_superseded_conflict {
161        "done".to_string()
162    } else if input.agent_status == "done" && input.requires_pr {
163        // Same-agent loop detection: if this agent is the same as the one that
164        // produced the previous no-code result, block immediately (#2410, #2686).
165        // Otherwise, block if max reroutes exhausted, else reroute.
166        if input.is_same_agent || input.no_code_reroutes >= input.max_reroutes as u64 {
167            "blocked".to_string()
168        } else {
169            "new".to_string()
170        }
171    } else if input.agent_status == "done" && !input.has_pushed {
172        // Only internal tasks reach here — external done+!has_pr+!has_pushed is
173        // always caught by branch 6 (requires_pr = true for external tasks with
174        // !has_pr and done status), so is_external can never be true here.
175        // Internal tasks may legitimately produce no git-visible changes.
176        "done".to_string()
177    } else if input.is_completed_status && input.has_pr {
178        // Agent said "completed" and a PR was created — send to review.
179        "needs_review".to_string()
180    } else if input.is_completed_status && !input.has_pr && input.has_delegations {
181        // Agent said "completed" with delegations but no PR — blocked on children.
182        "blocked".to_string()
183    } else if input.is_completed_status
184        && !input.has_pr
185        && input.has_pushed
186        && input.is_pr_base_invalid
187    {
188        "blocked".to_string()
189    } else if input.is_completed_status && !input.has_pr && input.has_pushed {
190        // Agent said "completed", commits pushed but no PR — re-dispatch for PR.
191        "routed".to_string()
192    } else if input.is_completed_status && input.requires_pr {
193        // Agent said "completed" on external task requiring PR without a PR or pushes.
194        // Same-agent loop detection applies, then reroute or block.
195        if input.is_same_agent || input.no_code_reroutes >= input.max_reroutes as u64 {
196            "blocked".to_string()
197        } else {
198            "new".to_string()
199        }
200    } else if input.is_completed_status {
201        // Agent said "completed" on internal task — mark done.
202        // Git ops already ran and detected no commits, so this is a legitimate
203        // completion without code changes.
204        "done".to_string()
205    } else if input.agent_status == "blocked" && input.is_retryable_blocked {
206        "new".to_string()
207    } else if crate::status_heuristics::status_looks_like_descriptive_completion(input.agent_status)
208    {
209        "done".to_string()
210    } else {
211        input.agent_status.to_string()
212    }
213}
214
215// ── Internal context ──────────────────────────────────────────────────────────
216
217/// Bundles store references so helper functions don't repeat four parameters.
218struct StoreCtx<'a> {
219    store: &'a Option<Arc<TaskStore>>,
220    store_id_opt: Option<i64>,
221    repo: &'a str,
222    task_id: &'a str,
223}
224
225impl<'a> StoreCtx<'a> {
226    /// Write fields, using the pre-resolved `store_id` when available.
227    async fn set(&self, fields: &[(&str, serde_json::Value)]) {
228        if let Some(store_id) = self.store_id_opt {
229            let _ = store::store_set_by_id(&self.store.as_ref(), store_id, fields).await;
230        } else {
231            store::store_set(self.store, self.repo, self.task_id, fields).await;
232        }
233    }
234
235    /// Load the full task record.
236    async fn get_task(&self) -> Option<crate::store::Task> {
237        if let Some(store_id) = self.store_id_opt {
238            store::opt_store_get_task_by_id(self.store, store_id).await
239        } else {
240            store::opt_store_get_task(self.store, self.repo, self.task_id).await
241        }
242    }
243
244    /// Atomically increment a counter field.
245    async fn increment(&self, field: &str) -> anyhow::Result<u64> {
246        if let Some(store_id) = self.store_id_opt {
247            store::store_increment_by_id(self.store, store_id, field).await
248        } else {
249            store::store_increment(self.store, self.repo, self.task_id, field).await
250        }
251    }
252
253    /// Append an activity event to the task timeline.
254    async fn append_activity(
255        &self,
256        event_type: &str,
257        agent: Option<&str>,
258        model: Option<&str>,
259        details: Option<&serde_json::Value>,
260    ) {
261        if let Some(store_id) = self.store_id_opt {
262            if let Some(ref s) = self.store {
263                if let Err(e) = s
264                    .append_activity(store_id, event_type, None, None, agent, model, details)
265                    .await
266                {
267                    tracing::warn!(
268                        task_id = self.task_id,
269                        event_type,
270                        error = %e,
271                        "store append_activity failed"
272                    );
273                }
274            }
275        } else {
276            store::store_log_activity(
277                self.store,
278                self.repo,
279                self.task_id,
280                event_type,
281                None,
282                None,
283                agent,
284                model,
285                details,
286            )
287            .await;
288        }
289    }
290}
291
292// ── Git operation result types ────────────────────────────────────────────────
293
294/// Outcome of the auto-commit → push → PR pipeline.
295#[derive(Default)]
296struct GitOpsResult {
297    has_pr: bool,
298    has_pushed: bool,
299    has_commits: bool,
300    /// PR creation failed with an invalid base branch (after all retries
301    /// and the GitHub API fallback).  Used to block the task instead of
302    /// re-dispatching it indefinitely.
303    pr_base_invalid: bool,
304}
305
306/// Push-failure detection result.
307struct PushFailureState {
308    push_failed: bool,
309    is_workflow_scope_failure: bool,
310    is_rebase_conflict_failure: bool,
311    /// Post-increment failure counter (0 when no push was attempted or failed).
312    push_failures: u64,
313    /// Last error stored in the task record (used for workflow-scope detection).
314    stored_last_error: String,
315}
316
317// ── Private git-ops helpers ───────────────────────────────────────────────────
318
319/// Attempt auto-commit; record any error in the store.
320async fn auto_commit_changes(
321    ctx: &StoreCtx<'_>,
322    work_dir: &Path,
323    task_title: &str,
324    agent_name: &str,
325    new_attempts: u32,
326) {
327    if let Err(e) =
328        git_ops::auto_commit(work_dir, ctx.task_id, task_title, agent_name, new_attempts).await
329    {
330        tracing::error!(task_id = ctx.task_id, error = ?e, "auto commit failed");
331        ctx.set(&[(
332            "last_error",
333            serde_json::json!(format!("auto commit failed: {e}")),
334        )])
335        .await;
336    }
337}
338
339/// Check whether commits exist ahead of the default branch.
340///
341/// When there are no commits, clears any stale push-failure error so that the
342/// review gate does not block an otherwise-approved task.
343async fn check_commits_and_clear_stale_errors(
344    ctx: &StoreCtx<'_>,
345    work_dir: &Path,
346    default_branch: &str,
347) -> bool {
348    let has_commits = git_ops::has_commits_ahead(work_dir, default_branch).await;
349    if !has_commits {
350        tracing::info!(
351            task_id = ctx.task_id,
352            "no commits ahead of default branch, skipping push + PR"
353        );
354        // Clear stale push failure from previous runs.
355        let last_err = ctx
356            .get_task()
357            .await
358            .map(|t| t.last_error)
359            .unwrap_or_default();
360        if last_err.contains("push failed") {
361            ctx.set(&[("last_error", serde_json::json!(""))]).await;
362        }
363    }
364    has_commits
365}
366
367/// Push the branch to the remote and log the activity.
368///
369/// On push failure due to diverged history (non-fast-forward), attempts to
370/// fetch the remote branch and rebase local commits on top. If rebase succeeds,
371/// the task will be re-routed to a new agent who can push the rebased commits.
372/// If rebase fails (conflicts), the task is blocked for human intervention.
373///
374/// Returns `PushResult::Success` on success, `PushResult::Rebased` if rebase
375/// succeeded and the task should be re-routed, `PushResult::Failed` if push
376/// failed and recovery was not possible, or `PushResult::NoCommits` if the
377/// branch has no commits ahead of the default branch.
378enum PushResult {
379    Success,
380    Rebased,
381    Failed,
382    NoCommits,       // agent made no code changes
383    WorktreeMissing, // retry after rebuilding missing worktree
384}
385
386fn is_missing_path_error(err: &anyhow::Error) -> bool {
387    err.chain().any(|cause| {
388        cause
389            .downcast_ref::<std::io::Error>()
390            .is_some_and(|ioe| ioe.kind() == std::io::ErrorKind::NotFound)
391    }) || err
392        .to_string()
393        .to_ascii_lowercase()
394        .contains("no such file or directory")
395}
396
397async fn recover_missing_worktree_for_push(
398    ctx: &StoreCtx<'_>,
399    wt: &worktree::WorktreeSetup,
400    task_title: &str,
401    agent_name: &str,
402    model_name: Option<&str>,
403) -> anyhow::Result<worktree::WorktreeSetup> {
404    tracing::warn!(
405        task_id = ctx.task_id,
406        worktree = %wt.work_dir.display(),
407        branch = wt.branch,
408        "worktree missing during push; attempting one-time recovery"
409    );
410    ctx.append_activity(
411        "push_recover_worktree",
412        Some(agent_name),
413        model_name,
414        Some(&serde_json::json!({
415            "status": "start",
416            "worktree": wt.work_dir.display().to_string(),
417            "branch": wt.branch,
418        })),
419    )
420    .await;
421
422    let recovered = worktree::setup_worktree(
423        ctx.task_id,
424        task_title,
425        &wt.main_project_dir,
426        ctx.store,
427        ctx.repo,
428    )
429    .await?;
430
431    if !recovered.work_dir.exists() {
432        anyhow::bail!(
433            "recovered worktree path is still missing: {}",
434            recovered.work_dir.display()
435        );
436    }
437
438    ctx.append_activity(
439        "push_recover_worktree",
440        Some(agent_name),
441        model_name,
442        Some(&serde_json::json!({
443            "status": "ok",
444            "worktree": recovered.work_dir.display().to_string(),
445            "branch": recovered.branch,
446        })),
447    )
448    .await;
449
450    Ok(recovered)
451}
452
453async fn push_branch_with_log(
454    ctx: &StoreCtx<'_>,
455    work_dir: &Path,
456    branch: &str,
457    default_branch: &str,
458    agent_name: &str,
459    model_name: Option<&str>,
460) -> PushResult {
461    match git_ops::push_branch(work_dir, branch, default_branch).await {
462        Ok(_) => {
463            ctx.append_activity(
464                "push",
465                Some(agent_name),
466                model_name,
467                Some(&serde_json::json!({
468                    "status": "ok",
469                    "branch": branch,
470                    "default_branch": default_branch,
471                })),
472            )
473            .await;
474            // Clear any stale push failure from a previous run so review_and_merge
475            // does not incorrectly block an approved task.
476            ctx.set(&[
477                ("last_error", serde_json::json!("")),
478                ("push_failures", serde_json::json!(0)),
479            ])
480            .await;
481            PushResult::Success
482        }
483        Err(e) => {
484            let err_str = e.to_string();
485            tracing::error!(task_id = ctx.task_id, error = ?e, "push failed");
486
487            if is_missing_path_error(&e) && !work_dir.exists() {
488                tracing::warn!(
489                    task_id = ctx.task_id,
490                    worktree = %work_dir.display(),
491                    "push failed because worktree path disappeared; will attempt recovery"
492                );
493                ctx.append_activity(
494                    "push",
495                    Some(agent_name),
496                    model_name,
497                    Some(&serde_json::json!({
498                        "status": "retry",
499                        "branch": branch,
500                        "default_branch": default_branch,
501                        "reason": "worktree_missing",
502                        "error": err_str,
503                    })),
504                )
505                .await;
506                return PushResult::WorktreeMissing;
507            }
508
509            // Check if this is a non-fast-forward error that we can recover from.
510            let is_diverged = err_str.contains("non-fast-forward")
511                || err_str.contains("rejected")
512                || err_str.contains("fetch first")
513                || err_str.contains("behind");
514
515            if is_diverged {
516                tracing::info!(
517                    task_id = ctx.task_id,
518                    branch = branch,
519                    "attempting fetch and rebase to recover from diverged branch"
520                );
521
522                // Attempt to fetch and rebase on origin/{branch}.
523                match git_ops::rebase_on_branch(work_dir, branch).await {
524                    Ok(true) => {
525                        // Rebase succeeded with commits replayed.
526                        tracing::info!(
527                            task_id = ctx.task_id,
528                            branch = branch,
529                            "rebase succeeded — task will be re-routed to new agent"
530                        );
531                        ctx.append_activity(
532                            "push_rebased",
533                            Some(agent_name),
534                            model_name,
535                            Some(&serde_json::json!({
536                                "status": "rebased",
537                                "branch": branch,
538                                "reason": "diverged_from_remote",
539                            })),
540                        )
541                        .await;
542                        ctx.set(&[(
543                            "last_error",
544                            serde_json::json!(
545                                "push failed: branch diverged from remote — rebased successfully, will retry with new agent"
546                            ),
547                        )])
548                        .await;
549                        return PushResult::Rebased;
550                    }
551                    Ok(false) => {
552                        // No local commits to rebase — nothing to push.
553                        tracing::info!(
554                            task_id = ctx.task_id,
555                            branch = branch,
556                            "no local commits after fetch — marking as success"
557                        );
558                        ctx.append_activity(
559                            "push",
560                            Some(agent_name),
561                            model_name,
562                            Some(&serde_json::json!({
563                                "status": "ok",
564                                "branch": branch,
565                                "reason": "no_commits_after_fetch",
566                            })),
567                        )
568                        .await;
569                        ctx.set(&[
570                            ("last_error", serde_json::json!("")),
571                            ("push_failures", serde_json::json!(0)),
572                        ])
573                        .await;
574                        return PushResult::Success;
575                    }
576                    Err(rebase_err) => {
577                        // Rebase failed (likely conflicts) — block for human intervention.
578                        tracing::error!(
579                            task_id = ctx.task_id,
580                            branch = branch,
581                            error = ?rebase_err,
582                            "rebase failed — blocking task for human intervention"
583                        );
584                        ctx.append_activity(
585                            "push_rebased",
586                            Some(agent_name),
587                            model_name,
588                            Some(&serde_json::json!({
589                                "status": "error",
590                                "branch": branch,
591                                "error": rebase_err.to_string(),
592                            })),
593                        )
594                        .await;
595                        ctx.set(&[(
596                            "last_error",
597                            serde_json::json!(format!(
598                                "push failed and rebase failed: {rebase_err} — manual resolution required"
599                            )),
600                        )])
601                        .await;
602                        return PushResult::Failed;
603                    }
604                }
605            }
606
607            // Non-recoverable push error — log and return failure.
608            ctx.append_activity(
609                "push",
610                Some(agent_name),
611                model_name,
612                Some(&serde_json::json!({
613                    "status": "error",
614                    "branch": branch,
615                    "default_branch": default_branch,
616                    "error": err_str,
617                })),
618            )
619            .await;
620            ctx.set(&[("last_error", serde_json::json!(format!("push failed: {e}")))])
621                .await;
622            PushResult::Failed
623        }
624    }
625}
626
627/// Create (or find an existing) PR and log the activity.
628///
629/// Returns `(has_pr, has_pushed, pr_base_invalid)`. `has_pushed` may be set to
630/// `false` when a 422 "no commits" GitHub error indicates the branch was
631/// already merged. `pr_base_invalid` is set when the base branch is rejected
632/// by GitHub (even after the API fallback), which is a terminal condition.
633async fn create_pr_with_log(
634    ctx: &StoreCtx<'_>,
635    wt: &worktree::WorktreeSetup,
636    task_title: &str,
637    resp: &AgentResponse,
638    agent_name: &str,
639    model_name: Option<&str>,
640    mut has_pushed: bool,
641) -> (bool, bool, bool) {
642    match git_ops::create_pr_if_needed(
643        &wt.work_dir,
644        &wt.branch,
645        task_title,
646        &resp.summary,
647        &resp.accomplished,
648        &resp.remaining,
649        &resp.files,
650        ctx.task_id,
651        agent_name,
652        model_name,
653        ctx.repo,
654        &wt.default_branch,
655    )
656    .await
657    {
658        Ok(ref url) => {
659            // Save pr_number to the store so the review gate can find it
660            // immediately without racing GitHub's list-API cache (~300 ms lag).
661            // This is set for both newly-created and pre-existing PRs.
662            match crate::engine::review::parse_pr_number_from_url(url) {
663                Ok(pr_num) => {
664                    if let Err(e) = store::store_set_result(
665                        ctx.store,
666                        ctx.repo,
667                        ctx.task_id,
668                        &[("pr_number", serde_json::json!(pr_num as i64))],
669                    )
670                    .await
671                    {
672                        tracing::error!(
673                            task_id = ctx.task_id,
674                            pr_url = %url,
675                            err = %e,
676                            "CRITICAL: failed to save pr_number to store — downstream review gate may trigger duplicate gh pr create"
677                        );
678                    }
679                }
680                Err(e) => {
681                    tracing::warn!(
682                        task_id = ctx.task_id,
683                        pr_url = %url,
684                        error = %e,
685                        "PR created but failed to parse PR number from URL"
686                    );
687                }
688            }
689            ctx.append_activity(
690                "pr_create",
691                Some(agent_name),
692                model_name,
693                Some(&serde_json::json!({"status": "created", "url": url})),
694            )
695            .await;
696            (true, has_pushed, false)
697        }
698        Err(e) => {
699            let err_str = format!("{e}");
700            tracing::error!(task_id = ctx.task_id, error = ?e, "create PR failed");
701            ctx.append_activity(
702                "pr_create",
703                Some(agent_name),
704                model_name,
705                Some(&serde_json::json!({
706                    "status": "error",
707                    "branch": wt.branch,
708                    "error": err_str.clone(),
709                })),
710            )
711            .await;
712            ctx.set(&[(
713                "last_error",
714                serde_json::json!(format!("create PR failed: {e}")),
715            )])
716            .await;
717            // Distinguish between terminal 422 errors and recoverable ones.
718            // - "No commits between" / "head invalid" → agent made no code changes
719            //   or the branch was already merged.  Clearing has_pushed lets the
720            //   task fall through to the "done" path.
721            // - "base invalid" → the base branch name is wrong.  After the
722            //   GitHub API fallback in create_pr_if_needed also fails, this is a
723            //   terminal configuration error.  We set pr_base_invalid so the
724            //   classifier blocks the task instead of re-dispatching forever.
725            // A GitHub auth error is terminal too: the commits are pushed, so only a token fix helps
726            let mut pr_base_invalid = false;
727            if agents::patterns::detect_auth_error(&err_str).is_some() {
728                tracing::error!(
729                    task_id = ctx.task_id,
730                    "PR creation failed with GitHub auth error — blocking task"
731                );
732                pr_base_invalid = true;
733            } else if err_str.contains("422") {
734                if err_str.contains("No commits between") || err_str.contains("head") {
735                    tracing::info!(
736                        task_id = ctx.task_id,
737                        "PR creation returned 422/no-commits/head-invalid — agent made no code changes or branch merged, marking done"
738                    );
739                    has_pushed = false;
740                } else if git_ops::is_invalid_base_error(&err_str) {
741                    tracing::error!(
742                        task_id = ctx.task_id,
743                        "PR creation failed with invalid base branch — blocking task"
744                    );
745                    pr_base_invalid = true;
746                }
747            }
748            (false, has_pushed, pr_base_invalid)
749        }
750    }
751}
752
753/// Run the full auto-commit → push → PR pipeline.
754async fn run_git_ops(
755    ctx: &StoreCtx<'_>,
756    wt: &worktree::WorktreeSetup,
757    task_title: &str,
758    resp: &AgentResponse,
759    agent_name: &str,
760    model_name: Option<&str>,
761    new_attempts: u32,
762) -> GitOpsResult {
763    auto_commit_changes(ctx, &wt.work_dir, task_title, agent_name, new_attempts).await;
764
765    let mut has_commits =
766        check_commits_and_clear_stale_errors(ctx, &wt.work_dir, &wt.default_branch).await;
767
768    // Skip push + PR if there are no commits ahead of the default branch.
769    // No-op tasks (e.g. "nothing to execute") produce no commits, so pushing
770    // and creating a PR would just waste API calls and trigger 422 errors.
771    let push_result = if !has_commits {
772        store::store_log_activity(
773            ctx.store,
774            ctx.repo,
775            ctx.task_id,
776            "push",
777            None,
778            None,
779            Some(agent_name),
780            model_name,
781            Some(&serde_json::json!({
782                "status": "skipped",
783                "reason": "no_commits_ahead",
784                "branch": wt.branch,
785                "default_branch": wt.default_branch,
786            })),
787        )
788        .await;
789        PushResult::NoCommits
790    } else {
791        push_branch_with_log(
792            ctx,
793            &wt.work_dir,
794            &wt.branch,
795            &wt.default_branch,
796            agent_name,
797            model_name,
798        )
799        .await
800    };
801    let push_result = if matches!(push_result, PushResult::WorktreeMissing) {
802        match recover_missing_worktree_for_push(ctx, wt, task_title, agent_name, model_name).await {
803            Ok(recovered) => {
804                let wt_for_ops = &recovered;
805                has_commits = check_commits_and_clear_stale_errors(
806                    ctx,
807                    &wt_for_ops.work_dir,
808                    &wt_for_ops.default_branch,
809                )
810                .await;
811                if !has_commits {
812                    PushResult::NoCommits
813                } else {
814                    push_branch_with_log(
815                        ctx,
816                        &wt_for_ops.work_dir,
817                        &wt_for_ops.branch,
818                        &wt_for_ops.default_branch,
819                        agent_name,
820                        model_name,
821                    )
822                    .await
823                }
824            }
825            Err(e) => {
826                tracing::error!(
827                    task_id = ctx.task_id,
828                    err = %e,
829                    "worktree recovery before push failed"
830                );
831                ctx.append_activity(
832                    "push_recover_worktree",
833                    Some(agent_name),
834                    model_name,
835                    Some(&serde_json::json!({
836                        "status": "error",
837                        "worktree": wt.work_dir.display().to_string(),
838                        "branch": wt.branch,
839                        "error": e.to_string(),
840                    })),
841                )
842                .await;
843                ctx.set(&[(
844                    "last_error",
845                    serde_json::json!(format!(
846                        "push failed: worktree disappeared and recovery failed: {e}"
847                    )),
848                )])
849                .await;
850                PushResult::Failed
851            }
852        }
853    } else {
854        push_result
855    };
856
857    let mut has_pushed = matches!(push_result, PushResult::Success);
858    let mut has_pr = false;
859
860    // Create PR (skip if push failed or repo is unknown)
861    if has_commits && matches!(push_result, PushResult::Failed) {
862        tracing::warn!(
863            task_id = ctx.task_id,
864            "skipping PR creation due to push failure"
865        );
866    } else if matches!(push_result, PushResult::Rebased) {
867        // Rebase succeeded — don't create PR now, task will be re-routed to new agent.
868        // The new agent will push the rebased commits.
869        tracing::info!(
870            task_id = ctx.task_id,
871            "skipping PR creation after successful rebase — task will be re-routed"
872        );
873    } else if matches!(push_result, PushResult::NoCommits) {
874        tracing::info!(
875            task_id = ctx.task_id,
876            "no commits ahead of default branch, skipping push + PR"
877        );
878    } else if !has_pushed {
879        // no commits — already logged "no commits ahead, skipping push + PR" at INFO level above
880    } else if ctx.repo.is_empty() {
881        tracing::warn!(
882            task_id = ctx.task_id,
883            "skipping PR creation — repo is empty (internal task?)"
884        );
885    } else {
886        let (pr, updated_pushed, pr_base_invalid) = create_pr_with_log(
887            ctx, wt, task_title, resp, agent_name, model_name, has_pushed,
888        )
889        .await;
890        has_pr = pr;
891        has_pushed = updated_pushed;
892        return GitOpsResult {
893            has_pr,
894            has_pushed,
895            has_commits,
896            pr_base_invalid,
897        };
898    }
899
900    GitOpsResult {
901        has_pr,
902        has_pushed,
903        has_commits,
904        pr_base_invalid: false,
905    }
906}
907
908// ── Private status-computation helpers ───────────────────────────────────────
909
910/// Comment on and close a PR whose change already landed elsewhere.
911async fn close_superseded_pr(ctx: &StoreCtx<'_>, pr_number: u64) {
912    if ctx.repo.is_empty() {
913        return;
914    }
915    let gh = match crate::github::http::GhHttp::new() {
916        Ok(gh) => gh,
917        Err(e) => {
918            tracing::warn!(task_id = ctx.task_id, err = %e, "cannot close superseded PR");
919            return;
920        }
921    };
922    let note = "Closing as superseded: the change is already on the default branch, so this branch has no commits ahead of it.";
923    if let Err(e) = gh.add_comment(ctx.repo, &pr_number.to_string(), note).await {
924        tracing::warn!(task_id = ctx.task_id, pr_number, err = %e, "failed to comment on superseded PR");
925    }
926    if let Err(e) = gh.close_pr(ctx.repo, pr_number).await {
927        tracing::warn!(task_id = ctx.task_id, pr_number, err = %e, "failed to close superseded PR");
928    }
929    ctx.set(&[(
930        "last_error",
931        serde_json::json!("PR closed as superseded: the fix already landed on the default branch"),
932    )])
933    .await;
934}
935
936/// The PR to close when a merge-conflict reroute produced no commits.
937///
938/// `merge_conflict_retries > 0` means review_poll sent the task back to resolve a
939/// conflict. Coming back with nothing ahead of the default branch means another
940/// PR already landed the same fix, so the open PR is superseded (#3780)
941fn superseded_conflict_pr(
942    merge_conflict_retries: i32,
943    pr_number: Option<i32>,
944    has_commits: bool,
945) -> Option<u64> {
946    if has_commits || merge_conflict_retries <= 0 {
947        return None;
948    }
949    pr_number.filter(|n| *n > 0).map(|n| n as u64)
950}
951
952/// Read push-failure state from the store and increment `push_failures` if needed.
953async fn detect_push_failure_state(
954    ctx: &StoreCtx<'_>,
955    has_commits: bool,
956    has_pushed: bool,
957) -> PushFailureState {
958    // Load stored task once to inspect last_error (avoid repeated DB reads).
959    let stored_last_error = ctx
960        .get_task()
961        .await
962        .map(|t| t.last_error)
963        .unwrap_or_default();
964
965    let push_failed = !has_pushed && has_commits && stored_last_error.contains("push failed");
966
967    // Detect workflow scope errors — these are non-retryable token permission issues.
968    // Rerouting to a different agent won't help since the same token is used.
969    let is_workflow_scope_failure = push_failed
970        && (stored_last_error.contains("workflow` scope")
971            || stored_last_error.contains("workflow' scope")
972            || (stored_last_error.contains("refusing to allow")
973                && stored_last_error.contains("workflow")));
974
975    // Detect successful rebase after push failure — this is a recovery case,
976    // not a true failure. The task will be re-routed to a new agent who can
977    // push the rebased commits. Don't increment push_failures counter in this case.
978    let is_rebased_recovery = push_failed && stored_last_error.contains("rebased successfully");
979    let is_rebase_conflict_failure =
980        push_failed && stored_last_error.contains("push failed and rebase failed:");
981
982    // Track push failures — block after 3 consecutive failures.
983    // Workflow-scope failures are non-retryable and blocked immediately; do not
984    // increment this retry counter for them so that later normal push failures
985    // are not prematurely blocked.
986    // Also skip incrementing when rebase succeeded — this is recovery, not failure.
987    let push_failures = if push_failed
988        && !is_workflow_scope_failure
989        && !is_rebased_recovery
990        && !is_rebase_conflict_failure
991    {
992        match ctx.increment("push_failures").await {
993            Ok(v) => v,
994            Err(e) => {
995                tracing::warn!(
996                    task_id = ctx.task_id,
997                    err = %e,
998                    "failed to increment push_failures — reading current value from store"
999                );
1000                // On DB error, read current value to ensure blocking threshold is still reachable
1001                ctx.get_task()
1002                    .await
1003                    .map(|t| (t.push_failures.max(0) as u64) + 1)
1004                    .unwrap_or(1) // assume at least 1 if we can't read either
1005            }
1006        }
1007    } else {
1008        0
1009    };
1010
1011    PushFailureState {
1012        push_failed,
1013        is_workflow_scope_failure,
1014        is_rebase_conflict_failure,
1015        push_failures,
1016        stored_last_error,
1017    }
1018}
1019
1020/// Increment `no_code_reroutes` and log a warning when the agent completed
1021/// without producing code changes on an external task requiring a PR.
1022async fn detect_no_code_reroutes(
1023    ctx: &StoreCtx<'_>,
1024    is_no_code_reroute: bool,
1025    new_attempts: u32,
1026    max_reroutes: u32,
1027) -> u64 {
1028    if !is_no_code_reroute {
1029        return 0;
1030    }
1031    tracing::warn!(
1032        task_id = ctx.task_id,
1033        attempts = new_attempts,
1034        max_reroutes,
1035        "agent reported done but produced no code changes on external task requiring PR"
1036    );
1037    match ctx.increment("no_code_reroutes").await {
1038        Ok(v) => v,
1039        Err(e) => {
1040            tracing::warn!(
1041                task_id = ctx.task_id,
1042                err = %e,
1043                "failed to increment no_code_reroutes — reading current value from store"
1044            );
1045            // On DB error, read current value to ensure blocking threshold is still reachable
1046            ctx.get_task()
1047                .await
1048                .map(|t| (t.no_code_reroutes.max(0) as u64) + 1)
1049                .unwrap_or(1) // assume at least 1 if we can't read either
1050        }
1051    }
1052}
1053
1054// ── Post-decision side-effect helpers ─────────────────────────────────────────
1055
1056/// Build a human-readable reason string from a "blocked" agent response.
1057///
1058/// Priority: explicit `error` field → `summary` → fallback.
1059/// Appends `remaining` items if present so operators know what was left to do.
1060fn agent_blocked_reason(resp: &crate::parser::AgentResponse) -> String {
1061    let summary_opt = if !resp.summary.is_empty() {
1062        Some(resp.summary.as_str())
1063    } else {
1064        None
1065    };
1066    let base = resp
1067        .error
1068        .as_deref()
1069        .filter(|s| !s.is_empty())
1070        .or(summary_opt)
1071        .unwrap_or("agent returned blocked status without a reason");
1072    if resp.remaining.is_empty() {
1073        base.to_string()
1074    } else {
1075        format!("{base}. Remaining: {}", resp.remaining.join("; "))
1076    }
1077}
1078
1079/// Heuristic classifier for agent-returned blocked reasons.
1080///
1081/// Returns true when the reason looks transient/retryable (network/provider/
1082/// auth-temporary/environment availability issues), and false for likely
1083/// permanent/input/configuration issues.
1084fn is_retryable_blocked_reason(reason: &str) -> bool {
1085    let r = reason.to_lowercase();
1086
1087    let permanent_patterns = [
1088        "not found",
1089        "does not exist",
1090        "invalid",
1091        "malformed",
1092        "syntax error",
1093        "unsupported",
1094        "unimplemented",
1095        "manual intervention required",
1096        "human intervention required",
1097    ];
1098    if permanent_patterns.iter().any(|p| r.contains(p)) {
1099        return false;
1100    }
1101
1102    let transient_patterns = [
1103        "unavailable",
1104        "temporarily unavailable",
1105        "timeout",
1106        "timed out",
1107        "connection reset",
1108        "connection refused",
1109        "network error",
1110        "rate limit",
1111        "try again",
1112        "service down",
1113        "permission denied",
1114        "worktree lock",
1115        "git lock",
1116        "lockfile permission",
1117        "lock permission",
1118        "resource busy",
1119        "could not resolve host",
1120        // Credential providers in headless/tmux sessions can fail transiently
1121        // when GPG agent/passphrase is unavailable.
1122        "gpg decryption fails",
1123        "gpg decryption failed",
1124        "gpg agent",
1125        "no passphrase available",
1126        "passphrase unavailable",
1127        "pass store",
1128        "passwordstoreprovider",
1129        "agentvaultprovider",
1130        "onepasswordprovider",
1131        "decryption failed in this agent session",
1132    ];
1133    transient_patterns.iter().any(|p| r.contains(p))
1134}
1135
1136/// Apply tracing and store writes that depend on the final status decision.
1137#[allow(clippy::too_many_arguments)]
1138async fn apply_post_decision_effects(
1139    ctx: &StoreCtx<'_>,
1140    final_status: &str,
1141    resp_status: &str,
1142    push_state: &PushFailureState,
1143    is_no_code_reroute: bool,
1144    has_pr: bool,
1145    has_pushed: bool,
1146    is_pr_base_invalid: bool,
1147    has_delegations: bool,
1148    is_external: bool,
1149    no_code_reroutes: u64,
1150    max_reroutes: u32,
1151    agent_name: &str,
1152) {
1153    if push_state.is_workflow_scope_failure {
1154        // Non-retryable — block immediately with actionable guidance.
1155        tracing::error!(
1156            task_id = ctx.task_id,
1157            "push failed: token lacks `workflow` OAuth scope — blocking immediately \
1158             (rerouting would not help)"
1159        );
1160        ctx.set(&[(
1161            "last_error",
1162            serde_json::json!(format!(
1163                "push failed: GitHub token lacks `workflow` OAuth scope. \
1164                 The agent modified .github/workflows/ files but the token cannot push them. \
1165                 Fix: add `workflow` scope to your GitHub token, or use a GitHub App for auth. \
1166                 Original error: {}",
1167                push_state.stored_last_error
1168            )),
1169        )])
1170        .await;
1171    } else if push_state.is_rebase_conflict_failure {
1172        tracing::error!(
1173            task_id = ctx.task_id,
1174            "push failed and automatic rebase recovery failed — blocking for human intervention"
1175        );
1176        ctx.set(&[
1177            ("agent", serde_json::json!(null)),
1178            ("model", serde_json::json!(null)),
1179        ])
1180        .await;
1181    } else if push_state.push_failed {
1182        // Check if this is a rebase recovery case (rebase succeeded after push failure).
1183        let is_rebased_recovery = push_state
1184            .stored_last_error
1185            .contains("rebased successfully");
1186        // Clear agent and model so router picks a different one on reroute (#1604)
1187        ctx.set(&[
1188            ("agent", serde_json::json!(null)),
1189            ("model", serde_json::json!(null)),
1190        ])
1191        .await;
1192        let push_failures = push_state.push_failures;
1193        if is_rebased_recovery {
1194            // Rebase succeeded — next agent will have up-to-date history.
1195            tracing::info!(
1196                task_id = ctx.task_id,
1197                "push failed but rebase succeeded — rerouting to different agent with up-to-date history"
1198            );
1199        } else if push_failures >= 3 {
1200            tracing::error!(
1201                task_id = ctx.task_id,
1202                push_failures,
1203                "push failed {push_failures} times — blocking for human intervention"
1204            );
1205        } else {
1206            tracing::warn!(
1207                task_id = ctx.task_id,
1208                push_failures,
1209                "agent done but push failed ({push_failures}/3) — rerouting to different agent"
1210            );
1211        }
1212    } else if resp_status == "done" && !has_pr && has_delegations {
1213        tracing::info!(
1214            task_id = ctx.task_id,
1215            "agent reported done with delegations but no PR — setting blocked"
1216        );
1217    } else if (resp_status == "done" || resp_status == "completed")
1218        && !has_pr
1219        && has_pushed
1220        && is_pr_base_invalid
1221    {
1222        tracing::error!(
1223            task_id = ctx.task_id,
1224            "PR creation failed with invalid base branch — blocking for human intervention"
1225        );
1226        ctx.set(&[(
1227            "last_error",
1228            serde_json::json!(
1229                "PR creation failed terminally (invalid base branch or GitHub auth error). The agent's commits were pushed successfully. \
1230                 Check the repository default branch and the GitHub token, then create the PR manually or unblock the task."
1231            ),
1232        )])
1233        .await;
1234    } else if resp_status == "done" && !has_pr && has_pushed {
1235        // Push succeeded but PR creation failed after retries.
1236        tracing::warn!(
1237            task_id = ctx.task_id,
1238            "agent done, commits pushed, but PR creation failed — re-dispatching as routed"
1239        );
1240    } else if is_no_code_reroute {
1241        // Record which agent produced this no-code result so the same-agent
1242        // detection on the next run can prevent loop-backs (#2410, #2686).
1243        // Also clear agent/model to force re-routing.
1244
1245        if final_status == "blocked" {
1246            // final_status is "blocked" either because max_reroutes was exhausted
1247            // or because is_same_agent was detected (both cases are handled by
1248            // classify_final_status). Log accordingly.
1249            let prev_no_code_agent = ctx
1250                .get_task()
1251                .await
1252                .map(|t| t.no_code_last_agent.clone())
1253                .unwrap_or_default();
1254            let is_same_agent = !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name;
1255
1256            let msg = if is_same_agent {
1257                tracing::error!(
1258                    task_id = ctx.task_id,
1259                    agent = %agent_name,
1260                    "blocking same-agent no-code loop: agent {} would be selected again",
1261                    agent_name
1262                );
1263                format!(
1264                    "agent {} completed without code changes twice — same-agent loop detected, blocking for human review",
1265                    agent_name
1266                )
1267            } else {
1268                tracing::error!(
1269                    task_id = ctx.task_id,
1270                    no_code_reroutes,
1271                    max_reroutes,
1272                    "blocking no-code reroute: reached max reroute attempts ({}/{}) for no-code-result",
1273                    no_code_reroutes, max_reroutes
1274                );
1275                format!(
1276                    "agent completed without code changes after {}/{} reroute attempts on external task requiring PR",
1277                    no_code_reroutes, max_reroutes
1278                )
1279            };
1280            ctx.set(&[("last_error", serde_json::json!(msg))]).await;
1281        } else {
1282            // Rerouting — no blocking error to record yet.
1283            ctx.set(&[(
1284                "last_error",
1285                serde_json::json!(
1286                    "agent completed without code changes on external task requiring PR"
1287                ),
1288            )])
1289            .await;
1290        }
1291
1292        // Always record which agent produced this no-code result so the router can
1293        // skip it on the next attempt. Also clear agent/model to force re-routing
1294        // (the router will pick a fresh agent, which will be caught by the same-agent
1295        // check on the next round if it's the same agent).
1296        ctx.set(&[
1297            ("no_code_last_agent", serde_json::json!(agent_name)),
1298            ("agent", serde_json::json!(null)),
1299            ("model", serde_json::json!(null)),
1300        ])
1301        .await;
1302    } else if resp_status == "done" && !has_pr && is_external {
1303        // External task with non-code labels (e.g. documentation, research) —
1304        // allowed to be marked done without a PR.
1305        tracing::info!(
1306            task_id = ctx.task_id,
1307            "external task with non-code labels reported done — marking done without PR"
1308        );
1309    } else if resp_status == "done" && !has_pr {
1310        tracing::info!(
1311            task_id = ctx.task_id,
1312            "internal task reported done with no PR — marking done"
1313        );
1314    }
1315}
1316
1317/// Store token usage if both input and output token counts are known.
1318async fn store_token_usage(
1319    ctx: &StoreCtx<'_>,
1320    input_tokens: Option<u64>,
1321    output_tokens: Option<u64>,
1322    model_name: Option<&str>,
1323) {
1324    let (Some(input), Some(output)) = (input_tokens, output_tokens) else {
1325        return;
1326    };
1327    let model = model_name.unwrap_or("haiku");
1328    if let Some(ref st) = ctx.store {
1329        if let Some(store_id) = ctx.store_id_opt {
1330            if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1331                tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1332            }
1333        } else if let Ok(Some(store_id)) = st.resolve_task_id(ctx.repo, ctx.task_id).await {
1334            if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1335                tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1336            }
1337        }
1338    }
1339}
1340
1341/// Handle a successful agent response: commit, push, PR, delegations, tokens.
1342///
1343/// Returns `Ok((status, push_failed))` where `status` is the final task status
1344/// string and `push_failed` is `true` when a push was attempted but failed (for
1345/// audit trail classification).
1346#[allow(clippy::too_many_arguments)]
1347pub async fn handle_success(
1348    task_id: &str,
1349    parsed: agents::ParsedResponse,
1350    wt: &worktree::WorktreeSetup,
1351    task_title: &str,
1352    agent_name: &str,
1353    model_name: Option<&str>,
1354    new_attempts: u32,
1355    repo: &str,
1356    store: &Option<Arc<TaskStore>>,
1357    raw_stdout: &str,
1358) -> anyhow::Result<(String, bool)> {
1359    // Extract token counts before consuming parsed.
1360    let input_tokens = parsed.input_tokens;
1361    let output_tokens = parsed.output_tokens;
1362    let resp = parsed.response;
1363
1364    tracing::info!(
1365        task_id,
1366        status = resp.status,
1367        summary = resp.summary,
1368        "agent completed successfully"
1369    );
1370
1371    // Resolve numeric store_id once so we can reuse it for multiple store ops
1372    // in this hot path and avoid repeated external_id -> store_id SQL lookups.
1373    let store_id_opt = store::resolve_store_id(store, repo, task_id).await;
1374    let ctx = StoreCtx {
1375        store,
1376        store_id_opt,
1377        repo,
1378        task_id,
1379    };
1380
1381    ctx.set(&[("network_retries", serde_json::json!(0))]).await;
1382
1383    // Auto-commit, push, create PR
1384    let git = if resp.status == "done"
1385        || resp.status == "completed"
1386        || resp.status == "in_progress"
1387        || resp.status == "needs_review"
1388    {
1389        run_git_ops(
1390            &ctx,
1391            wt,
1392            task_title,
1393            &resp,
1394            agent_name,
1395            model_name,
1396            new_attempts,
1397        )
1398        .await
1399    } else {
1400        GitOpsResult::default()
1401    };
1402
1403    // Store delegations in store if present (processed by run_with_context)
1404    if !resp.delegations.is_empty() {
1405        ctx.set(&[("delegations", serde_json::json!(resp.delegations))])
1406            .await;
1407    }
1408
1409    // Store result in task store
1410    // If agent said "done" and a PR exists, send to review before merge.
1411    // If agent said "done", pushed commits, but PR creation failed — review gate creates PR.
1412    // If agent said "done", no PR, and no delegations — work is complete
1413    // (e.g., review/analysis jobs that create issues but no code changes).
1414    // If agent said "done", no PR, but has delegations — blocked on children.
1415    let has_delegations = !resp.delegations.is_empty();
1416
1417    // Detect push failure, increment counters.
1418    let push_state = detect_push_failure_state(&ctx, git.has_commits, git.has_pushed).await;
1419
1420    // Determine whether this external task can be marked done without a PR.
1421    // External tasks always require a PR before reaching `done` — unless
1422    // commits were successfully pushed (in which case the `has_pushed` branch
1423    // handles routing). The `is_non_code_task` heuristic was removed because
1424    // it relied on agent output and could be fooled: an agent claiming
1425    // "already implemented" or "config-only" would match non-code keywords
1426    // and close the issue without verification.
1427    let is_external = !task_id.starts_with("internal:");
1428    let requires_pr = is_external && !git.has_pr && !resp.status.starts_with("needs_review");
1429
1430    // Preferred config key `workflow.max_reroute_attempts`; fall back to
1431    // `workflow.max_attempts` for backwards compatibility.
1432    let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
1433        .ok()
1434        .and_then(|s| s.parse().ok())
1435        .or_else(|| {
1436            config::get("workflow.max_attempts")
1437                .ok()
1438                .and_then(|s| s.parse().ok())
1439        })
1440        .unwrap_or(3);
1441
1442    // Determine whether we are entering the no-code-reroute branch (all
1443    // earlier chain conditions are false + done + requires_pr).
1444    let is_no_code_reroute = !push_state.is_workflow_scope_failure
1445        && !push_state.push_failed
1446        && (resp.status == "done" || resp.status == "completed")
1447        && !has_delegations
1448        && !git.has_pushed
1449        && requires_pr;
1450
1451    // A conflict reroute with nothing ahead of the default branch is a landed
1452    // fix, not a no-code run: close the superseded PR and mark the task done.
1453    let superseded_pr = match (is_no_code_reroute, ctx.get_task().await) {
1454        (true, Some(task)) => {
1455            superseded_conflict_pr(task.merge_conflict_retries, task.pr_number, git.has_commits)
1456        }
1457        _ => None,
1458    };
1459    let is_no_code_reroute = is_no_code_reroute && superseded_pr.is_none();
1460
1461    let no_code_reroutes =
1462        detect_no_code_reroutes(&ctx, is_no_code_reroute, new_attempts, max_reroutes).await;
1463
1464    let blocked_reason = if resp.status == "blocked" {
1465        Some(agent_blocked_reason(&resp))
1466    } else {
1467        None
1468    };
1469    let is_retryable_blocked = blocked_reason
1470        .as_deref()
1471        .map(is_retryable_blocked_reason)
1472        .unwrap_or(false);
1473
1474    // ── Detect same-agent loop ───────────────────────────────────────────────
1475    //
1476    // If this is a no-code reroute and the agent is the same as the one that
1477    // produced the previous no-code result, flag it for blocking (#2410, #2686).
1478    let is_same_agent = if is_no_code_reroute {
1479        let prev_no_code_agent = ctx
1480            .get_task()
1481            .await
1482            .map(|t| t.no_code_last_agent.clone())
1483            .unwrap_or_default();
1484        !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name
1485    } else {
1486        false
1487    };
1488
1489    // ── Pure status decision ─────────────────────────────────────────────────
1490
1491    let final_status_owned = classify_final_status(&DecisionInput {
1492        agent_status: &resp.status,
1493        is_workflow_scope_failure: push_state.is_workflow_scope_failure,
1494        is_rebase_conflict_failure: push_state.is_rebase_conflict_failure,
1495        push_failed: push_state.push_failed,
1496        push_failures: push_state.push_failures,
1497        has_pr: git.has_pr,
1498        has_delegations,
1499        has_pushed: git.has_pushed,
1500        is_pr_base_invalid: git.pr_base_invalid,
1501        requires_pr,
1502        no_code_reroutes,
1503        max_reroutes,
1504        is_same_agent,
1505        is_completed_status: resp.status == "completed",
1506        is_retryable_blocked,
1507        is_superseded_conflict: superseded_pr.is_some(),
1508    });
1509    let final_status = final_status_owned.as_str();
1510
1511    // ── Post-decision side effects (tracing + store updates) ─────────────────
1512
1513    apply_post_decision_effects(
1514        &ctx,
1515        final_status,
1516        &resp.status,
1517        &push_state,
1518        is_no_code_reroute,
1519        git.has_pr,
1520        git.has_pushed,
1521        git.pr_base_invalid,
1522        has_delegations,
1523        is_external,
1524        no_code_reroutes,
1525        max_reroutes,
1526        agent_name,
1527    )
1528    .await;
1529
1530    // When the agent explicitly returned "blocked", persist its explanation into
1531    // last_error so `orch task get` surfaces the reason without requiring manual
1532    // inspection of task_runs.parsed_response.  The apply_post_decision_effects
1533    // branches above only fire on push/no-code scenarios; a raw agent-blocked
1534    // response falls through all of them and would leave last_error empty.
1535    if resp.status == "blocked" {
1536        let reason = blocked_reason.unwrap_or_else(|| agent_blocked_reason(&resp));
1537        if final_status == "new" {
1538            // Retryable external block: cooldown failed agent/model, clear selection,
1539            // and reroute after backoff instead of permanently blocking.
1540            crate::engine::cooldown::record_agent_failure_with_message(agent_name, &reason).await;
1541            if let Some(m) = model_name {
1542                crate::engine::cooldown::record_model_failure(agent_name, m).await;
1543            }
1544            ctx.set(&[
1545                (
1546                    "last_error",
1547                    serde_json::json!(format!(
1548                        "transient blocker (auto-retry scheduled): {reason}"
1549                    )),
1550                ),
1551                ("agent", serde_json::json!(null)),
1552                ("model", serde_json::json!(null)),
1553            ])
1554            .await;
1555            tracing::warn!(
1556                task_id = ctx.task_id,
1557                reason = %reason,
1558                "agent returned retryable blocked status — rerouting as new with cooldown"
1559            );
1560        } else if final_status == "blocked" {
1561            ctx.set(&[("last_error", serde_json::json!(reason))]).await;
1562        }
1563    }
1564
1565    if let Some(pr_number) = superseded_pr {
1566        tracing::info!(
1567            task_id = ctx.task_id,
1568            pr_number,
1569            "conflict reroute found the fix already on the default branch — closing superseded PR"
1570        );
1571        close_superseded_pr(&ctx, pr_number).await;
1572    }
1573
1574    ctx.set(&[("summary", serde_json::json!(resp.summary))])
1575        .await;
1576
1577    // Store token usage — prefer agent-parsed tokens, fall back to response
1578    store_token_usage(
1579        &ctx,
1580        input_tokens.or(resp.input_tokens),
1581        output_tokens.or(resp.output_tokens),
1582        model_name,
1583    )
1584    .await;
1585
1586    // Store learnings for memory (for future retries)
1587    response::store_learnings_from_response(
1588        task_id,
1589        new_attempts,
1590        agent_name,
1591        model_name,
1592        &resp,
1593        resp.error.as_deref(),
1594        store,
1595        repo,
1596        raw_stdout,
1597    )
1598    .await;
1599
1600    // Note: done → in_review transition is handled by the engine
1601    // after triggering the review agent (engine/mod.rs)
1602    Ok((final_status.to_string(), push_state.push_failed))
1603}
1604
1605/// Labels that indicate a task is non-code and can be marked done without a PR.
1606#[cfg(test)]
1607mod tests {
1608    use super::*;
1609
1610    #[test]
1611    fn missing_path_error_detects_io_not_found() {
1612        let err = anyhow::Error::from(std::io::Error::new(
1613            std::io::ErrorKind::NotFound,
1614            "simulated missing path",
1615        ));
1616        assert!(is_missing_path_error(&err));
1617    }
1618
1619    #[test]
1620    fn missing_path_error_detects_message_fallback() {
1621        let err = anyhow::anyhow!("push failed: No such file or directory (os error 2)");
1622        assert!(is_missing_path_error(&err));
1623    }
1624
1625    #[test]
1626    fn superseded_conflict_pr_requires_conflict_reroute_and_no_commits() {
1627        // Conflict reroute, nothing ahead of the default branch → close the PR.
1628        assert_eq!(superseded_conflict_pr(1, Some(42), false), Some(42));
1629        // Commits exist → the agent did work, keep the PR.
1630        assert_eq!(superseded_conflict_pr(1, Some(42), true), None);
1631        // No conflict reroute → an ordinary no-code run.
1632        assert_eq!(superseded_conflict_pr(0, Some(42), false), None);
1633        // No PR to close.
1634        assert_eq!(superseded_conflict_pr(1, None, false), None);
1635    }
1636
1637    /// End-to-end: a conflict reroute that comes back with zero commits ahead of
1638    /// the default branch closes the superseded PR and marks the task done,
1639    /// instead of blocking it as a same-agent no-code loop (#3780).
1640    #[tokio::test]
1641    async fn conflict_reroute_with_no_commits_closes_pr_and_marks_done() {
1642        fn git(dir: &std::path::Path, args: &[&str]) {
1643            let ok = std::process::Command::new("git")
1644                .args(args)
1645                .current_dir(dir)
1646                .env("GIT_AUTHOR_NAME", "t")
1647                .env("GIT_AUTHOR_EMAIL", "t@example.com")
1648                .env("GIT_COMMITTER_NAME", "t")
1649                .env("GIT_COMMITTER_EMAIL", "t@example.com")
1650                .output()
1651                .expect("git runs")
1652                .status
1653                .success();
1654            assert!(ok, "git {args:?} failed");
1655        }
1656
1657        // A worktree whose branch has nothing ahead of origin/main — the state
1658        // the agent leaves behind when the fix already landed on the base.
1659        let dir = tempfile::tempdir().unwrap();
1660        let origin = dir.path().join("origin.git");
1661        std::fs::create_dir(&origin).unwrap();
1662        git(&origin, &["init", "--bare", "-b", "main"]);
1663        let work_dir = dir.path().join("work");
1664        std::fs::create_dir(&work_dir).unwrap();
1665        git(&work_dir, &["init", "-b", "main"]);
1666        git(
1667            &work_dir,
1668            &["remote", "add", "origin", &origin.display().to_string()],
1669        );
1670        std::fs::write(work_dir.join("a.txt"), "a").unwrap();
1671        git(&work_dir, &["add", "-A"]);
1672        git(&work_dir, &["commit", "-m", "init"]);
1673        git(&work_dir, &["push", "-u", "origin", "main"]);
1674        git(&work_dir, &["checkout", "-b", "fix-branch"]);
1675
1676        let wt = worktree::WorktreeSetup {
1677            work_dir: work_dir.clone(),
1678            branch: "fix-branch".to_string(),
1679            default_branch: "main".to_string(),
1680            main_project_dir: work_dir.clone(),
1681        };
1682
1683        let store = Arc::new(TaskStore::open_memory().await.unwrap());
1684        let store_id = store
1685            .create(&crate::store::NewTask {
1686                external_id: Some("3772".to_string()),
1687                repo: "owner/repo".to_string(),
1688                origin: "github".to_string(),
1689                title: "Fix the thing".to_string(),
1690                body: String::new(),
1691                source: "manual".to_string(),
1692                source_id: String::new(),
1693                author: "bot".to_string(),
1694                url: String::new(),
1695                labels: vec![],
1696                parent_id: None,
1697            })
1698            .await
1699            .unwrap();
1700        store
1701            .set_fields(store_id, &[("pr_number", serde_json::json!(42))])
1702            .await
1703            .unwrap();
1704        // review_poll rerouted this task to resolve a merge conflict.
1705        store
1706            .increment(store_id, "merge_conflict_retries")
1707            .await
1708            .unwrap();
1709        // A previous no-code run by the same agent — without the superseded
1710        // check this run would block the task as a same-agent loop.
1711        store
1712            .set_fields(
1713                store_id,
1714                &[("no_code_last_agent", serde_json::json!("claude"))],
1715            )
1716            .await
1717            .unwrap();
1718
1719        let parsed = agents::ParsedResponse {
1720            response: AgentResponse {
1721                status: "done".to_string(),
1722                summary: "the fix is already on main".to_string(),
1723                ..Default::default()
1724            },
1725            input_tokens: None,
1726            output_tokens: None,
1727            duration_ms: None,
1728        };
1729
1730        let (final_status, push_failed) = handle_success(
1731            "3772",
1732            parsed,
1733            &wt,
1734            "Fix the thing",
1735            "claude",
1736            Some("sonnet"),
1737            1,
1738            "owner/repo",
1739            &Some(Arc::clone(&store)),
1740            "",
1741        )
1742        .await
1743        .unwrap();
1744
1745        assert_eq!(
1746            final_status, "done",
1747            "a superseded conflict reroute must end done, not blocked"
1748        );
1749        assert!(!push_failed);
1750
1751        let task = store.get(store_id).await.unwrap();
1752        assert!(
1753            task.last_error.contains("superseded"),
1754            "the close attempt must be recorded on the task: {}",
1755            task.last_error
1756        );
1757    }
1758
1759    /// A conflict reroute with zero commits ahead marks the task done instead of
1760    /// blocking it as a same-agent no-code loop (#3780).
1761    #[test]
1762    fn classify_superseded_conflict_marks_done() {
1763        let status = classify_final_status(&DecisionInput {
1764            agent_status: "done",
1765            requires_pr: true,
1766            is_same_agent: true,
1767            is_superseded_conflict: true,
1768            ..Default::default()
1769        });
1770        assert_eq!(status, "done");
1771    }
1772
1773    // ── classify_final_status — one test per decision branch ─────────────────
1774
1775    /// Branch 1: workflow-scope push failure → block immediately (non-retryable).
1776    #[test]
1777    fn classify_workflow_scope_failure_blocks() {
1778        let status = classify_final_status(&DecisionInput {
1779            is_workflow_scope_failure: true,
1780            push_failed: true,
1781            push_failures: 1,
1782            ..Default::default()
1783        });
1784        assert_eq!(status, "blocked");
1785    }
1786
1787    /// Branch 1b: automatic rebase recovery failed (conflicts) → block immediately.
1788    #[test]
1789    fn classify_rebase_conflict_failure_blocks() {
1790        let status = classify_final_status(&DecisionInput {
1791            is_rebase_conflict_failure: true,
1792            push_failed: true,
1793            ..Default::default()
1794        });
1795        assert_eq!(status, "blocked");
1796    }
1797
1798    /// Branch 2a: push failed, < 3 times → reroute with "new".
1799    #[test]
1800    fn classify_push_failed_under_threshold_reroutes() {
1801        let status = classify_final_status(&DecisionInput {
1802            push_failed: true,
1803            push_failures: 2, // < 3
1804            ..Default::default()
1805        });
1806        assert_eq!(status, "new");
1807    }
1808
1809    /// Branch 2b: push failed >= 3 times → block for human intervention.
1810    #[test]
1811    fn classify_push_failed_at_threshold_blocks() {
1812        let status = classify_final_status(&DecisionInput {
1813            push_failed: true,
1814            push_failures: 3, // >= 3
1815            ..Default::default()
1816        });
1817        assert_eq!(status, "blocked");
1818    }
1819
1820    /// Branch 3: done + PR exists → send to review.
1821    #[test]
1822    fn classify_done_with_pr_goes_to_needs_review() {
1823        let status = classify_final_status(&DecisionInput {
1824            agent_status: "done",
1825            has_pr: true,
1826            ..Default::default()
1827        });
1828        assert_eq!(status, "needs_review");
1829    }
1830
1831    /// Branch 4: done + delegations, no PR → block (waiting on child tasks).
1832    #[test]
1833    fn classify_done_with_delegations_no_pr_blocks() {
1834        let status = classify_final_status(&DecisionInput {
1835            agent_status: "done",
1836            has_delegations: true,
1837            ..Default::default()
1838        });
1839        assert_eq!(status, "blocked");
1840    }
1841
1842    /// Branch 5: done + commits pushed but PR creation failed → re-dispatch.
1843    #[test]
1844    fn classify_done_pushed_no_pr_reroutes_for_pr_creation() {
1845        let status = classify_final_status(&DecisionInput {
1846            agent_status: "done",
1847            has_pushed: true,
1848            ..Default::default()
1849        });
1850        assert_eq!(status, "routed");
1851    }
1852
1853    /// Branch 5b: done + pushed + PR base invalid → block (terminal error).
1854    #[test]
1855    fn classify_done_pushed_pr_base_invalid_blocks() {
1856        let status = classify_final_status(&DecisionInput {
1857            agent_status: "done",
1858            has_pushed: true,
1859            is_pr_base_invalid: true,
1860            ..Default::default()
1861        });
1862        assert_eq!(status, "blocked");
1863    }
1864
1865    /// Branch 5c: completed + pushed + PR base invalid → block (terminal error).
1866    #[test]
1867    fn classify_completed_pushed_pr_base_invalid_blocks() {
1868        let status = classify_final_status(&DecisionInput {
1869            agent_status: "completed",
1870            is_completed_status: true,
1871            has_pushed: true,
1872            is_pr_base_invalid: true,
1873            ..Default::default()
1874        });
1875        assert_eq!(status, "blocked");
1876    }
1877
1878    /// Branch 6a: done + requires_pr, under max reroutes → reroute.
1879    #[test]
1880    fn classify_done_requires_pr_under_max_reroutes() {
1881        let status = classify_final_status(&DecisionInput {
1882            agent_status: "done",
1883            requires_pr: true,
1884            no_code_reroutes: 1,
1885            max_reroutes: 3,
1886            is_same_agent: false,
1887            ..Default::default()
1888        });
1889        assert_eq!(status, "new");
1890    }
1891
1892    /// Branch 6a edge: same agent on no-code reroute → block immediately.
1893    #[test]
1894    fn classify_done_requires_pr_same_agent_blocks_immediately() {
1895        let status = classify_final_status(&DecisionInput {
1896            agent_status: "done",
1897            requires_pr: true,
1898            no_code_reroutes: 1,
1899            max_reroutes: 3,
1900            is_same_agent: true, // Same agent would be selected again
1901            ..Default::default()
1902        });
1903        assert_eq!(status, "blocked");
1904    }
1905
1906    /// Branch 6b: done + requires_pr, at max reroutes → block.
1907    #[test]
1908    fn classify_done_requires_pr_at_max_reroutes_blocks() {
1909        let status = classify_final_status(&DecisionInput {
1910            agent_status: "done",
1911            requires_pr: true,
1912            no_code_reroutes: 3,
1913            max_reroutes: 3,
1914            is_same_agent: false,
1915            ..Default::default()
1916        });
1917        assert_eq!(status, "blocked");
1918    }
1919
1920    /// Branch 6b edge: exceeding max also blocks.
1921    #[test]
1922    fn classify_done_requires_pr_exceeds_max_reroutes_blocks() {
1923        let status = classify_final_status(&DecisionInput {
1924            agent_status: "done",
1925            requires_pr: true,
1926            no_code_reroutes: 5,
1927            max_reroutes: 3,
1928            is_same_agent: false,
1929            ..Default::default()
1930        });
1931        assert_eq!(status, "blocked");
1932    }
1933
1934    /// Same agent on external task requiring PR → block immediately (not wait for max).
1935    #[test]
1936    fn classify_done_external_no_pushed_same_agent_blocks_immediately() {
1937        let status = classify_final_status(&DecisionInput {
1938            agent_status: "done",
1939            requires_pr: true, // always true for external tasks with !has_pr + done
1940            no_code_reroutes: 0,
1941            max_reroutes: 3,
1942            is_same_agent: true,
1943            ..Default::default()
1944        });
1945        assert_eq!(status, "blocked");
1946    }
1947
1948    /// External task with no pushed commits: reroute (agent must produce commits
1949    /// before the issue can be closed). This was the source of issue #1898 — an
1950    /// agent claiming "already implemented" without any code changes would
1951    /// previously match non-code keywords and close the issue falsely.
1952    ///
1953    /// External tasks with done+!has_pr+!has_pushed always have requires_pr=true
1954    /// (see handle_success), so they are caught by branch 6, not branch 7.
1955    #[test]
1956    fn classify_done_external_no_pushed_commits_reroutes() {
1957        let status = classify_final_status(&DecisionInput {
1958            agent_status: "done",
1959            has_pushed: false,
1960            requires_pr: true, // always true for external tasks with !has_pr + done
1961            no_code_reroutes: 0,
1962            max_reroutes: 3,
1963            is_same_agent: false,
1964            ..Default::default()
1965        });
1966        assert_eq!(status, "new");
1967    }
1968
1969    /// Internal task with no pushed commits may still be marked done — internal
1970    /// tasks may produce no git-visible changes.
1971    #[test]
1972    fn classify_done_internal_task_no_pr_is_done() {
1973        let status = classify_final_status(&DecisionInput {
1974            agent_status: "done",
1975            has_pushed: false,
1976            requires_pr: false,
1977            ..Default::default()
1978        });
1979        assert_eq!(status, "done");
1980    }
1981
1982    /// Pass-through: non-"done" status is returned unchanged.
1983    #[test]
1984    fn classify_non_done_status_passes_through() {
1985        for status_str in &["in_progress", "needs_review", "blocked"] {
1986            let result = classify_final_status(&DecisionInput {
1987                agent_status: status_str,
1988                ..Default::default()
1989            });
1990            assert_eq!(
1991                result, *status_str,
1992                "status '{status_str}' should pass through unchanged"
1993            );
1994        }
1995    }
1996
1997    /// Descriptive completion text should normalize to done.
1998    #[test]
1999    fn classify_descriptive_completion_status_normalizes_to_done() {
2000        let status = classify_final_status(&DecisionInput {
2001            agent_status: "Trading scan complete. Nothing to trade.",
2002            ..Default::default()
2003        });
2004        assert_eq!(status, "done");
2005    }
2006
2007    /// Do not normalize to done when completion-like text includes failure cues.
2008    #[test]
2009    fn classify_descriptive_completion_with_failure_cues_does_not_normalize() {
2010        let status = classify_final_status(&DecisionInput {
2011            agent_status: "completed but failed to push branch due to error",
2012            ..Default::default()
2013        });
2014        assert_eq!(status, "completed but failed to push branch due to error");
2015    }
2016
2017    /// push_failed takes precedence over done+has_pr (push failure detected first).
2018    #[test]
2019    fn classify_push_failed_takes_precedence_over_has_pr() {
2020        let status = classify_final_status(&DecisionInput {
2021            agent_status: "done",
2022            has_pr: true,
2023            push_failed: true,
2024            push_failures: 1,
2025            ..Default::default()
2026        });
2027        // push_failed branch runs before done+has_pr check
2028        assert_eq!(status, "new");
2029    }
2030
2031    /// workflow_scope_failure takes precedence over push_failed < 3.
2032    #[test]
2033    fn classify_workflow_scope_failure_takes_precedence_over_push_reroute() {
2034        let status = classify_final_status(&DecisionInput {
2035            is_workflow_scope_failure: true,
2036            push_failed: true,
2037            push_failures: 1, // would normally reroute
2038            ..Default::default()
2039        });
2040        assert_eq!(status, "blocked");
2041    }
2042
2043    // ── "completed" status tests ────────────────────────────────────────────────
2044
2045    /// "completed" + has_pr → needs_review.
2046    #[test]
2047    fn classify_completed_with_pr_goes_to_needs_review() {
2048        let status = classify_final_status(&DecisionInput {
2049            agent_status: "completed",
2050            has_pr: true,
2051            is_completed_status: true,
2052            ..Default::default()
2053        });
2054        assert_eq!(status, "needs_review");
2055    }
2056
2057    /// "completed" + delegations, no PR → blocked.
2058    #[test]
2059    fn classify_completed_with_delegations_no_pr_blocks() {
2060        let status = classify_final_status(&DecisionInput {
2061            agent_status: "completed",
2062            has_delegations: true,
2063            is_completed_status: true,
2064            ..Default::default()
2065        });
2066        assert_eq!(status, "blocked");
2067    }
2068
2069    /// "completed" + pushed, no PR → routed for PR creation.
2070    #[test]
2071    fn classify_completed_pushed_no_pr_reroutes_for_pr_creation() {
2072        let status = classify_final_status(&DecisionInput {
2073            agent_status: "completed",
2074            has_pushed: true,
2075            is_completed_status: true,
2076            ..Default::default()
2077        });
2078        assert_eq!(status, "routed");
2079    }
2080
2081    /// "completed" + external task requiring PR, under max reroutes → reroute.
2082    #[test]
2083    fn classify_completed_requires_pr_under_max_reroutes() {
2084        let status = classify_final_status(&DecisionInput {
2085            agent_status: "completed",
2086            requires_pr: true,
2087            no_code_reroutes: 1,
2088            max_reroutes: 3,
2089            is_completed_status: true,
2090            is_same_agent: false,
2091            ..Default::default()
2092        });
2093        assert_eq!(status, "new");
2094    }
2095
2096    /// "completed" + external task, at max reroutes → block.
2097    #[test]
2098    fn classify_completed_requires_pr_at_max_reroutes_blocks() {
2099        let status = classify_final_status(&DecisionInput {
2100            agent_status: "completed",
2101            requires_pr: true,
2102            no_code_reroutes: 3,
2103            max_reroutes: 3,
2104            is_completed_status: true,
2105            is_same_agent: false,
2106            ..Default::default()
2107        });
2108        assert_eq!(status, "blocked");
2109    }
2110
2111    /// "completed" + external task, same agent on no-code reroute → block immediately.
2112    #[test]
2113    fn classify_completed_same_agent_blocks_immediately() {
2114        let status = classify_final_status(&DecisionInput {
2115            agent_status: "completed",
2116            requires_pr: true,
2117            no_code_reroutes: 1,
2118            max_reroutes: 3,
2119            is_completed_status: true,
2120            is_same_agent: true,
2121            ..Default::default()
2122        });
2123        assert_eq!(status, "blocked");
2124    }
2125
2126    /// "completed" on internal task (no requires_pr) → mark done.
2127    #[test]
2128    fn classify_completed_internal_task_is_done() {
2129        let status = classify_final_status(&DecisionInput {
2130            agent_status: "completed",
2131            requires_pr: false,
2132            is_completed_status: true,
2133            ..Default::default()
2134        });
2135        assert_eq!(status, "done");
2136    }
2137
2138    // ── agent_blocked_reason — regression tests for issue #3012 ──────────────
2139
2140    /// Agent explicitly returned blocked with an error field — use error as reason.
2141    #[test]
2142    fn agent_blocked_reason_prefers_error_field() {
2143        let resp = crate::parser::AgentResponse {
2144            status: "blocked".to_string(),
2145            error: Some("CDP endpoint not reachable".to_string()),
2146            summary: "Could not complete research".to_string(),
2147            remaining: vec!["Run x-twitter-brave with reachable endpoint".to_string()],
2148            ..Default::default()
2149        };
2150        let reason = agent_blocked_reason(&resp);
2151        assert!(
2152            reason.contains("CDP endpoint not reachable"),
2153            "reason: {reason}"
2154        );
2155        assert!(reason.contains("Remaining:"), "reason: {reason}");
2156        assert!(reason.contains("x-twitter-brave"), "reason: {reason}");
2157    }
2158
2159    /// No error field — falls back to summary.
2160    #[test]
2161    fn agent_blocked_reason_falls_back_to_summary() {
2162        let resp = crate::parser::AgentResponse {
2163            status: "blocked".to_string(),
2164            error: None,
2165            summary: "Could not access required data sources in sandboxed environment".to_string(),
2166            remaining: vec![],
2167            ..Default::default()
2168        };
2169        let reason = agent_blocked_reason(&resp);
2170        assert_eq!(
2171            reason,
2172            "Could not access required data sources in sandboxed environment"
2173        );
2174    }
2175
2176    /// No error or summary — returns generic fallback.
2177    #[test]
2178    fn agent_blocked_reason_generic_fallback() {
2179        let resp = crate::parser::AgentResponse {
2180            status: "blocked".to_string(),
2181            ..Default::default()
2182        };
2183        let reason = agent_blocked_reason(&resp);
2184        assert_eq!(reason, "agent returned blocked status without a reason");
2185    }
2186
2187    /// Agent-returned "blocked" status passes through classify_final_status unchanged.
2188    #[test]
2189    fn classify_agent_blocked_passes_through() {
2190        let status = classify_final_status(&DecisionInput {
2191            agent_status: "blocked",
2192            ..Default::default()
2193        });
2194        assert_eq!(status, "blocked");
2195    }
2196
2197    #[test]
2198    fn classify_retryable_agent_blocked_reroutes() {
2199        let status = classify_final_status(&DecisionInput {
2200            agent_status: "blocked",
2201            is_retryable_blocked: true,
2202            ..Default::default()
2203        });
2204        assert_eq!(status, "new");
2205    }
2206
2207    #[test]
2208    fn retryable_blocked_reason_detects_transient_patterns() {
2209        assert!(is_retryable_blocked_reason(
2210            "Twitter access unavailable: service timed out"
2211        ));
2212        assert!(is_retryable_blocked_reason(
2213            "final commit blocked by git worktree lock permission error"
2214        ));
2215        assert!(is_retryable_blocked_reason(
2216            "could not commit due a worktree git lock permission error"
2217        ));
2218        assert!(is_retryable_blocked_reason(
2219            "git lockfile permission restrictions in this environment"
2220        ));
2221        assert!(is_retryable_blocked_reason(
2222            "API rate limit hit, try again later"
2223        ));
2224        assert!(is_retryable_blocked_reason(
2225            "Credential bean/hyperliquid-address exists in the pass store but GPG decryption fails in this agent session — no passphrase available."
2226        ));
2227        assert!(is_retryable_blocked_reason(
2228            "credential resolution failed via PasswordStoreProvider; AgentVaultProvider unavailable"
2229        ));
2230    }
2231
2232    #[test]
2233    fn retryable_blocked_reason_rejects_permanent_patterns() {
2234        assert!(!is_retryable_blocked_reason(
2235            "invalid request payload: malformed JSON"
2236        ));
2237        assert!(!is_retryable_blocked_reason(
2238            "resource not found in repository"
2239        ));
2240    }
2241}