Skip to main content

orch/engine/runner/
response_handler.rs

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