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