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            let mut pr_base_invalid = false;
720            if err_str.contains("422") {
721                if err_str.contains("No commits between") || err_str.contains("head") {
722                    tracing::info!(
723                        task_id = ctx.task_id,
724                        "PR creation returned 422/no-commits/head-invalid — agent made no code changes or branch merged, marking done"
725                    );
726                    has_pushed = false;
727                } else if git_ops::is_invalid_base_error(&err_str) {
728                    tracing::error!(
729                        task_id = ctx.task_id,
730                        "PR creation failed with invalid base branch — blocking task"
731                    );
732                    pr_base_invalid = true;
733                }
734            }
735            (false, has_pushed, pr_base_invalid)
736        }
737    }
738}
739
740/// Run the full auto-commit → push → PR pipeline.
741async fn run_git_ops(
742    ctx: &StoreCtx<'_>,
743    wt: &worktree::WorktreeSetup,
744    task_title: &str,
745    resp: &AgentResponse,
746    agent_name: &str,
747    model_name: Option<&str>,
748    new_attempts: u32,
749) -> GitOpsResult {
750    auto_commit_changes(ctx, &wt.work_dir, task_title, agent_name, new_attempts).await;
751
752    let mut has_commits =
753        check_commits_and_clear_stale_errors(ctx, &wt.work_dir, &wt.default_branch).await;
754
755    // Skip push + PR if there are no commits ahead of the default branch.
756    // No-op tasks (e.g. "nothing to execute") produce no commits, so pushing
757    // and creating a PR would just waste API calls and trigger 422 errors.
758    let push_result = if !has_commits {
759        store::store_log_activity(
760            ctx.store,
761            ctx.repo,
762            ctx.task_id,
763            "push",
764            None,
765            None,
766            Some(agent_name),
767            model_name,
768            Some(&serde_json::json!({
769                "status": "skipped",
770                "reason": "no_commits_ahead",
771                "branch": wt.branch,
772                "default_branch": wt.default_branch,
773            })),
774        )
775        .await;
776        PushResult::NoCommits
777    } else {
778        push_branch_with_log(
779            ctx,
780            &wt.work_dir,
781            &wt.branch,
782            &wt.default_branch,
783            agent_name,
784            model_name,
785        )
786        .await
787    };
788    let push_result = if matches!(push_result, PushResult::WorktreeMissing) {
789        match recover_missing_worktree_for_push(ctx, wt, task_title, agent_name, model_name).await {
790            Ok(recovered) => {
791                let wt_for_ops = &recovered;
792                has_commits = check_commits_and_clear_stale_errors(
793                    ctx,
794                    &wt_for_ops.work_dir,
795                    &wt_for_ops.default_branch,
796                )
797                .await;
798                if !has_commits {
799                    PushResult::NoCommits
800                } else {
801                    push_branch_with_log(
802                        ctx,
803                        &wt_for_ops.work_dir,
804                        &wt_for_ops.branch,
805                        &wt_for_ops.default_branch,
806                        agent_name,
807                        model_name,
808                    )
809                    .await
810                }
811            }
812            Err(e) => {
813                tracing::error!(
814                    task_id = ctx.task_id,
815                    err = %e,
816                    "worktree recovery before push failed"
817                );
818                ctx.append_activity(
819                    "push_recover_worktree",
820                    Some(agent_name),
821                    model_name,
822                    Some(&serde_json::json!({
823                        "status": "error",
824                        "worktree": wt.work_dir.display().to_string(),
825                        "branch": wt.branch,
826                        "error": e.to_string(),
827                    })),
828                )
829                .await;
830                ctx.set(&[(
831                    "last_error",
832                    serde_json::json!(format!(
833                        "push failed: worktree disappeared and recovery failed: {e}"
834                    )),
835                )])
836                .await;
837                PushResult::Failed
838            }
839        }
840    } else {
841        push_result
842    };
843
844    let mut has_pushed = matches!(push_result, PushResult::Success);
845    let mut has_pr = false;
846
847    // Create PR (skip if push failed or repo is unknown)
848    if has_commits && matches!(push_result, PushResult::Failed) {
849        tracing::warn!(
850            task_id = ctx.task_id,
851            "skipping PR creation due to push failure"
852        );
853    } else if matches!(push_result, PushResult::Rebased) {
854        // Rebase succeeded — don't create PR now, task will be re-routed to new agent.
855        // The new agent will push the rebased commits.
856        tracing::info!(
857            task_id = ctx.task_id,
858            "skipping PR creation after successful rebase — task will be re-routed"
859        );
860    } else if matches!(push_result, PushResult::NoCommits) {
861        tracing::info!(
862            task_id = ctx.task_id,
863            "no commits ahead of default branch, skipping push + PR"
864        );
865    } else if !has_pushed {
866        // no commits — already logged "no commits ahead, skipping push + PR" at INFO level above
867    } else if ctx.repo.is_empty() {
868        tracing::warn!(
869            task_id = ctx.task_id,
870            "skipping PR creation — repo is empty (internal task?)"
871        );
872    } else {
873        let (pr, updated_pushed, pr_base_invalid) = create_pr_with_log(
874            ctx, wt, task_title, resp, agent_name, model_name, has_pushed,
875        )
876        .await;
877        has_pr = pr;
878        has_pushed = updated_pushed;
879        return GitOpsResult {
880            has_pr,
881            has_pushed,
882            has_commits,
883            pr_base_invalid,
884        };
885    }
886
887    GitOpsResult {
888        has_pr,
889        has_pushed,
890        has_commits,
891        pr_base_invalid: false,
892    }
893}
894
895// ── Private status-computation helpers ───────────────────────────────────────
896
897/// Read push-failure state from the store and increment `push_failures` if needed.
898async fn detect_push_failure_state(
899    ctx: &StoreCtx<'_>,
900    has_commits: bool,
901    has_pushed: bool,
902) -> PushFailureState {
903    // Load stored task once to inspect last_error (avoid repeated DB reads).
904    let stored_last_error = ctx
905        .get_task()
906        .await
907        .map(|t| t.last_error)
908        .unwrap_or_default();
909
910    let push_failed = !has_pushed && has_commits && stored_last_error.contains("push failed");
911
912    // Detect workflow scope errors — these are non-retryable token permission issues.
913    // Rerouting to a different agent won't help since the same token is used.
914    let is_workflow_scope_failure = push_failed
915        && (stored_last_error.contains("workflow` scope")
916            || stored_last_error.contains("workflow' scope")
917            || (stored_last_error.contains("refusing to allow")
918                && stored_last_error.contains("workflow")));
919
920    // Detect successful rebase after push failure — this is a recovery case,
921    // not a true failure. The task will be re-routed to a new agent who can
922    // push the rebased commits. Don't increment push_failures counter in this case.
923    let is_rebased_recovery = push_failed && stored_last_error.contains("rebased successfully");
924    let is_rebase_conflict_failure =
925        push_failed && stored_last_error.contains("push failed and rebase failed:");
926
927    // Track push failures — block after 3 consecutive failures.
928    // Workflow-scope failures are non-retryable and blocked immediately; do not
929    // increment this retry counter for them so that later normal push failures
930    // are not prematurely blocked.
931    // Also skip incrementing when rebase succeeded — this is recovery, not failure.
932    let push_failures = if push_failed
933        && !is_workflow_scope_failure
934        && !is_rebased_recovery
935        && !is_rebase_conflict_failure
936    {
937        match ctx.increment("push_failures").await {
938            Ok(v) => v,
939            Err(e) => {
940                tracing::warn!(
941                    task_id = ctx.task_id,
942                    err = %e,
943                    "failed to increment push_failures — reading current value from store"
944                );
945                // On DB error, read current value to ensure blocking threshold is still reachable
946                ctx.get_task()
947                    .await
948                    .map(|t| (t.push_failures.max(0) as u64) + 1)
949                    .unwrap_or(1) // assume at least 1 if we can't read either
950            }
951        }
952    } else {
953        0
954    };
955
956    PushFailureState {
957        push_failed,
958        is_workflow_scope_failure,
959        is_rebase_conflict_failure,
960        push_failures,
961        stored_last_error,
962    }
963}
964
965/// Increment `no_code_reroutes` and log a warning when the agent completed
966/// without producing code changes on an external task requiring a PR.
967async fn detect_no_code_reroutes(
968    ctx: &StoreCtx<'_>,
969    is_no_code_reroute: bool,
970    new_attempts: u32,
971    max_reroutes: u32,
972) -> u64 {
973    if !is_no_code_reroute {
974        return 0;
975    }
976    tracing::warn!(
977        task_id = ctx.task_id,
978        attempts = new_attempts,
979        max_reroutes,
980        "agent reported done but produced no code changes on external task requiring PR"
981    );
982    match ctx.increment("no_code_reroutes").await {
983        Ok(v) => v,
984        Err(e) => {
985            tracing::warn!(
986                task_id = ctx.task_id,
987                err = %e,
988                "failed to increment no_code_reroutes — reading current value from store"
989            );
990            // On DB error, read current value to ensure blocking threshold is still reachable
991            ctx.get_task()
992                .await
993                .map(|t| (t.no_code_reroutes.max(0) as u64) + 1)
994                .unwrap_or(1) // assume at least 1 if we can't read either
995        }
996    }
997}
998
999// ── Post-decision side-effect helpers ─────────────────────────────────────────
1000
1001/// Build a human-readable reason string from a "blocked" agent response.
1002///
1003/// Priority: explicit `error` field → `summary` → fallback.
1004/// Appends `remaining` items if present so operators know what was left to do.
1005fn agent_blocked_reason(resp: &crate::parser::AgentResponse) -> String {
1006    let summary_opt = if !resp.summary.is_empty() {
1007        Some(resp.summary.as_str())
1008    } else {
1009        None
1010    };
1011    let base = resp
1012        .error
1013        .as_deref()
1014        .filter(|s| !s.is_empty())
1015        .or(summary_opt)
1016        .unwrap_or("agent returned blocked status without a reason");
1017    if resp.remaining.is_empty() {
1018        base.to_string()
1019    } else {
1020        format!("{base}. Remaining: {}", resp.remaining.join("; "))
1021    }
1022}
1023
1024/// Heuristic classifier for agent-returned blocked reasons.
1025///
1026/// Returns true when the reason looks transient/retryable (network/provider/
1027/// auth-temporary/environment availability issues), and false for likely
1028/// permanent/input/configuration issues.
1029fn is_retryable_blocked_reason(reason: &str) -> bool {
1030    let r = reason.to_lowercase();
1031
1032    let permanent_patterns = [
1033        "not found",
1034        "does not exist",
1035        "invalid",
1036        "malformed",
1037        "syntax error",
1038        "unsupported",
1039        "unimplemented",
1040        "manual intervention required",
1041        "human intervention required",
1042    ];
1043    if permanent_patterns.iter().any(|p| r.contains(p)) {
1044        return false;
1045    }
1046
1047    let transient_patterns = [
1048        "unavailable",
1049        "temporarily unavailable",
1050        "timeout",
1051        "timed out",
1052        "connection reset",
1053        "connection refused",
1054        "network error",
1055        "rate limit",
1056        "try again",
1057        "service down",
1058        "permission denied",
1059        "worktree lock",
1060        "git lock",
1061        "lockfile permission",
1062        "lock permission",
1063        "resource busy",
1064        "could not resolve host",
1065        // Credential providers in headless/tmux sessions can fail transiently
1066        // when GPG agent/passphrase is unavailable.
1067        "gpg decryption fails",
1068        "gpg decryption failed",
1069        "gpg agent",
1070        "no passphrase available",
1071        "passphrase unavailable",
1072        "pass store",
1073        "passwordstoreprovider",
1074        "agentvaultprovider",
1075        "onepasswordprovider",
1076        "decryption failed in this agent session",
1077    ];
1078    transient_patterns.iter().any(|p| r.contains(p))
1079}
1080
1081/// Apply tracing and store writes that depend on the final status decision.
1082#[allow(clippy::too_many_arguments)]
1083async fn apply_post_decision_effects(
1084    ctx: &StoreCtx<'_>,
1085    final_status: &str,
1086    resp_status: &str,
1087    push_state: &PushFailureState,
1088    is_no_code_reroute: bool,
1089    has_pr: bool,
1090    has_pushed: bool,
1091    is_pr_base_invalid: bool,
1092    has_delegations: bool,
1093    is_external: bool,
1094    no_code_reroutes: u64,
1095    max_reroutes: u32,
1096    agent_name: &str,
1097) {
1098    if push_state.is_workflow_scope_failure {
1099        // Non-retryable — block immediately with actionable guidance.
1100        tracing::error!(
1101            task_id = ctx.task_id,
1102            "push failed: token lacks `workflow` OAuth scope — blocking immediately \
1103             (rerouting would not help)"
1104        );
1105        ctx.set(&[(
1106            "last_error",
1107            serde_json::json!(format!(
1108                "push failed: GitHub token lacks `workflow` OAuth scope. \
1109                 The agent modified .github/workflows/ files but the token cannot push them. \
1110                 Fix: add `workflow` scope to your GitHub token, or use a GitHub App for auth. \
1111                 Original error: {}",
1112                push_state.stored_last_error
1113            )),
1114        )])
1115        .await;
1116    } else if push_state.is_rebase_conflict_failure {
1117        tracing::error!(
1118            task_id = ctx.task_id,
1119            "push failed and automatic rebase recovery failed — blocking for human intervention"
1120        );
1121        ctx.set(&[
1122            ("agent", serde_json::json!(null)),
1123            ("model", serde_json::json!(null)),
1124        ])
1125        .await;
1126    } else if push_state.push_failed {
1127        // Check if this is a rebase recovery case (rebase succeeded after push failure).
1128        let is_rebased_recovery = push_state
1129            .stored_last_error
1130            .contains("rebased successfully");
1131        // Clear agent and model so router picks a different one on reroute (#1604)
1132        ctx.set(&[
1133            ("agent", serde_json::json!(null)),
1134            ("model", serde_json::json!(null)),
1135        ])
1136        .await;
1137        let push_failures = push_state.push_failures;
1138        if is_rebased_recovery {
1139            // Rebase succeeded — next agent will have up-to-date history.
1140            tracing::info!(
1141                task_id = ctx.task_id,
1142                "push failed but rebase succeeded — rerouting to different agent with up-to-date history"
1143            );
1144        } else if push_failures >= 3 {
1145            tracing::error!(
1146                task_id = ctx.task_id,
1147                push_failures,
1148                "push failed {push_failures} times — blocking for human intervention"
1149            );
1150        } else {
1151            tracing::warn!(
1152                task_id = ctx.task_id,
1153                push_failures,
1154                "agent done but push failed ({push_failures}/3) — rerouting to different agent"
1155            );
1156        }
1157    } else if resp_status == "done" && !has_pr && has_delegations {
1158        tracing::info!(
1159            task_id = ctx.task_id,
1160            "agent reported done with delegations but no PR — setting blocked"
1161        );
1162    } else if (resp_status == "done" || resp_status == "completed")
1163        && !has_pr
1164        && has_pushed
1165        && is_pr_base_invalid
1166    {
1167        tracing::error!(
1168            task_id = ctx.task_id,
1169            "PR creation failed with invalid base branch — blocking for human intervention"
1170        );
1171        ctx.set(&[(
1172            "last_error",
1173            serde_json::json!(
1174                "PR creation failed: the base branch is invalid. The agent's commits were pushed successfully, \
1175                 but GitHub rejected the PR because the base branch does not exist in the repository. \
1176                 Verify the repository has a valid default branch and manually create the PR if needed."
1177            ),
1178        )])
1179        .await;
1180    } else if resp_status == "done" && !has_pr && has_pushed {
1181        // Push succeeded but PR creation failed after retries.
1182        tracing::warn!(
1183            task_id = ctx.task_id,
1184            "agent done, commits pushed, but PR creation failed — re-dispatching as routed"
1185        );
1186    } else if is_no_code_reroute {
1187        // Record which agent produced this no-code result so the same-agent
1188        // detection on the next run can prevent loop-backs (#2410, #2686).
1189        // Also clear agent/model to force re-routing.
1190
1191        if final_status == "blocked" {
1192            // final_status is "blocked" either because max_reroutes was exhausted
1193            // or because is_same_agent was detected (both cases are handled by
1194            // classify_final_status). Log accordingly.
1195            let prev_no_code_agent = ctx
1196                .get_task()
1197                .await
1198                .map(|t| t.no_code_last_agent.clone())
1199                .unwrap_or_default();
1200            let is_same_agent = !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name;
1201
1202            let msg = if is_same_agent {
1203                tracing::error!(
1204                    task_id = ctx.task_id,
1205                    agent = %agent_name,
1206                    "blocking same-agent no-code loop: agent {} would be selected again",
1207                    agent_name
1208                );
1209                format!(
1210                    "agent {} completed without code changes twice — same-agent loop detected, blocking for human review",
1211                    agent_name
1212                )
1213            } else {
1214                tracing::error!(
1215                    task_id = ctx.task_id,
1216                    no_code_reroutes,
1217                    max_reroutes,
1218                    "blocking no-code reroute: reached max reroute attempts ({}/{}) for no-code-result",
1219                    no_code_reroutes, max_reroutes
1220                );
1221                format!(
1222                    "agent completed without code changes after {}/{} reroute attempts on external task requiring PR",
1223                    no_code_reroutes, max_reroutes
1224                )
1225            };
1226            ctx.set(&[("last_error", serde_json::json!(msg))]).await;
1227        } else {
1228            // Rerouting — no blocking error to record yet.
1229            ctx.set(&[(
1230                "last_error",
1231                serde_json::json!(
1232                    "agent completed without code changes on external task requiring PR"
1233                ),
1234            )])
1235            .await;
1236        }
1237
1238        // Always record which agent produced this no-code result so the router can
1239        // skip it on the next attempt. Also clear agent/model to force re-routing
1240        // (the router will pick a fresh agent, which will be caught by the same-agent
1241        // check on the next round if it's the same agent).
1242        ctx.set(&[
1243            ("no_code_last_agent", serde_json::json!(agent_name)),
1244            ("agent", serde_json::json!(null)),
1245            ("model", serde_json::json!(null)),
1246        ])
1247        .await;
1248    } else if resp_status == "done" && !has_pr && is_external {
1249        // External task with non-code labels (e.g. documentation, research) —
1250        // allowed to be marked done without a PR.
1251        tracing::info!(
1252            task_id = ctx.task_id,
1253            "external task with non-code labels reported done — marking done without PR"
1254        );
1255    } else if resp_status == "done" && !has_pr {
1256        tracing::info!(
1257            task_id = ctx.task_id,
1258            "internal task reported done with no PR — marking done"
1259        );
1260    }
1261}
1262
1263/// Store token usage if both input and output token counts are known.
1264async fn store_token_usage(
1265    ctx: &StoreCtx<'_>,
1266    input_tokens: Option<u64>,
1267    output_tokens: Option<u64>,
1268    model_name: Option<&str>,
1269) {
1270    let (Some(input), Some(output)) = (input_tokens, output_tokens) else {
1271        return;
1272    };
1273    let model = model_name.unwrap_or("haiku");
1274    if let Some(ref st) = ctx.store {
1275        if let Some(store_id) = ctx.store_id_opt {
1276            if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1277                tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1278            }
1279        } else if let Ok(Some(store_id)) = st.resolve_task_id(ctx.repo, ctx.task_id).await {
1280            if let Err(e) = st.store_tokens(store_id, input, output, model).await {
1281                tracing::warn!(task_id = ctx.task_id, ?e, "failed to store token usage");
1282            }
1283        }
1284    }
1285}
1286
1287/// Handle a successful agent response: commit, push, PR, delegations, tokens.
1288///
1289/// Returns `Ok((status, push_failed))` where `status` is the final task status
1290/// string and `push_failed` is `true` when a push was attempted but failed (for
1291/// audit trail classification).
1292#[allow(clippy::too_many_arguments)]
1293pub async fn handle_success(
1294    task_id: &str,
1295    parsed: agents::ParsedResponse,
1296    wt: &worktree::WorktreeSetup,
1297    task_title: &str,
1298    agent_name: &str,
1299    model_name: Option<&str>,
1300    new_attempts: u32,
1301    repo: &str,
1302    store: &Option<Arc<TaskStore>>,
1303    raw_stdout: &str,
1304) -> anyhow::Result<(String, bool)> {
1305    // Extract token counts before consuming parsed.
1306    let input_tokens = parsed.input_tokens;
1307    let output_tokens = parsed.output_tokens;
1308    let resp = parsed.response;
1309
1310    tracing::info!(
1311        task_id,
1312        status = resp.status,
1313        summary = resp.summary,
1314        "agent completed successfully"
1315    );
1316
1317    // Resolve numeric store_id once so we can reuse it for multiple store ops
1318    // in this hot path and avoid repeated external_id -> store_id SQL lookups.
1319    let store_id_opt = store::resolve_store_id(store, repo, task_id).await;
1320    let ctx = StoreCtx {
1321        store,
1322        store_id_opt,
1323        repo,
1324        task_id,
1325    };
1326
1327    ctx.set(&[("network_retries", serde_json::json!(0))]).await;
1328
1329    // Auto-commit, push, create PR
1330    let git = if resp.status == "done"
1331        || resp.status == "completed"
1332        || resp.status == "in_progress"
1333        || resp.status == "needs_review"
1334    {
1335        run_git_ops(
1336            &ctx,
1337            wt,
1338            task_title,
1339            &resp,
1340            agent_name,
1341            model_name,
1342            new_attempts,
1343        )
1344        .await
1345    } else {
1346        GitOpsResult::default()
1347    };
1348
1349    // Store delegations in store if present (processed by run_with_context)
1350    if !resp.delegations.is_empty() {
1351        ctx.set(&[("delegations", serde_json::json!(resp.delegations))])
1352            .await;
1353    }
1354
1355    // Store result in task store
1356    // If agent said "done" and a PR exists, send to review before merge.
1357    // If agent said "done", pushed commits, but PR creation failed — review gate creates PR.
1358    // If agent said "done", no PR, and no delegations — work is complete
1359    // (e.g., review/analysis jobs that create issues but no code changes).
1360    // If agent said "done", no PR, but has delegations — blocked on children.
1361    let has_delegations = !resp.delegations.is_empty();
1362
1363    // Detect push failure, increment counters.
1364    let push_state = detect_push_failure_state(&ctx, git.has_commits, git.has_pushed).await;
1365
1366    // Determine whether this external task can be marked done without a PR.
1367    // External tasks always require a PR before reaching `done` — unless
1368    // commits were successfully pushed (in which case the `has_pushed` branch
1369    // handles routing). The `is_non_code_task` heuristic was removed because
1370    // it relied on agent output and could be fooled: an agent claiming
1371    // "already implemented" or "config-only" would match non-code keywords
1372    // and close the issue without verification.
1373    let is_external = !task_id.starts_with("internal:");
1374    let requires_pr = is_external && !git.has_pr && !resp.status.starts_with("needs_review");
1375
1376    // Preferred config key `workflow.max_reroute_attempts`; fall back to
1377    // `workflow.max_attempts` for backwards compatibility.
1378    let max_reroutes: u32 = config::get("workflow.max_reroute_attempts")
1379        .ok()
1380        .and_then(|s| s.parse().ok())
1381        .or_else(|| {
1382            config::get("workflow.max_attempts")
1383                .ok()
1384                .and_then(|s| s.parse().ok())
1385        })
1386        .unwrap_or(3);
1387
1388    // Determine whether we are entering the no-code-reroute branch (all
1389    // earlier chain conditions are false + done + requires_pr).
1390    let is_no_code_reroute = !push_state.is_workflow_scope_failure
1391        && !push_state.push_failed
1392        && (resp.status == "done" || resp.status == "completed")
1393        && !has_delegations
1394        && !git.has_pushed
1395        && requires_pr;
1396
1397    let no_code_reroutes =
1398        detect_no_code_reroutes(&ctx, is_no_code_reroute, new_attempts, max_reroutes).await;
1399
1400    let blocked_reason = if resp.status == "blocked" {
1401        Some(agent_blocked_reason(&resp))
1402    } else {
1403        None
1404    };
1405    let is_retryable_blocked = blocked_reason
1406        .as_deref()
1407        .map(is_retryable_blocked_reason)
1408        .unwrap_or(false);
1409
1410    // ── Detect same-agent loop ───────────────────────────────────────────────
1411    //
1412    // If this is a no-code reroute and the agent is the same as the one that
1413    // produced the previous no-code result, flag it for blocking (#2410, #2686).
1414    let is_same_agent = if is_no_code_reroute {
1415        let prev_no_code_agent = ctx
1416            .get_task()
1417            .await
1418            .map(|t| t.no_code_last_agent.clone())
1419            .unwrap_or_default();
1420        !prev_no_code_agent.is_empty() && prev_no_code_agent == agent_name
1421    } else {
1422        false
1423    };
1424
1425    // ── Pure status decision ─────────────────────────────────────────────────
1426
1427    let final_status_owned = classify_final_status(&DecisionInput {
1428        agent_status: &resp.status,
1429        is_workflow_scope_failure: push_state.is_workflow_scope_failure,
1430        is_rebase_conflict_failure: push_state.is_rebase_conflict_failure,
1431        push_failed: push_state.push_failed,
1432        push_failures: push_state.push_failures,
1433        has_pr: git.has_pr,
1434        has_delegations,
1435        has_pushed: git.has_pushed,
1436        is_pr_base_invalid: git.pr_base_invalid,
1437        requires_pr,
1438        no_code_reroutes,
1439        max_reroutes,
1440        is_same_agent,
1441        is_completed_status: resp.status == "completed",
1442        is_retryable_blocked,
1443    });
1444    let final_status = final_status_owned.as_str();
1445
1446    // ── Post-decision side effects (tracing + store updates) ─────────────────
1447
1448    apply_post_decision_effects(
1449        &ctx,
1450        final_status,
1451        &resp.status,
1452        &push_state,
1453        is_no_code_reroute,
1454        git.has_pr,
1455        git.has_pushed,
1456        git.pr_base_invalid,
1457        has_delegations,
1458        is_external,
1459        no_code_reroutes,
1460        max_reroutes,
1461        agent_name,
1462    )
1463    .await;
1464
1465    // When the agent explicitly returned "blocked", persist its explanation into
1466    // last_error so `orch task get` surfaces the reason without requiring manual
1467    // inspection of task_runs.parsed_response.  The apply_post_decision_effects
1468    // branches above only fire on push/no-code scenarios; a raw agent-blocked
1469    // response falls through all of them and would leave last_error empty.
1470    if resp.status == "blocked" {
1471        let reason = blocked_reason.unwrap_or_else(|| agent_blocked_reason(&resp));
1472        if final_status == "new" {
1473            // Retryable external block: cooldown failed agent/model, clear selection,
1474            // and reroute after backoff instead of permanently blocking.
1475            crate::engine::cooldown::record_agent_failure_with_message(agent_name, &reason).await;
1476            if let Some(m) = model_name {
1477                crate::engine::cooldown::record_model_failure(agent_name, m).await;
1478            }
1479            ctx.set(&[
1480                (
1481                    "last_error",
1482                    serde_json::json!(format!(
1483                        "transient blocker (auto-retry scheduled): {reason}"
1484                    )),
1485                ),
1486                ("agent", serde_json::json!(null)),
1487                ("model", serde_json::json!(null)),
1488            ])
1489            .await;
1490            tracing::warn!(
1491                task_id = ctx.task_id,
1492                reason = %reason,
1493                "agent returned retryable blocked status — rerouting as new with cooldown"
1494            );
1495        } else if final_status == "blocked" {
1496            ctx.set(&[("last_error", serde_json::json!(reason))]).await;
1497        }
1498    }
1499
1500    ctx.set(&[("summary", serde_json::json!(resp.summary))])
1501        .await;
1502
1503    // Store token usage — prefer agent-parsed tokens, fall back to response
1504    store_token_usage(
1505        &ctx,
1506        input_tokens.or(resp.input_tokens),
1507        output_tokens.or(resp.output_tokens),
1508        model_name,
1509    )
1510    .await;
1511
1512    // Store learnings for memory (for future retries)
1513    response::store_learnings_from_response(
1514        task_id,
1515        new_attempts,
1516        agent_name,
1517        model_name,
1518        &resp,
1519        resp.error.as_deref(),
1520        store,
1521        repo,
1522        raw_stdout,
1523    )
1524    .await;
1525
1526    // Note: done → in_review transition is handled by the engine
1527    // after triggering the review agent (engine/mod.rs)
1528    Ok((final_status.to_string(), push_state.push_failed))
1529}
1530
1531/// Labels that indicate a task is non-code and can be marked done without a PR.
1532#[cfg(test)]
1533mod tests {
1534    use super::*;
1535
1536    #[test]
1537    fn missing_path_error_detects_io_not_found() {
1538        let err = anyhow::Error::from(std::io::Error::new(
1539            std::io::ErrorKind::NotFound,
1540            "simulated missing path",
1541        ));
1542        assert!(is_missing_path_error(&err));
1543    }
1544
1545    #[test]
1546    fn missing_path_error_detects_message_fallback() {
1547        let err = anyhow::anyhow!("push failed: No such file or directory (os error 2)");
1548        assert!(is_missing_path_error(&err));
1549    }
1550
1551    // ── classify_final_status — one test per decision branch ─────────────────
1552
1553    /// Branch 1: workflow-scope push failure → block immediately (non-retryable).
1554    #[test]
1555    fn classify_workflow_scope_failure_blocks() {
1556        let status = classify_final_status(&DecisionInput {
1557            is_workflow_scope_failure: true,
1558            push_failed: true,
1559            push_failures: 1,
1560            ..Default::default()
1561        });
1562        assert_eq!(status, "blocked");
1563    }
1564
1565    /// Branch 1b: automatic rebase recovery failed (conflicts) → block immediately.
1566    #[test]
1567    fn classify_rebase_conflict_failure_blocks() {
1568        let status = classify_final_status(&DecisionInput {
1569            is_rebase_conflict_failure: true,
1570            push_failed: true,
1571            ..Default::default()
1572        });
1573        assert_eq!(status, "blocked");
1574    }
1575
1576    /// Branch 2a: push failed, < 3 times → reroute with "new".
1577    #[test]
1578    fn classify_push_failed_under_threshold_reroutes() {
1579        let status = classify_final_status(&DecisionInput {
1580            push_failed: true,
1581            push_failures: 2, // < 3
1582            ..Default::default()
1583        });
1584        assert_eq!(status, "new");
1585    }
1586
1587    /// Branch 2b: push failed >= 3 times → block for human intervention.
1588    #[test]
1589    fn classify_push_failed_at_threshold_blocks() {
1590        let status = classify_final_status(&DecisionInput {
1591            push_failed: true,
1592            push_failures: 3, // >= 3
1593            ..Default::default()
1594        });
1595        assert_eq!(status, "blocked");
1596    }
1597
1598    /// Branch 3: done + PR exists → send to review.
1599    #[test]
1600    fn classify_done_with_pr_goes_to_needs_review() {
1601        let status = classify_final_status(&DecisionInput {
1602            agent_status: "done",
1603            has_pr: true,
1604            ..Default::default()
1605        });
1606        assert_eq!(status, "needs_review");
1607    }
1608
1609    /// Branch 4: done + delegations, no PR → block (waiting on child tasks).
1610    #[test]
1611    fn classify_done_with_delegations_no_pr_blocks() {
1612        let status = classify_final_status(&DecisionInput {
1613            agent_status: "done",
1614            has_delegations: true,
1615            ..Default::default()
1616        });
1617        assert_eq!(status, "blocked");
1618    }
1619
1620    /// Branch 5: done + commits pushed but PR creation failed → re-dispatch.
1621    #[test]
1622    fn classify_done_pushed_no_pr_reroutes_for_pr_creation() {
1623        let status = classify_final_status(&DecisionInput {
1624            agent_status: "done",
1625            has_pushed: true,
1626            ..Default::default()
1627        });
1628        assert_eq!(status, "routed");
1629    }
1630
1631    /// Branch 5b: done + pushed + PR base invalid → block (terminal error).
1632    #[test]
1633    fn classify_done_pushed_pr_base_invalid_blocks() {
1634        let status = classify_final_status(&DecisionInput {
1635            agent_status: "done",
1636            has_pushed: true,
1637            is_pr_base_invalid: true,
1638            ..Default::default()
1639        });
1640        assert_eq!(status, "blocked");
1641    }
1642
1643    /// Branch 5c: completed + pushed + PR base invalid → block (terminal error).
1644    #[test]
1645    fn classify_completed_pushed_pr_base_invalid_blocks() {
1646        let status = classify_final_status(&DecisionInput {
1647            agent_status: "completed",
1648            is_completed_status: true,
1649            has_pushed: true,
1650            is_pr_base_invalid: true,
1651            ..Default::default()
1652        });
1653        assert_eq!(status, "blocked");
1654    }
1655
1656    /// Branch 6a: done + requires_pr, under max reroutes → reroute.
1657    #[test]
1658    fn classify_done_requires_pr_under_max_reroutes() {
1659        let status = classify_final_status(&DecisionInput {
1660            agent_status: "done",
1661            requires_pr: true,
1662            no_code_reroutes: 1,
1663            max_reroutes: 3,
1664            is_same_agent: false,
1665            ..Default::default()
1666        });
1667        assert_eq!(status, "new");
1668    }
1669
1670    /// Branch 6a edge: same agent on no-code reroute → block immediately.
1671    #[test]
1672    fn classify_done_requires_pr_same_agent_blocks_immediately() {
1673        let status = classify_final_status(&DecisionInput {
1674            agent_status: "done",
1675            requires_pr: true,
1676            no_code_reroutes: 1,
1677            max_reroutes: 3,
1678            is_same_agent: true, // Same agent would be selected again
1679            ..Default::default()
1680        });
1681        assert_eq!(status, "blocked");
1682    }
1683
1684    /// Branch 6b: done + requires_pr, at max reroutes → block.
1685    #[test]
1686    fn classify_done_requires_pr_at_max_reroutes_blocks() {
1687        let status = classify_final_status(&DecisionInput {
1688            agent_status: "done",
1689            requires_pr: true,
1690            no_code_reroutes: 3,
1691            max_reroutes: 3,
1692            is_same_agent: false,
1693            ..Default::default()
1694        });
1695        assert_eq!(status, "blocked");
1696    }
1697
1698    /// Branch 6b edge: exceeding max also blocks.
1699    #[test]
1700    fn classify_done_requires_pr_exceeds_max_reroutes_blocks() {
1701        let status = classify_final_status(&DecisionInput {
1702            agent_status: "done",
1703            requires_pr: true,
1704            no_code_reroutes: 5,
1705            max_reroutes: 3,
1706            is_same_agent: false,
1707            ..Default::default()
1708        });
1709        assert_eq!(status, "blocked");
1710    }
1711
1712    /// Same agent on external task requiring PR → block immediately (not wait for max).
1713    #[test]
1714    fn classify_done_external_no_pushed_same_agent_blocks_immediately() {
1715        let status = classify_final_status(&DecisionInput {
1716            agent_status: "done",
1717            requires_pr: true, // always true for external tasks with !has_pr + done
1718            no_code_reroutes: 0,
1719            max_reroutes: 3,
1720            is_same_agent: true,
1721            ..Default::default()
1722        });
1723        assert_eq!(status, "blocked");
1724    }
1725
1726    /// External task with no pushed commits: reroute (agent must produce commits
1727    /// before the issue can be closed). This was the source of issue #1898 — an
1728    /// agent claiming "already implemented" without any code changes would
1729    /// previously match non-code keywords and close the issue falsely.
1730    ///
1731    /// External tasks with done+!has_pr+!has_pushed always have requires_pr=true
1732    /// (see handle_success), so they are caught by branch 6, not branch 7.
1733    #[test]
1734    fn classify_done_external_no_pushed_commits_reroutes() {
1735        let status = classify_final_status(&DecisionInput {
1736            agent_status: "done",
1737            has_pushed: false,
1738            requires_pr: true, // always true for external tasks with !has_pr + done
1739            no_code_reroutes: 0,
1740            max_reroutes: 3,
1741            is_same_agent: false,
1742            ..Default::default()
1743        });
1744        assert_eq!(status, "new");
1745    }
1746
1747    /// Internal task with no pushed commits may still be marked done — internal
1748    /// tasks may produce no git-visible changes.
1749    #[test]
1750    fn classify_done_internal_task_no_pr_is_done() {
1751        let status = classify_final_status(&DecisionInput {
1752            agent_status: "done",
1753            has_pushed: false,
1754            requires_pr: false,
1755            ..Default::default()
1756        });
1757        assert_eq!(status, "done");
1758    }
1759
1760    /// Pass-through: non-"done" status is returned unchanged.
1761    #[test]
1762    fn classify_non_done_status_passes_through() {
1763        for status_str in &["in_progress", "needs_review", "blocked"] {
1764            let result = classify_final_status(&DecisionInput {
1765                agent_status: status_str,
1766                ..Default::default()
1767            });
1768            assert_eq!(
1769                result, *status_str,
1770                "status '{status_str}' should pass through unchanged"
1771            );
1772        }
1773    }
1774
1775    /// Descriptive completion text should normalize to done.
1776    #[test]
1777    fn classify_descriptive_completion_status_normalizes_to_done() {
1778        let status = classify_final_status(&DecisionInput {
1779            agent_status: "Trading scan complete. Nothing to trade.",
1780            ..Default::default()
1781        });
1782        assert_eq!(status, "done");
1783    }
1784
1785    /// Do not normalize to done when completion-like text includes failure cues.
1786    #[test]
1787    fn classify_descriptive_completion_with_failure_cues_does_not_normalize() {
1788        let status = classify_final_status(&DecisionInput {
1789            agent_status: "completed but failed to push branch due to error",
1790            ..Default::default()
1791        });
1792        assert_eq!(status, "completed but failed to push branch due to error");
1793    }
1794
1795    /// push_failed takes precedence over done+has_pr (push failure detected first).
1796    #[test]
1797    fn classify_push_failed_takes_precedence_over_has_pr() {
1798        let status = classify_final_status(&DecisionInput {
1799            agent_status: "done",
1800            has_pr: true,
1801            push_failed: true,
1802            push_failures: 1,
1803            ..Default::default()
1804        });
1805        // push_failed branch runs before done+has_pr check
1806        assert_eq!(status, "new");
1807    }
1808
1809    /// workflow_scope_failure takes precedence over push_failed < 3.
1810    #[test]
1811    fn classify_workflow_scope_failure_takes_precedence_over_push_reroute() {
1812        let status = classify_final_status(&DecisionInput {
1813            is_workflow_scope_failure: true,
1814            push_failed: true,
1815            push_failures: 1, // would normally reroute
1816            ..Default::default()
1817        });
1818        assert_eq!(status, "blocked");
1819    }
1820
1821    // ── "completed" status tests ────────────────────────────────────────────────
1822
1823    /// "completed" + has_pr → needs_review.
1824    #[test]
1825    fn classify_completed_with_pr_goes_to_needs_review() {
1826        let status = classify_final_status(&DecisionInput {
1827            agent_status: "completed",
1828            has_pr: true,
1829            is_completed_status: true,
1830            ..Default::default()
1831        });
1832        assert_eq!(status, "needs_review");
1833    }
1834
1835    /// "completed" + delegations, no PR → blocked.
1836    #[test]
1837    fn classify_completed_with_delegations_no_pr_blocks() {
1838        let status = classify_final_status(&DecisionInput {
1839            agent_status: "completed",
1840            has_delegations: true,
1841            is_completed_status: true,
1842            ..Default::default()
1843        });
1844        assert_eq!(status, "blocked");
1845    }
1846
1847    /// "completed" + pushed, no PR → routed for PR creation.
1848    #[test]
1849    fn classify_completed_pushed_no_pr_reroutes_for_pr_creation() {
1850        let status = classify_final_status(&DecisionInput {
1851            agent_status: "completed",
1852            has_pushed: true,
1853            is_completed_status: true,
1854            ..Default::default()
1855        });
1856        assert_eq!(status, "routed");
1857    }
1858
1859    /// "completed" + external task requiring PR, under max reroutes → reroute.
1860    #[test]
1861    fn classify_completed_requires_pr_under_max_reroutes() {
1862        let status = classify_final_status(&DecisionInput {
1863            agent_status: "completed",
1864            requires_pr: true,
1865            no_code_reroutes: 1,
1866            max_reroutes: 3,
1867            is_completed_status: true,
1868            is_same_agent: false,
1869            ..Default::default()
1870        });
1871        assert_eq!(status, "new");
1872    }
1873
1874    /// "completed" + external task, at max reroutes → block.
1875    #[test]
1876    fn classify_completed_requires_pr_at_max_reroutes_blocks() {
1877        let status = classify_final_status(&DecisionInput {
1878            agent_status: "completed",
1879            requires_pr: true,
1880            no_code_reroutes: 3,
1881            max_reroutes: 3,
1882            is_completed_status: true,
1883            is_same_agent: false,
1884            ..Default::default()
1885        });
1886        assert_eq!(status, "blocked");
1887    }
1888
1889    /// "completed" + external task, same agent on no-code reroute → block immediately.
1890    #[test]
1891    fn classify_completed_same_agent_blocks_immediately() {
1892        let status = classify_final_status(&DecisionInput {
1893            agent_status: "completed",
1894            requires_pr: true,
1895            no_code_reroutes: 1,
1896            max_reroutes: 3,
1897            is_completed_status: true,
1898            is_same_agent: true,
1899            ..Default::default()
1900        });
1901        assert_eq!(status, "blocked");
1902    }
1903
1904    /// "completed" on internal task (no requires_pr) → mark done.
1905    #[test]
1906    fn classify_completed_internal_task_is_done() {
1907        let status = classify_final_status(&DecisionInput {
1908            agent_status: "completed",
1909            requires_pr: false,
1910            is_completed_status: true,
1911            ..Default::default()
1912        });
1913        assert_eq!(status, "done");
1914    }
1915
1916    // ── agent_blocked_reason — regression tests for issue #3012 ──────────────
1917
1918    /// Agent explicitly returned blocked with an error field — use error as reason.
1919    #[test]
1920    fn agent_blocked_reason_prefers_error_field() {
1921        let resp = crate::parser::AgentResponse {
1922            status: "blocked".to_string(),
1923            error: Some("CDP endpoint not reachable".to_string()),
1924            summary: "Could not complete research".to_string(),
1925            remaining: vec!["Run x-twitter-brave with reachable endpoint".to_string()],
1926            ..Default::default()
1927        };
1928        let reason = agent_blocked_reason(&resp);
1929        assert!(
1930            reason.contains("CDP endpoint not reachable"),
1931            "reason: {reason}"
1932        );
1933        assert!(reason.contains("Remaining:"), "reason: {reason}");
1934        assert!(reason.contains("x-twitter-brave"), "reason: {reason}");
1935    }
1936
1937    /// No error field — falls back to summary.
1938    #[test]
1939    fn agent_blocked_reason_falls_back_to_summary() {
1940        let resp = crate::parser::AgentResponse {
1941            status: "blocked".to_string(),
1942            error: None,
1943            summary: "Could not access required data sources in sandboxed environment".to_string(),
1944            remaining: vec![],
1945            ..Default::default()
1946        };
1947        let reason = agent_blocked_reason(&resp);
1948        assert_eq!(
1949            reason,
1950            "Could not access required data sources in sandboxed environment"
1951        );
1952    }
1953
1954    /// No error or summary — returns generic fallback.
1955    #[test]
1956    fn agent_blocked_reason_generic_fallback() {
1957        let resp = crate::parser::AgentResponse {
1958            status: "blocked".to_string(),
1959            ..Default::default()
1960        };
1961        let reason = agent_blocked_reason(&resp);
1962        assert_eq!(reason, "agent returned blocked status without a reason");
1963    }
1964
1965    /// Agent-returned "blocked" status passes through classify_final_status unchanged.
1966    #[test]
1967    fn classify_agent_blocked_passes_through() {
1968        let status = classify_final_status(&DecisionInput {
1969            agent_status: "blocked",
1970            ..Default::default()
1971        });
1972        assert_eq!(status, "blocked");
1973    }
1974
1975    #[test]
1976    fn classify_retryable_agent_blocked_reroutes() {
1977        let status = classify_final_status(&DecisionInput {
1978            agent_status: "blocked",
1979            is_retryable_blocked: true,
1980            ..Default::default()
1981        });
1982        assert_eq!(status, "new");
1983    }
1984
1985    #[test]
1986    fn retryable_blocked_reason_detects_transient_patterns() {
1987        assert!(is_retryable_blocked_reason(
1988            "Twitter access unavailable: service timed out"
1989        ));
1990        assert!(is_retryable_blocked_reason(
1991            "final commit blocked by git worktree lock permission error"
1992        ));
1993        assert!(is_retryable_blocked_reason(
1994            "could not commit due a worktree git lock permission error"
1995        ));
1996        assert!(is_retryable_blocked_reason(
1997            "git lockfile permission restrictions in this environment"
1998        ));
1999        assert!(is_retryable_blocked_reason(
2000            "API rate limit hit, try again later"
2001        ));
2002        assert!(is_retryable_blocked_reason(
2003            "Credential bean/hyperliquid-address exists in the pass store but GPG decryption fails in this agent session — no passphrase available."
2004        ));
2005        assert!(is_retryable_blocked_reason(
2006            "credential resolution failed via PasswordStoreProvider; AgentVaultProvider unavailable"
2007        ));
2008    }
2009
2010    #[test]
2011    fn retryable_blocked_reason_rejects_permanent_patterns() {
2012        assert!(!is_retryable_blocked_reason(
2013            "invalid request payload: malformed JSON"
2014        ));
2015        assert!(!is_retryable_blocked_reason(
2016            "resource not found in repository"
2017        ));
2018    }
2019}